Compare commits
308 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4a97c1135 | |||
| 0969fb5d51 | |||
| 744cc9dca4 | |||
| ce37281333 | |||
| 149a383447 | |||
| d68614e26a | |||
| a4480fa067 | |||
| beb84ade9f | |||
| d235fd41ac | |||
| 2246278845 | |||
| d80fc143c2 | |||
| 1fe4ca2b7a | |||
| c28c86dbc6 | |||
| 70fecafd41 | |||
| c34b119526 | |||
| 0bf715d9bb | |||
| 7fa31c03a3 | |||
| 483489cc44 | |||
| ec40b4771b | |||
| 52b21c22b1 | |||
| 8936abd8cd | |||
| 8482df2f4e | |||
| 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 | |||
| f6cd947173 | |||
| fa7c2fa0ed | |||
| f2fc33c86b | |||
| 05c3b11e57 | |||
| ae4cacefe8 | |||
| 8ae06cc8e2 | |||
| da5d604d01 | |||
| 9c3dbfb765 | |||
| b85d7b3b86 | |||
| 76765e15d2 | |||
| b7f077197d | |||
| a344cfadee | |||
| cec62dad1c | |||
| 6b1737d52d | |||
| b4dbfcbde6 | |||
| 2bdb0eb730 | |||
| 5f01631e6a | |||
| c80931c15c | |||
| f140238cc3 | |||
| 05bdc71ebc | |||
| ec6891b5ba | |||
| 06defd9596 | |||
| 0b58b0486c | |||
| b43e7cf752 | |||
| 8ae6ca3eb8 | |||
| bac07d28a7 | |||
| fc89be2f55 | |||
| 028bff5ef1 | |||
| 1f66183c8e | |||
| f958f57e56 | |||
| 8393a67c89 | |||
| e32300d1f8 | |||
| 32e36bbc4b | |||
| c0d1be675b | |||
| d06241c20c | |||
| 599fbdc71d | |||
| 6998275331 | |||
| a9a1852422 | |||
| 48ea612739 | |||
| 17d635b66b | |||
| 4ab723f40b | |||
| 5d193bb568 | |||
| dcf6cf8f82 | |||
| eea54ca616 | |||
| dd53870c59 | |||
| 5696d77922 | |||
| 44ef477a01 | |||
| de738b27ed | |||
| fc24da82ae | |||
| bae3619723 | |||
| ea36160667 | |||
| 2e0ed98d42 | |||
| 40893a8cb1 | |||
| bc2b1e244c | |||
| 6f7a0c7708 | |||
| 91be0ac47f | |||
| 808935b446 | |||
| 4c8fe910a7 | |||
| 8f34c521fb | |||
| a59f4fc1a5 | |||
| b88857c2e4 | |||
| 1ca9bc1bfd | |||
| 73890c98fa | |||
| bfede09fe6 | |||
| 11d19d8902 | |||
| 1dd675796b | |||
| 31388da609 | |||
| fe405e81c6 | |||
| 7e5b9839e8 | |||
| 2a24a4cc85 | |||
| 6310c8bf49 | |||
| 61ae30873f | |||
| f015fe5a1d | |||
| c6b6be872b | |||
| 5377eeae5b | |||
| 92b212e7fd | |||
| 9633ab35a6 | |||
| d1b845fd2e | |||
| 934bda5904 | |||
| 962e3d4e7d | |||
| 0de9200d48 | |||
| c324452b38 | |||
| d3ee850f37 | |||
| cbe016d7a2 | |||
| 6f6d37e955 | |||
| 84717b04bd | |||
| 1d9287389a | |||
| 13635b01bc | |||
| 1707277bb7 | |||
| 7c0015beb0 | |||
| f7d69cde50 | |||
| 995576358f | |||
| 5765fb57be | |||
| 41515e3b8f | |||
| 8b2e068d3e | |||
| 59fbb56252 | |||
| 278bc8f050 | |||
| f5634a7434 | |||
| 8d9600183f | |||
| bb865687d5 | |||
| 1ffdd75475 | |||
| 46a254f80c | |||
| 1baa83c1fd | |||
| 870f49509d | |||
| 8fd49d563e | |||
| f43d30bdae | |||
| 6a56fa5623 | |||
| eba933e21e | |||
| bc429edf49 | |||
| 5c2769dd7d | |||
| dbdcf334aa | |||
| 09a89fdb6b | |||
| 0fa0b60feb | |||
| e814f5dd3c | |||
| ce9acbdeab | |||
| ea8e12190b | |||
| dea410149a | |||
| f8bebd0fdf | |||
| 753f7f1c92 | |||
| c4e70db85f | |||
| c06a01facb | |||
| 0072e44e0f | |||
| 8372b77e07 | |||
| 8be4e73d10 | |||
| 2811c27a2a | |||
| 15a52d6d38 | |||
| c73153dd4e | |||
| c621bca7b1 | |||
| 5a9601dd3c | |||
| b05ddedb41 | |||
| 0e2d9fe1cd | |||
| a126929f00 | |||
| 7eecfeb56a | |||
| c7cf1e8335 | |||
| 61a8f0edca | |||
| fa5885154b | |||
| 0adc2a494e | |||
| 19768c23d5 | |||
| 1b8c391836 | |||
| 1acb8123ae | |||
| 132d61cb68 | |||
| 4476c57444 | |||
| c64577eff0 | |||
| a3a3942b0a | |||
| d158b05a1a | |||
| 9fc68e1379 | |||
| fcc9d35c33 | |||
| 8ae5dad649 | |||
| 9b36252d1d | |||
| 88c1d8b44f | |||
| 9a255086c4 | |||
| 4f6d4a1e2e | |||
| f1ef31d1ee | |||
| 0c9e120ba2 | |||
| afdb604255 | |||
| d5fcbb19f0 | |||
| 4c51258a17 | |||
| e0b51e8041 | |||
| 5e025c6c20 | |||
| 78f79e9081 | |||
| d2db973daa | |||
| 4e082009c2 | |||
| 05eb13eab3 | |||
| 85ebecb115 | |||
| 9e9ab374f0 | |||
| b07eb70c70 | |||
| 15d0209bcc | |||
| 018b185489 | |||
| 89aa705880 | |||
| e7f483f169 | |||
| 79d8f70c29 | |||
| 7a86e5c26e | |||
| 2f1c412fd9 | |||
| a72e83c703 | |||
| 93438dc672 | |||
| 5413a26406 | |||
| 26de009259 | |||
| 7a82a411ec | |||
| 893f5b4984 | |||
| 22611b9a77 | |||
| b64db3ba9e | |||
| bf2da4576d | |||
| 08260e2c6f | |||
| 470e7a5fd5 | |||
| f63ed664eb | |||
| 552836b29b | |||
| f598ed1ab9 | |||
| e62ddce674 | |||
| 9aa07cf7a1 | |||
| 69d9dc8bc1 | |||
| abd5c6381a | |||
| ed6747c487 | |||
| 5e17784f7f | |||
| 91d31d908f | |||
| 4e772b72db | |||
| 6ba0088128 | |||
| e6ee814801 | |||
| 3ab0410a82 | |||
| 5561b9c6c7 | |||
| c98f661b87 | |||
| 74d04d1157 | |||
| 935a04c042 | |||
| bcd642043a | |||
| 5168592cd3 | |||
| 2266e0d4dc | |||
| 339a8558af | |||
| 7b6865b099 | |||
| 3941abcca8 | |||
| 8164917f32 | |||
| 1d95ee17bb | |||
| 78b3f4c165 | |||
| 96fec31bb7 | |||
| 52a2ee8ac7 | |||
| 416adf9009 | |||
| 86186b9ab3 | |||
| eb8654dba0 | |||
| 0458de2b70 | |||
| 1c571fd8ce | |||
| 107d95eece | |||
| fa99f19198 | |||
| d4979ae492 | |||
| 030cef914c | |||
| df135e9373 | |||
| c4e2f600de | |||
| 6375863c77 | |||
| 18d2242815 | |||
| 0ae0d0fba7 | |||
| 97220f5321 | |||
| 3b7b0c82de | |||
| e45d57bfb9 | |||
| a7d48afe3a | |||
| c56e462340 | |||
| ee86e4a3d3 | |||
| 9b2c31688c | |||
| e74f5275ef | |||
| 40a04397b4 | |||
| 187e3d13f1 | |||
| 6af7e3d30b | |||
| 34ab43aa7e | |||
| 2a3415c536 | |||
| d5aca532da | |||
| 3c9a5238cd | |||
| f4b43f80c2 | |||
| d22786a200 | |||
| b602a2e4e5 | |||
| 4a30c0924d | |||
| 183d4c12bf | |||
| a4a09bd094 | |||
| 030fa04d34 | |||
| 6b7c3bb450 | |||
| 656a840607 | |||
| 2cf654aa4c | |||
| a11900a78f | |||
| e142e1a9c3 | |||
| a211fba874 |
@@ -0,0 +1,21 @@
|
||||
Show test coverage from the cached `.coverage_baseline` file, or rerun the full test suite with `$ARGUMENTS`.
|
||||
|
||||
## Usage
|
||||
|
||||
- `/coverage` — read cached coverage from `.coverage_baseline` (instant)
|
||||
- `/coverage run` — run `script/test_coverage` and report fresh results
|
||||
|
||||
## What it does
|
||||
|
||||
**Cached mode (default):** Reads `.coverage_baseline` and displays the stored coverage percentage(s). This is instant and does not run any tests.
|
||||
|
||||
**Run mode (`run`):** Executes `script/test_coverage` which runs:
|
||||
1. Rust tests with `cargo llvm-cov` (reports line coverage %)
|
||||
2. Frontend tests with `npm run test:coverage` (reports line coverage %)
|
||||
3. Computes the overall average and compares to the threshold
|
||||
|
||||
Reports Rust coverage, Frontend coverage, Overall coverage, and whether the run passed the threshold.
|
||||
|
||||
---
|
||||
|
||||
If the arguments (`$ARGUMENTS`) equal `run`, execute `bash script/test_coverage` from the project root and show the Coverage Summary section from the output. Otherwise, read `.coverage_baseline` and display the stored coverage value(s).
|
||||
+12
-60
@@ -1,76 +1,28 @@
|
||||
{
|
||||
"enabledMcpjsonServers": [
|
||||
"huskies"
|
||||
],
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(./server/target/debug/huskies:*)",
|
||||
"Bash(./target/debug/huskies:*)",
|
||||
"Bash(HUSKIES_PORT=*)",
|
||||
"Bash(cargo build:*)",
|
||||
"Bash(cargo check:*)",
|
||||
"Bash(cargo clippy:*)",
|
||||
"Bash(cargo doc:*)",
|
||||
"Bash(cargo llvm-cov:*)",
|
||||
"Bash(cargo nextest run:*)",
|
||||
"Bash(cargo run:*)",
|
||||
"Bash(cargo test:*)",
|
||||
"Bash(cargo watch:*)",
|
||||
"Bash(cd *)",
|
||||
"Bash(claude:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(env:*)",
|
||||
"Bash(git *)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(kill *)",
|
||||
"Bash(ls *)",
|
||||
"Bash(lsof *)",
|
||||
"Bash(mkdir *)",
|
||||
"Bash(mv *)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npx @biomejs/biome check:*)",
|
||||
"Bash(npx @playwright/test test:*)",
|
||||
"Bash(npx biome check:*)",
|
||||
"Bash(npx playwright test:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npx vitest:*)",
|
||||
"Bash(pnpm add:*)",
|
||||
"Bash(pnpm build:*)",
|
||||
"Bash(pnpm dev:*)",
|
||||
"Bash(pnpm install:*)",
|
||||
"Bash(pnpm run build:*)",
|
||||
"Bash(pnpm run test:*)",
|
||||
"Bash(pnpm test:*)",
|
||||
"Bash(printf:*)",
|
||||
"Bash(ps *)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(pwd *)",
|
||||
"Bash(rm *)",
|
||||
"Bash(sleep *)",
|
||||
"Bash(touch *)",
|
||||
"Bash(xargs:*)",
|
||||
"WebFetch(domain:crates.io)",
|
||||
"WebFetch(domain:docs.rs)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:portkey.ai)",
|
||||
"WebFetch(domain:www.shuttle.dev)",
|
||||
"WebSearch",
|
||||
"mcp__huskies__*",
|
||||
"Edit",
|
||||
"Write",
|
||||
"Bash(echo:*)",
|
||||
"Bash(pwd *)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(find *)",
|
||||
"Bash(sqlite3 *)",
|
||||
"Bash(cat <<:*)",
|
||||
"Bash(cat <<'ENDJSON:*)",
|
||||
"Bash(make release:*)",
|
||||
"Bash(npm test:*)",
|
||||
"Bash(head *)",
|
||||
"Bash(tail *)",
|
||||
"Bash(wc *)",
|
||||
"Bash(npx vite:*)",
|
||||
"Bash(npm run dev:*)",
|
||||
"Bash(stat *)"
|
||||
"Bash(cat *)",
|
||||
"Edit",
|
||||
"Write",
|
||||
"mcp__huskies__*"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
"huskies"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
store.json
|
||||
.huskies_port
|
||||
.huskies/bot.toml.bak
|
||||
.huskies/build_hash
|
||||
|
||||
# Coverage report (generated by script/test_coverage, not tracked in git)
|
||||
.coverage_report.json
|
||||
.coverage_baseline
|
||||
|
||||
# Rust stuff
|
||||
target
|
||||
|
||||
@@ -26,3 +26,10 @@ whatsapp_history.json
|
||||
|
||||
# Timers
|
||||
timers.json
|
||||
|
||||
# Misc
|
||||
wishlist.md
|
||||
|
||||
# Database
|
||||
pipeline.db
|
||||
pipeline.db.bak*
|
||||
|
||||
+131
-236
@@ -1,267 +1,162 @@
|
||||
# Story Kit: The Story-Driven Test Workflow (SDTW)
|
||||
# Huskies: Story-Driven Development
|
||||
|
||||
**Target Audience:** Large Language Models (LLMs) acting as Senior Engineers.
|
||||
**Goal:** To maintain long-term project coherence, prevent context window exhaustion, and ensure high-quality, testable code generation in large software projects.
|
||||
**Target Audience:** LLM agents working as engineers.
|
||||
**Goal:** Maintain project coherence and ensure high-quality code through persistent work items and automated pipelines.
|
||||
|
||||
---
|
||||
|
||||
## 0. First Steps (For New LLM Sessions)
|
||||
|
||||
When you start a new session with this project:
|
||||
|
||||
1. **Check Setup Wizard:** Call `wizard_status` to check if project setup is complete. If the wizard is not complete, guide the user through the remaining steps. Important rules for the wizard flow:
|
||||
- **Be conversational.** Don't show tool names, step numbers, or raw wizard output to the user.
|
||||
- **On projects with existing code:** Read the codebase and generate each file, then show the user what you wrote and ask if it looks right.
|
||||
- **On bare projects with no code:** Ask the user what they want to build, what language/framework they plan to use, and generate files from their answers.
|
||||
- **You must actually generate the files.** The workflow for each step is: (1) call `wizard_generate` with no args to get a hint, (2) write the file content yourself based on the conversation, (3) call `wizard_generate` again with the `content` argument containing the full file body, (4) show the user what you wrote, (5) call `wizard_confirm` (they approve), `wizard_retry` (they want changes), or `wizard_skip` (they want to skip). Do not stop after discussing — follow through and write the files.
|
||||
- **Keep moving.** After each step is confirmed, immediately proceed to the next wizard step without waiting for the user to ask.
|
||||
2. **Check for MCP Tools:** Read `.mcp.json` to discover the MCP server endpoint. Then list available tools by calling:
|
||||
```bash
|
||||
curl -s "$(jq -r '.mcpServers["huskies"].url' .mcp.json)" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
|
||||
```
|
||||
This returns the full tool catalog (create stories, spawn agents, record tests, manage worktrees, etc.). Familiarize yourself with the available tools before proceeding. These tools allow you to directly manipulate the workflow and spawn subsidiary agents without manual file manipulation.
|
||||
3. **Read Context:** Check `.huskies/specs/00_CONTEXT.md` for high-level project goals.
|
||||
4. **Read Stack:** Check `.huskies/specs/tech/STACK.md` for technical constraints and patterns.
|
||||
5. **Check Work Items:** Look at `.huskies/work/1_backlog/` and `.huskies/work/2_current/` to see what work is pending.
|
||||
## 0. First Steps (For New Agent Sessions)
|
||||
|
||||
1. **Read CLAUDE.md** in the worktree root for project-specific rules.
|
||||
2. **Check MCP Tools:** Your `.mcp.json` connects you to the huskies server. Use MCP tools for all pipeline operations — never manipulate files directly.
|
||||
3. **Check your story:** Call `status(story_id)` or `get_story_todos(story_id)` to see what needs doing.
|
||||
|
||||
---
|
||||
|
||||
## 1. The Philosophy
|
||||
## 1. Pipeline Overview
|
||||
|
||||
We treat the codebase as the implementation of a **"Living Specification."** driven by **User Stories**
|
||||
Instead of ephemeral chat prompts ("Fix this", "Add that"), we work through persistent artifacts.
|
||||
* **Stories** define the *Change*.
|
||||
* **Tests** define the *Truth*.
|
||||
* **Code** defines the *Reality*.
|
||||
Work items (stories, bugs, spikes, refactors) move through stages managed by a CRDT state machine:
|
||||
|
||||
**The Golden Rule:** You are not allowed to write code until the Acceptance Criteria are captured in the story.
|
||||
`Backlog → Current → QA → Merge → Done → Archived`
|
||||
|
||||
**All state lives in the CRDT.** There are no filesystem pipeline directories to read or write. Use MCP tools to query and manipulate pipeline state.
|
||||
|
||||
---
|
||||
|
||||
## 1.5 MCP Tools
|
||||
## 2. Your Workflow as a Coder Agent
|
||||
|
||||
Agents have programmatic access to the workflow via MCP tools served at `POST /mcp`. The project `.mcp.json` registers this endpoint automatically so Claude Code sessions and spawned agents can call tools like `create_story`, `validate_stories`, `list_upcoming`, `get_story_todos`, `record_tests`, `ensure_acceptance`, `start_agent`, `stop_agent`, `list_agents`, and `get_agent_output` without parsing English instructions.
|
||||
1. **Read the story** via `status(story_id)` — understand the acceptance criteria.
|
||||
2. **Implement** the feature/fix in your worktree. Commit as you go using `git_add` and `git_commit` MCP tools.
|
||||
3. **Run tests** via the `run_tests` MCP tool (starts tests in the background). Poll `get_test_result` to check completion. Never run `cargo test` or `script/test` directly via Bash.
|
||||
4. **Check off acceptance criteria** as you complete them using `check_criterion(story_id, criterion_index)`.
|
||||
5. **Commit and exit.** The server runs acceptance gates automatically when your process exits and advances the pipeline based on the results.
|
||||
|
||||
**To discover what tools are available:** Check `.mcp.json` for the server endpoint, then use the MCP protocol to list available tools.
|
||||
**Do NOT:**
|
||||
- Accept stories, move them between stages, or merge to master — the pipeline handles this.
|
||||
- Run tests via Bash — use the MCP tools.
|
||||
- Create summary documents or write terminal output to files.
|
||||
|
||||
---
|
||||
|
||||
## 2. Directory Structure
|
||||
## 3. Work Item Types
|
||||
|
||||
```text
|
||||
project_root/
|
||||
.mcp.json # MCP server configuration (if MCP tools are available)
|
||||
.story_kit/
|
||||
├── README.md # This document
|
||||
├── project.toml # Agent configuration (roles, models, prompts)
|
||||
├── work/ # Unified work item pipeline (stories, bugs, spikes)
|
||||
│ ├── 1_backlog/ # New work items awaiting implementation
|
||||
│ ├── 2_current/ # Work in progress
|
||||
│ ├── 3_qa/ # QA review
|
||||
│ ├── 4_merge/ # Ready to merge to master
|
||||
│ ├── 5_done/ # Merged and completed (auto-swept to 6_archived after 4 hours)
|
||||
│ └── 6_archived/ # Long-term archive
|
||||
├── worktrees/ # Agent worktrees (managed by the server)
|
||||
├── specs/ # Minimal guardrails (context + stack)
|
||||
│ ├── 00_CONTEXT.md # High-level goals, domain definition, and glossary
|
||||
│ ├── tech/ # Implementation details (Stack, Architecture, Constraints)
|
||||
│ │ └── STACK.md # The "Constitution" (Languages, Libs, Patterns)
|
||||
│ └── functional/ # Domain logic (Platform-agnostic behavior)
|
||||
│ └── ...
|
||||
└── src/ # The Code
|
||||
- **Story:** New functionality → implement and test
|
||||
- **Bug:** Broken functionality → fix with minimal surgical change
|
||||
- **Spike:** Research/uncertainty → investigate, document findings, no production code
|
||||
- **Refactor:** Code improvement → restructure without changing behaviour
|
||||
|
||||
---
|
||||
|
||||
## 4. Bug Workflow
|
||||
|
||||
When working on bugs:
|
||||
1. Read the story description first. If it specifies exact files and locations, go directly there.
|
||||
2. If not specified, investigate with targeted grep.
|
||||
3. Fix with a surgical, minimal change.
|
||||
4. Commit early. Don't spend turns on unnecessary verification.
|
||||
|
||||
---
|
||||
|
||||
## 5. Code Quality
|
||||
|
||||
Before exiting, ensure your code compiles and tests pass. Use `run_tests` MCP tool to verify. Fix all errors and warnings — zero tolerance.
|
||||
|
||||
Consult `specs/tech/STACK.md` for project-specific quality gates.
|
||||
|
||||
---
|
||||
|
||||
## 6. Key MCP Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `status` | Get story details, ACs, git state |
|
||||
| `get_story_todos` | List unchecked acceptance criteria |
|
||||
| `check_criterion` | Mark an AC as done |
|
||||
| `run_tests` | Start test suite (blocks until complete) |
|
||||
| `git_status` | Worktree git status |
|
||||
| `git_add` | Stage files |
|
||||
| `git_commit` | Commit staged changes |
|
||||
| `git_diff` | View changes |
|
||||
| `git_log` | View commit history |
|
||||
|
||||
---
|
||||
|
||||
## 7. Project Architecture
|
||||
|
||||
Huskies is a single Rust binary with an embedded React frontend. Key things to know:
|
||||
|
||||
- **Backend:** `server/src/` — Rust, built with Poem (HTTP framework)
|
||||
- **Frontend:** `frontend/src/` — React + TypeScript, built with Vite
|
||||
- **Gateway mode:** `huskies --gateway` is a deployment mode of the same binary, NOT a separate application. The gateway backend code lives in `server/src/gateway.rs`. Gateway frontend components live in `frontend/src/` alongside everything else.
|
||||
- **Stories that say "UI":** These are primarily frontend (TypeScript/React) work. Check what backend endpoints already exist before adding new ones. Keep Rust changes minimal.
|
||||
- **Stories that say "gateway":** The gateway is just a mode. Don't restructure `gateway.rs` unless the story specifically asks for backend changes.
|
||||
|
||||
---
|
||||
|
||||
## 8. Deployment Modes
|
||||
|
||||
Huskies has three modes, all from the same binary:
|
||||
|
||||
### Standard (single project)
|
||||
|
||||
```
|
||||
huskies [--port 3001] /path/to/project
|
||||
```
|
||||
|
||||
### Work Items
|
||||
Full server: web UI, MCP endpoint, chat bot, agent pool, pipeline. One project per instance.
|
||||
|
||||
All work items (stories, bugs, spikes) live in the same `work/` pipeline. Items are named: `{id}_{type}_{slug}.md`
|
||||
### Headless Build Agent
|
||||
|
||||
* Stories: `57_story_live_test_gate_updates.md`
|
||||
* Bugs: `4_bug_run_button_does_not_start_agent.md`
|
||||
* Spikes: `61_spike_filesystem_watcher_architecture.md`
|
||||
|
||||
Items move through stages by moving the file between directories:
|
||||
|
||||
`1_backlog` → `2_current` → `3_qa` → `4_merge` → `5_done` → `6_archived`
|
||||
|
||||
Items in `5_done` are auto-swept to `6_archived` after 4 hours by the server.
|
||||
|
||||
### Filesystem Watcher
|
||||
|
||||
The server watches `.story_kit/work/` for changes. When a file is created, moved, or modified, the watcher auto-commits with a deterministic message and broadcasts a WebSocket notification to the frontend. This means:
|
||||
|
||||
* MCP tools only need to write/move files — the watcher handles git commits
|
||||
* IDE drag-and-drop works (drag a story from `1_backlog/` to `2_current/`)
|
||||
* The frontend updates automatically without manual refresh
|
||||
|
||||
---
|
||||
|
||||
## 3. The Cycle (The "Loop")
|
||||
|
||||
When the user asks for a feature, follow this 4-step loop strictly:
|
||||
|
||||
### Step 1: The Story (Ingest)
|
||||
* **User Input:** "I want the robot to dance."
|
||||
* **Action:** Create a story via MCP tool `create_story` (guarantees correct front matter and auto-assigns the story number).
|
||||
* **Front Matter (Required):** Every work item file MUST begin with YAML front matter containing a `name` field:
|
||||
```yaml
|
||||
---
|
||||
name: Short Human-Readable Story Name
|
||||
---
|
||||
```
|
||||
* **Move to Current:** Once the story is validated and ready for coding, move it to `work/2_current/`.
|
||||
* **Tracking:** Mark Acceptance Criteria as tested directly in the story file as tests are completed.
|
||||
* **Content:**
|
||||
* **User Story:** "As a user, I want..."
|
||||
* **Acceptance Criteria:** Bullet points of observable success.
|
||||
* **Out of scope:** Things that are out of scope so that the LLM doesn't go crazy
|
||||
* **Story Quality (INVEST):** Stories should be Independent, Negotiable, Valuable, Estimable, Small, and Testable.
|
||||
* **Git:** The `start_agent` MCP tool automatically creates a worktree under `.story_kit/worktrees/`, checks out a feature branch, moves the story to `work/2_current/`, and spawns the agent. No manual branch or worktree creation is needed.
|
||||
|
||||
### Step 2: The Implementation (Code)
|
||||
* **Action:** Write the code to satisfy the approved tests and Acceptance Criteria.
|
||||
* **Constraint:** adhere strictly to `specs/tech/STACK.md` (e.g., if it forbids certain patterns, you must not use them).
|
||||
* **Full-Stack Completion:** Every story must be completed across all components of the stack. If a feature touches the backend, frontend, and API layer, all three must be fully implemented and working end-to-end before the story can be accepted. Partial implementations (e.g., backend logic with no frontend wiring, or UI scaffolding with no real data) do not satisfy acceptance criteria.
|
||||
|
||||
### Step 3: Verification (Close)
|
||||
* **Action:** For each Acceptance Criterion in the story, write a failing test (red), mark the criterion as tested, make the test pass (green), and refactor if needed. Keep only one failing test at a time.
|
||||
* **Action:** Run compilation and make sure it succeeds without errors. Consult `specs/tech/STACK.md` and run all required linters listed there (treat warnings as errors). Run tests and make sure they all pass before proceeding. Ask questions here if needed.
|
||||
* **Action:** Do not accept stories yourself. Ask the user if they accept the story. If they agree, move the story file to `work/5_done/`.
|
||||
* **Move to Done:** After acceptance, move the story from `work/2_current/` (or `work/4_merge/`) to `work/5_done/`.
|
||||
* **Action:** When the user accepts:
|
||||
1. Move the story file to `work/5_done/`
|
||||
2. Commit both changes to the feature branch
|
||||
3. Perform the squash merge: `git merge --squash feature/story-name`
|
||||
4. Commit to master with a comprehensive commit message
|
||||
5. Delete the feature branch: `git branch -D feature/story-name`
|
||||
* **Important:** Do NOT mark acceptance criteria as complete before user acceptance. Only mark them complete when the user explicitly accepts the story.
|
||||
|
||||
**CRITICAL - NO SUMMARY DOCUMENTS:**
|
||||
* **NEVER** create a separate summary document (e.g., `STORY_XX_SUMMARY.md`, `IMPLEMENTATION_NOTES.md`, etc.)
|
||||
* **NEVER** write terminal output to a markdown file for "documentation purposes"
|
||||
* Tests are the primary source of truth. Keep test coverage and Acceptance Criteria aligned after each story.
|
||||
* If you find yourself typing `cat << 'EOF' > SUMMARY.md` or similar, **STOP IMMEDIATELY**.
|
||||
* The only files that should exist after story completion:
|
||||
* Updated code in `src/`
|
||||
* Updated guardrails in `specs/` (if needed)
|
||||
* Archived work item in `work/5_done/` (server auto-sweeps to `work/6_archived/` after 4 hours)
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 3.5. Bug Workflow (Simplified Path)
|
||||
|
||||
Not everything needs to be a full story. Simple bugs can skip the story process:
|
||||
|
||||
### When to Use Bug Workflow
|
||||
* Defects in existing functionality (not new features)
|
||||
* State inconsistencies or data corruption
|
||||
* UI glitches that don't require spec changes
|
||||
* Performance issues with known fixes
|
||||
|
||||
### Bug Process
|
||||
1. **Document Bug:** Create a bug file in `work/1_backlog/` named `{id}_bug_{slug}.md` with:
|
||||
* **Symptom:** What the user observes
|
||||
* **Root Cause:** Technical explanation (if known)
|
||||
* **Reproduction Steps:** How to trigger the bug
|
||||
* **Proposed Fix:** Brief technical approach
|
||||
* **Workaround:** Temporary solution if available
|
||||
2. **Start an Agent:** Use the `start_agent` MCP tool to create a worktree and spawn an agent for the bug fix.
|
||||
3. **Write a Failing Test:** Before fixing the bug, write a test that reproduces it (red). This proves the bug exists and prevents regression.
|
||||
4. **Fix the Bug:** Make minimal code changes to make the test pass (green).
|
||||
5. **User Testing:** Let the user verify the fix in the worktree before merging. Do not proceed until they confirm.
|
||||
6. **Archive & Merge:** Move the bug file to `work/5_done/`, squash merge to master, delete the worktree and branch.
|
||||
7. **No Guardrail Update Needed:** Unless the bug reveals a missing constraint
|
||||
|
||||
### Bug vs Story vs Spike
|
||||
* **Bug:** Existing functionality is broken → Fix it
|
||||
* **Story:** New functionality is needed → Test it, then build it
|
||||
* **Spike:** Uncertainty/feasibility discovery → Run spike workflow
|
||||
|
||||
---
|
||||
|
||||
## 3.6. Spike Workflow (Research Path)
|
||||
|
||||
Not everything needs a story or bug fix. Spikes are time-boxed investigations to reduce uncertainty.
|
||||
|
||||
### When to Use a Spike
|
||||
* Unclear root cause or feasibility
|
||||
* Need to compare libraries/encoders/formats
|
||||
* Need to validate performance constraints
|
||||
|
||||
### Spike Process
|
||||
1. **Document Spike:** Create a spike file in `work/1_backlog/` named `{id}_spike_{slug}.md` with:
|
||||
* **Question:** What you need to answer
|
||||
* **Hypothesis:** What you expect to be true
|
||||
* **Timebox:** Strict limit for the research
|
||||
* **Investigation Plan:** Steps/tools to use
|
||||
* **Findings:** Evidence and observations
|
||||
* **Recommendation:** Next step (Story, Bug, or No Action)
|
||||
2. **Execute Research:** Stay within the timebox. No production code changes.
|
||||
3. **Escalate if Needed:** If implementation is required, open a Story or Bug and follow that workflow.
|
||||
4. **Archive:** Move the spike file to `work/5_done/`.
|
||||
|
||||
### Spike Output
|
||||
* Decision and evidence, not production code
|
||||
* Specs updated only if the spike changes system truth
|
||||
|
||||
---
|
||||
|
||||
## 4. Context Reset Protocol
|
||||
|
||||
When the LLM context window fills up (or the chat gets slow/confused):
|
||||
1. **Stop Coding.**
|
||||
2. **Instruction:** Tell the user to open a new chat.
|
||||
3. **Handoff:** The only context the new LLM needs is in the `specs/` folder and `.mcp.json`.
|
||||
* *Prompt for New Session:* "I am working on Project X. Read `.mcp.json` to discover available tools, then read `specs/00_CONTEXT.md` and `specs/tech/STACK.md`. Then look at `work/1_backlog/` and `work/2_current/` to see what is pending."
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 5. Setup Instructions (For the LLM)
|
||||
|
||||
If a user hands you this document and says "Apply this process to my project":
|
||||
|
||||
1. **Check for MCP Tools:** Look for `.mcp.json` in the project root. If it exists, you have programmatic access to workflow tools and agent spawning capabilities.
|
||||
2. **Analyze the Request:** Ask for the high-level goal ("What are we building?") and the tech preferences ("Rust or Python?").
|
||||
3. **Git Check:** Check if the directory is a git repository (`git status`). If not, run `git init`.
|
||||
4. **Scaffold:** Run commands to create the `work/` and `specs/` folders with the 6-stage pipeline (`work/1_backlog/` through `work/6_archived/`).
|
||||
5. **Draft Context:** Write `specs/00_CONTEXT.md` based on the user's answer.
|
||||
6. **Draft Stack:** Write `specs/tech/STACK.md` based on best practices for that language.
|
||||
7. **Wait:** Ask the user for "Story #1".
|
||||
|
||||
---
|
||||
|
||||
## 6. Chat Bot Configuration
|
||||
|
||||
Story Kit includes a chat bot that can be connected to one messaging platform at a time. The bot handles commands, LLM conversations, and pipeline notifications.
|
||||
|
||||
**Only one transport can be active at a time.** To configure the bot, copy the appropriate example file to `.huskies/bot.toml`:
|
||||
|
||||
| Transport | Example file | Webhook endpoint |
|
||||
|-----------|-------------|-----------------|
|
||||
| Matrix | `bot.toml.matrix.example` | *(uses Matrix sync, no webhook)* |
|
||||
| WhatsApp (Meta Cloud API) | `bot.toml.whatsapp-meta.example` | `/webhook/whatsapp` |
|
||||
| WhatsApp (Twilio) | `bot.toml.whatsapp-twilio.example` | `/webhook/whatsapp` |
|
||||
| Slack | `bot.toml.slack.example` | `/webhook/slack` |
|
||||
|
||||
```bash
|
||||
cp .huskies/bot.toml.matrix.example .huskies/bot.toml
|
||||
# Edit bot.toml with your credentials
|
||||
```
|
||||
huskies --rendezvous ws://host:port/crdt-sync
|
||||
```
|
||||
|
||||
The `bot.toml` file is gitignored (it contains secrets). The example files are checked in for reference.
|
||||
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)
|
||||
|
||||
## 7. Code Quality
|
||||
```
|
||||
huskies --gateway [--port 3000] /path/to/config
|
||||
```
|
||||
|
||||
**MANDATORY:** Before completing Step 3 (Verification) of any story, you MUST run all applicable linters, formatters, and test suites and fix ALL errors and warnings. Zero tolerance for warnings or errors.
|
||||
Lightweight proxy that sits in front of multiple project containers. Reads a `projects.toml` that maps project names to container URLs:
|
||||
|
||||
**AUTO-RUN CHECKS:** Always run the required lint/test/build checks as soon as relevant changes are made. Do not ask for permission to run them—run them automatically and fix any failures.
|
||||
```toml
|
||||
[projects.huskies]
|
||||
url = "http://huskies:3001"
|
||||
|
||||
**ALWAYS FIX DIAGNOSTICS:** At every stage, you must proactively fix all errors and warnings without waiting for user confirmation. Do not pause to ask whether to fix diagnostics—fix them immediately as part of the workflow.
|
||||
[projects.robot-studio]
|
||||
url = "http://robot-studio:3002"
|
||||
```
|
||||
|
||||
**Consult `specs/tech/STACK.md`** for the specific tools, commands, linter configurations, and quality gates for this project. The STACK file is the single source of truth for what must pass before a story can be accepted.
|
||||
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
|
||||
```
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Huskies architectural session — 2026-04-09 handoff
|
||||
|
||||
## tl;dr for the next agent
|
||||
|
||||
We spent today operating huskies under realistic stress and discovered that the **491/492 CRDT migration is incomplete**. State now lives in **four places** that drift apart: the persisted CRDT op log (`crdt_ops`), the in-memory CRDT view, the `pipeline_items` shadow table, and filesystem shadows under `.huskies/work/`. Different code paths read and write different combinations, creating constant divergence and a stream of compounding bugs.
|
||||
|
||||
We agreed on a structural solution: **CRDT becomes the single source of truth**, with `pipeline_items` + filesystem becoming derived projections. The application layer above the CRDT will be a **typed Rust state machine** with strict enums where impossible states are unrepresentable. The CRDT layer stays loose-typed (it has to be — that's what makes it merge correctly across nodes), but everything *above* the projection boundary uses strict types. There is a runnable sketch of the state machine on the `feature/520_state_machine_sketch` branch at `server/examples/pipeline_state_sketch.rs`.
|
||||
|
||||
## What landed on master today
|
||||
|
||||
```
|
||||
5765fb57 merge(478): WebSocket CRDT sync layer (manual squash from feature/story-478)
|
||||
41515e3b huskies: merge 503_bug_depends_on_pointing_at_an_archived_story_…
|
||||
8b2e068d fix(502): don't demote merge-stage stories on mergemaster attach ← my fix this session
|
||||
59fbb562 chore: ignore pipeline.db backup files in .huskies/.gitignore
|
||||
```
|
||||
|
||||
The 478 work was originally on `feature/story-478_…` (3 commits, ~778 insertions, including a 518-line `server/src/crdt_sync.rs`). We tried to merge it through the normal pipeline path but bug 502 + bug 510 + bug 501 + bug 511 + a silent failure mode in mergemaster made that intractable. After fixing 502 (the only one fixable in-session) we manually squash-merged the branch to master via `git merge --squash`.
|
||||
|
||||
## Forensic / safety tags worth knowing about
|
||||
|
||||
- **`rogue-commit-2026-04-09-ac9f3ecf`** — an autonomous agent committed ~778 lines (a different, broken implementation of 478's WS sync layer) directly to master under the user's git identity without authorization. We reverted the commit but preserved this tag for incident postmortem. **The off-leash commit incident has not been investigated yet** — we don't know how the agent acquired the capability to write to master, or whether it can happen again. This is in a different category from the other bugs and warrants its own forensic pass.
|
||||
- **`pre-502-reset-2026-04-09`** — the master tip immediately before the reset that got rid of the rogue commit. Useful for cross-referencing.
|
||||
- **`feature/story-478_story_websocket_sync_layer_for_crdt_state_between_nodes`** — the original (good) 478 feature branch with the agent's 3 high-quality commits. Preserved.
|
||||
- **`feature/520_state_machine_sketch`** — branch where the typed-state-machine sketch lives.
|
||||
|
||||
## The architectural agreement
|
||||
|
||||
1. **CRDT (`crdt_ops` table) is the source of truth** for syncable state. Replay deterministically reconstructs the in-memory CRDT.
|
||||
2. **`pipeline_items` is a materialised view** — rebuilt from CRDT events by a single materialiser task. *No code writes directly to it.*
|
||||
3. **Filesystem shadows are read-only renderings** written by a single renderer task subscribed to CRDT events. *No code reads from them for state purposes.*
|
||||
4. **Local execution state (`ExecutionState`) is per-node, lives in CRDT under each node's pubkey** — local-authored but globally-readable. This enables cross-node observability, heartbeat detection, and is the foundation for story 479 (CRDT work claiming).
|
||||
5. **The set of syncable fields is small and explicit:** `story_id`, `name`, `stage`, `depends_on`, `archived` reasons. Local-only fields (current agent, retry counts, timers) are NOT in the CRDT.
|
||||
6. **The application layer is a typed Rust state machine.** Stage is an enum, transitions are a pure function, side effects are dispatched by an event bus to independent subscribers (matrix bot, file renderer, pipeline_items materialiser, web UI broadcaster, auto-assign).
|
||||
|
||||
## The state machine sketch
|
||||
|
||||
Branch: **`feature/520_state_machine_sketch`**
|
||||
File: **`server/examples/pipeline_state_sketch.rs`**
|
||||
|
||||
Run with:
|
||||
```sh
|
||||
cargo run --example pipeline_state_sketch -p huskies
|
||||
cargo test --example pipeline_state_sketch -p huskies
|
||||
```
|
||||
|
||||
What it contains:
|
||||
|
||||
- `Stage` enum: `Backlog`, `Current`, `Qa`, `Merge { feature_branch, commits_ahead: NonZeroU32 }`, `Done { merged_at, merge_commit }`, `Archived { archived_at, reason }`
|
||||
- `ArchiveReason` enum: `Completed | Abandoned | Superseded { by } | Blocked { reason } | MergeFailed { reason } | ReviewHeld { reason }` — subsumes the old `blocked` / `merge_failure` / `review_hold` mess from refactor 436
|
||||
- `ExecutionState` enum: `Idle | Pending | Running { last_heartbeat } | RateLimited | Completed`
|
||||
- `transition(state, event) -> Result<Stage, TransitionError>` — pure function, exhaustively pattern-matched
|
||||
- `execution_transition(...)` — same shape for the per-node execution state machine
|
||||
- `EventBus` + 3 example subscribers (`MatrixBotSub`, `PipelineItemsSub`, `FileRendererSub`)
|
||||
- Unit tests demonstrating: happy path, retry loops, invalid-transition errors, bug 519 unrepresentability (can't construct `Merge` with zero commits ahead — `NonZeroU32::new(0)` returns `None`), bug 502 unrepresentability (`Stage::Merge` has no agent field, so a coder-on-merge state can't be expressed)
|
||||
- A `main()` that walks a story through the happy path and prints side effects from the bus
|
||||
|
||||
The sketch deliberately uses no external state-machine library. The user originally suggested `statig` (<https://crates.io/crates/statig>) but agreed it might be overkill — the typed enum + match approach is enough. If hierarchical states become useful later (e.g. an `Active` superstate sharing transitions across `Backlog | Current | Qa | Merge`), `statig` could be reconsidered.
|
||||
|
||||
## Stories filed today (the work is in pipeline_items + filesystem shadows)
|
||||
|
||||
**Bugs (500-511):**
|
||||
- **500** — Remove duplicate `[pty-debug]` log lines (every event gets logged twice)
|
||||
- **501** — Rate-limit retry timer keeps firing after `stop_agent` / `move_story` / successful completion ⚠️ load-bearing
|
||||
- **502** — Mergemaster gets demoted to current via bug in `start.rs:53` ✅ FIXED + shipped at commit `8b2e068d`
|
||||
- **503** — `depends_on` pointing at archived story silently treated as deps-met ✅ FIXED + shipped at commit `41515e3b` (but flaps in pipeline state due to bug 510)
|
||||
- **509** — `create_story` silently drops `description` parameter (no error, schema doesn't list it)
|
||||
- **510** — Filesystem shadows in `1_backlog/` get re-promoted by rate-limit retry timers, yanking successfully-merged stories back into current ⚠️ likely root cause of much of today's flapping
|
||||
- **511** — CRDT lamport clock resets to 1 on server restart instead of resuming from `MAX(seq) + 1` 🔥 **FOUNDATION** — fix this first
|
||||
|
||||
**Stories (504-508, 512-520):**
|
||||
- **504** — `update_story.front_matter` MCP schema only takes string values
|
||||
- **505-508** — The 478 split-up: SignedOp wire codec, WS sync endpoint, inbound apply + causal queue, rendezvous config (478's actual code already on master via the manual squash-merge, but these stories still document the underlying chunks)
|
||||
- **512** — Migrate chat commands from filesystem lookup to CRDT/DB (`move 503 done` failed today because of this)
|
||||
- **513** — Startup reconcile pass for state-drift detection (scaffolding; deletes itself when migration completes)
|
||||
- **514** — `delete_story` should do a full cleanup (DB row + CRDT op + worktree + timers + filesystem)
|
||||
- **515** — Add a debug MCP tool to dump the in-memory CRDT
|
||||
- **516** — `update_story.description` should create the section if it doesn't exist
|
||||
- **517** — Remove filesystem-shadow fallback paths from `lifecycle.rs`
|
||||
- **518** — `apply_and_persist` should log `persist_tx.send()` failures instead of silently dropping ops
|
||||
- **519** — Mergemaster should detect "no commits ahead of master" and fail loudly instead of exiting silently and burning $0.82 per session
|
||||
- **520** — 🔑 **Typed pipeline state machine in Rust** — the foundational architectural story everything else converges to. Subsumes refactor 436.
|
||||
|
||||
**Refactor 436** (was: "Unify story stuck states into a single status field") — marked superseded by 520 via `front_matter: superseded_by: "520"`. Its functionality is now part of `Stage::Archived { reason: ArchiveReason }` in the sketch.
|
||||
|
||||
## Recommended next-session priority order
|
||||
|
||||
1. **Fix bug 511 first** (CRDT lamport seq reset). ~30 lines in `crdt_state.rs::init()`. After CRDT replay, seed the local seq counter from `MAX(seq)` over own author. Without this, CRDT replay produces broken state and 510 keeps biting.
|
||||
2. **Verify the 511 fix unblocks 510.** Hypothesis: 510 (filesystem shadow split-brain) is largely a downstream symptom of 511 (replay puts ops in wrong order, in-memory state diverges, materialiser re-creates shadows from old state). If true, 510 may need only a small additional cleanup pass.
|
||||
3. **Read the state machine sketch and refine it.** Specifically:
|
||||
- Verify the local-vs-syncable field partition is right
|
||||
- Confirm `Stage::Merge` and `Stage::Done` carry exactly the data we need
|
||||
- Add any missing transitions
|
||||
- Decide whether `ExecutionState` should be in the same CRDT or a separate one (we tentatively chose the same CRDT under per-node-pubkey keys, for cross-node observability and heartbeat)
|
||||
4. **Land story 520** — promote the sketch to a real `server/src/pipeline_state.rs` module. Implement the projection layer (`TryFrom<&PipelineItemCrdt> for PipelineItem`).
|
||||
5. **Migrate consumers one at a time** in priority order: chat commands (512) → lifecycle (517) → delete_story (514) → mergemaster precondition (519, mostly subsumed by `NonZeroU32`).
|
||||
6. **Once nothing reads the loose `PipelineItemView` anymore, delete the loose API.** The CRDT looseness becomes purely an implementation detail.
|
||||
7. **Then the off-leash commit forensic** — investigate `rogue-commit-2026-04-09-ac9f3ecf`. How did an agent acquire `git push` capability? What code path enabled it? File a security-critical bug.
|
||||
|
||||
## What's currently weird / broken in the running system
|
||||
|
||||
- **`timers.json` keeps getting re-populated** even after we empty it. The cause: stopping an agent triggers the agent's exit handler, which calls the rate-limit auto-resume scheduler, which writes to `timers.json`. Bug 501 should cover this but it might need to be explicit about the stop-agent code path.
|
||||
- **Chat commands can't find stories that have no filesystem shadow.** Bug 512. Workaround: use MCP `move_story` / `delete_story` / etc. directly, NOT the web UI chat commands.
|
||||
- **The web UI shows stale state** for some stories because the API reads from the in-memory CRDT view, which can diverge from `pipeline_items`. This will be fixed naturally by 520 + 517 (single source of truth).
|
||||
- **`create_worktree` always creates from master** — intentional design choice ("keep conflicts low") but means it can't reuse an existing feature branch's work. Bit us with 478 today.
|
||||
- **Mergemaster's `merge_agent_work` exits silently** when there are no commits ahead of master — we lost ~$0.82 to one such session today. Bug 519 + the typed `NonZeroU32` constraint in story 520 will make this unrepresentable.
|
||||
|
||||
## Useful diagnostic recipes from today
|
||||
|
||||
- **View persisted CRDT ops:** `sqlite3 .huskies/pipeline.db "SELECT seq, substr(op_json, 1, 200) FROM crdt_ops ORDER BY seq DESC LIMIT 20"`
|
||||
- **View in-memory CRDT pipeline state:** call `mcp__huskies__get_pipeline_status` (it goes through `crdt_state::read_all_items()`)
|
||||
- **Tail server log filtered for bug 502 firings:** `tail -f .huskies/logs/server.log | grep --line-buffered "Failed to start mergemaster"`
|
||||
- **Tail server log without `[pty-debug]` noise:** `tail -f .huskies/logs/server.log | grep -v "\[pty-debug\]"`
|
||||
- **Check current pending timers:** `cat .huskies/timers.json`
|
||||
- **Forensically delete a story across all four state machines:** stop agents → remove worktree → empty timers → `DELETE FROM pipeline_items WHERE id LIKE '<id>%'` → `DELETE FROM crdt_ops WHERE op_json LIKE '%<id>%'`
|
||||
|
||||
## Token cost accounting
|
||||
|
||||
This session burned roughly **$15-25** in agent thrash, mostly from bug 501 + bug 510 respawning agents on already-completed stories. Once 511 + 510 + 501 are fixed, that bleed disappears.
|
||||
|
||||
## Open questions for the next session
|
||||
|
||||
1. **Should `ExecutionState` live in the same CRDT or a separate one?** We tentatively said same CRDT under per-node-pubkey keys. Need to validate this against the bft-json-crdt library's actual capabilities.
|
||||
2. **Heartbeat cadence?** How often should `last_heartbeat` be updated for `ExecutionState::Running`? Every 30s seems reasonable but should be config.
|
||||
3. **What's the migration path from existing pipeline_items rows to typed `PipelineItem`s?** A one-time migration script, or rebuild from `crdt_ops`?
|
||||
4. **Should we add `statig` after all?** Probably not for the initial implementation, but worth revisiting if we end up wanting hierarchical states (e.g., a `Working` superstate sharing transitions across active stages).
|
||||
@@ -0,0 +1,260 @@
|
||||
[[agent]]
|
||||
name = "coder-1"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 50
|
||||
max_budget_usd = 5.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks until tests complete and returns the results.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Always run the run_tests MCP tool before committing — do not commit until tests pass. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes."
|
||||
|
||||
[[agent]]
|
||||
name = "coder-2"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 50
|
||||
max_budget_usd = 5.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks until tests complete and returns the results.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Always run the run_tests MCP tool before committing — do not commit until tests pass. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes."
|
||||
|
||||
[[agent]]
|
||||
name = "coder-3"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 50
|
||||
max_budget_usd = 5.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks until tests complete and returns the results.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Always run the run_tests MCP tool before committing — do not commit until tests pass. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes."
|
||||
|
||||
[[agent]]
|
||||
name = "qa-2"
|
||||
stage = "qa"
|
||||
role = "Reviews coder work in worktrees: runs quality gates, verifies acceptance criteria, and reports findings."
|
||||
model = "sonnet"
|
||||
max_turns = 40
|
||||
max_budget_usd = 4.00
|
||||
prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report.
|
||||
|
||||
Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map.
|
||||
|
||||
## Your Workflow
|
||||
|
||||
### 0. Read the Story
|
||||
- Read the story file at `.huskies/work/3_qa/{{story_id}}.md`
|
||||
- Extract every acceptance criterion (the `- [ ]` checkbox lines)
|
||||
- Keep this list in mind for Step 3
|
||||
|
||||
### 1. Deterministic Gates (Prerequisites)
|
||||
Run these first — if any fail, reject immediately without proceeding to AC review:
|
||||
- Call the `run_tests` MCP tool — it blocks until complete. All gates must pass (0 lint errors/warnings, all tests green, frontend build clean if applicable).
|
||||
|
||||
### 2. Code Change Review
|
||||
- Run `git diff master...HEAD --stat` to see what files changed
|
||||
- Run `git diff master...HEAD` to review the actual changes
|
||||
- Flag any incomplete implementations:
|
||||
- `todo!()`, `unimplemented!()`, `panic!()` used as stubs
|
||||
- Placeholder strings like "TODO", "FIXME", "not implemented"
|
||||
- Empty match arms or arms that just return `Default::default()`
|
||||
- Hardcoded values where real logic is expected
|
||||
- Note any obvious coding mistakes (unused imports, dead code, unhandled errors)
|
||||
|
||||
### 3. Acceptance Criteria Review
|
||||
For each AC extracted in Step 0:
|
||||
- Review the diff and test files to determine if the code addresses this AC
|
||||
- PASS: describe specifically how the code addresses it (which file/function/test)
|
||||
- FAIL: explain exactly what is missing or incorrect
|
||||
|
||||
An AC fails if:
|
||||
- No code change or test relates to it
|
||||
- The implementation is stubbed out (todo!/unimplemented!)
|
||||
- A test exists but doesn't actually assert the behaviour described
|
||||
|
||||
### 4. Manual Testing Support (only if all gates PASS and all ACs PASS)
|
||||
- Build: run `run_build` MCP tool and note success/failure
|
||||
- If build succeeds: find a free port (try 3010-3020), set `HUSKIES_PORT=<port>` and start the server with `script/server`
|
||||
- Generate a testing plan including:
|
||||
- URL to visit in the browser
|
||||
- Things to check in the UI
|
||||
- curl commands to exercise relevant API endpoints
|
||||
- Stop the test server when done: send SIGTERM to the `script/server` process (e.g. `kill <pid>`)
|
||||
|
||||
### 5. Produce Structured Report and Verdict
|
||||
Print your QA report to stdout. Then call `approve_qa` or `reject_qa` via the MCP tool based on the overall result. Use this format:
|
||||
|
||||
```
|
||||
## QA Report for {{story_id}}
|
||||
|
||||
### Code Quality
|
||||
- run_tests MCP tool: PASS/FAIL (details)
|
||||
- Incomplete implementations: (list any todo!/unimplemented!/stubs found, or "None")
|
||||
- Other code review findings: (list any issues found, or "None")
|
||||
|
||||
### Acceptance Criteria Review
|
||||
- AC: <criterion text>
|
||||
Result: PASS/FAIL
|
||||
Evidence: <how the code addresses it, or what is missing>
|
||||
|
||||
(repeat for each AC)
|
||||
|
||||
### Manual Testing Plan
|
||||
- Server URL: http://localhost:PORT (or "Skipped — gate/AC failure" or "Build failed")
|
||||
- Pages to visit: (list, or "N/A")
|
||||
- Things to check: (list, or "N/A")
|
||||
- curl commands: (list, or "N/A")
|
||||
|
||||
### Overall: PASS/FAIL
|
||||
Reason: (summary of why it passed or the primary reason it failed)
|
||||
```
|
||||
|
||||
After printing the report:
|
||||
- If Overall is PASS: call `approve_qa(story_id='{{story_id}}')` via MCP
|
||||
- If Overall is FAIL: call `reject_qa(story_id='{{story_id}}', notes='<concise reason>')` via MCP so the coder knows exactly what to fix
|
||||
|
||||
## Rules
|
||||
- Do NOT modify any code — read-only review only
|
||||
- Gates must pass before AC review — a gate failure is an automatic reject
|
||||
- If any AC is not met, the overall result is FAIL
|
||||
- Always call approve_qa or reject_qa — never leave the story without a verdict"""
|
||||
system_prompt = "You are a QA agent. Your job is read-only: run quality gates, verify each acceptance criterion against the diff, and produce a structured QA report. Always call approve_qa or reject_qa via MCP to record your verdict. Do not modify code."
|
||||
|
||||
[[agent]]
|
||||
name = "coder-opus"
|
||||
stage = "coder"
|
||||
role = "Senior full-stack engineer for complex tasks. Implements features across all components."
|
||||
model = "opus"
|
||||
max_turns = 80
|
||||
max_budget_usd = 20.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks until tests complete and returns the results.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
|
||||
system_prompt = "You are a senior full-stack engineer working autonomously in a git worktree. You handle complex tasks requiring deep architectural understanding. Always run the run_tests MCP tool before committing — do not commit until tests pass. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes."
|
||||
|
||||
[[agent]]
|
||||
name = "qa"
|
||||
stage = "qa"
|
||||
role = "Reviews coder work in worktrees: runs quality gates, verifies acceptance criteria, and reports findings."
|
||||
model = "sonnet"
|
||||
max_turns = 40
|
||||
max_budget_usd = 4.00
|
||||
prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report.
|
||||
|
||||
Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map.
|
||||
|
||||
## Your Workflow
|
||||
|
||||
### 0. Read the Story
|
||||
- Read the story file at `.huskies/work/3_qa/{{story_id}}.md`
|
||||
- Extract every acceptance criterion (the `- [ ]` checkbox lines)
|
||||
- Keep this list in mind for Step 3
|
||||
|
||||
### 1. Deterministic Gates (Prerequisites)
|
||||
Run these first — if any fail, reject immediately without proceeding to AC review:
|
||||
- Call the `run_tests` MCP tool — it blocks until complete. All gates must pass (0 lint errors/warnings, all tests green, frontend build clean if applicable).
|
||||
|
||||
### 2. Code Change Review
|
||||
- Run `git diff master...HEAD --stat` to see what files changed
|
||||
- Run `git diff master...HEAD` to review the actual changes
|
||||
- Flag any incomplete implementations:
|
||||
- `todo!()`, `unimplemented!()`, `panic!()` used as stubs
|
||||
- Placeholder strings like "TODO", "FIXME", "not implemented"
|
||||
- Empty match arms or arms that just return `Default::default()`
|
||||
- Hardcoded values where real logic is expected
|
||||
- Note any obvious coding mistakes (unused imports, dead code, unhandled errors)
|
||||
|
||||
### 3. Acceptance Criteria Review
|
||||
For each AC extracted in Step 0:
|
||||
- Review the diff and test files to determine if the code addresses this AC
|
||||
- PASS: describe specifically how the code addresses it (which file/function/test)
|
||||
- FAIL: explain exactly what is missing or incorrect
|
||||
|
||||
An AC fails if:
|
||||
- No code change or test relates to it
|
||||
- The implementation is stubbed out (todo!/unimplemented!)
|
||||
- A test exists but doesn't actually assert the behaviour described
|
||||
|
||||
### 4. Manual Testing Support (only if all gates PASS and all ACs PASS)
|
||||
- Build: run `run_build` MCP tool and note success/failure
|
||||
- If build succeeds: find a free port (try 3010-3020), set `HUSKIES_PORT=<port>` and start the server with `script/server`
|
||||
- Generate a testing plan including:
|
||||
- URL to visit in the browser
|
||||
- Things to check in the UI
|
||||
- curl commands to exercise relevant API endpoints
|
||||
- Stop the test server when done: send SIGTERM to the `script/server` process (e.g. `kill <pid>`)
|
||||
|
||||
### 5. Produce Structured Report and Verdict
|
||||
Print your QA report to stdout. Then call `approve_qa` or `reject_qa` via the MCP tool based on the overall result. Use this format:
|
||||
|
||||
```
|
||||
## QA Report for {{story_id}}
|
||||
|
||||
### Code Quality
|
||||
- run_tests MCP tool: PASS/FAIL (details)
|
||||
- Incomplete implementations: (list any todo!/unimplemented!/stubs found, or "None")
|
||||
- Other code review findings: (list any issues found, or "None")
|
||||
|
||||
### Acceptance Criteria Review
|
||||
- AC: <criterion text>
|
||||
Result: PASS/FAIL
|
||||
Evidence: <how the code addresses it, or what is missing>
|
||||
|
||||
(repeat for each AC)
|
||||
|
||||
### Manual Testing Plan
|
||||
- Server URL: http://localhost:PORT (or "Skipped — gate/AC failure" or "Build failed")
|
||||
- Pages to visit: (list, or "N/A")
|
||||
- Things to check: (list, or "N/A")
|
||||
- curl commands: (list, or "N/A")
|
||||
|
||||
### Overall: PASS/FAIL
|
||||
Reason: (summary of why it passed or the primary reason it failed)
|
||||
```
|
||||
|
||||
After printing the report:
|
||||
- If Overall is PASS: call `approve_qa(story_id='{{story_id}}')` via MCP
|
||||
- If Overall is FAIL: call `reject_qa(story_id='{{story_id}}', notes='<concise reason>')` via MCP so the coder knows exactly what to fix
|
||||
|
||||
## Rules
|
||||
- Do NOT modify any code — read-only review only
|
||||
- Gates must pass before AC review — a gate failure is an automatic reject
|
||||
- If any AC is not met, the overall result is FAIL
|
||||
- Always call approve_qa or reject_qa — never leave the story without a verdict"""
|
||||
system_prompt = "You are a QA agent. Your job is read-only: run quality gates, verify each acceptance criterion against the diff, and produce a structured QA report. Always call approve_qa or reject_qa via MCP to record your verdict. Do not modify code."
|
||||
|
||||
[[agent]]
|
||||
name = "mergemaster"
|
||||
stage = "mergemaster"
|
||||
role = "Merges completed coder work into master, runs quality gates, archives stories, and cleans up worktrees."
|
||||
model = "opus"
|
||||
max_turns = 30
|
||||
max_budget_usd = 5.00
|
||||
prompt = """You are the mergemaster agent for story {{story_id}}. Your job is to merge the completed coder work into master.
|
||||
|
||||
Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map.
|
||||
|
||||
## Your Workflow
|
||||
1. Call merge_agent_work(story_id='{{story_id}}'). It blocks until the merge completes and returns the full result.
|
||||
2. If success and gates passed: you're done. Exit.
|
||||
3. If gates failed: read the gate_output carefully, fix the issues in the merge workspace at `.huskies/merge_workspace/`, run run_tests MCP tool to verify, recommit, and call merge_agent_work again.
|
||||
4. If merge failed for any other reason: call report_merge_failure(story_id='{{story_id}}', reason='<details>') and exit.
|
||||
5. After 3 failed fix attempts, call report_merge_failure and exit.
|
||||
|
||||
## Fixing Gate Failures
|
||||
|
||||
The auto-resolver often produces broken code. Common problems:
|
||||
- Duplicate imports or definitions (kept both sides)
|
||||
- Formatting issues (import ordering, line breaks)
|
||||
- Unclosed delimiters from bad conflict resolution
|
||||
- Type mismatches from incompatible merge of both sides
|
||||
|
||||
To fix:
|
||||
1. Read the broken files in `.huskies/merge_workspace/`
|
||||
2. Fix the issues — prefer master's structure, integrate only the feature's new code
|
||||
3. Run run_lint MCP tool to check formatting
|
||||
4. Run run_tests MCP tool to verify everything passes
|
||||
5. Commit the fix and call merge_agent_work again
|
||||
|
||||
## Rules
|
||||
- NEVER manually move story files between pipeline stages
|
||||
- NEVER call accept_story — merge_agent_work handles that
|
||||
- ALWAYS call report_merge_failure if you can't fix the merge"""
|
||||
system_prompt = "You are the mergemaster agent. Call merge_agent_work to merge. If gates fail, fix the issues in the merge workspace, verify with run_lint and run_tests MCP tools, recommit, and retrigger. After 3 failed attempts, call report_merge_failure and exit. Never move story files or call accept_story."
|
||||
@@ -0,0 +1,28 @@
|
||||
# Discord Transport
|
||||
# Copy this file to bot.toml and fill in your values.
|
||||
# Only one transport can be active at a time.
|
||||
#
|
||||
# Setup:
|
||||
# 1. Create a Discord Application at discord.com/developers/applications
|
||||
# 2. Go to Bot → create a bot and copy the token
|
||||
# 3. Enable "Message Content Intent" under Privileged Gateway Intents
|
||||
# 4. Go to OAuth2 → URL Generator, select "bot" scope with permissions:
|
||||
# Send Messages, Read Message History, Manage Messages
|
||||
# 5. Use the generated URL to invite the bot to your server
|
||||
# 6. Right-click the channel(s) → Copy Channel ID (enable Developer Mode in settings)
|
||||
|
||||
enabled = true
|
||||
transport = "discord"
|
||||
|
||||
discord_bot_token = "your-bot-token-here"
|
||||
discord_channel_ids = ["123456789012345678"]
|
||||
|
||||
# Discord user IDs allowed to interact with the bot.
|
||||
# When empty, all users in configured channels can interact.
|
||||
# discord_allowed_users = ["111222333444555666"]
|
||||
|
||||
# Bot display name (used in formatted messages).
|
||||
# display_name = "Assistant"
|
||||
|
||||
# Maximum conversation turns to remember per channel (default: 20).
|
||||
# history_size = 20
|
||||
+8
-312
@@ -18,6 +18,14 @@ max_retries = 3
|
||||
# (reads current HEAD branch).
|
||||
base_branch = "master"
|
||||
|
||||
# Suppress soft rate-limit warning notifications in chat.
|
||||
# Hard blocks and story-blocked notifications are always sent.
|
||||
rate_limit_notifications = false
|
||||
|
||||
# IANA timezone for timer scheduling (e.g. "Europe/London", "America/New_York").
|
||||
# Timer HH:MM inputs are interpreted in this timezone.
|
||||
timezone = "Europe/London"
|
||||
|
||||
[[component]]
|
||||
name = "frontend"
|
||||
path = "frontend"
|
||||
@@ -29,315 +37,3 @@ name = "server"
|
||||
path = "."
|
||||
setup = ["mkdir -p frontend/dist", "cargo check"]
|
||||
teardown = []
|
||||
|
||||
[[agent]]
|
||||
name = "coder-1"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 50
|
||||
max_budget_usd = 5.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
|
||||
|
||||
[[agent]]
|
||||
name = "coder-2"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 50
|
||||
max_budget_usd = 5.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
|
||||
|
||||
[[agent]]
|
||||
name = "coder-3"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 50
|
||||
max_budget_usd = 5.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
|
||||
|
||||
[[agent]]
|
||||
name = "qa-2"
|
||||
stage = "qa"
|
||||
role = "Reviews coder work in worktrees: runs quality gates, verifies acceptance criteria, and reports findings."
|
||||
model = "sonnet"
|
||||
max_turns = 40
|
||||
max_budget_usd = 4.00
|
||||
prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report.
|
||||
|
||||
Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
||||
|
||||
## Your Workflow
|
||||
|
||||
### 0. Read the Story
|
||||
- Read the story file at `.huskies/work/3_qa/{{story_id}}.md`
|
||||
- Extract every acceptance criterion (the `- [ ]` checkbox lines)
|
||||
- Keep this list in mind for Step 3
|
||||
|
||||
### 1. Deterministic Gates (Prerequisites)
|
||||
Run these first — if any fail, reject immediately without proceeding to AC review:
|
||||
- Run `cargo clippy --all-targets --all-features` — must show 0 errors, 0 warnings
|
||||
- Run `cargo test` and verify all tests pass
|
||||
- If a `frontend/` directory exists:
|
||||
- Run `npm run build` and note any TypeScript errors
|
||||
- Run `npx @biomejs/biome check src/` and note any linting issues
|
||||
- Run `npm test` and verify all frontend tests pass
|
||||
|
||||
### 2. Code Change Review
|
||||
- Run `git diff master...HEAD --stat` to see what files changed
|
||||
- Run `git diff master...HEAD` to review the actual changes
|
||||
- Flag any incomplete implementations:
|
||||
- `todo!()`, `unimplemented!()`, `panic!()` used as stubs
|
||||
- Placeholder strings like "TODO", "FIXME", "not implemented"
|
||||
- Empty match arms or arms that just return `Default::default()`
|
||||
- Hardcoded values where real logic is expected
|
||||
- Note any obvious coding mistakes (unused imports, dead code, unhandled errors)
|
||||
|
||||
### 3. Acceptance Criteria Review
|
||||
For each AC extracted in Step 0:
|
||||
- Review the diff and test files to determine if the code addresses this AC
|
||||
- PASS: describe specifically how the code addresses it (which file/function/test)
|
||||
- FAIL: explain exactly what is missing or incorrect
|
||||
|
||||
An AC fails if:
|
||||
- No code change or test relates to it
|
||||
- The implementation is stubbed out (todo!/unimplemented!)
|
||||
- A test exists but doesn't actually assert the behaviour described
|
||||
|
||||
### 4. Manual Testing Support (only if all gates PASS and all ACs PASS)
|
||||
- Build the server: run `cargo build` and note success/failure
|
||||
- If build succeeds: find a free port (try 3010-3020) and attempt to start the server
|
||||
- Generate a testing plan including:
|
||||
- URL to visit in the browser
|
||||
- Things to check in the UI
|
||||
- curl commands to exercise relevant API endpoints
|
||||
- Kill the test server when done: `pkill -f 'target.*huskies' || true` (NEVER use `pkill -f huskies` — it kills the vite dev server)
|
||||
|
||||
### 5. Produce Structured Report and Verdict
|
||||
Print your QA report to stdout. Then call `approve_qa` or `reject_qa` via the MCP tool based on the overall result. Use this format:
|
||||
|
||||
```
|
||||
## QA Report for {{story_id}}
|
||||
|
||||
### Code Quality
|
||||
- clippy: PASS/FAIL (details)
|
||||
- TypeScript build: PASS/FAIL/SKIP (details)
|
||||
- Biome lint: PASS/FAIL/SKIP (details)
|
||||
- cargo test: PASS/FAIL (N tests)
|
||||
- npm test: PASS/FAIL/SKIP (N tests)
|
||||
- Incomplete implementations: (list any todo!/unimplemented!/stubs found, or "None")
|
||||
- Other code review findings: (list any issues found, or "None")
|
||||
|
||||
### Acceptance Criteria Review
|
||||
- AC: <criterion text>
|
||||
Result: PASS/FAIL
|
||||
Evidence: <how the code addresses it, or what is missing>
|
||||
|
||||
(repeat for each AC)
|
||||
|
||||
### Manual Testing Plan
|
||||
- Server URL: http://localhost:PORT (or "Skipped — gate/AC failure" or "Build failed")
|
||||
- Pages to visit: (list, or "N/A")
|
||||
- Things to check: (list, or "N/A")
|
||||
- curl commands: (list, or "N/A")
|
||||
|
||||
### Overall: PASS/FAIL
|
||||
Reason: (summary of why it passed or the primary reason it failed)
|
||||
```
|
||||
|
||||
After printing the report:
|
||||
- If Overall is PASS: call `approve_qa(story_id='{{story_id}}')` via MCP
|
||||
- If Overall is FAIL: call `reject_qa(story_id='{{story_id}}', notes='<concise reason>')` via MCP so the coder knows exactly what to fix
|
||||
|
||||
## Rules
|
||||
- Do NOT modify any code — read-only review only
|
||||
- Gates must pass before AC review — a gate failure is an automatic reject
|
||||
- If any AC is not met, the overall result is FAIL
|
||||
- Always call approve_qa or reject_qa — never leave the story without a verdict"""
|
||||
system_prompt = "You are a QA agent. Your job is read-only: run quality gates, verify each acceptance criterion against the diff, and produce a structured QA report. Always call approve_qa or reject_qa via MCP to record your verdict. Do not modify code."
|
||||
|
||||
[[agent]]
|
||||
name = "coder-opus"
|
||||
stage = "coder"
|
||||
role = "Senior full-stack engineer for complex tasks. Implements features across all components."
|
||||
model = "opus"
|
||||
max_turns = 80
|
||||
max_budget_usd = 20.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
|
||||
system_prompt = "You are a senior full-stack engineer working autonomously in a git worktree. You handle complex tasks requiring deep architectural understanding. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
|
||||
|
||||
[[agent]]
|
||||
name = "qa"
|
||||
stage = "qa"
|
||||
role = "Reviews coder work in worktrees: runs quality gates, verifies acceptance criteria, and reports findings."
|
||||
model = "sonnet"
|
||||
max_turns = 40
|
||||
max_budget_usd = 4.00
|
||||
prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report.
|
||||
|
||||
Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
||||
|
||||
## Your Workflow
|
||||
|
||||
### 0. Read the Story
|
||||
- Read the story file at `.huskies/work/3_qa/{{story_id}}.md`
|
||||
- Extract every acceptance criterion (the `- [ ]` checkbox lines)
|
||||
- Keep this list in mind for Step 3
|
||||
|
||||
### 1. Deterministic Gates (Prerequisites)
|
||||
Run these first — if any fail, reject immediately without proceeding to AC review:
|
||||
- Run `cargo clippy --all-targets --all-features` — must show 0 errors, 0 warnings
|
||||
- Run `cargo test` and verify all tests pass
|
||||
- If a `frontend/` directory exists:
|
||||
- Run `npm run build` and note any TypeScript errors
|
||||
- Run `npx @biomejs/biome check src/` and note any linting issues
|
||||
- Run `npm test` and verify all frontend tests pass
|
||||
|
||||
### 2. Code Change Review
|
||||
- Run `git diff master...HEAD --stat` to see what files changed
|
||||
- Run `git diff master...HEAD` to review the actual changes
|
||||
- Flag any incomplete implementations:
|
||||
- `todo!()`, `unimplemented!()`, `panic!()` used as stubs
|
||||
- Placeholder strings like "TODO", "FIXME", "not implemented"
|
||||
- Empty match arms or arms that just return `Default::default()`
|
||||
- Hardcoded values where real logic is expected
|
||||
- Note any obvious coding mistakes (unused imports, dead code, unhandled errors)
|
||||
|
||||
### 3. Acceptance Criteria Review
|
||||
For each AC extracted in Step 0:
|
||||
- Review the diff and test files to determine if the code addresses this AC
|
||||
- PASS: describe specifically how the code addresses it (which file/function/test)
|
||||
- FAIL: explain exactly what is missing or incorrect
|
||||
|
||||
An AC fails if:
|
||||
- No code change or test relates to it
|
||||
- The implementation is stubbed out (todo!/unimplemented!)
|
||||
- A test exists but doesn't actually assert the behaviour described
|
||||
|
||||
### 4. Manual Testing Support (only if all gates PASS and all ACs PASS)
|
||||
- Build the server: run `cargo build` and note success/failure
|
||||
- If build succeeds: find a free port (try 3010-3020) and attempt to start the server
|
||||
- Generate a testing plan including:
|
||||
- URL to visit in the browser
|
||||
- Things to check in the UI
|
||||
- curl commands to exercise relevant API endpoints
|
||||
- Kill the test server when done: `pkill -f 'target.*huskies' || true` (NEVER use `pkill -f huskies` — it kills the vite dev server)
|
||||
|
||||
### 5. Produce Structured Report and Verdict
|
||||
Print your QA report to stdout. Then call `approve_qa` or `reject_qa` via the MCP tool based on the overall result. Use this format:
|
||||
|
||||
```
|
||||
## QA Report for {{story_id}}
|
||||
|
||||
### Code Quality
|
||||
- clippy: PASS/FAIL (details)
|
||||
- TypeScript build: PASS/FAIL/SKIP (details)
|
||||
- Biome lint: PASS/FAIL/SKIP (details)
|
||||
- cargo test: PASS/FAIL (N tests)
|
||||
- npm test: PASS/FAIL/SKIP (N tests)
|
||||
- Incomplete implementations: (list any todo!/unimplemented!/stubs found, or "None")
|
||||
- Other code review findings: (list any issues found, or "None")
|
||||
|
||||
### Acceptance Criteria Review
|
||||
- AC: <criterion text>
|
||||
Result: PASS/FAIL
|
||||
Evidence: <how the code addresses it, or what is missing>
|
||||
|
||||
(repeat for each AC)
|
||||
|
||||
### Manual Testing Plan
|
||||
- Server URL: http://localhost:PORT (or "Skipped — gate/AC failure" or "Build failed")
|
||||
- Pages to visit: (list, or "N/A")
|
||||
- Things to check: (list, or "N/A")
|
||||
- curl commands: (list, or "N/A")
|
||||
|
||||
### Overall: PASS/FAIL
|
||||
Reason: (summary of why it passed or the primary reason it failed)
|
||||
```
|
||||
|
||||
After printing the report:
|
||||
- If Overall is PASS: call `approve_qa(story_id='{{story_id}}')` via MCP
|
||||
- If Overall is FAIL: call `reject_qa(story_id='{{story_id}}', notes='<concise reason>')` via MCP so the coder knows exactly what to fix
|
||||
|
||||
## Rules
|
||||
- Do NOT modify any code — read-only review only
|
||||
- Gates must pass before AC review — a gate failure is an automatic reject
|
||||
- If any AC is not met, the overall result is FAIL
|
||||
- Always call approve_qa or reject_qa — never leave the story without a verdict"""
|
||||
system_prompt = "You are a QA agent. Your job is read-only: run quality gates, verify each acceptance criterion against the diff, and produce a structured QA report. Always call approve_qa or reject_qa via MCP to record your verdict. Do not modify code."
|
||||
|
||||
[[agent]]
|
||||
name = "mergemaster"
|
||||
stage = "mergemaster"
|
||||
role = "Merges completed coder work into master, runs quality gates, archives stories, and cleans up worktrees."
|
||||
model = "opus"
|
||||
max_turns = 30
|
||||
max_budget_usd = 5.00
|
||||
prompt = """You are the mergemaster agent for story {{story_id}}. Your job is to merge the completed coder work into master.
|
||||
|
||||
Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
||||
|
||||
## Your Workflow
|
||||
1. Call merge_agent_work(story_id='{{story_id}}') via the MCP tool to trigger the full merge pipeline
|
||||
2. Review the result: check success, had_conflicts, conflicts_resolved, gates_passed, and gate_output
|
||||
3. If merge succeeded and gates passed: report success to the human
|
||||
4. If conflicts were auto-resolved (conflicts_resolved=true) and gates passed: report success, noting which conflicts were resolved
|
||||
5. If conflicts could not be auto-resolved: **resolve them yourself** in the merge worktree (see below)
|
||||
6. If merge failed for any other reason: call report_merge_failure(story_id='{{story_id}}', reason='<details>') and report to the human
|
||||
7. If gates failed after merge: attempt to fix the issues yourself in the merge worktree, then re-trigger merge_agent_work. After 3 fix attempts, call report_merge_failure and stop.
|
||||
|
||||
## Resolving Complex Conflicts Yourself
|
||||
|
||||
When the auto-resolver fails, you have access to the merge worktree at `.story_kit/merge_workspace/`. Go in there and resolve the conflicts manually:
|
||||
|
||||
1. Run `git diff --name-only --diff-filter=U` in the merge worktree to list conflicted files
|
||||
2. **Build context before touching code.** Run `git log --oneline master...HEAD` on the feature branch to see its commits. Then run `git log --oneline --since="$(git log -1 --format=%ci <feature-branch-base-commit>)" master` to see what landed on master since the branch was created. Read the story files in `.story_kit/work/` for any recently merged stories that touch the same files — this tells you WHY master changed and what must be preserved.
|
||||
3. Read each conflicted file and understand both sides of the conflict
|
||||
4. **Understand intent, not just syntax.** The feature branch may be behind master — master's version of shared infrastructure is almost always correct. The feature branch's contribution is the NEW functionality it adds. Your job is to integrate the new into master's structure, not pick one side.
|
||||
5. Resolve by integrating the feature's new functionality into master's code structure
|
||||
5. Stage resolved files with `git add`
|
||||
6. Run `cargo check` (and `npm run build` if frontend changed) to verify compilation
|
||||
7. If it compiles, commit and re-trigger merge_agent_work
|
||||
|
||||
### Common conflict patterns in this project:
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**Formatting-only conflicts:** Both sides reformatted the same code differently. Resolution: pick either side (prefer master).
|
||||
|
||||
## Fixing Gate Failures
|
||||
|
||||
If quality gates fail (cargo clippy, cargo test, npm run build, npm test), attempt to fix issues yourself in the merge worktree.
|
||||
|
||||
**Fix yourself (up to 3 attempts total):**
|
||||
- Syntax errors (missing semicolons, brackets, commas)
|
||||
- Duplicate definitions from merge artifacts
|
||||
- Simple type annotation errors
|
||||
- Unused import warnings flagged by clippy
|
||||
- Mismatched braces from bad conflict resolution
|
||||
- Trivial formatting issues that block compilation or linting
|
||||
|
||||
**Report to human without attempting a fix:**
|
||||
- Logic errors or incorrect business logic
|
||||
- Missing function implementations
|
||||
- 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.
|
||||
|
||||
## CRITICAL Rules
|
||||
- NEVER manually move story files between pipeline stages (e.g. from 4_merge/ to 5_done/)
|
||||
- NEVER call accept_story — only merge_agent_work can move stories to done after a successful merge
|
||||
- When merge fails after exhausting your fix attempts, ALWAYS call report_merge_failure
|
||||
- Report conflict resolution outcomes clearly
|
||||
- Report gate failures with full output so the human can act if needed
|
||||
- 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."
|
||||
|
||||
+112
-112
@@ -1,130 +1,130 @@
|
||||
# Tech Stack & Constraints
|
||||
# Tech Stack
|
||||
|
||||
## Overview
|
||||
This project is a standalone Rust **web server binary** that serves a Vite/React frontend and exposes a **WebSocket API**. The built frontend assets are packaged with the binary (in a `frontend` directory) and served as static files. It functions as an **Agentic Code Assistant** capable of safely executing tools on the host system.
|
||||
## Backend
|
||||
- **Language:** Rust
|
||||
- **Framework:** Poem (HTTP + WebSocket + OpenAPI)
|
||||
- **Database:** SQLite via sqlx + rusqlite
|
||||
- **State:** BFT CRDT replicated document backed by SQLite
|
||||
- **Agents:** Claude Code CLI spawned in PTY pseudo-terminals
|
||||
- **Package manager:** cargo
|
||||
|
||||
## Core Stack
|
||||
* **Backend:** Rust (Web Server)
|
||||
* **MSRV:** Stable (latest)
|
||||
* **Framework:** Poem HTTP server with WebSocket support for streaming; HTTP APIs should use Poem OpenAPI (Swagger) for non-streaming endpoints.
|
||||
* **Frontend:** TypeScript + React
|
||||
* **Build Tool:** Vite
|
||||
* **Package Manager:** npm
|
||||
* **Styling:** CSS Modules or Tailwind (TBD - Defaulting to CSS Modules)
|
||||
* **State Management:** React Context / Hooks
|
||||
* **Chat UI:** Rendered Markdown with syntax highlighting.
|
||||
## Frontend
|
||||
- **Language:** TypeScript + React
|
||||
- **Build:** Vite
|
||||
- **Package manager:** npm
|
||||
- **Testing:** Vitest (unit), Playwright (e2e)
|
||||
|
||||
## Agent Architecture
|
||||
The application follows a **Tool-Use (Function Calling)** architecture:
|
||||
1. **Frontend:** Collects user input and sends it to the LLM.
|
||||
2. **LLM:** Decides to generate text OR request a **Tool Call** (e.g., `execute_shell`, `read_file`).
|
||||
3. **Web Server Backend (The "Hand"):**
|
||||
* Intercepts Tool Calls.
|
||||
* Validates the request against the **Safety Policy**.
|
||||
* Executes the native code (File I/O, Shell Process, Search).
|
||||
* Returns the output (stdout/stderr/file content) to the LLM.
|
||||
* **Streaming:** The backend sends real-time updates over WebSocket to keep the UI responsive during long-running Agent tasks.
|
||||
## Deployment
|
||||
- Single Rust binary with embedded React frontend (rust-embed)
|
||||
- Three modes: standard server, headless build agent (`--rendezvous`), multi-project gateway (`--gateway`)
|
||||
- Docker container with OrbStack recommended on macOS
|
||||
|
||||
## LLM Provider Abstraction
|
||||
To support both Remote and Local models, the system implements a `ModelProvider` abstraction layer.
|
||||
## Project Layout
|
||||
```
|
||||
server/src/ — Rust backend
|
||||
frontend/src/ — React frontend
|
||||
crates/bft-json-crdt/ — CRDT library
|
||||
.huskies/ — Pipeline config, agent config, specs
|
||||
script/ — test, build, lint scripts
|
||||
docker/ — Dockerfile and docker-compose
|
||||
website/ — Static marketing/docs site
|
||||
```
|
||||
|
||||
* **Strategy:**
|
||||
* Abstract the differences between API formats (OpenAI-compatible vs Anthropic vs Gemini).
|
||||
* Normalize "Tool Use" definitions, as each provider handles function calling schemas differently.
|
||||
* **Supported Providers:**
|
||||
* **Ollama:** Local inference (e.g., Llama 3, DeepSeek Coder) for privacy and offline usage.
|
||||
* **Anthropic:** Claude 3.5 models (Sonnet, Haiku) via API for coding tasks (Story 12).
|
||||
* **Provider Selection:**
|
||||
* Automatic detection based on model name prefix:
|
||||
* `claude-` → Anthropic API
|
||||
* Otherwise → Ollama
|
||||
* Single unified model dropdown with section headers ("Anthropic", "Ollama")
|
||||
* **API Key Management:**
|
||||
* Anthropic API key stored server-side and persisted securely
|
||||
* On first use of Claude model, user prompted to enter API key
|
||||
* Key persists across sessions (no re-entry needed)
|
||||
## Source Map
|
||||
|
||||
## Tooling Capabilities
|
||||
### Core
|
||||
|
||||
### 1. Filesystem (Native)
|
||||
* **Scope:** Strictly limited to the user-selected `project_root`.
|
||||
* **Operations:** Read, Write, List, Delete.
|
||||
* **Constraint:** Modifications to `.git/` are strictly forbidden via file APIs (use Git tools instead).
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/main.rs` | Entry point, CLI argument parsing, and server startup |
|
||||
| `server/src/config.rs` | Parses `project.toml` for agents, components, and server settings |
|
||||
| `server/src/state.rs` | Global mutable session state (project root, cancellation) |
|
||||
| `server/src/store.rs` | JSON-backed persistent key-value store for settings |
|
||||
| `server/src/gateway.rs` | Multi-project gateway mode (MCP proxy, project switching, agent registration) |
|
||||
|
||||
### 2. Shell Execution
|
||||
* **Library:** `tokio::process` for async execution.
|
||||
* **Constraint:** We do **not** run an interactive shell (repl). We run discrete, stateless commands.
|
||||
* **Allowlist:** The agent may only execute specific binaries:
|
||||
* `git`
|
||||
* `cargo`, `rustc`, `rustfmt`, `clippy`
|
||||
* `npm`, `node`, `yarn`, `pnpm`, `bun`
|
||||
* `ls`, `find`, `grep` (if not using internal search)
|
||||
* `mkdir`, `rm`, `touch`, `mv`, `cp`
|
||||
### Agents
|
||||
|
||||
### 3. Search & Navigation
|
||||
* **Library:** `ignore` (by BurntSushi) + `grep` logic.
|
||||
* **Behavior:**
|
||||
* Must respect `.gitignore` files automatically.
|
||||
* Must be performant (parallel traversal).
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/agents/mod.rs` | Types, configuration, and orchestration for coding agents |
|
||||
| `server/src/agents/gates.rs` | Runs test suites and validation scripts in agent worktrees |
|
||||
| `server/src/agents/lifecycle.rs` | File creation, archival, and stage transitions for pipeline items |
|
||||
| `server/src/agents/merge.rs` | Rebases agent work onto master and runs post-merge validation |
|
||||
| `server/src/agents/pty.rs` | Spawns agent processes in pseudo-terminals and streams output |
|
||||
| `server/src/agents/token_usage.rs` | Persists per-agent token consumption records to disk |
|
||||
| `server/src/agent_log.rs` | Reads and writes JSONL agent event logs to disk |
|
||||
| `server/src/agent_mode.rs` | Headless build-agent mode for distributed story processing |
|
||||
|
||||
## Coding Standards
|
||||
### Agent Pool
|
||||
|
||||
### Rust
|
||||
* **Style:** `rustfmt` standard.
|
||||
* **Linter:** `clippy` - Must pass with 0 warnings before merging.
|
||||
* **Error Handling:** Custom `AppError` type deriving `thiserror`. All Commands return `Result<T, AppError>`.
|
||||
* **Concurrency:** Heavy tools (Search, Shell) must run on `tokio` threads to avoid blocking the UI.
|
||||
* **Quality Gates:**
|
||||
* `cargo clippy --all-targets --all-features` must show 0 errors, 0 warnings
|
||||
* `cargo check` must succeed
|
||||
* `cargo nextest run` must pass all tests
|
||||
* **Test Coverage:**
|
||||
* Generate JSON report: `cargo llvm-cov nextest --no-clean --json --output-path .story_kit/coverage/server.json`
|
||||
* Generate lcov report: `cargo llvm-cov report --lcov --output-path .story_kit/coverage/server.lcov`
|
||||
* Reports are written to `.story_kit/coverage/` (excluded from git)
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/agents/pool/mod.rs` | Manages the set of active agents across all pipeline stages |
|
||||
| `server/src/agents/pool/start.rs` | Spawns a new agent process in a worktree for a story |
|
||||
| `server/src/agents/pool/stop.rs` | Terminates a running agent while preserving its worktree |
|
||||
| `server/src/agents/pool/pipeline/advance.rs` | Moves stories forward through pipeline stages |
|
||||
| `server/src/agents/pool/pipeline/completion.rs` | Processes exit results and triggers pipeline advancement |
|
||||
| `server/src/agents/pool/pipeline/merge.rs` | Orchestrates the merge-to-master flow for completed stories |
|
||||
| `server/src/agents/pool/auto_assign/auto_assign.rs` | Scans pipeline stages and dispatches agents to unassigned stories |
|
||||
|
||||
### TypeScript / React
|
||||
* **Style:** Biome formatter (replaces Prettier/ESLint).
|
||||
* **Linter:** Biome - Must pass with 0 errors, 0 warnings before merging.
|
||||
* **Types:** Shared types with Rust (via `tauri-specta` or manual interface matching) are preferred to ensure type safety across the bridge.
|
||||
* **Testing:** Vitest for unit/component tests; Playwright for end-to-end tests.
|
||||
* **Quality Gates:**
|
||||
* `npx @biomejs/biome check src/` must show 0 errors, 0 warnings
|
||||
* `npm run build` must succeed
|
||||
* `npm test` must pass
|
||||
* `npm run test:e2e` must pass
|
||||
* No `any` types allowed (use proper types or `unknown`)
|
||||
* React keys must use stable IDs, not array indices
|
||||
* All buttons must have explicit `type` attribute
|
||||
### CRDT & Database
|
||||
|
||||
## Libraries (Approved)
|
||||
* **Rust:**
|
||||
* `serde`, `serde_json`: Serialization.
|
||||
* `ignore`: Fast recursive directory iteration respecting gitignore.
|
||||
* `walkdir`: Simple directory traversal.
|
||||
* `tokio`: Async runtime.
|
||||
* `reqwest`: For LLM API calls (Anthropic, Ollama).
|
||||
* `eventsource-stream`: For Server-Sent Events (Anthropic streaming).
|
||||
* `uuid`: For unique message IDs.
|
||||
* `chrono`: For timestamps.
|
||||
* `poem`: HTTP server framework.
|
||||
* `poem-openapi`: OpenAPI (Swagger) for non-streaming HTTP APIs.
|
||||
* **JavaScript:**
|
||||
* `react-markdown`: For rendering chat responses.
|
||||
* `vitest`: Unit/component testing.
|
||||
* `playwright`: End-to-end testing.
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/crdt_state.rs` | Pipeline state as a conflict-free replicated document backed by SQLite |
|
||||
| `server/src/crdt_sync.rs` | WebSocket-based replication of pipeline state between nodes |
|
||||
| `server/src/pipeline_state.rs` | Typed pipeline state machine |
|
||||
| `server/src/db/mod.rs` | Content store, shadow writes, and CRDT op persistence |
|
||||
|
||||
## Running the App (Worktrees & Ports)
|
||||
### HTTP — MCP Tools (the tools agents call)
|
||||
|
||||
Multiple instances can run simultaneously in different worktrees. To avoid port conflicts:
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/http/mcp/mod.rs` | MCP endpoint dispatching tool calls |
|
||||
| `server/src/http/mcp/agent_tools.rs` | Start, stop, wait, list, and inspect agents |
|
||||
| `server/src/http/mcp/git_tools.rs` | Status, diff, add, commit, and log on agent worktrees |
|
||||
| `server/src/http/mcp/merge_tools.rs` | Merge agent work to master and report failures |
|
||||
| `server/src/http/mcp/shell_tools.rs` | Run commands, execute tests, and stream output |
|
||||
| `server/src/http/mcp/story_tools.rs` | Create, update, move, and manage stories/bugs/refactors |
|
||||
| `server/src/http/mcp/diagnostics.rs` | Server logs, CRDT dump, version, and story movement helpers |
|
||||
|
||||
- **Backend:** Set `HUSKIES_PORT` to a unique port (default is 3001). Example: `HUSKIES_PORT=3002 cargo run`
|
||||
- **Frontend:** Run `npm run dev` from `frontend/`. It auto-selects the next unused port. It reads `HUSKIES_PORT` to know which backend to talk to, so export it before running: `export HUSKIES_PORT=3002 && cd frontend && npm run dev`
|
||||
### Chat — Bot Commands
|
||||
|
||||
When running in a worktree, use a port that won't conflict with the main instance (3001). Ports 3002+ are good choices.
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/chat/commands/mod.rs` | Bot-level command registry shared by all transports |
|
||||
| `server/src/chat/commands/status.rs` | `status` command and pipeline status helpers |
|
||||
| `server/src/chat/commands/backlog.rs` | `backlog` command — shows only backlog-stage items |
|
||||
| `server/src/chat/commands/run_tests.rs` | `run_tests` command — run the project's test suite |
|
||||
|
||||
## Safety & Sandbox
|
||||
1. **Project Scope:** The application must strictly enforce that it does not read/write outside the `project_root` selected by the user.
|
||||
2. **Human in the Loop:**
|
||||
* Shell commands that modify state (non-readonly) should ideally require a UI confirmation (configurable).
|
||||
* File writes must be confirmed or revertible.
|
||||
### Chat — Transports
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/chat/transport/matrix/` | Matrix bot integration |
|
||||
| `server/src/chat/transport/slack/` | Slack bot integration |
|
||||
| `server/src/chat/transport/whatsapp/` | WhatsApp Business API integration |
|
||||
| `server/src/chat/transport/discord/` | Discord bot integration |
|
||||
|
||||
### Frontend
|
||||
|
||||
| Directory | Description |
|
||||
|-----------|-------------|
|
||||
| `frontend/src/components/` | React UI components |
|
||||
| `frontend/src/api/` | API client code (gateway, agents, etc.) |
|
||||
|
||||
### Utilities
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/rebuild.rs` | Server rebuild and restart logic |
|
||||
| `server/src/worktree.rs` | Creates, lists, and removes git worktrees for agent isolation |
|
||||
| `server/src/io/watcher.rs` | Filesystem watcher for `.huskies/work/` and `project.toml` |
|
||||
|
||||
## Quality Gates
|
||||
All enforced by `script/test`:
|
||||
1. Frontend build (`npm run build`)
|
||||
2. Rust formatting (`cargo fmt --all --check`)
|
||||
3. Rust linting (`cargo clippy -- -D warnings`)
|
||||
4. Rust tests (`cargo test`)
|
||||
5. Frontend tests (`npm test`)
|
||||
|
||||
+2
@@ -1,5 +1,7 @@
|
||||
---
|
||||
name: "Unify story stuck states into a single status field"
|
||||
status: "superseded"
|
||||
superseded_by: 520
|
||||
---
|
||||
|
||||
# Refactor 436: Unify story stuck states into a single status field
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: "Build agent mode with CRDT-based work claiming"
|
||||
agent: coder-opus
|
||||
depends_on: [478]
|
||||
---
|
||||
|
||||
# Story 479: Build agent mode with CRDT-based work claiming
|
||||
|
||||
## User Story
|
||||
|
||||
As a user with multiple laptops, I want to run huskies in build agent mode so it connects to the mesh, syncs state, and autonomously picks up and runs coding work.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] New CLI mode: huskies agent --rendezvous ws://host:3001
|
||||
- [ ] Agent mode: syncs CRDT state, runs coders, no web UI or chat interface
|
||||
- [ ] Work claiming via CRDT: node writes claim (node ID) to CRDT doc, merge resolves conflicts deterministically, losing node stops work
|
||||
- [ ] Agent picks up stories in current stage and runs Claude Code locally
|
||||
- [ ] Agent pushes feature branch to Gitea when done, reports completion via CRDT
|
||||
- [ ] Handles offline/reconnect: CRDT merges on reconnect, interrupted work is reclaimed after timeout
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: "Cryptographic node auth for distributed mesh"
|
||||
agent: coder-opus
|
||||
depends_on: [479]
|
||||
---
|
||||
|
||||
# Story 480: Cryptographic node auth for distributed mesh
|
||||
|
||||
## User Story
|
||||
|
||||
As a user running a distributed huskies mesh, I want nodes authenticated by Ed25519 keypairs so only trusted machines can join and see pipeline state.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Each node has an Ed25519 keypair (generated on first run or via CLI command)
|
||||
- [ ] Trusted nodes defined by a list of known public keys in config
|
||||
- [ ] Nodes authenticate on WebSocket connect by signing a challenge
|
||||
- [ ] CRDT node ID derived from public key (already built into bft-json-crdt crate)
|
||||
- [ ] Unauthorised nodes rejected on connect
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: "create_worktree deletes all files from main branch git index"
|
||||
---
|
||||
|
||||
# Bug 486: create_worktree deletes all files from main branch git index
|
||||
|
||||
## Description
|
||||
|
||||
On the reclaimer project, the create_worktree operation for story 34 produced a commit (cea0c48) with message "huskies: create 34_story_drawer_open_pushes_main_view_aside_with_animation" that removed 76 files (9853 deletions) from the main branch git index. All files remained on disk — nothing was lost — but every tracked file became untracked. Static analysis of the watcher code (watcher.rs:152-185) shows git add -A .huskies/work/ with correct current_dir, which should only affect files under .huskies/work/. No other code path in the server runs git add without a pathspec on the main branch. Root cause is unknown — may be related to the storkit→huskies migration leaving the git index in an inconsistent state, or a race condition during first-time scaffold on a project that previously used .storkit/.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Uncertain — may require fresh huskies setup on a project that previously used storkit. 2. Create a story via the bot or MCP tool. 3. Observe that the auto-commit for story creation removes all tracked files from the git index.
|
||||
|
||||
## Actual Result
|
||||
|
||||
The commit for story creation deleted 76 files (9853 deletions) from the git index while leaving them on disk.
|
||||
|
||||
## Expected Result
|
||||
|
||||
The commit for story creation should only add the new story markdown file under .huskies/work/1_backlog/. No other files should be affected.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: "Stale merge job lock prevents new merges after agent dies"
|
||||
---
|
||||
|
||||
# Bug 498: Stale merge job lock prevents new merges after agent dies
|
||||
|
||||
## Description
|
||||
|
||||
When the mergemaster agent is killed or stops while a merge is in progress, the in-memory `merge_jobs` map retains a `Running` status entry for that story. Subsequent attempts to call `merge_agent_work` get "Merge already in progress" and fail. The lock is never cleaned up.
|
||||
|
||||
This causes the mergemaster to loop: spawn, try merge, get "already in progress", waste turns, exit, respawn. The merge never completes.
|
||||
|
||||
The fix: clear the merge job entry when the mergemaster agent exits (whether cleanly or via kill/stop).
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Start mergemaster on a story in merge
|
||||
2. Kill/stop the mergemaster agent before merge completes
|
||||
3. Try to merge_agent_work again for the same story
|
||||
4. Get "Merge already in progress" error
|
||||
|
||||
## Actual Result
|
||||
|
||||
Stale Running entry in merge_jobs map blocks all future merge attempts until server restart.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Merge job lock is cleaned up when the agent exits, allowing retry.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: "CRDT lamport clock (inner.seq) resets to 1 on server restart instead of resuming from max(own_author_seq) + 1"
|
||||
---
|
||||
|
||||
# Bug 511: CRDT lamport clock (inner.seq) resets to 1 on server restart instead of resuming from max(own_author_seq) + 1
|
||||
|
||||
## Description
|
||||
|
||||
When the huskies server restarts (e.g. via `rebuild_and_restart`), the local node's CRDT lamport clock — `inner.seq` on each `SignedOp` — appears to reset to 1 instead of resuming from `MAX(seq) + 1` for the local author's own previously-persisted ops.
|
||||
|
||||
**Discovered live on 2026-04-09** while inspecting the `crdt_ops` table after a `rebuild_and_restart`. Pre-restart ops were at seqs 485-492 (creation ops for stories 503-510). Post-restart ops were being persisted at seqs 1, 2, 3, 4, 5, 6, 7 — visible by sorting `crdt_ops` by `created_at DESC`:
|
||||
|
||||
```
|
||||
created_at | seq
|
||||
2026-04-09T18:49:56 → seq 7 ← post-restart
|
||||
2026-04-09T18:38:22 → seq 6 ← post-restart
|
||||
2026-04-09T18:37:45 → seq 492 ← pre-restart, last write before restart
|
||||
2026-04-09T18:31:32 → seq 4 ← post-restart
|
||||
2026-04-09T18:27:04 → seq 3 ← post-restart
|
||||
```
|
||||
|
||||
So the local node, which had reached seq=492 before the restart, started writing new ops at seq=1 after the restart and is now climbing from there. This means **new ops have lower seqs than existing ops from the same author**.
|
||||
|
||||
## Why this matters
|
||||
|
||||
In a BFT JSON CRDT, `inner.seq` is the local lamport clock used for causality tracking. The library assumes per-author seqs are monotonically increasing — newer ops from the same author have higher seqs than older ops. Several things break when this invariant is violated:
|
||||
|
||||
1. **Causality / ordering on remote replay.** When a peer (or this same node after another restart) replays the persisted ops in `seq` order, the post-restart ops will be applied *before* the pre-restart ops, even though they happened later. This can produce a non-deterministic state and can cause field updates to "go backwards" — e.g. a story that was moved current → done pre-restart, then nothing post-restart, would correctly end at "done"; but if you also did a post-restart action, the seq ordering would re-play it in the wrong order.
|
||||
|
||||
2. **Op ID collisions.** The op id is a hash of the op contents (including author, seq, content). If a post-restart op happens to be structurally identical to a pre-restart op (e.g. "set stage to 1_backlog" with the same author and same seq=3), the op ids could collide. The persistence path uses `INSERT INTO crdt_ops ... ON CONFLICT(op_id) DO NOTHING`, which would *silently drop* the new op. (We have not yet observed this happen, but it's a latent risk.)
|
||||
|
||||
3. **Sync between nodes will desync.** Once the WebSocket sync layer (story 478, just merged) is exchanging ops between nodes, a restart on one node will produce ops with seqs that look "old" to the other node, and the receiving node may de-dupe or mis-order them. This will manifest as silent state divergence in multi-node deployments, which is exactly what the sync layer is supposed to prevent.
|
||||
|
||||
4. **Today's pipeline state confusion.** The 8 stories I created in this session (503-510) are at seqs 485-492 in the persisted CRDT. Their post-restart lifecycle moves are at seqs 1-15. If we replay the CRDT from disk in seq order, the lifecycle moves will be applied to *empty* state before the creation ops have run, and will silently no-op (because they reference content indexes that don't exist yet). On the next restart after this state, the in-memory view will show the stories in their *creation* state, not their post-restart-lifecycle state — i.e. all 8 stories will appear "stuck at 1_backlog" again. **This may well be the cause of bug 510's split-brain symptom**.
|
||||
|
||||
## Where the bug lives
|
||||
|
||||
`server/src/crdt_state.rs::init()` (around lines 80-115) replays persisted ops to reconstruct state, then constructs a fresh `CrdtState { crdt, keypair, index, persist_tx }`. The `BaseCrdt::new(&keypair)` call constructs a fresh CRDT with a fresh internal seq counter. The replay re-applies ops via `crdt.apply(signed_op)` which presumably updates the doc but does NOT advance the local seq counter (because `apply()` is for *remote* ops).
|
||||
|
||||
After replay, the local seq counter is at 0 (or wherever the BFT CRDT library defaults). The next call to `apply_and_persist` produces an op with `inner.seq = 1` (or whatever the next-counter value is) — even though there are already ops at seq 485+ from this author in the persisted state.
|
||||
|
||||
The fix is to inspect `MAX(inner.seq)` for ops where `author == local_keypair.public()` after the replay, and seed the BFT CRDT's local seq counter from `max + 1`. The exact API for "seed the seq counter" depends on the bft-json-crdt library — may need a small upstream change if not already exposed.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Start a fresh huskies server with an empty database. Verify `crdt_ops` is empty.
|
||||
2. Create several stories via `create_story` or similar — observe ops being persisted at incrementing seqs (1, 2, 3, ...).
|
||||
3. Note the highest seq via `sqlite3 .huskies/pipeline.db "SELECT MAX(seq) FROM crdt_ops;"` — call this N.
|
||||
4. Stop the server and start it again (or `rebuild_and_restart`).
|
||||
5. Create another story via `create_story`.
|
||||
6. Query `SELECT seq, created_at FROM crdt_ops ORDER BY created_at DESC LIMIT 5;`
|
||||
|
||||
## Actual Result
|
||||
|
||||
The new op (just created in step 5) is persisted with `seq = 1` (or some small value), NOT `seq = N + 1`. The lamport clock has been reset.
|
||||
|
||||
Concretely on 2026-04-09 we observed seqs in `crdt_ops` ordered by `created_at` DESC of: 7, 6, 492, 4, 3 — i.e. post-restart writes were at seqs 3, 4, 6, 7 even though the highest pre-restart seq was 492.
|
||||
|
||||
## Expected Result
|
||||
|
||||
After restart, the local node's seq counter must resume from `MAX(inner.seq)` across all persisted ops where `author == local_keypair.public()`, plus 1. The next op written by the local node should have `seq = N + 1` where N is the previous local high-water mark.
|
||||
|
||||
Equivalent stated: `inner.seq` on the local author's ops must be monotonically increasing across the entire lifetime of the local node's keypair, not just within a single process invocation.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] After a server restart, the next CRDT op written by the local node has seq = MAX(local_author_seq from crdt_ops) + 1, not 1
|
||||
- [ ] Regression test: seed crdt_ops with an op at seq=100 by the local author, restart the CRDT subsystem (or call init() in a test harness), trigger a write_item, assert the new op has seq=101
|
||||
- [ ] Regression test: a brand-new node (no pre-existing ops) still starts at seq=1 (no off-by-one introduced by the fix)
|
||||
- [ ] Inter-node test: simulate two nodes A and B, A writes ops up to seq=50, A restarts, A writes a new op which should be seq=51, broadcast to B, assert B applies it in the correct causal position
|
||||
- [ ] If the fix requires changes to bft-json-crdt itself (to expose a way to seed the local seq), the upstream change is documented in the bug body and either landed or vendored
|
||||
- [ ] After this fix is in place, replay-on-restart for the existing data (8 stories in pipeline_items at seqs 485-492 with lifecycle moves at seqs 1-15) is verified to produce the correct in-memory state — OR the existing broken-seq data is migrated as part of the fix
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: "Migrate chat commands from filesystem lookup to CRDT/DB"
|
||||
---
|
||||
|
||||
# Story 512: Migrate chat commands from filesystem lookup to CRDT/DB
|
||||
|
||||
## User Story
|
||||
|
||||
**Depends on story 520** (typed pipeline state machine). This story is best implemented as a *consumer* of the typed transition API, not against the loose `PipelineItemView`. Wait for 520 to land first, then migrate the chat command lookups to use the typed `find_story_by_number → Result<PipelineItem, _>` helper from the new module.
|
||||
|
||||
---
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
## Context
|
||||
|
||||
All the slash-style chat commands in `server/src/chat/commands/{move_story,show,depends,unblock}.rs` and `server/src/chat/transport/matrix/{start,assign,delete}.rs` look up stories by **searching for `.huskies/work/*/N_*.md` filesystem files**. After the 491/492 migration moved story content out of the filesystem and into `pipeline_items` + CRDT, these commands silently fail with `"No story, bug, or spike with number {N} found"` for any story whose filesystem shadow doesn't exist — *even when the story is fully present in the DB and CRDT*.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a user typing chat commands in the web UI or the matrix bot, I want move/show/depends/unblock/start/assign/delete to find any story that's in the pipeline regardless of whether its filesystem shadow exists, so the chat workflow stays usable post-migration.
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
Master commit `41515e3b` had 503's code, the in-memory CRDT view had 503 at stage='merge', the `pipeline_items` row existed (post my-sqlite-update at `5_done`), but `move 503 done` in the web UI returned **`No story, bug, or spike with number 503 found`** because no `.huskies/work/4_merge/503_*.md` file existed.
|
||||
|
||||
## Implementation note
|
||||
|
||||
The MCP `move_story` tool already does this correctly: it goes through `lifecycle::move_item` which checks `crdt_state::read_item(story_id)` first. The chat commands need to use the same lookup helper. The fix should consolidate all "find story by number" logic into one shared function used by every command.
|
||||
|
||||
## Context
|
||||
|
||||
All the slash-style chat commands in `server/src/chat/commands/{move_story,show,depends,unblock}.rs` and `server/src/chat/transport/matrix/{start,assign,delete}.rs` look up stories by **searching for `.huskies/work/*/N_*.md` filesystem files**. After the 491/492 migration moved story content out of the filesystem and into `pipeline_items` + CRDT, these commands silently fail with `"No story, bug, or spike with number {N} found"` for any story whose filesystem shadow doesn't exist — *even when the story is fully present in the DB and CRDT*.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a user typing chat commands in the web UI or the matrix bot, I want move/show/depends/unblock/start/assign/delete to find any story that's in the pipeline regardless of whether its filesystem shadow exists, so the chat workflow stays usable post-migration.
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
Master commit `41515e3b` had 503's code, the in-memory CRDT view had 503 at stage='merge', the `pipeline_items` row existed (post my-sqlite-update at `5_done`), but `move 503 done` in the web UI returned **`No story, bug, or spike with number 503 found`** because no `.huskies/work/4_merge/503_*.md` file existed.
|
||||
|
||||
## Implementation note
|
||||
|
||||
The MCP `move_story` tool already does this correctly: it goes through `lifecycle::move_item` which checks `crdt_state::read_item(story_id)` first. The chat commands need to use the same lookup helper. The fix should consolidate all "find story by number" logic into one shared function used by every command.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] All seven chat commands (move_story, show, depends, unblock, start, assign, delete) successfully find stories that exist in CRDT but have no filesystem shadow
|
||||
- [ ] Backward compat: commands still work for stories with only filesystem shadows (during the migration window)
|
||||
- [ ] A single shared `find_story_by_number` helper is introduced and used by every chat command
|
||||
- [ ] Lookup priority order is documented and consistent: CRDT first, then pipeline_items, then filesystem fallback
|
||||
- [ ] Regression test per command covering CRDT-only, filesystem-only, both-present, and not-found cases
|
||||
- [ ] Observed repro from 2026-04-09 (move 503 done failing even though 503 was fully present in CRDT and pipeline_items) is the canonical regression case
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: "Startup reconcile pass that detects drift between CRDT, pipeline_items, and filesystem shadows"
|
||||
---
|
||||
|
||||
# Story 513: Startup reconcile pass that detects drift between CRDT, pipeline_items, and filesystem shadows
|
||||
|
||||
## User Story
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Post-491/492, huskies has **four places state lives** that can drift apart:
|
||||
|
||||
1. `crdt_ops` table — the persisted CRDT operation log (intended source of truth)
|
||||
2. In-memory CRDT view — `state.crdt.doc.items` reconstructed from `crdt_ops` on startup, mutated by `apply_and_persist` during runtime
|
||||
3. `pipeline_items` table — a shadow / materialised view, written to as a shadow alongside CRDT writes
|
||||
4. Filesystem shadows in `.huskies/work/N_stage/*.md` — legacy rendering, still written by some paths and read by others
|
||||
|
||||
There is currently **no reconcile pass** that detects drift between them. We've watched this drift bite repeatedly today: stories appear in some views and not others, lifecycle moves happen in one but not another, my direct sqlite UPDATE was invisible to the API, etc. Each individual view looks "fine" in isolation, but the drift only becomes visible when a user notices a story behaving inconsistently.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a developer or operator running huskies, I want a startup reconcile pass that compares all four state sources and either reconciles them automatically (preferred) or logs structured warnings about the drift, so I can detect and diagnose state corruption before it causes user-visible bugs.
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
Throughout this session we observed: 478 in pipeline_items but missing from CRDT (after a direct sqlite insert), 503 in CRDT at stage=merge but pipeline_items at stage=5_done (after my UPDATE), filesystem shadows in `1_backlog/` for stories that were already in 5_done in the DB (bug 510), etc. None of these were detected by huskies itself — they were all only found by running ad-hoc `SELECT` queries during incident response.
|
||||
|
||||
## Scope
|
||||
|
||||
This is the *detection* story, not the *fix-the-drift* story. The reconcile pass should:
|
||||
|
||||
- Run at startup (after CRDT replay, before serving requests)
|
||||
- Compare each story's stage across all four sources
|
||||
- Emit structured log lines for each drift type (CRDT-only, FS-only, DB-only, stage mismatch, etc.)
|
||||
- Optionally surface a count to the matrix bot startup announcement (e.g. "⚠️ 3 stories have CRDT/DB drift — see logs")
|
||||
|
||||
The actual *fix-the-drift* logic (what to do when drift is detected) is a separate, larger story.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] At server startup, after CRDT replay, a reconcile_state() function runs that walks all four state sources and detects drift
|
||||
- [ ] Each drift type is logged with a structured line: e.g. `[reconcile] DRIFT story=X crdt_stage=Y db_stage=Z fs_stage=W` (or `MISSING` for absent)
|
||||
- [ ] If any drift is detected, the matrix bot startup announcement includes a count and a suggestion to check the server logs
|
||||
- [ ] The reconcile pass completes in < 1 second for a typical pipeline (~100 stories) so it doesn't slow startup meaningfully
|
||||
- [ ] Tests cover: no drift (clean state), CRDT-only story, DB-only story, FS-only story, stage mismatch between CRDT and DB
|
||||
- [ ] Documentation in README.md explains the reconcile pass and what each drift type means
|
||||
- [ ] The pass is opt-out via a config flag in case it produces noise during the migration window
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: "delete_story should do a full cleanup (CRDT op + DB row + filesystem shadow + worktree + pending timers)"
|
||||
---
|
||||
|
||||
# Story 514: delete_story should do a full cleanup (CRDT op + DB row + filesystem shadow + worktree + pending timers)
|
||||
|
||||
## User Story
|
||||
|
||||
**Depends on story 520** (typed pipeline state machine). With 520 in place, `delete_story` becomes a single typed transition (`* → Archived(Abandoned)` or a hard-delete CRDT op) followed by event subscribers that handle the worktree, timers, and filesystem cleanup. This story should be re-shaped as the consumer migration once 520 lands.
|
||||
|
||||
---
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
## Context
|
||||
|
||||
The MCP `delete_story` tool currently only **removes the filesystem markdown** from `.huskies/work/N_stage/`. It does NOT:
|
||||
|
||||
- Remove the row from `pipeline_items`
|
||||
- Write a CRDT delete op to `crdt_ops`
|
||||
- Tear down the in-memory CRDT entry
|
||||
- Remove the `.huskies/worktrees/N_…/` worktree
|
||||
- Cancel any pending rate-limit retry timers in `.huskies/timers.json`
|
||||
|
||||
So after `delete_story`, the story keeps appearing in `get_pipeline_status` (because the in-memory CRDT still has it), the timer fires and re-spawns an agent, the agent runs in the still-existing worktree, and the user has no idea why the "deleted" story keeps coming back.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a user calling `delete_story` (via MCP, web UI, or chat command), I want a complete tear-down of all state associated with that story across every layer, so the story is actually gone — no in-memory cache entries, no pending agents, no timers, no worktree, no shadow files, no future spawns.
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
Repeatedly throughout the session. The most concrete example was around 17:20: I called `delete_story 478_…`, the tool returned success, the markdown file at `.huskies/work/1_backlog/478_…md` was removed, but at 17:25:17 the rate-limit retry timer fired and **re-spawned a coder-1 on the deleted story** because the worktree still existed, the pipeline_items row still existed, and the timer entry still existed in `.huskies/timers.json`. We then had to do sqlite surgery + manual worktree removal + manual timers.json edit to actually kill 478.
|
||||
|
||||
## Implementation note
|
||||
|
||||
The current `delete_story` is on the legacy filesystem path. The fix needs to wrap it in a transaction that touches every layer:
|
||||
|
||||
1. Cancel any pending timers for this story_id (read timers.json, filter, write back)
|
||||
2. Stop any running/pending agents for this story_id (call `agent_pool.stop_agent` for each)
|
||||
3. Remove the worktree if it exists (`git worktree remove`)
|
||||
4. Write a CRDT delete op (`apply_and_persist` with a delete op)
|
||||
5. Wait for the persist task to confirm
|
||||
6. Delete the row from `pipeline_items` directly (or trust the materialiser to drop it)
|
||||
7. Remove the filesystem shadow
|
||||
|
||||
Each step should be best-effort with logging — partial failures should be visible, not silent.
|
||||
|
||||
## Context
|
||||
|
||||
The MCP `delete_story` tool currently only **removes the filesystem markdown** from `.huskies/work/N_stage/`. It does NOT:
|
||||
|
||||
- Remove the row from `pipeline_items`
|
||||
- Write a CRDT delete op to `crdt_ops`
|
||||
- Tear down the in-memory CRDT entry
|
||||
- Remove the `.huskies/worktrees/N_…/` worktree
|
||||
- Cancel any pending rate-limit retry timers in `.huskies/timers.json`
|
||||
|
||||
So after `delete_story`, the story keeps appearing in `get_pipeline_status` (because the in-memory CRDT still has it), the timer fires and re-spawns an agent, the agent runs in the still-existing worktree, and the user has no idea why the "deleted" story keeps coming back.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a user calling `delete_story` (via MCP, web UI, or chat command), I want a complete tear-down of all state associated with that story across every layer, so the story is actually gone — no in-memory cache entries, no pending agents, no timers, no worktree, no shadow files, no future spawns.
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
Repeatedly throughout the session. The most concrete example was around 17:20: I called `delete_story 478_…`, the tool returned success, the markdown file at `.huskies/work/1_backlog/478_…md` was removed, but at 17:25:17 the rate-limit retry timer fired and **re-spawned a coder-1 on the deleted story** because the worktree still existed, the pipeline_items row still existed, and the timer entry still existed in `.huskies/timers.json`. We then had to do sqlite surgery + manual worktree removal + manual timers.json edit to actually kill 478.
|
||||
|
||||
## Implementation note
|
||||
|
||||
The current `delete_story` is on the legacy filesystem path. The fix needs to wrap it in a transaction that touches every layer:
|
||||
|
||||
1. Cancel any pending timers for this story_id (read timers.json, filter, write back)
|
||||
2. Stop any running/pending agents for this story_id (call `agent_pool.stop_agent` for each)
|
||||
3. Remove the worktree if it exists (`git worktree remove`)
|
||||
4. Write a CRDT delete op (`apply_and_persist` with a delete op)
|
||||
5. Wait for the persist task to confirm
|
||||
6. Delete the row from `pipeline_items` directly (or trust the materialiser to drop it)
|
||||
7. Remove the filesystem shadow
|
||||
|
||||
Each step should be best-effort with logging — partial failures should be visible, not silent.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] delete_story returns success only when ALL of the following are true: no row in pipeline_items, no op in crdt_ops referencing the story_id (or a delete op present), no in-memory CRDT entry, no worktree directory, no timer entries, no filesystem shadow
|
||||
- [ ] Each tear-down step has its own log line so partial failures are diagnosable
|
||||
- [ ] If any tear-down step fails, the tool returns an error with which step failed and what was already torn down (so the user can finish the cleanup manually)
|
||||
- [ ] After delete_story, the story does NOT appear in get_pipeline_status, the web UI, or list_agents
|
||||
- [ ] After delete_story, no rate-limit retry timer can re-spawn an agent on the deleted story
|
||||
- [ ] Regression test using the 2026-04-09 repro: schedule a rate-limit timer for the story, call delete_story, fast-forward 5 minutes, assert no agent spawned
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: "Add a debug MCP tool to dump the in-memory CRDT state for inspection"
|
||||
---
|
||||
|
||||
# Story 515: Add a debug MCP tool to dump the in-memory CRDT state for inspection
|
||||
|
||||
## User Story
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
When diagnosing CRDT/state issues today, we had no way to look at the **in-memory** CRDT state directly. The closest available views were:
|
||||
|
||||
- `get_pipeline_status` — gives a summarised pipeline-shaped view (active/backlog/done) but hides the raw item structure, the index map, the lamport clock state, etc.
|
||||
- Querying `crdt_ops` directly via sqlite — gives the *persisted* state, which can diverge from the in-memory state (we saw this with bug 511, where post-restart writes use reset seq counters)
|
||||
- `read_item(story_id)` in `crdt_state.rs` — exists, returns a `PipelineItemView`, but is not exposed via MCP or HTTP
|
||||
|
||||
The result: every time I needed to check the CRDT state, I was either inferring it from `get_pipeline_status` (lossy) or querying the persisted ops (lagging the in-memory state). Neither gave me the ground truth.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a developer debugging huskies state issues, I want an MCP tool (or HTTP debug endpoint) that returns a structured dump of the in-memory CRDT state, so I can see exactly what the running server thinks is true without inferring from summaries.
|
||||
|
||||
## Suggested API
|
||||
|
||||
- Tool name: `mcp__huskies__dump_crdt`
|
||||
- Args: optional `story_id` filter (single story) or no args (dump everything)
|
||||
- Returns: JSON with one entry per item containing: `story_id`, all field values (`stage`, `name`, `agent`, `retry_count`, `blocked`, `depends_on`), the CRDT path/index bytes (for cross-referencing with `crdt_ops`), the local lamport seq counter, and a flag indicating whether the item is `is_deleted`
|
||||
- Returns metadata: total item count, current local seq counter value, count of pending ops in `persist_tx` channel (if observable)
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
This story would have saved us significant debugging time. Specific examples:
|
||||
|
||||
- When 478 was missing from `get_pipeline_status` after the manual sqlite insert, we had to infer "the API reads from in-memory CRDT, not from pipeline_items" by looking at source code. A `dump_crdt 478_…` call would have returned "not found" immediately, confirming the same conclusion.
|
||||
- When 503 was showing at stage=merge in the API but only had a creation op at stage=1_backlog in `crdt_ops`, we had to manually search for content-indexed update ops to figure out where the post-restart updates went. A dump tool showing the current in-memory state vs the persisted op count would have made the divergence obvious.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] New MCP tool `dump_crdt` is registered and callable
|
||||
- [ ] With no args, returns all items in the in-memory CRDT as a structured JSON list
|
||||
- [ ] With a story_id arg, returns just that one item (or null if not found)
|
||||
- [ ] Each item entry includes: story_id, stage, name, agent, retry_count, blocked, depends_on, content_index (hex), is_deleted
|
||||
- [ ] Returns top-level metadata: total_items, max_local_seq, pending_persist_ops_count (if available), in_memory_state_loaded (bool)
|
||||
- [ ] Tool description is clear that this is a debug tool, not for normal pipeline introspection (those should use get_pipeline_status)
|
||||
- [ ] Optional: also expose via HTTP at `/debug/crdt` for browser inspection
|
||||
- [ ] Documented in README.md under a 'debugging' section
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: "update_story.description should create the ## Description section if it doesn't exist (instead of erroring)"
|
||||
---
|
||||
|
||||
# Story 516: update_story.description should create the ## Description section if it doesn't exist (instead of erroring)
|
||||
|
||||
## User Story
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The MCP `update_story` tool's `description` parameter "replaces the `## Description` section content". If the section doesn't exist in the story file, the call **errors out** with `Section '## Description' not found in story file.`
|
||||
|
||||
This becomes a real problem when:
|
||||
|
||||
1. A story was created via `create_story` (which is buggy per 509 and writes a stub template with no `## Description` section)
|
||||
2. The user later wants to add a description via `update_story`
|
||||
3. The update fails with the cryptic "section not found" error
|
||||
|
||||
We hit this exact scenario today: after bug 509 dropped the descriptions of 6 stories (500, 504, 505, 506, 507, 508), I tried to recover them by calling `update_story` with `description=...` — and the call errored out because the stub template the buggy `create_story` had written had no `## Description` section. We had to fall back to stuffing everything into the `user_story` field.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a user calling `update_story.description` on any story (regardless of how it was originally created), I want the call to either replace the existing `## Description` section OR create one if it doesn't exist, so I never have to think about the template structure.
|
||||
|
||||
## Implementation note
|
||||
|
||||
The simplest fix is in the `update_story_in_file` (or equivalent) function: when looking for the `## Description` section, if not found, **insert it** at a sensible location — probably between `## User Story` and `## Acceptance Criteria` — and then write the description content there.
|
||||
|
||||
Related: this story partially covers the workaround for bug 509 (create_story drops description). If 509 is fixed first, the templates would always have a `## Description` section and this wouldn't matter. But this fix is still valuable for older stories created before 509 lands, AND for stories created via legacy paths that don't use the canonical template.
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
```
|
||||
> update_story story_id=500_story_remove_duplicate_pty_debug_log_lines description="..."
|
||||
Error: Section '## Description' not found in story file.
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] update_story.description succeeds whether or not the target story has a pre-existing ## Description section
|
||||
- [ ] When the section is missing, it is created at a consistent location (between ## User Story and ## Acceptance Criteria)
|
||||
- [ ] When the section exists, the existing replace-content behaviour is preserved (no regression)
|
||||
- [ ] Unit test covering both: section-exists path AND section-missing path
|
||||
- [ ] Symmetric fix for update_story.user_story (if it has the same brittleness)
|
||||
- [ ] Error messages for genuine failure modes (file not found, write failed) are still distinct from the now-silent missing-section case
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: "Remove filesystem-shadow fallback paths from lifecycle.rs (finish the migration to CRDT-only)"
|
||||
---
|
||||
|
||||
# Story 517: Remove filesystem-shadow fallback paths from lifecycle.rs (finish the migration to CRDT-only)
|
||||
|
||||
## User Story
|
||||
|
||||
**Depends on story 520** (typed pipeline state machine). Once 520 lands and consumers are migrated to the typed transition API, the lifecycle module no longer needs filesystem fallbacks — all state changes go through the typed `transition` function and the event bus. This story becomes the natural cleanup pass after 520 + 512 + 514 land.
|
||||
|
||||
---
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
## Context
|
||||
|
||||
`server/src/agents/lifecycle.rs::move_item` (the helper that backs `move_story_to_current`, `move_story_to_done`, `move_story_to_merge`, etc.) has **three execution paths**:
|
||||
|
||||
1. **CRDT-first path** (the "happy" post-migration path) — calls `crdt_state::read_item(story_id)`, then `db::move_item_stage`, which writes a CRDT op and broadcasts events
|
||||
2. **Content-store fallback** — if the story isn't in CRDT but exists in the db's content store, import it via `db::write_item_with_content`
|
||||
3. **Filesystem fallback** — if neither, scan `.huskies/work/N_stage/` for a markdown file, import it to the DB
|
||||
|
||||
Paths 2 and 3 are **migration scaffolding**. They were necessary while stories existed only on disk and the CRDT was empty, but post-491/492 they should be unnecessary. Worse, they actively *cause* drift today:
|
||||
|
||||
- The filesystem fallback can re-import stale shadow files into the DB, undoing intentional deletes
|
||||
- The path 3 search is blind to which stage a story "should" be in per the DB — it picks whatever stage dir has the file, which can promote stale shadows
|
||||
- This is the mechanism that makes bug 510 (split-brain shadow promotion) possible
|
||||
|
||||
`move_story_to_current` is hardcoded to read from `["1_backlog"]`, which is also part of the same legacy filesystem assumption.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a developer maintaining huskies, I want the lifecycle code to operate exclusively on the CRDT/DB and never touch filesystem shadows, so state drift is eliminated and the post-migration architecture is consistent.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
1. Inventory every code path in `lifecycle.rs` that touches the filesystem under `.huskies/work/`
|
||||
2. For each, determine whether it's a *read* (legacy fallback — can be removed if we're confident all stories are in CRDT now) or a *write* (legacy mirror — can be deferred to a separate filesystem-renderer task that derives state from CRDT)
|
||||
3. Remove the read fallbacks
|
||||
4. Move the writes to a downstream materialiser task that writes the filesystem shadows from CRDT events (so they're strictly read-only renderings)
|
||||
5. Run the bug-510 reconcile pass at startup (story TBD) before this lands, to ensure no story is stranded with only a filesystem shadow
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
We watched the filesystem fallback paths cause harm multiple times today:
|
||||
- Bug 510 split-brain: filesystem shadows in `1_backlog/` got re-promoted by timer fires after the DB had already moved the story to `5_done`
|
||||
- The 478 worktree's `move_story_to_current` no-op'd because there was no `1_backlog` shadow — even though 478 was in `4_merge` per the DB (this was actually correct behaviour given the function's narrow `from = ["1_backlog"]`, but it surfaces how filesystem-bound the function is)
|
||||
- Lifecycle moves were happening on the filesystem without writing CRDT ops (we initially mis-diagnosed this as "no transition ops in CRDT" before finding bug 511)
|
||||
|
||||
## Context
|
||||
|
||||
`server/src/agents/lifecycle.rs::move_item` (the helper that backs `move_story_to_current`, `move_story_to_done`, `move_story_to_merge`, etc.) has **three execution paths**:
|
||||
|
||||
1. **CRDT-first path** (the "happy" post-migration path) — calls `crdt_state::read_item(story_id)`, then `db::move_item_stage`, which writes a CRDT op and broadcasts events
|
||||
2. **Content-store fallback** — if the story isn't in CRDT but exists in the db's content store, import it via `db::write_item_with_content`
|
||||
3. **Filesystem fallback** — if neither, scan `.huskies/work/N_stage/` for a markdown file, import it to the DB
|
||||
|
||||
Paths 2 and 3 are **migration scaffolding**. They were necessary while stories existed only on disk and the CRDT was empty, but post-491/492 they should be unnecessary. Worse, they actively *cause* drift today:
|
||||
|
||||
- The filesystem fallback can re-import stale shadow files into the DB, undoing intentional deletes
|
||||
- The path 3 search is blind to which stage a story "should" be in per the DB — it picks whatever stage dir has the file, which can promote stale shadows
|
||||
- This is the mechanism that makes bug 510 (split-brain shadow promotion) possible
|
||||
|
||||
`move_story_to_current` is hardcoded to read from `["1_backlog"]`, which is also part of the same legacy filesystem assumption.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a developer maintaining huskies, I want the lifecycle code to operate exclusively on the CRDT/DB and never touch filesystem shadows, so state drift is eliminated and the post-migration architecture is consistent.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
1. Inventory every code path in `lifecycle.rs` that touches the filesystem under `.huskies/work/`
|
||||
2. For each, determine whether it's a *read* (legacy fallback — can be removed if we're confident all stories are in CRDT now) or a *write* (legacy mirror — can be deferred to a separate filesystem-renderer task that derives state from CRDT)
|
||||
3. Remove the read fallbacks
|
||||
4. Move the writes to a downstream materialiser task that writes the filesystem shadows from CRDT events (so they're strictly read-only renderings)
|
||||
5. Run the bug-510 reconcile pass at startup (story TBD) before this lands, to ensure no story is stranded with only a filesystem shadow
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
We watched the filesystem fallback paths cause harm multiple times today:
|
||||
- Bug 510 split-brain: filesystem shadows in `1_backlog/` got re-promoted by timer fires after the DB had already moved the story to `5_done`
|
||||
- The 478 worktree's `move_story_to_current` no-op'd because there was no `1_backlog` shadow — even though 478 was in `4_merge` per the DB (this was actually correct behaviour given the function's narrow `from = ["1_backlog"]`, but it surfaces how filesystem-bound the function is)
|
||||
- Lifecycle moves were happening on the filesystem without writing CRDT ops (we initially mis-diagnosed this as "no transition ops in CRDT" before finding bug 511)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Inventory of every filesystem touch in lifecycle.rs is documented in the story body or a follow-up comment
|
||||
- [ ] All read fallbacks in lifecycle.rs (paths 2 and 3 above) are removed
|
||||
- [ ] All write paths in lifecycle.rs that mirror to the filesystem are moved to a separate materialiser task driven by CRDT events
|
||||
- [ ] After the change, lifecycle.rs has zero direct std::fs:: calls under .huskies/work/
|
||||
- [ ] move_story_to_current no longer hardcodes from=['1_backlog'] — it reads the source stage from CRDT
|
||||
- [ ] Regression: the existing 'try filesystem fallback' tests are updated to test the new CRDT-only path instead of being deleted
|
||||
- [ ] A pre-flight script verifies all existing stories are in CRDT before this change lands (so nothing gets stranded)
|
||||
- [ ] Bug 510 (split-brain shadows) no longer reproduces after this change
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: "apply_and_persist should log when persist_tx send fails instead of silently dropping the op"
|
||||
---
|
||||
|
||||
# Story 518: apply_and_persist should log when persist_tx send fails instead of silently dropping the op
|
||||
|
||||
## User Story
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
`server/src/crdt_state.rs::apply_and_persist` updates the in-memory CRDT and then sends the signed op to the persistence task via a channel:
|
||||
|
||||
```rust
|
||||
fn apply_and_persist<F>(state: &mut CrdtState, op_fn: F) {
|
||||
let raw_op = op_fn(state);
|
||||
let signed = raw_op.sign(&state.keypair);
|
||||
state.crdt.apply(signed.clone()); // in-memory update
|
||||
let _ = state.persist_tx.send(signed.clone()); // ← fire-and-forget, error dropped
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The `let _ = ...` discards the return value of `send()`. If the channel is closed (because the persistence task panicked, was shut down, or has dropped its receiver), the op is silently dropped from persistence — but the in-memory CRDT is already updated. The next restart will replay only the persisted ops, and the in-memory state will quietly diverge from the persisted state.
|
||||
|
||||
This is also one of the candidate causes for some of the state drift we've been chasing. It's hard to rule out because there's no log line to confirm whether the persist task is still alive or whether sends are succeeding.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a developer or operator, I want any failure of `persist_tx.send()` to be logged immediately at WARN or ERROR level, so silent persistence loss is detectable instead of invisible.
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
Spent significant time investigating whether persist sends were silently failing. Eventually ruled it out empirically (we found that ops WERE being persisted, just with reset seq counters per bug 511). But the diagnosis would have been minutes instead of an hour if there was a log line to check.
|
||||
|
||||
## Fix (small)
|
||||
|
||||
```rust
|
||||
if let Err(e) = state.persist_tx.send(signed.clone()) {
|
||||
crate::slog_error!(
|
||||
"[crdt] Failed to send op to persist task: {e}; persist task may be dead. \
|
||||
In-memory state is now ahead of persisted state."
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Apply the same fix at every `let _ = state.persist_tx.send(...)` site in crdt_state.rs (there are at least 2 — one in apply_and_persist, one in apply_remote_op).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Every call site of `state.persist_tx.send(...)` in crdt_state.rs logs at ERROR level on send failure
|
||||
- [ ] The error message includes the channel error and a clear note that 'in-memory and persisted state may have diverged'
|
||||
- [ ] Unit test: shut down the persist receiver (drop the rx end), call write_item, assert an error is logged
|
||||
- [ ] No regression in the happy path (no extra log lines on success)
|
||||
- [ ] Consider: also expose a counter / metric for persist send failures so it can be monitored without grepping logs
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: "mergemaster should detect no-commits-ahead-of-master and fail loudly instead of exiting silently"
|
||||
---
|
||||
|
||||
# Story 519: mergemaster should detect no-commits-ahead-of-master and fail loudly instead of exiting silently
|
||||
|
||||
## User Story
|
||||
|
||||
**Depends on story 520** (typed pipeline state machine). Once 520 lands, this story largely *evaporates*: `Stage::Merge` is defined as `Merge { feature_branch: BranchName, commits_ahead: NonZeroU32 }`, so a merge state with zero commits ahead is **structurally unrepresentable**. The transition `Current → Merge` (or `Qa → Merge`) is required to provide a NonZeroU32 — the type system enforces it. This story remains useful as a *defensive runtime check* during the migration window before 520 lands; afterwards, it should be closed as redundant.
|
||||
|
||||
---
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
## Context
|
||||
|
||||
When mergemaster runs on a story whose worktree has **zero commits ahead of master** (e.g. because `create_worktree` always creates from master and the original feature branch was never checked out into the worktree), it currently:
|
||||
|
||||
1. Spawns its claude session
|
||||
2. Runs `merge_agent_work` MCP tool
|
||||
3. Finds nothing to merge
|
||||
4. Exits cleanly with `[agent:N:mergemaster] Done. Session: ...`
|
||||
5. **Does not log any error or warning**
|
||||
6. **Spends real money** on the empty session — we observed `cost=$0.82` for one such no-op run
|
||||
|
||||
The user has no signal that the merge didn't actually happen. The matrix bot fires a "QA → Merge" stage notification (because the story did move stages internally), then nothing — no `🎉 Merge → Done` notification follows. Master is unchanged.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a user watching the pipeline, I want mergemaster to detect "this worktree has no commits ahead of master" *before* spending money on a Claude session, and fail loudly with a clear error so I know to investigate the upstream cause (probably the worktree got reset to master).
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
Around 18:31:51, mergemaster spawned for 478 in a worktree that had been reset to master by the orphan cleanup logic at 18:29:54. By the time mergemaster ran, the worktree was on master with zero commits ahead. It ran a session, spent $0.82, exited "Done", and didn't merge anything. We didn't notice for several minutes because the failure was completely silent. We had to manually `git log master..feature/story-478_…` to confirm there was no merge commit on master.
|
||||
|
||||
## Fix
|
||||
|
||||
In mergemaster's startup sequence (probably in advance.rs or wherever the mergemaster session is spawned), add a pre-flight check:
|
||||
|
||||
```rust
|
||||
let commits_ahead = git_commits_ahead(worktree_path, "master")?;
|
||||
if commits_ahead == 0 {
|
||||
slog_error!(
|
||||
"[mergemaster] worktree {worktree_path} has no commits ahead of master; \
|
||||
refusing to spawn merge session. Likely cause: worktree was reset to \
|
||||
master after the feature branch's commits were created. Investigate the \
|
||||
worktree's git state before retrying."
|
||||
);
|
||||
return Err("no commits to merge".into());
|
||||
}
|
||||
```
|
||||
|
||||
This costs ~milliseconds (one git command) and saves the cost of an entire Claude session per false-positive.
|
||||
|
||||
## Context
|
||||
|
||||
When mergemaster runs on a story whose worktree has **zero commits ahead of master** (e.g. because `create_worktree` always creates from master and the original feature branch was never checked out into the worktree), it currently:
|
||||
|
||||
1. Spawns its claude session
|
||||
2. Runs `merge_agent_work` MCP tool
|
||||
3. Finds nothing to merge
|
||||
4. Exits cleanly with `[agent:N:mergemaster] Done. Session: ...`
|
||||
5. **Does not log any error or warning**
|
||||
6. **Spends real money** on the empty session — we observed `cost=$0.82` for one such no-op run
|
||||
|
||||
The user has no signal that the merge didn't actually happen. The matrix bot fires a "QA → Merge" stage notification (because the story did move stages internally), then nothing — no `🎉 Merge → Done` notification follows. Master is unchanged.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a user watching the pipeline, I want mergemaster to detect "this worktree has no commits ahead of master" *before* spending money on a Claude session, and fail loudly with a clear error so I know to investigate the upstream cause (probably the worktree got reset to master).
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
Around 18:31:51, mergemaster spawned for 478 in a worktree that had been reset to master by the orphan cleanup logic at 18:29:54. By the time mergemaster ran, the worktree was on master with zero commits ahead. It ran a session, spent $0.82, exited "Done", and didn't merge anything. We didn't notice for several minutes because the failure was completely silent. We had to manually `git log master..feature/story-478_…` to confirm there was no merge commit on master.
|
||||
|
||||
## Fix
|
||||
|
||||
In mergemaster's startup sequence (probably in advance.rs or wherever the mergemaster session is spawned), add a pre-flight check:
|
||||
|
||||
```rust
|
||||
let commits_ahead = git_commits_ahead(worktree_path, "master")?;
|
||||
if commits_ahead == 0 {
|
||||
slog_error!(
|
||||
"[mergemaster] worktree {worktree_path} has no commits ahead of master; \
|
||||
refusing to spawn merge session. Likely cause: worktree was reset to \
|
||||
master after the feature branch's commits were created. Investigate the \
|
||||
worktree's git state before retrying."
|
||||
);
|
||||
return Err("no commits to merge".into());
|
||||
}
|
||||
```
|
||||
|
||||
This costs ~milliseconds (one git command) and saves the cost of an entire Claude session per false-positive.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Before mergemaster spawns its Claude session, it runs `git log master..HEAD --oneline` (or equivalent) on the worktree
|
||||
- [ ] If the result is empty (zero commits ahead), mergemaster exits early with an ERROR log line and does NOT spawn the session
|
||||
- [ ] The error message is specific enough that the user can diagnose the upstream cause (e.g. mentions 'worktree was reset' and suggests checking the worktree's branch)
|
||||
- [ ] The matrix bot sends a clear failure notification (NOT a successful 🎉 emoji) when this happens
|
||||
- [ ] The story does not advance to a 'done' state when mergemaster exits this way; it stays in 4_merge with a clear blocked status
|
||||
- [ ] Regression test: create a worktree on master (no feature commits), invoke mergemaster, assert the early exit happens and no Claude session is spawned
|
||||
- [ ] Cost saving observed in the 2026-04-09 incident ($0.82 per no-op session) is documented in the test as the motivation
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
---
|
||||
name: "Typed pipeline state machine in Rust (foundation: replaces stringly-typed CRDT views with strict enums, subsumes 436)"
|
||||
---
|
||||
|
||||
# Story 520: Typed pipeline state machine in Rust (foundation: replaces stringly-typed CRDT views with strict enums, subsumes 436)
|
||||
|
||||
## User Story
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Today huskies represents pipeline state as a loose JSON document inside the BFT JSON CRDT. Each story has fields like `stage: String`, `agent: String`, `retry_count: f64`, `blocked: bool`, `depends_on: String` (JSON-encoded list, double-encoded). This stringly-typed representation allows **many impossible states** to be representable in the data model:
|
||||
|
||||
- `stage = "9_invalid"` — typo, no compile error
|
||||
- `stage = "5_done"` + `blocked = true` — a done story is blocked? what does that mean?
|
||||
- `stage = "4_merge"` with no commits ahead of master — the silent mergemaster failure mode (today's story 478)
|
||||
- A coder agent assigned to a story in `4_merge` — bug 502, the loop we fought all day today
|
||||
- `retry_count = 3.7` — fractional retry counts (it's an f64 because that's what JSON CRDTs do)
|
||||
- `agent = "coder-1"` AND `stage = "1_backlog"` — backlog story has an agent? sentinel encoding via empty string
|
||||
|
||||
Multiple bugs filed today (501, 502, 510, 511) exist *because* the type system can't enforce the pipeline invariants. **Patching individual symptoms forever is the wrong strategy.** The right strategy is to make impossible states unrepresentable at the Rust type level, using a typed state machine layered on top of the loose CRDT. The CRDT can stay loose at the persistence layer (it has to be — that's what makes it merge correctly across nodes), but every consumer above the CRDT operates on strict typed enums.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a developer working on huskies, I want the pipeline state to be expressed as a strict Rust state machine where impossible states and impossible transitions are compile-time errors, so future bugs in this category become structural rather than runtime drift.
|
||||
|
||||
## Design
|
||||
|
||||
### Two enum hierarchies
|
||||
|
||||
**Synced state (CRDT-backed, converges across nodes):**
|
||||
|
||||
```rust
|
||||
enum Stage {
|
||||
Backlog,
|
||||
Current,
|
||||
Qa,
|
||||
Merge { feature_branch: BranchName, commits_ahead: NonZeroU32 },
|
||||
Done { merged_at: DateTime<Utc>, merge_commit: GitSha },
|
||||
Archived { archived_at: DateTime<Utc>, reason: ArchiveReason },
|
||||
}
|
||||
|
||||
enum ArchiveReason {
|
||||
Completed, // normal accept_story → archived
|
||||
Abandoned, // user explicitly abandoned
|
||||
Superseded { by: StoryId },
|
||||
Blocked { reason: String }, // was bug 436's `blocked: true`
|
||||
MergeFailed { reason: String }, // was bug 436's `merge_failure`
|
||||
ReviewHeld { reason: String }, // was bug 436's `review_hold`
|
||||
}
|
||||
|
||||
struct PipelineItem {
|
||||
story_id: StoryId, // newtype, validated
|
||||
name: String,
|
||||
stage: Stage, // typed enum, all variants are valid by construction
|
||||
depends_on: Vec<StoryId>, // parsed, not stringified
|
||||
retry_count: u32, // not f64
|
||||
// No more separate `blocked`, `merge_failure`, `review_hold` — folded into Stage::Archived
|
||||
}
|
||||
```
|
||||
|
||||
**Per-node execution state (CRDT-backed under node_id key, local-authored but globally-readable):**
|
||||
|
||||
```rust
|
||||
enum ExecutionState {
|
||||
Idle,
|
||||
Pending { agent: AgentName, since: DateTime<Utc> },
|
||||
Running { agent: AgentName, started_at: DateTime<Utc>, last_heartbeat: DateTime<Utc> },
|
||||
RateLimited { agent: AgentName, resume_at: DateTime<Utc> },
|
||||
Completed { agent: AgentName, exit_code: i32, completed_at: DateTime<Utc> },
|
||||
}
|
||||
|
||||
// In the CRDT document, ExecutionState is stored under each node's pubkey:
|
||||
// crdt.execution_state: { node_pubkey → { story_id → ExecutionState } }
|
||||
```
|
||||
|
||||
The execution state lives in the CRDT under **each node's pubkey**. Each node only writes to entries where `node_pubkey == self.pubkey`, so there's no merge conflict — concurrent writes from the same author follow LWW, concurrent writes from different authors target different entries entirely. All nodes can READ all execution states across the mesh.
|
||||
|
||||
**This per-node-keyed CRDT pattern enables:**
|
||||
|
||||
- **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 a node hasn't updated its execution_state in N minutes, the entry is "stale" (laptop closed, process crashed, oom kill, etc.)
|
||||
- **Foundation for story 479** (CRDT work claiming) — a node knows what other nodes are doing *before* claiming work
|
||||
- **Stuck job recovery** — if node A's heartbeat dies mid-run, node B can see the stuck state and decide whether to take over
|
||||
- **Crash forensics** — the last persisted ExecutionState before a crash is preserved in CRDT, accessible from any node
|
||||
|
||||
### The transition function
|
||||
|
||||
```rust
|
||||
fn transition(
|
||||
state: PipelineItem,
|
||||
event: PipelineEvent,
|
||||
) -> Result<PipelineItem, TransitionError>
|
||||
```
|
||||
|
||||
Pure function. Takes the current state and an event, returns either the new state or a TransitionError. The compiler enforces that the result of every transition is structurally valid — you can't construct a `Stage::Merge` without `commits_ahead: NonZeroU32`, you can't construct a `Stage::Done` without a `merge_commit: GitSha`, etc.
|
||||
|
||||
**The set of valid transitions is small** (roughly 10):
|
||||
|
||||
- `Backlog → Current` — deps met, auto-assign promotes
|
||||
- `Current → Qa` — gates start
|
||||
- `Current → Merge` — qa: server, gates auto-pass
|
||||
- `Qa → Merge` — gates pass
|
||||
- `Qa → Current` — gates fail, retry
|
||||
- `Merge → Done` — mergemaster squash succeeds; *requires `Merge.commits_ahead > 0`*
|
||||
- `Done → Archived(Completed)` — accept_story
|
||||
- `* → Archived(Blocked / MergeFailed / ReviewHeld)` — stuck-state move
|
||||
- `Archived(Blocked) → Backlog` — unblock
|
||||
|
||||
Anything else is a `TransitionError`. The compiler refuses to compile code that constructs invalid transitions.
|
||||
|
||||
### The event subscriber pattern
|
||||
|
||||
State changes fire events on a bus. Side-effect handlers subscribe independently:
|
||||
|
||||
```rust
|
||||
type TransitionEvent = (PipelineItem /* before */, PipelineItem /* after */);
|
||||
|
||||
bus.subscribe("matrix-bot", |before, after| matrix_bot.notify_stage_change(before, after));
|
||||
bus.subscribe("filesystem", |before, after| fs_renderer.update(after));
|
||||
bus.subscribe("pipeline-table", |before, after| pipeline_items_table.upsert(after));
|
||||
bus.subscribe("auto-assign", |before, after| auto_assign.poke_if_relevant(after));
|
||||
bus.subscribe("web-ui-broadcast", |before, after| ws_clients.broadcast(after));
|
||||
```
|
||||
|
||||
Each subscriber is independent and concerns itself only with its own dispatch. Adding a new side effect = adding a new subscriber, not editing the transition function. **The "many things happen on state changes" complexity moves out of the state machine and into the bus consumers**, where each piece is testable in isolation.
|
||||
|
||||
### Projection layer (loose CRDT ↔ typed Rust)
|
||||
|
||||
The bft-json-crdt JSON document is the persistence layer. The typed enums are the application layer. A projection function bridges them at one carefully-controlled boundary:
|
||||
|
||||
```rust
|
||||
impl TryFrom<&PipelineItemCrdt> for PipelineItem {
|
||||
type Error = ProjectionError;
|
||||
fn try_from(crdt: &PipelineItemCrdt) -> Result<Self, ProjectionError> { ... }
|
||||
}
|
||||
|
||||
impl From<&PipelineItem> for PipelineItemCrdt {
|
||||
fn from(item: &PipelineItem) -> Self { ... }
|
||||
}
|
||||
```
|
||||
|
||||
When the CRDT contains data the typed layer can't parse (e.g. a stage value from a future huskies version, OR a merge that produces an inconsistent intermediate state), `try_from` returns a `ProjectionError`. The error surfaces to the caller — it doesn't silently propagate as garbage. The validation happens at exactly one point: the projection boundary.
|
||||
|
||||
## What this subsumes
|
||||
|
||||
**Story 436** ("Unify story stuck states into a single status field") is subsumed by the `Stage::Archived { reason: ArchiveReason }` variant. The unified status field IS the `ArchiveReason` enum. Story 436 is marked superseded by this story.
|
||||
|
||||
## What this enables (concrete bug eliminations)
|
||||
|
||||
- **Bug 502** becomes unrepresentable: there's no way to construct `Stage::Merge` with a Coder agent — Coder agents only attach to `Current` / `Qa` stages, and that constraint is in the type signature of the transition function.
|
||||
- **Bug 510** becomes irrelevant: there's no "stage='1_backlog' filesystem shadow vs stage='5_done' DB" drift, because Stage is a typed enum with a single source of truth (the CRDT), and projections are derived deterministically.
|
||||
- **Bug 519** (mergemaster silent on no-op merge) becomes unrepresentable: `Stage::Merge` requires `commits_ahead: NonZeroU32`. You can't construct a Merge state with zero commits.
|
||||
- **Bug 511** (lamport seq reset) becomes detectable: the projection layer notices when CRDT data fails to parse cleanly and surfaces a ProjectionError instead of silently producing garbage in-memory state.
|
||||
- **Story 479** (CRDT work claiming) has a clean foundation: ExecutionState gives every node visibility into what every other node is doing, including stale-heartbeat detection.
|
||||
- **Future state machine bugs become compile errors**, not runtime drift.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Define the `Stage`, `ArchiveReason`, `ExecutionState`, `PipelineItem` types in a new module (e.g. `server/src/pipeline_state.rs`).
|
||||
2. Implement the projection layer (try_from / from for PipelineItemCrdt).
|
||||
3. Implement the `transition` function with exhaustive valid transitions.
|
||||
4. Implement the event bus.
|
||||
5. Migrate consumers ONE AT A TIME — chat commands, lifecycle, API, auto-assign, matrix bot. Each migration is isolated; the compiler tells you when you've missed something.
|
||||
6. Once nothing reads the loose `PipelineItemView` anymore, delete it.
|
||||
7. Story 436 closes when this lands.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] A new module (e.g. server/src/pipeline_state.rs) defines the Stage, ArchiveReason, ExecutionState, and PipelineItem types with the variants described in the design
|
||||
- [ ] Stage::Merge has a NonZeroU32 commits_ahead field (so the bug 519 silent no-op merge is unrepresentable)
|
||||
- [ ] Stage::Done has GitSha merge_commit and DateTime merged_at fields (so a 'done' story always has merge metadata)
|
||||
- [ ] ArchiveReason enum subsumes the old blocked / merge_failure / review_hold front matter fields, with a sub-reason variant for each
|
||||
- [ ] PipelineItem.depends_on is Vec<StoryId>, not String (no more JSON-as-string)
|
||||
- [ ] ExecutionState lives in the CRDT under per-node-pubkey keys; each node only writes to its own subspace (validated by CRDT signature check)
|
||||
- [ ] Last_heartbeat field is updated periodically by the running node so other nodes can detect stale entries
|
||||
- [ ] A pure transition(state, event) -> Result<PipelineItem, TransitionError> function exists and is exhaustively pattern-matched
|
||||
- [ ] Every valid transition listed in the design (~10) is implemented and unit-tested with both success and error cases
|
||||
- [ ] The TryFrom<&PipelineItemCrdt> for PipelineItem projection function handles every currently-valid CRDT state and returns a structured ProjectionError for invalid ones (instead of silently propagating garbage)
|
||||
- [ ] An event bus pattern is in place where matrix bot, filesystem renderer, pipeline_items materialiser, auto-assign, and web UI broadcaster are independent subscribers
|
||||
- [ ] All call sites that previously read item.stage as a string or used the blocked / merge_failure / review_hold fields are migrated to the typed enum API
|
||||
- [ ] Story 436 is closed as superseded by this story
|
||||
- [ ] Bug 502 has a regression test that confirms the type system prevents the loop (the test should be a compile-fail test if possible)
|
||||
- [ ] Bug 510 (filesystem shadow split-brain) no longer reproduces after this lands, because the typed state machine has a single source of truth
|
||||
- [ ] Documentation in README.md or a new ARCHITECTURE.md explains the type hierarchy, the transition function, the event bus pattern, and the per-node ExecutionState convention
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: "MCP/HTTP capability to write a CRDT tombstone (delete op) for a story, to clear it from in-memory state"
|
||||
---
|
||||
|
||||
# Story 521: MCP/HTTP capability to write a CRDT tombstone (delete op) for a story, to clear it from in-memory state
|
||||
|
||||
## User Story
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Today (2026-04-09) we discovered the hard way that **there is no way to remove a story from huskies's running in-memory state without restarting the server process**. The state machines that keep stories alive include:
|
||||
|
||||
1. The persisted CRDT op log (`crdt_ops` table) — direct sqlite DELETE works
|
||||
2. The in-memory CRDT view (`CRDT_STATE` global in `server/src/crdt_state.rs`) — **no eviction API**
|
||||
3. The in-memory content store (`CONTENT_STORE` in `server/src/db/mod.rs:46`) — has `delete_content()` but no MCP / HTTP exposure
|
||||
4. The shadow `pipeline_items` table — direct sqlite DELETE works
|
||||
5. Filesystem shadows under `.huskies/work/` — `find -delete` works
|
||||
6. `timers.json` — direct file edit works
|
||||
|
||||
If a story gets into a bad state (split-brain, ghost row, runaway timer respawning it), we can scrub all the *persistent* layers (1, 4, 5, 6) but the *in-memory* layers (2, 3) keep regenerating it because some periodic code reads in-memory state and writes new ops based on what it sees. The only way to clear in-memory state today is `docker restart huskies`, which is heavy and disrupts the matrix bot, web UI, and any in-flight agents.
|
||||
|
||||
We need a **scoped, surgical capability** to write a CRDT tombstone op for a single story_id, which:
|
||||
- Marks the in-memory item as `is_deleted = true`
|
||||
- Persists the tombstone op to `crdt_ops` so future replays don't resurrect the story
|
||||
- Removes the story from `CONTENT_STORE`
|
||||
- Cleans up any pending `timers.json` entries for the story
|
||||
- Cancels any running agents on the story
|
||||
|
||||
…and exposes it as an MCP tool (e.g. `mcp__huskies__purge_story`) and ideally an HTTP endpoint, so an operator can "kill it with fire" without restarting the server.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a huskies operator, I want a single MCP/HTTP call that completely removes a story from every layer of state — persistent AND in-memory — so I never have to restart the entire server just to clean up one stuck story.
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
We spent the last hour of this session whack-a-moling stories 503 and 478. Even after:
|
||||
- `DELETE FROM pipeline_items WHERE id LIKE '503%'` ✓
|
||||
- `DELETE FROM crdt_ops WHERE op_json LIKE '%503_bug_depends_on%'` ✓
|
||||
- `mcp stop_agent + remove_worktree` for the running coders ✓
|
||||
- `find .huskies/work -name '503_*' -delete` ✓
|
||||
- emptying `timers.json` (multiple times — kept getting re-populated) ✓
|
||||
|
||||
…503 kept reappearing in `current` with new agents being spawned. The root cause: the in-memory `CRDT_STATE` (loaded from `crdt_ops` at startup at 18:19) still had 503 and 478 as live items, and a periodic code path was reading `crdt_state::read_all_items()`, seeing them as live, and triggering the auto-assign / rate-limit-retry chain.
|
||||
|
||||
Final resolution: `docker restart huskies` to wipe the in-memory state. Worked, but it's a sledgehammer.
|
||||
|
||||
## Implementation note
|
||||
|
||||
The bft-json-crdt library appears to support per-item delete via the `is_deleted: bool` field on each CRDT item (visible in the persisted op JSON we inspected today). Writing a delete op should look something like:
|
||||
|
||||
```rust
|
||||
crdt_state::apply_and_persist(&mut state, |s| {
|
||||
s.crdt.doc.items[idx].delete() // or whatever the BFT JSON CRDT delete API is
|
||||
})
|
||||
```
|
||||
|
||||
The op gets signed, applied to the in-memory state (marking the item deleted), and persisted to crdt_ops via the existing channel. Then `read_all_items()` should filter out `is_deleted: true` entries (it may already do this — verify in `extract_item_view`).
|
||||
|
||||
## Why this is distinct from bug 514 (delete_story full cleanup)
|
||||
|
||||
Bug 514 is about making the existing `delete_story` MCP tool do a full cleanup across all the layers we know about. **This** story is specifically about acquiring the *capability* to write a CRDT tombstone — without that, bug 514 can't be implemented correctly because it has no way to clear in-memory state. So 521 is a prerequisite for 514.
|
||||
|
||||
It's also a prerequisite for properly handling the fix for bug 510 (split-brain shadows) — when the reconcile pass detects a stale story, it needs a way to actually evict it. That eviction is what this story provides.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] A new MCP tool (e.g. `mcp__huskies__purge_story`) is registered and callable
|
||||
- [ ] The tool takes a story_id and returns a structured result indicating which layers were cleared (CRDT op, content store, timers, agents, worktree, filesystem)
|
||||
- [ ] The tool writes a signed CRDT tombstone op (is_deleted: true) for the item, applies it to the in-memory CRDT, and persists it to crdt_ops
|
||||
- [ ] After the tool runs, `read_all_items()` does NOT return the purged story (verify the filter handles is_deleted)
|
||||
- [ ] After the tool runs, `read_content(story_id)` returns None (CONTENT_STORE entry is removed)
|
||||
- [ ] After the tool runs, `timers.json` has no entries for the story
|
||||
- [ ] After the tool runs, no agents are running on the story (stop_agent is called for any active ones)
|
||||
- [ ] After the tool runs, the worktree at `.huskies/worktrees/{story_id}/` is removed
|
||||
- [ ] After the tool runs, the filesystem shadow at `.huskies/work/*/{story_id}.md` is removed
|
||||
- [ ] Idempotent: calling purge_story twice on the same story_id is safe and doesn't error
|
||||
- [ ] Bug 514 (delete_story full cleanup) is updated to use this purge capability internally
|
||||
- [ ] Regression test: insert a story via the normal write path, call purge_story, restart the server, verify the story is still gone (i.e. the tombstone persisted correctly)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: "Exclude git worktrees from loc command output"
|
||||
---
|
||||
|
||||
# Story 468: Exclude git worktrees from loc command output
|
||||
|
||||
## User Story
|
||||
|
||||
As a user running the `loc` bot command, I want worktree directories (`.huskies/worktrees/`) to be excluded from the file listing so that the output only shows project source files, not duplicated code from agent worktrees.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] loc command excludes files under .huskies/worktrees/ from line counts
|
||||
- [ ] loc command excludes files under target/ directories from line counts
|
||||
- [ ] Unit test verifies worktree paths are filtered out
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: "Scaffold missing rate_limit_notifications and timezone in default project.toml"
|
||||
---
|
||||
|
||||
# Bug 469: Scaffold missing rate_limit_notifications and timezone in default project.toml
|
||||
|
||||
## Description
|
||||
|
||||
The scaffold template for `project.toml` does not include the `rate_limit_notifications` or `timezone` fields. New projects get defaults (notifications on, no timezone), but these settings aren't visible or documented in the generated config file. Users have to discover them manually.
|
||||
|
||||
The 455 rename also stripped these fields from the huskies project's own `project.toml` because it was regenerated from the scaffold.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Run `huskies init` on a new project
|
||||
2. Check the generated `.huskies/project.toml`
|
||||
|
||||
## Actual Result
|
||||
|
||||
No `rate_limit_notifications` or `timezone` fields in generated project.toml.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Both fields present with commented defaults so users know they exist.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: "Reduce timer tick interval to 1 second and suppress idle tick logging"
|
||||
---
|
||||
|
||||
# Story 470: Reduce timer tick interval to 1 second and suppress idle tick logging
|
||||
|
||||
## User Story
|
||||
|
||||
As a user scheduling timers, I want the tick loop to check every 1 second instead of 30 so timers fire promptly, without flooding the logs with an entry every second when nothing is due.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Timer tick interval changed from 30 seconds to 1 second
|
||||
- [ ] No log entry on idle ticks (when take_due returns empty)
|
||||
- [ ] Log entry only when a timer actually fires (due list non-empty)
|
||||
- [ ] Startup log line still shows number of pending timers loaded
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: "Bot command to show overall test coverage"
|
||||
---
|
||||
|
||||
# Story 471: Bot command to show overall test coverage
|
||||
|
||||
## User Story
|
||||
|
||||
As a user on any chat transport (Matrix, WhatsApp, Slack, or web UI), I want a `coverage` command that runs the test suite with coverage instrumentation and reports the overall coverage percentage, so I can track code quality from any interface.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] New bot command `coverage` registered in the command registry
|
||||
- [ ] Runs test coverage (e.g. cargo llvm-cov or cargo tarpaulin) and parses the overall percentage
|
||||
- [ ] Reports overall line coverage percentage in chat response
|
||||
- [ ] Command appears in `help` output
|
||||
- [ ] Returns a clear error if the coverage tool is not installed
|
||||
- [ ] Command works across all transports (Matrix, WhatsApp, Slack, web UI) via the shared command registry
|
||||
- [ ] Available as both a bot command in chat and a slash command in Claude Code
|
||||
- [ ] By default, reads cached coverage from .coverage_baseline for an instant response without rerunning tests
|
||||
- [ ] Optional `coverage run` variant reruns script/test_coverage and reports fresh results
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: "Discord chat transport"
|
||||
agent: coder-opus
|
||||
---
|
||||
|
||||
# Story 472: Discord chat transport
|
||||
|
||||
## User Story
|
||||
|
||||
As a user who uses Discord, I want to control huskies from a Discord server so I can create stories, check status, start agents, and chat with the bot from Discord like I can from Matrix.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Discord transport implements the ChatTransport trait (send_message, send_typing, etc.)
|
||||
- [ ] Bot connects via Discord gateway websocket using serenity crate
|
||||
- [ ] All shared bot commands (status, help, start, unblock, etc.) work from Discord
|
||||
- [ ] Stage transition and block notifications are posted to configured Discord channel(s)
|
||||
- [ ] LLM fallthrough works — non-command messages are forwarded to Claude Code
|
||||
- [ ] Config in bot.toml: discord_token, discord_channel_ids, allowed_users
|
||||
- [ ] Bot only responds when mentioned or in configured channels
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: "Split Chat.tsx into smaller components"
|
||||
---
|
||||
|
||||
# Refactor 473: Split Chat.tsx into smaller components
|
||||
|
||||
## Current State
|
||||
|
||||
- TBD
|
||||
|
||||
## Desired State
|
||||
|
||||
Chat.tsx is 1513 lines and growing. ChatInput and ChatHeader are already split out. Break up the remaining monolith into focused components — likely candidates: message list/rendering, websocket connection management, message bubbles, typing indicators, and any other distinct UI concerns.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Chat.tsx reduced to under 500 lines by extracting components
|
||||
- [ ] Message list/rendering extracted into its own component
|
||||
- [ ] Message bubble rendering extracted into its own component
|
||||
- [ ] WebSocket connection logic extracted (hook or provider)
|
||||
- [ ] All existing Chat.test.tsx tests still pass
|
||||
- [ ] No visual or behavioural regressions in the chat UI
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: "Per-file test coverage report with improvement targets"
|
||||
---
|
||||
|
||||
# Story 474: Per-file test coverage report with improvement targets
|
||||
|
||||
## User Story
|
||||
|
||||
As a developer, I want a standardised JSON output format for test coverage so that any language's coverage tool can produce it and huskies can read it without language-specific logic.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Define a standard `.coverage_report.json` format: `{ "overall": float, "threshold": float, "files": [{ "path": string, "coverage": float }] }`
|
||||
- [ ] Update huskies' own `script/test_coverage` to write `.coverage_report.json` in this format (Rust via `cargo llvm-cov --json`, frontend via vitest)
|
||||
- [ ] `coverage` bot command reads `.coverage_report.json` and shows overall percentage plus top 5 lowest-covered files as improvement targets
|
||||
- [ ] Document the `.coverage_report.json` format in `.huskies/README.md` so other projects can produce it from any language
|
||||
- [ ] Huskies server has zero language-specific coverage logic — all intelligence is in the project's `script/test_coverage`
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: "Deduplicate lifecycle.rs move functions into a shared parameterised helper"
|
||||
---
|
||||
|
||||
# Refactor 475: Deduplicate lifecycle.rs move functions into a shared parameterised helper
|
||||
|
||||
## Current State
|
||||
|
||||
- TBD
|
||||
|
||||
## Desired State
|
||||
|
||||
The move_story_to_current, move_story_to_done, move_story_to_merge, move_story_to_qa, and reject_story_from_qa functions share the same pattern: build paths, check idempotency, find source file in one or more stages, rename, clear front matter fields, log. Extract a shared `move_story()` helper parameterised by source stages, target stage, and fields to clear. The named functions become thin wrappers. The existing `move_story_to_stage` function should also use this shared helper. 27 existing tests provide a safety net.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Single shared move_story helper function parameterised by source stages, target stage, and fields to clear
|
||||
- [ ] All existing named move functions (move_story_to_current, move_story_to_done, move_story_to_merge, move_story_to_qa, reject_story_from_qa) become thin wrappers
|
||||
- [ ] move_story_to_stage also delegates to the shared helper
|
||||
- [ ] All 27 existing tests pass unchanged
|
||||
- [ ] Net reduction of at least 150 lines from lifecycle.rs
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: "Split agents/pool/lifecycle.rs into submodules"
|
||||
agent: coder-opus
|
||||
---
|
||||
|
||||
# Refactor 476: Split agents/pool/lifecycle.rs into submodules
|
||||
|
||||
## Current State
|
||||
|
||||
- TBD
|
||||
|
||||
## Desired State
|
||||
|
||||
pool/lifecycle.rs is 1812 lines with 4 public functions (start_agent, stop_agent, wait_for_agent, remove_agents_for_story) plus 29 tests. start_agent is by far the largest — it handles agent selection, worktree creation, process spawning, and completion callbacks. Split into submodules: start.rs (agent start + selection logic), stop.rs (stop + cleanup), wait.rs (wait_for_agent), with tests co-located in each module.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] pool/lifecycle.rs split into submodules (e.g. start.rs, stop.rs, wait.rs)
|
||||
- [ ] Each submodule contains its related tests
|
||||
- [ ] All 29 existing tests pass unchanged
|
||||
- [ ] Public API unchanged — re-export from pool/mod.rs if needed
|
||||
- [ ] No functional changes, purely structural
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: "Scaffold does not generate agents.toml for new projects"
|
||||
---
|
||||
|
||||
# Bug 481: Scaffold does not copy agent definitions from project.toml to new projects
|
||||
|
||||
## Description
|
||||
|
||||
When scaffolding a new project with `huskies init`, the `[[agent]]` definitions from huskies' own `agents.toml` are not included in the new project's config. The scaffold generates a minimal `project.toml` with basic settings (default_qa, max_coders, etc.) but no `agents.toml` file at all.
|
||||
|
||||
Without agent definitions (prompts, system prompts, model, max_turns, budget), the agents run with no guidance — they don't know about the SDTW process, worktrees, feature branches, or the rule about not committing to master. Result: agents make changes directly in master.
|
||||
|
||||
The scaffold should generate a default `agents.toml` with the battle-tested agent definitions (coder, QA, mergemaster) including their prompts and system prompts so new projects get sensible agent behaviour out of the box.
|
||||
|
||||
Note: agent definitions were split from `project.toml` into `agents.toml` in story #482.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Run `huskies init` on a new project
|
||||
2. Check the generated `.huskies/project.toml`
|
||||
3. Note: no `[[agent]]` blocks present
|
||||
4. Start a coder agent on a story
|
||||
5. Agent works directly in master with no worktree, no feature branch, no process
|
||||
|
||||
## Actual Result
|
||||
|
||||
No agent definitions in scaffolded project.toml. Agents run with defaults and make changes directly in master.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Scaffolded project.toml includes default agent definitions (coder, QA, mergemaster) with prompts that enforce the SDTW process, worktrees, and feature branches.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: "Split agent definitions from project.toml into agents.toml"
|
||||
---
|
||||
|
||||
# Refactor 482: Split agent definitions from project.toml into agents.toml
|
||||
|
||||
## Current State
|
||||
|
||||
- TBD
|
||||
|
||||
## Desired State
|
||||
|
||||
Move all `[[agent]]` blocks from `.huskies/project.toml` into a separate `.huskies/agents.toml`. The server loads agents from agents.toml and merges with project.toml config. Falls back to inline `[[agent]]` blocks in project.toml for backwards compatibility. The watcher should detect changes to agents.toml and hot-reload. This is a prerequisite for bug 481 (scaffold copies default agents to new projects) — agents.toml becomes the embeddable template.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] All [[agent]] blocks moved from .huskies/project.toml to .huskies/agents.toml
|
||||
- [ ] Server loads agent config from agents.toml, falls back to inline [[agent]] in project.toml for backwards compat
|
||||
- [ ] Watcher detects agents.toml changes and triggers hot-reload
|
||||
- [ ] project.toml is significantly smaller (only project settings remain)
|
||||
- [ ] agents.toml is the canonical default template for scaffolding (prerequisite for bug 481)
|
||||
- [ ] All existing agent functionality unchanged
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: "Timer slash command not wired up in web UI"
|
||||
---
|
||||
|
||||
# Bug 483: Timer slash command not wired up in web UI
|
||||
|
||||
## Description
|
||||
|
||||
Three async bot commands are not wired up in the web UI's `bot_command.rs` dispatch: **timer**, **htop**, and **rmtree**. They fall through to `dispatch_sync` which calls the registry stub that returns `None`, resulting in "Unknown command."
|
||||
|
||||
The fix: add async dispatch branches for all three in `dispatch_command`:
|
||||
- `"timer" => dispatch_timer(args, project_root).await`
|
||||
- `"rmtree" => dispatch_rmtree(args, project_root, agents).await`
|
||||
- `"htop"` — either implement a simplified version or return a "not available in web UI" message (htop is a live dashboard designed for Matrix)
|
||||
|
||||
Commands already correctly dispatched: assign, start, delete, rebuild.
|
||||
Reset is handled by the frontend (clears local state) — not needed server-side.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Open the web UI
|
||||
2. Type `/timer list` or `/timer 463 14:00`
|
||||
3. See "Unknown command: /timer"
|
||||
|
||||
## Actual Result
|
||||
|
||||
Unknown command: `/timer`. Type `/help` to see available commands.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Timer command works in the web UI the same as it does via Matrix.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: "Story dependencies in pipeline auto-assign"
|
||||
---
|
||||
|
||||
# Story 484: Story dependencies in pipeline auto-assign
|
||||
|
||||
## User Story
|
||||
|
||||
As a user creating stories that depend on each other, I want to specify dependencies in the story front matter so dependent stories stay in backlog until their dependencies are done, then automatically move to current.
|
||||
|
||||
Stories with `depends_on` stay in backlog. A dependency check loop (similar to the timer tick) periodically scans backlog for stories whose dependencies have all reached done/archived. When all deps are met, the story is moved to current and the normal auto-assign picks it up — ensuring the worktree is created from post-dependency master.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] New optional `depends_on` field in story front matter accepts a list of story numbers (e.g. `depends_on: [477, 478]`)
|
||||
- [ ] Stories with unmet dependencies stay in **backlog**, not current
|
||||
- [ ] A dependency check loop (similar to the timer tick loop) periodically scans backlog for stories whose `depends_on` stories have all reached done or archived
|
||||
- [ ] When all deps are met, the loop moves the story from backlog to current — the normal auto-assign then picks it up with a worktree based on post-dependency master
|
||||
- [ ] Status command shows dependency info for stories waiting on deps
|
||||
- [ ] Stories with no depends_on field behave as before (no change)
|
||||
- [ ] Bot command `depends <number> <dep1> [dep2...]` to set dependencies from chat (all transports) and web UI slash command
|
||||
- [ ] Command wired up in bot_command.rs dispatch for web UI and registered in shared command registry for all chat transports
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: "Documentation site for huskies.dev"
|
||||
---
|
||||
|
||||
# Story 485: Documentation site for huskies.dev
|
||||
|
||||
## User Story
|
||||
|
||||
As a new user discovering huskies, I want documentation on huskies.dev so I can learn how to set up and use the tool without having to read the source code.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Docs site lives under website/ directory alongside the existing landing page
|
||||
- [ ] Accessible from huskies.dev (e.g. huskies.dev/docs/ or docs.huskies.dev)
|
||||
- [ ] Navigation link added to the main site header
|
||||
- [ ] Getting started / quickstart guide (Docker setup, first story, first agent run)
|
||||
- [ ] Configuration reference: project.toml, agents.toml, bot.toml
|
||||
- [ ] Bot commands reference (auto-generated from command registry or manually maintained)
|
||||
- [ ] Pipeline stages explained (backlog, current, QA, merge, done)
|
||||
- [ ] Chat transports guide (Matrix, WhatsApp, Slack, web UI)
|
||||
- [ ] CLI reference (huskies, huskies init, huskies agent)
|
||||
- [ ] Matches the visual style of the existing landing page
|
||||
- [ ] Static HTML — no build step required
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: "Display story dependencies in web UI and chat commands"
|
||||
---
|
||||
|
||||
# Story 487: Display story dependencies in web UI and chat commands
|
||||
|
||||
## User Story
|
||||
|
||||
As a user managing stories with dependencies, I want to see dependency information in the web UI story panel and in chat/slash command output, so I can understand which stories are blocked and why without reading the raw markdown files.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] TODO
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: "SQLite shadow write for pipeline state via sqlx"
|
||||
agent: "coder-opus"
|
||||
---
|
||||
|
||||
# Story 489: SQLite shadow write for pipeline state via sqlx
|
||||
|
||||
## User Story
|
||||
|
||||
As a developer, I want pipeline state dual-written to SQLite (via sqlx) alongside the existing filesystem directories, so we have a database layer ready for CRDT integration without changing any existing behaviour.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Add sqlx with SQLite feature as a dependency
|
||||
- [ ] Migrations embedded at compile time via sqlx::migrate! macro, run on startup
|
||||
- [ ] Schema uses backend-agnostic SQL (TEXT, INTEGER, no vendor-specific types) so migrations work on both SQLite and Postgres
|
||||
- [ ] Every move_story_to_X and pipeline state change writes to both .huskies/work/ directories AND SQLite
|
||||
- [ ] Reads still come from the filesystem (SQLite is shadow-only)
|
||||
- [ ] SQLite database stored at .huskies/pipeline.db
|
||||
- [ ] pipeline_items table: id, name, stage, agent, retry_count, blocked, depends_on, created_at, updated_at
|
||||
- [ ] All existing pipeline operations work unchanged from the user's perspective
|
||||
- [ ] agent: coder-opus
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: "CRDT state layer backed by SQLite"
|
||||
agent: coder-opus
|
||||
depends_on: [489]
|
||||
---
|
||||
|
||||
# Story 490: CRDT state layer backed by SQLite
|
||||
|
||||
## User Story
|
||||
|
||||
As a developer, I want the BFT JSON CRDT document backed by SQLite for persistence, so CRDT ops survive restarts and the state layer is ready for multi-node sync.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] BFT CRDT crate (crates/bft-json-crdt/) integrated into the server
|
||||
- [ ] CRDT ops persisted to SQLite via sqlx (backend-agnostic schema)
|
||||
- [ ] Pipeline state reads switch from filesystem to CRDT document
|
||||
- [ ] Pipeline state writes go through CRDT ops (which persist to SQLite)
|
||||
- [ ] Filesystem .huskies/work/ directories still updated as a secondary output for backwards compat during transition
|
||||
- [ ] CRDT state reconstructed from SQLite on startup
|
||||
- [ ] agent: coder-opus
|
||||
- [ ] depends_on: [489]
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: "Watcher fires on CRDT state transitions instead of filesystem events"
|
||||
agent: coder-opus
|
||||
depends_on: [490]
|
||||
---
|
||||
|
||||
# Story 491: Watcher fires on CRDT state transitions instead of filesystem events
|
||||
|
||||
## User Story
|
||||
|
||||
As a developer, I want the auto-assign loop, notifications, and WebSocket UI updates to subscribe to CRDT state transitions instead of filesystem directory watches.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Auto-assign triggers on CRDT state change (story entering a stage) instead of filesystem watcher
|
||||
- [ ] Stage transition notifications fire from CRDT state changes
|
||||
- [ ] WebSocket UI updates fire from CRDT state changes
|
||||
- [ ] The filesystem watcher for .huskies/work/ is removed or deprecated
|
||||
- [ ] Timer tick loop and dependency tick loop read from CRDT state
|
||||
- [ ] agent: coder-opus
|
||||
- [ ] depends_on: [490]
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: "Remove filesystem pipeline state and store story content in database"
|
||||
agent: coder-opus
|
||||
depends_on: [491]
|
||||
---
|
||||
|
||||
# Story 492: Remove filesystem pipeline state and store story content in database
|
||||
|
||||
## User Story
|
||||
|
||||
As a developer, I want to remove the .huskies/work/ stage directories entirely and store story content (markdown, front matter) in the database, so the CRDT + SQLite is the sole source of truth.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Story markdown content and front matter stored in SQLite (backend-agnostic schema)
|
||||
- [ ] .huskies/work/ stage directories no longer used for pipeline state or story storage
|
||||
- [ ] All pipeline operations (create, move, read, delete stories) work against the database
|
||||
- [ ] Migration path for existing projects: on startup, import any .huskies/work/ stories into the DB and archive the directories
|
||||
- [ ] Worktrees and config files (project.toml, agents.toml, bot.toml) remain on filesystem
|
||||
- [ ] agent: coder-opus
|
||||
- [ ] depends_on: [491]
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: "Story dependency chain not firing due to front matter format issues"
|
||||
---
|
||||
|
||||
# Bug 493: Story dependency chain not firing due to front matter format issues
|
||||
|
||||
## Description
|
||||
|
||||
Two issues prevent the dependency tick loop from working:
|
||||
|
||||
1. **create_story puts depends_on in AC text, not front matter**: When `depends_on: [489]` is passed as an acceptance criterion string, it ends up as a checkbox item (`- [ ] depends_on: [489]`) instead of YAML front matter. Stories 490, 491, 492 are affected.
|
||||
|
||||
2. **update_story stores depends_on as a quoted string instead of YAML array**: The `front_matter` parameter serializes `[490]` as the string `"[490]"` instead of the YAML array `[490]`. Stories 478, 479, 480 are affected — their front matter shows `depends_on: "[490]"` instead of `depends_on: [490]`.
|
||||
|
||||
The dependency tick loop reads `depends_on` from YAML front matter and expects an integer array. Neither format matches.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Create a story with `depends_on: [489]` in acceptance criteria
|
||||
2. Or use update_story with `front_matter: {"depends_on": "[490]"}`
|
||||
3. Check the generated front matter
|
||||
4. Observe dependency tick loop does not promote the story
|
||||
|
||||
## Actual Result
|
||||
|
||||
depends_on either missing from front matter (in AC text as checkbox) or stored as quoted string instead of YAML array.
|
||||
|
||||
## Expected Result
|
||||
|
||||
depends_on stored as a proper YAML array in front matter: `depends_on: [489]`
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: "MCP tool to run project test suite"
|
||||
---
|
||||
|
||||
# Story 494: MCP tool to run project test suite
|
||||
|
||||
## User Story
|
||||
|
||||
As an LLM agent or web UI user, I want an MCP tool that runs the project's test suite so I can verify code changes without shelling out to Bash.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] New MCP tool `run_tests` that executes `script/test` and returns pass/fail with output
|
||||
- [ ] Available as a bot command (`test`) in all chat transports
|
||||
- [ ] Available as a slash command (`/test`) in the web UI
|
||||
- [ ] Returns structured result: pass/fail, test count, and truncated output for failures
|
||||
- [ ] Runs in the project root (or optionally in a specified worktree path)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: "Status traffic light dots use unsupported HTML colouring - switch to emoji"
|
||||
---
|
||||
|
||||
# Bug 495: Status traffic light dots use unsupported HTML colouring - switch to emoji
|
||||
|
||||
## Description
|
||||
|
||||
The status command uses Unicode dots (●, ◑, ✗, ○) with `<font data-mx-color>` HTML tags for colouring. Element X (and most modern Matrix clients) doesn't support inline text colouring via any HTML method — not `data-mx-color`, not `style="color:"`, nothing.
|
||||
|
||||
Switch to coloured emoji which render natively in all clients:
|
||||
- 🟢 running normally (was ● green)
|
||||
- 🟠 throttled/rate limited (was ◑ orange)
|
||||
- 🔴 blocked (was ✗ red)
|
||||
- ⚪ idle / no agent (was ○ grey)
|
||||
|
||||
Remove the `build_pipeline_status_html` colour-wrapping logic since it's dead code with emoji.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Run `@timmy status` in Element X
|
||||
2. Observe dots are not coloured
|
||||
|
||||
## Actual Result
|
||||
|
||||
Plain uncoloured Unicode dots.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Coloured indicators visible in all Matrix clients.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: "Hard rate limit without reset_at never auto-schedules retry"
|
||||
---
|
||||
|
||||
# Bug 496: Hard rate limit without reset_at never auto-schedules retry
|
||||
|
||||
## Description
|
||||
|
||||
When the API returns a hard rate limit block (`status=rejected`) without a `reset_at` timestamp, `pty.rs` downgrades it to a `RateLimitWarning` instead of a `RateLimitHardBlock`. The auto-scheduler only listens for `RateLimitHardBlock` events, so no timer is set and the agent is never restarted. The agent sits idle until the 300s inactivity timeout kills it, and the story is stuck.
|
||||
|
||||
In practice, most hard blocks come without `reset_at` (as seen in the logs: "no reset_at in rate_limit_info"). This means the auto-resume feature from story 423 almost never fires.
|
||||
|
||||
Fix: when there's a hard block without `reset_at`, either:
|
||||
1. Send `RateLimitHardBlock` with a default backoff time (e.g. `Utc::now() + 5 minutes`)
|
||||
2. Or add a separate retry mechanism that doesn't depend on knowing the exact reset time
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Run an agent that hits the API rate limit
|
||||
2. Observe logs show "no reset_at in rate_limit_info"
|
||||
3. Agent gets killed by inactivity timeout
|
||||
4. Story sits in current with no agent, never restarted
|
||||
|
||||
## Actual Result
|
||||
|
||||
Hard block without reset_at is downgraded to RateLimitWarning. No timer set. Agent dies and story is stuck.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Hard block without reset_at triggers a retry with a default backoff (e.g. 5 minutes). Agent is automatically restarted when the backoff expires.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: "Dependency promotion loop missing — stories with met deps never move from backlog to current"
|
||||
---
|
||||
|
||||
# Bug 497: Dependency promotion loop missing — stories with met deps never move from backlog to current
|
||||
|
||||
## Description
|
||||
|
||||
Story 484 implemented dependency checking in auto-assign (skip stories with unmet deps) but did NOT implement the backlog-to-current promotion loop. Stories with `depends_on` that have all deps met sit in backlog forever — nothing moves them to current.
|
||||
|
||||
The AC for 484 specified: "A dependency check loop (similar to the timer tick loop) periodically scans backlog for stories whose depends_on stories have all reached done or archived. When all deps are met, the loop moves the story from backlog to current."
|
||||
|
||||
This loop was never built. Need a periodic tick (like the timer tick) that scans backlog, checks `depends_on` against done/archived, and promotes ready stories to current.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Create story A with no deps, create story B with `depends_on: [A]`
|
||||
2. Both in backlog
|
||||
3. Move A to current, let it complete through to done
|
||||
4. Observe B stays in backlog forever
|
||||
|
||||
## Actual Result
|
||||
|
||||
Story B stays in backlog despite all deps being met.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Story B is automatically promoted from backlog to current once story A reaches done.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: "Web UI shows project name in browser tab with huskies favicon"
|
||||
---
|
||||
|
||||
# Story 499: Web UI shows project name in browser tab with huskies favicon
|
||||
|
||||
## User Story
|
||||
|
||||
As a user running huskies on multiple projects, I want the browser tab to show the project name instead of hardcoded "Huskies", and I want a huskies favicon, so I can distinguish tabs and have proper branding.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Browser tab title shows the project folder name when a project is open (e.g. 'reclaimer | Huskies')
|
||||
- [ ] Browser tab title shows 'Huskies' when no project is open
|
||||
- [ ] A huskies-themed SVG favicon is served and shown in the browser tab
|
||||
- [ ] The Vite default favicon is replaced by the huskies favicon
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
Generated
+1807
-114
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = ["server"]
|
||||
members = ["server", "crates/bft-json-crdt"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -29,7 +29,7 @@ tempfile = "3"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
|
||||
toml = "1.1.0"
|
||||
uuid = { version = "1.22.0", features = ["v4", "serde"] }
|
||||
tokio-tungstenite = "0.29.0"
|
||||
tokio-tungstenite = { version = "0.29.0", features = ["connect", "rustls-tls-native-roots"] }
|
||||
walkdir = "2.5.0"
|
||||
filetime = "0.2"
|
||||
matrix-sdk = { version = "0.16.0", default-features = false, features = [
|
||||
@@ -42,3 +42,9 @@ pulldown-cmark = { version = "0.13.3", default-features = false, features = [
|
||||
] }
|
||||
regex = "1"
|
||||
libc = "0.2"
|
||||
sqlx = { version = "=0.9.0-alpha.1", default-features = false, features = [
|
||||
"runtime-tokio",
|
||||
"sqlite",
|
||||
"macros",
|
||||
"migrate",
|
||||
] }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -14,16 +14,14 @@ huskies init --port 3000
|
||||
|
||||
This creates a `.huskies/` directory with the pipeline structure, `project.toml`, and `.mcp.json`. The `.mcp.json` file lets Claude Code discover huskies' MCP tools automatically.
|
||||
|
||||
3. Open a Claude Code session in the same project directory. Claude will pick up the MCP tools from `.mcp.json`.
|
||||
Huskies also ships an embedded React frontend. Once the server is running, open `http://localhost:3000` to see the pipeline board, agent status, and chat interface.
|
||||
|
||||
3. Open a Claude Code session in the same project directory, or visit http://localhost:3000/.
|
||||
|
||||
4. Tell Claude: "help me set up this project with huskies." Claude will walk you through the setup wizard — generating project context, tech stack docs, and test/release scripts. Review each step and confirm or ask to retry.
|
||||
|
||||
Once setup is complete, Claude can create stories, start agents, check status, and manage the full pipeline via MCP tools — no commands to memorize.
|
||||
|
||||
## Web UI
|
||||
|
||||
Huskies also ships an embedded React frontend. Once the server is running, open `http://localhost:3000` to see the pipeline board, agent status, and chat interface.
|
||||
|
||||
## Chat transports
|
||||
|
||||
Huskies can be controlled via bot commands in **Matrix**, **WhatsApp**, and **Slack**. Configure a transport in `.huskies/bot.toml` — see the example files:
|
||||
@@ -33,12 +31,14 @@ Huskies can be controlled via bot commands in **Matrix**, **WhatsApp**, and **Sl
|
||||
- `.huskies/bot.toml.whatsapp-twilio.example`
|
||||
- `.huskies/bot.toml.slack.example`
|
||||
|
||||
## Prerequisites
|
||||
## Prerequisites for building
|
||||
|
||||
- Rust (2024 edition)
|
||||
- Node.js and npm
|
||||
- Docker (for Linux cross-compilation and container deployment)
|
||||
- `cross` (`cargo install cross`) for Linux static builds
|
||||
- `cross` (`cargo install cross`) optional, for Linux static builds. Only needed if you are building for a different architecture, e.g. if you want to build a Linux binary from a Mac.
|
||||
|
||||
You only need these installed if you want to build Huskies yourself. Alternately, you can just download and run the `huskies` binary for your system from https://code.crashlabs.io/crashlabs/huskies/releases
|
||||
|
||||
## Building for production
|
||||
|
||||
@@ -57,7 +57,11 @@ cross build --release --target x86_64-unknown-linux-musl
|
||||
Docker:
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml build
|
||||
script/docker_rebuild
|
||||
|
||||
# or
|
||||
|
||||
script/docker_restart
|
||||
```
|
||||
|
||||
## Running in development
|
||||
@@ -85,6 +89,137 @@ script/release 0.7.1
|
||||
|
||||
This bumps version in `Cargo.toml` and `package.json`, builds macOS arm64 and Linux amd64 binaries, tags the repo, and publishes a Gitea release with changelog and binaries attached.
|
||||
|
||||
## License
|
||||
## Multi-node CRDT sync (rendezvous)
|
||||
|
||||
Huskies nodes can replicate pipeline state in real-time over WebSocket. Add a
|
||||
`rendezvous` field to `.huskies/project.toml` to configure a peer:
|
||||
|
||||
```toml
|
||||
rendezvous = "ws://other-host:3001/crdt-sync"
|
||||
```
|
||||
|
||||
On startup, this node opens an outbound WebSocket connection to the configured
|
||||
URL and exchanges CRDT ops bidirectionally. The connection is fully symmetric:
|
||||
both sides send a bulk state dump on connect, then stream individual ops as they
|
||||
are applied locally.
|
||||
|
||||
### Reconnect behaviour
|
||||
|
||||
If the peer is unreachable on startup (or the connection drops mid-session), the
|
||||
client retries with exponential backoff starting at 1 s and capping at 30 s.
|
||||
Failures are logged at **WARN**; after 10 consecutive failures the level escalates
|
||||
to **ERROR** to surface persistent connectivity problems.
|
||||
|
||||
### Deployment topologies
|
||||
|
||||
**Peer-to-peer (two nodes pointing at each other):**
|
||||
|
||||
```
|
||||
Node A ←→ Node B
|
||||
```
|
||||
|
||||
Configure each node with the other's `/crdt-sync` URL. Both nodes exchange ops
|
||||
directly. Supported by this story — ops propagate in both directions and both
|
||||
nodes converge to the same state. Works well for two machines collaborating on
|
||||
the same project.
|
||||
|
||||
**Hub-and-spoke (many clients → one central rendezvous):**
|
||||
|
||||
```
|
||||
Client 1 ──┐
|
||||
Client 2 ──┤── Hub node
|
||||
Client 3 ──┘
|
||||
```
|
||||
|
||||
Point multiple client nodes at a single "hub" node. The hub receives ops from
|
||||
all clients and re-broadcasts them. Clients do *not* connect to each other —
|
||||
convergence is mediated through the hub. The hub itself runs a normal huskies
|
||||
instance with `rendezvous` unset (it only accepts inbound connections).
|
||||
|
||||
> **Caveat:** Hub-to-client relay depends on the hub's `/crdt-sync` inbound
|
||||
> WebSocket handler re-broadcasting every received op to all other connected
|
||||
> peers. That broadcast happens automatically via the shared `SYNC_TX` channel
|
||||
> (each locally-applied remote op is re-emitted), so hub-and-spoke works today
|
||||
> but has not been load-tested. Follow-up work may be needed for large fan-out
|
||||
> (many spoke clients) to avoid broadcast-channel lag.
|
||||
|
||||
## Startup reconcile pass
|
||||
|
||||
On startup, after CRDT replay and database initialisation, huskies runs a
|
||||
**reconcile pass** that compares pipeline state across three sources:
|
||||
|
||||
1. **In-memory CRDT** — the primary source of truth, reconstructed from
|
||||
`crdt_ops` on startup.
|
||||
2. **`pipeline_items` table** — a shadow/materialised view written alongside
|
||||
CRDT updates, used for fast DB queries.
|
||||
3. **Filesystem shadows** (`.huskies/work/N_stage/*.md`) — legacy rendering
|
||||
still written by some paths and read by agent worktrees.
|
||||
|
||||
Any disagreement between these sources is **drift**. The reconcile pass logs a
|
||||
structured line for each drifted item:
|
||||
|
||||
```
|
||||
[reconcile] DRIFT story=X crdt_stage=Y db_stage=Z fs_stage=W
|
||||
```
|
||||
|
||||
(`MISSING` is used where a source has no record for that story.)
|
||||
|
||||
### Drift types
|
||||
|
||||
| Type | Meaning |
|
||||
|------|---------|
|
||||
| `CRDT-only` | Story present in CRDT but absent from `pipeline_items` |
|
||||
| `DB-only` | Story present in `pipeline_items` but absent from CRDT |
|
||||
| `FS-only` | Story on the filesystem but absent from both CRDT and DB |
|
||||
| `stage-mismatch` | Story present in both CRDT and DB but with different stage values |
|
||||
|
||||
Note: a filesystem shadow that lags behind the CRDT/DB stage (both of which
|
||||
agree) is **not** treated as drift — the FS is a best-effort rendering and is
|
||||
allowed to lag.
|
||||
|
||||
If any drift is detected, the Matrix/Slack/WhatsApp bot startup announcement
|
||||
includes a count and a suggestion to check the server logs.
|
||||
|
||||
### Opt-out
|
||||
|
||||
Set `reconcile_on_startup = false` in `.huskies/project.toml` to disable the
|
||||
pass during the migration window if it produces noise.
|
||||
## Debugging
|
||||
|
||||
### Inspecting the in-memory CRDT state
|
||||
|
||||
When diagnosing state issues, use the `dump_crdt` MCP tool or the `/debug/crdt` HTTP endpoint to inspect the raw in-memory CRDT state directly. These surfaces show the ground truth that the running server holds — not a summarised pipeline view and not the persisted SQLite ops.
|
||||
|
||||
**MCP tool** (from Claude Code or any MCP client):
|
||||
|
||||
```
|
||||
mcp__huskies__dump_crdt
|
||||
# dump everything
|
||||
{}
|
||||
|
||||
# restrict to a single item
|
||||
{"story_id": "42_story_my_feature"}
|
||||
```
|
||||
|
||||
**HTTP endpoint** (browser or curl):
|
||||
|
||||
```bash
|
||||
# dump everything
|
||||
curl http://localhost:3001/debug/crdt
|
||||
|
||||
# restrict to a single item
|
||||
curl "http://localhost:3001/debug/crdt?story_id=42_story_my_feature"
|
||||
```
|
||||
|
||||
Both return a JSON document with:
|
||||
|
||||
- **`metadata`** — `in_memory_state_loaded`, `total_items`, `total_ops_in_list`, `max_seq_in_list`, `persisted_ops_count`, `pending_persist_ops_count`
|
||||
- **`items`** — one entry per CRDT list item (including tombstoned/deleted entries), each with `story_id`, `stage`, `name`, `agent`, `retry_count`, `blocked`, `depends_on`, `content_index` (hex OpId for cross-referencing with `crdt_ops`), and `is_deleted`
|
||||
|
||||
> **This is a debug tool.** For normal pipeline introspection use `get_pipeline_status` or `GET /api/pipeline` instead.
|
||||
|
||||
## Source Map
|
||||
|
||||
See `.huskies/specs/tech/STACK.md` for the full source map.
|
||||
|
||||
GPL-3.0. See [LICENSE](LICENSE).
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
*.js linguist-generated
|
||||
*.json linguist-generated
|
||||
@@ -0,0 +1 @@
|
||||
target
|
||||
Generated
+1924
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "bft-json-crdt"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["lib"]
|
||||
|
||||
[features]
|
||||
default = ["bft", "logging-list", "logging-json"]
|
||||
logging-list = ["logging-base"]
|
||||
logging-json = ["logging-base"]
|
||||
logging-base = []
|
||||
bft = []
|
||||
|
||||
[dependencies]
|
||||
bft-crdt-derive = { path = "bft-crdt-derive" }
|
||||
colored = "2.0.0"
|
||||
fastcrypto = "0.1.8"
|
||||
indexmap = { version = "2.2.6", features = ["serde"] }
|
||||
rand = "0.8.5"
|
||||
random_color = "0.6.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = { version = "1.0.85", features = ["preserve_order"] }
|
||||
serde_with = "3.8.1"
|
||||
sha2 = "0.10.6"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.4", features = ["html_reports"] }
|
||||
time = "0.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = { version = "1.0.85", features = ["preserve_order"] }
|
||||
|
||||
[[bench]]
|
||||
name = "speed"
|
||||
harness = false
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Jacky Zhao
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Byzantine Fault Tolerant CRDTs
|
||||
|
||||
This work is mainly inspired by implementing Martin Kleppmann's 2022 paper on *Making CRDTs Byzantine Fault Tolerant*[^2]
|
||||
on top of a simplified [Automerge](https://automerge.org/) implementation.
|
||||
|
||||
The goal is to show a working prototype that demonstrated in simple code the ideas behind
|
||||
1. An Automerge-like CRDT
|
||||
2. How a primitive list CRDT can be composed to create complex CRDTs like JSON
|
||||
2. How to add Byzantine Fault Tolerance to arbitrary CRDTs
|
||||
|
||||
Unlike most other CRDT implementations, I leave out many performance optimizations that would make the basic algorithm harder to understand.
|
||||
|
||||
Check out the [accompanying blog post for this project!](https://jzhao.xyz/posts/bft-json-crdt)
|
||||
|
||||
## Benchmarks
|
||||
Although this implementation does not optimize for performance, it still nonetheless performs quite well.
|
||||
|
||||
Benchmarking happened on a 2019 MacBook Pro with a 2.6GHz i7.
|
||||
Numbers are compared to Automerge which report their performance benchmarks [here](https://github.com/automerge/automerge-perf)
|
||||
|
||||
| # Ops | Raw String (JS) | Ours (basic) | Ours (BFT) | Automerge (JS) | Automerge (Rust) |
|
||||
|--|--|--|--|--|--|
|
||||
|10k | n/a | 0.081s | 1.793s | 1.6s | 0.047s |
|
||||
|100k | n/a | 9.321s | 38.842s | 43.0s | 0.597s |
|
||||
|All (259k)| 0.61s | 88.610s | 334.960s | Out of Memory| 1.780s |
|
||||
|Memory | 0.1MB | 27.6MB | 59.5MB | 880MB | 232.5MB |
|
||||
|
||||
## Flamegraph
|
||||
To get some flamegraphs of the time graph on MacOS, run:
|
||||
|
||||
```bash
|
||||
sudo cargo flamegraph --dev --root --bench speed
|
||||
```
|
||||
|
||||
## Further Work
|
||||
This is mostly a learning/instructional project but there are a few places where performance improvements are obvious:
|
||||
|
||||
1. This is backed by `std::Vec` which isn't great for random insert. Replace with a B-tree or something that provides better insert and find performance
|
||||
1. [Diamond Types](https://github.com/josephg/diamond-types) and [Automerge (Rust)](https://github.com/automerge/automerge-rs) use a B-tree
|
||||
2. Yjs is backed by a doubly linked-list and caches last ~5-10 accessed locations (assumes that most edits happen sequentially; seeks are rare)
|
||||
3. (funnily enough, main performance hit is dominated by find and not insert, see [this flamegraph](./flamegraphs/flamegraph_unoptimized.svg))
|
||||
2. Avoid calling `find` so many times. A few Automerge optimizations that were not implemented
|
||||
1. Use an index hint (especially for local inserts)
|
||||
2. Skipping the second `find` operation in `integrate` if sequence number is already larger
|
||||
3. Improve storage requirement. As of now, a single `Op` weighs in at *over* 168 bytes. This doesn't even fit in a single cache line!
|
||||
4. Implement 'transactions' for a group of changes that should be considered atomic.
|
||||
1. This would also speed up Ed25519 signature verification time by batching.
|
||||
2. For example, a peer might create an atomic 'transaction' that contains a bunch of changes.
|
||||
5. Currently, each character is a single op. Similar to Yjs, we can combine runs of characters into larger entities like what André, Luc, et al.[^1] suggest
|
||||
6. Implement proper persistence using SQLLite or something similar
|
||||
7. Compile the project to WASM and implement a transport layer so it can be used in browser. Something similar to [Yjs' WebRTC Connector](https://github.com/yjs/y-webrtc) could work.
|
||||
|
||||
[^1]: André, Luc, et al. "Supporting adaptable granularity of changes for massive-scale collaborative editing." 9th IEEE International Conference on Collaborative Computing: Networking, Applications and Worksharing. IEEE, 2013.
|
||||
[^2]: Kleppmann, Martin. "Making CRDTs Byzantine Fault Tolerant." Proceedings of the 9th Workshop on Principles and Practice of Consistency for Distributed Data. 2022.
|
||||
|
||||
## Acknowledgements
|
||||
Thank you to [Nalin Bhardwaj](https://nibnalin.me/) for helping me with my cryptography questions and [Martin Kleppmann](https://martin.kleppmann.com/)
|
||||
for his teaching materials and lectures which taught me a significant portion of what I've learned about distributed systems and CRDTs.
|
||||
@@ -0,0 +1,67 @@
|
||||
use bft_json_crdt::{
|
||||
json_crdt::JsonValue, keypair::make_author, list_crdt::ListCrdt, op::Op, op::ROOT_ID,
|
||||
};
|
||||
use criterion::{criterion_group, criterion_main, Criterion};
|
||||
use rand::seq::SliceRandom;
|
||||
|
||||
fn bench_insert_100_root(c: &mut Criterion) {
|
||||
c.bench_function("bench insert 100 root", |b| {
|
||||
b.iter(|| {
|
||||
let mut list = ListCrdt::<i64>::new(make_author(1), vec![]);
|
||||
for i in 0..100 {
|
||||
list.insert(ROOT_ID, i);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_insert_100_linear(c: &mut Criterion) {
|
||||
c.bench_function("bench insert 100 linear", |b| {
|
||||
b.iter(|| {
|
||||
let mut list = ListCrdt::<i64>::new(make_author(1), vec![]);
|
||||
let mut prev = ROOT_ID;
|
||||
for i in 0..100 {
|
||||
let op = list.insert(prev, i);
|
||||
prev = op.id;
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_insert_many_agents_conflicts(c: &mut Criterion) {
|
||||
c.bench_function("bench insert many agents conflicts", |b| {
|
||||
b.iter(|| {
|
||||
const N: u8 = 10;
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut crdts: Vec<ListCrdt<i64>> = Vec::with_capacity(N as usize);
|
||||
let mut logs: Vec<Op<JsonValue>> = Vec::new();
|
||||
for i in 0..N {
|
||||
let list = ListCrdt::new(make_author(i), vec![]);
|
||||
crdts.push(list);
|
||||
for _ in 0..5 {
|
||||
let op = crdts[i as usize].insert(ROOT_ID, i as i32);
|
||||
logs.push(op);
|
||||
}
|
||||
}
|
||||
|
||||
logs.shuffle(&mut rng);
|
||||
for op in logs {
|
||||
for c in &mut crdts {
|
||||
if op.author() != c.our_id {
|
||||
c.apply(op.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(crdts.windows(2).all(|w| w[0].view() == w[1].view()));
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_insert_100_root,
|
||||
bench_insert_100_linear,
|
||||
bench_insert_many_agents_conflicts
|
||||
);
|
||||
criterion_main!(benches);
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "bft-crdt-derive"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86f0b0d4bf799edbc74508c1e8bf170ff5f41238e5f8225603ca7caaae2b7860"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eda0fc3b0fb7c975631757e14d9049da17374063edb6ebbcbc54d880d4fe94e9"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"thiserror",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ea3d908b0e36316caf9e9e2c4625cdde190a7e6f440d794667ed17a1855e725"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.147"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d193d69bae983fc11a79df82342761dfbf28a99fc8d203dca4c3c1b590948965"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a864042229133ada95abf3b54fdc62ef5ccabe9515b64717bcb9a1919e59445d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10deb33631e3c9018b9baf9dcbbc4f737320d2b576bac10f6aefa048fa407e3e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "982d17546b47146b28f7c22e3d08465f6b8903d0ea13c1660d9d84a6e7adcdbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ceab39d59e4c9499d4e5a8ee0e2735b891bb7308ac83dfb4e80cad195c9f6f3"
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "bft-crdt-derive"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
indexmap = { version = "2.2.6", features = ["serde"] }
|
||||
proc-macro2 = "1.0.47"
|
||||
proc-macro-crate = "1.2.1"
|
||||
quote = "1.0.21"
|
||||
syn = { version = "1.0.103", features = ["full"] }
|
||||
@@ -0,0 +1,198 @@
|
||||
use proc_macro::TokenStream as OgTokenStream;
|
||||
use proc_macro2::{Ident, Span, TokenStream};
|
||||
use proc_macro_crate::{crate_name, FoundCrate};
|
||||
use quote::{quote, quote_spanned, ToTokens};
|
||||
use syn::{
|
||||
parse::{self, Parser},
|
||||
parse_macro_input,
|
||||
spanned::Spanned,
|
||||
Data, DeriveInput, Field, Fields, ItemStruct, LitStr, Type,
|
||||
};
|
||||
|
||||
/// Helper to get tokenstream representing the parent crate
|
||||
fn get_crate_name() -> TokenStream {
|
||||
let cr8 = crate_name("bft-json-bft-crdt").unwrap_or(FoundCrate::Itself);
|
||||
match cr8 {
|
||||
FoundCrate::Itself => quote! { ::bft_json_crdt },
|
||||
FoundCrate::Name(name) => {
|
||||
let ident = Ident::new(&name, Span::call_site());
|
||||
quote! { ::#ident }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Proc macro to insert a keypair and path field on a given struct
|
||||
#[proc_macro_attribute]
|
||||
pub fn add_crdt_fields(args: OgTokenStream, input: OgTokenStream) -> OgTokenStream {
|
||||
let mut input = parse_macro_input!(input as ItemStruct);
|
||||
let crate_name = get_crate_name();
|
||||
let _ = parse_macro_input!(args as parse::Nothing);
|
||||
|
||||
if let syn::Fields::Named(ref mut fields) = input.fields {
|
||||
fields.named.push(
|
||||
Field::parse_named
|
||||
.parse2(quote! { path: Vec<#crate_name::op::PathSegment> })
|
||||
.unwrap(),
|
||||
);
|
||||
fields.named.push(
|
||||
Field::parse_named
|
||||
.parse2(quote! { id: #crate_name::keypair::AuthorId })
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
quote! {
|
||||
#input
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Proc macro to automatically derive the CRDTNode trait
|
||||
#[proc_macro_derive(CrdtNode)]
|
||||
pub fn derive_json_crdt(input: OgTokenStream) -> OgTokenStream {
|
||||
// parse the input tokens into a syntax tree
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
let crate_name = get_crate_name();
|
||||
|
||||
// used in the quasi-quotation below as `#name`
|
||||
let ident = input.ident;
|
||||
let ident_str = LitStr::new(&ident.to_string(), ident.span());
|
||||
|
||||
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
|
||||
match input.data {
|
||||
Data::Struct(data) => match &data.fields {
|
||||
Fields::Named(fields) => {
|
||||
let mut field_impls = vec![];
|
||||
let mut ident_literals = vec![];
|
||||
let mut ident_strings = vec![];
|
||||
let mut tys = vec![];
|
||||
// parse all named fields
|
||||
for field in &fields.named {
|
||||
let ident = field.ident.as_ref().expect("Failed to get struct field identifier");
|
||||
if ident != "path" && ident != "id" {
|
||||
let ty = match &field.ty {
|
||||
Type::Path(t) => t.to_token_stream(),
|
||||
_ => return quote_spanned! { field.span() => compile_error!("Field should be a primitive or struct which implements CRDTNode") }.into(),
|
||||
};
|
||||
let str_literal = LitStr::new(&ident.to_string(), ident.span());
|
||||
ident_strings.push(str_literal.clone());
|
||||
ident_literals.push(ident.clone());
|
||||
tys.push(ty.clone());
|
||||
field_impls.push(quote! {
|
||||
#ident: <#ty as CrdtNode>::new(
|
||||
id,
|
||||
#crate_name::op::join_path(path.clone(), #crate_name::op::PathSegment::Field(#str_literal.to_string()))
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let expanded = quote! {
|
||||
impl #impl_generics #crate_name::json_crdt::CrdtNodeFromValue for #ident #ty_generics #where_clause {
|
||||
fn node_from(value: #crate_name::json_crdt::JsonValue, id: #crate_name::keypair::AuthorId, path: Vec<#crate_name::op::PathSegment>) -> Result<Self, String> {
|
||||
if let #crate_name::json_crdt::JsonValue::Object(mut obj) = value {
|
||||
Ok(#ident {
|
||||
path: path.clone(),
|
||||
id,
|
||||
#(#ident_literals: if let Some(val) = obj.remove(#ident_strings) {
|
||||
val.into_node(
|
||||
id,
|
||||
#crate_name::op::join_path(path.clone(), #crate_name::op::PathSegment::Field(#ident_strings.to_string()))
|
||||
)
|
||||
.unwrap()
|
||||
} else {
|
||||
<#tys as #crate_name::json_crdt::CrdtNode>::new(
|
||||
id,
|
||||
#crate_name::op::join_path(path.clone(), #crate_name::op::PathSegment::Field(#ident_strings.to_string()))
|
||||
)
|
||||
}),*
|
||||
})
|
||||
} else {
|
||||
Err(format!("failed to convert {:?} -> {}<T>", value, #ident_str.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// I'm pulling this out so that we can see actual CRD content in debug output.
|
||||
//
|
||||
// The plan is to mostly get rid of the macros anyway, so it's a reasonable first step.
|
||||
// It could (alternately) be just as good to keep the macros and change this function to
|
||||
// output actual field content instead of just field names.
|
||||
//
|
||||
// impl #impl_generics std::fmt::Debug for #ident #ty_generics #where_clause {
|
||||
// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// let mut fields = Vec::new();
|
||||
// #(fields.push(format!("{}", #ident_strings.to_string()));)*
|
||||
// write!(f, "{{ {:?} }}", fields.join(", "))
|
||||
// }
|
||||
// }
|
||||
|
||||
impl #impl_generics #crate_name::json_crdt::CrdtNode for #ident #ty_generics #where_clause {
|
||||
fn apply(&mut self, op: #crate_name::op::Op<#crate_name::json_crdt::JsonValue>) -> #crate_name::json_crdt::OpState {
|
||||
let path = op.path.clone();
|
||||
let author = op.id.clone();
|
||||
if !#crate_name::op::ensure_subpath(&self.path, &op.path) {
|
||||
#crate_name::debug::debug_path_mismatch(self.path.to_owned(), op.path);
|
||||
return #crate_name::json_crdt::OpState::ErrPathMismatch;
|
||||
}
|
||||
|
||||
if self.path.len() == op.path.len() {
|
||||
return #crate_name::json_crdt::OpState::ErrApplyOnStruct;
|
||||
} else {
|
||||
let idx = self.path.len();
|
||||
if let #crate_name::op::PathSegment::Field(path_seg) = &op.path[idx] {
|
||||
match &path_seg[..] {
|
||||
#(#ident_strings => {
|
||||
return self.#ident_literals.apply(op.into());
|
||||
}),*
|
||||
_ => {},
|
||||
};
|
||||
};
|
||||
return #crate_name::json_crdt::OpState::ErrPathMismatch
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self) -> #crate_name::json_crdt::JsonValue {
|
||||
let mut view_map = indexmap::IndexMap::new();
|
||||
#(view_map.insert(#ident_strings.to_string(), self.#ident_literals.view().into());)*
|
||||
#crate_name::json_crdt::JsonValue::Object(view_map)
|
||||
}
|
||||
|
||||
fn new(id: #crate_name::keypair::AuthorId, path: Vec<#crate_name::op::PathSegment>) -> Self {
|
||||
Self {
|
||||
path: path.clone(),
|
||||
id,
|
||||
#(#field_impls),*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl #crate_name::debug::DebugView for #ident {
|
||||
#[cfg(feature = "logging-base")]
|
||||
fn debug_view(&self, indent: usize) -> String {
|
||||
let inner_spacing = " ".repeat(indent + 2);
|
||||
let path_str = #crate_name::op::print_path(self.path.clone());
|
||||
let mut inner = vec![];
|
||||
#(inner.push(format!("{}\"{}\": {}", inner_spacing, #ident_strings, self.#ident_literals.debug_view(indent + 4)));)*
|
||||
let inner_str = inner.join("\n");
|
||||
format!("{} @ /{}\n{}", #ident_str, path_str, inner_str)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "logging-base"))]
|
||||
fn debug_view(&self, _indent: usize) -> String {
|
||||
"".to_string()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Hand the output tokens back to the compiler
|
||||
expanded.into()
|
||||
}
|
||||
_ => {
|
||||
quote_spanned! { ident.span() => compile_error!("Cannot derive CRDT on tuple or unit structs"); }
|
||||
.into()
|
||||
}
|
||||
},
|
||||
_ => quote_spanned! { ident.span() => compile_error!("Cannot derive CRDT on enums or unions"); }.into(),
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 502 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 300 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 80 KiB |
@@ -0,0 +1,317 @@
|
||||
use crate::{
|
||||
json_crdt::{BaseCrdt, CrdtNode, SignedOp},
|
||||
keypair::SignedDigest,
|
||||
list_crdt::ListCrdt,
|
||||
op::{Op, OpId, PathSegment},
|
||||
};
|
||||
|
||||
#[cfg(feature = "logging-base")]
|
||||
use {
|
||||
crate::{
|
||||
keypair::{lsb_32, AuthorId},
|
||||
op::{print_hex, print_path, ROOT_ID},
|
||||
},
|
||||
colored::Colorize,
|
||||
random_color::{Luminosity, RandomColor},
|
||||
};
|
||||
|
||||
#[cfg(feature = "logging-list")]
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Display;
|
||||
|
||||
#[cfg(feature = "logging-base")]
|
||||
fn author_to_hex(author: AuthorId) -> String {
|
||||
format!("{:#010x}", lsb_32(author))
|
||||
}
|
||||
|
||||
#[cfg(feature = "logging-base")]
|
||||
fn display_op_id<T: CrdtNode>(op: &Op<T>) -> String {
|
||||
let [r, g, b] = RandomColor::new()
|
||||
.luminosity(Luminosity::Light)
|
||||
.seed(lsb_32(op.author))
|
||||
.to_rgb_array();
|
||||
format!(
|
||||
"[{},{}]",
|
||||
author_to_hex(op.author).bold().truecolor(r, g, b),
|
||||
op.seq.to_string().yellow()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn debug_type_mismatch(_msg: String) {
|
||||
#[cfg(feature = "logging-base")]
|
||||
{
|
||||
println!(" {}\n {_msg}", "type mismatch! ignoring this node".red(),);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn debug_path_mismatch(_our_path: Vec<PathSegment>, _op_path: Vec<PathSegment>) {
|
||||
#[cfg(feature = "logging-base")]
|
||||
{
|
||||
println!(
|
||||
" {}\n current path: {}\n op path: {}",
|
||||
"path mismatch!".red(),
|
||||
print_path(_our_path),
|
||||
print_path(_op_path),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn debug_op_on_primitive(_op_path: Vec<PathSegment>) {
|
||||
#[cfg(feature = "logging-base")]
|
||||
{
|
||||
println!(
|
||||
" {} this is an error, ignoring op.\n op path: {}",
|
||||
"trying to apply() on a primitive!".red(),
|
||||
print_path(_op_path),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "logging-base")]
|
||||
fn display_author(author: AuthorId) -> String {
|
||||
let [r, g, b] = RandomColor::new()
|
||||
.luminosity(Luminosity::Light)
|
||||
.seed(lsb_32(author))
|
||||
.to_rgb_array();
|
||||
format!(" {} ", author_to_hex(author))
|
||||
.black()
|
||||
.on_truecolor(r, g, b)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub trait DebugView {
|
||||
fn debug_view(&self, indent: usize) -> String;
|
||||
}
|
||||
|
||||
impl<T: CrdtNode + DebugView> BaseCrdt<T> {
|
||||
pub fn debug_view(&self) {
|
||||
#[cfg(feature = "logging-json")]
|
||||
println!("document is now:\n{}", self.doc.debug_view(0));
|
||||
}
|
||||
|
||||
pub fn log_try_apply(&self, _op: &SignedOp) {
|
||||
#[cfg(feature = "logging-json")]
|
||||
println!(
|
||||
"{} trying to apply operation {} from {}",
|
||||
display_author(self.id),
|
||||
&print_hex(&_op.signed_digest)[..6],
|
||||
display_author(_op.inner.author())
|
||||
);
|
||||
}
|
||||
|
||||
pub fn debug_digest_failure(&self, _op: SignedOp) {
|
||||
#[cfg(feature = "logging-json")]
|
||||
println!(
|
||||
" {} cannot confirm signed_digest from {}",
|
||||
"digest failure!".red(),
|
||||
display_author(_op.author())
|
||||
);
|
||||
}
|
||||
|
||||
pub fn log_missing_causal_dep(&self, _missing: &SignedDigest) {
|
||||
#[cfg(feature = "logging-json")]
|
||||
println!(
|
||||
" {} haven't received op with digest {}",
|
||||
"missing causal dependency".red(),
|
||||
print_hex(_missing)
|
||||
);
|
||||
}
|
||||
|
||||
pub fn log_actually_apply(&self, _op: &SignedOp) {
|
||||
#[cfg(feature = "logging-json")]
|
||||
{
|
||||
println!(
|
||||
" applying op to path: /{}",
|
||||
print_path(_op.inner.path.clone())
|
||||
);
|
||||
println!("{}", _op.inner.debug_view(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Op<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
pub fn debug_hash_failure(&self) {
|
||||
#[cfg(feature = "logging-base")]
|
||||
{
|
||||
println!(" {}", "hash failure!".red());
|
||||
println!(" expected: {}", print_hex(&self.id));
|
||||
println!(" computed: {}", print_hex(&self.hash_to_id()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> DebugView for T
|
||||
where
|
||||
T: Display,
|
||||
{
|
||||
#[cfg(feature = "logging-base")]
|
||||
fn debug_view(&self, _indent: usize) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "logging-base"))]
|
||||
fn debug_view(&self, _indent: usize) -> String {
|
||||
"".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> DebugView for Op<T>
|
||||
where
|
||||
T: DebugView + CrdtNode,
|
||||
{
|
||||
#[cfg(not(feature = "logging-base"))]
|
||||
fn debug_view(&self, _indent: usize) -> String {
|
||||
"".to_string()
|
||||
}
|
||||
|
||||
#[cfg(feature = "logging-json")]
|
||||
fn debug_view(&self, indent: usize) -> String {
|
||||
let op_id = display_op_id(self);
|
||||
let content = if self.id == ROOT_ID && self.content.is_none() {
|
||||
"root".blue().bold().to_string()
|
||||
} else {
|
||||
self.content
|
||||
.as_ref()
|
||||
.map_or("[empty]".to_string(), |c| c.debug_view(indent + 2))
|
||||
};
|
||||
let content_str = if self.is_deleted && self.id != ROOT_ID {
|
||||
content.red().strikethrough().to_string()
|
||||
} else {
|
||||
content
|
||||
};
|
||||
|
||||
format!("{op_id} {content_str}")
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
pub fn log_ops(&self, _highlight: Option<OpId>) {
|
||||
#[cfg(feature = "logging-list")]
|
||||
{
|
||||
let mut lines = Vec::<String>::new();
|
||||
|
||||
// do in-order traversal
|
||||
let res: Vec<&Op<T>> = self.ops.iter().collect();
|
||||
if res.is_empty() {
|
||||
println!("[empty]");
|
||||
}
|
||||
|
||||
// figure out parent-child hierarchies from origins
|
||||
let mut parent_child_map: HashMap<OpId, Vec<OpId>> = HashMap::new();
|
||||
for op in &res {
|
||||
let children = parent_child_map.entry(op.origin).or_default();
|
||||
children.push(op.id);
|
||||
}
|
||||
|
||||
let is_last = |op: &Op<T>| -> bool {
|
||||
if op.id == ROOT_ID {
|
||||
return true;
|
||||
}
|
||||
if let Some(children) = parent_child_map.get(&op.origin) {
|
||||
return *children.last().unwrap() == op.id;
|
||||
}
|
||||
false
|
||||
};
|
||||
|
||||
// make stack of origins
|
||||
let mut stack: Vec<(OpId, &str)> = Vec::new();
|
||||
stack.push((ROOT_ID, ""));
|
||||
let mut prev = None;
|
||||
for op in &res {
|
||||
let origin_idx = self.find_idx(op.origin).unwrap();
|
||||
let origin = &res[origin_idx];
|
||||
let origin_id = origin.id;
|
||||
if let Some(prev) = prev {
|
||||
if origin_id == prev {
|
||||
// went down one layer, add to stack
|
||||
let stack_prefix_char = if is_last(origin) { " " } else { "│ " };
|
||||
stack.push((prev, stack_prefix_char));
|
||||
}
|
||||
}
|
||||
|
||||
// pop back up until we reach the right origin
|
||||
while stack.last().unwrap().0 != origin_id {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
let cur_char = if is_last(op) { "╰─" } else { "├─" };
|
||||
let prefixes = stack.iter().map(|s| s.1).collect::<Vec<_>>().join("");
|
||||
let highlight_text = if _highlight.is_some() && _highlight.unwrap() == op.id {
|
||||
if op.is_deleted {
|
||||
"<- deleted".bold().red()
|
||||
} else {
|
||||
"<- inserted".bold().green()
|
||||
}
|
||||
.to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
let content = if op.id == ROOT_ID {
|
||||
"root".blue().bold().to_string()
|
||||
} else {
|
||||
op.content
|
||||
.as_ref()
|
||||
.map_or("[empty]".to_string(), |c| c.hash())
|
||||
};
|
||||
if op.is_deleted && op.id != ROOT_ID {
|
||||
lines.push(format!(
|
||||
"{}{}{} {} {}",
|
||||
prefixes,
|
||||
cur_char,
|
||||
display_op_id(op),
|
||||
content.strikethrough().red(),
|
||||
highlight_text
|
||||
));
|
||||
} else {
|
||||
lines.push(format!(
|
||||
"{}{}{} {} {}",
|
||||
prefixes,
|
||||
cur_char,
|
||||
display_op_id(op),
|
||||
content,
|
||||
highlight_text
|
||||
));
|
||||
}
|
||||
prev = Some(op.id);
|
||||
}
|
||||
|
||||
// full string
|
||||
let flat = self.iter().map(|t| t.hash()).collect::<Vec<_>>().join("");
|
||||
lines.push(format!("Flattened result: {}", flat));
|
||||
println!("{}", lines.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn log_apply(&self, _op: &Op<T>) {
|
||||
#[cfg(feature = "logging-list")]
|
||||
{
|
||||
if _op.is_deleted {
|
||||
println!(
|
||||
"{} Performing a delete of {}@{}",
|
||||
display_author(self.our_id),
|
||||
display_op_id(_op),
|
||||
_op.sequence_num(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(content) = _op.content.as_ref() {
|
||||
println!(
|
||||
"{} Performing an insert of {}@{}: '{}' after {}",
|
||||
display_author(self.our_id),
|
||||
display_op_id(_op),
|
||||
_op.sequence_num(),
|
||||
content.hash(),
|
||||
display_op_id(_op)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,947 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fmt::Display,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
debug::{debug_op_on_primitive, DebugView},
|
||||
keypair::{sha256, sign, AuthorId, SignedDigest},
|
||||
list_crdt::ListCrdt,
|
||||
lww_crdt::LwwRegisterCrdt,
|
||||
op::{print_hex, print_path, Hashable, Op, OpId, PathSegment},
|
||||
};
|
||||
pub use bft_crdt_derive::*;
|
||||
use fastcrypto::traits::VerifyingKey;
|
||||
use fastcrypto::{
|
||||
ed25519::{Ed25519KeyPair, Ed25519PublicKey, Ed25519Signature},
|
||||
traits::{KeyPair, ToFromBytes},
|
||||
// Verifier,
|
||||
};
|
||||
// TODO: serde's json object serialization and deserialization (correctly) do not define anything
|
||||
// object field order in JSON objects. However, the hash check impl in bft-json-bft-crdt does take order
|
||||
// into account. This is going to cause problems later for non-Rust implementations, BFT hash checking
|
||||
// currently depends on JSON serialization/deserialization object order. This shouldn't be the case
|
||||
// but I've hacked in an IndexMap for the moment to get the PoC working. To see the problem, replace this with
|
||||
// a std HashMap, everything will screw up (annoyingly, only *most* of the time).
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{serde_as, Bytes};
|
||||
|
||||
/// Anything that can be nested in a JSON CRDT
|
||||
pub trait CrdtNode: CrdtNodeFromValue + Hashable + Clone {
|
||||
/// Create a new CRDT of this type
|
||||
fn new(id: AuthorId, path: Vec<PathSegment>) -> Self;
|
||||
/// Apply an operation to this CRDT, forwarding if necessary
|
||||
fn apply(&mut self, op: Op<JsonValue>) -> OpState;
|
||||
/// Get a JSON representation of the value in this node
|
||||
fn view(&self) -> JsonValue;
|
||||
}
|
||||
|
||||
/// Enum representing possible outcomes of applying an operation to a CRDT
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum OpState {
|
||||
/// Operation applied successfully
|
||||
Ok,
|
||||
/// Tried to apply an operation to a non-CRDT primitive (i.e. f64, bool, etc.)
|
||||
/// If you would like a mutable primitive, wrap it in a [`LWWRegisterCRDT`]
|
||||
ErrApplyOnPrimitive,
|
||||
/// Tried to apply an operation to a static struct CRDT
|
||||
/// If you would like a mutable object, use a [`Value`]
|
||||
ErrApplyOnStruct,
|
||||
/// Tried to apply an operation that contains content of the wrong type.
|
||||
/// In other words, the content cannot be coerced to the CRDT at the path specified.
|
||||
ErrMismatchedType,
|
||||
/// The signed digest of the message did not match the claimed author of the message.
|
||||
/// This can happen if the message was tampered with during delivery
|
||||
ErrDigestMismatch,
|
||||
/// The hash of the message did not match the contents of the message.
|
||||
/// This can happen if the author tried to perform an equivocation attack by creating an
|
||||
/// operation and modifying it has already been created
|
||||
ErrHashMismatch,
|
||||
/// Tried to apply an operation to a non-existent path. The author may have forgotten to attach
|
||||
/// a causal dependency
|
||||
ErrPathMismatch,
|
||||
/// Trying to modify/delete the sentinel (zero-th) node element that is used for book-keeping
|
||||
ErrListApplyToEmpty,
|
||||
/// We have not received all of the causal dependencies of this operation. It has been queued
|
||||
/// up and will be executed when its causal dependencies have been delivered
|
||||
MissingCausalDependencies,
|
||||
/// This op has already been applied (identified by its `signed_digest`).
|
||||
/// The CRDT state is unchanged — this is a no-op (idempotent self-loop guard).
|
||||
AlreadySeen,
|
||||
}
|
||||
|
||||
/// Maximum total number of ops that may sit in the causal-order hold queue at any
|
||||
/// one time, summed across all pending dependency buckets.
|
||||
///
|
||||
/// **Overflow policy: drop oldest.**
|
||||
/// When the limit is reached, the oldest pending op in the largest dependency bucket
|
||||
/// is silently evicted before the new op is queued. Rationale: a misbehaving or
|
||||
/// heavily-partitioned peer can send ops whose causal ancestors never arrive, causing
|
||||
/// unbounded memory growth. Dropping the oldest entry preserves the most recent
|
||||
/// information and caps memory use. The peer can reconnect and receive a fresh bulk
|
||||
/// state dump to recover any dropped ops.
|
||||
pub const CAUSAL_QUEUE_MAX: usize = 256;
|
||||
|
||||
/// The following types can be used as a 'terminal' type in CRDTs
|
||||
pub trait MarkPrimitive: Into<JsonValue> + Default {}
|
||||
impl MarkPrimitive for bool {}
|
||||
impl MarkPrimitive for i32 {}
|
||||
impl MarkPrimitive for i64 {}
|
||||
impl MarkPrimitive for f64 {}
|
||||
impl MarkPrimitive for char {}
|
||||
impl MarkPrimitive for String {}
|
||||
impl MarkPrimitive for JsonValue {}
|
||||
|
||||
/// Implement CrdtNode for non-CRDTs
|
||||
/// This is a stub implementation so most functions don't do anything/log an error
|
||||
impl<T> CrdtNode for T
|
||||
where
|
||||
T: CrdtNodeFromValue + MarkPrimitive + Hashable + Clone,
|
||||
{
|
||||
fn apply(&mut self, _op: Op<JsonValue>) -> OpState {
|
||||
OpState::ErrApplyOnPrimitive
|
||||
}
|
||||
|
||||
fn view(&self) -> JsonValue {
|
||||
self.to_owned().into()
|
||||
}
|
||||
|
||||
fn new(_id: AuthorId, _path: Vec<PathSegment>) -> Self {
|
||||
debug_op_on_primitive(_path);
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The base struct for a JSON CRDT. Allows for declaring causal
|
||||
/// dependencies across fields. It only accepts messages of [`SignedOp`] for BFT.
|
||||
pub struct BaseCrdt<T: CrdtNode> {
|
||||
/// Public key of this CRDT
|
||||
pub id: AuthorId,
|
||||
|
||||
/// Internal base CRDT
|
||||
pub doc: T,
|
||||
|
||||
/// In a real world scenario, this would be a proper hash graph that allows for
|
||||
/// efficient reconciliation of missing dependencies. We naively keep a hash set
|
||||
/// of messages we've seen (represented by their [`SignedDigest`]).
|
||||
received: HashSet<SignedDigest>,
|
||||
message_q: HashMap<SignedDigest, Vec<SignedOp>>,
|
||||
|
||||
/// Total count of ops currently held in [`message_q`] waiting for their causal
|
||||
/// dependencies to be delivered. Used to enforce [`CAUSAL_QUEUE_MAX`].
|
||||
queue_len: usize,
|
||||
}
|
||||
|
||||
/// An [`Op<Value>`] with a few bits of extra metadata
|
||||
#[serde_as]
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct SignedOp {
|
||||
// Note that this can be different from the author of the inner op as the inner op could have been created
|
||||
// by a different person
|
||||
author: AuthorId,
|
||||
/// Signed hash using priv key of author. Effectively [`OpID`] Use this as the ID to figure out what has been delivered already
|
||||
#[serde_as(as = "Bytes")]
|
||||
pub signed_digest: SignedDigest,
|
||||
pub inner: Op<JsonValue>,
|
||||
/// List of causal dependencies
|
||||
#[serde_as(as = "Vec<Bytes>")]
|
||||
pub depends_on: Vec<SignedDigest>,
|
||||
}
|
||||
|
||||
impl SignedOp {
|
||||
pub fn id(&self) -> OpId {
|
||||
self.inner.id
|
||||
}
|
||||
|
||||
pub fn author(&self) -> AuthorId {
|
||||
self.author
|
||||
}
|
||||
|
||||
/// Creates a digest of the following fields. Any changes in the fields will change the signed digest
|
||||
/// - id (hash of the following)
|
||||
/// - origin
|
||||
/// - author
|
||||
/// - seq
|
||||
/// - is_deleted
|
||||
/// - path
|
||||
/// - dependencies
|
||||
fn digest(&self) -> [u8; 32] {
|
||||
let path_string = print_path(self.inner.path.clone());
|
||||
let dependency_string = self
|
||||
.depends_on
|
||||
.iter()
|
||||
.map(print_hex)
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
let fmt_str = format!("{:?},{path_string},{dependency_string}", self.id());
|
||||
sha256(fmt_str)
|
||||
}
|
||||
|
||||
/// Sign this digest with the given keypair. Shouldn't need to be called manually,
|
||||
/// just use [`SignedOp::from_op`] instead
|
||||
fn sign_digest(&mut self, keypair: &Ed25519KeyPair) {
|
||||
self.signed_digest = sign(keypair, &self.digest()).sig.to_bytes()
|
||||
}
|
||||
|
||||
/// Ensure digest was actually signed by the author it claims to be signed by
|
||||
pub fn is_valid_digest(&self) -> bool {
|
||||
let digest = Ed25519Signature::from_bytes(&self.signed_digest);
|
||||
let pubkey = Ed25519PublicKey::from_bytes(&self.author());
|
||||
match (digest, pubkey) {
|
||||
(Ok(digest), Ok(pubkey)) => pubkey.verify(&self.digest(), &digest).is_ok(),
|
||||
(_, _) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign a normal op and add all the needed metadata
|
||||
pub fn from_op<T: CrdtNode>(
|
||||
value: Op<T>,
|
||||
keypair: &Ed25519KeyPair,
|
||||
depends_on: Vec<SignedDigest>,
|
||||
) -> Self {
|
||||
let author = keypair.public().0.to_bytes();
|
||||
let mut new = Self {
|
||||
inner: Op {
|
||||
content: value.content.map(|c| c.view()),
|
||||
origin: value.origin,
|
||||
author: value.author,
|
||||
seq: value.seq,
|
||||
path: value.path,
|
||||
is_deleted: value.is_deleted,
|
||||
id: value.id,
|
||||
},
|
||||
author,
|
||||
signed_digest: [0u8; 64],
|
||||
depends_on,
|
||||
};
|
||||
new.sign_digest(keypair);
|
||||
new
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: CrdtNode + DebugView> BaseCrdt<T> {
|
||||
/// Create a new BaseCRDT of the given type. Multiple BaseCRDTs
|
||||
/// can be created from a single keypair but you are responsible for
|
||||
/// routing messages to the right BaseCRDT. Usually you should just make a single
|
||||
/// struct that contains all the state you need.
|
||||
pub fn new(keypair: &Ed25519KeyPair) -> Self {
|
||||
let id = keypair.public().0.to_bytes();
|
||||
Self {
|
||||
id,
|
||||
doc: T::new(id, vec![]),
|
||||
received: HashSet::new(),
|
||||
message_q: HashMap::new(),
|
||||
queue_len: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a signed operation to this BaseCRDT, verifying integrity and routing to the right
|
||||
/// nested CRDT
|
||||
pub fn apply(&mut self, op: SignedOp) -> OpState {
|
||||
// self.log_try_apply(&op);
|
||||
|
||||
#[cfg(feature = "bft")]
|
||||
if !op.is_valid_digest() {
|
||||
self.debug_digest_failure(op);
|
||||
return OpState::ErrDigestMismatch;
|
||||
}
|
||||
|
||||
let op_id = op.signed_digest;
|
||||
|
||||
// Self-loop / dedup guard: if we have already processed this op (identified by
|
||||
// its signed_digest), return immediately without re-applying it. This prevents
|
||||
// echo loops where an op we broadcast to a peer comes back to us.
|
||||
if self.received.contains(&op_id) {
|
||||
return OpState::AlreadySeen;
|
||||
}
|
||||
|
||||
if !op.depends_on.is_empty() {
|
||||
for origin in &op.depends_on {
|
||||
if !self.received.contains(origin) {
|
||||
self.log_missing_causal_dep(origin);
|
||||
|
||||
// Bounded queue overflow: evict the oldest op from the largest
|
||||
// pending bucket before adding the new one. See CAUSAL_QUEUE_MAX.
|
||||
if self.queue_len >= CAUSAL_QUEUE_MAX {
|
||||
if let Some(bucket) = self.message_q.values_mut().max_by_key(|v| v.len()) {
|
||||
if !bucket.is_empty() {
|
||||
bucket.remove(0);
|
||||
self.queue_len = self.queue_len.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.message_q.entry(*origin).or_default().push(op);
|
||||
self.queue_len += 1;
|
||||
return OpState::MissingCausalDependencies;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apply
|
||||
// self.log_actually_apply(&op);
|
||||
let status = self.doc.apply(op.inner);
|
||||
// self.debug_view();
|
||||
|
||||
// Only record the op as seen when it applied successfully. If the op
|
||||
// was rejected (e.g. ErrHashMismatch from a tampered payload), we must
|
||||
// NOT add its signed_digest to `received`: a legitimate op that shares
|
||||
// the same signed_digest (e.g. the un-tampered original) would otherwise
|
||||
// be silently dropped as AlreadySeen.
|
||||
// Only mark as received and unblock dependents when the op was actually
|
||||
// applied. If we insert on error (e.g. ErrHashMismatch), a subsequent
|
||||
// apply of a *legitimate* op with the same signed_digest would be
|
||||
// silently dropped as AlreadySeen, preventing equivocation detection
|
||||
// from working correctly.
|
||||
if status == OpState::Ok {
|
||||
self.received.insert(op_id);
|
||||
|
||||
// apply all of its causal dependents if there are any
|
||||
let dependent_queue = self.message_q.remove(&op_id);
|
||||
if let Some(mut q) = dependent_queue {
|
||||
self.queue_len = self.queue_len.saturating_sub(q.len());
|
||||
for dependent in q.drain(..) {
|
||||
self.apply(dependent);
|
||||
}
|
||||
}
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
/// Number of ops currently held in the causal-order queue waiting for their
|
||||
/// dependencies to be satisfied.
|
||||
pub fn causal_queue_len(&self) -> usize {
|
||||
self.queue_len
|
||||
}
|
||||
}
|
||||
|
||||
/// An enum representing a JSON value
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum JsonValue {
|
||||
Null,
|
||||
Bool(bool),
|
||||
Number(f64),
|
||||
String(String),
|
||||
Array(Vec<JsonValue>),
|
||||
Object(IndexMap<String, JsonValue>),
|
||||
}
|
||||
|
||||
impl Display for JsonValue {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
JsonValue::Null => "null".to_string(),
|
||||
JsonValue::Bool(b) => b.to_string(),
|
||||
JsonValue::Number(n) => n.to_string(),
|
||||
JsonValue::String(s) => format!("\"{s}\""),
|
||||
JsonValue::Array(arr) => {
|
||||
if arr.len() > 1 {
|
||||
format!(
|
||||
"[\n{}\n]",
|
||||
arr.iter()
|
||||
.map(|x| format!(" {x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n")
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"[ {} ]",
|
||||
arr.iter()
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
JsonValue::Object(obj) => format!(
|
||||
"{{ {} }}",
|
||||
obj.iter()
|
||||
.map(|(k, v)| format!(" \"{k}\": {v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n")
|
||||
),
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for JsonValue {
|
||||
fn default() -> Self {
|
||||
Self::Null
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow easy conversion to and from serde's JSON format. This allows us to use the [`json!`]
|
||||
/// macro
|
||||
impl From<JsonValue> for serde_json::Value {
|
||||
fn from(value: JsonValue) -> Self {
|
||||
match value {
|
||||
JsonValue::Null => serde_json::Value::Null,
|
||||
JsonValue::Bool(x) => serde_json::Value::Bool(x),
|
||||
JsonValue::Number(x) => {
|
||||
serde_json::Value::Number(serde_json::Number::from_f64(x).unwrap())
|
||||
}
|
||||
JsonValue::String(x) => serde_json::Value::String(x),
|
||||
JsonValue::Array(x) => {
|
||||
serde_json::Value::Array(x.iter().map(|a| a.clone().into()).collect())
|
||||
}
|
||||
JsonValue::Object(x) => serde_json::Value::Object(
|
||||
x.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone().into()))
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Value> for JsonValue {
|
||||
fn from(value: serde_json::Value) -> Self {
|
||||
match value {
|
||||
serde_json::Value::Null => JsonValue::Null,
|
||||
serde_json::Value::Bool(x) => JsonValue::Bool(x),
|
||||
serde_json::Value::Number(x) => JsonValue::Number(x.as_f64().unwrap()),
|
||||
serde_json::Value::String(x) => JsonValue::String(x),
|
||||
serde_json::Value::Array(x) => {
|
||||
JsonValue::Array(x.iter().map(|a| a.clone().into()).collect())
|
||||
}
|
||||
serde_json::Value::Object(x) => JsonValue::Object(
|
||||
x.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone().into()))
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonValue {
|
||||
pub fn into_json(self) -> serde_json::Value {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from primitive types to [`JsonValue`]
|
||||
impl From<bool> for JsonValue {
|
||||
fn from(val: bool) -> Self {
|
||||
JsonValue::Bool(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for JsonValue {
|
||||
fn from(val: i64) -> Self {
|
||||
JsonValue::Number(val as f64)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i32> for JsonValue {
|
||||
fn from(val: i32) -> Self {
|
||||
JsonValue::Number(val as f64)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for JsonValue {
|
||||
fn from(val: f64) -> Self {
|
||||
JsonValue::Number(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for JsonValue {
|
||||
fn from(val: String) -> Self {
|
||||
JsonValue::String(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<char> for JsonValue {
|
||||
fn from(val: char) -> Self {
|
||||
JsonValue::String(val.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<Option<T>> for JsonValue
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn from(val: Option<T>) -> Self {
|
||||
match val {
|
||||
Some(x) => x.view(),
|
||||
None => JsonValue::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<Vec<T>> for JsonValue
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn from(value: Vec<T>) -> Self {
|
||||
JsonValue::Array(value.iter().map(|x| x.view()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallibly create a CRDT Node from a JSON Value
|
||||
pub trait CrdtNodeFromValue: Sized {
|
||||
fn node_from(value: JsonValue, id: AuthorId, path: Vec<PathSegment>) -> Result<Self, String>;
|
||||
}
|
||||
|
||||
/// Fallibly cast a JSON Value into a CRDT Node
|
||||
pub trait IntoCrdtNode<T>: Sized {
|
||||
fn into_node(self, id: AuthorId, path: Vec<PathSegment>) -> Result<T, String>;
|
||||
}
|
||||
|
||||
/// [`CrdtNodeFromValue`] implies [`IntoCrdtNode<T>`]
|
||||
impl<T> IntoCrdtNode<T> for JsonValue
|
||||
where
|
||||
T: CrdtNodeFromValue,
|
||||
{
|
||||
fn into_node(self, id: AuthorId, path: Vec<PathSegment>) -> Result<T, String> {
|
||||
T::node_from(self, id, path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trivial conversion from [`JsonValue`] to [`JsonValue`] as [`CrdtNodeFromValue`]
|
||||
impl CrdtNodeFromValue for JsonValue {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from bool to CRDT
|
||||
impl CrdtNodeFromValue for bool {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::Bool(x) = value {
|
||||
Ok(x)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> bool"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from f64 to CRDT
|
||||
impl CrdtNodeFromValue for f64 {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::Number(x) = value {
|
||||
Ok(x)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> f64"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from i64 to CRDT
|
||||
impl CrdtNodeFromValue for i64 {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::Number(x) = value {
|
||||
Ok(x as i64)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> f64"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from String to CRDT
|
||||
impl CrdtNodeFromValue for String {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::String(x) = value {
|
||||
Ok(x)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> String"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from char to CRDT
|
||||
impl CrdtNodeFromValue for char {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::String(x) = value.clone() {
|
||||
x.chars().next().ok_or(format!(
|
||||
"failed to convert {value:?} -> char: found a zero-length string"
|
||||
))
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> char"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CrdtNodeFromValue for LwwRegisterCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn node_from(value: JsonValue, id: AuthorId, path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
let mut crdt = LwwRegisterCrdt::new(id, path);
|
||||
crdt.set(value);
|
||||
Ok(crdt)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CrdtNodeFromValue for ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn node_from(value: JsonValue, id: AuthorId, path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::Array(arr) = value {
|
||||
let mut crdt = ListCrdt::new(id, path);
|
||||
let result: Result<(), String> =
|
||||
arr.into_iter().enumerate().try_for_each(|(i, val)| {
|
||||
crdt.insert_idx(i, val);
|
||||
Ok(())
|
||||
});
|
||||
result?;
|
||||
Ok(crdt)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> ListCRDT<T>"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
json_crdt::{add_crdt_fields, BaseCrdt, CrdtNode, IntoCrdtNode, JsonValue, OpState},
|
||||
keypair::make_keypair,
|
||||
list_crdt::ListCrdt,
|
||||
lww_crdt::LwwRegisterCrdt,
|
||||
op::{print_path, ROOT_ID},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_derive_basic() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Player {
|
||||
x: LwwRegisterCrdt<f64>,
|
||||
y: LwwRegisterCrdt<f64>,
|
||||
}
|
||||
|
||||
let keypair = make_keypair();
|
||||
let crdt = BaseCrdt::<Player>::new(&keypair);
|
||||
assert_eq!(print_path(crdt.doc.x.path), "x");
|
||||
assert_eq!(print_path(crdt.doc.y.path), "y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_nested() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Position {
|
||||
x: LwwRegisterCrdt<f64>,
|
||||
y: LwwRegisterCrdt<f64>,
|
||||
}
|
||||
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Player {
|
||||
pos: Position,
|
||||
balance: LwwRegisterCrdt<f64>,
|
||||
messages: ListCrdt<String>,
|
||||
}
|
||||
|
||||
let keypair = make_keypair();
|
||||
let crdt = BaseCrdt::<Player>::new(&keypair);
|
||||
assert_eq!(print_path(crdt.doc.pos.x.path), "pos.x");
|
||||
assert_eq!(print_path(crdt.doc.pos.y.path), "pos.y");
|
||||
assert_eq!(print_path(crdt.doc.balance.path), "balance");
|
||||
assert_eq!(print_path(crdt.doc.messages.path), "messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lww_ops() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Test {
|
||||
a: LwwRegisterCrdt<f64>,
|
||||
b: LwwRegisterCrdt<bool>,
|
||||
c: LwwRegisterCrdt<String>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let kp2 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Test>::new(&kp1);
|
||||
let mut base2 = BaseCrdt::<Test>::new(&kp2);
|
||||
|
||||
let _1_a_1 = base1.doc.a.set(3.0).sign(&kp1);
|
||||
let _1_b_1 = base1.doc.b.set(true).sign(&kp1);
|
||||
let _2_a_1 = base2.doc.a.set(1.5).sign(&kp2);
|
||||
let _2_a_2 = base2.doc.a.set(2.13).sign(&kp2);
|
||||
let _2_c_1 = base2.doc.c.set("abc".to_string()).sign(&kp2);
|
||||
|
||||
assert_eq!(base1.doc.a.view(), json!(3.0).into());
|
||||
assert_eq!(base2.doc.a.view(), json!(2.13).into());
|
||||
assert_eq!(base1.doc.b.view(), json!(true).into());
|
||||
assert_eq!(base2.doc.c.view(), json!("abc").into());
|
||||
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"a": 3.0,
|
||||
"b": true,
|
||||
"c": null,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
base2.doc.view().into_json(),
|
||||
json!({
|
||||
"a": 2.13,
|
||||
"b": null,
|
||||
"c": "abc",
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(base2.apply(_1_a_1), OpState::Ok);
|
||||
assert_eq!(base2.apply(_1_b_1), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2_a_1), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2_a_2), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2_c_1), OpState::Ok);
|
||||
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"a": 2.13,
|
||||
"b": true,
|
||||
"c": "abc"
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vec_and_map_ops() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Test {
|
||||
a: ListCrdt<String>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let kp2 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Test>::new(&kp1);
|
||||
let mut base2 = BaseCrdt::<Test>::new(&kp2);
|
||||
|
||||
let _1a = base1.doc.a.insert(ROOT_ID, "a".to_string()).sign(&kp1);
|
||||
let _1b = base1.doc.a.insert(_1a.id(), "b".to_string()).sign(&kp1);
|
||||
let _2c = base2.doc.a.insert(ROOT_ID, "c".to_string()).sign(&kp2);
|
||||
let _2d = base2.doc.a.insert(_1b.id(), "d".to_string()).sign(&kp2);
|
||||
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"a": ["a", "b"],
|
||||
})
|
||||
);
|
||||
|
||||
// as _1b hasn't been delivered to base2 yet
|
||||
assert_eq!(
|
||||
base2.doc.view().into_json(),
|
||||
json!({
|
||||
"a": ["c"],
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(base2.apply(_1b), OpState::MissingCausalDependencies);
|
||||
assert_eq!(base2.apply(_1a), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2d), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2c), OpState::Ok);
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_causal_field_dependency() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Item {
|
||||
name: LwwRegisterCrdt<String>,
|
||||
soulbound: LwwRegisterCrdt<bool>,
|
||||
}
|
||||
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Player {
|
||||
inventory: ListCrdt<Item>,
|
||||
balance: LwwRegisterCrdt<f64>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let kp2 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Player>::new(&kp1);
|
||||
let mut base2 = BaseCrdt::<Player>::new(&kp2);
|
||||
|
||||
// require balance update to happen before inventory update
|
||||
let _add_money = base1.doc.balance.set(5000.0).sign(&kp1);
|
||||
let _spend_money = base1
|
||||
.doc
|
||||
.balance
|
||||
.set(3000.0)
|
||||
.sign_with_dependencies(&kp1, vec![&_add_money]);
|
||||
|
||||
let sword: JsonValue = json!({
|
||||
"name": "Sword",
|
||||
"soulbound": true,
|
||||
})
|
||||
.into();
|
||||
let _new_inventory_item = base1
|
||||
.doc
|
||||
.inventory
|
||||
.insert_idx(0, sword)
|
||||
.sign_with_dependencies(&kp1, vec![&_spend_money]);
|
||||
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"balance": 3000.0,
|
||||
"inventory": [
|
||||
{
|
||||
"name": "Sword",
|
||||
"soulbound": true
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
// do it completely out of order
|
||||
assert_eq!(
|
||||
base2.apply(_new_inventory_item),
|
||||
OpState::MissingCausalDependencies
|
||||
);
|
||||
assert_eq!(
|
||||
base2.apply(_spend_money),
|
||||
OpState::MissingCausalDependencies
|
||||
);
|
||||
assert_eq!(base2.apply(_add_money), OpState::Ok);
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_2d_grid() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Game {
|
||||
grid: ListCrdt<ListCrdt<LwwRegisterCrdt<bool>>>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let kp2 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Game>::new(&kp1);
|
||||
let mut base2 = BaseCrdt::<Game>::new(&kp2);
|
||||
|
||||
// init a 2d grid
|
||||
let row0: JsonValue = json!([true, false]).into();
|
||||
let row1: JsonValue = json!([false, true]).into();
|
||||
let construct1 = base1.doc.grid.insert_idx(0, row0).sign(&kp1);
|
||||
let construct2 = base1.doc.grid.insert_idx(1, row1).sign(&kp1);
|
||||
|
||||
assert_eq!(base2.apply(construct1), OpState::Ok);
|
||||
assert_eq!(base2.apply(construct2.clone()), OpState::Ok);
|
||||
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"grid": [[true, false], [false, true]]
|
||||
})
|
||||
);
|
||||
|
||||
let set1 = base1.doc.grid[0][0].set(false).sign(&kp1);
|
||||
let set2 = base2.doc.grid[1][1].set(false).sign(&kp2);
|
||||
assert_eq!(base1.apply(set2), OpState::Ok);
|
||||
assert_eq!(base2.apply(set1), OpState::Ok);
|
||||
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"grid": [[false, false], [false, false]]
|
||||
})
|
||||
);
|
||||
|
||||
let topright = base1.doc.grid[0].id_at(1).unwrap();
|
||||
base1.doc.grid[0].delete(topright);
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"grid": [[false], [false, false]]
|
||||
})
|
||||
);
|
||||
|
||||
base1.doc.grid.delete(construct2.id());
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"grid": [[false]]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arb_json() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Test {
|
||||
reg: LwwRegisterCrdt<JsonValue>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Test>::new(&kp1);
|
||||
|
||||
let base_val: JsonValue = json!({
|
||||
"a": true,
|
||||
"b": "asdf",
|
||||
"c": {
|
||||
"d": [],
|
||||
"e": [ false ]
|
||||
}
|
||||
})
|
||||
.into();
|
||||
base1.doc.reg.set(base_val).sign(&kp1);
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"reg": {
|
||||
"a": true,
|
||||
"b": "asdf",
|
||||
"c": {
|
||||
"d": [],
|
||||
"e": [ false ]
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_json_types() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Nested {
|
||||
list: ListCrdt<f64>,
|
||||
}
|
||||
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Test {
|
||||
reg: LwwRegisterCrdt<bool>,
|
||||
strct: ListCrdt<Nested>,
|
||||
}
|
||||
|
||||
let key = make_keypair();
|
||||
let mut crdt = BaseCrdt::<Test>::new(&key);
|
||||
|
||||
// wrong type should not go through
|
||||
crdt.doc.reg.set(32);
|
||||
assert_eq!(crdt.doc.reg.view(), json!(null).into());
|
||||
crdt.doc.reg.set(true);
|
||||
assert_eq!(crdt.doc.reg.view(), json!(true).into());
|
||||
|
||||
// set nested
|
||||
let mut list_view: JsonValue = crdt.doc.strct.view().into();
|
||||
assert_eq!(list_view, json!([]).into());
|
||||
|
||||
// only keeps actual numbers
|
||||
let list: JsonValue = json!({"list": [0, 123, -0.45, "char", []]}).into();
|
||||
crdt.doc.strct.insert_idx(0, list);
|
||||
list_view = crdt.doc.strct.view().into();
|
||||
assert_eq!(list_view, json!([{ "list": [0, 123, -0.45]}]).into());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use fastcrypto::traits::VerifyingKey;
|
||||
pub use fastcrypto::{
|
||||
ed25519::{
|
||||
Ed25519KeyPair, Ed25519PublicKey, Ed25519Signature, ED25519_PUBLIC_KEY_LENGTH,
|
||||
ED25519_SIGNATURE_LENGTH,
|
||||
},
|
||||
traits::{KeyPair, Signer},
|
||||
// Verifier,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Represents the ID of a unique node. An Ed25519 public key
|
||||
pub type AuthorId = [u8; ED25519_PUBLIC_KEY_LENGTH];
|
||||
|
||||
/// A signed message
|
||||
pub type SignedDigest = [u8; ED25519_SIGNATURE_LENGTH];
|
||||
|
||||
/// Create a fake public key from a u8
|
||||
pub fn make_author(n: u8) -> AuthorId {
|
||||
let mut id = [0u8; ED25519_PUBLIC_KEY_LENGTH];
|
||||
id[0] = n;
|
||||
id
|
||||
}
|
||||
|
||||
/// Get the least significant 32 bits of a public key
|
||||
pub fn lsb_32(pubkey: AuthorId) -> u32 {
|
||||
((pubkey[0] as u32) << 24)
|
||||
+ ((pubkey[1] as u32) << 16)
|
||||
+ ((pubkey[2] as u32) << 8)
|
||||
+ (pubkey[3] as u32)
|
||||
}
|
||||
|
||||
/// SHA256 hash of a string
|
||||
pub fn sha256(input: String) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(input.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes.copy_from_slice(&result[..]);
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Generate a random Ed25519 keypair from OS rng
|
||||
pub fn make_keypair() -> Ed25519KeyPair {
|
||||
let mut csprng = rand::thread_rng();
|
||||
Ed25519KeyPair::generate(&mut csprng)
|
||||
}
|
||||
|
||||
/// Sign a byte array
|
||||
pub fn sign(keypair: &Ed25519KeyPair, message: &[u8]) -> Ed25519Signature {
|
||||
keypair.sign(message)
|
||||
}
|
||||
|
||||
/// Verify a byte array was signed by the given pubkey
|
||||
pub fn verify(pubkey: Ed25519PublicKey, message: &[u8], signature: Ed25519Signature) -> bool {
|
||||
pubkey.verify(message, &signature).is_ok()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod debug;
|
||||
pub mod json_crdt;
|
||||
pub mod keypair;
|
||||
pub mod list_crdt;
|
||||
pub mod lww_crdt;
|
||||
pub mod op;
|
||||
|
||||
extern crate self as bft_json_crdt;
|
||||
@@ -0,0 +1,445 @@
|
||||
use crate::{
|
||||
debug::debug_path_mismatch,
|
||||
json_crdt::{CrdtNode, JsonValue, OpState},
|
||||
keypair::AuthorId,
|
||||
op::*,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
cmp::{max, Ordering},
|
||||
collections::HashMap,
|
||||
fmt::Debug,
|
||||
ops::{Index, IndexMut},
|
||||
};
|
||||
|
||||
/// An RGA-like list CRDT that can store a CRDT-like datatype
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
/// Public key for this node
|
||||
pub our_id: AuthorId,
|
||||
/// Path to this CRDT
|
||||
pub path: Vec<PathSegment>,
|
||||
/// List of all the operations we know of
|
||||
pub ops: Vec<Op<T>>,
|
||||
/// Queue of messages where K is the ID of the message yet to arrive
|
||||
/// and V is the list of operations depending on it
|
||||
message_q: HashMap<OpId, Vec<Op<T>>>,
|
||||
/// The sequence number of this node
|
||||
our_seq: SequenceNumber,
|
||||
}
|
||||
|
||||
impl<T> ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
/// Create a new List CRDT with the given [`AuthorID`] (it should be unique)
|
||||
pub fn new(id: AuthorId, path: Vec<PathSegment>) -> ListCrdt<T> {
|
||||
let ops = vec![Op::make_root()];
|
||||
ListCrdt {
|
||||
our_id: id,
|
||||
path,
|
||||
ops,
|
||||
message_q: HashMap::new(),
|
||||
our_seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Locally insert some content causally after the given operation
|
||||
pub fn insert<U: Into<JsonValue>>(&mut self, after: OpId, content: U) -> Op<JsonValue> {
|
||||
let mut op = Op::new(
|
||||
after,
|
||||
self.our_id,
|
||||
self.our_seq + 1,
|
||||
false,
|
||||
Some(content.into()),
|
||||
self.path.to_owned(),
|
||||
);
|
||||
|
||||
// we need to know the op ID before setting the path as [`PathSegment::Index`] requires an
|
||||
// [`OpID`]
|
||||
let new_path = join_path(self.path.to_owned(), PathSegment::Index(op.id));
|
||||
op.path = new_path;
|
||||
self.apply(op.clone());
|
||||
op
|
||||
}
|
||||
|
||||
/// Shorthand function to insert at index locally. Indexing ignores deleted items
|
||||
pub fn insert_idx<U: Into<JsonValue> + Clone>(
|
||||
&mut self,
|
||||
idx: usize,
|
||||
content: U,
|
||||
) -> Op<JsonValue> {
|
||||
let mut i = 0;
|
||||
for op in &self.ops {
|
||||
if !op.is_deleted {
|
||||
if idx == i {
|
||||
return self.insert(op.id, content);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
panic!("index {idx} out of range (length of {i})")
|
||||
}
|
||||
|
||||
/// Shorthand to figure out the OpID of something with a given index.
|
||||
/// Useful for declaring a causal dependency if you didn't create the original
|
||||
pub fn id_at(&self, idx: usize) -> Option<OpId> {
|
||||
let mut i = 0;
|
||||
for op in &self.ops {
|
||||
if !op.is_deleted {
|
||||
if idx == i {
|
||||
return Some(op.id);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Mark a node as deleted. If the node doesn't exist, it will be stuck
|
||||
/// waiting for that node to be created.
|
||||
pub fn delete(&mut self, id: OpId) -> Op<JsonValue> {
|
||||
let op = Op::new(
|
||||
id,
|
||||
self.our_id,
|
||||
self.our_seq + 1,
|
||||
true,
|
||||
None,
|
||||
join_path(self.path.to_owned(), PathSegment::Index(id)),
|
||||
);
|
||||
self.apply(op.clone());
|
||||
op
|
||||
}
|
||||
|
||||
/// Find the idx of an operation with the given [`OpID`]
|
||||
pub fn find_idx(&self, id: OpId) -> Option<usize> {
|
||||
self.ops.iter().position(|op| op.id == id)
|
||||
}
|
||||
|
||||
/// Apply an operation (both local and remote) to this local list CRDT.
|
||||
/// Forwards it to a nested CRDT if necessary.
|
||||
pub fn apply(&mut self, op: Op<JsonValue>) -> OpState {
|
||||
if !op.is_valid_hash() {
|
||||
return OpState::ErrHashMismatch;
|
||||
}
|
||||
|
||||
if !ensure_subpath(&self.path, &op.path) {
|
||||
return OpState::ErrPathMismatch;
|
||||
}
|
||||
|
||||
// haven't reached end yet, navigate to inner CRDT
|
||||
if op.path.len() - 1 > self.path.len() {
|
||||
if let Some(PathSegment::Index(op_id)) = op.path.get(self.path.len()) {
|
||||
let op_id = op_id.to_owned();
|
||||
if let Some(idx) = self.find_idx(op_id) {
|
||||
if self.ops[idx].content.is_none() {
|
||||
return OpState::ErrListApplyToEmpty;
|
||||
} else {
|
||||
return self.ops[idx].content.as_mut().unwrap().apply(op);
|
||||
}
|
||||
} else {
|
||||
debug_path_mismatch(
|
||||
join_path(self.path.to_owned(), PathSegment::Index(op_id)),
|
||||
op.path,
|
||||
);
|
||||
return OpState::ErrPathMismatch;
|
||||
};
|
||||
} else {
|
||||
debug_path_mismatch(self.path.to_owned(), op.path);
|
||||
return OpState::ErrPathMismatch;
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise, this is just a direct replacement
|
||||
self.integrate(op.into())
|
||||
}
|
||||
|
||||
/// Main CRDT logic of integrating an op properly into our local log
|
||||
/// without causing conflicts. This is basically a really fancy
|
||||
/// insertion sort.
|
||||
///
|
||||
/// Effectively, we
|
||||
/// 1) find the parent item
|
||||
/// 2) find the right spot to insert before the next node
|
||||
fn integrate(&mut self, new_op: Op<T>) -> OpState {
|
||||
let op_id = new_op.id;
|
||||
let seq = new_op.sequence_num();
|
||||
let origin_id = self.find_idx(new_op.origin);
|
||||
|
||||
if origin_id.is_none() {
|
||||
self.message_q
|
||||
.entry(new_op.origin)
|
||||
.or_default()
|
||||
.push(new_op);
|
||||
return OpState::MissingCausalDependencies;
|
||||
}
|
||||
|
||||
let new_op_parent_idx = origin_id.unwrap();
|
||||
|
||||
// if its a delete operation, we don't need to do much
|
||||
self.log_apply(&new_op);
|
||||
if new_op.is_deleted {
|
||||
let op = &mut self.ops[new_op_parent_idx];
|
||||
op.is_deleted = true;
|
||||
return OpState::Ok;
|
||||
}
|
||||
|
||||
// otherwise, we are in an insert case
|
||||
// start looking from right after parent
|
||||
// stop when we reach end of document
|
||||
let mut i = new_op_parent_idx + 1;
|
||||
while i < self.ops.len() {
|
||||
let op = &self.ops[i];
|
||||
let op_parent_idx = self.find_idx(op.origin).unwrap();
|
||||
|
||||
// idempotency
|
||||
if op.id == new_op.id {
|
||||
return OpState::Ok;
|
||||
}
|
||||
|
||||
// first, lets compare causal origins
|
||||
match new_op_parent_idx.cmp(&op_parent_idx) {
|
||||
Ordering::Greater => break,
|
||||
Ordering::Equal => {
|
||||
// our parents our equal, we are siblings
|
||||
// siblings are sorted first by sequence number then by author id
|
||||
match new_op.sequence_num().cmp(&op.sequence_num()) {
|
||||
Ordering::Greater => break,
|
||||
Ordering::Equal => {
|
||||
// conflict, resolve arbitrarily but deterministically
|
||||
// tie-break on author id as that is unique
|
||||
if new_op.author() > op.author() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ordering::Less => (),
|
||||
}
|
||||
}
|
||||
Ordering::Less => (),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// insert at i
|
||||
self.ops.insert(i, new_op);
|
||||
self.our_seq = max(self.our_seq, seq);
|
||||
self.log_ops(Some(op_id));
|
||||
|
||||
// apply all of its causal dependents if there are any
|
||||
let dependent_queue = self.message_q.remove(&op_id);
|
||||
if let Some(mut q) = dependent_queue {
|
||||
for dependent in q.drain(..) {
|
||||
self.integrate(dependent);
|
||||
}
|
||||
}
|
||||
OpState::Ok
|
||||
}
|
||||
|
||||
/// Make an iterator out of list CRDT contents, ignoring deleted items and empty content
|
||||
pub fn iter(&self) -> impl Iterator<Item = &T> {
|
||||
self.ops
|
||||
.iter()
|
||||
.filter(|op| !op.is_deleted && op.content.is_some())
|
||||
.map(|op| op.content.as_ref().unwrap())
|
||||
}
|
||||
|
||||
/// Convenience function to get a vector of visible list elements
|
||||
pub fn view(&self) -> Vec<T> {
|
||||
self.iter().map(|i| i.to_owned()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Debug for ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"[{}]",
|
||||
self.ops
|
||||
.iter()
|
||||
.map(|op| format!("{:?}", op.id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Allows us to index into a List CRDT like we would with an array
|
||||
impl<T> Index<usize> for ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
type Output = T;
|
||||
fn index(&self, idx: usize) -> &Self::Output {
|
||||
let mut i = 0;
|
||||
for op in &self.ops {
|
||||
if !op.is_deleted && op.content.is_some() {
|
||||
if idx == i {
|
||||
return op.content.as_ref().unwrap();
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
panic!("index {idx} out of range (length of {i})")
|
||||
}
|
||||
}
|
||||
|
||||
/// Allows us to mutably index into a List CRDT like we would with an array
|
||||
impl<T> IndexMut<usize> for ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn index_mut(&mut self, idx: usize) -> &mut Self::Output {
|
||||
let mut i = 0;
|
||||
for op in &mut self.ops {
|
||||
if !op.is_deleted && op.content.is_some() {
|
||||
if idx == i {
|
||||
return op.content.as_mut().unwrap();
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
panic!("index {idx} out of range (length of {i})")
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CrdtNode for ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn apply(&mut self, op: Op<JsonValue>) -> OpState {
|
||||
self.apply(op.into())
|
||||
}
|
||||
|
||||
fn view(&self) -> JsonValue {
|
||||
self.view().into()
|
||||
}
|
||||
|
||||
fn new(id: AuthorId, path: Vec<PathSegment>) -> Self {
|
||||
Self::new(id, path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "logging-base")]
|
||||
use crate::debug::DebugView;
|
||||
#[cfg(feature = "logging-base")]
|
||||
impl<T> DebugView for ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode + DebugView,
|
||||
{
|
||||
fn debug_view(&self, indent: usize) -> String {
|
||||
let spacing = " ".repeat(indent);
|
||||
let path_str = print_path(self.path.clone());
|
||||
let inner = self
|
||||
.ops
|
||||
.iter()
|
||||
.map(|op| {
|
||||
format!(
|
||||
"{spacing}{}: {}",
|
||||
&print_hex(&op.id)[..6],
|
||||
op.debug_view(indent)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("List CRDT @ /{path_str}\n{inner}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::{json_crdt::OpState, keypair::make_author, list_crdt::ListCrdt, op::ROOT_ID};
|
||||
|
||||
#[test]
|
||||
fn test_list_simple() {
|
||||
let mut list = ListCrdt::<i64>::new(make_author(1), vec![]);
|
||||
let _one = list.insert(ROOT_ID, 1);
|
||||
let _two = list.insert(_one.id, 2);
|
||||
let _three = list.insert(_two.id, 3);
|
||||
let _four = list.insert(_one.id, 4);
|
||||
assert_eq!(list.view(), vec![1, 4, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_idempotence() {
|
||||
let mut list = ListCrdt::<i64>::new(make_author(1), vec![]);
|
||||
let op = list.insert(ROOT_ID, 1);
|
||||
for _ in 1..10 {
|
||||
assert_eq!(list.apply(op.clone()), OpState::Ok);
|
||||
}
|
||||
assert_eq!(list.view(), vec![1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_delete() {
|
||||
let mut list = ListCrdt::<char>::new(make_author(1), vec![]);
|
||||
let _one = list.insert(ROOT_ID, 'a');
|
||||
let _two = list.insert(_one.id, 'b');
|
||||
let _three = list.insert(ROOT_ID, 'c');
|
||||
list.delete(_one.id);
|
||||
list.delete(_two.id);
|
||||
assert_eq!(list.view(), vec!['c']);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_interweave_chars() {
|
||||
let mut list = ListCrdt::<char>::new(make_author(1), vec![]);
|
||||
let _one = list.insert(ROOT_ID, 'a');
|
||||
let _two = list.insert(_one.id, 'b');
|
||||
let _three = list.insert(ROOT_ID, 'c');
|
||||
assert_eq!(list.view(), vec!['c', 'a', 'b']);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_conflicting_agents() {
|
||||
let mut list1 = ListCrdt::<char>::new(make_author(1), vec![]);
|
||||
let mut list2 = ListCrdt::new(make_author(2), vec![]);
|
||||
let _1_a = list1.insert(ROOT_ID, 'a');
|
||||
assert_eq!(list2.apply(_1_a.clone()), OpState::Ok);
|
||||
let _2_b = list2.insert(_1_a.id, 'b');
|
||||
assert_eq!(list1.apply(_2_b.clone()), OpState::Ok);
|
||||
|
||||
let _2_d = list2.insert(ROOT_ID, 'd');
|
||||
let _2_y = list2.insert(_2_b.id, 'y');
|
||||
let _1_x = list1.insert(_2_b.id, 'x');
|
||||
|
||||
// create artificial delay, then apply out of order
|
||||
assert_eq!(list2.apply(_1_x), OpState::Ok);
|
||||
assert_eq!(list1.apply(_2_y), OpState::Ok);
|
||||
assert_eq!(list1.apply(_2_d), OpState::Ok);
|
||||
|
||||
assert_eq!(list1.view(), vec!['d', 'a', 'b', 'y', 'x']);
|
||||
assert_eq!(list1.view(), list2.view());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_delete_multiple_agent() {
|
||||
let mut list1 = ListCrdt::<char>::new(make_author(1), vec![]);
|
||||
let mut list2 = ListCrdt::new(make_author(2), vec![]);
|
||||
let _1_a = list1.insert(ROOT_ID, 'a');
|
||||
assert_eq!(list2.apply(_1_a.clone()), OpState::Ok);
|
||||
let _2_b = list2.insert(_1_a.id, 'b');
|
||||
let del_1_a = list1.delete(_1_a.id);
|
||||
assert_eq!(list1.apply(_2_b), OpState::Ok);
|
||||
assert_eq!(list2.apply(del_1_a), OpState::Ok);
|
||||
|
||||
assert_eq!(list1.view(), vec!['b']);
|
||||
assert_eq!(list1.view(), list2.view());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_nested() {
|
||||
let mut list1 = ListCrdt::<char>::new(make_author(1), vec![]);
|
||||
let _c = list1.insert(ROOT_ID, 'c');
|
||||
let _a = list1.insert(ROOT_ID, 'a');
|
||||
let _d = list1.insert(_c.id, 'd');
|
||||
let _b = list1.insert(_a.id, 'b');
|
||||
|
||||
assert_eq!(list1.view(), vec!['a', 'b', 'c', 'd']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
use crate::debug::DebugView;
|
||||
use crate::json_crdt::{CrdtNode, JsonValue, OpState};
|
||||
use crate::op::{join_path, print_path, Op, PathSegment, SequenceNumber};
|
||||
use std::cmp::{max, Ordering};
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::keypair::AuthorId;
|
||||
|
||||
/// A simple delete-wins, last-writer-wins (LWW) register CRDT.
|
||||
/// Basically only for adding support for primitives within a more complex CRDT
|
||||
#[derive(Clone)]
|
||||
pub struct LwwRegisterCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
/// Public key for this node
|
||||
pub our_id: AuthorId,
|
||||
/// Path to this CRDT
|
||||
pub path: Vec<PathSegment>,
|
||||
/// Internal value of this CRDT. We wrap it in an Op to retain the author/sequence metadata
|
||||
value: Op<T>,
|
||||
/// The sequence number of this node
|
||||
our_seq: SequenceNumber,
|
||||
}
|
||||
|
||||
impl<T> LwwRegisterCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
/// Create a new register CRDT with the given [`AuthorID`] (it should be unique)
|
||||
pub fn new(id: AuthorId, path: Vec<PathSegment>) -> LwwRegisterCrdt<T> {
|
||||
LwwRegisterCrdt {
|
||||
our_id: id,
|
||||
path,
|
||||
value: Op::make_root(),
|
||||
our_seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the current value of the register
|
||||
pub fn set<U: Into<JsonValue>>(&mut self, content: U) -> Op<JsonValue> {
|
||||
let mut op = Op::new(
|
||||
self.value.id,
|
||||
self.our_id,
|
||||
self.our_seq + 1,
|
||||
false,
|
||||
Some(content.into()),
|
||||
self.path.to_owned(),
|
||||
);
|
||||
|
||||
// we need to know the op ID before setting the path as [`PathSegment::Index`] requires an
|
||||
// [`OpID`]
|
||||
let new_path = join_path(self.path.to_owned(), PathSegment::Index(op.id));
|
||||
op.path = new_path;
|
||||
self.apply(op.clone());
|
||||
op
|
||||
}
|
||||
|
||||
/// Apply an operation (both local and remote) to this local register CRDT.
|
||||
pub fn apply(&mut self, op: Op<JsonValue>) -> OpState {
|
||||
if !op.is_valid_hash() {
|
||||
return OpState::ErrHashMismatch;
|
||||
}
|
||||
|
||||
let op: Op<T> = op.into();
|
||||
let seq = op.sequence_num();
|
||||
|
||||
// take most recent update by sequence number
|
||||
match seq.cmp(&self.our_seq) {
|
||||
Ordering::Greater => {
|
||||
self.value = Op {
|
||||
id: self.value.id,
|
||||
..op
|
||||
};
|
||||
}
|
||||
Ordering::Equal => {
|
||||
// if we are equal, tie break on author
|
||||
if op.author() < self.value.author() {
|
||||
// we want to keep id constant so replace everything but id
|
||||
self.value = Op {
|
||||
id: self.value.id,
|
||||
..op
|
||||
};
|
||||
}
|
||||
}
|
||||
Ordering::Less => {} // LWW, ignore if its outdate
|
||||
};
|
||||
|
||||
// update bookkeeping
|
||||
self.our_seq = max(self.our_seq, seq);
|
||||
OpState::Ok
|
||||
}
|
||||
|
||||
fn view(&self) -> Option<T> {
|
||||
self.value.content.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CrdtNode for LwwRegisterCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn apply(&mut self, op: Op<JsonValue>) -> OpState {
|
||||
self.apply(op.into())
|
||||
}
|
||||
|
||||
fn view(&self) -> JsonValue {
|
||||
self.view().into()
|
||||
}
|
||||
|
||||
fn new(id: AuthorId, path: Vec<PathSegment>) -> Self {
|
||||
Self::new(id, path)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> DebugView for LwwRegisterCrdt<T>
|
||||
where
|
||||
T: CrdtNode + DebugView,
|
||||
{
|
||||
fn debug_view(&self, indent: usize) -> String {
|
||||
let spacing = " ".repeat(indent);
|
||||
let path_str = print_path(self.path.clone());
|
||||
let inner = self.value.debug_view(indent + 2);
|
||||
format!("LWW Register CRDT @ /{path_str}\n{spacing}{inner}")
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Debug for LwwRegisterCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::LwwRegisterCrdt;
|
||||
use crate::{json_crdt::OpState, keypair::make_author};
|
||||
|
||||
#[test]
|
||||
fn test_lww_simple() {
|
||||
let mut register = LwwRegisterCrdt::new(make_author(1), vec![]);
|
||||
assert_eq!(register.view(), None);
|
||||
register.set(1);
|
||||
assert_eq!(register.view(), Some(1));
|
||||
register.set(99);
|
||||
assert_eq!(register.view(), Some(99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lww_multiple_writer() {
|
||||
let mut register1 = LwwRegisterCrdt::new(make_author(1), vec![]);
|
||||
let mut register2 = LwwRegisterCrdt::new(make_author(2), vec![]);
|
||||
let _a = register1.set('a');
|
||||
let _b = register1.set('b');
|
||||
let _c = register2.set('c');
|
||||
assert_eq!(register2.view(), Some('c'));
|
||||
assert_eq!(register1.apply(_c), OpState::Ok);
|
||||
assert_eq!(register2.apply(_b), OpState::Ok);
|
||||
assert_eq!(register2.apply(_a), OpState::Ok);
|
||||
assert_eq!(register1.view(), Some('b'));
|
||||
assert_eq!(register2.view(), Some('b'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lww_idempotence() {
|
||||
let mut register = LwwRegisterCrdt::new(make_author(1), vec![]);
|
||||
let op = register.set(1);
|
||||
for _ in 1..10 {
|
||||
assert_eq!(register.apply(op.clone()), OpState::Ok);
|
||||
}
|
||||
assert_eq!(register.view(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lww_consistent_tiebreak() {
|
||||
let mut register1 = LwwRegisterCrdt::new(make_author(1), vec![]);
|
||||
let mut register2 = LwwRegisterCrdt::new(make_author(2), vec![]);
|
||||
let _a = register1.set('a');
|
||||
let _b = register2.set('b');
|
||||
assert_eq!(register1.apply(_b), OpState::Ok);
|
||||
assert_eq!(register2.apply(_a), OpState::Ok);
|
||||
let _c = register1.set('c');
|
||||
let _d = register2.set('d');
|
||||
assert_eq!(register2.apply(_c), OpState::Ok);
|
||||
assert_eq!(register1.apply(_d), OpState::Ok);
|
||||
assert_eq!(register1.view(), register2.view());
|
||||
assert_eq!(register1.view(), Some('c'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
use crate::debug::{debug_path_mismatch, debug_type_mismatch};
|
||||
use crate::json_crdt::{CrdtNode, CrdtNodeFromValue, IntoCrdtNode, JsonValue, SignedOp};
|
||||
use crate::keypair::{sha256, AuthorId};
|
||||
use fastcrypto::ed25519::Ed25519KeyPair;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Debug;
|
||||
|
||||
/// A lamport clock timestamp. Used to track document versions
|
||||
pub type SequenceNumber = u64;
|
||||
|
||||
/// A unique ID for a single [`Op<T>`]
|
||||
pub type OpId = [u8; 32];
|
||||
|
||||
/// The root/sentinel op
|
||||
pub const ROOT_ID: OpId = [0u8; 32];
|
||||
|
||||
/// Part of a path to get to a specific CRDT in a nested CRDT
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum PathSegment {
|
||||
Field(String),
|
||||
Index(OpId),
|
||||
}
|
||||
|
||||
/// Format a byte array as a hex string
|
||||
pub fn print_hex<const N: usize>(bytes: &[u8; N]) -> String {
|
||||
bytes
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
/// Pretty print a path
|
||||
pub fn print_path(path: Vec<PathSegment>) -> String {
|
||||
path.iter()
|
||||
.map(|p| match p {
|
||||
PathSegment::Field(s) => s.to_string(),
|
||||
PathSegment::Index(i) => print_hex(i)[..6].to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
|
||||
/// Ensure our_path is a subpath of op_path. Note that two identical paths are considered subpaths
|
||||
/// of each other.
|
||||
pub fn ensure_subpath(our_path: &Vec<PathSegment>, op_path: &Vec<PathSegment>) -> bool {
|
||||
// if our_path is longer, it cannot be a subpath
|
||||
if our_path.len() > op_path.len() {
|
||||
debug_path_mismatch(our_path.to_owned(), op_path.to_owned());
|
||||
return false;
|
||||
}
|
||||
|
||||
// iterate to end of our_path, ensuring each element is the same
|
||||
for i in 0..our_path.len() {
|
||||
let ours = our_path.get(i);
|
||||
let theirs = op_path.get(i);
|
||||
if ours != theirs {
|
||||
debug_path_mismatch(our_path.to_owned(), op_path.to_owned());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Helper to easily append a [`PathSegment`] to a path
|
||||
pub fn join_path(path: Vec<PathSegment>, segment: PathSegment) -> Vec<PathSegment> {
|
||||
let mut p = path;
|
||||
p.push(segment);
|
||||
p
|
||||
}
|
||||
|
||||
/// Parse out the field from a [`PathSegment`]
|
||||
pub fn parse_field(path: Vec<PathSegment>) -> Option<String> {
|
||||
path.last().and_then(|segment| {
|
||||
if let PathSegment::Field(key) = segment {
|
||||
Some(key.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Represents a single node in a CRDT
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct Op<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
pub origin: OpId,
|
||||
pub author: AuthorId, // pub key of author
|
||||
pub seq: SequenceNumber,
|
||||
pub content: Option<T>,
|
||||
pub path: Vec<PathSegment>, // path to get to target CRDT
|
||||
pub is_deleted: bool,
|
||||
pub id: OpId, // hash of the operation
|
||||
}
|
||||
|
||||
/// Something can be turned into a string. This allows us to use [`content`] as in
|
||||
/// input into the SHA256 hash
|
||||
pub trait Hashable {
|
||||
fn hash(&self) -> String;
|
||||
}
|
||||
|
||||
/// Anything that implements Debug is trivially hashable
|
||||
impl<T> Hashable for T
|
||||
where
|
||||
T: Debug,
|
||||
{
|
||||
fn hash(&self) -> String {
|
||||
format!("{self:?}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversion from Op<Value> -> Op<T> given that T is a CRDT that can be created from a JSON value
|
||||
impl Op<JsonValue> {
|
||||
pub fn into<T: CrdtNodeFromValue + CrdtNode>(self) -> Op<T> {
|
||||
let content = if let Some(inner_content) = self.content {
|
||||
match inner_content.into_node(self.id, self.path.clone()) {
|
||||
Ok(node) => Some(node),
|
||||
Err(msg) => {
|
||||
debug_type_mismatch(msg);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Op {
|
||||
content,
|
||||
origin: self.origin,
|
||||
author: self.author,
|
||||
seq: self.seq,
|
||||
path: self.path,
|
||||
is_deleted: self.is_deleted,
|
||||
id: self.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Op<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
pub fn sign(self, keypair: &Ed25519KeyPair) -> SignedOp {
|
||||
SignedOp::from_op(self, keypair, vec![])
|
||||
}
|
||||
|
||||
pub fn sign_with_dependencies(
|
||||
self,
|
||||
keypair: &Ed25519KeyPair,
|
||||
dependencies: Vec<&SignedOp>,
|
||||
) -> SignedOp {
|
||||
SignedOp::from_op(
|
||||
self,
|
||||
keypair,
|
||||
dependencies
|
||||
.iter()
|
||||
.map(|dep| dep.signed_digest)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn author(&self) -> AuthorId {
|
||||
self.author
|
||||
}
|
||||
|
||||
pub fn sequence_num(&self) -> SequenceNumber {
|
||||
self.seq
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
origin: OpId,
|
||||
author: AuthorId,
|
||||
seq: SequenceNumber,
|
||||
is_deleted: bool,
|
||||
content: Option<T>,
|
||||
path: Vec<PathSegment>,
|
||||
) -> Op<T> {
|
||||
let mut op = Self {
|
||||
origin,
|
||||
id: ROOT_ID,
|
||||
author,
|
||||
seq,
|
||||
is_deleted,
|
||||
content,
|
||||
path,
|
||||
};
|
||||
op.id = op.hash_to_id();
|
||||
op
|
||||
}
|
||||
|
||||
/// Generate OpID by hashing our contents. Hash includes
|
||||
/// - content
|
||||
/// - origin
|
||||
/// - author
|
||||
/// - seq
|
||||
/// - is_deleted
|
||||
pub fn hash_to_id(&self) -> OpId {
|
||||
let content_str = match self.content.as_ref() {
|
||||
Some(content) => content.hash(),
|
||||
None => "".to_string(),
|
||||
};
|
||||
let fmt_str = format!(
|
||||
"{:?},{:?},{:?},{:?},{content_str}",
|
||||
self.origin, self.author, self.seq, self.is_deleted,
|
||||
);
|
||||
sha256(fmt_str)
|
||||
}
|
||||
|
||||
/// Rehashes the contents to make sure it matches the ID
|
||||
pub fn is_valid_hash(&self) -> bool {
|
||||
// make sure content is only none for deletion events
|
||||
if self.content.is_none() && !self.is_deleted {
|
||||
return false;
|
||||
}
|
||||
|
||||
// try to avoid expensive sig check if early fail
|
||||
let res = self.hash_to_id() == self.id;
|
||||
if !res {
|
||||
self.debug_hash_failure();
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// Special constructor for defining the sentinel root node
|
||||
pub fn make_root() -> Op<T> {
|
||||
Self {
|
||||
origin: ROOT_ID,
|
||||
id: ROOT_ID,
|
||||
author: [0u8; 32],
|
||||
seq: 0,
|
||||
is_deleted: false,
|
||||
content: None,
|
||||
path: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use bft_json_crdt::{
|
||||
json_crdt::{add_crdt_fields, BaseCrdt, CrdtNode, IntoCrdtNode, OpState},
|
||||
keypair::make_keypair,
|
||||
list_crdt::ListCrdt,
|
||||
lww_crdt::LwwRegisterCrdt,
|
||||
op::{Op, PathSegment, ROOT_ID},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
// What is potentially Byzantine behaviour?
|
||||
// 1. send valid updates
|
||||
// 2. send a mix of valid and invalid updates
|
||||
// a) messages with duplicate ID (attempt to overwrite old entries)
|
||||
// b) send incorrect sequence number to multiple nodes (which could lead to divergent state) -- this is called equivocation
|
||||
// c) ‘forge’ updates from another author (could happen when forwarding valid messages from peers)
|
||||
// 3. send malformed updates (e.g. missing fields)
|
||||
// this we don't test as we assume transport layer only allows valid messages
|
||||
// 4. overwhelm message queue by sending many updates far into the future
|
||||
// also untested! currently we keep an unbounded message queue
|
||||
// 5. block actual messages from honest actors (eclipse attack)
|
||||
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct ListExample {
|
||||
list: ListCrdt<char>,
|
||||
}
|
||||
|
||||
// case 2a + 2b
|
||||
#[test]
|
||||
fn test_equivocation() {
|
||||
let key = make_keypair();
|
||||
let test_key = make_keypair();
|
||||
let mut crdt = BaseCrdt::<ListExample>::new(&key);
|
||||
let mut test_crdt = BaseCrdt::<ListExample>::new(&test_key);
|
||||
let _a = crdt.doc.list.insert(ROOT_ID, 'a').sign(&key);
|
||||
let _b = crdt.doc.list.insert(_a.id(), 'b').sign(&key);
|
||||
|
||||
// make a fake operation with same id as _b but different content
|
||||
let mut fake_op = _b.clone();
|
||||
fake_op.inner.content = Some('c'.into());
|
||||
|
||||
// also try modifying the sequence number
|
||||
let mut fake_op_seq = _b.clone();
|
||||
fake_op_seq.inner.seq = 99;
|
||||
fake_op_seq.inner.is_deleted = true;
|
||||
|
||||
assert_eq!(crdt.apply(fake_op.clone()), OpState::ErrHashMismatch);
|
||||
assert_eq!(crdt.apply(fake_op_seq.clone()), OpState::ErrHashMismatch);
|
||||
|
||||
assert_eq!(test_crdt.apply(fake_op_seq), OpState::ErrHashMismatch);
|
||||
assert_eq!(test_crdt.apply(fake_op), OpState::ErrHashMismatch);
|
||||
assert_eq!(test_crdt.apply(_a), OpState::Ok);
|
||||
assert_eq!(test_crdt.apply(_b), OpState::Ok);
|
||||
|
||||
// make sure it doesn't accept either of the fake operations
|
||||
assert_eq!(crdt.doc.list.view(), vec!['a', 'b']);
|
||||
assert_eq!(crdt.doc.list.view(), test_crdt.doc.list.view());
|
||||
}
|
||||
|
||||
// case 2c
|
||||
#[test]
|
||||
fn test_forge_update() {
|
||||
let key = make_keypair();
|
||||
let test_key = make_keypair();
|
||||
let mut crdt = BaseCrdt::<ListExample>::new(&key);
|
||||
let mut test_crdt = BaseCrdt::<ListExample>::new(&test_key);
|
||||
let _a = crdt.doc.list.insert(ROOT_ID, 'a').sign(&key);
|
||||
|
||||
let fake_key = make_keypair(); // generate a new keypair as we don't have private key of list.our_id
|
||||
let mut op = Op {
|
||||
origin: _a.inner.id,
|
||||
author: crdt.doc.id, // pretend to be the owner of list
|
||||
content: Some('b'),
|
||||
path: vec![PathSegment::Field("list".to_string())],
|
||||
seq: 1,
|
||||
is_deleted: false,
|
||||
id: ROOT_ID, // placeholder, to be generated
|
||||
};
|
||||
|
||||
// this is a completely valid hash and digest, just signed by the wrong person
|
||||
// as keypair.public != list.public
|
||||
op.id = op.hash_to_id();
|
||||
let signed = op.sign(&fake_key);
|
||||
|
||||
assert_eq!(crdt.apply(signed.clone()), OpState::ErrHashMismatch);
|
||||
assert_eq!(test_crdt.apply(signed), OpState::ErrHashMismatch);
|
||||
assert_eq!(test_crdt.apply(_a), OpState::Ok);
|
||||
|
||||
// make sure it doesn't accept fake operation
|
||||
assert_eq!(crdt.doc.list.view(), vec!['a']);
|
||||
}
|
||||
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Nested {
|
||||
a: Nested2,
|
||||
}
|
||||
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Nested2 {
|
||||
b: LwwRegisterCrdt<bool>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_update() {
|
||||
let key = make_keypair();
|
||||
let test_key = make_keypair();
|
||||
let mut crdt = BaseCrdt::<Nested>::new(&key);
|
||||
let mut test_crdt = BaseCrdt::<Nested>::new(&test_key);
|
||||
let mut _true = crdt.doc.a.b.set(true);
|
||||
_true.path = vec![PathSegment::Field("x".to_string())];
|
||||
let mut _false = crdt.doc.a.b.set(false);
|
||||
_false.path = vec![
|
||||
PathSegment::Field("a".to_string()),
|
||||
PathSegment::Index(_false.id),
|
||||
];
|
||||
|
||||
let signed_true = _true.sign(&key);
|
||||
let signed_false = _false.sign(&key);
|
||||
let mut signed_false_fake_path = signed_false.clone();
|
||||
signed_false_fake_path.inner.path = vec![
|
||||
PathSegment::Field("a".to_string()),
|
||||
PathSegment::Field("b".to_string()),
|
||||
];
|
||||
|
||||
assert_eq!(test_crdt.apply(signed_true), OpState::ErrPathMismatch);
|
||||
assert_eq!(test_crdt.apply(signed_false), OpState::ErrPathMismatch);
|
||||
assert_eq!(
|
||||
test_crdt.apply(signed_false_fake_path),
|
||||
OpState::ErrDigestMismatch
|
||||
);
|
||||
|
||||
// make sure it doesn't accept fake operation
|
||||
assert_eq!(crdt.doc.a.b.view(), json!(false).into());
|
||||
assert_eq!(test_crdt.doc.a.b.view(), json!(null).into());
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use bft_json_crdt::{
|
||||
json_crdt::{CrdtNode, JsonValue},
|
||||
keypair::make_author,
|
||||
list_crdt::ListCrdt,
|
||||
op::{Op, OpId, ROOT_ID},
|
||||
};
|
||||
use rand::{rngs::ThreadRng, seq::SliceRandom, Rng};
|
||||
|
||||
fn random_op<T: CrdtNode>(arr: &[Op<T>], rng: &mut ThreadRng) -> OpId {
|
||||
arr.choose(rng).map(|op| op.id).unwrap_or(ROOT_ID)
|
||||
}
|
||||
|
||||
const TEST_N: usize = 100;
|
||||
|
||||
#[test]
|
||||
fn test_list_fuzz_commutative() {
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut op_log = Vec::<Op<JsonValue>>::new();
|
||||
let mut op_log1 = Vec::<Op<JsonValue>>::new();
|
||||
let mut op_log2 = Vec::<Op<JsonValue>>::new();
|
||||
let mut l1 = ListCrdt::<char>::new(make_author(1), vec![]);
|
||||
let mut l2 = ListCrdt::<char>::new(make_author(2), vec![]);
|
||||
let mut chk = ListCrdt::<char>::new(make_author(3), vec![]);
|
||||
for _ in 0..TEST_N {
|
||||
let letter1: char = rng.gen_range(b'a'..=b'z') as char;
|
||||
let letter2: char = rng.gen_range(b'a'..=b'z') as char;
|
||||
let op1 = if rng.gen_bool(4.0 / 5.0) {
|
||||
l1.insert(random_op(&op_log1, &mut rng), letter1)
|
||||
} else {
|
||||
l1.delete(random_op(&op_log1, &mut rng))
|
||||
};
|
||||
let op2 = if rng.gen_bool(4.0 / 5.0) {
|
||||
l2.insert(random_op(&op_log2, &mut rng), letter2)
|
||||
} else {
|
||||
l2.delete(random_op(&op_log2, &mut rng))
|
||||
};
|
||||
op_log1.push(op1.clone());
|
||||
op_log2.push(op2.clone());
|
||||
op_log.push(op1.clone());
|
||||
op_log.push(op2.clone());
|
||||
}
|
||||
|
||||
// shuffle ops
|
||||
op_log1.shuffle(&mut rng);
|
||||
op_log2.shuffle(&mut rng);
|
||||
|
||||
// apply to each other
|
||||
for op in op_log1 {
|
||||
l2.apply(op.clone());
|
||||
chk.apply(op.into());
|
||||
}
|
||||
for op in op_log2 {
|
||||
l1.apply(op.clone());
|
||||
chk.apply(op);
|
||||
}
|
||||
|
||||
// ensure all equal
|
||||
let l1_doc = l1.view();
|
||||
let l2_doc = l2.view();
|
||||
let chk_doc = chk.view();
|
||||
assert_eq!(l1_doc, l2_doc);
|
||||
assert_eq!(l1_doc, chk_doc);
|
||||
assert_eq!(l2_doc, chk_doc);
|
||||
|
||||
// now, allow cross mixing between both
|
||||
let mut op_log1 = Vec::<Op<JsonValue>>::new();
|
||||
let mut op_log2 = Vec::<Op<JsonValue>>::new();
|
||||
for _ in 0..TEST_N {
|
||||
let letter1: char = rng.gen_range(b'a'..=b'z') as char;
|
||||
let letter2: char = rng.gen_range(b'a'..=b'z') as char;
|
||||
let op1 = l1.insert(random_op(&op_log, &mut rng), letter1);
|
||||
let op2 = l2.insert(random_op(&op_log, &mut rng), letter2);
|
||||
op_log1.push(op1);
|
||||
op_log2.push(op2);
|
||||
}
|
||||
|
||||
for op in op_log1 {
|
||||
l2.apply(op.clone());
|
||||
chk.apply(op);
|
||||
}
|
||||
for op in op_log2 {
|
||||
l1.apply(op.clone());
|
||||
chk.apply(op);
|
||||
}
|
||||
|
||||
let l1_doc = l1.view();
|
||||
let l2_doc = l2.view();
|
||||
let chk_doc = chk.view();
|
||||
assert_eq!(l1_doc, l2_doc);
|
||||
assert_eq!(l1_doc, chk_doc);
|
||||
assert_eq!(l2_doc, chk_doc);
|
||||
}
|
||||
+259883
File diff suppressed because one or more lines are too long
Generated
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,79 @@
|
||||
use bft_json_crdt::keypair::make_author;
|
||||
use bft_json_crdt::list_crdt::ListCrdt;
|
||||
use bft_json_crdt::op::{OpId, ROOT_ID};
|
||||
use std::{fs::File, io::Read};
|
||||
use time::PreciseTime;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Edit {
|
||||
pos: usize,
|
||||
delete: bool,
|
||||
#[serde(default)]
|
||||
content: Option<char>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Trace {
|
||||
final_text: String,
|
||||
edits: Vec<Edit>,
|
||||
}
|
||||
|
||||
fn get_trace() -> Trace {
|
||||
let fp = "./tests/edits.json";
|
||||
match File::open(fp) {
|
||||
Err(e) => panic!("Open edits.json failed: {:?}", e.kind()),
|
||||
Ok(mut file) => {
|
||||
let mut content: String = String::new();
|
||||
file.read_to_string(&mut content)
|
||||
.expect("Problem reading file");
|
||||
serde_json::from_str(&content).expect("JSON was not well-formatted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Really large test to run Martin Kleppmann's
|
||||
/// editing trace over his paper
|
||||
/// Data source: https://github.com/automerge/automerge-perf
|
||||
// Commented out: takes 10+ minutes and 12GB+ RAM. Run manually with:
|
||||
// cargo test --package bft-json-crdt --test kleppmann_trace -- --ignored
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_editing_trace() {
|
||||
let t = get_trace();
|
||||
let mut list = ListCrdt::<char>::new(make_author(1), vec![]);
|
||||
let mut ops: Vec<OpId> = Vec::new();
|
||||
ops.push(ROOT_ID);
|
||||
let start = PreciseTime::now();
|
||||
let edits = t.edits;
|
||||
for (i, op) in edits.into_iter().enumerate() {
|
||||
let origin = ops[op.pos];
|
||||
if op.delete {
|
||||
let delete_op = list.delete(origin);
|
||||
ops.push(delete_op.id);
|
||||
} else {
|
||||
let new_op = list.insert(origin, op.content.unwrap());
|
||||
ops.push(new_op.id);
|
||||
}
|
||||
|
||||
match i {
|
||||
10_000 | 100_000 => {
|
||||
let end = PreciseTime::now();
|
||||
let runtime_sec = start.to(end);
|
||||
println!("took {runtime_sec:?} to run {i} ops");
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
|
||||
let end = PreciseTime::now();
|
||||
let runtime_sec = start.to(end);
|
||||
println!("took {runtime_sec:?} to finish");
|
||||
let result = list.iter().collect::<String>();
|
||||
let expected = t.final_text;
|
||||
assert_eq!(result.len(), expected.len());
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
+4
-3
@@ -9,8 +9,8 @@
|
||||
|
||||
FROM rust:1.90-bookworm AS base
|
||||
|
||||
# Clippy is needed at runtime for acceptance gates (cargo clippy)
|
||||
RUN rustup component add clippy
|
||||
# Clippy and rustfmt are needed at runtime for acceptance gates
|
||||
RUN rustup component add clippy rustfmt
|
||||
|
||||
# ── System deps ──────────────────────────────────────────────────────
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
@@ -109,7 +109,8 @@ RUN groupadd -r huskies \
|
||||
&& chown -R huskies:huskies /usr/local/cargo /usr/local/rustup \
|
||||
&& chown -R huskies:huskies /app \
|
||||
&& 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 ───────────────────────────────────────────────────────
|
||||
# Validates required env vars (GIT_USER_NAME, GIT_USER_EMAIL) and
|
||||
|
||||
@@ -69,6 +69,16 @@ services:
|
||||
- workspace-target:/workspace/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 ──────────────────────────────────────────
|
||||
# Read-only root filesystem. Only explicitly mounted volumes and
|
||||
# tmpfs paths are writable.
|
||||
@@ -108,6 +118,14 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Log rotation – test output goes to stdout via Stdio::inherit,
|
||||
# so container logs grow fast without rotation.
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
# Use tini as PID 1 to reap zombie child processes.
|
||||
# Without this, grandchild processes (esbuild, cargo, etc.) spawned by
|
||||
# npm/cargo during worktree setup and gate checks become zombies.
|
||||
@@ -122,3 +140,4 @@ volumes:
|
||||
claude-state:
|
||||
workspace-target:
|
||||
huskies-target:
|
||||
frontend-modules:
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/huskies.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Huskies</title>
|
||||
</head>
|
||||
|
||||
Generated
+4
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "living-spec-standalone",
|
||||
"version": "0.9.0",
|
||||
"name": "huskies",
|
||||
"version": "0.10.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "living-spec-standalone",
|
||||
"version": "0.9.0",
|
||||
"name": "huskies",
|
||||
"version": "0.10.2",
|
||||
"dependencies": {
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "living-spec-standalone",
|
||||
"name": "huskies",
|
||||
"private": true,
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Left ear -->
|
||||
<polygon points="5,14 8,4 13,13" fill="#4b5563"/>
|
||||
<polygon points="6.5,12.5 8.5,6 12,12" fill="#9ca3af"/>
|
||||
<!-- Right ear -->
|
||||
<polygon points="27,14 24,4 19,13" fill="#4b5563"/>
|
||||
<polygon points="25.5,12.5 23.5,6 20,12" fill="#9ca3af"/>
|
||||
<!-- Head -->
|
||||
<circle cx="16" cy="18" r="12" fill="#6b7280"/>
|
||||
<!-- White face mask -->
|
||||
<ellipse cx="16" cy="21" rx="8" ry="7" fill="#f9fafb"/>
|
||||
<!-- Left eye white -->
|
||||
<circle cx="12" cy="16" r="3" fill="white"/>
|
||||
<!-- Left eye iris - blue (husky trait) -->
|
||||
<circle cx="12.3" cy="16" r="2" fill="#3b82f6"/>
|
||||
<!-- Left eye pupil -->
|
||||
<circle cx="12.3" cy="16" r="1" fill="#111827"/>
|
||||
<!-- Left eye highlight -->
|
||||
<circle cx="11.7" cy="15.3" r="0.5" fill="white"/>
|
||||
<!-- Right eye white -->
|
||||
<circle cx="20" cy="16" r="3" fill="white"/>
|
||||
<!-- Right eye iris - blue -->
|
||||
<circle cx="20.3" cy="16" r="2" fill="#3b82f6"/>
|
||||
<!-- Right eye pupil -->
|
||||
<circle cx="20.3" cy="16" r="1" fill="#111827"/>
|
||||
<!-- Right eye highlight -->
|
||||
<circle cx="19.7" cy="15.3" r="0.5" fill="white"/>
|
||||
<!-- Nose -->
|
||||
<ellipse cx="16" cy="22" rx="2.5" ry="1.8" fill="#1f2937"/>
|
||||
<!-- Nose highlight -->
|
||||
<ellipse cx="15.3" cy="21.3" rx="0.7" ry="0.5" fill="#6b7280"/>
|
||||
<!-- Mouth line -->
|
||||
<path d="M16,23.5 Q14,25 13,24.5" stroke="#9ca3af" stroke-width="0.6" fill="none" stroke-linecap="round"/>
|
||||
<path d="M16,23.5 Q18,25 19,24.5" stroke="#9ca3af" stroke-width="0.6" fill="none" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -345,6 +345,59 @@ describe("App", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sets document title to Huskies when no project is open", async () => {
|
||||
mockedApi.getCurrentProject.mockResolvedValue(null);
|
||||
|
||||
await renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe("Huskies");
|
||||
});
|
||||
});
|
||||
|
||||
it("sets document title to project name when a project is open", async () => {
|
||||
mockedApi.getCurrentProject.mockResolvedValue("/home/user/reclaimer");
|
||||
|
||||
await renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe("reclaimer | Huskies");
|
||||
});
|
||||
});
|
||||
|
||||
it("resets document title to Huskies after closing project", async () => {
|
||||
mockedApi.openProject.mockResolvedValue("/home/user/myproject");
|
||||
mockedApi.closeProject.mockResolvedValue(true);
|
||||
|
||||
await renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText(/\/path\/to\/project/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
/\/path\/to\/project/i,
|
||||
) as HTMLInputElement;
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "/home/user/myproject");
|
||||
|
||||
const openButton = screen.getByRole("button", { name: /open project/i });
|
||||
await userEvent.click(openButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe("myproject | Huskies");
|
||||
});
|
||||
|
||||
const closeButton = await waitFor(() => screen.getByText("✕"));
|
||||
await userEvent.click(closeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe("Huskies");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles Enter key to trigger project open", async () => {
|
||||
mockedApi.openProject.mockResolvedValue("/home/user/myproject");
|
||||
|
||||
|
||||
+29
-1
@@ -2,11 +2,14 @@ import * as React from "react";
|
||||
import type { OAuthStatus } from "./api/client";
|
||||
import { api } from "./api/client";
|
||||
import { Chat } from "./components/Chat";
|
||||
import { GatewayPanel } from "./components/GatewayPanel";
|
||||
import { SelectionScreen } from "./components/selection/SelectionScreen";
|
||||
import { usePathCompletion } from "./components/selection/usePathCompletion";
|
||||
import { gatewayApi } from "./api/gateway";
|
||||
import "./App.css";
|
||||
|
||||
function App() {
|
||||
const [isGateway, setIsGateway] = React.useState<boolean | null>(null);
|
||||
const [projectPath, setProjectPath] = React.useState<string | null>(null);
|
||||
const [_view, setView] = React.useState<"chat" | "token-usage">("chat");
|
||||
const [isCheckingProject, setIsCheckingProject] = React.useState(true);
|
||||
@@ -19,6 +22,14 @@ function App() {
|
||||
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(() => {
|
||||
let active = true;
|
||||
function fetchOAuthStatus() {
|
||||
@@ -51,6 +62,17 @@ function App() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (projectPath) {
|
||||
const projectName =
|
||||
projectPath.replace(/\\/g, "/").split("/").filter(Boolean).pop() ??
|
||||
projectPath;
|
||||
document.title = `${projectName} | Huskies`;
|
||||
} else {
|
||||
document.title = "Huskies";
|
||||
}
|
||||
}, [projectPath]);
|
||||
|
||||
React.useEffect(() => {
|
||||
api
|
||||
.getKnownProjects()
|
||||
@@ -177,10 +199,16 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
if (isCheckingProject) {
|
||||
// Still probing server mode — wait before rendering.
|
||||
if (isGateway === null || isCheckingProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Gateway mode: render the agent management UI instead of the normal chat.
|
||||
if (isGateway) {
|
||||
return <GatewayPanel />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
className="container"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export interface BotConfig {
|
||||
transport: string | null;
|
||||
enabled: boolean | null;
|
||||
homeserver: string | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
room_ids: string[] | null;
|
||||
slack_bot_token: string | null;
|
||||
slack_signing_secret: string | null;
|
||||
slack_channel_ids: string[] | null;
|
||||
}
|
||||
|
||||
const DEFAULT_API_BASE = "/api";
|
||||
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
baseUrl = DEFAULT_API_BASE,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
headers: { "Content-Type": "application/json", ...(options.headers ?? {}) },
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Request failed (${res.status})`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const botConfigApi = {
|
||||
getConfig(baseUrl?: string): Promise<BotConfig> {
|
||||
return requestJson<BotConfig>("/bot/config", {}, baseUrl);
|
||||
},
|
||||
|
||||
saveConfig(config: BotConfig, baseUrl?: string): Promise<BotConfig> {
|
||||
return requestJson<BotConfig>(
|
||||
"/bot/config",
|
||||
{ method: "PUT", body: JSON.stringify(config) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -48,6 +48,7 @@ export interface PipelineStageItem {
|
||||
agent: AgentAssignment | null;
|
||||
review_hold: boolean | null;
|
||||
qa: string | null;
|
||||
depends_on: number[] | null;
|
||||
}
|
||||
|
||||
export interface PipelineState {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/// 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;
|
||||
/// Unix timestamp of the last heartbeat from this agent.
|
||||
last_seen: 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 PipelineItem {
|
||||
story_id: string;
|
||||
name: string;
|
||||
stage: string;
|
||||
agent?: { agent_name: string; model: string; status: string } | null;
|
||||
blocked?: boolean;
|
||||
retry_count?: number;
|
||||
merge_failure?: string;
|
||||
}
|
||||
|
||||
export interface ProjectPipelineStatus {
|
||||
active: PipelineItem[];
|
||||
backlog: { story_id: string; name: string }[];
|
||||
backlog_count: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AllProjectsPipeline {
|
||||
active: string;
|
||||
projects: Record<string, ProjectPipelineStatus>;
|
||||
}
|
||||
|
||||
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");
|
||||
},
|
||||
|
||||
/// Add a new project to the gateway config.
|
||||
addProject(name: string, url: string): Promise<GatewayProject> {
|
||||
return gatewayRequest<GatewayProject>("/api/gateway/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, url }),
|
||||
});
|
||||
},
|
||||
|
||||
/// Remove a project from the gateway config.
|
||||
removeProject(name: string): Promise<void> {
|
||||
return gatewayRequest<void>(
|
||||
`/api/gateway/projects/${encodeURIComponent(name)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
},
|
||||
|
||||
/// Send a heartbeat for an agent to update its last-seen timestamp.
|
||||
heartbeat(id: string): Promise<void> {
|
||||
return gatewayRequest<void>(`/gateway/agents/${id}/heartbeat`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
/// Fetch pipeline status from all registered projects.
|
||||
getAllProjectsPipeline(): Promise<AllProjectsPipeline> {
|
||||
return gatewayRequest<AllProjectsPipeline>("/api/gateway/pipeline");
|
||||
},
|
||||
|
||||
/// Switch the active project.
|
||||
switchProject(project: string): Promise<{ ok: boolean; error?: string }> {
|
||||
return gatewayRequest<{ ok: boolean; error?: string }>(
|
||||
"/api/gateway/switch",
|
||||
{ method: "POST", body: JSON.stringify({ project }) },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
interface ApiKeyDialogProps {
|
||||
apiKeyInput: string;
|
||||
onApiKeyChange: (value: string) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ApiKeyDialog({
|
||||
apiKeyInput,
|
||||
onApiKeyChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: ApiKeyDialogProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#2f2f2f",
|
||||
padding: "32px",
|
||||
borderRadius: "12px",
|
||||
maxWidth: "500px",
|
||||
width: "90%",
|
||||
border: "1px solid #444",
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginTop: 0, color: "#ececec" }}>
|
||||
Enter Anthropic API Key
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
color: "#aaa",
|
||||
fontSize: "0.9em",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
To use Claude models, please enter your Anthropic API key. Your key
|
||||
will be stored server-side and reused across sessions.
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => onApiKeyChange(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && onSave()}
|
||||
placeholder="sk-ant-..."
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #555",
|
||||
backgroundColor: "#1a1a1a",
|
||||
color: "#ececec",
|
||||
fontSize: "1em",
|
||||
marginBottom: "20px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "12px",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #555",
|
||||
backgroundColor: "transparent",
|
||||
color: "#aaa",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9em",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={!apiKeyInput.trim()}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
borderRadius: "8px",
|
||||
border: "none",
|
||||
backgroundColor: apiKeyInput.trim() ? "#ececec" : "#555",
|
||||
color: apiKeyInput.trim() ? "#000" : "#888",
|
||||
cursor: apiKeyInput.trim() ? "pointer" : "not-allowed",
|
||||
fontSize: "0.9em",
|
||||
}}
|
||||
>
|
||||
Save Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import * as React from "react";
|
||||
import type { BotConfig } from "../api/bot_config";
|
||||
import { botConfigApi } from "../api/bot_config";
|
||||
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
interface BotConfigPageProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const fieldStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: "0.8em",
|
||||
color: "#aaa",
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: "8px 10px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #333",
|
||||
background: "#1e1e1e",
|
||||
color: "#ececec",
|
||||
fontSize: "0.9em",
|
||||
fontFamily: "monospace",
|
||||
outline: "none",
|
||||
};
|
||||
|
||||
const sectionStyle: React.CSSProperties = {
|
||||
background: "#1e1e1e",
|
||||
border: "1px solid #333",
|
||||
borderRadius: "8px",
|
||||
padding: "20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "14px",
|
||||
};
|
||||
|
||||
const sectionTitleStyle: React.CSSProperties = {
|
||||
fontSize: "0.85em",
|
||||
fontWeight: 600,
|
||||
color: "#aaa",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
marginBottom: "2px",
|
||||
};
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
type?: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>{label}</label>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
style={inputStyle}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>{label} (one per line)</label>
|
||||
<textarea
|
||||
value={value.join("\n")}
|
||||
onChange={(e) =>
|
||||
onChange(e.target.value.split("\n").filter((s) => s.trim()))
|
||||
}
|
||||
placeholder={placeholder}
|
||||
rows={3}
|
||||
style={{ ...inputStyle, resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/// Bot configuration page — form for Matrix and Slack credentials.
|
||||
export function BotConfigPage({ onBack }: BotConfigPageProps) {
|
||||
const [transport, setTransport] = useState<"matrix" | "slack">("matrix");
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [homeserver, setHomeserver] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [roomIds, setRoomIds] = useState<string[]>([]);
|
||||
const [slackBotToken, setSlackBotToken] = useState("");
|
||||
const [slackSigningSecret, setSlackSigningSecret] = useState("");
|
||||
const [slackChannelIds, setSlackChannelIds] = useState<string[]>([]);
|
||||
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">(
|
||||
"idle",
|
||||
);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
botConfigApi
|
||||
.getConfig()
|
||||
.then((cfg) => {
|
||||
if (cfg.transport === "slack") setTransport("slack");
|
||||
setEnabled(cfg.enabled ?? false);
|
||||
setHomeserver(cfg.homeserver ?? "");
|
||||
setUsername(cfg.username ?? "");
|
||||
setPassword(cfg.password ?? "");
|
||||
setRoomIds(cfg.room_ids ?? []);
|
||||
setSlackBotToken(cfg.slack_bot_token ?? "");
|
||||
setSlackSigningSecret(cfg.slack_signing_secret ?? "");
|
||||
setSlackChannelIds(cfg.slack_channel_ids ?? []);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function buildConfig(): BotConfig {
|
||||
return {
|
||||
transport,
|
||||
enabled,
|
||||
homeserver: homeserver || null,
|
||||
username: username || null,
|
||||
password: password || null,
|
||||
room_ids: roomIds.length > 0 ? roomIds : null,
|
||||
slack_bot_token: slackBotToken || null,
|
||||
slack_signing_secret: slackSigningSecret || null,
|
||||
slack_channel_ids: slackChannelIds.length > 0 ? slackChannelIds : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setStatus("saving");
|
||||
setErrorMsg(null);
|
||||
try {
|
||||
await botConfigApi.saveConfig(buildConfig());
|
||||
setStatus("saved");
|
||||
setTimeout(() => setStatus("idle"), 2000);
|
||||
} catch (e) {
|
||||
setStatus("error");
|
||||
setErrorMsg(e instanceof Error ? e.message : "Save failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
backgroundColor: "#171717",
|
||||
color: "#ececec",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 24px",
|
||||
borderBottom: "1px solid #333",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "16px",
|
||||
background: "#171717",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "#888",
|
||||
fontSize: "0.9em",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span style={{ fontWeight: 700, fontSize: "1em" }}>
|
||||
Bot Configuration
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "24px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "20px",
|
||||
maxWidth: "600px",
|
||||
}}
|
||||
>
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>General</div>
|
||||
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>Transport</label>
|
||||
<select
|
||||
value={transport}
|
||||
onChange={(e) =>
|
||||
setTransport(e.target.value as "matrix" | "slack")
|
||||
}
|
||||
style={{ ...inputStyle, cursor: "pointer" }}
|
||||
>
|
||||
<option value="matrix">Matrix</option>
|
||||
<option value="slack">Slack</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9em",
|
||||
color: "#ccc",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{transport === "matrix" && (
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Matrix Credentials</div>
|
||||
<Field
|
||||
label="Homeserver"
|
||||
value={homeserver}
|
||||
onChange={setHomeserver}
|
||||
placeholder="https://matrix.example.com"
|
||||
/>
|
||||
<Field
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={setUsername}
|
||||
placeholder="@botname:example.com"
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder="bot password"
|
||||
type="password"
|
||||
/>
|
||||
<ListField
|
||||
label="Room IDs"
|
||||
value={roomIds}
|
||||
onChange={setRoomIds}
|
||||
placeholder="!roomid:example.com"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transport === "slack" && (
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Slack Credentials</div>
|
||||
<Field
|
||||
label="Bot Token"
|
||||
value={slackBotToken}
|
||||
onChange={setSlackBotToken}
|
||||
placeholder="xoxb-..."
|
||||
/>
|
||||
<Field
|
||||
label="Signing Secret"
|
||||
value={slackSigningSecret}
|
||||
onChange={setSlackSigningSecret}
|
||||
placeholder="signing secret"
|
||||
type="password"
|
||||
/>
|
||||
<ListField
|
||||
label="Channel IDs"
|
||||
value={slackChannelIds}
|
||||
onChange={setSlackChannelIds}
|
||||
placeholder="C01ABCDEF"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={status === "saving"}
|
||||
style={{
|
||||
padding: "8px 24px",
|
||||
borderRadius: "6px",
|
||||
border: "none",
|
||||
background: status === "saved" ? "#1a5c2a" : "#2563eb",
|
||||
color: "#fff",
|
||||
cursor: status === "saving" ? "not-allowed" : "pointer",
|
||||
fontSize: "0.9em",
|
||||
fontWeight: 600,
|
||||
opacity: status === "saving" ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{status === "saving"
|
||||
? "Saving..."
|
||||
: status === "saved"
|
||||
? "Saved!"
|
||||
: "Save"}
|
||||
</button>
|
||||
{status === "error" && errorMsg && (
|
||||
<span style={{ color: "#f08080", fontSize: "0.85em" }}>
|
||||
{errorMsg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+201
-1229
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,7 @@ interface ChatHeaderProps {
|
||||
onToggleTools: (enabled: boolean) => void;
|
||||
wsConnected: boolean;
|
||||
oauthStatus?: OAuthStatus | null;
|
||||
onShowBotConfig?: () => void;
|
||||
}
|
||||
|
||||
const getContextEmoji = (percentage: number): string => {
|
||||
@@ -58,6 +59,7 @@ export function ChatHeader({
|
||||
onToggleTools,
|
||||
wsConnected,
|
||||
oauthStatus = null,
|
||||
onShowBotConfig,
|
||||
}: ChatHeaderProps) {
|
||||
const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0;
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
@@ -513,6 +515,43 @@ export function ChatHeader({
|
||||
🔄 New Session
|
||||
</button>
|
||||
|
||||
{onShowBotConfig && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowBotConfig}
|
||||
title="Configure bot credentials"
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: "99px",
|
||||
border: "none",
|
||||
fontSize: "0.85em",
|
||||
backgroundColor: "#2f2f2f",
|
||||
color: "#888",
|
||||
cursor: "pointer",
|
||||
outline: "none",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#3f3f3f";
|
||||
e.currentTarget.style.color = "#ccc";
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#2f2f2f";
|
||||
e.currentTarget.style.color = "#888";
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#3f3f3f";
|
||||
e.currentTarget.style.color = "#ccc";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#2f2f2f";
|
||||
e.currentTarget.style.color = "#888";
|
||||
}}
|
||||
>
|
||||
⚙ Bot
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasModelOptions ? (
|
||||
<select
|
||||
value={model}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import * as React from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import type { WizardStateData } from "../api/client";
|
||||
import type { Message } from "../types";
|
||||
import { MessageItem } from "./MessageItem";
|
||||
import SetupWizard from "./SetupWizard";
|
||||
|
||||
const { useEffect, useRef } = React;
|
||||
|
||||
/** Fixed-height thinking trace block that auto-scrolls to bottom as text arrives. */
|
||||
export function ThinkingBlock({ text }: { text: string }) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="chat-thinking-block"
|
||||
ref={scrollRef}
|
||||
style={{
|
||||
maxHeight: "96px",
|
||||
overflowY: "auto",
|
||||
background: "#161b22",
|
||||
border: "1px solid #2d333b",
|
||||
borderRadius: "6px",
|
||||
padding: "6px 10px",
|
||||
fontSize: "0.78em",
|
||||
fontFamily: "monospace",
|
||||
color: "#6e7681",
|
||||
fontStyle: "italic",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
lineHeight: "1.4",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "0.8em",
|
||||
color: "#444c56",
|
||||
marginBottom: "4px",
|
||||
fontStyle: "normal",
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
thinking
|
||||
</span>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Streaming message renderer — stable component to avoid recreation on each render. */
|
||||
export function StreamingMessage({ content }: { content: string }) {
|
||||
return (
|
||||
<Markdown
|
||||
components={{
|
||||
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
|
||||
code: ({ className, children, ...props }: any) => {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
const isInline = !className;
|
||||
return !isInline && match ? (
|
||||
<SyntaxHighlighter
|
||||
// biome-ignore lint/suspicious/noExplicitAny: oneDark style types are incompatible
|
||||
style={oneDark as any}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
>
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Markdown>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChatMessageListProps {
|
||||
messages: Message[];
|
||||
loading: boolean;
|
||||
streamingContent: string;
|
||||
streamingThinking: string;
|
||||
activityStatus: string | null;
|
||||
wizardState: WizardStateData | null;
|
||||
setWizardState: React.Dispatch<React.SetStateAction<WizardStateData | null>>;
|
||||
needsOnboarding: boolean;
|
||||
setNeedsOnboarding: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
sendMessage: (text: string) => void;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
messagesEndRef: React.RefObject<HTMLDivElement | null>;
|
||||
onScroll: () => void;
|
||||
onboardingTriggeredRef: React.MutableRefObject<boolean>;
|
||||
}
|
||||
|
||||
export function ChatMessageList({
|
||||
messages,
|
||||
loading,
|
||||
streamingContent,
|
||||
streamingThinking,
|
||||
activityStatus,
|
||||
wizardState,
|
||||
setWizardState,
|
||||
needsOnboarding,
|
||||
setNeedsOnboarding,
|
||||
sendMessage,
|
||||
scrollContainerRef,
|
||||
messagesEndRef,
|
||||
onScroll,
|
||||
onboardingTriggeredRef,
|
||||
}: ChatMessageListProps) {
|
||||
return (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={onScroll}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "20px 0",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "24px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "768px",
|
||||
margin: "0 auto",
|
||||
width: "100%",
|
||||
padding: "0 24px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "24px",
|
||||
}}
|
||||
>
|
||||
{wizardState &&
|
||||
!wizardState.completed &&
|
||||
messages.length === 0 &&
|
||||
!loading && (
|
||||
<SetupWizard
|
||||
wizardState={wizardState}
|
||||
onWizardUpdate={setWizardState}
|
||||
sendMessage={sendMessage}
|
||||
/>
|
||||
)}
|
||||
{needsOnboarding &&
|
||||
!wizardState &&
|
||||
messages.length === 0 &&
|
||||
!loading && (
|
||||
<div
|
||||
data-testid="onboarding-welcome"
|
||||
style={{
|
||||
padding: "24px",
|
||||
borderRadius: "12px",
|
||||
background: "#1c2a1c",
|
||||
border: "1px solid #2d4a2d",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
margin: "0 0 8px 0",
|
||||
color: "#a0d4a0",
|
||||
fontSize: "1.1rem",
|
||||
}}
|
||||
>
|
||||
Welcome to Huskies
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: "0 0 16px 0",
|
||||
color: "#ccc",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
This project needs to be set up before you can start writing
|
||||
stories. The agent will guide you through configuring your
|
||||
project goals and tech stack.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="onboarding-start-button"
|
||||
onClick={() => {
|
||||
if (onboardingTriggeredRef.current) return;
|
||||
onboardingTriggeredRef.current = true;
|
||||
setNeedsOnboarding(false);
|
||||
sendMessage(
|
||||
"I just created a new project. Help me set it up.",
|
||||
);
|
||||
}}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
borderRadius: "8px",
|
||||
border: "none",
|
||||
backgroundColor: "#a0d4a0",
|
||||
color: "#1a1a1a",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.95rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Start Project Setup
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg: Message, idx: number) => (
|
||||
<MessageItem
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Message has no stable ID
|
||||
key={`msg-${idx}-${msg.role}-${msg.content.substring(0, 20)}`}
|
||||
msg={msg}
|
||||
/>
|
||||
))}
|
||||
{loading && streamingThinking && (
|
||||
<ThinkingBlock text={streamingThinking} />
|
||||
)}
|
||||
{loading && streamingContent && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
padding: "0",
|
||||
borderRadius: "0",
|
||||
background: "transparent",
|
||||
color: "#ececec",
|
||||
border: "none",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "1em",
|
||||
fontWeight: "500",
|
||||
whiteSpace: "normal",
|
||||
lineHeight: "1.6",
|
||||
}}
|
||||
>
|
||||
<div className="markdown-body">
|
||||
<StreamingMessage content={streamingContent} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{loading &&
|
||||
(activityStatus != null ||
|
||||
(!streamingContent && !streamingThinking)) && (
|
||||
<div
|
||||
data-testid="activity-indicator"
|
||||
style={{
|
||||
alignSelf: "flex-start",
|
||||
color: "#888",
|
||||
fontSize: "0.9em",
|
||||
marginTop: "10px",
|
||||
}}
|
||||
>
|
||||
<span className="pulse">{activityStatus ?? "Thinking..."}</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { AgentConfigInfo } from "../api/agents";
|
||||
import type { PipelineStageItem, PipelineState } from "../api/client";
|
||||
import { AgentPanel } from "./AgentPanel";
|
||||
import { LozengeFlyProvider } from "./LozengeFlyContext";
|
||||
import type { LogEntry } from "./ServerLogsPanel";
|
||||
import { ServerLogsPanel } from "./ServerLogsPanel";
|
||||
import { StagePanel } from "./StagePanel";
|
||||
import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
|
||||
|
||||
interface ChatPipelinePanelProps {
|
||||
isNarrowScreen: boolean;
|
||||
pipeline: PipelineState;
|
||||
pipelineVersion: number;
|
||||
agentConfigVersion: number;
|
||||
agentStateVersion: number;
|
||||
storyTokenCosts: Map<string, number>;
|
||||
agentRoster: AgentConfigInfo[];
|
||||
busyAgentNames: Set<string>;
|
||||
selectedWorkItemId: string | null;
|
||||
serverLogs: LogEntry[];
|
||||
onSelectWorkItem: (id: string) => void;
|
||||
onCloseWorkItem: () => void;
|
||||
onStartAgent: (storyId: string, agentName?: string) => void;
|
||||
onStopAgent: (storyId: string, agentName: string) => void;
|
||||
onDeleteItem: (item: PipelineStageItem) => void;
|
||||
}
|
||||
|
||||
export function ChatPipelinePanel({
|
||||
isNarrowScreen,
|
||||
pipeline,
|
||||
pipelineVersion,
|
||||
agentConfigVersion,
|
||||
agentStateVersion,
|
||||
storyTokenCosts,
|
||||
agentRoster,
|
||||
busyAgentNames,
|
||||
selectedWorkItemId,
|
||||
serverLogs,
|
||||
onSelectWorkItem,
|
||||
onCloseWorkItem,
|
||||
onStartAgent,
|
||||
onStopAgent,
|
||||
onDeleteItem,
|
||||
}: ChatPipelinePanelProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="chat-right-column"
|
||||
style={{
|
||||
flex: "0 0 40%",
|
||||
overflowY: "auto",
|
||||
borderLeft: isNarrowScreen ? "none" : "1px solid #333",
|
||||
borderTop: isNarrowScreen ? "1px solid #333" : "none",
|
||||
padding: "12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<LozengeFlyProvider pipeline={pipeline}>
|
||||
{selectedWorkItemId ? (
|
||||
<WorkItemDetailPanel
|
||||
storyId={selectedWorkItemId}
|
||||
pipelineVersion={pipelineVersion}
|
||||
onClose={onCloseWorkItem}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<AgentPanel
|
||||
configVersion={agentConfigVersion}
|
||||
stateVersion={agentStateVersion}
|
||||
/>
|
||||
<StagePanel
|
||||
title="Done"
|
||||
items={pipeline.done ?? []}
|
||||
costs={storyTokenCosts}
|
||||
onItemClick={(item) => onSelectWorkItem(item.story_id)}
|
||||
onStopAgent={onStopAgent}
|
||||
onDeleteItem={onDeleteItem}
|
||||
/>
|
||||
<StagePanel
|
||||
title="To Merge"
|
||||
items={pipeline.merge}
|
||||
costs={storyTokenCosts}
|
||||
onItemClick={(item) => onSelectWorkItem(item.story_id)}
|
||||
onStopAgent={onStopAgent}
|
||||
onDeleteItem={onDeleteItem}
|
||||
/>
|
||||
<StagePanel
|
||||
title="QA"
|
||||
items={pipeline.qa}
|
||||
costs={storyTokenCosts}
|
||||
onItemClick={(item) => onSelectWorkItem(item.story_id)}
|
||||
onStopAgent={onStopAgent}
|
||||
onDeleteItem={onDeleteItem}
|
||||
/>
|
||||
<StagePanel
|
||||
title="Current"
|
||||
items={pipeline.current}
|
||||
costs={storyTokenCosts}
|
||||
onItemClick={(item) => onSelectWorkItem(item.story_id)}
|
||||
agentRoster={agentRoster}
|
||||
busyAgentNames={busyAgentNames}
|
||||
onStartAgent={onStartAgent}
|
||||
onStopAgent={onStopAgent}
|
||||
onDeleteItem={onDeleteItem}
|
||||
/>
|
||||
<StagePanel
|
||||
title="Backlog"
|
||||
items={pipeline.backlog}
|
||||
costs={storyTokenCosts}
|
||||
onItemClick={(item) => onSelectWorkItem(item.story_id)}
|
||||
agentRoster={agentRoster}
|
||||
busyAgentNames={busyAgentNames}
|
||||
onStartAgent={onStartAgent}
|
||||
onStopAgent={onStopAgent}
|
||||
onDeleteItem={onDeleteItem}
|
||||
/>
|
||||
<ServerLogsPanel logs={serverLogs} />
|
||||
</>
|
||||
)}
|
||||
</LozengeFlyProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
/// Gateway management panel shown when huskies runs in `--gateway` mode.
|
||||
///
|
||||
/// Provides:
|
||||
/// - A cross-project pipeline status view showing active stories per project.
|
||||
/// - Clicking a project card switches to it.
|
||||
/// - 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 status, project assignment, and "Remove" buttons.
|
||||
/// - Auto-refresh every 5 seconds so new agents and disconnections appear without a page reload.
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
gatewayApi,
|
||||
type JoinedAgent,
|
||||
type GatewayProject,
|
||||
type AllProjectsPipeline,
|
||||
type PipelineItem,
|
||||
} from "../api/gateway";
|
||||
|
||||
const { useCallback, useEffect, useRef, useState } = React;
|
||||
|
||||
/// Seconds of silence before an agent is considered disconnected.
|
||||
const DISCONNECT_THRESHOLD_SECS = 60;
|
||||
|
||||
/// Poll the agent list this often (milliseconds).
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
type AgentStatus = "idle" | "working" | "disconnected";
|
||||
|
||||
/// Derive an agent's display status from its last-seen timestamp and project assignment.
|
||||
function agentStatus(agent: JoinedAgent): AgentStatus {
|
||||
const nowSecs = Date.now() / 1000;
|
||||
if (nowSecs - agent.last_seen > DISCONNECT_THRESHOLD_SECS) {
|
||||
return "disconnected";
|
||||
}
|
||||
return agent.assigned_project ? "working" : "idle";
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<AgentStatus, string> = {
|
||||
idle: "#6e7681",
|
||||
working: "#3fb950",
|
||||
disconnected: "#f85149",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<AgentStatus, string> = {
|
||||
idle: "Idle",
|
||||
working: "Working",
|
||||
disconnected: "Disconnected",
|
||||
};
|
||||
|
||||
const STAGE_COLORS: Record<string, string> = {
|
||||
current: "#3fb950",
|
||||
qa: "#d2a679",
|
||||
merge: "#79c0ff",
|
||||
done: "#6e7681",
|
||||
};
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
current: "In Progress",
|
||||
qa: "QA",
|
||||
merge: "Merging",
|
||||
done: "Done",
|
||||
};
|
||||
|
||||
/// A single story row inside a project pipeline card.
|
||||
function StoryRow({ item }: { item: PipelineItem }) {
|
||||
const color = STAGE_COLORS[item.stage] ?? "#8b949e";
|
||||
const label = STAGE_LABELS[item.stage] ?? item.stage;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "4px 0",
|
||||
fontSize: "0.82em",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
padding: "1px 6px",
|
||||
borderRadius: "10px",
|
||||
background: `${color}22`,
|
||||
color,
|
||||
border: `1px solid ${color}44`,
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ color: "#e6edf3", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/// Pipeline status card for a single project.
|
||||
function ProjectPipelineCard({
|
||||
name,
|
||||
pipeline,
|
||||
isActive,
|
||||
onSwitch,
|
||||
}: {
|
||||
name: string;
|
||||
pipeline: AllProjectsPipeline["projects"][string];
|
||||
isActive: boolean;
|
||||
onSwitch: (name: string) => void;
|
||||
}) {
|
||||
const activeItems = pipeline.active ?? [];
|
||||
const backlogCount = pipeline.backlog_count ?? 0;
|
||||
const hasError = Boolean(pipeline.error);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`pipeline-card-${name}`}
|
||||
onClick={() => onSwitch(name)}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
background: "#161b22",
|
||||
border: `1px solid ${isActive ? "#238636" : "#30363d"}`,
|
||||
borderRadius: "8px",
|
||||
marginBottom: "8px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
marginBottom: activeItems.length > 0 ? "8px" : 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, color: "#e6edf3" }}>{name}</span>
|
||||
{isActive && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7em",
|
||||
padding: "1px 6px",
|
||||
borderRadius: "10px",
|
||||
background: "#23863622",
|
||||
color: "#3fb950",
|
||||
border: "1px solid #23863644",
|
||||
}}
|
||||
>
|
||||
active
|
||||
</span>
|
||||
)}
|
||||
<span style={{ marginLeft: "auto", fontSize: "0.75em", color: "#6e7681" }}>
|
||||
{backlogCount > 0 ? `${backlogCount} in backlog` : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasError ? (
|
||||
<div style={{ fontSize: "0.8em", color: "#f85149" }}>{pipeline.error}</div>
|
||||
) : activeItems.length === 0 ? (
|
||||
<div style={{ fontSize: "0.8em", color: "#6e7681" }}>No active stories</div>
|
||||
) : (
|
||||
<div>
|
||||
{activeItems.map((item) => (
|
||||
<StoryRow key={item.story_id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenDisplay({ token }: { token: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
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 status = agentStatus(agent);
|
||||
const statusColor = STATUS_COLORS[status];
|
||||
const statusLabel = STATUS_LABELS[status];
|
||||
|
||||
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: statusColor,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title={statusLabel}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<span style={{ fontWeight: 600, color: "#e6edf3" }}>{agent.label}</span>
|
||||
<span
|
||||
data-testid={`agent-status-${agent.id}`}
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
padding: "1px 6px",
|
||||
borderRadius: "10px",
|
||||
background: `${statusColor}22`,
|
||||
color: statusColor,
|
||||
border: `1px solid ${statusColor}44`,
|
||||
}}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: "0.8em", color: "#8b949e" }}>
|
||||
{agent.address}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.75em", color: "#6e7681" }}>
|
||||
Registered {registeredAt}
|
||||
{agent.assigned_project && (
|
||||
<span style={{ marginLeft: "8px", color: "#8b949e" }}>
|
||||
· Project: {agent.assigned_project}
|
||||
</span>
|
||||
)}
|
||||
</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);
|
||||
const [pipeline, setPipeline] = useState<AllProjectsPipeline | null>(null);
|
||||
|
||||
// Add-project form state
|
||||
const [newProjectName, setNewProjectName] = useState("");
|
||||
const [newProjectUrl, setNewProjectUrl] = useState("");
|
||||
const [addingProject, setAddingProject] = useState(false);
|
||||
|
||||
// Keep stable refs so polling intervals don't recreate on state changes.
|
||||
const setAgentsRef = useRef(setAgents);
|
||||
setAgentsRef.current = setAgents;
|
||||
const setPipelineRef = useRef(setPipeline);
|
||||
setPipelineRef.current = setPipeline;
|
||||
|
||||
useEffect(() => {
|
||||
// Initial load.
|
||||
gatewayApi
|
||||
.listAgents()
|
||||
.then(setAgents)
|
||||
.catch(() => setAgents([]));
|
||||
gatewayApi
|
||||
.getGatewayInfo()
|
||||
.then((info) => setProjects(info.projects))
|
||||
.catch(() => setProjects([]));
|
||||
gatewayApi
|
||||
.getAllProjectsPipeline()
|
||||
.then(setPipeline)
|
||||
.catch(() => setPipeline(null));
|
||||
|
||||
// Poll so the dashboard auto-updates as agents connect/disconnect and
|
||||
// stories move through pipelines.
|
||||
const timer = setInterval(() => {
|
||||
gatewayApi
|
||||
.listAgents()
|
||||
.then((updated) => setAgentsRef.current(updated))
|
||||
.catch(() => {});
|
||||
gatewayApi
|
||||
.getAllProjectsPipeline()
|
||||
.then((updated) => setPipelineRef.current(updated))
|
||||
.catch(() => {});
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
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));
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleAddProject = useCallback(async () => {
|
||||
const name = newProjectName.trim();
|
||||
const url = newProjectUrl.trim();
|
||||
if (!name || !url) return;
|
||||
setAddingProject(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await gatewayApi.addProject(name, url);
|
||||
setProjects((prev) => [...prev, created]);
|
||||
setNewProjectName("");
|
||||
setNewProjectUrl("");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setAddingProject(false);
|
||||
}
|
||||
}, [newProjectName, newProjectUrl]);
|
||||
|
||||
const handleSwitchProject = useCallback(async (name: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
const result = await gatewayApi.switchProject(name);
|
||||
if (!result.ok) {
|
||||
setError(result.error ?? "Failed to switch project");
|
||||
return;
|
||||
}
|
||||
// Refresh pipeline to reflect new active project.
|
||||
const updated = await gatewayApi.getAllProjectsPipeline();
|
||||
setPipeline(updated);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRemoveProject = useCallback(async (name: string) => {
|
||||
if (!window.confirm(`Remove project "${name}"? This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
await gatewayApi.removeProject(name);
|
||||
setProjects((prev) => prev.filter((p) => p.name !== name));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
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>
|
||||
|
||||
{/* Cross-project pipeline status */}
|
||||
<section style={{ marginBottom: "32px" }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "1.1em",
|
||||
fontWeight: 600,
|
||||
marginBottom: "12px",
|
||||
borderBottom: "1px solid #21262d",
|
||||
paddingBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Pipeline Status
|
||||
</h2>
|
||||
{pipeline ? (
|
||||
Object.entries(pipeline.projects).map(([name, status]) => (
|
||||
<ProjectPipelineCard
|
||||
key={name}
|
||||
name={name}
|
||||
pipeline={status}
|
||||
isActive={name === pipeline.active}
|
||||
onSwitch={handleSwitchProject}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p style={{ color: "#6e7681" }}>Loading pipeline status…</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Project management */}
|
||||
<section style={{ marginTop: "32px" }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "1.1em",
|
||||
fontWeight: 600,
|
||||
marginBottom: "12px",
|
||||
borderBottom: "1px solid #21262d",
|
||||
paddingBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Projects{" "}
|
||||
{projects.length > 0 && (
|
||||
<span style={{ fontSize: "0.8em", color: "#8b949e", fontWeight: 400 }}>
|
||||
({projects.length})
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{/* Existing projects list */}
|
||||
{projects.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
data-testid={`project-row-${p.name}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
padding: "10px 14px",
|
||||
background: "#161b22",
|
||||
border: "1px solid #30363d",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, color: "#e6edf3" }}>{p.name}</div>
|
||||
<div style={{ fontSize: "0.8em", color: "#8b949e" }}>{p.url}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`remove-project-${p.name}`}
|
||||
onClick={() => handleRemoveProject(p.name)}
|
||||
style={{
|
||||
fontSize: "0.8em",
|
||||
padding: "4px 10px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #f85149",
|
||||
background: "none",
|
||||
color: "#f85149",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add project form */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: "12px",
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
alignItems: "flex-end",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: "1 1 140px" }}>
|
||||
<div style={{ fontSize: "0.75em", color: "#8b949e", marginBottom: "4px" }}>
|
||||
Name
|
||||
</div>
|
||||
<input
|
||||
data-testid="new-project-name"
|
||||
type="text"
|
||||
placeholder="my-project"
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "6px 10px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #30363d",
|
||||
background: "#0d1117",
|
||||
color: "#e6edf3",
|
||||
fontSize: "0.85em",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: "2 1 200px" }}>
|
||||
<div style={{ fontSize: "0.75em", color: "#8b949e", marginBottom: "4px" }}>
|
||||
Container URL
|
||||
</div>
|
||||
<input
|
||||
data-testid="new-project-url"
|
||||
type="text"
|
||||
placeholder="http://localhost:3001"
|
||||
value={newProjectUrl}
|
||||
onChange={(e) => setNewProjectUrl(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "6px 10px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #30363d",
|
||||
background: "#0d1117",
|
||||
color: "#e6edf3",
|
||||
fontSize: "0.85em",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="add-project-button"
|
||||
onClick={handleAddProject}
|
||||
disabled={addingProject || !newProjectName.trim() || !newProjectUrl.trim()}
|
||||
style={{
|
||||
padding: "6px 14px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #238636",
|
||||
background: addingProject ? "#1a2f1a" : "#238636",
|
||||
color: "#fff",
|
||||
cursor: addingProject ? "not-allowed" : "pointer",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.85em",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{addingProject ? "Adding…" : "Add Project"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "16px",
|
||||
padding: "10px 14px",
|
||||
background: "#f8514911",
|
||||
border: "1px solid #f85149",
|
||||
borderRadius: "6px",
|
||||
color: "#f85149",
|
||||
fontSize: "0.875em",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,6 +61,7 @@ describe("AgentLozenge fixed intrinsic width", () => {
|
||||
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
];
|
||||
const pipeline = makePipeline({ current: items });
|
||||
@@ -115,6 +116,7 @@ describe("LozengeFlyProvider fly-in visibility", () => {
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -157,6 +159,7 @@ describe("LozengeFlyProvider fly-in visibility", () => {
|
||||
},
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -221,6 +224,7 @@ describe("LozengeFlyProvider fly-in clone", () => {
|
||||
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -264,6 +268,7 @@ describe("LozengeFlyProvider fly-in clone", () => {
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -313,6 +318,7 @@ describe("LozengeFlyProvider fly-in clone", () => {
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -384,6 +390,7 @@ describe("LozengeFlyProvider fly-out", () => {
|
||||
agent: { agent_name: "coder-1", model: "haiku", status: "completed" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -411,6 +418,7 @@ describe("LozengeFlyProvider fly-out", () => {
|
||||
agent: null,
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -445,6 +453,7 @@ describe("AgentLozenge idle vs active appearance", () => {
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
];
|
||||
const { container } = render(
|
||||
@@ -471,6 +480,7 @@ describe("AgentLozenge idle vs active appearance", () => {
|
||||
agent: { agent_name: "coder-1", model: null, status: "pending" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
];
|
||||
const { container } = render(
|
||||
@@ -497,6 +507,7 @@ describe("AgentLozenge idle vs active appearance", () => {
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
];
|
||||
const { container } = render(
|
||||
@@ -550,6 +561,7 @@ describe("hiddenRosterAgents: assigned agents are absent from roster", () => {
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -573,6 +585,7 @@ describe("hiddenRosterAgents: assigned agents are absent from roster", () => {
|
||||
agent: null,
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -597,6 +610,7 @@ describe("hiddenRosterAgents: assigned agents are absent from roster", () => {
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -659,6 +673,7 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", ()
|
||||
agent: { agent_name: "coder-1", model: null, status: "completed" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -672,6 +687,7 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", ()
|
||||
agent: null,
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -716,6 +732,7 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", ()
|
||||
agent: { agent_name: "coder-1", model: null, status: "completed" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -729,6 +746,7 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", ()
|
||||
agent: null,
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -804,6 +822,7 @@ describe("LozengeFlyProvider agent swap (name change)", () => {
|
||||
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -817,6 +836,7 @@ describe("LozengeFlyProvider agent swap (name change)", () => {
|
||||
agent: { agent_name: "coder-2", model: "haiku", status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -903,6 +923,7 @@ describe("LozengeFlyProvider fly-out without roster element", () => {
|
||||
},
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -916,6 +937,7 @@ describe("LozengeFlyProvider fly-out without roster element", () => {
|
||||
agent: null,
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -989,6 +1011,7 @@ describe("FlyingLozengeClone initial non-flying render", () => {
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1066,6 +1089,7 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", ()
|
||||
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1079,6 +1103,7 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", ()
|
||||
agent: { agent_name: "coder-2", model: "haiku", status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1147,6 +1172,7 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", ()
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1160,6 +1186,7 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", ()
|
||||
agent: { agent_name: "coder-2", model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1247,6 +1274,7 @@ describe("Bug 137: animations remain functional through sustained agent activity
|
||||
agent: { agent_name: agentName, model: null, status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
interface PermissionRequest {
|
||||
requestId: string;
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PermissionDialogProps {
|
||||
permissionQueue: PermissionRequest[];
|
||||
onResponse: (approved: boolean, alwaysAllow?: boolean) => void;
|
||||
}
|
||||
|
||||
export function PermissionDialog({
|
||||
permissionQueue,
|
||||
onResponse,
|
||||
}: PermissionDialogProps) {
|
||||
const current = permissionQueue[0];
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#2f2f2f",
|
||||
padding: "32px",
|
||||
borderRadius: "12px",
|
||||
maxWidth: "520px",
|
||||
width: "90%",
|
||||
border: "1px solid #444",
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginTop: 0, color: "#ececec" }}>
|
||||
Permission Request
|
||||
{permissionQueue.length > 1 && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.6em",
|
||||
color: "#888",
|
||||
marginLeft: "8px",
|
||||
}}
|
||||
>
|
||||
(+{permissionQueue.length - 1} queued)
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
color: "#aaa",
|
||||
fontSize: "0.9em",
|
||||
marginBottom: "12px",
|
||||
}}
|
||||
>
|
||||
The agent wants to use the{" "}
|
||||
<strong style={{ color: "#ececec" }}>{current.toolName}</strong> tool.
|
||||
Do you approve?
|
||||
</p>
|
||||
{Object.keys(current.toolInput).length > 0 && (
|
||||
<pre
|
||||
style={{
|
||||
background: "#1a1a1a",
|
||||
border: "1px solid #333",
|
||||
borderRadius: "6px",
|
||||
padding: "12px",
|
||||
fontSize: "0.8em",
|
||||
color: "#ccc",
|
||||
overflowX: "auto",
|
||||
maxHeight: "200px",
|
||||
marginBottom: "20px",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(current.toolInput, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "12px",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResponse(false)}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #555",
|
||||
backgroundColor: "transparent",
|
||||
color: "#aaa",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9em",
|
||||
}}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResponse(true)}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
borderRadius: "8px",
|
||||
border: "none",
|
||||
backgroundColor: "#ececec",
|
||||
color: "#000",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9em",
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResponse(true, true)}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
borderRadius: "8px",
|
||||
border: "none",
|
||||
backgroundColor: "#4caf50",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9em",
|
||||
}}
|
||||
>
|
||||
Always Allow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user