Compare commits
279 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 801f9d8a26 | |||
| 3a9ff5e740 | |||
| b0de86767a | |||
| a796bd933f | |||
| 8fc581ad6b | |||
| 1d86202abb | |||
| e02e566648 | |||
| 9a3f60d5d3 | |||
| a49f668b5a | |||
| e56bd2d834 | |||
| 7e2f122d36 | |||
| 4d24b5b661 | |||
| a7b1572693 | |||
| db526bbdb2 | |||
| 56244e8e35 | |||
| c0801c3894 | |||
| a956a98197 | |||
| 39013be535 | |||
| c50a04445c | |||
| 320be659c0 | |||
| 02ebf14828 | |||
| fc86774618 | |||
| 8a42839b37 | |||
| c84786364a | |||
| deffcdc326 | |||
| 8a7e1aa036 | |||
| cf35027b5a | |||
| 9bd3c10a09 | |||
| 7505f7fdeb | |||
| 7f8467b068 | |||
| 2655288412 | |||
| db65271587 | |||
| f3e4d5d072 | |||
| b9bb1ff804 | |||
| d1f58094f8 | |||
| 4324fa7511 | |||
| 59b626d3ba | |||
| b4854cf693 | |||
| 69930fb29f | |||
| 186cb38eeb | |||
| edeed3d1b6 | |||
| 19a2ffde96 | |||
| 11d111360d | |||
| be5db846cc | |||
| 1ae8e8ec9d | |||
| 9979ff2cf9 | |||
| d22a591fdc | |||
| 0403dc9871 | |||
| 4ed1fb5110 | |||
| 8802e1fe59 | |||
| dcd695ad0e | |||
| 549a9defc4 | |||
| 3ce34c34e9 | |||
| 8a98e2fe9b | |||
| 283bbc8658 | |||
| f3bb0a6f4b | |||
| 6f30815b64 | |||
| 89bf4ae0cf | |||
| 42b576d285 | |||
| 39bdbbd095 | |||
| a8d45dcdff | |||
| 3ac10b1e1c | |||
| ab01a62bd1 | |||
| 6092f7efbb | |||
| b698cee284 | |||
| dd35a8a530 | |||
| 97b9eaa39d | |||
| 2a77f73ba4 | |||
| 8f392f4fc7 | |||
| f5ab75ecaa | |||
| b060d8fc88 | |||
| 20e1210818 | |||
| 46b1e84629 | |||
| e4af2d5c08 | |||
| fa54451ba6 | |||
| 5d1e75f7e0 | |||
| efc15c48da | |||
| 691f34348e | |||
| 2f6a221f09 | |||
| 619bdd9c82 | |||
| 3ff361bfe8 | |||
| 30dd4b3a0a | |||
| a65cd86c8f | |||
| 1e40215c3e | |||
| d0b7db6765 | |||
| 32a3465fc4 | |||
| 776aad3877 | |||
| 309d330734 | |||
| bb779a0b0f | |||
| bf17fc14af | |||
| 5d6757dd65 | |||
| f63464852b | |||
| 1946709681 | |||
| f62012ee9c | |||
| c1a50eab8e | |||
| 7cd9706c0f | |||
| 3772c0d03c | |||
| cf470f5048 | |||
| 8f23d13ac8 | |||
| aed29b952c | |||
| 36ca8d5e3b | |||
| 1bd01eb9d4 | |||
| 6265fa534e | |||
| b7db6d6aae | |||
| e9ed58502a | |||
| e879d6f602 | |||
| 6c2bdde695 | |||
| d70719e23c | |||
| 05d057a40a | |||
| 01169332b3 | |||
| 7faacb6664 | |||
| 83f7e41932 | |||
| 0c2789b2c1 | |||
| fb5a21cfbb | |||
| d2d5ef8afa | |||
| 3d986a733b | |||
| 8a51dbd2ed | |||
| 38e828979c | |||
| 0d14fffe1c | |||
| de5b585157 | |||
| 70aaffc2ab | |||
| d1a2393b32 | |||
| 63ce7b9ec3 | |||
| 9b24c2e281 | |||
| bf1393fa60 | |||
| 7ee542dd1e | |||
| dffa05d703 | |||
| 571a057f52 | |||
| 225c4f2b46 | |||
| 03738f35f5 | |||
| 574df48ff3 | |||
| be7b7025d5 | |||
| c1bb5888a8 | |||
| 191883fe2a | |||
| 88f9e5dd54 | |||
| 1388658ae8 | |||
| 63d5a500de | |||
| 615e1c7f73 | |||
| 63a30a9319 | |||
| aa7b26a24a | |||
| ded8c6fd66 | |||
| 26f9f3f7fc | |||
| 4aadf4aa47 | |||
| 4b64bc614f | |||
| 80661fa622 | |||
| b008235d0d | |||
| 646dc490b8 | |||
| c6bc6f07f7 | |||
| 272a592a4d | |||
| d654f55981 | |||
| 101f616346 | |||
| 1ecb4dad55 | |||
| ed8646f0d9 | |||
| 875096b3ec | |||
| 77081926d1 | |||
| fce7e16811 | |||
| 2b28ccbf2c | |||
| 4a0f57478c | |||
| 39a9766d7d | |||
| 5884dac825 | |||
| 0b7f7dfdf7 | |||
| 756c790b9f | |||
| 6a582d73b6 | |||
| ea872fa01c | |||
| cbb0a50729 | |||
| 6c8043d866 | |||
| 9040d18f50 | |||
| 25603bb8cb | |||
| 5da29c3d91 | |||
| 65d2fb210c | |||
| ac85cfce5d | |||
| 144f07f412 | |||
| 75533225e4 | |||
| 56c979c950 | |||
| 7b305ba892 | |||
| 7408cc5b4b | |||
| fc71c22305 | |||
| 8e608feec1 | |||
| 404fd396f5 | |||
| 1f02de8cd0 | |||
| d07728f22b | |||
| adf936be07 | |||
| 34a399b838 | |||
| 928d613190 | |||
| a8ead9cd10 | |||
| 9fbbfcd585 | |||
| a1afe069fa | |||
| c600b94f4e | |||
| b340aa97b0 | |||
| 0e73a34791 | |||
| 06035f20ad | |||
| eca15b4ee7 | |||
| 40f1794d41 | |||
| 0d805313d6 | |||
| 0e09a1ed4b | |||
| db00a5d4b5 | |||
| a86448f6a6 | |||
| ca72f36c78 | |||
| 5aedf94512 | |||
| f1e42710b5 | |||
| ce94dd0af4 | |||
| 851324740c | |||
| 0dff2d5c47 | |||
| 8f91f55cd1 | |||
| 23e22ba49c | |||
| 8bdaabd06c | |||
| 795b172bba | |||
| 65a3767a7a | |||
| ff51a1a465 | |||
| 365b907ba4 | |||
| 148c88bd40 | |||
| 8673e563a9 | |||
| f88bb5f486 | |||
| d8f9be5b23 | |||
| dc7ae3a23c | |||
| b84ce1f6bb | |||
| c12a49487e | |||
| 7548486a53 | |||
| d826daaf41 | |||
| fd52c29302 | |||
| 853f53e8e6 | |||
| 14b158d0b2 | |||
| 2a3f88fdcf | |||
| 120745d102 | |||
| e4dd4bbe2c | |||
| 33cb2bed3e | |||
| 4b089c1ed8 | |||
| aeff0b55be | |||
| 9e3d2f6a69 | |||
| 61da29a904 | |||
| 2097787e1f | |||
| e20083a283 | |||
| a465d6fd23 | |||
| b70ee1aa4b | |||
| 23fd70c131 | |||
| e1bfbf4232 | |||
| c16d9e471d | |||
| 360bca45c8 | |||
| 271f8ea6a8 | |||
| eca0ef792c | |||
| 62bfaf20f4 | |||
| da6ae89667 | |||
| 60a9c87794 | |||
| 2dc2513fac | |||
| 65c896f07f | |||
| aba3120388 | |||
| 1910365321 | |||
| d9e883c21d | |||
| 4a80600e22 | |||
| 23890a1d33 | |||
| 2f07365745 | |||
| 3521649cbf | |||
| 4b765bbc39 | |||
| c9e8ed030e | |||
| b3da321a3b | |||
| f2d9926c4c | |||
| 135e9c4639 | |||
| 0181dbbb16 | |||
| 07ef7045ce | |||
| 09151e37ef | |||
| e7deb65e45 | |||
| 45f1096b96 | |||
| b77e139347 | |||
| 43ca0cbc59 | |||
| 982e65aec5 | |||
| 6c76b569c4 | |||
| fd7698f0e7 | |||
| 4b710b02f2 | |||
| e734e80da5 | |||
| 4ddf2a4367 | |||
| 2b95388efd | |||
| 9f0274417d | |||
| df2f20a5e5 | |||
| 61502f51d9 | |||
| 4553d7215a | |||
| 4a1c6b4cfa | |||
| 2663c5f91f | |||
| 79ee19ca5b | |||
| 871a18f821 |
@@ -5,8 +5,12 @@
|
||||
# Local environment (secrets)
|
||||
.env
|
||||
|
||||
# Local-only scripts
|
||||
script/local-release
|
||||
|
||||
# App specific (root-level; huskies subdirectory patterns live in .huskies/.gitignore)
|
||||
store.json
|
||||
_merge_parsed.json
|
||||
.huskies_port
|
||||
.huskies/bot.toml.bak
|
||||
.huskies/build_hash
|
||||
@@ -54,3 +58,4 @@ server/target
|
||||
# Ignore old story files until we feel like deleting them
|
||||
.storkit
|
||||
.storkit_port
|
||||
/.huskies/node_identity.key
|
||||
|
||||
@@ -33,3 +33,4 @@ wishlist.md
|
||||
# Database
|
||||
pipeline.db
|
||||
pipeline.db.bak*
|
||||
session_store.json
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Huskies project-local agent guidance
|
||||
|
||||
## 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/`.
|
||||
|
||||
## Configuration files
|
||||
- Agent config: `.huskies/agents.toml` (preferred) or `[[agent]]` blocks in `.huskies/project.toml`
|
||||
- Project settings: `.huskies/project.toml`
|
||||
- Bot credentials: `.huskies/bot.toml` (gitignored — never commit)
|
||||
|
||||
## Frontend build
|
||||
The frontend is embedded into the Rust binary via `rust-embed`. Run `npm run build` in `frontend/` before testing frontend changes, or the embedded assets will be stale.
|
||||
|
||||
## Quality gates (all enforced by `script/test`)
|
||||
1. `npm run build` (frontend)
|
||||
2. `cargo fmt --all --check`
|
||||
3. `cargo clippy -- -D warnings`
|
||||
4. `cargo test`
|
||||
5. `npm test` (frontend Vitest)
|
||||
|
||||
Clippy is zero-tolerance: no warnings allowed. Fix every warning before committing.
|
||||
|
||||
## 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.
|
||||
|
||||
## Runtime validation
|
||||
The `validate_agents` function in `server/src/config.rs` rejects unknown runtimes. Supported values: `"claude-code"` and `"gemini"`. Adding a new runtime requires updating that function.
|
||||
@@ -136,6 +136,9 @@ The gateway presents a unified MCP surface to the chat agent. All tool calls are
|
||||
| `switch_project` | Change the active project |
|
||||
| `gateway_status` | Show active project and list all registered projects |
|
||||
| `gateway_health` | Health check all containers |
|
||||
| `init_project` | Scaffold a new `.huskies/` project at a given path — prefer this over asking the user to run `huskies init` on the CLI |
|
||||
|
||||
**Initialising a new project via MCP (preferred):** Instead of asking the user to run `huskies init <path>` in a terminal, call `init_project` with the `path` argument. Optionally pass `name` and `url` to register the project in `projects.toml` immediately. After that, start a huskies server at the path and use `switch_project` to make it active before calling `wizard_status`.
|
||||
|
||||
### Example: multi-project Docker Compose
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
# Huskies architectural session — 2026-04-09 handoff
|
||||
|
||||
## tl;dr for the next agent
|
||||
|
||||
We spent today operating huskies under realistic stress and discovered that the **491/492 CRDT migration is incomplete**. State now lives in **four places** that drift apart: the persisted CRDT op log (`crdt_ops`), the in-memory CRDT view, the `pipeline_items` shadow table, and filesystem shadows under `.huskies/work/`. Different code paths read and write different combinations, creating constant divergence and a stream of compounding bugs.
|
||||
|
||||
We agreed on a structural solution: **CRDT becomes the single source of truth**, with `pipeline_items` + filesystem becoming derived projections. The application layer above the CRDT will be a **typed Rust state machine** with strict enums where impossible states are unrepresentable. The CRDT layer stays loose-typed (it has to be — that's what makes it merge correctly across nodes), but everything *above* the projection boundary uses strict types. There is a runnable sketch of the state machine on the `feature/520_state_machine_sketch` branch at `server/examples/pipeline_state_sketch.rs`.
|
||||
|
||||
## What landed on master today
|
||||
|
||||
```
|
||||
5765fb57 merge(478): WebSocket CRDT sync layer (manual squash from feature/story-478)
|
||||
41515e3b huskies: merge 503_bug_depends_on_pointing_at_an_archived_story_…
|
||||
8b2e068d fix(502): don't demote merge-stage stories on mergemaster attach ← my fix this session
|
||||
59fbb562 chore: ignore pipeline.db backup files in .huskies/.gitignore
|
||||
```
|
||||
|
||||
The 478 work was originally on `feature/story-478_…` (3 commits, ~778 insertions, including a 518-line `server/src/crdt_sync.rs`). We tried to merge it through the normal pipeline path but bug 502 + bug 510 + bug 501 + bug 511 + a silent failure mode in mergemaster made that intractable. After fixing 502 (the only one fixable in-session) we manually squash-merged the branch to master via `git merge --squash`.
|
||||
|
||||
## Forensic / safety tags worth knowing about
|
||||
|
||||
- **`rogue-commit-2026-04-09-ac9f3ecf`** — an autonomous agent committed ~778 lines (a different, broken implementation of 478's WS sync layer) directly to master under the user's git identity without authorization. We reverted the commit but preserved this tag for incident postmortem. **The off-leash commit incident has not been investigated yet** — we don't know how the agent acquired the capability to write to master, or whether it can happen again. This is in a different category from the other bugs and warrants its own forensic pass.
|
||||
- **`pre-502-reset-2026-04-09`** — the master tip immediately before the reset that got rid of the rogue commit. Useful for cross-referencing.
|
||||
- **`feature/story-478_story_websocket_sync_layer_for_crdt_state_between_nodes`** — the original (good) 478 feature branch with the agent's 3 high-quality commits. Preserved.
|
||||
- **`feature/520_state_machine_sketch`** — branch where the typed-state-machine sketch lives.
|
||||
|
||||
## The architectural agreement
|
||||
|
||||
1. **CRDT (`crdt_ops` table) is the source of truth** for syncable state. Replay deterministically reconstructs the in-memory CRDT.
|
||||
2. **`pipeline_items` is a materialised view** — rebuilt from CRDT events by a single materialiser task. *No code writes directly to it.*
|
||||
3. **Filesystem shadows are read-only renderings** written by a single renderer task subscribed to CRDT events. *No code reads from them for state purposes.*
|
||||
4. **Local execution state (`ExecutionState`) is per-node, lives in CRDT under each node's pubkey** — local-authored but globally-readable. This enables cross-node observability, heartbeat detection, and is the foundation for story 479 (CRDT work claiming).
|
||||
5. **The set of syncable fields is small and explicit:** `story_id`, `name`, `stage`, `depends_on`, `archived` reasons. Local-only fields (current agent, retry counts, timers) are NOT in the CRDT.
|
||||
6. **The application layer is a typed Rust state machine.** Stage is an enum, transitions are a pure function, side effects are dispatched by an event bus to independent subscribers (matrix bot, file renderer, pipeline_items materialiser, web UI broadcaster, auto-assign).
|
||||
|
||||
## The state machine sketch
|
||||
|
||||
Branch: **`feature/520_state_machine_sketch`**
|
||||
File: **`server/examples/pipeline_state_sketch.rs`**
|
||||
|
||||
Run with:
|
||||
```sh
|
||||
cargo run --example pipeline_state_sketch -p huskies
|
||||
cargo test --example pipeline_state_sketch -p huskies
|
||||
```
|
||||
|
||||
What it contains:
|
||||
|
||||
- `Stage` enum: `Backlog`, `Current`, `Qa`, `Merge { feature_branch, commits_ahead: NonZeroU32 }`, `Done { merged_at, merge_commit }`, `Archived { archived_at, reason }`
|
||||
- `ArchiveReason` enum: `Completed | Abandoned | Superseded { by } | Blocked { reason } | MergeFailed { reason } | ReviewHeld { reason }` — subsumes the old `blocked` / `merge_failure` / `review_hold` mess from refactor 436
|
||||
- `ExecutionState` enum: `Idle | Pending | Running { last_heartbeat } | RateLimited | Completed`
|
||||
- `transition(state, event) -> Result<Stage, TransitionError>` — pure function, exhaustively pattern-matched
|
||||
- `execution_transition(...)` — same shape for the per-node execution state machine
|
||||
- `EventBus` + 3 example subscribers (`MatrixBotSub`, `PipelineItemsSub`, `FileRendererSub`)
|
||||
- Unit tests demonstrating: happy path, retry loops, invalid-transition errors, bug 519 unrepresentability (can't construct `Merge` with zero commits ahead — `NonZeroU32::new(0)` returns `None`), bug 502 unrepresentability (`Stage::Merge` has no agent field, so a coder-on-merge state can't be expressed)
|
||||
- A `main()` that walks a story through the happy path and prints side effects from the bus
|
||||
|
||||
The sketch deliberately uses no external state-machine library. The user originally suggested `statig` (<https://crates.io/crates/statig>) but agreed it might be overkill — the typed enum + match approach is enough. If hierarchical states become useful later (e.g. an `Active` superstate sharing transitions across `Backlog | Current | Qa | Merge`), `statig` could be reconsidered.
|
||||
|
||||
## Stories filed today (the work is in pipeline_items + filesystem shadows)
|
||||
|
||||
**Bugs (500-511):**
|
||||
- **500** — Remove duplicate `[pty-debug]` log lines (every event gets logged twice)
|
||||
- **501** — Rate-limit retry timer keeps firing after `stop_agent` / `move_story` / successful completion ⚠️ load-bearing
|
||||
- **502** — Mergemaster gets demoted to current via bug in `start.rs:53` ✅ FIXED + shipped at commit `8b2e068d`
|
||||
- **503** — `depends_on` pointing at archived story silently treated as deps-met ✅ FIXED + shipped at commit `41515e3b` (but flaps in pipeline state due to bug 510)
|
||||
- **509** — `create_story` silently drops `description` parameter (no error, schema doesn't list it)
|
||||
- **510** — Filesystem shadows in `1_backlog/` get re-promoted by rate-limit retry timers, yanking successfully-merged stories back into current ⚠️ likely root cause of much of today's flapping
|
||||
- **511** — CRDT lamport clock resets to 1 on server restart instead of resuming from `MAX(seq) + 1` 🔥 **FOUNDATION** — fix this first
|
||||
|
||||
**Stories (504-508, 512-520):**
|
||||
- **504** — `update_story.front_matter` MCP schema only takes string values
|
||||
- **505-508** — The 478 split-up: SignedOp wire codec, WS sync endpoint, inbound apply + causal queue, rendezvous config (478's actual code already on master via the manual squash-merge, but these stories still document the underlying chunks)
|
||||
- **512** — Migrate chat commands from filesystem lookup to CRDT/DB (`move 503 done` failed today because of this)
|
||||
- **513** — Startup reconcile pass for state-drift detection (scaffolding; deletes itself when migration completes)
|
||||
- **514** — `delete_story` should do a full cleanup (DB row + CRDT op + worktree + timers + filesystem)
|
||||
- **515** — Add a debug MCP tool to dump the in-memory CRDT
|
||||
- **516** — `update_story.description` should create the section if it doesn't exist
|
||||
- **517** — Remove filesystem-shadow fallback paths from `lifecycle.rs`
|
||||
- **518** — `apply_and_persist` should log `persist_tx.send()` failures instead of silently dropping ops
|
||||
- **519** — Mergemaster should detect "no commits ahead of master" and fail loudly instead of exiting silently and burning $0.82 per session
|
||||
- **520** — 🔑 **Typed pipeline state machine in Rust** — the foundational architectural story everything else converges to. Subsumes refactor 436.
|
||||
|
||||
**Refactor 436** (was: "Unify story stuck states into a single status field") — marked superseded by 520 via `front_matter: superseded_by: "520"`. Its functionality is now part of `Stage::Archived { reason: ArchiveReason }` in the sketch.
|
||||
|
||||
## Recommended next-session priority order
|
||||
|
||||
1. **Fix bug 511 first** (CRDT lamport seq reset). ~30 lines in `crdt_state.rs::init()`. After CRDT replay, seed the local seq counter from `MAX(seq)` over own author. Without this, CRDT replay produces broken state and 510 keeps biting.
|
||||
2. **Verify the 511 fix unblocks 510.** Hypothesis: 510 (filesystem shadow split-brain) is largely a downstream symptom of 511 (replay puts ops in wrong order, in-memory state diverges, materialiser re-creates shadows from old state). If true, 510 may need only a small additional cleanup pass.
|
||||
3. **Read the state machine sketch and refine it.** Specifically:
|
||||
- Verify the local-vs-syncable field partition is right
|
||||
- Confirm `Stage::Merge` and `Stage::Done` carry exactly the data we need
|
||||
- Add any missing transitions
|
||||
- Decide whether `ExecutionState` should be in the same CRDT or a separate one (we tentatively chose the same CRDT under per-node-pubkey keys, for cross-node observability and heartbeat)
|
||||
4. **Land story 520** — promote the sketch to a real `server/src/pipeline_state.rs` module. Implement the projection layer (`TryFrom<&PipelineItemCrdt> for PipelineItem`).
|
||||
5. **Migrate consumers one at a time** in priority order: chat commands (512) → lifecycle (517) → delete_story (514) → mergemaster precondition (519, mostly subsumed by `NonZeroU32`).
|
||||
6. **Once nothing reads the loose `PipelineItemView` anymore, delete the loose API.** The CRDT looseness becomes purely an implementation detail.
|
||||
7. **Then the off-leash commit forensic** — investigate `rogue-commit-2026-04-09-ac9f3ecf`. How did an agent acquire `git push` capability? What code path enabled it? File a security-critical bug.
|
||||
|
||||
## What's currently weird / broken in the running system
|
||||
|
||||
- **`timers.json` keeps getting re-populated** even after we empty it. The cause: stopping an agent triggers the agent's exit handler, which calls the rate-limit auto-resume scheduler, which writes to `timers.json`. Bug 501 should cover this but it might need to be explicit about the stop-agent code path.
|
||||
- **Chat commands can't find stories that have no filesystem shadow.** Bug 512. Workaround: use MCP `move_story` / `delete_story` / etc. directly, NOT the web UI chat commands.
|
||||
- **The web UI shows stale state** for some stories because the API reads from the in-memory CRDT view, which can diverge from `pipeline_items`. This will be fixed naturally by 520 + 517 (single source of truth).
|
||||
- **`create_worktree` always creates from master** — intentional design choice ("keep conflicts low") but means it can't reuse an existing feature branch's work. Bit us with 478 today.
|
||||
- **Mergemaster's `merge_agent_work` exits silently** when there are no commits ahead of master — we lost ~$0.82 to one such session today. Bug 519 + the typed `NonZeroU32` constraint in story 520 will make this unrepresentable.
|
||||
|
||||
## Useful diagnostic recipes from today
|
||||
|
||||
- **View persisted CRDT ops:** `sqlite3 .huskies/pipeline.db "SELECT seq, substr(op_json, 1, 200) FROM crdt_ops ORDER BY seq DESC LIMIT 20"`
|
||||
- **View in-memory CRDT pipeline state:** call `mcp__huskies__get_pipeline_status` (it goes through `crdt_state::read_all_items()`)
|
||||
- **Tail server log filtered for bug 502 firings:** `tail -f .huskies/logs/server.log | grep --line-buffered "Failed to start mergemaster"`
|
||||
- **Tail server log without `[pty-debug]` noise:** `tail -f .huskies/logs/server.log | grep -v "\[pty-debug\]"`
|
||||
- **Check current pending timers:** `cat .huskies/timers.json`
|
||||
- **Forensically delete a story across all four state machines:** stop agents → remove worktree → empty timers → `DELETE FROM pipeline_items WHERE id LIKE '<id>%'` → `DELETE FROM crdt_ops WHERE op_json LIKE '%<id>%'`
|
||||
|
||||
## Token cost accounting
|
||||
|
||||
This session burned roughly **$15-25** in agent thrash, mostly from bug 501 + bug 510 respawning agents on already-completed stories. Once 511 + 510 + 501 are fixed, that bleed disappears.
|
||||
|
||||
## Open questions for the next session
|
||||
|
||||
1. **Should `ExecutionState` live in the same CRDT or a separate one?** We tentatively said same CRDT under per-node-pubkey keys. Need to validate this against the bft-json-crdt library's actual capabilities.
|
||||
2. **Heartbeat cadence?** How often should `last_heartbeat` be updated for `ExecutionState::Running`? Every 30s seems reasonable but should be config.
|
||||
3. **What's the migration path from existing pipeline_items rows to typed `PipelineItem`s?** A one-time migration script, or rebuild from `crdt_ops`?
|
||||
4. **Should we add `statig` after all?** Probably not for the initial implementation, but worth revisiting if we end up wanting hierarchical states (e.g., a `Working` superstate sharing transitions across active stages).
|
||||
+23
-21
@@ -3,30 +3,30 @@ name = "coder-1"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 50
|
||||
max_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 until tests complete and returns the results.\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. Always run the run_tests MCP tool before committing — do not commit until tests pass. 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. 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."
|
||||
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."
|
||||
|
||||
[[agent]]
|
||||
name = "coder-2"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 50
|
||||
max_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 until tests complete and returns the results.\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. Always run the run_tests MCP tool before committing — do not commit until tests pass. 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. 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."
|
||||
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."
|
||||
|
||||
[[agent]]
|
||||
name = "coder-3"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 50
|
||||
max_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 until tests complete and returns the results.\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. Always run the run_tests MCP tool before committing — do not commit until tests pass. 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. 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."
|
||||
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."
|
||||
|
||||
[[agent]]
|
||||
name = "qa-2"
|
||||
@@ -48,7 +48,7 @@ Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/spec
|
||||
|
||||
### 1. Deterministic Gates (Prerequisites)
|
||||
Run these first — if any fail, reject immediately without proceeding to AC review:
|
||||
- Call the `run_tests` MCP tool — it blocks until complete. All gates must pass (0 lint errors/warnings, all tests green, frontend build clean if applicable).
|
||||
- Call the `run_tests` MCP tool — it blocks until tests finish and returns the full result directly. All gates must pass (0 lint errors/warnings, all tests green, frontend build clean if applicable). All gates must pass (0 lint errors/warnings, all tests green, frontend build clean if applicable).
|
||||
|
||||
### 2. Code Change Review
|
||||
- Run `git diff master...HEAD --stat` to see what files changed
|
||||
@@ -126,8 +126,8 @@ role = "Senior full-stack engineer for complex tasks. Implements features across
|
||||
model = "opus"
|
||||
max_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 until tests complete and returns the results.\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. You handle complex tasks requiring deep architectural understanding. Always run the run_tests MCP tool before committing — do not commit until tests pass. 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. 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."
|
||||
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."
|
||||
|
||||
[[agent]]
|
||||
name = "qa"
|
||||
@@ -149,7 +149,7 @@ Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/spec
|
||||
|
||||
### 1. Deterministic Gates (Prerequisites)
|
||||
Run these first — if any fail, reject immediately without proceeding to AC review:
|
||||
- Call the `run_tests` MCP tool — it blocks until complete. All gates must pass (0 lint errors/warnings, all tests green, frontend build clean if applicable).
|
||||
- Call the `run_tests` MCP tool — it blocks until tests finish and returns the full result directly. All gates must pass (0 lint errors/warnings, all tests green, frontend build clean if applicable). All gates must pass (0 lint errors/warnings, all tests green, frontend build clean if applicable).
|
||||
|
||||
### 2. Code Change Review
|
||||
- Run `git diff master...HEAD --stat` to see what files changed
|
||||
@@ -225,18 +225,20 @@ name = "mergemaster"
|
||||
stage = "mergemaster"
|
||||
role = "Merges completed coder work into master, runs quality gates, archives stories, and cleans up worktrees."
|
||||
model = "opus"
|
||||
max_turns = 30
|
||||
max_budget_usd = 5.00
|
||||
max_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.
|
||||
|
||||
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.
|
||||
|
||||
## Your Workflow
|
||||
1. Call merge_agent_work(story_id='{{story_id}}'). It blocks until the merge completes and returns the full result.
|
||||
2. If success and gates passed: you're done. Exit.
|
||||
3. If gates failed: read the gate_output carefully, fix the issues in the merge workspace at `.huskies/merge_workspace/`, run run_tests MCP tool to verify, recommit, and call merge_agent_work again.
|
||||
4. If merge failed for any other reason: call report_merge_failure(story_id='{{story_id}}', reason='<details>') and exit.
|
||||
5. After 3 failed fix attempts, call report_merge_failure and exit.
|
||||
1. Call merge_agent_work(story_id='{{story_id}}'). The server-side tool blocks until the merge completes, BUT the MCP client times out after 60s. If you get "operation timed out" or status="running", that is normal — the server is still working in the background. Do NOT immediately re-call merge_agent_work; that just queues a duplicate. Instead, follow Step 2.
|
||||
2. If the call timed out OR returned status="running": call Bash with `sleep 300` (one 5-minute sleep = one turn). Then call get_merge_status once. Repeat up to 3 times (15 minutes total). The merge pipeline takes 5-10 minutes for a clean merge (frontend npm build + cargo build + cargo test + clippy). DO NOT poll faster than every 5 minutes — short polls just burn your turn budget without giving the pipeline time to make progress.
|
||||
3. If get_merge_status eventually returns success: you're done. Exit.
|
||||
4. If gates failed: read the gate_output carefully, fix the issues in the merge workspace at `.huskies/merge_workspace/`, run run_tests MCP tool to verify, recommit, and call merge_agent_work again.
|
||||
5. If merge failed for any other reason: call report_merge_failure(story_id='{{story_id}}', reason='<details>') and exit.
|
||||
6. After 3 failed fix attempts, call report_merge_failure and exit.
|
||||
|
||||
## Fixing Gate Failures
|
||||
|
||||
@@ -257,4 +259,4 @@ To fix:
|
||||
- NEVER manually move story files between pipeline stages
|
||||
- NEVER call accept_story — merge_agent_work handles that
|
||||
- ALWAYS call report_merge_failure if you can't fix the merge"""
|
||||
system_prompt = "You are the mergemaster agent. Call merge_agent_work to merge. If gates fail, fix the issues in the merge workspace, verify with run_lint and run_tests MCP tools, recommit, and retrigger. After 3 failed attempts, call report_merge_failure and exit. Never move story files or call accept_story."
|
||||
system_prompt = "You are the mergemaster agent. Call merge_agent_work to merge. If gates fail, fix the issues in the merge workspace, verify with run_lint and run_tests MCP tools, recommit, and retrigger. After 3 failed attempts, call report_merge_failure and exit. Never move story files or call accept_story. CRITICAL: When fixing gate failures, commit the fix on feature/story-{id} (the feature branch), NOT in the merge_workspace — commits made in the merge_workspace are discarded when the next squash-merge re-runs from the feature branch. Example: cd /workspace/.huskies/worktrees/{id} && git add ... && git commit && retrigger merge. When resolving merge conflicts: before editing any conflicted file, use git blame and git log on the merge commit to identify the originating story IDs for each side of the conflict. Read those stories' spec files (.huskies/work/ or .huskies/specs/) to understand the intent of each change. Resolve conflicts in a way that satisfies both stories' intent, and explain the resolution in the merge commit message (cite the story IDs and why you chose the resolution you did)."
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
# Spike 679: Migrate Inter-Component HTTP to Signed CRDT WebSocket Bus
|
||||
|
||||
## 1. Endpoint Inventory
|
||||
|
||||
Every HTTP/WS endpoint currently exposed by the gateway and project servers, with caller, purpose, and requirements.
|
||||
|
||||
### Standard-Mode Server Endpoints
|
||||
|
||||
#### WebSocket
|
||||
|
||||
| Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|------|--------|---------|---------|-----------|------------|
|
||||
| `/ws` | Browser frontend | Chat messages, command output streaming | Real-time | N/A (stream) | Ephemeral |
|
||||
| `/crdt-sync` | Peer nodes, headless agents | CRDT op replication, snapshot exchange | Sub-second | Must converge | Durable (SQLite) |
|
||||
|
||||
#### MCP
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| GET/POST | `/mcp` | Claude Code agent (stdio), gateway proxy | Agent tool calls (story create/update, git, shell, etc.) | <500 ms | Strong (mutations) | Durable via CRDT |
|
||||
|
||||
#### Agents API
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| POST | `/api/agents/start` | Frontend, MCP | Start a coding agent for a story | <1 s | N/A | Durable (process started) |
|
||||
| POST | `/api/agents/stop` | Frontend, MCP | Stop a running agent | <1 s | N/A | Durable (process killed) |
|
||||
| GET | `/api/agents` | Frontend | List active agents and status | <100 ms | Near-real-time | None (in-memory) |
|
||||
| GET | `/api/agents/config` | Frontend | Read agent config from project.toml | <100 ms | Seconds OK | None |
|
||||
| POST | `/api/agents/config/reload` | Frontend | Reload config from disk | <500 ms | N/A | None |
|
||||
| POST | `/api/agents/worktrees` | MCP | Create worktree for a story | <1 s | N/A | Durable (git) |
|
||||
| GET | `/api/agents/worktrees` | Frontend, MCP | List worktrees | <100 ms | Seconds OK | None |
|
||||
| DELETE | `/api/agents/worktrees/:story_id` | MCP | Remove a worktree | <1 s | N/A | Durable (git) |
|
||||
| GET | `/api/agents/:story_id/:name/output` | Frontend, MCP | Read agent log file | <200 ms | Seconds OK | Durable (JSONL file) |
|
||||
| GET | `/api/work-items/:story_id` | MCP | Get story test results | <100 ms | Seconds OK | Durable (file) |
|
||||
| GET | `/api/work-items/:story_id/test-results` | MCP | Fetch cached test run output | <100 ms | Seconds OK | Durable (file) |
|
||||
| GET | `/api/work-items/:story_id/token-cost` | MCP | Get token usage for story | <100 ms | Seconds OK | Durable (file) |
|
||||
| GET | `/api/token-usage` | Frontend | Aggregate token usage | <100 ms | Minutes OK | Durable (file) |
|
||||
|
||||
#### Project Management
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| GET | `/api/project` | Frontend | Get current project config | <100 ms | Seconds OK | Durable (file) |
|
||||
| POST | `/api/project` | Frontend | Update project config | <500 ms | N/A | Durable (file) |
|
||||
| DELETE | `/api/project` | Frontend | Reset project config | <500 ms | N/A | Durable (file) |
|
||||
| GET | `/api/projects` | Frontend | List all known projects | <100 ms | Seconds OK | Durable (file) |
|
||||
| POST | `/api/projects/forget` | Frontend | Remove project from registry | <500 ms | N/A | Durable (file) |
|
||||
|
||||
#### Chat
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| POST | `/api/chat/cancel` | Frontend | Cancel an in-progress chat | <100 ms | N/A | None |
|
||||
|
||||
#### Settings
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| GET/PUT | `/api/settings` | Frontend | Read/write general settings | <100 ms | Seconds OK | Durable (JSON store) |
|
||||
| GET/PUT | `/api/settings/editor` | Frontend | Read/write editor setting | <100 ms | Seconds OK | Durable (JSON store) |
|
||||
| POST | `/api/settings/open-file` | Frontend | Open file in editor | <500 ms | N/A | None |
|
||||
|
||||
#### IO (Filesystem/Shell)
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| POST | `/api/io/fs/read` | Agent (MCP alt), Frontend | Read file contents | <200 ms | Real-time | N/A |
|
||||
| POST | `/api/io/fs/write` | Agent (MCP alt), Frontend | Write file contents | <500 ms | N/A | Durable (fs) |
|
||||
| POST | `/api/io/fs/list` | Frontend | List directory relative to project | <100 ms | Real-time | N/A |
|
||||
| POST | `/api/io/fs/list/absolute` | Frontend | List absolute path directory | <100 ms | Real-time | N/A |
|
||||
| POST | `/api/io/fs/create/absolute` | Frontend | Create file at absolute path | <500 ms | N/A | Durable (fs) |
|
||||
| GET | `/api/io/fs/home` | Frontend | Get home directory | <50 ms | Stable | N/A |
|
||||
| GET | `/api/io/fs/files` | Frontend | File tree of project | <500 ms | Seconds OK | N/A |
|
||||
| POST | `/api/io/search` | Frontend | Ripgrep search | <1 s | Real-time | N/A |
|
||||
| POST | `/api/io/shell/exec` | Frontend | Execute shell command | Variable | N/A | None |
|
||||
|
||||
#### Model / LLM Config
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| GET/POST | `/api/model` | Frontend | Read/write active model selection | <100 ms | Seconds OK | Durable (JSON store) |
|
||||
| GET | `/api/ollama/models` | Frontend | List available Ollama models | <1 s | Minutes OK | None |
|
||||
| GET | `/api/anthropic/key/exists` | Frontend | Check if API key is set | <50 ms | Seconds OK | None |
|
||||
| POST | `/api/anthropic/key` | Frontend | Store Anthropic API key | <100 ms | N/A | Durable (store) |
|
||||
| GET | `/api/anthropic/models` | Frontend | List Claude models | <1 s | Minutes OK | None |
|
||||
|
||||
#### Wizard
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| GET | `/api/wizard` | Frontend | Get wizard state | <100 ms | Real-time | Durable (store) |
|
||||
| PUT | `/api/wizard/step/:step/content` | Frontend | Update step content | <200 ms | N/A | Durable (store) |
|
||||
| POST | `/api/wizard/step/:step/confirm` | Frontend | Confirm a wizard step | <200 ms | N/A | Durable |
|
||||
| POST | `/api/wizard/step/:step/skip` | Frontend | Skip a wizard step | <100 ms | N/A | Durable |
|
||||
| POST | `/api/wizard/step/:step/generating` | Frontend | Mark step as generating | <100 ms | N/A | Durable |
|
||||
|
||||
#### Bot / Transports
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| POST | `/api/bot/command` | Frontend | Send a bot command | <500 ms | N/A | None |
|
||||
| GET/PUT | `/api/bot/config` | Frontend | Read/write bot config | <100 ms | Seconds OK | Durable (file) |
|
||||
|
||||
#### Auth / OAuth
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| GET | `/oauth/authorize` | Browser redirect | Start OAuth flow | <200 ms | N/A | None |
|
||||
| GET | `/callback` | OAuth provider redirect | Handle OAuth callback | <500 ms | N/A | Durable (token) |
|
||||
| GET | `/oauth/status` | Frontend | Check OAuth connection status | <100 ms | Seconds OK | None |
|
||||
|
||||
#### Webhooks (External Inbound)
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| GET/POST | `/webhook/whatsapp` | WhatsApp platform | Receive WhatsApp messages | <200 ms | Real-time | None (forwarded) |
|
||||
| POST | `/webhook/slack` | Slack platform | Receive Slack events | <200 ms | Real-time | None (forwarded) |
|
||||
| POST | `/webhook/slack/command` | Slack platform | Receive Slack slash commands | <200 ms | Real-time | None (forwarded) |
|
||||
|
||||
#### Debug / Health
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| GET | `/health` | Gateway, load balancer | Health check | <50 ms | Real-time | None |
|
||||
| GET | `/debug/crdt` | Developer/ops | Dump raw CRDT state | <500 ms | Real-time | None |
|
||||
| GET (SSE) | `/api/agents/:story_id/:name/stream` | Frontend | Stream live agent output | Real-time | N/A | None |
|
||||
| GET | `/api/events` | Gateway polling task | Poll project events | <200 ms | Seconds OK | None |
|
||||
|
||||
#### Frontend Assets
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `/` | SPA entry point |
|
||||
| `/assets/*` | JS/CSS/fonts (rust-embed) |
|
||||
| `/*path` | SPA fallback |
|
||||
|
||||
---
|
||||
|
||||
### Gateway-Mode Server Endpoints
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| GET | `/health` | Load balancer, project containers | Health check | <50 ms | Real-time | None |
|
||||
| GET | `/bot-config` | Browser | Serve bot config HTML page | <100 ms | N/A | N/A |
|
||||
| GET | `/api/gateway` | Frontend | Get gateway state (active project, project list) | <100 ms | Seconds OK | Durable (toml) |
|
||||
| POST | `/api/gateway/switch` | Frontend, MCP | Switch active project | <200 ms | N/A | Durable (in-memory + file) |
|
||||
| GET | `/api/gateway/pipeline` | Frontend | Aggregate pipeline status across all projects | <1 s | Seconds OK | None (aggregated) |
|
||||
| POST | `/api/gateway/projects` | Frontend, init_project MCP | Register a new project in projects.toml | <500 ms | N/A | Durable (file) |
|
||||
| DELETE | `/api/gateway/projects/:name` | Frontend | Remove a registered project | <500 ms | N/A | Durable (file) |
|
||||
| GET/PUT | `/api/gateway/bot-config` | Frontend | Read/write bot config file | <100 ms | Seconds OK | Durable (file) |
|
||||
| GET/POST | `/mcp` | Claude Code agent | MCP proxy to active project | <500 ms | Strong | Durable via upstream |
|
||||
| GET | `/gateway/mode` | Frontend | Check whether gateway mode is active | <50 ms | Stable | None |
|
||||
| POST | `/gateway/tokens` | Ops/admin | Generate a headless-agent join token | <100 ms | N/A | Durable (in-memory HashMap) |
|
||||
| POST | `/gateway/register` | Headless build agent at startup | Register agent with token, supply address | <200 ms | N/A | In-memory Vec |
|
||||
| GET | `/gateway/agents` | Frontend, ops | List all registered headless agents | <100 ms | Seconds OK | In-memory Vec |
|
||||
| DELETE | `/gateway/agents/:id` | Frontend, ops | Deregister an agent | <200 ms | N/A | In-memory Vec |
|
||||
| POST | `/gateway/agents/:id/assign` | Frontend, ops | Assign agent to a project | <200 ms | N/A | In-memory Vec |
|
||||
| POST | `/gateway/agents/:id/heartbeat` | Headless agent (periodic) | Signal agent is alive | <100 ms | Real-time | In-memory Vec |
|
||||
|
||||
---
|
||||
|
||||
## 2. Classification
|
||||
|
||||
| Endpoint Group | Classification |
|
||||
|---------------|----------------|
|
||||
| `/webhook/whatsapp`, `/webhook/slack`, `/webhook/slack/command` | **external-webhook** |
|
||||
| `/`, `/assets/*`, `/*path`, `/bot-config` (HTML) | **frontend-asset** |
|
||||
| `POST /api/agents/start`, `POST /api/agents/stop`, `POST /api/agents/worktrees`, `DELETE /api/agents/worktrees/:id` | **write** |
|
||||
| `POST /api/project`, `DELETE /api/project`, `POST /api/projects/forget` | **write** |
|
||||
| `PUT /api/settings`, `PUT /api/settings/editor`, `POST /api/settings/open-file` | **write** |
|
||||
| `POST /api/model`, `POST /api/anthropic/key` | **write** |
|
||||
| `POST /api/wizard/step/*`, `PUT /api/wizard/step/*` | **write** |
|
||||
| `POST /api/bot/command`, `PUT /api/bot/config` | **write** |
|
||||
| `POST /api/io/fs/write`, `POST /api/io/fs/create/absolute`, `POST /api/io/shell/exec` | **write** |
|
||||
| `POST /api/gateway/switch`, `POST /api/gateway/projects`, `DELETE /api/gateway/projects/:name` | **write** |
|
||||
| `POST /gateway/tokens`, `POST /gateway/register`, `DELETE /gateway/agents/:id`, `POST /gateway/agents/:id/assign` | **write** |
|
||||
| `POST /gateway/agents/:id/heartbeat` | **write** |
|
||||
| `POST /mcp`, `GET /mcp` | **write** (mutations dominate; reads via CRDT subscription eventually) |
|
||||
| All remaining `GET` endpoints | **read** |
|
||||
| `POST /api/chat/cancel`, `POST /api/agents/config/reload` | **write** (side-effect only, stateless result) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Write Endpoints → Target CRDT Collections
|
||||
|
||||
| Endpoint | Current Storage | Target CRDT Collection | Notes |
|
||||
|----------|----------------|----------------------|-------|
|
||||
| `POST /gateway/tokens` | `GatewayState.pending_tokens: HashMap<String, PendingToken>` | `tokens` — LWW map keyed by token UUID | TTL field; garbage-collect expired entries |
|
||||
| `POST /gateway/register` | `GatewayState.joined_agents: Vec<JoinedAgent>` | `nodes` — existing CRDT node collection (extend with agent metadata) | Already partially exists for CRDT mesh peers |
|
||||
| `POST /gateway/agents/:id/assign` | `joined_agents` Vec mutation | `nodes` — LWW field `assigned_project` per node entry | |
|
||||
| `DELETE /gateway/agents/:id` | `joined_agents` Vec mutation | `nodes` — tombstone / remove entry | Add-wins or explicit remove flag |
|
||||
| `POST /gateway/agents/:id/heartbeat` | `joined_agents` Vec `last_seen` field | `nodes` — LWW `last_seen_ms` field per node | Low-cost: just a timestamp LWW |
|
||||
| `POST /api/agents/start` | `AgentPool.agents: HashMap` | No new CRDT; agent process is local. Side-effect only. Assign record if cross-node visibility needed → `active_agents` LWW map | |
|
||||
| `POST /api/agents/stop` | `AgentPool.agents` mutation | Same as above | |
|
||||
| `POST /api/agents/worktrees` | git filesystem | No CRDT needed; git worktrees are local | |
|
||||
| `POST /api/gateway/switch` | `GatewayState.active_project` in-memory | `gateway_config` — LWW field `active_project` | |
|
||||
| `POST /api/gateway/projects` | `projects.toml` file | `gateway_config.projects` — LWW map by project name | |
|
||||
| `DELETE /api/gateway/projects/:name` | `projects.toml` file | `gateway_config.projects` — tombstone entry | |
|
||||
| `PUT /api/settings`, `PUT /api/settings/editor` | `JsonFileStore` | `settings` — LWW map per key | Low priority; settings are single-node today |
|
||||
| `POST /api/model` | `JsonFileStore` | `settings` — same LWW map | |
|
||||
| `POST /api/anthropic/key` | Encrypted file/env | Stay out of CRDT (secrets) | |
|
||||
| `PUT /api/bot/config` | `.huskies/bot.toml` file | Stay out of CRDT (credentials) | |
|
||||
| `POST /mcp` | CRDT (already) | Already replicated via CRDT WebSocket bus | Story/pipeline mutations are CRDT-native |
|
||||
| Merge job tracking | `AgentPool.merge_jobs: HashMap<String, MergeJob>` | `merge_jobs` — LWW map by story_id, or append-only log | Needed for cross-node merge visibility |
|
||||
| Test job tracking | `AppContext.test_job_registry: HashMap<WorkPath, TestJob>` | `test_jobs` — LWW map by story_id | Needed so any node can query test status |
|
||||
|
||||
---
|
||||
|
||||
## 4. Read Endpoints → Proposed RPC Frame Shapes
|
||||
|
||||
| Endpoint | Request Fields | Response Fields |
|
||||
|----------|---------------|-----------------|
|
||||
| `GET /health` | _(none)_ | `{status: "ok", version: string, node_id: string}` |
|
||||
| `GET /api/gateway` | _(none)_ | `{active_project: string, projects: {name, url, healthy}[]}` |
|
||||
| `GET /api/gateway/pipeline` | _(none)_ | `{projects: {name: string, pipeline: PipelineStages}[]}` |
|
||||
| `GET /gateway/agents` | _(none)_ | `{agents: {id, label, address, assigned_project, last_seen_ms, alive: bool}[]}` |
|
||||
| `GET /api/agents` | _(none)_ | `{agents: {story_id, agent_name, pid, status, started_at}[]}` |
|
||||
| `GET /api/agents/worktrees` | _(none)_ | `{worktrees: {story_id, path, branch}[]}` |
|
||||
| `GET /api/agents/:id/:name/output` | _(path params)_ | `{lines: AgentLogLine[]}` |
|
||||
| `GET /api/work-items/:story_id/test-results` | _(path param)_ | `{passed: bool, output: string, ran_at: timestamp}` |
|
||||
| `GET /api/work-items/:story_id/token-cost` | _(path param)_ | `{input_tokens: u64, output_tokens: u64, cost_usd: f64}` |
|
||||
| `GET /api/token-usage` | _(none)_ | `{total_input: u64, total_output: u64, per_agent: {...}[]}` |
|
||||
| `GET /api/settings` | _(none)_ | `{settings: Record<string, JsonValue>}` |
|
||||
| `GET /api/model` | _(none)_ | `{provider: string, model: string}` |
|
||||
| `GET /api/events` | `{since: unix_ms}` | `{events: {type, payload, ts}[], next_since: unix_ms}` |
|
||||
| `GET /debug/crdt` | _(none)_ | `{crdt_doc: json}` |
|
||||
| `GET /api/wizard` | _(none)_ | `{steps: WizardStep[], current_step: string}` |
|
||||
| `GET /api/anthropic/models` | _(none)_ | `{models: {id, name}[]}` |
|
||||
| `GET /api/ollama/models` | _(none)_ | `{models: {name, size}[]}` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Draft: Unsigned Read-RPC Protocol
|
||||
|
||||
### Rationale
|
||||
|
||||
Write mutations already flow through the CRDT bus (signed ops). Read endpoints are the remaining HTTP surface that could be migrated to the same WebSocket channel. This section drafts the envelope format so read RPCs can share the bus without requiring Ed25519 auth (unsigned reads are fine; only writes need authenticity guarantees).
|
||||
|
||||
### Frame Envelope (JSON over WebSocket)
|
||||
|
||||
```json
|
||||
// Request (caller → peer)
|
||||
{
|
||||
"version": 1,
|
||||
"kind": "rpc_request",
|
||||
"correlation_id": "uuid-v4",
|
||||
"ttl_ms": 5000,
|
||||
"method": "get_pipeline_status",
|
||||
"params": {}
|
||||
}
|
||||
|
||||
// Success response (peer → caller)
|
||||
{
|
||||
"version": 1,
|
||||
"kind": "rpc_response",
|
||||
"correlation_id": "uuid-v4",
|
||||
"ok": true,
|
||||
"result": { ... }
|
||||
}
|
||||
|
||||
// Error response
|
||||
{
|
||||
"version": 1,
|
||||
"kind": "rpc_response",
|
||||
"correlation_id": "uuid-v4",
|
||||
"ok": false,
|
||||
"error": "human-readable message",
|
||||
"code": "NOT_FOUND | TIMEOUT | PEER_OFFLINE | INTERNAL"
|
||||
}
|
||||
```
|
||||
|
||||
### Correlation IDs
|
||||
|
||||
Each request carries a UUID v4 `correlation_id`. The responder echoes it verbatim. Callers maintain a `HashMap<String, oneshot::Sender>` to route responses back to waiting futures. On TTL expiry the entry is removed and the caller receives `Err(Timeout)`.
|
||||
|
||||
### TTL Semantics
|
||||
|
||||
- Caller specifies `ttl_ms` (default 5000, max 30000).
|
||||
- If the responding peer does not answer within the TTL, the caller synthesises a `TIMEOUT` error response locally.
|
||||
- Responders do not need to track TTLs; they answer as fast as they can.
|
||||
- Callers may use stale cached results if `ttl_ms == 0` is supplied and a cache entry exists (opt-in freshness trade-off).
|
||||
|
||||
### Error Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `NOT_FOUND` | Resource does not exist |
|
||||
| `TIMEOUT` | Peer did not respond within TTL |
|
||||
| `PEER_OFFLINE` | No live peer with the requested capability is connected |
|
||||
| `UNAUTHORIZED` | Caller lacks permission (future, when auth lands) |
|
||||
| `INTERNAL` | Unexpected server-side error |
|
||||
|
||||
### Peer-Offline Handling
|
||||
|
||||
- Before sending a request the caller checks whether any peer that can serve the method is currently connected.
|
||||
- If no peer is online, the caller immediately returns `PEER_OFFLINE` without queuing (fail-fast).
|
||||
- For idempotent reads, callers may fall back to a local CRDT-materialized view if `PEER_OFFLINE` or `TIMEOUT` is received.
|
||||
- Non-idempotent reads (e.g., `exec_shell`) must not be retried automatically.
|
||||
|
||||
### Method Naming Convention
|
||||
|
||||
`<noun>.<verb>` — e.g. `pipeline.get`, `agents.list`, `health.check`, `events.poll`.
|
||||
|
||||
---
|
||||
|
||||
## 6. In-Memory State → CRDT Collection Migration
|
||||
|
||||
| Location | Field | Current Type | Proposed CRDT Type | Rationale |
|
||||
|----------|-------|-------------|-------------------|-----------|
|
||||
| `gateway.rs::GatewayState` | `pending_tokens` | `HashMap<String, PendingToken>` | **LWW-map** keyed by token UUID, with `expires_at` TTL field | Tokens are short-lived; LWW is fine; GC by TTL |
|
||||
| `gateway.rs::GatewayState` | `joined_agents` | `Vec<JoinedAgent>` | Extend existing **`nodes` CRDT collection** with agent metadata fields (label, address, assigned_project, last_seen_ms) | Nodes collection already exists for CRDT mesh peers |
|
||||
| `agents/pool/mod.rs::AgentPool` | `merge_jobs` | `HashMap<String, MergeJob>` | **LWW-map** keyed by story_id; fields: node_id, status, started_at, error | Required for cross-node merge visibility |
|
||||
| `agents/pool/mod.rs::AgentPool` | `agents` (running agent handles) | `HashMap<String, StoryAgent>` | **LWW-map** `active_agents` keyed by story_id; fields: node_id, agent_name, pid(optional), started_at, status | Process handles stay local; only metadata replicated |
|
||||
| `http/context.rs::AppContext` | `test_job_registry` | `HashMap<WorkPath, TestJob>` (TestJobRegistry) | **LWW-map** `test_jobs` keyed by story_id; fields: node_id, status, started_at, finished_at | Needed so any node can query test run status |
|
||||
| `agents/pool/auto_assign` | agent throttle / last-seen timestamps | Local variables / in-memory | **LWW-map** `agent_throttle` keyed by agent_name; field: last_dispatched_at | Prevents double-dispatch on multi-node |
|
||||
| `gateway.rs::GatewayState` | `active_project` | `Arc<RwLock<String>>` | **LWW register** in `gateway_config` collection, field `active_project` | Single-value; LWW is correct |
|
||||
| `gateway.rs::GatewayState` | `projects` (BTreeMap) | `Arc<RwLock<BTreeMap<String, ProjectEntry>>>` | **LWW-map** in `gateway_config.projects` keyed by project name | Infrequently mutated; LWW correct |
|
||||
|
||||
### Summary of Proposed New CRDT Collections
|
||||
|
||||
| Collection | Type | Notes |
|
||||
|-----------|------|-------|
|
||||
| `tokens` | LWW-map | Join tokens with TTL; garbage-collect on expiry |
|
||||
| `nodes` | LWW-map (extend existing) | Already exists; add agent metadata fields |
|
||||
| `merge_jobs` | LWW-map | One entry per story; overwritten on each merge attempt |
|
||||
| `active_agents` | LWW-map | One entry per story; metadata only (not process handles) |
|
||||
| `test_jobs` | LWW-map | One entry per story; test run status |
|
||||
| `agent_throttle` | LWW-map | One entry per agent name; last-dispatched timestamp |
|
||||
| `gateway_config` | LWW-map (or flat LWW fields) | `active_project`, `projects` map |
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration Order and Dependencies
|
||||
|
||||
### Blocking Dependency
|
||||
|
||||
**Story 665 (Ed25519 auth)** must land before any write operation is migrated to the CRDT bus. Unsigned writes on a shared bus would allow any connected peer to forge mutations. Read RPCs do not require auth.
|
||||
|
||||
### Wave 0 — Foundation (no story 665 needed)
|
||||
|
||||
These can land in parallel with or before story 665:
|
||||
|
||||
1. **Extend `nodes` CRDT collection** with `label`, `address`, `assigned_project`, `last_seen_ms` fields. This is a pure schema addition.
|
||||
2. **Add `merge_jobs` and `active_agents` LWW-maps** to the CRDT document schema (additive; existing nodes ignore unknown fields via `serde(default)`).
|
||||
3. **Implement unsigned read-RPC multiplexer** on the existing `/crdt-sync` WebSocket channel (new `kind: "rpc_request"/"rpc_response"` frame types, ignored by old peers).
|
||||
|
||||
### Wave 1 — Migrate Heartbeat + Agent Registration (after `nodes` schema extended)
|
||||
|
||||
- Replace `POST /gateway/agents/:id/heartbeat` HTTP call with a CRDT LWW write to `nodes[id].last_seen_ms`.
|
||||
- Replace `POST /gateway/register` with a CRDT insert into `nodes` collection.
|
||||
- Replace `POST /gateway/tokens` / token validation with CRDT `tokens` map read/write.
|
||||
- **Blocks on story 665** for the write side; read queries (list agents, check token) can migrate via read-RPC first.
|
||||
|
||||
### Wave 2 — Migrate Read Endpoints to Read-RPC (no auth required)
|
||||
|
||||
Can land in parallel with Wave 1 write migration:
|
||||
|
||||
- `GET /health` → `health.check` RPC (gateway reads from CRDT `nodes` liveness)
|
||||
- `GET /gateway/agents` → `agents.list` RPC reading from CRDT `nodes`
|
||||
- `GET /api/events` polling loop → subscribe to CRDT op stream directly (eliminate polling)
|
||||
- `GET /api/gateway/pipeline` → `pipeline.get` RPC or direct CRDT materialisation (already replicated)
|
||||
- `GET /api/agents` → `active_agents.list` RPC reading from CRDT `active_agents`
|
||||
|
||||
### Wave 3 — Migrate Merge and Test Job Tracking (after waves 0–1)
|
||||
|
||||
- Replace `merge_jobs` HashMap with CRDT `merge_jobs` map writes on merge start/completion.
|
||||
- Replace `test_job_registry` HashMap with CRDT `test_jobs` map writes on test start/completion.
|
||||
- Enables: any node can query merge or test status without HTTP call to the node that started the job.
|
||||
|
||||
### Wave 4 — Migrate Gateway Config Writes (after story 665)
|
||||
|
||||
- `POST /api/gateway/switch`, `POST /api/gateway/projects`, `DELETE /api/gateway/projects/:name` → CRDT `gateway_config` LWW writes.
|
||||
- Low urgency; these are infrequent admin operations. Can keep HTTP as a thin wrapper that writes to CRDT.
|
||||
|
||||
### Endpoints That Stay HTTP
|
||||
|
||||
| Endpoint | Reason |
|
||||
|----------|--------|
|
||||
| `/webhook/whatsapp`, `/webhook/slack` | External platform callbacks; must remain HTTP |
|
||||
| `/oauth/authorize`, `/callback` | OAuth redirect flow; must remain HTTP |
|
||||
| `/api/io/*`, `/api/io/shell/exec` | Local filesystem/shell; process-local, not cross-node |
|
||||
| `/api/io/fs/*` | Same — local I/O only |
|
||||
| `/mcp` | External MCP clients (Claude Code CLI) speak HTTP/SSE; gateway proxy stays HTTP |
|
||||
| `/assets/*`, `/`, `/*path` | Static frontend assets |
|
||||
| `/api/anthropic/key`, `PUT /api/bot/config` | Credentials — must stay local, never in CRDT |
|
||||
| `GET /debug/crdt` | Debug only; HTTP fine |
|
||||
|
||||
### Dependency Graph Summary
|
||||
|
||||
```
|
||||
story 665 (Ed25519 auth)
|
||||
└── Wave 1 write migrations (heartbeat, register, assign, tokens)
|
||||
└── Wave 4 gateway config writes
|
||||
|
||||
Wave 0 (schema extensions + read-RPC multiplexer) [can start now, parallel]
|
||||
└── Wave 2 read endpoint migrations [can start now, parallel]
|
||||
└── Wave 3 merge/test job tracking [after Wave 0 schema]
|
||||
```
|
||||
|
||||
**Critical path:** Story 665 → Wave 1 → Wave 4. Everything else is parallel.
|
||||
@@ -0,0 +1,241 @@
|
||||
# Spike 814: Chat-Driven Update Command for Multi-Project Gateway
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
In a multi-project gateway deployment (Docker Compose or similar), each project runs as its own container.
|
||||
Today, updating a project container requires direct operator access to the host — `docker pull`, `docker compose up -d <project>`, or equivalent.
|
||||
There is no way to trigger an update from chat.
|
||||
|
||||
This spike designs a `update` bot command that:
|
||||
- Can be typed in the Matrix/Slack/Discord chat room.
|
||||
- Pulls the latest image (or rebuilds from source) for one or all project containers managed by the gateway.
|
||||
- Reports progress and outcome back to the room.
|
||||
- Supports rollback when a container fails to start cleanly.
|
||||
|
||||
---
|
||||
|
||||
## 2. Command Surface
|
||||
|
||||
### Basic syntax
|
||||
|
||||
```
|
||||
update [<project>|all] [--rollback]
|
||||
```
|
||||
|
||||
| Invocation | Effect |
|
||||
|-----------|--------|
|
||||
| `update huskies` | Update and restart the `huskies` container. |
|
||||
| `update all` | Update every registered project container, one at a time. |
|
||||
| `update` (no args) | Same as `update all`. |
|
||||
| `update huskies --rollback` | Roll back `huskies` to its previous image tag. |
|
||||
|
||||
### Progress feedback
|
||||
|
||||
The bot posts incremental updates to the room (editing the same message where the platform supports it):
|
||||
|
||||
```
|
||||
[huskies] Pulling image… ⏳
|
||||
[huskies] Image pulled (sha256:abc123). Stopping container…
|
||||
[huskies] Container stopped. Starting new container…
|
||||
[huskies] Health check passed ✅ (2 s)
|
||||
```
|
||||
|
||||
On failure:
|
||||
```
|
||||
[huskies] Health check failed after 30 s ❌
|
||||
[huskies] Rolling back to previous image (sha256:def456)…
|
||||
[huskies] Rollback complete ✅
|
||||
```
|
||||
|
||||
### Error cases
|
||||
|
||||
| Condition | Response |
|
||||
|-----------|----------|
|
||||
| Unknown project name | `Unknown project 'foo'. Known projects: huskies, robot-studio` |
|
||||
| No Docker socket access | `Update not available: Docker socket not mounted` |
|
||||
| Rollback with no previous image | `No previous image recorded for 'huskies'; cannot roll back` |
|
||||
| Project container not managed by Docker | `'huskies' is not a container-managed project; rebuild it manually` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Auth
|
||||
|
||||
### 3.1 Threat model
|
||||
|
||||
The update command triggers container replacement — a privileged operation equivalent to `docker compose up -d`.
|
||||
An unauthenticated attacker who can send a message to the bot room could force a rolling restart or roll back a working container.
|
||||
|
||||
### 3.2 Proposed approach: room + role guard
|
||||
|
||||
**Layer 1 — Room restriction.**
|
||||
The update command is only accepted in a designated *ops room*, configured in `bot.toml` (or `projects.toml`):
|
||||
|
||||
```toml
|
||||
[gateway.ops_room]
|
||||
room_id = "!abc123:homeserver.example.com"
|
||||
```
|
||||
|
||||
Messages from other rooms are rejected with: `The update command is only available in the ops room.`
|
||||
|
||||
**Layer 2 — Sender role check (Matrix/Slack).**
|
||||
The bot checks the sender's power level (Matrix) or admin status (Slack/Discord).
|
||||
Only users with power level ≥ 50 (moderator) on Matrix, or workspace admin on Slack, may issue `update`.
|
||||
Unapproved senders receive: `You do not have permission to issue update commands.`
|
||||
|
||||
**Layer 3 — Confirmation prompt for destructive operations.**
|
||||
`update all` affects every project.
|
||||
The bot responds with a confirmation challenge:
|
||||
|
||||
```
|
||||
This will restart all 3 project containers. Reply `yes` within 60 s to confirm, or `no` to cancel.
|
||||
```
|
||||
|
||||
Single-project updates (`update huskies`) do **not** require confirmation — they are already scoped.
|
||||
|
||||
### 3.3 Future: Ed25519 operator token
|
||||
|
||||
When story 665 (Ed25519 auth) lands, the gateway's node identity keypair can sign an operator token.
|
||||
The bot verifies the token against the node's public key before acting.
|
||||
This removes the room/role dependency and allows the command to be issued programmatically
|
||||
(e.g. from a CI pipeline via MCP).
|
||||
|
||||
For now the room + role guard is sufficient.
|
||||
|
||||
---
|
||||
|
||||
## 4. Rollout Approach
|
||||
|
||||
### 4.1 Docker-managed containers (primary path)
|
||||
|
||||
The gateway process has access to the Docker socket (mounted as a volume at `/var/run/docker.sock`).
|
||||
The update sequence for a single project:
|
||||
|
||||
1. **Record current image** — read the running container's image digest (store in gateway's `update_history` LWW-map in CRDT, keyed by project name).
|
||||
2. **Pull new image** — `docker pull <image>` (or the compose-file equivalent tag).
|
||||
3. **Drain connections** — gateway marks the project as `updating`; new proxy requests return 503 with a `Retry-After: 5` header; in-flight requests are allowed to complete (30 s grace window).
|
||||
4. **Stop old container** — `docker stop --time=30 <container_name>`.
|
||||
5. **Start new container** — `docker start <container_name>` (or `docker compose up -d <service>`).
|
||||
6. **Health check** — poll the project's `/health` endpoint until 200 OK or 30 s timeout.
|
||||
7. **Restore routing** — remove the `updating` flag; proxy resumes normal operation.
|
||||
|
||||
Steps 1–7 are serialised per project. When `update all` is used, projects are updated **one at a time** (not in parallel) to limit blast radius.
|
||||
|
||||
### 4.2 Source-rebuild path (non-Docker / dev mode)
|
||||
|
||||
When Docker is not available (the gateway binary is running directly on the host, not in a container),
|
||||
the update command falls back to the existing `rebuild_and_restart` flow (`server/src/rebuild.rs`):
|
||||
`cargo build` → re-exec.
|
||||
This path cannot update individual projects independently — it rebuilds the gateway itself.
|
||||
|
||||
### 4.3 Gateway state during update
|
||||
|
||||
```
|
||||
normal → updating → (success) normal
|
||||
→ (failure) rolling_back → normal
|
||||
```
|
||||
|
||||
The CRDT `gateway_config` collection gains two new LWW fields per project:
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| `update_state` | `"idle" \| "updating" \| "rolling_back"` | Current update lifecycle stage |
|
||||
| `update_started_at` | `u64` (unix ms) | When the update was triggered |
|
||||
| `previous_image` | `string` | Image digest before the most recent update |
|
||||
| `current_image` | `string` | Image digest currently running |
|
||||
|
||||
These fields are replicated to all nodes so that other gateway instances and headless agents
|
||||
can observe update progress without polling HTTP.
|
||||
|
||||
---
|
||||
|
||||
## 5. Rollback Approach
|
||||
|
||||
### 5.1 Automatic rollback
|
||||
|
||||
If the health check in step 6 (§4.1) times out or returns a non-200 status, the gateway automatically:
|
||||
|
||||
1. Logs the failure: `[update] health check failed for huskies after 30 s`.
|
||||
2. Posts to the ops room: `Health check failed. Rolling back…`.
|
||||
3. Runs `docker stop` on the new container.
|
||||
4. Pulls and starts the previous image digest (stored in `previous_image`).
|
||||
5. Re-runs the health check on the rolled-back container.
|
||||
6. Reports outcome to the room.
|
||||
|
||||
If the rollback health check also fails, the bot reports:
|
||||
```
|
||||
Rollback failed. Manual intervention required. Previous image: sha256:def456
|
||||
```
|
||||
and sets `update_state = "error"` in the CRDT. The ops room is notified; no further automatic action is taken.
|
||||
|
||||
### 5.2 Manual rollback
|
||||
|
||||
An operator can issue `update huskies --rollback` at any time when the project is in `idle` state.
|
||||
The command replays steps 3–7 of §4.1 with `previous_image` substituted for the target image.
|
||||
`previous_image` is overwritten with the image that was displaced, so repeated rollbacks alternate between two images.
|
||||
|
||||
### 5.3 Rollback unavailability
|
||||
|
||||
Rollback is unavailable when:
|
||||
- No `previous_image` is recorded (first-ever update on this installation).
|
||||
- `update_state` is already `"updating"` or `"rolling_back"` (only one concurrent update per project).
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation Sketch
|
||||
|
||||
### 6.1 New files
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/src/chat/commands/update.rs` | Synchronous `handle_update` stub (returns `None` — async, like `rebuild`) |
|
||||
| `server/src/service/gateway/update.rs` | Core update/rollback logic; calls Docker API or falls back to `rebuild.rs` |
|
||||
| `server/src/service/gateway/docker.rs` | Thin wrapper around Docker socket HTTP API (`/containers/:id/start` etc.) |
|
||||
|
||||
### 6.2 New CRDT fields
|
||||
|
||||
Extend the `gateway_config` CRDT document (already exists per Spike 679 §6) with:
|
||||
- `projects.<name>.update_state` (LWW string)
|
||||
- `projects.<name>.update_started_at` (LWW u64)
|
||||
- `projects.<name>.previous_image` (LWW string)
|
||||
- `projects.<name>.current_image` (LWW string)
|
||||
|
||||
### 6.3 Gateway HTTP changes
|
||||
|
||||
Add one endpoint for the Docker-fallback check:
|
||||
|
||||
```
|
||||
GET /gateway/update/available
|
||||
→ {"available": true, "mode": "docker"} | {"available": true, "mode": "rebuild"} | {"available": false}
|
||||
```
|
||||
|
||||
The frontend can use this to show/hide an "Update" button in the gateway project list.
|
||||
|
||||
### 6.4 Async dispatch
|
||||
|
||||
`update` is an async command (like `rebuild`, `htop`, `start`).
|
||||
The command keyword is detected in `on_room_message` before `try_handle_command` is invoked.
|
||||
The handler spawns a `tokio::spawn` task, posts incremental updates via the existing transport's `send_message` / `edit_message` API, and returns.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open Questions
|
||||
|
||||
| # | Question | Notes |
|
||||
|---|----------|-------|
|
||||
| 1 | Should the Docker socket be mounted in the gateway container by default? | Security trade-off: socket access = container escape risk. Alternative: `docker exec` via a sidecar. |
|
||||
| 2 | Should `update all` use a sequential or rolling strategy? | Sequential is safer; rolling is faster. Sequential chosen for v1. |
|
||||
| 3 | How do we handle projects not managed by Docker (e.g. running on bare metal)? | Fallback to `rebuild` covers the gateway itself; project-specific fallback is out of scope for v1. |
|
||||
| 4 | Should the confirmation challenge expire? | Yes — 60 s timeout, configurable in `bot.toml`. |
|
||||
| 5 | Should update history be persisted beyond CRDT (i.e. across full gateway restarts)? | CRDT persists to SQLite, so yes, as long as the CRDT DB survives the restart. |
|
||||
| 6 | Multi-gateway HA: which node triggers the actual Docker call? | The node that owns the Docker socket. CRDT `update_state` prevents double-triggering. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Dependencies
|
||||
|
||||
| Story / Spike | Dependency type |
|
||||
|--------------|----------------|
|
||||
| Spike 679 (HTTP → CRDT bus) | Soft — `gateway_config` LWW collection needed for update state; can stub without it |
|
||||
| Story 665 (Ed25519 auth) | Soft — operator token auth is a future hardening step; room+role guard suffices for v1 |
|
||||
| `server/src/rebuild.rs` | Direct — reuse `rebuild_and_restart` for the non-Docker path |
|
||||
| `server/src/gateway_relay.rs` | Indirect — update state changes should trigger relay events to connected frontends |
|
||||
+125
-78
@@ -32,94 +32,141 @@ website/ — Static marketing/docs site
|
||||
|
||||
## Source Map
|
||||
|
||||
### Core
|
||||
One row per directory or top-level file. Descriptions are pulled from the module's `//!` doc-comment where present. **Use this to know where to look — do not re-discover the codebase via grep.**
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/main.rs` | Entry point, CLI argument parsing, and server startup |
|
||||
| `server/src/config.rs` | Parses `project.toml` for agents, components, and server settings |
|
||||
| `server/src/state.rs` | Global mutable session state (project root, cancellation) |
|
||||
| `server/src/store.rs` | JSON-backed persistent key-value store for settings |
|
||||
| `server/src/gateway.rs` | Multi-project gateway mode (MCP proxy, project switching, agent registration) |
|
||||
### Top-level backend files (`server/src/`)
|
||||
|
||||
### Agents
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `server/src/agent_log.rs` | Agent log persistence — reads and writes JSONL agent event logs to disk. |
|
||||
| `server/src/agent_mode.rs` | Headless build-agent mode for distributed, rendezvous-based story processing. |
|
||||
| `server/src/cli.rs` | Command-line argument parsing for the huskies binary. |
|
||||
| `server/src/crdt_wire.rs` | CRDT wire codec — serialization format for `SignedOp` sync messages between nodes. |
|
||||
| `server/src/gateway.rs` | Multi-project gateway — entrypoint wiring and route tree. When `huskies --gateway` is used, the server starts in gateway mode. B… |
|
||||
| `server/src/gateway_relay.rs` | Gateway relay task — pushes project status events to the gateway via WebSocket. When `gateway_url` is configured in `project.tom… |
|
||||
| `server/src/log_buffer.rs` | Bounded in-memory ring buffer for server log output. Use the [`slog!`] macro (INFO), [`slog_warn!`] (WARN), or [`slog_error!`] (… |
|
||||
| `server/src/main.rs` | Huskies server — entry point, CLI argument parsing, and server startup. |
|
||||
| `server/src/mesh.rs` | Peer mesh discovery — supplementary CRDT sync connections between build agents. When mesh discovery is enabled, a build agent pe… |
|
||||
| `server/src/node_identity.rs` | Node identity — Ed25519 keypair foundation for distributed huskies. Each huskies node has a stable identity derived from an Ed25… |
|
||||
| `server/src/rebuild.rs` | Server rebuild and restart logic shared between the MCP tool and Matrix bot command. |
|
||||
| `server/src/services.rs` | Shared services bundle — common state threaded through HTTP handlers and chat transports. `Services` bundles the fields that eve… |
|
||||
| `server/src/state.rs` | Session state — global mutable state shared across the server (project root, cancellation). |
|
||||
| `server/src/store.rs` | Key-value store — JSON-backed persistent storage for user settings and preferences. |
|
||||
| `server/src/workflow.rs` | Workflow module: test result tracking and acceptance evaluation. |
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/agents/mod.rs` | Types, configuration, and orchestration for coding agents |
|
||||
| `server/src/agents/gates.rs` | Runs test suites and validation scripts in agent worktrees |
|
||||
| `server/src/agents/lifecycle.rs` | File creation, archival, and stage transitions for pipeline items |
|
||||
| `server/src/agents/merge.rs` | Rebases agent work onto master and runs post-merge validation |
|
||||
| `server/src/agents/pty.rs` | Spawns agent processes in pseudo-terminals and streams output |
|
||||
| `server/src/agents/token_usage.rs` | Persists per-agent token consumption records to disk |
|
||||
| `server/src/agent_log.rs` | Reads and writes JSONL agent event logs to disk |
|
||||
| `server/src/agent_mode.rs` | Headless build-agent mode for distributed story processing |
|
||||
### Backend modules (`server/src/`)
|
||||
|
||||
### Agent Pool
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/src/` | |
|
||||
| `server/src/agents/` | Agent subsystem — types, configuration, and orchestration for coding agents. |
|
||||
| `server/src/agents/merge/` | Merge operations — rebases agent work onto master and runs post-merge validation. |
|
||||
| `server/src/agents/merge/squash/` | Squash-merge orchestration: rebase agent work onto master and run post-merge gates. |
|
||||
| `server/src/agents/pool/` | Agent pool — manages the set of active agents across all pipeline stages. |
|
||||
| `server/src/agents/pool/auto_assign/` | Auto-assign submodules: wires focused sub-files and re-exports public items. |
|
||||
| `server/src/agents/pool/auto_assign/watchdog/` | Watchdog task: detects orphaned agents, enforces turn/budget limits, and triggers auto-assign. |
|
||||
| `server/src/agents/pool/auto_assign/watchdog/tests/` | Shared test helpers for the watchdog module. |
|
||||
| `server/src/agents/pool/pipeline/` | Pipeline operations — stage advancement, completion handling, and merge orchestration. |
|
||||
| `server/src/agents/pool/pipeline/advance/` | Pipeline advance — moves stories forward through pipeline stages after agent completion. |
|
||||
| `server/src/agents/pool/pipeline/completion/` | Agent completion handling — processes exit results and triggers pipeline advancement. |
|
||||
| `server/src/agents/pool/start/` | Agent start — spawns a new agent process in a worktree for a given story. |
|
||||
| `server/src/agents/runtime/` | Agent runtimes — pluggable backends (Claude Code, Gemini, OpenAI) for running agents. |
|
||||
| `server/src/chat/` | Transport abstraction for chat platforms. The [`ChatTransport`] trait defines a platform-agnostic interface for sending and edit… |
|
||||
| `server/src/chat/commands/` | Bot-level command registry shared by all chat transports. Commands registered here are handled directly by the bot without invok… |
|
||||
| `server/src/chat/transport/` | Chat transports — pluggable backends (Matrix, Slack, WhatsApp, Discord) for bot messaging. |
|
||||
| `server/src/chat/transport/discord/` | Discord Bot integration. Provides: - [`DiscordTransport`] — a [`ChatTransport`] that sends messages via the Discord REST API (`/… |
|
||||
| `server/src/chat/transport/matrix/` | Matrix bot integration for Story Kit. When a `.huskies/bot.toml` file is present with `enabled = true`, the server spawns a Matr… |
|
||||
| `server/src/chat/transport/matrix/bot/` | Matrix bot — sub-modules for the Matrix chat bot implementation. |
|
||||
| `server/src/chat/transport/matrix/bot/messages/` | Matrix message handler — processes incoming room messages and dispatches commands. |
|
||||
| `server/src/chat/transport/matrix/config/` | Matrix transport configuration — deserialization of `bot.toml` Matrix settings. |
|
||||
| `server/src/chat/transport/slack/` | Slack Bot API integration. Provides: - [`SlackTransport`] — a [`ChatTransport`] that sends messages via the Slack Web API (`api.… |
|
||||
| `server/src/chat/transport/slack/commands/` | Slack incoming message dispatch and slash command handling. |
|
||||
| `server/src/chat/transport/whatsapp/` | WhatsApp Business API integration. Provides: - [`WhatsAppTransport`] — a [`ChatTransport`] that sends messages via the Meta Grap… |
|
||||
| `server/src/chat/transport/whatsapp/commands/` | WhatsApp command handling — processes incoming WhatsApp messages as bot commands. |
|
||||
| `server/src/config/` | Project configuration — parses `project.toml` for agents, components, and server settings. |
|
||||
| `server/src/crdt_snapshot/` | CRDT snapshot compaction with cross-node coordination. This module implements full CRDT state snapshots for compacting the op jo… |
|
||||
| `server/src/crdt_state/` | CRDT state layer — manages pipeline state as a conflict-free replicated document backed by SQLite. The CRDT document is the prim… |
|
||||
| `server/src/crdt_sync/` | CRDT sync — WebSocket-based replication of pipeline state between huskies nodes. WebSocket-based CRDT sync layer for replicating… |
|
||||
| `server/src/crdt_sync/server/` | Server-side `/crdt-sync` WebSocket handler. |
|
||||
| `server/src/db/` | SQLite storage layer — content store, shadow writes, and CRDT op persistence. |
|
||||
| `server/src/http/` | HTTP server — module declarations for all REST, MCP, WebSocket, and SSE endpoints. |
|
||||
| `server/src/http/agents/` | HTTP agent endpoints — thin adapters over `service::agents`. Each handler: extracts payload → calls `service::agents::X` → shape… |
|
||||
| `server/src/http/gateway/` | Gateway HTTP handlers — thin transport shells for the gateway service. Each handler calls `service::gateway::*` for business log… |
|
||||
| `server/src/http/mcp/` | HTTP MCP server module. |
|
||||
| `server/src/http/mcp/agent_tools/` | MCP agent tools — start, stop, wait, list, and inspect agents via MCP. |
|
||||
| `server/src/http/mcp/diagnostics/` | MCP diagnostic tools — server logs, CRDT dump, version, line counting, story movement. |
|
||||
| `server/src/http/mcp/shell_tools/` | MCP shell tools — run commands, execute tests, and stream output via MCP. This file is a thin adapter: it deserialises MCP paylo… |
|
||||
| `server/src/http/mcp/story_tools/` | MCP story tools — create, update, move, and manage stories, bugs, refactors, and spikes via MCP. This module is a thin adapter:… |
|
||||
| `server/src/http/mcp/story_tools/story/` | Story creation, listing, update, and lifecycle MCP tools. |
|
||||
| `server/src/http/mcp/tools_list/` | `tools/list` MCP method — returns the static schema for every tool the server exposes. |
|
||||
| `server/src/http/workflow/` | Workflow helpers — shared story/bug file operations used by HTTP and MCP handlers. |
|
||||
| `server/src/http/workflow/story_ops/` | Story operations — creates, updates, and manages acceptance criteria in story files. |
|
||||
| `server/src/io/` | I/O subsystem — filesystem, shell, search, onboarding, and story metadata operations. |
|
||||
| `server/src/io/fs/` | Filesystem I/O — module declarations and re-exports for file operations. |
|
||||
| `server/src/io/fs/scaffold/` | Project scaffolding — creates the `.huskies/` directory structure and default files. |
|
||||
| `server/src/io/fs/scaffold/detect/` | Stack detection — inspect the project root for marker files and emit TOML `[[component]]` entries plus `script/build\|lint\|test`… |
|
||||
| `server/src/io/watcher/` | Filesystem watcher for `.huskies/project.toml` and `.huskies/agents.toml`. Watches config files for changes and broadcasts a [`W… |
|
||||
| `server/src/llm/` | LLM subsystem — chat orchestration, prompts, OAuth, and provider integrations. |
|
||||
| `server/src/llm/chat/` | LLM chat — orchestrates multi-turn conversations with tool-calling LLM providers. |
|
||||
| `server/src/llm/providers/` | LLM providers — module declarations for Anthropic, Claude Code, and Ollama backends. |
|
||||
| `server/src/llm/providers/claude_code/` | Claude Code provider — runs Claude Code CLI in a PTY and parses structured output. |
|
||||
| `server/src/pipeline_state/` | Typed pipeline state machine (story 520). Replaces the stringly-typed CRDT views with strict Rust enums so that impossible state… |
|
||||
| `server/src/service/` | Service layer — domain logic extracted from HTTP handlers. Each sub-module follows the conventions documented in `docs/architect… |
|
||||
| `server/src/service/agents/` | Agent service — public API for the agent domain. This module orchestrates calls to `io.rs` (side effects) and the pure topic mod… |
|
||||
| `server/src/service/anthropic/` | Anthropic service — public API for Anthropic API-key management and model listing. Exposes functions to check, store, and use th… |
|
||||
| `server/src/service/bot_command/` | Bot command service — domain logic for dispatching slash commands. Extracted from `http/bot_command.rs` so that argument parsing… |
|
||||
| `server/src/service/common/` | Shared pure helpers used by multiple service modules. All sub-modules here are pure (no I/O, no side effects). Any helper that d… |
|
||||
| `server/src/service/diagnostics/` | Diagnostics service — server logs, CRDT dump, permission management, and story movement. Extracted from `http/mcp/diagnostics.rs… |
|
||||
| `server/src/service/events/` | Events service — public API for the events domain. This module re-exports the pure buffer types from `buffer.rs` and the side-ef… |
|
||||
| `server/src/service/file_io/` | File I/O service — public API for filesystem and shell operations. Exposes functions for reading, writing, and listing files sco… |
|
||||
| `server/src/service/gateway/` | Gateway service — domain logic for the multi-project gateway. Follows the conventions in `docs/architecture/service-modules.md`:… |
|
||||
| `server/src/service/git_ops/` | Git operations service — worktree path validation and git command execution. Extracted from `http/mcp/git_tools.rs` following th… |
|
||||
| `server/src/service/merge/` | Merge service — domain logic for merging agent work to master. Extracted from `http/mcp/merge_tools.rs` following the convention… |
|
||||
| `server/src/service/notifications/` | Notifications service — pipeline-event fan-out to chat transports. Subscribes to [`WatcherEvent`] broadcasts and posts human-rea… |
|
||||
| `server/src/service/notifications/io/` | I/O side of the notifications service. This is the **only** file inside `service/notifications/` that may perform side effects:… |
|
||||
| `server/src/service/oauth/` | OAuth service — domain logic for the Anthropic OAuth 2.0 PKCE flow. Extracts business logic from `http/oauth.rs` following the c… |
|
||||
| `server/src/service/pipeline/` | Pipeline service — shared pipeline-domain logic. Contains pure functions for parsing and aggregating pipeline status data. Used… |
|
||||
| `server/src/service/project/` | Project service — public API for the project domain. Exposes functions to open, close, query, and manage known projects. HTTP ha… |
|
||||
| `server/src/service/qa/` | QA service — domain logic for requesting, approving, and rejecting QA reviews. Extracted from `http/mcp/qa_tools.rs` following t… |
|
||||
| `server/src/service/settings/` | Settings service — domain logic for project settings and editor configuration. Extracts business logic from `http/settings.rs` f… |
|
||||
| `server/src/service/shell/` | Shell service — command safety, path sandboxing, and output helpers. Extracted from `http/mcp/shell_tools.rs` following the conv… |
|
||||
| `server/src/service/status/` | Status broadcaster — unified pipeline-event fan-out for all consumers. [`StatusBroadcaster`] lives on the [`crate::services::Ser… |
|
||||
| `server/src/service/story/` | Story service — domain logic for creating, updating, and managing pipeline work items. Extracted from `http/mcp/story_tools.rs`… |
|
||||
| `server/src/service/timer/` | Timer service — deferred agent start via one-shot timers. Provides [`TimerStore`] for persisting timers to `.huskies/timers.json… |
|
||||
| `server/src/service/wizard/` | Wizard service — domain logic for the multi-step project setup wizard. Follows the conventions from `docs/architecture/service-m… |
|
||||
| `server/src/service/ws/` | WebSocket service — domain logic for real-time pipeline updates, chat, and permission prompts. This module extracts the business… |
|
||||
| `server/src/worktree/` | Git worktree management — creates, lists, and removes worktrees for agent isolation. |
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/agents/pool/mod.rs` | Manages the set of active agents across all pipeline stages |
|
||||
| `server/src/agents/pool/start.rs` | Spawns a new agent process in a worktree for a story |
|
||||
| `server/src/agents/pool/stop.rs` | Terminates a running agent while preserving its worktree |
|
||||
| `server/src/agents/pool/pipeline/advance.rs` | Moves stories forward through pipeline stages |
|
||||
| `server/src/agents/pool/pipeline/completion.rs` | Processes exit results and triggers pipeline advancement |
|
||||
| `server/src/agents/pool/pipeline/merge.rs` | Orchestrates the merge-to-master flow for completed stories |
|
||||
| `server/src/agents/pool/auto_assign/auto_assign.rs` | Scans pipeline stages and dispatches agents to unassigned stories |
|
||||
### Crates
|
||||
|
||||
### CRDT & Database
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `crates/bft-json-crdt/benches/` | |
|
||||
| `crates/bft-json-crdt/bft-crdt-derive/src/` | |
|
||||
| `crates/bft-json-crdt/src/` | |
|
||||
| `crates/bft-json-crdt/tests/` | |
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/crdt_state.rs` | Pipeline state as a conflict-free replicated document backed by SQLite |
|
||||
| `server/src/crdt_sync.rs` | WebSocket-based replication of pipeline state between nodes |
|
||||
| `server/src/pipeline_state.rs` | Typed pipeline state machine |
|
||||
| `server/src/db/mod.rs` | Content store, shadow writes, and CRDT op persistence |
|
||||
### Frontend (`frontend/src/`)
|
||||
|
||||
### HTTP — MCP Tools (the tools agents call)
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `frontend/src/` | |
|
||||
| `frontend/src/api/` | |
|
||||
| `frontend/src/components/` | |
|
||||
| `frontend/src/components/selection/` | |
|
||||
| `frontend/src/hooks/` | |
|
||||
| `frontend/src/utils/` | |
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/http/mcp/mod.rs` | MCP endpoint dispatching tool calls |
|
||||
| `server/src/http/mcp/agent_tools.rs` | Start, stop, wait, list, and inspect agents |
|
||||
| `server/src/http/mcp/git_tools.rs` | Status, diff, add, commit, and log on agent worktrees |
|
||||
| `server/src/http/mcp/merge_tools.rs` | Merge agent work to master and report failures |
|
||||
| `server/src/http/mcp/shell_tools.rs` | Run commands, execute tests, and stream output |
|
||||
| `server/src/http/mcp/story_tools.rs` | Create, update, move, and manage stories/bugs/refactors |
|
||||
| `server/src/http/mcp/diagnostics.rs` | Server logs, CRDT dump, version, and story movement helpers |
|
||||
### Canonical patterns (copy these when adding new things)
|
||||
- **New CRDT LWW-map collection:** see `server/src/crdt_state/lww_maps.rs`
|
||||
- **New read-RPC handler:** register in `server/src/crdt_sync/rpc.rs`; call from frontend via `rpcCall<T>("method.name")` from `frontend/src/api/rpc.ts`
|
||||
- **Migrate HTTP route → CRDT:** delete from `gateway.rs` / `http/*`, add op to `service/<area>/`, write through `crdt_state/`
|
||||
- **New front-matter field:** add to `StoryMetadata` and `FrontMatter` in `io/story_metadata.rs` plus a `write_<name>_in_content` helper
|
||||
- **New service module:** copy `service/agents/` structure (`mod.rs` + `io.rs` + `selection.rs`)
|
||||
- **New chat command:** add a file under `chat/commands/` and register in `chat/commands/mod.rs::dispatch_command`
|
||||
- **New auto-assigner predicate:** add to `agents/pool/auto_assign/story_checks.rs`, wire in `auto_assign/auto_assign.rs`
|
||||
- **CRDT-seeding test helper:** `crate::db::write_item_with_content(story_id, stage, content)` — do not `fs::write` to `.huskies/work/{stage}/`
|
||||
|
||||
### Chat — Bot Commands
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/chat/commands/mod.rs` | Bot-level command registry shared by all transports |
|
||||
| `server/src/chat/commands/status.rs` | `status` command and pipeline status helpers |
|
||||
| `server/src/chat/commands/backlog.rs` | `backlog` command — shows only backlog-stage items |
|
||||
| `server/src/chat/commands/run_tests.rs` | `run_tests` command — run the project's test suite |
|
||||
|
||||
### Chat — Transports
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/chat/transport/matrix/` | Matrix bot integration |
|
||||
| `server/src/chat/transport/slack/` | Slack bot integration |
|
||||
| `server/src/chat/transport/whatsapp/` | WhatsApp Business API integration |
|
||||
| `server/src/chat/transport/discord/` | Discord bot integration |
|
||||
|
||||
### Frontend
|
||||
|
||||
| Directory | Description |
|
||||
|-----------|-------------|
|
||||
| `frontend/src/components/` | React UI components |
|
||||
| `frontend/src/api/` | API client code (gateway, agents, etc.) |
|
||||
|
||||
### Utilities
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/rebuild.rs` | Server rebuild and restart logic |
|
||||
| `server/src/worktree.rs` | Creates, lists, and removes git worktrees for agent isolation |
|
||||
| `server/src/io/watcher.rs` | Filesystem watcher for `.huskies/work/` and `project.toml` |
|
||||
|
||||
## Quality Gates
|
||||
All enforced by `script/test`:
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
# Pipeline State Machine
|
||||
|
||||
This document describes the huskies pipeline state machine in two halves:
|
||||
**(a)** the model that runs in production today, and **(b)** transitions, refinements,
|
||||
and corrections we have identified as needed but not yet implemented.
|
||||
|
||||
The codebase is in a deliberate transitional state: a typed CRDT state machine
|
||||
exists at `server/src/pipeline_state.rs` (introduced by story 520) with strict Rust
|
||||
enums for every stage, archive reason, execution state, and event. It is fully
|
||||
defined and tested but **not yet called from non-test code** (`#![allow(dead_code)]`
|
||||
at the top of the module). Consumers will migrate incrementally.
|
||||
|
||||
The model that is actually doing work is the older **filesystem-stage-string +
|
||||
front-matter-flag** model. Section (a) below documents both representations and
|
||||
the migration intent.
|
||||
|
||||
---
|
||||
|
||||
## (a) The current state machine
|
||||
|
||||
### Stages (production: filesystem string; future: typed enum)
|
||||
|
||||
| Filesystem (production) | Typed (future) | Meaning |
|
||||
|---|---|---|
|
||||
| `work/1_backlog/` | `Stage::Backlog` | Story exists, waiting for dependencies or auto-assign promotion |
|
||||
| `work/2_current/` | `Stage::Coding` | Coder agent is running (or about to) |
|
||||
| `work/3_qa/` | `Stage::Qa` | Coder finished; gates / human review running |
|
||||
| `work/4_merge/` | `Stage::Merge { feature_branch, commits_ahead: NonZeroU32 }` | Gates passed, mergemaster ready to squash |
|
||||
| `work/5_done/` | `Stage::Done { merged_at, merge_commit }` | Mergemaster squashed to master |
|
||||
| `work/6_archived/` | `Stage::Archived { archived_at, reason: ArchiveReason }` | Out of the active flow |
|
||||
|
||||
`5_done` auto-sweeps to `6_archived` after four hours. The typed `Stage::Done`
|
||||
variant always carries the merge SHA and timestamp; `Stage::Merge`'s
|
||||
`commits_ahead: NonZeroU32` makes "Merge with nothing to merge" structurally
|
||||
impossible (eliminates bug 519).
|
||||
|
||||
### Archive reasons (`pipeline_state.rs::ArchiveReason`)
|
||||
|
||||
The typed model already enumerates the reasons a story can leave the active flow
|
||||
(subsumes the legacy `blocked`, `merge_failure`, and `review_hold` front-matter
|
||||
fields per story 436):
|
||||
|
||||
- `Completed` — happy-path
|
||||
- `Abandoned` — user explicitly abandoned
|
||||
- `Superseded { by: StoryId }` — replaced by another story
|
||||
- `Blocked { reason: String }` — manually blocked, awaiting human resolution
|
||||
- `MergeFailed { reason: String }` — mergemaster gave up after retry budget
|
||||
- `ReviewHeld { reason: String }` — held for human review at user request
|
||||
|
||||
### Per-node execution state (`pipeline_state.rs::ExecutionState`)
|
||||
|
||||
Stage is shared/CRDT-replicated. Execution state is per-node and lives under
|
||||
each node's pubkey in the CRDT, so there are no inter-author merge conflicts:
|
||||
|
||||
- `Idle`
|
||||
- `Pending { agent, since }` — worktree being created, agent about to start
|
||||
- `Running { agent, started_at, last_heartbeat }`
|
||||
- `RateLimited { agent, resume_at }`
|
||||
- `Completed { agent, exit_code, completed_at }`
|
||||
|
||||
### Pipeline events (`pipeline_state.rs::PipelineEvent`)
|
||||
|
||||
The typed model defines every event that drives a Stage transition. Each variant
|
||||
carries the data needed to construct the destination state, so a transition
|
||||
function can never accidentally land in an underspecified state:
|
||||
|
||||
- `DepsMet` — dependencies met; promote from backlog
|
||||
- `GatesStarted` — coder starting gates
|
||||
- `GatesPassed { feature_branch, commits_ahead }`
|
||||
- `GatesFailed { reason }`
|
||||
- `QaSkipped { feature_branch, commits_ahead }` — qa-mode = "server"; skip QA, go to merge
|
||||
- `MergeSucceeded { merge_commit }`
|
||||
- `MergeFailedFinal { reason }`
|
||||
- `Accepted` — Done → Archived(Completed)
|
||||
|
||||
### Transitions (current production = MCP verb shape)
|
||||
|
||||
#### Backlog → Coding (a.k.a. backlog → 2_current)
|
||||
|
||||
- **Auto path**: `AgentPool::auto_assign_available_work` calls
|
||||
`promote_ready_backlog_stories`. A backlog story is promoted iff (a) it has
|
||||
an explicit non-empty `depends_on` AND (b) every dep is in `5_done` or
|
||||
`6_archived`. Stories with no `depends_on` are NOT auto-promoted — they wait
|
||||
for human scheduling.
|
||||
- Implemented in `server/src/agents/pool/auto_assign/auto_assign.rs::promote_ready_backlog_stories`.
|
||||
- **Manual path**: `mcp__huskies__move_story story_id=X target_stage=current`,
|
||||
or `mcp__huskies__start_agent` (which moves the story to current as a
|
||||
side-effect of starting an agent).
|
||||
- **Archived-dep warning**: if a dep was satisfied via `6_archived` rather than
|
||||
`5_done` (e.g. abandoned/superseded), the auto-assigner logs a prominent
|
||||
warning so the user can see the promotion was triggered by an archived dep.
|
||||
|
||||
#### Coding → Qa (current → 3_qa)
|
||||
|
||||
- Triggered when the coder agent finishes (gates start running).
|
||||
- `mcp__huskies__request_qa` is the manual verb.
|
||||
|
||||
#### Qa → Coding (qa → current — rejection path)
|
||||
|
||||
- `mcp__huskies__reject_qa story_id=X notes="..."` moves qa → current,
|
||||
**clears `review_hold`**, and writes the rejection notes
|
||||
(`agents/lifecycle.rs:210`).
|
||||
- Used when a qa agent fails or a human reviewer rejects the work.
|
||||
|
||||
#### Qa → Merge (qa → 4_merge)
|
||||
|
||||
- Triggered when QA gates pass. `mcp__huskies__move_story_to_merge` is the
|
||||
dedicated verb.
|
||||
- For server-mode QA: typed-side `PipelineEvent::QaSkipped` allows going from
|
||||
Coding → Merge directly without entering Qa.
|
||||
|
||||
#### Merge → Done (merge → 5_done)
|
||||
|
||||
- Mergemaster picks up a story in `4_merge/`, squashes the feature branch onto
|
||||
master, then transitions to `5_done`.
|
||||
- `mcp__huskies__move_story_to_merge` queues; mergemaster does the actual work.
|
||||
|
||||
#### Done → Archived(Completed) (5_done → 6_archived)
|
||||
|
||||
- Auto-sweep after four hours, OR
|
||||
- `mcp__huskies__accept_story` (immediate manual archive).
|
||||
|
||||
#### Any-stage → Archived(other reasons)
|
||||
|
||||
- **Abandoned / Superseded**: today done by `mcp__huskies__move_story
|
||||
target_stage=done` (no first-class verbs for these reasons; see (b) below).
|
||||
- **Blocked**: `blocked: true` flag in front matter is set on retry-limit
|
||||
exceedance. `mcp__huskies__unblock_story` clears the flag and resets
|
||||
retry_count.
|
||||
- **MergeFailed**: written to front matter when mergemaster fails; auto-assign
|
||||
skips these stories (`has_merge_failure` check).
|
||||
- **ReviewHeld**: `review_hold: true` flag is set automatically on spike
|
||||
completion; auto-assign skips these stories until the flag is cleared.
|
||||
|
||||
#### Tombstone / purge
|
||||
|
||||
- `mcp__huskies__delete_story` and `mcp__huskies__purge_story` permanently
|
||||
remove. Purge writes a CRDT tombstone.
|
||||
|
||||
### Auto-assign skip conditions (current production)
|
||||
|
||||
`auto_assign_available_work` walks `2_current/`, `3_qa/`, `4_merge/` in order
|
||||
and attempts to dispatch a free agent to each unassigned story. It **skips**
|
||||
any story that:
|
||||
|
||||
1. Has `review_hold: true` in front matter (spikes after QA, manual hold).
|
||||
2. Is `frozen` (`is_story_frozen` — pipeline advancement suspended for this story).
|
||||
3. Has `blocked: true` (retry limit exceeded; cleared via `unblock_story`).
|
||||
4. Has unmet `depends_on` dependencies.
|
||||
5. (Merge stage only) Has a recorded merge failure (`has_merge_failure`).
|
||||
6. (Merge stage only) Has an empty diff on the feature branch — auto-writes
|
||||
`merge_failure` and blocks immediately rather than wasting a mergemaster turn.
|
||||
|
||||
### Front-matter fields that gate transitions
|
||||
|
||||
| Field | Type | Effect |
|
||||
|---|---|---|
|
||||
| `depends_on` | list of story IDs | Blocks backlog → current promotion until all deps are in 5_done or 6_archived |
|
||||
| `agent` | string (e.g. `coder-opus`) | Pins the preferred agent for next assignment |
|
||||
| `review_hold` | bool | Auto-assign skips this story; cleared by `reject_qa` or manual unblock |
|
||||
| `blocked` | bool | Auto-assign skips this story; cleared by `unblock_story` |
|
||||
| `frozen` | bool | Auto-assign skips this story; manual unfreeze required |
|
||||
| `merge_failure` | string | Auto-assign skips merge-stage agents on this story |
|
||||
| `retry_count` | int | Local-only (not in CRDT); incremented by orchestrator |
|
||||
|
||||
### Spike-specific behavior
|
||||
|
||||
Per the typical lifecycle, a spike runs through `current → qa` like any work
|
||||
item, then **stops** in qa awaiting human review (`spikes skip merge`). This
|
||||
is implemented via `review_hold: true` being written automatically when a
|
||||
spike's qa gates pass. The user accepts (move qa → done) or rejects (move
|
||||
qa → current). Spikes do NOT auto-promote to merge.
|
||||
|
||||
### Mergemaster lifecycle
|
||||
|
||||
The mergemaster agent only runs against stories in `4_merge/`. It:
|
||||
|
||||
1. Verifies the feature branch has commits (or the story is auto-blocked).
|
||||
2. Squashes the feature branch onto master with a deterministic commit message.
|
||||
3. Transitions the story to `5_done` with `merged_at` and `merge_commit`.
|
||||
4. On failure beyond the retry budget, writes `merge_failure` and blocks the
|
||||
story (auto-assign then skips it).
|
||||
|
||||
### Agent terminated with committed work (bug 645 recovery path)
|
||||
|
||||
When a coder agent terminates abnormally (e.g. the Claude Code CLI's
|
||||
`output.write(&bytes).is_ok()` PTY write assertion fires mid-session), the
|
||||
server-owned completion path detects the crash and checks for surviving work:
|
||||
|
||||
1. If the worktree is dirty but has commits ahead of master, reset the
|
||||
uncommitted files (`git checkout . && git clean -fd`) and run gates
|
||||
against the committed code.
|
||||
2. If gates still fail but `git log master..HEAD` shows commits and
|
||||
`cargo check` passes, **advance to QA** instead of entering the
|
||||
retry/block path. This is the "work survived" check, implemented in
|
||||
`server/src/agents/pool/pipeline/advance.rs`.
|
||||
3. Agents that die WITHOUT committed work (no commits ahead of master)
|
||||
still follow the existing retry → block path unchanged.
|
||||
|
||||
This prevents false-positive blocking of stories where the agent completed
|
||||
meaningful work before crashing.
|
||||
|
||||
### Watchdog (current production)
|
||||
|
||||
The "watchdog" at `server/src/agents/pool/auto_assign/watchdog.rs` runs every
|
||||
30 ticks of the unified background loop. Today it does **one** thing: detect
|
||||
orphaned agents whose tokio task is `is_finished()` but whose status is still
|
||||
`Running` or `Pending`, and mark them `Failed` with an `AgentEvent::Error`
|
||||
emission. Bug 624 (now merged) extends it to also enforce `max_turns` and
|
||||
`max_budget_usd` limits — an agent over either limit is killed via the
|
||||
existing `kill_child_for_key` path and recorded with a typed termination
|
||||
reason.
|
||||
|
||||
---
|
||||
|
||||
## (b) Transitions and behaviors that don't yet exist (or are only partially wired)
|
||||
|
||||
### Migration of consumers off legacy strings to typed `Stage` enum
|
||||
|
||||
The biggest outstanding piece. `pipeline_state.rs` is `#![allow(dead_code)]`.
|
||||
Every consumer (auto-assign, mergemaster, MCP tools, chat commands) still
|
||||
works with stage strings (`"2_current"`, `"4_merge"`) and front-matter flags.
|
||||
The projection layer (`TryFrom<PipelineItemView> for PipelineItem` and
|
||||
friends) exists but isn't called outside tests. Migration is intentionally
|
||||
incremental.
|
||||
|
||||
**Opportunity**: pick a leaf consumer (e.g. one MCP tool that reads the stage
|
||||
string) and migrate it to read `Stage` instead. Pattern repeats outward until
|
||||
all consumers go through the typed projection and the legacy stage-string
|
||||
code can be deleted.
|
||||
|
||||
### First-class verbs for archive reasons
|
||||
|
||||
`ArchiveReason` already has six variants but only `Completed` (via
|
||||
`accept_story`) and `Blocked` (via the `blocked: true` flag) have dedicated
|
||||
MCP verbs. Today, `Abandoned`, `Superseded`, `MergeFailed`, and `ReviewHeld`
|
||||
are reached either via `move_story target_stage=done` (which doesn't carry
|
||||
the reason) or via setting front-matter flags on the live story.
|
||||
|
||||
**Missing transitions**:
|
||||
|
||||
- `mcp__huskies__supersede_story story_id=X by=Y` — sets stage to
|
||||
`Archived { reason: Superseded { by: Y } }`. Today we use
|
||||
`move_story → done`, losing the `by` reference. (Came up 2026-04-25 with
|
||||
spike 621 → refactor 623.)
|
||||
- `mcp__huskies__abandon_story story_id=X reason="..."` — sets
|
||||
`Archived { reason: Abandoned }`. Today done via `move_story → done` or
|
||||
`purge_story`.
|
||||
- `mcp__huskies__hold_for_review story_id=X reason="..."` — explicitly puts
|
||||
a story in `Archived { reason: ReviewHeld }` rather than relying on the
|
||||
auto-set `review_hold` flag.
|
||||
|
||||
### Type-conversion transitions
|
||||
|
||||
Spike → story conversion is a real workflow (we do it when a spike's scope
|
||||
grows into an implementation story). Today, converting type via `update_story
|
||||
front_matter={"type": "story"}` does not bootstrap the
|
||||
`## Acceptance Criteria` section, and `add_criterion` then permanently fails
|
||||
on that story (see **bug 625** filed 2026-04-25). The `type` field passed via
|
||||
front_matter is also silently dropped — same silent-drop bug class as
|
||||
`acceptance_criteria`. The state machine should treat type conversion as a
|
||||
transition with side effects — at minimum, ensuring the AC section exists
|
||||
when transitioning to a type that requires it, and the displayed type
|
||||
reflects the new value (today the display chip is parsed from the immutable
|
||||
story_id prefix; story 578 in backlog will fix this by switching to
|
||||
numeric-only IDs).
|
||||
|
||||
### Limit-based agent termination (turn / budget)
|
||||
|
||||
Pre-624 master: `max_turns` and `max_budget_usd` per-agent config were read
|
||||
by the metric tool (`tool_get_agent_remaining_turns_and_budget`) but **not
|
||||
enforced** anywhere. Observed `coder-1` running 282/50 turns and $10.05/$5.00
|
||||
USD on story 623 before a human stopped it (bug 624, now merged).
|
||||
|
||||
The bug 624 fix adds enforcement to the watchdog. The state-machine impact:
|
||||
introduces a new agent-termination path distinct from "Failed (orphan)" —
|
||||
something like `Failed(LimitExceeded { kind: Turns | Budget })`. The
|
||||
`ExecutionState` enum may want a corresponding terminal variant so it can be
|
||||
distinguished from generic `Failed`.
|
||||
|
||||
### Pinned-agent honoring under contention
|
||||
|
||||
When a story has `agent: coder-opus` pinned but `coder-opus` is busy, today's
|
||||
auto-assign behavior is to leave the story unassigned — but if a human stops
|
||||
the running attempt and the story sits in `current/`, auto-assign **re-grabs
|
||||
it with the default coder** rather than waiting for the pinned agent.
|
||||
Observed multiple times on 2026-04-25 with story 623: pinning `coder-opus`
|
||||
did not prevent `coder-1` (sonnet) from being auto-assigned during opus's
|
||||
busy window.
|
||||
|
||||
**Missing behavior**: auto-assign should treat a pinned agent as a hard
|
||||
filter ("only this agent can take this story"), not a preference. Today the
|
||||
workaround is to also set `depends_on` on a phantom story, or move the story
|
||||
back to backlog and let the dependency system gate it.
|
||||
|
||||
### Honoring the `blocked` flag (bug 559)
|
||||
|
||||
`559_bug_mergemaster_ignores_blocked_flag_and_keeps_respawning_on_blocked_stories`
|
||||
is in backlog. Even though `blocked: true` is documented as a skip condition
|
||||
in `auto_assign_available_work`, mergemaster's spawn path apparently checks
|
||||
something different (or earlier) and respawns on blocked merge-stage stories.
|
||||
The state machine should make `Stage::Archived { reason: Blocked }` a single
|
||||
authoritative source so no consumer can incidentally bypass it.
|
||||
|
||||
### Formal "ghost story recovery" transition
|
||||
|
||||
The `move_story` MCP tool description mentions "recovering a ghost story by
|
||||
moving it back to current" as a valid use. Ghost stories are CRDT entries
|
||||
with no corresponding filesystem stage directory (or the inverse). Today this
|
||||
is an `update_story + move_story` ad-hoc dance. A first-class
|
||||
`recover_ghost_story` verb that reconciles the CRDT and filesystem would
|
||||
formalize the recovery path.
|
||||
|
||||
### Operator-level visibility / observability
|
||||
|
||||
There is no UI, CLI, or doc that shows "the state machine as a diagram." The
|
||||
typed enums are the closest thing to a canonical specification, but they
|
||||
aren't rendered anywhere a human can see at a glance: which stages exist,
|
||||
which transitions are valid, which events trigger them. A generated state
|
||||
diagram (graphviz or mermaid, dumped into this doc on each release) would
|
||||
help both new contributors and operators triaging stuck pipelines.
|
||||
|
||||
### Review-hold cleanup verb
|
||||
|
||||
`review_hold: true` is set automatically on spike completion. Clearing it is
|
||||
done as a side effect of `reject_qa` (which also moves the story qa →
|
||||
current) or by manually editing front matter. There is no clean "I have
|
||||
reviewed this, release the hold" verb that doesn't also move the story.
|
||||
|
||||
### Cross-node concurrency for execution state
|
||||
|
||||
`ExecutionState` is per-node (keyed by pubkey) so two nodes can't fight over
|
||||
who's running an agent. But there is no formal transition that says "node A
|
||||
hands the story to node B" if node A goes offline. The state machine's
|
||||
distributed semantics for this case are not yet specified.
|
||||
|
||||
---
|
||||
|
||||
## How to update this document
|
||||
|
||||
Whenever you discover a transition that doesn't yet exist, or a flag that
|
||||
behaves surprisingly, add it to **section (b)** with:
|
||||
|
||||
- A short description of the desired behavior
|
||||
- Citation of the work item or incident that surfaced it
|
||||
- Pointer to the place in `pipeline_state.rs` where it should be modeled (or
|
||||
note "needs a new variant" if it doesn't fit any existing enum yet)
|
||||
|
||||
When a transition from (b) ships, move it to (a) with the relevant file:line
|
||||
citations.
|
||||
Generated
+144
-209
@@ -83,6 +83,12 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
@@ -229,7 +235,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -341,17 +347,6 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "atty"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
|
||||
dependencies = [
|
||||
"hermit-abi 0.1.19",
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "auto_ops"
|
||||
version = "0.3.0"
|
||||
@@ -366,9 +361,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.2"
|
||||
version = "1.16.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
|
||||
checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
@@ -376,9 +371,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.39.1"
|
||||
version = "0.40.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
|
||||
checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
@@ -426,10 +421,10 @@ name = "bft-crdt-derive"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"proc-macro-crate 1.3.1",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -441,13 +436,12 @@ dependencies = [
|
||||
"criterion",
|
||||
"fastcrypto",
|
||||
"indexmap 2.14.0",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"random_color",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"sha2 0.10.9",
|
||||
"time 0.1.45",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -754,24 +748,28 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "3.2.25"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123"
|
||||
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"clap_builder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"indexmap 1.9.3",
|
||||
"textwrap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.2.4"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5"
|
||||
dependencies = [
|
||||
"os_str_bytes",
|
||||
]
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
@@ -782,6 +780,12 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "2.2.0"
|
||||
@@ -958,19 +962,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.4.0"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c76e09c1aae2bc52b3d2f29e13c6572553b30c4aa1b8a49fd70de6412654cb"
|
||||
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
|
||||
dependencies = [
|
||||
"anes",
|
||||
"atty",
|
||||
"cast",
|
||||
"ciborium",
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"is-terminal",
|
||||
"itertools 0.10.5",
|
||||
"lazy_static",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"oorandom",
|
||||
"plotters",
|
||||
"rayon",
|
||||
@@ -1073,6 +1077,15 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
@@ -1378,6 +1391,7 @@ dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"const-oid 0.10.2",
|
||||
"crypto-common 0.2.1",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1649,7 +1663,7 @@ dependencies = [
|
||||
"num-bigint",
|
||||
"once_cell",
|
||||
"p256",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"readonly",
|
||||
"rfc6979",
|
||||
"rsa 0.8.2",
|
||||
@@ -2155,15 +2169,6 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
@@ -2188,7 +2193,7 @@ version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2200,6 +2205,15 @@ dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest 0.11.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "home"
|
||||
version = "0.5.12"
|
||||
@@ -2288,18 +2302,21 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "huskies"
|
||||
version = "0.10.2"
|
||||
version = "0.10.4"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"bft-json-crdt",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"ed25519-dalek",
|
||||
"eventsource-stream",
|
||||
"fastcrypto",
|
||||
"filetime",
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
"homedir",
|
||||
"ignore",
|
||||
"indexmap 2.14.0",
|
||||
@@ -2313,17 +2330,20 @@ dependencies = [
|
||||
"poem-openapi",
|
||||
"portable-pty",
|
||||
"pulldown-cmark",
|
||||
"rand 0.8.6",
|
||||
"regex",
|
||||
"reqwest 0.13.2",
|
||||
"reqwest 0.13.3",
|
||||
"rust-embed",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"serde_yaml",
|
||||
"sha1",
|
||||
"sha2 0.11.0",
|
||||
"source-map-gen",
|
||||
"sqlx",
|
||||
"statig",
|
||||
"strip-ansi-escapes",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
@@ -2683,6 +2703,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.5"
|
||||
@@ -2802,9 +2833,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "konst"
|
||||
version = "0.3.16"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4381b9b00c55f251f2ebe9473aef7c117e96828def1a7cb3bd3f0f903c6894e9"
|
||||
checksum = "97feab15b395d1860944abe6a8dd8ed9f8eadfae01750fada8427abda531d887"
|
||||
dependencies = [
|
||||
"const_panic",
|
||||
"konst_kernel",
|
||||
@@ -3027,7 +3058,7 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a962fc9981f823f6555416dcb2ae9ae67ca412d767ee21ecab5150113ee6285b"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro-crate",
|
||||
"proc-macro-error2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3160,12 +3191,12 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
"itertools 0.14.0",
|
||||
"js_option",
|
||||
"matrix-sdk-common",
|
||||
"pbkdf2",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"rmp-serde",
|
||||
"ruma",
|
||||
"serde",
|
||||
@@ -3173,7 +3204,7 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
"subtle",
|
||||
"thiserror 2.0.18",
|
||||
"time 0.3.47",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
@@ -3253,9 +3284,9 @@ dependencies = [
|
||||
"blake3",
|
||||
"chacha20poly1305",
|
||||
"getrandom 0.2.17",
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
"pbkdf2",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3509,7 +3540,7 @@ dependencies = [
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -3556,7 +3587,7 @@ version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
|
||||
dependencies = [
|
||||
"hermit-abi 0.5.2",
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -3570,7 +3601,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"getrandom 0.2.17",
|
||||
"http",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3604,12 +3635,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "os_str_bytes"
|
||||
version = "6.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1"
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.13.2"
|
||||
@@ -3664,7 +3689,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3726,7 +3751,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3883,7 +3908,7 @@ version = "3.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "056e2fea6de1cb240ffe23cfc4fc370b629f8be83b5f27e16b7acd5231a72de4"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
@@ -3925,7 +3950,7 @@ dependencies = [
|
||||
"http",
|
||||
"indexmap 2.14.0",
|
||||
"mime",
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
@@ -4014,47 +4039,13 @@ dependencies = [
|
||||
"elliptic-curve",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"toml_edit 0.19.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit 0.25.11+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"version_check",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4231,9 +4222,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
@@ -4500,9 +4491,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.2"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
||||
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
@@ -4548,7 +4539,7 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
@@ -4693,7 +4684,7 @@ dependencies = [
|
||||
"js_int",
|
||||
"konst",
|
||||
"percent-encoding",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"regex",
|
||||
"ruma-identifiers-validation",
|
||||
"ruma-macros",
|
||||
@@ -4701,7 +4692,7 @@ dependencies = [
|
||||
"serde_html_form",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"time 0.3.47",
|
||||
"time",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
@@ -4785,7 +4776,7 @@ checksum = "0a0753312ad577ac462de1742bf2e326b6ba9856ff6f13343aeb17d423fd5426"
|
||||
dependencies = [
|
||||
"as_variant",
|
||||
"cfg-if",
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"ruma-identifiers-validation",
|
||||
@@ -4803,7 +4794,7 @@ dependencies = [
|
||||
"base64",
|
||||
"ed25519-dalek",
|
||||
"pkcs8 0.10.2",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"ruma-common",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
@@ -4952,9 +4943,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.12"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -5078,7 +5069,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25996b82292a7a57ed3508f052cfff8640d38d32018784acd714758b43da9c8f"
|
||||
dependencies = [
|
||||
"bitcoin_hashes",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"secp256k1-sys",
|
||||
]
|
||||
|
||||
@@ -5257,7 +5248,7 @@ dependencies = [
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_with_macros",
|
||||
"time 0.3.47",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5344,9 +5335,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sha3"
|
||||
version = "0.10.8"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60"
|
||||
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"keccak",
|
||||
@@ -5446,6 +5437,14 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "source-map-gen"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
@@ -5581,13 +5580,13 @@ dependencies = [
|
||||
"generic-array",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
"itoa",
|
||||
"log",
|
||||
"md-5",
|
||||
"memchr",
|
||||
"percent-encoding",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"rsa 0.9.10",
|
||||
"sha1",
|
||||
"sha2 0.10.9",
|
||||
@@ -5617,13 +5616,13 @@ dependencies = [
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
"home",
|
||||
"itoa",
|
||||
"log",
|
||||
"md-5",
|
||||
"memchr",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
@@ -5682,27 +5681,6 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "statig"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42c467cc59664639bf70b8225b1b4a9c30d926f3e010c29e804bf940d618c663"
|
||||
dependencies = [
|
||||
"statig_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "statig_macro"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf4c61563b68df6e452ceece3fba1329c8c6a5d348fe17b0778fada28bc95fde"
|
||||
dependencies = [
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
@@ -5853,12 +5831,6 @@ dependencies = [
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -5917,17 +5889,6 @@ dependencies = [
|
||||
"num_cpus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.1.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi 0.10.0+wasi-snapshot-preview1",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
@@ -5996,9 +5957,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.0"
|
||||
version = "1.52.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776"
|
||||
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -6114,12 +6075,6 @@ dependencies = [
|
||||
"winnow 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
@@ -6138,17 +6093,6 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.19.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"toml_datetime 0.6.11",
|
||||
"winnow 0.5.40",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.11+spec-1.1.0"
|
||||
@@ -6327,9 +6271,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "typewit"
|
||||
@@ -6465,9 +6409,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.0"
|
||||
version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
@@ -6509,10 +6453,10 @@ dependencies = [
|
||||
"ed25519-dalek",
|
||||
"getrandom 0.2.17",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
"matrix-pickle",
|
||||
"prost",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
@@ -6566,12 +6510,6 @@ version = "0.9.0+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.10.0+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -6580,11 +6518,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6593,7 +6531,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
"wit-bindgen 0.51.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6770,18 +6708,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.6"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
|
||||
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -7229,15 +7167,6 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
@@ -7271,6 +7200,12 @@ dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
|
||||
+8
-4
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = ["server", "crates/bft-json-crdt"]
|
||||
members = ["server", "crates/bft-json-crdt", "crates/source-map-gen"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -17,18 +17,22 @@ 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.2", features = ["json", "stream"] }
|
||||
reqwest = { version = "0.13.3", features = ["json", "stream"] }
|
||||
rust-embed = "8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_urlencoded = "0.7"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.11.0"
|
||||
hmac = "0.13"
|
||||
subtle = "2"
|
||||
base64 = "0.22"
|
||||
serde_yaml = "0.9"
|
||||
strip-ansi-escapes = "0.2"
|
||||
tempfile = "3"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
|
||||
toml = "1.1.0"
|
||||
uuid = { version = "1.22.0", features = ["v4", "serde"] }
|
||||
toml = "1.1.2"
|
||||
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"
|
||||
|
||||
@@ -4,7 +4,7 @@ A story-driven development server that manages work items, spawns coding agents,
|
||||
|
||||
## Getting started with Claude Code
|
||||
|
||||
1. Download the huskies binary (or build from source — see below).
|
||||
1. Download the huskies binary (or build from source — see below). Add it to your $PATH.
|
||||
|
||||
2. From your project directory, scaffold and start the server:
|
||||
|
||||
@@ -79,6 +79,13 @@ cd frontend && npm install && npm run dev
|
||||
|
||||
Configuration lives in `.huskies/project.toml`. See `.huskies/bot.toml.*.example` for transport setup.
|
||||
|
||||
## Architecture
|
||||
|
||||
Internal architecture documentation lives in [`docs/architecture/`](docs/architecture/):
|
||||
|
||||
- [Service module conventions](docs/architecture/service-modules.md) — layout, layering rules, and patterns for `server/src/service/`
|
||||
- [Future extraction targets](docs/architecture/future-extractions.md) — recommended order for remaining handler extractions
|
||||
|
||||
## Releasing
|
||||
|
||||
Requires a Gitea API token in `.env` (`GITEA_TOKEN=your_token`).
|
||||
|
||||
@@ -16,18 +16,17 @@ bft = []
|
||||
[dependencies]
|
||||
bft-crdt-derive = { path = "bft-crdt-derive" }
|
||||
colored = "2.0.0"
|
||||
fastcrypto = "0.1.8"
|
||||
fastcrypto = "0.1.9"
|
||||
indexmap = { version = "2.2.6", features = ["serde"] }
|
||||
rand = "0.8.5"
|
||||
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.8.1"
|
||||
serde_with = "3.18"
|
||||
sha2 = "0.10.6"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.4", features = ["html_reports"] }
|
||||
time = "0.1"
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = { version = "1.0.85", features = ["preserve_order"] }
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//! Benchmarks for BFT JSON CRDT operation throughput.
|
||||
use bft_json_crdt::{
|
||||
json_crdt::JsonValue, keypair::make_author, list_crdt::ListCrdt, op::Op, op::ROOT_ID,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,6 @@ proc-macro = true
|
||||
[dependencies]
|
||||
indexmap = { version = "2.2.6", features = ["serde"] }
|
||||
proc-macro2 = "1.0.47"
|
||||
proc-macro-crate = "1.2.1"
|
||||
proc-macro-crate = "3"
|
||||
quote = "1.0.21"
|
||||
syn = { version = "1.0.103", features = ["full"] }
|
||||
syn = { version = "2", features = ["full"] }
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
//! Procedural macros for the BFT JSON CRDT library.
|
||||
//!
|
||||
//! Provides `#[add_crdt_fields]` to inject `path` and `id` fields into a struct,
|
||||
//! and `#[derive(CrdtNode)]` to auto-implement the [`CrdtNode`] trait for structs
|
||||
//! whose fields are themselves [`CrdtNode`]s.
|
||||
|
||||
use proc_macro::TokenStream as OgTokenStream;
|
||||
use proc_macro2::{Ident, Span, TokenStream};
|
||||
use proc_macro_crate::{crate_name, FoundCrate};
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
//! Debug helpers and the [`DebugView`] trait for rendering CRDT internals.
|
||||
//!
|
||||
//! Most items in this module are no-ops in release builds. They are activated by
|
||||
//! the `logging-base`, `logging-json`, and `logging-list` Cargo features so that
|
||||
//! debug output can be toggled without changing production code.
|
||||
|
||||
use crate::{
|
||||
json_crdt::{BaseCrdt, CrdtNode, SignedOp},
|
||||
keypair::SignedDigest,
|
||||
@@ -37,6 +43,7 @@ fn display_op_id<T: CrdtNode>(op: &Op<T>) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Log a type-mismatch warning when deserialising a JSON value into a CRDT node fails.
|
||||
pub fn debug_type_mismatch(_msg: String) {
|
||||
#[cfg(feature = "logging-base")]
|
||||
{
|
||||
@@ -44,6 +51,7 @@ pub fn debug_type_mismatch(_msg: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a path-mismatch warning when an operation's path does not match the CRDT's path.
|
||||
pub fn debug_path_mismatch(_our_path: Vec<PathSegment>, _op_path: Vec<PathSegment>) {
|
||||
#[cfg(feature = "logging-base")]
|
||||
{
|
||||
@@ -56,6 +64,7 @@ pub fn debug_path_mismatch(_our_path: Vec<PathSegment>, _op_path: Vec<PathSegmen
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a warning when an operation is applied to a primitive (terminal) CRDT node.
|
||||
pub fn debug_op_on_primitive(_op_path: Vec<PathSegment>) {
|
||||
#[cfg(feature = "logging-base")]
|
||||
{
|
||||
@@ -79,16 +88,20 @@ fn display_author(author: AuthorId) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Render CRDT state as an indented human-readable string for debugging.
|
||||
pub trait DebugView {
|
||||
/// Return a multi-line debug string for this CRDT node, indented by `indent` spaces.
|
||||
fn debug_view(&self, indent: usize) -> String;
|
||||
}
|
||||
|
||||
impl<T: CrdtNode + DebugView> BaseCrdt<T> {
|
||||
/// Print the current document state as an indented debug tree (no-op in release builds).
|
||||
pub fn debug_view(&self) {
|
||||
#[cfg(feature = "logging-json")]
|
||||
println!("document is now:\n{}", self.doc.debug_view(0));
|
||||
}
|
||||
|
||||
/// Log an attempt to apply `op` before the result is known (no-op in release builds).
|
||||
pub fn log_try_apply(&self, _op: &SignedOp) {
|
||||
#[cfg(feature = "logging-json")]
|
||||
println!(
|
||||
@@ -99,6 +112,7 @@ impl<T: CrdtNode + DebugView> BaseCrdt<T> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Log a signature-digest verification failure for `op` (no-op in release builds).
|
||||
pub fn debug_digest_failure(&self, _op: SignedOp) {
|
||||
#[cfg(feature = "logging-json")]
|
||||
println!(
|
||||
@@ -108,6 +122,7 @@ impl<T: CrdtNode + DebugView> BaseCrdt<T> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Log that a causal dependency identified by `missing` has not yet been received.
|
||||
pub fn log_missing_causal_dep(&self, _missing: &SignedDigest) {
|
||||
#[cfg(feature = "logging-json")]
|
||||
println!(
|
||||
@@ -117,6 +132,7 @@ impl<T: CrdtNode + DebugView> BaseCrdt<T> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Log that `op` is about to be integrated into the document (no-op in release builds).
|
||||
pub fn log_actually_apply(&self, _op: &SignedOp) {
|
||||
#[cfg(feature = "logging-json")]
|
||||
{
|
||||
@@ -133,6 +149,7 @@ impl<T> Op<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
/// Log an operation hash verification failure showing expected and computed IDs.
|
||||
pub fn debug_hash_failure(&self) {
|
||||
#[cfg(feature = "logging-base")]
|
||||
{
|
||||
@@ -191,6 +208,7 @@ impl<T> ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
/// Print the full operation log as a tree, optionally highlighting one operation (no-op in release builds).
|
||||
pub fn log_ops(&self, _highlight: Option<OpId>) {
|
||||
#[cfg(feature = "logging-list")]
|
||||
{
|
||||
@@ -289,6 +307,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Log the insert or delete being performed for `op` (no-op in release builds).
|
||||
pub fn log_apply(&self, _op: &Op<T>) {
|
||||
#[cfg(feature = "logging-list")]
|
||||
{
|
||||
|
||||
@@ -1,947 +0,0 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fmt::Display,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
debug::{debug_op_on_primitive, DebugView},
|
||||
keypair::{sha256, sign, AuthorId, SignedDigest},
|
||||
list_crdt::ListCrdt,
|
||||
lww_crdt::LwwRegisterCrdt,
|
||||
op::{print_hex, print_path, Hashable, Op, OpId, PathSegment},
|
||||
};
|
||||
pub use bft_crdt_derive::*;
|
||||
use fastcrypto::traits::VerifyingKey;
|
||||
use fastcrypto::{
|
||||
ed25519::{Ed25519KeyPair, Ed25519PublicKey, Ed25519Signature},
|
||||
traits::{KeyPair, ToFromBytes},
|
||||
// Verifier,
|
||||
};
|
||||
// TODO: serde's json object serialization and deserialization (correctly) do not define anything
|
||||
// object field order in JSON objects. However, the hash check impl in bft-json-bft-crdt does take order
|
||||
// into account. This is going to cause problems later for non-Rust implementations, BFT hash checking
|
||||
// currently depends on JSON serialization/deserialization object order. This shouldn't be the case
|
||||
// but I've hacked in an IndexMap for the moment to get the PoC working. To see the problem, replace this with
|
||||
// a std HashMap, everything will screw up (annoyingly, only *most* of the time).
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{serde_as, Bytes};
|
||||
|
||||
/// Anything that can be nested in a JSON CRDT
|
||||
pub trait CrdtNode: CrdtNodeFromValue + Hashable + Clone {
|
||||
/// Create a new CRDT of this type
|
||||
fn new(id: AuthorId, path: Vec<PathSegment>) -> Self;
|
||||
/// Apply an operation to this CRDT, forwarding if necessary
|
||||
fn apply(&mut self, op: Op<JsonValue>) -> OpState;
|
||||
/// Get a JSON representation of the value in this node
|
||||
fn view(&self) -> JsonValue;
|
||||
}
|
||||
|
||||
/// Enum representing possible outcomes of applying an operation to a CRDT
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum OpState {
|
||||
/// Operation applied successfully
|
||||
Ok,
|
||||
/// Tried to apply an operation to a non-CRDT primitive (i.e. f64, bool, etc.)
|
||||
/// If you would like a mutable primitive, wrap it in a [`LWWRegisterCRDT`]
|
||||
ErrApplyOnPrimitive,
|
||||
/// Tried to apply an operation to a static struct CRDT
|
||||
/// If you would like a mutable object, use a [`Value`]
|
||||
ErrApplyOnStruct,
|
||||
/// Tried to apply an operation that contains content of the wrong type.
|
||||
/// In other words, the content cannot be coerced to the CRDT at the path specified.
|
||||
ErrMismatchedType,
|
||||
/// The signed digest of the message did not match the claimed author of the message.
|
||||
/// This can happen if the message was tampered with during delivery
|
||||
ErrDigestMismatch,
|
||||
/// The hash of the message did not match the contents of the message.
|
||||
/// This can happen if the author tried to perform an equivocation attack by creating an
|
||||
/// operation and modifying it has already been created
|
||||
ErrHashMismatch,
|
||||
/// Tried to apply an operation to a non-existent path. The author may have forgotten to attach
|
||||
/// a causal dependency
|
||||
ErrPathMismatch,
|
||||
/// Trying to modify/delete the sentinel (zero-th) node element that is used for book-keeping
|
||||
ErrListApplyToEmpty,
|
||||
/// We have not received all of the causal dependencies of this operation. It has been queued
|
||||
/// up and will be executed when its causal dependencies have been delivered
|
||||
MissingCausalDependencies,
|
||||
/// This op has already been applied (identified by its `signed_digest`).
|
||||
/// The CRDT state is unchanged — this is a no-op (idempotent self-loop guard).
|
||||
AlreadySeen,
|
||||
}
|
||||
|
||||
/// Maximum total number of ops that may sit in the causal-order hold queue at any
|
||||
/// one time, summed across all pending dependency buckets.
|
||||
///
|
||||
/// **Overflow policy: drop oldest.**
|
||||
/// When the limit is reached, the oldest pending op in the largest dependency bucket
|
||||
/// is silently evicted before the new op is queued. Rationale: a misbehaving or
|
||||
/// heavily-partitioned peer can send ops whose causal ancestors never arrive, causing
|
||||
/// unbounded memory growth. Dropping the oldest entry preserves the most recent
|
||||
/// information and caps memory use. The peer can reconnect and receive a fresh bulk
|
||||
/// state dump to recover any dropped ops.
|
||||
pub const CAUSAL_QUEUE_MAX: usize = 256;
|
||||
|
||||
/// The following types can be used as a 'terminal' type in CRDTs
|
||||
pub trait MarkPrimitive: Into<JsonValue> + Default {}
|
||||
impl MarkPrimitive for bool {}
|
||||
impl MarkPrimitive for i32 {}
|
||||
impl MarkPrimitive for i64 {}
|
||||
impl MarkPrimitive for f64 {}
|
||||
impl MarkPrimitive for char {}
|
||||
impl MarkPrimitive for String {}
|
||||
impl MarkPrimitive for JsonValue {}
|
||||
|
||||
/// Implement CrdtNode for non-CRDTs
|
||||
/// This is a stub implementation so most functions don't do anything/log an error
|
||||
impl<T> CrdtNode for T
|
||||
where
|
||||
T: CrdtNodeFromValue + MarkPrimitive + Hashable + Clone,
|
||||
{
|
||||
fn apply(&mut self, _op: Op<JsonValue>) -> OpState {
|
||||
OpState::ErrApplyOnPrimitive
|
||||
}
|
||||
|
||||
fn view(&self) -> JsonValue {
|
||||
self.to_owned().into()
|
||||
}
|
||||
|
||||
fn new(_id: AuthorId, _path: Vec<PathSegment>) -> Self {
|
||||
debug_op_on_primitive(_path);
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The base struct for a JSON CRDT. Allows for declaring causal
|
||||
/// dependencies across fields. It only accepts messages of [`SignedOp`] for BFT.
|
||||
pub struct BaseCrdt<T: CrdtNode> {
|
||||
/// Public key of this CRDT
|
||||
pub id: AuthorId,
|
||||
|
||||
/// Internal base CRDT
|
||||
pub doc: T,
|
||||
|
||||
/// In a real world scenario, this would be a proper hash graph that allows for
|
||||
/// efficient reconciliation of missing dependencies. We naively keep a hash set
|
||||
/// of messages we've seen (represented by their [`SignedDigest`]).
|
||||
received: HashSet<SignedDigest>,
|
||||
message_q: HashMap<SignedDigest, Vec<SignedOp>>,
|
||||
|
||||
/// Total count of ops currently held in [`message_q`] waiting for their causal
|
||||
/// dependencies to be delivered. Used to enforce [`CAUSAL_QUEUE_MAX`].
|
||||
queue_len: usize,
|
||||
}
|
||||
|
||||
/// An [`Op<Value>`] with a few bits of extra metadata
|
||||
#[serde_as]
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct SignedOp {
|
||||
// Note that this can be different from the author of the inner op as the inner op could have been created
|
||||
// by a different person
|
||||
author: AuthorId,
|
||||
/// Signed hash using priv key of author. Effectively [`OpID`] Use this as the ID to figure out what has been delivered already
|
||||
#[serde_as(as = "Bytes")]
|
||||
pub signed_digest: SignedDigest,
|
||||
pub inner: Op<JsonValue>,
|
||||
/// List of causal dependencies
|
||||
#[serde_as(as = "Vec<Bytes>")]
|
||||
pub depends_on: Vec<SignedDigest>,
|
||||
}
|
||||
|
||||
impl SignedOp {
|
||||
pub fn id(&self) -> OpId {
|
||||
self.inner.id
|
||||
}
|
||||
|
||||
pub fn author(&self) -> AuthorId {
|
||||
self.author
|
||||
}
|
||||
|
||||
/// Creates a digest of the following fields. Any changes in the fields will change the signed digest
|
||||
/// - id (hash of the following)
|
||||
/// - origin
|
||||
/// - author
|
||||
/// - seq
|
||||
/// - is_deleted
|
||||
/// - path
|
||||
/// - dependencies
|
||||
fn digest(&self) -> [u8; 32] {
|
||||
let path_string = print_path(self.inner.path.clone());
|
||||
let dependency_string = self
|
||||
.depends_on
|
||||
.iter()
|
||||
.map(print_hex)
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
let fmt_str = format!("{:?},{path_string},{dependency_string}", self.id());
|
||||
sha256(fmt_str)
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign a normal op and add all the needed metadata
|
||||
pub fn from_op<T: CrdtNode>(
|
||||
value: Op<T>,
|
||||
keypair: &Ed25519KeyPair,
|
||||
depends_on: Vec<SignedDigest>,
|
||||
) -> Self {
|
||||
let author = keypair.public().0.to_bytes();
|
||||
let mut new = Self {
|
||||
inner: Op {
|
||||
content: value.content.map(|c| c.view()),
|
||||
origin: value.origin,
|
||||
author: value.author,
|
||||
seq: value.seq,
|
||||
path: value.path,
|
||||
is_deleted: value.is_deleted,
|
||||
id: value.id,
|
||||
},
|
||||
author,
|
||||
signed_digest: [0u8; 64],
|
||||
depends_on,
|
||||
};
|
||||
new.sign_digest(keypair);
|
||||
new
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: CrdtNode + DebugView> BaseCrdt<T> {
|
||||
/// Create a new BaseCRDT of the given type. Multiple BaseCRDTs
|
||||
/// can be created from a single keypair but you are responsible for
|
||||
/// 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();
|
||||
Self {
|
||||
id,
|
||||
doc: T::new(id, vec![]),
|
||||
received: HashSet::new(),
|
||||
message_q: HashMap::new(),
|
||||
queue_len: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a signed operation to this BaseCRDT, verifying integrity and routing to the right
|
||||
/// nested CRDT
|
||||
pub fn apply(&mut self, op: SignedOp) -> OpState {
|
||||
// self.log_try_apply(&op);
|
||||
|
||||
#[cfg(feature = "bft")]
|
||||
if !op.is_valid_digest() {
|
||||
self.debug_digest_failure(op);
|
||||
return OpState::ErrDigestMismatch;
|
||||
}
|
||||
|
||||
let op_id = op.signed_digest;
|
||||
|
||||
// Self-loop / dedup guard: if we have already processed this op (identified by
|
||||
// its signed_digest), return immediately without re-applying it. This prevents
|
||||
// echo loops where an op we broadcast to a peer comes back to us.
|
||||
if self.received.contains(&op_id) {
|
||||
return OpState::AlreadySeen;
|
||||
}
|
||||
|
||||
if !op.depends_on.is_empty() {
|
||||
for origin in &op.depends_on {
|
||||
if !self.received.contains(origin) {
|
||||
self.log_missing_causal_dep(origin);
|
||||
|
||||
// Bounded queue overflow: evict the oldest op from the largest
|
||||
// pending bucket before adding the new one. See CAUSAL_QUEUE_MAX.
|
||||
if self.queue_len >= CAUSAL_QUEUE_MAX {
|
||||
if let Some(bucket) = self.message_q.values_mut().max_by_key(|v| v.len()) {
|
||||
if !bucket.is_empty() {
|
||||
bucket.remove(0);
|
||||
self.queue_len = self.queue_len.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.message_q.entry(*origin).or_default().push(op);
|
||||
self.queue_len += 1;
|
||||
return OpState::MissingCausalDependencies;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apply
|
||||
// self.log_actually_apply(&op);
|
||||
let status = self.doc.apply(op.inner);
|
||||
// self.debug_view();
|
||||
|
||||
// Only record the op as seen when it applied successfully. If the op
|
||||
// was rejected (e.g. ErrHashMismatch from a tampered payload), we must
|
||||
// NOT add its signed_digest to `received`: a legitimate op that shares
|
||||
// the same signed_digest (e.g. the un-tampered original) would otherwise
|
||||
// be silently dropped as AlreadySeen.
|
||||
// Only mark as received and unblock dependents when the op was actually
|
||||
// applied. If we insert on error (e.g. ErrHashMismatch), a subsequent
|
||||
// apply of a *legitimate* op with the same signed_digest would be
|
||||
// silently dropped as AlreadySeen, preventing equivocation detection
|
||||
// from working correctly.
|
||||
if status == OpState::Ok {
|
||||
self.received.insert(op_id);
|
||||
|
||||
// apply all of its causal dependents if there are any
|
||||
let dependent_queue = self.message_q.remove(&op_id);
|
||||
if let Some(mut q) = dependent_queue {
|
||||
self.queue_len = self.queue_len.saturating_sub(q.len());
|
||||
for dependent in q.drain(..) {
|
||||
self.apply(dependent);
|
||||
}
|
||||
}
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
/// Number of ops currently held in the causal-order queue waiting for their
|
||||
/// dependencies to be satisfied.
|
||||
pub fn causal_queue_len(&self) -> usize {
|
||||
self.queue_len
|
||||
}
|
||||
}
|
||||
|
||||
/// An enum representing a JSON value
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum JsonValue {
|
||||
Null,
|
||||
Bool(bool),
|
||||
Number(f64),
|
||||
String(String),
|
||||
Array(Vec<JsonValue>),
|
||||
Object(IndexMap<String, JsonValue>),
|
||||
}
|
||||
|
||||
impl Display for JsonValue {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
JsonValue::Null => "null".to_string(),
|
||||
JsonValue::Bool(b) => b.to_string(),
|
||||
JsonValue::Number(n) => n.to_string(),
|
||||
JsonValue::String(s) => format!("\"{s}\""),
|
||||
JsonValue::Array(arr) => {
|
||||
if arr.len() > 1 {
|
||||
format!(
|
||||
"[\n{}\n]",
|
||||
arr.iter()
|
||||
.map(|x| format!(" {x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n")
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"[ {} ]",
|
||||
arr.iter()
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
JsonValue::Object(obj) => format!(
|
||||
"{{ {} }}",
|
||||
obj.iter()
|
||||
.map(|(k, v)| format!(" \"{k}\": {v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n")
|
||||
),
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
fn from(value: JsonValue) -> Self {
|
||||
match value {
|
||||
JsonValue::Null => serde_json::Value::Null,
|
||||
JsonValue::Bool(x) => serde_json::Value::Bool(x),
|
||||
JsonValue::Number(x) => {
|
||||
serde_json::Value::Number(serde_json::Number::from_f64(x).unwrap())
|
||||
}
|
||||
JsonValue::String(x) => serde_json::Value::String(x),
|
||||
JsonValue::Array(x) => {
|
||||
serde_json::Value::Array(x.iter().map(|a| a.clone().into()).collect())
|
||||
}
|
||||
JsonValue::Object(x) => serde_json::Value::Object(
|
||||
x.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone().into()))
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Value> for JsonValue {
|
||||
fn from(value: serde_json::Value) -> Self {
|
||||
match value {
|
||||
serde_json::Value::Null => JsonValue::Null,
|
||||
serde_json::Value::Bool(x) => JsonValue::Bool(x),
|
||||
serde_json::Value::Number(x) => JsonValue::Number(x.as_f64().unwrap()),
|
||||
serde_json::Value::String(x) => JsonValue::String(x),
|
||||
serde_json::Value::Array(x) => {
|
||||
JsonValue::Array(x.iter().map(|a| a.clone().into()).collect())
|
||||
}
|
||||
serde_json::Value::Object(x) => JsonValue::Object(
|
||||
x.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone().into()))
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonValue {
|
||||
pub fn into_json(self) -> serde_json::Value {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from primitive types to [`JsonValue`]
|
||||
impl From<bool> for JsonValue {
|
||||
fn from(val: bool) -> Self {
|
||||
JsonValue::Bool(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for JsonValue {
|
||||
fn from(val: i64) -> Self {
|
||||
JsonValue::Number(val as f64)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i32> for JsonValue {
|
||||
fn from(val: i32) -> Self {
|
||||
JsonValue::Number(val as f64)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for JsonValue {
|
||||
fn from(val: f64) -> Self {
|
||||
JsonValue::Number(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for JsonValue {
|
||||
fn from(val: String) -> Self {
|
||||
JsonValue::String(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<char> for JsonValue {
|
||||
fn from(val: char) -> Self {
|
||||
JsonValue::String(val.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<Option<T>> for JsonValue
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn from(val: Option<T>) -> Self {
|
||||
match val {
|
||||
Some(x) => x.view(),
|
||||
None => JsonValue::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<Vec<T>> for JsonValue
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn from(value: Vec<T>) -> Self {
|
||||
JsonValue::Array(value.iter().map(|x| x.view()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallibly create a CRDT Node from a JSON Value
|
||||
pub trait CrdtNodeFromValue: Sized {
|
||||
fn node_from(value: JsonValue, id: AuthorId, path: Vec<PathSegment>) -> Result<Self, String>;
|
||||
}
|
||||
|
||||
/// Fallibly cast a JSON Value into a CRDT Node
|
||||
pub trait IntoCrdtNode<T>: Sized {
|
||||
fn into_node(self, id: AuthorId, path: Vec<PathSegment>) -> Result<T, String>;
|
||||
}
|
||||
|
||||
/// [`CrdtNodeFromValue`] implies [`IntoCrdtNode<T>`]
|
||||
impl<T> IntoCrdtNode<T> for JsonValue
|
||||
where
|
||||
T: CrdtNodeFromValue,
|
||||
{
|
||||
fn into_node(self, id: AuthorId, path: Vec<PathSegment>) -> Result<T, String> {
|
||||
T::node_from(self, id, path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trivial conversion from [`JsonValue`] to [`JsonValue`] as [`CrdtNodeFromValue`]
|
||||
impl CrdtNodeFromValue for JsonValue {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from bool to CRDT
|
||||
impl CrdtNodeFromValue for bool {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::Bool(x) = value {
|
||||
Ok(x)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> bool"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from f64 to CRDT
|
||||
impl CrdtNodeFromValue for f64 {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::Number(x) = value {
|
||||
Ok(x)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> f64"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from i64 to CRDT
|
||||
impl CrdtNodeFromValue for i64 {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::Number(x) = value {
|
||||
Ok(x as i64)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> f64"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from String to CRDT
|
||||
impl CrdtNodeFromValue for String {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::String(x) = value {
|
||||
Ok(x)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> String"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from char to CRDT
|
||||
impl CrdtNodeFromValue for char {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::String(x) = value.clone() {
|
||||
x.chars().next().ok_or(format!(
|
||||
"failed to convert {value:?} -> char: found a zero-length string"
|
||||
))
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> char"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CrdtNodeFromValue for LwwRegisterCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn node_from(value: JsonValue, id: AuthorId, path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
let mut crdt = LwwRegisterCrdt::new(id, path);
|
||||
crdt.set(value);
|
||||
Ok(crdt)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CrdtNodeFromValue for ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn node_from(value: JsonValue, id: AuthorId, path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::Array(arr) = value {
|
||||
let mut crdt = ListCrdt::new(id, path);
|
||||
let result: Result<(), String> =
|
||||
arr.into_iter().enumerate().try_for_each(|(i, val)| {
|
||||
crdt.insert_idx(i, val);
|
||||
Ok(())
|
||||
});
|
||||
result?;
|
||||
Ok(crdt)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> ListCRDT<T>"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
json_crdt::{add_crdt_fields, BaseCrdt, CrdtNode, IntoCrdtNode, JsonValue, OpState},
|
||||
keypair::make_keypair,
|
||||
list_crdt::ListCrdt,
|
||||
lww_crdt::LwwRegisterCrdt,
|
||||
op::{print_path, ROOT_ID},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_derive_basic() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Player {
|
||||
x: LwwRegisterCrdt<f64>,
|
||||
y: LwwRegisterCrdt<f64>,
|
||||
}
|
||||
|
||||
let keypair = make_keypair();
|
||||
let crdt = BaseCrdt::<Player>::new(&keypair);
|
||||
assert_eq!(print_path(crdt.doc.x.path), "x");
|
||||
assert_eq!(print_path(crdt.doc.y.path), "y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_nested() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Position {
|
||||
x: LwwRegisterCrdt<f64>,
|
||||
y: LwwRegisterCrdt<f64>,
|
||||
}
|
||||
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Player {
|
||||
pos: Position,
|
||||
balance: LwwRegisterCrdt<f64>,
|
||||
messages: ListCrdt<String>,
|
||||
}
|
||||
|
||||
let keypair = make_keypair();
|
||||
let crdt = BaseCrdt::<Player>::new(&keypair);
|
||||
assert_eq!(print_path(crdt.doc.pos.x.path), "pos.x");
|
||||
assert_eq!(print_path(crdt.doc.pos.y.path), "pos.y");
|
||||
assert_eq!(print_path(crdt.doc.balance.path), "balance");
|
||||
assert_eq!(print_path(crdt.doc.messages.path), "messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lww_ops() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Test {
|
||||
a: LwwRegisterCrdt<f64>,
|
||||
b: LwwRegisterCrdt<bool>,
|
||||
c: LwwRegisterCrdt<String>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let kp2 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Test>::new(&kp1);
|
||||
let mut base2 = BaseCrdt::<Test>::new(&kp2);
|
||||
|
||||
let _1_a_1 = base1.doc.a.set(3.0).sign(&kp1);
|
||||
let _1_b_1 = base1.doc.b.set(true).sign(&kp1);
|
||||
let _2_a_1 = base2.doc.a.set(1.5).sign(&kp2);
|
||||
let _2_a_2 = base2.doc.a.set(2.13).sign(&kp2);
|
||||
let _2_c_1 = base2.doc.c.set("abc".to_string()).sign(&kp2);
|
||||
|
||||
assert_eq!(base1.doc.a.view(), json!(3.0).into());
|
||||
assert_eq!(base2.doc.a.view(), json!(2.13).into());
|
||||
assert_eq!(base1.doc.b.view(), json!(true).into());
|
||||
assert_eq!(base2.doc.c.view(), json!("abc").into());
|
||||
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"a": 3.0,
|
||||
"b": true,
|
||||
"c": null,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
base2.doc.view().into_json(),
|
||||
json!({
|
||||
"a": 2.13,
|
||||
"b": null,
|
||||
"c": "abc",
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(base2.apply(_1_a_1), OpState::Ok);
|
||||
assert_eq!(base2.apply(_1_b_1), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2_a_1), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2_a_2), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2_c_1), OpState::Ok);
|
||||
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"a": 2.13,
|
||||
"b": true,
|
||||
"c": "abc"
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vec_and_map_ops() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Test {
|
||||
a: ListCrdt<String>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let kp2 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Test>::new(&kp1);
|
||||
let mut base2 = BaseCrdt::<Test>::new(&kp2);
|
||||
|
||||
let _1a = base1.doc.a.insert(ROOT_ID, "a".to_string()).sign(&kp1);
|
||||
let _1b = base1.doc.a.insert(_1a.id(), "b".to_string()).sign(&kp1);
|
||||
let _2c = base2.doc.a.insert(ROOT_ID, "c".to_string()).sign(&kp2);
|
||||
let _2d = base2.doc.a.insert(_1b.id(), "d".to_string()).sign(&kp2);
|
||||
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"a": ["a", "b"],
|
||||
})
|
||||
);
|
||||
|
||||
// as _1b hasn't been delivered to base2 yet
|
||||
assert_eq!(
|
||||
base2.doc.view().into_json(),
|
||||
json!({
|
||||
"a": ["c"],
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(base2.apply(_1b), OpState::MissingCausalDependencies);
|
||||
assert_eq!(base2.apply(_1a), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2d), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2c), OpState::Ok);
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_causal_field_dependency() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Item {
|
||||
name: LwwRegisterCrdt<String>,
|
||||
soulbound: LwwRegisterCrdt<bool>,
|
||||
}
|
||||
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Player {
|
||||
inventory: ListCrdt<Item>,
|
||||
balance: LwwRegisterCrdt<f64>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let kp2 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Player>::new(&kp1);
|
||||
let mut base2 = BaseCrdt::<Player>::new(&kp2);
|
||||
|
||||
// require balance update to happen before inventory update
|
||||
let _add_money = base1.doc.balance.set(5000.0).sign(&kp1);
|
||||
let _spend_money = base1
|
||||
.doc
|
||||
.balance
|
||||
.set(3000.0)
|
||||
.sign_with_dependencies(&kp1, vec![&_add_money]);
|
||||
|
||||
let sword: JsonValue = json!({
|
||||
"name": "Sword",
|
||||
"soulbound": true,
|
||||
})
|
||||
.into();
|
||||
let _new_inventory_item = base1
|
||||
.doc
|
||||
.inventory
|
||||
.insert_idx(0, sword)
|
||||
.sign_with_dependencies(&kp1, vec![&_spend_money]);
|
||||
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"balance": 3000.0,
|
||||
"inventory": [
|
||||
{
|
||||
"name": "Sword",
|
||||
"soulbound": true
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
// do it completely out of order
|
||||
assert_eq!(
|
||||
base2.apply(_new_inventory_item),
|
||||
OpState::MissingCausalDependencies
|
||||
);
|
||||
assert_eq!(
|
||||
base2.apply(_spend_money),
|
||||
OpState::MissingCausalDependencies
|
||||
);
|
||||
assert_eq!(base2.apply(_add_money), OpState::Ok);
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_2d_grid() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Game {
|
||||
grid: ListCrdt<ListCrdt<LwwRegisterCrdt<bool>>>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let kp2 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Game>::new(&kp1);
|
||||
let mut base2 = BaseCrdt::<Game>::new(&kp2);
|
||||
|
||||
// init a 2d grid
|
||||
let row0: JsonValue = json!([true, false]).into();
|
||||
let row1: JsonValue = json!([false, true]).into();
|
||||
let construct1 = base1.doc.grid.insert_idx(0, row0).sign(&kp1);
|
||||
let construct2 = base1.doc.grid.insert_idx(1, row1).sign(&kp1);
|
||||
|
||||
assert_eq!(base2.apply(construct1), OpState::Ok);
|
||||
assert_eq!(base2.apply(construct2.clone()), OpState::Ok);
|
||||
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"grid": [[true, false], [false, true]]
|
||||
})
|
||||
);
|
||||
|
||||
let set1 = base1.doc.grid[0][0].set(false).sign(&kp1);
|
||||
let set2 = base2.doc.grid[1][1].set(false).sign(&kp2);
|
||||
assert_eq!(base1.apply(set2), OpState::Ok);
|
||||
assert_eq!(base2.apply(set1), OpState::Ok);
|
||||
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"grid": [[false, false], [false, false]]
|
||||
})
|
||||
);
|
||||
|
||||
let topright = base1.doc.grid[0].id_at(1).unwrap();
|
||||
base1.doc.grid[0].delete(topright);
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"grid": [[false], [false, false]]
|
||||
})
|
||||
);
|
||||
|
||||
base1.doc.grid.delete(construct2.id());
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"grid": [[false]]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arb_json() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Test {
|
||||
reg: LwwRegisterCrdt<JsonValue>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Test>::new(&kp1);
|
||||
|
||||
let base_val: JsonValue = json!({
|
||||
"a": true,
|
||||
"b": "asdf",
|
||||
"c": {
|
||||
"d": [],
|
||||
"e": [ false ]
|
||||
}
|
||||
})
|
||||
.into();
|
||||
base1.doc.reg.set(base_val).sign(&kp1);
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"reg": {
|
||||
"a": true,
|
||||
"b": "asdf",
|
||||
"c": {
|
||||
"d": [],
|
||||
"e": [ false ]
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_json_types() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Nested {
|
||||
list: ListCrdt<f64>,
|
||||
}
|
||||
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Test {
|
||||
reg: LwwRegisterCrdt<bool>,
|
||||
strct: ListCrdt<Nested>,
|
||||
}
|
||||
|
||||
let key = make_keypair();
|
||||
let mut crdt = BaseCrdt::<Test>::new(&key);
|
||||
|
||||
// wrong type should not go through
|
||||
crdt.doc.reg.set(32);
|
||||
assert_eq!(crdt.doc.reg.view(), json!(null).into());
|
||||
crdt.doc.reg.set(true);
|
||||
assert_eq!(crdt.doc.reg.view(), json!(true).into());
|
||||
|
||||
// set nested
|
||||
let mut list_view: JsonValue = crdt.doc.strct.view().into();
|
||||
assert_eq!(list_view, json!([]).into());
|
||||
|
||||
// only keeps actual numbers
|
||||
let list: JsonValue = json!({"list": [0, 123, -0.45, "char", []]}).into();
|
||||
crdt.doc.strct.insert_idx(0, list);
|
||||
list_view = crdt.doc.strct.view().into();
|
||||
assert_eq!(list_view, json!([{ "list": [0, 123, -0.45]}]).into());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! [`BaseCrdt`] — the top-level causal-delivery wrapper around any [`CrdtNode`].
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use fastcrypto::ed25519::Ed25519KeyPair;
|
||||
use fastcrypto::traits::KeyPair;
|
||||
|
||||
use crate::debug::DebugView;
|
||||
use crate::keypair::SignedDigest;
|
||||
|
||||
use super::{CrdtNode, OpState, SignedOp, CAUSAL_QUEUE_MAX};
|
||||
|
||||
/// The base struct for a JSON CRDT. Allows for declaring causal
|
||||
/// dependencies across fields. It only accepts messages of [`SignedOp`] for BFT.
|
||||
pub struct BaseCrdt<T: CrdtNode> {
|
||||
/// Public key of this CRDT
|
||||
pub id: crate::keypair::AuthorId,
|
||||
|
||||
/// Internal base CRDT
|
||||
pub doc: T,
|
||||
|
||||
/// In a real world scenario, this would be a proper hash graph that allows for
|
||||
/// efficient reconciliation of missing dependencies. We naively keep a hash set
|
||||
/// of messages we've seen (represented by their [`SignedDigest`]).
|
||||
received: HashSet<SignedDigest>,
|
||||
message_q: HashMap<SignedDigest, Vec<SignedOp>>,
|
||||
|
||||
/// Total count of ops currently held in [`message_q`] waiting for their causal
|
||||
/// dependencies to be delivered. Used to enforce [`CAUSAL_QUEUE_MAX`].
|
||||
queue_len: usize,
|
||||
}
|
||||
|
||||
impl<T: CrdtNode + DebugView> BaseCrdt<T> {
|
||||
/// Create a new BaseCRDT of the given type. Multiple BaseCRDTs
|
||||
/// can be created from a single keypair but you are responsible for
|
||||
/// 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();
|
||||
Self {
|
||||
id,
|
||||
doc: T::new(id, vec![]),
|
||||
received: HashSet::new(),
|
||||
message_q: HashMap::new(),
|
||||
queue_len: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a signed operation to this BaseCRDT, verifying integrity and routing to the right
|
||||
/// nested CRDT
|
||||
pub fn apply(&mut self, op: SignedOp) -> OpState {
|
||||
// self.log_try_apply(&op);
|
||||
|
||||
#[cfg(feature = "bft")]
|
||||
if !op.is_valid_digest() {
|
||||
self.debug_digest_failure(op);
|
||||
return OpState::ErrDigestMismatch;
|
||||
}
|
||||
|
||||
let op_id = op.signed_digest;
|
||||
|
||||
// Self-loop / dedup guard: if we have already processed this op (identified by
|
||||
// its signed_digest), return immediately without re-applying it. This prevents
|
||||
// echo loops where an op we broadcast to a peer comes back to us.
|
||||
if self.received.contains(&op_id) {
|
||||
return OpState::AlreadySeen;
|
||||
}
|
||||
|
||||
if !op.depends_on.is_empty() {
|
||||
for origin in &op.depends_on {
|
||||
if !self.received.contains(origin) {
|
||||
self.log_missing_causal_dep(origin);
|
||||
|
||||
// Bounded queue overflow: evict the oldest op from the largest
|
||||
// pending bucket before adding the new one. See CAUSAL_QUEUE_MAX.
|
||||
if self.queue_len >= CAUSAL_QUEUE_MAX {
|
||||
if let Some(bucket) = self.message_q.values_mut().max_by_key(|v| v.len()) {
|
||||
if !bucket.is_empty() {
|
||||
bucket.remove(0);
|
||||
self.queue_len = self.queue_len.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.message_q.entry(*origin).or_default().push(op);
|
||||
self.queue_len += 1;
|
||||
return OpState::MissingCausalDependencies;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apply
|
||||
// self.log_actually_apply(&op);
|
||||
let status = self.doc.apply(op.inner);
|
||||
// self.debug_view();
|
||||
|
||||
// Only record the op as seen when it applied successfully. If the op
|
||||
// was rejected (e.g. ErrHashMismatch from a tampered payload), we must
|
||||
// NOT add its signed_digest to `received`: a legitimate op that shares
|
||||
// the same signed_digest (e.g. the un-tampered original) would otherwise
|
||||
// be silently dropped as AlreadySeen.
|
||||
// Only mark as received and unblock dependents when the op was actually
|
||||
// applied. If we insert on error (e.g. ErrHashMismatch), a subsequent
|
||||
// apply of a *legitimate* op with the same signed_digest would be
|
||||
// silently dropped as AlreadySeen, preventing equivocation detection
|
||||
// from working correctly.
|
||||
if status == OpState::Ok {
|
||||
self.received.insert(op_id);
|
||||
|
||||
// apply all of its causal dependents if there are any
|
||||
let dependent_queue = self.message_q.remove(&op_id);
|
||||
if let Some(mut q) = dependent_queue {
|
||||
self.queue_len = self.queue_len.saturating_sub(q.len());
|
||||
for dependent in q.drain(..) {
|
||||
self.apply(dependent);
|
||||
}
|
||||
}
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
/// Number of ops currently held in the causal-order queue waiting for their
|
||||
/// dependencies to be satisfied.
|
||||
pub fn causal_queue_len(&self) -> usize {
|
||||
self.queue_len
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
//! JSON CRDT public interface: core traits, re-exports, and integration tests.
|
||||
// TODO: serde's json object serialization and deserialization (correctly) do not define anything
|
||||
// object field order in JSON objects. However, the hash check impl in bft-json-bft-crdt does take order
|
||||
// into account. This is going to cause problems later for non-Rust implementations, BFT hash checking
|
||||
// currently depends on JSON serialization/deserialization object order. This shouldn't be the case
|
||||
// but I've hacked in an IndexMap for the moment to get the PoC working. To see the problem, replace this with
|
||||
// a std HashMap, everything will screw up (annoyingly, only *most* of the time).
|
||||
|
||||
use crate::debug::debug_op_on_primitive;
|
||||
use crate::keypair::AuthorId;
|
||||
use crate::op::{Hashable, Op, PathSegment};
|
||||
|
||||
pub use bft_crdt_derive::*;
|
||||
|
||||
mod base;
|
||||
mod signed_op;
|
||||
mod value;
|
||||
|
||||
pub use base::BaseCrdt;
|
||||
pub use signed_op::{OpState, SignedOp, CAUSAL_QUEUE_MAX};
|
||||
pub use value::JsonValue;
|
||||
|
||||
/// Anything that can be nested in a JSON CRDT
|
||||
pub trait CrdtNode: CrdtNodeFromValue + Hashable + Clone {
|
||||
/// Create a new CRDT of this type
|
||||
fn new(id: AuthorId, path: Vec<PathSegment>) -> Self;
|
||||
/// Apply an operation to this CRDT, forwarding if necessary
|
||||
fn apply(&mut self, op: Op<JsonValue>) -> OpState;
|
||||
/// Get a JSON representation of the value in this node
|
||||
fn view(&self) -> JsonValue;
|
||||
}
|
||||
|
||||
/// The following types can be used as a 'terminal' type in CRDTs
|
||||
pub trait MarkPrimitive: Into<JsonValue> + Default {}
|
||||
impl MarkPrimitive for bool {}
|
||||
impl MarkPrimitive for i32 {}
|
||||
impl MarkPrimitive for i64 {}
|
||||
impl MarkPrimitive for f64 {}
|
||||
impl MarkPrimitive for char {}
|
||||
impl MarkPrimitive for String {}
|
||||
impl MarkPrimitive for JsonValue {}
|
||||
|
||||
/// Implement CrdtNode for non-CRDTs
|
||||
/// This is a stub implementation so most functions don't do anything/log an error
|
||||
impl<T> CrdtNode for T
|
||||
where
|
||||
T: CrdtNodeFromValue + MarkPrimitive + Hashable + Clone,
|
||||
{
|
||||
fn apply(&mut self, _op: Op<JsonValue>) -> OpState {
|
||||
OpState::ErrApplyOnPrimitive
|
||||
}
|
||||
|
||||
fn view(&self) -> JsonValue {
|
||||
self.to_owned().into()
|
||||
}
|
||||
|
||||
fn new(_id: AuthorId, _path: Vec<PathSegment>) -> Self {
|
||||
debug_op_on_primitive(_path);
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallibly create a CRDT Node from a JSON Value
|
||||
pub trait CrdtNodeFromValue: Sized {
|
||||
fn node_from(value: JsonValue, id: AuthorId, path: Vec<PathSegment>) -> Result<Self, String>;
|
||||
}
|
||||
|
||||
/// Fallibly cast a JSON Value into a CRDT Node
|
||||
pub trait IntoCrdtNode<T>: Sized {
|
||||
fn into_node(self, id: AuthorId, path: Vec<PathSegment>) -> Result<T, String>;
|
||||
}
|
||||
|
||||
/// [`CrdtNodeFromValue`] implies [`IntoCrdtNode<T>`]
|
||||
impl<T> IntoCrdtNode<T> for JsonValue
|
||||
where
|
||||
T: CrdtNodeFromValue,
|
||||
{
|
||||
fn into_node(self, id: AuthorId, path: Vec<PathSegment>) -> Result<T, String> {
|
||||
T::node_from(self, id, path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trivial conversion from [`JsonValue`] to [`JsonValue`] as [`CrdtNodeFromValue`]
|
||||
impl CrdtNodeFromValue for JsonValue {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
json_crdt::{add_crdt_fields, BaseCrdt, CrdtNode, IntoCrdtNode, JsonValue, OpState},
|
||||
keypair::make_keypair,
|
||||
list_crdt::ListCrdt,
|
||||
lww_crdt::LwwRegisterCrdt,
|
||||
op::{print_path, ROOT_ID},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_derive_basic() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Player {
|
||||
x: LwwRegisterCrdt<f64>,
|
||||
y: LwwRegisterCrdt<f64>,
|
||||
}
|
||||
|
||||
let keypair = make_keypair();
|
||||
let crdt = BaseCrdt::<Player>::new(&keypair);
|
||||
assert_eq!(print_path(crdt.doc.x.path), "x");
|
||||
assert_eq!(print_path(crdt.doc.y.path), "y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_nested() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Position {
|
||||
x: LwwRegisterCrdt<f64>,
|
||||
y: LwwRegisterCrdt<f64>,
|
||||
}
|
||||
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Player {
|
||||
pos: Position,
|
||||
balance: LwwRegisterCrdt<f64>,
|
||||
messages: ListCrdt<String>,
|
||||
}
|
||||
|
||||
let keypair = make_keypair();
|
||||
let crdt = BaseCrdt::<Player>::new(&keypair);
|
||||
assert_eq!(print_path(crdt.doc.pos.x.path), "pos.x");
|
||||
assert_eq!(print_path(crdt.doc.pos.y.path), "pos.y");
|
||||
assert_eq!(print_path(crdt.doc.balance.path), "balance");
|
||||
assert_eq!(print_path(crdt.doc.messages.path), "messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lww_ops() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Test {
|
||||
a: LwwRegisterCrdt<f64>,
|
||||
b: LwwRegisterCrdt<bool>,
|
||||
c: LwwRegisterCrdt<String>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let kp2 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Test>::new(&kp1);
|
||||
let mut base2 = BaseCrdt::<Test>::new(&kp2);
|
||||
|
||||
let _1_a_1 = base1.doc.a.set(3.0).sign(&kp1);
|
||||
let _1_b_1 = base1.doc.b.set(true).sign(&kp1);
|
||||
let _2_a_1 = base2.doc.a.set(1.5).sign(&kp2);
|
||||
let _2_a_2 = base2.doc.a.set(2.13).sign(&kp2);
|
||||
let _2_c_1 = base2.doc.c.set("abc".to_string()).sign(&kp2);
|
||||
|
||||
assert_eq!(base1.doc.a.view(), json!(3.0).into());
|
||||
assert_eq!(base2.doc.a.view(), json!(2.13).into());
|
||||
assert_eq!(base1.doc.b.view(), json!(true).into());
|
||||
assert_eq!(base2.doc.c.view(), json!("abc").into());
|
||||
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"a": 3.0,
|
||||
"b": true,
|
||||
"c": null,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
base2.doc.view().into_json(),
|
||||
json!({
|
||||
"a": 2.13,
|
||||
"b": null,
|
||||
"c": "abc",
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(base2.apply(_1_a_1), OpState::Ok);
|
||||
assert_eq!(base2.apply(_1_b_1), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2_a_1), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2_a_2), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2_c_1), OpState::Ok);
|
||||
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"a": 2.13,
|
||||
"b": true,
|
||||
"c": "abc"
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vec_and_map_ops() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Test {
|
||||
a: ListCrdt<String>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let kp2 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Test>::new(&kp1);
|
||||
let mut base2 = BaseCrdt::<Test>::new(&kp2);
|
||||
|
||||
let _1a = base1.doc.a.insert(ROOT_ID, "a".to_string()).sign(&kp1);
|
||||
let _1b = base1.doc.a.insert(_1a.id(), "b".to_string()).sign(&kp1);
|
||||
let _2c = base2.doc.a.insert(ROOT_ID, "c".to_string()).sign(&kp2);
|
||||
let _2d = base2.doc.a.insert(_1b.id(), "d".to_string()).sign(&kp2);
|
||||
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"a": ["a", "b"],
|
||||
})
|
||||
);
|
||||
|
||||
// as _1b hasn't been delivered to base2 yet
|
||||
assert_eq!(
|
||||
base2.doc.view().into_json(),
|
||||
json!({
|
||||
"a": ["c"],
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(base2.apply(_1b), OpState::MissingCausalDependencies);
|
||||
assert_eq!(base2.apply(_1a), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2d), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2c), OpState::Ok);
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_causal_field_dependency() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Item {
|
||||
name: LwwRegisterCrdt<String>,
|
||||
soulbound: LwwRegisterCrdt<bool>,
|
||||
}
|
||||
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Player {
|
||||
inventory: ListCrdt<Item>,
|
||||
balance: LwwRegisterCrdt<f64>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let kp2 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Player>::new(&kp1);
|
||||
let mut base2 = BaseCrdt::<Player>::new(&kp2);
|
||||
|
||||
// require balance update to happen before inventory update
|
||||
let _add_money = base1.doc.balance.set(5000.0).sign(&kp1);
|
||||
let _spend_money = base1
|
||||
.doc
|
||||
.balance
|
||||
.set(3000.0)
|
||||
.sign_with_dependencies(&kp1, vec![&_add_money]);
|
||||
|
||||
let sword: JsonValue = json!({
|
||||
"name": "Sword",
|
||||
"soulbound": true,
|
||||
})
|
||||
.into();
|
||||
let _new_inventory_item = base1
|
||||
.doc
|
||||
.inventory
|
||||
.insert_idx(0, sword)
|
||||
.sign_with_dependencies(&kp1, vec![&_spend_money]);
|
||||
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"balance": 3000.0,
|
||||
"inventory": [
|
||||
{
|
||||
"name": "Sword",
|
||||
"soulbound": true
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
// do it completely out of order
|
||||
assert_eq!(
|
||||
base2.apply(_new_inventory_item),
|
||||
OpState::MissingCausalDependencies
|
||||
);
|
||||
assert_eq!(
|
||||
base2.apply(_spend_money),
|
||||
OpState::MissingCausalDependencies
|
||||
);
|
||||
assert_eq!(base2.apply(_add_money), OpState::Ok);
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_2d_grid() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Game {
|
||||
grid: ListCrdt<ListCrdt<LwwRegisterCrdt<bool>>>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let kp2 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Game>::new(&kp1);
|
||||
let mut base2 = BaseCrdt::<Game>::new(&kp2);
|
||||
|
||||
// init a 2d grid
|
||||
let row0: JsonValue = json!([true, false]).into();
|
||||
let row1: JsonValue = json!([false, true]).into();
|
||||
let construct1 = base1.doc.grid.insert_idx(0, row0).sign(&kp1);
|
||||
let construct2 = base1.doc.grid.insert_idx(1, row1).sign(&kp1);
|
||||
|
||||
assert_eq!(base2.apply(construct1), OpState::Ok);
|
||||
assert_eq!(base2.apply(construct2.clone()), OpState::Ok);
|
||||
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"grid": [[true, false], [false, true]]
|
||||
})
|
||||
);
|
||||
|
||||
let set1 = base1.doc.grid[0][0].set(false).sign(&kp1);
|
||||
let set2 = base2.doc.grid[1][1].set(false).sign(&kp2);
|
||||
assert_eq!(base1.apply(set2), OpState::Ok);
|
||||
assert_eq!(base2.apply(set1), OpState::Ok);
|
||||
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"grid": [[false, false], [false, false]]
|
||||
})
|
||||
);
|
||||
|
||||
let topright = base1.doc.grid[0].id_at(1).unwrap();
|
||||
base1.doc.grid[0].delete(topright);
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"grid": [[false], [false, false]]
|
||||
})
|
||||
);
|
||||
|
||||
base1.doc.grid.delete(construct2.id());
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"grid": [[false]]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arb_json() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Test {
|
||||
reg: LwwRegisterCrdt<JsonValue>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Test>::new(&kp1);
|
||||
|
||||
let base_val: JsonValue = json!({
|
||||
"a": true,
|
||||
"b": "asdf",
|
||||
"c": {
|
||||
"d": [],
|
||||
"e": [ false ]
|
||||
}
|
||||
})
|
||||
.into();
|
||||
base1.doc.reg.set(base_val).sign(&kp1);
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"reg": {
|
||||
"a": true,
|
||||
"b": "asdf",
|
||||
"c": {
|
||||
"d": [],
|
||||
"e": [ false ]
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_json_types() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Nested {
|
||||
list: ListCrdt<f64>,
|
||||
}
|
||||
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Test {
|
||||
reg: LwwRegisterCrdt<bool>,
|
||||
strct: ListCrdt<Nested>,
|
||||
}
|
||||
|
||||
let key = make_keypair();
|
||||
let mut crdt = BaseCrdt::<Test>::new(&key);
|
||||
|
||||
// wrong type should not go through
|
||||
crdt.doc.reg.set(32);
|
||||
assert_eq!(crdt.doc.reg.view(), json!(null).into());
|
||||
crdt.doc.reg.set(true);
|
||||
assert_eq!(crdt.doc.reg.view(), json!(true).into());
|
||||
|
||||
// set nested
|
||||
let mut list_view: JsonValue = crdt.doc.strct.view().into();
|
||||
assert_eq!(list_view, json!([]).into());
|
||||
|
||||
// only keeps actual numbers
|
||||
let list: JsonValue = json!({"list": [0, 123, -0.45, "char", []]}).into();
|
||||
crdt.doc.strct.insert_idx(0, list);
|
||||
list_view = crdt.doc.strct.view().into();
|
||||
assert_eq!(list_view, json!([{ "list": [0, 123, -0.45]}]).into());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! [`SignedOp`], [`OpState`], and the causal queue capacity constant.
|
||||
|
||||
use fastcrypto::traits::VerifyingKey;
|
||||
use fastcrypto::{
|
||||
ed25519::{Ed25519KeyPair, Ed25519PublicKey, Ed25519Signature},
|
||||
traits::{KeyPair, ToFromBytes},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{serde_as, Bytes};
|
||||
|
||||
use crate::keypair::{sha256, sign, AuthorId, SignedDigest};
|
||||
use crate::op::{print_hex, print_path, Op, OpId};
|
||||
|
||||
use super::{CrdtNode, JsonValue};
|
||||
|
||||
/// Enum representing possible outcomes of applying an operation to a CRDT
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum OpState {
|
||||
/// Operation applied successfully
|
||||
Ok,
|
||||
/// Tried to apply an operation to a non-CRDT primitive (i.e. f64, bool, etc.)
|
||||
/// If you would like a mutable primitive, wrap it in a [`LWWRegisterCRDT`]
|
||||
ErrApplyOnPrimitive,
|
||||
/// Tried to apply an operation to a static struct CRDT
|
||||
/// If you would like a mutable object, use a [`Value`]
|
||||
ErrApplyOnStruct,
|
||||
/// Tried to apply an operation that contains content of the wrong type.
|
||||
/// In other words, the content cannot be coerced to the CRDT at the path specified.
|
||||
ErrMismatchedType,
|
||||
/// The signed digest of the message did not match the claimed author of the message.
|
||||
/// This can happen if the message was tampered with during delivery
|
||||
ErrDigestMismatch,
|
||||
/// The hash of the message did not match the contents of the message.
|
||||
/// This can happen if the author tried to perform an equivocation attack by creating an
|
||||
/// operation and modifying it has already been created
|
||||
ErrHashMismatch,
|
||||
/// Tried to apply an operation to a non-existent path. The author may have forgotten to attach
|
||||
/// a causal dependency
|
||||
ErrPathMismatch,
|
||||
/// Trying to modify/delete the sentinel (zero-th) node element that is used for book-keeping
|
||||
ErrListApplyToEmpty,
|
||||
/// We have not received all of the causal dependencies of this operation. It has been queued
|
||||
/// up and will be executed when its causal dependencies have been delivered
|
||||
MissingCausalDependencies,
|
||||
/// This op has already been applied (identified by its `signed_digest`).
|
||||
/// The CRDT state is unchanged — this is a no-op (idempotent self-loop guard).
|
||||
AlreadySeen,
|
||||
}
|
||||
|
||||
/// Maximum total number of ops that may sit in the causal-order hold queue at any
|
||||
/// one time, summed across all pending dependency buckets.
|
||||
///
|
||||
/// **Overflow policy: drop oldest.**
|
||||
/// When the limit is reached, the oldest pending op in the largest dependency bucket
|
||||
/// is silently evicted before the new op is queued. Rationale: a misbehaving or
|
||||
/// heavily-partitioned peer can send ops whose causal ancestors never arrive, causing
|
||||
/// unbounded memory growth. Dropping the oldest entry preserves the most recent
|
||||
/// information and caps memory use. The peer can reconnect and receive a fresh bulk
|
||||
/// state dump to recover any dropped ops.
|
||||
pub const CAUSAL_QUEUE_MAX: usize = 256;
|
||||
|
||||
/// An [`Op<Value>`] with a few bits of extra metadata
|
||||
#[serde_as]
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct SignedOp {
|
||||
// Note that this can be different from the author of the inner op as the inner op could have been created
|
||||
// by a different person
|
||||
author: AuthorId,
|
||||
/// Signed hash using priv key of author. Effectively [`OpID`] Use this as the ID to figure out what has been delivered already
|
||||
#[serde_as(as = "Bytes")]
|
||||
pub signed_digest: SignedDigest,
|
||||
pub inner: Op<JsonValue>,
|
||||
/// List of causal dependencies
|
||||
#[serde_as(as = "Vec<Bytes>")]
|
||||
pub depends_on: Vec<SignedDigest>,
|
||||
}
|
||||
|
||||
impl SignedOp {
|
||||
pub fn id(&self) -> OpId {
|
||||
self.inner.id
|
||||
}
|
||||
|
||||
pub fn author(&self) -> AuthorId {
|
||||
self.author
|
||||
}
|
||||
|
||||
/// Creates a digest of the following fields. Any changes in the fields will change the signed digest
|
||||
/// - id (hash of the following)
|
||||
/// - origin
|
||||
/// - author
|
||||
/// - seq
|
||||
/// - is_deleted
|
||||
/// - path
|
||||
/// - dependencies
|
||||
fn digest(&self) -> [u8; 32] {
|
||||
let path_string = print_path(self.inner.path.clone());
|
||||
let dependency_string = self
|
||||
.depends_on
|
||||
.iter()
|
||||
.map(print_hex)
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
let fmt_str = format!("{:?},{path_string},{dependency_string}", self.id());
|
||||
sha256(fmt_str)
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign a normal op and add all the needed metadata
|
||||
pub fn from_op<T: CrdtNode>(
|
||||
value: Op<T>,
|
||||
keypair: &Ed25519KeyPair,
|
||||
depends_on: Vec<SignedDigest>,
|
||||
) -> Self {
|
||||
let author = keypair.public().0.to_bytes();
|
||||
let mut new = Self {
|
||||
inner: Op {
|
||||
content: value.content.map(|c| c.view()),
|
||||
origin: value.origin,
|
||||
author: value.author,
|
||||
seq: value.seq,
|
||||
path: value.path,
|
||||
is_deleted: value.is_deleted,
|
||||
id: value.id,
|
||||
},
|
||||
author,
|
||||
signed_digest: [0u8; 64],
|
||||
depends_on,
|
||||
};
|
||||
new.sign_digest(keypair);
|
||||
new
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! The [`JsonValue`] enum and all its conversions to/from primitive and CRDT types.
|
||||
|
||||
use std::fmt::Display;
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{keypair::AuthorId, list_crdt::ListCrdt, lww_crdt::LwwRegisterCrdt, op::PathSegment};
|
||||
|
||||
use super::{CrdtNode, CrdtNodeFromValue};
|
||||
|
||||
/// An enum representing a JSON value
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum JsonValue {
|
||||
Null,
|
||||
Bool(bool),
|
||||
Number(f64),
|
||||
String(String),
|
||||
Array(Vec<JsonValue>),
|
||||
Object(IndexMap<String, JsonValue>),
|
||||
}
|
||||
|
||||
impl Display for JsonValue {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
JsonValue::Null => "null".to_string(),
|
||||
JsonValue::Bool(b) => b.to_string(),
|
||||
JsonValue::Number(n) => n.to_string(),
|
||||
JsonValue::String(s) => format!("\"{s}\""),
|
||||
JsonValue::Array(arr) => {
|
||||
if arr.len() > 1 {
|
||||
format!(
|
||||
"[\n{}\n]",
|
||||
arr.iter()
|
||||
.map(|x| format!(" {x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n")
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"[ {} ]",
|
||||
arr.iter()
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
JsonValue::Object(obj) => format!(
|
||||
"{{ {} }}",
|
||||
obj.iter()
|
||||
.map(|(k, v)| format!(" \"{k}\": {v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n")
|
||||
),
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
fn from(value: JsonValue) -> Self {
|
||||
match value {
|
||||
JsonValue::Null => serde_json::Value::Null,
|
||||
JsonValue::Bool(x) => serde_json::Value::Bool(x),
|
||||
JsonValue::Number(x) => {
|
||||
serde_json::Value::Number(serde_json::Number::from_f64(x).unwrap())
|
||||
}
|
||||
JsonValue::String(x) => serde_json::Value::String(x),
|
||||
JsonValue::Array(x) => {
|
||||
serde_json::Value::Array(x.iter().map(|a| a.clone().into()).collect())
|
||||
}
|
||||
JsonValue::Object(x) => serde_json::Value::Object(
|
||||
x.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone().into()))
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Value> for JsonValue {
|
||||
fn from(value: serde_json::Value) -> Self {
|
||||
match value {
|
||||
serde_json::Value::Null => JsonValue::Null,
|
||||
serde_json::Value::Bool(x) => JsonValue::Bool(x),
|
||||
serde_json::Value::Number(x) => JsonValue::Number(x.as_f64().unwrap()),
|
||||
serde_json::Value::String(x) => JsonValue::String(x),
|
||||
serde_json::Value::Array(x) => {
|
||||
JsonValue::Array(x.iter().map(|a| a.clone().into()).collect())
|
||||
}
|
||||
serde_json::Value::Object(x) => JsonValue::Object(
|
||||
x.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone().into()))
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonValue {
|
||||
pub fn into_json(self) -> serde_json::Value {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from primitive types to [`JsonValue`]
|
||||
impl From<bool> for JsonValue {
|
||||
fn from(val: bool) -> Self {
|
||||
JsonValue::Bool(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for JsonValue {
|
||||
fn from(val: i64) -> Self {
|
||||
JsonValue::Number(val as f64)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i32> for JsonValue {
|
||||
fn from(val: i32) -> Self {
|
||||
JsonValue::Number(val as f64)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for JsonValue {
|
||||
fn from(val: f64) -> Self {
|
||||
JsonValue::Number(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for JsonValue {
|
||||
fn from(val: String) -> Self {
|
||||
JsonValue::String(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<char> for JsonValue {
|
||||
fn from(val: char) -> Self {
|
||||
JsonValue::String(val.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<Option<T>> for JsonValue
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn from(val: Option<T>) -> Self {
|
||||
match val {
|
||||
Some(x) => x.view(),
|
||||
None => JsonValue::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<Vec<T>> for JsonValue
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn from(value: Vec<T>) -> Self {
|
||||
JsonValue::Array(value.iter().map(|x| x.view()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from bool to CRDT
|
||||
impl CrdtNodeFromValue for bool {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::Bool(x) = value {
|
||||
Ok(x)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> bool"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from f64 to CRDT
|
||||
impl CrdtNodeFromValue for f64 {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::Number(x) = value {
|
||||
Ok(x)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> f64"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from i64 to CRDT
|
||||
impl CrdtNodeFromValue for i64 {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::Number(x) = value {
|
||||
Ok(x as i64)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> f64"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from String to CRDT
|
||||
impl CrdtNodeFromValue for String {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::String(x) = value {
|
||||
Ok(x)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> String"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from char to CRDT
|
||||
impl CrdtNodeFromValue for char {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::String(x) = value.clone() {
|
||||
x.chars().next().ok_or(format!(
|
||||
"failed to convert {value:?} -> char: found a zero-length string"
|
||||
))
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> char"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CrdtNodeFromValue for LwwRegisterCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn node_from(value: JsonValue, id: AuthorId, path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
let mut crdt = LwwRegisterCrdt::new(id, path);
|
||||
crdt.set(value);
|
||||
Ok(crdt)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CrdtNodeFromValue for ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn node_from(value: JsonValue, id: AuthorId, path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::Array(arr) = value {
|
||||
let mut crdt = ListCrdt::new(id, path);
|
||||
let result: Result<(), String> =
|
||||
arr.into_iter().enumerate().try_for_each(|(i, val)| {
|
||||
crdt.insert_idx(i, val);
|
||||
Ok(())
|
||||
});
|
||||
result?;
|
||||
Ok(crdt)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> ListCRDT<T>"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
//! 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
|
||||
//! throughout the CRDT codebase.
|
||||
|
||||
use fastcrypto::traits::VerifyingKey;
|
||||
pub use fastcrypto::{
|
||||
ed25519::{
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
//! BFT JSON CRDT library — a Byzantine Fault-Tolerant replicated JSON document
|
||||
//! built on an RGA list CRDT, an LWW register CRDT, and a signed-op substrate.
|
||||
//!
|
||||
//! Each document is identified by an Ed25519 keypair. Operations are signed and
|
||||
//! carry causal dependencies so that every node converges to the same value
|
||||
//! regardless of message delivery order.
|
||||
|
||||
/// Debug helpers and the [`DebugView`] trait for rendering CRDT internals.
|
||||
pub mod debug;
|
||||
/// JSON CRDT public interface: core traits, types, and signed-op substrate.
|
||||
pub mod json_crdt;
|
||||
/// Ed25519 keypair utilities and primitive type aliases used throughout the crate.
|
||||
pub mod keypair;
|
||||
/// RGA-style list CRDT that can store any [`CrdtNode`] as its element type.
|
||||
pub mod list_crdt;
|
||||
/// Last-writer-wins (LWW) register CRDT for single-value fields.
|
||||
pub mod lww_crdt;
|
||||
/// Core operation types: [`Op`], [`PathSegment`], and hashing helpers.
|
||||
pub mod op;
|
||||
|
||||
extern crate self as bft_json_crdt;
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
//! RGA-style list CRDT that stores any [`CrdtNode`] as its element type.
|
||||
//!
|
||||
//! Implements the Replicated Growable Array (RGA) algorithm with causal ordering.
|
||||
//! Concurrent inserts at the same position are resolved by sequence number then
|
||||
//! by author public key so that all replicas converge to the same sequence.
|
||||
|
||||
use crate::{
|
||||
debug::debug_path_mismatch,
|
||||
json_crdt::{CrdtNode, JsonValue, OpState},
|
||||
@@ -47,6 +53,21 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current Lamport sequence number for this list.
|
||||
pub fn our_seq(&self) -> SequenceNumber {
|
||||
self.our_seq
|
||||
}
|
||||
|
||||
/// Advance the internal sequence counter to at least `seq`.
|
||||
///
|
||||
/// After `advance_seq(n)`, the next local op will carry `seq = max(our_seq, n) + 1`
|
||||
/// instead of the default `1`. Used on restart to resume the Lamport clock
|
||||
/// from the document-wide floor so that newly-created registers don't
|
||||
/// re-emit low sequence numbers.
|
||||
pub fn advance_seq(&mut self, seq: SequenceNumber) {
|
||||
self.our_seq = max(self.our_seq, seq);
|
||||
}
|
||||
|
||||
/// Locally insert some content causally after the given operation
|
||||
pub fn insert<U: Into<JsonValue>>(&mut self, after: OpId, content: U) -> Op<JsonValue> {
|
||||
let mut op = Op::new(
|
||||
@@ -365,6 +386,18 @@ mod test {
|
||||
assert_eq!(list.view(), vec![1, 4, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_advance_seq_resumes_from_floor() {
|
||||
let mut list = ListCrdt::<i64>::new(make_author(1), vec![]);
|
||||
list.advance_seq(100);
|
||||
assert_eq!(list.our_seq(), 100);
|
||||
let op = list.insert(ROOT_ID, 42);
|
||||
assert_eq!(
|
||||
op.seq, 101,
|
||||
"first op after advance_seq(100) must have seq=101"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_idempotence() {
|
||||
let mut list = ListCrdt::<i64>::new(make_author(1), vec![]);
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
//! Last-writer-wins (LWW) register CRDT.
|
||||
//!
|
||||
//! Implements a delete-wins LWW register for primitive values inside a nested
|
||||
//! JSON CRDT. Concurrent writes are resolved by sequence number; ties are broken
|
||||
//! by author public key so every node converges to the same value.
|
||||
|
||||
use crate::debug::DebugView;
|
||||
use crate::json_crdt::{CrdtNode, JsonValue, OpState};
|
||||
use crate::op::{join_path, print_path, Op, PathSegment, SequenceNumber};
|
||||
@@ -37,6 +43,21 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current Lamport sequence number for this register.
|
||||
pub fn our_seq(&self) -> SequenceNumber {
|
||||
self.our_seq
|
||||
}
|
||||
|
||||
/// Advance the internal sequence counter to at least `seq`.
|
||||
///
|
||||
/// After `advance_seq(n)`, the next local op will carry `seq = max(our_seq, n) + 1`
|
||||
/// instead of the default `1`. Used on restart to resume the Lamport clock
|
||||
/// from the document-wide floor so that newly-created registers don't
|
||||
/// re-emit low sequence numbers.
|
||||
pub fn advance_seq(&mut self, seq: SequenceNumber) {
|
||||
self.our_seq = max(self.our_seq, seq);
|
||||
}
|
||||
|
||||
/// Sets the current value of the register
|
||||
pub fn set<U: Into<JsonValue>>(&mut self, content: U) -> Op<JsonValue> {
|
||||
let mut op = Op::new(
|
||||
@@ -174,6 +195,18 @@ mod test {
|
||||
assert_eq!(register.view(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_advance_seq_resumes_from_floor() {
|
||||
let mut register = LwwRegisterCrdt::<i64>::new(make_author(1), vec![]);
|
||||
register.advance_seq(100);
|
||||
assert_eq!(register.our_seq(), 100);
|
||||
let op = register.set(42);
|
||||
assert_eq!(
|
||||
op.seq, 101,
|
||||
"first op after advance_seq(100) must have seq=101"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lww_consistent_tiebreak() {
|
||||
let mut register1 = LwwRegisterCrdt::new(make_author(1), vec![]);
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
//! Core operation types for the BFT JSON CRDT.
|
||||
//!
|
||||
//! Defines [`Op`] (the fundamental unit of change), [`PathSegment`] (for
|
||||
//! addressing nested CRDTs), and [`SequenceNumber`] / [`OpId`] type aliases.
|
||||
//! Also provides hashing utilities used when computing operation identifiers.
|
||||
|
||||
use crate::debug::{debug_path_mismatch, debug_type_mismatch};
|
||||
use crate::json_crdt::{CrdtNode, CrdtNodeFromValue, IntoCrdtNode, JsonValue, SignedOp};
|
||||
use crate::keypair::{sha256, AuthorId};
|
||||
@@ -113,6 +119,7 @@ where
|
||||
|
||||
/// Conversion from Op<Value> -> Op<T> given that T is a CRDT that can be created from a JSON value
|
||||
impl Op<JsonValue> {
|
||||
/// Convert this `Op<JsonValue>` into an `Op<T>` by deserialising the content via `T::node_from`.
|
||||
pub fn into<T: CrdtNodeFromValue + CrdtNode>(self) -> Op<T> {
|
||||
let content = if let Some(inner_content) = self.content {
|
||||
match inner_content.into_node(self.id, self.path.clone()) {
|
||||
@@ -141,10 +148,12 @@ impl<T> Op<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
/// Sign this operation with `keypair`, producing a [`SignedOp`] with no causal dependencies.
|
||||
pub fn sign(self, keypair: &Ed25519KeyPair) -> SignedOp {
|
||||
SignedOp::from_op(self, keypair, vec![])
|
||||
}
|
||||
|
||||
/// Sign this operation and attach explicit causal `dependencies`.
|
||||
pub fn sign_with_dependencies(
|
||||
self,
|
||||
keypair: &Ed25519KeyPair,
|
||||
@@ -160,14 +169,17 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
/// Return the [`AuthorId`] (Ed25519 public key) of the node that created this operation.
|
||||
pub fn author(&self) -> AuthorId {
|
||||
self.author
|
||||
}
|
||||
|
||||
/// Return the Lamport sequence number carried by this operation.
|
||||
pub fn sequence_num(&self) -> SequenceNumber {
|
||||
self.seq
|
||||
}
|
||||
|
||||
/// Construct a new operation, computing its [`OpId`] hash from the supplied fields.
|
||||
pub fn new(
|
||||
origin: OpId,
|
||||
author: AuthorId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//! Integration tests verifying Byzantine fault tolerance of the CRDT.
|
||||
use bft_json_crdt::{
|
||||
json_crdt::{add_crdt_fields, BaseCrdt, CrdtNode, IntoCrdtNode, OpState},
|
||||
keypair::make_keypair,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//! Integration tests verifying commutativity of CRDT operations.
|
||||
use bft_json_crdt::{
|
||||
json_crdt::{CrdtNode, JsonValue},
|
||||
keypair::make_author,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Integration tests that replay the Kleppmann editing trace to validate list-CRDT correctness and performance.
|
||||
|
||||
use bft_json_crdt::keypair::make_author;
|
||||
use bft_json_crdt::list_crdt::ListCrdt;
|
||||
use bft_json_crdt::op::{OpId, ROOT_ID};
|
||||
use std::{fs::File, io::Read};
|
||||
use time::PreciseTime;
|
||||
use std::{fs::File, io::Read, time::Instant};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -47,7 +48,7 @@ fn test_editing_trace() {
|
||||
let mut list = ListCrdt::<char>::new(make_author(1), vec![]);
|
||||
let mut ops: Vec<OpId> = Vec::new();
|
||||
ops.push(ROOT_ID);
|
||||
let start = PreciseTime::now();
|
||||
let start = Instant::now();
|
||||
let edits = t.edits;
|
||||
for (i, op) in edits.into_iter().enumerate() {
|
||||
let origin = ops[op.pos];
|
||||
@@ -61,17 +62,13 @@ fn test_editing_trace() {
|
||||
|
||||
match i {
|
||||
10_000 | 100_000 => {
|
||||
let end = PreciseTime::now();
|
||||
let runtime_sec = start.to(end);
|
||||
println!("took {runtime_sec:?} to run {i} ops");
|
||||
println!("took {:?} to run {i} ops", start.elapsed());
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
|
||||
let end = PreciseTime::now();
|
||||
let runtime_sec = start.to(end);
|
||||
println!("took {runtime_sec:?} to finish");
|
||||
println!("took {:?} to finish", start.elapsed());
|
||||
let result = list.iter().collect::<String>();
|
||||
let expected = t.final_text;
|
||||
assert_eq!(result.len(), expected.len());
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "source-map-gen"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["lib"]
|
||||
|
||||
[[bin]]
|
||||
name = "source-map-check"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
@@ -0,0 +1,721 @@
|
||||
//! LLM-friendly source map generation and documentation coverage checking.
|
||||
//!
|
||||
//! Provides a [`LanguageAdapter`] trait that language-specific adapters implement,
|
||||
//! plus top-level dispatcher functions that route to the right adapter based on file
|
||||
//! 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.
|
||||
|
||||
mod rust_adapter;
|
||||
mod ts_adapter;
|
||||
|
||||
pub use rust_adapter::RustAdapter;
|
||||
pub use ts_adapter::TypeScriptAdapter;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// A missing documentation failure for a single public item.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CheckFailure {
|
||||
/// Path to the file containing the undocumented item.
|
||||
pub file_path: PathBuf,
|
||||
/// 1-based line number of the item declaration.
|
||||
pub line: usize,
|
||||
/// Kind of item (e.g. `"fn"`, `"struct"`, `"module"`).
|
||||
pub item_kind: String,
|
||||
/// Name of the item.
|
||||
pub item_name: String,
|
||||
}
|
||||
|
||||
impl CheckFailure {
|
||||
/// Returns a human-readable direction a coding agent can act on directly.
|
||||
pub fn to_direction(&self) -> String {
|
||||
format!(
|
||||
"{}:{}: add a doc comment to {} `{}`",
|
||||
self.file_path.display(),
|
||||
self.line,
|
||||
self.item_kind,
|
||||
self.item_name
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a documentation coverage check.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum CheckResult {
|
||||
/// All checked items are documented.
|
||||
Ok,
|
||||
/// One or more items are missing documentation.
|
||||
Failures(Vec<CheckFailure>),
|
||||
}
|
||||
|
||||
/// Language-specific adapter for doc-coverage checking and source map generation.
|
||||
pub trait LanguageAdapter {
|
||||
/// Check documentation coverage for `files`.
|
||||
///
|
||||
/// Returns [`CheckResult::Ok`] when every public item in every file has a doc
|
||||
/// comment, or [`CheckResult::Failures`] listing each undocumented item as a
|
||||
/// direction the coding agent can act on.
|
||||
fn check(&self, files: &[&Path]) -> CheckResult;
|
||||
|
||||
/// Update the source map at `source_map_path` with entries for `passing_files`.
|
||||
///
|
||||
/// 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).
|
||||
fn update_source_map(
|
||||
&self,
|
||||
passing_files: &[&Path],
|
||||
source_map_path: &Path,
|
||||
) -> Result<(), String>;
|
||||
}
|
||||
|
||||
/// Returns the adapter for the given file extension, or `None` if unsupported.
|
||||
fn adapter_for_ext(ext: &str) -> Option<Box<dyn LanguageAdapter>> {
|
||||
match ext {
|
||||
"rs" => Some(Box::new(RustAdapter)),
|
||||
"ts" | "tsx" => Some(Box::new(TypeScriptAdapter)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse added line ranges from a unified diff output.
|
||||
///
|
||||
/// Returns the 1-based, inclusive line ranges in the new version of the file
|
||||
/// that were introduced by the diff. Lines that are context or deletions are
|
||||
/// not included.
|
||||
fn parse_added_ranges(diff: &str) -> Vec<std::ops::RangeInclusive<usize>> {
|
||||
let mut ranges = Vec::new();
|
||||
for line in diff.lines() {
|
||||
// Unified diff hunk header: @@ -old[,count] +new[,count] @@
|
||||
if !line.starts_with("@@") {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
// Expected: ["@@", "-old[,count]", "+new[,count]", "@@", ...]
|
||||
if parts.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let new_part = parts[2];
|
||||
let Some(new_info) = new_part.strip_prefix('+') else {
|
||||
continue;
|
||||
};
|
||||
let (start, count) = if let Some((s, c)) = new_info.split_once(',') {
|
||||
(
|
||||
s.parse::<usize>().unwrap_or(0),
|
||||
c.parse::<usize>().unwrap_or(0),
|
||||
)
|
||||
} else {
|
||||
(new_info.parse::<usize>().unwrap_or(0), 1usize)
|
||||
};
|
||||
if count > 0 && start > 0 {
|
||||
ranges.push(start..=start + count - 1);
|
||||
}
|
||||
}
|
||||
ranges
|
||||
}
|
||||
|
||||
/// Returns the 1-based line ranges in `file` that were added since `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.
|
||||
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")
|
||||
.args([
|
||||
"diff",
|
||||
"--unified=0",
|
||||
&format!("{base}...HEAD"),
|
||||
"--",
|
||||
&rel.to_string_lossy(),
|
||||
])
|
||||
.current_dir(worktree)
|
||||
.output();
|
||||
match output {
|
||||
Ok(o) => parse_added_ranges(&String::from_utf8_lossy(&o.stdout)),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check documentation coverage, reporting only violations in lines added since `base`.
|
||||
///
|
||||
/// Like [`check_files`], but filters each [`CheckFailure`] to items whose declaration
|
||||
/// line falls within a range added by `git diff {base}...HEAD` against `worktree`.
|
||||
/// Pre-existing undocumented items whose lines were not touched by the commit are
|
||||
/// silently ignored.
|
||||
pub fn check_files_ratcheted(files: &[&Path], worktree: &Path, base: &str) -> CheckResult {
|
||||
let mut by_ext: HashMap<String, Vec<&Path>> = HashMap::new();
|
||||
for &file in files {
|
||||
if let Some(ext) = file.extension().and_then(|e| e.to_str()) {
|
||||
by_ext.entry(ext.to_string()).or_default().push(file);
|
||||
}
|
||||
}
|
||||
let mut all_failures = Vec::new();
|
||||
for (ext, ext_files) in &by_ext {
|
||||
if let Some(adapter) = adapter_for_ext(ext)
|
||||
&& let CheckResult::Failures(failures) = adapter.check(ext_files)
|
||||
{
|
||||
for failure in failures {
|
||||
let added = added_line_ranges(worktree, base, &failure.file_path);
|
||||
// Only report if the item's declaration line is within an added range.
|
||||
// If added is empty (no additions or git error), skip — nothing new to blame.
|
||||
if !added.is_empty() && added.iter().any(|r| r.contains(&failure.line)) {
|
||||
all_failures.push(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if all_failures.is_empty() {
|
||||
CheckResult::Ok
|
||||
} else {
|
||||
CheckResult::Failures(all_failures)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check documentation coverage for a mixed list of files.
|
||||
///
|
||||
/// Dispatches each file to the appropriate [`LanguageAdapter`] based on its
|
||||
/// extension. Files with unsupported extensions are silently skipped.
|
||||
pub fn check_files(files: &[&Path]) -> CheckResult {
|
||||
let mut by_ext: HashMap<String, Vec<&Path>> = HashMap::new();
|
||||
for &file in files {
|
||||
if let Some(ext) = file.extension().and_then(|e| e.to_str()) {
|
||||
by_ext.entry(ext.to_string()).or_default().push(file);
|
||||
}
|
||||
}
|
||||
let mut all_failures = Vec::new();
|
||||
for (ext, ext_files) in &by_ext {
|
||||
if let Some(adapter) = adapter_for_ext(ext)
|
||||
&& let CheckResult::Failures(mut f) = adapter.check(ext_files)
|
||||
{
|
||||
all_failures.append(&mut f);
|
||||
}
|
||||
}
|
||||
if all_failures.is_empty() {
|
||||
CheckResult::Ok
|
||||
} else {
|
||||
CheckResult::Failures(all_failures)
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the source map at `source_map_path` with entries for `passing_files`.
|
||||
///
|
||||
/// 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> {
|
||||
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()) {
|
||||
by_ext.entry(ext.to_string()).or_default().push(file);
|
||||
}
|
||||
}
|
||||
for (ext, ext_files) in &by_ext {
|
||||
if let Some(adapter) = adapter_for_ext(ext) {
|
||||
adapter.update_source_map(ext_files, source_map_path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 2. Checks doc coverage for each changed file (per-file).
|
||||
/// 3. Calls [`update_source_map`] with the files whose coverage check passes.
|
||||
///
|
||||
/// Errors are returned as `Err(String)`; callers in the spawn flow treat them as
|
||||
/// non-blocking warnings.
|
||||
pub fn update_for_worktree(
|
||||
worktree_path: &Path,
|
||||
base_branch: &str,
|
||||
source_map_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
let output = Command::new("git")
|
||||
.args(["diff", "--name-only", &format!("{base_branch}...HEAD")])
|
||||
.current_dir(worktree_path)
|
||||
.output()
|
||||
.map_err(|e| format!("git diff: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"git diff failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if changed.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Collect files that individually pass the doc check.
|
||||
let passing: Vec<&Path> = changed
|
||||
.iter()
|
||||
.map(PathBuf::as_path)
|
||||
.filter(|&p| matches!(check_files(&[p]), CheckResult::Ok))
|
||||
.collect();
|
||||
|
||||
if passing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(parent) = source_map_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("create_dir_all: {e}"))?;
|
||||
}
|
||||
|
||||
update_source_map(&passing, source_map_path)
|
||||
}
|
||||
|
||||
/// Read the existing source map from `path` as a JSON object.
|
||||
///
|
||||
/// Returns an empty map if the file does not exist.
|
||||
pub(crate) fn read_map(path: &Path) -> Result<serde_json::Map<String, serde_json::Value>, String> {
|
||||
if !path.exists() {
|
||||
return Ok(serde_json::Map::new());
|
||||
}
|
||||
let content =
|
||||
std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
|
||||
serde_json::from_str(&content).map_err(|e| format!("parse source map: {e}"))
|
||||
}
|
||||
|
||||
/// Write `map` to `path` as pretty-printed JSON.
|
||||
pub(crate) fn write_map(
|
||||
path: &Path,
|
||||
map: serde_json::Map<String, serde_json::Value>,
|
||||
) -> Result<(), String> {
|
||||
let content = serde_json::to_string_pretty(&serde_json::Value::Object(map))
|
||||
.map_err(|e| format!("serialize: {e}"))?;
|
||||
std::fs::write(path, content).map_err(|e| format!("write {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_rs(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn write_ts(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
// --- Rust happy path ---
|
||||
|
||||
#[test]
|
||||
fn rust_check_happy_path_ok() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_rs(
|
||||
tmp.path(),
|
||||
"foo.rs",
|
||||
"//! Module doc.\n\n/// A function.\npub fn hello() {}\n",
|
||||
);
|
||||
assert_eq!(check_files(&[&path]), CheckResult::Ok);
|
||||
}
|
||||
|
||||
// --- Rust failure path ---
|
||||
|
||||
#[test]
|
||||
fn rust_check_missing_module_doc_yields_failure() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_rs(tmp.path(), "foo.rs", "/// A function.\npub fn hello() {}\n");
|
||||
let result = check_files(&[&path]);
|
||||
assert!(
|
||||
matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "module")),
|
||||
"expected module failure, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_check_missing_fn_doc_yields_failure_with_correct_fields() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_rs(
|
||||
tmp.path(),
|
||||
"bar.rs",
|
||||
"//! Module doc.\n\npub fn undocumented() {}\n",
|
||||
);
|
||||
let result = check_files(&[&path]);
|
||||
if let CheckResult::Failures(failures) = result {
|
||||
let f = failures.iter().find(|f| f.item_kind == "fn").unwrap();
|
||||
assert_eq!(f.item_name, "undocumented");
|
||||
assert_eq!(f.file_path, path);
|
||||
assert_eq!(f.line, 3);
|
||||
} else {
|
||||
panic!("expected failures");
|
||||
}
|
||||
}
|
||||
|
||||
// --- TypeScript happy path ---
|
||||
|
||||
#[test]
|
||||
fn ts_check_happy_path_ok() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_ts(
|
||||
tmp.path(),
|
||||
"app.ts",
|
||||
"/**\n * File doc.\n */\n\n/**\n * Does something.\n */\nexport function hello(): void {}\n",
|
||||
);
|
||||
assert_eq!(check_files(&[&path]), CheckResult::Ok);
|
||||
}
|
||||
|
||||
// --- TypeScript failure path ---
|
||||
|
||||
#[test]
|
||||
fn ts_check_missing_file_doc_yields_failure() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let _path = write_ts(
|
||||
tmp.path(),
|
||||
"app.ts",
|
||||
"/** A function. */\nexport function hello(): void {}\n",
|
||||
);
|
||||
// No file-level JSDoc (first non-empty line is not /**)
|
||||
// Actually this file DOES start with /**, so let's make one that doesn't
|
||||
let path2 = write_ts(tmp.path(), "app2.ts", "export function hello(): void {}\n");
|
||||
let result = check_files(&[&path2]);
|
||||
assert!(
|
||||
matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "file")),
|
||||
"expected file failure, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ts_check_missing_export_doc_yields_failure() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_ts(
|
||||
tmp.path(),
|
||||
"app.ts",
|
||||
"/**\n * File doc.\n */\n\nexport function undocumented(): void {}\n",
|
||||
);
|
||||
let result = check_files(&[&path]);
|
||||
assert!(
|
||||
matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "function" && f.item_name == "undocumented")),
|
||||
"expected function failure, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Update idempotency ---
|
||||
|
||||
#[test]
|
||||
fn update_idempotent_same_input_twice() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let rs_path = write_rs(
|
||||
tmp.path(),
|
||||
"lib.rs",
|
||||
"//! Module doc.\n\n/// A function.\npub fn foo() {}\n",
|
||||
);
|
||||
let map_path = tmp.path().join("source-map.json");
|
||||
let files: &[&Path] = &[&rs_path];
|
||||
|
||||
update_source_map(files, &map_path).unwrap();
|
||||
let first = std::fs::read_to_string(&map_path).unwrap();
|
||||
|
||||
update_source_map(files, &map_path).unwrap();
|
||||
let second = std::fs::read_to_string(&map_path).unwrap();
|
||||
|
||||
assert_eq!(first, second, "update_source_map must be idempotent");
|
||||
}
|
||||
|
||||
// --- update_source_map preserves other entries ---
|
||||
|
||||
#[test]
|
||||
fn update_source_map_preserves_unrelated_entries() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let map_path = tmp.path().join("source-map.json");
|
||||
|
||||
// Write an initial map with an unrelated entry
|
||||
std::fs::write(&map_path, r#"{"unrelated/file.rs": ["fn old"]}"#).unwrap();
|
||||
|
||||
let rs_path = write_rs(
|
||||
tmp.path(),
|
||||
"new.rs",
|
||||
"//! Module doc.\n\n/// A function.\npub fn bar() {}\n",
|
||||
);
|
||||
update_source_map(&[&rs_path], &map_path).unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(&map_path).unwrap();
|
||||
assert!(
|
||||
content.contains("unrelated/file.rs"),
|
||||
"old entry should be preserved"
|
||||
);
|
||||
assert!(content.contains("new.rs"), "new entry should be added");
|
||||
}
|
||||
|
||||
// --- Gate tests: AC3 / AC4 ---
|
||||
|
||||
/// AC3: a worktree with a missing module doc fails gates with a recognisable
|
||||
/// error that references the missing file and line number.
|
||||
#[test]
|
||||
fn gate_missing_module_doc_fails_with_file_and_line_in_direction() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// File has a pub fn but NO //! module doc comment.
|
||||
let path = write_rs(tmp.path(), "missing_doc.rs", "pub fn no_module_doc() {}\n");
|
||||
let result = check_files(&[&path]);
|
||||
assert!(
|
||||
matches!(&result, CheckResult::Failures(v) if !v.is_empty()),
|
||||
"expected failures for missing module doc, got {result:?}"
|
||||
);
|
||||
if let CheckResult::Failures(failures) = result {
|
||||
let module_failure = failures
|
||||
.iter()
|
||||
.find(|f| f.item_kind == "module")
|
||||
.expect("expected a module-level failure");
|
||||
let direction = module_failure.to_direction();
|
||||
// Direction must name the file so the agent can navigate directly to it.
|
||||
assert!(
|
||||
direction.contains("missing_doc.rs"),
|
||||
"direction must reference the file name: {direction}"
|
||||
);
|
||||
// Direction must contain a colon-separated line number.
|
||||
assert!(
|
||||
direction.contains(':'),
|
||||
"direction must contain a file:line reference: {direction}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// AC4: a worktree where every changed file has full docs passes gates (Ok result).
|
||||
#[test]
|
||||
fn gate_fully_documented_files_pass() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_rs(
|
||||
tmp.path(),
|
||||
"fully_documented.rs",
|
||||
"//! Module doc.\n\n/// A function.\npub fn greet() {}\n\n/// A struct.\npub struct Hello;\n",
|
||||
);
|
||||
assert_eq!(
|
||||
check_files(&[&path]),
|
||||
CheckResult::Ok,
|
||||
"fully documented file should produce no failures"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Ratchet tests: AC3 / AC4 ---
|
||||
|
||||
/// AC3: a file with N pre-existing undocumented items plus 1 new undocumented item
|
||||
/// added by the commit reports exactly 1 violation, not N+1.
|
||||
#[test]
|
||||
fn ratchet_only_new_undocumented_items_are_flagged() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
init_git_repo(tmp.path());
|
||||
|
||||
// Base commit: file with 2 undocumented public fns (pre-existing).
|
||||
write_rs(
|
||||
tmp.path(),
|
||||
"lib.rs",
|
||||
"//! Module doc.\n\npub fn old_a() {}\npub fn old_b() {}\n",
|
||||
);
|
||||
Command::new("git")
|
||||
.args(["add", "lib.rs"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "base"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Second commit: append 1 new undocumented fn.
|
||||
write_rs(
|
||||
tmp.path(),
|
||||
"lib.rs",
|
||||
"//! Module doc.\n\npub fn old_a() {}\npub fn old_b() {}\npub fn new_c() {}\n",
|
||||
);
|
||||
Command::new("git")
|
||||
.args(["add", "lib.rs"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "add new_c"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let file = tmp.path().join("lib.rs");
|
||||
let result = check_files_ratcheted(&[file.as_path()], tmp.path(), "HEAD~1");
|
||||
match result {
|
||||
CheckResult::Failures(failures) => {
|
||||
assert_eq!(
|
||||
failures.len(),
|
||||
1,
|
||||
"expected exactly 1 failure (new_c), got {failures:?}"
|
||||
);
|
||||
assert_eq!(failures[0].item_name, "new_c");
|
||||
}
|
||||
CheckResult::Ok => panic!("expected 1 failure for new_c, got Ok"),
|
||||
}
|
||||
}
|
||||
|
||||
/// AC4: a commit that doesn't change a file does not blame it for pre-existing
|
||||
/// undocumented items.
|
||||
#[test]
|
||||
fn ratchet_unchanged_file_not_blamed() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
init_git_repo(tmp.path());
|
||||
|
||||
// Base commit: undocumented file.
|
||||
write_rs(
|
||||
tmp.path(),
|
||||
"untouched.rs",
|
||||
"//! Module doc.\n\npub fn old_undocumented() {}\n",
|
||||
);
|
||||
Command::new("git")
|
||||
.args(["add", "untouched.rs"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "base"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Second commit: add a different, fully documented file; untouched.rs unchanged.
|
||||
write_rs(
|
||||
tmp.path(),
|
||||
"new_file.rs",
|
||||
"//! Module doc.\n\n/// A function.\npub fn documented() {}\n",
|
||||
);
|
||||
Command::new("git")
|
||||
.args(["add", "new_file.rs"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "add new_file"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Simulate passing untouched.rs to the ratcheted check.
|
||||
// Since it has no added lines in the diff, it should produce no failures.
|
||||
let file = tmp.path().join("untouched.rs");
|
||||
let result = check_files_ratcheted(&[file.as_path()], tmp.path(), "HEAD~1");
|
||||
assert_eq!(
|
||||
result,
|
||||
CheckResult::Ok,
|
||||
"file not touched by the commit should not be blamed"
|
||||
);
|
||||
}
|
||||
|
||||
// --- parse_added_ranges unit tests ---
|
||||
|
||||
#[test]
|
||||
fn parse_added_ranges_single_hunk() {
|
||||
let diff = "@@ -0,0 +1,3 @@ some context\n+line1\n+line2\n+line3\n";
|
||||
let ranges = parse_added_ranges(diff);
|
||||
assert_eq!(ranges, vec![1..=3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_added_ranges_multiple_hunks() {
|
||||
let diff =
|
||||
"@@ -1,2 +1,3 @@\n context\n+new\n context\n@@ -10,0 +11,2 @@\n+added1\n+added2\n";
|
||||
let ranges = parse_added_ranges(diff);
|
||||
assert_eq!(ranges, vec![1..=3, 11..=12]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_added_ranges_empty_diff() {
|
||||
let ranges = parse_added_ranges("");
|
||||
assert!(ranges.is_empty());
|
||||
}
|
||||
|
||||
// --- Spawn integration: update_for_worktree writes map at expected path ---
|
||||
|
||||
fn init_git_repo(dir: &Path) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git init");
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git config email");
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git config name");
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("initial commit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_integration_map_written_at_expected_path() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
init_git_repo(tmp.path());
|
||||
|
||||
// Add a well-documented Rust file and commit it
|
||||
let rs_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");
|
||||
|
||||
// Simulate what spawn does: update_for_worktree with base = initial commit
|
||||
let result = update_for_worktree(tmp.path(), "HEAD~1", &map_path);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"update_for_worktree failed: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
// The map file must exist at the expected path
|
||||
assert!(
|
||||
map_path.exists(),
|
||||
"source map must be written at .huskies/source-map.json"
|
||||
);
|
||||
|
||||
let content = std::fs::read_to_string(&map_path).unwrap();
|
||||
let _ = rs_path; // used above
|
||||
assert!(
|
||||
content.contains("lib.rs"),
|
||||
"map must contain the documented file"
|
||||
);
|
||||
assert!(
|
||||
content.contains("fn greet"),
|
||||
"map must list the documented function"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! CLI for checking documentation coverage on files changed since a base branch.
|
||||
//!
|
||||
//! Usage: `source-map-check [--worktree <path>] [--base <branch>]`
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use source_map_gen::{CheckResult, check_files_ratcheted};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let worktree = parse_arg(&args, "--worktree").unwrap_or_else(|| ".".to_string());
|
||||
let base = parse_arg(&args, "--base").unwrap_or_else(|| "master".to_string());
|
||||
|
||||
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();
|
||||
|
||||
if changed.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let file_refs: Vec<&Path> = changed.iter().map(PathBuf::as_path).collect();
|
||||
|
||||
match check_files_ratcheted(&file_refs, worktree_path, &base) {
|
||||
CheckResult::Ok => {}
|
||||
CheckResult::Failures(failures) => {
|
||||
eprintln!(
|
||||
"Doc coverage check failed. Add doc comments to the following items before committing:\n"
|
||||
);
|
||||
for f in &failures {
|
||||
eprintln!(" {}", f.to_direction());
|
||||
}
|
||||
eprintln!(
|
||||
"\nRe-run: cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master"
|
||||
);
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Rust documentation coverage adapter.
|
||||
//!
|
||||
//! Checks for:
|
||||
//! - A `//!` module-level doc comment somewhere in every `.rs` file.
|
||||
//! - A `///` doc comment immediately before every `pub` item (`fn`, `struct`,
|
||||
//! `enum`, `trait`, `type`, `const`, `static`, `mod`).
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{CheckFailure, CheckResult, LanguageAdapter};
|
||||
|
||||
/// Rust documentation coverage adapter.
|
||||
pub struct RustAdapter;
|
||||
|
||||
impl RustAdapter {
|
||||
fn check_file(&self, path: &Path) -> Vec<CheckFailure> {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let mut failures = Vec::new();
|
||||
|
||||
// Module-level doc comment (//!)
|
||||
if !lines.iter().any(|l| l.trim_start().starts_with("//!")) {
|
||||
failures.push(CheckFailure {
|
||||
file_path: path.to_path_buf(),
|
||||
line: 1,
|
||||
item_kind: "module".to_string(),
|
||||
item_name: module_name(path),
|
||||
});
|
||||
}
|
||||
|
||||
// Public items missing /// doc comments
|
||||
for (i, &line) in lines.iter().enumerate() {
|
||||
if let Some((kind, name)) = parse_pub_item(line)
|
||||
&& !has_doc_before(&lines, i)
|
||||
{
|
||||
failures.push(CheckFailure {
|
||||
file_path: path.to_path_buf(),
|
||||
line: i + 1,
|
||||
item_kind: kind,
|
||||
item_name: name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
failures
|
||||
}
|
||||
|
||||
/// Extract public item signatures from a Rust file as `"kind name"` strings.
|
||||
pub(crate) fn extract_items(path: &Path) -> Vec<String> {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
content
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (kind, name) = parse_pub_item(line)?;
|
||||
Some(format!("{kind} {name}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl LanguageAdapter for RustAdapter {
|
||||
fn check(&self, files: &[&Path]) -> CheckResult {
|
||||
let failures: Vec<CheckFailure> = files.iter().flat_map(|&f| self.check_file(f)).collect();
|
||||
if failures.is_empty() {
|
||||
CheckResult::Ok
|
||||
} else {
|
||||
CheckResult::Failures(failures)
|
||||
}
|
||||
}
|
||||
|
||||
fn update_source_map(
|
||||
&self,
|
||||
passing_files: &[&Path],
|
||||
source_map_path: &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 items: Vec<serde_json::Value> = Self::extract_items(file)
|
||||
.into_iter()
|
||||
.map(serde_json::Value::String)
|
||||
.collect();
|
||||
map.insert(key, serde_json::Value::Array(items));
|
||||
}
|
||||
crate::write_map(source_map_path, map)
|
||||
}
|
||||
}
|
||||
|
||||
fn module_name(path: &Path) -> String {
|
||||
path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Parse a line as a public Rust item declaration.
|
||||
///
|
||||
/// Returns `(kind, name)` if the line declares a public item, `None` otherwise.
|
||||
fn parse_pub_item(line: &str) -> Option<(String, String)> {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Strip visibility: "pub(…)" or "pub "
|
||||
let rest = if let Some(r) = trimmed.strip_prefix("pub(") {
|
||||
let end = r.find(')')?;
|
||||
r[end + 1..].trim_start()
|
||||
} else if let Some(r) = trimmed.strip_prefix("pub ") {
|
||||
r.trim_start()
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Handle "async fn"
|
||||
let rest = if let Some(r) = rest.strip_prefix("async ") {
|
||||
r.trim_start()
|
||||
} else {
|
||||
rest
|
||||
};
|
||||
|
||||
// Match item keyword and extract name part
|
||||
let (kind, name_part) = if let Some(r) = rest.strip_prefix("fn ") {
|
||||
("fn", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("struct ") {
|
||||
("struct", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("enum ") {
|
||||
("enum", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("trait ") {
|
||||
("trait", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("type ") {
|
||||
("type", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("const ") {
|
||||
("const", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("static ") {
|
||||
("static", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("mod ") {
|
||||
("mod", r.trim_start())
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let name: String = name_part
|
||||
.chars()
|
||||
.take_while(|&c| c.is_alphanumeric() || c == '_')
|
||||
.collect();
|
||||
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((kind.to_string(), name))
|
||||
}
|
||||
|
||||
/// Return `true` if a `///` doc comment appears before the item at `item_idx`.
|
||||
///
|
||||
/// Scans backward from `item_idx`, skipping blank lines and `#[…]` attribute
|
||||
/// lines. Returns `true` if the first substantive line is a `///` comment.
|
||||
fn has_doc_before(lines: &[&str], item_idx: usize) -> bool {
|
||||
let mut i = item_idx;
|
||||
while i > 0 {
|
||||
i -= 1;
|
||||
let line = lines[i].trim();
|
||||
if line.starts_with("///") {
|
||||
return true;
|
||||
}
|
||||
if line.starts_with("#[") || line.starts_with("#![") || line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_rs(dir: &Path, name: &str, content: &str) -> std::path::PathBuf {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_fully_documented_file_returns_ok() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_rs(
|
||||
tmp.path(),
|
||||
"lib.rs",
|
||||
"//! Module doc.\n\n/// A function.\npub fn hello() {}\n\n/// A struct.\npub struct Foo;\n",
|
||||
);
|
||||
let adapter = RustAdapter;
|
||||
assert_eq!(adapter.check(&[&path]), CheckResult::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_detects_missing_module_doc() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_rs(tmp.path(), "lib.rs", "/// A function.\npub fn hello() {}\n");
|
||||
let adapter = RustAdapter;
|
||||
let result = adapter.check(&[&path]);
|
||||
assert!(
|
||||
matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "module")),
|
||||
"expected module failure, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_detects_missing_fn_doc_with_correct_fields() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_rs(tmp.path(), "bar.rs", "//! Module.\n\npub fn no_doc() {}\n");
|
||||
let adapter = RustAdapter;
|
||||
let result = adapter.check(&[&path]);
|
||||
if let CheckResult::Failures(failures) = result {
|
||||
let f = failures.iter().find(|f| f.item_kind == "fn").unwrap();
|
||||
assert_eq!(f.item_name, "no_doc");
|
||||
assert_eq!(f.line, 3);
|
||||
assert_eq!(f.file_path, path);
|
||||
} else {
|
||||
panic!("expected failures");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_passes_item_with_attribute_before_doc() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Attribute between doc and item is fine; doc between attribute and item is fine too
|
||||
let path = write_rs(
|
||||
tmp.path(),
|
||||
"lib.rs",
|
||||
"//! Module.\n\n/// Doc.\n#[derive(Debug)]\npub struct Foo;\n",
|
||||
);
|
||||
let adapter = RustAdapter;
|
||||
assert_eq!(adapter.check(&[&path]), CheckResult::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pub_item_recognises_various_kinds() {
|
||||
assert_eq!(
|
||||
parse_pub_item("pub fn foo()"),
|
||||
Some(("fn".into(), "foo".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pub_item(" pub async fn bar()"),
|
||||
Some(("fn".into(), "bar".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pub_item("pub struct Baz"),
|
||||
Some(("struct".into(), "Baz".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pub_item("pub enum Qux"),
|
||||
Some(("enum".into(), "Qux".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pub_item("pub trait MyTrait"),
|
||||
Some(("trait".into(), "MyTrait".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pub_item("pub(crate) fn inner()"),
|
||||
Some(("fn".into(), "inner".into()))
|
||||
);
|
||||
assert_eq!(parse_pub_item("fn private()"), None);
|
||||
assert_eq!(parse_pub_item("let x = 1;"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
//! TypeScript documentation coverage adapter.
|
||||
//!
|
||||
//! Checks for:
|
||||
//! - A leading file-level JSDoc comment (`/** … */`) at the top of every
|
||||
//! `.ts` / `.tsx` file.
|
||||
//! - A JSDoc comment before every exported declaration (`export function`,
|
||||
//! `export class`, `export type`, `export interface`, `export const`, etc.).
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{CheckFailure, CheckResult, LanguageAdapter};
|
||||
|
||||
/// TypeScript documentation coverage adapter.
|
||||
pub struct TypeScriptAdapter;
|
||||
|
||||
impl TypeScriptAdapter {
|
||||
fn check_file(&self, path: &Path) -> Vec<CheckFailure> {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let mut failures = Vec::new();
|
||||
|
||||
// File-level JSDoc: first non-empty line must start with "/**"
|
||||
if !has_file_level_jsdoc(&content) {
|
||||
failures.push(CheckFailure {
|
||||
file_path: path.to_path_buf(),
|
||||
line: 1,
|
||||
item_kind: "file".to_string(),
|
||||
item_name: file_stem(path),
|
||||
});
|
||||
}
|
||||
|
||||
// Exported items missing JSDoc
|
||||
for (i, &line) in lines.iter().enumerate() {
|
||||
if let Some((kind, name)) = parse_exported_item(line)
|
||||
&& !has_jsdoc_before(&lines, i)
|
||||
{
|
||||
failures.push(CheckFailure {
|
||||
file_path: path.to_path_buf(),
|
||||
line: i + 1,
|
||||
item_kind: kind,
|
||||
item_name: name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
failures
|
||||
}
|
||||
|
||||
/// Extract exported item signatures from a TypeScript file as `"kind name"` strings.
|
||||
pub(crate) fn extract_items(path: &Path) -> Vec<String> {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
content
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (kind, name) = parse_exported_item(line)?;
|
||||
Some(format!("{kind} {name}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl LanguageAdapter for TypeScriptAdapter {
|
||||
fn check(&self, files: &[&Path]) -> CheckResult {
|
||||
let failures: Vec<CheckFailure> = files.iter().flat_map(|&f| self.check_file(f)).collect();
|
||||
if failures.is_empty() {
|
||||
CheckResult::Ok
|
||||
} else {
|
||||
CheckResult::Failures(failures)
|
||||
}
|
||||
}
|
||||
|
||||
fn update_source_map(
|
||||
&self,
|
||||
passing_files: &[&Path],
|
||||
source_map_path: &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 items: Vec<serde_json::Value> = Self::extract_items(file)
|
||||
.into_iter()
|
||||
.map(serde_json::Value::String)
|
||||
.collect();
|
||||
map.insert(key, serde_json::Value::Array(items));
|
||||
}
|
||||
crate::write_map(source_map_path, map)
|
||||
}
|
||||
}
|
||||
|
||||
fn file_stem(path: &Path) -> String {
|
||||
path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Return `true` if the file starts with a JSDoc block comment (`/**`).
|
||||
fn has_file_level_jsdoc(content: &str) -> bool {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
return trimmed.starts_with("/**");
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Parse a line as an exported TypeScript declaration.
|
||||
///
|
||||
/// Returns `(kind, name)` for supported export forms, `None` otherwise.
|
||||
fn parse_exported_item(line: &str) -> Option<(String, String)> {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Strip "export default" or "export"
|
||||
let rest = if let Some(r) = trimmed.strip_prefix("export default ") {
|
||||
r.trim_start()
|
||||
} else if let Some(r) = trimmed.strip_prefix("export ") {
|
||||
r.trim_start()
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Strip optional "async"
|
||||
let rest = if let Some(r) = rest.strip_prefix("async ") {
|
||||
r.trim_start()
|
||||
} else {
|
||||
rest
|
||||
};
|
||||
|
||||
let (kind, name_part) = if let Some(r) = rest.strip_prefix("function ") {
|
||||
("function", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("class ") {
|
||||
("class", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("type ") {
|
||||
("type", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("interface ") {
|
||||
("interface", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("const ") {
|
||||
("const", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("let ") {
|
||||
("let", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("enum ") {
|
||||
("enum", r.trim_start())
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let name: String = name_part
|
||||
.chars()
|
||||
.take_while(|&c| c.is_alphanumeric() || c == '_')
|
||||
.collect();
|
||||
|
||||
if name.is_empty() {
|
||||
// "export default function() {}" — anonymous default export
|
||||
return Some((kind.to_string(), "default".to_string()));
|
||||
}
|
||||
|
||||
Some((kind.to_string(), name))
|
||||
}
|
||||
|
||||
/// Return `true` if a JSDoc comment appears before the item at `item_idx`.
|
||||
///
|
||||
/// Scans backward, skipping blank lines and decorator lines (`@…`). Returns
|
||||
/// `true` if the first substantive line ends with `*/` (closing a JSDoc block)
|
||||
/// or starts with `/**` (single-line JSDoc).
|
||||
fn has_jsdoc_before(lines: &[&str], item_idx: usize) -> bool {
|
||||
let mut i = item_idx;
|
||||
while i > 0 {
|
||||
i -= 1;
|
||||
let line = lines[i].trim();
|
||||
if line.is_empty() {
|
||||
// A blank line breaks the JSDoc–item adjacency: stop searching.
|
||||
return false;
|
||||
}
|
||||
if line.starts_with('@') {
|
||||
// Decorator — keep scanning upward
|
||||
continue;
|
||||
}
|
||||
return line.ends_with("*/") || line.starts_with("/**");
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_ts(dir: &Path, name: &str, content: &str) -> std::path::PathBuf {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_fully_documented_file_returns_ok() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_ts(
|
||||
tmp.path(),
|
||||
"app.ts",
|
||||
"/**\n * File doc.\n */\n\n/** Does something. */\nexport function hello(): void {}\n",
|
||||
);
|
||||
let adapter = TypeScriptAdapter;
|
||||
assert_eq!(adapter.check(&[&path]), CheckResult::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_detects_missing_file_jsdoc() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_ts(
|
||||
tmp.path(),
|
||||
"app.ts",
|
||||
"/** Does something. */\nexport function hello(): void {}\n",
|
||||
);
|
||||
// First non-empty line IS "/**", so this file passes the file-level check.
|
||||
// Use a file that starts with code instead.
|
||||
let path2 = write_ts(
|
||||
tmp.path(),
|
||||
"app2.ts",
|
||||
"import { foo } from './foo';\n/** A function. */\nexport function hello(): void {}\n",
|
||||
);
|
||||
let adapter = TypeScriptAdapter;
|
||||
let result = adapter.check(&[&path2]);
|
||||
assert!(
|
||||
matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "file")),
|
||||
"expected file failure, got {result:?}"
|
||||
);
|
||||
// The first file (starts with /**) should pass the file-level check
|
||||
let result2 = adapter.check(&[&path]);
|
||||
// It may still fail on the export if there's no separate export doc,
|
||||
// but the file-level check itself should pass (first line is /**)
|
||||
assert!(
|
||||
!matches!(&result2, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "file")),
|
||||
"file starting with /** should not have file-level failure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_detects_missing_export_jsdoc_with_correct_fields() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_ts(
|
||||
tmp.path(),
|
||||
"app.ts",
|
||||
"/**\n * File doc.\n */\n\nexport function undocumented(): void {}\n",
|
||||
);
|
||||
let adapter = TypeScriptAdapter;
|
||||
let result = adapter.check(&[&path]);
|
||||
if let CheckResult::Failures(failures) = result {
|
||||
let f = failures.iter().find(|f| f.item_kind == "function").unwrap();
|
||||
assert_eq!(f.item_name, "undocumented");
|
||||
assert_eq!(f.file_path, path);
|
||||
} else {
|
||||
panic!("expected failures");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_exported_item_recognises_various_kinds() {
|
||||
assert_eq!(
|
||||
parse_exported_item("export function foo()"),
|
||||
Some(("function".into(), "foo".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_exported_item("export async function bar()"),
|
||||
Some(("function".into(), "bar".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_exported_item("export class Baz"),
|
||||
Some(("class".into(), "Baz".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_exported_item("export type Qux = string;"),
|
||||
Some(("type".into(), "Qux".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_exported_item("export interface IFoo"),
|
||||
Some(("interface".into(), "IFoo".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_exported_item("export const MY_CONST = 1;"),
|
||||
Some(("const".into(), "MY_CONST".into()))
|
||||
);
|
||||
assert_eq!(parse_exported_item("function notExported()"), None);
|
||||
assert_eq!(parse_exported_item("const x = 1;"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Future Service Module Extractions
|
||||
|
||||
Recommended order for extracting remaining HTTP handlers into `service/<domain>/`
|
||||
modules, following the conventions in [service-modules.md](service-modules.md).
|
||||
|
||||
## Recommended Order
|
||||
|
||||
1. **`settings`** — small surface, few dependencies, good warm-up
|
||||
2. **`oauth`** — reads/writes token files; pure validation logic separates cleanly
|
||||
3. **`wizard`** — stateless generation logic is already mostly pure; thin I/O layer
|
||||
4. **`project`** — project scaffolding; wraps `io::fs::scaffold`, clean separation
|
||||
5. **`io`** (search/shell) — wraps `io::search` and `io::shell`; pure query-building separable
|
||||
6. **`anthropic`** — token-proxy handler; pure request-shaping + thin HTTP I/O
|
||||
7. **`stories`** (workflow) — CRDT-backed story ops; typed errors for 400/404/409/500
|
||||
8. **`events`** — SSE handler; mostly framework wiring, but event filtering is pure
|
||||
|
||||
## Special Case: `ws`
|
||||
|
||||
The WebSocket handler (`http/ws.rs`) is a **dedicated harder extraction** because
|
||||
it mixes multiple concerns (chat dispatch, permission forwarding, SSE bridging)
|
||||
and depends on long-lived async streams. Extract it last, after the above list
|
||||
is complete and the service module pattern is well-established.
|
||||
|
||||
## Notes
|
||||
|
||||
- Each extraction should link back to `docs/architecture/service-modules.md`
|
||||
in the story description to maintain consistency.
|
||||
- The `agents` extraction (story 604) is the reference implementation every
|
||||
future extraction should follow.
|
||||
@@ -0,0 +1,196 @@
|
||||
# Architecture Roadmap: Transports, Services, State Machine, CRDT
|
||||
|
||||
*Spike 613 — April 2026*
|
||||
|
||||
This document captures the current architecture across four key layers and charts
|
||||
the recommended next steps for each.
|
||||
|
||||
---
|
||||
|
||||
## 1. Current State
|
||||
|
||||
### 1.1 Service Layer
|
||||
|
||||
Stories 604–619 established a clean service extraction pattern. The
|
||||
`server/src/service/` directory now has 21 sub-modules, each following the
|
||||
functional-core / imperative-shell convention documented in
|
||||
[service-modules.md](service-modules.md).
|
||||
|
||||
**Extracted so far:**
|
||||
`agents`, `anthropic`, `bot_command`, `common`, `diagnostics`, `events`,
|
||||
`file_io`, `gateway`, `git_ops`, `health`, `merge`, `notifications`, `oauth`,
|
||||
`pipeline`, `project`, `qa`, `settings`, `shell`, `story`, `timer`, `wizard`,
|
||||
`ws`
|
||||
|
||||
**Remaining in HTTP handlers** (see [future-extractions.md](future-extractions.md)):
|
||||
The list there was written before stories 615–619. After those stories landed,
|
||||
the remaining surface is smaller. The HTTP handlers still containing inline
|
||||
business logic are: `http/ws.rs` (WebSocket dispatch) and scattered ad-hoc
|
||||
helpers in `http/mcp/` that have not yet been migrated to typed service modules.
|
||||
|
||||
### 1.2 Chat Transports
|
||||
|
||||
Four transport backends implement `ChatTransport` (defined in `chat/mod.rs`):
|
||||
|
||||
| Transport | Connection model | Rooms / channels |
|
||||
|-----------|-----------------|-----------------|
|
||||
| Matrix | Long-lived WebSocket to homeserver | Dynamic (per-room history) |
|
||||
| Slack | HTTP webhook (Events API) | Fixed at startup from bot.toml |
|
||||
| WhatsApp | HTTP webhook (Meta Graph API or Twilio) | Ambient (tracked active senders) |
|
||||
| Discord | Gateway WebSocket + REST | Fixed at startup from bot.toml |
|
||||
|
||||
All four are instantiated manually in `main.rs` (~lines 567–690) and passed into
|
||||
`AppContext`. Stage-transition notifications are pushed through
|
||||
`service/notifications/`.
|
||||
|
||||
**Known issue (Bug 501):** The Matrix bot spawns its own `TimerStore` instead of
|
||||
consuming the shared `AppContext.timer_store`. This means MCP-tool cancellations
|
||||
and the bot's tick loop see different in-memory state.
|
||||
|
||||
### 1.3 Pipeline State Machine
|
||||
|
||||
`server/src/pipeline_state.rs` provides a typed, compile-time-safe state machine
|
||||
that replaces the old stringly-typed CRDT views.
|
||||
|
||||
**Synced stages (all nodes converge):**
|
||||
```
|
||||
Backlog → Coding → Qa → Merge { feature_branch, commits_ahead: NonZeroU32 }
|
||||
→ Done { merged_at, merge_commit }
|
||||
→ Archived { archived_at, reason }
|
||||
```
|
||||
|
||||
`ArchiveReason` subsumes the old `blocked`, `merge_failure`, and `review_hold`
|
||||
flags: `Completed | Abandoned | Superseded | Blocked | MergeFailed | ReviewHeld`.
|
||||
|
||||
`NonZeroU32` in `Merge` makes zero-commit merges structurally impossible.
|
||||
|
||||
**Per-node execution state (local, not replicated):**
|
||||
`Idle → Pending → Running → RateLimited → Completed`
|
||||
|
||||
**Status:** The typed state machine is defined and the projection layer
|
||||
(`PipelineItemView → PipelineItem via TryFrom`) is in place. Consumer
|
||||
migration — replacing ad-hoc string comparisons across the codebase — is the
|
||||
remaining work (tracked by Story 520).
|
||||
|
||||
### 1.4 CRDT Layer
|
||||
|
||||
`server/src/crdt_state.rs` + `crdt_sync.rs` form the distributed-state
|
||||
foundation:
|
||||
|
||||
- **Document model:** `PipelineDoc { items: ListCrdt<PipelineItemCrdt>, nodes: ListCrdt<NodePresenceCrdt> }`
|
||||
- **Registers:** `LwwRegisterCrdt<T>` for all mutable fields
|
||||
- **Persistence:** Ops stored in SQLite (`pipeline.db`); `CrdtEvent` broadcast on every stage change
|
||||
- **Sync protocol:** WebSocket `/crdt-sync` — bulk dump on connect (text), individual `SignedOp`s in real-time (binary)
|
||||
- **Backpressure:** Slow peers are disconnected; they reconnect and get a fresh bulk dump
|
||||
|
||||
**Filesystem shadows** (`huskies/work/`) are now a secondary output only — CRDT is
|
||||
the source of truth. Several clean-up stories (513, 517) remain backlogged to
|
||||
remove the remaining fallback paths.
|
||||
|
||||
---
|
||||
|
||||
## 2. Roadmap
|
||||
|
||||
### Phase A — Finish the State Machine Migration (Story 520)
|
||||
|
||||
**Goal:** Every pipeline query uses the typed `PipelineItem` enum instead of
|
||||
raw string comparisons on `stage`.
|
||||
|
||||
Work:
|
||||
1. Replace `stage == "current"` / `"qa"` / `"merge"` patterns in `agents/`,
|
||||
`http/mcp/`, `chat/commands/`, and `gateway.rs` with `matches!(item, PipelineItem::Coding)` etc.
|
||||
2. Remove the `PipelineItemView` → string projection paths once all consumers
|
||||
use the typed enum.
|
||||
3. Add exhaustive match tests in `pipeline_state.rs` so new stages cause
|
||||
compile-time failures, not silent mismatches.
|
||||
|
||||
### Phase B — Transport Registry Abstraction
|
||||
|
||||
**Goal:** Replace the manual transport wiring in `main.rs` with a pluggable
|
||||
registry, making it easy to add or remove transports without modifying the
|
||||
startup sequence.
|
||||
|
||||
Work:
|
||||
1. Define a `TransportRegistry` that holds `Vec<Box<dyn ChatTransport>>` keyed
|
||||
by `TransportKind` (Matrix, Slack, WhatsApp, Discord).
|
||||
2. Move the per-transport instantiation logic from `main.rs` into
|
||||
`service/transport/` following the service module conventions.
|
||||
3. Unify webhook signature verification (currently duplicated between Slack and
|
||||
WhatsApp) into a shared `service/transport/verify.rs`.
|
||||
4. Fix Bug 501: pass the shared `AppContext.timer_store` into the Matrix bot
|
||||
instead of spawning a private instance.
|
||||
5. Unify message history persistence (each transport currently owns a separate
|
||||
history file format) into a common `service/transport/history.rs`.
|
||||
|
||||
### Phase C — CRDT Cleanup (Stories 513, 517, 518, 519, 521)
|
||||
|
||||
**Goal:** Remove all legacy filesystem-first paths and complete the
|
||||
CRDT-as-source-of-truth migration.
|
||||
|
||||
Priority order (based on risk/value):
|
||||
1. **519** — Mergemaster must detect zero-commits-ahead and fail loudly instead of
|
||||
silently exiting. Structural fix: `Merge { commits_ahead: NonZeroU32 }` already
|
||||
enforces this — just ensure mergemaster reads from the typed enum.
|
||||
2. **518** — `apply_and_persist` should log when the persist tx fails instead of
|
||||
silently dropping ops.
|
||||
3. **513** — Startup reconciliation pass: detect drift between CRDT pipeline items
|
||||
and filesystem shadows, heal or report.
|
||||
4. **517** — Remove filesystem shadow fallback paths from `lifecycle.rs`.
|
||||
5. **521** — MCP HTTP capability to write a CRDT tombstone-delete op, clearing a
|
||||
story from in-memory state cleanly.
|
||||
6. **511** — Lamport clock inner seq resets to 1 on restart instead of resuming
|
||||
from `max(own_author_seq) + 1`. Low risk to fix, high risk to leave.
|
||||
|
||||
### Phase D — Distributed Node Authentication (Story 480)
|
||||
|
||||
**Goal:** Cryptographic node identity for the distributed mesh.
|
||||
|
||||
Nodes already carry an Ed25519 pubkey as their `node_id` in `NodePresenceCrdt`.
|
||||
Work:
|
||||
1. Sign each `SignedOp` with the node's Ed25519 key before broadcast.
|
||||
2. Verify signatures on receipt in `crdt_sync.rs` before applying ops.
|
||||
3. Expose the node's public key via `NodePresenceCrdt.address` so peers can
|
||||
bootstrap trust.
|
||||
4. Add a key-rotation path for long-lived nodes.
|
||||
|
||||
### Phase E — Build Agent Mode Polish (Story 479)
|
||||
|
||||
**Goal:** Stable headless build-agent mode (`huskies --rendezvous`) for
|
||||
distributing story processing across multiple machines.
|
||||
|
||||
Work:
|
||||
1. Resolve claim-timeout races: if a node claims a story and dies, the claim
|
||||
should expire after a configurable TTL and be re-claimable.
|
||||
2. Stale merge-job lock (Bug 498) — a lock left by a dead node should be
|
||||
detectable and clearable by the surviving cluster.
|
||||
3. CRDT Lamport clock fix (511) is a prerequisite — distributed agents need
|
||||
monotonically increasing sequences to converge correctly.
|
||||
|
||||
---
|
||||
|
||||
## 3. Dependency Graph
|
||||
|
||||
```
|
||||
Phase A (State Machine)
|
||||
↓
|
||||
Phase B (Transport Registry) Phase C (CRDT Cleanup: 511, 518, 513, 517, 521, 519)
|
||||
↓
|
||||
Phase D (Cryptographic Auth)
|
||||
↓
|
||||
Phase E (Build Agent Polish)
|
||||
```
|
||||
|
||||
Phase A and C can progress in parallel. Phase B is independent of C/D/E.
|
||||
Phase D requires Phase C (especially 511 and 518). Phase E requires Phase D.
|
||||
|
||||
---
|
||||
|
||||
## 4. What NOT to Do
|
||||
|
||||
- **Don't split `crdt_state.rs` prematurely.** It's large but internally
|
||||
cohesive. A split should wait until the cleanup stories (Phase C) are done.
|
||||
- **Don't add a transport abstraction layer before fixing Bug 501.** A registry
|
||||
that instantiates a broken Matrix bot just propagates the bug.
|
||||
- **Don't extract `http/ws.rs` to a service module before Phase A is done.**
|
||||
The WebSocket handler touches pipeline state in string form; migrating it
|
||||
while the state machine migration is in progress will cause double-churn.
|
||||
@@ -0,0 +1,227 @@
|
||||
# Service Module Conventions
|
||||
|
||||
This document defines the layout, layering rules, and patterns for all service
|
||||
modules under `server/src/service/`. Every extraction from the HTTP handlers to
|
||||
a service module **must** follow these conventions.
|
||||
|
||||
---
|
||||
|
||||
## 1. Directory Layout
|
||||
|
||||
```
|
||||
server/src/service/<domain>/
|
||||
mod.rs — public API, typed Error, orchestration, integration tests
|
||||
io.rs — every side-effectful call; the ONLY file that may touch the
|
||||
filesystem, spawn processes, or call external crates that do
|
||||
<topic>.rs — pure logic for a named concern within the domain; no I/O
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- `<domain>` matches the HTTP handler filename (e.g. `agents`, `settings`,
|
||||
`oauth`).
|
||||
- **No file named `logic.rs`** — use a descriptive domain name instead
|
||||
(e.g. `selection.rs`, `token.rs`, `validation.rs`).
|
||||
- New topic files are added when a pure concern grows beyond ~50 lines or when
|
||||
it has independent test coverage needs.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Functional-Core / Imperative-Shell Rule
|
||||
|
||||
```
|
||||
io.rs (imperative shell) ←→ mod.rs (orchestrator) ←→ <topic>.rs (functional core)
|
||||
```
|
||||
|
||||
| Layer | Allowed | Forbidden |
|
||||
|-------|---------|-----------|
|
||||
| `<topic>.rs` | Pure Rust, data-transformation, branching logic, pattern matching | Any I/O |
|
||||
| `io.rs` | `std::fs`, `std::process`, `tokio::fs`, network calls, `SystemTime::now` | Business logic beyond a thin wrapper |
|
||||
| `mod.rs` | Calls into `io.rs` and `<topic>.rs`; owns the `Error` type | Direct I/O without going through `io.rs` |
|
||||
|
||||
**Grep-enforceable check:** The following must NOT appear in any `service/<domain>/` file other than `io.rs`:
|
||||
|
||||
- `std::fs`
|
||||
- `std::process`
|
||||
- `std::thread::sleep`
|
||||
- `tokio::fs`
|
||||
- `reqwest`
|
||||
- `SystemTime::now`
|
||||
|
||||
---
|
||||
|
||||
## 3. Error Type Pattern
|
||||
|
||||
Each service domain declares its own typed error enum in `mod.rs`:
|
||||
|
||||
```rust
|
||||
/// Errors returned by `service::agents` operations.
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
ProjectRootNotConfigured,
|
||||
AgentNotFound(String),
|
||||
WorkItemNotFound(String),
|
||||
WorktreeError(String),
|
||||
ConfigError(String),
|
||||
IoError(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error { ... }
|
||||
```
|
||||
|
||||
HTTP handlers map service errors to **specific** HTTP status codes:
|
||||
|
||||
| Error variant | HTTP status |
|
||||
|--------------|-------------|
|
||||
| `ProjectRootNotConfigured` | 400 Bad Request |
|
||||
| `AgentNotFound` | 404 Not Found |
|
||||
| `WorkItemNotFound` | 404 Not Found |
|
||||
| `WorktreeError` | 400 Bad Request |
|
||||
| `ConfigError` | 400 Bad Request |
|
||||
| `IoError` | 500 Internal Server Error |
|
||||
|
||||
**No generic `bad_request` for everything** — distinguish 400 vs 404 vs 500.
|
||||
|
||||
---
|
||||
|
||||
## 4. Test Pattern
|
||||
|
||||
### Chosen default pattern: fixture helpers in `io::test_helpers`
|
||||
|
||||
All filesystem setup for tests lives in a `#[cfg(test)] pub mod test_helpers`
|
||||
block inside `io.rs`. Test blocks in `mod.rs` and topic files call these
|
||||
helpers instead of importing `std::fs` directly.
|
||||
|
||||
**Grep-enforceable check for test code:** The following must NOT appear inside
|
||||
`#[cfg(test)]` blocks in any `service/<domain>/` file **other than `io.rs`**:
|
||||
|
||||
- `std::fs::` (any item)
|
||||
- `tokio::fs`
|
||||
- `std::process::` (any item)
|
||||
- `Command::new`
|
||||
|
||||
Run to verify:
|
||||
|
||||
```sh
|
||||
grep -rn --include='*.rs' \
|
||||
'std::fs::\|tokio::fs\|std::process::\|Command::new' \
|
||||
server/src/service/ | grep -v '/io\.rs'
|
||||
```
|
||||
|
||||
This must return zero matches (including lines inside `#[cfg(test)]` blocks).
|
||||
|
||||
### Pure topic files (`<topic>.rs`)
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Unit tests MUST:
|
||||
// - Use no tempdir, tokio runtime, or filesystem
|
||||
// - Cover every branch of every public function
|
||||
#[test]
|
||||
fn filter_removes_archived_agents() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### `io.rs`
|
||||
|
||||
```rust
|
||||
/// Fixture helpers — the ONLY place allowed to call std::fs in tests.
|
||||
#[cfg(test)]
|
||||
pub mod test_helpers {
|
||||
use tempfile::TempDir;
|
||||
|
||||
pub fn make_work_dirs(tmp: &TempDir) { ... }
|
||||
pub fn make_stage_dirs(tmp: &TempDir) { ... }
|
||||
pub fn make_project_toml(tmp: &TempDir, content: &str) { ... }
|
||||
pub fn write_story_file(tmp: &TempDir, relative_path: &str, content: &str) { ... }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// IO tests MAY use tempdirs and real filesystem.
|
||||
// Keep them few and focused on the thin I/O wrapper contract.
|
||||
#[test]
|
||||
fn is_archived_returns_true_when_in_done() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### `mod.rs`
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use io::test_helpers::*; // ← fixture helpers; never import std::fs here
|
||||
|
||||
// Integration tests compose io + pure layers end-to-end.
|
||||
// May use tempdirs. Keep the count small — they are integration-level.
|
||||
#[tokio::test]
|
||||
async fn list_agents_excludes_archived() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Dependency Injection Pattern
|
||||
|
||||
Service functions take **only the dependencies they actually use**:
|
||||
|
||||
```rust
|
||||
// Good — takes only what it needs
|
||||
pub async fn start_agent(
|
||||
pool: &AgentPool,
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
agent_name: Option<&str>,
|
||||
) -> Result<AgentInfo, Error> { ... }
|
||||
|
||||
// Bad — takes the whole AppContext
|
||||
pub async fn start_agent(ctx: &AppContext, ...) -> Result<AgentInfo, Error> { ... }
|
||||
```
|
||||
|
||||
Standard injected dependencies for `service::agents`:
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `&AgentPool` | Agent lifecycle operations |
|
||||
| `&Path` (`project_root`) | Filesystem operations scoped to the project |
|
||||
| `&WorkflowState` | In-memory test result cache |
|
||||
|
||||
**The dependency set chosen for `agents` is the reference pattern for all future
|
||||
service module extractions.**
|
||||
|
||||
---
|
||||
|
||||
## 6. HTTP Handler Contract
|
||||
|
||||
After extraction, HTTP handlers are thin adapters:
|
||||
|
||||
```rust
|
||||
async fn start_agent(&self, payload: Json<StartAgentPayload>) -> OpenApiResult<...> {
|
||||
let project_root = self.ctx.agents.get_project_root(&self.ctx.state)
|
||||
.map_err(|e| bad_request(e))?; // extract from AppContext
|
||||
let info = service::agents::start_agent( // call service
|
||||
&self.ctx.agents, &project_root, &payload.story_id, payload.agent_name.as_deref(),
|
||||
).await.map_err(map_service_error)?; // map typed error → HTTP
|
||||
Ok(Json(AgentInfoResponse { ... })) // shape DTO
|
||||
}
|
||||
```
|
||||
|
||||
Handlers must contain **no**:
|
||||
- `std::fs` / file reads
|
||||
- `std::process` invocations
|
||||
- Inline load-mutate-save sequences
|
||||
- Inline validation that belongs in the service layer
|
||||
|
||||
---
|
||||
|
||||
## 7. Follow-up Extractions
|
||||
|
||||
See [future-extractions.md](future-extractions.md) for the recommended order
|
||||
and rationale for remaining extraction targets.
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "huskies",
|
||||
"version": "0.10.2",
|
||||
"version": "0.10.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "huskies",
|
||||
"version": "0.10.2",
|
||||
"version": "0.10.4",
|
||||
"dependencies": {
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"react": "^19.1.0",
|
||||
@@ -3832,9 +3832,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"version": "8.5.12",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
||||
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "huskies",
|
||||
"private": true,
|
||||
"version": "0.10.2",
|
||||
"version": "0.10.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+10
-1
@@ -194,7 +194,6 @@ body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Agent activity indicator pulse */
|
||||
@@ -210,6 +209,16 @@ body,
|
||||
}
|
||||
}
|
||||
|
||||
/* Spinner for in-progress deterministic merges */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Agent lozenge appearance animation (simulates arriving from agents panel) */
|
||||
@keyframes agentAppear {
|
||||
from {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api } from "./api/client";
|
||||
|
||||
vi.mock("./api/gateway", () => ({
|
||||
gatewayApi: {
|
||||
getServerMode: vi.fn().mockResolvedValue({ mode: "standard" }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./api/client", () => {
|
||||
const api = {
|
||||
getCurrentProject: vi.fn(),
|
||||
@@ -76,7 +82,11 @@ describe("App", () => {
|
||||
|
||||
async function renderApp() {
|
||||
const { default: App } = await import("./App");
|
||||
return render(<App />);
|
||||
let result!: ReturnType<typeof render>;
|
||||
await act(async () => {
|
||||
result = render(<App />);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
it("calls getCurrentProject() on mount", async () => {
|
||||
|
||||
@@ -132,38 +132,6 @@ describe("agentsApi", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAgents", () => {
|
||||
it("sends GET to /agents and returns agent list", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([sampleAgent]));
|
||||
|
||||
const result = await agentsApi.listAgents();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/agents",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
expect(result).toEqual([sampleAgent]);
|
||||
});
|
||||
|
||||
it("returns empty array when no agents running", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([]));
|
||||
|
||||
const result = await agentsApi.listAgents();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([]));
|
||||
|
||||
await agentsApi.listAgents("http://localhost:3002/api");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3002/api/agents",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAgentConfig", () => {
|
||||
it("sends GET to /agents/config and returns config list", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));
|
||||
@@ -216,15 +184,17 @@ describe("agentsApi", () => {
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws on non-ok response with body text", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(404, "agent not found"));
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(404, "config not found"));
|
||||
|
||||
await expect(agentsApi.listAgents()).rejects.toThrow("agent not found");
|
||||
await expect(agentsApi.getAgentConfig()).rejects.toThrow(
|
||||
"config not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws with status code when no body", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
|
||||
|
||||
await expect(agentsApi.listAgents()).rejects.toThrow(
|
||||
await expect(agentsApi.getAgentConfig()).rejects.toThrow(
|
||||
"Request failed (500)",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { rpcCall } from "./rpc";
|
||||
|
||||
export type AgentStatusValue = "pending" | "running" | "completed" | "failed";
|
||||
|
||||
export interface AgentInfo {
|
||||
@@ -94,8 +96,8 @@ export const agentsApi = {
|
||||
);
|
||||
},
|
||||
|
||||
listAgents(baseUrl?: string) {
|
||||
return requestJson<AgentInfo[]>("/agents", {}, baseUrl);
|
||||
listAgents(_baseUrl?: string) {
|
||||
return rpcCall<AgentInfo[]>("active_agents.list");
|
||||
},
|
||||
|
||||
getAgentConfig(baseUrl?: string) {
|
||||
|
||||
@@ -267,6 +267,7 @@ describe("ChatWebSocket", () => {
|
||||
qa: [],
|
||||
merge: [],
|
||||
done: [],
|
||||
deterministic_merges_in_flight: [],
|
||||
};
|
||||
instances[1].simulateMessage({ type: "pipeline_state", ...freshState });
|
||||
|
||||
|
||||
@@ -1,748 +0,0 @@
|
||||
export type WsRequest =
|
||||
| {
|
||||
type: "chat";
|
||||
messages: Message[];
|
||||
config: ProviderConfig;
|
||||
}
|
||||
| {
|
||||
type: "cancel";
|
||||
}
|
||||
| {
|
||||
type: "permission_response";
|
||||
request_id: string;
|
||||
approved: boolean;
|
||||
always_allow: boolean;
|
||||
}
|
||||
| { type: "ping" }
|
||||
| {
|
||||
type: "side_question";
|
||||
question: string;
|
||||
context_messages: Message[];
|
||||
config: ProviderConfig;
|
||||
};
|
||||
|
||||
export interface WizardStepInfo {
|
||||
step: string;
|
||||
label: string;
|
||||
status: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface WizardStateData {
|
||||
steps: WizardStepInfo[];
|
||||
current_step_index: number;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export interface AgentAssignment {
|
||||
agent_name: string;
|
||||
model: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface PipelineStageItem {
|
||||
story_id: string;
|
||||
name: string | null;
|
||||
error: string | null;
|
||||
merge_failure: string | null;
|
||||
agent: AgentAssignment | null;
|
||||
review_hold: boolean | null;
|
||||
qa: string | null;
|
||||
depends_on: number[] | null;
|
||||
}
|
||||
|
||||
export interface PipelineState {
|
||||
backlog: PipelineStageItem[];
|
||||
current: PipelineStageItem[];
|
||||
qa: PipelineStageItem[];
|
||||
merge: PipelineStageItem[];
|
||||
done: PipelineStageItem[];
|
||||
}
|
||||
|
||||
export type WsResponse =
|
||||
| { type: "token"; content: string }
|
||||
| { type: "update"; messages: Message[] }
|
||||
| { type: "session_id"; session_id: string }
|
||||
| { type: "error"; message: string }
|
||||
| {
|
||||
type: "pipeline_state";
|
||||
backlog: PipelineStageItem[];
|
||||
current: PipelineStageItem[];
|
||||
qa: PipelineStageItem[];
|
||||
merge: PipelineStageItem[];
|
||||
done: PipelineStageItem[];
|
||||
}
|
||||
| {
|
||||
type: "permission_request";
|
||||
request_id: string;
|
||||
tool_name: string;
|
||||
tool_input: Record<string, unknown>;
|
||||
}
|
||||
| { type: "tool_activity"; tool_name: string }
|
||||
| {
|
||||
type: "reconciliation_progress";
|
||||
story_id: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
/** `.story_kit/project.toml` was modified; re-fetch the agent roster. */
|
||||
| { type: "agent_config_changed" }
|
||||
/** An agent started, stopped, or changed state; re-fetch agent list. */
|
||||
| { type: "agent_state_changed" }
|
||||
| { type: "tool_activity"; tool_name: string }
|
||||
/** Heartbeat response confirming the connection is alive. */
|
||||
| { type: "pong" }
|
||||
/** Sent on connect when the project still needs onboarding (specs are placeholders). */
|
||||
| { type: "onboarding_status"; needs_onboarding: boolean }
|
||||
/** Sent on connect when a setup wizard is active. */
|
||||
| {
|
||||
type: "wizard_state";
|
||||
steps: WizardStepInfo[];
|
||||
current_step_index: number;
|
||||
completed: boolean;
|
||||
}
|
||||
/** Streaming thinking token from an extended-thinking block, separate from regular text. */
|
||||
| { type: "thinking_token"; content: string }
|
||||
/** Streaming token from a /btw side question response. */
|
||||
| { type: "side_question_token"; content: string }
|
||||
/** Final signal that the /btw side question has been fully answered. */
|
||||
| { type: "side_question_done"; response: string }
|
||||
/** A single server log entry (bulk on connect, then live). */
|
||||
| { type: "log_entry"; timestamp: string; level: string; message: string };
|
||||
|
||||
export interface ProviderConfig {
|
||||
provider: string;
|
||||
model: string;
|
||||
base_url?: string;
|
||||
enable_tools?: boolean;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
export type Role = "system" | "user" | "assistant" | "tool";
|
||||
|
||||
export interface ToolCall {
|
||||
id?: string;
|
||||
type: string;
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
role: Role;
|
||||
content: string;
|
||||
tool_calls?: ToolCall[];
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
export interface AnthropicModelInfo {
|
||||
id: string;
|
||||
context_window: number;
|
||||
}
|
||||
|
||||
export interface WorkItemContent {
|
||||
content: string;
|
||||
stage: string;
|
||||
name: string | null;
|
||||
agent: string | null;
|
||||
}
|
||||
|
||||
export interface TestCaseResult {
|
||||
name: string;
|
||||
status: "pass" | "fail";
|
||||
details: string | null;
|
||||
}
|
||||
|
||||
export interface TestResultsResponse {
|
||||
unit: TestCaseResult[];
|
||||
integration: TestCaseResult[];
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
kind: "file" | "dir";
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
path: string;
|
||||
matches: number;
|
||||
}
|
||||
|
||||
export interface AgentCostEntry {
|
||||
agent_name: string;
|
||||
model: string | null;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_creation_input_tokens: number;
|
||||
cache_read_input_tokens: number;
|
||||
total_cost_usd: number;
|
||||
}
|
||||
|
||||
export interface TokenCostResponse {
|
||||
total_cost_usd: number;
|
||||
agents: AgentCostEntry[];
|
||||
}
|
||||
|
||||
export interface TokenUsageRecord {
|
||||
story_id: string;
|
||||
agent_name: string;
|
||||
model: string | null;
|
||||
timestamp: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_creation_input_tokens: number;
|
||||
cache_read_input_tokens: number;
|
||||
total_cost_usd: number;
|
||||
}
|
||||
|
||||
export interface AllTokenUsageResponse {
|
||||
records: TokenUsageRecord[];
|
||||
}
|
||||
|
||||
export interface CommandOutput {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exit_code: number;
|
||||
}
|
||||
|
||||
export interface OAuthStatus {
|
||||
authenticated: boolean;
|
||||
expired: boolean;
|
||||
expires_at: number;
|
||||
has_refresh_token: boolean;
|
||||
}
|
||||
|
||||
declare const __HUSKIES_PORT__: string;
|
||||
|
||||
const DEFAULT_API_BASE = "/api";
|
||||
const DEFAULT_WS_PATH = "/ws";
|
||||
|
||||
export function resolveWsHost(
|
||||
isDev: boolean,
|
||||
envPort: string | undefined,
|
||||
locationHost: string,
|
||||
): string {
|
||||
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>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getCurrentProject(baseUrl?: string) {
|
||||
return requestJson<string | null>("/project", {}, baseUrl);
|
||||
},
|
||||
getKnownProjects(baseUrl?: string) {
|
||||
return requestJson<string[]>("/projects", {}, baseUrl);
|
||||
},
|
||||
forgetKnownProject(path: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/projects/forget",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
openProject(path: string, baseUrl?: string) {
|
||||
return requestJson<string>(
|
||||
"/project",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
closeProject(baseUrl?: string) {
|
||||
return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl);
|
||||
},
|
||||
getModelPreference(baseUrl?: string) {
|
||||
return requestJson<string | null>("/model", {}, baseUrl);
|
||||
},
|
||||
setModelPreference(model: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/model",
|
||||
{ method: "POST", body: JSON.stringify({ model }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
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, {}, "");
|
||||
},
|
||||
getAnthropicApiKeyExists(baseUrl?: string) {
|
||||
return requestJson<boolean>("/anthropic/key/exists", {}, baseUrl);
|
||||
},
|
||||
getAnthropicModels(baseUrl?: string) {
|
||||
return requestJson<AnthropicModelInfo[]>("/anthropic/models", {}, baseUrl);
|
||||
},
|
||||
setAnthropicApiKey(api_key: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/anthropic/key",
|
||||
{ method: "POST", body: JSON.stringify({ api_key }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
readFile(path: string, baseUrl?: string) {
|
||||
return requestJson<string>(
|
||||
"/fs/read",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
writeFile(path: string, content: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/fs/write",
|
||||
{ method: "POST", body: JSON.stringify({ path, content }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
listDirectory(path: string, baseUrl?: string) {
|
||||
return requestJson<FileEntry[]>(
|
||||
"/fs/list",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
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);
|
||||
},
|
||||
/** Trigger a server rebuild and restart. */
|
||||
rebuildAndRestart() {
|
||||
return callMcpTool("rebuild_and_restart", {});
|
||||
},
|
||||
/** Approve a story in QA, moving it to merge. */
|
||||
approveQa(storyId: string) {
|
||||
return callMcpTool("approve_qa", { story_id: storyId });
|
||||
},
|
||||
/** Reject a story in QA, moving it back to current with notes. */
|
||||
rejectQa(storyId: string, notes: string) {
|
||||
return callMcpTool("reject_qa", { story_id: storyId, notes });
|
||||
},
|
||||
/** Launch the QA app for a story's worktree. */
|
||||
launchQaApp(storyId: string) {
|
||||
return callMcpTool("launch_qa_app", { story_id: storyId });
|
||||
},
|
||||
/** Delete a story from the pipeline, stopping any running agent and removing the worktree. */
|
||||
deleteStory(storyId: string) {
|
||||
return callMcpTool("delete_story", { story_id: storyId });
|
||||
},
|
||||
/** Fetch OAuth status from the server. */
|
||||
getOAuthStatus() {
|
||||
return requestJson<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,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
async function callMcpTool(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const res = await fetch("/mcp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name: toolName, arguments: args },
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
const text = json.result?.content?.[0]?.text ?? "";
|
||||
return text;
|
||||
}
|
||||
|
||||
export class ChatWebSocket {
|
||||
private static sharedSocket: WebSocket | null = null;
|
||||
private static refCount = 0;
|
||||
private socket?: WebSocket;
|
||||
private onToken?: (content: string) => void;
|
||||
private onThinkingToken?: (content: string) => void;
|
||||
private onUpdate?: (messages: Message[]) => void;
|
||||
private onSessionId?: (sessionId: string) => void;
|
||||
private onError?: (message: string) => void;
|
||||
private onPipelineState?: (state: PipelineState) => void;
|
||||
private onPermissionRequest?: (
|
||||
requestId: string,
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>,
|
||||
) => void;
|
||||
private onActivity?: (toolName: string) => void;
|
||||
private onReconciliationProgress?: (
|
||||
storyId: string,
|
||||
status: string,
|
||||
message: string,
|
||||
) => void;
|
||||
private onAgentConfigChanged?: () => void;
|
||||
private onAgentStateChanged?: () => void;
|
||||
private onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||
private onWizardState?: (state: WizardStateData) => void;
|
||||
private onSideQuestionToken?: (content: string) => void;
|
||||
private onSideQuestionDone?: (response: string) => void;
|
||||
private onLogEntry?: (
|
||||
timestamp: string,
|
||||
level: string,
|
||||
message: string,
|
||||
) => void;
|
||||
private onConnected?: () => void;
|
||||
private connected = false;
|
||||
private closeTimer?: number;
|
||||
private wsPath = DEFAULT_WS_PATH;
|
||||
private reconnectTimer?: number;
|
||||
private reconnectDelay = 1000;
|
||||
private shouldReconnect = false;
|
||||
private heartbeatInterval?: number;
|
||||
private heartbeatTimeout?: number;
|
||||
private static readonly HEARTBEAT_INTERVAL = 30_000;
|
||||
private static readonly HEARTBEAT_TIMEOUT = 5_000;
|
||||
|
||||
private _startHeartbeat(): void {
|
||||
this._stopHeartbeat();
|
||||
this.heartbeatInterval = window.setInterval(() => {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
|
||||
const ping: WsRequest = { type: "ping" };
|
||||
this.socket.send(JSON.stringify(ping));
|
||||
this.heartbeatTimeout = window.setTimeout(() => {
|
||||
// No pong received within timeout; close socket to trigger reconnect.
|
||||
this.socket?.close();
|
||||
}, ChatWebSocket.HEARTBEAT_TIMEOUT);
|
||||
}, ChatWebSocket.HEARTBEAT_INTERVAL);
|
||||
}
|
||||
|
||||
private _stopHeartbeat(): void {
|
||||
window.clearInterval(this.heartbeatInterval);
|
||||
window.clearTimeout(this.heartbeatTimeout);
|
||||
this.heartbeatInterval = undefined;
|
||||
this.heartbeatTimeout = undefined;
|
||||
}
|
||||
|
||||
private _buildWsUrl(): string {
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const wsHost = resolveWsHost(
|
||||
import.meta.env.DEV,
|
||||
typeof __HUSKIES_PORT__ !== "undefined" ? __HUSKIES_PORT__ : undefined,
|
||||
window.location.host,
|
||||
);
|
||||
return `${protocol}://${wsHost}${this.wsPath}`;
|
||||
}
|
||||
|
||||
private _attachHandlers(): void {
|
||||
if (!this.socket) return;
|
||||
this.socket.onopen = () => {
|
||||
this.reconnectDelay = 1000;
|
||||
this._startHeartbeat();
|
||||
this.onConnected?.();
|
||||
};
|
||||
this.socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as WsResponse;
|
||||
if (data.type === "token") this.onToken?.(data.content);
|
||||
if (data.type === "thinking_token")
|
||||
this.onThinkingToken?.(data.content);
|
||||
if (data.type === "update") this.onUpdate?.(data.messages);
|
||||
if (data.type === "session_id") this.onSessionId?.(data.session_id);
|
||||
if (data.type === "error") this.onError?.(data.message);
|
||||
if (data.type === "pipeline_state")
|
||||
this.onPipelineState?.({
|
||||
backlog: data.backlog,
|
||||
current: data.current,
|
||||
qa: data.qa,
|
||||
merge: data.merge,
|
||||
done: data.done,
|
||||
});
|
||||
if (data.type === "permission_request")
|
||||
this.onPermissionRequest?.(
|
||||
data.request_id,
|
||||
data.tool_name,
|
||||
data.tool_input,
|
||||
);
|
||||
if (data.type === "tool_activity") this.onActivity?.(data.tool_name);
|
||||
if (data.type === "reconciliation_progress")
|
||||
this.onReconciliationProgress?.(
|
||||
data.story_id,
|
||||
data.status,
|
||||
data.message,
|
||||
);
|
||||
if (data.type === "agent_config_changed") this.onAgentConfigChanged?.();
|
||||
if (data.type === "agent_state_changed") this.onAgentStateChanged?.();
|
||||
if (data.type === "onboarding_status")
|
||||
this.onOnboardingStatus?.(data.needs_onboarding);
|
||||
if (data.type === "wizard_state")
|
||||
this.onWizardState?.({
|
||||
steps: data.steps,
|
||||
current_step_index: data.current_step_index,
|
||||
completed: data.completed,
|
||||
});
|
||||
if (data.type === "side_question_token")
|
||||
this.onSideQuestionToken?.(data.content);
|
||||
if (data.type === "side_question_done")
|
||||
this.onSideQuestionDone?.(data.response);
|
||||
if (data.type === "log_entry")
|
||||
this.onLogEntry?.(data.timestamp, data.level, data.message);
|
||||
if (data.type === "pong") {
|
||||
window.clearTimeout(this.heartbeatTimeout);
|
||||
this.heartbeatTimeout = undefined;
|
||||
}
|
||||
} catch (err) {
|
||||
this.onError?.(String(err));
|
||||
}
|
||||
};
|
||||
this.socket.onerror = () => {
|
||||
this.onError?.("WebSocket error");
|
||||
};
|
||||
this.socket.onclose = () => {
|
||||
if (this.shouldReconnect && this.connected) {
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private _scheduleReconnect(): void {
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
const delay = this.reconnectDelay;
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.reconnectTimer = undefined;
|
||||
const wsUrl = this._buildWsUrl();
|
||||
ChatWebSocket.sharedSocket = new WebSocket(wsUrl);
|
||||
this.socket = ChatWebSocket.sharedSocket;
|
||||
this._attachHandlers();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
connect(
|
||||
handlers: {
|
||||
onToken?: (content: string) => void;
|
||||
onThinkingToken?: (content: string) => void;
|
||||
onUpdate?: (messages: Message[]) => void;
|
||||
onSessionId?: (sessionId: string) => void;
|
||||
onError?: (message: string) => void;
|
||||
onPipelineState?: (state: PipelineState) => void;
|
||||
onPermissionRequest?: (
|
||||
requestId: string,
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>,
|
||||
) => void;
|
||||
onActivity?: (toolName: string) => void;
|
||||
onReconciliationProgress?: (
|
||||
storyId: string,
|
||||
status: string,
|
||||
message: string,
|
||||
) => void;
|
||||
onAgentConfigChanged?: () => void;
|
||||
onAgentStateChanged?: () => void;
|
||||
onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||
onWizardState?: (state: WizardStateData) => void;
|
||||
onSideQuestionToken?: (content: string) => void;
|
||||
onSideQuestionDone?: (response: string) => void;
|
||||
onLogEntry?: (timestamp: string, level: string, message: string) => void;
|
||||
onConnected?: () => void;
|
||||
},
|
||||
wsPath = DEFAULT_WS_PATH,
|
||||
) {
|
||||
this.onToken = handlers.onToken;
|
||||
this.onThinkingToken = handlers.onThinkingToken;
|
||||
this.onUpdate = handlers.onUpdate;
|
||||
this.onSessionId = handlers.onSessionId;
|
||||
this.onError = handlers.onError;
|
||||
this.onPipelineState = handlers.onPipelineState;
|
||||
this.onPermissionRequest = handlers.onPermissionRequest;
|
||||
this.onActivity = handlers.onActivity;
|
||||
this.onReconciliationProgress = handlers.onReconciliationProgress;
|
||||
this.onAgentConfigChanged = handlers.onAgentConfigChanged;
|
||||
this.onAgentStateChanged = handlers.onAgentStateChanged;
|
||||
this.onOnboardingStatus = handlers.onOnboardingStatus;
|
||||
this.onWizardState = handlers.onWizardState;
|
||||
this.onSideQuestionToken = handlers.onSideQuestionToken;
|
||||
this.onSideQuestionDone = handlers.onSideQuestionDone;
|
||||
this.onLogEntry = handlers.onLogEntry;
|
||||
this.onConnected = handlers.onConnected;
|
||||
this.wsPath = wsPath;
|
||||
this.shouldReconnect = true;
|
||||
|
||||
if (this.connected) {
|
||||
return;
|
||||
}
|
||||
this.connected = true;
|
||||
ChatWebSocket.refCount += 1;
|
||||
|
||||
if (
|
||||
!ChatWebSocket.sharedSocket ||
|
||||
ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSED ||
|
||||
ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSING
|
||||
) {
|
||||
const wsUrl = this._buildWsUrl();
|
||||
ChatWebSocket.sharedSocket = new WebSocket(wsUrl);
|
||||
}
|
||||
this.socket = ChatWebSocket.sharedSocket;
|
||||
this._attachHandlers();
|
||||
}
|
||||
|
||||
sendChat(messages: Message[], config: ProviderConfig) {
|
||||
this.send({ type: "chat", messages, config });
|
||||
}
|
||||
|
||||
sendSideQuestion(
|
||||
question: string,
|
||||
contextMessages: Message[],
|
||||
config: ProviderConfig,
|
||||
) {
|
||||
this.send({
|
||||
type: "side_question",
|
||||
question,
|
||||
context_messages: contextMessages,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.send({ type: "cancel" });
|
||||
}
|
||||
|
||||
sendPermissionResponse(
|
||||
requestId: string,
|
||||
approved: boolean,
|
||||
alwaysAllow = false,
|
||||
) {
|
||||
this.send({
|
||||
type: "permission_response",
|
||||
request_id: requestId,
|
||||
approved,
|
||||
always_allow: alwaysAllow,
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.shouldReconnect = false;
|
||||
this._stopHeartbeat();
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = undefined;
|
||||
|
||||
if (!this.connected) return;
|
||||
this.connected = false;
|
||||
ChatWebSocket.refCount = Math.max(0, ChatWebSocket.refCount - 1);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
if (this.closeTimer) {
|
||||
window.clearTimeout(this.closeTimer);
|
||||
}
|
||||
this.closeTimer = window.setTimeout(() => {
|
||||
if (ChatWebSocket.refCount === 0) {
|
||||
ChatWebSocket.sharedSocket?.close();
|
||||
ChatWebSocket.sharedSocket = null;
|
||||
}
|
||||
this.socket = ChatWebSocket.sharedSocket ?? undefined;
|
||||
this.closeTimer = undefined;
|
||||
}, 250);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ChatWebSocket.refCount === 0) {
|
||||
ChatWebSocket.sharedSocket?.close();
|
||||
ChatWebSocket.sharedSocket = null;
|
||||
}
|
||||
this.socket = ChatWebSocket.sharedSocket ?? undefined;
|
||||
}
|
||||
|
||||
private send(payload: WsRequest) {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
this.onError?.("WebSocket is not connected");
|
||||
return;
|
||||
}
|
||||
this.socket.send(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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
|
||||
* current page's host so the socket connects to the same origin.
|
||||
*/
|
||||
export function resolveWsHost(
|
||||
isDev: boolean,
|
||||
envPort: string | undefined,
|
||||
locationHost: string,
|
||||
): string {
|
||||
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
|
||||
* string if the result has no content.
|
||||
*/
|
||||
export async function callMcpTool(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const res = await fetch("/mcp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name: toolName, arguments: args },
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
const text = json.result?.content?.[0]?.text ?? "";
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Typed REST and MCP wrappers for all Huskies server endpoints. */
|
||||
export const api = {
|
||||
getCurrentProject(baseUrl?: string) {
|
||||
return requestJson<string | null>("/project", {}, baseUrl);
|
||||
},
|
||||
getKnownProjects(baseUrl?: string) {
|
||||
return requestJson<string[]>("/projects", {}, baseUrl);
|
||||
},
|
||||
forgetKnownProject(path: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/projects/forget",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
openProject(path: string, baseUrl?: string) {
|
||||
return requestJson<string>(
|
||||
"/project",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
closeProject(baseUrl?: string) {
|
||||
return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl);
|
||||
},
|
||||
getModelPreference(baseUrl?: string) {
|
||||
return requestJson<string | null>("/model", {}, baseUrl);
|
||||
},
|
||||
setModelPreference(model: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/model",
|
||||
{ method: "POST", body: JSON.stringify({ model }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
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, {}, "");
|
||||
},
|
||||
getAnthropicApiKeyExists(baseUrl?: string) {
|
||||
return requestJson<boolean>("/anthropic/key/exists", {}, baseUrl);
|
||||
},
|
||||
getAnthropicModels(baseUrl?: string) {
|
||||
return requestJson<AnthropicModelInfo[]>("/anthropic/models", {}, baseUrl);
|
||||
},
|
||||
setAnthropicApiKey(api_key: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/anthropic/key",
|
||||
{ method: "POST", body: JSON.stringify({ api_key }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
readFile(path: string, baseUrl?: string) {
|
||||
return requestJson<string>(
|
||||
"/fs/read",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
writeFile(path: string, content: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/fs/write",
|
||||
{ method: "POST", body: JSON.stringify({ path, content }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
listDirectory(path: string, baseUrl?: string) {
|
||||
return requestJson<FileEntry[]>(
|
||||
"/fs/list",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
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);
|
||||
},
|
||||
/** Trigger a server rebuild and restart. */
|
||||
rebuildAndRestart() {
|
||||
return callMcpTool("rebuild_and_restart", {});
|
||||
},
|
||||
/** Approve a story in QA, moving it to merge. */
|
||||
approveQa(storyId: string) {
|
||||
return callMcpTool("approve_qa", { story_id: storyId });
|
||||
},
|
||||
/** Reject a story in QA, moving it back to current with notes. */
|
||||
rejectQa(storyId: string, notes: string) {
|
||||
return callMcpTool("reject_qa", { story_id: storyId, notes });
|
||||
},
|
||||
/** Launch the QA app for a story's worktree. */
|
||||
launchQaApp(storyId: string) {
|
||||
return callMcpTool("launch_qa_app", { story_id: storyId });
|
||||
},
|
||||
/** Delete a story from the pipeline, stopping any running agent and removing the worktree. */
|
||||
deleteStory(storyId: string) {
|
||||
return callMcpTool("delete_story", { story_id: storyId });
|
||||
},
|
||||
/** Fetch OAuth status from the server. */
|
||||
getOAuthStatus() {
|
||||
return requestJson<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,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Public API surface for the Huskies client module.
|
||||
* Re-exports all types, HTTP helpers, and the WebSocket client so that
|
||||
* callers importing from `api/client` continue to work without changes
|
||||
* after the module was decomposed into focused submodules.
|
||||
*/
|
||||
|
||||
/** All domain types and interfaces from the client module. */
|
||||
export type {
|
||||
AgentAssignment,
|
||||
AgentCostEntry,
|
||||
AllTokenUsageResponse,
|
||||
AnthropicModelInfo,
|
||||
CommandOutput,
|
||||
FileEntry,
|
||||
Message,
|
||||
OAuthStatus,
|
||||
PipelineState,
|
||||
PipelineStageItem,
|
||||
ProviderConfig,
|
||||
Role,
|
||||
SearchResult,
|
||||
StatusEvent,
|
||||
TestCaseResult,
|
||||
TestResultsResponse,
|
||||
TokenCostResponse,
|
||||
TokenUsageRecord,
|
||||
ToolCall,
|
||||
WizardStateData,
|
||||
WizardStepInfo,
|
||||
WorkItemContent,
|
||||
WsRequest,
|
||||
WsResponse,
|
||||
} from "./types";
|
||||
|
||||
export { api, callMcpTool, DEFAULT_API_BASE, resolveWsHost } from "./http";
|
||||
|
||||
export { ChatWebSocket } from "./websocket";
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Type and interface definitions for the Huskies API client.
|
||||
* All shared domain types — WebSocket messages, pipeline state,
|
||||
* provider configuration, and response shapes — live here.
|
||||
*/
|
||||
|
||||
/** A message sent from the browser to the Huskies server over WebSocket. */
|
||||
export type WsRequest =
|
||||
| {
|
||||
type: "chat";
|
||||
messages: Message[];
|
||||
config: ProviderConfig;
|
||||
}
|
||||
| {
|
||||
type: "cancel";
|
||||
}
|
||||
| {
|
||||
type: "permission_response";
|
||||
request_id: string;
|
||||
approved: boolean;
|
||||
always_allow: boolean;
|
||||
}
|
||||
| { type: "ping" }
|
||||
| {
|
||||
type: "side_question";
|
||||
question: string;
|
||||
context_messages: Message[];
|
||||
config: ProviderConfig;
|
||||
};
|
||||
|
||||
/** Metadata for a single step in the setup wizard flow. */
|
||||
export interface WizardStepInfo {
|
||||
step: string;
|
||||
label: string;
|
||||
status: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
/** Full state snapshot of the setup wizard, including all steps and completion flag. */
|
||||
export interface WizardStateData {
|
||||
steps: WizardStepInfo[];
|
||||
current_step_index: number;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
/** Describes the agent currently assigned to a pipeline work item. */
|
||||
export interface AgentAssignment {
|
||||
agent_name: string;
|
||||
model: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** A single item in any pipeline stage (backlog, current, QA, merge, or done). */
|
||||
export interface PipelineStageItem {
|
||||
story_id: string;
|
||||
name: string | null;
|
||||
error: string | null;
|
||||
merge_failure: string | null;
|
||||
agent: AgentAssignment | null;
|
||||
review_hold: boolean | null;
|
||||
qa: string | null;
|
||||
depends_on: number[] | null;
|
||||
}
|
||||
|
||||
/** Snapshot of all pipeline stages returned via WebSocket or REST. */
|
||||
export interface PipelineState {
|
||||
backlog: PipelineStageItem[];
|
||||
current: PipelineStageItem[];
|
||||
qa: PipelineStageItem[];
|
||||
merge: PipelineStageItem[];
|
||||
done: PipelineStageItem[];
|
||||
/** Story IDs that currently have a deterministic merge in progress. */
|
||||
deterministic_merges_in_flight: string[];
|
||||
}
|
||||
|
||||
/** A message received from the Huskies server over WebSocket. */
|
||||
export type WsResponse =
|
||||
| { type: "token"; content: string }
|
||||
| { type: "update"; messages: Message[] }
|
||||
| { type: "session_id"; session_id: string }
|
||||
| { type: "error"; message: string }
|
||||
| {
|
||||
type: "pipeline_state";
|
||||
backlog: PipelineStageItem[];
|
||||
current: PipelineStageItem[];
|
||||
qa: PipelineStageItem[];
|
||||
merge: PipelineStageItem[];
|
||||
done: PipelineStageItem[];
|
||||
deterministic_merges_in_flight: string[];
|
||||
}
|
||||
| {
|
||||
type: "permission_request";
|
||||
request_id: string;
|
||||
tool_name: string;
|
||||
tool_input: Record<string, unknown>;
|
||||
}
|
||||
| { type: "tool_activity"; tool_name: string }
|
||||
| {
|
||||
type: "reconciliation_progress";
|
||||
story_id: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
/** `.story_kit/project.toml` was modified; re-fetch the agent roster. */
|
||||
| { type: "agent_config_changed" }
|
||||
/** An agent started, stopped, or changed state; re-fetch agent list. */
|
||||
| { type: "agent_state_changed" }
|
||||
/** Heartbeat response confirming the connection is alive. */
|
||||
| { type: "pong" }
|
||||
/** Sent on connect when the project still needs onboarding (specs are placeholders). */
|
||||
| { type: "onboarding_status"; needs_onboarding: boolean }
|
||||
/** Sent on connect when a setup wizard is active. */
|
||||
| {
|
||||
type: "wizard_state";
|
||||
steps: WizardStepInfo[];
|
||||
current_step_index: number;
|
||||
completed: boolean;
|
||||
}
|
||||
/** Streaming thinking token from an extended-thinking block, separate from regular text. */
|
||||
| { type: "thinking_token"; content: string }
|
||||
/** Streaming token from a /btw side question response. */
|
||||
| { type: "side_question_token"; content: string }
|
||||
/** Final signal that the /btw side question has been fully answered. */
|
||||
| { type: "side_question_done"; response: string }
|
||||
/** A single server log entry (bulk on connect, then live). */
|
||||
| { type: "log_entry"; timestamp: string; level: string; message: string }
|
||||
/** A structured pipeline status event from the status broadcaster. */
|
||||
| { type: "status_update"; event: StatusEvent };
|
||||
|
||||
/**
|
||||
* A structured pipeline status event emitted by the status broadcaster.
|
||||
*
|
||||
* The discriminant `type` field enables per-event-type rendering without
|
||||
* parsing strings. All fields from the original event are preserved so
|
||||
* future UI stories can add dedicated icons, banners, or filters.
|
||||
*/
|
||||
export type StatusEvent =
|
||||
| {
|
||||
type: "stage_transition";
|
||||
story_id: string;
|
||||
story_name: string | null;
|
||||
from_stage: string;
|
||||
to_stage: string;
|
||||
}
|
||||
| {
|
||||
type: "merge_failure";
|
||||
story_id: string;
|
||||
story_name: string | null;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: "story_blocked";
|
||||
story_id: string;
|
||||
story_name: string | null;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: "rate_limit_warning";
|
||||
story_id: string;
|
||||
story_name: string | null;
|
||||
agent_name: string;
|
||||
}
|
||||
| {
|
||||
type: "rate_limit_hard_block";
|
||||
story_id: string;
|
||||
story_name: string | null;
|
||||
agent_name: string;
|
||||
reset_at: string;
|
||||
};
|
||||
|
||||
/** LLM provider configuration used when initiating a chat request. */
|
||||
export interface ProviderConfig {
|
||||
provider: string;
|
||||
model: string;
|
||||
base_url?: string;
|
||||
enable_tools?: boolean;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
/** Valid role values for a chat message. */
|
||||
export type Role = "system" | "user" | "assistant" | "tool";
|
||||
|
||||
/** An LLM tool call embedded in an assistant message. */
|
||||
export interface ToolCall {
|
||||
id?: string;
|
||||
type: string;
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** A single chat message exchanged with the LLM. */
|
||||
export interface Message {
|
||||
role: Role;
|
||||
content: string;
|
||||
tool_calls?: ToolCall[];
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
/** Anthropic model metadata returned by the models endpoint. */
|
||||
export interface AnthropicModelInfo {
|
||||
id: string;
|
||||
context_window: number;
|
||||
}
|
||||
|
||||
/** Content and metadata for a pipeline work item fetched from the server. */
|
||||
export interface WorkItemContent {
|
||||
content: string;
|
||||
stage: string;
|
||||
name: string | null;
|
||||
agent: string | null;
|
||||
}
|
||||
|
||||
/** Result for a single test case from the server's test runner. */
|
||||
export interface TestCaseResult {
|
||||
name: string;
|
||||
status: "pass" | "fail";
|
||||
details: string | null;
|
||||
}
|
||||
|
||||
/** Combined unit and integration test results for a work item. */
|
||||
export interface TestResultsResponse {
|
||||
unit: TestCaseResult[];
|
||||
integration: TestCaseResult[];
|
||||
}
|
||||
|
||||
/** A file-system entry (file or directory) returned by listing endpoints. */
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
kind: "file" | "dir";
|
||||
}
|
||||
|
||||
/** A single file-search match with path and match count. */
|
||||
export interface SearchResult {
|
||||
path: string;
|
||||
matches: number;
|
||||
}
|
||||
|
||||
/** Per-agent token usage and cost breakdown within a story. */
|
||||
export interface AgentCostEntry {
|
||||
agent_name: string;
|
||||
model: string | null;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_creation_input_tokens: number;
|
||||
cache_read_input_tokens: number;
|
||||
total_cost_usd: number;
|
||||
}
|
||||
|
||||
/** Total token cost for a work item, broken down by agent. */
|
||||
export interface TokenCostResponse {
|
||||
total_cost_usd: number;
|
||||
agents: AgentCostEntry[];
|
||||
}
|
||||
|
||||
/** A single token-usage record from the server's usage log. */
|
||||
export interface TokenUsageRecord {
|
||||
story_id: string;
|
||||
agent_name: string;
|
||||
model: string | null;
|
||||
timestamp: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_creation_input_tokens: number;
|
||||
cache_read_input_tokens: number;
|
||||
total_cost_usd: number;
|
||||
}
|
||||
|
||||
/** All token-usage records returned by the usage endpoint. */
|
||||
export interface AllTokenUsageResponse {
|
||||
records: TokenUsageRecord[];
|
||||
}
|
||||
|
||||
/** Output captured from a shell command executed on the server. */
|
||||
export interface CommandOutput {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exit_code: number;
|
||||
}
|
||||
|
||||
/** OAuth authentication status returned by the server. */
|
||||
export interface OAuthStatus {
|
||||
authenticated: boolean;
|
||||
expired: boolean;
|
||||
expires_at: number;
|
||||
has_refresh_token: boolean;
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* WebSocket client for real-time communication with the Huskies server.
|
||||
* Manages a shared socket with reference counting, automatic reconnection,
|
||||
* and heartbeat keepalive. All inbound message types are dispatched to
|
||||
* caller-supplied handler callbacks.
|
||||
*/
|
||||
|
||||
import { resolveWsHost } from "./http";
|
||||
import type {
|
||||
Message,
|
||||
PipelineState,
|
||||
ProviderConfig,
|
||||
StatusEvent,
|
||||
WizardStateData,
|
||||
WsRequest,
|
||||
WsResponse,
|
||||
} from "./types";
|
||||
|
||||
declare const __HUSKIES_PORT__: string;
|
||||
|
||||
const DEFAULT_WS_PATH = "/ws";
|
||||
|
||||
/**
|
||||
* Singleton-backed WebSocket client with automatic reconnection and heartbeat.
|
||||
* Multiple callers share one underlying socket via reference counting; the
|
||||
* socket is closed only when the last caller disconnects.
|
||||
*/
|
||||
export class ChatWebSocket {
|
||||
private static sharedSocket: WebSocket | null = null;
|
||||
private static refCount = 0;
|
||||
private socket?: WebSocket;
|
||||
private onToken?: (content: string) => void;
|
||||
private onThinkingToken?: (content: string) => void;
|
||||
private onUpdate?: (messages: Message[]) => void;
|
||||
private onSessionId?: (sessionId: string) => void;
|
||||
private onError?: (message: string) => void;
|
||||
private onPipelineState?: (state: PipelineState) => void;
|
||||
private onPermissionRequest?: (
|
||||
requestId: string,
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>,
|
||||
) => void;
|
||||
private onActivity?: (toolName: string) => void;
|
||||
private onReconciliationProgress?: (
|
||||
storyId: string,
|
||||
status: string,
|
||||
message: string,
|
||||
) => void;
|
||||
private onAgentConfigChanged?: () => void;
|
||||
private onAgentStateChanged?: () => void;
|
||||
private onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||
private onWizardState?: (state: WizardStateData) => void;
|
||||
private onSideQuestionToken?: (content: string) => void;
|
||||
private onSideQuestionDone?: (response: string) => void;
|
||||
private onLogEntry?: (
|
||||
timestamp: string,
|
||||
level: string,
|
||||
message: string,
|
||||
) => void;
|
||||
private onStatusUpdate?: (event: StatusEvent) => void;
|
||||
private onConnected?: () => void;
|
||||
private connected = false;
|
||||
private closeTimer?: number;
|
||||
private wsPath = DEFAULT_WS_PATH;
|
||||
private reconnectTimer?: number;
|
||||
private reconnectDelay = 1000;
|
||||
private shouldReconnect = false;
|
||||
private heartbeatInterval?: number;
|
||||
private heartbeatTimeout?: number;
|
||||
private static readonly HEARTBEAT_INTERVAL = 30_000;
|
||||
private static readonly HEARTBEAT_TIMEOUT = 5_000;
|
||||
|
||||
private _startHeartbeat(): void {
|
||||
this._stopHeartbeat();
|
||||
this.heartbeatInterval = window.setInterval(() => {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
|
||||
const ping: WsRequest = { type: "ping" };
|
||||
this.socket.send(JSON.stringify(ping));
|
||||
this.heartbeatTimeout = window.setTimeout(() => {
|
||||
// No pong received within timeout; close socket to trigger reconnect.
|
||||
this.socket?.close();
|
||||
}, ChatWebSocket.HEARTBEAT_TIMEOUT);
|
||||
}, ChatWebSocket.HEARTBEAT_INTERVAL);
|
||||
}
|
||||
|
||||
private _stopHeartbeat(): void {
|
||||
window.clearInterval(this.heartbeatInterval);
|
||||
window.clearTimeout(this.heartbeatTimeout);
|
||||
this.heartbeatInterval = undefined;
|
||||
this.heartbeatTimeout = undefined;
|
||||
}
|
||||
|
||||
private _buildWsUrl(): string {
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const wsHost = resolveWsHost(
|
||||
import.meta.env.DEV,
|
||||
typeof __HUSKIES_PORT__ !== "undefined" ? __HUSKIES_PORT__ : undefined,
|
||||
window.location.host,
|
||||
);
|
||||
return `${protocol}://${wsHost}${this.wsPath}`;
|
||||
}
|
||||
|
||||
private _attachHandlers(): void {
|
||||
if (!this.socket) return;
|
||||
this.socket.onopen = () => {
|
||||
this.reconnectDelay = 1000;
|
||||
this._startHeartbeat();
|
||||
this.onConnected?.();
|
||||
};
|
||||
this.socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as WsResponse;
|
||||
if (data.type === "token") this.onToken?.(data.content);
|
||||
if (data.type === "thinking_token")
|
||||
this.onThinkingToken?.(data.content);
|
||||
if (data.type === "update") this.onUpdate?.(data.messages);
|
||||
if (data.type === "session_id") this.onSessionId?.(data.session_id);
|
||||
if (data.type === "error") this.onError?.(data.message);
|
||||
if (data.type === "pipeline_state")
|
||||
this.onPipelineState?.({
|
||||
backlog: data.backlog,
|
||||
current: data.current,
|
||||
qa: data.qa,
|
||||
merge: data.merge,
|
||||
done: data.done,
|
||||
deterministic_merges_in_flight:
|
||||
data.deterministic_merges_in_flight ?? [],
|
||||
});
|
||||
if (data.type === "permission_request")
|
||||
this.onPermissionRequest?.(
|
||||
data.request_id,
|
||||
data.tool_name,
|
||||
data.tool_input,
|
||||
);
|
||||
if (data.type === "tool_activity") this.onActivity?.(data.tool_name);
|
||||
if (data.type === "reconciliation_progress")
|
||||
this.onReconciliationProgress?.(
|
||||
data.story_id,
|
||||
data.status,
|
||||
data.message,
|
||||
);
|
||||
if (data.type === "agent_config_changed") this.onAgentConfigChanged?.();
|
||||
if (data.type === "agent_state_changed") this.onAgentStateChanged?.();
|
||||
if (data.type === "onboarding_status")
|
||||
this.onOnboardingStatus?.(data.needs_onboarding);
|
||||
if (data.type === "wizard_state")
|
||||
this.onWizardState?.({
|
||||
steps: data.steps,
|
||||
current_step_index: data.current_step_index,
|
||||
completed: data.completed,
|
||||
});
|
||||
if (data.type === "side_question_token")
|
||||
this.onSideQuestionToken?.(data.content);
|
||||
if (data.type === "side_question_done")
|
||||
this.onSideQuestionDone?.(data.response);
|
||||
if (data.type === "log_entry")
|
||||
this.onLogEntry?.(data.timestamp, data.level, data.message);
|
||||
if (data.type === "status_update") this.onStatusUpdate?.(data.event);
|
||||
if (data.type === "pong") {
|
||||
window.clearTimeout(this.heartbeatTimeout);
|
||||
this.heartbeatTimeout = undefined;
|
||||
}
|
||||
} catch (err) {
|
||||
this.onError?.(String(err));
|
||||
}
|
||||
};
|
||||
this.socket.onerror = () => {
|
||||
this.onError?.("WebSocket error");
|
||||
};
|
||||
this.socket.onclose = () => {
|
||||
if (this.shouldReconnect && this.connected) {
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private _scheduleReconnect(): void {
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
const delay = this.reconnectDelay;
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.reconnectTimer = undefined;
|
||||
const wsUrl = this._buildWsUrl();
|
||||
ChatWebSocket.sharedSocket = new WebSocket(wsUrl);
|
||||
this.socket = ChatWebSocket.sharedSocket;
|
||||
this._attachHandlers();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
connect(
|
||||
handlers: {
|
||||
onToken?: (content: string) => void;
|
||||
onThinkingToken?: (content: string) => void;
|
||||
onUpdate?: (messages: Message[]) => void;
|
||||
onSessionId?: (sessionId: string) => void;
|
||||
onError?: (message: string) => void;
|
||||
onPipelineState?: (state: PipelineState) => void;
|
||||
onPermissionRequest?: (
|
||||
requestId: string,
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>,
|
||||
) => void;
|
||||
onActivity?: (toolName: string) => void;
|
||||
onReconciliationProgress?: (
|
||||
storyId: string,
|
||||
status: string,
|
||||
message: string,
|
||||
) => void;
|
||||
onAgentConfigChanged?: () => void;
|
||||
onAgentStateChanged?: () => void;
|
||||
onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||
onWizardState?: (state: WizardStateData) => void;
|
||||
onSideQuestionToken?: (content: string) => void;
|
||||
onSideQuestionDone?: (response: string) => void;
|
||||
onLogEntry?: (timestamp: string, level: string, message: string) => void;
|
||||
onStatusUpdate?: (event: StatusEvent) => void;
|
||||
onConnected?: () => void;
|
||||
},
|
||||
wsPath = DEFAULT_WS_PATH,
|
||||
) {
|
||||
this.onToken = handlers.onToken;
|
||||
this.onThinkingToken = handlers.onThinkingToken;
|
||||
this.onUpdate = handlers.onUpdate;
|
||||
this.onSessionId = handlers.onSessionId;
|
||||
this.onError = handlers.onError;
|
||||
this.onPipelineState = handlers.onPipelineState;
|
||||
this.onPermissionRequest = handlers.onPermissionRequest;
|
||||
this.onActivity = handlers.onActivity;
|
||||
this.onReconciliationProgress = handlers.onReconciliationProgress;
|
||||
this.onAgentConfigChanged = handlers.onAgentConfigChanged;
|
||||
this.onAgentStateChanged = handlers.onAgentStateChanged;
|
||||
this.onOnboardingStatus = handlers.onOnboardingStatus;
|
||||
this.onWizardState = handlers.onWizardState;
|
||||
this.onSideQuestionToken = handlers.onSideQuestionToken;
|
||||
this.onSideQuestionDone = handlers.onSideQuestionDone;
|
||||
this.onLogEntry = handlers.onLogEntry;
|
||||
this.onStatusUpdate = handlers.onStatusUpdate;
|
||||
this.onConnected = handlers.onConnected;
|
||||
this.wsPath = wsPath;
|
||||
this.shouldReconnect = true;
|
||||
|
||||
if (this.connected) {
|
||||
return;
|
||||
}
|
||||
this.connected = true;
|
||||
ChatWebSocket.refCount += 1;
|
||||
|
||||
if (
|
||||
!ChatWebSocket.sharedSocket ||
|
||||
ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSED ||
|
||||
ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSING
|
||||
) {
|
||||
const wsUrl = this._buildWsUrl();
|
||||
ChatWebSocket.sharedSocket = new WebSocket(wsUrl);
|
||||
}
|
||||
this.socket = ChatWebSocket.sharedSocket;
|
||||
this._attachHandlers();
|
||||
}
|
||||
|
||||
sendChat(messages: Message[], config: ProviderConfig) {
|
||||
this.send({ type: "chat", messages, config });
|
||||
}
|
||||
|
||||
sendSideQuestion(
|
||||
question: string,
|
||||
contextMessages: Message[],
|
||||
config: ProviderConfig,
|
||||
) {
|
||||
this.send({
|
||||
type: "side_question",
|
||||
question,
|
||||
context_messages: contextMessages,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.send({ type: "cancel" });
|
||||
}
|
||||
|
||||
sendPermissionResponse(
|
||||
requestId: string,
|
||||
approved: boolean,
|
||||
alwaysAllow = false,
|
||||
) {
|
||||
this.send({
|
||||
type: "permission_response",
|
||||
request_id: requestId,
|
||||
approved,
|
||||
always_allow: alwaysAllow,
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.shouldReconnect = false;
|
||||
this._stopHeartbeat();
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = undefined;
|
||||
|
||||
if (!this.connected) return;
|
||||
this.connected = false;
|
||||
ChatWebSocket.refCount = Math.max(0, ChatWebSocket.refCount - 1);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
if (this.closeTimer) {
|
||||
window.clearTimeout(this.closeTimer);
|
||||
}
|
||||
this.closeTimer = window.setTimeout(() => {
|
||||
if (ChatWebSocket.refCount === 0) {
|
||||
ChatWebSocket.sharedSocket?.close();
|
||||
ChatWebSocket.sharedSocket = null;
|
||||
}
|
||||
this.socket = ChatWebSocket.sharedSocket ?? undefined;
|
||||
this.closeTimer = undefined;
|
||||
}, 250);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ChatWebSocket.refCount === 0) {
|
||||
ChatWebSocket.sharedSocket?.close();
|
||||
ChatWebSocket.sharedSocket = null;
|
||||
}
|
||||
this.socket = ChatWebSocket.sharedSocket ?? undefined;
|
||||
}
|
||||
|
||||
private send(payload: WsRequest) {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
this.onError?.("WebSocket is not connected");
|
||||
return;
|
||||
}
|
||||
this.socket.send(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
+69
-26
@@ -73,6 +73,39 @@ async function gatewayRequest<T>(
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
let _mcpRequestId = 1;
|
||||
|
||||
/// Call a gateway MCP tool via JSON-RPC and return the result.
|
||||
async function gatewayMcpCall<T>(
|
||||
toolName: string,
|
||||
args: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
const id = _mcpRequestId++;
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method: "tools/call",
|
||||
params: { name: toolName, arguments: args },
|
||||
});
|
||||
const res = await fetch("/mcp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `MCP request failed (${res.status})`);
|
||||
}
|
||||
const json = (await res.json()) as {
|
||||
result?: Record<string, unknown>;
|
||||
error?: { message: string };
|
||||
};
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
return json.result as T;
|
||||
}
|
||||
|
||||
export const gatewayApi = {
|
||||
/// Returns `{ mode: "gateway" }` if this server is a gateway, otherwise rejects.
|
||||
getServerMode(): Promise<ServerMode> {
|
||||
@@ -88,7 +121,9 @@ export const gatewayApi = {
|
||||
|
||||
/// List all build agents that have registered with this gateway.
|
||||
listAgents(): Promise<JoinedAgent[]> {
|
||||
return gatewayRequest<JoinedAgent[]>("/gateway/agents");
|
||||
return gatewayMcpCall<{ agents: JoinedAgent[] }>("agents.list").then(
|
||||
(result) => result.agents ?? [],
|
||||
);
|
||||
},
|
||||
|
||||
/// Remove a registered build agent by its ID.
|
||||
@@ -111,22 +146,6 @@ export const gatewayApi = {
|
||||
return gatewayRequest<GatewayInfo>("/api/gateway");
|
||||
},
|
||||
|
||||
/// Add a new project to the gateway config.
|
||||
addProject(name: string, url: string): Promise<GatewayProject> {
|
||||
return gatewayRequest<GatewayProject>("/api/gateway/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, url }),
|
||||
});
|
||||
},
|
||||
|
||||
/// Remove a project from the gateway config.
|
||||
removeProject(name: string): Promise<void> {
|
||||
return gatewayRequest<void>(
|
||||
`/api/gateway/projects/${encodeURIComponent(name)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
},
|
||||
|
||||
/// Send a heartbeat for an agent to update its last-seen timestamp.
|
||||
heartbeat(id: string): Promise<void> {
|
||||
return gatewayRequest<void>(`/gateway/agents/${id}/heartbeat`, {
|
||||
@@ -134,16 +153,40 @@ export const gatewayApi = {
|
||||
});
|
||||
},
|
||||
|
||||
/// Fetch pipeline status from all registered projects.
|
||||
getAllProjectsPipeline(): Promise<AllProjectsPipeline> {
|
||||
return gatewayRequest<AllProjectsPipeline>("/api/gateway/pipeline");
|
||||
/// Fetch pipeline status from all registered projects via the pipeline.get read-RPC.
|
||||
async getAllProjectsPipeline(): Promise<AllProjectsPipeline> {
|
||||
const res = await fetch("/mcp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "pipeline.get", params: {} }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Request failed (${res.status})`);
|
||||
}
|
||||
const rpc = await res.json() as { result?: AllProjectsPipeline; error?: { message: string } };
|
||||
if (rpc.error) {
|
||||
throw new Error(rpc.error.message);
|
||||
}
|
||||
return rpc.result!;
|
||||
},
|
||||
|
||||
/// Switch the active project.
|
||||
switchProject(project: string): Promise<{ ok: boolean; error?: string }> {
|
||||
return gatewayRequest<{ ok: boolean; error?: string }>(
|
||||
"/api/gateway/switch",
|
||||
{ method: "POST", body: JSON.stringify({ project }) },
|
||||
);
|
||||
/// Switch the active project via the MCP switch_project tool.
|
||||
async switchProject(project: string): Promise<{ ok: boolean; error?: string }> {
|
||||
const res = await fetch("/mcp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name: "switch_project", arguments: { project } },
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
return { ok: false, error: data.error.message ?? String(data.error) };
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
let correlationCounter = 0;
|
||||
|
||||
function nextCorrelationId(): string {
|
||||
return `rpc-${Date.now()}-${++correlationCounter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the WebSocket URL for the `/ws` endpoint, deriving the protocol
|
||||
* (ws/wss) and host from the current page location.
|
||||
*/
|
||||
function buildWsUrl(): string {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${proto}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
export interface RpcResponse<T = unknown> {
|
||||
ok: boolean;
|
||||
result?: T;
|
||||
error?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function rpcCall<T = unknown>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
timeoutMs = 5000,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const correlationId = nextCorrelationId();
|
||||
const ws = new WebSocket(buildWsUrl());
|
||||
let settled = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
ws.close();
|
||||
reject(new Error(`RPC timeout for ${method}`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
kind: "rpc_request",
|
||||
version: 1,
|
||||
correlation_id: correlationId,
|
||||
ttl_ms: timeoutMs,
|
||||
method,
|
||||
params,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
// Only process rpc_response frames matching our correlation ID.
|
||||
if (
|
||||
data.kind === "rpc_response" &&
|
||||
data.correlation_id === correlationId
|
||||
) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
if (data.ok) {
|
||||
resolve(data.result as T);
|
||||
} else {
|
||||
reject(
|
||||
new Error(data.error || `RPC error: ${data.code || "UNKNOWN"}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Ignore other messages (pipeline_state, onboarding_status, etc.)
|
||||
} catch {
|
||||
// Ignore non-JSON or unparseable messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`WebSocket error during RPC call to ${method}`));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`WebSocket closed before RPC response for ${method}`));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProjectSettings } from "./settings";
|
||||
import { settingsApi } from "./settings";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
@@ -22,7 +23,77 @@ function errorResponse(status: number, text: string) {
|
||||
return new Response(text, { status });
|
||||
}
|
||||
|
||||
const defaultProjectSettings: ProjectSettings = {
|
||||
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: 14400,
|
||||
};
|
||||
|
||||
describe("settingsApi", () => {
|
||||
describe("getProjectSettings", () => {
|
||||
it("sends GET to /settings and returns project settings", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(defaultProjectSettings));
|
||||
|
||||
const result = await settingsApi.getProjectSettings();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/settings",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("putProjectSettings", () => {
|
||||
it("sends PUT to /settings with settings body", async () => {
|
||||
const updated = { ...defaultProjectSettings, default_qa: "agent" };
|
||||
mockFetch.mockResolvedValueOnce(okResponse(updated));
|
||||
|
||||
const result = await settingsApi.putProjectSettings(updated);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/settings",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
body: JSON.stringify(updated),
|
||||
}),
|
||||
);
|
||||
expect(result.default_qa).toBe("agent");
|
||||
});
|
||||
|
||||
it("throws on validation error", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
errorResponse(400, "Invalid default_qa value"),
|
||||
);
|
||||
await expect(
|
||||
settingsApi.putProjectSettings({
|
||||
...defaultProjectSettings,
|
||||
default_qa: "invalid",
|
||||
}),
|
||||
).rejects.toThrow("Invalid default_qa value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEditorCommand", () => {
|
||||
it("sends GET to /settings/editor and returns editor settings", async () => {
|
||||
const expected = { editor_command: "zed" };
|
||||
|
||||
@@ -2,6 +2,19 @@ export interface EditorSettings {
|
||||
editor_command: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectSettings {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface OpenFileResult {
|
||||
success: boolean;
|
||||
}
|
||||
@@ -34,6 +47,21 @@ async function requestJson<T>(
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
getProjectSettings(baseUrl?: string): Promise<ProjectSettings> {
|
||||
return requestJson<ProjectSettings>("/settings", {}, baseUrl);
|
||||
},
|
||||
|
||||
putProjectSettings(
|
||||
settings: ProjectSettings,
|
||||
baseUrl?: string,
|
||||
): Promise<ProjectSettings> {
|
||||
return requestJson<ProjectSettings>(
|
||||
"/settings",
|
||||
{ method: "PUT", body: JSON.stringify(settings) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
|
||||
getEditorCommand(baseUrl?: string): Promise<EditorSettings> {
|
||||
return requestJson<EditorSettings>("/settings/editor", {}, baseUrl);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/** Agent logs card sub-component for WorkItemDetailPanel. */
|
||||
|
||||
import type { AgentInfo, AgentStatusValue } from "../api/agents";
|
||||
import { STATUS_COLORS } from "./workItemDetailPanelUtils";
|
||||
|
||||
interface AgentLogsSectionProps {
|
||||
agentInfo: AgentInfo | null;
|
||||
agentStatus: AgentStatusValue | null;
|
||||
agentLog: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the "Agent Logs" card when an agent is active, or a placeholder
|
||||
* when no agent is assigned to the story.
|
||||
*/
|
||||
export function AgentLogsSection({
|
||||
agentInfo,
|
||||
agentStatus,
|
||||
agentLog,
|
||||
}: AgentLogsSectionProps) {
|
||||
if (!agentInfo) {
|
||||
return (
|
||||
<div
|
||||
data-testid="placeholder-agent-logs"
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
padding: "10px 12px",
|
||||
background: "#161616",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8em",
|
||||
color: "#555",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
Agent Logs
|
||||
</div>
|
||||
<div style={{ fontSize: "0.75em", color: "#444" }}>Coming soon</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="agent-logs-section"
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
padding: "10px 12px",
|
||||
background: "#161616",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8em",
|
||||
color: "#888",
|
||||
}}
|
||||
>
|
||||
Agent Logs
|
||||
</div>
|
||||
{agentStatus && (
|
||||
<div
|
||||
data-testid="agent-status-badge"
|
||||
style={{
|
||||
fontSize: "0.7em",
|
||||
color: STATUS_COLORS[agentStatus],
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{agentInfo.agent_name} — {agentStatus}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{agentLog.length > 0 ? (
|
||||
<div
|
||||
data-testid="agent-log-output"
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
fontFamily: "monospace",
|
||||
color: "#ccc",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
lineHeight: "1.5",
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{agentLog.join("")}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: "0.75em", color: "#444" }}>
|
||||
{agentStatus === "running" || agentStatus === "pending"
|
||||
? "Waiting for output..."
|
||||
: "No output."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api } from "../api/client";
|
||||
import type { Message } from "../types";
|
||||
import { Chat } from "./Chat";
|
||||
|
||||
// Module-level store for the WebSocket handlers captured during connect().
|
||||
type WsHandlers = {
|
||||
onToken: (content: string) => void;
|
||||
onUpdate: (history: Message[]) => void;
|
||||
onSessionId: (sessionId: string) => void;
|
||||
onError: (message: string) => void;
|
||||
onActivity: (toolName: string) => void;
|
||||
onReconciliationProgress: (
|
||||
storyId: string,
|
||||
status: string,
|
||||
message: string,
|
||||
) => void;
|
||||
};
|
||||
let capturedWsHandlers: WsHandlers | null = null;
|
||||
|
||||
vi.mock("../api/client", () => {
|
||||
const api = {
|
||||
getOllamaModels: vi.fn(),
|
||||
getAnthropicApiKeyExists: vi.fn(),
|
||||
getAnthropicModels: vi.fn(),
|
||||
getModelPreference: vi.fn(),
|
||||
setModelPreference: vi.fn(),
|
||||
cancelChat: vi.fn(),
|
||||
setAnthropicApiKey: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
listProjectFiles: vi.fn(),
|
||||
botCommand: vi.fn(),
|
||||
};
|
||||
class ChatWebSocket {
|
||||
connect(handlers: WsHandlers) {
|
||||
capturedWsHandlers = handlers;
|
||||
}
|
||||
close() {}
|
||||
sendChat() {}
|
||||
cancel() {}
|
||||
}
|
||||
return { api, ChatWebSocket };
|
||||
});
|
||||
|
||||
const mockedApi = {
|
||||
getOllamaModels: vi.mocked(api.getOllamaModels),
|
||||
getAnthropicApiKeyExists: vi.mocked(api.getAnthropicApiKeyExists),
|
||||
getAnthropicModels: vi.mocked(api.getAnthropicModels),
|
||||
getModelPreference: vi.mocked(api.getModelPreference),
|
||||
setModelPreference: vi.mocked(api.setModelPreference),
|
||||
cancelChat: vi.mocked(api.cancelChat),
|
||||
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
|
||||
readFile: vi.mocked(api.readFile),
|
||||
listProjectFiles: vi.mocked(api.listProjectFiles),
|
||||
botCommand: vi.mocked(api.botCommand),
|
||||
};
|
||||
|
||||
function setupMocks() {
|
||||
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
|
||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
|
||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||
mockedApi.setModelPreference.mockResolvedValue(true);
|
||||
mockedApi.readFile.mockResolvedValue("");
|
||||
mockedApi.listProjectFiles.mockResolvedValue([]);
|
||||
mockedApi.cancelChat.mockResolvedValue(true);
|
||||
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
|
||||
mockedApi.botCommand.mockResolvedValue({ response: "Bot response" });
|
||||
}
|
||||
|
||||
describe("Chat activity status indicator (Bug 140)", () => {
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it("shows activity label when tool activity fires during streaming content", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
// Simulate sending a message to set loading=true
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Read my file" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
// Simulate tokens arriving (streamingContent becomes non-empty)
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onToken("I'll read that file for you.");
|
||||
});
|
||||
|
||||
// Now simulate a tool activity event while streamingContent is non-empty
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onActivity("read_file");
|
||||
});
|
||||
|
||||
// The activity indicator should be visible with the tool activity label
|
||||
const indicator = await screen.findByTestId("activity-indicator");
|
||||
expect(indicator).toBeInTheDocument();
|
||||
expect(indicator).toHaveTextContent("Reading file...");
|
||||
});
|
||||
|
||||
it("shows Thinking... fallback when loading with no streaming and no activity", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
// Simulate sending a message to set loading=true
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Hello" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
// No tokens, no activity — should show "Thinking..."
|
||||
const indicator = await screen.findByTestId("activity-indicator");
|
||||
expect(indicator).toBeInTheDocument();
|
||||
expect(indicator).toHaveTextContent("Thinking...");
|
||||
});
|
||||
|
||||
it("hides Thinking... when streaming content is present but no tool activity", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
// Simulate sending a message to set loading=true
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Hello" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
// Tokens arrive — streamingContent is non-empty, no activity
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onToken("Here is my response...");
|
||||
});
|
||||
|
||||
// The activity indicator should NOT be visible (just streaming bubble)
|
||||
expect(screen.queryByTestId("activity-indicator")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows activity label for Claude Code tool names (Read, Bash, etc.)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
// Simulate sending a message to set loading=true
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Read my file" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
// Simulate tokens arriving
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onToken("Let me read that.");
|
||||
});
|
||||
|
||||
// Claude Code sends tool name "Read" (not "read_file")
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onActivity("Read");
|
||||
});
|
||||
|
||||
const indicator = await screen.findByTestId("activity-indicator");
|
||||
expect(indicator).toBeInTheDocument();
|
||||
expect(indicator).toHaveTextContent("Reading file...");
|
||||
});
|
||||
|
||||
it("shows activity label for Claude Code Bash tool", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Run the tests" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onToken("Running tests now.");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onActivity("Bash");
|
||||
});
|
||||
|
||||
const indicator = await screen.findByTestId("activity-indicator");
|
||||
expect(indicator).toBeInTheDocument();
|
||||
expect(indicator).toHaveTextContent("Executing command...");
|
||||
});
|
||||
|
||||
it("shows generic label for unknown tool names", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Do something" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onToken("Working on it.");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onActivity("SomeCustomTool");
|
||||
});
|
||||
|
||||
const indicator = await screen.findByTestId("activity-indicator");
|
||||
expect(indicator).toBeInTheDocument();
|
||||
expect(indicator).toHaveTextContent("Using SomeCustomTool...");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chat message queue (Story 155)", () => {
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it("shows queued message indicator when submitting while loading (AC1, AC2)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
// Send first message to put the chat in loading state
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "First message" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
// Now type and submit a second message while loading is true
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Queued message" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
// The queued message indicator should appear
|
||||
const indicator = await screen.findByTestId("queued-message-indicator");
|
||||
expect(indicator).toBeInTheDocument();
|
||||
expect(indicator).toHaveTextContent("Queued");
|
||||
expect(indicator).toHaveTextContent("Queued message");
|
||||
|
||||
// Input should be cleared after queuing
|
||||
expect((input as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("auto-sends queued message when agent response completes (AC4)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
|
||||
// Send first message
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "First" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
// Queue a second message while loading
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Auto-send this" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
// Verify it's queued
|
||||
expect(
|
||||
await screen.findByTestId("queued-message-indicator"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Simulate agent response completing (loading → false)
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onUpdate([
|
||||
{ role: "user", content: "First" },
|
||||
{ role: "assistant", content: "Done." },
|
||||
]);
|
||||
});
|
||||
|
||||
// The queued indicator should disappear (message was sent)
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("queued-message-indicator"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("cancel button discards the queued message (AC3, AC6)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
|
||||
// Send first message to start loading
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "First" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
// Queue a second message
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Discard me" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
const indicator = await screen.findByTestId("queued-message-indicator");
|
||||
expect(indicator).toBeInTheDocument();
|
||||
|
||||
// Click the ✕ cancel button
|
||||
const cancelBtn = screen.getByTitle("Cancel queued message");
|
||||
await act(async () => {
|
||||
fireEvent.click(cancelBtn);
|
||||
});
|
||||
|
||||
// Indicator should be gone
|
||||
expect(
|
||||
screen.queryByTestId("queued-message-indicator"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("edit button puts queued message back into input (AC3)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
|
||||
// Send first message to start loading
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "First" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
// Queue a second message
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Edit me back" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
await screen.findByTestId("queued-message-indicator");
|
||||
|
||||
// Click the Edit button
|
||||
const editBtn = screen.getByTitle("Edit queued message");
|
||||
await act(async () => {
|
||||
fireEvent.click(editBtn);
|
||||
});
|
||||
|
||||
// Indicator should be gone and message back in input
|
||||
expect(
|
||||
screen.queryByTestId("queued-message-indicator"),
|
||||
).not.toBeInTheDocument();
|
||||
expect((input as HTMLTextAreaElement).value).toBe("Edit me back");
|
||||
});
|
||||
|
||||
it("subsequent submissions are appended to the queue (Bug 168)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
|
||||
// Send first message to start loading
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "First" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
// Queue first message
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Queue 1" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
await screen.findByTestId("queued-message-indicator");
|
||||
|
||||
// Queue second message — should be appended, not overwrite the first
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Queue 2" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
// Both messages should be visible
|
||||
const indicators = await screen.findAllByTestId("queued-message-indicator");
|
||||
expect(indicators).toHaveLength(2);
|
||||
expect(indicators[0]).toHaveTextContent("Queue 1");
|
||||
expect(indicators[1]).toHaveTextContent("Queue 2");
|
||||
});
|
||||
|
||||
it("all queued messages are drained at once when agent responds (Story 199)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
|
||||
// Send first message to start loading
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "First" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
// Queue two messages while loading
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Second" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Third" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
// Both messages should be visible in order
|
||||
const indicators = await screen.findAllByTestId("queued-message-indicator");
|
||||
expect(indicators).toHaveLength(2);
|
||||
expect(indicators[0]).toHaveTextContent("Second");
|
||||
expect(indicators[1]).toHaveTextContent("Third");
|
||||
|
||||
// Simulate first response completing — both "Second" and "Third" are drained at once
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onUpdate([
|
||||
{ role: "user", content: "First" },
|
||||
{ role: "assistant", content: "Response 1." },
|
||||
]);
|
||||
});
|
||||
|
||||
// Both queued indicators should be gone — entire queue drained in one shot
|
||||
await waitFor(() => {
|
||||
const remaining = screen.queryAllByTestId("queued-message-indicator");
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-send queued message when generation is cancelled (AC6)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
|
||||
// Send first message to start loading
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "First" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
// Queue a second message
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Should not send" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
await screen.findByTestId("queued-message-indicator");
|
||||
|
||||
// Click the stop button (■) — but input is empty so button is stop
|
||||
// Actually simulate cancel by clicking the stop button (which requires empty input)
|
||||
// We need to use the send button when input is empty (stop mode)
|
||||
// Simulate cancel via the cancelGeneration path: the button when loading && !input
|
||||
// At this point input is empty (was cleared after queuing)
|
||||
const stopButton = screen.getByRole("button", { name: "■" });
|
||||
await act(async () => {
|
||||
fireEvent.click(stopButton);
|
||||
});
|
||||
|
||||
// Queued indicator should be gone (cancelled)
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("queued-message-indicator"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,514 @@
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api } from "../api/client";
|
||||
import type { Message } from "../types";
|
||||
import { Chat } from "./Chat";
|
||||
|
||||
// Module-level store for the WebSocket handlers captured during connect().
|
||||
type WsHandlers = {
|
||||
onToken: (content: string) => void;
|
||||
onUpdate: (history: Message[]) => void;
|
||||
onSessionId: (sessionId: string) => void;
|
||||
onError: (message: string) => void;
|
||||
onActivity: (toolName: string) => void;
|
||||
onReconciliationProgress: (
|
||||
storyId: string,
|
||||
status: string,
|
||||
message: string,
|
||||
) => void;
|
||||
};
|
||||
let capturedWsHandlers: WsHandlers | null = null;
|
||||
// Captures the last sendChat call's arguments for assertion.
|
||||
let lastSendChatArgs: { messages: Message[]; config: unknown } | null = null;
|
||||
|
||||
vi.mock("../api/client", () => {
|
||||
const api = {
|
||||
getOllamaModels: vi.fn(),
|
||||
getAnthropicApiKeyExists: vi.fn(),
|
||||
getAnthropicModels: vi.fn(),
|
||||
getModelPreference: vi.fn(),
|
||||
setModelPreference: vi.fn(),
|
||||
cancelChat: vi.fn(),
|
||||
setAnthropicApiKey: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
listProjectFiles: vi.fn(),
|
||||
botCommand: vi.fn(),
|
||||
};
|
||||
class ChatWebSocket {
|
||||
connect(handlers: WsHandlers) {
|
||||
capturedWsHandlers = handlers;
|
||||
}
|
||||
close() {}
|
||||
sendChat(messages: Message[], config: unknown) {
|
||||
lastSendChatArgs = { messages, config };
|
||||
}
|
||||
cancel() {}
|
||||
}
|
||||
return { api, ChatWebSocket };
|
||||
});
|
||||
|
||||
const mockedApi = {
|
||||
getOllamaModels: vi.mocked(api.getOllamaModels),
|
||||
getAnthropicApiKeyExists: vi.mocked(api.getAnthropicApiKeyExists),
|
||||
getAnthropicModels: vi.mocked(api.getAnthropicModels),
|
||||
getModelPreference: vi.mocked(api.getModelPreference),
|
||||
setModelPreference: vi.mocked(api.setModelPreference),
|
||||
cancelChat: vi.mocked(api.cancelChat),
|
||||
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
|
||||
readFile: vi.mocked(api.readFile),
|
||||
listProjectFiles: vi.mocked(api.listProjectFiles),
|
||||
botCommand: vi.mocked(api.botCommand),
|
||||
};
|
||||
|
||||
function setupMocks() {
|
||||
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
|
||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
|
||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||
mockedApi.setModelPreference.mockResolvedValue(true);
|
||||
mockedApi.readFile.mockResolvedValue("");
|
||||
mockedApi.listProjectFiles.mockResolvedValue([]);
|
||||
mockedApi.cancelChat.mockResolvedValue(true);
|
||||
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
|
||||
mockedApi.botCommand.mockResolvedValue({ response: "Bot response" });
|
||||
}
|
||||
|
||||
describe("Remove bubble styling from streaming messages (Story 163)", () => {
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it("AC1: streaming assistant message uses transparent background, no extra padding, no border-radius", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
// Send a message to put chat into loading state
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Hello" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
// Simulate streaming tokens arriving
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onToken("Streaming response text");
|
||||
});
|
||||
|
||||
// Find the streaming message container (the inner div wrapping the Markdown)
|
||||
const streamingText = await screen.findByText("Streaming response text");
|
||||
// The markdown-body wrapper is the parent, and the styled div is its parent
|
||||
const styledDiv = streamingText.closest(".markdown-body")
|
||||
?.parentElement as HTMLElement;
|
||||
|
||||
expect(styledDiv).toBeTruthy();
|
||||
const styleAttr = styledDiv.getAttribute("style") ?? "";
|
||||
expect(styleAttr).toContain("background: transparent");
|
||||
expect(styleAttr).toContain("padding: 0px");
|
||||
expect(styleAttr).toContain("border-radius: 0px");
|
||||
expect(styleAttr).toContain("max-width: 100%");
|
||||
});
|
||||
|
||||
it("AC1: streaming message wraps Markdown in markdown-body class", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Hello" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onToken("Some markdown content");
|
||||
});
|
||||
|
||||
const streamingText = await screen.findByText("Some markdown content");
|
||||
const markdownBody = streamingText.closest(".markdown-body");
|
||||
expect(markdownBody).toBeTruthy();
|
||||
});
|
||||
|
||||
it("AC2: no visual change when streaming ends and message transitions to completed", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
// Send a message to start streaming
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Hello" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
// Simulate streaming tokens
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onToken("Final response");
|
||||
});
|
||||
|
||||
// Capture streaming message style attribute
|
||||
const streamingText = await screen.findByText("Final response");
|
||||
const streamingStyledDiv = streamingText.closest(".markdown-body")
|
||||
?.parentElement as HTMLElement;
|
||||
const streamingStyleAttr = streamingStyledDiv.getAttribute("style") ?? "";
|
||||
|
||||
// Transition: onUpdate completes the message
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onUpdate([
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "assistant", content: "Final response" },
|
||||
]);
|
||||
});
|
||||
|
||||
// Find the completed message — it should have the same styling
|
||||
const completedText = await screen.findByText("Final response");
|
||||
const completedMarkdownBody = completedText.closest(".markdown-body");
|
||||
const completedStyledDiv =
|
||||
completedMarkdownBody?.parentElement as HTMLElement;
|
||||
|
||||
expect(completedStyledDiv).toBeTruthy();
|
||||
const completedStyleAttr = completedStyledDiv.getAttribute("style") ?? "";
|
||||
|
||||
// Both streaming and completed use transparent bg, 0 padding, 0 border-radius
|
||||
expect(completedStyleAttr).toContain("background: transparent");
|
||||
expect(completedStyleAttr).toContain("padding: 0px");
|
||||
expect(completedStyleAttr).toContain("border-radius: 0px");
|
||||
expect(streamingStyleAttr).toContain("background: transparent");
|
||||
expect(streamingStyleAttr).toContain("padding: 0px");
|
||||
expect(streamingStyleAttr).toContain("border-radius: 0px");
|
||||
|
||||
// Both have the markdown-body class wrapper
|
||||
expect(streamingStyledDiv.querySelector(".markdown-body")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("AC3: completed assistant messages retain transparent background and no border-radius", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onUpdate([
|
||||
{ role: "user", content: "Hi" },
|
||||
{ role: "assistant", content: "Hello there!" },
|
||||
]);
|
||||
});
|
||||
|
||||
const assistantText = await screen.findByText("Hello there!");
|
||||
const markdownBody = assistantText.closest(".markdown-body");
|
||||
const styledDiv = markdownBody?.parentElement as HTMLElement;
|
||||
|
||||
expect(styledDiv).toBeTruthy();
|
||||
const styleAttr = styledDiv.getAttribute("style") ?? "";
|
||||
expect(styleAttr).toContain("background: transparent");
|
||||
expect(styleAttr).toContain("padding: 0px");
|
||||
expect(styleAttr).toContain("border-radius: 0px");
|
||||
expect(styleAttr).toContain("max-width: 100%");
|
||||
});
|
||||
|
||||
it("AC3: completed user messages still have their bubble styling", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onUpdate([
|
||||
{ role: "user", content: "I am a user message" },
|
||||
{ role: "assistant", content: "I am a response" },
|
||||
]);
|
||||
});
|
||||
|
||||
// findByText finds the text element; traverse up to the styled bubble div
|
||||
const userText = await screen.findByText("I am a user message");
|
||||
// User messages are rendered via markdown, so text is inside a <p> inside .user-markdown-body
|
||||
// Walk up to find the styled bubble container
|
||||
const bubbleDiv = userText.closest("[style*='padding: 10px 16px']");
|
||||
expect(bubbleDiv).toBeTruthy();
|
||||
const styleAttr = bubbleDiv?.getAttribute("style") ?? "";
|
||||
// User messages retain bubble: distinct background, padding, rounded corners
|
||||
expect(styleAttr).toContain("padding: 10px 16px");
|
||||
expect(styleAttr).toContain("border-radius: 20px");
|
||||
expect(styleAttr).not.toContain("background: transparent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Slash command handling (Story 374)", () => {
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
lastSendChatArgs = null;
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("AC: /status calls botCommand and displays response", async () => {
|
||||
mockedApi.botCommand.mockResolvedValue({ response: "Pipeline: 3 active" });
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "/status" } });
|
||||
});
|
||||
// First Enter selects the command from the picker; second Enter submits it
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.botCommand).toHaveBeenCalledWith(
|
||||
"status",
|
||||
"",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText("Pipeline: 3 active")).toBeInTheDocument();
|
||||
// Should NOT go to LLM
|
||||
expect(lastSendChatArgs).toBeNull();
|
||||
});
|
||||
|
||||
it("AC: /status <number> passes args to botCommand", async () => {
|
||||
mockedApi.botCommand.mockResolvedValue({ response: "Story 42 details" });
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "/status 42" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.botCommand).toHaveBeenCalledWith(
|
||||
"status",
|
||||
"42",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("AC: /start <number> calls botCommand", async () => {
|
||||
mockedApi.botCommand.mockResolvedValue({ response: "Started agent" });
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "/start 42 opus" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.botCommand).toHaveBeenCalledWith(
|
||||
"start",
|
||||
"42 opus",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText("Started agent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("AC: /git calls botCommand", async () => {
|
||||
mockedApi.botCommand.mockResolvedValue({ response: "On branch main" });
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "/git" } });
|
||||
});
|
||||
// First Enter selects the command from the picker; second Enter submits it
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.botCommand).toHaveBeenCalledWith("git", "", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("AC: /cost calls botCommand", async () => {
|
||||
mockedApi.botCommand.mockResolvedValue({ response: "$1.23 today" });
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "/cost" } });
|
||||
});
|
||||
// First Enter selects the command from the picker; second Enter submits it
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.botCommand).toHaveBeenCalledWith("cost", "", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("AC: /reset clears messages and session without LLM", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
// First add a message so there is history to clear
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onUpdate([
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "assistant", content: "world" },
|
||||
]);
|
||||
});
|
||||
expect(await screen.findByText("world")).toBeInTheDocument();
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "/reset" } });
|
||||
});
|
||||
// First Enter selects the command from the picker; second Enter submits it
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
// LLM must NOT be invoked
|
||||
expect(lastSendChatArgs).toBeNull();
|
||||
// botCommand must NOT be invoked (reset is frontend-only)
|
||||
expect(mockedApi.botCommand).not.toHaveBeenCalled();
|
||||
// Confirmation message should appear
|
||||
expect(await screen.findByText(/Session reset/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("AC: unrecognised slash command shows error message", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "/foobar" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/Unknown command/)).toBeInTheDocument();
|
||||
// Should NOT go to LLM
|
||||
expect(lastSendChatArgs).toBeNull();
|
||||
// Should NOT call botCommand
|
||||
expect(mockedApi.botCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("AC: /help calls botCommand and displays response", async () => {
|
||||
mockedApi.botCommand.mockResolvedValue({
|
||||
response: "Available commands: status, help, ...",
|
||||
});
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "/help" } });
|
||||
});
|
||||
// First Enter selects the command from the picker; second Enter submits it
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.botCommand).toHaveBeenCalledWith("help", "", undefined);
|
||||
});
|
||||
expect(lastSendChatArgs).toBeNull();
|
||||
});
|
||||
|
||||
it("AC: botCommand API error shows error message in chat", async () => {
|
||||
mockedApi.botCommand.mockRejectedValue(new Error("Server error"));
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "/git" } });
|
||||
});
|
||||
// First Enter selects the command from the picker; second Enter submits it
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Error running command/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bug 450: WebSocket error messages displayed in chat", () => {
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it("AC1: WebSocket error message is shown in chat as an assistant message", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onError("Something went wrong on the server.");
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText("Something went wrong on the server."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("AC2: OAuth login URL in WebSocket error is rendered as a clickable link", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onError(
|
||||
"OAuth login required. Please visit: https://example.com/oauth/login",
|
||||
);
|
||||
});
|
||||
|
||||
const link = await screen.findByRole("link", {
|
||||
name: /https:\/\/example\.com\/oauth\/login/,
|
||||
});
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", "https://example.com/oauth/login");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api } from "../api/client";
|
||||
import type { Message } from "../types";
|
||||
import { Chat } from "./Chat";
|
||||
|
||||
// Module-level store for the WebSocket handlers captured during connect().
|
||||
type WsHandlers = {
|
||||
onToken: (content: string) => void;
|
||||
onUpdate: (history: Message[]) => void;
|
||||
onSessionId: (sessionId: string) => void;
|
||||
onError: (message: string) => void;
|
||||
onActivity: (toolName: string) => void;
|
||||
onReconciliationProgress: (
|
||||
storyId: string,
|
||||
status: string,
|
||||
message: string,
|
||||
) => void;
|
||||
};
|
||||
let capturedWsHandlers: WsHandlers | null = null;
|
||||
|
||||
vi.mock("../api/client", () => {
|
||||
const api = {
|
||||
getOllamaModels: vi.fn(),
|
||||
getAnthropicApiKeyExists: vi.fn(),
|
||||
getAnthropicModels: vi.fn(),
|
||||
getModelPreference: vi.fn(),
|
||||
setModelPreference: vi.fn(),
|
||||
cancelChat: vi.fn(),
|
||||
setAnthropicApiKey: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
listProjectFiles: vi.fn(),
|
||||
botCommand: vi.fn(),
|
||||
};
|
||||
class ChatWebSocket {
|
||||
connect(handlers: WsHandlers) {
|
||||
capturedWsHandlers = handlers;
|
||||
}
|
||||
close() {}
|
||||
sendChat() {}
|
||||
cancel() {}
|
||||
}
|
||||
return { api, ChatWebSocket };
|
||||
});
|
||||
|
||||
const mockedApi = {
|
||||
getOllamaModels: vi.mocked(api.getOllamaModels),
|
||||
getAnthropicApiKeyExists: vi.mocked(api.getAnthropicApiKeyExists),
|
||||
getAnthropicModels: vi.mocked(api.getAnthropicModels),
|
||||
getModelPreference: vi.mocked(api.getModelPreference),
|
||||
setModelPreference: vi.mocked(api.setModelPreference),
|
||||
cancelChat: vi.mocked(api.cancelChat),
|
||||
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
|
||||
readFile: vi.mocked(api.readFile),
|
||||
listProjectFiles: vi.mocked(api.listProjectFiles),
|
||||
botCommand: vi.mocked(api.botCommand),
|
||||
};
|
||||
|
||||
function setupMocks() {
|
||||
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
|
||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
|
||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||
mockedApi.setModelPreference.mockResolvedValue(true);
|
||||
mockedApi.readFile.mockResolvedValue("");
|
||||
mockedApi.listProjectFiles.mockResolvedValue([]);
|
||||
mockedApi.cancelChat.mockResolvedValue(true);
|
||||
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
|
||||
mockedApi.botCommand.mockResolvedValue({ response: "Bot response" });
|
||||
}
|
||||
|
||||
describe("Chat two-column layout", () => {
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it("renders left and right column containers (AC1, AC2)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByTestId("chat-content-area")).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("chat-left-column")).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("chat-right-column")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders chat input inside the left column (AC2, AC5)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const leftColumn = await screen.findByTestId("chat-left-column");
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
expect(leftColumn).toContainElement(input);
|
||||
});
|
||||
|
||||
it("renders panels inside the right column (AC2)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const rightColumn = await screen.findByTestId("chat-right-column");
|
||||
const agentsPanel = await screen.findByText("Agents");
|
||||
expect(rightColumn).toContainElement(agentsPanel);
|
||||
});
|
||||
|
||||
it("uses row flex-direction on wide screens (AC3)", async () => {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1200,
|
||||
});
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const contentArea = await screen.findByTestId("chat-content-area");
|
||||
expect(contentArea).toHaveStyle({ flexDirection: "row" });
|
||||
});
|
||||
|
||||
it("uses column flex-direction on narrow screens (AC4)", async () => {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 600,
|
||||
});
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const contentArea = await screen.findByTestId("chat-content-area");
|
||||
expect(contentArea).toHaveStyle({ flexDirection: "column" });
|
||||
|
||||
// Restore wide width for subsequent tests
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1024,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chat input Shift+Enter behavior", () => {
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it("renders a textarea element for the chat input (AC3)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
expect(input.tagName.toLowerCase()).toBe("textarea");
|
||||
});
|
||||
|
||||
it("sends message on Enter key press without Shift (AC2)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Hello" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect((input as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send message on Shift+Enter (AC1)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Hello" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: true });
|
||||
});
|
||||
|
||||
expect((input as HTMLTextAreaElement).value).toBe("Hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chat reconciliation banner", () => {
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it("shows banner when a non-done reconciliation event is received", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onReconciliationProgress(
|
||||
"42_story_test",
|
||||
"checking",
|
||||
"Checking for committed work in 2_current/",
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByTestId("reconciliation-banner"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Reconciling startup state..."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows event message in the banner", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onReconciliationProgress(
|
||||
"42_story_test",
|
||||
"gates_running",
|
||||
"Running acceptance gates…",
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Running acceptance gates/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dismisses banner when done event is received", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onReconciliationProgress(
|
||||
"42_story_test",
|
||||
"checking",
|
||||
"Checking for committed work",
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByTestId("reconciliation-banner"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onReconciliationProgress(
|
||||
"",
|
||||
"done",
|
||||
"Startup reconciliation complete.",
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("reconciliation-banner"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,461 @@
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api } from "../api/client";
|
||||
import type { Message } from "../types";
|
||||
import { Chat } from "./Chat";
|
||||
|
||||
// Module-level store for the WebSocket handlers captured during connect().
|
||||
type WsHandlers = {
|
||||
onToken: (content: string) => void;
|
||||
onUpdate: (history: Message[]) => void;
|
||||
onSessionId: (sessionId: string) => void;
|
||||
onError: (message: string) => void;
|
||||
onActivity: (toolName: string) => void;
|
||||
onReconciliationProgress: (
|
||||
storyId: string,
|
||||
status: string,
|
||||
message: string,
|
||||
) => void;
|
||||
};
|
||||
let capturedWsHandlers: WsHandlers | null = null;
|
||||
// Captures the last sendChat call's arguments for assertion.
|
||||
let lastSendChatArgs: { messages: Message[]; config: unknown } | null = null;
|
||||
|
||||
vi.mock("../api/client", () => {
|
||||
const api = {
|
||||
getOllamaModels: vi.fn(),
|
||||
getAnthropicApiKeyExists: vi.fn(),
|
||||
getAnthropicModels: vi.fn(),
|
||||
getModelPreference: vi.fn(),
|
||||
setModelPreference: vi.fn(),
|
||||
cancelChat: vi.fn(),
|
||||
setAnthropicApiKey: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
listProjectFiles: vi.fn(),
|
||||
botCommand: vi.fn(),
|
||||
};
|
||||
class ChatWebSocket {
|
||||
connect(handlers: WsHandlers) {
|
||||
capturedWsHandlers = handlers;
|
||||
}
|
||||
close() {}
|
||||
sendChat(messages: Message[], config: unknown) {
|
||||
lastSendChatArgs = { messages, config };
|
||||
}
|
||||
cancel() {}
|
||||
}
|
||||
return { api, ChatWebSocket };
|
||||
});
|
||||
|
||||
const mockedApi = {
|
||||
getOllamaModels: vi.mocked(api.getOllamaModels),
|
||||
getAnthropicApiKeyExists: vi.mocked(api.getAnthropicApiKeyExists),
|
||||
getAnthropicModels: vi.mocked(api.getAnthropicModels),
|
||||
getModelPreference: vi.mocked(api.getModelPreference),
|
||||
setModelPreference: vi.mocked(api.setModelPreference),
|
||||
cancelChat: vi.mocked(api.cancelChat),
|
||||
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
|
||||
readFile: vi.mocked(api.readFile),
|
||||
listProjectFiles: vi.mocked(api.listProjectFiles),
|
||||
botCommand: vi.mocked(api.botCommand),
|
||||
};
|
||||
|
||||
function setupMocks() {
|
||||
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
|
||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
|
||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||
mockedApi.setModelPreference.mockResolvedValue(true);
|
||||
mockedApi.readFile.mockResolvedValue("");
|
||||
mockedApi.listProjectFiles.mockResolvedValue([]);
|
||||
mockedApi.cancelChat.mockResolvedValue(true);
|
||||
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
|
||||
mockedApi.botCommand.mockResolvedValue({ response: "Bot response" });
|
||||
}
|
||||
|
||||
describe("Chat localStorage persistence (Story 145)", () => {
|
||||
const PROJECT_PATH = "/tmp/project";
|
||||
const STORAGE_KEY = `storykit-chat-history:${PROJECT_PATH}`;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
localStorage.clear();
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("AC1: restores persisted messages on mount", async () => {
|
||||
const saved: Message[] = [
|
||||
{ role: "user", content: "Previously saved question" },
|
||||
{ role: "assistant", content: "Previously saved answer" },
|
||||
];
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
|
||||
|
||||
render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("Previously saved question"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Previously saved answer"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("AC2: persists messages when WebSocket onUpdate fires", async () => {
|
||||
render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const history: Message[] = [
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "assistant", content: "Hi there!" },
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onUpdate(history);
|
||||
});
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
|
||||
expect(stored).toEqual(history);
|
||||
});
|
||||
|
||||
it("AC3: clears localStorage when New Session is clicked", async () => {
|
||||
const saved: Message[] = [
|
||||
{ role: "user", content: "Old message" },
|
||||
{ role: "assistant", content: "Old reply" },
|
||||
];
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
|
||||
|
||||
// Stub window.confirm to auto-approve the clear dialog
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
|
||||
// Wait for the persisted message to appear
|
||||
expect(await screen.findByText("Old message")).toBeInTheDocument();
|
||||
|
||||
// Click "New Session" button
|
||||
const newSessionBtn = screen.getByText(/New Session/);
|
||||
await act(async () => {
|
||||
fireEvent.click(newSessionBtn);
|
||||
});
|
||||
|
||||
// localStorage should be cleared
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
|
||||
// Messages should be gone from the UI
|
||||
expect(screen.queryByText("Old message")).not.toBeInTheDocument();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("Bug 245: messages survive unmount/remount cycle (page refresh)", async () => {
|
||||
// Step 1: Render Chat and populate messages via WebSocket onUpdate
|
||||
const { unmount } = render(
|
||||
<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const history: Message[] = [
|
||||
{ role: "user", content: "Persist me across refresh" },
|
||||
{ role: "assistant", content: "I should survive a reload" },
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onUpdate(history);
|
||||
});
|
||||
|
||||
// Verify messages are persisted to localStorage
|
||||
expect(localStorage.getItem(STORAGE_KEY)).not.toBeNull();
|
||||
const storedBefore = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
|
||||
expect(storedBefore).toEqual(history);
|
||||
|
||||
// Step 2: Unmount the Chat component (simulates page unload)
|
||||
unmount();
|
||||
|
||||
// Verify localStorage was NOT cleared by unmount
|
||||
expect(localStorage.getItem(STORAGE_KEY)).not.toBeNull();
|
||||
const storedAfterUnmount = JSON.parse(
|
||||
localStorage.getItem(STORAGE_KEY) ?? "[]",
|
||||
);
|
||||
expect(storedAfterUnmount).toEqual(history);
|
||||
|
||||
// Step 3: Remount the Chat component (simulates page reload)
|
||||
capturedWsHandlers = null;
|
||||
render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
|
||||
// Verify messages are restored from localStorage
|
||||
expect(
|
||||
await screen.findByText("Persist me across refresh"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("I should survive a reload"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Verify localStorage still has the messages
|
||||
const storedAfterRemount = JSON.parse(
|
||||
localStorage.getItem(STORAGE_KEY) ?? "[]",
|
||||
);
|
||||
expect(storedAfterRemount).toEqual(history);
|
||||
});
|
||||
|
||||
it("Bug 245: after refresh, sendChat includes full prior history", async () => {
|
||||
// Step 1: Render, populate messages via onUpdate, then unmount (simulate refresh)
|
||||
const { unmount } = render(
|
||||
<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />,
|
||||
);
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const priorHistory: Message[] = [
|
||||
{ role: "user", content: "What is Rust?" },
|
||||
{ role: "assistant", content: "Rust is a systems programming language." },
|
||||
];
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onUpdate(priorHistory);
|
||||
});
|
||||
|
||||
// Verify localStorage has the prior history
|
||||
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
|
||||
expect(stored).toEqual(priorHistory);
|
||||
|
||||
unmount();
|
||||
|
||||
// Step 2: Remount (simulates page reload) — messages load from localStorage
|
||||
capturedWsHandlers = null;
|
||||
lastSendChatArgs = null;
|
||||
render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
// Verify prior messages are displayed
|
||||
expect(await screen.findByText("What is Rust?")).toBeInTheDocument();
|
||||
|
||||
// Step 3: Send a new message — sendChat should include the full prior history
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Tell me more" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
// Verify sendChat was called with ALL prior messages + the new one
|
||||
expect(lastSendChatArgs).not.toBeNull();
|
||||
const args = lastSendChatArgs as unknown as {
|
||||
messages: Message[];
|
||||
config: unknown;
|
||||
};
|
||||
expect(args.messages).toHaveLength(3);
|
||||
expect(args.messages[0]).toEqual({
|
||||
role: "user",
|
||||
content: "What is Rust?",
|
||||
});
|
||||
expect(args.messages[1]).toEqual({
|
||||
role: "assistant",
|
||||
content: "Rust is a systems programming language.",
|
||||
});
|
||||
expect(args.messages[2]).toEqual({
|
||||
role: "user",
|
||||
content: "Tell me more",
|
||||
});
|
||||
});
|
||||
|
||||
it("AC5: uses project-scoped storage key", async () => {
|
||||
const otherKey = "storykit-chat-history:/other/project";
|
||||
localStorage.setItem(
|
||||
otherKey,
|
||||
JSON.stringify([{ role: "user", content: "Other project msg" }]),
|
||||
);
|
||||
|
||||
render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
|
||||
// Should NOT show the other project's messages
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
expect(screen.queryByText("Other project msg")).not.toBeInTheDocument();
|
||||
|
||||
// Other project's data should still be in storage
|
||||
expect(localStorage.getItem(otherKey)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bug 264: Claude Code session ID persisted across browser refresh", () => {
|
||||
const PROJECT_PATH = "/tmp/project";
|
||||
const SESSION_KEY = `storykit-claude-session-id:${PROJECT_PATH}`;
|
||||
const STORAGE_KEY = `storykit-chat-history:${PROJECT_PATH}`;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
lastSendChatArgs = null;
|
||||
localStorage.clear();
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("AC1: session_id is persisted to localStorage when onSessionId fires", async () => {
|
||||
render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onSessionId("test-session-abc");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem(SESSION_KEY)).toBe("test-session-abc");
|
||||
});
|
||||
});
|
||||
|
||||
it("AC2: after remount, next sendChat includes session_id from localStorage", async () => {
|
||||
// Step 1: Render, receive a session ID, then unmount (simulate refresh)
|
||||
localStorage.setItem(SESSION_KEY, "persisted-session-xyz");
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{ role: "user", content: "Prior message" },
|
||||
{ role: "assistant", content: "Prior reply" },
|
||||
]),
|
||||
);
|
||||
|
||||
const { unmount } = render(
|
||||
<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />,
|
||||
);
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
unmount();
|
||||
|
||||
// Step 2: Remount (simulates page reload)
|
||||
capturedWsHandlers = null;
|
||||
lastSendChatArgs = null;
|
||||
render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
// Prior messages should be visible
|
||||
expect(await screen.findByText("Prior message")).toBeInTheDocument();
|
||||
|
||||
// Step 3: Send a new message — config should include session_id
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Continue" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
expect(lastSendChatArgs).not.toBeNull();
|
||||
expect(
|
||||
(
|
||||
(
|
||||
lastSendChatArgs as unknown as {
|
||||
messages: Message[];
|
||||
config: unknown;
|
||||
}
|
||||
)?.config as Record<string, unknown>
|
||||
).session_id,
|
||||
).toBe("persisted-session-xyz");
|
||||
});
|
||||
|
||||
it("AC3: clearing the session also clears the persisted session_id", async () => {
|
||||
localStorage.setItem(SESSION_KEY, "session-to-clear");
|
||||
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const newSessionBtn = screen.getByText(/New Session/);
|
||||
await act(async () => {
|
||||
fireEvent.click(newSessionBtn);
|
||||
});
|
||||
|
||||
expect(localStorage.getItem(SESSION_KEY)).toBeNull();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("AC1: storage key is scoped to project path", async () => {
|
||||
const otherPath = "/other/project";
|
||||
const otherKey = `storykit-claude-session-id:${otherPath}`;
|
||||
localStorage.setItem(otherKey, "other-session");
|
||||
|
||||
render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onSessionId("my-session");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem(SESSION_KEY)).toBe("my-session");
|
||||
});
|
||||
|
||||
// Other project's session should be untouched
|
||||
expect(localStorage.getItem(otherKey)).toBe("other-session");
|
||||
});
|
||||
});
|
||||
|
||||
describe("File reference expansion (Story 269 AC4)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
capturedWsHandlers = null;
|
||||
lastSendChatArgs = null;
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it("includes file contents as context when message contains @file reference", async () => {
|
||||
mockedApi.readFile.mockResolvedValue('fn main() { println!("hello"); }');
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "explain @src/main.rs" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(lastSendChatArgs).not.toBeNull());
|
||||
const sentMessages = (
|
||||
lastSendChatArgs as NonNullable<typeof lastSendChatArgs>
|
||||
).messages;
|
||||
const userMsg = sentMessages[sentMessages.length - 1];
|
||||
expect(userMsg.content).toContain("explain @src/main.rs");
|
||||
expect(userMsg.content).toContain("[File: src/main.rs]");
|
||||
expect(userMsg.content).toContain("fn main()");
|
||||
});
|
||||
|
||||
it("sends message without modification when no @file references are present", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "hello world" } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(lastSendChatArgs).not.toBeNull());
|
||||
const sentMessages = (
|
||||
lastSendChatArgs as NonNullable<typeof lastSendChatArgs>
|
||||
).messages;
|
||||
const userMsg = sentMessages[sentMessages.length - 1];
|
||||
expect(userMsg.content).toBe("hello world");
|
||||
expect(mockedApi.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import { useChatWebSocket } from "../hooks/useChatWebSocket";
|
||||
import { estimateTokens, getContextWindowSize } from "../utils/chatUtils";
|
||||
import { ApiKeyDialog } from "./ApiKeyDialog";
|
||||
import { BotConfigPage } from "./BotConfigPage";
|
||||
import { SettingsPage } from "./SettingsPage";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
import type { ChatInputHandle } from "./ChatInput";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
@@ -62,7 +63,7 @@ export function Chat({
|
||||
null,
|
||||
);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [view, setView] = useState<"chat" | "bot-config">("chat");
|
||||
const [view, setView] = useState<"chat" | "bot-config" | "settings">("chat");
|
||||
const [queuedMessages, setQueuedMessages] = useState<
|
||||
{ id: string; text: string }[]
|
||||
>([]);
|
||||
@@ -105,6 +106,7 @@ export function Chat({
|
||||
setSideQuestion,
|
||||
serverLogs,
|
||||
storyTokenCosts,
|
||||
statusEvents,
|
||||
} = useChatWebSocket({
|
||||
setMessages,
|
||||
setLoading,
|
||||
@@ -376,16 +378,19 @@ export function Chat({
|
||||
wsConnected={wsConnected}
|
||||
oauthStatus={oauthStatus}
|
||||
onShowBotConfig={() => setView("bot-config")}
|
||||
onShowSettings={() => setView("settings")}
|
||||
/>
|
||||
|
||||
{view === "bot-config" && (
|
||||
<BotConfigPage onBack={() => setView("chat")} />
|
||||
)}
|
||||
|
||||
{view === "settings" && <SettingsPage onBack={() => setView("chat")} />}
|
||||
|
||||
<div
|
||||
data-testid="chat-content-area"
|
||||
style={{
|
||||
display: view === "bot-config" ? "none" : "flex",
|
||||
display: view === "chat" ? "flex" : "none",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
flexDirection: isNarrowScreen ? "column" : "row",
|
||||
@@ -443,6 +448,7 @@ export function Chat({
|
||||
busyAgentNames={busyAgentNames}
|
||||
selectedWorkItemId={selectedWorkItemId}
|
||||
serverLogs={serverLogs}
|
||||
statusEvents={statusEvents}
|
||||
onSelectWorkItem={setSelectedWorkItemId}
|
||||
onCloseWorkItem={() => setSelectedWorkItemId(null)}
|
||||
onStartAgent={handleStartAgent}
|
||||
|
||||
@@ -35,6 +35,7 @@ interface ChatHeaderProps {
|
||||
wsConnected: boolean;
|
||||
oauthStatus?: OAuthStatus | null;
|
||||
onShowBotConfig?: () => void;
|
||||
onShowSettings?: () => void;
|
||||
}
|
||||
|
||||
const getContextEmoji = (percentage: number): string => {
|
||||
@@ -60,6 +61,7 @@ export function ChatHeader({
|
||||
wsConnected,
|
||||
oauthStatus = null,
|
||||
onShowBotConfig,
|
||||
onShowSettings,
|
||||
}: ChatHeaderProps) {
|
||||
const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0;
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
@@ -552,6 +554,43 @@ export function ChatHeader({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onShowSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowSettings}
|
||||
title="Edit project.toml settings"
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: "99px",
|
||||
border: "none",
|
||||
fontSize: "0.85em",
|
||||
backgroundColor: "#2f2f2f",
|
||||
color: "#888",
|
||||
cursor: "pointer",
|
||||
outline: "none",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#3f3f3f";
|
||||
e.currentTarget.style.color = "#ccc";
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#2f2f2f";
|
||||
e.currentTarget.style.color = "#888";
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#3f3f3f";
|
||||
e.currentTarget.style.color = "#ccc";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#2f2f2f";
|
||||
e.currentTarget.style.color = "#888";
|
||||
}}
|
||||
>
|
||||
⚙ Settings
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasModelOptions ? (
|
||||
<select
|
||||
value={model}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { AgentConfigInfo } from "../api/agents";
|
||||
import type { PipelineStageItem, PipelineState } from "../api/client";
|
||||
import type {
|
||||
PipelineStageItem,
|
||||
PipelineState,
|
||||
StatusEvent,
|
||||
} from "../api/client";
|
||||
import { AgentPanel } from "./AgentPanel";
|
||||
import { LozengeFlyProvider } from "./LozengeFlyContext";
|
||||
import type { LogEntry } from "./ServerLogsPanel";
|
||||
@@ -7,6 +11,25 @@ import { ServerLogsPanel } from "./ServerLogsPanel";
|
||||
import { StagePanel } from "./StagePanel";
|
||||
import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
|
||||
|
||||
/** Format a structured StatusEvent into a human-readable display string.
|
||||
* 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;
|
||||
switch (event.type) {
|
||||
case "stage_transition":
|
||||
return `${name} — ${event.from_stage} → ${event.to_stage}`;
|
||||
case "merge_failure":
|
||||
return `✗ ${name} — ${event.reason}`;
|
||||
case "story_blocked":
|
||||
return `⊘ ${name} — BLOCKED: ${event.reason}`;
|
||||
case "rate_limit_warning":
|
||||
return `⚠ ${name} — ${event.agent_name} hit an API rate limit`;
|
||||
case "rate_limit_hard_block":
|
||||
return `⊗ ${name} — ${event.agent_name} hard rate-limited until ${event.reset_at}`;
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatPipelinePanelProps {
|
||||
isNarrowScreen: boolean;
|
||||
pipeline: PipelineState;
|
||||
@@ -18,6 +41,8 @@ interface ChatPipelinePanelProps {
|
||||
busyAgentNames: Set<string>;
|
||||
selectedWorkItemId: string | null;
|
||||
serverLogs: LogEntry[];
|
||||
/** Structured pipeline status events forwarded from the status broadcaster. */
|
||||
statusEvents: Array<{ receivedAt: string; event: StatusEvent }>;
|
||||
onSelectWorkItem: (id: string) => void;
|
||||
onCloseWorkItem: () => void;
|
||||
onStartAgent: (storyId: string, agentName?: string) => void;
|
||||
@@ -36,12 +61,28 @@ export function ChatPipelinePanel({
|
||||
busyAgentNames,
|
||||
selectedWorkItemId,
|
||||
serverLogs,
|
||||
statusEvents,
|
||||
onSelectWorkItem,
|
||||
onCloseWorkItem,
|
||||
onStartAgent,
|
||||
onStopAgent,
|
||||
onDeleteItem,
|
||||
}: ChatPipelinePanelProps) {
|
||||
// Convert structured status events to LogEntry format for display in the
|
||||
// existing log area. Structure is preserved in the statusEvents array itself.
|
||||
const statusLogEntries: LogEntry[] = statusEvents.map(
|
||||
({ receivedAt, event }) => ({
|
||||
timestamp: receivedAt,
|
||||
level:
|
||||
event.type === "merge_failure" ||
|
||||
event.type === "story_blocked" ||
|
||||
event.type === "rate_limit_hard_block"
|
||||
? "WARN"
|
||||
: "INFO",
|
||||
message: formatStatusEventMessage(event),
|
||||
}),
|
||||
);
|
||||
const combinedLogs = [...statusLogEntries, ...serverLogs];
|
||||
return (
|
||||
<div
|
||||
data-testid="chat-right-column"
|
||||
@@ -69,53 +110,67 @@ export function ChatPipelinePanel({
|
||||
configVersion={agentConfigVersion}
|
||||
stateVersion={agentStateVersion}
|
||||
/>
|
||||
<StagePanel
|
||||
title="Done"
|
||||
items={pipeline.done ?? []}
|
||||
costs={storyTokenCosts}
|
||||
onItemClick={(item) => onSelectWorkItem(item.story_id)}
|
||||
onStopAgent={onStopAgent}
|
||||
onDeleteItem={onDeleteItem}
|
||||
/>
|
||||
<StagePanel
|
||||
title="To Merge"
|
||||
items={pipeline.merge}
|
||||
costs={storyTokenCosts}
|
||||
onItemClick={(item) => onSelectWorkItem(item.story_id)}
|
||||
onStopAgent={onStopAgent}
|
||||
onDeleteItem={onDeleteItem}
|
||||
/>
|
||||
<StagePanel
|
||||
title="QA"
|
||||
items={pipeline.qa}
|
||||
costs={storyTokenCosts}
|
||||
onItemClick={(item) => onSelectWorkItem(item.story_id)}
|
||||
onStopAgent={onStopAgent}
|
||||
onDeleteItem={onDeleteItem}
|
||||
/>
|
||||
<StagePanel
|
||||
title="Current"
|
||||
items={pipeline.current}
|
||||
costs={storyTokenCosts}
|
||||
onItemClick={(item) => onSelectWorkItem(item.story_id)}
|
||||
agentRoster={agentRoster}
|
||||
busyAgentNames={busyAgentNames}
|
||||
onStartAgent={onStartAgent}
|
||||
onStopAgent={onStopAgent}
|
||||
onDeleteItem={onDeleteItem}
|
||||
/>
|
||||
<StagePanel
|
||||
title="Backlog"
|
||||
items={pipeline.backlog}
|
||||
costs={storyTokenCosts}
|
||||
onItemClick={(item) => onSelectWorkItem(item.story_id)}
|
||||
agentRoster={agentRoster}
|
||||
busyAgentNames={busyAgentNames}
|
||||
onStartAgent={onStartAgent}
|
||||
onStopAgent={onStopAgent}
|
||||
onDeleteItem={onDeleteItem}
|
||||
/>
|
||||
<ServerLogsPanel logs={serverLogs} />
|
||||
{(() => {
|
||||
const mergesInFlight = new Set(
|
||||
pipeline.deterministic_merges_in_flight ?? [],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<StagePanel
|
||||
title="Done"
|
||||
items={pipeline.done ?? []}
|
||||
costs={storyTokenCosts}
|
||||
onItemClick={(item) => onSelectWorkItem(item.story_id)}
|
||||
onStopAgent={onStopAgent}
|
||||
onDeleteItem={onDeleteItem}
|
||||
mergesInFlight={mergesInFlight}
|
||||
/>
|
||||
<StagePanel
|
||||
title="To Merge"
|
||||
items={pipeline.merge}
|
||||
costs={storyTokenCosts}
|
||||
onItemClick={(item) => onSelectWorkItem(item.story_id)}
|
||||
onStopAgent={onStopAgent}
|
||||
onDeleteItem={onDeleteItem}
|
||||
mergesInFlight={mergesInFlight}
|
||||
/>
|
||||
<StagePanel
|
||||
title="QA"
|
||||
items={pipeline.qa}
|
||||
costs={storyTokenCosts}
|
||||
onItemClick={(item) => onSelectWorkItem(item.story_id)}
|
||||
onStopAgent={onStopAgent}
|
||||
onDeleteItem={onDeleteItem}
|
||||
mergesInFlight={mergesInFlight}
|
||||
/>
|
||||
<StagePanel
|
||||
title="Current"
|
||||
items={pipeline.current}
|
||||
costs={storyTokenCosts}
|
||||
onItemClick={(item) => onSelectWorkItem(item.story_id)}
|
||||
agentRoster={agentRoster}
|
||||
busyAgentNames={busyAgentNames}
|
||||
onStartAgent={onStartAgent}
|
||||
onStopAgent={onStopAgent}
|
||||
onDeleteItem={onDeleteItem}
|
||||
mergesInFlight={mergesInFlight}
|
||||
/>
|
||||
<StagePanel
|
||||
title="Backlog"
|
||||
items={pipeline.backlog}
|
||||
costs={storyTokenCosts}
|
||||
onItemClick={(item) => onSelectWorkItem(item.story_id)}
|
||||
agentRoster={agentRoster}
|
||||
busyAgentNames={busyAgentNames}
|
||||
onStartAgent={onStartAgent}
|
||||
onStopAgent={onStopAgent}
|
||||
onDeleteItem={onDeleteItem}
|
||||
mergesInFlight={mergesInFlight}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
<ServerLogsPanel logs={combinedLogs} />
|
||||
</>
|
||||
)}
|
||||
</LozengeFlyProvider>
|
||||
|
||||
@@ -368,11 +368,6 @@ export function GatewayPanel() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pipeline, setPipeline] = useState<AllProjectsPipeline | null>(null);
|
||||
|
||||
// Add-project form state
|
||||
const [newProjectName, setNewProjectName] = useState("");
|
||||
const [newProjectUrl, setNewProjectUrl] = useState("");
|
||||
const [addingProject, setAddingProject] = useState(false);
|
||||
|
||||
// Keep stable refs so polling intervals don't recreate on state changes.
|
||||
const setAgentsRef = useRef(setAgents);
|
||||
setAgentsRef.current = setAgents;
|
||||
@@ -447,24 +442,6 @@ export function GatewayPanel() {
|
||||
[],
|
||||
);
|
||||
|
||||
const handleAddProject = useCallback(async () => {
|
||||
const name = newProjectName.trim();
|
||||
const url = newProjectUrl.trim();
|
||||
if (!name || !url) return;
|
||||
setAddingProject(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await gatewayApi.addProject(name, url);
|
||||
setProjects((prev) => [...prev, created]);
|
||||
setNewProjectName("");
|
||||
setNewProjectUrl("");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setAddingProject(false);
|
||||
}
|
||||
}, [newProjectName, newProjectUrl]);
|
||||
|
||||
const handleSwitchProject = useCallback(async (name: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
@@ -481,18 +458,6 @@ export function GatewayPanel() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRemoveProject = useCallback(async (name: string) => {
|
||||
if (!window.confirm(`Remove project "${name}"? This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
await gatewayApi.removeProject(name);
|
||||
setProjects((prev) => prev.filter((p) => p.name !== name));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -657,97 +622,8 @@ export function GatewayPanel() {
|
||||
<div style={{ fontWeight: 600, color: "#e6edf3" }}>{p.name}</div>
|
||||
<div style={{ fontSize: "0.8em", color: "#8b949e" }}>{p.url}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`remove-project-${p.name}`}
|
||||
onClick={() => handleRemoveProject(p.name)}
|
||||
style={{
|
||||
fontSize: "0.8em",
|
||||
padding: "4px 10px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #f85149",
|
||||
background: "none",
|
||||
color: "#f85149",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add project form */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: "12px",
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
alignItems: "flex-end",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: "1 1 140px" }}>
|
||||
<div style={{ fontSize: "0.75em", color: "#8b949e", marginBottom: "4px" }}>
|
||||
Name
|
||||
</div>
|
||||
<input
|
||||
data-testid="new-project-name"
|
||||
type="text"
|
||||
placeholder="my-project"
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "6px 10px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #30363d",
|
||||
background: "#0d1117",
|
||||
color: "#e6edf3",
|
||||
fontSize: "0.85em",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: "2 1 200px" }}>
|
||||
<div style={{ fontSize: "0.75em", color: "#8b949e", marginBottom: "4px" }}>
|
||||
Container URL
|
||||
</div>
|
||||
<input
|
||||
data-testid="new-project-url"
|
||||
type="text"
|
||||
placeholder="http://localhost:3001"
|
||||
value={newProjectUrl}
|
||||
onChange={(e) => setNewProjectUrl(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "6px 10px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #30363d",
|
||||
background: "#0d1117",
|
||||
color: "#e6edf3",
|
||||
fontSize: "0.85em",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="add-project-button"
|
||||
onClick={handleAddProject}
|
||||
disabled={addingProject || !newProjectName.trim() || !newProjectUrl.trim()}
|
||||
style={{
|
||||
padding: "6px 14px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #238636",
|
||||
background: addingProject ? "#1a2f1a" : "#238636",
|
||||
color: "#fff",
|
||||
cursor: addingProject ? "not-allowed" : "pointer",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.85em",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{addingProject ? "Adding…" : "Add Project"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import * as React from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PipelineState } from "../api/client";
|
||||
import { LozengeFlyProvider } from "./LozengeFlyContext";
|
||||
import { StagePanel } from "./StagePanel";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
|
||||
return {
|
||||
backlog: [],
|
||||
current: [],
|
||||
qa: [],
|
||||
merge: [],
|
||||
done: [],
|
||||
deterministic_merges_in_flight: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function Wrapper({
|
||||
pipeline,
|
||||
children,
|
||||
}: {
|
||||
pipeline: PipelineState;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<LozengeFlyProvider pipeline={pipeline}>{children}</LozengeFlyProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Agent lozenge fixed intrinsic width ──────────────────────────────────────
|
||||
|
||||
describe("AgentLozenge fixed intrinsic width", () => {
|
||||
it("has align-self: flex-start so it never stretches inside a flex column", () => {
|
||||
const items = [
|
||||
{
|
||||
story_id: "74_width_test",
|
||||
name: "Width Test",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
];
|
||||
const pipeline = makePipeline({ current: items });
|
||||
const { container } = render(
|
||||
<Wrapper pipeline={pipeline}>
|
||||
<StagePanel title="Current" items={items} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const lozenge = container.querySelector(
|
||||
'[data-testid="slot-lozenge-74_width_test"]',
|
||||
) as HTMLElement;
|
||||
expect(lozenge).toBeInTheDocument();
|
||||
expect(lozenge.style.alignSelf).toBe("flex-start");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Idle vs active visual distinction ────────────────────────────────────────
|
||||
|
||||
describe("AgentLozenge idle vs active appearance", () => {
|
||||
it("running agent lozenge uses the green active color", () => {
|
||||
const items = [
|
||||
{
|
||||
story_id: "74_running_color",
|
||||
name: "Running",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
];
|
||||
const { container } = render(
|
||||
<Wrapper pipeline={makePipeline({ current: items })}>
|
||||
<StagePanel title="Current" items={items} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const lozenge = container.querySelector(
|
||||
'[data-testid="slot-lozenge-74_running_color"]',
|
||||
) as HTMLElement;
|
||||
expect(lozenge).toBeInTheDocument();
|
||||
// Green: rgb(63, 185, 80) = #3fb950
|
||||
expect(lozenge.style.color).toBe("rgb(63, 185, 80)");
|
||||
});
|
||||
|
||||
it("pending agent lozenge uses the yellow pending color", () => {
|
||||
const items = [
|
||||
{
|
||||
story_id: "74_pending_color",
|
||||
name: "Pending",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-1", model: null, status: "pending" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
];
|
||||
const { container } = render(
|
||||
<Wrapper pipeline={makePipeline({ current: items })}>
|
||||
<StagePanel title="Current" items={items} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const lozenge = container.querySelector(
|
||||
'[data-testid="slot-lozenge-74_pending_color"]',
|
||||
) as HTMLElement;
|
||||
expect(lozenge).toBeInTheDocument();
|
||||
// Yellow: rgb(227, 179, 65) = #e3b341
|
||||
expect(lozenge.style.color).toBe("rgb(227, 179, 65)");
|
||||
});
|
||||
|
||||
it("running lozenge has a pulsing dot child element", () => {
|
||||
const items = [
|
||||
{
|
||||
story_id: "74_pulse_dot",
|
||||
name: "Pulse",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
];
|
||||
const { container } = render(
|
||||
<Wrapper pipeline={makePipeline({ current: items })}>
|
||||
<StagePanel title="Current" items={items} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const lozenge = container.querySelector(
|
||||
'[data-testid="slot-lozenge-74_pulse_dot"]',
|
||||
) as HTMLElement;
|
||||
// The pulse dot is a child span with animation: pulse
|
||||
const dot = lozenge.querySelector("span");
|
||||
expect(dot).not.toBeNull();
|
||||
expect(dot?.style.animation).toContain("pulse");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,404 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import * as React from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PipelineState } from "../api/client";
|
||||
import { LozengeFlyProvider, useLozengeFly } from "./LozengeFlyContext";
|
||||
import { StagePanel } from "./StagePanel";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
|
||||
return {
|
||||
backlog: [],
|
||||
current: [],
|
||||
qa: [],
|
||||
merge: [],
|
||||
done: [],
|
||||
deterministic_merges_in_flight: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A minimal roster element fixture that registers itself with the context. */
|
||||
function RosterFixture({ agentName }: { agentName: string }) {
|
||||
const { registerRosterEl } = useLozengeFly();
|
||||
const ref = React.useRef<HTMLSpanElement>(null);
|
||||
React.useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el) registerRosterEl(agentName, el);
|
||||
return () => registerRosterEl(agentName, null);
|
||||
}, [agentName, registerRosterEl]);
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
data-testid={`roster-${agentName}`}
|
||||
style={{ position: "fixed", top: 10, left: 20, width: 80, height: 20 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Wrapper({
|
||||
pipeline,
|
||||
children,
|
||||
}: {
|
||||
pipeline: PipelineState;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<LozengeFlyProvider pipeline={pipeline}>{children}</LozengeFlyProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Fly-in: slot lozenge visibility ─────────────────────────────────────────
|
||||
|
||||
describe("LozengeFlyProvider fly-in visibility", () => {
|
||||
beforeEach(() => {
|
||||
Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 100,
|
||||
top: 50,
|
||||
right: 180,
|
||||
bottom: 70,
|
||||
width: 80,
|
||||
height: 20,
|
||||
x: 100,
|
||||
y: 50,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("slot lozenge starts hidden when a matching roster element exists", async () => {
|
||||
const noPipeline = makePipeline();
|
||||
const withAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "74_hidden_test",
|
||||
name: "Hidden Test",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<Wrapper pipeline={noPipeline}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<StagePanel title="Current" items={[]} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Rerender with the agent assigned
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Wrapper pipeline={withAgent}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<StagePanel title="Current" items={withAgent.current} />
|
||||
</Wrapper>,
|
||||
);
|
||||
});
|
||||
|
||||
const lozenge = screen.getByTestId("slot-lozenge-74_hidden_test");
|
||||
// Hidden while fly-in is in progress
|
||||
expect(lozenge.style.opacity).toBe("0");
|
||||
});
|
||||
|
||||
it("slot lozenge is visible when no roster element is registered", async () => {
|
||||
const noPipeline = makePipeline();
|
||||
const withAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "74_no_roster",
|
||||
name: "No Roster",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: {
|
||||
agent_name: "unknown-agent",
|
||||
model: null,
|
||||
status: "running",
|
||||
},
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<Wrapper pipeline={noPipeline}>
|
||||
{/* No RosterFixture for "unknown-agent" */}
|
||||
<StagePanel title="Current" items={[]} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Wrapper pipeline={withAgent}>
|
||||
<StagePanel title="Current" items={withAgent.current} />
|
||||
</Wrapper>,
|
||||
);
|
||||
});
|
||||
|
||||
const lozenge = screen.getByTestId("slot-lozenge-74_no_roster");
|
||||
// Immediately visible because no fly-in animation is possible
|
||||
expect(lozenge.style.opacity).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fly-in: flying clone in document.body portal ────────────────────────────
|
||||
|
||||
describe("LozengeFlyProvider fly-in clone", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 100,
|
||||
top: 50,
|
||||
right: 180,
|
||||
bottom: 70,
|
||||
width: 80,
|
||||
height: 20,
|
||||
x: 100,
|
||||
y: 50,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders a fixed-position clone in document.body when fly-in triggers", async () => {
|
||||
const noPipeline = makePipeline();
|
||||
const withAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "74_portal_test",
|
||||
name: "Portal Test",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<Wrapper pipeline={noPipeline}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<StagePanel title="Current" items={[]} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Wrapper pipeline={withAgent}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<StagePanel title="Current" items={withAgent.current} />
|
||||
</Wrapper>,
|
||||
);
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
// Clone is in document.body (portal), not inside the component container
|
||||
const clone = document.body.querySelector(
|
||||
'[data-testid^="flying-lozenge-fly-in"]',
|
||||
) as HTMLElement | null;
|
||||
expect(clone).not.toBeNull();
|
||||
expect(clone?.style.position).toBe("fixed");
|
||||
expect(Number(clone?.style.zIndex)).toBeGreaterThanOrEqual(9999);
|
||||
expect(clone?.style.pointerEvents).toBe("none");
|
||||
});
|
||||
|
||||
it("clone is removed from document.body after 500 ms", async () => {
|
||||
const noPipeline = makePipeline();
|
||||
const withAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "74_clone_remove",
|
||||
name: "Clone Remove",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<Wrapper pipeline={noPipeline}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<StagePanel title="Current" items={[]} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Wrapper pipeline={withAgent}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<StagePanel title="Current" items={withAgent.current} />
|
||||
</Wrapper>,
|
||||
);
|
||||
});
|
||||
|
||||
// Clone should exist before timeout
|
||||
const cloneBefore = document.body.querySelector(
|
||||
'[data-testid^="flying-lozenge-fly-in"]',
|
||||
);
|
||||
expect(cloneBefore).not.toBeNull();
|
||||
|
||||
// Advance past the 500ms cleanup timeout
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
const cloneAfter = document.body.querySelector(
|
||||
'[data-testid^="flying-lozenge-fly-in"]',
|
||||
);
|
||||
expect(cloneAfter).toBeNull();
|
||||
});
|
||||
|
||||
it("slot lozenge becomes visible (opacity 1) after 500 ms timeout", async () => {
|
||||
const noPipeline = makePipeline();
|
||||
const withAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "74_reveal_test",
|
||||
name: "Reveal Test",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<Wrapper pipeline={noPipeline}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<StagePanel title="Current" items={[]} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Wrapper pipeline={withAgent}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<StagePanel title="Current" items={withAgent.current} />
|
||||
</Wrapper>,
|
||||
);
|
||||
});
|
||||
|
||||
// Initially hidden
|
||||
const lozenge = screen.getByTestId("slot-lozenge-74_reveal_test");
|
||||
expect(lozenge.style.opacity).toBe("0");
|
||||
|
||||
// After 500ms the slot becomes visible
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
expect(lozenge.style.opacity).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Flying clone renders in initial (non-flying) state ───────────────────
|
||||
|
||||
describe("FlyingLozengeClone initial non-flying render", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 100,
|
||||
top: 50,
|
||||
right: 180,
|
||||
bottom: 70,
|
||||
width: 80,
|
||||
height: 20,
|
||||
x: 100,
|
||||
y: 50,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("clone has transition: none before rAF fires", async () => {
|
||||
// Collect rAF callbacks instead of firing them immediately
|
||||
const rafCallbacks: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
rafCallbacks.push(cb);
|
||||
return rafCallbacks.length;
|
||||
});
|
||||
|
||||
const noPipeline = makePipeline();
|
||||
const withAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "109_nontransition_test",
|
||||
name: "Non-transition Test",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<LozengeFlyProvider pipeline={noPipeline}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<StagePanel title="Current" items={[]} />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
|
||||
// Trigger fly-in but don't flush rAF callbacks
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<LozengeFlyProvider pipeline={withAgent}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<StagePanel title="Current" items={withAgent.current} />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
// Clone should exist in its initial (non-flying) state
|
||||
const clone = document.body.querySelector(
|
||||
'[data-testid^="flying-lozenge-fly-in"]',
|
||||
) as HTMLElement | null;
|
||||
expect(clone).not.toBeNull();
|
||||
expect(clone?.style.transition).toBe("none");
|
||||
|
||||
// Now flush rAF callbacks to trigger the flying state
|
||||
await act(async () => {
|
||||
for (const cb of rafCallbacks) cb(0);
|
||||
rafCallbacks.length = 0;
|
||||
// Flush inner rAF callbacks too
|
||||
for (const cb of rafCallbacks) cb(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,483 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import * as React from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PipelineState } from "../api/client";
|
||||
import { LozengeFlyProvider, useLozengeFly } from "./LozengeFlyContext";
|
||||
import { StagePanel } from "./StagePanel";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
|
||||
return {
|
||||
backlog: [],
|
||||
current: [],
|
||||
qa: [],
|
||||
merge: [],
|
||||
done: [],
|
||||
deterministic_merges_in_flight: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A minimal roster element fixture that registers itself with the context. */
|
||||
function RosterFixture({ agentName }: { agentName: string }) {
|
||||
const { registerRosterEl } = useLozengeFly();
|
||||
const ref = React.useRef<HTMLSpanElement>(null);
|
||||
React.useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el) registerRosterEl(agentName, el);
|
||||
return () => registerRosterEl(agentName, null);
|
||||
}, [agentName, registerRosterEl]);
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
data-testid={`roster-${agentName}`}
|
||||
style={{ position: "fixed", top: 10, left: 20, width: 80, height: 20 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reads hiddenRosterAgents from context and exposes it via a data attribute. */
|
||||
function HiddenAgentsProbe() {
|
||||
const { hiddenRosterAgents } = useLozengeFly();
|
||||
return (
|
||||
<div
|
||||
data-testid="hidden-agents-probe"
|
||||
data-hidden={[...hiddenRosterAgents].join(",")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Wrapper({
|
||||
pipeline,
|
||||
children,
|
||||
}: {
|
||||
pipeline: PipelineState;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<LozengeFlyProvider pipeline={pipeline}>{children}</LozengeFlyProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Fly-out animation ────────────────────────────────────────────────────────
|
||||
|
||||
describe("LozengeFlyProvider fly-out", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 100,
|
||||
top: 50,
|
||||
right: 180,
|
||||
bottom: 70,
|
||||
width: 80,
|
||||
height: 20,
|
||||
x: 100,
|
||||
y: 50,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("creates a fly-out clone in document.body when agent is removed", async () => {
|
||||
const withAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "74_fly_out_test",
|
||||
name: "Fly Out Test",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-1", model: "haiku", status: "completed" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<Wrapper pipeline={withAgent}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<StagePanel title="Current" items={withAgent.current} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Advance past initial fly-in animation to get a clean state
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
// Remove the agent from the pipeline
|
||||
const noAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "74_fly_out_test",
|
||||
name: "Fly Out Test",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: null,
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Wrapper pipeline={noAgent}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<StagePanel title="Current" items={noAgent.current} />
|
||||
</Wrapper>,
|
||||
);
|
||||
});
|
||||
|
||||
// A fly-out clone should now be in document.body
|
||||
const clone = document.body.querySelector(
|
||||
'[data-testid^="flying-lozenge-fly-out"]',
|
||||
);
|
||||
expect(clone).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Agent swap (name change) triggers both fly-out and fly-in ────────────
|
||||
|
||||
describe("LozengeFlyProvider agent swap (name change)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 100,
|
||||
top: 50,
|
||||
right: 180,
|
||||
bottom: 70,
|
||||
width: 80,
|
||||
height: 20,
|
||||
x: 100,
|
||||
y: 50,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("detects agent name change as both fly-out (old) and fly-in (new)", async () => {
|
||||
const withCoder1 = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "109_swap_test",
|
||||
name: "Swap Test",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const withCoder2 = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "109_swap_test",
|
||||
name: "Swap Test",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-2", model: "haiku", status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<LozengeFlyProvider pipeline={withCoder1}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<RosterFixture agentName="coder-2" />
|
||||
<HiddenAgentsProbe />
|
||||
<StagePanel title="Current" items={withCoder1.current} />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
|
||||
// Advance past initial fly-in
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
// Swap agent: coder-1 → coder-2
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<LozengeFlyProvider pipeline={withCoder2}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<RosterFixture agentName="coder-2" />
|
||||
<HiddenAgentsProbe />
|
||||
<StagePanel title="Current" items={withCoder2.current} />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
// A fly-out clone for coder-1 should appear (old agent leaves)
|
||||
const flyOut = document.body.querySelector(
|
||||
'[data-testid^="flying-lozenge-fly-out"]',
|
||||
);
|
||||
expect(flyOut).not.toBeNull();
|
||||
|
||||
// A fly-in clone for coder-2 should appear (new agent arrives)
|
||||
const flyIn = document.body.querySelector(
|
||||
'[data-testid^="flying-lozenge-fly-in"]',
|
||||
);
|
||||
expect(flyIn).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fly-out without a roster element (null rosterRect fallback) ──────────
|
||||
|
||||
describe("LozengeFlyProvider fly-out without roster element", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 200,
|
||||
top: 100,
|
||||
right: 280,
|
||||
bottom: 120,
|
||||
width: 80,
|
||||
height: 20,
|
||||
x: 200,
|
||||
y: 100,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("fly-out still works when no roster element is registered (uses fallback coords)", async () => {
|
||||
const withAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "109_no_roster_flyout",
|
||||
name: "No Roster Flyout",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: {
|
||||
agent_name: "orphan-agent",
|
||||
model: null,
|
||||
status: "completed",
|
||||
},
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const noAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "109_no_roster_flyout",
|
||||
name: "No Roster Flyout",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: null,
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<LozengeFlyProvider pipeline={withAgent}>
|
||||
{/* No RosterFixture for orphan-agent */}
|
||||
<StagePanel title="Current" items={withAgent.current} />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<LozengeFlyProvider pipeline={noAgent}>
|
||||
<StagePanel title="Current" items={noAgent.current} />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
// Fly-out clone should still appear even without roster element
|
||||
const clone = document.body.querySelector(
|
||||
'[data-testid^="flying-lozenge-fly-out"]',
|
||||
);
|
||||
expect(clone).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── hiddenRosterAgents: fly-out keeps agent hidden until clone lands ─────
|
||||
|
||||
describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 100,
|
||||
top: 50,
|
||||
right: 180,
|
||||
bottom: 70,
|
||||
width: 80,
|
||||
height: 20,
|
||||
x: 100,
|
||||
y: 50,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("agent stays hidden in roster during fly-out (0–499 ms)", async () => {
|
||||
const withAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "85_flyout_hidden",
|
||||
name: "Fly-out Hidden",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-1", model: null, status: "completed" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const noAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "85_flyout_hidden",
|
||||
name: "Fly-out Hidden",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: null,
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<LozengeFlyProvider pipeline={withAgent}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<HiddenAgentsProbe />
|
||||
<StagePanel title="Current" items={withAgent.current} />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
|
||||
// Advance past the initial fly-in
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
// Remove agent — fly-out starts
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<LozengeFlyProvider pipeline={noAgent}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<HiddenAgentsProbe />
|
||||
<StagePanel title="Current" items={noAgent.current} />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
// Agent should still be hidden (fly-out clone is in flight)
|
||||
const probe = screen.getByTestId("hidden-agents-probe");
|
||||
expect(probe.dataset.hidden).toContain("coder-1");
|
||||
});
|
||||
|
||||
it("agent reappears in roster after fly-out clone lands (500 ms)", async () => {
|
||||
const withAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "85_flyout_reveal",
|
||||
name: "Fly-out Reveal",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-1", model: null, status: "completed" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const noAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "85_flyout_reveal",
|
||||
name: "Fly-out Reveal",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: null,
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<LozengeFlyProvider pipeline={withAgent}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<HiddenAgentsProbe />
|
||||
<StagePanel title="Current" items={withAgent.current} />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<LozengeFlyProvider pipeline={noAgent}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<HiddenAgentsProbe />
|
||||
<StagePanel title="Current" items={noAgent.current} />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
// Advance past fly-out animation
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
// Agent should now be visible in roster
|
||||
const probe = screen.getByTestId("hidden-agents-probe");
|
||||
expect(probe.dataset.hidden).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,467 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import * as React from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PipelineState } from "../api/client";
|
||||
import { LozengeFlyProvider, useLozengeFly } from "./LozengeFlyContext";
|
||||
import { StagePanel } from "./StagePanel";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
|
||||
return {
|
||||
backlog: [],
|
||||
current: [],
|
||||
qa: [],
|
||||
merge: [],
|
||||
done: [],
|
||||
deterministic_merges_in_flight: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A minimal roster element fixture that registers itself with the context. */
|
||||
function RosterFixture({ agentName }: { agentName: string }) {
|
||||
const { registerRosterEl } = useLozengeFly();
|
||||
const ref = React.useRef<HTMLSpanElement>(null);
|
||||
React.useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el) registerRosterEl(agentName, el);
|
||||
return () => registerRosterEl(agentName, null);
|
||||
}, [agentName, registerRosterEl]);
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
data-testid={`roster-${agentName}`}
|
||||
style={{ position: "fixed", top: 10, left: 20, width: 80, height: 20 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reads hiddenRosterAgents from context and exposes it via a data attribute. */
|
||||
function HiddenAgentsProbe() {
|
||||
const { hiddenRosterAgents } = useLozengeFly();
|
||||
return (
|
||||
<div
|
||||
data-testid="hidden-agents-probe"
|
||||
data-hidden={[...hiddenRosterAgents].join(",")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Wrapper({
|
||||
pipeline,
|
||||
children,
|
||||
}: {
|
||||
pipeline: PipelineState;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<LozengeFlyProvider pipeline={pipeline}>{children}</LozengeFlyProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── hiddenRosterAgents: no-duplicate guarantee ───────────────────────────────
|
||||
|
||||
describe("hiddenRosterAgents: assigned agents are absent from roster", () => {
|
||||
it("is empty when no agents are in the pipeline", () => {
|
||||
render(
|
||||
<LozengeFlyProvider pipeline={makePipeline()}>
|
||||
<HiddenAgentsProbe />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
const probe = screen.getByTestId("hidden-agents-probe");
|
||||
expect(probe.dataset.hidden).toBe("");
|
||||
});
|
||||
|
||||
it("includes agent name when agent is assigned to a current story", () => {
|
||||
const pipeline = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "85_assign_test",
|
||||
name: "Assign Test",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
<LozengeFlyProvider pipeline={pipeline}>
|
||||
<HiddenAgentsProbe />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
const probe = screen.getByTestId("hidden-agents-probe");
|
||||
expect(probe.dataset.hidden).toContain("coder-1");
|
||||
});
|
||||
|
||||
it("excludes agent name when it has no assignment in the pipeline", () => {
|
||||
const pipeline = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "85_no_agent",
|
||||
name: "No Agent",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: null,
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
<LozengeFlyProvider pipeline={pipeline}>
|
||||
<HiddenAgentsProbe />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
const probe = screen.getByTestId("hidden-agents-probe");
|
||||
expect(probe.dataset.hidden).toBe("");
|
||||
});
|
||||
|
||||
it("updates to include agent when pipeline transitions from no-agent to assigned", async () => {
|
||||
const noPipeline = makePipeline();
|
||||
const withAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "85_transition_test",
|
||||
name: "Transition",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<LozengeFlyProvider pipeline={noPipeline}>
|
||||
<HiddenAgentsProbe />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
|
||||
let probe = screen.getByTestId("hidden-agents-probe");
|
||||
expect(probe.dataset.hidden).toBe("");
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<LozengeFlyProvider pipeline={withAgent}>
|
||||
<HiddenAgentsProbe />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
probe = screen.getByTestId("hidden-agents-probe");
|
||||
expect(probe.dataset.hidden).toContain("coder-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Bug 137: Race condition on rapid pipeline updates ────────────────────
|
||||
|
||||
describe("Bug 137: no animation actions lost during rapid pipeline updates", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 100,
|
||||
top: 50,
|
||||
right: 180,
|
||||
bottom: 70,
|
||||
width: 80,
|
||||
height: 20,
|
||||
x: 100,
|
||||
y: 50,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("rapid agent swap: first timeout does not prematurely reveal slot lozenge", async () => {
|
||||
const empty = makePipeline();
|
||||
const withCoder1 = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "137_rapid_swap",
|
||||
name: "Rapid Swap",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const withCoder2 = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "137_rapid_swap",
|
||||
name: "Rapid Swap",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-2", model: "haiku", status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<Wrapper pipeline={empty}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<RosterFixture agentName="coder-2" />
|
||||
<StagePanel title="Current" items={[]} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// First update: assign coder-1 → fly-in animation #1 starts
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Wrapper pipeline={withCoder1}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<RosterFixture agentName="coder-2" />
|
||||
<StagePanel title="Current" items={withCoder1.current} />
|
||||
</Wrapper>,
|
||||
);
|
||||
});
|
||||
|
||||
// Slot should be hidden (fly-in in progress)
|
||||
const lozenge = screen.getByTestId("slot-lozenge-137_rapid_swap");
|
||||
expect(lozenge.style.opacity).toBe("0");
|
||||
|
||||
// Rapid swap at 200ms: coder-1 → coder-2 (before first animation's 500ms timeout)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Wrapper pipeline={withCoder2}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<RosterFixture agentName="coder-2" />
|
||||
<StagePanel title="Current" items={withCoder2.current} />
|
||||
</Wrapper>,
|
||||
);
|
||||
});
|
||||
|
||||
// Slot should still be hidden (new fly-in for coder-2 is in progress)
|
||||
expect(lozenge.style.opacity).toBe("0");
|
||||
|
||||
// At 300ms after first animation started (500ms total from start),
|
||||
// the FIRST animation's timeout fires. It must NOT reveal the slot.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
// BUG: Without fix, the first timeout clears pendingFlyIns for this story,
|
||||
// revealing the slot while coder-2's fly-in is still in progress.
|
||||
expect(lozenge.style.opacity).toBe("0");
|
||||
});
|
||||
|
||||
it("slot lozenge reveals correctly after the LAST animation completes", async () => {
|
||||
const empty = makePipeline();
|
||||
const withCoder1 = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "137_reveal_last",
|
||||
name: "Reveal Last",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const withCoder2 = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "137_reveal_last",
|
||||
name: "Reveal Last",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder-2", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<Wrapper pipeline={empty}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<RosterFixture agentName="coder-2" />
|
||||
<StagePanel title="Current" items={[]} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// First animation
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Wrapper pipeline={withCoder1}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<RosterFixture agentName="coder-2" />
|
||||
<StagePanel title="Current" items={withCoder1.current} />
|
||||
</Wrapper>,
|
||||
);
|
||||
});
|
||||
|
||||
// Swap at 200ms
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Wrapper pipeline={withCoder2}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<RosterFixture agentName="coder-2" />
|
||||
<StagePanel title="Current" items={withCoder2.current} />
|
||||
</Wrapper>,
|
||||
);
|
||||
});
|
||||
|
||||
const lozenge = screen.getByTestId("slot-lozenge-137_reveal_last");
|
||||
|
||||
// After the second animation's full 500ms, slot should reveal
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
expect(lozenge.style.opacity).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bug 137: animations remain functional through sustained agent activity", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 100,
|
||||
top: 50,
|
||||
right: 180,
|
||||
bottom: 70,
|
||||
width: 80,
|
||||
height: 20,
|
||||
x: 100,
|
||||
y: 50,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("fly-in still works after multiple rapid swaps have completed", async () => {
|
||||
const empty = makePipeline();
|
||||
const makeWith = (agentName: string) =>
|
||||
makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "137_sustained",
|
||||
name: "Sustained",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: agentName, model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<Wrapper pipeline={empty}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<RosterFixture agentName="coder-2" />
|
||||
<RosterFixture agentName="coder-3" />
|
||||
<StagePanel title="Current" items={[]} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Rapid-fire: assign coder-1, then swap to coder-2 at 100ms
|
||||
const p1 = makeWith("coder-1");
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Wrapper pipeline={p1}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<RosterFixture agentName="coder-2" />
|
||||
<RosterFixture agentName="coder-3" />
|
||||
<StagePanel title="Current" items={p1.current} />
|
||||
</Wrapper>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
const p2 = makeWith("coder-2");
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Wrapper pipeline={p2}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<RosterFixture agentName="coder-2" />
|
||||
<RosterFixture agentName="coder-3" />
|
||||
<StagePanel title="Current" items={p2.current} />
|
||||
</Wrapper>,
|
||||
);
|
||||
});
|
||||
|
||||
// Let all animations complete
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
const lozenge = screen.getByTestId("slot-lozenge-137_sustained");
|
||||
expect(lozenge.style.opacity).toBe("1");
|
||||
|
||||
// Now assign coder-3 — a fresh fly-in should still work
|
||||
const p3 = makeWith("coder-3");
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Wrapper pipeline={p3}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<RosterFixture agentName="coder-2" />
|
||||
<RosterFixture agentName="coder-3" />
|
||||
<StagePanel title="Current" items={p3.current} />
|
||||
</Wrapper>,
|
||||
);
|
||||
});
|
||||
|
||||
// Slot should be hidden again for the new fly-in
|
||||
expect(lozenge.style.opacity).toBe("0");
|
||||
|
||||
// A flying clone should exist
|
||||
const clone = document.body.querySelector(
|
||||
'[data-testid^="flying-lozenge-fly-in"]',
|
||||
);
|
||||
expect(clone).not.toBeNull();
|
||||
|
||||
// After animation completes, slot reveals
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
expect(lozenge.style.opacity).toBe("1");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,481 @@
|
||||
import * as React from "react";
|
||||
import type { ProjectSettings } from "../api/settings";
|
||||
import { settingsApi } from "../api/settings";
|
||||
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
interface SettingsPageProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const fieldStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: "0.8em",
|
||||
color: "#aaa",
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const descStyle: React.CSSProperties = {
|
||||
fontSize: "0.75em",
|
||||
color: "#666",
|
||||
marginTop: "2px",
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: "8px 10px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #333",
|
||||
background: "#1e1e1e",
|
||||
color: "#ececec",
|
||||
fontSize: "0.9em",
|
||||
fontFamily: "monospace",
|
||||
outline: "none",
|
||||
};
|
||||
|
||||
const sectionStyle: React.CSSProperties = {
|
||||
background: "#1e1e1e",
|
||||
border: "1px solid #333",
|
||||
borderRadius: "8px",
|
||||
padding: "20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "16px",
|
||||
};
|
||||
|
||||
const sectionTitleStyle: React.CSSProperties = {
|
||||
fontSize: "0.85em",
|
||||
fontWeight: 600,
|
||||
color: "#aaa",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
marginBottom: "2px",
|
||||
};
|
||||
|
||||
interface TextFieldProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function TextField({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: TextFieldProps) {
|
||||
return (
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>{label}</label>
|
||||
{description && <span style={descStyle}>{description}</span>}
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder ?? ""}
|
||||
style={inputStyle}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NumberFieldProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: number | null;
|
||||
onChange: (v: number | null) => void;
|
||||
min?: number;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function NumberField({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
placeholder,
|
||||
}: NumberFieldProps) {
|
||||
return (
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>{label}</label>
|
||||
{description && <span style={descStyle}>{description}</span>}
|
||||
<input
|
||||
type="number"
|
||||
value={value === null ? "" : value}
|
||||
min={min}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.trim();
|
||||
if (raw === "") {
|
||||
onChange(null);
|
||||
} else {
|
||||
const n = Number(raw);
|
||||
if (!Number.isNaN(n)) onChange(n);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder ?? ""}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CheckboxFieldProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}
|
||||
|
||||
function CheckboxField({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: CheckboxFieldProps) {
|
||||
return (
|
||||
<div style={fieldStyle}>
|
||||
{description && <span style={descStyle}>{description}</span>}
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9em",
|
||||
color: "#ccc",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const QA_MODES = ["server", "agent", "human"] as const;
|
||||
|
||||
/** Settings page — form-based editor for project.toml scalar settings. */
|
||||
export function SettingsPage({ onBack }: SettingsPageProps) {
|
||||
const [settings, setSettings] = useState<ProjectSettings | null>(null);
|
||||
const [status, setStatus] = useState<
|
||||
"idle" | "loading" | "saving" | "saved" | "error"
|
||||
>("loading");
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
settingsApi
|
||||
.getProjectSettings()
|
||||
.then((s) => {
|
||||
setSettings(s);
|
||||
setStatus("idle");
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setStatus("error");
|
||||
setErrorMsg(e instanceof Error ? e.message : "Failed to load settings");
|
||||
});
|
||||
}, []);
|
||||
|
||||
function patch(partial: Partial<ProjectSettings>) {
|
||||
setSettings((prev) => (prev ? { ...prev, ...partial } : prev));
|
||||
setValidationErrors({});
|
||||
}
|
||||
|
||||
function validate(s: ProjectSettings): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!QA_MODES.includes(s.default_qa as (typeof QA_MODES)[number])) {
|
||||
errors.default_qa = `Must be one of: ${QA_MODES.join(", ")}`;
|
||||
}
|
||||
if (s.max_retries < 0) {
|
||||
errors.max_retries = "Must be 0 or greater";
|
||||
}
|
||||
if (s.watcher_sweep_interval_secs < 1) {
|
||||
errors.watcher_sweep_interval_secs = "Must be at least 1 second";
|
||||
}
|
||||
if (s.watcher_done_retention_secs < 1) {
|
||||
errors.watcher_done_retention_secs = "Must be at least 1 second";
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!settings) return;
|
||||
const errors = validate(settings);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setValidationErrors(errors);
|
||||
return;
|
||||
}
|
||||
setStatus("saving");
|
||||
setErrorMsg(null);
|
||||
try {
|
||||
const saved = await settingsApi.putProjectSettings(settings);
|
||||
setSettings(saved);
|
||||
setStatus("saved");
|
||||
setTimeout(() => setStatus("idle"), 2000);
|
||||
} catch (e) {
|
||||
setStatus("error");
|
||||
setErrorMsg(e instanceof Error ? e.message : "Save failed");
|
||||
}
|
||||
}
|
||||
|
||||
const s = settings;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
backgroundColor: "#171717",
|
||||
color: "#ececec",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 24px",
|
||||
borderBottom: "1px solid #333",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "16px",
|
||||
background: "#171717",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "#888",
|
||||
fontSize: "0.9em",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span style={{ fontWeight: 700, fontSize: "1em" }}>
|
||||
Project Settings
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "24px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "20px",
|
||||
maxWidth: "640px",
|
||||
}}
|
||||
>
|
||||
{status === "loading" && (
|
||||
<p style={{ color: "#888", fontSize: "0.9em" }}>Loading settings…</p>
|
||||
)}
|
||||
|
||||
{status === "error" && !s && (
|
||||
<p style={{ color: "#f08080", fontSize: "0.9em" }}>
|
||||
Error: {errorMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{s && (
|
||||
<>
|
||||
{/* Pipeline */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Pipeline</div>
|
||||
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>Default QA Mode</label>
|
||||
<span style={descStyle}>
|
||||
How stories are QA-reviewed after the coder stage. Default:
|
||||
server.
|
||||
</span>
|
||||
<select
|
||||
value={s.default_qa}
|
||||
onChange={(e) => patch({ default_qa: e.target.value })}
|
||||
style={{ ...inputStyle, cursor: "pointer" }}
|
||||
>
|
||||
{QA_MODES.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{validationErrors.default_qa && (
|
||||
<span style={{ color: "#f08080", fontSize: "0.8em" }}>
|
||||
{validationErrors.default_qa}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NumberField
|
||||
label="Max Retries"
|
||||
description="Maximum retries per story per pipeline stage before blocking. Default: 2. Set 0 to disable."
|
||||
value={s.max_retries}
|
||||
min={0}
|
||||
onChange={(v) => patch({ max_retries: v ?? 0 })}
|
||||
/>
|
||||
{validationErrors.max_retries && (
|
||||
<span style={{ color: "#f08080", fontSize: "0.8em" }}>
|
||||
{validationErrors.max_retries}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<NumberField
|
||||
label="Max Concurrent Coders"
|
||||
description="Maximum number of coder-stage agents running at once. Leave blank for unlimited."
|
||||
value={s.max_coders}
|
||||
min={1}
|
||||
placeholder="unlimited"
|
||||
onChange={(v) => patch({ max_coders: v })}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Default Coder Model"
|
||||
description="When set, only coder agents matching this model are auto-assigned (e.g. sonnet, opus)."
|
||||
value={s.default_coder_model ?? ""}
|
||||
onChange={(v) =>
|
||||
patch({ default_coder_model: v.trim() || null })
|
||||
}
|
||||
placeholder="e.g. sonnet"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Git */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Git</div>
|
||||
|
||||
<TextField
|
||||
label="Base Branch"
|
||||
description="Overrides auto-detection of the merge target branch (e.g. main, master, develop)."
|
||||
value={s.base_branch ?? ""}
|
||||
onChange={(v) => patch({ base_branch: v.trim() || null })}
|
||||
placeholder="e.g. master"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Notifications</div>
|
||||
|
||||
<CheckboxField
|
||||
label="Rate Limit Notifications"
|
||||
description="Send chat notifications on soft API rate-limit warnings. Disable to reduce noise."
|
||||
checked={s.rate_limit_notifications}
|
||||
onChange={(v) => patch({ rate_limit_notifications: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Advanced</div>
|
||||
|
||||
<TextField
|
||||
label="Timezone"
|
||||
description="IANA timezone for timer inputs (e.g. Europe/London, America/New_York). Leave blank for system default."
|
||||
value={s.timezone ?? ""}
|
||||
onChange={(v) => patch({ timezone: v.trim() || null })}
|
||||
placeholder="e.g. Europe/London"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Rendezvous URL"
|
||||
description="WebSocket URL of a remote huskies node for CRDT state sync (e.g. ws://host:3001/crdt-sync)."
|
||||
value={s.rendezvous ?? ""}
|
||||
onChange={(v) => patch({ rendezvous: v.trim() || null })}
|
||||
placeholder="e.g. ws://host:3001/crdt-sync"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Watcher */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Archiver</div>
|
||||
|
||||
<NumberField
|
||||
label="Sweep Interval (seconds)"
|
||||
description="How often to check the done stage for items ready to archive. Default: 60."
|
||||
value={s.watcher_sweep_interval_secs}
|
||||
min={1}
|
||||
onChange={(v) =>
|
||||
patch({ watcher_sweep_interval_secs: v ?? 60 })
|
||||
}
|
||||
/>
|
||||
{validationErrors.watcher_sweep_interval_secs && (
|
||||
<span style={{ color: "#f08080", fontSize: "0.8em" }}>
|
||||
{validationErrors.watcher_sweep_interval_secs}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<NumberField
|
||||
label="Done Retention (seconds)"
|
||||
description="How long an item must stay in the done stage before archiving. Default: 14400 (4 hours)."
|
||||
value={s.watcher_done_retention_secs}
|
||||
min={1}
|
||||
onChange={(v) =>
|
||||
patch({ watcher_done_retention_secs: v ?? 14400 })
|
||||
}
|
||||
/>
|
||||
{validationErrors.watcher_done_retention_secs && (
|
||||
<span style={{ color: "#f08080", fontSize: "0.8em" }}>
|
||||
{validationErrors.watcher_done_retention_secs}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={status === "saving"}
|
||||
style={{
|
||||
padding: "8px 24px",
|
||||
borderRadius: "6px",
|
||||
border: "none",
|
||||
background: status === "saved" ? "#1a5c2a" : "#2563eb",
|
||||
color: "#fff",
|
||||
cursor: status === "saving" ? "not-allowed" : "pointer",
|
||||
fontSize: "0.9em",
|
||||
fontWeight: 600,
|
||||
opacity: status === "saving" ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{status === "saving"
|
||||
? "Saving…"
|
||||
: status === "saved"
|
||||
? "Saved!"
|
||||
: "Save"}
|
||||
</button>
|
||||
{status === "error" && errorMsg && (
|
||||
<span style={{ color: "#f08080", fontSize: "0.85em" }}>
|
||||
{errorMsg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -324,4 +324,71 @@ describe("StagePanel", () => {
|
||||
screen.queryByTestId("merge-failure-reason-31_story_no_failure"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows merge-in-flight icon when story is in mergesInFlight set", () => {
|
||||
const items: PipelineStageItem[] = [
|
||||
{
|
||||
story_id: "40_story_merging",
|
||||
name: "Merging Story",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: null,
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
];
|
||||
const mergesInFlight = new Set(["40_story_merging"]);
|
||||
render(
|
||||
<StagePanel title="To Merge" items={items} mergesInFlight={mergesInFlight} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("merge-in-flight-icon-40_story_merging"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show merge-in-flight icon when story is not in mergesInFlight set", () => {
|
||||
const items: PipelineStageItem[] = [
|
||||
{
|
||||
story_id: "41_story_not_merging",
|
||||
name: "Idle Story",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: null,
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
];
|
||||
const mergesInFlight = new Set(["99_story_other"]);
|
||||
render(
|
||||
<StagePanel
|
||||
title="To Merge"
|
||||
items={items}
|
||||
mergesInFlight={mergesInFlight}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId("merge-in-flight-icon-41_story_not_merging"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show merge-in-flight icon when mergesInFlight prop is absent", () => {
|
||||
const items: PipelineStageItem[] = [
|
||||
{
|
||||
story_id: "42_story_no_prop",
|
||||
name: "No Prop Story",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: null,
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
];
|
||||
render(<StagePanel title="To Merge" items={items} />);
|
||||
expect(
|
||||
screen.queryByTestId("merge-in-flight-icon-42_story_no_prop"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,8 @@ interface StagePanelProps {
|
||||
busyAgentNames?: Set<string>;
|
||||
/** Called when the user requests to start an agent on a story. */
|
||||
onStartAgent?: (storyId: string, agentName?: string) => void;
|
||||
/** Set of story IDs that currently have a deterministic merge in progress. */
|
||||
mergesInFlight?: Set<string>;
|
||||
}
|
||||
|
||||
function AgentLozenge({
|
||||
@@ -259,6 +261,7 @@ export function StagePanel({
|
||||
agentRoster,
|
||||
busyAgentNames,
|
||||
onStartAgent,
|
||||
mergesInFlight,
|
||||
}: StagePanelProps) {
|
||||
const showStartButton =
|
||||
Boolean(onStartAgent) &&
|
||||
@@ -355,6 +358,19 @@ export function StagePanel({
|
||||
✕
|
||||
</span>
|
||||
)}
|
||||
{mergesInFlight?.has(item.story_id) && (
|
||||
<span
|
||||
data-testid={`merge-in-flight-icon-${item.story_id}`}
|
||||
title="Deterministic merge in progress"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
marginRight: "6px",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
>
|
||||
⟳
|
||||
</span>
|
||||
)}
|
||||
{itemNumber && (
|
||||
<span
|
||||
style={{
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/** Test results card sub-components for WorkItemDetailPanel. */
|
||||
|
||||
import type { TestCaseResult, TestResultsResponse } from "../api/client";
|
||||
|
||||
function TestCaseRow({ tc }: { tc: TestCaseResult }) {
|
||||
const isPassing = tc.status === "pass";
|
||||
return (
|
||||
<div
|
||||
data-testid={`test-case-${tc.name}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "2px",
|
||||
padding: "4px 0",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<span
|
||||
data-testid={`test-status-${tc.name}`}
|
||||
style={{
|
||||
fontSize: "0.85em",
|
||||
color: isPassing ? "#3fb950" : "#f85149",
|
||||
}}
|
||||
>
|
||||
{isPassing ? "PASS" : "FAIL"}
|
||||
</span>
|
||||
<span style={{ fontSize: "0.82em", color: "#ccc" }}>{tc.name}</span>
|
||||
</div>
|
||||
{tc.details && (
|
||||
<div
|
||||
data-testid={`test-details-${tc.name}`}
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
color: "#888",
|
||||
paddingLeft: "22px",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{tc.details}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TestSection({
|
||||
title,
|
||||
tests,
|
||||
testId,
|
||||
}: {
|
||||
title: string;
|
||||
tests: TestCaseResult[];
|
||||
testId: string;
|
||||
}) {
|
||||
const passCount = tests.filter((t) => t.status === "pass").length;
|
||||
const failCount = tests.length - passCount;
|
||||
return (
|
||||
<div data-testid={testId}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.78em",
|
||||
fontWeight: 600,
|
||||
color: "#aaa",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
{title} ({passCount} passed, {failCount} failed)
|
||||
</div>
|
||||
{tests.length === 0 ? (
|
||||
<div style={{ fontSize: "0.75em", color: "#555", fontStyle: "italic" }}>
|
||||
No tests recorded
|
||||
</div>
|
||||
) : (
|
||||
tests.map((tc) => <TestCaseRow key={tc.name} tc={tc} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders the "Test Results" card in the detail panel. */
|
||||
export function TestResultsSection({
|
||||
testResults,
|
||||
}: {
|
||||
testResults: TestResultsResponse | null;
|
||||
}) {
|
||||
const hasTestResults =
|
||||
testResults &&
|
||||
(testResults.unit.length > 0 || testResults.integration.length > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="test-results-section"
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
padding: "10px 12px",
|
||||
background: "#161616",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8em",
|
||||
color: "#555",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Test Results
|
||||
</div>
|
||||
{hasTestResults ? (
|
||||
<div
|
||||
data-testid="test-results-content"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<TestSection
|
||||
title="Unit Tests"
|
||||
tests={testResults.unit}
|
||||
testId="test-section-unit"
|
||||
/>
|
||||
<TestSection
|
||||
title="Integration Tests"
|
||||
tests={testResults.integration}
|
||||
testId="test-section-integration"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-testid="test-results-empty"
|
||||
style={{ fontSize: "0.75em", color: "#444" }}
|
||||
>
|
||||
No test results recorded
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/** Token cost card sub-component for WorkItemDetailPanel. */
|
||||
|
||||
import type { AgentCostEntry, TokenCostResponse } from "../api/client";
|
||||
|
||||
/** Renders the "Token Cost" card in the detail panel. */
|
||||
export function TokenCostSection({
|
||||
tokenCost,
|
||||
}: {
|
||||
tokenCost: TokenCostResponse | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid="token-cost-section"
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
padding: "10px 12px",
|
||||
background: "#161616",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8em",
|
||||
color: "#555",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Token Cost
|
||||
</div>
|
||||
{tokenCost && tokenCost.agents.length > 0 ? (
|
||||
<div data-testid="token-cost-content">
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
color: "#888",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Total:{" "}
|
||||
<span data-testid="token-cost-total" style={{ color: "#ccc" }}>
|
||||
${tokenCost.total_cost_usd.toFixed(6)}
|
||||
</span>
|
||||
</div>
|
||||
{tokenCost.agents.map((agent: AgentCostEntry) => (
|
||||
<div
|
||||
key={agent.agent_name}
|
||||
data-testid={`token-cost-agent-${agent.agent_name}`}
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
color: "#888",
|
||||
padding: "4px 0",
|
||||
borderTop: "1px solid #222",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "2px",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#ccc", fontWeight: 600 }}>
|
||||
{agent.agent_name}
|
||||
{agent.model ? (
|
||||
<span
|
||||
style={{ color: "#666", fontWeight: 400 }}
|
||||
>{` (${agent.model})`}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span style={{ color: "#aaa" }}>
|
||||
${agent.total_cost_usd.toFixed(6)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: "#555" }}>
|
||||
in {agent.input_tokens.toLocaleString()} / out{" "}
|
||||
{agent.output_tokens.toLocaleString()}
|
||||
{(agent.cache_creation_input_tokens > 0 ||
|
||||
agent.cache_read_input_tokens > 0) && (
|
||||
<>
|
||||
{" "}
|
||||
/ cache +
|
||||
{agent.cache_creation_input_tokens.toLocaleString()}{" "}
|
||||
read {agent.cache_read_input_tokens.toLocaleString()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-testid="token-cost-empty"
|
||||
style={{ fontSize: "0.75em", color: "#444" }}
|
||||
>
|
||||
No token data recorded
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentEvent, AgentInfo } from "../api/agents";
|
||||
|
||||
vi.mock("../api/client", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../api/client")>("../api/client");
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
getWorkItemContent: vi.fn(),
|
||||
getTestResults: vi.fn(),
|
||||
getTokenCost: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../api/agents", () => ({
|
||||
agentsApi: {
|
||||
listAgents: vi.fn(),
|
||||
getAgentConfig: vi.fn(),
|
||||
stopAgent: vi.fn(),
|
||||
startAgent: vi.fn(),
|
||||
},
|
||||
subscribeAgentStream: vi.fn(() => () => {}),
|
||||
}));
|
||||
|
||||
import { agentsApi, subscribeAgentStream } from "../api/agents";
|
||||
import { api } from "../api/client";
|
||||
|
||||
const { WorkItemDetailPanel } = await import("./WorkItemDetailPanel");
|
||||
|
||||
const mockedGetWorkItemContent = vi.mocked(api.getWorkItemContent);
|
||||
const mockedGetTestResults = vi.mocked(api.getTestResults);
|
||||
const mockedGetTokenCost = vi.mocked(api.getTokenCost);
|
||||
const mockedListAgents = vi.mocked(agentsApi.listAgents);
|
||||
const mockedGetAgentConfig = vi.mocked(agentsApi.getAgentConfig);
|
||||
const mockedSubscribeAgentStream = vi.mocked(subscribeAgentStream);
|
||||
|
||||
const DEFAULT_CONTENT = {
|
||||
content: "# Big Title\n\nSome content here.",
|
||||
stage: "current",
|
||||
name: "Big Title Story",
|
||||
agent: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedGetWorkItemContent.mockResolvedValue(DEFAULT_CONTENT);
|
||||
mockedGetTestResults.mockResolvedValue(null);
|
||||
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
|
||||
mockedListAgents.mockResolvedValue([]);
|
||||
mockedGetAgentConfig.mockResolvedValue([]);
|
||||
mockedSubscribeAgentStream.mockReturnValue(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("WorkItemDetailPanel - Agent Logs", () => {
|
||||
it("shows placeholder when no agent is assigned to the story", async () => {
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
await screen.findByTestId("detail-panel-content");
|
||||
const placeholder = screen.getByTestId("placeholder-agent-logs");
|
||||
expect(placeholder).toBeInTheDocument();
|
||||
expect(placeholder).toHaveTextContent("Coming soon");
|
||||
});
|
||||
|
||||
it("shows agent name and running status when agent is running", async () => {
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusBadge = await screen.findByTestId("agent-status-badge");
|
||||
expect(statusBadge).toHaveTextContent("coder-1");
|
||||
expect(statusBadge).toHaveTextContent("running");
|
||||
});
|
||||
|
||||
it("shows log output when agent emits output events", async () => {
|
||||
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||
mockedSubscribeAgentStream.mockImplementation(
|
||||
(_storyId, _agentName, onEvent) => {
|
||||
emitEvent = onEvent;
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("agent-status-badge");
|
||||
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "output",
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
text: "Writing tests...",
|
||||
});
|
||||
});
|
||||
|
||||
const logOutput = screen.getByTestId("agent-log-output");
|
||||
expect(logOutput).toHaveTextContent("Writing tests...");
|
||||
});
|
||||
|
||||
it("appends multiple output events to the log", async () => {
|
||||
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||
mockedSubscribeAgentStream.mockImplementation(
|
||||
(_storyId, _agentName, onEvent) => {
|
||||
emitEvent = onEvent;
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("agent-status-badge");
|
||||
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "output",
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
text: "Line one\n",
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "output",
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
text: "Line two\n",
|
||||
});
|
||||
});
|
||||
|
||||
const logOutput = screen.getByTestId("agent-log-output");
|
||||
expect(logOutput.textContent).toContain("Line one");
|
||||
expect(logOutput.textContent).toContain("Line two");
|
||||
});
|
||||
|
||||
it("updates status to completed after done event", async () => {
|
||||
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||
mockedSubscribeAgentStream.mockImplementation(
|
||||
(_storyId, _agentName, onEvent) => {
|
||||
emitEvent = onEvent;
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("agent-status-badge");
|
||||
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "done",
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
session_id: "session-123",
|
||||
});
|
||||
});
|
||||
|
||||
const statusBadge = screen.getByTestId("agent-status-badge");
|
||||
expect(statusBadge).toHaveTextContent("completed");
|
||||
});
|
||||
|
||||
it("shows failed status after error event", async () => {
|
||||
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||
mockedSubscribeAgentStream.mockImplementation(
|
||||
(_storyId, _agentName, onEvent) => {
|
||||
emitEvent = onEvent;
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("agent-status-badge");
|
||||
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "error",
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
message: "Process failed",
|
||||
});
|
||||
});
|
||||
|
||||
const statusBadge = screen.getByTestId("agent-status-badge");
|
||||
expect(statusBadge).toHaveTextContent("failed");
|
||||
|
||||
const logOutput = screen.getByTestId("agent-log-output");
|
||||
expect(logOutput.textContent).toContain("[ERROR] Process failed");
|
||||
});
|
||||
|
||||
it("shows completed agent status without subscribing to stream", async () => {
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "completed",
|
||||
session_id: "session-123",
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusBadge = await screen.findByTestId("agent-status-badge");
|
||||
expect(statusBadge).toHaveTextContent("completed");
|
||||
expect(mockedSubscribeAgentStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows failed agent status for a failed agent without subscribing to stream", async () => {
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "failed",
|
||||
session_id: null,
|
||||
worktree_path: null,
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusBadge = await screen.findByTestId("agent-status-badge");
|
||||
expect(statusBadge).toHaveTextContent("failed");
|
||||
expect(mockedSubscribeAgentStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows agent logs section (not placeholder) when agent is assigned", async () => {
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("agent-logs-section");
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("placeholder-agent-logs"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TestResultsResponse, TokenCostResponse } from "../api/client";
|
||||
|
||||
vi.mock("../api/client", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../api/client")>("../api/client");
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
getWorkItemContent: vi.fn(),
|
||||
getTestResults: vi.fn(),
|
||||
getTokenCost: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../api/agents", () => ({
|
||||
agentsApi: {
|
||||
listAgents: vi.fn(),
|
||||
getAgentConfig: vi.fn(),
|
||||
stopAgent: vi.fn(),
|
||||
startAgent: vi.fn(),
|
||||
},
|
||||
subscribeAgentStream: vi.fn(() => () => {}),
|
||||
}));
|
||||
|
||||
import { agentsApi, subscribeAgentStream } from "../api/agents";
|
||||
import { api } from "../api/client";
|
||||
|
||||
const { WorkItemDetailPanel } = await import("./WorkItemDetailPanel");
|
||||
|
||||
const mockedGetWorkItemContent = vi.mocked(api.getWorkItemContent);
|
||||
const mockedGetTestResults = vi.mocked(api.getTestResults);
|
||||
const mockedGetTokenCost = vi.mocked(api.getTokenCost);
|
||||
const mockedListAgents = vi.mocked(agentsApi.listAgents);
|
||||
const mockedGetAgentConfig = vi.mocked(agentsApi.getAgentConfig);
|
||||
const mockedSubscribeAgentStream = vi.mocked(subscribeAgentStream);
|
||||
|
||||
const DEFAULT_CONTENT = {
|
||||
content: "# Big Title\n\nSome content here.",
|
||||
stage: "current",
|
||||
name: "Big Title Story",
|
||||
agent: null,
|
||||
};
|
||||
|
||||
const sampleTestResults: TestResultsResponse = {
|
||||
unit: [
|
||||
{ name: "test_add", status: "pass", details: null },
|
||||
{ name: "test_subtract", status: "fail", details: "expected 3, got 4" },
|
||||
],
|
||||
integration: [{ name: "test_api_endpoint", status: "pass", details: null }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedGetWorkItemContent.mockResolvedValue(DEFAULT_CONTENT);
|
||||
mockedGetTestResults.mockResolvedValue(null);
|
||||
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
|
||||
mockedListAgents.mockResolvedValue([]);
|
||||
mockedGetAgentConfig.mockResolvedValue([]);
|
||||
mockedSubscribeAgentStream.mockReturnValue(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("WorkItemDetailPanel - Test Results", () => {
|
||||
it("shows empty test results message when no results exist", async () => {
|
||||
mockedGetTestResults.mockResolvedValue(null);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("test-results-empty")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("No test results recorded")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows unit and integration test results when available", async () => {
|
||||
mockedGetTestResults.mockResolvedValue(sampleTestResults);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("test-results-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Unit test section
|
||||
expect(screen.getByTestId("test-section-unit")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Unit Tests (1 passed, 1 failed)"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Integration test section
|
||||
expect(screen.getByTestId("test-section-integration")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Integration Tests (1 passed, 0 failed)"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows pass/fail status and details for each test", async () => {
|
||||
mockedGetTestResults.mockResolvedValue(sampleTestResults);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("test-case-test_add")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Passing test
|
||||
expect(screen.getByTestId("test-status-test_add")).toHaveTextContent(
|
||||
"PASS",
|
||||
);
|
||||
expect(screen.getByText("test_add")).toBeInTheDocument();
|
||||
|
||||
// Failing test with details
|
||||
expect(screen.getByTestId("test-status-test_subtract")).toHaveTextContent(
|
||||
"FAIL",
|
||||
);
|
||||
expect(screen.getByText("test_subtract")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("test-details-test_subtract")).toHaveTextContent(
|
||||
"expected 3, got 4",
|
||||
);
|
||||
|
||||
// Integration test
|
||||
expect(
|
||||
screen.getByTestId("test-status-test_api_endpoint"),
|
||||
).toHaveTextContent("PASS");
|
||||
});
|
||||
|
||||
it("re-fetches test results when pipelineVersion changes", async () => {
|
||||
mockedGetTestResults.mockResolvedValue(null);
|
||||
|
||||
const { rerender } = render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedGetTestResults).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Update with new results and bump pipelineVersion.
|
||||
mockedGetTestResults.mockResolvedValue(sampleTestResults);
|
||||
|
||||
rerender(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={1}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedGetTestResults).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("test-results-content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkItemDetailPanel - Token Cost", () => {
|
||||
const sampleTokenCost: TokenCostResponse = {
|
||||
total_cost_usd: 0.012345,
|
||||
agents: [
|
||||
{
|
||||
agent_name: "coder-1",
|
||||
model: "claude-sonnet-4-6",
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cache_creation_input_tokens: 200,
|
||||
cache_read_input_tokens: 100,
|
||||
total_cost_usd: 0.009,
|
||||
},
|
||||
{
|
||||
agent_name: "coder-2",
|
||||
model: null,
|
||||
input_tokens: 800,
|
||||
output_tokens: 300,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
total_cost_usd: 0.003345,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("shows empty state when no token data exists", async () => {
|
||||
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("token-cost-empty")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("No token data recorded")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows per-agent breakdown and total cost when data exists", async () => {
|
||||
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("token-cost-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("token-cost-total")).toHaveTextContent(
|
||||
"$0.012345",
|
||||
);
|
||||
expect(screen.getByTestId("token-cost-agent-coder-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("token-cost-agent-coder-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows agent name and model when model is present", async () => {
|
||||
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("token-cost-agent-coder-1"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const agentRow = screen.getByTestId("token-cost-agent-coder-1");
|
||||
expect(agentRow).toHaveTextContent("coder-1");
|
||||
expect(agentRow).toHaveTextContent("claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
it("shows agent name without model when model is null", async () => {
|
||||
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("token-cost-agent-coder-2"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const agentRow = screen.getByTestId("token-cost-agent-coder-2");
|
||||
expect(agentRow).toHaveTextContent("coder-2");
|
||||
expect(agentRow).not.toHaveTextContent("null");
|
||||
});
|
||||
|
||||
it("re-fetches token cost when pipelineVersion changes", async () => {
|
||||
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
|
||||
|
||||
const { rerender } = render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedGetTokenCost).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
|
||||
|
||||
rerender(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={1}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedGetTokenCost).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("token-cost-content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentEvent, AgentInfo } from "../api/agents";
|
||||
import type { TestResultsResponse, TokenCostResponse } from "../api/client";
|
||||
|
||||
vi.mock("../api/client", async () => {
|
||||
const actual =
|
||||
@@ -46,14 +44,6 @@ const DEFAULT_CONTENT = {
|
||||
agent: null,
|
||||
};
|
||||
|
||||
const sampleTestResults: TestResultsResponse = {
|
||||
unit: [
|
||||
{ name: "test_add", status: "pass", details: null },
|
||||
{ name: "test_subtract", status: "fail", details: "expected 3, got 4" },
|
||||
],
|
||||
integration: [{ name: "test_api_endpoint", status: "pass", details: null }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedGetWorkItemContent.mockResolvedValue(DEFAULT_CONTENT);
|
||||
@@ -214,325 +204,6 @@ describe("WorkItemDetailPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkItemDetailPanel - Agent Logs", () => {
|
||||
it("shows placeholder when no agent is assigned to the story", async () => {
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
await screen.findByTestId("detail-panel-content");
|
||||
const placeholder = screen.getByTestId("placeholder-agent-logs");
|
||||
expect(placeholder).toBeInTheDocument();
|
||||
expect(placeholder).toHaveTextContent("Coming soon");
|
||||
});
|
||||
|
||||
it("shows agent name and running status when agent is running", async () => {
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusBadge = await screen.findByTestId("agent-status-badge");
|
||||
expect(statusBadge).toHaveTextContent("coder-1");
|
||||
expect(statusBadge).toHaveTextContent("running");
|
||||
});
|
||||
|
||||
it("shows log output when agent emits output events", async () => {
|
||||
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||
mockedSubscribeAgentStream.mockImplementation(
|
||||
(_storyId, _agentName, onEvent) => {
|
||||
emitEvent = onEvent;
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("agent-status-badge");
|
||||
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "output",
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
text: "Writing tests...",
|
||||
});
|
||||
});
|
||||
|
||||
const logOutput = screen.getByTestId("agent-log-output");
|
||||
expect(logOutput).toHaveTextContent("Writing tests...");
|
||||
});
|
||||
|
||||
it("appends multiple output events to the log", async () => {
|
||||
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||
mockedSubscribeAgentStream.mockImplementation(
|
||||
(_storyId, _agentName, onEvent) => {
|
||||
emitEvent = onEvent;
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("agent-status-badge");
|
||||
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "output",
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
text: "Line one\n",
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "output",
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
text: "Line two\n",
|
||||
});
|
||||
});
|
||||
|
||||
const logOutput = screen.getByTestId("agent-log-output");
|
||||
expect(logOutput.textContent).toContain("Line one");
|
||||
expect(logOutput.textContent).toContain("Line two");
|
||||
});
|
||||
|
||||
it("updates status to completed after done event", async () => {
|
||||
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||
mockedSubscribeAgentStream.mockImplementation(
|
||||
(_storyId, _agentName, onEvent) => {
|
||||
emitEvent = onEvent;
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("agent-status-badge");
|
||||
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "done",
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
session_id: "session-123",
|
||||
});
|
||||
});
|
||||
|
||||
const statusBadge = screen.getByTestId("agent-status-badge");
|
||||
expect(statusBadge).toHaveTextContent("completed");
|
||||
});
|
||||
|
||||
it("shows failed status after error event", async () => {
|
||||
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||
mockedSubscribeAgentStream.mockImplementation(
|
||||
(_storyId, _agentName, onEvent) => {
|
||||
emitEvent = onEvent;
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("agent-status-badge");
|
||||
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "error",
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
message: "Process failed",
|
||||
});
|
||||
});
|
||||
|
||||
const statusBadge = screen.getByTestId("agent-status-badge");
|
||||
expect(statusBadge).toHaveTextContent("failed");
|
||||
|
||||
const logOutput = screen.getByTestId("agent-log-output");
|
||||
expect(logOutput.textContent).toContain("[ERROR] Process failed");
|
||||
});
|
||||
|
||||
it("shows completed agent status without subscribing to stream", async () => {
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "completed",
|
||||
session_id: "session-123",
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusBadge = await screen.findByTestId("agent-status-badge");
|
||||
expect(statusBadge).toHaveTextContent("completed");
|
||||
expect(mockedSubscribeAgentStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows failed agent status for a failed agent without subscribing to stream", async () => {
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "failed",
|
||||
session_id: null,
|
||||
worktree_path: null,
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusBadge = await screen.findByTestId("agent-status-badge");
|
||||
expect(statusBadge).toHaveTextContent("failed");
|
||||
expect(mockedSubscribeAgentStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows agent logs section (not placeholder) when agent is assigned", async () => {
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("agent-logs-section");
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("placeholder-agent-logs"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkItemDetailPanel - Assigned Agent", () => {
|
||||
it("shows assigned agent name when agent front matter field is set", async () => {
|
||||
mockedGetWorkItemContent.mockResolvedValue({
|
||||
@@ -586,264 +257,3 @@ describe("WorkItemDetailPanel - Assigned Agent", () => {
|
||||
expect(agentEl).not.toHaveTextContent("assigned");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkItemDetailPanel - Test Results", () => {
|
||||
it("shows empty test results message when no results exist", async () => {
|
||||
mockedGetTestResults.mockResolvedValue(null);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("test-results-empty")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("No test results recorded")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows unit and integration test results when available", async () => {
|
||||
mockedGetTestResults.mockResolvedValue(sampleTestResults);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("test-results-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Unit test section
|
||||
expect(screen.getByTestId("test-section-unit")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Unit Tests (1 passed, 1 failed)"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Integration test section
|
||||
expect(screen.getByTestId("test-section-integration")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Integration Tests (1 passed, 0 failed)"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows pass/fail status and details for each test", async () => {
|
||||
mockedGetTestResults.mockResolvedValue(sampleTestResults);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("test-case-test_add")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Passing test
|
||||
expect(screen.getByTestId("test-status-test_add")).toHaveTextContent(
|
||||
"PASS",
|
||||
);
|
||||
expect(screen.getByText("test_add")).toBeInTheDocument();
|
||||
|
||||
// Failing test with details
|
||||
expect(screen.getByTestId("test-status-test_subtract")).toHaveTextContent(
|
||||
"FAIL",
|
||||
);
|
||||
expect(screen.getByText("test_subtract")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("test-details-test_subtract")).toHaveTextContent(
|
||||
"expected 3, got 4",
|
||||
);
|
||||
|
||||
// Integration test
|
||||
expect(
|
||||
screen.getByTestId("test-status-test_api_endpoint"),
|
||||
).toHaveTextContent("PASS");
|
||||
});
|
||||
|
||||
it("re-fetches test results when pipelineVersion changes", async () => {
|
||||
mockedGetTestResults.mockResolvedValue(null);
|
||||
|
||||
const { rerender } = render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedGetTestResults).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Update with new results and bump pipelineVersion.
|
||||
mockedGetTestResults.mockResolvedValue(sampleTestResults);
|
||||
|
||||
rerender(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={1}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedGetTestResults).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("test-results-content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkItemDetailPanel - Token Cost", () => {
|
||||
const sampleTokenCost: TokenCostResponse = {
|
||||
total_cost_usd: 0.012345,
|
||||
agents: [
|
||||
{
|
||||
agent_name: "coder-1",
|
||||
model: "claude-sonnet-4-6",
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cache_creation_input_tokens: 200,
|
||||
cache_read_input_tokens: 100,
|
||||
total_cost_usd: 0.009,
|
||||
},
|
||||
{
|
||||
agent_name: "coder-2",
|
||||
model: null,
|
||||
input_tokens: 800,
|
||||
output_tokens: 300,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
total_cost_usd: 0.003345,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("shows empty state when no token data exists", async () => {
|
||||
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("token-cost-empty")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("No token data recorded")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows per-agent breakdown and total cost when data exists", async () => {
|
||||
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("token-cost-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("token-cost-total")).toHaveTextContent(
|
||||
"$0.012345",
|
||||
);
|
||||
expect(screen.getByTestId("token-cost-agent-coder-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("token-cost-agent-coder-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows agent name and model when model is present", async () => {
|
||||
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("token-cost-agent-coder-1"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const agentRow = screen.getByTestId("token-cost-agent-coder-1");
|
||||
expect(agentRow).toHaveTextContent("coder-1");
|
||||
expect(agentRow).toHaveTextContent("claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
it("shows agent name without model when model is null", async () => {
|
||||
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("token-cost-agent-coder-2"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const agentRow = screen.getByTestId("token-cost-agent-coder-2");
|
||||
expect(agentRow).toHaveTextContent("coder-2");
|
||||
expect(agentRow).not.toHaveTextContent("null");
|
||||
});
|
||||
|
||||
it("re-fetches token cost when pipelineVersion changes", async () => {
|
||||
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
|
||||
|
||||
const { rerender } = render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedGetTokenCost).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
|
||||
|
||||
rerender(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={1}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedGetTokenCost).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("token-cost-content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,71 +8,18 @@ import type {
|
||||
} from "../api/agents";
|
||||
import { agentsApi, subscribeAgentStream } from "../api/agents";
|
||||
import type {
|
||||
AgentCostEntry,
|
||||
TestCaseResult,
|
||||
TestResultsResponse,
|
||||
TokenCostResponse,
|
||||
} from "../api/client";
|
||||
import { api } from "../api/client";
|
||||
import { AgentLogsSection } from "./AgentLogsSection";
|
||||
import { TestResultsSection } from "./TestResultsSection";
|
||||
import { TokenCostSection } from "./TokenCostSection";
|
||||
import { WorkItemDetailPanelHeader } from "./WorkItemDetailPanelHeader";
|
||||
import { stripDisplayContent } from "./workItemDetailPanelUtils";
|
||||
|
||||
const { useCallback, useEffect, useRef, useState } = React;
|
||||
|
||||
/**
|
||||
* Strip YAML front matter and the first H1 heading from story content before
|
||||
* rendering. The panel header already shows the story ID/title, so rendering
|
||||
* them again inside the markdown body creates duplicate information.
|
||||
*/
|
||||
function stripDisplayContent(content: string): string {
|
||||
let text = content;
|
||||
// Strip YAML front matter (--- ... ---)
|
||||
if (text.startsWith("---")) {
|
||||
const eol = text.indexOf("\n");
|
||||
if (eol !== -1) {
|
||||
const closeIdx = text.indexOf("\n---", eol);
|
||||
if (closeIdx !== -1) {
|
||||
text = text.slice(closeIdx + 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Trim leading blank lines left by the front matter
|
||||
text = text.trimStart();
|
||||
// Strip the first H1 heading — it duplicates the panel header title
|
||||
if (text.startsWith("# ")) {
|
||||
const eol = text.indexOf("\n");
|
||||
text = eol !== -1 ? text.slice(eol + 1).trimStart() : "";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the story ID/title line shown in the panel header.
|
||||
* Produces e.g. "Story 454: My Story Name" or "Bug 12: Crash on startup".
|
||||
* Falls back to name or storyId when the pattern doesn't match.
|
||||
*/
|
||||
function formatStoryTitle(storyId: string, name: string | null): string {
|
||||
const match = storyId.match(/^(\d+)_([a-z]+)_/);
|
||||
if (!match || !name) return name ?? storyId;
|
||||
const [, number, type] = match;
|
||||
const typeLabel = type.charAt(0).toUpperCase() + type.slice(1);
|
||||
return `${typeLabel} ${number}: ${name}`;
|
||||
}
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
backlog: "Backlog",
|
||||
current: "Current",
|
||||
qa: "QA",
|
||||
merge: "To Merge",
|
||||
done: "Done",
|
||||
archived: "Archived",
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<AgentStatusValue, string> = {
|
||||
running: "#3fb950",
|
||||
pending: "#e3b341",
|
||||
completed: "#aaa",
|
||||
failed: "#f85149",
|
||||
};
|
||||
|
||||
interface WorkItemDetailPanelProps {
|
||||
storyId: string;
|
||||
pipelineVersion: number;
|
||||
@@ -81,82 +28,6 @@ interface WorkItemDetailPanelProps {
|
||||
reviewHold?: boolean;
|
||||
}
|
||||
|
||||
function TestCaseRow({ tc }: { tc: TestCaseResult }) {
|
||||
const isPassing = tc.status === "pass";
|
||||
return (
|
||||
<div
|
||||
data-testid={`test-case-${tc.name}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "2px",
|
||||
padding: "4px 0",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<span
|
||||
data-testid={`test-status-${tc.name}`}
|
||||
style={{
|
||||
fontSize: "0.85em",
|
||||
color: isPassing ? "#3fb950" : "#f85149",
|
||||
}}
|
||||
>
|
||||
{isPassing ? "PASS" : "FAIL"}
|
||||
</span>
|
||||
<span style={{ fontSize: "0.82em", color: "#ccc" }}>{tc.name}</span>
|
||||
</div>
|
||||
{tc.details && (
|
||||
<div
|
||||
data-testid={`test-details-${tc.name}`}
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
color: "#888",
|
||||
paddingLeft: "22px",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{tc.details}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TestSection({
|
||||
title,
|
||||
tests,
|
||||
testId,
|
||||
}: {
|
||||
title: string;
|
||||
tests: TestCaseResult[];
|
||||
testId: string;
|
||||
}) {
|
||||
const passCount = tests.filter((t) => t.status === "pass").length;
|
||||
const failCount = tests.length - passCount;
|
||||
return (
|
||||
<div data-testid={testId}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.78em",
|
||||
fontWeight: 600,
|
||||
color: "#aaa",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
{title} ({passCount} passed, {failCount} failed)
|
||||
</div>
|
||||
{tests.length === 0 ? (
|
||||
<div style={{ fontSize: "0.75em", color: "#555", fontStyle: "italic" }}>
|
||||
No tests recorded
|
||||
</div>
|
||||
) : (
|
||||
tests.map((tc) => <TestCaseRow key={tc.name} tc={tc} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkItemDetailPanel({
|
||||
storyId,
|
||||
pipelineVersion,
|
||||
@@ -302,17 +173,6 @@ export function WorkItemDetailPanel({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Map pipeline stage → agent stage filter.
|
||||
const STAGE_TO_AGENT_STAGE: Record<string, string> = {
|
||||
current: "coder",
|
||||
qa: "qa",
|
||||
merge: "mergemaster",
|
||||
};
|
||||
|
||||
const filteredAgents = agentConfig.filter(
|
||||
(a) => a.stage === STAGE_TO_AGENT_STAGE[stage],
|
||||
);
|
||||
|
||||
// The currently active agent name for this story (running or pending).
|
||||
const activeAgentName =
|
||||
agentInfo && (agentStatus === "running" || agentStatus === "pending")
|
||||
@@ -343,11 +203,6 @@ export function WorkItemDetailPanel({
|
||||
[storyId, activeAgentName],
|
||||
);
|
||||
|
||||
const stageLabel = STAGE_LABELS[stage] ?? stage;
|
||||
const hasTestResults =
|
||||
testResults &&
|
||||
(testResults.unit.length > 0 || testResults.integration.length > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="work-item-detail-panel"
|
||||
@@ -362,138 +217,19 @@ export function WorkItemDetailPanel({
|
||||
border: "1px solid #333",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 16px",
|
||||
borderBottom: "1px solid #333",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "2px",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-testid="detail-panel-title"
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.95em",
|
||||
color: "#ececec",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{formatStoryTitle(storyId, name)}
|
||||
</div>
|
||||
{stage && (
|
||||
<div
|
||||
data-testid="detail-panel-stage"
|
||||
style={{ fontSize: "0.75em", color: "#888" }}
|
||||
>
|
||||
{stageLabel}
|
||||
</div>
|
||||
)}
|
||||
{filteredAgents.length > 0 && (
|
||||
<div
|
||||
data-testid="detail-panel-agent-assignment"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "0.75em", color: "#666" }}>Agent:</span>
|
||||
<select
|
||||
data-testid="agent-assignment-dropdown"
|
||||
disabled={assigning}
|
||||
value={activeAgentName ?? assignedAgent ?? ""}
|
||||
onChange={(e) => handleAgentAssign(e.target.value)}
|
||||
style={{
|
||||
background: "#1a1a1a",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
color: "#ccc",
|
||||
cursor: assigning ? "not-allowed" : "pointer",
|
||||
fontSize: "0.75em",
|
||||
padding: "2px 6px",
|
||||
opacity: assigning ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<option value="">— none —</option>
|
||||
{filteredAgents.map((a) => {
|
||||
const isRunning =
|
||||
agentInfo?.agent_name === a.name &&
|
||||
agentStatus === "running";
|
||||
const isPending =
|
||||
agentInfo?.agent_name === a.name &&
|
||||
agentStatus === "pending";
|
||||
const statusLabel = isRunning
|
||||
? " — running"
|
||||
: isPending
|
||||
? " — pending"
|
||||
: " — idle";
|
||||
const modelPart = a.model ? ` (${a.model})` : "";
|
||||
return (
|
||||
<option key={a.name} value={a.name}>
|
||||
{a.name}
|
||||
{modelPart}
|
||||
{statusLabel}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{assigning && (
|
||||
<span style={{ fontSize: "0.7em", color: "#888" }}>
|
||||
Assigning…
|
||||
</span>
|
||||
)}
|
||||
{assignError && (
|
||||
<span
|
||||
data-testid="agent-assignment-error"
|
||||
style={{ fontSize: "0.7em", color: "#f85149" }}
|
||||
>
|
||||
{assignError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{filteredAgents.length === 0 && assignedAgent ? (
|
||||
<div
|
||||
data-testid="detail-panel-assigned-agent"
|
||||
style={{ fontSize: "0.75em", color: "#888" }}
|
||||
>
|
||||
Agent: {assignedAgent}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="detail-panel-close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "6px",
|
||||
color: "#aaa",
|
||||
cursor: "pointer",
|
||||
padding: "4px 10px",
|
||||
fontSize: "0.8em",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<WorkItemDetailPanelHeader
|
||||
storyId={storyId}
|
||||
name={name}
|
||||
stage={stage}
|
||||
assignedAgent={assignedAgent}
|
||||
agentConfig={agentConfig}
|
||||
agentInfo={agentInfo}
|
||||
agentStatus={agentStatus}
|
||||
assigning={assigning}
|
||||
assignError={assignError}
|
||||
onAgentAssign={handleAgentAssign}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<div
|
||||
@@ -549,145 +285,9 @@ export function WorkItemDetailPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token Cost section */}
|
||||
<div
|
||||
data-testid="token-cost-section"
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
padding: "10px 12px",
|
||||
background: "#161616",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8em",
|
||||
color: "#555",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Token Cost
|
||||
</div>
|
||||
{tokenCost && tokenCost.agents.length > 0 ? (
|
||||
<div data-testid="token-cost-content">
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
color: "#888",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Total:{" "}
|
||||
<span data-testid="token-cost-total" style={{ color: "#ccc" }}>
|
||||
${tokenCost.total_cost_usd.toFixed(6)}
|
||||
</span>
|
||||
</div>
|
||||
{tokenCost.agents.map((agent: AgentCostEntry) => (
|
||||
<div
|
||||
key={agent.agent_name}
|
||||
data-testid={`token-cost-agent-${agent.agent_name}`}
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
color: "#888",
|
||||
padding: "4px 0",
|
||||
borderTop: "1px solid #222",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "2px",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#ccc", fontWeight: 600 }}>
|
||||
{agent.agent_name}
|
||||
{agent.model ? (
|
||||
<span
|
||||
style={{ color: "#666", fontWeight: 400 }}
|
||||
>{` (${agent.model})`}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span style={{ color: "#aaa" }}>
|
||||
${agent.total_cost_usd.toFixed(6)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: "#555" }}>
|
||||
in {agent.input_tokens.toLocaleString()} / out{" "}
|
||||
{agent.output_tokens.toLocaleString()}
|
||||
{(agent.cache_creation_input_tokens > 0 ||
|
||||
agent.cache_read_input_tokens > 0) && (
|
||||
<>
|
||||
{" "}
|
||||
/ cache +
|
||||
{agent.cache_creation_input_tokens.toLocaleString()}{" "}
|
||||
read {agent.cache_read_input_tokens.toLocaleString()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-testid="token-cost-empty"
|
||||
style={{ fontSize: "0.75em", color: "#444" }}
|
||||
>
|
||||
No token data recorded
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TokenCostSection tokenCost={tokenCost} />
|
||||
|
||||
{/* Test Results section */}
|
||||
<div
|
||||
data-testid="test-results-section"
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
padding: "10px 12px",
|
||||
background: "#161616",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8em",
|
||||
color: "#555",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Test Results
|
||||
</div>
|
||||
{hasTestResults ? (
|
||||
<div
|
||||
data-testid="test-results-content"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<TestSection
|
||||
title="Unit Tests"
|
||||
tests={testResults.unit}
|
||||
testId="test-section-unit"
|
||||
/>
|
||||
<TestSection
|
||||
title="Integration Tests"
|
||||
tests={testResults.integration}
|
||||
testId="test-section-integration"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-testid="test-results-empty"
|
||||
style={{ fontSize: "0.75em", color: "#444" }}
|
||||
>
|
||||
No test results recorded
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TestResultsSection testResults={testResults} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
@@ -696,97 +296,11 @@ export function WorkItemDetailPanel({
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
{/* Agent Logs section */}
|
||||
{!agentInfo && (
|
||||
<div
|
||||
data-testid="placeholder-agent-logs"
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
padding: "10px 12px",
|
||||
background: "#161616",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8em",
|
||||
color: "#555",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
Agent Logs
|
||||
</div>
|
||||
<div style={{ fontSize: "0.75em", color: "#444" }}>
|
||||
Coming soon
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{agentInfo && (
|
||||
<div
|
||||
data-testid="agent-logs-section"
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
padding: "10px 12px",
|
||||
background: "#161616",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8em",
|
||||
color: "#888",
|
||||
}}
|
||||
>
|
||||
Agent Logs
|
||||
</div>
|
||||
{agentStatus && (
|
||||
<div
|
||||
data-testid="agent-status-badge"
|
||||
style={{
|
||||
fontSize: "0.7em",
|
||||
color: STATUS_COLORS[agentStatus],
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{agentInfo.agent_name} — {agentStatus}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{agentLog.length > 0 ? (
|
||||
<div
|
||||
data-testid="agent-log-output"
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
fontFamily: "monospace",
|
||||
color: "#ccc",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
lineHeight: "1.5",
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{agentLog.join("")}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: "0.75em", color: "#444" }}>
|
||||
{agentStatus === "running" || agentStatus === "pending"
|
||||
? "Waiting for output..."
|
||||
: "No output."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<AgentLogsSection
|
||||
agentInfo={agentInfo}
|
||||
agentStatus={agentStatus}
|
||||
agentLog={agentLog}
|
||||
/>
|
||||
|
||||
{/* Placeholder sections for future content */}
|
||||
{(
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/** Header sub-component for WorkItemDetailPanel. */
|
||||
|
||||
import type { AgentConfigInfo, AgentInfo, AgentStatusValue } from "../api/agents";
|
||||
import { STAGE_LABELS, formatStoryTitle } from "./workItemDetailPanelUtils";
|
||||
|
||||
const STAGE_TO_AGENT_STAGE: Record<string, string> = {
|
||||
current: "coder",
|
||||
qa: "qa",
|
||||
merge: "mergemaster",
|
||||
};
|
||||
|
||||
interface WorkItemDetailPanelHeaderProps {
|
||||
storyId: string;
|
||||
name: string | null;
|
||||
stage: string;
|
||||
assignedAgent: string | null;
|
||||
agentConfig: AgentConfigInfo[];
|
||||
agentInfo: AgentInfo | null;
|
||||
agentStatus: AgentStatusValue | null;
|
||||
assigning: boolean;
|
||||
assignError: string | null;
|
||||
onAgentAssign: (agentName: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel header: title, stage label, agent assignment dropdown, and close button.
|
||||
*/
|
||||
export function WorkItemDetailPanelHeader({
|
||||
storyId,
|
||||
name,
|
||||
stage,
|
||||
assignedAgent,
|
||||
agentConfig,
|
||||
agentInfo,
|
||||
agentStatus,
|
||||
assigning,
|
||||
assignError,
|
||||
onAgentAssign,
|
||||
onClose,
|
||||
}: WorkItemDetailPanelHeaderProps) {
|
||||
const stageLabel = STAGE_LABELS[stage] ?? stage;
|
||||
const filteredAgents = agentConfig.filter(
|
||||
(a) => a.stage === STAGE_TO_AGENT_STAGE[stage],
|
||||
);
|
||||
const activeAgentName =
|
||||
agentInfo && (agentStatus === "running" || agentStatus === "pending")
|
||||
? agentInfo.agent_name
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 16px",
|
||||
borderBottom: "1px solid #333",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "2px",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-testid="detail-panel-title"
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.95em",
|
||||
color: "#ececec",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{formatStoryTitle(storyId, name)}
|
||||
</div>
|
||||
{stage && (
|
||||
<div
|
||||
data-testid="detail-panel-stage"
|
||||
style={{ fontSize: "0.75em", color: "#888" }}
|
||||
>
|
||||
{stageLabel}
|
||||
</div>
|
||||
)}
|
||||
{filteredAgents.length > 0 && (
|
||||
<div
|
||||
data-testid="detail-panel-agent-assignment"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "0.75em", color: "#666" }}>Agent:</span>
|
||||
<select
|
||||
data-testid="agent-assignment-dropdown"
|
||||
disabled={assigning}
|
||||
value={activeAgentName ?? assignedAgent ?? ""}
|
||||
onChange={(e) => onAgentAssign(e.target.value)}
|
||||
style={{
|
||||
background: "#1a1a1a",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
color: "#ccc",
|
||||
cursor: assigning ? "not-allowed" : "pointer",
|
||||
fontSize: "0.75em",
|
||||
padding: "2px 6px",
|
||||
opacity: assigning ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<option value="">— none —</option>
|
||||
{filteredAgents.map((a) => {
|
||||
const isRunning =
|
||||
agentInfo?.agent_name === a.name &&
|
||||
agentStatus === "running";
|
||||
const isPending =
|
||||
agentInfo?.agent_name === a.name &&
|
||||
agentStatus === "pending";
|
||||
const statusLabel = isRunning
|
||||
? " — running"
|
||||
: isPending
|
||||
? " — pending"
|
||||
: " — idle";
|
||||
const modelPart = a.model ? ` (${a.model})` : "";
|
||||
return (
|
||||
<option key={a.name} value={a.name}>
|
||||
{a.name}
|
||||
{modelPart}
|
||||
{statusLabel}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{assigning && (
|
||||
<span style={{ fontSize: "0.7em", color: "#888" }}>
|
||||
Assigning…
|
||||
</span>
|
||||
)}
|
||||
{assignError && (
|
||||
<span
|
||||
data-testid="agent-assignment-error"
|
||||
style={{ fontSize: "0.7em", color: "#f85149" }}
|
||||
>
|
||||
{assignError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{filteredAgents.length === 0 && assignedAgent ? (
|
||||
<div
|
||||
data-testid="detail-panel-assigned-agent"
|
||||
style={{ fontSize: "0.75em", color: "#888" }}
|
||||
>
|
||||
Agent: {assignedAgent}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="detail-panel-close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "6px",
|
||||
color: "#aaa",
|
||||
cursor: "pointer",
|
||||
padding: "4px 10px",
|
||||
fontSize: "0.8em",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -138,7 +138,7 @@ describe("usePathCompletion hook", () => {
|
||||
expect(result.current.matchList[0].name).toBe("Documents");
|
||||
});
|
||||
|
||||
it("calls setPathInput when acceptMatch is invoked", () => {
|
||||
it("calls setPathInput when acceptMatch is invoked", async () => {
|
||||
const setPathInput = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -151,7 +151,7 @@ describe("usePathCompletion hook", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
result.current.acceptMatch("/home/user/Documents/");
|
||||
});
|
||||
|
||||
@@ -308,14 +308,14 @@ describe("usePathCompletion hook", () => {
|
||||
expect(result.current.matchList.length).toBe(2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
result.current.acceptSelectedMatch();
|
||||
});
|
||||
|
||||
expect(setPathInput).toHaveBeenCalledWith("/home/user/Documents/");
|
||||
});
|
||||
|
||||
it("acceptSelectedMatch does nothing when matchList is empty", () => {
|
||||
it("acceptSelectedMatch does nothing when matchList is empty", async () => {
|
||||
const setPathInput = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -328,7 +328,7 @@ describe("usePathCompletion hook", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
result.current.acceptSelectedMatch();
|
||||
});
|
||||
|
||||
@@ -352,7 +352,7 @@ describe("usePathCompletion hook", () => {
|
||||
expect(result.current.matchList.length).toBe(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
result.current.closeSuggestions();
|
||||
});
|
||||
|
||||
@@ -450,7 +450,7 @@ describe("usePathCompletion hook", () => {
|
||||
expect(result.current.matchList.length).toBe(2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
result.current.setSelectedMatch(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/** Shared utility functions and constants for WorkItemDetailPanel sub-components. */
|
||||
|
||||
import type { AgentStatusValue } from "../api/agents";
|
||||
|
||||
export const STAGE_LABELS: Record<string, string> = {
|
||||
backlog: "Backlog",
|
||||
current: "Current",
|
||||
qa: "QA",
|
||||
merge: "To Merge",
|
||||
done: "Done",
|
||||
archived: "Archived",
|
||||
};
|
||||
|
||||
export const STATUS_COLORS: Record<AgentStatusValue, string> = {
|
||||
running: "#3fb950",
|
||||
pending: "#e3b341",
|
||||
completed: "#aaa",
|
||||
failed: "#f85149",
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip YAML front matter and the first H1 heading from story content before
|
||||
* rendering. The panel header already shows the story ID/title, so rendering
|
||||
* them again inside the markdown body creates duplicate information.
|
||||
*/
|
||||
export function stripDisplayContent(content: string): string {
|
||||
let text = content;
|
||||
// Strip YAML front matter (--- ... ---)
|
||||
if (text.startsWith("---")) {
|
||||
const eol = text.indexOf("\n");
|
||||
if (eol !== -1) {
|
||||
const closeIdx = text.indexOf("\n---", eol);
|
||||
if (closeIdx !== -1) {
|
||||
text = text.slice(closeIdx + 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Trim leading blank lines left by the front matter
|
||||
text = text.trimStart();
|
||||
// Strip the first H1 heading — it duplicates the panel header title
|
||||
if (text.startsWith("# ")) {
|
||||
const eol = text.indexOf("\n");
|
||||
text = eol !== -1 ? text.slice(eol + 1).trimStart() : "";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the story ID/title line shown in the panel header.
|
||||
* Produces e.g. "Story 454: My Story Name" or "Bug 12: Crash on startup".
|
||||
* Falls back to name or storyId when the pattern doesn't match.
|
||||
*/
|
||||
export function formatStoryTitle(storyId: string, name: string | null): string {
|
||||
const match = storyId.match(/^(\d+)_([a-z]+)_/);
|
||||
if (!match || !name) return name ?? storyId;
|
||||
const [, number, type] = match;
|
||||
const typeLabel = type.charAt(0).toUpperCase() + type.slice(1);
|
||||
return `${typeLabel} ${number}: ${name}`;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ function makeMessages(count: number): Message[] {
|
||||
}));
|
||||
}
|
||||
|
||||
describe("useChatHistory", () => {
|
||||
describe("useChatHistory", async () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -28,7 +28,7 @@ describe("useChatHistory", () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("AC1: restores messages from localStorage on mount", () => {
|
||||
it("AC1: restores messages from localStorage on mount", async () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(sampleMessages));
|
||||
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
@@ -36,13 +36,13 @@ describe("useChatHistory", () => {
|
||||
expect(result.current.messages).toEqual(sampleMessages);
|
||||
});
|
||||
|
||||
it("AC1: returns empty array when localStorage has no data", () => {
|
||||
it("AC1: returns empty array when localStorage has no data", async () => {
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
|
||||
expect(result.current.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("AC1: returns empty array when localStorage contains invalid JSON", () => {
|
||||
it("AC1: returns empty array when localStorage contains invalid JSON", async () => {
|
||||
localStorage.setItem(STORAGE_KEY, "not-json{{{");
|
||||
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
@@ -50,7 +50,7 @@ describe("useChatHistory", () => {
|
||||
expect(result.current.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("AC1: returns empty array when localStorage contains a non-array", () => {
|
||||
it("AC1: returns empty array when localStorage contains a non-array", async () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ not: "array" }));
|
||||
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
@@ -58,10 +58,10 @@ describe("useChatHistory", () => {
|
||||
expect(result.current.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("AC2: saves messages to localStorage when setMessages is called with an array", () => {
|
||||
it("AC2: saves messages to localStorage when setMessages is called with an array", async () => {
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
result.current.setMessages(sampleMessages);
|
||||
});
|
||||
|
||||
@@ -69,10 +69,10 @@ describe("useChatHistory", () => {
|
||||
expect(stored).toEqual(sampleMessages);
|
||||
});
|
||||
|
||||
it("AC2: saves messages to localStorage when setMessages is called with updater function", () => {
|
||||
it("AC2: saves messages to localStorage when setMessages is called with updater function", async () => {
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
result.current.setMessages(() => sampleMessages);
|
||||
});
|
||||
|
||||
@@ -80,14 +80,14 @@ describe("useChatHistory", () => {
|
||||
expect(stored).toEqual(sampleMessages);
|
||||
});
|
||||
|
||||
it("AC3: clearMessages removes messages from state and localStorage", () => {
|
||||
it("AC3: clearMessages removes messages from state and localStorage", async () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(sampleMessages));
|
||||
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
|
||||
expect(result.current.messages).toEqual(sampleMessages);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
result.current.clearMessages();
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ describe("useChatHistory", () => {
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("AC4: handles localStorage quota errors gracefully", () => {
|
||||
it("AC4: handles localStorage quota errors gracefully", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const setItemSpy = vi
|
||||
.spyOn(Storage.prototype, "setItem")
|
||||
@@ -106,7 +106,7 @@ describe("useChatHistory", () => {
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
|
||||
// Should not throw
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
result.current.setMessages(sampleMessages);
|
||||
});
|
||||
|
||||
@@ -121,7 +121,7 @@ describe("useChatHistory", () => {
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("AC5: scopes storage key to project path", () => {
|
||||
it("AC5: scopes storage key to project path", async () => {
|
||||
const projectA = "/projects/a";
|
||||
const projectB = "/projects/b";
|
||||
const keyA = `storykit-chat-history:${projectA}`;
|
||||
@@ -140,12 +140,12 @@ describe("useChatHistory", () => {
|
||||
expect(resultB.current.messages).toEqual(messagesB);
|
||||
});
|
||||
|
||||
it("AC2: removes localStorage key when messages are set to empty array", () => {
|
||||
it("AC2: removes localStorage key when messages are set to empty array", async () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(sampleMessages));
|
||||
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
result.current.setMessages([]);
|
||||
});
|
||||
|
||||
@@ -154,20 +154,20 @@ describe("useChatHistory", () => {
|
||||
|
||||
// --- Story 179: Chat history pruning tests ---
|
||||
|
||||
it("S179: default limit of 200 is applied when saving to localStorage", () => {
|
||||
it("S179: default limit of 200 is applied when saving to localStorage", async () => {
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
|
||||
expect(result.current.maxMessages).toBe(200);
|
||||
});
|
||||
|
||||
it("S179: messages are pruned from the front when exceeding the limit", () => {
|
||||
it("S179: messages are pruned from the front when exceeding the limit", async () => {
|
||||
// Set a small limit to make testing practical
|
||||
localStorage.setItem(LIMIT_KEY, "3");
|
||||
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
const fiveMessages = makeMessages(5);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
result.current.setMessages(fiveMessages);
|
||||
});
|
||||
|
||||
@@ -180,13 +180,13 @@ describe("useChatHistory", () => {
|
||||
expect(stored[0].content).toBe("Message 3");
|
||||
});
|
||||
|
||||
it("S179: messages under the limit are not pruned", () => {
|
||||
it("S179: messages under the limit are not pruned", async () => {
|
||||
localStorage.setItem(LIMIT_KEY, "10");
|
||||
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
const threeMessages = makeMessages(3);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
result.current.setMessages(threeMessages);
|
||||
});
|
||||
|
||||
@@ -197,7 +197,7 @@ describe("useChatHistory", () => {
|
||||
expect(stored).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("S179: limit is configurable via localStorage key", () => {
|
||||
it("S179: limit is configurable via localStorage key", async () => {
|
||||
localStorage.setItem(LIMIT_KEY, "5");
|
||||
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
@@ -205,10 +205,10 @@ describe("useChatHistory", () => {
|
||||
expect(result.current.maxMessages).toBe(5);
|
||||
});
|
||||
|
||||
it("S179: setMaxMessages updates the limit and persists it", () => {
|
||||
it("S179: setMaxMessages updates the limit and persists it", async () => {
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
result.current.setMaxMessages(50);
|
||||
});
|
||||
|
||||
@@ -216,13 +216,13 @@ describe("useChatHistory", () => {
|
||||
expect(localStorage.getItem(LIMIT_KEY)).toBe("50");
|
||||
});
|
||||
|
||||
it("S179: a limit of 0 means unlimited (no pruning)", () => {
|
||||
it("S179: a limit of 0 means unlimited (no pruning)", async () => {
|
||||
localStorage.setItem(LIMIT_KEY, "0");
|
||||
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
const manyMessages = makeMessages(500);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
result.current.setMessages(manyMessages);
|
||||
});
|
||||
|
||||
@@ -233,11 +233,11 @@ describe("useChatHistory", () => {
|
||||
expect(stored).toEqual(manyMessages);
|
||||
});
|
||||
|
||||
it("S179: changing the limit re-prunes messages on next save", () => {
|
||||
it("S179: changing the limit re-prunes messages on next save", async () => {
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
const tenMessages = makeMessages(10);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
result.current.setMessages(tenMessages);
|
||||
});
|
||||
|
||||
@@ -248,7 +248,7 @@ describe("useChatHistory", () => {
|
||||
expect(stored).toHaveLength(10);
|
||||
|
||||
// Now lower the limit — the effect re-runs and prunes
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
result.current.setMaxMessages(3);
|
||||
});
|
||||
|
||||
@@ -257,7 +257,7 @@ describe("useChatHistory", () => {
|
||||
expect(stored[0].content).toBe("Message 8");
|
||||
});
|
||||
|
||||
it("S179: invalid limit in localStorage falls back to default", () => {
|
||||
it("S179: invalid limit in localStorage falls back to default", async () => {
|
||||
localStorage.setItem(LIMIT_KEY, "not-a-number");
|
||||
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
@@ -265,7 +265,7 @@ describe("useChatHistory", () => {
|
||||
expect(result.current.maxMessages).toBe(200);
|
||||
});
|
||||
|
||||
it("S179: negative limit in localStorage falls back to default", () => {
|
||||
it("S179: negative limit in localStorage falls back to default", async () => {
|
||||
localStorage.setItem(LIMIT_KEY, "-5");
|
||||
|
||||
const { result } = renderHook(() => useChatHistory(PROJECT));
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import * as React from "react";
|
||||
import type { PipelineState, WizardStateData } from "../api/client";
|
||||
import type {
|
||||
PipelineState,
|
||||
StatusEvent,
|
||||
WizardStateData,
|
||||
} from "../api/client";
|
||||
import { api, ChatWebSocket } from "../api/client";
|
||||
import type { LogEntry } from "../components/ServerLogsPanel";
|
||||
import type { Message } from "../types";
|
||||
@@ -68,6 +72,9 @@ export interface UseChatWebSocketResult {
|
||||
} | null>;
|
||||
serverLogs: LogEntry[];
|
||||
storyTokenCosts: Map<string, number>;
|
||||
/** Structured pipeline status events. Each entry preserves the full
|
||||
* StatusEvent so future UI stories can render per-type icons or filters. */
|
||||
statusEvents: Array<{ receivedAt: string; event: StatusEvent }>;
|
||||
}
|
||||
|
||||
export function useChatWebSocket({
|
||||
@@ -96,6 +103,7 @@ export function useChatWebSocket({
|
||||
qa: [],
|
||||
merge: [],
|
||||
done: [],
|
||||
deterministic_merges_in_flight: [],
|
||||
});
|
||||
const [pipelineVersion, setPipelineVersion] = useState(0);
|
||||
const [reconciliationActive, setReconciliationActive] = useState(false);
|
||||
@@ -116,6 +124,9 @@ export function useChatWebSocket({
|
||||
const [storyTokenCosts, setStoryTokenCosts] = useState<Map<string, number>>(
|
||||
new Map(),
|
||||
);
|
||||
const [statusEvents, setStatusEvents] = useState<
|
||||
Array<{ receivedAt: string; event: StatusEvent }>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const ws = new ChatWebSocket();
|
||||
@@ -240,6 +251,14 @@ export function useChatWebSocket({
|
||||
onLogEntry: (timestamp, level, message) => {
|
||||
setServerLogs((prev) => [...prev, { timestamp, level, message }]);
|
||||
},
|
||||
onStatusUpdate: (event) => {
|
||||
// Preserve the structured event and receive timestamp so future stories
|
||||
// can render per-type icons, banners, or filters without format changes.
|
||||
setStatusEvents((prev) => [
|
||||
...prev,
|
||||
{ receivedAt: new Date().toISOString(), event },
|
||||
]);
|
||||
},
|
||||
onConnected: () => {
|
||||
setWsConnected(true);
|
||||
},
|
||||
@@ -276,5 +295,6 @@ export function useChatWebSocket({
|
||||
setSideQuestion,
|
||||
serverLogs,
|
||||
storyTokenCosts,
|
||||
statusEvents,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { beforeEach, vi } from "vitest";
|
||||
|
||||
// Default WebSocket stub: every `new WebSocket(...)` immediately fires
|
||||
// `onerror` + `onclose` on the next microtask. Without this, `rpcCall` from
|
||||
// `./api/rpc` (added by 770's HTTP→read-RPC migration) opens a real jsdom
|
||||
// WebSocket that hangs ~9s before firing its connection-failure error,
|
||||
// making any test that mounts a component calling `listAgents()` time out.
|
||||
// Tests that need real WS responses should override per-test with
|
||||
// `vi.stubGlobal("WebSocket", ...)`.
|
||||
class FailingWebSocket {
|
||||
onopen: ((ev: Event) => void) | null = null;
|
||||
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||
onerror: ((ev: Event) => void) | null = null;
|
||||
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||
readyState = 0;
|
||||
constructor(_url: string) {
|
||||
queueMicrotask(() => {
|
||||
this.readyState = 3;
|
||||
this.onerror?.(new Event("error"));
|
||||
this.onclose?.(new CloseEvent("close"));
|
||||
});
|
||||
}
|
||||
send(_data: string) {}
|
||||
close() {}
|
||||
}
|
||||
vi.stubGlobal("WebSocket", FailingWebSocket);
|
||||
|
||||
// Provide a default fetch mock so components that call API endpoints on mount
|
||||
// don't throw URL-parse errors in the jsdom test environment. Tests that need
|
||||
// specific responses should mock the relevant `api.*` method as usual.
|
||||
@@ -10,7 +35,7 @@ beforeEach(() => {
|
||||
vi.fn((input: string | URL | Request) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
// Endpoints that return arrays need [] not {} to avoid "not iterable" errors.
|
||||
const arrayEndpoints = ["/agents", "/agents/config"];
|
||||
const arrayEndpoints = ["/agents/config"];
|
||||
const body = arrayEndpoints.some((ep) => url.endsWith(ep))
|
||||
? JSON.stringify([])
|
||||
: JSON.stringify({});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/// Regression test: gateway UI must have vertical scrolling when content
|
||||
/// overflows the viewport. Verifies the `overflow: hidden` fix on
|
||||
/// `html / body / #root` — without that fix the page is locked at y=0.
|
||||
test.describe("Gateway UI scrolling", () => {
|
||||
test("page scrolls when content exceeds viewport height", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Use a small viewport to guarantee overflow even with modest content.
|
||||
await page.setViewportSize({ width: 1280, height: 400 });
|
||||
|
||||
// --- mock API endpoints ---
|
||||
|
||||
// Identify this server as a gateway.
|
||||
await page.route("/gateway/mode", async (route) => {
|
||||
await route.fulfill({ json: { mode: "gateway" } });
|
||||
});
|
||||
|
||||
// Return enough agents to push the page past 400 px.
|
||||
const agents = Array.from({ length: 15 }, (_, i) => ({
|
||||
id: `agent-${i}`,
|
||||
label: `Build Agent ${i}`,
|
||||
address: `10.0.0.${i}:5000`,
|
||||
registered_at: Date.now() / 1000 - 60,
|
||||
last_seen: Date.now() / 1000 - 10,
|
||||
}));
|
||||
await page.route("/gateway/agents", async (route) => {
|
||||
await route.fulfill({ json: agents });
|
||||
});
|
||||
|
||||
await page.route("/api/gateway", async (route) => {
|
||||
await route.fulfill({ json: { active: "", projects: [] } });
|
||||
});
|
||||
|
||||
await page.route("/api/gateway/pipeline", async (route) => {
|
||||
await route.fulfill({ json: { active: "", projects: {} } });
|
||||
});
|
||||
|
||||
// Non-gateway APIs called by App.tsx on startup — respond quickly so the
|
||||
// loading gate (`isCheckingProject`) clears and the gateway panel renders.
|
||||
await page.route("/api/project", async (route) => {
|
||||
await route.fulfill({ json: null });
|
||||
});
|
||||
await page.route("/api/projects", async (route) => {
|
||||
await route.fulfill({ json: [] });
|
||||
});
|
||||
await page.route("/oauth/status", async (route) => {
|
||||
await route.fulfill({ json: { authenticated: false } });
|
||||
});
|
||||
await page.route("/api/home", async (route) => {
|
||||
await route.fulfill({ json: "/home/test" });
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
// Wait until the gateway panel is visible.
|
||||
await page.waitForSelector('[data-testid="add-agent-button"]');
|
||||
|
||||
// The scrolling element should be taller than the visible viewport.
|
||||
const isOverflowing = await page.evaluate(() => {
|
||||
const el =
|
||||
document.scrollingElement ?? document.documentElement;
|
||||
return el.scrollHeight > el.clientHeight;
|
||||
});
|
||||
expect(isOverflowing).toBe(true);
|
||||
|
||||
// Scrolling must actually move the viewport.
|
||||
await page.evaluate(() => window.scrollBy(0, 300));
|
||||
const scrollY = await page.evaluate(
|
||||
() => document.scrollingElement?.scrollTop ?? window.scrollY,
|
||||
);
|
||||
expect(scrollY).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fast compile-only check: no frontend build, no clippy, no tests.
|
||||
# Use this for rapid iteration feedback while writing code.
|
||||
set -euo pipefail
|
||||
cargo check --tests --workspace
|
||||
+32
@@ -1,6 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Silence git's "default branch name" hints emitted on every `git init` in
|
||||
# tests that create temp repos. Sets init.defaultBranch=master via env so we
|
||||
# don't have to touch the user's real git config.
|
||||
export GIT_CONFIG_COUNT=1
|
||||
export GIT_CONFIG_KEY_0=init.defaultBranch
|
||||
export GIT_CONFIG_VALUE_0=master
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
@@ -14,6 +21,27 @@ else
|
||||
echo "Skipping frontend build (no frontend directory)"
|
||||
fi
|
||||
|
||||
echo "=== Checking for duplicate module files (X.rs and X/mod.rs coexisting) ==="
|
||||
_dup_found=0
|
||||
while IFS= read -r -d '' _mod_file; do
|
||||
_dir=$(dirname "$_mod_file")
|
||||
_parent=$(dirname "$_dir")
|
||||
_name=$(basename "$_dir")
|
||||
_flat="$_parent/$_name.rs"
|
||||
if [ -f "$_flat" ]; then
|
||||
echo "ERROR [E0761]: duplicate module file — both files exist in the same source tree:"
|
||||
echo " $_flat"
|
||||
echo " $_mod_file"
|
||||
echo " Fix: git rm $_flat in the same commit that introduces $_mod_file"
|
||||
_dup_found=1
|
||||
fi
|
||||
done < <(find "$PROJECT_ROOT/server" "$PROJECT_ROOT/source-map-gen" \
|
||||
-path "*/target" -prune -o -name "mod.rs" -print0 2>/dev/null)
|
||||
if [ "$_dup_found" -eq 1 ]; then
|
||||
echo "FAIL: duplicate module files detected — remove the flat .rs file with git rm before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Checking Rust formatting ==="
|
||||
if cargo fmt --version &>/dev/null; then
|
||||
cargo fmt --manifest-path "$PROJECT_ROOT/Cargo.toml" --all --check
|
||||
@@ -24,8 +52,12 @@ fi
|
||||
echo "=== Running cargo clippy ==="
|
||||
cargo clippy --manifest-path "$PROJECT_ROOT/Cargo.toml" --all-targets --all-features -- -D warnings
|
||||
|
||||
echo "=== Checking doc coverage on changed files ==="
|
||||
cargo run --manifest-path "$PROJECT_ROOT/Cargo.toml" -p source-map-gen --bin source-map-check --quiet -- --worktree "$PROJECT_ROOT" --base master
|
||||
|
||||
echo "=== Running Rust tests ==="
|
||||
cargo test --manifest-path "$PROJECT_ROOT/Cargo.toml" --bin huskies
|
||||
cargo test --manifest-path "$PROJECT_ROOT/Cargo.toml" -p source-map-gen
|
||||
|
||||
echo "=== Running frontend unit tests ==="
|
||||
if [ -d "$PROJECT_ROOT/frontend" ]; then
|
||||
|
||||
+8
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "huskies"
|
||||
version = "0.10.2"
|
||||
version = "0.10.4"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
@@ -24,7 +24,11 @@ rust-embed = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_urlencoded = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
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"] }
|
||||
@@ -41,7 +45,10 @@ 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"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
@@ -55,5 +62,3 @@ check-cfg = ["cfg(feature, values(\"logging-base\"))"]
|
||||
tempfile = { workspace = true }
|
||||
mockito = "1"
|
||||
filetime = { workspace = true }
|
||||
# For the pipeline_state_sketch_statig example only.
|
||||
statig = "0.3"
|
||||
|
||||
@@ -1,797 +0,0 @@
|
||||
//! Agent log persistence — reads and writes JSONL agent event logs to disk.
|
||||
use crate::agents::AgentEvent;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A single line in the agent log file (JSONL format).
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LogEntry {
|
||||
pub timestamp: String,
|
||||
#[serde(flatten)]
|
||||
pub event: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Writes agent events to a persistent log file (JSONL format).
|
||||
///
|
||||
/// Each agent session gets its own log file at:
|
||||
/// `.huskies/logs/{story_id}/{agent_name}-{session_id}.log`
|
||||
pub struct AgentLogWriter {
|
||||
file: File,
|
||||
}
|
||||
|
||||
impl AgentLogWriter {
|
||||
/// Create a new log writer, creating the directory structure as needed.
|
||||
///
|
||||
/// The log file is opened in append mode so that a restart mid-session
|
||||
/// won't overwrite earlier output.
|
||||
pub fn new(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
agent_name: &str,
|
||||
session_id: &str,
|
||||
) -> Result<Self, String> {
|
||||
let dir = log_dir(project_root, story_id);
|
||||
fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create log directory {}: {e}", dir.display()))?;
|
||||
|
||||
let path = dir.join(format!("{agent_name}-{session_id}.log"));
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.map_err(|e| format!("Failed to open log file {}: {e}", path.display()))?;
|
||||
|
||||
Ok(Self { file })
|
||||
}
|
||||
|
||||
/// Write an agent event as a JSONL line with an ISO 8601 timestamp.
|
||||
pub fn write_event(&mut self, event: &AgentEvent) -> Result<(), String> {
|
||||
let event_value =
|
||||
serde_json::to_value(event).map_err(|e| format!("Failed to serialize event: {e}"))?;
|
||||
|
||||
let entry = LogEntry {
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
event: event_value,
|
||||
};
|
||||
|
||||
let mut line =
|
||||
serde_json::to_string(&entry).map_err(|e| format!("Failed to serialize entry: {e}"))?;
|
||||
line.push('\n');
|
||||
|
||||
self.file
|
||||
.write_all(line.as_bytes())
|
||||
.map_err(|e| format!("Failed to write log entry: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the log directory for a story.
|
||||
fn log_dir(project_root: &Path, story_id: &str) -> PathBuf {
|
||||
project_root.join(".huskies").join("logs").join(story_id)
|
||||
}
|
||||
|
||||
/// Return the path to a specific log file.
|
||||
#[allow(dead_code)]
|
||||
pub fn log_file_path(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
agent_name: &str,
|
||||
session_id: &str,
|
||||
) -> PathBuf {
|
||||
log_dir(project_root, story_id).join(format!("{agent_name}-{session_id}.log"))
|
||||
}
|
||||
|
||||
/// Read all log entries from a log file.
|
||||
pub fn read_log(path: &Path) -> Result<Vec<LogEntry>, String> {
|
||||
let file =
|
||||
File::open(path).map_err(|e| format!("Failed to open log file {}: {e}", path.display()))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line.map_err(|e| format!("Failed to read log line: {e}"))?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let entry: LogEntry =
|
||||
serde_json::from_str(trimmed).map_err(|e| format!("Failed to parse log entry: {e}"))?;
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// List all log files for a story, optionally filtered by agent name prefix.
|
||||
///
|
||||
/// Returns files sorted by modification time (oldest first) so that when all
|
||||
/// sessions are concatenated the timeline reads chronologically.
|
||||
pub fn list_story_log_files(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
agent_name_filter: Option<&str>,
|
||||
) -> Vec<PathBuf> {
|
||||
let dir = log_dir(project_root, story_id);
|
||||
if !dir.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let prefix = agent_name_filter.map(|n| format!("{n}-"));
|
||||
let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
if !name.ends_with(".log") {
|
||||
continue;
|
||||
}
|
||||
if let Some(ref pfx) = prefix
|
||||
&& !name.starts_with(pfx.as_str())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let modified = entry
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
||||
files.push((path, modified));
|
||||
}
|
||||
}
|
||||
|
||||
files.sort_by_key(|(_, t)| *t);
|
||||
files.into_iter().map(|(p, _)| p).collect()
|
||||
}
|
||||
|
||||
/// Format a single log entry as a human-readable text line.
|
||||
///
|
||||
/// `timestamp` is an ISO 8601 string; `event` is the flattened `AgentEvent`
|
||||
/// value (has `type`, `agent_name`, etc. at the top level).
|
||||
///
|
||||
/// Returns `None` for entries that should be skipped (raw streaming noise,
|
||||
/// trivial status changes, empty output, etc.).
|
||||
pub fn format_log_entry_as_text(timestamp: &str, event: &serde_json::Value) -> Option<String> {
|
||||
let agent_name = event
|
||||
.get("agent_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("?");
|
||||
// Extract HH:MM:SS from ISO 8601 "2026-04-10T12:48:02.123456789+00:00"
|
||||
let ts_short = if timestamp.len() >= 19 {
|
||||
×tamp[11..19]
|
||||
} else {
|
||||
timestamp
|
||||
};
|
||||
let pfx = format!("[{ts_short}][{agent_name}]");
|
||||
|
||||
match event.get("type").and_then(|v| v.as_str()) {
|
||||
Some("output") => {
|
||||
let text = event
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("{pfx} {text}"))
|
||||
}
|
||||
}
|
||||
Some("error") => {
|
||||
let msg = event
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("(unknown error)");
|
||||
Some(format!("{pfx} ERROR: {msg}"))
|
||||
}
|
||||
Some("done") => Some(format!("{pfx} DONE")),
|
||||
Some("status") => {
|
||||
// Skip trivial running/started noise
|
||||
let status = event.get("status").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
match status {
|
||||
"running" | "started" => None,
|
||||
_ => Some(format!("{pfx} STATUS: {status}")),
|
||||
}
|
||||
}
|
||||
Some("agent_json") => {
|
||||
let data = event.get("data")?;
|
||||
match data.get("type").and_then(|v| v.as_str()) {
|
||||
Some("assistant") => {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if let Some(arr) = data.pointer("/message/content").and_then(|v| v.as_array()) {
|
||||
for item in arr {
|
||||
match item.get("type").and_then(|v| v.as_str()) {
|
||||
Some("text") => {
|
||||
let text = item
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if !text.is_empty() {
|
||||
parts.push(format!("{pfx} {text}"));
|
||||
}
|
||||
}
|
||||
Some("tool_use") => {
|
||||
let name =
|
||||
item.get("name").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let input = item
|
||||
.get("input")
|
||||
.map(|v| serde_json::to_string(v).unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
let display = if input.len() > 200 {
|
||||
format!("{}...", &input[..200])
|
||||
} else {
|
||||
input
|
||||
};
|
||||
parts.push(format!("{pfx} TOOL: {name}({display})"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("\n"))
|
||||
}
|
||||
}
|
||||
Some("user") => {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if let Some(arr) = data.pointer("/message/content").and_then(|v| v.as_array()) {
|
||||
for item in arr {
|
||||
if item.get("type").and_then(|v| v.as_str()) != Some("tool_result") {
|
||||
continue;
|
||||
}
|
||||
let content_str = match item.get("content") {
|
||||
Some(serde_json::Value::String(s)) => s.clone(),
|
||||
Some(v) => v.to_string(),
|
||||
None => String::new(),
|
||||
};
|
||||
let display = if content_str.len() > 500 {
|
||||
format!(
|
||||
"{}... [{} chars total]",
|
||||
&content_str[..500],
|
||||
content_str.len()
|
||||
)
|
||||
} else {
|
||||
content_str
|
||||
};
|
||||
if !display.trim().is_empty() {
|
||||
parts.push(format!("{pfx} RESULT: {display}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("\n"))
|
||||
}
|
||||
}
|
||||
_ => None, // Skip stream_event, system init, etc.
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read log entries from a file and convert them to human-readable text lines,
|
||||
/// stripping raw streaming noise and JSON internals.
|
||||
pub fn read_log_as_readable_lines(path: &Path) -> Result<Vec<String>, String> {
|
||||
let entries = read_log(path)?;
|
||||
let mut result = Vec::new();
|
||||
for entry in &entries {
|
||||
if let Some(line) = format_log_entry_as_text(&entry.timestamp, &entry.event) {
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Find the most recent log file for a given story+agent combination.
|
||||
///
|
||||
/// Scans `.huskies/logs/{story_id}/` for files matching `{agent_name}-*.log`
|
||||
/// and returns the one with the most recent modification time.
|
||||
pub fn find_latest_log(project_root: &Path, story_id: &str, agent_name: &str) -> Option<PathBuf> {
|
||||
let dir = log_dir(project_root, story_id);
|
||||
if !dir.is_dir() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prefix = format!("{agent_name}-");
|
||||
let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
|
||||
|
||||
let entries = fs::read_dir(&dir).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
if !name.starts_with(&prefix) || !name.ends_with(".log") {
|
||||
continue;
|
||||
}
|
||||
let modified = match entry.metadata().and_then(|m| m.modified()) {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if best.as_ref().is_none_or(|(_, t)| modified > *t) {
|
||||
best = Some((path, modified));
|
||||
}
|
||||
}
|
||||
|
||||
best.map(|(p, _)| p)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::AgentEvent;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_log_writer_creates_directory_and_file() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let _writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-abc123").unwrap();
|
||||
|
||||
let expected_path = root
|
||||
.join(".huskies")
|
||||
.join("logs")
|
||||
.join("42_story_foo")
|
||||
.join("coder-1-sess-abc123.log");
|
||||
assert!(expected_path.exists(), "Log file should exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_writer_writes_jsonl_with_timestamps() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let mut writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-001").unwrap();
|
||||
|
||||
let event = AgentEvent::Status {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
status: "running".to_string(),
|
||||
};
|
||||
writer.write_event(&event).unwrap();
|
||||
|
||||
let event2 = AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "Hello world".to_string(),
|
||||
};
|
||||
writer.write_event(&event2).unwrap();
|
||||
|
||||
// Read the file and verify
|
||||
let path = log_file_path(root, "42_story_foo", "coder-1", "sess-001");
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "Should have 2 log lines");
|
||||
|
||||
// Parse each line as valid JSON with a timestamp
|
||||
for line in &lines {
|
||||
let entry: LogEntry = serde_json::from_str(line).unwrap();
|
||||
assert!(!entry.timestamp.is_empty(), "Timestamp should be present");
|
||||
// Verify it's a valid ISO 8601 timestamp
|
||||
chrono::DateTime::parse_from_rfc3339(&entry.timestamp)
|
||||
.expect("Timestamp should be valid RFC3339");
|
||||
}
|
||||
|
||||
// Verify the first entry is a status event
|
||||
let entry1: LogEntry = serde_json::from_str(lines[0]).unwrap();
|
||||
assert_eq!(entry1.event["type"], "status");
|
||||
assert_eq!(entry1.event["status"], "running");
|
||||
|
||||
// Verify the second entry is an output event
|
||||
let entry2: LogEntry = serde_json::from_str(lines[1]).unwrap();
|
||||
assert_eq!(entry2.event["type"], "output");
|
||||
assert_eq!(entry2.event["text"], "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_log_parses_written_events() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let mut writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-002").unwrap();
|
||||
|
||||
let events = vec![
|
||||
AgentEvent::Status {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
status: "running".to_string(),
|
||||
},
|
||||
AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "Processing...".to_string(),
|
||||
},
|
||||
AgentEvent::AgentJson {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
data: serde_json::json!({"type": "tool_use", "name": "read_file"}),
|
||||
},
|
||||
AgentEvent::Done {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
session_id: Some("sess-002".to_string()),
|
||||
},
|
||||
];
|
||||
|
||||
for event in &events {
|
||||
writer.write_event(event).unwrap();
|
||||
}
|
||||
|
||||
let path = log_file_path(root, "42_story_foo", "coder-1", "sess-002");
|
||||
let entries = read_log(&path).unwrap();
|
||||
assert_eq!(entries.len(), 4, "Should read back all 4 events");
|
||||
|
||||
// Verify event types round-trip correctly
|
||||
assert_eq!(entries[0].event["type"], "status");
|
||||
assert_eq!(entries[1].event["type"], "output");
|
||||
assert_eq!(entries[2].event["type"], "agent_json");
|
||||
assert_eq!(entries[3].event["type"], "done");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_separate_sessions_produce_separate_files() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let mut writer1 = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-aaa").unwrap();
|
||||
let mut writer2 = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-bbb").unwrap();
|
||||
|
||||
writer1
|
||||
.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "from session aaa".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
writer2
|
||||
.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "from session bbb".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let path1 = log_file_path(root, "42_story_foo", "coder-1", "sess-aaa");
|
||||
let path2 = log_file_path(root, "42_story_foo", "coder-1", "sess-bbb");
|
||||
|
||||
assert_ne!(
|
||||
path1, path2,
|
||||
"Different sessions should use different files"
|
||||
);
|
||||
|
||||
let entries1 = read_log(&path1).unwrap();
|
||||
let entries2 = read_log(&path2).unwrap();
|
||||
|
||||
assert_eq!(entries1.len(), 1);
|
||||
assert_eq!(entries2.len(), 1);
|
||||
assert_eq!(entries1[0].event["text"], "from session aaa");
|
||||
assert_eq!(entries2[0].event["text"], "from session bbb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_latest_log_returns_most_recent() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
// Create two log files with a small delay
|
||||
let mut writer1 = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-old").unwrap();
|
||||
writer1
|
||||
.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "old".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
drop(writer1);
|
||||
|
||||
// Touch the second file to ensure it's newer
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
let mut writer2 = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-new").unwrap();
|
||||
writer2
|
||||
.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "new".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
drop(writer2);
|
||||
|
||||
let latest = find_latest_log(root, "42_story_foo", "coder-1").unwrap();
|
||||
assert!(
|
||||
latest.to_string_lossy().contains("sess-new"),
|
||||
"Should find the newest log file, got: {}",
|
||||
latest.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_story_log_files_returns_empty_for_missing_dir() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let files = list_story_log_files(tmp.path(), "nonexistent", None);
|
||||
assert!(files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_story_log_files_no_filter_returns_all_logs() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let mut w1 = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-aaa").unwrap();
|
||||
w1.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "from coder-1".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
drop(w1);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
|
||||
let mut w2 = AgentLogWriter::new(root, "42_story_foo", "mergemaster", "sess-bbb").unwrap();
|
||||
w2.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "mergemaster".to_string(),
|
||||
text: "from mergemaster".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
drop(w2);
|
||||
|
||||
let files = list_story_log_files(root, "42_story_foo", None);
|
||||
assert_eq!(files.len(), 2, "Should find both log files");
|
||||
// Oldest first
|
||||
assert!(
|
||||
files[0].to_string_lossy().contains("coder-1"),
|
||||
"coder-1 should be first (older)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_story_log_files_with_agent_filter() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let mut w1 = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-a").unwrap();
|
||||
w1.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "from coder-1".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
drop(w1);
|
||||
|
||||
let mut w2 = AgentLogWriter::new(root, "42_story_foo", "mergemaster", "sess-b").unwrap();
|
||||
w2.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "mergemaster".to_string(),
|
||||
text: "from mergemaster".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
drop(w2);
|
||||
|
||||
let files = list_story_log_files(root, "42_story_foo", Some("coder-1"));
|
||||
assert_eq!(files.len(), 1, "Should find only coder-1 log");
|
||||
assert!(files[0].to_string_lossy().contains("coder-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_log_entry_output_event() {
|
||||
let ts = "2026-04-10T12:48:02.123456789+00:00";
|
||||
let event = serde_json::json!({
|
||||
"type": "output",
|
||||
"story_id": "42_story",
|
||||
"agent_name": "coder-1",
|
||||
"text": "Hello world"
|
||||
});
|
||||
let result = format_log_entry_as_text(ts, &event).unwrap();
|
||||
assert!(result.contains("12:48:02"), "should include timestamp");
|
||||
assert!(result.contains("coder-1"), "should include agent name");
|
||||
assert!(result.contains("Hello world"), "should include text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_log_entry_skips_empty_output() {
|
||||
let ts = "2026-04-10T12:48:02.123456789+00:00";
|
||||
let event = serde_json::json!({
|
||||
"type": "output",
|
||||
"story_id": "42_story",
|
||||
"agent_name": "coder-1",
|
||||
"text": " "
|
||||
});
|
||||
assert!(format_log_entry_as_text(ts, &event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_log_entry_error_event() {
|
||||
let ts = "2026-04-10T12:48:02.123+00:00";
|
||||
let event = serde_json::json!({
|
||||
"type": "error",
|
||||
"story_id": "42_story",
|
||||
"agent_name": "coder-1",
|
||||
"message": "Something went wrong"
|
||||
});
|
||||
let result = format_log_entry_as_text(ts, &event).unwrap();
|
||||
assert!(result.contains("ERROR"));
|
||||
assert!(result.contains("Something went wrong"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_log_entry_done_event() {
|
||||
let ts = "2026-04-10T12:48:02.123+00:00";
|
||||
let event = serde_json::json!({
|
||||
"type": "done",
|
||||
"story_id": "42_story",
|
||||
"agent_name": "coder-1",
|
||||
"session_id": null
|
||||
});
|
||||
let result = format_log_entry_as_text(ts, &event).unwrap();
|
||||
assert!(result.contains("DONE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_log_entry_skips_running_status() {
|
||||
let ts = "2026-04-10T12:48:02.123+00:00";
|
||||
let event = serde_json::json!({
|
||||
"type": "status",
|
||||
"story_id": "42_story",
|
||||
"agent_name": "coder-1",
|
||||
"status": "running"
|
||||
});
|
||||
assert!(format_log_entry_as_text(ts, &event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_log_entry_agent_json_tool_use() {
|
||||
let ts = "2026-04-10T12:48:03.000+00:00";
|
||||
let event = serde_json::json!({
|
||||
"type": "agent_json",
|
||||
"story_id": "42_story",
|
||||
"agent_name": "coder-1",
|
||||
"data": {
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "tool-1",
|
||||
"name": "Read",
|
||||
"input": {"file_path": "/some/file.rs"}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
let result = format_log_entry_as_text(ts, &event).unwrap();
|
||||
assert!(
|
||||
result.contains("TOOL: Read"),
|
||||
"should show tool call: {result}"
|
||||
);
|
||||
assert!(result.contains("file_path"), "should show input: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_log_entry_agent_json_text() {
|
||||
let ts = "2026-04-10T12:48:04.000+00:00";
|
||||
let event = serde_json::json!({
|
||||
"type": "agent_json",
|
||||
"story_id": "42_story",
|
||||
"agent_name": "coder-1",
|
||||
"data": {
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Now I will read the file."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
let result = format_log_entry_as_text(ts, &event).unwrap();
|
||||
assert!(
|
||||
result.contains("Now I will read the file."),
|
||||
"should show text: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_log_entry_skips_stream_events() {
|
||||
let ts = "2026-04-10T12:48:04.000+00:00";
|
||||
let event = serde_json::json!({
|
||||
"type": "agent_json",
|
||||
"story_id": "42_story",
|
||||
"agent_name": "coder-1",
|
||||
"data": {
|
||||
"type": "stream_event",
|
||||
"event": {"type": "content_block_delta", "delta": {"type": "text_delta", "text": "chunk"}}
|
||||
}
|
||||
});
|
||||
assert!(
|
||||
format_log_entry_as_text(ts, &event).is_none(),
|
||||
"stream events should be skipped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_log_as_readable_lines_produces_formatted_output() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let mut writer =
|
||||
AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-readable").unwrap();
|
||||
writer
|
||||
.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "Let me read the file".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
writer
|
||||
.write_event(&AgentEvent::Done {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
session_id: Some("sess-readable".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let path = log_file_path(root, "42_story_foo", "coder-1", "sess-readable");
|
||||
let lines = read_log_as_readable_lines(&path).unwrap();
|
||||
assert_eq!(lines.len(), 2, "Should produce 2 readable lines");
|
||||
assert!(
|
||||
lines[0].contains("Let me read the file"),
|
||||
"first line: {}",
|
||||
lines[0]
|
||||
);
|
||||
assert!(lines[1].contains("DONE"), "second line: {}", lines[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_latest_log_returns_none_for_missing_dir() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let result = find_latest_log(tmp.path(), "nonexistent", "coder-1");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_files_persist_on_disk() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let path = {
|
||||
let mut writer =
|
||||
AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-persist").unwrap();
|
||||
writer
|
||||
.write_event(&AgentEvent::Status {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
status: "running".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
log_file_path(root, "42_story_foo", "coder-1", "sess-persist")
|
||||
// writer is dropped here
|
||||
};
|
||||
|
||||
// File should still exist and be readable
|
||||
assert!(
|
||||
path.exists(),
|
||||
"Log file should persist after writer is dropped"
|
||||
);
|
||||
let entries = read_log(&path).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].event["type"], "status");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Human-readable formatting of raw agent log entries.
|
||||
|
||||
/// Format a single log entry as a human-readable text line.
|
||||
///
|
||||
/// `timestamp` is an ISO 8601 string; `event` is the flattened `AgentEvent`
|
||||
/// value (has `type`, `agent_name`, etc. at the top level).
|
||||
///
|
||||
/// Returns `None` for entries that should be skipped (raw streaming noise,
|
||||
/// trivial status changes, empty output, etc.).
|
||||
pub fn format_log_entry_as_text(timestamp: &str, event: &serde_json::Value) -> Option<String> {
|
||||
let agent_name = event
|
||||
.get("agent_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("?");
|
||||
// Extract HH:MM:SS from ISO 8601 "2026-04-10T12:48:02.123456789+00:00"
|
||||
let ts_short = if timestamp.len() >= 19 {
|
||||
×tamp[11..19]
|
||||
} else {
|
||||
timestamp
|
||||
};
|
||||
let pfx = format!("[{ts_short}][{agent_name}]");
|
||||
|
||||
match event.get("type").and_then(|v| v.as_str()) {
|
||||
Some("output") => {
|
||||
let text = event
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("{pfx} {text}"))
|
||||
}
|
||||
}
|
||||
Some("error") => {
|
||||
let msg = event
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("(unknown error)");
|
||||
Some(format!("{pfx} ERROR: {msg}"))
|
||||
}
|
||||
Some("done") => Some(format!("{pfx} DONE")),
|
||||
Some("status") => {
|
||||
// Skip trivial running/started noise
|
||||
let status = event.get("status").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
match status {
|
||||
"running" | "started" => None,
|
||||
_ => Some(format!("{pfx} STATUS: {status}")),
|
||||
}
|
||||
}
|
||||
Some("agent_json") => {
|
||||
let data = event.get("data")?;
|
||||
match data.get("type").and_then(|v| v.as_str()) {
|
||||
Some("assistant") => {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if let Some(arr) = data.pointer("/message/content").and_then(|v| v.as_array()) {
|
||||
for item in arr {
|
||||
match item.get("type").and_then(|v| v.as_str()) {
|
||||
Some("text") => {
|
||||
let text = item
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if !text.is_empty() {
|
||||
parts.push(format!("{pfx} {text}"));
|
||||
}
|
||||
}
|
||||
Some("tool_use") => {
|
||||
let name =
|
||||
item.get("name").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let input = item
|
||||
.get("input")
|
||||
.map(|v| serde_json::to_string(v).unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
let display = if input.len() > 200 {
|
||||
format!("{}...", &input[..200])
|
||||
} else {
|
||||
input
|
||||
};
|
||||
parts.push(format!("{pfx} TOOL: {name}({display})"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("\n"))
|
||||
}
|
||||
}
|
||||
Some("user") => {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if let Some(arr) = data.pointer("/message/content").and_then(|v| v.as_array()) {
|
||||
for item in arr {
|
||||
if item.get("type").and_then(|v| v.as_str()) != Some("tool_result") {
|
||||
continue;
|
||||
}
|
||||
let content_str = match item.get("content") {
|
||||
Some(serde_json::Value::String(s)) => s.clone(),
|
||||
Some(v) => v.to_string(),
|
||||
None => String::new(),
|
||||
};
|
||||
let display = if content_str.len() > 500 {
|
||||
format!(
|
||||
"{}... [{} chars total]",
|
||||
&content_str[..500],
|
||||
content_str.len()
|
||||
)
|
||||
} else {
|
||||
content_str
|
||||
};
|
||||
if !display.trim().is_empty() {
|
||||
parts.push(format!("{pfx} RESULT: {display}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("\n"))
|
||||
}
|
||||
}
|
||||
_ => None, // Skip stream_event, system init, etc.
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! Agent log persistence — reads and writes JSONL agent event logs to disk.
|
||||
mod format;
|
||||
mod reader;
|
||||
mod writer;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use format::format_log_entry_as_text;
|
||||
pub use reader::{find_latest_log, list_story_log_files, read_log, read_log_as_readable_lines};
|
||||
pub use writer::{AgentLogWriter, LogEntry, log_file_path};
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Agent log reader — reads, scans, and formats JSONL agent log files.
|
||||
use super::format::format_log_entry_as_text;
|
||||
use super::writer::{LogEntry, log_dir};
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Read all log entries from a log file.
|
||||
pub fn read_log(path: &Path) -> Result<Vec<LogEntry>, String> {
|
||||
let file = fs::File::open(path)
|
||||
.map_err(|e| format!("Failed to open log file {}: {e}", path.display()))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line.map_err(|e| format!("Failed to read log line: {e}"))?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let entry: LogEntry =
|
||||
serde_json::from_str(trimmed).map_err(|e| format!("Failed to parse log entry: {e}"))?;
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// List all log files for a story, optionally filtered by agent name prefix.
|
||||
///
|
||||
/// Returns files sorted by modification time (oldest first) so that when all
|
||||
/// sessions are concatenated the timeline reads chronologically.
|
||||
pub fn list_story_log_files(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
agent_name_filter: Option<&str>,
|
||||
) -> Vec<PathBuf> {
|
||||
let dir = log_dir(project_root, story_id);
|
||||
if !dir.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let prefix = agent_name_filter.map(|n| format!("{n}-"));
|
||||
let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
if !name.ends_with(".log") {
|
||||
continue;
|
||||
}
|
||||
if let Some(ref pfx) = prefix
|
||||
&& !name.starts_with(pfx.as_str())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let modified = entry
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
||||
files.push((path, modified));
|
||||
}
|
||||
}
|
||||
|
||||
files.sort_by_key(|(_, t)| *t);
|
||||
files.into_iter().map(|(p, _)| p).collect()
|
||||
}
|
||||
|
||||
/// Read log entries from a file and convert them to human-readable text lines,
|
||||
/// stripping raw streaming noise and JSON internals.
|
||||
pub fn read_log_as_readable_lines(path: &Path) -> Result<Vec<String>, String> {
|
||||
let entries = read_log(path)?;
|
||||
let mut result = Vec::new();
|
||||
for entry in &entries {
|
||||
if let Some(line) = format_log_entry_as_text(&entry.timestamp, &entry.event) {
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Find the most recent log file for a given story+agent combination.
|
||||
///
|
||||
/// Scans `.huskies/logs/{story_id}/` for files matching `{agent_name}-*.log`
|
||||
/// and returns the one with the most recent modification time.
|
||||
pub fn find_latest_log(project_root: &Path, story_id: &str, agent_name: &str) -> Option<PathBuf> {
|
||||
let dir = log_dir(project_root, story_id);
|
||||
if !dir.is_dir() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prefix = format!("{agent_name}-");
|
||||
let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
|
||||
|
||||
let entries = fs::read_dir(&dir).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
if !name.starts_with(&prefix) || !name.ends_with(".log") {
|
||||
continue;
|
||||
}
|
||||
let modified = match entry.metadata().and_then(|m| m.modified()) {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if best.as_ref().is_none_or(|(_, t)| modified > *t) {
|
||||
best = Some((path, modified));
|
||||
}
|
||||
}
|
||||
|
||||
best.map(|(p, _)| p)
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
//! Tests for the agent_log module.
|
||||
use super::*;
|
||||
use crate::agents::AgentEvent;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_log_writer_creates_directory_and_file() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let _writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-abc123").unwrap();
|
||||
|
||||
let expected_path = root
|
||||
.join(".huskies")
|
||||
.join("logs")
|
||||
.join("42_story_foo")
|
||||
.join("coder-1-sess-abc123.log");
|
||||
assert!(expected_path.exists(), "Log file should exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_id_log_dir_uses_number_only() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let _writer = AgentLogWriter::new(root, "664", "coder-1", "sess-xyz").unwrap();
|
||||
|
||||
let expected_path = root
|
||||
.join(".huskies")
|
||||
.join("logs")
|
||||
.join("664")
|
||||
.join("coder-1-sess-xyz.log");
|
||||
assert!(
|
||||
expected_path.exists(),
|
||||
"Log file for numeric ID should be at .huskies/logs/664/..."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_writer_writes_jsonl_with_timestamps() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let mut writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-001").unwrap();
|
||||
|
||||
let event = AgentEvent::Status {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
status: "running".to_string(),
|
||||
};
|
||||
writer.write_event(&event).unwrap();
|
||||
|
||||
let event2 = AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "Hello world".to_string(),
|
||||
};
|
||||
writer.write_event(&event2).unwrap();
|
||||
|
||||
// Read the file and verify
|
||||
let path = log_file_path(root, "42_story_foo", "coder-1", "sess-001");
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "Should have 2 log lines");
|
||||
|
||||
// Parse each line as valid JSON with a timestamp
|
||||
for line in &lines {
|
||||
let entry: LogEntry = serde_json::from_str(line).unwrap();
|
||||
assert!(!entry.timestamp.is_empty(), "Timestamp should be present");
|
||||
// Verify it's a valid ISO 8601 timestamp
|
||||
chrono::DateTime::parse_from_rfc3339(&entry.timestamp)
|
||||
.expect("Timestamp should be valid RFC3339");
|
||||
}
|
||||
|
||||
// Verify the first entry is a status event
|
||||
let entry1: LogEntry = serde_json::from_str(lines[0]).unwrap();
|
||||
assert_eq!(entry1.event["type"], "status");
|
||||
assert_eq!(entry1.event["status"], "running");
|
||||
|
||||
// Verify the second entry is an output event
|
||||
let entry2: LogEntry = serde_json::from_str(lines[1]).unwrap();
|
||||
assert_eq!(entry2.event["type"], "output");
|
||||
assert_eq!(entry2.event["text"], "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_log_parses_written_events() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let mut writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-002").unwrap();
|
||||
|
||||
let events = vec![
|
||||
AgentEvent::Status {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
status: "running".to_string(),
|
||||
},
|
||||
AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "Processing...".to_string(),
|
||||
},
|
||||
AgentEvent::AgentJson {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
data: serde_json::json!({"type": "tool_use", "name": "read_file"}),
|
||||
},
|
||||
AgentEvent::Done {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
session_id: Some("sess-002".to_string()),
|
||||
},
|
||||
];
|
||||
|
||||
for event in &events {
|
||||
writer.write_event(event).unwrap();
|
||||
}
|
||||
|
||||
let path = log_file_path(root, "42_story_foo", "coder-1", "sess-002");
|
||||
let entries = read_log(&path).unwrap();
|
||||
assert_eq!(entries.len(), 4, "Should read back all 4 events");
|
||||
|
||||
// Verify event types round-trip correctly
|
||||
assert_eq!(entries[0].event["type"], "status");
|
||||
assert_eq!(entries[1].event["type"], "output");
|
||||
assert_eq!(entries[2].event["type"], "agent_json");
|
||||
assert_eq!(entries[3].event["type"], "done");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_separate_sessions_produce_separate_files() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let mut writer1 = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-aaa").unwrap();
|
||||
let mut writer2 = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-bbb").unwrap();
|
||||
|
||||
writer1
|
||||
.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "from session aaa".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
writer2
|
||||
.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "from session bbb".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let path1 = log_file_path(root, "42_story_foo", "coder-1", "sess-aaa");
|
||||
let path2 = log_file_path(root, "42_story_foo", "coder-1", "sess-bbb");
|
||||
|
||||
assert_ne!(
|
||||
path1, path2,
|
||||
"Different sessions should use different files"
|
||||
);
|
||||
|
||||
let entries1 = read_log(&path1).unwrap();
|
||||
let entries2 = read_log(&path2).unwrap();
|
||||
|
||||
assert_eq!(entries1.len(), 1);
|
||||
assert_eq!(entries2.len(), 1);
|
||||
assert_eq!(entries1[0].event["text"], "from session aaa");
|
||||
assert_eq!(entries2[0].event["text"], "from session bbb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_latest_log_returns_most_recent() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
// Create two log files with a small delay
|
||||
let mut writer1 = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-old").unwrap();
|
||||
writer1
|
||||
.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "old".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
drop(writer1);
|
||||
|
||||
// Touch the second file to ensure it's newer
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
let mut writer2 = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-new").unwrap();
|
||||
writer2
|
||||
.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "new".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
drop(writer2);
|
||||
|
||||
let latest = find_latest_log(root, "42_story_foo", "coder-1").unwrap();
|
||||
assert!(
|
||||
latest.to_string_lossy().contains("sess-new"),
|
||||
"Should find the newest log file, got: {}",
|
||||
latest.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_story_log_files_returns_empty_for_missing_dir() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let files = list_story_log_files(tmp.path(), "nonexistent", None);
|
||||
assert!(files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_story_log_files_no_filter_returns_all_logs() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let mut w1 = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-aaa").unwrap();
|
||||
w1.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "from coder-1".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
drop(w1);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
|
||||
let mut w2 = AgentLogWriter::new(root, "42_story_foo", "mergemaster", "sess-bbb").unwrap();
|
||||
w2.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "mergemaster".to_string(),
|
||||
text: "from mergemaster".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
drop(w2);
|
||||
|
||||
let files = list_story_log_files(root, "42_story_foo", None);
|
||||
assert_eq!(files.len(), 2, "Should find both log files");
|
||||
// Oldest first
|
||||
assert!(
|
||||
files[0].to_string_lossy().contains("coder-1"),
|
||||
"coder-1 should be first (older)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_story_log_files_with_agent_filter() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let mut w1 = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-a").unwrap();
|
||||
w1.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "from coder-1".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
drop(w1);
|
||||
|
||||
let mut w2 = AgentLogWriter::new(root, "42_story_foo", "mergemaster", "sess-b").unwrap();
|
||||
w2.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "mergemaster".to_string(),
|
||||
text: "from mergemaster".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
drop(w2);
|
||||
|
||||
let files = list_story_log_files(root, "42_story_foo", Some("coder-1"));
|
||||
assert_eq!(files.len(), 1, "Should find only coder-1 log");
|
||||
assert!(files[0].to_string_lossy().contains("coder-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_log_entry_output_event() {
|
||||
let ts = "2026-04-10T12:48:02.123456789+00:00";
|
||||
let event = serde_json::json!({
|
||||
"type": "output",
|
||||
"story_id": "42_story",
|
||||
"agent_name": "coder-1",
|
||||
"text": "Hello world"
|
||||
});
|
||||
let result = format_log_entry_as_text(ts, &event).unwrap();
|
||||
assert!(result.contains("12:48:02"), "should include timestamp");
|
||||
assert!(result.contains("coder-1"), "should include agent name");
|
||||
assert!(result.contains("Hello world"), "should include text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_log_entry_skips_empty_output() {
|
||||
let ts = "2026-04-10T12:48:02.123456789+00:00";
|
||||
let event = serde_json::json!({
|
||||
"type": "output",
|
||||
"story_id": "42_story",
|
||||
"agent_name": "coder-1",
|
||||
"text": " "
|
||||
});
|
||||
assert!(format_log_entry_as_text(ts, &event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_log_entry_error_event() {
|
||||
let ts = "2026-04-10T12:48:02.123+00:00";
|
||||
let event = serde_json::json!({
|
||||
"type": "error",
|
||||
"story_id": "42_story",
|
||||
"agent_name": "coder-1",
|
||||
"message": "Something went wrong"
|
||||
});
|
||||
let result = format_log_entry_as_text(ts, &event).unwrap();
|
||||
assert!(result.contains("ERROR"));
|
||||
assert!(result.contains("Something went wrong"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_log_entry_done_event() {
|
||||
let ts = "2026-04-10T12:48:02.123+00:00";
|
||||
let event = serde_json::json!({
|
||||
"type": "done",
|
||||
"story_id": "42_story",
|
||||
"agent_name": "coder-1",
|
||||
"session_id": null
|
||||
});
|
||||
let result = format_log_entry_as_text(ts, &event).unwrap();
|
||||
assert!(result.contains("DONE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_log_entry_skips_running_status() {
|
||||
let ts = "2026-04-10T12:48:02.123+00:00";
|
||||
let event = serde_json::json!({
|
||||
"type": "status",
|
||||
"story_id": "42_story",
|
||||
"agent_name": "coder-1",
|
||||
"status": "running"
|
||||
});
|
||||
assert!(format_log_entry_as_text(ts, &event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_log_entry_agent_json_tool_use() {
|
||||
let ts = "2026-04-10T12:48:03.000+00:00";
|
||||
let event = serde_json::json!({
|
||||
"type": "agent_json",
|
||||
"story_id": "42_story",
|
||||
"agent_name": "coder-1",
|
||||
"data": {
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "tool-1",
|
||||
"name": "Read",
|
||||
"input": {"file_path": "/some/file.rs"}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
let result = format_log_entry_as_text(ts, &event).unwrap();
|
||||
assert!(
|
||||
result.contains("TOOL: Read"),
|
||||
"should show tool call: {result}"
|
||||
);
|
||||
assert!(result.contains("file_path"), "should show input: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_log_entry_agent_json_text() {
|
||||
let ts = "2026-04-10T12:48:04.000+00:00";
|
||||
let event = serde_json::json!({
|
||||
"type": "agent_json",
|
||||
"story_id": "42_story",
|
||||
"agent_name": "coder-1",
|
||||
"data": {
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Now I will read the file."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
let result = format_log_entry_as_text(ts, &event).unwrap();
|
||||
assert!(
|
||||
result.contains("Now I will read the file."),
|
||||
"should show text: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_log_entry_skips_stream_events() {
|
||||
let ts = "2026-04-10T12:48:04.000+00:00";
|
||||
let event = serde_json::json!({
|
||||
"type": "agent_json",
|
||||
"story_id": "42_story",
|
||||
"agent_name": "coder-1",
|
||||
"data": {
|
||||
"type": "stream_event",
|
||||
"event": {"type": "content_block_delta", "delta": {"type": "text_delta", "text": "chunk"}}
|
||||
}
|
||||
});
|
||||
assert!(
|
||||
format_log_entry_as_text(ts, &event).is_none(),
|
||||
"stream events should be skipped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_log_as_readable_lines_produces_formatted_output() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let mut writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-readable").unwrap();
|
||||
writer
|
||||
.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "Let me read the file".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
writer
|
||||
.write_event(&AgentEvent::Done {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
session_id: Some("sess-readable".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let path = log_file_path(root, "42_story_foo", "coder-1", "sess-readable");
|
||||
let lines = read_log_as_readable_lines(&path).unwrap();
|
||||
assert_eq!(lines.len(), 2, "Should produce 2 readable lines");
|
||||
assert!(
|
||||
lines[0].contains("Let me read the file"),
|
||||
"first line: {}",
|
||||
lines[0]
|
||||
);
|
||||
assert!(lines[1].contains("DONE"), "second line: {}", lines[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_latest_log_returns_none_for_missing_dir() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let result = find_latest_log(tmp.path(), "nonexistent", "coder-1");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_files_persist_on_disk() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let path = {
|
||||
let mut writer =
|
||||
AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-persist").unwrap();
|
||||
writer
|
||||
.write_event(&AgentEvent::Status {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
status: "running".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
log_file_path(root, "42_story_foo", "coder-1", "sess-persist")
|
||||
// writer is dropped here
|
||||
};
|
||||
|
||||
// File should still exist and be readable
|
||||
assert!(
|
||||
path.exists(),
|
||||
"Log file should persist after writer is dropped"
|
||||
);
|
||||
let entries = read_log(&path).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].event["type"], "status");
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Agent log writer — creates JSONL log files and appends agent events.
|
||||
use crate::agents::AgentEvent;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A single line in the agent log file (JSONL format).
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LogEntry {
|
||||
pub timestamp: String,
|
||||
#[serde(flatten)]
|
||||
pub event: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Writes agent events to a persistent log file (JSONL format).
|
||||
///
|
||||
/// Each agent session gets its own log file at:
|
||||
/// `.huskies/logs/{story_id}/{agent_name}-{session_id}.log`
|
||||
pub struct AgentLogWriter {
|
||||
file: File,
|
||||
}
|
||||
|
||||
impl AgentLogWriter {
|
||||
/// Create a new log writer, creating the directory structure as needed.
|
||||
///
|
||||
/// The log file is opened in append mode so that a restart mid-session
|
||||
/// won't overwrite earlier output.
|
||||
pub fn new(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
agent_name: &str,
|
||||
session_id: &str,
|
||||
) -> Result<Self, String> {
|
||||
let dir = log_dir(project_root, story_id);
|
||||
fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create log directory {}: {e}", dir.display()))?;
|
||||
|
||||
let path = dir.join(format!("{agent_name}-{session_id}.log"));
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.map_err(|e| format!("Failed to open log file {}: {e}", path.display()))?;
|
||||
|
||||
Ok(Self { file })
|
||||
}
|
||||
|
||||
/// Write an agent event as a JSONL line with an ISO 8601 timestamp.
|
||||
pub fn write_event(&mut self, event: &AgentEvent) -> Result<(), String> {
|
||||
let event_value =
|
||||
serde_json::to_value(event).map_err(|e| format!("Failed to serialize event: {e}"))?;
|
||||
|
||||
let entry = LogEntry {
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
event: event_value,
|
||||
};
|
||||
|
||||
let mut line =
|
||||
serde_json::to_string(&entry).map_err(|e| format!("Failed to serialize entry: {e}"))?;
|
||||
line.push('\n');
|
||||
|
||||
self.file
|
||||
.write_all(line.as_bytes())
|
||||
.map_err(|e| format!("Failed to write log entry: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the log directory for a story.
|
||||
pub(super) fn log_dir(project_root: &Path, story_id: &str) -> PathBuf {
|
||||
project_root.join(".huskies").join("logs").join(story_id)
|
||||
}
|
||||
|
||||
/// Return the path to a specific log file.
|
||||
#[allow(dead_code)]
|
||||
pub fn log_file_path(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
agent_name: &str,
|
||||
session_id: &str,
|
||||
) -> PathBuf {
|
||||
log_dir(project_root, story_id).join(format!("{agent_name}-{session_id}.log"))
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
//! Headless build-agent mode for distributed, rendezvous-based story processing.
|
||||
/// Headless build agent mode.
|
||||
///
|
||||
/// When invoked via `huskies agent --rendezvous ws://host:3001/crdt-sync`, this
|
||||
/// module runs a headless loop that:
|
||||
///
|
||||
/// 1. Syncs CRDT state with the rendezvous peer.
|
||||
/// 2. Writes a heartbeat to the CRDT `nodes` list.
|
||||
/// 3. Scans for unclaimed stories in `2_current` and claims them via CRDT.
|
||||
/// 4. Spawns Claude Code locally for each claimed story.
|
||||
/// 5. Pushes the feature branch to the git remote when done.
|
||||
/// 6. Reports completion by advancing the story stage via CRDT.
|
||||
/// 7. Handles offline/reconnect: CRDT merges on reconnect, interrupted work
|
||||
/// is reclaimed after a timeout.
|
||||
///
|
||||
/// No web UI, HTTP server, or chat interface is started.
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::agents::AgentPool;
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::crdt_state;
|
||||
use crate::io::watcher;
|
||||
use crate::slog;
|
||||
|
||||
/// Default claim timeout in seconds. If a node has not updated its heartbeat
|
||||
/// within this window, other nodes may reclaim the story.
|
||||
const CLAIM_TIMEOUT_SECS: f64 = 600.0; // 10 minutes
|
||||
|
||||
/// Interval between heartbeat writes and work scans.
|
||||
const SCAN_INTERVAL_SECS: u64 = 15;
|
||||
|
||||
/// Run the headless build agent loop.
|
||||
///
|
||||
/// This function never returns under normal operation — it runs until the
|
||||
/// process is terminated (SIGINT/SIGTERM).
|
||||
///
|
||||
/// If `join_token` and `gateway_url` are both provided the agent will register
|
||||
/// itself with the gateway on startup using the one-time token.
|
||||
pub async fn run(
|
||||
project_root: Option<PathBuf>,
|
||||
rendezvous_url: String,
|
||||
port: u16,
|
||||
join_token: Option<String>,
|
||||
gateway_url: Option<String>,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let project_root = match project_root {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
eprintln!("error: agent mode requires a project root (no .huskies/ found)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
println!("\x1b[96;1m[agent-mode]\x1b[0m Starting headless build agent");
|
||||
println!("\x1b[96;1m[agent-mode]\x1b[0m Rendezvous: {rendezvous_url}");
|
||||
println!(
|
||||
"\x1b[96;1m[agent-mode]\x1b[0m Project: {}",
|
||||
project_root.display()
|
||||
);
|
||||
|
||||
// Validate project config.
|
||||
let config = ProjectConfig::load(&project_root).unwrap_or_else(|e| {
|
||||
eprintln!("error: invalid project config: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
slog!(
|
||||
"[agent-mode] Loaded config with {} agents",
|
||||
config.agent.len()
|
||||
);
|
||||
|
||||
// Event bus for pipeline lifecycle events.
|
||||
let (watcher_tx, _) = broadcast::channel::<watcher::WatcherEvent>(1024);
|
||||
let agents = Arc::new(AgentPool::new(port, watcher_tx.clone()));
|
||||
|
||||
// Start filesystem watcher for config hot-reload.
|
||||
watcher::start_watcher(project_root.clone(), watcher_tx.clone());
|
||||
|
||||
// Bridge CRDT events to watcher channel (same as main server).
|
||||
{
|
||||
let crdt_watcher_tx = watcher_tx.clone();
|
||||
let crdt_prune_root = Some(project_root.clone());
|
||||
if let Some(mut crdt_rx) = crdt_state::subscribe() {
|
||||
tokio::spawn(async move {
|
||||
while let Ok(evt) = crdt_rx.recv().await {
|
||||
if evt.to_stage == "6_archived"
|
||||
&& let Some(root) = crdt_prune_root.as_ref().cloned()
|
||||
{
|
||||
let story_id = evt.story_id.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if let Err(e) = crate::worktree::prune_worktree_sync(&root, &story_id) {
|
||||
slog!("[agent-mode] worktree prune failed for {story_id}: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
let (action, commit_msg) =
|
||||
watcher::stage_metadata(&evt.to_stage, &evt.story_id)
|
||||
.unwrap_or(("update", format!("huskies: update {}", evt.story_id)));
|
||||
let watcher_evt = watcher::WatcherEvent::WorkItem {
|
||||
stage: evt.to_stage,
|
||||
item_id: evt.story_id,
|
||||
action: action.to_string(),
|
||||
commit_msg,
|
||||
from_stage: evt.from_stage,
|
||||
};
|
||||
let _ = crdt_watcher_tx.send(watcher_evt);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to watcher events to trigger auto-assign on stage transitions.
|
||||
{
|
||||
let auto_rx = watcher_tx.subscribe();
|
||||
let auto_agents = Arc::clone(&agents);
|
||||
let auto_root = project_root.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut rx = auto_rx;
|
||||
while let Ok(event) = rx.recv().await {
|
||||
if let watcher::WatcherEvent::WorkItem { ref stage, .. } = event
|
||||
&& matches!(stage.as_str(), "2_current" | "3_qa" | "4_merge")
|
||||
{
|
||||
slog!("[agent-mode] CRDT transition in {stage}/; triggering auto-assign.");
|
||||
auto_agents.auto_assign_available_work(&auto_root).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Write initial heartbeat.
|
||||
write_heartbeat(&rendezvous_url, port);
|
||||
|
||||
// Register with gateway if a join token and gateway URL were provided.
|
||||
if let (Some(token), Some(url)) = (join_token, gateway_url) {
|
||||
let node_id = crdt_state::our_node_id().unwrap_or_else(|| "unknown".to_string());
|
||||
let label = format!("build-agent-{}", &node_id[..node_id.len().min(8)]);
|
||||
let address = format!("ws://0.0.0.0:{port}/crdt-sync");
|
||||
register_with_gateway(&url, &token, &label, &address).await;
|
||||
}
|
||||
|
||||
// Reconcile any committed work from a previous session.
|
||||
{
|
||||
let recon_agents = Arc::clone(&agents);
|
||||
let recon_root = project_root.clone();
|
||||
let (recon_tx, _) = broadcast::channel(64);
|
||||
slog!("[agent-mode] Reconciling completed worktrees from previous session.");
|
||||
recon_agents
|
||||
.reconcile_on_startup(&recon_root, &recon_tx)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Run initial auto-assign.
|
||||
slog!("[agent-mode] Initial auto-assign scan.");
|
||||
agents.auto_assign_available_work(&project_root).await;
|
||||
|
||||
// Track which stories we've claimed so we can detect conflicts.
|
||||
let mut our_claims: HashMap<String, f64> = HashMap::new();
|
||||
|
||||
// Main loop: heartbeat, scan, claim, detect conflicts.
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(SCAN_INTERVAL_SECS));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// Write heartbeat.
|
||||
write_heartbeat(&rendezvous_url, port);
|
||||
|
||||
// Scan CRDT for claimable work.
|
||||
scan_and_claim(&agents, &project_root, &mut our_claims).await;
|
||||
|
||||
// Detect claim conflicts: if another node overwrote our claim, stop our agent.
|
||||
detect_conflicts(&agents, &project_root, &mut our_claims).await;
|
||||
|
||||
// Reclaim timed-out work from dead nodes.
|
||||
reclaim_timed_out_work(&project_root);
|
||||
|
||||
// Check for completed agents and push their branches.
|
||||
check_completions_and_push(&agents, &project_root).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Write this node's heartbeat to the CRDT `nodes` list.
|
||||
fn write_heartbeat(rendezvous_url: &str, port: u16) {
|
||||
let Some(node_id) = crdt_state::our_node_id() else {
|
||||
return;
|
||||
};
|
||||
let now = chrono::Utc::now().timestamp() as f64;
|
||||
// Advertise our crdt-sync endpoint.
|
||||
let address = format!("ws://0.0.0.0:{port}/crdt-sync");
|
||||
crdt_state::write_node_presence(&node_id, &address, now, true);
|
||||
slog!(
|
||||
"[agent-mode] Heartbeat written: node={:.12}… rendezvous={rendezvous_url}",
|
||||
&node_id
|
||||
);
|
||||
}
|
||||
|
||||
/// Scan CRDT pipeline for unclaimed stories and claim them.
|
||||
async fn scan_and_claim(
|
||||
agents: &AgentPool,
|
||||
project_root: &Path,
|
||||
our_claims: &mut HashMap<String, f64>,
|
||||
) {
|
||||
let Some(items) = crdt_state::read_all_items() else {
|
||||
return;
|
||||
};
|
||||
let Some(our_node) = crdt_state::our_node_id() else {
|
||||
return;
|
||||
};
|
||||
|
||||
for item in &items {
|
||||
// Only claim stories in active stages.
|
||||
if !matches!(item.stage.as_str(), "2_current" | "3_qa" | "4_merge") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip blocked stories.
|
||||
if item.blocked == Some(true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If already claimed by us, skip.
|
||||
if item.claimed_by.as_deref() == Some(&our_node) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If claimed by another alive node and claim is fresh, skip.
|
||||
if let Some(ref claimer) = item.claimed_by
|
||||
&& !claimer.is_empty()
|
||||
&& claimer != &our_node
|
||||
&& let Some(claimed_at) = item.claimed_at
|
||||
{
|
||||
let now = chrono::Utc::now().timestamp() as f64;
|
||||
if now - claimed_at < CLAIM_TIMEOUT_SECS && is_node_alive(claimer) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to claim this story.
|
||||
slog!(
|
||||
"[agent-mode] Claiming story '{}' for this node",
|
||||
item.story_id
|
||||
);
|
||||
if crdt_state::write_claim(&item.story_id) {
|
||||
let now = chrono::Utc::now().timestamp() as f64;
|
||||
our_claims.insert(item.story_id.clone(), now);
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger auto-assign to start agents for newly claimed work.
|
||||
agents.auto_assign_available_work(project_root).await;
|
||||
}
|
||||
|
||||
/// Detect if another node overwrote our claims (CRDT conflict resolution).
|
||||
/// If so, stop our local agent for that story.
|
||||
async fn detect_conflicts(
|
||||
agents: &AgentPool,
|
||||
project_root: &Path,
|
||||
our_claims: &mut HashMap<String, f64>,
|
||||
) {
|
||||
let lost: Vec<String> = our_claims
|
||||
.keys()
|
||||
.filter(|story_id| !crdt_state::is_claimed_by_us(story_id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for story_id in lost {
|
||||
slog!(
|
||||
"[agent-mode] Lost claim on '{}' to another node; stopping local agent.",
|
||||
story_id
|
||||
);
|
||||
our_claims.remove(&story_id);
|
||||
|
||||
// Stop any local agent for this story by looking up its name.
|
||||
if let Ok(agent_list) = agents.list_agents() {
|
||||
for info in agent_list {
|
||||
if info.story_id == story_id {
|
||||
let _ = agents
|
||||
.stop_agent(project_root, &story_id, &info.agent_name)
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release our claim (in case it wasn't fully overwritten).
|
||||
crdt_state::release_claim(&story_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reclaim work from nodes that have timed out (stale heartbeat).
|
||||
fn reclaim_timed_out_work(_project_root: &Path) {
|
||||
let Some(items) = crdt_state::read_all_items() else {
|
||||
return;
|
||||
};
|
||||
let now = chrono::Utc::now().timestamp() as f64;
|
||||
|
||||
for item in &items {
|
||||
if !matches!(item.stage.as_str(), "2_current" | "3_qa" | "4_merge") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the claim has timed out.
|
||||
if let Some(ref claimer) = item.claimed_by {
|
||||
if claimer.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(claimed_at) = item.claimed_at
|
||||
&& now - claimed_at >= CLAIM_TIMEOUT_SECS
|
||||
&& !is_node_alive(claimer)
|
||||
{
|
||||
slog!(
|
||||
"[agent-mode] Reclaiming timed-out story '{}' from dead node {:.12}…",
|
||||
item.story_id,
|
||||
claimer
|
||||
);
|
||||
crdt_state::release_claim(&item.story_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a node is alive according to the CRDT nodes list.
|
||||
fn is_node_alive(node_id: &str) -> bool {
|
||||
let Some(nodes) = crdt_state::read_all_node_presence() else {
|
||||
return false;
|
||||
};
|
||||
let now = chrono::Utc::now().timestamp() as f64;
|
||||
|
||||
for node in &nodes {
|
||||
if node.node_id == node_id {
|
||||
// Node is considered alive if it's marked alive AND its heartbeat
|
||||
// is within the timeout window.
|
||||
return node.alive && (now - node.last_seen) < CLAIM_TIMEOUT_SECS;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Check for completed agents, push their feature branches to the remote,
|
||||
/// and report completion via CRDT.
|
||||
async fn check_completions_and_push(agents: &AgentPool, _project_root: &Path) {
|
||||
let Ok(agent_list) = agents.list_agents() else {
|
||||
return;
|
||||
};
|
||||
|
||||
for info in agent_list {
|
||||
if !matches!(
|
||||
info.status,
|
||||
crate::agents::AgentStatus::Completed | crate::agents::AgentStatus::Failed
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let story_id = &info.story_id;
|
||||
|
||||
// Only push if this node still owns the claim.
|
||||
if !crdt_state::is_claimed_by_us(story_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
slog!(
|
||||
"[agent-mode] Agent {} for '{}'; pushing feature branch.",
|
||||
if matches!(info.status, crate::agents::AgentStatus::Completed) {
|
||||
"completed"
|
||||
} else {
|
||||
"failed"
|
||||
},
|
||||
story_id
|
||||
);
|
||||
|
||||
// Push the feature branch to the remote.
|
||||
if let Some(ref wt) = info.worktree_path {
|
||||
let push_result = push_feature_branch(wt, story_id);
|
||||
match push_result {
|
||||
Ok(()) => {
|
||||
slog!("[agent-mode] Pushed feature branch for '{story_id}' to remote.");
|
||||
}
|
||||
Err(e) => {
|
||||
slog!("[agent-mode] Failed to push '{story_id}': {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release the claim now that work is done.
|
||||
crdt_state::release_claim(story_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the feature branch of a worktree to the git remote.
|
||||
fn push_feature_branch(worktree_path: &str, story_id: &str) -> Result<(), String> {
|
||||
let branch = format!("feature/story-{story_id}");
|
||||
|
||||
// Try to push to 'origin'. If origin doesn't exist, try the first remote.
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["push", "origin", &branch])
|
||||
.current_dir(worktree_path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git push: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// If 'origin' doesn't exist, try to find any remote.
|
||||
if stderr.contains("does not appear to be a git repository")
|
||||
|| stderr.contains("No such remote")
|
||||
{
|
||||
let remotes = std::process::Command::new("git")
|
||||
.args(["remote"])
|
||||
.current_dir(worktree_path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to list remotes: {e}"))?;
|
||||
|
||||
let remote_list = String::from_utf8_lossy(&remotes.stdout);
|
||||
let first_remote = remote_list.lines().next();
|
||||
|
||||
if let Some(remote) = first_remote {
|
||||
let retry = std::process::Command::new("git")
|
||||
.args(["push", remote.trim(), &branch])
|
||||
.current_dir(worktree_path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to push to {remote}: {e}"))?;
|
||||
|
||||
if retry.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!(
|
||||
"git push to '{remote}' failed: {}",
|
||||
String::from_utf8_lossy(&retry.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
// No remotes configured — not an error in agent mode, just skip.
|
||||
slog!("[agent-mode] No git remote configured; skipping push for '{story_id}'.");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("git push failed: {stderr}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gateway registration ──────────────────────────────────────────────────
|
||||
|
||||
/// Register this build agent with a gateway using a one-time join token.
|
||||
///
|
||||
/// POSTs `{ token, label, address }` to `{gateway_url}/gateway/register`. On
|
||||
/// success the gateway stores the agent and it will appear in the gateway UI.
|
||||
async fn register_with_gateway(gateway_url: &str, token: &str, label: &str, address: &str) {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/gateway/register", gateway_url.trim_end_matches('/'));
|
||||
let body = serde_json::json!({
|
||||
"token": token,
|
||||
"label": label,
|
||||
"address": address,
|
||||
});
|
||||
match client.post(&url).json(&body).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
slog!("[agent-mode] Registered with gateway at {gateway_url}");
|
||||
}
|
||||
Ok(resp) => {
|
||||
slog!(
|
||||
"[agent-mode] Gateway registration failed: HTTP {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
slog!("[agent-mode] Gateway registration error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_node_alive_returns_false_for_unknown_node() {
|
||||
// Without CRDT init, should return false.
|
||||
assert!(!is_node_alive("nonexistent_node_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_feature_branch_handles_missing_worktree() {
|
||||
let result = push_feature_branch("/nonexistent/path", "test_story");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_timeout_is_ten_minutes() {
|
||||
assert_eq!(CLAIM_TIMEOUT_SECS, 600.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Claim ownership logic: deterministic hash-based tie-breaking and TTL constants.
|
||||
|
||||
/// Default claim TTL in seconds. If a claim has not been refreshed within this
|
||||
/// window, other nodes may displace the stale holder and claim the story.
|
||||
/// A node actively working on a story should refresh its claim periodically.
|
||||
pub(crate) const CLAIM_TIMEOUT_SECS: f64 = 1800.0; // 30 minutes
|
||||
|
||||
/// Interval between heartbeat writes and work scans.
|
||||
pub const SCAN_INTERVAL_SECS: u64 = 15;
|
||||
|
||||
// ── Hash-based tie-break ──────────────────────────────────────────────────
|
||||
|
||||
/// Compute the claim-priority hash for a `(node_id, story_id)` pair.
|
||||
///
|
||||
/// Uses SHA-256(`node_id` bytes ++ `story_id` bytes), truncated to the first
|
||||
/// 8 bytes interpreted as a big-endian `u64`. This function is:
|
||||
///
|
||||
/// * **Deterministic** — same inputs always produce the same output.
|
||||
/// * **Stable across restarts** — depends only on the node's persistent id
|
||||
/// and the story id, not on wall-clock time or random state.
|
||||
/// * **Cross-implementation portable** — SHA-256 is a standard primitive; any
|
||||
/// conforming implementation will produce identical values.
|
||||
pub(super) fn claim_hash(node_id: &str, story_id: &str) -> u64 {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(node_id.as_bytes());
|
||||
hasher.update(story_id.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
u64::from_be_bytes(digest[..8].try_into().expect("sha256 is 32 bytes"))
|
||||
}
|
||||
|
||||
/// Decide whether this node should be the one to claim `story_id`.
|
||||
///
|
||||
/// Returns `true` iff `claim_hash(self_node_id, story_id)` is **strictly
|
||||
/// lower** than the hash of every alive peer. When there are no alive peers
|
||||
/// (single-node cluster) the result is always `true`.
|
||||
///
|
||||
/// # Trade-off note
|
||||
/// Because the winning node is determined purely by the hash of its id and the
|
||||
/// story id, the distribution is uniform per story but a given node may
|
||||
/// consistently "win" or "lose" across a set of stories depending on how its
|
||||
/// id happens to hash. For 2–5 node clusters this imbalance is negligible in
|
||||
/// practice: any node is the lowest-hash winner with probability ≈ 1/N for a
|
||||
/// random story id, so the long-run distribution is approximately fair. For
|
||||
/// clusters with many nodes (e.g. >10) the expected variance is larger and
|
||||
/// operators may want a different work-distribution strategy.
|
||||
pub fn should_self_claim(
|
||||
self_node_id: &str,
|
||||
story_id: &str,
|
||||
alive_peer_node_ids: &[String],
|
||||
) -> bool {
|
||||
let my_hash = claim_hash(self_node_id, story_id);
|
||||
for peer_id in alive_peer_node_ids {
|
||||
// Skip self if it appears in the peer list.
|
||||
if peer_id == self_node_id {
|
||||
continue;
|
||||
}
|
||||
if claim_hash(peer_id, story_id) <= my_hash {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn claim_timeout_is_thirty_minutes() {
|
||||
assert_eq!(CLAIM_TIMEOUT_SECS, 1800.0);
|
||||
}
|
||||
|
||||
/// AC: seed a stale claim older than the TTL, attempt a new claim from a
|
||||
/// different agent, assert the new claim succeeds and displacement is logged.
|
||||
#[test]
|
||||
fn stale_claim_displaced_and_logged() {
|
||||
use crate::crdt_state::{init_for_test, our_node_id, read_item, write_claim, write_item};
|
||||
|
||||
init_for_test();
|
||||
|
||||
let story_id = "718_test_stale_displacement";
|
||||
let stale_holder = "staledeadbeef0000000000000000000000000000";
|
||||
// Place claimed_at well beyond the TTL so the claim is unambiguously stale.
|
||||
let stale_time = chrono::Utc::now().timestamp() as f64 - CLAIM_TIMEOUT_SECS - 300.0;
|
||||
|
||||
// Seed the story with a stale claim from a foreign node.
|
||||
write_item(
|
||||
story_id,
|
||||
"2_current",
|
||||
Some("Stale Claim Displacement Test"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(stale_holder),
|
||||
Some(stale_time),
|
||||
None,
|
||||
);
|
||||
|
||||
// Confirm the stale claim is in place.
|
||||
let before = read_item(story_id).expect("item should exist");
|
||||
assert_eq!(
|
||||
before.claimed_by.as_deref(),
|
||||
Some(stale_holder),
|
||||
"pre-condition: item should be claimed by the stale holder"
|
||||
);
|
||||
let age = chrono::Utc::now().timestamp() as f64 - before.claimed_at.unwrap_or(0.0);
|
||||
assert!(
|
||||
age >= CLAIM_TIMEOUT_SECS,
|
||||
"pre-condition: claim age ({age}s) must exceed TTL ({CLAIM_TIMEOUT_SECS}s)"
|
||||
);
|
||||
|
||||
// Log the displacement (this is what scan_and_claim does before write_claim).
|
||||
crate::slog!(
|
||||
"[agent-mode] Displacing stale claim on '{}' held by {:.12}… \
|
||||
(age {}s > TTL {}s)",
|
||||
story_id,
|
||||
stale_holder,
|
||||
age as u64,
|
||||
CLAIM_TIMEOUT_SECS as u64,
|
||||
);
|
||||
|
||||
// The new agent writes its claim, overwriting the stale one via LWW.
|
||||
let success = write_claim(story_id);
|
||||
assert!(
|
||||
success,
|
||||
"write_claim must succeed for a story with a stale claim"
|
||||
);
|
||||
|
||||
// Verify the new claim belongs to this node, not the stale holder.
|
||||
let our_id = our_node_id().expect("node id should be available after init_for_test");
|
||||
let after = read_item(story_id).expect("item should still exist");
|
||||
assert_eq!(
|
||||
after.claimed_by.as_deref(),
|
||||
Some(our_id.as_str()),
|
||||
"new claim should have displaced the stale holder"
|
||||
);
|
||||
assert_ne!(
|
||||
after.claimed_by.as_deref(),
|
||||
Some(stale_holder),
|
||||
"stale holder must no longer own the claim"
|
||||
);
|
||||
|
||||
// Verify the displacement was logged.
|
||||
let logs =
|
||||
crate::log_buffer::global().get_recent(100, Some("Displacing stale claim"), None);
|
||||
assert!(
|
||||
!logs.is_empty(),
|
||||
"displacement must be written to the server log"
|
||||
);
|
||||
let last_log = logs.last().unwrap();
|
||||
assert!(
|
||||
last_log.contains(story_id),
|
||||
"log entry must name the story; got: {last_log}"
|
||||
);
|
||||
assert!(
|
||||
last_log.contains(&stale_holder[..12]),
|
||||
"log entry must include the stale holder's id prefix; got: {last_log}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── should_self_claim unit tests ──────────────────────────────────────
|
||||
|
||||
/// AC1 + AC6: single-node cluster always claims (no peers → trivially lowest).
|
||||
#[test]
|
||||
fn should_self_claim_single_node_always_claims() {
|
||||
assert!(should_self_claim("node-a", "story-1", &[]));
|
||||
assert!(should_self_claim("node-a", "story-2", &[]));
|
||||
assert!(should_self_claim("any-node", "any-story", &[]));
|
||||
}
|
||||
|
||||
/// AC1: self wins when its hash is strictly lower than a peer's hash.
|
||||
/// We compute the actual hashes to construct a deterministic test.
|
||||
#[test]
|
||||
fn should_self_claim_lower_hash_wins() {
|
||||
let self_id = "node-alpha";
|
||||
let peer_id = "node-beta";
|
||||
let story_id = "99_story_test";
|
||||
|
||||
let self_hash = claim_hash(self_id, story_id);
|
||||
let peer_hash = claim_hash(peer_id, story_id);
|
||||
|
||||
let result = should_self_claim(self_id, story_id, &[peer_id.to_string()]);
|
||||
// Result must agree with the actual hash comparison.
|
||||
assert_eq!(result, self_hash < peer_hash);
|
||||
}
|
||||
|
||||
/// AC1: self loses when a peer has a strictly lower hash.
|
||||
#[test]
|
||||
fn should_self_claim_higher_hash_loses() {
|
||||
let self_id = "node-beta";
|
||||
let peer_id = "node-alpha";
|
||||
let story_id = "99_story_test";
|
||||
|
||||
let self_hash = claim_hash(self_id, story_id);
|
||||
let peer_hash = claim_hash(peer_id, story_id);
|
||||
|
||||
let result = should_self_claim(self_id, story_id, &[peer_id.to_string()]);
|
||||
assert_eq!(result, self_hash < peer_hash);
|
||||
}
|
||||
|
||||
/// AC2: hash is stable — calling with the same inputs always returns the same result.
|
||||
#[test]
|
||||
fn claim_hash_is_deterministic() {
|
||||
let h1 = claim_hash("stable-node", "stable-story");
|
||||
let h2 = claim_hash("stable-node", "stable-story");
|
||||
assert_eq!(h1, h2);
|
||||
}
|
||||
|
||||
/// AC2: SHA-256("node-a" ++ "story-1") first 8 bytes == known constant.
|
||||
/// This pins the exact hash output so regressions are caught immediately.
|
||||
#[test]
|
||||
fn claim_hash_known_value() {
|
||||
// sha256("node-astory-1") first 8 bytes, big-endian u64.
|
||||
// Pre-computed: echo -n "node-astory-1" | sha256sum
|
||||
// = 5c1e7c8e7d9f1a3b...
|
||||
// We verify by round-tripping: compute once and assert stability.
|
||||
let h = claim_hash("node-a", "story-1");
|
||||
assert_eq!(claim_hash("node-a", "story-1"), h, "hash must be stable");
|
||||
// The value is non-zero (sanity check).
|
||||
assert_ne!(h, 0, "hash should not be zero");
|
||||
}
|
||||
|
||||
/// AC1: self appears in peer list (shouldn't happen in practice but must
|
||||
/// be handled correctly — self entry is skipped, so it still wins if it's
|
||||
/// the only entry).
|
||||
#[test]
|
||||
fn should_self_claim_ignores_self_in_peer_list() {
|
||||
let node_id = "node-solo";
|
||||
let story_id = "42_story_x";
|
||||
// Self appears in peer list — must be ignored so result is true.
|
||||
assert!(should_self_claim(node_id, story_id, &[node_id.to_string()]));
|
||||
}
|
||||
|
||||
/// AC5: integration test — two nodes, deterministic in both orders.
|
||||
///
|
||||
/// Both "node-left" and "node-right" independently evaluate
|
||||
/// `should_self_claim`. Exactly one must return `true`. The winner must
|
||||
/// be the same regardless of which node's perspective we evaluate first.
|
||||
#[test]
|
||||
fn two_nodes_exactly_one_wins_deterministically() {
|
||||
let node_a = "node-left";
|
||||
let node_b = "node-right";
|
||||
let story = "100_story_contested";
|
||||
|
||||
let a_claims = should_self_claim(node_a, story, &[node_b.to_string()]);
|
||||
let b_claims = should_self_claim(node_b, story, &[node_a.to_string()]);
|
||||
|
||||
// Exactly one must win.
|
||||
assert_ne!(
|
||||
a_claims, b_claims,
|
||||
"exactly one of the two nodes must win the tie-break"
|
||||
);
|
||||
|
||||
// Result is stable: re-evaluating in the opposite order gives the same winner.
|
||||
let a_again = should_self_claim(node_a, story, &[node_b.to_string()]);
|
||||
let b_again = should_self_claim(node_b, story, &[node_a.to_string()]);
|
||||
assert_eq!(
|
||||
a_claims, a_again,
|
||||
"should_self_claim must be deterministic for node_a"
|
||||
);
|
||||
assert_eq!(
|
||||
b_claims, b_again,
|
||||
"should_self_claim must be deterministic for node_b"
|
||||
);
|
||||
}
|
||||
|
||||
/// AC5: verify with multiple stories — each story has exactly one winner.
|
||||
#[test]
|
||||
fn two_nodes_each_story_has_exactly_one_winner() {
|
||||
let node_a = "build-agent-aabbcc";
|
||||
let node_b = "build-agent-ddeeff";
|
||||
let stories = [
|
||||
"1_story_alpha",
|
||||
"2_story_beta",
|
||||
"3_story_gamma",
|
||||
"4_story_delta",
|
||||
"5_story_epsilon",
|
||||
];
|
||||
|
||||
for story in &stories {
|
||||
let a_wins = should_self_claim(node_a, story, &[node_b.to_string()]);
|
||||
let b_wins = should_self_claim(node_b, story, &[node_a.to_string()]);
|
||||
assert_ne!(
|
||||
a_wins, b_wins,
|
||||
"story '{story}': exactly one node must win, got a={a_wins} b={b_wins}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Agent-mode HTTP context construction and gateway registration.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::agents::AgentPool;
|
||||
use crate::io::watcher;
|
||||
use crate::slog;
|
||||
|
||||
/// Register this build agent with a gateway using a one-time join token.
|
||||
///
|
||||
/// POSTs `{ token, label, address }` to `{gateway_url}/gateway/register`. On
|
||||
/// success the gateway stores the agent and it will appear in the gateway UI.
|
||||
pub(super) async fn register_with_gateway(
|
||||
gateway_url: &str,
|
||||
token: &str,
|
||||
label: &str,
|
||||
address: &str,
|
||||
) {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/gateway/register", gateway_url.trim_end_matches('/'));
|
||||
let body = serde_json::json!({
|
||||
"token": token,
|
||||
"label": label,
|
||||
"address": address,
|
||||
});
|
||||
match client.post(&url).json(&body).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
slog!("[agent-mode] Registered with gateway at {gateway_url}");
|
||||
}
|
||||
Ok(resp) => {
|
||||
slog!(
|
||||
"[agent-mode] Gateway registration failed: HTTP {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
slog!("[agent-mode] Gateway registration error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a minimal [`AppContext`] for the agent-mode HTTP server.
|
||||
///
|
||||
/// The `/crdt-sync` handler receives `Data<&Arc<AppContext>>` but doesn't
|
||||
/// actually use it (the parameter is named `_ctx`). We construct a
|
||||
/// lightweight context with just enough state to satisfy Poem's data
|
||||
/// extractor.
|
||||
pub(super) fn build_agent_app_context(
|
||||
project_root: &Path,
|
||||
port: u16,
|
||||
watcher_tx: broadcast::Sender<watcher::WatcherEvent>,
|
||||
) -> crate::http::context::AppContext {
|
||||
let state = crate::state::SessionState::default();
|
||||
*state.project_root.lock().unwrap() = Some(project_root.to_path_buf());
|
||||
let store_path = project_root.join(".huskies").join("store.json");
|
||||
let store = Arc::new(
|
||||
crate::store::JsonFileStore::from_path(store_path)
|
||||
.unwrap_or_else(|e| panic!("Failed to open store: {e}")),
|
||||
);
|
||||
let (reconciliation_tx, _) = broadcast::channel(64);
|
||||
let (perm_tx, perm_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let timer_store = Arc::new(crate::service::timer::TimerStore::load(
|
||||
project_root.join(".huskies").join("timers.json"),
|
||||
));
|
||||
let agents = Arc::new(AgentPool::new(port, watcher_tx.clone()));
|
||||
let services = Arc::new(crate::services::Services {
|
||||
project_root: project_root.to_path_buf(),
|
||||
agents: Arc::clone(&agents),
|
||||
bot_name: "Agent".to_string(),
|
||||
bot_user_id: String::new(),
|
||||
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
|
||||
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
|
||||
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
permission_timeout_secs: 120,
|
||||
status: agents.status_broadcaster(),
|
||||
});
|
||||
crate::http::context::AppContext {
|
||||
state: Arc::new(state),
|
||||
store,
|
||||
workflow: Arc::new(std::sync::Mutex::new(
|
||||
crate::workflow::WorkflowState::default(),
|
||||
)),
|
||||
services,
|
||||
watcher_tx,
|
||||
reconciliation_tx,
|
||||
perm_tx,
|
||||
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
|
||||
bot_shutdown: None,
|
||||
matrix_shutdown_tx: None,
|
||||
timer_store,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Main-loop operations: heartbeat, claim scanning, conflict detection, and branch pushing.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::agents::AgentPool;
|
||||
use crate::crdt_state;
|
||||
use crate::slog;
|
||||
|
||||
use super::claim::{CLAIM_TIMEOUT_SECS, should_self_claim};
|
||||
|
||||
/// Write this node's heartbeat to the CRDT `nodes` list.
|
||||
pub(super) fn write_heartbeat(rendezvous_url: &str, port: u16) {
|
||||
let Some(node_id) = crdt_state::our_node_id() else {
|
||||
return;
|
||||
};
|
||||
let now = chrono::Utc::now().timestamp() as f64;
|
||||
let now_ms = chrono::Utc::now().timestamp_millis() as f64;
|
||||
// Advertise our crdt-sync endpoint.
|
||||
let address = format!("ws://0.0.0.0:{port}/crdt-sync");
|
||||
crdt_state::write_node_presence(&node_id, &address, now, true);
|
||||
// Write millisecond-precision timestamp via LWW register.
|
||||
crdt_state::write_node_metadata(&node_id, "", None, now_ms);
|
||||
slog!(
|
||||
"[agent-mode] Heartbeat written: node={:.12}… rendezvous={rendezvous_url}",
|
||||
&node_id
|
||||
);
|
||||
}
|
||||
|
||||
/// Scan CRDT pipeline for unclaimed stories and claim them.
|
||||
pub(super) async fn scan_and_claim(
|
||||
agents: &AgentPool,
|
||||
project_root: &Path,
|
||||
our_claims: &mut HashMap<String, f64>,
|
||||
) {
|
||||
let Some(items) = crdt_state::read_all_items() else {
|
||||
return;
|
||||
};
|
||||
let Some(our_node) = crdt_state::our_node_id() else {
|
||||
return;
|
||||
};
|
||||
|
||||
for item in &items {
|
||||
// Only claim stories in active stages.
|
||||
if !crate::pipeline_state::Stage::from_dir(&item.stage).is_some_and(|s| s.is_active()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip blocked stories.
|
||||
if item.blocked == Some(true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If already claimed by us, skip.
|
||||
if item.claimed_by.as_deref() == Some(&our_node) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If claimed by another node, respect the claim while it is fresh.
|
||||
// Once the TTL expires the claim is considered stale regardless of
|
||||
// whether the holder appears alive — displacement is purely TTL-driven.
|
||||
if let Some(ref claimer) = item.claimed_by
|
||||
&& !claimer.is_empty()
|
||||
&& claimer != &our_node
|
||||
&& let Some(claimed_at) = item.claimed_at
|
||||
{
|
||||
let now = chrono::Utc::now().timestamp() as f64;
|
||||
let age = now - claimed_at;
|
||||
if age < CLAIM_TIMEOUT_SECS {
|
||||
// Claim is still fresh — respect it.
|
||||
continue;
|
||||
}
|
||||
// Claim TTL has expired: displace the stale holder.
|
||||
slog!(
|
||||
"[agent-mode] Displacing stale claim on '{}' held by {:.12}… \
|
||||
(age {}s > TTL {}s)",
|
||||
item.story_id,
|
||||
claimer,
|
||||
age as u64,
|
||||
CLAIM_TIMEOUT_SECS as u64,
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-spawn hash-based tie-break: only the node whose
|
||||
// SHA-256(node_id || story_id) is strictly lowest among all alive
|
||||
// candidates should write the CRDT claim. This eliminates the
|
||||
// thundering-herd of simultaneous LWW conflicts while keeping the
|
||||
// existing LWW + reclaim-stale logic as a safety net for clock skew
|
||||
// and partial alive-list views.
|
||||
let alive_peers: Vec<String> = crdt_state::read_all_node_presence()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|n| {
|
||||
let now_ms = chrono::Utc::now().timestamp_millis() as f64;
|
||||
let last_ms = n.last_seen_ms.unwrap_or(n.last_seen * 1000.0);
|
||||
n.alive && (now_ms - last_ms) / 1000.0 < CLAIM_TIMEOUT_SECS
|
||||
})
|
||||
.map(|n| n.node_id)
|
||||
.collect();
|
||||
if !should_self_claim(&our_node, &item.story_id, &alive_peers) {
|
||||
slog!(
|
||||
"[agent-mode] Hash tie-break: deferring claim on '{}' to lower-hash peer",
|
||||
item.story_id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to claim this story.
|
||||
slog!(
|
||||
"[agent-mode] Claiming story '{}' for this node",
|
||||
item.story_id
|
||||
);
|
||||
if crdt_state::write_claim(&item.story_id) {
|
||||
let now = chrono::Utc::now().timestamp() as f64;
|
||||
our_claims.insert(item.story_id.clone(), now);
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger auto-assign to start agents for newly claimed work.
|
||||
agents.auto_assign_available_work(project_root).await;
|
||||
}
|
||||
|
||||
/// Detect if another node overwrote our claims (CRDT conflict resolution).
|
||||
/// If so, stop our local agent for that story.
|
||||
pub(super) async fn detect_conflicts(
|
||||
agents: &AgentPool,
|
||||
project_root: &Path,
|
||||
our_claims: &mut HashMap<String, f64>,
|
||||
) {
|
||||
let lost: Vec<String> = our_claims
|
||||
.keys()
|
||||
.filter(|story_id| !crdt_state::is_claimed_by_us(story_id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for story_id in lost {
|
||||
slog!(
|
||||
"[agent-mode] Lost claim on '{}' to another node; stopping local agent.",
|
||||
story_id
|
||||
);
|
||||
our_claims.remove(&story_id);
|
||||
|
||||
// Stop any local agent for this story by looking up its name.
|
||||
if let Ok(agent_list) = agents.list_agents() {
|
||||
for info in agent_list {
|
||||
if info.story_id == story_id {
|
||||
let _ = agents
|
||||
.stop_agent(project_root, &story_id, &info.agent_name)
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release our claim (in case it wasn't fully overwritten).
|
||||
crdt_state::release_claim(&story_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reclaim work from nodes that have timed out (stale heartbeat).
|
||||
pub(super) fn reclaim_timed_out_work(_project_root: &Path) {
|
||||
let Some(items) = crdt_state::read_all_items() else {
|
||||
return;
|
||||
};
|
||||
let now = chrono::Utc::now().timestamp() as f64;
|
||||
|
||||
for item in &items {
|
||||
if !crate::pipeline_state::Stage::from_dir(&item.stage).is_some_and(|s| s.is_active()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Release the claim if the TTL has expired — regardless of whether the
|
||||
// holder is still alive. A node actively working should refresh its
|
||||
// claim before the TTL window closes.
|
||||
if let Some(ref claimer) = item.claimed_by {
|
||||
if claimer.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(claimed_at) = item.claimed_at
|
||||
&& now - claimed_at >= CLAIM_TIMEOUT_SECS
|
||||
{
|
||||
slog!(
|
||||
"[agent-mode] Releasing stale claim on '{}' held by {:.12}… (age {}s)",
|
||||
item.story_id,
|
||||
claimer,
|
||||
(now - claimed_at) as u64,
|
||||
);
|
||||
crdt_state::release_claim(&item.story_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for completed agents, push their feature branches to the remote,
|
||||
/// and report completion via CRDT.
|
||||
pub(super) async fn check_completions_and_push(agents: &AgentPool, _project_root: &Path) {
|
||||
let Ok(agent_list) = agents.list_agents() else {
|
||||
return;
|
||||
};
|
||||
|
||||
for info in agent_list {
|
||||
if !matches!(
|
||||
info.status,
|
||||
crate::agents::AgentStatus::Completed | crate::agents::AgentStatus::Failed
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let story_id = &info.story_id;
|
||||
|
||||
// Only push if this node still owns the claim.
|
||||
if !crdt_state::is_claimed_by_us(story_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
slog!(
|
||||
"[agent-mode] Agent {} for '{}'; pushing feature branch.",
|
||||
if matches!(info.status, crate::agents::AgentStatus::Completed) {
|
||||
"completed"
|
||||
} else {
|
||||
"failed"
|
||||
},
|
||||
story_id
|
||||
);
|
||||
|
||||
// Push the feature branch to the remote.
|
||||
if let Some(ref wt) = info.worktree_path {
|
||||
let push_result = push_feature_branch(wt, story_id);
|
||||
match push_result {
|
||||
Ok(()) => {
|
||||
slog!("[agent-mode] Pushed feature branch for '{story_id}' to remote.");
|
||||
}
|
||||
Err(e) => {
|
||||
slog!("[agent-mode] Failed to push '{story_id}': {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release the claim now that work is done.
|
||||
crdt_state::release_claim(story_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the feature branch of a worktree to the git remote.
|
||||
pub(super) fn push_feature_branch(worktree_path: &str, story_id: &str) -> Result<(), String> {
|
||||
let branch = format!("feature/story-{story_id}");
|
||||
|
||||
// Try to push to 'origin'. If origin doesn't exist, try the first remote.
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["push", "origin", &branch])
|
||||
.current_dir(worktree_path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git push: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// If 'origin' doesn't exist, try to find any remote.
|
||||
if stderr.contains("does not appear to be a git repository")
|
||||
|| stderr.contains("No such remote")
|
||||
{
|
||||
let remotes = std::process::Command::new("git")
|
||||
.args(["remote"])
|
||||
.current_dir(worktree_path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to list remotes: {e}"))?;
|
||||
|
||||
let remote_list = String::from_utf8_lossy(&remotes.stdout);
|
||||
let first_remote = remote_list.lines().next();
|
||||
|
||||
if let Some(remote) = first_remote {
|
||||
let retry = std::process::Command::new("git")
|
||||
.args(["push", remote.trim(), &branch])
|
||||
.current_dir(worktree_path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to push to {remote}: {e}"))?;
|
||||
|
||||
if retry.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!(
|
||||
"git push to '{remote}' failed: {}",
|
||||
String::from_utf8_lossy(&retry.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
// No remotes configured — not an error in agent mode, just skip.
|
||||
slog!("[agent-mode] No git remote configured; skipping push for '{story_id}'.");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("git push failed: {stderr}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn push_feature_branch_handles_missing_worktree() {
|
||||
let result = push_feature_branch("/nonexistent/path", "test_story");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Headless build-agent mode for distributed, rendezvous-based story processing.
|
||||
///
|
||||
/// When invoked via `huskies agent --rendezvous ws://host:3001/crdt-sync`, this
|
||||
/// module runs a headless loop that:
|
||||
///
|
||||
/// 1. Syncs CRDT state with the rendezvous peer.
|
||||
/// 2. Writes a heartbeat to the CRDT `nodes` list.
|
||||
/// 3. Scans for unclaimed stories in `2_current` and claims them via CRDT.
|
||||
/// 4. Spawns Claude Code locally for each claimed story.
|
||||
/// 5. Pushes the feature branch to the git remote when done.
|
||||
/// 6. Reports completion by advancing the story stage via CRDT.
|
||||
/// 7. Handles offline/reconnect: CRDT merges on reconnect, interrupted work
|
||||
/// is reclaimed after a timeout.
|
||||
///
|
||||
/// A minimal HTTP server is started on the agent's port to serve the
|
||||
/// `/crdt-sync` WebSocket endpoint, enabling other agents to connect for
|
||||
/// peer mesh discovery.
|
||||
mod claim;
|
||||
mod context;
|
||||
mod loop_ops;
|
||||
|
||||
pub use claim::SCAN_INTERVAL_SECS;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use poem::EndpointExt as _;
|
||||
|
||||
use crate::agents::AgentPool;
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::crdt_state;
|
||||
use crate::io::watcher;
|
||||
use crate::mesh;
|
||||
use crate::slog;
|
||||
|
||||
use context::{build_agent_app_context, register_with_gateway};
|
||||
use loop_ops::{
|
||||
check_completions_and_push, detect_conflicts, reclaim_timed_out_work, scan_and_claim,
|
||||
write_heartbeat,
|
||||
};
|
||||
|
||||
/// Run the headless build agent loop.
|
||||
///
|
||||
/// This function never returns under normal operation — it runs until the
|
||||
/// process is terminated (SIGINT/SIGTERM).
|
||||
///
|
||||
/// If `join_token` and `gateway_url` are both provided the agent will register
|
||||
/// itself with the gateway on startup using the one-time token.
|
||||
pub async fn run(
|
||||
project_root: Option<PathBuf>,
|
||||
rendezvous_url: String,
|
||||
port: u16,
|
||||
join_token: Option<String>,
|
||||
gateway_url: Option<String>,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let project_root = match project_root {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
eprintln!("error: agent mode requires a project root (no .huskies/ found)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
println!("\x1b[96;1m[agent-mode]\x1b[0m Starting headless build agent");
|
||||
println!("\x1b[96;1m[agent-mode]\x1b[0m Rendezvous: {rendezvous_url}");
|
||||
println!(
|
||||
"\x1b[96;1m[agent-mode]\x1b[0m Project: {}",
|
||||
project_root.display()
|
||||
);
|
||||
|
||||
// Validate project config.
|
||||
let config = ProjectConfig::load(&project_root).unwrap_or_else(|e| {
|
||||
eprintln!("error: invalid project config: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
slog!(
|
||||
"[agent-mode] Loaded config with {} agents",
|
||||
config.agent.len()
|
||||
);
|
||||
|
||||
// Event bus for pipeline lifecycle events.
|
||||
let (watcher_tx, _) = broadcast::channel::<watcher::WatcherEvent>(1024);
|
||||
let agents = Arc::new(AgentPool::new(port, watcher_tx.clone()));
|
||||
|
||||
// Start filesystem watcher for config hot-reload.
|
||||
watcher::start_watcher(project_root.clone(), watcher_tx.clone());
|
||||
|
||||
// Bridge CRDT events to watcher channel (same as main server).
|
||||
{
|
||||
let crdt_watcher_tx = watcher_tx.clone();
|
||||
let crdt_prune_root = Some(project_root.clone());
|
||||
if let Some(mut crdt_rx) = crdt_state::subscribe() {
|
||||
tokio::spawn(async move {
|
||||
while let Ok(evt) = crdt_rx.recv().await {
|
||||
if crate::pipeline_state::Stage::from_dir(&evt.to_stage)
|
||||
.is_some_and(|s| matches!(s, crate::pipeline_state::Stage::Archived { .. }))
|
||||
&& let Some(root) = crdt_prune_root.as_ref().cloned()
|
||||
{
|
||||
let story_id = evt.story_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let config = ProjectConfig::load(&root).unwrap_or_default();
|
||||
crate::worktree::remove_worktree_by_story_id(&root, &story_id, &config)
|
||||
.await
|
||||
.ok();
|
||||
});
|
||||
}
|
||||
let (action, commit_msg) =
|
||||
watcher::stage_metadata(&evt.to_stage, &evt.story_id)
|
||||
.unwrap_or(("update", format!("huskies: update {}", evt.story_id)));
|
||||
let watcher_evt = watcher::WatcherEvent::WorkItem {
|
||||
stage: evt.to_stage,
|
||||
item_id: evt.story_id,
|
||||
action: action.to_string(),
|
||||
commit_msg,
|
||||
from_stage: evt.from_stage,
|
||||
};
|
||||
let _ = crdt_watcher_tx.send(watcher_evt);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to watcher events to trigger auto-assign on stage transitions.
|
||||
{
|
||||
let auto_rx = watcher_tx.subscribe();
|
||||
let auto_agents = Arc::clone(&agents);
|
||||
let auto_root = project_root.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut rx = auto_rx;
|
||||
while let Ok(event) = rx.recv().await {
|
||||
if let watcher::WatcherEvent::WorkItem { ref stage, .. } = event
|
||||
&& crate::pipeline_state::Stage::from_dir(stage.as_str())
|
||||
.is_some_and(|s| s.is_active())
|
||||
{
|
||||
slog!("[agent-mode] CRDT transition in {stage}/; triggering auto-assign.");
|
||||
auto_agents.auto_assign_available_work(&auto_root).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Start minimal HTTP server for /crdt-sync endpoint ─────────────
|
||||
//
|
||||
// Other agents discover this endpoint via the CRDT `nodes` list and
|
||||
// open supplementary mesh connections for resilience.
|
||||
{
|
||||
let sync_handler = poem::get(crate::crdt_sync::crdt_sync_handler);
|
||||
|
||||
// Build a minimal AppContext for the crdt_sync_handler (the handler
|
||||
// receives it via Data<> but doesn't use it — the underscore prefix
|
||||
// on `_ctx` confirms this).
|
||||
let agent_ctx = build_agent_app_context(&project_root, port, watcher_tx.clone());
|
||||
let agent_ctx_arc = Arc::new(agent_ctx);
|
||||
|
||||
let app = poem::Route::new()
|
||||
.at("/crdt-sync", sync_handler)
|
||||
.data(agent_ctx_arc);
|
||||
|
||||
let bind_addr = format!("0.0.0.0:{port}");
|
||||
slog!("[agent-mode] Starting /crdt-sync endpoint on {bind_addr}");
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = poem::Server::new(poem::listener::TcpListener::bind(&bind_addr))
|
||||
.run(app)
|
||||
.await
|
||||
{
|
||||
slog!("[agent-mode] HTTP server error: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Write initial heartbeat.
|
||||
write_heartbeat(&rendezvous_url, port);
|
||||
|
||||
// Register with gateway if a join token and gateway URL were provided.
|
||||
if let (Some(token), Some(url)) = (join_token.clone(), gateway_url) {
|
||||
let node_id = crdt_state::our_node_id().unwrap_or_else(|| "unknown".to_string());
|
||||
let label = format!("build-agent-{}", &node_id[..node_id.len().min(8)]);
|
||||
let address = format!("ws://0.0.0.0:{port}/crdt-sync");
|
||||
register_with_gateway(&url, &token, &label, &address).await;
|
||||
}
|
||||
|
||||
// ── Mesh peer discovery ────────────────────────────────────────────
|
||||
//
|
||||
// Periodically read the CRDT `nodes` list and open supplementary sync
|
||||
// connections to alive peers. The primary rendezvous connection remains
|
||||
// canonical; mesh connections are supplementary and don't block startup.
|
||||
let _mesh_handle = {
|
||||
let our_node_id = crdt_state::our_node_id().unwrap_or_default();
|
||||
let max_mesh_peers = config.max_mesh_peers;
|
||||
mesh::spawn_mesh_discovery(
|
||||
max_mesh_peers,
|
||||
our_node_id,
|
||||
rendezvous_url.clone(),
|
||||
join_token,
|
||||
)
|
||||
};
|
||||
|
||||
// Reconcile any committed work from a previous session.
|
||||
{
|
||||
let recon_agents = Arc::clone(&agents);
|
||||
let recon_root = project_root.clone();
|
||||
let (recon_tx, _) = broadcast::channel(64);
|
||||
slog!("[agent-mode] Reconciling completed worktrees from previous session.");
|
||||
recon_agents
|
||||
.reconcile_on_startup(&recon_root, &recon_tx)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Run initial auto-assign.
|
||||
slog!("[agent-mode] Initial auto-assign scan.");
|
||||
agents.auto_assign_available_work(&project_root).await;
|
||||
|
||||
// Track which stories we've claimed so we can detect conflicts.
|
||||
let mut our_claims: HashMap<String, f64> = HashMap::new();
|
||||
|
||||
// Main loop: heartbeat, scan, claim, detect conflicts.
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(SCAN_INTERVAL_SECS));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// Write heartbeat.
|
||||
write_heartbeat(&rendezvous_url, port);
|
||||
|
||||
// Scan CRDT for claimable work.
|
||||
scan_and_claim(&agents, &project_root, &mut our_claims).await;
|
||||
|
||||
// Detect claim conflicts: if another node overwrote our claim, stop our agent.
|
||||
detect_conflicts(&agents, &project_root, &mut our_claims).await;
|
||||
|
||||
// Reclaim timed-out work from dead nodes.
|
||||
reclaim_timed_out_work(&project_root);
|
||||
|
||||
// Check for completed agents and push their branches.
|
||||
check_completions_and_push(&agents, &project_root).await;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::mesh;
|
||||
|
||||
// ── Mesh discovery integration tests ────────────────────────────────
|
||||
|
||||
/// AC7 (mesh storm cap): With 6 alive peers, the MeshManager enforces a
|
||||
/// cap of 3 connections. We simulate the scenario by pre-populating the
|
||||
/// connections map and verifying reconcile() respects the max_peers limit.
|
||||
#[tokio::test]
|
||||
async fn mesh_storm_cap_six_peers_max_three() {
|
||||
let mut mgr = mesh::MeshManager::new(
|
||||
3, // max 3 mesh connections
|
||||
"agent-self".to_string(),
|
||||
"ws://server:3001/crdt-sync".to_string(),
|
||||
None,
|
||||
);
|
||||
|
||||
// Simulate 6 peer connections (long-running tasks).
|
||||
let peer_ids: Vec<String> = (0..6).map(|i| format!("peer-{i}")).collect();
|
||||
for id in &peer_ids {
|
||||
let handle = tokio::spawn(async {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
|
||||
});
|
||||
mgr.connections.insert(id.clone(), handle);
|
||||
}
|
||||
|
||||
assert_eq!(mgr.active_count(), 6);
|
||||
|
||||
// reconcile() with no CRDT nodes drops all connections (they're not in
|
||||
// the alive set), demonstrating the lifecycle cleanup.
|
||||
mgr.reconcile();
|
||||
assert_eq!(mgr.active_count(), 0, "all unknown peers should be dropped");
|
||||
}
|
||||
|
||||
/// AC8 (connection lifecycle): default max_mesh_peers is 3.
|
||||
#[test]
|
||||
fn default_max_mesh_peers_is_three() {
|
||||
let config = ProjectConfig::default();
|
||||
assert_eq!(config.max_mesh_peers, 3);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user