Compare commits
12 Commits
c28c86dbc6
...
v0.10.2
| Author | SHA1 | Date | |
|---|---|---|---|
| f4a97c1135 | |||
| 0969fb5d51 | |||
| 744cc9dca4 | |||
| ce37281333 | |||
| 149a383447 | |||
| d68614e26a | |||
| a4480fa067 | |||
| beb84ade9f | |||
| d235fd41ac | |||
| 2246278845 | |||
| d80fc143c2 | |||
| 1fe4ca2b7a |
@@ -5,7 +5,7 @@ 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 to understand the dev process. 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."
|
||||
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]]
|
||||
@@ -15,7 +15,7 @@ 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 to understand the dev process. 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."
|
||||
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]]
|
||||
@@ -25,7 +25,7 @@ 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 to understand the dev process. 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."
|
||||
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]]
|
||||
@@ -37,7 +37,7 @@ 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 to understand the dev process.
|
||||
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
|
||||
|
||||
@@ -126,7 +126,7 @@ role = "Senior full-stack engineer for complex tasks. Implements features across
|
||||
model = "opus"
|
||||
max_turns = 80
|
||||
max_budget_usd = 20.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md to understand the dev process. 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."
|
||||
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]]
|
||||
@@ -138,7 +138,7 @@ 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 to understand the dev process.
|
||||
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
|
||||
|
||||
@@ -229,7 +229,7 @@ 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 to understand the project.
|
||||
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.
|
||||
|
||||
+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`)
|
||||
|
||||
Generated
+5
-5
@@ -2288,7 +2288,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "huskies"
|
||||
version = "0.10.1"
|
||||
version = "0.10.2"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -5996,9 +5996,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.51.1"
|
||||
version = "1.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
|
||||
checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -6333,9 +6333,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "typewit"
|
||||
version = "1.15.1"
|
||||
version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc19094686c694eb41b3b99dcc2f2975d4b078512fa22ae6c63f7ca318bdcff7"
|
||||
checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6"
|
||||
dependencies = [
|
||||
"typewit_proc_macros",
|
||||
]
|
||||
|
||||
@@ -220,283 +220,6 @@ Both return a JSON document with:
|
||||
|
||||
## Source Map
|
||||
|
||||
### Core
|
||||
|
||||
| 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 |
|
||||
|
||||
### Agents
|
||||
|
||||
| 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 |
|
||||
|
||||
### Agent Pool
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/agents/pool/mod.rs` | Manages the set of active agents across all pipeline stages |
|
||||
| `server/src/agents/pool/types.rs` | `AgentPool`, `StoryAgent`, and related data structures |
|
||||
| `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/wait.rs` | Blocks until an agent reaches a terminal state |
|
||||
| `server/src/agents/pool/query.rs` | Lists available/active agents and info lookups |
|
||||
| `server/src/agents/pool/process.rs` | Kills orphaned PTY child processes on shutdown |
|
||||
| `server/src/agents/pool/worktree.rs` | Creates and configures git worktrees for agents |
|
||||
| `server/src/agents/pool/test_helpers.rs` | In-memory pool construction and test assertions |
|
||||
|
||||
### Agent Pool — Auto-assign
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/agents/pool/auto_assign/mod.rs` | Wires sub-files and re-exports public items |
|
||||
| `server/src/agents/pool/auto_assign/auto_assign.rs` | Scans pipeline stages and dispatches agents to unassigned stories |
|
||||
| `server/src/agents/pool/auto_assign/reconcile.rs` | Startup reconciliation: detects committed work and advances pipeline |
|
||||
| `server/src/agents/pool/auto_assign/scan.rs` | Scans pipeline stages for work items and queries pool state |
|
||||
| `server/src/agents/pool/auto_assign/story_checks.rs` | Front-matter checks: review holds, blocked state, merge failures |
|
||||
| `server/src/agents/pool/auto_assign/watchdog.rs` | Detects orphaned agents and triggers auto-assign |
|
||||
|
||||
### Agent Pool — Pipeline
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/agents/pool/pipeline/mod.rs` | Stage advancement, completion handling, and merge orchestration |
|
||||
| `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 |
|
||||
|
||||
### Agent Runtimes
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/agents/runtime/mod.rs` | Pluggable backends (Claude Code, Gemini, OpenAI) for running agents |
|
||||
| `server/src/agents/runtime/claude_code.rs` | Launches Claude Code CLI sessions as agent backends |
|
||||
| `server/src/agents/runtime/gemini.rs` | Drives Google Gemini API sessions as agent backends |
|
||||
| `server/src/agents/runtime/openai.rs` | Drives OpenAI API sessions as agent backends |
|
||||
|
||||
### CRDT
|
||||
|
||||
| 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/crdt_wire.rs` | Serialization format for `SignedOp` sync messages |
|
||||
| `server/src/pipeline_state.rs` | Typed pipeline state machine |
|
||||
|
||||
### Database
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/db/mod.rs` | Content store, shadow writes, and CRDT op persistence |
|
||||
|
||||
### HTTP Server
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/http/mod.rs` | Module declarations for all REST, MCP, WebSocket, and SSE endpoints |
|
||||
| `server/src/http/context.rs` | Shared `AppContext` threaded through all HTTP handlers |
|
||||
| `server/src/http/agents.rs` | REST API for listing, starting, stopping, and inspecting agents |
|
||||
| `server/src/http/agents_sse.rs` | Server-Sent Events endpoint for real-time agent output |
|
||||
| `server/src/http/anthropic.rs` | Proxy for model listing and key-validation to Anthropic |
|
||||
| `server/src/http/assets.rs` | Serves the embedded React frontend via `rust-embed` |
|
||||
| `server/src/http/bot_command.rs` | Bot command HTTP endpoint |
|
||||
| `server/src/http/chat.rs` | REST API for the LLM-powered chat interface |
|
||||
| `server/src/http/health.rs` | Returns a static "ok" response |
|
||||
| `server/src/http/io.rs` | REST API for file and directory operations |
|
||||
| `server/src/http/model.rs` | REST API for model selection and LLM provider management |
|
||||
| `server/src/http/oauth.rs` | Anthropic OAuth callback and token exchange flow |
|
||||
| `server/src/http/project.rs` | REST API for project initialization and context management |
|
||||
| `server/src/http/settings.rs` | REST API for user preferences and editor configuration |
|
||||
| `server/src/http/wizard.rs` | REST API for the project setup wizard |
|
||||
| `server/src/http/ws.rs` | Real-time pipeline updates, chat, and permission prompts |
|
||||
| `server/src/http/test_helpers.rs` | Shared test utilities for HTTP handler tests |
|
||||
|
||||
### HTTP — MCP Tools
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/http/mcp/mod.rs` | Model Context Protocol endpoint dispatching tool calls |
|
||||
| `server/src/http/mcp/agent_tools.rs` | Start, stop, wait, list, and inspect agents via MCP |
|
||||
| `server/src/http/mcp/diagnostics.rs` | Server logs, CRDT dump, and story movement helpers |
|
||||
| `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/qa_tools.rs` | Request, approve, and reject QA reviews |
|
||||
| `server/src/http/mcp/shell_tools.rs` | Run commands, execute tests, and stream output |
|
||||
| `server/src/http/mcp/status_tools.rs` | Pipeline status, story triage, and AC inspection |
|
||||
| `server/src/http/mcp/story_tools.rs` | Create, update, move, and manage stories/bugs/refactors |
|
||||
| `server/src/http/mcp/wizard_tools.rs` | Interactive setup wizard tool implementations |
|
||||
|
||||
### HTTP — Workflow
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/http/workflow/mod.rs` | Shared story/bug file operations for HTTP and MCP handlers |
|
||||
| `server/src/http/workflow/bug_ops.rs` | Creates bug, refactor, and spike files in the pipeline |
|
||||
| `server/src/http/workflow/story_ops.rs` | Creates, updates, and manages acceptance criteria in stories |
|
||||
| `server/src/http/workflow/test_results.rs` | Writes structured test results into story markdown |
|
||||
|
||||
### I/O
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/io/mod.rs` | Filesystem, shell, search, onboarding, and story metadata operations |
|
||||
| `server/src/io/fs/mod.rs` | Module declarations and re-exports for file operations |
|
||||
| `server/src/io/fs/files.rs` | Read, write, list, and create files and directories |
|
||||
| `server/src/io/fs/paths.rs` | Resolves CLI and session-relative paths to absolute paths |
|
||||
| `server/src/io/fs/preferences.rs` | Reads and writes model selection and user settings |
|
||||
| `server/src/io/fs/project.rs` | Tracks known projects and resolves the active project root |
|
||||
| `server/src/io/fs/scaffold.rs` | Creates the `.huskies/` directory structure and default files |
|
||||
| `server/src/io/onboarding.rs` | Checks whether scaffold templates have been customized |
|
||||
| `server/src/io/search.rs` | Full-text search across project files |
|
||||
| `server/src/io/shell.rs` | Runs commands in the project directory and captures output |
|
||||
| `server/src/io/story_metadata.rs` | Parses and modifies YAML front matter in story markdown |
|
||||
| `server/src/io/watcher.rs` | Filesystem watcher for `.huskies/work/` and `project.toml` |
|
||||
| `server/src/io/wizard.rs` | Multi-step project onboarding flow with per-step status |
|
||||
| `server/src/io/test_helpers.rs` | Shared test utilities for I/O module tests |
|
||||
|
||||
### Chat
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/chat/mod.rs` | Transport abstraction for chat platforms |
|
||||
| `server/src/chat/lookup.rs` | Shared story-lookup helper for chat commands |
|
||||
| `server/src/chat/timer.rs` | Deferred agent start via one-shot timers |
|
||||
| `server/src/chat/util.rs` | Shared text utilities used by all transports |
|
||||
| `server/src/chat/test_helpers.rs` | Shared test utilities for chat handler tests |
|
||||
|
||||
### Chat — Commands
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/chat/commands/mod.rs` | Bot-level command registry shared by all transports |
|
||||
| `server/src/chat/commands/ambient.rs` | `ambient` command handler |
|
||||
| `server/src/chat/commands/assign.rs` | `assign` command handler |
|
||||
| `server/src/chat/commands/backlog.rs` | `backlog` command — shows only backlog-stage items |
|
||||
| `server/src/chat/commands/cost.rs` | `cost` command handler |
|
||||
| `server/src/chat/commands/coverage.rs` | `coverage` command — show or refresh test coverage |
|
||||
| `server/src/chat/commands/depends.rs` | `depends` command handler |
|
||||
| `server/src/chat/commands/git.rs` | `git` command handler |
|
||||
| `server/src/chat/commands/help.rs` | `help` command handler |
|
||||
| `server/src/chat/commands/loc.rs` | `loc` command — top source files by line count |
|
||||
| `server/src/chat/commands/move_story.rs` | `move` command handler |
|
||||
| `server/src/chat/commands/overview.rs` | `overview` command handler |
|
||||
| `server/src/chat/commands/run_tests.rs` | `test` command — run the project's test suite |
|
||||
| `server/src/chat/commands/setup.rs` | `setup` command handler |
|
||||
| `server/src/chat/commands/show.rs` | `show` command handler |
|
||||
| `server/src/chat/commands/status.rs` | `status` command and pipeline status helpers |
|
||||
| `server/src/chat/commands/timer.rs` | `timer` command handler |
|
||||
| `server/src/chat/commands/triage.rs` | Story triage dump subcommand of `status` |
|
||||
| `server/src/chat/commands/unblock.rs` | `unblock` command handler |
|
||||
| `server/src/chat/commands/unreleased.rs` | `unreleased` command handler |
|
||||
|
||||
### Chat — Matrix Transport
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/chat/transport/matrix/mod.rs` | Matrix bot integration |
|
||||
| `server/src/chat/transport/matrix/config.rs` | Deserialization of `bot.toml` Matrix settings |
|
||||
| `server/src/chat/transport/matrix/commands.rs` | Re-exports from `crate::chat::commands` |
|
||||
| `server/src/chat/transport/matrix/transport_impl.rs` | Matrix `ChatTransport` implementation |
|
||||
| `server/src/chat/transport/matrix/assign.rs` | Assign/re-assign a coder model to a story |
|
||||
| `server/src/chat/transport/matrix/delete.rs` | Delete a story/bug/spike from the pipeline |
|
||||
| `server/src/chat/transport/matrix/htop.rs` | Live-updating system and agent process dashboard |
|
||||
| `server/src/chat/transport/matrix/notifications.rs` | Stage transition notifications for Matrix rooms |
|
||||
| `server/src/chat/transport/matrix/rebuild.rs` | Trigger a server rebuild and restart |
|
||||
| `server/src/chat/transport/matrix/reset.rs` | Clear the current Claude Code session for a room |
|
||||
| `server/src/chat/transport/matrix/rmtree.rs` | Delete the worktree for a story |
|
||||
| `server/src/chat/transport/matrix/start.rs` | Start a coder agent on a story |
|
||||
|
||||
### Chat — Matrix Bot
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/chat/transport/matrix/bot/mod.rs` | Sub-modules for the Matrix chat bot |
|
||||
| `server/src/chat/transport/matrix/bot/context.rs` | Shared state (rooms, history, permissions) |
|
||||
| `server/src/chat/transport/matrix/bot/format.rs` | Markdown-to-HTML conversion and startup announcements |
|
||||
| `server/src/chat/transport/matrix/bot/history.rs` | Per-room message history for LLM context |
|
||||
| `server/src/chat/transport/matrix/bot/mentions.rs` | Checks whether a message mentions the bot |
|
||||
| `server/src/chat/transport/matrix/bot/messages.rs` | Processes incoming messages and dispatches commands |
|
||||
| `server/src/chat/transport/matrix/bot/run.rs` | Connects to homeserver and processes sync events |
|
||||
| `server/src/chat/transport/matrix/bot/verification.rs` | Interactive emoji verification flow for E2EE |
|
||||
|
||||
### Chat — Slack Transport
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/chat/transport/slack/mod.rs` | Slack Bot API integration |
|
||||
| `server/src/chat/transport/slack/commands.rs` | Incoming message dispatch and slash command handling |
|
||||
| `server/src/chat/transport/slack/format.rs` | Markdown to Slack mrkdwn conversion |
|
||||
| `server/src/chat/transport/slack/history.rs` | Conversation history persistence |
|
||||
| `server/src/chat/transport/slack/meta.rs` | `ChatTransport` implementation for Slack |
|
||||
| `server/src/chat/transport/slack/verify.rs` | Request signature verification |
|
||||
|
||||
### Chat — Discord Transport
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/chat/transport/discord/mod.rs` | Discord Bot integration |
|
||||
| `server/src/chat/transport/discord/commands.rs` | Incoming message dispatch and command handling |
|
||||
| `server/src/chat/transport/discord/format.rs` | Markdown to Discord format conversion |
|
||||
| `server/src/chat/transport/discord/gateway.rs` | Minimal Discord Gateway WebSocket client |
|
||||
| `server/src/chat/transport/discord/history.rs` | Conversation history persistence |
|
||||
| `server/src/chat/transport/discord/meta.rs` | `ChatTransport` implementation for Discord |
|
||||
|
||||
### Chat — WhatsApp Transport
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/chat/transport/whatsapp/mod.rs` | WhatsApp Business API integration |
|
||||
| `server/src/chat/transport/whatsapp/commands.rs` | Processes incoming messages as bot commands |
|
||||
| `server/src/chat/transport/whatsapp/format.rs` | Markdown-to-WhatsApp conversion and message chunking |
|
||||
| `server/src/chat/transport/whatsapp/history.rs` | Per-number history and messaging window tracking |
|
||||
| `server/src/chat/transport/whatsapp/meta.rs` | Meta Cloud API transport via Graph API |
|
||||
| `server/src/chat/transport/whatsapp/twilio.rs` | Twilio transport for sending/receiving messages |
|
||||
|
||||
### Chat — Transport Abstraction
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/chat/transport/mod.rs` | Pluggable backends (Matrix, Slack, WhatsApp, Discord) |
|
||||
|
||||
### LLM
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/llm/mod.rs` | Chat orchestration, prompts, OAuth, and provider integrations |
|
||||
| `server/src/llm/chat.rs` | Multi-turn conversations with tool-calling LLM providers |
|
||||
| `server/src/llm/oauth.rs` | Token refresh and credential management for Claude API |
|
||||
| `server/src/llm/prompts.rs` | Static prompt templates for chat and onboarding |
|
||||
| `server/src/llm/types.rs` | `Message`, `Role`, `ToolCall`, `ModelProvider` types |
|
||||
|
||||
### LLM — Providers
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/llm/providers/mod.rs` | Module declarations for Anthropic, Claude Code, and Ollama |
|
||||
| `server/src/llm/providers/anthropic.rs` | Streaming completion client for Claude Messages API |
|
||||
| `server/src/llm/providers/claude_code.rs` | Runs Claude Code CLI in a PTY and parses output |
|
||||
| `server/src/llm/providers/ollama.rs` | Streaming completion client for Ollama models |
|
||||
|
||||
### Utilities
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/src/log_buffer.rs` | Bounded in-memory ring buffer for server log output |
|
||||
| `server/src/rebuild.rs` | Server rebuild and restart logic |
|
||||
| `server/src/workflow.rs` | Test result tracking and acceptance evaluation |
|
||||
| `server/src/worktree.rs` | Creates, lists, and removes git worktrees for agent isolation |
|
||||
|
||||
## License
|
||||
See `.huskies/specs/tech/STACK.md` for the full source map.
|
||||
|
||||
GPL-3.0. See [LICENSE](LICENSE).
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "huskies",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "huskies",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"dependencies": {
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "huskies",
|
||||
"private": true,
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -8,6 +8,8 @@ export interface JoinedAgent {
|
||||
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;
|
||||
}
|
||||
@@ -22,6 +24,28 @@ export interface GatewayInfo {
|
||||
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;
|
||||
}
|
||||
@@ -86,4 +110,40 @@ export const gatewayApi = {
|
||||
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,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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useChatSend } from "../hooks/useChatSend";
|
||||
import { useChatWebSocket } from "../hooks/useChatWebSocket";
|
||||
import { estimateTokens, getContextWindowSize } from "../utils/chatUtils";
|
||||
import { ApiKeyDialog } from "./ApiKeyDialog";
|
||||
import { BotConfigPage } from "./BotConfigPage";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
import type { ChatInputHandle } from "./ChatInput";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
@@ -61,6 +62,7 @@ export function Chat({
|
||||
null,
|
||||
);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [view, setView] = useState<"chat" | "bot-config">("chat");
|
||||
const [queuedMessages, setQueuedMessages] = useState<
|
||||
{ id: string; text: string }[]
|
||||
>([]);
|
||||
@@ -373,12 +375,17 @@ export function Chat({
|
||||
onToggleTools={setEnableTools}
|
||||
wsConnected={wsConnected}
|
||||
oauthStatus={oauthStatus}
|
||||
onShowBotConfig={() => setView("bot-config")}
|
||||
/>
|
||||
|
||||
{view === "bot-config" && (
|
||||
<BotConfigPage onBack={() => setView("chat")} />
|
||||
)}
|
||||
|
||||
<div
|
||||
data-testid="chat-content-area"
|
||||
style={{
|
||||
display: "flex",
|
||||
display: view === "bot-config" ? "none" : "flex",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
flexDirection: isNarrowScreen ? "column" : "row",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,14 +1,173 @@
|
||||
/// 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 project assignment and "Remove" buttons.
|
||||
/// - 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 } from "../api/gateway";
|
||||
import {
|
||||
gatewayApi,
|
||||
type JoinedAgent,
|
||||
type GatewayProject,
|
||||
type AllProjectsPipeline,
|
||||
type PipelineItem,
|
||||
} from "../api/gateway";
|
||||
|
||||
const { useCallback, useEffect, useState } = React;
|
||||
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);
|
||||
@@ -100,7 +259,9 @@ function AgentRow({
|
||||
onAssign: (id: string, project: string | null) => void;
|
||||
}) {
|
||||
const registeredAt = new Date(agent.registered_at * 1000).toLocaleString();
|
||||
const isAssigned = Boolean(agent.assigned_project);
|
||||
const status = agentStatus(agent);
|
||||
const statusColor = STATUS_COLORS[status];
|
||||
const statusLabel = STATUS_LABELS[status];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -121,18 +282,38 @@ function AgentRow({
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "50%",
|
||||
background: isAssigned ? "#3fb950" : "#6e7681",
|
||||
background: statusColor,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title={isAssigned ? "Assigned" : "Idle (unassigned)"}
|
||||
title={statusLabel}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, color: "#e6edf3" }}>{agent.label}</div>
|
||||
<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
|
||||
@@ -185,8 +366,21 @@ export function GatewayPanel() {
|
||||
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)
|
||||
@@ -195,6 +389,25 @@ export function GatewayPanel() {
|
||||
.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 () => {
|
||||
@@ -234,6 +447,53 @@ export function GatewayPanel() {
|
||||
[],
|
||||
);
|
||||
|
||||
const handleAddProject = useCallback(async () => {
|
||||
const name = newProjectName.trim();
|
||||
const url = newProjectUrl.trim();
|
||||
if (!name || !url) return;
|
||||
setAddingProject(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await gatewayApi.addProject(name, url);
|
||||
setProjects((prev) => [...prev, created]);
|
||||
setNewProjectName("");
|
||||
setNewProjectUrl("");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setAddingProject(false);
|
||||
}
|
||||
}, [newProjectName, newProjectUrl]);
|
||||
|
||||
const handleSwitchProject = useCallback(async (name: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
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={{
|
||||
@@ -252,6 +512,34 @@ export function GatewayPanel() {
|
||||
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
|
||||
@@ -330,6 +618,138 @@ export function GatewayPanel() {
|
||||
)}
|
||||
</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={{
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "huskies"
|
||||
version = "0.10.1"
|
||||
version = "0.10.2"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ use super::scan::{
|
||||
};
|
||||
use super::story_checks::{
|
||||
check_archived_dependencies, has_merge_failure, has_review_hold, has_unmet_dependencies,
|
||||
is_story_blocked, read_story_front_matter_agent,
|
||||
is_story_blocked, is_story_frozen, read_story_front_matter_agent,
|
||||
};
|
||||
|
||||
impl AgentPool {
|
||||
@@ -103,6 +103,12 @@ impl AgentPool {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip frozen stories — pipeline advancement is suspended.
|
||||
if is_story_frozen(project_root, stage_dir, story_id) {
|
||||
slog!("[auto-assign] Story '{story_id}' is frozen; skipping until unfrozen.");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip blocked stories (retry limit exceeded).
|
||||
if is_story_blocked(project_root, stage_dir, story_id) {
|
||||
continue;
|
||||
|
||||
@@ -93,6 +93,19 @@ pub(super) fn check_archived_dependencies(
|
||||
crate::io::story_metadata::check_archived_deps(project_root, stage_dir, story_id)
|
||||
}
|
||||
|
||||
/// Return `true` if the story file has `frozen: true` in its front matter.
|
||||
pub(super) fn is_story_frozen(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
let contents = match read_story_contents(project_root, story_id) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|m| m.frozen)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Return `true` if the story file has a `merge_failure` field in its front matter.
|
||||
pub(super) fn has_merge_failure(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
|
||||
@@ -40,6 +40,13 @@ impl AgentPool {
|
||||
.map(agent_config_stage)
|
||||
.unwrap_or_else(|| pipeline_stage(agent_name));
|
||||
|
||||
// If the story is frozen, do not advance the pipeline. The agent's work
|
||||
// is done but the story stays at its current stage.
|
||||
if crate::io::story_metadata::is_story_frozen_in_store(story_id) {
|
||||
slog!("[pipeline] Story '{story_id}' is frozen; pipeline advancement suppressed.");
|
||||
return;
|
||||
}
|
||||
|
||||
match stage {
|
||||
PipelineStage::Other => {
|
||||
// Supervisors and unknown agents do not advance the pipeline.
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Handler for the `diff` command.
|
||||
//!
|
||||
//! Shows the git diff from the configured main branch to the story's worktree
|
||||
//! HEAD, formatted for readability in chat.
|
||||
|
||||
use super::CommandContext;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Display the git diff from the configured main branch to a story's worktree HEAD.
|
||||
///
|
||||
/// Usage: `diff <number>`
|
||||
pub(super) fn handle_diff(ctx: &CommandContext) -> Option<String> {
|
||||
let num_str = ctx.args.trim();
|
||||
if num_str.is_empty() {
|
||||
return Some(format!(
|
||||
"Usage: `{} diff <number>`\n\nShows the git diff from the main branch to the story's worktree HEAD.",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
if !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(format!(
|
||||
"Invalid story number: `{num_str}`. Usage: `{} diff <number>`",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
|
||||
let story_id = match find_story_id(num_str) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return Some(format!(
|
||||
"No story with number **{num_str}** found in the pipeline."
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let wt_path = crate::worktree::worktree_path(ctx.project_root, &story_id);
|
||||
if !wt_path.is_dir() {
|
||||
return Some(format!(
|
||||
"Story **{num_str}** has no worktree. The diff is only available once a coder has started working on it."
|
||||
));
|
||||
}
|
||||
|
||||
let base_branch = resolve_base_branch(ctx.project_root);
|
||||
let range = format!("{base_branch}...HEAD");
|
||||
|
||||
let stat = run_git(&wt_path, &["diff", "--stat", &range]);
|
||||
let diff = run_git(&wt_path, &["diff", &range]);
|
||||
|
||||
let mut out = format!("## Diff — story {num_str} vs `{base_branch}`\n\n");
|
||||
|
||||
if stat.is_empty() && diff.is_empty() {
|
||||
out.push_str("*(no changes relative to main branch)*\n");
|
||||
return Some(out);
|
||||
}
|
||||
|
||||
if !stat.is_empty() {
|
||||
out.push_str("**Changed files:**\n```\n");
|
||||
out.push_str(&stat);
|
||||
out.push_str("\n```\n\n");
|
||||
}
|
||||
|
||||
if !diff.is_empty() {
|
||||
const MAX_DIFF_BYTES: usize = 8_000;
|
||||
if diff.len() > MAX_DIFF_BYTES {
|
||||
let truncated = truncate_at_char_boundary(&diff, MAX_DIFF_BYTES);
|
||||
out.push_str("**Diff** *(truncated — showing first 8 KB)*:\n```diff\n");
|
||||
out.push_str(truncated);
|
||||
out.push_str("\n... (truncated)\n```\n");
|
||||
} else {
|
||||
out.push_str("**Diff:**\n```diff\n");
|
||||
out.push_str(&diff);
|
||||
out.push_str("\n```\n");
|
||||
}
|
||||
}
|
||||
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Find the story_id in the pipeline whose numeric prefix matches `num_str`.
|
||||
fn find_story_id(num_str: &str) -> Option<String> {
|
||||
let items = crate::pipeline_state::read_all_typed();
|
||||
items.into_iter().find_map(|item| {
|
||||
let file_num = item
|
||||
.story_id
|
||||
.0
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("");
|
||||
if file_num == num_str {
|
||||
Some(item.story_id.0.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the configured base branch, or auto-detect it from the project root HEAD.
|
||||
fn resolve_base_branch(project_root: &Path) -> String {
|
||||
crate::config::ProjectConfig::load(project_root)
|
||||
.ok()
|
||||
.and_then(|c| c.base_branch)
|
||||
.unwrap_or_else(|| {
|
||||
Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_else(|| "master".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Run a git command in `dir`, returning trimmed stdout (empty string on failure).
|
||||
fn run_git(dir: &Path, args: &[&str]) -> String {
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Truncate `s` to at most `max_bytes` bytes without splitting a UTF-8 character.
|
||||
fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> &str {
|
||||
if s.len() <= max_bytes {
|
||||
return s;
|
||||
}
|
||||
let mut boundary = max_bytes;
|
||||
while !s.is_char_boundary(boundary) {
|
||||
boundary -= 1;
|
||||
}
|
||||
&s[..boundary]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::AgentPool;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::super::{CommandDispatch, try_handle_command};
|
||||
|
||||
fn diff_cmd(root: &std::path::Path, args: &str) -> Option<String> {
|
||||
let agents = Arc::new(AgentPool::new_test(3000));
|
||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
};
|
||||
try_handle_command(&dispatch, &format!("@timmy diff {args}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_command_is_registered() {
|
||||
let found = super::super::commands().iter().any(|c| c.name == "diff");
|
||||
assert!(found, "diff command must be in the registry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("diff"),
|
||||
"help should list diff command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_command_no_args_returns_usage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = diff_cmd(tmp.path(), "").unwrap();
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
"no args should show usage: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_command_non_numeric_returns_error() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = diff_cmd(tmp.path(), "abc").unwrap();
|
||||
assert!(
|
||||
output.contains("Invalid"),
|
||||
"non-numeric arg should return error: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_command_story_not_found_returns_friendly_message() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = diff_cmd(tmp.path(), "99993").unwrap();
|
||||
assert!(
|
||||
output.contains("99993"),
|
||||
"message should include story number: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("found") || output.contains("pipeline"),
|
||||
"message should explain not found: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_command_no_worktree_returns_clear_error() {
|
||||
use crate::chat::test_helpers::write_story_file;
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"55551_story_no_worktree.md",
|
||||
"---\nname: No Worktree\n---\n",
|
||||
);
|
||||
let output = diff_cmd(tmp.path(), "55551").unwrap();
|
||||
assert!(
|
||||
output.contains("worktree")
|
||||
|| output.contains("no worktree")
|
||||
|| output.contains("Worktree"),
|
||||
"should report missing worktree: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_at_char_boundary_short_string() {
|
||||
let s = "hello";
|
||||
assert_eq!(truncate_at_char_boundary(s, 100), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_at_char_boundary_exact_limit() {
|
||||
let s = "hello";
|
||||
assert_eq!(truncate_at_char_boundary(s, 5), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_at_char_boundary_over_limit() {
|
||||
let s = "hello world";
|
||||
assert_eq!(truncate_at_char_boundary(s, 5), "hello");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
//! Handler for the `freeze` and `unfreeze` commands.
|
||||
//!
|
||||
//! `freeze <number>` sets `frozen: true` on the story, halting pipeline
|
||||
//! advancement and auto-assign until `unfreeze <number>` clears the flag.
|
||||
|
||||
use super::CommandContext;
|
||||
use crate::io::story_metadata::{
|
||||
clear_front_matter_field_in_content, parse_front_matter, set_front_matter_field,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
/// Handle the `freeze` command.
|
||||
///
|
||||
/// Parses `<number>` from `ctx.args`, locates the work item, and sets
|
||||
/// `frozen: true` in its front matter.
|
||||
pub(super) fn handle_freeze(ctx: &CommandContext) -> Option<String> {
|
||||
let num_str = ctx.args.trim();
|
||||
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(format!(
|
||||
"Usage: `{} freeze <number>` (e.g. `freeze 42`)",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
Some(freeze_by_number(ctx.project_root, num_str))
|
||||
}
|
||||
|
||||
/// Core freeze logic: find story by numeric prefix and set `frozen: true`.
|
||||
///
|
||||
/// Returns a Markdown-formatted response string suitable for all transports.
|
||||
pub(crate) fn freeze_by_number(project_root: &Path, story_number: &str) -> String {
|
||||
let (story_id, _, _, _) =
|
||||
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
||||
Some(found) => found,
|
||||
None => {
|
||||
return format!("No story, bug, or spike with number **{story_number}** found.");
|
||||
}
|
||||
};
|
||||
|
||||
freeze_by_story_id(&story_id)
|
||||
}
|
||||
|
||||
fn freeze_by_story_id(story_id: &str) -> String {
|
||||
let contents = match crate::db::read_content(story_id) {
|
||||
Some(c) => c,
|
||||
None => return format!("Failed to read story content for **{story_id}**"),
|
||||
};
|
||||
|
||||
let meta = match parse_front_matter(&contents) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return format!("Failed to parse front matter for **{story_id}**: {e}"),
|
||||
};
|
||||
|
||||
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
|
||||
|
||||
if meta.frozen == Some(true) {
|
||||
return format!("**{story_name}** ({story_id}) is already frozen.");
|
||||
}
|
||||
|
||||
let updated = set_front_matter_field(&contents, "frozen", "true");
|
||||
|
||||
crate::db::write_content(story_id, &updated);
|
||||
let stage = crate::pipeline_state::read_typed(story_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|i| i.stage.dir_name().to_string())
|
||||
.unwrap_or_else(|| "2_current".to_string());
|
||||
crate::db::write_item_with_content(story_id, &stage, &updated);
|
||||
|
||||
format!(
|
||||
"Frozen **{story_name}** ({story_id}). Pipeline advancement and auto-assign suppressed until unfrozen."
|
||||
)
|
||||
}
|
||||
|
||||
/// Handle the `unfreeze` command.
|
||||
///
|
||||
/// Parses `<number>` from `ctx.args`, locates the work item, and clears the
|
||||
/// `frozen` flag to resume normal pipeline behaviour.
|
||||
pub(super) fn handle_unfreeze(ctx: &CommandContext) -> Option<String> {
|
||||
let num_str = ctx.args.trim();
|
||||
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(format!(
|
||||
"Usage: `{} unfreeze <number>` (e.g. `unfreeze 42`)",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
Some(unfreeze_by_number(ctx.project_root, num_str))
|
||||
}
|
||||
|
||||
/// Core unfreeze logic: find story by numeric prefix and clear `frozen` flag.
|
||||
pub(crate) fn unfreeze_by_number(project_root: &Path, story_number: &str) -> String {
|
||||
let (story_id, _, _, _) =
|
||||
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
||||
Some(found) => found,
|
||||
None => {
|
||||
return format!("No story, bug, or spike with number **{story_number}** found.");
|
||||
}
|
||||
};
|
||||
|
||||
unfreeze_by_story_id(&story_id)
|
||||
}
|
||||
|
||||
fn unfreeze_by_story_id(story_id: &str) -> String {
|
||||
let contents = match crate::db::read_content(story_id) {
|
||||
Some(c) => c,
|
||||
None => return format!("Failed to read story content for **{story_id}**"),
|
||||
};
|
||||
|
||||
let meta = match parse_front_matter(&contents) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return format!("Failed to parse front matter for **{story_id}**: {e}"),
|
||||
};
|
||||
|
||||
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
|
||||
|
||||
if meta.frozen != Some(true) {
|
||||
return format!("**{story_name}** ({story_id}) is not frozen. Nothing to unfreeze.");
|
||||
}
|
||||
|
||||
let updated = clear_front_matter_field_in_content(&contents, "frozen");
|
||||
|
||||
crate::db::write_content(story_id, &updated);
|
||||
let stage = crate::pipeline_state::read_typed(story_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|i| i.stage.dir_name().to_string())
|
||||
.unwrap_or_else(|| "2_current".to_string());
|
||||
crate::db::write_item_with_content(story_id, &stage, &updated);
|
||||
|
||||
format!("Unfrozen **{story_name}** ({story_id}). Normal pipeline behaviour resumed.")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agents::AgentPool;
|
||||
use crate::chat::test_helpers::write_story_file;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::super::{CommandDispatch, try_handle_command};
|
||||
|
||||
fn freeze_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
|
||||
let agents = Arc::new(AgentPool::new_test(3000));
|
||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
};
|
||||
try_handle_command(&dispatch, &format!("@timmy freeze {args}"))
|
||||
}
|
||||
|
||||
fn unfreeze_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
|
||||
let agents = Arc::new(AgentPool::new_test(3000));
|
||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
};
|
||||
try_handle_command(&dispatch, &format!("@timmy unfreeze {args}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeze_command_is_registered() {
|
||||
use super::super::commands;
|
||||
assert!(
|
||||
commands().iter().any(|c| c.name == "freeze"),
|
||||
"freeze command must be in the registry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfreeze_command_is_registered() {
|
||||
use super::super::commands;
|
||||
assert!(
|
||||
commands().iter().any(|c| c.name == "unfreeze"),
|
||||
"unfreeze command must be in the registry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeze_command_no_args_returns_usage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = freeze_cmd_with_root(tmp.path(), "").unwrap();
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
"no args should show usage: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfreeze_command_no_args_returns_usage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = unfreeze_cmd_with_root(tmp.path(), "").unwrap();
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
"no args should show usage: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeze_command_not_found_returns_error() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = freeze_cmd_with_root(tmp.path(), "9988").unwrap();
|
||||
assert!(
|
||||
output.contains("9988") && output.contains("found"),
|
||||
"not-found message should include number and 'found': {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeze_command_sets_frozen_flag() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
crate::db::ensure_content_store();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"9940_story_freezeme.md",
|
||||
"---\nname: Freeze Me\n---\n# Story\n",
|
||||
);
|
||||
let output = freeze_cmd_with_root(tmp.path(), "9940").unwrap();
|
||||
assert!(
|
||||
output.contains("Frozen") && output.contains("Freeze Me"),
|
||||
"should confirm freeze with story name: {output}"
|
||||
);
|
||||
let contents = crate::db::read_content("9940_story_freezeme")
|
||||
.expect("story content should be readable after freeze");
|
||||
assert!(
|
||||
contents.contains("frozen: true"),
|
||||
"frozen flag should be set: {contents}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfreeze_command_clears_frozen_flag() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
crate::db::ensure_content_store();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"9941_story_frozen.md",
|
||||
"---\nname: Frozen Story\nfrozen: true\n---\n# Story\n",
|
||||
);
|
||||
let output = unfreeze_cmd_with_root(tmp.path(), "9941").unwrap();
|
||||
assert!(
|
||||
output.contains("Unfrozen") && output.contains("Frozen Story"),
|
||||
"should confirm unfreeze with story name: {output}"
|
||||
);
|
||||
let contents = crate::db::read_content("9941_story_frozen")
|
||||
.expect("story content should be readable after unfreeze");
|
||||
assert!(
|
||||
!contents.contains("frozen:"),
|
||||
"frozen flag should be removed: {contents}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfreeze_command_not_frozen_returns_error() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"9942_story_notfrozen.md",
|
||||
"---\nname: Not Frozen\n---\n# Story\n",
|
||||
);
|
||||
let output = unfreeze_cmd_with_root(tmp.path(), "9942").unwrap();
|
||||
assert!(
|
||||
output.contains("not frozen"),
|
||||
"should return not-frozen error: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeze_command_already_frozen_returns_message() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"9943_story_alreadyfrozen.md",
|
||||
"---\nname: Already Frozen\nfrozen: true\n---\n# Story\n",
|
||||
);
|
||||
let output = freeze_cmd_with_root(tmp.path(), "9943").unwrap();
|
||||
assert!(
|
||||
output.contains("already frozen"),
|
||||
"should say already frozen: {output}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ mod backlog;
|
||||
mod cost;
|
||||
mod coverage;
|
||||
mod depends;
|
||||
mod diff;
|
||||
mod freeze;
|
||||
mod git;
|
||||
mod help;
|
||||
pub(crate) mod loc;
|
||||
@@ -163,6 +165,11 @@ pub fn commands() -> &'static [BotCommand] {
|
||||
description: "Display the full text of a work item: `show <number>`",
|
||||
handler: show::handle_show,
|
||||
},
|
||||
BotCommand {
|
||||
name: "diff",
|
||||
description: "Show git diff from main branch to story worktree HEAD: `diff <number>`",
|
||||
handler: diff::handle_diff,
|
||||
},
|
||||
BotCommand {
|
||||
name: "overview",
|
||||
description: "Show implementation summary for a merged story: `overview <number>`",
|
||||
@@ -203,6 +210,16 @@ pub fn commands() -> &'static [BotCommand] {
|
||||
description: "Reset a blocked story: `unblock <number>` (clears blocked flag and resets retry count)",
|
||||
handler: unblock::handle_unblock,
|
||||
},
|
||||
BotCommand {
|
||||
name: "freeze",
|
||||
description: "Freeze a story at its current stage: `freeze <number>` (suppresses pipeline advancement and auto-assign)",
|
||||
handler: freeze::handle_freeze,
|
||||
},
|
||||
BotCommand {
|
||||
name: "unfreeze",
|
||||
description: "Unfreeze a story: `unfreeze <number>` (resumes normal pipeline behaviour)",
|
||||
handler: freeze::handle_unfreeze,
|
||||
},
|
||||
BotCommand {
|
||||
name: "unreleased",
|
||||
description: "Show stories merged to master since the last release tag",
|
||||
|
||||
@@ -21,8 +21,8 @@ pub(super) fn handle_show(ctx: &CommandContext) -> Option<String> {
|
||||
));
|
||||
}
|
||||
|
||||
// Find the story by numeric prefix: CRDT → content store → filesystem.
|
||||
let (story_id, _stage_dir, path, content) =
|
||||
// Find the story by numeric prefix: CRDT → content store.
|
||||
let (story_id, _stage_dir, _path, content) =
|
||||
match crate::chat::lookup::find_story_by_number(ctx.project_root, num_str) {
|
||||
Some(found) => found,
|
||||
None => {
|
||||
@@ -32,16 +32,11 @@ pub(super) fn handle_show(ctx: &CommandContext) -> Option<String> {
|
||||
}
|
||||
};
|
||||
|
||||
// `content` is populated from the content store (CRDT/DB path) or read
|
||||
// from disk during the filesystem fallback. If it is None (story found in
|
||||
// CRDT but no content-store entry yet), attempt a direct disk read.
|
||||
Some(
|
||||
content
|
||||
.or_else(|| std::fs::read_to_string(&path).ok())
|
||||
.unwrap_or_else(|| {
|
||||
format!("Story {story_id} found in pipeline but its content is unavailable.")
|
||||
}),
|
||||
)
|
||||
// `content` comes from the CRDT / content store. If unavailable, report
|
||||
// it rather than silently reading a stale on-disk copy.
|
||||
Some(content.unwrap_or_else(|| {
|
||||
format!("Story {story_id} found in pipeline but its content is unavailable.")
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -228,7 +228,13 @@ fn render_item_line(
|
||||
} else {
|
||||
Some(item.name.as_str())
|
||||
};
|
||||
let display = story_short_label(story_id, name_opt);
|
||||
let frozen = crate::io::story_metadata::is_story_frozen_in_store(story_id);
|
||||
let base_label = story_short_label(story_id, name_opt);
|
||||
let display = if frozen {
|
||||
format!("\u{2744}\u{FE0F} {base_label}") // ❄️ prefix
|
||||
} else {
|
||||
base_label
|
||||
};
|
||||
let cost_suffix = cost_by_story
|
||||
.get(story_id)
|
||||
.filter(|&&c| c > 0.0)
|
||||
|
||||
+314
-88
@@ -65,6 +65,10 @@ pub struct JoinedAgent {
|
||||
pub address: String,
|
||||
/// Unix timestamp when the agent registered.
|
||||
pub registered_at: f64,
|
||||
/// Unix timestamp of the last heartbeat from this agent. Defaults to `registered_at`
|
||||
/// for agents loaded from persisted state that predate the heartbeat feature.
|
||||
#[serde(default)]
|
||||
pub last_seen: f64,
|
||||
/// Project this agent is assigned to, if any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub assigned_project: Option<String>,
|
||||
@@ -97,8 +101,8 @@ struct AssignAgentRequest {
|
||||
/// Shared gateway state threaded through HTTP handlers.
|
||||
#[derive(Clone)]
|
||||
pub struct GatewayState {
|
||||
/// The parsed gateway config with all registered projects.
|
||||
pub config: GatewayConfig,
|
||||
/// The live set of registered projects (initially loaded from `projects.toml`).
|
||||
pub projects: Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
||||
/// The currently active project name.
|
||||
pub active_project: Arc<RwLock<String>>,
|
||||
/// HTTP client for proxying requests to project containers.
|
||||
@@ -126,6 +130,21 @@ fn load_agents(config_dir: &Path) -> Vec<JoinedAgent> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the current projects map to `<config_dir>/projects.toml`.
|
||||
/// Silently ignores write errors or skips when `config_dir` is empty.
|
||||
async fn save_config(projects: &BTreeMap<String, ProjectEntry>, config_dir: &Path) {
|
||||
if config_dir.as_os_str().is_empty() {
|
||||
return;
|
||||
}
|
||||
let path = config_dir.join("projects.toml");
|
||||
let config = GatewayConfig {
|
||||
projects: projects.clone(),
|
||||
};
|
||||
if let Ok(data) = toml::to_string_pretty(&config) {
|
||||
let _ = tokio::fs::write(&path, data).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the current agent list to `<config_dir>/gateway_agents.json`.
|
||||
/// Silently ignores write errors (e.g. read-only filesystem or empty path).
|
||||
async fn save_agents(agents: &[JoinedAgent], config_dir: &Path) {
|
||||
@@ -151,7 +170,7 @@ impl GatewayState {
|
||||
let first = config.projects.keys().next().unwrap().clone();
|
||||
let agents = load_agents(&config_dir);
|
||||
Ok(Self {
|
||||
config,
|
||||
projects: Arc::new(RwLock::new(config.projects)),
|
||||
active_project: Arc::new(RwLock::new(first)),
|
||||
client: Client::new(),
|
||||
joined_agents: Arc::new(RwLock::new(agents)),
|
||||
@@ -165,8 +184,9 @@ impl GatewayState {
|
||||
/// Get the URL of the currently active project.
|
||||
async fn active_url(&self) -> Result<String, String> {
|
||||
let name = self.active_project.read().await.clone();
|
||||
self.config
|
||||
.projects
|
||||
self.projects
|
||||
.read()
|
||||
.await
|
||||
.get(&name)
|
||||
.map(|p| p.url.clone())
|
||||
.ok_or_else(|| format!("active project '{name}' not found in config"))
|
||||
@@ -485,27 +505,30 @@ async fn handle_switch_project(params: &Value, state: &GatewayState) -> JsonRpcR
|
||||
return JsonRpcResponse::error(None, -32602, "missing required parameter: project".into());
|
||||
}
|
||||
|
||||
if !state.config.projects.contains_key(project) {
|
||||
let available: Vec<&str> = state.config.projects.keys().map(|s| s.as_str()).collect();
|
||||
return JsonRpcResponse::error(
|
||||
None,
|
||||
-32602,
|
||||
format!(
|
||||
"unknown project '{project}'. Available: {}",
|
||||
available.join(", ")
|
||||
),
|
||||
);
|
||||
}
|
||||
let url = {
|
||||
let projects = state.projects.read().await;
|
||||
if !projects.contains_key(project) {
|
||||
let available: Vec<&str> = projects.keys().map(|s| s.as_str()).collect();
|
||||
return JsonRpcResponse::error(
|
||||
None,
|
||||
-32602,
|
||||
format!(
|
||||
"unknown project '{project}'. Available: {}",
|
||||
available.join(", ")
|
||||
),
|
||||
);
|
||||
}
|
||||
projects[project].url.clone()
|
||||
};
|
||||
|
||||
*state.active_project.write().await = project.to_string();
|
||||
|
||||
let url = &state.config.projects[project].url;
|
||||
JsonRpcResponse::success(
|
||||
None,
|
||||
json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Switched to project '{project}' ({})", url)
|
||||
"text": format!("Switched to project '{project}' ({url})")
|
||||
}]
|
||||
}),
|
||||
)
|
||||
@@ -562,8 +585,15 @@ async fn handle_gateway_status(state: &GatewayState) -> JsonRpcResponse {
|
||||
async fn handle_gateway_health(state: &GatewayState) -> JsonRpcResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for (name, entry) in &state.config.projects {
|
||||
let health_url = format!("{}/health", entry.url.trim_end_matches('/'));
|
||||
let project_entries: Vec<(String, String)> = state
|
||||
.projects
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.map(|(n, e)| (n.clone(), e.url.clone()))
|
||||
.collect();
|
||||
for (name, url) in &project_entries {
|
||||
let health_url = format!("{}/health", url.trim_end_matches('/'));
|
||||
let status = match state.client.get(&health_url).send().await {
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
@@ -665,11 +695,13 @@ pub async fn gateway_register_agent_handler(
|
||||
tokens.remove(&req.token);
|
||||
drop(tokens);
|
||||
|
||||
let now = chrono::Utc::now().timestamp() as f64;
|
||||
let agent = JoinedAgent {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
label: req.label,
|
||||
address: req.address,
|
||||
registered_at: chrono::Utc::now().timestamp() as f64,
|
||||
registered_at: now,
|
||||
last_seen: now,
|
||||
assigned_project: None,
|
||||
};
|
||||
|
||||
@@ -749,7 +781,7 @@ pub async fn gateway_assign_agent_handler(
|
||||
.and_then(|p| if p.is_empty() { None } else { Some(p) });
|
||||
|
||||
if let Some(ref p) = project
|
||||
&& !state.config.projects.contains_key(p.as_str())
|
||||
&& !state.projects.read().await.contains_key(p.as_str())
|
||||
{
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
@@ -789,6 +821,38 @@ pub async fn gateway_assign_agent_handler(
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /gateway/agents/:id/heartbeat` — update an agent's last-seen timestamp.
|
||||
///
|
||||
/// Build agents should call this periodically (e.g. every 30 s) so the gateway
|
||||
/// can distinguish live agents from disconnected ones. Returns 204 No Content on
|
||||
/// success or 404 if the agent ID is not found.
|
||||
#[handler]
|
||||
pub async fn gateway_heartbeat_handler(
|
||||
PoemPath(id): PoemPath<String>,
|
||||
state: Data<&Arc<GatewayState>>,
|
||||
) -> Response {
|
||||
let found = {
|
||||
let mut agents = state.joined_agents.write().await;
|
||||
match agents.iter_mut().find(|a| a.id == id) {
|
||||
None => false,
|
||||
Some(a) => {
|
||||
a.last_seen = chrono::Utc::now().timestamp() as f64;
|
||||
true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if found {
|
||||
Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(Body::empty())
|
||||
} else {
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("agent not found"))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Health aggregation endpoint ──────────────────────────────────────
|
||||
|
||||
/// HTTP GET `/health` handler for the gateway — aggregates health from all projects.
|
||||
@@ -797,8 +861,15 @@ pub async fn gateway_health_handler(state: Data<&Arc<GatewayState>>) -> Response
|
||||
let mut all_healthy = true;
|
||||
let mut statuses = BTreeMap::new();
|
||||
|
||||
for (name, entry) in &state.config.projects {
|
||||
let health_url = format!("{}/health", entry.url.trim_end_matches('/'));
|
||||
let project_entries: Vec<(String, String)> = state
|
||||
.projects
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.map(|(n, e)| (n.clone(), e.url.clone()))
|
||||
.collect();
|
||||
for (name, url) in &project_entries {
|
||||
let health_url = format!("{}/health", url.trim_end_matches('/'));
|
||||
let healthy = match state.client.get(&health_url).send().await {
|
||||
Ok(resp) => resp.status().is_success(),
|
||||
Err(_) => false,
|
||||
@@ -1000,8 +1071,9 @@ pub async fn gateway_index_handler() -> Response {
|
||||
pub async fn gateway_api_handler(state: Data<&Arc<GatewayState>>) -> Response {
|
||||
let active = state.active_project.read().await.clone();
|
||||
let projects: Vec<Value> = state
|
||||
.config
|
||||
.projects
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.map(|(name, entry)| {
|
||||
json!({
|
||||
@@ -1065,6 +1137,104 @@ pub async fn gateway_switch_handler(
|
||||
))
|
||||
}
|
||||
|
||||
// ── Project management API ───────────────────────────────────────────
|
||||
|
||||
/// Request body for adding a new project.
|
||||
#[derive(Deserialize)]
|
||||
struct AddProjectRequest {
|
||||
name: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
/// `POST /api/gateway/projects` — add a new project to the gateway config.
|
||||
///
|
||||
/// Expects JSON `{ "name": "...", "url": "..." }`. Returns the created project
|
||||
/// or 409 Conflict if a project with the same name already exists.
|
||||
#[handler]
|
||||
pub async fn gateway_add_project_handler(
|
||||
state: Data<&Arc<GatewayState>>,
|
||||
body: Json<AddProjectRequest>,
|
||||
) -> Response {
|
||||
let name = body.0.name.trim().to_string();
|
||||
let url = body.0.url.trim().to_string();
|
||||
|
||||
if name.is_empty() {
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body(Body::from("project name must not be empty"));
|
||||
}
|
||||
if url.is_empty() {
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body(Body::from("project url must not be empty"));
|
||||
}
|
||||
|
||||
{
|
||||
let mut projects = state.projects.write().await;
|
||||
if projects.contains_key(&name) {
|
||||
return Response::builder()
|
||||
.status(StatusCode::CONFLICT)
|
||||
.body(Body::from(format!("project '{name}' already exists")));
|
||||
}
|
||||
projects.insert(name.clone(), ProjectEntry { url: url.clone() });
|
||||
}
|
||||
|
||||
let snapshot = state.projects.read().await.clone();
|
||||
save_config(&snapshot, &state.config_dir).await;
|
||||
|
||||
crate::slog!("[gateway] Added project '{name}' ({url})");
|
||||
let body_val = json!({ "name": name, "url": url });
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&body_val).unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
|
||||
/// `DELETE /api/gateway/projects/:name` — remove a project from the gateway config.
|
||||
///
|
||||
/// Returns 204 No Content on success. Returns 400 if this is the last project
|
||||
/// (the gateway requires at least one project to remain configured).
|
||||
#[handler]
|
||||
pub async fn gateway_remove_project_handler(
|
||||
PoemPath(name): PoemPath<String>,
|
||||
state: Data<&Arc<GatewayState>>,
|
||||
) -> Response {
|
||||
let active = state.active_project.read().await.clone();
|
||||
|
||||
{
|
||||
let mut projects = state.projects.write().await;
|
||||
if !projects.contains_key(&name) {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from(format!("project '{name}' not found")));
|
||||
}
|
||||
if projects.len() == 1 {
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body(Body::from("cannot remove the last project"));
|
||||
}
|
||||
projects.remove(&name);
|
||||
}
|
||||
|
||||
let snapshot = state.projects.read().await.clone();
|
||||
save_config(&snapshot, &state.config_dir).await;
|
||||
|
||||
// If the removed project was active, switch to the first remaining.
|
||||
if active == name {
|
||||
let first = state.projects.read().await.keys().next().cloned();
|
||||
if let Some(new_active) = first {
|
||||
*state.active_project.write().await = new_active;
|
||||
}
|
||||
}
|
||||
|
||||
crate::slog!("[gateway] Removed project '{name}'");
|
||||
Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(Body::empty())
|
||||
}
|
||||
|
||||
// ── Bot configuration API ────────────────────────────────────────────
|
||||
|
||||
/// Request/response body for the bot configuration API.
|
||||
@@ -1143,6 +1313,66 @@ fn toml_string(s: &str) -> String {
|
||||
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
|
||||
}
|
||||
|
||||
/// `GET /api/gateway/pipeline` — fetch pipeline status from all registered projects.
|
||||
///
|
||||
/// Returns `{ "active": "<project>", "projects": { "<name>": { "active": [...], "backlog": [...], "backlog_count": N } | { "error": "..." } } }`.
|
||||
#[handler]
|
||||
pub async fn gateway_all_pipeline_handler(state: Data<&Arc<GatewayState>>) -> Response {
|
||||
let project_entries: Vec<(String, String)> = state
|
||||
.projects
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.map(|(n, e)| (n.clone(), e.url.clone()))
|
||||
.collect();
|
||||
|
||||
let mut results: BTreeMap<String, Value> = BTreeMap::new();
|
||||
|
||||
for (name, url) in &project_entries {
|
||||
let mcp_url = format!("{}/mcp", url.trim_end_matches('/'));
|
||||
let rpc_body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "get_pipeline_status",
|
||||
"arguments": {}
|
||||
}
|
||||
});
|
||||
|
||||
let status = match state.client.post(&mcp_url).json(&rpc_body).send().await {
|
||||
Ok(resp) => match resp.json::<Value>().await {
|
||||
Ok(upstream) => {
|
||||
// The tool result is a JSON string embedded in content[0].text.
|
||||
if let Some(text) = upstream
|
||||
.get("result")
|
||||
.and_then(|r| r.get("content"))
|
||||
.and_then(|c| c.get(0))
|
||||
.and_then(|c| c.get("text"))
|
||||
.and_then(|t| t.as_str())
|
||||
{
|
||||
serde_json::from_str(text)
|
||||
.unwrap_or_else(|_| json!({ "error": "invalid pipeline json" }))
|
||||
} else {
|
||||
json!({ "error": "unexpected response shape" })
|
||||
}
|
||||
}
|
||||
Err(e) => json!({ "error": format!("invalid response: {e}") }),
|
||||
},
|
||||
Err(e) => json!({ "error": format!("unreachable: {e}") }),
|
||||
};
|
||||
|
||||
results.insert(name.clone(), status);
|
||||
}
|
||||
|
||||
let active = state.active_project.read().await.clone();
|
||||
let body = json!({ "active": active, "projects": results });
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// `GET /api/gateway/bot-config` — return current bot.toml fields as JSON.
|
||||
#[handler]
|
||||
pub async fn gateway_bot_config_get_handler(state: Data<&Arc<GatewayState>>) -> Response {
|
||||
@@ -1173,7 +1403,7 @@ pub async fn gateway_bot_config_save_handler(
|
||||
if let Some(h) = handle.take() {
|
||||
h.abort();
|
||||
}
|
||||
let gateway_projects: Vec<String> = state.config.projects.keys().cloned().collect();
|
||||
let gateway_projects: Vec<String> = state.projects.read().await.keys().cloned().collect();
|
||||
let new_handle = spawn_gateway_bot(
|
||||
&state.config_dir,
|
||||
Arc::clone(&state.active_project),
|
||||
@@ -1422,8 +1652,9 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
||||
crate::slog!(
|
||||
"[gateway] Registered projects: {}",
|
||||
state_arc
|
||||
.config
|
||||
.projects
|
||||
.read()
|
||||
.await
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
@@ -1437,7 +1668,7 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
||||
}
|
||||
|
||||
// Spawn the Matrix bot if `.huskies/bot.toml` exists in the config directory.
|
||||
let gateway_projects: Vec<String> = state_arc.config.projects.keys().cloned().collect();
|
||||
let gateway_projects: Vec<String> = state_arc.projects.read().await.keys().cloned().collect();
|
||||
let bot_abort = spawn_gateway_bot(
|
||||
&config_dir,
|
||||
Arc::clone(&state_arc.active_project),
|
||||
@@ -1447,10 +1678,21 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
||||
*state_arc.bot_handle.lock().await = bot_abort;
|
||||
|
||||
let route = poem::Route::new()
|
||||
.at("/", poem::get(gateway_index_handler))
|
||||
.at("/bot-config", poem::get(gateway_bot_config_page_handler))
|
||||
.at("/api/gateway", poem::get(gateway_api_handler))
|
||||
.at("/api/gateway/switch", poem::post(gateway_switch_handler))
|
||||
.at(
|
||||
"/api/gateway/pipeline",
|
||||
poem::get(gateway_all_pipeline_handler),
|
||||
)
|
||||
.at(
|
||||
"/api/gateway/projects",
|
||||
poem::post(gateway_add_project_handler),
|
||||
)
|
||||
.at(
|
||||
"/api/gateway/projects/:name",
|
||||
poem::delete(gateway_remove_project_handler),
|
||||
)
|
||||
.at(
|
||||
"/api/gateway/bot-config",
|
||||
poem::get(gateway_bot_config_get_handler).post(gateway_bot_config_save_handler),
|
||||
@@ -1479,6 +1721,10 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
||||
"/gateway/agents/:id/assign",
|
||||
poem::post(gateway_assign_agent_handler),
|
||||
)
|
||||
.at(
|
||||
"/gateway/agents/:id/heartbeat",
|
||||
poem::post(gateway_heartbeat_handler),
|
||||
)
|
||||
// Serve the embedded React frontend so the gateway has a UI.
|
||||
.at(
|
||||
"/assets/*path",
|
||||
@@ -1923,6 +2169,7 @@ enabled = false
|
||||
label: "agent-1".into(),
|
||||
address: "ws://a:3001/crdt-sync".into(),
|
||||
registered_at: 0.0,
|
||||
last_seen: 0.0,
|
||||
assigned_project: None,
|
||||
});
|
||||
let app = poem::Route::new()
|
||||
@@ -1944,6 +2191,7 @@ enabled = false
|
||||
label: "to-delete".into(),
|
||||
address: "ws://x:3001/crdt-sync".into(),
|
||||
registered_at: 0.0,
|
||||
last_seen: 0.0,
|
||||
assigned_project: None,
|
||||
});
|
||||
let app = poem::Route::new()
|
||||
@@ -1972,66 +2220,44 @@ enabled = false
|
||||
assert_eq!(resp.0.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ── Bot configuration helper tests ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn toml_string_plain() {
|
||||
assert_eq!(toml_string("hello"), "\"hello\"");
|
||||
#[tokio::test]
|
||||
async fn heartbeat_updates_last_seen() {
|
||||
let state = make_test_state();
|
||||
state.joined_agents.write().await.push(JoinedAgent {
|
||||
id: "hb-id".into(),
|
||||
label: "hb-agent".into(),
|
||||
address: "ws://hb:3001/crdt-sync".into(),
|
||||
registered_at: 0.0,
|
||||
last_seen: 0.0,
|
||||
assigned_project: None,
|
||||
});
|
||||
let app = poem::Route::new()
|
||||
.at(
|
||||
"/gateway/agents/:id/heartbeat",
|
||||
poem::post(gateway_heartbeat_handler),
|
||||
)
|
||||
.data(state.clone());
|
||||
let cli = poem::test::TestClient::new(app);
|
||||
let resp = cli.post("/gateway/agents/hb-id/heartbeat").send().await;
|
||||
assert_eq!(resp.0.status(), StatusCode::NO_CONTENT);
|
||||
let agents = state.joined_agents.read().await;
|
||||
assert!(agents[0].last_seen > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_string_escapes_quotes_and_backslashes() {
|
||||
assert_eq!(toml_string(r#"say "hi""#), r#""say \"hi\"""#);
|
||||
assert_eq!(toml_string(r"a\b"), r#""a\\b""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_read_matrix_bot_config_round_trips() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let payload = BotConfigPayload {
|
||||
transport: "matrix".into(),
|
||||
homeserver: Some("https://matrix.example.com".into()),
|
||||
username: Some("@bot:example.com".into()),
|
||||
password: Some("s3cr3t".into()),
|
||||
slack_bot_token: None,
|
||||
slack_signing_secret: None,
|
||||
};
|
||||
write_bot_config(tmp.path(), &payload).expect("write should succeed");
|
||||
|
||||
let read = read_bot_config_raw(tmp.path());
|
||||
assert_eq!(read.transport, "matrix");
|
||||
assert_eq!(
|
||||
read.homeserver.as_deref(),
|
||||
Some("https://matrix.example.com")
|
||||
);
|
||||
assert_eq!(read.username.as_deref(), Some("@bot:example.com"));
|
||||
assert_eq!(read.password.as_deref(), Some("s3cr3t"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_read_slack_bot_config_round_trips() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let payload = BotConfigPayload {
|
||||
transport: "slack".into(),
|
||||
homeserver: None,
|
||||
username: None,
|
||||
password: None,
|
||||
slack_bot_token: Some("xoxb-abc123".into()),
|
||||
slack_signing_secret: Some("sig-secret".into()),
|
||||
};
|
||||
write_bot_config(tmp.path(), &payload).expect("write should succeed");
|
||||
|
||||
let read = read_bot_config_raw(tmp.path());
|
||||
assert_eq!(read.transport, "slack");
|
||||
assert_eq!(read.slack_bot_token.as_deref(), Some("xoxb-abc123"));
|
||||
assert_eq!(read.slack_signing_secret.as_deref(), Some("sig-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bot_config_raw_returns_default_when_file_absent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let read = read_bot_config_raw(tmp.path());
|
||||
assert_eq!(read.transport, "");
|
||||
assert!(read.homeserver.is_none());
|
||||
#[tokio::test]
|
||||
async fn heartbeat_unknown_id_returns_not_found() {
|
||||
let state = make_test_state();
|
||||
let app = poem::Route::new()
|
||||
.at(
|
||||
"/gateway/agents/:id/heartbeat",
|
||||
poem::post(gateway_heartbeat_handler),
|
||||
)
|
||||
.data(state.clone());
|
||||
let cli = poem::test::TestClient::new(app);
|
||||
let resp = cli
|
||||
.post("/gateway/agents/no-such-id/heartbeat")
|
||||
.send()
|
||||
.await;
|
||||
assert_eq!(resp.0.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Bot configuration endpoints — GET/PUT for .huskies/bot.toml credentials.
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Tags)]
|
||||
enum BotConfigTags {
|
||||
BotConfig,
|
||||
}
|
||||
|
||||
#[derive(Object, Serialize, Deserialize, Default)]
|
||||
struct BotConfigPayload {
|
||||
pub transport: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub homeserver: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub room_ids: Option<Vec<String>>,
|
||||
pub slack_bot_token: Option<String>,
|
||||
pub slack_signing_secret: Option<String>,
|
||||
pub slack_channel_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub struct BotConfigApi {
|
||||
pub ctx: Arc<AppContext>,
|
||||
}
|
||||
|
||||
#[OpenApi(tag = "BotConfigTags::BotConfig")]
|
||||
impl BotConfigApi {
|
||||
/// Read current bot credentials from .huskies/bot.toml.
|
||||
#[oai(path = "/bot/config", method = "get")]
|
||||
async fn get_config(&self) -> OpenApiResult<Json<BotConfigPayload>> {
|
||||
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||
let path = root.join(".huskies").join("bot.toml");
|
||||
let config: BotConfigPayload = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| toml::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
Ok(Json(config))
|
||||
}
|
||||
|
||||
/// Persist bot credentials to .huskies/bot.toml.
|
||||
#[oai(path = "/bot/config", method = "put")]
|
||||
async fn put_config(
|
||||
&self,
|
||||
payload: Json<BotConfigPayload>,
|
||||
) -> OpenApiResult<Json<BotConfigPayload>> {
|
||||
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||
let path = root.join(".huskies").join("bot.toml");
|
||||
let content = toml::to_string(&payload.0).map_err(|e| bad_request(e.to_string()))?;
|
||||
std::fs::write(&path, content).map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(payload)
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,92 @@ pub(super) fn tool_get_agent_config(ctx: &AppContext) -> Result<String, String>
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
/// Get remaining turns and budget for a running agent.
|
||||
///
|
||||
/// Returns turns used, max turns, remaining turns, budget used, max budget,
|
||||
/// and remaining budget for the named agent. Fails if the agent is not
|
||||
/// currently running or pending.
|
||||
pub(super) fn tool_get_agent_remaining_turns_and_budget(
|
||||
args: &Value,
|
||||
ctx: &AppContext,
|
||||
) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
let agent_name = args
|
||||
.get("agent_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: agent_name")?;
|
||||
|
||||
// Verify the agent exists and is running/pending.
|
||||
let agents = ctx.agents.list_agents()?;
|
||||
let agent_info = agents
|
||||
.iter()
|
||||
.find(|a| a.story_id == story_id && a.agent_name == agent_name)
|
||||
.ok_or_else(|| format!("No agent '{agent_name}' found for story '{story_id}'"))?;
|
||||
|
||||
if !matches!(
|
||||
agent_info.status,
|
||||
crate::agents::AgentStatus::Running | crate::agents::AgentStatus::Pending
|
||||
) {
|
||||
return Err(format!(
|
||||
"Agent '{agent_name}' for story '{story_id}' is not running (status: {})",
|
||||
agent_info.status
|
||||
));
|
||||
}
|
||||
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
let config = ProjectConfig::load(&project_root)?;
|
||||
|
||||
// Find the agent config (max_turns, max_budget_usd).
|
||||
let agent_config = config.agent.iter().find(|a| a.name == agent_name);
|
||||
|
||||
let max_turns = agent_config.and_then(|a| a.max_turns);
|
||||
let max_budget_usd = agent_config.and_then(|a| a.max_budget_usd);
|
||||
|
||||
// Count turns by reading log files and counting assistant events.
|
||||
let log_files =
|
||||
crate::agent_log::list_story_log_files(&project_root, story_id, Some(agent_name));
|
||||
let mut turns_used: u64 = 0;
|
||||
for path in &log_files {
|
||||
if let Ok(entries) = crate::agent_log::read_log(path) {
|
||||
for entry in &entries {
|
||||
if entry.event.get("type").and_then(|v| v.as_str()) == Some("agent_json")
|
||||
&& let Some(data) = entry.event.get("data")
|
||||
&& data.get("type").and_then(|v| v.as_str()) == Some("assistant")
|
||||
{
|
||||
turns_used += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute budget used from completed-session token usage records.
|
||||
let all_records = crate::agents::token_usage::read_all(&project_root).unwrap_or_default();
|
||||
let budget_used_usd: f64 = all_records
|
||||
.iter()
|
||||
.filter(|r| r.story_id == story_id && r.agent_name == agent_name)
|
||||
.map(|r| r.usage.total_cost_usd)
|
||||
.sum();
|
||||
|
||||
let remaining_turns = max_turns.map(|max| (max as i64) - (turns_used as i64));
|
||||
let remaining_budget_usd = max_budget_usd.map(|max| max - budget_used_usd);
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"story_id": story_id,
|
||||
"agent_name": agent_name,
|
||||
"status": agent_info.status.to_string(),
|
||||
"turns_used": turns_used,
|
||||
"max_turns": max_turns,
|
||||
"remaining_turns": remaining_turns,
|
||||
"budget_used_usd": budget_used_usd,
|
||||
"max_budget_usd": max_budget_usd,
|
||||
"remaining_budget_usd": remaining_budget_usd,
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
pub(super) async fn tool_wait_for_agent(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
@@ -840,4 +926,112 @@ stage = "coder"
|
||||
let pct = read_coverage_percent_from_json(tmp.path());
|
||||
assert!(pct.is_none());
|
||||
}
|
||||
|
||||
// ── get_agent_remaining_turns_and_budget tests ──────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tool_get_agent_remaining_turns_and_budget_missing_story_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result =
|
||||
tool_get_agent_remaining_turns_and_budget(&json!({"agent_name": "coder-1"}), &ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("story_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_agent_remaining_turns_and_budget_missing_agent_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result =
|
||||
tool_get_agent_remaining_turns_and_budget(&json!({"story_id": "1_test"}), &ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("agent_name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_agent_remaining_turns_and_budget_no_agent_returns_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_get_agent_remaining_turns_and_budget(
|
||||
&json!({"story_id": "99_nope", "agent_name": "coder-1"}),
|
||||
&ctx,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.contains("No agent"),
|
||||
"expected 'No agent' error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_agent_remaining_turns_and_budget_completed_agent_returns_error() {
|
||||
use crate::agents::AgentStatus;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
ctx.agents
|
||||
.inject_test_agent("42_story", "coder-1", AgentStatus::Completed);
|
||||
|
||||
let result = tool_get_agent_remaining_turns_and_budget(
|
||||
&json!({"story_id": "42_story", "agent_name": "coder-1"}),
|
||||
&ctx,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.contains("not running"),
|
||||
"expected 'not running' error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_agent_remaining_turns_and_budget_running_agent_returns_data() {
|
||||
use crate::agents::AgentStatus;
|
||||
use crate::store::StoreOps;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
ctx.store
|
||||
.set("project_root", json!(tmp.path().to_string_lossy().as_ref()));
|
||||
ctx.agents
|
||||
.inject_test_agent("42_story", "coder-1", AgentStatus::Running);
|
||||
|
||||
let result = tool_get_agent_remaining_turns_and_budget(
|
||||
&json!({"story_id": "42_story", "agent_name": "coder-1"}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
|
||||
assert_eq!(parsed["story_id"], "42_story");
|
||||
assert_eq!(parsed["agent_name"], "coder-1");
|
||||
assert_eq!(parsed["status"], "running");
|
||||
assert!(parsed.get("turns_used").is_some());
|
||||
assert!(parsed.get("budget_used_usd").is_some());
|
||||
// max_turns and max_budget_usd may be null if not configured
|
||||
assert!(parsed.get("max_turns").is_some());
|
||||
assert!(parsed.get("remaining_turns").is_some());
|
||||
assert!(parsed.get("max_budget_usd").is_some());
|
||||
assert!(parsed.get("remaining_budget_usd").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_agent_remaining_turns_and_budget_in_tools_list() {
|
||||
use super::super::handle_tools_list;
|
||||
let resp = handle_tools_list(Some(json!(1)));
|
||||
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
|
||||
let tool = tools
|
||||
.iter()
|
||||
.find(|t| t["name"] == "get_agent_remaining_turns_and_budget");
|
||||
assert!(
|
||||
tool.is_some(),
|
||||
"get_agent_remaining_turns_and_budget missing from tools list"
|
||||
);
|
||||
let t = tool.unwrap();
|
||||
let required = t["inputSchema"]["required"].as_array().unwrap();
|
||||
let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
|
||||
assert!(req_names.contains(&"story_id"));
|
||||
assert!(req_names.contains(&"agent_name"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,6 +431,24 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
"required": ["story_id", "agent_name"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_agent_remaining_turns_and_budget",
|
||||
"description": "Get remaining turns and budget for a running agent. Returns turns used, max turns, remaining turns, budget used (from completed sessions), max budget, and remaining budget. Only works for agents in running or pending state.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"story_id": {
|
||||
"type": "string",
|
||||
"description": "Story identifier (e.g. '42_story_my_feature')"
|
||||
},
|
||||
"agent_name": {
|
||||
"type": "string",
|
||||
"description": "Agent name (e.g. 'coder-1', 'mergemaster', 'qa')"
|
||||
}
|
||||
},
|
||||
"required": ["story_id", "agent_name"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "create_worktree",
|
||||
"description": "Create a git worktree for a story under .huskies/worktrees/{story_id} with deterministic naming. Writes .mcp.json and runs component setup. Returns the worktree path.",
|
||||
@@ -1272,6 +1290,9 @@ async fn handle_tools_call(id: Option<Value>, params: &Value, ctx: &AppContext)
|
||||
"reload_agent_config" => agent_tools::tool_get_agent_config(ctx),
|
||||
"get_agent_output" => agent_tools::tool_get_agent_output(&args, ctx).await,
|
||||
"wait_for_agent" => agent_tools::tool_wait_for_agent(&args, ctx).await,
|
||||
"get_agent_remaining_turns_and_budget" => {
|
||||
agent_tools::tool_get_agent_remaining_turns_and_budget(&args, ctx)
|
||||
}
|
||||
// Worktree tools
|
||||
"create_worktree" => agent_tools::tool_create_worktree(&args, ctx).await,
|
||||
"list_worktrees" => agent_tools::tool_list_worktrees(ctx),
|
||||
@@ -1423,6 +1444,7 @@ mod tests {
|
||||
assert!(names.contains(&"reload_agent_config"));
|
||||
assert!(names.contains(&"get_agent_output"));
|
||||
assert!(names.contains(&"wait_for_agent"));
|
||||
assert!(names.contains(&"get_agent_remaining_turns_and_budget"));
|
||||
assert!(names.contains(&"create_worktree"));
|
||||
assert!(names.contains(&"list_worktrees"));
|
||||
assert!(names.contains(&"remove_worktree"));
|
||||
@@ -1469,7 +1491,7 @@ mod tests {
|
||||
assert!(names.contains(&"dump_crdt"));
|
||||
assert!(names.contains(&"get_version"));
|
||||
assert!(names.contains(&"remove_criterion"));
|
||||
assert_eq!(tools.len(), 65);
|
||||
assert_eq!(tools.len(), 66);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod agents_sse;
|
||||
pub mod anthropic;
|
||||
pub mod assets;
|
||||
pub mod bot_command;
|
||||
pub mod bot_config;
|
||||
pub mod chat;
|
||||
pub mod context;
|
||||
pub mod health;
|
||||
@@ -23,6 +24,7 @@ pub mod ws;
|
||||
use agents::AgentsApi;
|
||||
use anthropic::AnthropicApi;
|
||||
use bot_command::BotCommandApi;
|
||||
use bot_config::BotConfigApi;
|
||||
use chat::ChatApi;
|
||||
use context::AppContext;
|
||||
use health::HealthApi;
|
||||
@@ -196,6 +198,7 @@ type ApiTuple = (
|
||||
HealthApi,
|
||||
BotCommandApi,
|
||||
wizard::WizardApi,
|
||||
BotConfigApi,
|
||||
);
|
||||
|
||||
type ApiService = OpenApiService<ApiTuple, ()>;
|
||||
@@ -213,6 +216,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
||||
HealthApi,
|
||||
BotCommandApi { ctx: ctx.clone() },
|
||||
wizard::WizardApi { ctx: ctx.clone() },
|
||||
BotConfigApi { ctx: ctx.clone() },
|
||||
);
|
||||
|
||||
let api_service =
|
||||
@@ -228,7 +232,8 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
||||
SettingsApi { ctx: ctx.clone() },
|
||||
HealthApi,
|
||||
BotCommandApi { ctx: ctx.clone() },
|
||||
wizard::WizardApi { ctx },
|
||||
wizard::WizardApi { ctx: ctx.clone() },
|
||||
BotConfigApi { ctx },
|
||||
);
|
||||
|
||||
let docs_service =
|
||||
|
||||
@@ -57,6 +57,9 @@ pub struct StoryMetadata {
|
||||
/// Story numbers this story depends on. Auto-assign will skip this story
|
||||
/// until all dependencies have reached `5_done` or `6_archived`.
|
||||
pub depends_on: Option<Vec<u32>>,
|
||||
/// When `true`, the story is frozen: auto-assign skips it, the pipeline
|
||||
/// does not advance it, and no mergemaster is spawned.
|
||||
pub frozen: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -89,6 +92,8 @@ struct FrontMatter {
|
||||
blocked: Option<bool>,
|
||||
/// Story numbers this story depends on.
|
||||
depends_on: Option<Vec<u32>>,
|
||||
/// When `true`, the story is frozen.
|
||||
frozen: Option<bool>,
|
||||
}
|
||||
|
||||
pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
|
||||
@@ -129,6 +134,7 @@ fn build_metadata(front: FrontMatter) -> StoryMetadata {
|
||||
retry_count: front.retry_count,
|
||||
blocked: front.blocked,
|
||||
depends_on: front.depends_on,
|
||||
frozen: front.frozen,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,6 +445,20 @@ pub fn increment_retry_count_in_content(contents: &str) -> (String, u32) {
|
||||
(updated, new_count)
|
||||
}
|
||||
|
||||
/// Return `true` if the story has `frozen: true` in the content store.
|
||||
///
|
||||
/// Used by the pipeline advance code to suppress stage transitions for frozen stories.
|
||||
pub fn is_story_frozen_in_store(story_id: &str) -> bool {
|
||||
let contents = match crate::db::read_content(story_id) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|m| m.frozen)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Write `blocked: true` to story content (pure function).
|
||||
pub fn write_blocked_in_content(contents: &str) -> String {
|
||||
set_front_matter_field(contents, "blocked", "true")
|
||||
|
||||
Reference in New Issue
Block a user