Compare commits
4 Commits
734d3f2eb0
...
8421104645
| Author | SHA1 | Date | |
|---|---|---|---|
| 8421104645 | |||
| 379ff16d3e | |||
| 2c5326f339 | |||
| bb845d17cf |
@@ -7,7 +7,7 @@ max_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. Always run the run_tests MCP tool 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. If run_tests errors with a transport timeout, call it again — it's idempotent and attaches to the same in-flight test job, so retries are safe and eventually return a pass/fail result."
|
||||
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. Always run the run_tests MCP tool 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."
|
||||
|
||||
[[agent]]
|
||||
name = "coder-2"
|
||||
@@ -18,7 +18,7 @@ max_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. Always run the run_tests MCP tool 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. If run_tests errors with a transport timeout, call it again — it's idempotent and attaches to the same in-flight test job, so retries are safe and eventually return a pass/fail result."
|
||||
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. Always run the run_tests MCP tool 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."
|
||||
|
||||
[[agent]]
|
||||
name = "coder-3"
|
||||
@@ -29,7 +29,7 @@ max_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. Always run the run_tests MCP tool 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. If run_tests errors with a transport timeout, call it again — it's idempotent and attaches to the same in-flight test job, so retries are safe and eventually return a pass/fail result."
|
||||
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. Always run the run_tests MCP tool 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."
|
||||
|
||||
[[agent]]
|
||||
name = "qa-2"
|
||||
@@ -131,7 +131,7 @@ max_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. Always run the run_tests MCP tool 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. If run_tests errors with a transport timeout, call it again — it's idempotent and attaches to the same in-flight test job, so retries are safe and eventually return a pass/fail result."
|
||||
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. Always run the run_tests MCP tool 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."
|
||||
|
||||
[[agent]]
|
||||
name = "qa"
|
||||
|
||||
@@ -518,10 +518,11 @@ mod tests {
|
||||
"[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"9860_story_conflict",
|
||||
"4_merge",
|
||||
"4_merge_failure",
|
||||
"---\nname: Conflict\nmerge_failure: \"CONFLICT (content): server/src/lib.rs\"\n---\n",
|
||||
crate::db::ItemMeta::from_yaml(
|
||||
"---\nname: Conflict\nmerge_failure: \"CONFLICT (content): server/src/lib.rs\"\n---\n",
|
||||
@@ -555,10 +556,11 @@ mod tests {
|
||||
"[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"9861_story_nothing",
|
||||
"4_merge",
|
||||
"4_merge_failure",
|
||||
"---\nname: Nothing\nmerge_failure: \"nothing to commit, working tree clean\"\n---\n",
|
||||
crate::db::ItemMeta::from_yaml(
|
||||
"---\nname: Nothing\nmerge_failure: \"nothing to commit, working tree clean\"\n---\n",
|
||||
|
||||
@@ -12,7 +12,7 @@ use super::super::super::PipelineStage;
|
||||
use super::super::AgentPool;
|
||||
use super::scan::{find_free_agent_for_stage, is_story_assigned_for_stage, scan_stage_items};
|
||||
use super::story_checks::{
|
||||
has_content_conflict_failure, has_merge_failure, has_mergemaster_attempted, has_review_hold,
|
||||
has_content_conflict_failure, has_mergemaster_attempted, has_review_hold,
|
||||
has_unmet_dependencies, is_story_blocked, is_story_frozen,
|
||||
};
|
||||
|
||||
@@ -36,75 +36,6 @@ impl AgentPool {
|
||||
// call invokes the LLM-driven recovery path.
|
||||
let merge_items = scan_stage_items(project_root, "4_merge");
|
||||
for story_id in &merge_items {
|
||||
// Stories with a recorded merge failure may be eligible for
|
||||
// automatic mergemaster dispatch when the failure is a content
|
||||
// conflict — otherwise they need human intervention.
|
||||
if has_merge_failure(project_root, "4_merge", story_id) {
|
||||
// Auto-spawn mergemaster for content conflicts, but only once.
|
||||
if has_content_conflict_failure(project_root, "4_merge", story_id)
|
||||
&& !has_mergemaster_attempted(project_root, "4_merge", story_id)
|
||||
&& !is_story_blocked(project_root, "4_merge", story_id)
|
||||
{
|
||||
// Find the mergemaster agent.
|
||||
let mergemaster_agent = {
|
||||
let agents = match self.agents.lock() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
slog_error!(
|
||||
"[auto-assign] Failed to lock agents for mergemaster check: {e}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if is_story_assigned_for_stage(
|
||||
config,
|
||||
&agents,
|
||||
story_id,
|
||||
&PipelineStage::Mergemaster,
|
||||
) {
|
||||
// Already running — don't spawn again.
|
||||
None
|
||||
} else {
|
||||
find_free_agent_for_stage(config, &agents, &PipelineStage::Mergemaster)
|
||||
.map(str::to_string)
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(agent_name) = mergemaster_agent {
|
||||
slog!(
|
||||
"[auto-assign] Content conflict on '{story_id}'; \
|
||||
auto-spawning mergemaster '{agent_name}'."
|
||||
);
|
||||
// Record mergemaster_attempted before spawning so a
|
||||
// crash/restart doesn't re-trigger an infinite loop.
|
||||
if let Some(contents) = crate::db::read_content(story_id) {
|
||||
let updated =
|
||||
crate::db::yaml_legacy::write_mergemaster_attempted_in_content(
|
||||
&contents,
|
||||
);
|
||||
crate::db::write_content(story_id, &updated);
|
||||
crate::db::write_item_with_content(
|
||||
story_id,
|
||||
"4_merge",
|
||||
&updated,
|
||||
crate::db::ItemMeta::from_yaml(&updated),
|
||||
);
|
||||
}
|
||||
crate::crdt_state::set_mergemaster_attempted(story_id, true);
|
||||
if let Err(e) = self
|
||||
.start_agent(project_root, story_id, Some(&agent_name), None, None)
|
||||
.await
|
||||
{
|
||||
slog!(
|
||||
"[auto-assign] Failed to start mergemaster '{agent_name}' \
|
||||
for '{story_id}': {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if has_review_hold(project_root, "4_merge", story_id) {
|
||||
continue;
|
||||
}
|
||||
@@ -176,5 +107,57 @@ impl AgentPool {
|
||||
slog!("[auto-assign] Triggering server-side merge for '{story_id}' in 4_merge/");
|
||||
self.trigger_server_side_merge(project_root, story_id);
|
||||
}
|
||||
|
||||
// ── 4_merge_failure: auto-spawn mergemaster on content conflict ───────
|
||||
//
|
||||
// Stories transition to 4_merge_failure when the server-side merge fails.
|
||||
// Content conflicts get one automatic mergemaster attempt; other failures
|
||||
// require human intervention.
|
||||
let merge_failure_items = scan_stage_items(project_root, "4_merge_failure");
|
||||
for story_id in &merge_failure_items {
|
||||
if has_content_conflict_failure(project_root, "4_merge_failure", story_id)
|
||||
&& !has_mergemaster_attempted(project_root, "4_merge_failure", story_id)
|
||||
{
|
||||
let mergemaster_agent = {
|
||||
let agents = match self.agents.lock() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
slog_error!(
|
||||
"[auto-assign] Failed to lock agents for mergemaster check: {e}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if is_story_assigned_for_stage(
|
||||
config,
|
||||
&agents,
|
||||
story_id,
|
||||
&PipelineStage::Mergemaster,
|
||||
) {
|
||||
None
|
||||
} else {
|
||||
find_free_agent_for_stage(config, &agents, &PipelineStage::Mergemaster)
|
||||
.map(str::to_string)
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(agent_name) = mergemaster_agent {
|
||||
slog!(
|
||||
"[auto-assign] Content conflict on '{story_id}'; \
|
||||
auto-spawning mergemaster '{agent_name}'."
|
||||
);
|
||||
crate::crdt_state::set_mergemaster_attempted(story_id, true);
|
||||
if let Err(e) = self
|
||||
.start_agent(project_root, story_id, Some(&agent_name), None, None)
|
||||
.await
|
||||
{
|
||||
slog!(
|
||||
"[auto-assign] Failed to start mergemaster '{agent_name}' \
|
||||
for '{story_id}': {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,37 +29,79 @@ pub(super) fn read_story_front_matter_agent(
|
||||
parse_front_matter(&contents).ok()?.agent
|
||||
}
|
||||
|
||||
/// Return `true` if the story file in the given stage has `review_hold: true` in its front matter.
|
||||
pub(super) fn has_review_hold(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
||||
use crate::db::yaml_legacy::parse_front_matter;
|
||||
let contents = match read_story_contents(project_root, story_id) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
parse_front_matter(&contents)
|
||||
/// Return `true` if the story is in the `Frozen` pipeline stage.
|
||||
///
|
||||
/// In the typed CRDT model, `Frozen` is the authoritative representation of
|
||||
/// stories that are held for human review (replacing the legacy
|
||||
/// `review_hold: true` YAML front-matter field). The typed stage register is
|
||||
/// the only source consulted — stale YAML is ignored.
|
||||
pub(super) fn has_review_hold(_project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
||||
crate::pipeline_state::read_typed(story_id)
|
||||
.ok()
|
||||
.and_then(|m| m.review_hold)
|
||||
.flatten()
|
||||
.map(|item| item.stage.is_frozen())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Return `true` if the story is blocked — either via the typed `Stage::Blocked`
|
||||
/// variant or the legacy `blocked: true` front-matter field.
|
||||
pub(super) fn is_story_blocked(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
||||
// Check the typed stage first (authoritative after story 866).
|
||||
if let Ok(Some(item)) = crate::pipeline_state::read_typed(story_id)
|
||||
&& item.stage.is_blocked()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Legacy fallback: check front-matter field for backward compatibility.
|
||||
use crate::db::yaml_legacy::parse_front_matter;
|
||||
let contents = match read_story_contents(project_root, story_id) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
parse_front_matter(&contents)
|
||||
/// Return `true` if the story is blocked via the typed `Stage::Blocked` or
|
||||
/// `Stage::MergeFailure` variant (or the legacy `Archived(Blocked)` state).
|
||||
///
|
||||
/// The typed pipeline stage register is the only source consulted — the legacy
|
||||
/// `blocked: true` YAML front-matter field is no longer checked.
|
||||
pub(super) fn is_story_blocked(_project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
||||
crate::pipeline_state::read_typed(story_id)
|
||||
.ok()
|
||||
.and_then(|m| m.blocked)
|
||||
.flatten()
|
||||
.map(|item| item.stage.is_blocked())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Return `true` if the story's merge failure contains a git content-conflict
|
||||
/// marker (`"Merge conflict"` or `"CONFLICT (content):"`).
|
||||
///
|
||||
/// Used by the auto-assigner to decide whether to spawn mergemaster automatically.
|
||||
/// The typed stage register is consulted first; the CRDT content store is then
|
||||
/// scanned for conflict markers (the projection layer does not carry the reason
|
||||
/// string). No YAML front-matter parsing is performed.
|
||||
pub(super) fn has_content_conflict_failure(
|
||||
_project_root: &Path,
|
||||
_stage_dir: &str,
|
||||
story_id: &str,
|
||||
) -> bool {
|
||||
let is_merge_failure = crate::pipeline_state::read_typed(story_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|item| {
|
||||
matches!(
|
||||
item.stage,
|
||||
crate::pipeline_state::Stage::MergeFailure { .. }
|
||||
)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !is_merge_failure {
|
||||
return false;
|
||||
}
|
||||
// The projection does not carry the reason string; read the raw content
|
||||
// from the CRDT content store and scan for conflict markers.
|
||||
crate::db::read_content(story_id)
|
||||
.map(|content| {
|
||||
content.contains("Merge conflict") || content.contains("CONFLICT (content):")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Return `true` if the CRDT `mergemaster_attempted` register is set for this story.
|
||||
///
|
||||
/// Used to prevent the auto-assigner from repeatedly spawning mergemaster for
|
||||
/// the same story after a failed mergemaster session. The CRDT register is the
|
||||
/// only source consulted — the legacy YAML field is no longer checked.
|
||||
pub(super) fn has_mergemaster_attempted(
|
||||
_project_root: &Path,
|
||||
_stage_dir: &str,
|
||||
story_id: &str,
|
||||
) -> bool {
|
||||
crate::crdt_state::read_item(story_id)
|
||||
.and_then(|view| view.mergemaster_attempted)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -120,97 +162,115 @@ pub(super) fn is_story_frozen(_project_root: &Path, _stage_dir: &str, story_id:
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Return `true` if the story file has a `merge_failure` field in its front matter.
|
||||
pub(super) fn has_merge_failure(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
||||
use crate::db::yaml_legacy::parse_front_matter;
|
||||
let contents = match read_story_contents(project_root, story_id) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|m| m.merge_failure)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Return `true` if the story's `merge_failure` contains a git content-conflict
|
||||
/// marker (`"Merge conflict"` or `"CONFLICT (content):"`).
|
||||
///
|
||||
/// Used by the auto-assigner to decide whether to spawn mergemaster automatically.
|
||||
pub(super) fn has_content_conflict_failure(
|
||||
project_root: &Path,
|
||||
_stage_dir: &str,
|
||||
story_id: &str,
|
||||
) -> bool {
|
||||
use crate::db::yaml_legacy::parse_front_matter;
|
||||
let contents = match read_story_contents(project_root, story_id) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|m| m.merge_failure)
|
||||
.map(|reason| reason.contains("Merge conflict") || reason.contains("CONFLICT (content):"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Return `true` if the story has `mergemaster_attempted: true` in its front matter.
|
||||
///
|
||||
/// Used to prevent the auto-assigner from repeatedly spawning mergemaster for
|
||||
/// the same story after a failed mergemaster session.
|
||||
pub(super) fn has_mergemaster_attempted(
|
||||
project_root: &Path,
|
||||
_stage_dir: &str,
|
||||
story_id: &str,
|
||||
) -> bool {
|
||||
use crate::db::yaml_legacy::parse_front_matter;
|
||||
let contents = match read_story_contents(project_root, story_id) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|m| m.mergemaster_attempted)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── has_review_hold ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn has_review_hold_returns_true_when_set() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fn has_review_hold_returns_true_when_frozen() {
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"10_spike_research",
|
||||
"3_qa",
|
||||
"---\nname: Research spike\nreview_hold: true\n---\n# Spike\n",
|
||||
crate::db::ItemMeta::from_yaml(
|
||||
"---\nname: Research spike\nreview_hold: true\n---\n# Spike\n",
|
||||
),
|
||||
);
|
||||
assert!(has_review_hold(tmp.path(), "3_qa", "10_spike_research"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_review_hold_returns_false_when_not_set() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let qa_dir = tmp.path().join(".huskies/work/3_qa");
|
||||
std::fs::create_dir_all(&qa_dir).unwrap();
|
||||
let spike_path = qa_dir.join("10_spike_research.md");
|
||||
std::fs::write(&spike_path, "---\nname: Research spike\n---\n# Spike\n").unwrap();
|
||||
assert!(!has_review_hold(tmp.path(), "3_qa", "10_spike_research"));
|
||||
crate::db::write_item_with_content(
|
||||
"890_spike_frozen",
|
||||
"7_frozen",
|
||||
"---\nname: Frozen Spike\n---\n# Spike\n",
|
||||
crate::db::ItemMeta::named("Frozen Spike"),
|
||||
);
|
||||
assert!(has_review_hold(tmp.path(), "3_qa", "890_spike_frozen"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_review_hold_returns_false_when_file_missing() {
|
||||
fn has_review_hold_returns_false_for_qa_stage() {
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
crate::db::write_item_with_content(
|
||||
"890_spike_active_qa",
|
||||
"3_qa",
|
||||
"---\nname: Active QA Spike\n---\n# Spike\n",
|
||||
crate::db::ItemMeta::named("Active QA Spike"),
|
||||
);
|
||||
assert!(!has_review_hold(tmp.path(), "3_qa", "890_spike_active_qa"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_review_hold_returns_false_when_story_unknown() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert!(!has_review_hold(tmp.path(), "3_qa", "99_spike_missing"));
|
||||
}
|
||||
|
||||
// ── is_story_blocked — regression: typed stage is sole authority ──────────
|
||||
|
||||
#[test]
|
||||
fn is_story_blocked_set_via_typed_stage_returns_true() {
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
crate::db::write_item_with_content(
|
||||
"890_story_blocked_set",
|
||||
"2_blocked",
|
||||
"---\nname: Blocked Story\n---\n",
|
||||
crate::db::ItemMeta::named("Blocked Story"),
|
||||
);
|
||||
assert!(is_story_blocked(
|
||||
tmp.path(),
|
||||
"2_blocked",
|
||||
"890_story_blocked_set"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_story_blocked_cleared_via_typed_stage_returns_false() {
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// First set to blocked.
|
||||
crate::db::write_item_with_content(
|
||||
"890_story_blocked_clear",
|
||||
"2_blocked",
|
||||
"---\nname: Clearable Story\n---\n",
|
||||
crate::db::ItemMeta::named("Clearable Story"),
|
||||
);
|
||||
// Then clear by transitioning to an active stage.
|
||||
crate::db::write_item_with_content(
|
||||
"890_story_blocked_clear",
|
||||
"2_current",
|
||||
"---\nname: Clearable Story\n---\n",
|
||||
crate::db::ItemMeta::named("Clearable Story"),
|
||||
);
|
||||
assert!(!is_story_blocked(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"890_story_blocked_clear"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_story_blocked_stale_yaml_is_ignored() {
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// YAML front matter says `blocked: true`, but the typed CRDT stage is backlog.
|
||||
// After removing the YAML fallback, the function must return false.
|
||||
crate::db::write_item_with_content(
|
||||
"890_story_stale_yaml",
|
||||
"1_backlog",
|
||||
"---\nname: Stale\nblocked: true\n---\n",
|
||||
crate::db::ItemMeta::named("Stale"),
|
||||
);
|
||||
assert!(
|
||||
!is_story_blocked(tmp.path(), "1_backlog", "890_story_stale_yaml"),
|
||||
"stale YAML `blocked: true` must not be reported as blocked when typed stage is Backlog"
|
||||
);
|
||||
}
|
||||
|
||||
// ── has_unmet_dependencies ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn has_unmet_dependencies_returns_true_when_dep_not_done() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -404,7 +404,7 @@ pub fn apply_compaction(snapshot: Snapshot) -> bool {
|
||||
// For this implementation, the snapshot state IS the full state — peers
|
||||
// discard their old journal and replace it with the snapshot's ops.
|
||||
// The op_manifest preserves attribution for the discarded ops.
|
||||
if let Some(all_ops) = crdt_state::ALL_OPS.get()
|
||||
if let Some(all_ops) = crdt_state::all_ops_lock()
|
||||
&& let Ok(mut v) = all_ops.lock()
|
||||
{
|
||||
// Calculate ops to prune: those with seq < at_seq
|
||||
@@ -428,7 +428,7 @@ pub fn apply_compaction(snapshot: Snapshot) -> bool {
|
||||
*v = kept_ops;
|
||||
|
||||
// Rebuild vector clock from remaining ops.
|
||||
if let Some(vc) = crdt_state::VECTOR_CLOCK.get()
|
||||
if let Some(vc) = crdt_state::vector_clock_lock()
|
||||
&& let Ok(mut clock) = vc.lock()
|
||||
{
|
||||
clock.clear();
|
||||
|
||||
@@ -60,7 +60,7 @@ pub use write::{
|
||||
#[cfg(test)]
|
||||
pub use state::init_for_test;
|
||||
|
||||
pub(crate) use state::{ALL_OPS, VECTOR_CLOCK};
|
||||
pub(crate) use state::{all_ops_lock, vector_clock_lock};
|
||||
|
||||
/// Hex-encode a byte slice (no external dep needed).
|
||||
pub(crate) mod hex {
|
||||
|
||||
@@ -10,10 +10,9 @@ use tokio::sync::broadcast;
|
||||
|
||||
use super::VectorClock;
|
||||
use super::state::{
|
||||
ALL_OPS, SYNC_TX, VECTOR_CLOCK, apply_and_persist, emit_event, get_crdt,
|
||||
rebuild_active_agent_index, rebuild_agent_throttle_index, rebuild_index,
|
||||
rebuild_merge_job_index, rebuild_node_index, rebuild_test_job_index, rebuild_token_index,
|
||||
track_op,
|
||||
SYNC_TX, all_ops_lock, apply_and_persist, emit_event, get_crdt, rebuild_active_agent_index,
|
||||
rebuild_agent_throttle_index, rebuild_index, rebuild_merge_job_index, rebuild_node_index,
|
||||
rebuild_test_job_index, rebuild_token_index, track_op, vector_clock_lock,
|
||||
};
|
||||
use super::types::{CrdtEvent, PipelineDoc};
|
||||
use crate::slog;
|
||||
@@ -31,7 +30,7 @@ pub fn subscribe_ops() -> Option<broadcast::Receiver<SignedOp>> {
|
||||
/// Used during initial sync handshake so a newly-connected peer can
|
||||
/// reconstruct the full CRDT state. Returns `None` before `init()`.
|
||||
pub fn all_ops_json() -> Option<Vec<String>> {
|
||||
ALL_OPS.get().map(|m| m.lock().unwrap().clone())
|
||||
all_ops_lock().map(|m| m.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
/// Return this node's current vector clock.
|
||||
@@ -42,7 +41,7 @@ pub fn all_ops_json() -> Option<Vec<String>> {
|
||||
///
|
||||
/// Returns `None` before `init()`.
|
||||
pub fn our_vector_clock() -> Option<VectorClock> {
|
||||
VECTOR_CLOCK.get().map(|m| m.lock().unwrap().clone())
|
||||
vector_clock_lock().map(|m| m.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
/// Return only the ops that a peer with the given `peer_clock` is missing.
|
||||
@@ -53,7 +52,7 @@ pub fn our_vector_clock() -> Option<VectorClock> {
|
||||
///
|
||||
/// Returns `None` before `init()`.
|
||||
pub fn ops_since(peer_clock: &VectorClock) -> Option<Vec<String>> {
|
||||
let all = ALL_OPS.get()?.lock().ok()?;
|
||||
let all = all_ops_lock()?.lock().ok()?;
|
||||
let mut author_counts: HashMap<String, u64> = HashMap::new();
|
||||
let mut result = Vec::new();
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::collections::HashMap;
|
||||
|
||||
use bft_json_crdt::json_crdt::*;
|
||||
|
||||
use super::state::{ALL_OPS, apply_and_persist, get_crdt, rebuild_index};
|
||||
use super::state::{all_ops_lock, apply_and_persist, get_crdt, rebuild_index};
|
||||
use super::types::{PipelineDoc, PipelineItemCrdt, PipelineItemView};
|
||||
use bft_json_crdt::op::ROOT_ID;
|
||||
|
||||
@@ -55,8 +55,7 @@ pub struct CrdtStateDump {
|
||||
pub fn dump_crdt_state(story_id_filter: Option<&str>) -> CrdtStateDump {
|
||||
let in_memory_state_loaded = get_crdt().is_some();
|
||||
|
||||
let persisted_ops_count = ALL_OPS
|
||||
.get()
|
||||
let persisted_ops_count = all_ops_lock()
|
||||
.and_then(|m| m.lock().ok().map(|v| v.len()))
|
||||
.unwrap_or(0);
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ pub(super) use indices::{
|
||||
rebuild_index, rebuild_merge_job_index, rebuild_node_index, rebuild_test_job_index,
|
||||
rebuild_token_index,
|
||||
};
|
||||
pub(crate) use statics::{ALL_OPS, VECTOR_CLOCK};
|
||||
pub(super) use statics::{SYNC_TX, track_op};
|
||||
pub(crate) use statics::{all_ops_lock, vector_clock_lock};
|
||||
|
||||
// ── CrdtState struct ─────────────────────────────────────────────────
|
||||
|
||||
@@ -113,9 +113,10 @@ pub(super) fn get_crdt() -> Option<&'static Mutex<CrdtState>> {
|
||||
|
||||
/// Initialise a minimal in-memory CRDT state for unit tests.
|
||||
///
|
||||
/// This avoids the async SQLite setup from `init()`. Ops are accepted via a
|
||||
/// channel whose receiver is immediately dropped, so nothing is persisted.
|
||||
/// Safe to call multiple times — subsequent calls are no-ops (OnceLock).
|
||||
/// This avoids the async SQLite setup from `init()`. Ops are sent to a
|
||||
/// channel whose receiver is leaked (so nothing is persisted, but the channel
|
||||
/// stays open and `apply_and_persist` succeeds silently).
|
||||
/// Safe to call multiple times — subsequent calls are no-ops (thread-local).
|
||||
#[cfg(test)]
|
||||
pub fn init_for_test() {
|
||||
// Initialise thread-local CRDT for test isolation.
|
||||
@@ -126,7 +127,11 @@ pub fn init_for_test() {
|
||||
if lock.get().is_none() {
|
||||
let keypair = make_keypair();
|
||||
let crdt = BaseCrdt::<PipelineDoc>::new(&keypair);
|
||||
let (persist_tx, _rx) = mpsc::unbounded_channel();
|
||||
let (persist_tx, rx) = mpsc::unbounded_channel();
|
||||
// Leak the receiver so the channel stays open: apply_and_persist
|
||||
// can then send without error, preventing [crdt_persist] WARNs
|
||||
// from racing with other tests that watch the global log buffer.
|
||||
std::mem::forget(rx);
|
||||
let state = CrdtState {
|
||||
crdt,
|
||||
keypair,
|
||||
@@ -147,6 +152,13 @@ pub fn init_for_test() {
|
||||
});
|
||||
let _ = statics::CRDT_EVENT_TX.get_or_init(|| broadcast::channel::<CrdtEvent>(256).0);
|
||||
let _ = statics::SYNC_TX.get_or_init(|| broadcast::channel::<SignedOp>(1024).0);
|
||||
let _ = statics::ALL_OPS.get_or_init(|| Mutex::new(Vec::new()));
|
||||
let _ = statics::VECTOR_CLOCK.get_or_init(|| Mutex::new(VectorClock::new()));
|
||||
// Per-thread op journal + vector clock — keeps parallel tests' writes
|
||||
// from corrupting each other's view of ALL_OPS (notably, one thread's
|
||||
// `apply_compaction` could otherwise prune another thread's ops).
|
||||
statics::ALL_OPS_TL.with(|lock| {
|
||||
let _ = lock.set(Mutex::new(Vec::new()));
|
||||
});
|
||||
statics::VECTOR_CLOCK_TL.with(|lock| {
|
||||
let _ = lock.set(Mutex::new(VectorClock::new()));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
//! channel ([`CRDT_EVENT_TX`]), and the in-memory op journal
|
||||
//! ([`ALL_OPS`] / [`VECTOR_CLOCK`]) that tracks every applied op for
|
||||
//! delta-sync.
|
||||
//!
|
||||
//! In `cfg(test)`, the op journal and vector clock are stored in
|
||||
//! thread-local `OnceLock`s (mirroring [`super::CRDT_STATE_TL`]) so parallel
|
||||
//! tests do not share `ALL_OPS` — preventing one test's `apply_compaction`
|
||||
//! from pruning another test's freshly-written ops.
|
||||
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
@@ -32,16 +37,62 @@ pub(crate) static ALL_OPS: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
|
||||
/// re-parsing all ops when a peer requests `our_vector_clock()`.
|
||||
pub(crate) static VECTOR_CLOCK: OnceLock<Mutex<VectorClock>> = OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Per-thread op journal for test isolation. Each test thread sees its
|
||||
/// own ALL_OPS so parallel tests cannot prune each other's ops via
|
||||
/// `apply_compaction`. Set up by `init_for_test`.
|
||||
pub(in crate::crdt_state) static ALL_OPS_TL: OnceLock<Mutex<Vec<String>>> = const { OnceLock::new() };
|
||||
/// Per-thread vector clock for test isolation. See [`ALL_OPS_TL`].
|
||||
pub(in crate::crdt_state) static VECTOR_CLOCK_TL: OnceLock<Mutex<VectorClock>> = const { OnceLock::new() };
|
||||
}
|
||||
|
||||
/// Return the mutex guarding the op journal, if initialised.
|
||||
///
|
||||
/// In production: the global `ALL_OPS`. In `cfg(test)`: the per-thread
|
||||
/// `ALL_OPS_TL`, so parallel tests do not share the journal.
|
||||
pub(crate) fn all_ops_lock() -> Option<&'static Mutex<Vec<String>>> {
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
ALL_OPS.get()
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
let ptr = ALL_OPS_TL.with(|lock| lock as *const OnceLock<Mutex<Vec<String>>>);
|
||||
// SAFETY: the thread-local lives as long as the spawning thread,
|
||||
// which outlives any test code using it. We only need 'static for
|
||||
// the return type; consumers never hold the reference past the test.
|
||||
unsafe { &*ptr }.get()
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the mutex guarding the vector clock, if initialised.
|
||||
///
|
||||
/// In production: the global `VECTOR_CLOCK`. In `cfg(test)`: the per-thread
|
||||
/// `VECTOR_CLOCK_TL`.
|
||||
pub(crate) fn vector_clock_lock() -> Option<&'static Mutex<VectorClock>> {
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
VECTOR_CLOCK.get()
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
let ptr = VECTOR_CLOCK_TL.with(|lock| lock as *const OnceLock<Mutex<VectorClock>>);
|
||||
// SAFETY: see all_ops_lock above.
|
||||
unsafe { &*ptr }.get()
|
||||
}
|
||||
}
|
||||
|
||||
/// Append an op's JSON to `ALL_OPS` and bump the author's count in `VECTOR_CLOCK`.
|
||||
///
|
||||
/// Centralises the bookkeeping that must stay in sync between the two statics.
|
||||
pub(in crate::crdt_state) fn track_op(signed: &SignedOp, json: String) {
|
||||
if let Some(all) = ALL_OPS.get()
|
||||
if let Some(all) = all_ops_lock()
|
||||
&& let Ok(mut v) = all.lock()
|
||||
{
|
||||
v.push(json);
|
||||
}
|
||||
if let Some(vc) = VECTOR_CLOCK.get()
|
||||
if let Some(vc) = vector_clock_lock()
|
||||
&& let Ok(mut clock) = vc.lock()
|
||||
{
|
||||
let author_hex = hex::encode(&signed.author());
|
||||
|
||||
@@ -188,11 +188,6 @@ pub(crate) fn write_review_hold_in_content(contents: &str) -> String {
|
||||
set_front_matter_field(contents, "review_hold", "true")
|
||||
}
|
||||
|
||||
/// Write `mergemaster_attempted: true` to story content.
|
||||
pub(crate) fn write_mergemaster_attempted_in_content(contents: &str) -> String {
|
||||
set_front_matter_field(contents, "mergemaster_attempted", "true")
|
||||
}
|
||||
|
||||
/// Remove a key from the YAML front matter of a story file on disk.
|
||||
///
|
||||
/// Legacy filesystem-backed wrapper around
|
||||
|
||||
@@ -927,3 +927,176 @@ enabled = false
|
||||
let config = BotConfig::load(tmp.path());
|
||||
assert!(config.is_none());
|
||||
}
|
||||
|
||||
// ── Gateway MCP SSE proxy integration tests ──────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_mcp_sse_proxy_streams_progress_and_final_response() {
|
||||
let mut mock_sled = mockito::Server::new_async().await;
|
||||
|
||||
let prog1 = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/progress",
|
||||
"params": { "progressToken": "tok1", "progress": 1.0 }
|
||||
});
|
||||
let prog2 = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/progress",
|
||||
"params": { "progressToken": "tok1", "progress": 2.0 }
|
||||
});
|
||||
let final_resp = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": { "content": [{ "type": "text", "text": "tests passed" }] }
|
||||
});
|
||||
let sse_body = format!("data: {prog1}\n\ndata: {prog2}\n\ndata: {final_resp}\n\n");
|
||||
|
||||
let _mock = mock_sled
|
||||
.mock("POST", "/mcp")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "text/event-stream")
|
||||
.with_body(&sse_body)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert(
|
||||
"sled".to_string(),
|
||||
ProjectEntry {
|
||||
url: mock_sled.url(),
|
||||
},
|
||||
);
|
||||
let config = GatewayConfig { projects };
|
||||
let state = Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap());
|
||||
|
||||
let app = poem::Route::new()
|
||||
.at("/mcp", poem::post(gateway_mcp_post_handler))
|
||||
.data(state.clone());
|
||||
let cli = poem::test::TestClient::new(app);
|
||||
|
||||
let rpc_body = serde_json::to_vec(&serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "run_tests",
|
||||
"arguments": {},
|
||||
"_meta": { "progressToken": "tok1" }
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let resp = cli
|
||||
.post("/mcp")
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
.body(rpc_body)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let body = resp.0.into_body().into_string().await.unwrap();
|
||||
|
||||
let data_lines: Vec<&str> = body
|
||||
.lines()
|
||||
.filter_map(|l| l.strip_prefix("data: "))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
data_lines.len(),
|
||||
3,
|
||||
"Expected 3 SSE events (2 progress + 1 final); got {}: {:?}",
|
||||
data_lines.len(),
|
||||
body
|
||||
);
|
||||
|
||||
let ev1: serde_json::Value =
|
||||
serde_json::from_str(data_lines[0]).expect("event 1 is valid JSON");
|
||||
assert_eq!(
|
||||
ev1["method"], "notifications/progress",
|
||||
"event 1 must be a progress notification"
|
||||
);
|
||||
assert_eq!(ev1["params"]["progress"], 1.0);
|
||||
|
||||
let ev2: serde_json::Value =
|
||||
serde_json::from_str(data_lines[1]).expect("event 2 is valid JSON");
|
||||
assert_eq!(
|
||||
ev2["method"], "notifications/progress",
|
||||
"event 2 must be a progress notification"
|
||||
);
|
||||
assert_eq!(ev2["params"]["progress"], 2.0);
|
||||
|
||||
let ev3: serde_json::Value =
|
||||
serde_json::from_str(data_lines[2]).expect("event 3 is valid JSON");
|
||||
assert_eq!(ev3["id"], 1, "event 3 must be the final JSON-RPC response");
|
||||
assert!(
|
||||
ev3.get("result").is_some(),
|
||||
"event 3 must carry a result field"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_mcp_post_without_sse_returns_plain_json() {
|
||||
let mut mock_sled = mockito::Server::new_async().await;
|
||||
|
||||
let json_resp = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"result": { "content": [{ "type": "text", "text": "done" }] }
|
||||
});
|
||||
|
||||
let _mock = mock_sled
|
||||
.mock("POST", "/mcp")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(serde_json::to_string(&json_resp).unwrap())
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert(
|
||||
"sled".to_string(),
|
||||
ProjectEntry {
|
||||
url: mock_sled.url(),
|
||||
},
|
||||
);
|
||||
let config = GatewayConfig { projects };
|
||||
let state = Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap());
|
||||
|
||||
let app = poem::Route::new()
|
||||
.at("/mcp", poem::post(gateway_mcp_post_handler))
|
||||
.data(state.clone());
|
||||
let cli = poem::test::TestClient::new(app);
|
||||
|
||||
let rpc_body = serde_json::to_vec(&serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": { "name": "run_tests", "arguments": {} }
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let resp = cli
|
||||
.post("/mcp")
|
||||
.header("content-type", "application/json")
|
||||
.body(rpc_body)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let ct = resp
|
||||
.0
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
assert!(
|
||||
ct.contains("application/json"),
|
||||
"Non-SSE path must return application/json; got: {ct}"
|
||||
);
|
||||
|
||||
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||
assert_eq!(body["id"], 2);
|
||||
assert!(
|
||||
body.get("result").is_some(),
|
||||
"Expected result in plain JSON response"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ use crate::service::gateway::{self, GatewayState};
|
||||
use poem::handler;
|
||||
use poem::http::StatusCode;
|
||||
use poem::web::Data;
|
||||
use poem::{Body, Request, Response};
|
||||
use poem::web::sse::{Event, SSE};
|
||||
use poem::{Body, IntoResponse, Request, Response};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
// ── MCP tool definitions ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -146,6 +148,31 @@ pub async fn gateway_mcp_post_handler(
|
||||
return to_json_response(JsonRpcResponse::error(None, -32600, "Missing id".into()));
|
||||
}
|
||||
|
||||
// SSE proxy: tools/call with Accept: text/event-stream + progressToken for
|
||||
// non-gateway tools is forwarded to the sled's SSE endpoint so progress
|
||||
// notifications flow through to the gateway client unchanged.
|
||||
if rpc.method == "tools/call" {
|
||||
let accepts_sse = req
|
||||
.header("accept")
|
||||
.map(|h| h.contains("text/event-stream"))
|
||||
.unwrap_or(false);
|
||||
let has_progress_token = rpc
|
||||
.params
|
||||
.get("_meta")
|
||||
.and_then(|m| m.get("progressToken"))
|
||||
.is_some();
|
||||
if accepts_sse && has_progress_token {
|
||||
let tool_name = rpc
|
||||
.params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if !GATEWAY_TOOLS.contains(&tool_name) {
|
||||
return proxy_and_respond_sse(&state, &bytes, rpc.id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match rpc.method.as_str() {
|
||||
"initialize" => to_json_response(handle_initialize(rpc.id)),
|
||||
"tools/list" => match handle_tools_list(&state, rpc.id.clone()).await {
|
||||
@@ -193,6 +220,73 @@ async fn proxy_and_respond(state: &GatewayState, bytes: &[u8], id: Option<Value>
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream an MCP tool call to the active sled as SSE, re-emitting each `data:`
|
||||
/// event from the sled to the originating gateway client without buffering.
|
||||
///
|
||||
/// On sled disconnect mid-stream a JSON-RPC error event is emitted so the
|
||||
/// client does not hang forever.
|
||||
async fn proxy_and_respond_sse(state: &GatewayState, bytes: &[u8], id: Option<Value>) -> Response {
|
||||
let url = match state.active_url().await {
|
||||
Ok(u) => u,
|
||||
Err(e) => return sse_error_response(id, -32603, e.to_string()),
|
||||
};
|
||||
|
||||
let resp = match gateway::io::proxy_mcp_call_sse(&state.client, &url, bytes).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return sse_error_response(id, -32603, format!("proxy error: {e}")),
|
||||
};
|
||||
|
||||
let id_for_error = id;
|
||||
let stream = async_stream::stream! {
|
||||
use futures::StreamExt as _;
|
||||
let mut buf = String::new();
|
||||
let byte_stream = resp.bytes_stream();
|
||||
tokio::pin!(byte_stream);
|
||||
|
||||
while let Some(chunk) = byte_stream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
if let Ok(text) = std::str::from_utf8(&bytes) {
|
||||
buf.push_str(text);
|
||||
// Emit a gateway SSE event for each complete `data:` line.
|
||||
while let Some(pos) = buf.find('\n') {
|
||||
let line = buf[..pos].trim_end_matches('\r').to_string();
|
||||
buf = buf[pos + 1..].to_string();
|
||||
if let Some(data) = line.strip_prefix("data: ") {
|
||||
yield Event::message(data.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let err = JsonRpcResponse::error(
|
||||
id_for_error.clone(),
|
||||
-32603,
|
||||
format!("upstream disconnected: {e}"),
|
||||
);
|
||||
let data = serde_json::to_string(&err).unwrap_or_default();
|
||||
yield Event::message(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
SSE::new(stream)
|
||||
.keep_alive(Duration::from_secs(15))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Build a minimal SSE response containing a single JSON-RPC error event.
|
||||
fn sse_error_response(id: Option<Value>, code: i64, msg: String) -> Response {
|
||||
let err = JsonRpcResponse::error(id, code, msg);
|
||||
let data = serde_json::to_string(&err).unwrap_or_default();
|
||||
let stream = async_stream::stream! {
|
||||
yield Event::message(data);
|
||||
};
|
||||
SSE::new(stream).into_response()
|
||||
}
|
||||
|
||||
/// GET handler — method not allowed.
|
||||
#[handler]
|
||||
pub async fn gateway_mcp_get_handler() -> Response {
|
||||
|
||||
@@ -108,6 +108,28 @@ pub async fn proxy_mcp_call(
|
||||
.map_err(|e| format!("failed to read response from {mcp_url}: {e}"))
|
||||
}
|
||||
|
||||
/// Proxy an MCP `tools/call` request to the sled with `Accept: text/event-stream`
|
||||
/// and return the raw response for streaming. No per-request timeout is applied
|
||||
/// so long-running tool calls (e.g. `run_tests`, up to 1200 s) are not cut short.
|
||||
///
|
||||
/// The caller reads `.bytes_stream()` from the returned response and re-emits
|
||||
/// each SSE `data:` line as a new event to the originating client.
|
||||
pub async fn proxy_mcp_call_sse(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
request_bytes: &[u8],
|
||||
) -> Result<reqwest::Response, String> {
|
||||
let mcp_url = format!("{}/mcp", base_url.trim_end_matches('/'));
|
||||
client
|
||||
.post(&mcp_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "text/event-stream")
|
||||
.body(request_bytes.to_vec())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("failed to reach {mcp_url}: {e}"))
|
||||
}
|
||||
|
||||
/// Fetch tools/list from a project's MCP endpoint.
|
||||
pub async fn fetch_tools_list(client: &Client, base_url: &str) -> Result<Value, String> {
|
||||
let mcp_url = format!("{}/mcp", base_url.trim_end_matches('/'));
|
||||
|
||||
Reference in New Issue
Block a user