Compare commits
65 Commits
0416bf343c
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcc2b9c3eb | ||
|
|
0c4239501a | ||
|
|
13b6ecd958 | ||
|
|
1816a94617 | ||
|
|
56d3373e69 | ||
|
|
efdb0c5814 | ||
|
|
b8365275d8 | ||
|
|
6ddfd29927 | ||
|
|
01b157a2e4 | ||
|
|
99a59d7ad1 | ||
|
|
eb8adb6225 | ||
|
|
2262f2ca6b | ||
|
|
2bb36d0e68 | ||
|
|
86102f8ad6 | ||
|
|
edf47601c4 | ||
|
|
b606e1de92 | ||
|
|
0d5f0de876 | ||
|
|
bb41f3951c | ||
|
|
e3d7931f17 | ||
|
|
87b5648123 | ||
|
|
506bdd4df8 | ||
|
|
a9bec3c29e | ||
|
|
69936f457f | ||
|
|
24dd3d9fa9 | ||
|
|
bc45a91b3e | ||
|
|
db7c11508e | ||
|
|
47173e0d3a | ||
|
|
f610ef6046 | ||
|
|
89f776b978 | ||
|
|
e4227cf673 | ||
|
|
f346712dd1 | ||
|
|
f9419e5ea7 | ||
|
|
c32bab03a4 | ||
|
|
ea23042698 | ||
|
|
3825b03fda | ||
|
|
d6cfd18e6a | ||
|
|
01ac8a8345 | ||
|
|
153f8812d7 | ||
|
|
01c7c39872 | ||
|
|
eec8f3ac15 | ||
|
|
28626ab80a | ||
|
|
4262af7faa | ||
|
|
628b60ad15 | ||
|
|
c504738949 | ||
|
|
0d5b9724c1 | ||
|
|
b189ca845c | ||
|
|
8094d32cbb | ||
|
|
1c2824fa31 | ||
|
|
af72f593e8 | ||
|
|
ac8112bf0b | ||
|
|
9bf4b65707 | ||
|
|
240ebf055a | ||
|
|
293a2fcfb6 | ||
|
|
4ccc3d9149 | ||
|
|
eef0f3ee7d | ||
|
|
9dc7c21b05 | ||
|
|
76369de391 | ||
|
|
b747cc0fab | ||
|
|
f74a0425a9 | ||
|
|
b0b21765d9 | ||
|
|
9075bc1a84 | ||
|
|
9f873dc839 | ||
|
|
3774c3dca7 | ||
|
|
cd095f9a99 | ||
|
|
fe0f560b58 |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
# Docker build context exclusions
|
||||
**/target/
|
||||
**/node_modules/
|
||||
frontend/dist/
|
||||
.storkit/worktrees/
|
||||
.storkit/logs/
|
||||
.storkit/work/6_archived/
|
||||
.git/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
4
.ignore
4
.ignore
@@ -3,6 +3,6 @@ frontend/
|
||||
node_modules/
|
||||
.claude/
|
||||
.git/
|
||||
.story_kit/
|
||||
.storkit/
|
||||
store.json
|
||||
.story_kit_port
|
||||
.storkit_port
|
||||
|
||||
@@ -33,7 +33,7 @@ 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."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features 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-2"
|
||||
@@ -43,7 +43,7 @@ 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."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features 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"
|
||||
@@ -53,7 +53,7 @@ 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."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features 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 = "qa-2"
|
||||
@@ -130,7 +130,7 @@ model = "opus"
|
||||
max_turns = 80
|
||||
max_budget_usd = 20.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .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 senior full-stack engineer working autonomously in a git worktree. You handle complex tasks requiring deep architectural understanding. 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 senior full-stack engineer working autonomously in a git worktree. You handle complex tasks requiring deep architectural understanding. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features 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 = "qa"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: "Web UI OAuth flow for Claude authentication"
|
||||
---
|
||||
|
||||
# Story 368: Web UI OAuth flow for Claude authentication
|
||||
|
||||
## User Story
|
||||
|
||||
As a new user running storkit in Docker, I want to authenticate Claude through the web UI instead of running `claude login` in a terminal inside the container, so that the entire setup experience stays in the browser after `docker compose up`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Backend exposes /auth/start endpoint that generates the Claude OAuth URL with redirect_uri pointing to localhost:3001
|
||||
- [ ] Backend exposes /auth/callback endpoint that receives the OAuth token and stores it where Claude Code expects it
|
||||
- [ ] Backend exposes /auth/status endpoint that reports whether valid Claude credentials exist
|
||||
- [ ] Frontend shows a setup screen when no Claude auth is detected on first visit
|
||||
- [ ] Setup screen has a 'Connect Claude Account' button that initiates the OAuth flow
|
||||
- [ ] OAuth redirect returns to the web UI which confirms success and dismisses the setup screen
|
||||
- [ ] Credentials are persisted in the claude-state Docker volume so they survive container restarts
|
||||
- [ ] The entire flow works without any terminal interaction after docker compose up
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: "CLI treats --help and --version as project paths"
|
||||
---
|
||||
|
||||
# Bug 369: CLI treats --help and --version as project paths
|
||||
|
||||
## Description
|
||||
|
||||
When running `storkit <anything>`, the binary treats the first argument as a project path, creates a directory for it, and scaffolds `.storkit/` inside. This happens for `--help`, `--version`, `serve`, `x`, or any other string. There is no validation that the argument is an existing directory or a reasonable path before creating it.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Run `storkit --help` or `storkit serve` or `storkit x` in any directory
|
||||
2. Observe that a directory with that name is created with a full `.storkit/` scaffold inside it
|
||||
|
||||
## Actual Result
|
||||
|
||||
Any argument is treated as a project path and a directory is created and scaffolded. No flags are recognised.
|
||||
|
||||
## Expected Result
|
||||
|
||||
- `storkit --help` prints usage info and exits
|
||||
- `storkit --version` prints the version and exits
|
||||
- `storkit <path>` only works if the path already exists as a directory
|
||||
- If the path does not exist, storkit prints a clear error and exits non-zero
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] storkit --help prints usage information and exits with code 0
|
||||
- [ ] storkit --version prints the version and exits with code 0
|
||||
- [ ] storkit -h and storkit -V work as short aliases
|
||||
- [ ] storkit does not create directories for any argument — the path must already exist
|
||||
- [ ] If the path does not exist, storkit prints a clear error and exits non-zero
|
||||
- [ ] Arguments starting with - that are not recognised produce a clear error message
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: "Scaffold does not create .mcp.json in project root"
|
||||
---
|
||||
|
||||
# Bug 370: Scaffold does not create .mcp.json in project root
|
||||
|
||||
## Description
|
||||
|
||||
Two related problems with project setup:
|
||||
|
||||
1. When the user clicks the "project setup" button in the web UI to open a new project, the scaffold does not reliably run — the `.storkit/` directory and associated files may not be created.
|
||||
2. Even when the scaffold does run, it does not write `.mcp.json` to the project root. Without this file, agents spawned in worktrees cannot find the MCP server, causing `--permission-prompt-tool mcp__storkit__prompt_permission not found` errors and agent failures.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Open the storkit web UI and use the project setup button to open a new project directory
|
||||
2. Check whether the full scaffold was created (`.storkit/`, `CLAUDE.md`, `script/test`, etc.)
|
||||
3. Check the project root for `.mcp.json`
|
||||
|
||||
## Actual Result
|
||||
|
||||
The scaffold may not run when using the UI project setup flow. When it does run, `.mcp.json` is not created in the project root. Agents fail because MCP tools are unavailable.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Clicking the project setup button reliably runs the full scaffold, including `.mcp.json` pointing to the server's port.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] The web UI project setup button triggers the full scaffold for new projects
|
||||
- [ ] scaffold_story_kit writes .mcp.json to the project root with the server's port
|
||||
- [ ] Existing .mcp.json is not overwritten if already present
|
||||
- [ ] .mcp.json is included in .gitignore since the port is environment-specific
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: "Harden Docker setup for security"
|
||||
retry_count: 3
|
||||
blocked: true
|
||||
---
|
||||
|
||||
# Story 359: Harden Docker setup for security
|
||||
|
||||
## User Story
|
||||
|
||||
As a storkit operator, I want the Docker container to run with hardened security settings, so that a compromised agent or malicious codebase cannot escape the container or affect the host.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Container runs as a non-root user
|
||||
- [ ] Root filesystem is read-only with only necessary paths writable (e.g. /tmp, cargo cache, claude state volumes)
|
||||
- [ ] Linux capabilities dropped to minimum required (cap_drop: ALL, add back only what's needed)
|
||||
- [ ] no-new-privileges flag is set
|
||||
- [ ] Resource limits (CPU and memory) are configured in docker-compose.yml
|
||||
- [ ] Outbound network access is restricted where possible
|
||||
- [ ] ANTHROPIC_API_KEY is passed via Docker secrets or .env file, not hardcoded in compose
|
||||
- [ ] Image passes a CVE scan with no critical vulnerabilities
|
||||
- [ ] Port binding uses 127.0.0.1 instead of 0.0.0.0 (e.g. "127.0.0.1:3001:3001") so the web UI is not exposed on all interfaces
|
||||
- [ ] Git identity is configured via explicit GIT_USER_NAME and GIT_USER_EMAIL env vars; container fails loudly on startup if either is missing (note: multi-user/distributed case where different users need different identities is out of scope and will require a different solution)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: "Remove deprecated manual_qa front matter field"
|
||||
---
|
||||
|
||||
# Story 361: Remove deprecated manual_qa front matter field
|
||||
|
||||
## User Story
|
||||
|
||||
As a developer, I want the deprecated manual_qa boolean field removed from the codebase, so that the front matter schema stays clean and doesn't accumulate legacy boolean flags alongside the more expressive qa: server|agent|human field that replaced it.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] manual_qa field is removed from the FrontMatter and StoryMetadata structs in story_metadata.rs
|
||||
- [ ] Legacy mapping from manual_qa: true → qa: human is removed
|
||||
- [ ] Any existing story files using manual_qa are migrated to qa: human
|
||||
- [ ] Codebase compiles cleanly with no references to manual_qa remaining
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: "Bot whatsup command shows in-progress work summary"
|
||||
---
|
||||
|
||||
# Story 362: Bot whatsup command shows in-progress work summary
|
||||
|
||||
## User Story
|
||||
|
||||
As a project owner in a Matrix room, I want to type "{bot_name} whatsup {story_number}" and see a full triage dump for that story, so that when something goes wrong I can immediately understand its state — blocked status, agent activity, git changes, and log tail — without hunting across multiple places or asking the bot to investigate.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] '{bot_name} whatsup {number}' finds the story in work/2_current/ by story number
|
||||
- [ ] Shows the story number, name, and current pipeline stage
|
||||
- [ ] Shows relevant front matter fields: blocked, agent, and any other non-empty fields
|
||||
- [ ] Shows which Acceptance Criteria are checked vs unchecked
|
||||
- [ ] Shows active branch and worktree path if one exists
|
||||
- [ ] Shows git diff --stat of changes on the branch since branching from master
|
||||
- [ ] Shows last 5 commit messages on the feature branch (not master)
|
||||
- [ ] Shows the last 20 lines of the agent log for this story (if a log exists)
|
||||
- [ ] Returns a friendly message if the story is not found or not currently in progress
|
||||
- [ ] Registered in the command registry so it appears in help output
|
||||
- [ ] Handled at bot level without LLM invocation — uses git, filesystem, and log files only
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Interpreting or summarising log output with an LLM
|
||||
- Showing logs from previous agent runs (only the current/most recent)
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: "MCP tool for whatsup story triage"
|
||||
---
|
||||
|
||||
# Story 363: MCP tool for whatsup story triage
|
||||
|
||||
## User Story
|
||||
|
||||
As an LLM assistant, I want to call a single MCP tool to get a full triage dump for an in-progress story, so that I can answer status questions quickly without making 8+ separate calls to piece together the picture myself.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 'whatsup' MCP tool accepts a story_id parameter
|
||||
- [ ] Returns story front matter fields (name, blocked, agent, and any other non-empty fields)
|
||||
- [ ] Returns AC checklist with checked/unchecked status
|
||||
- [ ] Returns active branch and worktree path if one exists
|
||||
- [ ] Returns git diff --stat of changes on the feature branch since branching from master
|
||||
- [ ] Returns last 5 commit messages on the feature branch
|
||||
- [ ] Returns last 20 lines of the most recent agent log for the story
|
||||
- [ ] Returns a clear error if the story is not found or not in work/2_current/
|
||||
- [ ] Registered and discoverable via the MCP tools/list endpoint
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: "Surface API rate limit warnings in chat"
|
||||
---
|
||||
|
||||
# Story 365: Surface API rate limit warnings in chat
|
||||
|
||||
## User Story
|
||||
|
||||
As a project owner watching the chat, I want to see rate limit warnings surfaced directly in the conversation when they appear in the agent's PTY output, so that I know immediately when an agent is being throttled without having to watch server logs.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Server detects rate limit warnings in pty-debug output lines
|
||||
- [x] When a rate limit warning is detected, a notification is sent to the active chat (Matrix/Slack/WhatsApp)
|
||||
- [x] The notification includes which agent/story triggered the rate limit
|
||||
- [x] Rate limit notifications are debounced to avoid spamming the chat with repeated warnings
|
||||
|
||||
## Technical Context
|
||||
|
||||
Claude Code emits `rate_limit_event` JSON in its streaming output:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "rate_limit_event",
|
||||
"rate_limit_info": {
|
||||
"status": "allowed_warning",
|
||||
"resetsAt": 1774443600,
|
||||
"rateLimitType": "seven_day",
|
||||
"utilization": 0.82,
|
||||
"isUsingOverage": false,
|
||||
"surpassedThreshold": 0.75
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key fields:
|
||||
- `status`: `"allowed_warning"` when approaching limit, likely `"blocked"` or similar when hard-limited
|
||||
- `rateLimitType`: e.g. `"seven_day"` rolling window
|
||||
- `utilization`: 0.0–1.0 fraction of limit consumed
|
||||
- `resetsAt`: Unix timestamp when the window resets
|
||||
- `surpassedThreshold`: the threshold that triggered the warning (e.g. 0.75 = 75%)
|
||||
|
||||
These events are already logged as `[pty-debug] raw line:` in the server logs. The PTY reader in `server/src/llm/providers/claude_code.rs` (line ~234) sees them but doesn't currently parse or act on them.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
|
||||
## Test Results
|
||||
|
||||
<!-- storkit-test-results: {"unit":[{"name":"rate_limit_event_json_sends_watcher_warning","status":"pass","details":"PTY reader detects rate_limit_event JSON and emits RateLimitWarning watcher event"},{"name":"rate_limit_warning_sends_notification_with_agent_and_story","status":"pass","details":"Notification listener sends chat message with agent and story info"},{"name":"rate_limit_warning_is_debounced","status":"pass","details":"Second warning within 60s window is suppressed"},{"name":"rate_limit_warnings_for_different_agents_both_notify","status":"pass","details":"Different agents are debounced independently"},{"name":"format_rate_limit_notification_includes_agent_and_story","status":"pass","details":"Notification text includes story number, name, and agent name"},{"name":"format_rate_limit_notification_falls_back_to_item_id","status":"pass","details":"Falls back to item_id when story name is unavailable"}],"integration":[]} -->
|
||||
|
||||
### Unit Tests (6 passed, 0 failed)
|
||||
|
||||
- ✅ rate_limit_event_json_sends_watcher_warning — PTY reader detects rate_limit_event JSON and emits RateLimitWarning watcher event
|
||||
- ✅ rate_limit_warning_sends_notification_with_agent_and_story — Notification listener sends chat message with agent and story info
|
||||
- ✅ rate_limit_warning_is_debounced — Second warning within 60s window is suppressed
|
||||
- ✅ rate_limit_warnings_for_different_agents_both_notify — Different agents are debounced independently
|
||||
- ✅ format_rate_limit_notification_includes_agent_and_story — Notification text includes story number, name, and agent name
|
||||
- ✅ format_rate_limit_notification_falls_back_to_item_id — Falls back to item_id when story name is unavailable
|
||||
|
||||
### Integration Tests (0 passed, 0 failed)
|
||||
|
||||
*No integration tests recorded.*
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: "Bot sends shutdown message on server stop or rebuild"
|
||||
---
|
||||
|
||||
# Story 366: Bot sends shutdown message on server stop or rebuild
|
||||
|
||||
## User Story
|
||||
|
||||
As a project owner in a chat room, I want the bot to send a message when the server is shutting down (via ctrl-c or rebuild_and_restart), so that I know the bot is going offline and won't wonder why it stopped responding.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bot sends a shutdown message to active chat channels when the server receives SIGINT/SIGTERM (ctrl-c)
|
||||
- [ ] Bot sends a shutdown message before rebuild_and_restart kills the current process
|
||||
- [ ] Message indicates the reason (manual stop vs rebuild)
|
||||
- [ ] Message is sent best-effort — shutdown is not blocked if the message fails to send
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: "Rename bot whatsup command to status"
|
||||
---
|
||||
|
||||
# Story 367: Rename bot whatsup command to status
|
||||
|
||||
## User Story
|
||||
|
||||
As a project owner using the bot from a phone, I want to type "status {number}" instead of "whatsup {number}" to get a story triage dump, because "whatsup" gets autocorrected to "WhatsApp" on mobile keyboards.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] '{bot_name} status {number}' returns the same triage dump that 'whatsup' currently returns
|
||||
- [ ] The 'whatsup' command is removed or aliased to 'status'
|
||||
- [ ] Help output shows 'status' as the command name
|
||||
- [ ] The MCP tool name (whatsup) is unaffected — this only changes the bot command
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
42
Cargo.lock
generated
42
Cargo.lock
generated
@@ -1774,9 +1774,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "iri-string"
|
||||
version = "0.7.10"
|
||||
version = "0.7.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
|
||||
checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
@@ -1815,7 +1815,7 @@ dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
@@ -1824,9 +1824,31 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
|
||||
dependencies = [
|
||||
"jni-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
|
||||
dependencies = [
|
||||
"jni-sys-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys-macros"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
@@ -2948,9 +2970,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.13.1"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83c41efbf8f90ac44de7f3a868f0867851d261b56291732d0cbf7cceaaeb55a6"
|
||||
checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"memchr",
|
||||
@@ -3625,9 +3647,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
version = "0.103.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -3994,7 +4016,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "storkit"
|
||||
version = "0.4.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# Briefing for Timmy — Spike 329
|
||||
|
||||
Hey Timmy. You're running inside a Docker container as part of spike 329. Here's everything
|
||||
you need to know to pick up where we left off.
|
||||
|
||||
## What this spike is
|
||||
|
||||
Evaluate running the full storkit stack (server, agents, web UI) inside a single Docker
|
||||
container, using OrbStack on macOS for better bind-mount performance. The goal is host
|
||||
isolation — not agent-to-agent isolation. Read the full spike doc at:
|
||||
|
||||
`.storkit/work/1_backlog/329_spike_evaluate_docker_orbstack_for_agent_isolation_and_resource_limiting.md`
|
||||
|
||||
## What's been done (2026-03-21)
|
||||
|
||||
### Environment confirmed
|
||||
- Debian 12 bookworm, arm64, 10 CPUs
|
||||
- Rust 1.90.0, Node v22.22.1, git 2.39.5, Claude Code CLI — all present
|
||||
- Running under **OrbStack** (confirmed via bind-mount path `/run/host_mark/Users → /workspace`)
|
||||
|
||||
### Key benchmarks run
|
||||
Bind-mount directory traversal is **~23x slower per file** than a Docker volume:
|
||||
|
||||
| Filesystem | Files | Time |
|
||||
|---|---|---|
|
||||
| Docker volume (`cargo/registry`) | 21,703 | 38ms |
|
||||
| Bind mount `target/` subtree | 270,550 | 10,564ms |
|
||||
| Bind mount non-target | 50,048 | 11,314ms |
|
||||
|
||||
Sequential I/O is fine (440 MB/s write, 1.3 GB/s read on bind mount). The problem is
|
||||
purely stat-heavy operations — exactly what cargo does on incremental builds.
|
||||
|
||||
### Two bugs found and fixed
|
||||
|
||||
**Bug 1 — `target/` on bind mount** (`docker/docker-compose.yml`)
|
||||
Added named Docker volumes to keep build artifacts off the slow bind mount:
|
||||
```yaml
|
||||
- workspace-target:/workspace/target
|
||||
- storkit-target:/app/target
|
||||
```
|
||||
|
||||
**Bug 2 — missing `build-essential` in runtime stage** (`docker/Dockerfile`)
|
||||
The runtime stage copies the Rust toolchain but not `gcc`/`cc`. `cargo build` fails with
|
||||
`linker 'cc' not found`. Fixed by adding `build-essential`, `pkg-config`, `libssl-dev`
|
||||
to the runtime apt-get block.
|
||||
|
||||
### `./..:/app` bind mount
|
||||
The original commit had this commented out. Another bot uncommented it — this is correct.
|
||||
It lets `rebuild_and_restart` pick up live host changes. The `storkit-target:/app/target`
|
||||
volume keeps `/app/target` off the bind mount.
|
||||
|
||||
## What still needs doing
|
||||
|
||||
1. **Rebuild the image** with the patched Dockerfile and run a full `cargo build --release`
|
||||
benchmark end-to-end. This couldn't be done in the first session because the container
|
||||
was already running the old (pre-fix) image.
|
||||
|
||||
2. **Docker Desktop vs OrbStack comparison** — repeat the benchmarks with Docker Desktop
|
||||
to quantify the performance delta. We expect OrbStack to be significantly faster due to
|
||||
VirtioFS vs gRPC-FUSE, but need actual numbers.
|
||||
|
||||
## Worktree git note
|
||||
|
||||
The worktree git refs are broken inside the container — they reference the host path
|
||||
(`/Users/dave/workspace/...`) which doesn't exist in the container. Use
|
||||
`git -C /workspace <command>` instead of running git from the worktree dir.
|
||||
|
||||
## Files changed so far (uncommitted)
|
||||
|
||||
- `docker/Dockerfile` — added `build-essential`, `pkg-config`, `libssl-dev` to runtime stage
|
||||
- `docker/docker-compose.yml` — added `workspace-target` and `storkit-target` volumes
|
||||
- `.storkit/work/1_backlog/329_spike_...md` — findings written up in full
|
||||
|
||||
These changes are **not yet committed**. Commit them before rebuilding the container.
|
||||
@@ -1,8 +1,9 @@
|
||||
# Docker build context exclusions
|
||||
target/
|
||||
frontend/node_modules/
|
||||
**/target/
|
||||
**/node_modules/
|
||||
frontend/dist/
|
||||
.storkit/worktrees/
|
||||
.storkit/logs/
|
||||
.storkit/work/6_archived/
|
||||
.git/
|
||||
*.swp
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
|
||||
FROM rust:1.90-bookworm AS base
|
||||
|
||||
# Clippy is needed at runtime for acceptance gates (cargo clippy)
|
||||
RUN rustup component add clippy
|
||||
|
||||
# ── System deps ──────────────────────────────────────────────────────
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
@@ -33,10 +36,6 @@ RUN curl -LsSf https://get.nexte.st/latest/linux | tar zxf - -C /usr/local/bin
|
||||
# The CLI binary is `claude`.
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# ── Biome (frontend linter) ─────────────────────────────────────────
|
||||
# Installed project-locally via npm install, but having it global avoids
|
||||
# needing node_modules for CI-style checks.
|
||||
|
||||
# ── Working directory ────────────────────────────────────────────────
|
||||
# /app holds the storkit source (copied in at build time for the binary).
|
||||
# /workspace is where the target project repo gets bind-mounted at runtime.
|
||||
@@ -68,6 +67,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
# procps provides ps, needed by tests and process management
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node.js in runtime
|
||||
@@ -98,6 +99,24 @@ COPY --from=base /usr/local/bin/storkit /usr/local/bin/storkit
|
||||
# Alternative: mount the source as a volume.
|
||||
COPY --from=base /app /app
|
||||
|
||||
# ── Non-root user ────────────────────────────────────────────────────
|
||||
# Claude Code refuses --dangerously-skip-permissions (bypassPermissions)
|
||||
# when running as root. Create a dedicated user so agents can launch.
|
||||
RUN groupadd -r storkit \
|
||||
&& useradd -r -g storkit -m -d /home/storkit storkit \
|
||||
&& mkdir -p /home/storkit/.claude \
|
||||
&& chown -R storkit:storkit /home/storkit \
|
||||
&& chown -R storkit:storkit /usr/local/cargo /usr/local/rustup \
|
||||
&& chown -R storkit:storkit /app \
|
||||
&& mkdir -p /workspace/target /app/target \
|
||||
&& chown storkit:storkit /workspace/target /app/target
|
||||
|
||||
# ── Entrypoint ───────────────────────────────────────────────────────
|
||||
# Validates required env vars (GIT_USER_NAME, GIT_USER_EMAIL) and
|
||||
# configures git identity before starting the server.
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
||||
USER storkit
|
||||
WORKDIR /workspace
|
||||
|
||||
# ── Ports ────────────────────────────────────────────────────────────
|
||||
@@ -105,11 +124,9 @@ WORKDIR /workspace
|
||||
EXPOSE 3001
|
||||
|
||||
# ── Volumes (defined in docker-compose.yml) ──────────────────────────
|
||||
# /workspace – bind mount: target project repo
|
||||
# /root/.claude – named volume: Claude Code sessions/state
|
||||
# /usr/local/cargo/registry – named volume: cargo dependency cache
|
||||
# /workspace – bind mount: target project repo
|
||||
# /home/storkit/.claude – named volume: Claude Code sessions/state
|
||||
# /usr/local/cargo/registry – named volume: cargo dependency cache
|
||||
|
||||
# ── Entrypoint ───────────────────────────────────────────────────────
|
||||
# Run storkit against the bind-mounted project at /workspace.
|
||||
# The server picks up ANTHROPIC_API_KEY from the environment.
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
CMD ["storkit", "/workspace"]
|
||||
|
||||
@@ -16,11 +16,15 @@ services:
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: storkit
|
||||
ports:
|
||||
# Web UI + MCP endpoint
|
||||
- "3001:3001"
|
||||
# Bind to localhost only — not exposed on all interfaces.
|
||||
- "127.0.0.1:3001:3001"
|
||||
environment:
|
||||
# Required: Anthropic API key for Claude Code agents
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:?Set ANTHROPIC_API_KEY}
|
||||
# Optional: Anthropic API key. If unset, Claude Code falls back to
|
||||
# OAuth credentials from `claude login` (e.g. Max subscription).
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
# Required: git identity for agent commits
|
||||
- GIT_USER_NAME=${GIT_USER_NAME:?Set GIT_USER_NAME}
|
||||
- GIT_USER_EMAIL=${GIT_USER_EMAIL:?Set GIT_USER_EMAIL}
|
||||
# Optional: override the server port (default 3001)
|
||||
- STORKIT_PORT=3001
|
||||
# Optional: Matrix bot credentials (if using Matrix integration)
|
||||
@@ -45,7 +49,7 @@ services:
|
||||
|
||||
# Claude Code state – persists session history, projects config,
|
||||
# and conversation transcripts so --resume works across restarts.
|
||||
- claude-state:/root/.claude
|
||||
- claude-state:/home/storkit/.claude
|
||||
|
||||
# Storkit source tree for rebuild_and_restart.
|
||||
# The binary has CARGO_MANIFEST_DIR baked in at compile time
|
||||
@@ -63,16 +67,37 @@ services:
|
||||
- workspace-target:/workspace/target
|
||||
- storkit-target:/app/target
|
||||
|
||||
# ── Security hardening ──────────────────────────────────────────
|
||||
# Read-only root filesystem. Only explicitly mounted volumes and
|
||||
# tmpfs paths are writable.
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=512M,exec
|
||||
- /home/storkit:size=512M,uid=999,gid=999,exec
|
||||
|
||||
# Drop all Linux capabilities, then add back only what's needed.
|
||||
# SETUID/SETGID needed by Claude Code's PTY allocation (openpty).
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- SETUID
|
||||
- SETGID
|
||||
|
||||
# Prevent child processes from gaining new privileges via setuid,
|
||||
# setgid, or other mechanisms.
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
# Resource limits – cap the whole system.
|
||||
# Adjust based on your machine. These are conservative defaults.
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "4"
|
||||
memory: 8G
|
||||
cpus: "8"
|
||||
memory: 24G
|
||||
reservations:
|
||||
cpus: "1"
|
||||
memory: 2G
|
||||
cpus: "2"
|
||||
memory: 4G
|
||||
|
||||
# Health check – verify the MCP endpoint responds
|
||||
healthcheck:
|
||||
|
||||
34
docker/entrypoint.sh
Executable file
34
docker/entrypoint.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# ── Git identity ─────────────────────────────────────────────────────
|
||||
# Agents commit code inside the container. Without a git identity,
|
||||
# commits fail or use garbage defaults. Fail loudly at startup so the
|
||||
# operator knows immediately.
|
||||
if [ -z "$GIT_USER_NAME" ]; then
|
||||
echo "FATAL: GIT_USER_NAME is not set. Export it in your environment or docker-compose.yml." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$GIT_USER_EMAIL" ]; then
|
||||
echo "FATAL: GIT_USER_EMAIL is not set. Export it in your environment or docker-compose.yml." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use GIT_AUTHOR/COMMITTER env vars instead of git config --global,
|
||||
# so the root filesystem can stay read-only (no ~/.gitconfig write).
|
||||
export GIT_AUTHOR_NAME="$GIT_USER_NAME"
|
||||
export GIT_COMMITTER_NAME="$GIT_USER_NAME"
|
||||
export GIT_AUTHOR_EMAIL="$GIT_USER_EMAIL"
|
||||
export GIT_COMMITTER_EMAIL="$GIT_USER_EMAIL"
|
||||
|
||||
# ── Frontend native deps ────────────────────────────────────────────
|
||||
# The project repo is bind-mounted from the host, so node_modules/
|
||||
# may contain native binaries for the wrong platform (e.g. darwin
|
||||
# binaries on a Linux container). Reinstall to get the right ones.
|
||||
if [ -d /workspace/frontend ] && [ -f /workspace/frontend/package.json ]; then
|
||||
echo "Installing frontend dependencies for container platform..."
|
||||
cd /workspace/frontend && npm install --prefer-offline 2>/dev/null || true
|
||||
cd /workspace
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "living-spec-standalone",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "living-spec-standalone",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "living-spec-standalone",
|
||||
"private": true,
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "storkit",
|
||||
"name": "workspace",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
|
||||
@@ -147,9 +147,65 @@ else
|
||||
| sed 's/^/- /')
|
||||
fi
|
||||
|
||||
# ── Generate summary overview ─────────────────────────────────
|
||||
# Group completed items by keyword clusters to identify the
|
||||
# release's focus areas.
|
||||
generate_summary() {
|
||||
local all_items="$1"
|
||||
local themes=""
|
||||
|
||||
# Count items matching each theme keyword (one item per line via echo -e)
|
||||
local expanded
|
||||
expanded=$(echo -e "$all_items")
|
||||
local bot_count=$(echo "$expanded" | grep -icE 'bot|command|chat|matrix|slack|whatsapp|status|help|assign|rebuild|shutdown|whatsup' || true)
|
||||
local mcp_count=$(echo "$expanded" | grep -icE 'mcp|tool' || true)
|
||||
local docker_count=$(echo "$expanded" | grep -icE 'docker|container|gvisor|orbstack|harden|security' || true)
|
||||
local agent_count=$(echo "$expanded" | grep -icE 'agent|runtime|chatgpt|gemini|openai|model|coder' || true)
|
||||
local ui_count=$(echo "$expanded" | grep -icE 'frontend|ui|web|oauth|scaffold' || true)
|
||||
local infra_count=$(echo "$expanded" | grep -icE 'release|makefile|refactor|upgrade|worktree|pipeline' || true)
|
||||
|
||||
# Build theme list, highest count first
|
||||
local -a theme_pairs=()
|
||||
[ "$agent_count" -gt 0 ] && theme_pairs+=("${agent_count}:multi-model agents")
|
||||
[ "$bot_count" -gt 0 ] && theme_pairs+=("${bot_count}:bot commands")
|
||||
[ "$mcp_count" -gt 0 ] && theme_pairs+=("${mcp_count}:MCP tools")
|
||||
[ "$docker_count" -gt 0 ] && theme_pairs+=("${docker_count}:Docker hardening")
|
||||
[ "$ui_count" -gt 0 ] && theme_pairs+=("${ui_count}:developer experience")
|
||||
[ "$infra_count" -gt 0 ] && theme_pairs+=("${infra_count}:infrastructure")
|
||||
|
||||
# Sort by count descending, take top 3
|
||||
local sorted=$(printf '%s\n' "${theme_pairs[@]}" | sort -t: -k1 -nr | head -3)
|
||||
local labels=""
|
||||
while IFS=: read -r count label; do
|
||||
[ -z "$label" ] && continue
|
||||
if [ -z "$labels" ]; then
|
||||
# Capitalise first theme
|
||||
labels="$(echo "${label:0:1}" | tr '[:lower:]' '[:upper:]')${label:1}"
|
||||
else
|
||||
labels="${labels}, ${label}"
|
||||
fi
|
||||
done <<< "$sorted"
|
||||
|
||||
echo "$labels"
|
||||
}
|
||||
|
||||
ALL_ITEMS="${FEATURES}${FIXES}${REFACTORS}"
|
||||
SUMMARY=$(generate_summary "$ALL_ITEMS")
|
||||
if [ -n "$SUMMARY" ]; then
|
||||
SUMMARY_LINE="**Focus:** ${SUMMARY}"
|
||||
else
|
||||
SUMMARY_LINE=""
|
||||
fi
|
||||
|
||||
# Assemble the release body.
|
||||
RELEASE_BODY="## What's Changed"
|
||||
|
||||
if [ -n "$SUMMARY_LINE" ]; then
|
||||
RELEASE_BODY="${RELEASE_BODY}
|
||||
|
||||
${SUMMARY_LINE}"
|
||||
fi
|
||||
|
||||
if [ -n "$FEATURES" ]; then
|
||||
RELEASE_BODY="${RELEASE_BODY}
|
||||
|
||||
|
||||
1
serve
1
serve
Submodule serve deleted from 1ec5c08ae7
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "storkit"
|
||||
version = "0.4.1"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@@ -254,9 +254,8 @@ mod tests {
|
||||
fn run_project_tests_uses_script_test_when_present_and_passes() {
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path();
|
||||
let script_dir = path.join("script");
|
||||
fs::create_dir_all(&script_dir).unwrap();
|
||||
@@ -276,9 +275,8 @@ mod tests {
|
||||
fn run_project_tests_reports_failure_when_script_test_exits_nonzero() {
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path();
|
||||
let script_dir = path.join("script");
|
||||
fs::create_dir_all(&script_dir).unwrap();
|
||||
@@ -313,9 +311,8 @@ mod tests {
|
||||
fn coverage_gate_passes_when_script_exits_zero() {
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path();
|
||||
let script_dir = path.join("script");
|
||||
fs::create_dir_all(&script_dir).unwrap();
|
||||
@@ -342,9 +339,8 @@ mod tests {
|
||||
fn coverage_gate_fails_when_script_exits_nonzero() {
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path();
|
||||
let script_dir = path.join("script");
|
||||
fs::create_dir_all(&script_dir).unwrap();
|
||||
|
||||
@@ -144,6 +144,10 @@ impl AgentPool {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// Create a pool with a dummy watcher channel for unit tests.
|
||||
#[cfg(test)]
|
||||
pub fn new_test(port: u16) -> Self {
|
||||
@@ -522,7 +526,7 @@ impl AgentPool {
|
||||
|
||||
let run_result = match runtime_name {
|
||||
"claude-code" => {
|
||||
let runtime = ClaudeCodeRuntime::new(child_killers_clone.clone());
|
||||
let runtime = ClaudeCodeRuntime::new(child_killers_clone.clone(), watcher_tx_clone.clone());
|
||||
let ctx = RuntimeContext {
|
||||
story_id: sid.clone(),
|
||||
agent_name: aname.clone(),
|
||||
@@ -1101,6 +1105,7 @@ mod tests {
|
||||
use crate::agents::{AgentEvent, AgentStatus, PipelineStage};
|
||||
use crate::config::ProjectConfig;
|
||||
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
||||
use std::process::Command;
|
||||
|
||||
fn make_config(toml_str: &str) -> ProjectConfig {
|
||||
ProjectConfig::parse(toml_str).unwrap()
|
||||
@@ -1187,13 +1192,10 @@ mod tests {
|
||||
|
||||
/// Returns true if a process with the given PID is currently running.
|
||||
fn process_is_running(pid: u32) -> bool {
|
||||
std::process::Command::new("ps")
|
||||
.arg("-p")
|
||||
.arg(pid.to_string())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
Command::new("ps")
|
||||
.args(["-p", &pid.to_string()])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use tokio::sync::broadcast;
|
||||
|
||||
use super::{AgentEvent, TokenUsage};
|
||||
use crate::agent_log::AgentLogWriter;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use crate::slog;
|
||||
use crate::slog_warn;
|
||||
|
||||
@@ -47,6 +48,7 @@ pub(in crate::agents) async fn run_agent_pty_streaming(
|
||||
log_writer: Option<Arc<Mutex<AgentLogWriter>>>,
|
||||
inactivity_timeout_secs: u64,
|
||||
child_killers: Arc<Mutex<HashMap<String, Box<dyn ChildKiller + Send + Sync>>>>,
|
||||
watcher_tx: broadcast::Sender<WatcherEvent>,
|
||||
) -> Result<PtyResult, String> {
|
||||
let sid = story_id.to_string();
|
||||
let aname = agent_name.to_string();
|
||||
@@ -70,6 +72,7 @@ pub(in crate::agents) async fn run_agent_pty_streaming(
|
||||
log_writer.as_deref(),
|
||||
inactivity_timeout_secs,
|
||||
&child_killers,
|
||||
&watcher_tx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
@@ -162,6 +165,7 @@ fn run_agent_pty_blocking(
|
||||
log_writer: Option<&Mutex<AgentLogWriter>>,
|
||||
inactivity_timeout_secs: u64,
|
||||
child_killers: &Arc<Mutex<HashMap<String, Box<dyn ChildKiller + Send + Sync>>>>,
|
||||
watcher_tx: &broadcast::Sender<WatcherEvent>,
|
||||
) -> Result<PtyResult, String> {
|
||||
let pty_system = native_pty_system();
|
||||
|
||||
@@ -342,6 +346,15 @@ fn run_agent_pty_blocking(
|
||||
// because thinking and text already arrived via stream_event.
|
||||
// The raw JSON is still forwarded as AgentJson below.
|
||||
"assistant" | "user" => {}
|
||||
"rate_limit_event" => {
|
||||
slog!(
|
||||
"[agent:{story_id}:{agent_name}] API rate limit warning received"
|
||||
);
|
||||
let _ = watcher_tx.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: story_id.to_string(),
|
||||
agent_name: agent_name.to_string(),
|
||||
});
|
||||
}
|
||||
"result" => {
|
||||
// Extract token usage from the result event.
|
||||
if let Some(usage) = TokenUsage::from_result_event(&json) {
|
||||
@@ -390,6 +403,70 @@ fn run_agent_pty_blocking(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::AgentEvent;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
// ── AC1: pty detects rate_limit_event and emits RateLimitWarning ─────────
|
||||
|
||||
/// Verify that when a `rate_limit_event` JSON line appears in PTY output,
|
||||
/// `run_agent_pty_streaming` sends a `WatcherEvent::RateLimitWarning` with
|
||||
/// the correct story_id and agent_name.
|
||||
///
|
||||
/// The command invoked is: `sh -p -- <script>` where `--` terminates
|
||||
/// option parsing so the script path is treated as the operand.
|
||||
#[tokio::test]
|
||||
async fn rate_limit_event_json_sends_watcher_warning() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let script = tmp.path().join("emit_rate_limit.sh");
|
||||
std::fs::write(
|
||||
&script,
|
||||
"#!/bin/sh\nprintf '%s\\n' '{\"type\":\"rate_limit_event\",\"rate_limit_info\":{\"status\":\"allowed_warning\"}}'\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let (tx, _rx) = broadcast::channel::<AgentEvent>(64);
|
||||
let (watcher_tx, mut watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||
let event_log = Arc::new(Mutex::new(Vec::new()));
|
||||
let child_killers = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
// sh -p "--" <script>: -p = privileged mode, "--" = end options,
|
||||
// then the script path is the file operand.
|
||||
let result = run_agent_pty_streaming(
|
||||
"365_story_test",
|
||||
"coder-1",
|
||||
"sh",
|
||||
&[script.to_string_lossy().to_string()],
|
||||
"--",
|
||||
"/tmp",
|
||||
&tx,
|
||||
&event_log,
|
||||
None,
|
||||
0,
|
||||
child_killers,
|
||||
watcher_tx,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "PTY run should succeed: {:?}", result.err());
|
||||
|
||||
let evt = watcher_rx
|
||||
.try_recv()
|
||||
.expect("Expected a RateLimitWarning to be sent on watcher_tx");
|
||||
match evt {
|
||||
WatcherEvent::RateLimitWarning {
|
||||
story_id,
|
||||
agent_name,
|
||||
} => {
|
||||
assert_eq!(story_id, "365_story_test");
|
||||
assert_eq!(agent_name, "coder-1");
|
||||
}
|
||||
other => panic!("Expected RateLimitWarning, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emit_event_writes_to_log_writer() {
|
||||
|
||||
@@ -5,6 +5,7 @@ use portable_pty::ChildKiller;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::agent_log::AgentLogWriter;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
|
||||
use super::{AgentEvent, AgentRuntime, RuntimeContext, RuntimeResult, RuntimeStatus};
|
||||
|
||||
@@ -15,13 +16,18 @@ use super::{AgentEvent, AgentRuntime, RuntimeContext, RuntimeResult, RuntimeStat
|
||||
/// token tracking, and inactivity timeout behaviour.
|
||||
pub struct ClaudeCodeRuntime {
|
||||
child_killers: Arc<Mutex<HashMap<String, Box<dyn ChildKiller + Send + Sync>>>>,
|
||||
watcher_tx: broadcast::Sender<WatcherEvent>,
|
||||
}
|
||||
|
||||
impl ClaudeCodeRuntime {
|
||||
pub fn new(
|
||||
child_killers: Arc<Mutex<HashMap<String, Box<dyn ChildKiller + Send + Sync>>>>,
|
||||
watcher_tx: broadcast::Sender<WatcherEvent>,
|
||||
) -> Self {
|
||||
Self { child_killers }
|
||||
Self {
|
||||
child_killers,
|
||||
watcher_tx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +51,7 @@ impl AgentRuntime for ClaudeCodeRuntime {
|
||||
log_writer,
|
||||
ctx.inactivity_timeout_secs,
|
||||
Arc::clone(&self.child_killers),
|
||||
self.watcher_tx.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -144,16 +144,20 @@ mod tests {
|
||||
#[test]
|
||||
fn claude_code_runtime_get_status_returns_idle() {
|
||||
use std::collections::HashMap;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
let killers = Arc::new(Mutex::new(HashMap::new()));
|
||||
let runtime = ClaudeCodeRuntime::new(killers);
|
||||
let (watcher_tx, _) = broadcast::channel::<WatcherEvent>(16);
|
||||
let runtime = ClaudeCodeRuntime::new(killers, watcher_tx);
|
||||
assert_eq!(runtime.get_status(), RuntimeStatus::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_code_runtime_stream_events_empty() {
|
||||
use std::collections::HashMap;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
let killers = Arc::new(Mutex::new(HashMap::new()));
|
||||
let runtime = ClaudeCodeRuntime::new(killers);
|
||||
let (watcher_tx, _) = broadcast::channel::<WatcherEvent>(16);
|
||||
let runtime = ClaudeCodeRuntime::new(killers, watcher_tx);
|
||||
assert!(runtime.stream_events().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::agents::{AgentPool, ReconciliationEvent};
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use crate::rebuild::{BotShutdownNotifier, ShutdownReason};
|
||||
use crate::state::SessionState;
|
||||
use crate::store::JsonFileStore;
|
||||
use crate::workflow::WorkflowState;
|
||||
@@ -52,6 +53,20 @@ pub struct AppContext {
|
||||
/// 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>>>,
|
||||
/// Best-effort shutdown notifier for active bot channels (Slack / WhatsApp).
|
||||
///
|
||||
/// When set, the MCP `rebuild_and_restart` tool uses this to announce the
|
||||
/// shutdown to configured channels before re-execing the server binary.
|
||||
/// `None` when no webhook-based bot transport is configured.
|
||||
pub bot_shutdown: Option<Arc<BotShutdownNotifier>>,
|
||||
/// Watch sender used to signal the Matrix bot task that the server is
|
||||
/// shutting down (rebuild path). The bot task listens for this signal and
|
||||
/// sends a shutdown announcement to all configured rooms.
|
||||
///
|
||||
/// Wrapped in `Arc` so `AppContext` can implement `Clone`.
|
||||
/// `None` when no Matrix bot is configured.
|
||||
pub matrix_shutdown_tx:
|
||||
Option<Arc<tokio::sync::watch::Sender<Option<ShutdownReason>>>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -73,6 +88,8 @@ impl AppContext {
|
||||
perm_tx,
|
||||
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
|
||||
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
|
||||
bot_shutdown: None,
|
||||
matrix_shutdown_tx: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,18 @@ pub(super) fn tool_get_server_logs(args: &Value) -> Result<String, String> {
|
||||
/// Rebuild the server binary and re-exec (delegates to `crate::rebuild`).
|
||||
pub(super) async fn tool_rebuild_and_restart(ctx: &AppContext) -> Result<String, String> {
|
||||
slog!("[rebuild] Rebuild and restart requested via MCP tool");
|
||||
|
||||
// Signal the Matrix bot (if active) so it can send its own shutdown
|
||||
// announcement before the process is replaced. Best-effort: we wait up
|
||||
// to 1.5 s for the message to be delivered.
|
||||
if let Some(ref tx) = ctx.matrix_shutdown_tx {
|
||||
let _ = tx.send(Some(crate::rebuild::ShutdownReason::Rebuild));
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
}
|
||||
|
||||
let project_root = ctx.state.get_project_root().unwrap_or_default();
|
||||
crate::rebuild::rebuild_and_restart(&ctx.agents, &project_root).await
|
||||
let notifier = ctx.bot_shutdown.as_deref();
|
||||
crate::rebuild::rebuild_and_restart(&ctx.agents, &project_root, notifier).await
|
||||
}
|
||||
|
||||
/// Generate a Claude Code permission rule string for the given tool name and input.
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod merge_tools;
|
||||
pub mod qa_tools;
|
||||
pub mod shell_tools;
|
||||
pub mod story_tools;
|
||||
pub mod whatsup_tools;
|
||||
|
||||
/// Returns true when the Accept header includes text/event-stream.
|
||||
fn wants_sse(req: &Request) -> bool {
|
||||
@@ -1121,6 +1122,20 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
},
|
||||
"required": ["worktree_path"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "whatsup",
|
||||
"description": "Get a full triage dump for an in-progress story: front matter, AC checklist, active worktree/branch, git diff --stat since master, last 5 commits, and last 20 lines of the most recent agent log. Returns a clear error if the story is not in work/2_current/.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"story_id": {
|
||||
"type": "string",
|
||||
"description": "Story identifier (filename stem, e.g. '42_story_my_feature')"
|
||||
}
|
||||
},
|
||||
"required": ["story_id"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}),
|
||||
@@ -1209,6 +1224,8 @@ async fn handle_tools_call(
|
||||
"git_add" => git_tools::tool_git_add(&args, ctx).await,
|
||||
"git_commit" => git_tools::tool_git_commit(&args, ctx).await,
|
||||
"git_log" => git_tools::tool_git_log(&args, ctx).await,
|
||||
// Story triage
|
||||
"whatsup" => whatsup_tools::tool_whatsup(&args, ctx).await,
|
||||
_ => Err(format!("Unknown tool: {tool_name}")),
|
||||
};
|
||||
|
||||
@@ -1324,7 +1341,8 @@ mod tests {
|
||||
assert!(names.contains(&"git_add"));
|
||||
assert!(names.contains(&"git_commit"));
|
||||
assert!(names.contains(&"git_log"));
|
||||
assert_eq!(tools.len(), 48);
|
||||
assert!(names.contains(&"whatsup"));
|
||||
assert_eq!(tools.len(), 49);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
364
server/src/http/mcp/whatsup_tools.rs
Normal file
364
server/src/http/mcp/whatsup_tools.rs
Normal file
@@ -0,0 +1,364 @@
|
||||
use crate::http::context::AppContext;
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Parse all AC items from a story file, returning (text, is_checked) pairs.
|
||||
fn parse_ac_items(contents: &str) -> Vec<(String, bool)> {
|
||||
let mut in_ac_section = false;
|
||||
let mut items = Vec::new();
|
||||
|
||||
for line in contents.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == "## Acceptance Criteria" {
|
||||
in_ac_section = true;
|
||||
continue;
|
||||
}
|
||||
// Stop at the next heading
|
||||
if in_ac_section && trimmed.starts_with("## ") {
|
||||
break;
|
||||
}
|
||||
if in_ac_section {
|
||||
if let Some(rest) = trimmed.strip_prefix("- [x] ").or(trimmed.strip_prefix("- [X] ")) {
|
||||
items.push((rest.to_string(), true));
|
||||
} else if let Some(rest) = trimmed.strip_prefix("- [ ] ") {
|
||||
items.push((rest.to_string(), false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
/// Find the most recent log file for any agent under `.storkit/logs/{story_id}/`.
|
||||
fn find_most_recent_log(project_root: &Path, story_id: &str) -> Option<PathBuf> {
|
||||
let dir = project_root
|
||||
.join(".storkit")
|
||||
.join("logs")
|
||||
.join(story_id);
|
||||
|
||||
if !dir.is_dir() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
|
||||
|
||||
let entries = fs::read_dir(&dir).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
if !name.ends_with(".log") {
|
||||
continue;
|
||||
}
|
||||
let modified = match entry.metadata().and_then(|m| m.modified()) {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if best.as_ref().is_none_or(|(_, t)| modified > *t) {
|
||||
best = Some((path, modified));
|
||||
}
|
||||
}
|
||||
|
||||
best.map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Return the last N raw lines from a file.
|
||||
fn last_n_lines(path: &Path, n: usize) -> Result<Vec<String>, String> {
|
||||
let content =
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read log file: {e}"))?;
|
||||
let lines: Vec<String> = content
|
||||
.lines()
|
||||
.rev()
|
||||
.take(n)
|
||||
.map(|l| l.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
/// Run `git diff --stat {base}...HEAD` in the worktree.
|
||||
async fn git_diff_stat(worktree: &Path, base: &str) -> Option<String> {
|
||||
let dir = worktree.to_path_buf();
|
||||
let base_arg = format!("{base}...HEAD");
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["diff", "--stat", &base_arg])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.ok()?;
|
||||
if output.status.success() {
|
||||
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Return the last N commit messages on the current branch relative to base.
|
||||
async fn git_log_commits(worktree: &Path, base: &str, count: usize) -> Option<Vec<String>> {
|
||||
let dir = worktree.to_path_buf();
|
||||
let range = format!("{base}..HEAD");
|
||||
let count_str = count.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["log", &range, "--oneline", &format!("-{count_str}")])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.ok()?;
|
||||
if output.status.success() {
|
||||
let lines: Vec<String> = String::from_utf8(output.stdout)
|
||||
.ok()?
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|l| l.to_string())
|
||||
.collect();
|
||||
Some(lines)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Return the active branch name for the given directory.
|
||||
async fn git_branch(dir: &Path) -> Option<String> {
|
||||
let dir = dir.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.ok()?;
|
||||
if output.status.success() {
|
||||
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub(super) async fn tool_whatsup(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
|
||||
let root = ctx.state.get_project_root()?;
|
||||
let current_dir = root.join(".storkit").join("work").join("2_current");
|
||||
let filepath = current_dir.join(format!("{story_id}.md"));
|
||||
|
||||
if !filepath.exists() {
|
||||
return Err(format!(
|
||||
"Story '{story_id}' not found in work/2_current/. Check the story_id and ensure it is in the current stage."
|
||||
));
|
||||
}
|
||||
|
||||
let contents =
|
||||
fs::read_to_string(&filepath).map_err(|e| format!("Failed to read story file: {e}"))?;
|
||||
|
||||
// --- Front matter ---
|
||||
let mut front_matter = serde_json::Map::new();
|
||||
if let Ok(meta) = crate::io::story_metadata::parse_front_matter(&contents) {
|
||||
if let Some(name) = &meta.name {
|
||||
front_matter.insert("name".to_string(), json!(name));
|
||||
}
|
||||
if let Some(agent) = &meta.agent {
|
||||
front_matter.insert("agent".to_string(), json!(agent));
|
||||
}
|
||||
if let Some(true) = meta.blocked {
|
||||
front_matter.insert("blocked".to_string(), json!(true));
|
||||
}
|
||||
if let Some(qa) = &meta.qa {
|
||||
front_matter.insert("qa".to_string(), json!(qa.as_str()));
|
||||
}
|
||||
if let Some(rc) = meta.retry_count
|
||||
&& rc > 0
|
||||
{
|
||||
front_matter.insert("retry_count".to_string(), json!(rc));
|
||||
}
|
||||
if let Some(mf) = &meta.merge_failure {
|
||||
front_matter.insert("merge_failure".to_string(), json!(mf));
|
||||
}
|
||||
if let Some(rh) = meta.review_hold
|
||||
&& rh
|
||||
{
|
||||
front_matter.insert("review_hold".to_string(), json!(rh));
|
||||
}
|
||||
}
|
||||
|
||||
// --- AC checklist ---
|
||||
let ac_items: Vec<Value> = parse_ac_items(&contents)
|
||||
.into_iter()
|
||||
.map(|(text, checked)| json!({ "text": text, "checked": checked }))
|
||||
.collect();
|
||||
|
||||
// --- Worktree ---
|
||||
let worktree_path = root.join(".storkit").join("worktrees").join(story_id);
|
||||
let (_, worktree_info) = if worktree_path.is_dir() {
|
||||
let branch = git_branch(&worktree_path).await;
|
||||
(
|
||||
branch.clone(),
|
||||
Some(json!({
|
||||
"path": worktree_path.to_string_lossy(),
|
||||
"branch": branch,
|
||||
})),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// --- Git diff stat ---
|
||||
let diff_stat = if worktree_path.is_dir() {
|
||||
git_diff_stat(&worktree_path, "master").await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// --- Last 5 commits ---
|
||||
let commits = if worktree_path.is_dir() {
|
||||
git_log_commits(&worktree_path, "master", 5).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// --- Most recent agent log (last 20 lines) ---
|
||||
let agent_log = match find_most_recent_log(&root, story_id) {
|
||||
Some(log_path) => {
|
||||
let filename = log_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
match last_n_lines(&log_path, 20) {
|
||||
Ok(lines) => Some(json!({
|
||||
"file": filename,
|
||||
"lines": lines,
|
||||
})),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let result = json!({
|
||||
"story_id": story_id,
|
||||
"front_matter": front_matter,
|
||||
"acceptance_criteria": ac_items,
|
||||
"worktree": worktree_info,
|
||||
"git_diff_stat": diff_stat,
|
||||
"commits": commits,
|
||||
"agent_log": agent_log,
|
||||
});
|
||||
|
||||
serde_json::to_string_pretty(&result).map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn parse_ac_items_returns_checked_and_unchecked() {
|
||||
let content = "---\nname: test\n---\n\n## Acceptance Criteria\n\n- [ ] item one\n- [x] item two\n- [X] item three\n\n## Out of Scope\n\n- [ ] not an ac\n";
|
||||
let items = parse_ac_items(content);
|
||||
assert_eq!(items.len(), 3);
|
||||
assert_eq!(items[0], ("item one".to_string(), false));
|
||||
assert_eq!(items[1], ("item two".to_string(), true));
|
||||
assert_eq!(items[2], ("item three".to_string(), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ac_items_empty_when_no_section() {
|
||||
let content = "---\nname: test\n---\n\nNo AC section here.\n";
|
||||
let items = parse_ac_items(content);
|
||||
assert!(items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_most_recent_log_returns_none_for_missing_dir() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let result = find_most_recent_log(tmp.path(), "nonexistent_story");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_most_recent_log_returns_newest_file() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let log_dir = tmp
|
||||
.path()
|
||||
.join(".storkit")
|
||||
.join("logs")
|
||||
.join("42_story_foo");
|
||||
fs::create_dir_all(&log_dir).unwrap();
|
||||
|
||||
let old_path = log_dir.join("coder-1-sess-old.log");
|
||||
fs::write(&old_path, "old content").unwrap();
|
||||
|
||||
// Ensure different mtime
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
let new_path = log_dir.join("coder-1-sess-new.log");
|
||||
fs::write(&new_path, "new content").unwrap();
|
||||
|
||||
let result = find_most_recent_log(tmp.path(), "42_story_foo").unwrap();
|
||||
assert!(
|
||||
result.to_string_lossy().contains("sess-new"),
|
||||
"Expected newest file, got: {}",
|
||||
result.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_whatsup_returns_error_for_missing_story() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
||||
let result = tool_whatsup(&json!({"story_id": "999_story_nonexistent"}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found in work/2_current/"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_whatsup_returns_story_data() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let current_dir = tmp
|
||||
.path()
|
||||
.join(".storkit")
|
||||
.join("work")
|
||||
.join("2_current");
|
||||
fs::create_dir_all(¤t_dir).unwrap();
|
||||
|
||||
let story_content = "---\nname: My Test Story\nagent: coder-1\n---\n\n## Acceptance Criteria\n\n- [ ] First criterion\n- [x] Second criterion\n\n## Out of Scope\n\n- nothing\n";
|
||||
fs::write(current_dir.join("42_story_test.md"), story_content).unwrap();
|
||||
|
||||
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
||||
let result = tool_whatsup(&json!({"story_id": "42_story_test"}), &ctx)
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
|
||||
assert_eq!(parsed["story_id"], "42_story_test");
|
||||
assert_eq!(parsed["front_matter"]["name"], "My Test Story");
|
||||
assert_eq!(parsed["front_matter"]["agent"], "coder-1");
|
||||
|
||||
let ac = parsed["acceptance_criteria"].as_array().unwrap();
|
||||
assert_eq!(ac.len(), 2);
|
||||
assert_eq!(ac[0]["text"], "First criterion");
|
||||
assert_eq!(ac[0]["checked"], false);
|
||||
assert_eq!(ac[1]["text"], "Second criterion");
|
||||
assert_eq!(ac[1]["checked"], true);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ impl ProjectApi {
|
||||
payload.0.path,
|
||||
&self.ctx.state,
|
||||
self.ctx.store.as_ref(),
|
||||
self.ctx.agents.port(),
|
||||
)
|
||||
.await
|
||||
.map_err(bad_request)?;
|
||||
|
||||
@@ -158,9 +158,10 @@ impl From<WatcherEvent> for Option<WsResponse> {
|
||||
}),
|
||||
WatcherEvent::ConfigChanged => Some(WsResponse::AgentConfigChanged),
|
||||
WatcherEvent::AgentStateChanged => Some(WsResponse::AgentStateChanged),
|
||||
// MergeFailure is handled by the Matrix notification listener only;
|
||||
// no WebSocket message is needed for the frontend.
|
||||
// MergeFailure and RateLimitWarning are handled by the chat notification
|
||||
// listener only; no WebSocket message is needed for the frontend.
|
||||
WatcherEvent::MergeFailure { .. } => None,
|
||||
WatcherEvent::RateLimitWarning { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,7 +369,7 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
|
||||
/// the project root and git does not support `../` patterns in `.gitignore`
|
||||
/// files, so they cannot be expressed in `.storkit/.gitignore`.
|
||||
fn append_root_gitignore_entries(root: &Path) -> Result<(), String> {
|
||||
let entries = [".storkit_port", "store.json"];
|
||||
let entries = [".storkit_port", "store.json", ".mcp.json"];
|
||||
|
||||
let gitignore_path = root.join(".gitignore");
|
||||
let existing = if gitignore_path.exists() {
|
||||
@@ -404,7 +404,7 @@ fn append_root_gitignore_entries(root: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scaffold_story_kit(root: &Path) -> Result<(), String> {
|
||||
fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> {
|
||||
let story_kit_root = root.join(".storkit");
|
||||
let specs_root = story_kit_root.join("specs");
|
||||
let tech_root = specs_root.join("tech");
|
||||
@@ -440,6 +440,14 @@ fn scaffold_story_kit(root: &Path) -> Result<(), String> {
|
||||
write_script_if_missing(&script_root.join("test"), STORY_KIT_SCRIPT_TEST)?;
|
||||
write_file_if_missing(&root.join("CLAUDE.md"), STORY_KIT_CLAUDE_MD)?;
|
||||
|
||||
// Write .mcp.json at the project root so agents can find the MCP server.
|
||||
// Only written when missing — never overwrites an existing file, because
|
||||
// the port is environment-specific and must not clobber a running instance.
|
||||
let mcp_content = format!(
|
||||
"{{\n \"mcpServers\": {{\n \"storkit\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n"
|
||||
);
|
||||
write_file_if_missing(&root.join(".mcp.json"), &mcp_content)?;
|
||||
|
||||
// Create .claude/settings.json with sensible permission defaults so that
|
||||
// Claude Code (both agents and web UI chat) can operate without constant
|
||||
// permission prompts.
|
||||
@@ -505,14 +513,14 @@ fn scaffold_story_kit(root: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_project_root_with_story_kit(path: PathBuf) -> Result<(), String> {
|
||||
async fn ensure_project_root_with_story_kit(path: PathBuf, port: u16) -> Result<(), String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if !path.exists() {
|
||||
fs::create_dir_all(&path)
|
||||
.map_err(|e| format!("Failed to create project directory: {}", e))?;
|
||||
}
|
||||
if !path.join(".storkit").is_dir() {
|
||||
scaffold_story_kit(&path)?;
|
||||
scaffold_story_kit(&path, port)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -524,10 +532,11 @@ pub async fn open_project(
|
||||
path: String,
|
||||
state: &SessionState,
|
||||
store: &dyn StoreOps,
|
||||
port: u16,
|
||||
) -> Result<String, String> {
|
||||
let p = PathBuf::from(&path);
|
||||
|
||||
ensure_project_root_with_story_kit(p.clone()).await?;
|
||||
ensure_project_root_with_story_kit(p.clone(), port).await?;
|
||||
validate_project_path(p.clone()).await?;
|
||||
|
||||
{
|
||||
@@ -816,7 +825,7 @@ mod tests {
|
||||
let store = make_store(&dir);
|
||||
let state = SessionState::default();
|
||||
|
||||
let result = open_project(project_dir.to_string_lossy().to_string(), &state, &store).await;
|
||||
let result = open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let root = state.get_project_root().unwrap();
|
||||
@@ -824,26 +833,47 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_project_does_not_write_mcp_json() {
|
||||
// open_project must NOT overwrite .mcp.json — test servers started by QA
|
||||
// agents share the real project root, so writing here would clobber the
|
||||
// root .mcp.json with the wrong port. .mcp.json is written once during
|
||||
// worktree creation (worktree.rs) and should not be touched again.
|
||||
async fn open_project_does_not_overwrite_existing_mcp_json() {
|
||||
// scaffold must NOT overwrite .mcp.json when it already exists — QA
|
||||
// test servers share the real project root, and re-writing would
|
||||
// clobber the file with the wrong port.
|
||||
let dir = tempdir().unwrap();
|
||||
let project_dir = dir.path().join("myproject");
|
||||
fs::create_dir_all(&project_dir).unwrap();
|
||||
// Pre-write .mcp.json with a different port to simulate an already-configured project.
|
||||
let mcp_path = project_dir.join(".mcp.json");
|
||||
fs::write(&mcp_path, "{\"existing\": true}").unwrap();
|
||||
let store = make_store(&dir);
|
||||
let state = SessionState::default();
|
||||
|
||||
open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(&mcp_path).unwrap(),
|
||||
"{\"existing\": true}",
|
||||
"open_project must not overwrite an existing .mcp.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_project_writes_mcp_json_when_missing() {
|
||||
let dir = tempdir().unwrap();
|
||||
let project_dir = dir.path().join("myproject");
|
||||
fs::create_dir_all(&project_dir).unwrap();
|
||||
let store = make_store(&dir);
|
||||
let state = SessionState::default();
|
||||
|
||||
open_project(project_dir.to_string_lossy().to_string(), &state, &store)
|
||||
open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mcp_path = project_dir.join(".mcp.json");
|
||||
assert!(
|
||||
!mcp_path.exists(),
|
||||
"open_project must not write .mcp.json — that would overwrite the root with the wrong port"
|
||||
);
|
||||
assert!(mcp_path.exists(), "open_project should write .mcp.json for new projects");
|
||||
let content = fs::read_to_string(&mcp_path).unwrap();
|
||||
assert!(content.contains("3001"), "mcp.json should reference the server port");
|
||||
assert!(content.contains("localhost"), "mcp.json should reference localhost");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -898,7 +928,7 @@ mod tests {
|
||||
let store = make_store(&dir);
|
||||
let state = SessionState::default();
|
||||
|
||||
open_project(project_dir.to_string_lossy().to_string(), &state, &store)
|
||||
open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -1071,7 +1101,7 @@ mod tests {
|
||||
#[test]
|
||||
fn scaffold_story_kit_creates_structure() {
|
||||
let dir = tempdir().unwrap();
|
||||
scaffold_story_kit(dir.path()).unwrap();
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
assert!(dir.path().join(".storkit/README.md").exists());
|
||||
assert!(dir.path().join(".storkit/project.toml").exists());
|
||||
@@ -1085,7 +1115,7 @@ mod tests {
|
||||
#[test]
|
||||
fn scaffold_story_kit_creates_work_pipeline_dirs() {
|
||||
let dir = tempdir().unwrap();
|
||||
scaffold_story_kit(dir.path()).unwrap();
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
let stages = [
|
||||
"1_backlog",
|
||||
@@ -1109,7 +1139,7 @@ mod tests {
|
||||
#[test]
|
||||
fn scaffold_story_kit_project_toml_has_coder_qa_mergemaster() {
|
||||
let dir = tempdir().unwrap();
|
||||
scaffold_story_kit(dir.path()).unwrap();
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
let content = fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap();
|
||||
assert!(content.contains("[[agent]]"));
|
||||
@@ -1122,7 +1152,7 @@ mod tests {
|
||||
#[test]
|
||||
fn scaffold_context_is_blank_template_not_story_kit_content() {
|
||||
let dir = tempdir().unwrap();
|
||||
scaffold_story_kit(dir.path()).unwrap();
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
let content = fs::read_to_string(dir.path().join(".storkit/specs/00_CONTEXT.md")).unwrap();
|
||||
assert!(content.contains("<!-- storkit:scaffold-template -->"));
|
||||
@@ -1138,7 +1168,7 @@ mod tests {
|
||||
#[test]
|
||||
fn scaffold_stack_is_blank_template_not_story_kit_content() {
|
||||
let dir = tempdir().unwrap();
|
||||
scaffold_story_kit(dir.path()).unwrap();
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
let content = fs::read_to_string(dir.path().join(".storkit/specs/tech/STACK.md")).unwrap();
|
||||
assert!(content.contains("<!-- storkit:scaffold-template -->"));
|
||||
@@ -1157,7 +1187,7 @@ mod tests {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
scaffold_story_kit(dir.path()).unwrap();
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
let script_test = dir.path().join("script/test");
|
||||
assert!(script_test.exists(), "script/test should be created");
|
||||
@@ -1175,7 +1205,7 @@ mod tests {
|
||||
fs::create_dir_all(readme.parent().unwrap()).unwrap();
|
||||
fs::write(&readme, "custom content").unwrap();
|
||||
|
||||
scaffold_story_kit(dir.path()).unwrap();
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content");
|
||||
}
|
||||
@@ -1183,13 +1213,13 @@ mod tests {
|
||||
#[test]
|
||||
fn scaffold_story_kit_is_idempotent() {
|
||||
let dir = tempdir().unwrap();
|
||||
scaffold_story_kit(dir.path()).unwrap();
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
let readme_content = fs::read_to_string(dir.path().join(".storkit/README.md")).unwrap();
|
||||
let toml_content = fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap();
|
||||
|
||||
// Run again — must not change content or add duplicate .gitignore entries
|
||||
scaffold_story_kit(dir.path()).unwrap();
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.path().join(".storkit/README.md")).unwrap(),
|
||||
@@ -1237,7 +1267,7 @@ mod tests {
|
||||
.status()
|
||||
.unwrap();
|
||||
|
||||
scaffold_story_kit(dir.path()).unwrap();
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
// Only 1 commit should exist — scaffold must not commit into an existing repo
|
||||
let log_output = std::process::Command::new("git")
|
||||
@@ -1256,7 +1286,7 @@ mod tests {
|
||||
#[test]
|
||||
fn scaffold_creates_story_kit_gitignore_with_relative_entries() {
|
||||
let dir = tempdir().unwrap();
|
||||
scaffold_story_kit(dir.path()).unwrap();
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
// .storkit/.gitignore must contain relative patterns for files under .storkit/
|
||||
let sk_content = fs::read_to_string(dir.path().join(".storkit/.gitignore")).unwrap();
|
||||
@@ -1287,7 +1317,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
scaffold_story_kit(dir.path()).unwrap();
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
let content = fs::read_to_string(dir.path().join(".storkit/.gitignore")).unwrap();
|
||||
let worktrees_count = content.lines().filter(|l| l.trim() == "worktrees/").count();
|
||||
@@ -1303,7 +1333,7 @@ mod tests {
|
||||
#[test]
|
||||
fn scaffold_creates_claude_md_at_project_root() {
|
||||
let dir = tempdir().unwrap();
|
||||
scaffold_story_kit(dir.path()).unwrap();
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
let claude_md = dir.path().join("CLAUDE.md");
|
||||
assert!(
|
||||
@@ -1332,7 +1362,7 @@ mod tests {
|
||||
let claude_md = dir.path().join("CLAUDE.md");
|
||||
fs::write(&claude_md, "custom CLAUDE.md content").unwrap();
|
||||
|
||||
scaffold_story_kit(dir.path()).unwrap();
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(&claude_md).unwrap(),
|
||||
@@ -1341,6 +1371,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaffold_story_kit_writes_mcp_json_with_port() {
|
||||
let dir = tempdir().unwrap();
|
||||
scaffold_story_kit(dir.path(), 4242).unwrap();
|
||||
|
||||
let mcp_path = dir.path().join(".mcp.json");
|
||||
assert!(mcp_path.exists(), ".mcp.json should be created by scaffold");
|
||||
let content = fs::read_to_string(&mcp_path).unwrap();
|
||||
assert!(content.contains("4242"), ".mcp.json should reference the given port");
|
||||
assert!(content.contains("localhost"), ".mcp.json should reference localhost");
|
||||
assert!(content.contains("storkit"), ".mcp.json should name the storkit server");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaffold_story_kit_does_not_overwrite_existing_mcp_json() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mcp_path = dir.path().join(".mcp.json");
|
||||
fs::write(&mcp_path, "{\"custom\": true}").unwrap();
|
||||
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(&mcp_path).unwrap(),
|
||||
"{\"custom\": true}",
|
||||
"scaffold should not overwrite an existing .mcp.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaffold_gitignore_includes_mcp_json() {
|
||||
let dir = tempdir().unwrap();
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
let root_gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
|
||||
assert!(
|
||||
root_gitignore.contains(".mcp.json"),
|
||||
"root .gitignore should include .mcp.json (port is environment-specific)"
|
||||
);
|
||||
}
|
||||
|
||||
// --- open_project scaffolding ---
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1351,7 +1421,7 @@ mod tests {
|
||||
let store = make_store(&dir);
|
||||
let state = SessionState::default();
|
||||
|
||||
open_project(project_dir.to_string_lossy().to_string(), &state, &store)
|
||||
open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -1370,7 +1440,7 @@ mod tests {
|
||||
let store = make_store(&dir);
|
||||
let state = SessionState::default();
|
||||
|
||||
open_project(project_dir.to_string_lossy().to_string(), &state, &store)
|
||||
open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -1572,7 +1642,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
scaffold_story_kit(dir.path()).unwrap();
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
let content = fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap();
|
||||
assert!(
|
||||
@@ -1592,7 +1662,7 @@ mod tests {
|
||||
#[test]
|
||||
fn scaffold_project_toml_fallback_when_no_stack_detected() {
|
||||
let dir = tempdir().unwrap();
|
||||
scaffold_story_kit(dir.path()).unwrap();
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
let content = fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap();
|
||||
assert!(
|
||||
@@ -1614,7 +1684,7 @@ mod tests {
|
||||
let existing = "[[component]]\nname = \"custom\"\npath = \".\"\nsetup = [\"make build\"]\n";
|
||||
fs::write(sk_dir.join("project.toml"), existing).unwrap();
|
||||
|
||||
scaffold_story_kit(dir.path()).unwrap();
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
let content = fs::read_to_string(sk_dir.join("project.toml")).unwrap();
|
||||
assert_eq!(
|
||||
|
||||
@@ -77,10 +77,8 @@ struct FrontMatter {
|
||||
merge_failure: Option<String>,
|
||||
agent: Option<String>,
|
||||
review_hold: Option<bool>,
|
||||
/// New configurable QA mode field: "human", "server", or "agent".
|
||||
/// 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).
|
||||
@@ -113,12 +111,7 @@ pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaErro
|
||||
}
|
||||
|
||||
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 })
|
||||
};
|
||||
let qa = front.qa.as_deref().and_then(QaMode::from_str);
|
||||
|
||||
StoryMetadata {
|
||||
name: front.name,
|
||||
@@ -513,27 +506,6 @@ workflow: tdd
|
||||
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();
|
||||
|
||||
@@ -59,6 +59,14 @@ pub enum WatcherEvent {
|
||||
/// Human-readable description of the failure.
|
||||
reason: String,
|
||||
},
|
||||
/// An agent hit an API rate limit.
|
||||
/// Triggers a warning notification to configured chat rooms.
|
||||
RateLimitWarning {
|
||||
/// Work item ID the agent is working on.
|
||||
story_id: String,
|
||||
/// Name of the agent that hit the rate limit.
|
||||
agent_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Return `true` if `path` is the root-level `.storkit/project.toml`, i.e.
|
||||
|
||||
@@ -24,6 +24,7 @@ use crate::http::build_routes;
|
||||
use crate::http::context::AppContext;
|
||||
use crate::http::{remove_port_file, resolve_port, write_port_file};
|
||||
use crate::io::fs::find_story_kit_root;
|
||||
use crate::rebuild::{BotShutdownNotifier, ShutdownReason};
|
||||
use crate::state::SessionState;
|
||||
use crate::store::JsonFileStore;
|
||||
use crate::workflow::WorkflowState;
|
||||
@@ -33,6 +34,32 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// What the first CLI argument means.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum CliDirective {
|
||||
/// `--help` / `-h`
|
||||
Help,
|
||||
/// `--version` / `-V`
|
||||
Version,
|
||||
/// An unrecognised flag (starts with `-`).
|
||||
UnknownFlag(String),
|
||||
/// A positional path argument.
|
||||
Path,
|
||||
/// No arguments at all.
|
||||
None,
|
||||
}
|
||||
|
||||
/// Inspect the raw CLI arguments and return the directive they imply.
|
||||
fn classify_cli_args(args: &[String]) -> CliDirective {
|
||||
match args.first().map(String::as_str) {
|
||||
None => CliDirective::None,
|
||||
Some("--help" | "-h") => CliDirective::Help,
|
||||
Some("--version" | "-V") => CliDirective::Version,
|
||||
Some(a) if a.starts_with('-') => CliDirective::UnknownFlag(a.to_string()),
|
||||
Some(_) => CliDirective::Path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the optional positional path argument (everything after the binary
|
||||
/// name) into an absolute `PathBuf`. Returns `None` when no argument was
|
||||
/// supplied so that the caller can fall back to the auto-detect behaviour.
|
||||
@@ -52,8 +79,61 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
|
||||
// Collect CLI args, skipping the binary name (argv[0]).
|
||||
let cli_args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
||||
// Handle CLI flags before treating anything as a project path.
|
||||
match classify_cli_args(&cli_args) {
|
||||
CliDirective::Help => {
|
||||
println!("storkit [PATH]");
|
||||
println!();
|
||||
println!("Serve a storkit project.");
|
||||
println!();
|
||||
println!("USAGE:");
|
||||
println!(" storkit [PATH]");
|
||||
println!();
|
||||
println!("ARGS:");
|
||||
println!(
|
||||
" PATH Path to an existing project directory. \
|
||||
If omitted, storkit searches parent directories for a .storkit/ root."
|
||||
);
|
||||
println!();
|
||||
println!("OPTIONS:");
|
||||
println!(" -h, --help Print this help and exit");
|
||||
println!(" -V, --version Print the version and exit");
|
||||
std::process::exit(0);
|
||||
}
|
||||
CliDirective::Version => {
|
||||
println!("storkit {}", env!("CARGO_PKG_VERSION"));
|
||||
std::process::exit(0);
|
||||
}
|
||||
CliDirective::UnknownFlag(flag) => {
|
||||
eprintln!("error: unknown option: {flag}");
|
||||
eprintln!("Run 'storkit --help' for usage.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
CliDirective::Path | CliDirective::None => {}
|
||||
}
|
||||
|
||||
let explicit_path = parse_project_path_arg(&cli_args, &cwd);
|
||||
|
||||
// When a path is given explicitly on the CLI, it must already exist as a
|
||||
// directory. We do not create directories from the command line.
|
||||
if let Some(ref path) = explicit_path {
|
||||
if !path.exists() {
|
||||
eprintln!(
|
||||
"error: path does not exist: {}",
|
||||
path.display()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
if !path.is_dir() {
|
||||
eprintln!(
|
||||
"error: path is not a directory: {}",
|
||||
path.display()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(explicit_root) = explicit_path {
|
||||
// An explicit path was given on the command line.
|
||||
// Open it directly — scaffold .storkit/ if it is missing — and
|
||||
@@ -62,6 +142,7 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
explicit_root.to_string_lossy().to_string(),
|
||||
&app_state,
|
||||
store.as_ref(),
|
||||
port,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -84,6 +165,7 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
project_root.to_string_lossy().to_string(),
|
||||
&app_state,
|
||||
store.as_ref(),
|
||||
port,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
@@ -177,17 +259,6 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
let startup_reconciliation_tx = reconciliation_tx.clone();
|
||||
// Clone for shutdown cleanup — kill orphaned PTY children before exiting.
|
||||
let agents_for_shutdown = Arc::clone(&agents);
|
||||
let ctx = AppContext {
|
||||
state: app_state,
|
||||
store,
|
||||
workflow,
|
||||
agents,
|
||||
watcher_tx,
|
||||
reconciliation_tx,
|
||||
perm_tx,
|
||||
perm_rx,
|
||||
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
|
||||
};
|
||||
|
||||
// Build WhatsApp webhook context if bot.toml configures transport = "whatsapp".
|
||||
let whatsapp_ctx: Option<Arc<whatsapp::WhatsAppWebhookContext>> = startup_root
|
||||
@@ -255,7 +326,50 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
})
|
||||
});
|
||||
|
||||
let app = build_routes(ctx, whatsapp_ctx, slack_ctx);
|
||||
// Build a best-effort shutdown notifier for webhook-based transports.
|
||||
//
|
||||
// • Slack: channels are fixed at startup (channel_ids from bot.toml).
|
||||
// • WhatsApp: active senders are tracked at runtime in ambient_rooms.
|
||||
// We keep the WhatsApp context Arc so we can read the rooms at shutdown.
|
||||
// • Matrix: the bot task manages its own announcement via matrix_shutdown_tx.
|
||||
let bot_shutdown_notifier: Option<Arc<BotShutdownNotifier>> =
|
||||
if let Some(ref ctx) = slack_ctx {
|
||||
let channels: Vec<String> = ctx.channel_ids.iter().cloned().collect();
|
||||
Some(Arc::new(BotShutdownNotifier::new(
|
||||
Arc::clone(&ctx.transport) as Arc<dyn crate::transport::ChatTransport>,
|
||||
channels,
|
||||
ctx.bot_name.clone(),
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Retain a reference to the WhatsApp context for shutdown notifications.
|
||||
// At shutdown time we read ambient_rooms to get the current set of active senders.
|
||||
let whatsapp_ctx_for_shutdown: Option<Arc<whatsapp::WhatsAppWebhookContext>> =
|
||||
whatsapp_ctx.clone();
|
||||
|
||||
// Watch channel: signals the Matrix bot task to send a shutdown announcement.
|
||||
// `None` initial value means "server is running".
|
||||
let (matrix_shutdown_tx, matrix_shutdown_rx) =
|
||||
tokio::sync::watch::channel::<Option<ShutdownReason>>(None);
|
||||
let matrix_shutdown_tx = Arc::new(matrix_shutdown_tx);
|
||||
let matrix_shutdown_tx_for_rebuild = Arc::clone(&matrix_shutdown_tx);
|
||||
|
||||
let ctx = AppContext {
|
||||
state: app_state,
|
||||
store,
|
||||
workflow,
|
||||
agents,
|
||||
watcher_tx,
|
||||
reconciliation_tx,
|
||||
perm_tx,
|
||||
perm_rx,
|
||||
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
|
||||
bot_shutdown: bot_shutdown_notifier.clone(),
|
||||
matrix_shutdown_tx: Some(Arc::clone(&matrix_shutdown_tx)),
|
||||
};
|
||||
|
||||
let app = build_routes(ctx, whatsapp_ctx.clone(), slack_ctx.clone());
|
||||
|
||||
// Optional Matrix bot: connect to the homeserver and start listening for
|
||||
// messages if `.storkit/bot.toml` is present and enabled.
|
||||
@@ -265,7 +379,11 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
watcher_tx_for_bot,
|
||||
perm_rx_for_bot,
|
||||
Arc::clone(&startup_agents),
|
||||
matrix_shutdown_rx,
|
||||
);
|
||||
} else {
|
||||
// Keep the receiver alive (drop it) so the sender never errors.
|
||||
drop(matrix_shutdown_rx);
|
||||
}
|
||||
|
||||
// On startup:
|
||||
@@ -295,6 +413,36 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
|
||||
let result = Server::new(TcpListener::bind(&addr)).run(app).await;
|
||||
|
||||
// ── Shutdown notifications (best-effort) ─────────────────────────────
|
||||
//
|
||||
// The server is stopping (SIGINT / SIGTERM). Notify active bot channels
|
||||
// so participants know the bot is going offline. We do this before killing
|
||||
// PTY children so network I/O can still complete.
|
||||
|
||||
// Slack: notifier holds the fixed channel list.
|
||||
if let Some(ref notifier) = bot_shutdown_notifier {
|
||||
notifier.notify(ShutdownReason::Manual).await;
|
||||
}
|
||||
|
||||
// WhatsApp: read the current set of ambient rooms and notify each sender.
|
||||
if let Some(ref ctx) = whatsapp_ctx_for_shutdown {
|
||||
let rooms: Vec<String> = ctx.ambient_rooms.lock().unwrap().iter().cloned().collect();
|
||||
if !rooms.is_empty() {
|
||||
let wa_notifier = BotShutdownNotifier::new(
|
||||
Arc::clone(&ctx.transport) as Arc<dyn crate::transport::ChatTransport>,
|
||||
rooms,
|
||||
ctx.bot_name.clone(),
|
||||
);
|
||||
wa_notifier.notify(ShutdownReason::Manual).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Matrix: signal the bot task and give it a short window to send its message.
|
||||
let _ = matrix_shutdown_tx_for_rebuild.send(Some(ShutdownReason::Manual));
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
|
||||
// ── Cleanup ──────────────────────────────────────────────────────────
|
||||
|
||||
// Kill all active PTY child processes before exiting to prevent orphaned
|
||||
// Claude Code processes from running after the server restarts.
|
||||
agents_for_shutdown.kill_all_children();
|
||||
@@ -332,6 +480,61 @@ name = "coder"
|
||||
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
|
||||
}
|
||||
|
||||
// ── classify_cli_args ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn classify_none_when_no_args() {
|
||||
assert_eq!(classify_cli_args(&[]), CliDirective::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_help_long() {
|
||||
assert_eq!(
|
||||
classify_cli_args(&["--help".to_string()]),
|
||||
CliDirective::Help
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_help_short() {
|
||||
assert_eq!(
|
||||
classify_cli_args(&["-h".to_string()]),
|
||||
CliDirective::Help
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_version_long() {
|
||||
assert_eq!(
|
||||
classify_cli_args(&["--version".to_string()]),
|
||||
CliDirective::Version
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_version_short() {
|
||||
assert_eq!(
|
||||
classify_cli_args(&["-V".to_string()]),
|
||||
CliDirective::Version
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_unknown_flag() {
|
||||
assert_eq!(
|
||||
classify_cli_args(&["--serve".to_string()]),
|
||||
CliDirective::UnknownFlag("--serve".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_path() {
|
||||
assert_eq!(
|
||||
classify_cli_args(&["/some/path".to_string()]),
|
||||
CliDirective::Path
|
||||
);
|
||||
}
|
||||
|
||||
// ── parse_project_path_arg ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -213,6 +213,7 @@ pub async fn run_bot(
|
||||
watcher_rx: tokio::sync::broadcast::Receiver<crate::io::watcher::WatcherEvent>,
|
||||
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
|
||||
agents: Arc<AgentPool>,
|
||||
shutdown_rx: tokio::sync::watch::Receiver<Option<crate::rebuild::ShutdownReason>>,
|
||||
) -> Result<(), String> {
|
||||
let store_path = project_root.join(".storkit").join("matrix_store");
|
||||
let client = Client::builder()
|
||||
@@ -426,6 +427,30 @@ pub async fn run_bot(
|
||||
notif_project_root,
|
||||
);
|
||||
|
||||
// Spawn a shutdown watcher that sends a best-effort goodbye message to all
|
||||
// configured rooms when the server is about to stop (SIGINT/SIGTERM or rebuild).
|
||||
{
|
||||
let shutdown_transport = Arc::clone(&transport);
|
||||
let shutdown_rooms: Vec<String> =
|
||||
announce_room_ids.iter().map(|r| r.to_string()).collect();
|
||||
let shutdown_bot_name = announce_bot_name.clone();
|
||||
let mut rx = shutdown_rx;
|
||||
tokio::spawn(async move {
|
||||
// Wait until the channel holds Some(reason).
|
||||
if rx.wait_for(|v| v.is_some()).await.is_ok() {
|
||||
let reason = rx.borrow().clone();
|
||||
let notifier = crate::rebuild::BotShutdownNotifier::new(
|
||||
shutdown_transport,
|
||||
shutdown_rooms,
|
||||
shutdown_bot_name,
|
||||
);
|
||||
if let Some(r) = reason {
|
||||
notifier.notify(r).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Send a startup announcement to each configured room so users know the
|
||||
// bot is online. This runs once per process start — the sync loop handles
|
||||
// reconnects internally so this code is never reached again on a network
|
||||
|
||||
@@ -14,6 +14,7 @@ mod move_story;
|
||||
mod overview;
|
||||
mod show;
|
||||
mod status;
|
||||
mod whatsup;
|
||||
|
||||
use crate::agents::AgentPool;
|
||||
use std::collections::HashSet;
|
||||
@@ -88,7 +89,7 @@ pub fn commands() -> &'static [BotCommand] {
|
||||
},
|
||||
BotCommand {
|
||||
name: "status",
|
||||
description: "Show pipeline status and agent availability",
|
||||
description: "Show pipeline status and agent availability; or `status <number>` for a story triage dump",
|
||||
handler: status::handle_status,
|
||||
},
|
||||
BotCommand {
|
||||
|
||||
@@ -7,7 +7,11 @@ use std::collections::{HashMap, HashSet};
|
||||
use super::CommandContext;
|
||||
|
||||
pub(super) fn handle_status(ctx: &CommandContext) -> Option<String> {
|
||||
Some(build_pipeline_status(ctx.project_root, ctx.agents))
|
||||
if ctx.args.trim().is_empty() {
|
||||
Some(build_pipeline_status(ctx.project_root, ctx.agents))
|
||||
} else {
|
||||
super::whatsup::handle_whatsup(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a short display label for a work item.
|
||||
|
||||
548
server/src/matrix/commands/whatsup.rs
Normal file
548
server/src/matrix/commands/whatsup.rs
Normal file
@@ -0,0 +1,548 @@
|
||||
//! Handler for the `whatsup` command.
|
||||
//!
|
||||
//! Produces a triage dump for a story that is currently in-progress
|
||||
//! (`work/2_current/`): metadata, acceptance criteria, worktree/branch state,
|
||||
//! git diff, recent commits, and the tail of the agent log.
|
||||
//!
|
||||
//! The command is handled entirely at the bot level — no LLM invocation.
|
||||
|
||||
use super::CommandContext;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// Handle `{bot_name} whatsup {number}`.
|
||||
pub(super) fn handle_whatsup(ctx: &CommandContext) -> Option<String> {
|
||||
let num_str = ctx.args.trim();
|
||||
if num_str.is_empty() {
|
||||
return Some(format!(
|
||||
"Usage: `{} status <number>`\n\nShows a triage dump for a story currently in progress.",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
if !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(format!(
|
||||
"Invalid story number: `{num_str}`. Usage: `{} status <number>`",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
|
||||
let current_dir = ctx
|
||||
.project_root
|
||||
.join(".storkit")
|
||||
.join("work")
|
||||
.join("2_current");
|
||||
|
||||
match find_story_in_dir(¤t_dir, num_str) {
|
||||
Some((path, stem)) => Some(build_triage_dump(ctx, &path, &stem, num_str)),
|
||||
None => Some(format!(
|
||||
"Story **{num_str}** is not currently in progress (not found in `work/2_current/`)."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a `.md` file whose numeric prefix matches `num_str` in `dir`.
|
||||
///
|
||||
/// Returns `(path, file_stem)` for the first match.
|
||||
fn find_story_in_dir(dir: &Path, num_str: &str) -> Option<(PathBuf, String)> {
|
||||
let entries = std::fs::read_dir(dir).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("");
|
||||
if file_num == num_str {
|
||||
return Some((path.clone(), stem.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Build the full triage dump for a story.
|
||||
fn build_triage_dump(
|
||||
ctx: &CommandContext,
|
||||
story_path: &Path,
|
||||
story_id: &str,
|
||||
num_str: &str,
|
||||
) -> String {
|
||||
let contents = match std::fs::read_to_string(story_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return format!("Failed to read story {num_str}: {e}"),
|
||||
};
|
||||
|
||||
let meta = crate::io::story_metadata::parse_front_matter(&contents).ok();
|
||||
let name = meta.as_ref().and_then(|m| m.name.as_deref()).unwrap_or("(unnamed)");
|
||||
|
||||
let mut out = String::new();
|
||||
|
||||
// ---- Header ----
|
||||
out.push_str(&format!("## Story {num_str} — {name}\n"));
|
||||
out.push_str("**Stage:** In Progress (`2_current`)\n\n");
|
||||
|
||||
// ---- Front matter fields ----
|
||||
if let Some(ref m) = meta {
|
||||
let mut fields: Vec<String> = Vec::new();
|
||||
if let Some(true) = m.blocked {
|
||||
fields.push("**blocked:** true".to_string());
|
||||
}
|
||||
if let Some(ref agent) = m.agent {
|
||||
fields.push(format!("**agent:** {agent}"));
|
||||
}
|
||||
if let Some(ref qa) = m.qa {
|
||||
fields.push(format!("**qa:** {qa}"));
|
||||
}
|
||||
if let Some(true) = m.review_hold {
|
||||
fields.push("**review_hold:** true".to_string());
|
||||
}
|
||||
if let Some(rc) = m.retry_count
|
||||
&& rc > 0
|
||||
{
|
||||
fields.push(format!("**retry_count:** {rc}"));
|
||||
}
|
||||
if let Some(ref cb) = m.coverage_baseline {
|
||||
fields.push(format!("**coverage_baseline:** {cb}"));
|
||||
}
|
||||
if let Some(ref mf) = m.merge_failure {
|
||||
fields.push(format!("**merge_failure:** {mf}"));
|
||||
}
|
||||
if !fields.is_empty() {
|
||||
out.push_str("**Front matter:**\n");
|
||||
for f in &fields {
|
||||
out.push_str(&format!(" • {f}\n"));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Acceptance criteria ----
|
||||
let criteria = parse_acceptance_criteria(&contents);
|
||||
if !criteria.is_empty() {
|
||||
out.push_str("**Acceptance Criteria:**\n");
|
||||
for (checked, text) in &criteria {
|
||||
let mark = if *checked { "✅" } else { "⬜" };
|
||||
out.push_str(&format!(" {mark} {text}\n"));
|
||||
}
|
||||
let total = criteria.len();
|
||||
let done = criteria.iter().filter(|(c, _)| *c).count();
|
||||
out.push_str(&format!(" *{done}/{total} complete*\n"));
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// ---- Worktree and branch ----
|
||||
let wt_path = crate::worktree::worktree_path(ctx.project_root, story_id);
|
||||
let branch = format!("feature/story-{story_id}");
|
||||
if wt_path.is_dir() {
|
||||
out.push_str(&format!("**Worktree:** `{}`\n", wt_path.display()));
|
||||
out.push_str(&format!("**Branch:** `{branch}`\n\n"));
|
||||
|
||||
// ---- git diff --stat ----
|
||||
let diff_stat = run_git(
|
||||
&wt_path,
|
||||
&["diff", "--stat", "master...HEAD"],
|
||||
);
|
||||
if !diff_stat.is_empty() {
|
||||
out.push_str("**Diff stat (vs master):**\n```\n");
|
||||
out.push_str(&diff_stat);
|
||||
out.push_str("```\n\n");
|
||||
} else {
|
||||
out.push_str("**Diff stat (vs master):** *(no changes)*\n\n");
|
||||
}
|
||||
|
||||
// ---- Last 5 commits on feature branch ----
|
||||
let log = run_git(
|
||||
&wt_path,
|
||||
&[
|
||||
"log",
|
||||
"master..HEAD",
|
||||
"--pretty=format:%h %s",
|
||||
"-5",
|
||||
],
|
||||
);
|
||||
if !log.is_empty() {
|
||||
out.push_str("**Recent commits (branch only):**\n```\n");
|
||||
out.push_str(&log);
|
||||
out.push_str("\n```\n\n");
|
||||
} else {
|
||||
out.push_str("**Recent commits (branch only):** *(none yet)*\n\n");
|
||||
}
|
||||
} else {
|
||||
out.push_str(&format!("**Branch:** `{branch}`\n"));
|
||||
out.push_str("**Worktree:** *(not yet created)*\n\n");
|
||||
}
|
||||
|
||||
// ---- Agent log tail ----
|
||||
let log_dir = ctx
|
||||
.project_root
|
||||
.join(".storkit")
|
||||
.join("logs")
|
||||
.join(story_id);
|
||||
match latest_log_file(&log_dir) {
|
||||
Some(log_path) => {
|
||||
let tail = read_log_tail(&log_path, 20);
|
||||
let filename = log_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("agent.log");
|
||||
if tail.is_empty() {
|
||||
out.push_str(&format!("**Agent log** (`{filename}`):** *(empty)*\n"));
|
||||
} else {
|
||||
out.push_str(&format!("**Agent log tail** (`{filename}`):\n```\n"));
|
||||
out.push_str(&tail);
|
||||
out.push_str("\n```\n");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
out.push_str("**Agent log:** *(no log found)*\n");
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse acceptance criteria from story markdown.
|
||||
///
|
||||
/// Returns a list of `(checked, text)` for every `- [ ] ...` and `- [x] ...` line.
|
||||
fn parse_acceptance_criteria(contents: &str) -> Vec<(bool, String)> {
|
||||
contents
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if let Some(text) = trimmed.strip_prefix("- [x] ").or_else(|| trimmed.strip_prefix("- [X] ")) {
|
||||
Some((true, text.to_string()))
|
||||
} else {
|
||||
trimmed.strip_prefix("- [ ] ").map(|text| (false, text.to_string()))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Run a git command in the given directory, returning trimmed stdout (or empty on error).
|
||||
fn run_git(dir: &Path, args: &[&str]) -> String {
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Find the most recently modified `.log` file in the given directory,
|
||||
/// regardless of agent name.
|
||||
fn latest_log_file(log_dir: &Path) -> Option<PathBuf> {
|
||||
if !log_dir.is_dir() {
|
||||
return None;
|
||||
}
|
||||
let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
|
||||
for entry in std::fs::read_dir(log_dir).ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("log") {
|
||||
continue;
|
||||
}
|
||||
let modified = match entry.metadata().and_then(|m| m.modified()) {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if best.as_ref().is_none_or(|(_, t)| modified > *t) {
|
||||
best = Some((path, modified));
|
||||
}
|
||||
}
|
||||
best.map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Read the last `n` non-empty lines from a file as a single string.
|
||||
fn read_log_tail(path: &Path, n: usize) -> String {
|
||||
let contents = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
let lines: Vec<&str> = contents.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
let start = lines.len().saturating_sub(n);
|
||||
lines[start..].join("\n")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::AgentPool;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::super::{CommandDispatch, try_handle_command};
|
||||
|
||||
fn whatsup_cmd(root: &Path, args: &str) -> Option<String> {
|
||||
let agents = Arc::new(AgentPool::new_test(3000));
|
||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
};
|
||||
try_handle_command(&dispatch, &format!("@timmy status {args}"))
|
||||
}
|
||||
|
||||
fn write_story_file(root: &Path, stage: &str, filename: &str, content: &str) {
|
||||
let dir = root.join(".storkit/work").join(stage);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(filename), content).unwrap();
|
||||
}
|
||||
|
||||
// -- registration -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn whatsup_command_is_not_registered() {
|
||||
let found = super::super::commands().iter().any(|c| c.name == "whatsup");
|
||||
assert!(!found, "whatsup command must not be in the registry (renamed to status)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("status"),
|
||||
"help should list status command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- input validation ---------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn whatsup_no_args_returns_usage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = whatsup_cmd(tmp.path(), "").unwrap();
|
||||
assert!(
|
||||
output.contains("Pipeline Status"),
|
||||
"no args should show pipeline status: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsup_non_numeric_returns_error() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = whatsup_cmd(tmp.path(), "abc").unwrap();
|
||||
assert!(
|
||||
output.contains("Invalid"),
|
||||
"non-numeric arg should return error: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- not found ----------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn whatsup_story_not_in_current_returns_friendly_message() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
// Create the directory but put the story in backlog, not current
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"1_backlog",
|
||||
"42_story_not_in_current.md",
|
||||
"---\nname: Not in current\n---\n",
|
||||
);
|
||||
let output = whatsup_cmd(tmp.path(), "42").unwrap();
|
||||
assert!(
|
||||
output.contains("42"),
|
||||
"message should include story number: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("not") || output.contains("Not"),
|
||||
"message should say not found/in progress: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- found in 2_current -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn whatsup_shows_story_name_and_stage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"99_story_my_feature.md",
|
||||
"---\nname: My Feature\n---\n\n## Acceptance Criteria\n\n- [ ] First thing\n- [x] Done thing\n",
|
||||
);
|
||||
let output = whatsup_cmd(tmp.path(), "99").unwrap();
|
||||
assert!(output.contains("99"), "should show story number: {output}");
|
||||
assert!(
|
||||
output.contains("My Feature"),
|
||||
"should show story name: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("In Progress") || output.contains("2_current"),
|
||||
"should show pipeline stage: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsup_shows_acceptance_criteria() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"99_story_criteria_test.md",
|
||||
"---\nname: Criteria Test\n---\n\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n",
|
||||
);
|
||||
let output = whatsup_cmd(tmp.path(), "99").unwrap();
|
||||
assert!(
|
||||
output.contains("First thing"),
|
||||
"should show unchecked criterion: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("Done thing"),
|
||||
"should show checked criterion: {output}"
|
||||
);
|
||||
// 1 of 3 done
|
||||
assert!(
|
||||
output.contains("1/3"),
|
||||
"should show checked/total count: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsup_shows_blocked_field() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"55_story_blocked_story.md",
|
||||
"---\nname: Blocked Story\nblocked: true\n---\n",
|
||||
);
|
||||
let output = whatsup_cmd(tmp.path(), "55").unwrap();
|
||||
assert!(
|
||||
output.contains("blocked"),
|
||||
"should show blocked field: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsup_shows_agent_field() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"55_story_agent_story.md",
|
||||
"---\nname: Agent Story\nagent: coder-1\n---\n",
|
||||
);
|
||||
let output = whatsup_cmd(tmp.path(), "55").unwrap();
|
||||
assert!(
|
||||
output.contains("coder-1"),
|
||||
"should show agent field: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsup_no_worktree_shows_not_created() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"77_story_no_worktree.md",
|
||||
"---\nname: No Worktree\n---\n",
|
||||
);
|
||||
let output = whatsup_cmd(tmp.path(), "77").unwrap();
|
||||
// Branch name should still appear
|
||||
assert!(
|
||||
output.contains("feature/story-77"),
|
||||
"should show branch name: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsup_no_log_shows_no_log_message() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"77_story_no_log.md",
|
||||
"---\nname: No Log\n---\n",
|
||||
);
|
||||
let output = whatsup_cmd(tmp.path(), "77").unwrap();
|
||||
assert!(
|
||||
output.contains("no log") || output.contains("No log") || output.contains("*(no log found)*"),
|
||||
"should indicate no log exists: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- parse_acceptance_criteria ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_criteria_mixed() {
|
||||
let input = "## AC\n- [ ] First\n- [x] Done\n- [X] Also done\n- [ ] Last\n";
|
||||
let result = parse_acceptance_criteria(input);
|
||||
assert_eq!(result.len(), 4);
|
||||
assert_eq!(result[0], (false, "First".to_string()));
|
||||
assert_eq!(result[1], (true, "Done".to_string()));
|
||||
assert_eq!(result[2], (true, "Also done".to_string()));
|
||||
assert_eq!(result[3], (false, "Last".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_criteria_empty() {
|
||||
let input = "# Story\nNo checkboxes here.\n";
|
||||
let result = parse_acceptance_criteria(input);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
// -- read_log_tail -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn read_log_tail_returns_last_n_lines() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let path = tmp.path().join("test.log");
|
||||
let content = (1..=30).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
|
||||
std::fs::write(&path, &content).unwrap();
|
||||
let tail = read_log_tail(&path, 5);
|
||||
let lines: Vec<&str> = tail.lines().collect();
|
||||
assert_eq!(lines.len(), 5);
|
||||
assert_eq!(lines[0], "line 26");
|
||||
assert_eq!(lines[4], "line 30");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_log_tail_fewer_lines_than_n() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let path = tmp.path().join("short.log");
|
||||
std::fs::write(&path, "line A\nline B\n").unwrap();
|
||||
let tail = read_log_tail(&path, 20);
|
||||
assert!(tail.contains("line A"));
|
||||
assert!(tail.contains("line B"));
|
||||
}
|
||||
|
||||
// -- latest_log_file ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn latest_log_file_returns_none_for_missing_dir() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let result = latest_log_file(&tmp.path().join("nonexistent"));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_log_file_finds_log() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let log_path = tmp.path().join("coder-1-sess-abc.log");
|
||||
std::fs::write(&log_path, "some log content\n").unwrap();
|
||||
let result = latest_log_file(tmp.path());
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap(), log_path);
|
||||
}
|
||||
}
|
||||
@@ -32,9 +32,10 @@ pub use config::BotConfig;
|
||||
use crate::agents::AgentPool;
|
||||
use crate::http::context::PermissionForward;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use crate::rebuild::ShutdownReason;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex as TokioMutex, broadcast, mpsc};
|
||||
use tokio::sync::{Mutex as TokioMutex, broadcast, mpsc, watch};
|
||||
|
||||
/// Attempt to start the Matrix bot.
|
||||
///
|
||||
@@ -50,12 +51,17 @@ use tokio::sync::{Mutex as TokioMutex, broadcast, mpsc};
|
||||
/// `prompt_permission` tool. The bot locks it during active chat sessions
|
||||
/// to surface permission prompts to the Matrix room and relay user decisions.
|
||||
///
|
||||
/// `shutdown_rx` is a watch channel that delivers a `ShutdownReason` when the
|
||||
/// server is about to stop (SIGINT/SIGTERM or rebuild). The bot uses this to
|
||||
/// announce the shutdown to all configured rooms before the process exits.
|
||||
///
|
||||
/// Must be called from within a Tokio runtime context (e.g., from `main`).
|
||||
pub fn spawn_bot(
|
||||
project_root: &Path,
|
||||
watcher_tx: broadcast::Sender<WatcherEvent>,
|
||||
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
|
||||
agents: Arc<AgentPool>,
|
||||
shutdown_rx: watch::Receiver<Option<ShutdownReason>>,
|
||||
) {
|
||||
let config = match BotConfig::load(project_root) {
|
||||
Some(c) => c,
|
||||
@@ -83,7 +89,8 @@ pub fn spawn_bot(
|
||||
let root = project_root.to_path_buf();
|
||||
let watcher_rx = watcher_tx.subscribe();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = bot::run_bot(config, root, watcher_rx, perm_rx, agents).await {
|
||||
if let Err(e) = bot::run_bot(config, root, watcher_rx, perm_rx, agents, shutdown_rx).await
|
||||
{
|
||||
crate::slog!("[matrix-bot] Fatal error: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,8 +7,10 @@ use crate::io::story_metadata::parse_front_matter;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use crate::slog;
|
||||
use crate::transport::ChatTransport;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// Human-readable display name for a pipeline stage directory.
|
||||
@@ -99,6 +101,44 @@ pub fn format_error_notification(
|
||||
(plain, html)
|
||||
}
|
||||
|
||||
/// Search all pipeline stages for a story name.
|
||||
///
|
||||
/// Tries each known pipeline stage directory in order and returns the first
|
||||
/// name found. Used for events (like rate-limit warnings) that arrive without
|
||||
/// a known stage.
|
||||
fn find_story_name_any_stage(project_root: &Path, item_id: &str) -> Option<String> {
|
||||
for stage in &["2_current", "3_qa", "4_merge", "1_backlog", "5_done"] {
|
||||
if let Some(name) = read_story_name(project_root, stage, item_id) {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Minimum time between rate-limit notifications for the same agent.
|
||||
const RATE_LIMIT_DEBOUNCE: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Format a rate limit warning notification message.
|
||||
///
|
||||
/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`.
|
||||
pub fn format_rate_limit_notification(
|
||||
item_id: &str,
|
||||
story_name: Option<&str>,
|
||||
agent_name: &str,
|
||||
) -> (String, String) {
|
||||
let number = extract_story_number(item_id).unwrap_or(item_id);
|
||||
let name = story_name.unwrap_or(item_id);
|
||||
|
||||
let plain = format!(
|
||||
"\u{26a0}\u{fe0f} #{number} {name} \u{2014} {agent_name} hit an API rate limit"
|
||||
);
|
||||
let html = format!(
|
||||
"\u{26a0}\u{fe0f} <strong>#{number}</strong> <em>{name}</em> \u{2014} \
|
||||
{agent_name} hit an API rate limit"
|
||||
);
|
||||
(plain, html)
|
||||
}
|
||||
|
||||
/// Spawn a background task that listens for watcher events and posts
|
||||
/// stage-transition notifications to all configured rooms via the
|
||||
/// [`ChatTransport`] abstraction.
|
||||
@@ -110,6 +150,10 @@ pub fn spawn_notification_listener(
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut rx = watcher_rx;
|
||||
// Tracks when a rate-limit notification was last sent for each
|
||||
// "story_id:agent_name" key, to debounce repeated warnings.
|
||||
let mut rate_limit_last_notified: HashMap<String, Instant> = HashMap::new();
|
||||
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(WatcherEvent::WorkItem {
|
||||
@@ -163,6 +207,43 @@ pub fn spawn_notification_listener(
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(WatcherEvent::RateLimitWarning {
|
||||
ref story_id,
|
||||
ref agent_name,
|
||||
}) => {
|
||||
// Debounce: skip if we sent a notification for this agent
|
||||
// within the last RATE_LIMIT_DEBOUNCE seconds.
|
||||
let debounce_key = format!("{story_id}:{agent_name}");
|
||||
let now = Instant::now();
|
||||
if let Some(&last) = rate_limit_last_notified.get(&debounce_key)
|
||||
&& now.duration_since(last) < RATE_LIMIT_DEBOUNCE
|
||||
{
|
||||
slog!(
|
||||
"[matrix-bot] Rate-limit notification debounced for \
|
||||
{story_id}:{agent_name}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
rate_limit_last_notified.insert(debounce_key, now);
|
||||
|
||||
let story_name = find_story_name_any_stage(&project_root, story_id);
|
||||
let (plain, html) = format_rate_limit_notification(
|
||||
story_id,
|
||||
story_name.as_deref(),
|
||||
agent_name,
|
||||
);
|
||||
|
||||
slog!("[matrix-bot] Sending rate-limit notification: {plain}");
|
||||
|
||||
for room_id in &room_ids {
|
||||
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
||||
slog!(
|
||||
"[matrix-bot] Failed to send rate-limit notification \
|
||||
to {room_id}: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {} // Ignore non-work-item events
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
slog!(
|
||||
@@ -183,6 +264,144 @@ pub fn spawn_notification_listener(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use crate::transport::MessageId;
|
||||
|
||||
// ── MockTransport ───────────────────────────────────────────────────────
|
||||
|
||||
type CallLog = Arc<std::sync::Mutex<Vec<(String, String, String)>>>;
|
||||
|
||||
/// Records every `send_message` call for inspection in tests.
|
||||
struct MockTransport {
|
||||
calls: CallLog,
|
||||
}
|
||||
|
||||
impl MockTransport {
|
||||
fn new() -> (Arc<Self>, CallLog) {
|
||||
let calls: CallLog = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
(Arc::new(Self { calls: Arc::clone(&calls) }), calls)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl crate::transport::ChatTransport for MockTransport {
|
||||
async fn send_message(&self, room_id: &str, plain: &str, html: &str) -> Result<MessageId, String> {
|
||||
self.calls.lock().unwrap().push((room_id.to_string(), plain.to_string(), html.to_string()));
|
||||
Ok("mock-msg-id".to_string())
|
||||
}
|
||||
|
||||
async fn edit_message(&self, _room_id: &str, _id: &str, _plain: &str, _html: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── spawn_notification_listener: RateLimitWarning ───────────────────────
|
||||
|
||||
/// AC2 + AC3: when a RateLimitWarning event arrives, send_message is called
|
||||
/// with a notification that names the agent and story.
|
||||
#[tokio::test]
|
||||
async fn rate_limit_warning_sends_notification_with_agent_and_story() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let stage_dir = tmp.path().join(".storkit").join("work").join("2_current");
|
||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
||||
std::fs::write(
|
||||
stage_dir.join("365_story_rate_limit.md"),
|
||||
"---\nname: Rate Limit Test Story\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||
let (transport, calls) = MockTransport::new();
|
||||
|
||||
spawn_notification_listener(
|
||||
transport,
|
||||
vec!["!room123:example.org".to_string()],
|
||||
watcher_rx,
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
|
||||
watcher_tx.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "365_story_rate_limit".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
}).unwrap();
|
||||
|
||||
// Give the spawned task time to process the event.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1, "Expected exactly one notification");
|
||||
let (room_id, plain, _html) = &calls[0];
|
||||
assert_eq!(room_id, "!room123:example.org");
|
||||
assert!(plain.contains("365"), "plain should contain story number");
|
||||
assert!(plain.contains("Rate Limit Test Story"), "plain should contain story name");
|
||||
assert!(plain.contains("coder-1"), "plain should contain agent name");
|
||||
assert!(plain.contains("rate limit"), "plain should mention rate limit");
|
||||
}
|
||||
|
||||
/// AC4: a second RateLimitWarning for the same agent within the debounce
|
||||
/// window must NOT trigger a second notification.
|
||||
#[tokio::test]
|
||||
async fn rate_limit_warning_is_debounced() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||
let (transport, calls) = MockTransport::new();
|
||||
|
||||
spawn_notification_listener(
|
||||
transport,
|
||||
vec!["!room1:example.org".to_string()],
|
||||
watcher_rx,
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
|
||||
// Send the same warning twice in rapid succession.
|
||||
for _ in 0..2 {
|
||||
watcher_tx.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_debounce".to_string(),
|
||||
agent_name: "coder-2".to_string(),
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1, "Debounce should suppress the second notification");
|
||||
}
|
||||
|
||||
/// AC4 (corollary): warnings for different agents are NOT debounced against
|
||||
/// each other — both should produce notifications.
|
||||
#[tokio::test]
|
||||
async fn rate_limit_warnings_for_different_agents_both_notify() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||
let (transport, calls) = MockTransport::new();
|
||||
|
||||
spawn_notification_listener(
|
||||
transport,
|
||||
vec!["!room1:example.org".to_string()],
|
||||
watcher_rx,
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
|
||||
watcher_tx.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
}).unwrap();
|
||||
watcher_tx.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-2".to_string(),
|
||||
}).unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 2, "Different agents should each trigger a notification");
|
||||
}
|
||||
|
||||
// ── stage_display_name ──────────────────────────────────────────────────
|
||||
|
||||
@@ -319,6 +538,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── format_rate_limit_notification ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn format_rate_limit_notification_includes_agent_and_story() {
|
||||
let (plain, html) = format_rate_limit_notification(
|
||||
"365_story_my_feature",
|
||||
Some("My Feature"),
|
||||
"coder-2",
|
||||
);
|
||||
assert_eq!(
|
||||
plain,
|
||||
"\u{26a0}\u{fe0f} #365 My Feature \u{2014} coder-2 hit an API rate limit"
|
||||
);
|
||||
assert_eq!(
|
||||
html,
|
||||
"\u{26a0}\u{fe0f} <strong>#365</strong> <em>My Feature</em> \u{2014} coder-2 hit an API rate limit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_rate_limit_notification_falls_back_to_item_id() {
|
||||
let (plain, _html) =
|
||||
format_rate_limit_notification("42_story_thing", None, "coder-1");
|
||||
assert_eq!(
|
||||
plain,
|
||||
"\u{26a0}\u{fe0f} #42 42_story_thing \u{2014} coder-1 hit an API rate limit"
|
||||
);
|
||||
}
|
||||
|
||||
// ── format_stage_notification ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -50,7 +50,7 @@ pub async fn handle_rebuild(
|
||||
agents: &Arc<AgentPool>,
|
||||
) -> String {
|
||||
crate::slog!("[matrix-bot] rebuild command received (bot={bot_name})");
|
||||
match crate::rebuild::rebuild_and_restart(agents, project_root).await {
|
||||
match crate::rebuild::rebuild_and_restart(agents, project_root, None).await {
|
||||
Ok(msg) => msg,
|
||||
Err(e) => format!("Rebuild failed: {e}"),
|
||||
}
|
||||
|
||||
@@ -2,7 +2,72 @@
|
||||
|
||||
use crate::agents::AgentPool;
|
||||
use crate::slog;
|
||||
use crate::transport::ChatTransport;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
// ── Shutdown notification ────────────────────────────────────────────────
|
||||
|
||||
/// The reason the server is shutting down.
|
||||
///
|
||||
/// Used to select the appropriate shutdown message sent to active bot channels.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ShutdownReason {
|
||||
/// The operator stopped the server manually (SIGINT / SIGTERM / ctrl-c).
|
||||
Manual,
|
||||
/// A rebuild-and-restart was requested (via MCP tool or bot command).
|
||||
Rebuild,
|
||||
}
|
||||
|
||||
/// Sends a shutdown announcement to all configured bot channels.
|
||||
///
|
||||
/// Wraps a [`ChatTransport`] together with the list of channel/room IDs the
|
||||
/// bot is active in. Calling [`notify`] is best-effort — failures are logged
|
||||
/// but never propagate, so shutdown is never blocked by a failed send.
|
||||
pub struct BotShutdownNotifier {
|
||||
transport: Arc<dyn ChatTransport>,
|
||||
channels: Vec<String>,
|
||||
bot_name: String,
|
||||
}
|
||||
|
||||
impl BotShutdownNotifier {
|
||||
pub fn new(
|
||||
transport: Arc<dyn ChatTransport>,
|
||||
channels: Vec<String>,
|
||||
bot_name: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
transport,
|
||||
channels,
|
||||
bot_name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a shutdown message to all configured channels.
|
||||
///
|
||||
/// Errors from individual sends are logged and ignored so that a single
|
||||
/// failing channel does not prevent messages from reaching the rest.
|
||||
pub async fn notify(&self, reason: ShutdownReason) {
|
||||
let msg = match reason {
|
||||
ShutdownReason::Manual => {
|
||||
format!("{} is going offline (server stopped).", self.bot_name)
|
||||
}
|
||||
ShutdownReason::Rebuild => {
|
||||
format!(
|
||||
"{} is going offline to pick up a new build.",
|
||||
self.bot_name
|
||||
)
|
||||
}
|
||||
};
|
||||
for channel in &self.channels {
|
||||
if let Err(e) = self.transport.send_message(channel, &msg, &msg).await {
|
||||
slog!("[shutdown] Failed to send shutdown message to {channel}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rebuild ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Rebuild the server binary and re-exec.
|
||||
///
|
||||
@@ -10,9 +75,14 @@ use std::path::Path;
|
||||
/// 2. Runs `cargo build [-p storkit]` from the workspace root, matching
|
||||
/// the current build profile (debug or release).
|
||||
/// 3. If the build fails, returns the build error (server stays up).
|
||||
/// 4. If the build succeeds, re-execs the process with the new binary via
|
||||
/// `std::os::unix::process::CommandExt::exec()`.
|
||||
pub async fn rebuild_and_restart(agents: &AgentPool, project_root: &Path) -> Result<String, String> {
|
||||
/// 4. If the build succeeds, sends a best-effort shutdown notification (if a
|
||||
/// [`BotShutdownNotifier`] is provided), then re-execs the process with
|
||||
/// the new binary via `std::os::unix::process::CommandExt::exec()`.
|
||||
pub async fn rebuild_and_restart(
|
||||
agents: &AgentPool,
|
||||
project_root: &Path,
|
||||
notifier: Option<&BotShutdownNotifier>,
|
||||
) -> Result<String, String> {
|
||||
slog!("[rebuild] Rebuild and restart requested");
|
||||
|
||||
// 1. Gracefully stop all running agents.
|
||||
@@ -69,10 +139,23 @@ pub async fn rebuild_and_restart(agents: &AgentPool, project_root: &Path) -> Res
|
||||
|
||||
slog!("[rebuild] Build succeeded, re-execing with new binary");
|
||||
|
||||
// 4. Re-exec with the new binary.
|
||||
// Collect current argv so we preserve any CLI arguments (e.g. project path).
|
||||
let current_exe =
|
||||
std::env::current_exe().map_err(|e| format!("Cannot determine current executable: {e}"))?;
|
||||
// 4. Send shutdown notification before replacing the process so that chat
|
||||
// participants know the bot is going offline. Best-effort only — we
|
||||
// do not abort the rebuild if the send fails.
|
||||
if let Some(n) = notifier {
|
||||
n.notify(ShutdownReason::Rebuild).await;
|
||||
}
|
||||
|
||||
// 5. Re-exec with the new binary.
|
||||
// Use the cargo output path rather than current_exe() so that rebuilds
|
||||
// inside Docker work correctly — the running binary may be installed at
|
||||
// /usr/local/bin/storkit (read-only) while cargo writes the new binary
|
||||
// to /app/target/release/storkit (a writable volume).
|
||||
let new_exe = if cfg!(debug_assertions) {
|
||||
workspace_root.join("target/debug/storkit")
|
||||
} else {
|
||||
workspace_root.join("target/release/storkit")
|
||||
};
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// Remove the port file before re-exec so the new process can write its own.
|
||||
@@ -89,10 +172,177 @@ pub async fn rebuild_and_restart(agents: &AgentPool, project_root: &Path) -> Res
|
||||
// Use exec() to replace the current process.
|
||||
// This never returns on success.
|
||||
use std::os::unix::process::CommandExt;
|
||||
let err = std::process::Command::new(¤t_exe)
|
||||
let err = std::process::Command::new(&new_exe)
|
||||
.args(&args[1..])
|
||||
.exec();
|
||||
|
||||
// If we get here, exec() failed.
|
||||
Err(format!("Failed to exec new binary: {err}"))
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use crate::transport::MessageId;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// In-memory transport that records sent messages.
|
||||
struct CapturingTransport {
|
||||
sent: Mutex<Vec<(String, String)>>,
|
||||
fail: bool,
|
||||
}
|
||||
|
||||
impl CapturingTransport {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
sent: Mutex::new(Vec::new()),
|
||||
fail: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn failing() -> Self {
|
||||
Self {
|
||||
sent: Mutex::new(Vec::new()),
|
||||
fail: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn messages(&self) -> Vec<(String, String)> {
|
||||
self.sent.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatTransport for CapturingTransport {
|
||||
async fn send_message(
|
||||
&self,
|
||||
room_id: &str,
|
||||
plain: &str,
|
||||
_html: &str,
|
||||
) -> Result<MessageId, String> {
|
||||
if self.fail {
|
||||
return Err("send failed".to_string());
|
||||
}
|
||||
self.sent
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((room_id.to_string(), plain.to_string()));
|
||||
Ok("msg-id".to_string())
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&self,
|
||||
_room_id: &str,
|
||||
_original_message_id: &str,
|
||||
_plain: &str,
|
||||
_html: &str,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notify_manual_sends_to_all_channels() {
|
||||
let transport = Arc::new(CapturingTransport::new());
|
||||
let notifier = BotShutdownNotifier::new(
|
||||
Arc::clone(&transport) as Arc<dyn ChatTransport>,
|
||||
vec!["#channel1".to_string(), "#channel2".to_string()],
|
||||
"Timmy".to_string(),
|
||||
);
|
||||
|
||||
notifier.notify(ShutdownReason::Manual).await;
|
||||
|
||||
let msgs = transport.messages();
|
||||
assert_eq!(msgs.len(), 2);
|
||||
assert_eq!(msgs[0].0, "#channel1");
|
||||
assert_eq!(msgs[1].0, "#channel2");
|
||||
// Message must indicate manual stop.
|
||||
assert!(
|
||||
msgs[0].1.contains("offline"),
|
||||
"expected 'offline' in manual message: {}",
|
||||
msgs[0].1
|
||||
);
|
||||
assert!(
|
||||
msgs[0].1.contains("stopped") || msgs[0].1.contains("manual"),
|
||||
"expected reason in manual message: {}",
|
||||
msgs[0].1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notify_rebuild_sends_rebuild_reason() {
|
||||
let transport = Arc::new(CapturingTransport::new());
|
||||
let notifier = BotShutdownNotifier::new(
|
||||
Arc::clone(&transport) as Arc<dyn ChatTransport>,
|
||||
vec!["#general".to_string()],
|
||||
"Timmy".to_string(),
|
||||
);
|
||||
|
||||
notifier.notify(ShutdownReason::Rebuild).await;
|
||||
|
||||
let msgs = transport.messages();
|
||||
assert_eq!(msgs.len(), 1);
|
||||
// Message must indicate rebuild, not manual stop.
|
||||
assert!(
|
||||
msgs[0].1.contains("build") || msgs[0].1.contains("rebuild"),
|
||||
"expected rebuild reason in message: {}",
|
||||
msgs[0].1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notify_manual_and_rebuild_messages_are_distinct() {
|
||||
let transport_a = Arc::new(CapturingTransport::new());
|
||||
let notifier_a = BotShutdownNotifier::new(
|
||||
Arc::clone(&transport_a) as Arc<dyn ChatTransport>,
|
||||
vec!["C1".to_string()],
|
||||
"Bot".to_string(),
|
||||
);
|
||||
notifier_a.notify(ShutdownReason::Manual).await;
|
||||
|
||||
let transport_b = Arc::new(CapturingTransport::new());
|
||||
let notifier_b = BotShutdownNotifier::new(
|
||||
Arc::clone(&transport_b) as Arc<dyn ChatTransport>,
|
||||
vec!["C1".to_string()],
|
||||
"Bot".to_string(),
|
||||
);
|
||||
notifier_b.notify(ShutdownReason::Rebuild).await;
|
||||
|
||||
let manual_msg = &transport_a.messages()[0].1;
|
||||
let rebuild_msg = &transport_b.messages()[0].1;
|
||||
assert_ne!(manual_msg, rebuild_msg, "manual and rebuild messages must differ");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notify_is_best_effort_failing_send_does_not_panic() {
|
||||
// A transport that always fails should not cause notify() to panic or
|
||||
// return an error — the failure is swallowed silently.
|
||||
let transport = Arc::new(CapturingTransport::failing());
|
||||
let notifier = BotShutdownNotifier::new(
|
||||
Arc::clone(&transport) as Arc<dyn ChatTransport>,
|
||||
vec!["#channel".to_string()],
|
||||
"Timmy".to_string(),
|
||||
);
|
||||
// Should complete without panicking.
|
||||
notifier.notify(ShutdownReason::Manual).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notify_with_no_channels_is_noop() {
|
||||
let transport = Arc::new(CapturingTransport::new());
|
||||
let notifier = BotShutdownNotifier::new(
|
||||
Arc::clone(&transport) as Arc<dyn ChatTransport>,
|
||||
vec![],
|
||||
"Timmy".to_string(),
|
||||
);
|
||||
notifier.notify(ShutdownReason::Manual).await;
|
||||
assert!(transport.messages().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +253,23 @@ fn remove_worktree_sync(project_root: &Path, wt_path: &Path, branch: &str) -> Re
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
slog!("[worktree] remove warning: {stderr}");
|
||||
if stderr.contains("not a working tree") {
|
||||
// Orphaned directory: git doesn't recognise it as a worktree.
|
||||
// Remove the directory directly and prune stale git metadata.
|
||||
slog!(
|
||||
"[worktree] orphaned worktree detected, removing directory: {}",
|
||||
wt_path.display()
|
||||
);
|
||||
if let Err(e) = std::fs::remove_dir_all(wt_path) {
|
||||
slog!("[worktree] failed to remove orphaned directory: {e}");
|
||||
}
|
||||
let _ = Command::new("git")
|
||||
.args(["worktree", "prune"])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
} else {
|
||||
slog!("[worktree] remove warning: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
// Delete branch (best effort)
|
||||
@@ -630,6 +646,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_worktree_sync_removes_orphaned_directory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let project_root = tmp.path().join("my-project");
|
||||
fs::create_dir_all(&project_root).unwrap();
|
||||
init_git_repo(&project_root);
|
||||
|
||||
// Create a directory that looks like a worktree but isn't registered with git
|
||||
let wt_path = project_root
|
||||
.join(".storkit")
|
||||
.join("worktrees")
|
||||
.join("orphan");
|
||||
fs::create_dir_all(&wt_path).unwrap();
|
||||
fs::write(wt_path.join("some_file.txt"), "stale").unwrap();
|
||||
assert!(wt_path.exists());
|
||||
|
||||
// git worktree remove will fail with "not a working tree",
|
||||
// but the fallback should rm -rf the directory
|
||||
remove_worktree_sync(&project_root, &wt_path, "feature/orphan").unwrap();
|
||||
assert!(!wt_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_worktree_sync_cleans_up_directory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user