Compare commits
204 Commits
801f9d8a26
...
v0.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
| bbc4c9aa45 | |||
| 556d335997 | |||
| c66016394b | |||
| 23c3301903 | |||
| e6865a1bc6 | |||
| 8f666bd6b3 | |||
| 5678f2a556 | |||
| 54d9737428 | |||
| 667601012c | |||
| b64eb69aee | |||
| 6d53382f8c | |||
| 7d7e02f7b0 | |||
| 595777f366 | |||
| 96e227d8d4 | |||
| bb5abcd042 | |||
| 03a0ca258a | |||
| b9709a6466 | |||
| 977b954e98 | |||
| 8f99fede34 | |||
| 0d3c5579da | |||
| 1f9f34ab58 | |||
| 4553df5b21 | |||
| 311883f45d | |||
| 9e06fff8a8 | |||
| 8f6ba69bf2 | |||
| 0b3a33a63c | |||
| b0090aba84 | |||
| 822fcdaf2b | |||
| 6c05c63997 | |||
| ee20e54d40 | |||
| cfccc2e73c | |||
| 960b4f4d1d | |||
| bc99821274 | |||
| 3d741acefb | |||
| 5a3f94cae1 | |||
| 8faf19f3ab | |||
| 8625b9a7fc | |||
| 995c878961 | |||
| 8f7cdea392 | |||
| 9501412598 | |||
| f1c96595de | |||
| c353c0a6be | |||
| 72d79deec9 | |||
| a80d0a497a | |||
| 0a45805f7b | |||
| 4fad283814 | |||
| 3f2ded13a8 | |||
| c64deca7c2 | |||
| 8e996e2bd3 | |||
| c7a7cb4281 | |||
| 0572af2193 | |||
| bab337b289 | |||
| 5e5c5a0e08 | |||
| 91b4e4ff7c | |||
| 309542cf2c | |||
| 8b2ba1c810 | |||
| e3f5875b8e | |||
| ebf58ef224 | |||
| 761b6934f1 | |||
| 13ab97a615 | |||
| 4520e0e6f9 | |||
| 52180bc402 | |||
| 29e800da21 | |||
| 5ed1438ab9 | |||
| 69b207872a | |||
| 8754c790b9 | |||
| 4e007bb770 | |||
| a5cd3a2152 | |||
| 1ee23e7bfe | |||
| cd9021fedf | |||
| eb48ef19e7 | |||
| 2758f744f2 | |||
| bbdee1239b | |||
| 75dc1fc15a | |||
| b6898886d7 | |||
| 92b1744c3a | |||
| cd411ba443 | |||
| c61f715878 | |||
| caed894db9 | |||
| a078d3df7c | |||
| 580480094e | |||
| c3c9db3d8b | |||
| 430079ecbc | |||
| 91fbad568a | |||
| e6d051d016 | |||
| f268dca5bb | |||
| dcb43c465a | |||
| c811672e18 | |||
| 14a39b6205 | |||
| 246f44d8f3 | |||
| e5d2465f66 | |||
| 7854fbd78a | |||
| 4b18c01835 | |||
| e9a7468d8a | |||
| 51aa649ce4 | |||
| 6fc6c9fcb2 | |||
| 5617da5c27 | |||
| 61815ebf5c | |||
| 77dc09668c | |||
| a47fbc4179 | |||
| 2a2c7ee625 | |||
| 9a6963ac04 | |||
| 93f774fcbb | |||
| 40ea100eae | |||
| 604fb55bd8 | |||
| c89a5c2da6 | |||
| 2f1274ec7c | |||
| 3c9851d17d | |||
| 184c214c34 | |||
| 658e02c9b2 | |||
| 28338a8e8d | |||
| 8b53e20ca9 | |||
| 78b1ecdc3c | |||
| 396a47d7c2 | |||
| 765d54fc4b | |||
| c228ae1640 | |||
| 6a015d6202 | |||
| 6bd11d41f9 | |||
| 4a8ed4348b | |||
| 7491eec257 | |||
| 65416476e3 | |||
| bd517f2857 | |||
| 0b50a624b8 | |||
| 6e76b6a063 | |||
| a7840ea4b0 | |||
| 4a0fbcaa95 | |||
| d87722f6c8 | |||
| 09a8edc0a1 | |||
| 9ce5a8df0c | |||
| 3a8894ea8f | |||
| 9ccbdff19f | |||
| 0a825b9f27 | |||
| 7ca5339450 | |||
| f2943c7e69 | |||
| 2f50e2198b | |||
| c5abc44a63 | |||
| cd214d7246 | |||
| 0f0cf59329 | |||
| b8ec3e2025 | |||
| 541433d96e | |||
| 8e9112066f | |||
| baf3b12fff | |||
| 12ae7ec8bb | |||
| 937792f208 | |||
| d78dd9e8f9 | |||
| 93443e2ff1 | |||
| 69d91d7707 | |||
| 6c62e0fa31 | |||
| 4888f051c3 | |||
| 7d7ab85994 | |||
| aadbb1b2af | |||
| f9f16d6a14 | |||
| 7660a460a5 | |||
| 37877db38d | |||
| 23f58f5762 | |||
| bfea832402 | |||
| 6e704a33b7 | |||
| f775f4cfb9 | |||
| 03a99b3cf1 | |||
| b8945654bf | |||
| 9eb5116f7e | |||
| a49a1cf7cb | |||
| b940b95ec3 | |||
| 148ce37beb | |||
| b76633b79b | |||
| c3144b7937 | |||
| 86e8f2441f | |||
| 19b7edb60c | |||
| 6feb68f3e3 | |||
| ce07c4d7b7 | |||
| 916dc2b11d | |||
| e65f6ace84 | |||
| 3891de685c | |||
| d04facd24f | |||
| 734597902f | |||
| 38df9c78af | |||
| a34c9796b5 | |||
| 90b31fc84f | |||
| 8421104645 | |||
| 379ff16d3e | |||
| 2c5326f339 | |||
| bb845d17cf | |||
| 734d3f2eb0 | |||
| ddc4228b10 | |||
| a97a10fba2 | |||
| d64f1e94ff | |||
| 22bf203853 | |||
| f06492f540 | |||
| e955250474 | |||
| 98d496b1ad | |||
| cd12cb5e2c | |||
| 9be438e6d3 | |||
| fac4442969 | |||
| 5b48f0d051 | |||
| 5248e7ee21 | |||
| f8a295eaec | |||
| 61cf7684de | |||
| 3911c24c26 | |||
| 1251b869a6 | |||
| 66f340a7a3 | |||
| a8eac3c278 | |||
| 7a0c186d94 | |||
| 7ac3fc2e3e | |||
| 0e4a970e3a |
+5
-19
@@ -1,28 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cargo build:*)",
|
||||
"Bash(cargo check:*)",
|
||||
"Bash(git *)",
|
||||
"Bash(ls *)",
|
||||
"Bash(mkdir *)",
|
||||
"Bash(mv *)",
|
||||
"Bash(rm *)",
|
||||
"Bash(touch *)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(pwd *)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(find *)",
|
||||
"Bash(head *)",
|
||||
"Bash(tail *)",
|
||||
"Bash(wc *)",
|
||||
"Bash(cat *)",
|
||||
"Bash",
|
||||
"Read",
|
||||
"Edit",
|
||||
"Write",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"mcp__huskies__*"
|
||||
]
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
"huskies"
|
||||
]
|
||||
"enabledMcpjsonServers": ["huskies"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Claude Code
|
||||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
.mcp.json
|
||||
|
||||
# Local environment (secrets)
|
||||
@@ -15,6 +16,9 @@ _merge_parsed.json
|
||||
.huskies/bot.toml.bak
|
||||
.huskies/build_hash
|
||||
|
||||
# Per-worktree planning file (written by coder agents, must never reach squash commits)
|
||||
PLAN.md
|
||||
|
||||
# Coverage report (generated by script/test_coverage, not tracked in git)
|
||||
.coverage_report.json
|
||||
.coverage_baseline
|
||||
|
||||
@@ -1,5 +1,60 @@
|
||||
# Huskies project-local agent guidance
|
||||
|
||||
## Session Start & Resume Protocol
|
||||
|
||||
### PLAN.md — required for every coder session
|
||||
|
||||
At the very start of each coder session, before doing any code exploration, check for `PLAN.md` in the worktree root:
|
||||
|
||||
**If `PLAN.md` exists (resuming after a watchdog respawn):**
|
||||
1. Read `PLAN.md` first — it is your primary orientation document.
|
||||
2. Only after reading it, call `git_log` / `git_diff` to see commits made since the plan was last updated.
|
||||
3. Reconcile any divergence between the plan and the current git state, then update the plan.
|
||||
|
||||
**If `PLAN.md` is absent (first session on this story):**
|
||||
1. Write `PLAN.md` before any grep, file read, or exploration tool call.
|
||||
2. Populate it with what you know from the story ACs alone; add specifics as you discover them.
|
||||
|
||||
### What PLAN.md must contain
|
||||
|
||||
`PLAN.md` is a living document. Update it after each completed AC or natural unit of work — not only at the start.
|
||||
|
||||
**Required trigger:** Before every `wip(...)` commit AND the final commit, update PLAN.md's "Current state" section to reflect what's now done, and tick off completed items in "What's left". This is required, not optional — stale "Current state: No code changes yet" while files are being edited is a process failure. Stage the PLAN.md update in the same commit as the code change it describes.
|
||||
|
||||
Required sections:
|
||||
|
||||
```markdown
|
||||
# Plan: Story <id>
|
||||
|
||||
## ACs → implementation locations
|
||||
- AC 1: <exact file path>:<line range> — <one-line description of what changes>
|
||||
- AC 2: <exact file path>:<line range> — …
|
||||
…
|
||||
|
||||
## Decisions
|
||||
- <Decision made>: <rationale> — rejected alternative: <what was considered and why it lost>
|
||||
…
|
||||
|
||||
## Current state
|
||||
<What has been done so far. Reference commit hashes or specific functions completed.>
|
||||
|
||||
## What's left
|
||||
- [ ] <specific remaining task with file path and function name>
|
||||
…
|
||||
```
|
||||
|
||||
### Non-conforming outputs
|
||||
|
||||
A PLAN.md that contains only generic steps like "read the code", "write the code", "run the tests", or leaves file paths as `<TBD>` or unspecified is **non-conforming**. Every AC entry must name a real file path and describe the actual change. Every decision entry must name both the chosen approach and at least one rejected alternative with a reason. A stub plan is worse than no plan — rewrite it with specifics.
|
||||
|
||||
## Doc comments — your merge will fail if you skip even one
|
||||
|
||||
Every time you introduce a NEW public item — `pub mod X`, `pub fn`, `pub struct`, `pub enum`, `pub trait`, `pub const`, `pub static`, `pub type`, or a `mod X;` declaration that introduces a new module file — the line directly above it **MUST** be a doc comment starting with `///` (or `//!` at the top of a new module file).
|
||||
|
||||
There are no exceptions. The merge gate runs `source-map-check` and rejects the merge for any single missing doc comment. Two stories today (961, 962) passed every test, every clippy check, and every other gate, then got bounced at the final step because of one missed `///` on a `pub mod` line. **Treat the `///` as part of writing the declaration, not as an afterthought.**
|
||||
|
||||
Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` and address every missing-docs direction it prints. If you added a new module file (e.g. `foo.rs` or `foo/mod.rs`), the FIRST line of that file MUST be a `//! What this module is for` doc comment.
|
||||
|
||||
## Documentation
|
||||
Docs live in `website/docs/*.html` (static HTML), **not** Markdown files. When a story asks you to document something, edit the relevant `.html` file in `website/docs/`.
|
||||
|
||||
@@ -20,6 +75,12 @@ The frontend is embedded into the Rust binary via `rust-embed`. Run `npm run bui
|
||||
|
||||
Clippy is zero-tolerance: no warnings allowed. Fix every warning before committing.
|
||||
|
||||
## Pre-commit hook
|
||||
|
||||
Every agent worktree has a pre-commit hook installed at `.git-hooks/pre-commit` that runs `script/check` (fmt-check, clippy, cargo check, source-map-check) before every `git commit`. If the hook fails, fix the issues shown and re-run `script/check` to validate.
|
||||
|
||||
`git commit --no-verify` bypasses the hook. Do **not** use it. The hook exists to prevent broken commits from reaching the merge gate; bypassing it defeats the purpose and wastes CI cycles.
|
||||
|
||||
## File size
|
||||
Target a maximum of 800 lines per source file as a soft guide. If a file grows beyond 800 lines, decompose it by concern into smaller modules. Split at natural seams: group related types, functions, or handlers together and move each cohesive group to its own file. This keeps files readable and diffs focused.
|
||||
|
||||
|
||||
+26
-15
@@ -3,37 +3,44 @@ name = "coder-1"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 80
|
||||
max_turns = 200
|
||||
max_tool_turns = 80
|
||||
max_budget_usd = 5.00
|
||||
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. 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."
|
||||
disallowed_tools = ["ScheduleWakeup"]
|
||||
prompt ="You are working in a git worktree on story {{story_id}}. The story details are in your prompt above. See .huskies/specs/tech/STACK.md for the tech stack and source map when needed. 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. 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"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 80
|
||||
max_turns = 200
|
||||
max_tool_turns = 80
|
||||
max_budget_usd = 5.00
|
||||
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. 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."
|
||||
disallowed_tools = ["ScheduleWakeup"]
|
||||
prompt ="You are working in a git worktree on story {{story_id}}. The story details are in your prompt above. See .huskies/specs/tech/STACK.md for the tech stack and source map when needed. 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. 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"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 80
|
||||
max_turns = 200
|
||||
max_tool_turns = 80
|
||||
max_budget_usd = 5.00
|
||||
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. 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."
|
||||
disallowed_tools = ["ScheduleWakeup"]
|
||||
prompt ="You are working in a git worktree on story {{story_id}}. The story details are in your prompt above. See .huskies/specs/tech/STACK.md for the tech stack and source map when needed. 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. 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"
|
||||
stage = "qa"
|
||||
role = "Reviews coder work in worktrees: runs quality gates, verifies acceptance criteria, and reports findings."
|
||||
model = "sonnet"
|
||||
max_turns = 40
|
||||
max_turns = 120
|
||||
max_tool_turns = 40
|
||||
max_budget_usd = 4.00
|
||||
prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report.
|
||||
|
||||
@@ -124,17 +131,20 @@ name = "coder-opus"
|
||||
stage = "coder"
|
||||
role = "Senior full-stack engineer for complex tasks. Implements features across all components."
|
||||
model = "opus"
|
||||
max_turns = 80
|
||||
max_turns = 200
|
||||
max_tool_turns = 80
|
||||
max_budget_usd = 20.00
|
||||
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. 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."
|
||||
disallowed_tools = ["ScheduleWakeup"]
|
||||
prompt ="You are working in a git worktree on story {{story_id}}. The story details are in your prompt above. See .huskies/specs/tech/STACK.md for the tech stack and source map when needed. 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. 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"
|
||||
stage = "qa"
|
||||
role = "Reviews coder work in worktrees: runs quality gates, verifies acceptance criteria, and reports findings."
|
||||
model = "sonnet"
|
||||
max_turns = 40
|
||||
max_turns = 120
|
||||
max_tool_turns = 40
|
||||
max_budget_usd = 4.00
|
||||
prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report.
|
||||
|
||||
@@ -225,7 +235,8 @@ name = "mergemaster"
|
||||
stage = "mergemaster"
|
||||
role = "Merges completed coder work into master, runs quality gates, archives stories, and cleans up worktrees."
|
||||
model = "opus"
|
||||
max_turns = 100
|
||||
max_turns = 250
|
||||
max_tool_turns = 100
|
||||
max_budget_usd = 25.00
|
||||
inactivity_timeout_secs = 900
|
||||
prompt = """You are the mergemaster agent for story {{story_id}}. Your job is to merge the completed coder work into master.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
# Backlog Triage — Post-929/934 (Story 935)
|
||||
|
||||
Reviewed all active backlog/parked stories against the changes landed in:
|
||||
- **929**: deleted `db/yaml_legacy.rs` — CRDT is the sole source of truth
|
||||
- **934**: typed `Stage` enum replaces the directory-string state model
|
||||
|
||||
## Summary
|
||||
|
||||
| Tag | Count | Stories |
|
||||
|-----|-------|---------|
|
||||
| subsumed-by-929 | 1 | 938 |
|
||||
| subsumed-by-934 | 0 | — |
|
||||
| deleted-as-duplicate | 1 | 931 (dup of 930) |
|
||||
| needs-rewire-to-typed-model | 3 | 895, 919, 930 |
|
||||
| unaffected | 8 | 810, 811, 893, 897, 899, 928, 937, 939 |
|
||||
| anomaly (zombie, no CRDT file) | 1 | 912 |
|
||||
|
||||
**Total reviewed: 14**
|
||||
|
||||
## Per-Story Tags
|
||||
|
||||
| ID | Name | Tag | Action |
|
||||
|----|------|-----|--------|
|
||||
| 810 | Upgrade libsqlite3-sys | unaffected | — |
|
||||
| 811 | Fly.io Machines API spike | unaffected | — |
|
||||
| 893 | MergeFailure→Coding legal transition | unaffected | ACs already reference typed CRDT Stage |
|
||||
| 895 | Show Blocked section in chat status | needs-rewire-to-typed-model | Rewired ACs 0, 4, 5 to reference `Stage::Coding`, `Stage::MergeFailure`, `ArchiveReason::Frozen` |
|
||||
| 897 | Gateway permission prompts | unaffected | — |
|
||||
| 899 | Gateway↔sled WS migration | unaffected | — |
|
||||
| 912 | Auto-spawn mergemaster on conflict | anomaly | Listed in upcoming but `get_story_todos` returns "Story file not found" — no CRDT entry; zombie entry to investigate |
|
||||
| 919 | unblock_story MergeFailure regresses to backlog | needs-rewire-to-typed-model | Rewired all 3 ACs: replaced `4_merge` dir with `Stage::Merge`, "failure flag" with `Stage::MergeFailure` |
|
||||
| 928 | update_story depends_on doesn't persist | unaffected | ACs already reference CRDT register |
|
||||
| 930 | merge_agent_work doesn't auto-transition to Done | needs-rewire-to-typed-model | Rewired ACs 0 and 2: replaced `5_done` dir with `Stage::Done` |
|
||||
| 931 | Duplicate of 930 (same bug, same name) | deleted-as-duplicate | Also referenced `4_merge_failure`/`5_done` directories and ad-hoc `blocked`/`merge_failure` flags |
|
||||
| 937 | start_agent spawns on tombstoned story | unaffected | ACs already reference CRDT `is_deleted` |
|
||||
| 938 | start_agent falls back to .md files | subsumed-by-929 | The .md-file fallback was eliminated by 929; also a duplicate of 937 |
|
||||
| 939 | Move frontend API to WS-RPC | unaffected | — |
|
||||
@@ -0,0 +1,280 @@
|
||||
# Spike 811: Fly.io Machines API Integration for Multi-Tenant Huskies SaaS
|
||||
|
||||
## Goal
|
||||
|
||||
Investigate how to operate huskies as a hosted multi-tenant SaaS on
|
||||
[Fly.io Machines](https://fly.io/docs/machines/). Each tenant owns one or
|
||||
more huskies *project* containers; a fronting gateway routes traffic by
|
||||
tenant and provisions/destroys backing machines on demand. This document
|
||||
captures the architecture, the API surface we need, and the operational
|
||||
concerns that need answers before we start writing production code.
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
```
|
||||
┌──────────────────────┐ ┌───────────────────────────────────────────┐
|
||||
│ Browser / CLI / Bot │───────▶│ huskies-gateway (Fly app: huskies-gw) │
|
||||
└──────────────────────┘ HTTPS │ * authenticates tenant │
|
||||
│ * picks active project for tenant │
|
||||
│ * proxies /mcp /ws /api to machine │
|
||||
│ * provisions machines via Machines API │
|
||||
└──────────────────┬────────────────────────┘
|
||||
│ .flycast (Wireguard)
|
||||
▼
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ huskies-project-{tenant}-{project} │
|
||||
│ (Fly app: huskies-projects, machine per tier)│
|
||||
│ * runs `huskies --port 3001 /data/project` │
|
||||
│ * persistent volume mounted at /data │
|
||||
│ * .huskies/ + sled CRDT live on volume │
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Two Fly apps:
|
||||
|
||||
* `huskies-gw` — small, always-on, replicated across regions; runs the
|
||||
existing `huskies --gateway` binary plus a thin **Fly orchestrator**
|
||||
layer that calls the Machines API.
|
||||
* `huskies-projects` — single Fly app holding *one machine per tenant
|
||||
project*. Using one app (rather than one app per tenant) keeps quota
|
||||
management, IAM, and image distribution simple while still giving us
|
||||
per-machine networking (`{machine_id}.vm.huskies-projects.internal`)
|
||||
and per-tenant Fly volumes.
|
||||
|
||||
## Listed Concerns
|
||||
|
||||
The story brief flags the following concerns. Each is addressed below.
|
||||
|
||||
1. Machine lifecycle & API surface
|
||||
2. Tenant isolation
|
||||
3. Persistence and volumes
|
||||
4. Networking & routing
|
||||
5. Secrets and tenant credentials
|
||||
6. Cost model and idle-shutdown
|
||||
7. Wake-on-request / cold-start latency
|
||||
8. Observability and logs
|
||||
9. Disaster recovery and backups
|
||||
10. Quotas and abuse limits
|
||||
|
||||
---
|
||||
|
||||
### 1. Machine Lifecycle & API Surface
|
||||
|
||||
Fly Machines is a REST API at `https://api.machines.dev/v1`. Auth is a
|
||||
single bearer token per Fly organization (`FLY_API_TOKEN`).
|
||||
|
||||
Endpoints we will call:
|
||||
|
||||
| Verb | Path | Use |
|
||||
|------|------|-----|
|
||||
| `POST` | `/apps/{app}/machines` | Create a new project machine |
|
||||
| `GET` | `/apps/{app}/machines/{id}` | Poll status |
|
||||
| `GET` | `/apps/{app}/machines/{id}/wait?state=started&timeout=30` | Block until state |
|
||||
| `POST` | `/apps/{app}/machines/{id}/start` | Wake a stopped machine |
|
||||
| `POST` | `/apps/{app}/machines/{id}/stop` | Graceful stop (idle scale-to-zero) |
|
||||
| `POST` | `/apps/{app}/machines/{id}/suspend` | Suspend RAM-to-disk (fast wake) |
|
||||
| `DELETE` | `/apps/{app}/machines/{id}?force=true` | Destroy permanently |
|
||||
| `GET` | `/apps/{app}/machines` | Enumerate during reconcile |
|
||||
| `POST` | `/apps/{app}/volumes` | Create persistent volume for tenant |
|
||||
| `DELETE` | `/apps/{app}/volumes/{id}` | Reclaim volume when tenant deletes project |
|
||||
|
||||
States the orchestrator observes: `created → starting → started → stopping
|
||||
→ stopped → destroying → destroyed` (`replacing` and `suspending` are
|
||||
transient).
|
||||
|
||||
A successful provisioning sequence is:
|
||||
|
||||
1. `POST /volumes` (one-time per tenant project, 1 GiB default).
|
||||
2. `POST /machines` with `config = { image, env, mounts: [{volume, path:"/data"}], guest, services }`.
|
||||
3. `GET /machines/{id}/wait?state=started` (~10–20 s on cold start).
|
||||
4. Cache `{tenant, project} → machine_id` in the gateway CRDT
|
||||
(`gateway_projects` LWW-map already exists — extend the value with
|
||||
`machine_id`, `volume_id`, `last_used_at`).
|
||||
|
||||
Destruction:
|
||||
|
||||
1. `POST /machines/{id}/stop` (graceful, lets sled flush).
|
||||
2. `DELETE /machines/{id}?force=true`.
|
||||
3. Optionally `DELETE /volumes/{id}` (only when tenant explicitly deletes
|
||||
the project; idle stop must **never** delete volumes).
|
||||
|
||||
### 2. Tenant Isolation
|
||||
|
||||
* **Filesystem:** each machine has its own ephemeral root and its own
|
||||
Fly volume mounted at `/data`. Volumes are not shareable across
|
||||
machines, so tenants cannot read each other's CRDT.
|
||||
* **Network:** machines on the same Fly app can reach each other via
|
||||
6PN private networking. We must explicitly *not* expose the project
|
||||
server externally; only the gateway holds a public IP. Project
|
||||
machines bind to `[::]:3001` and rely on `.flycast` private routing.
|
||||
* **Credentials:** project machines never see the gateway's
|
||||
`FLY_API_TOKEN`. Tenant-supplied secrets (Anthropic key, Matrix
|
||||
password, etc.) are stored as Fly secrets *scoped to the machine* via
|
||||
the `secrets` field at create time, encrypted at rest by Fly.
|
||||
* **CPU/RAM:** `guest = { cpu_kind: "shared", cpus: 2, memory_mb: 2048 }`
|
||||
is a sensible default; larger tenants get `performance` cpus. Hard
|
||||
caps prevent a runaway agent from eating a neighbour's quota.
|
||||
|
||||
### 3. Persistence and Volumes
|
||||
|
||||
* Fly volumes are zone-pinned. We pick the volume region from the
|
||||
tenant's primary region (`PRIMARY_REGION` env on the gateway), with
|
||||
fallback to `iad`.
|
||||
* The volume holds:
|
||||
* `/data/project/.huskies/` — pipeline.db (sled), bot.toml, project.toml
|
||||
* `/data/project/.git` — repository (initially cloned at first run)
|
||||
* `/data/project/` — working tree
|
||||
* Sled needs a clean shutdown. The orchestrator must always `stop`
|
||||
before `destroy`. We rely on Fly's `kill_signal = "SIGTERM"` + the
|
||||
existing huskies shutdown path in `rebuild.rs`.
|
||||
* **Snapshots:** Fly snapshots volumes daily by default (5-day
|
||||
retention). For paid tiers we extend retention via `snapshot_retention`
|
||||
on the volume.
|
||||
|
||||
### 4. Networking & Routing
|
||||
|
||||
The gateway already proxies MCP/WS/REST by active project. For SaaS we
|
||||
add tenant resolution **before** the project lookup:
|
||||
|
||||
```
|
||||
Host: alice.huskies.app → tenant = alice
|
||||
↓
|
||||
GET /tenants/alice/projects/foo → project_id, machine_id
|
||||
↓
|
||||
proxy to fdaa:0:abcd:a7b:e2:1::3:3001 (or {machine_id}.vm.huskies-projects.internal:3001)
|
||||
```
|
||||
|
||||
* Tenant resolution lives in a new `tenants` CRDT LWW-map keyed by
|
||||
subdomain → tenant_id; reuses the existing CRDT bus.
|
||||
* Internal DNS: `<machine_id>.vm.huskies-projects.internal` resolves on
|
||||
the private network. `<app>.flycast` is the load-balanced anycast
|
||||
name; we prefer the explicit machine address since each tenant has
|
||||
exactly one project machine at a time.
|
||||
* TLS terminates at the Fly edge for `*.huskies.app`. The gateway
|
||||
receives plain HTTP/2 inside 6PN.
|
||||
|
||||
### 5. Secrets and Tenant Credentials
|
||||
|
||||
* `FLY_API_TOKEN` lives only on the gateway (`fly secrets set
|
||||
FLY_API_TOKEN=… -a huskies-gw`).
|
||||
* Per-tenant `ANTHROPIC_API_KEY`, `MATRIX_PASSWORD`, etc. are POSTed by
|
||||
the tenant in the SaaS UI, encrypted with the gateway's KMS key, and
|
||||
passed to the machine at create time via the Machines API
|
||||
`config.env` (Fly stores env values encrypted).
|
||||
* Rotation: changing a tenant secret means `POST /machines/{id}/update`
|
||||
with the new env, which triggers a rolling replace. The orchestrator
|
||||
schedules this during the tenant's idle window when possible.
|
||||
|
||||
### 6. Cost Model and Idle-Shutdown
|
||||
|
||||
Indicative pricing (us-east, 2026):
|
||||
|
||||
| Machine | Hourly | Notes |
|
||||
|---------|--------|-------|
|
||||
| `shared-cpu-2x@2048` always-on | ~$0.027 | $19/mo if 24×7 |
|
||||
| `shared-cpu-2x@2048` suspended | ~$0.0009 | $0.65/mo idle |
|
||||
| Volume 1 GiB | ~$0.0002 | $0.15/mo |
|
||||
|
||||
Multi-tenant pricing requires **suspend on idle**:
|
||||
|
||||
* Auto-stop: in the machine config, set `services[].auto_stop_machines
|
||||
= "suspend"` and `services[].auto_start_machines = true`. Fly's
|
||||
internal proxy stops the machine after the configured `min_machines`
|
||||
count is zero and there is no incoming traffic for ~5 min.
|
||||
* On the next request, the proxy auto-wakes the machine. Suspend resume
|
||||
is ~300 ms (RAM snapshot from disk); a full `stopped → started` is
|
||||
10–20 s. We prefer `suspend` for SaaS.
|
||||
* For long-lived agents (a coder agent running on the machine), the
|
||||
gateway sends keepalive pings so Fly does not idle-stop while work is
|
||||
in progress. Implementation: gateway tracks `active_agents` count for
|
||||
each machine in CRDT; if `>0`, hit `/api/agents` once per minute.
|
||||
|
||||
### 7. Wake-on-Request / Cold-Start Latency
|
||||
|
||||
Three latency tiers:
|
||||
|
||||
| Tier | Wake | When |
|
||||
|------|------|------|
|
||||
| Suspended | ~300 ms | Default for active tenants |
|
||||
| Stopped | 10–20 s | Tenants idle > 7 days |
|
||||
| Destroyed | 60–90 s (clone + boot) | Free tier reaped after 30 d |
|
||||
|
||||
The gateway returns a `202 Accepted` with a `Retry-After: 1` header
|
||||
while wake is in progress and surfaces a "warming up" splash. The
|
||||
existing `huskies-gw` MCP code path needs an explicit wake call for
|
||||
in-flight requests because Fly's automatic wake only triggers on TCP
|
||||
SYN to a registered service port.
|
||||
|
||||
### 8. Observability and Logs
|
||||
|
||||
* `fly logs -a huskies-projects -i <machine_id>` streams stdout/stderr.
|
||||
We expose this through the gateway as `GET /api/admin/tenants/{id}/logs`.
|
||||
* Each machine ships logs to the gateway via a sidecar `vector`
|
||||
process? Decision: **no** — Fly's built-in NATS log shipper is enough
|
||||
for v1; revisit if log volume grows.
|
||||
* Metrics: Fly auto-exports per-machine CPU/RAM/network as Prometheus
|
||||
series scrapeable from a `huskies-metrics` machine in the same 6PN.
|
||||
We hook into Grafana Cloud's free tier for the dashboard.
|
||||
|
||||
### 9. Disaster Recovery and Backups
|
||||
|
||||
* Volume snapshots (daily) cover hardware failure.
|
||||
* The CRDT replicates to the gateway over the existing `/crdt-sync`
|
||||
WebSocket. The gateway keeps a 30-day rolling backup of each tenant's
|
||||
CRDT in S3 (`s3://huskies-backups/{tenant}/{date}.ops`). This lets us
|
||||
reconstruct the project tree even if a Fly volume is unrecoverable.
|
||||
* Restore flow: provision a fresh machine + volume, replay the latest
|
||||
snapshot, then replay incremental ops from S3. Documented in a
|
||||
follow-up runbook story.
|
||||
|
||||
### 10. Quotas and Abuse Limits
|
||||
|
||||
* Per-tenant: max 2 concurrent agents, max 8 GiB volume, max 4 CPU,
|
||||
max 200 OAuth-paid model dollars per month. Enforced in the gateway
|
||||
before calling the Machines API. Over-quota → `429 Too Many Requests`
|
||||
with a Stripe upsell page.
|
||||
* Per-Fly-app: Fly soft-limits 1000 machines per app. At scale we
|
||||
shard tenants across `huskies-projects-{0..9}` apps using
|
||||
`consistent_hash(tenant_id)`.
|
||||
* Abuse: every tenant signs up with a verified email + Stripe card.
|
||||
Free tier capped at 1 project, suspended after 7 days idle, destroyed
|
||||
after 30 days idle.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rejected alternative |
|
||||
|----------|--------|----------------------|
|
||||
| Apps topology | **Single `huskies-projects` app, one machine per tenant** | One app per tenant: clean isolation, but blows out Fly app quotas and complicates IAM |
|
||||
| Idle strategy | **Suspend, not stop** | Stop: cheaper but 20 s cold start is poor UX for chat |
|
||||
| Secrets path | **Machine env via Machines API at create time** | Fly app-level secrets: shared across all tenant machines, leaks across tenants |
|
||||
| State storage | **Per-tenant Fly volume holding sled + git** | Object storage only: would require rewriting sled backend |
|
||||
| Tenant resolution | **Subdomain → CRDT `tenants` LWW-map** | Path prefix routing: harder to issue per-tenant TLS, breaks browser cookies |
|
||||
| Volume retention | **Never delete on idle stop; only on explicit project deletion** | Auto-delete after N days idle: too easy to lose user data |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. How do we hand off long-running coder agents during a Fly host
|
||||
evacuation (machine replace event)? Suspend won't survive a host
|
||||
reboot; we may need a "draining" hook that finishes the current AC
|
||||
and commits before allowing replacement.
|
||||
2. Should the gateway also live as Fly machines (auto-scale) or stay
|
||||
as Fly app v1 with replicas? Probably the former for global routing,
|
||||
but that's a separate spike.
|
||||
3. Billing surfaces: do we pass through Fly's per-machine cost to the
|
||||
tenant, or amortize it into a flat per-project price? Product call.
|
||||
4. Outbound network egress (model API calls, git pushes) is metered by
|
||||
Fly. At Claude Opus rates, model API egress dwarfs everything else,
|
||||
so this is a rounding error — confirm at 100-tenant scale.
|
||||
|
||||
## Proof-of-Concept Script
|
||||
|
||||
A working sketch lives at
|
||||
[`fly_multitenant_poc.sh`](./fly_multitenant_poc.sh). It demonstrates
|
||||
end-to-end: read `FLY_API_TOKEN`, create a volume, create a machine
|
||||
attached to it, wait until started, stop, and destroy. The script is
|
||||
runnable but is **not** what production code looks like — production
|
||||
will translate these calls into Rust against a typed `flyio_machines`
|
||||
client crate, called from a new `server::service::cloud::fly`
|
||||
module that the gateway invokes on tenant signup.
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# fly_multitenant_poc.sh — Proof of concept for Spike 811.
|
||||
#
|
||||
# Demonstrates the Fly.io Machines API calls that the huskies gateway
|
||||
# will eventually make to provision and tear down a per-tenant project
|
||||
# machine. Run against a real Fly org with FLY_API_TOKEN set, or read it
|
||||
# as a commented sketch — the calls are the contract.
|
||||
#
|
||||
# This is NOT production code. Production will issue these requests
|
||||
# from Rust (see server::service::cloud::fly) with retries, structured
|
||||
# errors, and CRDT writes to record machine_id/volume_id. The shell
|
||||
# script exists so the spec is verifiable end-to-end.
|
||||
#
|
||||
# Required env:
|
||||
# FLY_API_TOKEN - org-scoped Fly token
|
||||
# FLY_APP - name of the huskies-projects Fly app (must exist)
|
||||
# TENANT_ID - identifier used to tag and name the machine
|
||||
# REGION - Fly region code, e.g. "iad" (default: iad)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${FLY_API_TOKEN:?FLY_API_TOKEN must be set}"
|
||||
: "${FLY_APP:?FLY_APP must be set}"
|
||||
: "${TENANT_ID:?TENANT_ID must be set}"
|
||||
REGION="${REGION:-iad}"
|
||||
IMAGE="registry.fly.io/huskies-projects:latest"
|
||||
|
||||
API="https://api.machines.dev/v1"
|
||||
AUTH=(-H "Authorization: Bearer ${FLY_API_TOKEN}" -H "Content-Type: application/json")
|
||||
|
||||
echo "==> 1. Create a 1 GiB persistent volume for tenant ${TENANT_ID}"
|
||||
VOLUME_JSON=$(curl -sS -X POST "${API}/apps/${FLY_APP}/volumes" "${AUTH[@]}" --data @- <<EOF
|
||||
{
|
||||
"name": "huskies_${TENANT_ID}",
|
||||
"region": "${REGION}",
|
||||
"size_gb": 1
|
||||
}
|
||||
EOF
|
||||
)
|
||||
VOLUME_ID=$(echo "${VOLUME_JSON}" | jq -r .id)
|
||||
echo " volume_id = ${VOLUME_ID}"
|
||||
|
||||
echo "==> 2. Create a machine attached to the volume, with auto-suspend"
|
||||
MACHINE_JSON=$(curl -sS -X POST "${API}/apps/${FLY_APP}/machines" "${AUTH[@]}" --data @- <<EOF
|
||||
{
|
||||
"name": "huskies-${TENANT_ID}",
|
||||
"region": "${REGION}",
|
||||
"config": {
|
||||
"image": "${IMAGE}",
|
||||
"env": {
|
||||
"TENANT_ID": "${TENANT_ID}",
|
||||
"HUSKIES_PORT": "3001",
|
||||
"PRIMARY_REGION": "${REGION}"
|
||||
},
|
||||
"guest": { "cpu_kind": "shared", "cpus": 2, "memory_mb": 2048 },
|
||||
"mounts": [ { "volume": "${VOLUME_ID}", "path": "/data" } ],
|
||||
"services": [ {
|
||||
"ports": [
|
||||
{ "port": 443, "handlers": ["tls","http"] },
|
||||
{ "port": 80, "handlers": ["http"] }
|
||||
],
|
||||
"protocol": "tcp",
|
||||
"internal_port": 3001,
|
||||
"auto_stop_machines": "suspend",
|
||||
"auto_start_machines": true,
|
||||
"min_machines_running": 0
|
||||
} ],
|
||||
"metadata": { "tenant": "${TENANT_ID}", "managed_by": "huskies-gw" },
|
||||
"restart": { "policy": "on-failure", "max_retries": 5 }
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
MACHINE_ID=$(echo "${MACHINE_JSON}" | jq -r .id)
|
||||
PRIVATE_IP=$(echo "${MACHINE_JSON}" | jq -r .private_ip)
|
||||
echo " machine_id = ${MACHINE_ID}"
|
||||
echo " private_ip = ${PRIVATE_IP}"
|
||||
|
||||
echo "==> 3. Wait for the machine to reach 'started' (long-poll, 60s timeout)"
|
||||
curl -sS "${API}/apps/${FLY_APP}/machines/${MACHINE_ID}/wait?state=started&timeout=60" "${AUTH[@]}" \
|
||||
| jq -r '" state = " + .ok'
|
||||
|
||||
echo " machine reachable at ${MACHINE_ID}.vm.${FLY_APP}.internal:3001"
|
||||
|
||||
# ----- At this point the gateway would record (tenant, machine_id, volume_id)
|
||||
# ----- into the CRDT and start proxying traffic. We pause here.
|
||||
sleep 2
|
||||
|
||||
echo "==> 4. Graceful stop (lets sled flush; idle-suspend uses the same path)"
|
||||
curl -sS -X POST "${API}/apps/${FLY_APP}/machines/${MACHINE_ID}/stop" "${AUTH[@]}" \
|
||||
--data '{"signal":"SIGTERM","timeout":"30s"}' > /dev/null
|
||||
|
||||
echo "==> 5. Destroy the machine"
|
||||
curl -sS -X DELETE "${API}/apps/${FLY_APP}/machines/${MACHINE_ID}?force=true" "${AUTH[@]}" > /dev/null
|
||||
echo " machine destroyed"
|
||||
|
||||
echo "==> 6. Reclaim the volume (only when the tenant deletes the project)"
|
||||
curl -sS -X DELETE "${API}/apps/${FLY_APP}/volumes/${VOLUME_ID}" "${AUTH[@]}" > /dev/null
|
||||
echo " volume reclaimed"
|
||||
|
||||
echo "==> done."
|
||||
Generated
+560
-1390
File diff suppressed because it is too large
Load Diff
+8
-4
@@ -15,14 +15,16 @@ ignore = "0.4.25"
|
||||
mime_guess = "2"
|
||||
notify = "8.2.0"
|
||||
poem = { version = "3", features = ["websocket", "test"] }
|
||||
poem-openapi = { version = "5", features = ["swagger-ui"] }
|
||||
portable-pty = "0.9.0"
|
||||
reqwest = { version = "0.13.3", features = ["json", "stream"] }
|
||||
rust-embed = "8"
|
||||
ed25519-dalek = { version = "2", default-features = false, features = ["rand_core"] }
|
||||
indexmap = { version = "2.14.0", features = ["serde"] }
|
||||
rand = "0.10"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_urlencoded = "0.7"
|
||||
sha1 = "0.10"
|
||||
sha1 = "0.11"
|
||||
sha2 = "0.11.0"
|
||||
hmac = "0.13"
|
||||
subtle = "2"
|
||||
@@ -36,8 +38,7 @@ uuid = { version = "1.23.1", features = ["v4", "serde"] }
|
||||
tokio-tungstenite = { version = "0.29.0", features = ["connect", "rustls-tls-native-roots"] }
|
||||
walkdir = "2.5.0"
|
||||
filetime = "0.2"
|
||||
matrix-sdk = { version = "0.16.0", default-features = false, features = [
|
||||
"rustls-tls",
|
||||
matrix-sdk = { version = "0.17", default-features = false, features = [
|
||||
"sqlite",
|
||||
"e2e-encryption",
|
||||
] }
|
||||
@@ -46,6 +47,9 @@ pulldown-cmark = { version = "0.13.3", default-features = false, features = [
|
||||
] }
|
||||
regex = "1"
|
||||
libc = "0.2"
|
||||
nutype = { version = "0.7", features = ["serde"] }
|
||||
garde = { version = "0.22", features = ["derive"] }
|
||||
ammonia = "4.1"
|
||||
sqlx = { version = "=0.9.0-alpha.1", default-features = false, features = [
|
||||
"runtime-tokio",
|
||||
"sqlite",
|
||||
|
||||
@@ -33,7 +33,7 @@ Huskies can be controlled via bot commands in **Matrix**, **WhatsApp**, and **Sl
|
||||
|
||||
## Prerequisites for building
|
||||
|
||||
- Rust (2024 edition)
|
||||
- Rust 1.93 or newer (2024 edition; MSRV is 1.93, pulled in by matrix-sdk 0.17's use of `Duration::from_mins`)
|
||||
- Node.js and npm
|
||||
- Docker (for Linux cross-compilation and container deployment)
|
||||
- `cross` (`cargo install cross`) optional, for Linux static builds. Only needed if you are building for a different architecture, e.g. if you want to build a Linux binary from a Mac.
|
||||
|
||||
@@ -15,20 +15,20 @@ bft = []
|
||||
|
||||
[dependencies]
|
||||
bft-crdt-derive = { path = "bft-crdt-derive" }
|
||||
colored = "2.0.0"
|
||||
fastcrypto = "0.1.9"
|
||||
indexmap = { version = "2.2.6", features = ["serde"] }
|
||||
rand = "0.8"
|
||||
random_color = "0.6.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = { version = "1.0.85", features = ["preserve_order"] }
|
||||
serde_with = "3.18"
|
||||
sha2 = "0.10.6"
|
||||
colored = "3"
|
||||
ed25519-dalek = { workspace = true }
|
||||
indexmap = { workspace = true, features = ["serde"] }
|
||||
rand = { workspace = true }
|
||||
random_color = "1"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true, features = ["preserve_order"] }
|
||||
serde_with = "3"
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = { version = "1.0.85", features = ["preserve_order"] }
|
||||
criterion = { version = "0.8", features = ["html_reports"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true, features = ["preserve_order"] }
|
||||
|
||||
[[bench]]
|
||||
name = "speed"
|
||||
|
||||
@@ -33,7 +33,7 @@ fn bench_insert_many_agents_conflicts(c: &mut Criterion) {
|
||||
c.bench_function("bench insert many agents conflicts", |b| {
|
||||
b.iter(|| {
|
||||
const N: u8 = 10;
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut rng = rand::rng();
|
||||
let mut crdts: Vec<ListCrdt<i64>> = Vec::with_capacity(N as usize);
|
||||
let mut logs: Vec<Op<JsonValue>> = Vec::new();
|
||||
for i in 0..N {
|
||||
|
||||
@@ -159,7 +159,7 @@ pub fn derive_json_crdt(input: OgTokenStream) -> OgTokenStream {
|
||||
}
|
||||
|
||||
fn view(&self) -> #crate_name::json_crdt::JsonValue {
|
||||
let mut view_map = indexmap::IndexMap::new();
|
||||
let mut view_map = #crate_name::indexmap::IndexMap::new();
|
||||
#(view_map.insert(#ident_strings.to_string(), self.#ident_literals.view().into());)*
|
||||
#crate_name::json_crdt::JsonValue::Object(view_map)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use {
|
||||
op::{print_hex, print_path, ROOT_ID},
|
||||
},
|
||||
colored::Colorize,
|
||||
random_color::{Luminosity, RandomColor},
|
||||
random_color::{options::Luminosity, RandomColor},
|
||||
};
|
||||
|
||||
#[cfg(feature = "logging-list")]
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use fastcrypto::ed25519::Ed25519KeyPair;
|
||||
use fastcrypto::traits::KeyPair;
|
||||
use crate::keypair::Ed25519KeyPair;
|
||||
|
||||
use crate::debug::DebugView;
|
||||
use crate::keypair::SignedDigest;
|
||||
@@ -36,7 +35,7 @@ impl<T: CrdtNode + DebugView> BaseCrdt<T> {
|
||||
/// routing messages to the right BaseCRDT. Usually you should just make a single
|
||||
/// struct that contains all the state you need.
|
||||
pub fn new(keypair: &Ed25519KeyPair) -> Self {
|
||||
let id = keypair.public().0.to_bytes();
|
||||
let id = keypair.verifying_key().to_bytes();
|
||||
Self {
|
||||
id,
|
||||
doc: T::new(id, vec![]),
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
//! [`SignedOp`], [`OpState`], and the causal queue capacity constant.
|
||||
|
||||
use fastcrypto::traits::VerifyingKey;
|
||||
use fastcrypto::{
|
||||
ed25519::{Ed25519KeyPair, Ed25519PublicKey, Ed25519Signature},
|
||||
traits::{KeyPair, ToFromBytes},
|
||||
};
|
||||
use crate::keypair::{Ed25519KeyPair, Ed25519PublicKey, Ed25519Signature};
|
||||
use ed25519_dalek::Verifier as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{serde_as, Bytes};
|
||||
|
||||
@@ -107,16 +104,15 @@ impl SignedOp {
|
||||
/// Sign this digest with the given keypair. Shouldn't need to be called manually,
|
||||
/// just use [`SignedOp::from_op`] instead
|
||||
fn sign_digest(&mut self, keypair: &Ed25519KeyPair) {
|
||||
self.signed_digest = sign(keypair, &self.digest()).sig.to_bytes()
|
||||
self.signed_digest = sign(keypair, &self.digest()).to_bytes()
|
||||
}
|
||||
|
||||
/// Ensure digest was actually signed by the author it claims to be signed by
|
||||
pub fn is_valid_digest(&self) -> bool {
|
||||
let digest = Ed25519Signature::from_bytes(&self.signed_digest);
|
||||
let pubkey = Ed25519PublicKey::from_bytes(&self.author());
|
||||
match (digest, pubkey) {
|
||||
(Ok(digest), Ok(pubkey)) => pubkey.verify(&self.digest(), &digest).is_ok(),
|
||||
(_, _) => false,
|
||||
match Ed25519PublicKey::from_bytes(&self.author()) {
|
||||
Ok(pubkey) => pubkey.verify(&self.digest(), &digest).is_ok(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +122,7 @@ impl SignedOp {
|
||||
keypair: &Ed25519KeyPair,
|
||||
depends_on: Vec<SignedDigest>,
|
||||
) -> Self {
|
||||
let author = keypair.public().0.to_bytes();
|
||||
let author = keypair.verifying_key().to_bytes();
|
||||
let mut new = Self {
|
||||
inner: Op {
|
||||
content: value.content.map(|c| c.view()),
|
||||
|
||||
@@ -10,8 +10,9 @@ use crate::{keypair::AuthorId, list_crdt::ListCrdt, lww_crdt::LwwRegisterCrdt, o
|
||||
use super::{CrdtNode, CrdtNodeFromValue};
|
||||
|
||||
/// An enum representing a JSON value
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub enum JsonValue {
|
||||
#[default]
|
||||
Null,
|
||||
Bool(bool),
|
||||
Number(f64),
|
||||
@@ -61,12 +62,6 @@ impl Display for JsonValue {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for JsonValue {
|
||||
fn default() -> Self {
|
||||
Self::Null
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow easy conversion to and from serde's JSON format. This allows us to use the [`json!`]
|
||||
/// macro
|
||||
impl From<JsonValue> for serde_json::Value {
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
//! Ed25519 keypair utilities and type aliases for node identity and signing.
|
||||
//!
|
||||
//! Provides the [`AuthorId`] and [`SignedDigest`] type aliases, a SHA-256 helper,
|
||||
//! and convenience wrappers around the `fastcrypto` Ed25519 primitives used
|
||||
//! and convenience wrappers around the `ed25519-dalek` Ed25519 primitives used
|
||||
//! throughout the CRDT codebase.
|
||||
|
||||
use fastcrypto::traits::VerifyingKey;
|
||||
pub use fastcrypto::{
|
||||
ed25519::{
|
||||
Ed25519KeyPair, Ed25519PublicKey, Ed25519Signature, ED25519_PUBLIC_KEY_LENGTH,
|
||||
ED25519_SIGNATURE_LENGTH,
|
||||
},
|
||||
traits::{KeyPair, Signer},
|
||||
// Verifier,
|
||||
};
|
||||
use ed25519_dalek::Signer as _;
|
||||
use ed25519_dalek::Verifier as _;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Ed25519 signing key (private + public pair).
|
||||
pub type Ed25519KeyPair = ed25519_dalek::SigningKey;
|
||||
/// Ed25519 verifying (public) key.
|
||||
pub type Ed25519PublicKey = ed25519_dalek::VerifyingKey;
|
||||
/// Ed25519 signature.
|
||||
pub type Ed25519Signature = ed25519_dalek::Signature;
|
||||
|
||||
/// Length of an Ed25519 public key in bytes.
|
||||
pub const ED25519_PUBLIC_KEY_LENGTH: usize = 32;
|
||||
/// Length of an Ed25519 signature in bytes.
|
||||
pub const ED25519_SIGNATURE_LENGTH: usize = 64;
|
||||
|
||||
/// Represents the ID of a unique node. An Ed25519 public key
|
||||
pub type AuthorId = [u8; ED25519_PUBLIC_KEY_LENGTH];
|
||||
|
||||
@@ -48,8 +53,10 @@ pub fn sha256(input: String) -> [u8; 32] {
|
||||
|
||||
/// Generate a random Ed25519 keypair from OS rng
|
||||
pub fn make_keypair() -> Ed25519KeyPair {
|
||||
let mut csprng = rand::thread_rng();
|
||||
Ed25519KeyPair::generate(&mut csprng)
|
||||
use rand::Rng as _;
|
||||
let mut seed = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut seed);
|
||||
Ed25519KeyPair::from_bytes(&seed)
|
||||
}
|
||||
|
||||
/// Sign a byte array
|
||||
|
||||
@@ -19,3 +19,8 @@ pub mod lww_crdt;
|
||||
pub mod op;
|
||||
|
||||
extern crate self as bft_json_crdt;
|
||||
|
||||
/// Re-exported so that code generated by `#[derive(CrdtNode)]` can resolve
|
||||
/// `indexmap` through this crate without requiring downstream crates to
|
||||
/// declare it as a direct dependency.
|
||||
pub use indexmap;
|
||||
|
||||
@@ -299,9 +299,12 @@ where
|
||||
fn index(&self, idx: usize) -> &Self::Output {
|
||||
let mut i = 0;
|
||||
for op in &self.ops {
|
||||
if !op.is_deleted && op.content.is_some() {
|
||||
if op.is_deleted {
|
||||
continue;
|
||||
}
|
||||
if let Some(content) = op.content.as_ref() {
|
||||
if idx == i {
|
||||
return op.content.as_ref().unwrap();
|
||||
return content;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@@ -318,9 +321,12 @@ where
|
||||
fn index_mut(&mut self, idx: usize) -> &mut Self::Output {
|
||||
let mut i = 0;
|
||||
for op in &mut self.ops {
|
||||
if !op.is_deleted && op.content.is_some() {
|
||||
if op.is_deleted {
|
||||
continue;
|
||||
}
|
||||
if let Some(content) = op.content.as_mut() {
|
||||
if idx == i {
|
||||
return op.content.as_mut().unwrap();
|
||||
return content;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
|
||||
use crate::debug::{debug_path_mismatch, debug_type_mismatch};
|
||||
use crate::json_crdt::{CrdtNode, CrdtNodeFromValue, IntoCrdtNode, JsonValue, SignedOp};
|
||||
use crate::keypair::{sha256, AuthorId};
|
||||
use fastcrypto::ed25519::Ed25519KeyPair;
|
||||
use crate::keypair::{sha256, AuthorId, Ed25519KeyPair};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Debug;
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ use bft_json_crdt::{
|
||||
list_crdt::ListCrdt,
|
||||
op::{Op, OpId, ROOT_ID},
|
||||
};
|
||||
use rand::{rngs::ThreadRng, seq::SliceRandom, Rng};
|
||||
use rand::{
|
||||
seq::{IndexedRandom, SliceRandom},
|
||||
Rng, RngExt,
|
||||
};
|
||||
|
||||
fn random_op<T: CrdtNode>(arr: &[Op<T>], rng: &mut ThreadRng) -> OpId {
|
||||
fn random_op<T: CrdtNode>(arr: &[Op<T>], rng: &mut impl Rng) -> OpId {
|
||||
arr.choose(rng).map(|op| op.id).unwrap_or(ROOT_ID)
|
||||
}
|
||||
|
||||
@@ -15,7 +18,7 @@ const TEST_N: usize = 100;
|
||||
|
||||
#[test]
|
||||
fn test_list_fuzz_commutative() {
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut rng = rand::rng();
|
||||
let mut op_log = Vec::<Op<JsonValue>>::new();
|
||||
let mut op_log1 = Vec::<Op<JsonValue>>::new();
|
||||
let mut op_log2 = Vec::<Op<JsonValue>>::new();
|
||||
@@ -23,14 +26,14 @@ fn test_list_fuzz_commutative() {
|
||||
let mut l2 = ListCrdt::<char>::new(make_author(2), vec![]);
|
||||
let mut chk = ListCrdt::<char>::new(make_author(3), vec![]);
|
||||
for _ in 0..TEST_N {
|
||||
let letter1: char = rng.gen_range(b'a'..=b'z') as char;
|
||||
let letter2: char = rng.gen_range(b'a'..=b'z') as char;
|
||||
let op1 = if rng.gen_bool(4.0 / 5.0) {
|
||||
let letter1: char = rng.random_range(b'a'..=b'z') as char;
|
||||
let letter2: char = rng.random_range(b'a'..=b'z') as char;
|
||||
let op1 = if rng.random_bool(4.0 / 5.0) {
|
||||
l1.insert(random_op(&op_log1, &mut rng), letter1)
|
||||
} else {
|
||||
l1.delete(random_op(&op_log1, &mut rng))
|
||||
};
|
||||
let op2 = if rng.gen_bool(4.0 / 5.0) {
|
||||
let op2 = if rng.random_bool(4.0 / 5.0) {
|
||||
l2.insert(random_op(&op_log2, &mut rng), letter2)
|
||||
} else {
|
||||
l2.delete(random_op(&op_log2, &mut rng))
|
||||
@@ -67,8 +70,8 @@ fn test_list_fuzz_commutative() {
|
||||
let mut op_log1 = Vec::<Op<JsonValue>>::new();
|
||||
let mut op_log2 = Vec::<Op<JsonValue>>::new();
|
||||
for _ in 0..TEST_N {
|
||||
let letter1: char = rng.gen_range(b'a'..=b'z') as char;
|
||||
let letter2: char = rng.gen_range(b'a'..=b'z') as char;
|
||||
let letter1: char = rng.random_range(b'a'..=b'z') as char;
|
||||
let letter2: char = rng.random_range(b'a'..=b'z') as char;
|
||||
let op1 = l1.insert(random_op(&op_log, &mut rng), letter1);
|
||||
let op2 = l2.insert(random_op(&op_log, &mut rng), letter2);
|
||||
op_log1.push(op1);
|
||||
|
||||
@@ -10,6 +10,10 @@ crate-type = ["lib"]
|
||||
name = "source-map-check"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "source-map-regen"
|
||||
path = "src/regen_main.rs"
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# source-map-gen
|
||||
|
||||
LLM-friendly source map generation and documentation coverage checking for the
|
||||
huskies pipeline.
|
||||
|
||||
The crate exposes two artifacts:
|
||||
|
||||
- A **library** that extracts public-item signatures from Rust and TypeScript
|
||||
source files, writes them to a JSON map, and checks doc-comment coverage on a
|
||||
changed-file set.
|
||||
- Two **CLI binaries** (`source-map-check`, `source-map-regen`) used by
|
||||
`script/check` and by autonomous coder agents.
|
||||
|
||||
## Why this exists
|
||||
|
||||
The huskies orchestrator embeds `.huskies/source-map.json` directly into the
|
||||
orientation prompt of every autonomous coder it spawns (see
|
||||
`server/src/agents/local_prompt.rs`). The map is a compact, sorted index of
|
||||
every public item in the project — function and method signatures, struct
|
||||
fields, exported TS symbols — that lets a fresh agent answer "what's already
|
||||
here?" without scanning the tree itself.
|
||||
|
||||
Two properties matter:
|
||||
|
||||
1. **Determinism.** Running the regenerator twice on an unchanged tree must
|
||||
produce a byte-identical file. Sorted keys, sorted arrays, stable formatting.
|
||||
2. **No stale entries.** The map cannot reference items that no longer exist,
|
||||
or the orientation bundle lies to agents.
|
||||
|
||||
## Binaries
|
||||
|
||||
### `source-map-check`
|
||||
|
||||
Doc-coverage validator. Used by the pre-commit gate and by coder agents before
|
||||
they commit.
|
||||
|
||||
```
|
||||
cargo run -p source-map-gen --bin source-map-check -- \
|
||||
--worktree . --base master
|
||||
```
|
||||
|
||||
Collects every file that differs from `--base` in any git state (committed,
|
||||
staged, unstaged, untracked), runs the per-language adapter's check, and exits
|
||||
non-zero with one actionable line per undocumented public item:
|
||||
|
||||
```
|
||||
server/src/foo.rs:42: add a doc comment to fn `bar`. Example: `/// Brief description.` above the declaration
|
||||
```
|
||||
|
||||
Coverage is *ratcheted to added lines*: only items whose declaration falls
|
||||
inside a hunk added since `--base` are reported. Pre-existing undocumented
|
||||
items in untouched lines are ignored, so the gate cannot retroactively block
|
||||
work on an unrelated change.
|
||||
|
||||
### `source-map-regen`
|
||||
|
||||
Rebuilds `.huskies/source-map.json` from scratch.
|
||||
|
||||
```
|
||||
cargo run -p source-map-gen --bin source-map-regen -- --project-root .
|
||||
```
|
||||
|
||||
Enumerates every tracked file via `git ls-files`, extracts its public items via
|
||||
the language adapter, and writes a sorted JSON map. Wired into `script/check`
|
||||
so each pre-commit run captures a fresh snapshot. Cannot leave stale entries —
|
||||
unlike incremental update, this path always starts from the empty map.
|
||||
|
||||
## Library
|
||||
|
||||
```rust
|
||||
use source_map_gen::{check_files_ratcheted, regenerate_source_map, CheckResult};
|
||||
```
|
||||
|
||||
Key entry points:
|
||||
|
||||
- `regenerate_source_map(worktree, source_map_path)` — full rebuild from
|
||||
`git ls-files`. Deterministic.
|
||||
- `check_files_ratcheted(files, worktree, base)` — doc-coverage check filtered
|
||||
to lines added since `base`.
|
||||
- `check_files(files)` — non-ratcheted variant; reports every undocumented
|
||||
public item.
|
||||
- `added_line_ranges(worktree, base, file)` — 1-based inclusive line ranges in
|
||||
`file` added since `base`, covering all git states (committed, staged,
|
||||
unstaged, untracked).
|
||||
- `update_source_map(passing_files, source_map_path, root)` — patches the map
|
||||
in place for the given files. Used by the incremental path; production code
|
||||
prefers `regenerate_source_map` to avoid stale entries.
|
||||
|
||||
Languages plug in via the `LanguageAdapter` trait. The crate ships
|
||||
`RustAdapter` and `TypeScriptAdapter`.
|
||||
|
||||
## Map format
|
||||
|
||||
`.huskies/source-map.json` is a JSON object keyed by repo-relative file path,
|
||||
each value an array of public-item signatures from that file:
|
||||
|
||||
```json
|
||||
{
|
||||
"server/src/foo.rs": [
|
||||
"pub fn parse_config(path: &Path) -> Result<Config, Error>",
|
||||
"pub struct Config"
|
||||
],
|
||||
"frontend/src/api.ts": [
|
||||
"export function fetchStories(): Promise<Story[]>"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Keys are sorted alphabetically; each value array preserves the order returned
|
||||
by the adapter. The file is checked into git only as a generated artifact —
|
||||
treat it as build output, not as something to hand-edit.
|
||||
@@ -5,8 +5,9 @@
|
||||
//! extension (`.rs` → [`RustAdapter`], `.ts`/`.tsx` → [`TypeScriptAdapter`]).
|
||||
//!
|
||||
//! The entry point for agent spawn integration is [`update_for_worktree`], which
|
||||
//! runs `git diff --name-only` to find changed files and updates the source map for
|
||||
//! those that pass the documentation coverage check.
|
||||
//! finds changed files and updates the source map for those that pass the documentation
|
||||
//! coverage check. [`added_line_ranges`] covers all git states — committed, staged,
|
||||
//! unstaged, and untracked — so doc-gap detection is independent of index state.
|
||||
|
||||
mod rust_adapter;
|
||||
mod ts_adapter;
|
||||
@@ -32,16 +33,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.
|
||||
@@ -67,10 +86,14 @@ pub trait LanguageAdapter {
|
||||
/// Reads the existing map, updates only the entries for the provided files, and
|
||||
/// writes back. Entries for files not in `passing_files` are preserved unchanged.
|
||||
/// Running twice with the same input produces identical file content (idempotent).
|
||||
///
|
||||
/// When `root` is `Some`, keys are written as paths relative to `root` so the
|
||||
/// map stays portable across machines and worktree locations.
|
||||
fn update_source_map(
|
||||
&self,
|
||||
passing_files: &[&Path],
|
||||
source_map_path: &Path,
|
||||
root: Option<&Path>,
|
||||
) -> Result<(), String>;
|
||||
}
|
||||
|
||||
@@ -119,30 +142,78 @@ fn parse_added_ranges(diff: &str) -> Vec<std::ops::RangeInclusive<usize>> {
|
||||
ranges
|
||||
}
|
||||
|
||||
/// Returns the 1-based line ranges in `file` that were added since `base` in `worktree`.
|
||||
/// Returns the 1-based line ranges in `file` that were added relative to `base` in `worktree`.
|
||||
///
|
||||
/// Uses `git diff --unified=0 {base}...HEAD -- {file}` and parses the hunk headers.
|
||||
/// Returns an empty `Vec` on git errors or when there are no added lines.
|
||||
/// Covers all git states:
|
||||
/// - Untracked files (not yet `git add`-ed): the entire file is treated as added.
|
||||
/// - Committed changes since `base`: `git diff --unified=0 {base}...HEAD`
|
||||
/// - Staged changes: `git diff --unified=0 --cached`
|
||||
/// - Unstaged changes: `git diff --unified=0`
|
||||
///
|
||||
/// Returns an empty `Vec` when there are no additions in any state.
|
||||
pub fn added_line_ranges(
|
||||
worktree: &Path,
|
||||
base: &str,
|
||||
file: &Path,
|
||||
) -> Vec<std::ops::RangeInclusive<usize>> {
|
||||
let rel = file.strip_prefix(worktree).unwrap_or(file);
|
||||
let output = Command::new("git")
|
||||
let rel_str = rel.to_string_lossy();
|
||||
|
||||
// For untracked files, every line is a new addition.
|
||||
let tracked = Command::new("git")
|
||||
.args(["ls-files", "--", &*rel_str])
|
||||
.current_dir(worktree)
|
||||
.output();
|
||||
if let Ok(out) = tracked
|
||||
&& out.status.success()
|
||||
&& out.stdout.is_empty()
|
||||
{
|
||||
let line_count = std::fs::read_to_string(file)
|
||||
.map(|s| s.lines().count())
|
||||
.unwrap_or(0);
|
||||
return if line_count > 0 {
|
||||
vec![1..=line_count]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
}
|
||||
|
||||
let mut ranges = Vec::new();
|
||||
|
||||
// Committed changes since base.
|
||||
let committed = Command::new("git")
|
||||
.args([
|
||||
"diff",
|
||||
"--unified=0",
|
||||
&format!("{base}...HEAD"),
|
||||
"--",
|
||||
&rel.to_string_lossy(),
|
||||
&*rel_str,
|
||||
])
|
||||
.current_dir(worktree)
|
||||
.output();
|
||||
match output {
|
||||
Ok(o) => parse_added_ranges(&String::from_utf8_lossy(&o.stdout)),
|
||||
Err(_) => Vec::new(),
|
||||
if let Ok(o) = committed {
|
||||
ranges.extend(parse_added_ranges(&String::from_utf8_lossy(&o.stdout)));
|
||||
}
|
||||
|
||||
// Staged changes not yet committed.
|
||||
let staged = Command::new("git")
|
||||
.args(["diff", "--unified=0", "--cached", "--", &*rel_str])
|
||||
.current_dir(worktree)
|
||||
.output();
|
||||
if let Ok(o) = staged {
|
||||
ranges.extend(parse_added_ranges(&String::from_utf8_lossy(&o.stdout)));
|
||||
}
|
||||
|
||||
// Unstaged changes to tracked files.
|
||||
let unstaged = Command::new("git")
|
||||
.args(["diff", "--unified=0", "--", &*rel_str])
|
||||
.current_dir(worktree)
|
||||
.output();
|
||||
if let Ok(o) = unstaged {
|
||||
ranges.extend(parse_added_ranges(&String::from_utf8_lossy(&o.stdout)));
|
||||
}
|
||||
|
||||
ranges
|
||||
}
|
||||
|
||||
/// Check documentation coverage, reporting only violations in lines added since `base`.
|
||||
@@ -210,7 +281,14 @@ pub fn check_files(files: &[&Path]) -> CheckResult {
|
||||
///
|
||||
/// Dispatches each file to the appropriate [`LanguageAdapter`] based on extension.
|
||||
/// Files with unsupported extensions are silently skipped.
|
||||
pub fn update_source_map(passing_files: &[&Path], source_map_path: &Path) -> Result<(), String> {
|
||||
///
|
||||
/// When `root` is `Some`, keys in the map are written relative to `root` so the
|
||||
/// map stays portable across machines and worktree locations.
|
||||
pub fn update_source_map(
|
||||
passing_files: &[&Path],
|
||||
source_map_path: &Path,
|
||||
root: Option<&Path>,
|
||||
) -> Result<(), String> {
|
||||
let mut by_ext: HashMap<String, Vec<&Path>> = HashMap::new();
|
||||
for &file in passing_files {
|
||||
if let Some(ext) = file.extension().and_then(|e| e.to_str()) {
|
||||
@@ -219,12 +297,73 @@ pub fn update_source_map(passing_files: &[&Path], source_map_path: &Path) -> Res
|
||||
}
|
||||
for (ext, ext_files) in &by_ext {
|
||||
if let Some(adapter) = adapter_for_ext(ext) {
|
||||
adapter.update_source_map(ext_files, source_map_path)?;
|
||||
adapter.update_source_map(ext_files, source_map_path, root)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Regenerate the source map from scratch for all tracked source files in `worktree`.
|
||||
///
|
||||
/// Uses `git ls-files` to enumerate every tracked Rust and TypeScript file, extracts
|
||||
/// their public item signatures, and writes a fresh JSON map sorted by key. Running
|
||||
/// twice with unchanged source produces byte-identical output (deterministic).
|
||||
///
|
||||
/// Unlike [`update_for_worktree`], this path cannot leave stale entries: every file in
|
||||
/// the map was present and tracked at the time of writing.
|
||||
pub fn regenerate_source_map(worktree: &Path, source_map_path: &Path) -> Result<(), String> {
|
||||
let output = Command::new("git")
|
||||
.args(["ls-files"])
|
||||
.current_dir(worktree)
|
||||
.output()
|
||||
.map_err(|e| format!("git ls-files: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"git ls-files failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
|
||||
// Use BTreeMap so keys are sorted alphabetically → deterministic output.
|
||||
let mut entries: std::collections::BTreeMap<String, Vec<serde_json::Value>> =
|
||||
std::collections::BTreeMap::new();
|
||||
|
||||
for rel_path in String::from_utf8_lossy(&output.stdout).lines() {
|
||||
if rel_path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let abs_path = worktree.join(rel_path);
|
||||
if !abs_path.exists() {
|
||||
continue;
|
||||
}
|
||||
let ext = abs_path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
let items: Vec<serde_json::Value> = match ext {
|
||||
"rs" => RustAdapter::extract_items(&abs_path)
|
||||
.into_iter()
|
||||
.map(serde_json::Value::String)
|
||||
.collect(),
|
||||
"ts" | "tsx" => TypeScriptAdapter::extract_items(&abs_path)
|
||||
.into_iter()
|
||||
.map(serde_json::Value::String)
|
||||
.collect(),
|
||||
_ => continue,
|
||||
};
|
||||
entries.insert(rel_path.to_string(), items);
|
||||
}
|
||||
|
||||
let map: serde_json::Map<String, serde_json::Value> = entries
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, serde_json::Value::Array(v)))
|
||||
.collect();
|
||||
|
||||
if let Some(parent) = source_map_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("create_dir_all: {e}"))?;
|
||||
}
|
||||
|
||||
write_map(source_map_path, map)
|
||||
}
|
||||
|
||||
/// Update the source map for files that changed since `base_branch` in `worktree_path`.
|
||||
///
|
||||
/// 1. Runs `git diff --name-only {base_branch}...HEAD` in the worktree.
|
||||
@@ -233,7 +372,12 @@ pub fn update_source_map(passing_files: &[&Path], source_map_path: &Path) -> Res
|
||||
///
|
||||
/// Errors are returned as `Err(String)`; callers in the spawn flow treat them as
|
||||
/// non-blocking warnings.
|
||||
pub fn update_for_worktree(
|
||||
///
|
||||
/// # Note
|
||||
/// This incremental path is retained for testing only. Production map writes use
|
||||
/// [`regenerate_source_map`] which cannot leave stale entries.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn update_for_worktree(
|
||||
worktree_path: &Path,
|
||||
base_branch: &str,
|
||||
source_map_path: &Path,
|
||||
@@ -277,7 +421,20 @@ pub fn update_for_worktree(
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("create_dir_all: {e}"))?;
|
||||
}
|
||||
|
||||
update_source_map(&passing, source_map_path)
|
||||
update_source_map(&passing, source_map_path, Some(worktree_path))
|
||||
}
|
||||
|
||||
/// Compute the map key for a file, stripping `root` when present.
|
||||
///
|
||||
/// Returns a root-relative path string when `root` is `Some` and the file is
|
||||
/// under that root; falls back to the file's own path string otherwise.
|
||||
pub(crate) fn relative_key(file: &Path, root: Option<&Path>) -> String {
|
||||
if let Some(r) = root
|
||||
&& let Ok(rel) = file.strip_prefix(r)
|
||||
{
|
||||
return rel.to_string_lossy().to_string();
|
||||
}
|
||||
file.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// Read the existing source map from `path` as a JSON object.
|
||||
@@ -426,10 +583,10 @@ mod tests {
|
||||
let map_path = tmp.path().join("source-map.json");
|
||||
let files: &[&Path] = &[&rs_path];
|
||||
|
||||
update_source_map(files, &map_path).unwrap();
|
||||
update_source_map(files, &map_path, None).unwrap();
|
||||
let first = std::fs::read_to_string(&map_path).unwrap();
|
||||
|
||||
update_source_map(files, &map_path).unwrap();
|
||||
update_source_map(files, &map_path, None).unwrap();
|
||||
let second = std::fs::read_to_string(&map_path).unwrap();
|
||||
|
||||
assert_eq!(first, second, "update_source_map must be idempotent");
|
||||
@@ -450,7 +607,7 @@ mod tests {
|
||||
"new.rs",
|
||||
"//! Module doc.\n\n/// A function.\npub fn bar() {}\n",
|
||||
);
|
||||
update_source_map(&[&rs_path], &map_path).unwrap();
|
||||
update_source_map(&[&rs_path], &map_path, None).unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(&map_path).unwrap();
|
||||
assert!(
|
||||
@@ -718,4 +875,156 @@ mod tests {
|
||||
"map must list the documented function"
|
||||
);
|
||||
}
|
||||
|
||||
/// AC2/AC3: keys written by `update_for_worktree` are project-root-relative,
|
||||
/// not absolute paths into the worktree directory.
|
||||
#[test]
|
||||
fn update_for_worktree_writes_relative_keys() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
init_git_repo(tmp.path());
|
||||
|
||||
write_rs(
|
||||
tmp.path(),
|
||||
"lib.rs",
|
||||
"//! Module doc.\n\n/// A function.\npub fn greet() {}\n",
|
||||
);
|
||||
Command::new("git")
|
||||
.args(["add", "lib.rs"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.expect("git add");
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "add lib.rs"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.expect("git commit");
|
||||
|
||||
let huskies_dir = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&huskies_dir).unwrap();
|
||||
let map_path = huskies_dir.join("source-map.json");
|
||||
|
||||
update_for_worktree(tmp.path(), "HEAD~1", &map_path).unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(&map_path).unwrap();
|
||||
let map: serde_json::Value = serde_json::from_str(&content).unwrap();
|
||||
let obj = map.as_object().unwrap();
|
||||
|
||||
// Every key must be relative — no absolute path prefix.
|
||||
for key in obj.keys() {
|
||||
assert!(
|
||||
!key.starts_with('/'),
|
||||
"key must be relative, got absolute path: {key}"
|
||||
);
|
||||
assert!(
|
||||
!key.contains("/.huskies/worktrees/"),
|
||||
"key must not contain worktree path infix: {key}"
|
||||
);
|
||||
}
|
||||
|
||||
// The key for lib.rs must be exactly "lib.rs".
|
||||
assert!(
|
||||
obj.contains_key("lib.rs"),
|
||||
"expected key 'lib.rs', got keys: {:?}",
|
||||
obj.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// AC2: an untracked Rust file lacking a doc comment is caught by `check_files_ratcheted`.
|
||||
///
|
||||
/// The file is never `git add`-ed, so it is invisible to `git diff {base}...HEAD`.
|
||||
/// The ratchet must still surface the missing-doc failure.
|
||||
#[test]
|
||||
fn untracked_file_with_missing_doc_fails() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
init_git_repo(tmp.path());
|
||||
|
||||
// Base commit so there is a HEAD to diff against.
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "base"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Write a new Rust file with a missing doc comment but do NOT `git add` it.
|
||||
write_rs(
|
||||
tmp.path(),
|
||||
"untracked.rs",
|
||||
"//! Module doc.\n\npub fn no_doc_here() {}\n",
|
||||
);
|
||||
|
||||
let file = tmp.path().join("untracked.rs");
|
||||
let result = check_files_ratcheted(&[file.as_path()], tmp.path(), "HEAD");
|
||||
assert!(
|
||||
matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_name == "no_doc_here")),
|
||||
"expected failure for undocumented fn in untracked file, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// AC4: running `regenerate_source_map` twice on the same source tree produces
|
||||
/// byte-identical output.
|
||||
#[test]
|
||||
fn regenerate_source_map_is_deterministic() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
init_git_repo(tmp.path());
|
||||
|
||||
// Add a few tracked files and commit them.
|
||||
write_rs(
|
||||
tmp.path(),
|
||||
"alpha.rs",
|
||||
"//! Alpha module.\n\n/// Does alpha.\npub fn alpha() {}\n",
|
||||
);
|
||||
write_rs(
|
||||
tmp.path(),
|
||||
"beta.rs",
|
||||
"//! Beta module.\n\n/// Does beta.\npub fn beta() {}\n",
|
||||
);
|
||||
Command::new("git")
|
||||
.args(["add", "alpha.rs", "beta.rs"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "add files"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let map_path = tmp.path().join("source-map.json");
|
||||
|
||||
let result1 = regenerate_source_map(tmp.path(), &map_path);
|
||||
assert!(
|
||||
result1.is_ok(),
|
||||
"first regenerate failed: {:?}",
|
||||
result1.err()
|
||||
);
|
||||
let first = std::fs::read_to_string(&map_path).unwrap();
|
||||
|
||||
let result2 = regenerate_source_map(tmp.path(), &map_path);
|
||||
assert!(
|
||||
result2.is_ok(),
|
||||
"second regenerate failed: {:?}",
|
||||
result2.err()
|
||||
);
|
||||
let second = std::fs::read_to_string(&map_path).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
first, second,
|
||||
"regenerate_source_map must be byte-identical on repeated runs"
|
||||
);
|
||||
}
|
||||
|
||||
/// `relative_key` strips the root prefix from an absolute path.
|
||||
#[test]
|
||||
fn relative_key_strips_root_prefix() {
|
||||
let root = Path::new("/workspace/.huskies/worktrees/978");
|
||||
let file = Path::new("/workspace/.huskies/worktrees/978/server/src/foo.rs");
|
||||
assert_eq!(relative_key(file, Some(root)), "server/src/foo.rs");
|
||||
}
|
||||
|
||||
/// `relative_key` falls back to the full path when root is `None`.
|
||||
#[test]
|
||||
fn relative_key_none_root_returns_full_path() {
|
||||
let file = Path::new("/absolute/path/foo.rs");
|
||||
assert_eq!(relative_key(file, None), "/absolute/path/foo.rs");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
//! Exits with code 1 and prints LLM-friendly directions when public items are
|
||||
//! missing doc comments. Exits 0 (silently) when all changed files are fully
|
||||
//! documented or when there are no relevant changes to check.
|
||||
//!
|
||||
//! The file set is derived from all worktree states: committed changes since
|
||||
//! `base`, staged changes, unstaged changes, and untracked files. This ensures
|
||||
//! the result is independent of git index state.
|
||||
|
||||
use source_map_gen::{CheckResult, check_files_ratcheted};
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
@@ -17,29 +22,7 @@ fn main() {
|
||||
|
||||
let worktree_path = Path::new(&worktree);
|
||||
|
||||
let output = match Command::new("git")
|
||||
.args(["diff", "--name-only", &format!("{base}...HEAD")])
|
||||
.current_dir(worktree_path)
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
eprintln!("source-map-check: git diff failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
// Base branch not found or other git error — skip the check gracefully.
|
||||
return;
|
||||
}
|
||||
|
||||
let changed: Vec<PathBuf> = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|l| worktree_path.join(l))
|
||||
.filter(|p| p.exists())
|
||||
.collect();
|
||||
let changed = collect_changed_files(worktree_path, &base);
|
||||
|
||||
if changed.is_empty() {
|
||||
return;
|
||||
@@ -64,6 +47,64 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect all files that differ from `base` in any git state: committed, staged,
|
||||
/// unstaged, or untracked. Returns deduplicated absolute paths that exist on disk.
|
||||
fn collect_changed_files(worktree_path: &Path, base: &str) -> Vec<PathBuf> {
|
||||
let mut names: HashSet<String> = HashSet::new();
|
||||
|
||||
// Committed changes since base (three-dot diff handles divergent histories).
|
||||
run_git_name_list(
|
||||
worktree_path,
|
||||
&["diff", "--name-only", &format!("{base}...HEAD")],
|
||||
&mut names,
|
||||
);
|
||||
|
||||
// Staged changes not yet committed.
|
||||
run_git_name_list(
|
||||
worktree_path,
|
||||
&["diff", "--name-only", "--cached"],
|
||||
&mut names,
|
||||
);
|
||||
|
||||
// Unstaged changes to tracked files.
|
||||
run_git_name_list(worktree_path, &["diff", "--name-only"], &mut names);
|
||||
|
||||
// Untracked files (new files not yet added to the index).
|
||||
run_git_name_list(
|
||||
worktree_path,
|
||||
&["ls-files", "--others", "--exclude-standard"],
|
||||
&mut names,
|
||||
);
|
||||
|
||||
names
|
||||
.into_iter()
|
||||
.map(|l| worktree_path.join(l))
|
||||
.filter(|p| p.exists())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Run a git command and collect each non-empty output line into `out`.
|
||||
///
|
||||
/// Silently ignores git errors so a missing base branch or a fresh repo without
|
||||
/// any commits does not abort the check.
|
||||
fn run_git_name_list(worktree_path: &Path, args: &[&str], out: &mut HashSet<String>) {
|
||||
let Ok(output) = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(worktree_path)
|
||||
.output()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !output.status.success() {
|
||||
return;
|
||||
}
|
||||
for line in String::from_utf8_lossy(&output.stdout).lines() {
|
||||
if !line.is_empty() {
|
||||
out.insert(line.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a flag value from an argument list (e.g. `--flag value`).
|
||||
fn parse_arg(args: &[String], flag: &str) -> Option<String> {
|
||||
args.windows(2).find(|w| w[0] == flag).map(|w| w[1].clone())
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//! CLI binary for manual regeneration of `.huskies/source-map.json`.
|
||||
//!
|
||||
//! Usage: `source-map-regen [--project-root <path>]`
|
||||
//!
|
||||
//! Scans every tracked Rust and TypeScript file in the project via `git ls-files`,
|
||||
//! extracts public item signatures, and writes a fresh sorted JSON map. The output
|
||||
//! is byte-identical across runs on the same source tree (deterministic).
|
||||
//!
|
||||
//! The pre-commit gate (`script/check`) no longer calls this binary directly — map
|
||||
//! regeneration is now inlined into the coder spawn path (`local_prompt.rs`) so every
|
||||
//! agent session starts with a fresh snapshot. This binary is kept as an escape hatch
|
||||
//! for manual out-of-band regeneration (e.g. after bulk refactors outside the pipeline).
|
||||
|
||||
use source_map_gen::regenerate_source_map;
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = parse_arg(&args, "--project-root").unwrap_or_else(|| ".".to_string());
|
||||
let root_path = Path::new(&root);
|
||||
let map_path = root_path.join(".huskies").join("source-map.json");
|
||||
|
||||
if let Err(e) = regenerate_source_map(root_path, &map_path) {
|
||||
eprintln!("source-map-regen: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a flag value from an argument list (e.g. `--flag value`).
|
||||
fn parse_arg(args: &[String], flag: &str) -> Option<String> {
|
||||
args.windows(2).find(|w| w[0] == flag).map(|w| w[1].clone())
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{CheckFailure, CheckResult, LanguageAdapter};
|
||||
use crate::{CheckFailure, CheckResult, LanguageAdapter, relative_key};
|
||||
|
||||
/// Rust documentation coverage adapter.
|
||||
pub struct RustAdapter;
|
||||
@@ -79,10 +79,11 @@ impl LanguageAdapter for RustAdapter {
|
||||
&self,
|
||||
passing_files: &[&Path],
|
||||
source_map_path: &Path,
|
||||
root: Option<&Path>,
|
||||
) -> Result<(), String> {
|
||||
let mut map = crate::read_map(source_map_path)?;
|
||||
for &file in passing_files {
|
||||
let key = file.to_string_lossy().to_string();
|
||||
let key = relative_key(file, root);
|
||||
let items: Vec<serde_json::Value> = Self::extract_items(file)
|
||||
.into_iter()
|
||||
.map(serde_json::Value::String)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{CheckFailure, CheckResult, LanguageAdapter};
|
||||
use crate::{CheckFailure, CheckResult, LanguageAdapter, relative_key};
|
||||
|
||||
/// TypeScript documentation coverage adapter.
|
||||
pub struct TypeScriptAdapter;
|
||||
@@ -80,10 +80,11 @@ impl LanguageAdapter for TypeScriptAdapter {
|
||||
&self,
|
||||
passing_files: &[&Path],
|
||||
source_map_path: &Path,
|
||||
root: Option<&Path>,
|
||||
) -> Result<(), String> {
|
||||
let mut map = crate::read_map(source_map_path)?;
|
||||
for &file in passing_files {
|
||||
let key = file.to_string_lossy().to_string();
|
||||
let key = relative_key(file, root);
|
||||
let items: Vec<serde_json::Value> = Self::extract_items(file)
|
||||
.into_iter()
|
||||
.map(serde_json::Value::String)
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
#
|
||||
# Tested with: OrbStack (recommended on macOS), Docker Desktop (slower bind mounts)
|
||||
|
||||
FROM rust:1.90-bookworm AS base
|
||||
FROM rust:1.93-bookworm AS base
|
||||
|
||||
# Clippy and rustfmt are needed at runtime for acceptance gates
|
||||
RUN rustup component add clippy rustfmt
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "huskies",
|
||||
"version": "0.10.4",
|
||||
"version": "0.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "huskies",
|
||||
"version": "0.10.4",
|
||||
"version": "0.11.0",
|
||||
"dependencies": {
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "huskies",
|
||||
"private": true,
|
||||
"version": "0.10.4",
|
||||
"version": "0.11.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+12
-4
@@ -31,6 +31,7 @@ function App() {
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isGateway === null || isGateway) return;
|
||||
let active = true;
|
||||
function fetchOAuthStatus() {
|
||||
api
|
||||
@@ -46,9 +47,14 @@ function App() {
|
||||
active = false;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, []);
|
||||
}, [isGateway]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isGateway === null) return;
|
||||
if (isGateway) {
|
||||
setIsCheckingProject(false);
|
||||
return;
|
||||
}
|
||||
api
|
||||
.getCurrentProject()
|
||||
.then((path) => {
|
||||
@@ -60,7 +66,7 @@ function App() {
|
||||
.finally(() => {
|
||||
setIsCheckingProject(false);
|
||||
});
|
||||
}, []);
|
||||
}, [isGateway]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (projectPath) {
|
||||
@@ -74,13 +80,15 @@ function App() {
|
||||
}, [projectPath]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isGateway === null || isGateway) return;
|
||||
api
|
||||
.getKnownProjects()
|
||||
.then((projects) => setKnownProjects(projects))
|
||||
.catch((error) => console.error(error));
|
||||
}, []);
|
||||
}, [isGateway]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isGateway === null || isGateway) return;
|
||||
let active = true;
|
||||
api
|
||||
.getHomeDirectory()
|
||||
@@ -102,7 +110,7 @@ function App() {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
}, [isGateway]);
|
||||
|
||||
const {
|
||||
matchList,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Test helpers for stubbing the WebSocket used by `rpcCall`.
|
||||
*
|
||||
* `rpcCall` opens a transient WebSocket, sends an `rpc_request` frame, and
|
||||
* resolves once the matching `rpc_response` arrives. `installRpcMock`
|
||||
* installs a `WebSocket` global that records sent frames and replies with
|
||||
* canned responses keyed by RPC method name.
|
||||
*/
|
||||
|
||||
import { vi } from "vitest";
|
||||
|
||||
interface MockSocket {
|
||||
url: string;
|
||||
sent: 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test handle returned by `installMockRpcWebSocket`: records sockets and calls,
|
||||
* lets the test register canned responses (or override responses for specific
|
||||
* methods), and restores the real `WebSocket` constructor on cleanup.
|
||||
*/
|
||||
export interface MockRpcInstaller {
|
||||
/** All sockets created during the test, in order. */
|
||||
instances: MockSocket[];
|
||||
/** All RPC method names that were called. */
|
||||
calls: { method: string; params: Record<string, unknown> }[];
|
||||
/**
|
||||
* Register a result to be returned for `method`. If the value is a
|
||||
* function, it is invoked with the request params and its return value
|
||||
* (or the resolved promise) is used as the result.
|
||||
*/
|
||||
respond(method: string, result: unknown): void;
|
||||
/** Make `method` reply with an `ok:false` response. */
|
||||
respondError(method: string, error: string, code?: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a stub `WebSocket` global that synchronously resolves RPC calls
|
||||
* with results registered via the returned [`MockRpcInstaller`].
|
||||
*/
|
||||
export function installRpcMock(): MockRpcInstaller {
|
||||
const instances: MockSocket[] = [];
|
||||
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
||||
const results = new Map<string, unknown>();
|
||||
const errors = new Map<string, { error: string; code?: string }>();
|
||||
|
||||
class MockWebSocket implements MockSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
url: string;
|
||||
sent: 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) {
|
||||
this.sent.push(data);
|
||||
let frame: {
|
||||
correlation_id?: string;
|
||||
method?: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
try {
|
||||
frame = JSON.parse(data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const { correlation_id, method, params } = frame;
|
||||
if (!correlation_id || !method) return;
|
||||
calls.push({ method, params: params ?? {} });
|
||||
queueMicrotask(() => {
|
||||
const err = errors.get(method);
|
||||
if (err) {
|
||||
this.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
kind: "rpc_response",
|
||||
version: 1,
|
||||
correlation_id,
|
||||
ok: false,
|
||||
error: err.error,
|
||||
code: err.code,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (results.has(method)) {
|
||||
this.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
kind: "rpc_response",
|
||||
version: 1,
|
||||
correlation_id,
|
||||
ok: true,
|
||||
result: results.get(method),
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// No registered response — synthesise NOT_FOUND so the test fails
|
||||
// loudly instead of timing out.
|
||||
this.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
kind: "rpc_response",
|
||||
version: 1,
|
||||
correlation_id,
|
||||
ok: false,
|
||||
error: `no mock for ${method}`,
|
||||
code: "NOT_FOUND",
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
return {
|
||||
instances,
|
||||
calls,
|
||||
respond(method, result) {
|
||||
results.set(method, result);
|
||||
},
|
||||
respondError(method, error, code) {
|
||||
errors.set(method, { error, code });
|
||||
},
|
||||
};
|
||||
}
|
||||
+59
-129
@@ -1,28 +1,16 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentConfigInfo, AgentEvent, AgentInfo } from "./agents";
|
||||
import { agentsApi, subscribeAgentStream } from "./agents";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
import { installRpcMock } from "./__test_utils__/mockRpcWebSocket";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
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 sampleAgent: AgentInfo = {
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder",
|
||||
@@ -47,155 +35,97 @@ const sampleConfig: AgentConfigInfo = {
|
||||
|
||||
describe("agentsApi", () => {
|
||||
describe("startAgent", () => {
|
||||
it("sends POST to /agents/start with story_id", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(sampleAgent));
|
||||
it("dispatches agents.start RPC with story_id and returns AgentInfo", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("agents.start", sampleAgent);
|
||||
|
||||
const result = await agentsApi.startAgent("42_story_test");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/agents/start",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
story_id: "42_story_test",
|
||||
agent_name: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(rpc.calls).toEqual([
|
||||
{
|
||||
method: "agents.start",
|
||||
params: { story_id: "42_story_test", agent_name: undefined },
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual(sampleAgent);
|
||||
});
|
||||
|
||||
it("sends POST with optional agent_name", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(sampleAgent));
|
||||
it("sends optional agent_name in params", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("agents.start", sampleAgent);
|
||||
|
||||
await agentsApi.startAgent("42_story_test", "coder");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/agents/start",
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(sampleAgent));
|
||||
|
||||
await agentsApi.startAgent(
|
||||
"42_story_test",
|
||||
undefined,
|
||||
"http://localhost:3002/api",
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3002/api/agents/start",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
expect(rpc.calls).toEqual([
|
||||
{
|
||||
method: "agents.start",
|
||||
params: { story_id: "42_story_test", agent_name: "coder" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopAgent", () => {
|
||||
it("sends POST to /agents/stop with story_id and agent_name", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(true));
|
||||
it("dispatches agents.stop RPC with story_id and agent_name", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("agents.stop", true);
|
||||
|
||||
const result = await agentsApi.stopAgent("42_story_test", "coder");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/agents/stop",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(rpc.calls).toEqual([
|
||||
{
|
||||
method: "agents.stop",
|
||||
params: { story_id: "42_story_test", agent_name: "coder" },
|
||||
},
|
||||
]);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(false));
|
||||
|
||||
await agentsApi.stopAgent(
|
||||
"42_story_test",
|
||||
"coder",
|
||||
"http://localhost:3002/api",
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3002/api/agents/stop",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAgentConfig", () => {
|
||||
it("sends GET to /agents/config and returns config list", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));
|
||||
it("dispatches an agent_config.list RPC and returns the config list", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("agent_config.list", [sampleConfig]);
|
||||
|
||||
const result = await agentsApi.getAgentConfig();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/agents/config",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
expect(rpc.calls).toEqual([
|
||||
{ method: "agent_config.list", params: {} },
|
||||
]);
|
||||
expect(result).toEqual([sampleConfig]);
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));
|
||||
|
||||
await agentsApi.getAgentConfig("http://localhost:3002/api");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3002/api/agents/config",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloadConfig", () => {
|
||||
it("sends POST to /agents/config/reload", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));
|
||||
|
||||
const result = await agentsApi.reloadConfig();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/agents/config/reload",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
expect(result).toEqual([sampleConfig]);
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([]));
|
||||
|
||||
await agentsApi.reloadConfig("http://localhost:3002/api");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3002/api/agents/config/reload",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws on non-ok response with body text", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(404, "config not found"));
|
||||
it("surfaces RPC errors visibly", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respondError("agent_config.list", "config not found", "NOT_FOUND");
|
||||
|
||||
await expect(agentsApi.getAgentConfig()).rejects.toThrow(
|
||||
"config not found",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("throws with status code when no body", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
|
||||
describe("reloadConfig", () => {
|
||||
it("dispatches agent_config.list RPC and returns the config list", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("agent_config.list", [sampleConfig]);
|
||||
|
||||
await expect(agentsApi.getAgentConfig()).rejects.toThrow(
|
||||
"Request failed (500)",
|
||||
const result = await agentsApi.reloadConfig();
|
||||
|
||||
expect(rpc.calls).toEqual([
|
||||
{ method: "agent_config.list", params: {} },
|
||||
]);
|
||||
expect(result).toEqual([sampleConfig]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("surfaces RPC errors from startAgent", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respondError("agents.start", "story not found", "NOT_FOUND");
|
||||
|
||||
await expect(agentsApi.startAgent("missing_story")).rejects.toThrow(
|
||||
"story not found",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+19
-65
@@ -40,84 +40,38 @@ export interface AgentConfigInfo {
|
||||
max_budget_usd: number | null;
|
||||
}
|
||||
|
||||
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 agentsApi = {
|
||||
startAgent(storyId: string, agentName?: string, baseUrl?: string) {
|
||||
return requestJson<AgentInfo>(
|
||||
"/agents/start",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
story_id: storyId,
|
||||
agent_name: agentName,
|
||||
}),
|
||||
},
|
||||
baseUrl,
|
||||
);
|
||||
startAgent(storyId: string, agentName?: string) {
|
||||
return rpcCall<AgentInfo>("agents.start", {
|
||||
story_id: storyId,
|
||||
agent_name: agentName,
|
||||
});
|
||||
},
|
||||
|
||||
stopAgent(storyId: string, agentName: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/agents/stop",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
story_id: storyId,
|
||||
agent_name: agentName,
|
||||
}),
|
||||
},
|
||||
baseUrl,
|
||||
);
|
||||
stopAgent(storyId: string, agentName: string) {
|
||||
return rpcCall<boolean>("agents.stop", {
|
||||
story_id: storyId,
|
||||
agent_name: agentName,
|
||||
});
|
||||
},
|
||||
|
||||
listAgents(_baseUrl?: string) {
|
||||
return rpcCall<AgentInfo[]>("active_agents.list");
|
||||
},
|
||||
|
||||
getAgentConfig(baseUrl?: string) {
|
||||
return requestJson<AgentConfigInfo[]>("/agents/config", {}, baseUrl);
|
||||
getAgentConfig(_baseUrl?: string) {
|
||||
return rpcCall<AgentConfigInfo[]>("agent_config.list");
|
||||
},
|
||||
|
||||
reloadConfig(baseUrl?: string) {
|
||||
return requestJson<AgentConfigInfo[]>(
|
||||
"/agents/config/reload",
|
||||
{ method: "POST" },
|
||||
baseUrl,
|
||||
);
|
||||
reloadConfig() {
|
||||
return rpcCall<AgentConfigInfo[]>("agent_config.list");
|
||||
},
|
||||
|
||||
getAgentOutput(storyId: string, agentName: string, baseUrl?: string) {
|
||||
return requestJson<{ output: string }>(
|
||||
`/agents/${encodeURIComponent(storyId)}/${encodeURIComponent(agentName)}/output`,
|
||||
{},
|
||||
baseUrl,
|
||||
);
|
||||
getAgentOutput(storyId: string, agentName: string, _baseUrl?: string) {
|
||||
return rpcCall<{ output: string }>("agents.get_output", {
|
||||
story_id: storyId,
|
||||
agent_name: agentName,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,43 +1,18 @@
|
||||
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;
|
||||
}
|
||||
/**
|
||||
* WS-RPC client for chat-bot transport config (Matrix / Slack / WhatsApp).
|
||||
*/
|
||||
import { rpcCall } from "./rpc";
|
||||
import type { BotConfigPayload } from "./rpcContract";
|
||||
|
||||
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 requestJson<BotConfig>("/bot/config", {}, baseUrl);
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api, ChatWebSocket, resolveWsHost } from "./client";
|
||||
import { installRpcMock } from "./__test_utils__/mockRpcWebSocket";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
@@ -11,33 +12,21 @@ 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 });
|
||||
}
|
||||
|
||||
describe("api client", () => {
|
||||
describe("getCurrentProject", () => {
|
||||
it("sends GET to /project", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse("/home/user/project"));
|
||||
it("dispatches project.current RPC and returns the path", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("project.current", "/home/user/project");
|
||||
|
||||
const result = await api.getCurrentProject();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/project",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
expect(rpc.calls).toEqual([{ method: "project.current", params: {} }]);
|
||||
expect(result).toBe("/home/user/project");
|
||||
});
|
||||
|
||||
it("returns null when no project open", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(null));
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("project.current", null);
|
||||
|
||||
const result = await api.getCurrentProject();
|
||||
expect(result).toBeNull();
|
||||
@@ -45,95 +34,119 @@ 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: {} }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getKnownProjects", () => {
|
||||
it("returns array of project paths", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(["/a", "/b"]));
|
||||
it("dispatches project.known RPC and returns the path list", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("project.known", ["/a", "/b"]);
|
||||
|
||||
const result = await api.getKnownProjects();
|
||||
expect(rpc.calls).toEqual([{ method: "project.known", params: {} }]);
|
||||
expect(result).toEqual(["/a", "/b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws on non-ok response with body text", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(404, "Not found"));
|
||||
it("surfaces RPC errors visibly", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respondError("project.current", "store offline", "INTERNAL");
|
||||
|
||||
await expect(api.getCurrentProject()).rejects.toThrow("Not found");
|
||||
await expect(api.getCurrentProject()).rejects.toThrow("store offline");
|
||||
});
|
||||
|
||||
it("throws with status code when no body", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
|
||||
it("surfaces RPC errors visibly for write methods", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respondError("project.open", "No such directory", "INTERNAL");
|
||||
|
||||
await expect(api.getCurrentProject()).rejects.toThrow(
|
||||
"Request failed (500)",
|
||||
await expect(api.openProject("/some/path")).rejects.toThrow(
|
||||
"No such directory",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchFiles", () => {
|
||||
it("sends POST with query", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
okResponse([{ path: "src/main.rs", matches: 1 }]),
|
||||
);
|
||||
|
||||
const result = await api.searchFiles("hello");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/fs/search",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ query: "hello" }),
|
||||
}),
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("execShell", () => {
|
||||
it("sends POST with command and args", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
okResponse({ stdout: "output", stderr: "", exit_code: 0 }),
|
||||
);
|
||||
|
||||
const result = await api.execShell("ls", ["-la"]);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/shell/exec",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ command: "ls", args: ["-la"] }),
|
||||
}),
|
||||
);
|
||||
expect(result.exit_code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWsHost", () => {
|
||||
|
||||
+72
-163
@@ -1,25 +1,26 @@
|
||||
/**
|
||||
* HTTP transport layer for the Huskies API client.
|
||||
* Provides the low-level `requestJson` helper, the `callMcpTool` function
|
||||
* for MCP JSON-RPC calls, the `resolveWsHost` utility, and the `api`
|
||||
* object exposing all REST endpoints.
|
||||
* Provides the `callMcpTool` function for MCP JSON-RPC calls, the
|
||||
* `resolveWsHost` utility, and the `api` object exposing all endpoints.
|
||||
*/
|
||||
|
||||
import { rpcCall } from "../rpc";
|
||||
import type {
|
||||
OkResult,
|
||||
OpenProjectResult,
|
||||
SetAnthropicApiKeyParams,
|
||||
SetModelPreferenceParams,
|
||||
} from "../rpcContract";
|
||||
import type {
|
||||
AllTokenUsageResponse,
|
||||
AnthropicModelInfo,
|
||||
CommandOutput,
|
||||
FileEntry,
|
||||
OAuthStatus,
|
||||
SearchResult,
|
||||
TestResultsResponse,
|
||||
TokenCostResponse,
|
||||
WorkItemContent,
|
||||
} from "./types";
|
||||
|
||||
/** Base URL prefix for all REST API requests in production. */
|
||||
export const DEFAULT_API_BASE = "/api";
|
||||
|
||||
/**
|
||||
* Resolve the WebSocket host to connect to.
|
||||
* In development, uses the injected port (or 3001); in production, uses the
|
||||
@@ -33,31 +34,6 @@ export function resolveWsHost(
|
||||
return isDev ? `127.0.0.1:${envPort || "3001"}` : locationHost;
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke an MCP tool via the server's JSON-RPC `/mcp` endpoint.
|
||||
* Returns the first text content block from the tool result, or an empty
|
||||
@@ -85,145 +61,82 @@ export async function callMcpTool(
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Typed REST and MCP wrappers for all Huskies server endpoints. */
|
||||
/** Typed wrappers for all Huskies server endpoints. */
|
||||
export const api = {
|
||||
getCurrentProject(baseUrl?: string) {
|
||||
return requestJson<string | null>("/project", {}, baseUrl);
|
||||
getCurrentProject(_baseUrl?: string) {
|
||||
return rpcCall<string | null>("project.current");
|
||||
},
|
||||
getKnownProjects(baseUrl?: string) {
|
||||
return requestJson<string[]>("/projects", {}, baseUrl);
|
||||
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;
|
||||
},
|
||||
async openProject(path: string, _baseUrl?: string) {
|
||||
const r = await rpcCall<OpenProjectResult>("project.open", { path });
|
||||
return r.path;
|
||||
},
|
||||
async closeProject(_baseUrl?: string) {
|
||||
const r = await rpcCall<OkResult>("project.close");
|
||||
return r.ok;
|
||||
},
|
||||
getModelPreference(_baseUrl?: string) {
|
||||
return rpcCall<string | null>("model.get_preference");
|
||||
},
|
||||
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[]>(
|
||||
"ollama.list_models",
|
||||
baseUrlParam ? { base_url: baseUrlParam } : {},
|
||||
);
|
||||
},
|
||||
openProject(path: string, baseUrl?: string) {
|
||||
return requestJson<string>(
|
||||
"/project",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
getAnthropicApiKeyExists(_baseUrl?: string) {
|
||||
return rpcCall<boolean>("anthropic.key_exists");
|
||||
},
|
||||
closeProject(baseUrl?: string) {
|
||||
return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl);
|
||||
getAnthropicModels(_baseUrl?: string) {
|
||||
return rpcCall<AnthropicModelInfo[]>("anthropic.list_models");
|
||||
},
|
||||
getModelPreference(baseUrl?: string) {
|
||||
return requestJson<string | null>("/model", {}, 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;
|
||||
},
|
||||
setModelPreference(model: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/model",
|
||||
{ method: "POST", body: JSON.stringify({ model }) },
|
||||
baseUrl,
|
||||
);
|
||||
readFile(path: string) {
|
||||
return rpcCall<string>("io.read_file", { path });
|
||||
},
|
||||
getOllamaModels(baseUrlParam?: string, baseUrl?: string) {
|
||||
const url = new URL(
|
||||
buildApiUrl("/ollama/models", baseUrl),
|
||||
window.location.origin,
|
||||
);
|
||||
if (baseUrlParam) {
|
||||
url.searchParams.set("base_url", baseUrlParam);
|
||||
}
|
||||
return requestJson<string[]>(url.pathname + url.search, {}, "");
|
||||
listDirectoryAbsolute(path: string) {
|
||||
return rpcCall<FileEntry[]>("io.list_directory_absolute", { path });
|
||||
},
|
||||
getAnthropicApiKeyExists(baseUrl?: string) {
|
||||
return requestJson<boolean>("/anthropic/key/exists", {}, baseUrl);
|
||||
getHomeDirectory(_baseUrl?: string) {
|
||||
return rpcCall<string>("io.home_directory");
|
||||
},
|
||||
getAnthropicModels(baseUrl?: string) {
|
||||
return requestJson<AnthropicModelInfo[]>("/anthropic/models", {}, baseUrl);
|
||||
listProjectFiles(_baseUrl?: string) {
|
||||
return rpcCall<string[]>("io.list_project_files");
|
||||
},
|
||||
setAnthropicApiKey(api_key: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/anthropic/key",
|
||||
{ method: "POST", body: JSON.stringify({ api_key }) },
|
||||
baseUrl,
|
||||
);
|
||||
async cancelChat(_baseUrl?: string) {
|
||||
const r = await rpcCall<OkResult>("chat.cancel");
|
||||
return r.ok;
|
||||
},
|
||||
readFile(path: string, baseUrl?: string) {
|
||||
return requestJson<string>(
|
||||
"/fs/read",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
getWorkItemContent(storyId: string, _baseUrl?: string) {
|
||||
return rpcCall<WorkItemContent>("work_items.get", { story_id: storyId });
|
||||
},
|
||||
writeFile(path: string, content: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/fs/write",
|
||||
{ method: "POST", body: JSON.stringify({ path, content }) },
|
||||
baseUrl,
|
||||
);
|
||||
getTestResults(storyId: string, _baseUrl?: string) {
|
||||
return rpcCall<TestResultsResponse | null>("work_items.test_results", {
|
||||
story_id: storyId,
|
||||
});
|
||||
},
|
||||
listDirectory(path: string, baseUrl?: string) {
|
||||
return requestJson<FileEntry[]>(
|
||||
"/fs/list",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
getTokenCost(storyId: string, _baseUrl?: string) {
|
||||
return rpcCall<TokenCostResponse>("work_items.token_cost", {
|
||||
story_id: storyId,
|
||||
});
|
||||
},
|
||||
listDirectoryAbsolute(path: string, baseUrl?: string) {
|
||||
return requestJson<FileEntry[]>(
|
||||
"/io/fs/list/absolute",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
createDirectoryAbsolute(path: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/io/fs/create/absolute",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getHomeDirectory(baseUrl?: string) {
|
||||
return requestJson<string>("/io/fs/home", {}, baseUrl);
|
||||
},
|
||||
listProjectFiles(baseUrl?: string) {
|
||||
return requestJson<string[]>("/io/fs/files", {}, baseUrl);
|
||||
},
|
||||
searchFiles(query: string, baseUrl?: string) {
|
||||
return requestJson<SearchResult[]>(
|
||||
"/fs/search",
|
||||
{ method: "POST", body: JSON.stringify({ query }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
execShell(command: string, args: string[], baseUrl?: string) {
|
||||
return requestJson<CommandOutput>(
|
||||
"/shell/exec",
|
||||
{ method: "POST", body: JSON.stringify({ command, args }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
cancelChat(baseUrl?: string) {
|
||||
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl);
|
||||
},
|
||||
getWorkItemContent(storyId: string, baseUrl?: string) {
|
||||
return requestJson<WorkItemContent>(
|
||||
`/work-items/${encodeURIComponent(storyId)}`,
|
||||
{},
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getTestResults(storyId: string, baseUrl?: string) {
|
||||
return requestJson<TestResultsResponse | null>(
|
||||
`/work-items/${encodeURIComponent(storyId)}/test-results`,
|
||||
{},
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getTokenCost(storyId: string, baseUrl?: string) {
|
||||
return requestJson<TokenCostResponse>(
|
||||
`/work-items/${encodeURIComponent(storyId)}/token-cost`,
|
||||
{},
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getAllTokenUsage(baseUrl?: string) {
|
||||
return requestJson<AllTokenUsageResponse>("/token-usage", {}, baseUrl);
|
||||
getAllTokenUsage(_baseUrl?: string) {
|
||||
return rpcCall<AllTokenUsageResponse>("token_usage.all");
|
||||
},
|
||||
/** Trigger a server rebuild and restart. */
|
||||
rebuildAndRestart() {
|
||||
@@ -247,14 +160,10 @@ export const api = {
|
||||
},
|
||||
/** Fetch OAuth status from the server. */
|
||||
getOAuthStatus() {
|
||||
return requestJson<OAuthStatus>("/oauth/status", {}, "");
|
||||
return rpcCall<OAuthStatus>("oauth.status");
|
||||
},
|
||||
/** Execute a bot slash command without LLM invocation. Returns markdown response text. */
|
||||
botCommand(command: string, args: string, baseUrl?: string) {
|
||||
return requestJson<{ response: string }>(
|
||||
"/bot/command",
|
||||
{ method: "POST", body: JSON.stringify({ command, args }) },
|
||||
baseUrl,
|
||||
);
|
||||
botCommand(command: string, args: string) {
|
||||
return rpcCall<{ response: string }>("bot.command", { command, args });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -33,6 +33,6 @@ export type {
|
||||
WsResponse,
|
||||
} from "./types";
|
||||
|
||||
export { api, callMcpTool, DEFAULT_API_BASE, resolveWsHost } from "./http";
|
||||
export { api, callMcpTool, resolveWsHost } from "./http";
|
||||
|
||||
export { ChatWebSocket } from "./websocket";
|
||||
|
||||
@@ -53,13 +53,17 @@ export interface AgentAssignment {
|
||||
/** A single item in any pipeline stage (backlog, current, QA, merge, or done). */
|
||||
export interface PipelineStageItem {
|
||||
story_id: string;
|
||||
name: string | null;
|
||||
name: string;
|
||||
error: string | null;
|
||||
merge_failure: string | null;
|
||||
agent: AgentAssignment | null;
|
||||
review_hold: boolean | null;
|
||||
qa: string | null;
|
||||
depends_on: number[] | null;
|
||||
/** True when the item is in Stage::Blocked — awaiting human unblock. */
|
||||
blocked?: boolean | null;
|
||||
/** True when the item is in Stage::Frozen — paused at its current stage. */
|
||||
frozen?: boolean | null;
|
||||
}
|
||||
|
||||
/** Snapshot of all pipeline stages returned via WebSocket or REST. */
|
||||
@@ -138,32 +142,32 @@ export type StatusEvent =
|
||||
| {
|
||||
type: "stage_transition";
|
||||
story_id: string;
|
||||
story_name: string | null;
|
||||
story_name: string;
|
||||
from_stage: string;
|
||||
to_stage: string;
|
||||
}
|
||||
| {
|
||||
type: "merge_failure";
|
||||
story_id: string;
|
||||
story_name: string | null;
|
||||
story_name: string;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: "story_blocked";
|
||||
story_id: string;
|
||||
story_name: string | null;
|
||||
story_name: string;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: "rate_limit_warning";
|
||||
story_id: string;
|
||||
story_name: string | null;
|
||||
story_name: string;
|
||||
agent_name: string;
|
||||
}
|
||||
| {
|
||||
type: "rate_limit_hard_block";
|
||||
story_id: string;
|
||||
story_name: string | null;
|
||||
story_name: string;
|
||||
agent_name: string;
|
||||
reset_at: string;
|
||||
};
|
||||
@@ -208,7 +212,7 @@ export interface AnthropicModelInfo {
|
||||
export interface WorkItemContent {
|
||||
content: string;
|
||||
stage: string;
|
||||
name: string | null;
|
||||
name: string;
|
||||
agent: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ export class ChatWebSocket {
|
||||
) => void;
|
||||
private onStatusUpdate?: (event: StatusEvent) => void;
|
||||
private onConnected?: () => void;
|
||||
private onDisconnected?: () => void;
|
||||
private connected = false;
|
||||
private closeTimer?: number;
|
||||
private wsPath = DEFAULT_WS_PATH;
|
||||
@@ -169,6 +170,7 @@ export class ChatWebSocket {
|
||||
};
|
||||
this.socket.onclose = () => {
|
||||
if (this.shouldReconnect && this.connected) {
|
||||
this.onDisconnected?.();
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
};
|
||||
@@ -215,6 +217,7 @@ export class ChatWebSocket {
|
||||
onLogEntry?: (timestamp: string, level: string, message: string) => void;
|
||||
onStatusUpdate?: (event: StatusEvent) => void;
|
||||
onConnected?: () => void;
|
||||
onDisconnected?: () => void;
|
||||
},
|
||||
wsPath = DEFAULT_WS_PATH,
|
||||
) {
|
||||
@@ -236,6 +239,7 @@ export class ChatWebSocket {
|
||||
this.onLogEntry = handlers.onLogEntry;
|
||||
this.onStatusUpdate = handlers.onStatusUpdate;
|
||||
this.onConnected = handlers.onConnected;
|
||||
this.onDisconnected = handlers.onDisconnected;
|
||||
this.wsPath = wsPath;
|
||||
this.shouldReconnect = true;
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface ProjectPipelineStatus {
|
||||
active: PipelineItem[];
|
||||
backlog: { story_id: string; name: string }[];
|
||||
backlog_count: number;
|
||||
archived?: PipelineItem[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -54,6 +55,21 @@ export interface ServerMode {
|
||||
mode: "gateway" | "standard";
|
||||
}
|
||||
|
||||
/// Type guard: verify that an unknown value has the AllProjectsPipeline shape.
|
||||
/// Prevents silent "no active stories" when the backend response shape drifts.
|
||||
function isAllProjectsPipeline(value: unknown): value is AllProjectsPipeline {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
if (typeof v.active !== "string") return false;
|
||||
if (typeof v.projects !== "object" || v.projects === null) return false;
|
||||
for (const proj of Object.values(v.projects as Record<string, unknown>)) {
|
||||
if (typeof proj !== "object" || proj === null) return false;
|
||||
const p = proj as Record<string, unknown>;
|
||||
if (!Array.isArray(p.active) && typeof p.error !== "string") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function gatewayRequest<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
@@ -164,11 +180,15 @@ export const gatewayApi = {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Request failed (${res.status})`);
|
||||
}
|
||||
const rpc = await res.json() as { result?: AllProjectsPipeline; error?: { message: string } };
|
||||
const rpc = await res.json() as { result?: unknown; error?: { message: string } };
|
||||
if (rpc.error) {
|
||||
throw new Error(rpc.error.message);
|
||||
}
|
||||
return rpc.result!;
|
||||
const result = rpc.result;
|
||||
if (!isAllProjectsPipeline(result)) {
|
||||
throw new Error("pipeline.get returned unexpected shape");
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/// Switch the active project via the MCP switch_project tool.
|
||||
|
||||
+163
-32
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* Lightweight read-RPC client over the `/ws` WebSocket.
|
||||
*
|
||||
* Opens a short-lived WebSocket, sends an `rpc_request` frame, waits for the
|
||||
* matching `rpc_response`, then closes the connection.
|
||||
* Each `rpcCall` opens a short-lived WebSocket, sends an `rpc_request` frame,
|
||||
* waits for the matching `rpc_response`, then closes the connection.
|
||||
*
|
||||
* On a transient connection failure the call is retried once before rejecting,
|
||||
* which lets a freshly-started backend race finish before the user sees an
|
||||
* error. Failures surface as `Error` instances whose `.message` is intended
|
||||
* to be visible (toast / banner) — callers must not swallow them silently.
|
||||
*/
|
||||
|
||||
let correlationCounter = 0;
|
||||
@@ -27,26 +32,59 @@ export interface RpcResponse<T = unknown> {
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/** Error subclass for RPC failures so callers can recognise them. */
|
||||
export class RpcError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code?: string,
|
||||
public readonly method?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "RpcError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Maximum number of automatic retries on transient WebSocket failure. */
|
||||
const MAX_RETRIES = 1;
|
||||
|
||||
/** Delay between retry attempts (ms). */
|
||||
const RETRY_DELAY_MS = 250;
|
||||
|
||||
/**
|
||||
* Send a read-RPC request over a temporary WebSocket connection and return
|
||||
* the result. Rejects if the server responds with `ok: false` or if the
|
||||
* connection times out.
|
||||
* Internal: a single one-shot RPC attempt. Resolves with the result or
|
||||
* rejects with an `RpcError`.
|
||||
*/
|
||||
export function rpcCall<T = unknown>(
|
||||
function rpcAttempt<T>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
timeoutMs = 5000,
|
||||
params: object,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const correlationId = nextCorrelationId();
|
||||
const ws = new WebSocket(buildWsUrl());
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(buildWsUrl());
|
||||
} catch (err) {
|
||||
reject(
|
||||
new RpcError(
|
||||
`Failed to open WebSocket for ${method}: ${(err as Error).message}`,
|
||||
"CONNECT_FAILED",
|
||||
method,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
ws.close();
|
||||
reject(new Error(`RPC timeout for ${method}`));
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
reject(new RpcError(`RPC timeout for ${method}`, "TIMEOUT", method));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
@@ -64,27 +102,68 @@ export function rpcCall<T = unknown>(
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
let data: unknown;
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
// Only process rpc_response frames matching our correlation ID.
|
||||
if (
|
||||
data.kind === "rpc_response" &&
|
||||
data.correlation_id === correlationId
|
||||
) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
if (data.ok) {
|
||||
resolve(data.result as T);
|
||||
} else {
|
||||
reject(
|
||||
new Error(data.error || `RPC error: ${data.code || "UNKNOWN"}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Ignore other messages (pipeline_state, onboarding_status, etc.)
|
||||
data = JSON.parse(event.data);
|
||||
} catch {
|
||||
// Ignore non-JSON or unparseable messages
|
||||
// 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,7 +171,13 @@ export function rpcCall<T = unknown>(
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`WebSocket error during RPC call to ${method}`));
|
||||
reject(
|
||||
new RpcError(
|
||||
`WebSocket error during RPC call to ${method}`,
|
||||
"CONNECT_FAILED",
|
||||
method,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,8 +185,54 @@ export function rpcCall<T = unknown>(
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`WebSocket closed before RPC response for ${method}`));
|
||||
reject(
|
||||
new RpcError(
|
||||
`WebSocket closed before RPC response for ${method}`,
|
||||
"CONNECT_FAILED",
|
||||
method,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Return true if the error is one we should retry (connection-level). */
|
||||
function isRetryable(err: unknown): boolean {
|
||||
return (
|
||||
err instanceof RpcError &&
|
||||
(err.code === "CONNECT_FAILED" || err.code === "TIMEOUT")
|
||||
);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a read-RPC request over a temporary WebSocket connection and return
|
||||
* the result. On transient connection failure the call is retried once
|
||||
* before rejecting. Rejects with [`RpcError`] on server-side errors,
|
||||
* timeouts, or persistent connection failures.
|
||||
*/
|
||||
export async function rpcCall<T = unknown>(
|
||||
method: string,
|
||||
params: object = {},
|
||||
timeoutMs = 5000,
|
||||
): Promise<T> {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await rpcAttempt<T>(method, params, timeoutMs);
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (attempt < MAX_RETRIES && isRetryable(err)) {
|
||||
await sleep(RETRY_DELAY_MS);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
// Unreachable but TypeScript can't prove it.
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
@@ -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,28 +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";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
});
|
||||
import { installRpcMock } from "./__test_utils__/mockRpcWebSocket";
|
||||
|
||||
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,
|
||||
@@ -38,52 +23,48 @@ const defaultProjectSettings: ProjectSettings = {
|
||||
|
||||
describe("settingsApi", () => {
|
||||
describe("getProjectSettings", () => {
|
||||
it("sends GET to /settings and returns project settings", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(defaultProjectSettings));
|
||||
it("dispatches settings.get_project RPC and returns project settings", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("settings.get_project", defaultProjectSettings);
|
||||
|
||||
const result = await settingsApi.getProjectSettings();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/settings",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(rpc.calls).toEqual([
|
||||
{ method: "settings.get_project", params: {} },
|
||||
]);
|
||||
expect(result).toEqual(defaultProjectSettings);
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(defaultProjectSettings));
|
||||
await settingsApi.getProjectSettings("http://localhost:4000/api");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:4000/api/settings",
|
||||
expect.anything(),
|
||||
it("surfaces RPC errors visibly", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respondError("settings.get_project", "no project open", "INTERNAL");
|
||||
|
||||
await expect(settingsApi.getProjectSettings()).rejects.toThrow(
|
||||
"no project open",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -95,107 +76,104 @@ describe("settingsApi", () => {
|
||||
});
|
||||
|
||||
describe("getEditorCommand", () => {
|
||||
it("sends GET to /settings/editor and returns editor settings", async () => {
|
||||
it("dispatches settings.get_editor RPC and returns editor settings", async () => {
|
||||
const rpc = installRpcMock();
|
||||
const expected = { editor_command: "zed" };
|
||||
mockFetch.mockResolvedValueOnce(okResponse(expected));
|
||||
rpc.respond("settings.get_editor", expected);
|
||||
|
||||
const result = await settingsApi.getEditorCommand();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/settings/editor",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(rpc.calls).toEqual([
|
||||
{ method: "settings.get_editor", params: {} },
|
||||
]);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns null editor_command when not configured", async () => {
|
||||
const expected = { editor_command: null };
|
||||
mockFetch.mockResolvedValueOnce(okResponse(expected));
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("settings.get_editor", { editor_command: null });
|
||||
|
||||
const result = await settingsApi.getEditorCommand();
|
||||
expect(result.editor_command).toBeNull();
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse({ editor_command: "code" }));
|
||||
|
||||
await settingsApi.getEditorCommand("http://localhost:4000/api");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:4000/api/settings/editor",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws with response body text on non-ok response", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(400, "Bad Request"));
|
||||
it("surfaces RPC errors for getEditorCommand", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respondError("settings.get_editor", "store unavailable", "INTERNAL");
|
||||
|
||||
await expect(settingsApi.getEditorCommand()).rejects.toThrow(
|
||||
"Bad Request",
|
||||
"store unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws with status code message when response body is empty", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
|
||||
|
||||
await expect(settingsApi.getEditorCommand()).rejects.toThrow(
|
||||
"Request failed (500)",
|
||||
);
|
||||
});
|
||||
|
||||
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",
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -19,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 requestJson<ProjectSettings>("/settings", {}, baseUrl);
|
||||
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 requestJson<EditorSettings>("/settings/editor", {}, baseUrl);
|
||||
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 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -277,7 +277,6 @@ describe("Slash command handling (Story 374)", () => {
|
||||
expect(mockedApi.botCommand).toHaveBeenCalledWith(
|
||||
"status",
|
||||
"",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText("Pipeline: 3 active")).toBeInTheDocument();
|
||||
@@ -302,7 +301,6 @@ describe("Slash command handling (Story 374)", () => {
|
||||
expect(mockedApi.botCommand).toHaveBeenCalledWith(
|
||||
"status",
|
||||
"42",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -324,7 +322,6 @@ describe("Slash command handling (Story 374)", () => {
|
||||
expect(mockedApi.botCommand).toHaveBeenCalledWith(
|
||||
"start",
|
||||
"42 opus",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText("Started agent")).toBeInTheDocument();
|
||||
@@ -348,7 +345,7 @@ describe("Slash command handling (Story 374)", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.botCommand).toHaveBeenCalledWith("git", "", undefined);
|
||||
expect(mockedApi.botCommand).toHaveBeenCalledWith("git", "");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -370,7 +367,7 @@ describe("Slash command handling (Story 374)", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.botCommand).toHaveBeenCalledWith("cost", "", undefined);
|
||||
expect(mockedApi.botCommand).toHaveBeenCalledWith("cost", "");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -446,7 +443,7 @@ describe("Slash command handling (Story 374)", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.botCommand).toHaveBeenCalledWith("help", "", undefined);
|
||||
expect(mockedApi.botCommand).toHaveBeenCalledWith("help", "");
|
||||
});
|
||||
expect(lastSendChatArgs).toBeNull();
|
||||
});
|
||||
@@ -474,13 +471,13 @@ describe("Slash command handling (Story 374)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bug 450: WebSocket error messages displayed in chat", () => {
|
||||
describe("Story 1058: WebSocket errors do not appear in chat", () => {
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it("AC1: WebSocket error message is shown in chat as an assistant message", async () => {
|
||||
it("does not add a chat message when onError is called", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
@@ -490,11 +487,11 @@ describe("Bug 450: WebSocket error messages displayed in chat", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText("Something went wrong on the server."),
|
||||
).toBeInTheDocument();
|
||||
screen.queryByText("Something went wrong on the server."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("AC2: OAuth login URL in WebSocket error is rendered as a clickable link", async () => {
|
||||
it("does not add a chat message for errors containing a URL", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
@@ -505,10 +502,10 @@ describe("Bug 450: WebSocket error messages displayed in chat", () => {
|
||||
);
|
||||
});
|
||||
|
||||
const link = await screen.findByRole("link", {
|
||||
name: /https:\/\/example\.com\/oauth\/login/,
|
||||
});
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", "https://example.com/oauth/login");
|
||||
expect(
|
||||
screen.queryByRole("link", {
|
||||
name: /https:\/\/example\.com\/oauth\/login/,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,6 +84,8 @@ export function Chat({
|
||||
const {
|
||||
wsRef,
|
||||
wsConnected,
|
||||
wsConnectivity,
|
||||
wsDisconnectedAt,
|
||||
streamingContent,
|
||||
setStreamingContent,
|
||||
streamingThinking,
|
||||
@@ -376,6 +378,8 @@ export function Chat({
|
||||
enableTools={enableTools}
|
||||
onToggleTools={setEnableTools}
|
||||
wsConnected={wsConnected}
|
||||
wsConnectivity={wsConnectivity}
|
||||
wsDisconnectedAt={wsDisconnectedAt}
|
||||
oauthStatus={oauthStatus}
|
||||
onShowBotConfig={() => setView("bot-config")}
|
||||
onShowSettings={() => setView("settings")}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { WsConnectivity } from "../hooks/useChatWebSocket";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
|
||||
vi.mock("../api/client", () => ({
|
||||
@@ -21,6 +22,8 @@ interface ChatHeaderProps {
|
||||
enableTools: boolean;
|
||||
onToggleTools: (enabled: boolean) => void;
|
||||
wsConnected: boolean;
|
||||
wsConnectivity?: WsConnectivity;
|
||||
wsDisconnectedAt?: Date | null;
|
||||
}
|
||||
|
||||
function makeProps(overrides: Partial<ChatHeaderProps> = {}): ChatHeaderProps {
|
||||
@@ -289,6 +292,53 @@ describe("ChatHeader", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Connectivity indicator ────────────────────────────────────────────────
|
||||
|
||||
it("does not render connectivity dot when wsConnectivity is not provided", () => {
|
||||
render(<ChatHeader {...makeProps()} />);
|
||||
expect(screen.queryByTestId("ws-connectivity-dot")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders green dot with title 'Connected' when connected", () => {
|
||||
render(<ChatHeader {...makeProps({ wsConnectivity: "connected" })} />);
|
||||
const dot = screen.getByTestId("ws-connectivity-dot");
|
||||
expect(dot).toBeInTheDocument();
|
||||
expect(dot).toHaveAttribute("title", "Connected");
|
||||
expect(dot.style.backgroundColor).toBe("rgb(76, 175, 80)");
|
||||
});
|
||||
|
||||
it("renders amber dot with title 'Reconnecting…' when reconnecting", () => {
|
||||
render(<ChatHeader {...makeProps({ wsConnectivity: "reconnecting" })} />);
|
||||
const dot = screen.getByTestId("ws-connectivity-dot");
|
||||
expect(dot).toHaveAttribute("title", "Reconnecting…");
|
||||
expect(dot.style.backgroundColor).toBe("rgb(245, 166, 35)");
|
||||
});
|
||||
|
||||
it("renders amber dot with title 'Connecting…' when connecting", () => {
|
||||
render(<ChatHeader {...makeProps({ wsConnectivity: "connecting" })} />);
|
||||
const dot = screen.getByTestId("ws-connectivity-dot");
|
||||
expect(dot).toHaveAttribute("title", "Connecting…");
|
||||
expect(dot.style.backgroundColor).toBe("rgb(245, 166, 35)");
|
||||
});
|
||||
|
||||
it("renders red dot with title 'Disconnected' when failed with no timestamp", () => {
|
||||
render(<ChatHeader {...makeProps({ wsConnectivity: "failed" })} />);
|
||||
const dot = screen.getByTestId("ws-connectivity-dot");
|
||||
expect(dot).toHaveAttribute("title", "Disconnected");
|
||||
expect(dot.style.backgroundColor).toBe("rgb(229, 57, 53)");
|
||||
});
|
||||
|
||||
it("renders red dot with 'Disconnected since HH:MM' when failed with timestamp", () => {
|
||||
const disconnectedAt = new Date("2026-05-14T14:30:00");
|
||||
render(
|
||||
<ChatHeader
|
||||
{...makeProps({ wsConnectivity: "failed", wsDisconnectedAt: disconnectedAt })}
|
||||
/>,
|
||||
);
|
||||
const dot = screen.getByTestId("ws-connectivity-dot");
|
||||
expect(dot.getAttribute("title")).toMatch(/Disconnected since/);
|
||||
});
|
||||
|
||||
it("clears reconnecting state when wsConnected transitions to true", async () => {
|
||||
const { api } = await import("../api/client");
|
||||
vi.mocked(api.rebuildAndRestart).mockRejectedValue(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import type { OAuthStatus } from "../api/client";
|
||||
import { api } from "../api/client";
|
||||
import type { WsConnectivity } from "../hooks/useChatWebSocket";
|
||||
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
@@ -33,6 +34,8 @@ interface ChatHeaderProps {
|
||||
enableTools: boolean;
|
||||
onToggleTools: (enabled: boolean) => void;
|
||||
wsConnected: boolean;
|
||||
wsConnectivity?: WsConnectivity;
|
||||
wsDisconnectedAt?: Date | null;
|
||||
oauthStatus?: OAuthStatus | null;
|
||||
onShowBotConfig?: () => void;
|
||||
onShowSettings?: () => void;
|
||||
@@ -59,6 +62,8 @@ export function ChatHeader({
|
||||
enableTools,
|
||||
onToggleTools,
|
||||
wsConnected,
|
||||
wsConnectivity,
|
||||
wsDisconnectedAt,
|
||||
oauthStatus = null,
|
||||
onShowBotConfig,
|
||||
onShowSettings,
|
||||
@@ -117,6 +122,28 @@ export function ChatHeader({
|
||||
const rebuildButtonDisabled =
|
||||
rebuildStatus === "building" || rebuildStatus === "reconnecting";
|
||||
|
||||
const connectivityDotColor =
|
||||
wsConnectivity === "connected"
|
||||
? "#4caf50"
|
||||
: wsConnectivity === "failed"
|
||||
? "#e53935"
|
||||
: wsConnectivity !== undefined
|
||||
? "#f5a623"
|
||||
: undefined;
|
||||
|
||||
const connectivityTitle =
|
||||
wsConnectivity === "connected"
|
||||
? "Connected"
|
||||
: wsConnectivity === "reconnecting"
|
||||
? "Reconnecting…"
|
||||
: wsConnectivity === "failed"
|
||||
? wsDisconnectedAt
|
||||
? `Disconnected since ${wsDisconnectedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`
|
||||
: "Disconnected"
|
||||
: wsConnectivity === "connecting"
|
||||
? "Connecting…"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Confirmation dialog overlay */}
|
||||
@@ -347,6 +374,20 @@ export function ChatHeader({
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
|
||||
{connectivityDotColor !== undefined && (
|
||||
<div
|
||||
data-testid="ws-connectivity-dot"
|
||||
title={connectivityTitle}
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: connectivityDotColor,
|
||||
flexShrink: 0,
|
||||
cursor: "default",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{oauthStatus !== null &&
|
||||
(!oauthStatus.authenticated || oauthStatus.expired) && (
|
||||
<button
|
||||
|
||||
@@ -15,7 +15,7 @@ import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
|
||||
* This conversion happens at render time, not at the WebSocket boundary,
|
||||
* so the original StatusEvent structure is preserved in state. */
|
||||
function formatStatusEventMessage(event: StatusEvent): string {
|
||||
const name = event.story_name ?? event.story_id;
|
||||
const name = event.story_name || event.story_id;
|
||||
switch (event.type) {
|
||||
case "stage_transition":
|
||||
return `${name} — ${event.from_stage} → ${event.to_stage}`;
|
||||
@@ -133,6 +133,7 @@ export function ChatPipelinePanel({
|
||||
onStopAgent={onStopAgent}
|
||||
onDeleteItem={onDeleteItem}
|
||||
mergesInFlight={mergesInFlight}
|
||||
isMergeStage
|
||||
/>
|
||||
<StagePanel
|
||||
title="QA"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/** React error boundary that catches render-time exceptions and shows a
|
||||
* recoverable error UI instead of a white screen. */
|
||||
import * as React from "react";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/** Catches uncaught render exceptions in its subtree and displays a message. */
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100vh",
|
||||
background: "#0d1117",
|
||||
color: "#e6edf3",
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
gap: "16px",
|
||||
padding: "32px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "2em" }}>⚠</div>
|
||||
<div style={{ fontWeight: 600, fontSize: "1.1em" }}>
|
||||
Something went wrong
|
||||
</div>
|
||||
<div style={{ color: "#8b949e", fontSize: "0.9em", maxWidth: "480px" }}>
|
||||
{this.state.error.message}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.handleReset}
|
||||
style={{
|
||||
padding: "8px 18px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #30363d",
|
||||
background: "#21262d",
|
||||
color: "#e6edf3",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9em",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/** Tests for GatewayPanel — verifies story id and name rendering in the gateway aggregate view. */
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PipelineItem } from "../api/gateway";
|
||||
import { StoryRow } from "./GatewayPanel";
|
||||
|
||||
describe("StoryRow", () => {
|
||||
it("renders #id prefix before the story name", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "42_story_add_feature",
|
||||
name: "Add Feature",
|
||||
stage: "current",
|
||||
};
|
||||
const { container } = render(<StoryRow item={item} />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders #id prefix for a backlogged story", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "7_bug_fix_crash",
|
||||
name: "Fix crash on startup",
|
||||
stage: "qa",
|
||||
};
|
||||
const { container } = render(<StoryRow item={item} />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders awaiting-slot badge for merge item with no agent", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "no-number-id",
|
||||
name: "Mystery Story",
|
||||
stage: "merge",
|
||||
};
|
||||
const { container } = render(<StoryRow item={item} />);
|
||||
expect(container).toMatchSnapshot();
|
||||
expect(screen.getByText("awaiting-slot")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// AC1: active mergemaster is visually distinct
|
||||
it("shows MERGING badge for merge item with running mergemaster (active)", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "70_story_merging_active",
|
||||
name: "Merging Active",
|
||||
stage: "merge",
|
||||
agent: { agent_name: "mergemaster", model: "claude", status: "running" },
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("▶ MERGING")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// AC2: awaiting-slot with queue position labels
|
||||
it("shows NEXT IN QUEUE for first awaiting-slot merge item", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "71_story_next_in_queue",
|
||||
name: "Next in Queue",
|
||||
stage: "merge",
|
||||
};
|
||||
render(<StoryRow item={item} mergeQueuePos={1} />);
|
||||
expect(screen.getByText("NEXT IN QUEUE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows awaiting-slot with position for subsequent queued merge items", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "72_story_second_in_queue",
|
||||
name: "Second in Queue",
|
||||
stage: "merge",
|
||||
};
|
||||
render(<StoryRow item={item} mergeQueuePos={2} />);
|
||||
expect(screen.getByText("awaiting-slot (#2)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// AC2: failure kind labels derived from merge_failure string
|
||||
it("shows ConflictDetected for merge_failure with conflict text", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "73_story_conflict",
|
||||
name: "Conflict Story",
|
||||
stage: "merge",
|
||||
blocked: true,
|
||||
merge_failure: "Merge conflict: conflicts detected",
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("ConflictDetected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows GatesFailed for merge_failure with quality gates text", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "74_story_gates",
|
||||
name: "Gates Failed Story",
|
||||
stage: "merge",
|
||||
blocked: true,
|
||||
merge_failure: "Quality gates failed: cargo test failed",
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("GatesFailed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows RECOVERING badge for merge_failure item with running mergemaster", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "60_story_merge_recovering",
|
||||
name: "Merge Recovering",
|
||||
stage: "merge",
|
||||
merge_failure: "Squash merge failed",
|
||||
agent: { agent_name: "mergemaster", model: "claude", status: "running" },
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("⟳ RECOVERING")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows QUEUED badge for merge_failure item with pending mergemaster", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "61_story_merge_queued",
|
||||
name: "Merge Queued",
|
||||
stage: "merge",
|
||||
merge_failure: "Squash merge failed",
|
||||
agent: { agent_name: "mergemaster", model: "claude", status: "pending" },
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("⏳ QUEUED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows FAILED badge for merge_failure item with no recovery agent", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "62_story_merge_final",
|
||||
name: "Merge Final",
|
||||
stage: "merge",
|
||||
merge_failure: "Squash merge failed",
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("✕ FAILED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows RECOVERING badge for blocked item with running recovery agent", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "63_story_blocked_recovering",
|
||||
name: "Blocked Recovering",
|
||||
stage: "current",
|
||||
blocked: true,
|
||||
agent: { agent_name: "coder", model: "claude", status: "running" },
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("⟳ RECOVERING")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows QUEUED badge for blocked item with pending recovery agent", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "64_story_blocked_queued",
|
||||
name: "Blocked Queued",
|
||||
stage: "current",
|
||||
blocked: true,
|
||||
agent: { agent_name: "coder", model: "claude", status: "pending" },
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("⏳ QUEUED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows BLOCKED badge for blocked item with no recovery agent", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "65_story_blocked_human",
|
||||
name: "Blocked Human",
|
||||
stage: "current",
|
||||
blocked: true,
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("⊘ BLOCKED")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -49,23 +49,85 @@ const STATUS_LABELS: Record<AgentStatus, string> = {
|
||||
};
|
||||
|
||||
const STAGE_COLORS: Record<string, string> = {
|
||||
backlog: "#8b949e",
|
||||
current: "#3fb950",
|
||||
qa: "#d2a679",
|
||||
merge: "#79c0ff",
|
||||
done: "#6e7681",
|
||||
archived: "#6e7681",
|
||||
};
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
backlog: "Backlog",
|
||||
current: "In Progress",
|
||||
qa: "QA",
|
||||
merge: "Merging",
|
||||
done: "Done",
|
||||
archived: "Archived",
|
||||
};
|
||||
|
||||
/// Derive a short label from a merge failure string based on the failure kind.
|
||||
function mergeFailureKindLabel(failure: string): string {
|
||||
if (failure.includes("Merge conflict") || failure.includes("CONFLICT")) {
|
||||
return "ConflictDetected";
|
||||
}
|
||||
if (failure.includes("Quality gates failed") || failure.includes("gates failed")) {
|
||||
return "GatesFailed";
|
||||
}
|
||||
if (failure.includes("no code changes") || failure.includes("empty diff")) {
|
||||
return "EmptyDiff";
|
||||
}
|
||||
if (failure.includes("No commits")) {
|
||||
return "NoCommits";
|
||||
}
|
||||
return "✕ FAILED";
|
||||
}
|
||||
|
||||
/// A single story row inside a project pipeline card.
|
||||
function StoryRow({ item }: { item: PipelineItem }) {
|
||||
const color = STAGE_COLORS[item.stage] ?? "#8b949e";
|
||||
const label = STAGE_LABELS[item.stage] ?? item.stage;
|
||||
/** Render one story row in a gateway-aggregate panel: `#<id> <name>` with stage badge. */
|
||||
export function StoryRow({ item, mergeQueuePos }: { item: PipelineItem; mergeQueuePos?: number }) {
|
||||
const isStuck = item.merge_failure != null || item.blocked;
|
||||
const isMergeActive = item.stage === "merge" && !isStuck && item.agent?.status === "running";
|
||||
|
||||
let color: string;
|
||||
let label: string;
|
||||
|
||||
if (isMergeActive) {
|
||||
color = "#58a6ff";
|
||||
label = "▶ MERGING";
|
||||
} else if (isStuck) {
|
||||
const agentStatus = item.agent?.status;
|
||||
if (agentStatus === "running") {
|
||||
color = "#e3b341";
|
||||
label = "⟳ RECOVERING";
|
||||
} else if (agentStatus === "pending") {
|
||||
color = "#e3b341";
|
||||
label = "⏳ QUEUED";
|
||||
} else if (item.merge_failure != null) {
|
||||
color = "#f85149";
|
||||
label = mergeFailureKindLabel(item.merge_failure);
|
||||
} else {
|
||||
color = "#f85149";
|
||||
label = "⊘ BLOCKED";
|
||||
}
|
||||
} else if (item.stage === "merge" && item.agent?.status === "pending") {
|
||||
color = "#e3b341";
|
||||
label = "⏳ QUEUED";
|
||||
} else if (item.stage === "merge") {
|
||||
color = "#6e7681";
|
||||
if (mergeQueuePos === 1) {
|
||||
label = "NEXT IN QUEUE";
|
||||
} else if (mergeQueuePos != null) {
|
||||
label = `awaiting-slot (#${mergeQueuePos})`;
|
||||
} else {
|
||||
label = "awaiting-slot";
|
||||
}
|
||||
} else {
|
||||
color = STAGE_COLORS[item.stage] ?? "#8b949e";
|
||||
label = STAGE_LABELS[item.stage] ?? item.stage;
|
||||
}
|
||||
|
||||
const idNum = item.story_id.match(/^(\d+)/)?.[1];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -75,6 +137,10 @@ function StoryRow({ item }: { item: PipelineItem }) {
|
||||
gap: "8px",
|
||||
padding: "4px 0",
|
||||
fontSize: "0.82em",
|
||||
background: isMergeActive ? "#58a6ff0a" : undefined,
|
||||
borderRadius: isMergeActive ? "4px" : undefined,
|
||||
paddingLeft: isMergeActive ? "4px" : undefined,
|
||||
paddingRight: isMergeActive ? "4px" : undefined,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
@@ -91,83 +157,13 @@ function StoryRow({ item }: { item: PipelineItem }) {
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ color: "#e6edf3", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{idNum && <span style={{ color: "#8b949e", fontFamily: "monospace" }}>#{idNum}{" "}</span>}
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/// Pipeline status card for a single project.
|
||||
function ProjectPipelineCard({
|
||||
name,
|
||||
pipeline,
|
||||
isActive,
|
||||
onSwitch,
|
||||
}: {
|
||||
name: string;
|
||||
pipeline: AllProjectsPipeline["projects"][string];
|
||||
isActive: boolean;
|
||||
onSwitch: (name: string) => void;
|
||||
}) {
|
||||
const activeItems = pipeline.active ?? [];
|
||||
const backlogCount = pipeline.backlog_count ?? 0;
|
||||
const hasError = Boolean(pipeline.error);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`pipeline-card-${name}`}
|
||||
onClick={() => onSwitch(name)}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
background: "#161b22",
|
||||
border: `1px solid ${isActive ? "#238636" : "#30363d"}`,
|
||||
borderRadius: "8px",
|
||||
marginBottom: "8px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
marginBottom: activeItems.length > 0 ? "8px" : 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, color: "#e6edf3" }}>{name}</span>
|
||||
{isActive && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7em",
|
||||
padding: "1px 6px",
|
||||
borderRadius: "10px",
|
||||
background: "#23863622",
|
||||
color: "#3fb950",
|
||||
border: "1px solid #23863644",
|
||||
}}
|
||||
>
|
||||
active
|
||||
</span>
|
||||
)}
|
||||
<span style={{ marginLeft: "auto", fontSize: "0.75em", color: "#6e7681" }}>
|
||||
{backlogCount > 0 ? `${backlogCount} in backlog` : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasError ? (
|
||||
<div style={{ fontSize: "0.8em", color: "#f85149" }}>{pipeline.error}</div>
|
||||
) : activeItems.length === 0 ? (
|
||||
<div style={{ fontSize: "0.8em", color: "#6e7681" }}>No active stories</div>
|
||||
) : (
|
||||
<div>
|
||||
{activeItems.map((item) => (
|
||||
<StoryRow key={item.story_id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenDisplay({ token }: { token: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -359,6 +355,291 @@ function AgentRow({
|
||||
);
|
||||
}
|
||||
|
||||
type TabKey = "backlog" | "in-progress" | "done" | "archived";
|
||||
|
||||
const TAB_STORAGE_KEY = "gateway_selected_tab";
|
||||
|
||||
/// Read the persisted tab from localStorage, defaulting to "in-progress".
|
||||
function readStoredTab(): TabKey {
|
||||
const stored = localStorage.getItem(TAB_STORAGE_KEY);
|
||||
if (
|
||||
stored === "backlog" ||
|
||||
stored === "in-progress" ||
|
||||
stored === "done" ||
|
||||
stored === "archived"
|
||||
) {
|
||||
return stored;
|
||||
}
|
||||
return "in-progress";
|
||||
}
|
||||
|
||||
/// Aggregate pipeline items from all projects for a given tab.
|
||||
function aggregateItems(
|
||||
pipeline: AllProjectsPipeline,
|
||||
tab: TabKey,
|
||||
): { project: string; items: PipelineItem[] }[] {
|
||||
return Object.entries(pipeline.projects)
|
||||
.map(([project, status]) => {
|
||||
if (status.error) return { project, items: [] };
|
||||
if (tab === "backlog") {
|
||||
return {
|
||||
project,
|
||||
items: (status.backlog ?? []).map((b) => ({
|
||||
story_id: b.story_id,
|
||||
name: b.name,
|
||||
stage: "backlog",
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (tab === "in-progress") {
|
||||
return {
|
||||
project,
|
||||
items: (status.active ?? []).filter(
|
||||
(i) => i.stage !== "done",
|
||||
),
|
||||
};
|
||||
}
|
||||
if (tab === "done") {
|
||||
return {
|
||||
project,
|
||||
items: (status.active ?? []).filter((i) => i.stage === "done"),
|
||||
};
|
||||
}
|
||||
// archived
|
||||
return { project, items: status.archived ?? [] };
|
||||
})
|
||||
.filter((g) => g.items.length > 0);
|
||||
}
|
||||
|
||||
/// Count total items across all projects for a given tab.
|
||||
function tabCount(pipeline: AllProjectsPipeline, tab: TabKey): number {
|
||||
return Object.values(pipeline.projects).reduce((sum, status) => {
|
||||
if (status.error) return sum;
|
||||
if (tab === "backlog") return sum + (status.backlog_count ?? 0);
|
||||
if (tab === "in-progress") {
|
||||
return (
|
||||
sum +
|
||||
(status.active ?? []).filter((i) => i.stage !== "done").length
|
||||
);
|
||||
}
|
||||
if (tab === "done") {
|
||||
return (
|
||||
sum + (status.active ?? []).filter((i) => i.stage === "done").length
|
||||
);
|
||||
}
|
||||
return sum + (status.archived ?? []).length;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/// Tab bar button.
|
||||
function TabButton({
|
||||
label,
|
||||
count,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderRadius: "6px 6px 0 0",
|
||||
border: "1px solid",
|
||||
borderColor: active ? "#30363d" : "transparent",
|
||||
borderBottomColor: active ? "#0d1117" : "transparent",
|
||||
background: active ? "#0d1117" : "none",
|
||||
color: active ? "#e6edf3" : "#8b949e",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9em",
|
||||
fontWeight: active ? 600 : 400,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{count > 0 && (
|
||||
<span
|
||||
style={{
|
||||
padding: "1px 6px",
|
||||
borderRadius: "10px",
|
||||
background: active ? "#21262d" : "#161b22",
|
||||
color: active ? "#e6edf3" : "#6e7681",
|
||||
fontSize: "0.8em",
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/// A project-labelled story row used in the aggregate tab view.
|
||||
function ProjectStoryRow({
|
||||
project,
|
||||
item,
|
||||
showProject,
|
||||
mergeQueuePos,
|
||||
}: {
|
||||
project: string;
|
||||
item: PipelineItem;
|
||||
showProject: boolean;
|
||||
mergeQueuePos?: number;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
{showProject && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
padding: "1px 6px",
|
||||
borderRadius: "10px",
|
||||
background: "#161b22",
|
||||
color: "#8b949e",
|
||||
border: "1px solid #30363d",
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{project}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<StoryRow item={item} mergeQueuePos={mergeQueuePos} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const IN_PROGRESS_STAGE_LABELS: Record<string, string> = {
|
||||
current: "Coding",
|
||||
qa: "QA",
|
||||
merge: "Merging",
|
||||
};
|
||||
|
||||
/// In Progress tab content — items grouped by stage (coding / qa / merging).
|
||||
function InProgressTabContent({
|
||||
groups,
|
||||
}: {
|
||||
groups: { project: string; items: PipelineItem[] }[];
|
||||
}) {
|
||||
const allItems = groups.flatMap((g) =>
|
||||
g.items.map((item) => ({ project: g.project, item })),
|
||||
);
|
||||
const multiProject = new Set(allItems.map((x) => x.project)).size > 1;
|
||||
|
||||
const byStage = {
|
||||
current: allItems.filter((x) => x.item.stage === "current"),
|
||||
qa: allItems.filter((x) => x.item.stage === "qa"),
|
||||
merge: allItems.filter((x) => x.item.stage === "merge"),
|
||||
};
|
||||
|
||||
const stages = (["current", "qa", "merge"] as const).filter(
|
||||
(s) => byStage[s].length > 0,
|
||||
);
|
||||
|
||||
// Compute queue position among clean awaiting merge items (Stage::Merge, no failure, no running agent).
|
||||
const mergeQueuePosMap = new Map<string, number>();
|
||||
let queuePos = 0;
|
||||
for (const { project, item } of byStage.merge) {
|
||||
if (
|
||||
!item.blocked &&
|
||||
!item.merge_failure &&
|
||||
item.agent?.status !== "running"
|
||||
) {
|
||||
queuePos += 1;
|
||||
mergeQueuePosMap.set(`${project}:${item.story_id}`, queuePos);
|
||||
}
|
||||
}
|
||||
|
||||
if (allItems.length === 0) {
|
||||
return (
|
||||
<p style={{ color: "#6e7681", padding: "16px 0" }}>
|
||||
No items in progress.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{stages.map((stage) => (
|
||||
<div key={stage} style={{ marginBottom: "20px" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.8em",
|
||||
fontWeight: 600,
|
||||
color: STAGE_COLORS[stage] ?? "#8b949e",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
marginBottom: "8px",
|
||||
paddingBottom: "4px",
|
||||
borderBottom: `1px solid ${STAGE_COLORS[stage] ?? "#8b949e"}33`,
|
||||
}}
|
||||
>
|
||||
{IN_PROGRESS_STAGE_LABELS[stage]}{" "}
|
||||
<span style={{ color: "#6e7681" }}>
|
||||
({byStage[stage].length})
|
||||
</span>
|
||||
</div>
|
||||
{byStage[stage].map(({ project, item }) => (
|
||||
<ProjectStoryRow
|
||||
key={`${project}:${item.story_id}`}
|
||||
project={project}
|
||||
item={item}
|
||||
showProject={multiProject}
|
||||
mergeQueuePos={
|
||||
stage === "merge"
|
||||
? mergeQueuePosMap.get(`${project}:${item.story_id}`)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/// Flat list tab content for Backlog, Done, and Archived.
|
||||
function FlatTabContent({
|
||||
groups,
|
||||
emptyMessage,
|
||||
}: {
|
||||
groups: { project: string; items: PipelineItem[] }[];
|
||||
emptyMessage: string;
|
||||
}) {
|
||||
const allItems = groups.flatMap((g) =>
|
||||
g.items.map((item) => ({ project: g.project, item })),
|
||||
);
|
||||
const multiProject = new Set(allItems.map((x) => x.project)).size > 1;
|
||||
|
||||
if (allItems.length === 0) {
|
||||
return (
|
||||
<p style={{ color: "#6e7681", padding: "16px 0" }}>{emptyMessage}</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{allItems.map(({ project, item }) => (
|
||||
<ProjectStoryRow
|
||||
key={`${project}:${item.story_id}`}
|
||||
project={project}
|
||||
item={item}
|
||||
showProject={multiProject}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/// Gateway management panel — rendered when running in `--gateway` mode.
|
||||
export function GatewayPanel() {
|
||||
const [agents, setAgents] = useState<JoinedAgent[]>([]);
|
||||
@@ -367,6 +648,7 @@ export function GatewayPanel() {
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pipeline, setPipeline] = useState<AllProjectsPipeline | null>(null);
|
||||
const [selectedTab, setSelectedTab] = useState<TabKey>(readStoredTab);
|
||||
|
||||
// Keep stable refs so polling intervals don't recreate on state changes.
|
||||
const setAgentsRef = useRef(setAgents);
|
||||
@@ -442,20 +724,9 @@ export function GatewayPanel() {
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSwitchProject = useCallback(async (name: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
const result = await gatewayApi.switchProject(name);
|
||||
if (!result.ok) {
|
||||
setError(result.error ?? "Failed to switch project");
|
||||
return;
|
||||
}
|
||||
// Refresh pipeline to reflect new active project.
|
||||
const updated = await gatewayApi.getAllProjectsPipeline();
|
||||
setPipeline(updated);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
const handleSelectTab = useCallback((tab: TabKey) => {
|
||||
setSelectedTab(tab);
|
||||
localStorage.setItem(TAB_STORAGE_KEY, tab);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -477,29 +748,62 @@ export function GatewayPanel() {
|
||||
Manage build agents connected to this gateway.
|
||||
</p>
|
||||
|
||||
{/* Cross-project pipeline status */}
|
||||
{/* Cross-project pipeline tabs */}
|
||||
<section style={{ marginBottom: "32px" }}>
|
||||
<h2
|
||||
{/* Tab bar */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "1.1em",
|
||||
fontWeight: 600,
|
||||
marginBottom: "12px",
|
||||
borderBottom: "1px solid #21262d",
|
||||
paddingBottom: "8px",
|
||||
display: "flex",
|
||||
gap: "2px",
|
||||
borderBottom: "1px solid #30363d",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
Pipeline Status
|
||||
</h2>
|
||||
{pipeline ? (
|
||||
Object.entries(pipeline.projects).map(([name, status]) => (
|
||||
<ProjectPipelineCard
|
||||
key={name}
|
||||
name={name}
|
||||
pipeline={status}
|
||||
isActive={name === pipeline.active}
|
||||
onSwitch={handleSwitchProject}
|
||||
{(
|
||||
[
|
||||
{ key: "backlog", label: "Backlog" },
|
||||
{ key: "in-progress", label: "In Progress" },
|
||||
{ key: "done", label: "Done" },
|
||||
{ key: "archived", label: "Archived" },
|
||||
] as { key: TabKey; label: string }[]
|
||||
).map(({ key, label }) => (
|
||||
<TabButton
|
||||
key={key}
|
||||
label={label}
|
||||
count={pipeline ? tabCount(pipeline, key) : 0}
|
||||
active={selectedTab === key}
|
||||
onClick={() => handleSelectTab(key)}
|
||||
/>
|
||||
))
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{pipeline ? (
|
||||
<>
|
||||
{selectedTab === "backlog" && (
|
||||
<FlatTabContent
|
||||
groups={aggregateItems(pipeline, "backlog")}
|
||||
emptyMessage="No items in backlog."
|
||||
/>
|
||||
)}
|
||||
{selectedTab === "in-progress" && (
|
||||
<InProgressTabContent
|
||||
groups={aggregateItems(pipeline, "in-progress")}
|
||||
/>
|
||||
)}
|
||||
{selectedTab === "done" && (
|
||||
<FlatTabContent
|
||||
groups={aggregateItems(pipeline, "done")}
|
||||
emptyMessage="No completed items."
|
||||
/>
|
||||
)}
|
||||
{selectedTab === "archived" && (
|
||||
<FlatTabContent
|
||||
groups={aggregateItems(pipeline, "archived")}
|
||||
emptyMessage="No archived items."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p style={{ color: "#6e7681" }}>Loading pipeline status…</p>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { WizardStateData, WizardStepInfo } from "../api/client";
|
||||
|
||||
const API_BASE = "/api";
|
||||
import { rpcCall } from "../api/rpc";
|
||||
|
||||
interface SetupWizardProps {
|
||||
wizardState: WizardStateData;
|
||||
@@ -50,27 +49,17 @@ function stepBorder(status: string, isActive: boolean): string {
|
||||
/** Messages sent to the chat to trigger agent generation for each step. */
|
||||
const STEP_PROMPTS: Record<string, string> = {
|
||||
context:
|
||||
"Read the codebase and generate .huskies/specs/00_CONTEXT.md with a project context spec. Include High-Level Goal, Core Features, Domain Definition, and Glossary sections. Then call the wizard API to store the content: PUT /api/wizard/step/context/content",
|
||||
"Read the codebase and generate .huskies/specs/00_CONTEXT.md with a project context spec. Include High-Level Goal, Core Features, Domain Definition, and Glossary sections. Then call the wizard MCP tool `wizard_generate` with step=context to store the content.",
|
||||
stack:
|
||||
"Read the tech stack and generate .huskies/specs/tech/STACK.md with a tech stack spec. Include Core Stack, Coding Standards, Quality Gates, and Libraries sections. Then call the wizard API to store the content: PUT /api/wizard/step/stack/content",
|
||||
"Read the tech stack and generate .huskies/specs/tech/STACK.md with a tech stack spec. Include Core Stack, Coding Standards, Quality Gates, and Libraries sections. Then call the wizard MCP tool `wizard_generate` with step=stack to store the content.",
|
||||
test_script:
|
||||
"Read the project structure and create script/test — a bash script that runs the project's actual test suite. Then call the wizard API: PUT /api/wizard/step/test_script/content",
|
||||
"Read the project structure and create script/test — a bash script that runs the project's actual test suite. Then call the wizard MCP tool `wizard_generate` with step=test_script to store the content.",
|
||||
release_script:
|
||||
"Read the project's deployment setup and create script/release tailored to the project. Then call the wizard API: PUT /api/wizard/step/release_script/content",
|
||||
"Read the project's deployment setup and create script/release tailored to the project. Then call the wizard MCP tool `wizard_generate` with step=release_script to store the content.",
|
||||
test_coverage:
|
||||
"If the stack supports coverage reporting, create script/test_coverage. Then call the wizard API: PUT /api/wizard/step/test_coverage/content",
|
||||
"If the stack supports coverage reporting, create script/test_coverage. Then call the wizard MCP tool `wizard_generate` with step=test_coverage to store the content.",
|
||||
};
|
||||
|
||||
async function apiPost(path: string): Promise<WizardStateData | null> {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}${path}`, { method: "POST" });
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as WizardStateData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function StepCard({
|
||||
step,
|
||||
isActive,
|
||||
@@ -272,10 +261,14 @@ export default function SetupWizard({
|
||||
|
||||
const handleConfirm = useCallback(
|
||||
async (step: WizardStepInfo) => {
|
||||
const result = await apiPost(`/wizard/step/${step.step}/confirm`);
|
||||
if (result) {
|
||||
try {
|
||||
const result = await rpcCall<WizardStateData>("wizard.confirm_step", {
|
||||
step: step.step,
|
||||
});
|
||||
onWizardUpdate(result);
|
||||
setRefreshKey((k) => k + 1);
|
||||
} catch {
|
||||
// ignore — state remains unchanged
|
||||
}
|
||||
},
|
||||
[onWizardUpdate],
|
||||
@@ -283,10 +276,14 @@ export default function SetupWizard({
|
||||
|
||||
const handleSkip = useCallback(
|
||||
async (step: WizardStepInfo) => {
|
||||
const result = await apiPost(`/wizard/step/${step.step}/skip`);
|
||||
if (result) {
|
||||
try {
|
||||
const result = await rpcCall<WizardStateData>("wizard.skip_step", {
|
||||
step: step.step,
|
||||
});
|
||||
onWizardUpdate(result);
|
||||
setRefreshKey((k) => k + 1);
|
||||
} catch {
|
||||
// ignore — state remains unchanged
|
||||
}
|
||||
},
|
||||
[onWizardUpdate],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PipelineStageItem } from "../api/client";
|
||||
import { StagePanel } from "./StagePanel";
|
||||
@@ -113,7 +114,7 @@ describe("StagePanel", () => {
|
||||
const items: PipelineStageItem[] = [
|
||||
{
|
||||
story_id: "1_story_bad",
|
||||
name: null,
|
||||
name: "",
|
||||
error: "Missing front matter",
|
||||
merge_failure: null,
|
||||
agent: null,
|
||||
@@ -391,4 +392,182 @@ describe("StagePanel", () => {
|
||||
screen.queryByTestId("merge-in-flight-icon-42_story_no_prop"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows spinning RECOVERING badge for blocked item with running recovery agent", () => {
|
||||
const items: PipelineStageItem[] = [
|
||||
{
|
||||
story_id: "50_story_blocked_recovering",
|
||||
name: "Blocked Recovering Story",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder", model: "claude", status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
blocked: true,
|
||||
},
|
||||
];
|
||||
render(<StagePanel title="Current" items={items} />);
|
||||
const badge = screen.getByTestId("blocked-badge-50_story_blocked_recovering");
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge).toHaveTextContent("RECOVERING");
|
||||
});
|
||||
|
||||
it("shows QUEUED badge for blocked item with pending recovery agent", () => {
|
||||
const items: PipelineStageItem[] = [
|
||||
{
|
||||
story_id: "51_story_blocked_queued",
|
||||
name: "Blocked Queued Story",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder", model: "claude", status: "pending" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
blocked: true,
|
||||
},
|
||||
];
|
||||
render(<StagePanel title="Current" items={items} />);
|
||||
const badge = screen.getByTestId("blocked-badge-51_story_blocked_queued");
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge).toHaveTextContent("QUEUED");
|
||||
});
|
||||
|
||||
it("shows red BLOCKED badge for blocked item with no recovery agent", () => {
|
||||
const items: PipelineStageItem[] = [
|
||||
{
|
||||
story_id: "52_story_blocked_human",
|
||||
name: "Blocked Human Story",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: null,
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
blocked: true,
|
||||
},
|
||||
];
|
||||
render(<StagePanel title="Current" items={items} />);
|
||||
const badge = screen.getByTestId("blocked-badge-52_story_blocked_human");
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge).toHaveTextContent("BLOCKED");
|
||||
});
|
||||
|
||||
it("shows spinning icon for merge_failure item with running mergemaster", () => {
|
||||
const items: PipelineStageItem[] = [
|
||||
{
|
||||
story_id: "53_story_merge_recovering",
|
||||
name: "Merge Recovering Story",
|
||||
error: null,
|
||||
merge_failure: "Squash merge failed: conflicts",
|
||||
agent: { agent_name: "mergemaster", model: "claude", status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
];
|
||||
render(<StagePanel title="Merge" items={items} />);
|
||||
const icon = screen.getByTestId("merge-failure-icon-53_story_merge_recovering");
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveTextContent("⟳");
|
||||
});
|
||||
|
||||
it("shows hourglass icon for merge_failure item with pending mergemaster", () => {
|
||||
const items: PipelineStageItem[] = [
|
||||
{
|
||||
story_id: "54_story_merge_queued",
|
||||
name: "Merge Queued Story",
|
||||
error: null,
|
||||
merge_failure: "Squash merge failed: conflicts",
|
||||
agent: { agent_name: "mergemaster", model: "claude", status: "pending" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
];
|
||||
render(<StagePanel title="Merge" items={items} />);
|
||||
const icon = screen.getByTestId("merge-failure-icon-54_story_merge_queued");
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveTextContent("⏳");
|
||||
});
|
||||
|
||||
it("renders gate output in a bounded box with expand and copy controls", () => {
|
||||
const items: PipelineStageItem[] = [
|
||||
{
|
||||
story_id: "60_story_gate_output",
|
||||
name: "Gate Output Story",
|
||||
error: null,
|
||||
merge_failure: "Quality gates failed: cargo test output here",
|
||||
agent: null,
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
];
|
||||
render(<StagePanel title="Merge" items={items} />);
|
||||
expect(screen.getByTestId("gate-output-text")).toHaveTextContent(
|
||||
"Quality gates failed: cargo test output here",
|
||||
);
|
||||
expect(screen.getByTestId("gate-output-toggle")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("gate-output-copy")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expand toggle changes label from Expand to Collapse", async () => {
|
||||
const user = userEvent.setup();
|
||||
const items: PipelineStageItem[] = [
|
||||
{
|
||||
story_id: "61_story_expand",
|
||||
name: "Expand Story",
|
||||
error: null,
|
||||
merge_failure: "A".repeat(1000),
|
||||
agent: null,
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
];
|
||||
render(<StagePanel title="Merge" items={items} />);
|
||||
const toggle = screen.getByTestId("gate-output-toggle");
|
||||
expect(toggle).toHaveTextContent("Expand");
|
||||
await user.click(toggle);
|
||||
expect(toggle).toHaveTextContent("Collapse");
|
||||
await user.click(toggle);
|
||||
expect(toggle).toHaveTextContent("Expand");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StagePanel - defensive rendering", () => {
|
||||
it("renders without exception when a story is missing its name field", () => {
|
||||
const items = [
|
||||
{
|
||||
story_id: "60_story_no_name",
|
||||
name: undefined as unknown as string,
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: null,
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
];
|
||||
expect(() => render(<StagePanel title="Current" items={items} />)).not.toThrow();
|
||||
expect(screen.getByTestId("card-60_story_no_name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders without exception when a story is missing its story_id field", () => {
|
||||
const items = [
|
||||
{
|
||||
story_id: undefined as unknown as string,
|
||||
name: "Orphaned Story",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: null,
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
];
|
||||
expect(() => render(<StagePanel title="Current" items={items} />)).not.toThrow();
|
||||
expect(screen.getByText("Orphaned Story")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,82 @@ import { useLozengeFly } from "./LozengeFlyContext";
|
||||
|
||||
const { useLayoutEffect, useRef, useState } = React;
|
||||
|
||||
/** Renders merge-failure gate output in a bounded scroll region with expand and copy controls. */
|
||||
function GateOutputBox({ text }: { text: string }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
};
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
color: "#aaa",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.75em",
|
||||
padding: "1px 6px",
|
||||
lineHeight: 1.4,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: "4px" }}>
|
||||
<div
|
||||
data-testid="gate-output-text"
|
||||
style={{
|
||||
fontSize: "0.8em",
|
||||
color: "#f85149",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
fontFamily: "monospace",
|
||||
background: "#1a0808",
|
||||
borderRadius: "4px",
|
||||
padding: "6px 8px",
|
||||
maxHeight: expanded ? "none" : "10rem",
|
||||
overflowY: expanded ? "visible" : "auto",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "6px",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="gate-output-toggle"
|
||||
onClick={handleToggle}
|
||||
style={btnStyle}
|
||||
>
|
||||
{expanded ? "▲ Collapse" : "▼ Expand"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="gate-output-copy"
|
||||
onClick={handleCopy}
|
||||
style={btnStyle}
|
||||
>
|
||||
{copied ? "✓ Copied" : "⎘ Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type WorkItemType = "story" | "bug" | "spike" | "refactor" | "unknown";
|
||||
|
||||
const TYPE_COLORS: Record<WorkItemType, string> = {
|
||||
@@ -55,6 +131,8 @@ interface StagePanelProps {
|
||||
onStartAgent?: (storyId: string, agentName?: string) => void;
|
||||
/** Set of story IDs that currently have a deterministic merge in progress. */
|
||||
mergesInFlight?: Set<string>;
|
||||
/** True when this panel shows merge-stage items — enables the mergemaster robot icon. */
|
||||
isMergeStage?: boolean;
|
||||
}
|
||||
|
||||
function AgentLozenge({
|
||||
@@ -262,6 +340,7 @@ export function StagePanel({
|
||||
busyAgentNames,
|
||||
onStartAgent,
|
||||
mergesInFlight,
|
||||
isMergeStage,
|
||||
}: StagePanelProps) {
|
||||
const showStartButton =
|
||||
Boolean(onStartAgent) &&
|
||||
@@ -310,8 +389,10 @@ export function StagePanel({
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const itemNumber = item.story_id.match(/^(\d+)/)?.[1];
|
||||
const itemType = getWorkItemType(item.story_id);
|
||||
const itemNumber = item.story_id?.match(/^(\d+)/)?.[1];
|
||||
const itemType = item.story_id
|
||||
? getWorkItemType(item.story_id)
|
||||
: "unknown";
|
||||
const borderColor = TYPE_COLORS[itemType];
|
||||
const typeLabel = TYPE_LABELS[itemType];
|
||||
const hasMergeFailure = Boolean(item.merge_failure);
|
||||
@@ -345,19 +426,66 @@ export function StagePanel({
|
||||
<>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: "0.9em" }}>
|
||||
{hasMergeFailure && (
|
||||
<span
|
||||
data-testid={`merge-failure-icon-${item.story_id}`}
|
||||
title="Merge failed"
|
||||
style={{
|
||||
color: "#f85149",
|
||||
marginRight: "6px",
|
||||
fontStyle: "normal",
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
)}
|
||||
{hasMergeFailure &&
|
||||
(() => {
|
||||
const agentStatus = item.agent?.status;
|
||||
if (agentStatus === "running") {
|
||||
return (
|
||||
<span
|
||||
data-testid={`merge-failure-icon-${item.story_id}`}
|
||||
title="Merge recovery in progress — no human action needed"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
color: "#e3b341",
|
||||
marginRight: "6px",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
>
|
||||
⟳
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (agentStatus === "pending") {
|
||||
return (
|
||||
<span
|
||||
data-testid={`merge-failure-icon-${item.story_id}`}
|
||||
title="Merge recovery scheduled — waiting for a slot"
|
||||
style={{
|
||||
color: "#e3b341",
|
||||
marginRight: "6px",
|
||||
fontStyle: "normal",
|
||||
}}
|
||||
>
|
||||
⏳
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
data-testid={`merge-failure-icon-${item.story_id}`}
|
||||
title="Merge failed — needs human"
|
||||
style={{
|
||||
color: "#f85149",
|
||||
marginRight: "6px",
|
||||
fontStyle: "normal",
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{isMergeStage &&
|
||||
item.agent?.status === "running" && (
|
||||
<span
|
||||
data-testid={`mergemaster-icon-${item.story_id}`}
|
||||
title="Mergemaster recovery agent running"
|
||||
style={{
|
||||
marginRight: "4px",
|
||||
}}
|
||||
>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
{mergesInFlight?.has(item.story_id) && (
|
||||
<span
|
||||
data-testid={`merge-in-flight-icon-${item.story_id}`}
|
||||
@@ -396,6 +524,93 @@ export function StagePanel({
|
||||
{typeLabel}
|
||||
</span>
|
||||
)}
|
||||
{item.blocked &&
|
||||
!item.merge_failure &&
|
||||
(() => {
|
||||
const agentStatus = item.agent?.status;
|
||||
if (agentStatus === "running") {
|
||||
return (
|
||||
<span
|
||||
data-testid={`blocked-badge-${item.story_id}`}
|
||||
title="Recovery coder running — no human action needed"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
fontSize: "0.65em",
|
||||
fontWeight: 700,
|
||||
color: "#e3b341",
|
||||
background: "#2a1f0a",
|
||||
border: "1px solid #6e4a00",
|
||||
borderRadius: "4px",
|
||||
padding: "1px 4px",
|
||||
marginRight: "8px",
|
||||
letterSpacing: "0.05em",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
>
|
||||
⟳ RECOVERING
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (agentStatus === "pending") {
|
||||
return (
|
||||
<span
|
||||
data-testid={`blocked-badge-${item.story_id}`}
|
||||
title="Recovery coder queued — waiting for a slot"
|
||||
style={{
|
||||
fontSize: "0.65em",
|
||||
fontWeight: 700,
|
||||
color: "#e3b341",
|
||||
background: "#2a1f0a",
|
||||
border: "1px solid #6e4a00",
|
||||
borderRadius: "4px",
|
||||
padding: "1px 4px",
|
||||
marginRight: "8px",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
⏳ QUEUED
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
data-testid={`blocked-badge-${item.story_id}`}
|
||||
title="Blocked — awaiting human unblock"
|
||||
style={{
|
||||
fontSize: "0.65em",
|
||||
fontWeight: 700,
|
||||
color: "#f85149",
|
||||
background: "#2a1010",
|
||||
border: "1px solid #6e1b1b",
|
||||
borderRadius: "4px",
|
||||
padding: "1px 4px",
|
||||
marginRight: "8px",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
⊘ BLOCKED
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{item.frozen && (
|
||||
<span
|
||||
data-testid={`frozen-badge-${item.story_id}`}
|
||||
title="Frozen — auto-assign paused"
|
||||
style={{
|
||||
fontSize: "0.65em",
|
||||
fontWeight: 700,
|
||||
color: "#58a6ff",
|
||||
background: "#0d1f36",
|
||||
border: "1px solid #1a3a6e",
|
||||
borderRadius: "4px",
|
||||
padding: "1px 4px",
|
||||
marginRight: "8px",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
❄ FROZEN
|
||||
</span>
|
||||
)}
|
||||
{costs?.has(item.story_id) && (
|
||||
<span
|
||||
data-testid={`cost-badge-${item.story_id}`}
|
||||
@@ -409,7 +624,7 @@ export function StagePanel({
|
||||
${costs.get(item.story_id)?.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{item.name ?? item.story_id}
|
||||
{item.name || item.story_id}
|
||||
</div>
|
||||
{item.error && (
|
||||
<div
|
||||
@@ -425,15 +640,8 @@ export function StagePanel({
|
||||
{item.merge_failure && (
|
||||
<div
|
||||
data-testid={`merge-failure-reason-${item.story_id}`}
|
||||
style={{
|
||||
fontSize: "0.8em",
|
||||
color: "#f85149",
|
||||
marginTop: "4px",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{item.merge_failure}
|
||||
<GateOutputBox text={item.merge_failure} />
|
||||
</div>
|
||||
)}
|
||||
{item.depends_on && item.depends_on.length > 0 && (
|
||||
@@ -499,10 +707,10 @@ export function StagePanel({
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`delete-btn-${item.story_id}`}
|
||||
title={`Delete ${item.name ?? item.story_id}`}
|
||||
title={`Delete ${item.name || item.story_id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const label = item.name ?? item.story_id;
|
||||
const label = item.name || item.story_id;
|
||||
if (
|
||||
window.confirm(
|
||||
`Delete "${label}"? This cannot be undone.`,
|
||||
|
||||
@@ -258,7 +258,7 @@ export function WorkItemDetailPanel({
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && content !== null && (
|
||||
{!loading && !error && content != null && (
|
||||
<div
|
||||
data-testid="detail-panel-content"
|
||||
className="markdown-body"
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`StoryRow > renders #id prefix before the story name 1`] = `
|
||||
<div>
|
||||
<div
|
||||
style="display: flex; align-items: center; gap: 8px; padding-top: 4px; padding-bottom: 4px; font-size: 0.82em;"
|
||||
>
|
||||
<span
|
||||
style="padding: 1px 6px; border-radius: 10px; background: rgba(63, 185, 80, 0.133); color: rgb(63, 185, 80); border: 1px solid rgba(63, 185, 80, 0.267); white-space: nowrap; flex-shrink: 0;"
|
||||
>
|
||||
In Progress
|
||||
</span>
|
||||
<span
|
||||
style="color: rgb(230, 237, 243); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||||
>
|
||||
<span
|
||||
style="color: rgb(139, 148, 158); font-family: monospace;"
|
||||
>
|
||||
#
|
||||
42
|
||||
|
||||
</span>
|
||||
Add Feature
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`StoryRow > renders #id prefix for a backlogged story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
style="display: flex; align-items: center; gap: 8px; padding-top: 4px; padding-bottom: 4px; font-size: 0.82em;"
|
||||
>
|
||||
<span
|
||||
style="padding: 1px 6px; border-radius: 10px; background: rgba(210, 166, 121, 0.133); color: rgb(210, 166, 121); border: 1px solid rgba(210, 166, 121, 0.267); white-space: nowrap; flex-shrink: 0;"
|
||||
>
|
||||
QA
|
||||
</span>
|
||||
<span
|
||||
style="color: rgb(230, 237, 243); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||||
>
|
||||
<span
|
||||
style="color: rgb(139, 148, 158); font-family: monospace;"
|
||||
>
|
||||
#
|
||||
7
|
||||
|
||||
</span>
|
||||
Fix crash on startup
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`StoryRow > renders awaiting-slot badge for merge item with no agent 1`] = `
|
||||
<div>
|
||||
<div
|
||||
style="display: flex; align-items: center; gap: 8px; padding-top: 4px; padding-bottom: 4px; font-size: 0.82em;"
|
||||
>
|
||||
<span
|
||||
style="padding: 1px 6px; border-radius: 10px; background: rgba(110, 118, 129, 0.133); color: rgb(110, 118, 129); border: 1px solid rgba(110, 118, 129, 0.267); white-space: nowrap; flex-shrink: 0;"
|
||||
>
|
||||
awaiting-slot
|
||||
</span>
|
||||
<span
|
||||
style="color: rgb(230, 237, 243); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||||
>
|
||||
Mystery Story
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -24,6 +24,9 @@ export const STATUS_COLORS: Record<AgentStatusValue, string> = {
|
||||
* them again inside the markdown body creates duplicate information.
|
||||
*/
|
||||
export function stripDisplayContent(content: string): string {
|
||||
// Guard: content may be undefined/null at runtime if the server response is
|
||||
// missing the field (e.g. a tombstoned story returns an error object).
|
||||
if (!content) return "";
|
||||
let text = content;
|
||||
// Strip YAML front matter (--- ... ---)
|
||||
if (text.startsWith("---")) {
|
||||
|
||||
@@ -125,7 +125,7 @@ export function useChatSend({
|
||||
{ role: "user", content: messageText },
|
||||
]);
|
||||
try {
|
||||
const result = await api.botCommand(cmd, args, undefined);
|
||||
const result = await api.botCommand(cmd, args);
|
||||
setMessages((prev: Message[]) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: result.response },
|
||||
|
||||
@@ -11,6 +11,9 @@ import { formatToolActivity } from "../utils/chatUtils";
|
||||
|
||||
const { useEffect, useRef, useState } = React;
|
||||
|
||||
/** Connectivity state of the WebSocket connection. */
|
||||
export type WsConnectivity = "connecting" | "connected" | "reconnecting" | "failed";
|
||||
|
||||
type SetState<T> = React.Dispatch<React.SetStateAction<T>>;
|
||||
|
||||
interface UseChatWebSocketParams {
|
||||
@@ -32,6 +35,8 @@ interface ReconciliationEvent {
|
||||
export interface UseChatWebSocketResult {
|
||||
wsRef: React.MutableRefObject<ChatWebSocket | null>;
|
||||
wsConnected: boolean;
|
||||
wsConnectivity: WsConnectivity;
|
||||
wsDisconnectedAt: Date | null;
|
||||
streamingContent: string;
|
||||
setStreamingContent: SetState<string>;
|
||||
streamingThinking: string;
|
||||
@@ -87,6 +92,9 @@ export function useChatWebSocket({
|
||||
}: UseChatWebSocketParams): UseChatWebSocketResult {
|
||||
const wsRef = useRef<ChatWebSocket | null>(null);
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
const [wsConnectivity, setWsConnectivity] = useState<WsConnectivity>("connecting");
|
||||
const [wsDisconnectedAt, setWsDisconnectedAt] = useState<Date | null>(null);
|
||||
const failedTimerRef = useRef<number | undefined>(undefined);
|
||||
const [streamingContent, setStreamingContent] = useState("");
|
||||
const [streamingThinking, setStreamingThinking] = useState("");
|
||||
const [activityStatus, setActivityStatus] = useState<string | null>(null);
|
||||
@@ -162,14 +170,6 @@ export function useChatWebSocket({
|
||||
console.error("WebSocket error:", message);
|
||||
setLoading(false);
|
||||
setActivityStatus(null);
|
||||
const markdownMessage = message.replace(
|
||||
/(https?:\/\/[^\s]+)/g,
|
||||
"[$1]($1)",
|
||||
);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: markdownMessage },
|
||||
]);
|
||||
if (queuedMessagesRef.current.length > 0) {
|
||||
const batch = queuedMessagesRef.current.map((item) => item.text);
|
||||
queuedMessagesRef.current = [];
|
||||
@@ -261,18 +261,34 @@ export function useChatWebSocket({
|
||||
},
|
||||
onConnected: () => {
|
||||
setWsConnected(true);
|
||||
setWsConnectivity("connected");
|
||||
setWsDisconnectedAt(null);
|
||||
window.clearTimeout(failedTimerRef.current);
|
||||
failedTimerRef.current = undefined;
|
||||
},
|
||||
onDisconnected: () => {
|
||||
setWsConnectivity("reconnecting");
|
||||
setWsDisconnectedAt(new Date());
|
||||
window.clearTimeout(failedTimerRef.current);
|
||||
failedTimerRef.current = window.setTimeout(() => {
|
||||
setWsConnectivity("failed");
|
||||
}, 30_000);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
wsRef.current = null;
|
||||
window.clearTimeout(failedTimerRef.current);
|
||||
failedTimerRef.current = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
wsRef,
|
||||
wsConnected,
|
||||
wsConnectivity,
|
||||
wsDisconnectedAt,
|
||||
streamingContent,
|
||||
setStreamingContent,
|
||||
streamingThinking,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import * as React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
+15
-3
@@ -1,5 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fast compile-only check: no frontend build, no clippy, no tests.
|
||||
# Use this for rapid iteration feedback while writing code.
|
||||
# Pre-commit quality gate: fmt-check, clippy, cargo check, and doc-coverage.
|
||||
# Run this before committing to catch fmt drift, clippy warnings, compile
|
||||
# errors, and missing doc comments without waiting for the full test suite.
|
||||
set -euo pipefail
|
||||
cargo check --tests --workspace
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
echo "=== Checking Rust formatting ==="
|
||||
cargo fmt --manifest-path "$PROJECT_ROOT/Cargo.toml" --all --check
|
||||
|
||||
echo "=== Running cargo clippy ==="
|
||||
cargo clippy --manifest-path "$PROJECT_ROOT/Cargo.toml" --workspace --all-targets -- -D warnings
|
||||
|
||||
echo "=== Checking doc coverage on changed files ==="
|
||||
cargo run --manifest-path "$PROJECT_ROOT/Cargo.toml" -p source-map-gen --bin source-map-check --quiet -- --worktree "$PROJECT_ROOT" --base master
|
||||
|
||||
+19
-14
@@ -11,14 +11,16 @@ export GIT_CONFIG_VALUE_0=master
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
echo "=== Building frontend ==="
|
||||
if [ -d "$PROJECT_ROOT/frontend" ]; then
|
||||
cd "$PROJECT_ROOT/frontend"
|
||||
npm install
|
||||
npm run build
|
||||
cd "$PROJECT_ROOT"
|
||||
# Ordered fail-fast: cheapest deterministic checks first, slowest builds and
|
||||
# test suites last. `set -euo pipefail` aborts at the first failure, so a fmt
|
||||
# or clippy drift never wastes time on a frontend build or a multi-minute
|
||||
# test run.
|
||||
|
||||
echo "=== Checking Rust formatting ==="
|
||||
if cargo fmt --version &>/dev/null; then
|
||||
cargo fmt --manifest-path "$PROJECT_ROOT/Cargo.toml" --all --check
|
||||
else
|
||||
echo "Skipping frontend build (no frontend directory)"
|
||||
echo "Skipping Rust formatting check (rustfmt not installed)"
|
||||
fi
|
||||
|
||||
echo "=== Checking for duplicate module files (X.rs and X/mod.rs coexisting) ==="
|
||||
@@ -42,19 +44,22 @@ if [ "$_dup_found" -eq 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Checking Rust formatting ==="
|
||||
if cargo fmt --version &>/dev/null; then
|
||||
cargo fmt --manifest-path "$PROJECT_ROOT/Cargo.toml" --all --check
|
||||
else
|
||||
echo "Skipping Rust formatting check (rustfmt not installed)"
|
||||
fi
|
||||
|
||||
echo "=== Running cargo clippy ==="
|
||||
cargo clippy --manifest-path "$PROJECT_ROOT/Cargo.toml" --all-targets --all-features -- -D warnings
|
||||
|
||||
echo "=== Checking doc coverage on changed files ==="
|
||||
cargo run --manifest-path "$PROJECT_ROOT/Cargo.toml" -p source-map-gen --bin source-map-check --quiet -- --worktree "$PROJECT_ROOT" --base master
|
||||
|
||||
echo "=== Building frontend ==="
|
||||
if [ -d "$PROJECT_ROOT/frontend" ]; then
|
||||
cd "$PROJECT_ROOT/frontend"
|
||||
npm install
|
||||
npm run build
|
||||
cd "$PROJECT_ROOT"
|
||||
else
|
||||
echo "Skipping frontend build (no frontend directory)"
|
||||
fi
|
||||
|
||||
echo "=== Running Rust tests ==="
|
||||
cargo test --manifest-path "$PROJECT_ROOT/Cargo.toml" --bin huskies
|
||||
cargo test --manifest-path "$PROJECT_ROOT/Cargo.toml" -p source-map-gen
|
||||
|
||||
+14
-10
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "huskies"
|
||||
version = "0.10.4"
|
||||
version = "0.11.0"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
@@ -10,14 +10,12 @@ async-trait = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
chrono-tz = { workspace = true }
|
||||
eventsource-stream = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
homedir = { workspace = true }
|
||||
ignore = { workspace = true }
|
||||
mime_guess = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
poem = { workspace = true, features = ["websocket"] }
|
||||
poem-openapi = { workspace = true, features = ["swagger-ui"] }
|
||||
portable-pty = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "stream", "form"] }
|
||||
rust-embed = { workspace = true }
|
||||
@@ -29,8 +27,6 @@ sha2 = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
strip-ansi-escapes = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "process"] }
|
||||
toml = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v4", "serde"] }
|
||||
@@ -40,16 +36,24 @@ pulldown-cmark = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
|
||||
# Force bundled SQLite so static musl builds don't need a system libsqlite3
|
||||
# Listed here to enable the `bundled` feature, which propagates via Cargo's
|
||||
# feature unification to sqlx-sqlite and matrix-sdk-sqlite (rusqlite) so the
|
||||
# static musl docker build can compile SQLite from source instead of linking
|
||||
# against a missing system libsqlite3.
|
||||
#
|
||||
# The 0.35 pin is the ceiling: rusqlite 0.37 (matrix-sdk-sqlite) requires
|
||||
# 0.35.x exactly, and sqlx-sqlite 0.9.0-alpha.1 requires >=0.30, <0.36. Bumping
|
||||
# this needs one of those upstreams to widen their range first.
|
||||
libsqlite3-sys = { version = "0.35.0", features = ["bundled"] }
|
||||
sqlx = { workspace = true }
|
||||
wait-timeout = "0.2.1"
|
||||
bft-json-crdt = { path = "../crates/bft-json-crdt", default-features = false, features = ["bft"] }
|
||||
source-map-gen = { path = "../crates/source-map-gen" }
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
fastcrypto = "0.1.8"
|
||||
rand = "0.8"
|
||||
indexmap = { version = "2.2.6", features = ["serde"] }
|
||||
ed25519-dalek = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
nutype = { workspace = true }
|
||||
garde = { workspace = true }
|
||||
ammonia = { workspace = true }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Story 945: drop the `blocked` boolean column from the shadow pipeline_items
|
||||
-- table. `Stage::Blocked { reason }` is now the single source of truth for
|
||||
-- "blocked" — the legacy flag has been deleted from the CRDT and Rust types.
|
||||
ALTER TABLE pipeline_items DROP COLUMN blocked;
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS event_triggers (
|
||||
id TEXT PRIMARY KEY,
|
||||
predicate_json TEXT NOT NULL,
|
||||
action_json TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE IF NOT EXISTS timers (
|
||||
story_id TEXT PRIMARY KEY,
|
||||
scheduled_at TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS scheduled_timers (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT,
|
||||
fire_at TEXT NOT NULL,
|
||||
action_json TEXT NOT NULL,
|
||||
mode_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Human-readable formatting of raw agent log entries.
|
||||
|
||||
use crate::chat::util::truncate_at_char_boundary;
|
||||
|
||||
/// Format a single log entry as a human-readable text line.
|
||||
///
|
||||
/// `timestamp` is an ISO 8601 string; `event` is the flattened `AgentEvent`
|
||||
@@ -7,6 +9,7 @@
|
||||
///
|
||||
/// Returns `None` for entries that should be skipped (raw streaming noise,
|
||||
/// trivial status changes, empty output, etc.).
|
||||
#[allow(clippy::string_slice)] // timestamp[11..19]: ISO 8601 is ASCII-only, these byte offsets are always valid
|
||||
pub fn format_log_entry_as_text(timestamp: &str, event: &serde_json::Value) -> Option<String> {
|
||||
let agent_name = event
|
||||
.get("agent_name")
|
||||
@@ -75,7 +78,7 @@ pub fn format_log_entry_as_text(timestamp: &str, event: &serde_json::Value) -> O
|
||||
.map(|v| serde_json::to_string(v).unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
let display = if input.len() > 200 {
|
||||
format!("{}...", &input[..200])
|
||||
format!("{}...", truncate_at_char_boundary(&input, 200))
|
||||
} else {
|
||||
input
|
||||
};
|
||||
@@ -104,9 +107,19 @@ pub fn format_log_entry_as_text(timestamp: &str, event: &serde_json::Value) -> O
|
||||
None => String::new(),
|
||||
};
|
||||
let display = if content_str.len() > 500 {
|
||||
// Walk back to the nearest char boundary so we
|
||||
// don't panic when the 500-byte mark lands
|
||||
// inside a multi-byte UTF-8 codepoint (e.g.
|
||||
// box-drawing chars like '─', smart quotes,
|
||||
// emoji). `is_char_boundary(len)` is always
|
||||
// true so the loop terminates.
|
||||
let mut end = 500;
|
||||
while !content_str.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!(
|
||||
"{}... [{} chars total]",
|
||||
&content_str[..500],
|
||||
&content_str[..end],
|
||||
content_str.len()
|
||||
)
|
||||
} else {
|
||||
@@ -129,3 +142,42 @@ pub fn format_log_entry_as_text(timestamp: &str, event: &serde_json::Value) -> O
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// Regression: a tool_result whose content is >500 bytes AND has a
|
||||
/// multi-byte UTF-8 codepoint straddling byte 500 must not panic.
|
||||
/// Previously `&content_str[..500]` would slice mid-codepoint and crash
|
||||
/// the get_agent_output MCP tool.
|
||||
#[test]
|
||||
fn tool_result_truncation_handles_multibyte_at_boundary() {
|
||||
// 498 ASCII filler + a 3-byte '─' (U+2500) starting at byte 499 +
|
||||
// 100 more ASCII chars. The naive `..500` slice would land inside
|
||||
// the box-drawing char and panic.
|
||||
let mut content = "a".repeat(499);
|
||||
content.push('─');
|
||||
content.push_str(&"b".repeat(100));
|
||||
assert!(content.len() > 500);
|
||||
assert!(!content.is_char_boundary(500));
|
||||
|
||||
let event = json!({
|
||||
"type": "agent_json",
|
||||
"agent_name": "coder-1",
|
||||
"data": {
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [{ "type": "tool_result", "content": content }]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let out = format_log_entry_as_text("2026-05-12T15:30:00.000000Z", &event);
|
||||
assert!(out.is_some(), "tool_result must format without panicking");
|
||||
let s = out.unwrap();
|
||||
assert!(s.contains("RESULT:"));
|
||||
assert!(s.contains("chars total"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,38 +76,55 @@ mod tests {
|
||||
/// AC: seed a stale claim older than the TTL, attempt a new claim from a
|
||||
/// different agent, assert the new claim succeeds and displacement is logged.
|
||||
#[test]
|
||||
#[allow(clippy::string_slice)] // stale_holder is a hex/ASCII string literal; [..12] always valid
|
||||
fn stale_claim_displaced_and_logged() {
|
||||
use crate::crdt_state::{init_for_test, our_node_id, read_item, write_claim, write_item};
|
||||
use crate::pipeline_state::{AgentClaim, AgentName, Stage};
|
||||
use chrono::TimeZone;
|
||||
|
||||
init_for_test();
|
||||
|
||||
let story_id = "718_test_stale_displacement";
|
||||
let stale_holder = "staledeadbeef0000000000000000000000000000";
|
||||
// Place claimed_at well beyond the TTL so the claim is unambiguously stale.
|
||||
let stale_time = chrono::Utc::now().timestamp() as f64 - CLAIM_TIMEOUT_SECS - 300.0;
|
||||
let stale_time = chrono::Utc::now().timestamp() as u64 - CLAIM_TIMEOUT_SECS as u64 - 300;
|
||||
|
||||
// Seed the story with a stale claim from a foreign node.
|
||||
write_item(
|
||||
story_id,
|
||||
"2_current",
|
||||
&Stage::Coding {
|
||||
claim: Some(AgentClaim {
|
||||
agent: AgentName(stale_holder.to_string()),
|
||||
claimed_at: chrono::Utc
|
||||
.timestamp_opt(stale_time as i64, 0)
|
||||
.single()
|
||||
.unwrap(),
|
||||
}),
|
||||
plan: Default::default(),
|
||||
retries: 0,
|
||||
},
|
||||
Some("Stale Claim Displacement Test"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(stale_holder),
|
||||
Some(stale_time),
|
||||
None,
|
||||
);
|
||||
|
||||
// Confirm the stale claim is in place.
|
||||
let before = read_item(story_id).expect("item should exist");
|
||||
let before_claim = match before.stage() {
|
||||
Stage::Coding { claim, .. } => claim.as_ref(),
|
||||
Stage::Merge { claim, .. } => claim.as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(
|
||||
before.claimed_by.as_deref(),
|
||||
before_claim.map(|c| c.agent.0.as_str()),
|
||||
Some(stale_holder),
|
||||
"pre-condition: item should be claimed by the stale holder"
|
||||
);
|
||||
let age = chrono::Utc::now().timestamp() as f64 - before.claimed_at.unwrap_or(0.0);
|
||||
let age = chrono::Utc::now().timestamp() as f64
|
||||
- before_claim
|
||||
.map(|c| c.claimed_at.timestamp() as f64)
|
||||
.unwrap_or(0.0);
|
||||
assert!(
|
||||
age >= CLAIM_TIMEOUT_SECS,
|
||||
"pre-condition: claim age ({age}s) must exceed TTL ({CLAIM_TIMEOUT_SECS}s)"
|
||||
@@ -133,13 +150,18 @@ mod tests {
|
||||
// Verify the new claim belongs to this node, not the stale holder.
|
||||
let our_id = our_node_id().expect("node id should be available after init_for_test");
|
||||
let after = read_item(story_id).expect("item should still exist");
|
||||
let after_claim = match after.stage() {
|
||||
Stage::Coding { claim, .. } => claim.as_ref(),
|
||||
Stage::Merge { claim, .. } => claim.as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(
|
||||
after.claimed_by.as_deref(),
|
||||
after_claim.map(|c| c.agent.0.as_str()),
|
||||
Some(our_id.as_str()),
|
||||
"new claim should have displaced the stale holder"
|
||||
);
|
||||
assert_ne!(
|
||||
after.claimed_by.as_deref(),
|
||||
after_claim.map(|c| c.agent.0.as_str()),
|
||||
Some(stale_holder),
|
||||
"stale holder must no longer own the claim"
|
||||
);
|
||||
|
||||
@@ -64,6 +64,9 @@ pub(super) fn build_agent_app_context(
|
||||
let timer_store = Arc::new(crate::service::timer::TimerStore::load(
|
||||
project_root.join(".huskies").join("timers.json"),
|
||||
));
|
||||
let scheduled_timer_store = Arc::new(crate::service::timer::ScheduledTimerStore::load(
|
||||
project_root.join(".huskies").join("scheduled_timers.json"),
|
||||
));
|
||||
let agents = Arc::new(AgentPool::new(port, watcher_tx.clone()));
|
||||
let services = Arc::new(crate::services::Services {
|
||||
project_root: project_root.to_path_buf(),
|
||||
@@ -90,5 +93,11 @@ pub(super) fn build_agent_app_context(
|
||||
bot_shutdown: None,
|
||||
matrix_shutdown_tx: None,
|
||||
timer_store,
|
||||
scheduled_timer_store,
|
||||
event_trigger_store: Arc::new(
|
||||
crate::service::event_triggers::store::EventTriggerStore::load(
|
||||
project_root.join(".huskies").join("event_triggers.json"),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,31 +41,49 @@ pub(super) async fn scan_and_claim(
|
||||
};
|
||||
|
||||
for item in &items {
|
||||
// Only claim stories in active stages.
|
||||
if !crate::pipeline_state::Stage::from_dir(&item.stage).is_some_and(|s| s.is_active()) {
|
||||
// Only claim stories in execution stages (Coding, Qa, Merge).
|
||||
if !matches!(
|
||||
item.stage(),
|
||||
crate::pipeline_state::Stage::Coding { .. }
|
||||
| crate::pipeline_state::Stage::Qa
|
||||
| crate::pipeline_state::Stage::Merge { .. }
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip blocked stories.
|
||||
if item.blocked == Some(true) {
|
||||
// Skip blocked stories (story 945: `Stage::Blocked` is the source of truth).
|
||||
if matches!(
|
||||
item.stage(),
|
||||
crate::pipeline_state::Stage::Blocked { .. }
|
||||
| crate::pipeline_state::Stage::MergeFailure { .. }
|
||||
| crate::pipeline_state::Stage::MergeFailureFinal { .. }
|
||||
| crate::pipeline_state::Stage::Archived {
|
||||
reason: crate::pipeline_state::ArchiveReason::Blocked { .. },
|
||||
..
|
||||
}
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let item_claim = match item.stage() {
|
||||
crate::pipeline_state::Stage::Coding { claim, .. } => claim.as_ref(),
|
||||
crate::pipeline_state::Stage::Merge { claim, .. } => claim.as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// If already claimed by us, skip.
|
||||
if item.claimed_by.as_deref() == Some(&our_node) {
|
||||
if item_claim.is_some_and(|c| c.agent.0 == our_node) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If claimed by another node, respect the claim while it is fresh.
|
||||
// Once the TTL expires the claim is considered stale regardless of
|
||||
// whether the holder appears alive — displacement is purely TTL-driven.
|
||||
if let Some(ref claimer) = item.claimed_by
|
||||
&& !claimer.is_empty()
|
||||
&& claimer != &our_node
|
||||
&& let Some(claimed_at) = item.claimed_at
|
||||
if let Some(claim) = item_claim
|
||||
&& claim.agent.0 != our_node
|
||||
{
|
||||
let now = chrono::Utc::now().timestamp() as f64;
|
||||
let age = now - claimed_at;
|
||||
let now = chrono::Utc::now().timestamp() as u64;
|
||||
let age = now.saturating_sub(claim.claimed_at.timestamp() as u64) as f64;
|
||||
if age < CLAIM_TIMEOUT_SECS {
|
||||
// Claim is still fresh — respect it.
|
||||
continue;
|
||||
@@ -74,8 +92,8 @@ pub(super) async fn scan_and_claim(
|
||||
slog!(
|
||||
"[agent-mode] Displacing stale claim on '{}' held by {:.12}… \
|
||||
(age {}s > TTL {}s)",
|
||||
item.story_id,
|
||||
claimer,
|
||||
item.story_id(),
|
||||
claim.agent.0,
|
||||
age as u64,
|
||||
CLAIM_TIMEOUT_SECS as u64,
|
||||
);
|
||||
@@ -97,10 +115,10 @@ pub(super) async fn scan_and_claim(
|
||||
})
|
||||
.map(|n| n.node_id)
|
||||
.collect();
|
||||
if !should_self_claim(&our_node, &item.story_id, &alive_peers) {
|
||||
if !should_self_claim(&our_node, item.story_id(), &alive_peers) {
|
||||
slog!(
|
||||
"[agent-mode] Hash tie-break: deferring claim on '{}' to lower-hash peer",
|
||||
item.story_id
|
||||
item.story_id()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -108,11 +126,11 @@ pub(super) async fn scan_and_claim(
|
||||
// Try to claim this story.
|
||||
slog!(
|
||||
"[agent-mode] Claiming story '{}' for this node",
|
||||
item.story_id
|
||||
item.story_id()
|
||||
);
|
||||
if crdt_state::write_claim(&item.story_id) {
|
||||
if crdt_state::write_claim(item.story_id()) {
|
||||
let now = chrono::Utc::now().timestamp() as f64;
|
||||
our_claims.insert(item.story_id.clone(), now);
|
||||
our_claims.insert(item.story_id().to_string(), now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,27 +183,34 @@ pub(super) fn reclaim_timed_out_work(_project_root: &Path) {
|
||||
let now = chrono::Utc::now().timestamp() as f64;
|
||||
|
||||
for item in &items {
|
||||
if !crate::pipeline_state::Stage::from_dir(&item.stage).is_some_and(|s| s.is_active()) {
|
||||
if !matches!(
|
||||
item.stage(),
|
||||
crate::pipeline_state::Stage::Coding { .. }
|
||||
| crate::pipeline_state::Stage::Qa
|
||||
| crate::pipeline_state::Stage::Merge { .. }
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Release the claim if the TTL has expired — regardless of whether the
|
||||
// holder is still alive. A node actively working should refresh its
|
||||
// claim before the TTL window closes.
|
||||
if let Some(ref claimer) = item.claimed_by {
|
||||
if claimer.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(claimed_at) = item.claimed_at
|
||||
&& now - claimed_at >= CLAIM_TIMEOUT_SECS
|
||||
{
|
||||
let reclaim_claim = match item.stage() {
|
||||
crate::pipeline_state::Stage::Coding { claim, .. } => claim.as_ref(),
|
||||
crate::pipeline_state::Stage::Merge { claim, .. } => claim.as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(claim) = reclaim_claim {
|
||||
let claim_ts = claim.claimed_at.timestamp() as u64;
|
||||
let age = now as u64 - claim_ts.min(now as u64);
|
||||
if age as f64 >= CLAIM_TIMEOUT_SECS {
|
||||
slog!(
|
||||
"[agent-mode] Releasing stale claim on '{}' held by {:.12}… (age {}s)",
|
||||
item.story_id,
|
||||
claimer,
|
||||
(now - claimed_at) as u64,
|
||||
item.story_id(),
|
||||
claim.agent.0,
|
||||
age,
|
||||
);
|
||||
crdt_state::release_claim(&item.story_id);
|
||||
crdt_state::release_claim(item.story_id());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ use loop_ops::{
|
||||
///
|
||||
/// If `join_token` and `gateway_url` are both provided the agent will register
|
||||
/// itself with the gateway on startup using the one-time token.
|
||||
#[allow(clippy::string_slice)] // node_id is a UUID (ASCII); min(8) clamps within its length
|
||||
pub async fn run(
|
||||
project_root: Option<PathBuf>,
|
||||
rendezvous_url: String,
|
||||
@@ -94,9 +95,13 @@ pub async fn run(
|
||||
if let Some(mut crdt_rx) = crdt_state::subscribe() {
|
||||
tokio::spawn(async move {
|
||||
while let Ok(evt) = crdt_rx.recv().await {
|
||||
if crate::pipeline_state::Stage::from_dir(&evt.to_stage)
|
||||
.is_some_and(|s| matches!(s, crate::pipeline_state::Stage::Archived { .. }))
|
||||
&& let Some(root) = crdt_prune_root.as_ref().cloned()
|
||||
if matches!(
|
||||
evt.to_stage,
|
||||
crate::pipeline_state::Stage::Archived { .. }
|
||||
| crate::pipeline_state::Stage::Abandoned { .. }
|
||||
| crate::pipeline_state::Stage::Superseded { .. }
|
||||
| crate::pipeline_state::Stage::Rejected { .. }
|
||||
) && let Some(root) = crdt_prune_root.as_ref().cloned()
|
||||
{
|
||||
let story_id = evt.story_id.clone();
|
||||
tokio::spawn(async move {
|
||||
@@ -107,14 +112,13 @@ pub async fn run(
|
||||
});
|
||||
}
|
||||
let (action, commit_msg) =
|
||||
watcher::stage_metadata(&evt.to_stage, &evt.story_id)
|
||||
.unwrap_or(("update", format!("huskies: update {}", evt.story_id)));
|
||||
watcher::stage_metadata(&evt.to_stage, &evt.story_id);
|
||||
let watcher_evt = watcher::WatcherEvent::WorkItem {
|
||||
stage: evt.to_stage,
|
||||
stage: evt.to_stage.dir_name().to_string(),
|
||||
item_id: evt.story_id,
|
||||
action: action.to_string(),
|
||||
commit_msg,
|
||||
from_stage: evt.from_stage,
|
||||
from_stage: evt.from_stage.map(|s| s.dir_name().to_string()),
|
||||
};
|
||||
let _ = crdt_watcher_tx.send(watcher_evt);
|
||||
}
|
||||
@@ -122,7 +126,7 @@ pub async fn run(
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to watcher events to trigger auto-assign on stage transitions.
|
||||
// Subscribe to watcher events to trigger auto-assign on every stage transition.
|
||||
{
|
||||
let auto_rx = watcher_tx.subscribe();
|
||||
let auto_agents = Arc::clone(&agents);
|
||||
@@ -130,10 +134,7 @@ pub async fn run(
|
||||
tokio::spawn(async move {
|
||||
let mut rx = auto_rx;
|
||||
while let Ok(event) = rx.recv().await {
|
||||
if let watcher::WatcherEvent::WorkItem { ref stage, .. } = event
|
||||
&& crate::pipeline_state::Stage::from_dir(stage.as_str())
|
||||
.is_some_and(|s| s.is_active())
|
||||
{
|
||||
if let watcher::WatcherEvent::WorkItem { ref stage, .. } = event {
|
||||
slog!("[agent-mode] CRDT transition in {stage}/; triggering auto-assign.");
|
||||
auto_agents.auto_assign_available_work(&auto_root).await;
|
||||
}
|
||||
@@ -197,16 +198,10 @@ pub async fn run(
|
||||
)
|
||||
};
|
||||
|
||||
// Reconcile any committed work from a previous session.
|
||||
{
|
||||
let recon_agents = Arc::clone(&agents);
|
||||
let recon_root = project_root.clone();
|
||||
let (recon_tx, _) = broadcast::channel(64);
|
||||
slog!("[agent-mode] Reconciling completed worktrees from previous session.");
|
||||
recon_agents
|
||||
.reconcile_on_startup(&recon_root, &recon_tx)
|
||||
.await;
|
||||
}
|
||||
// Replay current pipeline state so subscribers (worktree lifecycle, merge-failure
|
||||
// auto-spawn) react to any stories already in active stages, then auto-assign.
|
||||
slog!("[agent-mode] Replaying current pipeline state.");
|
||||
crate::pipeline_state::replay_current_pipeline_state();
|
||||
|
||||
// Run initial auto-assign.
|
||||
slog!("[agent-mode] Initial auto-assign scan.");
|
||||
|
||||
+266
-5
@@ -1,9 +1,116 @@
|
||||
//! Acceptance gates — runs test suites and validation scripts in agent worktrees.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use wait_timeout::ChildExt;
|
||||
|
||||
/// Typed classification of a gate failure, produced at the gate execution boundary.
|
||||
///
|
||||
/// Downstream decision logic (e.g. `is_self_evident_fix`) matches on the variant
|
||||
/// rather than scanning the raw output string for patterns.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum GateFailureKind {
|
||||
/// `cargo fmt --check` or `rustfmt --check` detected formatting drift.
|
||||
Fmt,
|
||||
/// `cargo clippy` produced warnings or errors (promoted via `-D warnings`).
|
||||
Lint,
|
||||
/// Test suite (`script/test`, `cargo nextest`, `cargo test`) failed.
|
||||
Test,
|
||||
/// `source-map-check` gate found missing or incomplete doc comments.
|
||||
SourceMapCheck,
|
||||
/// Git content conflict detected during squash-rebase.
|
||||
ContentConflict,
|
||||
/// Build-level failure (duplicate module files E0761, compile error).
|
||||
Build,
|
||||
/// Unclassified gate failure.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl GateFailureKind {
|
||||
/// Classify a gate failure from its raw output at the gate execution boundary.
|
||||
///
|
||||
/// Called once when a gate fails to produce a typed kind. Downstream code
|
||||
/// matches on the variant and must not call this on subsequent reads.
|
||||
pub fn classify(output: &str) -> Self {
|
||||
if output.contains("CONFLICT (content):") || output.contains("Merge conflict:") {
|
||||
GateFailureKind::ContentConflict
|
||||
} else if output.contains("Diff in ") || output.contains("would reformat") {
|
||||
GateFailureKind::Fmt
|
||||
} else if output.contains("missing-docs direction") {
|
||||
GateFailureKind::SourceMapCheck
|
||||
} else if output.contains("error[clippy::")
|
||||
|| output.contains("warning[clippy::")
|
||||
|| output.contains("missing_doc_comments")
|
||||
{
|
||||
GateFailureKind::Lint
|
||||
} else if output.contains("error[E") {
|
||||
// rustc compile errors (e.g. `error[E0063]: missing field`).
|
||||
// When this appears in the post-squash gate run, it almost always
|
||||
// signals cross-merge breakage — master gained a field/variant the
|
||||
// feature branch's code does not match. Mergemaster handles the
|
||||
// recovery in its ConflictDetected path.
|
||||
GateFailureKind::Build
|
||||
} else {
|
||||
GateFailureKind::Test
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this failure class is a self-evident fix that a short coder session
|
||||
/// can resolve without human intervention (fmt drift, lint warnings, missing docs).
|
||||
pub fn is_self_evident_fix(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
GateFailureKind::Fmt | GateFailureKind::Lint | GateFailureKind::SourceMapCheck
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of running quality gates, produced at the gate execution boundary.
|
||||
///
|
||||
/// `failure_kind` drives routing decisions; `output` is retained for human-readable
|
||||
/// display and injection into agent retry prompts only — it must not be used as a
|
||||
/// decision source (story 986).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GateOutcome {
|
||||
/// Whether all gates passed.
|
||||
pub passed: bool,
|
||||
/// Typed failure classification; `None` when `passed` is `true`.
|
||||
pub failure_kind: Option<GateFailureKind>,
|
||||
/// Human-readable combined gate output (display/prompt injection only).
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
impl GateOutcome {
|
||||
/// Passing outcome.
|
||||
pub(crate) fn pass(output: String) -> Self {
|
||||
Self {
|
||||
passed: true,
|
||||
failure_kind: None,
|
||||
output,
|
||||
}
|
||||
}
|
||||
|
||||
/// Failing outcome — classifies `failure_kind` from the output at construction.
|
||||
pub(crate) fn fail(output: String) -> Self {
|
||||
let failure_kind = Some(GateFailureKind::classify(&output));
|
||||
Self {
|
||||
passed: false,
|
||||
failure_kind,
|
||||
output,
|
||||
}
|
||||
}
|
||||
|
||||
/// Failing outcome for a pre-classified build error (e.g. duplicate module files).
|
||||
pub(crate) fn build_error(output: String) -> Self {
|
||||
Self {
|
||||
passed: false,
|
||||
failure_kind: Some(GateFailureKind::Build),
|
||||
output,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum time any single test command is allowed to run before being killed.
|
||||
const TEST_TIMEOUT: Duration = Duration::from_secs(1200); // 20 minutes
|
||||
|
||||
@@ -214,8 +321,8 @@ fn run_command_with_timeout(
|
||||
|
||||
/// Run `cargo clippy` and the project test suite (via `script/test` if present,
|
||||
/// otherwise `cargo nextest run` / `cargo test`) in the given directory.
|
||||
/// Returns `(gates_passed, combined_output)`.
|
||||
pub(crate) fn run_acceptance_gates(path: &Path) -> Result<(bool, String), String> {
|
||||
/// Returns a [`GateOutcome`] with a typed failure classification.
|
||||
pub(crate) fn run_acceptance_gates(path: &Path) -> Result<GateOutcome, String> {
|
||||
// Pre-flight: detect duplicate module files (E0761) before running the
|
||||
// full test suite so the failure message is immediately actionable.
|
||||
let duplicates = find_duplicate_module_files(path);
|
||||
@@ -231,17 +338,17 @@ pub(crate) fn run_acceptance_gates(path: &Path) -> Result<(bool, String), String
|
||||
mod_path.display()
|
||||
));
|
||||
}
|
||||
return Ok((false, msg));
|
||||
return Ok(GateOutcome::build_error(msg));
|
||||
}
|
||||
|
||||
// Run script/test (or fallback to cargo test). Project-specific linting
|
||||
// and test commands belong in script/test.
|
||||
let (test_success, test_out) = run_project_tests(path)?;
|
||||
if !test_success {
|
||||
return Ok((false, test_out));
|
||||
return Ok(GateOutcome::fail(test_out));
|
||||
}
|
||||
|
||||
Ok((true, test_out))
|
||||
Ok(GateOutcome::pass(test_out))
|
||||
}
|
||||
|
||||
/// Scan `root` recursively for Rust source files where both `path/X.rs` and
|
||||
@@ -717,4 +824,158 @@ mod tests {
|
||||
"untracked file should be restored after cargo check"
|
||||
);
|
||||
}
|
||||
|
||||
// ── GateFailureKind::classify ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn classify_fmt_from_diff_in() {
|
||||
assert_eq!(
|
||||
GateFailureKind::classify("Diff in server/src/lib.rs\n--- original\n+++ reformatted"),
|
||||
GateFailureKind::Fmt
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_fmt_from_would_reformat() {
|
||||
assert_eq!(
|
||||
GateFailureKind::classify(
|
||||
"Checking server/src/lib.rs\nwould reformat server/src/lib.rs"
|
||||
),
|
||||
GateFailureKind::Fmt
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_lint_from_clippy_error() {
|
||||
assert_eq!(
|
||||
GateFailureKind::classify("error[clippy::unused_variable]: unused variable `x`"),
|
||||
GateFailureKind::Lint
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_lint_from_clippy_warning() {
|
||||
assert_eq!(
|
||||
GateFailureKind::classify("warning[clippy::needless_return]: unneeded return"),
|
||||
GateFailureKind::Lint
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_lint_from_missing_doc_comments() {
|
||||
assert_eq!(
|
||||
GateFailureKind::classify(
|
||||
"error: missing_doc_comments: public item lacks documentation"
|
||||
),
|
||||
GateFailureKind::Lint
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_source_map_check_from_missing_docs_direction() {
|
||||
assert_eq!(
|
||||
GateFailureKind::classify("missing-docs direction: server/src/foo.rs:42 pub fn bar"),
|
||||
GateFailureKind::SourceMapCheck
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_content_conflict() {
|
||||
assert_eq!(
|
||||
GateFailureKind::classify("CONFLICT (content): Merge conflict in server/src/lib.rs"),
|
||||
GateFailureKind::ContentConflict
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_build_from_rustc_compile_error() {
|
||||
// Post-squash compile errors (typical when master drifts under a feature
|
||||
// branch — e.g. story 1018 hit `error[E0063]: missing field` after
|
||||
// master gained a Stage::Coding field the feature branch did not set).
|
||||
assert_eq!(
|
||||
GateFailureKind::classify(
|
||||
"error[E0063]: missing field `plan` in initializer of `Stage`\n --> server/src/foo.rs:166:20"
|
||||
),
|
||||
GateFailureKind::Build
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_build_does_not_misfire_on_clippy_error() {
|
||||
// Clippy errors look like `error[clippy::name]` and must remain Lint,
|
||||
// not Build, because the `error[E` prefix would otherwise overlap.
|
||||
assert_eq!(
|
||||
GateFailureKind::classify("error[clippy::unused_variable]: unused variable `x`"),
|
||||
GateFailureKind::Lint
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_test_failure_for_unrecognised_output() {
|
||||
assert_eq!(
|
||||
GateFailureKind::classify("test result: FAILED. 3 passed; 1 failed"),
|
||||
GateFailureKind::Test
|
||||
);
|
||||
}
|
||||
|
||||
// ── GateFailureKind::is_self_evident_fix ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn fmt_is_self_evident_fix() {
|
||||
assert!(GateFailureKind::Fmt.is_self_evident_fix());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lint_is_self_evident_fix() {
|
||||
assert!(GateFailureKind::Lint.is_self_evident_fix());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_map_check_is_self_evident_fix() {
|
||||
assert!(GateFailureKind::SourceMapCheck.is_self_evident_fix());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_failure_is_not_self_evident_fix() {
|
||||
assert!(!GateFailureKind::Test.is_self_evident_fix());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_conflict_is_not_self_evident_fix() {
|
||||
assert!(!GateFailureKind::ContentConflict.is_self_evident_fix());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_error_is_not_self_evident_fix() {
|
||||
assert!(!GateFailureKind::Build.is_self_evident_fix());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_is_not_self_evident_fix() {
|
||||
assert!(!GateFailureKind::Other.is_self_evident_fix());
|
||||
}
|
||||
|
||||
// ── GateOutcome constructors ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn gate_outcome_pass_has_no_failure_kind() {
|
||||
let outcome = GateOutcome::pass("all tests passed".to_string());
|
||||
assert!(outcome.passed);
|
||||
assert!(outcome.failure_kind.is_none());
|
||||
assert_eq!(outcome.output, "all tests passed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_outcome_fail_classifies_kind() {
|
||||
let outcome = GateOutcome::fail("Diff in server/src/lib.rs".to_string());
|
||||
assert!(!outcome.passed);
|
||||
assert_eq!(outcome.failure_kind, Some(GateFailureKind::Fmt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_outcome_build_error_sets_build_kind() {
|
||||
let outcome = GateOutcome::build_error("ERROR [E0761]: duplicate module files".to_string());
|
||||
assert!(!outcome.passed);
|
||||
assert_eq!(outcome.failure_kind, Some(GateFailureKind::Build));
|
||||
}
|
||||
}
|
||||
|
||||
+661
-151
File diff suppressed because it is too large
Load Diff
@@ -1,66 +1,169 @@
|
||||
//! Project-local agent prompt layer.
|
||||
//!
|
||||
//! Reads `.huskies/AGENT.md` from the project root and appends its content to
|
||||
//! the baked-in agent prompt at spawn time. This lets projects record
|
||||
//! non-obvious facts (directory conventions, known traps, etc.) that every
|
||||
//! agent should know without modifying the shared agent configuration.
|
||||
//! Reads a fixed set of orientation files from the project root and concatenates
|
||||
//! them into a single bundle that is appended to every coder's system prompt at
|
||||
//! spawn time. This eliminates the cold-start tax where agents spent early turns
|
||||
//! reading files that could have been preloaded.
|
||||
//!
|
||||
//! Files bundled (in order):
|
||||
//! - `CLAUDE.md`
|
||||
//! - `.huskies/README.md`
|
||||
//! - `.huskies/specs/00_CONTEXT.md`
|
||||
//! - `.huskies/AGENT.md`
|
||||
//! - `.huskies/source-map.json` (up to 200 KB; truncated with a log if larger)
|
||||
//!
|
||||
//! `STACK.md` is intentionally excluded — it is large and changes often; agents
|
||||
//! should grep it on demand.
|
||||
//!
|
||||
//! Behaviour contract:
|
||||
//! - If the file is missing or empty the caller receives `None`; agents spawn
|
||||
//! normally with no warnings or errors.
|
||||
//! - If the file exists and is non-empty, the content is returned and an
|
||||
//! INFO-level log line is emitted with the file path and byte count.
|
||||
//! - The file is read fresh on every agent spawn — no caching.
|
||||
//! - Files that are missing or empty are skipped silently (no error, no section).
|
||||
//! - If all files are absent or empty, `None` is returned and agents spawn normally.
|
||||
//! - A single INFO-level log line is emitted with the total byte count and the
|
||||
//! list of files that were actually included.
|
||||
//! - Files are read fresh on every agent spawn — no caching.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Attempt to load the project-local agent prompt from `.huskies/AGENT.md`.
|
||||
/// Files to bundle into the orientation prompt, in order.
|
||||
/// Paths are relative to the project root.
|
||||
const ORIENTATION_FILES: &[&str] = &[
|
||||
"CLAUDE.md",
|
||||
".huskies/README.md",
|
||||
".huskies/specs/00_CONTEXT.md",
|
||||
".huskies/AGENT.md",
|
||||
];
|
||||
|
||||
/// Path to the source map (relative to project root), appended after AGENT.md.
|
||||
const SOURCE_MAP_REL: &str = ".huskies/source-map.json";
|
||||
|
||||
/// Maximum bytes of source-map content to embed in the prompt.
|
||||
const SOURCE_MAP_BYTE_CAP: usize = 200 * 1024;
|
||||
|
||||
/// Attempt to load the project-local agent prompt by concatenating orientation
|
||||
/// files from the project root.
|
||||
///
|
||||
/// Returns `Some(content)` when the file exists and is non-empty, or `None`
|
||||
/// when the file is absent or empty. Never returns an error; any I/O problem
|
||||
/// is silently treated as "no local prompt".
|
||||
/// Returns `Some(bundle)` when at least one file exists and is non-empty, with
|
||||
/// sections separated by `=== <file> ===` delimiters. Returns `None` when all
|
||||
/// files are absent or empty. Never returns an error.
|
||||
pub fn read_project_local_prompt(project_root: &Path) -> Option<String> {
|
||||
let path = project_root.join(".huskies/AGENT.md");
|
||||
let content = std::fs::read_to_string(&path).ok()?;
|
||||
let mut sections: Vec<(&str, String)> = Vec::new();
|
||||
|
||||
for &rel_path in ORIENTATION_FILES {
|
||||
let abs_path = project_root.join(rel_path);
|
||||
let Ok(content) = std::fs::read_to_string(&abs_path) else {
|
||||
continue;
|
||||
};
|
||||
let trimmed = content.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
sections.push((rel_path, trimmed.to_string()));
|
||||
}
|
||||
|
||||
// Read source-map.json (after AGENT.md) with a byte cap.
|
||||
let source_map_content = read_source_map_section(project_root);
|
||||
|
||||
if sections.is_empty() && source_map_content.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut included_files: Vec<&str> = sections.iter().map(|(name, _)| *name).collect();
|
||||
let mut bundle = String::new();
|
||||
for (i, (name, content)) in sections.iter().enumerate() {
|
||||
if i > 0 {
|
||||
bundle.push('\n');
|
||||
}
|
||||
bundle.push_str(&format!("=== {name} ===\n"));
|
||||
bundle.push_str(content);
|
||||
}
|
||||
|
||||
if let Some(sm) = source_map_content {
|
||||
if !bundle.is_empty() {
|
||||
bundle.push('\n');
|
||||
}
|
||||
bundle.push_str(&format!("=== {SOURCE_MAP_REL} ===\n"));
|
||||
bundle.push_str(&sm);
|
||||
included_files.push(SOURCE_MAP_REL);
|
||||
}
|
||||
|
||||
crate::slog!(
|
||||
"[agents] orientation bundle: {} bytes, files: [{}]",
|
||||
bundle.len(),
|
||||
included_files.join(", ")
|
||||
);
|
||||
|
||||
Some(bundle)
|
||||
}
|
||||
|
||||
/// Read `.huskies/source-map.json` from `project_root`, applying a byte cap.
|
||||
///
|
||||
/// Returns `None` when the file is absent, unreadable, or empty.
|
||||
/// When the content exceeds [`SOURCE_MAP_BYTE_CAP`], truncates at a char
|
||||
/// boundary and logs the truncation.
|
||||
#[allow(clippy::string_slice)] // cap is walked back to a char boundary before slicing
|
||||
fn read_source_map_section(project_root: &Path) -> Option<String> {
|
||||
let path = project_root.join(SOURCE_MAP_REL);
|
||||
let Ok(content) = std::fs::read_to_string(&path) else {
|
||||
return None;
|
||||
};
|
||||
let trimmed = content.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
crate::slog!(
|
||||
"[agents] project-local prompt loaded: {} ({} bytes)",
|
||||
path.display(),
|
||||
trimmed.len()
|
||||
);
|
||||
Some(trimmed.to_string())
|
||||
if trimmed.len() > SOURCE_MAP_BYTE_CAP {
|
||||
let mut cap = SOURCE_MAP_BYTE_CAP;
|
||||
while cap > 0 && !trimmed.is_char_boundary(cap) {
|
||||
cap -= 1;
|
||||
}
|
||||
crate::slog!(
|
||||
"[agents] source-map.json truncated: {} bytes > {} byte cap; \
|
||||
including first {} bytes",
|
||||
trimmed.len(),
|
||||
SOURCE_MAP_BYTE_CAP,
|
||||
cap
|
||||
);
|
||||
Some(trimmed[..cap].to_string())
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write_file(base: &Path, rel: &str, content: &str) {
|
||||
let path = base.join(rel);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
std::fs::write(path, content).unwrap();
|
||||
}
|
||||
|
||||
// ── Legacy tests (kept passing) ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_file_absent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let result = read_project_local_prompt(tmp.path());
|
||||
assert!(result.is_none(), "missing file must return None");
|
||||
assert!(result.is_none(), "all files missing must return None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_file_empty() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let huskies_dir = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&huskies_dir).unwrap();
|
||||
std::fs::write(huskies_dir.join("AGENT.md"), "").unwrap();
|
||||
write_file(tmp.path(), ".huskies/AGENT.md", "");
|
||||
let result = read_project_local_prompt(tmp.path());
|
||||
assert!(result.is_none(), "empty file must return None");
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"empty file must return None when others absent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_file_whitespace_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let huskies_dir = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&huskies_dir).unwrap();
|
||||
std::fs::write(huskies_dir.join("AGENT.md"), " \n\n ").unwrap();
|
||||
write_file(tmp.path(), ".huskies/AGENT.md", " \n\n ");
|
||||
let result = read_project_local_prompt(tmp.path());
|
||||
assert!(result.is_none(), "whitespace-only file must return None");
|
||||
}
|
||||
@@ -68,10 +171,12 @@ mod tests {
|
||||
#[test]
|
||||
fn returns_content_when_file_non_empty() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let huskies_dir = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&huskies_dir).unwrap();
|
||||
let marker = "DISTINCTIVE_MARKER_XYZ42";
|
||||
std::fs::write(huskies_dir.join("AGENT.md"), format!("# Hints\n{marker}\n")).unwrap();
|
||||
write_file(
|
||||
tmp.path(),
|
||||
".huskies/AGENT.md",
|
||||
&format!("# Hints\n{marker}\n"),
|
||||
);
|
||||
let result = read_project_local_prompt(tmp.path());
|
||||
assert!(result.is_some(), "non-empty file must return Some");
|
||||
let content = result.unwrap();
|
||||
@@ -83,13 +188,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn appended_to_prompt_integration() {
|
||||
// Simulates the start.rs usage: marker appears in the constructed
|
||||
// system prompt when the file is present, absent when it is not.
|
||||
let tmp_with = tempfile::tempdir().unwrap();
|
||||
let huskies_dir = tmp_with.path().join(".huskies");
|
||||
std::fs::create_dir_all(&huskies_dir).unwrap();
|
||||
let marker = "INTEGRATION_MARKER_601";
|
||||
std::fs::write(huskies_dir.join("AGENT.md"), marker).unwrap();
|
||||
write_file(tmp_with.path(), ".huskies/AGENT.md", marker);
|
||||
|
||||
let base_prompt = "You are a coder agent.".to_string();
|
||||
let local = read_project_local_prompt(tmp_with.path());
|
||||
@@ -102,7 +203,6 @@ mod tests {
|
||||
"marker must appear in effective prompt when file present: {effective}"
|
||||
);
|
||||
|
||||
// Without the file
|
||||
let tmp_without = tempfile::tempdir().unwrap();
|
||||
let local2 = read_project_local_prompt(tmp_without.path());
|
||||
assert!(local2.is_none(), "no marker when file absent");
|
||||
@@ -115,4 +215,201 @@ mod tests {
|
||||
"marker must NOT appear in effective prompt when file absent: {effective2}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── New tests for four-file bundle behaviour ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn all_four_files_present_concatenated() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_file(tmp.path(), "CLAUDE.md", "claude content");
|
||||
write_file(tmp.path(), ".huskies/README.md", "readme content");
|
||||
write_file(
|
||||
tmp.path(),
|
||||
".huskies/specs/00_CONTEXT.md",
|
||||
"context content",
|
||||
);
|
||||
write_file(tmp.path(), ".huskies/AGENT.md", "agent content");
|
||||
|
||||
let result = read_project_local_prompt(tmp.path()).unwrap();
|
||||
assert!(
|
||||
result.contains("claude content"),
|
||||
"must include CLAUDE.md: {result}"
|
||||
);
|
||||
assert!(
|
||||
result.contains("readme content"),
|
||||
"must include README.md: {result}"
|
||||
);
|
||||
assert!(
|
||||
result.contains("context content"),
|
||||
"must include 00_CONTEXT.md: {result}"
|
||||
);
|
||||
assert!(
|
||||
result.contains("agent content"),
|
||||
"must include AGENT.md: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn some_files_missing_only_present_in_bundle() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_file(tmp.path(), "CLAUDE.md", "claude only");
|
||||
// README.md, 00_CONTEXT.md, AGENT.md absent
|
||||
|
||||
let result = read_project_local_prompt(tmp.path()).unwrap();
|
||||
assert!(
|
||||
result.contains("claude only"),
|
||||
"must include CLAUDE.md: {result}"
|
||||
);
|
||||
assert!(
|
||||
!result.contains("=== .huskies/README.md ==="),
|
||||
"absent README must not have a section delimiter: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_files_missing_returns_none() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let result = read_project_local_prompt(tmp.path());
|
||||
assert!(result.is_none(), "all absent must return None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_delimiters_appear_between_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_file(tmp.path(), "CLAUDE.md", "first");
|
||||
write_file(tmp.path(), ".huskies/AGENT.md", "last");
|
||||
|
||||
let result = read_project_local_prompt(tmp.path()).unwrap();
|
||||
assert!(
|
||||
result.contains("=== CLAUDE.md ==="),
|
||||
"must have CLAUDE.md delimiter: {result}"
|
||||
);
|
||||
assert!(
|
||||
result.contains("=== .huskies/AGENT.md ==="),
|
||||
"must have AGENT.md delimiter: {result}"
|
||||
);
|
||||
// Delimiter for CLAUDE.md must appear before its content
|
||||
let claude_delim = result.find("=== CLAUDE.md ===").unwrap();
|
||||
let first_content = result.find("first").unwrap();
|
||||
assert!(
|
||||
claude_delim < first_content,
|
||||
"delimiter must precede content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_md_not_included() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_file(tmp.path(), "CLAUDE.md", "hello");
|
||||
write_file(tmp.path(), ".huskies/specs/tech/STACK.md", "stack content");
|
||||
|
||||
let result = read_project_local_prompt(tmp.path()).unwrap();
|
||||
assert!(
|
||||
!result.contains("stack content"),
|
||||
"STACK.md must not appear in bundle: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── source-map.json tests ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn source_map_included_after_agent_md() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_file(tmp.path(), ".huskies/AGENT.md", "agent content");
|
||||
write_file(
|
||||
tmp.path(),
|
||||
".huskies/source-map.json",
|
||||
r#"{"src/lib.rs": {"symbols": ["foo"]}}"#,
|
||||
);
|
||||
|
||||
let result = read_project_local_prompt(tmp.path()).unwrap();
|
||||
assert!(
|
||||
result.contains("=== .huskies/source-map.json ==="),
|
||||
"source-map delimiter must be present: {result}"
|
||||
);
|
||||
assert!(
|
||||
result.contains(r#""src/lib.rs""#),
|
||||
"source-map content must be present: {result}"
|
||||
);
|
||||
// source-map section must appear after AGENT.md section
|
||||
let agent_pos = result.find("=== .huskies/AGENT.md ===").unwrap();
|
||||
let sm_pos = result.find("=== .huskies/source-map.json ===").unwrap();
|
||||
assert!(
|
||||
sm_pos > agent_pos,
|
||||
"source-map section must come after AGENT.md section"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_map_missing_skipped_silently() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_file(tmp.path(), ".huskies/AGENT.md", "agent content");
|
||||
// source-map.json intentionally absent
|
||||
|
||||
let result = read_project_local_prompt(tmp.path()).unwrap();
|
||||
assert!(
|
||||
!result.contains("source-map.json"),
|
||||
"absent source-map must not create a section: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_map_empty_skipped_silently() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_file(tmp.path(), ".huskies/AGENT.md", "agent content");
|
||||
write_file(tmp.path(), ".huskies/source-map.json", "");
|
||||
|
||||
let result = read_project_local_prompt(tmp.path()).unwrap();
|
||||
assert!(
|
||||
!result.contains("source-map.json"),
|
||||
"empty source-map must not create a section: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_map_only_returns_some() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Only source-map.json present; all orientation files absent.
|
||||
write_file(
|
||||
tmp.path(),
|
||||
".huskies/source-map.json",
|
||||
r#"{"src/main.rs": {}}"#,
|
||||
);
|
||||
|
||||
let result = read_project_local_prompt(tmp.path());
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"source-map alone must produce Some bundle"
|
||||
);
|
||||
assert!(
|
||||
result.unwrap().contains("=== .huskies/source-map.json ==="),
|
||||
"bundle must contain source-map section"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::string_slice)] // sm_start is derived from str::find — always a char boundary
|
||||
fn source_map_truncated_at_byte_cap() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_file(tmp.path(), ".huskies/AGENT.md", "agent");
|
||||
// Build content larger than SOURCE_MAP_BYTE_CAP (200 KB).
|
||||
let big = "x".repeat(SOURCE_MAP_BYTE_CAP + 1024);
|
||||
write_file(tmp.path(), ".huskies/source-map.json", &big);
|
||||
|
||||
let result = read_project_local_prompt(tmp.path()).unwrap();
|
||||
assert!(
|
||||
result.contains("=== .huskies/source-map.json ==="),
|
||||
"truncated source-map must still produce a section: {result}"
|
||||
);
|
||||
// The content length of just the source-map section must be <= SOURCE_MAP_BYTE_CAP.
|
||||
let sm_start = result.find("=== .huskies/source-map.json ===").unwrap()
|
||||
+ "=== .huskies/source-map.json ===\n".len();
|
||||
let sm_content = &result[sm_start..];
|
||||
assert!(
|
||||
sm_content.len() <= SOURCE_MAP_BYTE_CAP,
|
||||
"source-map section content must be <= {} bytes, got {}",
|
||||
SOURCE_MAP_BYTE_CAP,
|
||||
sm_content.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+166
-19
@@ -6,6 +6,82 @@ mod squash;
|
||||
|
||||
pub(crate) use squash::run_squash_merge;
|
||||
|
||||
/// Typed outcome of a completed squash-merge operation.
|
||||
///
|
||||
/// Each variant captures only the fields relevant to that outcome, eliminating
|
||||
/// the four-bool soup of the old `MergeReport`.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum MergeResult {
|
||||
/// Squash commit landed on the base branch and all quality gates passed.
|
||||
Success {
|
||||
/// `true` when conflicts were detected and automatically resolved.
|
||||
conflicts_resolved: bool,
|
||||
conflict_details: Option<String>,
|
||||
/// Human-readable output from the quality-gate run.
|
||||
gate_output: String,
|
||||
},
|
||||
/// Merge was aborted due to unresolvable conflicts; base branch is untouched.
|
||||
Conflict {
|
||||
details: Option<String>,
|
||||
output: String,
|
||||
},
|
||||
/// Squash commit produced but quality gates failed; base branch may carry the commit.
|
||||
GateFailure {
|
||||
output: String,
|
||||
#[serde(default)]
|
||||
failure_kind: Option<crate::agents::gates::GateFailureKind>,
|
||||
},
|
||||
/// Feature branch had zero commits ahead of the base branch.
|
||||
NoCommits { output: String },
|
||||
/// Unclassified failure (cherry-pick failed, git error, etc.).
|
||||
Other {
|
||||
output: String,
|
||||
conflict_details: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl MergeResult {
|
||||
/// Extract the human-readable output string from any variant.
|
||||
pub fn output(&self) -> &str {
|
||||
match self {
|
||||
Self::Success { gate_output, .. } => gate_output,
|
||||
Self::Conflict { output, .. }
|
||||
| Self::GateFailure { output, .. }
|
||||
| Self::NoCommits { output }
|
||||
| Self::Other { output, .. } => output,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a non-success outcome into the pipeline-level `MergeFailureKind`
|
||||
/// used to drive recovery routing.
|
||||
///
|
||||
/// A `GateFailure` whose typed `failure_kind` is `Build` is reclassified as
|
||||
/// `ConflictDetected` — a post-squash compile error after a clean git merge
|
||||
/// is semantically a merge conflict that git's diff3 missed (the conflicting
|
||||
/// literal lives in a different file from the type definition that changed
|
||||
/// on master). Routing it as a conflict ensures mergemaster auto-spawns to
|
||||
/// resolve it, rather than the failure sitting as an opaque `GatesFailed`.
|
||||
///
|
||||
/// Panics on `Success`; callers must guard with a success check first.
|
||||
pub fn to_merge_failure_kind(&self) -> crate::pipeline_state::MergeFailureKind {
|
||||
use crate::pipeline_state::MergeFailureKind;
|
||||
match self {
|
||||
Self::Success { .. } => {
|
||||
panic!("to_merge_failure_kind called on MergeResult::Success")
|
||||
}
|
||||
Self::NoCommits { .. } => MergeFailureKind::NoCommits,
|
||||
Self::Conflict { details, .. } => MergeFailureKind::ConflictDetected(details.clone()),
|
||||
Self::GateFailure {
|
||||
output,
|
||||
failure_kind: Some(crate::agents::gates::GateFailureKind::Build),
|
||||
} => MergeFailureKind::ConflictDetected(Some(output.clone())),
|
||||
Self::GateFailure { output, .. } => MergeFailureKind::GatesFailed(output.clone()),
|
||||
Self::Other { output, .. } => MergeFailureKind::Other(output.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of an async merge job.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum MergeJobStatus {
|
||||
@@ -33,27 +109,98 @@ pub struct MergeJob {
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct MergeReport {
|
||||
pub story_id: String,
|
||||
pub success: bool,
|
||||
pub had_conflicts: bool,
|
||||
/// `true` when conflicts were detected but automatically resolved.
|
||||
pub conflicts_resolved: bool,
|
||||
pub conflict_details: Option<String>,
|
||||
pub gates_passed: bool,
|
||||
pub gate_output: String,
|
||||
/// Typed outcome of the merge operation.
|
||||
pub result: MergeResult,
|
||||
pub worktree_cleaned_up: bool,
|
||||
pub story_archived: bool,
|
||||
}
|
||||
|
||||
/// Result of a squash-merge operation.
|
||||
pub(crate) struct SquashMergeResult {
|
||||
pub(crate) success: bool,
|
||||
pub(crate) had_conflicts: bool,
|
||||
/// `true` when conflicts were detected but automatically resolved.
|
||||
pub(crate) conflicts_resolved: bool,
|
||||
pub(crate) conflict_details: Option<String>,
|
||||
pub(crate) output: String,
|
||||
/// Whether quality gates ran and passed. `false` when `success` is `false`
|
||||
/// due to a gate failure; callers can use this to distinguish gate failures
|
||||
/// from merge/commit/FF failures in the `MergeReport`.
|
||||
pub(crate) gates_passed: bool,
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::gates::GateFailureKind;
|
||||
use crate::pipeline_state::MergeFailureKind;
|
||||
|
||||
#[test]
|
||||
fn to_failure_kind_maps_build_gate_failure_to_conflict_detected() {
|
||||
// Post-squash compile error (e.g. story 1018: `error[E0063]: missing field
|
||||
// plan` because master gained Stage::Coding's plan field after the
|
||||
// coder's branch was committed) is a semantic merge conflict that git
|
||||
// missed. Mergemaster should auto-spawn, so the kind must be
|
||||
// ConflictDetected, not GatesFailed.
|
||||
let output = "error[E0063]: missing field `plan` in initializer of `Stage`".to_string();
|
||||
let result = MergeResult::GateFailure {
|
||||
output: output.clone(),
|
||||
failure_kind: Some(GateFailureKind::Build),
|
||||
};
|
||||
match result.to_merge_failure_kind() {
|
||||
MergeFailureKind::ConflictDetected(details) => {
|
||||
assert_eq!(details.as_deref(), Some(output.as_str()));
|
||||
}
|
||||
other => panic!("expected ConflictDetected, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_failure_kind_maps_test_gate_failure_to_gates_failed() {
|
||||
// Non-build gate failures (test failure, fmt drift, lint, etc.) are
|
||||
// genuine gate problems, not merge conflicts. They must remain
|
||||
// GatesFailed so mergemaster does NOT auto-spawn for them.
|
||||
let result = MergeResult::GateFailure {
|
||||
output: "test result: FAILED. 1 failed".to_string(),
|
||||
failure_kind: Some(GateFailureKind::Test),
|
||||
};
|
||||
assert!(matches!(
|
||||
result.to_merge_failure_kind(),
|
||||
MergeFailureKind::GatesFailed(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_failure_kind_maps_git_conflict_to_conflict_detected() {
|
||||
let result = MergeResult::Conflict {
|
||||
details: Some("conflicts in server/src/foo.rs".to_string()),
|
||||
output: "merge failed".to_string(),
|
||||
};
|
||||
match result.to_merge_failure_kind() {
|
||||
MergeFailureKind::ConflictDetected(details) => {
|
||||
assert_eq!(details.as_deref(), Some("conflicts in server/src/foo.rs"));
|
||||
}
|
||||
other => panic!("expected ConflictDetected, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_failure_kind_maps_no_commits() {
|
||||
let result = MergeResult::NoCommits {
|
||||
output: "no commits".to_string(),
|
||||
};
|
||||
assert!(matches!(
|
||||
result.to_merge_failure_kind(),
|
||||
MergeFailureKind::NoCommits
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_failure_kind_maps_other() {
|
||||
let result = MergeResult::Other {
|
||||
output: "cherry-pick failed".to_string(),
|
||||
conflict_details: None,
|
||||
};
|
||||
assert!(matches!(
|
||||
result.to_merge_failure_kind(),
|
||||
MergeFailureKind::Other(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "to_merge_failure_kind called on MergeResult::Success")]
|
||||
fn to_failure_kind_panics_on_success() {
|
||||
let result = MergeResult::Success {
|
||||
conflicts_resolved: false,
|
||||
conflict_details: None,
|
||||
gate_output: String::new(),
|
||||
};
|
||||
let _ = result.to_merge_failure_kind();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::process::Command;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use super::super::gates::run_project_tests;
|
||||
use super::{MergeReport, SquashMergeResult};
|
||||
use super::{MergeReport, MergeResult};
|
||||
use crate::config::ProjectConfig;
|
||||
|
||||
/// Global lock ensuring only one squash-merge runs at a time.
|
||||
@@ -21,7 +21,7 @@ pub(crate) fn run_squash_merge(
|
||||
project_root: &Path,
|
||||
branch: &str,
|
||||
story_id: &str,
|
||||
) -> Result<SquashMergeResult, String> {
|
||||
) -> Result<MergeResult, String> {
|
||||
// Acquire the merge lock so concurrent calls don't clobber each other.
|
||||
let _lock = MERGE_LOCK
|
||||
.lock()
|
||||
@@ -48,10 +48,12 @@ pub(crate) fn run_squash_merge(
|
||||
.parse()
|
||||
.unwrap_or(1); // parse failure → don't false-positive; let merge proceed
|
||||
if ahead == 0 {
|
||||
return Err(format!(
|
||||
"{story_id}: no commits to merge — feature branch '{branch}' \
|
||||
has 0 commits ahead of '{base_branch}'"
|
||||
));
|
||||
return Ok(MergeResult::NoCommits {
|
||||
output: format!(
|
||||
"{story_id}: no commits to merge — feature branch '{branch}' \
|
||||
has 0 commits ahead of '{base_branch}'"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,9 +108,6 @@ pub(crate) fn run_squash_merge(
|
||||
all_output.push_str(&merge_stderr);
|
||||
all_output.push('\n');
|
||||
|
||||
let conflicts_resolved = false;
|
||||
let mut conflict_details: Option<String> = None;
|
||||
|
||||
if !merge.status.success() {
|
||||
all_output.push_str(
|
||||
"=== Conflicts detected — aborting merge. Use `start_agent mergemaster` \
|
||||
@@ -116,18 +115,12 @@ pub(crate) fn run_squash_merge(
|
||||
);
|
||||
let details =
|
||||
format!("Merge conflicts in branch '{branch}':\n{merge_stdout}{merge_stderr}");
|
||||
conflict_details = Some(details);
|
||||
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||
return Ok(SquashMergeResult {
|
||||
success: false,
|
||||
had_conflicts: true,
|
||||
conflicts_resolved,
|
||||
conflict_details,
|
||||
return Ok(MergeResult::Conflict {
|
||||
details: Some(details),
|
||||
output: all_output,
|
||||
gates_passed: false,
|
||||
});
|
||||
}
|
||||
let had_conflicts = false;
|
||||
|
||||
// ── Commit in the temporary worktree ──────────────────────────
|
||||
all_output.push_str("=== git commit ===\n");
|
||||
@@ -158,23 +151,16 @@ pub(crate) fn run_squash_merge(
|
||||
all_output
|
||||
.push_str("=== Nothing to commit — feature branch already merged into base ===\n");
|
||||
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||
return Ok(SquashMergeResult {
|
||||
success: true,
|
||||
had_conflicts: false,
|
||||
return Ok(MergeResult::Success {
|
||||
conflicts_resolved: false,
|
||||
conflict_details: None,
|
||||
output: all_output,
|
||||
gates_passed: true,
|
||||
gate_output: all_output,
|
||||
});
|
||||
}
|
||||
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||
return Ok(SquashMergeResult {
|
||||
success: false,
|
||||
had_conflicts,
|
||||
conflicts_resolved,
|
||||
conflict_details,
|
||||
return Ok(MergeResult::Other {
|
||||
output: all_output,
|
||||
gates_passed: false,
|
||||
conflict_details: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -196,17 +182,13 @@ pub(crate) fn run_squash_merge(
|
||||
"=== Merge commit contains only .huskies/ file moves, no code changes ===\n",
|
||||
);
|
||||
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||
return Ok(SquashMergeResult {
|
||||
success: false,
|
||||
had_conflicts,
|
||||
conflicts_resolved,
|
||||
return Ok(MergeResult::Other {
|
||||
output: all_output,
|
||||
conflict_details: Some(
|
||||
"Feature branch has no code changes outside .huskies/ — only \
|
||||
pipeline file moves were found."
|
||||
.to_string(),
|
||||
),
|
||||
output: all_output,
|
||||
gates_passed: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -251,37 +233,29 @@ pub(crate) fn run_squash_merge(
|
||||
// Run gates in the merge worktree so that failures abort before master moves.
|
||||
all_output.push_str("=== Running quality gates before fast-forward ===\n");
|
||||
match run_merge_quality_gates(&merge_wt_path) {
|
||||
Ok((true, gate_out)) => {
|
||||
all_output.push_str(&gate_out);
|
||||
Ok(outcome) if outcome.passed => {
|
||||
all_output.push_str(&outcome.output);
|
||||
all_output.push('\n');
|
||||
all_output.push_str("=== Quality gates passed ===\n");
|
||||
}
|
||||
Ok((false, gate_out)) => {
|
||||
all_output.push_str(&gate_out);
|
||||
Ok(outcome) => {
|
||||
all_output.push_str(&outcome.output);
|
||||
all_output.push('\n');
|
||||
all_output.push_str(
|
||||
"=== Quality gates FAILED — aborting fast-forward, master unchanged ===\n",
|
||||
);
|
||||
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||
return Ok(SquashMergeResult {
|
||||
success: false,
|
||||
had_conflicts,
|
||||
conflicts_resolved,
|
||||
conflict_details,
|
||||
return Ok(MergeResult::GateFailure {
|
||||
output: all_output,
|
||||
gates_passed: false,
|
||||
failure_kind: outcome.failure_kind,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
all_output.push_str(&format!("Gate check error: {e}\n"));
|
||||
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||
return Ok(SquashMergeResult {
|
||||
success: false,
|
||||
had_conflicts,
|
||||
conflicts_resolved,
|
||||
conflict_details,
|
||||
return Ok(MergeResult::GateFailure {
|
||||
output: all_output,
|
||||
gates_passed: false,
|
||||
failure_kind: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -314,15 +288,11 @@ pub(crate) fn run_squash_merge(
|
||||
.output();
|
||||
all_output.push_str("=== Cherry-pick failed — aborting, master unchanged ===\n");
|
||||
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||
return Ok(SquashMergeResult {
|
||||
success: false,
|
||||
had_conflicts,
|
||||
conflicts_resolved,
|
||||
return Ok(MergeResult::Other {
|
||||
output: all_output,
|
||||
conflict_details: Some(format!(
|
||||
"Cherry-pick of squash commit failed (conflict with master?):\n{cp_stderr}"
|
||||
)),
|
||||
output: all_output,
|
||||
gates_passed: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -349,15 +319,11 @@ pub(crate) fn run_squash_merge(
|
||||
'{current_branch}'. Cherry-pick landed on wrong branch. ===\n"
|
||||
));
|
||||
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||
return Ok(SquashMergeResult {
|
||||
success: false,
|
||||
had_conflicts,
|
||||
conflicts_resolved,
|
||||
return Ok(MergeResult::Other {
|
||||
output: all_output,
|
||||
conflict_details: Some(format!(
|
||||
"Cherry-pick landed on '{current_branch}' instead of '{base_branch}'"
|
||||
)),
|
||||
output: all_output,
|
||||
gates_passed: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -383,15 +349,11 @@ pub(crate) fn run_squash_merge(
|
||||
"=== VERIFICATION FAILED: cherry-pick produced no code changes on master. ===\n",
|
||||
);
|
||||
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||
return Ok(SquashMergeResult {
|
||||
success: false,
|
||||
had_conflicts,
|
||||
conflicts_resolved,
|
||||
return Ok(MergeResult::Other {
|
||||
output: all_output,
|
||||
conflict_details: Some(
|
||||
"Cherry-pick commit contains no code changes (empty diff)".to_string(),
|
||||
),
|
||||
output: all_output,
|
||||
gates_passed: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -399,17 +361,62 @@ pub(crate) fn run_squash_merge(
|
||||
"=== Verified: cherry-pick landed on '{base_branch}' with code changes ===\n"
|
||||
));
|
||||
|
||||
// ── Regen source-map.json on master after cherry-pick ─────────
|
||||
// Run deterministically on project_root (now on master). Skip the commit
|
||||
// when regen produces no diff (idempotent case). Failure is non-fatal.
|
||||
{
|
||||
let map_path = project_root.join(".huskies").join("source-map.json");
|
||||
let old_content = std::fs::read_to_string(&map_path).ok();
|
||||
match source_map_gen::regenerate_source_map(project_root, &map_path) {
|
||||
Err(e) => {
|
||||
all_output.push_str(&format!(
|
||||
"=== source-map regen failed (non-fatal): {e} ===\n"
|
||||
));
|
||||
}
|
||||
Ok(()) => {
|
||||
let new_content = std::fs::read_to_string(&map_path).ok();
|
||||
if old_content != new_content {
|
||||
all_output.push_str("=== source-map.json changed — committing on master ===\n");
|
||||
let _ = Command::new("git")
|
||||
.args(["add", ".huskies/source-map.json"])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
match Command::new("git")
|
||||
.args(["commit", "-m", "huskies: regen source-map.json"])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
{
|
||||
Ok(c) if c.status.success() => {
|
||||
all_output.push_str("=== source-map.json committed on master ===\n");
|
||||
}
|
||||
Ok(c) => {
|
||||
let stderr = String::from_utf8_lossy(&c.stderr);
|
||||
all_output.push_str(&format!(
|
||||
"=== source-map commit failed (non-fatal): {stderr} ===\n"
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
all_output.push_str(&format!(
|
||||
"=== source-map commit error (non-fatal): {e} ===\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
all_output
|
||||
.push_str("=== source-map.json unchanged — no follow-up commit ===\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Clean up ──────────────────────────────────────────────────
|
||||
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||
all_output.push_str("=== Merge-queue cleanup complete ===\n");
|
||||
|
||||
Ok(SquashMergeResult {
|
||||
success: true,
|
||||
had_conflicts,
|
||||
conflicts_resolved,
|
||||
conflict_details,
|
||||
output: all_output,
|
||||
gates_passed: true,
|
||||
Ok(MergeResult::Success {
|
||||
conflicts_resolved: false,
|
||||
conflict_details: None,
|
||||
gate_output: all_output,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -435,7 +442,11 @@ pub(crate) fn cleanup_merge_workspace(
|
||||
.output();
|
||||
}
|
||||
|
||||
fn run_merge_quality_gates(project_root: &Path) -> Result<(bool, String), String> {
|
||||
fn run_merge_quality_gates(
|
||||
project_root: &Path,
|
||||
) -> Result<crate::agents::gates::GateOutcome, String> {
|
||||
use crate::agents::gates::GateOutcome;
|
||||
|
||||
let mut all_output = String::new();
|
||||
let mut all_passed = true;
|
||||
|
||||
@@ -449,7 +460,11 @@ fn run_merge_quality_gates(project_root: &Path) -> Result<(bool, String), String
|
||||
if !success {
|
||||
all_passed = false;
|
||||
}
|
||||
return Ok((all_passed, all_output));
|
||||
return if all_passed {
|
||||
Ok(GateOutcome::pass(all_output))
|
||||
} else {
|
||||
Ok(GateOutcome::fail(all_output))
|
||||
};
|
||||
}
|
||||
|
||||
// No script/test — fall back to cargo gates for Rust projects.
|
||||
@@ -481,7 +496,11 @@ fn run_merge_quality_gates(project_root: &Path) -> Result<(bool, String), String
|
||||
}
|
||||
}
|
||||
|
||||
Ok((all_passed, all_output))
|
||||
if all_passed {
|
||||
Ok(GateOutcome::pass(all_output))
|
||||
} else {
|
||||
Ok(GateOutcome::fail(all_output))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -64,9 +64,9 @@ async fn squash_merge_md_only_changes_fails() {
|
||||
// The squash merge will commit the .huskies/ file, but should fail because
|
||||
// there are no code changes outside .huskies/.
|
||||
assert!(
|
||||
!result.success,
|
||||
"merge with only .huskies/ changes must fail: {}",
|
||||
result.output
|
||||
!matches!(result, super::MergeResult::Success { .. }),
|
||||
"merge with only .huskies/ changes must fail: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
// Cleanup should still happen.
|
||||
@@ -146,12 +146,10 @@ async fn squash_merge_additive_conflict_both_additions_preserved() {
|
||||
let result = run_squash_merge(repo, "feature/story-238_additive", "238_additive").unwrap();
|
||||
|
||||
// Deterministic merge does NOT auto-resolve conflicts — AC3 requires failure.
|
||||
assert!(result.had_conflicts, "additive conflict should be detected");
|
||||
assert!(
|
||||
!result.conflicts_resolved,
|
||||
"deterministic merge must NOT auto-resolve conflicts"
|
||||
matches!(result, super::MergeResult::Conflict { .. }),
|
||||
"additive conflict should produce Conflict variant; got: {result:?}"
|
||||
);
|
||||
assert!(!result.success, "conflict must cause merge failure");
|
||||
|
||||
// Master must not have been modified (merge aborted).
|
||||
let content = fs::read_to_string(repo.join("module.rs")).unwrap();
|
||||
@@ -254,18 +252,13 @@ async fn squash_merge_conflict_resolved_but_gates_fail_reported_as_failure() {
|
||||
// Squash-merge: conflict detected → aborted immediately (no gate run).
|
||||
let result = run_squash_merge(repo, "feature/story-238_gates_fail", "238_gates_fail").unwrap();
|
||||
|
||||
assert!(result.had_conflicts, "conflict must be detected");
|
||||
assert!(
|
||||
!result.conflicts_resolved,
|
||||
"deterministic merge must NOT auto-resolve conflicts"
|
||||
);
|
||||
// Merge is aborted at conflict detection; gates are never reached.
|
||||
assert!(
|
||||
!result.success,
|
||||
"conflicting merge must be reported as failed"
|
||||
matches!(result, super::MergeResult::Conflict { .. }),
|
||||
"conflicting merge must produce Conflict variant; got: {result:?}"
|
||||
);
|
||||
assert!(
|
||||
!result.output.is_empty(),
|
||||
!result.output().is_empty(),
|
||||
"output must contain conflict details"
|
||||
);
|
||||
|
||||
@@ -329,9 +322,9 @@ async fn squash_merge_cleans_up_stale_workspace() {
|
||||
let result = run_squash_merge(repo, "feature/story-stale_test", "stale_test").unwrap();
|
||||
|
||||
assert!(
|
||||
result.success,
|
||||
"merge should succeed after cleaning up stale workspace: {}",
|
||||
result.output
|
||||
matches!(result, super::MergeResult::Success { .. }),
|
||||
"merge should succeed after cleaning up stale workspace: {:?}",
|
||||
result
|
||||
);
|
||||
assert!(
|
||||
!stale_ws.exists(),
|
||||
@@ -398,18 +391,124 @@ fn squash_merge_runs_component_setup_from_project_toml() {
|
||||
|
||||
// The output must mention component setup, proving the new code path ran.
|
||||
assert!(
|
||||
result.output.contains("component setup"),
|
||||
result.output().contains("component setup"),
|
||||
"merge output must mention component setup when project.toml has components, got:\n{}",
|
||||
result.output
|
||||
result.output()
|
||||
);
|
||||
// The sentinel command must appear in the output.
|
||||
assert!(
|
||||
result.output.contains("sentinel"),
|
||||
result.output().contains("sentinel"),
|
||||
"merge output must name the component, got:\n{}",
|
||||
result.output
|
||||
result.output()
|
||||
);
|
||||
}
|
||||
|
||||
/// AC6: the regen+commit step runs on `project_root` (master) only.
|
||||
/// After a successful merge where the source-map changes, `git log --name-only`
|
||||
/// shows a follow-up commit whose diff contains ONLY `.huskies/source-map.json`.
|
||||
#[tokio::test]
|
||||
async fn regen_commit_on_master_touches_only_source_map() {
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_git_repo(repo);
|
||||
|
||||
// Put a stale source-map.json on master so regen will produce a different result.
|
||||
let sk_dir = repo.join(".huskies");
|
||||
fs::create_dir_all(&sk_dir).unwrap();
|
||||
fs::write(sk_dir.join("source-map.json"), "{\"stale\": true}\n").unwrap();
|
||||
|
||||
// Add a tracked Rust file so the regenerator has something to index.
|
||||
fs::create_dir_all(repo.join("src")).unwrap();
|
||||
fs::write(
|
||||
repo.join("src/lib.rs"),
|
||||
"//! Library.\n\n/// Says hello.\npub fn hello() {}\n",
|
||||
)
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "initial with stale source-map"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Feature branch: add a new file.
|
||||
Command::new("git")
|
||||
.args(["checkout", "-b", "feature/story-1065_regen_test"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
fs::write(
|
||||
repo.join("src/extra.rs"),
|
||||
"//! Extra.\n\n/// Extra fn.\npub fn extra() {}\n",
|
||||
)
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "add extra.rs"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["checkout", "master"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let result =
|
||||
run_squash_merge(repo, "feature/story-1065_regen_test", "1065_regen_test").unwrap();
|
||||
|
||||
assert!(
|
||||
matches!(result, super::MergeResult::Success { .. }),
|
||||
"clean merge must succeed; got: {result:?}"
|
||||
);
|
||||
|
||||
// Find the regen commit if one was created.
|
||||
let log_out = Command::new("git")
|
||||
.args(["log", "--oneline", "--name-only"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
let log = String::from_utf8_lossy(&log_out.stdout);
|
||||
|
||||
// If a regen commit exists, its diff must contain ONLY the source-map path.
|
||||
if log.contains("huskies: regen source-map.json") {
|
||||
// Extract files changed in the regen commit.
|
||||
let show_out = Command::new("git")
|
||||
.args(["show", "--name-only", "--format=", "HEAD"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
let show = String::from_utf8_lossy(&show_out.stdout);
|
||||
|
||||
// If HEAD is the regen commit, its files list must be exactly one entry.
|
||||
let head_msg = Command::new("git")
|
||||
.args(["log", "-1", "--format=%s"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
let head_subject = String::from_utf8_lossy(&head_msg.stdout);
|
||||
if head_subject.trim() == "huskies: regen source-map.json" {
|
||||
let changed_files: Vec<&str> = show.lines().filter(|l| !l.is_empty()).collect();
|
||||
assert_eq!(
|
||||
changed_files,
|
||||
vec![".huskies/source-map.json"],
|
||||
"regen commit must touch ONLY .huskies/source-map.json; got: {changed_files:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn squash_merge_succeeds_without_components_in_project_toml() {
|
||||
@@ -461,13 +560,13 @@ fn squash_merge_succeeds_without_components_in_project_toml() {
|
||||
|
||||
// No pnpm or frontend references should appear in the output.
|
||||
assert!(
|
||||
!result.output.contains("pnpm"),
|
||||
!result.output().contains("pnpm"),
|
||||
"output must not mention pnpm, got:\n{}",
|
||||
result.output
|
||||
result.output()
|
||||
);
|
||||
assert!(
|
||||
!result.output.contains("frontend/"),
|
||||
!result.output().contains("frontend/"),
|
||||
"output must not mention frontend/, got:\n{}",
|
||||
result.output
|
||||
result.output()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,21 +101,11 @@ async fn squash_merge_uses_merge_queue_no_conflict_markers_on_master() {
|
||||
"master must never contain conflict markers, got:\n{master_content}"
|
||||
);
|
||||
|
||||
// The merge should have had conflicts.
|
||||
assert!(result.had_conflicts, "should detect conflicts");
|
||||
|
||||
// Conflicts should have been auto-resolved (both are simple additions).
|
||||
if result.conflicts_resolved {
|
||||
assert!(result.success, "auto-resolved merge should succeed");
|
||||
assert!(
|
||||
master_content.contains("master addition"),
|
||||
"master side should be present"
|
||||
);
|
||||
assert!(
|
||||
master_content.contains("feature addition"),
|
||||
"feature side should be present"
|
||||
);
|
||||
}
|
||||
// The merge should have had conflicts (returned as Conflict variant).
|
||||
assert!(
|
||||
matches!(result, super::MergeResult::Conflict { .. }),
|
||||
"should detect conflicts; got: {result:?}"
|
||||
);
|
||||
|
||||
// Verify no leftover merge-queue branch.
|
||||
let branches = Command::new("git")
|
||||
@@ -172,14 +162,15 @@ async fn squash_merge_clean_merge_succeeds() {
|
||||
|
||||
let result = run_squash_merge(repo, "feature/story-clean_test", "clean_test").unwrap();
|
||||
|
||||
assert!(result.success, "clean merge should succeed");
|
||||
assert!(
|
||||
!result.had_conflicts,
|
||||
"clean merge should have no conflicts"
|
||||
);
|
||||
assert!(
|
||||
!result.conflicts_resolved,
|
||||
"no conflicts means nothing to resolve"
|
||||
matches!(
|
||||
result,
|
||||
super::MergeResult::Success {
|
||||
conflicts_resolved: false,
|
||||
..
|
||||
}
|
||||
),
|
||||
"clean merge should succeed without conflicts; got: {result:?}"
|
||||
);
|
||||
assert!(
|
||||
repo.join("new_file.txt").exists(),
|
||||
@@ -197,7 +188,10 @@ async fn squash_merge_nonexistent_branch_fails() {
|
||||
|
||||
let result = run_squash_merge(repo, "feature/story-nope", "nope").unwrap();
|
||||
|
||||
assert!(!result.success, "merge of nonexistent branch should fail");
|
||||
assert!(
|
||||
!matches!(result, super::MergeResult::Success { .. }),
|
||||
"merge of nonexistent branch should fail; got: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -267,11 +261,10 @@ async fn squash_merge_succeeds_when_master_diverges() {
|
||||
let result = run_squash_merge(repo, "feature/story-diverge_test", "diverge_test").unwrap();
|
||||
|
||||
assert!(
|
||||
result.success,
|
||||
"squash merge should succeed despite diverged master: {}",
|
||||
result.output
|
||||
matches!(result, super::MergeResult::Success { .. }),
|
||||
"squash merge should succeed despite diverged master: {:?}",
|
||||
result
|
||||
);
|
||||
assert!(!result.had_conflicts, "no conflicts expected");
|
||||
|
||||
// Verify the feature file landed on master.
|
||||
assert!(
|
||||
@@ -346,9 +339,9 @@ async fn squash_merge_empty_diff_fails() {
|
||||
// Either form is a failure — just not success.
|
||||
match result {
|
||||
Ok(r) => assert!(
|
||||
!r.success,
|
||||
"empty diff merge must fail, not silently succeed: {}",
|
||||
r.output
|
||||
!matches!(r, super::MergeResult::Success { .. }),
|
||||
"empty diff merge must fail, not silently succeed: {:?}",
|
||||
r
|
||||
),
|
||||
Err(e) => assert!(
|
||||
e.contains("no commits to merge") || e.contains("nothing to commit"),
|
||||
@@ -417,24 +410,21 @@ async fn idempotent_retry_after_successful_merge_returns_success() {
|
||||
.expect("first merge produces Ok");
|
||||
// The merge may fail gates in test env (no script/test); only require that
|
||||
// the squash applied SOMETHING (cargo gates env-dependent).
|
||||
if r1.success {
|
||||
if matches!(r1, super::MergeResult::Success { .. }) {
|
||||
// Second merge of the SAME branch: must report success (idempotent),
|
||||
// not merge_failure. Feature branch's content is already on master so
|
||||
// the squash produces "nothing to commit" — bug 777 makes this success.
|
||||
let r2 = run_squash_merge(repo, "feature/story-777_idem", "777_idem")
|
||||
.expect("second merge produces Ok");
|
||||
assert!(
|
||||
r2.success,
|
||||
"idempotent retry must return success: {}",
|
||||
r2.output
|
||||
);
|
||||
assert!(
|
||||
!r2.had_conflicts,
|
||||
"idempotent retry should report no conflicts"
|
||||
);
|
||||
assert!(
|
||||
r2.conflict_details.is_none(),
|
||||
"no conflict_details on idempotent retry"
|
||||
matches!(
|
||||
r2,
|
||||
super::MergeResult::Success {
|
||||
conflicts_resolved: false,
|
||||
..
|
||||
}
|
||||
),
|
||||
"idempotent retry must return Success without conflicts: {r2:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ pub mod session_store;
|
||||
/// Token-usage tracking and budget estimation.
|
||||
pub mod token_usage;
|
||||
|
||||
/// Typed agent model enum (Sonnet/Opus/Haiku).
|
||||
pub mod model;
|
||||
pub use model::AgentModel;
|
||||
|
||||
use crate::config::AgentConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -89,6 +93,30 @@ pub enum AgentStatus {
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// The execution state of a rate-limited agent session.
|
||||
///
|
||||
/// Replaces the legacy `throttled: bool` flag, carrying the expiry time so
|
||||
/// the scheduler can decide when to allow a retry rather than skipping
|
||||
/// indefinitely.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum AgentExecution {
|
||||
/// The agent hit a rate-limit and is paused until `until`.
|
||||
Throttled {
|
||||
/// UTC instant at which the rate limit expires and the agent may resume.
|
||||
until: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
impl AgentExecution {
|
||||
/// Return `true` if the throttle period has not yet elapsed.
|
||||
pub fn is_active(&self) -> bool {
|
||||
match self {
|
||||
Self::Throttled { until } => chrono::Utc::now() < *until,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an agent was forcibly terminated by the watchdog.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -157,6 +185,11 @@ pub struct CompletionReport {
|
||||
pub summary: String,
|
||||
pub gates_passed: bool,
|
||||
pub gate_output: String,
|
||||
/// True when the coder exited with no commits but left uncommitted content in
|
||||
/// the worktree. The pipeline advance will issue a commit-only recovery respawn
|
||||
/// rather than a normal retry, and will NOT consume a `retry_count` slot.
|
||||
#[serde(default)]
|
||||
pub needs_commit_recovery: bool,
|
||||
}
|
||||
|
||||
/// Token usage from a Claude Code session's `result` event.
|
||||
@@ -176,13 +209,13 @@ impl TokenUsage {
|
||||
/// data in the agent log (since `total_cost_usd` is only available in the
|
||||
/// `result` event at session end). Uses conservative (high) pricing when
|
||||
/// the model is unknown so budget limits are hit sooner rather than later.
|
||||
pub fn estimate_cost_usd(&self, model: Option<&str>) -> f64 {
|
||||
pub fn estimate_cost_usd(&self, model: Option<&AgentModel>) -> f64 {
|
||||
// Per-million-token pricing (input, output, cache_read, cache_create).
|
||||
let (inp, out, cr, cc) = match model {
|
||||
Some(m) if m.contains("haiku") => (0.80, 4.0, 0.08, 1.00),
|
||||
Some(m) if m.contains("sonnet") => (3.0, 15.0, 0.30, 3.75),
|
||||
// Opus or unknown → most expensive = conservative.
|
||||
_ => (15.0, 75.0, 1.50, 18.75),
|
||||
Some(AgentModel::Haiku) => (0.80, 4.0, 0.08, 1.00),
|
||||
Some(AgentModel::Sonnet) => (3.0, 15.0, 0.30, 3.75),
|
||||
// Opus, Other, or unknown → most expensive = conservative.
|
||||
Some(AgentModel::Opus) | Some(AgentModel::Other(_)) | None => (15.0, 75.0, 1.50, 18.75),
|
||||
};
|
||||
(self.input_tokens as f64 * inp
|
||||
+ self.output_tokens as f64 * out
|
||||
@@ -231,8 +264,9 @@ pub struct AgentInfo {
|
||||
pub completion: Option<CompletionReport>,
|
||||
/// UUID identifying the persistent log file for this session.
|
||||
pub log_session_id: Option<String>,
|
||||
/// True when a rate-limit throttle warning was received for this agent.
|
||||
pub throttled: bool,
|
||||
/// Set when the agent is rate-limited; holds the UTC expiry time.
|
||||
/// `None` when the agent is not throttled.
|
||||
pub throttled: Option<chrono::DateTime<chrono::Utc>>,
|
||||
/// Set when the watchdog terminates the agent for exceeding a limit.
|
||||
pub termination_reason: Option<TerminationReason>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Typed agent model — replaces raw model strings throughout the agent subsystem.
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::fmt;
|
||||
|
||||
/// Supported agent model families.
|
||||
///
|
||||
/// Serialises to the canonical short name ("sonnet", "opus", "haiku") or, for
|
||||
/// `Other`, the original string verbatim. Deserialises from any string:
|
||||
/// Claude family names are matched by substring (e.g. "claude-sonnet-4-6"),
|
||||
/// everything else becomes `Other(string)` so non-Claude runtimes (Gemini,
|
||||
/// OpenAI, etc.) survive a config round-trip without error.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AgentModel {
|
||||
/// Claude Sonnet family.
|
||||
Sonnet,
|
||||
/// Claude Opus family.
|
||||
Opus,
|
||||
/// Claude Haiku family.
|
||||
Haiku,
|
||||
/// Any model string not recognised as a Claude family name.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl AgentModel {
|
||||
/// Canonical short name used for serialisation and CLI `--model` flags.
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Sonnet => "sonnet",
|
||||
Self::Opus => "opus",
|
||||
Self::Haiku => "haiku",
|
||||
Self::Other(s) => s.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse any model string into a variant — always succeeds.
|
||||
///
|
||||
/// Claude family names are matched by substring; everything else becomes
|
||||
/// `Other`.
|
||||
pub fn from_api_str(s: &str) -> Self {
|
||||
if s.contains("haiku") {
|
||||
Self::Haiku
|
||||
} else if s.contains("sonnet") {
|
||||
Self::Sonnet
|
||||
} else if s.contains("opus") {
|
||||
Self::Opus
|
||||
} else {
|
||||
Self::Other(s.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AgentModel {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for AgentModel {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for AgentModel {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(d)?;
|
||||
Ok(Self::from_api_str(&s))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn short_names_deserialise() {
|
||||
let s: AgentModel = serde_json::from_str("\"sonnet\"").unwrap();
|
||||
assert_eq!(s, AgentModel::Sonnet);
|
||||
let o: AgentModel = serde_json::from_str("\"opus\"").unwrap();
|
||||
assert_eq!(o, AgentModel::Opus);
|
||||
let h: AgentModel = serde_json::from_str("\"haiku\"").unwrap();
|
||||
assert_eq!(h, AgentModel::Haiku);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_names_deserialise() {
|
||||
let s: AgentModel = serde_json::from_str("\"claude-sonnet-4-6\"").unwrap();
|
||||
assert_eq!(s, AgentModel::Sonnet);
|
||||
let h: AgentModel = serde_json::from_str("\"claude-haiku-4-5-20251001\"").unwrap();
|
||||
assert_eq!(h, AgentModel::Haiku);
|
||||
let o: AgentModel = serde_json::from_str("\"claude-opus-4-5\"").unwrap();
|
||||
assert_eq!(o, AgentModel::Opus);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialises_to_short_name() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AgentModel::Sonnet).unwrap(),
|
||||
"\"sonnet\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AgentModel::Opus).unwrap(),
|
||||
"\"opus\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AgentModel::Haiku).unwrap(),
|
||||
"\"haiku\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_string_becomes_other() {
|
||||
let r: AgentModel = serde_json::from_str("\"gemini-2.5-pro\"").unwrap();
|
||||
assert_eq!(r, AgentModel::Other("gemini-2.5-pro".to_string()));
|
||||
assert_eq!(r.as_str(), "gemini-2.5-pro");
|
||||
// Round-trips verbatim
|
||||
assert_eq!(serde_json::to_string(&r).unwrap(), "\"gemini-2.5-pro\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_none_round_trips() {
|
||||
let v: Option<AgentModel> = serde_json::from_str("null").unwrap();
|
||||
assert!(v.is_none());
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ impl AgentPool {
|
||||
/// 3. Trigger server-side merges (or auto-spawn mergemaster) for `4_merge/`.
|
||||
pub async fn auto_assign_available_work(&self, project_root: &Path) {
|
||||
// Promote any backlog stories whose dependencies are all done.
|
||||
self.promote_ready_backlog_stories(project_root);
|
||||
self.promote_ready_backlog_stories();
|
||||
|
||||
let config = match ProjectConfig::load(project_root) {
|
||||
Ok(c) => c,
|
||||
@@ -57,7 +57,12 @@ mod tests {
|
||||
.unwrap();
|
||||
// Place the story in 2_current/ via CRDT (the only source of truth).
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content("story-3", "2_current", "---\nname: Story 3\n---\n");
|
||||
crate::db::write_item_with_content(
|
||||
"story-3",
|
||||
"2_current",
|
||||
"---\nname: Story 3\n---\n",
|
||||
crate::db::ItemMeta::named("Story 3"),
|
||||
);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
// No agents are running — coder-1 is free.
|
||||
@@ -139,6 +144,11 @@ mod tests {
|
||||
"9930_story_qa1",
|
||||
"3_qa",
|
||||
"---\nname: QA Story\nagent: coder-1\n---\n",
|
||||
crate::db::ItemMeta {
|
||||
name: Some("QA Story".into()),
|
||||
agent: Some("coder-1".into()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
@@ -188,6 +198,11 @@ mod tests {
|
||||
"story-pref",
|
||||
"2_current",
|
||||
"---\nname: Coder Story\nagent: coder-1\n---\n",
|
||||
crate::db::ItemMeta {
|
||||
name: Some("Coder Story".into()),
|
||||
agent: Some("coder-1".into()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
@@ -235,6 +250,11 @@ mod tests {
|
||||
"9931_story_noqa",
|
||||
"3_qa",
|
||||
"---\nname: QA Story No Agent\nagent: coder-1\n---\n",
|
||||
crate::db::ItemMeta {
|
||||
name: Some("QA Story No Agent".into()),
|
||||
agent: Some("coder-1".into()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
@@ -273,8 +293,10 @@ mod tests {
|
||||
crate::db::write_item_with_content(
|
||||
"9932_story_waiting",
|
||||
"2_current",
|
||||
"---\nname: Waiting\ndepends_on: [9999]\n---\n",
|
||||
"# Waiting\n",
|
||||
crate::db::ItemMeta::named("Waiting"),
|
||||
);
|
||||
crate::crdt_state::set_depends_on("9932_story_waiting", &[9999]);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
pool.auto_assign_available_work(root).await;
|
||||
@@ -307,13 +329,20 @@ mod tests {
|
||||
// Seed stories via CRDT (the only source of truth).
|
||||
crate::db::ensure_content_store();
|
||||
// Dep 999 is now done.
|
||||
crate::db::write_item_with_content("999_story_dep", "5_done", "---\nname: Dep\n---\n");
|
||||
crate::db::write_item_with_content(
|
||||
"999_story_dep",
|
||||
"5_done",
|
||||
"---\nname: Dep\n---\n",
|
||||
crate::db::ItemMeta::named("Dep"),
|
||||
);
|
||||
// Story 10 depends on 999 which is done.
|
||||
crate::db::write_item_with_content(
|
||||
"10_story_unblocked",
|
||||
"2_current",
|
||||
"---\nname: Unblocked\ndepends_on: [999]\n---\n",
|
||||
"# Unblocked\n",
|
||||
crate::db::ItemMeta::named("Unblocked"),
|
||||
);
|
||||
crate::crdt_state::set_depends_on("10_story_unblocked", &[999]);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
pool.auto_assign_available_work(root).await;
|
||||
@@ -355,18 +384,18 @@ mod tests {
|
||||
crate::db::ensure_content_store();
|
||||
let dep_content = "---\nname: Dep\n---\n";
|
||||
std::fs::write(done.join("1_story_dep.md"), dep_content).unwrap();
|
||||
crate::db::write_content("1_story_dep", dep_content);
|
||||
crate::db::write_content(crate::db::ContentKey::Story("1_story_dep"), dep_content);
|
||||
// Story B depends on story 1.
|
||||
let story_b_content = "---\nname: B\ndepends_on: [1]\n---\n";
|
||||
std::fs::write(backlog.join("2_story_b.md"), story_b_content).unwrap();
|
||||
crate::db::write_content("2_story_b", story_b_content);
|
||||
crate::db::write_content(crate::db::ContentKey::Story("2_story_b"), story_b_content);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
pool.auto_assign_available_work(root).await;
|
||||
|
||||
// The lifecycle function updates the content store (not the filesystem),
|
||||
// so verify the move via the DB.
|
||||
let content = crate::db::read_content("2_story_b")
|
||||
let content = crate::db::read_content(crate::db::ContentKey::Story("2_story_b"))
|
||||
.expect("story B should be in content store after promotion");
|
||||
assert!(
|
||||
content.contains("name: B"),
|
||||
@@ -429,11 +458,14 @@ mod tests {
|
||||
crate::db::ensure_content_store();
|
||||
let dep_content = "---\nname: CRDT Spike\n---\n";
|
||||
std::fs::write(archived.join("490_spike_crdt.md"), dep_content).unwrap();
|
||||
crate::db::write_content("490_spike_crdt", dep_content);
|
||||
crate::db::write_content(crate::db::ContentKey::Story("490_spike_crdt"), dep_content);
|
||||
// Story 478 depends on 490 (the archived spike).
|
||||
let story_content = "---\nname: Dependent\ndepends_on: [490]\n---\n";
|
||||
std::fs::write(backlog.join("478_story_dependent.md"), story_content).unwrap();
|
||||
crate::db::write_content("478_story_dependent", story_content);
|
||||
crate::db::write_content(
|
||||
crate::db::ContentKey::Story("478_story_dependent"),
|
||||
story_content,
|
||||
);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
pool.auto_assign_available_work(root).await;
|
||||
@@ -441,7 +473,7 @@ mod tests {
|
||||
// Story 478 must be promoted even though dep 490 is only in 6_archived
|
||||
// (not in 5_done), because archived = satisfied. The lifecycle function
|
||||
// updates the content store, so verify via the DB.
|
||||
let content = crate::db::read_content("478_story_dependent")
|
||||
let content = crate::db::read_content(crate::db::ContentKey::Story("478_story_dependent"))
|
||||
.expect("story 478 should be in content store after promotion");
|
||||
assert!(
|
||||
content.contains("name: Dependent"),
|
||||
@@ -477,145 +509,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Story 827: auto-spawn mergemaster on content conflict ─────────────────
|
||||
|
||||
/// A story in 4_merge with a content-conflict merge_failure and no
|
||||
/// mergemaster_attempted flag must trigger an auto-spawn of mergemaster.
|
||||
#[tokio::test]
|
||||
async fn auto_assign_spawns_mergemaster_for_content_conflict() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&sk).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
"[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"9860_story_conflict",
|
||||
"4_merge",
|
||||
"---\nname: Conflict\nmerge_failure: \"CONFLICT (content): server/src/lib.rs\"\n---\n",
|
||||
);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
pool.auto_assign_available_work(tmp.path()).await;
|
||||
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
let mergemaster_spawned = agents.iter().any(|(key, a)| {
|
||||
key.contains("9860_story_conflict")
|
||||
&& a.agent_name == "mergemaster"
|
||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
});
|
||||
assert!(
|
||||
mergemaster_spawned,
|
||||
"mergemaster should be spawned for a content-conflict story"
|
||||
);
|
||||
}
|
||||
|
||||
/// A story with merge_failure containing only "nothing to commit" must NOT
|
||||
/// auto-spawn mergemaster.
|
||||
#[tokio::test]
|
||||
async fn auto_assign_does_not_spawn_mergemaster_for_non_conflict_failure() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&sk).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
"[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"9861_story_nothing",
|
||||
"4_merge",
|
||||
"---\nname: Nothing\nmerge_failure: \"nothing to commit, working tree clean\"\n---\n",
|
||||
);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
pool.auto_assign_available_work(tmp.path()).await;
|
||||
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
let mergemaster_spawned = agents.iter().any(|(key, a)| {
|
||||
key.contains("9861_story_nothing")
|
||||
&& a.agent_name == "mergemaster"
|
||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
});
|
||||
assert!(
|
||||
!mergemaster_spawned,
|
||||
"mergemaster must not be spawned for non-conflict failures"
|
||||
);
|
||||
}
|
||||
|
||||
/// A story in 4_merge with blocked: true must NOT auto-spawn mergemaster
|
||||
/// even when it has an unresolved content-conflict merge_failure and
|
||||
/// mergemaster_attempted is still false.
|
||||
#[tokio::test]
|
||||
async fn auto_assign_does_not_spawn_mergemaster_for_blocked_story() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&sk).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
"[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"9863_story_blocked_conflict",
|
||||
"4_merge",
|
||||
"---\nname: Blocked conflict\nmerge_failure: \"CONFLICT (content): foo.rs\"\nblocked: true\n---\n",
|
||||
);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
pool.auto_assign_available_work(tmp.path()).await;
|
||||
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
let mergemaster_spawned = agents.iter().any(|(key, a)| {
|
||||
key.contains("9863_story_blocked_conflict")
|
||||
&& a.agent_name == "mergemaster"
|
||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
});
|
||||
assert!(
|
||||
!mergemaster_spawned,
|
||||
"mergemaster must not be spawned for a blocked story"
|
||||
);
|
||||
}
|
||||
|
||||
/// A story with mergemaster_attempted: true must NOT auto-spawn again, even
|
||||
/// if the merge_failure still contains a content conflict.
|
||||
#[tokio::test]
|
||||
async fn auto_assign_does_not_respawn_mergemaster_when_already_attempted() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&sk).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
"[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"9862_story_attempted",
|
||||
"4_merge",
|
||||
"---\nname: Already tried\nmerge_failure: \"CONFLICT (content): foo.rs\"\nmergemaster_attempted: true\n---\n",
|
||||
);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
pool.auto_assign_available_work(tmp.path()).await;
|
||||
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
let mergemaster_spawned = agents.iter().any(|(key, a)| {
|
||||
key.contains("9862_story_attempted")
|
||||
&& a.agent_name == "mergemaster"
|
||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
});
|
||||
assert!(
|
||||
!mergemaster_spawned,
|
||||
"mergemaster must not re-spawn when mergemaster_attempted is true"
|
||||
);
|
||||
}
|
||||
|
||||
/// Two concurrent auto_assign_available_work calls must not assign the same
|
||||
/// agent to two stories simultaneously. After both complete, at most one
|
||||
/// Pending/Running entry must exist per agent name.
|
||||
@@ -675,4 +568,72 @@ mod tests {
|
||||
found {active_coder_count} active entries"
|
||||
);
|
||||
}
|
||||
|
||||
// ── AC4: startup event replay + pool reconstruction ──────────────────
|
||||
|
||||
/// AC4: Simulates a server restart by seeding the CRDT with a story in
|
||||
/// Coding stage, calling `replay_current_pipeline_state` (the new startup
|
||||
/// path), then `auto_assign_available_work`. Asserts the pool ends in the
|
||||
/// expected state: exactly one agent assigned to the story.
|
||||
#[tokio::test]
|
||||
async fn startup_replay_followed_by_auto_assign_assigns_agent_once() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&sk).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
crate::db::ensure_content_store();
|
||||
let story_id = "9903_restart_replay";
|
||||
crate::db::write_item_with_content(
|
||||
story_id,
|
||||
"2_current",
|
||||
"---\nname: Restart Replay\n---\n",
|
||||
crate::db::ItemMeta::named("Restart Replay"),
|
||||
);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
|
||||
// Simulate startup: replay current state, then auto-assign.
|
||||
crate::pipeline_state::replay_current_pipeline_state();
|
||||
pool.auto_assign_available_work(tmp.path()).await;
|
||||
|
||||
let count_after_first = {
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
agents
|
||||
.iter()
|
||||
.filter(|(key, a)| {
|
||||
key.contains(story_id)
|
||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
})
|
||||
.count()
|
||||
};
|
||||
|
||||
// AC3 (idempotency): replaying twice must not double-spawn agents.
|
||||
crate::pipeline_state::replay_current_pipeline_state();
|
||||
pool.auto_assign_available_work(tmp.path()).await;
|
||||
|
||||
let count_after_second = {
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
agents
|
||||
.iter()
|
||||
.filter(|(key, a)| {
|
||||
key.contains(story_id)
|
||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
})
|
||||
.count()
|
||||
};
|
||||
|
||||
assert!(
|
||||
count_after_first <= 1,
|
||||
"after first replay+assign at most one agent must be assigned to {story_id}"
|
||||
);
|
||||
assert_eq!(
|
||||
count_after_first, count_after_second,
|
||||
"second replay must not spawn additional agents (idempotency)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Backlog promotion: scan `1_backlog/` and promote stories whose `depends_on` are all met.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::pipeline_state::Stage;
|
||||
use crate::slog;
|
||||
use crate::slog_warn;
|
||||
|
||||
@@ -23,27 +22,23 @@ impl AgentPool {
|
||||
/// was abandoned/superseded before the dependent existed), a prominent warning is
|
||||
/// logged so the user can see the promotion was triggered by an archived dep, not
|
||||
/// a clean completion.
|
||||
pub(super) fn promote_ready_backlog_stories(&self, project_root: &Path) {
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
|
||||
let items = scan_stage_items(project_root, "1_backlog");
|
||||
pub(super) fn promote_ready_backlog_stories(&self) {
|
||||
let items = scan_stage_items(&Stage::Backlog);
|
||||
for story_id in &items {
|
||||
// Only promote stories that explicitly declare dependencies.
|
||||
let contents = crate::db::read_content(story_id);
|
||||
let has_deps = contents
|
||||
.and_then(|c| parse_front_matter(&c).ok())
|
||||
.and_then(|m| m.depends_on)
|
||||
.map(|d| !d.is_empty())
|
||||
// Only promote stories that explicitly declare dependencies
|
||||
// (story 929: read from the CRDT register, not YAML).
|
||||
let has_deps = crate::crdt_state::read_item(story_id)
|
||||
.map(|w| !w.depends_on().is_empty())
|
||||
.unwrap_or(false);
|
||||
if !has_deps {
|
||||
continue;
|
||||
}
|
||||
// Check whether any dependencies are still unmet.
|
||||
if has_unmet_dependencies(project_root, "1_backlog", story_id) {
|
||||
if has_unmet_dependencies(story_id) {
|
||||
continue;
|
||||
}
|
||||
// Warn if any deps were satisfied via archive rather than via clean done.
|
||||
let archived_deps = check_archived_dependencies(project_root, "1_backlog", story_id);
|
||||
let archived_deps = check_archived_dependencies(story_id);
|
||||
if !archived_deps.is_empty() {
|
||||
slog_warn!(
|
||||
"[auto-assign] Story '{story_id}' is being promoted because deps \
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! Merge stage dispatch: trigger server-side merges and auto-spawn mergemaster for content conflicts.
|
||||
//! Merge stage dispatch: trigger server-side squash-merges for stories in `Stage::Merge`.
|
||||
|
||||
use std::num::NonZeroU32;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::pipeline_state::{BranchName, Stage};
|
||||
use crate::slog;
|
||||
use crate::slog_error;
|
||||
use crate::slog_warn;
|
||||
@@ -10,20 +12,19 @@ use crate::worktree;
|
||||
|
||||
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::scan::{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_unmet_dependencies, is_story_blocked, is_story_frozen,
|
||||
has_review_hold, has_unmet_dependencies, is_story_blocked, is_story_frozen,
|
||||
};
|
||||
|
||||
impl AgentPool {
|
||||
/// Process stories in `4_merge/`: trigger server-side squash-merges and auto-spawn
|
||||
/// a mergemaster agent when a content-conflict failure is detected.
|
||||
/// Process stories in `4_merge/`: trigger server-side squash-merges.
|
||||
///
|
||||
/// 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. Each eligible story without an active merge job triggers
|
||||
/// `trigger_server_side_merge`.
|
||||
/// Each eligible story without an active merge job triggers
|
||||
/// `trigger_server_side_merge`. Mergemaster auto-spawn for
|
||||
/// `Stage::MergeFailure` stories is handled by the
|
||||
/// [`merge_failure_subscriber`][super::merge_failure_subscriber] — this
|
||||
/// function no longer scans the `merge_failure` stage.
|
||||
pub(super) async fn assign_merge_stage(&self, project_root: &Path, config: &ProjectConfig) {
|
||||
// ── 4_merge: deterministic server-side merge (no LLM agent) ──────────
|
||||
//
|
||||
@@ -34,86 +35,29 @@ impl AgentPool {
|
||||
// written to the CRDT and a notification is emitted; the story stays in
|
||||
// 4_merge/ until a human intervenes or an explicit `start_agent mergemaster`
|
||||
// call invokes the LLM-driven recovery path.
|
||||
let merge_items = scan_stage_items(project_root, "4_merge");
|
||||
let merge_stage = Stage::Merge {
|
||||
feature_branch: BranchName(String::new()),
|
||||
commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"),
|
||||
claim: None,
|
||||
retries: 0,
|
||||
server_start_time: None,
|
||||
};
|
||||
let merge_items = scan_stage_items(&merge_stage);
|
||||
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::io::story_metadata::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::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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if has_review_hold(story_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if has_review_hold(project_root, "4_merge", story_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_story_frozen(project_root, "4_merge", story_id) {
|
||||
if is_story_frozen(story_id) {
|
||||
slog!("[auto-assign] Story '{story_id}' in 4_merge/ is frozen; skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_story_blocked(project_root, "4_merge", story_id) {
|
||||
if is_story_blocked(story_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if has_unmet_dependencies(project_root, "4_merge", story_id) {
|
||||
if has_unmet_dependencies(story_id) {
|
||||
slog!("[auto-assign] Story '{story_id}' in 4_merge/ has unmet deps; skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
//! TransitionFired subscriber that auto-blocks stories after N consecutive MergeFailure transitions.
|
||||
//!
|
||||
//! Listens on the pipeline transition broadcast channel and, for each story,
|
||||
//! counts how many times it has entered [`Stage::MergeFailure`] consecutively.
|
||||
//! When the count reaches the configurable threshold (default 3), the story is
|
||||
//! transitioned to [`Stage::Blocked`] with a reason that names the failure kind.
|
||||
//!
|
||||
//! The counter for a story resets whenever a non-`MergeFailure` transition fires
|
||||
//! for that story (e.g. after a successful merge or a `FixupRequested` demotion
|
||||
//! back to coding).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::pipeline_state::{MergeFailureKind, PipelineEvent, Stage, StoryId};
|
||||
use crate::slog;
|
||||
use crate::slog_warn;
|
||||
|
||||
use super::super::super::PipelineStage;
|
||||
use super::super::AgentPool;
|
||||
use super::scan::is_story_assigned_for_stage;
|
||||
|
||||
/// Spawn a background task that blocks stories after N consecutive `MergeFailure` transitions.
|
||||
///
|
||||
/// Subscribes to the pipeline transition broadcast channel and tracks a per-story
|
||||
/// consecutive-failure counter. When a story's count reaches the threshold configured
|
||||
/// in `project.toml` (`merge_failure_block_threshold`, default 3), the story is
|
||||
/// transitioned to `Stage::Blocked` with a reason that names the failure kind.
|
||||
///
|
||||
/// The counter resets when the story leaves `MergeFailure` (e.g. on `FixupRequested`,
|
||||
/// `ReQueuedForQa`, or a successful merge via `Unblock → Merge → Done`).
|
||||
///
|
||||
/// Bug 1025: while a mergemaster is actively running on the story, its
|
||||
/// iteration loop (squash → fail → fix → retry) generates multiple
|
||||
/// MergeFailure transitions. Those are NOT consecutive give-ups — they are
|
||||
/// recovery iterations in progress. We skip counter increments while a
|
||||
/// mergemaster is in the pool for the story; the counter only increments on
|
||||
/// transitions that happen with no recovery agent attached.
|
||||
pub(crate) fn spawn_merge_failure_block_subscriber(pool: Arc<AgentPool>, project_root: PathBuf) {
|
||||
let mut rx = crate::pipeline_state::subscribe_transitions();
|
||||
tokio::spawn(async move {
|
||||
let mut counters: HashMap<StoryId, (u32, MergeFailureKind)> = HashMap::new();
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(fired) => {
|
||||
let recovery_running =
|
||||
is_mergemaster_running(&pool, &project_root, &fired.story_id.0);
|
||||
on_transition(&project_root, &fired, &mut counters, recovery_running);
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
slog_warn!(
|
||||
"[merge-block-sub] Subscriber lagged, skipped {n} event(s). \
|
||||
Some consecutive-failure counts may be understated."
|
||||
);
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Return true if a mergemaster agent is currently in the pool for `story_id`.
|
||||
/// Used to suppress counter increments while recovery is actively iterating
|
||||
/// (bug 1025).
|
||||
fn is_mergemaster_running(pool: &AgentPool, project_root: &Path, story_id: &str) -> bool {
|
||||
let config = match crate::config::ProjectConfig::load(project_root) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let agents = match pool.agents.lock() {
|
||||
Ok(a) => a,
|
||||
Err(_) => return false,
|
||||
};
|
||||
is_story_assigned_for_stage(&config, &agents, story_id, &PipelineStage::Mergemaster)
|
||||
}
|
||||
|
||||
/// Handle a single transition event: update counters and emit Block if threshold is reached.
|
||||
///
|
||||
/// `recovery_running`: when `true`, a mergemaster is currently in the pool for
|
||||
/// the story and the failure is part of an in-flight recovery loop. We do NOT
|
||||
/// increment the consecutive-failure counter in that case (bug 1025).
|
||||
fn on_transition(
|
||||
project_root: &Path,
|
||||
fired: &crate::pipeline_state::TransitionFired,
|
||||
counters: &mut HashMap<StoryId, (u32, MergeFailureKind)>,
|
||||
recovery_running: bool,
|
||||
) {
|
||||
match &fired.after {
|
||||
Stage::MergeFailure { kind, .. } => {
|
||||
if recovery_running {
|
||||
slog!(
|
||||
"[merge-block-sub] Story '{}' MergeFailure while mergemaster is running; \
|
||||
not counting toward block threshold (recovery in progress).",
|
||||
fired.story_id.0
|
||||
);
|
||||
return;
|
||||
}
|
||||
let entry = counters
|
||||
.entry(fired.story_id.clone())
|
||||
.or_insert_with(|| (0, kind.clone()));
|
||||
entry.0 += 1;
|
||||
entry.1 = kind.clone();
|
||||
|
||||
let count = entry.0;
|
||||
let threshold = load_threshold(project_root);
|
||||
|
||||
if threshold == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if count >= threshold {
|
||||
let kind_str = failure_kind_label(kind);
|
||||
let reason = format!(
|
||||
"Auto-blocked after {count} consecutive MergeFailure ({kind_str}) transitions."
|
||||
);
|
||||
let story_id = fired.story_id.0.as_str();
|
||||
slog!(
|
||||
"[merge-block-sub] Story '{story_id}' reached {count} consecutive \
|
||||
MergeFailure ({kind_str}); blocking."
|
||||
);
|
||||
if let Err(e) = crate::pipeline_state::apply_transition(
|
||||
story_id,
|
||||
PipelineEvent::Block { reason },
|
||||
None,
|
||||
) {
|
||||
slog_warn!("[merge-block-sub] Failed to block '{story_id}': {e}");
|
||||
} else {
|
||||
counters.remove(&fired.story_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
counters.remove(&fired.story_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the threshold from project config, falling back to the compiled default.
|
||||
fn load_threshold(project_root: &Path) -> u32 {
|
||||
crate::config::ProjectConfig::load(project_root)
|
||||
.map(|c| c.merge_failure_block_threshold)
|
||||
.unwrap_or(3)
|
||||
}
|
||||
|
||||
/// Short human-readable label for a [`MergeFailureKind`] variant.
|
||||
fn failure_kind_label(kind: &MergeFailureKind) -> &'static str {
|
||||
match kind {
|
||||
MergeFailureKind::ConflictDetected(_) => "ConflictDetected",
|
||||
MergeFailureKind::GatesFailed(_) => "GatesFailed",
|
||||
MergeFailureKind::EmptyDiff => "EmptyDiff",
|
||||
MergeFailureKind::NoCommits => "NoCommits",
|
||||
MergeFailureKind::Other(_) => "Other",
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pipeline_state::{BranchName, PipelineEvent, Stage, StoryId, TransitionFired};
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
fn setup_project(tmp: &tempfile::TempDir) {
|
||||
let sk = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&sk).unwrap();
|
||||
std::fs::write(sk.join("project.toml"), "[[agent]]\nname = \"coder\"\n").unwrap();
|
||||
}
|
||||
|
||||
fn seed_at_merge(story_id: &str) {
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
story_id,
|
||||
"4_merge",
|
||||
"---\nname: Test\n---\n",
|
||||
crate::db::ItemMeta::named("Test"),
|
||||
);
|
||||
}
|
||||
|
||||
fn make_merge_failure_fired(story_id: &str, kind: MergeFailureKind) -> TransitionFired {
|
||||
TransitionFired {
|
||||
story_id: StoryId(story_id.to_string()),
|
||||
before: Stage::Merge {
|
||||
feature_branch: BranchName("feature/test".to_string()),
|
||||
commits_ahead: NonZeroU32::new(1).unwrap(),
|
||||
claim: None,
|
||||
retries: 0,
|
||||
server_start_time: None,
|
||||
},
|
||||
after: Stage::MergeFailure {
|
||||
kind: kind.clone(),
|
||||
feature_branch: BranchName("feature/test".to_string()),
|
||||
commits_ahead: NonZeroU32::new(1).unwrap(),
|
||||
},
|
||||
event: PipelineEvent::MergeFailed { kind },
|
||||
at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_coding_fired(story_id: &str) -> TransitionFired {
|
||||
TransitionFired {
|
||||
story_id: StoryId(story_id.to_string()),
|
||||
before: Stage::MergeFailure {
|
||||
kind: MergeFailureKind::GatesFailed("error".to_string()),
|
||||
feature_branch: BranchName("feature/test".to_string()),
|
||||
commits_ahead: NonZeroU32::new(1).unwrap(),
|
||||
},
|
||||
after: Stage::Coding {
|
||||
claim: None,
|
||||
plan: Default::default(),
|
||||
retries: 0,
|
||||
},
|
||||
event: PipelineEvent::FixupRequested,
|
||||
at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// AC3 (threshold-not-reached): 2 consecutive failures below threshold of 3 must NOT block.
|
||||
#[test]
|
||||
fn below_threshold_does_not_block() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_project(&tmp);
|
||||
let story_id = "1018_below";
|
||||
seed_at_merge(story_id);
|
||||
|
||||
// Transition to MergeFailure once to establish the stage.
|
||||
crate::agents::lifecycle::transition_to_merge_failure(
|
||||
story_id,
|
||||
MergeFailureKind::GatesFailed("error".to_string()),
|
||||
)
|
||||
.expect("initial MergeFailure transition");
|
||||
|
||||
let mut counters: HashMap<StoryId, (u32, MergeFailureKind)> = HashMap::new();
|
||||
let kind = MergeFailureKind::GatesFailed("error".to_string());
|
||||
|
||||
// Fire 2 MergeFailure events (default threshold is 3).
|
||||
for _ in 0..2 {
|
||||
let fired = make_merge_failure_fired(story_id, kind.clone());
|
||||
on_transition(tmp.path(), &fired, &mut counters, false);
|
||||
}
|
||||
|
||||
// Story must still be in MergeFailure (not Blocked).
|
||||
let item = crate::pipeline_state::read_typed(story_id)
|
||||
.expect("read")
|
||||
.expect("item");
|
||||
assert!(
|
||||
matches!(item.stage, Stage::MergeFailure { .. }),
|
||||
"story must still be in MergeFailure after 2 failures (threshold 3): {:?}",
|
||||
item.stage
|
||||
);
|
||||
}
|
||||
|
||||
/// AC3 (threshold-reached): 3 consecutive failures at threshold of 3 must block.
|
||||
#[test]
|
||||
fn at_threshold_blocks_with_failure_kind_in_reason() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_project(&tmp);
|
||||
let story_id = "1018_at_threshold";
|
||||
seed_at_merge(story_id);
|
||||
|
||||
crate::agents::lifecycle::transition_to_merge_failure(
|
||||
story_id,
|
||||
MergeFailureKind::GatesFailed("fmt error".to_string()),
|
||||
)
|
||||
.expect("initial MergeFailure transition");
|
||||
|
||||
let mut counters: HashMap<StoryId, (u32, MergeFailureKind)> = HashMap::new();
|
||||
let kind = MergeFailureKind::GatesFailed("fmt error".to_string());
|
||||
|
||||
// Fire 3 MergeFailure events — the 3rd must trigger the block.
|
||||
for _ in 0..3 {
|
||||
let fired = make_merge_failure_fired(story_id, kind.clone());
|
||||
on_transition(tmp.path(), &fired, &mut counters, false);
|
||||
}
|
||||
|
||||
let item = crate::pipeline_state::read_typed(story_id)
|
||||
.expect("read")
|
||||
.expect("item");
|
||||
assert!(
|
||||
matches!(item.stage, Stage::Blocked { .. }),
|
||||
"story must be Blocked after 3 consecutive MergeFailures: {:?}",
|
||||
item.stage
|
||||
);
|
||||
|
||||
// The block reason must name the failure kind.
|
||||
if let Stage::Blocked { reason } = &item.stage {
|
||||
assert!(
|
||||
reason.contains("GatesFailed"),
|
||||
"block reason must name the failure kind: {reason}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// AC3 (reset): counter clears after a non-MergeFailure transition.
|
||||
///
|
||||
/// 2 failures → FixupRequested reset → 2 more failures: still below threshold, no block.
|
||||
#[test]
|
||||
fn counter_resets_on_non_merge_failure_transition() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_project(&tmp);
|
||||
let story_id = "1018_reset";
|
||||
seed_at_merge(story_id);
|
||||
|
||||
crate::agents::lifecycle::transition_to_merge_failure(
|
||||
story_id,
|
||||
MergeFailureKind::ConflictDetected(None),
|
||||
)
|
||||
.expect("initial MergeFailure transition");
|
||||
|
||||
let mut counters: HashMap<StoryId, (u32, MergeFailureKind)> = HashMap::new();
|
||||
let kind = MergeFailureKind::ConflictDetected(None);
|
||||
|
||||
// Fire 2 MergeFailure events.
|
||||
for _ in 0..2 {
|
||||
let fired = make_merge_failure_fired(story_id, kind.clone());
|
||||
on_transition(tmp.path(), &fired, &mut counters, false);
|
||||
}
|
||||
assert_eq!(
|
||||
counters.get(&StoryId(story_id.to_string())).map(|e| e.0),
|
||||
Some(2),
|
||||
"counter must be 2 after 2 failures"
|
||||
);
|
||||
|
||||
// Simulate FixupRequested (non-MergeFailure transition).
|
||||
let reset_fired = make_coding_fired(story_id);
|
||||
on_transition(tmp.path(), &reset_fired, &mut counters, false);
|
||||
assert!(
|
||||
!counters.contains_key(&StoryId(story_id.to_string())),
|
||||
"counter must be cleared after non-MergeFailure transition"
|
||||
);
|
||||
|
||||
// Re-seed to MergeFailure so we can apply the block transition.
|
||||
crate::agents::lifecycle::transition_to_merge_failure(
|
||||
story_id,
|
||||
MergeFailureKind::ConflictDetected(None),
|
||||
)
|
||||
.expect("re-enter MergeFailure after reset");
|
||||
|
||||
// Fire 2 more MergeFailure events — still below threshold.
|
||||
for _ in 0..2 {
|
||||
let fired = make_merge_failure_fired(story_id, kind.clone());
|
||||
on_transition(tmp.path(), &fired, &mut counters, false);
|
||||
}
|
||||
|
||||
let item = crate::pipeline_state::read_typed(story_id)
|
||||
.expect("read")
|
||||
.expect("item");
|
||||
assert!(
|
||||
matches!(item.stage, Stage::MergeFailure { .. }),
|
||||
"story must still be in MergeFailure after reset + 2 new failures: {:?}",
|
||||
item.stage
|
||||
);
|
||||
}
|
||||
|
||||
/// Bug 1025: while a mergemaster is running, MergeFailure transitions are
|
||||
/// recovery iterations, not consecutive give-ups. 3 failures with
|
||||
/// `recovery_running=true` must NOT block.
|
||||
#[test]
|
||||
fn mergemaster_running_suppresses_block() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_project(&tmp);
|
||||
let story_id = "1025_recovery_running";
|
||||
seed_at_merge(story_id);
|
||||
|
||||
crate::agents::lifecycle::transition_to_merge_failure(
|
||||
story_id,
|
||||
MergeFailureKind::ConflictDetected(None),
|
||||
)
|
||||
.expect("initial MergeFailure transition");
|
||||
|
||||
let mut counters: HashMap<StoryId, (u32, MergeFailureKind)> = HashMap::new();
|
||||
let kind = MergeFailureKind::ConflictDetected(None);
|
||||
|
||||
// Fire 3 MergeFailure events WHILE a mergemaster is running (gated).
|
||||
for _ in 0..3 {
|
||||
let fired = make_merge_failure_fired(story_id, kind.clone());
|
||||
on_transition(tmp.path(), &fired, &mut counters, true);
|
||||
}
|
||||
|
||||
// Counter must NOT have incremented at all — recovery in progress.
|
||||
assert!(
|
||||
!counters.contains_key(&StoryId(story_id.to_string())),
|
||||
"counter must not increment while mergemaster is running"
|
||||
);
|
||||
|
||||
// And the story must still be in MergeFailure (not Blocked).
|
||||
let item = crate::pipeline_state::read_typed(story_id)
|
||||
.expect("read")
|
||||
.expect("item");
|
||||
assert!(
|
||||
matches!(item.stage, Stage::MergeFailure { .. }),
|
||||
"story must NOT be blocked while mergemaster is running (recovery in progress): {:?}",
|
||||
item.stage
|
||||
);
|
||||
}
|
||||
|
||||
/// Bug 1025 regression guard: the genuinely-stuck case (no mergemaster
|
||||
/// running) still blocks at the threshold, so the original 1018 behaviour
|
||||
/// is preserved.
|
||||
#[test]
|
||||
fn no_mergemaster_still_blocks_at_threshold() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_project(&tmp);
|
||||
let story_id = "1025_genuine_stuck";
|
||||
seed_at_merge(story_id);
|
||||
|
||||
crate::agents::lifecycle::transition_to_merge_failure(
|
||||
story_id,
|
||||
MergeFailureKind::ConflictDetected(None),
|
||||
)
|
||||
.expect("initial MergeFailure transition");
|
||||
|
||||
let mut counters: HashMap<StoryId, (u32, MergeFailureKind)> = HashMap::new();
|
||||
let kind = MergeFailureKind::ConflictDetected(None);
|
||||
|
||||
// Fire 3 MergeFailure events with NO mergemaster (recovery_running=false).
|
||||
for _ in 0..3 {
|
||||
let fired = make_merge_failure_fired(story_id, kind.clone());
|
||||
on_transition(tmp.path(), &fired, &mut counters, false);
|
||||
}
|
||||
|
||||
// Story must be Blocked (genuine-stuck case unchanged).
|
||||
let item = crate::pipeline_state::read_typed(story_id)
|
||||
.expect("read")
|
||||
.expect("item");
|
||||
assert!(
|
||||
matches!(item.stage, Stage::Blocked { .. }),
|
||||
"story must still block when no mergemaster is running: {:?}",
|
||||
item.stage
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
//! TransitionFired subscriber that auto-spawns mergemaster on ConflictDetected merge failures.
|
||||
//!
|
||||
//! Listens on the pipeline transition broadcast channel and schedules a
|
||||
//! mergemaster agent whenever a story enters
|
||||
//! `Stage::MergeFailure { kind: ConflictDetected(_), .. }`.
|
||||
//! Other [`MergeFailureKind`] variants require human intervention and are
|
||||
//! intentionally ignored here.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::pipeline_state::{MergeFailureKind, Stage};
|
||||
use crate::slog;
|
||||
use crate::slog_warn;
|
||||
|
||||
use super::super::super::PipelineStage;
|
||||
use super::super::AgentPool;
|
||||
use super::scan::{find_free_agent_for_stage, is_story_assigned_for_stage};
|
||||
|
||||
/// Spawn a background task that auto-spawns mergemaster agents on
|
||||
/// `Stage::MergeFailure { kind: ConflictDetected(_) }` transitions.
|
||||
///
|
||||
/// The task subscribes to the pipeline transition broadcast channel and calls
|
||||
/// [`AgentPool::start_agent`] with the first free mergemaster agent whenever a
|
||||
/// story transitions into a recoverable conflict state. All other
|
||||
/// [`MergeFailureKind`] variants are silently skipped — they need a human.
|
||||
pub(crate) fn spawn_merge_failure_subscriber(pool: Arc<AgentPool>, project_root: PathBuf) {
|
||||
let mut rx = crate::pipeline_state::subscribe_transitions();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(fired) => {
|
||||
on_merge_failure_transition(&pool, &project_root, &fired).await;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
slog_warn!(
|
||||
"[merge-failure-sub] Subscriber lagged, skipped {n} event(s). \
|
||||
ConflictDetected stories may need manual mergemaster spawn."
|
||||
);
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn on_merge_failure_transition(
|
||||
pool: &AgentPool,
|
||||
project_root: &Path,
|
||||
fired: &crate::pipeline_state::TransitionFired,
|
||||
) {
|
||||
let Stage::MergeFailure { ref kind, .. } = fired.after else {
|
||||
return;
|
||||
};
|
||||
|
||||
let story_id = &fired.story_id.0;
|
||||
|
||||
match kind {
|
||||
MergeFailureKind::ConflictDetected(_) => {
|
||||
let config = match crate::config::ProjectConfig::load(project_root) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
slog_warn!("[merge-failure-sub] Failed to load config for '{story_id}': {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let agent_name = {
|
||||
let agents = match pool.agents.lock() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
slog_warn!(
|
||||
"[merge-failure-sub] Failed to lock agent pool for '{story_id}': {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if is_story_assigned_for_stage(
|
||||
&config,
|
||||
&agents,
|
||||
story_id,
|
||||
&PipelineStage::Mergemaster,
|
||||
) {
|
||||
return; // mergemaster already running for this story
|
||||
}
|
||||
find_free_agent_for_stage(&config, &agents, &PipelineStage::Mergemaster)
|
||||
.map(str::to_string)
|
||||
};
|
||||
|
||||
if let Some(agent) = agent_name {
|
||||
slog!(
|
||||
"[merge-failure-sub] ConflictDetected on '{story_id}'; \
|
||||
auto-spawning mergemaster '{agent}'."
|
||||
);
|
||||
if let Err(e) = pool
|
||||
.start_agent(project_root, story_id, Some(&agent), None, None)
|
||||
.await
|
||||
{
|
||||
slog!("[merge-failure-sub] Failed to spawn '{agent}' for '{story_id}': {e}");
|
||||
}
|
||||
} else {
|
||||
slog!(
|
||||
"[merge-failure-sub] ConflictDetected on '{story_id}'; \
|
||||
no free mergemaster agent available."
|
||||
);
|
||||
}
|
||||
}
|
||||
// GatesFailed, EmptyDiff, NoCommits, Other — all require human intervention.
|
||||
MergeFailureKind::GatesFailed(_)
|
||||
| MergeFailureKind::EmptyDiff
|
||||
| MergeFailureKind::NoCommits
|
||||
| MergeFailureKind::Other(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::{AgentPool, AgentStatus};
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
fn setup_project(tmp: &tempfile::TempDir) {
|
||||
let sk = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&sk).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
"[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn seed_at_merge(story_id: &str) {
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
story_id,
|
||||
"4_merge",
|
||||
"---\nname: Test\n---\n",
|
||||
crate::db::ItemMeta::named("Test"),
|
||||
);
|
||||
}
|
||||
|
||||
fn make_pool(port: u16) -> Arc<AgentPool> {
|
||||
let (tx, _) = broadcast::channel::<WatcherEvent>(4);
|
||||
Arc::new(AgentPool::new(port, tx))
|
||||
}
|
||||
|
||||
fn make_fired(
|
||||
story_id: &str,
|
||||
kind: MergeFailureKind,
|
||||
) -> crate::pipeline_state::TransitionFired {
|
||||
use crate::pipeline_state::{BranchName, PipelineEvent, StoryId, TransitionFired};
|
||||
use std::num::NonZeroU32;
|
||||
TransitionFired {
|
||||
story_id: StoryId(story_id.to_string()),
|
||||
before: crate::pipeline_state::Stage::Merge {
|
||||
feature_branch: BranchName("feature/test".to_string()),
|
||||
commits_ahead: NonZeroU32::new(1).unwrap(),
|
||||
claim: None,
|
||||
retries: 0,
|
||||
server_start_time: None,
|
||||
},
|
||||
after: crate::pipeline_state::Stage::MergeFailure {
|
||||
kind: kind.clone(),
|
||||
feature_branch: BranchName("feature/test".to_string()),
|
||||
commits_ahead: NonZeroU32::new(1).unwrap(),
|
||||
},
|
||||
event: PipelineEvent::MergeFailed { kind },
|
||||
at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── AC4: each MergeFailureKind variant ──────────────────────────────────
|
||||
|
||||
/// ConflictDetected → on_merge_failure_transition must spawn mergemaster.
|
||||
///
|
||||
/// Calls the handler directly (not via the broadcast subscriber) to avoid
|
||||
/// cross-test channel contamination from the global TRANSITION_TX.
|
||||
#[tokio::test]
|
||||
async fn conflict_detected_spawns_mergemaster_via_subscriber() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_project(&tmp);
|
||||
let story_id = "998_sub_conflict";
|
||||
seed_at_merge(story_id);
|
||||
|
||||
let pool = make_pool(3998);
|
||||
let fired = make_fired(
|
||||
story_id,
|
||||
MergeFailureKind::ConflictDetected(Some("CONFLICT (content): src/lib.rs".to_string())),
|
||||
);
|
||||
on_merge_failure_transition(&pool, tmp.path(), &fired).await;
|
||||
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
assert!(
|
||||
agents.iter().any(|(key, a)| {
|
||||
key.contains(story_id)
|
||||
&& a.agent_name == "mergemaster"
|
||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
}),
|
||||
"mergemaster must be spawned for ConflictDetected"
|
||||
);
|
||||
}
|
||||
|
||||
/// GatesFailed → subscriber must NOT spawn mergemaster (human intervention needed).
|
||||
#[tokio::test]
|
||||
async fn gates_failed_does_not_spawn_mergemaster() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_project(&tmp);
|
||||
let story_id = "998_sub_gates";
|
||||
seed_at_merge(story_id);
|
||||
|
||||
let pool = make_pool(3997);
|
||||
spawn_merge_failure_subscriber(Arc::clone(&pool), tmp.path().to_path_buf());
|
||||
|
||||
crate::agents::lifecycle::transition_to_merge_failure(
|
||||
story_id,
|
||||
MergeFailureKind::GatesFailed("error[E0308]: mismatched types".to_string()),
|
||||
)
|
||||
.expect("transition must succeed");
|
||||
|
||||
// Give the subscriber time to run (it should do nothing).
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
let spawned = agents.iter().any(|(key, a)| {
|
||||
key.contains(story_id)
|
||||
&& a.agent_name == "mergemaster"
|
||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
});
|
||||
assert!(!spawned, "mergemaster must NOT be spawned for GatesFailed");
|
||||
}
|
||||
|
||||
/// EmptyDiff → subscriber must NOT spawn mergemaster.
|
||||
#[tokio::test]
|
||||
async fn empty_diff_does_not_spawn_mergemaster() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_project(&tmp);
|
||||
let story_id = "998_sub_emptydiff";
|
||||
seed_at_merge(story_id);
|
||||
|
||||
let pool = make_pool(3996);
|
||||
spawn_merge_failure_subscriber(Arc::clone(&pool), tmp.path().to_path_buf());
|
||||
|
||||
crate::agents::lifecycle::transition_to_merge_failure(
|
||||
story_id,
|
||||
MergeFailureKind::EmptyDiff,
|
||||
)
|
||||
.expect("transition must succeed");
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
let spawned = agents.iter().any(|(key, a)| {
|
||||
key.contains(story_id)
|
||||
&& a.agent_name == "mergemaster"
|
||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
});
|
||||
assert!(!spawned, "mergemaster must NOT be spawned for EmptyDiff");
|
||||
}
|
||||
|
||||
/// NoCommits → subscriber must NOT spawn mergemaster.
|
||||
#[tokio::test]
|
||||
async fn no_commits_does_not_spawn_mergemaster() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_project(&tmp);
|
||||
let story_id = "998_sub_nocommits";
|
||||
seed_at_merge(story_id);
|
||||
|
||||
let pool = make_pool(3995);
|
||||
spawn_merge_failure_subscriber(Arc::clone(&pool), tmp.path().to_path_buf());
|
||||
|
||||
crate::agents::lifecycle::transition_to_merge_failure(
|
||||
story_id,
|
||||
MergeFailureKind::NoCommits,
|
||||
)
|
||||
.expect("transition must succeed");
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
let spawned = agents.iter().any(|(key, a)| {
|
||||
key.contains(story_id)
|
||||
&& a.agent_name == "mergemaster"
|
||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
});
|
||||
assert!(!spawned, "mergemaster must NOT be spawned for NoCommits");
|
||||
}
|
||||
|
||||
/// Other(_) → subscriber must NOT spawn mergemaster.
|
||||
#[tokio::test]
|
||||
async fn other_does_not_spawn_mergemaster() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_project(&tmp);
|
||||
let story_id = "998_sub_other";
|
||||
seed_at_merge(story_id);
|
||||
|
||||
let pool = make_pool(3994);
|
||||
spawn_merge_failure_subscriber(Arc::clone(&pool), tmp.path().to_path_buf());
|
||||
|
||||
crate::agents::lifecycle::transition_to_merge_failure(
|
||||
story_id,
|
||||
MergeFailureKind::Other("unknown error".to_string()),
|
||||
)
|
||||
.expect("transition must succeed");
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
let spawned = agents.iter().any(|(key, a)| {
|
||||
key.contains(story_id)
|
||||
&& a.agent_name == "mergemaster"
|
||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
});
|
||||
assert!(!spawned, "mergemaster must NOT be spawned for Other");
|
||||
}
|
||||
|
||||
/// ConflictDetected self-loop — handler must NOT spawn a second mergemaster
|
||||
/// when one is already Pending/Running for the story.
|
||||
///
|
||||
/// Calls the handler twice directly (no broadcast subscriber) so there is no
|
||||
/// timing window: the first call sets the agent to Pending synchronously,
|
||||
/// and the second call sees that Pending entry and returns early.
|
||||
#[tokio::test]
|
||||
async fn conflict_detected_self_loop_does_not_double_spawn() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_project(&tmp);
|
||||
let story_id = "998_sub_selfloop";
|
||||
seed_at_merge(story_id);
|
||||
|
||||
let pool = make_pool(3993);
|
||||
let fired = make_fired(
|
||||
story_id,
|
||||
MergeFailureKind::ConflictDetected(Some("CONFLICT".to_string())),
|
||||
);
|
||||
|
||||
// First call — spawns mergemaster (agent enters Pending).
|
||||
on_merge_failure_transition(&pool, tmp.path(), &fired).await;
|
||||
{
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
assert!(
|
||||
agents.iter().any(|(key, a)| {
|
||||
key.contains(story_id)
|
||||
&& a.agent_name == "mergemaster"
|
||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
}),
|
||||
"mergemaster must be Pending after first ConflictDetected"
|
||||
);
|
||||
}
|
||||
|
||||
// Second call (self-loop) — agent is still Pending; guard must prevent double-spawn.
|
||||
on_merge_failure_transition(&pool, tmp.path(), &fired).await;
|
||||
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
let active_count = agents
|
||||
.iter()
|
||||
.filter(|(key, a)| {
|
||||
key.contains(story_id)
|
||||
&& a.agent_name == "mergemaster"
|
||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(
|
||||
active_count, 1,
|
||||
"mergemaster must not be double-spawned on ConflictDetected self-loop"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,11 @@
|
||||
mod auto_assign;
|
||||
mod backlog;
|
||||
mod merge;
|
||||
/// TransitionFired subscriber that auto-blocks stories after N consecutive MergeFailure transitions.
|
||||
pub(crate) mod merge_failure_block_subscriber;
|
||||
/// TransitionFired subscriber that auto-spawns mergemaster on ConflictDetected merge failures.
|
||||
pub(crate) mod merge_failure_subscriber;
|
||||
mod pipeline;
|
||||
mod reconcile;
|
||||
mod scan;
|
||||
mod story_checks;
|
||||
pub(crate) mod watchdog;
|
||||
@@ -13,3 +16,8 @@ pub(crate) mod watchdog;
|
||||
// Re-export items that were pub(super) in the original monolithic auto_assign.rs
|
||||
// so that pool::lifecycle and pool::pipeline continue to access them unchanged.
|
||||
pub(super) use scan::{find_free_agent_for_stage, is_agent_free};
|
||||
|
||||
/// Re-export for `startup::tick_loop`.
|
||||
pub(crate) use merge_failure_block_subscriber::spawn_merge_failure_block_subscriber;
|
||||
/// Re-export for `startup::tick_loop`.
|
||||
pub(crate) use merge_failure_subscriber::spawn_merge_failure_subscriber;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::pipeline_state::Stage;
|
||||
use crate::slog;
|
||||
use crate::slog_error;
|
||||
|
||||
@@ -25,13 +26,21 @@ impl AgentPool {
|
||||
/// guards. Agent front-matter preferences and stage-mismatch fallback are handled
|
||||
/// here as well.
|
||||
pub(super) async fn assign_pipeline_stages(&self, project_root: &Path, config: &ProjectConfig) {
|
||||
let stages: [(&str, PipelineStage); 2] = [
|
||||
("2_current", PipelineStage::Coder),
|
||||
("3_qa", PipelineStage::Qa),
|
||||
let stages: [(Stage, PipelineStage); 2] = [
|
||||
(
|
||||
Stage::Coding {
|
||||
claim: None,
|
||||
plan: Default::default(),
|
||||
retries: 0,
|
||||
},
|
||||
PipelineStage::Coder,
|
||||
),
|
||||
(Stage::Qa, PipelineStage::Qa),
|
||||
];
|
||||
|
||||
for (stage_dir, stage) in &stages {
|
||||
let items = scan_stage_items(project_root, stage_dir);
|
||||
for (pipeline_stage, stage) in &stages {
|
||||
let stage_dir = pipeline_stage.dir_name();
|
||||
let items = scan_stage_items(pipeline_stage);
|
||||
if items.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -39,23 +48,23 @@ impl AgentPool {
|
||||
for story_id in &items {
|
||||
// Items marked with review_hold (e.g. spikes after QA passes) stay
|
||||
// in their current stage for human review — don't auto-assign agents.
|
||||
if has_review_hold(project_root, stage_dir, story_id) {
|
||||
if has_review_hold(story_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip frozen stories — pipeline advancement is suspended.
|
||||
if is_story_frozen(project_root, stage_dir, story_id) {
|
||||
if is_story_frozen(story_id) {
|
||||
slog!("[auto-assign] Story '{story_id}' is frozen; skipping until unfrozen.");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip blocked stories (retry limit exceeded).
|
||||
if is_story_blocked(project_root, stage_dir, story_id) {
|
||||
if is_story_blocked(story_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip stories whose dependencies haven't landed yet.
|
||||
if has_unmet_dependencies(project_root, stage_dir, story_id) {
|
||||
if has_unmet_dependencies(story_id) {
|
||||
slog!(
|
||||
"[auto-assign] Story '{story_id}' has unmet dependencies; skipping until deps are done."
|
||||
);
|
||||
@@ -64,8 +73,7 @@ impl AgentPool {
|
||||
|
||||
// Re-acquire the lock on each iteration to see state changes
|
||||
// from previous start_agent calls in the same pass.
|
||||
let preferred_agent =
|
||||
read_story_front_matter_agent(project_root, stage_dir, story_id);
|
||||
let preferred_agent = read_story_front_matter_agent(story_id);
|
||||
|
||||
// Check max_coders limit for the Coder stage before agent selection.
|
||||
// If the pool is full, all remaining items in this stage wait.
|
||||
|
||||
@@ -1,534 +1,4 @@
|
||||
//! Startup reconciliation: detect stories with committed work and advance the pipeline.
|
||||
|
||||
use std::path::Path;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::pipeline_state::Stage;
|
||||
use crate::worktree;
|
||||
|
||||
use super::super::super::ReconciliationEvent;
|
||||
use super::super::{AgentPool, find_active_story_stage};
|
||||
|
||||
impl AgentPool {
|
||||
/// Reconcile stories whose agent work was committed while the server was offline.
|
||||
///
|
||||
/// On server startup the in-memory agent pool is empty, so any story that an agent
|
||||
/// completed during a previous session is stuck: the worktree has committed work but
|
||||
/// the pipeline never advanced. This method detects those stories, re-runs the
|
||||
/// acceptance gates, and advances the pipeline stage so that `auto_assign_available_work`
|
||||
/// (called immediately after) picks up the right next-stage agents.
|
||||
///
|
||||
/// Algorithm:
|
||||
/// 1. List all worktree directories under `{project_root}/.huskies/worktrees/`.
|
||||
/// 2. For each worktree, check whether its feature branch has commits ahead of the
|
||||
/// base branch (`master` / `main`).
|
||||
/// 3. If committed work is found AND the story is in `2_current/` or `3_qa/`:
|
||||
/// - Run acceptance gates (uncommitted-change check + clippy + tests).
|
||||
/// - On pass + `2_current/`: move the story to `3_qa/`.
|
||||
/// - On pass + `3_qa/`: run the coverage gate; if that also passes move to `4_merge/`.
|
||||
/// - On failure: leave the story where it is so `auto_assign_available_work` can
|
||||
/// start a fresh agent to retry.
|
||||
/// 4. Stories in `4_merge/` are left for `auto_assign_available_work` to handle via a
|
||||
/// fresh mergemaster (squash-merge must be re-executed by the mergemaster agent).
|
||||
pub async fn reconcile_on_startup(
|
||||
&self,
|
||||
project_root: &Path,
|
||||
progress_tx: &broadcast::Sender<ReconciliationEvent>,
|
||||
) {
|
||||
let worktrees = match worktree::list_worktrees(project_root) {
|
||||
Ok(wt) => wt,
|
||||
Err(e) => {
|
||||
eprintln!("[startup:reconcile] Failed to list worktrees: {e}");
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: String::new(),
|
||||
status: "done".to_string(),
|
||||
message: format!("Reconciliation failed: {e}"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for wt_entry in &worktrees {
|
||||
let story_id = &wt_entry.story_id;
|
||||
let wt_path = wt_entry.path.clone();
|
||||
|
||||
// Determine which active stage the story is in.
|
||||
let stage = match find_active_story_stage(project_root, story_id) {
|
||||
Some(s) => s,
|
||||
None => continue, // Not in any active stage (backlog/archived or unknown).
|
||||
};
|
||||
|
||||
// 4_merge/ is left for auto_assign to handle with a fresh mergemaster.
|
||||
if matches!(stage, Stage::Merge { .. }) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "checking".to_string(),
|
||||
message: format!("Checking for committed work in {}/", stage.dir_name()),
|
||||
});
|
||||
|
||||
// Check whether the worktree has commits ahead of the base branch.
|
||||
let wt_path_for_check = wt_path.clone();
|
||||
let has_work = tokio::task::spawn_blocking(move || {
|
||||
crate::agents::gates::worktree_has_committed_work(&wt_path_for_check)
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_work {
|
||||
eprintln!(
|
||||
"[startup:reconcile] No committed work for '{story_id}' in {}/; skipping.",
|
||||
stage.dir_name()
|
||||
);
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "skipped".to_string(),
|
||||
message: "No committed work found; skipping.".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"[startup:reconcile] Found committed work for '{story_id}' in {}/. Running acceptance gates.",
|
||||
stage.dir_name()
|
||||
);
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "gates_running".to_string(),
|
||||
message: "Running acceptance gates…".to_string(),
|
||||
});
|
||||
|
||||
// Run acceptance gates on the worktree.
|
||||
let wt_path_for_gates = wt_path.clone();
|
||||
let gates_result = tokio::task::spawn_blocking(move || {
|
||||
crate::agents::gates::check_uncommitted_changes(&wt_path_for_gates)?;
|
||||
crate::agents::gates::run_acceptance_gates(&wt_path_for_gates)
|
||||
})
|
||||
.await;
|
||||
|
||||
let (gates_passed, gate_output) = match gates_result {
|
||||
Ok(Ok(pair)) => pair,
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("[startup:reconcile] Gate check error for '{story_id}': {e}");
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "failed".to_string(),
|
||||
message: format!("Gate error: {e}"),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[startup:reconcile] Gate check task panicked for '{story_id}': {e}");
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "failed".to_string(),
|
||||
message: format!("Gate task panicked: {e}"),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if !gates_passed {
|
||||
eprintln!(
|
||||
"[startup:reconcile] Gates failed for '{story_id}': {gate_output}\n\
|
||||
Leaving in {}/ for auto-assign to restart the agent.",
|
||||
stage.dir_name()
|
||||
);
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "failed".to_string(),
|
||||
message: "Gates failed; will be retried by auto-assign.".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"[startup:reconcile] Gates passed for '{story_id}' (stage: {}/).",
|
||||
stage.dir_name()
|
||||
);
|
||||
|
||||
if matches!(stage, Stage::Coding) {
|
||||
// Coder stage — determine qa mode to decide next step.
|
||||
let qa_mode = {
|
||||
let item_type = crate::agents::lifecycle::item_type_from_id(story_id);
|
||||
if item_type == "spike" {
|
||||
crate::io::story_metadata::QaMode::Human
|
||||
} else {
|
||||
let default_qa = crate::config::ProjectConfig::load(project_root)
|
||||
.unwrap_or_default()
|
||||
.default_qa_mode();
|
||||
let story_path = project_root
|
||||
.join(".huskies/work/2_current")
|
||||
.join(format!("{story_id}.md"));
|
||||
crate::io::story_metadata::resolve_qa_mode(&story_path, default_qa)
|
||||
}
|
||||
};
|
||||
|
||||
match qa_mode {
|
||||
crate::io::story_metadata::QaMode::Server => {
|
||||
if let Err(e) = crate::agents::move_story_to_merge(story_id) {
|
||||
eprintln!(
|
||||
"[startup:reconcile] Failed to move '{story_id}' to 4_merge/: {e}"
|
||||
);
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "failed".to_string(),
|
||||
message: format!("Failed to advance to merge: {e}"),
|
||||
});
|
||||
} else {
|
||||
eprintln!(
|
||||
"[startup:reconcile] Moved '{story_id}' → 4_merge/ (qa: server)."
|
||||
);
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "advanced".to_string(),
|
||||
message: "Gates passed — moved to merge (qa: server).".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
crate::io::story_metadata::QaMode::Agent => {
|
||||
if let Err(e) = crate::agents::move_story_to_qa(story_id) {
|
||||
eprintln!(
|
||||
"[startup:reconcile] Failed to move '{story_id}' to 3_qa/: {e}"
|
||||
);
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "failed".to_string(),
|
||||
message: format!("Failed to advance to QA: {e}"),
|
||||
});
|
||||
} else {
|
||||
eprintln!("[startup:reconcile] Moved '{story_id}' → 3_qa/.");
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "advanced".to_string(),
|
||||
message: "Gates passed — moved to QA.".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
crate::io::story_metadata::QaMode::Human => {
|
||||
if let Err(e) = crate::agents::move_story_to_qa(story_id) {
|
||||
eprintln!(
|
||||
"[startup:reconcile] Failed to move '{story_id}' to 3_qa/: {e}"
|
||||
);
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "failed".to_string(),
|
||||
message: format!("Failed to advance to QA: {e}"),
|
||||
});
|
||||
} else {
|
||||
let story_path = project_root
|
||||
.join(".huskies/work/3_qa")
|
||||
.join(format!("{story_id}.md"));
|
||||
if let Err(e) =
|
||||
crate::io::story_metadata::write_review_hold(&story_path)
|
||||
{
|
||||
eprintln!(
|
||||
"[startup:reconcile] Failed to set review_hold on '{story_id}': {e}"
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"[startup:reconcile] Moved '{story_id}' → 3_qa/ (qa: human — holding for review)."
|
||||
);
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "review_hold".to_string(),
|
||||
message: "Gates passed — holding for human review.".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if matches!(stage, Stage::Qa) {
|
||||
// QA stage → run coverage gate before advancing to merge.
|
||||
let wt_path_for_cov = wt_path.clone();
|
||||
let coverage_result = tokio::task::spawn_blocking(move || {
|
||||
crate::agents::gates::run_coverage_gate(&wt_path_for_cov)
|
||||
})
|
||||
.await;
|
||||
|
||||
let (coverage_passed, coverage_output) = match coverage_result {
|
||||
Ok(Ok(pair)) => pair,
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("[startup:reconcile] Coverage gate error for '{story_id}': {e}");
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "failed".to_string(),
|
||||
message: format!("Coverage gate error: {e}"),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[startup:reconcile] Coverage gate panicked for '{story_id}': {e}"
|
||||
);
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "failed".to_string(),
|
||||
message: format!("Coverage gate panicked: {e}"),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if coverage_passed {
|
||||
// Check whether this item needs human review before merging.
|
||||
let needs_human_review = {
|
||||
let item_type = crate::agents::lifecycle::item_type_from_id(story_id);
|
||||
if item_type == "spike" {
|
||||
true
|
||||
} else {
|
||||
let story_path = project_root
|
||||
.join(".huskies/work/3_qa")
|
||||
.join(format!("{story_id}.md"));
|
||||
let default_qa = crate::config::ProjectConfig::load(project_root)
|
||||
.unwrap_or_default()
|
||||
.default_qa_mode();
|
||||
matches!(
|
||||
crate::io::story_metadata::resolve_qa_mode(&story_path, default_qa),
|
||||
crate::io::story_metadata::QaMode::Human
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
if needs_human_review {
|
||||
let story_path = project_root
|
||||
.join(".huskies/work/3_qa")
|
||||
.join(format!("{story_id}.md"));
|
||||
if let Err(e) = crate::io::story_metadata::write_review_hold(&story_path) {
|
||||
eprintln!(
|
||||
"[startup:reconcile] Failed to set review_hold on '{story_id}': {e}"
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"[startup:reconcile] '{story_id}' passed QA — holding for human review."
|
||||
);
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "review_hold".to_string(),
|
||||
message: "Passed QA — waiting for human review.".to_string(),
|
||||
});
|
||||
} else if let Err(e) = crate::agents::move_story_to_merge(story_id) {
|
||||
eprintln!(
|
||||
"[startup:reconcile] Failed to move '{story_id}' to 4_merge/: {e}"
|
||||
);
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "failed".to_string(),
|
||||
message: format!("Failed to advance to merge: {e}"),
|
||||
});
|
||||
} else {
|
||||
eprintln!("[startup:reconcile] Moved '{story_id}' → 4_merge/.");
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "advanced".to_string(),
|
||||
message: "Gates passed — moved to merge.".to_string(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
eprintln!(
|
||||
"[startup:reconcile] Coverage gate failed for '{story_id}': {coverage_output}\n\
|
||||
Leaving in 3_qa/ for auto-assign to restart the QA agent."
|
||||
);
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "failed".to_string(),
|
||||
message: "Coverage gate failed; will be retried.".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Signal that reconciliation is complete.
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: String::new(),
|
||||
status: "done".to_string(),
|
||||
message: "Startup reconciliation complete.".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::process::Command;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use super::super::super::AgentPool;
|
||||
use crate::agents::ReconciliationEvent;
|
||||
|
||||
fn init_git_repo(repo: &std::path::Path) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
// Create initial commit so master branch exists.
|
||||
std::fs::write(repo.join("README.md"), "# test\n").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "initial"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_on_startup_noop_when_no_worktrees() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pool = AgentPool::new_test(3001);
|
||||
let (tx, _rx) = broadcast::channel(16);
|
||||
// Should not panic; no worktrees to reconcile.
|
||||
pool.reconcile_on_startup(tmp.path(), &tx).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_on_startup_emits_done_event() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pool = AgentPool::new_test(3001);
|
||||
let (tx, mut rx) = broadcast::channel::<ReconciliationEvent>(16);
|
||||
pool.reconcile_on_startup(tmp.path(), &tx).await;
|
||||
|
||||
// Collect all events; the last must be "done".
|
||||
let mut events: Vec<ReconciliationEvent> = Vec::new();
|
||||
while let Ok(evt) = rx.try_recv() {
|
||||
events.push(evt);
|
||||
}
|
||||
assert!(
|
||||
events.iter().any(|e| e.status == "done"),
|
||||
"reconcile_on_startup must emit a 'done' event; got: {:?}",
|
||||
events.iter().map(|e| &e.status).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_on_startup_skips_story_without_committed_work() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
// Set up story in 2_current/.
|
||||
let current = root.join(".huskies/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(current.join("60_story_test.md"), "test").unwrap();
|
||||
|
||||
// Create a worktree directory that is a fresh git repo with no commits
|
||||
// ahead of its own base branch (simulates a worktree where no work was done).
|
||||
let wt_dir = root.join(".huskies/worktrees/60_story_test");
|
||||
fs::create_dir_all(&wt_dir).unwrap();
|
||||
init_git_repo(&wt_dir);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
let (tx, _rx) = broadcast::channel(16);
|
||||
pool.reconcile_on_startup(root, &tx).await;
|
||||
|
||||
// Story should still be in 2_current/ — nothing was reconciled.
|
||||
assert!(
|
||||
current.join("60_story_test.md").exists(),
|
||||
"story should stay in 2_current/ when worktree has no committed work"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_on_startup_runs_gates_on_worktree_with_committed_work() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
// Set up a git repo for the project root.
|
||||
init_git_repo(root);
|
||||
|
||||
// Set up story in 2_current/ and commit it so the project root is clean.
|
||||
let current = root.join(".huskies/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(current.join("61_story_test.md"), "test").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args([
|
||||
"-c",
|
||||
"user.email=test@test.com",
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"commit",
|
||||
"-m",
|
||||
"add story",
|
||||
])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Create a real git worktree for the story.
|
||||
let wt_dir = root.join(".huskies/worktrees/61_story_test");
|
||||
fs::create_dir_all(wt_dir.parent().unwrap()).unwrap();
|
||||
Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"add",
|
||||
&wt_dir.to_string_lossy(),
|
||||
"-b",
|
||||
"feature/story-61_story_test",
|
||||
])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Add a commit to the feature branch (simulates coder completing work).
|
||||
fs::write(wt_dir.join("implementation.txt"), "done").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(&wt_dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args([
|
||||
"-c",
|
||||
"user.email=test@test.com",
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"commit",
|
||||
"-m",
|
||||
"implement story",
|
||||
])
|
||||
.current_dir(&wt_dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
crate::agents::gates::worktree_has_committed_work(&wt_dir),
|
||||
"test setup: worktree should have committed work"
|
||||
);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
let (tx, _rx) = broadcast::channel(16);
|
||||
pool.reconcile_on_startup(root, &tx).await;
|
||||
|
||||
// In the test env, cargo clippy will fail (no Cargo.toml) so gates fail
|
||||
// and the story stays in 2_current/. The important assertion is that
|
||||
// reconcile ran without panicking and the story is in a consistent state.
|
||||
let in_current = current.join("61_story_test.md").exists();
|
||||
let in_qa = root.join(".huskies/work/3_qa/61_story_test.md").exists();
|
||||
assert!(
|
||||
in_current || in_qa,
|
||||
"story should be in 2_current/ or 3_qa/ after reconciliation"
|
||||
);
|
||||
}
|
||||
}
|
||||
//! Scan-based startup reconciliation deleted in story 1016.
|
||||
// Server-restart pool reconstruction now uses TransitionFired event replay.
|
||||
// See: pipeline_state::replay_current_pipeline_state
|
||||
// and: startup::tick_loop::spawn_startup_reconciliation
|
||||
|
||||
@@ -2,12 +2,27 @@
|
||||
|
||||
use crate::config::ProjectConfig;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use super::super::super::{AgentStatus, PipelineStage, agent_config_stage, pipeline_stage};
|
||||
use super::super::StoryAgent;
|
||||
|
||||
/// Return `true` if the agent has a throttle set whose expiry has already passed.
|
||||
///
|
||||
/// Returns `false` when the agent has no throttle, or when the throttle's
|
||||
/// `until` time is still in the future (throttle is active, agent is waiting).
|
||||
fn is_throttle_expired(agent: &StoryAgent) -> bool {
|
||||
agent
|
||||
.throttled
|
||||
.as_ref()
|
||||
.map(|e| !e.is_active())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Return `true` if `agent_name` has no active (pending/running) entry in the pool.
|
||||
///
|
||||
/// An agent with an expired throttle is considered free even if its status
|
||||
/// is still `Running` — the scheduler may retry rather than skip indefinitely.
|
||||
/// Agents without any throttle (or with an active throttle) are still considered busy.
|
||||
pub(in crate::agents::pool) fn is_agent_free(
|
||||
agents: &HashMap<String, StoryAgent>,
|
||||
agent_name: &str,
|
||||
@@ -15,16 +30,18 @@ pub(in crate::agents::pool) fn is_agent_free(
|
||||
!agents.values().any(|a| {
|
||||
a.agent_name == agent_name
|
||||
&& matches!(a.status, AgentStatus::Running | AgentStatus::Pending)
|
||||
&& !is_throttle_expired(a)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn scan_stage_items(_project_root: &Path, stage_dir: &str) -> Vec<String> {
|
||||
/// Return all story_ids in the given pipeline `Stage`, sourced from the CRDT.
|
||||
pub(super) fn scan_stage_items(want: &crate::pipeline_state::Stage) -> Vec<String> {
|
||||
use std::collections::BTreeSet;
|
||||
let mut items = BTreeSet::new();
|
||||
|
||||
// CRDT is the only source of truth — no filesystem fallback.
|
||||
for item in crate::pipeline_state::read_all_typed() {
|
||||
if item.stage.dir_name() == stage_dir {
|
||||
if std::mem::discriminant(&item.stage) == std::mem::discriminant(want) {
|
||||
items.insert(item.story_id.0.clone());
|
||||
}
|
||||
}
|
||||
@@ -103,11 +120,9 @@ pub(in crate::agents::pool) fn find_free_agent_for_stage<'a>(
|
||||
// model matches. This keeps opus agents reserved for explicit requests.
|
||||
if *stage == PipelineStage::Coder
|
||||
&& let Some(ref default_model) = config.default_coder_model
|
||||
&& agent_config.model.as_ref() != Some(default_model)
|
||||
{
|
||||
let agent_model = agent_config.model.as_deref().unwrap_or("");
|
||||
if agent_model != default_model {
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let is_busy = agents.values().any(|a| {
|
||||
a.agent_name == agent_config.name
|
||||
@@ -148,7 +163,8 @@ mod tests {
|
||||
project_root: None,
|
||||
log_session_id: None,
|
||||
merge_failure_reported: false,
|
||||
throttled: false,
|
||||
merge_success_reported: false,
|
||||
throttled: None,
|
||||
termination_reason: None,
|
||||
status_buffer: None,
|
||||
}
|
||||
@@ -162,50 +178,52 @@ mod tests {
|
||||
// attempt to promote an archived story.
|
||||
#[test]
|
||||
fn scan_stage_items_skips_filesystem_item_known_to_crdt_at_different_stage() {
|
||||
use crate::pipeline_state::Stage;
|
||||
crate::db::ensure_content_store();
|
||||
// Write the story into the CRDT as 6_archived.
|
||||
crate::db::write_item_with_content(
|
||||
"9970_story_archived",
|
||||
"6_archived",
|
||||
"---\nname: Archived\n---\n",
|
||||
crate::db::ItemMeta::named("Archived"),
|
||||
);
|
||||
|
||||
// Also place a stale .md file in a temp 1_backlog/ dir.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let backlog = tmp.path().join(".huskies/work/1_backlog");
|
||||
std::fs::create_dir_all(&backlog).unwrap();
|
||||
std::fs::write(
|
||||
backlog.join("9970_story_archived.md"),
|
||||
"---\nname: Archived\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let items = scan_stage_items(tmp.path(), "1_backlog");
|
||||
let items = scan_stage_items(&Stage::Backlog);
|
||||
assert!(
|
||||
!items.contains(&"9970_story_archived".to_string()),
|
||||
"archived CRDT story must not appear in 1_backlog scan via stale filesystem shadow"
|
||||
"archived CRDT story must not appear in backlog scan"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_stage_items_returns_empty_for_missing_dir() {
|
||||
// Use a unique stage name that no other test writes to, so
|
||||
// the global CRDT store won't contribute stale items.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let items = scan_stage_items(tmp.path(), "9_nonexistent");
|
||||
assert!(items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_stage_items_returns_sorted_story_ids() {
|
||||
use crate::pipeline_state::Stage;
|
||||
// Write items via the CRDT store (the primary source of truth).
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content("9942_story_foo", "2_current", "---\nname: foo\n---");
|
||||
crate::db::write_item_with_content("9940_story_bar", "2_current", "---\nname: bar\n---");
|
||||
crate::db::write_item_with_content("9935_story_baz", "2_current", "---\nname: baz\n---");
|
||||
crate::db::write_item_with_content(
|
||||
"9942_story_foo",
|
||||
"2_current",
|
||||
"---\nname: foo\n---",
|
||||
crate::db::ItemMeta::named("foo"),
|
||||
);
|
||||
crate::db::write_item_with_content(
|
||||
"9940_story_bar",
|
||||
"2_current",
|
||||
"---\nname: bar\n---",
|
||||
crate::db::ItemMeta::named("bar"),
|
||||
);
|
||||
crate::db::write_item_with_content(
|
||||
"9935_story_baz",
|
||||
"2_current",
|
||||
"---\nname: baz\n---",
|
||||
crate::db::ItemMeta::named("baz"),
|
||||
);
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let items = scan_stage_items(tmp.path(), "2_current");
|
||||
let items = scan_stage_items(&Stage::Coding {
|
||||
claim: None,
|
||||
plan: Default::default(),
|
||||
retries: 0,
|
||||
});
|
||||
// The global CRDT may contain items from other tests, so check
|
||||
// that our three items are present and appear in sorted order.
|
||||
assert!(
|
||||
@@ -571,6 +589,51 @@ model = "sonnet"
|
||||
assert_eq!(free, Some("qa"));
|
||||
}
|
||||
|
||||
// ── is_agent_free: throttle-expiry behaviour ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn is_agent_free_returns_false_for_running_agent_no_throttle() {
|
||||
let mut agents = HashMap::new();
|
||||
agents.insert(
|
||||
"s1:coder-1".to_string(),
|
||||
make_test_story_agent("coder-1", AgentStatus::Running),
|
||||
);
|
||||
assert!(
|
||||
!is_agent_free(&agents, "coder-1"),
|
||||
"running agent with no throttle should be busy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_agent_free_returns_false_for_running_agent_with_active_throttle() {
|
||||
let mut agent = make_test_story_agent("coder-1", AgentStatus::Running);
|
||||
// Throttle expires far in the future → still active.
|
||||
agent.throttled = Some(crate::agents::AgentExecution::Throttled {
|
||||
until: chrono::Utc::now() + chrono::Duration::hours(1),
|
||||
});
|
||||
let mut agents = HashMap::new();
|
||||
agents.insert("s1:coder-1".to_string(), agent);
|
||||
assert!(
|
||||
!is_agent_free(&agents, "coder-1"),
|
||||
"running agent with active throttle should still be busy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_agent_free_returns_true_for_running_agent_with_expired_throttle() {
|
||||
let mut agent = make_test_story_agent("coder-1", AgentStatus::Running);
|
||||
// Throttle expired in the past → agent is eligible for retry.
|
||||
agent.throttled = Some(crate::agents::AgentExecution::Throttled {
|
||||
until: chrono::Utc::now() - chrono::Duration::minutes(1),
|
||||
});
|
||||
let mut agents = HashMap::new();
|
||||
agents.insert("s1:coder-1".to_string(), agent);
|
||||
assert!(
|
||||
is_agent_free(&agents, "coder-1"),
|
||||
"running agent with expired throttle should be considered free"
|
||||
);
|
||||
}
|
||||
|
||||
// ── count_active_agents_for_stage ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,168 +1,77 @@
|
||||
//! Front-matter checks for story files: review holds, blocked state, and merge failures.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Read story contents from the DB content store (CRDT-backed).
|
||||
fn read_story_contents(_project_root: &Path, story_id: &str) -> Option<String> {
|
||||
crate::db::read_content(story_id)
|
||||
}
|
||||
|
||||
/// Read the optional `agent:` field from the front matter of a story file.
|
||||
/// Read the optional `agent:` pin for a story.
|
||||
///
|
||||
/// Returns `Some(agent_name)` if the front matter specifies an agent, or `None`
|
||||
/// if the field is absent or the file cannot be read / parsed.
|
||||
pub(super) fn read_story_front_matter_agent(
|
||||
project_root: &Path,
|
||||
_stage_dir: &str,
|
||||
story_id: &str,
|
||||
) -> Option<String> {
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
let contents = read_story_contents(project_root, story_id)?;
|
||||
parse_front_matter(&contents).ok()?.agent
|
||||
/// After story 871 the agent assignment lives in the CRDT typed register
|
||||
/// (`PipelineItemView.agent`), not the YAML front matter. We check the CRDT
|
||||
/// first; falling back to legacy YAML parsing keeps behaviour intact for any
|
||||
/// stories whose CRDT entry doesn't yet have the field set.
|
||||
pub(super) fn read_story_front_matter_agent(story_id: &str) -> Option<String> {
|
||||
// Story 929: agent name comes from the CRDT register. The previous
|
||||
// YAML fallback is gone — post-891 every story has its CRDT entry,
|
||||
// and any story without one is treated as having no pinned agent.
|
||||
crate::crdt_state::read_item(story_id).and_then(|w| w.agent().map(|a| a.to_string()))
|
||||
}
|
||||
|
||||
/// 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::io::story_metadata::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.review_hold)
|
||||
/// Return `true` if the story is in `Stage::ReviewHold`.
|
||||
///
|
||||
/// Story 945: `Stage::ReviewHold { resume_to, reason }` is the single source
|
||||
/// of truth — the legacy `review_hold: bool` CRDT register has been deleted.
|
||||
/// The auto-assigner uses this to keep human-QA items / spikes parked after
|
||||
/// gates pass until a reviewer explicitly clears the hold (e.g. via
|
||||
/// `tool_approve_qa`).
|
||||
pub(super) fn has_review_hold(story_id: &str) -> bool {
|
||||
crate::crdt_state::read_item(story_id)
|
||||
.map(|w| matches!(w.stage(), crate::pipeline_state::Stage::ReviewHold { .. }))
|
||||
.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::io::story_metadata::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(story_id: &str) -> bool {
|
||||
crate::pipeline_state::read_typed(story_id)
|
||||
.ok()
|
||||
.and_then(|m| m.blocked)
|
||||
.flatten()
|
||||
.map(|item| {
|
||||
matches!(
|
||||
item.stage,
|
||||
crate::pipeline_state::Stage::Blocked { .. }
|
||||
| crate::pipeline_state::Stage::MergeFailure { .. }
|
||||
| crate::pipeline_state::Stage::MergeFailureFinal { .. }
|
||||
| crate::pipeline_state::Stage::Archived {
|
||||
reason: crate::pipeline_state::ArchiveReason::Blocked { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Return `true` if the story has any `depends_on` entries that are not yet in
|
||||
/// `5_done` or `6_archived`.
|
||||
///
|
||||
/// Reads dependency state from the CRDT document first. Falls back to the
|
||||
/// filesystem when the CRDT layer is not initialised.
|
||||
pub(super) fn has_unmet_dependencies(project_root: &Path, stage_dir: &str, story_id: &str) -> bool {
|
||||
// Prefer CRDT-based check.
|
||||
let crdt_deps = crate::crdt_state::check_unmet_deps_crdt(story_id);
|
||||
if !crdt_deps.is_empty() {
|
||||
return true;
|
||||
}
|
||||
// If the CRDT had the item and returned empty deps, it means all are met.
|
||||
if crate::pipeline_state::read_typed(story_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Fallback: filesystem check (CRDT not initialised or item not yet in CRDT).
|
||||
!crate::io::story_metadata::check_unmet_deps(project_root, stage_dir, story_id).is_empty()
|
||||
/// `5_done` or `6_archived`. Reads dependency state from the CRDT (story 929).
|
||||
pub(super) fn has_unmet_dependencies(story_id: &str) -> bool {
|
||||
!crate::crdt_state::check_unmet_deps_crdt(story_id).is_empty()
|
||||
}
|
||||
|
||||
/// Return the list of dependency story numbers that are in `6_archived` (satisfied
|
||||
/// via archive rather than via a clean `5_done` completion).
|
||||
///
|
||||
/// Used to emit a warning when backlog promotion fires because one or more deps were
|
||||
/// archived. Returns an empty `Vec` when no deps are archived. Reads from CRDT
|
||||
/// first; falls back to filesystem when CRDT is not initialised.
|
||||
pub(super) fn check_archived_dependencies(
|
||||
project_root: &Path,
|
||||
stage_dir: &str,
|
||||
story_id: &str,
|
||||
) -> Vec<u32> {
|
||||
// Prefer CRDT-based check when the item is known to CRDT.
|
||||
if crate::pipeline_state::read_typed(story_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
{
|
||||
return crate::crdt_state::check_archived_deps_crdt(story_id);
|
||||
}
|
||||
// Fallback: filesystem.
|
||||
crate::io::story_metadata::check_archived_deps(project_root, stage_dir, story_id)
|
||||
/// via archive rather than via a clean `5_done` completion). Reads from the CRDT
|
||||
/// (story 929).
|
||||
pub(super) fn check_archived_dependencies(story_id: &str) -> Vec<u32> {
|
||||
crate::crdt_state::check_archived_deps_crdt(story_id)
|
||||
}
|
||||
|
||||
/// Return `true` if the story is in the `Frozen` pipeline stage.
|
||||
/// Return `true` if the story is in `Stage::Frozen`.
|
||||
///
|
||||
/// Checks the typed CRDT stage via `read_typed`.
|
||||
pub(super) fn is_story_frozen(_project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
||||
crate::pipeline_state::read_typed(story_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|item| item.stage.is_frozen())
|
||||
.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::io::story_metadata::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::io::story_metadata::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::io::story_metadata::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)
|
||||
/// Story 945: `Stage::Frozen { resume_to }` is the single source of truth —
|
||||
/// the legacy `frozen: bool` CRDT register has been deleted. Frozen stories
|
||||
/// are skipped by the auto-assigner until `Unfreeze` returns them to
|
||||
/// `resume_to`.
|
||||
pub(super) fn is_story_frozen(story_id: &str) -> bool {
|
||||
crate::crdt_state::read_item(story_id)
|
||||
.map(|view| matches!(view.stage(), crate::pipeline_state::Stage::Frozen { .. }))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -172,82 +81,142 @@ pub(super) fn has_mergemaster_attempted(
|
||||
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_flag_set() {
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
// Story 945: review_hold is now a typed Stage variant, seeded via
|
||||
// the wire-form stage register directly.
|
||||
crate::crdt_state::write_item_str(
|
||||
"890_spike_held",
|
||||
"review_hold",
|
||||
Some("Held Spike"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(has_review_hold("890_spike_held"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_review_hold_returns_false_when_flag_unset() {
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
crate::crdt_state::write_item_str(
|
||||
"890_spike_active_qa",
|
||||
"3_qa",
|
||||
Some("Active QA Spike"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(!has_review_hold("890_spike_active_qa"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_review_hold_returns_false_when_story_unknown() {
|
||||
assert!(!has_review_hold("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();
|
||||
crate::db::write_item_with_content(
|
||||
"10_spike_research",
|
||||
"3_qa",
|
||||
"---\nname: Research spike\nreview_hold: true\n---\n# Spike\n",
|
||||
"890_story_blocked_set",
|
||||
"2_blocked",
|
||||
"---\nname: Blocked Story\n---\n",
|
||||
crate::db::ItemMeta::named("Blocked Story"),
|
||||
);
|
||||
assert!(has_review_hold(tmp.path(), "3_qa", "10_spike_research"));
|
||||
assert!(is_story_blocked("890_story_blocked_set"));
|
||||
}
|
||||
|
||||
#[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"));
|
||||
fn is_story_blocked_cleared_via_typed_stage_returns_false() {
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
// 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("890_story_blocked_clear"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_review_hold_returns_false_when_file_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert!(!has_review_hold(tmp.path(), "3_qa", "99_spike_missing"));
|
||||
fn is_story_blocked_stale_yaml_is_ignored() {
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
// 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("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();
|
||||
let current = tmp.path().join(".huskies/work/2_current");
|
||||
std::fs::create_dir_all(¤t).unwrap();
|
||||
std::fs::write(
|
||||
current.join("10_story_blocked.md"),
|
||||
"---\nname: Blocked\ndepends_on: [999]\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(has_unmet_dependencies(
|
||||
tmp.path(),
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::crdt_state::write_item_str(
|
||||
"10_story_blocked",
|
||||
"2_current",
|
||||
"10_story_blocked"
|
||||
));
|
||||
Some("Blocked"),
|
||||
None,
|
||||
Some("[999]"),
|
||||
None,
|
||||
);
|
||||
assert!(has_unmet_dependencies("10_story_blocked"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_unmet_dependencies_returns_false_when_dep_done() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".huskies/work/2_current");
|
||||
let done = tmp.path().join(".huskies/work/5_done");
|
||||
std::fs::create_dir_all(¤t).unwrap();
|
||||
std::fs::create_dir_all(&done).unwrap();
|
||||
std::fs::write(done.join("999_story_dep.md"), "---\nname: Dep\n---\n").unwrap();
|
||||
std::fs::write(
|
||||
current.join("10_story_ok.md"),
|
||||
"---\nname: Ok\ndepends_on: [999]\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!has_unmet_dependencies(
|
||||
tmp.path(),
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::crdt_state::write_item_str("999_story_dep", "5_done", Some("Dep"), None, None, None);
|
||||
crate::crdt_state::write_item_str(
|
||||
"10_story_ok",
|
||||
"2_current",
|
||||
"10_story_ok"
|
||||
));
|
||||
Some("Ok"),
|
||||
None,
|
||||
Some("[999]"),
|
||||
None,
|
||||
);
|
||||
assert!(!has_unmet_dependencies("10_story_ok"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_unmet_dependencies_returns_false_when_no_deps() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".huskies/work/2_current");
|
||||
std::fs::create_dir_all(¤t).unwrap();
|
||||
std::fs::write(current.join("5_story_free.md"), "---\nname: Free\n---\n").unwrap();
|
||||
assert!(!has_unmet_dependencies(
|
||||
tmp.path(),
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::crdt_state::write_item_str(
|
||||
"5_story_free",
|
||||
"2_current",
|
||||
"5_story_free"
|
||||
));
|
||||
Some("Free"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(!has_unmet_dependencies("5_story_free"));
|
||||
}
|
||||
|
||||
// ── Bug 503: archived-dep visibility ─────────────────────────────────────
|
||||
@@ -255,42 +224,48 @@ mod tests {
|
||||
/// check_archived_dependencies returns dep IDs that are in 6_archived.
|
||||
#[test]
|
||||
fn check_archived_dependencies_returns_archived_ids() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let backlog = tmp.path().join(".huskies/work/1_backlog");
|
||||
let archived = tmp.path().join(".huskies/work/6_archived");
|
||||
std::fs::create_dir_all(&backlog).unwrap();
|
||||
std::fs::create_dir_all(&archived).unwrap();
|
||||
std::fs::write(
|
||||
archived.join("500_spike_crdt.md"),
|
||||
"---\nname: CRDT Spike\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
backlog.join("503_story_dependent.md"),
|
||||
"---\nname: Dependent\ndepends_on: [500]\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
let archived_deps =
|
||||
check_archived_dependencies(tmp.path(), "1_backlog", "503_story_dependent");
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::crdt_state::write_item_str(
|
||||
"500_spike_crdt",
|
||||
"6_archived",
|
||||
Some("CRDT Spike"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
crate::crdt_state::write_item_str(
|
||||
"503_story_dependent",
|
||||
"1_backlog",
|
||||
Some("Dependent"),
|
||||
None,
|
||||
Some("[500]"),
|
||||
None,
|
||||
);
|
||||
let archived_deps = check_archived_dependencies("503_story_dependent");
|
||||
assert_eq!(archived_deps, vec![500]);
|
||||
}
|
||||
|
||||
/// check_archived_dependencies returns empty when dep is in 5_done (not archived).
|
||||
#[test]
|
||||
fn check_archived_dependencies_empty_when_dep_in_done() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let backlog = tmp.path().join(".huskies/work/1_backlog");
|
||||
let done = tmp.path().join(".huskies/work/5_done");
|
||||
std::fs::create_dir_all(&backlog).unwrap();
|
||||
std::fs::create_dir_all(&done).unwrap();
|
||||
std::fs::write(done.join("490_story_done.md"), "---\nname: Done\n---\n").unwrap();
|
||||
std::fs::write(
|
||||
backlog.join("503_story_waiting.md"),
|
||||
"---\nname: Waiting\ndepends_on: [490]\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
let archived_deps =
|
||||
check_archived_dependencies(tmp.path(), "1_backlog", "503_story_waiting");
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::crdt_state::write_item_str(
|
||||
"490_story_done",
|
||||
"5_done",
|
||||
Some("Done"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
crate::crdt_state::write_item_str(
|
||||
"503_story_waiting",
|
||||
"1_backlog",
|
||||
Some("Waiting"),
|
||||
None,
|
||||
Some("[490]"),
|
||||
None,
|
||||
);
|
||||
let archived_deps = check_archived_dependencies("503_story_waiting");
|
||||
assert!(archived_deps.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,10 @@ pub(crate) fn compute_budget_from_single_log(path: &Path) -> f64 {
|
||||
&& let Some(message) = data.get("message")
|
||||
&& let Some(usage) = message.get("usage")
|
||||
{
|
||||
let model = message.get("model").and_then(|v| v.as_str());
|
||||
let model = message
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(crate::agents::AgentModel::from_api_str);
|
||||
let token_usage = TokenUsage {
|
||||
input_tokens: usage
|
||||
.get("input_tokens")
|
||||
@@ -87,7 +90,7 @@ pub(crate) fn compute_budget_from_single_log(path: &Path) -> f64 {
|
||||
.unwrap_or(0),
|
||||
total_cost_usd: 0.0,
|
||||
};
|
||||
cost += token_usage.estimate_cost_usd(model);
|
||||
cost += token_usage.estimate_cost_usd(model.as_ref());
|
||||
}
|
||||
}
|
||||
cost
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user