Compare commits
298 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce688fc0bf | |||
| c131896432 | |||
| 42e6eec9e9 | |||
| fe00fe6a25 | |||
| c97b7c841f | |||
| 2d0387fe63 | |||
| 71d3047ef0 | |||
| d86cc38b2a | |||
| 21b2efd268 | |||
| badd522d60 | |||
| ecd3f600d9 | |||
| 099df17e77 | |||
| c88e42eba2 | |||
| 89058ebd49 | |||
| d8204ab7ed | |||
| e2ea1af4c8 | |||
| 08780475d0 | |||
| 6eb2742e7d | |||
| c1b7e12b0b | |||
| 53d44ff42a | |||
| 6331dea8b0 | |||
| 240beec7de | |||
| 7de167b21b | |||
| 49af014a84 | |||
| 73cf1c6ff9 | |||
| f8b1e14b74 | |||
| 265e6f9a15 | |||
| 40e995da88 | |||
| 6e4fb7fd4b | |||
| 0695ad7ae6 | |||
| eb6b07531a | |||
| 2d6846fe03 | |||
| a5bfd40233 | |||
| a40500eea9 | |||
| f8212f102f | |||
| 59302b465d | |||
| efafe44db1 | |||
| 6a2f81e873 | |||
| 3a43337735 | |||
| b6df89d24c | |||
| 10d992a7e4 | |||
| 5c63618b30 | |||
| 7db0b78e88 | |||
| 979492449e | |||
| 6fbe239313 | |||
| 26527e7dae | |||
| 04a57e92c2 | |||
| d59efa0b5c | |||
| 4216ced493 | |||
| 9f4f493486 | |||
| 63d86f1263 | |||
| 398a5806e7 | |||
| 1adc734801 | |||
| 0ae6dfd565 | |||
| 8531bac6cd | |||
| ce13c00ebd | |||
| 2857c3b46b | |||
| d944885ce9 | |||
| 62d1535e76 | |||
| 46556d308a | |||
| fc5481dbe4 | |||
| 01e60a670c | |||
| c4010854a5 | |||
| fb1311cdae | |||
| 4aa76ce673 | |||
| fb82bd7bca | |||
| b7df5cbe4e | |||
| fe9804b32c | |||
| 8446ab1c71 | |||
| b5054b08d3 | |||
| df32a1542b | |||
| e82602db77 | |||
| 2d6105c778 | |||
| d89940e85b | |||
| 60fceee204 | |||
| 13f7dab5f0 | |||
| f7413cc711 | |||
| b053f14d58 | |||
| 56179d712e | |||
| a06bf6778b | |||
| 1506141155 | |||
| ae69cd50b1 | |||
| 0c23d209a0 | |||
| eac5763e03 | |||
| 6530eeab6d | |||
| 5eb8f2f8a7 | |||
| f9b140add9 | |||
| d4db96f709 | |||
| 5f08573db8 | |||
| da83fcb78d | |||
| f04bdd1f14 | |||
| bb6a6063e8 | |||
| bf813d910b | |||
| 374aa77f27 | |||
| 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"]
|
||||
}
|
||||
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Pre-commit hook installed by huskies.
|
||||
# Runs script/check (fmt-check, clippy, cargo check, source-map-check)
|
||||
# before every commit. Aborts if any gate fails.
|
||||
#
|
||||
# Emergency bypass: git commit --no-verify (see AGENT.md — avoid this)
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
|
||||
printf '[pre-commit] Running script/check ...\n'
|
||||
OUTPUT=$("$REPO_ROOT/script/check" 2>&1)
|
||||
STATUS=$?
|
||||
|
||||
if [ "$STATUS" -ne 0 ]; then
|
||||
printf '\n=== PRE-COMMIT HOOK FAILED ===\n\n'
|
||||
printf '%s\n' "$OUTPUT"
|
||||
printf '\nFix the issues above, then re-validate with:\n'
|
||||
printf ' script/check\n'
|
||||
printf '\nEmergency bypass (see AGENT.md -- avoid this):\n'
|
||||
printf ' git commit --no-verify\n\n'
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,5 +1,6 @@
|
||||
# Claude Code
|
||||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
.mcp.json
|
||||
|
||||
# Local environment (secrets)
|
||||
@@ -14,6 +15,11 @@ _merge_parsed.json
|
||||
.huskies_port
|
||||
.huskies/bot.toml.bak
|
||||
.huskies/build_hash
|
||||
# Phantom 0-byte pipeline.db sometimes appears at repo root from old code; canonical DB lives at .huskies/pipeline.db
|
||||
/pipeline.db
|
||||
|
||||
# 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
|
||||
|
||||
@@ -29,6 +29,7 @@ timers.json
|
||||
|
||||
# Misc
|
||||
wishlist.md
|
||||
double_timmy_log.md
|
||||
|
||||
# Database
|
||||
pipeline.db
|
||||
|
||||
+62
-1
@@ -1,7 +1,62 @@
|
||||
# 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/`.
|
||||
Docs live in `website/app/docs/*.tsx` (Next.js pages), **not** Markdown files. When a story asks you to document something, edit the relevant `.tsx` file under `website/app/docs/`. Run `npm run build` in `website/` to verify your changes render correctly.
|
||||
|
||||
## Configuration files
|
||||
- Agent config: `.huskies/agents.toml` (preferred) or `[[agent]]` blocks in `.huskies/project.toml`
|
||||
@@ -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,221 @@
|
||||
# Chat-Driven Project Bootstrap
|
||||
|
||||
Design overview for going from "I want a new project" to a running,
|
||||
container-isolated, editor-accessible huskies project in one chat command.
|
||||
|
||||
## Goal
|
||||
|
||||
A user can say to Timmy in chat:
|
||||
|
||||
```
|
||||
new project myapp --stack rust
|
||||
new project legacy-rails --git git@github.com:me/legacy-rails.git
|
||||
```
|
||||
|
||||
and end up with:
|
||||
|
||||
1. A fresh docker container running the project's huskies node.
|
||||
2. The project's source code bind-mounted from the host so the user can
|
||||
edit it in any editor.
|
||||
3. SSH into the container so editors can run LSPs, builds, and tests
|
||||
inside the container — never on the host.
|
||||
4. Optional git remote configured for push to GitHub or Gitea.
|
||||
5. The new sled registered with the gateway, so Timmy can drive coders /
|
||||
mergemaster / etc. on the project via existing chat commands.
|
||||
|
||||
Manual repo creation on GitHub/Gitea remains the user's job. Everything
|
||||
downstream of that is orchestrated.
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ Browser / Matrix │───┐
|
||||
└──────────────────────┘ │
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ Gateway (huskies-gw) │
|
||||
│ • chat dispatcher │
|
||||
│ • new-project │
|
||||
│ • routing │
|
||||
└─────────┬─────────────┘
|
||||
│
|
||||
┌─────────┴───────────────────────────────────┐
|
||||
│ docker engine (host) │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌─────────┐ │
|
||||
│ │ project-A │ │ project-B │ │ ... │ │
|
||||
│ │ sled + │ │ sled + │ │ │ │
|
||||
│ │ sshd + │ │ sshd + │ │ │ │
|
||||
│ │ LSPs │ │ LSPs │ │ │ │
|
||||
│ └─────┬──────┘ └─────┬──────┘ └─────────┘ │
|
||||
└────────┼──────────────┼─────────────────────┘
|
||||
│ │
|
||||
bind mount │ │ bind mount
|
||||
┌────────┴───┐ ┌─────┴──────┐
|
||||
│ ~/code/A │ │ ~/code/B │ ◄── host
|
||||
└────────────┘ └────────────┘ editor opens
|
||||
these paths
|
||||
```
|
||||
|
||||
- One container per project. The container runs the project's huskies
|
||||
binary (sled), an SSH server, and the stack-appropriate LSP(s).
|
||||
- Source lives on the host (e.g. `~/code/<project>`), bind-mounted into
|
||||
the container at a known path. Host can git-diff, back up, or edit.
|
||||
- The gateway is editor-agnostic and project-agnostic — it talks to each
|
||||
sled via the existing rendezvous / CRDT-sync protocol.
|
||||
|
||||
## Three Personas
|
||||
|
||||
| Persona | What they do | What they need |
|
||||
|---------|--------------|----------------|
|
||||
| Chat-only user | Drives everything via Matrix/web chat | Installed huskies binary; chat client |
|
||||
| Editor-using technical user | Same + edits source in their editor | SSH config to the container + editor-specific remote-dev setup |
|
||||
| Multi-project user | Several projects running in parallel | Gateway-listed projects, all routable from one chat |
|
||||
|
||||
Chat-only users never touch SSH. Editor users go through a one-time
|
||||
"copy this SSH command into your editor's remote settings" handoff at
|
||||
project creation time.
|
||||
|
||||
## The Bootstrap Chat Command
|
||||
|
||||
```
|
||||
new project <name> [--stack <stack>] [--git <url>] [--path <host-path>]
|
||||
```
|
||||
|
||||
Flow:
|
||||
|
||||
1. **Validate**: name unique among existing projects; host path doesn't already
|
||||
exist; stack (if declared) is one of the supported overlays.
|
||||
2. **Allocate** a fresh per-project port range (gateway picks).
|
||||
3. **Create host directory** at `--path` (default `~/huskies/<name>/`).
|
||||
4. If `--git` provided, `git clone` into that directory; else `git init`.
|
||||
5. **Detect stack** from cloned content if not declared:
|
||||
- `Cargo.toml` → `rust`
|
||||
- `package.json` → `node`
|
||||
- `go.mod` → `go`
|
||||
- `pyproject.toml` / `requirements.txt` / `setup.py` → `python`
|
||||
- `Gemfile` → `ruby`
|
||||
- `pom.xml` / `build.gradle` → `jvm`
|
||||
- Multiple → pick the dominant, warn.
|
||||
- None → minimal base image, user can install tooling later.
|
||||
6. **Compose the container** from `huskies-project-base` + the stack
|
||||
overlay (Dockerfile fragments under `docker/stacks/<stack>/`).
|
||||
7. **Launch** the container with bind mount + port forwards + an
|
||||
auto-generated SSH key.
|
||||
8. **Seed `.huskies/project.toml`** with sensible defaults.
|
||||
9. **Register** the project with the gateway (`gateway_projects` LWW-map).
|
||||
10. **Reply in chat** with: project name, host path, SSH command, and
|
||||
a `huskies status <name>` invocation to verify.
|
||||
|
||||
## Container Template
|
||||
|
||||
Layered:
|
||||
|
||||
- **`huskies-project-base`**: debian-slim + git + huskies binary + sshd
|
||||
+ sudo + a `huskies` user with the SSH pubkey installed.
|
||||
- **`huskies-stack-<stack>`**: per-stack additions. E.g. rust gets
|
||||
`rustup` + `rust-analyzer` + `cargo-nextest`; node gets `node@22` +
|
||||
`typescript-language-server`; etc.
|
||||
- **Project layer**: the bind-mounted `/workspace` is the project source,
|
||||
written by the host's editor, read by the in-container tooling.
|
||||
|
||||
The container's SSH server is bound to a host-local port (not exposed
|
||||
externally). Auth is the per-project keypair generated at bootstrap;
|
||||
the public key sits inside the container, the private key on host.
|
||||
|
||||
## Build Sandbox Model
|
||||
|
||||
The threat: editing code in a host-side editor causes the editor (or its
|
||||
LSP plugin) to run `cargo check` / `npm install` / `pip install` /
|
||||
similar, which executes arbitrary code from project dependencies —
|
||||
`build.rs`, proc-macros, npm `postinstall`, Python `setup.py`, Ruby
|
||||
native-extension build scripts, etc. A malicious dependency compromises
|
||||
the host.
|
||||
|
||||
The mitigation: all build / type-check / dependency-install commands
|
||||
execute **inside the project container**. The host's editor connects to
|
||||
the container over SSH; rust-analyzer (or equivalent) runs inside the
|
||||
container; the host process never `exec`s untrusted build scripts.
|
||||
|
||||
Container isolation is the docker default plus:
|
||||
- No `--privileged`.
|
||||
- No host bind mounts beyond the project source and the SSH key.
|
||||
- No host network beyond the gateway's CRDT sync port.
|
||||
- `--cap-drop=ALL` plus the minimum caps needed (probably none).
|
||||
|
||||
This isn't a hardened sandbox in the gvisor / Firecracker sense — a
|
||||
docker-escape exploit on a compromised container still escalates to
|
||||
host. For most consumer threat models (malicious crate from
|
||||
crates.io / npm), docker's default isolation is sufficient. Tighter
|
||||
sandboxing (gvisor) is a separate future spike if needed.
|
||||
|
||||
## Editor Connection — Editor-Agnostic SSH
|
||||
|
||||
| Editor | Connection mechanism |
|
||||
|--------|----------------------|
|
||||
| VSCode | Remote-SSH extension |
|
||||
| JetBrains (IntelliJ/Rover) | JetBrains Gateway (SSH) |
|
||||
| Zed | Built-in SSH remoting (mac/linux only today) |
|
||||
| Vim/Neovim | SSH terminal session, or local nvim + LSP-over-SSH |
|
||||
| Emacs | TRAMP + remote LSP via lsp-mode |
|
||||
|
||||
All converge on: `ssh huskies@127.0.0.1 -p <project-port> -i ~/.huskies/<name>/id_ed25519`.
|
||||
That string is emitted in the bootstrap chat reply.
|
||||
|
||||
## Git Integration
|
||||
|
||||
- Initial setup is `git init` or `git clone` inside the container.
|
||||
- For push: user's existing GitHub / Gitea SSH key is bind-mounted
|
||||
read-only into the container at `~/.ssh/id_*`, OR the user supplies a
|
||||
push token via `huskies secrets set GIT_TOKEN=...` (stored as a Fly
|
||||
secret equivalent — for now, a chmod 600 file in the container).
|
||||
- The container's `git` config gets `user.name` / `user.email` from the
|
||||
gateway-level user identity.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Alternative |
|
||||
|----------|--------|-------------|
|
||||
| Container per project | One container per project | One container many projects: simpler but breaks isolation, breaks per-project deps |
|
||||
| Editor model | SSH-remote (any editor) | VSCode Dev Containers only: simpler config but locks out everyone else |
|
||||
| Source location | Bind mount from host | Inside container only: breaks "I can also edit on my laptop" requirement |
|
||||
| Stack detection | Auto from project files, override with `--stack` | Always declared: more friction at bootstrap |
|
||||
| Push secrets | Bind-mounted host SSH key OR per-project token | Gateway holds tokens: bigger blast radius |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Per-project resource limits.** Should each container have a hard
|
||||
CPU / RAM cap so a runaway agent doesn't starve the host?
|
||||
2. **Lifecycle / cleanup.** If the user deletes a project from chat,
|
||||
what gets removed? Container yes; host source no (data loss); git
|
||||
remotes yes? Need a confirm step.
|
||||
3. **Multi-tenant.** Out of scope for this design (that's huskies.dev
|
||||
territory). This doc assumes single-user local-only.
|
||||
4. **Windows specifics.** Bind mounts work but line-ending /
|
||||
permission edge cases. Probably document "use WSL2 for best
|
||||
experience" rather than fight Windows native paths.
|
||||
5. **Gateway-on-host vs gateway-in-container.** The gateway today runs
|
||||
in its own container. New per-project containers connect via docker
|
||||
network. Need to confirm the network plumbing works for arbitrary
|
||||
per-project containers, not just the manually-configured ones.
|
||||
|
||||
## Phasing
|
||||
|
||||
The work breaks naturally into:
|
||||
|
||||
- **Phase 0 (now):** this design doc.
|
||||
- **Phase 1:** chat command exists and provisions a bare project
|
||||
container (no stack overlay, no SSH, no git clone — just
|
||||
"start a container, register with gateway"). Validates the
|
||||
orchestration shell.
|
||||
- **Phase 2:** stack-aware container template — base image + overlays;
|
||||
detection from project files.
|
||||
- **Phase 3:** SSH-remote editor access — sshd in the container,
|
||||
per-project keypair, chat-reply emits the connection string.
|
||||
- **Phase 4:** git integration — `--git <url>` clones, host SSH key
|
||||
mount, push verification.
|
||||
- **Phase 5:** per-project resource limits + cleanup chat commands.
|
||||
|
||||
Each phase ships independently and is usable on its own. Phase 1 alone
|
||||
gives chat-only users a working project; later phases add the editor
|
||||
and git polish.
|
||||
@@ -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
-1436
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.
|
||||
@@ -79,6 +79,10 @@ cd frontend && npm install && npm run dev
|
||||
|
||||
Configuration lives in `.huskies/project.toml`. See `.huskies/bot.toml.*.example` for transport setup.
|
||||
|
||||
## Website
|
||||
|
||||
The huskies.dev website source has moved to [crashlabs/huskies-server](https://code.crashlabs.io/crashlabs/huskies-server).
|
||||
|
||||
## Architecture
|
||||
|
||||
Internal architecture documentation lives in [`docs/architecture/`](docs/architecture/):
|
||||
|
||||
@@ -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)
|
||||
|
||||
+12
-3
@@ -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
|
||||
@@ -46,8 +46,17 @@ WORKDIR /app
|
||||
# build.rs) can produce the release binary with embedded frontend assets.
|
||||
COPY . .
|
||||
|
||||
# Build frontend deps first (better layer caching)
|
||||
RUN cd frontend && npm ci
|
||||
# Build frontend deps first (better layer caching).
|
||||
# Cannot use `npm ci` because of npm's optional-dependencies bug
|
||||
# (npm/cli#4828): platform-specific bindings (e.g. rolldown's
|
||||
# linux-arm64-gnu native binary, introduced by 1119's vite 5→8 upgrade)
|
||||
# get listed in package-lock.json for the lockfile author's platform
|
||||
# only, so `npm ci` skips them on every other platform — the build
|
||||
# then fails at runtime with `Cannot find native binding`. Wipe the
|
||||
# lockfile + node_modules and let `npm install` resolve fresh for the
|
||||
# build platform. The lockfile mutation stays inside the container
|
||||
# image and never reaches the host repo.
|
||||
RUN cd frontend && rm -rf node_modules package-lock.json && npm install
|
||||
|
||||
# Build the release binary (build.rs runs npm run build for the frontend)
|
||||
RUN cargo build --release \
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# huskies-project-base — minimal base for all project containers.
|
||||
#
|
||||
# This image provides git, the huskies server binary, and a non-root user.
|
||||
# It carries no language tooling. Per-stack overlays (docker/stacks/<name>/
|
||||
# Dockerfile.fragment) layer their toolchains on top of this base.
|
||||
#
|
||||
# Prerequisites: build the main `huskies` image first so its binary is
|
||||
# available as a build source.
|
||||
#
|
||||
# docker build -t huskies -f docker/Dockerfile .
|
||||
# docker build -t huskies-project-base -f docker/Dockerfile.base .
|
||||
#
|
||||
# To build a stack image (e.g. rust):
|
||||
# (echo "FROM huskies-project-base"; \
|
||||
# cat docker/stacks/rust/Dockerfile.fragment) | \
|
||||
# docker build -t huskies-project-rust -
|
||||
|
||||
FROM huskies AS huskies-src
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
ca-certificates \
|
||||
libssl3 \
|
||||
procps \
|
||||
openssh-server \
|
||||
sudo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy the huskies binary and entrypoint from the main image.
|
||||
COPY --from=huskies-src /usr/local/bin/huskies /usr/local/bin/huskies
|
||||
COPY --from=huskies-src /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Non-root user — Claude Code refuses --dangerously-skip-permissions as root.
|
||||
# -s /bin/bash required for SSH sessions to start a real shell.
|
||||
RUN groupadd -r huskies \
|
||||
&& useradd -r -g huskies -m -d /home/huskies -s /bin/bash huskies \
|
||||
&& mkdir -p /home/huskies/.claude \
|
||||
&& mkdir -p /home/huskies/.ssh \
|
||||
&& chmod 700 /home/huskies/.ssh \
|
||||
&& chown -R huskies:huskies /home/huskies \
|
||||
&& mkdir -p /workspace \
|
||||
&& chown huskies:huskies /workspace \
|
||||
&& git config --global init.defaultBranch master \
|
||||
&& echo "huskies ALL=(root) NOPASSWD: /usr/sbin/sshd" > /etc/sudoers.d/huskies-sshd \
|
||||
&& chmod 0440 /etc/sudoers.d/huskies-sshd \
|
||||
&& mkdir -p /run/sshd \
|
||||
&& sed -i \
|
||||
-e 's/#PasswordAuthentication yes/PasswordAuthentication no/' \
|
||||
-e 's/#PubkeyAuthentication yes/PubkeyAuthentication yes/' \
|
||||
-e 's/UsePAM yes/UsePAM no/' \
|
||||
/etc/ssh/sshd_config
|
||||
|
||||
# Shell profile for SSH sessions: land in /workspace and load toolchain paths.
|
||||
RUN printf 'cd /workspace\n[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"\n' \
|
||||
> /home/huskies/.profile \
|
||||
&& chown huskies:huskies /home/huskies/.profile
|
||||
|
||||
USER huskies
|
||||
WORKDIR /workspace
|
||||
|
||||
EXPOSE 3001 22
|
||||
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
CMD ["huskies", "/workspace"]
|
||||
@@ -1,6 +1,22 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# ── SSH authorized key ────────────────────────────────────────────────
|
||||
# HUSKIES_SSH_PUBKEY is set by `new project` when it generates a keypair.
|
||||
# Write it to authorized_keys so the user can connect with the matching
|
||||
# private key stored at ~/.huskies/<project>/id_ed25519 on the host.
|
||||
if [ -n "$HUSKIES_SSH_PUBKEY" ]; then
|
||||
mkdir -p /home/huskies/.ssh
|
||||
chmod 700 /home/huskies/.ssh
|
||||
printf '%s\n' "$HUSKIES_SSH_PUBKEY" > /home/huskies/.ssh/authorized_keys
|
||||
chmod 600 /home/huskies/.ssh/authorized_keys
|
||||
fi
|
||||
|
||||
# ── SSH daemon ────────────────────────────────────────────────────────
|
||||
# Start sshd in the background so the container accepts SSH connections.
|
||||
# Uses sudo (huskies has NOPASSWD for /usr/sbin/sshd in sudoers.d).
|
||||
sudo /usr/sbin/sshd -D -e &
|
||||
|
||||
# ── Git identity ─────────────────────────────────────────────────────
|
||||
# Agents commit code inside the container. Without a git identity,
|
||||
# commits fail or use garbage defaults. Fail loudly at startup so the
|
||||
@@ -25,6 +41,20 @@ export GIT_COMMITTER_NAME="$GIT_USER_NAME"
|
||||
export GIT_AUTHOR_EMAIL="$GIT_USER_EMAIL"
|
||||
export GIT_COMMITTER_EMAIL="$GIT_USER_EMAIL"
|
||||
|
||||
# ── Git credential helper (HTTPS push) ────────────────────────────────────
|
||||
# If GIT_PUSH_TOKEN is supplied at container creation time, configure git's
|
||||
# built-in credential store so `git push` over HTTPS authenticates without
|
||||
# user interaction. GIT_CLONE_URL provides the host portion of the URL used
|
||||
# as the key in ~/.git-credentials.
|
||||
if [ -n "$GIT_PUSH_TOKEN" ] && [ -n "$GIT_CLONE_URL" ]; then
|
||||
_scheme=$(echo "$GIT_CLONE_URL" | cut -d':' -f1)
|
||||
_host=$(echo "$GIT_CLONE_URL" | sed 's|^https\?://||' | cut -d'/' -f1)
|
||||
git config --global credential.helper store
|
||||
printf '%s://x-access-token:%s@%s\n' "$_scheme" "$GIT_PUSH_TOKEN" "$_host" \
|
||||
> /home/huskies/.git-credentials
|
||||
chmod 600 /home/huskies/.git-credentials
|
||||
fi
|
||||
|
||||
# ── Frontend native deps ────────────────────────────────────────────
|
||||
# The project repo is bind-mounted from the host, so node_modules/
|
||||
# may contain native binaries for the wrong platform (e.g. darwin
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Go stack overlay fragment.
|
||||
#
|
||||
# Layer this on top of huskies-project-base to produce a project container
|
||||
# with Go 1.22, gopls (official Go language server), and standard tooling.
|
||||
#
|
||||
# Build the combined image:
|
||||
# (echo "FROM huskies-project-base"; \
|
||||
# cat docker/stacks/go/Dockerfile.fragment) | \
|
||||
# docker build -t huskies-project-go -
|
||||
#
|
||||
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||
|
||||
USER root
|
||||
|
||||
# Official Go binary distribution — Debian's golang-go package is too old for gopls.
|
||||
# Update GOVERSION to pick up a newer release.
|
||||
ENV GOVERSION="1.22.3"
|
||||
RUN curl -fsSL "https://go.dev/dl/go${GOVERSION}.linux-amd64.tar.gz" \
|
||||
| tar -C /usr/local -xzf -
|
||||
|
||||
ENV PATH="/usr/local/go/bin:${PATH}"
|
||||
|
||||
# gopls: the official Go language server.
|
||||
# GOBIN=/usr/local/bin puts the binary on the system PATH for all users.
|
||||
RUN GOBIN=/usr/local/bin go install golang.org/x/tools/gopls@latest
|
||||
|
||||
USER huskies
|
||||
@@ -0,0 +1,4 @@
|
||||
# Stack detection markers for the go stack.
|
||||
# Each non-blank, non-comment line names a file relative to the project root.
|
||||
# If any listed file exists in the project directory, this stack is matched.
|
||||
go.mod
|
||||
@@ -0,0 +1,50 @@
|
||||
# JVM stack overlay fragment.
|
||||
#
|
||||
# Layer this on top of huskies-project-base to produce a project container
|
||||
# with OpenJDK 21, Maven, and eclipse.jdt.ls (the canonical Java/JVM LSP).
|
||||
#
|
||||
# Build the combined image:
|
||||
# (echo "FROM huskies-project-base"; \
|
||||
# cat docker/stacks/jvm/Dockerfile.fragment) | \
|
||||
# docker build -t huskies-project-jvm -
|
||||
#
|
||||
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||
|
||||
USER root
|
||||
|
||||
# OpenJDK 21 (current LTS) and Maven for build support.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
openjdk-21-jdk-headless \
|
||||
maven \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV JAVA_HOME="/usr/lib/jvm/java-21-openjdk-amd64"
|
||||
|
||||
# Eclipse JDT Language Server — canonical LSP for Java/JVM (Java, Kotlin, Groovy).
|
||||
# Pin to a specific release; update JDTLS_VERSION + JDTLS_BUILD for upgrades.
|
||||
# All releases: https://github.com/eclipse-jdtls/eclipse.jdt.ls/releases
|
||||
ENV JDTLS_VERSION="1.38.0" \
|
||||
JDTLS_BUILD="202503271418"
|
||||
RUN mkdir -p /opt/jdtls \
|
||||
&& curl -fsSL \
|
||||
"https://download.eclipse.org/jdtls/milestones/${JDTLS_VERSION}/jdt-language-server-${JDTLS_VERSION}-${JDTLS_BUILD}.tar.gz" \
|
||||
| tar -xzf - -C /opt/jdtls
|
||||
|
||||
# Wrapper script so `jdtls` is available as a PATH command.
|
||||
RUN { \
|
||||
echo '#!/bin/sh'; \
|
||||
echo 'JAR=$(ls /opt/jdtls/plugins/org.eclipse.equinox.launcher_*.jar 2>/dev/null | head -1)'; \
|
||||
echo 'exec java \'; \
|
||||
echo ' -Declipse.application=org.eclipse.jdt.ls.core.id1 \'; \
|
||||
echo ' -Dosgi.bundles.defaultStartLevel=4 \'; \
|
||||
echo ' -Declipse.product=org.eclipse.jdt.ls.core.product \'; \
|
||||
echo ' -Dlog.protocol=true \'; \
|
||||
echo ' -Dlog.level=ALL \'; \
|
||||
echo ' -jar "$JAR" \'; \
|
||||
echo ' -configuration /opt/jdtls/config_linux \'; \
|
||||
echo ' "$@"'; \
|
||||
} > /usr/local/bin/jdtls \
|
||||
&& chmod +x /usr/local/bin/jdtls
|
||||
|
||||
USER huskies
|
||||
@@ -0,0 +1,6 @@
|
||||
# Stack detection markers for the jvm stack.
|
||||
# Each non-blank, non-comment line names a file relative to the project root.
|
||||
# If any listed file exists in the project directory, this stack is matched.
|
||||
pom.xml
|
||||
build.gradle
|
||||
build.gradle.kts
|
||||
@@ -0,0 +1,26 @@
|
||||
# Node stack overlay fragment.
|
||||
#
|
||||
# Layer this on top of huskies-project-base to produce a project container
|
||||
# with Node.js 22, TypeScript (tsc), and typescript-language-server.
|
||||
#
|
||||
# Build the combined image:
|
||||
# (echo "FROM huskies-project-base"; \
|
||||
# cat docker/stacks/node/Dockerfile.fragment) | \
|
||||
# docker build -t huskies-project-node -
|
||||
#
|
||||
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||
|
||||
USER root
|
||||
|
||||
# Node.js 22.x (LTS).
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# TypeScript compiler and language server for LSP-aware agents.
|
||||
# tsc: TypeScript compiler (tsc --version)
|
||||
# typescript-language-server: LSP server used by editors/agents
|
||||
RUN npm install -g typescript typescript-language-server
|
||||
|
||||
USER huskies
|
||||
@@ -0,0 +1,7 @@
|
||||
# Stack detection markers for the node stack.
|
||||
# Each non-blank, non-comment line names a file relative to the project root.
|
||||
# If any listed file exists in the project directory, this stack is matched.
|
||||
# tsconfig.json is listed explicitly so TypeScript-only projects are detected
|
||||
# even without a package.json at the repo root.
|
||||
package.json
|
||||
tsconfig.json
|
||||
@@ -0,0 +1,27 @@
|
||||
# Python stack overlay fragment.
|
||||
#
|
||||
# Layer this on top of huskies-project-base to produce a project container
|
||||
# with Python 3, pip, and pyright (the Microsoft Python LSP / type checker).
|
||||
#
|
||||
# Build the combined image:
|
||||
# (echo "FROM huskies-project-base"; \
|
||||
# cat docker/stacks/python/Dockerfile.fragment) | \
|
||||
# docker build -t huskies-project-python -
|
||||
#
|
||||
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||
|
||||
USER root
|
||||
|
||||
# Python 3 runtime and pip.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
python3-pip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# pyright: Microsoft's Python language server / static type checker.
|
||||
# --break-system-packages is required on Debian 12+ where pip is externally
|
||||
# managed; the flag is safe inside a Docker container.
|
||||
RUN pip install --no-cache-dir --break-system-packages pyright
|
||||
|
||||
USER huskies
|
||||
@@ -0,0 +1,6 @@
|
||||
# Stack detection markers for the python stack.
|
||||
# Each non-blank, non-comment line names a file relative to the project root.
|
||||
# If any listed file exists in the project directory, this stack is matched.
|
||||
pyproject.toml
|
||||
requirements.txt
|
||||
setup.py
|
||||
@@ -0,0 +1,28 @@
|
||||
# Ruby stack overlay fragment.
|
||||
#
|
||||
# Layer this on top of huskies-project-base to produce a project container
|
||||
# with Ruby, Bundler, and ruby-lsp (the Shopify Ruby language server).
|
||||
#
|
||||
# Build the combined image:
|
||||
# (echo "FROM huskies-project-base"; \
|
||||
# cat docker/stacks/ruby/Dockerfile.fragment) | \
|
||||
# docker build -t huskies-project-ruby -
|
||||
#
|
||||
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||
|
||||
USER root
|
||||
|
||||
# Ruby runtime, development headers (needed by native gem extensions), and Bundler.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ruby \
|
||||
ruby-dev \
|
||||
bundler \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ruby-lsp: Shopify's Ruby language server (LSP-compliant, actively maintained).
|
||||
# Installed globally so the `ruby-lsp` binary is available on PATH.
|
||||
RUN gem install ruby-lsp
|
||||
|
||||
USER huskies
|
||||
@@ -0,0 +1,4 @@
|
||||
# Stack detection markers for the ruby stack.
|
||||
# Each non-blank, non-comment line names a file relative to the project root.
|
||||
# If any listed file exists in the project directory, this stack is matched.
|
||||
Gemfile
|
||||
@@ -0,0 +1,37 @@
|
||||
# Rust stack overlay fragment.
|
||||
#
|
||||
# Layer this on top of huskies-project-base to produce a project container
|
||||
# with a full Rust toolchain, rust-analyzer, and cargo-nextest.
|
||||
#
|
||||
# Build the combined image:
|
||||
# (echo "FROM huskies-project-base"; \
|
||||
# cat docker/stacks/rust/Dockerfile.fragment) | \
|
||||
# docker build -t huskies-project-rust -
|
||||
#
|
||||
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||
|
||||
USER root
|
||||
|
||||
# Build tools required by rustup and many Rust crates.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV RUSTUP_HOME="/home/huskies/.rustup" \
|
||||
CARGO_HOME="/home/huskies/.cargo"
|
||||
|
||||
# Install stable Rust + rust-analyzer component as the huskies user.
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| su huskies -c "sh -s -- -y --no-modify-path --default-toolchain stable" \
|
||||
&& /home/huskies/.cargo/bin/rustup component add rust-analyzer \
|
||||
&& chown -R huskies:huskies /home/huskies/.rustup /home/huskies/.cargo
|
||||
|
||||
# cargo-nextest: fast Rust test runner used by huskies quality gates.
|
||||
RUN curl -LsSf https://get.nexte.st/latest/linux | tar zxf - -C /usr/local/bin
|
||||
|
||||
ENV PATH="/home/huskies/.cargo/bin:${PATH}"
|
||||
|
||||
USER huskies
|
||||
@@ -0,0 +1,4 @@
|
||||
# Stack detection markers for the rust stack.
|
||||
# Each non-blank, non-comment line names a file relative to the project root.
|
||||
# If any listed file exists in the project directory, this stack is matched.
|
||||
Cargo.toml
|
||||
Generated
+798
-1068
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "huskies",
|
||||
"private": true,
|
||||
"version": "0.10.4",
|
||||
"version": "0.12.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -32,11 +32,11 @@
|
||||
"@types/node": "^25.0.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"jsdom": "^28.1.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^5.4.21",
|
||||
"vitest": "^2.1.4"
|
||||
"vite": "^8.0.13",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +160,7 @@ describe("App", () => {
|
||||
});
|
||||
|
||||
it("shows error when openProject fails", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mockedApi.openProject.mockRejectedValue(new Error("Path does not exist"));
|
||||
|
||||
await renderApp();
|
||||
@@ -182,6 +183,7 @@ describe("App", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Path does not exist/)).toBeInTheDocument();
|
||||
});
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("shows known projects list", async () => {
|
||||
|
||||
+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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
+61
-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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -336,6 +266,8 @@ describe("subscribeAgentStream", () => {
|
||||
});
|
||||
|
||||
it("handles malformed JSON without throwing", () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||
|
||||
expect(() => {
|
||||
|
||||
+15
-61
@@ -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({
|
||||
startAgent(storyId: string, agentName?: string) {
|
||||
return rpcCall<AgentInfo>("agents.start", {
|
||||
story_id: storyId,
|
||||
agent_name: agentName,
|
||||
}),
|
||||
},
|
||||
baseUrl,
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
stopAgent(storyId: string, agentName: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/agents/stop",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
stopAgent(storyId: string, agentName: string) {
|
||||
return rpcCall<boolean>("agents.stop", {
|
||||
story_id: storyId,
|
||||
agent_name: agentName,
|
||||
}),
|
||||
},
|
||||
baseUrl,
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
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";
|
||||
|
||||
@@ -50,16 +50,47 @@ export interface AgentAssignment {
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** Display column for a work item — derived server-side from `Stage::pipeline()` (story 1085). */
|
||||
export type Pipeline =
|
||||
| "backlog"
|
||||
| "coding"
|
||||
| "qa"
|
||||
| "merge"
|
||||
| "done"
|
||||
| "closed"
|
||||
| "archived";
|
||||
|
||||
/** Badge/indicator for a work item — derived server-side from `Stage::status()` (story 1085). */
|
||||
export type Status =
|
||||
| "active"
|
||||
| "frozen"
|
||||
| "review-hold"
|
||||
| "blocked"
|
||||
| "merge-failure"
|
||||
| "merge-failure-final"
|
||||
| "abandoned"
|
||||
| "superseded"
|
||||
| "rejected"
|
||||
| "done";
|
||||
|
||||
/** 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;
|
||||
/** Display column (story 1085); falls back to the bucket name on legacy servers. */
|
||||
pipeline?: Pipeline;
|
||||
/** Display badge (story 1085); falls back to derived `blocked`/`frozen` on legacy servers. */
|
||||
status?: Status;
|
||||
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 +169,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,8 +239,10 @@ export interface AnthropicModelInfo {
|
||||
export interface WorkItemContent {
|
||||
content: string;
|
||||
stage: string;
|
||||
name: string | null;
|
||||
name: string;
|
||||
agent: string | null;
|
||||
/** Origin JSON string (story 1088), or null for pre-origin items. */
|
||||
origin: string | null;
|
||||
}
|
||||
|
||||
/** Result for a single test case from the server's test runner. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -24,10 +24,38 @@ export interface GatewayInfo {
|
||||
projects: GatewayProject[];
|
||||
}
|
||||
|
||||
/** Display column for a work item — derived server-side from `Stage::pipeline()` (story 1085). */
|
||||
export type Pipeline =
|
||||
| "backlog"
|
||||
| "coding"
|
||||
| "qa"
|
||||
| "merge"
|
||||
| "done"
|
||||
| "closed"
|
||||
| "archived";
|
||||
|
||||
/** Badge/indicator for a work item — derived server-side from `Stage::status()` (story 1085). */
|
||||
export type Status =
|
||||
| "active"
|
||||
| "frozen"
|
||||
| "review-hold"
|
||||
| "blocked"
|
||||
| "merge-failure"
|
||||
| "merge-failure-final"
|
||||
| "abandoned"
|
||||
| "superseded"
|
||||
| "rejected"
|
||||
| "done";
|
||||
|
||||
export interface PipelineItem {
|
||||
story_id: string;
|
||||
name: string;
|
||||
/** Legacy stage string (kept for back-compat); prefer `pipeline` + `status`. */
|
||||
stage: string;
|
||||
/** Display column (story 1085). Optional until all servers are upgraded. */
|
||||
pipeline?: Pipeline;
|
||||
/** Display badge (story 1085). Optional until all servers are upgraded. */
|
||||
status?: Status;
|
||||
agent?: { agent_name: string; model: string; status: string } | null;
|
||||
blocked?: boolean;
|
||||
retry_count?: number;
|
||||
@@ -38,6 +66,7 @@ export interface ProjectPipelineStatus {
|
||||
active: PipelineItem[];
|
||||
backlog: { story_id: string; name: string }[];
|
||||
backlog_count: number;
|
||||
archived?: PipelineItem[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -54,6 +83,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 +208,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.
|
||||
|
||||
+159
-28
@@ -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;
|
||||
try {
|
||||
ws.close();
|
||||
reject(new Error(`RPC timeout for ${method}`));
|
||||
} 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
|
||||
) {
|
||||
data = JSON.parse(event.data);
|
||||
} catch {
|
||||
// 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();
|
||||
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.)
|
||||
} catch {
|
||||
// Ignore non-JSON or unparseable messages
|
||||
/* 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,20 @@ 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", () => {
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
setupMocks();
|
||||
consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("AC1: WebSocket error message is shown in chat as an assistant message", async () => {
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
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 +494,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 +509,10 @@ describe("Bug 450: WebSocket error messages displayed in chat", () => {
|
||||
);
|
||||
});
|
||||
|
||||
const link = await screen.findByRole("link", {
|
||||
expect(
|
||||
screen.queryByRole("link", {
|
||||
name: /https:\/\/example\.com\/oauth\/login/,
|
||||
});
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", "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,203 @@
|
||||
/** 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();
|
||||
});
|
||||
|
||||
// Story 1085: failure kind no longer derived from substring. Items in
|
||||
// the merge_failure / merge_failure_final status get a generic FAILED badge;
|
||||
// the kind detail is exposed via the typed `status` field for callers that
|
||||
// need it (instead of being squeezed into the badge text).
|
||||
it("shows ✕ FAILED badge for merge-failure status", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "73_story_conflict",
|
||||
name: "Conflict Story",
|
||||
stage: "merge",
|
||||
pipeline: "merge",
|
||||
status: "merge-failure",
|
||||
merge_failure: "Merge conflict: conflicts detected",
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("✕ FAILED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows ⛔ FAILED (FINAL) badge for merge-failure-final status", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "74_story_gates",
|
||||
name: "Gates Failed Story",
|
||||
stage: "merge",
|
||||
pipeline: "merge",
|
||||
status: "merge-failure-final",
|
||||
merge_failure: "Quality gates failed: cargo test failed",
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("⛔ FAILED (FINAL)")).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();
|
||||
});
|
||||
|
||||
// Story 1085 AC 4 — Frozen items remain visible in their underlying column
|
||||
// with a frozen indicator. The server hands us `pipeline: "coding"` for a
|
||||
// frozen-while-coding story and the badge is decorated separately.
|
||||
it("shows ❄ FROZEN badge for a frozen item (column stays as underlying pipeline)", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "70_story_frozen_coding",
|
||||
name: "Paused Coding Story",
|
||||
stage: "current",
|
||||
pipeline: "coding",
|
||||
status: "frozen",
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("❄ FROZEN")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Story 1085 AC 4 (subsumes 1052) — Done items must never get a
|
||||
// MergeFailure indicator, even if a stale `merge_failure` string is present.
|
||||
it("done items render Done badge, never MergeFailure", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "71_story_done",
|
||||
name: "Completed Story",
|
||||
stage: "done",
|
||||
pipeline: "done",
|
||||
status: "done",
|
||||
merge_failure: "ignored stale string",
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("Done")).toBeInTheDocument();
|
||||
expect(screen.queryByText("✕ FAILED")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/FAILED/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -14,9 +14,42 @@ import {
|
||||
type JoinedAgent,
|
||||
type GatewayProject,
|
||||
type AllProjectsPipeline,
|
||||
type Pipeline,
|
||||
type PipelineItem,
|
||||
type Status,
|
||||
} from "../api/gateway";
|
||||
|
||||
/// Resolve an item's pipeline column. Servers running the new (story 1085)
|
||||
/// backend send `pipeline`; older servers only send `stage` so we fall back to
|
||||
/// mapping the bucket name onto the new column vocabulary.
|
||||
function itemPipeline(item: PipelineItem): Pipeline {
|
||||
if (item.pipeline) return item.pipeline;
|
||||
switch (item.stage) {
|
||||
case "current":
|
||||
return "coding";
|
||||
case "qa":
|
||||
return "qa";
|
||||
case "merge":
|
||||
return "merge";
|
||||
case "done":
|
||||
return "done";
|
||||
case "archived":
|
||||
return "archived";
|
||||
default:
|
||||
return "backlog";
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an item's badge. Falls back to `merge_failure`/`blocked` on
|
||||
/// legacy servers that don't yet emit `status`.
|
||||
function itemStatus(item: PipelineItem): Status {
|
||||
if (item.status) return item.status;
|
||||
if (item.merge_failure) return "merge-failure";
|
||||
if (item.blocked) return "blocked";
|
||||
if (item.stage === "done") return "done";
|
||||
return "active";
|
||||
}
|
||||
|
||||
const { useCallback, useEffect, useRef, useState } = React;
|
||||
|
||||
/// Seconds of silence before an agent is considered disconnected.
|
||||
@@ -48,24 +81,101 @@ const STATUS_LABELS: Record<AgentStatus, string> = {
|
||||
disconnected: "Disconnected",
|
||||
};
|
||||
|
||||
const STAGE_COLORS: Record<string, string> = {
|
||||
current: "#3fb950",
|
||||
const PIPELINE_COLORS: Record<Pipeline, string> = {
|
||||
backlog: "#8b949e",
|
||||
coding: "#3fb950",
|
||||
qa: "#d2a679",
|
||||
merge: "#79c0ff",
|
||||
done: "#6e7681",
|
||||
closed: "#6e7681",
|
||||
archived: "#6e7681",
|
||||
};
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
current: "In Progress",
|
||||
const PIPELINE_LABELS: Record<Pipeline, string> = {
|
||||
backlog: "Backlog",
|
||||
coding: "In Progress",
|
||||
qa: "QA",
|
||||
merge: "Merging",
|
||||
done: "Done",
|
||||
closed: "Closed",
|
||||
archived: "Archived",
|
||||
};
|
||||
|
||||
/// 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 status badge. */
|
||||
export function StoryRow({ item, mergeQueuePos }: { item: PipelineItem; mergeQueuePos?: number }) {
|
||||
const pipeline = itemPipeline(item);
|
||||
const status = itemStatus(item);
|
||||
const agentStatus = item.agent?.status;
|
||||
|
||||
let color: string;
|
||||
let label: string;
|
||||
let frozenPrefix = "";
|
||||
|
||||
// Frozen items keep their underlying pipeline column but get a ❄️ badge.
|
||||
// (AC 4 — story 1085, subsumes the freeze-hides-item bug.)
|
||||
if (status === "frozen") {
|
||||
color = "#79c0ff";
|
||||
label = "❄ FROZEN";
|
||||
frozenPrefix = "❄ ";
|
||||
} else if (status === "merge-failure" || status === "merge-failure-final") {
|
||||
// Done items never reach this branch — `Stage::status()` returns
|
||||
// `Status::Done` for done items (AC 4).
|
||||
if (agentStatus === "running") {
|
||||
color = "#e3b341";
|
||||
label = "⟳ RECOVERING";
|
||||
} else if (agentStatus === "pending") {
|
||||
color = "#e3b341";
|
||||
label = "⏳ QUEUED";
|
||||
} else {
|
||||
color = "#f85149";
|
||||
label = status === "merge-failure-final" ? "⛔ FAILED (FINAL)" : "✕ FAILED";
|
||||
}
|
||||
} else if (status === "blocked") {
|
||||
if (agentStatus === "running") {
|
||||
color = "#e3b341";
|
||||
label = "⟳ RECOVERING";
|
||||
} else if (agentStatus === "pending") {
|
||||
color = "#e3b341";
|
||||
label = "⏳ QUEUED";
|
||||
} else {
|
||||
color = "#f85149";
|
||||
label = "⊘ BLOCKED";
|
||||
}
|
||||
} else if (status === "review-hold") {
|
||||
color = "#d2a679";
|
||||
label = "REVIEW HOLD";
|
||||
} else if (status === "abandoned") {
|
||||
color = "#6e7681";
|
||||
label = "ABANDONED";
|
||||
} else if (status === "superseded") {
|
||||
color = "#6e7681";
|
||||
label = "SUPERSEDED";
|
||||
} else if (status === "rejected") {
|
||||
color = "#f85149";
|
||||
label = "REJECTED";
|
||||
} else if (pipeline === "merge" && agentStatus === "running") {
|
||||
color = "#58a6ff";
|
||||
label = "▶ MERGING";
|
||||
} else if (pipeline === "merge" && agentStatus === "pending") {
|
||||
color = "#e3b341";
|
||||
label = "⏳ QUEUED";
|
||||
} else if (pipeline === "merge") {
|
||||
color = "#6e7681";
|
||||
if (mergeQueuePos === 1) {
|
||||
label = "NEXT IN QUEUE";
|
||||
} else if (mergeQueuePos != null) {
|
||||
label = `awaiting-slot (#${mergeQueuePos})`;
|
||||
} else {
|
||||
label = "awaiting-slot";
|
||||
}
|
||||
} else {
|
||||
color = PIPELINE_COLORS[pipeline] ?? "#8b949e";
|
||||
label = PIPELINE_LABELS[pipeline] ?? pipeline;
|
||||
}
|
||||
|
||||
const isMergeActive = pipeline === "merge" && status === "active" && agentStatus === "running";
|
||||
const idNum = item.story_id.match(/^(\d+)/)?.[1];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -75,6 +185,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 +205,13 @@ function StoryRow({ item }: { item: PipelineItem }) {
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ color: "#e6edf3", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{item.name}
|
||||
{idNum && <span style={{ color: "#8b949e", fontFamily: "monospace" }}>#{idNum}{" "}</span>}
|
||||
{frozenPrefix}{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 +403,293 @@ 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",
|
||||
pipeline: "backlog" as Pipeline,
|
||||
status: "active" as Status,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (tab === "in-progress") {
|
||||
return {
|
||||
project,
|
||||
items: (status.active ?? []).filter(
|
||||
(i) => itemPipeline(i) !== "done",
|
||||
),
|
||||
};
|
||||
}
|
||||
if (tab === "done") {
|
||||
return {
|
||||
project,
|
||||
items: (status.active ?? []).filter((i) => itemPipeline(i) === "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) => itemPipeline(i) !== "done").length
|
||||
);
|
||||
}
|
||||
if (tab === "done") {
|
||||
return (
|
||||
sum + (status.active ?? []).filter((i) => itemPipeline(i) === "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_PIPELINE_LABELS: Record<"coding" | "qa" | "merge", string> = {
|
||||
coding: "Coding",
|
||||
qa: "QA",
|
||||
merge: "Merging",
|
||||
};
|
||||
|
||||
/// In Progress tab content — items grouped by their `pipeline` column.
|
||||
///
|
||||
/// Frozen items appear in the column corresponding to their underlying
|
||||
/// `Stage::resume_to` (server-side), so they always show up in-place.
|
||||
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 byPipeline = {
|
||||
coding: allItems.filter((x) => itemPipeline(x.item) === "coding"),
|
||||
qa: allItems.filter((x) => itemPipeline(x.item) === "qa"),
|
||||
merge: allItems.filter((x) => itemPipeline(x.item) === "merge"),
|
||||
};
|
||||
|
||||
const pipelines = (["coding", "qa", "merge"] as const).filter(
|
||||
(p) => byPipeline[p].length > 0,
|
||||
);
|
||||
|
||||
// Compute queue position among "clean" awaiting-merge items: pipeline=merge,
|
||||
// status=active, and no agent currently running.
|
||||
const mergeQueuePosMap = new Map<string, number>();
|
||||
let queuePos = 0;
|
||||
for (const { project, item } of byPipeline.merge) {
|
||||
if (itemStatus(item) === "active" && 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>
|
||||
{pipelines.map((p) => (
|
||||
<div key={p} style={{ marginBottom: "20px" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.8em",
|
||||
fontWeight: 600,
|
||||
color: PIPELINE_COLORS[p] ?? "#8b949e",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
marginBottom: "8px",
|
||||
paddingBottom: "4px",
|
||||
borderBottom: `1px solid ${PIPELINE_COLORS[p] ?? "#8b949e"}33`,
|
||||
}}
|
||||
>
|
||||
{IN_PROGRESS_PIPELINE_LABELS[p]}{" "}
|
||||
<span style={{ color: "#6e7681" }}>
|
||||
({byPipeline[p].length})
|
||||
</span>
|
||||
</div>
|
||||
{byPipeline[p].map(({ project, item }) => (
|
||||
<ProjectStoryRow
|
||||
key={`${project}:${item.story_id}`}
|
||||
project={project}
|
||||
item={item}
|
||||
showProject={multiProject}
|
||||
mergeQueuePos={
|
||||
p === "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 +698,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 +774,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 +798,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,10 +426,44 @@ export function StagePanel({
|
||||
<>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: "0.9em" }}>
|
||||
{hasMergeFailure && (
|
||||
{hasMergeFailure &&
|
||||
(() => {
|
||||
const agentStatus = item.agent?.status;
|
||||
if (agentStatus === "running") {
|
||||
return (
|
||||
<span
|
||||
data-testid={`merge-failure-icon-${item.story_id}`}
|
||||
title="Merge failed"
|
||||
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",
|
||||
@@ -357,6 +472,19 @@ export function StagePanel({
|
||||
>
|
||||
✕
|
||||
</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
|
||||
@@ -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.`,
|
||||
|
||||
@@ -43,6 +43,7 @@ const DEFAULT_CONTENT = {
|
||||
stage: "current",
|
||||
name: "Big Title Story",
|
||||
agent: null,
|
||||
origin: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -43,6 +43,7 @@ const DEFAULT_CONTENT = {
|
||||
stage: "current",
|
||||
name: "Big Title Story",
|
||||
agent: null,
|
||||
origin: null,
|
||||
};
|
||||
|
||||
const sampleTestResults: TestResultsResponse = {
|
||||
|
||||
@@ -42,6 +42,7 @@ const DEFAULT_CONTENT = {
|
||||
stage: "current",
|
||||
name: "Big Title Story",
|
||||
agent: null,
|
||||
origin: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -127,6 +128,7 @@ describe("WorkItemDetailPanel", () => {
|
||||
stage: "current",
|
||||
name: "My Story Name",
|
||||
agent: null,
|
||||
origin: null,
|
||||
});
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
@@ -146,6 +148,7 @@ describe("WorkItemDetailPanel", () => {
|
||||
stage: "current",
|
||||
name: "My Story Name",
|
||||
agent: null,
|
||||
origin: null,
|
||||
});
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
@@ -164,6 +167,7 @@ describe("WorkItemDetailPanel", () => {
|
||||
stage: "current",
|
||||
name: "My Story Name",
|
||||
agent: null,
|
||||
origin: null,
|
||||
});
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
@@ -186,6 +190,7 @@ describe("WorkItemDetailPanel", () => {
|
||||
stage: "current",
|
||||
name: "My Story Name",
|
||||
agent: null,
|
||||
origin: null,
|
||||
});
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
|
||||
@@ -20,6 +20,26 @@ import { stripDisplayContent } from "./workItemDetailPanelUtils";
|
||||
|
||||
const { useCallback, useEffect, useRef, useState } = React;
|
||||
|
||||
/** Parse and format an origin JSON string for display. */
|
||||
function formatOrigin(origin: string | null): string {
|
||||
if (!origin) return "unknown";
|
||||
try {
|
||||
const obj = JSON.parse(origin) as {
|
||||
kind?: string;
|
||||
id?: string;
|
||||
ts?: number;
|
||||
};
|
||||
const kind = obj.kind ?? "unknown";
|
||||
const id = obj.id ? ` (${obj.id})` : "";
|
||||
const ts = obj.ts
|
||||
? ` at ${new Date(obj.ts * 1000).toISOString().replace("T", " ").slice(0, 19)}Z`
|
||||
: "";
|
||||
return `${kind}${id}${ts}`;
|
||||
} catch {
|
||||
return origin;
|
||||
}
|
||||
}
|
||||
|
||||
interface WorkItemDetailPanelProps {
|
||||
storyId: string;
|
||||
pipelineVersion: number;
|
||||
@@ -38,6 +58,7 @@ export function WorkItemDetailPanel({
|
||||
const [stage, setStage] = useState<string>("");
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [assignedAgent, setAssignedAgent] = useState<string | null>(null);
|
||||
const [origin, setOrigin] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [agentInfo, setAgentInfo] = useState<AgentInfo | null>(null);
|
||||
@@ -63,6 +84,7 @@ export function WorkItemDetailPanel({
|
||||
setStage(data.stage);
|
||||
setName(data.name);
|
||||
setAssignedAgent(data.agent);
|
||||
setOrigin(data.origin);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to load content");
|
||||
@@ -258,7 +280,7 @@ export function WorkItemDetailPanel({
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && content !== null && (
|
||||
{!loading && !error && content != null && (
|
||||
<div
|
||||
data-testid="detail-panel-content"
|
||||
className="markdown-body"
|
||||
@@ -289,6 +311,19 @@ export function WorkItemDetailPanel({
|
||||
|
||||
<TestResultsSection testResults={testResults} />
|
||||
|
||||
{!loading && (
|
||||
<div
|
||||
data-testid="detail-panel-origin"
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
color: "#555",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
origin: {formatOrigin(origin)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -227,6 +227,7 @@ describe("usePathCompletion hook", () => {
|
||||
});
|
||||
|
||||
it("sets completionError when listDirectoryAbsolute throws an Error", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mockListDir.mockRejectedValue(new Error("Permission denied"));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -242,9 +243,13 @@ describe("usePathCompletion hook", () => {
|
||||
await waitFor(() => {
|
||||
expect(result.current.completionError).toBe("Permission denied");
|
||||
});
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(new Error("Permission denied"));
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("sets generic completionError when listDirectoryAbsolute throws a non-Error", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mockListDir.mockRejectedValue("some string error");
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -262,6 +267,9 @@ describe("usePathCompletion hook", () => {
|
||||
"Failed to compute suggestion.",
|
||||
);
|
||||
});
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith("some string error");
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("clears suggestionTail when selected match path does not start with input", async () => {
|
||||
|
||||
@@ -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>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build all project images in dependency order:
|
||||
# huskies → huskies-project-base → huskies-project-<stack> (one per stack fragment)
|
||||
#
|
||||
# Run this after `script/docker_rebuild` or whenever you add a new stack.
|
||||
# Safe to re-run: each step re-tags the image with the latest layers.
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if [[ -f .env ]]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
CACHE_FLAG=""
|
||||
if [[ "${1:-}" == "--no-cache" ]]; then
|
||||
CACHE_FLAG="--no-cache"
|
||||
fi
|
||||
|
||||
echo "==> Building huskies"
|
||||
docker build $CACHE_FLAG -t huskies -f docker/Dockerfile .
|
||||
|
||||
echo "==> Building huskies-project-base"
|
||||
docker build $CACHE_FLAG -t huskies-project-base -f docker/Dockerfile.base .
|
||||
|
||||
for fragment in docker/stacks/*/Dockerfile.fragment; do
|
||||
stack=$(basename "$(dirname "$fragment")")
|
||||
image="huskies-project-${stack}"
|
||||
echo "==> Building ${image}"
|
||||
(printf 'FROM huskies-project-base\n'; cat "$fragment") \
|
||||
| docker build $CACHE_FLAG -t "$image" -
|
||||
done
|
||||
|
||||
echo "All project images built."
|
||||
+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
|
||||
|
||||
@@ -24,4 +24,6 @@ docker compose -f docker/docker-compose.yml down
|
||||
docker compose -f docker/docker-compose.yml build $CACHE_FLAG
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
|
||||
script/build-project-images $CACHE_FLAG
|
||||
|
||||
echo "Rebuild complete. Logs: docker compose -f docker/docker-compose.yml logs -f"
|
||||
|
||||
+30
-6
@@ -124,19 +124,43 @@ else
|
||||
fi
|
||||
|
||||
# Categorise merged work items and format names.
|
||||
# Supports two subject formats (after stripping the "huskies: merge " prefix):
|
||||
# New: "1063 story Human Readable Name"
|
||||
# Old: "1063_story_human_readable_name"
|
||||
FEATURES=""
|
||||
FIXES=""
|
||||
REFACTORS=""
|
||||
while IFS= read -r item; do
|
||||
[ -z "$item" ] && continue
|
||||
# Strip the numeric prefix and type to get the human name.
|
||||
name=$(echo "$item" | sed -E 's/^[0-9]+_(story|bug|refactor|spike)_//' | tr '_' ' ')
|
||||
|
||||
# Extract the leading numeric ID (present in both formats).
|
||||
id=$(echo "$item" | grep -oE '^[0-9]+')
|
||||
|
||||
# Detect format and extract human name + type word.
|
||||
if echo "$item" | grep -qE '^[0-9]+ (story|bug|refactor|spike|epic) '; then
|
||||
# New format: "1063 story Human Name Here"
|
||||
type_word=$(echo "$item" | sed -E 's/^[0-9]+ ([a-z]+) .*/\1/')
|
||||
name=$(echo "$item" | sed -E 's/^[0-9]+ [a-z]+ //')
|
||||
else
|
||||
# Legacy slug format: "1063_story_human_name_here"
|
||||
type_word=$(echo "$item" | sed -E 's/^[0-9]+_([a-z]+)_.*/\1/')
|
||||
name=$(echo "$item" | sed -E 's/^[0-9]+_(story|bug|refactor|spike|epic)_//' | tr '_' ' ')
|
||||
fi
|
||||
|
||||
# Capitalise first letter.
|
||||
name="$(echo "${name:0:1}" | tr '[:lower:]' '[:upper:]')${name:1}"
|
||||
case "$item" in
|
||||
*_bug_*) FIXES="${FIXES}- ${name}\n" ;;
|
||||
*_refactor_*) REFACTORS="${REFACTORS}- ${name}\n" ;;
|
||||
*) FEATURES="${FEATURES}- ${name}\n" ;;
|
||||
|
||||
# Format as "Name (ID)" when a numeric ID was found, plain name otherwise.
|
||||
if [ -n "$id" ]; then
|
||||
entry="${name} (${id})"
|
||||
else
|
||||
entry="${name}"
|
||||
fi
|
||||
|
||||
case "$type_word" in
|
||||
bug) FIXES="${FIXES}- ${entry}\n" ;;
|
||||
refactor) REFACTORS="${REFACTORS}- ${entry}\n" ;;
|
||||
*) FEATURES="${FEATURES}- ${entry}\n" ;;
|
||||
esac
|
||||
done <<< "$MERGED_RAW"
|
||||
|
||||
|
||||
+33
-11
@@ -11,14 +11,18 @@ 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. The frontend build
|
||||
# must run *before* anything that compiles Rust, because story 1113 introduced
|
||||
# a compile-time dependency on `frontend/dist/` via `rust-embed` — a fresh
|
||||
# merge worktree without that directory will fail `cargo clippy` on
|
||||
# `EmbeddedAssets::iter()` before the frontend build has a chance to populate
|
||||
# it. `set -euo pipefail` aborts at the first failure.
|
||||
|
||||
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,11 +46,29 @@ 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
|
||||
echo "=== Building frontend ==="
|
||||
if [ -d "$PROJECT_ROOT/frontend" ]; then
|
||||
cd "$PROJECT_ROOT/frontend"
|
||||
# The merge gate runs in workspaces whose pre-existing `node_modules` was
|
||||
# populated by an earlier `npm install --omit=dev` (or a partial install).
|
||||
# In that state `npm install` reports "up to date, audited N packages"
|
||||
# without actually adding the missing devDependencies, so the subsequent
|
||||
# `tsc && vite build` fails with `sh: 1: tsc: not found`.
|
||||
#
|
||||
# Repair the install when typescript isn't reachable (story 1086 merge gate
|
||||
# regression). We probe the on-disk binary rather than relying on PATH so
|
||||
# this also covers the case where `node_modules/.bin/` is missing.
|
||||
if [ ! -x node_modules/typescript/bin/tsc ]; then
|
||||
echo "[script/test] node_modules missing typescript; performing clean install."
|
||||
rm -rf node_modules
|
||||
npm install --include=dev
|
||||
else
|
||||
echo "Skipping Rust formatting check (rustfmt not installed)"
|
||||
npm install --include=dev
|
||||
fi
|
||||
npm run build
|
||||
cd "$PROJECT_ROOT"
|
||||
else
|
||||
echo "Skipping frontend build (no frontend directory)"
|
||||
fi
|
||||
|
||||
echo "=== Running cargo clippy ==="
|
||||
|
||||
+14
-12
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "huskies"
|
||||
version = "0.10.4"
|
||||
version = "0.12.1"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
@@ -10,17 +10,13 @@ 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 }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_urlencoded = { workspace = true }
|
||||
@@ -29,8 +25,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 +34,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 }
|
||||
|
||||
@@ -17,6 +17,20 @@ fn run(cmd: &str, args: &[&str], dir: &Path) {
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-env-changed=PROFILE");
|
||||
|
||||
// Embed the current git commit hash at compile time so `get_version` always
|
||||
// reflects the binary that is actually running, not a potentially-stale file.
|
||||
println!("cargo:rerun-if-changed=../.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=../.git/refs/");
|
||||
let git_hash = std::process::Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
println!("cargo:rustc-env=BUILD_GIT_HASH={git_hash}");
|
||||
println!("cargo:rerun-if-changed=../frontend/package.json");
|
||||
println!("cargo:rerun-if-changed=../frontend/package-lock.json");
|
||||
println!("cargo:rerun-if-changed=../frontend/vite.config.ts");
|
||||
|
||||
@@ -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
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user