Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 327163eb60 | |||
| 8f1dd0ad13 | |||
| 28adef9739 | |||
| badfabcf5e | |||
| d0d2b17484 | |||
| efe434ede3 | |||
| df5ba8ebab | |||
| ff1149750b | |||
| d824dc4b73 | |||
| 28777b0c77 | |||
| f412c7dee6 | |||
| 44fe52195e | |||
| 979cf39228 | |||
| 10d3517648 | |||
| 8a62b62819 | |||
| 2e412af4dd | |||
| 39b1964b68 | |||
| bd04c6acd7 | |||
| 7977b7c5f8 | |||
| d618bc3b32 | |||
| 845b85e7a7 | |||
| ed2526ce41 | |||
| 05655847d8 | |||
| 0cb68e1de9 | |||
| cd189cfe60 | |||
| 69dab063a8 | |||
| 5806156af3 | |||
| 12497eb4f1 | |||
| 8b5275a30b | |||
| 5536803ad6 | |||
| c4462e2918 |
@@ -9,6 +9,7 @@
|
|||||||
store.json
|
store.json
|
||||||
.huskies_port
|
.huskies_port
|
||||||
.huskies/bot.toml.bak
|
.huskies/bot.toml.bak
|
||||||
|
.huskies/build_hash
|
||||||
|
|
||||||
# Coverage report (generated by script/test_coverage, not tracked in git)
|
# Coverage report (generated by script/test_coverage, not tracked in git)
|
||||||
.coverage_report.json
|
.coverage_report.json
|
||||||
|
|||||||
+71
-2
@@ -72,10 +72,79 @@ Consult `specs/tech/STACK.md` for project-specific quality gates.
|
|||||||
| `status` | Get story details, ACs, git state |
|
| `status` | Get story details, ACs, git state |
|
||||||
| `get_story_todos` | List unchecked acceptance criteria |
|
| `get_story_todos` | List unchecked acceptance criteria |
|
||||||
| `check_criterion` | Mark an AC as done |
|
| `check_criterion` | Mark an AC as done |
|
||||||
| `run_tests` | Start test suite (async, returns immediately) |
|
| `run_tests` | Start test suite (blocks until complete) |
|
||||||
| `get_test_result` | Poll for test completion |
|
|
||||||
| `git_status` | Worktree git status |
|
| `git_status` | Worktree git status |
|
||||||
| `git_add` | Stage files |
|
| `git_add` | Stage files |
|
||||||
| `git_commit` | Commit staged changes |
|
| `git_commit` | Commit staged changes |
|
||||||
| `git_diff` | View changes |
|
| `git_diff` | View changes |
|
||||||
| `git_log` | View commit history |
|
| `git_log` | View commit history |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Deployment Modes
|
||||||
|
|
||||||
|
Huskies has three modes, all from the same binary:
|
||||||
|
|
||||||
|
### Standard (single project)
|
||||||
|
|
||||||
|
```
|
||||||
|
huskies [--port 3001] /path/to/project
|
||||||
|
```
|
||||||
|
|
||||||
|
Full server: web UI, MCP endpoint, chat bot, agent pool, pipeline. One project per instance.
|
||||||
|
|
||||||
|
### Headless Build Agent
|
||||||
|
|
||||||
|
```
|
||||||
|
huskies --rendezvous ws://host:port/crdt-sync
|
||||||
|
```
|
||||||
|
|
||||||
|
Connects to an existing huskies instance as a worker node. Syncs the CRDT, claims work from the pipeline, runs agents. No web UI, no chat — just a build worker. Use this to add more compute to a project by running extra containers.
|
||||||
|
|
||||||
|
### Gateway (multi-project)
|
||||||
|
|
||||||
|
```
|
||||||
|
huskies --gateway [--port 3000] /path/to/config
|
||||||
|
```
|
||||||
|
|
||||||
|
Lightweight proxy that sits in front of multiple project containers. Reads a `projects.toml` that maps project names to container URLs:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[projects.huskies]
|
||||||
|
url = "http://huskies:3001"
|
||||||
|
|
||||||
|
[projects.robot-studio]
|
||||||
|
url = "http://robot-studio:3002"
|
||||||
|
```
|
||||||
|
|
||||||
|
The gateway presents a unified MCP surface to the chat agent. All tool calls are proxied to the active project's container. Gateway-specific tools:
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `switch_project` | Change the active project |
|
||||||
|
| `gateway_status` | Show active project and list all registered projects |
|
||||||
|
| `gateway_health` | Health check all containers |
|
||||||
|
|
||||||
|
### Example: multi-project Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
gateway:
|
||||||
|
image: huskies
|
||||||
|
command: ["huskies", "--gateway", "--port", "3000", "/workspace"]
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:3000"
|
||||||
|
depends_on: [huskies, robot-studio]
|
||||||
|
|
||||||
|
huskies:
|
||||||
|
image: huskies
|
||||||
|
volumes:
|
||||||
|
- /path/to/huskies:/workspace
|
||||||
|
|
||||||
|
robot-studio:
|
||||||
|
image: huskies
|
||||||
|
environment:
|
||||||
|
- HUSKIES_PORT=3002
|
||||||
|
volumes:
|
||||||
|
- /path/to/robot-studio:/workspace
|
||||||
|
```
|
||||||
|
|||||||
+10
-13
@@ -224,7 +224,7 @@ system_prompt = "You are a QA agent. Your job is read-only: run quality gates, v
|
|||||||
name = "mergemaster"
|
name = "mergemaster"
|
||||||
stage = "mergemaster"
|
stage = "mergemaster"
|
||||||
role = "Merges completed coder work into master, runs quality gates, archives stories, and cleans up worktrees."
|
role = "Merges completed coder work into master, runs quality gates, archives stories, and cleans up worktrees."
|
||||||
model = "sonnet"
|
model = "opus"
|
||||||
max_turns = 30
|
max_turns = 30
|
||||||
max_budget_usd = 5.00
|
max_budget_usd = 5.00
|
||||||
prompt = """You are the mergemaster agent for story {{story_id}}. Your job is to merge the completed coder work into master.
|
prompt = """You are the mergemaster agent for story {{story_id}}. Your job is to merge the completed coder work into master.
|
||||||
@@ -253,33 +253,30 @@ When the auto-resolver fails, you have access to the merge worktree at `.story_k
|
|||||||
6. Call the `run_tests` MCP tool to start tests, then poll `get_test_result` until complete
|
6. Call the `run_tests` MCP tool to start tests, then poll `get_test_result` until complete
|
||||||
7. If it compiles, commit and re-trigger merge_agent_work
|
7. If it compiles, commit and re-trigger merge_agent_work
|
||||||
|
|
||||||
### Common conflict patterns in this project:
|
### Common conflict patterns:
|
||||||
|
|
||||||
**Story file rename/rename conflicts:** Both branches moved the story .md file to different pipeline directories. Resolution: `git rm` both sides — story files in `work/2_current/`, `work/3_qa/`, `work/4_merge/` are gitignored and don't need to be committed.
|
**Story file rename/rename conflicts:** Both branches moved the story .md file to different pipeline directories. Resolution: `git rm` both sides — story files in pipeline directories are gitignored and don't need to be committed.
|
||||||
|
|
||||||
**bot.rs tokio::select! conflicts:** Master has a `tokio::select!` loop in `handle_message()` that handles permission forwarding (story 275). Feature branches created before story 275 have a simpler direct `provider.chat_stream().await` call. Resolution: KEEP master's tokio::select! loop. Integrate only the feature's new logic (e.g. typing indicators, new callbacks) into the existing loop structure. Do NOT replace the loop with the old direct call.
|
|
||||||
|
|
||||||
**Duplicate functions/imports:** The auto-resolver keeps both sides, producing duplicates. Resolution: keep one copy (prefer master's version), delete the duplicate.
|
**Duplicate functions/imports:** The auto-resolver keeps both sides, producing duplicates. Resolution: keep one copy (prefer master's version), delete the duplicate.
|
||||||
|
|
||||||
**Formatting-only conflicts:** Both sides reformatted the same code differently. Resolution: pick either side (prefer master).
|
**Formatting-only conflicts:** Both sides reformatted the same code differently. Resolution: pick either side (prefer master).
|
||||||
|
|
||||||
|
**IMPORTANT: After resolving ANY conflict or fixing ANY gate failure in the merge workspace, use the `run_lint` MCP tool to check formatting, then `run_tests` to verify everything passes before recommitting.** The auto-resolver frequently produces code that compiles but fails formatting or linting checks.
|
||||||
|
|
||||||
## Fixing Gate Failures
|
## Fixing Gate Failures
|
||||||
|
|
||||||
If quality gates fail, attempt to fix issues yourself in the merge worktree. Use the run_tests MCP tool (then poll get_test_result) to verify — do not run script/test via Bash.
|
If quality gates fail, attempt to fix issues yourself in the merge workspace. Use the run_tests MCP tool to verify before recommitting.
|
||||||
|
|
||||||
**Fix yourself (up to 3 attempts total):**
|
**Fix yourself (up to 3 attempts total):**
|
||||||
- Syntax errors (missing semicolons, brackets, commas)
|
- Syntax errors
|
||||||
- Duplicate definitions from merge artifacts
|
- Duplicate definitions from merge artifacts
|
||||||
- Simple type annotation errors
|
- Unused import warnings
|
||||||
- Unused import warnings flagged by clippy
|
- Formatting issues that block linting
|
||||||
- Mismatched braces from bad conflict resolution
|
|
||||||
- Trivial formatting issues that block compilation or linting
|
|
||||||
|
|
||||||
**Report to human without attempting a fix:**
|
**Report to human without attempting a fix:**
|
||||||
- Logic errors or incorrect business logic
|
- Logic errors or incorrect business logic
|
||||||
- Missing function implementations
|
- Missing function implementations
|
||||||
- Architectural changes required
|
- Architectural changes required
|
||||||
- Non-trivial refactoring needed
|
|
||||||
|
|
||||||
**Max retry limit:** If gates still fail after 3 fix attempts, call report_merge_failure to record the failure, then stop immediately and report the full gate output to the human.
|
**Max retry limit:** If gates still fail after 3 fix attempts, call report_merge_failure to record the failure, then stop immediately and report the full gate output to the human.
|
||||||
|
|
||||||
@@ -290,4 +287,4 @@ If quality gates fail, attempt to fix issues yourself in the merge worktree. Use
|
|||||||
- Report conflict resolution outcomes clearly
|
- Report conflict resolution outcomes clearly
|
||||||
- Report gate failures with full output so the human can act if needed
|
- Report gate failures with full output so the human can act if needed
|
||||||
- The server automatically runs acceptance gates when your process exits"""
|
- The server automatically runs acceptance gates when your process exits"""
|
||||||
system_prompt = "You are the mergemaster agent. Your primary job is to merge feature branches to master. First try the merge_agent_work MCP tool. If the auto-resolver fails on complex conflicts, resolve them yourself in the merge worktree — you are an opus-class agent capable of understanding both sides of a conflict and producing correct merged code. Common patterns: keep master's tokio::select! permission loop in bot.rs, discard story file rename conflicts (gitignored), remove duplicate definitions. After resolving, verify compilation before re-triggering merge. CRITICAL: Never manually move story files or call accept_story. After 3 failed fix attempts, call report_merge_failure and stop."
|
system_prompt = "You are the mergemaster agent. Your primary job is to merge feature branches to master. First try the merge_agent_work MCP tool. If the auto-resolver fails on complex conflicts, resolve them yourself in the merge workspace. Common patterns: discard story file rename conflicts (gitignored), remove duplicate definitions/imports. After resolving, verify with run_tests MCP tool before re-triggering merge. CRITICAL: Never manually move story files or call accept_story. After 3 failed fix attempts, call report_merge_failure and stop."
|
||||||
|
|||||||
-70
@@ -1,70 +0,0 @@
|
|||||||
---
|
|
||||||
name: "Stale 1_backlog filesystem shadows get re-promoted by rate-limit retry timers, yanking successfully-merged stories back into current"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Bug 510: Stale 1_backlog filesystem shadows get re-promoted by rate-limit retry timers, yanking successfully-merged stories back into current
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
After a story successfully completes the entire pipeline — coder runs, gates pass, mergemaster squashes the feature branch to master, lifecycle moves the story from `4_merge/` to `5_done/` — a stale filesystem shadow of the story's markdown file remains in `.huskies/work/1_backlog/`. This shadow is a leftover from the 491/492 migration: story state moved to the database as the source of truth, but the lifecycle move logic in `lifecycle.rs` is still operating on the filesystem and doesn't fully clean up after successful pipeline completions.
|
|
||||||
|
|
||||||
When a rate-limit retry timer subsequently fires for that story (rate limits get scheduled by story 496's auto-retry whenever an agent is hard-blocked, and bug 501 means those timers aren't cancelled on successful completion either), the timer fire path calls `move_story_to_current()`, which uses the **filesystem-only** `move_item` helper. That helper finds the stale `1_backlog/` shadow and "moves" it to `2_current/` — even though the story is correctly in `5_done` in the database.
|
|
||||||
|
|
||||||
Net effect: a fully-merged, archived-to-done story suddenly reappears in `current` with a fresh coder spawned on it. The matrix bot sends `Done → Current` notifications. The agent burns tokens working on a story whose work has already shipped to master. The user sees the story flapping and assumes the merge didn't actually happen.
|
|
||||||
|
|
||||||
**Observed live on 2026-04-09 against story 503:**
|
|
||||||
|
|
||||||
```
|
|
||||||
18:31:32 [lifecycle] Moved '503_…' from work/4_merge/ to work/5_done/
|
|
||||||
18:31:32 [bot] Sending stage notification: 🎉 #503 … — Merge → Done
|
|
||||||
18:32:21 [timer] Timer fired for story 503_…
|
|
||||||
18:32:21 [lifecycle] Moved '503_…' from work/1_backlog/ to work/2_current/ ← stale shadow!
|
|
||||||
18:32:21 [auto-assign] Assigning 'coder-1' to '503_…' in 2_current/
|
|
||||||
```
|
|
||||||
|
|
||||||
The merge to master persisted (commit `41515e3b` is on master). Only the *pipeline state* got corrupted by the stale shadow being re-promoted.
|
|
||||||
|
|
||||||
This is **distinct from bug 501** (which is about manual `stop_agent` not cancelling timers) but compounds it: 501 is about user-initiated stops, this is about successful pipeline completions. Both share a root cause — the rate-limit retry timer system has no notion of "this story has moved on, cancel any pending retries" — but the *consequences* of this bug are worse because the timer fires successfully and re-creates work that shouldn't exist.
|
|
||||||
|
|
||||||
Also distinct from bug 502 (mergemaster stage-mismatch) which has been fixed.
|
|
||||||
|
|
||||||
The deeper architectural problem this exposes: **`lifecycle.rs::move_item` and `move_story_to_current` are still on the legacy filesystem path** while the rest of the pipeline (491/492) has moved to DB-as-source-of-truth. The filesystem shadows in `.huskies/work/N_stage/` are supposed to be a *materialized rendering* of the DB state, not a parallel source of truth — but `move_item` treats them as authoritative.
|
|
||||||
|
|
||||||
## How to Reproduce
|
|
||||||
|
|
||||||
1. Take any story through the full pipeline successfully — coder runs, gates pass, mergemaster squashes to master, story moves to `5_done`.
|
|
||||||
2. While the story was in flight, ensure at least one coder run hit a hard rate limit (so a retry timer was scheduled). Bug 501 means that timer survives the successful completion.
|
|
||||||
3. Verify post-completion state:
|
|
||||||
- `SELECT stage FROM pipeline_items WHERE id = 'N_story_X';` returns `5_done` ✓
|
|
||||||
- `ls .huskies/work/1_backlog/N_story_X.md` shows the file STILL EXISTS (the stale shadow)
|
|
||||||
- `cat .huskies/timers.json` shows a pending entry for `N_story_X` with a future `scheduled_at`
|
|
||||||
4. Wait for the timer to fire (default ~5 minutes after the last rate-limit hit).
|
|
||||||
|
|
||||||
## Actual Result
|
|
||||||
|
|
||||||
When the timer fires:
|
|
||||||
- The `[timer] Timer fired` log line appears for the already-done story
|
|
||||||
- `move_story_to_current` is called and finds the stale `1_backlog/N_story_X.md` shadow
|
|
||||||
- Lifecycle log: `[lifecycle] Moved 'N_…' from work/1_backlog/ to work/2_current/`
|
|
||||||
- Auto-assign sees the story in `2_current/` and spawns a coder
|
|
||||||
- Matrix bot sends `Done → Current` (and then later `Current → Current` etc.) stage notifications, spamming the room
|
|
||||||
- The new coder works on a story whose work is already shipped on master, burning tokens
|
|
||||||
- The story is now visible in BOTH `5_done` (via DB) AND `2_current` (via filesystem shadow), depending on which view the consumer reads
|
|
||||||
- The actual master commit is unaffected — the merge that already landed is still there. Only the *pipeline state* is corrupted.
|
|
||||||
|
|
||||||
## Expected Result
|
|
||||||
|
|
||||||
Successful pipeline completions must fully clean up the story's filesystem shadows. After `move_story_to_done` runs, `.huskies/work/1_backlog/N_story_X.md` (and any other stage shadow) for that story must not exist.
|
|
||||||
|
|
||||||
Additionally — and this is the more general fix — the rate-limit retry timer system must cancel any pending timers for a story when that story successfully completes the pipeline. This is a sibling fix to bug 501 (which is about cancelling on manual stop): both manual stop and successful completion should mean "no more retries".
|
|
||||||
|
|
||||||
The deepest fix is to migrate `lifecycle.rs::move_item` off the filesystem path and onto the DB path so the shadow files can be torn down entirely (or made strictly read-only renderings). That's a larger change that probably wants its own story, not a bug fix.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- [ ] After a story moves to 5_done via the normal pipeline path (mergemaster success), the filesystem shadow at .huskies/work/1_backlog/N_story_X.md is removed (and any other stage shadows are also removed)
|
|
||||||
- [ ] When a story moves to 5_done, any pending rate-limit retry timer for that story is cancelled (the entry is removed from timers.json before the file is persisted)
|
|
||||||
- [ ] Regression test: simulate the full repro sequence — run a story through the pipeline with a mid-flight rate limit, complete the merge, fast-forward to the timer fire, assert (a) the story stays in 5_done, (b) no agent is spawned, (c) no Done→Current notification fires
|
|
||||||
- [ ] No regression in bug 501's fix for manual-stop timer cancellation
|
|
||||||
- [ ] Filesystem shadow cleanup is symmetric — also runs on delete_story, move_story to backlog, etc., not just the done path
|
|
||||||
- [ ] The matrix bot does not spam Done→Current notifications for stories whose work has actually completed
|
|
||||||
Generated
+45
-122
@@ -482,9 +482,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.0"
|
version = "2.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
@@ -1769,21 +1769,6 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "foreign-types"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
|
||||||
dependencies = [
|
|
||||||
"foreign-types-shared",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "foreign-types-shared"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -2303,7 +2288,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "huskies"
|
name = "huskies"
|
||||||
version = "0.10.0"
|
version = "0.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -2381,9 +2366,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-rustls"
|
name = "hyper-rustls"
|
||||||
version = "0.27.8"
|
version = "0.27.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2b52f86d1d4bc0d6b4e6826d960b1b333217e07d36b882dca570a5e1c48895b"
|
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"http",
|
"http",
|
||||||
"hyper",
|
"hyper",
|
||||||
@@ -2658,7 +2643,7 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199"
|
checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.1",
|
||||||
"inotify-sys",
|
"inotify-sys",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -2878,9 +2863,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.184"
|
version = "0.2.185"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libm"
|
name = "libm"
|
||||||
@@ -2894,7 +2879,7 @@ version = "0.1.16"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.1",
|
||||||
"libc",
|
"libc",
|
||||||
"plain",
|
"plain",
|
||||||
"redox_syscall 0.7.4",
|
"redox_syscall 0.7.4",
|
||||||
@@ -3113,7 +3098,7 @@ checksum = "70f404a390ff98a73c426b1496b169be60ce6a93723a9a664e579d978a84c5e4"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"as_variant",
|
"as_variant",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.1",
|
||||||
"decancer",
|
"decancer",
|
||||||
"eyeball",
|
"eyeball",
|
||||||
"eyeball-im",
|
"eyeball-im",
|
||||||
@@ -3401,7 +3386,7 @@ dependencies = [
|
|||||||
"hyper-util",
|
"hyper-util",
|
||||||
"log",
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rand 0.9.3",
|
"rand 0.9.4",
|
||||||
"regex",
|
"regex",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
@@ -3427,23 +3412,6 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "native-tls"
|
|
||||||
version = "0.2.18"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"log",
|
|
||||||
"openssl",
|
|
||||||
"openssl-probe",
|
|
||||||
"openssl-sys",
|
|
||||||
"schannel",
|
|
||||||
"security-framework",
|
|
||||||
"security-framework-sys",
|
|
||||||
"tempfile",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "new_debug_unreachable"
|
name = "new_debug_unreachable"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -3456,7 +3424,7 @@ version = "0.28.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
|
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases 0.1.1",
|
"cfg_aliases 0.1.1",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -3468,7 +3436,7 @@ version = "0.30.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases 0.2.1",
|
"cfg_aliases 0.2.1",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -3490,7 +3458,7 @@ version = "8.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
|
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.1",
|
||||||
"fsevent-sys",
|
"fsevent-sys",
|
||||||
"inotify",
|
"inotify",
|
||||||
"kqueue",
|
"kqueue",
|
||||||
@@ -3508,7 +3476,7 @@ version = "2.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
|
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3630,50 +3598,12 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "openssl"
|
|
||||||
version = "0.10.77"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.11.0",
|
|
||||||
"cfg-if",
|
|
||||||
"foreign-types",
|
|
||||||
"libc",
|
|
||||||
"once_cell",
|
|
||||||
"openssl-macros",
|
|
||||||
"openssl-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "openssl-macros"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "openssl-sys"
|
|
||||||
version = "0.9.113"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"libc",
|
|
||||||
"pkg-config",
|
|
||||||
"vcpkg",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "os_str_bytes"
|
name = "os_str_bytes"
|
||||||
version = "6.6.1"
|
version = "6.6.1"
|
||||||
@@ -4186,7 +4116,7 @@ version = "0.13.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad"
|
checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.1",
|
||||||
"memchr",
|
"memchr",
|
||||||
"pulldown-cmark-escape",
|
"pulldown-cmark-escape",
|
||||||
"unicase",
|
"unicase",
|
||||||
@@ -4238,7 +4168,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"lru-slab",
|
"lru-slab",
|
||||||
"rand 0.9.3",
|
"rand 0.9.4",
|
||||||
"ring",
|
"ring",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -4312,9 +4242,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.9.3"
|
version = "0.9.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166"
|
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand_chacha 0.9.0",
|
"rand_chacha 0.9.0",
|
||||||
"rand_core 0.9.5",
|
"rand_core 0.9.5",
|
||||||
@@ -4415,9 +4345,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rayon"
|
name = "rayon"
|
||||||
version = "1.11.0"
|
version = "1.12.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
|
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"either",
|
"either",
|
||||||
"rayon-core",
|
"rayon-core",
|
||||||
@@ -4465,7 +4395,7 @@ version = "0.5.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4474,7 +4404,7 @@ version = "0.7.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
|
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4886,7 +4816,7 @@ version = "0.37.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
|
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.1",
|
||||||
"fallible-iterator",
|
"fallible-iterator",
|
||||||
"fallible-streaming-iterator",
|
"fallible-streaming-iterator",
|
||||||
"hashlink",
|
"hashlink",
|
||||||
@@ -4949,7 +4879,7 @@ version = "1.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.1",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
@@ -5022,9 +4952,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.11"
|
version = "0.103.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4"
|
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"ring",
|
"ring",
|
||||||
@@ -5167,7 +5097,7 @@ version = "3.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.1",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -5357,9 +5287,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serial2"
|
name = "serial2"
|
||||||
version = "0.2.35"
|
version = "0.2.36"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e66ab7ee258c6456796c6098e1b53a5baa1a5e0637347de59ddb44ee8e20be6e"
|
checksum = "fcdbc46aa3882ec3d48ec2b5abcb4f0d863a13d7599265f3faa6d851f23c12f3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -5637,7 +5567,7 @@ checksum = "6fef16f3d52a3710a672b48175b713e86476e2df85576a753c8b37ad11a483c0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64",
|
"base64",
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.1",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
"crc",
|
"crc",
|
||||||
@@ -5677,7 +5607,7 @@ checksum = "f053cf36ecb2793a9d9bb02d01bbad1ef66481d5db6ff5ab2dfb7b070cc0d13c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64",
|
"base64",
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.1",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"crc",
|
"crc",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
@@ -5884,7 +5814,7 @@ version = "0.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.1",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation 0.9.4",
|
||||||
"system-configuration-sys",
|
"system-configuration-sys",
|
||||||
]
|
]
|
||||||
@@ -6092,16 +6022,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-native-tls"
|
|
||||||
version = "0.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
|
||||||
dependencies = [
|
|
||||||
"native-tls",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
@@ -6144,9 +6064,11 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
"native-tls",
|
"rustls",
|
||||||
|
"rustls-native-certs",
|
||||||
|
"rustls-pki-types",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-rustls",
|
||||||
"tungstenite 0.29.0",
|
"tungstenite 0.29.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6276,7 +6198,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-compression",
|
"async-compression",
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -6379,7 +6301,7 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"httparse",
|
"httparse",
|
||||||
"log",
|
"log",
|
||||||
"rand 0.9.3",
|
"rand 0.9.4",
|
||||||
"sha1",
|
"sha1",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"utf-8",
|
"utf-8",
|
||||||
@@ -6396,8 +6318,9 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"httparse",
|
"httparse",
|
||||||
"log",
|
"log",
|
||||||
"native-tls",
|
"rand 0.9.4",
|
||||||
"rand 0.9.3",
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
"sha1",
|
"sha1",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
@@ -6429,7 +6352,7 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
|
checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand 0.9.3",
|
"rand 0.9.4",
|
||||||
"web-time",
|
"web-time",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6806,7 +6729,7 @@ version = "0.244.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.1",
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"semver",
|
"semver",
|
||||||
@@ -7397,7 +7320,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.1",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Huskies
|
# Huskies
|
||||||
|
|
||||||
A story-driven development server that manages work items, spawns coding agents, and runs them through a pipeline from backlog to done. Ships as a single Rust binary with an embedded React frontend.
|
A story-driven development server that manages work items, spawns coding agents, and runs them through a pipeline from backlog to done. Ships as a single Rust binary with an embedded React frontend. Can also be run in WhatsApp, Matrix, and Slack chats.
|
||||||
|
|
||||||
## Getting started with Claude Code
|
## Getting started with Claude Code
|
||||||
|
|
||||||
|
|||||||
@@ -264,11 +264,7 @@ impl<T: CrdtNode + DebugView> BaseCrdt<T> {
|
|||||||
// Bounded queue overflow: evict the oldest op from the largest
|
// Bounded queue overflow: evict the oldest op from the largest
|
||||||
// pending bucket before adding the new one. See CAUSAL_QUEUE_MAX.
|
// pending bucket before adding the new one. See CAUSAL_QUEUE_MAX.
|
||||||
if self.queue_len >= CAUSAL_QUEUE_MAX {
|
if self.queue_len >= CAUSAL_QUEUE_MAX {
|
||||||
if let Some(bucket) = self
|
if let Some(bucket) = self.message_q.values_mut().max_by_key(|v| v.len()) {
|
||||||
.message_q
|
|
||||||
.values_mut()
|
|
||||||
.max_by_key(|v| v.len())
|
|
||||||
{
|
|
||||||
if !bucket.is_empty() {
|
if !bucket.is_empty() {
|
||||||
bucket.remove(0);
|
bucket.remove(0);
|
||||||
self.queue_len = self.queue_len.saturating_sub(1);
|
self.queue_len = self.queue_len.saturating_sub(1);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::debug::DebugView;
|
use crate::debug::DebugView;
|
||||||
use crate::json_crdt::{CrdtNode, OpState, JsonValue};
|
use crate::json_crdt::{CrdtNode, JsonValue, OpState};
|
||||||
use crate::op::{join_path, print_path, Op, PathSegment, SequenceNumber};
|
use crate::op::{join_path, print_path, Op, PathSegment, SequenceNumber};
|
||||||
use std::cmp::{max, Ordering};
|
use std::cmp::{max, Ordering};
|
||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
|
|||||||
+2
-1
@@ -109,7 +109,8 @@ RUN groupadd -r huskies \
|
|||||||
&& chown -R huskies:huskies /usr/local/cargo /usr/local/rustup \
|
&& chown -R huskies:huskies /usr/local/cargo /usr/local/rustup \
|
||||||
&& chown -R huskies:huskies /app \
|
&& chown -R huskies:huskies /app \
|
||||||
&& mkdir -p /workspace/target /app/target \
|
&& mkdir -p /workspace/target /app/target \
|
||||||
&& chown huskies:huskies /workspace/target /app/target
|
&& chown huskies:huskies /workspace/target /app/target \
|
||||||
|
&& git config --global init.defaultBranch master
|
||||||
|
|
||||||
# ── Entrypoint ───────────────────────────────────────────────────────
|
# ── Entrypoint ───────────────────────────────────────────────────────
|
||||||
# Validates required env vars (GIT_USER_NAME, GIT_USER_EMAIL) and
|
# Validates required env vars (GIT_USER_NAME, GIT_USER_EMAIL) and
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ services:
|
|||||||
- workspace-target:/workspace/target
|
- workspace-target:/workspace/target
|
||||||
- huskies-target:/app/target
|
- huskies-target:/app/target
|
||||||
|
|
||||||
|
# Isolate frontend node_modules from the host.
|
||||||
|
# npm install pulls platform-specific native binaries (esbuild,
|
||||||
|
# rollup, etc.) — macOS binaries won't run on Linux and vice versa.
|
||||||
|
# Without this volume, building on the Mac host writes macOS
|
||||||
|
# node_modules into the bind mount, then the Linux container tries
|
||||||
|
# to execute them and fails. The Docker volume gives the container
|
||||||
|
# its own Linux-native node_modules that doesn't collide with the
|
||||||
|
# host's.
|
||||||
|
- frontend-modules:/workspace/frontend/node_modules
|
||||||
|
|
||||||
# ── Security hardening ──────────────────────────────────────────
|
# ── Security hardening ──────────────────────────────────────────
|
||||||
# Read-only root filesystem. Only explicitly mounted volumes and
|
# Read-only root filesystem. Only explicitly mounted volumes and
|
||||||
# tmpfs paths are writable.
|
# tmpfs paths are writable.
|
||||||
@@ -130,3 +140,4 @@ volumes:
|
|||||||
claude-state:
|
claude-state:
|
||||||
workspace-target:
|
workspace-target:
|
||||||
huskies-target:
|
huskies-target:
|
||||||
|
frontend-modules:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "huskies",
|
"name": "huskies",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "huskies",
|
"name": "huskies",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "huskies",
|
"name": "huskies",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+18
-1
@@ -2,11 +2,14 @@ import * as React from "react";
|
|||||||
import type { OAuthStatus } from "./api/client";
|
import type { OAuthStatus } from "./api/client";
|
||||||
import { api } from "./api/client";
|
import { api } from "./api/client";
|
||||||
import { Chat } from "./components/Chat";
|
import { Chat } from "./components/Chat";
|
||||||
|
import { GatewayPanel } from "./components/GatewayPanel";
|
||||||
import { SelectionScreen } from "./components/selection/SelectionScreen";
|
import { SelectionScreen } from "./components/selection/SelectionScreen";
|
||||||
import { usePathCompletion } from "./components/selection/usePathCompletion";
|
import { usePathCompletion } from "./components/selection/usePathCompletion";
|
||||||
|
import { gatewayApi } from "./api/gateway";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [isGateway, setIsGateway] = React.useState<boolean | null>(null);
|
||||||
const [projectPath, setProjectPath] = React.useState<string | null>(null);
|
const [projectPath, setProjectPath] = React.useState<string | null>(null);
|
||||||
const [_view, setView] = React.useState<"chat" | "token-usage">("chat");
|
const [_view, setView] = React.useState<"chat" | "token-usage">("chat");
|
||||||
const [isCheckingProject, setIsCheckingProject] = React.useState(true);
|
const [isCheckingProject, setIsCheckingProject] = React.useState(true);
|
||||||
@@ -19,6 +22,14 @@ function App() {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Detect gateway mode on startup — if /gateway/mode returns 200, we're a gateway.
|
||||||
|
React.useEffect(() => {
|
||||||
|
gatewayApi
|
||||||
|
.getServerMode()
|
||||||
|
.then((result) => setIsGateway(result.mode === "gateway"))
|
||||||
|
.catch(() => setIsGateway(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
function fetchOAuthStatus() {
|
function fetchOAuthStatus() {
|
||||||
@@ -188,10 +199,16 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCheckingProject) {
|
// Still probing server mode — wait before rendering.
|
||||||
|
if (isGateway === null || isCheckingProject) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gateway mode: render the agent management UI instead of the normal chat.
|
||||||
|
if (isGateway) {
|
||||||
|
return <GatewayPanel />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
className="container"
|
className="container"
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/// Gateway API client — used when running in gateway mode.
|
||||||
|
///
|
||||||
|
/// The gateway mode is detected by checking `GET /gateway/mode`. If it returns
|
||||||
|
/// `{ "mode": "gateway" }` the frontend switches to the gateway UI.
|
||||||
|
|
||||||
|
export interface JoinedAgent {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
address: string;
|
||||||
|
registered_at: number;
|
||||||
|
/// Project this agent is assigned to, if any.
|
||||||
|
assigned_project?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayProject {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayInfo {
|
||||||
|
active: string;
|
||||||
|
projects: GatewayProject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateTokenResponse {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerMode {
|
||||||
|
mode: "gateway" | "standard";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gatewayRequest<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
headers: { "Content-Type": "application/json", ...(options.headers ?? {}) },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(text || `Request failed (${res.status})`);
|
||||||
|
}
|
||||||
|
// DELETE /gateway/agents/:id returns 204 No Content.
|
||||||
|
if (res.status === 204) {
|
||||||
|
return undefined as unknown as T;
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gatewayApi = {
|
||||||
|
/// Returns `{ mode: "gateway" }` if this server is a gateway, otherwise rejects.
|
||||||
|
getServerMode(): Promise<ServerMode> {
|
||||||
|
return gatewayRequest<ServerMode>("/gateway/mode");
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Generate a one-time join token for a new build agent.
|
||||||
|
generateToken(): Promise<GenerateTokenResponse> {
|
||||||
|
return gatewayRequest<GenerateTokenResponse>("/gateway/tokens", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/// List all build agents that have registered with this gateway.
|
||||||
|
listAgents(): Promise<JoinedAgent[]> {
|
||||||
|
return gatewayRequest<JoinedAgent[]>("/gateway/agents");
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Remove a registered build agent by its ID.
|
||||||
|
removeAgent(id: string): Promise<void> {
|
||||||
|
return gatewayRequest<void>(`/gateway/agents/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Assign an agent to a project, or unassign it by passing null.
|
||||||
|
assignAgent(id: string, project: string | null): Promise<JoinedAgent> {
|
||||||
|
return gatewayRequest<JoinedAgent>(`/gateway/agents/${id}/assign`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ project }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Get the list of registered projects from the gateway.
|
||||||
|
getGatewayInfo(): Promise<GatewayInfo> {
|
||||||
|
return gatewayRequest<GatewayInfo>("/api/gateway");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
/// Gateway management panel shown when huskies runs in `--gateway` mode.
|
||||||
|
///
|
||||||
|
/// Provides:
|
||||||
|
/// - An "Add Agent" button that generates a one-time join token.
|
||||||
|
/// - Instructions for running a build agent with the token.
|
||||||
|
/// - A list of connected agents with per-agent project assignment and "Remove" buttons.
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { gatewayApi, type JoinedAgent, type GatewayProject } from "../api/gateway";
|
||||||
|
|
||||||
|
const { useCallback, useEffect, useState } = React;
|
||||||
|
|
||||||
|
function TokenDisplay({ token }: { token: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const envCmd = `HUSKIES_JOIN_TOKEN=${token} huskies agent --rendezvous <CRDT_SYNC_URL>`;
|
||||||
|
const flagCmd = `huskies agent --rendezvous <CRDT_SYNC_URL> --join-token ${token}`;
|
||||||
|
|
||||||
|
const copyToClipboard = useCallback((text: string) => {
|
||||||
|
void navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "12px",
|
||||||
|
padding: "12px 16px",
|
||||||
|
background: "#161b22",
|
||||||
|
border: "1px solid #238636",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: "0.85em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: "#3fb950", fontWeight: 600, marginBottom: "8px" }}>
|
||||||
|
Token generated — run the build agent with one of:
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: "6px" }}>
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
background: "#0d1117",
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#e6edf3",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{envCmd}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
background: "#0d1117",
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#e6edf3",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flagCmd}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyToClipboard(flagCmd)}
|
||||||
|
style={{
|
||||||
|
marginTop: "8px",
|
||||||
|
fontSize: "0.8em",
|
||||||
|
padding: "3px 10px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid #444",
|
||||||
|
background: "none",
|
||||||
|
color: "#aaa",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? "Copied!" : "Copy flag command"}
|
||||||
|
</button>
|
||||||
|
<div style={{ marginTop: "8px", color: "#666", fontSize: "0.85em" }}>
|
||||||
|
This token is single-use. Generate a new one for each agent.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentRow({
|
||||||
|
agent,
|
||||||
|
projects,
|
||||||
|
onRemove,
|
||||||
|
onAssign,
|
||||||
|
}: {
|
||||||
|
agent: JoinedAgent;
|
||||||
|
projects: GatewayProject[];
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
onAssign: (id: string, project: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const registeredAt = new Date(agent.registered_at * 1000).toLocaleString();
|
||||||
|
const isAssigned = Boolean(agent.assigned_project);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={`agent-row-${agent.id}`}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "12px",
|
||||||
|
padding: "10px 14px",
|
||||||
|
background: "#161b22",
|
||||||
|
border: "1px solid #30363d",
|
||||||
|
borderRadius: "8px",
|
||||||
|
marginBottom: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "8px",
|
||||||
|
height: "8px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: isAssigned ? "#3fb950" : "#6e7681",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
title={isAssigned ? "Assigned" : "Idle (unassigned)"}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 600, color: "#e6edf3" }}>{agent.label}</div>
|
||||||
|
<div style={{ fontSize: "0.8em", color: "#8b949e" }}>
|
||||||
|
{agent.address}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.75em", color: "#6e7681" }}>
|
||||||
|
Registered {registeredAt}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
data-testid={`assign-agent-${agent.id}`}
|
||||||
|
value={agent.assigned_project ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onAssign(agent.id, e.target.value === "" ? null : e.target.value)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8em",
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid #30363d",
|
||||||
|
background: "#0d1117",
|
||||||
|
color: "#e6edf3",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">— unassigned —</option>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.name} value={p.name}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`remove-agent-${agent.id}`}
|
||||||
|
onClick={() => onRemove(agent.id)}
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8em",
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid #f85149",
|
||||||
|
background: "none",
|
||||||
|
color: "#f85149",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gateway management panel — rendered when running in `--gateway` mode.
|
||||||
|
export function GatewayPanel() {
|
||||||
|
const [agents, setAgents] = useState<JoinedAgent[]>([]);
|
||||||
|
const [projects, setProjects] = useState<GatewayProject[]>([]);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
gatewayApi
|
||||||
|
.listAgents()
|
||||||
|
.then(setAgents)
|
||||||
|
.catch(() => setAgents([]));
|
||||||
|
gatewayApi
|
||||||
|
.getGatewayInfo()
|
||||||
|
.then((info) => setProjects(info.projects))
|
||||||
|
.catch(() => setProjects([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddAgent = useCallback(async () => {
|
||||||
|
setGenerating(true);
|
||||||
|
setError(null);
|
||||||
|
setToken(null);
|
||||||
|
try {
|
||||||
|
const result = await gatewayApi.generateToken();
|
||||||
|
setToken(result.token);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRemoveAgent = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await gatewayApi.removeAgent(id);
|
||||||
|
setAgents((prev) => prev.filter((a) => a.id !== id));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAssignAgent = useCallback(
|
||||||
|
async (id: string, project: string | null) => {
|
||||||
|
try {
|
||||||
|
const updated = await gatewayApi.assignAgent(id, project);
|
||||||
|
setAgents((prev) =>
|
||||||
|
prev.map((a) => (a.id === updated.id ? updated : a)),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
background: "#0d1117",
|
||||||
|
color: "#e6edf3",
|
||||||
|
padding: "32px",
|
||||||
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: "720px", margin: "0 auto" }}>
|
||||||
|
<h1 style={{ fontSize: "1.5em", fontWeight: 700, marginBottom: "4px" }}>
|
||||||
|
Huskies Gateway
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "#8b949e", marginBottom: "32px" }}>
|
||||||
|
Manage build agents connected to this gateway.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Add Agent */}
|
||||||
|
<section style={{ marginBottom: "32px" }}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: "1.1em",
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: "12px",
|
||||||
|
borderBottom: "1px solid #21262d",
|
||||||
|
paddingBottom: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Agent
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="add-agent-button"
|
||||||
|
onClick={handleAddAgent}
|
||||||
|
disabled={generating}
|
||||||
|
style={{
|
||||||
|
padding: "8px 18px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #238636",
|
||||||
|
background: generating ? "#1a2f1a" : "#238636",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: generating ? "not-allowed" : "pointer",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.9em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{generating ? "Generating…" : "Add Agent"}
|
||||||
|
</button>
|
||||||
|
{token && <TokenDisplay token={token} />}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Agent list */}
|
||||||
|
<section>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: "1.1em",
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: "12px",
|
||||||
|
borderBottom: "1px solid #21262d",
|
||||||
|
paddingBottom: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Connected Agents{" "}
|
||||||
|
{agents.length > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8em",
|
||||||
|
color: "#8b949e",
|
||||||
|
fontWeight: 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
({agents.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
{agents.length === 0 ? (
|
||||||
|
<p style={{ color: "#6e7681" }}>
|
||||||
|
No agents connected yet. Click "Add Agent" to generate a join
|
||||||
|
token.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<AgentRow
|
||||||
|
key={agent.id}
|
||||||
|
agent={agent}
|
||||||
|
projects={projects}
|
||||||
|
onRemove={handleRemoveAgent}
|
||||||
|
onAssign={handleAssignAgent}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "16px",
|
||||||
|
padding: "10px 14px",
|
||||||
|
background: "#f8514911",
|
||||||
|
border: "1px solid #f85149",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "#f85149",
|
||||||
|
fontSize: "0.875em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ export const SLASH_COMMANDS: SlashCommand[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "/backlog",
|
name: "/backlog",
|
||||||
description: "Show all items in the backlog with dependency satisfaction status.",
|
description:
|
||||||
|
"Show all items in the backlog with dependency satisfaction status.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "/status",
|
name: "/status",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export default defineConfig(() => {
|
|||||||
build: {
|
build: {
|
||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
chunkSizeWarningLimit: 1100,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|||||||
|
|
||||||
echo "=== Checking Rust formatting ==="
|
echo "=== Checking Rust formatting ==="
|
||||||
if cargo fmt --version &>/dev/null; then
|
if cargo fmt --version &>/dev/null; then
|
||||||
cargo fmt --manifest-path "$PROJECT_ROOT/Cargo.toml" --check
|
cargo fmt --manifest-path "$PROJECT_ROOT/Cargo.toml" --all --check
|
||||||
else
|
else
|
||||||
echo "Skipping Rust formatting check (rustfmt not installed)"
|
echo "Skipping Rust formatting check (rustfmt not installed)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ fi
|
|||||||
|
|
||||||
echo "=== Checking Rust formatting ==="
|
echo "=== Checking Rust formatting ==="
|
||||||
if cargo fmt --version &>/dev/null; then
|
if cargo fmt --version &>/dev/null; then
|
||||||
cargo fmt --manifest-path "$PROJECT_ROOT/Cargo.toml" --check
|
cargo fmt --manifest-path "$PROJECT_ROOT/Cargo.toml" --all --check
|
||||||
else
|
else
|
||||||
echo "Skipping Rust formatting check (rustfmt not installed)"
|
echo "Skipping Rust formatting check (rustfmt not installed)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "huskies"
|
name = "huskies"
|
||||||
version = "0.10.0"
|
version = "0.10.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@@ -1,858 +0,0 @@
|
|||||||
//! Pipeline state machine — design sketch (story 520) — BARE version.
|
|
||||||
//!
|
|
||||||
//! This is a SCRATCH EXPERIMENT, not wired into anything else in the codebase.
|
|
||||||
//! "Bare" version: hand-rolled with plain Rust enums and pattern matching,
|
|
||||||
//! no external state-machine library. See `pipeline_state_sketch_statig.rs`
|
|
||||||
//! for a parallel version using the `statig` crate.
|
|
||||||
//!
|
|
||||||
//! Run with:
|
|
||||||
//! cargo run --example pipeline_state_sketch_bare -p huskies
|
|
||||||
//! Test with:
|
|
||||||
//! cargo test --example pipeline_state_sketch_bare -p huskies
|
|
||||||
//!
|
|
||||||
//! Goal: demonstrate the typed pipeline state machine that should replace
|
|
||||||
//! huskies's stringly-typed CRDT state. It is intentionally standalone —
|
|
||||||
//! no integration with crdt_state, no persistence, no events escape this
|
|
||||||
//! file. Once we agree on the shape, this becomes the foundation for the
|
|
||||||
//! real implementation in src/pipeline_state.rs.
|
|
||||||
//!
|
|
||||||
//! The point of this version is to show that the Rust type system alone is
|
|
||||||
//! enough to make impossible states unrepresentable, without needing any
|
|
||||||
//! state-machine framework.
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use std::num::NonZeroU32;
|
|
||||||
|
|
||||||
// ── Newtypes ─────────────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Each of these is a "wrapper around String" today, but the wrapping itself
|
|
||||||
// is the point: a function that takes a `BranchName` cannot accidentally be
|
|
||||||
// called with a `StoryId`. Validation can be added later (e.g. `BranchName::new`
|
|
||||||
// returns `Result<Self, BranchNameError>` and the inner `String` is private)
|
|
||||||
// without changing call sites.
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
||||||
pub struct StoryId(pub String);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
||||||
pub struct BranchName(pub String);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
||||||
pub struct GitSha(pub String);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
||||||
pub struct AgentName(pub String);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
||||||
pub struct NodePubkey(pub [u8; 32]);
|
|
||||||
|
|
||||||
// ── Synced pipeline stage (lives in CRDT, converges across nodes) ────────────
|
|
||||||
//
|
|
||||||
// This is the SHARED state — every node sees the same Stage for a given story
|
|
||||||
// after CRDT convergence. Local-only state (which agent is running, retry
|
|
||||||
// count, rate-limit timers) lives separately in `ExecutionState` below, keyed
|
|
||||||
// by node pubkey.
|
|
||||||
//
|
|
||||||
// Notice what is NOT a field on Stage:
|
|
||||||
// - `agent` — that's local execution state, not pipeline state
|
|
||||||
// - `retry_count` — also local
|
|
||||||
// - `blocked` — folded into `Archived { reason: Blocked { .. } }`
|
|
||||||
//
|
|
||||||
// And notice what IS a field, by construction:
|
|
||||||
// - Stage::Merge requires a non-zero commits_ahead (silent no-op merge is unrepresentable)
|
|
||||||
// - Stage::Done requires a merge_commit (a "done" story without merge metadata is unrepresentable)
|
|
||||||
// - Stage::Archived always carries a reason (no "archived but we don't know why")
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum Stage {
|
|
||||||
/// Story exists, waiting for dependencies or auto-assign promotion.
|
|
||||||
Backlog,
|
|
||||||
|
|
||||||
/// Story is being actively coded somewhere in the mesh.
|
|
||||||
/// (Which node is local — see ExecutionState.)
|
|
||||||
Coding,
|
|
||||||
|
|
||||||
/// Coder has run; gates are running.
|
|
||||||
Qa,
|
|
||||||
|
|
||||||
/// Gates passed (or were skipped); ready to merge.
|
|
||||||
/// `commits_ahead: NonZeroU32` makes "Merge with nothing to merge" structurally impossible.
|
|
||||||
/// This single field eliminates today's bug 519 (silent mergemaster no-op).
|
|
||||||
Merge {
|
|
||||||
feature_branch: BranchName,
|
|
||||||
commits_ahead: NonZeroU32,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Mergemaster squashed to master. Always carries the merge metadata,
|
|
||||||
/// so a "done" story is provably reachable from master.
|
|
||||||
Done {
|
|
||||||
merged_at: DateTime<Utc>,
|
|
||||||
merge_commit: GitSha,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Out of the active flow. The reason explains why.
|
|
||||||
Archived {
|
|
||||||
archived_at: DateTime<Utc>,
|
|
||||||
reason: ArchiveReason,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum ArchiveReason {
|
|
||||||
/// Normal happy-path completion: accepted and filed away.
|
|
||||||
Completed,
|
|
||||||
/// User explicitly abandoned the story.
|
|
||||||
Abandoned,
|
|
||||||
/// Replaced by another story.
|
|
||||||
Superseded { by: StoryId },
|
|
||||||
/// Manually blocked, awaiting human resolution. Was bug-436's `blocked: true`.
|
|
||||||
Blocked { reason: String },
|
|
||||||
/// Mergemaster failed beyond the retry budget. Was bug-436's `merge_failure`.
|
|
||||||
MergeFailed { reason: String },
|
|
||||||
/// Held in review at human request. Was bug-436's `review_hold`.
|
|
||||||
ReviewHeld { reason: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Per-node execution state (lives in CRDT under node_pubkey key) ───────────
|
|
||||||
//
|
|
||||||
// LOCAL-AUTHORED but GLOBALLY-READABLE. Each node only writes to entries where
|
|
||||||
// node_pubkey == self, so there are no inter-author CRDT merge conflicts. Other
|
|
||||||
// nodes can READ all entries to know what's happening across the mesh.
|
|
||||||
//
|
|
||||||
// In the real CRDT document, this would be stored as something like:
|
|
||||||
// crdt.execution_state: { node_pubkey -> { story_id -> ExecutionState } }
|
|
||||||
//
|
|
||||||
// Why this matters operationally:
|
|
||||||
// - Cross-node observability: matrix bot can show "node A is running coder-1
|
|
||||||
// on story X, node B is rate-limited on story Y"
|
|
||||||
// - Heartbeat detection: if `last_heartbeat` is stale > N min, the entry is
|
|
||||||
// dead (laptop closed, OOM, segfault). Other nodes can take over (story 479).
|
|
||||||
// - Foundation for CRDT-based work claiming (story 479).
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum ExecutionState {
|
|
||||||
/// No agent on this node is currently working on this story.
|
|
||||||
Idle,
|
|
||||||
|
|
||||||
/// An agent has been requested but hasn't started its subprocess yet.
|
|
||||||
Pending {
|
|
||||||
agent: AgentName,
|
|
||||||
since: DateTime<Utc>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// An agent's subprocess is alive on this node.
|
|
||||||
/// `last_heartbeat` is updated periodically; if stale, the process probably died.
|
|
||||||
Running {
|
|
||||||
agent: AgentName,
|
|
||||||
started_at: DateTime<Utc>,
|
|
||||||
last_heartbeat: DateTime<Utc>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Agent hit a rate limit; will resume at the given time.
|
|
||||||
RateLimited {
|
|
||||||
agent: AgentName,
|
|
||||||
resume_at: DateTime<Utc>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Agent finished. exit_code disambiguates clean exit / panic / etc.
|
|
||||||
Completed {
|
|
||||||
agent: AgentName,
|
|
||||||
exit_code: i32,
|
|
||||||
completed_at: DateTime<Utc>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pipeline events ──────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Events drive Stage transitions. Each event carries any data needed to
|
|
||||||
// construct the destination state, so the type signature of `transition`
|
|
||||||
// guarantees we can never accidentally land in an underspecified state.
|
|
||||||
//
|
|
||||||
// (Compare with today's stringly-typed code, where you call
|
|
||||||
// `move_story_to_merge(story_id)` and the destination state is built from
|
|
||||||
// whatever happens to be in scope at the time.)
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum PipelineEvent {
|
|
||||||
/// All depends_on stories are in Done or Archived; promotion fires.
|
|
||||||
DepsMet,
|
|
||||||
|
|
||||||
/// Coder is going to start running gates.
|
|
||||||
GatesStarted,
|
|
||||||
|
|
||||||
/// Gates passed normally — ready to merge. Carries the data needed to
|
|
||||||
/// construct Stage::Merge, so the transition can't produce a malformed merge state.
|
|
||||||
GatesPassed {
|
|
||||||
feature_branch: BranchName,
|
|
||||||
commits_ahead: NonZeroU32,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Gates failed; coder will retry.
|
|
||||||
GatesFailed { reason: String },
|
|
||||||
|
|
||||||
/// QA mode is "server" — skip QA and go straight to merge.
|
|
||||||
QaSkipped {
|
|
||||||
feature_branch: BranchName,
|
|
||||||
commits_ahead: NonZeroU32,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Mergemaster successfully squashed and pushed to master.
|
|
||||||
MergeSucceeded { merge_commit: GitSha },
|
|
||||||
|
|
||||||
/// Mergemaster gave up after the retry budget.
|
|
||||||
MergeFailedFinal { reason: String },
|
|
||||||
|
|
||||||
/// User accepted a Done story (or auto-accept fired).
|
|
||||||
Accepted,
|
|
||||||
|
|
||||||
/// User explicitly blocked the story.
|
|
||||||
Block { reason: String },
|
|
||||||
|
|
||||||
/// User explicitly unblocked.
|
|
||||||
Unblock,
|
|
||||||
|
|
||||||
/// User explicitly abandoned.
|
|
||||||
Abandon,
|
|
||||||
|
|
||||||
/// User marked the story as superseded by another.
|
|
||||||
Supersede { by: StoryId },
|
|
||||||
|
|
||||||
/// User put the story on review hold.
|
|
||||||
ReviewHold { reason: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Transition errors ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum TransitionError {
|
|
||||||
/// The current stage doesn't accept this event.
|
|
||||||
InvalidTransition {
|
|
||||||
from_stage: String,
|
|
||||||
event: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── The transition function ──────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Pure function. Takes the current Stage and an event, returns the new Stage
|
|
||||||
// or a TransitionError. The compiler enforces that every constructed Stage
|
|
||||||
// has all required fields, so impossible destination states are unrepresentable.
|
|
||||||
//
|
|
||||||
// "What about the *side effects* of a transition?" — they don't go in here.
|
|
||||||
// transition() is pure. Side effects (matrix bot notifications, file writes,
|
|
||||||
// agent spawns, web UI broadcasts) are dispatched by an event bus that watches
|
|
||||||
// the (before, after) tuple. See the `EventBus` sketch further down.
|
|
||||||
|
|
||||||
pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, TransitionError> {
|
|
||||||
use PipelineEvent::*;
|
|
||||||
use Stage::*;
|
|
||||||
|
|
||||||
let stage_label = stage_label(&state);
|
|
||||||
let event_label = event_label(&event);
|
|
||||||
let invalid = || TransitionError::InvalidTransition {
|
|
||||||
from_stage: stage_label.to_string(),
|
|
||||||
event: event_label.to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let now = Utc::now();
|
|
||||||
|
|
||||||
match (state, event) {
|
|
||||||
// ── Forward path: backlog → current → (qa →) merge → done ──────────
|
|
||||||
(Backlog, DepsMet) => Ok(Coding),
|
|
||||||
(Coding, GatesStarted) => Ok(Qa),
|
|
||||||
(Coding, QaSkipped { feature_branch, commits_ahead }) => Ok(Merge {
|
|
||||||
feature_branch,
|
|
||||||
commits_ahead,
|
|
||||||
}),
|
|
||||||
(Qa, GatesPassed { feature_branch, commits_ahead }) => Ok(Merge {
|
|
||||||
feature_branch,
|
|
||||||
commits_ahead,
|
|
||||||
}),
|
|
||||||
// Gates failed → back to Coding for retry. (Retry-budget enforcement
|
|
||||||
// lives outside this function — it's accounting on the local side.)
|
|
||||||
(Qa, GatesFailed { .. }) => Ok(Coding),
|
|
||||||
(Merge { .. }, MergeSucceeded { merge_commit }) => Ok(Done {
|
|
||||||
merged_at: now,
|
|
||||||
merge_commit,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// ── Done → Archived(Completed) ─────────────────────────────────────
|
|
||||||
(Done { .. }, Accepted) => Ok(Archived {
|
|
||||||
archived_at: now,
|
|
||||||
reason: ArchiveReason::Completed,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// ── Stuck states (any active stage → Archived with a reason) ──────
|
|
||||||
(Backlog, Block { reason })
|
|
||||||
| (Coding, Block { reason })
|
|
||||||
| (Qa, Block { reason })
|
|
||||||
| (Merge { .. }, Block { reason }) => Ok(Archived {
|
|
||||||
archived_at: now,
|
|
||||||
reason: ArchiveReason::Blocked { reason },
|
|
||||||
}),
|
|
||||||
|
|
||||||
(Backlog, ReviewHold { reason })
|
|
||||||
| (Coding, ReviewHold { reason })
|
|
||||||
| (Qa, ReviewHold { reason })
|
|
||||||
| (Merge { .. }, ReviewHold { reason }) => Ok(Archived {
|
|
||||||
archived_at: now,
|
|
||||||
reason: ArchiveReason::ReviewHeld { reason },
|
|
||||||
}),
|
|
||||||
|
|
||||||
(Merge { .. }, MergeFailedFinal { reason }) => Ok(Archived {
|
|
||||||
archived_at: now,
|
|
||||||
reason: ArchiveReason::MergeFailed { reason },
|
|
||||||
}),
|
|
||||||
|
|
||||||
// ── Abandon / supersede from any active or done stage ──────────────
|
|
||||||
(Backlog, Abandon)
|
|
||||||
| (Coding, Abandon)
|
|
||||||
| (Qa, Abandon)
|
|
||||||
| (Merge { .. }, Abandon)
|
|
||||||
| (Done { .. }, Abandon) => Ok(Archived {
|
|
||||||
archived_at: now,
|
|
||||||
reason: ArchiveReason::Abandoned,
|
|
||||||
}),
|
|
||||||
|
|
||||||
(Backlog, Supersede { by })
|
|
||||||
| (Coding, Supersede { by })
|
|
||||||
| (Qa, Supersede { by })
|
|
||||||
| (Merge { .. }, Supersede { by })
|
|
||||||
| (Done { .. }, Supersede { by }) => Ok(Archived {
|
|
||||||
archived_at: now,
|
|
||||||
reason: ArchiveReason::Superseded { by },
|
|
||||||
}),
|
|
||||||
|
|
||||||
// ── Unblock: only from Archived(Blocked) → Backlog ─────────────────
|
|
||||||
(
|
|
||||||
Archived {
|
|
||||||
reason: ArchiveReason::Blocked { .. },
|
|
||||||
..
|
|
||||||
},
|
|
||||||
Unblock,
|
|
||||||
) => Ok(Backlog),
|
|
||||||
|
|
||||||
// ── Everything else is invalid ─────────────────────────────────────
|
|
||||||
_ => Err(invalid()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stage_label(s: &Stage) -> &'static str {
|
|
||||||
match s {
|
|
||||||
Stage::Backlog => "Backlog",
|
|
||||||
Stage::Coding => "Coding",
|
|
||||||
Stage::Qa => "Qa",
|
|
||||||
Stage::Merge { .. } => "Merge",
|
|
||||||
Stage::Done { .. } => "Done",
|
|
||||||
Stage::Archived { .. } => "Archived",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn event_label(e: &PipelineEvent) -> &'static str {
|
|
||||||
match e {
|
|
||||||
PipelineEvent::DepsMet => "DepsMet",
|
|
||||||
PipelineEvent::GatesStarted => "GatesStarted",
|
|
||||||
PipelineEvent::GatesPassed { .. } => "GatesPassed",
|
|
||||||
PipelineEvent::GatesFailed { .. } => "GatesFailed",
|
|
||||||
PipelineEvent::QaSkipped { .. } => "QaSkipped",
|
|
||||||
PipelineEvent::MergeSucceeded { .. } => "MergeSucceeded",
|
|
||||||
PipelineEvent::MergeFailedFinal { .. } => "MergeFailedFinal",
|
|
||||||
PipelineEvent::Accepted => "Accepted",
|
|
||||||
PipelineEvent::Block { .. } => "Block",
|
|
||||||
PipelineEvent::Unblock => "Unblock",
|
|
||||||
PipelineEvent::Abandon => "Abandon",
|
|
||||||
PipelineEvent::Supersede { .. } => "Supersede",
|
|
||||||
PipelineEvent::ReviewHold { .. } => "ReviewHold",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Per-node execution state machine ─────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Independent of the pipeline stage machine. Tracks "what is THIS node doing
|
|
||||||
// about this story right now." Multiple nodes can have different ExecutionState
|
|
||||||
// for the same story_id at the same time — and that's fine, because each node
|
|
||||||
// owns its own subspace in the CRDT.
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum ExecutionEvent {
|
|
||||||
SpawnRequested { agent: AgentName },
|
|
||||||
SpawnedSuccessfully,
|
|
||||||
Heartbeat,
|
|
||||||
HitRateLimit { resume_at: DateTime<Utc> },
|
|
||||||
Exited { exit_code: i32 },
|
|
||||||
Stopped,
|
|
||||||
Reset,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn execution_transition(
|
|
||||||
state: ExecutionState,
|
|
||||||
event: ExecutionEvent,
|
|
||||||
) -> Result<ExecutionState, TransitionError> {
|
|
||||||
use ExecutionEvent::*;
|
|
||||||
use ExecutionState::*;
|
|
||||||
|
|
||||||
let now = Utc::now();
|
|
||||||
|
|
||||||
match (state, event) {
|
|
||||||
(Idle, SpawnRequested { agent }) => Ok(Pending { agent, since: now }),
|
|
||||||
|
|
||||||
(Pending { agent, .. }, SpawnedSuccessfully) => Ok(Running {
|
|
||||||
agent,
|
|
||||||
started_at: now,
|
|
||||||
last_heartbeat: now,
|
|
||||||
}),
|
|
||||||
|
|
||||||
(
|
|
||||||
Running {
|
|
||||||
agent, started_at, ..
|
|
||||||
},
|
|
||||||
Heartbeat,
|
|
||||||
) => Ok(Running {
|
|
||||||
agent,
|
|
||||||
started_at,
|
|
||||||
last_heartbeat: now,
|
|
||||||
}),
|
|
||||||
|
|
||||||
(Running { agent, .. }, HitRateLimit { resume_at })
|
|
||||||
| (Pending { agent, .. }, HitRateLimit { resume_at }) => Ok(RateLimited { agent, resume_at }),
|
|
||||||
|
|
||||||
(RateLimited { agent, .. }, SpawnedSuccessfully) => Ok(Running {
|
|
||||||
agent,
|
|
||||||
started_at: now,
|
|
||||||
last_heartbeat: now,
|
|
||||||
}),
|
|
||||||
|
|
||||||
(Running { agent, .. }, Exited { exit_code })
|
|
||||||
| (Pending { agent, .. }, Exited { exit_code })
|
|
||||||
| (RateLimited { agent, .. }, Exited { exit_code }) => Ok(Completed {
|
|
||||||
agent,
|
|
||||||
exit_code,
|
|
||||||
completed_at: now,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Stop and Reset always return to Idle, from anywhere.
|
|
||||||
(_, Stopped) | (_, Reset) => Ok(Idle),
|
|
||||||
|
|
||||||
_ => Err(TransitionError::InvalidTransition {
|
|
||||||
from_stage: "ExecutionState".to_string(),
|
|
||||||
event: "<exec event>".to_string(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Event bus sketch ─────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// This is intentionally tiny — the goal is to show that the side-effect dispatch
|
|
||||||
// is *separable* from the transition function. Real implementation would use
|
|
||||||
// tokio broadcast channels or a proper event bus, but the pattern is the same.
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TransitionFired {
|
|
||||||
pub story_id: StoryId,
|
|
||||||
pub before: Stage,
|
|
||||||
pub after: Stage,
|
|
||||||
pub event: PipelineEvent,
|
|
||||||
pub at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait TransitionSubscriber: Send + Sync {
|
|
||||||
fn name(&self) -> &'static str;
|
|
||||||
fn on_transition(&self, fired: &TransitionFired);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct EventBus {
|
|
||||||
subscribers: Vec<Box<dyn TransitionSubscriber>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventBus {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
subscribers: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subscribe<S: TransitionSubscriber + 'static>(&mut self, subscriber: S) {
|
|
||||||
self.subscribers.push(Box::new(subscriber));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn fire(&self, event: TransitionFired) {
|
|
||||||
for sub in &self.subscribers {
|
|
||||||
sub.on_transition(&event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for EventBus {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example subscribers (just println! for the sketch):
|
|
||||||
|
|
||||||
pub struct MatrixBotSub;
|
|
||||||
impl TransitionSubscriber for MatrixBotSub {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"matrix-bot"
|
|
||||||
}
|
|
||||||
fn on_transition(&self, f: &TransitionFired) {
|
|
||||||
println!(
|
|
||||||
" [matrix-bot] #{}: {} → {}",
|
|
||||||
f.story_id.0,
|
|
||||||
stage_label(&f.before),
|
|
||||||
stage_label(&f.after)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct FileRendererSub;
|
|
||||||
impl TransitionSubscriber for FileRendererSub {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"filesystem"
|
|
||||||
}
|
|
||||||
fn on_transition(&self, f: &TransitionFired) {
|
|
||||||
println!(
|
|
||||||
" [filesystem] re-rendering .huskies/work/{}/{}.md",
|
|
||||||
stage_dir_name(&f.after),
|
|
||||||
f.story_id.0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PipelineItemsSub;
|
|
||||||
impl TransitionSubscriber for PipelineItemsSub {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"pipeline-items"
|
|
||||||
}
|
|
||||||
fn on_transition(&self, f: &TransitionFired) {
|
|
||||||
println!(
|
|
||||||
" [pipeline-items] UPDATE pipeline_items SET stage = '{}' WHERE id = '{}'",
|
|
||||||
stage_dir_name(&f.after),
|
|
||||||
f.story_id.0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stage_dir_name(s: &Stage) -> &'static str {
|
|
||||||
match s {
|
|
||||||
Stage::Backlog => "1_backlog",
|
|
||||||
Stage::Coding => "2_current",
|
|
||||||
Stage::Qa => "3_qa",
|
|
||||||
Stage::Merge { .. } => "4_merge",
|
|
||||||
Stage::Done { .. } => "5_done",
|
|
||||||
Stage::Archived { .. } => "6_archived",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn nz(n: u32) -> NonZeroU32 {
|
|
||||||
NonZeroU32::new(n).unwrap()
|
|
||||||
}
|
|
||||||
fn fb(name: &str) -> BranchName {
|
|
||||||
BranchName(name.to_string())
|
|
||||||
}
|
|
||||||
fn sha(s: &str) -> GitSha {
|
|
||||||
GitSha(s.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Happy path ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn happy_path_backlog_through_done() {
|
|
||||||
let s = Stage::Backlog;
|
|
||||||
let s = transition(s, PipelineEvent::DepsMet).unwrap();
|
|
||||||
assert!(matches!(s, Stage::Coding));
|
|
||||||
|
|
||||||
let s = transition(
|
|
||||||
s,
|
|
||||||
PipelineEvent::QaSkipped {
|
|
||||||
feature_branch: fb("feature/story-1"),
|
|
||||||
commits_ahead: nz(3),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(s, Stage::Merge { .. }));
|
|
||||||
|
|
||||||
let s = transition(
|
|
||||||
s,
|
|
||||||
PipelineEvent::MergeSucceeded {
|
|
||||||
merge_commit: sha("abc123"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(s, Stage::Done { .. }));
|
|
||||||
|
|
||||||
let s = transition(s, PipelineEvent::Accepted).unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
s,
|
|
||||||
Stage::Archived {
|
|
||||||
reason: ArchiveReason::Completed,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn qa_retry_loop() {
|
|
||||||
let s = Stage::Coding;
|
|
||||||
let s = transition(s, PipelineEvent::GatesStarted).unwrap();
|
|
||||||
assert!(matches!(s, Stage::Qa));
|
|
||||||
|
|
||||||
let s = transition(
|
|
||||||
s,
|
|
||||||
PipelineEvent::GatesFailed {
|
|
||||||
reason: "tests failed".into(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(s, Stage::Coding));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Bug 519 made unrepresentable: Merge with zero commits ahead ────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn merge_with_zero_commits_is_unrepresentable() {
|
|
||||||
// NonZeroU32::new(0) returns None — the type system literally refuses
|
|
||||||
// to construct a Merge state with no commits ahead of master. This is
|
|
||||||
// bug 519's "silent mergemaster no-op" gone, structurally.
|
|
||||||
assert!(NonZeroU32::new(0).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Bug 502 made unrepresentable: agent on the wrong stage ─────────────
|
|
||||||
//
|
|
||||||
// There's nothing to test here at the *Stage* level, because Stage doesn't
|
|
||||||
// have an `agent` field at all. Agent assignment is per-node ExecutionState.
|
|
||||||
// The "coder agent on a Merge stage" failure mode from bug 502 cannot be
|
|
||||||
// expressed in this type system: a coder can attach to a story (writing to
|
|
||||||
// its node-local ExecutionState), but the Stage::Merge variant has no slot
|
|
||||||
// for an agent. The "wrong-stage agent" error is gone because the wrong
|
|
||||||
// state is unrepresentable.
|
|
||||||
|
|
||||||
// ── Invalid transitions return errors ──────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cannot_jump_from_backlog_to_done() {
|
|
||||||
let s = Stage::Backlog;
|
|
||||||
let result = transition(s, PipelineEvent::Accepted);
|
|
||||||
assert!(matches!(
|
|
||||||
result,
|
|
||||||
Err(TransitionError::InvalidTransition { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cannot_unblock_a_done_story() {
|
|
||||||
let s = Stage::Done {
|
|
||||||
merged_at: Utc::now(),
|
|
||||||
merge_commit: sha("abc"),
|
|
||||||
};
|
|
||||||
let result = transition(s, PipelineEvent::Unblock);
|
|
||||||
assert!(matches!(
|
|
||||||
result,
|
|
||||||
Err(TransitionError::InvalidTransition { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cannot_unblock_a_review_held_story() {
|
|
||||||
// Unblock is specifically for Blocked, not for any Archived variant.
|
|
||||||
let s = Stage::Archived {
|
|
||||||
archived_at: Utc::now(),
|
|
||||||
reason: ArchiveReason::ReviewHeld {
|
|
||||||
reason: "TBD".into(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let result = transition(s, PipelineEvent::Unblock);
|
|
||||||
assert!(matches!(
|
|
||||||
result,
|
|
||||||
Err(TransitionError::InvalidTransition { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Block from any active stage ────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn block_from_any_active_stage() {
|
|
||||||
for s in [Stage::Backlog, Stage::Coding, Stage::Qa] {
|
|
||||||
let result = transition(
|
|
||||||
s.clone(),
|
|
||||||
PipelineEvent::Block {
|
|
||||||
reason: "stuck".into(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
result,
|
|
||||||
Ok(Stage::Archived {
|
|
||||||
reason: ArchiveReason::Blocked { .. },
|
|
||||||
..
|
|
||||||
})
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also from Merge:
|
|
||||||
let m = Stage::Merge {
|
|
||||||
feature_branch: fb("f"),
|
|
||||||
commits_ahead: nz(1),
|
|
||||||
};
|
|
||||||
let result = transition(
|
|
||||||
m,
|
|
||||||
PipelineEvent::Block {
|
|
||||||
reason: "stuck".into(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
result,
|
|
||||||
Ok(Stage::Archived {
|
|
||||||
reason: ArchiveReason::Blocked { .. },
|
|
||||||
..
|
|
||||||
})
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unblock_returns_to_backlog() {
|
|
||||||
let s = Stage::Archived {
|
|
||||||
archived_at: Utc::now(),
|
|
||||||
reason: ArchiveReason::Blocked {
|
|
||||||
reason: "test".into(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let result = transition(s, PipelineEvent::Unblock).unwrap();
|
|
||||||
assert!(matches!(result, Stage::Backlog));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Execution state ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn execution_happy_path() {
|
|
||||||
let e = ExecutionState::Idle;
|
|
||||||
let e = execution_transition(
|
|
||||||
e,
|
|
||||||
ExecutionEvent::SpawnRequested {
|
|
||||||
agent: AgentName("coder-1".into()),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(e, ExecutionState::Pending { .. }));
|
|
||||||
|
|
||||||
let e = execution_transition(e, ExecutionEvent::SpawnedSuccessfully).unwrap();
|
|
||||||
assert!(matches!(e, ExecutionState::Running { .. }));
|
|
||||||
|
|
||||||
let e = execution_transition(e, ExecutionEvent::Heartbeat).unwrap();
|
|
||||||
assert!(matches!(e, ExecutionState::Running { .. }));
|
|
||||||
|
|
||||||
let e = execution_transition(e, ExecutionEvent::Exited { exit_code: 0 }).unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
e,
|
|
||||||
ExecutionState::Completed { exit_code: 0, .. }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn execution_rate_limit_then_resume() {
|
|
||||||
let e = ExecutionState::Running {
|
|
||||||
agent: AgentName("coder-1".into()),
|
|
||||||
started_at: Utc::now(),
|
|
||||||
last_heartbeat: Utc::now(),
|
|
||||||
};
|
|
||||||
let e = execution_transition(
|
|
||||||
e,
|
|
||||||
ExecutionEvent::HitRateLimit {
|
|
||||||
resume_at: Utc::now() + chrono::Duration::minutes(5),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(e, ExecutionState::RateLimited { .. }));
|
|
||||||
|
|
||||||
let e = execution_transition(e, ExecutionEvent::SpawnedSuccessfully).unwrap();
|
|
||||||
assert!(matches!(e, ExecutionState::Running { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn execution_stop_from_anywhere_returns_idle() {
|
|
||||||
let e = ExecutionState::Running {
|
|
||||||
agent: AgentName("coder-1".into()),
|
|
||||||
started_at: Utc::now(),
|
|
||||||
last_heartbeat: Utc::now(),
|
|
||||||
};
|
|
||||||
let e = execution_transition(e, ExecutionEvent::Stopped).unwrap();
|
|
||||||
assert!(matches!(e, ExecutionState::Idle));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── main: a quick interactive demo ───────────────────────────────────────────
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
println!("─── Pipeline state machine sketch (story 520) ───\n");
|
|
||||||
|
|
||||||
// Set up the event bus with three subscribers — one for each side effect.
|
|
||||||
let mut bus = EventBus::new();
|
|
||||||
bus.subscribe(MatrixBotSub);
|
|
||||||
bus.subscribe(PipelineItemsSub);
|
|
||||||
bus.subscribe(FileRendererSub);
|
|
||||||
|
|
||||||
let story_id = StoryId("100_story_demo".into());
|
|
||||||
|
|
||||||
// Helper to apply a transition + fire the bus.
|
|
||||||
let mut current_stage = Stage::Backlog;
|
|
||||||
let step = |bus: &EventBus,
|
|
||||||
stage: &mut Stage,
|
|
||||||
event: PipelineEvent|
|
|
||||||
-> Result<(), TransitionError> {
|
|
||||||
let before = stage.clone();
|
|
||||||
let after = transition(stage.clone(), event.clone())?;
|
|
||||||
bus.fire(TransitionFired {
|
|
||||||
story_id: story_id.clone(),
|
|
||||||
before,
|
|
||||||
after: after.clone(),
|
|
||||||
event,
|
|
||||||
at: Utc::now(),
|
|
||||||
});
|
|
||||||
*stage = after;
|
|
||||||
Ok(())
|
|
||||||
};
|
|
||||||
|
|
||||||
println!("Initial: {current_stage:?}\n");
|
|
||||||
|
|
||||||
println!("→ DepsMet");
|
|
||||||
step(&bus, &mut current_stage, PipelineEvent::DepsMet).unwrap();
|
|
||||||
println!();
|
|
||||||
|
|
||||||
println!("→ QaSkipped (qa: server, gates auto-pass)");
|
|
||||||
step(
|
|
||||||
&bus,
|
|
||||||
&mut current_stage,
|
|
||||||
PipelineEvent::QaSkipped {
|
|
||||||
feature_branch: BranchName("feature/story-100".into()),
|
|
||||||
commits_ahead: NonZeroU32::new(3).unwrap(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
println!();
|
|
||||||
|
|
||||||
println!("→ MergeSucceeded");
|
|
||||||
step(
|
|
||||||
&bus,
|
|
||||||
&mut current_stage,
|
|
||||||
PipelineEvent::MergeSucceeded {
|
|
||||||
merge_commit: GitSha("abc1234".into()),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
println!();
|
|
||||||
|
|
||||||
println!("→ Accepted");
|
|
||||||
step(&bus, &mut current_stage, PipelineEvent::Accepted).unwrap();
|
|
||||||
println!();
|
|
||||||
|
|
||||||
println!("Final: {current_stage:?}\n");
|
|
||||||
|
|
||||||
println!("─── Trying an invalid transition: Done → Unblock ───");
|
|
||||||
let invalid_result = transition(current_stage.clone(), PipelineEvent::Unblock);
|
|
||||||
println!("Result: {invalid_result:?}");
|
|
||||||
}
|
|
||||||
@@ -1,785 +0,0 @@
|
|||||||
//! Pipeline state machine — design sketch (story 520) — STATIG version.
|
|
||||||
//!
|
|
||||||
//! Parallel to `pipeline_state_sketch_bare.rs`. Same domain types, same
|
|
||||||
//! transitions, same event semantics — but the state machine is built using
|
|
||||||
//! the `statig` crate (https://crates.io/crates/statig) instead of being
|
|
||||||
//! hand-rolled.
|
|
||||||
//!
|
|
||||||
//! Run with:
|
|
||||||
//! cargo run --example pipeline_state_sketch_statig -p huskies
|
|
||||||
//! Test with:
|
|
||||||
//! cargo test --example pipeline_state_sketch_statig -p huskies
|
|
||||||
//!
|
|
||||||
//! Why both versions?
|
|
||||||
//!
|
|
||||||
//! - The **bare** version shows that plain Rust enums + a transition function
|
|
||||||
//! are *enough* to make impossible states unrepresentable. No framework.
|
|
||||||
//! - The **statig** version shows what we'd gain by adopting a state-machine
|
|
||||||
//! crate: hierarchical states (the `active` superstate factors out the
|
|
||||||
//! cross-cutting Block/ReviewHold/Abandon/Supersede transitions, which the
|
|
||||||
//! bare version had to duplicate inline with `|` patterns), generated
|
|
||||||
//! `State` enum with type-safe data-carrying constructors, and stateful
|
|
||||||
//! `handle(&event)` dispatch. Type safety is preserved either way:
|
|
||||||
//! `State::merge(BranchName, NonZeroU32)` requires both args at the
|
|
||||||
//! constructor, just like `Stage::Merge { feature_branch, commits_ahead }`
|
|
||||||
//! in the bare version.
|
|
||||||
//!
|
|
||||||
//! Trade-off: statig adds a dependency and a proc-macro layer, which makes
|
|
||||||
//! the code harder to read for someone unfamiliar with the crate. The
|
|
||||||
//! framework-free version is more transparent but requires manual
|
|
||||||
//! pattern-matching and inline duplication for cross-cutting transitions.
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use statig::prelude::*;
|
|
||||||
use std::num::NonZeroU32;
|
|
||||||
|
|
||||||
// ── Newtypes (same as bare version) ──────────────────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
||||||
pub struct StoryId(pub String);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
||||||
pub struct BranchName(pub String);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
||||||
pub struct GitSha(pub String);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
||||||
pub struct AgentName(pub String);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
||||||
pub struct NodePubkey(pub [u8; 32]);
|
|
||||||
|
|
||||||
// ── Archive reason (same as bare version) ────────────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum ArchiveReason {
|
|
||||||
Completed,
|
|
||||||
Abandoned,
|
|
||||||
Superseded { by: StoryId },
|
|
||||||
Blocked { reason: String },
|
|
||||||
MergeFailed { reason: String },
|
|
||||||
ReviewHeld { reason: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pipeline events (same as bare version) ───────────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum PipelineEvent {
|
|
||||||
DepsMet,
|
|
||||||
GatesStarted,
|
|
||||||
GatesPassed {
|
|
||||||
feature_branch: BranchName,
|
|
||||||
commits_ahead: NonZeroU32,
|
|
||||||
},
|
|
||||||
GatesFailed {
|
|
||||||
reason: String,
|
|
||||||
},
|
|
||||||
QaSkipped {
|
|
||||||
feature_branch: BranchName,
|
|
||||||
commits_ahead: NonZeroU32,
|
|
||||||
},
|
|
||||||
MergeSucceeded {
|
|
||||||
merge_commit: GitSha,
|
|
||||||
},
|
|
||||||
MergeFailedFinal {
|
|
||||||
reason: String,
|
|
||||||
},
|
|
||||||
Accepted,
|
|
||||||
Block {
|
|
||||||
reason: String,
|
|
||||||
},
|
|
||||||
Unblock,
|
|
||||||
Abandon,
|
|
||||||
Supersede {
|
|
||||||
by: StoryId,
|
|
||||||
},
|
|
||||||
ReviewHold {
|
|
||||||
reason: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── The state machine ────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// statig requires a "context" struct (the `Self` of the impl block). For us
|
|
||||||
// it's empty — all per-state data lives ON the state itself, carried forward
|
|
||||||
// by the auto-generated `State::xxx(...)` constructors.
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct PipelineMachine;
|
|
||||||
|
|
||||||
#[state_machine(
|
|
||||||
initial = "State::backlog()",
|
|
||||||
state(derive(Debug, Clone, PartialEq, Eq))
|
|
||||||
)]
|
|
||||||
impl PipelineMachine {
|
|
||||||
// ── Active stages: backlog, coding, qa, merge ────────────────────────
|
|
||||||
//
|
|
||||||
// Each is a child of the `active` superstate, which handles the
|
|
||||||
// cross-cutting transitions (Block / ReviewHold / Abandon / Supersede)
|
|
||||||
// exactly once instead of being duplicated per state.
|
|
||||||
|
|
||||||
#[state(superstate = "active")]
|
|
||||||
fn backlog(event: &PipelineEvent) -> Response<State> {
|
|
||||||
match event {
|
|
||||||
PipelineEvent::DepsMet => Transition(State::coding()),
|
|
||||||
_ => Super, // defer to `active` (and ultimately to "unhandled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[state(superstate = "active")]
|
|
||||||
fn coding(event: &PipelineEvent) -> Response<State> {
|
|
||||||
match event {
|
|
||||||
PipelineEvent::GatesStarted => Transition(State::qa()),
|
|
||||||
PipelineEvent::QaSkipped {
|
|
||||||
feature_branch,
|
|
||||||
commits_ahead,
|
|
||||||
} => Transition(State::merge(feature_branch.clone(), *commits_ahead)),
|
|
||||||
_ => Super,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[state(superstate = "active")]
|
|
||||||
fn qa(event: &PipelineEvent) -> Response<State> {
|
|
||||||
match event {
|
|
||||||
PipelineEvent::GatesPassed {
|
|
||||||
feature_branch,
|
|
||||||
commits_ahead,
|
|
||||||
} => Transition(State::merge(feature_branch.clone(), *commits_ahead)),
|
|
||||||
PipelineEvent::GatesFailed { .. } => Transition(State::coding()),
|
|
||||||
_ => Super,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[state(superstate = "active")]
|
|
||||||
fn merge(
|
|
||||||
_feature_branch: &mut BranchName,
|
|
||||||
_commits_ahead: &mut NonZeroU32,
|
|
||||||
event: &PipelineEvent,
|
|
||||||
) -> Response<State> {
|
|
||||||
// Note: the type signature of this state function REQUIRES both
|
|
||||||
// _feature_branch and _commits_ahead. There is no way to construct
|
|
||||||
// a Merge state without them. NonZeroU32 makes "merge with zero
|
|
||||||
// commits ahead" structurally unrepresentable (bug 519 fixed by
|
|
||||||
// construction, same as the bare version).
|
|
||||||
//
|
|
||||||
// The fields are prefixed with `_` because this state function only
|
|
||||||
// transitions forward and doesn't read them — but they're available
|
|
||||||
// to inspect via the State::Merge variant generated by the macro.
|
|
||||||
match event {
|
|
||||||
PipelineEvent::MergeSucceeded { merge_commit } => Transition(State::done(
|
|
||||||
Utc::now(),
|
|
||||||
merge_commit.clone(),
|
|
||||||
)),
|
|
||||||
PipelineEvent::MergeFailedFinal { reason } => Transition(State::archived(
|
|
||||||
Utc::now(),
|
|
||||||
ArchiveReason::MergeFailed {
|
|
||||||
reason: reason.clone(),
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
_ => Super,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cross-cutting superstate ─────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// This is the statig payoff: ONE place defines what Block/ReviewHold/
|
|
||||||
// Abandon/Supersede do across all four active stages. The bare version
|
|
||||||
// had to duplicate this with `|` patterns. Adding a new active stage
|
|
||||||
// here means just adding it as a child of `active`; the cross-cutting
|
|
||||||
// transitions come for free.
|
|
||||||
|
|
||||||
#[superstate]
|
|
||||||
fn active(event: &PipelineEvent) -> Response<State> {
|
|
||||||
let now = Utc::now();
|
|
||||||
match event {
|
|
||||||
PipelineEvent::Block { reason } => Transition(State::archived(
|
|
||||||
now,
|
|
||||||
ArchiveReason::Blocked {
|
|
||||||
reason: reason.clone(),
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
PipelineEvent::ReviewHold { reason } => Transition(State::archived(
|
|
||||||
now,
|
|
||||||
ArchiveReason::ReviewHeld {
|
|
||||||
reason: reason.clone(),
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
PipelineEvent::Abandon => {
|
|
||||||
Transition(State::archived(now, ArchiveReason::Abandoned))
|
|
||||||
}
|
|
||||||
PipelineEvent::Supersede { by } => Transition(State::archived(
|
|
||||||
now,
|
|
||||||
ArchiveReason::Superseded { by: by.clone() },
|
|
||||||
)),
|
|
||||||
_ => Handled, // unhandled events are silently ignored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Done is special: it's not a child of `active` because Block and ──
|
|
||||||
// ── ReviewHold are NOT valid from Done (per the bare version's rules).
|
|
||||||
// ── Abandon and Supersede ARE valid, so we have to handle them inline.
|
|
||||||
|
|
||||||
#[state]
|
|
||||||
fn done(
|
|
||||||
merged_at: &mut DateTime<Utc>,
|
|
||||||
merge_commit: &mut GitSha,
|
|
||||||
event: &PipelineEvent,
|
|
||||||
) -> Response<State> {
|
|
||||||
let now = Utc::now();
|
|
||||||
let _ = merged_at; // currently unused; available for queries
|
|
||||||
let _ = merge_commit;
|
|
||||||
match event {
|
|
||||||
PipelineEvent::Accepted => {
|
|
||||||
Transition(State::archived(now, ArchiveReason::Completed))
|
|
||||||
}
|
|
||||||
PipelineEvent::Abandon => {
|
|
||||||
Transition(State::archived(now, ArchiveReason::Abandoned))
|
|
||||||
}
|
|
||||||
PipelineEvent::Supersede { by } => Transition(State::archived(
|
|
||||||
now,
|
|
||||||
ArchiveReason::Superseded { by: by.clone() },
|
|
||||||
)),
|
|
||||||
_ => Handled,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Archived is terminal except for Unblock from Blocked → Backlog ───
|
|
||||||
|
|
||||||
#[state]
|
|
||||||
fn archived(
|
|
||||||
archived_at: &mut DateTime<Utc>,
|
|
||||||
reason: &mut ArchiveReason,
|
|
||||||
event: &PipelineEvent,
|
|
||||||
) -> Response<State> {
|
|
||||||
let _ = archived_at;
|
|
||||||
match event {
|
|
||||||
PipelineEvent::Unblock => {
|
|
||||||
if matches!(reason, ArchiveReason::Blocked { .. }) {
|
|
||||||
Transition(State::backlog())
|
|
||||||
} else {
|
|
||||||
Handled // unblock only valid from Blocked
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Handled,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Per-node execution state machine ─────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Independent of the pipeline stage machine. Tracks "what is THIS node doing
|
|
||||||
// about this story right now." Lives in its own sub-module so its generated
|
|
||||||
// `State` enum doesn't collide with `PipelineMachine`'s.
|
|
||||||
//
|
|
||||||
// In a real implementation, multiple nodes can have different ExecutionState
|
|
||||||
// for the same story_id at the same time — and that's fine, because each
|
|
||||||
// node owns its own subspace in the CRDT (keyed by node pubkey).
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum ExecutionEvent {
|
|
||||||
SpawnRequested { agent: AgentName },
|
|
||||||
SpawnedSuccessfully,
|
|
||||||
Heartbeat,
|
|
||||||
HitRateLimit { resume_at: DateTime<Utc> },
|
|
||||||
Exited { exit_code: i32 },
|
|
||||||
Stopped,
|
|
||||||
Reset,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod execution {
|
|
||||||
use super::{AgentName, DateTime, ExecutionEvent, Utc};
|
|
||||||
use statig::prelude::*;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct ExecutionMachine;
|
|
||||||
|
|
||||||
#[state_machine(
|
|
||||||
initial = "State::idle()",
|
|
||||||
state(derive(Debug, Clone, PartialEq, Eq))
|
|
||||||
)]
|
|
||||||
impl ExecutionMachine {
|
|
||||||
// ── Idle: no agent on this node is working on this story ──────────
|
|
||||||
|
|
||||||
#[state(superstate = "any")]
|
|
||||||
fn idle(event: &ExecutionEvent) -> Response<State> {
|
|
||||||
match event {
|
|
||||||
ExecutionEvent::SpawnRequested { agent } => {
|
|
||||||
Transition(State::pending(agent.clone(), Utc::now()))
|
|
||||||
}
|
|
||||||
_ => Super,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pending: agent has been requested but hasn't started yet ──────
|
|
||||||
|
|
||||||
#[state(superstate = "any")]
|
|
||||||
fn pending(
|
|
||||||
agent: &mut AgentName,
|
|
||||||
_since: &mut DateTime<Utc>,
|
|
||||||
event: &ExecutionEvent,
|
|
||||||
) -> Response<State> {
|
|
||||||
match event {
|
|
||||||
ExecutionEvent::SpawnedSuccessfully => {
|
|
||||||
let now = Utc::now();
|
|
||||||
Transition(State::running(agent.clone(), now, now))
|
|
||||||
}
|
|
||||||
ExecutionEvent::HitRateLimit { resume_at } => {
|
|
||||||
Transition(State::rate_limited(agent.clone(), *resume_at))
|
|
||||||
}
|
|
||||||
ExecutionEvent::Exited { exit_code } => Transition(State::completed(
|
|
||||||
agent.clone(),
|
|
||||||
*exit_code,
|
|
||||||
Utc::now(),
|
|
||||||
)),
|
|
||||||
_ => Super,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Running: agent's subprocess is alive ──────────────────────────
|
|
||||||
//
|
|
||||||
// Heartbeat is a self-transition: we update last_heartbeat in-place
|
|
||||||
// via the &mut reference and return `Handled` (no actual stage change).
|
|
||||||
// This is statig's idiomatic way to mutate state-local data without
|
|
||||||
// transitioning.
|
|
||||||
|
|
||||||
#[state(superstate = "any")]
|
|
||||||
fn running(
|
|
||||||
agent: &mut AgentName,
|
|
||||||
_started_at: &mut DateTime<Utc>,
|
|
||||||
last_heartbeat: &mut DateTime<Utc>,
|
|
||||||
event: &ExecutionEvent,
|
|
||||||
) -> Response<State> {
|
|
||||||
match event {
|
|
||||||
ExecutionEvent::Heartbeat => {
|
|
||||||
*last_heartbeat = Utc::now();
|
|
||||||
Handled
|
|
||||||
}
|
|
||||||
ExecutionEvent::HitRateLimit { resume_at } => {
|
|
||||||
Transition(State::rate_limited(agent.clone(), *resume_at))
|
|
||||||
}
|
|
||||||
ExecutionEvent::Exited { exit_code } => Transition(State::completed(
|
|
||||||
agent.clone(),
|
|
||||||
*exit_code,
|
|
||||||
Utc::now(),
|
|
||||||
)),
|
|
||||||
_ => Super,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── RateLimited: waiting for the API rate-limit window to clear ───
|
|
||||||
|
|
||||||
#[state(superstate = "any")]
|
|
||||||
fn rate_limited(
|
|
||||||
agent: &mut AgentName,
|
|
||||||
_resume_at: &mut DateTime<Utc>,
|
|
||||||
event: &ExecutionEvent,
|
|
||||||
) -> Response<State> {
|
|
||||||
match event {
|
|
||||||
ExecutionEvent::SpawnedSuccessfully => {
|
|
||||||
let now = Utc::now();
|
|
||||||
Transition(State::running(agent.clone(), now, now))
|
|
||||||
}
|
|
||||||
ExecutionEvent::Exited { exit_code } => Transition(State::completed(
|
|
||||||
agent.clone(),
|
|
||||||
*exit_code,
|
|
||||||
Utc::now(),
|
|
||||||
)),
|
|
||||||
_ => Super,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Completed: agent finished, exit code captured ─────────────────
|
|
||||||
|
|
||||||
#[state(superstate = "any")]
|
|
||||||
fn completed(
|
|
||||||
agent: &mut AgentName,
|
|
||||||
exit_code: &mut i32,
|
|
||||||
completed_at: &mut DateTime<Utc>,
|
|
||||||
event: &ExecutionEvent,
|
|
||||||
) -> Response<State> {
|
|
||||||
// Completed is mostly terminal; only Stopped/Reset (handled by
|
|
||||||
// the `any` superstate) returns to Idle. Field names are kept
|
|
||||||
// un-underscored so the generated State::Completed variant
|
|
||||||
// exposes them as `exit_code` etc. for test pattern matching.
|
|
||||||
let _ = (agent, exit_code, completed_at, event);
|
|
||||||
Super
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cross-cutting: Stopped and Reset return to Idle from anywhere ─
|
|
||||||
|
|
||||||
#[superstate]
|
|
||||||
fn any(event: &ExecutionEvent) -> Response<State> {
|
|
||||||
match event {
|
|
||||||
ExecutionEvent::Stopped | ExecutionEvent::Reset => {
|
|
||||||
Transition(State::idle())
|
|
||||||
}
|
|
||||||
_ => Handled,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Side effects via statig's entry/exit actions (alternative to EventBus) ───
|
|
||||||
//
|
|
||||||
// The bare version uses an explicit EventBus + Subscriber trait + per-state
|
|
||||||
// publish-on-transition pattern. statig has a more native equivalent:
|
|
||||||
// `#[action]`-tagged functions that fire on state entry / exit / transition.
|
|
||||||
//
|
|
||||||
// We don't include a full action-based example here — it would roughly look
|
|
||||||
// like adding `entry_action = "log_entry"` to each #[state] attribute and
|
|
||||||
// defining `fn log_entry(...)` in the impl block. The trade-off is that
|
|
||||||
// statig's actions are tightly coupled to the state machine impl block,
|
|
||||||
// while the bare version's EventBus allows arbitrary external subscribers
|
|
||||||
// to plug in without touching the state machine code. Both patterns are
|
|
||||||
// valid; pick based on whether you want side-effect dispatch INSIDE the
|
|
||||||
// machine (statig actions) or OUTSIDE it (bare EventBus).
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn nz(n: u32) -> NonZeroU32 {
|
|
||||||
NonZeroU32::new(n).unwrap()
|
|
||||||
}
|
|
||||||
fn fb(name: &str) -> BranchName {
|
|
||||||
BranchName(name.to_string())
|
|
||||||
}
|
|
||||||
fn sha(s: &str) -> GitSha {
|
|
||||||
GitSha(s.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Happy path ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn happy_path_backlog_through_done() {
|
|
||||||
let mut sm = PipelineMachine.state_machine();
|
|
||||||
assert!(matches!(sm.state(), State::Backlog {}));
|
|
||||||
|
|
||||||
sm.handle(&PipelineEvent::DepsMet);
|
|
||||||
assert!(matches!(sm.state(), State::Coding {}));
|
|
||||||
|
|
||||||
sm.handle(&PipelineEvent::QaSkipped {
|
|
||||||
feature_branch: fb("feature/story-1"),
|
|
||||||
commits_ahead: nz(3),
|
|
||||||
});
|
|
||||||
assert!(matches!(sm.state(), State::Merge { .. }));
|
|
||||||
|
|
||||||
sm.handle(&PipelineEvent::MergeSucceeded {
|
|
||||||
merge_commit: sha("abc123"),
|
|
||||||
});
|
|
||||||
assert!(matches!(sm.state(), State::Done { .. }));
|
|
||||||
|
|
||||||
sm.handle(&PipelineEvent::Accepted);
|
|
||||||
assert!(matches!(
|
|
||||||
sm.state(),
|
|
||||||
State::Archived {
|
|
||||||
reason: ArchiveReason::Completed,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn qa_retry_loop() {
|
|
||||||
let mut sm = PipelineMachine.state_machine();
|
|
||||||
sm.handle(&PipelineEvent::DepsMet);
|
|
||||||
sm.handle(&PipelineEvent::GatesStarted);
|
|
||||||
assert!(matches!(sm.state(), State::Qa {}));
|
|
||||||
|
|
||||||
sm.handle(&PipelineEvent::GatesFailed {
|
|
||||||
reason: "tests failed".into(),
|
|
||||||
});
|
|
||||||
assert!(matches!(sm.state(), State::Coding {}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Bug 519 unrepresentability: Merge with zero commits ahead ──────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn merge_with_zero_commits_is_unrepresentable() {
|
|
||||||
// Identical to the bare version: NonZeroU32::new(0) returns None,
|
|
||||||
// so a State::merge(branch, ZERO) literally cannot be constructed.
|
|
||||||
assert!(NonZeroU32::new(0).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cross-cutting Block from any active stage (superstate proves it) ───
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn block_from_backlog_via_superstate() {
|
|
||||||
let mut sm = PipelineMachine.state_machine();
|
|
||||||
sm.handle(&PipelineEvent::Block {
|
|
||||||
reason: "stuck".into(),
|
|
||||||
});
|
|
||||||
assert!(matches!(
|
|
||||||
sm.state(),
|
|
||||||
State::Archived {
|
|
||||||
reason: ArchiveReason::Blocked { .. },
|
|
||||||
..
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn block_from_coding_via_superstate() {
|
|
||||||
let mut sm = PipelineMachine.state_machine();
|
|
||||||
sm.handle(&PipelineEvent::DepsMet);
|
|
||||||
sm.handle(&PipelineEvent::Block {
|
|
||||||
reason: "stuck".into(),
|
|
||||||
});
|
|
||||||
assert!(matches!(
|
|
||||||
sm.state(),
|
|
||||||
State::Archived {
|
|
||||||
reason: ArchiveReason::Blocked { .. },
|
|
||||||
..
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn block_from_qa_via_superstate() {
|
|
||||||
let mut sm = PipelineMachine.state_machine();
|
|
||||||
sm.handle(&PipelineEvent::DepsMet);
|
|
||||||
sm.handle(&PipelineEvent::GatesStarted);
|
|
||||||
sm.handle(&PipelineEvent::Block {
|
|
||||||
reason: "stuck".into(),
|
|
||||||
});
|
|
||||||
assert!(matches!(
|
|
||||||
sm.state(),
|
|
||||||
State::Archived {
|
|
||||||
reason: ArchiveReason::Blocked { .. },
|
|
||||||
..
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn block_from_merge_via_superstate() {
|
|
||||||
let mut sm = PipelineMachine.state_machine();
|
|
||||||
sm.handle(&PipelineEvent::DepsMet);
|
|
||||||
sm.handle(&PipelineEvent::QaSkipped {
|
|
||||||
feature_branch: fb("f"),
|
|
||||||
commits_ahead: nz(1),
|
|
||||||
});
|
|
||||||
sm.handle(&PipelineEvent::Block {
|
|
||||||
reason: "stuck".into(),
|
|
||||||
});
|
|
||||||
assert!(matches!(
|
|
||||||
sm.state(),
|
|
||||||
State::Archived {
|
|
||||||
reason: ArchiveReason::Blocked { .. },
|
|
||||||
..
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Block from Done is NOT valid (Done isn't a child of `active`) ──────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn block_from_done_is_ignored() {
|
|
||||||
let mut sm = PipelineMachine.state_machine();
|
|
||||||
sm.handle(&PipelineEvent::DepsMet);
|
|
||||||
sm.handle(&PipelineEvent::QaSkipped {
|
|
||||||
feature_branch: fb("f"),
|
|
||||||
commits_ahead: nz(1),
|
|
||||||
});
|
|
||||||
sm.handle(&PipelineEvent::MergeSucceeded {
|
|
||||||
merge_commit: sha("abc"),
|
|
||||||
});
|
|
||||||
// Now in Done. Block should NOT transition us anywhere.
|
|
||||||
sm.handle(&PipelineEvent::Block {
|
|
||||||
reason: "stuck".into(),
|
|
||||||
});
|
|
||||||
assert!(matches!(sm.state(), State::Done { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Abandon from Done IS valid (handled inline in done()) ──────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn abandon_from_done_works() {
|
|
||||||
let mut sm = PipelineMachine.state_machine();
|
|
||||||
sm.handle(&PipelineEvent::DepsMet);
|
|
||||||
sm.handle(&PipelineEvent::QaSkipped {
|
|
||||||
feature_branch: fb("f"),
|
|
||||||
commits_ahead: nz(1),
|
|
||||||
});
|
|
||||||
sm.handle(&PipelineEvent::MergeSucceeded {
|
|
||||||
merge_commit: sha("abc"),
|
|
||||||
});
|
|
||||||
sm.handle(&PipelineEvent::Abandon);
|
|
||||||
assert!(matches!(
|
|
||||||
sm.state(),
|
|
||||||
State::Archived {
|
|
||||||
reason: ArchiveReason::Abandoned,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Unblock from Archived(Blocked) → Backlog ───────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unblock_returns_to_backlog() {
|
|
||||||
let mut sm = PipelineMachine.state_machine();
|
|
||||||
sm.handle(&PipelineEvent::Block {
|
|
||||||
reason: "test".into(),
|
|
||||||
});
|
|
||||||
assert!(matches!(
|
|
||||||
sm.state(),
|
|
||||||
State::Archived {
|
|
||||||
reason: ArchiveReason::Blocked { .. },
|
|
||||||
..
|
|
||||||
}
|
|
||||||
));
|
|
||||||
|
|
||||||
sm.handle(&PipelineEvent::Unblock);
|
|
||||||
assert!(matches!(sm.state(), State::Backlog {}));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unblock_from_review_held_does_nothing() {
|
|
||||||
// Unblock is specifically for Blocked, not for any Archived variant.
|
|
||||||
let mut sm = PipelineMachine.state_machine();
|
|
||||||
sm.handle(&PipelineEvent::ReviewHold {
|
|
||||||
reason: "TBD".into(),
|
|
||||||
});
|
|
||||||
// Now in Archived(ReviewHeld). Unblock should NOT transition.
|
|
||||||
sm.handle(&PipelineEvent::Unblock);
|
|
||||||
assert!(matches!(
|
|
||||||
sm.state(),
|
|
||||||
State::Archived {
|
|
||||||
reason: ArchiveReason::ReviewHeld { .. },
|
|
||||||
..
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ExecutionMachine tests ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
use super::execution::{ExecutionMachine, State as ExecState};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn execution_happy_path() {
|
|
||||||
let mut em = ExecutionMachine.state_machine();
|
|
||||||
assert!(matches!(em.state(), ExecState::Idle {}));
|
|
||||||
|
|
||||||
em.handle(&ExecutionEvent::SpawnRequested {
|
|
||||||
agent: AgentName("coder-1".into()),
|
|
||||||
});
|
|
||||||
assert!(matches!(em.state(), ExecState::Pending { .. }));
|
|
||||||
|
|
||||||
em.handle(&ExecutionEvent::SpawnedSuccessfully);
|
|
||||||
assert!(matches!(em.state(), ExecState::Running { .. }));
|
|
||||||
|
|
||||||
em.handle(&ExecutionEvent::Heartbeat);
|
|
||||||
// Heartbeat updates last_heartbeat in-place; we stay in Running.
|
|
||||||
assert!(matches!(em.state(), ExecState::Running { .. }));
|
|
||||||
|
|
||||||
em.handle(&ExecutionEvent::Exited { exit_code: 0 });
|
|
||||||
assert!(matches!(em.state(), ExecState::Completed { exit_code: 0, .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn execution_rate_limit_then_resume() {
|
|
||||||
let mut em = ExecutionMachine.state_machine();
|
|
||||||
em.handle(&ExecutionEvent::SpawnRequested {
|
|
||||||
agent: AgentName("coder-1".into()),
|
|
||||||
});
|
|
||||||
em.handle(&ExecutionEvent::SpawnedSuccessfully);
|
|
||||||
em.handle(&ExecutionEvent::HitRateLimit {
|
|
||||||
resume_at: Utc::now() + chrono::Duration::minutes(5),
|
|
||||||
});
|
|
||||||
assert!(matches!(em.state(), ExecState::RateLimited { .. }));
|
|
||||||
|
|
||||||
em.handle(&ExecutionEvent::SpawnedSuccessfully);
|
|
||||||
assert!(matches!(em.state(), ExecState::Running { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn execution_stop_from_running_returns_idle_via_superstate() {
|
|
||||||
let mut em = ExecutionMachine.state_machine();
|
|
||||||
em.handle(&ExecutionEvent::SpawnRequested {
|
|
||||||
agent: AgentName("coder-1".into()),
|
|
||||||
});
|
|
||||||
em.handle(&ExecutionEvent::SpawnedSuccessfully);
|
|
||||||
em.handle(&ExecutionEvent::Stopped);
|
|
||||||
assert!(matches!(em.state(), ExecState::Idle {}));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn execution_stop_from_pending_returns_idle_via_superstate() {
|
|
||||||
let mut em = ExecutionMachine.state_machine();
|
|
||||||
em.handle(&ExecutionEvent::SpawnRequested {
|
|
||||||
agent: AgentName("coder-1".into()),
|
|
||||||
});
|
|
||||||
em.handle(&ExecutionEvent::Stopped);
|
|
||||||
assert!(matches!(em.state(), ExecState::Idle {}));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn execution_stop_from_rate_limited_returns_idle_via_superstate() {
|
|
||||||
let mut em = ExecutionMachine.state_machine();
|
|
||||||
em.handle(&ExecutionEvent::SpawnRequested {
|
|
||||||
agent: AgentName("coder-1".into()),
|
|
||||||
});
|
|
||||||
em.handle(&ExecutionEvent::SpawnedSuccessfully);
|
|
||||||
em.handle(&ExecutionEvent::HitRateLimit {
|
|
||||||
resume_at: Utc::now() + chrono::Duration::minutes(5),
|
|
||||||
});
|
|
||||||
em.handle(&ExecutionEvent::Stopped);
|
|
||||||
assert!(matches!(em.state(), ExecState::Idle {}));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nodepubkey_type_is_constructible() {
|
|
||||||
// Just exercise the NodePubkey newtype so it isn't dead code.
|
|
||||||
// In a real implementation it'd key the per-node ExecutionState
|
|
||||||
// map inside the CRDT.
|
|
||||||
let _ = NodePubkey([0u8; 32]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── main: a quick interactive demo ───────────────────────────────────────────
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
println!("─── Pipeline state machine sketch (story 520) — STATIG version ───\n");
|
|
||||||
|
|
||||||
let mut sm = PipelineMachine.state_machine();
|
|
||||||
println!("Initial: {:?}\n", sm.state());
|
|
||||||
|
|
||||||
println!("→ DepsMet");
|
|
||||||
sm.handle(&PipelineEvent::DepsMet);
|
|
||||||
println!(" state: {:?}\n", sm.state());
|
|
||||||
|
|
||||||
println!("→ QaSkipped");
|
|
||||||
sm.handle(&PipelineEvent::QaSkipped {
|
|
||||||
feature_branch: BranchName("feature/story-100".into()),
|
|
||||||
commits_ahead: NonZeroU32::new(3).unwrap(),
|
|
||||||
});
|
|
||||||
println!(" state: {:?}\n", sm.state());
|
|
||||||
|
|
||||||
println!("→ MergeSucceeded");
|
|
||||||
sm.handle(&PipelineEvent::MergeSucceeded {
|
|
||||||
merge_commit: GitSha("abc1234".into()),
|
|
||||||
});
|
|
||||||
println!(" state: {:?}\n", sm.state());
|
|
||||||
|
|
||||||
println!("→ Accepted");
|
|
||||||
sm.handle(&PipelineEvent::Accepted);
|
|
||||||
println!(" state: {:?}\n", sm.state());
|
|
||||||
|
|
||||||
println!("─── Trying invalid transition: Done → Unblock ───");
|
|
||||||
let mut sm2 = PipelineMachine.state_machine();
|
|
||||||
sm2.handle(&PipelineEvent::DepsMet);
|
|
||||||
sm2.handle(&PipelineEvent::QaSkipped {
|
|
||||||
feature_branch: BranchName("feature/story-101".into()),
|
|
||||||
commits_ahead: NonZeroU32::new(2).unwrap(),
|
|
||||||
});
|
|
||||||
sm2.handle(&PipelineEvent::MergeSucceeded {
|
|
||||||
merge_commit: GitSha("def5678".into()),
|
|
||||||
});
|
|
||||||
println!(" before Unblock: {:?}", sm2.state());
|
|
||||||
sm2.handle(&PipelineEvent::Unblock); // silently ignored — no transition
|
|
||||||
println!(" after Unblock: {:?} (no change — Unblock is a no-op from Done)", sm2.state());
|
|
||||||
}
|
|
||||||
+45
-58
@@ -6,7 +6,6 @@ use std::fs::{self, File, OpenOptions};
|
|||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
|
||||||
/// A single line in the agent log file (JSONL format).
|
/// A single line in the agent log file (JSONL format).
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct LogEntry {
|
pub struct LogEntry {
|
||||||
@@ -72,10 +71,7 @@ impl AgentLogWriter {
|
|||||||
|
|
||||||
/// Return the log directory for a story.
|
/// Return the log directory for a story.
|
||||||
fn log_dir(project_root: &Path, story_id: &str) -> PathBuf {
|
fn log_dir(project_root: &Path, story_id: &str) -> PathBuf {
|
||||||
project_root
|
project_root.join(".huskies").join("logs").join(story_id)
|
||||||
.join(".huskies")
|
|
||||||
.join("logs")
|
|
||||||
.join(story_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the path to a specific log file.
|
/// Return the path to a specific log file.
|
||||||
@@ -102,8 +98,8 @@ pub fn read_log(path: &Path) -> Result<Vec<LogEntry>, String> {
|
|||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let entry: LogEntry = serde_json::from_str(trimmed)
|
let entry: LogEntry =
|
||||||
.map_err(|e| format!("Failed to parse log entry: {e}"))?;
|
serde_json::from_str(trimmed).map_err(|e| format!("Failed to parse log entry: {e}"))?;
|
||||||
entries.push(entry);
|
entries.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,10 +193,7 @@ pub fn format_log_entry_as_text(timestamp: &str, event: &serde_json::Value) -> O
|
|||||||
Some("done") => Some(format!("{pfx} DONE")),
|
Some("done") => Some(format!("{pfx} DONE")),
|
||||||
Some("status") => {
|
Some("status") => {
|
||||||
// Skip trivial running/started noise
|
// Skip trivial running/started noise
|
||||||
let status = event
|
let status = event.get("status").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
.get("status")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("?");
|
|
||||||
match status {
|
match status {
|
||||||
"running" | "started" => None,
|
"running" | "started" => None,
|
||||||
_ => Some(format!("{pfx} STATUS: {status}")),
|
_ => Some(format!("{pfx} STATUS: {status}")),
|
||||||
@@ -211,10 +204,7 @@ pub fn format_log_entry_as_text(timestamp: &str, event: &serde_json::Value) -> O
|
|||||||
match data.get("type").and_then(|v| v.as_str()) {
|
match data.get("type").and_then(|v| v.as_str()) {
|
||||||
Some("assistant") => {
|
Some("assistant") => {
|
||||||
let mut parts: Vec<String> = Vec::new();
|
let mut parts: Vec<String> = Vec::new();
|
||||||
if let Some(arr) = data
|
if let Some(arr) = data.pointer("/message/content").and_then(|v| v.as_array()) {
|
||||||
.pointer("/message/content")
|
|
||||||
.and_then(|v| v.as_array())
|
|
||||||
{
|
|
||||||
for item in arr {
|
for item in arr {
|
||||||
match item.get("type").and_then(|v| v.as_str()) {
|
match item.get("type").and_then(|v| v.as_str()) {
|
||||||
Some("text") => {
|
Some("text") => {
|
||||||
@@ -228,15 +218,11 @@ pub fn format_log_entry_as_text(timestamp: &str, event: &serde_json::Value) -> O
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("tool_use") => {
|
Some("tool_use") => {
|
||||||
let name = item
|
let name =
|
||||||
.get("name")
|
item.get("name").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("?");
|
|
||||||
let input = item
|
let input = item
|
||||||
.get("input")
|
.get("input")
|
||||||
.map(|v| {
|
.map(|v| serde_json::to_string(v).unwrap_or_default())
|
||||||
serde_json::to_string(v).unwrap_or_default()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let display = if input.len() > 200 {
|
let display = if input.len() > 200 {
|
||||||
format!("{}...", &input[..200])
|
format!("{}...", &input[..200])
|
||||||
@@ -257,14 +243,9 @@ pub fn format_log_entry_as_text(timestamp: &str, event: &serde_json::Value) -> O
|
|||||||
}
|
}
|
||||||
Some("user") => {
|
Some("user") => {
|
||||||
let mut parts: Vec<String> = Vec::new();
|
let mut parts: Vec<String> = Vec::new();
|
||||||
if let Some(arr) = data
|
if let Some(arr) = data.pointer("/message/content").and_then(|v| v.as_array()) {
|
||||||
.pointer("/message/content")
|
|
||||||
.and_then(|v| v.as_array())
|
|
||||||
{
|
|
||||||
for item in arr {
|
for item in arr {
|
||||||
if item.get("type").and_then(|v| v.as_str())
|
if item.get("type").and_then(|v| v.as_str()) != Some("tool_result") {
|
||||||
!= Some("tool_result")
|
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let content_str = match item.get("content") {
|
let content_str = match item.get("content") {
|
||||||
@@ -316,11 +297,7 @@ pub fn read_log_as_readable_lines(path: &Path) -> Result<Vec<String>, String> {
|
|||||||
///
|
///
|
||||||
/// Scans `.huskies/logs/{story_id}/` for files matching `{agent_name}-*.log`
|
/// Scans `.huskies/logs/{story_id}/` for files matching `{agent_name}-*.log`
|
||||||
/// and returns the one with the most recent modification time.
|
/// and returns the one with the most recent modification time.
|
||||||
pub fn find_latest_log(
|
pub fn find_latest_log(project_root: &Path, story_id: &str, agent_name: &str) -> Option<PathBuf> {
|
||||||
project_root: &Path,
|
|
||||||
story_id: &str,
|
|
||||||
agent_name: &str,
|
|
||||||
) -> Option<PathBuf> {
|
|
||||||
let dir = log_dir(project_root, story_id);
|
let dir = log_dir(project_root, story_id);
|
||||||
if !dir.is_dir() {
|
if !dir.is_dir() {
|
||||||
return None;
|
return None;
|
||||||
@@ -362,8 +339,7 @@ mod tests {
|
|||||||
let tmp = tempdir().unwrap();
|
let tmp = tempdir().unwrap();
|
||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
|
|
||||||
let _writer =
|
let _writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-abc123").unwrap();
|
||||||
AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-abc123").unwrap();
|
|
||||||
|
|
||||||
let expected_path = root
|
let expected_path = root
|
||||||
.join(".huskies")
|
.join(".huskies")
|
||||||
@@ -378,8 +354,7 @@ mod tests {
|
|||||||
let tmp = tempdir().unwrap();
|
let tmp = tempdir().unwrap();
|
||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
|
|
||||||
let mut writer =
|
let mut writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-001").unwrap();
|
||||||
AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-001").unwrap();
|
|
||||||
|
|
||||||
let event = AgentEvent::Status {
|
let event = AgentEvent::Status {
|
||||||
story_id: "42_story_foo".to_string(),
|
story_id: "42_story_foo".to_string(),
|
||||||
@@ -426,8 +401,7 @@ mod tests {
|
|||||||
let tmp = tempdir().unwrap();
|
let tmp = tempdir().unwrap();
|
||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
|
|
||||||
let mut writer =
|
let mut writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-002").unwrap();
|
||||||
AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-002").unwrap();
|
|
||||||
|
|
||||||
let events = vec![
|
let events = vec![
|
||||||
AgentEvent::Status {
|
AgentEvent::Status {
|
||||||
@@ -472,10 +446,8 @@ mod tests {
|
|||||||
let tmp = tempdir().unwrap();
|
let tmp = tempdir().unwrap();
|
||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
|
|
||||||
let mut writer1 =
|
let mut writer1 = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-aaa").unwrap();
|
||||||
AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-aaa").unwrap();
|
let mut writer2 = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-bbb").unwrap();
|
||||||
let mut writer2 =
|
|
||||||
AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-bbb").unwrap();
|
|
||||||
|
|
||||||
writer1
|
writer1
|
||||||
.write_event(&AgentEvent::Output {
|
.write_event(&AgentEvent::Output {
|
||||||
@@ -496,7 +468,10 @@ mod tests {
|
|||||||
let path1 = log_file_path(root, "42_story_foo", "coder-1", "sess-aaa");
|
let path1 = log_file_path(root, "42_story_foo", "coder-1", "sess-aaa");
|
||||||
let path2 = log_file_path(root, "42_story_foo", "coder-1", "sess-bbb");
|
let path2 = log_file_path(root, "42_story_foo", "coder-1", "sess-bbb");
|
||||||
|
|
||||||
assert_ne!(path1, path2, "Different sessions should use different files");
|
assert_ne!(
|
||||||
|
path1, path2,
|
||||||
|
"Different sessions should use different files"
|
||||||
|
);
|
||||||
|
|
||||||
let entries1 = read_log(&path1).unwrap();
|
let entries1 = read_log(&path1).unwrap();
|
||||||
let entries2 = read_log(&path2).unwrap();
|
let entries2 = read_log(&path2).unwrap();
|
||||||
@@ -513,8 +488,7 @@ mod tests {
|
|||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
|
|
||||||
// Create two log files with a small delay
|
// Create two log files with a small delay
|
||||||
let mut writer1 =
|
let mut writer1 = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-old").unwrap();
|
||||||
AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-old").unwrap();
|
|
||||||
writer1
|
writer1
|
||||||
.write_event(&AgentEvent::Output {
|
.write_event(&AgentEvent::Output {
|
||||||
story_id: "42_story_foo".to_string(),
|
story_id: "42_story_foo".to_string(),
|
||||||
@@ -527,8 +501,7 @@ mod tests {
|
|||||||
// Touch the second file to ensure it's newer
|
// Touch the second file to ensure it's newer
|
||||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||||
|
|
||||||
let mut writer2 =
|
let mut writer2 = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-new").unwrap();
|
||||||
AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-new").unwrap();
|
|
||||||
writer2
|
writer2
|
||||||
.write_event(&AgentEvent::Output {
|
.write_event(&AgentEvent::Output {
|
||||||
story_id: "42_story_foo".to_string(),
|
story_id: "42_story_foo".to_string(),
|
||||||
@@ -568,8 +541,7 @@ mod tests {
|
|||||||
drop(w1);
|
drop(w1);
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
|
||||||
let mut w2 =
|
let mut w2 = AgentLogWriter::new(root, "42_story_foo", "mergemaster", "sess-bbb").unwrap();
|
||||||
AgentLogWriter::new(root, "42_story_foo", "mergemaster", "sess-bbb").unwrap();
|
|
||||||
w2.write_event(&AgentEvent::Output {
|
w2.write_event(&AgentEvent::Output {
|
||||||
story_id: "42_story_foo".to_string(),
|
story_id: "42_story_foo".to_string(),
|
||||||
agent_name: "mergemaster".to_string(),
|
agent_name: "mergemaster".to_string(),
|
||||||
@@ -601,8 +573,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
drop(w1);
|
drop(w1);
|
||||||
|
|
||||||
let mut w2 =
|
let mut w2 = AgentLogWriter::new(root, "42_story_foo", "mergemaster", "sess-b").unwrap();
|
||||||
AgentLogWriter::new(root, "42_story_foo", "mergemaster", "sess-b").unwrap();
|
|
||||||
w2.write_event(&AgentEvent::Output {
|
w2.write_event(&AgentEvent::Output {
|
||||||
story_id: "42_story_foo".to_string(),
|
story_id: "42_story_foo".to_string(),
|
||||||
agent_name: "mergemaster".to_string(),
|
agent_name: "mergemaster".to_string(),
|
||||||
@@ -704,7 +675,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
let result = format_log_entry_as_text(ts, &event).unwrap();
|
let result = format_log_entry_as_text(ts, &event).unwrap();
|
||||||
assert!(result.contains("TOOL: Read"), "should show tool call: {result}");
|
assert!(
|
||||||
|
result.contains("TOOL: Read"),
|
||||||
|
"should show tool call: {result}"
|
||||||
|
);
|
||||||
assert!(result.contains("file_path"), "should show input: {result}");
|
assert!(result.contains("file_path"), "should show input: {result}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,7 +702,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
let result = format_log_entry_as_text(ts, &event).unwrap();
|
let result = format_log_entry_as_text(ts, &event).unwrap();
|
||||||
assert!(result.contains("Now I will read the file."), "should show text: {result}");
|
assert!(
|
||||||
|
result.contains("Now I will read the file."),
|
||||||
|
"should show text: {result}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -743,7 +720,10 @@ mod tests {
|
|||||||
"event": {"type": "content_block_delta", "delta": {"type": "text_delta", "text": "chunk"}}
|
"event": {"type": "content_block_delta", "delta": {"type": "text_delta", "text": "chunk"}}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
assert!(format_log_entry_as_text(ts, &event).is_none(), "stream events should be skipped");
|
assert!(
|
||||||
|
format_log_entry_as_text(ts, &event).is_none(),
|
||||||
|
"stream events should be skipped"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -771,7 +751,11 @@ mod tests {
|
|||||||
let path = log_file_path(root, "42_story_foo", "coder-1", "sess-readable");
|
let path = log_file_path(root, "42_story_foo", "coder-1", "sess-readable");
|
||||||
let lines = read_log_as_readable_lines(&path).unwrap();
|
let lines = read_log_as_readable_lines(&path).unwrap();
|
||||||
assert_eq!(lines.len(), 2, "Should produce 2 readable lines");
|
assert_eq!(lines.len(), 2, "Should produce 2 readable lines");
|
||||||
assert!(lines[0].contains("Let me read the file"), "first line: {}", lines[0]);
|
assert!(
|
||||||
|
lines[0].contains("Let me read the file"),
|
||||||
|
"first line: {}",
|
||||||
|
lines[0]
|
||||||
|
);
|
||||||
assert!(lines[1].contains("DONE"), "second line: {}", lines[1]);
|
assert!(lines[1].contains("DONE"), "second line: {}", lines[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,7 +786,10 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// File should still exist and be readable
|
// File should still exist and be readable
|
||||||
assert!(path.exists(), "Log file should persist after writer is dropped");
|
assert!(
|
||||||
|
path.exists(),
|
||||||
|
"Log file should persist after writer is dropped"
|
||||||
|
);
|
||||||
let entries = read_log(&path).unwrap();
|
let entries = read_log(&path).unwrap();
|
||||||
assert_eq!(entries.len(), 1);
|
assert_eq!(entries.len(), 1);
|
||||||
assert_eq!(entries[0].event["type"], "status");
|
assert_eq!(entries[0].event["type"], "status");
|
||||||
|
|||||||
@@ -36,10 +36,15 @@ const SCAN_INTERVAL_SECS: u64 = 15;
|
|||||||
///
|
///
|
||||||
/// This function never returns under normal operation — it runs until the
|
/// This function never returns under normal operation — it runs until the
|
||||||
/// process is terminated (SIGINT/SIGTERM).
|
/// process is terminated (SIGINT/SIGTERM).
|
||||||
|
///
|
||||||
|
/// If `join_token` and `gateway_url` are both provided the agent will register
|
||||||
|
/// itself with the gateway on startup using the one-time token.
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
project_root: Option<PathBuf>,
|
project_root: Option<PathBuf>,
|
||||||
rendezvous_url: String,
|
rendezvous_url: String,
|
||||||
port: u16,
|
port: u16,
|
||||||
|
join_token: Option<String>,
|
||||||
|
gateway_url: Option<String>,
|
||||||
) -> Result<(), std::io::Error> {
|
) -> Result<(), std::io::Error> {
|
||||||
let project_root = match project_root {
|
let project_root = match project_root {
|
||||||
Some(r) => r,
|
Some(r) => r,
|
||||||
@@ -51,14 +56,20 @@ pub async fn run(
|
|||||||
|
|
||||||
println!("\x1b[96;1m[agent-mode]\x1b[0m Starting headless build agent");
|
println!("\x1b[96;1m[agent-mode]\x1b[0m Starting headless build agent");
|
||||||
println!("\x1b[96;1m[agent-mode]\x1b[0m Rendezvous: {rendezvous_url}");
|
println!("\x1b[96;1m[agent-mode]\x1b[0m Rendezvous: {rendezvous_url}");
|
||||||
println!("\x1b[96;1m[agent-mode]\x1b[0m Project: {}", project_root.display());
|
println!(
|
||||||
|
"\x1b[96;1m[agent-mode]\x1b[0m Project: {}",
|
||||||
|
project_root.display()
|
||||||
|
);
|
||||||
|
|
||||||
// Validate project config.
|
// Validate project config.
|
||||||
let config = ProjectConfig::load(&project_root).unwrap_or_else(|e| {
|
let config = ProjectConfig::load(&project_root).unwrap_or_else(|e| {
|
||||||
eprintln!("error: invalid project config: {e}");
|
eprintln!("error: invalid project config: {e}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
});
|
});
|
||||||
slog!("[agent-mode] Loaded config with {} agents", config.agent.len());
|
slog!(
|
||||||
|
"[agent-mode] Loaded config with {} agents",
|
||||||
|
config.agent.len()
|
||||||
|
);
|
||||||
|
|
||||||
// Event bus for pipeline lifecycle events.
|
// Event bus for pipeline lifecycle events.
|
||||||
let (watcher_tx, _) = broadcast::channel::<watcher::WatcherEvent>(1024);
|
let (watcher_tx, _) = broadcast::channel::<watcher::WatcherEvent>(1024);
|
||||||
@@ -79,9 +90,7 @@ pub async fn run(
|
|||||||
{
|
{
|
||||||
let story_id = evt.story_id.clone();
|
let story_id = evt.story_id.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
if let Err(e) =
|
if let Err(e) = crate::worktree::prune_worktree_sync(&root, &story_id) {
|
||||||
crate::worktree::prune_worktree_sync(&root, &story_id)
|
|
||||||
{
|
|
||||||
slog!("[agent-mode] worktree prune failed for {story_id}: {e}");
|
slog!("[agent-mode] worktree prune failed for {story_id}: {e}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -113,9 +122,7 @@ pub async fn run(
|
|||||||
if let watcher::WatcherEvent::WorkItem { ref stage, .. } = event
|
if let watcher::WatcherEvent::WorkItem { ref stage, .. } = event
|
||||||
&& matches!(stage.as_str(), "2_current" | "3_qa" | "4_merge")
|
&& matches!(stage.as_str(), "2_current" | "3_qa" | "4_merge")
|
||||||
{
|
{
|
||||||
slog!(
|
slog!("[agent-mode] CRDT transition in {stage}/; triggering auto-assign.");
|
||||||
"[agent-mode] CRDT transition in {stage}/; triggering auto-assign."
|
|
||||||
);
|
|
||||||
auto_agents.auto_assign_available_work(&auto_root).await;
|
auto_agents.auto_assign_available_work(&auto_root).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,6 +132,14 @@ pub async fn run(
|
|||||||
// Write initial heartbeat.
|
// Write initial heartbeat.
|
||||||
write_heartbeat(&rendezvous_url, port);
|
write_heartbeat(&rendezvous_url, port);
|
||||||
|
|
||||||
|
// Register with gateway if a join token and gateway URL were provided.
|
||||||
|
if let (Some(token), Some(url)) = (join_token, gateway_url) {
|
||||||
|
let node_id = crdt_state::our_node_id().unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let label = format!("build-agent-{}", &node_id[..node_id.len().min(8)]);
|
||||||
|
let address = format!("ws://0.0.0.0:{port}/crdt-sync");
|
||||||
|
register_with_gateway(&url, &token, &label, &address).await;
|
||||||
|
}
|
||||||
|
|
||||||
// Reconcile any committed work from a previous session.
|
// Reconcile any committed work from a previous session.
|
||||||
{
|
{
|
||||||
let recon_agents = Arc::clone(&agents);
|
let recon_agents = Arc::clone(&agents);
|
||||||
@@ -425,6 +440,36 @@ fn push_feature_branch(worktree_path: &str, story_id: &str) -> Result<(), String
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Gateway registration ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Register this build agent with a gateway using a one-time join token.
|
||||||
|
///
|
||||||
|
/// POSTs `{ token, label, address }` to `{gateway_url}/gateway/register`. On
|
||||||
|
/// success the gateway stores the agent and it will appear in the gateway UI.
|
||||||
|
async fn register_with_gateway(gateway_url: &str, token: &str, label: &str, address: &str) {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let url = format!("{}/gateway/register", gateway_url.trim_end_matches('/'));
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"token": token,
|
||||||
|
"label": label,
|
||||||
|
"address": address,
|
||||||
|
});
|
||||||
|
match client.post(&url).json(&body).send().await {
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
slog!("[agent-mode] Registered with gateway at {gateway_url}");
|
||||||
|
}
|
||||||
|
Ok(resp) => {
|
||||||
|
slog!(
|
||||||
|
"[agent-mode] Gateway registration failed: HTTP {}",
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
slog!("[agent-mode] Gateway registration error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -36,9 +36,7 @@ pub(crate) fn worktree_has_committed_work(wt_path: &Path) -> bool {
|
|||||||
.current_dir(wt_path)
|
.current_dir(wt_path)
|
||||||
.output();
|
.output();
|
||||||
match output {
|
match output {
|
||||||
Ok(out) if out.status.success() => {
|
Ok(out) if out.status.success() => !String::from_utf8_lossy(&out.stdout).trim().is_empty(),
|
||||||
!String::from_utf8_lossy(&out.stdout).trim().is_empty()
|
|
||||||
}
|
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,14 +256,21 @@ mod tests {
|
|||||||
let script_dir = path.join("script");
|
let script_dir = path.join("script");
|
||||||
fs::create_dir_all(&script_dir).unwrap();
|
fs::create_dir_all(&script_dir).unwrap();
|
||||||
let script_test = script_dir.join("test");
|
let script_test = script_dir.join("test");
|
||||||
fs::write(&script_test, "#!/usr/bin/env bash\necho 'all tests passed'\nexit 0\n").unwrap();
|
fs::write(
|
||||||
|
&script_test,
|
||||||
|
"#!/usr/bin/env bash\necho 'all tests passed'\nexit 0\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let mut perms = fs::metadata(&script_test).unwrap().permissions();
|
let mut perms = fs::metadata(&script_test).unwrap().permissions();
|
||||||
perms.set_mode(0o755);
|
perms.set_mode(0o755);
|
||||||
fs::set_permissions(&script_test, perms).unwrap();
|
fs::set_permissions(&script_test, perms).unwrap();
|
||||||
|
|
||||||
let (passed, output) = run_project_tests(path).unwrap();
|
let (passed, output) = run_project_tests(path).unwrap();
|
||||||
assert!(passed, "script/test exiting 0 should pass");
|
assert!(passed, "script/test exiting 0 should pass");
|
||||||
assert!(output.contains("script/test"), "output should mention script/test");
|
assert!(
|
||||||
|
output.contains("script/test"),
|
||||||
|
"output should mention script/test"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -286,7 +291,10 @@ mod tests {
|
|||||||
|
|
||||||
let (passed, output) = run_project_tests(path).unwrap();
|
let (passed, output) = run_project_tests(path).unwrap();
|
||||||
assert!(!passed, "script/test exiting 1 should fail");
|
assert!(!passed, "script/test exiting 1 should fail");
|
||||||
assert!(output.contains("script/test"), "output should mention script/test");
|
assert!(
|
||||||
|
output.contains("script/test"),
|
||||||
|
"output should mention script/test"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── run_coverage_gate tests ───────────────────────────────────────────────
|
// ── run_coverage_gate tests ───────────────────────────────────────────────
|
||||||
@@ -347,7 +355,10 @@ mod tests {
|
|||||||
let script = script_dir.join("test_coverage");
|
let script = script_dir.join("test_coverage");
|
||||||
{
|
{
|
||||||
let mut f = fs::File::create(&script).unwrap();
|
let mut f = fs::File::create(&script).unwrap();
|
||||||
f.write_all(b"#!/usr/bin/env bash\necho 'FAIL: Coverage 40% is below threshold 80%'\nexit 1\n").unwrap();
|
f.write_all(
|
||||||
|
b"#!/usr/bin/env bash\necho 'FAIL: Coverage 40% is below threshold 80%'\nexit 1\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
f.sync_all().unwrap();
|
f.sync_all().unwrap();
|
||||||
}
|
}
|
||||||
let mut perms = fs::metadata(&script).unwrap().permissions();
|
let mut perms = fs::metadata(&script).unwrap().permissions();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::slog;
|
|||||||
|
|
||||||
type ContentTransform = Option<Box<dyn Fn(&str) -> String>>;
|
type ContentTransform = Option<Box<dyn Fn(&str) -> String>>;
|
||||||
|
|
||||||
pub(super) fn item_type_from_id(item_id: &str) -> &'static str {
|
pub(crate) fn item_type_from_id(item_id: &str) -> &'static str {
|
||||||
// New format: {digits}_{type}_{slug}
|
// New format: {digits}_{type}_{slug}
|
||||||
let after_num = item_id.trim_start_matches(|c: char| c.is_ascii_digit());
|
let after_num = item_id.trim_start_matches(|c: char| c.is_ascii_digit());
|
||||||
if after_num.starts_with("_bug_") {
|
if after_num.starts_with("_bug_") {
|
||||||
@@ -37,9 +37,7 @@ fn move_item<'a>(
|
|||||||
// Use the typed projection for compile-safe stage comparison.
|
// Use the typed projection for compile-safe stage comparison.
|
||||||
if let Ok(Some(typed_item)) = crate::pipeline_state::read_typed(story_id) {
|
if let Ok(Some(typed_item)) = crate::pipeline_state::read_typed(story_id) {
|
||||||
let current_dir = typed_item.stage.dir_name();
|
let current_dir = typed_item.stage.dir_name();
|
||||||
if current_dir == target_dir
|
if current_dir == target_dir || extra_done_dirs.contains(¤t_dir) {
|
||||||
|| extra_done_dirs.contains(¤t_dir)
|
|
||||||
{
|
|
||||||
return Ok(None); // Idempotent: already there.
|
return Ok(None); // Idempotent: already there.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,11 +75,7 @@ fn move_item<'a>(
|
|||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
crate::db::move_item_stage(
|
crate::db::move_item_stage(story_id, target_dir, transform.as_ref().map(|f| f.as_ref()));
|
||||||
story_id,
|
|
||||||
target_dir,
|
|
||||||
transform.as_ref().map(|f| f.as_ref()),
|
|
||||||
);
|
|
||||||
|
|
||||||
slog!("[lifecycle] Moved '{story_id}' from work/{src_dir}/ to work/{target_dir}/");
|
slog!("[lifecycle] Moved '{story_id}' from work/{src_dir}/ to work/{target_dir}/");
|
||||||
return Ok(Some(src_dir));
|
return Ok(Some(src_dir));
|
||||||
@@ -121,7 +115,16 @@ fn move_item<'a>(
|
|||||||
/// that has already advanced past the coding stage.
|
/// that has already advanced past the coding stage.
|
||||||
/// Idempotent: if already in `2_current/`, returns Ok. If not found, logs and returns Ok.
|
/// Idempotent: if already in `2_current/`, returns Ok. If not found, logs and returns Ok.
|
||||||
pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(), String> {
|
pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(), String> {
|
||||||
move_item(project_root, story_id, &["1_backlog"], "2_current", &[], true, &[]).map(|_| ())
|
move_item(
|
||||||
|
project_root,
|
||||||
|
story_id,
|
||||||
|
&["1_backlog"],
|
||||||
|
"2_current",
|
||||||
|
&[],
|
||||||
|
true,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check whether a feature branch `feature/story-{story_id}` exists and has
|
/// Check whether a feature branch `feature/story-{story_id}` exists and has
|
||||||
@@ -155,14 +158,15 @@ pub fn feature_branch_has_unmerged_changes(project_root: &Path, story_id: &str)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move a story from `work/2_current/` or `work/4_merge/` to `work/5_done/`.
|
/// Move a story from `work/2_current/`, `work/3_qa/`, or `work/4_merge/` to `work/5_done/`.
|
||||||
///
|
///
|
||||||
/// Idempotent if already in `5_done/` or `6_archived/`. Errors if not found in `2_current/` or `4_merge/`.
|
/// Idempotent if already in `5_done/` or `6_archived/`. Errors if not found in any earlier stage.
|
||||||
|
/// Spikes may transition directly from `3_qa/` to `5_done/`, skipping the merge stage.
|
||||||
pub fn move_story_to_done(project_root: &Path, story_id: &str) -> Result<(), String> {
|
pub fn move_story_to_done(project_root: &Path, story_id: &str) -> Result<(), String> {
|
||||||
move_item(
|
move_item(
|
||||||
project_root,
|
project_root,
|
||||||
story_id,
|
story_id,
|
||||||
&["2_current", "4_merge"],
|
&["2_current", "3_qa", "4_merge"],
|
||||||
"5_done",
|
"5_done",
|
||||||
&["6_archived"],
|
&["6_archived"],
|
||||||
false,
|
false,
|
||||||
@@ -204,12 +208,25 @@ pub fn move_story_to_qa(project_root: &Path, story_id: &str) -> Result<(), Strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Move a story from `work/3_qa/` back to `work/2_current/`, clearing `review_hold` and writing notes.
|
/// Move a story from `work/3_qa/` back to `work/2_current/`, clearing `review_hold` and writing notes.
|
||||||
pub fn reject_story_from_qa(project_root: &Path, story_id: &str, notes: &str) -> Result<(), String> {
|
pub fn reject_story_from_qa(
|
||||||
let moved = move_item(project_root, story_id, &["3_qa"], "2_current", &[], false, &["review_hold"])?;
|
project_root: &Path,
|
||||||
|
story_id: &str,
|
||||||
|
notes: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let moved = move_item(
|
||||||
|
project_root,
|
||||||
|
story_id,
|
||||||
|
&["3_qa"],
|
||||||
|
"2_current",
|
||||||
|
&[],
|
||||||
|
false,
|
||||||
|
&["review_hold"],
|
||||||
|
)?;
|
||||||
if moved.is_some() && !notes.is_empty() {
|
if moved.is_some() && !notes.is_empty() {
|
||||||
// Append rejection notes to the stored content.
|
// Append rejection notes to the stored content.
|
||||||
if let Some(content) = crate::db::read_content(story_id) {
|
if let Some(content) = crate::db::read_content(story_id) {
|
||||||
let updated = crate::io::story_metadata::write_rejection_notes_to_content(&content, notes);
|
let updated =
|
||||||
|
crate::io::story_metadata::write_rejection_notes_to_content(&content, notes);
|
||||||
crate::db::write_content(story_id, &updated);
|
crate::db::write_content(story_id, &updated);
|
||||||
// Re-sync to DB.
|
// Re-sync to DB.
|
||||||
crate::db::write_item_with_content(story_id, "2_current", &updated);
|
crate::db::write_item_with_content(story_id, "2_current", &updated);
|
||||||
@@ -250,8 +267,16 @@ pub fn move_story_to_stage(
|
|||||||
|
|
||||||
let all_dirs: Vec<&str> = STAGES.iter().map(|(_, dir)| *dir).collect();
|
let all_dirs: Vec<&str> = STAGES.iter().map(|(_, dir)| *dir).collect();
|
||||||
|
|
||||||
match move_item(project_root, story_id, &all_dirs, target_dir, &[], false, &[])
|
match move_item(
|
||||||
.map_err(|_| format!("Work item '{story_id}' not found in any pipeline stage."))?
|
project_root,
|
||||||
|
story_id,
|
||||||
|
&all_dirs,
|
||||||
|
target_dir,
|
||||||
|
&[],
|
||||||
|
false,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.map_err(|_| format!("Work item '{story_id}' not found in any pipeline stage."))?
|
||||||
{
|
{
|
||||||
Some(src_dir) => {
|
Some(src_dir) => {
|
||||||
let from_stage = STAGES
|
let from_stage = STAGES
|
||||||
|
|||||||
@@ -248,7 +248,9 @@ pub(crate) fn run_squash_merge(
|
|||||||
.output()
|
.output()
|
||||||
.map_err(|e| format!("Failed to check merge diff: {e}"))?;
|
.map_err(|e| format!("Failed to check merge diff: {e}"))?;
|
||||||
let changed_files = String::from_utf8_lossy(&diff_check.stdout);
|
let changed_files = String::from_utf8_lossy(&diff_check.stdout);
|
||||||
let has_code_changes = changed_files.lines().any(|f| !f.starts_with(".huskies/work/"));
|
let has_code_changes = changed_files
|
||||||
|
.lines()
|
||||||
|
.any(|f| !f.starts_with(".huskies/work/"));
|
||||||
if !has_code_changes {
|
if !has_code_changes {
|
||||||
all_output.push_str(
|
all_output.push_str(
|
||||||
"=== Merge commit contains only .huskies/ file moves, no code changes ===\n",
|
"=== Merge commit contains only .huskies/ file moves, no code changes ===\n",
|
||||||
@@ -423,7 +425,14 @@ pub(crate) fn run_squash_merge(
|
|||||||
// Exclude .huskies/work/ (pipeline file moves) but keep .huskies/project.toml
|
// Exclude .huskies/work/ (pipeline file moves) but keep .huskies/project.toml
|
||||||
// and other config files which are legitimate deliverables.
|
// and other config files which are legitimate deliverables.
|
||||||
let diff_stat = Command::new("git")
|
let diff_stat = Command::new("git")
|
||||||
.args(["diff", "--stat", "HEAD~1..HEAD", "--", ".", ":(exclude).huskies/work"])
|
.args([
|
||||||
|
"diff",
|
||||||
|
"--stat",
|
||||||
|
"HEAD~1..HEAD",
|
||||||
|
"--",
|
||||||
|
".",
|
||||||
|
":(exclude).huskies/work",
|
||||||
|
])
|
||||||
.current_dir(project_root)
|
.current_dir(project_root)
|
||||||
.output()
|
.output()
|
||||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
|||||||
@@ -64,8 +64,7 @@ impl AgentPool {
|
|||||||
}
|
}
|
||||||
// All deps met — promote from backlog to current.
|
// All deps met — promote from backlog to current.
|
||||||
slog!("[auto-assign] Story '{story_id}' deps met; promoting from backlog to current.");
|
slog!("[auto-assign] Story '{story_id}' deps met; promoting from backlog to current.");
|
||||||
if let Err(e) =
|
if let Err(e) = crate::agents::lifecycle::move_story_to_current(project_root, story_id)
|
||||||
crate::agents::lifecycle::move_story_to_current(project_root, story_id)
|
|
||||||
{
|
{
|
||||||
slog!("[auto-assign] Failed to promote '{story_id}' to current: {e}");
|
slog!("[auto-assign] Failed to promote '{story_id}' to current: {e}");
|
||||||
}
|
}
|
||||||
@@ -160,10 +159,12 @@ impl AgentPool {
|
|||||||
);
|
);
|
||||||
let _ = crate::io::story_metadata::write_blocked(&story_path);
|
let _ = crate::io::story_metadata::write_blocked(&story_path);
|
||||||
}
|
}
|
||||||
let _ = self.watcher_tx.send(crate::io::watcher::WatcherEvent::StoryBlocked {
|
let _ = self
|
||||||
story_id: story_id.to_string(),
|
.watcher_tx
|
||||||
reason: empty_diff_reason.to_string(),
|
.send(crate::io::watcher::WatcherEvent::StoryBlocked {
|
||||||
});
|
story_id: story_id.to_string(),
|
||||||
|
reason: empty_diff_reason.to_string(),
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,15 +300,15 @@ mod tests {
|
|||||||
async fn auto_assign_picks_up_story_queued_in_current() {
|
async fn auto_assign_picks_up_story_queued_in_current() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let sk = tmp.path().join(".huskies");
|
let sk = tmp.path().join(".huskies");
|
||||||
let current = sk.join("work/2_current");
|
std::fs::create_dir_all(&sk).unwrap();
|
||||||
std::fs::create_dir_all(¤t).unwrap();
|
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
sk.join("project.toml"),
|
sk.join("project.toml"),
|
||||||
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
|
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// Place the story in 2_current/ (simulating the "queued" state).
|
// Place the story in 2_current/ via CRDT (the only source of truth).
|
||||||
std::fs::write(current.join("story-3.md"), "---\nname: Story 3\n---\n").unwrap();
|
crate::db::ensure_content_store();
|
||||||
|
crate::db::write_item_with_content("story-3", "2_current", "---\nname: Story 3\n---\n");
|
||||||
|
|
||||||
let pool = AgentPool::new_test(3001);
|
let pool = AgentPool::new_test(3001);
|
||||||
// No agents are running — coder-1 is free.
|
// No agents are running — coder-1 is free.
|
||||||
@@ -548,31 +549,33 @@ mod tests {
|
|||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
let sk = root.join(".huskies");
|
let sk = root.join(".huskies");
|
||||||
let current = sk.join("work/2_current");
|
std::fs::create_dir_all(&sk).unwrap();
|
||||||
let done = sk.join("work/5_done");
|
|
||||||
std::fs::create_dir_all(¤t).unwrap();
|
|
||||||
std::fs::create_dir_all(&done).unwrap();
|
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
sk.join("project.toml"),
|
sk.join("project.toml"),
|
||||||
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
|
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
// Seed stories via CRDT (the only source of truth).
|
||||||
|
crate::db::ensure_content_store();
|
||||||
// Dep 999 is now done.
|
// Dep 999 is now done.
|
||||||
std::fs::write(done.join("999_story_dep.md"), "---\nname: Dep\n---\n").unwrap();
|
crate::db::write_item_with_content("999_story_dep", "5_done", "---\nname: Dep\n---\n");
|
||||||
// Story 10 depends on 999 which is done.
|
// Story 10 depends on 999 which is done.
|
||||||
std::fs::write(
|
crate::db::write_item_with_content(
|
||||||
current.join("10_story_unblocked.md"),
|
"10_story_unblocked",
|
||||||
|
"2_current",
|
||||||
"---\nname: Unblocked\ndepends_on: [999]\n---\n",
|
"---\nname: Unblocked\ndepends_on: [999]\n---\n",
|
||||||
)
|
);
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let pool = AgentPool::new_test(3001);
|
let pool = AgentPool::new_test(3001);
|
||||||
pool.auto_assign_available_work(root).await;
|
pool.auto_assign_available_work(root).await;
|
||||||
|
|
||||||
let agents = pool.agents.lock().unwrap();
|
let agents = pool.agents.lock().unwrap();
|
||||||
let has_pending = agents
|
let has_pending = agents.values().any(|a| {
|
||||||
.values()
|
matches!(
|
||||||
.any(|a| matches!(a.status, crate::agents::AgentStatus::Pending | crate::agents::AgentStatus::Running));
|
a.status,
|
||||||
|
crate::agents::AgentStatus::Pending | crate::agents::AgentStatus::Running
|
||||||
|
)
|
||||||
|
});
|
||||||
assert!(
|
assert!(
|
||||||
has_pending,
|
has_pending,
|
||||||
"story with all deps done should be auto-assigned"
|
"story with all deps done should be auto-assigned"
|
||||||
|
|||||||
@@ -161,17 +161,19 @@ impl AgentPool {
|
|||||||
|
|
||||||
match qa_mode {
|
match qa_mode {
|
||||||
crate::io::story_metadata::QaMode::Server => {
|
crate::io::story_metadata::QaMode::Server => {
|
||||||
if let Err(e) =
|
if let Err(e) = crate::agents::move_story_to_merge(project_root, story_id) {
|
||||||
crate::agents::move_story_to_merge(project_root, story_id)
|
eprintln!(
|
||||||
{
|
"[startup:reconcile] Failed to move '{story_id}' to 4_merge/: {e}"
|
||||||
eprintln!("[startup:reconcile] Failed to move '{story_id}' to 4_merge/: {e}");
|
);
|
||||||
let _ = progress_tx.send(ReconciliationEvent {
|
let _ = progress_tx.send(ReconciliationEvent {
|
||||||
story_id: story_id.clone(),
|
story_id: story_id.clone(),
|
||||||
status: "failed".to_string(),
|
status: "failed".to_string(),
|
||||||
message: format!("Failed to advance to merge: {e}"),
|
message: format!("Failed to advance to merge: {e}"),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
eprintln!("[startup:reconcile] Moved '{story_id}' → 4_merge/ (qa: server).");
|
eprintln!(
|
||||||
|
"[startup:reconcile] Moved '{story_id}' → 4_merge/ (qa: server)."
|
||||||
|
);
|
||||||
let _ = progress_tx.send(ReconciliationEvent {
|
let _ = progress_tx.send(ReconciliationEvent {
|
||||||
story_id: story_id.clone(),
|
story_id: story_id.clone(),
|
||||||
status: "advanced".to_string(),
|
status: "advanced".to_string(),
|
||||||
@@ -180,10 +182,10 @@ impl AgentPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
crate::io::story_metadata::QaMode::Agent => {
|
crate::io::story_metadata::QaMode::Agent => {
|
||||||
if let Err(e) =
|
if let Err(e) = crate::agents::move_story_to_qa(project_root, story_id) {
|
||||||
crate::agents::move_story_to_qa(project_root, story_id)
|
eprintln!(
|
||||||
{
|
"[startup:reconcile] Failed to move '{story_id}' to 3_qa/: {e}"
|
||||||
eprintln!("[startup:reconcile] Failed to move '{story_id}' to 3_qa/: {e}");
|
);
|
||||||
let _ = progress_tx.send(ReconciliationEvent {
|
let _ = progress_tx.send(ReconciliationEvent {
|
||||||
story_id: story_id.clone(),
|
story_id: story_id.clone(),
|
||||||
status: "failed".to_string(),
|
status: "failed".to_string(),
|
||||||
@@ -199,10 +201,10 @@ impl AgentPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
crate::io::story_metadata::QaMode::Human => {
|
crate::io::story_metadata::QaMode::Human => {
|
||||||
if let Err(e) =
|
if let Err(e) = crate::agents::move_story_to_qa(project_root, story_id) {
|
||||||
crate::agents::move_story_to_qa(project_root, story_id)
|
eprintln!(
|
||||||
{
|
"[startup:reconcile] Failed to move '{story_id}' to 3_qa/: {e}"
|
||||||
eprintln!("[startup:reconcile] Failed to move '{story_id}' to 3_qa/: {e}");
|
);
|
||||||
let _ = progress_tx.send(ReconciliationEvent {
|
let _ = progress_tx.send(ReconciliationEvent {
|
||||||
story_id: story_id.clone(),
|
story_id: story_id.clone(),
|
||||||
status: "failed".to_string(),
|
status: "failed".to_string(),
|
||||||
@@ -219,7 +221,9 @@ impl AgentPool {
|
|||||||
"[startup:reconcile] Failed to set review_hold on '{story_id}': {e}"
|
"[startup:reconcile] Failed to set review_hold on '{story_id}': {e}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
eprintln!("[startup:reconcile] Moved '{story_id}' → 3_qa/ (qa: human — holding for review).");
|
eprintln!(
|
||||||
|
"[startup:reconcile] Moved '{story_id}' → 3_qa/ (qa: human — holding for review)."
|
||||||
|
);
|
||||||
let _ = progress_tx.send(ReconciliationEvent {
|
let _ = progress_tx.send(ReconciliationEvent {
|
||||||
story_id: story_id.clone(),
|
story_id: story_id.clone(),
|
||||||
status: "review_hold".to_string(),
|
status: "review_hold".to_string(),
|
||||||
@@ -284,9 +288,7 @@ impl AgentPool {
|
|||||||
let story_path = project_root
|
let story_path = project_root
|
||||||
.join(".huskies/work/3_qa")
|
.join(".huskies/work/3_qa")
|
||||||
.join(format!("{story_id}.md"));
|
.join(format!("{story_id}.md"));
|
||||||
if let Err(e) =
|
if let Err(e) = crate::io::story_metadata::write_review_hold(&story_path) {
|
||||||
crate::io::story_metadata::write_review_hold(&story_path)
|
|
||||||
{
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[startup:reconcile] Failed to set review_hold on '{story_id}': {e}"
|
"[startup:reconcile] Failed to set review_hold on '{story_id}': {e}"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,30 +18,17 @@ pub(in crate::agents::pool) fn is_agent_free(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn scan_stage_items(project_root: &Path, stage_dir: &str) -> Vec<String> {
|
pub(super) fn scan_stage_items(_project_root: &Path, stage_dir: &str) -> Vec<String> {
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
let mut items = BTreeSet::new();
|
let mut items = BTreeSet::new();
|
||||||
|
|
||||||
// Include CRDT items via the typed projection — the primary source of truth.
|
// CRDT is the only source of truth — no filesystem fallback.
|
||||||
for item in crate::pipeline_state::read_all_typed() {
|
for item in crate::pipeline_state::read_all_typed() {
|
||||||
if item.stage.dir_name() == stage_dir {
|
if item.stage.dir_name() == stage_dir {
|
||||||
items.insert(item.story_id.0.clone());
|
items.insert(item.story_id.0.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also include filesystem items (backwards compat / migration fallback).
|
|
||||||
let dir = project_root.join(".huskies").join("work").join(stage_dir);
|
|
||||||
if dir.is_dir() && let Ok(entries) = std::fs::read_dir(&dir) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().and_then(|e| e.to_str()) == Some("md")
|
|
||||||
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
|
|
||||||
{
|
|
||||||
items.insert(stem.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
items.into_iter().collect()
|
items.into_iter().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +152,39 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Bug 556: stale filesystem shadow must not override CRDT stage ──────────
|
||||||
|
//
|
||||||
|
// A story file left in 1_backlog/ on disk but tracked as 6_archived in the
|
||||||
|
// CRDT must NOT appear when scanning 1_backlog. Without the fix, the
|
||||||
|
// filesystem fallback would add it, causing promote_ready_backlog_stories to
|
||||||
|
// attempt to promote an archived story.
|
||||||
|
#[test]
|
||||||
|
fn scan_stage_items_skips_filesystem_item_known_to_crdt_at_different_stage() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
|
// Write the story into the CRDT as 6_archived.
|
||||||
|
crate::db::write_item_with_content(
|
||||||
|
"9970_story_archived",
|
||||||
|
"6_archived",
|
||||||
|
"---\nname: Archived\n---\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also place a stale .md file in a temp 1_backlog/ dir.
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let backlog = tmp.path().join(".huskies/work/1_backlog");
|
||||||
|
std::fs::create_dir_all(&backlog).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
backlog.join("9970_story_archived.md"),
|
||||||
|
"---\nname: Archived\n---\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let items = scan_stage_items(tmp.path(), "1_backlog");
|
||||||
|
assert!(
|
||||||
|
!items.contains(&"9970_story_archived".to_string()),
|
||||||
|
"archived CRDT story must not appear in 1_backlog scan via stale filesystem shadow"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scan_stage_items_returns_empty_for_missing_dir() {
|
fn scan_stage_items_returns_empty_for_missing_dir() {
|
||||||
// Use a unique stage name that no other test writes to, so
|
// Use a unique stage name that no other test writes to, so
|
||||||
@@ -576,7 +596,9 @@ stage = "coder"
|
|||||||
);
|
);
|
||||||
|
|
||||||
let count = count_active_agents_for_stage(&config, &agents, &PipelineStage::Coder);
|
let count = count_active_agents_for_stage(&config, &agents, &PipelineStage::Coder);
|
||||||
assert_eq!(count, 1, "Only Running coder should be counted, not Completed");
|
assert_eq!(
|
||||||
|
count, 1,
|
||||||
|
"Only Running coder should be counted, not Completed"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,18 +52,18 @@ pub(super) fn is_story_blocked(project_root: &Path, _stage_dir: &str, story_id:
|
|||||||
///
|
///
|
||||||
/// Reads dependency state from the CRDT document first. Falls back to the
|
/// Reads dependency state from the CRDT document first. Falls back to the
|
||||||
/// filesystem when the CRDT layer is not initialised.
|
/// filesystem when the CRDT layer is not initialised.
|
||||||
pub(super) fn has_unmet_dependencies(
|
pub(super) fn has_unmet_dependencies(project_root: &Path, stage_dir: &str, story_id: &str) -> bool {
|
||||||
project_root: &Path,
|
|
||||||
stage_dir: &str,
|
|
||||||
story_id: &str,
|
|
||||||
) -> bool {
|
|
||||||
// Prefer CRDT-based check.
|
// Prefer CRDT-based check.
|
||||||
let crdt_deps = crate::crdt_state::check_unmet_deps_crdt(story_id);
|
let crdt_deps = crate::crdt_state::check_unmet_deps_crdt(story_id);
|
||||||
if !crdt_deps.is_empty() {
|
if !crdt_deps.is_empty() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// If the CRDT had the item and returned empty deps, it means all are met.
|
// If the CRDT had the item and returned empty deps, it means all are met.
|
||||||
if crate::pipeline_state::read_typed(story_id).ok().flatten().is_some() {
|
if crate::pipeline_state::read_typed(story_id)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Fallback: filesystem check (CRDT not initialised or item not yet in CRDT).
|
// Fallback: filesystem check (CRDT not initialised or item not yet in CRDT).
|
||||||
@@ -82,7 +82,11 @@ pub(super) fn check_archived_dependencies(
|
|||||||
story_id: &str,
|
story_id: &str,
|
||||||
) -> Vec<u32> {
|
) -> Vec<u32> {
|
||||||
// Prefer CRDT-based check when the item is known to CRDT.
|
// Prefer CRDT-based check when the item is known to CRDT.
|
||||||
if crate::pipeline_state::read_typed(story_id).ok().flatten().is_some() {
|
if crate::pipeline_state::read_typed(story_id)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
return crate::crdt_state::check_archived_deps_crdt(story_id);
|
return crate::crdt_state::check_archived_deps_crdt(story_id);
|
||||||
}
|
}
|
||||||
// Fallback: filesystem.
|
// Fallback: filesystem.
|
||||||
@@ -146,7 +150,11 @@ mod tests {
|
|||||||
"---\nname: Blocked\ndepends_on: [999]\n---\n",
|
"---\nname: Blocked\ndepends_on: [999]\n---\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(has_unmet_dependencies(tmp.path(), "2_current", "10_story_blocked"));
|
assert!(has_unmet_dependencies(
|
||||||
|
tmp.path(),
|
||||||
|
"2_current",
|
||||||
|
"10_story_blocked"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -162,7 +170,11 @@ mod tests {
|
|||||||
"---\nname: Ok\ndepends_on: [999]\n---\n",
|
"---\nname: Ok\ndepends_on: [999]\n---\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!has_unmet_dependencies(tmp.path(), "2_current", "10_story_ok"));
|
assert!(!has_unmet_dependencies(
|
||||||
|
tmp.path(),
|
||||||
|
"2_current",
|
||||||
|
"10_story_ok"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -171,7 +183,11 @@ mod tests {
|
|||||||
let current = tmp.path().join(".huskies/work/2_current");
|
let current = tmp.path().join(".huskies/work/2_current");
|
||||||
std::fs::create_dir_all(¤t).unwrap();
|
std::fs::create_dir_all(¤t).unwrap();
|
||||||
std::fs::write(current.join("5_story_free.md"), "---\nname: Free\n---\n").unwrap();
|
std::fs::write(current.join("5_story_free.md"), "---\nname: Free\n---\n").unwrap();
|
||||||
assert!(!has_unmet_dependencies(tmp.path(), "2_current", "5_story_free"));
|
assert!(!has_unmet_dependencies(
|
||||||
|
tmp.path(),
|
||||||
|
"2_current",
|
||||||
|
"5_story_free"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bug 503: archived-dep visibility ─────────────────────────────────────
|
// ── Bug 503: archived-dep visibility ─────────────────────────────────────
|
||||||
@@ -184,7 +200,11 @@ mod tests {
|
|||||||
let archived = tmp.path().join(".huskies/work/6_archived");
|
let archived = tmp.path().join(".huskies/work/6_archived");
|
||||||
std::fs::create_dir_all(&backlog).unwrap();
|
std::fs::create_dir_all(&backlog).unwrap();
|
||||||
std::fs::create_dir_all(&archived).unwrap();
|
std::fs::create_dir_all(&archived).unwrap();
|
||||||
std::fs::write(archived.join("500_spike_crdt.md"), "---\nname: CRDT Spike\n---\n").unwrap();
|
std::fs::write(
|
||||||
|
archived.join("500_spike_crdt.md"),
|
||||||
|
"---\nname: CRDT Spike\n---\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
backlog.join("503_story_dependent.md"),
|
backlog.join("503_story_dependent.md"),
|
||||||
"---\nname: Dependent\ndepends_on: [500]\n---\n",
|
"---\nname: Dependent\ndepends_on: [500]\n---\n",
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ impl AgentPool {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
|
||||||
use super::super::super::{AgentPool, composite_key};
|
use super::super::super::{AgentPool, composite_key};
|
||||||
|
use super::*;
|
||||||
|
|
||||||
// ── check_orphaned_agents return value tests (bug 161) ──────────────────
|
// ── check_orphaned_agents return value tests (bug 161) ──────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
//! Agent pool — manages the set of active agents across all pipeline stages.
|
//! Agent pool — manages the set of active agents across all pipeline stages.
|
||||||
mod auto_assign;
|
mod auto_assign;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
mod start;
|
|
||||||
mod stop;
|
|
||||||
mod wait;
|
|
||||||
mod process;
|
mod process;
|
||||||
mod query;
|
mod query;
|
||||||
|
mod start;
|
||||||
|
mod stop;
|
||||||
mod types;
|
mod types;
|
||||||
|
mod wait;
|
||||||
mod worktree;
|
mod worktree;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -68,10 +68,15 @@ impl AgentPool {
|
|||||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
};
|
};
|
||||||
let (story_id, agent_name) = match &event {
|
let (story_id, agent_name) = match &event {
|
||||||
WatcherEvent::RateLimitWarning { story_id, agent_name }
|
WatcherEvent::RateLimitWarning {
|
||||||
| WatcherEvent::RateLimitHardBlock { story_id, agent_name, .. } => {
|
story_id,
|
||||||
(story_id.clone(), agent_name.clone())
|
agent_name,
|
||||||
}
|
}
|
||||||
|
| WatcherEvent::RateLimitHardBlock {
|
||||||
|
story_id,
|
||||||
|
agent_name,
|
||||||
|
..
|
||||||
|
} => (story_id.clone(), agent_name.clone()),
|
||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
let key = composite_key(&story_id, &agent_name);
|
let key = composite_key(&story_id, &agent_name);
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
//! Pipeline advance — moves stories forward through pipeline stages after agent completion.
|
//! Pipeline advance — moves stories forward through pipeline stages after agent completion.
|
||||||
use crate::config::ProjectConfig;
|
use crate::config::ProjectConfig;
|
||||||
|
use crate::io::watcher::WatcherEvent;
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use crate::slog_error;
|
use crate::slog_error;
|
||||||
use crate::slog_warn;
|
use crate::slog_warn;
|
||||||
use crate::io::watcher::WatcherEvent;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use super::super::super::{
|
use super::super::super::{CompletionReport, PipelineStage, agent_config_stage, pipeline_stage};
|
||||||
CompletionReport, PipelineStage,
|
|
||||||
agent_config_stage, pipeline_stage,
|
|
||||||
};
|
|
||||||
use super::super::{AgentPool, StoryAgent};
|
use super::super::{AgentPool, StoryAgent};
|
||||||
|
|
||||||
impl AgentPool {
|
impl AgentPool {
|
||||||
@@ -66,14 +63,16 @@ impl AgentPool {
|
|||||||
"[pipeline] Coder '{agent_name}' passed gates for '{story_id}'. \
|
"[pipeline] Coder '{agent_name}' passed gates for '{story_id}'. \
|
||||||
qa: server — moving directly to merge."
|
qa: server — moving directly to merge."
|
||||||
);
|
);
|
||||||
if let Err(e) =
|
if let Err(e) = crate::agents::lifecycle::move_story_to_merge(
|
||||||
crate::agents::lifecycle::move_story_to_merge(&project_root, story_id)
|
&project_root,
|
||||||
{
|
story_id,
|
||||||
|
) {
|
||||||
slog_error!(
|
slog_error!(
|
||||||
"[pipeline] Failed to move '{story_id}' to 4_merge/: {e}"
|
"[pipeline] Failed to move '{story_id}' to 4_merge/: {e}"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
self.start_mergemaster_or_block(&project_root, story_id).await;
|
self.start_mergemaster_or_block(&project_root, story_id)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
crate::io::story_metadata::QaMode::Agent => {
|
crate::io::story_metadata::QaMode::Agent => {
|
||||||
@@ -81,13 +80,17 @@ impl AgentPool {
|
|||||||
"[pipeline] Coder '{agent_name}' passed gates for '{story_id}'. \
|
"[pipeline] Coder '{agent_name}' passed gates for '{story_id}'. \
|
||||||
qa: agent — moving to QA."
|
qa: agent — moving to QA."
|
||||||
);
|
);
|
||||||
if let Err(e) = crate::agents::lifecycle::move_story_to_qa(&project_root, story_id) {
|
if let Err(e) =
|
||||||
|
crate::agents::lifecycle::move_story_to_qa(&project_root, story_id)
|
||||||
|
{
|
||||||
slog_error!("[pipeline] Failed to move '{story_id}' to 3_qa/: {e}");
|
slog_error!("[pipeline] Failed to move '{story_id}' to 3_qa/: {e}");
|
||||||
} else if let Err(e) = self
|
} else if let Err(e) = self
|
||||||
.start_agent(&project_root, story_id, Some("qa"), None, None)
|
.start_agent(&project_root, story_id, Some("qa"), None, None)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
slog_error!("[pipeline] Failed to start qa agent for '{story_id}': {e}");
|
slog_error!(
|
||||||
|
"[pipeline] Failed to start qa agent for '{story_id}': {e}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
crate::io::story_metadata::QaMode::Human => {
|
crate::io::story_metadata::QaMode::Human => {
|
||||||
@@ -95,7 +98,9 @@ impl AgentPool {
|
|||||||
"[pipeline] Coder '{agent_name}' passed gates for '{story_id}'. \
|
"[pipeline] Coder '{agent_name}' passed gates for '{story_id}'. \
|
||||||
qa: human — holding for human review."
|
qa: human — holding for human review."
|
||||||
);
|
);
|
||||||
if let Err(e) = crate::agents::lifecycle::move_story_to_qa(&project_root, story_id) {
|
if let Err(e) =
|
||||||
|
crate::agents::lifecycle::move_story_to_qa(&project_root, story_id)
|
||||||
|
{
|
||||||
slog_error!("[pipeline] Failed to move '{story_id}' to 3_qa/: {e}");
|
slog_error!("[pipeline] Failed to move '{story_id}' to 3_qa/: {e}");
|
||||||
} else {
|
} else {
|
||||||
write_review_hold_to_store(story_id);
|
write_review_hold_to_store(story_id);
|
||||||
@@ -104,7 +109,8 @@ impl AgentPool {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Increment retry count and check if blocked.
|
// Increment retry count and check if blocked.
|
||||||
if let Some(reason) = should_block_story(story_id, config.max_retries, "coder") {
|
if let Some(reason) = should_block_story(story_id, config.max_retries, "coder")
|
||||||
|
{
|
||||||
// Story has exceeded retry limit — do not restart.
|
// Story has exceeded retry limit — do not restart.
|
||||||
let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked {
|
let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked {
|
||||||
story_id: story_id.to_string(),
|
story_id: story_id.to_string(),
|
||||||
@@ -144,13 +150,14 @@ impl AgentPool {
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| project_root.clone());
|
.unwrap_or_else(|| project_root.clone());
|
||||||
let cp = coverage_path.clone();
|
let cp = coverage_path.clone();
|
||||||
let coverage_result =
|
let coverage_result = tokio::task::spawn_blocking(move || {
|
||||||
tokio::task::spawn_blocking(move || crate::agents::gates::run_coverage_gate(&cp))
|
crate::agents::gates::run_coverage_gate(&cp)
|
||||||
.await
|
})
|
||||||
.unwrap_or_else(|e| {
|
.await
|
||||||
slog_warn!("[pipeline] Coverage gate task panicked: {e}");
|
.unwrap_or_else(|e| {
|
||||||
Ok((false, format!("Coverage gate task panicked: {e}")))
|
slog_warn!("[pipeline] Coverage gate task panicked: {e}");
|
||||||
});
|
Ok((false, format!("Coverage gate task panicked: {e}")))
|
||||||
|
});
|
||||||
let (coverage_passed, coverage_output) = match coverage_result {
|
let (coverage_passed, coverage_output) = match coverage_result {
|
||||||
Ok(pair) => pair,
|
Ok(pair) => pair,
|
||||||
Err(e) => (false, e),
|
Err(e) => (false, e),
|
||||||
@@ -184,17 +191,21 @@ impl AgentPool {
|
|||||||
"[pipeline] QA passed gates and coverage for '{story_id}'. \
|
"[pipeline] QA passed gates and coverage for '{story_id}'. \
|
||||||
Moving directly to merge."
|
Moving directly to merge."
|
||||||
);
|
);
|
||||||
if let Err(e) =
|
if let Err(e) = crate::agents::lifecycle::move_story_to_merge(
|
||||||
crate::agents::lifecycle::move_story_to_merge(&project_root, story_id)
|
&project_root,
|
||||||
{
|
story_id,
|
||||||
|
) {
|
||||||
slog_error!(
|
slog_error!(
|
||||||
"[pipeline] Failed to move '{story_id}' to 4_merge/: {e}"
|
"[pipeline] Failed to move '{story_id}' to 4_merge/: {e}"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
self.start_mergemaster_or_block(&project_root, story_id).await;
|
self.start_mergemaster_or_block(&project_root, story_id)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if let Some(reason) = should_block_story(story_id, config.max_retries, "qa-coverage") {
|
} else if let Some(reason) =
|
||||||
|
should_block_story(story_id, config.max_retries, "qa-coverage")
|
||||||
|
{
|
||||||
// Story has exceeded retry limit — do not restart.
|
// Story has exceeded retry limit — do not restart.
|
||||||
let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked {
|
let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked {
|
||||||
story_id: story_id.to_string(),
|
story_id: story_id.to_string(),
|
||||||
@@ -217,7 +228,8 @@ impl AgentPool {
|
|||||||
slog_error!("[pipeline] Failed to restart qa for '{story_id}': {e}");
|
slog_error!("[pipeline] Failed to restart qa for '{story_id}': {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if let Some(reason) = should_block_story(story_id, config.max_retries, "qa") {
|
} else if let Some(reason) = should_block_story(story_id, config.max_retries, "qa")
|
||||||
|
{
|
||||||
// Story has exceeded retry limit — do not restart.
|
// Story has exceeded retry limit — do not restart.
|
||||||
let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked {
|
let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked {
|
||||||
story_id: story_id.to_string(),
|
story_id: story_id.to_string(),
|
||||||
@@ -272,13 +284,14 @@ impl AgentPool {
|
|||||||
"[pipeline] Mergemaster completed for '{story_id}'. Running post-merge tests on master."
|
"[pipeline] Mergemaster completed for '{story_id}'. Running post-merge tests on master."
|
||||||
);
|
);
|
||||||
let root = project_root.clone();
|
let root = project_root.clone();
|
||||||
let test_result =
|
let test_result = tokio::task::spawn_blocking(move || {
|
||||||
tokio::task::spawn_blocking(move || crate::agents::gates::run_project_tests(&root))
|
crate::agents::gates::run_project_tests(&root)
|
||||||
.await
|
})
|
||||||
.unwrap_or_else(|e| {
|
.await
|
||||||
slog_warn!("[pipeline] Post-merge test task panicked: {e}");
|
.unwrap_or_else(|e| {
|
||||||
Ok((false, format!("Test task panicked: {e}")))
|
slog_warn!("[pipeline] Post-merge test task panicked: {e}");
|
||||||
});
|
Ok((false, format!("Test task panicked: {e}")))
|
||||||
|
});
|
||||||
let (passed, output) = match test_result {
|
let (passed, output) = match test_result {
|
||||||
Ok(pair) => pair,
|
Ok(pair) => pair,
|
||||||
Err(e) => (false, e),
|
Err(e) => (false, e),
|
||||||
@@ -309,7 +322,9 @@ impl AgentPool {
|
|||||||
slog!(
|
slog!(
|
||||||
"[pipeline] Story '{story_id}' done. Worktree preserved for inspection."
|
"[pipeline] Story '{story_id}' done. Worktree preserved for inspection."
|
||||||
);
|
);
|
||||||
} else if let Some(reason) = should_block_story(story_id, config.max_retries, "mergemaster") {
|
} else if let Some(reason) =
|
||||||
|
should_block_story(story_id, config.max_retries, "mergemaster")
|
||||||
|
{
|
||||||
// Story has exceeded retry limit — do not restart.
|
// Story has exceeded retry limit — do not restart.
|
||||||
let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked {
|
let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked {
|
||||||
story_id: story_id.to_string(),
|
story_id: story_id.to_string(),
|
||||||
@@ -564,7 +579,10 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
crate::db::ensure_content_store();
|
crate::db::ensure_content_store();
|
||||||
crate::db::write_content("9909_story_agent_qa", "---\nname: Test\nqa: agent\n---\ntest");
|
crate::db::write_content(
|
||||||
|
"9909_story_agent_qa",
|
||||||
|
"---\nname: Test\nqa: agent\n---\ntest",
|
||||||
|
);
|
||||||
|
|
||||||
let pool = AgentPool::new_test(3001);
|
let pool = AgentPool::new_test(3001);
|
||||||
pool.run_pipeline_advance(
|
pool.run_pipeline_advance(
|
||||||
@@ -672,14 +690,9 @@ mod tests {
|
|||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
|
|
||||||
// Set up story in 2_current/
|
// Seed story via CRDT (the only source of truth).
|
||||||
let current = root.join(".huskies/work/2_current");
|
crate::db::ensure_content_store();
|
||||||
fs::create_dir_all(¤t).unwrap();
|
crate::db::write_item_with_content("173_story_test", "2_current", "---\nname: test\n---\n");
|
||||||
fs::write(current.join("173_story_test.md"), "test").unwrap();
|
|
||||||
// Ensure 3_qa/ exists for the move target
|
|
||||||
fs::create_dir_all(root.join(".huskies/work/3_qa")).unwrap();
|
|
||||||
// Ensure 1_backlog/ exists (start_agent calls move_story_to_current)
|
|
||||||
fs::create_dir_all(root.join(".huskies/work/1_backlog")).unwrap();
|
|
||||||
|
|
||||||
// Write a project.toml with a qa agent so start_agent can resolve it.
|
// Write a project.toml with a qa agent so start_agent can resolve it.
|
||||||
fs::create_dir_all(root.join(".huskies")).unwrap();
|
fs::create_dir_all(root.join(".huskies")).unwrap();
|
||||||
@@ -758,10 +771,26 @@ stage = "qa"
|
|||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
|
|
||||||
// Init a bare git repo on master with one empty commit.
|
// Init a bare git repo on master with one empty commit.
|
||||||
Command::new("git").args(["init"]).current_dir(root).output().unwrap();
|
Command::new("git")
|
||||||
Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(root).output().unwrap();
|
.args(["init"])
|
||||||
Command::new("git").args(["config", "user.name", "Test"]).current_dir(root).output().unwrap();
|
.current_dir(root)
|
||||||
Command::new("git").args(["commit", "--allow-empty", "-m", "init"]).current_dir(root).output().unwrap();
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["config", "user.email", "test@test.com"])
|
||||||
|
.current_dir(root)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["config", "user.name", "Test"])
|
||||||
|
.current_dir(root)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "--allow-empty", "-m", "init"])
|
||||||
|
.current_dir(root)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Create a feature branch that points at master HEAD (zero commits ahead).
|
// Create a feature branch that points at master HEAD (zero commits ahead).
|
||||||
// This replicates the incident where the worktree was reset to master.
|
// This replicates the incident where the worktree was reset to master.
|
||||||
@@ -775,7 +804,11 @@ stage = "qa"
|
|||||||
let current = root.join(".huskies/work/2_current");
|
let current = root.join(".huskies/work/2_current");
|
||||||
fs::create_dir_all(¤t).unwrap();
|
fs::create_dir_all(¤t).unwrap();
|
||||||
fs::create_dir_all(root.join(".huskies/work/4_merge")).unwrap();
|
fs::create_dir_all(root.join(".huskies/work/4_merge")).unwrap();
|
||||||
fs::write(current.join("9919_story_no_commits.md"), "---\nname: Test\n---\n").unwrap();
|
fs::write(
|
||||||
|
current.join("9919_story_no_commits.md"),
|
||||||
|
"---\nname: Test\n---\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
crate::db::ensure_content_store();
|
crate::db::ensure_content_store();
|
||||||
crate::db::write_content("9919_story_no_commits", "---\nname: Test\n---\n");
|
crate::db::write_content("9919_story_no_commits", "---\nname: Test\n---\n");
|
||||||
|
|
||||||
@@ -835,15 +868,14 @@ stage = "qa"
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pipeline_advance_picks_up_waiting_qa_stories_after_completion() {
|
async fn pipeline_advance_picks_up_waiting_qa_stories_after_completion() {
|
||||||
use std::fs;
|
|
||||||
use super::super::super::auto_assign::is_agent_free;
|
use super::super::super::auto_assign::is_agent_free;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
|
|
||||||
let sk = root.join(".huskies");
|
let sk = root.join(".huskies");
|
||||||
let qa_dir = sk.join("work/3_qa");
|
fs::create_dir_all(&sk).unwrap();
|
||||||
fs::create_dir_all(&qa_dir).unwrap();
|
|
||||||
|
|
||||||
// Configure a single QA agent.
|
// Configure a single QA agent.
|
||||||
fs::write(
|
fs::write(
|
||||||
@@ -856,19 +888,21 @@ stage = "qa"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
// Seed stories via CRDT (the only source of truth).
|
||||||
|
crate::db::ensure_content_store();
|
||||||
// Story 292 is in QA with QA agent running (will "complete" via
|
// Story 292 is in QA with QA agent running (will "complete" via
|
||||||
// run_pipeline_advance below). Story 293 is in QA with NO agent —
|
// run_pipeline_advance below). Story 293 is in QA with NO agent —
|
||||||
// simulating the "stuck" state from bug 295.
|
// simulating the "stuck" state from bug 295.
|
||||||
fs::write(
|
crate::db::write_item_with_content(
|
||||||
qa_dir.join("292_story_first.md"),
|
"292_story_first",
|
||||||
|
"3_qa",
|
||||||
"---\nname: First\nqa: human\n---\n",
|
"---\nname: First\nqa: human\n---\n",
|
||||||
)
|
);
|
||||||
.unwrap();
|
crate::db::write_item_with_content(
|
||||||
fs::write(
|
"293_story_second",
|
||||||
qa_dir.join("293_story_second.md"),
|
"3_qa",
|
||||||
"---\nname: Second\nqa: human\n---\n",
|
"---\nname: Second\nqa: human\n---\n",
|
||||||
)
|
);
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let pool = AgentPool::new_test(3001);
|
let pool = AgentPool::new_test(3001);
|
||||||
// QA is currently running on story 292.
|
// QA is currently running on story 292.
|
||||||
@@ -908,8 +942,7 @@ stage = "qa"
|
|||||||
// After pipeline advance, auto_assign should have started QA on story 293.
|
// After pipeline advance, auto_assign should have started QA on story 293.
|
||||||
let agents = pool.agents.lock().unwrap();
|
let agents = pool.agents.lock().unwrap();
|
||||||
let qa_on_293 = agents.values().any(|a| {
|
let qa_on_293 = agents.values().any(|a| {
|
||||||
a.agent_name == "qa"
|
a.agent_name == "qa" && matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
|
||||||
});
|
});
|
||||||
assert!(
|
assert!(
|
||||||
qa_on_293,
|
qa_on_293,
|
||||||
@@ -940,10 +973,26 @@ stage = "qa"
|
|||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
|
|
||||||
// Init a git repo so post-merge tests would pass if they ran.
|
// Init a git repo so post-merge tests would pass if they ran.
|
||||||
Command::new("git").args(["init"]).current_dir(root).output().unwrap();
|
Command::new("git")
|
||||||
Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(root).output().unwrap();
|
.args(["init"])
|
||||||
Command::new("git").args(["config", "user.name", "Test"]).current_dir(root).output().unwrap();
|
.current_dir(root)
|
||||||
Command::new("git").args(["commit", "--allow-empty", "-m", "init"]).current_dir(root).output().unwrap();
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["config", "user.email", "test@test.com"])
|
||||||
|
.current_dir(root)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["config", "user.name", "Test"])
|
||||||
|
.current_dir(root)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "--allow-empty", "-m", "init"])
|
||||||
|
.current_dir(root)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Set up pipeline dirs.
|
// Set up pipeline dirs.
|
||||||
fs::create_dir_all(root.join(".huskies/work/5_done")).unwrap();
|
fs::create_dir_all(root.join(".huskies/work/5_done")).unwrap();
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
//! Agent completion handling — processes exit results and triggers pipeline advancement.
|
//! Agent completion handling — processes exit results and triggers pipeline advancement.
|
||||||
use crate::slog;
|
|
||||||
use crate::io::watcher::WatcherEvent;
|
use crate::io::watcher::WatcherEvent;
|
||||||
|
use crate::slog;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use super::super::super::{AgentEvent, AgentStatus, CompletionReport, PipelineStage, pipeline_stage};
|
use super::super::super::{
|
||||||
|
AgentEvent, AgentStatus, CompletionReport, PipelineStage, pipeline_stage,
|
||||||
|
};
|
||||||
use super::super::{AgentPool, StoryAgent, composite_key};
|
use super::super::{AgentPool, StoryAgent, composite_key};
|
||||||
use super::advance::spawn_pipeline_advance;
|
use super::advance::spawn_pipeline_advance;
|
||||||
|
|
||||||
@@ -207,7 +209,10 @@ pub(in crate::agents::pool) async fn run_server_owned_completion(
|
|||||||
// hold the build lock while gates try to run.
|
// hold the build lock while gates try to run.
|
||||||
if let Some(wt_path) = worktree_path.as_ref()
|
if let Some(wt_path) = worktree_path.as_ref()
|
||||||
&& let Ok(output) = std::process::Command::new("pgrep")
|
&& let Ok(output) = std::process::Command::new("pgrep")
|
||||||
.args(["-f", &format!("--manifest-path {}/Cargo.toml", wt_path.display())])
|
.args([
|
||||||
|
"-f",
|
||||||
|
&format!("--manifest-path {}/Cargo.toml", wt_path.display()),
|
||||||
|
])
|
||||||
.output()
|
.output()
|
||||||
{
|
{
|
||||||
let pids = String::from_utf8_lossy(&output.stdout);
|
let pids = String::from_utf8_lossy(&output.stdout);
|
||||||
@@ -216,7 +221,9 @@ pub(in crate::agents::pool) async fn run_server_owned_completion(
|
|||||||
crate::slog!(
|
crate::slog!(
|
||||||
"[agents] Killing stale cargo process (pid {pid}) for '{story_id}' before running gates"
|
"[agents] Killing stale cargo process (pid {pid}) for '{story_id}' before running gates"
|
||||||
);
|
);
|
||||||
unsafe { libc::kill(pid, libc::SIGKILL); }
|
unsafe {
|
||||||
|
libc::kill(pid, libc::SIGKILL);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,8 +318,8 @@ pub(in crate::agents::pool) async fn run_server_owned_completion(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
|
||||||
use super::super::super::AgentPool;
|
use super::super::super::AgentPool;
|
||||||
|
use super::*;
|
||||||
use crate::agents::{AgentEvent, AgentStatus, CompletionReport};
|
use crate::agents::{AgentEvent, AgentStatus, CompletionReport};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|||||||
@@ -24,16 +24,23 @@ impl AgentPool {
|
|||||||
project_root: &Path,
|
project_root: &Path,
|
||||||
story_id: &str,
|
story_id: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Guard against double-starts.
|
// Guard against double-starts; clear any completed/failed entry so the
|
||||||
|
// caller can retry without needing to call a separate cleanup step.
|
||||||
{
|
{
|
||||||
let jobs = self.merge_jobs.lock().map_err(|e| e.to_string())?;
|
let mut jobs = self.merge_jobs.lock().map_err(|e| e.to_string())?;
|
||||||
if let Some(job) = jobs.get(story_id)
|
if let Some(job) = jobs.get(story_id) {
|
||||||
&& matches!(job.status, crate::agents::merge::MergeJobStatus::Running)
|
match &job.status {
|
||||||
{
|
crate::agents::merge::MergeJobStatus::Running => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Merge already in progress for '{story_id}'. \
|
"Merge already in progress for '{story_id}'. \
|
||||||
Use get_merge_status to poll for completion."
|
Use get_merge_status to poll for completion."
|
||||||
));
|
));
|
||||||
|
}
|
||||||
|
// Completed or Failed: clear stale entry so we can start fresh.
|
||||||
|
_ => {
|
||||||
|
jobs.remove(story_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,10 +92,11 @@ impl AgentPool {
|
|||||||
let sid = story_id.to_string();
|
let sid = story_id.to_string();
|
||||||
let br = branch.clone();
|
let br = branch.clone();
|
||||||
|
|
||||||
let merge_result =
|
let merge_result = tokio::task::spawn_blocking(move || {
|
||||||
tokio::task::spawn_blocking(move || crate::agents::merge::run_squash_merge(&root, &br, &sid))
|
crate::agents::merge::run_squash_merge(&root, &br, &sid)
|
||||||
.await
|
})
|
||||||
.map_err(|e| format!("Merge task panicked: {e}"))??;
|
.await
|
||||||
|
.map_err(|e| format!("Merge task panicked: {e}"))??;
|
||||||
|
|
||||||
if !merge_result.success {
|
if !merge_result.success {
|
||||||
return Ok(crate::agents::merge::MergeReport {
|
return Ok(crate::agents::merge::MergeReport {
|
||||||
@@ -185,8 +193,8 @@ impl AgentPool {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
|
||||||
use super::super::super::AgentPool;
|
use super::super::super::AgentPool;
|
||||||
|
use super::*;
|
||||||
use crate::agents::merge::{MergeJob, MergeJobStatus};
|
use crate::agents::merge::{MergeJob, MergeJobStatus};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ impl AgentPool {
|
|||||||
|
|
||||||
/// Test helper: inject a child killer into the registry.
|
/// Test helper: inject a child killer into the registry.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn inject_child_killer(&self, key: &str, killer: Box<dyn portable_pty::ChildKiller + Send + Sync>) {
|
pub fn inject_child_killer(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
killer: Box<dyn portable_pty::ChildKiller + Send + Sync>,
|
||||||
|
) {
|
||||||
let mut killers = self.child_killers.lock().unwrap();
|
let mut killers = self.child_killers.lock().unwrap();
|
||||||
killers.insert(key.to_string(), killer);
|
killers.insert(key.to_string(), killer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ use std::path::PathBuf;
|
|||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use super::super::{AgentEvent, AgentInfo, AgentStatus, PipelineStage, agent_config_stage};
|
use super::super::{AgentEvent, AgentInfo, AgentStatus, PipelineStage, agent_config_stage};
|
||||||
use super::types::{agent_info_from_entry, composite_key};
|
|
||||||
use super::AgentPool;
|
use super::AgentPool;
|
||||||
|
use super::types::{agent_info_from_entry, composite_key};
|
||||||
|
|
||||||
impl AgentPool {
|
impl AgentPool {
|
||||||
/// Return the names of configured agents for `stage` that are not currently
|
/// Return the names of configured agents for `stage` that are not currently
|
||||||
|
|||||||
@@ -6,14 +6,15 @@ use std::path::Path;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
use super::super::runtime::{
|
||||||
|
AgentRuntime, ClaudeCodeRuntime, GeminiRuntime, OpenAiRuntime, RuntimeContext,
|
||||||
|
};
|
||||||
use super::super::{
|
use super::super::{
|
||||||
AgentEvent, AgentInfo, AgentStatus, PipelineStage, agent_config_stage,
|
AgentEvent, AgentInfo, AgentStatus, PipelineStage, agent_config_stage, pipeline_stage,
|
||||||
pipeline_stage,
|
|
||||||
};
|
};
|
||||||
use super::types::{PendingGuard, StoryAgent, composite_key};
|
use super::types::{PendingGuard, StoryAgent, composite_key};
|
||||||
use super::{AgentPool, auto_assign};
|
|
||||||
use super::worktree::find_active_story_stage;
|
use super::worktree::find_active_story_stage;
|
||||||
use super::super::runtime::{AgentRuntime, ClaudeCodeRuntime, GeminiRuntime, OpenAiRuntime, RuntimeContext};
|
use super::{AgentPool, auto_assign};
|
||||||
|
|
||||||
impl AgentPool {
|
impl AgentPool {
|
||||||
/// Start an agent for a story: load config, create worktree, spawn agent.
|
/// Start an agent for a story: load config, create worktree, spawn agent.
|
||||||
@@ -102,7 +103,9 @@ impl AgentPool {
|
|||||||
// the auto_assign path (bug 379).
|
// the auto_assign path (bug 379).
|
||||||
let front_matter_agent: Option<String> = if agent_name.is_none() {
|
let front_matter_agent: Option<String> = if agent_name.is_none() {
|
||||||
crate::db::read_content(story_id).and_then(|contents| {
|
crate::db::read_content(story_id).and_then(|contents| {
|
||||||
crate::io::story_metadata::parse_front_matter(&contents).ok()?.agent
|
crate::io::story_metadata::parse_front_matter(&contents)
|
||||||
|
.ok()?
|
||||||
|
.agent
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -446,7 +449,10 @@ impl AgentPool {
|
|||||||
|
|
||||||
let run_result = match runtime_name {
|
let run_result = match runtime_name {
|
||||||
"claude-code" => {
|
"claude-code" => {
|
||||||
let runtime = ClaudeCodeRuntime::new(child_killers_clone.clone(), watcher_tx_clone.clone());
|
let runtime = ClaudeCodeRuntime::new(
|
||||||
|
child_killers_clone.clone(),
|
||||||
|
watcher_tx_clone.clone(),
|
||||||
|
);
|
||||||
let ctx = RuntimeContext {
|
let ctx = RuntimeContext {
|
||||||
story_id: sid.clone(),
|
story_id: sid.clone(),
|
||||||
agent_name: aname.clone(),
|
agent_name: aname.clone(),
|
||||||
@@ -514,7 +520,10 @@ impl AgentPool {
|
|||||||
.find_agent(&aname)
|
.find_agent(&aname)
|
||||||
.and_then(|a| a.model.clone());
|
.and_then(|a| a.model.clone());
|
||||||
let record = crate::agents::token_usage::build_record(
|
let record = crate::agents::token_usage::build_record(
|
||||||
&sid, &aname, model, usage.clone(),
|
&sid,
|
||||||
|
&aname,
|
||||||
|
model,
|
||||||
|
usage.clone(),
|
||||||
);
|
);
|
||||||
if let Err(e) = crate::agents::token_usage::append_record(pr, &record) {
|
if let Err(e) = crate::agents::token_usage::append_record(pr, &record) {
|
||||||
slog_error!(
|
slog_error!(
|
||||||
@@ -568,15 +577,13 @@ impl AgentPool {
|
|||||||
// re-dispatches a new mergemaster if the story still needs
|
// re-dispatches a new mergemaster if the story still needs
|
||||||
// merging. This avoids an async call to start_agent inside
|
// merging. This avoids an async call to start_agent inside
|
||||||
// a tokio::spawn (which would require Send).
|
// a tokio::spawn (which would require Send).
|
||||||
let _ = watcher_tx_clone.send(
|
let _ = watcher_tx_clone.send(crate::io::watcher::WatcherEvent::WorkItem {
|
||||||
crate::io::watcher::WatcherEvent::WorkItem {
|
stage: "4_merge".to_string(),
|
||||||
stage: "4_merge".to_string(),
|
item_id: sid.clone(),
|
||||||
item_id: sid.clone(),
|
action: "reassign".to_string(),
|
||||||
action: "reassign".to_string(),
|
commit_msg: String::new(),
|
||||||
commit_msg: String::new(),
|
from_stage: None,
|
||||||
from_stage: None,
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// Server-owned completion: run acceptance gates automatically
|
// Server-owned completion: run acceptance gates automatically
|
||||||
// when the agent process exits normally.
|
// when the agent process exits normally.
|
||||||
@@ -712,7 +719,9 @@ stage = "coder"
|
|||||||
pool.inject_test_agent("story-1", "coder-1", AgentStatus::Running);
|
pool.inject_test_agent("story-1", "coder-1", AgentStatus::Running);
|
||||||
pool.inject_test_agent("story-2", "coder-2", AgentStatus::Pending);
|
pool.inject_test_agent("story-2", "coder-2", AgentStatus::Pending);
|
||||||
|
|
||||||
let result = pool.start_agent(tmp.path(), "story-3", None, None, None).await;
|
let result = pool
|
||||||
|
.start_agent(tmp.path(), "story-3", None, None, None)
|
||||||
|
.await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let err = result.unwrap_err();
|
let err = result.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -744,7 +753,9 @@ stage = "coder"
|
|||||||
let pool = AgentPool::new_test(3001);
|
let pool = AgentPool::new_test(3001);
|
||||||
pool.inject_test_agent("story-1", "coder-1", AgentStatus::Running);
|
pool.inject_test_agent("story-1", "coder-1", AgentStatus::Running);
|
||||||
|
|
||||||
let result = pool.start_agent(tmp.path(), "story-3", None, None, None).await;
|
let result = pool
|
||||||
|
.start_agent(tmp.path(), "story-3", None, None, None)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let err = result.unwrap_err();
|
let err = result.unwrap_err();
|
||||||
@@ -782,7 +793,9 @@ stage = "coder"
|
|||||||
|
|
||||||
let pool = AgentPool::new_test(3001);
|
let pool = AgentPool::new_test(3001);
|
||||||
|
|
||||||
let result = pool.start_agent(tmp.path(), "story-5", None, None, None).await;
|
let result = pool
|
||||||
|
.start_agent(tmp.path(), "story-5", None, None, None)
|
||||||
|
.await;
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -843,7 +856,9 @@ stage = "coder"
|
|||||||
let pool = AgentPool::new_test(3001);
|
let pool = AgentPool::new_test(3001);
|
||||||
pool.inject_test_agent("story-a", "qa", AgentStatus::Running);
|
pool.inject_test_agent("story-a", "qa", AgentStatus::Running);
|
||||||
|
|
||||||
let result = pool.start_agent(root, "story-b", Some("qa"), None, None).await;
|
let result = pool
|
||||||
|
.start_agent(root, "story-b", Some("qa"), None, None)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
result.is_err(),
|
result.is_err(),
|
||||||
@@ -870,7 +885,9 @@ stage = "coder"
|
|||||||
let pool = AgentPool::new_test(3001);
|
let pool = AgentPool::new_test(3001);
|
||||||
pool.inject_test_agent("story-a", "qa", AgentStatus::Completed);
|
pool.inject_test_agent("story-a", "qa", AgentStatus::Completed);
|
||||||
|
|
||||||
let result = pool.start_agent(root, "story-b", Some("qa"), None, None).await;
|
let result = pool
|
||||||
|
.start_agent(root, "story-b", Some("qa"), None, None)
|
||||||
|
.await;
|
||||||
|
|
||||||
if let Err(ref e) = result {
|
if let Err(ref e) = result {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -962,7 +979,9 @@ stage = "coder"
|
|||||||
let pool = AgentPool::new_test(3099);
|
let pool = AgentPool::new_test(3099);
|
||||||
pool.inject_test_agent("story-x", "qa", AgentStatus::Running);
|
pool.inject_test_agent("story-x", "qa", AgentStatus::Running);
|
||||||
|
|
||||||
let result = pool.start_agent(root, "story-y", Some("qa"), None, None).await;
|
let result = pool
|
||||||
|
.start_agent(root, "story-y", Some("qa"), None, None)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let err = result.unwrap_err();
|
let err = result.unwrap_err();
|
||||||
@@ -1247,11 +1266,7 @@ stage = "coder"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
crate::db::ensure_content_store();
|
crate::db::ensure_content_store();
|
||||||
crate::db::write_item_with_content(
|
crate::db::write_item_with_content("310_story_foo", "2_current", "---\nname: Foo\n---\n");
|
||||||
"310_story_foo",
|
|
||||||
"2_current",
|
|
||||||
"---\nname: Foo\n---\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
let pool = AgentPool::new_test(3099);
|
let pool = AgentPool::new_test(3099);
|
||||||
let result = pool
|
let result = pool
|
||||||
@@ -1323,11 +1338,7 @@ stage = "coder"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
crate::db::ensure_content_store();
|
crate::db::ensure_content_store();
|
||||||
crate::db::write_item_with_content(
|
crate::db::write_item_with_content("55_story_baz", "4_merge", "---\nname: Baz\n---\n");
|
||||||
"55_story_baz",
|
|
||||||
"4_merge",
|
|
||||||
"---\nname: Baz\n---\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
let pool = AgentPool::new_test(3099);
|
let pool = AgentPool::new_test(3099);
|
||||||
let result = pool
|
let result = pool
|
||||||
@@ -1459,7 +1470,13 @@ stage = "coder"
|
|||||||
|
|
||||||
let pool = AgentPool::new_test(3098);
|
let pool = AgentPool::new_test(3098);
|
||||||
let result = pool
|
let result = pool
|
||||||
.start_agent(root, "502_story_split_brain", Some("mergemaster"), None, None)
|
.start_agent(
|
||||||
|
root,
|
||||||
|
"502_story_split_brain",
|
||||||
|
Some("mergemaster"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Stage check must not reject mergemaster.
|
// Stage check must not reject mergemaster.
|
||||||
@@ -1475,11 +1492,15 @@ stage = "coder"
|
|||||||
// Before the fix, line 53 of start.rs would have demoted it to
|
// Before the fix, line 53 of start.rs would have demoted it to
|
||||||
// 2_current/ via move_story_to_current finding the 1_backlog shadow.
|
// 2_current/ via move_story_to_current finding the 1_backlog shadow.
|
||||||
assert!(
|
assert!(
|
||||||
sk_dir.join("work/4_merge/502_story_split_brain.md").exists(),
|
sk_dir
|
||||||
|
.join("work/4_merge/502_story_split_brain.md")
|
||||||
|
.exists(),
|
||||||
"story must still be in 4_merge/ after start_agent(mergemaster, ...)"
|
"story must still be in 4_merge/ after start_agent(mergemaster, ...)"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!sk_dir.join("work/2_current/502_story_split_brain.md").exists(),
|
!sk_dir
|
||||||
|
.join("work/2_current/502_story_split_brain.md")
|
||||||
|
.exists(),
|
||||||
"story must NOT have been demoted to 2_current/ — that's bug 502"
|
"story must NOT have been demoted to 2_current/ — that's bug 502"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1564,11 +1585,7 @@ stage = "coder"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let story_content = "---\nname: Test Story\nagent: coder-opus\n---\n# Story 368\n";
|
let story_content = "---\nname: Test Story\nagent: coder-opus\n---\n# Story 368\n";
|
||||||
std::fs::write(
|
std::fs::write(backlog.join("368_story_test.md"), story_content).unwrap();
|
||||||
backlog.join("368_story_test.md"),
|
|
||||||
story_content,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
// Also write to the filesystem current dir and content store so that
|
// Also write to the filesystem current dir and content store so that
|
||||||
// start_agent reads the correct front matter even when another test has
|
// start_agent reads the correct front matter even when another test has
|
||||||
// left a stale entry for "368_story_test" in the global CRDT.
|
// left a stale entry for "368_story_test" in the global CRDT.
|
||||||
@@ -1583,7 +1600,10 @@ stage = "coder"
|
|||||||
let result = pool
|
let result = pool
|
||||||
.start_agent(tmp.path(), "368_story_test", None, None, None)
|
.start_agent(tmp.path(), "368_story_test", None, None, None)
|
||||||
.await;
|
.await;
|
||||||
assert!(result.is_err(), "expected error when preferred agent is busy");
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"expected error when preferred agent is busy"
|
||||||
|
);
|
||||||
let err = result.unwrap_err();
|
let err = result.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
err.contains("coder-opus"),
|
err.contains("coder-opus"),
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ use crate::slog_error;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use super::super::{AgentEvent, AgentStatus};
|
use super::super::{AgentEvent, AgentStatus};
|
||||||
use super::types::composite_key;
|
|
||||||
use super::AgentPool;
|
use super::AgentPool;
|
||||||
|
use super::types::composite_key;
|
||||||
|
|
||||||
impl AgentPool {
|
impl AgentPool {
|
||||||
/// Stop a running agent. Worktree is preserved for inspection.
|
/// Stop a running agent. Worktree is preserved for inspection.
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use std::sync::{Arc, Mutex};
|
|||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use super::super::{AgentEvent, AgentStatus, CompletionReport};
|
use super::super::{AgentEvent, AgentStatus, CompletionReport};
|
||||||
use super::types::{StoryAgent, composite_key};
|
|
||||||
use super::AgentPool;
|
use super::AgentPool;
|
||||||
|
use super::types::{StoryAgent, composite_key};
|
||||||
|
|
||||||
impl AgentPool {
|
impl AgentPool {
|
||||||
/// Test helper: inject a pre-built agent entry so unit tests can exercise
|
/// Test helper: inject a pre-built agent entry so unit tests can exercise
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Agent wait — blocks until an agent reaches a terminal state with optional timeout.
|
//! Agent wait — blocks until an agent reaches a terminal state with optional timeout.
|
||||||
use super::super::{AgentEvent, AgentInfo, AgentStatus};
|
use super::super::{AgentEvent, AgentInfo, AgentStatus};
|
||||||
use super::types::{agent_info_from_entry, composite_key};
|
|
||||||
use super::AgentPool;
|
use super::AgentPool;
|
||||||
|
use super::types::{agent_info_from_entry, composite_key};
|
||||||
|
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ impl AgentPool {
|
|||||||
|
|
||||||
/// Return the active pipeline stage directory name for `story_id`, or `None` if the
|
/// Return the active pipeline stage directory name for `story_id`, or `None` if the
|
||||||
/// story is not in any active stage (`2_current/`, `3_qa/`, `4_merge/`).
|
/// story is not in any active stage (`2_current/`, `3_qa/`, `4_merge/`).
|
||||||
pub(super) fn find_active_story_stage(_project_root: &Path, story_id: &str) -> Option<&'static str> {
|
pub(super) fn find_active_story_stage(
|
||||||
|
_project_root: &Path,
|
||||||
|
story_id: &str,
|
||||||
|
) -> Option<&'static str> {
|
||||||
if let Ok(Some(item)) = crate::pipeline_state::read_typed(story_id)
|
if let Ok(Some(item)) = crate::pipeline_state::read_typed(story_id)
|
||||||
&& item.stage.is_active()
|
&& item.stage.is_active()
|
||||||
{
|
{
|
||||||
@@ -39,11 +42,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn find_active_story_stage_detects_current() {
|
fn find_active_story_stage_detects_current() {
|
||||||
crate::db::ensure_content_store();
|
crate::db::ensure_content_store();
|
||||||
crate::db::write_item_with_content(
|
crate::db::write_item_with_content("10_story_test", "2_current", "---\nname: Test\n---\n");
|
||||||
"10_story_test",
|
|
||||||
"2_current",
|
|
||||||
"---\nname: Test\n---\n",
|
|
||||||
);
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
find_active_story_stage(tmp.path(), "10_story_test"),
|
find_active_story_stage(tmp.path(), "10_story_test"),
|
||||||
@@ -54,23 +53,18 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn find_active_story_stage_detects_qa() {
|
fn find_active_story_stage_detects_qa() {
|
||||||
crate::db::ensure_content_store();
|
crate::db::ensure_content_store();
|
||||||
crate::db::write_item_with_content(
|
crate::db::write_item_with_content("11_story_test", "3_qa", "---\nname: Test\n---\n");
|
||||||
"11_story_test",
|
|
||||||
"3_qa",
|
|
||||||
"---\nname: Test\n---\n",
|
|
||||||
);
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
assert_eq!(find_active_story_stage(tmp.path(), "11_story_test"), Some("3_qa"));
|
assert_eq!(
|
||||||
|
find_active_story_stage(tmp.path(), "11_story_test"),
|
||||||
|
Some("3_qa")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn find_active_story_stage_detects_merge() {
|
fn find_active_story_stage_detects_merge() {
|
||||||
crate::db::ensure_content_store();
|
crate::db::ensure_content_store();
|
||||||
crate::db::write_item_with_content(
|
crate::db::write_item_with_content("12_story_test", "4_merge", "---\nname: Test\n---\n");
|
||||||
"12_story_test",
|
|
||||||
"4_merge",
|
|
||||||
"---\nname: Test\n---\n",
|
|
||||||
);
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
find_active_story_stage(tmp.path(), "12_story_test"),
|
find_active_story_stage(tmp.path(), "12_story_test"),
|
||||||
|
|||||||
+20
-18
@@ -237,10 +237,23 @@ fn run_agent_pty_blocking(
|
|||||||
story_id.replace(['_', '.'], "-")
|
story_id.replace(['_', '.'], "-")
|
||||||
);
|
);
|
||||||
let session_count = std::fs::read_dir(&session_dir)
|
let session_count = std::fs::read_dir(&session_dir)
|
||||||
.map(|d| d.filter(|e| e.as_ref().map(|e| e.path().extension().is_some_and(|ext| ext == "jsonl")).unwrap_or(false)).count())
|
.map(|d| {
|
||||||
|
d.filter(|e| {
|
||||||
|
e.as_ref()
|
||||||
|
.map(|e| e.path().extension().is_some_and(|ext| ext == "jsonl"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
})
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let session_bytes: u64 = std::fs::read_dir(&session_dir)
|
let session_bytes: u64 = std::fs::read_dir(&session_dir)
|
||||||
.map(|d| d.filter_map(|e| e.ok()).filter(|e| e.path().extension().is_some_and(|ext| ext == "jsonl")).filter_map(|e| e.metadata().ok()).map(|m| m.len()).sum())
|
.map(|d| {
|
||||||
|
d.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.path().extension().is_some_and(|ext| ext == "jsonl"))
|
||||||
|
.filter_map(|e| e.metadata().ok())
|
||||||
|
.map(|m| m.len())
|
||||||
|
.sum()
|
||||||
|
})
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
slog!(
|
slog!(
|
||||||
@@ -373,12 +386,7 @@ fn run_agent_pty_blocking(
|
|||||||
"stream_event" => {
|
"stream_event" => {
|
||||||
if let Some(event) = json.get("event") {
|
if let Some(event) = json.get("event") {
|
||||||
handle_agent_stream_event(
|
handle_agent_stream_event(
|
||||||
event,
|
event, story_id, agent_name, tx, event_log, log_writer,
|
||||||
story_id,
|
|
||||||
agent_name,
|
|
||||||
tx,
|
|
||||||
event_log,
|
|
||||||
log_writer,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -409,8 +417,7 @@ fn run_agent_pty_blocking(
|
|||||||
t
|
t
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let default = chrono::Utc::now()
|
let default = chrono::Utc::now() + chrono::Duration::minutes(5);
|
||||||
+ chrono::Duration::minutes(5);
|
|
||||||
slog!(
|
slog!(
|
||||||
"[agent:{story_id}:{agent_name}] API rate limit hard block \
|
"[agent:{story_id}:{agent_name}] API rate limit hard block \
|
||||||
(status={status}); no reset_at in rate_limit_info, \
|
(status={status}); no reset_at in rate_limit_info, \
|
||||||
@@ -469,14 +476,10 @@ fn run_agent_pty_blocking(
|
|||||||
let wait_result = child.wait();
|
let wait_result = child.wait();
|
||||||
match &wait_result {
|
match &wait_result {
|
||||||
Ok(status) => {
|
Ok(status) => {
|
||||||
slog!(
|
slog!("[agent:{story_id}:{agent_name}] Child exited: {status:?}");
|
||||||
"[agent:{story_id}:{agent_name}] Child exited: {status:?}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
slog!(
|
slog!("[agent:{story_id}:{agent_name}] Child wait error: {e}");
|
||||||
"[agent:{story_id}:{agent_name}] Child wait error: {e}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,8 +712,7 @@ mod tests {
|
|||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
|
|
||||||
let log_writer =
|
let log_writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-emit").unwrap();
|
||||||
AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-emit").unwrap();
|
|
||||||
let log_mutex = Mutex::new(log_writer);
|
let log_mutex = Mutex::new(log_writer);
|
||||||
|
|
||||||
let (tx, _rx) = broadcast::channel::<AgentEvent>(64);
|
let (tx, _rx) = broadcast::channel::<AgentEvent>(64);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use crate::agent_log::AgentLogWriter;
|
use crate::agent_log::AgentLogWriter;
|
||||||
@@ -135,14 +135,15 @@ impl AgentRuntime for GeminiRuntime {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
slog!("[gemini] Turn {turn} for {}:{}", ctx.story_id, ctx.agent_name);
|
slog!(
|
||||||
|
"[gemini] Turn {turn} for {}:{}",
|
||||||
let request_body = build_generate_content_request(
|
ctx.story_id,
|
||||||
&system_instruction,
|
ctx.agent_name
|
||||||
&contents,
|
|
||||||
&gemini_tools,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let request_body =
|
||||||
|
build_generate_content_request(&system_instruction, &contents, &gemini_tools);
|
||||||
|
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
|
"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
|
||||||
);
|
);
|
||||||
@@ -201,8 +202,7 @@ impl AgentRuntime for GeminiRuntime {
|
|||||||
text_parts.push(text.to_string());
|
text_parts.push(text.to_string());
|
||||||
}
|
}
|
||||||
if let Some(fc) = part.get("functionCall")
|
if let Some(fc) = part.get("functionCall")
|
||||||
&& let (Some(name), Some(args)) =
|
&& let (Some(name), Some(args)) = (fc["name"].as_str(), fc.get("args"))
|
||||||
(fc["name"].as_str(), fc.get("args"))
|
|
||||||
{
|
{
|
||||||
function_calls.push(GeminiFunctionCall {
|
function_calls.push(GeminiFunctionCall {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
@@ -263,18 +263,14 @@ impl AgentRuntime for GeminiRuntime {
|
|||||||
text: format!("\n[Tool call: {}]\n", fc.name),
|
text: format!("\n[Tool call: {}]\n", fc.name),
|
||||||
});
|
});
|
||||||
|
|
||||||
let tool_result =
|
let tool_result = call_mcp_tool(&client, &mcp_base, &fc.name, &fc.args).await;
|
||||||
call_mcp_tool(&client, &mcp_base, &fc.name, &fc.args).await;
|
|
||||||
|
|
||||||
let response_value = match &tool_result {
|
let response_value = match &tool_result {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
emit(AgentEvent::Output {
|
emit(AgentEvent::Output {
|
||||||
story_id: ctx.story_id.clone(),
|
story_id: ctx.story_id.clone(),
|
||||||
agent_name: ctx.agent_name.clone(),
|
agent_name: ctx.agent_name.clone(),
|
||||||
text: format!(
|
text: format!("[Tool result: {} chars]\n", result.len()),
|
||||||
"[Tool result: {} chars]\n",
|
|
||||||
result.len()
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
json!({ "result": result })
|
json!({ "result": result })
|
||||||
}
|
}
|
||||||
@@ -453,7 +449,10 @@ async fn fetch_and_convert_mcp_tools(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
slog!("[gemini] Loaded {} MCP tools as function declarations", declarations.len());
|
slog!(
|
||||||
|
"[gemini] Loaded {} MCP tools as function declarations",
|
||||||
|
declarations.len()
|
||||||
|
);
|
||||||
Ok(declarations)
|
Ok(declarations)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,10 +559,7 @@ async fn call_mcp_tool(
|
|||||||
// MCP tools/call returns { result: { content: [{ type: "text", text: "..." }] } }
|
// MCP tools/call returns { result: { content: [{ type: "text", text: "..." }] } }
|
||||||
let content = &body["result"]["content"];
|
let content = &body["result"]["content"];
|
||||||
if let Some(arr) = content.as_array() {
|
if let Some(arr) = content.as_array() {
|
||||||
let texts: Vec<&str> = arr
|
let texts: Vec<&str> = arr.iter().filter_map(|c| c["text"].as_str()).collect();
|
||||||
.iter()
|
|
||||||
.filter_map(|c| c["text"].as_str())
|
|
||||||
.collect();
|
|
||||||
if !texts.is_empty() {
|
if !texts.is_empty() {
|
||||||
return Ok(texts.join("\n"));
|
return Ok(texts.join("\n"));
|
||||||
}
|
}
|
||||||
@@ -747,7 +743,10 @@ mod tests {
|
|||||||
|
|
||||||
let body = build_generate_content_request(&system, &contents, &tools);
|
let body = build_generate_content_request(&system, &contents, &tools);
|
||||||
assert!(body["tools"][0]["functionDeclarations"].is_array());
|
assert!(body["tools"][0]["functionDeclarations"].is_array());
|
||||||
assert_eq!(body["tools"][0]["functionDeclarations"][0]["name"], "my_tool");
|
assert_eq!(
|
||||||
|
body["tools"][0]["functionDeclarations"][0]["name"],
|
||||||
|
"my_tool"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -151,8 +151,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claude_code_runtime_get_status_returns_idle() {
|
fn claude_code_runtime_get_status_returns_idle() {
|
||||||
use std::collections::HashMap;
|
|
||||||
use crate::io::watcher::WatcherEvent;
|
use crate::io::watcher::WatcherEvent;
|
||||||
|
use std::collections::HashMap;
|
||||||
let killers = Arc::new(Mutex::new(HashMap::new()));
|
let killers = Arc::new(Mutex::new(HashMap::new()));
|
||||||
let (watcher_tx, _) = broadcast::channel::<WatcherEvent>(16);
|
let (watcher_tx, _) = broadcast::channel::<WatcherEvent>(16);
|
||||||
let runtime = ClaudeCodeRuntime::new(killers, watcher_tx);
|
let runtime = ClaudeCodeRuntime::new(killers, watcher_tx);
|
||||||
@@ -161,8 +161,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claude_code_runtime_stream_events_empty() {
|
fn claude_code_runtime_stream_events_empty() {
|
||||||
use std::collections::HashMap;
|
|
||||||
use crate::io::watcher::WatcherEvent;
|
use crate::io::watcher::WatcherEvent;
|
||||||
|
use std::collections::HashMap;
|
||||||
let killers = Arc::new(Mutex::new(HashMap::new()));
|
let killers = Arc::new(Mutex::new(HashMap::new()));
|
||||||
let (watcher_tx, _) = broadcast::channel::<WatcherEvent>(16);
|
let (watcher_tx, _) = broadcast::channel::<WatcherEvent>(16);
|
||||||
let runtime = ClaudeCodeRuntime::new(killers, watcher_tx);
|
let runtime = ClaudeCodeRuntime::new(killers, watcher_tx);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use crate::agent_log::AgentLogWriter;
|
use crate::agent_log::AgentLogWriter;
|
||||||
@@ -471,10 +471,7 @@ async fn call_mcp_tool(
|
|||||||
// MCP tools/call returns { result: { content: [{ type: "text", text: "..." }] } }
|
// MCP tools/call returns { result: { content: [{ type: "text", text: "..." }] } }
|
||||||
let content = &body["result"]["content"];
|
let content = &body["result"]["content"];
|
||||||
if let Some(arr) = content.as_array() {
|
if let Some(arr) = content.as_array() {
|
||||||
let texts: Vec<&str> = arr
|
let texts: Vec<&str> = arr.iter().filter_map(|c| c["text"].as_str()).collect();
|
||||||
.iter()
|
|
||||||
.filter_map(|c| c["text"].as_str())
|
|
||||||
.collect();
|
|
||||||
if !texts.is_empty() {
|
if !texts.is_empty() {
|
||||||
return Ok(texts.join("\n"));
|
return Ok(texts.join("\n"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,10 @@ mod tests {
|
|||||||
// "timmy ambient on" — bot name mentioned but not @-prefixed, so
|
// "timmy ambient on" — bot name mentioned but not @-prefixed, so
|
||||||
// is_addressed is false; strip_bot_mention still strips "timmy ".
|
// is_addressed is false; strip_bot_mention still strips "timmy ".
|
||||||
let result = try_handle_command(&dispatch, "timmy ambient on");
|
let result = try_handle_command(&dispatch, "timmy ambient on");
|
||||||
assert!(result.is_some(), "ambient on should fire even when is_addressed=false");
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"ambient on should fire even when is_addressed=false"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
ambient_rooms.lock().unwrap().contains(&room_id),
|
ambient_rooms.lock().unwrap().contains(&room_id),
|
||||||
"room should be in ambient_rooms after ambient on"
|
"room should be in ambient_rooms after ambient on"
|
||||||
@@ -92,7 +95,10 @@ mod tests {
|
|||||||
};
|
};
|
||||||
// Bare "ambient off" in an ambient room (is_addressed=false).
|
// Bare "ambient off" in an ambient room (is_addressed=false).
|
||||||
let result = try_handle_command(&dispatch, "ambient off");
|
let result = try_handle_command(&dispatch, "ambient off");
|
||||||
assert!(result.is_some(), "bare ambient off should be handled without LLM");
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"bare ambient off should be handled without LLM"
|
||||||
|
);
|
||||||
let output = result.unwrap();
|
let output = result.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Ambient mode off"),
|
output.contains("Ambient mode off"),
|
||||||
@@ -161,7 +167,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ambient_invalid_args_returns_usage() {
|
fn ambient_invalid_args_returns_usage() {
|
||||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy ambient");
|
let result = super::super::tests::try_cmd_addressed(
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
"@timmy ambient",
|
||||||
|
);
|
||||||
let output = result.unwrap();
|
let output = result.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Usage"),
|
output.contains("Usage"),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! Handler for the `backlog` command — shows only Stage::Backlog items.
|
//! Handler for the `backlog` command — shows only Stage::Backlog items.
|
||||||
|
|
||||||
use crate::pipeline_state::{PipelineItem, Stage};
|
|
||||||
use super::CommandContext;
|
use super::CommandContext;
|
||||||
use super::status::{story_short_label, unmet_deps_from_items};
|
use super::status::{story_short_label, unmet_deps_from_items};
|
||||||
|
use crate::pipeline_state::{PipelineItem, Stage};
|
||||||
|
|
||||||
pub(super) fn handle_backlog(_ctx: &CommandContext) -> Option<String> {
|
pub(super) fn handle_backlog(_ctx: &CommandContext) -> Option<String> {
|
||||||
Some(build_backlog_output())
|
Some(build_backlog_output())
|
||||||
@@ -94,16 +94,29 @@ mod tests {
|
|||||||
make_item("30_story_in_qa", "In QA", Stage::Qa),
|
make_item("30_story_in_qa", "In QA", Stage::Qa),
|
||||||
];
|
];
|
||||||
let output = build_backlog_from_items(&items);
|
let output = build_backlog_from_items(&items);
|
||||||
assert!(output.contains("In Backlog"), "should show backlog item: {output}");
|
assert!(
|
||||||
assert!(!output.contains("In Progress"), "should not show coding items: {output}");
|
output.contains("In Backlog"),
|
||||||
assert!(!output.contains("In QA"), "should not show QA items: {output}");
|
"should show backlog item: {output}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!output.contains("In Progress"),
|
||||||
|
"should not show coding items: {output}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!output.contains("In QA"),
|
||||||
|
"should not show QA items: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- AC: shows number, type, name -----------------------------------------
|
// -- AC: shows number, type, name -----------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backlog_shows_number_type_and_name() {
|
fn backlog_shows_number_type_and_name() {
|
||||||
let items = vec![make_item("42_story_my_feature", "My Feature", Stage::Backlog)];
|
let items = vec![make_item(
|
||||||
|
"42_story_my_feature",
|
||||||
|
"My Feature",
|
||||||
|
Stage::Backlog,
|
||||||
|
)];
|
||||||
let output = build_backlog_from_items(&items);
|
let output = build_backlog_from_items(&items);
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("42 [story] — My Feature"),
|
output.contains("42 [story] — My Feature"),
|
||||||
@@ -116,7 +129,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn backlog_shows_waiting_on_for_unmet_deps() {
|
fn backlog_shows_waiting_on_for_unmet_deps() {
|
||||||
let items = vec![
|
let items = vec![
|
||||||
make_item_with_deps("10_story_waiting", "Waiting Story", Stage::Backlog, vec![999]),
|
make_item_with_deps(
|
||||||
|
"10_story_waiting",
|
||||||
|
"Waiting Story",
|
||||||
|
Stage::Backlog,
|
||||||
|
vec![999],
|
||||||
|
),
|
||||||
make_item("999_story_dep", "Dep Story", Stage::Backlog),
|
make_item("999_story_dep", "Dep Story", Stage::Backlog),
|
||||||
];
|
];
|
||||||
let output = build_backlog_from_items(&items);
|
let output = build_backlog_from_items(&items);
|
||||||
@@ -150,16 +168,17 @@ mod tests {
|
|||||||
fn backlog_no_waiting_on_when_no_deps() {
|
fn backlog_no_waiting_on_when_no_deps() {
|
||||||
let items = vec![make_item("5_story_nodeps", "No Deps", Stage::Backlog)];
|
let items = vec![make_item("5_story_nodeps", "No Deps", Stage::Backlog)];
|
||||||
let output = build_backlog_from_items(&items);
|
let output = build_backlog_from_items(&items);
|
||||||
assert!(!output.contains("waiting on"), "no dep suffix when no deps: {output}");
|
assert!(
|
||||||
|
!output.contains("waiting on"),
|
||||||
|
"no dep suffix when no deps: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- AC: command is registered in the registry ----------------------------
|
// -- AC: command is registered in the registry ----------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backlog_command_in_registry() {
|
fn backlog_command_in_registry() {
|
||||||
let found = super::super::commands()
|
let found = super::super::commands().iter().any(|c| c.name == "backlog");
|
||||||
.iter()
|
|
||||||
.any(|c| c.name == "backlog");
|
|
||||||
assert!(found, "backlog must be registered in commands()");
|
assert!(found, "backlog must be registered in commands()");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +190,10 @@ mod tests {
|
|||||||
"@timmy help",
|
"@timmy help",
|
||||||
);
|
);
|
||||||
let output = result.unwrap_or_default();
|
let output = result.unwrap_or_default();
|
||||||
assert!(output.contains("backlog"), "backlog should appear in help output: {output}");
|
assert!(
|
||||||
|
output.contains("backlog"),
|
||||||
|
"backlog should appear in help output: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -181,7 +203,10 @@ mod tests {
|
|||||||
"@timmy:homeserver.local",
|
"@timmy:homeserver.local",
|
||||||
"@timmy backlog",
|
"@timmy backlog",
|
||||||
);
|
);
|
||||||
assert!(result.is_some(), "backlog command should match and return Some");
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"backlog command should match and return Some"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -192,7 +217,10 @@ mod tests {
|
|||||||
"@timmy backlog",
|
"@timmy backlog",
|
||||||
);
|
);
|
||||||
let output = result.unwrap_or_default();
|
let output = result.unwrap_or_default();
|
||||||
assert!(output.contains("Backlog"), "backlog output should contain Backlog header: {output}");
|
assert!(
|
||||||
|
output.contains("Backlog"),
|
||||||
|
"backlog output should contain Backlog header: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- empty backlog --------------------------------------------------------
|
// -- empty backlog --------------------------------------------------------
|
||||||
@@ -201,6 +229,9 @@ mod tests {
|
|||||||
fn backlog_shows_none_when_empty() {
|
fn backlog_shows_none_when_empty() {
|
||||||
let items = vec![make_item("1_story_done", "Done", Stage::Coding)];
|
let items = vec![make_item("1_story_done", "Done", Stage::Coding)];
|
||||||
let output = build_backlog_from_items(&items);
|
let output = build_backlog_from_items(&items);
|
||||||
assert!(output.contains("*(none)*"), "should show none when no backlog items: {output}");
|
assert!(
|
||||||
|
output.contains("*(none)*"),
|
||||||
|
"should show none when no backlog items: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use super::status::story_short_label;
|
|
||||||
use super::CommandContext;
|
use super::CommandContext;
|
||||||
|
use super::status::story_short_label;
|
||||||
|
|
||||||
/// Show token spend: 24h total, top 5 stories, agent-type breakdown, and
|
/// Show token spend: 24h total, top 5 stories, agent-type breakdown, and
|
||||||
/// all-time total.
|
/// all-time total.
|
||||||
@@ -102,7 +102,10 @@ mod tests {
|
|||||||
use crate::agents::AgentPool;
|
use crate::agents::AgentPool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
fn write_token_records(root: &std::path::Path, records: &[crate::agents::token_usage::TokenUsageRecord]) {
|
fn write_token_records(
|
||||||
|
root: &std::path::Path,
|
||||||
|
records: &[crate::agents::token_usage::TokenUsageRecord],
|
||||||
|
) {
|
||||||
for r in records {
|
for r in records {
|
||||||
crate::agents::token_usage::append_record(root, r).unwrap();
|
crate::agents::token_usage::append_record(root, r).unwrap();
|
||||||
}
|
}
|
||||||
@@ -118,7 +121,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_record(story_id: &str, agent_name: &str, cost: f64, hours_ago: i64) -> crate::agents::token_usage::TokenUsageRecord {
|
fn make_record(
|
||||||
|
story_id: &str,
|
||||||
|
agent_name: &str,
|
||||||
|
cost: f64,
|
||||||
|
hours_ago: i64,
|
||||||
|
) -> crate::agents::token_usage::TokenUsageRecord {
|
||||||
let ts = (chrono::Utc::now() - chrono::Duration::hours(hours_ago)).to_rfc3339();
|
let ts = (chrono::Utc::now() - chrono::Duration::hours(hours_ago)).to_rfc3339();
|
||||||
crate::agents::token_usage::TokenUsageRecord {
|
crate::agents::token_usage::TokenUsageRecord {
|
||||||
story_id: story_id.to_string(),
|
story_id: story_id.to_string(),
|
||||||
@@ -157,55 +165,89 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cost_command_appears_in_help() {
|
fn cost_command_appears_in_help() {
|
||||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
let result = super::super::tests::try_cmd_addressed(
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
"@timmy help",
|
||||||
|
);
|
||||||
let output = result.unwrap();
|
let output = result.unwrap();
|
||||||
assert!(output.contains("cost"), "help should list cost command: {output}");
|
assert!(
|
||||||
|
output.contains("cost"),
|
||||||
|
"help should list cost command: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cost_command_no_records() {
|
fn cost_command_no_records() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||||
assert!(output.contains("No usage records found"), "should show empty message: {output}");
|
assert!(
|
||||||
|
output.contains("No usage records found"),
|
||||||
|
"should show empty message: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cost_command_shows_24h_total() {
|
fn cost_command_shows_24h_total() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
write_token_records(tmp.path(), &[
|
write_token_records(
|
||||||
make_record("42_story_foo", "coder-1", 1.50, 2),
|
tmp.path(),
|
||||||
make_record("42_story_foo", "coder-1", 0.50, 5),
|
&[
|
||||||
]);
|
make_record("42_story_foo", "coder-1", 1.50, 2),
|
||||||
|
make_record("42_story_foo", "coder-1", 0.50, 5),
|
||||||
|
],
|
||||||
|
);
|
||||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||||
assert!(output.contains("**Last 24h:** $2.00"), "should show 24h total: {output}");
|
assert!(
|
||||||
|
output.contains("**Last 24h:** $2.00"),
|
||||||
|
"should show 24h total: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cost_command_excludes_old_from_24h() {
|
fn cost_command_excludes_old_from_24h() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
write_token_records(tmp.path(), &[
|
write_token_records(
|
||||||
make_record("42_story_foo", "coder-1", 1.00, 2), // within 24h
|
tmp.path(),
|
||||||
make_record("43_story_bar", "coder-1", 5.00, 48), // older
|
&[
|
||||||
]);
|
make_record("42_story_foo", "coder-1", 1.00, 2), // within 24h
|
||||||
|
make_record("43_story_bar", "coder-1", 5.00, 48), // older
|
||||||
|
],
|
||||||
|
);
|
||||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||||
assert!(output.contains("**Last 24h:** $1.00"), "should only count recent: {output}");
|
assert!(
|
||||||
assert!(output.contains("**All-time:** $6.00"), "all-time should include everything: {output}");
|
output.contains("**Last 24h:** $1.00"),
|
||||||
|
"should only count recent: {output}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains("**All-time:** $6.00"),
|
||||||
|
"all-time should include everything: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cost_command_shows_top_stories() {
|
fn cost_command_shows_top_stories() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
write_token_records(tmp.path(), &[
|
write_token_records(
|
||||||
make_record("42_story_foo", "coder-1", 3.00, 1),
|
tmp.path(),
|
||||||
make_record("43_story_bar", "coder-1", 1.00, 1),
|
&[
|
||||||
make_record("42_story_foo", "qa-1", 2.00, 1),
|
make_record("42_story_foo", "coder-1", 3.00, 1),
|
||||||
]);
|
make_record("43_story_bar", "coder-1", 1.00, 1),
|
||||||
|
make_record("42_story_foo", "qa-1", 2.00, 1),
|
||||||
|
],
|
||||||
|
);
|
||||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||||
assert!(output.contains("Top Stories"), "should have top stories section: {output}");
|
assert!(
|
||||||
|
output.contains("Top Stories"),
|
||||||
|
"should have top stories section: {output}"
|
||||||
|
);
|
||||||
// Story 42 ($5.00) should appear before story 43 ($1.00)
|
// Story 42 ($5.00) should appear before story 43 ($1.00)
|
||||||
let pos_42 = output.find("42").unwrap();
|
let pos_42 = output.find("42").unwrap();
|
||||||
let pos_43 = output.find("43").unwrap();
|
let pos_43 = output.find("43").unwrap();
|
||||||
assert!(pos_42 < pos_43, "story 42 should appear before 43 (sorted by cost): {output}");
|
assert!(
|
||||||
|
pos_42 < pos_43,
|
||||||
|
"story 42 should appear before 43 (sorted by cost): {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -213,45 +255,75 @@ mod tests {
|
|||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let mut records = Vec::new();
|
let mut records = Vec::new();
|
||||||
for i in 1..=7 {
|
for i in 1..=7 {
|
||||||
records.push(make_record(&format!("{i}_story_s{i}"), "coder-1", i as f64, 1));
|
records.push(make_record(
|
||||||
|
&format!("{i}_story_s{i}"),
|
||||||
|
"coder-1",
|
||||||
|
i as f64,
|
||||||
|
1,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
write_token_records(tmp.path(), &records);
|
write_token_records(tmp.path(), &records);
|
||||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||||
// The top 5 most expensive are stories 7,6,5,4,3. Stories 1 and 2 should be excluded.
|
// The top 5 most expensive are stories 7,6,5,4,3. Stories 1 and 2 should be excluded.
|
||||||
let top_section = output.split("**By Agent Type").next().unwrap();
|
let top_section = output.split("**By Agent Type").next().unwrap();
|
||||||
assert!(!top_section.contains("• 1 —"), "story 1 should not be in top 5: {output}");
|
assert!(
|
||||||
assert!(!top_section.contains("• 2 —"), "story 2 should not be in top 5: {output}");
|
!top_section.contains("• 1 —"),
|
||||||
|
"story 1 should not be in top 5: {output}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!top_section.contains("• 2 —"),
|
||||||
|
"story 2 should not be in top 5: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cost_command_shows_agent_type_breakdown() {
|
fn cost_command_shows_agent_type_breakdown() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
write_token_records(tmp.path(), &[
|
write_token_records(
|
||||||
make_record("42_story_foo", "coder-1", 2.00, 1),
|
tmp.path(),
|
||||||
make_record("42_story_foo", "qa-1", 1.50, 1),
|
&[
|
||||||
make_record("42_story_foo", "mergemaster", 0.50, 1),
|
make_record("42_story_foo", "coder-1", 2.00, 1),
|
||||||
]);
|
make_record("42_story_foo", "qa-1", 1.50, 1),
|
||||||
|
make_record("42_story_foo", "mergemaster", 0.50, 1),
|
||||||
|
],
|
||||||
|
);
|
||||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||||
assert!(output.contains("By Agent Type"), "should have agent type section: {output}");
|
assert!(
|
||||||
|
output.contains("By Agent Type"),
|
||||||
|
"should have agent type section: {output}"
|
||||||
|
);
|
||||||
assert!(output.contains("coder"), "should show coder type: {output}");
|
assert!(output.contains("coder"), "should show coder type: {output}");
|
||||||
assert!(output.contains("qa"), "should show qa type: {output}");
|
assert!(output.contains("qa"), "should show qa type: {output}");
|
||||||
assert!(output.contains("mergemaster"), "should show mergemaster type: {output}");
|
assert!(
|
||||||
|
output.contains("mergemaster"),
|
||||||
|
"should show mergemaster type: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cost_command_shows_all_time_total() {
|
fn cost_command_shows_all_time_total() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
write_token_records(tmp.path(), &[
|
write_token_records(
|
||||||
make_record("42_story_foo", "coder-1", 1.00, 2),
|
tmp.path(),
|
||||||
make_record("43_story_bar", "coder-1", 9.00, 100),
|
&[
|
||||||
]);
|
make_record("42_story_foo", "coder-1", 1.00, 2),
|
||||||
|
make_record("43_story_bar", "coder-1", 9.00, 100),
|
||||||
|
],
|
||||||
|
);
|
||||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||||
assert!(output.contains("**All-time:** $10.00"), "should show all-time total: {output}");
|
assert!(
|
||||||
|
output.contains("**All-time:** $10.00"),
|
||||||
|
"should show all-time total: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cost_command_case_insensitive() {
|
fn cost_command_case_insensitive() {
|
||||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy COST");
|
let result = super::super::tests::try_cmd_addressed(
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
"@timmy COST",
|
||||||
|
);
|
||||||
assert!(result.is_some(), "COST should match case-insensitively");
|
assert!(result.is_some(), "COST should match case-insensitively");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,12 +59,18 @@ fn read_cached_coverage(project_root: &std::path::Path) -> String {
|
|||||||
fn read_coverage_report(path: &std::path::Path) -> String {
|
fn read_coverage_report(path: &std::path::Path) -> String {
|
||||||
let content = match std::fs::read_to_string(path) {
|
let content = match std::fs::read_to_string(path) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => return format!("**Coverage (cached)**\n\nError reading `.coverage_report.json`: {e}"),
|
Err(e) => {
|
||||||
|
return format!("**Coverage (cached)**\n\nError reading `.coverage_report.json`: {e}");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let report: CoverageReport = match serde_json::from_str(&content) {
|
let report: CoverageReport = match serde_json::from_str(&content) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => return format!("**Coverage (cached)**\n\nFailed to parse `.coverage_report.json`: {e}"),
|
Err(e) => {
|
||||||
|
return format!(
|
||||||
|
"**Coverage (cached)**\n\nFailed to parse `.coverage_report.json`: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
format_coverage_report(&report)
|
format_coverage_report(&report)
|
||||||
@@ -81,13 +87,22 @@ fn format_coverage_report(report: &CoverageReport) -> String {
|
|||||||
// Top 5 lowest-covered files (already sorted ascending in the JSON, but sort
|
// Top 5 lowest-covered files (already sorted ascending in the JSON, but sort
|
||||||
// defensively here so the display is correct even if the file was hand-edited).
|
// defensively here so the display is correct even if the file was hand-edited).
|
||||||
let mut sorted: Vec<&FileCoverage> = report.files.iter().collect();
|
let mut sorted: Vec<&FileCoverage> = report.files.iter().collect();
|
||||||
sorted.sort_by(|a, b| a.coverage.partial_cmp(&b.coverage).unwrap_or(std::cmp::Ordering::Equal));
|
sorted.sort_by(|a, b| {
|
||||||
|
a.coverage
|
||||||
|
.partial_cmp(&b.coverage)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
|
||||||
let targets: Vec<&FileCoverage> = sorted.into_iter().take(5).collect();
|
let targets: Vec<&FileCoverage> = sorted.into_iter().take(5).collect();
|
||||||
if !targets.is_empty() {
|
if !targets.is_empty() {
|
||||||
out.push_str("\n**Top 5 files needing coverage:**\n");
|
out.push_str("\n**Top 5 files needing coverage:**\n");
|
||||||
for (i, file) in targets.iter().enumerate() {
|
for (i, file) in targets.iter().enumerate() {
|
||||||
out.push_str(&format!("{}. {} — {:.1}%\n", i + 1, file.path, file.coverage));
|
out.push_str(&format!(
|
||||||
|
"{}. {} — {:.1}%\n",
|
||||||
|
i + 1,
|
||||||
|
file.path,
|
||||||
|
file.coverage
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,8 +177,13 @@ fn run_coverage(project_root: &std::path::Path) -> String {
|
|||||||
// Replace the "cached" label with "fresh".
|
// Replace the "cached" label with "fresh".
|
||||||
result = result.replacen("Coverage (cached)", "Coverage (fresh)", 1);
|
result = result.replacen("Coverage (cached)", "Coverage (fresh)", 1);
|
||||||
// Replace the cached hint with a pass/fail indicator.
|
// Replace the cached hint with a pass/fail indicator.
|
||||||
let pass_indicator = if out.status.success() { "PASS" } else { "FAIL: coverage below threshold" };
|
let pass_indicator = if out.status.success() {
|
||||||
result = result.replacen("*Run `coverage run` for fresh results.*", pass_indicator, 1);
|
"PASS"
|
||||||
|
} else {
|
||||||
|
"FAIL: coverage below threshold"
|
||||||
|
};
|
||||||
|
result =
|
||||||
|
result.replacen("*Run `coverage run` for fresh results.*", pass_indicator, 1);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,9 +342,18 @@ mod tests {
|
|||||||
let output = handle_coverage(&ctx).unwrap();
|
let output = handle_coverage(&ctx).unwrap();
|
||||||
|
|
||||||
assert!(output.contains("72.5"), "should include overall: {output}");
|
assert!(output.contains("72.5"), "should include overall: {output}");
|
||||||
assert!(output.contains("60.0"), "should include threshold: {output}");
|
assert!(
|
||||||
assert!(output.contains("15.0"), "should include lowest-covered file pct: {output}");
|
output.contains("60.0"),
|
||||||
assert!(output.contains("server/src/low.rs"), "should include lowest-covered file path: {output}");
|
"should include threshold: {output}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains("15.0"),
|
||||||
|
"should include lowest-covered file pct: {output}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains("server/src/low.rs"),
|
||||||
|
"should include lowest-covered file path: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -348,9 +377,18 @@ mod tests {
|
|||||||
let output = handle_coverage(&ctx).unwrap();
|
let output = handle_coverage(&ctx).unwrap();
|
||||||
|
|
||||||
assert!(output.contains("a.rs"), "should show lowest file: {output}");
|
assert!(output.contains("a.rs"), "should show lowest file: {output}");
|
||||||
assert!(output.contains("e.rs"), "should show 5th lowest file: {output}");
|
assert!(
|
||||||
assert!(!output.contains("f.rs"), "should not show 6th file: {output}");
|
output.contains("e.rs"),
|
||||||
assert!(!output.contains("g.rs"), "should not show 7th file: {output}");
|
"should show 5th lowest file: {output}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!output.contains("f.rs"),
|
||||||
|
"should not show 6th file: {output}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!output.contains("g.rs"),
|
||||||
|
"should not show 7th file: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -466,15 +504,24 @@ mod tests {
|
|||||||
overall: 66.25,
|
overall: 66.25,
|
||||||
threshold: 60.0,
|
threshold: 60.0,
|
||||||
files: vec![
|
files: vec![
|
||||||
FileCoverage { path: "a.rs".to_string(), coverage: 10.0 },
|
FileCoverage {
|
||||||
FileCoverage { path: "b.rs".to_string(), coverage: 80.0 },
|
path: "a.rs".to_string(),
|
||||||
|
coverage: 10.0,
|
||||||
|
},
|
||||||
|
FileCoverage {
|
||||||
|
path: "b.rs".to_string(),
|
||||||
|
coverage: 80.0,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
let result = format_coverage_report(&report);
|
let result = format_coverage_report(&report);
|
||||||
assert!(result.contains("66.2"), "should show overall: {result}");
|
assert!(result.contains("66.2"), "should show overall: {result}");
|
||||||
assert!(result.contains("60.0"), "should show threshold: {result}");
|
assert!(result.contains("60.0"), "should show threshold: {result}");
|
||||||
assert!(result.contains("a.rs"), "should show lowest file: {result}");
|
assert!(result.contains("a.rs"), "should show lowest file: {result}");
|
||||||
assert!(result.contains("10.0"), "should show lowest file pct: {result}");
|
assert!(
|
||||||
|
result.contains("10.0"),
|
||||||
|
"should show lowest file pct: {result}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -490,9 +537,18 @@ Frontend line coverage: 70.0%\n\
|
|||||||
PASS: Coverage 66.25% meets threshold 60.00%\n\
|
PASS: Coverage 66.25% meets threshold 60.00%\n\
|
||||||
";
|
";
|
||||||
let result = parse_coverage_output(sample, true);
|
let result = parse_coverage_output(sample, true);
|
||||||
assert!(result.contains("62.5"), "should include Rust coverage: {result}");
|
assert!(
|
||||||
assert!(result.contains("70.0"), "should include Frontend coverage: {result}");
|
result.contains("62.5"),
|
||||||
assert!(result.contains("66.25"), "should include Overall coverage: {result}");
|
"should include Rust coverage: {result}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("70.0"),
|
||||||
|
"should include Frontend coverage: {result}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("66.25"),
|
||||||
|
"should include Overall coverage: {result}"
|
||||||
|
);
|
||||||
assert!(result.contains("PASS"), "should indicate PASS: {result}");
|
assert!(result.contains("PASS"), "should indicate PASS: {result}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,14 +128,20 @@ mod tests {
|
|||||||
"@timmy help",
|
"@timmy help",
|
||||||
);
|
);
|
||||||
let output = result.unwrap();
|
let output = result.unwrap();
|
||||||
assert!(output.contains("depends"), "help should list depends command: {output}");
|
assert!(
|
||||||
|
output.contains("depends"),
|
||||||
|
"help should list depends command: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn depends_no_args_returns_usage() {
|
fn depends_no_args_returns_usage() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = depends_cmd_with_root(tmp.path(), "").unwrap();
|
let output = depends_cmd_with_root(tmp.path(), "").unwrap();
|
||||||
assert!(output.contains("Usage"), "no args should show usage: {output}");
|
assert!(
|
||||||
|
output.contains("Usage"),
|
||||||
|
"no args should show usage: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -188,10 +194,9 @@ mod tests {
|
|||||||
output.contains("477") && output.contains("478"),
|
output.contains("477") && output.contains("478"),
|
||||||
"response should mention dep numbers: {output}"
|
"response should mention dep numbers: {output}"
|
||||||
);
|
);
|
||||||
let contents = std::fs::read_to_string(
|
let contents =
|
||||||
tmp.path().join(".huskies/work/1_backlog/42_story_foo.md"),
|
std::fs::read_to_string(tmp.path().join(".huskies/work/1_backlog/42_story_foo.md"))
|
||||||
)
|
.unwrap();
|
||||||
.unwrap();
|
|
||||||
assert!(
|
assert!(
|
||||||
contents.contains("depends_on: [477, 478]"),
|
contents.contains("depends_on: [477, 478]"),
|
||||||
"file should have depends_on set: {contents}"
|
"file should have depends_on set: {contents}"
|
||||||
@@ -212,10 +217,9 @@ mod tests {
|
|||||||
output.contains("Cleared"),
|
output.contains("Cleared"),
|
||||||
"should confirm clearing deps: {output}"
|
"should confirm clearing deps: {output}"
|
||||||
);
|
);
|
||||||
let contents = std::fs::read_to_string(
|
let contents =
|
||||||
tmp.path().join(".huskies/work/2_current/10_story_bar.md"),
|
std::fs::read_to_string(tmp.path().join(".huskies/work/2_current/10_story_bar.md"))
|
||||||
)
|
.unwrap();
|
||||||
.unwrap();
|
|
||||||
assert!(
|
assert!(
|
||||||
!contents.contains("depends_on"),
|
!contents.contains("depends_on"),
|
||||||
"file should have depends_on cleared: {contents}"
|
"file should have depends_on cleared: {contents}"
|
||||||
|
|||||||
@@ -100,9 +100,16 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn git_command_appears_in_help() {
|
fn git_command_appears_in_help() {
|
||||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
let result = super::super::tests::try_cmd_addressed(
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
"@timmy help",
|
||||||
|
);
|
||||||
let output = result.unwrap();
|
let output = result.unwrap();
|
||||||
assert!(output.contains("git"), "help should list git command: {output}");
|
assert!(
|
||||||
|
output.contains("git"),
|
||||||
|
"help should list git command: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -197,7 +204,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn git_command_case_insensitive() {
|
fn git_command_case_insensitive() {
|
||||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy GIT");
|
let result = super::super::tests::try_cmd_addressed(
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
"@timmy GIT",
|
||||||
|
);
|
||||||
assert!(result.is_some(), "GIT should match case-insensitively");
|
assert!(result.is_some(), "GIT should match case-insensitively");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Handler for the `help` command.
|
//! Handler for the `help` command.
|
||||||
|
|
||||||
use super::{commands, CommandContext};
|
use super::{CommandContext, commands};
|
||||||
|
|
||||||
pub(super) fn handle_help(ctx: &CommandContext) -> Option<String> {
|
pub(super) fn handle_help(ctx: &CommandContext) -> Option<String> {
|
||||||
let mut output = format!("**{} Commands**\n\n", ctx.bot_name);
|
let mut output = format!("**{} Commands**\n\n", ctx.bot_name);
|
||||||
@@ -14,7 +14,7 @@ pub(super) fn handle_help(ctx: &CommandContext) -> Option<String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::super::tests::{try_cmd_addressed, commands};
|
use super::super::tests::{commands, try_cmd_addressed};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_command_matches() {
|
fn help_command_matches() {
|
||||||
@@ -74,7 +74,10 @@ mod tests {
|
|||||||
fn help_output_includes_status() {
|
fn help_output_includes_status() {
|
||||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||||
let output = result.unwrap();
|
let output = result.unwrap();
|
||||||
assert!(output.contains("status"), "help should list status command: {output}");
|
assert!(
|
||||||
|
output.contains("status"),
|
||||||
|
"help should list status command: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -86,7 +89,9 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|c| {
|
.map(|c| {
|
||||||
let marker = format!("**{}**", c.name);
|
let marker = format!("**{}**", c.name);
|
||||||
let pos = output.find(&marker).expect("command must appear in help as **name**");
|
let pos = output
|
||||||
|
.find(&marker)
|
||||||
|
.expect("command must appear in help as **name**");
|
||||||
(pos, c.name)
|
(pos, c.name)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -94,20 +99,29 @@ mod tests {
|
|||||||
let names_in_order: Vec<&str> = positions.iter().map(|(_, n)| *n).collect();
|
let names_in_order: Vec<&str> = positions.iter().map(|(_, n)| *n).collect();
|
||||||
let mut sorted = names_in_order.clone();
|
let mut sorted = names_in_order.clone();
|
||||||
sorted.sort();
|
sorted.sort();
|
||||||
assert_eq!(names_in_order, sorted, "commands must appear in alphabetical order");
|
assert_eq!(
|
||||||
|
names_in_order, sorted,
|
||||||
|
"commands must appear in alphabetical order"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_output_includes_ambient() {
|
fn help_output_includes_ambient() {
|
||||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||||
let output = result.unwrap();
|
let output = result.unwrap();
|
||||||
assert!(output.contains("ambient"), "help should list ambient command: {output}");
|
assert!(
|
||||||
|
output.contains("ambient"),
|
||||||
|
"help should list ambient command: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_output_includes_htop() {
|
fn help_output_includes_htop() {
|
||||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||||
let output = result.unwrap();
|
let output = result.unwrap();
|
||||||
assert!(output.contains("htop"), "help should list htop command: {output}");
|
assert!(
|
||||||
|
output.contains("htop"),
|
||||||
|
"help should list htop command: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,11 +152,53 @@ fn loc_top_n(project_root: &std::path::Path, top_n: usize) -> String {
|
|||||||
fn is_source_extension(ext: &str) -> bool {
|
fn is_source_extension(ext: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
ext,
|
ext,
|
||||||
"rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "java" | "c" | "cpp" | "h"
|
"rs" | "ts"
|
||||||
| "hpp" | "cs" | "rb" | "swift" | "kt" | "scala" | "hs" | "ml" | "ex" | "exs"
|
| "tsx"
|
||||||
| "clj" | "lua" | "sh" | "bash" | "zsh" | "fish" | "ps1" | "toml" | "yaml"
|
| "js"
|
||||||
| "yml" | "json" | "md" | "html" | "css" | "scss" | "less" | "sql" | "graphql"
|
| "jsx"
|
||||||
| "proto" | "tf" | "hcl" | "nix" | "r" | "jl" | "dart" | "vue" | "svelte"
|
| "py"
|
||||||
|
| "go"
|
||||||
|
| "java"
|
||||||
|
| "c"
|
||||||
|
| "cpp"
|
||||||
|
| "h"
|
||||||
|
| "hpp"
|
||||||
|
| "cs"
|
||||||
|
| "rb"
|
||||||
|
| "swift"
|
||||||
|
| "kt"
|
||||||
|
| "scala"
|
||||||
|
| "hs"
|
||||||
|
| "ml"
|
||||||
|
| "ex"
|
||||||
|
| "exs"
|
||||||
|
| "clj"
|
||||||
|
| "lua"
|
||||||
|
| "sh"
|
||||||
|
| "bash"
|
||||||
|
| "zsh"
|
||||||
|
| "fish"
|
||||||
|
| "ps1"
|
||||||
|
| "toml"
|
||||||
|
| "yaml"
|
||||||
|
| "yml"
|
||||||
|
| "json"
|
||||||
|
| "md"
|
||||||
|
| "html"
|
||||||
|
| "css"
|
||||||
|
| "scss"
|
||||||
|
| "less"
|
||||||
|
| "sql"
|
||||||
|
| "graphql"
|
||||||
|
| "proto"
|
||||||
|
| "tf"
|
||||||
|
| "hcl"
|
||||||
|
| "nix"
|
||||||
|
| "r"
|
||||||
|
| "jl"
|
||||||
|
| "dart"
|
||||||
|
| "vue"
|
||||||
|
| "svelte"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +244,10 @@ mod tests {
|
|||||||
"@timmy help",
|
"@timmy help",
|
||||||
);
|
);
|
||||||
let output = result.unwrap();
|
let output = result.unwrap();
|
||||||
assert!(output.contains("loc"), "help should list loc command: {output}");
|
assert!(
|
||||||
|
output.contains("loc"),
|
||||||
|
"help should list loc command: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -220,7 +265,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
// At most 10 entries (numbered lines "1." through "10.")
|
// At most 10 entries (numbered lines "1." through "10.")
|
||||||
let count = output.lines().filter(|l| l.contains(". `")).count();
|
let count = output.lines().filter(|l| l.contains(". `")).count();
|
||||||
assert!(count <= 10, "default should return at most 10 files, got {count}");
|
assert!(
|
||||||
|
count <= 10,
|
||||||
|
"default should return at most 10 files, got {count}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -233,7 +281,10 @@ mod tests {
|
|||||||
let ctx = make_ctx(&agents, &ambient_rooms, repo_root, "5");
|
let ctx = make_ctx(&agents, &ambient_rooms, repo_root, "5");
|
||||||
let output = handle_loc(&ctx).unwrap();
|
let output = handle_loc(&ctx).unwrap();
|
||||||
let count = output.lines().filter(|l| l.contains(". `")).count();
|
let count = output.lines().filter(|l| l.contains(". `")).count();
|
||||||
assert!(count <= 5, "loc 5 should return at most 5 files, got {count}");
|
assert!(
|
||||||
|
count <= 5,
|
||||||
|
"loc 5 should return at most 5 files, got {count}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -246,7 +297,10 @@ mod tests {
|
|||||||
let ctx = make_ctx(&agents, &ambient_rooms, repo_root, "20");
|
let ctx = make_ctx(&agents, &ambient_rooms, repo_root, "20");
|
||||||
let output = handle_loc(&ctx).unwrap();
|
let output = handle_loc(&ctx).unwrap();
|
||||||
let count = output.lines().filter(|l| l.contains(". `")).count();
|
let count = output.lines().filter(|l| l.contains(". `")).count();
|
||||||
assert!(count <= 20, "loc 20 should return at most 20 files, got {count}");
|
assert!(
|
||||||
|
count <= 20,
|
||||||
|
"loc 20 should return at most 20 files, got {count}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -110,7 +110,9 @@ fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
|||||||
// Try content store first.
|
// Try content store first.
|
||||||
for id in crate::db::all_content_ids() {
|
for id in crate::db::all_content_ids() {
|
||||||
let file_num = id.split('_').next().unwrap_or("");
|
let file_num = id.split('_').next().unwrap_or("");
|
||||||
if file_num == num_str && let Some(c) = crate::db::read_content(&id) {
|
if file_num == num_str
|
||||||
|
&& let Some(c) = crate::db::read_content(&id)
|
||||||
|
{
|
||||||
return crate::io::story_metadata::parse_front_matter(&c)
|
return crate::io::story_metadata::parse_front_matter(&c)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|m| m.name);
|
.and_then(|m| m.name);
|
||||||
@@ -119,7 +121,12 @@ fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
|||||||
|
|
||||||
// Fallback: filesystem scan.
|
// Fallback: filesystem scan.
|
||||||
let stages = [
|
let stages = [
|
||||||
"1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived",
|
"1_backlog",
|
||||||
|
"2_current",
|
||||||
|
"3_qa",
|
||||||
|
"4_merge",
|
||||||
|
"5_done",
|
||||||
|
"6_archived",
|
||||||
];
|
];
|
||||||
for stage in &stages {
|
for stage in &stages {
|
||||||
let dir = root.join(".huskies").join("work").join(stage);
|
let dir = root.join(".huskies").join("work").join(stage);
|
||||||
|
|||||||
@@ -86,9 +86,7 @@ pub(super) fn handle_test(ctx: &CommandContext) -> Option<String> {
|
|||||||
let mut result = format!("**Test: {status}**\n\n");
|
let mut result = format!("**Test: {status}**\n\n");
|
||||||
|
|
||||||
if tests_passed > 0 || tests_failed > 0 {
|
if tests_passed > 0 || tests_failed > 0 {
|
||||||
result.push_str(&format!(
|
result.push_str(&format!("{tests_passed} passed, {tests_failed} failed\n\n"));
|
||||||
"{tests_passed} passed, {tests_failed} failed\n\n"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result.push_str(&format!("```\n{truncated}\n```"));
|
result.push_str(&format!("```\n{truncated}\n```"));
|
||||||
@@ -128,7 +126,11 @@ fn parse_test_counts(output: &str) -> (u64, u64) {
|
|||||||
fn extract_count(line: &str, label: &str) -> Option<u64> {
|
fn extract_count(line: &str, label: &str) -> Option<u64> {
|
||||||
let pos = line.find(label)?;
|
let pos = line.find(label)?;
|
||||||
let before = line[..pos].trim_end();
|
let before = line[..pos].trim_end();
|
||||||
let num_str: String = before.chars().rev().take_while(|c| c.is_ascii_digit()).collect();
|
let num_str: String = before
|
||||||
|
.chars()
|
||||||
|
.rev()
|
||||||
|
.take_while(|c| c.is_ascii_digit())
|
||||||
|
.collect();
|
||||||
if num_str.is_empty() {
|
if num_str.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -250,10 +252,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_command_works_via_dispatch() {
|
fn test_command_works_via_dispatch() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
write_script(
|
write_script(dir.path(), "#!/usr/bin/env bash\necho 'ok'\nexit 0\n");
|
||||||
dir.path(),
|
|
||||||
"#!/usr/bin/env bash\necho 'ok'\nexit 0\n",
|
|
||||||
);
|
|
||||||
let agents = test_agents();
|
let agents = test_agents();
|
||||||
let ambient = test_ambient();
|
let ambient = test_ambient();
|
||||||
let room_id = "!test:example.com".to_string();
|
let room_id = "!test:example.com".to_string();
|
||||||
@@ -317,8 +316,14 @@ mod tests {
|
|||||||
let ambient = test_ambient();
|
let ambient = test_ambient();
|
||||||
let ctx = make_ctx(&agents, &ambient, dir.path(), "");
|
let ctx = make_ctx(&agents, &ambient, dir.path(), "");
|
||||||
let output = handle_test(&ctx).unwrap();
|
let output = handle_test(&ctx).unwrap();
|
||||||
assert!(output.contains("PASS"), "no-arg should use project root: {output}");
|
assert!(
|
||||||
assert!(output.contains('7'), "should show count from project root script: {output}");
|
output.contains("PASS"),
|
||||||
|
"no-arg should use project root: {output}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains('7'),
|
||||||
|
"should show count from project root script: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -329,8 +334,14 @@ mod tests {
|
|||||||
let ambient = test_ambient();
|
let ambient = test_ambient();
|
||||||
let ctx = make_ctx(&agents, &ambient, dir.path(), "541");
|
let ctx = make_ctx(&agents, &ambient, dir.path(), "541");
|
||||||
let output = handle_test(&ctx).unwrap();
|
let output = handle_test(&ctx).unwrap();
|
||||||
assert!(output.contains("PASS"), "should run tests in worktree: {output}");
|
assert!(
|
||||||
assert!(output.contains('2'), "should show count from worktree script: {output}");
|
output.contains("PASS"),
|
||||||
|
"should run tests in worktree: {output}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains('2'),
|
||||||
|
"should show count from worktree script: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -382,6 +393,9 @@ mod tests {
|
|||||||
"run_tests with story number must respond via dispatch"
|
"run_tests with story number must respond via dispatch"
|
||||||
);
|
);
|
||||||
let output = result.unwrap();
|
let output = result.unwrap();
|
||||||
assert!(output.contains("PASS"), "should PASS for valid worktree: {output}");
|
assert!(
|
||||||
|
output.contains("PASS"),
|
||||||
|
"should PASS for valid worktree: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use super::CommandContext;
|
|||||||
use crate::http::mcp::wizard_tools::{
|
use crate::http::mcp::wizard_tools::{
|
||||||
generation_hint, is_script_step, step_output_path, write_if_missing,
|
generation_hint, is_script_step, step_output_path, write_if_missing,
|
||||||
};
|
};
|
||||||
use crate::io::wizard::{format_wizard_state, StepStatus, WizardState};
|
use crate::io::wizard::{StepStatus, WizardState, format_wizard_state};
|
||||||
|
|
||||||
pub(super) fn handle_setup(ctx: &CommandContext) -> Option<String> {
|
pub(super) fn handle_setup(ctx: &CommandContext) -> Option<String> {
|
||||||
let sub = ctx.args.trim().to_ascii_lowercase();
|
let sub = ctx.args.trim().to_ascii_lowercase();
|
||||||
@@ -84,17 +84,16 @@ fn wizard_confirm_reply(ctx: &CommandContext) -> String {
|
|||||||
let content = state.steps[idx].content.clone();
|
let content = state.steps[idx].content.clone();
|
||||||
|
|
||||||
// Write content to disk (only if a file path exists and the file is absent).
|
// Write content to disk (only if a file path exists and the file is absent).
|
||||||
let write_msg =
|
let write_msg = if let (Some(c), Some(ref path)) = (&content, step_output_path(root, step)) {
|
||||||
if let (Some(c), Some(ref path)) = (&content, step_output_path(root, step)) {
|
let executable = is_script_step(step);
|
||||||
let executable = is_script_step(step);
|
match write_if_missing(path, c, executable) {
|
||||||
match write_if_missing(path, c, executable) {
|
Ok(true) => format!(" File written: `{}`.", path.display()),
|
||||||
Ok(true) => format!(" File written: `{}`.", path.display()),
|
Ok(false) => format!(" File `{}` already exists — skipped.", path.display()),
|
||||||
Ok(false) => format!(" File `{}` already exists — skipped.", path.display()),
|
Err(e) => return format!("Error: {e}"),
|
||||||
Err(e) => return format!("Error: {e}"),
|
}
|
||||||
}
|
} else {
|
||||||
} else {
|
String::new()
|
||||||
String::new()
|
};
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = state.confirm_step(step) {
|
if let Err(e) = state.confirm_step(step) {
|
||||||
return format!("Cannot confirm step: {e}");
|
return format!("Cannot confirm step: {e}");
|
||||||
@@ -140,10 +139,7 @@ fn wizard_skip_reply(ctx: &CommandContext) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if state.completed {
|
if state.completed {
|
||||||
format!(
|
format!("Step '{}' skipped. Setup wizard complete!", step.label())
|
||||||
"Step '{}' skipped. Setup wizard complete!",
|
|
||||||
step.label()
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
let next = &state.steps[state.current_step_index()];
|
let next = &state.steps[state.current_step_index()];
|
||||||
format!(
|
format!(
|
||||||
|
|||||||
@@ -78,9 +78,16 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn show_command_appears_in_help() {
|
fn show_command_appears_in_help() {
|
||||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
let result = super::super::tests::try_cmd_addressed(
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
"@timmy help",
|
||||||
|
);
|
||||||
let output = result.unwrap();
|
let output = result.unwrap();
|
||||||
assert!(output.contains("show"), "help should list show command: {output}");
|
assert!(
|
||||||
|
output.contains("show"),
|
||||||
|
"help should list show command: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -167,7 +174,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn show_command_case_insensitive() {
|
fn show_command_case_insensitive() {
|
||||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy SHOW 1");
|
let result = super::super::tests::try_cmd_addressed(
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
"@timmy SHOW 1",
|
||||||
|
);
|
||||||
assert!(result.is_some(), "SHOW should match case-insensitively");
|
assert!(result.is_some(), "SHOW should match case-insensitively");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,14 +119,13 @@ fn build_status_from_items(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Read token usage once for all stories to avoid repeated file I/O.
|
// Read token usage once for all stories to avoid repeated file I/O.
|
||||||
let cost_by_story: HashMap<String, f64> =
|
let cost_by_story: HashMap<String, f64> = crate::agents::token_usage::read_all(project_root)
|
||||||
crate::agents::token_usage::read_all(project_root)
|
.unwrap_or_default()
|
||||||
.unwrap_or_default()
|
.into_iter()
|
||||||
.into_iter()
|
.fold(HashMap::new(), |mut map, r| {
|
||||||
.fold(HashMap::new(), |mut map, r| {
|
*map.entry(r.story_id).or_insert(0.0) += r.usage.total_cost_usd;
|
||||||
*map.entry(r.story_id).or_insert(0.0) += r.usage.total_cost_usd;
|
map
|
||||||
map
|
});
|
||||||
});
|
|
||||||
|
|
||||||
let config = ProjectConfig::load(project_root).ok();
|
let config = ProjectConfig::load(project_root).ok();
|
||||||
|
|
||||||
@@ -165,10 +164,8 @@ fn build_status_from_items(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Blocked items: Archived { reason: Blocked } shown with 🔴 indicator.
|
// Blocked items: Archived { reason: Blocked } shown with 🔴 indicator.
|
||||||
let mut blocked_items: Vec<&PipelineItem> = items
|
let mut blocked_items: Vec<&PipelineItem> =
|
||||||
.iter()
|
items.iter().filter(|i| i.stage.is_blocked()).collect();
|
||||||
.filter(|i| i.stage.is_blocked())
|
|
||||||
.collect();
|
|
||||||
blocked_items.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0));
|
blocked_items.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0));
|
||||||
if !blocked_items.is_empty() {
|
if !blocked_items.is_empty() {
|
||||||
out.push_str(&format!("**Blocked** ({})\n", blocked_items.len()));
|
out.push_str(&format!("**Blocked** ({})\n", blocked_items.len()));
|
||||||
@@ -294,13 +291,21 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn status_command_matches() {
|
fn status_command_matches() {
|
||||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status");
|
let result = super::super::tests::try_cmd_addressed(
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
"@timmy status",
|
||||||
|
);
|
||||||
assert!(result.is_some(), "status command should match");
|
assert!(result.is_some(), "status command should match");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn status_command_returns_pipeline_text() {
|
fn status_command_returns_pipeline_text() {
|
||||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status");
|
let result = super::super::tests::try_cmd_addressed(
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
"@timmy status",
|
||||||
|
);
|
||||||
let output = result.unwrap();
|
let output = result.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Pipeline Status"),
|
output.contains("Pipeline Status"),
|
||||||
@@ -310,7 +315,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn status_command_case_insensitive() {
|
fn status_command_case_insensitive() {
|
||||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy STATUS");
|
let result = super::super::tests::try_cmd_addressed(
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
"@timmy STATUS",
|
||||||
|
);
|
||||||
assert!(result.is_some(), "STATUS should match case-insensitively");
|
assert!(result.is_some(), "STATUS should match case-insensitively");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +327,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn short_label_extracts_number_and_name() {
|
fn short_label_extracts_number_and_name() {
|
||||||
let label = story_short_label("293_story_register_all_bot_commands", Some("Register all bot commands"));
|
let label = story_short_label(
|
||||||
|
"293_story_register_all_bot_commands",
|
||||||
|
Some("Register all bot commands"),
|
||||||
|
);
|
||||||
assert_eq!(label, "293 [story] — Register all bot commands");
|
assert_eq!(label, "293 [story] — Register all bot commands");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +348,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn short_label_does_not_include_underscore_slug() {
|
fn short_label_does_not_include_underscore_slug() {
|
||||||
let label = story_short_label("293_story_register_all_bot_commands_in_the_command_registry", Some("Register all bot commands"));
|
let label = story_short_label(
|
||||||
|
"293_story_register_all_bot_commands_in_the_command_registry",
|
||||||
|
Some("Register all bot commands"),
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!label.contains("story_register"),
|
!label.contains("story_register"),
|
||||||
"label should not contain the slug portion: {label}"
|
"label should not contain the slug portion: {label}"
|
||||||
@@ -345,19 +360,28 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn short_label_shows_bug_type() {
|
fn short_label_shows_bug_type() {
|
||||||
let label = story_short_label("375_bug_default_project_toml", Some("Default project.toml issue"));
|
let label = story_short_label(
|
||||||
|
"375_bug_default_project_toml",
|
||||||
|
Some("Default project.toml issue"),
|
||||||
|
);
|
||||||
assert_eq!(label, "375 [bug] — Default project.toml issue");
|
assert_eq!(label, "375 [bug] — Default project.toml issue");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn short_label_shows_spike_type() {
|
fn short_label_shows_spike_type() {
|
||||||
let label = story_short_label("61_spike_filesystem_watcher_architecture", Some("Filesystem watcher architecture"));
|
let label = story_short_label(
|
||||||
|
"61_spike_filesystem_watcher_architecture",
|
||||||
|
Some("Filesystem watcher architecture"),
|
||||||
|
);
|
||||||
assert_eq!(label, "61 [spike] — Filesystem watcher architecture");
|
assert_eq!(label, "61 [spike] — Filesystem watcher architecture");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn short_label_shows_refactor_type() {
|
fn short_label_shows_refactor_type() {
|
||||||
let label = story_short_label("260_refactor_upgrade_libsqlite3_sys", Some("Upgrade libsqlite3-sys"));
|
let label = story_short_label(
|
||||||
|
"260_refactor_upgrade_libsqlite3_sys",
|
||||||
|
Some("Upgrade libsqlite3-sys"),
|
||||||
|
);
|
||||||
assert_eq!(label, "260 [refactor] — Upgrade libsqlite3-sys");
|
assert_eq!(label, "260 [refactor] — Upgrade libsqlite3-sys");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,7 +530,12 @@ mod tests {
|
|||||||
// Story 10 depends on story 999, which is NOT in all_items (treated as met)
|
// Story 10 depends on story 999, which is NOT in all_items (treated as met)
|
||||||
// OR present in backlog (unmet). Let's add dep 999 in Backlog stage (unmet).
|
// OR present in backlog (unmet). Let's add dep 999 in Backlog stage (unmet).
|
||||||
let items = vec![
|
let items = vec![
|
||||||
make_item_with_deps("10_story_waiting", "Waiting Story", Stage::Coding, vec![999]),
|
make_item_with_deps(
|
||||||
|
"10_story_waiting",
|
||||||
|
"Waiting Story",
|
||||||
|
Stage::Coding,
|
||||||
|
vec![999],
|
||||||
|
),
|
||||||
make_item("999_story_dep", "Dep Story", Stage::Backlog),
|
make_item("999_story_dep", "Dep Story", Stage::Backlog),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -526,11 +555,20 @@ mod tests {
|
|||||||
|
|
||||||
// Dep 999 is in Done stage — met.
|
// Dep 999 is in Done stage — met.
|
||||||
let items = vec![
|
let items = vec![
|
||||||
make_item_with_deps("10_story_unblocked", "Unblocked Story", Stage::Coding, vec![999]),
|
make_item_with_deps(
|
||||||
make_item("999_story_dep", "Dep Story", Stage::Done {
|
"10_story_unblocked",
|
||||||
merged_at: Utc::now(),
|
"Unblocked Story",
|
||||||
merge_commit: crate::pipeline_state::GitSha("abc123".to_string()),
|
Stage::Coding,
|
||||||
}),
|
vec![999],
|
||||||
|
),
|
||||||
|
make_item(
|
||||||
|
"999_story_dep",
|
||||||
|
"Dep Story",
|
||||||
|
Stage::Done {
|
||||||
|
merged_at: Utc::now(),
|
||||||
|
merge_commit: crate::pipeline_state::GitSha("abc123".to_string()),
|
||||||
|
},
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let agents = AgentPool::new_test(3000);
|
let agents = AgentPool::new_test(3000);
|
||||||
@@ -678,8 +716,12 @@ mod tests {
|
|||||||
|
|
||||||
// Must appear under Done, not Backlog.
|
// Must appear under Done, not Backlog.
|
||||||
let done_pos = output.find("**Done**").expect("Done section must exist");
|
let done_pos = output.find("**Done**").expect("Done section must exist");
|
||||||
let backlog_pos = output.find("**Backlog**").expect("Backlog section must exist");
|
let backlog_pos = output
|
||||||
let story_pos = output.find("503 [story]").expect("story must appear in output");
|
.find("**Backlog**")
|
||||||
|
.expect("Backlog section must exist");
|
||||||
|
let story_pos = output
|
||||||
|
.find("503 [story]")
|
||||||
|
.expect("story must appear in output");
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
story_pos > done_pos,
|
story_pos > done_pos,
|
||||||
|
|||||||
@@ -33,17 +33,13 @@ pub(super) fn handle_triage(ctx: &CommandContext) -> Option<String> {
|
|||||||
|
|
||||||
match find_story_by_number(num_str) {
|
match find_story_by_number(num_str) {
|
||||||
Some((story_id, item)) => Some(build_triage_dump(ctx, &story_id, &item, num_str)),
|
Some((story_id, item)) => Some(build_triage_dump(ctx, &story_id, &item, num_str)),
|
||||||
None => Some(format!(
|
None => Some(format!("Story **{num_str}** not found in the pipeline.")),
|
||||||
"Story **{num_str}** not found in the pipeline."
|
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find a pipeline item whose numeric prefix matches `num_str` by querying the
|
/// Find a pipeline item whose numeric prefix matches `num_str` by querying the
|
||||||
/// CRDT state. Returns `(story_id, PipelineItem)` for the first match.
|
/// CRDT state. Returns `(story_id, PipelineItem)` for the first match.
|
||||||
fn find_story_by_number(
|
fn find_story_by_number(num_str: &str) -> Option<(String, crate::pipeline_state::PipelineItem)> {
|
||||||
num_str: &str,
|
|
||||||
) -> Option<(String, crate::pipeline_state::PipelineItem)> {
|
|
||||||
let items = crate::pipeline_state::read_all_typed();
|
let items = crate::pipeline_state::read_all_typed();
|
||||||
for item in items {
|
for item in items {
|
||||||
let file_num = item
|
let file_num = item
|
||||||
@@ -74,7 +70,10 @@ fn build_triage_dump(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let meta = crate::io::story_metadata::parse_front_matter(&contents).ok();
|
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 name = meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.name.as_deref())
|
||||||
|
.unwrap_or("(unnamed)");
|
||||||
|
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
|
|
||||||
@@ -147,10 +146,7 @@ fn build_triage_dump(
|
|||||||
out.push_str(&format!("**Branch:** `{branch}`\n\n"));
|
out.push_str(&format!("**Branch:** `{branch}`\n\n"));
|
||||||
|
|
||||||
// ---- git diff --stat ----
|
// ---- git diff --stat ----
|
||||||
let diff_stat = run_git(
|
let diff_stat = run_git(&wt_path, &["diff", "--stat", "master...HEAD"]);
|
||||||
&wt_path,
|
|
||||||
&["diff", "--stat", "master...HEAD"],
|
|
||||||
);
|
|
||||||
if !diff_stat.is_empty() {
|
if !diff_stat.is_empty() {
|
||||||
out.push_str("**Diff stat (vs master):**\n```\n");
|
out.push_str("**Diff stat (vs master):**\n```\n");
|
||||||
out.push_str(&diff_stat);
|
out.push_str(&diff_stat);
|
||||||
@@ -162,12 +158,7 @@ fn build_triage_dump(
|
|||||||
// ---- Last 5 commits on feature branch ----
|
// ---- Last 5 commits on feature branch ----
|
||||||
let log = run_git(
|
let log = run_git(
|
||||||
&wt_path,
|
&wt_path,
|
||||||
&[
|
&["log", "master..HEAD", "--pretty=format:%h %s", "-5"],
|
||||||
"log",
|
|
||||||
"master..HEAD",
|
|
||||||
"--pretty=format:%h %s",
|
|
||||||
"-5",
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
if !log.is_empty() {
|
if !log.is_empty() {
|
||||||
out.push_str("**Recent commits (branch only):**\n```\n");
|
out.push_str("**Recent commits (branch only):**\n```\n");
|
||||||
@@ -192,10 +183,15 @@ fn parse_acceptance_criteria(contents: &str) -> Vec<(bool, String)> {
|
|||||||
.lines()
|
.lines()
|
||||||
.filter_map(|line| {
|
.filter_map(|line| {
|
||||||
let trimmed = line.trim();
|
let trimmed = line.trim();
|
||||||
if let Some(text) = trimmed.strip_prefix("- [x] ").or_else(|| trimmed.strip_prefix("- [X] ")) {
|
if let Some(text) = trimmed
|
||||||
|
.strip_prefix("- [x] ")
|
||||||
|
.or_else(|| trimmed.strip_prefix("- [X] "))
|
||||||
|
{
|
||||||
Some((true, text.to_string()))
|
Some((true, text.to_string()))
|
||||||
} else {
|
} else {
|
||||||
trimmed.strip_prefix("- [ ] ").map(|text| (false, text.to_string()))
|
trimmed
|
||||||
|
.strip_prefix("- [ ] ")
|
||||||
|
.map(|text| (false, text.to_string()))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -248,7 +244,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn whatsup_command_is_not_registered() {
|
fn whatsup_command_is_not_registered() {
|
||||||
let found = super::super::commands().iter().any(|c| c.name == "whatsup");
|
let found = super::super::commands().iter().any(|c| c.name == "whatsup");
|
||||||
assert!(!found, "whatsup command must not be in the registry (renamed to status)");
|
assert!(
|
||||||
|
!found,
|
||||||
|
"whatsup command must not be in the registry (renamed to status)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -340,7 +339,10 @@ mod tests {
|
|||||||
"---\nname: Backlog Item\n---\n",
|
"---\nname: Backlog Item\n---\n",
|
||||||
);
|
);
|
||||||
let output = status_triage_cmd(tmp.path(), "9901").unwrap();
|
let output = status_triage_cmd(tmp.path(), "9901").unwrap();
|
||||||
assert!(output.contains("9901"), "should show story number: {output}");
|
assert!(
|
||||||
|
output.contains("9901"),
|
||||||
|
"should show story number: {output}"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Backlog Item"),
|
output.contains("Backlog Item"),
|
||||||
"should show story name: {output}"
|
"should show story name: {output}"
|
||||||
@@ -361,7 +363,10 @@ mod tests {
|
|||||||
"---\nname: QA Item\n---\n",
|
"---\nname: QA Item\n---\n",
|
||||||
);
|
);
|
||||||
let output = status_triage_cmd(tmp.path(), "9902").unwrap();
|
let output = status_triage_cmd(tmp.path(), "9902").unwrap();
|
||||||
assert!(output.contains("9902"), "should show story number: {output}");
|
assert!(
|
||||||
|
output.contains("9902"),
|
||||||
|
"should show story number: {output}"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("QA Item"),
|
output.contains("QA Item"),
|
||||||
"should show story name: {output}"
|
"should show story name: {output}"
|
||||||
@@ -439,7 +444,10 @@ mod tests {
|
|||||||
output.contains("depends_on") || output.contains("#477"),
|
output.contains("depends_on") || output.contains("#477"),
|
||||||
"should show depends_on field: {output}"
|
"should show depends_on field: {output}"
|
||||||
);
|
);
|
||||||
assert!(output.contains("478"), "should list all dependency numbers: {output}");
|
assert!(
|
||||||
|
output.contains("478"),
|
||||||
|
"should list all dependency numbers: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -459,7 +467,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// -- parse_acceptance_criteria -----------------------------------------
|
// -- parse_acceptance_criteria -----------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -479,5 +486,4 @@ mod tests {
|
|||||||
let result = parse_acceptance_criteria(input);
|
let result = parse_acceptance_criteria(input);
|
||||||
assert!(result.is_empty());
|
assert!(result.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
//! and returns a confirmation.
|
//! and returns a confirmation.
|
||||||
|
|
||||||
use super::CommandContext;
|
use super::CommandContext;
|
||||||
use crate::io::story_metadata::{clear_front_matter_field, clear_front_matter_field_in_content, parse_front_matter, set_front_matter_field};
|
use crate::io::story_metadata::{
|
||||||
|
clear_front_matter_field, clear_front_matter_field_in_content, parse_front_matter,
|
||||||
|
set_front_matter_field,
|
||||||
|
};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Handle the `unblock` command.
|
/// Handle the `unblock` command.
|
||||||
@@ -37,9 +40,7 @@ pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> Stri
|
|||||||
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
||||||
Some(found) => found,
|
Some(found) => found,
|
||||||
None => {
|
None => {
|
||||||
return format!(
|
return format!("No story, bug, or spike with number **{story_number}** found.");
|
||||||
"No story, bug, or spike with number **{story_number}** found."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,9 +72,7 @@ fn unblock_by_story_id(story_id: &str) -> String {
|
|||||||
let has_merge_failure = meta.merge_failure.is_some();
|
let has_merge_failure = meta.merge_failure.is_some();
|
||||||
|
|
||||||
if !has_blocked && !has_merge_failure {
|
if !has_blocked && !has_merge_failure {
|
||||||
return format!(
|
return format!("**{story_name}** ({story_id}) is not blocked. Nothing to unblock.");
|
||||||
"**{story_name}** ({story_id}) is not blocked. Nothing to unblock."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut updated = contents;
|
let mut updated = contents;
|
||||||
@@ -94,9 +93,16 @@ fn unblock_by_story_id(story_id: &str) -> String {
|
|||||||
crate::db::write_item_with_content(story_id, &stage, &updated);
|
crate::db::write_item_with_content(story_id, &stage, &updated);
|
||||||
|
|
||||||
let mut cleared = Vec::new();
|
let mut cleared = Vec::new();
|
||||||
if has_blocked { cleared.push("blocked"); }
|
if has_blocked {
|
||||||
if has_merge_failure { cleared.push("merge_failure"); }
|
cleared.push("blocked");
|
||||||
format!("Unblocked **{story_name}** ({story_id}). Cleared: {}. Retry count reset to 0.", cleared.join(", "))
|
}
|
||||||
|
if has_merge_failure {
|
||||||
|
cleared.push("merge_failure");
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"Unblocked **{story_name}** ({story_id}). Cleared: {}. Retry count reset to 0.",
|
||||||
|
cleared.join(", ")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Core unblock logic: reset blocked state for a known story file path.
|
/// Core unblock logic: reset blocked state for a known story file path.
|
||||||
@@ -121,9 +127,7 @@ pub(crate) fn unblock_by_path(path: &Path, story_id: &str) -> String {
|
|||||||
let has_merge_failure = meta.merge_failure.is_some();
|
let has_merge_failure = meta.merge_failure.is_some();
|
||||||
|
|
||||||
if !has_blocked && !has_merge_failure {
|
if !has_blocked && !has_merge_failure {
|
||||||
return format!(
|
return format!("**{story_name}** ({story_id}) is not blocked. Nothing to unblock.");
|
||||||
"**{story_name}** ({story_id}) is not blocked. Nothing to unblock."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the blocked flag if present.
|
// Clear the blocked flag if present.
|
||||||
@@ -147,9 +151,16 @@ pub(crate) fn unblock_by_path(path: &Path, story_id: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut cleared = Vec::new();
|
let mut cleared = Vec::new();
|
||||||
if has_blocked { cleared.push("blocked"); }
|
if has_blocked {
|
||||||
if has_merge_failure { cleared.push("merge_failure"); }
|
cleared.push("blocked");
|
||||||
format!("Unblocked **{story_name}** ({story_id}). Cleared: {}. Retry count reset to 0.", cleared.join(", "))
|
}
|
||||||
|
if has_merge_failure {
|
||||||
|
cleared.push("merge_failure");
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"Unblocked **{story_name}** ({story_id}). Cleared: {}. Retry count reset to 0.",
|
||||||
|
cleared.join(", ")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -276,7 +287,8 @@ mod tests {
|
|||||||
let contents = crate::db::read_content("9903_story_stuck")
|
let contents = crate::db::read_content("9903_story_stuck")
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
std::fs::read_to_string(
|
std::fs::read_to_string(
|
||||||
tmp.path().join(".huskies/work/2_current/9903_story_stuck.md"),
|
tmp.path()
|
||||||
|
.join(".huskies/work/2_current/9903_story_stuck.md"),
|
||||||
)
|
)
|
||||||
.ok()
|
.ok()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ pub(super) fn handle_unreleased(ctx: &CommandContext) -> Option<String> {
|
|||||||
|
|
||||||
if commits.is_empty() {
|
if commits.is_empty() {
|
||||||
let msg = match &tag {
|
let msg = match &tag {
|
||||||
Some(t) => format!(
|
Some(t) => format!("No unreleased stories since the last release tag **{t}**."),
|
||||||
"No unreleased stories since the last release tag **{t}**."
|
|
||||||
),
|
|
||||||
None => "No release tags found and no story merge commits on master.".to_string(),
|
None => "No release tags found and no story merge commits on master.".to_string(),
|
||||||
};
|
};
|
||||||
return Some(msg);
|
return Some(msg);
|
||||||
@@ -36,9 +34,7 @@ pub(super) fn handle_unreleased(ctx: &CommandContext) -> Option<String> {
|
|||||||
|
|
||||||
if stories.is_empty() {
|
if stories.is_empty() {
|
||||||
let msg = match &tag {
|
let msg = match &tag {
|
||||||
Some(t) => format!(
|
Some(t) => format!("No unreleased stories since the last release tag **{t}**."),
|
||||||
"No unreleased stories since the last release tag **{t}**."
|
|
||||||
),
|
|
||||||
None => "No release tags found and no story merge commits on master.".to_string(),
|
None => "No release tags found and no story merge commits on master.".to_string(),
|
||||||
};
|
};
|
||||||
return Some(msg);
|
return Some(msg);
|
||||||
@@ -50,8 +46,7 @@ pub(super) fn handle_unreleased(ctx: &CommandContext) -> Option<String> {
|
|||||||
None => "**Unreleased stories (no prior release tag):**\n\n".to_string(),
|
None => "**Unreleased stories (no prior release tag):**\n\n".to_string(),
|
||||||
};
|
};
|
||||||
for (num, slug) in &stories {
|
for (num, slug) in &stories {
|
||||||
let name = find_story_name(root, &num.to_string())
|
let name = find_story_name(root, &num.to_string()).unwrap_or_else(|| slug_to_name(slug));
|
||||||
.unwrap_or_else(|| slug_to_name(slug));
|
|
||||||
out.push_str(&format!("- **{num}** — {name}\n"));
|
out.push_str(&format!("- **{num}** — {name}\n"));
|
||||||
}
|
}
|
||||||
Some(out)
|
Some(out)
|
||||||
@@ -79,10 +74,7 @@ fn find_last_release_tag(root: &std::path::Path) -> Option<String> {
|
|||||||
|
|
||||||
/// Return the subjects of all `huskies: merge …` commits reachable from HEAD
|
/// Return the subjects of all `huskies: merge …` commits reachable from HEAD
|
||||||
/// but not from `since_tag` (or all commits when `since_tag` is `None`).
|
/// but not from `since_tag` (or all commits when `since_tag` is `None`).
|
||||||
fn list_merge_commits_since(
|
fn list_merge_commits_since(root: &std::path::Path, since_tag: Option<&str>) -> Vec<String> {
|
||||||
root: &std::path::Path,
|
|
||||||
since_tag: Option<&str>,
|
|
||||||
) -> Vec<String> {
|
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
let range = match since_tag {
|
let range = match since_tag {
|
||||||
@@ -153,7 +145,9 @@ fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
|||||||
// Try content store first.
|
// Try content store first.
|
||||||
for id in crate::db::all_content_ids() {
|
for id in crate::db::all_content_ids() {
|
||||||
let file_num = id.split('_').next().unwrap_or("");
|
let file_num = id.split('_').next().unwrap_or("");
|
||||||
if file_num == num_str && let Some(c) = crate::db::read_content(&id) {
|
if file_num == num_str
|
||||||
|
&& let Some(c) = crate::db::read_content(&id)
|
||||||
|
{
|
||||||
return crate::io::story_metadata::parse_front_matter(&c)
|
return crate::io::story_metadata::parse_front_matter(&c)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|m| m.name);
|
.and_then(|m| m.name);
|
||||||
@@ -162,7 +156,12 @@ fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
|||||||
|
|
||||||
// Fallback: filesystem scan.
|
// Fallback: filesystem scan.
|
||||||
const STAGES: &[&str] = &[
|
const STAGES: &[&str] = &[
|
||||||
"1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived",
|
"1_backlog",
|
||||||
|
"2_current",
|
||||||
|
"3_qa",
|
||||||
|
"4_merge",
|
||||||
|
"5_done",
|
||||||
|
"6_archived",
|
||||||
];
|
];
|
||||||
for stage in STAGES {
|
for stage in STAGES {
|
||||||
let dir = root.join(".huskies").join("work").join(stage);
|
let dir = root.join(".huskies").join("work").join(stage);
|
||||||
@@ -225,7 +224,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unreleased_command_is_registered() {
|
fn unreleased_command_is_registered() {
|
||||||
let found = super::super::commands().iter().any(|c| c.name == "unreleased");
|
let found = super::super::commands()
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.name == "unreleased");
|
||||||
assert!(found, "unreleased command must be in the registry");
|
assert!(found, "unreleased command must be in the registry");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +250,10 @@ mod tests {
|
|||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = unreleased_cmd_with_root(tmp.path()).unwrap();
|
let output = unreleased_cmd_with_root(tmp.path()).unwrap();
|
||||||
// Should return some message (not panic), either about no tags or no commits.
|
// Should return some message (not panic), either about no tags or no commits.
|
||||||
assert!(!output.is_empty(), "should return a non-empty message: {output}");
|
assert!(
|
||||||
|
!output.is_empty(),
|
||||||
|
"should return a non-empty message: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -261,7 +265,10 @@ mod tests {
|
|||||||
let output = unreleased_cmd_with_root(repo_root).unwrap();
|
let output = unreleased_cmd_with_root(repo_root).unwrap();
|
||||||
// The response should mention "unreleased" or "no unreleased" — just make
|
// The response should mention "unreleased" or "no unreleased" — just make
|
||||||
// sure it's non-empty and doesn't panic.
|
// sure it's non-empty and doesn't panic.
|
||||||
assert!(!output.is_empty(), "should return a non-empty message: {output}");
|
assert!(
|
||||||
|
!output.is_empty(),
|
||||||
|
"should return a non-empty message: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -271,7 +278,10 @@ mod tests {
|
|||||||
"@timmy:homeserver.local",
|
"@timmy:homeserver.local",
|
||||||
"@timmy UNRELEASED",
|
"@timmy UNRELEASED",
|
||||||
);
|
);
|
||||||
assert!(result.is_some(), "UNRELEASED should match case-insensitively");
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"UNRELEASED should match case-insensitively"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- parse_story_from_subject ------------------------------------------
|
// -- parse_story_from_subject ------------------------------------------
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ mod tests {
|
|||||||
fn not_found_returns_none() {
|
fn not_found_returns_none() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let result = find_story_by_number(tmp.path(), "999");
|
let result = find_story_by_number(tmp.path(), "999");
|
||||||
assert!(result.is_none(), "should return None when story is not found");
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"should return None when story is not found"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub(crate) mod lookup;
|
pub(crate) mod lookup;
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod test_helpers;
|
||||||
pub mod timer;
|
pub mod timer;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) mod test_helpers;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
@@ -96,8 +96,9 @@ mod tests {
|
|||||||
fn assert_transport<T: ChatTransport>() {}
|
fn assert_transport<T: ChatTransport>() {}
|
||||||
assert_transport::<crate::chat::transport::slack::SlackTransport>();
|
assert_transport::<crate::chat::transport::slack::SlackTransport>();
|
||||||
|
|
||||||
let _: Arc<dyn ChatTransport> =
|
let _: Arc<dyn ChatTransport> = Arc::new(
|
||||||
Arc::new(crate::chat::transport::slack::SlackTransport::new("xoxb-test".to_string()));
|
crate::chat::transport::slack::SlackTransport::new("xoxb-test".to_string()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify that TwilioWhatsAppTransport satisfies the ChatTransport trait
|
/// Verify that TwilioWhatsAppTransport satisfies the ChatTransport trait
|
||||||
@@ -107,11 +108,12 @@ mod tests {
|
|||||||
fn assert_transport<T: ChatTransport>() {}
|
fn assert_transport<T: ChatTransport>() {}
|
||||||
assert_transport::<crate::chat::transport::whatsapp::TwilioWhatsAppTransport>();
|
assert_transport::<crate::chat::transport::whatsapp::TwilioWhatsAppTransport>();
|
||||||
|
|
||||||
let _: Arc<dyn ChatTransport> =
|
let _: Arc<dyn ChatTransport> = Arc::new(
|
||||||
Arc::new(crate::chat::transport::whatsapp::TwilioWhatsAppTransport::new(
|
crate::chat::transport::whatsapp::TwilioWhatsAppTransport::new(
|
||||||
"ACtest".to_string(),
|
"ACtest".to_string(),
|
||||||
"authtoken".to_string(),
|
"authtoken".to_string(),
|
||||||
"+14155551234".to_string(),
|
"+14155551234".to_string(),
|
||||||
));
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-67
@@ -161,10 +161,7 @@ pub(crate) async fn tick_once(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let remaining = store.list().len();
|
let remaining = store.list().len();
|
||||||
crate::slog!(
|
crate::slog!("[timer] Tick: {} due, {remaining} remaining", due.len());
|
||||||
"[timer] Tick: {} due, {remaining} remaining",
|
|
||||||
due.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
for entry in due {
|
for entry in due {
|
||||||
crate::slog!("[timer] Timer fired for story {}", entry.story_id);
|
crate::slog!("[timer] Timer fired for story {}", entry.story_id);
|
||||||
@@ -287,9 +284,7 @@ pub fn spawn_rate_limit_auto_scheduler(
|
|||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||||
crate::slog!(
|
crate::slog!("[timer] Rate-limit auto-scheduler lagged, skipped {n} events");
|
||||||
"[timer] Rate-limit auto-scheduler lagged, skipped {n} events"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||||
crate::slog!(
|
crate::slog!(
|
||||||
@@ -398,44 +393,43 @@ pub async fn handle_timer_command(
|
|||||||
let story_id = match resolve_story_id(&story_number_or_id, project_root) {
|
let story_id = match resolve_story_id(&story_number_or_id, project_root) {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => {
|
None => {
|
||||||
return format!(
|
return format!("No story with number or ID **{story_number_or_id}** found.");
|
||||||
"No story with number or ID **{story_number_or_id}** found."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// The story must be in backlog or current. When the timer fires,
|
// The story must be in backlog or current. When the timer fires,
|
||||||
// backlog stories are moved to current automatically.
|
// backlog stories are moved to current automatically.
|
||||||
// Check CRDT state first, then fall back to filesystem.
|
// Check CRDT state first, then fall back to filesystem.
|
||||||
let in_valid_stage = if let Ok(Some(item)) = crate::pipeline_state::read_typed(&story_id) {
|
let in_valid_stage =
|
||||||
use crate::pipeline_state::Stage;
|
if let Ok(Some(item)) = crate::pipeline_state::read_typed(&story_id) {
|
||||||
matches!(item.stage, Stage::Backlog | Stage::Coding)
|
use crate::pipeline_state::Stage;
|
||||||
} else {
|
matches!(item.stage, Stage::Backlog | Stage::Coding)
|
||||||
let work_dir = project_root.join(".huskies").join("work");
|
} else {
|
||||||
work_dir.join("1_backlog").join(format!("{story_id}.md")).exists()
|
let work_dir = project_root.join(".huskies").join("work");
|
||||||
|| work_dir.join("2_current").join(format!("{story_id}.md")).exists()
|
work_dir
|
||||||
};
|
.join("1_backlog")
|
||||||
|
.join(format!("{story_id}.md"))
|
||||||
|
.exists()
|
||||||
|
|| work_dir
|
||||||
|
.join("2_current")
|
||||||
|
.join(format!("{story_id}.md"))
|
||||||
|
.exists()
|
||||||
|
};
|
||||||
if !in_valid_stage {
|
if !in_valid_stage {
|
||||||
return format!(
|
return format!("Story **{story_id}** is not in backlog or current.");
|
||||||
"Story **{story_id}** is not in backlog or current."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let scheduled_at = match next_occurrence_of_hhmm(&hhmm, tz_str) {
|
let scheduled_at = match next_occurrence_of_hhmm(&hhmm, tz_str) {
|
||||||
Some(t) => t,
|
Some(t) => t,
|
||||||
None => {
|
None => {
|
||||||
return format!(
|
return format!("Invalid time **{hhmm}**. Use `HH:MM` format (e.g. `14:30`).");
|
||||||
"Invalid time **{hhmm}**. Use `HH:MM` format (e.g. `14:30`)."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match store.add(story_id.clone(), scheduled_at) {
|
match store.add(story_id.clone(), scheduled_at) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let (display_time, tz_label) = format_in_timezone(scheduled_at, tz_str);
|
let (display_time, tz_label) = format_in_timezone(scheduled_at, tz_str);
|
||||||
format!(
|
format!("Timer set for **{story_id}** at **{display_time}** ({tz_label}).")
|
||||||
"Timer set for **{story_id}** at **{display_time}** ({tz_label})."
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Err(e) => format!("Failed to save timer: {e}"),
|
Err(e) => format!("Failed to save timer: {e}"),
|
||||||
}
|
}
|
||||||
@@ -448,11 +442,7 @@ pub async fn handle_timer_command(
|
|||||||
let mut lines = vec!["**Pending timers:**".to_string()];
|
let mut lines = vec!["**Pending timers:**".to_string()];
|
||||||
for t in &timers {
|
for t in &timers {
|
||||||
let (display_time, _) = format_in_timezone(t.scheduled_at, tz_str);
|
let (display_time, _) = format_in_timezone(t.scheduled_at, tz_str);
|
||||||
lines.push(format!(
|
lines.push(format!("- **{}** → {}", t.story_id, display_time));
|
||||||
"- **{}** → {}",
|
|
||||||
t.story_id,
|
|
||||||
display_time
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
@@ -465,13 +455,11 @@ pub async fn handle_timer_command(
|
|||||||
format!("No timer found for **{story_id}**.")
|
format!("No timer found for **{story_id}**.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TimerCommand::BadArgs => {
|
TimerCommand::BadArgs => "Usage:\n\
|
||||||
"Usage:\n\
|
|
||||||
- `timer <story_id> <HH:MM>` — schedule deferred start\n\
|
- `timer <story_id> <HH:MM>` — schedule deferred start\n\
|
||||||
- `timer list` — show pending timers\n\
|
- `timer list` — show pending timers\n\
|
||||||
- `timer cancel <story_id>` — remove a timer"
|
- `timer cancel <story_id>` — remove a timer"
|
||||||
.to_string()
|
.to_string(),
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,10 +517,7 @@ fn format_in_timezone(dt: DateTime<Utc>, timezone: Option<&str>) -> (String, Str
|
|||||||
match timezone.and_then(|s| s.parse::<Tz>().ok()) {
|
match timezone.and_then(|s| s.parse::<Tz>().ok()) {
|
||||||
Some(tz) => {
|
Some(tz) => {
|
||||||
let tz_time = dt.with_timezone(&tz);
|
let tz_time = dt.with_timezone(&tz);
|
||||||
(
|
(tz_time.format("%Y-%m-%d %H:%M").to_string(), tz.to_string())
|
||||||
tz_time.format("%Y-%m-%d %H:%M").to_string(),
|
|
||||||
tz.to_string(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let local_time = dt.with_timezone(&Local);
|
let local_time = dt.with_timezone(&Local);
|
||||||
@@ -571,7 +556,12 @@ fn resolve_story_id(number_or_id: &str, project_root: &Path) -> Option<String> {
|
|||||||
// --- DB-first lookup ---
|
// --- DB-first lookup ---
|
||||||
for id in crate::db::all_content_ids() {
|
for id in crate::db::all_content_ids() {
|
||||||
let file_num = id.split('_').next().unwrap_or("");
|
let file_num = id.split('_').next().unwrap_or("");
|
||||||
if file_num == number_or_id && crate::pipeline_state::read_typed(&id).ok().flatten().is_some() {
|
if file_num == number_or_id
|
||||||
|
&& crate::pipeline_state::read_typed(&id)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
return Some(id);
|
return Some(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -643,14 +633,20 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn next_occurrence_with_named_timezone_is_in_the_future() {
|
fn next_occurrence_with_named_timezone_is_in_the_future() {
|
||||||
let result = next_occurrence_of_hhmm("14:30", Some("Europe/London")).unwrap();
|
let result = next_occurrence_of_hhmm("14:30", Some("Europe/London")).unwrap();
|
||||||
assert!(result > Utc::now(), "next occurrence (Europe/London) must be in the future");
|
assert!(
|
||||||
|
result > Utc::now(),
|
||||||
|
"next occurrence (Europe/London) must be in the future"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn next_occurrence_with_invalid_timezone_falls_back_to_local() {
|
fn next_occurrence_with_invalid_timezone_falls_back_to_local() {
|
||||||
// An unrecognised timezone name falls back to chrono::Local (returns Some).
|
// An unrecognised timezone name falls back to chrono::Local (returns Some).
|
||||||
let result = next_occurrence_of_hhmm("14:30", Some("Invalid/Zone"));
|
let result = next_occurrence_of_hhmm("14:30", Some("Invalid/Zone"));
|
||||||
assert!(result.is_some(), "invalid timezone should fall back to local and return Some");
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"invalid timezone should fall back to local and return Some"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── extract_timer_command ───────────────────────────────────────────
|
// ── extract_timer_command ───────────────────────────────────────────
|
||||||
@@ -679,11 +675,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn timer_cancel_story_id() {
|
fn timer_cancel_story_id() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
extract_timer_command(
|
extract_timer_command("Timmy timer cancel 421_story_foo", "Timmy", "@bot:home"),
|
||||||
"Timmy timer cancel 421_story_foo",
|
|
||||||
"Timmy",
|
|
||||||
"@bot:home"
|
|
||||||
),
|
|
||||||
Some(TimerCommand::Cancel {
|
Some(TimerCommand::Cancel {
|
||||||
story_number_or_id: "421_story_foo".to_string()
|
story_number_or_id: "421_story_foo".to_string()
|
||||||
})
|
})
|
||||||
@@ -701,11 +693,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn timer_schedule_with_story_id() {
|
fn timer_schedule_with_story_id() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
extract_timer_command(
|
extract_timer_command("Timmy timer 421_story_foo 14:30", "Timmy", "@bot:home"),
|
||||||
"Timmy timer 421_story_foo 14:30",
|
|
||||||
"Timmy",
|
|
||||||
"@bot:home"
|
|
||||||
),
|
|
||||||
Some(TimerCommand::Schedule {
|
Some(TimerCommand::Schedule {
|
||||||
story_number_or_id: "421_story_foo".to_string(),
|
story_number_or_id: "421_story_foo".to_string(),
|
||||||
hhmm: "14:30".to_string(),
|
hhmm: "14:30".to_string(),
|
||||||
@@ -727,11 +715,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn timer_schedule_missing_time_is_bad_args() {
|
fn timer_schedule_missing_time_is_bad_args() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
extract_timer_command(
|
extract_timer_command("Timmy timer 421_story_foo", "Timmy", "@bot:home"),
|
||||||
"Timmy timer 421_story_foo",
|
|
||||||
"Timmy",
|
|
||||||
"@bot:home"
|
|
||||||
),
|
|
||||||
Some(TimerCommand::BadArgs)
|
Some(TimerCommand::BadArgs)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -944,10 +928,7 @@ mod tests {
|
|||||||
dir.path(),
|
dir.path(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(
|
assert!(result.contains("No timer found"), "unexpected: {result}");
|
||||||
result.contains("No timer found"),
|
|
||||||
"unexpected: {result}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1014,10 +995,7 @@ mod tests {
|
|||||||
dir.path(),
|
dir.path(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(
|
assert!(result.contains("Timer set for"), "unexpected: {result}");
|
||||||
result.contains("Timer set for"),
|
|
||||||
"unexpected: {result}"
|
|
||||||
);
|
|
||||||
assert_eq!(store.list().len(), 1);
|
assert_eq!(store.list().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1111,7 +1089,10 @@ mod tests {
|
|||||||
"story should be in the content store after timer fires"
|
"story should be in the content store after timer fires"
|
||||||
);
|
);
|
||||||
// Timer was consumed.
|
// Timer was consumed.
|
||||||
assert!(store.list().is_empty(), "fired timer should be removed from store");
|
assert!(
|
||||||
|
store.list().is_empty(),
|
||||||
|
"fired timer should be removed from store"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AC4: tick_once integration test ─────────────────────────────────
|
// ── AC4: tick_once integration test ─────────────────────────────────
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ use std::sync::{Arc, Mutex};
|
|||||||
use tokio::sync::{Mutex as TokioMutex, oneshot};
|
use tokio::sync::{Mutex as TokioMutex, oneshot};
|
||||||
|
|
||||||
use crate::agents::AgentPool;
|
use crate::agents::AgentPool;
|
||||||
|
use crate::chat::ChatTransport;
|
||||||
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
||||||
use crate::chat::util::is_permission_approval;
|
use crate::chat::util::is_permission_approval;
|
||||||
use crate::chat::ChatTransport;
|
|
||||||
use crate::http::context::{PermissionDecision, PermissionForward};
|
use crate::http::context::{PermissionDecision, PermissionForward};
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
|
|
||||||
@@ -42,8 +42,7 @@ pub struct DiscordContext {
|
|||||||
/// Permission requests from the MCP `prompt_permission` tool arrive here.
|
/// Permission requests from the MCP `prompt_permission` tool arrive here.
|
||||||
pub perm_rx: Arc<TokioMutex<tokio::sync::mpsc::UnboundedReceiver<PermissionForward>>>,
|
pub perm_rx: Arc<TokioMutex<tokio::sync::mpsc::UnboundedReceiver<PermissionForward>>>,
|
||||||
/// Pending permission replies keyed by channel ID.
|
/// Pending permission replies keyed by channel ID.
|
||||||
pub pending_perm_replies:
|
pub pending_perm_replies: Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionDecision>>>>,
|
||||||
Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionDecision>>>>,
|
|
||||||
/// Seconds before an unanswered permission prompt is auto-denied.
|
/// Seconds before an unanswered permission prompt is auto-denied.
|
||||||
pub permission_timeout_secs: u64,
|
pub permission_timeout_secs: u64,
|
||||||
}
|
}
|
||||||
@@ -135,16 +134,13 @@ pub(super) async fn handle_incoming_message(
|
|||||||
let total_ticks = (duration_secs as usize) / 2;
|
let total_ticks = (duration_secs as usize) / 2;
|
||||||
for tick in 1..=total_ticks {
|
for tick in 1..=total_ticks {
|
||||||
tokio::time::sleep(interval).await;
|
tokio::time::sleep(interval).await;
|
||||||
let updated =
|
let updated = crate::chat::transport::matrix::htop::build_htop_message(
|
||||||
crate::chat::transport::matrix::htop::build_htop_message(
|
&agents,
|
||||||
&agents,
|
(tick * 2) as u32,
|
||||||
(tick * 2) as u32,
|
duration_secs,
|
||||||
duration_secs,
|
);
|
||||||
);
|
|
||||||
let updated = markdown_to_discord(&updated);
|
let updated = markdown_to_discord(&updated);
|
||||||
if let Err(e) =
|
if let Err(e) = transport.edit_message(&ch, &msg_id, &updated, "").await {
|
||||||
transport.edit_message(&ch, &msg_id, &updated, "").await
|
|
||||||
{
|
|
||||||
slog!("[discord] Failed to edit htop message: {e}");
|
slog!("[discord] Failed to edit htop message: {e}");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -320,12 +316,7 @@ pub(super) async fn handle_incoming_message(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Forward a message to Claude Code and send the response back via Discord.
|
/// Forward a message to Claude Code and send the response back via Discord.
|
||||||
async fn handle_llm_message(
|
async fn handle_llm_message(ctx: &DiscordContext, channel: &str, user: &str, user_message: &str) {
|
||||||
ctx: &DiscordContext,
|
|
||||||
channel: &str,
|
|
||||||
user: &str,
|
|
||||||
user_message: &str,
|
|
||||||
) {
|
|
||||||
use crate::chat::util::drain_complete_paragraphs;
|
use crate::chat::util::drain_complete_paragraphs;
|
||||||
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -334,9 +325,7 @@ async fn handle_llm_message(
|
|||||||
// Look up existing session ID for this channel.
|
// Look up existing session ID for this channel.
|
||||||
let resume_session_id: Option<String> = {
|
let resume_session_id: Option<String> = {
|
||||||
let guard = ctx.history.lock().await;
|
let guard = ctx.history.lock().await;
|
||||||
guard
|
guard.get(channel).and_then(|conv| conv.session_id.clone())
|
||||||
.get(channel)
|
|
||||||
.and_then(|conv| conv.session_id.clone())
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let bot_name = &ctx.bot_name;
|
let bot_name = &ctx.bot_name;
|
||||||
@@ -446,9 +435,7 @@ async fn handle_llm_message(
|
|||||||
let last_text = messages
|
let last_text = messages
|
||||||
.iter()
|
.iter()
|
||||||
.rev()
|
.rev()
|
||||||
.find(|m| {
|
.find(|m| m.role == crate::llm::types::Role::Assistant && !m.content.is_empty())
|
||||||
m.role == crate::llm::types::Role::Assistant && !m.content.is_empty()
|
|
||||||
})
|
|
||||||
.map(|m| m.content.clone())
|
.map(|m| m.content.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if !last_text.is_empty() {
|
if !last_text.is_empty() {
|
||||||
|
|||||||
@@ -150,8 +150,7 @@ async fn run_gateway(ctx: Arc<DiscordContext>) -> Result<(), String> {
|
|||||||
.ok_or("Gateway closed before Hello")?
|
.ok_or("Gateway closed before Hello")?
|
||||||
.map_err(|e| format!("Gateway read error: {e}"))?;
|
.map_err(|e| format!("Gateway read error: {e}"))?;
|
||||||
|
|
||||||
let hello_payload: GatewayPayload =
|
let hello_payload: GatewayPayload = parse_ws_message(&hello).ok_or("Failed to parse Hello")?;
|
||||||
parse_ws_message(&hello).ok_or("Failed to parse Hello")?;
|
|
||||||
|
|
||||||
if hello_payload.op != OP_HELLO {
|
if hello_payload.op != OP_HELLO {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -164,8 +163,7 @@ async fn run_gateway(ctx: Arc<DiscordContext>) -> Result<(), String> {
|
|||||||
serde_json::from_value(hello_payload.d.ok_or("Hello missing data")?)
|
serde_json::from_value(hello_payload.d.ok_or("Hello missing data")?)
|
||||||
.map_err(|e| format!("Failed to parse Hello data: {e}"))?;
|
.map_err(|e| format!("Failed to parse Hello data: {e}"))?;
|
||||||
|
|
||||||
let heartbeat_interval =
|
let heartbeat_interval = std::time::Duration::from_millis(hello_data.heartbeat_interval);
|
||||||
std::time::Duration::from_millis(hello_data.heartbeat_interval);
|
|
||||||
slog!(
|
slog!(
|
||||||
"[discord] Heartbeat interval: {}ms",
|
"[discord] Heartbeat interval: {}ms",
|
||||||
hello_data.heartbeat_interval
|
hello_data.heartbeat_interval
|
||||||
@@ -258,19 +256,12 @@ async fn run_gateway(ctx: Arc<DiscordContext>) -> Result<(), String> {
|
|||||||
&& let Ok(ready) = serde_json::from_value::<ReadyData>(d)
|
&& let Ok(ready) = serde_json::from_value::<ReadyData>(d)
|
||||||
{
|
{
|
||||||
bot_user_id = Some(ready.user.id.clone());
|
bot_user_id = Some(ready.user.id.clone());
|
||||||
slog!(
|
slog!("[discord] READY — bot user ID: {}", ready.user.id);
|
||||||
"[discord] READY — bot user ID: {}",
|
|
||||||
ready.user.id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"MESSAGE_CREATE" => {
|
"MESSAGE_CREATE" => {
|
||||||
if let Some(d) = payload.d {
|
if let Some(d) = payload.d {
|
||||||
dispatch_message(
|
dispatch_message(Arc::clone(&ctx), d, bot_user_id.clone());
|
||||||
Arc::clone(&ctx),
|
|
||||||
d,
|
|
||||||
bot_user_id.clone(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -355,15 +346,11 @@ fn dispatch_message(
|
|||||||
|
|
||||||
// Check if the bot was mentioned, or if we respond to all messages in
|
// Check if the bot was mentioned, or if we respond to all messages in
|
||||||
// configured channels (ambient mode).
|
// configured channels (ambient mode).
|
||||||
let bot_mentioned = bot_user_id.as_ref().is_some_and(|bid| {
|
let bot_mentioned = bot_user_id
|
||||||
msg.mentions.iter().any(|m| m.id == *bid)
|
.as_ref()
|
||||||
});
|
.is_some_and(|bid| msg.mentions.iter().any(|m| m.id == *bid));
|
||||||
|
|
||||||
let in_ambient = ctx
|
let in_ambient = ctx.ambient_rooms.lock().unwrap().contains(&msg.channel_id);
|
||||||
.ambient_rooms
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.contains(&msg.channel_id);
|
|
||||||
|
|
||||||
if !bot_mentioned && !in_ambient {
|
if !bot_mentioned && !in_ambient {
|
||||||
return;
|
return;
|
||||||
@@ -392,8 +379,7 @@ fn dispatch_message(
|
|||||||
msg.channel_id
|
msg.channel_id
|
||||||
);
|
);
|
||||||
|
|
||||||
commands::handle_incoming_message(&ctx, &msg.channel_id, &author.id, &content)
|
commands::handle_incoming_message(&ctx, &msg.channel_id, &author.id, &content).await;
|
||||||
.await;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,8 +403,7 @@ mod tests {
|
|||||||
let json = r#"{"op": 10, "d": {"heartbeat_interval": 41250}}"#;
|
let json = r#"{"op": 10, "d": {"heartbeat_interval": 41250}}"#;
|
||||||
let payload: GatewayPayload = serde_json::from_str(json).unwrap();
|
let payload: GatewayPayload = serde_json::from_str(json).unwrap();
|
||||||
assert_eq!(payload.op, OP_HELLO);
|
assert_eq!(payload.op, OP_HELLO);
|
||||||
let hello: HelloData =
|
let hello: HelloData = serde_json::from_value(payload.d.unwrap()).unwrap();
|
||||||
serde_json::from_value(payload.d.unwrap()).unwrap();
|
|
||||||
assert_eq!(hello.heartbeat_interval, 41250);
|
assert_eq!(hello.heartbeat_interval, 41250);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -181,8 +181,7 @@ mod tests {
|
|||||||
.create_async()
|
.create_async()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let transport =
|
let transport = DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
||||||
DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
|
||||||
|
|
||||||
let result = transport
|
let result = transport
|
||||||
.send_message("123456", "hello", "<p>hello</p>")
|
.send_message("123456", "hello", "<p>hello</p>")
|
||||||
@@ -202,8 +201,7 @@ mod tests {
|
|||||||
.create_async()
|
.create_async()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let transport =
|
let transport = DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
||||||
DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
|
||||||
|
|
||||||
let result = transport.send_message("bad", "hello", "").await;
|
let result = transport.send_message("bad", "hello", "").await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
@@ -220,8 +218,7 @@ mod tests {
|
|||||||
.create_async()
|
.create_async()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let transport =
|
let transport = DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
||||||
DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
|
||||||
|
|
||||||
let result = transport
|
let result = transport
|
||||||
.edit_message("123456", "999888777", "updated", "")
|
.edit_message("123456", "999888777", "updated", "")
|
||||||
@@ -240,12 +237,9 @@ mod tests {
|
|||||||
.create_async()
|
.create_async()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let transport =
|
let transport = DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
||||||
DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
|
||||||
|
|
||||||
let result = transport
|
let result = transport.edit_message("123456", "bad", "updated", "").await;
|
||||||
.edit_message("123456", "bad", "updated", "")
|
|
||||||
.await;
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert!(result.unwrap_err().contains("404"));
|
assert!(result.unwrap_err().contains("404"));
|
||||||
}
|
}
|
||||||
@@ -259,8 +253,7 @@ mod tests {
|
|||||||
.create_async()
|
.create_async()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let transport =
|
let transport = DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
||||||
DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
|
||||||
|
|
||||||
assert!(transport.send_typing("123456", true).await.is_ok());
|
assert!(transport.send_typing("123456", true).await.is_ok());
|
||||||
}
|
}
|
||||||
@@ -281,8 +274,7 @@ mod tests {
|
|||||||
.create_async()
|
.create_async()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let transport =
|
let transport = DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
||||||
DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
|
||||||
|
|
||||||
let result = transport.send_message("123456", "hello", "").await;
|
let result = transport.send_message("123456", "hello", "").await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
@@ -296,7 +288,6 @@ mod tests {
|
|||||||
fn assert_transport<T: ChatTransport>() {}
|
fn assert_transport<T: ChatTransport>() {}
|
||||||
assert_transport::<DiscordTransport>();
|
assert_transport::<DiscordTransport>();
|
||||||
|
|
||||||
let _: Arc<dyn ChatTransport> =
|
let _: Arc<dyn ChatTransport> = Arc::new(DiscordTransport::new("test-token".to_string()));
|
||||||
Arc::new(DiscordTransport::new("test-token".to_string()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,7 @@ use std::path::Path;
|
|||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
pub enum AssignCommand {
|
pub enum AssignCommand {
|
||||||
/// Assign the story with this number to the given model.
|
/// Assign the story with this number to the given model.
|
||||||
Assign {
|
Assign { story_number: String, model: String },
|
||||||
story_number: String,
|
|
||||||
model: String,
|
|
||||||
},
|
|
||||||
/// The user typed `assign` but without valid arguments.
|
/// The user typed `assign` but without valid arguments.
|
||||||
BadArgs,
|
BadArgs,
|
||||||
}
|
}
|
||||||
@@ -96,9 +93,7 @@ pub async fn handle_assign(
|
|||||||
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
||||||
Some(found) => found,
|
Some(found) => found,
|
||||||
None => {
|
None => {
|
||||||
return format!(
|
return format!("No story, bug, or spike with number **{story_number}** found.");
|
||||||
"No story, bug, or spike with number **{story_number}** found."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -282,11 +277,8 @@ mod tests {
|
|||||||
fn extract_assign_command_multibyte_prefix_no_panic() {
|
fn extract_assign_command_multibyte_prefix_no_panic() {
|
||||||
// "xxxx⏺ assign 42 opus" — ⏺ (U+23FA) is 3 bytes, starting at byte 4.
|
// "xxxx⏺ assign 42 opus" — ⏺ (U+23FA) is 3 bytes, starting at byte 4.
|
||||||
// "@timmy" has len 6 so text[..6] lands inside ⏺ — panics without the fix.
|
// "@timmy" has len 6 so text[..6] lands inside ⏺ — panics without the fix.
|
||||||
let cmd = extract_assign_command(
|
let cmd =
|
||||||
"xxxx\u{23FA} assign 42 opus",
|
extract_assign_command("xxxx\u{23FA} assign 42 opus", "Timmy", "@timmy:home.local");
|
||||||
"Timmy",
|
|
||||||
"@timmy:home.local",
|
|
||||||
);
|
|
||||||
assert_eq!(cmd, None);
|
assert_eq!(cmd, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,7 +445,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
// Should indicate a restart occurred (not just "will be used when starts")
|
// Should indicate a restart occurred (not just "will be used when starts")
|
||||||
assert!(
|
assert!(
|
||||||
response.to_lowercase().contains("stop") || response.to_lowercase().contains("reassign"),
|
response.to_lowercase().contains("stop")
|
||||||
|
|| response.to_lowercase().contains("reassign"),
|
||||||
"response should indicate stop/reassign: {response}"
|
"response should indicate stop/reassign: {response}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
//! Matrix bot context — shared state for the Matrix bot (rooms, history, permissions).
|
//! Matrix bot context — shared state for the Matrix bot (rooms, history, permissions).
|
||||||
use crate::agents::AgentPool;
|
use crate::agents::AgentPool;
|
||||||
use crate::chat::timer::TimerStore;
|
|
||||||
use crate::chat::ChatTransport;
|
use crate::chat::ChatTransport;
|
||||||
|
use crate::chat::timer::TimerStore;
|
||||||
use crate::http::context::{PermissionDecision, PermissionForward};
|
use crate::http::context::{PermissionDecision, PermissionForward};
|
||||||
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId, OwnedUserId};
|
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId, OwnedUserId};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{RwLock, mpsc, oneshot};
|
||||||
|
|
||||||
use super::history::ConversationHistory;
|
use super::history::ConversationHistory;
|
||||||
|
|
||||||
@@ -59,6 +59,12 @@ pub struct BotContext {
|
|||||||
pub transport: Arc<dyn ChatTransport>,
|
pub transport: Arc<dyn ChatTransport>,
|
||||||
/// Persistent store for pending deferred-start timers.
|
/// Persistent store for pending deferred-start timers.
|
||||||
pub timer_store: Arc<TimerStore>,
|
pub timer_store: Arc<TimerStore>,
|
||||||
|
/// In gateway mode: the currently active project (shared with the gateway HTTP handler).
|
||||||
|
/// `None` in standalone single-project mode.
|
||||||
|
pub gateway_active_project: Option<Arc<RwLock<String>>>,
|
||||||
|
/// In gateway mode: valid project names accepted by the `switch` command.
|
||||||
|
/// Empty in standalone mode.
|
||||||
|
pub gateway_projects: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -110,6 +116,8 @@ mod tests {
|
|||||||
timer_store: Arc::new(crate::chat::timer::TimerStore::load(
|
timer_store: Arc::new(crate::chat::timer::TimerStore::load(
|
||||||
std::path::PathBuf::from("/tmp/timers.json"),
|
std::path::PathBuf::from("/tmp/timers.json"),
|
||||||
)),
|
)),
|
||||||
|
gateway_active_project: None,
|
||||||
|
gateway_projects: vec![],
|
||||||
};
|
};
|
||||||
// Clone must work (required by Matrix SDK event handler injection).
|
// Clone must work (required by Matrix SDK event handler injection).
|
||||||
let _cloned = ctx.clone();
|
let _cloned = ctx.clone();
|
||||||
|
|||||||
@@ -104,7 +104,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn startup_announcement_uses_configured_display_name_not_hardcoded() {
|
fn startup_announcement_uses_configured_display_name_not_hardcoded() {
|
||||||
assert_eq!(format_startup_announcement("HAL"), "HAL is online.");
|
assert_eq!(format_startup_announcement("HAL"), "HAL is online.");
|
||||||
assert_eq!(format_startup_announcement("Assistant"), "Assistant is online.");
|
assert_eq!(
|
||||||
|
format_startup_announcement("Assistant"),
|
||||||
|
"Assistant is online."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -71,11 +71,7 @@ pub fn load_history(project_root: &std::path::Path) -> HashMap<OwnedRoomId, Room
|
|||||||
persisted
|
persisted
|
||||||
.rooms
|
.rooms
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|(k, v)| {
|
.filter_map(|(k, v)| k.parse::<OwnedRoomId>().ok().map(|room_id| (room_id, v)))
|
||||||
k.parse::<OwnedRoomId>()
|
|
||||||
.ok()
|
|
||||||
.map(|room_id| (room_id, v))
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,9 +97,7 @@ pub fn is_addressed_to_other(body: &str, bot_user_id: &OwnedUserId, bot_name: &s
|
|||||||
// Handles both "@localpart" and "@localpart:homeserver" forms.
|
// Handles both "@localpart" and "@localpart:homeserver" forms.
|
||||||
if let Some(rest) = lower.strip_prefix('@') {
|
if let Some(rest) = lower.strip_prefix('@') {
|
||||||
// Extract everything up to the first whitespace character.
|
// Extract everything up to the first whitespace character.
|
||||||
let word_end = rest
|
let word_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
|
||||||
.find(|c: char| c.is_whitespace())
|
|
||||||
.unwrap_or(rest.len());
|
|
||||||
let mention = &rest[..word_end]; // e.g. "sally" or "sally:example.com"
|
let mention = &rest[..word_end]; // e.g. "sally" or "sally:example.com"
|
||||||
|
|
||||||
// Strip the homeserver part to get just the localpart.
|
// Strip the homeserver part to get just the localpart.
|
||||||
|
|||||||
@@ -82,9 +82,7 @@ pub(super) async fn on_room_message(
|
|||||||
// Always let "ambient on" through — it is the one command that must work
|
// Always let "ambient on" through — it is the one command that must work
|
||||||
// even when the bot is not mentioned and ambient mode is off, otherwise
|
// even when the bot is not mentioned and ambient mode is off, otherwise
|
||||||
// there is no way to re-enable ambient mode without an @-mention.
|
// there is no way to re-enable ambient mode without an @-mention.
|
||||||
let is_ambient_on = body
|
let is_ambient_on = body.to_ascii_lowercase().contains("ambient on");
|
||||||
.to_ascii_lowercase()
|
|
||||||
.contains("ambient on");
|
|
||||||
|
|
||||||
if !is_addressed && !is_ambient && !is_ambient_on {
|
if !is_addressed && !is_ambient && !is_ambient_on {
|
||||||
slog!(
|
slog!(
|
||||||
@@ -97,7 +95,9 @@ pub(super) async fn on_room_message(
|
|||||||
// In ambient mode, ignore messages that are explicitly addressed to a
|
// In ambient mode, ignore messages that are explicitly addressed to a
|
||||||
// different entity (e.g. "sally: do X" or "@sally do X" when we are stu).
|
// different entity (e.g. "sally: do X" or "@sally do X" when we are stu).
|
||||||
// We still let through messages addressed to us and the "ambient on" command.
|
// We still let through messages addressed to us and the "ambient on" command.
|
||||||
if is_ambient && !is_addressed && !is_ambient_on
|
if is_ambient
|
||||||
|
&& !is_addressed
|
||||||
|
&& !is_ambient_on
|
||||||
&& is_addressed_to_other(&body, &ctx.bot_user_id, &ctx.bot_name)
|
&& is_addressed_to_other(&body, &ctx.bot_user_id, &ctx.bot_name)
|
||||||
{
|
{
|
||||||
slog!(
|
slog!(
|
||||||
@@ -158,7 +158,10 @@ pub(super) async fn on_room_message(
|
|||||||
"Permission denied."
|
"Permission denied."
|
||||||
};
|
};
|
||||||
let html = markdown_to_html(confirmation);
|
let html = markdown_to_html(confirmation);
|
||||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, confirmation, &html).await
|
if let Ok(msg_id) = ctx
|
||||||
|
.transport
|
||||||
|
.send_message(&room_id_str, confirmation, &html)
|
||||||
|
.await
|
||||||
&& let Ok(event_id) = msg_id.parse()
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
{
|
{
|
||||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
@@ -182,9 +185,14 @@ pub(super) async fn on_room_message(
|
|||||||
ambient_rooms: &ctx.ambient_rooms,
|
ambient_rooms: &ctx.ambient_rooms,
|
||||||
room_id: &room_id_str,
|
room_id: &room_id_str,
|
||||||
};
|
};
|
||||||
if let Some((response, response_html)) = super::super::commands::try_handle_command_with_html(&dispatch, &user_message) {
|
if let Some((response, response_html)) =
|
||||||
|
super::super::commands::try_handle_command_with_html(&dispatch, &user_message)
|
||||||
|
{
|
||||||
slog!("[matrix-bot] Handled bot command from {sender}");
|
slog!("[matrix-bot] Handled bot command from {sender}");
|
||||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &response_html).await
|
if let Ok(msg_id) = ctx
|
||||||
|
.transport
|
||||||
|
.send_message(&room_id_str, &response, &response_html)
|
||||||
|
.await
|
||||||
&& let Ok(event_id) = msg_id.parse()
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
{
|
{
|
||||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
@@ -224,7 +232,10 @@ pub(super) async fn on_room_message(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let html = markdown_to_html(&response);
|
let html = markdown_to_html(&response);
|
||||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
|
if let Ok(msg_id) = ctx
|
||||||
|
.transport
|
||||||
|
.send_message(&room_id_str, &response, &html)
|
||||||
|
.await
|
||||||
&& let Ok(event_id) = msg_id.parse()
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
{
|
{
|
||||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
@@ -272,9 +283,7 @@ pub(super) async fn on_room_message(
|
|||||||
) {
|
) {
|
||||||
let response = match del_cmd {
|
let response = match del_cmd {
|
||||||
super::super::delete::DeleteCommand::Delete { story_number } => {
|
super::super::delete::DeleteCommand::Delete { story_number } => {
|
||||||
slog!(
|
slog!("[matrix-bot] Handling delete command from {sender}: story {story_number}");
|
||||||
"[matrix-bot] Handling delete command from {sender}: story {story_number}"
|
|
||||||
);
|
|
||||||
super::super::delete::handle_delete(
|
super::super::delete::handle_delete(
|
||||||
&ctx.bot_name,
|
&ctx.bot_name,
|
||||||
&story_number,
|
&story_number,
|
||||||
@@ -288,7 +297,10 @@ pub(super) async fn on_room_message(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let html = markdown_to_html(&response);
|
let html = markdown_to_html(&response);
|
||||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
|
if let Ok(msg_id) = ctx
|
||||||
|
.transport
|
||||||
|
.send_message(&room_id_str, &response, &html)
|
||||||
|
.await
|
||||||
&& let Ok(event_id) = msg_id.parse()
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
{
|
{
|
||||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
@@ -305,9 +317,7 @@ pub(super) async fn on_room_message(
|
|||||||
) {
|
) {
|
||||||
let response = match rmtree_cmd {
|
let response = match rmtree_cmd {
|
||||||
super::super::rmtree::RmtreeCommand::Rmtree { story_number } => {
|
super::super::rmtree::RmtreeCommand::Rmtree { story_number } => {
|
||||||
slog!(
|
slog!("[matrix-bot] Handling rmtree command from {sender}: story {story_number}");
|
||||||
"[matrix-bot] Handling rmtree command from {sender}: story {story_number}"
|
|
||||||
);
|
|
||||||
super::super::rmtree::handle_rmtree(
|
super::super::rmtree::handle_rmtree(
|
||||||
&ctx.bot_name,
|
&ctx.bot_name,
|
||||||
&story_number,
|
&story_number,
|
||||||
@@ -321,7 +331,10 @@ pub(super) async fn on_room_message(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let html = markdown_to_html(&response);
|
let html = markdown_to_html(&response);
|
||||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
|
if let Ok(msg_id) = ctx
|
||||||
|
.transport
|
||||||
|
.send_message(&room_id_str, &response, &html)
|
||||||
|
.await
|
||||||
&& let Ok(event_id) = msg_id.parse()
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
{
|
{
|
||||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
@@ -361,7 +374,10 @@ pub(super) async fn on_room_message(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let html = markdown_to_html(&response);
|
let html = markdown_to_html(&response);
|
||||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
|
if let Ok(msg_id) = ctx
|
||||||
|
.transport
|
||||||
|
.send_message(&room_id_str, &response, &html)
|
||||||
|
.await
|
||||||
&& let Ok(event_id) = msg_id.parse()
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
{
|
{
|
||||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
@@ -387,7 +403,10 @@ pub(super) async fn on_room_message(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let html = markdown_to_html(&response);
|
let html = markdown_to_html(&response);
|
||||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
|
if let Ok(msg_id) = ctx
|
||||||
|
.transport
|
||||||
|
.send_message(&room_id_str, &response, &html)
|
||||||
|
.await
|
||||||
&& let Ok(event_id) = msg_id.parse()
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
{
|
{
|
||||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
@@ -408,19 +427,22 @@ pub(super) async fn on_room_message(
|
|||||||
// Acknowledge immediately — the rebuild may take a while or re-exec.
|
// Acknowledge immediately — the rebuild may take a while or re-exec.
|
||||||
let ack = "Rebuilding server… this may take a moment.";
|
let ack = "Rebuilding server… this may take a moment.";
|
||||||
let ack_html = markdown_to_html(ack);
|
let ack_html = markdown_to_html(ack);
|
||||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, ack, &ack_html).await
|
if let Ok(msg_id) = ctx
|
||||||
|
.transport
|
||||||
|
.send_message(&room_id_str, ack, &ack_html)
|
||||||
|
.await
|
||||||
&& let Ok(event_id) = msg_id.parse()
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
{
|
{
|
||||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
}
|
}
|
||||||
let response = super::super::rebuild::handle_rebuild(
|
let response =
|
||||||
&ctx.bot_name,
|
super::super::rebuild::handle_rebuild(&ctx.bot_name, &ctx.project_root, &ctx.agents)
|
||||||
&ctx.project_root,
|
.await;
|
||||||
&ctx.agents,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let html = markdown_to_html(&response);
|
let html = markdown_to_html(&response);
|
||||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
|
if let Ok(msg_id) = ctx
|
||||||
|
.transport
|
||||||
|
.send_message(&room_id_str, &response, &html)
|
||||||
|
.await
|
||||||
&& let Ok(event_id) = msg_id.parse()
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
{
|
{
|
||||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
@@ -428,6 +450,47 @@ pub(super) async fn on_room_message(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In gateway mode, handle the "switch <project>" command to change the
|
||||||
|
// active project without invoking the LLM.
|
||||||
|
if let Some(ref active_project) = ctx.gateway_active_project {
|
||||||
|
let stripped = crate::chat::util::strip_bot_mention(
|
||||||
|
&user_message,
|
||||||
|
&ctx.bot_name,
|
||||||
|
ctx.bot_user_id.as_str(),
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches(|c: char| !c.is_alphanumeric())
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let (cmd, arg) = match stripped.split_once(char::is_whitespace) {
|
||||||
|
Some((c, a)) => (c.to_string(), a.trim().to_string()),
|
||||||
|
None => (stripped.clone(), String::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if cmd.eq_ignore_ascii_case("switch") {
|
||||||
|
let response = if arg.is_empty() {
|
||||||
|
let available = ctx.gateway_projects.join(", ");
|
||||||
|
format!("Usage: `switch <project>`. Available projects: {available}")
|
||||||
|
} else if ctx.gateway_projects.iter().any(|p| p == &arg) {
|
||||||
|
*active_project.write().await = arg.clone();
|
||||||
|
format!("Switched to project **{arg}**.")
|
||||||
|
} else {
|
||||||
|
let available = ctx.gateway_projects.join(", ");
|
||||||
|
format!("Unknown project `{arg}`. Available: {available}")
|
||||||
|
};
|
||||||
|
let html = markdown_to_html(&response);
|
||||||
|
if let Ok(msg_id) = ctx
|
||||||
|
.transport
|
||||||
|
.send_message(&room_id_str, &response, &html)
|
||||||
|
.await
|
||||||
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
|
{
|
||||||
|
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for the timer command, which requires async file I/O and cannot
|
// Check for the timer command, which requires async file I/O and cannot
|
||||||
// be handled by the sync command registry.
|
// be handled by the sync command registry.
|
||||||
if let Some(timer_cmd) = crate::chat::timer::extract_timer_command(
|
if let Some(timer_cmd) = crate::chat::timer::extract_timer_command(
|
||||||
@@ -443,7 +506,10 @@ pub(super) async fn on_room_message(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let html = markdown_to_html(&response);
|
let html = markdown_to_html(&response);
|
||||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
|
if let Ok(msg_id) = ctx
|
||||||
|
.transport
|
||||||
|
.send_message(&room_id_str, &response, &html)
|
||||||
|
.await
|
||||||
&& let Ok(event_id) = msg_id.parse()
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
{
|
{
|
||||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
@@ -470,16 +536,20 @@ pub(super) async fn handle_message(
|
|||||||
// flattening history into a text prefix.
|
// flattening history into a text prefix.
|
||||||
let resume_session_id: Option<String> = {
|
let resume_session_id: Option<String> = {
|
||||||
let guard = ctx.history.lock().await;
|
let guard = ctx.history.lock().await;
|
||||||
guard
|
guard.get(&room_id).and_then(|conv| conv.session_id.clone())
|
||||||
.get(&room_id)
|
|
||||||
.and_then(|conv| conv.session_id.clone())
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The prompt is just the current message with sender attribution.
|
// The prompt is just the current message with sender attribution.
|
||||||
// Prior conversation context is carried by the Claude Code session.
|
// Prior conversation context is carried by the Claude Code session.
|
||||||
let bot_name = &ctx.bot_name;
|
let bot_name = &ctx.bot_name;
|
||||||
|
let active_project_ctx = if let Some(ref ap) = ctx.gateway_active_project {
|
||||||
|
let name = ap.read().await.clone();
|
||||||
|
format!("[Active project: {name}]\n")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
let prompt = format!(
|
let prompt = format!(
|
||||||
"[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n\n{}",
|
"[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n{active_project_ctx}\n{}",
|
||||||
format_user_prompt(&sender, &user_message)
|
format_user_prompt(&sender, &user_message)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -501,7 +571,9 @@ pub(super) async fn handle_message(
|
|||||||
let post_task = tokio::spawn(async move {
|
let post_task = tokio::spawn(async move {
|
||||||
while let Some(chunk) = msg_rx.recv().await {
|
while let Some(chunk) = msg_rx.recv().await {
|
||||||
let html = markdown_to_html(&chunk);
|
let html = markdown_to_html(&chunk);
|
||||||
if let Ok(msg_id) = post_transport.send_message(&post_room_id, &chunk, &html).await
|
if let Ok(msg_id) = post_transport
|
||||||
|
.send_message(&post_room_id, &chunk, &html)
|
||||||
|
.await
|
||||||
&& let Ok(event_id) = msg_id.parse()
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
{
|
{
|
||||||
sent_ids_for_post.lock().await.insert(event_id);
|
sent_ids_for_post.lock().await.insert(event_id);
|
||||||
@@ -631,9 +703,7 @@ pub(super) async fn handle_message(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
slog!("[matrix-bot] LLM error: {e}");
|
slog!("[matrix-bot] LLM error: {e}");
|
||||||
let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) {
|
let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) {
|
||||||
format!(
|
format!("Authentication required. [Click here to log in to Claude]({url})")
|
||||||
"Authentication required. [Click here to log in to Claude]({url})"
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
format!("Error processing your request: {e}")
|
format!("Error processing your request: {e}")
|
||||||
};
|
};
|
||||||
@@ -654,7 +724,11 @@ pub(super) async fn handle_message(
|
|||||||
let conv = guard.entry(room_id).or_default();
|
let conv = guard.entry(room_id).or_default();
|
||||||
|
|
||||||
// Store the session ID so the next turn uses --resume.
|
// Store the session ID so the next turn uses --resume.
|
||||||
slog!("[matrix-bot] storing session_id: {:?} (was: {:?})", new_session_id, conv.session_id);
|
slog!(
|
||||||
|
"[matrix-bot] storing session_id: {:?} (was: {:?})",
|
||||||
|
new_session_id,
|
||||||
|
conv.session_id
|
||||||
|
);
|
||||||
if new_session_id.is_some() {
|
if new_session_id.is_some() {
|
||||||
conv.session_id = new_session_id;
|
conv.session_id = new_session_id;
|
||||||
}
|
}
|
||||||
@@ -713,7 +787,10 @@ mod tests {
|
|||||||
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
|
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
|
||||||
let url = crate::llm::oauth::extract_login_url_from_error(err);
|
let url = crate::llm::oauth::extract_login_url_from_error(err);
|
||||||
assert!(url.is_some(), "should extract URL from OAuth error");
|
assert!(url.is_some(), "should extract URL from OAuth error");
|
||||||
let msg = format!("Authentication required. [Click here to log in to Claude]({})", url.unwrap());
|
let msg = format!(
|
||||||
|
"Authentication required. [Click here to log in to Claude]({})",
|
||||||
|
url.unwrap()
|
||||||
|
);
|
||||||
assert!(msg.contains("http://localhost:3001/oauth/authorize"));
|
assert!(msg.contains("http://localhost:3001/oauth/authorize"));
|
||||||
assert!(msg.contains("[Click here to log in to Claude]"));
|
assert!(msg.contains("[Click here to log in to Claude]"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
//! Matrix bot run loop — connects to the homeserver and processes sync events.
|
//! Matrix bot run loop — connects to the homeserver and processes sync events.
|
||||||
use crate::agents::AgentPool;
|
use crate::agents::AgentPool;
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use matrix_sdk::{Client, LoopCtrl, config::SyncSettings};
|
|
||||||
use matrix_sdk::ruma::OwnedRoomId;
|
use matrix_sdk::ruma::OwnedRoomId;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use matrix_sdk::{Client, LoopCtrl, config::SyncSettings};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
use tokio::sync::{mpsc, watch};
|
use tokio::sync::{RwLock, mpsc, watch};
|
||||||
|
|
||||||
use super::context::BotContext;
|
use super::context::BotContext;
|
||||||
use super::format::{format_startup_announcement, markdown_to_html};
|
use super::format::{format_startup_announcement, markdown_to_html};
|
||||||
@@ -19,6 +19,7 @@ use super::verification::{on_room_verification_request, on_to_device_verificatio
|
|||||||
/// Connect to the Matrix homeserver, join all configured rooms, and start
|
/// Connect to the Matrix homeserver, join all configured rooms, and start
|
||||||
/// listening for messages. Runs the full Matrix sync loop — call from a
|
/// listening for messages. Runs the full Matrix sync loop — call from a
|
||||||
/// `tokio::spawn` task so it doesn't block the main thread.
|
/// `tokio::spawn` task so it doesn't block the main thread.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn run_bot(
|
pub async fn run_bot(
|
||||||
config: super::super::config::BotConfig,
|
config: super::super::config::BotConfig,
|
||||||
project_root: PathBuf,
|
project_root: PathBuf,
|
||||||
@@ -27,6 +28,8 @@ pub async fn run_bot(
|
|||||||
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<crate::http::context::PermissionForward>>>,
|
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<crate::http::context::PermissionForward>>>,
|
||||||
agents: Arc<AgentPool>,
|
agents: Arc<AgentPool>,
|
||||||
shutdown_rx: watch::Receiver<Option<crate::rebuild::ShutdownReason>>,
|
shutdown_rx: watch::Receiver<Option<crate::rebuild::ShutdownReason>>,
|
||||||
|
gateway_active_project: Option<Arc<RwLock<String>>>,
|
||||||
|
gateway_projects: Vec<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let store_path = project_root.join(".huskies").join("matrix_store");
|
let store_path = project_root.join(".huskies").join("matrix_store");
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
@@ -73,7 +76,10 @@ pub async fn run_bot(
|
|||||||
.ok_or_else(|| "No user ID after login".to_string())?
|
.ok_or_else(|| "No user ID after login".to_string())?
|
||||||
.to_owned();
|
.to_owned();
|
||||||
|
|
||||||
slog!("[matrix-bot] Logged in as {bot_user_id} (device: {})", login_response.device_id);
|
slog!(
|
||||||
|
"[matrix-bot] Logged in as {bot_user_id} (device: {})",
|
||||||
|
login_response.device_id
|
||||||
|
);
|
||||||
|
|
||||||
// Bootstrap cross-signing keys for E2EE verification support.
|
// Bootstrap cross-signing keys for E2EE verification support.
|
||||||
// Pass the bot's password for UIA (User-Interactive Authentication) —
|
// Pass the bot's password for UIA (User-Interactive Authentication) —
|
||||||
@@ -81,9 +87,7 @@ pub async fn run_bot(
|
|||||||
{
|
{
|
||||||
use matrix_sdk::ruma::api::client::uiaa;
|
use matrix_sdk::ruma::api::client::uiaa;
|
||||||
let password_auth = uiaa::AuthData::Password(uiaa::Password::new(
|
let password_auth = uiaa::AuthData::Password(uiaa::Password::new(
|
||||||
uiaa::UserIdentifier::UserIdOrLocalpart(
|
uiaa::UserIdentifier::UserIdOrLocalpart(config.username.clone().unwrap_or_default()),
|
||||||
config.username.clone().unwrap_or_default(),
|
|
||||||
),
|
|
||||||
config.password.clone().unwrap_or_default(),
|
config.password.clone().unwrap_or_default(),
|
||||||
));
|
));
|
||||||
if let Err(e) = client
|
if let Err(e) = client
|
||||||
@@ -171,11 +175,7 @@ pub async fn run_bot(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Restore persisted ambient rooms from config.
|
// Restore persisted ambient rooms from config.
|
||||||
let persisted_ambient: HashSet<String> = config
|
let persisted_ambient: HashSet<String> = config.ambient_rooms.iter().cloned().collect();
|
||||||
.ambient_rooms
|
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
if !persisted_ambient.is_empty() {
|
if !persisted_ambient.is_empty() {
|
||||||
slog!(
|
slog!(
|
||||||
"[matrix-bot] Restored ambient mode for {} room(s): {:?}",
|
"[matrix-bot] Restored ambient mode for {} room(s): {:?}",
|
||||||
@@ -189,11 +189,13 @@ pub async fn run_bot(
|
|||||||
"whatsapp" => {
|
"whatsapp" => {
|
||||||
if config.whatsapp_provider == "twilio" {
|
if config.whatsapp_provider == "twilio" {
|
||||||
slog!("[matrix-bot] Using WhatsApp/Twilio transport");
|
slog!("[matrix-bot] Using WhatsApp/Twilio transport");
|
||||||
Arc::new(crate::chat::transport::whatsapp::TwilioWhatsAppTransport::new(
|
Arc::new(
|
||||||
config.twilio_account_sid.clone().unwrap_or_default(),
|
crate::chat::transport::whatsapp::TwilioWhatsAppTransport::new(
|
||||||
config.twilio_auth_token.clone().unwrap_or_default(),
|
config.twilio_account_sid.clone().unwrap_or_default(),
|
||||||
config.twilio_whatsapp_number.clone().unwrap_or_default(),
|
config.twilio_auth_token.clone().unwrap_or_default(),
|
||||||
))
|
config.twilio_whatsapp_number.clone().unwrap_or_default(),
|
||||||
|
),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
slog!("[matrix-bot] Using WhatsApp/Meta transport");
|
slog!("[matrix-bot] Using WhatsApp/Meta transport");
|
||||||
Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new(
|
Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new(
|
||||||
@@ -208,7 +210,9 @@ pub async fn run_bot(
|
|||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
slog!("[matrix-bot] Using Matrix transport");
|
slog!("[matrix-bot] Using Matrix transport");
|
||||||
Arc::new(super::super::transport_impl::MatrixTransport::new(client.clone()))
|
Arc::new(super::super::transport_impl::MatrixTransport::new(
|
||||||
|
client.clone(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -222,10 +226,7 @@ pub async fn run_bot(
|
|||||||
project_root.join(".huskies").join("timers.json"),
|
project_root.join(".huskies").join("timers.json"),
|
||||||
));
|
));
|
||||||
// Auto-schedule timers when an agent hits a hard rate limit.
|
// Auto-schedule timers when an agent hits a hard rate limit.
|
||||||
crate::chat::timer::spawn_rate_limit_auto_scheduler(
|
crate::chat::timer::spawn_rate_limit_auto_scheduler(Arc::clone(&timer_store), watcher_rx_auto);
|
||||||
Arc::clone(&timer_store),
|
|
||||||
watcher_rx_auto,
|
|
||||||
);
|
|
||||||
|
|
||||||
let ctx = BotContext {
|
let ctx = BotContext {
|
||||||
bot_user_id,
|
bot_user_id,
|
||||||
@@ -244,9 +245,13 @@ pub async fn run_bot(
|
|||||||
htop_sessions: Arc::new(TokioMutex::new(HashMap::new())),
|
htop_sessions: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
transport: Arc::clone(&transport),
|
transport: Arc::clone(&transport),
|
||||||
timer_store,
|
timer_store,
|
||||||
|
gateway_active_project,
|
||||||
|
gateway_projects,
|
||||||
};
|
};
|
||||||
|
|
||||||
slog!("[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected");
|
slog!(
|
||||||
|
"[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected"
|
||||||
|
);
|
||||||
|
|
||||||
// Register event handlers and inject shared context.
|
// Register event handlers and inject shared context.
|
||||||
client.add_event_handler_context(ctx);
|
client.add_event_handler_context(ctx);
|
||||||
@@ -256,8 +261,7 @@ pub async fn run_bot(
|
|||||||
|
|
||||||
// Spawn the stage-transition notification listener before entering the
|
// Spawn the stage-transition notification listener before entering the
|
||||||
// sync loop so it starts receiving watcher events immediately.
|
// sync loop so it starts receiving watcher events immediately.
|
||||||
let notif_room_id_strings: Vec<String> =
|
let notif_room_id_strings: Vec<String> = notif_room_ids.iter().map(|r| r.to_string()).collect();
|
||||||
notif_room_ids.iter().map(|r| r.to_string()).collect();
|
|
||||||
super::super::notifications::spawn_notification_listener(
|
super::super::notifications::spawn_notification_listener(
|
||||||
Arc::clone(&transport),
|
Arc::clone(&transport),
|
||||||
move || notif_room_id_strings.clone(),
|
move || notif_room_id_strings.clone(),
|
||||||
@@ -269,8 +273,7 @@ pub async fn run_bot(
|
|||||||
// configured rooms when the server is about to stop (SIGINT/SIGTERM or rebuild).
|
// configured rooms when the server is about to stop (SIGINT/SIGTERM or rebuild).
|
||||||
{
|
{
|
||||||
let shutdown_transport = Arc::clone(&transport);
|
let shutdown_transport = Arc::clone(&transport);
|
||||||
let shutdown_rooms: Vec<String> =
|
let shutdown_rooms: Vec<String> = announce_room_ids.iter().map(|r| r.to_string()).collect();
|
||||||
announce_room_ids.iter().map(|r| r.to_string()).collect();
|
|
||||||
let shutdown_bot_name = announce_bot_name.clone();
|
let shutdown_bot_name = announce_bot_name.clone();
|
||||||
let mut rx = shutdown_rx;
|
let mut rx = shutdown_rx;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -400,8 +403,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn io_error_is_not_fatal() {
|
fn io_error_is_not_fatal() {
|
||||||
let e: matrix_sdk::Error =
|
let e: matrix_sdk::Error =
|
||||||
std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused")
|
std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused").into();
|
||||||
.into();
|
|
||||||
assert!(!is_fatal_sync_error(&e));
|
assert!(!is_fatal_sync_error(&e));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,7 +425,11 @@ mod tests {
|
|||||||
const MAX_BACKOFF_SECS: u64 = 300;
|
const MAX_BACKOFF_SECS: u64 = 300;
|
||||||
let steps: Vec<u64> = std::iter::successors(Some(5u64), |&d| {
|
let steps: Vec<u64> = std::iter::successors(Some(5u64), |&d| {
|
||||||
let next = (d * 2).min(MAX_BACKOFF_SECS);
|
let next = (d * 2).min(MAX_BACKOFF_SECS);
|
||||||
if next < MAX_BACKOFF_SECS { Some(next) } else { None }
|
if next < MAX_BACKOFF_SECS {
|
||||||
|
Some(next)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
// First few steps: 5, 10, 20, 40, 80, 160
|
// First few steps: 5, 10, 20, 40, 80, 160
|
||||||
@@ -433,4 +439,3 @@ mod tests {
|
|||||||
assert_eq!(steps[3], 40);
|
assert_eq!(steps[3], 40);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,8 +84,9 @@ pub(super) async fn on_to_device_verification_request(
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
VerificationRequestState::Done
|
VerificationRequestState::Done | VerificationRequestState::Cancelled(_) => {
|
||||||
| VerificationRequestState::Cancelled(_) => break,
|
break;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,10 +101,7 @@ pub(super) async fn on_to_device_verification_request(
|
|||||||
/// Modern Element sends `m.key.verification.request` as an `m.room.message`
|
/// Modern Element sends `m.key.verification.request` as an `m.room.message`
|
||||||
/// event rather than a to-device event. We look for that message type and
|
/// event rather than a to-device event. We look for that message type and
|
||||||
/// drive the same SAS flow as the to-device handler.
|
/// drive the same SAS flow as the to-device handler.
|
||||||
pub(super) async fn on_room_verification_request(
|
pub(super) async fn on_room_verification_request(ev: OriginalSyncRoomMessageEvent, client: Client) {
|
||||||
ev: OriginalSyncRoomMessageEvent,
|
|
||||||
client: Client,
|
|
||||||
) {
|
|
||||||
// Only act on in-room verification request messages.
|
// Only act on in-room verification request messages.
|
||||||
if !matches!(ev.content.msgtype, MessageType::VerificationRequest(_)) {
|
if !matches!(ev.content.msgtype, MessageType::VerificationRequest(_)) {
|
||||||
return;
|
return;
|
||||||
@@ -152,8 +150,9 @@ pub(super) async fn on_room_verification_request(
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
VerificationRequestState::Done
|
VerificationRequestState::Done | VerificationRequestState::Cancelled(_) => {
|
||||||
| VerificationRequestState::Cancelled(_) => break,
|
break;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ pub struct BotConfig {
|
|||||||
|
|
||||||
// ── WhatsApp Business API fields ─────────────────────────────────
|
// ── WhatsApp Business API fields ─────────────────────────────────
|
||||||
// These are only required when `transport = "whatsapp"`.
|
// These are only required when `transport = "whatsapp"`.
|
||||||
|
|
||||||
/// WhatsApp Business phone number ID from the Meta dashboard.
|
/// WhatsApp Business phone number ID from the Meta dashboard.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub whatsapp_phone_number_id: Option<String>,
|
pub whatsapp_phone_number_id: Option<String>,
|
||||||
@@ -105,7 +104,6 @@ pub struct BotConfig {
|
|||||||
|
|
||||||
// ── Twilio WhatsApp fields ─────────────────────────────────────────
|
// ── Twilio WhatsApp fields ─────────────────────────────────────────
|
||||||
// Only required when `transport = "whatsapp"` and `whatsapp_provider = "twilio"`.
|
// Only required when `transport = "whatsapp"` and `whatsapp_provider = "twilio"`.
|
||||||
|
|
||||||
/// Twilio Account SID (starts with `AC`).
|
/// Twilio Account SID (starts with `AC`).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub twilio_account_sid: Option<String>,
|
pub twilio_account_sid: Option<String>,
|
||||||
@@ -126,7 +124,6 @@ pub struct BotConfig {
|
|||||||
|
|
||||||
// ── Slack Bot API fields ─────────────────────────────────────────
|
// ── Slack Bot API fields ─────────────────────────────────────────
|
||||||
// These are only required when `transport = "slack"`.
|
// These are only required when `transport = "slack"`.
|
||||||
|
|
||||||
/// Slack Bot User OAuth Token (starts with `xoxb-`).
|
/// Slack Bot User OAuth Token (starts with `xoxb-`).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub slack_bot_token: Option<String>,
|
pub slack_bot_token: Option<String>,
|
||||||
@@ -139,7 +136,6 @@ pub struct BotConfig {
|
|||||||
|
|
||||||
// ── Discord Bot API fields ──────────────────────────────────────
|
// ── Discord Bot API fields ──────────────────────────────────────
|
||||||
// These are only required when `transport = "discord"`.
|
// These are only required when `transport = "discord"`.
|
||||||
|
|
||||||
/// Discord bot token from the Discord Developer Portal.
|
/// Discord bot token from the Discord Developer Portal.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub discord_bot_token: Option<String>,
|
pub discord_bot_token: Option<String>,
|
||||||
@@ -189,21 +185,33 @@ impl BotConfig {
|
|||||||
if config.transport == "whatsapp" {
|
if config.transport == "whatsapp" {
|
||||||
if config.whatsapp_provider == "twilio" {
|
if config.whatsapp_provider == "twilio" {
|
||||||
// Validate Twilio-specific fields.
|
// Validate Twilio-specific fields.
|
||||||
if config.twilio_account_sid.as_ref().is_none_or(|s| s.is_empty()) {
|
if config
|
||||||
|
.twilio_account_sid
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|s| s.is_empty())
|
||||||
|
{
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
|
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
|
||||||
twilio_account_sid"
|
twilio_account_sid"
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if config.twilio_auth_token.as_ref().is_none_or(|s| s.is_empty()) {
|
if config
|
||||||
|
.twilio_auth_token
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|s| s.is_empty())
|
||||||
|
{
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
|
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
|
||||||
twilio_auth_token"
|
twilio_auth_token"
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if config.twilio_whatsapp_number.as_ref().is_none_or(|s| s.is_empty()) {
|
if config
|
||||||
|
.twilio_whatsapp_number
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|s| s.is_empty())
|
||||||
|
{
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
|
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
|
||||||
twilio_whatsapp_number"
|
twilio_whatsapp_number"
|
||||||
@@ -212,21 +220,33 @@ impl BotConfig {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Validate Meta (default) WhatsApp fields.
|
// Validate Meta (default) WhatsApp fields.
|
||||||
if config.whatsapp_phone_number_id.as_ref().is_none_or(|s| s.is_empty()) {
|
if config
|
||||||
|
.whatsapp_phone_number_id
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|s| s.is_empty())
|
||||||
|
{
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
||||||
whatsapp_phone_number_id"
|
whatsapp_phone_number_id"
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if config.whatsapp_access_token.as_ref().is_none_or(|s| s.is_empty()) {
|
if config
|
||||||
|
.whatsapp_access_token
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|s| s.is_empty())
|
||||||
|
{
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
||||||
whatsapp_access_token"
|
whatsapp_access_token"
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if config.whatsapp_verify_token.as_ref().is_none_or(|s| s.is_empty()) {
|
if config
|
||||||
|
.whatsapp_verify_token
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|s| s.is_empty())
|
||||||
|
{
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
||||||
whatsapp_verify_token"
|
whatsapp_verify_token"
|
||||||
@@ -243,7 +263,11 @@ impl BotConfig {
|
|||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if config.slack_signing_secret.as_ref().is_none_or(|s| s.is_empty()) {
|
if config
|
||||||
|
.slack_signing_secret
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|s| s.is_empty())
|
||||||
|
{
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[bot] bot.toml: transport=\"slack\" requires \
|
"[bot] bot.toml: transport=\"slack\" requires \
|
||||||
slack_signing_secret"
|
slack_signing_secret"
|
||||||
@@ -259,7 +283,11 @@ impl BotConfig {
|
|||||||
}
|
}
|
||||||
} else if config.transport == "discord" {
|
} else if config.transport == "discord" {
|
||||||
// Validate Discord-specific fields.
|
// Validate Discord-specific fields.
|
||||||
if config.discord_bot_token.as_ref().is_none_or(|s| s.is_empty()) {
|
if config
|
||||||
|
.discord_bot_token
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|s| s.is_empty())
|
||||||
|
{
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[bot] bot.toml: transport=\"discord\" requires \
|
"[bot] bot.toml: transport=\"discord\" requires \
|
||||||
discord_bot_token"
|
discord_bot_token"
|
||||||
@@ -276,21 +304,15 @@ impl BotConfig {
|
|||||||
} else {
|
} else {
|
||||||
// Default transport is Matrix — validate Matrix-specific fields.
|
// Default transport is Matrix — validate Matrix-specific fields.
|
||||||
if config.homeserver.as_ref().is_none_or(|s| s.is_empty()) {
|
if config.homeserver.as_ref().is_none_or(|s| s.is_empty()) {
|
||||||
eprintln!(
|
eprintln!("[bot] bot.toml: transport=\"matrix\" requires homeserver");
|
||||||
"[bot] bot.toml: transport=\"matrix\" requires homeserver"
|
|
||||||
);
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if config.username.as_ref().is_none_or(|s| s.is_empty()) {
|
if config.username.as_ref().is_none_or(|s| s.is_empty()) {
|
||||||
eprintln!(
|
eprintln!("[bot] bot.toml: transport=\"matrix\" requires username");
|
||||||
"[bot] bot.toml: transport=\"matrix\" requires username"
|
|
||||||
);
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if config.password.as_ref().is_none_or(|s| s.is_empty()) {
|
if config.password.as_ref().is_none_or(|s| s.is_empty()) {
|
||||||
eprintln!(
|
eprintln!("[bot] bot.toml: transport=\"matrix\" requires password");
|
||||||
"[bot] bot.toml: transport=\"matrix\" requires password"
|
|
||||||
);
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if config.room_ids.is_empty() {
|
if config.room_ids.is_empty() {
|
||||||
@@ -402,7 +424,10 @@ enabled = true
|
|||||||
let result = BotConfig::load(tmp.path());
|
let result = BotConfig::load(tmp.path());
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
let config = result.unwrap();
|
let config = result.unwrap();
|
||||||
assert_eq!(config.homeserver.as_deref(), Some("https://matrix.example.com"));
|
assert_eq!(
|
||||||
|
config.homeserver.as_deref(),
|
||||||
|
Some("https://matrix.example.com")
|
||||||
|
);
|
||||||
assert_eq!(config.username.as_deref(), Some("@bot:example.com"));
|
assert_eq!(config.username.as_deref(), Some("@bot:example.com"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.effective_room_ids(),
|
config.effective_room_ids(),
|
||||||
@@ -761,18 +786,9 @@ whatsapp_verify_token = "my-verify"
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let config = BotConfig::load(tmp.path()).unwrap();
|
let config = BotConfig::load(tmp.path()).unwrap();
|
||||||
assert_eq!(config.transport, "whatsapp");
|
assert_eq!(config.transport, "whatsapp");
|
||||||
assert_eq!(
|
assert_eq!(config.whatsapp_phone_number_id.as_deref(), Some("123456"));
|
||||||
config.whatsapp_phone_number_id.as_deref(),
|
assert_eq!(config.whatsapp_access_token.as_deref(), Some("EAAtoken"));
|
||||||
Some("123456")
|
assert_eq!(config.whatsapp_verify_token.as_deref(), Some("my-verify"));
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
config.whatsapp_access_token.as_deref(),
|
|
||||||
Some("EAAtoken")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
config.whatsapp_verify_token.as_deref(),
|
|
||||||
Some("my-verify")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1106,14 +1122,8 @@ discord_channel_ids = ["123456789012345678"]
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let config = BotConfig::load(tmp.path()).unwrap();
|
let config = BotConfig::load(tmp.path()).unwrap();
|
||||||
assert_eq!(config.transport, "discord");
|
assert_eq!(config.transport, "discord");
|
||||||
assert_eq!(
|
assert_eq!(config.discord_bot_token.as_deref(), Some("Bot.Token.Here"));
|
||||||
config.discord_bot_token.as_deref(),
|
assert_eq!(config.discord_channel_ids, vec!["123456789012345678"]);
|
||||||
Some("Bot.Token.Here")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
config.discord_channel_ids,
|
|
||||||
vec!["123456789012345678"]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1176,9 +1186,6 @@ discord_allowed_users = ["111222333", "444555666"]
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(config.discord_allowed_users, vec!["111222333", "444555666"]);
|
||||||
config.discord_allowed_users,
|
|
||||||
vec!["111222333", "444555666"]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,9 +65,7 @@ pub async fn handle_delete(
|
|||||||
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
||||||
Some(found) => found,
|
Some(found) => found,
|
||||||
None => {
|
None => {
|
||||||
return format!(
|
return format!("No story, bug, or spike with number **{story_number}** found.");
|
||||||
"No story, bug, or spike with number **{story_number}** found."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ use std::time::Duration;
|
|||||||
use tokio::sync::{Mutex as TokioMutex, watch};
|
use tokio::sync::{Mutex as TokioMutex, watch};
|
||||||
|
|
||||||
use crate::agents::{AgentPool, AgentStatus};
|
use crate::agents::{AgentPool, AgentStatus};
|
||||||
|
use crate::chat::ChatTransport;
|
||||||
use crate::chat::util::strip_bot_mention;
|
use crate::chat::util::strip_bot_mention;
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use crate::chat::ChatTransport;
|
|
||||||
|
|
||||||
use super::bot::markdown_to_html;
|
use super::bot::markdown_to_html;
|
||||||
|
|
||||||
@@ -51,7 +51,11 @@ pub type HtopSessions = Arc<TokioMutex<HashMap<String, HtopSession>>>;
|
|||||||
/// - `htop stop` → `Stop`
|
/// - `htop stop` → `Stop`
|
||||||
/// - `htop 10m` → `Start { duration_secs: 600 }`
|
/// - `htop 10m` → `Start { duration_secs: 600 }`
|
||||||
/// - `htop 120` → `Start { duration_secs: 120 }` (bare seconds)
|
/// - `htop 120` → `Start { duration_secs: 120 }` (bare seconds)
|
||||||
pub fn extract_htop_command(message: &str, bot_name: &str, bot_user_id: &str) -> Option<HtopCommand> {
|
pub fn extract_htop_command(
|
||||||
|
message: &str,
|
||||||
|
bot_name: &str,
|
||||||
|
bot_user_id: &str,
|
||||||
|
) -> Option<HtopCommand> {
|
||||||
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
|
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
|
||||||
let trimmed = stripped.trim();
|
let trimmed = stripped.trim();
|
||||||
|
|
||||||
@@ -261,7 +265,10 @@ pub async fn run_htop_loop(
|
|||||||
let text = build_htop_message(&agents, tick as u32, duration_secs);
|
let text = build_htop_message(&agents, tick as u32, duration_secs);
|
||||||
let html = markdown_to_html(&text);
|
let html = markdown_to_html(&text);
|
||||||
|
|
||||||
if let Err(e) = transport.edit_message(&room_id, &initial_message_id, &text, &html).await {
|
if let Err(e) = transport
|
||||||
|
.edit_message(&room_id, &initial_message_id, &text, &html)
|
||||||
|
.await
|
||||||
|
{
|
||||||
slog!("[htop] Failed to update message: {e}");
|
slog!("[htop] Failed to update message: {e}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -274,7 +281,10 @@ pub async fn run_htop_loop(
|
|||||||
async fn send_stopped_message(transport: &dyn ChatTransport, room_id: &str, message_id: &str) {
|
async fn send_stopped_message(transport: &dyn ChatTransport, room_id: &str, message_id: &str) {
|
||||||
let text = "**htop** — monitoring stopped.";
|
let text = "**htop** — monitoring stopped.";
|
||||||
let html = markdown_to_html(text);
|
let html = markdown_to_html(text);
|
||||||
if let Err(e) = transport.edit_message(room_id, message_id, text, &html).await {
|
if let Err(e) = transport
|
||||||
|
.edit_message(room_id, message_id, text, &html)
|
||||||
|
.await
|
||||||
|
{
|
||||||
slog!("[htop] Failed to send stop message: {e}");
|
slog!("[htop] Failed to send stop message: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -302,7 +312,10 @@ pub async fn handle_htop_start(
|
|||||||
// Send the initial message.
|
// Send the initial message.
|
||||||
let initial_text = build_htop_message(&agents, 0, duration_secs);
|
let initial_text = build_htop_message(&agents, 0, duration_secs);
|
||||||
let initial_html = markdown_to_html(&initial_text);
|
let initial_html = markdown_to_html(&initial_text);
|
||||||
let message_id = match transport.send_message(room_id, &initial_text, &initial_html).await {
|
let message_id = match transport
|
||||||
|
.send_message(room_id, &initial_text, &initial_html)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
slog!("[htop] Failed to send initial message: {e}");
|
slog!("[htop] Failed to send initial message: {e}");
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ pub mod commands;
|
|||||||
pub(crate) mod config;
|
pub(crate) mod config;
|
||||||
pub mod delete;
|
pub mod delete;
|
||||||
pub mod htop;
|
pub mod htop;
|
||||||
|
pub mod notifications;
|
||||||
pub mod rebuild;
|
pub mod rebuild;
|
||||||
pub mod reset;
|
pub mod reset;
|
||||||
pub mod rmtree;
|
pub mod rmtree;
|
||||||
pub mod start;
|
pub mod start;
|
||||||
pub mod notifications;
|
|
||||||
pub mod transport_impl;
|
pub mod transport_impl;
|
||||||
|
|
||||||
pub use bot::{ConversationEntry, ConversationRole, RoomConversation};
|
pub use bot::{ConversationEntry, ConversationRole, RoomConversation};
|
||||||
@@ -37,7 +37,7 @@ use crate::io::watcher::WatcherEvent;
|
|||||||
use crate::rebuild::ShutdownReason;
|
use crate::rebuild::ShutdownReason;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{Mutex as TokioMutex, broadcast, mpsc, watch};
|
use tokio::sync::{Mutex as TokioMutex, RwLock, broadcast, mpsc, watch};
|
||||||
|
|
||||||
/// Attempt to start the Matrix bot.
|
/// Attempt to start the Matrix bot.
|
||||||
///
|
///
|
||||||
@@ -64,6 +64,8 @@ pub fn spawn_bot(
|
|||||||
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
|
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
|
||||||
agents: Arc<AgentPool>,
|
agents: Arc<AgentPool>,
|
||||||
shutdown_rx: watch::Receiver<Option<ShutdownReason>>,
|
shutdown_rx: watch::Receiver<Option<ShutdownReason>>,
|
||||||
|
gateway_active_project: Option<Arc<RwLock<String>>>,
|
||||||
|
gateway_projects: Vec<String>,
|
||||||
) {
|
) {
|
||||||
let config = match BotConfig::load(project_root) {
|
let config = match BotConfig::load(project_root) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
@@ -92,9 +94,18 @@ pub fn spawn_bot(
|
|||||||
let watcher_rx = watcher_tx.subscribe();
|
let watcher_rx = watcher_tx.subscribe();
|
||||||
let watcher_rx_auto = watcher_tx.subscribe();
|
let watcher_rx_auto = watcher_tx.subscribe();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) =
|
if let Err(e) = bot::run_bot(
|
||||||
bot::run_bot(config, root, watcher_rx, watcher_rx_auto, perm_rx, agents, shutdown_rx)
|
config,
|
||||||
.await
|
root,
|
||||||
|
watcher_rx,
|
||||||
|
watcher_rx_auto,
|
||||||
|
perm_rx,
|
||||||
|
agents,
|
||||||
|
shutdown_rx,
|
||||||
|
gateway_active_project,
|
||||||
|
gateway_projects,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
crate::slog!("[matrix-bot] Fatal error: {e}");
|
crate::slog!("[matrix-bot] Fatal error: {e}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
//! Subscribes to [`WatcherEvent`] broadcasts and posts a notification to all
|
//! Subscribes to [`WatcherEvent`] broadcasts and posts a notification to all
|
||||||
//! configured Matrix rooms whenever a work item moves between pipeline stages.
|
//! configured Matrix rooms whenever a work item moves between pipeline stages.
|
||||||
|
|
||||||
|
use crate::chat::ChatTransport;
|
||||||
use crate::config::ProjectConfig;
|
use crate::config::ProjectConfig;
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::io::story_metadata::parse_front_matter;
|
||||||
use crate::io::watcher::WatcherEvent;
|
use crate::io::watcher::WatcherEvent;
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use crate::chat::ChatTransport;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -35,16 +35,11 @@ pub fn extract_story_number(item_id: &str) -> Option<&str> {
|
|||||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the story name from the work item file's YAML front matter.
|
/// Read the story name from the CRDT content store's YAML front matter.
|
||||||
///
|
///
|
||||||
/// Returns `None` if the file doesn't exist or has no parseable name.
|
/// Returns `None` if the item is not in the content store or has no parseable name.
|
||||||
pub fn read_story_name(project_root: &Path, stage: &str, item_id: &str) -> Option<String> {
|
pub fn read_story_name(_project_root: &Path, _stage: &str, item_id: &str) -> Option<String> {
|
||||||
let path = project_root
|
let contents = crate::db::read_content(item_id)?;
|
||||||
.join(".huskies")
|
|
||||||
.join("work")
|
|
||||||
.join(stage)
|
|
||||||
.join(format!("{item_id}.md"));
|
|
||||||
let contents = std::fs::read_to_string(&path).ok()?;
|
|
||||||
let meta = parse_front_matter(&contents).ok()?;
|
let meta = parse_front_matter(&contents).ok()?;
|
||||||
meta.name
|
meta.name
|
||||||
}
|
}
|
||||||
@@ -81,24 +76,15 @@ pub fn format_error_notification(
|
|||||||
let name = story_name.unwrap_or(item_id);
|
let name = story_name.unwrap_or(item_id);
|
||||||
|
|
||||||
let plain = format!("\u{274c} #{number} {name} \u{2014} {reason}");
|
let plain = format!("\u{274c} #{number} {name} \u{2014} {reason}");
|
||||||
let html = format!(
|
let html = format!("\u{274c} <strong>#{number}</strong> <em>{name}</em> \u{2014} {reason}");
|
||||||
"\u{274c} <strong>#{number}</strong> <em>{name}</em> \u{2014} {reason}"
|
|
||||||
);
|
|
||||||
(plain, html)
|
(plain, html)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search all pipeline stages for a story name.
|
/// Look up a story name from the CRDT content store.
|
||||||
///
|
///
|
||||||
/// Tries each known pipeline stage directory in order and returns the first
|
/// Used for events (like rate-limit warnings) that arrive without a known stage.
|
||||||
/// 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> {
|
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"] {
|
read_story_name(project_root, "", item_id)
|
||||||
if let Some(name) = read_story_name(project_root, stage, item_id) {
|
|
||||||
return Some(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format a blocked-story notification message.
|
/// Format a blocked-story notification message.
|
||||||
@@ -113,9 +99,8 @@ pub fn format_blocked_notification(
|
|||||||
let name = story_name.unwrap_or(item_id);
|
let name = story_name.unwrap_or(item_id);
|
||||||
|
|
||||||
let plain = format!("\u{1f6ab} #{number} {name} \u{2014} BLOCKED: {reason}");
|
let plain = format!("\u{1f6ab} #{number} {name} \u{2014} BLOCKED: {reason}");
|
||||||
let html = format!(
|
let html =
|
||||||
"\u{1f6ab} <strong>#{number}</strong> <em>{name}</em> \u{2014} BLOCKED: {reason}"
|
format!("\u{1f6ab} <strong>#{number}</strong> <em>{name}</em> \u{2014} BLOCKED: {reason}");
|
||||||
);
|
|
||||||
(plain, html)
|
(plain, html)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +111,6 @@ const RATE_LIMIT_DEBOUNCE: Duration = Duration::from_secs(60);
|
|||||||
/// into a single notification (only the final stage is announced).
|
/// into a single notification (only the final stage is announced).
|
||||||
const STAGE_TRANSITION_DEBOUNCE: Duration = Duration::from_millis(200);
|
const STAGE_TRANSITION_DEBOUNCE: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
|
|
||||||
/// Format a rate limit warning notification message.
|
/// Format a rate limit warning notification message.
|
||||||
///
|
///
|
||||||
/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`.
|
/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`.
|
||||||
@@ -138,9 +122,8 @@ pub fn format_rate_limit_notification(
|
|||||||
let number = extract_story_number(item_id).unwrap_or(item_id);
|
let number = extract_story_number(item_id).unwrap_or(item_id);
|
||||||
let name = story_name.unwrap_or(item_id);
|
let name = story_name.unwrap_or(item_id);
|
||||||
|
|
||||||
let plain = format!(
|
let plain =
|
||||||
"\u{26a0}\u{fe0f} #{number} {name} \u{2014} {agent_name} hit an API rate limit"
|
format!("\u{26a0}\u{fe0f} #{number} {name} \u{2014} {agent_name} hit an API rate limit");
|
||||||
);
|
|
||||||
let html = format!(
|
let html = format!(
|
||||||
"\u{26a0}\u{fe0f} <strong>#{number}</strong> <em>{name}</em> \u{2014} \
|
"\u{26a0}\u{fe0f} <strong>#{number}</strong> <em>{name}</em> \u{2014} \
|
||||||
{agent_name} hit an API rate limit"
|
{agent_name} hit an API rate limit"
|
||||||
@@ -223,9 +206,7 @@ pub fn spawn_notification_listener(
|
|||||||
// and must be skipped — the old inferred_from_stage fallback
|
// and must be skipped — the old inferred_from_stage fallback
|
||||||
// produced wrong notifications for stories that skipped stages
|
// produced wrong notifications for stories that skipped stages
|
||||||
// (e.g. "QA → Merge" when QA was never entered).
|
// (e.g. "QA → Merge" when QA was never entered).
|
||||||
let from_display = from_stage
|
let from_display = from_stage.as_deref().map(stage_display_name);
|
||||||
.as_deref()
|
|
||||||
.map(stage_display_name);
|
|
||||||
let Some(from_display) = from_display else {
|
let Some(from_display) = from_display else {
|
||||||
continue; // creation or unknown transition — skip
|
continue; // creation or unknown transition — skip
|
||||||
};
|
};
|
||||||
@@ -246,33 +227,24 @@ pub fn spawn_notification_listener(
|
|||||||
e.2 = story_name.clone();
|
e.2 = story_name.clone();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.or_insert_with(|| {
|
.or_insert_with(|| (from_display.to_string(), stage.clone(), story_name));
|
||||||
(from_display.to_string(), stage.clone(), story_name)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start or extend the debounce window.
|
// Start or extend the debounce window.
|
||||||
flush_deadline =
|
flush_deadline = Some(tokio::time::Instant::now() + STAGE_TRANSITION_DEBOUNCE);
|
||||||
Some(tokio::time::Instant::now() + STAGE_TRANSITION_DEBOUNCE);
|
|
||||||
}
|
}
|
||||||
Ok(WatcherEvent::MergeFailure {
|
Ok(WatcherEvent::MergeFailure {
|
||||||
ref story_id,
|
ref story_id,
|
||||||
ref reason,
|
ref reason,
|
||||||
}) => {
|
}) => {
|
||||||
let story_name =
|
let story_name = read_story_name(&project_root, "4_merge", story_id);
|
||||||
read_story_name(&project_root, "4_merge", story_id);
|
let (plain, html) =
|
||||||
let (plain, html) = format_error_notification(
|
format_error_notification(story_id, story_name.as_deref(), reason);
|
||||||
story_id,
|
|
||||||
story_name.as_deref(),
|
|
||||||
reason,
|
|
||||||
);
|
|
||||||
|
|
||||||
slog!("[bot] Sending error notification: {plain}");
|
slog!("[bot] Sending error notification: {plain}");
|
||||||
|
|
||||||
for room_id in &get_room_ids() {
|
for room_id in &get_room_ids() {
|
||||||
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
||||||
slog!(
|
slog!("[bot] Failed to send error notification to {room_id}: {e}");
|
||||||
"[bot] Failed to send error notification to {room_id}: {e}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,11 +275,8 @@ pub fn spawn_notification_listener(
|
|||||||
rate_limit_last_notified.insert(debounce_key, now);
|
rate_limit_last_notified.insert(debounce_key, now);
|
||||||
|
|
||||||
let story_name = find_story_name_any_stage(&project_root, story_id);
|
let story_name = find_story_name_any_stage(&project_root, story_id);
|
||||||
let (plain, html) = format_rate_limit_notification(
|
let (plain, html) =
|
||||||
story_id,
|
format_rate_limit_notification(story_id, story_name.as_deref(), agent_name);
|
||||||
story_name.as_deref(),
|
|
||||||
agent_name,
|
|
||||||
);
|
|
||||||
|
|
||||||
slog!("[bot] Sending rate-limit notification: {plain}");
|
slog!("[bot] Sending rate-limit notification: {plain}");
|
||||||
|
|
||||||
@@ -325,19 +294,14 @@ pub fn spawn_notification_listener(
|
|||||||
ref reason,
|
ref reason,
|
||||||
}) => {
|
}) => {
|
||||||
let story_name = find_story_name_any_stage(&project_root, story_id);
|
let story_name = find_story_name_any_stage(&project_root, story_id);
|
||||||
let (plain, html) = format_blocked_notification(
|
let (plain, html) =
|
||||||
story_id,
|
format_blocked_notification(story_id, story_name.as_deref(), reason);
|
||||||
story_name.as_deref(),
|
|
||||||
reason,
|
|
||||||
);
|
|
||||||
|
|
||||||
slog!("[bot] Sending blocked notification: {plain}");
|
slog!("[bot] Sending blocked notification: {plain}");
|
||||||
|
|
||||||
for room_id in &get_room_ids() {
|
for room_id in &get_room_ids() {
|
||||||
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
||||||
slog!(
|
slog!("[bot] Failed to send blocked notification to {room_id}: {e}");
|
||||||
"[bot] Failed to send blocked notification to {room_id}: {e}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,14 +326,10 @@ pub fn spawn_notification_listener(
|
|||||||
}
|
}
|
||||||
Ok(_) => {} // Ignore other events
|
Ok(_) => {} // Ignore other events
|
||||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||||
slog!(
|
slog!("[bot] Notification listener lagged, skipped {n} events");
|
||||||
"[bot] Notification listener lagged, skipped {n} events"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Err(broadcast::error::RecvError::Closed) => {
|
Err(broadcast::error::RecvError::Closed) => {
|
||||||
slog!(
|
slog!("[bot] Watcher channel closed, stopping notification listener");
|
||||||
"[bot] Watcher channel closed, stopping notification listener"
|
|
||||||
);
|
|
||||||
// Flush any coalesced transitions that haven't fired yet.
|
// Flush any coalesced transitions that haven't fired yet.
|
||||||
for (item_id, (from_display, to_stage_key, story_name)) in
|
for (item_id, (from_display, to_stage_key, story_name)) in
|
||||||
pending_transitions.drain()
|
pending_transitions.drain()
|
||||||
@@ -383,12 +343,8 @@ pub fn spawn_notification_listener(
|
|||||||
);
|
);
|
||||||
slog!("[bot] Sending stage notification: {plain}");
|
slog!("[bot] Sending stage notification: {plain}");
|
||||||
for room_id in &get_room_ids() {
|
for room_id in &get_room_ids() {
|
||||||
if let Err(e) =
|
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
||||||
transport.send_message(room_id, &plain, &html).await
|
slog!("[bot] Failed to send notification to {room_id}: {e}");
|
||||||
{
|
|
||||||
slog!(
|
|
||||||
"[bot] Failed to send notification to {room_id}: {e}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,8 +358,8 @@ pub fn spawn_notification_listener(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use async_trait::async_trait;
|
|
||||||
use crate::chat::MessageId;
|
use crate::chat::MessageId;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
// ── MockTransport ───────────────────────────────────────────────────────
|
// ── MockTransport ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -417,18 +373,38 @@ mod tests {
|
|||||||
impl MockTransport {
|
impl MockTransport {
|
||||||
fn new() -> (Arc<Self>, CallLog) {
|
fn new() -> (Arc<Self>, CallLog) {
|
||||||
let calls: CallLog = Arc::new(std::sync::Mutex::new(Vec::new()));
|
let calls: CallLog = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||||
(Arc::new(Self { calls: Arc::clone(&calls) }), calls)
|
(
|
||||||
|
Arc::new(Self {
|
||||||
|
calls: Arc::clone(&calls),
|
||||||
|
}),
|
||||||
|
calls,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl crate::chat::ChatTransport for MockTransport {
|
impl crate::chat::ChatTransport for MockTransport {
|
||||||
async fn send_message(&self, room_id: &str, plain: &str, html: &str) -> Result<MessageId, String> {
|
async fn send_message(
|
||||||
self.calls.lock().unwrap().push((room_id.to_string(), plain.to_string(), html.to_string()));
|
&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())
|
Ok("mock-msg-id".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn edit_message(&self, _room_id: &str, _id: &str, _plain: &str, _html: &str) -> Result<(), String> {
|
async fn edit_message(
|
||||||
|
&self,
|
||||||
|
_room_id: &str,
|
||||||
|
_id: &str,
|
||||||
|
_plain: &str,
|
||||||
|
_html: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,13 +420,13 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn rate_limit_warning_sends_notification_with_agent_and_story() {
|
async fn rate_limit_warning_sends_notification_with_agent_and_story() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let stage_dir = tmp.path().join(".huskies").join("work").join("2_current");
|
// Seed story via CRDT (the only source of truth).
|
||||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
crate::db::ensure_content_store();
|
||||||
std::fs::write(
|
crate::db::write_item_with_content(
|
||||||
stage_dir.join("365_story_rate_limit.md"),
|
"365_story_rate_limit",
|
||||||
|
"2_current",
|
||||||
"---\nname: Rate Limit Test Story\n---\n",
|
"---\nname: Rate Limit Test Story\n---\n",
|
||||||
)
|
);
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||||
let (transport, calls) = MockTransport::new();
|
let (transport, calls) = MockTransport::new();
|
||||||
@@ -462,10 +438,12 @@ mod tests {
|
|||||||
tmp.path().to_path_buf(),
|
tmp.path().to_path_buf(),
|
||||||
);
|
);
|
||||||
|
|
||||||
watcher_tx.send(WatcherEvent::RateLimitWarning {
|
watcher_tx
|
||||||
story_id: "365_story_rate_limit".to_string(),
|
.send(WatcherEvent::RateLimitWarning {
|
||||||
agent_name: "coder-1".to_string(),
|
story_id: "365_story_rate_limit".to_string(),
|
||||||
}).unwrap();
|
agent_name: "coder-1".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Give the spawned task time to process the event.
|
// Give the spawned task time to process the event.
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
@@ -475,9 +453,15 @@ mod tests {
|
|||||||
let (room_id, plain, _html) = &calls[0];
|
let (room_id, plain, _html) = &calls[0];
|
||||||
assert_eq!(room_id, "!room123:example.org");
|
assert_eq!(room_id, "!room123:example.org");
|
||||||
assert!(plain.contains("365"), "plain should contain story number");
|
assert!(plain.contains("365"), "plain should contain story number");
|
||||||
assert!(plain.contains("Rate Limit Test Story"), "plain should contain story name");
|
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("coder-1"), "plain should contain agent name");
|
||||||
assert!(plain.contains("rate limit"), "plain should mention rate limit");
|
assert!(
|
||||||
|
plain.contains("rate limit"),
|
||||||
|
"plain should mention rate limit"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AC4: a second RateLimitWarning for the same agent within the debounce
|
/// AC4: a second RateLimitWarning for the same agent within the debounce
|
||||||
@@ -498,16 +482,22 @@ mod tests {
|
|||||||
|
|
||||||
// Send the same warning twice in rapid succession.
|
// Send the same warning twice in rapid succession.
|
||||||
for _ in 0..2 {
|
for _ in 0..2 {
|
||||||
watcher_tx.send(WatcherEvent::RateLimitWarning {
|
watcher_tx
|
||||||
story_id: "42_story_debounce".to_string(),
|
.send(WatcherEvent::RateLimitWarning {
|
||||||
agent_name: "coder-2".to_string(),
|
story_id: "42_story_debounce".to_string(),
|
||||||
}).unwrap();
|
agent_name: "coder-2".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
let calls = calls.lock().unwrap();
|
let calls = calls.lock().unwrap();
|
||||||
assert_eq!(calls.len(), 1, "Debounce should suppress the second notification");
|
assert_eq!(
|
||||||
|
calls.len(),
|
||||||
|
1,
|
||||||
|
"Debounce should suppress the second notification"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AC4 (corollary): warnings for different agents are NOT debounced against
|
/// AC4 (corollary): warnings for different agents are NOT debounced against
|
||||||
@@ -526,19 +516,27 @@ mod tests {
|
|||||||
tmp.path().to_path_buf(),
|
tmp.path().to_path_buf(),
|
||||||
);
|
);
|
||||||
|
|
||||||
watcher_tx.send(WatcherEvent::RateLimitWarning {
|
watcher_tx
|
||||||
story_id: "42_story_foo".to_string(),
|
.send(WatcherEvent::RateLimitWarning {
|
||||||
agent_name: "coder-1".to_string(),
|
story_id: "42_story_foo".to_string(),
|
||||||
}).unwrap();
|
agent_name: "coder-1".to_string(),
|
||||||
watcher_tx.send(WatcherEvent::RateLimitWarning {
|
})
|
||||||
story_id: "42_story_foo".to_string(),
|
.unwrap();
|
||||||
agent_name: "coder-2".to_string(),
|
watcher_tx
|
||||||
}).unwrap();
|
.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;
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
let calls = calls.lock().unwrap();
|
let calls = calls.lock().unwrap();
|
||||||
assert_eq!(calls.len(), 2, "Different agents should each trigger a notification");
|
assert_eq!(
|
||||||
|
calls.len(),
|
||||||
|
2,
|
||||||
|
"Different agents should each trigger a notification"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── dynamic room IDs (WhatsApp ambient_rooms pattern) ───────────────────
|
// ── dynamic room IDs (WhatsApp ambient_rooms pattern) ───────────────────
|
||||||
@@ -550,13 +548,9 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn stage_notification_uses_dynamic_room_ids() {
|
async fn stage_notification_uses_dynamic_room_ids() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let stage_dir = tmp.path().join(".huskies").join("work").join("3_qa");
|
// Seed story via CRDT (the only source of truth).
|
||||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
crate::db::ensure_content_store();
|
||||||
std::fs::write(
|
crate::db::write_item_with_content("10_story_foo", "3_qa", "---\nname: Foo Story\n---\n");
|
||||||
stage_dir.join("10_story_foo.md"),
|
|
||||||
"---\nname: Foo Story\n---\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||||
let (transport, calls) = MockTransport::new();
|
let (transport, calls) = MockTransport::new();
|
||||||
@@ -573,25 +567,40 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Add a room after the listener is spawned (simulates a user messaging first).
|
// Add a room after the listener is spawned (simulates a user messaging first).
|
||||||
rooms.lock().unwrap().insert("phone:+15551234567".to_string());
|
rooms
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert("phone:+15551234567".to_string());
|
||||||
|
|
||||||
watcher_tx.send(WatcherEvent::WorkItem {
|
watcher_tx
|
||||||
stage: "3_qa".to_string(),
|
.send(WatcherEvent::WorkItem {
|
||||||
item_id: "10_story_foo".to_string(),
|
stage: "3_qa".to_string(),
|
||||||
action: "qa".to_string(),
|
item_id: "10_story_foo".to_string(),
|
||||||
commit_msg: "huskies: qa 10_story_foo".to_string(),
|
action: "qa".to_string(),
|
||||||
from_stage: Some("2_current".to_string()),
|
commit_msg: "huskies: qa 10_story_foo".to_string(),
|
||||||
}).unwrap();
|
from_stage: Some("2_current".to_string()),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Wait longer than STAGE_TRANSITION_DEBOUNCE (200ms) so the coalesced
|
// Wait longer than STAGE_TRANSITION_DEBOUNCE (200ms) so the coalesced
|
||||||
// notification flushes.
|
// notification flushes.
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
|
||||||
|
|
||||||
let calls = calls.lock().unwrap();
|
let calls = calls.lock().unwrap();
|
||||||
assert_eq!(calls.len(), 1, "Should deliver to the dynamically added room");
|
assert_eq!(
|
||||||
|
calls.len(),
|
||||||
|
1,
|
||||||
|
"Should deliver to the dynamically added room"
|
||||||
|
);
|
||||||
assert_eq!(calls[0].0, "phone:+15551234567");
|
assert_eq!(calls[0].0, "phone:+15551234567");
|
||||||
assert!(calls[0].1.contains("10"), "plain should contain story number");
|
assert!(
|
||||||
assert!(calls[0].1.contains("Foo Story"), "plain should contain story name");
|
calls[0].1.contains("10"),
|
||||||
|
"plain should contain story number"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
calls[0].1.contains("Foo Story"),
|
||||||
|
"plain should contain story name"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When no rooms are registered (e.g. no WhatsApp users have messaged yet),
|
/// When no rooms are registered (e.g. no WhatsApp users have messaged yet),
|
||||||
@@ -603,20 +612,17 @@ mod tests {
|
|||||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||||
let (transport, calls) = MockTransport::new();
|
let (transport, calls) = MockTransport::new();
|
||||||
|
|
||||||
spawn_notification_listener(
|
spawn_notification_listener(transport, Vec::new, watcher_rx, tmp.path().to_path_buf());
|
||||||
transport,
|
|
||||||
Vec::new,
|
|
||||||
watcher_rx,
|
|
||||||
tmp.path().to_path_buf(),
|
|
||||||
);
|
|
||||||
|
|
||||||
watcher_tx.send(WatcherEvent::WorkItem {
|
watcher_tx
|
||||||
stage: "3_qa".to_string(),
|
.send(WatcherEvent::WorkItem {
|
||||||
item_id: "10_story_foo".to_string(),
|
stage: "3_qa".to_string(),
|
||||||
action: "qa".to_string(),
|
item_id: "10_story_foo".to_string(),
|
||||||
commit_msg: "huskies: qa 10_story_foo".to_string(),
|
action: "qa".to_string(),
|
||||||
from_stage: Some("2_current".to_string()),
|
commit_msg: "huskies: qa 10_story_foo".to_string(),
|
||||||
}).unwrap();
|
from_stage: Some("2_current".to_string()),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
@@ -659,46 +665,37 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_story_name_reads_from_front_matter() {
|
fn read_story_name_reads_from_front_matter() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
crate::db::ensure_content_store();
|
||||||
let stage_dir = tmp
|
crate::db::write_item_with_content(
|
||||||
.path()
|
"9942_story_my_feature",
|
||||||
.join(".huskies")
|
"2_current",
|
||||||
.join("work")
|
|
||||||
.join("2_current");
|
|
||||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
|
||||||
std::fs::write(
|
|
||||||
stage_dir.join("42_story_my_feature.md"),
|
|
||||||
"---\nname: My Cool Feature\n---\n# Story\n",
|
"---\nname: My Cool Feature\n---\n# Story\n",
|
||||||
)
|
);
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let name = read_story_name(tmp.path(), "2_current", "42_story_my_feature");
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let name = read_story_name(tmp.path(), "2_current", "9942_story_my_feature");
|
||||||
assert_eq!(name.as_deref(), Some("My Cool Feature"));
|
assert_eq!(name.as_deref(), Some("My Cool Feature"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_story_name_returns_none_for_missing_file() {
|
fn read_story_name_returns_none_for_missing_file() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let name = read_story_name(tmp.path(), "2_current", "99_story_missing");
|
let name = read_story_name(tmp.path(), "2_current", "99_story_missing_notif_test");
|
||||||
assert_eq!(name, None);
|
assert_eq!(name, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_story_name_returns_none_for_missing_name_field() {
|
fn read_story_name_returns_none_for_missing_name_field() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
crate::db::ensure_content_store();
|
||||||
let stage_dir = tmp
|
crate::db::write_item_with_content(
|
||||||
.path()
|
"9943_story_no_name",
|
||||||
.join(".huskies")
|
"2_current",
|
||||||
.join("work")
|
|
||||||
.join("2_current");
|
|
||||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
|
||||||
std::fs::write(
|
|
||||||
stage_dir.join("42_story_no_name.md"),
|
|
||||||
"---\ncoverage_baseline: 50%\n---\n# Story\n",
|
"---\ncoverage_baseline: 50%\n---\n# Story\n",
|
||||||
)
|
);
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let name = read_story_name(tmp.path(), "2_current", "42_story_no_name");
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let name = read_story_name(tmp.path(), "2_current", "9943_story_no_name");
|
||||||
assert_eq!(name, None);
|
assert_eq!(name, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,8 +703,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_error_notification_with_story_name() {
|
fn format_error_notification_with_story_name() {
|
||||||
let (plain, html) =
|
let (plain, html) = format_error_notification(
|
||||||
format_error_notification("262_story_bot_errors", Some("Bot error notifications"), "merge conflict in src/main.rs");
|
"262_story_bot_errors",
|
||||||
|
Some("Bot error notifications"),
|
||||||
|
"merge conflict in src/main.rs",
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plain,
|
plain,
|
||||||
"\u{274c} #262 Bot error notifications \u{2014} merge conflict in src/main.rs"
|
"\u{274c} #262 Bot error notifications \u{2014} merge conflict in src/main.rs"
|
||||||
@@ -720,12 +720,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_error_notification_without_story_name_falls_back_to_item_id() {
|
fn format_error_notification_without_story_name_falls_back_to_item_id() {
|
||||||
let (plain, _html) =
|
let (plain, _html) = format_error_notification("42_bug_fix_thing", None, "tests failed");
|
||||||
format_error_notification("42_bug_fix_thing", None, "tests failed");
|
assert_eq!(plain, "\u{274c} #42 42_bug_fix_thing \u{2014} tests failed");
|
||||||
assert_eq!(
|
|
||||||
plain,
|
|
||||||
"\u{274c} #42 42_bug_fix_thing \u{2014} tests failed"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -759,8 +755,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_blocked_notification_falls_back_to_item_id() {
|
fn format_blocked_notification_falls_back_to_item_id() {
|
||||||
let (plain, _html) =
|
let (plain, _html) = format_blocked_notification("42_story_thing", None, "empty diff");
|
||||||
format_blocked_notification("42_story_thing", None, "empty diff");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plain,
|
plain,
|
||||||
"\u{1f6ab} #42 42_story_thing \u{2014} BLOCKED: empty diff"
|
"\u{1f6ab} #42 42_story_thing \u{2014} BLOCKED: empty diff"
|
||||||
@@ -774,13 +769,13 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn story_blocked_sends_notification_with_reason() {
|
async fn story_blocked_sends_notification_with_reason() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let stage_dir = tmp.path().join(".huskies").join("work").join("2_current");
|
// Seed story via CRDT (the only source of truth).
|
||||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
crate::db::ensure_content_store();
|
||||||
std::fs::write(
|
crate::db::write_item_with_content(
|
||||||
stage_dir.join("425_story_blocking_test.md"),
|
"425_story_blocking_test",
|
||||||
|
"2_current",
|
||||||
"---\nname: Blocking Test Story\n---\n",
|
"---\nname: Blocking Test Story\n---\n",
|
||||||
)
|
);
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||||
let (transport, calls) = MockTransport::new();
|
let (transport, calls) = MockTransport::new();
|
||||||
@@ -792,10 +787,12 @@ mod tests {
|
|||||||
tmp.path().to_path_buf(),
|
tmp.path().to_path_buf(),
|
||||||
);
|
);
|
||||||
|
|
||||||
watcher_tx.send(WatcherEvent::StoryBlocked {
|
watcher_tx
|
||||||
story_id: "425_story_blocking_test".to_string(),
|
.send(WatcherEvent::StoryBlocked {
|
||||||
reason: "Retry limit exceeded (3/3) at coder stage".to_string(),
|
story_id: "425_story_blocking_test".to_string(),
|
||||||
}).unwrap();
|
reason: "Retry limit exceeded (3/3) at coder stage".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
@@ -804,10 +801,22 @@ mod tests {
|
|||||||
let (room_id, plain, html) = &calls[0];
|
let (room_id, plain, html) = &calls[0];
|
||||||
assert_eq!(room_id, "!room123:example.org");
|
assert_eq!(room_id, "!room123:example.org");
|
||||||
assert!(plain.contains("425"), "plain should contain story number");
|
assert!(plain.contains("425"), "plain should contain story number");
|
||||||
assert!(plain.contains("Blocking Test Story"), "plain should contain story name");
|
assert!(
|
||||||
assert!(plain.contains("BLOCKED"), "plain should contain BLOCKED label");
|
plain.contains("Blocking Test Story"),
|
||||||
assert!(plain.contains("Retry limit exceeded"), "plain should contain the reason");
|
"plain should contain story name"
|
||||||
assert!(html.contains("BLOCKED"), "html should contain BLOCKED label");
|
);
|
||||||
|
assert!(
|
||||||
|
plain.contains("BLOCKED"),
|
||||||
|
"plain should contain BLOCKED label"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
plain.contains("Retry limit exceeded"),
|
||||||
|
"plain should contain the reason"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
html.contains("BLOCKED"),
|
||||||
|
"html should contain BLOCKED label"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// StoryBlocked with no room registered should not panic.
|
/// StoryBlocked with no room registered should not panic.
|
||||||
@@ -818,17 +827,14 @@ mod tests {
|
|||||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||||
let (transport, calls) = MockTransport::new();
|
let (transport, calls) = MockTransport::new();
|
||||||
|
|
||||||
spawn_notification_listener(
|
spawn_notification_listener(transport, Vec::new, watcher_rx, tmp.path().to_path_buf());
|
||||||
transport,
|
|
||||||
Vec::new,
|
|
||||||
watcher_rx,
|
|
||||||
tmp.path().to_path_buf(),
|
|
||||||
);
|
|
||||||
|
|
||||||
watcher_tx.send(WatcherEvent::StoryBlocked {
|
watcher_tx
|
||||||
story_id: "42_story_no_rooms".to_string(),
|
.send(WatcherEvent::StoryBlocked {
|
||||||
reason: "empty diff".to_string(),
|
story_id: "42_story_no_rooms".to_string(),
|
||||||
}).unwrap();
|
reason: "empty diff".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
@@ -840,11 +846,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_rate_limit_notification_includes_agent_and_story() {
|
fn format_rate_limit_notification_includes_agent_and_story() {
|
||||||
let (plain, html) = format_rate_limit_notification(
|
let (plain, html) =
|
||||||
"365_story_my_feature",
|
format_rate_limit_notification("365_story_my_feature", Some("My Feature"), "coder-2");
|
||||||
Some("My Feature"),
|
|
||||||
"coder-2",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plain,
|
plain,
|
||||||
"\u{26a0}\u{fe0f} #365 My Feature \u{2014} coder-2 hit an API rate limit"
|
"\u{26a0}\u{fe0f} #365 My Feature \u{2014} coder-2 hit an API rate limit"
|
||||||
@@ -857,8 +860,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_rate_limit_notification_falls_back_to_item_id() {
|
fn format_rate_limit_notification_falls_back_to_item_id() {
|
||||||
let (plain, _html) =
|
let (plain, _html) = format_rate_limit_notification("42_story_thing", None, "coder-1");
|
||||||
format_rate_limit_notification("42_story_thing", None, "coder-1");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plain,
|
plain,
|
||||||
"\u{26a0}\u{fe0f} #42 42_story_thing \u{2014} coder-1 hit an API rate limit"
|
"\u{26a0}\u{fe0f} #42 42_story_thing \u{2014} coder-1 hit an API rate limit"
|
||||||
@@ -869,12 +871,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_notification_done_stage_includes_party_emoji() {
|
fn format_notification_done_stage_includes_party_emoji() {
|
||||||
let (plain, html) = format_stage_notification(
|
let (plain, html) =
|
||||||
"353_story_done",
|
format_stage_notification("353_story_done", Some("Done Story"), "Merge", "Done");
|
||||||
Some("Done Story"),
|
|
||||||
"Merge",
|
|
||||||
"Done",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plain,
|
plain,
|
||||||
"\u{1f389} #353 Done Story \u{2014} Merge \u{2192} Done"
|
"\u{1f389} #353 Done Story \u{2014} Merge \u{2192} Done"
|
||||||
@@ -887,12 +885,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_notification_non_done_stage_has_no_emoji() {
|
fn format_notification_non_done_stage_has_no_emoji() {
|
||||||
let (plain, _html) = format_stage_notification(
|
let (plain, _html) =
|
||||||
"42_story_thing",
|
format_stage_notification("42_story_thing", Some("Some Story"), "Backlog", "Current");
|
||||||
Some("Some Story"),
|
|
||||||
"Backlog",
|
|
||||||
"Current",
|
|
||||||
);
|
|
||||||
assert!(!plain.contains("\u{1f389}"));
|
assert!(!plain.contains("\u{1f389}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,26 +910,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_notification_without_story_name_falls_back_to_item_id() {
|
fn format_notification_without_story_name_falls_back_to_item_id() {
|
||||||
let (plain, _html) = format_stage_notification(
|
let (plain, _html) = format_stage_notification("42_bug_fix_thing", None, "Current", "QA");
|
||||||
"42_bug_fix_thing",
|
assert_eq!(plain, "#42 42_bug_fix_thing \u{2014} Current \u{2192} QA");
|
||||||
None,
|
|
||||||
"Current",
|
|
||||||
"QA",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
plain,
|
|
||||||
"#42 42_bug_fix_thing \u{2014} Current \u{2192} QA"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_notification_non_numeric_id_uses_full_id() {
|
fn format_notification_non_numeric_id_uses_full_id() {
|
||||||
let (plain, _html) = format_stage_notification(
|
let (plain, _html) =
|
||||||
"abc_story_thing",
|
format_stage_notification("abc_story_thing", Some("Some Story"), "QA", "Merge");
|
||||||
Some("Some Story"),
|
|
||||||
"QA",
|
|
||||||
"Merge",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plain,
|
plain,
|
||||||
"#abc_story_thing Some Story \u{2014} QA \u{2192} Merge"
|
"#abc_story_thing Some Story \u{2014} QA \u{2192} Merge"
|
||||||
@@ -967,15 +949,21 @@ mod tests {
|
|||||||
tmp.path().to_path_buf(),
|
tmp.path().to_path_buf(),
|
||||||
);
|
);
|
||||||
|
|
||||||
watcher_tx.send(WatcherEvent::RateLimitWarning {
|
watcher_tx
|
||||||
story_id: "42_story_suppress".to_string(),
|
.send(WatcherEvent::RateLimitWarning {
|
||||||
agent_name: "coder-1".to_string(),
|
story_id: "42_story_suppress".to_string(),
|
||||||
}).unwrap();
|
agent_name: "coder-1".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
let calls = calls.lock().unwrap();
|
let calls = calls.lock().unwrap();
|
||||||
assert_eq!(calls.len(), 0, "RateLimitWarning should be suppressed when rate_limit_notifications = false");
|
assert_eq!(
|
||||||
|
calls.len(),
|
||||||
|
0,
|
||||||
|
"RateLimitWarning should be suppressed when rate_limit_notifications = false"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// RateLimitHardBlock is never posted to Matrix — it is logged server-side only.
|
/// RateLimitHardBlock is never posted to Matrix — it is logged server-side only.
|
||||||
@@ -994,11 +982,13 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let reset_at = chrono::Utc::now() + chrono::Duration::hours(1);
|
let reset_at = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||||
watcher_tx.send(WatcherEvent::RateLimitHardBlock {
|
watcher_tx
|
||||||
story_id: "42_story_hard_block".to_string(),
|
.send(WatcherEvent::RateLimitHardBlock {
|
||||||
agent_name: "coder-1".to_string(),
|
story_id: "42_story_hard_block".to_string(),
|
||||||
reset_at,
|
agent_name: "coder-1".to_string(),
|
||||||
}).unwrap();
|
reset_at,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
@@ -1028,10 +1018,12 @@ mod tests {
|
|||||||
tmp.path().to_path_buf(),
|
tmp.path().to_path_buf(),
|
||||||
);
|
);
|
||||||
|
|
||||||
watcher_tx.send(WatcherEvent::StoryBlocked {
|
watcher_tx
|
||||||
story_id: "42_story_blocked".to_string(),
|
.send(WatcherEvent::StoryBlocked {
|
||||||
reason: "retry limit exceeded".to_string(),
|
story_id: "42_story_blocked".to_string(),
|
||||||
}).unwrap();
|
reason: "retry limit exceeded".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
@@ -1064,10 +1056,12 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// First warning is sent.
|
// First warning is sent.
|
||||||
watcher_tx.send(WatcherEvent::RateLimitWarning {
|
watcher_tx
|
||||||
story_id: "42_story_reload".to_string(),
|
.send(WatcherEvent::RateLimitWarning {
|
||||||
agent_name: "coder-1".to_string(),
|
story_id: "42_story_reload".to_string(),
|
||||||
}).unwrap();
|
agent_name: "coder-1".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
// Disable notifications and trigger hot-reload.
|
// Disable notifications and trigger hot-reload.
|
||||||
@@ -1080,14 +1074,20 @@ mod tests {
|
|||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
// Second warning (different agent to bypass debounce) should be suppressed.
|
// Second warning (different agent to bypass debounce) should be suppressed.
|
||||||
watcher_tx.send(WatcherEvent::RateLimitWarning {
|
watcher_tx
|
||||||
story_id: "42_story_reload".to_string(),
|
.send(WatcherEvent::RateLimitWarning {
|
||||||
agent_name: "coder-2".to_string(),
|
story_id: "42_story_reload".to_string(),
|
||||||
}).unwrap();
|
agent_name: "coder-2".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
let calls = calls.lock().unwrap();
|
let calls = calls.lock().unwrap();
|
||||||
assert_eq!(calls.len(), 1, "Only the first warning should be sent; second should be suppressed after hot-reload");
|
assert_eq!(
|
||||||
|
calls.len(),
|
||||||
|
1,
|
||||||
|
"Only the first warning should be sent; second should be suppressed after hot-reload"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bug 549: synthetic events with from_stage=None must not notify ──────
|
// ── Bug 549: synthetic events with from_stage=None must not notify ──────
|
||||||
@@ -1111,19 +1111,22 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Synthetic reassign event within 4_merge — no actual stage change.
|
// Synthetic reassign event within 4_merge — no actual stage change.
|
||||||
watcher_tx.send(WatcherEvent::WorkItem {
|
watcher_tx
|
||||||
stage: "4_merge".to_string(),
|
.send(WatcherEvent::WorkItem {
|
||||||
item_id: "549_story_skip_qa".to_string(),
|
stage: "4_merge".to_string(),
|
||||||
action: "reassign".to_string(),
|
item_id: "549_story_skip_qa".to_string(),
|
||||||
commit_msg: String::new(),
|
action: "reassign".to_string(),
|
||||||
from_stage: None,
|
commit_msg: String::new(),
|
||||||
}).unwrap();
|
from_stage: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
|
||||||
|
|
||||||
let calls = calls.lock().unwrap();
|
let calls = calls.lock().unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
calls.len(), 0,
|
calls.len(),
|
||||||
|
0,
|
||||||
"Synthetic events with from_stage=None must not generate notifications"
|
"Synthetic events with from_stage=None must not generate notifications"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1152,13 +1155,15 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Story skips QA: from_stage is 2_current, not 3_qa.
|
// Story skips QA: from_stage is 2_current, not 3_qa.
|
||||||
watcher_tx.send(WatcherEvent::WorkItem {
|
watcher_tx
|
||||||
stage: "4_merge".to_string(),
|
.send(WatcherEvent::WorkItem {
|
||||||
item_id: "549_story_skip_qa".to_string(),
|
stage: "4_merge".to_string(),
|
||||||
action: "merge".to_string(),
|
item_id: "549_story_skip_qa".to_string(),
|
||||||
commit_msg: "huskies: merge 549_story_skip_qa".to_string(),
|
action: "merge".to_string(),
|
||||||
from_stage: Some("2_current".to_string()),
|
commit_msg: "huskies: merge 549_story_skip_qa".to_string(),
|
||||||
}).unwrap();
|
from_stage: Some("2_current".to_string()),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
|
||||||
|
|
||||||
|
|||||||
@@ -73,11 +73,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_with_full_user_id() {
|
fn extract_with_full_user_id() {
|
||||||
let cmd = extract_rebuild_command(
|
let cmd =
|
||||||
"@timmy:home.local rebuild",
|
extract_rebuild_command("@timmy:home.local rebuild", "Timmy", "@timmy:home.local");
|
||||||
"Timmy",
|
|
||||||
"@timmy:home.local",
|
|
||||||
);
|
|
||||||
assert_eq!(cmd, Some(RebuildCommand));
|
assert_eq!(cmd, Some(RebuildCommand));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ pub async fn handle_reset(
|
|||||||
) -> String {
|
) -> String {
|
||||||
{
|
{
|
||||||
let mut guard = history.lock().await;
|
let mut guard = history.lock().await;
|
||||||
let conv = guard.entry(room_id.clone()).or_insert_with(RoomConversation::default);
|
let conv = guard
|
||||||
|
.entry(room_id.clone())
|
||||||
|
.or_insert_with(RoomConversation::default);
|
||||||
conv.session_id = None;
|
conv.session_id = None;
|
||||||
conv.entries.clear();
|
conv.entries.clear();
|
||||||
crate::chat::transport::matrix::bot::save_history(project_root, &guard);
|
crate::chat::transport::matrix::bot::save_history(project_root, &guard);
|
||||||
@@ -75,8 +77,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_with_full_user_id() {
|
fn extract_with_full_user_id() {
|
||||||
let cmd =
|
let cmd = extract_reset_command("@timmy:home.local reset", "Timmy", "@timmy:home.local");
|
||||||
extract_reset_command("@timmy:home.local reset", "Timmy", "@timmy:home.local");
|
|
||||||
assert_eq!(cmd, Some(ResetCommand));
|
assert_eq!(cmd, Some(ResetCommand));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,21 +116,27 @@ mod tests {
|
|||||||
let room_id: OwnedRoomId = "!test:example.com".parse().unwrap();
|
let room_id: OwnedRoomId = "!test:example.com".parse().unwrap();
|
||||||
let history: ConversationHistory = Arc::new(TokioMutex::new({
|
let history: ConversationHistory = Arc::new(TokioMutex::new({
|
||||||
let mut m = HashMap::new();
|
let mut m = HashMap::new();
|
||||||
m.insert(room_id.clone(), RoomConversation {
|
m.insert(
|
||||||
session_id: Some("old-session-id".to_string()),
|
room_id.clone(),
|
||||||
entries: vec![ConversationEntry {
|
RoomConversation {
|
||||||
role: ConversationRole::User,
|
session_id: Some("old-session-id".to_string()),
|
||||||
sender: "@alice:example.com".to_string(),
|
entries: vec![ConversationEntry {
|
||||||
content: "previous message".to_string(),
|
role: ConversationRole::User,
|
||||||
}],
|
sender: "@alice:example.com".to_string(),
|
||||||
});
|
content: "previous message".to_string(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
);
|
||||||
m
|
m
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let response = handle_reset("Timmy", &room_id, &history, tmp.path()).await;
|
let response = handle_reset("Timmy", &room_id, &history, tmp.path()).await;
|
||||||
|
|
||||||
assert!(response.contains("reset"), "response should mention reset: {response}");
|
assert!(
|
||||||
|
response.contains("reset"),
|
||||||
|
"response should mention reset: {response}"
|
||||||
|
);
|
||||||
|
|
||||||
let guard = history.lock().await;
|
let guard = history.lock().await;
|
||||||
let conv = guard.get(&room_id).unwrap();
|
let conv = guard.get(&room_id).unwrap();
|
||||||
|
|||||||
@@ -107,9 +107,7 @@ pub async fn handle_rmtree(
|
|||||||
return format!("Failed to remove worktree for story {story_number}: {e}");
|
return format!("Failed to remove worktree for story {story_number}: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
crate::slog!(
|
crate::slog!("[matrix-bot] rmtree command: removed worktree for {story_id} (bot={bot_name})");
|
||||||
"[matrix-bot] rmtree command: removed worktree for {story_id} (bot={bot_name})"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut response = format!("Removed worktree for **{story_id}**.");
|
let mut response = format!("Removed worktree for **{story_id}**.");
|
||||||
if !stopped_agents.is_empty() {
|
if !stopped_agents.is_empty() {
|
||||||
@@ -131,11 +129,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_with_full_user_id() {
|
fn extract_with_full_user_id() {
|
||||||
let cmd = extract_rmtree_command(
|
let cmd =
|
||||||
"@timmy:home.local rmtree 42",
|
extract_rmtree_command("@timmy:home.local rmtree 42", "Timmy", "@timmy:home.local");
|
||||||
"Timmy",
|
|
||||||
"@timmy:home.local",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cmd,
|
cmd,
|
||||||
Some(RmtreeCommand::Rmtree {
|
Some(RmtreeCommand::Rmtree {
|
||||||
|
|||||||
@@ -84,9 +84,7 @@ pub async fn handle_start(
|
|||||||
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
||||||
Some(found) => found,
|
Some(found) => found,
|
||||||
None => {
|
None => {
|
||||||
return format!(
|
return format!("No story, bug, or spike with number **{story_number}** found.");
|
||||||
"No story, bug, or spike with number **{story_number}** found."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,7 +113,13 @@ pub async fn handle_start(
|
|||||||
);
|
);
|
||||||
|
|
||||||
match agents
|
match agents
|
||||||
.start_agent(project_root, &story_id, resolved_agent.as_deref(), None, None)
|
.start_agent(
|
||||||
|
project_root,
|
||||||
|
&story_id,
|
||||||
|
resolved_agent.as_deref(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(info) => {
|
Ok(info) => {
|
||||||
@@ -231,7 +235,14 @@ mod tests {
|
|||||||
async fn handle_start_returns_not_found_for_unknown_number() {
|
async fn handle_start_returns_not_found_for_unknown_number() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let project_root = tmp.path();
|
let project_root = tmp.path();
|
||||||
for stage in &["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"] {
|
for stage in &[
|
||||||
|
"1_backlog",
|
||||||
|
"2_current",
|
||||||
|
"3_qa",
|
||||||
|
"4_merge",
|
||||||
|
"5_done",
|
||||||
|
"6_archived",
|
||||||
|
] {
|
||||||
std::fs::create_dir_all(project_root.join(".huskies").join("work").join(stage))
|
std::fs::create_dir_all(project_root.join(".huskies").join("work").join(stage))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
@@ -276,7 +287,8 @@ mod tests {
|
|||||||
"response must not say 'Failed' when coders are busy: {response}"
|
"response must not say 'Failed' when coders are busy: {response}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
response.to_lowercase().contains("queue") || response.to_lowercase().contains("available"),
|
response.to_lowercase().contains("queue")
|
||||||
|
|| response.to_lowercase().contains("available"),
|
||||||
"response must mention queued/available state: {response}"
|
"response must mention queued/available state: {response}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
//! Slack incoming message dispatch and slash command handling.
|
//! Slack incoming message dispatch and slash command handling.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::{Mutex as TokioMutex, oneshot};
|
use tokio::sync::{Mutex as TokioMutex, oneshot};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
|
use super::format::markdown_to_slack;
|
||||||
|
use super::history::{SlackConversationHistory, save_slack_history};
|
||||||
|
use super::meta::SlackTransport;
|
||||||
use crate::agents::AgentPool;
|
use crate::agents::AgentPool;
|
||||||
|
use crate::chat::ChatTransport;
|
||||||
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
||||||
use crate::chat::util::is_permission_approval;
|
use crate::chat::util::is_permission_approval;
|
||||||
use crate::slog;
|
|
||||||
use crate::chat::ChatTransport;
|
|
||||||
use crate::http::context::{PermissionDecision, PermissionForward};
|
use crate::http::context::{PermissionDecision, PermissionForward};
|
||||||
use super::meta::SlackTransport;
|
use crate::slog;
|
||||||
use super::history::{SlackConversationHistory, save_slack_history};
|
|
||||||
use super::format::markdown_to_slack;
|
|
||||||
|
|
||||||
// ── Slash command types ─────────────────────────────────────────────────
|
// ── Slash command types ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -81,8 +81,7 @@ pub struct SlackWebhookContext {
|
|||||||
/// Permission requests from the MCP `prompt_permission` tool arrive here.
|
/// Permission requests from the MCP `prompt_permission` tool arrive here.
|
||||||
pub perm_rx: Arc<TokioMutex<tokio::sync::mpsc::UnboundedReceiver<PermissionForward>>>,
|
pub perm_rx: Arc<TokioMutex<tokio::sync::mpsc::UnboundedReceiver<PermissionForward>>>,
|
||||||
/// Pending permission replies keyed by channel ID.
|
/// Pending permission replies keyed by channel ID.
|
||||||
pub pending_perm_replies:
|
pub pending_perm_replies: Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionDecision>>>>,
|
||||||
Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionDecision>>>>,
|
|
||||||
/// Seconds before an unanswered permission prompt is auto-denied.
|
/// Seconds before an unanswered permission prompt is auto-denied.
|
||||||
pub permission_timeout_secs: u64,
|
pub permission_timeout_secs: u64,
|
||||||
}
|
}
|
||||||
@@ -154,8 +153,11 @@ pub(super) async fn handle_incoming_message(
|
|||||||
}
|
}
|
||||||
HtopCommand::Start { duration_secs } => {
|
HtopCommand::Start { duration_secs } => {
|
||||||
// On Slack, htop uses native message editing for live updates.
|
// On Slack, htop uses native message editing for live updates.
|
||||||
let snapshot =
|
let snapshot = crate::chat::transport::matrix::htop::build_htop_message(
|
||||||
crate::chat::transport::matrix::htop::build_htop_message(&ctx.agents, 0, duration_secs);
|
&ctx.agents,
|
||||||
|
0,
|
||||||
|
duration_secs,
|
||||||
|
);
|
||||||
let snapshot = markdown_to_slack(&snapshot);
|
let snapshot = markdown_to_slack(&snapshot);
|
||||||
let msg_id = match ctx.transport.send_message(channel, &snapshot, "").await {
|
let msg_id = match ctx.transport.send_message(channel, &snapshot, "").await {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
@@ -179,9 +181,7 @@ pub(super) async fn handle_incoming_message(
|
|||||||
duration_secs,
|
duration_secs,
|
||||||
);
|
);
|
||||||
let updated = markdown_to_slack(&updated);
|
let updated = markdown_to_slack(&updated);
|
||||||
if let Err(e) =
|
if let Err(e) = transport.edit_message(&ch, &msg_id, &updated, "").await {
|
||||||
transport.edit_message(&ch, &msg_id, &updated, "").await
|
|
||||||
{
|
|
||||||
slog!("[slack] Failed to edit htop message: {e}");
|
slog!("[slack] Failed to edit htop message: {e}");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -245,7 +245,9 @@ pub(super) async fn handle_incoming_message(
|
|||||||
) {
|
) {
|
||||||
let response = match rmtree_cmd {
|
let response = match rmtree_cmd {
|
||||||
crate::chat::transport::matrix::rmtree::RmtreeCommand::Rmtree { story_number } => {
|
crate::chat::transport::matrix::rmtree::RmtreeCommand::Rmtree { story_number } => {
|
||||||
slog!("[slack] Handling rmtree command from {user} in {channel}: story {story_number}");
|
slog!(
|
||||||
|
"[slack] Handling rmtree command from {user} in {channel}: story {story_number}"
|
||||||
|
);
|
||||||
crate::chat::transport::matrix::rmtree::handle_rmtree(
|
crate::chat::transport::matrix::rmtree::handle_rmtree(
|
||||||
&ctx.bot_name,
|
&ctx.bot_name,
|
||||||
&story_number,
|
&story_number,
|
||||||
@@ -273,7 +275,9 @@ pub(super) async fn handle_incoming_message(
|
|||||||
slog!("[slack] Handling reset command from {user} in {channel}");
|
slog!("[slack] Handling reset command from {user} in {channel}");
|
||||||
{
|
{
|
||||||
let mut guard = ctx.history.lock().await;
|
let mut guard = ctx.history.lock().await;
|
||||||
let conv = guard.entry(channel.to_string()).or_insert_with(RoomConversation::default);
|
let conv = guard
|
||||||
|
.entry(channel.to_string())
|
||||||
|
.or_insert_with(RoomConversation::default);
|
||||||
conv.session_id = None;
|
conv.session_id = None;
|
||||||
conv.entries.clear();
|
conv.entries.clear();
|
||||||
save_slack_history(&ctx.project_root, &guard);
|
save_slack_history(&ctx.project_root, &guard);
|
||||||
@@ -295,7 +299,9 @@ pub(super) async fn handle_incoming_message(
|
|||||||
story_number,
|
story_number,
|
||||||
agent_hint,
|
agent_hint,
|
||||||
} => {
|
} => {
|
||||||
slog!("[slack] Handling start command from {user} in {channel}: story {story_number}");
|
slog!(
|
||||||
|
"[slack] Handling start command from {user} in {channel}: story {story_number}"
|
||||||
|
);
|
||||||
crate::chat::transport::matrix::start::handle_start(
|
crate::chat::transport::matrix::start::handle_start(
|
||||||
&ctx.bot_name,
|
&ctx.bot_name,
|
||||||
&story_number,
|
&story_number,
|
||||||
@@ -320,8 +326,13 @@ pub(super) async fn handle_incoming_message(
|
|||||||
&ctx.bot_user_id,
|
&ctx.bot_user_id,
|
||||||
) {
|
) {
|
||||||
let response = match assign_cmd {
|
let response = match assign_cmd {
|
||||||
crate::chat::transport::matrix::assign::AssignCommand::Assign { story_number, model } => {
|
crate::chat::transport::matrix::assign::AssignCommand::Assign {
|
||||||
slog!("[slack] Handling assign command from {user} in {channel}: story {story_number} model {model}");
|
story_number,
|
||||||
|
model,
|
||||||
|
} => {
|
||||||
|
slog!(
|
||||||
|
"[slack] Handling assign command from {user} in {channel}: story {story_number} model {model}"
|
||||||
|
);
|
||||||
crate::chat::transport::matrix::assign::handle_assign(
|
crate::chat::transport::matrix::assign::handle_assign(
|
||||||
&ctx.bot_name,
|
&ctx.bot_name,
|
||||||
&story_number,
|
&story_number,
|
||||||
@@ -352,17 +363,15 @@ async fn handle_llm_message(
|
|||||||
user: &str,
|
user: &str,
|
||||||
user_message: &str,
|
user_message: &str,
|
||||||
) {
|
) {
|
||||||
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
|
||||||
use crate::chat::util::drain_complete_paragraphs;
|
use crate::chat::util::drain_complete_paragraphs;
|
||||||
|
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
|
|
||||||
// Look up existing session ID for this channel.
|
// Look up existing session ID for this channel.
|
||||||
let resume_session_id: Option<String> = {
|
let resume_session_id: Option<String> = {
|
||||||
let guard = ctx.history.lock().await;
|
let guard = ctx.history.lock().await;
|
||||||
guard
|
guard.get(channel).and_then(|conv| conv.session_id.clone())
|
||||||
.get(channel)
|
|
||||||
.and_then(|conv| conv.session_id.clone())
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let bot_name = &ctx.bot_name;
|
let bot_name = &ctx.bot_name;
|
||||||
@@ -383,7 +392,9 @@ async fn handle_llm_message(
|
|||||||
let post_task = tokio::spawn(async move {
|
let post_task = tokio::spawn(async move {
|
||||||
while let Some(chunk) = msg_rx.recv().await {
|
while let Some(chunk) = msg_rx.recv().await {
|
||||||
let formatted = markdown_to_slack(&chunk);
|
let formatted = markdown_to_slack(&chunk);
|
||||||
let _ = post_transport.send_message(&post_channel, &formatted, "").await;
|
let _ = post_transport
|
||||||
|
.send_message(&post_channel, &formatted, "")
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -472,9 +483,7 @@ async fn handle_llm_message(
|
|||||||
let last_text = messages
|
let last_text = messages
|
||||||
.iter()
|
.iter()
|
||||||
.rev()
|
.rev()
|
||||||
.find(|m| {
|
.find(|m| m.role == crate::llm::types::Role::Assistant && !m.content.is_empty())
|
||||||
m.role == crate::llm::types::Role::Assistant && !m.content.is_empty()
|
|
||||||
})
|
|
||||||
.map(|m| m.content.clone())
|
.map(|m| m.content.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if !last_text.is_empty() {
|
if !last_text.is_empty() {
|
||||||
@@ -559,7 +568,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn slash_command_maps_status() {
|
fn slash_command_maps_status() {
|
||||||
assert_eq!(slash_command_to_bot_keyword("/huskies-status"), Some("status"));
|
assert_eq!(
|
||||||
|
slash_command_to_bot_keyword("/huskies-status"),
|
||||||
|
Some("status")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -600,9 +612,8 @@ mod tests {
|
|||||||
response_type: "ephemeral",
|
response_type: "ephemeral",
|
||||||
text: "hello".to_string(),
|
text: "hello".to_string(),
|
||||||
};
|
};
|
||||||
let json: serde_json::Value = serde_json::from_str(
|
let json: serde_json::Value =
|
||||||
&serde_json::to_string(&resp).unwrap()
|
serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
|
||||||
).unwrap();
|
|
||||||
assert_eq!(json["response_type"], "ephemeral");
|
assert_eq!(json["response_type"], "ephemeral");
|
||||||
assert_eq!(json["text"], "hello");
|
assert_eq!(json["text"], "hello");
|
||||||
}
|
}
|
||||||
@@ -642,7 +653,10 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let result = try_handle_command(&dispatch, &synthetic);
|
let result = try_handle_command(&dispatch, &synthetic);
|
||||||
assert!(result.is_some(), "status slash command should produce output via registry");
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"status slash command should produce output via registry"
|
||||||
|
);
|
||||||
assert!(result.unwrap().contains("Pipeline Status"));
|
assert!(result.unwrap().contains("Pipeline Status"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -671,7 +685,10 @@ mod tests {
|
|||||||
let result = try_handle_command(&dispatch, &synthetic);
|
let result = try_handle_command(&dispatch, &synthetic);
|
||||||
assert!(result.is_some(), "show slash command should produce output");
|
assert!(result.is_some(), "show slash command should produce output");
|
||||||
let output = result.unwrap();
|
let output = result.unwrap();
|
||||||
assert!(output.contains("999"), "show output should reference the story number: {output}");
|
assert!(
|
||||||
|
output.contains("999"),
|
||||||
|
"show output should reference the story number: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── rebuild command extraction ─────────────────────────────────────
|
// ── rebuild command extraction ─────────────────────────────────────
|
||||||
@@ -704,7 +721,10 @@ mod tests {
|
|||||||
"Huskies",
|
"Huskies",
|
||||||
"slack-bot",
|
"slack-bot",
|
||||||
);
|
);
|
||||||
assert!(result.is_none(), "'status' should not be recognised as rebuild");
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"'status' should not be recognised as rebuild"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── reset command extraction ───────────────────────────────────────
|
// ── reset command extraction ───────────────────────────────────────
|
||||||
@@ -731,21 +751,26 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn reset_command_clears_slack_session() {
|
async fn reset_command_clears_slack_session() {
|
||||||
|
use crate::chat::transport::matrix::{
|
||||||
|
ConversationEntry, ConversationRole, RoomConversation,
|
||||||
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
|
||||||
|
|
||||||
let channel = "C01ABCDEF";
|
let channel = "C01ABCDEF";
|
||||||
let history: SlackConversationHistory = Arc::new(TokioMutex::new({
|
let history: SlackConversationHistory = Arc::new(TokioMutex::new({
|
||||||
let mut m = HashMap::new();
|
let mut m = HashMap::new();
|
||||||
m.insert(channel.to_string(), RoomConversation {
|
m.insert(
|
||||||
session_id: Some("old-session".to_string()),
|
channel.to_string(),
|
||||||
entries: vec![ConversationEntry {
|
RoomConversation {
|
||||||
role: ConversationRole::User,
|
session_id: Some("old-session".to_string()),
|
||||||
sender: "U01GHIJKL".to_string(),
|
entries: vec![ConversationEntry {
|
||||||
content: "previous message".to_string(),
|
role: ConversationRole::User,
|
||||||
}],
|
sender: "U01GHIJKL".to_string(),
|
||||||
});
|
content: "previous message".to_string(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
);
|
||||||
m
|
m
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -755,7 +780,9 @@ mod tests {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut guard = history.lock().await;
|
let mut guard = history.lock().await;
|
||||||
let conv = guard.entry(channel.to_string()).or_insert_with(RoomConversation::default);
|
let conv = guard
|
||||||
|
.entry(channel.to_string())
|
||||||
|
.or_insert_with(RoomConversation::default);
|
||||||
conv.session_id = None;
|
conv.session_id = None;
|
||||||
conv.entries.clear();
|
conv.entries.clear();
|
||||||
save_slack_history(tmp.path(), &guard);
|
save_slack_history(tmp.path(), &guard);
|
||||||
@@ -862,6 +889,9 @@ mod tests {
|
|||||||
"Timmy",
|
"Timmy",
|
||||||
"@timmy:home.local",
|
"@timmy:home.local",
|
||||||
);
|
);
|
||||||
assert!(result.is_none(), "'status' should not be recognised as assign on Slack");
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"'status' should not be recognised as assign on Slack"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,8 @@ pub fn markdown_to_slack(text: &str) -> String {
|
|||||||
LazyLock::new(|| Regex::new(r"(?m)^#{1,6}\s+(.+)$").unwrap());
|
LazyLock::new(|| Regex::new(r"(?m)^#{1,6}\s+(.+)$").unwrap());
|
||||||
static RE_BOLD_ITALIC: LazyLock<Regex> =
|
static RE_BOLD_ITALIC: LazyLock<Regex> =
|
||||||
LazyLock::new(|| Regex::new(r"\*\*\*(.+?)\*\*\*").unwrap());
|
LazyLock::new(|| Regex::new(r"\*\*\*(.+?)\*\*\*").unwrap());
|
||||||
static RE_BOLD: LazyLock<Regex> =
|
static RE_BOLD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
|
||||||
LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
|
static RE_STRIKETHROUGH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap());
|
||||||
static RE_STRIKETHROUGH: LazyLock<Regex> =
|
|
||||||
LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap());
|
|
||||||
static RE_LINK: LazyLock<Regex> =
|
static RE_LINK: LazyLock<Regex> =
|
||||||
LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
|
LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
|
||||||
|
|
||||||
@@ -105,8 +103,14 @@ mod tests {
|
|||||||
fn slack_fenced_code_block_preserved() {
|
fn slack_fenced_code_block_preserved() {
|
||||||
let input = "```rust\nlet x = 1;\n```";
|
let input = "```rust\nlet x = 1;\n```";
|
||||||
let output = markdown_to_slack(input);
|
let output = markdown_to_slack(input);
|
||||||
assert!(output.contains("let x = 1;"), "code block content must be preserved");
|
assert!(
|
||||||
assert!(output.contains("```"), "fenced code delimiters must be preserved");
|
output.contains("let x = 1;"),
|
||||||
|
"code block content must be preserved"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains("```"),
|
||||||
|
"fenced code delimiters must be preserved"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -104,9 +104,8 @@ impl ChatTransport for SlackTransport {
|
|||||||
return Err(format!("Slack API returned {status}: {resp_text}"));
|
return Err(format!("Slack API returned {status}: {resp_text}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: SlackApiResponse = serde_json::from_str(&resp_text).map_err(|e| {
|
let parsed: SlackApiResponse = serde_json::from_str(&resp_text)
|
||||||
format!("Failed to parse Slack API response: {e} — body: {resp_text}")
|
.map_err(|e| format!("Failed to parse Slack API response: {e} — body: {resp_text}"))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
if !parsed.ok {
|
if !parsed.ok {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -190,10 +189,7 @@ mod tests {
|
|||||||
.create_async()
|
.create_async()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let transport = SlackTransport::with_api_base(
|
let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url());
|
||||||
"xoxb-test-token".to_string(),
|
|
||||||
server.url(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = transport
|
let result = transport
|
||||||
.send_message("C01ABCDEF", "hello", "<p>hello</p>")
|
.send_message("C01ABCDEF", "hello", "<p>hello</p>")
|
||||||
@@ -212,14 +208,9 @@ mod tests {
|
|||||||
.create_async()
|
.create_async()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let transport = SlackTransport::with_api_base(
|
let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url());
|
||||||
"xoxb-test-token".to_string(),
|
|
||||||
server.url(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = transport
|
let result = transport.send_message("C_INVALID", "hello", "").await;
|
||||||
.send_message("C_INVALID", "hello", "")
|
|
||||||
.await;
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert!(
|
assert!(
|
||||||
result.unwrap_err().contains("channel_not_found"),
|
result.unwrap_err().contains("channel_not_found"),
|
||||||
@@ -237,10 +228,7 @@ mod tests {
|
|||||||
.create_async()
|
.create_async()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let transport = SlackTransport::with_api_base(
|
let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url());
|
||||||
"xoxb-test-token".to_string(),
|
|
||||||
server.url(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = transport
|
let result = transport
|
||||||
.edit_message("C01ABCDEF", "1234567890.123456", "updated", "")
|
.edit_message("C01ABCDEF", "1234567890.123456", "updated", "")
|
||||||
@@ -258,10 +246,7 @@ mod tests {
|
|||||||
.create_async()
|
.create_async()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let transport = SlackTransport::with_api_base(
|
let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url());
|
||||||
"xoxb-test-token".to_string(),
|
|
||||||
server.url(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = transport
|
let result = transport
|
||||||
.edit_message("C01ABCDEF", "bad-ts", "updated", "")
|
.edit_message("C01ABCDEF", "bad-ts", "updated", "")
|
||||||
@@ -287,10 +272,7 @@ mod tests {
|
|||||||
.create_async()
|
.create_async()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let transport = SlackTransport::with_api_base(
|
let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url());
|
||||||
"xoxb-test-token".to_string(),
|
|
||||||
server.url(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = transport.send_message("C01ABCDEF", "hello", "").await;
|
let result = transport.send_message("C01ABCDEF", "hello", "").await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ pub mod history;
|
|||||||
pub mod meta;
|
pub mod meta;
|
||||||
pub mod verify;
|
pub mod verify;
|
||||||
|
|
||||||
|
pub use commands::SlackWebhookContext;
|
||||||
|
pub use format::markdown_to_slack;
|
||||||
pub use history::load_slack_history;
|
pub use history::load_slack_history;
|
||||||
pub use meta::SlackTransport;
|
pub use meta::SlackTransport;
|
||||||
pub use format::markdown_to_slack;
|
|
||||||
pub use commands::SlackWebhookContext;
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use poem::{Request, Response, handler, http::StatusCode};
|
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
|
use poem::{Request, Response, handler, http::StatusCode};
|
||||||
|
|
||||||
// ── Slack Events API types ──────────────────────────────────────────────
|
// ── Slack Events API types ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -71,10 +71,7 @@ pub async fn webhook_receive(
|
|||||||
.header("X-Slack-Request-Timestamp")
|
.header("X-Slack-Request-Timestamp")
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
let signature = req
|
let signature = req.header("X-Slack-Signature").unwrap_or("").to_string();
|
||||||
.header("X-Slack-Signature")
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let bytes = match body.into_bytes().await {
|
let bytes = match body.into_bytes().await {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
@@ -98,9 +95,7 @@ pub async fn webhook_receive(
|
|||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
slog!("[slack] Failed to parse webhook payload: {e}");
|
slog!("[slack] Failed to parse webhook payload: {e}");
|
||||||
return Response::builder()
|
return Response::builder().status(StatusCode::OK).body("ok");
|
||||||
.status(StatusCode::OK)
|
|
||||||
.body("ok");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,8 +119,7 @@ pub async fn webhook_receive(
|
|||||||
&& event.r#type.as_deref() == Some("message")
|
&& event.r#type.as_deref() == Some("message")
|
||||||
&& event.subtype.is_none()
|
&& event.subtype.is_none()
|
||||||
&& event.bot_id.is_none()
|
&& event.bot_id.is_none()
|
||||||
&& let (Some(channel), Some(user), Some(text)) =
|
&& let (Some(channel), Some(user), Some(text)) = (event.channel, event.user, event.text)
|
||||||
(event.channel, event.user, event.text)
|
|
||||||
&& ctx.channel_ids.contains(&channel)
|
&& ctx.channel_ids.contains(&channel)
|
||||||
{
|
{
|
||||||
let ctx = Arc::clone(*ctx);
|
let ctx = Arc::clone(*ctx);
|
||||||
@@ -135,9 +129,7 @@ pub async fn webhook_receive(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Response::builder()
|
Response::builder().status(StatusCode::OK).body("ok")
|
||||||
.status(StatusCode::OK)
|
|
||||||
.body("ok")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /webhook/slack/command — receive incoming Slack slash commands.
|
/// POST /webhook/slack/command — receive incoming Slack slash commands.
|
||||||
@@ -155,10 +147,7 @@ pub async fn slash_command_receive(
|
|||||||
.header("X-Slack-Request-Timestamp")
|
.header("X-Slack-Request-Timestamp")
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
let signature = req
|
let signature = req.header("X-Slack-Signature").unwrap_or("").to_string();
|
||||||
.header("X-Slack-Signature")
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let bytes = match body.into_bytes().await {
|
let bytes = match body.into_bytes().await {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
@@ -178,16 +167,15 @@ pub async fn slash_command_receive(
|
|||||||
.body("Invalid signature");
|
.body("Invalid signature");
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload: commands::SlackSlashCommandPayload =
|
let payload: commands::SlackSlashCommandPayload = match serde_urlencoded::from_bytes(&bytes) {
|
||||||
match serde_urlencoded::from_bytes(&bytes) {
|
Ok(p) => p,
|
||||||
Ok(p) => p,
|
Err(e) => {
|
||||||
Err(e) => {
|
slog!("[slack] Failed to parse slash command payload: {e}");
|
||||||
slog!("[slack] Failed to parse slash command payload: {e}");
|
return Response::builder()
|
||||||
return Response::builder()
|
.status(StatusCode::BAD_REQUEST)
|
||||||
.status(StatusCode::BAD_REQUEST)
|
.body("Bad request");
|
||||||
.body("Bad request");
|
}
|
||||||
}
|
};
|
||||||
};
|
|
||||||
|
|
||||||
slog!(
|
slog!(
|
||||||
"[slack] Slash command from {}: {} {}",
|
"[slack] Slash command from {}: {} {}",
|
||||||
|
|||||||
@@ -215,7 +215,12 @@ mod tests {
|
|||||||
let body = b"test body";
|
let body = b"test body";
|
||||||
|
|
||||||
let sig = compute_test_signature("correct-secret", timestamp, body);
|
let sig = compute_test_signature("correct-secret", timestamp, body);
|
||||||
assert!(!verify_slack_signature("wrong-secret", timestamp, body, &sig));
|
assert!(!verify_slack_signature(
|
||||||
|
"wrong-secret",
|
||||||
|
timestamp,
|
||||||
|
body,
|
||||||
|
&sig
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to compute a test signature using our sha256 + HMAC implementation.
|
/// Helper to compute a test signature using our sha256 + HMAC implementation.
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
//! WhatsApp command handling — processes incoming WhatsApp messages as bot commands.
|
//! WhatsApp command handling — processes incoming WhatsApp messages as bot commands.
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
|
||||||
use crate::chat::util::is_permission_approval;
|
|
||||||
use crate::http::context::{PermissionDecision};
|
|
||||||
use crate::slog;
|
|
||||||
use super::WhatsAppWebhookContext;
|
use super::WhatsAppWebhookContext;
|
||||||
use super::format::{chunk_for_whatsapp, markdown_to_whatsapp};
|
use super::format::{chunk_for_whatsapp, markdown_to_whatsapp};
|
||||||
use super::history::save_whatsapp_history;
|
use super::history::save_whatsapp_history;
|
||||||
|
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
||||||
|
use crate::chat::util::is_permission_approval;
|
||||||
|
use crate::http::context::PermissionDecision;
|
||||||
|
use crate::slog;
|
||||||
|
|
||||||
/// Dispatch an incoming WhatsApp message to bot commands.
|
/// Dispatch an incoming WhatsApp message to bot commands.
|
||||||
pub(super) async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender: &str, message: &str) {
|
pub(super) async fn handle_incoming_message(
|
||||||
|
ctx: &WhatsAppWebhookContext,
|
||||||
|
sender: &str,
|
||||||
|
message: &str,
|
||||||
|
) {
|
||||||
use crate::chat::commands::{CommandDispatch, try_handle_command};
|
use crate::chat::commands::{CommandDispatch, try_handle_command};
|
||||||
|
|
||||||
// Allowlist check: when configured, silently ignore unauthorized senders.
|
// Allowlist check: when configured, silently ignore unauthorized senders.
|
||||||
if !ctx.allowed_phones.is_empty()
|
if !ctx.allowed_phones.is_empty() && !ctx.allowed_phones.iter().any(|p| p == sender) {
|
||||||
&& !ctx.allowed_phones.iter().any(|p| p == sender)
|
|
||||||
{
|
|
||||||
slog!("[whatsapp] Ignoring message from unauthorized sender: {sender}");
|
slog!("[whatsapp] Ignoring message from unauthorized sender: {sender}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -173,7 +175,9 @@ pub(super) async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender
|
|||||||
slog!("[whatsapp] Handling reset command from {sender}");
|
slog!("[whatsapp] Handling reset command from {sender}");
|
||||||
{
|
{
|
||||||
let mut guard = ctx.history.lock().await;
|
let mut guard = ctx.history.lock().await;
|
||||||
let conv = guard.entry(sender.to_string()).or_insert_with(RoomConversation::default);
|
let conv = guard
|
||||||
|
.entry(sender.to_string())
|
||||||
|
.or_insert_with(RoomConversation::default);
|
||||||
conv.session_id = None;
|
conv.session_id = None;
|
||||||
conv.entries.clear();
|
conv.entries.clear();
|
||||||
save_whatsapp_history(&ctx.project_root, &guard);
|
save_whatsapp_history(&ctx.project_root, &guard);
|
||||||
@@ -219,8 +223,13 @@ pub(super) async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender
|
|||||||
&ctx.bot_user_id,
|
&ctx.bot_user_id,
|
||||||
) {
|
) {
|
||||||
let response = match assign_cmd {
|
let response = match assign_cmd {
|
||||||
crate::chat::transport::matrix::assign::AssignCommand::Assign { story_number, model } => {
|
crate::chat::transport::matrix::assign::AssignCommand::Assign {
|
||||||
slog!("[whatsapp] Handling assign command from {sender}: story {story_number} model {model}");
|
story_number,
|
||||||
|
model,
|
||||||
|
} => {
|
||||||
|
slog!(
|
||||||
|
"[whatsapp] Handling assign command from {sender}: story {story_number} model {model}"
|
||||||
|
);
|
||||||
crate::chat::transport::matrix::assign::handle_assign(
|
crate::chat::transport::matrix::assign::handle_assign(
|
||||||
&ctx.bot_name,
|
&ctx.bot_name,
|
||||||
&story_number,
|
&story_number,
|
||||||
@@ -385,9 +394,7 @@ async fn handle_llm_message(ctx: &WhatsAppWebhookContext, sender: &str, user_mes
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
slog!("[whatsapp] LLM error: {e}");
|
slog!("[whatsapp] LLM error: {e}");
|
||||||
let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) {
|
let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) {
|
||||||
format!(
|
format!("Authentication required. Log in to Claude here: {url}")
|
||||||
"Authentication required. Log in to Claude here: {url}"
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
format!("Error processing your request: {e}")
|
format!("Error processing your request: {e}")
|
||||||
};
|
};
|
||||||
@@ -434,20 +441,18 @@ async fn handle_llm_message(ctx: &WhatsAppWebhookContext, sender: &str, user_mes
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::agents::AgentPool;
|
|
||||||
use crate::io::watcher::WatcherEvent;
|
|
||||||
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
|
||||||
use super::super::history::{MessagingWindowTracker, WhatsAppConversationHistory};
|
|
||||||
use super::super::WhatsAppWebhookContext;
|
use super::super::WhatsAppWebhookContext;
|
||||||
|
use super::super::history::{MessagingWindowTracker, WhatsAppConversationHistory};
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::agents::AgentPool;
|
||||||
|
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
||||||
|
use crate::io::watcher::WatcherEvent;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
|
||||||
/// Build a minimal WhatsAppWebhookContext for allowlist tests.
|
/// Build a minimal WhatsAppWebhookContext for allowlist tests.
|
||||||
fn make_ctx_with_allowlist(
|
fn make_ctx_with_allowlist(allowed_phones: Vec<String>) -> Arc<WhatsAppWebhookContext> {
|
||||||
allowed_phones: Vec<String>,
|
|
||||||
) -> Arc<WhatsAppWebhookContext> {
|
|
||||||
struct NullTransport;
|
struct NullTransport;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -505,9 +510,15 @@ mod tests {
|
|||||||
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
|
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
|
||||||
let url = crate::llm::oauth::extract_login_url_from_error(err);
|
let url = crate::llm::oauth::extract_login_url_from_error(err);
|
||||||
assert!(url.is_some(), "should extract URL from OAuth error");
|
assert!(url.is_some(), "should extract URL from OAuth error");
|
||||||
let msg = format!("Authentication required. Log in to Claude here: {}", url.unwrap());
|
let msg = format!(
|
||||||
|
"Authentication required. Log in to Claude here: {}",
|
||||||
|
url.unwrap()
|
||||||
|
);
|
||||||
assert!(msg.contains("http://localhost:3001/oauth/authorize"));
|
assert!(msg.contains("http://localhost:3001/oauth/authorize"));
|
||||||
assert!(!msg.contains('['), "WhatsApp message should not use Markdown link syntax");
|
assert!(
|
||||||
|
!msg.contains('['),
|
||||||
|
"WhatsApp message should not use Markdown link syntax"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -594,7 +605,10 @@ mod tests {
|
|||||||
"Timmy",
|
"Timmy",
|
||||||
"@timmy:home.local",
|
"@timmy:home.local",
|
||||||
);
|
);
|
||||||
assert!(result.is_none(), "'status' should not be recognised as rebuild");
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"'status' should not be recognised as rebuild"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── reset command extraction ───────────────────────────────────────
|
// ── reset command extraction ───────────────────────────────────────
|
||||||
@@ -624,14 +638,17 @@ mod tests {
|
|||||||
let sender = "+15555550100";
|
let sender = "+15555550100";
|
||||||
let history: WhatsAppConversationHistory = Arc::new(TokioMutex::new({
|
let history: WhatsAppConversationHistory = Arc::new(TokioMutex::new({
|
||||||
let mut m = HashMap::new();
|
let mut m = HashMap::new();
|
||||||
m.insert(sender.to_string(), RoomConversation {
|
m.insert(
|
||||||
session_id: Some("old-session".to_string()),
|
sender.to_string(),
|
||||||
entries: vec![ConversationEntry {
|
RoomConversation {
|
||||||
role: ConversationRole::User,
|
session_id: Some("old-session".to_string()),
|
||||||
sender: sender.to_string(),
|
entries: vec![ConversationEntry {
|
||||||
content: "previous message".to_string(),
|
role: ConversationRole::User,
|
||||||
}],
|
sender: sender.to_string(),
|
||||||
});
|
content: "previous message".to_string(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
);
|
||||||
m
|
m
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -641,7 +658,9 @@ mod tests {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut guard = history.lock().await;
|
let mut guard = history.lock().await;
|
||||||
let conv = guard.entry(sender.to_string()).or_insert_with(RoomConversation::default);
|
let conv = guard
|
||||||
|
.entry(sender.to_string())
|
||||||
|
.or_insert_with(RoomConversation::default);
|
||||||
conv.session_id = None;
|
conv.session_id = None;
|
||||||
conv.entries.clear();
|
conv.entries.clear();
|
||||||
save_whatsapp_history(tmp.path(), &guard);
|
save_whatsapp_history(tmp.path(), &guard);
|
||||||
@@ -748,7 +767,10 @@ mod tests {
|
|||||||
"Timmy",
|
"Timmy",
|
||||||
"@timmy:home.local",
|
"@timmy:home.local",
|
||||||
);
|
);
|
||||||
assert!(result.is_none(), "'status' should not be recognised as rmtree");
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"'status' should not be recognised as rmtree"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── assign command extraction ──────────────────────────────────────
|
// ── assign command extraction ──────────────────────────────────────
|
||||||
@@ -805,6 +827,9 @@ mod tests {
|
|||||||
"Timmy",
|
"Timmy",
|
||||||
"@timmy:home.local",
|
"@timmy:home.local",
|
||||||
);
|
);
|
||||||
assert!(result.is_none(), "'status' should not be recognised as assign");
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"'status' should not be recognised as assign"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,14 +66,11 @@ pub fn markdown_to_whatsapp(text: &str) -> String {
|
|||||||
LazyLock::new(|| Regex::new(r"(?m)^#{1,6}\s+(.+)$").unwrap());
|
LazyLock::new(|| Regex::new(r"(?m)^#{1,6}\s+(.+)$").unwrap());
|
||||||
static RE_BOLD_ITALIC: LazyLock<Regex> =
|
static RE_BOLD_ITALIC: LazyLock<Regex> =
|
||||||
LazyLock::new(|| Regex::new(r"\*\*\*(.+?)\*\*\*").unwrap());
|
LazyLock::new(|| Regex::new(r"\*\*\*(.+?)\*\*\*").unwrap());
|
||||||
static RE_BOLD: LazyLock<Regex> =
|
static RE_BOLD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
|
||||||
LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
|
static RE_STRIKETHROUGH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap());
|
||||||
static RE_STRIKETHROUGH: LazyLock<Regex> =
|
|
||||||
LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap());
|
|
||||||
static RE_LINK: LazyLock<Regex> =
|
static RE_LINK: LazyLock<Regex> =
|
||||||
LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
|
LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
|
||||||
static RE_HR: LazyLock<Regex> =
|
static RE_HR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^---+$").unwrap());
|
||||||
LazyLock::new(|| Regex::new(r"(?m)^---+$").unwrap());
|
|
||||||
|
|
||||||
// 1. Protect fenced code blocks by replacing them with placeholders.
|
// 1. Protect fenced code blocks by replacing them with placeholders.
|
||||||
let mut code_blocks: Vec<String> = Vec::new();
|
let mut code_blocks: Vec<String> = Vec::new();
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::history::MessagingWindowTracker;
|
||||||
use crate::chat::{ChatTransport, MessageId};
|
use crate::chat::{ChatTransport, MessageId};
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use super::history::MessagingWindowTracker;
|
|
||||||
|
|
||||||
// ── API base URLs (overridable for tests) ────────────────────────────────
|
// ── API base URLs (overridable for tests) ────────────────────────────────
|
||||||
|
|
||||||
@@ -55,7 +55,11 @@ impl WhatsAppTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn with_api_base(phone_number_id: String, access_token: String, api_base: String) -> Self {
|
pub(crate) fn with_api_base(
|
||||||
|
phone_number_id: String,
|
||||||
|
access_token: String,
|
||||||
|
api_base: String,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
phone_number_id,
|
phone_number_id,
|
||||||
access_token,
|
access_token,
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ pub mod history;
|
|||||||
pub mod meta;
|
pub mod meta;
|
||||||
pub mod twilio;
|
pub mod twilio;
|
||||||
|
|
||||||
pub use history::{load_whatsapp_history, MessagingWindowTracker, WhatsAppConversationHistory};
|
pub use history::{MessagingWindowTracker, WhatsAppConversationHistory, load_whatsapp_history};
|
||||||
pub use meta::WhatsAppTransport;
|
pub use meta::WhatsAppTransport;
|
||||||
pub use twilio::{extract_twilio_text_messages, TwilioWhatsAppTransport};
|
pub use twilio::{TwilioWhatsAppTransport, extract_twilio_text_messages};
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
@@ -132,8 +132,7 @@ pub struct WhatsAppWebhookContext {
|
|||||||
/// Permission requests from the MCP `prompt_permission` tool arrive here.
|
/// Permission requests from the MCP `prompt_permission` tool arrive here.
|
||||||
pub perm_rx: Arc<TokioMutex<tokio::sync::mpsc::UnboundedReceiver<PermissionForward>>>,
|
pub perm_rx: Arc<TokioMutex<tokio::sync::mpsc::UnboundedReceiver<PermissionForward>>>,
|
||||||
/// Pending permission replies keyed by sender phone number.
|
/// Pending permission replies keyed by sender phone number.
|
||||||
pub pending_perm_replies:
|
pub pending_perm_replies: Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionDecision>>>>,
|
||||||
Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionDecision>>>>,
|
|
||||||
/// Seconds before an unanswered permission prompt is auto-denied.
|
/// Seconds before an unanswered permission prompt is auto-denied.
|
||||||
pub permission_timeout_secs: u64,
|
pub permission_timeout_secs: u64,
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-11
@@ -202,9 +202,7 @@ pub fn normalize_line_breaks(text: &str) -> String {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Horizontal rules: lines made entirely of -, *, or _ (at least 3 chars).
|
// Horizontal rules: lines made entirely of -, *, or _ (at least 3 chars).
|
||||||
let all_hr_chars = trimmed
|
let all_hr_chars = trimmed.chars().all(|c| matches!(c, '-' | '*' | '_' | ' '));
|
||||||
.chars()
|
|
||||||
.all(|c| matches!(c, '-' | '*' | '_' | ' '));
|
|
||||||
let hr_char_count = trimmed.chars().filter(|c| !c.is_whitespace()).count();
|
let hr_char_count = trimmed.chars().filter(|c| !c.is_whitespace()).count();
|
||||||
all_hr_chars && hr_char_count >= 3
|
all_hr_chars && hr_char_count >= 3
|
||||||
}
|
}
|
||||||
@@ -389,11 +387,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn strip_mention_emoji_display_name_no_separator() {
|
fn strip_mention_emoji_display_name_no_separator() {
|
||||||
// Display name with emoji, no separator
|
// Display name with emoji, no separator
|
||||||
let rest = strip_bot_mention(
|
let rest = strip_bot_mention("timmy ⚡️ ambient on", "timmy ⚡️", "@timmy:homeserver.local");
|
||||||
"timmy ⚡️ ambient on",
|
|
||||||
"timmy ⚡️",
|
|
||||||
"@timmy:homeserver.local",
|
|
||||||
);
|
|
||||||
assert_eq!(rest, "ambient on");
|
assert_eq!(rest, "ambient on");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,9 +632,18 @@ mod tests {
|
|||||||
let output = normalize_line_breaks(input);
|
let output = normalize_line_breaks(input);
|
||||||
// Prose sentences before and after the code block get doubled.
|
// Prose sentences before and after the code block get doubled.
|
||||||
// The code block itself is preserved.
|
// The code block itself is preserved.
|
||||||
assert!(output.contains("First sentence.\n\nSecond sentence."), "prose before code: {output}");
|
assert!(
|
||||||
assert!(output.contains("```rust\nlet x = 1;\nlet y = 2;\n```"), "code block preserved: {output}");
|
output.contains("First sentence.\n\nSecond sentence."),
|
||||||
assert!(output.contains("Third sentence.\n\nFourth sentence."), "prose after code: {output}");
|
"prose before code: {output}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains("```rust\nlet x = 1;\nlet y = 2;\n```"),
|
||||||
|
"code block preserved: {output}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains("Third sentence.\n\nFourth sentence."),
|
||||||
|
"prose after code: {output}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+45
-42
@@ -101,7 +101,6 @@ fn default_rate_limit_notifications() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct ComponentConfig {
|
pub struct ComponentConfig {
|
||||||
@@ -288,27 +287,28 @@ impl ProjectConfig {
|
|||||||
// Parsed successfully but no agents — could be legacy or no agent section.
|
// Parsed successfully but no agents — could be legacy or no agent section.
|
||||||
// Try legacy format.
|
// Try legacy format.
|
||||||
if let Ok(legacy) = toml::from_str::<LegacyProjectConfig>(content)
|
if let Ok(legacy) = toml::from_str::<LegacyProjectConfig>(content)
|
||||||
&& let Some(agent) = legacy.agent {
|
&& let Some(agent) = legacy.agent
|
||||||
slog!(
|
{
|
||||||
"[config] Warning: [agent] table is deprecated. \
|
slog!(
|
||||||
|
"[config] Warning: [agent] table is deprecated. \
|
||||||
Use [[agent]] array format instead."
|
Use [[agent]] array format instead."
|
||||||
);
|
);
|
||||||
let config = ProjectConfig {
|
let config = ProjectConfig {
|
||||||
component: legacy.component,
|
component: legacy.component,
|
||||||
agent: vec![agent],
|
agent: vec![agent],
|
||||||
watcher: legacy.watcher,
|
watcher: legacy.watcher,
|
||||||
default_qa: legacy.default_qa,
|
default_qa: legacy.default_qa,
|
||||||
default_coder_model: legacy.default_coder_model,
|
default_coder_model: legacy.default_coder_model,
|
||||||
max_coders: legacy.max_coders,
|
max_coders: legacy.max_coders,
|
||||||
max_retries: legacy.max_retries,
|
max_retries: legacy.max_retries,
|
||||||
base_branch: legacy.base_branch,
|
base_branch: legacy.base_branch,
|
||||||
rate_limit_notifications: legacy.rate_limit_notifications,
|
rate_limit_notifications: legacy.rate_limit_notifications,
|
||||||
timezone: legacy.timezone,
|
timezone: legacy.timezone,
|
||||||
rendezvous: None,
|
rendezvous: None,
|
||||||
};
|
};
|
||||||
validate_agents(&config.agent)?;
|
validate_agents(&config.agent)?;
|
||||||
return Ok(config);
|
return Ok(config);
|
||||||
}
|
}
|
||||||
// No agent section at all
|
// No agent section at all
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
@@ -411,10 +411,11 @@ impl ProjectConfig {
|
|||||||
args.push(model.clone());
|
args.push(model.clone());
|
||||||
}
|
}
|
||||||
if let Some(ref tools) = agent.allowed_tools
|
if let Some(ref tools) = agent.allowed_tools
|
||||||
&& !tools.is_empty() {
|
&& !tools.is_empty()
|
||||||
args.push("--allowedTools".to_string());
|
{
|
||||||
args.push(tools.join(","));
|
args.push("--allowedTools".to_string());
|
||||||
}
|
args.push(tools.join(","));
|
||||||
|
}
|
||||||
if let Some(turns) = agent.max_turns {
|
if let Some(turns) = agent.max_turns {
|
||||||
args.push("--max-turns".to_string());
|
args.push("--max-turns".to_string());
|
||||||
args.push(turns.to_string());
|
args.push(turns.to_string());
|
||||||
@@ -443,19 +444,21 @@ fn validate_agents(agents: &[AgentConfig]) -> Result<(), String> {
|
|||||||
return Err(format!("Duplicate agent name: '{}'", agent.name));
|
return Err(format!("Duplicate agent name: '{}'", agent.name));
|
||||||
}
|
}
|
||||||
if let Some(budget) = agent.max_budget_usd
|
if let Some(budget) = agent.max_budget_usd
|
||||||
&& budget <= 0.0 {
|
&& budget <= 0.0
|
||||||
return Err(format!(
|
{
|
||||||
"Agent '{}': max_budget_usd must be positive, got {budget}",
|
return Err(format!(
|
||||||
agent.name
|
"Agent '{}': max_budget_usd must be positive, got {budget}",
|
||||||
));
|
agent.name
|
||||||
}
|
));
|
||||||
|
}
|
||||||
if let Some(turns) = agent.max_turns
|
if let Some(turns) = agent.max_turns
|
||||||
&& turns == 0 {
|
&& turns == 0
|
||||||
return Err(format!(
|
{
|
||||||
"Agent '{}': max_turns must be positive, got 0",
|
return Err(format!(
|
||||||
agent.name
|
"Agent '{}': max_turns must be positive, got 0",
|
||||||
));
|
agent.name
|
||||||
}
|
));
|
||||||
|
}
|
||||||
if let Some(ref runtime) = agent.runtime {
|
if let Some(ref runtime) = agent.runtime {
|
||||||
match runtime.as_str() {
|
match runtime.as_str() {
|
||||||
"claude-code" | "gemini" => {}
|
"claude-code" | "gemini" => {}
|
||||||
@@ -957,10 +960,7 @@ name = "coder"
|
|||||||
runtime = "claude-code"
|
runtime = "claude-code"
|
||||||
"#;
|
"#;
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(config.agent[0].runtime, Some("claude-code".to_string()));
|
||||||
config.agent[0].runtime,
|
|
||||||
Some("claude-code".to_string())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1067,7 +1067,10 @@ prompt = "git difftool {{base_branch}}...HEAD"
|
|||||||
name = "coder"
|
name = "coder"
|
||||||
"#;
|
"#;
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
assert!(config.rate_limit_notifications, "rate_limit_notifications should default to true");
|
assert!(
|
||||||
|
config.rate_limit_notifications,
|
||||||
|
"rate_limit_notifications should default to true"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+75
-60
@@ -20,8 +20,8 @@ use bft_json_crdt::op::ROOT_ID;
|
|||||||
use fastcrypto::ed25519::Ed25519KeyPair;
|
use fastcrypto::ed25519::Ed25519KeyPair;
|
||||||
use fastcrypto::traits::ToFromBytes;
|
use fastcrypto::traits::ToFromBytes;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sqlx::sqlite::SqliteConnectOptions;
|
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
use sqlx::sqlite::SqliteConnectOptions;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
|
||||||
@@ -218,10 +218,9 @@ pub async fn init(db_path: &Path) -> Result<(), sqlx::Error> {
|
|||||||
let mut crdt = BaseCrdt::<PipelineDoc>::new(&keypair);
|
let mut crdt = BaseCrdt::<PipelineDoc>::new(&keypair);
|
||||||
|
|
||||||
// Replay persisted ops to reconstruct state.
|
// Replay persisted ops to reconstruct state.
|
||||||
let rows: Vec<(String,)> =
|
let rows: Vec<(String,)> = sqlx::query_as("SELECT op_json FROM crdt_ops ORDER BY rowid ASC")
|
||||||
sqlx::query_as("SELECT op_json FROM crdt_ops ORDER BY rowid ASC")
|
.fetch_all(&pool)
|
||||||
.fetch_all(&pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut all_ops_vec = Vec::with_capacity(rows.len());
|
let mut all_ops_vec = Vec::with_capacity(rows.len());
|
||||||
for (op_json,) in &rows {
|
for (op_json,) in &rows {
|
||||||
@@ -316,7 +315,13 @@ pub fn init_for_test() {
|
|||||||
let keypair = make_keypair();
|
let keypair = make_keypair();
|
||||||
let crdt = BaseCrdt::<PipelineDoc>::new(&keypair);
|
let crdt = BaseCrdt::<PipelineDoc>::new(&keypair);
|
||||||
let (persist_tx, _rx) = mpsc::unbounded_channel();
|
let (persist_tx, _rx) = mpsc::unbounded_channel();
|
||||||
let state = CrdtState { crdt, keypair, index: HashMap::new(), node_index: HashMap::new(), persist_tx };
|
let state = CrdtState {
|
||||||
|
crdt,
|
||||||
|
keypair,
|
||||||
|
index: HashMap::new(),
|
||||||
|
node_index: HashMap::new(),
|
||||||
|
persist_tx,
|
||||||
|
};
|
||||||
let _ = lock.set(Mutex::new(state));
|
let _ = lock.set(Mutex::new(state));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -458,9 +463,7 @@ pub fn write_item(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Some(b) = blocked {
|
if let Some(b) = blocked {
|
||||||
apply_and_persist(&mut state, |s| {
|
apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].blocked.set(b));
|
||||||
s.crdt.doc.items[idx].blocked.set(b)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if let Some(d) = depends_on {
|
if let Some(d) = depends_on {
|
||||||
apply_and_persist(&mut state, |s| {
|
apply_and_persist(&mut state, |s| {
|
||||||
@@ -473,14 +476,10 @@ pub fn write_item(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Some(ca) = claimed_at {
|
if let Some(ca) = claimed_at {
|
||||||
apply_and_persist(&mut state, |s| {
|
apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].claimed_at.set(ca));
|
||||||
s.crdt.doc.items[idx].claimed_at.set(ca)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if let Some(ma) = merged_at {
|
if let Some(ma) = merged_at {
|
||||||
apply_and_persist(&mut state, |s| {
|
apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].merged_at.set(ma));
|
||||||
s.crdt.doc.items[idx].merged_at.set(ma)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast a CrdtEvent if the stage actually changed.
|
// Broadcast a CrdtEvent if the stage actually changed.
|
||||||
@@ -514,9 +513,7 @@ pub fn write_item(
|
|||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
apply_and_persist(&mut state, |s| {
|
apply_and_persist(&mut state, |s| s.crdt.doc.items.insert(ROOT_ID, item_json));
|
||||||
s.crdt.doc.items.insert(ROOT_ID, item_json)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rebuild index after insertion (indices may shift).
|
// Rebuild index after insertion (indices may shift).
|
||||||
state.index = rebuild_index(&state.crdt);
|
state.index = rebuild_index(&state.crdt);
|
||||||
@@ -561,11 +558,9 @@ pub fn apply_remote_op(op: SignedOp) -> bool {
|
|||||||
let pre_stages: HashMap<String, String> = state
|
let pre_stages: HashMap<String, String> = state
|
||||||
.index
|
.index
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|(sid, &idx)| {
|
.filter_map(|(sid, &idx)| match state.crdt.doc.items[idx].stage.view() {
|
||||||
match state.crdt.doc.items[idx].stage.view() {
|
JsonValue::String(s) => Some((sid.clone(), s)),
|
||||||
JsonValue::String(s) => Some((sid.clone(), s)),
|
_ => None,
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -668,9 +663,7 @@ pub fn write_claim(story_id: &str) -> bool {
|
|||||||
apply_and_persist(&mut state, |s| {
|
apply_and_persist(&mut state, |s| {
|
||||||
s.crdt.doc.items[idx].claimed_by.set(node_id.clone())
|
s.crdt.doc.items[idx].claimed_by.set(node_id.clone())
|
||||||
});
|
});
|
||||||
apply_and_persist(&mut state, |s| {
|
apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].claimed_at.set(now));
|
||||||
s.crdt.doc.items[idx].claimed_at.set(now)
|
|
||||||
});
|
|
||||||
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -690,9 +683,7 @@ pub fn release_claim(story_id: &str) {
|
|||||||
apply_and_persist(&mut state, |s| {
|
apply_and_persist(&mut state, |s| {
|
||||||
s.crdt.doc.items[idx].claimed_by.set(String::new())
|
s.crdt.doc.items[idx].claimed_by.set(String::new())
|
||||||
});
|
});
|
||||||
apply_and_persist(&mut state, |s| {
|
apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].claimed_at.set(0.0));
|
||||||
s.crdt.doc.items[idx].claimed_at.set(0.0)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if this node currently holds the claim on a pipeline item.
|
/// Check if this node currently holds the claim on a pipeline item.
|
||||||
@@ -725,9 +716,7 @@ pub fn write_node_presence(node_id: &str, address: &str, last_seen: f64, alive:
|
|||||||
apply_and_persist(&mut state, |s| {
|
apply_and_persist(&mut state, |s| {
|
||||||
s.crdt.doc.nodes[idx].last_seen.set(last_seen)
|
s.crdt.doc.nodes[idx].last_seen.set(last_seen)
|
||||||
});
|
});
|
||||||
apply_and_persist(&mut state, |s| {
|
apply_and_persist(&mut state, |s| s.crdt.doc.nodes[idx].alive.set(alive));
|
||||||
s.crdt.doc.nodes[idx].alive.set(alive)
|
|
||||||
});
|
|
||||||
apply_and_persist(&mut state, |s| {
|
apply_and_persist(&mut state, |s| {
|
||||||
s.crdt.doc.nodes[idx].address.set(address.to_string())
|
s.crdt.doc.nodes[idx].address.set(address.to_string())
|
||||||
});
|
});
|
||||||
@@ -741,9 +730,7 @@ pub fn write_node_presence(node_id: &str, address: &str, last_seen: f64, alive:
|
|||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
apply_and_persist(&mut state, |s| {
|
apply_and_persist(&mut state, |s| s.crdt.doc.nodes.insert(ROOT_ID, node_json));
|
||||||
s.crdt.doc.nodes.insert(ROOT_ID, node_json)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rebuild node index after insertion.
|
// Rebuild node index after insertion.
|
||||||
state.node_index = rebuild_node_index(&state.crdt);
|
state.node_index = rebuild_node_index(&state.crdt);
|
||||||
@@ -1019,8 +1006,7 @@ pub fn read_item(story_id: &str) -> Option<PipelineItemView> {
|
|||||||
/// or an `Err` if the CRDT layer isn't initialised or the story_id is
|
/// or an `Err` if the CRDT layer isn't initialised or the story_id is
|
||||||
/// unknown to the in-memory state.
|
/// unknown to the in-memory state.
|
||||||
pub fn evict_item(story_id: &str) -> Result<(), String> {
|
pub fn evict_item(story_id: &str) -> Result<(), String> {
|
||||||
let state_mutex = get_crdt()
|
let state_mutex = get_crdt().ok_or_else(|| "CRDT layer not initialised".to_string())?;
|
||||||
.ok_or_else(|| "CRDT layer not initialised".to_string())?;
|
|
||||||
let mut state = state_mutex
|
let mut state = state_mutex
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| format!("CRDT lock poisoned: {e}"))?;
|
.map_err(|e| format!("CRDT lock poisoned: {e}"))?;
|
||||||
@@ -1033,12 +1019,10 @@ pub fn evict_item(story_id: &str) -> Result<(), String> {
|
|||||||
|
|
||||||
// Resolve the item's OpId before the closure (the closure will mutably
|
// Resolve the item's OpId before the closure (the closure will mutably
|
||||||
// borrow `state`, so we can't access `state.crdt.doc.items` from inside).
|
// borrow `state`, so we can't access `state.crdt.doc.items` from inside).
|
||||||
let item_id = state
|
let item_id =
|
||||||
.crdt
|
state.crdt.doc.items.id_at(idx).ok_or_else(|| {
|
||||||
.doc
|
format!("Item index {idx} for '{story_id}' did not resolve to an OpId")
|
||||||
.items
|
})?;
|
||||||
.id_at(idx)
|
|
||||||
.ok_or_else(|| format!("Item index {idx} for '{story_id}' did not resolve to an OpId"))?;
|
|
||||||
|
|
||||||
// Write the delete op via the existing apply_and_persist machinery.
|
// Write the delete op via the existing apply_and_persist machinery.
|
||||||
// This signs the op, applies it to the in-memory CRDT (marking the item
|
// This signs the op, applies it to the in-memory CRDT (marking the item
|
||||||
@@ -1084,9 +1068,7 @@ fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemView> {
|
|||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let depends_on = match item.depends_on.view() {
|
let depends_on = match item.depends_on.view() {
|
||||||
JsonValue::String(s) if !s.is_empty() => {
|
JsonValue::String(s) if !s.is_empty() => serde_json::from_str::<Vec<u32>>(&s).ok(),
|
||||||
serde_json::from_str::<Vec<u32>>(&s).ok()
|
|
||||||
}
|
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1142,9 +1124,9 @@ pub fn dep_is_done_crdt(dep_number: u32) -> bool {
|
|||||||
pub fn dep_is_archived_crdt(dep_number: u32) -> bool {
|
pub fn dep_is_archived_crdt(dep_number: u32) -> bool {
|
||||||
let prefix = format!("{dep_number}_");
|
let prefix = format!("{dep_number}_");
|
||||||
if let Some(items) = read_all_items() {
|
if let Some(items) = read_all_items() {
|
||||||
items.iter().any(|item| {
|
items
|
||||||
item.story_id.starts_with(&prefix) && item.stage == "6_archived"
|
.iter()
|
||||||
})
|
.any(|item| item.story_id.starts_with(&prefix) && item.stage == "6_archived")
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -1226,8 +1208,14 @@ mod tests {
|
|||||||
assert_eq!(view.len(), 1);
|
assert_eq!(view.len(), 1);
|
||||||
|
|
||||||
let item = &crdt.doc.items[0];
|
let item = &crdt.doc.items[0];
|
||||||
assert_eq!(item.story_id.view(), JsonValue::String("10_story_test".to_string()));
|
assert_eq!(
|
||||||
assert_eq!(item.stage.view(), JsonValue::String("2_current".to_string()));
|
item.story_id.view(),
|
||||||
|
JsonValue::String("10_story_test".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
item.stage.view(),
|
||||||
|
JsonValue::String("2_current".to_string())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1252,7 +1240,10 @@ mod tests {
|
|||||||
crdt.apply(insert_op);
|
crdt.apply(insert_op);
|
||||||
|
|
||||||
// Update stage
|
// Update stage
|
||||||
let stage_op = crdt.doc.items[0].stage.set("2_current".to_string()).sign(&kp);
|
let stage_op = crdt.doc.items[0]
|
||||||
|
.stage
|
||||||
|
.set("2_current".to_string())
|
||||||
|
.sign(&kp);
|
||||||
crdt.apply(stage_op);
|
crdt.apply(stage_op);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1283,10 +1274,16 @@ mod tests {
|
|||||||
let op1 = crdt1.doc.items.insert(ROOT_ID, item_json).sign(&kp);
|
let op1 = crdt1.doc.items.insert(ROOT_ID, item_json).sign(&kp);
|
||||||
crdt1.apply(op1.clone());
|
crdt1.apply(op1.clone());
|
||||||
|
|
||||||
let op2 = crdt1.doc.items[0].stage.set("2_current".to_string()).sign(&kp);
|
let op2 = crdt1.doc.items[0]
|
||||||
|
.stage
|
||||||
|
.set("2_current".to_string())
|
||||||
|
.sign(&kp);
|
||||||
crdt1.apply(op2.clone());
|
crdt1.apply(op2.clone());
|
||||||
|
|
||||||
let op3 = crdt1.doc.items[0].name.set("Updated Name".to_string()).sign(&kp);
|
let op3 = crdt1.doc.items[0]
|
||||||
|
.name
|
||||||
|
.set("Updated Name".to_string())
|
||||||
|
.sign(&kp);
|
||||||
crdt1.apply(op3.clone());
|
crdt1.apply(op3.clone());
|
||||||
|
|
||||||
// Replay ops on a fresh CRDT.
|
// Replay ops on a fresh CRDT.
|
||||||
@@ -1568,7 +1565,11 @@ mod tests {
|
|||||||
"claimed_at": 0.0,
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op = crdt.doc.items.insert(bft_json_crdt::op::ROOT_ID, item).sign(&kp);
|
let op = crdt
|
||||||
|
.doc
|
||||||
|
.items
|
||||||
|
.insert(bft_json_crdt::op::ROOT_ID, item)
|
||||||
|
.sign(&kp);
|
||||||
// This uses the global state which may not be initialised in tests.
|
// This uses the global state which may not be initialised in tests.
|
||||||
let _ = apply_remote_op(op);
|
let _ = apply_remote_op(op);
|
||||||
}
|
}
|
||||||
@@ -1591,7 +1592,11 @@ mod tests {
|
|||||||
"claimed_at": 0.0,
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op = crdt.doc.items.insert(bft_json_crdt::op::ROOT_ID, item).sign(&kp);
|
let op = crdt
|
||||||
|
.doc
|
||||||
|
.items
|
||||||
|
.insert(bft_json_crdt::op::ROOT_ID, item)
|
||||||
|
.sign(&kp);
|
||||||
|
|
||||||
let json1 = serde_json::to_string(&op).unwrap();
|
let json1 = serde_json::to_string(&op).unwrap();
|
||||||
let roundtripped: SignedOp = serde_json::from_str(&json1).unwrap();
|
let roundtripped: SignedOp = serde_json::from_str(&json1).unwrap();
|
||||||
@@ -1620,7 +1625,11 @@ mod tests {
|
|||||||
"claimed_at": 0.0,
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op = crdt.doc.items.insert(bft_json_crdt::op::ROOT_ID, item).sign(&kp);
|
let op = crdt
|
||||||
|
.doc
|
||||||
|
.items
|
||||||
|
.insert(bft_json_crdt::op::ROOT_ID, item)
|
||||||
|
.sign(&kp);
|
||||||
tx.send(op.clone()).unwrap();
|
tx.send(op.clone()).unwrap();
|
||||||
|
|
||||||
let received = rx.try_recv().unwrap();
|
let received = rx.try_recv().unwrap();
|
||||||
@@ -1693,7 +1702,10 @@ mod tests {
|
|||||||
// Now update the stage. The stage LwwRegisterCrdt for this item starts
|
// Now update the stage. The stage LwwRegisterCrdt for this item starts
|
||||||
// at our_seq=0, so this field op gets seq=1. Crucially: seq=1 < seq=6.
|
// at our_seq=0, so this field op gets seq=1. Crucially: seq=1 < seq=6.
|
||||||
let idx = rebuild_index(&crdt)["511_story_target"];
|
let idx = rebuild_index(&crdt)["511_story_target"];
|
||||||
let stage_op = crdt.doc.items[idx].stage.set("2_current".to_string()).sign(&kp);
|
let stage_op = crdt.doc.items[idx]
|
||||||
|
.stage
|
||||||
|
.set("2_current".to_string())
|
||||||
|
.sign(&kp);
|
||||||
crdt.apply(stage_op.clone());
|
crdt.apply(stage_op.clone());
|
||||||
// stage_op.inner.seq == 1
|
// stage_op.inner.seq == 1
|
||||||
|
|
||||||
@@ -1808,8 +1820,11 @@ mod tests {
|
|||||||
|
|
||||||
apply_and_persist(&mut state, |s| s.crdt.doc.items.insert(ROOT_ID, item_json));
|
apply_and_persist(&mut state, |s| s.crdt.doc.items.insert(ROOT_ID, item_json));
|
||||||
|
|
||||||
let error_entries = crate::log_buffer::global()
|
let error_entries = crate::log_buffer::global().get_recent_entries(
|
||||||
.get_recent_entries(1000, None, Some(&crate::log_buffer::LogLevel::Error));
|
1000,
|
||||||
|
None,
|
||||||
|
Some(&crate::log_buffer::LogLevel::Error),
|
||||||
|
);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
error_entries.len() > before_errors,
|
error_entries.len() > before_errors,
|
||||||
|
|||||||
+68
-53
@@ -408,7 +408,9 @@ mod tests {
|
|||||||
|
|
||||||
// Serialise op1 into a SyncMessage::Op.
|
// Serialise op1 into a SyncMessage::Op.
|
||||||
let op1_json = serde_json::to_string(&op1).unwrap();
|
let op1_json = serde_json::to_string(&op1).unwrap();
|
||||||
let wire_msg = SyncMessage::Op { op: op1_json.clone() };
|
let wire_msg = SyncMessage::Op {
|
||||||
|
op: op1_json.clone(),
|
||||||
|
};
|
||||||
let wire_json = serde_json::to_string(&wire_msg).unwrap();
|
let wire_json = serde_json::to_string(&wire_msg).unwrap();
|
||||||
|
|
||||||
// ── Node B: receive the op through protocol ──
|
// ── Node B: receive the op through protocol ──
|
||||||
@@ -517,10 +519,7 @@ mod tests {
|
|||||||
.sign(&kp);
|
.sign(&kp);
|
||||||
crdt_a.apply(op2.clone());
|
crdt_a.apply(op2.clone());
|
||||||
|
|
||||||
let op3 = crdt_a.doc.items[0]
|
let op3 = crdt_a.doc.items[0].stage.set("3_qa".to_string()).sign(&kp);
|
||||||
.stage
|
|
||||||
.set("3_qa".to_string())
|
|
||||||
.sign(&kp);
|
|
||||||
crdt_a.apply(op3.clone());
|
crdt_a.apply(op3.clone());
|
||||||
|
|
||||||
// Serialise all ops as a bulk message (simulates partition heal).
|
// Serialise all ops as a bulk message (simulates partition heal).
|
||||||
@@ -623,7 +622,10 @@ name = "test"
|
|||||||
|
|
||||||
// Simulate a clean reconnect.
|
// Simulate a clean reconnect.
|
||||||
consecutive_failures = 0;
|
consecutive_failures = 0;
|
||||||
assert_eq!(consecutive_failures, 0, "counter must reset to 0 on success");
|
assert_eq!(
|
||||||
|
consecutive_failures, 0,
|
||||||
|
"counter must reset to 0 on success"
|
||||||
|
);
|
||||||
|
|
||||||
// Next error is attempt 1 — well below the ERROR threshold.
|
// Next error is attempt 1 — well below the ERROR threshold.
|
||||||
consecutive_failures += 1;
|
consecutive_failures += 1;
|
||||||
@@ -685,7 +687,10 @@ name = "test"
|
|||||||
assert_eq!(crdt.doc.items.view().len(), 1);
|
assert_eq!(crdt.doc.items.view().len(), 1);
|
||||||
|
|
||||||
// Stage update also deduplicated correctly.
|
// Stage update also deduplicated correctly.
|
||||||
let stage_op = crdt.doc.items[0].stage.set("2_current".to_string()).sign(&kp);
|
let stage_op = crdt.doc.items[0]
|
||||||
|
.stage
|
||||||
|
.set("2_current".to_string())
|
||||||
|
.sign(&kp);
|
||||||
assert_eq!(crdt.apply(stage_op.clone()), OpState::Ok);
|
assert_eq!(crdt.apply(stage_op.clone()), OpState::Ok);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
crdt.doc.items[0].stage.view(),
|
crdt.doc.items[0].stage.view(),
|
||||||
@@ -806,10 +811,7 @@ name = "test"
|
|||||||
.set("2_current".to_string())
|
.set("2_current".to_string())
|
||||||
.sign(&kp);
|
.sign(&kp);
|
||||||
crdt_a.apply(op2.clone());
|
crdt_a.apply(op2.clone());
|
||||||
let op3 = crdt_a.doc.items[0]
|
let op3 = crdt_a.doc.items[0].stage.set("3_qa".to_string()).sign(&kp);
|
||||||
.stage
|
|
||||||
.set("3_qa".to_string())
|
|
||||||
.sign(&kp);
|
|
||||||
crdt_a.apply(op3.clone());
|
crdt_a.apply(op3.clone());
|
||||||
|
|
||||||
// Receiver applies all ops in the correct order.
|
// Receiver applies all ops in the correct order.
|
||||||
@@ -830,7 +832,7 @@ name = "test"
|
|||||||
/// pending op is evicted (queue never grows beyond the cap).
|
/// pending op is evicted (queue never grows beyond the cap).
|
||||||
#[test]
|
#[test]
|
||||||
fn causal_queue_overflow_drops_oldest() {
|
fn causal_queue_overflow_drops_oldest() {
|
||||||
use bft_json_crdt::json_crdt::{BaseCrdt, OpState, CAUSAL_QUEUE_MAX};
|
use bft_json_crdt::json_crdt::{BaseCrdt, CAUSAL_QUEUE_MAX, OpState};
|
||||||
use bft_json_crdt::keypair::make_keypair;
|
use bft_json_crdt::keypair::make_keypair;
|
||||||
use bft_json_crdt::op::ROOT_ID;
|
use bft_json_crdt::op::ROOT_ID;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -854,11 +856,7 @@ name = "test"
|
|||||||
"claimed_at": 0.0,
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let phantom_op = source
|
let phantom_op = source.doc.items.insert(ROOT_ID, phantom_item).sign(&kp);
|
||||||
.doc
|
|
||||||
.items
|
|
||||||
.insert(ROOT_ID, phantom_item)
|
|
||||||
.sign(&kp);
|
|
||||||
|
|
||||||
// Receiver never sees phantom_op, so any op declaring it as a dep will
|
// Receiver never sees phantom_op, so any op declaring it as a dep will
|
||||||
// sit in the causal queue forever (until evicted by overflow).
|
// sit in the causal queue forever (until evicted by overflow).
|
||||||
@@ -871,9 +869,7 @@ name = "test"
|
|||||||
for i in 0..CAUSAL_QUEUE_MAX + 5 {
|
for i in 0..CAUSAL_QUEUE_MAX + 5 {
|
||||||
let stage_name = format!("stage_{i}");
|
let stage_name = format!("stage_{i}");
|
||||||
// Generate from source so seq numbers are valid.
|
// Generate from source so seq numbers are valid.
|
||||||
let op = source
|
let op = source.doc.items[0]
|
||||||
.doc
|
|
||||||
.items[0]
|
|
||||||
.stage
|
.stage
|
||||||
.set(stage_name)
|
.set(stage_name)
|
||||||
.sign_with_dependencies(&kp, vec![&phantom_op]);
|
.sign_with_dependencies(&kp, vec![&phantom_op]);
|
||||||
@@ -1006,8 +1002,13 @@ name = "test"
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|item| {
|
.filter_map(|item| {
|
||||||
if let JV::Object(m) = CrdtNode::view(item) {
|
if let JV::Object(m) = CrdtNode::view(item) {
|
||||||
m.get("story_id")
|
m.get("story_id").and_then(|s| {
|
||||||
.and_then(|s| if let JV::String(s) = s { Some(s.clone()) } else { None })
|
if let JV::String(s) = s {
|
||||||
|
Some(s.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -1194,15 +1195,9 @@ name = "test"
|
|||||||
.set("2_current".to_string())
|
.set("2_current".to_string())
|
||||||
.sign(&kp);
|
.sign(&kp);
|
||||||
crdt.apply(op2.clone());
|
crdt.apply(op2.clone());
|
||||||
let op3 = crdt.doc.items[0]
|
let op3 = crdt.doc.items[0].stage.set("3_qa".to_string()).sign(&kp);
|
||||||
.stage
|
|
||||||
.set("3_qa".to_string())
|
|
||||||
.sign(&kp);
|
|
||||||
crdt.apply(op3.clone());
|
crdt.apply(op3.clone());
|
||||||
let op4 = crdt.doc.items[0]
|
let op4 = crdt.doc.items[0].stage.set("4_merge".to_string()).sign(&kp);
|
||||||
.stage
|
|
||||||
.set("4_merge".to_string())
|
|
||||||
.sign(&kp);
|
|
||||||
crdt.apply(op4.clone());
|
crdt.apply(op4.clone());
|
||||||
|
|
||||||
// Send more ops than the channel capacity without consuming.
|
// Send more ops than the channel capacity without consuming.
|
||||||
@@ -1245,8 +1240,8 @@ name = "test"
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio_tungstenite::{accept_async, connect_async};
|
|
||||||
use tokio_tungstenite::tungstenite::Message as TMsg;
|
use tokio_tungstenite::tungstenite::Message as TMsg;
|
||||||
|
use tokio_tungstenite::{accept_async, connect_async};
|
||||||
|
|
||||||
use crate::crdt_state::PipelineDoc;
|
use crate::crdt_state::PipelineDoc;
|
||||||
|
|
||||||
@@ -1271,7 +1266,9 @@ name = "test"
|
|||||||
|
|
||||||
// Serialise A's full state as a bulk message.
|
// Serialise A's full state as a bulk message.
|
||||||
let op1_json = serde_json::to_string(&op1).unwrap();
|
let op1_json = serde_json::to_string(&op1).unwrap();
|
||||||
let bulk_msg = SyncMessage::Bulk { ops: vec![op1_json] };
|
let bulk_msg = SyncMessage::Bulk {
|
||||||
|
ops: vec![op1_json],
|
||||||
|
};
|
||||||
let bulk_wire = serde_json::to_string(&bulk_msg).unwrap();
|
let bulk_wire = serde_json::to_string(&bulk_msg).unwrap();
|
||||||
|
|
||||||
// ── Start Node A's WebSocket server on a random port ───────────────
|
// ── Start Node A's WebSocket server on a random port ───────────────
|
||||||
@@ -1349,11 +1346,17 @@ name = "test"
|
|||||||
// ── Assert convergence ─────────────────────────────────────────────
|
// ── Assert convergence ─────────────────────────────────────────────
|
||||||
|
|
||||||
// Node B received Node A's item.
|
// Node B received Node A's item.
|
||||||
assert_eq!(crdt_b.doc.items.view().len(), 2,
|
assert_eq!(
|
||||||
"Node B must see both items after sync");
|
crdt_b.doc.items.view().len(),
|
||||||
let has_a_item = crdt_b.doc.items.view().iter().any(|item| {
|
2,
|
||||||
item.story_id.view() == JV::String("508_e2e_convergence".to_string())
|
"Node B must see both items after sync"
|
||||||
});
|
);
|
||||||
|
let has_a_item = crdt_b
|
||||||
|
.doc
|
||||||
|
.items
|
||||||
|
.view()
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.story_id.view() == JV::String("508_e2e_convergence".to_string()));
|
||||||
assert!(has_a_item, "Node B must have Node A's item");
|
assert!(has_a_item, "Node B must have Node A's item");
|
||||||
|
|
||||||
// Node A received Node B's op via the WebSocket.
|
// Node A received Node B's op via the WebSocket.
|
||||||
@@ -1378,8 +1381,8 @@ name = "test"
|
|||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio_tungstenite::{accept_async, connect_async};
|
|
||||||
use tokio_tungstenite::tungstenite::Message as TMsg;
|
use tokio_tungstenite::tungstenite::Message as TMsg;
|
||||||
|
use tokio_tungstenite::{accept_async, connect_async};
|
||||||
|
|
||||||
use crate::crdt_state::PipelineDoc;
|
use crate::crdt_state::PipelineDoc;
|
||||||
|
|
||||||
@@ -1482,10 +1485,7 @@ name = "test"
|
|||||||
}
|
}
|
||||||
|
|
||||||
// B sends its bulk state to A.
|
// B sends its bulk state to A.
|
||||||
sink_b
|
sink_b.send(TMsg::Text(b_bulk_wire.into())).await.unwrap();
|
||||||
.send(TMsg::Text(b_bulk_wire.into()))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
|
||||||
@@ -1504,26 +1504,41 @@ name = "test"
|
|||||||
// ── Assert convergence ─────────────────────────────────────────────
|
// ── Assert convergence ─────────────────────────────────────────────
|
||||||
|
|
||||||
// Both nodes must have 2 items.
|
// Both nodes must have 2 items.
|
||||||
assert_eq!(crdt_a.doc.items.view().len(), 2,
|
assert_eq!(
|
||||||
"A must have 2 items after healing");
|
crdt_a.doc.items.view().len(),
|
||||||
assert_eq!(crdt_b.doc.items.view().len(), 2,
|
2,
|
||||||
"B must have 2 items after healing");
|
"A must have 2 items after healing"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
crdt_b.doc.items.view().len(),
|
||||||
|
2,
|
||||||
|
"B must have 2 items after healing"
|
||||||
|
);
|
||||||
|
|
||||||
// A must see B's story.
|
// A must see B's story.
|
||||||
let b_story_on_a = crdt_a.doc.items.view().iter().any(|item| {
|
let b_story_on_a = crdt_a
|
||||||
item.story_id.view() == JV::String("508_heal_b".to_string())
|
.doc
|
||||||
});
|
.items
|
||||||
|
.view()
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.story_id.view() == JV::String("508_heal_b".to_string()));
|
||||||
assert!(b_story_on_a, "A must have B's story after healing");
|
assert!(b_story_on_a, "A must have B's story after healing");
|
||||||
|
|
||||||
// B must see A's stage advance.
|
// B must see A's stage advance.
|
||||||
let a_story_on_b = crdt_b.doc.items.view().iter().any(|item| {
|
let a_story_on_b = crdt_b
|
||||||
item.story_id.view() == JV::String("508_heal_a".to_string())
|
.doc
|
||||||
});
|
.items
|
||||||
|
.view()
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.story_id.view() == JV::String("508_heal_a".to_string()));
|
||||||
assert!(a_story_on_b, "B must have A's story after healing");
|
assert!(a_story_on_b, "B must have A's story after healing");
|
||||||
|
|
||||||
// CRDT views must be byte-identical (convergence).
|
// CRDT views must be byte-identical (convergence).
|
||||||
let view_a = serde_json::to_string(&CrdtNode::view(&crdt_a.doc.items)).unwrap();
|
let view_a = serde_json::to_string(&CrdtNode::view(&crdt_a.doc.items)).unwrap();
|
||||||
let view_b = serde_json::to_string(&CrdtNode::view(&crdt_b.doc.items)).unwrap();
|
let view_b = serde_json::to_string(&CrdtNode::view(&crdt_b.doc.items)).unwrap();
|
||||||
assert_eq!(view_a, view_b, "Both nodes must converge to identical state");
|
assert_eq!(
|
||||||
|
view_a, view_b,
|
||||||
|
"Both nodes must converge to identical state"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,11 @@ mod tests {
|
|||||||
// ── helpers ──────────────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Build a fresh CRDT and return its keypair along with a signed insert op.
|
/// Build a fresh CRDT and return its keypair along with a signed insert op.
|
||||||
fn make_insert_op() -> (BaseCrdt<PipelineDoc>, bft_json_crdt::keypair::Ed25519KeyPair, SignedOp) {
|
fn make_insert_op() -> (
|
||||||
|
BaseCrdt<PipelineDoc>,
|
||||||
|
bft_json_crdt::keypair::Ed25519KeyPair,
|
||||||
|
SignedOp,
|
||||||
|
) {
|
||||||
let kp = make_keypair();
|
let kp = make_keypair();
|
||||||
let mut crdt = BaseCrdt::<PipelineDoc>::new(&kp);
|
let mut crdt = BaseCrdt::<PipelineDoc>::new(&kp);
|
||||||
let item: JsonValue = json!({
|
let item: JsonValue = json!({
|
||||||
@@ -172,11 +176,7 @@ mod tests {
|
|||||||
fn roundtrip_delete_op() {
|
fn roundtrip_delete_op() {
|
||||||
let (mut crdt, kp, insert_op) = make_insert_op();
|
let (mut crdt, kp, insert_op) = make_insert_op();
|
||||||
// Delete the inserted item.
|
// Delete the inserted item.
|
||||||
let delete_op = crdt
|
let delete_op = crdt.doc.items.delete(insert_op.inner.id).sign(&kp);
|
||||||
.doc
|
|
||||||
.items
|
|
||||||
.delete(insert_op.inner.id)
|
|
||||||
.sign(&kp);
|
|
||||||
crdt.apply(delete_op.clone());
|
crdt.apply(delete_op.clone());
|
||||||
|
|
||||||
let bytes = encode(&delete_op);
|
let bytes = encode(&delete_op);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user