huskies: merge 949
This commit is contained in:
@@ -8,7 +8,7 @@ max_tool_turns = 80
|
||||
max_budget_usd = 5.00
|
||||
disallowed_tools = ["ScheduleWakeup"]
|
||||
prompt ="You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks server-side until tests finish (up to 20 minutes) and returns the full result. Do NOT call get_test_result — run_tests already gives you the pass/fail outcome.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. To read story content, ACs, or description, call the `get_story_todos` MCP tool — do NOT search for a story `.md` file on disk; story content is CRDT-only. Do NOT run run_tests at the start of a new session on a freshly-forked worktree — master is gated and assumed green. Only run run_tests after you have made changes, to validate your own diff. Always run run_tests before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. When splitting `path/X.rs` into `path/X/mod.rs` + submodules, you MUST `git rm path/X.rs` in the SAME commit — leaving both files produces a `duplicate module file` cargo error (E0761) that breaks the build. Each new file you create as part of a decompose (e.g. the new `mod.rs`, `tests.rs`, and any submodule .rs files) MUST start with a `//!` doc comment describing what that module is for. The doc-coverage gate WILL block your merge if you skip this on any new file. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. To read story content, ACs, or description, call the `get_story_todos` MCP tool — do NOT search for a story `.md` file on disk; story content is CRDT-only. Do NOT run run_tests at the start of a new session on a freshly-forked worktree — master is gated and assumed green. Only run run_tests after you have made changes, to validate your own diff. Always run run_tests before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing."
|
||||
|
||||
[[agent]]
|
||||
name = "coder-2"
|
||||
@@ -20,7 +20,7 @@ max_tool_turns = 80
|
||||
max_budget_usd = 5.00
|
||||
disallowed_tools = ["ScheduleWakeup"]
|
||||
prompt ="You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks server-side until tests finish (up to 20 minutes) and returns the full result. Do NOT call get_test_result — run_tests already gives you the pass/fail outcome.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. To read story content, ACs, or description, call the `get_story_todos` MCP tool — do NOT search for a story `.md` file on disk; story content is CRDT-only. Do NOT run run_tests at the start of a new session on a freshly-forked worktree — master is gated and assumed green. Only run run_tests after you have made changes, to validate your own diff. Always run run_tests before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. When splitting `path/X.rs` into `path/X/mod.rs` + submodules, you MUST `git rm path/X.rs` in the SAME commit — leaving both files produces a `duplicate module file` cargo error (E0761) that breaks the build. Each new file you create as part of a decompose (e.g. the new `mod.rs`, `tests.rs`, and any submodule .rs files) MUST start with a `//!` doc comment describing what that module is for. The doc-coverage gate WILL block your merge if you skip this on any new file. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. To read story content, ACs, or description, call the `get_story_todos` MCP tool — do NOT search for a story `.md` file on disk; story content is CRDT-only. Do NOT run run_tests at the start of a new session on a freshly-forked worktree — master is gated and assumed green. Only run run_tests after you have made changes, to validate your own diff. Always run run_tests before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing."
|
||||
|
||||
[[agent]]
|
||||
name = "coder-3"
|
||||
@@ -32,7 +32,7 @@ max_tool_turns = 80
|
||||
max_budget_usd = 5.00
|
||||
disallowed_tools = ["ScheduleWakeup"]
|
||||
prompt ="You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks server-side until tests finish (up to 20 minutes) and returns the full result. Do NOT call get_test_result — run_tests already gives you the pass/fail outcome.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. To read story content, ACs, or description, call the `get_story_todos` MCP tool — do NOT search for a story `.md` file on disk; story content is CRDT-only. Do NOT run run_tests at the start of a new session on a freshly-forked worktree — master is gated and assumed green. Only run run_tests after you have made changes, to validate your own diff. Always run run_tests before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. When splitting `path/X.rs` into `path/X/mod.rs` + submodules, you MUST `git rm path/X.rs` in the SAME commit — leaving both files produces a `duplicate module file` cargo error (E0761) that breaks the build. Each new file you create as part of a decompose (e.g. the new `mod.rs`, `tests.rs`, and any submodule .rs files) MUST start with a `//!` doc comment describing what that module is for. The doc-coverage gate WILL block your merge if you skip this on any new file. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. To read story content, ACs, or description, call the `get_story_todos` MCP tool — do NOT search for a story `.md` file on disk; story content is CRDT-only. Do NOT run run_tests at the start of a new session on a freshly-forked worktree — master is gated and assumed green. Only run run_tests after you have made changes, to validate your own diff. Always run run_tests before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing."
|
||||
|
||||
[[agent]]
|
||||
name = "qa-2"
|
||||
@@ -136,7 +136,7 @@ max_tool_turns = 80
|
||||
max_budget_usd = 20.00
|
||||
disallowed_tools = ["ScheduleWakeup"]
|
||||
prompt ="You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks server-side until tests finish (up to 20 minutes) and returns the full result. Do NOT call get_test_result — run_tests already gives you the pass/fail outcome.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
|
||||
system_prompt = "You are a senior full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. To read story content, ACs, or description, call the `get_story_todos` MCP tool — do NOT search for a story `.md` file on disk; story content is CRDT-only. You handle complex tasks requiring deep architectural understanding. Do NOT run run_tests at the start of a new session on a freshly-forked worktree — master is gated and assumed green. Only run run_tests after you have made changes, to validate your own diff. Always run run_tests before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. When splitting `path/X.rs` into `path/X/mod.rs` + submodules, you MUST `git rm path/X.rs` in the SAME commit — leaving both files produces a `duplicate module file` cargo error (E0761) that breaks the build. Each new file you create as part of a decompose (e.g. the new `mod.rs`, `tests.rs`, and any submodule .rs files) MUST start with a `//!` doc comment describing what that module is for. The doc-coverage gate WILL block your merge if you skip this on any new file. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing."
|
||||
system_prompt = "You are a senior full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. To read story content, ACs, or description, call the `get_story_todos` MCP tool — do NOT search for a story `.md` file on disk; story content is CRDT-only. You handle complex tasks requiring deep architectural understanding. Do NOT run run_tests at the start of a new session on a freshly-forked worktree — master is gated and assumed green. Only run run_tests after you have made changes, to validate your own diff. Always run run_tests before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing."
|
||||
|
||||
[[agent]]
|
||||
name = "qa"
|
||||
|
||||
@@ -32,16 +32,34 @@ pub struct CheckFailure {
|
||||
}
|
||||
|
||||
impl CheckFailure {
|
||||
/// Returns a human-readable direction a coding agent can act on directly.
|
||||
/// Returns a human-readable direction a coding agent can act on directly,
|
||||
/// including a language-appropriate syntax example so the fix is in the error.
|
||||
pub fn to_direction(&self) -> String {
|
||||
format!(
|
||||
"{}:{}: add a doc comment to {} `{}`",
|
||||
"{}:{}: add a doc comment to {} `{}`. Example: {}",
|
||||
self.file_path.display(),
|
||||
self.line,
|
||||
self.item_kind,
|
||||
self.item_name
|
||||
self.item_name,
|
||||
self.example_syntax(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Concrete doc-comment syntax appropriate for this file's language.
|
||||
fn example_syntax(&self) -> &'static str {
|
||||
let ext = self
|
||||
.file_path
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("");
|
||||
let is_module_or_file = matches!(self.item_kind.as_str(), "module" | "file");
|
||||
match ext {
|
||||
"rs" if is_module_or_file => "`//! Brief description.` at the top of the file",
|
||||
"rs" => "`/// Brief description.` above the declaration",
|
||||
"ts" | "tsx" => "`/** Brief description. */` above the declaration",
|
||||
_ => "(see project conventions for this file type)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a documentation coverage check.
|
||||
|
||||
@@ -2,47 +2,17 @@
|
||||
* WS-RPC client for chat-bot transport config (Matrix / Slack / WhatsApp).
|
||||
*/
|
||||
import { rpcCall } from "./rpc";
|
||||
import type { BotConfigPayload } from "./rpcContract";
|
||||
|
||||
export interface BotConfig {
|
||||
transport: string | null;
|
||||
enabled: boolean | null;
|
||||
homeserver: string | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
room_ids: string[] | null;
|
||||
slack_bot_token: string | null;
|
||||
slack_signing_secret: string | null;
|
||||
slack_channel_ids: string[] | null;
|
||||
}
|
||||
|
||||
const DEFAULT_API_BASE = "/api";
|
||||
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
baseUrl = DEFAULT_API_BASE,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
headers: { "Content-Type": "application/json", ...(options.headers ?? {}) },
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Request failed (${res.status})`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
/** Re-export of the wire-format `BotConfigPayload` as the client-facing `BotConfig` alias. */
|
||||
export type BotConfig = BotConfigPayload;
|
||||
|
||||
export const botConfigApi = {
|
||||
getConfig(_baseUrl?: string): Promise<BotConfig> {
|
||||
return rpcCall<BotConfig>("bot_config.get");
|
||||
},
|
||||
|
||||
saveConfig(config: BotConfig, baseUrl?: string): Promise<BotConfig> {
|
||||
return requestJson<BotConfig>(
|
||||
"/bot/config",
|
||||
{ method: "PUT", body: JSON.stringify(config) },
|
||||
baseUrl,
|
||||
);
|
||||
saveConfig(config: BotConfig, _baseUrl?: string): Promise<BotConfig> {
|
||||
return rpcCall<BotConfigPayload>("bot_config.save", config);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -45,31 +45,88 @@ describe("api client", () => {
|
||||
});
|
||||
|
||||
describe("openProject", () => {
|
||||
it("sends POST with path", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse("/home/user/project"));
|
||||
it("dispatches project.open RPC with path and returns the canonical path", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("project.open", { path: "/home/user/project" });
|
||||
|
||||
await api.openProject("/home/user/project");
|
||||
const result = await api.openProject("/home/user/project");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/project",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path: "/home/user/project" }),
|
||||
}),
|
||||
);
|
||||
expect(rpc.calls).toEqual([
|
||||
{
|
||||
method: "project.open",
|
||||
params: { path: "/home/user/project" },
|
||||
},
|
||||
]);
|
||||
expect(result).toBe("/home/user/project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeProject", () => {
|
||||
it("sends DELETE to /project", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(true));
|
||||
it("dispatches project.close RPC and returns ok", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("project.close", { ok: true });
|
||||
|
||||
await api.closeProject();
|
||||
const result = await api.closeProject();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/project",
|
||||
expect.objectContaining({ method: "DELETE" }),
|
||||
);
|
||||
expect(rpc.calls).toEqual([{ method: "project.close", params: {} }]);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("forgetKnownProject", () => {
|
||||
it("dispatches project.forget RPC with path", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("project.forget", { ok: true });
|
||||
|
||||
const result = await api.forgetKnownProject("/some/path");
|
||||
|
||||
expect(rpc.calls).toEqual([
|
||||
{ method: "project.forget", params: { path: "/some/path" } },
|
||||
]);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setModelPreference", () => {
|
||||
it("dispatches model.set_preference RPC", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("model.set_preference", { ok: true });
|
||||
|
||||
await api.setModelPreference("claude-sonnet-4-6");
|
||||
|
||||
expect(rpc.calls).toEqual([
|
||||
{
|
||||
method: "model.set_preference",
|
||||
params: { model: "claude-sonnet-4-6" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setAnthropicApiKey", () => {
|
||||
it("dispatches anthropic.set_api_key RPC", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("anthropic.set_api_key", { ok: true });
|
||||
|
||||
await api.setAnthropicApiKey("sk-ant-xxx");
|
||||
|
||||
expect(rpc.calls).toEqual([
|
||||
{
|
||||
method: "anthropic.set_api_key",
|
||||
params: { api_key: "sk-ant-xxx" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelChat", () => {
|
||||
it("dispatches chat.cancel RPC", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("chat.cancel", { ok: true });
|
||||
|
||||
await api.cancelChat();
|
||||
|
||||
expect(rpc.calls).toEqual([{ method: "chat.cancel", params: {} }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,10 +149,19 @@ describe("api client", () => {
|
||||
await expect(api.getCurrentProject()).rejects.toThrow("store offline");
|
||||
});
|
||||
|
||||
it("surfaces RPC errors visibly for write methods", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respondError("project.open", "No such directory", "INTERNAL");
|
||||
|
||||
await expect(api.openProject("/some/path")).rejects.toThrow(
|
||||
"No such directory",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on non-ok HTTP response for legacy POST endpoints", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
|
||||
|
||||
await expect(api.openProject("/some/path")).rejects.toThrow(
|
||||
await expect(api.searchFiles("query")).rejects.toThrow(
|
||||
"Request failed (500)",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
*/
|
||||
|
||||
import { rpcCall } from "../rpc";
|
||||
import type {
|
||||
OkResult,
|
||||
OpenProjectResult,
|
||||
SetAnthropicApiKeyParams,
|
||||
SetModelPreferenceParams,
|
||||
} from "../rpcContract";
|
||||
import type {
|
||||
AllTokenUsageResponse,
|
||||
AnthropicModelInfo,
|
||||
@@ -94,32 +100,25 @@ export const api = {
|
||||
getKnownProjects(_baseUrl?: string) {
|
||||
return rpcCall<string[]>("project.known");
|
||||
},
|
||||
forgetKnownProject(path: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/projects/forget",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
async forgetKnownProject(path: string, _baseUrl?: string) {
|
||||
const r = await rpcCall<OkResult>("project.forget", { path });
|
||||
return r.ok;
|
||||
},
|
||||
openProject(path: string, baseUrl?: string) {
|
||||
return requestJson<string>(
|
||||
"/project",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
async openProject(path: string, _baseUrl?: string) {
|
||||
const r = await rpcCall<OpenProjectResult>("project.open", { path });
|
||||
return r.path;
|
||||
},
|
||||
closeProject(baseUrl?: string) {
|
||||
return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl);
|
||||
async closeProject(_baseUrl?: string) {
|
||||
const r = await rpcCall<OkResult>("project.close");
|
||||
return r.ok;
|
||||
},
|
||||
getModelPreference(_baseUrl?: string) {
|
||||
return rpcCall<string | null>("model.get_preference");
|
||||
},
|
||||
setModelPreference(model: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/model",
|
||||
{ method: "POST", body: JSON.stringify({ model }) },
|
||||
baseUrl,
|
||||
);
|
||||
async setModelPreference(model: string, _baseUrl?: string) {
|
||||
const params: SetModelPreferenceParams = { model };
|
||||
const r = await rpcCall<OkResult>("model.set_preference", params);
|
||||
return r.ok;
|
||||
},
|
||||
getOllamaModels(baseUrlParam?: string, _baseUrl?: string) {
|
||||
return rpcCall<string[]>(
|
||||
@@ -133,12 +132,10 @@ export const api = {
|
||||
getAnthropicModels(_baseUrl?: string) {
|
||||
return rpcCall<AnthropicModelInfo[]>("anthropic.list_models");
|
||||
},
|
||||
setAnthropicApiKey(api_key: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/anthropic/key",
|
||||
{ method: "POST", body: JSON.stringify({ api_key }) },
|
||||
baseUrl,
|
||||
);
|
||||
async setAnthropicApiKey(api_key: string, _baseUrl?: string) {
|
||||
const params: SetAnthropicApiKeyParams = { api_key };
|
||||
const r = await rpcCall<OkResult>("anthropic.set_api_key", params);
|
||||
return r.ok;
|
||||
},
|
||||
readFile(path: string, baseUrl?: string) {
|
||||
return requestJson<string>(
|
||||
@@ -195,8 +192,9 @@ export const api = {
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
cancelChat(baseUrl?: string) {
|
||||
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl);
|
||||
async cancelChat(_baseUrl?: string) {
|
||||
const r = await rpcCall<OkResult>("chat.cancel");
|
||||
return r.ok;
|
||||
},
|
||||
getWorkItemContent(storyId: string, _baseUrl?: string) {
|
||||
return rpcCall<WorkItemContent>("work_items.get", { story_id: storyId });
|
||||
|
||||
+62
-28
@@ -56,7 +56,7 @@ const RETRY_DELAY_MS = 250;
|
||||
*/
|
||||
function rpcAttempt<T>(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
params: object,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
@@ -102,34 +102,68 @@ function rpcAttempt<T>(
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
let data: unknown;
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (
|
||||
data.kind === "rpc_response" &&
|
||||
data.correlation_id === correlationId
|
||||
) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (data.ok) {
|
||||
resolve(data.result as T);
|
||||
} else {
|
||||
reject(
|
||||
new RpcError(
|
||||
data.error || `RPC error: ${data.code || "UNKNOWN"}`,
|
||||
data.code,
|
||||
method,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Ignore other frames (pipeline_state, onboarding_status, etc.)
|
||||
data = JSON.parse(event.data);
|
||||
} catch {
|
||||
/* ignore non-JSON / malformed frames */
|
||||
// Non-JSON frame is not ours — keep waiting.
|
||||
return;
|
||||
}
|
||||
if (!data || typeof data !== "object") {
|
||||
return;
|
||||
}
|
||||
const frame = data as {
|
||||
kind?: unknown;
|
||||
correlation_id?: unknown;
|
||||
ok?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
code?: unknown;
|
||||
};
|
||||
if (frame.kind !== "rpc_response" || frame.correlation_id !== correlationId) {
|
||||
// Not addressed to this call — ignore (pipeline_state, etc.).
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (typeof frame.ok !== "boolean") {
|
||||
reject(
|
||||
new RpcError(
|
||||
`Malformed RPC response for ${method}: missing or non-boolean 'ok' field`,
|
||||
"MALFORMED",
|
||||
method,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (frame.ok) {
|
||||
if (!("result" in frame)) {
|
||||
reject(
|
||||
new RpcError(
|
||||
`Malformed RPC response for ${method}: 'ok:true' frame missing 'result' field`,
|
||||
"MALFORMED",
|
||||
method,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve(frame.result as T);
|
||||
} else {
|
||||
const errMsg =
|
||||
typeof frame.error === "string" ? frame.error : undefined;
|
||||
const errCode = typeof frame.code === "string" ? frame.code : undefined;
|
||||
reject(
|
||||
new RpcError(
|
||||
errMsg || `RPC error: ${errCode || "UNKNOWN"}`,
|
||||
errCode,
|
||||
method,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -183,7 +217,7 @@ function sleep(ms: number): Promise<void> {
|
||||
*/
|
||||
export async function rpcCall<T = unknown>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
params: object = {},
|
||||
timeoutMs = 5000,
|
||||
): Promise<T> {
|
||||
let lastErr: unknown;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"model.set_preference": {
|
||||
"params": {
|
||||
"model": "claude-sonnet-4-6"
|
||||
},
|
||||
"result": {
|
||||
"ok": true
|
||||
}
|
||||
},
|
||||
"anthropic.set_api_key": {
|
||||
"params": {
|
||||
"api_key": "sk-ant-..."
|
||||
},
|
||||
"result": {
|
||||
"ok": true
|
||||
}
|
||||
},
|
||||
"settings.put_editor": {
|
||||
"params": {
|
||||
"editor_command": "zed"
|
||||
},
|
||||
"result": {
|
||||
"editor_command": "zed"
|
||||
}
|
||||
},
|
||||
"settings.open_file": {
|
||||
"params": {
|
||||
"path": "src/main.rs",
|
||||
"line": 42
|
||||
},
|
||||
"result": {
|
||||
"ok": true
|
||||
}
|
||||
},
|
||||
"settings.put_project": {
|
||||
"params": {
|
||||
"default_qa": "server",
|
||||
"default_coder_model": null,
|
||||
"max_coders": null,
|
||||
"max_retries": 2,
|
||||
"base_branch": null,
|
||||
"rate_limit_notifications": true,
|
||||
"timezone": null,
|
||||
"rendezvous": null,
|
||||
"watcher_sweep_interval_secs": 60,
|
||||
"watcher_done_retention_secs": 86400
|
||||
},
|
||||
"result": {
|
||||
"default_qa": "server",
|
||||
"default_coder_model": null,
|
||||
"max_coders": null,
|
||||
"max_retries": 2,
|
||||
"base_branch": null,
|
||||
"rate_limit_notifications": true,
|
||||
"timezone": null,
|
||||
"rendezvous": null,
|
||||
"watcher_sweep_interval_secs": 60,
|
||||
"watcher_done_retention_secs": 86400
|
||||
}
|
||||
},
|
||||
"project.open": {
|
||||
"params": {
|
||||
"path": "/path/to/project"
|
||||
},
|
||||
"result": {
|
||||
"path": "/path/to/project"
|
||||
}
|
||||
},
|
||||
"project.close": {
|
||||
"params": {},
|
||||
"result": {
|
||||
"ok": true
|
||||
}
|
||||
},
|
||||
"project.forget": {
|
||||
"params": {
|
||||
"path": "/path/to/project"
|
||||
},
|
||||
"result": {
|
||||
"ok": true
|
||||
}
|
||||
},
|
||||
"bot_config.save": {
|
||||
"params": {
|
||||
"transport": "matrix",
|
||||
"enabled": true,
|
||||
"homeserver": "https://matrix.example",
|
||||
"username": "bot",
|
||||
"password": "secret",
|
||||
"room_ids": [
|
||||
"!room:example"
|
||||
],
|
||||
"slack_bot_token": null,
|
||||
"slack_signing_secret": null,
|
||||
"slack_channel_ids": null
|
||||
},
|
||||
"result": {
|
||||
"transport": "matrix",
|
||||
"enabled": true,
|
||||
"homeserver": "https://matrix.example",
|
||||
"username": "bot",
|
||||
"password": "secret",
|
||||
"room_ids": [
|
||||
"!room:example"
|
||||
],
|
||||
"slack_bot_token": null,
|
||||
"slack_signing_secret": null,
|
||||
"slack_channel_ids": null
|
||||
}
|
||||
},
|
||||
"chat.cancel": {
|
||||
"params": {},
|
||||
"result": {
|
||||
"ok": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Snapshot test: the frontend `CONTRACT_FIXTURES` table must match the
|
||||
* Rust-side snapshot. When the Rust contract changes, the snapshot file
|
||||
* regenerates (via `UPDATE_RPC_CONTRACT_SNAPSHOT=1 cargo test`) and this
|
||||
* test catches any TS shapes that have drifted.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CONTRACT_FIXTURES } from "./rpcContract";
|
||||
import snapshot from "./rpcContract.snapshot.json";
|
||||
|
||||
describe("rpcContract", () => {
|
||||
it("CONTRACT_FIXTURES matches the Rust-generated snapshot", () => {
|
||||
// Convert TS fixtures into the same shape the Rust snapshot serialises
|
||||
// to: a method-keyed object of `{ params, result }`.
|
||||
const fromTs = Object.fromEntries(
|
||||
Object.entries(CONTRACT_FIXTURES).map(([method, payloads]) => [
|
||||
method,
|
||||
{ params: payloads.params, result: payloads.result },
|
||||
]),
|
||||
);
|
||||
expect(fromTs).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("declares the same method names as the snapshot", () => {
|
||||
const tsMethods = Object.keys(CONTRACT_FIXTURES).sort();
|
||||
const rustMethods = Object.keys(snapshot).sort();
|
||||
expect(tsMethods).toEqual(rustMethods);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Frontend mirror of the Rust typed RPC contract in
|
||||
* `server/src/crdt_sync/rpc_contract.rs`.
|
||||
*
|
||||
* Every typed write method declared on the backend has matching TypeScript
|
||||
* params/result types here. The `CONTRACT_FIXTURES` table also exposes the
|
||||
* same canonical example payloads as the Rust `CONTRACT_METHODS` slice — the
|
||||
* `rpcContract.test.ts` test compares them against the committed
|
||||
* `rpcContract.snapshot.json` that the Rust test regenerates. If the Rust
|
||||
* shapes drift from the TS shapes, the snapshot drifts and one side fails in
|
||||
* CI — surfacing the mismatch as a compile / test error instead of a runtime
|
||||
* one.
|
||||
*
|
||||
* When adding a method on the backend:
|
||||
* 1. Add the params + result type here.
|
||||
* 2. Add the entry to `CONTRACT_FIXTURES` with a canonical example.
|
||||
* 3. Re-run `UPDATE_RPC_CONTRACT_SNAPSHOT=1 cargo test` to refresh
|
||||
* `rpcContract.snapshot.json`.
|
||||
*/
|
||||
|
||||
// ── Params types ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Params for `model.set_preference`. */
|
||||
export interface SetModelPreferenceParams {
|
||||
model: string;
|
||||
}
|
||||
|
||||
/** Params for `anthropic.set_api_key`. */
|
||||
export interface SetAnthropicApiKeyParams {
|
||||
api_key: string;
|
||||
}
|
||||
|
||||
/** Params for `settings.put_editor`. */
|
||||
export interface PutEditorParams {
|
||||
editor_command: string | null;
|
||||
}
|
||||
|
||||
/** Params for `settings.open_file`. */
|
||||
export interface OpenFileParams {
|
||||
path: string;
|
||||
line: number | null;
|
||||
}
|
||||
|
||||
/** Params for `project.open`. */
|
||||
export interface OpenProjectParams {
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Params for `project.forget`. */
|
||||
export interface ForgetProjectParams {
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Payload for `bot_config.save` (and result of `bot_config.get`). */
|
||||
export interface BotConfigPayload {
|
||||
transport: string | null;
|
||||
enabled: boolean | null;
|
||||
homeserver: string | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
room_ids: string[] | null;
|
||||
slack_bot_token: string | null;
|
||||
slack_signing_secret: string | null;
|
||||
slack_channel_ids: string[] | null;
|
||||
}
|
||||
|
||||
/** Payload for `settings.put_project` (also returned by `settings.get_project`). */
|
||||
export interface ProjectSettingsPayload {
|
||||
default_qa: string;
|
||||
default_coder_model: string | null;
|
||||
max_coders: number | null;
|
||||
max_retries: number;
|
||||
base_branch: string | null;
|
||||
rate_limit_notifications: boolean;
|
||||
timezone: string | null;
|
||||
rendezvous: string | null;
|
||||
watcher_sweep_interval_secs: number;
|
||||
watcher_done_retention_secs: number;
|
||||
}
|
||||
|
||||
// ── Result types ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Result envelope for write methods that simply succeed or fail. */
|
||||
export interface OkResult {
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
/** Result for `settings.put_editor`. */
|
||||
export interface EditorSettingsResult {
|
||||
editor_command: string | null;
|
||||
}
|
||||
|
||||
/** Result for `project.open`. */
|
||||
export interface OpenProjectResult {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// ── Method → params/result mapping ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compile-time mapping from typed RPC method name to its params + result
|
||||
* shapes. Used by `callTypedRpc` to enforce that callers pass the right
|
||||
* params and receive the right return type for a method.
|
||||
*/
|
||||
export interface TypedRpcMethods {
|
||||
"model.set_preference": {
|
||||
params: SetModelPreferenceParams;
|
||||
result: OkResult;
|
||||
};
|
||||
"anthropic.set_api_key": {
|
||||
params: SetAnthropicApiKeyParams;
|
||||
result: OkResult;
|
||||
};
|
||||
"settings.put_editor": {
|
||||
params: PutEditorParams;
|
||||
result: EditorSettingsResult;
|
||||
};
|
||||
"settings.open_file": {
|
||||
params: OpenFileParams;
|
||||
result: OkResult;
|
||||
};
|
||||
"settings.put_project": {
|
||||
params: ProjectSettingsPayload;
|
||||
result: ProjectSettingsPayload;
|
||||
};
|
||||
"project.open": {
|
||||
params: OpenProjectParams;
|
||||
result: OpenProjectResult;
|
||||
};
|
||||
"project.close": {
|
||||
params: Record<string, never>;
|
||||
result: OkResult;
|
||||
};
|
||||
"project.forget": {
|
||||
params: ForgetProjectParams;
|
||||
result: OkResult;
|
||||
};
|
||||
"bot_config.save": {
|
||||
params: BotConfigPayload;
|
||||
result: BotConfigPayload;
|
||||
};
|
||||
"chat.cancel": {
|
||||
params: Record<string, never>;
|
||||
result: OkResult;
|
||||
};
|
||||
}
|
||||
|
||||
/** Union of all typed RPC method names declared in the contract. */
|
||||
export type TypedRpcMethodName = keyof TypedRpcMethods;
|
||||
|
||||
// ── Canonical fixtures (mirror of Rust `CONTRACT_METHODS`) ──────────────────
|
||||
|
||||
/**
|
||||
* One canonical example payload per typed RPC method. The shape *must*
|
||||
* match the corresponding Rust `CONTRACT_METHODS` entry. Drift between this
|
||||
* table and `rpcContract.snapshot.json` (regenerated by the Rust side) fails
|
||||
* the `rpcContract.test.ts` snapshot check.
|
||||
*/
|
||||
export const CONTRACT_FIXTURES: {
|
||||
[K in TypedRpcMethodName]: {
|
||||
params: TypedRpcMethods[K]["params"];
|
||||
result: TypedRpcMethods[K]["result"];
|
||||
};
|
||||
} = {
|
||||
"model.set_preference": {
|
||||
params: { model: "claude-sonnet-4-6" },
|
||||
result: { ok: true },
|
||||
},
|
||||
"anthropic.set_api_key": {
|
||||
params: { api_key: "sk-ant-..." },
|
||||
result: { ok: true },
|
||||
},
|
||||
"settings.put_editor": {
|
||||
params: { editor_command: "zed" },
|
||||
result: { editor_command: "zed" },
|
||||
},
|
||||
"settings.open_file": {
|
||||
params: { path: "src/main.rs", line: 42 },
|
||||
result: { ok: true },
|
||||
},
|
||||
"settings.put_project": {
|
||||
params: {
|
||||
default_qa: "server",
|
||||
default_coder_model: null,
|
||||
max_coders: null,
|
||||
max_retries: 2,
|
||||
base_branch: null,
|
||||
rate_limit_notifications: true,
|
||||
timezone: null,
|
||||
rendezvous: null,
|
||||
watcher_sweep_interval_secs: 60,
|
||||
watcher_done_retention_secs: 86_400,
|
||||
},
|
||||
result: {
|
||||
default_qa: "server",
|
||||
default_coder_model: null,
|
||||
max_coders: null,
|
||||
max_retries: 2,
|
||||
base_branch: null,
|
||||
rate_limit_notifications: true,
|
||||
timezone: null,
|
||||
rendezvous: null,
|
||||
watcher_sweep_interval_secs: 60,
|
||||
watcher_done_retention_secs: 86_400,
|
||||
},
|
||||
},
|
||||
"project.open": {
|
||||
params: { path: "/path/to/project" },
|
||||
result: { path: "/path/to/project" },
|
||||
},
|
||||
"project.close": {
|
||||
params: {},
|
||||
result: { ok: true },
|
||||
},
|
||||
"project.forget": {
|
||||
params: { path: "/path/to/project" },
|
||||
result: { ok: true },
|
||||
},
|
||||
"bot_config.save": {
|
||||
params: {
|
||||
transport: "matrix",
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example",
|
||||
username: "bot",
|
||||
password: "secret",
|
||||
room_ids: ["!room:example"],
|
||||
slack_bot_token: null,
|
||||
slack_signing_secret: null,
|
||||
slack_channel_ids: null,
|
||||
},
|
||||
result: {
|
||||
transport: "matrix",
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example",
|
||||
username: "bot",
|
||||
password: "secret",
|
||||
room_ids: ["!room:example"],
|
||||
slack_bot_token: null,
|
||||
slack_signing_secret: null,
|
||||
slack_channel_ids: null,
|
||||
},
|
||||
},
|
||||
"chat.cancel": {
|
||||
params: {},
|
||||
result: { ok: true },
|
||||
},
|
||||
};
|
||||
@@ -1,29 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
/** Tests for the `settings` WS-RPC client (project settings read/write). */
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProjectSettings } from "./settings";
|
||||
import { settingsApi } from "./settings";
|
||||
import { installRpcMock } from "./__test_utils__/mockRpcWebSocket";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function okResponse(body: unknown) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function errorResponse(status: number, text: string) {
|
||||
return new Response(text, { status });
|
||||
}
|
||||
|
||||
const defaultProjectSettings: ProjectSettings = {
|
||||
default_qa: "server",
|
||||
default_coder_model: null,
|
||||
@@ -62,25 +46,25 @@ describe("settingsApi", () => {
|
||||
});
|
||||
|
||||
describe("putProjectSettings", () => {
|
||||
it("sends PUT to /settings with settings body", async () => {
|
||||
it("dispatches settings.put_project RPC with settings", async () => {
|
||||
const updated = { ...defaultProjectSettings, default_qa: "agent" };
|
||||
mockFetch.mockResolvedValueOnce(okResponse(updated));
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("settings.put_project", updated);
|
||||
|
||||
const result = await settingsApi.putProjectSettings(updated);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/settings",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
body: JSON.stringify(updated),
|
||||
}),
|
||||
);
|
||||
expect(rpc.calls).toEqual([
|
||||
{ method: "settings.put_project", params: updated },
|
||||
]);
|
||||
expect(result.default_qa).toBe("agent");
|
||||
});
|
||||
|
||||
it("throws on validation error", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
errorResponse(400, "Invalid default_qa value"),
|
||||
it("throws on validation error from RPC", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respondError(
|
||||
"settings.put_project",
|
||||
"Invalid default_qa value",
|
||||
"INVALID",
|
||||
);
|
||||
await expect(
|
||||
settingsApi.putProjectSettings({
|
||||
@@ -115,47 +99,65 @@ describe("settingsApi", () => {
|
||||
});
|
||||
|
||||
describe("setEditorCommand", () => {
|
||||
it("sends PUT to /settings/editor with command body", async () => {
|
||||
const expected = { editor_command: "zed" };
|
||||
mockFetch.mockResolvedValueOnce(okResponse(expected));
|
||||
it("dispatches settings.put_editor RPC with command", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("settings.put_editor", { editor_command: "zed" });
|
||||
|
||||
const result = await settingsApi.setEditorCommand("zed");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/settings/editor",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ editor_command: "zed" }),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
expect(rpc.calls).toEqual([
|
||||
{
|
||||
method: "settings.put_editor",
|
||||
params: { editor_command: "zed" },
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual({ editor_command: "zed" });
|
||||
});
|
||||
|
||||
it("sends PUT with null to clear the editor command", async () => {
|
||||
const expected = { editor_command: null };
|
||||
mockFetch.mockResolvedValueOnce(okResponse(expected));
|
||||
it("dispatches settings.put_editor with null to clear", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("settings.put_editor", { editor_command: null });
|
||||
|
||||
const result = await settingsApi.setEditorCommand(null);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/settings/editor",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ editor_command: null }),
|
||||
}),
|
||||
);
|
||||
expect(rpc.calls).toEqual([
|
||||
{
|
||||
method: "settings.put_editor",
|
||||
params: { editor_command: null },
|
||||
},
|
||||
]);
|
||||
expect(result.editor_command).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse({ editor_command: "vim" }));
|
||||
describe("openFile", () => {
|
||||
it("dispatches settings.open_file RPC with path and line", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("settings.open_file", { ok: true });
|
||||
|
||||
await settingsApi.setEditorCommand("vim", "http://localhost:4000/api");
|
||||
const result = await settingsApi.openFile("src/main.rs", 42);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:4000/api/settings/editor",
|
||||
expect.objectContaining({ method: "PUT" }),
|
||||
);
|
||||
expect(rpc.calls).toEqual([
|
||||
{
|
||||
method: "settings.open_file",
|
||||
params: { path: "src/main.rs", line: 42 },
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("dispatches settings.open_file with null line when omitted", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("settings.open_file", { ok: true });
|
||||
|
||||
await settingsApi.openFile("src/main.rs");
|
||||
|
||||
expect(rpc.calls).toEqual([
|
||||
{
|
||||
method: "settings.open_file",
|
||||
params: { path: "src/main.rs", line: null },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,8 +171,9 @@ describe("settingsApi", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on setEditorCommand error", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(403, "Forbidden"));
|
||||
it("surfaces RPC errors for setEditorCommand", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respondError("settings.put_editor", "Forbidden", "FORBIDDEN");
|
||||
|
||||
await expect(settingsApi.setEditorCommand("code")).rejects.toThrow(
|
||||
"Forbidden",
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
* WS-RPC client for editor and project settings.
|
||||
*/
|
||||
import { rpcCall } from "./rpc";
|
||||
import type {
|
||||
EditorSettingsResult,
|
||||
OkResult,
|
||||
OpenFileParams,
|
||||
ProjectSettingsPayload,
|
||||
PutEditorParams,
|
||||
} from "./rpcContract";
|
||||
|
||||
export interface EditorSettings {
|
||||
editor_command: string | null;
|
||||
@@ -24,80 +31,39 @@ export interface OpenFileResult {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_API_BASE = "/api";
|
||||
|
||||
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
baseUrl = DEFAULT_API_BASE,
|
||||
): Promise<T> {
|
||||
const res = await fetch(buildApiUrl(path, baseUrl), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
getProjectSettings(_baseUrl?: string): Promise<ProjectSettings> {
|
||||
return rpcCall<ProjectSettings>("settings.get_project");
|
||||
},
|
||||
|
||||
putProjectSettings(
|
||||
async putProjectSettings(
|
||||
settings: ProjectSettings,
|
||||
baseUrl?: string,
|
||||
_baseUrl?: string,
|
||||
): Promise<ProjectSettings> {
|
||||
return requestJson<ProjectSettings>(
|
||||
"/settings",
|
||||
{ method: "PUT", body: JSON.stringify(settings) },
|
||||
baseUrl,
|
||||
);
|
||||
const params: ProjectSettingsPayload = settings;
|
||||
return rpcCall<ProjectSettingsPayload>("settings.put_project", params);
|
||||
},
|
||||
|
||||
getEditorCommand(_baseUrl?: string): Promise<EditorSettings> {
|
||||
return rpcCall<EditorSettings>("settings.get_editor");
|
||||
},
|
||||
|
||||
setEditorCommand(
|
||||
async setEditorCommand(
|
||||
command: string | null,
|
||||
baseUrl?: string,
|
||||
_baseUrl?: string,
|
||||
): Promise<EditorSettings> {
|
||||
return requestJson<EditorSettings>(
|
||||
"/settings/editor",
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ editor_command: command }),
|
||||
},
|
||||
baseUrl,
|
||||
);
|
||||
const params: PutEditorParams = { editor_command: command };
|
||||
const r = await rpcCall<EditorSettingsResult>("settings.put_editor", params);
|
||||
return { editor_command: r.editor_command };
|
||||
},
|
||||
|
||||
openFile(
|
||||
async openFile(
|
||||
path: string,
|
||||
line?: number,
|
||||
baseUrl?: string,
|
||||
_baseUrl?: string,
|
||||
): Promise<OpenFileResult> {
|
||||
const params = new URLSearchParams({ path });
|
||||
if (line !== undefined) {
|
||||
params.set("line", String(line));
|
||||
}
|
||||
return requestJson<OpenFileResult>(
|
||||
`/settings/open-file?${params.toString()}`,
|
||||
{ method: "POST" },
|
||||
baseUrl,
|
||||
);
|
||||
const params: OpenFileParams = { path, line: line ?? null };
|
||||
const r = await rpcCall<OkResult>("settings.open_file", params);
|
||||
return { success: r.ok };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Frontend seam test: drive a real React component against a fixture derived
|
||||
* from the actual RPC response (the canonical `CONTRACT_FIXTURES` shared with
|
||||
* the Rust side via the snapshot file).
|
||||
*
|
||||
* The first test renders `SettingsPage` against the well-formed fixture and
|
||||
* asserts the form populates with values from the RPC response — proving the
|
||||
* backend ↔ frontend wire shape lines up end-to-end without hand-rolled
|
||||
* fixtures.
|
||||
*
|
||||
* The second test feeds a *malformed* RPC response (a frame missing the
|
||||
* required envelope `ok` field) and asserts the `rpc.ts` client surfaces a
|
||||
* visible error in the rendered UI instead of leaving the page empty.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { SettingsPage } from "./SettingsPage";
|
||||
import { CONTRACT_FIXTURES } from "../api/rpcContract";
|
||||
import snapshot from "../api/rpcContract.snapshot.json";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
interface MockSocket {
|
||||
url: string;
|
||||
onopen: ((ev: Event) => void) | null;
|
||||
onmessage: ((ev: { data: string }) => void) | null;
|
||||
onerror: ((ev: Event) => void) | null;
|
||||
onclose: ((ev: CloseEvent) => void) | null;
|
||||
readyState: number;
|
||||
send(data: string): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a `WebSocket` shim that hands each registered method a single
|
||||
* canned frame. Callers register either a normal RPC result or a
|
||||
* deliberately malformed frame body (returned verbatim — i.e. the body
|
||||
* literally has no `ok` field, simulating a server bug).
|
||||
*/
|
||||
function installSeamWs(replies: {
|
||||
[method: string]: { kind: "ok"; result: unknown } | { kind: "raw"; body: object };
|
||||
}) {
|
||||
const instances: MockSocket[] = [];
|
||||
class SeamWs implements MockSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
url: string;
|
||||
onopen: ((ev: Event) => void) | null = null;
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onerror: ((ev: Event) => void) | null = null;
|
||||
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||
readyState = 0;
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
instances.push(this);
|
||||
queueMicrotask(() => {
|
||||
this.readyState = 1;
|
||||
this.onopen?.(new Event("open"));
|
||||
});
|
||||
}
|
||||
send(data: string) {
|
||||
let frame: {
|
||||
correlation_id?: string;
|
||||
method?: string;
|
||||
};
|
||||
try {
|
||||
frame = JSON.parse(data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const { correlation_id, method } = frame;
|
||||
if (!correlation_id || !method) return;
|
||||
queueMicrotask(() => {
|
||||
const reply = replies[method];
|
||||
if (!reply) {
|
||||
this.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
kind: "rpc_response",
|
||||
version: 1,
|
||||
correlation_id,
|
||||
ok: false,
|
||||
error: `no fixture for ${method}`,
|
||||
code: "NOT_FOUND",
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (reply.kind === "ok") {
|
||||
this.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
kind: "rpc_response",
|
||||
version: 1,
|
||||
correlation_id,
|
||||
ok: true,
|
||||
result: reply.result,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// raw: deliberately malformed envelope (no `ok` field)
|
||||
this.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
kind: "rpc_response",
|
||||
version: 1,
|
||||
correlation_id,
|
||||
...reply.body,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("WebSocket", SeamWs);
|
||||
return instances;
|
||||
}
|
||||
|
||||
describe("SettingsPage seam test", () => {
|
||||
it("renders ProjectSettings from the typed RPC contract fixture", async () => {
|
||||
// Sanity: the in-source fixture mirrors the on-disk snapshot file. If
|
||||
// this trips, the contract has drifted from the Rust side.
|
||||
expect(CONTRACT_FIXTURES["settings.put_project"].result).toEqual(
|
||||
snapshot["settings.put_project"].result,
|
||||
);
|
||||
|
||||
const fixture = CONTRACT_FIXTURES["settings.put_project"].result;
|
||||
installSeamWs({
|
||||
"settings.get_project": { kind: "ok", result: fixture },
|
||||
});
|
||||
|
||||
const onBack = vi.fn();
|
||||
render(<SettingsPage onBack={onBack} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue(String(fixture.max_retries))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Field driven directly by the RPC payload populates the form.
|
||||
expect(
|
||||
screen.getByDisplayValue(String(fixture.watcher_sweep_interval_secs)),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByDisplayValue(String(fixture.watcher_done_retention_secs)),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a visible error when the RPC response is malformed", async () => {
|
||||
// `body` lacks the envelope `ok` field. The fixed `rpc.ts` client
|
||||
// should reject loudly with a `MALFORMED` error instead of letting
|
||||
// the page render empty.
|
||||
installSeamWs({
|
||||
"settings.get_project": {
|
||||
kind: "raw",
|
||||
body: { result: { not_actually_settings: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const onBack = vi.fn();
|
||||
render(<SettingsPage onBack={onBack} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Malformed RPC response/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// And critically — no empty form is rendered.
|
||||
expect(screen.queryByText(/default qa/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("user can edit and the new value flows through settings.put_project RPC", async () => {
|
||||
const fixture = CONTRACT_FIXTURES["settings.put_project"].result;
|
||||
const updated = { ...fixture, max_retries: 9 };
|
||||
installSeamWs({
|
||||
"settings.get_project": { kind: "ok", result: fixture },
|
||||
"settings.put_project": { kind: "ok", result: updated },
|
||||
});
|
||||
|
||||
const onBack = vi.fn();
|
||||
render(<SettingsPage onBack={onBack} />);
|
||||
|
||||
const maxRetriesInput = (await screen.findByDisplayValue(
|
||||
String(fixture.max_retries),
|
||||
)) as HTMLInputElement;
|
||||
|
||||
fireEvent.change(maxRetriesInput, { target: { value: "9" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("9")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,7 @@ mod client;
|
||||
mod dispatch;
|
||||
mod handshake;
|
||||
mod rpc;
|
||||
mod rpc_contract;
|
||||
mod server;
|
||||
mod wire;
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ use std::sync::{Arc, OnceLock};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::rpc_contract::{
|
||||
BotConfigPayload, EditorSettingsResult, ForgetProjectParams, OkResult, OpenFileParams,
|
||||
OpenProjectParams, OpenProjectResult, ProjectSettingsPayload, PutEditorParams,
|
||||
SetAnthropicApiKeyParams, SetModelPreferenceParams,
|
||||
};
|
||||
use super::wire::RpcFrame;
|
||||
use crate::state::SessionState;
|
||||
use crate::store::JsonFileStore;
|
||||
@@ -119,8 +124,50 @@ static HANDLERS: &[(&str, Handler)] = &[
|
||||
("agents.get_output", |p| {
|
||||
Box::pin(handle_agents_get_output(p))
|
||||
}),
|
||||
// ── typed write methods ──────────────────────────────────────────────
|
||||
("model.set_preference", |p| {
|
||||
Box::pin(handle_model_set_preference(p))
|
||||
}),
|
||||
("anthropic.set_api_key", |p| {
|
||||
Box::pin(handle_anthropic_set_api_key(p))
|
||||
}),
|
||||
("settings.put_editor", |p| {
|
||||
Box::pin(handle_settings_put_editor(p))
|
||||
}),
|
||||
("settings.open_file", |p| {
|
||||
Box::pin(handle_settings_open_file(p))
|
||||
}),
|
||||
("settings.put_project", |p| {
|
||||
Box::pin(handle_settings_put_project(p))
|
||||
}),
|
||||
("project.open", |p| Box::pin(handle_project_open(p))),
|
||||
("project.close", |p| Box::pin(handle_project_close(p))),
|
||||
("project.forget", |p| Box::pin(handle_project_forget(p))),
|
||||
("bot_config.save", |p| Box::pin(handle_bot_config_save(p))),
|
||||
("chat.cancel", |p| Box::pin(handle_chat_cancel(p))),
|
||||
];
|
||||
|
||||
// ── typed-write helper macros ───────────────────────────────────────────────
|
||||
|
||||
/// Parse the incoming JSON params into a typed struct or return a typed error
|
||||
/// response shaped as `{"error": "..."}`.
|
||||
macro_rules! parse_params {
|
||||
($params:expr, $ty:ty) => {
|
||||
match serde_json::from_value::<$ty>($params) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return serde_json::json!({"error": format!("invalid params: {e}")}),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn ok_json() -> Value {
|
||||
serde_json::to_value(OkResult { ok: true }).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn err_json(msg: impl Into<String>) -> Value {
|
||||
serde_json::json!({"error": msg.into()})
|
||||
}
|
||||
|
||||
// ── handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Handler for the `health.check` method.
|
||||
@@ -532,6 +579,205 @@ async fn handle_agents_get_output(params: Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
// ── typed write handlers ────────────────────────────────────────────────────
|
||||
|
||||
/// Handler for `model.set_preference`. Persists the user's chosen LLM model.
|
||||
async fn handle_model_set_preference(params: Value) -> Value {
|
||||
let typed = parse_params!(params, SetModelPreferenceParams);
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
match crate::io::fs::set_model_preference(typed.model, ctx.store.as_ref()) {
|
||||
Ok(()) => ok_json(),
|
||||
Err(e) => err_json(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `anthropic.set_api_key`. Stores an Anthropic API key.
|
||||
async fn handle_anthropic_set_api_key(params: Value) -> Value {
|
||||
let typed = parse_params!(params, SetAnthropicApiKeyParams);
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
match crate::service::anthropic::set_api_key(ctx.store.as_ref(), typed.api_key) {
|
||||
Ok(()) => ok_json(),
|
||||
Err(e) => err_json(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `settings.put_editor`. Updates the configured editor command.
|
||||
async fn handle_settings_put_editor(params: Value) -> Value {
|
||||
use crate::store::StoreOps;
|
||||
let typed = parse_params!(params, PutEditorParams);
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
let trimmed = typed
|
||||
.editor_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
match trimmed {
|
||||
Some(cmd) => {
|
||||
ctx.store.set(
|
||||
crate::service::settings::EDITOR_COMMAND_KEY,
|
||||
serde_json::json!(cmd.clone()),
|
||||
);
|
||||
if let Err(e) = ctx.store.save() {
|
||||
return err_json(e);
|
||||
}
|
||||
serde_json::to_value(EditorSettingsResult {
|
||||
editor_command: Some(cmd),
|
||||
})
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
None => {
|
||||
ctx.store
|
||||
.delete(crate::service::settings::EDITOR_COMMAND_KEY);
|
||||
if let Err(e) = ctx.store.save() {
|
||||
return err_json(e);
|
||||
}
|
||||
serde_json::to_value(EditorSettingsResult {
|
||||
editor_command: None,
|
||||
})
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `settings.open_file`. Spawns the configured editor.
|
||||
async fn handle_settings_open_file(params: Value) -> Value {
|
||||
let typed = parse_params!(params, OpenFileParams);
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
match crate::service::settings::open_file_in_editor(ctx.store.as_ref(), &typed.path, typed.line)
|
||||
{
|
||||
Ok(()) => ok_json(),
|
||||
Err(e) => err_json(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `settings.put_project`. Persists project.toml scalar settings.
|
||||
async fn handle_settings_put_project(params: Value) -> Value {
|
||||
let typed = parse_params!(params, ProjectSettingsPayload);
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
let Ok(root) = ctx.state.get_project_root() else {
|
||||
return err_json("No project open");
|
||||
};
|
||||
let domain = crate::service::settings::ProjectSettings {
|
||||
default_qa: typed.default_qa,
|
||||
default_coder_model: typed.default_coder_model,
|
||||
max_coders: typed.max_coders,
|
||||
max_retries: typed.max_retries,
|
||||
base_branch: typed.base_branch,
|
||||
rate_limit_notifications: typed.rate_limit_notifications,
|
||||
timezone: typed.timezone,
|
||||
rendezvous: typed.rendezvous,
|
||||
watcher_sweep_interval_secs: typed.watcher_sweep_interval_secs,
|
||||
watcher_done_retention_secs: typed.watcher_done_retention_secs,
|
||||
};
|
||||
if let Err(e) = crate::service::settings::validate_project_settings(&domain) {
|
||||
return err_json(e.to_string());
|
||||
}
|
||||
if let Err(e) = crate::service::settings::write_project_settings(&root, &domain) {
|
||||
return err_json(e.to_string());
|
||||
}
|
||||
match crate::service::settings::load_project_settings(&root) {
|
||||
Ok(s) => serde_json::to_value(ProjectSettingsPayload {
|
||||
default_qa: s.default_qa,
|
||||
default_coder_model: s.default_coder_model,
|
||||
max_coders: s.max_coders,
|
||||
max_retries: s.max_retries,
|
||||
base_branch: s.base_branch,
|
||||
rate_limit_notifications: s.rate_limit_notifications,
|
||||
timezone: s.timezone,
|
||||
rendezvous: s.rendezvous,
|
||||
watcher_sweep_interval_secs: s.watcher_sweep_interval_secs,
|
||||
watcher_done_retention_secs: s.watcher_done_retention_secs,
|
||||
})
|
||||
.unwrap_or(Value::Null),
|
||||
Err(e) => err_json(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `project.open`. Opens a project on disk and updates session state.
|
||||
async fn handle_project_open(params: Value) -> Value {
|
||||
let typed = parse_params!(params, OpenProjectParams);
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
let port = crate::http::resolve_port();
|
||||
match crate::service::project::open_project(typed.path, &ctx.state, ctx.store.as_ref(), port)
|
||||
.await
|
||||
{
|
||||
Ok(path) => serde_json::to_value(OpenProjectResult { path }).unwrap_or(Value::Null),
|
||||
Err(e) => err_json(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `project.close`. Closes the currently open project.
|
||||
async fn handle_project_close(_params: Value) -> Value {
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
match crate::service::project::close_project(&ctx.state, ctx.store.as_ref()) {
|
||||
Ok(()) => ok_json(),
|
||||
Err(e) => err_json(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `project.forget`. Removes a project from the known list.
|
||||
async fn handle_project_forget(params: Value) -> Value {
|
||||
let typed = parse_params!(params, ForgetProjectParams);
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
match crate::service::project::forget_known_project(typed.path, ctx.store.as_ref()) {
|
||||
Ok(()) => ok_json(),
|
||||
Err(e) => err_json(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `bot_config.save`. Writes `.huskies/bot.toml`.
|
||||
async fn handle_bot_config_save(params: Value) -> Value {
|
||||
let typed = parse_params!(params, BotConfigPayload);
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
let Ok(root) = ctx.state.get_project_root() else {
|
||||
return err_json("No project open");
|
||||
};
|
||||
let bot_dir = root.join(".huskies");
|
||||
if let Err(e) = std::fs::create_dir_all(&bot_dir) {
|
||||
return err_json(format!("create .huskies dir: {e}"));
|
||||
}
|
||||
let bot_path = bot_dir.join("bot.toml");
|
||||
match toml::to_string_pretty(&typed) {
|
||||
Ok(s) => {
|
||||
if let Err(e) = std::fs::write(&bot_path, s) {
|
||||
return err_json(format!("write bot.toml: {e}"));
|
||||
}
|
||||
}
|
||||
Err(e) => return err_json(format!("serialise bot.toml: {e}")),
|
||||
}
|
||||
serde_json::to_value(typed).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// Handler for `chat.cancel`. Cancels the in-flight LLM chat stream.
|
||||
async fn handle_chat_cancel(_params: Value) -> Value {
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
match crate::llm::chat::cancel_chat(&ctx.state) {
|
||||
Ok(()) => ok_json(),
|
||||
Err(e) => err_json(e),
|
||||
}
|
||||
}
|
||||
|
||||
// ── dispatch ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Dispatch an incoming RPC method call to the registered handler.
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
//! Typed contract for `/crdt-sync` WS-RPC methods.
|
||||
//!
|
||||
//! Each RPC method has a typed params struct and a typed result struct.
|
||||
//! Handlers deserialize the incoming params into the typed struct, do their
|
||||
//! work, and serialise a typed result struct back. Frontend code mirrors
|
||||
//! these definitions in `frontend/src/api/rpcContract.ts`; the
|
||||
//! `rpc_contract_snapshot` test in this module emits a canonical JSON snapshot
|
||||
//! of every method's params + result. If the Rust and TS shapes drift, the
|
||||
//! snapshot drifts too and the test fails — surfacing the mismatch in CI.
|
||||
//!
|
||||
//! When adding a method:
|
||||
//! 1. Add typed `*_params` and `*_result` structs here.
|
||||
//! 2. Add it to the [`CONTRACT_METHODS`] slice with a fixture for params + result.
|
||||
//! 3. Mirror the structs and the method name in
|
||||
//! `frontend/src/api/rpcContract.ts`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ── Params types ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Params for `model.set_preference`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SetModelPreferenceParams {
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
/// Params for `anthropic.set_api_key`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SetAnthropicApiKeyParams {
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
/// Params for `settings.put_editor`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PutEditorParams {
|
||||
pub editor_command: Option<String>,
|
||||
}
|
||||
|
||||
/// Params for `settings.open_file`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct OpenFileParams {
|
||||
pub path: String,
|
||||
pub line: Option<u32>,
|
||||
}
|
||||
|
||||
/// Params for `project.open`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct OpenProjectParams {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Params for `project.forget`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ForgetProjectParams {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Params for `bot_config.save`. Shape mirrors the response from `bot_config.get`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BotConfigPayload {
|
||||
pub transport: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub homeserver: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub room_ids: Option<Vec<String>>,
|
||||
pub slack_bot_token: Option<String>,
|
||||
pub slack_signing_secret: Option<String>,
|
||||
pub slack_channel_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Params (and result) for `settings.put_project`. Mirrors the
|
||||
/// `settings.get_project` shape.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ProjectSettingsPayload {
|
||||
pub default_qa: String,
|
||||
pub default_coder_model: Option<String>,
|
||||
pub max_coders: Option<u32>,
|
||||
pub max_retries: u32,
|
||||
pub base_branch: Option<String>,
|
||||
pub rate_limit_notifications: bool,
|
||||
pub timezone: Option<String>,
|
||||
pub rendezvous: Option<String>,
|
||||
pub watcher_sweep_interval_secs: u64,
|
||||
pub watcher_done_retention_secs: u64,
|
||||
}
|
||||
|
||||
// ── Result types ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Result envelope for write methods that simply succeed or fail.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct OkResult {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
/// Result for `settings.put_editor`. Mirrors `settings.get_editor` envelope.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct EditorSettingsResult {
|
||||
pub editor_command: Option<String>,
|
||||
}
|
||||
|
||||
/// Result for `project.open` — returns the canonical path string.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct OpenProjectResult {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
// ── Contract metadata ────────────────────────────────────────────────────────
|
||||
|
||||
/// One entry per registered RPC method, used by the drift-snapshot test.
|
||||
#[cfg(test)]
|
||||
pub struct ContractEntry {
|
||||
pub method: &'static str,
|
||||
pub params_example: fn() -> serde_json::Value,
|
||||
pub result_example: fn() -> serde_json::Value,
|
||||
}
|
||||
|
||||
/// Canonical fixtures for every typed write method. The TS-side
|
||||
/// `rpcContract.ts` file MUST expose the same shapes for every entry.
|
||||
#[cfg(test)]
|
||||
pub static CONTRACT_METHODS: &[ContractEntry] = &[
|
||||
ContractEntry {
|
||||
method: "model.set_preference",
|
||||
params_example: || {
|
||||
serde_json::to_value(SetModelPreferenceParams {
|
||||
model: "claude-sonnet-4-6".to_string(),
|
||||
})
|
||||
.unwrap()
|
||||
},
|
||||
result_example: || serde_json::to_value(OkResult { ok: true }).unwrap(),
|
||||
},
|
||||
ContractEntry {
|
||||
method: "anthropic.set_api_key",
|
||||
params_example: || {
|
||||
serde_json::to_value(SetAnthropicApiKeyParams {
|
||||
api_key: "sk-ant-...".to_string(),
|
||||
})
|
||||
.unwrap()
|
||||
},
|
||||
result_example: || serde_json::to_value(OkResult { ok: true }).unwrap(),
|
||||
},
|
||||
ContractEntry {
|
||||
method: "settings.put_editor",
|
||||
params_example: || {
|
||||
serde_json::to_value(PutEditorParams {
|
||||
editor_command: Some("zed".to_string()),
|
||||
})
|
||||
.unwrap()
|
||||
},
|
||||
result_example: || {
|
||||
serde_json::to_value(EditorSettingsResult {
|
||||
editor_command: Some("zed".to_string()),
|
||||
})
|
||||
.unwrap()
|
||||
},
|
||||
},
|
||||
ContractEntry {
|
||||
method: "settings.open_file",
|
||||
params_example: || {
|
||||
serde_json::to_value(OpenFileParams {
|
||||
path: "src/main.rs".to_string(),
|
||||
line: Some(42),
|
||||
})
|
||||
.unwrap()
|
||||
},
|
||||
result_example: || serde_json::to_value(OkResult { ok: true }).unwrap(),
|
||||
},
|
||||
ContractEntry {
|
||||
method: "settings.put_project",
|
||||
params_example: || {
|
||||
serde_json::to_value(ProjectSettingsPayload {
|
||||
default_qa: "server".to_string(),
|
||||
default_coder_model: None,
|
||||
max_coders: None,
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
rendezvous: None,
|
||||
watcher_sweep_interval_secs: 60,
|
||||
watcher_done_retention_secs: 86_400,
|
||||
})
|
||||
.unwrap()
|
||||
},
|
||||
result_example: || {
|
||||
serde_json::to_value(ProjectSettingsPayload {
|
||||
default_qa: "server".to_string(),
|
||||
default_coder_model: None,
|
||||
max_coders: None,
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
rendezvous: None,
|
||||
watcher_sweep_interval_secs: 60,
|
||||
watcher_done_retention_secs: 86_400,
|
||||
})
|
||||
.unwrap()
|
||||
},
|
||||
},
|
||||
ContractEntry {
|
||||
method: "project.open",
|
||||
params_example: || {
|
||||
serde_json::to_value(OpenProjectParams {
|
||||
path: "/path/to/project".to_string(),
|
||||
})
|
||||
.unwrap()
|
||||
},
|
||||
result_example: || {
|
||||
serde_json::to_value(OpenProjectResult {
|
||||
path: "/path/to/project".to_string(),
|
||||
})
|
||||
.unwrap()
|
||||
},
|
||||
},
|
||||
ContractEntry {
|
||||
method: "project.close",
|
||||
params_example: || serde_json::json!({}),
|
||||
result_example: || serde_json::to_value(OkResult { ok: true }).unwrap(),
|
||||
},
|
||||
ContractEntry {
|
||||
method: "project.forget",
|
||||
params_example: || {
|
||||
serde_json::to_value(ForgetProjectParams {
|
||||
path: "/path/to/project".to_string(),
|
||||
})
|
||||
.unwrap()
|
||||
},
|
||||
result_example: || serde_json::to_value(OkResult { ok: true }).unwrap(),
|
||||
},
|
||||
ContractEntry {
|
||||
method: "bot_config.save",
|
||||
params_example: || {
|
||||
serde_json::to_value(BotConfigPayload {
|
||||
transport: Some("matrix".to_string()),
|
||||
enabled: Some(true),
|
||||
homeserver: Some("https://matrix.example".to_string()),
|
||||
username: Some("bot".to_string()),
|
||||
password: Some("secret".to_string()),
|
||||
room_ids: Some(vec!["!room:example".to_string()]),
|
||||
slack_bot_token: None,
|
||||
slack_signing_secret: None,
|
||||
slack_channel_ids: None,
|
||||
})
|
||||
.unwrap()
|
||||
},
|
||||
result_example: || {
|
||||
serde_json::to_value(BotConfigPayload {
|
||||
transport: Some("matrix".to_string()),
|
||||
enabled: Some(true),
|
||||
homeserver: Some("https://matrix.example".to_string()),
|
||||
username: Some("bot".to_string()),
|
||||
password: Some("secret".to_string()),
|
||||
room_ids: Some(vec!["!room:example".to_string()]),
|
||||
slack_bot_token: None,
|
||||
slack_signing_secret: None,
|
||||
slack_channel_ids: None,
|
||||
})
|
||||
.unwrap()
|
||||
},
|
||||
},
|
||||
ContractEntry {
|
||||
method: "chat.cancel",
|
||||
params_example: || serde_json::json!({}),
|
||||
result_example: || serde_json::to_value(OkResult { ok: true }).unwrap(),
|
||||
},
|
||||
];
|
||||
|
||||
/// Build the canonical snapshot of the typed RPC contract.
|
||||
///
|
||||
/// The snapshot is a JSON object keyed by method name; each value contains
|
||||
/// `params` and `result` example payloads. The frontend imports this same
|
||||
/// snapshot via `frontend/src/api/rpcContract.snapshot.json`. When the
|
||||
/// frontend types drift from the Rust types, the snapshots disagree and
|
||||
/// `rpc_contract_snapshot_matches_committed_file` fails.
|
||||
#[cfg(test)]
|
||||
pub fn build_contract_snapshot() -> serde_json::Value {
|
||||
let mut obj = serde_json::Map::new();
|
||||
for entry in CONTRACT_METHODS {
|
||||
let mut method_obj = serde_json::Map::new();
|
||||
method_obj.insert("params".to_string(), (entry.params_example)());
|
||||
method_obj.insert("result".to_string(), (entry.result_example)());
|
||||
obj.insert(
|
||||
entry.method.to_string(),
|
||||
serde_json::Value::Object(method_obj),
|
||||
);
|
||||
}
|
||||
serde_json::Value::Object(obj)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn snapshot_path() -> PathBuf {
|
||||
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
manifest
|
||||
.parent()
|
||||
.expect("workspace root")
|
||||
.join("frontend")
|
||||
.join("src")
|
||||
.join("api")
|
||||
.join("rpcContract.snapshot.json")
|
||||
}
|
||||
|
||||
/// Guards that the Rust-side typed contract matches the committed
|
||||
/// frontend snapshot. The snapshot file is consumed by the TS contract,
|
||||
/// so any drift between Rust types and TS types fails this test.
|
||||
///
|
||||
/// To update: run with `UPDATE_RPC_CONTRACT_SNAPSHOT=1` set.
|
||||
#[test]
|
||||
fn rpc_contract_snapshot_matches_committed_file() {
|
||||
let current = build_contract_snapshot();
|
||||
let rendered =
|
||||
serde_json::to_string_pretty(¤t).expect("snapshot must serialise") + "\n";
|
||||
|
||||
let path = snapshot_path();
|
||||
if std::env::var("UPDATE_RPC_CONTRACT_SNAPSHOT").is_ok() {
|
||||
std::fs::write(&path, &rendered).expect("write snapshot");
|
||||
return;
|
||||
}
|
||||
|
||||
let on_disk = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
assert_eq!(
|
||||
on_disk.trim(),
|
||||
rendered.trim(),
|
||||
"RPC contract snapshot drift at {} — run UPDATE_RPC_CONTRACT_SNAPSHOT=1 cargo test to regenerate, then update frontend/src/api/rpcContract.ts to match",
|
||||
path.display(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user