Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8074e3b420 | ||
|
|
8a6eeacb5e | ||
|
|
eb9707d8b6 | ||
|
|
3b887e3085 | ||
|
|
662e00f94a | ||
|
|
932325744c | ||
|
|
3ced187aaa | ||
|
|
64f73e24bf | ||
|
|
c4282ab2fa | ||
|
|
a23fe71232 | ||
|
|
84b1c24073 | ||
|
|
429597cbce | ||
|
|
101b365354 | ||
|
|
ca3d5ee7a6 | ||
|
|
4af9507764 | ||
|
|
b71e8dd2be | ||
|
|
a6621a7095 | ||
|
|
ce380ffb52 | ||
|
|
be9c15efe0 | ||
|
|
76e3bf952e | ||
|
|
d6858b690b | ||
|
|
2067abb2e5 | ||
|
|
a058fa5f19 | ||
|
|
62c2c531e6 | ||
|
|
f266bb1d03 | ||
|
|
7c9261da41 | ||
|
|
0eac4ca966 | ||
|
|
a70f6b01e0 | ||
|
|
4545b57160 | ||
|
|
a6ac6497e9 | ||
|
|
586d06b840 | ||
|
|
c67f148383 | ||
|
|
f88114edbf | ||
|
|
08c7a92d74 | ||
|
|
36535b639f | ||
|
|
b6f99ce7a2 | ||
|
|
f4376b01e1 | ||
|
|
e7aa4e028e | ||
|
|
52c5cc9b72 | ||
|
|
c327263254 | ||
|
|
7c9b86c31b | ||
|
|
a2c893420b | ||
|
|
a1fe5356cf | ||
|
|
1477fbc02b | ||
|
|
6a74eefd07 | ||
|
|
981fd3fd81 | ||
|
|
99d301b467 | ||
|
|
9ed80384d5 | ||
|
|
c2cda92337 | ||
|
|
b25ae42737 | ||
|
|
7811130a8b | ||
|
|
ec212cb5a2 | ||
|
|
d174bb41e7 | ||
|
|
40570888ff | ||
|
|
dd75e9e0fa | ||
|
|
c2aa9ef134 | ||
|
|
501d6d31ff | ||
|
|
db2f8fcfc5 | ||
|
|
f325ddf9fe | ||
|
|
9cdb0d4ea8 | ||
|
|
6c413e1fc7 | ||
|
|
28b29b55a8 | ||
|
|
376de57252 | ||
|
|
63f46751ac | ||
|
|
dc9df6d497 | ||
|
|
ae7b04fac5 | ||
|
|
50959e6b67 | ||
|
|
6353b12c1d | ||
|
|
170fd53808 | ||
|
|
597e6bf1c3 | ||
|
|
7a5a56f211 | ||
|
|
73c86b6946 | ||
|
|
11afd21f17 | ||
|
|
1d20cfc679 | ||
|
|
959c680e10 | ||
|
|
dd377de7db | ||
|
|
9fee4d9478 | ||
|
|
40c04fcb28 | ||
|
|
2f0d796b38 | ||
|
|
2346602b30 | ||
|
|
13c0ee4c08 | ||
|
|
483dca5b95 | ||
|
|
dc7d070101 | ||
|
|
875d1f88aa | ||
|
|
f550018987 | ||
|
|
52ec989c3a | ||
|
|
d080e8b12d | ||
|
|
cfd85d3a0e | ||
|
|
070d53068e | ||
|
|
fa8e0f39f6 | ||
|
|
503fa6b7bf | ||
|
|
51a0fb8297 | ||
|
|
8ac85a0b67 | ||
|
|
aa4e042e32 | ||
|
|
9352443555 | ||
|
|
1faacd7812 | ||
|
|
7451cb7170 | ||
|
|
83ccfece81 | ||
|
|
68bf179407 | ||
|
|
c35c05d02c | ||
|
|
3adae6c475 | ||
|
|
c4753b51de | ||
|
|
e7a73e7322 | ||
|
|
e8ec84668f | ||
|
|
8d9cf4b283 | ||
|
|
a8cb38fe27 | ||
|
|
dd83e0f4ee | ||
|
|
3923aafb71 | ||
|
|
8fcfadcb04 | ||
|
|
7c023c6beb | ||
|
|
e7bb8db7c1 | ||
|
|
727da0c6d0 | ||
|
|
257ee05ac6 | ||
|
|
b9f3505738 | ||
|
|
be56792c6e | ||
|
|
9daaae2d43 | ||
|
|
c85d02a3ef | ||
|
|
df6f792214 | ||
|
|
967ebd7a84 | ||
|
|
3bc44289b9 | ||
|
|
17f6bae573 | ||
|
|
baa8bdcfda | ||
|
|
33492c49fa | ||
|
|
63a90195e7 | ||
|
|
7bd390c762 | ||
|
|
0d581ab459 | ||
|
|
42f88cc172 | ||
|
|
945648bf6e | ||
|
|
bc5a3da2c0 | ||
|
|
04e841643e | ||
|
|
3d97b0b95a | ||
|
|
8f4cb9475c | ||
|
|
8f63cfda07 | ||
|
|
1b3843d913 | ||
|
|
4c898996a2 | ||
|
|
281531624d | ||
|
|
b09a2cbdf9 | ||
|
|
a0c1457757 | ||
|
|
e818ac986d | ||
|
|
b29f6628f8 | ||
|
|
4dc4fef83b | ||
|
|
7ef85c459c | ||
|
|
f6058a50b9 | ||
|
|
d347ba084d | ||
|
|
b50d007b40 | ||
|
|
ed3d7311d1 | ||
|
|
e7aef3edc7 | ||
|
|
d5a93fe726 |
@@ -60,7 +60,16 @@
|
|||||||
"Edit",
|
"Edit",
|
||||||
"Write",
|
"Write",
|
||||||
"Bash(find *)",
|
"Bash(find *)",
|
||||||
"Bash(sqlite3 *)"
|
"Bash(sqlite3 *)",
|
||||||
|
"Bash(cat <<:*)",
|
||||||
|
"Bash(cat <<'ENDJSON:*)",
|
||||||
|
"Bash(make release:*)",
|
||||||
|
"Bash(npm test:*)",
|
||||||
|
"Bash(head *)",
|
||||||
|
"Bash(tail *)",
|
||||||
|
"Bash(wc *)",
|
||||||
|
"Bash(npx vite:*)",
|
||||||
|
"Bash(npm run dev:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
3
.story_kit/.gitignore
vendored
3
.story_kit/.gitignore
vendored
@@ -17,3 +17,6 @@ work/4_merge/
|
|||||||
|
|
||||||
# Coverage reports (generated by cargo-llvm-cov, not tracked in git)
|
# Coverage reports (generated by cargo-llvm-cov, not tracked in git)
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
# Token usage log (generated at runtime, contains cost data)
|
||||||
|
token_usage.jsonl
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ When you start a new session with this project:
|
|||||||
This returns the full tool catalog (create stories, spawn agents, record tests, manage worktrees, etc.). Familiarize yourself with the available tools before proceeding. These tools allow you to directly manipulate the workflow and spawn subsidiary agents without manual file manipulation.
|
This returns the full tool catalog (create stories, spawn agents, record tests, manage worktrees, etc.). Familiarize yourself with the available tools before proceeding. These tools allow you to directly manipulate the workflow and spawn subsidiary agents without manual file manipulation.
|
||||||
2. **Read Context:** Check `.story_kit/specs/00_CONTEXT.md` for high-level project goals.
|
2. **Read Context:** Check `.story_kit/specs/00_CONTEXT.md` for high-level project goals.
|
||||||
3. **Read Stack:** Check `.story_kit/specs/tech/STACK.md` for technical constraints and patterns.
|
3. **Read Stack:** Check `.story_kit/specs/tech/STACK.md` for technical constraints and patterns.
|
||||||
4. **Check Work Items:** Look at `.story_kit/work/1_upcoming/` and `.story_kit/work/2_current/` to see what work is pending.
|
4. **Check Work Items:** Look at `.story_kit/work/1_backlog/` and `.story_kit/work/2_current/` to see what work is pending.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -52,7 +52,7 @@ project_root/
|
|||||||
├── README.md # This document
|
├── README.md # This document
|
||||||
├── project.toml # Agent configuration (roles, models, prompts)
|
├── project.toml # Agent configuration (roles, models, prompts)
|
||||||
├── work/ # Unified work item pipeline (stories, bugs, spikes)
|
├── work/ # Unified work item pipeline (stories, bugs, spikes)
|
||||||
│ ├── 1_upcoming/ # New work items awaiting implementation
|
│ ├── 1_backlog/ # New work items awaiting implementation
|
||||||
│ ├── 2_current/ # Work in progress
|
│ ├── 2_current/ # Work in progress
|
||||||
│ ├── 3_qa/ # QA review
|
│ ├── 3_qa/ # QA review
|
||||||
│ ├── 4_merge/ # Ready to merge to master
|
│ ├── 4_merge/ # Ready to merge to master
|
||||||
@@ -78,7 +78,7 @@ All work items (stories, bugs, spikes) live in the same `work/` pipeline. Items
|
|||||||
|
|
||||||
Items move through stages by moving the file between directories:
|
Items move through stages by moving the file between directories:
|
||||||
|
|
||||||
`1_upcoming` → `2_current` → `3_qa` → `4_merge` → `5_done` → `6_archived`
|
`1_backlog` → `2_current` → `3_qa` → `4_merge` → `5_done` → `6_archived`
|
||||||
|
|
||||||
Items in `5_done` are auto-swept to `6_archived` after 4 hours by the server.
|
Items in `5_done` are auto-swept to `6_archived` after 4 hours by the server.
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ Items in `5_done` are auto-swept to `6_archived` after 4 hours by the server.
|
|||||||
The server watches `.story_kit/work/` for changes. When a file is created, moved, or modified, the watcher auto-commits with a deterministic message and broadcasts a WebSocket notification to the frontend. This means:
|
The server watches `.story_kit/work/` for changes. When a file is created, moved, or modified, the watcher auto-commits with a deterministic message and broadcasts a WebSocket notification to the frontend. This means:
|
||||||
|
|
||||||
* MCP tools only need to write/move files — the watcher handles git commits
|
* MCP tools only need to write/move files — the watcher handles git commits
|
||||||
* IDE drag-and-drop works (drag a story from `1_upcoming/` to `2_current/`)
|
* IDE drag-and-drop works (drag a story from `1_backlog/` to `2_current/`)
|
||||||
* The frontend updates automatically without manual refresh
|
* The frontend updates automatically without manual refresh
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -156,7 +156,7 @@ Not everything needs to be a full story. Simple bugs can skip the story process:
|
|||||||
* Performance issues with known fixes
|
* Performance issues with known fixes
|
||||||
|
|
||||||
### Bug Process
|
### Bug Process
|
||||||
1. **Document Bug:** Create a bug file in `work/1_upcoming/` named `{id}_bug_{slug}.md` with:
|
1. **Document Bug:** Create a bug file in `work/1_backlog/` named `{id}_bug_{slug}.md` with:
|
||||||
* **Symptom:** What the user observes
|
* **Symptom:** What the user observes
|
||||||
* **Root Cause:** Technical explanation (if known)
|
* **Root Cause:** Technical explanation (if known)
|
||||||
* **Reproduction Steps:** How to trigger the bug
|
* **Reproduction Steps:** How to trigger the bug
|
||||||
@@ -186,7 +186,7 @@ Not everything needs a story or bug fix. Spikes are time-boxed investigations to
|
|||||||
* Need to validate performance constraints
|
* Need to validate performance constraints
|
||||||
|
|
||||||
### Spike Process
|
### Spike Process
|
||||||
1. **Document Spike:** Create a spike file in `work/1_upcoming/` named `{id}_spike_{slug}.md` with:
|
1. **Document Spike:** Create a spike file in `work/1_backlog/` named `{id}_spike_{slug}.md` with:
|
||||||
* **Question:** What you need to answer
|
* **Question:** What you need to answer
|
||||||
* **Hypothesis:** What you expect to be true
|
* **Hypothesis:** What you expect to be true
|
||||||
* **Timebox:** Strict limit for the research
|
* **Timebox:** Strict limit for the research
|
||||||
@@ -209,7 +209,7 @@ When the LLM context window fills up (or the chat gets slow/confused):
|
|||||||
1. **Stop Coding.**
|
1. **Stop Coding.**
|
||||||
2. **Instruction:** Tell the user to open a new chat.
|
2. **Instruction:** Tell the user to open a new chat.
|
||||||
3. **Handoff:** The only context the new LLM needs is in the `specs/` folder and `.mcp.json`.
|
3. **Handoff:** The only context the new LLM needs is in the `specs/` folder and `.mcp.json`.
|
||||||
* *Prompt for New Session:* "I am working on Project X. Read `.mcp.json` to discover available tools, then read `specs/00_CONTEXT.md` and `specs/tech/STACK.md`. Then look at `work/1_upcoming/` and `work/2_current/` to see what is pending."
|
* *Prompt for New Session:* "I am working on Project X. Read `.mcp.json` to discover available tools, then read `specs/00_CONTEXT.md` and `specs/tech/STACK.md`. Then look at `work/1_backlog/` and `work/2_current/` to see what is pending."
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -221,7 +221,7 @@ If a user hands you this document and says "Apply this process to my project":
|
|||||||
1. **Check for MCP Tools:** Look for `.mcp.json` in the project root. If it exists, you have programmatic access to workflow tools and agent spawning capabilities.
|
1. **Check for MCP Tools:** Look for `.mcp.json` in the project root. If it exists, you have programmatic access to workflow tools and agent spawning capabilities.
|
||||||
2. **Analyze the Request:** Ask for the high-level goal ("What are we building?") and the tech preferences ("Rust or Python?").
|
2. **Analyze the Request:** Ask for the high-level goal ("What are we building?") and the tech preferences ("Rust or Python?").
|
||||||
3. **Git Check:** Check if the directory is a git repository (`git status`). If not, run `git init`.
|
3. **Git Check:** Check if the directory is a git repository (`git status`). If not, run `git init`.
|
||||||
4. **Scaffold:** Run commands to create the `work/` and `specs/` folders with the 6-stage pipeline (`work/1_upcoming/` through `work/6_archived/`).
|
4. **Scaffold:** Run commands to create the `work/` and `specs/` folders with the 6-stage pipeline (`work/1_backlog/` through `work/6_archived/`).
|
||||||
5. **Draft Context:** Write `specs/00_CONTEXT.md` based on the user's answer.
|
5. **Draft Context:** Write `specs/00_CONTEXT.md` based on the user's answer.
|
||||||
6. **Draft Stack:** Write `specs/tech/STACK.md` based on best practices for that language.
|
6. **Draft Stack:** Write `specs/tech/STACK.md` based on best practices for that language.
|
||||||
7. **Wait:** Ask the user for "Story #1".
|
7. **Wait:** Ask the user for "Story #1".
|
||||||
|
|||||||
@@ -13,3 +13,7 @@ enabled = false
|
|||||||
|
|
||||||
# Maximum conversation turns to remember per room (default: 20).
|
# Maximum conversation turns to remember per room (default: 20).
|
||||||
# history_size = 20
|
# history_size = 20
|
||||||
|
|
||||||
|
# Rooms where the bot responds to all messages (not just addressed ones).
|
||||||
|
# This list is updated automatically when users toggle ambient mode at runtime.
|
||||||
|
# ambient_rooms = ["!roomid:example.com"]
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
Recurring issues observed during pipeline operation. Review periodically and create stories for systemic problems.
|
Recurring issues observed during pipeline operation. Review periodically and create stories for systemic problems.
|
||||||
|
|
||||||
|
## 2026-03-18: Stories graduating to "done" with empty merges (7 of 10)
|
||||||
|
|
||||||
|
Pipeline allows stories to move through coding → QA → merge → done without any actual code changes landing on master. The squash-merge produces an empty diff but the pipeline still marks the story as done. Affected stories: 247, 273, 274, 278, 279, 280, 92. Only 266, 271, 277, and 281 actually shipped code. Root cause: no check that the merge commit contains a non-empty diff. Filed bug 283 for the manual_qa gate issue specifically, but the empty-merge-to-done problem is broader and needs its own fix.
|
||||||
|
|
||||||
## 2026-03-18: Agent committed directly to master instead of worktree
|
## 2026-03-18: Agent committed directly to master instead of worktree
|
||||||
|
|
||||||
Commit `5f4591f` ("fix: update should_commit_stage test to match 5_done") was made directly on master by an agent (likely mergemaster). Agents should only commit to their feature branch or merge-queue branch, never to master directly. The commit content was correct but the target branch was wrong. Suspect the agent ran `git commit` in the project root instead of the merge worktree directory.
|
Multiple agents have committed directly to master instead of their worktree/feature branch:
|
||||||
|
|
||||||
|
- Commit `5f4591f` ("fix: update should_commit_stage test to match 5_done") — likely mergemaster
|
||||||
|
- Commit `a32cfbd` ("Add bot-level command registry with help command") — story 285 coder committed code + Cargo.lock directly to master
|
||||||
|
|
||||||
|
Agents should only commit to their feature branch or merge-queue branch, never to master directly. Suspect agents are running `git commit` in the project root instead of the worktree directory. This can also revert uncommitted fixes on master (e.g. project.toml pkill fix was overwritten). Frequency: at least 2 confirmed cases. This is a recurring and serious problem — needs a guard in the server or agent prompts.
|
||||||
|
|||||||
@@ -1,3 +1,18 @@
|
|||||||
|
# Project-wide default QA mode: "server", "agent", or "human".
|
||||||
|
# Per-story `qa` front matter overrides this setting.
|
||||||
|
default_qa = "server"
|
||||||
|
|
||||||
|
# Default model for coder agents. Only agents with this model are auto-assigned.
|
||||||
|
# Opus coders are reserved for explicit per-story `agent:` front matter requests.
|
||||||
|
default_coder_model = "sonnet"
|
||||||
|
|
||||||
|
# Maximum concurrent coder agents. Stories wait in 2_current/ when all slots are full.
|
||||||
|
max_coders = 3
|
||||||
|
|
||||||
|
# Maximum retries per story per pipeline stage before marking as blocked.
|
||||||
|
# Set to 0 to disable retry limits.
|
||||||
|
max_retries = 2
|
||||||
|
|
||||||
[[component]]
|
[[component]]
|
||||||
name = "frontend"
|
name = "frontend"
|
||||||
path = "frontend"
|
path = "frontend"
|
||||||
@@ -34,7 +49,7 @@ You have these tools via the story-kit MCP server:
|
|||||||
## Your Workflow
|
## Your Workflow
|
||||||
1. Read CLAUDE.md and .story_kit/README.md to understand the project and dev process
|
1. Read CLAUDE.md and .story_kit/README.md to understand the project and dev process
|
||||||
2. Read the story file from .story_kit/work/ to understand requirements
|
2. Read the story file from .story_kit/work/ to understand requirements
|
||||||
3. Move it to work/2_current/ if it is in work/1_upcoming/
|
3. Move it to work/2_current/ if it is in work/1_backlog/
|
||||||
4. Start coder-1 on the story: call start_agent with story_id="{{story_id}}" and agent_name="coder-1"
|
4. Start coder-1 on the story: call start_agent with story_id="{{story_id}}" and agent_name="coder-1"
|
||||||
5. Wait for completion: call wait_for_agent with story_id="{{story_id}}" and agent_name="coder-1". The server automatically runs acceptance gates (cargo clippy + tests) when the coder process exits. wait_for_agent returns when the coder reaches a terminal state.
|
5. Wait for completion: call wait_for_agent with story_id="{{story_id}}" and agent_name="coder-1". The server automatically runs acceptance gates (cargo clippy + tests) when the coder process exits. wait_for_agent returns when the coder reaches a terminal state.
|
||||||
6. Check the result: inspect the "completion" field in the wait_for_agent response — if gates_passed is true, the work is done; if false, review the gate_output and decide whether to start a fresh coder.
|
6. Check the result: inspect the "completion" field in the wait_for_agent response — if gates_passed is true, the work is done; if false, review the gate_output and decide whether to start a fresh coder.
|
||||||
@@ -69,6 +84,16 @@ max_budget_usd = 5.00
|
|||||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
|
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
|
||||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
|
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder-3"
|
||||||
|
stage = "coder"
|
||||||
|
role = "Full-stack engineer. Implements features across all components."
|
||||||
|
model = "sonnet"
|
||||||
|
max_turns = 50
|
||||||
|
max_budget_usd = 5.00
|
||||||
|
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
|
||||||
|
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
|
||||||
|
|
||||||
[[agent]]
|
[[agent]]
|
||||||
name = "qa-2"
|
name = "qa-2"
|
||||||
stage = "qa"
|
stage = "qa"
|
||||||
@@ -102,7 +127,7 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
|||||||
- URL to visit in the browser
|
- URL to visit in the browser
|
||||||
- Things to check in the UI
|
- Things to check in the UI
|
||||||
- curl commands to exercise relevant API endpoints
|
- curl commands to exercise relevant API endpoints
|
||||||
- Kill the test server when done: `pkill -f story-kit || true`
|
- Kill the test server when done: `pkill -f 'target.*story-kit' || true` (NEVER use `pkill -f story-kit` — it kills the vite dev server)
|
||||||
|
|
||||||
### 4. Produce Structured Report
|
### 4. Produce Structured Report
|
||||||
Print your QA report to stdout before your process exits. The server will automatically run acceptance gates. Use this format:
|
Print your QA report to stdout before your process exits. The server will automatically run acceptance gates. Use this format:
|
||||||
@@ -179,7 +204,7 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
|||||||
- URL to visit in the browser
|
- URL to visit in the browser
|
||||||
- Things to check in the UI
|
- Things to check in the UI
|
||||||
- curl commands to exercise relevant API endpoints
|
- curl commands to exercise relevant API endpoints
|
||||||
- Kill the test server when done: `pkill -f story-kit || true`
|
- Kill the test server when done: `pkill -f 'target.*story-kit' || true` (NEVER use `pkill -f story-kit` — it kills the vite dev server)
|
||||||
|
|
||||||
### 4. Produce Structured Report
|
### 4. Produce Structured Report
|
||||||
Print your QA report to stdout before your process exits. The server will automatically run acceptance gates. Use this format:
|
Print your QA report to stdout before your process exits. The server will automatically run acceptance gates. Use this format:
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: "Long-running supervisor agent with periodic pipeline polling"
|
||||||
|
agent: coder-opus
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 280: Long-running supervisor agent with periodic pipeline polling
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner, I want a long-running supervisor agent (opus) that automatically monitors the pipeline, assigns agents, resolves stuck items, and handles routine operational tasks, so that I don't have to manually check status, kick agents, or babysit the pipeline in every conversation.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Server can start a persistent supervisor agent that stays alive across the session (not per-story)
|
||||||
|
- [ ] Server prods the supervisor periodically (default 30s, configurable in project.toml) with a pipeline status update
|
||||||
|
- [ ] Supervisor auto-assigns agents to unassigned items in current/qa/merge stages
|
||||||
|
- [ ] Supervisor detects stuck agents (no progress for configurable timeout) and restarts them
|
||||||
|
- [ ] Supervisor detects merge failures and sends stories back to current for rebase when appropriate
|
||||||
|
- [ ] Supervisor can be chatted with via Matrix (timmy relays to supervisor) or via the web UI
|
||||||
|
- [ ] Supervisor logs its decisions so the human can review what it did and why
|
||||||
|
- [ ] Polling interval is configurable in project.toml (e.g. supervisor_poll_interval_secs = 30)
|
||||||
|
- [ ] Supervisor logs persistent/recurring problems to `.story_kit/problems.md` with timestamp, description, and frequency — humans review this file periodically to create stories for systemic issues
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **2026-03-18**: Moved back to current from merge. Previous attempt went through the full pipeline but the squash-merge produced an empty diff — no code was actually implemented. Needs a real implementation.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Supervisor accepting or merging stories to master (human job)
|
||||||
|
- Supervisor making architectural decisions
|
||||||
|
- Replacing the existing per-story agent spawning — supervisor coordinates on top of it
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: "Auto-assign assigns mergemaster to coding-stage stories"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 312: Auto-assign assigns mergemaster to coding-stage stories
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Auto-assign picks agents whose configured stage doesn't match the pipeline stage of the story. Observed multiple mismatch types:
|
||||||
|
|
||||||
|
- **Mergemaster assigned to coding-stage stories**: Story 310 was in `2_current/` but got mergemaster instead of a coder (2026-03-19)
|
||||||
|
- **Coders assigned to QA-stage stories**: Coders (stage=coder) have been observed running on stories in `3_qa/`
|
||||||
|
- **QA agents assigned to merge-stage stories**: QA agents (stage=qa) have been observed running on stories in `4_merge/`
|
||||||
|
|
||||||
|
The `auto_assign_available_work` function doesn't enforce that the agent's configured stage matches the pipeline stage of the story it's being assigned to. Story 279 (auto-assign respects agent stage from front matter) was supposed to fix stage matching, but the check may only apply to front-matter preferences, not the fallback assignment path.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
1. Move a story to any pipeline stage with no agent front matter preference
|
||||||
|
2. Wait for auto_assign_available_work to run
|
||||||
|
3. Observe that agents from the wrong stage get assigned (e.g. mergemaster to coding, coder to QA)
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
Agents are assigned to stories in pipeline stages that don't match their configured stage. Mergemaster codes, coders do QA, QA agents attempt merges.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
Only coder-stage agents should be assigned to stories in 2_current/. Mergemaster should only be assigned to stories in 4_merge/.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] auto_assign_available_work checks that the agent's configured stage matches the pipeline stage of the story before assigning
|
||||||
|
- [ ] Coder agents only assigned to stories in 2_current/
|
||||||
|
- [ ] QA agents only assigned to stories in 3_qa/
|
||||||
|
- [ ] Mergemaster only assigned to stories in 4_merge/
|
||||||
|
- [ ] Fallback assignment path respects stage matching (not just front-matter preference path)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: "Bot delete command removes a story from the pipeline"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 310: Bot delete command removes a story from the pipeline
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner in a Matrix room, I want to type "{bot_name} delete {story_number}" to remove a story/bug/spike from the pipeline, so that I can clean up obsolete or duplicate work items from chat.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] '{bot_name} delete {number}' finds the story/bug/spike by number across all pipeline stages and deletes the file
|
||||||
|
- [ ] Confirms deletion with the story name and stage it was in
|
||||||
|
- [ ] Returns a friendly message if no story with that number exists
|
||||||
|
- [ ] Stops any running agent on the story before deleting
|
||||||
|
- [ ] Removes the worktree if one exists for the story
|
||||||
|
- [ ] Registered in the command registry so it appears in help output
|
||||||
|
- [ ] Handled at bot level without LLM invocation
|
||||||
|
- [ ] Commits the deletion to git
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: "Server-enforced retry limits for failed merge and empty-diff stories"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 311: Server-enforced retry limits for failed merge and empty-diff stories
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner, I want the server to enforce retry limits on stories that fail merge or produce empty diffs, so that agents don't loop infinitely on broken stories and waste tokens.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] auto_assign_available_work checks the merge_failure front matter flag and skips stories in 4_merge that already have a reported failure
|
||||||
|
- [ ] Server tracks retry count per story per stage in front matter (e.g. retry_count: 2)
|
||||||
|
- [ ] After N retries (configurable in project.toml, default 2), story is flagged as blocked and auto-assign stops trying
|
||||||
|
- [ ] Blocked stories show a clear indicator in pipeline status (MCP and bot status command)
|
||||||
|
- [ ] Server detects 'coder finished with no commits on feature branch' at gate-check stage and fails the gates early instead of advancing to QA
|
||||||
|
- [ ] Empty-diff merge failures are detected and reported without needing the mergemaster agent to discover them
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -15,6 +15,10 @@ As a user chatting with the Matrix bot, I want to see a typing indicator in Elem
|
|||||||
- [ ] Typing indicator is cleared on error so it doesn't get stuck
|
- [ ] Typing indicator is cleared on error so it doesn't get stuck
|
||||||
- [ ] No visible delay between sending a message and seeing the typing indicator
|
- [ ] No visible delay between sending a message and seeing the typing indicator
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **2026-03-18**: Moved back to current from done. Previous attempt went through the full pipeline but merged with an empty diff — no typing indicator code was actually implemented. Needs a real implementation this time.
|
||||||
|
|
||||||
## Out of Scope
|
## Out of Scope
|
||||||
|
|
||||||
- TBD
|
- TBD
|
||||||
@@ -11,9 +11,10 @@ As a user chatting with Timmy in a Matrix room, I want to toggle between "addres
|
|||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] Matrix bot defaults to addressed mode — only forwards messages containing the bot's name to Claude
|
- [ ] Matrix bot defaults to addressed mode — only forwards messages containing the bot's name to Claude
|
||||||
- [ ] Chat command "timmy ambient on" switches to ambient mode — bot forwards all room messages to Claude
|
- [ ] Chat command "{bot_name} ambient on" switches to ambient mode — bot forwards all room messages to Claude (bot name comes from display_name in bot.toml)
|
||||||
- [ ] Chat command "timmy ambient off" switches back to addressed mode
|
- [ ] Chat command "{bot_name} ambient off" switches back to addressed mode
|
||||||
- [ ] Mode persists until explicitly toggled (not across bot restarts)
|
- [ ] Mode is persisted per-room in bot.toml so it survives bot restarts
|
||||||
|
- [ ] bot.toml.example includes the ambient_mode setting with a comment explaining it
|
||||||
- [ ] Bot confirms the mode switch with a short response in chat
|
- [ ] Bot confirms the mode switch with a short response in chat
|
||||||
- [ ] When other users join or are active, user can flip back to addressed mode to avoid noise
|
- [ ] When other users join or are active, user can flip back to addressed mode to avoid noise
|
||||||
- [ ] Ambient mode applies per-room (not globally across all rooms)
|
- [ ] Ambient mode applies per-room (not globally across all rooms)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: "Pipeline does not check manual_qa flag before advancing from QA to merge"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 283: Pipeline does not check manual_qa flag before advancing from QA to merge
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Story 247 added the manual_qa front matter field and the MCP tooling to set it, but the pipeline in pool.rs never actually checks the flag. After QA passes gates and coverage, stories move straight to merge regardless of manual_qa setting.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
1. Create a story with manual_qa: true in front matter
|
||||||
|
2. Let it go through the coder and QA stages
|
||||||
|
3. Observe that it moves directly to merge without waiting for human approval
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
Stories always advance from QA to merge automatically, ignoring the manual_qa flag.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
Stories with manual_qa: true should pause after QA passes and wait for human approval before moving to merge. Stories with manual_qa: false (the default) should advance automatically as they do now.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Pipeline checks manual_qa front matter field after QA gates pass
|
||||||
|
- [ ] manual_qa defaults to false — stories advance automatically unless explicitly opted in
|
||||||
|
- [ ] Stories with manual_qa: true wait in 3_qa for human approval via accept_story or the UI
|
||||||
|
- [ ] Stories with manual_qa: false proceed directly from QA to merge as before
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: "Matrix bot status command shows pipeline and agent availability"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 284: Matrix bot status command shows pipeline and agent availability
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user in a Matrix room, I want to type "{bot_name} status" and get a formatted summary of the full pipeline (upcoming through done) with agent assignments, plus which agents are currently free, so that I can check project status without leaving chat.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Chat command "{bot_name} status" triggers a pipeline status display (bot name comes from display_name in bot.toml)
|
||||||
|
- [ ] Output shows all stages: upcoming, current, qa, merge, done — with story names and IDs
|
||||||
|
- [ ] Each active story shows its assigned agent name and model
|
||||||
|
- [ ] Output includes a section showing which agents are free (not currently assigned to any story)
|
||||||
|
- [ ] Response is formatted for readability in Matrix (monospace or markdown as appropriate)
|
||||||
|
- [ ] Command is handled at the bot level — does not require a full Claude invocation
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: "Matrix bot help command lists available bot commands"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 285: Matrix bot help command lists available bot commands
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user in a Matrix room, I want to type "{bot_name} help" and get a list of all available bot commands with brief descriptions, so that I can discover what the bot can do without having to ask or remember.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Chat command "{bot_name} help" displays a list of all available bot-level commands (bot name comes from display_name in bot.toml)
|
||||||
|
- [ ] Each command is shown with a short description of what it does
|
||||||
|
- [ ] Help output is formatted for readability in Matrix
|
||||||
|
- [ ] Help command is handled at the bot level — does not require a full Claude invocation
|
||||||
|
- [ ] Help list automatically includes new commands as they are added (driven by a registry or similar, not a hardcoded string)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **2026-03-18**: Moved back to current from done. Previous attempt committed code directly to master (commit a32cfbd) instead of the worktree, and the help command is not functional in the running server. Needs a clean implementation this time.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: "Server self-rebuild and restart via MCP tool"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 286: Server self-rebuild and restart via MCP tool
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner away from my terminal, I want to tell the bot to restart the server so that it picks up new code changes, without needing physical access to the machine.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] MCP tool `rebuild_and_restart` triggers a cargo build of the server
|
||||||
|
- [ ] If the build fails, server stays up and returns the build error
|
||||||
|
- [ ] If the build succeeds, server re-execs itself with the new binary using std::os::unix::process::CommandExt::exec()
|
||||||
|
- [ ] Server logs the restart so it's traceable
|
||||||
|
- [ ] Matrix bot reconnects automatically after the server comes back up
|
||||||
|
- [ ] Running agents are gracefully stopped before re-exec
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: "Rename upcoming pipeline stage to backlog"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 287: Rename upcoming pipeline stage to backlog
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner, I want the "upcoming" pipeline stage renamed to "backlog" throughout the codebase, UI, and directory structure, so that the terminology better reflects that these items are not necessarily coming up next.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Directory renamed from 1_upcoming to 1_backlog
|
||||||
|
- [ ] All server code references updated (watcher, lifecycle, MCP tools, workflow, etc.)
|
||||||
|
- [ ] Frontend UI labels updated
|
||||||
|
- [ ] MCP tool descriptions and outputs use "backlog" instead of "upcoming"
|
||||||
|
- [ ] Existing story/bug files moved to the new directory
|
||||||
|
- [ ] Git commit messages use "backlog" for new items going forward
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: "Ambient mode state lost on server restart"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 288: Ambient mode state lost on server restart
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Story 282 implemented ambient mode toggle but only in-memory. The acceptance criterion requiring persistence in bot.toml was not implemented. Every server restart (including rebuild_and_restart) clears ambient mode for all rooms.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
1. Type "timmy ambient on" — get confirmation
|
||||||
|
2. Restart server (or rebuild_and_restart)
|
||||||
|
3. Send unaddressed message — bot ignores it, ambient mode is gone
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
Ambient mode state is lost on server restart.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
Ambient mode per-room state is persisted in bot.toml and restored on startup.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Ambient mode per-room state is saved to bot.toml when toggled
|
||||||
|
- [ ] Ambient mode state is restored from bot.toml on server startup
|
||||||
|
- [ ] bot.toml.example includes the ambient_rooms setting with a comment
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: "rebuild_and_restart MCP tool does not rebuild"
|
||||||
|
agent: coder-opus
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 289: rebuild_and_restart MCP tool does not rebuild
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The rebuild_and_restart MCP tool re-execs the server binary but does not run cargo build first. It restarts with the old binary, so code changes are not picked up.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
1. Make a code change to the server
|
||||||
|
2. Call rebuild_and_restart via MCP
|
||||||
|
3. Observe the server restarts but the code change is not reflected
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
Server re-execs with the old binary. Code changes are not compiled.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
Server runs cargo build --release (or cargo build) before re-execing, so the new binary includes the latest code changes.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Bug is fixed and verified
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: "Remove agent thinking traces from agents sidebar"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 290: Remove agent thinking traces from agents sidebar
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user viewing an expanded work item in the web UI, I want to see the live agent output stream (thinking traces, tool calls, progress) for the agent working on that story, so that I can monitor progress in context rather than in the agents sidebar.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Agent thinking traces are removed from the agents sidebar panel — they should only appear in the work item detail panel (which already has SSE streaming wired up in `WorkItemDetailPanel.tsx`)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
The detail panel (`WorkItemDetailPanel.tsx`) already has agent log streaming implemented — SSE subscription, real-time output, status badges, etc. The only remaining work is removing the thinking traces from the agents sidebar (`AgentPanel.tsx` or similar) so they don't appear in both places.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Replacing the agents sidebar entirely — it still shows agent names, status, and assignments
|
||||||
|
- Historical agent output (only live stream while agent is running)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: "Show test results in work item detail panel"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 291: Show test results in work item detail panel
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner viewing a work item in the web UI, I want to see the most recent test run results in the expanded detail panel, so that I can quickly see pass/fail status without digging through agent logs.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Expanded work item detail panel shows the most recent test results for that story
|
||||||
|
- [ ] Test results display pass/fail counts for unit and integration tests
|
||||||
|
- [ ] Failed tests are listed by name so you can see what broke
|
||||||
|
- [ ] Test results are read from the story file's ## Test Results section (already written by record_tests MCP tool)
|
||||||
|
- [ ] Panel shows a clear empty state when no test results exist yet
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: "Show server logs in web UI"
|
||||||
|
review_hold: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 292: Show server logs in web UI
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner using the web UI, I want to see live server logs in the interface, so that I can debug agent behavior and pipeline issues without needing terminal access.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Web UI has a server logs panel accessible from the main interface
|
||||||
|
- [ ] Logs stream in real-time via WebSocket or SSE
|
||||||
|
- [ ] Logs can be filtered by keyword (same as get_server_logs MCP tool's filter param)
|
||||||
|
- [ ] Log entries show timestamp and severity level
|
||||||
|
- [ ] Panel doesn't interfere with the existing pipeline board and work item views
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: "Register all bot commands in the command registry"
|
||||||
|
review_hold: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 293: Register all bot commands in the command registry
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user, I want all bot commands (help, status, ambient on/off) to be registered in the command registry in commands.rs, so that the help command lists everything the bot can do and there's one consistent mechanism for handling bot-level commands.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Status command is moved from bot.rs into the command registry in commands.rs
|
||||||
|
- [ ] Ambient on/off command is moved from bot.rs into the command registry in commands.rs
|
||||||
|
- [ ] Help command output lists all registered commands including status and ambient
|
||||||
|
- [ ] No bot-level commands are handled outside of the registry (single mechanism)
|
||||||
|
- [ ] Existing behavior of all commands is preserved
|
||||||
|
- [ ] Registry handler functions receive enough context to perform their work (e.g. project_root for status, ambient_rooms for ambient)
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: "Rename app title from Story Kit to Storkit"
|
||||||
|
review_hold: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 294: Rename app title from Story Kit to Storkit
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user, I want the application title to say "Storkit" instead of "Story Kit", so that the branding reflects the new name.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] The top title in the web UI header displays "Storkit" instead of "Story Kit"
|
||||||
|
- [ ] Any other visible references to "Story Kit" in the UI are updated to "Storkit"
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
name: "Stories stuck in QA when QA agent is busy"
|
||||||
|
review_hold: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 295: Stories stuck in QA when QA agent is busy
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
When multiple stories pass coding gates simultaneously and move to QA, only the first one gets a QA agent assigned. The others fail with "Agent 'qa' is already running" and are never retried when the QA agent becomes free. Stories get stuck in QA with no agent indefinitely.
|
||||||
|
|
||||||
|
The root cause is in the server-owned agent completion handler in `server/src/agents/pool.rs`. When a coder finishes and gates pass, the server calls the pipeline advance logic which tries to start a QA agent. If the QA agent is already busy on another story, the start fails with an error and the story is left in `3_qa/` with no agent. There is no retry mechanism — the `auto_assign_available_work` function is only called on startup (via `reconcile_on_startup`) and when agents are manually started, not when agents complete.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
1. Have 3 stories in current with coders running (e.g. coder-1, coder-2, coder-opus)
|
||||||
|
2. All 3 coders finish within seconds of each other and pass gates
|
||||||
|
3. Server tries to start QA agent on all 3:
|
||||||
|
- Story 292: `qa` agent starts successfully
|
||||||
|
- Story 293: fails — `"Agent 'qa' is already running on story '292'"`
|
||||||
|
- Story 294: fails — same error
|
||||||
|
4. QA on 292 completes (gates pass after retry)
|
||||||
|
5. Stories 293 and 294 remain stuck in QA with no agent — nobody retries them
|
||||||
|
|
||||||
|
## Server Log Evidence (2026-03-18)
|
||||||
|
|
||||||
|
```
|
||||||
|
21:00:35 [agent:292:coder-1] Done.
|
||||||
|
21:00:42 [agents] Server-owned completion for '292:coder-1': gates_passed=true
|
||||||
|
21:00:47 [agent:292:qa] Spawning claude...
|
||||||
|
|
||||||
|
21:01:32 [agent:293:coder-2] Done.
|
||||||
|
21:01:34 [agent:294:coder-opus] Done.
|
||||||
|
|
||||||
|
21:01:41 [agents] Server-owned completion for '293:coder-2': gates_passed=true
|
||||||
|
21:01:41 [ERROR] Failed to start qa agent for '293': Agent 'qa' is already running on story '292'
|
||||||
|
|
||||||
|
21:01:48 [agents] Server-owned completion for '294:coder-opus': gates_passed=true
|
||||||
|
21:01:48 [ERROR] Failed to start qa agent for '294': Agent 'qa' is already running on story '292'
|
||||||
|
|
||||||
|
21:08:18 [agents] Server-owned completion for '292:qa': gates_passed=true
|
||||||
|
(293 and 294 are never picked up)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
Stories 293 and 294 stuck in QA with no agent after 292's QA agent was busy. The pipeline status shows them in `3_qa` with `agent: null` indefinitely.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
When a QA agent finishes a story, `auto_assign_available_work` should be called to scan for unassigned stories in all active stages and assign free agents. Stories 293 and 294 should get QA agents as soon as the QA agent finishes with 292.
|
||||||
|
|
||||||
|
## Suggested Fix
|
||||||
|
|
||||||
|
In the server-owned completion handler (the code path that runs after an agent's process exits), call `auto_assign_available_work()` after processing the completed story. This ensures that when any agent becomes free, the server immediately looks for pending work to assign it to.
|
||||||
|
|
||||||
|
The relevant code is in `server/src/agents/pool.rs` — the `handle_agent_completion` path (around line 804-950) and `auto_assign_available_work` (around line 1437-1559).
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] When an agent completes (any stage), `auto_assign_available_work` is called to pick up pending stories
|
||||||
|
- [ ] Stories that failed agent assignment due to busy agents are picked up when agents become available
|
||||||
|
- [ ] Server logs when a story is queued for retry vs permanently failed
|
||||||
|
- [ ] Multiple stories completing QA sequentially works correctly (story A finishes QA → story B gets QA agent)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
name: "Track per-agent token usage for cost visibility and optimisation"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 296: Track per-agent token usage for cost visibility and optimisation
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner, I want to see how many tokens each agent consumes per story, so that I can identify expensive operations and optimise token usage across the pipeline.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Implement per-agent token tracking that captures input tokens, output tokens, and cache tokens for each agent run
|
||||||
|
- [ ] Token usage is recorded per story and per agent (e.g. coder-1 on story 293 used X tokens)
|
||||||
|
- [ ] Running totals are visible — either via MCP tool, web UI, or both
|
||||||
|
- [ ] Historical token usage is persisted so it survives server restarts (e.g. in story files or a separate log)
|
||||||
|
- [ ] Data is structured to support later analysis (e.g. which agent types are most expensive, which stories cost the most)
|
||||||
|
|
||||||
|
## Research Notes
|
||||||
|
|
||||||
|
Claude Code's JSON stream already emits all the data we need. No external library required.
|
||||||
|
|
||||||
|
**Data available in the `result` event at end of each agent session:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "result",
|
||||||
|
"total_cost_usd": 1.57,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 7,
|
||||||
|
"output_tokens": 475,
|
||||||
|
"cache_creation_input_tokens": 185020,
|
||||||
|
"cache_read_input_tokens": 810585
|
||||||
|
},
|
||||||
|
"modelUsage": {
|
||||||
|
"claude-opus-4-6[1m]": {
|
||||||
|
"inputTokens": 7,
|
||||||
|
"outputTokens": 475,
|
||||||
|
"cacheReadInputTokens": 810585,
|
||||||
|
"cacheCreationInputTokens": 185020,
|
||||||
|
"costUSD": 1.57
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Where to hook in:**
|
||||||
|
- `server/src/llm/providers/claude_code.rs` — `process_json_event()` already parses the JSON stream but currently ignores usage data from the `result` event
|
||||||
|
- Parse `usage` + `total_cost_usd` from the `result` event and pipe it to the agent completion handler in `server/src/agents/pool.rs`
|
||||||
|
|
||||||
|
**No external libraries needed** — Anthropic SDK, LiteLLM, Helicone, Langfuse etc. are all overkill since we have direct access to Claude Code's output stream.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: "Improve bot status command formatting"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 297: Improve bot status command formatting
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user reading the bot's status output in Matrix, I want to see clean story numbers and titles (not filenames), with agent assignments shown inline, so that the output is easy to scan at a glance.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Status output shows story number and title (e.g. '293 — Register all bot commands') not the full filename stem
|
||||||
|
- [ ] Each story shows which agent is working on it if one is assigned (e.g. 'coder-1 (sonnet)')
|
||||||
|
- [ ] Stories with no agent assigned show no agent info rather than cluttering the output
|
||||||
|
- [ ] Output is compact and scannable in a Matrix chat window
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: "Bot htop command with live-updating process dashboard"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 298: Bot htop command with live-updating process dashboard
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner in a Matrix room, I want to type "{bot_name} htop" and see a live-updating dashboard of system load and agent processes, so that I can monitor resource usage without needing terminal access.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] '{bot_name} htop' sends an initial status message showing load average and per-agent process info (CPU, memory, story assignment)
|
||||||
|
- [ ] Message is edited every 5 seconds with updated stats
|
||||||
|
- [ ] Only shows processes related to the project (agent PIDs and their child process trees)
|
||||||
|
- [ ] '{bot_name} htop stop' stops the live updating and sends a final 'monitoring stopped' edit
|
||||||
|
- [ ] Works regardless of what language/toolchain the agents are using (monitors by PID tree, not by process name)
|
||||||
|
- [ ] Uses Matrix message editing (replacement events) to update in place
|
||||||
|
- [ ] Only one htop session per room at a time — a second '{bot_name} htop' stops the existing session and starts a fresh one
|
||||||
|
- [ ] Auto-stops after 5 minutes by default to prevent runaway editing
|
||||||
|
- [ ] Optional timeout override: '{bot_name} htop 10m' to set a custom duration
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: "Bot git status command shows working tree and branch info"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 299: Bot git status command shows working tree and branch info
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner in a Matrix room, I want to type "{bot_name} git" and see the current git status (branch, uncommitted changes, how far ahead/behind remote), so that I can check the repo state without terminal access.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] '{bot_name} git' displays current branch name
|
||||||
|
- [ ] Shows count of uncommitted changes (staged and unstaged) with filenames
|
||||||
|
- [ ] Shows how many commits ahead/behind the remote branch
|
||||||
|
- [ ] Output is formatted compactly for Matrix chat
|
||||||
|
- [ ] Registered in the command registry in commands.rs so it appears in help output
|
||||||
|
- [ ] Handled at bot level without LLM invocation
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: "Show token cost badge on pipeline board work items"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 300: Show token cost badge on pipeline board work items
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner viewing the pipeline board, I want to see the total token cost for each work item displayed as a badge, so that I can quickly spot expensive stories at a glance.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Each work item on the pipeline board shows its total cost in USD as a small badge
|
||||||
|
- [ ] Cost is fetched from the token_usage.jsonl data via a new API endpoint
|
||||||
|
- [ ] Items with no recorded usage show no badge (not $0.00)
|
||||||
|
- [ ] Cost updates when the pipeline refreshes (e.g. after an agent completes)
|
||||||
|
- [ ] Expanded work item detail panel shows per-agent cost breakdown (coder, QA, mergemaster) with token counts
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: "Dedicated token usage page in web UI"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 301: Dedicated token usage page in web UI
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner, I want a dedicated token usage page in the web UI that shows per-story and per-agent cost breakdowns with totals, so that I can analyse where tokens are being spent and identify optimisation opportunities.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] New page/panel accessible from the main navigation
|
||||||
|
- [ ] Shows a table of all recorded agent sessions with story, agent name, model, token counts, and cost
|
||||||
|
- [ ] Sortable by cost, story, agent, or date
|
||||||
|
- [ ] Shows summary totals: total cost, cost by agent type (coder vs QA vs mergemaster), cost by model (opus vs sonnet)
|
||||||
|
- [ ] Data loads from the token_usage.jsonl log via API endpoint
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: "Bot cost command shows total and per-story token spend"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 302: Bot cost command shows total and per-story token spend
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner in a Matrix room, I want to type "{bot_name} cost" to see total token spend and the top most expensive stories, so that I can check burn rate from my phone.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] '{bot_name} cost' shows total spend for the last 24 hours
|
||||||
|
- [ ] Shows top 5 most expensive stories from the last 24 hours with their costs
|
||||||
|
- [ ] Shows cost breakdown by agent type (coder, QA, mergemaster) for the last 24 hours
|
||||||
|
- [ ] Also shows an all-time total for context
|
||||||
|
- [ ] Registered in the command registry so it appears in help output
|
||||||
|
- [ ] Handled at bot level without LLM invocation
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: "Bot cost command with story filter for detailed breakdown"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 303: Bot cost command with story filter for detailed breakdown
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner in a Matrix room, I want to type "{bot_name} cost 293" to see a detailed token breakdown for a specific story, so that I can understand where the tokens went on an expensive item.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] '{bot_name} cost {story_number}' shows all agent sessions for that story
|
||||||
|
- [ ] Each session shows agent name, model, input/output/cache tokens, and cost in USD
|
||||||
|
- [ ] Shows total cost for the story at the bottom
|
||||||
|
- [ ] Registered in the command registry (can share the 'cost' command with args parsing)
|
||||||
|
- [ ] Returns a friendly message if no usage data exists for the story
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: "MCP tool to move stories between pipeline stages"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 304: MCP tool to move stories between pipeline stages
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a bot operator (Timmy), I want an MCP tool that moves stories between pipeline stages, so that I don't need shell mv permissions to manage the pipeline.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] New MCP tool 'move_story' accepts story_id and target_stage (e.g. 'backlog', 'current', 'qa', 'merge', 'done')
|
||||||
|
- [ ] Validates the story exists before moving
|
||||||
|
- [ ] Handles the file move between stage directories
|
||||||
|
- [ ] Returns a confirmation message with the old and new stage
|
||||||
|
- [ ] Works for stories, bugs, spikes, and refactors
|
||||||
|
- [ ] Replaces the need for shell mv commands to move story files
|
||||||
|
- [ ] Tool description tells bots to prefer specific tools (accept_story, move_story_to_merge, request_qa) when available, and use move_story only for arbitrary moves that lack a dedicated tool (e.g. moving to backlog, moving ghost stories back to current)
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: "Bot show command displays story text in chat"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 305: Bot show command displays story text in chat
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner in a Matrix room, I want to type "{bot_name} show {story_number}" and see the full story text displayed in chat, so that I can review story details without accessing the file system.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] '{bot_name} show {number}' finds the story/bug/spike by number across all pipeline stages and displays its full markdown content
|
||||||
|
- [ ] Output is formatted for readability in Matrix
|
||||||
|
- [ ] Returns a friendly message if no story with that number exists
|
||||||
|
- [ ] Registered in the command registry so it appears in help output
|
||||||
|
- [ ] Handled at bot level without LLM invocation
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: "Replace manual_qa boolean with configurable qa mode field"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 306: Replace manual_qa boolean with configurable qa mode field
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner, I want to configure QA mode per-story and set a project-wide default, so that I can choose between human review, server-only gate checks, or full agent QA on a per-story basis.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Replace manual_qa: true/false front matter field with qa: human|server|agent
|
||||||
|
- [ ] qa: server — skip the QA agent entirely, rely on server's automated gate checks (clippy + tests + coverage). If gates pass, advance straight to merge
|
||||||
|
- [ ] qa: agent — current behavior, spin up a QA agent (Claude session) to review code and run gates
|
||||||
|
- [ ] qa: human — hold in QA for human approval after server gates pass (current manual_qa: true behavior)
|
||||||
|
- [ ] Default qa mode is configurable in project.toml (e.g. default_qa = "server")
|
||||||
|
- [ ] Set the initial default in project.toml to "server"
|
||||||
|
- [ ] Per-story front matter qa field overrides the project default
|
||||||
|
- [ ] Backwards compatible: existing stories without a qa field use the project default
|
||||||
|
- [ ] Remove the old manual_qa field handling and replace with the new qa field throughout pool.rs, story_metadata.rs, and any other references
|
||||||
|
- [ ] Update bot.toml.example and project.toml documentation to reflect the new field
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: "Configurable coder pool size and default model in project.toml"
|
||||||
|
agent: coder-opus
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 307: Configurable coder pool size and default model in project.toml
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner, I want to configure the number of concurrent coder agents and their default model in project.toml, so that I can control resource usage and cost while still being able to override per-story when needed.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] New project.toml setting: default_coder_model (e.g. 'sonnet') determines which model is used for coder agents by default
|
||||||
|
- [ ] New project.toml setting: max_coders (e.g. 3) limits concurrent coder agent slots
|
||||||
|
- [ ] Add one more sonnet coder to the agent config (coder-3) for a total of 3 sonnet coders
|
||||||
|
- [ ] When all coder slots are full, new stories wait in current until a slot frees up
|
||||||
|
- [ ] Per-story front matter agent field still overrides the default (e.g. agent: coder-opus assigns opus)
|
||||||
|
- [ ] Opus coders are only used when explicitly requested via front matter
|
||||||
|
- [ ] QA and mergemaster limits are unchanged (not configurable via this story)
|
||||||
|
- [ ] auto_assign_available_work respects the max_coders limit
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: "Show token cost breakdown in expanded work item detail panel"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 309: Show token cost breakdown in expanded work item detail panel
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner viewing a work item in the web UI, I want to see a per-agent token cost breakdown in the expanded detail panel, so that I can understand where tokens were spent on that story.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] WorkItemDetailPanel fetches token cost data using the existing /work-items/:story_id/token-cost endpoint
|
||||||
|
- [ ] Shows per-agent session breakdown: agent name, model, token counts (input/output/cache), cost in USD
|
||||||
|
- [ ] Shows total cost for the story
|
||||||
|
- [ ] Shows empty state when no token data exists for the story
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
36
Cargo.lock
generated
36
Cargo.lock
generated
@@ -3997,7 +3997,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "story-kit"
|
name = "story-kit"
|
||||||
version = "0.3.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -4026,7 +4026,7 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.29.0",
|
"tokio-tungstenite 0.29.0",
|
||||||
"toml 1.0.6+spec-1.1.0",
|
"toml 1.0.7+spec-1.1.0",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wait-timeout",
|
"wait-timeout",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
@@ -4367,22 +4367,22 @@ dependencies = [
|
|||||||
"serde_spanned",
|
"serde_spanned",
|
||||||
"toml_datetime 0.7.5+spec-1.1.0",
|
"toml_datetime 0.7.5+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"winnow",
|
"winnow 0.7.14",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "1.0.6+spec-1.1.0"
|
version = "1.0.7+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc"
|
checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"serde_spanned",
|
"serde_spanned",
|
||||||
"toml_datetime 1.0.0+spec-1.1.0",
|
"toml_datetime 1.0.1+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"toml_writer",
|
"toml_writer",
|
||||||
"winnow",
|
"winnow 1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4396,9 +4396,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.0.0+spec-1.1.0"
|
version = "1.0.1+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
|
checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
@@ -4412,23 +4412,23 @@ dependencies = [
|
|||||||
"indexmap",
|
"indexmap",
|
||||||
"toml_datetime 0.7.5+spec-1.1.0",
|
"toml_datetime 0.7.5+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"winnow",
|
"winnow 0.7.14",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_parser"
|
name = "toml_parser"
|
||||||
version = "1.0.9+spec-1.1.0"
|
version = "1.0.10+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
|
checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow",
|
"winnow 1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_writer"
|
name = "toml_writer"
|
||||||
version = "1.0.6+spec-1.1.0"
|
version = "1.0.7+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
|
checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
@@ -5444,6 +5444,12 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winreg"
|
name = "winreg"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ serde_yaml = "0.9"
|
|||||||
strip-ansi-escapes = "0.2"
|
strip-ansi-escapes = "0.2"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
|
||||||
toml = "1.0.6"
|
toml = "1.0.7"
|
||||||
uuid = { version = "1.22.0", features = ["v4", "serde"] }
|
uuid = { version = "1.22.0", features = ["v4", "serde"] }
|
||||||
tokio-tungstenite = "0.29.0"
|
tokio-tungstenite = "0.29.0"
|
||||||
walkdir = "2.5.0"
|
walkdir = "2.5.0"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Story Kit</title>
|
<title>Storkit</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import "./App.css";
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [projectPath, setProjectPath] = React.useState<string | null>(null);
|
const [projectPath, setProjectPath] = React.useState<string | null>(null);
|
||||||
|
const [_view, setView] = React.useState<"chat" | "token-usage">("chat");
|
||||||
const [isCheckingProject, setIsCheckingProject] = React.useState(true);
|
const [isCheckingProject, setIsCheckingProject] = React.useState(true);
|
||||||
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
|
||||||
const [pathInput, setPathInput] = React.useState("");
|
const [pathInput, setPathInput] = React.useState("");
|
||||||
@@ -120,6 +121,7 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
await api.closeProject();
|
await api.closeProject();
|
||||||
setProjectPath(null);
|
setProjectPath(null);
|
||||||
|
setView("chat");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,8 +128,7 @@ export function subscribeAgentStream(
|
|||||||
onEvent: (event: AgentEvent) => void,
|
onEvent: (event: AgentEvent) => void,
|
||||||
onError?: (error: Event) => void,
|
onError?: (error: Event) => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
const host = import.meta.env.DEV ? "http://127.0.0.1:3001" : "";
|
const url = `/agents/${encodeURIComponent(storyId)}/${encodeURIComponent(agentName)}/stream`;
|
||||||
const url = `${host}/agents/${encodeURIComponent(storyId)}/${encodeURIComponent(agentName)}/stream`;
|
|
||||||
|
|
||||||
const eventSource = new EventSource(url);
|
const eventSource = new EventSource(url);
|
||||||
|
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ describe("ChatWebSocket", () => {
|
|||||||
|
|
||||||
// Server pushes pipeline_state on fresh connection
|
// Server pushes pipeline_state on fresh connection
|
||||||
const freshState = {
|
const freshState = {
|
||||||
upcoming: [{ story_id: "1_story_test", name: "Test", error: null }],
|
backlog: [{ story_id: "1_story_test", name: "Test", error: null }],
|
||||||
current: [],
|
current: [],
|
||||||
qa: [],
|
qa: [],
|
||||||
merge: [],
|
merge: [],
|
||||||
|
|||||||
@@ -33,10 +33,12 @@ export interface PipelineStageItem {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
merge_failure: string | null;
|
merge_failure: string | null;
|
||||||
agent: AgentAssignment | null;
|
agent: AgentAssignment | null;
|
||||||
|
review_hold: boolean | null;
|
||||||
|
qa: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PipelineState {
|
export interface PipelineState {
|
||||||
upcoming: PipelineStageItem[];
|
backlog: PipelineStageItem[];
|
||||||
current: PipelineStageItem[];
|
current: PipelineStageItem[];
|
||||||
qa: PipelineStageItem[];
|
qa: PipelineStageItem[];
|
||||||
merge: PipelineStageItem[];
|
merge: PipelineStageItem[];
|
||||||
@@ -50,7 +52,7 @@ export type WsResponse =
|
|||||||
| { type: "error"; message: string }
|
| { type: "error"; message: string }
|
||||||
| {
|
| {
|
||||||
type: "pipeline_state";
|
type: "pipeline_state";
|
||||||
upcoming: PipelineStageItem[];
|
backlog: PipelineStageItem[];
|
||||||
current: PipelineStageItem[];
|
current: PipelineStageItem[];
|
||||||
qa: PipelineStageItem[];
|
qa: PipelineStageItem[];
|
||||||
merge: PipelineStageItem[];
|
merge: PipelineStageItem[];
|
||||||
@@ -83,7 +85,9 @@ export type WsResponse =
|
|||||||
/** Streaming token from a /btw side question response. */
|
/** Streaming token from a /btw side question response. */
|
||||||
| { type: "side_question_token"; content: string }
|
| { type: "side_question_token"; content: string }
|
||||||
/** Final signal that the /btw side question has been fully answered. */
|
/** Final signal that the /btw side question has been fully answered. */
|
||||||
| { type: "side_question_done"; response: string };
|
| { 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 {
|
export interface ProviderConfig {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -139,6 +143,37 @@ export interface SearchResult {
|
|||||||
matches: number;
|
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 {
|
export interface CommandOutput {
|
||||||
stdout: string;
|
stdout: string;
|
||||||
stderr: string;
|
stderr: string;
|
||||||
@@ -312,8 +347,52 @@ export const api = {
|
|||||||
baseUrl,
|
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);
|
||||||
|
},
|
||||||
|
/** 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 });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 {
|
export class ChatWebSocket {
|
||||||
private static sharedSocket: WebSocket | null = null;
|
private static sharedSocket: WebSocket | null = null;
|
||||||
private static refCount = 0;
|
private static refCount = 0;
|
||||||
@@ -340,6 +419,11 @@ export class ChatWebSocket {
|
|||||||
private onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
private onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||||
private onSideQuestionToken?: (content: string) => void;
|
private onSideQuestionToken?: (content: string) => void;
|
||||||
private onSideQuestionDone?: (response: string) => void;
|
private onSideQuestionDone?: (response: string) => void;
|
||||||
|
private onLogEntry?: (
|
||||||
|
timestamp: string,
|
||||||
|
level: string,
|
||||||
|
message: string,
|
||||||
|
) => void;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private closeTimer?: number;
|
private closeTimer?: number;
|
||||||
private wsPath = DEFAULT_WS_PATH;
|
private wsPath = DEFAULT_WS_PATH;
|
||||||
@@ -398,7 +482,7 @@ export class ChatWebSocket {
|
|||||||
if (data.type === "error") this.onError?.(data.message);
|
if (data.type === "error") this.onError?.(data.message);
|
||||||
if (data.type === "pipeline_state")
|
if (data.type === "pipeline_state")
|
||||||
this.onPipelineState?.({
|
this.onPipelineState?.({
|
||||||
upcoming: data.upcoming,
|
backlog: data.backlog,
|
||||||
current: data.current,
|
current: data.current,
|
||||||
qa: data.qa,
|
qa: data.qa,
|
||||||
merge: data.merge,
|
merge: data.merge,
|
||||||
@@ -425,6 +509,8 @@ export class ChatWebSocket {
|
|||||||
this.onSideQuestionToken?.(data.content);
|
this.onSideQuestionToken?.(data.content);
|
||||||
if (data.type === "side_question_done")
|
if (data.type === "side_question_done")
|
||||||
this.onSideQuestionDone?.(data.response);
|
this.onSideQuestionDone?.(data.response);
|
||||||
|
if (data.type === "log_entry")
|
||||||
|
this.onLogEntry?.(data.timestamp, data.level, data.message);
|
||||||
if (data.type === "pong") {
|
if (data.type === "pong") {
|
||||||
window.clearTimeout(this.heartbeatTimeout);
|
window.clearTimeout(this.heartbeatTimeout);
|
||||||
this.heartbeatTimeout = undefined;
|
this.heartbeatTimeout = undefined;
|
||||||
@@ -480,6 +566,7 @@ export class ChatWebSocket {
|
|||||||
onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||||
onSideQuestionToken?: (content: string) => void;
|
onSideQuestionToken?: (content: string) => void;
|
||||||
onSideQuestionDone?: (response: string) => void;
|
onSideQuestionDone?: (response: string) => void;
|
||||||
|
onLogEntry?: (timestamp: string, level: string, message: string) => void;
|
||||||
},
|
},
|
||||||
wsPath = DEFAULT_WS_PATH,
|
wsPath = DEFAULT_WS_PATH,
|
||||||
) {
|
) {
|
||||||
@@ -497,6 +584,7 @@ export class ChatWebSocket {
|
|||||||
this.onOnboardingStatus = handlers.onOnboardingStatus;
|
this.onOnboardingStatus = handlers.onOnboardingStatus;
|
||||||
this.onSideQuestionToken = handlers.onSideQuestionToken;
|
this.onSideQuestionToken = handlers.onSideQuestionToken;
|
||||||
this.onSideQuestionDone = handlers.onSideQuestionDone;
|
this.onSideQuestionDone = handlers.onSideQuestionDone;
|
||||||
|
this.onLogEntry = handlers.onLogEntry;
|
||||||
this.wsPath = wsPath;
|
this.wsPath = wsPath;
|
||||||
this.shouldReconnect = true;
|
this.shouldReconnect = true;
|
||||||
|
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ describe("RosterBadge availability state", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Thinking traces hidden from agent stream UI", () => {
|
describe("Agent output not shown in sidebar (story 290)", () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Element.prototype.scrollIntoView = vi.fn();
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
});
|
});
|
||||||
@@ -224,7 +224,51 @@ describe("Thinking traces hidden from agent stream UI", () => {
|
|||||||
mockedSubscribeAgentStream.mockReturnValue(() => {});
|
mockedSubscribeAgentStream.mockReturnValue(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
// AC1: thinking block is never rendered even when thinking events arrive
|
// AC1: output events do not appear in the agents sidebar
|
||||||
|
it("does not render agent output when output event arrives", async () => {
|
||||||
|
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||||
|
mockedSubscribeAgentStream.mockImplementation(
|
||||||
|
(_storyId, _agentName, onEvent) => {
|
||||||
|
emitEvent = onEvent;
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const agentList: AgentInfo[] = [
|
||||||
|
{
|
||||||
|
story_id: "290_output",
|
||||||
|
agent_name: "coder-1",
|
||||||
|
status: "running",
|
||||||
|
session_id: null,
|
||||||
|
worktree_path: "/tmp/wt",
|
||||||
|
base_branch: "master",
|
||||||
|
log_session_id: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockedAgents.listAgents.mockResolvedValue(agentList);
|
||||||
|
|
||||||
|
const { container } = render(<AgentPanel />);
|
||||||
|
await screen.findByTestId("roster-badge-coder-1");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
emitEvent?.({
|
||||||
|
type: "output",
|
||||||
|
story_id: "290_output",
|
||||||
|
agent_name: "coder-1",
|
||||||
|
text: "doing some work...",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// No output elements in the sidebar
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid^="agent-output-"]'),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid^="agent-stream-"]'),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// AC1: thinking events do not appear in the agents sidebar
|
||||||
it("does not render thinking block when thinking event arrives", async () => {
|
it("does not render thinking block when thinking event arrives", async () => {
|
||||||
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||||
mockedSubscribeAgentStream.mockImplementation(
|
mockedSubscribeAgentStream.mockImplementation(
|
||||||
@@ -236,7 +280,7 @@ describe("Thinking traces hidden from agent stream UI", () => {
|
|||||||
|
|
||||||
const agentList: AgentInfo[] = [
|
const agentList: AgentInfo[] = [
|
||||||
{
|
{
|
||||||
story_id: "218_thinking",
|
story_id: "290_thinking",
|
||||||
agent_name: "coder-1",
|
agent_name: "coder-1",
|
||||||
status: "running",
|
status: "running",
|
||||||
session_id: null,
|
session_id: null,
|
||||||
@@ -253,109 +297,16 @@ describe("Thinking traces hidden from agent stream UI", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
emitEvent?.({
|
emitEvent?.({
|
||||||
type: "thinking",
|
type: "thinking",
|
||||||
story_id: "218_thinking",
|
story_id: "290_thinking",
|
||||||
agent_name: "coder-1",
|
agent_name: "coder-1",
|
||||||
text: "Let me consider the problem carefully...",
|
text: "Let me consider the problem carefully...",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// AC1: thinking block must not be present
|
// No thinking block or output in sidebar
|
||||||
expect(screen.queryByTestId("thinking-block")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("thinking-block")).not.toBeInTheDocument();
|
||||||
});
|
expect(
|
||||||
|
screen.queryByText("Let me consider the problem carefully..."),
|
||||||
// AC2: after thinking events, only regular output is rendered
|
).not.toBeInTheDocument();
|
||||||
it("renders regular output but not thinking block when both arrive", async () => {
|
|
||||||
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
|
||||||
mockedSubscribeAgentStream.mockImplementation(
|
|
||||||
(_storyId, _agentName, onEvent) => {
|
|
||||||
emitEvent = onEvent;
|
|
||||||
return () => {};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const agentList: AgentInfo[] = [
|
|
||||||
{
|
|
||||||
story_id: "218_output",
|
|
||||||
agent_name: "coder-1",
|
|
||||||
status: "running",
|
|
||||||
session_id: null,
|
|
||||||
worktree_path: "/tmp/wt",
|
|
||||||
base_branch: "master",
|
|
||||||
log_session_id: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
mockedAgents.listAgents.mockResolvedValue(agentList);
|
|
||||||
|
|
||||||
render(<AgentPanel />);
|
|
||||||
await screen.findByTestId("roster-badge-coder-1");
|
|
||||||
|
|
||||||
// Thinking event — must be ignored visually
|
|
||||||
await act(async () => {
|
|
||||||
emitEvent?.({
|
|
||||||
type: "thinking",
|
|
||||||
story_id: "218_output",
|
|
||||||
agent_name: "coder-1",
|
|
||||||
text: "thinking deeply",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// AC3: output event still renders correctly (no regression)
|
|
||||||
await act(async () => {
|
|
||||||
emitEvent?.({
|
|
||||||
type: "output",
|
|
||||||
story_id: "218_output",
|
|
||||||
agent_name: "coder-1",
|
|
||||||
text: "Here is the result.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// AC1: no thinking block
|
|
||||||
expect(screen.queryByTestId("thinking-block")).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
// AC2+AC3: output area renders the text but NOT thinking text
|
|
||||||
const outputArea = screen.getByTestId("agent-output-coder-1");
|
|
||||||
expect(outputArea).toBeInTheDocument();
|
|
||||||
expect(outputArea.textContent).toContain("Here is the result.");
|
|
||||||
expect(outputArea.textContent).not.toContain("thinking deeply");
|
|
||||||
});
|
|
||||||
|
|
||||||
// AC3: output-only event stream (no thinking) still works
|
|
||||||
it("renders output event text without a thinking block", async () => {
|
|
||||||
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
|
||||||
mockedSubscribeAgentStream.mockImplementation(
|
|
||||||
(_storyId, _agentName, onEvent) => {
|
|
||||||
emitEvent = onEvent;
|
|
||||||
return () => {};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const agentList: AgentInfo[] = [
|
|
||||||
{
|
|
||||||
story_id: "218_noThink",
|
|
||||||
agent_name: "coder-1",
|
|
||||||
status: "running",
|
|
||||||
session_id: null,
|
|
||||||
worktree_path: "/tmp/wt",
|
|
||||||
base_branch: "master",
|
|
||||||
log_session_id: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
mockedAgents.listAgents.mockResolvedValue(agentList);
|
|
||||||
|
|
||||||
render(<AgentPanel />);
|
|
||||||
await screen.findByTestId("roster-badge-coder-1");
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
emitEvent?.({
|
|
||||||
type: "output",
|
|
||||||
story_id: "218_noThink",
|
|
||||||
agent_name: "coder-1",
|
|
||||||
text: "plain output line",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.queryByTestId("thinking-block")).not.toBeInTheDocument();
|
|
||||||
const outputArea = screen.getByTestId("agent-output-coder-1");
|
|
||||||
expect(outputArea.textContent).toContain("plain output line");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ const { useCallback, useEffect, useRef, useState } = React;
|
|||||||
interface AgentState {
|
interface AgentState {
|
||||||
agentName: string;
|
agentName: string;
|
||||||
status: AgentStatusValue;
|
status: AgentStatusValue;
|
||||||
log: string[];
|
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
worktreePath: string | null;
|
worktreePath: string | null;
|
||||||
baseBranch: string | null;
|
baseBranch: string | null;
|
||||||
@@ -120,7 +119,6 @@ export function AgentPanel({
|
|||||||
const current = prev[key] ?? {
|
const current = prev[key] ?? {
|
||||||
agentName,
|
agentName,
|
||||||
status: "pending" as AgentStatusValue,
|
status: "pending" as AgentStatusValue,
|
||||||
log: [],
|
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
worktreePath: null,
|
worktreePath: null,
|
||||||
baseBranch: null,
|
baseBranch: null,
|
||||||
@@ -144,14 +142,6 @@ export function AgentPanel({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "output":
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
[key]: {
|
|
||||||
...current,
|
|
||||||
log: [...current.log, event.text ?? ""],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
case "done":
|
case "done":
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
@@ -168,17 +158,12 @@ export function AgentPanel({
|
|||||||
[key]: {
|
[key]: {
|
||||||
...current,
|
...current,
|
||||||
status: "failed",
|
status: "failed",
|
||||||
log: [
|
|
||||||
...current.log,
|
|
||||||
`[ERROR] ${event.message ?? "Unknown error"}`,
|
|
||||||
],
|
|
||||||
terminalAt: current.terminalAt ?? Date.now(),
|
terminalAt: current.terminalAt ?? Date.now(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
case "thinking":
|
|
||||||
// Thinking traces are internal model state — never display them.
|
|
||||||
return prev;
|
|
||||||
default:
|
default:
|
||||||
|
// output, thinking, and other events are not displayed in the sidebar.
|
||||||
|
// Agent output streams appear in the work item detail panel instead.
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -204,7 +189,6 @@ export function AgentPanel({
|
|||||||
agentMap[key] = {
|
agentMap[key] = {
|
||||||
agentName: a.agent_name,
|
agentName: a.agent_name,
|
||||||
status: a.status,
|
status: a.status,
|
||||||
log: [],
|
|
||||||
sessionId: a.session_id,
|
sessionId: a.session_id,
|
||||||
worktreePath: a.worktree_path,
|
worktreePath: a.worktree_path,
|
||||||
baseBranch: a.base_branch,
|
baseBranch: a.base_branch,
|
||||||
@@ -261,9 +245,6 @@ export function AgentPanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Agents that have streaming content to show
|
|
||||||
const activeAgents = Object.values(agents).filter((a) => a.log.length > 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -420,35 +401,6 @@ export function AgentPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Per-agent streaming output */}
|
|
||||||
{activeAgents.map((agent) => (
|
|
||||||
<div
|
|
||||||
key={`stream-${agent.agentName}`}
|
|
||||||
data-testid={`agent-stream-${agent.agentName}`}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{agent.log.length > 0 && (
|
|
||||||
<div
|
|
||||||
data-testid={`agent-output-${agent.agentName}`}
|
|
||||||
style={{
|
|
||||||
fontSize: "0.8em",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
color: "#ccc",
|
|
||||||
whiteSpace: "pre-wrap",
|
|
||||||
wordBreak: "break-word",
|
|
||||||
lineHeight: "1.5",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{agent.log.join("")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{actionError && (
|
{actionError && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -631,7 +631,10 @@ describe("Chat localStorage persistence (Story 145)", () => {
|
|||||||
|
|
||||||
// Verify sendChat was called with ALL prior messages + the new one
|
// Verify sendChat was called with ALL prior messages + the new one
|
||||||
expect(lastSendChatArgs).not.toBeNull();
|
expect(lastSendChatArgs).not.toBeNull();
|
||||||
const args = lastSendChatArgs as unknown as { messages: Message[]; config: unknown };
|
const args = lastSendChatArgs as unknown as {
|
||||||
|
messages: Message[];
|
||||||
|
config: unknown;
|
||||||
|
};
|
||||||
expect(args.messages).toHaveLength(3);
|
expect(args.messages).toHaveLength(3);
|
||||||
expect(args.messages[0]).toEqual({
|
expect(args.messages[0]).toEqual({
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -1350,7 +1353,14 @@ describe("Bug 264: Claude Code session ID persisted across browser refresh", ()
|
|||||||
|
|
||||||
expect(lastSendChatArgs).not.toBeNull();
|
expect(lastSendChatArgs).not.toBeNull();
|
||||||
expect(
|
expect(
|
||||||
((lastSendChatArgs as unknown as { messages: Message[]; config: unknown })?.config as Record<string, unknown>).session_id,
|
(
|
||||||
|
(
|
||||||
|
lastSendChatArgs as unknown as {
|
||||||
|
messages: Message[];
|
||||||
|
config: unknown;
|
||||||
|
}
|
||||||
|
)?.config as Record<string, unknown>
|
||||||
|
).session_id,
|
||||||
).toBe("persisted-session-xyz");
|
).toBe("persisted-session-xyz");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { ChatInput } from "./ChatInput";
|
|||||||
import { HelpOverlay } from "./HelpOverlay";
|
import { HelpOverlay } from "./HelpOverlay";
|
||||||
import { LozengeFlyProvider } from "./LozengeFlyContext";
|
import { LozengeFlyProvider } from "./LozengeFlyContext";
|
||||||
import { MessageItem } from "./MessageItem";
|
import { MessageItem } from "./MessageItem";
|
||||||
|
import type { LogEntry } from "./ServerLogsPanel";
|
||||||
|
import { ServerLogsPanel } from "./ServerLogsPanel";
|
||||||
import { SideQuestionOverlay } from "./SideQuestionOverlay";
|
import { SideQuestionOverlay } from "./SideQuestionOverlay";
|
||||||
import { StagePanel } from "./StagePanel";
|
import { StagePanel } from "./StagePanel";
|
||||||
import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
|
import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
|
||||||
@@ -165,7 +167,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
const [apiKeyInput, setApiKeyInput] = useState("");
|
const [apiKeyInput, setApiKeyInput] = useState("");
|
||||||
const [hasAnthropicKey, setHasAnthropicKey] = useState(false);
|
const [hasAnthropicKey, setHasAnthropicKey] = useState(false);
|
||||||
const [pipeline, setPipeline] = useState<PipelineState>({
|
const [pipeline, setPipeline] = useState<PipelineState>({
|
||||||
upcoming: [],
|
backlog: [],
|
||||||
current: [],
|
current: [],
|
||||||
qa: [],
|
qa: [],
|
||||||
merge: [],
|
merge: [],
|
||||||
@@ -200,6 +202,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
const [agentConfigVersion, setAgentConfigVersion] = useState(0);
|
const [agentConfigVersion, setAgentConfigVersion] = useState(0);
|
||||||
const [agentStateVersion, setAgentStateVersion] = useState(0);
|
const [agentStateVersion, setAgentStateVersion] = useState(0);
|
||||||
const [pipelineVersion, setPipelineVersion] = useState(0);
|
const [pipelineVersion, setPipelineVersion] = useState(0);
|
||||||
|
const [storyTokenCosts, setStoryTokenCosts] = useState<Map<string, number>>(
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
||||||
const onboardingTriggeredRef = useRef(false);
|
const onboardingTriggeredRef = useRef(false);
|
||||||
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(
|
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(
|
||||||
@@ -214,6 +219,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
const [serverLogs, setServerLogs] = useState<LogEntry[]>([]);
|
||||||
// Ref so stale WebSocket callbacks can read the current queued messages
|
// Ref so stale WebSocket callbacks can read the current queued messages
|
||||||
const queuedMessagesRef = useRef<{ id: string; text: string }[]>([]);
|
const queuedMessagesRef = useRef<{ id: string; text: string }[]>([]);
|
||||||
const queueIdCounterRef = useRef(0);
|
const queueIdCounterRef = useRef(0);
|
||||||
@@ -360,6 +366,29 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
onPipelineState: (state) => {
|
onPipelineState: (state) => {
|
||||||
setPipeline(state);
|
setPipeline(state);
|
||||||
setPipelineVersion((v) => v + 1);
|
setPipelineVersion((v) => v + 1);
|
||||||
|
const allItems = [
|
||||||
|
...state.backlog,
|
||||||
|
...state.current,
|
||||||
|
...state.qa,
|
||||||
|
...state.merge,
|
||||||
|
...state.done,
|
||||||
|
];
|
||||||
|
for (const item of allItems) {
|
||||||
|
api
|
||||||
|
.getTokenCost(item.story_id)
|
||||||
|
.then((cost) => {
|
||||||
|
if (cost.total_cost_usd > 0) {
|
||||||
|
setStoryTokenCosts((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(item.story_id, cost.total_cost_usd);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Silently ignore — cost data may not exist yet.
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onPermissionRequest: (requestId, toolName, toolInput) => {
|
onPermissionRequest: (requestId, toolName, toolInput) => {
|
||||||
setPermissionQueue((prev) => [
|
setPermissionQueue((prev) => [
|
||||||
@@ -402,6 +431,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
prev ? { ...prev, response, loading: false } : prev,
|
prev ? { ...prev, response, loading: false } : prev,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
onLogEntry: (timestamp, level, message) => {
|
||||||
|
setServerLogs((prev) => [...prev, { timestamp, level, message }]);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -813,7 +845,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
fontSize: "1.1rem",
|
fontSize: "1.1rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Welcome to Story Kit
|
Welcome to Storkit
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
@@ -999,28 +1031,34 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
<StagePanel
|
<StagePanel
|
||||||
title="Done"
|
title="Done"
|
||||||
items={pipeline.done ?? []}
|
items={pipeline.done ?? []}
|
||||||
|
costs={storyTokenCosts}
|
||||||
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
/>
|
/>
|
||||||
<StagePanel
|
<StagePanel
|
||||||
title="To Merge"
|
title="To Merge"
|
||||||
items={pipeline.merge}
|
items={pipeline.merge}
|
||||||
|
costs={storyTokenCosts}
|
||||||
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
/>
|
/>
|
||||||
<StagePanel
|
<StagePanel
|
||||||
title="QA"
|
title="QA"
|
||||||
items={pipeline.qa}
|
items={pipeline.qa}
|
||||||
|
costs={storyTokenCosts}
|
||||||
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
/>
|
/>
|
||||||
<StagePanel
|
<StagePanel
|
||||||
title="Current"
|
title="Current"
|
||||||
items={pipeline.current}
|
items={pipeline.current}
|
||||||
|
costs={storyTokenCosts}
|
||||||
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
/>
|
/>
|
||||||
<StagePanel
|
<StagePanel
|
||||||
title="Upcoming"
|
title="Backlog"
|
||||||
items={pipeline.upcoming}
|
items={pipeline.backlog}
|
||||||
|
costs={storyTokenCosts}
|
||||||
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
/>
|
/>
|
||||||
|
<ServerLogsPanel logs={serverLogs} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</LozengeFlyProvider>
|
</LozengeFlyProvider>
|
||||||
|
|||||||
@@ -136,9 +136,9 @@ describe("ChatHeader", () => {
|
|||||||
expect(screen.getByText("Built: 2026-01-01 00:00")).toBeInTheDocument();
|
expect(screen.getByText("Built: 2026-01-01 00:00")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("displays Story Kit branding in the header", () => {
|
it("displays Storkit branding in the header", () => {
|
||||||
render(<ChatHeader {...makeProps()} />);
|
render(<ChatHeader {...makeProps()} />);
|
||||||
expect(screen.getByText("Story Kit")).toBeInTheDocument();
|
expect(screen.getByText("Storkit")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("labels the claude-pty optgroup as 'Claude Code'", () => {
|
it("labels the claude-pty optgroup as 'Claude Code'", () => {
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function ChatHeader({
|
|||||||
letterSpacing: "0.02em",
|
letterSpacing: "0.02em",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Story Kit
|
Storkit
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
title={projectPath}
|
title={projectPath}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
|
|
||||||
const { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } = React;
|
const {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} = React;
|
||||||
|
|
||||||
export interface ChatInputHandle {
|
export interface ChatInputHandle {
|
||||||
appendToInput(text: string): void;
|
appendToInput(text: string): void;
|
||||||
@@ -131,10 +138,13 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Compute filtered files for current picker query
|
// Compute filtered files for current picker query
|
||||||
const filteredFiles = pickerQuery !== null
|
const filteredFiles =
|
||||||
|
pickerQuery !== null
|
||||||
? projectFiles
|
? projectFiles
|
||||||
.filter((f) => fuzzyMatch(f, pickerQuery))
|
.filter((f) => fuzzyMatch(f, pickerQuery))
|
||||||
.sort((a, b) => fuzzyScore(a, pickerQuery) - fuzzyScore(b, pickerQuery))
|
.sort(
|
||||||
|
(a, b) => fuzzyScore(a, pickerQuery) - fuzzyScore(b, pickerQuery),
|
||||||
|
)
|
||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
@@ -177,7 +187,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
|
|
||||||
// Lazily load files on first trigger
|
// Lazily load files on first trigger
|
||||||
if (projectFiles.length === 0) {
|
if (projectFiles.length === 0) {
|
||||||
api.listProjectFiles().then(setProjectFiles).catch(() => {});
|
api
|
||||||
|
.listProjectFiles()
|
||||||
|
.then(setProjectFiles)
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (pickerQuery !== null) dismissPicker();
|
if (pickerQuery !== null) dismissPicker();
|
||||||
@@ -191,7 +204,9 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
if (pickerQuery !== null && filteredFiles.length > 0) {
|
if (pickerQuery !== null && filteredFiles.length > 0) {
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPickerSelectedIndex((i) => Math.min(i + 1, filteredFiles.length - 1));
|
setPickerSelectedIndex((i) =>
|
||||||
|
Math.min(i + 1, filteredFiles.length - 1),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === "ArrowUp") {
|
if (e.key === "ArrowUp") {
|
||||||
@@ -220,7 +235,13 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
handleSubmit();
|
handleSubmit();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[pickerQuery, filteredFiles, pickerSelectedIndex, selectFile, dismissPicker],
|
[
|
||||||
|
pickerQuery,
|
||||||
|
filteredFiles,
|
||||||
|
pickerSelectedIndex,
|
||||||
|
selectFile,
|
||||||
|
dismissPicker,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { StagePanel } from "./StagePanel";
|
|||||||
|
|
||||||
function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
|
function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
|
||||||
return {
|
return {
|
||||||
upcoming: [],
|
backlog: [],
|
||||||
current: [],
|
current: [],
|
||||||
qa: [],
|
qa: [],
|
||||||
merge: [],
|
merge: [],
|
||||||
@@ -59,6 +59,8 @@ describe("AgentLozenge fixed intrinsic width", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
|
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const pipeline = makePipeline({ current: items });
|
const pipeline = makePipeline({ current: items });
|
||||||
@@ -111,6 +113,8 @@ describe("LozengeFlyProvider fly-in visibility", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -151,6 +155,8 @@ describe("LozengeFlyProvider fly-in visibility", () => {
|
|||||||
model: null,
|
model: null,
|
||||||
status: "running",
|
status: "running",
|
||||||
},
|
},
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -213,6 +219,8 @@ describe("LozengeFlyProvider fly-in clone", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
|
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -254,6 +262,8 @@ describe("LozengeFlyProvider fly-in clone", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -301,6 +311,8 @@ describe("LozengeFlyProvider fly-in clone", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -370,6 +382,8 @@ describe("LozengeFlyProvider fly-out", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-1", model: "haiku", status: "completed" },
|
agent: { agent_name: "coder-1", model: "haiku", status: "completed" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -395,6 +409,8 @@ describe("LozengeFlyProvider fly-out", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: null,
|
agent: null,
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -427,6 +443,8 @@ describe("AgentLozenge idle vs active appearance", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
@@ -451,6 +469,8 @@ describe("AgentLozenge idle vs active appearance", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-1", model: null, status: "pending" },
|
agent: { agent_name: "coder-1", model: null, status: "pending" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
@@ -475,6 +495,8 @@ describe("AgentLozenge idle vs active appearance", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
@@ -526,6 +548,8 @@ describe("hiddenRosterAgents: assigned agents are absent from roster", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -547,6 +571,8 @@ describe("hiddenRosterAgents: assigned agents are absent from roster", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: null,
|
agent: null,
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -569,6 +595,8 @@ describe("hiddenRosterAgents: assigned agents are absent from roster", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -629,6 +657,8 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", ()
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-1", model: null, status: "completed" },
|
agent: { agent_name: "coder-1", model: null, status: "completed" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -640,6 +670,8 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", ()
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: null,
|
agent: null,
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -682,6 +714,8 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", ()
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-1", model: null, status: "completed" },
|
agent: { agent_name: "coder-1", model: null, status: "completed" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -693,6 +727,8 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", ()
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: null,
|
agent: null,
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -766,6 +802,8 @@ describe("LozengeFlyProvider agent swap (name change)", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
|
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -777,6 +815,8 @@ describe("LozengeFlyProvider agent swap (name change)", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-2", model: "haiku", status: "running" },
|
agent: { agent_name: "coder-2", model: "haiku", status: "running" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -861,6 +901,8 @@ describe("LozengeFlyProvider fly-out without roster element", () => {
|
|||||||
model: null,
|
model: null,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
},
|
},
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -872,6 +914,8 @@ describe("LozengeFlyProvider fly-out without roster element", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: null,
|
agent: null,
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -943,6 +987,8 @@ describe("FlyingLozengeClone initial non-flying render", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -1018,6 +1064,8 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", ()
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
|
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -1029,6 +1077,8 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", ()
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-2", model: "haiku", status: "running" },
|
agent: { agent_name: "coder-2", model: "haiku", status: "running" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -1095,6 +1145,8 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", ()
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -1106,6 +1158,8 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", ()
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: "coder-2", model: null, status: "running" },
|
agent: { agent_name: "coder-2", model: null, status: "running" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -1191,6 +1245,8 @@ describe("Bug 137: animations remain functional through sustained agent activity
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: { agent_name: agentName, model: null, status: "running" },
|
agent: { agent_name: agentName, model: null, status: "running" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function LozengeFlyProvider({
|
|||||||
const assignedAgentNames = useMemo(() => {
|
const assignedAgentNames = useMemo(() => {
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
for (const item of [
|
for (const item of [
|
||||||
...pipeline.upcoming,
|
...pipeline.backlog,
|
||||||
...pipeline.current,
|
...pipeline.current,
|
||||||
...pipeline.qa,
|
...pipeline.qa,
|
||||||
...pipeline.merge,
|
...pipeline.merge,
|
||||||
@@ -165,13 +165,13 @@ export function LozengeFlyProvider({
|
|||||||
|
|
||||||
const prev = prevPipelineRef.current;
|
const prev = prevPipelineRef.current;
|
||||||
const allPrev = [
|
const allPrev = [
|
||||||
...prev.upcoming,
|
...prev.backlog,
|
||||||
...prev.current,
|
...prev.current,
|
||||||
...prev.qa,
|
...prev.qa,
|
||||||
...prev.merge,
|
...prev.merge,
|
||||||
];
|
];
|
||||||
const allCurr = [
|
const allCurr = [
|
||||||
...pipeline.upcoming,
|
...pipeline.backlog,
|
||||||
...pipeline.current,
|
...pipeline.current,
|
||||||
...pipeline.qa,
|
...pipeline.qa,
|
||||||
...pipeline.merge,
|
...pipeline.merge,
|
||||||
|
|||||||
246
frontend/src/components/ServerLogsPanel.tsx
Normal file
246
frontend/src/components/ServerLogsPanel.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const { useCallback, useEffect, useRef, useState } = React;
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: string;
|
||||||
|
level: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerLogsPanelProps {
|
||||||
|
logs: LogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelColor(level: string): string {
|
||||||
|
switch (level.toUpperCase()) {
|
||||||
|
case "ERROR":
|
||||||
|
return "#e06c75";
|
||||||
|
case "WARN":
|
||||||
|
return "#e5c07b";
|
||||||
|
default:
|
||||||
|
return "#98c379";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerLogsPanel({ logs }: ServerLogsPanelProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const [severityFilter, setSeverityFilter] = useState<string>("ALL");
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const userScrolledUpRef = useRef(false);
|
||||||
|
const lastScrollTopRef = useRef(0);
|
||||||
|
|
||||||
|
const filteredLogs = logs.filter((entry) => {
|
||||||
|
const matchesSeverity =
|
||||||
|
severityFilter === "ALL" || entry.level.toUpperCase() === severityFilter;
|
||||||
|
const matchesFilter =
|
||||||
|
filter === "" ||
|
||||||
|
entry.message.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
|
entry.timestamp.includes(filter);
|
||||||
|
return matchesSeverity && matchesFilter;
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
lastScrollTopRef.current = el.scrollTop;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-scroll when new entries arrive (unless user scrolled up).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
if (!userScrolledUpRef.current) {
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
}, [filteredLogs.length, isOpen, scrollToBottom]);
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 5;
|
||||||
|
if (el.scrollTop < lastScrollTopRef.current) {
|
||||||
|
userScrolledUpRef.current = true;
|
||||||
|
}
|
||||||
|
if (isAtBottom) {
|
||||||
|
userScrolledUpRef.current = false;
|
||||||
|
}
|
||||||
|
lastScrollTopRef.current = el.scrollTop;
|
||||||
|
};
|
||||||
|
|
||||||
|
const severityButtons = ["ALL", "INFO", "WARN", "ERROR"] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="server-logs-panel"
|
||||||
|
style={{
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "1px solid #333",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header / toggle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="server-logs-panel-toggle"
|
||||||
|
onClick={() => setIsOpen((v) => !v)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "#1e1e1e",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#ccc",
|
||||||
|
fontSize: "0.85em",
|
||||||
|
fontWeight: 600,
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Server Logs</span>
|
||||||
|
<span style={{ color: "#666", fontSize: "0.85em" }}>
|
||||||
|
{logs.length > 0 && (
|
||||||
|
<span style={{ marginRight: "8px", color: "#555" }}>
|
||||||
|
{logs.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isOpen ? "▲" : "▼"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div style={{ background: "#0d1117" }}>
|
||||||
|
{/* Filter controls */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "6px",
|
||||||
|
padding: "8px",
|
||||||
|
borderBottom: "1px solid #1e1e1e",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
data-testid="server-logs-filter-input"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
placeholder="Filter logs..."
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: "80px",
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid #333",
|
||||||
|
background: "#161b22",
|
||||||
|
color: "#ccc",
|
||||||
|
fontSize: "0.8em",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{severityButtons.map((sev) => (
|
||||||
|
<button
|
||||||
|
key={sev}
|
||||||
|
type="button"
|
||||||
|
data-testid={`server-logs-severity-${sev.toLowerCase()}`}
|
||||||
|
onClick={() => setSeverityFilter(sev)}
|
||||||
|
style={{
|
||||||
|
padding: "3px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor:
|
||||||
|
severityFilter === sev ? levelColor(sev) : "#333",
|
||||||
|
background:
|
||||||
|
severityFilter === sev
|
||||||
|
? "rgba(255,255,255,0.06)"
|
||||||
|
: "transparent",
|
||||||
|
color:
|
||||||
|
sev === "ALL"
|
||||||
|
? severityFilter === "ALL"
|
||||||
|
? "#ccc"
|
||||||
|
: "#555"
|
||||||
|
: levelColor(sev),
|
||||||
|
fontSize: "0.75em",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: severityFilter === sev ? 700 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sev}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log entries */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
data-testid="server-logs-entries"
|
||||||
|
style={{
|
||||||
|
maxHeight: "240px",
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: "4px 0",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "0.75em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredLogs.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "16px",
|
||||||
|
color: "#444",
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "0.9em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No log entries
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredLogs.map((entry, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${entry.timestamp}-${idx}`}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "6px",
|
||||||
|
padding: "1px 8px",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
borderBottom: "1px solid #111",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{ color: "#444", flexShrink: 0, minWidth: "70px" }}
|
||||||
|
>
|
||||||
|
{entry.timestamp.replace("T", " ").replace("Z", "")}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: levelColor(entry.level),
|
||||||
|
flexShrink: 0,
|
||||||
|
minWidth: "38px",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.level}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "#c9d1d9",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ describe("StagePanel", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: null,
|
agent: null,
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
render(<StagePanel title="Current" items={items} />);
|
render(<StagePanel title="Current" items={items} />);
|
||||||
@@ -37,6 +39,8 @@ describe("StagePanel", () => {
|
|||||||
model: "sonnet",
|
model: "sonnet",
|
||||||
status: "running",
|
status: "running",
|
||||||
},
|
},
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
render(<StagePanel title="Current" items={items} />);
|
render(<StagePanel title="Current" items={items} />);
|
||||||
@@ -56,6 +60,8 @@ describe("StagePanel", () => {
|
|||||||
model: null,
|
model: null,
|
||||||
status: "running",
|
status: "running",
|
||||||
},
|
},
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
render(<StagePanel title="Current" items={items} />);
|
render(<StagePanel title="Current" items={items} />);
|
||||||
@@ -74,6 +80,8 @@ describe("StagePanel", () => {
|
|||||||
model: "haiku",
|
model: "haiku",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
},
|
},
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
render(<StagePanel title="QA" items={items} />);
|
render(<StagePanel title="QA" items={items} />);
|
||||||
@@ -88,6 +96,8 @@ describe("StagePanel", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: null,
|
agent: null,
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
render(<StagePanel title="Current" items={items} />);
|
render(<StagePanel title="Current" items={items} />);
|
||||||
@@ -102,6 +112,8 @@ describe("StagePanel", () => {
|
|||||||
error: "Missing front matter",
|
error: "Missing front matter",
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: null,
|
agent: null,
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
render(<StagePanel title="Upcoming" items={items} />);
|
render(<StagePanel title="Upcoming" items={items} />);
|
||||||
@@ -116,6 +128,8 @@ describe("StagePanel", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: null,
|
agent: null,
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
render(<StagePanel title="Upcoming" items={items} />);
|
render(<StagePanel title="Upcoming" items={items} />);
|
||||||
@@ -132,6 +146,8 @@ describe("StagePanel", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: null,
|
agent: null,
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
render(<StagePanel title="Current" items={items} />);
|
render(<StagePanel title="Current" items={items} />);
|
||||||
@@ -148,6 +164,8 @@ describe("StagePanel", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: null,
|
agent: null,
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
render(<StagePanel title="QA" items={items} />);
|
render(<StagePanel title="QA" items={items} />);
|
||||||
@@ -164,6 +182,8 @@ describe("StagePanel", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: null,
|
agent: null,
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
render(<StagePanel title="Done" items={items} />);
|
render(<StagePanel title="Done" items={items} />);
|
||||||
@@ -180,6 +200,8 @@ describe("StagePanel", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: null,
|
agent: null,
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
render(<StagePanel title="Upcoming" items={items} />);
|
render(<StagePanel title="Upcoming" items={items} />);
|
||||||
@@ -199,6 +221,8 @@ describe("StagePanel", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: null,
|
agent: null,
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
render(<StagePanel title="Current" items={items} />);
|
render(<StagePanel title="Current" items={items} />);
|
||||||
@@ -215,6 +239,8 @@ describe("StagePanel", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: null,
|
agent: null,
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
render(<StagePanel title="QA" items={items} />);
|
render(<StagePanel title="QA" items={items} />);
|
||||||
@@ -231,6 +257,8 @@ describe("StagePanel", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: null,
|
agent: null,
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
render(<StagePanel title="Done" items={items} />);
|
render(<StagePanel title="Done" items={items} />);
|
||||||
@@ -247,6 +275,8 @@ describe("StagePanel", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: "Squash merge failed: conflicts in Cargo.lock",
|
merge_failure: "Squash merge failed: conflicts in Cargo.lock",
|
||||||
agent: null,
|
agent: null,
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
render(<StagePanel title="Merge" items={items} />);
|
render(<StagePanel title="Merge" items={items} />);
|
||||||
@@ -266,6 +296,8 @@ describe("StagePanel", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
merge_failure: null,
|
merge_failure: null,
|
||||||
agent: null,
|
agent: null,
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
render(<StagePanel title="Merge" items={items} />);
|
render(<StagePanel title="Merge" items={items} />);
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ interface StagePanelProps {
|
|||||||
items: PipelineStageItem[];
|
items: PipelineStageItem[];
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
onItemClick?: (item: PipelineStageItem) => void;
|
onItemClick?: (item: PipelineStageItem) => void;
|
||||||
|
/** Map of story_id → total_cost_usd for displaying cost badges. */
|
||||||
|
costs?: Map<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentLozenge({
|
function AgentLozenge({
|
||||||
@@ -128,6 +130,7 @@ export function StagePanel({
|
|||||||
items,
|
items,
|
||||||
emptyMessage = "Empty.",
|
emptyMessage = "Empty.",
|
||||||
onItemClick,
|
onItemClick,
|
||||||
|
costs,
|
||||||
}: StagePanelProps) {
|
}: StagePanelProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -240,6 +243,19 @@ export function StagePanel({
|
|||||||
{typeLabel}
|
{typeLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{costs?.has(item.story_id) && (
|
||||||
|
<span
|
||||||
|
data-testid={`cost-badge-${item.story_id}`}
|
||||||
|
style={{
|
||||||
|
fontSize: "0.65em",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#e3b341",
|
||||||
|
marginRight: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${costs.get(item.story_id)?.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{item.name ?? item.story_id}
|
{item.name ?? item.story_id}
|
||||||
</div>
|
</div>
|
||||||
{item.error && (
|
{item.error && (
|
||||||
|
|||||||
440
frontend/src/components/TokenUsagePage.tsx
Normal file
440
frontend/src/components/TokenUsagePage.tsx
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import type { TokenUsageRecord } from "../api/client";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
|
||||||
|
type SortKey =
|
||||||
|
| "timestamp"
|
||||||
|
| "story_id"
|
||||||
|
| "agent_name"
|
||||||
|
| "model"
|
||||||
|
| "total_cost_usd";
|
||||||
|
type SortDir = "asc" | "desc";
|
||||||
|
|
||||||
|
function formatCost(usd: number): string {
|
||||||
|
if (usd === 0) return "$0.00";
|
||||||
|
if (usd < 0.001) return `$${usd.toFixed(6)}`;
|
||||||
|
if (usd < 0.01) return `$${usd.toFixed(4)}`;
|
||||||
|
return `$${usd.toFixed(3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
const h = String(d.getHours()).padStart(2, "0");
|
||||||
|
const m = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day} ${h}:${m}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Infer an agent type from the agent name. */
|
||||||
|
function agentType(agentName: string): string {
|
||||||
|
const lower = agentName.toLowerCase();
|
||||||
|
if (lower.startsWith("coder")) return "coder";
|
||||||
|
if (lower.startsWith("qa")) return "qa";
|
||||||
|
if (lower.startsWith("mergemaster") || lower.startsWith("merge"))
|
||||||
|
return "mergemaster";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortHeaderProps {
|
||||||
|
label: string;
|
||||||
|
sortKey: SortKey;
|
||||||
|
current: SortKey;
|
||||||
|
dir: SortDir;
|
||||||
|
onSort: (key: SortKey) => void;
|
||||||
|
align?: "left" | "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortHeader({
|
||||||
|
label,
|
||||||
|
sortKey,
|
||||||
|
current,
|
||||||
|
dir,
|
||||||
|
onSort,
|
||||||
|
align = "left",
|
||||||
|
}: SortHeaderProps) {
|
||||||
|
const active = current === sortKey;
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
textAlign: align,
|
||||||
|
cursor: "pointer",
|
||||||
|
userSelect: "none",
|
||||||
|
borderBottom: "1px solid #333",
|
||||||
|
color: active ? "#ececec" : "#aaa",
|
||||||
|
fontWeight: active ? "700" : "500",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontSize: "0.8em",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
onClick={() => onSort(sortKey)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{active ? (dir === "asc" ? " ↑" : " ↓") : ""}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenUsagePageProps {
|
||||||
|
projectPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TokenUsagePage({
|
||||||
|
projectPath: _projectPath,
|
||||||
|
}: TokenUsagePageProps) {
|
||||||
|
const [records, setRecords] = React.useState<TokenUsageRecord[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [sortKey, setSortKey] = React.useState<SortKey>("timestamp");
|
||||||
|
const [sortDir, setSortDir] = React.useState<SortDir>("desc");
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
api
|
||||||
|
.getAllTokenUsage()
|
||||||
|
.then((resp) => setRecords(resp.records))
|
||||||
|
.catch((e) =>
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load token usage"),
|
||||||
|
)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleSort(key: SortKey) {
|
||||||
|
if (key === sortKey) {
|
||||||
|
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||||
|
} else {
|
||||||
|
setSortKey(key);
|
||||||
|
setSortDir(key === "timestamp" ? "desc" : "asc");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = React.useMemo(() => {
|
||||||
|
return [...records].sort((a, b) => {
|
||||||
|
let cmp = 0;
|
||||||
|
switch (sortKey) {
|
||||||
|
case "timestamp":
|
||||||
|
cmp = a.timestamp.localeCompare(b.timestamp);
|
||||||
|
break;
|
||||||
|
case "story_id":
|
||||||
|
cmp = a.story_id.localeCompare(b.story_id);
|
||||||
|
break;
|
||||||
|
case "agent_name":
|
||||||
|
cmp = a.agent_name.localeCompare(b.agent_name);
|
||||||
|
break;
|
||||||
|
case "model":
|
||||||
|
cmp = (a.model ?? "").localeCompare(b.model ?? "");
|
||||||
|
break;
|
||||||
|
case "total_cost_usd":
|
||||||
|
cmp = a.total_cost_usd - b.total_cost_usd;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return sortDir === "asc" ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
}, [records, sortKey, sortDir]);
|
||||||
|
|
||||||
|
// Compute summary totals
|
||||||
|
const totalCost = records.reduce((s, r) => s + r.total_cost_usd, 0);
|
||||||
|
|
||||||
|
const byAgentType = React.useMemo(() => {
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
for (const r of records) {
|
||||||
|
const t = agentType(r.agent_name);
|
||||||
|
map[t] = (map[t] ?? 0) + r.total_cost_usd;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [records]);
|
||||||
|
|
||||||
|
const byModel = React.useMemo(() => {
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
for (const r of records) {
|
||||||
|
const m = r.model ?? "unknown";
|
||||||
|
map[m] = (map[m] ?? 0) + r.total_cost_usd;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [records]);
|
||||||
|
|
||||||
|
const cellStyle: React.CSSProperties = {
|
||||||
|
padding: "7px 12px",
|
||||||
|
borderBottom: "1px solid #222",
|
||||||
|
fontSize: "0.85em",
|
||||||
|
color: "#ccc",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
overflowY: "auto",
|
||||||
|
background: "#111",
|
||||||
|
padding: "24px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
color: "#ececec",
|
||||||
|
margin: "0 0 20px",
|
||||||
|
fontSize: "1.1em",
|
||||||
|
fontWeight: "700",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Token Usage
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Summary totals */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "16px",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
marginBottom: "24px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SummaryCard
|
||||||
|
label="Total Cost"
|
||||||
|
value={formatCost(totalCost)}
|
||||||
|
highlight
|
||||||
|
/>
|
||||||
|
{Object.entries(byAgentType)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([type, cost]) => (
|
||||||
|
<SummaryCard
|
||||||
|
key={type}
|
||||||
|
label={`${type.charAt(0).toUpperCase()}${type.slice(1)}`}
|
||||||
|
value={formatCost(cost)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{Object.entries(byModel)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.map(([model, cost]) => (
|
||||||
|
<SummaryCard key={model} label={model} value={formatCost(cost)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<p style={{ color: "#555", fontSize: "0.9em" }}>Loading...</p>
|
||||||
|
)}
|
||||||
|
{error && <p style={{ color: "#e05c5c", fontSize: "0.9em" }}>{error}</p>}
|
||||||
|
|
||||||
|
{!loading && !error && records.length === 0 && (
|
||||||
|
<p style={{ color: "#555", fontSize: "0.9em" }}>
|
||||||
|
No token usage records found.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && records.length > 0 && (
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
fontSize: "0.9em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: "#1a1a1a" }}>
|
||||||
|
<SortHeader
|
||||||
|
label="Date"
|
||||||
|
sortKey="timestamp"
|
||||||
|
current={sortKey}
|
||||||
|
dir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label="Story"
|
||||||
|
sortKey="story_id"
|
||||||
|
current={sortKey}
|
||||||
|
dir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label="Agent"
|
||||||
|
sortKey="agent_name"
|
||||||
|
current={sortKey}
|
||||||
|
dir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label="Model"
|
||||||
|
sortKey="model"
|
||||||
|
current={sortKey}
|
||||||
|
dir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
...cellStyle,
|
||||||
|
borderBottom: "1px solid #333",
|
||||||
|
textAlign: "right",
|
||||||
|
color: "#aaa",
|
||||||
|
fontSize: "0.8em",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Input
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
...cellStyle,
|
||||||
|
borderBottom: "1px solid #333",
|
||||||
|
textAlign: "right",
|
||||||
|
color: "#aaa",
|
||||||
|
fontSize: "0.8em",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cache+
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
...cellStyle,
|
||||||
|
borderBottom: "1px solid #333",
|
||||||
|
textAlign: "right",
|
||||||
|
color: "#aaa",
|
||||||
|
fontSize: "0.8em",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cache↩
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
...cellStyle,
|
||||||
|
borderBottom: "1px solid #333",
|
||||||
|
textAlign: "right",
|
||||||
|
color: "#aaa",
|
||||||
|
fontSize: "0.8em",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Output
|
||||||
|
</th>
|
||||||
|
<SortHeader
|
||||||
|
label="Cost"
|
||||||
|
sortKey="total_cost_usd"
|
||||||
|
current={sortKey}
|
||||||
|
dir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sorted.map((r, i) => (
|
||||||
|
<tr
|
||||||
|
key={`${r.story_id}-${r.agent_name}-${r.timestamp}`}
|
||||||
|
style={{ background: i % 2 === 0 ? "#111" : "#161616" }}
|
||||||
|
>
|
||||||
|
<td style={cellStyle}>{formatTimestamp(r.timestamp)}</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
...cellStyle,
|
||||||
|
color: "#8b9cf7",
|
||||||
|
maxWidth: "220px",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.story_id}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...cellStyle, color: "#7ec8a4" }}>
|
||||||
|
{r.agent_name}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...cellStyle, color: "#c9a96e" }}>
|
||||||
|
{r.model ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...cellStyle, textAlign: "right" }}>
|
||||||
|
{formatTokens(r.input_tokens)}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...cellStyle, textAlign: "right" }}>
|
||||||
|
{formatTokens(r.cache_creation_input_tokens)}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...cellStyle, textAlign: "right" }}>
|
||||||
|
{formatTokens(r.cache_read_input_tokens)}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...cellStyle, textAlign: "right" }}>
|
||||||
|
{formatTokens(r.output_tokens)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
...cellStyle,
|
||||||
|
textAlign: "right",
|
||||||
|
color: "#e08c5c",
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCost(r.total_cost_usd)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
highlight = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
highlight?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: highlight ? "#1e1e2e" : "#1a1a1a",
|
||||||
|
border: `1px solid ${highlight ? "#3a3a5a" : "#2a2a2a"}`,
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "12px 16px",
|
||||||
|
minWidth: "120px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.7em",
|
||||||
|
color: "#666",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.07em",
|
||||||
|
marginBottom: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "1.1em",
|
||||||
|
fontWeight: "700",
|
||||||
|
color: highlight ? "#c9a96e" : "#ececec",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { AgentEvent, AgentInfo } from "../api/agents";
|
import type { AgentEvent, AgentInfo } from "../api/agents";
|
||||||
import type { TestResultsResponse } from "../api/client";
|
import type { TestResultsResponse, TokenCostResponse } from "../api/client";
|
||||||
|
|
||||||
vi.mock("../api/client", async () => {
|
vi.mock("../api/client", async () => {
|
||||||
const actual =
|
const actual =
|
||||||
@@ -12,6 +12,7 @@ vi.mock("../api/client", async () => {
|
|||||||
...actual.api,
|
...actual.api,
|
||||||
getWorkItemContent: vi.fn(),
|
getWorkItemContent: vi.fn(),
|
||||||
getTestResults: vi.fn(),
|
getTestResults: vi.fn(),
|
||||||
|
getTokenCost: vi.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -30,6 +31,7 @@ const { WorkItemDetailPanel } = await import("./WorkItemDetailPanel");
|
|||||||
|
|
||||||
const mockedGetWorkItemContent = vi.mocked(api.getWorkItemContent);
|
const mockedGetWorkItemContent = vi.mocked(api.getWorkItemContent);
|
||||||
const mockedGetTestResults = vi.mocked(api.getTestResults);
|
const mockedGetTestResults = vi.mocked(api.getTestResults);
|
||||||
|
const mockedGetTokenCost = vi.mocked(api.getTokenCost);
|
||||||
const mockedListAgents = vi.mocked(agentsApi.listAgents);
|
const mockedListAgents = vi.mocked(agentsApi.listAgents);
|
||||||
const mockedSubscribeAgentStream = vi.mocked(subscribeAgentStream);
|
const mockedSubscribeAgentStream = vi.mocked(subscribeAgentStream);
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockedGetWorkItemContent.mockResolvedValue(DEFAULT_CONTENT);
|
mockedGetWorkItemContent.mockResolvedValue(DEFAULT_CONTENT);
|
||||||
mockedGetTestResults.mockResolvedValue(null);
|
mockedGetTestResults.mockResolvedValue(null);
|
||||||
|
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
|
||||||
mockedListAgents.mockResolvedValue([]);
|
mockedListAgents.mockResolvedValue([]);
|
||||||
mockedSubscribeAgentStream.mockReturnValue(() => {});
|
mockedSubscribeAgentStream.mockReturnValue(() => {});
|
||||||
});
|
});
|
||||||
@@ -608,3 +611,146 @@ describe("WorkItemDetailPanel - Test Results", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ import * as React from "react";
|
|||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
import type { AgentEvent, AgentInfo, AgentStatusValue } from "../api/agents";
|
import type { AgentEvent, AgentInfo, AgentStatusValue } from "../api/agents";
|
||||||
import { agentsApi, subscribeAgentStream } from "../api/agents";
|
import { agentsApi, subscribeAgentStream } from "../api/agents";
|
||||||
import type { TestCaseResult, TestResultsResponse } from "../api/client";
|
import type {
|
||||||
|
AgentCostEntry,
|
||||||
|
TestCaseResult,
|
||||||
|
TestResultsResponse,
|
||||||
|
TokenCostResponse,
|
||||||
|
} from "../api/client";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
|
|
||||||
const { useEffect, useRef, useState } = React;
|
const { useEffect, useRef, useState } = React;
|
||||||
|
|
||||||
const STAGE_LABELS: Record<string, string> = {
|
const STAGE_LABELS: Record<string, string> = {
|
||||||
upcoming: "Upcoming",
|
backlog: "Backlog",
|
||||||
current: "Current",
|
current: "Current",
|
||||||
qa: "QA",
|
qa: "QA",
|
||||||
merge: "To Merge",
|
merge: "To Merge",
|
||||||
@@ -27,6 +32,8 @@ interface WorkItemDetailPanelProps {
|
|||||||
storyId: string;
|
storyId: string;
|
||||||
pipelineVersion: number;
|
pipelineVersion: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/** True when the item is in QA and awaiting human review. */
|
||||||
|
reviewHold?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TestCaseRow({ tc }: { tc: TestCaseResult }) {
|
function TestCaseRow({ tc }: { tc: TestCaseResult }) {
|
||||||
@@ -109,6 +116,7 @@ export function WorkItemDetailPanel({
|
|||||||
storyId,
|
storyId,
|
||||||
pipelineVersion,
|
pipelineVersion,
|
||||||
onClose,
|
onClose,
|
||||||
|
reviewHold: _reviewHold,
|
||||||
}: WorkItemDetailPanelProps) {
|
}: WorkItemDetailPanelProps) {
|
||||||
const [content, setContent] = useState<string | null>(null);
|
const [content, setContent] = useState<string | null>(null);
|
||||||
const [stage, setStage] = useState<string>("");
|
const [stage, setStage] = useState<string>("");
|
||||||
@@ -122,6 +130,7 @@ export function WorkItemDetailPanel({
|
|||||||
const [testResults, setTestResults] = useState<TestResultsResponse | null>(
|
const [testResults, setTestResults] = useState<TestResultsResponse | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [tokenCost, setTokenCost] = useState<TokenCostResponse | null>(null);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const cleanupRef = useRef<(() => void) | null>(null);
|
const cleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
@@ -156,6 +165,18 @@ export function WorkItemDetailPanel({
|
|||||||
});
|
});
|
||||||
}, [storyId, pipelineVersion]);
|
}, [storyId, pipelineVersion]);
|
||||||
|
|
||||||
|
// Fetch token cost on mount and when pipeline updates arrive.
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.getTokenCost(storyId)
|
||||||
|
.then((data) => {
|
||||||
|
setTokenCost(data);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Silently ignore — token cost may not exist yet.
|
||||||
|
});
|
||||||
|
}, [storyId, pipelineVersion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cleanupRef.current?.();
|
cleanupRef.current?.();
|
||||||
cleanupRef.current = null;
|
cleanupRef.current = null;
|
||||||
@@ -362,6 +383,96 @@ export function WorkItemDetailPanel({
|
|||||||
</div>
|
</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>
|
||||||
|
|
||||||
{/* Test Results section */}
|
{/* Test Results section */}
|
||||||
<div
|
<div
|
||||||
data-testid="test-results-section"
|
data-testid="test-results-section"
|
||||||
@@ -420,10 +531,34 @@ export function WorkItemDetailPanel({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Agent Logs section */}
|
{/* Agent Logs section */}
|
||||||
|
{!agentInfo && (
|
||||||
<div
|
<div
|
||||||
data-testid={
|
data-testid="placeholder-agent-logs"
|
||||||
agentInfo ? "agent-logs-section" : "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={{
|
style={{
|
||||||
border: "1px solid #2a2a2a",
|
border: "1px solid #2a2a2a",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
@@ -436,19 +571,19 @@ export function WorkItemDetailPanel({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
marginBottom: agentInfo ? "6px" : "4px",
|
marginBottom: "6px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: "0.8em",
|
fontSize: "0.8em",
|
||||||
color: agentInfo ? "#888" : "#555",
|
color: "#888",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Agent Logs
|
Agent Logs
|
||||||
</div>
|
</div>
|
||||||
{agentInfo && agentStatus && (
|
{agentStatus && (
|
||||||
<div
|
<div
|
||||||
data-testid="agent-status-badge"
|
data-testid="agent-status-badge"
|
||||||
style={{
|
style={{
|
||||||
@@ -461,7 +596,7 @@ export function WorkItemDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{agentInfo && agentLog.length > 0 ? (
|
{agentLog.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
data-testid="agent-log-output"
|
data-testid="agent-log-output"
|
||||||
style={{
|
style={{
|
||||||
@@ -477,18 +612,15 @@ export function WorkItemDetailPanel({
|
|||||||
>
|
>
|
||||||
{agentLog.join("")}
|
{agentLog.join("")}
|
||||||
</div>
|
</div>
|
||||||
) : agentInfo ? (
|
) : (
|
||||||
<div style={{ fontSize: "0.75em", color: "#444" }}>
|
<div style={{ fontSize: "0.75em", color: "#444" }}>
|
||||||
{agentStatus === "running" || agentStatus === "pending"
|
{agentStatus === "running" || agentStatus === "pending"
|
||||||
? "Waiting for output..."
|
? "Waiting for output..."
|
||||||
: "No output."}
|
: "No output."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div style={{ fontSize: "0.75em", color: "#444" }}>
|
|
||||||
Coming soon
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Placeholder sections for future content */}
|
{/* Placeholder sections for future content */}
|
||||||
{(
|
{(
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function makeProps(
|
|||||||
describe("SelectionScreen", () => {
|
describe("SelectionScreen", () => {
|
||||||
it("renders the title and description", () => {
|
it("renders the title and description", () => {
|
||||||
render(<SelectionScreen {...makeProps()} />);
|
render(<SelectionScreen {...makeProps()} />);
|
||||||
expect(screen.getByText("Story Kit")).toBeInTheDocument();
|
expect(screen.getByText("Storkit")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Paste or complete a project path to start."),
|
screen.getByText("Paste or complete a project path to start."),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function SelectionScreen({
|
|||||||
className="selection-screen"
|
className="selection-screen"
|
||||||
style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }}
|
style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }}
|
||||||
>
|
>
|
||||||
<h1>Story Kit</h1>
|
<h1>Storkit</h1>
|
||||||
<p>Paste or complete a project path to start.</p>
|
<p>Paste or complete a project path to start.</p>
|
||||||
|
|
||||||
{knownProjects.length > 0 && (
|
{knownProjects.length > 0 && (
|
||||||
|
|||||||
@@ -1 +1,20 @@
|
|||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
|
import { beforeEach, vi } from "vitest";
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
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 body = arrayEndpoints.some((ep) => url.endsWith(ep))
|
||||||
|
? JSON.stringify([])
|
||||||
|
: JSON.stringify({});
|
||||||
|
return Promise.resolve(new Response(body, { status: 200 }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export default defineConfig(() => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/agents": {
|
||||||
|
target: `http://127.0.0.1:${String(backendPort)}`,
|
||||||
|
timeout: 120000,
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on("error", (_err) => {});
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
ignored: [
|
ignored: [
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
|
environmentOptions: {
|
||||||
|
jsdom: {
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
},
|
||||||
|
},
|
||||||
globals: true,
|
globals: true,
|
||||||
testTimeout: 10_000,
|
testTimeout: 10_000,
|
||||||
setupFiles: ["./src/setupTests.ts"],
|
setupFiles: ["./src/setupTests.ts"],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use crate::io::story_metadata::clear_front_matter_field;
|
use crate::io::story_metadata::{clear_front_matter_field, write_rejection_notes};
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
|
|
||||||
pub(super) fn item_type_from_id(item_id: &str) -> &'static str {
|
pub(super) fn item_type_from_id(item_id: &str) -> &'static str {
|
||||||
@@ -16,9 +16,9 @@ pub(super) fn item_type_from_id(item_id: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the source directory path for a work item (always work/1_upcoming/).
|
/// Return the source directory path for a work item (always work/1_backlog/).
|
||||||
fn item_source_dir(project_root: &Path, _item_id: &str) -> PathBuf {
|
fn item_source_dir(project_root: &Path, _item_id: &str) -> PathBuf {
|
||||||
project_root.join(".story_kit").join("work").join("1_upcoming")
|
project_root.join(".story_kit").join("work").join("1_backlog")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the done directory path for a work item (always work/5_done/).
|
/// Return the done directory path for a work item (always work/5_done/).
|
||||||
@@ -26,10 +26,10 @@ fn item_archive_dir(project_root: &Path, _item_id: &str) -> PathBuf {
|
|||||||
project_root.join(".story_kit").join("work").join("5_done")
|
project_root.join(".story_kit").join("work").join("5_done")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move a work item (story, bug, or spike) from `work/1_upcoming/` to `work/2_current/`.
|
/// Move a work item (story, bug, or spike) from `work/1_backlog/` to `work/2_current/`.
|
||||||
///
|
///
|
||||||
/// Idempotent: if the item is already in `2_current/`, returns Ok without committing.
|
/// Idempotent: if the item is already in `2_current/`, returns Ok without committing.
|
||||||
/// If the item is not found in `1_upcoming/`, logs a warning and returns Ok.
|
/// If the item is not found in `1_backlog/`, logs a warning and returns Ok.
|
||||||
pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(), String> {
|
pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(), String> {
|
||||||
let sk = project_root.join(".story_kit").join("work");
|
let sk = project_root.join(".story_kit").join("work");
|
||||||
let current_dir = sk.join("2_current");
|
let current_dir = sk.join("2_current");
|
||||||
@@ -131,9 +131,11 @@ pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(),
|
|||||||
std::fs::rename(&source_path, &done_path)
|
std::fs::rename(&source_path, &done_path)
|
||||||
.map_err(|e| format!("Failed to move story '{story_id}' to 5_done/: {e}"))?;
|
.map_err(|e| format!("Failed to move story '{story_id}' to 5_done/: {e}"))?;
|
||||||
|
|
||||||
// Strip stale merge_failure from front matter now that the story is done.
|
// Strip stale pipeline fields from front matter now that the story is done.
|
||||||
if let Err(e) = clear_front_matter_field(&done_path, "merge_failure") {
|
for field in &["merge_failure", "retry_count", "blocked"] {
|
||||||
slog!("[lifecycle] Warning: could not clear merge_failure from '{story_id}': {e}");
|
if let Err(e) = clear_front_matter_field(&done_path, field) {
|
||||||
|
slog!("[lifecycle] Warning: could not clear {field} from '{story_id}': {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let from_dir = if source_path == current_path {
|
let from_dir = if source_path == current_path {
|
||||||
@@ -183,6 +185,14 @@ pub fn move_story_to_merge(project_root: &Path, story_id: &str) -> Result<(), St
|
|||||||
} else {
|
} else {
|
||||||
"work/3_qa/"
|
"work/3_qa/"
|
||||||
};
|
};
|
||||||
|
// Reset retry count and blocked for the new stage.
|
||||||
|
if let Err(e) = clear_front_matter_field(&merge_path, "retry_count") {
|
||||||
|
slog!("[lifecycle] Warning: could not clear retry_count for '{story_id}': {e}");
|
||||||
|
}
|
||||||
|
if let Err(e) = clear_front_matter_field(&merge_path, "blocked") {
|
||||||
|
slog!("[lifecycle] Warning: could not clear blocked for '{story_id}': {e}");
|
||||||
|
}
|
||||||
|
|
||||||
slog!("[lifecycle] Moved '{story_id}' from {from_dir} to work/4_merge/");
|
slog!("[lifecycle] Moved '{story_id}' from {from_dir} to work/4_merge/");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -214,21 +224,147 @@ pub fn move_story_to_qa(project_root: &Path, story_id: &str) -> Result<(), Strin
|
|||||||
std::fs::rename(¤t_path, &qa_path)
|
std::fs::rename(¤t_path, &qa_path)
|
||||||
.map_err(|e| format!("Failed to move '{story_id}' to 3_qa/: {e}"))?;
|
.map_err(|e| format!("Failed to move '{story_id}' to 3_qa/: {e}"))?;
|
||||||
|
|
||||||
|
// Reset retry count for the new stage.
|
||||||
|
if let Err(e) = clear_front_matter_field(&qa_path, "retry_count") {
|
||||||
|
slog!("[lifecycle] Warning: could not clear retry_count for '{story_id}': {e}");
|
||||||
|
}
|
||||||
|
if let Err(e) = clear_front_matter_field(&qa_path, "blocked") {
|
||||||
|
slog!("[lifecycle] Warning: could not clear blocked for '{story_id}': {e}");
|
||||||
|
}
|
||||||
|
|
||||||
slog!("[lifecycle] Moved '{story_id}' from work/2_current/ to work/3_qa/");
|
slog!("[lifecycle] Moved '{story_id}' from work/2_current/ to work/3_qa/");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move a bug from `work/2_current/` or `work/1_upcoming/` to `work/5_done/` and auto-commit.
|
/// Move a story from `work/3_qa/` back to `work/2_current/` and write rejection notes.
|
||||||
|
///
|
||||||
|
/// Used when a human reviewer rejects a story during manual QA.
|
||||||
|
/// Clears the `review_hold` front matter field and appends rejection notes to the story file.
|
||||||
|
pub fn reject_story_from_qa(
|
||||||
|
project_root: &Path,
|
||||||
|
story_id: &str,
|
||||||
|
notes: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let sk = project_root.join(".story_kit").join("work");
|
||||||
|
let qa_path = sk.join("3_qa").join(format!("{story_id}.md"));
|
||||||
|
let current_dir = sk.join("2_current");
|
||||||
|
let current_path = current_dir.join(format!("{story_id}.md"));
|
||||||
|
|
||||||
|
if current_path.exists() {
|
||||||
|
return Ok(()); // Already in 2_current — idempotent.
|
||||||
|
}
|
||||||
|
|
||||||
|
if !qa_path.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"Work item '{story_id}' not found in work/3_qa/. Cannot reject."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::create_dir_all(¤t_dir)
|
||||||
|
.map_err(|e| format!("Failed to create work/2_current/ directory: {e}"))?;
|
||||||
|
std::fs::rename(&qa_path, ¤t_path)
|
||||||
|
.map_err(|e| format!("Failed to move '{story_id}' from 3_qa/ to 2_current/: {e}"))?;
|
||||||
|
|
||||||
|
// Clear review_hold since the story is going back for rework.
|
||||||
|
if let Err(e) = clear_front_matter_field(¤t_path, "review_hold") {
|
||||||
|
slog!("[lifecycle] Warning: could not clear review_hold from '{story_id}': {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write rejection notes into the story file so the coder can see what needs fixing.
|
||||||
|
if !notes.is_empty()
|
||||||
|
&& let Err(e) = write_rejection_notes(¤t_path, notes)
|
||||||
|
{
|
||||||
|
slog!("[lifecycle] Warning: could not write rejection notes to '{story_id}': {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
slog!("[lifecycle] Rejected '{story_id}' from work/3_qa/ back to work/2_current/");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move any work item to an arbitrary pipeline stage by searching all stages.
|
||||||
|
///
|
||||||
|
/// Accepts `target_stage` as one of: `backlog`, `current`, `qa`, `merge`, `done`.
|
||||||
|
/// Idempotent: if the item is already in the target stage, returns Ok.
|
||||||
|
/// Returns `(from_stage, to_stage)` on success.
|
||||||
|
pub fn move_story_to_stage(
|
||||||
|
project_root: &Path,
|
||||||
|
story_id: &str,
|
||||||
|
target_stage: &str,
|
||||||
|
) -> Result<(String, String), String> {
|
||||||
|
let stage_dirs: &[(&str, &str)] = &[
|
||||||
|
("backlog", "1_backlog"),
|
||||||
|
("current", "2_current"),
|
||||||
|
("qa", "3_qa"),
|
||||||
|
("merge", "4_merge"),
|
||||||
|
("done", "5_done"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let target_dir_name = stage_dirs
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| *name == target_stage)
|
||||||
|
.map(|(_, dir)| *dir)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"Invalid target_stage '{target_stage}'. Must be one of: backlog, current, qa, merge, done"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let sk = project_root.join(".story_kit").join("work");
|
||||||
|
let target_dir = sk.join(target_dir_name);
|
||||||
|
let target_path = target_dir.join(format!("{story_id}.md"));
|
||||||
|
|
||||||
|
if target_path.exists() {
|
||||||
|
return Ok((target_stage.to_string(), target_stage.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search all named stages plus the archive stage.
|
||||||
|
let search_dirs: &[(&str, &str)] = &[
|
||||||
|
("backlog", "1_backlog"),
|
||||||
|
("current", "2_current"),
|
||||||
|
("qa", "3_qa"),
|
||||||
|
("merge", "4_merge"),
|
||||||
|
("done", "5_done"),
|
||||||
|
("archived", "6_archived"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut found_path: Option<std::path::PathBuf> = None;
|
||||||
|
let mut from_stage = "";
|
||||||
|
for (stage_name, dir_name) in search_dirs {
|
||||||
|
let candidate = sk.join(dir_name).join(format!("{story_id}.md"));
|
||||||
|
if candidate.exists() {
|
||||||
|
found_path = Some(candidate);
|
||||||
|
from_stage = stage_name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let source_path =
|
||||||
|
found_path.ok_or_else(|| format!("Work item '{story_id}' not found in any pipeline stage."))?;
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&target_dir)
|
||||||
|
.map_err(|e| format!("Failed to create work/{target_dir_name}/ directory: {e}"))?;
|
||||||
|
std::fs::rename(&source_path, &target_path)
|
||||||
|
.map_err(|e| format!("Failed to move '{story_id}' to work/{target_dir_name}/: {e}"))?;
|
||||||
|
|
||||||
|
slog!(
|
||||||
|
"[lifecycle] Moved '{story_id}' from work/{from_stage}/ to work/{target_dir_name}/"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((from_stage.to_string(), target_stage.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move a bug from `work/2_current/` or `work/1_backlog/` to `work/5_done/` and auto-commit.
|
||||||
///
|
///
|
||||||
/// * If the bug is in `2_current/`, it is moved to `5_done/` and committed.
|
/// * If the bug is in `2_current/`, it is moved to `5_done/` and committed.
|
||||||
/// * If the bug is still in `1_upcoming/` (never started), it is moved directly to `5_done/`.
|
/// * If the bug is still in `1_backlog/` (never started), it is moved directly to `5_done/`.
|
||||||
/// * If the bug is already in `5_done/`, this is a no-op (idempotent).
|
/// * If the bug is already in `5_done/`, this is a no-op (idempotent).
|
||||||
/// * If the bug is not found anywhere, an error is returned.
|
/// * If the bug is not found anywhere, an error is returned.
|
||||||
pub fn close_bug_to_archive(project_root: &Path, bug_id: &str) -> Result<(), String> {
|
pub fn close_bug_to_archive(project_root: &Path, bug_id: &str) -> Result<(), String> {
|
||||||
let sk = project_root.join(".story_kit").join("work");
|
let sk = project_root.join(".story_kit").join("work");
|
||||||
let current_path = sk.join("2_current").join(format!("{bug_id}.md"));
|
let current_path = sk.join("2_current").join(format!("{bug_id}.md"));
|
||||||
let upcoming_path = sk.join("1_upcoming").join(format!("{bug_id}.md"));
|
let backlog_path = sk.join("1_backlog").join(format!("{bug_id}.md"));
|
||||||
let archive_dir = item_archive_dir(project_root, bug_id);
|
let archive_dir = item_archive_dir(project_root, bug_id);
|
||||||
let archive_path = archive_dir.join(format!("{bug_id}.md"));
|
let archive_path = archive_dir.join(format!("{bug_id}.md"));
|
||||||
|
|
||||||
@@ -238,11 +374,11 @@ pub fn close_bug_to_archive(project_root: &Path, bug_id: &str) -> Result<(), Str
|
|||||||
|
|
||||||
let source_path = if current_path.exists() {
|
let source_path = if current_path.exists() {
|
||||||
current_path.clone()
|
current_path.clone()
|
||||||
} else if upcoming_path.exists() {
|
} else if backlog_path.exists() {
|
||||||
upcoming_path.clone()
|
backlog_path.clone()
|
||||||
} else {
|
} else {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Bug '{bug_id}' not found in work/2_current/ or work/1_upcoming/. Cannot close bug."
|
"Bug '{bug_id}' not found in work/2_current/ or work/1_backlog/. Cannot close bug."
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -269,15 +405,15 @@ mod tests {
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
let upcoming = root.join(".story_kit/work/1_upcoming");
|
let backlog = root.join(".story_kit/work/1_backlog");
|
||||||
let current = root.join(".story_kit/work/2_current");
|
let current = root.join(".story_kit/work/2_current");
|
||||||
fs::create_dir_all(&upcoming).unwrap();
|
fs::create_dir_all(&backlog).unwrap();
|
||||||
fs::create_dir_all(¤t).unwrap();
|
fs::create_dir_all(¤t).unwrap();
|
||||||
fs::write(upcoming.join("10_story_foo.md"), "test").unwrap();
|
fs::write(backlog.join("10_story_foo.md"), "test").unwrap();
|
||||||
|
|
||||||
move_story_to_current(root, "10_story_foo").unwrap();
|
move_story_to_current(root, "10_story_foo").unwrap();
|
||||||
|
|
||||||
assert!(!upcoming.join("10_story_foo.md").exists());
|
assert!(!backlog.join("10_story_foo.md").exists());
|
||||||
assert!(current.join("10_story_foo.md").exists());
|
assert!(current.join("10_story_foo.md").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,25 +431,25 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn move_story_to_current_noop_when_not_in_upcoming() {
|
fn move_story_to_current_noop_when_not_in_backlog() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
assert!(move_story_to_current(tmp.path(), "99_missing").is_ok());
|
assert!(move_story_to_current(tmp.path(), "99_missing").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn move_bug_to_current_moves_from_upcoming() {
|
fn move_bug_to_current_moves_from_backlog() {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
let upcoming = root.join(".story_kit/work/1_upcoming");
|
let backlog = root.join(".story_kit/work/1_backlog");
|
||||||
let current = root.join(".story_kit/work/2_current");
|
let current = root.join(".story_kit/work/2_current");
|
||||||
fs::create_dir_all(&upcoming).unwrap();
|
fs::create_dir_all(&backlog).unwrap();
|
||||||
fs::create_dir_all(¤t).unwrap();
|
fs::create_dir_all(¤t).unwrap();
|
||||||
fs::write(upcoming.join("1_bug_test.md"), "# Bug 1\n").unwrap();
|
fs::write(backlog.join("1_bug_test.md"), "# Bug 1\n").unwrap();
|
||||||
|
|
||||||
move_story_to_current(root, "1_bug_test").unwrap();
|
move_story_to_current(root, "1_bug_test").unwrap();
|
||||||
|
|
||||||
assert!(!upcoming.join("1_bug_test.md").exists());
|
assert!(!backlog.join("1_bug_test.md").exists());
|
||||||
assert!(current.join("1_bug_test.md").exists());
|
assert!(current.join("1_bug_test.md").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,17 +471,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn close_bug_moves_from_upcoming_when_not_started() {
|
fn close_bug_moves_from_backlog_when_not_started() {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
let upcoming = root.join(".story_kit/work/1_upcoming");
|
let backlog = root.join(".story_kit/work/1_backlog");
|
||||||
fs::create_dir_all(&upcoming).unwrap();
|
fs::create_dir_all(&backlog).unwrap();
|
||||||
fs::write(upcoming.join("3_bug_test.md"), "# Bug 3\n").unwrap();
|
fs::write(backlog.join("3_bug_test.md"), "# Bug 3\n").unwrap();
|
||||||
|
|
||||||
close_bug_to_archive(root, "3_bug_test").unwrap();
|
close_bug_to_archive(root, "3_bug_test").unwrap();
|
||||||
|
|
||||||
assert!(!upcoming.join("3_bug_test.md").exists());
|
assert!(!backlog.join("3_bug_test.md").exists());
|
||||||
assert!(root.join(".story_kit/work/5_done/3_bug_test.md").exists());
|
assert!(root.join(".story_kit/work/5_done/3_bug_test.md").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,4 +688,142 @@ mod tests {
|
|||||||
"should return false when no feature branch"
|
"should return false when no feature branch"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── reject_story_from_qa tests ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reject_story_from_qa_moves_to_current() {
|
||||||
|
use std::fs;
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path();
|
||||||
|
let qa_dir = root.join(".story_kit/work/3_qa");
|
||||||
|
let current_dir = root.join(".story_kit/work/2_current");
|
||||||
|
fs::create_dir_all(&qa_dir).unwrap();
|
||||||
|
fs::create_dir_all(¤t_dir).unwrap();
|
||||||
|
fs::write(
|
||||||
|
qa_dir.join("50_story_test.md"),
|
||||||
|
"---\nname: Test\nreview_hold: true\n---\n# Story\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
reject_story_from_qa(root, "50_story_test", "Button color wrong").unwrap();
|
||||||
|
|
||||||
|
assert!(!qa_dir.join("50_story_test.md").exists());
|
||||||
|
assert!(current_dir.join("50_story_test.md").exists());
|
||||||
|
let contents = fs::read_to_string(current_dir.join("50_story_test.md")).unwrap();
|
||||||
|
assert!(contents.contains("Button color wrong"));
|
||||||
|
assert!(contents.contains("## QA Rejection Notes"));
|
||||||
|
assert!(!contents.contains("review_hold"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reject_story_from_qa_errors_when_not_in_qa() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let result = reject_story_from_qa(tmp.path(), "99_nonexistent", "notes");
|
||||||
|
assert!(result.unwrap_err().contains("not found in work/3_qa/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reject_story_from_qa_idempotent_when_in_current() {
|
||||||
|
use std::fs;
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path();
|
||||||
|
let current_dir = root.join(".story_kit/work/2_current");
|
||||||
|
fs::create_dir_all(¤t_dir).unwrap();
|
||||||
|
fs::write(current_dir.join("51_story_test.md"), "---\nname: Test\n---\n# Story\n").unwrap();
|
||||||
|
|
||||||
|
reject_story_from_qa(root, "51_story_test", "notes").unwrap();
|
||||||
|
assert!(current_dir.join("51_story_test.md").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── move_story_to_stage tests ─────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_story_to_stage_moves_from_backlog_to_current() {
|
||||||
|
use std::fs;
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path();
|
||||||
|
let backlog = root.join(".story_kit/work/1_backlog");
|
||||||
|
let current = root.join(".story_kit/work/2_current");
|
||||||
|
fs::create_dir_all(&backlog).unwrap();
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
fs::write(backlog.join("60_story_move.md"), "test").unwrap();
|
||||||
|
|
||||||
|
let (from, to) = move_story_to_stage(root, "60_story_move", "current").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(from, "backlog");
|
||||||
|
assert_eq!(to, "current");
|
||||||
|
assert!(!backlog.join("60_story_move.md").exists());
|
||||||
|
assert!(current.join("60_story_move.md").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_story_to_stage_moves_from_current_to_backlog() {
|
||||||
|
use std::fs;
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path();
|
||||||
|
let current = root.join(".story_kit/work/2_current");
|
||||||
|
let backlog = root.join(".story_kit/work/1_backlog");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
fs::create_dir_all(&backlog).unwrap();
|
||||||
|
fs::write(current.join("61_story_back.md"), "test").unwrap();
|
||||||
|
|
||||||
|
let (from, to) = move_story_to_stage(root, "61_story_back", "backlog").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(from, "current");
|
||||||
|
assert_eq!(to, "backlog");
|
||||||
|
assert!(!current.join("61_story_back.md").exists());
|
||||||
|
assert!(backlog.join("61_story_back.md").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_story_to_stage_idempotent_when_already_in_target() {
|
||||||
|
use std::fs;
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path();
|
||||||
|
let current = root.join(".story_kit/work/2_current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
fs::write(current.join("62_story_idem.md"), "test").unwrap();
|
||||||
|
|
||||||
|
let (from, to) = move_story_to_stage(root, "62_story_idem", "current").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(from, "current");
|
||||||
|
assert_eq!(to, "current");
|
||||||
|
assert!(current.join("62_story_idem.md").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_story_to_stage_invalid_target_returns_error() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let result = move_story_to_stage(tmp.path(), "1_story_test", "invalid");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("Invalid target_stage"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_story_to_stage_not_found_returns_error() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let result = move_story_to_stage(tmp.path(), "99_story_ghost", "current");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("not found in any pipeline stage"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_story_to_stage_finds_in_qa_dir() {
|
||||||
|
use std::fs;
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path();
|
||||||
|
let qa_dir = root.join(".story_kit/work/3_qa");
|
||||||
|
let backlog = root.join(".story_kit/work/1_backlog");
|
||||||
|
fs::create_dir_all(&qa_dir).unwrap();
|
||||||
|
fs::create_dir_all(&backlog).unwrap();
|
||||||
|
fs::write(qa_dir.join("63_story_qa.md"), "test").unwrap();
|
||||||
|
|
||||||
|
let (from, to) = move_story_to_stage(root, "63_story_qa", "backlog").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(from, "qa");
|
||||||
|
assert_eq!(to, "backlog");
|
||||||
|
assert!(!qa_dir.join("63_story_qa.md").exists());
|
||||||
|
assert!(backlog.join("63_story_qa.md").exists());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ pub mod lifecycle;
|
|||||||
pub mod merge;
|
pub mod merge;
|
||||||
mod pool;
|
mod pool;
|
||||||
mod pty;
|
mod pty;
|
||||||
|
pub mod token_usage;
|
||||||
|
|
||||||
use crate::config::AgentConfig;
|
use crate::config::AgentConfig;
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub use lifecycle::{
|
pub use lifecycle::{
|
||||||
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_archived,
|
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_archived,
|
||||||
move_story_to_merge, move_story_to_qa,
|
move_story_to_merge, move_story_to_qa, move_story_to_stage, reject_story_from_qa,
|
||||||
};
|
};
|
||||||
pub use pool::AgentPool;
|
pub use pool::AgentPool;
|
||||||
|
|
||||||
@@ -136,6 +137,45 @@ pub struct CompletionReport {
|
|||||||
pub gate_output: String,
|
pub gate_output: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Token usage from a Claude Code session's `result` event.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct TokenUsage {
|
||||||
|
pub input_tokens: u64,
|
||||||
|
pub output_tokens: u64,
|
||||||
|
pub cache_creation_input_tokens: u64,
|
||||||
|
pub cache_read_input_tokens: u64,
|
||||||
|
pub total_cost_usd: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenUsage {
|
||||||
|
/// Parse token usage from a Claude Code `result` JSON event.
|
||||||
|
pub fn from_result_event(json: &serde_json::Value) -> Option<Self> {
|
||||||
|
let usage = json.get("usage")?;
|
||||||
|
Some(Self {
|
||||||
|
input_tokens: usage
|
||||||
|
.get("input_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0),
|
||||||
|
output_tokens: usage
|
||||||
|
.get("output_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0),
|
||||||
|
cache_creation_input_tokens: usage
|
||||||
|
.get("cache_creation_input_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0),
|
||||||
|
cache_read_input_tokens: usage
|
||||||
|
.get("cache_read_input_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0),
|
||||||
|
total_cost_usd: json
|
||||||
|
.get("total_cost_usd")
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.unwrap_or(0.0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Clone)]
|
#[derive(Debug, Serialize, Clone)]
|
||||||
pub struct AgentInfo {
|
pub struct AgentInfo {
|
||||||
pub story_id: String,
|
pub story_id: String,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,17 @@ use std::sync::{Arc, Mutex};
|
|||||||
use portable_pty::{ChildKiller, CommandBuilder, PtySize, native_pty_system};
|
use portable_pty::{ChildKiller, CommandBuilder, PtySize, native_pty_system};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use super::AgentEvent;
|
use super::{AgentEvent, TokenUsage};
|
||||||
use crate::agent_log::AgentLogWriter;
|
use crate::agent_log::AgentLogWriter;
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use crate::slog_warn;
|
use crate::slog_warn;
|
||||||
|
|
||||||
|
/// Result from a PTY agent session, containing the session ID and token usage.
|
||||||
|
pub(super) struct PtyResult {
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
pub token_usage: Option<TokenUsage>,
|
||||||
|
}
|
||||||
|
|
||||||
fn composite_key(story_id: &str, agent_name: &str) -> String {
|
fn composite_key(story_id: &str, agent_name: &str) -> String {
|
||||||
format!("{story_id}:{agent_name}")
|
format!("{story_id}:{agent_name}")
|
||||||
}
|
}
|
||||||
@@ -41,7 +47,7 @@ pub(super) async fn run_agent_pty_streaming(
|
|||||||
log_writer: Option<Arc<Mutex<AgentLogWriter>>>,
|
log_writer: Option<Arc<Mutex<AgentLogWriter>>>,
|
||||||
inactivity_timeout_secs: u64,
|
inactivity_timeout_secs: u64,
|
||||||
child_killers: Arc<Mutex<HashMap<String, Box<dyn ChildKiller + Send + Sync>>>>,
|
child_killers: Arc<Mutex<HashMap<String, Box<dyn ChildKiller + Send + Sync>>>>,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<PtyResult, String> {
|
||||||
let sid = story_id.to_string();
|
let sid = story_id.to_string();
|
||||||
let aname = agent_name.to_string();
|
let aname = agent_name.to_string();
|
||||||
let cmd = command.to_string();
|
let cmd = command.to_string();
|
||||||
@@ -156,7 +162,7 @@ fn run_agent_pty_blocking(
|
|||||||
log_writer: Option<&Mutex<AgentLogWriter>>,
|
log_writer: Option<&Mutex<AgentLogWriter>>,
|
||||||
inactivity_timeout_secs: u64,
|
inactivity_timeout_secs: u64,
|
||||||
child_killers: &Arc<Mutex<HashMap<String, Box<dyn ChildKiller + Send + Sync>>>>,
|
child_killers: &Arc<Mutex<HashMap<String, Box<dyn ChildKiller + Send + Sync>>>>,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<PtyResult, String> {
|
||||||
let pty_system = native_pty_system();
|
let pty_system = native_pty_system();
|
||||||
|
|
||||||
let pair = pty_system
|
let pair = pty_system
|
||||||
@@ -251,6 +257,7 @@ fn run_agent_pty_blocking(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut session_id: Option<String> = None;
|
let mut session_id: Option<String> = None;
|
||||||
|
let mut token_usage: Option<TokenUsage> = None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let recv_result = match timeout_dur {
|
let recv_result = match timeout_dur {
|
||||||
@@ -334,7 +341,21 @@ fn run_agent_pty_blocking(
|
|||||||
// Complete assistant events are skipped for content extraction
|
// Complete assistant events are skipped for content extraction
|
||||||
// because thinking and text already arrived via stream_event.
|
// because thinking and text already arrived via stream_event.
|
||||||
// The raw JSON is still forwarded as AgentJson below.
|
// The raw JSON is still forwarded as AgentJson below.
|
||||||
"assistant" | "user" | "result" => {}
|
"assistant" | "user" => {}
|
||||||
|
"result" => {
|
||||||
|
// Extract token usage from the result event.
|
||||||
|
if let Some(usage) = TokenUsage::from_result_event(&json) {
|
||||||
|
slog!(
|
||||||
|
"[agent:{story_id}:{agent_name}] Token usage: in={} out={} cache_create={} cache_read={} cost=${:.4}",
|
||||||
|
usage.input_tokens,
|
||||||
|
usage.output_tokens,
|
||||||
|
usage.cache_creation_input_tokens,
|
||||||
|
usage.cache_read_input_tokens,
|
||||||
|
usage.total_cost_usd,
|
||||||
|
);
|
||||||
|
token_usage = Some(usage);
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,7 +380,10 @@ fn run_agent_pty_blocking(
|
|||||||
session_id
|
session_id
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(session_id)
|
Ok(PtyResult {
|
||||||
|
session_id,
|
||||||
|
token_usage,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
202
server/src/agents/token_usage.rs
Normal file
202
server/src/agents/token_usage.rs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::TokenUsage;
|
||||||
|
|
||||||
|
/// A single token usage record persisted to disk.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct TokenUsageRecord {
|
||||||
|
pub story_id: String,
|
||||||
|
pub agent_name: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub usage: TokenUsage,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a token usage record to the persistent JSONL file.
|
||||||
|
///
|
||||||
|
/// Each line is a self-contained JSON object, making appends atomic and
|
||||||
|
/// reads simple. The file lives at `.story_kit/token_usage.jsonl`.
|
||||||
|
pub fn append_record(project_root: &Path, record: &TokenUsageRecord) -> Result<(), String> {
|
||||||
|
let path = token_usage_path(project_root);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("Failed to create token_usage directory: {e}"))?;
|
||||||
|
}
|
||||||
|
let mut line =
|
||||||
|
serde_json::to_string(record).map_err(|e| format!("Failed to serialize record: {e}"))?;
|
||||||
|
line.push('\n');
|
||||||
|
use std::io::Write;
|
||||||
|
let file = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&path)
|
||||||
|
.map_err(|e| format!("Failed to open token_usage file: {e}"))?;
|
||||||
|
let mut writer = std::io::BufWriter::new(file);
|
||||||
|
writer
|
||||||
|
.write_all(line.as_bytes())
|
||||||
|
.map_err(|e| format!("Failed to write token_usage record: {e}"))?;
|
||||||
|
writer
|
||||||
|
.flush()
|
||||||
|
.map_err(|e| format!("Failed to flush token_usage file: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read all token usage records from the persistent file.
|
||||||
|
pub fn read_all(project_root: &Path) -> Result<Vec<TokenUsageRecord>, String> {
|
||||||
|
let path = token_usage_path(project_root);
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let content =
|
||||||
|
fs::read_to_string(&path).map_err(|e| format!("Failed to read token_usage file: {e}"))?;
|
||||||
|
let mut records = Vec::new();
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match serde_json::from_str::<TokenUsageRecord>(trimmed) {
|
||||||
|
Ok(record) => records.push(record),
|
||||||
|
Err(e) => {
|
||||||
|
crate::slog_warn!("[token_usage] Skipping malformed line: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `TokenUsageRecord` from the parts available at completion time.
|
||||||
|
pub fn build_record(
|
||||||
|
story_id: &str,
|
||||||
|
agent_name: &str,
|
||||||
|
model: Option<String>,
|
||||||
|
usage: TokenUsage,
|
||||||
|
) -> TokenUsageRecord {
|
||||||
|
TokenUsageRecord {
|
||||||
|
story_id: story_id.to_string(),
|
||||||
|
agent_name: agent_name.to_string(),
|
||||||
|
timestamp: Utc::now().to_rfc3339(),
|
||||||
|
model,
|
||||||
|
usage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_usage_path(project_root: &Path) -> std::path::PathBuf {
|
||||||
|
project_root.join(".story_kit").join("token_usage.jsonl")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn sample_usage() -> TokenUsage {
|
||||||
|
TokenUsage {
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 200,
|
||||||
|
cache_creation_input_tokens: 5000,
|
||||||
|
cache_read_input_tokens: 10000,
|
||||||
|
total_cost_usd: 1.57,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_and_read_roundtrip() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
let record = build_record("42_story_foo", "coder-1", None, sample_usage());
|
||||||
|
append_record(root, &record).unwrap();
|
||||||
|
|
||||||
|
let records = read_all(root).unwrap();
|
||||||
|
assert_eq!(records.len(), 1);
|
||||||
|
assert_eq!(records[0].story_id, "42_story_foo");
|
||||||
|
assert_eq!(records[0].agent_name, "coder-1");
|
||||||
|
assert_eq!(records[0].usage, sample_usage());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_appends_accumulate() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
let r1 = build_record("s1", "coder-1", None, sample_usage());
|
||||||
|
let r2 = build_record("s2", "coder-2", None, sample_usage());
|
||||||
|
append_record(root, &r1).unwrap();
|
||||||
|
append_record(root, &r2).unwrap();
|
||||||
|
|
||||||
|
let records = read_all(root).unwrap();
|
||||||
|
assert_eq!(records.len(), 2);
|
||||||
|
assert_eq!(records[0].story_id, "s1");
|
||||||
|
assert_eq!(records[1].story_id, "s2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_empty_returns_empty() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let records = read_all(dir.path()).unwrap();
|
||||||
|
assert!(records.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_lines_are_skipped() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let root = dir.path();
|
||||||
|
let path = root.join(".story_kit").join("token_usage.jsonl");
|
||||||
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
fs::write(&path, "not json\n{\"bad\":true}\n").unwrap();
|
||||||
|
|
||||||
|
let records = read_all(root).unwrap();
|
||||||
|
assert!(records.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_usage_from_result_event() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"type": "result",
|
||||||
|
"total_cost_usd": 1.57,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 7,
|
||||||
|
"output_tokens": 475,
|
||||||
|
"cache_creation_input_tokens": 185020,
|
||||||
|
"cache_read_input_tokens": 810585
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let usage = TokenUsage::from_result_event(&json).unwrap();
|
||||||
|
assert_eq!(usage.input_tokens, 7);
|
||||||
|
assert_eq!(usage.output_tokens, 475);
|
||||||
|
assert_eq!(usage.cache_creation_input_tokens, 185020);
|
||||||
|
assert_eq!(usage.cache_read_input_tokens, 810585);
|
||||||
|
assert!((usage.total_cost_usd - 1.57).abs() < f64::EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_usage_from_result_event_missing_usage() {
|
||||||
|
let json = serde_json::json!({"type": "result"});
|
||||||
|
assert!(TokenUsage::from_result_event(&json).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_usage_from_result_event_partial_fields() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"type": "result",
|
||||||
|
"total_cost_usd": 0.5,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 10,
|
||||||
|
"output_tokens": 20
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let usage = TokenUsage::from_result_event(&json).unwrap();
|
||||||
|
assert_eq!(usage.input_tokens, 10);
|
||||||
|
assert_eq!(usage.output_tokens, 20);
|
||||||
|
assert_eq!(usage.cache_creation_input_tokens, 0);
|
||||||
|
assert_eq!(usage.cache_read_input_tokens, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,25 @@ pub struct ProjectConfig {
|
|||||||
pub agent: Vec<AgentConfig>,
|
pub agent: Vec<AgentConfig>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub watcher: WatcherConfig,
|
pub watcher: WatcherConfig,
|
||||||
|
/// Project-wide default QA mode: "server", "agent", or "human".
|
||||||
|
/// Per-story `qa` front matter overrides this. Default: "server".
|
||||||
|
#[serde(default = "default_qa")]
|
||||||
|
pub default_qa: String,
|
||||||
|
/// Default model for coder-stage agents (e.g. "sonnet").
|
||||||
|
/// When set, `find_free_agent_for_stage` only considers coder agents whose
|
||||||
|
/// model matches this value, so opus agents are only used when explicitly
|
||||||
|
/// requested via story front matter `agent:` field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_coder_model: Option<String>,
|
||||||
|
/// Maximum number of concurrent coder-stage agents.
|
||||||
|
/// When set, `auto_assign_available_work` will not start more than this many
|
||||||
|
/// coder agents at once. Stories wait in `2_current/` until a slot frees up.
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_coders: Option<usize>,
|
||||||
|
/// Maximum number of retries per story per pipeline stage before marking as blocked.
|
||||||
|
/// Default: 2. Set to 0 to disable retry limits.
|
||||||
|
#[serde(default = "default_max_retries")]
|
||||||
|
pub max_retries: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configuration for the filesystem watcher's sweep behaviour.
|
/// Configuration for the filesystem watcher's sweep behaviour.
|
||||||
@@ -46,6 +65,14 @@ fn default_done_retention_secs() -> u64 {
|
|||||||
4 * 60 * 60 // 4 hours
|
4 * 60 * 60 // 4 hours
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_qa() -> String {
|
||||||
|
"server".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_retries() -> u32 {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct ComponentConfig {
|
pub struct ComponentConfig {
|
||||||
@@ -124,6 +151,14 @@ struct LegacyProjectConfig {
|
|||||||
agent: Option<AgentConfig>,
|
agent: Option<AgentConfig>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
watcher: WatcherConfig,
|
watcher: WatcherConfig,
|
||||||
|
#[serde(default = "default_qa")]
|
||||||
|
default_qa: String,
|
||||||
|
#[serde(default)]
|
||||||
|
default_coder_model: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
max_coders: Option<usize>,
|
||||||
|
#[serde(default = "default_max_retries")]
|
||||||
|
max_retries: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ProjectConfig {
|
impl Default for ProjectConfig {
|
||||||
@@ -145,6 +180,10 @@ impl Default for ProjectConfig {
|
|||||||
inactivity_timeout_secs: default_inactivity_timeout_secs(),
|
inactivity_timeout_secs: default_inactivity_timeout_secs(),
|
||||||
}],
|
}],
|
||||||
watcher: WatcherConfig::default(),
|
watcher: WatcherConfig::default(),
|
||||||
|
default_qa: default_qa(),
|
||||||
|
default_coder_model: None,
|
||||||
|
max_coders: None,
|
||||||
|
max_retries: default_max_retries(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,6 +225,10 @@ impl ProjectConfig {
|
|||||||
component: legacy.component,
|
component: legacy.component,
|
||||||
agent: vec![agent],
|
agent: vec![agent],
|
||||||
watcher: legacy.watcher,
|
watcher: legacy.watcher,
|
||||||
|
default_qa: legacy.default_qa,
|
||||||
|
default_coder_model: legacy.default_coder_model,
|
||||||
|
max_coders: legacy.max_coders,
|
||||||
|
max_retries: legacy.max_retries,
|
||||||
};
|
};
|
||||||
validate_agents(&config.agent)?;
|
validate_agents(&config.agent)?;
|
||||||
return Ok(config);
|
return Ok(config);
|
||||||
@@ -206,6 +249,10 @@ impl ProjectConfig {
|
|||||||
component: legacy.component,
|
component: legacy.component,
|
||||||
agent: vec![agent],
|
agent: vec![agent],
|
||||||
watcher: legacy.watcher,
|
watcher: legacy.watcher,
|
||||||
|
default_qa: legacy.default_qa,
|
||||||
|
default_coder_model: legacy.default_coder_model,
|
||||||
|
max_coders: legacy.max_coders,
|
||||||
|
max_retries: legacy.max_retries,
|
||||||
};
|
};
|
||||||
validate_agents(&config.agent)?;
|
validate_agents(&config.agent)?;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
@@ -214,12 +261,23 @@ impl ProjectConfig {
|
|||||||
component: legacy.component,
|
component: legacy.component,
|
||||||
agent: Vec::new(),
|
agent: Vec::new(),
|
||||||
watcher: legacy.watcher,
|
watcher: legacy.watcher,
|
||||||
|
default_qa: legacy.default_qa,
|
||||||
|
default_coder_model: legacy.default_coder_model,
|
||||||
|
max_coders: legacy.max_coders,
|
||||||
|
max_retries: legacy.max_retries,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the project-wide default QA mode parsed from `default_qa`.
|
||||||
|
/// Falls back to `Server` if the value is unrecognised.
|
||||||
|
pub fn default_qa_mode(&self) -> crate::io::story_metadata::QaMode {
|
||||||
|
crate::io::story_metadata::QaMode::from_str(&self.default_qa)
|
||||||
|
.unwrap_or(crate::io::story_metadata::QaMode::Server)
|
||||||
|
}
|
||||||
|
|
||||||
/// Look up an agent config by name.
|
/// Look up an agent config by name.
|
||||||
pub fn find_agent(&self, name: &str) -> Option<&AgentConfig> {
|
pub fn find_agent(&self, name: &str) -> Option<&AgentConfig> {
|
||||||
self.agent.iter().find(|a| a.name == name)
|
self.agent.iter().find(|a| a.name == name)
|
||||||
@@ -689,4 +747,68 @@ command = "claude"
|
|||||||
assert_eq!(config.watcher.done_retention_secs, 900);
|
assert_eq!(config.watcher.done_retention_secs, 900);
|
||||||
assert_eq!(config.agent.len(), 1);
|
assert_eq!(config.agent.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── default_coder_model & max_coders ─────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_default_coder_model_and_max_coders() {
|
||||||
|
let toml_str = r#"
|
||||||
|
default_coder_model = "sonnet"
|
||||||
|
max_coders = 3
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder-1"
|
||||||
|
stage = "coder"
|
||||||
|
model = "sonnet"
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder-opus"
|
||||||
|
stage = "coder"
|
||||||
|
model = "opus"
|
||||||
|
"#;
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert_eq!(config.default_coder_model, Some("sonnet".to_string()));
|
||||||
|
assert_eq!(config.max_coders, Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_coder_model_and_max_coders_default_to_none() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "coder-1"
|
||||||
|
"#;
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert_eq!(config.default_coder_model, None);
|
||||||
|
assert_eq!(config.max_coders, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_toml_has_default_coder_model_and_max_coders() {
|
||||||
|
// Verify the actual project.toml has the new settings.
|
||||||
|
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let project_root = manifest_dir.parent().unwrap();
|
||||||
|
let config = ProjectConfig::load(project_root).unwrap();
|
||||||
|
assert_eq!(config.default_coder_model, Some("sonnet".to_string()));
|
||||||
|
assert_eq!(config.max_coders, Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_toml_has_three_sonnet_coders() {
|
||||||
|
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let project_root = manifest_dir.parent().unwrap();
|
||||||
|
let config = ProjectConfig::load(project_root).unwrap();
|
||||||
|
|
||||||
|
let sonnet_coders: Vec<_> = config
|
||||||
|
.agent
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.stage.as_deref() == Some("coder") && a.model.as_deref() == Some("sonnet"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sonnet_coders.len(),
|
||||||
|
3,
|
||||||
|
"Expected 3 sonnet coders (coder-1, coder-2, coder-3), found {}",
|
||||||
|
sonnet_coders.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,45 @@ struct AgentOutputResponse {
|
|||||||
output: String,
|
output: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-agent cost breakdown entry for the token cost endpoint.
|
||||||
|
#[derive(Object, Serialize)]
|
||||||
|
struct AgentCostEntry {
|
||||||
|
agent_name: String,
|
||||||
|
model: Option<String>,
|
||||||
|
input_tokens: u64,
|
||||||
|
output_tokens: u64,
|
||||||
|
cache_creation_input_tokens: u64,
|
||||||
|
cache_read_input_tokens: u64,
|
||||||
|
total_cost_usd: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for the work item token cost endpoint.
|
||||||
|
#[derive(Object, Serialize)]
|
||||||
|
struct TokenCostResponse {
|
||||||
|
total_cost_usd: f64,
|
||||||
|
agents: Vec<AgentCostEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single token usage record in the all-usage response.
|
||||||
|
#[derive(Object, Serialize)]
|
||||||
|
struct TokenUsageRecordResponse {
|
||||||
|
story_id: String,
|
||||||
|
agent_name: String,
|
||||||
|
model: Option<String>,
|
||||||
|
timestamp: String,
|
||||||
|
input_tokens: u64,
|
||||||
|
output_tokens: u64,
|
||||||
|
cache_creation_input_tokens: u64,
|
||||||
|
cache_read_input_tokens: u64,
|
||||||
|
total_cost_usd: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for the all token usage endpoint.
|
||||||
|
#[derive(Object, Serialize)]
|
||||||
|
struct AllTokenUsageResponse {
|
||||||
|
records: Vec<TokenUsageRecordResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns true if the story file exists in `work/5_done/` or `work/6_archived/`.
|
/// Returns true if the story file exists in `work/5_done/` or `work/6_archived/`.
|
||||||
///
|
///
|
||||||
/// Used to exclude agents for already-archived stories from the `list_agents`
|
/// Used to exclude agents for already-archived stories from the `list_agents`
|
||||||
@@ -339,7 +378,7 @@ impl AgentsApi {
|
|||||||
.map_err(bad_request)?;
|
.map_err(bad_request)?;
|
||||||
|
|
||||||
let stages = [
|
let stages = [
|
||||||
("1_upcoming", "upcoming"),
|
("1_backlog", "backlog"),
|
||||||
("2_current", "current"),
|
("2_current", "current"),
|
||||||
("3_qa", "qa"),
|
("3_qa", "qa"),
|
||||||
("4_merge", "merge"),
|
("4_merge", "merge"),
|
||||||
@@ -463,6 +502,94 @@ impl AgentsApi {
|
|||||||
|
|
||||||
Ok(Json(true))
|
Ok(Json(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the total token cost and per-agent breakdown for a work item.
|
||||||
|
///
|
||||||
|
/// Returns the sum of all recorded token usage for the given story_id.
|
||||||
|
/// If no usage has been recorded, returns zero cost with an empty agents list.
|
||||||
|
#[oai(path = "/work-items/:story_id/token-cost", method = "get")]
|
||||||
|
async fn get_work_item_token_cost(
|
||||||
|
&self,
|
||||||
|
story_id: Path<String>,
|
||||||
|
) -> OpenApiResult<Json<TokenCostResponse>> {
|
||||||
|
let project_root = self
|
||||||
|
.ctx
|
||||||
|
.agents
|
||||||
|
.get_project_root(&self.ctx.state)
|
||||||
|
.map_err(bad_request)?;
|
||||||
|
|
||||||
|
let all_records = crate::agents::token_usage::read_all(&project_root)
|
||||||
|
.map_err(|e| bad_request(format!("Failed to read token usage: {e}")))?;
|
||||||
|
|
||||||
|
let mut agent_map: std::collections::HashMap<String, AgentCostEntry> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
|
||||||
|
let mut total_cost_usd = 0.0_f64;
|
||||||
|
|
||||||
|
for record in all_records.into_iter().filter(|r| r.story_id == story_id.0) {
|
||||||
|
total_cost_usd += record.usage.total_cost_usd;
|
||||||
|
let entry = agent_map
|
||||||
|
.entry(record.agent_name.clone())
|
||||||
|
.or_insert_with(|| AgentCostEntry {
|
||||||
|
agent_name: record.agent_name.clone(),
|
||||||
|
model: record.model.clone(),
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
total_cost_usd: 0.0,
|
||||||
|
});
|
||||||
|
entry.input_tokens += record.usage.input_tokens;
|
||||||
|
entry.output_tokens += record.usage.output_tokens;
|
||||||
|
entry.cache_creation_input_tokens += record.usage.cache_creation_input_tokens;
|
||||||
|
entry.cache_read_input_tokens += record.usage.cache_read_input_tokens;
|
||||||
|
entry.total_cost_usd += record.usage.total_cost_usd;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut agents: Vec<AgentCostEntry> = agent_map.into_values().collect();
|
||||||
|
agents.sort_by(|a, b| a.agent_name.cmp(&b.agent_name));
|
||||||
|
|
||||||
|
Ok(Json(TokenCostResponse {
|
||||||
|
total_cost_usd,
|
||||||
|
agents,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all token usage records across all stories.
|
||||||
|
///
|
||||||
|
/// Returns the full history from the persistent token_usage.jsonl log.
|
||||||
|
#[oai(path = "/token-usage", method = "get")]
|
||||||
|
async fn get_all_token_usage(
|
||||||
|
&self,
|
||||||
|
) -> OpenApiResult<Json<AllTokenUsageResponse>> {
|
||||||
|
let project_root = self
|
||||||
|
.ctx
|
||||||
|
.agents
|
||||||
|
.get_project_root(&self.ctx.state)
|
||||||
|
.map_err(bad_request)?;
|
||||||
|
|
||||||
|
let records = crate::agents::token_usage::read_all(&project_root)
|
||||||
|
.map_err(|e| bad_request(format!("Failed to read token usage: {e}")))?;
|
||||||
|
|
||||||
|
let response_records: Vec<TokenUsageRecordResponse> = records
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| TokenUsageRecordResponse {
|
||||||
|
story_id: r.story_id,
|
||||||
|
agent_name: r.agent_name,
|
||||||
|
model: r.model,
|
||||||
|
timestamp: r.timestamp,
|
||||||
|
input_tokens: r.usage.input_tokens,
|
||||||
|
output_tokens: r.usage.output_tokens,
|
||||||
|
cache_creation_input_tokens: r.usage.cache_creation_input_tokens,
|
||||||
|
cache_read_input_tokens: r.usage.cache_read_input_tokens,
|
||||||
|
total_cost_usd: r.usage.total_cost_usd,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(AllTokenUsageResponse {
|
||||||
|
records: response_records,
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -809,12 +936,12 @@ allowed_tools = ["Read", "Bash"]
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_work_item_content_returns_content_from_upcoming() {
|
async fn get_work_item_content_returns_content_from_backlog() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
make_stage_dir(root, "1_upcoming");
|
make_stage_dir(root, "1_backlog");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
root.join(".story_kit/work/1_upcoming/42_story_foo.md"),
|
root.join(".story_kit/work/1_backlog/42_story_foo.md"),
|
||||||
"---\nname: \"Foo Story\"\n---\n\n# Story 42: Foo Story\n\nSome content.",
|
"---\nname: \"Foo Story\"\n---\n\n# Story 42: Foo Story\n\nSome content.",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -828,7 +955,7 @@ allowed_tools = ["Read", "Bash"]
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.0;
|
.0;
|
||||||
assert!(result.content.contains("Some content."));
|
assert!(result.content.contains("Some content."));
|
||||||
assert_eq!(result.stage, "upcoming");
|
assert_eq!(result.stage, "backlog");
|
||||||
assert_eq!(result.name, Some("Foo Story".to_string()));
|
assert_eq!(result.name, Some("Foo Story".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1113,7 +1240,7 @@ allowed_tools = ["Read", "Bash"]
|
|||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let root = tmp.path().to_path_buf();
|
let root = tmp.path().to_path_buf();
|
||||||
// Create work dirs including 2_current for the story file.
|
// Create work dirs including 2_current for the story file.
|
||||||
for stage in &["1_upcoming", "2_current", "5_done", "6_archived"] {
|
for stage in &["1_backlog", "2_current", "5_done", "6_archived"] {
|
||||||
std::fs::create_dir_all(root.join(".story_kit").join("work").join(stage)).unwrap();
|
std::fs::create_dir_all(root.join(".story_kit").join("work").join(stage)).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ pub struct AppContext {
|
|||||||
/// Receiver for permission requests. The active WebSocket handler locks
|
/// Receiver for permission requests. The active WebSocket handler locks
|
||||||
/// this and polls for incoming permission forwards.
|
/// this and polls for incoming permission forwards.
|
||||||
pub perm_rx: Arc<tokio::sync::Mutex<mpsc::UnboundedReceiver<PermissionForward>>>,
|
pub perm_rx: Arc<tokio::sync::Mutex<mpsc::UnboundedReceiver<PermissionForward>>>,
|
||||||
|
/// Child process of the QA app launched for manual testing.
|
||||||
|
/// Only one instance runs at a time.
|
||||||
|
pub qa_app_process: Arc<std::sync::Mutex<Option<std::process::Child>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -69,6 +72,7 @@ impl AppContext {
|
|||||||
reconciliation_tx,
|
reconciliation_tx,
|
||||||
perm_tx,
|
perm_tx,
|
||||||
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
|
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
|
||||||
|
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -102,7 +102,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let api_service =
|
let api_service =
|
||||||
OpenApiService::new(api, "Story Kit API", "1.0").server("http://127.0.0.1:3001/api");
|
OpenApiService::new(api, "Storkit API", "1.0").server("http://127.0.0.1:3001/api");
|
||||||
|
|
||||||
let docs_api = (
|
let docs_api = (
|
||||||
ProjectApi { ctx: ctx.clone() },
|
ProjectApi { ctx: ctx.clone() },
|
||||||
@@ -116,7 +116,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let docs_service =
|
let docs_service =
|
||||||
OpenApiService::new(docs_api, "Story Kit API", "1.0").server("http://127.0.0.1:3001/api");
|
OpenApiService::new(docs_api, "Storkit API", "1.0").server("http://127.0.0.1:3001/api");
|
||||||
|
|
||||||
(api_service, docs_service)
|
(api_service, docs_service)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ pub struct UpcomingStory {
|
|||||||
pub merge_failure: Option<String>,
|
pub merge_failure: Option<String>,
|
||||||
/// Active agent working on this item, if any.
|
/// Active agent working on this item, if any.
|
||||||
pub agent: Option<AgentAssignment>,
|
pub agent: Option<AgentAssignment>,
|
||||||
|
/// True when the item is held in QA for human review.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub review_hold: Option<bool>,
|
||||||
|
/// QA mode for this item: "human", "server", or "agent".
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub qa: Option<String>,
|
||||||
|
/// Number of retries at the current pipeline stage.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub retry_count: Option<u32>,
|
||||||
|
/// True when the story has exceeded its retry limit and will not be auto-assigned.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub blocked: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct StoryValidationResult {
|
pub struct StoryValidationResult {
|
||||||
@@ -35,7 +47,7 @@ pub struct StoryValidationResult {
|
|||||||
/// Full pipeline state across all stages.
|
/// Full pipeline state across all stages.
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct PipelineState {
|
pub struct PipelineState {
|
||||||
pub upcoming: Vec<UpcomingStory>,
|
pub backlog: Vec<UpcomingStory>,
|
||||||
pub current: Vec<UpcomingStory>,
|
pub current: Vec<UpcomingStory>,
|
||||||
pub qa: Vec<UpcomingStory>,
|
pub qa: Vec<UpcomingStory>,
|
||||||
pub merge: Vec<UpcomingStory>,
|
pub merge: Vec<UpcomingStory>,
|
||||||
@@ -46,7 +58,7 @@ pub struct PipelineState {
|
|||||||
pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
|
pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
|
||||||
let agent_map = build_active_agent_map(ctx);
|
let agent_map = build_active_agent_map(ctx);
|
||||||
Ok(PipelineState {
|
Ok(PipelineState {
|
||||||
upcoming: load_stage_items(ctx, "1_upcoming", &HashMap::new())?,
|
backlog: load_stage_items(ctx, "1_backlog", &HashMap::new())?,
|
||||||
current: load_stage_items(ctx, "2_current", &agent_map)?,
|
current: load_stage_items(ctx, "2_current", &agent_map)?,
|
||||||
qa: load_stage_items(ctx, "3_qa", &agent_map)?,
|
qa: load_stage_items(ctx, "3_qa", &agent_map)?,
|
||||||
merge: load_stage_items(ctx, "4_merge", &agent_map)?,
|
merge: load_stage_items(ctx, "4_merge", &agent_map)?,
|
||||||
@@ -117,12 +129,12 @@ fn load_stage_items(
|
|||||||
.to_string();
|
.to_string();
|
||||||
let contents = fs::read_to_string(&path)
|
let contents = fs::read_to_string(&path)
|
||||||
.map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?;
|
.map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?;
|
||||||
let (name, error, merge_failure) = match parse_front_matter(&contents) {
|
let (name, error, merge_failure, review_hold, qa, retry_count, blocked) = match parse_front_matter(&contents) {
|
||||||
Ok(meta) => (meta.name, None, meta.merge_failure),
|
Ok(meta) => (meta.name, None, meta.merge_failure, meta.review_hold, meta.qa.map(|m| m.as_str().to_string()), meta.retry_count, meta.blocked),
|
||||||
Err(e) => (None, Some(e.to_string()), None),
|
Err(e) => (None, Some(e.to_string()), None, None, None, None, None),
|
||||||
};
|
};
|
||||||
let agent = agent_map.get(&story_id).cloned();
|
let agent = agent_map.get(&story_id).cloned();
|
||||||
stories.push(UpcomingStory { story_id, name, error, merge_failure, agent });
|
stories.push(UpcomingStory { story_id, name, error, merge_failure, agent, review_hold, qa, retry_count, blocked });
|
||||||
}
|
}
|
||||||
|
|
||||||
stories.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
stories.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
||||||
@@ -130,7 +142,7 @@ fn load_stage_items(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, String> {
|
pub fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, String> {
|
||||||
load_stage_items(ctx, "1_upcoming", &HashMap::new())
|
load_stage_items(ctx, "1_backlog", &HashMap::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared create-story logic used by both the OpenApi and MCP handlers.
|
/// Shared create-story logic used by both the OpenApi and MCP handlers.
|
||||||
@@ -152,11 +164,11 @@ pub fn create_story_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let filename = format!("{story_number}_story_{slug}.md");
|
let filename = format!("{story_number}_story_{slug}.md");
|
||||||
let upcoming_dir = root.join(".story_kit").join("work").join("1_upcoming");
|
let backlog_dir = root.join(".story_kit").join("work").join("1_backlog");
|
||||||
fs::create_dir_all(&upcoming_dir)
|
fs::create_dir_all(&backlog_dir)
|
||||||
.map_err(|e| format!("Failed to create upcoming directory: {e}"))?;
|
.map_err(|e| format!("Failed to create backlog directory: {e}"))?;
|
||||||
|
|
||||||
let filepath = upcoming_dir.join(&filename);
|
let filepath = backlog_dir.join(&filename);
|
||||||
if filepath.exists() {
|
if filepath.exists() {
|
||||||
return Err(format!("Story file already exists: {filename}"));
|
return Err(format!("Story file already exists: {filename}"));
|
||||||
}
|
}
|
||||||
@@ -206,7 +218,7 @@ pub fn create_story_file(
|
|||||||
|
|
||||||
// ── Bug file helpers ──────────────────────────────────────────────
|
// ── Bug file helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
/// Create a bug file in `work/1_upcoming/` with a deterministic filename and auto-commit.
|
/// Create a bug file in `work/1_backlog/` with a deterministic filename and auto-commit.
|
||||||
///
|
///
|
||||||
/// Returns the bug_id (e.g. `"4_bug_login_crash"`).
|
/// Returns the bug_id (e.g. `"4_bug_login_crash"`).
|
||||||
pub fn create_bug_file(
|
pub fn create_bug_file(
|
||||||
@@ -226,9 +238,9 @@ pub fn create_bug_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let filename = format!("{bug_number}_bug_{slug}.md");
|
let filename = format!("{bug_number}_bug_{slug}.md");
|
||||||
let bugs_dir = root.join(".story_kit").join("work").join("1_upcoming");
|
let bugs_dir = root.join(".story_kit").join("work").join("1_backlog");
|
||||||
fs::create_dir_all(&bugs_dir)
|
fs::create_dir_all(&bugs_dir)
|
||||||
.map_err(|e| format!("Failed to create upcoming directory: {e}"))?;
|
.map_err(|e| format!("Failed to create backlog directory: {e}"))?;
|
||||||
|
|
||||||
let filepath = bugs_dir.join(&filename);
|
let filepath = bugs_dir.join(&filename);
|
||||||
if filepath.exists() {
|
if filepath.exists() {
|
||||||
@@ -276,7 +288,7 @@ pub fn create_bug_file(
|
|||||||
|
|
||||||
// ── Spike file helpers ────────────────────────────────────────────
|
// ── Spike file helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
/// Create a spike file in `work/1_upcoming/` with a deterministic filename.
|
/// Create a spike file in `work/1_backlog/` with a deterministic filename.
|
||||||
///
|
///
|
||||||
/// Returns the spike_id (e.g. `"4_spike_filesystem_watcher_architecture"`).
|
/// Returns the spike_id (e.g. `"4_spike_filesystem_watcher_architecture"`).
|
||||||
pub fn create_spike_file(
|
pub fn create_spike_file(
|
||||||
@@ -292,11 +304,11 @@ pub fn create_spike_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let filename = format!("{spike_number}_spike_{slug}.md");
|
let filename = format!("{spike_number}_spike_{slug}.md");
|
||||||
let upcoming_dir = root.join(".story_kit").join("work").join("1_upcoming");
|
let backlog_dir = root.join(".story_kit").join("work").join("1_backlog");
|
||||||
fs::create_dir_all(&upcoming_dir)
|
fs::create_dir_all(&backlog_dir)
|
||||||
.map_err(|e| format!("Failed to create upcoming directory: {e}"))?;
|
.map_err(|e| format!("Failed to create backlog directory: {e}"))?;
|
||||||
|
|
||||||
let filepath = upcoming_dir.join(&filename);
|
let filepath = backlog_dir.join(&filename);
|
||||||
if filepath.exists() {
|
if filepath.exists() {
|
||||||
return Err(format!("Spike file already exists: {filename}"));
|
return Err(format!("Spike file already exists: {filename}"));
|
||||||
}
|
}
|
||||||
@@ -338,7 +350,7 @@ pub fn create_spike_file(
|
|||||||
Ok(spike_id)
|
Ok(spike_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a refactor work item file in `work/1_upcoming/`.
|
/// Create a refactor work item file in `work/1_backlog/`.
|
||||||
///
|
///
|
||||||
/// Returns the refactor_id (e.g. `"5_refactor_split_agents_rs"`).
|
/// Returns the refactor_id (e.g. `"5_refactor_split_agents_rs"`).
|
||||||
pub fn create_refactor_file(
|
pub fn create_refactor_file(
|
||||||
@@ -355,11 +367,11 @@ pub fn create_refactor_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let filename = format!("{refactor_number}_refactor_{slug}.md");
|
let filename = format!("{refactor_number}_refactor_{slug}.md");
|
||||||
let upcoming_dir = root.join(".story_kit").join("work").join("1_upcoming");
|
let backlog_dir = root.join(".story_kit").join("work").join("1_backlog");
|
||||||
fs::create_dir_all(&upcoming_dir)
|
fs::create_dir_all(&backlog_dir)
|
||||||
.map_err(|e| format!("Failed to create upcoming directory: {e}"))?;
|
.map_err(|e| format!("Failed to create backlog directory: {e}"))?;
|
||||||
|
|
||||||
let filepath = upcoming_dir.join(&filename);
|
let filepath = backlog_dir.join(&filename);
|
||||||
if filepath.exists() {
|
if filepath.exists() {
|
||||||
return Err(format!("Refactor file already exists: {filename}"));
|
return Err(format!("Refactor file already exists: {filename}"));
|
||||||
}
|
}
|
||||||
@@ -427,18 +439,18 @@ fn extract_bug_name(path: &Path) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all open bugs — files in `work/1_upcoming/` matching the `_bug_` naming pattern.
|
/// List all open bugs — files in `work/1_backlog/` matching the `_bug_` naming pattern.
|
||||||
///
|
///
|
||||||
/// Returns a sorted list of `(bug_id, name)` pairs.
|
/// Returns a sorted list of `(bug_id, name)` pairs.
|
||||||
pub fn list_bug_files(root: &Path) -> Result<Vec<(String, String)>, String> {
|
pub fn list_bug_files(root: &Path) -> Result<Vec<(String, String)>, String> {
|
||||||
let upcoming_dir = root.join(".story_kit").join("work").join("1_upcoming");
|
let backlog_dir = root.join(".story_kit").join("work").join("1_backlog");
|
||||||
if !upcoming_dir.exists() {
|
if !backlog_dir.exists() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut bugs = Vec::new();
|
let mut bugs = Vec::new();
|
||||||
for entry in
|
for entry in
|
||||||
fs::read_dir(&upcoming_dir).map_err(|e| format!("Failed to read upcoming directory: {e}"))?
|
fs::read_dir(&backlog_dir).map_err(|e| format!("Failed to read backlog directory: {e}"))?
|
||||||
{
|
{
|
||||||
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
|
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
@@ -477,18 +489,18 @@ fn is_refactor_item(stem: &str) -> bool {
|
|||||||
after_num.starts_with("_refactor_")
|
after_num.starts_with("_refactor_")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all open refactors — files in `work/1_upcoming/` matching the `_refactor_` naming pattern.
|
/// List all open refactors — files in `work/1_backlog/` matching the `_refactor_` naming pattern.
|
||||||
///
|
///
|
||||||
/// Returns a sorted list of `(refactor_id, name)` pairs.
|
/// Returns a sorted list of `(refactor_id, name)` pairs.
|
||||||
pub fn list_refactor_files(root: &Path) -> Result<Vec<(String, String)>, String> {
|
pub fn list_refactor_files(root: &Path) -> Result<Vec<(String, String)>, String> {
|
||||||
let upcoming_dir = root.join(".story_kit").join("work").join("1_upcoming");
|
let backlog_dir = root.join(".story_kit").join("work").join("1_backlog");
|
||||||
if !upcoming_dir.exists() {
|
if !backlog_dir.exists() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut refactors = Vec::new();
|
let mut refactors = Vec::new();
|
||||||
for entry in fs::read_dir(&upcoming_dir)
|
for entry in fs::read_dir(&backlog_dir)
|
||||||
.map_err(|e| format!("Failed to read upcoming directory: {e}"))?
|
.map_err(|e| format!("Failed to read backlog directory: {e}"))?
|
||||||
{
|
{
|
||||||
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
|
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
@@ -525,11 +537,11 @@ pub fn list_refactor_files(root: &Path) -> Result<Vec<(String, String)>, String>
|
|||||||
|
|
||||||
/// Locate a work item file by searching all active pipeline stages.
|
/// Locate a work item file by searching all active pipeline stages.
|
||||||
///
|
///
|
||||||
/// Searches in priority order: 2_current, 1_upcoming, 3_qa, 4_merge, 5_done, 6_archived.
|
/// Searches in priority order: 2_current, 1_backlog, 3_qa, 4_merge, 5_done, 6_archived.
|
||||||
fn find_story_file(project_root: &Path, story_id: &str) -> Result<PathBuf, String> {
|
fn find_story_file(project_root: &Path, story_id: &str) -> Result<PathBuf, String> {
|
||||||
let filename = format!("{story_id}.md");
|
let filename = format!("{story_id}.md");
|
||||||
let sk = project_root.join(".story_kit").join("work");
|
let sk = project_root.join(".story_kit").join("work");
|
||||||
for stage in &["2_current", "1_upcoming", "3_qa", "4_merge", "5_done", "6_archived"] {
|
for stage in &["2_current", "1_backlog", "3_qa", "4_merge", "5_done", "6_archived"] {
|
||||||
let path = sk.join(stage).join(&filename);
|
let path = sk.join(stage).join(&filename);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
return Ok(path);
|
return Ok(path);
|
||||||
@@ -778,7 +790,7 @@ fn next_item_number(root: &std::path::Path) -> Result<u32, String> {
|
|||||||
let work_base = root.join(".story_kit").join("work");
|
let work_base = root.join(".story_kit").join("work");
|
||||||
let mut max_num: u32 = 0;
|
let mut max_num: u32 = 0;
|
||||||
|
|
||||||
for subdir in &["1_upcoming", "2_current", "3_qa", "4_merge", "5_done", "6_archived"] {
|
for subdir in &["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"] {
|
||||||
let dir = work_base.join(subdir);
|
let dir = work_base.join(subdir);
|
||||||
if !dir.exists() {
|
if !dir.exists() {
|
||||||
continue;
|
continue;
|
||||||
@@ -973,10 +985,10 @@ pub fn validate_story_dirs(
|
|||||||
) -> Result<Vec<StoryValidationResult>, String> {
|
) -> Result<Vec<StoryValidationResult>, String> {
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
|
|
||||||
// Directories to validate: work/2_current/ + work/1_upcoming/
|
// Directories to validate: work/2_current/ + work/1_backlog/
|
||||||
let dirs_to_validate: Vec<PathBuf> = vec![
|
let dirs_to_validate: Vec<PathBuf> = vec![
|
||||||
root.join(".story_kit").join("work").join("2_current"),
|
root.join(".story_kit").join("work").join("2_current"),
|
||||||
root.join(".story_kit").join("work").join("1_upcoming"),
|
root.join(".story_kit").join("work").join("1_backlog"),
|
||||||
];
|
];
|
||||||
|
|
||||||
for dir in &dirs_to_validate {
|
for dir in &dirs_to_validate {
|
||||||
@@ -1042,7 +1054,7 @@ mod tests {
|
|||||||
let root = tmp.path().to_path_buf();
|
let root = tmp.path().to_path_buf();
|
||||||
|
|
||||||
for (stage, id) in &[
|
for (stage, id) in &[
|
||||||
("1_upcoming", "10_story_upcoming"),
|
("1_backlog", "10_story_upcoming"),
|
||||||
("2_current", "20_story_current"),
|
("2_current", "20_story_current"),
|
||||||
("3_qa", "30_story_qa"),
|
("3_qa", "30_story_qa"),
|
||||||
("4_merge", "40_story_merge"),
|
("4_merge", "40_story_merge"),
|
||||||
@@ -1060,8 +1072,8 @@ mod tests {
|
|||||||
let ctx = crate::http::context::AppContext::new_test(root);
|
let ctx = crate::http::context::AppContext::new_test(root);
|
||||||
let state = load_pipeline_state(&ctx).unwrap();
|
let state = load_pipeline_state(&ctx).unwrap();
|
||||||
|
|
||||||
assert_eq!(state.upcoming.len(), 1);
|
assert_eq!(state.backlog.len(), 1);
|
||||||
assert_eq!(state.upcoming[0].story_id, "10_story_upcoming");
|
assert_eq!(state.backlog[0].story_id, "10_story_upcoming");
|
||||||
|
|
||||||
assert_eq!(state.current.len(), 1);
|
assert_eq!(state.current.len(), 1);
|
||||||
assert_eq!(state.current[0].story_id, "20_story_current");
|
assert_eq!(state.current[0].story_id, "20_story_current");
|
||||||
@@ -1164,15 +1176,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn load_upcoming_parses_metadata() {
|
fn load_upcoming_parses_metadata() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||||
fs::create_dir_all(&upcoming).unwrap();
|
fs::create_dir_all(&backlog).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
upcoming.join("31_story_view_upcoming.md"),
|
backlog.join("31_story_view_upcoming.md"),
|
||||||
"---\nname: View Upcoming\n---\n# Story\n",
|
"---\nname: View Upcoming\n---\n# Story\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
upcoming.join("32_story_worktree.md"),
|
backlog.join("32_story_worktree.md"),
|
||||||
"---\nname: Worktree Orchestration\n---\n# Story\n",
|
"---\nname: Worktree Orchestration\n---\n# Story\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1189,11 +1201,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn load_upcoming_skips_non_md_files() {
|
fn load_upcoming_skips_non_md_files() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||||
fs::create_dir_all(&upcoming).unwrap();
|
fs::create_dir_all(&backlog).unwrap();
|
||||||
fs::write(upcoming.join(".gitkeep"), "").unwrap();
|
fs::write(backlog.join(".gitkeep"), "").unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
upcoming.join("31_story_example.md"),
|
backlog.join("31_story_example.md"),
|
||||||
"---\nname: A Story\n---\n",
|
"---\nname: A Story\n---\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1208,16 +1220,16 @@ mod tests {
|
|||||||
fn validate_story_dirs_valid_files() {
|
fn validate_story_dirs_valid_files() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let current = tmp.path().join(".story_kit/work/2_current");
|
let current = tmp.path().join(".story_kit/work/2_current");
|
||||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||||
fs::create_dir_all(¤t).unwrap();
|
fs::create_dir_all(¤t).unwrap();
|
||||||
fs::create_dir_all(&upcoming).unwrap();
|
fs::create_dir_all(&backlog).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
current.join("28_story_todos.md"),
|
current.join("28_story_todos.md"),
|
||||||
"---\nname: Show TODOs\n---\n# Story\n",
|
"---\nname: Show TODOs\n---\n# Story\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
upcoming.join("36_story_front_matter.md"),
|
backlog.join("36_story_front_matter.md"),
|
||||||
"---\nname: Enforce Front Matter\n---\n# Story\n",
|
"---\nname: Enforce Front Matter\n---\n# Story\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1302,7 +1314,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn next_item_number_empty_dirs() {
|
fn next_item_number_empty_dirs() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let base = tmp.path().join(".story_kit/work/1_upcoming");
|
let base = tmp.path().join(".story_kit/work/1_backlog");
|
||||||
fs::create_dir_all(&base).unwrap();
|
fs::create_dir_all(&base).unwrap();
|
||||||
assert_eq!(next_item_number(tmp.path()).unwrap(), 1);
|
assert_eq!(next_item_number(tmp.path()).unwrap(), 1);
|
||||||
}
|
}
|
||||||
@@ -1310,13 +1322,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn next_item_number_scans_all_dirs() {
|
fn next_item_number_scans_all_dirs() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||||
let current = tmp.path().join(".story_kit/work/2_current");
|
let current = tmp.path().join(".story_kit/work/2_current");
|
||||||
let archived = tmp.path().join(".story_kit/work/5_done");
|
let archived = tmp.path().join(".story_kit/work/5_done");
|
||||||
fs::create_dir_all(&upcoming).unwrap();
|
fs::create_dir_all(&backlog).unwrap();
|
||||||
fs::create_dir_all(¤t).unwrap();
|
fs::create_dir_all(¤t).unwrap();
|
||||||
fs::create_dir_all(&archived).unwrap();
|
fs::create_dir_all(&archived).unwrap();
|
||||||
fs::write(upcoming.join("10_story_foo.md"), "").unwrap();
|
fs::write(backlog.join("10_story_foo.md"), "").unwrap();
|
||||||
fs::write(current.join("20_story_bar.md"), "").unwrap();
|
fs::write(current.join("20_story_bar.md"), "").unwrap();
|
||||||
fs::write(archived.join("15_story_baz.md"), "").unwrap();
|
fs::write(archived.join("15_story_baz.md"), "").unwrap();
|
||||||
assert_eq!(next_item_number(tmp.path()).unwrap(), 21);
|
assert_eq!(next_item_number(tmp.path()).unwrap(), 21);
|
||||||
@@ -1334,9 +1346,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn create_story_writes_correct_content() {
|
fn create_story_writes_correct_content() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||||
fs::create_dir_all(&upcoming).unwrap();
|
fs::create_dir_all(&backlog).unwrap();
|
||||||
fs::write(upcoming.join("36_story_existing.md"), "").unwrap();
|
fs::write(backlog.join("36_story_existing.md"), "").unwrap();
|
||||||
|
|
||||||
let number = next_item_number(tmp.path()).unwrap();
|
let number = next_item_number(tmp.path()).unwrap();
|
||||||
assert_eq!(number, 37);
|
assert_eq!(number, 37);
|
||||||
@@ -1345,7 +1357,7 @@ mod tests {
|
|||||||
assert_eq!(slug, "my_new_feature");
|
assert_eq!(slug, "my_new_feature");
|
||||||
|
|
||||||
let filename = format!("{number}_{slug}.md");
|
let filename = format!("{number}_{slug}.md");
|
||||||
let filepath = upcoming.join(&filename);
|
let filepath = backlog.join(&filename);
|
||||||
|
|
||||||
let mut content = String::new();
|
let mut content = String::new();
|
||||||
content.push_str("---\n");
|
content.push_str("---\n");
|
||||||
@@ -1377,10 +1389,10 @@ mod tests {
|
|||||||
let result = create_story_file(tmp.path(), name, None, None, false);
|
let result = create_story_file(tmp.path(), name, None, None, false);
|
||||||
assert!(result.is_ok(), "create_story_file failed: {result:?}");
|
assert!(result.is_ok(), "create_story_file failed: {result:?}");
|
||||||
|
|
||||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||||
let story_id = result.unwrap();
|
let story_id = result.unwrap();
|
||||||
let filename = format!("{story_id}.md");
|
let filename = format!("{story_id}.md");
|
||||||
let contents = fs::read_to_string(upcoming.join(&filename)).unwrap();
|
let contents = fs::read_to_string(backlog.join(&filename)).unwrap();
|
||||||
|
|
||||||
let meta = parse_front_matter(&contents).expect("front matter should be valid YAML");
|
let meta = parse_front_matter(&contents).expect("front matter should be valid YAML");
|
||||||
assert_eq!(meta.name.as_deref(), Some(name));
|
assert_eq!(meta.name.as_deref(), Some(name));
|
||||||
@@ -1389,10 +1401,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn create_story_rejects_duplicate() {
|
fn create_story_rejects_duplicate() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||||
fs::create_dir_all(&upcoming).unwrap();
|
fs::create_dir_all(&backlog).unwrap();
|
||||||
|
|
||||||
let filepath = upcoming.join("1_story_my_feature.md");
|
let filepath = backlog.join("1_story_my_feature.md");
|
||||||
fs::write(&filepath, "existing").unwrap();
|
fs::write(&filepath, "existing").unwrap();
|
||||||
|
|
||||||
// Simulate the check
|
// Simulate the check
|
||||||
@@ -1511,17 +1523,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn find_story_file_searches_current_then_upcoming() {
|
fn find_story_file_searches_current_then_backlog() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let current = tmp.path().join(".story_kit/work/2_current");
|
let current = tmp.path().join(".story_kit/work/2_current");
|
||||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||||
fs::create_dir_all(¤t).unwrap();
|
fs::create_dir_all(¤t).unwrap();
|
||||||
fs::create_dir_all(&upcoming).unwrap();
|
fs::create_dir_all(&backlog).unwrap();
|
||||||
|
|
||||||
// Only in upcoming
|
// Only in backlog
|
||||||
fs::write(upcoming.join("6_test.md"), "").unwrap();
|
fs::write(backlog.join("6_test.md"), "").unwrap();
|
||||||
let found = find_story_file(tmp.path(), "6_test").unwrap();
|
let found = find_story_file(tmp.path(), "6_test").unwrap();
|
||||||
assert!(found.ends_with("1_upcoming/6_test.md") || found.ends_with("1_upcoming\\6_test.md"));
|
assert!(found.ends_with("1_backlog/6_test.md") || found.ends_with("1_backlog\\6_test.md"));
|
||||||
|
|
||||||
// Also in current — current should win
|
// Also in current — current should win
|
||||||
fs::write(current.join("6_test.md"), "").unwrap();
|
fs::write(current.join("6_test.md"), "").unwrap();
|
||||||
@@ -1685,12 +1697,12 @@ mod tests {
|
|||||||
fs::write(&filepath, "---\nname: T\n---\n\n## User Story\n\nSome story\n").unwrap();
|
fs::write(&filepath, "---\nname: T\n---\n\n## User Story\n\nSome story\n").unwrap();
|
||||||
|
|
||||||
let mut fields = HashMap::new();
|
let mut fields = HashMap::new();
|
||||||
fields.insert("manual_qa".to_string(), "true".to_string());
|
fields.insert("qa".to_string(), "human".to_string());
|
||||||
fields.insert("priority".to_string(), "high".to_string());
|
fields.insert("priority".to_string(), "high".to_string());
|
||||||
update_story_in_file(tmp.path(), "25_test", None, None, Some(&fields)).unwrap();
|
update_story_in_file(tmp.path(), "25_test", None, None, Some(&fields)).unwrap();
|
||||||
|
|
||||||
let result = fs::read_to_string(&filepath).unwrap();
|
let result = fs::read_to_string(&filepath).unwrap();
|
||||||
assert!(result.contains("manual_qa: \"true\""), "manual_qa field should be set");
|
assert!(result.contains("qa: \"human\""), "qa field should be set");
|
||||||
assert!(result.contains("priority: \"high\""), "priority field should be set");
|
assert!(result.contains("priority: \"high\""), "priority field should be set");
|
||||||
assert!(result.contains("name: T"), "name field preserved");
|
assert!(result.contains("name: T"), "name field preserved");
|
||||||
}
|
}
|
||||||
@@ -1724,19 +1736,19 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn next_item_number_increments_from_existing_bugs() {
|
fn next_item_number_increments_from_existing_bugs() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||||
fs::create_dir_all(&upcoming).unwrap();
|
fs::create_dir_all(&backlog).unwrap();
|
||||||
fs::write(upcoming.join("1_bug_crash.md"), "").unwrap();
|
fs::write(backlog.join("1_bug_crash.md"), "").unwrap();
|
||||||
fs::write(upcoming.join("3_bug_another.md"), "").unwrap();
|
fs::write(backlog.join("3_bug_another.md"), "").unwrap();
|
||||||
assert_eq!(next_item_number(tmp.path()).unwrap(), 4);
|
assert_eq!(next_item_number(tmp.path()).unwrap(), 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn next_item_number_scans_archived_too() {
|
fn next_item_number_scans_archived_too() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||||
let archived = tmp.path().join(".story_kit/work/5_done");
|
let archived = tmp.path().join(".story_kit/work/5_done");
|
||||||
fs::create_dir_all(&upcoming).unwrap();
|
fs::create_dir_all(&backlog).unwrap();
|
||||||
fs::create_dir_all(&archived).unwrap();
|
fs::create_dir_all(&archived).unwrap();
|
||||||
fs::write(archived.join("5_bug_old.md"), "").unwrap();
|
fs::write(archived.join("5_bug_old.md"), "").unwrap();
|
||||||
assert_eq!(next_item_number(tmp.path()).unwrap(), 6);
|
assert_eq!(next_item_number(tmp.path()).unwrap(), 6);
|
||||||
@@ -1752,11 +1764,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn list_bug_files_excludes_archive_subdir() {
|
fn list_bug_files_excludes_archive_subdir() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let upcoming_dir = tmp.path().join(".story_kit/work/1_upcoming");
|
let backlog_dir = tmp.path().join(".story_kit/work/1_backlog");
|
||||||
let archived_dir = tmp.path().join(".story_kit/work/5_done");
|
let archived_dir = tmp.path().join(".story_kit/work/5_done");
|
||||||
fs::create_dir_all(&upcoming_dir).unwrap();
|
fs::create_dir_all(&backlog_dir).unwrap();
|
||||||
fs::create_dir_all(&archived_dir).unwrap();
|
fs::create_dir_all(&archived_dir).unwrap();
|
||||||
fs::write(upcoming_dir.join("1_bug_open.md"), "# Bug 1: Open Bug\n").unwrap();
|
fs::write(backlog_dir.join("1_bug_open.md"), "# Bug 1: Open Bug\n").unwrap();
|
||||||
fs::write(archived_dir.join("2_bug_closed.md"), "# Bug 2: Closed Bug\n").unwrap();
|
fs::write(archived_dir.join("2_bug_closed.md"), "# Bug 2: Closed Bug\n").unwrap();
|
||||||
|
|
||||||
let result = list_bug_files(tmp.path()).unwrap();
|
let result = list_bug_files(tmp.path()).unwrap();
|
||||||
@@ -1768,11 +1780,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn list_bug_files_sorted_by_id() {
|
fn list_bug_files_sorted_by_id() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let upcoming_dir = tmp.path().join(".story_kit/work/1_upcoming");
|
let backlog_dir = tmp.path().join(".story_kit/work/1_backlog");
|
||||||
fs::create_dir_all(&upcoming_dir).unwrap();
|
fs::create_dir_all(&backlog_dir).unwrap();
|
||||||
fs::write(upcoming_dir.join("3_bug_third.md"), "# Bug 3: Third\n").unwrap();
|
fs::write(backlog_dir.join("3_bug_third.md"), "# Bug 3: Third\n").unwrap();
|
||||||
fs::write(upcoming_dir.join("1_bug_first.md"), "# Bug 1: First\n").unwrap();
|
fs::write(backlog_dir.join("1_bug_first.md"), "# Bug 1: First\n").unwrap();
|
||||||
fs::write(upcoming_dir.join("2_bug_second.md"), "# Bug 2: Second\n").unwrap();
|
fs::write(backlog_dir.join("2_bug_second.md"), "# Bug 2: Second\n").unwrap();
|
||||||
|
|
||||||
let result = list_bug_files(tmp.path()).unwrap();
|
let result = list_bug_files(tmp.path()).unwrap();
|
||||||
assert_eq!(result.len(), 3);
|
assert_eq!(result.len(), 3);
|
||||||
@@ -1810,7 +1822,7 @@ mod tests {
|
|||||||
|
|
||||||
let filepath = tmp
|
let filepath = tmp
|
||||||
.path()
|
.path()
|
||||||
.join(".story_kit/work/1_upcoming/1_bug_login_crash.md");
|
.join(".story_kit/work/1_backlog/1_bug_login_crash.md");
|
||||||
assert!(filepath.exists());
|
assert!(filepath.exists());
|
||||||
let contents = fs::read_to_string(&filepath).unwrap();
|
let contents = fs::read_to_string(&filepath).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1854,7 +1866,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let filepath = tmp.path().join(".story_kit/work/1_upcoming/1_bug_some_bug.md");
|
let filepath = tmp.path().join(".story_kit/work/1_backlog/1_bug_some_bug.md");
|
||||||
let contents = fs::read_to_string(&filepath).unwrap();
|
let contents = fs::read_to_string(&filepath).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
contents.starts_with("---\nname: \"Some Bug\"\n---"),
|
contents.starts_with("---\nname: \"Some Bug\"\n---"),
|
||||||
@@ -1876,7 +1888,7 @@ mod tests {
|
|||||||
|
|
||||||
let filepath = tmp
|
let filepath = tmp
|
||||||
.path()
|
.path()
|
||||||
.join(".story_kit/work/1_upcoming/1_spike_filesystem_watcher_architecture.md");
|
.join(".story_kit/work/1_backlog/1_spike_filesystem_watcher_architecture.md");
|
||||||
assert!(filepath.exists());
|
assert!(filepath.exists());
|
||||||
let contents = fs::read_to_string(&filepath).unwrap();
|
let contents = fs::read_to_string(&filepath).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1900,7 +1912,7 @@ mod tests {
|
|||||||
create_spike_file(tmp.path(), "FS Watcher Spike", Some(description)).unwrap();
|
create_spike_file(tmp.path(), "FS Watcher Spike", Some(description)).unwrap();
|
||||||
|
|
||||||
let filepath =
|
let filepath =
|
||||||
tmp.path().join(".story_kit/work/1_upcoming/1_spike_fs_watcher_spike.md");
|
tmp.path().join(".story_kit/work/1_backlog/1_spike_fs_watcher_spike.md");
|
||||||
let contents = fs::read_to_string(&filepath).unwrap();
|
let contents = fs::read_to_string(&filepath).unwrap();
|
||||||
assert!(contents.contains(description));
|
assert!(contents.contains(description));
|
||||||
}
|
}
|
||||||
@@ -1910,7 +1922,7 @@ mod tests {
|
|||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
create_spike_file(tmp.path(), "My Spike", None).unwrap();
|
create_spike_file(tmp.path(), "My Spike", None).unwrap();
|
||||||
|
|
||||||
let filepath = tmp.path().join(".story_kit/work/1_upcoming/1_spike_my_spike.md");
|
let filepath = tmp.path().join(".story_kit/work/1_backlog/1_spike_my_spike.md");
|
||||||
let contents = fs::read_to_string(&filepath).unwrap();
|
let contents = fs::read_to_string(&filepath).unwrap();
|
||||||
// Should have placeholder TBD in Question section
|
// Should have placeholder TBD in Question section
|
||||||
assert!(contents.contains("## Question\n\n- TBD\n"));
|
assert!(contents.contains("## Question\n\n- TBD\n"));
|
||||||
@@ -1931,10 +1943,10 @@ mod tests {
|
|||||||
let result = create_spike_file(tmp.path(), name, None);
|
let result = create_spike_file(tmp.path(), name, None);
|
||||||
assert!(result.is_ok(), "create_spike_file failed: {result:?}");
|
assert!(result.is_ok(), "create_spike_file failed: {result:?}");
|
||||||
|
|
||||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||||
let spike_id = result.unwrap();
|
let spike_id = result.unwrap();
|
||||||
let filename = format!("{spike_id}.md");
|
let filename = format!("{spike_id}.md");
|
||||||
let contents = fs::read_to_string(upcoming.join(&filename)).unwrap();
|
let contents = fs::read_to_string(backlog.join(&filename)).unwrap();
|
||||||
|
|
||||||
let meta = parse_front_matter(&contents).expect("front matter should be valid YAML");
|
let meta = parse_front_matter(&contents).expect("front matter should be valid YAML");
|
||||||
assert_eq!(meta.name.as_deref(), Some(name));
|
assert_eq!(meta.name.as_deref(), Some(name));
|
||||||
@@ -1943,9 +1955,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn create_spike_file_increments_from_existing_items() {
|
fn create_spike_file_increments_from_existing_items() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
|
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||||
fs::create_dir_all(&upcoming).unwrap();
|
fs::create_dir_all(&backlog).unwrap();
|
||||||
fs::write(upcoming.join("5_story_existing.md"), "").unwrap();
|
fs::write(backlog.join("5_story_existing.md"), "").unwrap();
|
||||||
|
|
||||||
let spike_id = create_spike_file(tmp.path(), "My Spike", None).unwrap();
|
let spike_id = create_spike_file(tmp.path(), "My Spike", None).unwrap();
|
||||||
assert!(spike_id.starts_with("6_spike_"), "expected spike number 6, got: {spike_id}");
|
assert!(spike_id.starts_with("6_spike_"), "expected spike number 6, got: {spike_id}");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use crate::io::onboarding;
|
|||||||
use crate::io::watcher::WatcherEvent;
|
use crate::io::watcher::WatcherEvent;
|
||||||
use crate::llm::chat;
|
use crate::llm::chat;
|
||||||
use crate::llm::types::Message;
|
use crate::llm::types::Message;
|
||||||
|
use crate::log_buffer;
|
||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use poem::handler;
|
use poem::handler;
|
||||||
use poem::web::Data;
|
use poem::web::Data;
|
||||||
@@ -79,7 +80,7 @@ enum WsResponse {
|
|||||||
},
|
},
|
||||||
/// Full pipeline state pushed on connect and after every work-item watcher event.
|
/// Full pipeline state pushed on connect and after every work-item watcher event.
|
||||||
PipelineState {
|
PipelineState {
|
||||||
upcoming: Vec<crate::http::workflow::UpcomingStory>,
|
backlog: Vec<crate::http::workflow::UpcomingStory>,
|
||||||
current: Vec<crate::http::workflow::UpcomingStory>,
|
current: Vec<crate::http::workflow::UpcomingStory>,
|
||||||
qa: Vec<crate::http::workflow::UpcomingStory>,
|
qa: Vec<crate::http::workflow::UpcomingStory>,
|
||||||
merge: Vec<crate::http::workflow::UpcomingStory>,
|
merge: Vec<crate::http::workflow::UpcomingStory>,
|
||||||
@@ -132,6 +133,13 @@ enum WsResponse {
|
|||||||
SideQuestionDone {
|
SideQuestionDone {
|
||||||
response: String,
|
response: String,
|
||||||
},
|
},
|
||||||
|
/// A single server log entry. Sent in bulk on connect (recent history),
|
||||||
|
/// then streamed live as new entries arrive.
|
||||||
|
LogEntry {
|
||||||
|
timestamp: String,
|
||||||
|
level: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<WatcherEvent> for Option<WsResponse> {
|
impl From<WatcherEvent> for Option<WsResponse> {
|
||||||
@@ -160,7 +168,7 @@ impl From<WatcherEvent> for Option<WsResponse> {
|
|||||||
impl From<PipelineState> for WsResponse {
|
impl From<PipelineState> for WsResponse {
|
||||||
fn from(s: PipelineState) -> Self {
|
fn from(s: PipelineState) -> Self {
|
||||||
WsResponse::PipelineState {
|
WsResponse::PipelineState {
|
||||||
upcoming: s.upcoming,
|
backlog: s.backlog,
|
||||||
current: s.current,
|
current: s.current,
|
||||||
qa: s.qa,
|
qa: s.qa,
|
||||||
merge: s.merge,
|
merge: s.merge,
|
||||||
@@ -208,6 +216,42 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push recent server log entries so the client has history on connect.
|
||||||
|
{
|
||||||
|
let entries = log_buffer::global().get_recent_entries(100, None, None);
|
||||||
|
for entry in entries {
|
||||||
|
let _ = tx.send(WsResponse::LogEntry {
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
level: entry.level.as_str().to_string(),
|
||||||
|
message: entry.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to live log entries and forward them to the client.
|
||||||
|
let tx_logs = tx.clone();
|
||||||
|
let mut log_rx = log_buffer::global().subscribe();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match log_rx.recv().await {
|
||||||
|
Ok(entry) => {
|
||||||
|
if tx_logs
|
||||||
|
.send(WsResponse::LogEntry {
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
level: entry.level.as_str().to_string(),
|
||||||
|
message: entry.message,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Subscribe to filesystem watcher events and forward them to the client.
|
// Subscribe to filesystem watcher events and forward them to the client.
|
||||||
// After each work-item event, also push the updated pipeline state.
|
// After each work-item event, also push the updated pipeline state.
|
||||||
// Config-changed events are forwarded as-is without a pipeline refresh.
|
// Config-changed events are forwarded as-is without a pipeline refresh.
|
||||||
@@ -693,9 +737,13 @@ mod tests {
|
|||||||
error: None,
|
error: None,
|
||||||
merge_failure: None,
|
merge_failure: None,
|
||||||
agent: None,
|
agent: None,
|
||||||
|
review_hold: None,
|
||||||
|
qa: None,
|
||||||
|
retry_count: None,
|
||||||
|
blocked: None,
|
||||||
};
|
};
|
||||||
let resp = WsResponse::PipelineState {
|
let resp = WsResponse::PipelineState {
|
||||||
upcoming: vec![story],
|
backlog: vec![story],
|
||||||
current: vec![],
|
current: vec![],
|
||||||
qa: vec![],
|
qa: vec![],
|
||||||
merge: vec![],
|
merge: vec![],
|
||||||
@@ -703,8 +751,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let json = serde_json::to_value(&resp).unwrap();
|
let json = serde_json::to_value(&resp).unwrap();
|
||||||
assert_eq!(json["type"], "pipeline_state");
|
assert_eq!(json["type"], "pipeline_state");
|
||||||
assert_eq!(json["upcoming"].as_array().unwrap().len(), 1);
|
assert_eq!(json["backlog"].as_array().unwrap().len(), 1);
|
||||||
assert_eq!(json["upcoming"][0]["story_id"], "10_story_test");
|
assert_eq!(json["backlog"][0]["story_id"], "10_story_test");
|
||||||
assert!(json["current"].as_array().unwrap().is_empty());
|
assert!(json["current"].as_array().unwrap().is_empty());
|
||||||
assert!(json["done"].as_array().unwrap().is_empty());
|
assert!(json["done"].as_array().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
@@ -824,12 +872,16 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pipeline_state_converts_to_ws_response() {
|
fn pipeline_state_converts_to_ws_response() {
|
||||||
let state = PipelineState {
|
let state = PipelineState {
|
||||||
upcoming: vec![UpcomingStory {
|
backlog: vec![UpcomingStory {
|
||||||
story_id: "1_story_a".to_string(),
|
story_id: "1_story_a".to_string(),
|
||||||
name: Some("Story A".to_string()),
|
name: Some("Story A".to_string()),
|
||||||
error: None,
|
error: None,
|
||||||
merge_failure: None,
|
merge_failure: None,
|
||||||
agent: None,
|
agent: None,
|
||||||
|
review_hold: None,
|
||||||
|
qa: None,
|
||||||
|
retry_count: None,
|
||||||
|
blocked: None,
|
||||||
}],
|
}],
|
||||||
current: vec![UpcomingStory {
|
current: vec![UpcomingStory {
|
||||||
story_id: "2_story_b".to_string(),
|
story_id: "2_story_b".to_string(),
|
||||||
@@ -837,6 +889,10 @@ mod tests {
|
|||||||
error: None,
|
error: None,
|
||||||
merge_failure: None,
|
merge_failure: None,
|
||||||
agent: None,
|
agent: None,
|
||||||
|
review_hold: None,
|
||||||
|
qa: None,
|
||||||
|
retry_count: None,
|
||||||
|
blocked: None,
|
||||||
}],
|
}],
|
||||||
qa: vec![],
|
qa: vec![],
|
||||||
merge: vec![],
|
merge: vec![],
|
||||||
@@ -846,13 +902,17 @@ mod tests {
|
|||||||
error: None,
|
error: None,
|
||||||
merge_failure: None,
|
merge_failure: None,
|
||||||
agent: None,
|
agent: None,
|
||||||
|
review_hold: None,
|
||||||
|
qa: None,
|
||||||
|
retry_count: None,
|
||||||
|
blocked: None,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
let resp: WsResponse = state.into();
|
let resp: WsResponse = state.into();
|
||||||
let json = serde_json::to_value(&resp).unwrap();
|
let json = serde_json::to_value(&resp).unwrap();
|
||||||
assert_eq!(json["type"], "pipeline_state");
|
assert_eq!(json["type"], "pipeline_state");
|
||||||
assert_eq!(json["upcoming"].as_array().unwrap().len(), 1);
|
assert_eq!(json["backlog"].as_array().unwrap().len(), 1);
|
||||||
assert_eq!(json["upcoming"][0]["story_id"], "1_story_a");
|
assert_eq!(json["backlog"][0]["story_id"], "1_story_a");
|
||||||
assert_eq!(json["current"].as_array().unwrap().len(), 1);
|
assert_eq!(json["current"].as_array().unwrap().len(), 1);
|
||||||
assert_eq!(json["current"][0]["story_id"], "2_story_b");
|
assert_eq!(json["current"][0]["story_id"], "2_story_b");
|
||||||
assert!(json["qa"].as_array().unwrap().is_empty());
|
assert!(json["qa"].as_array().unwrap().is_empty());
|
||||||
@@ -864,7 +924,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn empty_pipeline_state_converts_to_ws_response() {
|
fn empty_pipeline_state_converts_to_ws_response() {
|
||||||
let state = PipelineState {
|
let state = PipelineState {
|
||||||
upcoming: vec![],
|
backlog: vec![],
|
||||||
current: vec![],
|
current: vec![],
|
||||||
qa: vec![],
|
qa: vec![],
|
||||||
merge: vec![],
|
merge: vec![],
|
||||||
@@ -873,7 +933,7 @@ mod tests {
|
|||||||
let resp: WsResponse = state.into();
|
let resp: WsResponse = state.into();
|
||||||
let json = serde_json::to_value(&resp).unwrap();
|
let json = serde_json::to_value(&resp).unwrap();
|
||||||
assert_eq!(json["type"], "pipeline_state");
|
assert_eq!(json["type"], "pipeline_state");
|
||||||
assert!(json["upcoming"].as_array().unwrap().is_empty());
|
assert!(json["backlog"].as_array().unwrap().is_empty());
|
||||||
assert!(json["current"].as_array().unwrap().is_empty());
|
assert!(json["current"].as_array().unwrap().is_empty());
|
||||||
assert!(json["qa"].as_array().unwrap().is_empty());
|
assert!(json["qa"].as_array().unwrap().is_empty());
|
||||||
assert!(json["merge"].as_array().unwrap().is_empty());
|
assert!(json["merge"].as_array().unwrap().is_empty());
|
||||||
@@ -991,7 +1051,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pipeline_state_with_agent_converts_correctly() {
|
fn pipeline_state_with_agent_converts_correctly() {
|
||||||
let state = PipelineState {
|
let state = PipelineState {
|
||||||
upcoming: vec![],
|
backlog: vec![],
|
||||||
current: vec![UpcomingStory {
|
current: vec![UpcomingStory {
|
||||||
story_id: "10_story_x".to_string(),
|
story_id: "10_story_x".to_string(),
|
||||||
name: Some("Story X".to_string()),
|
name: Some("Story X".to_string()),
|
||||||
@@ -1002,6 +1062,10 @@ mod tests {
|
|||||||
model: Some("claude-3-5-sonnet".to_string()),
|
model: Some("claude-3-5-sonnet".to_string()),
|
||||||
status: "running".to_string(),
|
status: "running".to_string(),
|
||||||
}),
|
}),
|
||||||
|
review_hold: None,
|
||||||
|
qa: None,
|
||||||
|
retry_count: None,
|
||||||
|
blocked: None,
|
||||||
}],
|
}],
|
||||||
qa: vec![],
|
qa: vec![],
|
||||||
merge: vec![],
|
merge: vec![],
|
||||||
@@ -1046,7 +1110,7 @@ mod tests {
|
|||||||
let root = tmp.path().to_path_buf();
|
let root = tmp.path().to_path_buf();
|
||||||
|
|
||||||
// Create minimal pipeline dirs so load_pipeline_state succeeds.
|
// Create minimal pipeline dirs so load_pipeline_state succeeds.
|
||||||
for stage in &["1_upcoming", "2_current", "3_qa", "4_merge"] {
|
for stage in &["1_backlog", "2_current", "3_qa", "4_merge"] {
|
||||||
std::fs::create_dir_all(root.join(".story_kit").join("work").join(stage)).unwrap();
|
std::fs::create_dir_all(root.join(".story_kit").join("work").join(stage)).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1126,10 +1190,30 @@ mod tests {
|
|||||||
"expected onboarding_status, got: {onboarding}"
|
"expected onboarding_status, got: {onboarding}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Drain any log_entry messages sent as initial history on connect.
|
||||||
|
// These are buffered before tests send their own requests.
|
||||||
|
loop {
|
||||||
|
// Use a very short timeout: if nothing arrives quickly, the burst is done.
|
||||||
|
let Ok(Some(Ok(msg))) =
|
||||||
|
tokio::time::timeout(std::time::Duration::from_millis(200), stream.next()).await
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let val: serde_json::Value = match msg {
|
||||||
|
tungstenite::Message::Text(t) => serde_json::from_str(t.as_ref()).unwrap(),
|
||||||
|
_ => break,
|
||||||
|
};
|
||||||
|
if val["type"] != "log_entry" {
|
||||||
|
// Unexpected non-log message during drain — this shouldn't happen.
|
||||||
|
panic!("unexpected message during log drain: {val}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
(sink, stream, initial)
|
(sink, stream, initial)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read next text message from the stream with a timeout.
|
/// Read next non-log_entry text message from the stream with a timeout.
|
||||||
|
/// Skips any `log_entry` messages that arrive between events.
|
||||||
async fn next_msg(
|
async fn next_msg(
|
||||||
stream: &mut futures::stream::SplitStream<
|
stream: &mut futures::stream::SplitStream<
|
||||||
tokio_tungstenite::WebSocketStream<
|
tokio_tungstenite::WebSocketStream<
|
||||||
@@ -1137,14 +1221,19 @@ mod tests {
|
|||||||
>,
|
>,
|
||||||
>,
|
>,
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
|
loop {
|
||||||
let msg = tokio::time::timeout(std::time::Duration::from_secs(2), stream.next())
|
let msg = tokio::time::timeout(std::time::Duration::from_secs(2), stream.next())
|
||||||
.await
|
.await
|
||||||
.expect("timeout waiting for message")
|
.expect("timeout waiting for message")
|
||||||
.expect("stream ended")
|
.expect("stream ended")
|
||||||
.expect("ws error");
|
.expect("ws error");
|
||||||
match msg {
|
let val: serde_json::Value = match msg {
|
||||||
tungstenite::Message::Text(t) => serde_json::from_str(t.as_ref()).unwrap(),
|
tungstenite::Message::Text(t) => serde_json::from_str(t.as_ref()).unwrap(),
|
||||||
other => panic!("expected text message, got: {other:?}"),
|
other => panic!("expected text message, got: {other:?}"),
|
||||||
|
};
|
||||||
|
if val["type"] != "log_entry" {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1155,7 +1244,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(initial["type"], "pipeline_state");
|
assert_eq!(initial["type"], "pipeline_state");
|
||||||
// All stages should be empty arrays since no .md files were created.
|
// All stages should be empty arrays since no .md files were created.
|
||||||
assert!(initial["upcoming"].as_array().unwrap().is_empty());
|
assert!(initial["backlog"].as_array().unwrap().is_empty());
|
||||||
assert!(initial["current"].as_array().unwrap().is_empty());
|
assert!(initial["current"].as_array().unwrap().is_empty());
|
||||||
assert!(initial["qa"].as_array().unwrap().is_empty());
|
assert!(initial["qa"].as_array().unwrap().is_empty());
|
||||||
assert!(initial["merge"].as_array().unwrap().is_empty());
|
assert!(initial["merge"].as_array().unwrap().is_empty());
|
||||||
|
|||||||
@@ -99,7 +99,11 @@ const STORY_KIT_CLAUDE_SETTINGS: &str = r#"{
|
|||||||
}
|
}
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
const DEFAULT_PROJECT_AGENTS_TOML: &str = r#"[[agent]]
|
const DEFAULT_PROJECT_AGENTS_TOML: &str = r#"# Project-wide default QA mode: "server", "agent", or "human".
|
||||||
|
# Per-story `qa` front matter overrides this setting.
|
||||||
|
default_qa = "server"
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
name = "coder-1"
|
name = "coder-1"
|
||||||
stage = "coder"
|
stage = "coder"
|
||||||
role = "Full-stack engineer. Implements features across all components."
|
role = "Full-stack engineer. Implements features across all components."
|
||||||
@@ -409,7 +413,7 @@ fn scaffold_story_kit(root: &Path) -> Result<(), String> {
|
|||||||
|
|
||||||
// Create the work/ pipeline directories, each with a .gitkeep so empty dirs survive git clone
|
// Create the work/ pipeline directories, each with a .gitkeep so empty dirs survive git clone
|
||||||
let work_stages = [
|
let work_stages = [
|
||||||
"1_upcoming",
|
"1_backlog",
|
||||||
"2_current",
|
"2_current",
|
||||||
"3_qa",
|
"3_qa",
|
||||||
"4_merge",
|
"4_merge",
|
||||||
@@ -1085,7 +1089,7 @@ mod tests {
|
|||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
scaffold_story_kit(dir.path()).unwrap();
|
scaffold_story_kit(dir.path()).unwrap();
|
||||||
|
|
||||||
let stages = ["1_upcoming", "2_current", "3_qa", "4_merge", "5_done", "6_archived"];
|
let stages = ["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"];
|
||||||
for stage in &stages {
|
for stage in &stages {
|
||||||
let path = dir.path().join(".story_kit/work").join(stage);
|
let path = dir.path().join(".story_kit/work").join(stage);
|
||||||
assert!(path.is_dir(), "work/{} should be a directory", stage);
|
assert!(path.is_dir(), "work/{} should be a directory", stage);
|
||||||
|
|||||||
@@ -2,6 +2,45 @@ use serde::Deserialize;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// QA mode for a story: determines how the pipeline handles post-coder review.
|
||||||
|
///
|
||||||
|
/// - `Server` — skip the QA agent; rely on server gate checks (clippy + tests).
|
||||||
|
/// If gates pass, advance straight to merge.
|
||||||
|
/// - `Agent` — spin up a QA agent (Claude session) to review code and run gates.
|
||||||
|
/// - `Human` — hold in QA for human approval after server gates pass.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum QaMode {
|
||||||
|
Server,
|
||||||
|
Agent,
|
||||||
|
Human,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QaMode {
|
||||||
|
/// Parse a string into a `QaMode`. Returns `None` for unrecognised values.
|
||||||
|
pub fn from_str(s: &str) -> Option<Self> {
|
||||||
|
match s.trim().to_lowercase().as_str() {
|
||||||
|
"server" => Some(Self::Server),
|
||||||
|
"agent" => Some(Self::Agent),
|
||||||
|
"human" => Some(Self::Human),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Server => "server",
|
||||||
|
Self::Agent => "agent",
|
||||||
|
Self::Human => "human",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for QaMode {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
pub struct StoryMetadata {
|
pub struct StoryMetadata {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
@@ -9,6 +48,11 @@ pub struct StoryMetadata {
|
|||||||
pub merge_failure: Option<String>,
|
pub merge_failure: Option<String>,
|
||||||
pub agent: Option<String>,
|
pub agent: Option<String>,
|
||||||
pub review_hold: Option<bool>,
|
pub review_hold: Option<bool>,
|
||||||
|
pub qa: Option<QaMode>,
|
||||||
|
/// Number of times this story has been retried at its current pipeline stage.
|
||||||
|
pub retry_count: Option<u32>,
|
||||||
|
/// When `true`, auto-assign will skip this story (retry limit exceeded).
|
||||||
|
pub blocked: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -33,6 +77,14 @@ struct FrontMatter {
|
|||||||
merge_failure: Option<String>,
|
merge_failure: Option<String>,
|
||||||
agent: Option<String>,
|
agent: Option<String>,
|
||||||
review_hold: Option<bool>,
|
review_hold: Option<bool>,
|
||||||
|
/// New configurable QA mode field: "human", "server", or "agent".
|
||||||
|
qa: Option<String>,
|
||||||
|
/// Legacy boolean field — mapped to `qa: human` (true) or ignored (false/absent).
|
||||||
|
manual_qa: Option<bool>,
|
||||||
|
/// Number of times this story has been retried at its current pipeline stage.
|
||||||
|
retry_count: Option<u32>,
|
||||||
|
/// When `true`, auto-assign will skip this story (retry limit exceeded).
|
||||||
|
blocked: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
|
pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
|
||||||
@@ -61,12 +113,22 @@ pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaErro
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_metadata(front: FrontMatter) -> StoryMetadata {
|
fn build_metadata(front: FrontMatter) -> StoryMetadata {
|
||||||
|
// Resolve qa mode: prefer the new `qa` field, fall back to legacy `manual_qa`.
|
||||||
|
let qa = if let Some(ref qa_str) = front.qa {
|
||||||
|
QaMode::from_str(qa_str)
|
||||||
|
} else {
|
||||||
|
front.manual_qa.and_then(|v| if v { Some(QaMode::Human) } else { None })
|
||||||
|
};
|
||||||
|
|
||||||
StoryMetadata {
|
StoryMetadata {
|
||||||
name: front.name,
|
name: front.name,
|
||||||
coverage_baseline: front.coverage_baseline,
|
coverage_baseline: front.coverage_baseline,
|
||||||
merge_failure: front.merge_failure,
|
merge_failure: front.merge_failure,
|
||||||
agent: front.agent,
|
agent: front.agent,
|
||||||
review_hold: front.review_hold,
|
review_hold: front.review_hold,
|
||||||
|
qa,
|
||||||
|
retry_count: front.retry_count,
|
||||||
|
blocked: front.blocked,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +255,67 @@ pub fn set_front_matter_field(contents: &str, key: &str, value: &str) -> String
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Increment the `retry_count` field in the story file's front matter.
|
||||||
|
///
|
||||||
|
/// Reads the current value (defaulting to 0), increments by 1, and writes back.
|
||||||
|
/// Returns the new retry count.
|
||||||
|
pub fn increment_retry_count(path: &Path) -> Result<u32, String> {
|
||||||
|
let contents =
|
||||||
|
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
|
||||||
|
|
||||||
|
let current = parse_front_matter(&contents)
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.retry_count)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let new_count = current + 1;
|
||||||
|
|
||||||
|
let updated = set_front_matter_field(&contents, "retry_count", &new_count.to_string());
|
||||||
|
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
|
||||||
|
Ok(new_count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `blocked: true` to the YAML front matter of a story file.
|
||||||
|
///
|
||||||
|
/// Used to mark stories that have exceeded the retry limit and should not
|
||||||
|
/// be auto-assigned again.
|
||||||
|
pub fn write_blocked(path: &Path) -> Result<(), String> {
|
||||||
|
let contents =
|
||||||
|
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
|
||||||
|
let updated = set_front_matter_field(&contents, "blocked", "true");
|
||||||
|
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append rejection notes to a story file body.
|
||||||
|
///
|
||||||
|
/// Adds a `## QA Rejection Notes` section at the end of the file so the coder
|
||||||
|
/// agent can see what needs fixing.
|
||||||
|
pub fn write_rejection_notes(path: &Path, notes: &str) -> Result<(), String> {
|
||||||
|
let contents =
|
||||||
|
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
|
||||||
|
|
||||||
|
let section = format!("\n\n## QA Rejection Notes\n\n{notes}\n");
|
||||||
|
let updated = format!("{contents}{section}");
|
||||||
|
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the effective QA mode for a story file.
|
||||||
|
///
|
||||||
|
/// Reads the `qa` front matter field. If absent, falls back to `default`.
|
||||||
|
/// Spikes are **not** handled here — the caller is responsible for overriding
|
||||||
|
/// to `Human` for spikes.
|
||||||
|
pub fn resolve_qa_mode(path: &Path, default: QaMode) -> QaMode {
|
||||||
|
let contents = match fs::read_to_string(path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return default,
|
||||||
|
};
|
||||||
|
match parse_front_matter(&contents) {
|
||||||
|
Ok(meta) => meta.qa.unwrap_or(default),
|
||||||
|
Err(_) => default,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn parse_unchecked_todos(contents: &str) -> Vec<String> {
|
pub fn parse_unchecked_todos(contents: &str) -> Vec<String> {
|
||||||
contents
|
contents
|
||||||
.lines()
|
.lines()
|
||||||
@@ -367,4 +490,81 @@ workflow: tdd
|
|||||||
assert!(contents.contains("review_hold: true"));
|
assert!(contents.contains("review_hold: true"));
|
||||||
assert!(contents.contains("name: My Spike"));
|
assert!(contents.contains("name: My Spike"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_qa_mode_from_front_matter() {
|
||||||
|
let input = "---\nname: Story\nqa: server\n---\n# Story\n";
|
||||||
|
let meta = parse_front_matter(input).expect("front matter");
|
||||||
|
assert_eq!(meta.qa, Some(QaMode::Server));
|
||||||
|
|
||||||
|
let input = "---\nname: Story\nqa: agent\n---\n# Story\n";
|
||||||
|
let meta = parse_front_matter(input).expect("front matter");
|
||||||
|
assert_eq!(meta.qa, Some(QaMode::Agent));
|
||||||
|
|
||||||
|
let input = "---\nname: Story\nqa: human\n---\n# Story\n";
|
||||||
|
let meta = parse_front_matter(input).expect("front matter");
|
||||||
|
assert_eq!(meta.qa, Some(QaMode::Human));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn qa_mode_defaults_to_none() {
|
||||||
|
let input = "---\nname: Story\n---\n# Story\n";
|
||||||
|
let meta = parse_front_matter(input).expect("front matter");
|
||||||
|
assert_eq!(meta.qa, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_manual_qa_true_maps_to_human() {
|
||||||
|
let input = "---\nname: Story\nmanual_qa: true\n---\n# Story\n";
|
||||||
|
let meta = parse_front_matter(input).expect("front matter");
|
||||||
|
assert_eq!(meta.qa, Some(QaMode::Human));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_manual_qa_false_maps_to_none() {
|
||||||
|
let input = "---\nname: Story\nmanual_qa: false\n---\n# Story\n";
|
||||||
|
let meta = parse_front_matter(input).expect("front matter");
|
||||||
|
assert_eq!(meta.qa, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn qa_field_takes_precedence_over_manual_qa() {
|
||||||
|
let input = "---\nname: Story\nqa: server\nmanual_qa: true\n---\n# Story\n";
|
||||||
|
let meta = parse_front_matter(input).expect("front matter");
|
||||||
|
assert_eq!(meta.qa, Some(QaMode::Server));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_qa_mode_uses_file_value() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let path = tmp.path().join("story.md");
|
||||||
|
std::fs::write(&path, "---\nname: Test\nqa: human\n---\n# Story\n").unwrap();
|
||||||
|
assert_eq!(resolve_qa_mode(&path, QaMode::Server), QaMode::Human);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_qa_mode_falls_back_to_default() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let path = tmp.path().join("story.md");
|
||||||
|
std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap();
|
||||||
|
assert_eq!(resolve_qa_mode(&path, QaMode::Server), QaMode::Server);
|
||||||
|
assert_eq!(resolve_qa_mode(&path, QaMode::Agent), QaMode::Agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_qa_mode_missing_file_uses_default() {
|
||||||
|
let path = std::path::Path::new("/nonexistent/story.md");
|
||||||
|
assert_eq!(resolve_qa_mode(path, QaMode::Server), QaMode::Server);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_rejection_notes_appends_section() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let path = tmp.path().join("story.md");
|
||||||
|
std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap();
|
||||||
|
write_rejection_notes(&path, "Button color is wrong").unwrap();
|
||||||
|
let contents = std::fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(contents.contains("## QA Rejection Notes"));
|
||||||
|
assert!(contents.contains("Button color is wrong"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ pub fn is_config_file(path: &Path, git_root: &Path) -> bool {
|
|||||||
/// Map a pipeline directory name to a (action, commit-message-prefix) pair.
|
/// Map a pipeline directory name to a (action, commit-message-prefix) pair.
|
||||||
fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, String)> {
|
fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, String)> {
|
||||||
let (action, prefix) = match stage {
|
let (action, prefix) = match stage {
|
||||||
"1_upcoming" => ("create", format!("story-kit: create {item_id}")),
|
"1_backlog" => ("create", format!("story-kit: create {item_id}")),
|
||||||
"2_current" => ("start", format!("story-kit: start {item_id}")),
|
"2_current" => ("start", format!("story-kit: start {item_id}")),
|
||||||
"3_qa" => ("qa", format!("story-kit: queue {item_id} for QA")),
|
"3_qa" => ("qa", format!("story-kit: queue {item_id} for QA")),
|
||||||
"4_merge" => ("merge", format!("story-kit: queue {item_id} for merge")),
|
"4_merge" => ("merge", format!("story-kit: queue {item_id} for merge")),
|
||||||
@@ -111,7 +111,7 @@ fn stage_for_path(path: &Path) -> Option<String> {
|
|||||||
.parent()
|
.parent()
|
||||||
.and_then(|p| p.file_name())
|
.and_then(|p| p.file_name())
|
||||||
.and_then(|n| n.to_str())?;
|
.and_then(|n| n.to_str())?;
|
||||||
matches!(stage, "1_upcoming" | "2_current" | "3_qa" | "4_merge" | "5_done" | "6_archived")
|
matches!(stage, "1_backlog" | "2_current" | "3_qa" | "4_merge" | "5_done" | "6_archived")
|
||||||
.then(|| stage.to_string())
|
.then(|| stage.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ fn git_add_work_and_commit(git_root: &Path, message: &str) -> Result<bool, Strin
|
|||||||
/// Intermediate stages (current, qa, merge, done) are transient pipeline state
|
/// Intermediate stages (current, qa, merge, done) are transient pipeline state
|
||||||
/// that don't need to be committed — they're only relevant while the server is
|
/// that don't need to be committed — they're only relevant while the server is
|
||||||
/// running and are broadcast to WebSocket clients for real-time UI updates.
|
/// running and are broadcast to WebSocket clients for real-time UI updates.
|
||||||
const COMMIT_WORTHY_STAGES: &[&str] = &["1_upcoming", "5_done", "6_archived"];
|
const COMMIT_WORTHY_STAGES: &[&str] = &["1_backlog", "5_done", "6_archived"];
|
||||||
|
|
||||||
/// Return `true` if changes in `stage` should be committed to git.
|
/// Return `true` if changes in `stage` should be committed to git.
|
||||||
fn should_commit_stage(stage: &str) -> bool {
|
fn should_commit_stage(stage: &str) -> bool {
|
||||||
@@ -172,7 +172,7 @@ fn should_commit_stage(stage: &str) -> bool {
|
|||||||
/// (they represent the destination of a move or a new file). Deletions are
|
/// (they represent the destination of a move or a new file). Deletions are
|
||||||
/// captured by `git add -A .story_kit/work/` automatically.
|
/// captured by `git add -A .story_kit/work/` automatically.
|
||||||
///
|
///
|
||||||
/// Only terminal stages (`1_upcoming` and `6_archived`) trigger git commits.
|
/// Only terminal stages (`1_backlog` and `6_archived`) trigger git commits.
|
||||||
/// All stages broadcast a [`WatcherEvent`] so the frontend stays in sync.
|
/// All stages broadcast a [`WatcherEvent`] so the frontend stays in sync.
|
||||||
fn flush_pending(
|
fn flush_pending(
|
||||||
pending: &HashMap<PathBuf, String>,
|
pending: &HashMap<PathBuf, String>,
|
||||||
@@ -574,13 +574,13 @@ mod tests {
|
|||||||
fn flush_pending_commits_and_broadcasts_for_terminal_stage() {
|
fn flush_pending_commits_and_broadcasts_for_terminal_stage() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
init_git_repo(tmp.path());
|
init_git_repo(tmp.path());
|
||||||
let stage_dir = make_stage_dir(tmp.path(), "1_upcoming");
|
let stage_dir = make_stage_dir(tmp.path(), "1_backlog");
|
||||||
let story_path = stage_dir.join("42_story_foo.md");
|
let story_path = stage_dir.join("42_story_foo.md");
|
||||||
fs::write(&story_path, "---\nname: test\n---\n").unwrap();
|
fs::write(&story_path, "---\nname: test\n---\n").unwrap();
|
||||||
|
|
||||||
let (tx, mut rx) = tokio::sync::broadcast::channel(16);
|
let (tx, mut rx) = tokio::sync::broadcast::channel(16);
|
||||||
let mut pending = HashMap::new();
|
let mut pending = HashMap::new();
|
||||||
pending.insert(story_path, "1_upcoming".to_string());
|
pending.insert(story_path, "1_backlog".to_string());
|
||||||
|
|
||||||
flush_pending(&pending, tmp.path(), &tx);
|
flush_pending(&pending, tmp.path(), &tx);
|
||||||
|
|
||||||
@@ -592,7 +592,7 @@ mod tests {
|
|||||||
action,
|
action,
|
||||||
commit_msg,
|
commit_msg,
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(stage, "1_upcoming");
|
assert_eq!(stage, "1_backlog");
|
||||||
assert_eq!(item_id, "42_story_foo");
|
assert_eq!(item_id, "42_story_foo");
|
||||||
assert_eq!(action, "create");
|
assert_eq!(action, "create");
|
||||||
assert_eq!(commit_msg, "story-kit: create 42_story_foo");
|
assert_eq!(commit_msg, "story-kit: create 42_story_foo");
|
||||||
@@ -660,7 +660,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn flush_pending_broadcasts_for_all_pipeline_stages() {
|
fn flush_pending_broadcasts_for_all_pipeline_stages() {
|
||||||
let stages = [
|
let stages = [
|
||||||
("1_upcoming", "create", "story-kit: create 10_story_x"),
|
("1_backlog", "create", "story-kit: create 10_story_x"),
|
||||||
("3_qa", "qa", "story-kit: queue 10_story_x for QA"),
|
("3_qa", "qa", "story-kit: queue 10_story_x for QA"),
|
||||||
("4_merge", "merge", "story-kit: queue 10_story_x for merge"),
|
("4_merge", "merge", "story-kit: queue 10_story_x for merge"),
|
||||||
("5_done", "done", "story-kit: done 10_story_x"),
|
("5_done", "done", "story-kit: done 10_story_x"),
|
||||||
@@ -792,10 +792,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn flush_pending_clears_merge_failure_when_moving_to_upcoming() {
|
fn flush_pending_clears_merge_failure_when_moving_to_backlog() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
init_git_repo(tmp.path());
|
init_git_repo(tmp.path());
|
||||||
let stage_dir = make_stage_dir(tmp.path(), "1_upcoming");
|
let stage_dir = make_stage_dir(tmp.path(), "1_backlog");
|
||||||
let story_path = stage_dir.join("51_story_reset.md");
|
let story_path = stage_dir.join("51_story_reset.md");
|
||||||
fs::write(
|
fs::write(
|
||||||
&story_path,
|
&story_path,
|
||||||
@@ -805,14 +805,14 @@ mod tests {
|
|||||||
|
|
||||||
let (tx, _rx) = tokio::sync::broadcast::channel(16);
|
let (tx, _rx) = tokio::sync::broadcast::channel(16);
|
||||||
let mut pending = HashMap::new();
|
let mut pending = HashMap::new();
|
||||||
pending.insert(story_path.clone(), "1_upcoming".to_string());
|
pending.insert(story_path.clone(), "1_backlog".to_string());
|
||||||
|
|
||||||
flush_pending(&pending, tmp.path(), &tx);
|
flush_pending(&pending, tmp.path(), &tx);
|
||||||
|
|
||||||
let contents = fs::read_to_string(&story_path).unwrap();
|
let contents = fs::read_to_string(&story_path).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
!contents.contains("merge_failure"),
|
!contents.contains("merge_failure"),
|
||||||
"merge_failure should be stripped when story lands in 1_upcoming"
|
"merge_failure should be stripped when story lands in 1_backlog"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -937,7 +937,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn should_commit_stage_only_for_terminal_stages() {
|
fn should_commit_stage_only_for_terminal_stages() {
|
||||||
// Terminal stages — should commit.
|
// Terminal stages — should commit.
|
||||||
assert!(should_commit_stage("1_upcoming"));
|
assert!(should_commit_stage("1_backlog"));
|
||||||
assert!(should_commit_stage("5_done"));
|
assert!(should_commit_stage("5_done"));
|
||||||
assert!(should_commit_stage("6_archived"));
|
assert!(should_commit_stage("6_archived"));
|
||||||
// Intermediate stages — broadcast-only, no commit.
|
// Intermediate stages — broadcast-only, no commit.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use std::fs::OpenOptions;
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
const CAPACITY: usize = 1000;
|
const CAPACITY: usize = 1000;
|
||||||
|
|
||||||
@@ -72,16 +73,25 @@ impl LogEntry {
|
|||||||
pub struct LogBuffer {
|
pub struct LogBuffer {
|
||||||
entries: Mutex<VecDeque<LogEntry>>,
|
entries: Mutex<VecDeque<LogEntry>>,
|
||||||
log_file: Mutex<Option<PathBuf>>,
|
log_file: Mutex<Option<PathBuf>>,
|
||||||
|
/// Broadcast channel for live log streaming to WebSocket subscribers.
|
||||||
|
broadcast_tx: broadcast::Sender<LogEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LogBuffer {
|
impl LogBuffer {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
|
let (broadcast_tx, _) = broadcast::channel(512);
|
||||||
Self {
|
Self {
|
||||||
entries: Mutex::new(VecDeque::with_capacity(CAPACITY)),
|
entries: Mutex::new(VecDeque::with_capacity(CAPACITY)),
|
||||||
log_file: Mutex::new(None),
|
log_file: Mutex::new(None),
|
||||||
|
broadcast_tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subscribe to live log entries as they are pushed.
|
||||||
|
pub fn subscribe(&self) -> broadcast::Receiver<LogEntry> {
|
||||||
|
self.broadcast_tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the persistent log file path. Call once at startup after the
|
/// Set the persistent log file path. Call once at startup after the
|
||||||
/// project root is known.
|
/// project root is known.
|
||||||
pub fn set_log_file(&self, path: PathBuf) {
|
pub fn set_log_file(&self, path: PathBuf) {
|
||||||
@@ -112,8 +122,11 @@ impl LogBuffer {
|
|||||||
if buf.len() >= CAPACITY {
|
if buf.len() >= CAPACITY {
|
||||||
buf.pop_front();
|
buf.pop_front();
|
||||||
}
|
}
|
||||||
buf.push_back(entry);
|
buf.push_back(entry.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort broadcast to WebSocket subscribers.
|
||||||
|
let _ = self.broadcast_tx.send(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return up to `count` recent log lines as formatted strings,
|
/// Return up to `count` recent log lines as formatted strings,
|
||||||
@@ -140,6 +153,31 @@ impl LogBuffer {
|
|||||||
let start = filtered.len().saturating_sub(count);
|
let start = filtered.len().saturating_sub(count);
|
||||||
filtered[start..].to_vec()
|
filtered[start..].to_vec()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return up to `count` recent `LogEntry` structs (not formatted strings),
|
||||||
|
/// optionally filtered by substring and/or severity level.
|
||||||
|
/// Entries are returned in chronological order (oldest first).
|
||||||
|
pub fn get_recent_entries(
|
||||||
|
&self,
|
||||||
|
count: usize,
|
||||||
|
filter: Option<&str>,
|
||||||
|
severity: Option<&LogLevel>,
|
||||||
|
) -> Vec<LogEntry> {
|
||||||
|
let buf = match self.entries.lock() {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => return vec![],
|
||||||
|
};
|
||||||
|
let filtered: Vec<LogEntry> = buf
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| {
|
||||||
|
severity.is_none_or(|s| &entry.level == s)
|
||||||
|
&& filter.is_none_or(|f| entry.message.contains(f) || entry.formatted().contains(f))
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let start = filtered.len().saturating_sub(count);
|
||||||
|
filtered[start..].to_vec()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static GLOBAL: OnceLock<LogBuffer> = OnceLock::new();
|
static GLOBAL: OnceLock<LogBuffer> = OnceLock::new();
|
||||||
@@ -208,10 +246,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn evicts_oldest_at_capacity() {
|
fn evicts_oldest_at_capacity() {
|
||||||
let buf = LogBuffer {
|
let buf = LogBuffer::new();
|
||||||
entries: Mutex::new(VecDeque::with_capacity(CAPACITY)),
|
|
||||||
log_file: Mutex::new(None),
|
|
||||||
};
|
|
||||||
// Fill past capacity
|
// Fill past capacity
|
||||||
for i in 0..=CAPACITY {
|
for i in 0..=CAPACITY {
|
||||||
buf.push_entry(LogLevel::Info, format!("line {i}"));
|
buf.push_entry(LogLevel::Info, format!("line {i}"));
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
reconciliation_tx,
|
reconciliation_tx,
|
||||||
perm_tx,
|
perm_tx,
|
||||||
perm_rx,
|
perm_rx,
|
||||||
|
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = build_routes(ctx);
|
let app = build_routes(ctx);
|
||||||
@@ -196,7 +197,7 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
// Optional Matrix bot: connect to the homeserver and start listening for
|
// Optional Matrix bot: connect to the homeserver and start listening for
|
||||||
// messages if `.story_kit/bot.toml` is present and enabled.
|
// messages if `.story_kit/bot.toml` is present and enabled.
|
||||||
if let Some(ref root) = startup_root {
|
if let Some(ref root) = startup_root {
|
||||||
matrix::spawn_bot(root, watcher_tx_for_bot, perm_rx_for_bot);
|
matrix::spawn_bot(root, watcher_tx_for_bot, perm_rx_for_bot, Arc::clone(&startup_agents));
|
||||||
}
|
}
|
||||||
|
|
||||||
// On startup:
|
// On startup:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::agents::AgentPool;
|
||||||
use crate::http::context::{PermissionDecision, PermissionForward};
|
use crate::http::context::{PermissionDecision, PermissionForward};
|
||||||
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
@@ -165,6 +166,15 @@ pub struct BotContext {
|
|||||||
/// The name the bot uses to refer to itself. Derived from `display_name`
|
/// The name the bot uses to refer to itself. Derived from `display_name`
|
||||||
/// in bot.toml; defaults to "Assistant" when unset.
|
/// in bot.toml; defaults to "Assistant" when unset.
|
||||||
pub bot_name: String,
|
pub bot_name: String,
|
||||||
|
/// Set of room IDs where ambient mode is active. In ambient mode the bot
|
||||||
|
/// responds to all messages rather than only addressed ones.
|
||||||
|
/// Uses a sync mutex since locks are never held across await points.
|
||||||
|
pub ambient_rooms: Arc<std::sync::Mutex<HashSet<OwnedRoomId>>>,
|
||||||
|
/// Agent pool for checking agent availability.
|
||||||
|
pub agents: Arc<AgentPool>,
|
||||||
|
/// Per-room htop monitoring sessions. Keyed by room ID; each entry holds
|
||||||
|
/// a stop-signal sender that the background task watches.
|
||||||
|
pub htop_sessions: super::htop::HtopSessions,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -179,6 +189,10 @@ pub fn format_startup_announcement(bot_name: &str) -> String {
|
|||||||
format!("{bot_name} is online.")
|
format!("{bot_name} is online.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Command extraction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Bot entry point
|
// Bot entry point
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -191,6 +205,7 @@ pub async fn run_bot(
|
|||||||
project_root: PathBuf,
|
project_root: PathBuf,
|
||||||
watcher_rx: tokio::sync::broadcast::Receiver<crate::io::watcher::WatcherEvent>,
|
watcher_rx: tokio::sync::broadcast::Receiver<crate::io::watcher::WatcherEvent>,
|
||||||
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
|
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
|
||||||
|
agents: Arc<AgentPool>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let store_path = project_root.join(".story_kit").join("matrix_store");
|
let store_path = project_root.join(".story_kit").join("matrix_store");
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
@@ -210,7 +225,7 @@ pub async fn run_bot(
|
|||||||
let mut login_builder = client
|
let mut login_builder = client
|
||||||
.matrix_auth()
|
.matrix_auth()
|
||||||
.login_username(&config.username, &config.password)
|
.login_username(&config.username, &config.password)
|
||||||
.initial_device_display_name("Story Kit Bot");
|
.initial_device_display_name("Storkit Bot");
|
||||||
|
|
||||||
if let Some(ref device_id) = saved_device_id {
|
if let Some(ref device_id) = saved_device_id {
|
||||||
login_builder = login_builder.device_id(device_id);
|
login_builder = login_builder.device_id(device_id);
|
||||||
@@ -329,6 +344,21 @@ pub async fn run_bot(
|
|||||||
persisted.len()
|
persisted.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Restore persisted ambient rooms from config, ignoring any that are not
|
||||||
|
// in the configured target_room_ids to avoid stale entries.
|
||||||
|
let persisted_ambient: HashSet<OwnedRoomId> = config
|
||||||
|
.ambient_rooms
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| s.parse::<OwnedRoomId>().ok())
|
||||||
|
.collect();
|
||||||
|
if !persisted_ambient.is_empty() {
|
||||||
|
slog!(
|
||||||
|
"[matrix-bot] Restored ambient mode for {} room(s): {:?}",
|
||||||
|
persisted_ambient.len(),
|
||||||
|
persisted_ambient
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let bot_name = config
|
let bot_name = config
|
||||||
.display_name
|
.display_name
|
||||||
.clone()
|
.clone()
|
||||||
@@ -347,6 +377,9 @@ pub async fn run_bot(
|
|||||||
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
|
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
permission_timeout_secs: config.permission_timeout_secs,
|
permission_timeout_secs: config.permission_timeout_secs,
|
||||||
bot_name,
|
bot_name,
|
||||||
|
ambient_rooms: Arc::new(std::sync::Mutex::new(persisted_ambient)),
|
||||||
|
agents,
|
||||||
|
htop_sessions: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
};
|
};
|
||||||
|
|
||||||
slog!("[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected");
|
slog!("[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected");
|
||||||
@@ -666,11 +699,14 @@ async fn on_room_message(
|
|||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only respond when the bot is directly addressed (mentioned by name/ID)
|
// Only respond when the bot is directly addressed (mentioned by name/ID),
|
||||||
// or when the message is a reply to one of the bot's own messages.
|
// when the message is a reply to one of the bot's own messages, or when
|
||||||
if !mentions_bot(&body, formatted_body.as_deref(), &ctx.bot_user_id)
|
// ambient mode is enabled for this room.
|
||||||
&& !is_reply_to_bot(ev.content.relates_to.as_ref(), &ctx.bot_sent_event_ids).await
|
let is_addressed = mentions_bot(&body, formatted_body.as_deref(), &ctx.bot_user_id)
|
||||||
{
|
|| is_reply_to_bot(ev.content.relates_to.as_ref(), &ctx.bot_sent_event_ids).await;
|
||||||
|
let is_ambient = ctx.ambient_rooms.lock().unwrap().contains(&incoming_room_id);
|
||||||
|
|
||||||
|
if !is_addressed && !is_ambient {
|
||||||
slog!(
|
slog!(
|
||||||
"[matrix-bot] Ignoring unaddressed message from {}",
|
"[matrix-bot] Ignoring unaddressed message from {}",
|
||||||
ev.sender
|
ev.sender
|
||||||
@@ -743,6 +779,88 @@ async fn on_room_message(
|
|||||||
let user_message = body;
|
let user_message = body;
|
||||||
slog!("[matrix-bot] Message from {sender}: {user_message}");
|
slog!("[matrix-bot] Message from {sender}: {user_message}");
|
||||||
|
|
||||||
|
// Check for bot-level commands (help, status, ambient, …) before invoking
|
||||||
|
// the LLM. All commands are registered in commands.rs — no special-casing
|
||||||
|
// needed here.
|
||||||
|
let dispatch = super::commands::CommandDispatch {
|
||||||
|
bot_name: &ctx.bot_name,
|
||||||
|
bot_user_id: ctx.bot_user_id.as_str(),
|
||||||
|
project_root: &ctx.project_root,
|
||||||
|
agents: &ctx.agents,
|
||||||
|
ambient_rooms: &ctx.ambient_rooms,
|
||||||
|
room_id: &incoming_room_id,
|
||||||
|
is_addressed,
|
||||||
|
};
|
||||||
|
if let Some(response) = super::commands::try_handle_command(&dispatch, &user_message) {
|
||||||
|
slog!("[matrix-bot] Handled bot command from {sender}");
|
||||||
|
let html = markdown_to_html(&response);
|
||||||
|
if let Ok(resp) = room
|
||||||
|
.send(RoomMessageEventContent::text_html(response, html))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
ctx.bot_sent_event_ids.lock().await.insert(resp.event_id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for the htop command, which requires async Matrix access (Room)
|
||||||
|
// and cannot be handled by the sync command registry.
|
||||||
|
if let Some(htop_cmd) =
|
||||||
|
super::htop::extract_htop_command(&user_message, &ctx.bot_name, ctx.bot_user_id.as_str())
|
||||||
|
{
|
||||||
|
slog!("[matrix-bot] Handling htop command from {sender}: {htop_cmd:?}");
|
||||||
|
match htop_cmd {
|
||||||
|
super::htop::HtopCommand::Stop => {
|
||||||
|
super::htop::handle_htop_stop(&room, &incoming_room_id, &ctx.htop_sessions).await;
|
||||||
|
}
|
||||||
|
super::htop::HtopCommand::Start { duration_secs } => {
|
||||||
|
super::htop::handle_htop_start(
|
||||||
|
&room,
|
||||||
|
&incoming_room_id,
|
||||||
|
&ctx.htop_sessions,
|
||||||
|
Arc::clone(&ctx.agents),
|
||||||
|
duration_secs,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for the delete command, which requires async agent/worktree ops
|
||||||
|
// and cannot be handled by the sync command registry.
|
||||||
|
if let Some(del_cmd) = super::delete::extract_delete_command(
|
||||||
|
&user_message,
|
||||||
|
&ctx.bot_name,
|
||||||
|
ctx.bot_user_id.as_str(),
|
||||||
|
) {
|
||||||
|
let response = match del_cmd {
|
||||||
|
super::delete::DeleteCommand::Delete { story_number } => {
|
||||||
|
slog!(
|
||||||
|
"[matrix-bot] Handling delete command from {sender}: story {story_number}"
|
||||||
|
);
|
||||||
|
super::delete::handle_delete(
|
||||||
|
&ctx.bot_name,
|
||||||
|
&story_number,
|
||||||
|
&ctx.project_root,
|
||||||
|
&ctx.agents,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
super::delete::DeleteCommand::BadArgs => {
|
||||||
|
format!("Usage: `{} delete <number>`", ctx.bot_name)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let html = markdown_to_html(&response);
|
||||||
|
if let Ok(resp) = room
|
||||||
|
.send(RoomMessageEventContent::text_html(response, html))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
ctx.bot_sent_event_ids.lock().await.insert(resp.event_id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Spawn a separate task so the Matrix sync loop is not blocked while we
|
// Spawn a separate task so the Matrix sync loop is not blocked while we
|
||||||
// wait for the LLM response (which can take several seconds).
|
// wait for the LLM response (which can take several seconds).
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -1251,6 +1369,9 @@ mod tests {
|
|||||||
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
|
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
permission_timeout_secs: 120,
|
permission_timeout_secs: 120,
|
||||||
bot_name: "Assistant".to_string(),
|
bot_name: "Assistant".to_string(),
|
||||||
|
ambient_rooms: Arc::new(std::sync::Mutex::new(HashSet::new())),
|
||||||
|
agents: Arc::new(AgentPool::new_test(3000)),
|
||||||
|
htop_sessions: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
};
|
};
|
||||||
// Clone must work (required by Matrix SDK event handler injection).
|
// Clone must work (required by Matrix SDK event handler injection).
|
||||||
let _cloned = ctx.clone();
|
let _cloned = ctx.clone();
|
||||||
@@ -1730,4 +1851,5 @@ mod tests {
|
|||||||
assert_eq!(resolve_bot_name(None), "Assistant");
|
assert_eq!(resolve_bot_name(None), "Assistant");
|
||||||
assert_eq!(resolve_bot_name(Some("Timmy".to_string())), "Timmy");
|
assert_eq!(resolve_bot_name(Some("Timmy".to_string())), "Timmy");
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
1477
server/src/matrix/commands.rs
Normal file
1477
server/src/matrix/commands.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,11 @@ pub struct BotConfig {
|
|||||||
/// If unset, the bot falls back to "Assistant".
|
/// If unset, the bot falls back to "Assistant".
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
|
/// Room IDs where ambient mode is active (bot responds to all messages).
|
||||||
|
/// Updated at runtime when the user toggles ambient mode — do not edit
|
||||||
|
/// manually while the bot is running.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ambient_rooms: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BotConfig {
|
impl BotConfig {
|
||||||
@@ -97,6 +102,46 @@ impl BotConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persist the current set of ambient room IDs back to `bot.toml`.
|
||||||
|
///
|
||||||
|
/// Reads the existing file as a TOML document, updates the `ambient_rooms`
|
||||||
|
/// array, and writes the result back. Errors are logged but not propagated
|
||||||
|
/// so a persistence failure never interrupts the bot's message handling.
|
||||||
|
pub fn save_ambient_rooms(project_root: &Path, room_ids: &[String]) {
|
||||||
|
let path = project_root.join(".story_kit").join("bot.toml");
|
||||||
|
let content = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[matrix-bot] save_ambient_rooms: failed to read bot.toml: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut doc: toml::Value = match toml::from_str(&content) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[matrix-bot] save_ambient_rooms: failed to parse bot.toml: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let toml::Value::Table(ref mut t) = doc {
|
||||||
|
let arr = toml::Value::Array(
|
||||||
|
room_ids
|
||||||
|
.iter()
|
||||||
|
.map(|s| toml::Value::String(s.clone()))
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
t.insert("ambient_rooms".to_string(), arr);
|
||||||
|
}
|
||||||
|
match toml::to_string_pretty(&doc) {
|
||||||
|
Ok(new_content) => {
|
||||||
|
if let Err(e) = std::fs::write(&path, new_content) {
|
||||||
|
eprintln!("[matrix-bot] save_ambient_rooms: failed to write bot.toml: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("[matrix-bot] save_ambient_rooms: failed to serialise bot.toml: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -378,4 +423,90 @@ require_verified_devices = true
|
|||||||
"bot.toml with legacy require_verified_devices key must still load"
|
"bot.toml with legacy require_verified_devices key must still load"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_reads_ambient_rooms() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".story_kit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("bot.toml"),
|
||||||
|
r#"
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
username = "@bot:example.com"
|
||||||
|
password = "secret"
|
||||||
|
room_ids = ["!abc:example.com"]
|
||||||
|
enabled = true
|
||||||
|
ambient_rooms = ["!abc:example.com"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let config = BotConfig::load(tmp.path()).unwrap();
|
||||||
|
assert_eq!(config.ambient_rooms, vec!["!abc:example.com"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_ambient_rooms_defaults_to_empty_when_absent() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".story_kit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("bot.toml"),
|
||||||
|
r#"
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
username = "@bot:example.com"
|
||||||
|
password = "secret"
|
||||||
|
room_ids = ["!abc:example.com"]
|
||||||
|
enabled = true
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let config = BotConfig::load(tmp.path()).unwrap();
|
||||||
|
assert!(config.ambient_rooms.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_ambient_rooms_persists_to_bot_toml() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".story_kit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("bot.toml"),
|
||||||
|
r#"homeserver = "https://matrix.example.com"
|
||||||
|
username = "@bot:example.com"
|
||||||
|
password = "secret"
|
||||||
|
room_ids = ["!abc:example.com"]
|
||||||
|
enabled = true
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
save_ambient_rooms(tmp.path(), &["!abc:example.com".to_string()]);
|
||||||
|
|
||||||
|
let config = BotConfig::load(tmp.path()).unwrap();
|
||||||
|
assert_eq!(config.ambient_rooms, vec!["!abc:example.com"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_ambient_rooms_clears_when_empty() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".story_kit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("bot.toml"),
|
||||||
|
r#"homeserver = "https://matrix.example.com"
|
||||||
|
username = "@bot:example.com"
|
||||||
|
password = "secret"
|
||||||
|
room_ids = ["!abc:example.com"]
|
||||||
|
enabled = true
|
||||||
|
ambient_rooms = ["!abc:example.com"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
save_ambient_rooms(tmp.path(), &[]);
|
||||||
|
|
||||||
|
let config = BotConfig::load(tmp.path()).unwrap();
|
||||||
|
assert!(config.ambient_rooms.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user