Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce688fc0bf | |||
| c131896432 | |||
| 42e6eec9e9 | |||
| fe00fe6a25 | |||
| c97b7c841f | |||
| 2d0387fe63 | |||
| 71d3047ef0 | |||
| d86cc38b2a | |||
| 21b2efd268 | |||
| badd522d60 | |||
| ecd3f600d9 | |||
| 099df17e77 | |||
| c88e42eba2 | |||
| 89058ebd49 | |||
| d8204ab7ed | |||
| e2ea1af4c8 | |||
| 08780475d0 | |||
| 6eb2742e7d | |||
| c1b7e12b0b | |||
| 53d44ff42a | |||
| 6331dea8b0 | |||
| 240beec7de | |||
| 7de167b21b | |||
| 49af014a84 | |||
| 73cf1c6ff9 | |||
| f8b1e14b74 | |||
| 265e6f9a15 | |||
| 40e995da88 | |||
| 6e4fb7fd4b | |||
| 0695ad7ae6 | |||
| eb6b07531a | |||
| 2d6846fe03 | |||
| a5bfd40233 | |||
| a40500eea9 | |||
| f8212f102f | |||
| 59302b465d | |||
| efafe44db1 | |||
| 6a2f81e873 | |||
| 3a43337735 | |||
| b6df89d24c | |||
| 10d992a7e4 | |||
| 5c63618b30 |
@@ -15,6 +15,8 @@ _merge_parsed.json
|
|||||||
.huskies_port
|
.huskies_port
|
||||||
.huskies/bot.toml.bak
|
.huskies/bot.toml.bak
|
||||||
.huskies/build_hash
|
.huskies/build_hash
|
||||||
|
# Phantom 0-byte pipeline.db sometimes appears at repo root from old code; canonical DB lives at .huskies/pipeline.db
|
||||||
|
/pipeline.db
|
||||||
|
|
||||||
# Per-worktree planning file (written by coder agents, must never reach squash commits)
|
# Per-worktree planning file (written by coder agents, must never reach squash commits)
|
||||||
PLAN.md
|
PLAN.md
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@ There are no exceptions. The merge gate runs `source-map-check` and rejects the
|
|||||||
Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` and address every missing-docs direction it prints. If you added a new module file (e.g. `foo.rs` or `foo/mod.rs`), the FIRST line of that file MUST be a `//! What this module is for` doc comment.
|
Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` and address every missing-docs direction it prints. If you added a new module file (e.g. `foo.rs` or `foo/mod.rs`), the FIRST line of that file MUST be a `//! What this module is for` doc comment.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
Docs live in `website/docs/*.html` (static HTML), **not** Markdown files. When a story asks you to document something, edit the relevant `.html` file in `website/docs/`.
|
Docs live in `website/app/docs/*.tsx` (Next.js pages), **not** Markdown files. When a story asks you to document something, edit the relevant `.tsx` file under `website/app/docs/`. Run `npm run build` in `website/` to verify your changes render correctly.
|
||||||
|
|
||||||
## Configuration files
|
## Configuration files
|
||||||
- Agent config: `.huskies/agents.toml` (preferred) or `[[agent]]` blocks in `.huskies/project.toml`
|
- Agent config: `.huskies/agents.toml` (preferred) or `[[agent]]` blocks in `.huskies/project.toml`
|
||||||
|
|||||||
+51
-10
@@ -856,6 +856,9 @@
|
|||||||
"server/src/chat/commands/move_story.rs": [
|
"server/src/chat/commands/move_story.rs": [
|
||||||
"fn handle_move"
|
"fn handle_move"
|
||||||
],
|
],
|
||||||
|
"server/src/chat/commands/new_project.rs": [
|
||||||
|
"fn handle_new_project_fallback"
|
||||||
|
],
|
||||||
"server/src/chat/commands/overview.rs": [
|
"server/src/chat/commands/overview.rs": [
|
||||||
"fn handle_overview"
|
"fn handle_overview"
|
||||||
],
|
],
|
||||||
@@ -996,10 +999,10 @@
|
|||||||
"fn handle_message"
|
"fn handle_message"
|
||||||
],
|
],
|
||||||
"server/src/chat/transport/matrix/bot/messages/mod.rs": [
|
"server/src/chat/transport/matrix/bot/messages/mod.rs": [
|
||||||
"fn format_user_prompt",
|
"fn format_user_prompt"
|
||||||
"fn format_drained_events"
|
|
||||||
],
|
],
|
||||||
"server/src/chat/transport/matrix/bot/messages/on_room_message.rs": [
|
"server/src/chat/transport/matrix/bot/messages/on_room_message.rs": [
|
||||||
|
"fn eval_switch_command",
|
||||||
"fn on_room_message"
|
"fn on_room_message"
|
||||||
],
|
],
|
||||||
"server/src/chat/transport/matrix/bot/mod.rs": [
|
"server/src/chat/transport/matrix/bot/mod.rs": [
|
||||||
@@ -1070,6 +1073,7 @@
|
|||||||
"mod config",
|
"mod config",
|
||||||
"mod delete",
|
"mod delete",
|
||||||
"mod htop",
|
"mod htop",
|
||||||
|
"mod new_project",
|
||||||
"mod rebuild",
|
"mod rebuild",
|
||||||
"mod reset",
|
"mod reset",
|
||||||
"mod rmtree",
|
"mod rmtree",
|
||||||
@@ -1077,6 +1081,13 @@
|
|||||||
"mod transport_impl",
|
"mod transport_impl",
|
||||||
"fn spawn_bot"
|
"fn spawn_bot"
|
||||||
],
|
],
|
||||||
|
"server/src/chat/transport/matrix/new_project.rs": [
|
||||||
|
"struct NewProjectCommand",
|
||||||
|
"fn extract_new_project_command",
|
||||||
|
"fn detect_stack",
|
||||||
|
"fn image_for_stack",
|
||||||
|
"fn handle_new_project"
|
||||||
|
],
|
||||||
"server/src/chat/transport/matrix/rebuild.rs": [
|
"server/src/chat/transport/matrix/rebuild.rs": [
|
||||||
"struct RebuildCommand",
|
"struct RebuildCommand",
|
||||||
"fn extract_rebuild_command",
|
"fn extract_rebuild_command",
|
||||||
@@ -1282,6 +1293,13 @@
|
|||||||
"fn delete_agent_throttle",
|
"fn delete_agent_throttle",
|
||||||
"fn extract_agent_throttle_view"
|
"fn extract_agent_throttle_view"
|
||||||
],
|
],
|
||||||
|
"server/src/crdt_state/lww_maps/event_log.rs": [
|
||||||
|
"const GAP_PIPELINE_EVENT",
|
||||||
|
"struct EventLogEntryRaw",
|
||||||
|
"fn append_event_log_entry",
|
||||||
|
"fn append_gap_log_entry",
|
||||||
|
"fn read_all_event_log_entries"
|
||||||
|
],
|
||||||
"server/src/crdt_state/lww_maps/gateway_projects.rs": [
|
"server/src/crdt_state/lww_maps/gateway_projects.rs": [
|
||||||
"fn write_gateway_project",
|
"fn write_gateway_project",
|
||||||
"fn read_all_gateway_projects",
|
"fn read_all_gateway_projects",
|
||||||
@@ -1289,6 +1307,12 @@
|
|||||||
"fn delete_gateway_project",
|
"fn delete_gateway_project",
|
||||||
"fn extract_gateway_project_view"
|
"fn extract_gateway_project_view"
|
||||||
],
|
],
|
||||||
|
"server/src/crdt_state/lww_maps/llm_sessions.rs": [
|
||||||
|
"fn write_llm_session",
|
||||||
|
"fn read_llm_session",
|
||||||
|
"fn assemble_and_advance_session",
|
||||||
|
"fn extract_llm_session_view"
|
||||||
|
],
|
||||||
"server/src/crdt_state/lww_maps/merge_jobs.rs": [
|
"server/src/crdt_state/lww_maps/merge_jobs.rs": [
|
||||||
"fn write_merge_job",
|
"fn write_merge_job",
|
||||||
"fn read_all_merge_jobs",
|
"fn read_all_merge_jobs",
|
||||||
@@ -1364,10 +1388,13 @@
|
|||||||
"fn rebuild_active_agent_index",
|
"fn rebuild_active_agent_index",
|
||||||
"fn rebuild_test_job_index",
|
"fn rebuild_test_job_index",
|
||||||
"fn rebuild_agent_throttle_index",
|
"fn rebuild_agent_throttle_index",
|
||||||
"fn rebuild_gateway_project_index"
|
"fn rebuild_gateway_project_index",
|
||||||
|
"fn rebuild_llm_session_index"
|
||||||
],
|
],
|
||||||
"server/src/crdt_state/state/init.rs": [
|
"server/src/crdt_state/state/init.rs": [
|
||||||
"fn init"
|
"enum PersistMsg",
|
||||||
|
"fn init",
|
||||||
|
"fn flush_persistence"
|
||||||
],
|
],
|
||||||
"server/src/crdt_state/state/mod.rs": [
|
"server/src/crdt_state/state/mod.rs": [
|
||||||
"fn subscribe",
|
"fn subscribe",
|
||||||
@@ -1378,6 +1405,7 @@
|
|||||||
"fn init_for_test"
|
"fn init_for_test"
|
||||||
],
|
],
|
||||||
"server/src/crdt_state/state/statics.rs": [
|
"server/src/crdt_state/state/statics.rs": [
|
||||||
|
"static PERSIST_PENDING",
|
||||||
"static CRDT_EVENT_TX",
|
"static CRDT_EVENT_TX",
|
||||||
"static SYNC_TX",
|
"static SYNC_TX",
|
||||||
"static ALL_OPS",
|
"static ALL_OPS",
|
||||||
@@ -1393,6 +1421,12 @@
|
|||||||
"struct CrdtEvent",
|
"struct CrdtEvent",
|
||||||
"struct GatewayConfigCrdt",
|
"struct GatewayConfigCrdt",
|
||||||
"struct PipelineDoc",
|
"struct PipelineDoc",
|
||||||
|
"struct EventLogEntryCrdt",
|
||||||
|
"struct LlmSessionCrdt",
|
||||||
|
"enum ScopeFilter",
|
||||||
|
"fn from_scope_str",
|
||||||
|
"fn to_scope_str",
|
||||||
|
"struct LlmSessionView",
|
||||||
"struct PipelineItemCrdt",
|
"struct PipelineItemCrdt",
|
||||||
"struct NodePresenceCrdt",
|
"struct NodePresenceCrdt",
|
||||||
"struct EpicId",
|
"struct EpicId",
|
||||||
@@ -1583,6 +1617,14 @@
|
|||||||
"fn backup_pre_pipeline_status",
|
"fn backup_pre_pipeline_status",
|
||||||
"fn check_schema_drift"
|
"fn check_schema_drift"
|
||||||
],
|
],
|
||||||
|
"server/src/event_log/mod.rs": [
|
||||||
|
"type EventId",
|
||||||
|
"struct LoggedEvent",
|
||||||
|
"fn log_transition_event",
|
||||||
|
"fn read_event_log",
|
||||||
|
"fn insert_gap_sentinel",
|
||||||
|
"fn spawn_event_log_subscriber"
|
||||||
|
],
|
||||||
"server/src/gateway/mod.rs": [
|
"server/src/gateway/mod.rs": [
|
||||||
"fn build_gateway_route",
|
"fn build_gateway_route",
|
||||||
"fn run"
|
"fn run"
|
||||||
@@ -1594,11 +1636,6 @@
|
|||||||
"server/src/http/agents_sse.rs": [
|
"server/src/http/agents_sse.rs": [
|
||||||
"fn agent_stream"
|
"fn agent_stream"
|
||||||
],
|
],
|
||||||
"server/src/http/assets.rs": [
|
|
||||||
"fn embedded_asset",
|
|
||||||
"fn embedded_file",
|
|
||||||
"fn embedded_index"
|
|
||||||
],
|
|
||||||
"server/src/http/context.rs": [
|
"server/src/http/context.rs": [
|
||||||
"enum PermissionDecision",
|
"enum PermissionDecision",
|
||||||
"struct PermissionForward",
|
"struct PermissionForward",
|
||||||
@@ -1831,7 +1868,6 @@
|
|||||||
],
|
],
|
||||||
"server/src/http/mod.rs": [
|
"server/src/http/mod.rs": [
|
||||||
"mod agents_sse",
|
"mod agents_sse",
|
||||||
"mod assets",
|
|
||||||
"mod context",
|
"mod context",
|
||||||
"mod events",
|
"mod events",
|
||||||
"mod identity",
|
"mod identity",
|
||||||
@@ -2164,6 +2200,9 @@
|
|||||||
"struct CompletionResponse",
|
"struct CompletionResponse",
|
||||||
"trait ModelProvider"
|
"trait ModelProvider"
|
||||||
],
|
],
|
||||||
|
"server/src/llm_session/mod.rs": [
|
||||||
|
"fn assemble_prompt_context"
|
||||||
|
],
|
||||||
"server/src/log_buffer.rs": [
|
"server/src/log_buffer.rs": [
|
||||||
"enum LogLevel",
|
"enum LogLevel",
|
||||||
"fn as_str",
|
"fn as_str",
|
||||||
@@ -2184,7 +2223,9 @@
|
|||||||
"mod crdt_state",
|
"mod crdt_state",
|
||||||
"mod crdt_sync",
|
"mod crdt_sync",
|
||||||
"mod crdt_wire",
|
"mod crdt_wire",
|
||||||
|
"mod event_log",
|
||||||
"mod gateway",
|
"mod gateway",
|
||||||
|
"mod llm_session",
|
||||||
"mod log_buffer",
|
"mod log_buffer",
|
||||||
"mod mesh",
|
"mod mesh",
|
||||||
"mod node_identity",
|
"mod node_identity",
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
# Chat-Driven Project Bootstrap
|
||||||
|
|
||||||
|
Design overview for going from "I want a new project" to a running,
|
||||||
|
container-isolated, editor-accessible huskies project in one chat command.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A user can say to Timmy in chat:
|
||||||
|
|
||||||
|
```
|
||||||
|
new project myapp --stack rust
|
||||||
|
new project legacy-rails --git git@github.com:me/legacy-rails.git
|
||||||
|
```
|
||||||
|
|
||||||
|
and end up with:
|
||||||
|
|
||||||
|
1. A fresh docker container running the project's huskies node.
|
||||||
|
2. The project's source code bind-mounted from the host so the user can
|
||||||
|
edit it in any editor.
|
||||||
|
3. SSH into the container so editors can run LSPs, builds, and tests
|
||||||
|
inside the container — never on the host.
|
||||||
|
4. Optional git remote configured for push to GitHub or Gitea.
|
||||||
|
5. The new sled registered with the gateway, so Timmy can drive coders /
|
||||||
|
mergemaster / etc. on the project via existing chat commands.
|
||||||
|
|
||||||
|
Manual repo creation on GitHub/Gitea remains the user's job. Everything
|
||||||
|
downstream of that is orchestrated.
|
||||||
|
|
||||||
|
## Architecture at a Glance
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ Browser / Matrix │───┐
|
||||||
|
└──────────────────────┘ │
|
||||||
|
▼
|
||||||
|
┌───────────────────────┐
|
||||||
|
│ Gateway (huskies-gw) │
|
||||||
|
│ • chat dispatcher │
|
||||||
|
│ • new-project │
|
||||||
|
│ • routing │
|
||||||
|
└─────────┬─────────────┘
|
||||||
|
│
|
||||||
|
┌─────────┴───────────────────────────────────┐
|
||||||
|
│ docker engine (host) │
|
||||||
|
│ ┌────────────┐ ┌────────────┐ ┌─────────┐ │
|
||||||
|
│ │ project-A │ │ project-B │ │ ... │ │
|
||||||
|
│ │ sled + │ │ sled + │ │ │ │
|
||||||
|
│ │ sshd + │ │ sshd + │ │ │ │
|
||||||
|
│ │ LSPs │ │ LSPs │ │ │ │
|
||||||
|
│ └─────┬──────┘ └─────┬──────┘ └─────────┘ │
|
||||||
|
└────────┼──────────────┼─────────────────────┘
|
||||||
|
│ │
|
||||||
|
bind mount │ │ bind mount
|
||||||
|
┌────────┴───┐ ┌─────┴──────┐
|
||||||
|
│ ~/code/A │ │ ~/code/B │ ◄── host
|
||||||
|
└────────────┘ └────────────┘ editor opens
|
||||||
|
these paths
|
||||||
|
```
|
||||||
|
|
||||||
|
- One container per project. The container runs the project's huskies
|
||||||
|
binary (sled), an SSH server, and the stack-appropriate LSP(s).
|
||||||
|
- Source lives on the host (e.g. `~/code/<project>`), bind-mounted into
|
||||||
|
the container at a known path. Host can git-diff, back up, or edit.
|
||||||
|
- The gateway is editor-agnostic and project-agnostic — it talks to each
|
||||||
|
sled via the existing rendezvous / CRDT-sync protocol.
|
||||||
|
|
||||||
|
## Three Personas
|
||||||
|
|
||||||
|
| Persona | What they do | What they need |
|
||||||
|
|---------|--------------|----------------|
|
||||||
|
| Chat-only user | Drives everything via Matrix/web chat | Installed huskies binary; chat client |
|
||||||
|
| Editor-using technical user | Same + edits source in their editor | SSH config to the container + editor-specific remote-dev setup |
|
||||||
|
| Multi-project user | Several projects running in parallel | Gateway-listed projects, all routable from one chat |
|
||||||
|
|
||||||
|
Chat-only users never touch SSH. Editor users go through a one-time
|
||||||
|
"copy this SSH command into your editor's remote settings" handoff at
|
||||||
|
project creation time.
|
||||||
|
|
||||||
|
## The Bootstrap Chat Command
|
||||||
|
|
||||||
|
```
|
||||||
|
new project <name> [--stack <stack>] [--git <url>] [--path <host-path>]
|
||||||
|
```
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
1. **Validate**: name unique among existing projects; host path doesn't already
|
||||||
|
exist; stack (if declared) is one of the supported overlays.
|
||||||
|
2. **Allocate** a fresh per-project port range (gateway picks).
|
||||||
|
3. **Create host directory** at `--path` (default `~/huskies/<name>/`).
|
||||||
|
4. If `--git` provided, `git clone` into that directory; else `git init`.
|
||||||
|
5. **Detect stack** from cloned content if not declared:
|
||||||
|
- `Cargo.toml` → `rust`
|
||||||
|
- `package.json` → `node`
|
||||||
|
- `go.mod` → `go`
|
||||||
|
- `pyproject.toml` / `requirements.txt` / `setup.py` → `python`
|
||||||
|
- `Gemfile` → `ruby`
|
||||||
|
- `pom.xml` / `build.gradle` → `jvm`
|
||||||
|
- Multiple → pick the dominant, warn.
|
||||||
|
- None → minimal base image, user can install tooling later.
|
||||||
|
6. **Compose the container** from `huskies-project-base` + the stack
|
||||||
|
overlay (Dockerfile fragments under `docker/stacks/<stack>/`).
|
||||||
|
7. **Launch** the container with bind mount + port forwards + an
|
||||||
|
auto-generated SSH key.
|
||||||
|
8. **Seed `.huskies/project.toml`** with sensible defaults.
|
||||||
|
9. **Register** the project with the gateway (`gateway_projects` LWW-map).
|
||||||
|
10. **Reply in chat** with: project name, host path, SSH command, and
|
||||||
|
a `huskies status <name>` invocation to verify.
|
||||||
|
|
||||||
|
## Container Template
|
||||||
|
|
||||||
|
Layered:
|
||||||
|
|
||||||
|
- **`huskies-project-base`**: debian-slim + git + huskies binary + sshd
|
||||||
|
+ sudo + a `huskies` user with the SSH pubkey installed.
|
||||||
|
- **`huskies-stack-<stack>`**: per-stack additions. E.g. rust gets
|
||||||
|
`rustup` + `rust-analyzer` + `cargo-nextest`; node gets `node@22` +
|
||||||
|
`typescript-language-server`; etc.
|
||||||
|
- **Project layer**: the bind-mounted `/workspace` is the project source,
|
||||||
|
written by the host's editor, read by the in-container tooling.
|
||||||
|
|
||||||
|
The container's SSH server is bound to a host-local port (not exposed
|
||||||
|
externally). Auth is the per-project keypair generated at bootstrap;
|
||||||
|
the public key sits inside the container, the private key on host.
|
||||||
|
|
||||||
|
## Build Sandbox Model
|
||||||
|
|
||||||
|
The threat: editing code in a host-side editor causes the editor (or its
|
||||||
|
LSP plugin) to run `cargo check` / `npm install` / `pip install` /
|
||||||
|
similar, which executes arbitrary code from project dependencies —
|
||||||
|
`build.rs`, proc-macros, npm `postinstall`, Python `setup.py`, Ruby
|
||||||
|
native-extension build scripts, etc. A malicious dependency compromises
|
||||||
|
the host.
|
||||||
|
|
||||||
|
The mitigation: all build / type-check / dependency-install commands
|
||||||
|
execute **inside the project container**. The host's editor connects to
|
||||||
|
the container over SSH; rust-analyzer (or equivalent) runs inside the
|
||||||
|
container; the host process never `exec`s untrusted build scripts.
|
||||||
|
|
||||||
|
Container isolation is the docker default plus:
|
||||||
|
- No `--privileged`.
|
||||||
|
- No host bind mounts beyond the project source and the SSH key.
|
||||||
|
- No host network beyond the gateway's CRDT sync port.
|
||||||
|
- `--cap-drop=ALL` plus the minimum caps needed (probably none).
|
||||||
|
|
||||||
|
This isn't a hardened sandbox in the gvisor / Firecracker sense — a
|
||||||
|
docker-escape exploit on a compromised container still escalates to
|
||||||
|
host. For most consumer threat models (malicious crate from
|
||||||
|
crates.io / npm), docker's default isolation is sufficient. Tighter
|
||||||
|
sandboxing (gvisor) is a separate future spike if needed.
|
||||||
|
|
||||||
|
## Editor Connection — Editor-Agnostic SSH
|
||||||
|
|
||||||
|
| Editor | Connection mechanism |
|
||||||
|
|--------|----------------------|
|
||||||
|
| VSCode | Remote-SSH extension |
|
||||||
|
| JetBrains (IntelliJ/Rover) | JetBrains Gateway (SSH) |
|
||||||
|
| Zed | Built-in SSH remoting (mac/linux only today) |
|
||||||
|
| Vim/Neovim | SSH terminal session, or local nvim + LSP-over-SSH |
|
||||||
|
| Emacs | TRAMP + remote LSP via lsp-mode |
|
||||||
|
|
||||||
|
All converge on: `ssh huskies@127.0.0.1 -p <project-port> -i ~/.huskies/<name>/id_ed25519`.
|
||||||
|
That string is emitted in the bootstrap chat reply.
|
||||||
|
|
||||||
|
## Git Integration
|
||||||
|
|
||||||
|
- Initial setup is `git init` or `git clone` inside the container.
|
||||||
|
- For push: user's existing GitHub / Gitea SSH key is bind-mounted
|
||||||
|
read-only into the container at `~/.ssh/id_*`, OR the user supplies a
|
||||||
|
push token via `huskies secrets set GIT_TOKEN=...` (stored as a Fly
|
||||||
|
secret equivalent — for now, a chmod 600 file in the container).
|
||||||
|
- The container's `git` config gets `user.name` / `user.email` from the
|
||||||
|
gateway-level user identity.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Alternative |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| Container per project | One container per project | One container many projects: simpler but breaks isolation, breaks per-project deps |
|
||||||
|
| Editor model | SSH-remote (any editor) | VSCode Dev Containers only: simpler config but locks out everyone else |
|
||||||
|
| Source location | Bind mount from host | Inside container only: breaks "I can also edit on my laptop" requirement |
|
||||||
|
| Stack detection | Auto from project files, override with `--stack` | Always declared: more friction at bootstrap |
|
||||||
|
| Push secrets | Bind-mounted host SSH key OR per-project token | Gateway holds tokens: bigger blast radius |
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Per-project resource limits.** Should each container have a hard
|
||||||
|
CPU / RAM cap so a runaway agent doesn't starve the host?
|
||||||
|
2. **Lifecycle / cleanup.** If the user deletes a project from chat,
|
||||||
|
what gets removed? Container yes; host source no (data loss); git
|
||||||
|
remotes yes? Need a confirm step.
|
||||||
|
3. **Multi-tenant.** Out of scope for this design (that's huskies.dev
|
||||||
|
territory). This doc assumes single-user local-only.
|
||||||
|
4. **Windows specifics.** Bind mounts work but line-ending /
|
||||||
|
permission edge cases. Probably document "use WSL2 for best
|
||||||
|
experience" rather than fight Windows native paths.
|
||||||
|
5. **Gateway-on-host vs gateway-in-container.** The gateway today runs
|
||||||
|
in its own container. New per-project containers connect via docker
|
||||||
|
network. Need to confirm the network plumbing works for arbitrary
|
||||||
|
per-project containers, not just the manually-configured ones.
|
||||||
|
|
||||||
|
## Phasing
|
||||||
|
|
||||||
|
The work breaks naturally into:
|
||||||
|
|
||||||
|
- **Phase 0 (now):** this design doc.
|
||||||
|
- **Phase 1:** chat command exists and provisions a bare project
|
||||||
|
container (no stack overlay, no SSH, no git clone — just
|
||||||
|
"start a container, register with gateway"). Validates the
|
||||||
|
orchestration shell.
|
||||||
|
- **Phase 2:** stack-aware container template — base image + overlays;
|
||||||
|
detection from project files.
|
||||||
|
- **Phase 3:** SSH-remote editor access — sshd in the container,
|
||||||
|
per-project keypair, chat-reply emits the connection string.
|
||||||
|
- **Phase 4:** git integration — `--git <url>` clones, host SSH key
|
||||||
|
mount, push verification.
|
||||||
|
- **Phase 5:** per-project resource limits + cleanup chat commands.
|
||||||
|
|
||||||
|
Each phase ships independently and is usable on its own. Phase 1 alone
|
||||||
|
gives chat-only users a working project; later phases add the editor
|
||||||
|
and git polish.
|
||||||
Generated
+1
-47
@@ -1911,7 +1911,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "huskies"
|
name = "huskies"
|
||||||
version = "0.11.1"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@@ -1931,7 +1931,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"libsqlite3-sys",
|
"libsqlite3-sys",
|
||||||
"matrix-sdk",
|
"matrix-sdk",
|
||||||
"mime_guess",
|
|
||||||
"mockito",
|
"mockito",
|
||||||
"notify",
|
"notify",
|
||||||
"nutype",
|
"nutype",
|
||||||
@@ -1941,7 +1940,6 @@ dependencies = [
|
|||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rust-embed",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
@@ -2978,16 +2976,6 @@ version = "0.1.54"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cbf6f36070878c42c5233846cd3de24cf9016828fd47bc22957a687298bb21fc"
|
checksum = "cbf6f36070878c42c5233846cd3de24cf9016828fd47bc22957a687298bb21fc"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mime_guess"
|
|
||||||
version = "2.0.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
|
||||||
dependencies = [
|
|
||||||
"mime",
|
|
||||||
"unicase",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -4206,40 +4194,6 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rust-embed"
|
|
||||||
version = "8.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
|
|
||||||
dependencies = [
|
|
||||||
"rust-embed-impl",
|
|
||||||
"rust-embed-utils",
|
|
||||||
"walkdir",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rust-embed-impl"
|
|
||||||
version = "8.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"rust-embed-utils",
|
|
||||||
"syn 2.0.117",
|
|
||||||
"walkdir",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rust-embed-utils"
|
|
||||||
version = "8.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
|
|
||||||
dependencies = [
|
|
||||||
"sha2 0.10.9",
|
|
||||||
"walkdir",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ cd frontend && npm install && npm run dev
|
|||||||
|
|
||||||
Configuration lives in `.huskies/project.toml`. See `.huskies/bot.toml.*.example` for transport setup.
|
Configuration lives in `.huskies/project.toml`. See `.huskies/bot.toml.*.example` for transport setup.
|
||||||
|
|
||||||
|
## Website
|
||||||
|
|
||||||
|
The huskies.dev website source has moved to [crashlabs/huskies-server](https://code.crashlabs.io/crashlabs/huskies-server).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Internal architecture documentation lives in [`docs/architecture/`](docs/architecture/):
|
Internal architecture documentation lives in [`docs/architecture/`](docs/architecture/):
|
||||||
|
|||||||
+11
-2
@@ -46,8 +46,17 @@ WORKDIR /app
|
|||||||
# build.rs) can produce the release binary with embedded frontend assets.
|
# build.rs) can produce the release binary with embedded frontend assets.
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build frontend deps first (better layer caching)
|
# Build frontend deps first (better layer caching).
|
||||||
RUN cd frontend && npm ci
|
# Cannot use `npm ci` because of npm's optional-dependencies bug
|
||||||
|
# (npm/cli#4828): platform-specific bindings (e.g. rolldown's
|
||||||
|
# linux-arm64-gnu native binary, introduced by 1119's vite 5→8 upgrade)
|
||||||
|
# get listed in package-lock.json for the lockfile author's platform
|
||||||
|
# only, so `npm ci` skips them on every other platform — the build
|
||||||
|
# then fails at runtime with `Cannot find native binding`. Wipe the
|
||||||
|
# lockfile + node_modules and let `npm install` resolve fresh for the
|
||||||
|
# build platform. The lockfile mutation stays inside the container
|
||||||
|
# image and never reaches the host repo.
|
||||||
|
RUN cd frontend && rm -rf node_modules package-lock.json && npm install
|
||||||
|
|
||||||
# Build the release binary (build.rs runs npm run build for the frontend)
|
# Build the release binary (build.rs runs npm run build for the frontend)
|
||||||
RUN cargo build --release \
|
RUN cargo build --release \
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# huskies-project-base — minimal base for all project containers.
|
||||||
|
#
|
||||||
|
# This image provides git, the huskies server binary, and a non-root user.
|
||||||
|
# It carries no language tooling. Per-stack overlays (docker/stacks/<name>/
|
||||||
|
# Dockerfile.fragment) layer their toolchains on top of this base.
|
||||||
|
#
|
||||||
|
# Prerequisites: build the main `huskies` image first so its binary is
|
||||||
|
# available as a build source.
|
||||||
|
#
|
||||||
|
# docker build -t huskies -f docker/Dockerfile .
|
||||||
|
# docker build -t huskies-project-base -f docker/Dockerfile.base .
|
||||||
|
#
|
||||||
|
# To build a stack image (e.g. rust):
|
||||||
|
# (echo "FROM huskies-project-base"; \
|
||||||
|
# cat docker/stacks/rust/Dockerfile.fragment) | \
|
||||||
|
# docker build -t huskies-project-rust -
|
||||||
|
|
||||||
|
FROM huskies AS huskies-src
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
ca-certificates \
|
||||||
|
libssl3 \
|
||||||
|
procps \
|
||||||
|
openssh-server \
|
||||||
|
sudo \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy the huskies binary and entrypoint from the main image.
|
||||||
|
COPY --from=huskies-src /usr/local/bin/huskies /usr/local/bin/huskies
|
||||||
|
COPY --from=huskies-src /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
# Non-root user — Claude Code refuses --dangerously-skip-permissions as root.
|
||||||
|
# -s /bin/bash required for SSH sessions to start a real shell.
|
||||||
|
RUN groupadd -r huskies \
|
||||||
|
&& useradd -r -g huskies -m -d /home/huskies -s /bin/bash huskies \
|
||||||
|
&& mkdir -p /home/huskies/.claude \
|
||||||
|
&& mkdir -p /home/huskies/.ssh \
|
||||||
|
&& chmod 700 /home/huskies/.ssh \
|
||||||
|
&& chown -R huskies:huskies /home/huskies \
|
||||||
|
&& mkdir -p /workspace \
|
||||||
|
&& chown huskies:huskies /workspace \
|
||||||
|
&& git config --global init.defaultBranch master \
|
||||||
|
&& echo "huskies ALL=(root) NOPASSWD: /usr/sbin/sshd" > /etc/sudoers.d/huskies-sshd \
|
||||||
|
&& chmod 0440 /etc/sudoers.d/huskies-sshd \
|
||||||
|
&& mkdir -p /run/sshd \
|
||||||
|
&& sed -i \
|
||||||
|
-e 's/#PasswordAuthentication yes/PasswordAuthentication no/' \
|
||||||
|
-e 's/#PubkeyAuthentication yes/PubkeyAuthentication yes/' \
|
||||||
|
-e 's/UsePAM yes/UsePAM no/' \
|
||||||
|
/etc/ssh/sshd_config
|
||||||
|
|
||||||
|
# Shell profile for SSH sessions: land in /workspace and load toolchain paths.
|
||||||
|
RUN printf 'cd /workspace\n[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"\n' \
|
||||||
|
> /home/huskies/.profile \
|
||||||
|
&& chown huskies:huskies /home/huskies/.profile
|
||||||
|
|
||||||
|
USER huskies
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
EXPOSE 3001 22
|
||||||
|
|
||||||
|
ENTRYPOINT ["entrypoint.sh"]
|
||||||
|
CMD ["huskies", "/workspace"]
|
||||||
@@ -1,6 +1,22 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# ── SSH authorized key ────────────────────────────────────────────────
|
||||||
|
# HUSKIES_SSH_PUBKEY is set by `new project` when it generates a keypair.
|
||||||
|
# Write it to authorized_keys so the user can connect with the matching
|
||||||
|
# private key stored at ~/.huskies/<project>/id_ed25519 on the host.
|
||||||
|
if [ -n "$HUSKIES_SSH_PUBKEY" ]; then
|
||||||
|
mkdir -p /home/huskies/.ssh
|
||||||
|
chmod 700 /home/huskies/.ssh
|
||||||
|
printf '%s\n' "$HUSKIES_SSH_PUBKEY" > /home/huskies/.ssh/authorized_keys
|
||||||
|
chmod 600 /home/huskies/.ssh/authorized_keys
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── SSH daemon ────────────────────────────────────────────────────────
|
||||||
|
# Start sshd in the background so the container accepts SSH connections.
|
||||||
|
# Uses sudo (huskies has NOPASSWD for /usr/sbin/sshd in sudoers.d).
|
||||||
|
sudo /usr/sbin/sshd -D -e &
|
||||||
|
|
||||||
# ── Git identity ─────────────────────────────────────────────────────
|
# ── Git identity ─────────────────────────────────────────────────────
|
||||||
# Agents commit code inside the container. Without a git identity,
|
# Agents commit code inside the container. Without a git identity,
|
||||||
# commits fail or use garbage defaults. Fail loudly at startup so the
|
# commits fail or use garbage defaults. Fail loudly at startup so the
|
||||||
@@ -25,6 +41,20 @@ export GIT_COMMITTER_NAME="$GIT_USER_NAME"
|
|||||||
export GIT_AUTHOR_EMAIL="$GIT_USER_EMAIL"
|
export GIT_AUTHOR_EMAIL="$GIT_USER_EMAIL"
|
||||||
export GIT_COMMITTER_EMAIL="$GIT_USER_EMAIL"
|
export GIT_COMMITTER_EMAIL="$GIT_USER_EMAIL"
|
||||||
|
|
||||||
|
# ── Git credential helper (HTTPS push) ────────────────────────────────────
|
||||||
|
# If GIT_PUSH_TOKEN is supplied at container creation time, configure git's
|
||||||
|
# built-in credential store so `git push` over HTTPS authenticates without
|
||||||
|
# user interaction. GIT_CLONE_URL provides the host portion of the URL used
|
||||||
|
# as the key in ~/.git-credentials.
|
||||||
|
if [ -n "$GIT_PUSH_TOKEN" ] && [ -n "$GIT_CLONE_URL" ]; then
|
||||||
|
_scheme=$(echo "$GIT_CLONE_URL" | cut -d':' -f1)
|
||||||
|
_host=$(echo "$GIT_CLONE_URL" | sed 's|^https\?://||' | cut -d'/' -f1)
|
||||||
|
git config --global credential.helper store
|
||||||
|
printf '%s://x-access-token:%s@%s\n' "$_scheme" "$GIT_PUSH_TOKEN" "$_host" \
|
||||||
|
> /home/huskies/.git-credentials
|
||||||
|
chmod 600 /home/huskies/.git-credentials
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Frontend native deps ────────────────────────────────────────────
|
# ── Frontend native deps ────────────────────────────────────────────
|
||||||
# The project repo is bind-mounted from the host, so node_modules/
|
# The project repo is bind-mounted from the host, so node_modules/
|
||||||
# may contain native binaries for the wrong platform (e.g. darwin
|
# may contain native binaries for the wrong platform (e.g. darwin
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Go stack overlay fragment.
|
||||||
|
#
|
||||||
|
# Layer this on top of huskies-project-base to produce a project container
|
||||||
|
# with Go 1.22, gopls (official Go language server), and standard tooling.
|
||||||
|
#
|
||||||
|
# Build the combined image:
|
||||||
|
# (echo "FROM huskies-project-base"; \
|
||||||
|
# cat docker/stacks/go/Dockerfile.fragment) | \
|
||||||
|
# docker build -t huskies-project-go -
|
||||||
|
#
|
||||||
|
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||||
|
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Official Go binary distribution — Debian's golang-go package is too old for gopls.
|
||||||
|
# Update GOVERSION to pick up a newer release.
|
||||||
|
ENV GOVERSION="1.22.3"
|
||||||
|
RUN curl -fsSL "https://go.dev/dl/go${GOVERSION}.linux-amd64.tar.gz" \
|
||||||
|
| tar -C /usr/local -xzf -
|
||||||
|
|
||||||
|
ENV PATH="/usr/local/go/bin:${PATH}"
|
||||||
|
|
||||||
|
# gopls: the official Go language server.
|
||||||
|
# GOBIN=/usr/local/bin puts the binary on the system PATH for all users.
|
||||||
|
RUN GOBIN=/usr/local/bin go install golang.org/x/tools/gopls@latest
|
||||||
|
|
||||||
|
USER huskies
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Stack detection markers for the go stack.
|
||||||
|
# Each non-blank, non-comment line names a file relative to the project root.
|
||||||
|
# If any listed file exists in the project directory, this stack is matched.
|
||||||
|
go.mod
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# JVM stack overlay fragment.
|
||||||
|
#
|
||||||
|
# Layer this on top of huskies-project-base to produce a project container
|
||||||
|
# with OpenJDK 21, Maven, and eclipse.jdt.ls (the canonical Java/JVM LSP).
|
||||||
|
#
|
||||||
|
# Build the combined image:
|
||||||
|
# (echo "FROM huskies-project-base"; \
|
||||||
|
# cat docker/stacks/jvm/Dockerfile.fragment) | \
|
||||||
|
# docker build -t huskies-project-jvm -
|
||||||
|
#
|
||||||
|
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||||
|
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# OpenJDK 21 (current LTS) and Maven for build support.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
openjdk-21-jdk-headless \
|
||||||
|
maven \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV JAVA_HOME="/usr/lib/jvm/java-21-openjdk-amd64"
|
||||||
|
|
||||||
|
# Eclipse JDT Language Server — canonical LSP for Java/JVM (Java, Kotlin, Groovy).
|
||||||
|
# Pin to a specific release; update JDTLS_VERSION + JDTLS_BUILD for upgrades.
|
||||||
|
# All releases: https://github.com/eclipse-jdtls/eclipse.jdt.ls/releases
|
||||||
|
ENV JDTLS_VERSION="1.38.0" \
|
||||||
|
JDTLS_BUILD="202503271418"
|
||||||
|
RUN mkdir -p /opt/jdtls \
|
||||||
|
&& curl -fsSL \
|
||||||
|
"https://download.eclipse.org/jdtls/milestones/${JDTLS_VERSION}/jdt-language-server-${JDTLS_VERSION}-${JDTLS_BUILD}.tar.gz" \
|
||||||
|
| tar -xzf - -C /opt/jdtls
|
||||||
|
|
||||||
|
# Wrapper script so `jdtls` is available as a PATH command.
|
||||||
|
RUN { \
|
||||||
|
echo '#!/bin/sh'; \
|
||||||
|
echo 'JAR=$(ls /opt/jdtls/plugins/org.eclipse.equinox.launcher_*.jar 2>/dev/null | head -1)'; \
|
||||||
|
echo 'exec java \'; \
|
||||||
|
echo ' -Declipse.application=org.eclipse.jdt.ls.core.id1 \'; \
|
||||||
|
echo ' -Dosgi.bundles.defaultStartLevel=4 \'; \
|
||||||
|
echo ' -Declipse.product=org.eclipse.jdt.ls.core.product \'; \
|
||||||
|
echo ' -Dlog.protocol=true \'; \
|
||||||
|
echo ' -Dlog.level=ALL \'; \
|
||||||
|
echo ' -jar "$JAR" \'; \
|
||||||
|
echo ' -configuration /opt/jdtls/config_linux \'; \
|
||||||
|
echo ' "$@"'; \
|
||||||
|
} > /usr/local/bin/jdtls \
|
||||||
|
&& chmod +x /usr/local/bin/jdtls
|
||||||
|
|
||||||
|
USER huskies
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Stack detection markers for the jvm stack.
|
||||||
|
# Each non-blank, non-comment line names a file relative to the project root.
|
||||||
|
# If any listed file exists in the project directory, this stack is matched.
|
||||||
|
pom.xml
|
||||||
|
build.gradle
|
||||||
|
build.gradle.kts
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Node stack overlay fragment.
|
||||||
|
#
|
||||||
|
# Layer this on top of huskies-project-base to produce a project container
|
||||||
|
# with Node.js 22, TypeScript (tsc), and typescript-language-server.
|
||||||
|
#
|
||||||
|
# Build the combined image:
|
||||||
|
# (echo "FROM huskies-project-base"; \
|
||||||
|
# cat docker/stacks/node/Dockerfile.fragment) | \
|
||||||
|
# docker build -t huskies-project-node -
|
||||||
|
#
|
||||||
|
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||||
|
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Node.js 22.x (LTS).
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||||
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# TypeScript compiler and language server for LSP-aware agents.
|
||||||
|
# tsc: TypeScript compiler (tsc --version)
|
||||||
|
# typescript-language-server: LSP server used by editors/agents
|
||||||
|
RUN npm install -g typescript typescript-language-server
|
||||||
|
|
||||||
|
USER huskies
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Stack detection markers for the node stack.
|
||||||
|
# Each non-blank, non-comment line names a file relative to the project root.
|
||||||
|
# If any listed file exists in the project directory, this stack is matched.
|
||||||
|
# tsconfig.json is listed explicitly so TypeScript-only projects are detected
|
||||||
|
# even without a package.json at the repo root.
|
||||||
|
package.json
|
||||||
|
tsconfig.json
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Python stack overlay fragment.
|
||||||
|
#
|
||||||
|
# Layer this on top of huskies-project-base to produce a project container
|
||||||
|
# with Python 3, pip, and pyright (the Microsoft Python LSP / type checker).
|
||||||
|
#
|
||||||
|
# Build the combined image:
|
||||||
|
# (echo "FROM huskies-project-base"; \
|
||||||
|
# cat docker/stacks/python/Dockerfile.fragment) | \
|
||||||
|
# docker build -t huskies-project-python -
|
||||||
|
#
|
||||||
|
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||||
|
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Python 3 runtime and pip.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# pyright: Microsoft's Python language server / static type checker.
|
||||||
|
# --break-system-packages is required on Debian 12+ where pip is externally
|
||||||
|
# managed; the flag is safe inside a Docker container.
|
||||||
|
RUN pip install --no-cache-dir --break-system-packages pyright
|
||||||
|
|
||||||
|
USER huskies
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Stack detection markers for the python stack.
|
||||||
|
# Each non-blank, non-comment line names a file relative to the project root.
|
||||||
|
# If any listed file exists in the project directory, this stack is matched.
|
||||||
|
pyproject.toml
|
||||||
|
requirements.txt
|
||||||
|
setup.py
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Ruby stack overlay fragment.
|
||||||
|
#
|
||||||
|
# Layer this on top of huskies-project-base to produce a project container
|
||||||
|
# with Ruby, Bundler, and ruby-lsp (the Shopify Ruby language server).
|
||||||
|
#
|
||||||
|
# Build the combined image:
|
||||||
|
# (echo "FROM huskies-project-base"; \
|
||||||
|
# cat docker/stacks/ruby/Dockerfile.fragment) | \
|
||||||
|
# docker build -t huskies-project-ruby -
|
||||||
|
#
|
||||||
|
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||||
|
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Ruby runtime, development headers (needed by native gem extensions), and Bundler.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ruby \
|
||||||
|
ruby-dev \
|
||||||
|
bundler \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ruby-lsp: Shopify's Ruby language server (LSP-compliant, actively maintained).
|
||||||
|
# Installed globally so the `ruby-lsp` binary is available on PATH.
|
||||||
|
RUN gem install ruby-lsp
|
||||||
|
|
||||||
|
USER huskies
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Stack detection markers for the ruby stack.
|
||||||
|
# Each non-blank, non-comment line names a file relative to the project root.
|
||||||
|
# If any listed file exists in the project directory, this stack is matched.
|
||||||
|
Gemfile
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Rust stack overlay fragment.
|
||||||
|
#
|
||||||
|
# Layer this on top of huskies-project-base to produce a project container
|
||||||
|
# with a full Rust toolchain, rust-analyzer, and cargo-nextest.
|
||||||
|
#
|
||||||
|
# Build the combined image:
|
||||||
|
# (echo "FROM huskies-project-base"; \
|
||||||
|
# cat docker/stacks/rust/Dockerfile.fragment) | \
|
||||||
|
# docker build -t huskies-project-rust -
|
||||||
|
#
|
||||||
|
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||||
|
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Build tools required by rustup and many Rust crates.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
pkg-config \
|
||||||
|
libssl-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV RUSTUP_HOME="/home/huskies/.rustup" \
|
||||||
|
CARGO_HOME="/home/huskies/.cargo"
|
||||||
|
|
||||||
|
# Install stable Rust + rust-analyzer component as the huskies user.
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||||
|
| su huskies -c "sh -s -- -y --no-modify-path --default-toolchain stable" \
|
||||||
|
&& /home/huskies/.cargo/bin/rustup component add rust-analyzer \
|
||||||
|
&& chown -R huskies:huskies /home/huskies/.rustup /home/huskies/.cargo
|
||||||
|
|
||||||
|
# cargo-nextest: fast Rust test runner used by huskies quality gates.
|
||||||
|
RUN curl -LsSf https://get.nexte.st/latest/linux | tar zxf - -C /usr/local/bin
|
||||||
|
|
||||||
|
ENV PATH="/home/huskies/.cargo/bin:${PATH}"
|
||||||
|
|
||||||
|
USER huskies
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Stack detection markers for the rust stack.
|
||||||
|
# Each non-blank, non-comment line names a file relative to the project root.
|
||||||
|
# If any listed file exists in the project directory, this stack is matched.
|
||||||
|
Cargo.toml
|
||||||
Generated
+798
-1068
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "huskies",
|
"name": "huskies",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.11.1",
|
"version": "0.12.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -32,11 +32,11 @@
|
|||||||
"@types/node": "^25.0.0",
|
"@types/node": "^25.0.0",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"@vitest/coverage-v8": "^2.1.9",
|
"@vitest/coverage-v8": "^4.1.6",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^5.4.21",
|
"vite": "^8.0.13",
|
||||||
"vitest": "^2.1.4"
|
"vitest": "^4.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ describe("App", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shows error when openProject fails", async () => {
|
it("shows error when openProject fails", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
mockedApi.openProject.mockRejectedValue(new Error("Path does not exist"));
|
mockedApi.openProject.mockRejectedValue(new Error("Path does not exist"));
|
||||||
|
|
||||||
await renderApp();
|
await renderApp();
|
||||||
@@ -182,6 +183,7 @@ describe("App", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/Path does not exist/)).toBeInTheDocument();
|
expect(screen.getByText(/Path does not exist/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
errorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows known projects list", async () => {
|
it("shows known projects list", async () => {
|
||||||
|
|||||||
@@ -266,6 +266,8 @@ describe("subscribeAgentStream", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("handles malformed JSON without throwing", () => {
|
it("handles malformed JSON without throwing", () => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
|
|||||||
@@ -472,9 +472,16 @@ describe("Slash command handling (Story 374)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Story 1058: WebSocket errors do not appear in chat", () => {
|
describe("Story 1058: WebSocket errors do not appear in chat", () => {
|
||||||
|
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
capturedWsHandlers = null;
|
capturedWsHandlers = null;
|
||||||
setupMocks();
|
setupMocks();
|
||||||
|
consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not add a chat message when onError is called", async () => {
|
it("does not add a chat message when onError is called", async () => {
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ describe("usePathCompletion hook", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sets completionError when listDirectoryAbsolute throws an Error", async () => {
|
it("sets completionError when listDirectoryAbsolute throws an Error", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
mockListDir.mockRejectedValue(new Error("Permission denied"));
|
mockListDir.mockRejectedValue(new Error("Permission denied"));
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
@@ -242,9 +243,13 @@ describe("usePathCompletion hook", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.completionError).toBe("Permission denied");
|
expect(result.current.completionError).toBe("Permission denied");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(new Error("Permission denied"));
|
||||||
|
errorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets generic completionError when listDirectoryAbsolute throws a non-Error", async () => {
|
it("sets generic completionError when listDirectoryAbsolute throws a non-Error", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
mockListDir.mockRejectedValue("some string error");
|
mockListDir.mockRejectedValue("some string error");
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
@@ -262,6 +267,9 @@ describe("usePathCompletion hook", () => {
|
|||||||
"Failed to compute suggestion.",
|
"Failed to compute suggestion.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("some string error");
|
||||||
|
errorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears suggestionTail when selected match path does not start with input", async () => {
|
it("clears suggestionTail when selected match path does not start with input", async () => {
|
||||||
|
|||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Build all project images in dependency order:
|
||||||
|
# huskies → huskies-project-base → huskies-project-<stack> (one per stack fragment)
|
||||||
|
#
|
||||||
|
# Run this after `script/docker_rebuild` or whenever you add a new stack.
|
||||||
|
# Safe to re-run: each step re-tags the image with the latest layers.
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
if [[ -f .env ]]; then
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
CACHE_FLAG=""
|
||||||
|
if [[ "${1:-}" == "--no-cache" ]]; then
|
||||||
|
CACHE_FLAG="--no-cache"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Building huskies"
|
||||||
|
docker build $CACHE_FLAG -t huskies -f docker/Dockerfile .
|
||||||
|
|
||||||
|
echo "==> Building huskies-project-base"
|
||||||
|
docker build $CACHE_FLAG -t huskies-project-base -f docker/Dockerfile.base .
|
||||||
|
|
||||||
|
for fragment in docker/stacks/*/Dockerfile.fragment; do
|
||||||
|
stack=$(basename "$(dirname "$fragment")")
|
||||||
|
image="huskies-project-${stack}"
|
||||||
|
echo "==> Building ${image}"
|
||||||
|
(printf 'FROM huskies-project-base\n'; cat "$fragment") \
|
||||||
|
| docker build $CACHE_FLAG -t "$image" -
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "All project images built."
|
||||||
@@ -24,4 +24,6 @@ docker compose -f docker/docker-compose.yml down
|
|||||||
docker compose -f docker/docker-compose.yml build $CACHE_FLAG
|
docker compose -f docker/docker-compose.yml build $CACHE_FLAG
|
||||||
docker compose -f docker/docker-compose.yml up -d
|
docker compose -f docker/docker-compose.yml up -d
|
||||||
|
|
||||||
|
script/build-project-images $CACHE_FLAG
|
||||||
|
|
||||||
echo "Rebuild complete. Logs: docker compose -f docker/docker-compose.yml logs -f"
|
echo "Rebuild complete. Logs: docker compose -f docker/docker-compose.yml logs -f"
|
||||||
|
|||||||
+12
-10
@@ -11,10 +11,12 @@ export GIT_CONFIG_VALUE_0=master
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
# Ordered fail-fast: cheapest deterministic checks first, slowest builds and
|
# Ordered fail-fast: cheapest deterministic checks first. The frontend build
|
||||||
# test suites last. `set -euo pipefail` aborts at the first failure, so a fmt
|
# must run *before* anything that compiles Rust, because story 1113 introduced
|
||||||
# or clippy drift never wastes time on a frontend build or a multi-minute
|
# a compile-time dependency on `frontend/dist/` via `rust-embed` — a fresh
|
||||||
# test run.
|
# merge worktree without that directory will fail `cargo clippy` on
|
||||||
|
# `EmbeddedAssets::iter()` before the frontend build has a chance to populate
|
||||||
|
# it. `set -euo pipefail` aborts at the first failure.
|
||||||
|
|
||||||
echo "=== Checking Rust formatting ==="
|
echo "=== Checking Rust formatting ==="
|
||||||
if cargo fmt --version &>/dev/null; then
|
if cargo fmt --version &>/dev/null; then
|
||||||
@@ -44,12 +46,6 @@ if [ "$_dup_found" -eq 1 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "=== Running cargo clippy ==="
|
|
||||||
cargo clippy --manifest-path "$PROJECT_ROOT/Cargo.toml" --all-targets --all-features -- -D warnings
|
|
||||||
|
|
||||||
echo "=== Checking doc coverage on changed files ==="
|
|
||||||
cargo run --manifest-path "$PROJECT_ROOT/Cargo.toml" -p source-map-gen --bin source-map-check --quiet -- --worktree "$PROJECT_ROOT" --base master
|
|
||||||
|
|
||||||
echo "=== Building frontend ==="
|
echo "=== Building frontend ==="
|
||||||
if [ -d "$PROJECT_ROOT/frontend" ]; then
|
if [ -d "$PROJECT_ROOT/frontend" ]; then
|
||||||
cd "$PROJECT_ROOT/frontend"
|
cd "$PROJECT_ROOT/frontend"
|
||||||
@@ -75,6 +71,12 @@ else
|
|||||||
echo "Skipping frontend build (no frontend directory)"
|
echo "Skipping frontend build (no frontend directory)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "=== Running cargo clippy ==="
|
||||||
|
cargo clippy --manifest-path "$PROJECT_ROOT/Cargo.toml" --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
|
echo "=== Checking doc coverage on changed files ==="
|
||||||
|
cargo run --manifest-path "$PROJECT_ROOT/Cargo.toml" -p source-map-gen --bin source-map-check --quiet -- --worktree "$PROJECT_ROOT" --base master
|
||||||
|
|
||||||
echo "=== Running Rust tests ==="
|
echo "=== Running Rust tests ==="
|
||||||
cargo test --manifest-path "$PROJECT_ROOT/Cargo.toml" --bin huskies
|
cargo test --manifest-path "$PROJECT_ROOT/Cargo.toml" --bin huskies
|
||||||
cargo test --manifest-path "$PROJECT_ROOT/Cargo.toml" -p source-map-gen
|
cargo test --manifest-path "$PROJECT_ROOT/Cargo.toml" -p source-map-gen
|
||||||
|
|||||||
+1
-3
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "huskies"
|
name = "huskies"
|
||||||
version = "0.11.1"
|
version = "0.12.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
@@ -13,12 +13,10 @@ chrono-tz = { workspace = true }
|
|||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
homedir = { workspace = true }
|
homedir = { workspace = true }
|
||||||
ignore = { workspace = true }
|
ignore = { workspace = true }
|
||||||
mime_guess = { workspace = true }
|
|
||||||
notify = { workspace = true }
|
notify = { workspace = true }
|
||||||
poem = { workspace = true, features = ["websocket"] }
|
poem = { workspace = true, features = ["websocket"] }
|
||||||
portable-pty = { workspace = true }
|
portable-pty = { workspace = true }
|
||||||
reqwest = { workspace = true, features = ["json", "stream", "form"] }
|
reqwest = { workspace = true, features = ["json", "stream", "form"] }
|
||||||
rust-embed = { workspace = true }
|
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde_urlencoded = { workspace = true }
|
serde_urlencoded = { workspace = true }
|
||||||
|
|||||||
@@ -33,16 +33,28 @@ impl GateFailureKind {
|
|||||||
/// Called once when a gate fails to produce a typed kind. Downstream code
|
/// Called once when a gate fails to produce a typed kind. Downstream code
|
||||||
/// matches on the variant and must not call this on subsequent reads.
|
/// matches on the variant and must not call this on subsequent reads.
|
||||||
pub fn classify(output: &str) -> Self {
|
pub fn classify(output: &str) -> Self {
|
||||||
|
// Strip `test <name> ... ok` lines before checking lint-trigger keywords so
|
||||||
|
// a passing test whose name contains e.g. `missing_doc_comments` or `clippy::`
|
||||||
|
// does not produce a false-positive Lint classification (story 1101).
|
||||||
|
let stripped_for_lint: String = output
|
||||||
|
.lines()
|
||||||
|
.filter(|l| {
|
||||||
|
let t = l.trim();
|
||||||
|
!(t.starts_with("test ") && t.ends_with("... ok"))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let is_lint = stripped_for_lint.contains("error[clippy::")
|
||||||
|
|| stripped_for_lint.contains("warning[clippy::")
|
||||||
|
|| stripped_for_lint.contains("missing_doc_comments");
|
||||||
|
|
||||||
if output.contains("CONFLICT (content):") || output.contains("Merge conflict:") {
|
if output.contains("CONFLICT (content):") || output.contains("Merge conflict:") {
|
||||||
GateFailureKind::ContentConflict
|
GateFailureKind::ContentConflict
|
||||||
} else if output.contains("Diff in ") || output.contains("would reformat") {
|
} else if output.contains("Diff in ") || output.contains("would reformat") {
|
||||||
GateFailureKind::Fmt
|
GateFailureKind::Fmt
|
||||||
} else if output.contains("missing-docs direction") {
|
} else if output.contains("missing-docs direction") {
|
||||||
GateFailureKind::SourceMapCheck
|
GateFailureKind::SourceMapCheck
|
||||||
} else if output.contains("error[clippy::")
|
} else if is_lint {
|
||||||
|| output.contains("warning[clippy::")
|
|
||||||
|| output.contains("missing_doc_comments")
|
|
||||||
{
|
|
||||||
GateFailureKind::Lint
|
GateFailureKind::Lint
|
||||||
} else if output.contains("error[E") {
|
} else if output.contains("error[E") {
|
||||||
// rustc compile errors (e.g. `error[E0063]: missing field`).
|
// rustc compile errors (e.g. `error[E0063]: missing field`).
|
||||||
@@ -871,6 +883,19 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Story 1101: a passing test whose name contains a lint trigger keyword
|
||||||
|
/// must NOT produce a Lint classification.
|
||||||
|
#[test]
|
||||||
|
fn classify_does_not_false_positive_on_test_name_substring() {
|
||||||
|
let output = "test agents::gates::tests::classify_lint_from_missing_doc_comments ... ok\n\
|
||||||
|
test result: ok. 1 passed; 0 failed";
|
||||||
|
assert_ne!(
|
||||||
|
GateFailureKind::classify(output),
|
||||||
|
GateFailureKind::Lint,
|
||||||
|
"passing test name containing 'missing_doc_comments' must not classify as Lint"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_source_map_check_from_missing_docs_direction() {
|
fn classify_source_map_check_from_missing_docs_direction() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -186,50 +186,6 @@ impl AgentPool {
|
|||||||
.map(|k| k.is_self_evident_fix())
|
.map(|k| k.is_self_evident_fix())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
// Bug 1101 diagnostic: log the classified failure_kind and the
|
|
||||||
// matched classifier-trigger substring with surrounding context,
|
|
||||||
// so we can confirm whether classify() is incorrectly matching
|
|
||||||
// a passing-step stdout substring (e.g. "Diff in " inside a
|
|
||||||
// failing test's panic message) and bouncing the story to a
|
|
||||||
// fixup coder. Remove once the fix lands.
|
|
||||||
if let Ok(r) = report.as_ref()
|
|
||||||
&& let crate::agents::merge::MergeResult::GateFailure {
|
|
||||||
output: gate_output,
|
|
||||||
failure_kind: Some(k),
|
|
||||||
} = &r.result
|
|
||||||
{
|
|
||||||
const TRIGGERS: &[&str] = &[
|
|
||||||
"CONFLICT (content):",
|
|
||||||
"Merge conflict:",
|
|
||||||
"Diff in ",
|
|
||||||
"would reformat",
|
|
||||||
"missing-docs direction",
|
|
||||||
"error[clippy::",
|
|
||||||
"warning[clippy::",
|
|
||||||
"missing_doc_comments",
|
|
||||||
"error[E",
|
|
||||||
];
|
|
||||||
let matched = TRIGGERS
|
|
||||||
.iter()
|
|
||||||
.find_map(|t| gate_output.find(t).map(|i| (*t, i)));
|
|
||||||
let (trigger, context) = match matched {
|
|
||||||
Some((t, i)) => {
|
|
||||||
let start = i.saturating_sub(30);
|
|
||||||
let end = (i + t.len() + 60).min(gate_output.len());
|
|
||||||
let ctx = gate_output
|
|
||||||
.get(start..end)
|
|
||||||
.unwrap_or("<context unavailable>")
|
|
||||||
.replace('\n', " ");
|
|
||||||
(Some(t), ctx)
|
|
||||||
}
|
|
||||||
None => (None, String::from("<no trigger matched>")),
|
|
||||||
};
|
|
||||||
slog!(
|
|
||||||
"[merge] classify diagnostic for '{sid}': failure_kind={k:?} \
|
|
||||||
is_fixup={is_fixup} trigger={trigger:?} context='{context}'"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if is_no_commits {
|
if is_no_commits {
|
||||||
let reason = kind.display_reason();
|
let reason = kind.display_reason();
|
||||||
if let Err(e) = crate::agents::lifecycle::transition_to_blocked(&sid, &reason) {
|
if let Err(e) = crate::agents::lifecycle::transition_to_blocked(&sid, &reason) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ mod help;
|
|||||||
pub(crate) mod loc;
|
pub(crate) mod loc;
|
||||||
mod logs;
|
mod logs;
|
||||||
mod move_story;
|
mod move_story;
|
||||||
|
mod new_project;
|
||||||
mod overview;
|
mod overview;
|
||||||
mod run_tests;
|
mod run_tests;
|
||||||
mod setup;
|
mod setup;
|
||||||
@@ -262,6 +263,11 @@ pub fn commands() -> &'static [BotCommand] {
|
|||||||
description: "List orphaned worktrees (dry run), or `cleanup_worktrees --confirm` to remove them",
|
description: "List orphaned worktrees (dry run), or `cleanup_worktrees --confirm` to remove them",
|
||||||
handler: handle_cleanup_worktrees_fallback,
|
handler: handle_cleanup_worktrees_fallback,
|
||||||
},
|
},
|
||||||
|
BotCommand {
|
||||||
|
name: "new",
|
||||||
|
description: "Bootstrap a new project container (gateway only): `new project <name>`",
|
||||||
|
handler: new_project::handle_new_project_fallback,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//! `new project` command stub.
|
||||||
|
//!
|
||||||
|
//! The command is handled asynchronously in the Matrix transport's
|
||||||
|
//! `on_room_message` handler (gateway mode only). This file exists so that
|
||||||
|
//! `help` lists the command and the gateway proxy block does not forward it
|
||||||
|
//! to the active project sled.
|
||||||
|
|
||||||
|
use super::CommandContext;
|
||||||
|
|
||||||
|
/// Fallback handler for the `new` command when it is not intercepted by the
|
||||||
|
/// async gateway handler in `on_room_message`. In practice this is never
|
||||||
|
/// called — `new project` is detected and handled before `try_handle_command`
|
||||||
|
/// runs in gateway mode, and in standalone mode there is no matching project
|
||||||
|
/// bootstrap context.
|
||||||
|
///
|
||||||
|
/// Returns `None` to prevent the LLM from receiving the raw command text.
|
||||||
|
pub fn handle_new_project_fallback(_ctx: &CommandContext) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -300,6 +300,20 @@ pub(super) async fn handle_incoming_message(
|
|||||||
handle_llm_message(ctx, channel, user, message).await;
|
handle_llm_message(ctx, channel, user, message).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the prompt for a Discord LLM turn, prepending any pending
|
||||||
|
/// CRDT pipeline-transition events as a `<system-reminder>` block.
|
||||||
|
fn build_discord_llm_prompt(
|
||||||
|
session_id: &str,
|
||||||
|
bot_name: &str,
|
||||||
|
user: &str,
|
||||||
|
user_message: &str,
|
||||||
|
) -> String {
|
||||||
|
let event_ctx = crate::llm_session::assemble_prompt_context(session_id);
|
||||||
|
format!(
|
||||||
|
"{event_ctx}[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n\n{user}: {user_message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Forward a message to Claude Code and send the response back via Discord.
|
/// Forward a message to Claude Code and send the response back via Discord.
|
||||||
async fn handle_llm_message(ctx: &DiscordContext, channel: &str, user: &str, user_message: &str) {
|
async fn handle_llm_message(ctx: &DiscordContext, channel: &str, user: &str, user_message: &str) {
|
||||||
use crate::chat::util::drain_complete_paragraphs;
|
use crate::chat::util::drain_complete_paragraphs;
|
||||||
@@ -314,8 +328,11 @@ async fn handle_llm_message(ctx: &DiscordContext, channel: &str, user: &str, use
|
|||||||
};
|
};
|
||||||
|
|
||||||
let bot_name = &ctx.services.bot_name;
|
let bot_name = &ctx.services.bot_name;
|
||||||
let prompt = format!(
|
let prompt = build_discord_llm_prompt(
|
||||||
"[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n\n{user}: {user_message}"
|
resume_session_id.as_deref().unwrap_or(channel),
|
||||||
|
bot_name,
|
||||||
|
user,
|
||||||
|
user_message,
|
||||||
);
|
);
|
||||||
|
|
||||||
let provider = ClaudeCodeProvider::new();
|
let provider = ClaudeCodeProvider::new();
|
||||||
@@ -604,4 +621,40 @@ mod tests {
|
|||||||
assert!(conv.session_id.is_none(), "session_id should be cleared");
|
assert!(conv.session_id.is_none(), "session_id should be cleared");
|
||||||
assert!(conv.entries.is_empty(), "entries should be cleared");
|
assert!(conv.entries.is_empty(), "entries should be cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AC 4: fire a `TransitionFired` event, simulate a Discord user turn, and
|
||||||
|
/// assert the assembled prompt contains the event (end-to-end non-Matrix test).
|
||||||
|
#[test]
|
||||||
|
fn discord_prompt_includes_transition_event() {
|
||||||
|
use crate::pipeline_state::{PipelineEvent, PlanState, Stage, StoryId, TransitionFired};
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
|
|
||||||
|
crate::event_log::log_transition_event(&TransitionFired {
|
||||||
|
story_id: StoryId("77_discord_test".to_string()),
|
||||||
|
before: Stage::Backlog,
|
||||||
|
after: Stage::Coding {
|
||||||
|
claim: None,
|
||||||
|
plan: PlanState::Missing,
|
||||||
|
retries: 0,
|
||||||
|
},
|
||||||
|
event: PipelineEvent::DepsMet,
|
||||||
|
at: chrono::Utc::now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let prompt =
|
||||||
|
build_discord_llm_prompt("discord-ch-test", "Timmy", "@alice", "what is the status?");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
prompt.contains("<system-reminder>"),
|
||||||
|
"assembled prompt must include system-reminder block; got: {prompt}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
prompt.contains("77_discord_test"),
|
||||||
|
"assembled prompt must contain story id; got: {prompt}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
prompt.contains("what is the status?"),
|
||||||
|
"assembled prompt must contain user message; got: {prompt}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Matrix bot context — shared state for the Matrix bot (rooms, history, permissions).
|
//! Matrix bot context — shared state for the Matrix bot (rooms, history, permissions).
|
||||||
use crate::chat::ChatTransport;
|
use crate::chat::ChatTransport;
|
||||||
|
use crate::service::gateway::config::ProjectEntry;
|
||||||
use crate::service::timer::TimerStore;
|
use crate::service::timer::TimerStore;
|
||||||
use crate::services::Services;
|
use crate::services::Services;
|
||||||
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId, OwnedUserId};
|
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId, OwnedUserId};
|
||||||
@@ -87,27 +88,15 @@ pub struct BotContext {
|
|||||||
/// In gateway mode: the currently active project (shared with the gateway HTTP handler).
|
/// In gateway mode: the currently active project (shared with the gateway HTTP handler).
|
||||||
/// `None` in standalone single-project mode.
|
/// `None` in standalone single-project mode.
|
||||||
pub gateway_active_project: Option<Arc<RwLock<String>>>,
|
pub gateway_active_project: Option<Arc<RwLock<String>>>,
|
||||||
/// In gateway mode: valid project names accepted by the `switch` command.
|
|
||||||
/// Empty in standalone mode.
|
|
||||||
pub gateway_projects: Vec<String>,
|
|
||||||
/// In gateway mode: mapping of project name → base URL (e.g. `"http://localhost:3001"`).
|
/// In gateway mode: mapping of project name → base URL (e.g. `"http://localhost:3001"`).
|
||||||
/// Used to proxy bot commands to the active project over WebSocket (`/ws`).
|
/// Used to proxy bot commands to the active project over WebSocket (`/ws`).
|
||||||
/// Empty in standalone mode.
|
/// Empty in standalone mode.
|
||||||
pub gateway_project_urls: BTreeMap<String, String>,
|
pub gateway_project_urls: BTreeMap<String, String>,
|
||||||
/// Pipeline transition events buffered since the last LLM turn.
|
/// In gateway mode: shared live projects map from [`GatewayState`].
|
||||||
///
|
///
|
||||||
/// A background task appends one compact audit line per real stage
|
/// The `new project` command writes here so HTTP handlers see the new entry
|
||||||
/// transition. `handle_message` drains this buffer and injects it as a
|
/// immediately without requiring a gateway restart. `None` in standalone mode.
|
||||||
/// `<system-reminder>` block at the head of the next user prompt so Timmy
|
pub gateway_projects_store: Option<Arc<RwLock<BTreeMap<String, ProjectEntry>>>>,
|
||||||
/// sees pipeline activity without requiring a separate message.
|
|
||||||
pub pending_pipeline_events: Arc<TokioMutex<Vec<String>>>,
|
|
||||||
/// Gateway aggregate transition events buffered since the last LLM turn.
|
|
||||||
///
|
|
||||||
/// In gateway mode a background task appends one compact audit line per
|
|
||||||
/// `GatewayStatusEvent` received from the gateway broadcaster. Drained
|
|
||||||
/// alongside `pending_pipeline_events` on each user message. Always
|
|
||||||
/// empty in standalone (non-gateway) mode.
|
|
||||||
pub pending_gateway_events: Arc<TokioMutex<Vec<String>>>,
|
|
||||||
/// Bounded FIFO set of already-handled incoming event IDs.
|
/// Bounded FIFO set of already-handled incoming event IDs.
|
||||||
///
|
///
|
||||||
/// The Matrix sync loop can replay events on reconnect. This set ensures
|
/// The Matrix sync loop can replay events on reconnect. This set ensures
|
||||||
@@ -277,7 +266,6 @@ mod tests {
|
|||||||
fn test_bot_context(
|
fn test_bot_context(
|
||||||
services: Arc<Services>,
|
services: Arc<Services>,
|
||||||
gateway_active_project: Option<Arc<RwLock<String>>>,
|
gateway_active_project: Option<Arc<RwLock<String>>>,
|
||||||
gateway_projects: Vec<String>,
|
|
||||||
gateway_project_urls: BTreeMap<String, String>,
|
gateway_project_urls: BTreeMap<String, String>,
|
||||||
) -> BotContext {
|
) -> BotContext {
|
||||||
BotContext {
|
BotContext {
|
||||||
@@ -298,10 +286,8 @@ mod tests {
|
|||||||
std::path::PathBuf::from("/tmp/timers.json"),
|
std::path::PathBuf::from("/tmp/timers.json"),
|
||||||
)),
|
)),
|
||||||
gateway_active_project,
|
gateway_active_project,
|
||||||
gateway_projects,
|
|
||||||
gateway_project_urls,
|
gateway_project_urls,
|
||||||
pending_pipeline_events: Arc::new(TokioMutex::new(Vec::new())),
|
gateway_projects_store: None,
|
||||||
pending_gateway_events: Arc::new(TokioMutex::new(Vec::new())),
|
|
||||||
handled_incoming_event_ids: Arc::new(TokioMutex::new(SeenEventIds::new(
|
handled_incoming_event_ids: Arc::new(TokioMutex::new(SeenEventIds::new(
|
||||||
SEEN_EVENT_IDS_CAP,
|
SEEN_EVENT_IDS_CAP,
|
||||||
))),
|
))),
|
||||||
@@ -318,7 +304,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn effective_project_root_standalone_returns_project_root() {
|
async fn effective_project_root_standalone_returns_project_root() {
|
||||||
let services = test_services(PathBuf::from("/projects/myapp"));
|
let services = test_services(PathBuf::from("/projects/myapp"));
|
||||||
let ctx = test_bot_context(services, None, vec![], BTreeMap::new());
|
let ctx = test_bot_context(services, None, BTreeMap::new());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ctx.effective_project_root().await,
|
ctx.effective_project_root().await,
|
||||||
PathBuf::from("/projects/myapp")
|
PathBuf::from("/projects/myapp")
|
||||||
@@ -332,7 +318,6 @@ mod tests {
|
|||||||
let ctx = test_bot_context(
|
let ctx = test_bot_context(
|
||||||
services,
|
services,
|
||||||
Some(Arc::clone(&active)),
|
Some(Arc::clone(&active)),
|
||||||
vec!["huskies".into(), "robot-studio".into()],
|
|
||||||
BTreeMap::from([
|
BTreeMap::from([
|
||||||
("huskies".into(), "http://localhost:3001".into()),
|
("huskies".into(), "http://localhost:3001".into()),
|
||||||
("robot-studio".into(), "http://localhost:3002".into()),
|
("robot-studio".into(), "http://localhost:3002".into()),
|
||||||
@@ -351,7 +336,6 @@ mod tests {
|
|||||||
let ctx = test_bot_context(
|
let ctx = test_bot_context(
|
||||||
services,
|
services,
|
||||||
Some(Arc::clone(&active)),
|
Some(Arc::clone(&active)),
|
||||||
vec!["huskies".into(), "robot-studio".into()],
|
|
||||||
BTreeMap::from([
|
BTreeMap::from([
|
||||||
("huskies".into(), "http://localhost:3001".into()),
|
("huskies".into(), "http://localhost:3001".into()),
|
||||||
("robot-studio".into(), "http://localhost:3002".into()),
|
("robot-studio".into(), "http://localhost:3002".into()),
|
||||||
@@ -432,7 +416,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn bot_context_has_no_require_verified_devices_field() {
|
fn bot_context_has_no_require_verified_devices_field() {
|
||||||
let services = test_services(PathBuf::from("/tmp"));
|
let services = test_services(PathBuf::from("/tmp"));
|
||||||
let ctx = test_bot_context(services, None, vec![], BTreeMap::new());
|
let ctx = test_bot_context(services, None, BTreeMap::new());
|
||||||
let _cloned = ctx.clone();
|
let _cloned = ctx.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,7 +466,6 @@ mod tests {
|
|||||||
let ctx = test_bot_context(
|
let ctx = test_bot_context(
|
||||||
services,
|
services,
|
||||||
Some(Arc::clone(&active)),
|
Some(Arc::clone(&active)),
|
||||||
vec!["huskies".into()],
|
|
||||||
BTreeMap::from([("huskies".into(), base_url)]),
|
BTreeMap::from([("huskies".into(), base_url)]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use super::super::context::BotContext;
|
|||||||
use super::super::format::markdown_to_html;
|
use super::super::format::markdown_to_html;
|
||||||
use super::super::history::{ConversationEntry, ConversationRole, save_history};
|
use super::super::history::{ConversationEntry, ConversationRole, save_history};
|
||||||
|
|
||||||
use super::{format_drained_events, format_user_prompt};
|
use super::format_user_prompt;
|
||||||
|
|
||||||
pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
||||||
room_id_str: String,
|
room_id_str: String,
|
||||||
@@ -31,28 +31,10 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
|||||||
guard.get(&room_id).and_then(|conv| conv.session_id.clone())
|
guard.get(&room_id).and_then(|conv| conv.session_id.clone())
|
||||||
};
|
};
|
||||||
|
|
||||||
// Drain pipeline and gateway transition events buffered since the last LLM
|
// Pull new pipeline-transition events from the CRDT event log for this
|
||||||
// turn and prepend them as a passive <system-reminder> block so Timmy sees
|
// session and atomically advance the high-water marks so the same events
|
||||||
// pipeline activity without requiring a separate message. Sled events come
|
// are not re-injected on the next turn.
|
||||||
// from `pending_pipeline_events`; gateway events from `pending_gateway_events`.
|
let event_log_ctx = crate::llm_session::assemble_prompt_context(&room_id_str);
|
||||||
// In practice only one buffer is non-empty (sled mode vs gateway mode).
|
|
||||||
let system_reminder_prefix = {
|
|
||||||
let mut sled_guard = ctx.pending_pipeline_events.lock().await;
|
|
||||||
let mut gtw_guard = ctx.pending_gateway_events.lock().await;
|
|
||||||
let all_lines: Vec<String> = sled_guard.drain(..).chain(gtw_guard.drain(..)).collect();
|
|
||||||
drop(sled_guard);
|
|
||||||
drop(gtw_guard);
|
|
||||||
slog!(
|
|
||||||
"[matrix-bot] drained {} gateway audit lines for LLM context",
|
|
||||||
all_lines.len()
|
|
||||||
);
|
|
||||||
let prefix = format_drained_events(all_lines);
|
|
||||||
slog!(
|
|
||||||
"[matrix-bot] format_drained_events output: {} bytes",
|
|
||||||
prefix.len()
|
|
||||||
);
|
|
||||||
prefix
|
|
||||||
};
|
|
||||||
|
|
||||||
// The prompt is just the current message with sender attribution.
|
// The prompt is just the current message with sender attribution.
|
||||||
// Prior conversation context is carried by the Claude Code session.
|
// Prior conversation context is carried by the Claude Code session.
|
||||||
@@ -64,7 +46,7 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
|||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
let prompt = format!(
|
let prompt = format!(
|
||||||
"{system_reminder_prefix}[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n{active_project_ctx}\n{}",
|
"{event_log_ctx}[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n{active_project_ctx}\n{}",
|
||||||
format_user_prompt(&sender, &user_message)
|
format_user_prompt(&sender, &user_message)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,27 +11,6 @@ pub(super) fn format_user_prompt(sender: &str, message: &str) -> String {
|
|||||||
format!("{sender}: {message}")
|
format!("{sender}: {message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain `lines` into a `<system-reminder>` block for injection at the head of
|
|
||||||
/// the next LLM prompt. Returns an empty string when `lines` is empty.
|
|
||||||
///
|
|
||||||
/// At most 20 lines are shown verbatim; excess lines are replaced with a
|
|
||||||
/// `…and N more` indicator to keep context size bounded.
|
|
||||||
pub(in crate::chat::transport::matrix::bot) fn format_drained_events(lines: Vec<String>) -> String {
|
|
||||||
if lines.is_empty() {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
const MAX_PIPELINE_EVENTS: usize = 20;
|
|
||||||
let total = lines.len();
|
|
||||||
let shown_count = total.min(MAX_PIPELINE_EVENTS);
|
|
||||||
let shown = lines[..shown_count].join("\n");
|
|
||||||
let tail = if total > MAX_PIPELINE_EVENTS {
|
|
||||||
format!("\n...and {} more", total - MAX_PIPELINE_EVENTS)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
format!("<system-reminder>\n{shown}{tail}\n</system-reminder>\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Matrix event handler for room messages. Each invocation spawns an
|
/// Matrix event handler for room messages. Each invocation spawns an
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -72,49 +51,6 @@ mod tests {
|
|||||||
assert!(crate::llm::oauth::extract_login_url_from_error(err).is_none());
|
assert!(crate::llm::oauth::extract_login_url_from_error(err).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- format_drained_events ----------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_drained_events_empty_returns_empty_string() {
|
|
||||||
assert_eq!(format_drained_events(vec![]), String::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_drained_events_wraps_in_system_reminder() {
|
|
||||||
let result = format_drained_events(vec!["audit ts=2026 id=1 event=x".to_string()]);
|
|
||||||
assert!(result.starts_with("<system-reminder>\n"), "got: {result}");
|
|
||||||
assert!(result.ends_with("</system-reminder>\n"), "got: {result}");
|
|
||||||
assert!(
|
|
||||||
result.contains("audit ts=2026 id=1 event=x"),
|
|
||||||
"got: {result}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_drained_events_caps_at_20_with_overflow_indicator() {
|
|
||||||
let lines: Vec<String> = (0..25).map(|i| format!("line {i}")).collect();
|
|
||||||
let result = format_drained_events(lines);
|
|
||||||
assert!(result.contains("...and 5 more"), "got: {result}");
|
|
||||||
assert!(
|
|
||||||
result.contains("line 19"),
|
|
||||||
"last shown line missing; got: {result}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!result.contains("line 20"),
|
|
||||||
"line 21 must be hidden; got: {result}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_drained_events_exactly_20_no_overflow_indicator() {
|
|
||||||
let lines: Vec<String> = (0..20).map(|i| format!("line {i}")).collect();
|
|
||||||
let result = format_drained_events(lines);
|
|
||||||
assert!(
|
|
||||||
!result.contains("...and"),
|
|
||||||
"must not show overflow when exactly 20; got: {result}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- bot_name / system prompt -------------------------------------------
|
// -- bot_name / system prompt -------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -19,6 +19,31 @@ use super::super::verification::check_sender_verified;
|
|||||||
|
|
||||||
use super::handle_message;
|
use super::handle_message;
|
||||||
|
|
||||||
|
/// Evaluate a `switch <arg>` command against the live project store.
|
||||||
|
///
|
||||||
|
/// Reads valid project names from the store at call time so newly added
|
||||||
|
/// projects are visible without a bot restart. Returns the reply text.
|
||||||
|
pub(super) async fn eval_switch_command(
|
||||||
|
arg: &str,
|
||||||
|
active_project: &tokio::sync::RwLock<String>,
|
||||||
|
store: &tokio::sync::RwLock<
|
||||||
|
std::collections::BTreeMap<String, crate::service::gateway::config::ProjectEntry>,
|
||||||
|
>,
|
||||||
|
) -> String {
|
||||||
|
let projects: Vec<String> = store.read().await.keys().cloned().collect();
|
||||||
|
if arg.is_empty() {
|
||||||
|
let available = projects.join(", ");
|
||||||
|
format!("Usage: `switch <project>`. Available projects: {available}")
|
||||||
|
} else if projects.iter().any(|p| p == arg) {
|
||||||
|
*active_project.write().await = arg.to_string();
|
||||||
|
crate::crdt_state::write_gateway_active_project(arg);
|
||||||
|
format!("Switched to project **{arg}**.")
|
||||||
|
} else {
|
||||||
|
let available = projects.join(", ");
|
||||||
|
format!("Unknown project `{arg}`. Available: {available}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||||
ev: OriginalSyncRoomMessageEvent,
|
ev: OriginalSyncRoomMessageEvent,
|
||||||
room: Room,
|
room: Room,
|
||||||
@@ -193,7 +218,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
|||||||
if ctx.is_gateway() {
|
if ctx.is_gateway() {
|
||||||
// Commands that are meaningful on the gateway itself (no project state needed).
|
// Commands that are meaningful on the gateway itself (no project state needed).
|
||||||
const GATEWAY_LOCAL_COMMANDS: &[&str] =
|
const GATEWAY_LOCAL_COMMANDS: &[&str] =
|
||||||
&["help", "ambient", "reset", "switch", "all_status"];
|
&["help", "ambient", "reset", "switch", "all_status", "new"];
|
||||||
|
|
||||||
let stripped = crate::chat::util::strip_bot_mention(
|
let stripped = crate::chat::util::strip_bot_mention(
|
||||||
&user_message,
|
&user_message,
|
||||||
@@ -260,6 +285,49 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
|||||||
// Gateway-local commands and freeform text fall through to normal handling below.
|
// Gateway-local commands and freeform text fall through to normal handling below.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In gateway mode, handle the "new project <name> [--stack <stack>]" command
|
||||||
|
// to bootstrap a project container and register it with the gateway.
|
||||||
|
if ctx.is_gateway()
|
||||||
|
&& let Some(cmd) = super::super::super::new_project::extract_new_project_command(
|
||||||
|
&user_message,
|
||||||
|
&ctx.services.bot_name,
|
||||||
|
ctx.matrix_user_id.as_str(),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
slog!(
|
||||||
|
"[matrix-bot] Handling new project command from {sender}: name={:?} stack={:?} git_url={:?} adopt_path={:?}",
|
||||||
|
cmd.name,
|
||||||
|
cmd.stack,
|
||||||
|
cmd.git_url,
|
||||||
|
cmd.adopt_path,
|
||||||
|
);
|
||||||
|
let response = if let Some(ref store) = ctx.gateway_projects_store {
|
||||||
|
super::super::super::new_project::handle_new_project(
|
||||||
|
&cmd.name,
|
||||||
|
cmd.stack.as_deref(),
|
||||||
|
cmd.git_url.as_deref(),
|
||||||
|
cmd.git_token.as_deref(),
|
||||||
|
cmd.host_path.as_deref(),
|
||||||
|
cmd.adopt_path.as_deref(),
|
||||||
|
store,
|
||||||
|
&ctx.services.project_root,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
"Gateway projects store unavailable — cannot create project.".to_string()
|
||||||
|
};
|
||||||
|
let html = markdown_to_html(&response);
|
||||||
|
if let Ok(msg_id) = ctx
|
||||||
|
.transport
|
||||||
|
.send_message(&room_id_str, &response, &html)
|
||||||
|
.await
|
||||||
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
|
{
|
||||||
|
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for bot-level commands (help, status, ambient, …) before invoking
|
// Check for bot-level commands (help, status, ambient, …) before invoking
|
||||||
// the LLM. All commands are registered in commands.rs — no special-casing
|
// the LLM. All commands are registered in commands.rs — no special-casing
|
||||||
// needed here.
|
// needed here.
|
||||||
@@ -529,16 +597,10 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if cmd.eq_ignore_ascii_case("switch") {
|
if cmd.eq_ignore_ascii_case("switch") {
|
||||||
let response = if arg.is_empty() {
|
let response = if let Some(ref store) = ctx.gateway_projects_store {
|
||||||
let available = ctx.gateway_projects.join(", ");
|
eval_switch_command(&arg, active_project, store).await
|
||||||
format!("Usage: `switch <project>`. Available projects: {available}")
|
|
||||||
} else if ctx.gateway_projects.iter().any(|p| p == &arg) {
|
|
||||||
*active_project.write().await = arg.clone();
|
|
||||||
crate::crdt_state::write_gateway_active_project(&arg);
|
|
||||||
format!("Switched to project **{arg}**.")
|
|
||||||
} else {
|
} else {
|
||||||
let available = ctx.gateway_projects.join(", ");
|
"Switch is unavailable: project store not initialised.".to_string()
|
||||||
format!("Unknown project `{arg}`. Available: {available}")
|
|
||||||
};
|
};
|
||||||
let html = markdown_to_html(&response);
|
let html = markdown_to_html(&response);
|
||||||
if let Ok(msg_id) = ctx
|
if let Ok(msg_id) = ctx
|
||||||
@@ -661,3 +723,80 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
|||||||
.chat_dispatcher
|
.chat_dispatcher
|
||||||
.submit(room_id_str, user_message, factory);
|
.submit(room_id_str, user_message, factory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::eval_switch_command;
|
||||||
|
use crate::service::gateway::config::ProjectEntry;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
/// Regression test: `switch` reads from the live store, not a snapshot Vec.
|
||||||
|
///
|
||||||
|
/// Seeds an empty store, inserts a project at runtime, then asserts the
|
||||||
|
/// command finds it — covering the bug where a stale `gateway_projects` Vec
|
||||||
|
/// caused newly added projects to be invisible until the bot restarted.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn switch_reads_live_store_after_runtime_insert() {
|
||||||
|
let active = RwLock::new("huskies".to_string());
|
||||||
|
let store: RwLock<BTreeMap<String, ProjectEntry>> = RwLock::new(BTreeMap::new());
|
||||||
|
|
||||||
|
// Empty store: unknown project.
|
||||||
|
let resp = eval_switch_command("robot-studio", &active, &store).await;
|
||||||
|
assert!(
|
||||||
|
resp.contains("Unknown project"),
|
||||||
|
"empty store should not find robot-studio: {resp}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert the project at runtime — no restart.
|
||||||
|
store.write().await.insert(
|
||||||
|
"robot-studio".to_string(),
|
||||||
|
ProjectEntry {
|
||||||
|
url: Some("http://localhost:3002".to_string()),
|
||||||
|
auth_token: None,
|
||||||
|
ssh_port: None,
|
||||||
|
host_path: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now the live store has the project; switch must succeed.
|
||||||
|
let resp = eval_switch_command("robot-studio", &active, &store).await;
|
||||||
|
assert_eq!(
|
||||||
|
resp, "Switched to project **robot-studio**.",
|
||||||
|
"live store insert must be visible without restart: {resp}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*active.read().await,
|
||||||
|
"robot-studio",
|
||||||
|
"active project must be updated after switch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn switch_empty_arg_lists_available_projects() {
|
||||||
|
let active = RwLock::new("huskies".to_string());
|
||||||
|
let store: RwLock<BTreeMap<String, ProjectEntry>> = RwLock::new(BTreeMap::from([(
|
||||||
|
"huskies".to_string(),
|
||||||
|
ProjectEntry {
|
||||||
|
url: None,
|
||||||
|
auth_token: None,
|
||||||
|
ssh_port: None,
|
||||||
|
host_path: None,
|
||||||
|
},
|
||||||
|
)]));
|
||||||
|
|
||||||
|
let resp = eval_switch_command("", &active, &store).await;
|
||||||
|
assert!(
|
||||||
|
resp.contains("Usage:"),
|
||||||
|
"empty arg should show usage: {resp}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
resp.contains("huskies"),
|
||||||
|
"usage should list available projects: {resp}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,8 +28,14 @@ pub async fn run_bot(
|
|||||||
watcher_tx: tokio::sync::broadcast::Sender<crate::io::watcher::WatcherEvent>,
|
watcher_tx: tokio::sync::broadcast::Sender<crate::io::watcher::WatcherEvent>,
|
||||||
shutdown_rx: watch::Receiver<Option<crate::rebuild::ShutdownReason>>,
|
shutdown_rx: watch::Receiver<Option<crate::rebuild::ShutdownReason>>,
|
||||||
gateway_active_project: Option<Arc<RwLock<String>>>,
|
gateway_active_project: Option<Arc<RwLock<String>>>,
|
||||||
gateway_projects: Vec<String>,
|
|
||||||
gateway_project_urls: std::collections::BTreeMap<String, String>,
|
gateway_project_urls: std::collections::BTreeMap<String, String>,
|
||||||
|
gateway_projects_store: Option<
|
||||||
|
Arc<
|
||||||
|
RwLock<
|
||||||
|
std::collections::BTreeMap<String, crate::service::gateway::config::ProjectEntry>,
|
||||||
|
>,
|
||||||
|
>,
|
||||||
|
>,
|
||||||
timer_store: Arc<TimerStore>,
|
timer_store: Arc<TimerStore>,
|
||||||
gateway_event_rx: Option<
|
gateway_event_rx: Option<
|
||||||
tokio::sync::broadcast::Receiver<crate::service::gateway::GatewayStatusEvent>,
|
tokio::sync::broadcast::Receiver<crate::service::gateway::GatewayStatusEvent>,
|
||||||
@@ -297,93 +303,11 @@ pub async fn run_bot(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to pipeline stage transitions and buffer compact audit lines
|
// The forwarder only needs live (future) events — resubscribe is fine.
|
||||||
// between Timmy's turns. Replay events (before == after stage label) are
|
// Pipeline-transition context is now delivered to the LLM via
|
||||||
// silently dropped — only real transitions are recorded.
|
// `assemble_prompt_context` (CRDT event log) rather than these in-memory
|
||||||
let pending_pipeline_events: Arc<TokioMutex<Vec<String>>> =
|
// buffers, so the buffer tasks are gone; only the forwarder remains.
|
||||||
Arc::new(TokioMutex::new(Vec::new()));
|
let gateway_event_rx_for_forwarder = gateway_event_rx.map(|rx| rx.resubscribe());
|
||||||
{
|
|
||||||
use crate::pipeline_state::{format_audit_entry, stage_label, subscribe_transitions};
|
|
||||||
let mut rx = subscribe_transitions();
|
|
||||||
let buf = Arc::clone(&pending_pipeline_events);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
match rx.recv().await {
|
|
||||||
Ok(fired) => {
|
|
||||||
if stage_label(&fired.before) == stage_label(&fired.after) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let line = format_audit_entry(&fired);
|
|
||||||
buf.lock().await.push(line);
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
|
||||||
slog!("[matrix-bot] pipeline event buffer lagged by {n} events");
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to gateway-side status events and buffer compact audit lines for
|
|
||||||
// the LLM context.
|
|
||||||
//
|
|
||||||
// Investigation log (story 1078) — hypotheses ruled out:
|
|
||||||
// (A) gateway_event_rx is None: impossible — spawn_gateway_bot always passes
|
|
||||||
// Some(state.event_tx.clone()) in gateway mode (gateway/mod.rs:130).
|
|
||||||
// (B) recv() never returns: buf task uses the ORIGINAL event_rx (subscribed
|
|
||||||
// before Matrix init) so any events buffered during init are visible;
|
|
||||||
// future events arrive normally via the shared broadcast channel.
|
|
||||||
// (C) Different Arc: buf and ctx.pending_gateway_events are both clones of
|
|
||||||
// the same Arc<TokioMutex<Vec<String>>> — writes in the buf task are
|
|
||||||
// immediately visible to handle_message.
|
|
||||||
// (D) format_drained_events empty on non-empty input: the function is
|
|
||||||
// pure/tested; the drain slog in handle_message now makes the count
|
|
||||||
// observable so we can confirm it is non-zero when events arrive.
|
|
||||||
//
|
|
||||||
// Bug fixed here: previously the buffer task held `event_rx.resubscribe()`,
|
|
||||||
// which starts at the *current tail* (next unsent message) and silently
|
|
||||||
// discards every event that arrived during the Matrix login / room-join /
|
|
||||||
// cross-signing phase (~5–30 s window). The forwarder now gets the
|
|
||||||
// resubscribed receiver (only needs live events going forward); the buffer
|
|
||||||
// task holds the original `event_rx` so it drains the init-window backlog
|
|
||||||
// on first poll.
|
|
||||||
let pending_gateway_events: Arc<TokioMutex<Vec<String>>> =
|
|
||||||
Arc::new(TokioMutex::new(Vec::new()));
|
|
||||||
let gateway_event_rx_for_forwarder = if let Some(event_rx) = gateway_event_rx {
|
|
||||||
// The forwarder only needs live (future) events — resubscribe is fine.
|
|
||||||
let forwarder_rx = event_rx.resubscribe();
|
|
||||||
// Buffer task: hold the *original* receiver so init-window events are
|
|
||||||
// not lost. Silently accumulate compact audit lines for Timmy's context.
|
|
||||||
{
|
|
||||||
use crate::service::gateway::polling::format_gateway_audit_line;
|
|
||||||
let buf = Arc::clone(&pending_gateway_events);
|
|
||||||
slog!("[matrix-bot] subscribed to gateway events; buffer task starting");
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut rx = event_rx;
|
|
||||||
loop {
|
|
||||||
match rx.recv().await {
|
|
||||||
Ok(event) => {
|
|
||||||
slog!(
|
|
||||||
"[matrix-bot] buffered audit line for project={} id={}",
|
|
||||||
event.project,
|
|
||||||
event.event.timestamp_ms()
|
|
||||||
);
|
|
||||||
let line = format_gateway_audit_line(&event.project, &event.event);
|
|
||||||
buf.lock().await.push(line);
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
|
||||||
slog!("[matrix-bot] gateway event buffer lagged by {n} events");
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Some(forwarder_rx)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let ctx = BotContext {
|
let ctx = BotContext {
|
||||||
services,
|
services,
|
||||||
@@ -397,10 +321,8 @@ pub async fn run_bot(
|
|||||||
transport: Arc::clone(&transport),
|
transport: Arc::clone(&transport),
|
||||||
timer_store,
|
timer_store,
|
||||||
gateway_active_project,
|
gateway_active_project,
|
||||||
gateway_projects,
|
|
||||||
gateway_project_urls,
|
gateway_project_urls,
|
||||||
pending_pipeline_events,
|
gateway_projects_store,
|
||||||
pending_gateway_events,
|
|
||||||
handled_incoming_event_ids: Arc::new(TokioMutex::new(super::context::SeenEventIds::new(
|
handled_incoming_event_ids: Arc::new(TokioMutex::new(super::context::SeenEventIds::new(
|
||||||
super::context::SEEN_EVENT_IDS_CAP,
|
super::context::SEEN_EVENT_IDS_CAP,
|
||||||
))),
|
))),
|
||||||
@@ -620,89 +542,4 @@ mod tests {
|
|||||||
assert_eq!(steps[2], 20);
|
assert_eq!(steps[2], 20);
|
||||||
assert_eq!(steps[3], 40);
|
assert_eq!(steps[3], 40);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression test (story 1078): gateway broadcast events must reach
|
|
||||||
/// `pending_gateway_events` and produce an `audit ts=…` line in the
|
|
||||||
/// `format_drained_events` output that is prepended to Timmy's prompt.
|
|
||||||
///
|
|
||||||
/// The test spins up a mock `event_tx` broadcaster, sends one
|
|
||||||
/// `StageTransition` event, lets the buffer task process it, drains the
|
|
||||||
/// buffer, and asserts the result contains the expected audit prefix.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn gateway_buffer_task_injects_audit_line_into_context() {
|
|
||||||
use super::super::messages::format_drained_events;
|
|
||||||
use crate::service::events::StoredEvent;
|
|
||||||
use crate::service::gateway::GatewayStatusEvent;
|
|
||||||
use crate::service::gateway::polling::format_gateway_audit_line;
|
|
||||||
|
|
||||||
let (event_tx, event_rx) = tokio::sync::broadcast::channel::<GatewayStatusEvent>(16);
|
|
||||||
|
|
||||||
// pending_gateway_events shared between buffer task and drain site.
|
|
||||||
let pending: Arc<TokioMutex<Vec<String>>> = Arc::new(TokioMutex::new(Vec::new()));
|
|
||||||
|
|
||||||
// Spawn a minimal buffer task — same logic as run_bot uses.
|
|
||||||
{
|
|
||||||
let buf = Arc::clone(&pending);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut rx = event_rx;
|
|
||||||
loop {
|
|
||||||
match rx.recv().await {
|
|
||||||
Ok(event) => {
|
|
||||||
let line = format_gateway_audit_line(&event.project, &event.event);
|
|
||||||
buf.lock().await.push(line);
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send one stage-transition event, as a project node would.
|
|
||||||
let evt = GatewayStatusEvent {
|
|
||||||
project: "huskies".to_string(),
|
|
||||||
event: StoredEvent::StageTransition {
|
|
||||||
story_id: "42_story_feat".to_string(),
|
|
||||||
story_name: String::new(),
|
|
||||||
from_stage: "2_current".to_string(),
|
|
||||||
to_stage: "3_qa".to_string(),
|
|
||||||
timestamp_ms: 1_000_000,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let receivers = event_tx.send(evt).unwrap_or(0);
|
|
||||||
assert!(
|
|
||||||
receivers > 0,
|
|
||||||
"event must have at least one active receiver"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for the buffer task to process the event.
|
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
|
||||||
loop {
|
|
||||||
if !pending.lock().await.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
std::time::Instant::now() < deadline,
|
|
||||||
"buffer task did not receive the event within 2 s"
|
|
||||||
);
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drain and format — mirrors what handle_message does.
|
|
||||||
let lines: Vec<String> = pending.lock().await.drain(..).collect();
|
|
||||||
let prefix = format_drained_events(lines);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
prefix.contains("audit ts="),
|
|
||||||
"prompt prefix must contain 'audit ts='; got: {prefix}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
prefix.contains("project=huskies"),
|
|
||||||
"prompt prefix must name the project; got: {prefix}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
prefix.starts_with("<system-reminder>\n"),
|
|
||||||
"prefix must open with <system-reminder>; got: {prefix}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,4 +202,20 @@ pub struct BotConfig {
|
|||||||
/// Defaults to 1 500 ms (1.5 s).
|
/// Defaults to 1 500 ms (1.5 s).
|
||||||
#[serde(default = "default_coalesce_window_ms")]
|
#[serde(default = "default_coalesce_window_ms")]
|
||||||
pub coalesce_window_ms: u64,
|
pub coalesce_window_ms: u64,
|
||||||
|
|
||||||
|
/// Git `user.name` to inject into project containers created by `new project`.
|
||||||
|
///
|
||||||
|
/// Passed as `GIT_USER_NAME` to the container entrypoint so agents can commit
|
||||||
|
/// code with the correct author identity. Falls back to the host's
|
||||||
|
/// `git config user.name` when absent.
|
||||||
|
#[serde(default)]
|
||||||
|
pub git_user_name: Option<String>,
|
||||||
|
|
||||||
|
/// Git `user.email` to inject into project containers created by `new project`.
|
||||||
|
///
|
||||||
|
/// Passed as `GIT_USER_EMAIL` to the container entrypoint so agents can commit
|
||||||
|
/// code with the correct author identity. Falls back to the host's
|
||||||
|
/// `git config user.email` when absent.
|
||||||
|
#[serde(default)]
|
||||||
|
pub git_user_email: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ pub(crate) mod config;
|
|||||||
pub mod delete;
|
pub mod delete;
|
||||||
/// htop-style agent monitor command — renders a live process table in Matrix.
|
/// htop-style agent monitor command — renders a live process table in Matrix.
|
||||||
pub mod htop;
|
pub mod htop;
|
||||||
|
/// `new project <name>` chat command — Phase 1 gateway project bootstrap.
|
||||||
|
pub mod new_project;
|
||||||
/// Rebuild command — triggers a server rebuild/restart via a bot command.
|
/// Rebuild command — triggers a server rebuild/restart via a bot command.
|
||||||
pub mod rebuild;
|
pub mod rebuild;
|
||||||
/// Reset command — handles `!reset` bot commands to restart the server state.
|
/// Reset command — handles `!reset` bot commands to restart the server state.
|
||||||
@@ -79,8 +81,14 @@ pub fn spawn_bot(
|
|||||||
services: Arc<Services>,
|
services: Arc<Services>,
|
||||||
shutdown_rx: watch::Receiver<Option<ShutdownReason>>,
|
shutdown_rx: watch::Receiver<Option<ShutdownReason>>,
|
||||||
gateway_active_project: Option<Arc<RwLock<String>>>,
|
gateway_active_project: Option<Arc<RwLock<String>>>,
|
||||||
gateway_projects: Vec<String>,
|
|
||||||
gateway_project_urls: std::collections::BTreeMap<String, String>,
|
gateway_project_urls: std::collections::BTreeMap<String, String>,
|
||||||
|
gateway_projects_store: Option<
|
||||||
|
Arc<
|
||||||
|
RwLock<
|
||||||
|
std::collections::BTreeMap<String, crate::service::gateway::config::ProjectEntry>,
|
||||||
|
>,
|
||||||
|
>,
|
||||||
|
>,
|
||||||
timer_store: Arc<TimerStore>,
|
timer_store: Arc<TimerStore>,
|
||||||
gateway_event_rx: Option<
|
gateway_event_rx: Option<
|
||||||
tokio::sync::broadcast::Receiver<crate::service::gateway::GatewayStatusEvent>,
|
tokio::sync::broadcast::Receiver<crate::service::gateway::GatewayStatusEvent>,
|
||||||
@@ -120,8 +128,8 @@ pub fn spawn_bot(
|
|||||||
watcher_tx,
|
watcher_tx,
|
||||||
shutdown_rx,
|
shutdown_rx,
|
||||||
gateway_active_project,
|
gateway_active_project,
|
||||||
gateway_projects,
|
|
||||||
gateway_project_urls,
|
gateway_project_urls,
|
||||||
|
gateway_projects_store,
|
||||||
timer_store,
|
timer_store,
|
||||||
gateway_event_rx,
|
gateway_event_rx,
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -29,8 +29,11 @@ pub(super) async fn handle_llm_message(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let bot_name = &ctx.services.bot_name;
|
let bot_name = &ctx.services.bot_name;
|
||||||
|
let event_ctx = crate::llm_session::assemble_prompt_context(
|
||||||
|
resume_session_id.as_deref().unwrap_or(channel),
|
||||||
|
);
|
||||||
let prompt = format!(
|
let prompt = format!(
|
||||||
"[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n\n{user}: {user_message}"
|
"{event_ctx}[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n\n{user}: {user_message}"
|
||||||
);
|
);
|
||||||
|
|
||||||
let provider = ClaudeCodeProvider::new();
|
let provider = ClaudeCodeProvider::new();
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ pub(super) async fn handle_llm_message(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let bot_name = &ctx.services.bot_name;
|
let bot_name = &ctx.services.bot_name;
|
||||||
|
let event_ctx =
|
||||||
|
crate::llm_session::assemble_prompt_context(resume_session_id.as_deref().unwrap_or(sender));
|
||||||
let prompt = format!(
|
let prompt = format!(
|
||||||
"[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n\n{sender}: {user_message}"
|
"{event_ctx}[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n\n{sender}: {user_message}"
|
||||||
);
|
);
|
||||||
|
|
||||||
let provider = ClaudeCodeProvider::new();
|
let provider = ClaudeCodeProvider::new();
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
//! Read/write helpers for the `event_log` append-only list in the CRDT document.
|
||||||
|
//!
|
||||||
|
//! Every pipeline stage transition is appended as an [`EventLogEntryCrdt`][super::super::types::EventLogEntryCrdt]
|
||||||
|
//! entry. Entries are never updated or tombstoned — the list is strictly grow-only.
|
||||||
|
//! Monotonic sequencing is computed at write time while holding the CRDT lock,
|
||||||
|
//! so `event_seq` values for a given sled are always contiguous and gap-free.
|
||||||
|
|
||||||
|
use bft_json_crdt::json_crdt::{JsonValue, *};
|
||||||
|
use bft_json_crdt::op::ROOT_ID;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::super::state::{apply_and_persist, get_crdt};
|
||||||
|
use super::super::types::EventLogEntryCrdt;
|
||||||
|
|
||||||
|
/// `pipeline_event` value used to mark a gap sentinel entry in the event log.
|
||||||
|
///
|
||||||
|
/// A gap sentinel is appended when the event-log subscriber detects that the
|
||||||
|
/// broadcast channel dropped events (i.e. it received `RecvError::Lagged`).
|
||||||
|
/// The `from_stage` and `to_stage` fields encode the logical EventId range
|
||||||
|
/// `[from, to]` of the dropped events as decimal strings.
|
||||||
|
pub const GAP_PIPELINE_EVENT: &str = "EventStreamGap";
|
||||||
|
|
||||||
|
/// Raw event log entry extracted from the CRDT document.
|
||||||
|
///
|
||||||
|
/// All fields are decoded to Rust primitives; entries with a missing or
|
||||||
|
/// malformed `sled_id` are silently dropped by [`read_all_event_log_entries`].
|
||||||
|
pub struct EventLogEntryRaw {
|
||||||
|
/// Monotonic sequence number for the recording sled (0-based).
|
||||||
|
pub event_seq: u64,
|
||||||
|
/// Hex-encoded Ed25519 public key of the sled that wrote this entry.
|
||||||
|
pub sled_id: String,
|
||||||
|
/// Unix timestamp (seconds) when the transition fired.
|
||||||
|
pub timestamp: f64,
|
||||||
|
/// Story ID of the work item that transitioned.
|
||||||
|
pub story_id: String,
|
||||||
|
/// Human-readable label of the stage before the transition.
|
||||||
|
pub from_stage: String,
|
||||||
|
/// Human-readable label of the stage after the transition.
|
||||||
|
pub to_stage: String,
|
||||||
|
/// String label of the `PipelineEvent` variant.
|
||||||
|
pub pipeline_event: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a new event log entry to the CRDT, computing the monotonic `event_seq`
|
||||||
|
/// atomically while the CRDT lock is held.
|
||||||
|
///
|
||||||
|
/// No-ops silently when the CRDT is not yet initialised.
|
||||||
|
pub fn append_event_log_entry(
|
||||||
|
sled_id: &str,
|
||||||
|
timestamp: f64,
|
||||||
|
story_id: &str,
|
||||||
|
from_stage: &str,
|
||||||
|
to_stage: &str,
|
||||||
|
pipeline_event: &str,
|
||||||
|
) {
|
||||||
|
let Some(state_mutex) = get_crdt() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(mut state) = state_mutex.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Count existing entries for this sled while holding the lock so the seq
|
||||||
|
// is computed and used in the same critical section — no TOCTOU gap.
|
||||||
|
let event_seq = state
|
||||||
|
.crdt
|
||||||
|
.doc
|
||||||
|
.event_log
|
||||||
|
.iter()
|
||||||
|
.filter(|e| matches!(e.sled_id.view(), JsonValue::String(s) if s == sled_id))
|
||||||
|
.count() as f64;
|
||||||
|
|
||||||
|
// Append after the last existing entry so the list stays in insertion order.
|
||||||
|
// Inserting after ROOT_ID would place each entry at the front (RGA semantics),
|
||||||
|
// reversing the sequence; inserting after the current tail preserves order.
|
||||||
|
let total_len = state.crdt.doc.event_log.view().len();
|
||||||
|
let after = if total_len > 0 {
|
||||||
|
super::list_id_at(&state.crdt.doc.event_log, total_len - 1).unwrap_or(ROOT_ID)
|
||||||
|
} else {
|
||||||
|
ROOT_ID
|
||||||
|
};
|
||||||
|
|
||||||
|
let entry: JsonValue = json!({
|
||||||
|
"event_seq": event_seq,
|
||||||
|
"sled_id": sled_id,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"story_id": story_id,
|
||||||
|
"from_stage": from_stage,
|
||||||
|
"to_stage": to_stage,
|
||||||
|
"pipeline_event": pipeline_event,
|
||||||
|
})
|
||||||
|
.into();
|
||||||
|
|
||||||
|
apply_and_persist(&mut state, |s| s.crdt.doc.event_log.insert(after, entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append an `EventStreamGap` sentinel entry to the CRDT event log.
|
||||||
|
///
|
||||||
|
/// Called when the event-log broadcast subscriber detects that the channel
|
||||||
|
/// dropped events (`RecvError::Lagged`). `from_id` and `to_id` are the
|
||||||
|
/// logical sequence numbers (in the per-sled event stream) of the first and
|
||||||
|
/// last dropped events respectively. The sentinel itself also consumes one
|
||||||
|
/// CRDT `event_seq` slot so the monotonic counter remains contiguous across
|
||||||
|
/// the gap.
|
||||||
|
pub fn append_gap_log_entry(sled_id: &str, from_id: u64, to_id: u64) {
|
||||||
|
let timestamp = chrono::Utc::now().timestamp() as f64;
|
||||||
|
append_event_log_entry(
|
||||||
|
sled_id,
|
||||||
|
timestamp,
|
||||||
|
"",
|
||||||
|
&from_id.to_string(),
|
||||||
|
&to_id.to_string(),
|
||||||
|
GAP_PIPELINE_EVENT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read all event log entries from the CRDT document.
|
||||||
|
///
|
||||||
|
/// Entries with a missing or empty `sled_id` are silently skipped.
|
||||||
|
/// Order reflects CRDT insertion order (RGA list semantics).
|
||||||
|
pub fn read_all_event_log_entries() -> Vec<EventLogEntryRaw> {
|
||||||
|
let Some(state_mutex) = get_crdt() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Ok(state) = state_mutex.lock() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.crdt
|
||||||
|
.doc
|
||||||
|
.event_log
|
||||||
|
.iter()
|
||||||
|
.filter_map(extract_entry)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a CRDT event log entry to its read-side representation.
|
||||||
|
fn extract_entry(e: &EventLogEntryCrdt) -> Option<EventLogEntryRaw> {
|
||||||
|
let event_seq = match e.event_seq.view() {
|
||||||
|
JsonValue::Number(n) => n as u64,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let sled_id = match e.sled_id.view() {
|
||||||
|
JsonValue::String(s) if !s.is_empty() => s,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let timestamp = match e.timestamp.view() {
|
||||||
|
JsonValue::Number(n) => n,
|
||||||
|
_ => 0.0,
|
||||||
|
};
|
||||||
|
let story_id = match e.story_id.view() {
|
||||||
|
JsonValue::String(s) => s,
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let from_stage = match e.from_stage.view() {
|
||||||
|
JsonValue::String(s) => s,
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let to_stage = match e.to_stage.view() {
|
||||||
|
JsonValue::String(s) => s,
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let pipeline_event = match e.pipeline_event.view() {
|
||||||
|
JsonValue::String(s) => s,
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
Some(EventLogEntryRaw {
|
||||||
|
event_seq,
|
||||||
|
sled_id,
|
||||||
|
timestamp,
|
||||||
|
story_id,
|
||||||
|
from_stage,
|
||||||
|
to_stage,
|
||||||
|
pipeline_event,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
//! Read/write helpers for the `llm_sessions` LWW-map collection, including the
|
||||||
|
//! atomic `assemble_and_advance_session` helper used by the Matrix bot.
|
||||||
|
//!
|
||||||
|
//! LLM sessions are keyed by `session_id` (typically a Matrix room ID) and track
|
||||||
|
//! per-sled high-water marks so that `assemble_and_advance_session` can inject
|
||||||
|
//! only events the LLM has not yet seen and advance the marks atomically within
|
||||||
|
//! a single CRDT lock acquisition.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
use bft_json_crdt::json_crdt::{JsonValue, *};
|
||||||
|
use bft_json_crdt::op::ROOT_ID;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::super::state::{apply_and_persist, get_crdt, rebuild_llm_session_index};
|
||||||
|
use super::super::types::{LlmSessionCrdt, LlmSessionView, ScopeFilter};
|
||||||
|
use super::event_log::GAP_PIPELINE_EVENT;
|
||||||
|
|
||||||
|
/// Write or upsert an LLM session entry keyed by `session_id`.
|
||||||
|
///
|
||||||
|
/// Creates a new entry if `session_id` is not yet present; updates
|
||||||
|
/// `persona_name` and `scope` on an existing entry. The `high_water`
|
||||||
|
/// register is not touched by this function — use `assemble_and_advance_session`
|
||||||
|
/// to advance it atomically.
|
||||||
|
///
|
||||||
|
/// The `scope` string must be in wire form: `"all"` for [`ScopeFilter::All`]
|
||||||
|
/// or `"sleds:hex1,hex2"` for [`ScopeFilter::Sleds`].
|
||||||
|
pub fn write_llm_session(session_id: &str, persona_name: &str, scope: &str) {
|
||||||
|
let Some(state_mutex) = get_crdt() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(mut state) = state_mutex.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(&idx) = state.llm_session_index.get(session_id) {
|
||||||
|
apply_and_persist(&mut state, |s| {
|
||||||
|
s.crdt.doc.llm_sessions[idx]
|
||||||
|
.persona_name
|
||||||
|
.set(persona_name.to_string())
|
||||||
|
});
|
||||||
|
apply_and_persist(&mut state, |s| {
|
||||||
|
s.crdt.doc.llm_sessions[idx].scope.set(scope.to_string())
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let entry: JsonValue = json!({
|
||||||
|
"session_id": session_id,
|
||||||
|
"persona_name": persona_name,
|
||||||
|
"scope": scope,
|
||||||
|
"high_water": "{}",
|
||||||
|
})
|
||||||
|
.into();
|
||||||
|
apply_and_persist(&mut state, |s| {
|
||||||
|
s.crdt.doc.llm_sessions.insert(ROOT_ID, entry)
|
||||||
|
});
|
||||||
|
state.llm_session_index = rebuild_llm_session_index(&state.crdt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a single LLM session entry by `session_id`.
|
||||||
|
pub fn read_llm_session(session_id: &str) -> Option<LlmSessionView> {
|
||||||
|
let state_mutex = get_crdt()?;
|
||||||
|
let state = state_mutex.lock().ok()?;
|
||||||
|
let &idx = state.llm_session_index.get(session_id)?;
|
||||||
|
extract_llm_session_view(&state.crdt.doc.llm_sessions[idx])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically read new event-log entries for `session_id` past the stored
|
||||||
|
/// high-water marks, render them as a block of audit lines, and advance the
|
||||||
|
/// marks to prevent double-injection on the next call.
|
||||||
|
///
|
||||||
|
/// The set of sleds whose events are collected is determined by the session's
|
||||||
|
/// [`ScopeFilter`]:
|
||||||
|
/// - [`ScopeFilter::All`]: events from every sled present in the event log are
|
||||||
|
/// included — this is the gateway-level persona default that gives a full
|
||||||
|
/// cross-sled view.
|
||||||
|
/// - [`ScopeFilter::Sleds`]: only events whose `sled_id` is in the stored set
|
||||||
|
/// are included. When the stored set is empty (legacy `"single-sled"` rows or
|
||||||
|
/// freshly created sessions with no explicit scope), the local node's sled ID
|
||||||
|
/// is used as the sole member, preserving prior single-sled behaviour.
|
||||||
|
///
|
||||||
|
/// Returns an empty `Vec` when there are no new events or the CRDT is not
|
||||||
|
/// initialised.
|
||||||
|
pub fn assemble_and_advance_session(session_id: &str) -> Vec<String> {
|
||||||
|
let local_sled_id = crate::crdt_state::our_node_id().unwrap_or_default();
|
||||||
|
|
||||||
|
let Some(state_mutex) = get_crdt() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Ok(mut state) = state_mutex.lock() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine the session's scope filter and current high-water map.
|
||||||
|
let (scope_filter, current_high_water) = match state.llm_session_index.get(session_id).copied()
|
||||||
|
{
|
||||||
|
Some(idx) => {
|
||||||
|
let filter = parse_scope(&state.crdt.doc.llm_sessions[idx], &local_sled_id);
|
||||||
|
let hw = parse_high_water(&state.crdt.doc.llm_sessions[idx]);
|
||||||
|
(filter, hw)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// New session with no stored entry: default to local sled only.
|
||||||
|
let mut ids = BTreeSet::new();
|
||||||
|
if !local_sled_id.is_empty() {
|
||||||
|
ids.insert(local_sled_id.clone());
|
||||||
|
}
|
||||||
|
(ScopeFilter::Sleds(ids), BTreeMap::new())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the set of sled IDs to collect events from.
|
||||||
|
let target_sleds: BTreeSet<String> = match &scope_filter {
|
||||||
|
ScopeFilter::All => {
|
||||||
|
// Collect every unique sled_id present in the event log at this moment
|
||||||
|
// (live, not snapshotted — picks up newly adopted sleds automatically).
|
||||||
|
state
|
||||||
|
.crdt
|
||||||
|
.doc
|
||||||
|
.event_log
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| match e.sled_id.view() {
|
||||||
|
JsonValue::String(s) if !s.is_empty() => Some(s),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
ScopeFilter::Sleds(ids) if ids.is_empty() => {
|
||||||
|
// Empty set → legacy fallback: local sled only.
|
||||||
|
if local_sled_id.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
std::iter::once(local_sled_id.clone()).collect()
|
||||||
|
}
|
||||||
|
ScopeFilter::Sleds(ids) => ids.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if target_sleds.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect new events from each target sled past its high-water mark.
|
||||||
|
let mut new_events: Vec<(f64, String, String, String, String, String)> = state
|
||||||
|
.crdt
|
||||||
|
.doc
|
||||||
|
.event_log
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| extract_new_event_multi(e, &target_sleds, ¤t_high_water))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if new_events.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by (sled_id, event_seq) for deterministic ordering.
|
||||||
|
new_events.sort_by(|a, b| {
|
||||||
|
a.1.cmp(&b.1)
|
||||||
|
.then(a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Advance the high-water mark for each sled that had new events.
|
||||||
|
let mut new_high_water = current_high_water;
|
||||||
|
for (seq, sled_id, ..) in &new_events {
|
||||||
|
let entry = new_high_water.entry(sled_id.clone()).or_insert(0);
|
||||||
|
if *seq as u64 > *entry {
|
||||||
|
*entry = *seq as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let new_hw_json = serde_json::to_string(&new_high_water).unwrap_or_else(|_| "{}".to_string());
|
||||||
|
|
||||||
|
// Upsert the session entry with the new high-water value.
|
||||||
|
let idx_opt = state.llm_session_index.get(session_id).copied();
|
||||||
|
if let Some(idx) = idx_opt {
|
||||||
|
apply_and_persist(&mut state, |s| {
|
||||||
|
s.crdt.doc.llm_sessions[idx]
|
||||||
|
.high_water
|
||||||
|
.set(new_hw_json.clone())
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let scope_str = scope_filter.to_scope_str();
|
||||||
|
let entry: JsonValue = json!({
|
||||||
|
"session_id": session_id,
|
||||||
|
"persona_name": "",
|
||||||
|
"scope": scope_str,
|
||||||
|
"high_water": new_hw_json,
|
||||||
|
})
|
||||||
|
.into();
|
||||||
|
apply_and_persist(&mut state, |s| {
|
||||||
|
s.crdt.doc.llm_sessions.insert(ROOT_ID, entry)
|
||||||
|
});
|
||||||
|
state.llm_session_index = rebuild_llm_session_index(&state.crdt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observability: log event-log size and gap count across the session's
|
||||||
|
// target sleds (the scope actually assembled for this session).
|
||||||
|
let total_entries = state
|
||||||
|
.crdt
|
||||||
|
.doc
|
||||||
|
.event_log
|
||||||
|
.iter()
|
||||||
|
.filter(|e| matches!(e.sled_id.view(), JsonValue::String(s) if target_sleds.contains(&s)))
|
||||||
|
.count();
|
||||||
|
let gap_count = state
|
||||||
|
.crdt
|
||||||
|
.doc
|
||||||
|
.event_log
|
||||||
|
.iter()
|
||||||
|
.filter(|e| {
|
||||||
|
matches!(e.sled_id.view(), JsonValue::String(s) if target_sleds.contains(&s))
|
||||||
|
&& matches!(e.pipeline_event.view(), JsonValue::String(s) if s == GAP_PIPELINE_EVENT)
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
crate::slog!(
|
||||||
|
"[event-log] assemble session={session_id} sled_entries={total_entries} gap_count={gap_count}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render each new event as a compact audit line; gap sentinels get a
|
||||||
|
// human-readable message so the LLM is never presented with raw field data.
|
||||||
|
new_events
|
||||||
|
.into_iter()
|
||||||
|
.map(
|
||||||
|
|(_, sled_id, story_id, from_stage, to_stage, pipeline_event)| {
|
||||||
|
if pipeline_event == GAP_PIPELINE_EVENT {
|
||||||
|
format!("events between {from_stage} and {to_stage} were dropped")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"pipeline_event sled_id=\"{sled_id}\" story_id=\"{story_id}\" \
|
||||||
|
from=\"{from_stage}\" to=\"{to_stage}\" event=\"{pipeline_event}\""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the high-water JSON string from an `LlmSessionCrdt` entry.
|
||||||
|
fn parse_high_water(entry: &LlmSessionCrdt) -> BTreeMap<String, u64> {
|
||||||
|
match entry.high_water.view() {
|
||||||
|
JsonValue::String(s) if !s.is_empty() && s != "{}" => {
|
||||||
|
serde_json::from_str(&s).unwrap_or_default()
|
||||||
|
}
|
||||||
|
_ => BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the scope filter from an `LlmSessionCrdt` entry, falling back to
|
||||||
|
/// a single-element set containing `local_sled_id` for legacy / empty scope strings.
|
||||||
|
fn parse_scope(entry: &LlmSessionCrdt, local_sled_id: &str) -> ScopeFilter {
|
||||||
|
let raw = match entry.scope.view() {
|
||||||
|
JsonValue::String(s) => s,
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let filter = ScopeFilter::from_scope_str(&raw);
|
||||||
|
// For a Sleds filter with an empty set (legacy "single-sled" or ""),
|
||||||
|
// fall back to the local sled.
|
||||||
|
if let ScopeFilter::Sleds(ref ids) = filter
|
||||||
|
&& ids.is_empty()
|
||||||
|
&& !local_sled_id.is_empty()
|
||||||
|
{
|
||||||
|
let mut fallback = BTreeSet::new();
|
||||||
|
fallback.insert(local_sled_id.to_string());
|
||||||
|
return ScopeFilter::Sleds(fallback);
|
||||||
|
}
|
||||||
|
filter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract one event log entry if its `sled_id` is in `target_sleds` and its
|
||||||
|
/// `event_seq` is strictly greater than the matching high-water value (or no
|
||||||
|
/// high-water has been recorded yet for that sled).
|
||||||
|
///
|
||||||
|
/// Returns `(event_seq, sled_id, story_id, from_stage, to_stage, pipeline_event)`.
|
||||||
|
fn extract_new_event_multi(
|
||||||
|
e: &crate::crdt_state::types::EventLogEntryCrdt,
|
||||||
|
target_sleds: &BTreeSet<String>,
|
||||||
|
high_water: &BTreeMap<String, u64>,
|
||||||
|
) -> Option<(f64, String, String, String, String, String)> {
|
||||||
|
let sled_id = match e.sled_id.view() {
|
||||||
|
JsonValue::String(s) if !s.is_empty() && target_sleds.contains(&s) => s,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let event_seq = match e.event_seq.view() {
|
||||||
|
JsonValue::Number(n) => n,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let last_seen = high_water.get(&sled_id).copied();
|
||||||
|
if last_seen.is_some_and(|last| event_seq as u64 <= last) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let story_id = match e.story_id.view() {
|
||||||
|
JsonValue::String(s) => s,
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let from_stage = match e.from_stage.view() {
|
||||||
|
JsonValue::String(s) => s,
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let to_stage = match e.to_stage.view() {
|
||||||
|
JsonValue::String(s) => s,
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let pipeline_event = match e.pipeline_event.view() {
|
||||||
|
JsonValue::String(s) => s,
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
Some((
|
||||||
|
event_seq,
|
||||||
|
sled_id,
|
||||||
|
story_id,
|
||||||
|
from_stage,
|
||||||
|
to_stage,
|
||||||
|
pipeline_event,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a CRDT LLM session entry into its read-only view representation.
|
||||||
|
pub(super) fn extract_llm_session_view(entry: &LlmSessionCrdt) -> Option<LlmSessionView> {
|
||||||
|
let session_id = match entry.session_id.view() {
|
||||||
|
JsonValue::String(s) if !s.is_empty() => s,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let persona_name = match entry.persona_name.view() {
|
||||||
|
JsonValue::String(s) => s,
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let local_sled_id = crate::crdt_state::our_node_id().unwrap_or_default();
|
||||||
|
let scope_filter = parse_scope(entry, &local_sled_id);
|
||||||
|
let high_water = parse_high_water(entry);
|
||||||
|
Some(LlmSessionView {
|
||||||
|
session_id,
|
||||||
|
persona_name,
|
||||||
|
scope_filter,
|
||||||
|
high_water,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@ use bft_json_crdt::op::OpId;
|
|||||||
|
|
||||||
mod active_agents;
|
mod active_agents;
|
||||||
mod agent_throttle;
|
mod agent_throttle;
|
||||||
|
mod event_log;
|
||||||
mod gateway_projects;
|
mod gateway_projects;
|
||||||
|
mod llm_sessions;
|
||||||
mod merge_jobs;
|
mod merge_jobs;
|
||||||
mod test_jobs;
|
mod test_jobs;
|
||||||
mod tokens;
|
mod tokens;
|
||||||
@@ -28,9 +30,14 @@ pub use active_agents::{
|
|||||||
pub use agent_throttle::{
|
pub use agent_throttle::{
|
||||||
delete_agent_throttle, read_agent_throttle, read_all_agent_throttles, write_agent_throttle,
|
delete_agent_throttle, read_agent_throttle, read_all_agent_throttles, write_agent_throttle,
|
||||||
};
|
};
|
||||||
|
pub use event_log::{
|
||||||
|
EventLogEntryRaw, GAP_PIPELINE_EVENT, append_event_log_entry, append_gap_log_entry,
|
||||||
|
read_all_event_log_entries,
|
||||||
|
};
|
||||||
pub use gateway_projects::{
|
pub use gateway_projects::{
|
||||||
delete_gateway_project, read_all_gateway_projects, read_gateway_project, write_gateway_project,
|
delete_gateway_project, read_all_gateway_projects, read_gateway_project, write_gateway_project,
|
||||||
};
|
};
|
||||||
|
pub use llm_sessions::{assemble_and_advance_session, read_llm_session, write_llm_session};
|
||||||
pub use merge_jobs::{delete_merge_job, read_all_merge_jobs, read_merge_job, write_merge_job};
|
pub use merge_jobs::{delete_merge_job, read_all_merge_jobs, read_merge_job, write_merge_job};
|
||||||
pub use test_jobs::{delete_test_job, read_all_test_jobs, read_test_job, write_test_job};
|
pub use test_jobs::{delete_test_job, read_all_test_jobs, read_test_job, write_test_job};
|
||||||
pub use tokens::{delete_token_usage, read_all_token_usage, read_token_usage, write_token_usage};
|
pub use tokens::{delete_token_usage, read_all_token_usage, read_token_usage, write_token_usage};
|
||||||
|
|||||||
@@ -28,12 +28,14 @@ mod write;
|
|||||||
|
|
||||||
pub use gateway_config::{read_gateway_active_project, write_gateway_active_project};
|
pub use gateway_config::{read_gateway_active_project, write_gateway_active_project};
|
||||||
pub use lww_maps::{
|
pub use lww_maps::{
|
||||||
delete_active_agent, delete_agent_throttle, delete_gateway_project, delete_merge_job,
|
EventLogEntryRaw, GAP_PIPELINE_EVENT, append_event_log_entry, append_gap_log_entry,
|
||||||
delete_test_job, delete_token_usage, read_active_agent, read_agent_throttle,
|
assemble_and_advance_session, delete_active_agent, delete_agent_throttle,
|
||||||
read_all_active_agents, read_all_agent_throttles, read_all_gateway_projects,
|
delete_gateway_project, delete_merge_job, delete_test_job, delete_token_usage,
|
||||||
read_all_merge_jobs, read_all_test_jobs, read_all_token_usage, read_gateway_project,
|
read_active_agent, read_agent_throttle, read_all_active_agents, read_all_agent_throttles,
|
||||||
read_merge_job, read_test_job, read_token_usage, write_active_agent, write_agent_throttle,
|
read_all_event_log_entries, read_all_gateway_projects, read_all_merge_jobs, read_all_test_jobs,
|
||||||
write_gateway_project, write_merge_job, write_test_job, write_token_usage,
|
read_all_token_usage, read_gateway_project, read_llm_session, read_merge_job, read_test_job,
|
||||||
|
read_token_usage, write_active_agent, write_agent_throttle, write_gateway_project,
|
||||||
|
write_llm_session, write_merge_job, write_test_job, write_token_usage,
|
||||||
};
|
};
|
||||||
pub use ops::{all_ops_json, apply_remote_op, ops_since, our_vector_clock, subscribe_ops};
|
pub use ops::{all_ops_json, apply_remote_op, ops_since, our_vector_clock, subscribe_ops};
|
||||||
pub use presence::{
|
pub use presence::{
|
||||||
@@ -45,12 +47,14 @@ pub use read::{
|
|||||||
dep_is_archived_crdt, dep_is_done_crdt, dump_crdt_state, evict_item, is_tombstoned,
|
dep_is_archived_crdt, dep_is_done_crdt, dump_crdt_state, evict_item, is_tombstoned,
|
||||||
read_all_items, read_item, tombstoned_ids,
|
read_all_items, read_item, tombstoned_ids,
|
||||||
};
|
};
|
||||||
|
pub(crate) use state::flush_persistence;
|
||||||
pub use state::{init, subscribe};
|
pub use state::{init, subscribe};
|
||||||
pub use types::{
|
pub use types::{
|
||||||
ActiveAgentCrdt, ActiveAgentView, AgentThrottleCrdt, AgentThrottleView, CrdtEvent, EpicId,
|
ActiveAgentCrdt, ActiveAgentView, AgentThrottleCrdt, AgentThrottleView, CrdtEvent, EpicId,
|
||||||
GatewayConfigCrdt, GatewayProjectCrdt, GatewayProjectView, MergeJobCrdt, MergeJobView,
|
EventLogEntryCrdt, GatewayConfigCrdt, GatewayProjectCrdt, GatewayProjectView, LlmSessionCrdt,
|
||||||
NodePresenceCrdt, NodePresenceView, PipelineDoc, PipelineItemCrdt, PipelineItemView,
|
LlmSessionView, MergeJobCrdt, MergeJobView, NodePresenceCrdt, NodePresenceView, PipelineDoc,
|
||||||
TestJobCrdt, TestJobView, TokenUsageCrdt, TokenUsageView, WorkItem,
|
PipelineItemCrdt, PipelineItemView, ScopeFilter, TestJobCrdt, TestJobView, TokenUsageCrdt,
|
||||||
|
TokenUsageView, WorkItem,
|
||||||
};
|
};
|
||||||
pub use write::{
|
pub use write::{
|
||||||
bump_retry_count, migrate_legacy_stage_strings, migrate_merge_job, migrate_names_from_slugs,
|
bump_retry_count, migrate_legacy_stage_strings, migrate_merge_job, migrate_names_from_slugs,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#![allow(unused_imports, dead_code)]
|
#![allow(unused_imports, dead_code)]
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
use super::hex;
|
use super::hex;
|
||||||
use bft_json_crdt::json_crdt::*;
|
use bft_json_crdt::json_crdt::*;
|
||||||
@@ -10,9 +11,10 @@ use tokio::sync::broadcast;
|
|||||||
|
|
||||||
use super::VectorClock;
|
use super::VectorClock;
|
||||||
use super::state::{
|
use super::state::{
|
||||||
SYNC_TX, all_ops_lock, apply_and_persist, emit_event, get_crdt, rebuild_active_agent_index,
|
PERSIST_PENDING, PersistMsg, SYNC_TX, all_ops_lock, apply_and_persist, emit_event, get_crdt,
|
||||||
rebuild_agent_throttle_index, rebuild_index, rebuild_merge_job_index, rebuild_node_index,
|
rebuild_active_agent_index, rebuild_agent_throttle_index, rebuild_index,
|
||||||
rebuild_test_job_index, rebuild_token_index, track_op, vector_clock_lock,
|
rebuild_merge_job_index, rebuild_node_index, rebuild_test_job_index, rebuild_token_index,
|
||||||
|
track_op, vector_clock_lock,
|
||||||
};
|
};
|
||||||
use super::types::{CrdtEvent, PipelineDoc};
|
use super::types::{CrdtEvent, PipelineDoc};
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
@@ -116,9 +118,15 @@ pub fn apply_remote_op(op: SignedOp) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist the op.
|
// Persist the op.
|
||||||
if let Err(e) = state.persist_tx.send(op.clone()) {
|
if state
|
||||||
|
.persist_tx
|
||||||
|
.send(PersistMsg::Op(Box::new(op.clone())))
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
PERSIST_PENDING.fetch_add(1, Ordering::Relaxed);
|
||||||
|
} else {
|
||||||
crate::slog_error!(
|
crate::slog_error!(
|
||||||
"[crdt] Failed to send remote op to persist task: {e}; persist task may be dead. \
|
"[crdt] Failed to send remote op to persist task; persist task may be dead. \
|
||||||
In-memory state is now ahead of persisted state."
|
In-memory state is now ahead of persisted state."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ use std::collections::HashMap;
|
|||||||
use bft_json_crdt::json_crdt::*;
|
use bft_json_crdt::json_crdt::*;
|
||||||
use bft_json_crdt::op::{OpId, ROOT_ID};
|
use bft_json_crdt::op::{OpId, ROOT_ID};
|
||||||
|
|
||||||
use super::state::{all_ops_lock, apply_and_persist, get_crdt, rebuild_index};
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
use super::state::{PERSIST_PENDING, all_ops_lock, apply_and_persist, get_crdt, rebuild_index};
|
||||||
use super::types::{PipelineDoc, PipelineItemCrdt, PipelineItemView};
|
use super::types::{PipelineDoc, PipelineItemCrdt, PipelineItemView};
|
||||||
|
|
||||||
// ── Debug dump ───────────────────────────────────────────────────────
|
// ── Debug dump ───────────────────────────────────────────────────────
|
||||||
@@ -44,6 +46,8 @@ pub struct CrdtStateDump {
|
|||||||
pub max_seq_in_list: u64,
|
pub max_seq_in_list: u64,
|
||||||
/// Count of ops in the ALL_OPS journal (persisted ops replayed at startup).
|
/// Count of ops in the ALL_OPS journal (persisted ops replayed at startup).
|
||||||
pub persisted_ops_count: usize,
|
pub persisted_ops_count: usize,
|
||||||
|
/// Count of ops queued in the persistence channel not yet written to SQLite.
|
||||||
|
pub pending_persist_ops_count: usize,
|
||||||
pub items: Vec<CrdtItemDump>,
|
pub items: Vec<CrdtItemDump>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +65,7 @@ pub fn dump_crdt_state(story_id_filter: Option<&str>) -> CrdtStateDump {
|
|||||||
let persisted_ops_count = all_ops_lock()
|
let persisted_ops_count = all_ops_lock()
|
||||||
.and_then(|m| m.lock().ok().map(|v| v.len()))
|
.and_then(|m| m.lock().ok().map(|v| v.len()))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
let pending_persist_ops_count = PERSIST_PENDING.load(Ordering::Relaxed);
|
||||||
|
|
||||||
let Some(state_mutex) = get_crdt() else {
|
let Some(state_mutex) = get_crdt() else {
|
||||||
return CrdtStateDump {
|
return CrdtStateDump {
|
||||||
@@ -69,6 +74,7 @@ pub fn dump_crdt_state(story_id_filter: Option<&str>) -> CrdtStateDump {
|
|||||||
total_ops_in_list: 0,
|
total_ops_in_list: 0,
|
||||||
max_seq_in_list: 0,
|
max_seq_in_list: 0,
|
||||||
persisted_ops_count,
|
persisted_ops_count,
|
||||||
|
pending_persist_ops_count,
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -80,6 +86,7 @@ pub fn dump_crdt_state(story_id_filter: Option<&str>) -> CrdtStateDump {
|
|||||||
total_ops_in_list: 0,
|
total_ops_in_list: 0,
|
||||||
max_seq_in_list: 0,
|
max_seq_in_list: 0,
|
||||||
persisted_ops_count,
|
persisted_ops_count,
|
||||||
|
pending_persist_ops_count,
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -179,6 +186,7 @@ pub fn dump_crdt_state(story_id_filter: Option<&str>) -> CrdtStateDump {
|
|||||||
total_ops_in_list,
|
total_ops_in_list,
|
||||||
max_seq_in_list,
|
max_seq_in_list,
|
||||||
persisted_ops_count,
|
persisted_ops_count,
|
||||||
|
pending_persist_ops_count,
|
||||||
items,
|
items,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@
|
|||||||
//! it to the live document, sends it to the persistence channel, and broadcasts
|
//! it to the live document, sends it to the persistence channel, and broadcasts
|
||||||
//! it to sync peers via [`super::SYNC_TX`].
|
//! it to sync peers via [`super::SYNC_TX`].
|
||||||
|
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
use bft_json_crdt::json_crdt::JsonValue;
|
use bft_json_crdt::json_crdt::JsonValue;
|
||||||
use bft_json_crdt::op::Op;
|
use bft_json_crdt::op::Op;
|
||||||
|
|
||||||
use super::super::types::CrdtEvent;
|
use super::super::types::CrdtEvent;
|
||||||
use super::{CrdtState, statics};
|
use super::{CrdtState, init::PersistMsg, statics};
|
||||||
|
|
||||||
/// Create a CRDT op via `op_fn`, sign it, apply it, and send it to the
|
/// Create a CRDT op via `op_fn`, sign it, apply it, and send it to the
|
||||||
/// persistence channel. The closure receives `&mut CrdtState` so it can
|
/// persistence channel. The closure receives `&mut CrdtState` so it can
|
||||||
@@ -21,7 +23,13 @@ where
|
|||||||
let raw_op = op_fn(state);
|
let raw_op = op_fn(state);
|
||||||
let signed = raw_op.sign(&state.keypair);
|
let signed = raw_op.sign(&state.keypair);
|
||||||
state.crdt.apply(signed.clone());
|
state.crdt.apply(signed.clone());
|
||||||
if state.persist_tx.send(signed.clone()).is_err() {
|
if state
|
||||||
|
.persist_tx
|
||||||
|
.send(PersistMsg::Op(Box::new(signed.clone())))
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
statics::PERSIST_PENDING.fetch_add(1, Ordering::Relaxed);
|
||||||
|
} else {
|
||||||
let op_type = if signed.inner.is_deleted {
|
let op_type = if signed.inner.is_deleted {
|
||||||
"Delete"
|
"Delete"
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -113,3 +113,16 @@ pub(in crate::crdt_state) fn rebuild_gateway_project_index(
|
|||||||
}
|
}
|
||||||
map
|
map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rebuild the session_id → llm_sessions list index.
|
||||||
|
pub(in crate::crdt_state) fn rebuild_llm_session_index(
|
||||||
|
crdt: &BaseCrdt<PipelineDoc>,
|
||||||
|
) -> HashMap<String, usize> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
for (i, entry) in crdt.doc.llm_sessions.iter().enumerate() {
|
||||||
|
if let JsonValue::String(ref k) = entry.session_id.view() {
|
||||||
|
map.insert(k.clone(), i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,25 +8,34 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue, SignedOp};
|
use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue, SignedOp};
|
||||||
use bft_json_crdt::keypair::{Ed25519KeyPair, make_keypair};
|
use bft_json_crdt::keypair::{Ed25519KeyPair, make_keypair};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use sqlx::sqlite::SqliteConnectOptions;
|
use sqlx::sqlite::SqliteConnectOptions;
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||||
|
|
||||||
use super::super::VectorClock;
|
use super::super::VectorClock;
|
||||||
use super::super::hex;
|
use super::super::hex;
|
||||||
use super::super::types::{CrdtEvent, PipelineDoc};
|
use super::super::types::{CrdtEvent, PipelineDoc};
|
||||||
use super::indices::{
|
use super::indices::{
|
||||||
rebuild_active_agent_index, rebuild_agent_throttle_index, rebuild_gateway_project_index,
|
rebuild_active_agent_index, rebuild_agent_throttle_index, rebuild_gateway_project_index,
|
||||||
rebuild_index, rebuild_merge_job_index, rebuild_node_index, rebuild_test_job_index,
|
rebuild_index, rebuild_llm_session_index, rebuild_merge_job_index, rebuild_node_index,
|
||||||
rebuild_token_index,
|
rebuild_test_job_index, rebuild_token_index,
|
||||||
};
|
};
|
||||||
use super::statics::{ALL_OPS, CRDT_EVENT_TX, SYNC_TX, VECTOR_CLOCK};
|
use super::statics::{ALL_OPS, CRDT_EVENT_TX, PERSIST_PENDING, SYNC_TX, VECTOR_CLOCK};
|
||||||
use super::{CRDT_STATE, CrdtState};
|
use super::{CRDT_STATE, CrdtState};
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
|
|
||||||
|
/// Message type for the persistence background channel.
|
||||||
|
pub(crate) enum PersistMsg {
|
||||||
|
/// Persist this op to SQLite.
|
||||||
|
Op(Box<SignedOp>),
|
||||||
|
/// Drain: signal the sender after all preceding ops are committed.
|
||||||
|
Flush(oneshot::Sender<()>),
|
||||||
|
}
|
||||||
|
|
||||||
/// Initialise the CRDT state layer.
|
/// Initialise the CRDT state layer.
|
||||||
///
|
///
|
||||||
/// Opens the SQLite database, loads or creates a node keypair, replays any
|
/// Opens the SQLite database, loads or creates a node keypair, replays any
|
||||||
@@ -94,6 +103,7 @@ pub async fn init(db_path: &Path) -> Result<(), sqlx::Error> {
|
|||||||
let test_job_index = rebuild_test_job_index(&crdt);
|
let test_job_index = rebuild_test_job_index(&crdt);
|
||||||
let agent_throttle_index = rebuild_agent_throttle_index(&crdt);
|
let agent_throttle_index = rebuild_agent_throttle_index(&crdt);
|
||||||
let gateway_project_index = rebuild_gateway_project_index(&crdt);
|
let gateway_project_index = rebuild_gateway_project_index(&crdt);
|
||||||
|
let llm_session_index = rebuild_llm_session_index(&crdt);
|
||||||
|
|
||||||
// Advance the top-level list clocks to the Lamport floor so that
|
// Advance the top-level list clocks to the Lamport floor so that
|
||||||
// list-level inserts don't re-emit low seq numbers.
|
// list-level inserts don't re-emit low seq numbers.
|
||||||
@@ -105,6 +115,7 @@ pub async fn init(db_path: &Path) -> Result<(), sqlx::Error> {
|
|||||||
crdt.doc.test_jobs.advance_seq(lamport_floor);
|
crdt.doc.test_jobs.advance_seq(lamport_floor);
|
||||||
crdt.doc.agent_throttle.advance_seq(lamport_floor);
|
crdt.doc.agent_throttle.advance_seq(lamport_floor);
|
||||||
crdt.doc.gateway_projects.advance_seq(lamport_floor);
|
crdt.doc.gateway_projects.advance_seq(lamport_floor);
|
||||||
|
crdt.doc.llm_sessions.advance_seq(lamport_floor);
|
||||||
crdt.doc
|
crdt.doc
|
||||||
.gateway_config
|
.gateway_config
|
||||||
.active_project
|
.active_project
|
||||||
@@ -119,35 +130,46 @@ pub async fn init(db_path: &Path) -> Result<(), sqlx::Error> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Spawn background persistence task.
|
// Spawn background persistence task.
|
||||||
let (persist_tx, mut persist_rx) = mpsc::unbounded_channel::<SignedOp>();
|
let (persist_tx, mut persist_rx) = mpsc::unbounded_channel::<PersistMsg>();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(op) = persist_rx.recv().await {
|
while let Some(msg) = persist_rx.recv().await {
|
||||||
let op_json = match serde_json::to_string(&op) {
|
match msg {
|
||||||
Ok(j) => j,
|
PersistMsg::Op(op) => {
|
||||||
Err(e) => {
|
let op = *op;
|
||||||
slog!("[crdt] Failed to serialize op: {e}");
|
let op_json = match serde_json::to_string(&op) {
|
||||||
continue;
|
Ok(j) => j,
|
||||||
|
Err(e) => {
|
||||||
|
slog!("[crdt] Failed to serialize op: {e}");
|
||||||
|
PERSIST_PENDING.fetch_sub(1, Ordering::Relaxed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let op_id = hex::encode(&op.id());
|
||||||
|
let seq = op.inner.seq as i64;
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
"INSERT INTO crdt_ops (op_id, seq, op_json, created_at) \
|
||||||
|
VALUES (?1, ?2, ?3, ?4) \
|
||||||
|
ON CONFLICT(op_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(&op_id)
|
||||||
|
.bind(seq)
|
||||||
|
.bind(&op_json)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
slog!("[crdt] Failed to persist op {}: {e}", &op_id[..12]);
|
||||||
|
}
|
||||||
|
PERSIST_PENDING.fetch_sub(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
PersistMsg::Flush(reply) => {
|
||||||
|
// All ops queued before this message have already been processed.
|
||||||
|
let _ = reply.send(());
|
||||||
}
|
}
|
||||||
};
|
|
||||||
let op_id = hex::encode(&op.id());
|
|
||||||
let seq = op.inner.seq as i64;
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
let result = sqlx::query(
|
|
||||||
"INSERT INTO crdt_ops (op_id, seq, op_json, created_at) \
|
|
||||||
VALUES (?1, ?2, ?3, ?4) \
|
|
||||||
ON CONFLICT(op_id) DO NOTHING",
|
|
||||||
)
|
|
||||||
.bind(&op_id)
|
|
||||||
.bind(seq)
|
|
||||||
.bind(&op_json)
|
|
||||||
.bind(&now)
|
|
||||||
.execute(&pool)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Err(e) = result {
|
|
||||||
slog!("[crdt] Failed to persist op {}: {e}", &op_id[..12]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -163,6 +185,7 @@ pub async fn init(db_path: &Path) -> Result<(), sqlx::Error> {
|
|||||||
test_job_index,
|
test_job_index,
|
||||||
agent_throttle_index,
|
agent_throttle_index,
|
||||||
gateway_project_index,
|
gateway_project_index,
|
||||||
|
llm_session_index,
|
||||||
persist_tx,
|
persist_tx,
|
||||||
lamport_floor,
|
lamport_floor,
|
||||||
tombstones,
|
tombstones,
|
||||||
@@ -181,6 +204,43 @@ pub async fn init(db_path: &Path) -> Result<(), sqlx::Error> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Signal the persistence background task to drain and wait until all currently-queued
|
||||||
|
/// ops have been written to SQLite, or until `timeout` elapses.
|
||||||
|
///
|
||||||
|
/// Because the persistence channel is FIFO, a `Flush` sentinel processed by the task
|
||||||
|
/// guarantees that every `Op` sent before it has already been committed. On timeout a
|
||||||
|
/// warning is logged with the queue depth so regressions are visible in logs.
|
||||||
|
pub(crate) async fn flush_persistence(timeout: std::time::Duration) {
|
||||||
|
let Some(state_mutex) = super::get_crdt() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let persist_tx = {
|
||||||
|
let Ok(state) = state_mutex.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
state.persist_tx.clone()
|
||||||
|
};
|
||||||
|
let pending_at_send = PERSIST_PENDING.load(Ordering::Relaxed);
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
if persist_tx.send(PersistMsg::Flush(tx)).is_err() {
|
||||||
|
slog!("[rebuild] Persistence channel closed — skipping flush");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match tokio::time::timeout(timeout, rx).await {
|
||||||
|
Ok(_) => {
|
||||||
|
slog!("[rebuild] Persistence channel drained ({pending_at_send} ops flushed)");
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let pending_now = PERSIST_PENDING.load(Ordering::Relaxed);
|
||||||
|
slog!(
|
||||||
|
"[rebuild] WARNING: persistence flush timed out after {}ms; \
|
||||||
|
queue_depth_at_send={pending_at_send} queue_depth_now={pending_now}",
|
||||||
|
timeout.as_millis()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load or create the Ed25519 keypair used by this node.
|
/// Load or create the Ed25519 keypair used by this node.
|
||||||
async fn load_or_create_keypair(pool: &SqlitePool) -> Result<Ed25519KeyPair, sqlx::Error> {
|
async fn load_or_create_keypair(pool: &SqlitePool) -> Result<Ed25519KeyPair, sqlx::Error> {
|
||||||
let row: Option<(Vec<u8>,)> =
|
let row: Option<(Vec<u8>,)> =
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ mod tests;
|
|||||||
// ── Re-exports for crdt_state siblings ──────────────────────────────
|
// ── Re-exports for crdt_state siblings ──────────────────────────────
|
||||||
|
|
||||||
pub use init::init;
|
pub use init::init;
|
||||||
|
pub(crate) use init::{PersistMsg, flush_persistence};
|
||||||
|
|
||||||
/// Subscribe to CRDT state-transition events.
|
/// Subscribe to CRDT state-transition events.
|
||||||
///
|
///
|
||||||
@@ -38,11 +39,11 @@ pub fn subscribe() -> Option<broadcast::Receiver<super::types::CrdtEvent>> {
|
|||||||
pub(super) use apply::{apply_and_persist, emit_event};
|
pub(super) use apply::{apply_and_persist, emit_event};
|
||||||
pub(super) use indices::{
|
pub(super) use indices::{
|
||||||
rebuild_active_agent_index, rebuild_agent_throttle_index, rebuild_gateway_project_index,
|
rebuild_active_agent_index, rebuild_agent_throttle_index, rebuild_gateway_project_index,
|
||||||
rebuild_index, rebuild_merge_job_index, rebuild_node_index, rebuild_test_job_index,
|
rebuild_index, rebuild_llm_session_index, rebuild_merge_job_index, rebuild_node_index,
|
||||||
rebuild_token_index,
|
rebuild_test_job_index, rebuild_token_index,
|
||||||
};
|
};
|
||||||
|
pub(crate) use statics::{PERSIST_PENDING, all_ops_lock, vector_clock_lock};
|
||||||
pub(super) use statics::{SYNC_TX, track_op};
|
pub(super) use statics::{SYNC_TX, track_op};
|
||||||
pub(crate) use statics::{all_ops_lock, vector_clock_lock};
|
|
||||||
|
|
||||||
// ── CrdtState struct ─────────────────────────────────────────────────
|
// ── CrdtState struct ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -66,8 +67,10 @@ pub(super) struct CrdtState {
|
|||||||
pub(super) agent_throttle_index: HashMap<String, usize>,
|
pub(super) agent_throttle_index: HashMap<String, usize>,
|
||||||
/// Maps project name → index in the gateway_projects ListCrdt for O(1) lookup.
|
/// Maps project name → index in the gateway_projects ListCrdt for O(1) lookup.
|
||||||
pub(super) gateway_project_index: HashMap<String, usize>,
|
pub(super) gateway_project_index: HashMap<String, usize>,
|
||||||
/// Channel sender for fire-and-forget op persistence.
|
/// Maps session_id → index in the llm_sessions ListCrdt for O(1) lookup.
|
||||||
pub(super) persist_tx: mpsc::UnboundedSender<SignedOp>,
|
pub(super) llm_session_index: HashMap<String, usize>,
|
||||||
|
/// Channel sender for op persistence and drain signalling.
|
||||||
|
pub(super) persist_tx: mpsc::UnboundedSender<init::PersistMsg>,
|
||||||
/// Max sequence number seen across all ops during init() replay.
|
/// Max sequence number seen across all ops during init() replay.
|
||||||
///
|
///
|
||||||
/// Newly-created registers (post-init) must have their Lamport clock
|
/// Newly-created registers (post-init) must have their Lamport clock
|
||||||
@@ -122,49 +125,58 @@ pub(super) fn get_crdt() -> Option<&'static Mutex<CrdtState>> {
|
|||||||
/// This avoids the async SQLite setup from `init()`. Ops are sent to a
|
/// This avoids the async SQLite setup from `init()`. Ops are sent to a
|
||||||
/// channel whose receiver is leaked (so nothing is persisted, but the channel
|
/// channel whose receiver is leaked (so nothing is persisted, but the channel
|
||||||
/// stays open and `apply_and_persist` succeeds silently).
|
/// stays open and `apply_and_persist` succeeds silently).
|
||||||
/// Safe to call multiple times — subsequent calls are no-ops (thread-local).
|
/// Always resets all thread-local state so each call produces a clean slate —
|
||||||
|
/// no cross-test pollution when two tests share the same thread.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn init_for_test() {
|
pub fn init_for_test() {
|
||||||
// Initialise thread-local CRDT for test isolation.
|
let keypair = make_keypair();
|
||||||
// Only creates a new CRDT if one isn't set yet on this thread;
|
let crdt = BaseCrdt::<PipelineDoc>::new(&keypair);
|
||||||
// subsequent calls are no-ops (matching the old OnceLock semantics
|
let (persist_tx, rx) = mpsc::unbounded_channel::<init::PersistMsg>();
|
||||||
// while keeping each thread isolated).
|
// Leak the receiver so the channel stays open: apply_and_persist
|
||||||
|
// can then send without error, preventing [crdt_persist] WARNs
|
||||||
|
// from racing with other tests that watch the global log buffer.
|
||||||
|
std::mem::forget(rx);
|
||||||
|
let fresh = CrdtState {
|
||||||
|
crdt,
|
||||||
|
keypair,
|
||||||
|
index: HashMap::new(),
|
||||||
|
node_index: HashMap::new(),
|
||||||
|
token_index: HashMap::new(),
|
||||||
|
merge_job_index: HashMap::new(),
|
||||||
|
active_agent_index: HashMap::new(),
|
||||||
|
test_job_index: HashMap::new(),
|
||||||
|
agent_throttle_index: HashMap::new(),
|
||||||
|
gateway_project_index: HashMap::new(),
|
||||||
|
llm_session_index: HashMap::new(),
|
||||||
|
persist_tx,
|
||||||
|
lamport_floor: 0,
|
||||||
|
tombstones: HashSet::new(),
|
||||||
|
};
|
||||||
CRDT_STATE_TL.with(|lock| {
|
CRDT_STATE_TL.with(|lock| {
|
||||||
if lock.get().is_none() {
|
if let Some(mutex) = lock.get() {
|
||||||
let keypair = make_keypair();
|
// Already set on this thread — replace contents so the second
|
||||||
let crdt = BaseCrdt::<PipelineDoc>::new(&keypair);
|
// (and subsequent) test on the same thread starts clean.
|
||||||
let (persist_tx, rx) = mpsc::unbounded_channel();
|
*mutex.lock().unwrap() = fresh;
|
||||||
// Leak the receiver so the channel stays open: apply_and_persist
|
} else {
|
||||||
// can then send without error, preventing [crdt_persist] WARNs
|
let _ = lock.set(Mutex::new(fresh));
|
||||||
// from racing with other tests that watch the global log buffer.
|
|
||||||
std::mem::forget(rx);
|
|
||||||
let state = CrdtState {
|
|
||||||
crdt,
|
|
||||||
keypair,
|
|
||||||
index: HashMap::new(),
|
|
||||||
node_index: HashMap::new(),
|
|
||||||
token_index: HashMap::new(),
|
|
||||||
merge_job_index: HashMap::new(),
|
|
||||||
active_agent_index: HashMap::new(),
|
|
||||||
test_job_index: HashMap::new(),
|
|
||||||
agent_throttle_index: HashMap::new(),
|
|
||||||
gateway_project_index: HashMap::new(),
|
|
||||||
persist_tx,
|
|
||||||
lamport_floor: 0,
|
|
||||||
tombstones: HashSet::new(),
|
|
||||||
};
|
|
||||||
let _ = lock.set(Mutex::new(state));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let _ = statics::CRDT_EVENT_TX.get_or_init(|| broadcast::channel::<CrdtEvent>(256).0);
|
let _ = statics::CRDT_EVENT_TX.get_or_init(|| broadcast::channel::<CrdtEvent>(256).0);
|
||||||
let _ = statics::SYNC_TX.get_or_init(|| broadcast::channel::<SignedOp>(1024).0);
|
let _ = statics::SYNC_TX.get_or_init(|| broadcast::channel::<SignedOp>(1024).0);
|
||||||
// Per-thread op journal + vector clock — keeps parallel tests' writes
|
// Per-thread op journal + vector clock — always cleared so a second test
|
||||||
// from corrupting each other's view of ALL_OPS (notably, one thread's
|
// on the same thread cannot see ops written by the first.
|
||||||
// `apply_compaction` could otherwise prune another thread's ops).
|
|
||||||
statics::ALL_OPS_TL.with(|lock| {
|
statics::ALL_OPS_TL.with(|lock| {
|
||||||
let _ = lock.set(Mutex::new(Vec::new()));
|
if let Some(mutex) = lock.get() {
|
||||||
|
mutex.lock().unwrap().clear();
|
||||||
|
} else {
|
||||||
|
let _ = lock.set(Mutex::new(Vec::new()));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
statics::VECTOR_CLOCK_TL.with(|lock| {
|
statics::VECTOR_CLOCK_TL.with(|lock| {
|
||||||
let _ = lock.set(Mutex::new(VectorClock::new()));
|
if let Some(mutex) = lock.get() {
|
||||||
|
mutex.lock().unwrap().clear();
|
||||||
|
} else {
|
||||||
|
let _ = lock.set(Mutex::new(VectorClock::new()));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
//! tests do not share `ALL_OPS` — preventing one test's `apply_compaction`
|
//! tests do not share `ALL_OPS` — preventing one test's `apply_compaction`
|
||||||
//! from pruning another test's freshly-written ops.
|
//! from pruning another test's freshly-written ops.
|
||||||
|
|
||||||
|
use std::sync::atomic::AtomicUsize;
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
use bft_json_crdt::json_crdt::SignedOp;
|
use bft_json_crdt::json_crdt::SignedOp;
|
||||||
@@ -19,6 +20,14 @@ use super::super::VectorClock;
|
|||||||
use super::super::hex;
|
use super::super::hex;
|
||||||
use super::super::types::CrdtEvent;
|
use super::super::types::CrdtEvent;
|
||||||
|
|
||||||
|
/// Count of ops queued in the persistence channel that have not yet been written to SQLite.
|
||||||
|
///
|
||||||
|
/// Incremented when an op is sent into the channel; decremented after the
|
||||||
|
/// persistence task commits it. Exposed via `dump_crdt_state` as
|
||||||
|
/// `pending_persist_ops_count` so operators can tell whether there is a flush
|
||||||
|
/// backlog before calling `rebuild_and_restart`.
|
||||||
|
pub(crate) static PERSIST_PENDING: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
/// Broadcast channel for CRDT events (stage transitions, etc.).
|
/// Broadcast channel for CRDT events (stage transitions, etc.).
|
||||||
pub(super) static CRDT_EVENT_TX: OnceLock<broadcast::Sender<CrdtEvent>> = OnceLock::new();
|
pub(super) static CRDT_EVENT_TX: OnceLock<broadcast::Sender<CrdtEvent>> = OnceLock::new();
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
use super::super::hex;
|
use super::super::hex;
|
||||||
use super::super::read::extract_item_view;
|
use super::super::read::extract_item_view;
|
||||||
use super::super::types::PipelineDoc;
|
use super::super::types::PipelineDoc;
|
||||||
|
use super::init::PersistMsg;
|
||||||
use super::*;
|
use super::*;
|
||||||
use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue, SignedOp};
|
use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue, SignedOp};
|
||||||
use bft_json_crdt::keypair::make_keypair;
|
use bft_json_crdt::keypair::make_keypair;
|
||||||
@@ -222,7 +223,7 @@ async fn init_and_write_read_roundtrip() {
|
|||||||
fn persist_tx_send_failure_logs_warn_with_op_type_and_seq() {
|
fn persist_tx_send_failure_logs_warn_with_op_type_and_seq() {
|
||||||
let kp = make_keypair();
|
let kp = make_keypair();
|
||||||
let crdt = BaseCrdt::<PipelineDoc>::new(&kp);
|
let crdt = BaseCrdt::<PipelineDoc>::new(&kp);
|
||||||
let (persist_tx, persist_rx) = mpsc::unbounded_channel::<SignedOp>();
|
let (persist_tx, persist_rx) = mpsc::unbounded_channel::<PersistMsg>();
|
||||||
|
|
||||||
let mut state = CrdtState {
|
let mut state = CrdtState {
|
||||||
crdt,
|
crdt,
|
||||||
@@ -235,6 +236,7 @@ fn persist_tx_send_failure_logs_warn_with_op_type_and_seq() {
|
|||||||
test_job_index: HashMap::new(),
|
test_job_index: HashMap::new(),
|
||||||
agent_throttle_index: HashMap::new(),
|
agent_throttle_index: HashMap::new(),
|
||||||
gateway_project_index: HashMap::new(),
|
gateway_project_index: HashMap::new(),
|
||||||
|
llm_session_index: HashMap::new(),
|
||||||
persist_tx,
|
persist_tx,
|
||||||
lamport_floor: 0,
|
lamport_floor: 0,
|
||||||
tombstones: std::collections::HashSet::new(),
|
tombstones: std::collections::HashSet::new(),
|
||||||
@@ -296,7 +298,7 @@ fn persist_tx_send_failure_logs_warn_with_op_type_and_seq() {
|
|||||||
fn persist_tx_send_success_emits_no_warn() {
|
fn persist_tx_send_success_emits_no_warn() {
|
||||||
let kp = make_keypair();
|
let kp = make_keypair();
|
||||||
let crdt = BaseCrdt::<PipelineDoc>::new(&kp);
|
let crdt = BaseCrdt::<PipelineDoc>::new(&kp);
|
||||||
let (persist_tx, _persist_rx) = mpsc::unbounded_channel::<SignedOp>();
|
let (persist_tx, _persist_rx) = mpsc::unbounded_channel::<PersistMsg>();
|
||||||
|
|
||||||
let mut state = CrdtState {
|
let mut state = CrdtState {
|
||||||
crdt,
|
crdt,
|
||||||
@@ -309,6 +311,7 @@ fn persist_tx_send_success_emits_no_warn() {
|
|||||||
test_job_index: HashMap::new(),
|
test_job_index: HashMap::new(),
|
||||||
agent_throttle_index: HashMap::new(),
|
agent_throttle_index: HashMap::new(),
|
||||||
gateway_project_index: HashMap::new(),
|
gateway_project_index: HashMap::new(),
|
||||||
|
llm_session_index: HashMap::new(),
|
||||||
persist_tx,
|
persist_tx,
|
||||||
lamport_floor: 0,
|
lamport_floor: 0,
|
||||||
tombstones: std::collections::HashSet::new(),
|
tombstones: std::collections::HashSet::new(),
|
||||||
@@ -485,3 +488,102 @@ async fn restart_new_register_resumes_from_lamport_floor() {
|
|||||||
max_seq,
|
max_seq,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression test for story 1116: ops sent before `flush_persistence` must all be
|
||||||
|
/// present in the `crdt_ops` SQLite table after the flush completes.
|
||||||
|
///
|
||||||
|
/// Bug: `rebuild_and_restart` called `exec()` before the persistence task had
|
||||||
|
/// a chance to drain the unbounded channel, silently dropping queued ops.
|
||||||
|
///
|
||||||
|
/// Reproducer: apply N ops → call `rebuild_and_restart` → the process re-execs
|
||||||
|
/// and on the next startup `persisted_ops_count` is < N (lost ops).
|
||||||
|
/// Fixed by: send a `Flush` sentinel through the channel before `exec()`; the
|
||||||
|
/// task echoes back only after all preceding `Op` messages are committed.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn flush_persistence_drains_all_ops_before_ack() {
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let db_path = tmp.path().join("flush_drain_test.db");
|
||||||
|
|
||||||
|
let options = SqliteConnectOptions::new()
|
||||||
|
.filename(&db_path)
|
||||||
|
.create_if_missing(true);
|
||||||
|
let pool = SqlitePool::connect_with(options).await.unwrap();
|
||||||
|
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
|
||||||
|
|
||||||
|
let kp = make_keypair();
|
||||||
|
let mut crdt = BaseCrdt::<PipelineDoc>::new(&kp);
|
||||||
|
|
||||||
|
// Spawn an isolated persistence task — same logic as init() but without
|
||||||
|
// touching the global singleton (keeping this test fully self-contained).
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel::<PersistMsg>();
|
||||||
|
let pool_clone = pool.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
use std::sync::atomic::AtomicUsize;
|
||||||
|
let counter = AtomicUsize::new(0);
|
||||||
|
while let Some(msg) = rx.recv().await {
|
||||||
|
match msg {
|
||||||
|
PersistMsg::Op(op) => {
|
||||||
|
let op_json = serde_json::to_string(&op).unwrap();
|
||||||
|
let op_id = hex::encode(&op.id());
|
||||||
|
let seq = op.inner.seq as i64;
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO crdt_ops (op_id, seq, op_json, created_at) \
|
||||||
|
VALUES (?1, ?2, ?3, ?4) ON CONFLICT(op_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(&op_id)
|
||||||
|
.bind(seq)
|
||||||
|
.bind(&op_json)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&pool_clone)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
counter.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
PersistMsg::Flush(reply) => {
|
||||||
|
let _ = reply.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const N: usize = 10;
|
||||||
|
for i in 0..N {
|
||||||
|
let item: JsonValue = json!({
|
||||||
|
"story_id": format!("1116_drain_{i}"),
|
||||||
|
"stage": "1_backlog",
|
||||||
|
"name": format!("Drain Test {i}"),
|
||||||
|
"agent": "",
|
||||||
|
"retry_count": 0.0,
|
||||||
|
"blocked": false,
|
||||||
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
|
})
|
||||||
|
.into();
|
||||||
|
let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
|
||||||
|
crdt.apply(op.clone());
|
||||||
|
tx.send(PersistMsg::Op(Box::new(op))).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send flush sentinel and wait — all N ops must be committed first.
|
||||||
|
let (flush_tx, flush_rx) = oneshot::channel();
|
||||||
|
tx.send(PersistMsg::Flush(flush_tx)).unwrap();
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(5), flush_rx)
|
||||||
|
.await
|
||||||
|
.expect("flush timed out — persistence task did not drain within 5 s")
|
||||||
|
.expect("flush oneshot dropped unexpectedly");
|
||||||
|
|
||||||
|
// Verify all N ops are in the database.
|
||||||
|
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM crdt_ops")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
count as usize, N,
|
||||||
|
"all {N} ops must be in crdt_ops after flush; got {count}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,121 @@ pub struct PipelineDoc {
|
|||||||
pub agent_throttle: ListCrdt<AgentThrottleCrdt>,
|
pub agent_throttle: ListCrdt<AgentThrottleCrdt>,
|
||||||
pub gateway_projects: ListCrdt<GatewayProjectCrdt>,
|
pub gateway_projects: ListCrdt<GatewayProjectCrdt>,
|
||||||
pub gateway_config: GatewayConfigCrdt,
|
pub gateway_config: GatewayConfigCrdt,
|
||||||
|
/// Append-only log of every pipeline transition, persisted as CRDT ops.
|
||||||
|
pub event_log: ListCrdt<EventLogEntryCrdt>,
|
||||||
|
/// Per-session LLM context state (high-water marks for event log injection).
|
||||||
|
pub llm_sessions: ListCrdt<LlmSessionCrdt>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CRDT entry representing a single persisted pipeline stage-transition event.
|
||||||
|
///
|
||||||
|
/// Entries are append-only; once written they are never updated or tombstoned.
|
||||||
|
/// The `event_seq` field is a per-sled monotonic counter computed at write time
|
||||||
|
/// (count of existing entries for that sled), giving deterministic ordering for
|
||||||
|
/// all transitions recorded by a single node even after CRDT replay on restart.
|
||||||
|
#[add_crdt_fields]
|
||||||
|
#[derive(Clone, CrdtNode, Debug)]
|
||||||
|
pub struct EventLogEntryCrdt {
|
||||||
|
/// Monotonic sequence number for this sled (0, 1, 2, …). Stored as `f64`
|
||||||
|
/// because all CRDT scalar registers use JSON numbers.
|
||||||
|
pub event_seq: LwwRegisterCrdt<f64>,
|
||||||
|
/// Hex-encoded Ed25519 public key of the sled that recorded this event.
|
||||||
|
pub sled_id: LwwRegisterCrdt<String>,
|
||||||
|
/// Unix timestamp (seconds) when the transition fired.
|
||||||
|
pub timestamp: LwwRegisterCrdt<f64>,
|
||||||
|
/// Story ID of the work item that transitioned (e.g. `"42_story_foo"`).
|
||||||
|
pub story_id: LwwRegisterCrdt<String>,
|
||||||
|
/// Human-readable label of the stage before the transition.
|
||||||
|
pub from_stage: LwwRegisterCrdt<String>,
|
||||||
|
/// Human-readable label of the stage after the transition.
|
||||||
|
pub to_stage: LwwRegisterCrdt<String>,
|
||||||
|
/// String label of the `PipelineEvent` variant that triggered the transition.
|
||||||
|
pub pipeline_event: LwwRegisterCrdt<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CRDT entry tracking an LLM session's event-log injection state.
|
||||||
|
///
|
||||||
|
/// Each session (keyed by `session_id`, typically a Matrix room ID) records the
|
||||||
|
/// per-sled high-water marks so that `assemble_prompt_context` can inject only
|
||||||
|
/// events the LLM has not yet seen and then advance the marks atomically.
|
||||||
|
#[add_crdt_fields]
|
||||||
|
#[derive(Clone, CrdtNode, Debug)]
|
||||||
|
pub struct LlmSessionCrdt {
|
||||||
|
/// Stable session identifier (e.g. Matrix room ID).
|
||||||
|
pub session_id: LwwRegisterCrdt<String>,
|
||||||
|
/// Human-readable persona name (e.g. `"Timmy"`).
|
||||||
|
pub persona_name: LwwRegisterCrdt<String>,
|
||||||
|
/// Scope wire string parsed by [`ScopeFilter::from_scope_str`]: `"all"`,
|
||||||
|
/// `"sleds:hex1,hex2"`, or legacy `"single-sled"` / empty (→ local sled).
|
||||||
|
pub scope: LwwRegisterCrdt<String>,
|
||||||
|
/// JSON-serialised `BTreeMap<sled_id, last_seen_event_seq>` tracking how far
|
||||||
|
/// each sled's event stream has been injected into this session's prompts.
|
||||||
|
pub high_water: LwwRegisterCrdt<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which sleds' events an LLM session may see.
|
||||||
|
///
|
||||||
|
/// Stored as a compact string in the CRDT register and parsed at read time.
|
||||||
|
/// The default for a freshly-created session with no stored scope is
|
||||||
|
/// [`ScopeFilter::LocalOnly`], which preserves prior single-sled behaviour.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum ScopeFilter {
|
||||||
|
/// Include events from every sled present in the CRDT event log.
|
||||||
|
///
|
||||||
|
/// Default for gateway-level personas (e.g. Timmy in multi-project mode).
|
||||||
|
All,
|
||||||
|
/// Include only events whose `sled_id` is in the given set.
|
||||||
|
///
|
||||||
|
/// Default for sled-level personas: the set contains only the sled's own ID.
|
||||||
|
Sleds(std::collections::BTreeSet<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScopeFilter {
|
||||||
|
/// Parse a wire-form scope string stored in the CRDT register.
|
||||||
|
///
|
||||||
|
/// Recognised forms:
|
||||||
|
/// - `"all"` → [`ScopeFilter::All`]
|
||||||
|
/// - `"sleds:hex1,hex2,…"` → [`ScopeFilter::Sleds`]
|
||||||
|
/// - Anything else (including legacy `"single-sled"` and empty) →
|
||||||
|
/// [`ScopeFilter::Sleds`] with an empty set; callers should fall back
|
||||||
|
/// to the local sled ID in that case.
|
||||||
|
pub fn from_scope_str(s: &str) -> Self {
|
||||||
|
if s == "all" {
|
||||||
|
return ScopeFilter::All;
|
||||||
|
}
|
||||||
|
if let Some(rest) = s.strip_prefix("sleds:") {
|
||||||
|
let ids = rest
|
||||||
|
.split(',')
|
||||||
|
.filter(|id| !id.is_empty())
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.collect();
|
||||||
|
return ScopeFilter::Sleds(ids);
|
||||||
|
}
|
||||||
|
ScopeFilter::Sleds(std::collections::BTreeSet::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode this filter as the compact wire string stored in the CRDT.
|
||||||
|
pub fn to_scope_str(&self) -> String {
|
||||||
|
match self {
|
||||||
|
ScopeFilter::All => "all".to_string(),
|
||||||
|
ScopeFilter::Sleds(ids) => {
|
||||||
|
let joined = ids.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(",");
|
||||||
|
format!("sleds:{joined}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-side snapshot of a single LLM session entry.
|
||||||
|
pub struct LlmSessionView {
|
||||||
|
/// Stable session identifier.
|
||||||
|
pub session_id: String,
|
||||||
|
/// Persona name for the bot in this session.
|
||||||
|
pub persona_name: String,
|
||||||
|
/// Parsed event-scope filter derived from the `scope` CRDT register.
|
||||||
|
pub scope_filter: ScopeFilter,
|
||||||
|
/// Decoded high-water map: sled_id → last seen event_seq.
|
||||||
|
pub high_water: std::collections::BTreeMap<String, u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CRDT sub-document representing a single pipeline work item with LWW fields for stage, agent, etc.
|
/// CRDT sub-document representing a single pipeline work item with LWW fields for stage, agent, etc.
|
||||||
|
|||||||
@@ -165,7 +165,9 @@ pub fn delete_content(key: ContentKey<'_>) {
|
|||||||
|
|
||||||
/// Ensure the in-memory content store is initialised.
|
/// Ensure the in-memory content store is initialised.
|
||||||
///
|
///
|
||||||
/// Safe to call multiple times — the `OnceLock` is set at most once.
|
/// In non-test builds: init-once via `OnceLock` (safe to call multiple times).
|
||||||
|
/// In test builds: always resets `CONTENT_STORE_TL` to an empty `HashMap` so
|
||||||
|
/// each test on the same thread starts with a clean store.
|
||||||
pub fn ensure_content_store() {
|
pub fn ensure_content_store() {
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
{
|
{
|
||||||
@@ -175,7 +177,11 @@ pub fn ensure_content_store() {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
{
|
{
|
||||||
CONTENT_STORE_TL.with(|lock| {
|
CONTENT_STORE_TL.with(|lock| {
|
||||||
if lock.get().is_none() {
|
if let Some(mutex) = lock.get() {
|
||||||
|
// Already initialised on this thread — reset to empty so the
|
||||||
|
// next test does not see content written by a previous test.
|
||||||
|
mutex.lock().unwrap().clear();
|
||||||
|
} else {
|
||||||
let _ = lock.set(Mutex::new(HashMap::new()));
|
let _ = lock.set(Mutex::new(HashMap::new()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -203,6 +209,41 @@ pub(super) fn init_content_store(map: HashMap<String, String>) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Regression: two sequential `ensure_content_store()` + write + read cycles
|
||||||
|
/// in the same test body must not see each other's content. Before the fix,
|
||||||
|
/// `ensure_content_store()` was a no-op on the second call (OnceLock gating),
|
||||||
|
/// so the second cycle could read items written in the first cycle.
|
||||||
|
#[test]
|
||||||
|
fn sequential_ensure_content_store_resets_state() {
|
||||||
|
// ── Cycle 1 ──────────────────────────────────────────────────────────
|
||||||
|
ensure_content_store();
|
||||||
|
write_content(ContentKey::Story("1111_cycle1"), "cycle-one body");
|
||||||
|
assert_eq!(
|
||||||
|
read_content(ContentKey::Story("1111_cycle1")).as_deref(),
|
||||||
|
Some("cycle-one body"),
|
||||||
|
"cycle 1: item must be readable after write"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Cycle 2: reset, write a different item ────────────────────────────
|
||||||
|
ensure_content_store();
|
||||||
|
// Cycle-1 item must no longer be visible.
|
||||||
|
assert!(
|
||||||
|
read_content(ContentKey::Story("1111_cycle1")).is_none(),
|
||||||
|
"cycle 2: store must be empty; cycle-1 content must not bleed through"
|
||||||
|
);
|
||||||
|
write_content(ContentKey::Story("1111_cycle2"), "cycle-two body");
|
||||||
|
assert_eq!(
|
||||||
|
read_content(ContentKey::Story("1111_cycle2")).as_deref(),
|
||||||
|
Some("cycle-two body"),
|
||||||
|
"cycle 2: own item must be readable"
|
||||||
|
);
|
||||||
|
// And cycle-1 key must still be absent.
|
||||||
|
assert!(
|
||||||
|
read_content(ContentKey::Story("1111_cycle1")).is_none(),
|
||||||
|
"cycle 2: cycle-1 content must remain absent after cycle-2 write"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// AC 2 regression: writing under `ContentKey::Story` is not visible under
|
/// AC 2 regression: writing under `ContentKey::Story` is not visible under
|
||||||
/// `ContentKey::GateOutput` (and vice versa). The typed key namespace, not
|
/// `ContentKey::GateOutput` (and vice versa). The typed key namespace, not
|
||||||
/// runtime substring matching, enforces the separation.
|
/// runtime substring matching, enforces the separation.
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ pub fn write_item_with_content(story_id: &str, stage: &str, content: &str, meta:
|
|||||||
.and_then(|d| serde_json::to_string(d).ok());
|
.and_then(|d| serde_json::to_string(d).ok());
|
||||||
|
|
||||||
// Update in-memory content store.
|
// Update in-memory content store.
|
||||||
|
// In test builds, the caller (test setup) is responsible for calling
|
||||||
|
// ensure_content_store() once before writing — calling it here would
|
||||||
|
// reset the store on every write, losing items from prior writes in the
|
||||||
|
// same test. In production, the lazy-init call is safe because nothing
|
||||||
|
// resets the store between writes.
|
||||||
|
#[cfg(not(test))]
|
||||||
ensure_content_store();
|
ensure_content_store();
|
||||||
write_content(ContentKey::Story(story_id), content);
|
write_content(ContentKey::Story(story_id), content);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
//! Pipeline transition event log — persists every `TransitionFired` event into
|
||||||
|
//! the CRDT so the log survives server restarts and replicates across nodes.
|
||||||
|
//!
|
||||||
|
//! ## Design
|
||||||
|
//!
|
||||||
|
//! Each [`TransitionFired`][crate::pipeline_state::TransitionFired] is written
|
||||||
|
//! as an [`EventLogEntryCrdt`][crate::crdt_state::EventLogEntryCrdt] entry in
|
||||||
|
//! the `PipelineDoc::event_log` grow-only list. Because the list is backed by
|
||||||
|
//! CRDT ops that are persisted to SQLite and replayed on startup, the log
|
||||||
|
//! survives `rebuild_and_restart` without any additional bookkeeping.
|
||||||
|
//!
|
||||||
|
//! A monotonic per-sled sequence number (`event_seq`) is computed atomically
|
||||||
|
//! while the CRDT lock is held, guaranteeing that no two entries from the same
|
||||||
|
//! sled share a sequence number and that the numbers are contiguous from 0.
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use chrono::DateTime;
|
||||||
|
|
||||||
|
/// Monotonic per-sled logical sequence number identifying a pipeline event.
|
||||||
|
///
|
||||||
|
/// This is the sequence number that *would have been assigned* to an event in the
|
||||||
|
/// contiguous logical event stream, as tracked by the event-log subscriber. It
|
||||||
|
/// differs from the CRDT `event_seq` (which counts CRDT entries including gap
|
||||||
|
/// sentinels) but is meaningful for identifying the range of dropped events when
|
||||||
|
/// a gap is inserted.
|
||||||
|
pub type EventId = u64;
|
||||||
|
|
||||||
|
/// A snapshot of a single persisted pipeline transition event.
|
||||||
|
///
|
||||||
|
/// Constructed by [`read_event_log`] from the raw CRDT entries.
|
||||||
|
pub struct LoggedEvent {
|
||||||
|
/// Monotonic sequence number for `sled_id` (0-based, contiguous).
|
||||||
|
pub event_id: u64,
|
||||||
|
/// Hex-encoded Ed25519 public key of the sled that recorded this event.
|
||||||
|
pub sled_id: String,
|
||||||
|
/// UTC timestamp when the transition fired.
|
||||||
|
pub at: DateTime<chrono::Utc>,
|
||||||
|
/// Story ID of the work item that transitioned.
|
||||||
|
pub story_id: String,
|
||||||
|
/// Human-readable label of the stage before the transition.
|
||||||
|
pub from_stage: String,
|
||||||
|
/// Human-readable label of the stage after the transition.
|
||||||
|
pub to_stage: String,
|
||||||
|
/// String label of the `PipelineEvent` variant that triggered the transition.
|
||||||
|
pub pipeline_event: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a single `TransitionFired` event into the CRDT event log.
|
||||||
|
///
|
||||||
|
/// Computes the next monotonic `event_seq` for this sled atomically inside
|
||||||
|
/// the CRDT write lock and appends the entry. No-ops when the CRDT is not
|
||||||
|
/// yet initialised (e.g. in gateway mode with no project root).
|
||||||
|
pub fn log_transition_event(fired: &crate::pipeline_state::TransitionFired) {
|
||||||
|
let sled_id = crate::crdt_state::our_node_id().unwrap_or_default();
|
||||||
|
let timestamp = fired.at.timestamp() as f64;
|
||||||
|
let from_stage = crate::pipeline_state::stage_label(&fired.before);
|
||||||
|
let to_stage = crate::pipeline_state::stage_label(&fired.after);
|
||||||
|
let pipeline_event = crate::pipeline_state::event_label(&fired.event);
|
||||||
|
|
||||||
|
crate::crdt_state::append_event_log_entry(
|
||||||
|
&sled_id,
|
||||||
|
timestamp,
|
||||||
|
&fired.story_id.0,
|
||||||
|
from_stage,
|
||||||
|
to_stage,
|
||||||
|
pipeline_event,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read all persisted events from the CRDT event log.
|
||||||
|
///
|
||||||
|
/// Entries are returned sorted by `(sled_id, event_id)` so that events from
|
||||||
|
/// each sled appear in monotonic order. Entries with malformed CRDT fields
|
||||||
|
/// are silently dropped.
|
||||||
|
pub fn read_event_log() -> Vec<LoggedEvent> {
|
||||||
|
let mut entries: Vec<LoggedEvent> = crate::crdt_state::read_all_event_log_entries()
|
||||||
|
.into_iter()
|
||||||
|
.map(|raw| LoggedEvent {
|
||||||
|
event_id: raw.event_seq,
|
||||||
|
sled_id: raw.sled_id,
|
||||||
|
at: DateTime::from_timestamp(raw.timestamp as i64, 0).unwrap_or_default(),
|
||||||
|
story_id: raw.story_id,
|
||||||
|
from_stage: raw.from_stage,
|
||||||
|
to_stage: raw.to_stage,
|
||||||
|
pipeline_event: raw.pipeline_event,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
entries.sort_by(|a, b| a.sled_id.cmp(&b.sled_id).then(a.event_id.cmp(&b.event_id)));
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a gap sentinel to the event log for the local sled.
|
||||||
|
///
|
||||||
|
/// Encodes the logical [`EventId`] range `[from_id, to_id]` of dropped events
|
||||||
|
/// using the `EventStreamGap` pipeline event marker. Should be called whenever
|
||||||
|
/// the event-log subscriber detects a lag in the broadcast channel so that no
|
||||||
|
/// drop is silent.
|
||||||
|
pub fn insert_gap_sentinel(from_id: EventId, to_id: EventId) {
|
||||||
|
let sled_id = crate::crdt_state::our_node_id().unwrap_or_default();
|
||||||
|
crate::crdt_state::append_gap_log_entry(&sled_id, from_id, to_id);
|
||||||
|
log_gap_observability(&sled_id, from_id, to_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a background task that persists every `TransitionFired` event to the CRDT.
|
||||||
|
///
|
||||||
|
/// Subscribes to the global `TransitionFired` broadcast channel. Normal events
|
||||||
|
/// are persisted via [`log_transition_event`]. When the subscriber lags (the
|
||||||
|
/// broadcast channel drops the oldest messages), a single
|
||||||
|
/// `EventStreamGap` sentinel is appended to the log covering the dropped range
|
||||||
|
/// so no transition is silently lost.
|
||||||
|
pub fn spawn_event_log_subscriber() {
|
||||||
|
let mut rx = crate::pipeline_state::subscribe_transitions();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Tracks the next expected logical sequence number in the subscriber's
|
||||||
|
// view of the event stream. Incremented on every successfully processed
|
||||||
|
// event; advanced by the gap size on each lag so we can identify the
|
||||||
|
// exact logical range of dropped events.
|
||||||
|
let mut next_logical_seq: EventId = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(fired) => {
|
||||||
|
log_transition_event(&fired);
|
||||||
|
next_logical_seq += 1;
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||||
|
let from = next_logical_seq;
|
||||||
|
let to = next_logical_seq + n - 1;
|
||||||
|
crate::slog_warn!(
|
||||||
|
"[event-log] Subscriber lagged; {n} event(s) dropped \
|
||||||
|
(logical ids {from}..={to}); gap sentinel appended."
|
||||||
|
);
|
||||||
|
insert_gap_sentinel(from, to);
|
||||||
|
next_logical_seq += n;
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit observability log lines after inserting a gap sentinel.
|
||||||
|
fn log_gap_observability(sled_id: &str, from_id: EventId, to_id: EventId) {
|
||||||
|
let entries = crate::crdt_state::read_all_event_log_entries();
|
||||||
|
let sled_total: usize = entries.iter().filter(|e| e.sled_id == sled_id).count();
|
||||||
|
let gap_count: usize = entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| {
|
||||||
|
e.sled_id == sled_id && e.pipeline_event == crate::crdt_state::GAP_PIPELINE_EVENT
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
crate::slog!(
|
||||||
|
"[event-log] gap inserted sled={sled_id} from={from_id} to={to_id} \
|
||||||
|
sled_entries={sled_total} gap_count={gap_count}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::crdt_state::PipelineDoc;
|
||||||
|
use crate::pipeline_state::{PipelineEvent, PlanState, Stage, StoryId, TransitionFired};
|
||||||
|
use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue, OpState};
|
||||||
|
use bft_json_crdt::keypair::make_keypair;
|
||||||
|
use bft_json_crdt::op::ROOT_ID;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn make_fired(i: u32) -> TransitionFired {
|
||||||
|
TransitionFired {
|
||||||
|
story_id: StoryId(format!("test_{i}")),
|
||||||
|
before: Stage::Backlog,
|
||||||
|
after: Stage::Coding {
|
||||||
|
claim: None,
|
||||||
|
plan: PlanState::Missing,
|
||||||
|
retries: 0,
|
||||||
|
},
|
||||||
|
event: PipelineEvent::DepsMet,
|
||||||
|
at: chrono::Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC4: fire N `TransitionFired` events, simulate a restart by re-initialising
|
||||||
|
/// the CRDT (replaying all ops on a fresh doc), assert all N entries appear in
|
||||||
|
/// the log in insertion order with monotonically increasing IDs.
|
||||||
|
#[test]
|
||||||
|
fn event_log_survives_crdt_reinit() {
|
||||||
|
let kp = make_keypair();
|
||||||
|
let mut crdt1 = BaseCrdt::<PipelineDoc>::new(&kp);
|
||||||
|
let sled_id = crate::crdt_state::hex::encode(&crdt1.id);
|
||||||
|
|
||||||
|
let n = 5usize;
|
||||||
|
let mut ops = Vec::new();
|
||||||
|
// Track the last OpId so each entry appends to the end (insert after
|
||||||
|
// ROOT_ID would place each entry at the front, reversing the sequence).
|
||||||
|
let mut last_id = ROOT_ID;
|
||||||
|
|
||||||
|
for i in 0..n {
|
||||||
|
let entry: JsonValue = json!({
|
||||||
|
"event_seq": i as f64,
|
||||||
|
"sled_id": &sled_id,
|
||||||
|
"timestamp": 1_000_000.0_f64 + i as f64,
|
||||||
|
"story_id": format!("story_{i}"),
|
||||||
|
"from_stage": "backlog",
|
||||||
|
"to_stage": "coding",
|
||||||
|
"pipeline_event": "DepsMet",
|
||||||
|
})
|
||||||
|
.into();
|
||||||
|
let op = crdt1.doc.event_log.insert(last_id, entry).sign(&kp);
|
||||||
|
last_id = op.inner.id;
|
||||||
|
assert_eq!(crdt1.apply(op.clone()), OpState::Ok);
|
||||||
|
ops.push(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(crdt1.doc.event_log.view().len(), n);
|
||||||
|
|
||||||
|
// Simulate restart: replay the same ops on a fresh CRDT instance.
|
||||||
|
let mut crdt2 = BaseCrdt::<PipelineDoc>::new(&kp);
|
||||||
|
for op in ops {
|
||||||
|
assert_eq!(crdt2.apply(op), OpState::Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
crdt2.doc.event_log.view().len(),
|
||||||
|
n,
|
||||||
|
"all {n} entries must survive CRDT re-init"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Entries must appear in insertion order with monotonically increasing IDs.
|
||||||
|
for i in 0..n {
|
||||||
|
let entry = &crdt2.doc.event_log[i];
|
||||||
|
let seq = match entry.event_seq.view() {
|
||||||
|
JsonValue::Number(v) => v as u64,
|
||||||
|
other => panic!("expected numeric event_seq at index {i}, got {other:?}"),
|
||||||
|
};
|
||||||
|
assert_eq!(seq, i as u64, "event_seq must equal insertion index {i}");
|
||||||
|
assert_eq!(
|
||||||
|
entry.story_id.view(),
|
||||||
|
JsonValue::String(format!("story_{i}")),
|
||||||
|
"story_id mismatch at index {i}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
entry.sled_id.view(),
|
||||||
|
JsonValue::String(sled_id.clone()),
|
||||||
|
"sled_id mismatch at index {i}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC4: fill the feeder queue past capacity by inserting a gap sentinel, then
|
||||||
|
/// assert (a) the gap sentinel appears in the event log and (b) the assembled
|
||||||
|
/// context contains the human-readable gap line.
|
||||||
|
#[test]
|
||||||
|
fn gap_sentinel_in_log_and_assembled_context() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
|
|
||||||
|
// Log 3 real events (logical ids 0, 1, 2).
|
||||||
|
for i in 0..3u32 {
|
||||||
|
log_transition_event(&make_fired(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate: the feeder queue overflowed and logical ids 3..=5 were dropped.
|
||||||
|
insert_gap_sentinel(3, 5);
|
||||||
|
|
||||||
|
// Log one more real event after the gap.
|
||||||
|
log_transition_event(&make_fired(99));
|
||||||
|
|
||||||
|
// (a) Gap sentinel must appear in read_event_log().
|
||||||
|
let entries = read_event_log();
|
||||||
|
let gap = entries
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.pipeline_event == crate::crdt_state::GAP_PIPELINE_EVENT);
|
||||||
|
assert!(gap.is_some(), "gap sentinel must be present in event log");
|
||||||
|
let gap = gap.unwrap();
|
||||||
|
// from_stage encodes the from EventId; to_stage encodes the to EventId.
|
||||||
|
assert_eq!(gap.from_stage, "3", "gap from_stage must be '3'");
|
||||||
|
assert_eq!(gap.to_stage, "5", "gap to_stage must be '5'");
|
||||||
|
|
||||||
|
// (b) assemble_prompt_context must render the gap line.
|
||||||
|
let ctx = crate::llm_session::assemble_prompt_context("room-gap-e2e");
|
||||||
|
assert!(
|
||||||
|
ctx.contains("events between 3 and 5 were dropped"),
|
||||||
|
"assembled context must contain gap line; got: {ctx}"
|
||||||
|
);
|
||||||
|
// Real events must also appear.
|
||||||
|
assert!(
|
||||||
|
ctx.contains("test_0"),
|
||||||
|
"first story must appear; got: {ctx}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ctx.contains("test_99"),
|
||||||
|
"last story must appear; got: {ctx}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC2: every `TransitionFired` event is written to the log without filtering.
|
||||||
|
#[test]
|
||||||
|
fn log_transition_event_appends_all_events() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
|
|
||||||
|
let n = 4u32;
|
||||||
|
for i in 0..n {
|
||||||
|
log_transition_event(&make_fired(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = crate::crdt_state::read_all_event_log_entries();
|
||||||
|
assert_eq!(
|
||||||
|
entries.len(),
|
||||||
|
n as usize,
|
||||||
|
"expected {n} event log entries, got {}",
|
||||||
|
entries.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify monotonic sequence numbers 0..n-1.
|
||||||
|
let mut seqs: Vec<u64> = entries.iter().map(|e| e.event_seq).collect();
|
||||||
|
seqs.sort_unstable();
|
||||||
|
let expected: Vec<u64> = (0..u64::from(n)).collect();
|
||||||
|
assert_eq!(seqs, expected, "event_seq values must be 0..{n}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,13 +62,6 @@ pub fn build_gateway_route(state_arc: Arc<GatewayState>) -> impl poem::Endpoint
|
|||||||
"/gateway/agents/:id/assign",
|
"/gateway/agents/:id/assign",
|
||||||
poem::post(gateway_assign_agent_handler),
|
poem::post(gateway_assign_agent_handler),
|
||||||
)
|
)
|
||||||
// Serve the embedded React frontend so the gateway has a UI.
|
|
||||||
.at(
|
|
||||||
"/assets/*path",
|
|
||||||
poem::get(crate::http::assets::embedded_asset),
|
|
||||||
)
|
|
||||||
.at("/*path", poem::get(crate::http::assets::embedded_file))
|
|
||||||
.at("/", poem::get(crate::http::assets::embedded_index))
|
|
||||||
.data(state_arc)
|
.data(state_arc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +106,6 @@ 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.
|
// Spawn the Matrix bot if `.huskies/bot.toml` exists in the config directory.
|
||||||
let gateway_projects: Vec<String> = state_arc.projects.read().await.keys().cloned().collect();
|
|
||||||
let gateway_project_urls: std::collections::BTreeMap<String, String> = state_arc
|
let gateway_project_urls: std::collections::BTreeMap<String, String> = state_arc
|
||||||
.projects
|
.projects
|
||||||
.read()
|
.read()
|
||||||
@@ -124,8 +116,8 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
|||||||
let (bot_abort, bot_shutdown_tx) = gateway::io::spawn_gateway_bot(
|
let (bot_abort, bot_shutdown_tx) = gateway::io::spawn_gateway_bot(
|
||||||
&config_dir,
|
&config_dir,
|
||||||
Arc::clone(&state_arc.active_project),
|
Arc::clone(&state_arc.active_project),
|
||||||
gateway_projects,
|
|
||||||
gateway_project_urls,
|
gateway_project_urls,
|
||||||
|
Arc::clone(&state_arc.projects),
|
||||||
port,
|
port,
|
||||||
Some(state_arc.event_tx.clone()),
|
Some(state_arc.event_tx.clone()),
|
||||||
Arc::clone(&state_arc.perm_rx),
|
Arc::clone(&state_arc.perm_rx),
|
||||||
|
|||||||
@@ -1175,6 +1175,8 @@ async fn ws_only_sled_handles_tools_list_and_tools_call() {
|
|||||||
ProjectEntry {
|
ProjectEntry {
|
||||||
url: None,
|
url: None,
|
||||||
auth_token: Some("secret".into()),
|
auth_token: Some("secret".into()),
|
||||||
|
ssh_port: None,
|
||||||
|
host_path: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig {
|
let config = GatewayConfig {
|
||||||
@@ -1244,6 +1246,8 @@ async fn two_concurrent_sleds_are_routed_by_active_project() {
|
|||||||
ProjectEntry {
|
ProjectEntry {
|
||||||
url: None,
|
url: None,
|
||||||
auth_token: Some("alpha-tok".into()),
|
auth_token: Some("alpha-tok".into()),
|
||||||
|
ssh_port: None,
|
||||||
|
host_path: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
projects.insert(
|
projects.insert(
|
||||||
@@ -1251,6 +1255,8 @@ async fn two_concurrent_sleds_are_routed_by_active_project() {
|
|||||||
ProjectEntry {
|
ProjectEntry {
|
||||||
url: None,
|
url: None,
|
||||||
auth_token: Some("beta-tok".into()),
|
auth_token: Some("beta-tok".into()),
|
||||||
|
ssh_port: None,
|
||||||
|
host_path: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig {
|
let config = GatewayConfig {
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
//! Static asset serving — serves the embedded React frontend via `rust-embed`.
|
|
||||||
use poem::{
|
|
||||||
Response, handler,
|
|
||||||
http::{StatusCode, header},
|
|
||||||
web::Path,
|
|
||||||
};
|
|
||||||
use rust_embed::RustEmbed;
|
|
||||||
|
|
||||||
#[derive(RustEmbed)]
|
|
||||||
#[folder = "../frontend/dist"]
|
|
||||||
struct EmbeddedAssets;
|
|
||||||
|
|
||||||
fn serve_embedded(path: &str) -> Response {
|
|
||||||
let normalized = if path.is_empty() {
|
|
||||||
"index.html"
|
|
||||||
} else {
|
|
||||||
path.trim_start_matches('/')
|
|
||||||
};
|
|
||||||
|
|
||||||
let is_asset_request = normalized.starts_with("assets/");
|
|
||||||
let asset = if is_asset_request {
|
|
||||||
EmbeddedAssets::get(normalized)
|
|
||||||
} else {
|
|
||||||
EmbeddedAssets::get(normalized).or_else(|| {
|
|
||||||
if normalized == "index.html" {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
EmbeddedAssets::get("index.html")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
match asset {
|
|
||||||
Some(content) => {
|
|
||||||
let body = content.data.into_owned();
|
|
||||||
let mime = mime_guess::from_path(normalized)
|
|
||||||
.first_or_octet_stream()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header(header::CONTENT_TYPE, mime)
|
|
||||||
.body(body)
|
|
||||||
}
|
|
||||||
None => Response::builder()
|
|
||||||
.status(StatusCode::NOT_FOUND)
|
|
||||||
.body("Not Found"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serve a single embedded asset from the `assets/` folder.
|
|
||||||
#[handler]
|
|
||||||
pub fn embedded_asset(Path(path): Path<String>) -> Response {
|
|
||||||
let asset_path = format!("assets/{path}");
|
|
||||||
serve_embedded(&asset_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serve an embedded file by path (falls back to `index.html` for SPA routing).
|
|
||||||
#[handler]
|
|
||||||
pub fn embedded_file(Path(path): Path<String>) -> Response {
|
|
||||||
serve_embedded(&path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serve the embedded SPA entrypoint.
|
|
||||||
#[handler]
|
|
||||||
pub fn embedded_index() -> Response {
|
|
||||||
serve_embedded("index.html")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use poem::http::StatusCode;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn non_asset_path_spa_fallback_or_not_found() {
|
|
||||||
// Non-asset paths fall back to index.html for SPA client-side routing.
|
|
||||||
// In release builds (with embedded dist/) this returns 200.
|
|
||||||
// In debug builds without a built frontend dist/ it returns 404.
|
|
||||||
let response = serve_embedded("__nonexistent_spa_route__.html");
|
|
||||||
let status = response.status();
|
|
||||||
assert!(
|
|
||||||
status == StatusCode::OK || status == StatusCode::NOT_FOUND,
|
|
||||||
"unexpected status: {status}",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_asset_path_prefix_returns_not_found() {
|
|
||||||
// assets/ prefix: no SPA fallback – returns 404 if the file does not exist
|
|
||||||
let response = serve_embedded("assets/__nonexistent__.js");
|
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn serve_embedded_does_not_panic_on_empty_path() {
|
|
||||||
// Empty path normalises to index.html; OK in release, 404 in debug without dist/
|
|
||||||
let response = serve_embedded("");
|
|
||||||
let status = response.status();
|
|
||||||
assert!(
|
|
||||||
status == StatusCode::OK || status == StatusCode::NOT_FOUND,
|
|
||||||
"unexpected status: {status}",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn embedded_assets_struct_is_iterable() {
|
|
||||||
// Verifies that rust-embed compiled the EmbeddedAssets struct correctly.
|
|
||||||
// In debug builds without a built frontend dist/ directory the iterator is empty; that is
|
|
||||||
// expected. In release builds it will contain all bundled frontend files.
|
|
||||||
let _files: Vec<_> = EmbeddedAssets::iter().collect();
|
|
||||||
// No assertion needed – the test passes as long as it compiles and does not panic.
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn embedded_index_handler_returns_ok_or_not_found() {
|
|
||||||
// Route the handler through TestClient; index.html is the SPA entry point.
|
|
||||||
let app = poem::Route::new().at("/", poem::get(embedded_index));
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
let resp = cli.get("/").send().await;
|
|
||||||
let status = resp.0.status();
|
|
||||||
assert!(
|
|
||||||
status == StatusCode::OK || status == StatusCode::NOT_FOUND,
|
|
||||||
"unexpected status: {status}",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn embedded_file_handler_with_path_returns_ok_or_not_found() {
|
|
||||||
// Non-asset paths fall back to index.html (SPA routing) or 404.
|
|
||||||
let app = poem::Route::new().at("/*path", poem::get(embedded_file));
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
let resp = cli.get("/__spa_route__").send().await;
|
|
||||||
let status = resp.0.status();
|
|
||||||
assert!(
|
|
||||||
status == StatusCode::OK || status == StatusCode::NOT_FOUND,
|
|
||||||
"unexpected status: {status}",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn embedded_asset_handler_missing_file_returns_not_found() {
|
|
||||||
// The assets/ prefix disables SPA fallback; missing files must return 404.
|
|
||||||
let app = poem::Route::new().at("/assets/*path", poem::get(embedded_asset));
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
let resp = cli.get("/assets/__nonexistent__.js").send().await;
|
|
||||||
assert_eq!(resp.0.status(), StatusCode::NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,7 @@ const GATEWAY_TOOLS: &[&str] = &[
|
|||||||
"gateway_status",
|
"gateway_status",
|
||||||
"gateway_health",
|
"gateway_health",
|
||||||
"init_project",
|
"init_project",
|
||||||
|
"adopt_project",
|
||||||
"aggregate_pipeline_status",
|
"aggregate_pipeline_status",
|
||||||
"agents.list",
|
"agents.list",
|
||||||
// Handled at the gateway so the Matrix bot's perm_rx listener is used
|
// Handled at the gateway so the Matrix bot's perm_rx listener is used
|
||||||
@@ -82,6 +83,28 @@ pub(crate) fn gateway_tool_definitions() -> Vec<Value> {
|
|||||||
"required": ["path"]
|
"required": ["path"]
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
json!({
|
||||||
|
"name": "adopt_project",
|
||||||
|
"description": "Wrap a Docker container around an existing host checkout — the same as `new project <name> --adopt <path>`. No git clone or git init is performed; the directory is bind-mounted at /workspace. Launches the appropriate stack-specific image, generates an SSH keypair, and registers the project in projects.toml. Returns the SSH connection command and detected stack.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Short project name (letters, digits, hyphens, underscores). Must be unique across registered projects."
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Absolute host filesystem path to the existing checkout to adopt. Must be an existing directory."
|
||||||
|
},
|
||||||
|
"stack": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional: override stack detection (e.g. 'rust', 'node', 'python'). Auto-detected from directory contents when omitted."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["name", "path"]
|
||||||
|
}
|
||||||
|
}),
|
||||||
json!({
|
json!({
|
||||||
"name": "aggregate_pipeline_status",
|
"name": "aggregate_pipeline_status",
|
||||||
"description": "Fetch pipeline status from ALL registered projects in parallel and return an aggregated report. For each project: stage counts (backlog/current/qa/merge/done) and a list of blocked or failing items with triage detail. Unreachable projects are included with an error state rather than failing the whole call.",
|
"description": "Fetch pipeline status from ALL registered projects in parallel and return an aggregated report. For each project: stage counts (backlog/current/qa/merge/done) and a list of blocked or failing items with triage detail. Unreachable projects are included with an error state rather than failing the whole call.",
|
||||||
@@ -358,6 +381,7 @@ async fn handle_gateway_tool(
|
|||||||
"gateway_status" => handle_gateway_status_tool(state, id).await,
|
"gateway_status" => handle_gateway_status_tool(state, id).await,
|
||||||
"gateway_health" => handle_gateway_health_tool(state, id).await,
|
"gateway_health" => handle_gateway_health_tool(state, id).await,
|
||||||
"init_project" => handle_init_project_tool(params, state, id).await,
|
"init_project" => handle_init_project_tool(params, state, id).await,
|
||||||
|
"adopt_project" => handle_adopt_project_tool(params, state, id).await,
|
||||||
"aggregate_pipeline_status" => handle_aggregate_pipeline_status_tool(state, id).await,
|
"aggregate_pipeline_status" => handle_aggregate_pipeline_status_tool(state, id).await,
|
||||||
"agents.list" => handle_agents_list_tool(id),
|
"agents.list" => handle_agents_list_tool(id),
|
||||||
"prompt_permission" => handle_prompt_permission_tool(params, state, id).await,
|
"prompt_permission" => handle_prompt_permission_tool(params, state, id).await,
|
||||||
@@ -525,6 +549,81 @@ async fn handle_init_project_tool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle the `adopt_project` gateway tool.
|
||||||
|
///
|
||||||
|
/// Wraps a Docker container around an existing host checkout — the MCP
|
||||||
|
/// equivalent of the `new project <name> --adopt <path>` chat command.
|
||||||
|
/// Validates that `path` exists and is a directory before delegating to
|
||||||
|
/// `handle_new_project`, which performs stack detection, container launch,
|
||||||
|
/// SSH keypair generation, and project registration.
|
||||||
|
async fn handle_adopt_project_tool(
|
||||||
|
params: &Value,
|
||||||
|
state: &GatewayState,
|
||||||
|
id: Option<Value>,
|
||||||
|
) -> JsonRpcResponse {
|
||||||
|
use crate::chat::transport::matrix::new_project::handle_new_project;
|
||||||
|
|
||||||
|
let args = params.get("arguments").unwrap_or(params);
|
||||||
|
let name = args
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
let path_str = args
|
||||||
|
.get("path")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
let stack = args.get("stack").and_then(|v| v.as_str());
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
return JsonRpcResponse::error(id, -32602, "missing required parameter: name".into());
|
||||||
|
}
|
||||||
|
if path_str.is_empty() {
|
||||||
|
return JsonRpcResponse::error(id, -32602, "missing required parameter: path".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = std::path::Path::new(path_str);
|
||||||
|
if !path.exists() {
|
||||||
|
return JsonRpcResponse::error(
|
||||||
|
id,
|
||||||
|
-32602,
|
||||||
|
format!(
|
||||||
|
"Adopt path `{path_str}` does not exist — specify the path to an existing checkout."
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !path.is_dir() {
|
||||||
|
return JsonRpcResponse::error(
|
||||||
|
id,
|
||||||
|
-32602,
|
||||||
|
format!("Adopt path `{path_str}` is not a directory."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = handle_new_project(
|
||||||
|
name,
|
||||||
|
stack,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(path_str),
|
||||||
|
&state.projects,
|
||||||
|
&state.config_dir,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
JsonRpcResponse::success(
|
||||||
|
id,
|
||||||
|
json!({
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": result
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_aggregate_pipeline_status_tool(
|
async fn handle_aggregate_pipeline_status_tool(
|
||||||
state: &GatewayState,
|
state: &GatewayState,
|
||||||
id: Option<Value>,
|
id: Option<Value>,
|
||||||
@@ -686,3 +785,123 @@ async fn handle_pipeline_get(state: &GatewayState, id: Option<Value>) -> JsonRpc
|
|||||||
|
|
||||||
JsonRpcResponse::success(id, json!({ "active": active, "projects": results }))
|
JsonRpcResponse::success(id, json!({ "active": active, "projects": results }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::service::gateway::config::{GatewayConfig, ProjectEntry};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
fn make_test_state(config_dir: &std::path::Path) -> Arc<GatewayState> {
|
||||||
|
let mut projects = BTreeMap::new();
|
||||||
|
projects.insert(
|
||||||
|
"test-project".to_string(),
|
||||||
|
ProjectEntry::with_url("http://127.0.0.1:3001"),
|
||||||
|
);
|
||||||
|
let config = GatewayConfig {
|
||||||
|
projects,
|
||||||
|
sled_tokens: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
Arc::new(GatewayState::new(config, config_dir.to_path_buf(), 3000).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn adopt_project_tool_missing_name_returns_error() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = make_test_state(dir.path());
|
||||||
|
let params = json!({ "arguments": { "path": "/some/path" } });
|
||||||
|
let resp = handle_adopt_project_tool(¶ms, &state, Some(json!(1))).await;
|
||||||
|
assert!(resp.error.is_some(), "expected error for missing name");
|
||||||
|
let msg = resp.error.unwrap().message;
|
||||||
|
assert!(msg.contains("name"), "expected 'name' in error, got: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn adopt_project_tool_missing_path_returns_error() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = make_test_state(dir.path());
|
||||||
|
let params = json!({ "arguments": { "name": "myapp" } });
|
||||||
|
let resp = handle_adopt_project_tool(¶ms, &state, Some(json!(1))).await;
|
||||||
|
assert!(resp.error.is_some(), "expected error for missing path");
|
||||||
|
let msg = resp.error.unwrap().message;
|
||||||
|
assert!(msg.contains("path"), "expected 'path' in error, got: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn adopt_project_tool_nonexistent_path_returns_error() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = make_test_state(dir.path());
|
||||||
|
let params = json!({ "arguments": { "name": "myapp", "path": "/nonexistent/xyz/abc123" } });
|
||||||
|
let resp = handle_adopt_project_tool(¶ms, &state, Some(json!(1))).await;
|
||||||
|
assert!(resp.error.is_some(), "expected error for nonexistent path");
|
||||||
|
let msg = resp.error.unwrap().message;
|
||||||
|
assert!(
|
||||||
|
msg.contains("does not exist"),
|
||||||
|
"expected 'does not exist' in error, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn adopt_project_tool_file_path_returns_error() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let file = dir.path().join("not_a_dir.txt");
|
||||||
|
std::fs::write(&file, "content").unwrap();
|
||||||
|
let state = make_test_state(dir.path());
|
||||||
|
let params = json!({ "arguments": { "name": "myapp", "path": file.to_str().unwrap() } });
|
||||||
|
let resp = handle_adopt_project_tool(¶ms, &state, Some(json!(1))).await;
|
||||||
|
assert!(resp.error.is_some(), "expected error for file path");
|
||||||
|
let msg = resp.error.unwrap().message;
|
||||||
|
assert!(
|
||||||
|
msg.contains("not a directory"),
|
||||||
|
"expected 'not a directory' in error, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The MCP entry point produces the same validation outcome as the chat-routed call.
|
||||||
|
///
|
||||||
|
/// Both paths ultimately run the same checks: path-doesn't-exist and
|
||||||
|
/// path-is-file are tested here to verify the MCP layer is consistent
|
||||||
|
/// with `handle_new_project` in `new_project.rs`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn adopt_project_tool_matches_chat_routed_call() {
|
||||||
|
use crate::chat::transport::matrix::new_project::handle_new_project;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let file = dir.path().join("a_file.txt");
|
||||||
|
std::fs::write(&file, "not a dir").unwrap();
|
||||||
|
let file_path = file.to_str().unwrap();
|
||||||
|
|
||||||
|
// Chat-routed: handle_new_project returns a text string with the error.
|
||||||
|
let store = Arc::new(RwLock::new(BTreeMap::new()));
|
||||||
|
let chat_result = handle_new_project(
|
||||||
|
"myapp",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(file_path),
|
||||||
|
&store,
|
||||||
|
dir.path(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
chat_result.contains("not a directory"),
|
||||||
|
"chat path should report 'not a directory', got: {chat_result}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// MCP-routed: handle_adopt_project_tool returns a JSON-RPC error.
|
||||||
|
let state = make_test_state(dir.path());
|
||||||
|
let params = json!({ "arguments": { "name": "myapp2", "path": file_path } });
|
||||||
|
let mcp_resp = handle_adopt_project_tool(¶ms, &state, Some(json!(1))).await;
|
||||||
|
assert!(mcp_resp.error.is_some(), "MCP path should return an error");
|
||||||
|
let mcp_msg = mcp_resp.error.unwrap().message;
|
||||||
|
assert!(
|
||||||
|
mcp_msg.contains("not a directory"),
|
||||||
|
"MCP path should report 'not a directory', got: {mcp_msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ pub(crate) fn tool_dump_crdt(args: &Value) -> Result<String, String> {
|
|||||||
"total_ops_in_list": dump.total_ops_in_list,
|
"total_ops_in_list": dump.total_ops_in_list,
|
||||||
"max_seq_in_list": dump.max_seq_in_list,
|
"max_seq_in_list": dump.max_seq_in_list,
|
||||||
"persisted_ops_count": dump.persisted_ops_count,
|
"persisted_ops_count": dump.persisted_ops_count,
|
||||||
"pending_persist_ops_count": null,
|
"pending_persist_ops_count": dump.pending_persist_ops_count,
|
||||||
},
|
},
|
||||||
"items": items,
|
"items": items,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ mod tests {
|
|||||||
use crate::http::test_helpers::test_ctx;
|
use crate::http::test_helpers::test_ctx;
|
||||||
|
|
||||||
fn setup_git_repo_in(dir: &std::path::Path) {
|
fn setup_git_repo_in(dir: &std::path::Path) {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
std::process::Command::new("git")
|
std::process::Command::new("git")
|
||||||
.args(["init"])
|
.args(["init"])
|
||||||
.current_dir(dir)
|
.current_dir(dir)
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_refactor_accepts_single_criterion() {
|
fn tool_create_refactor_accepts_single_criterion() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
let result = tool_create_refactor(
|
let result = tool_create_refactor(
|
||||||
@@ -146,6 +147,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_refactor_accepts_mixed_junk_and_real_acceptance_criteria() {
|
fn tool_create_refactor_accepts_mixed_junk_and_real_acceptance_criteria() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
let result = tool_create_refactor(
|
let result = tool_create_refactor(
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_spike_creates_file() {
|
fn tool_create_spike_creates_file() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
|
|
||||||
@@ -147,6 +148,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_spike_creates_file_without_description() {
|
fn tool_create_spike_creates_file_without_description() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
|
|
||||||
@@ -202,6 +204,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_spike_accepts_single_criterion() {
|
fn tool_create_spike_accepts_single_criterion() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
let result = tool_create_spike(
|
let result = tool_create_spike(
|
||||||
@@ -233,6 +236,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_spike_accepts_mixed_junk_and_real_acceptance_criteria() {
|
fn tool_create_spike_accepts_mixed_junk_and_real_acceptance_criteria() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
let result = tool_create_spike(
|
let result = tool_create_spike(
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_story_accepts_single_criterion() {
|
fn tool_create_story_accepts_single_criterion() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
let result = tool_create_story(
|
let result = tool_create_story(
|
||||||
@@ -283,6 +284,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_story_accepts_mixed_junk_and_real_acceptance_criteria() {
|
fn tool_create_story_accepts_mixed_junk_and_real_acceptance_criteria() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
let result = tool_create_story(
|
let result = tool_create_story(
|
||||||
@@ -299,6 +301,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_story_description_is_written_to_file() {
|
fn tool_create_story_description_is_written_to_file() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
|
|
||||||
@@ -368,6 +371,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_story_html_sanitised_in_name() {
|
fn tool_create_story_html_sanitised_in_name() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
// HTML in name is sanitised (not rejected)
|
// HTML in name is sanitised (not rejected)
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_story_and_list_upcoming() {
|
fn tool_create_story_and_list_upcoming() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
// No git repo needed: spike 61 — create_story just writes the file;
|
// No git repo needed: spike 61 — create_story just writes the file;
|
||||||
// the filesystem watcher handles the commit asynchronously.
|
// the filesystem watcher handles the commit asynchronously.
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
//! HTTP server — module declarations for all REST, MCP, WebSocket, and SSE endpoints.
|
//! HTTP server — module declarations for all REST, MCP, WebSocket, and SSE endpoints.
|
||||||
/// Server-sent event stream for real-time agent output.
|
/// Server-sent event stream for real-time agent output.
|
||||||
pub mod agents_sse;
|
pub mod agents_sse;
|
||||||
/// Static asset serving (embedded frontend files).
|
|
||||||
pub mod assets;
|
|
||||||
/// Shared application context threaded through handlers.
|
/// Shared application context threaded through handlers.
|
||||||
pub mod context;
|
pub mod context;
|
||||||
/// Server-sent event stream for pipeline/watcher events.
|
/// Server-sent event stream for pipeline/watcher events.
|
||||||
@@ -100,10 +98,7 @@ pub fn build_routes(
|
|||||||
get(oauth::oauth_callback).data(oauth_state.clone()),
|
get(oauth::oauth_callback).data(oauth_state.clone()),
|
||||||
)
|
)
|
||||||
.at("/oauth/status", get(oauth::oauth_status))
|
.at("/oauth/status", get(oauth::oauth_status))
|
||||||
.at("/debug/crdt", get(debug_crdt_handler))
|
.at("/debug/crdt", get(debug_crdt_handler));
|
||||||
.at("/assets/*path", get(assets::embedded_asset))
|
|
||||||
.at("/", get(assets::embedded_index))
|
|
||||||
.at("/*path", get(assets::embedded_file));
|
|
||||||
|
|
||||||
if let Some(buf) = event_buffer {
|
if let Some(buf) = event_buffer {
|
||||||
route = route.at("/api/events", get(events::events_handler).data(buf));
|
route = route.at("/api/events", get(events::events_handler).data(buf));
|
||||||
@@ -203,7 +198,7 @@ pub fn debug_crdt_handler(req: &poem::Request) -> poem::Response {
|
|||||||
"total_ops_in_list": dump.total_ops_in_list,
|
"total_ops_in_list": dump.total_ops_in_list,
|
||||||
"max_seq_in_list": dump.max_seq_in_list,
|
"max_seq_in_list": dump.max_seq_in_list,
|
||||||
"persisted_ops_count": dump.persisted_ops_count,
|
"persisted_ops_count": dump.persisted_ops_count,
|
||||||
"pending_persist_ops_count": null,
|
"pending_persist_ops_count": dump.pending_persist_ops_count,
|
||||||
},
|
},
|
||||||
"items": items,
|
"items": items,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use super::spike::create_spike_file;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
fn setup_git_repo(root: &std::path::Path) {
|
fn setup_git_repo(root: &std::path::Path) {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
std::process::Command::new("git")
|
std::process::Command::new("git")
|
||||||
.args(["init"])
|
.args(["init"])
|
||||||
.current_dir(root)
|
.current_dir(root)
|
||||||
@@ -166,6 +167,7 @@ fn extract_bug_name_from_content_parses_heading() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_bug_file_writes_correct_content() {
|
fn create_bug_file_writes_correct_content() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
setup_git_repo(tmp.path());
|
setup_git_repo(tmp.path());
|
||||||
|
|
||||||
@@ -257,6 +259,7 @@ fn create_bug_file_rejects_empty_acceptance_criteria() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_spike_file_writes_correct_content() {
|
fn create_spike_file_writes_correct_content() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
let spike_id = create_spike_file(
|
let spike_id = create_spike_file(
|
||||||
@@ -294,6 +297,7 @@ fn create_spike_file_writes_correct_content() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_spike_file_uses_description_when_provided() {
|
fn create_spike_file_uses_description_when_provided() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let description = "What is the best approach for watching filesystem events?";
|
let description = "What is the best approach for watching filesystem events?";
|
||||||
|
|
||||||
@@ -319,6 +323,7 @@ fn create_spike_file_uses_description_when_provided() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_spike_file_uses_placeholder_when_no_description() {
|
fn create_spike_file_uses_placeholder_when_no_description() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let spike_id = create_spike_file(
|
let spike_id = create_spike_file(
|
||||||
tmp.path(),
|
tmp.path(),
|
||||||
@@ -350,6 +355,7 @@ fn create_spike_file_rejects_empty_name() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_spike_file_with_special_chars_in_name_produces_valid_yaml() {
|
fn create_spike_file_with_special_chars_in_name_produces_valid_yaml() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let name = "Spike: compare \"fast\" vs slow encoders";
|
let name = "Spike: compare \"fast\" vs slow encoders";
|
||||||
let result = create_spike_file(
|
let result = create_spike_file(
|
||||||
@@ -423,6 +429,7 @@ fn create_bug_file_with_depends_on_persists_to_crdt() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_bug_file_without_depends_on_omits_field() {
|
fn create_bug_file_without_depends_on_omits_field() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
setup_git_repo(tmp.path());
|
setup_git_repo(tmp.path());
|
||||||
|
|
||||||
@@ -474,6 +481,7 @@ fn create_refactor_file_with_depends_on_persists_to_crdt() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_refactor_file_without_depends_on_omits_field() {
|
fn create_refactor_file_without_depends_on_omits_field() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
setup_git_repo(tmp.path());
|
setup_git_repo(tmp.path());
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,14 @@ where
|
|||||||
let received_at = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
let received_at = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
inject_received_at(&mut messages, &received_at);
|
inject_received_at(&mut messages, &received_at);
|
||||||
|
|
||||||
|
// Assemble CRDT pipeline-transition events once per turn and advance the
|
||||||
|
// high-water mark. Uses the Claude Code session_id when available so the
|
||||||
|
// same event stream key is used for resumable sessions; falls back to
|
||||||
|
// "web-ui" for Anthropic/Ollama turns which have no persistent session.
|
||||||
|
let event_ctx = crate::llm_session::assemble_prompt_context(
|
||||||
|
config.session_id.as_deref().unwrap_or("web-ui"),
|
||||||
|
);
|
||||||
|
|
||||||
let _ = state.cancel_tx.send(false);
|
let _ = state.cancel_tx.send(false);
|
||||||
let mut cancel_rx = state.cancel_rx.clone();
|
let mut cancel_rx = state.cancel_rx.clone();
|
||||||
cancel_rx.borrow_and_update();
|
cancel_rx.borrow_and_update();
|
||||||
@@ -177,10 +185,14 @@ where
|
|||||||
// would be lost because Claude Code only receives a single prompt
|
// would be lost because Claude Code only receives a single prompt
|
||||||
// string. In that case, prepend the conversation history so the LLM
|
// string. In that case, prepend the conversation history so the LLM
|
||||||
// retains full context even though the session cannot be resumed.
|
// retains full context even though the session cannot be resumed.
|
||||||
|
// In both cases, prepend any pending CRDT pipeline-transition events.
|
||||||
let user_message = if config.session_id.is_some() {
|
let user_message = if config.session_id.is_some() {
|
||||||
latest_user_content
|
format!("{event_ctx}{latest_user_content}")
|
||||||
} else {
|
} else {
|
||||||
build_claude_code_context_prompt(&messages, &latest_user_content)
|
format!(
|
||||||
|
"{event_ctx}{}",
|
||||||
|
build_claude_code_context_prompt(&messages, &latest_user_content)
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let project_root = state
|
let project_root = state
|
||||||
@@ -233,6 +245,14 @@ where
|
|||||||
&[]
|
&[]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Prepend pipeline-transition events to the last user message so Anthropic
|
||||||
|
// and Ollama providers also receive the CRDT context on every turn.
|
||||||
|
if !event_ctx.is_empty()
|
||||||
|
&& let Some(msg) = messages.iter_mut().rev().find(|m| m.role == Role::User)
|
||||||
|
{
|
||||||
|
msg.content = format!("{event_ctx}{}", msg.content);
|
||||||
|
}
|
||||||
|
|
||||||
let mut current_history = messages.clone();
|
let mut current_history = messages.clone();
|
||||||
|
|
||||||
// Build the system prompt — append onboarding instructions when the
|
// Build the system prompt — append onboarding instructions when the
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
//! LLM session management — CRDT-backed context assembly for bot prompts.
|
||||||
|
//!
|
||||||
|
//! The central export is [`assemble_prompt_context`], which reads new pipeline
|
||||||
|
//! transition events from the CRDT event log past the session's stored high-water
|
||||||
|
//! marks, wraps them in a `<system-reminder>` block for injection at the head of
|
||||||
|
//! the next LLM prompt, and atomically advances the marks so a mid-turn crash
|
||||||
|
//! cannot double-inject the same events.
|
||||||
|
|
||||||
|
/// Assemble a `<system-reminder>` block containing new pipeline-transition events
|
||||||
|
/// for `session_id` and atomically advance the high-water marks.
|
||||||
|
///
|
||||||
|
/// Reads events from the local sled's CRDT event log that have not yet been
|
||||||
|
/// injected into this session (tracked via per-sled high-water marks stored in
|
||||||
|
/// the `LlmSessionCrdt` entity). Returns an empty string when there are no new
|
||||||
|
/// events or the CRDT is not yet initialised.
|
||||||
|
pub fn assemble_prompt_context(session_id: &str) -> String {
|
||||||
|
let lines = crate::crdt_state::assemble_and_advance_session(session_id);
|
||||||
|
let event_count = lines.len();
|
||||||
|
crate::slog!(
|
||||||
|
"[llm-session] assemble_prompt_context session={session_id} new_events={event_count}"
|
||||||
|
);
|
||||||
|
if lines.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let body = lines.join("\n");
|
||||||
|
format!("<system-reminder>\n{body}\n</system-reminder>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::pipeline_state::{PipelineEvent, PlanState, Stage, StoryId, TransitionFired};
|
||||||
|
|
||||||
|
fn make_fired(story_id: &str) -> TransitionFired {
|
||||||
|
TransitionFired {
|
||||||
|
story_id: StoryId(story_id.to_string()),
|
||||||
|
before: Stage::Backlog,
|
||||||
|
after: Stage::Coding {
|
||||||
|
claim: None,
|
||||||
|
plan: PlanState::Missing,
|
||||||
|
retries: 0,
|
||||||
|
},
|
||||||
|
event: PipelineEvent::DepsMet,
|
||||||
|
at: chrono::Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC 4: fire a `TransitionFired` event, call `assemble_prompt_context` via
|
||||||
|
/// the session helper, assert the rendered output contains the event details.
|
||||||
|
/// A second call must return empty because the high-water was advanced.
|
||||||
|
#[test]
|
||||||
|
fn assemble_prompt_context_includes_new_events_and_advances_high_water() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
|
|
||||||
|
// Log two transition events for different stories.
|
||||||
|
crate::event_log::log_transition_event(&make_fired("42_story_foo"));
|
||||||
|
crate::event_log::log_transition_event(&make_fired("99_story_bar"));
|
||||||
|
|
||||||
|
let ctx = assemble_prompt_context("room-test-1");
|
||||||
|
|
||||||
|
// Must be wrapped in a <system-reminder> block.
|
||||||
|
assert!(
|
||||||
|
ctx.starts_with("<system-reminder>\n"),
|
||||||
|
"missing opening tag; got: {ctx}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ctx.ends_with("</system-reminder>\n"),
|
||||||
|
"missing closing tag; got: {ctx}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both story IDs must appear in the rendered block.
|
||||||
|
assert!(
|
||||||
|
ctx.contains("42_story_foo"),
|
||||||
|
"first story missing; got: {ctx}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ctx.contains("99_story_bar"),
|
||||||
|
"second story missing; got: {ctx}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The pipeline_event label must appear.
|
||||||
|
assert!(ctx.contains("DepsMet"), "event label missing; got: {ctx}");
|
||||||
|
|
||||||
|
// Second call: high-water was advanced — no new events, returns empty.
|
||||||
|
let ctx2 = assemble_prompt_context("room-test-1");
|
||||||
|
assert!(
|
||||||
|
ctx2.is_empty(),
|
||||||
|
"second call must be empty after high-water advance; got: {ctx2}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Different session IDs have independent high-water marks.
|
||||||
|
#[test]
|
||||||
|
fn assemble_prompt_context_sessions_are_independent() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
|
|
||||||
|
crate::event_log::log_transition_event(&make_fired("77_story_x"));
|
||||||
|
|
||||||
|
// Session A sees the event.
|
||||||
|
let ctx_a = assemble_prompt_context("room-session-a");
|
||||||
|
assert!(
|
||||||
|
ctx_a.contains("77_story_x"),
|
||||||
|
"session A must see the event; got: {ctx_a}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Session B also sees it (independent high-water).
|
||||||
|
let ctx_b = assemble_prompt_context("room-session-b");
|
||||||
|
assert!(
|
||||||
|
ctx_b.contains("77_story_x"),
|
||||||
|
"session B must see the event; got: {ctx_b}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second call on A: already advanced.
|
||||||
|
let ctx_a2 = assemble_prompt_context("room-session-a");
|
||||||
|
assert!(
|
||||||
|
ctx_a2.is_empty(),
|
||||||
|
"session A second call must be empty; got: {ctx_a2}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// But B's second call is also empty.
|
||||||
|
let ctx_b2 = assemble_prompt_context("room-session-b");
|
||||||
|
assert!(
|
||||||
|
ctx_b2.is_empty(),
|
||||||
|
"session B second call must be empty; got: {ctx_b2}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events logged after a prior advance are included in the next call.
|
||||||
|
#[test]
|
||||||
|
fn assemble_prompt_context_includes_events_logged_after_advance() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
|
|
||||||
|
crate::event_log::log_transition_event(&make_fired("10_story_old"));
|
||||||
|
// First call drains and advances.
|
||||||
|
let ctx1 = assemble_prompt_context("room-incremental");
|
||||||
|
assert!(ctx1.contains("10_story_old"), "got: {ctx1}");
|
||||||
|
|
||||||
|
// Log a new event after the advance.
|
||||||
|
crate::event_log::log_transition_event(&make_fired("20_story_new"));
|
||||||
|
let ctx2 = assemble_prompt_context("room-incremental");
|
||||||
|
assert!(
|
||||||
|
ctx2.contains("20_story_new"),
|
||||||
|
"new event must appear; got: {ctx2}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!ctx2.contains("10_story_old"),
|
||||||
|
"old event must not reappear; got: {ctx2}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `assemble_prompt_context` returns empty string when there are no events.
|
||||||
|
#[test]
|
||||||
|
fn assemble_prompt_context_empty_when_no_events() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
|
let ctx = assemble_prompt_context("room-empty");
|
||||||
|
assert!(ctx.is_empty(), "must be empty with no events; got: {ctx}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC 4: two sleds each fire one transition; a session scoped `All` sees
|
||||||
|
/// both events; a session scoped `Sleds([sled-A])` sees only sled-A's event.
|
||||||
|
///
|
||||||
|
/// Simulates the gateway aggregate view by directly calling
|
||||||
|
/// `append_event_log_entry` with two distinct sled IDs, then asserting
|
||||||
|
/// scope-filtered assembly behaves correctly.
|
||||||
|
#[test]
|
||||||
|
fn scope_filter_all_sees_both_sleds_filter_sees_one() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
|
|
||||||
|
let sled_a = "aaaaaaaaaaaaaaaa";
|
||||||
|
let sled_b = "bbbbbbbbbbbbbbbb";
|
||||||
|
|
||||||
|
// Each sled fires one pipeline transition.
|
||||||
|
crate::crdt_state::append_event_log_entry(
|
||||||
|
sled_a,
|
||||||
|
1_000_000.0,
|
||||||
|
"10_story_alpha",
|
||||||
|
"1_backlog",
|
||||||
|
"2_current",
|
||||||
|
"DepsMet",
|
||||||
|
);
|
||||||
|
crate::crdt_state::append_event_log_entry(
|
||||||
|
sled_b,
|
||||||
|
1_000_001.0,
|
||||||
|
"20_story_beta",
|
||||||
|
"2_current",
|
||||||
|
"3_qa",
|
||||||
|
"AgentCompleted",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set up a session scoped to ALL sleds.
|
||||||
|
crate::crdt_state::write_llm_session("room-scope-all", "Timmy", "all");
|
||||||
|
// Set up a session scoped to sled-A only.
|
||||||
|
let sled_a_scope = format!("sleds:{sled_a}");
|
||||||
|
crate::crdt_state::write_llm_session("room-scope-sled-a", "Sally", &sled_a_scope);
|
||||||
|
|
||||||
|
// All-scope session: both events must appear.
|
||||||
|
let ctx_all = assemble_prompt_context("room-scope-all");
|
||||||
|
assert!(
|
||||||
|
ctx_all.contains("10_story_alpha"),
|
||||||
|
"All scope must contain sled-A event; got: {ctx_all}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ctx_all.contains("20_story_beta"),
|
||||||
|
"All scope must contain sled-B event; got: {ctx_all}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sled-A-only session: only sled-A's event visible.
|
||||||
|
let ctx_a = assemble_prompt_context("room-scope-sled-a");
|
||||||
|
assert!(
|
||||||
|
ctx_a.contains("10_story_alpha"),
|
||||||
|
"Sleds filter must contain sled-A event; got: {ctx_a}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!ctx_a.contains("20_story_beta"),
|
||||||
|
"Sleds filter must NOT contain sled-B event; got: {ctx_a}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second call on both sessions: nothing new (high-water advanced).
|
||||||
|
let ctx_all2 = assemble_prompt_context("room-scope-all");
|
||||||
|
assert!(
|
||||||
|
ctx_all2.is_empty(),
|
||||||
|
"All scope second call must be empty; got: {ctx_all2}"
|
||||||
|
);
|
||||||
|
let ctx_a2 = assemble_prompt_context("room-scope-sled-a");
|
||||||
|
assert!(
|
||||||
|
ctx_a2.is_empty(),
|
||||||
|
"Sleds filter second call must be empty; got: {ctx_a2}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newly-added sled events appear in an All-scope session without
|
||||||
|
/// restarting (AC 5 runtime pickup).
|
||||||
|
#[test]
|
||||||
|
fn scope_filter_all_picks_up_new_sled_at_runtime() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
|
|
||||||
|
let sled_a = "cccccccccccccccc";
|
||||||
|
let sled_new = "dddddddddddddddd";
|
||||||
|
|
||||||
|
// Only sled-A exists initially.
|
||||||
|
crate::crdt_state::append_event_log_entry(
|
||||||
|
sled_a,
|
||||||
|
2_000_000.0,
|
||||||
|
"30_story_first",
|
||||||
|
"1_backlog",
|
||||||
|
"2_current",
|
||||||
|
"DepsMet",
|
||||||
|
);
|
||||||
|
crate::crdt_state::write_llm_session("room-runtime-pickup", "Timmy", "all");
|
||||||
|
|
||||||
|
let ctx1 = assemble_prompt_context("room-runtime-pickup");
|
||||||
|
assert!(
|
||||||
|
ctx1.contains("30_story_first"),
|
||||||
|
"first event must appear; got: {ctx1}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// sled_new is adopted at runtime — its event is appended without restart.
|
||||||
|
crate::crdt_state::append_event_log_entry(
|
||||||
|
sled_new,
|
||||||
|
2_000_001.0,
|
||||||
|
"40_story_second",
|
||||||
|
"2_current",
|
||||||
|
"3_qa",
|
||||||
|
"AgentCompleted",
|
||||||
|
);
|
||||||
|
|
||||||
|
let ctx2 = assemble_prompt_context("room-runtime-pickup");
|
||||||
|
assert!(
|
||||||
|
ctx2.contains("40_story_second"),
|
||||||
|
"newly adopted sled event must appear; got: {ctx2}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!ctx2.contains("30_story_first"),
|
||||||
|
"old event must not reappear; got: {ctx2}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -20,12 +20,16 @@ pub mod crdt_sync;
|
|||||||
/// CRDT wire format — on-wire message types for the crdt-sync protocol.
|
/// CRDT wire format — on-wire message types for the crdt-sync protocol.
|
||||||
pub mod crdt_wire;
|
pub mod crdt_wire;
|
||||||
mod db;
|
mod db;
|
||||||
|
/// Event log — CRDT-persisted append-only log of every pipeline stage transition.
|
||||||
|
pub(crate) mod event_log;
|
||||||
/// Gateway mode — multi-project reverse proxy that fronts multiple project containers.
|
/// Gateway mode — multi-project reverse proxy that fronts multiple project containers.
|
||||||
pub mod gateway;
|
pub mod gateway;
|
||||||
mod gateway_relay;
|
mod gateway_relay;
|
||||||
mod http;
|
mod http;
|
||||||
mod io;
|
mod io;
|
||||||
mod llm;
|
mod llm;
|
||||||
|
/// LLM session management — CRDT-backed context assembly for bot prompts.
|
||||||
|
pub(crate) mod llm_session;
|
||||||
/// Log buffer — in-memory ring buffer for recent server-side log lines.
|
/// Log buffer — in-memory ring buffer for recent server-side log lines.
|
||||||
pub mod log_buffer;
|
pub mod log_buffer;
|
||||||
/// Mesh — peer discovery and multi-hop CRDT replication over WebSocket.
|
/// Mesh — peer discovery and multi-hop CRDT replication over WebSocket.
|
||||||
@@ -364,8 +368,8 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
Arc::clone(&services),
|
Arc::clone(&services),
|
||||||
matrix_shutdown_rx,
|
matrix_shutdown_rx,
|
||||||
None,
|
None,
|
||||||
vec![],
|
|
||||||
std::collections::BTreeMap::new(),
|
std::collections::BTreeMap::new(),
|
||||||
|
None,
|
||||||
timer_store_for_bot,
|
timer_store_for_bot,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -189,6 +189,11 @@ pub async fn rebuild_and_restart(
|
|||||||
n.notify(ShutdownReason::Rebuild).await;
|
n.notify(ShutdownReason::Rebuild).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5b. Drain the persistence channel so no queued ops are lost when exec()
|
||||||
|
// replaces this process. Times out after 5 s with a logged warning
|
||||||
|
// naming the queue depth so any regression is visible in logs.
|
||||||
|
crate::crdt_state::flush_persistence(std::time::Duration::from_secs(5)).await;
|
||||||
|
|
||||||
// 6. Re-exec with the new binary.
|
// 6. Re-exec with the new binary.
|
||||||
// Use the cargo output path rather than current_exe() so that rebuilds
|
// Use the cargo output path rather than current_exe() so that rebuilds
|
||||||
// inside Docker work correctly — the running binary may be installed at
|
// inside Docker work correctly — the running binary may be installed at
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ pub struct ProjectEntry {
|
|||||||
/// `[sled_tokens]` table for projects that set this field.
|
/// `[sled_tokens]` table for projects that set this field.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub auth_token: Option<String>,
|
pub auth_token: Option<String>,
|
||||||
|
/// Host-local port for SSH access into the project container.
|
||||||
|
///
|
||||||
|
/// Set by `new project` (story 1108). The container's SSH server is bound
|
||||||
|
/// to `127.0.0.1:<ssh_port>:22` so the user can connect with
|
||||||
|
/// `ssh huskies@127.0.0.1 -p <ssh_port> -i ~/.huskies/<name>/id_ed25519`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ssh_port: Option<u16>,
|
||||||
|
/// Absolute host path of the project directory (workspace bind-mount source).
|
||||||
|
///
|
||||||
|
/// Set by `new project` (story 1114). When `--path` is supplied the value
|
||||||
|
/// differs from the default `~/huskies/<name>/`. Stored here so that later
|
||||||
|
/// commands can route to the correct directory without re-deriving it.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub host_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProjectEntry {
|
impl ProjectEntry {
|
||||||
@@ -36,6 +50,8 @@ impl ProjectEntry {
|
|||||||
Self {
|
Self {
|
||||||
url: Some(url.into()),
|
url: Some(url.into()),
|
||||||
auth_token: None,
|
auth_token: None,
|
||||||
|
ssh_port: None,
|
||||||
|
host_path: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +221,8 @@ auth_token = "secret"
|
|||||||
ProjectEntry {
|
ProjectEntry {
|
||||||
url: None,
|
url: None,
|
||||||
auth_token: Some("secret".into()),
|
auth_token: Some("secret".into()),
|
||||||
|
ssh_port: None,
|
||||||
|
host_path: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig {
|
let config = GatewayConfig {
|
||||||
@@ -238,6 +256,8 @@ auth_token = "secret"
|
|||||||
ProjectEntry {
|
ProjectEntry {
|
||||||
url: None,
|
url: None,
|
||||||
auth_token: Some("tok".into()),
|
auth_token: Some("tok".into()),
|
||||||
|
ssh_port: None,
|
||||||
|
host_path: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert_eq!(validate_project_exists(&projects, "ws").unwrap(), "");
|
assert_eq!(validate_project_exists(&projects, "ws").unwrap(), "");
|
||||||
@@ -256,6 +276,8 @@ auth_token = "secret"
|
|||||||
let e = ProjectEntry {
|
let e = ProjectEntry {
|
||||||
url: None,
|
url: None,
|
||||||
auth_token: Some("tok".into()),
|
auth_token: Some("tok".into()),
|
||||||
|
ssh_port: None,
|
||||||
|
host_path: None,
|
||||||
};
|
};
|
||||||
assert!(!e.has_url());
|
assert!(!e.has_url());
|
||||||
}
|
}
|
||||||
@@ -297,6 +319,8 @@ auth_token = "secret"
|
|||||||
let entry = ProjectEntry {
|
let entry = ProjectEntry {
|
||||||
url: Some("http://a:3001".into()),
|
url: Some("http://a:3001".into()),
|
||||||
auth_token: Some("mysecret".into()),
|
auth_token: Some("mysecret".into()),
|
||||||
|
ssh_port: None,
|
||||||
|
host_path: None,
|
||||||
};
|
};
|
||||||
let mut projects = BTreeMap::new();
|
let mut projects = BTreeMap::new();
|
||||||
projects.insert("myproj".into(), entry);
|
projects.insert("myproj".into(), entry);
|
||||||
@@ -315,4 +339,44 @@ auth_token = "secret"
|
|||||||
Some("mysecret")
|
Some("mysecret")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_project_entry_with_ssh_port() {
|
||||||
|
let entry = ProjectEntry {
|
||||||
|
url: Some("http://127.0.0.1:3101".into()),
|
||||||
|
auth_token: None,
|
||||||
|
ssh_port: Some(2201),
|
||||||
|
host_path: None,
|
||||||
|
};
|
||||||
|
let mut projects = BTreeMap::new();
|
||||||
|
projects.insert("myproj".into(), entry);
|
||||||
|
let config = GatewayConfig {
|
||||||
|
projects,
|
||||||
|
sled_tokens: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||||
|
let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
|
||||||
|
assert_eq!(parsed.projects["myproj"].ssh_port, Some(2201));
|
||||||
|
// ssh_port must appear in the serialised TOML.
|
||||||
|
assert!(
|
||||||
|
toml_str.contains("ssh_port = 2201"),
|
||||||
|
"ssh_port missing from TOML: {toml_str}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssh_port_none_is_omitted_from_toml() {
|
||||||
|
let entry = ProjectEntry::with_url("http://127.0.0.1:3101");
|
||||||
|
let mut projects = BTreeMap::new();
|
||||||
|
projects.insert("p".into(), entry);
|
||||||
|
let config = GatewayConfig {
|
||||||
|
projects,
|
||||||
|
sled_tokens: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||||
|
assert!(
|
||||||
|
!toml_str.contains("ssh_port"),
|
||||||
|
"ssh_port should be omitted when None: {toml_str}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -500,11 +500,12 @@ pub type ActiveProject = std::sync::Arc<tokio::sync::RwLock<String>>;
|
|||||||
/// Returns `(abort_handle, shutdown_tx)`. The caller **must** hold
|
/// Returns `(abort_handle, shutdown_tx)`. The caller **must** hold
|
||||||
/// `shutdown_tx` for the bot's lifetime and send `Some(ShutdownReason)` on it
|
/// `shutdown_tx` for the bot's lifetime and send `Some(ShutdownReason)` on it
|
||||||
/// before process exit so the bot can announce "going offline" to its rooms.
|
/// before process exit so the bot can announce "going offline" to its rooms.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn spawn_gateway_bot(
|
pub fn spawn_gateway_bot(
|
||||||
config_dir: &Path,
|
config_dir: &Path,
|
||||||
active_project: ActiveProject,
|
active_project: ActiveProject,
|
||||||
gateway_projects: Vec<String>,
|
|
||||||
gateway_project_urls: BTreeMap<String, String>,
|
gateway_project_urls: BTreeMap<String, String>,
|
||||||
|
gateway_projects_store: std::sync::Arc<tokio::sync::RwLock<BTreeMap<String, ProjectEntry>>>,
|
||||||
port: u16,
|
port: u16,
|
||||||
gateway_event_tx: Option<tokio::sync::broadcast::Sender<super::GatewayStatusEvent>>,
|
gateway_event_tx: Option<tokio::sync::broadcast::Sender<super::GatewayStatusEvent>>,
|
||||||
perm_rx: std::sync::Arc<
|
perm_rx: std::sync::Arc<
|
||||||
@@ -576,8 +577,8 @@ pub fn spawn_gateway_bot(
|
|||||||
services,
|
services,
|
||||||
shutdown_rx,
|
shutdown_rx,
|
||||||
Some(active_project),
|
Some(active_project),
|
||||||
gateway_projects,
|
|
||||||
gateway_project_urls,
|
gateway_project_urls,
|
||||||
|
Some(gateway_projects_store),
|
||||||
timer_store,
|
timer_store,
|
||||||
gateway_event_rx,
|
gateway_event_rx,
|
||||||
);
|
);
|
||||||
@@ -602,11 +603,13 @@ mod tests {
|
|||||||
let (_perm_tx, perm_rx) =
|
let (_perm_tx, perm_rx) =
|
||||||
tokio::sync::mpsc::unbounded_channel::<crate::http::context::PermissionForward>();
|
tokio::sync::mpsc::unbounded_channel::<crate::http::context::PermissionForward>();
|
||||||
let perm_rx = std::sync::Arc::new(tokio::sync::Mutex::new(perm_rx));
|
let perm_rx = std::sync::Arc::new(tokio::sync::Mutex::new(perm_rx));
|
||||||
|
let projects_store =
|
||||||
|
std::sync::Arc::new(tokio::sync::RwLock::new(std::collections::BTreeMap::new()));
|
||||||
let (handle, shutdown_tx) = spawn_gateway_bot(
|
let (handle, shutdown_tx) = spawn_gateway_bot(
|
||||||
tmp.path(),
|
tmp.path(),
|
||||||
active,
|
active,
|
||||||
vec!["proj".to_string()],
|
|
||||||
std::collections::BTreeMap::new(),
|
std::collections::BTreeMap::new(),
|
||||||
|
projects_store,
|
||||||
3001,
|
3001,
|
||||||
Some(event_tx),
|
Some(event_tx),
|
||||||
perm_rx,
|
perm_rx,
|
||||||
|
|||||||
@@ -613,6 +613,12 @@ pub async fn init_project(
|
|||||||
|
|
||||||
/// Broadcast a status event received from a project node to all local subscribers.
|
/// Broadcast a status event received from a project node to all local subscribers.
|
||||||
///
|
///
|
||||||
|
/// For [`crate::service::events::StoredEvent::StageTransition`] events the
|
||||||
|
/// transition is also appended to the gateway's CRDT event log using the
|
||||||
|
/// project name as the `sled_id`. This builds the live tail-merged aggregate
|
||||||
|
/// view that [`crate::crdt_state::assemble_and_advance_session`] reads when a
|
||||||
|
/// session's [`crate::crdt_state::ScopeFilter`] is set to `All`.
|
||||||
|
///
|
||||||
/// Returns the number of active receivers that received the event.
|
/// Returns the number of active receivers that received the event.
|
||||||
/// A return value of zero means no subscribers are currently connected.
|
/// A return value of zero means no subscribers are currently connected.
|
||||||
pub fn broadcast_status_event(
|
pub fn broadcast_status_event(
|
||||||
@@ -620,6 +626,26 @@ pub fn broadcast_status_event(
|
|||||||
project: String,
|
project: String,
|
||||||
event: crate::service::events::StoredEvent,
|
event: crate::service::events::StoredEvent,
|
||||||
) -> usize {
|
) -> usize {
|
||||||
|
// Append StageTransition events to the gateway CRDT so the session
|
||||||
|
// assembler can deliver a unified cross-sled event stream.
|
||||||
|
if let crate::service::events::StoredEvent::StageTransition {
|
||||||
|
ref story_id,
|
||||||
|
ref from_stage,
|
||||||
|
ref to_stage,
|
||||||
|
timestamp_ms,
|
||||||
|
..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
let timestamp_secs = timestamp_ms as f64 / 1_000.0_f64;
|
||||||
|
crate::crdt_state::append_event_log_entry(
|
||||||
|
&project,
|
||||||
|
timestamp_secs,
|
||||||
|
story_id,
|
||||||
|
from_stage,
|
||||||
|
to_stage,
|
||||||
|
"StageTransition",
|
||||||
|
);
|
||||||
|
}
|
||||||
let msg = GatewayStatusEvent { project, event };
|
let msg = GatewayStatusEvent { project, event };
|
||||||
state.event_tx.send(msg).unwrap_or(0)
|
state.event_tx.send(msg).unwrap_or(0)
|
||||||
}
|
}
|
||||||
@@ -647,7 +673,6 @@ pub async fn save_bot_config_and_restart(state: &GatewayState, content: &str) ->
|
|||||||
if let Some(h) = handle.take() {
|
if let Some(h) = handle.take() {
|
||||||
h.abort();
|
h.abort();
|
||||||
}
|
}
|
||||||
let gateway_projects: Vec<String> = state.projects.read().await.keys().cloned().collect();
|
|
||||||
let gateway_project_urls: BTreeMap<String, String> = state
|
let gateway_project_urls: BTreeMap<String, String> = state
|
||||||
.projects
|
.projects
|
||||||
.read()
|
.read()
|
||||||
@@ -659,8 +684,8 @@ pub async fn save_bot_config_and_restart(state: &GatewayState, content: &str) ->
|
|||||||
let (new_handle, new_shutdown_tx) = io::spawn_gateway_bot(
|
let (new_handle, new_shutdown_tx) = io::spawn_gateway_bot(
|
||||||
&state.config_dir,
|
&state.config_dir,
|
||||||
Arc::clone(&state.active_project),
|
Arc::clone(&state.active_project),
|
||||||
gateway_projects,
|
|
||||||
gateway_project_urls,
|
gateway_project_urls,
|
||||||
|
Arc::clone(&state.projects),
|
||||||
state.port,
|
state.port,
|
||||||
Some(state.event_tx.clone()),
|
Some(state.event_tx.clone()),
|
||||||
Arc::clone(&state.perm_rx),
|
Arc::clone(&state.perm_rx),
|
||||||
@@ -746,6 +771,8 @@ mod tests {
|
|||||||
ProjectEntry {
|
ProjectEntry {
|
||||||
url: None,
|
url: None,
|
||||||
auth_token: Some("tok".into()),
|
auth_token: Some("tok".into()),
|
||||||
|
ssh_port: None,
|
||||||
|
host_path: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig {
|
let config = GatewayConfig {
|
||||||
@@ -884,6 +911,8 @@ mod tests {
|
|||||||
ProjectEntry {
|
ProjectEntry {
|
||||||
url: Some("http://huskies:3001".into()),
|
url: Some("http://huskies:3001".into()),
|
||||||
auth_token: Some("secret-token".into()),
|
auth_token: Some("secret-token".into()),
|
||||||
|
ssh_port: None,
|
||||||
|
host_path: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let config = GatewayConfig {
|
let config = GatewayConfig {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! with `[project-name]` prefixes. The actual I/O (HTTP polling, spawning
|
//! with `[project-name]` prefixes. The actual I/O (HTTP polling, spawning
|
||||||
//! tasks, sending messages) lives in `io.rs`.
|
//! tasks, sending messages) lives in `io.rs`.
|
||||||
|
|
||||||
use crate::pipeline_state::{Stage, stage_label};
|
use crate::pipeline_state::Stage;
|
||||||
use crate::service::events::StoredEvent;
|
use crate::service::events::StoredEvent;
|
||||||
use crate::service::notifications::{
|
use crate::service::notifications::{
|
||||||
format_blocked_notification, format_error_notification, format_stage_notification,
|
format_blocked_notification, format_error_notification, format_stage_notification,
|
||||||
@@ -56,7 +56,9 @@ pub fn format_gateway_event(project_name: &str, event: &StoredEvent) -> (String,
|
|||||||
///
|
///
|
||||||
/// Produces a structured one-line entry with stable `key=value` fields, including
|
/// Produces a structured one-line entry with stable `key=value` fields, including
|
||||||
/// the project name, mirroring the sled-side `format_audit_entry` format.
|
/// the project name, mirroring the sled-side `format_audit_entry` format.
|
||||||
|
#[cfg(test)]
|
||||||
pub fn format_gateway_audit_line(project: &str, event: &StoredEvent) -> String {
|
pub fn format_gateway_audit_line(project: &str, event: &StoredEvent) -> String {
|
||||||
|
use crate::pipeline_state::stage_label;
|
||||||
let ts_ms = event.timestamp_ms();
|
let ts_ms = event.timestamp_ms();
|
||||||
let ts = chrono::DateTime::from_timestamp_millis(ts_ms as i64)
|
let ts = chrono::DateTime::from_timestamp_millis(ts_ms as i64)
|
||||||
.unwrap_or_else(chrono::Utc::now)
|
.unwrap_or_else(chrono::Utc::now)
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ pub(crate) fn spawn_event_bridges(
|
|||||||
// Audit log subscriber: write one structured line per pipeline transition.
|
// Audit log subscriber: write one structured line per pipeline transition.
|
||||||
crate::pipeline_state::spawn_audit_log_subscriber();
|
crate::pipeline_state::spawn_audit_log_subscriber();
|
||||||
|
|
||||||
|
// Event log subscriber: persist every transition to the CRDT event log so
|
||||||
|
// the history survives rebuild_and_restart and replicates across nodes.
|
||||||
|
crate::event_log::spawn_event_log_subscriber();
|
||||||
|
|
||||||
// CRDT → watcher bridge: translate CRDT stage-transition events into
|
// CRDT → watcher bridge: translate CRDT stage-transition events into
|
||||||
// WatcherEvent::WorkItem so downstream consumers (WebSocket, auto-assign)
|
// WatcherEvent::WorkItem so downstream consumers (WebSocket, auto-assign)
|
||||||
// see a uniform stream regardless of whether the event originated from the
|
// see a uniform stream regardless of whether the event originated from the
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>CLI Reference — Huskies Docs</title>
|
|
||||||
<meta name="description" content="Command-line reference for huskies, huskies init, and related subcommands.">
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;500;600;700;800&family=Karla:ital,wght@0,300;0,400;0,500;1,300;1,400&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="docs.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="shell">
|
|
||||||
<header class="reveal r1">
|
|
||||||
<a href="/" class="logo">huskies</a>
|
|
||||||
<nav>
|
|
||||||
<a href="/#how">How it works</a>
|
|
||||||
<a href="/#features">Features</a>
|
|
||||||
<a href="/docs/" class="active">Docs</a>
|
|
||||||
<a href="https://code.crashlabs.io/crashlabs/huskies">Source</a>
|
|
||||||
<a href="https://code.crashlabs.io/crashlabs/huskies/releases">Releases</a>
|
|
||||||
<a href="mailto:hello@huskies.dev" class="nav-cta">Get in touch</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="shell">
|
|
||||||
<div class="docs-layout">
|
|
||||||
<aside class="sidebar reveal r2">
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Getting started</div>
|
|
||||||
<nav>
|
|
||||||
<a href="/docs/">Overview</a>
|
|
||||||
<a href="quickstart.html">Quickstart</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Reference</div>
|
|
||||||
<nav>
|
|
||||||
<a href="configuration.html">Configuration</a>
|
|
||||||
<a href="commands.html">Bot commands</a>
|
|
||||||
<a href="cli.html" class="active">CLI</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Guides</div>
|
|
||||||
<nav>
|
|
||||||
<a href="pipeline.html">Pipeline stages</a>
|
|
||||||
<a href="transports.html">Chat transports</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="docs-main reveal r3">
|
|
||||||
<h1 class="page-title">CLI Reference</h1>
|
|
||||||
<p class="page-subtitle">Huskies ships as a single binary. Most interaction happens through the web UI or chat transports, but the CLI is used for initial setup and server control.</p>
|
|
||||||
|
|
||||||
<h2>huskies</h2>
|
|
||||||
<p>Start the huskies server.</p>
|
|
||||||
<pre><code>huskies [OPTIONS]</code></pre>
|
|
||||||
|
|
||||||
<h3>Options</h3>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Flag</th><th>Default</th><th>Description</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>--port <PORT></td><td>3000</td><td>HTTP port to listen on. Set the <code>HUSKIES_PORT</code> environment variable as an alternative.</td></tr>
|
|
||||||
<tr><td>--project <PATH></td><td>current dir</td><td>Path to the project directory. Huskies looks for <code>.huskies/</code> here.</td></tr>
|
|
||||||
<tr><td>--help</td><td>—</td><td>Print help and exit.</td></tr>
|
|
||||||
<tr><td>--version</td><td>—</td><td>Print version and exit.</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h3>Examples</h3>
|
|
||||||
<pre><code># Start on the default port
|
|
||||||
huskies
|
|
||||||
|
|
||||||
# Start on a custom port
|
|
||||||
huskies --port 3001
|
|
||||||
|
|
||||||
# Specify project directory explicitly
|
|
||||||
huskies --project /path/to/project --port 3000
|
|
||||||
|
|
||||||
# Using environment variable
|
|
||||||
HUSKIES_PORT=3002 huskies</code></pre>
|
|
||||||
|
|
||||||
<div class="note">
|
|
||||||
<strong>Multiple instances:</strong> Each worktree or project can run its own huskies instance on a different port. Use <code>HUSKIES_PORT</code> to avoid conflicts when running several instances simultaneously.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>huskies init</h2>
|
|
||||||
<p>Initialise a project directory for use with huskies. Creates the <code>.huskies/</code> directory structure, default configuration files, and <code>.mcp.json</code>.</p>
|
|
||||||
<pre><code>huskies init [OPTIONS]</code></pre>
|
|
||||||
|
|
||||||
<h3>Options</h3>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Flag</th><th>Default</th><th>Description</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>--port <PORT></td><td>3000</td><td>Port written into <code>.mcp.json</code> for MCP tool discovery.</td></tr>
|
|
||||||
<tr><td>--project <PATH></td><td>current dir</td><td>Directory to initialise. Must be a git repository.</td></tr>
|
|
||||||
<tr><td>--help</td><td>—</td><td>Print help and exit.</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h3>What it creates</h3>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Path</th><th>Description</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td><code>.huskies/project.toml</code></td><td>Project-wide settings (QA mode, agent limits, timezone, etc.).</td></tr>
|
|
||||||
<tr><td><code>.huskies/agents.toml</code></td><td>Agent definitions for coder, QA, and mergemaster roles.</td></tr>
|
|
||||||
<tr><td><code>.huskies/work/1_backlog/</code></td><td>Pipeline stage directories (1 through 6).</td></tr>
|
|
||||||
<tr><td><code>.huskies/specs/00_CONTEXT.md</code></td><td>Placeholder project context file for the setup wizard.</td></tr>
|
|
||||||
<tr><td><code>.huskies/specs/tech/STACK.md</code></td><td>Placeholder tech stack file for the setup wizard.</td></tr>
|
|
||||||
<tr><td><code>.mcp.json</code></td><td>MCP server config so Claude Code discovers huskies' tools automatically.</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="note">
|
|
||||||
<strong>Git required:</strong> The project directory must be a git repository. Run <code>git init</code> first if needed.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>huskies agent</h2>
|
|
||||||
<p>Spawn a single agent process directly from the command line. This is the command the server uses internally when you run <code>start <number></code> in chat — you rarely need to invoke it manually.</p>
|
|
||||||
<pre><code>huskies agent [OPTIONS]</code></pre>
|
|
||||||
|
|
||||||
<h3>Options</h3>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Flag</th><th>Description</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>--story <ID></td><td>Story ID slug to work on (e.g. <code>42_story_add_login</code>).</td></tr>
|
|
||||||
<tr><td>--agent <NAME></td><td>Agent name from <code>agents.toml</code> to use (e.g. <code>coder-1</code>, <code>qa</code>).</td></tr>
|
|
||||||
<tr><td>--worktree <PATH></td><td>Path to the git worktree the agent should work in.</td></tr>
|
|
||||||
<tr><td>--port <PORT></td><td>Huskies server port, so the agent can call MCP tools.</td></tr>
|
|
||||||
<tr><td>--help</td><td>Print help and exit.</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2>Environment variables</h2>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Variable</th><th>Description</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td><code>HUSKIES_PORT</code></td><td>Server port. Overrides the <code>--port</code> flag.</td></tr>
|
|
||||||
<tr><td><code>ANTHROPIC_API_KEY</code></td><td>Anthropic API key for agent sessions. Can also be set via the web UI on first use.</td></tr>
|
|
||||||
<tr><td><code>GITEA_TOKEN</code></td><td>Gitea API token used by the <code>script/release</code> script when publishing releases.</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2>Gateway event-push protocol</h2>
|
|
||||||
<p>Project nodes can push pipeline status events to the gateway in real time over a WebSocket connection. The gateway fans each event out to all connected local subscribers.</p>
|
|
||||||
|
|
||||||
<h3>Connecting</h3>
|
|
||||||
<ol>
|
|
||||||
<li>Obtain a one-time join token: <code>POST /gateway/tokens</code> → <code>{"token":"…"}</code></li>
|
|
||||||
<li>Open a WebSocket upgrade to <code>GET /gateway/events/push?token=TOKEN&project=PROJECT_NAME</code></li>
|
|
||||||
<li>The token is consumed on upgrade. The project name is attached to every event the server broadcasts downstream.</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h3>Sending events</h3>
|
|
||||||
<p>Each message must be a JSON-encoded <code>StoredEvent</code> frame:</p>
|
|
||||||
<pre><code>// Stage transition
|
|
||||||
{"type":"stage_transition","story_id":"42_story_login","from_stage":"2_current","to_stage":"3_qa","timestamp_ms":1700000000000}
|
|
||||||
|
|
||||||
// Merge failure
|
|
||||||
{"type":"merge_failure","story_id":"42_story_login","reason":"conflict in src/main.rs","timestamp_ms":1700000001000}
|
|
||||||
|
|
||||||
// Story blocked
|
|
||||||
{"type":"story_blocked","story_id":"42_story_login","reason":"retry limit exceeded","timestamp_ms":1700000002000}</code></pre>
|
|
||||||
<p>The server does not send frames back. Any other frames received by the project node indicate an error or server restart — treat them as a disconnect signal.</p>
|
|
||||||
|
|
||||||
<h3>Reconnect with exponential back-off</h3>
|
|
||||||
<p>Project nodes <strong>must</strong> reconnect on any disconnect. Use the following policy to avoid thundering herds after a gateway restart:</p>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Parameter</th><th>Value</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>Initial delay</td><td>1 s</td></tr>
|
|
||||||
<tr><td>Back-off multiplier</td><td>2× per attempt</td></tr>
|
|
||||||
<tr><td>Maximum delay</td><td>60 s</td></tr>
|
|
||||||
<tr><td>Jitter</td><td>±10 % of the computed delay</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p>Pseudocode:</p>
|
|
||||||
<pre><code>delay = 1.0 // seconds
|
|
||||||
max_delay = 60.0
|
|
||||||
|
|
||||||
loop:
|
|
||||||
token = POST /gateway/tokens
|
|
||||||
connect ws:/gateway/events/push?token=TOKEN&project=NAME
|
|
||||||
while connected:
|
|
||||||
send StoredEvent frames
|
|
||||||
// disconnected — wait and retry
|
|
||||||
jitter = delay * (random(0.9, 1.1))
|
|
||||||
sleep(min(jitter, max_delay))
|
|
||||||
delay = min(delay * 2, max_delay)</code></pre>
|
|
||||||
|
|
||||||
<div class="note">
|
|
||||||
<strong>New token per connection:</strong> Each WebSocket upgrade consumes the join token. Request a fresh token for every reconnect attempt.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Building from source</h2>
|
|
||||||
<h3>Standard release build</h3>
|
|
||||||
<pre><code>cargo build --release
|
|
||||||
# Output: target/release/huskies</code></pre>
|
|
||||||
|
|
||||||
<h3>Static Linux binary (musl)</h3>
|
|
||||||
<p>Requires <code>cross</code>: <code>cargo install cross</code>.</p>
|
|
||||||
<pre><code>cross build --release --target x86_64-unknown-linux-musl</code></pre>
|
|
||||||
|
|
||||||
<h3>Docker image</h3>
|
|
||||||
<pre><code>docker compose -f docker/docker-compose.yml build</code></pre>
|
|
||||||
|
|
||||||
<h3>Release script</h3>
|
|
||||||
<p>Builds macOS arm64 and Linux amd64 binaries, bumps the version, tags the repo, and publishes a Gitea release with changelog and binaries attached.</p>
|
|
||||||
<pre><code>script/release 0.8.0</code></pre>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="reveal r3">
|
|
||||||
<span>© 2026 Libby Labs Ltd.</span>
|
|
||||||
<a href="/privacy.html">Privacy Policy</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Bot Commands — Huskies Docs</title>
|
|
||||||
<meta name="description" content="Full reference of huskies bot commands available in Matrix, Slack, WhatsApp, Discord, and the web UI.">
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;500;600;700;800&family=Karla:ital,wght@0,300;0,400;0,500;1,300;1,400&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="docs.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="shell">
|
|
||||||
<header class="reveal r1">
|
|
||||||
<a href="/" class="logo">huskies</a>
|
|
||||||
<nav>
|
|
||||||
<a href="/#how">How it works</a>
|
|
||||||
<a href="/#features">Features</a>
|
|
||||||
<a href="/docs/" class="active">Docs</a>
|
|
||||||
<a href="https://code.crashlabs.io/crashlabs/huskies">Source</a>
|
|
||||||
<a href="https://code.crashlabs.io/crashlabs/huskies/releases">Releases</a>
|
|
||||||
<a href="mailto:hello@huskies.dev" class="nav-cta">Get in touch</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="shell">
|
|
||||||
<div class="docs-layout">
|
|
||||||
<aside class="sidebar reveal r2">
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Getting started</div>
|
|
||||||
<nav>
|
|
||||||
<a href="/docs/">Overview</a>
|
|
||||||
<a href="quickstart.html">Quickstart</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Reference</div>
|
|
||||||
<nav>
|
|
||||||
<a href="configuration.html">Configuration</a>
|
|
||||||
<a href="commands.html" class="active">Bot commands</a>
|
|
||||||
<a href="cli.html">CLI</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Guides</div>
|
|
||||||
<nav>
|
|
||||||
<a href="pipeline.html">Pipeline stages</a>
|
|
||||||
<a href="transports.html">Chat transports</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="docs-main reveal r3">
|
|
||||||
<h1 class="page-title">Bot Commands</h1>
|
|
||||||
<p class="page-subtitle">Commands available in every chat transport (Matrix, Slack, WhatsApp, Discord) and the built-in web UI. Commands are case-insensitive. Run <code>help</code> in any chat to see the list.</p>
|
|
||||||
|
|
||||||
<div class="note">
|
|
||||||
<strong>How to invoke:</strong> In chat rooms, address the bot first (e.g. <code>@huskies start 42</code>) or enable ambient mode so it responds to all messages. In the web UI, type commands directly.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Pipeline management</h2>
|
|
||||||
<div class="cmd-grid">
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">status</div>
|
|
||||||
<div class="cmd-desc">Show pipeline status and agent availability. Use <code>status <number></code> for a detailed triage dump on a specific story.</div>
|
|
||||||
</div>
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">start</div>
|
|
||||||
<div class="cmd-desc">Start an agent on a story: <code>start <number></code>. To use the opus model: <code>start <number> opus</code>.</div>
|
|
||||||
</div>
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">move</div>
|
|
||||||
<div class="cmd-desc">Move a work item to a pipeline stage: <code>move <number> <stage></code>. Stages: <code>backlog</code>, <code>current</code>, <code>qa</code>, <code>merge</code>, <code>done</code>.</div>
|
|
||||||
</div>
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">show</div>
|
|
||||||
<div class="cmd-desc">Display the full text of a work item: <code>show <number></code>.</div>
|
|
||||||
</div>
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">delete</div>
|
|
||||||
<div class="cmd-desc">Remove a work item from the pipeline: <code>delete <number></code>.</div>
|
|
||||||
</div>
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">unblock</div>
|
|
||||||
<div class="cmd-desc">Reset a blocked story: <code>unblock <number></code>. Clears the blocked flag and resets the retry count.</div>
|
|
||||||
</div>
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">assign</div>
|
|
||||||
<div class="cmd-desc">Pre-assign a model to a story before starting: <code>assign <number> <model></code> (e.g. <code>assign 42 opus</code>).</div>
|
|
||||||
</div>
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">backlog</div>
|
|
||||||
<div class="cmd-desc">Show all items in the backlog with dependency satisfaction status — which are ready to start and which are still waiting on other stories.</div>
|
|
||||||
</div>
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">depends</div>
|
|
||||||
<div class="cmd-desc">Set story dependencies: <code>depends <number> [dep1 dep2 ...]</code>. Call with no deps to clear all dependencies.</div>
|
|
||||||
</div>
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">timer</div>
|
|
||||||
<div class="cmd-desc">Schedule a deferred agent start: <code>timer <number> HH:MM</code>. List all timers: <code>timer list</code>. Cancel: <code>timer cancel <number></code>. Times are interpreted in the project timezone.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Worktrees</h2>
|
|
||||||
<div class="cmd-grid">
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">rmtree</div>
|
|
||||||
<div class="cmd-desc">Delete the worktree for a story without removing the story from the pipeline: <code>rmtree <number></code>. Useful for freeing disk space on a story that needs to be restarted.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Observability</h2>
|
|
||||||
<div class="cmd-grid">
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">cost</div>
|
|
||||||
<div class="cmd-desc">Show token spend: 24h total, top stories, breakdown by agent type, and all-time total.</div>
|
|
||||||
</div>
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">coverage</div>
|
|
||||||
<div class="cmd-desc">Show test coverage from the cached baseline. Use <code>coverage run</code> to rerun the full test suite and regenerate the report.</div>
|
|
||||||
</div>
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">git</div>
|
|
||||||
<div class="cmd-desc">Show git status for the main repository: current branch, uncommitted changes, and ahead/behind remote.</div>
|
|
||||||
</div>
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">htop</div>
|
|
||||||
<div class="cmd-desc">Live system and agent process dashboard. Use <code>htop</code> to start, <code>htop 10m</code> to run for 10 minutes, <code>htop stop</code> to stop.</div>
|
|
||||||
</div>
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">run_tests</div>
|
|
||||||
<div class="cmd-desc">Run the project's test suite (<code>script/test</code>) and show a pass/fail summary with output. Use <code>run_tests <number></code> to run tests inside a specific story's worktree instead of the project root.</div>
|
|
||||||
</div>
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">loc</div>
|
|
||||||
<div class="cmd-desc">Show top source files by line count: <code>loc</code> (top 10), <code>loc <N></code> for N files, or <code>loc <filepath></code> for a specific file.</div>
|
|
||||||
</div>
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">overview</div>
|
|
||||||
<div class="cmd-desc">Show an implementation summary for a merged story: <code>overview <number></code>.</div>
|
|
||||||
</div>
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">unreleased</div>
|
|
||||||
<div class="cmd-desc">Show stories merged to master since the last release tag.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Server management</h2>
|
|
||||||
<div class="cmd-grid">
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">rebuild</div>
|
|
||||||
<div class="cmd-desc">Rebuild the huskies server binary and restart the process.</div>
|
|
||||||
</div>
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">reset</div>
|
|
||||||
<div class="cmd-desc">Clear the current Claude Code session and start a fresh context window.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Setup & configuration</h2>
|
|
||||||
<div class="cmd-grid">
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">setup</div>
|
|
||||||
<div class="cmd-desc">Show setup wizard progress. Drive the wizard from chat: <code>setup generate</code>, <code>setup confirm</code>, <code>setup skip</code>, <code>setup retry</code>.</div>
|
|
||||||
</div>
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">ambient</div>
|
|
||||||
<div class="cmd-desc">Toggle ambient mode for the current room: <code>ambient on</code> or <code>ambient off</code>. In ambient mode the bot responds to all messages, not just addressed ones.</div>
|
|
||||||
</div>
|
|
||||||
<div class="cmd-row">
|
|
||||||
<div class="cmd-name">help</div>
|
|
||||||
<div class="cmd-desc">Show the list of available commands.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="reveal r3">
|
|
||||||
<span>© 2026 Libby Labs Ltd.</span>
|
|
||||||
<a href="/privacy.html">Privacy Policy</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Configuration — Huskies Docs</title>
|
|
||||||
<meta name="description" content="Reference for project.toml, agents.toml, and bot.toml configuration files.">
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;500;600;700;800&family=Karla:ital,wght@0,300;0,400;0,500;1,300;1,400&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="docs.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="shell">
|
|
||||||
<header class="reveal r1">
|
|
||||||
<a href="/" class="logo">huskies</a>
|
|
||||||
<nav>
|
|
||||||
<a href="/#how">How it works</a>
|
|
||||||
<a href="/#features">Features</a>
|
|
||||||
<a href="/docs/" class="active">Docs</a>
|
|
||||||
<a href="https://code.crashlabs.io/crashlabs/huskies">Source</a>
|
|
||||||
<a href="https://code.crashlabs.io/crashlabs/huskies/releases">Releases</a>
|
|
||||||
<a href="mailto:hello@huskies.dev" class="nav-cta">Get in touch</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="shell">
|
|
||||||
<div class="docs-layout">
|
|
||||||
<aside class="sidebar reveal r2">
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Getting started</div>
|
|
||||||
<nav>
|
|
||||||
<a href="/docs/">Overview</a>
|
|
||||||
<a href="quickstart.html">Quickstart</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Reference</div>
|
|
||||||
<nav>
|
|
||||||
<a href="configuration.html" class="active">Configuration</a>
|
|
||||||
<a href="commands.html">Bot commands</a>
|
|
||||||
<a href="cli.html">CLI</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Guides</div>
|
|
||||||
<nav>
|
|
||||||
<a href="pipeline.html">Pipeline stages</a>
|
|
||||||
<a href="transports.html">Chat transports</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="docs-main reveal r3">
|
|
||||||
<h1 class="page-title">Configuration</h1>
|
|
||||||
<p class="page-subtitle">Huskies is configured via three TOML files in your <code>.huskies/</code> directory. All files are created by <code>huskies init</code> with sensible defaults.</p>
|
|
||||||
|
|
||||||
<h2 id="project-toml">project.toml</h2>
|
|
||||||
<p>Project-wide settings. Lives at <code>.huskies/project.toml</code>.</p>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Key</th><th>Type</th><th>Default</th><th>Description</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>default_qa</td>
|
|
||||||
<td>string</td>
|
|
||||||
<td><code>"server"</code></td>
|
|
||||||
<td>Default QA mode. One of <code>"server"</code> (automated gate run), <code>"agent"</code> (spawn a QA agent), or <code>"human"</code> (manual approval).</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>default_coder_model</td>
|
|
||||||
<td>string</td>
|
|
||||||
<td><code>"sonnet"</code></td>
|
|
||||||
<td>Default model for coder agents. Only agents matching this model are auto-assigned. Use <code>"opus"</code> on individual stories for complex tasks.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>max_coders</td>
|
|
||||||
<td>integer</td>
|
|
||||||
<td><code>3</code></td>
|
|
||||||
<td>Maximum concurrent coder agents. Stories wait in <code>2_current/</code> when all slots are full.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>max_retries</td>
|
|
||||||
<td>integer</td>
|
|
||||||
<td><code>3</code></td>
|
|
||||||
<td>Maximum retries per story per pipeline stage before marking it as blocked. Set to <code>0</code> to disable.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>base_branch</td>
|
|
||||||
<td>string</td>
|
|
||||||
<td>auto-detected</td>
|
|
||||||
<td>Base branch for merges and agent prompts. When unset, huskies reads the current HEAD branch.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>rate_limit_notifications</td>
|
|
||||||
<td>bool</td>
|
|
||||||
<td><code>false</code></td>
|
|
||||||
<td>Send chat notifications when API soft rate limits are hit. Hard blocks and story-blocked notifications are always sent.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>timezone</td>
|
|
||||||
<td>string</td>
|
|
||||||
<td><code>"UTC"</code></td>
|
|
||||||
<td>IANA timezone for timer scheduling (e.g. <code>"Europe/London"</code>, <code>"America/New_York"</code>). Timer HH:MM inputs are interpreted in this timezone.</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h3>Component setup</h3>
|
|
||||||
<p>The <code>[[component]]</code> sections define how to build and verify each part of your project. The server runs setup commands before accepting a story's QA and teardown commands after merging.</p>
|
|
||||||
<pre><code>[[component]]
|
|
||||||
name = "frontend"
|
|
||||||
path = "frontend"
|
|
||||||
setup = ["npm ci", "npm run build"]
|
|
||||||
teardown = []
|
|
||||||
|
|
||||||
[[component]]
|
|
||||||
name = "server"
|
|
||||||
path = "."
|
|
||||||
setup = ["mkdir -p frontend/dist", "cargo check"]
|
|
||||||
teardown = []</code></pre>
|
|
||||||
|
|
||||||
<h3>Story front matter overrides</h3>
|
|
||||||
<p>Individual stories can override project defaults using YAML front matter:</p>
|
|
||||||
<pre><code>---
|
|
||||||
name: "My Complex Story"
|
|
||||||
qa: agent # override default_qa
|
|
||||||
agent: opus # use the opus coder agent
|
|
||||||
---</code></pre>
|
|
||||||
|
|
||||||
<h2 id="agents-toml">agents.toml</h2>
|
|
||||||
<p>Agent definitions. Lives at <code>.huskies/agents.toml</code>. Each <code>[[agent]]</code> block defines one agent slot.</p>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Key</th><th>Description</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>name</td>
|
|
||||||
<td>Unique identifier for this agent slot (e.g. <code>"coder-1"</code>, <code>"qa"</code>).</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>stage</td>
|
|
||||||
<td>Pipeline stage this agent handles. One of <code>"coder"</code>, <code>"qa"</code>, or <code>"mergemaster"</code>.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>role</td>
|
|
||||||
<td>Human-readable description of the agent's responsibilities (shown in status output).</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>model</td>
|
|
||||||
<td>Claude model to use. One of <code>"sonnet"</code> (claude-sonnet-4-6) or <code>"opus"</code> (claude-opus-4-6).</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>max_turns</td>
|
|
||||||
<td>Maximum conversation turns before the agent is forcefully stopped.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>max_budget_usd</td>
|
|
||||||
<td>Maximum API spend in USD before the agent is stopped.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>prompt</td>
|
|
||||||
<td>The initial user-turn prompt sent to the agent. Supports template variables (see below).</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>system_prompt</td>
|
|
||||||
<td>The system prompt sent to the agent session.</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h3>Template variables</h3>
|
|
||||||
<p>The following variables are interpolated into <code>prompt</code> and <code>system_prompt</code> at agent start time:</p>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Variable</th><th>Description</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>{{story_id}}</td><td>The story's ID slug (e.g. <code>42_story_add_login</code>).</td></tr>
|
|
||||||
<tr><td>{{worktree_path}}</td><td>Absolute path to the agent's git worktree.</td></tr>
|
|
||||||
<tr><td>{{base_branch}}</td><td>The base branch name from <code>project.toml</code>.</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h3>Example: adding an opus coder</h3>
|
|
||||||
<pre><code>[[agent]]
|
|
||||||
name = "coder-opus"
|
|
||||||
stage = "coder"
|
|
||||||
role = "Senior engineer for complex tasks."
|
|
||||||
model = "opus"
|
|
||||||
max_turns = 80
|
|
||||||
max_budget_usd = 20.00
|
|
||||||
prompt = "You are working on story {{story_id}} ..."
|
|
||||||
system_prompt = "You are a senior full-stack engineer ..."</code></pre>
|
|
||||||
<p>To use this agent for a specific story, add <code>agent: opus</code> to the story's front matter, or run <code>start <number> opus</code> in chat.</p>
|
|
||||||
|
|
||||||
<h2 id="agent-md">Project-local agent prompt (<code>.huskies/AGENT.md</code>)</h2>
|
|
||||||
<p>Place a file at <code>.huskies/AGENT.md</code> in your project root to append project-specific guidance to every agent's initial prompt at spawn time.</p>
|
|
||||||
|
|
||||||
<h3>How it works</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Huskies reads <code>.huskies/AGENT.md</code> each time an agent is spawned — no caching, no restart required.</li>
|
|
||||||
<li>The file content is appended <em>after</em> the baked-in agent prompt, so project guidance refines core instructions without overriding them.</li>
|
|
||||||
<li>Applies to all agent roles: coder, QA, mergemaster, and supervisor.</li>
|
|
||||||
<li>If the file is missing or empty, agents spawn normally — no warnings, no errors.</li>
|
|
||||||
<li>When the file exists and is non-empty, a single <code>INFO</code> log line is emitted showing the file path and byte count.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Ordering</h3>
|
|
||||||
<ol>
|
|
||||||
<li>Baked-in agent prompt (from <code>agents.toml</code> or <code>project.toml</code>)</li>
|
|
||||||
<li>Project-local content from <code>.huskies/AGENT.md</code></li>
|
|
||||||
<li>Resume context (only on agent restart after a gate failure)</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h3>Example</h3>
|
|
||||||
<pre><code># .huskies/AGENT.md
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
Docs live in `website/docs/*.html`, not Markdown files.
|
|
||||||
Edit the relevant .html file when a story asks for documentation.
|
|
||||||
|
|
||||||
## Quality gates
|
|
||||||
Run `cargo clippy -- -D warnings` before committing. Zero warnings allowed.</code></pre>
|
|
||||||
<p>Edit the file at any time — the next agent spawn picks up the latest content automatically.</p>
|
|
||||||
|
|
||||||
<h2 id="bot-toml">bot.toml</h2>
|
|
||||||
<p>Chat transport configuration. Lives at <code>.huskies/bot.toml</code>. This file is gitignored as it contains credentials. Copy the appropriate example file to get started:</p>
|
|
||||||
<pre><code>cp .huskies/bot.toml.matrix.example .huskies/bot.toml</code></pre>
|
|
||||||
<p>Only one transport can be active at a time. See the <a href="transports.html">Chat transports</a> guide for setup instructions for each platform.</p>
|
|
||||||
|
|
||||||
<h3>Common fields</h3>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Key</th><th>Description</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>enabled</td><td>Set to <code>true</code> to activate the bot. Set to <code>false</code> to disable without removing the file.</td></tr>
|
|
||||||
<tr><td>transport</td><td>Transport type: <code>"matrix"</code>, <code>"whatsapp"</code>, <code>"slack"</code>, or <code>"discord"</code>.</td></tr>
|
|
||||||
<tr><td>display_name</td><td>Optional. Bot display name in chat messages.</td></tr>
|
|
||||||
<tr><td>history_size</td><td>Optional. Maximum conversation turns to remember per room/user (default: 20).</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2 id="gateway-aggregated-stream">Gateway: aggregated chat stream</h2>
|
|
||||||
<p>When running <code>huskies --gateway</code>, you can configure a single bot that receives pipeline notifications from <strong>all</strong> registered projects. Events are prefixed with <code>[project-name]</code> so you can tell them apart in one shared room.</p>
|
|
||||||
|
|
||||||
<p>The aggregated stream is configured entirely in the <strong>gateway's</strong> <code>.huskies/bot.toml</code> — no per-project bot config is required and no per-project files need to change when you add a new project to <code>projects.toml</code>.</p>
|
|
||||||
|
|
||||||
<h3>Enabling the aggregated stream</h3>
|
|
||||||
<p>Add or edit <code><gateway-config-dir>/.huskies/bot.toml</code> and set <code>enabled = true</code>. The gateway bot will automatically poll every project listed in <code>projects.toml</code> and forward events to the configured rooms.</p>
|
|
||||||
<pre><code># <gateway-config-dir>/.huskies/bot.toml
|
|
||||||
enabled = true
|
|
||||||
transport = "matrix"
|
|
||||||
homeserver = "https://matrix.example.com"
|
|
||||||
username = "@gateway-bot:example.com"
|
|
||||||
password = "secret"
|
|
||||||
room_ids = ["!gateway-room:example.com"]
|
|
||||||
allowed_users = ["@you:example.com"]
|
|
||||||
|
|
||||||
# Gateway-specific: poll interval and on/off switch
|
|
||||||
aggregated_notifications_poll_interval_secs = 5 # default
|
|
||||||
aggregated_notifications_enabled = true # default</code></pre>
|
|
||||||
|
|
||||||
<h3>Aggregated stream settings</h3>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Key</th><th>Type</th><th>Default</th><th>Description</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>aggregated_notifications_enabled</td>
|
|
||||||
<td>bool</td>
|
|
||||||
<td><code>true</code></td>
|
|
||||||
<td>Set to <code>false</code> to disable the aggregated stream without disabling the gateway bot entirely. Per-project configs are never consulted.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>aggregated_notifications_poll_interval_secs</td>
|
|
||||||
<td>integer</td>
|
|
||||||
<td><code>5</code></td>
|
|
||||||
<td>How often (in seconds) the gateway polls each project's <code>/api/events</code> endpoint. Lower values reduce notification latency.</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h3>No-duplicate guarantee</h3>
|
|
||||||
<p>Per-project bots and the gateway aggregated stream send to different rooms — they are independent. Events from a per-project bot go to that project's rooms; events from the gateway stream go to the gateway rooms. The same event will never appear twice in either room.</p>
|
|
||||||
|
|
||||||
<h3>Unreachable projects</h3>
|
|
||||||
<p>If a per-project server is temporarily unreachable, the gateway logs a warning and skips that project for the current poll cycle. All other projects continue to deliver notifications normally. No configuration change is required — the poller retries on the next interval.</p>
|
|
||||||
|
|
||||||
<h3>Supported event types</h3>
|
|
||||||
<p>The aggregated stream delivers the following event types, each prefixed with the project name:</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Stage transitions</strong> — story created, agent started, QA requested, QA approved/rejected, merge succeeded (all pipeline stage moves)</li>
|
|
||||||
<li><strong>Merge failures</strong> — merge failed with a reason</li>
|
|
||||||
<li><strong>Story blocked</strong> — story blocked after exceeding retry limit</li>
|
|
||||||
</ul>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="reveal r3">
|
|
||||||
<span>© 2026 Libby Labs Ltd.</span>
|
|
||||||
<a href="/privacy.html">Privacy Policy</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,447 +0,0 @@
|
|||||||
:root {
|
|
||||||
--bg: #080c15;
|
|
||||||
--surface: #0e1420;
|
|
||||||
--surface-hover: #131a28;
|
|
||||||
--border: #1a2235;
|
|
||||||
--text: #e8ecf4;
|
|
||||||
--text-secondary: #8892a8;
|
|
||||||
--text-dim: #4a5568;
|
|
||||||
--cyan: #22d3ee;
|
|
||||||
--cyan-dim: rgba(34, 211, 238, 0.07);
|
|
||||||
--cyan-glow: rgba(34, 211, 238, 0.15);
|
|
||||||
--display: 'Bricolage Grotesque', sans-serif;
|
|
||||||
--body: 'Karla', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
html { scroll-behavior: smooth; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: var(--body);
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
line-height: 1.6;
|
|
||||||
min-height: 100vh;
|
|
||||||
overflow-x: hidden;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
a { color: var(--cyan); text-decoration: none; transition: opacity 0.2s; }
|
|
||||||
a:hover { opacity: 0.7; }
|
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
@keyframes fadeUp {
|
|
||||||
from { opacity: 0; transform: translateY(18px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal {
|
|
||||||
opacity: 0;
|
|
||||||
animation: fadeUp 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
||||||
}
|
|
||||||
.r1 { animation-delay: 0.05s; }
|
|
||||||
.r2 { animation-delay: 0.15s; }
|
|
||||||
.r3 { animation-delay: 0.3s; }
|
|
||||||
|
|
||||||
/* Outer shell */
|
|
||||||
.shell {
|
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.shell { padding: 0 1.25rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
header {
|
|
||||||
padding: 2rem 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
font-family: var(--display);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
header nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
header nav a {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
header nav a:hover { color: var(--text); opacity: 1; }
|
|
||||||
header nav a.active { color: var(--cyan); }
|
|
||||||
|
|
||||||
.nav-cta {
|
|
||||||
color: var(--cyan) !important;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Docs layout */
|
|
||||||
.docs-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 220px 1fr;
|
|
||||||
gap: 0;
|
|
||||||
min-height: calc(100vh - 80px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar */
|
|
||||||
.sidebar {
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
padding: 2.5rem 0 2.5rem 0;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
height: 100vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-section {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-heading {
|
|
||||||
font-family: var(--display);
|
|
||||||
font-size: 0.6rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-dim);
|
|
||||||
padding: 0 1.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar nav a {
|
|
||||||
display: block;
|
|
||||||
padding: 0.4rem 1.5rem;
|
|
||||||
font-size: 0.83rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
transition: color 0.15s, background 0.15s;
|
|
||||||
border-left: 2px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar nav a:hover {
|
|
||||||
color: var(--text);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar nav a.active {
|
|
||||||
color: var(--cyan);
|
|
||||||
border-left-color: var(--cyan);
|
|
||||||
background: var(--cyan-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main content */
|
|
||||||
.docs-main {
|
|
||||||
padding: 2.5rem 3rem 4rem;
|
|
||||||
max-width: 780px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.docs-layout { grid-template-columns: 1fr; }
|
|
||||||
.sidebar {
|
|
||||||
position: static;
|
|
||||||
height: auto;
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
padding: 1.5rem 0;
|
|
||||||
}
|
|
||||||
.docs-main { padding: 2rem 0 3rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Typography */
|
|
||||||
.page-title {
|
|
||||||
font-family: var(--display);
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-subtitle {
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 300;
|
|
||||||
margin-bottom: 3rem;
|
|
||||||
line-height: 1.7;
|
|
||||||
max-width: 560px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-family: var(--display);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
margin: 3rem 0 1rem;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
scroll-margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2:first-of-type {
|
|
||||||
margin-top: 0;
|
|
||||||
padding-top: 0;
|
|
||||||
border-top: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-family: var(--display);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 1.8rem 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.8;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p strong {
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul, ol {
|
|
||||||
padding-left: 1.4rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.8;
|
|
||||||
margin-bottom: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
li strong {
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Code */
|
|
||||||
code {
|
|
||||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.1em 0.4em;
|
|
||||||
color: var(--cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 1.2rem 1.4rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
margin: 1rem 0 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre code {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--text);
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tables */
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 1rem 0 1.5rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
text-align: left;
|
|
||||||
font-family: var(--display);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-dim);
|
|
||||||
padding: 0.6rem 1rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: 0.65rem 1rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
vertical-align: top;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
td:first-child {
|
|
||||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--cyan);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:last-child td { border-bottom: none; }
|
|
||||||
|
|
||||||
/* Callout / note box */
|
|
||||||
.note {
|
|
||||||
background: var(--cyan-dim);
|
|
||||||
border: 1px solid rgba(34, 211, 238, 0.2);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 1rem 1.2rem;
|
|
||||||
margin: 1.2rem 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note strong {
|
|
||||||
color: var(--cyan);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Step list */
|
|
||||||
.step-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
counter-reset: steps;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-list li {
|
|
||||||
counter-increment: steps;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 40px 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1.2rem 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-list li:first-child { border-top: 1px solid var(--border); }
|
|
||||||
|
|
||||||
.step-list li::before {
|
|
||||||
content: counter(steps, decimal-leading-zero);
|
|
||||||
font-family: var(--display);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-dim);
|
|
||||||
padding-top: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Command cards */
|
|
||||||
.cmd-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
background: var(--border);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin: 1rem 0 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmd-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 160px 1fr;
|
|
||||||
background: var(--surface);
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmd-row:hover { background: var(--surface-hover); }
|
|
||||||
|
|
||||||
.cmd-name {
|
|
||||||
padding: 0.9rem 1.1rem;
|
|
||||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--cyan);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmd-desc {
|
|
||||||
padding: 0.9rem 1.1rem;
|
|
||||||
font-size: 0.84rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.6;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Docs index cards */
|
|
||||||
.doc-cards {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1px;
|
|
||||||
background: var(--border);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-card {
|
|
||||||
background: var(--surface);
|
|
||||||
padding: 1.6rem;
|
|
||||||
transition: background 0.2s;
|
|
||||||
text-decoration: none;
|
|
||||||
display: block;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-card:hover { background: var(--surface-hover); opacity: 1; }
|
|
||||||
|
|
||||||
.doc-card-title {
|
|
||||||
font-family: var(--display);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-card-desc {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.doc-cards { grid-template-columns: 1fr; }
|
|
||||||
.cmd-row { grid-template-columns: 130px 1fr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
footer {
|
|
||||||
padding: 2rem 0;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-dim);
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer a {
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer a:hover { color: var(--text-secondary); }
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Documentation — Huskies</title>
|
|
||||||
<meta name="description" content="Huskies documentation: quickstart, configuration, bot commands, pipeline, and more.">
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;500;600;700;800&family=Karla:ital,wght@0,300;0,400;0,500;1,300;1,400&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="docs.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="shell">
|
|
||||||
<header class="reveal r1">
|
|
||||||
<a href="/" class="logo">huskies</a>
|
|
||||||
<nav>
|
|
||||||
<a href="/#how">How it works</a>
|
|
||||||
<a href="/#features">Features</a>
|
|
||||||
<a href="/docs/" class="active">Docs</a>
|
|
||||||
<a href="https://code.crashlabs.io/crashlabs/huskies">Source</a>
|
|
||||||
<a href="https://code.crashlabs.io/crashlabs/huskies/releases">Releases</a>
|
|
||||||
<a href="mailto:hello@huskies.dev" class="nav-cta">Get in touch</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="shell">
|
|
||||||
<div class="docs-layout">
|
|
||||||
<aside class="sidebar reveal r2">
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Getting started</div>
|
|
||||||
<nav>
|
|
||||||
<a href="/docs/" class="active">Overview</a>
|
|
||||||
<a href="quickstart.html">Quickstart</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Reference</div>
|
|
||||||
<nav>
|
|
||||||
<a href="configuration.html">Configuration</a>
|
|
||||||
<a href="commands.html">Bot commands</a>
|
|
||||||
<a href="cli.html">CLI</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Guides</div>
|
|
||||||
<nav>
|
|
||||||
<a href="pipeline.html">Pipeline stages</a>
|
|
||||||
<a href="transports.html">Chat transports</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="docs-main reveal r3">
|
|
||||||
<p class="hero-kicker" style="font-family:var(--display);font-size:0.68rem;font-weight:600;letter-spacing:0.18em;text-transform:uppercase;color:var(--cyan);margin-bottom:1rem;">Documentation</p>
|
|
||||||
<h1 class="page-title">Huskies Docs</h1>
|
|
||||||
<p class="page-subtitle">Everything you need to set up and run huskies — a story-driven development pipeline that turns coding agents into a disciplined team.</p>
|
|
||||||
|
|
||||||
<div class="doc-cards">
|
|
||||||
<a class="doc-card" href="quickstart.html">
|
|
||||||
<div class="doc-card-title">Quickstart</div>
|
|
||||||
<div class="doc-card-desc">Install huskies, run the server, create your first story, and watch an agent implement it.</div>
|
|
||||||
</a>
|
|
||||||
<a class="doc-card" href="configuration.html">
|
|
||||||
<div class="doc-card-title">Configuration</div>
|
|
||||||
<div class="doc-card-desc">Reference for <code>project.toml</code>, <code>agents.toml</code>, and <code>bot.toml</code>.</div>
|
|
||||||
</a>
|
|
||||||
<a class="doc-card" href="commands.html">
|
|
||||||
<div class="doc-card-title">Bot commands</div>
|
|
||||||
<div class="doc-card-desc">Full list of commands available in Matrix, Slack, WhatsApp, and the web UI.</div>
|
|
||||||
</a>
|
|
||||||
<a class="doc-card" href="pipeline.html">
|
|
||||||
<div class="doc-card-title">Pipeline stages</div>
|
|
||||||
<div class="doc-card-desc">How work items move from backlog through QA and merge to done.</div>
|
|
||||||
</a>
|
|
||||||
<a class="doc-card" href="transports.html">
|
|
||||||
<div class="doc-card-title">Chat transports</div>
|
|
||||||
<div class="doc-card-desc">Connect huskies to Matrix, WhatsApp, Slack, Discord, or the built-in web UI.</div>
|
|
||||||
</a>
|
|
||||||
<a class="doc-card" href="cli.html">
|
|
||||||
<div class="doc-card-title">CLI reference</div>
|
|
||||||
<div class="doc-card-desc">Command-line flags for <code>huskies</code>, <code>huskies init</code>, and <code>huskies agent</code>.</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>What is huskies?</h2>
|
|
||||||
<p>Huskies is a story-driven development server. You write stories (feature requests) with acceptance criteria; huskies spawns coding agents in isolated git worktrees, runs them through quality gates, and squash-merges the result to your main branch — all without you writing a line of code.</p>
|
|
||||||
<p>It ships as a single Rust binary with an embedded React frontend. No separate database or build infrastructure required.</p>
|
|
||||||
|
|
||||||
<h2>How it works</h2>
|
|
||||||
<ol class="step-list">
|
|
||||||
<li>
|
|
||||||
<div>
|
|
||||||
<strong>Write a story.</strong> Describe the change with acceptance criteria via the web UI, a chat room (Matrix, WhatsApp, Slack), or by dropping a Markdown file in <code>.huskies/work/1_backlog/</code>.
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div>
|
|
||||||
<strong>Agent picks it up.</strong> Run <code>start <number></code> (or configure auto-start). A coder agent creates a feature branch, implements the code, and writes tests against your criteria.
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div>
|
|
||||||
<strong>Quality gates run.</strong> Linters, tests, and compilation checks run automatically when the agent exits. Nothing moves forward until everything passes.
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div>
|
|
||||||
<strong>QA review.</strong> A QA agent verifies each acceptance criterion, runs your test suite, and either approves or rejects with detailed findings.
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div>
|
|
||||||
<strong>Merge & land.</strong> A merge agent resolves conflicts and squash-merges to your main branch. The worktree is cleaned up automatically.
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>Key concepts</h2>
|
|
||||||
<p><strong>Stories</strong> are Markdown files with YAML front matter. They live in <code>.huskies/work/</code> and move through pipeline stages as work progresses.</p>
|
|
||||||
<p><strong>Agents</strong> are Claude Code sessions that run autonomously in git worktrees. Each story gets its own isolated worktree so multiple stories can be in flight simultaneously.</p>
|
|
||||||
<p><strong>MCP tools</strong> give Claude Code sessions programmatic access to the pipeline: creating stories, starting agents, checking status, recording test results.</p>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="reveal r3">
|
|
||||||
<span>© 2026 Libby Labs Ltd.</span>
|
|
||||||
<a href="/privacy.html">Privacy Policy</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Pipeline Stages — Huskies Docs</title>
|
|
||||||
<meta name="description" content="How work items move through the huskies pipeline: backlog, current, QA, merge, done.">
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;500;600;700;800&family=Karla:ital,wght@0,300;0,400;0,500;1,300;1,400&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="docs.css">
|
|
||||||
<style>
|
|
||||||
.pipeline-stages {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
background: var(--border);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin: 1.5rem 0;
|
|
||||||
}
|
|
||||||
.stage-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 48px 140px 1fr;
|
|
||||||
gap: 0;
|
|
||||||
background: var(--surface);
|
|
||||||
transition: background 0.2s;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
.stage-row:hover { background: var(--surface-hover); }
|
|
||||||
.stage-num {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-family: var(--display);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-dim);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.stage-name {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1rem 1.1rem;
|
|
||||||
font-family: var(--display);
|
|
||||||
font-size: 0.88rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.stage-desc {
|
|
||||||
padding: 1rem 1.2rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.stage-row.active .stage-name { color: var(--cyan); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="shell">
|
|
||||||
<header class="reveal r1">
|
|
||||||
<a href="/" class="logo">huskies</a>
|
|
||||||
<nav>
|
|
||||||
<a href="/#how">How it works</a>
|
|
||||||
<a href="/#features">Features</a>
|
|
||||||
<a href="/docs/" class="active">Docs</a>
|
|
||||||
<a href="https://code.crashlabs.io/crashlabs/huskies">Source</a>
|
|
||||||
<a href="https://code.crashlabs.io/crashlabs/huskies/releases">Releases</a>
|
|
||||||
<a href="mailto:hello@huskies.dev" class="nav-cta">Get in touch</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="shell">
|
|
||||||
<div class="docs-layout">
|
|
||||||
<aside class="sidebar reveal r2">
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Getting started</div>
|
|
||||||
<nav>
|
|
||||||
<a href="/docs/">Overview</a>
|
|
||||||
<a href="quickstart.html">Quickstart</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Reference</div>
|
|
||||||
<nav>
|
|
||||||
<a href="configuration.html">Configuration</a>
|
|
||||||
<a href="commands.html">Bot commands</a>
|
|
||||||
<a href="cli.html">CLI</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Guides</div>
|
|
||||||
<nav>
|
|
||||||
<a href="pipeline.html" class="active">Pipeline stages</a>
|
|
||||||
<a href="transports.html">Chat transports</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="docs-main reveal r3">
|
|
||||||
<h1 class="page-title">Pipeline Stages</h1>
|
|
||||||
<p class="page-subtitle">Work items move through six stages from idea to archive. Each stage is a directory under <code>.huskies/work/</code>. Moving a file between directories advances the story.</p>
|
|
||||||
|
|
||||||
<div class="pipeline-stages">
|
|
||||||
<div class="stage-row">
|
|
||||||
<div class="stage-num">1</div>
|
|
||||||
<div class="stage-name">Backlog</div>
|
|
||||||
<div class="stage-desc"><code>1_backlog/</code> — New work items awaiting prioritisation. Stories sit here until you decide to start them.</div>
|
|
||||||
</div>
|
|
||||||
<div class="stage-row active">
|
|
||||||
<div class="stage-num">2</div>
|
|
||||||
<div class="stage-name">Current</div>
|
|
||||||
<div class="stage-desc"><code>2_current/</code> — Work in progress. Run <code>start <number></code> to assign a coder agent. Multiple stories can be in current simultaneously (up to <code>max_coders</code>).</div>
|
|
||||||
</div>
|
|
||||||
<div class="stage-row">
|
|
||||||
<div class="stage-num">3</div>
|
|
||||||
<div class="stage-name">QA</div>
|
|
||||||
<div class="stage-desc"><code>3_qa/</code> — Quality review. The server automatically moves stories here when the coder agent passes all quality gates. A QA agent (or a human) verifies each acceptance criterion.</div>
|
|
||||||
</div>
|
|
||||||
<div class="stage-row">
|
|
||||||
<div class="stage-num">4</div>
|
|
||||||
<div class="stage-name">Merge</div>
|
|
||||||
<div class="stage-desc"><code>4_merge/</code> — Ready to merge. Stories reach here after QA approval. Run <code>start <number></code> to trigger the mergemaster agent, which squash-merges to your base branch.</div>
|
|
||||||
</div>
|
|
||||||
<div class="stage-row">
|
|
||||||
<div class="stage-num">5</div>
|
|
||||||
<div class="stage-name">Done</div>
|
|
||||||
<div class="stage-desc"><code>5_done/</code> — Merged and complete. The mergemaster moves stories here after a successful merge. Auto-swept to archive after 4 hours.</div>
|
|
||||||
</div>
|
|
||||||
<div class="stage-row">
|
|
||||||
<div class="stage-num">6</div>
|
|
||||||
<div class="stage-name">Archived</div>
|
|
||||||
<div class="stage-desc"><code>6_archived/</code> — Long-term storage. Stories land here automatically from done. Use <code>overview <number></code> to see the implementation summary for any archived story.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Work item types</h2>
|
|
||||||
<p>All work item types move through the same pipeline. They differ in naming convention and workflow:</p>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Type</th><th>Filename pattern</th><th>When to use</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>story</td>
|
|
||||||
<td><code>42_story_add_login.md</code></td>
|
|
||||||
<td>New functionality. Requires acceptance criteria and tests.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>bug</td>
|
|
||||||
<td><code>43_bug_login_crashes.md</code></td>
|
|
||||||
<td>Defect in existing functionality. Write a failing test first.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>spike</td>
|
|
||||||
<td><code>44_spike_auth_options.md</code></td>
|
|
||||||
<td>Time-boxed research to reduce uncertainty. No production code.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>refactor</td>
|
|
||||||
<td><code>45_refactor_extract_auth.md</code></td>
|
|
||||||
<td>Code quality improvement. Behaviour must not change.</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2>Story file format</h2>
|
|
||||||
<p>Every work item is a Markdown file with YAML front matter:</p>
|
|
||||||
<pre><code>---
|
|
||||||
name: "Short human-readable name"
|
|
||||||
qa: agent # optional: override default_qa
|
|
||||||
agent: opus # optional: request specific agent model
|
|
||||||
---
|
|
||||||
|
|
||||||
# Story 42: Add login endpoint
|
|
||||||
|
|
||||||
## User Story
|
|
||||||
As a user, I want to log in with email and password so that I can access my account.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
- [ ] POST /auth/login accepts email and password
|
|
||||||
- [ ] Returns a JWT token on success
|
|
||||||
- [ ] Returns 401 on invalid credentials
|
|
||||||
- [ ] Rate-limited to 5 attempts per minute per IP
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
- OAuth / social login
|
|
||||||
- Password reset flow</code></pre>
|
|
||||||
|
|
||||||
<h2>Acceptance criteria tracking</h2>
|
|
||||||
<p>Acceptance criteria use Markdown checkboxes (<code>- [ ]</code>). The QA agent reviews each criterion against the code diff and marks passing criteria as <code>- [x]</code> in the story file. Criteria that fail are noted in the QA report.</p>
|
|
||||||
|
|
||||||
<div class="note">
|
|
||||||
<strong>Golden rule:</strong> No code is written until acceptance criteria are captured in the story. The agent reads the story file to understand what to build and what to test.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Filesystem watcher</h2>
|
|
||||||
<p>The server watches <code>.huskies/work/</code> for changes. When a file is created, moved, or modified, the watcher auto-commits with a deterministic message and broadcasts a WebSocket update to the frontend. This means:</p>
|
|
||||||
<ul>
|
|
||||||
<li>You can drag a story between stage folders in your IDE and it advances automatically.</li>
|
|
||||||
<li>MCP tools only need to write or move files — the watcher handles git commits.</li>
|
|
||||||
<li>The pipeline board updates in real time without a manual refresh.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>Blocked stories</h2>
|
|
||||||
<p>A story is marked <strong>blocked</strong> when it fails the same pipeline stage more than <code>max_retries</code> times (default: 3). Blocked stories require manual intervention:</p>
|
|
||||||
<ol>
|
|
||||||
<li>Run <code>status <number></code> to see the failure log.</li>
|
|
||||||
<li>Fix the underlying issue (update the story, fix a build problem, etc.).</li>
|
|
||||||
<li>Run <code>unblock <number></code> to reset the retry counter.</li>
|
|
||||||
<li>Run <code>start <number></code> to try again.</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>Dependencies</h2>
|
|
||||||
<p>Stories can declare dependencies using the <code>depends</code> command. A story with unresolved dependencies waits in <code>2_current/</code> until all its dependencies have reached <code>5_done/</code>.</p>
|
|
||||||
<pre><code>depends 45 42 43 # story 45 waits for 42 and 43 to finish
|
|
||||||
depends 45 # clear all dependencies</code></pre>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="reveal r3">
|
|
||||||
<span>© 2026 Libby Labs Ltd.</span>
|
|
||||||
<a href="/privacy.html">Privacy Policy</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Quickstart — Huskies Docs</title>
|
|
||||||
<meta name="description" content="Get huskies running in minutes: Docker setup, first story, first agent run.">
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;500;600;700;800&family=Karla:ital,wght@0,300;0,400;0,500;1,300;1,400&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="docs.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="shell">
|
|
||||||
<header class="reveal r1">
|
|
||||||
<a href="/" class="logo">huskies</a>
|
|
||||||
<nav>
|
|
||||||
<a href="/#how">How it works</a>
|
|
||||||
<a href="/#features">Features</a>
|
|
||||||
<a href="/docs/" class="active">Docs</a>
|
|
||||||
<a href="https://code.crashlabs.io/crashlabs/huskies">Source</a>
|
|
||||||
<a href="https://code.crashlabs.io/crashlabs/huskies/releases">Releases</a>
|
|
||||||
<a href="mailto:hello@huskies.dev" class="nav-cta">Get in touch</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="shell">
|
|
||||||
<div class="docs-layout">
|
|
||||||
<aside class="sidebar reveal r2">
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Getting started</div>
|
|
||||||
<nav>
|
|
||||||
<a href="/docs/">Overview</a>
|
|
||||||
<a href="quickstart.html" class="active">Quickstart</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Reference</div>
|
|
||||||
<nav>
|
|
||||||
<a href="configuration.html">Configuration</a>
|
|
||||||
<a href="commands.html">Bot commands</a>
|
|
||||||
<a href="cli.html">CLI</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Guides</div>
|
|
||||||
<nav>
|
|
||||||
<a href="pipeline.html">Pipeline stages</a>
|
|
||||||
<a href="transports.html">Chat transports</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="docs-main reveal r3">
|
|
||||||
<h1 class="page-title">Quickstart</h1>
|
|
||||||
<p class="page-subtitle">Get huskies running in your project in a few minutes. This guide covers Docker setup, running from a binary, your first story, and your first agent run.</p>
|
|
||||||
|
|
||||||
<h2>Option A: Docker (recommended)</h2>
|
|
||||||
<p>The easiest way to run huskies is with Docker Compose. This requires Docker and a Claude API key.</p>
|
|
||||||
<ol class="step-list">
|
|
||||||
<li>
|
|
||||||
<div>
|
|
||||||
<strong>Get the compose file.</strong> Download <code>docker-compose.yml</code> from the <a href="https://code.crashlabs.io/crashlabs/huskies/releases">releases page</a> or copy it from the repository's <code>docker/</code> directory.
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div>
|
|
||||||
<strong>Set your API key.</strong> Create a <code>.env</code> file next to the compose file:
|
|
||||||
<pre><code>ANTHROPIC_API_KEY=sk-ant-...</code></pre>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div>
|
|
||||||
<strong>Mount your project.</strong> Edit the compose file to mount your project directory:
|
|
||||||
<pre><code>volumes:
|
|
||||||
- /path/to/your/project:/workspace</code></pre>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div>
|
|
||||||
<strong>Start the server.</strong>
|
|
||||||
<pre><code>docker compose up</code></pre>
|
|
||||||
Open <a href="http://localhost:3000">http://localhost:3000</a> to see the pipeline board.
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>Option B: Binary</h2>
|
|
||||||
<p>Download the pre-built binary for your platform from the <a href="https://code.crashlabs.io/crashlabs/huskies/releases">releases page</a> and place it somewhere on your <code>PATH</code>.</p>
|
|
||||||
|
|
||||||
<h3>macOS (Apple Silicon)</h3>
|
|
||||||
<pre><code>curl -L https://code.crashlabs.io/crashlabs/huskies/releases/download/latest/huskies-aarch64-apple-darwin \
|
|
||||||
-o /usr/local/bin/huskies
|
|
||||||
chmod +x /usr/local/bin/huskies</code></pre>
|
|
||||||
|
|
||||||
<h3>Linux (x86-64)</h3>
|
|
||||||
<pre><code>curl -L https://code.crashlabs.io/crashlabs/huskies/releases/download/latest/huskies-x86_64-unknown-linux-musl \
|
|
||||||
-o /usr/local/bin/huskies
|
|
||||||
chmod +x /usr/local/bin/huskies</code></pre>
|
|
||||||
|
|
||||||
<h2>Option C: Build from source</h2>
|
|
||||||
<p>Requires Rust (stable), Node.js, and npm.</p>
|
|
||||||
<pre><code>git clone https://code.crashlabs.io/crashlabs/huskies
|
|
||||||
cd huskies
|
|
||||||
cargo build --release
|
|
||||||
# Binary is at target/release/huskies</code></pre>
|
|
||||||
|
|
||||||
<h2>Initialise your project</h2>
|
|
||||||
<p>From your project directory, run the init command. This creates the <code>.huskies/</code> directory with the pipeline structure and configuration files.</p>
|
|
||||||
<pre><code>cd /path/to/your/project
|
|
||||||
huskies init --port 3000</code></pre>
|
|
||||||
<p>This creates:</p>
|
|
||||||
<ul>
|
|
||||||
<li><code>.huskies/project.toml</code> — project-wide settings</li>
|
|
||||||
<li><code>.huskies/agents.toml</code> — agent definitions (coder, QA, mergemaster)</li>
|
|
||||||
<li><code>.huskies/work/</code> — the 6-stage pipeline directory</li>
|
|
||||||
<li><code>.mcp.json</code> — MCP server config for Claude Code integration</li>
|
|
||||||
<li><code>.huskies/specs/</code> — placeholder spec files for your project context</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="note">
|
|
||||||
<strong>Claude Code integration:</strong> The <code>.mcp.json</code> file automatically registers huskies' MCP tools with Claude Code. Open a Claude Code session in your project and it will discover tools like <code>create_story</code>, <code>start_agent</code>, and <code>get_pipeline_status</code> automatically.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Start the server</h2>
|
|
||||||
<pre><code>huskies --port 3000</code></pre>
|
|
||||||
<p>Open <a href="http://localhost:3000">http://localhost:3000</a> to see the pipeline board, agent status, and chat interface.</p>
|
|
||||||
|
|
||||||
<h2>Run the setup wizard</h2>
|
|
||||||
<p>Open a Claude Code session in your project directory (or use the web chat UI), and tell Claude:</p>
|
|
||||||
<pre><code>help me set up this project with huskies</code></pre>
|
|
||||||
<p>Claude will walk you through the setup wizard — generating project context (<code>specs/00_CONTEXT.md</code>), tech stack docs (<code>specs/tech/STACK.md</code>), and test/release scripts. Review each step and confirm or ask to retry.</p>
|
|
||||||
|
|
||||||
<h2>Create your first story</h2>
|
|
||||||
<p>In the chat UI or via a chat transport, type:</p>
|
|
||||||
<pre><code>I want to add a health check endpoint to the API</code></pre>
|
|
||||||
<p>Claude will create a story file in <code>.huskies/work/1_backlog/</code> with a user story and acceptance criteria. Review it, then move it to current:</p>
|
|
||||||
<pre><code>move <story-number> current</code></pre>
|
|
||||||
|
|
||||||
<h2>Start an agent</h2>
|
|
||||||
<p>Once a story is in <code>2_current/</code>, start a coding agent:</p>
|
|
||||||
<pre><code>start <story-number></code></pre>
|
|
||||||
<p>The agent creates an isolated git worktree, implements the feature against the acceptance criteria, runs quality gates (clippy, tests, biome), and exits. The server automatically advances the story to QA if all gates pass.</p>
|
|
||||||
|
|
||||||
<h2>Review and merge</h2>
|
|
||||||
<p>Once QA passes, the story moves to <code>4_merge/</code>. To merge:</p>
|
|
||||||
<pre><code>start <story-number></code></pre>
|
|
||||||
<p>The mergemaster agent resolves any conflicts and squash-merges to your main branch. The worktree is cleaned up automatically.</p>
|
|
||||||
|
|
||||||
<div class="note">
|
|
||||||
<strong>Tip:</strong> Use <code>status</code> in the chat at any time to see the current pipeline state, active agents, and their progress.
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="reveal r3">
|
|
||||||
<span>© 2026 Libby Labs Ltd.</span>
|
|
||||||
<a href="/privacy.html">Privacy Policy</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Chat Transports — Huskies Docs</title>
|
|
||||||
<meta name="description" content="Connect huskies to Matrix, WhatsApp, Slack, Discord, or the built-in web UI.">
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;500;600;700;800&family=Karla:ital,wght@0,300;0,400;0,500;1,300;1,400&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="docs.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="shell">
|
|
||||||
<header class="reveal r1">
|
|
||||||
<a href="/" class="logo">huskies</a>
|
|
||||||
<nav>
|
|
||||||
<a href="/#how">How it works</a>
|
|
||||||
<a href="/#features">Features</a>
|
|
||||||
<a href="/docs/" class="active">Docs</a>
|
|
||||||
<a href="https://code.crashlabs.io/crashlabs/huskies">Source</a>
|
|
||||||
<a href="https://code.crashlabs.io/crashlabs/huskies/releases">Releases</a>
|
|
||||||
<a href="mailto:hello@huskies.dev" class="nav-cta">Get in touch</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="shell">
|
|
||||||
<div class="docs-layout">
|
|
||||||
<aside class="sidebar reveal r2">
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Getting started</div>
|
|
||||||
<nav>
|
|
||||||
<a href="/docs/">Overview</a>
|
|
||||||
<a href="quickstart.html">Quickstart</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Reference</div>
|
|
||||||
<nav>
|
|
||||||
<a href="configuration.html">Configuration</a>
|
|
||||||
<a href="commands.html">Bot commands</a>
|
|
||||||
<a href="cli.html">CLI</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-heading">Guides</div>
|
|
||||||
<nav>
|
|
||||||
<a href="pipeline.html">Pipeline stages</a>
|
|
||||||
<a href="transports.html" class="active">Chat transports</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="docs-main reveal r3">
|
|
||||||
<h1 class="page-title">Chat Transports</h1>
|
|
||||||
<p class="page-subtitle">Huskies can be controlled via bot commands in any of five transports. Only one external transport can be active at a time. The web UI is always available regardless.</p>
|
|
||||||
|
|
||||||
<div class="note">
|
|
||||||
<strong>Configuration:</strong> Copy the relevant example file to <code>.huskies/bot.toml</code> and fill in your credentials. The file is gitignored. Restart huskies after changes.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Web UI</h2>
|
|
||||||
<p>The built-in web interface is always available at <code>http://localhost:<port></code>. No configuration required. It provides:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Pipeline board showing all work items and their stages</li>
|
|
||||||
<li>Agent status panel with live output streaming</li>
|
|
||||||
<li>Chat interface for running commands and talking to Claude</li>
|
|
||||||
<li>Coverage and cost dashboards</li>
|
|
||||||
</ul>
|
|
||||||
<p>No <code>bot.toml</code> is required for the web UI. If no transport is configured, huskies runs in web-only mode.</p>
|
|
||||||
|
|
||||||
<h2>Matrix</h2>
|
|
||||||
<p>Matrix uses the Matrix Client-Server API with long-polling sync. No public webhook URL is required — the bot connects outbound to your homeserver.</p>
|
|
||||||
|
|
||||||
<h3>Setup</h3>
|
|
||||||
<ol class="step-list">
|
|
||||||
<li><div>Register a Matrix account for the bot on your homeserver (e.g. <code>@huskies:example.com</code>).</div></li>
|
|
||||||
<li><div>Invite the bot account to the rooms you want it to monitor.</div></li>
|
|
||||||
<li>
|
|
||||||
<div>
|
|
||||||
Copy the example config and fill in your credentials:
|
|
||||||
<pre><code>cp .huskies/bot.toml.matrix.example .huskies/bot.toml</code></pre>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h3>bot.toml fields</h3>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Key</th><th>Description</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>homeserver</td><td>Your Matrix homeserver URL (e.g. <code>https://matrix.example.com</code>).</td></tr>
|
|
||||||
<tr><td>username</td><td>Bot account Matrix ID (e.g. <code>@huskies:example.com</code>).</td></tr>
|
|
||||||
<tr><td>password</td><td>Bot account password.</td></tr>
|
|
||||||
<tr><td>room_ids</td><td>List of room IDs to listen in (e.g. <code>["!roomid:example.com"]</code>).</td></tr>
|
|
||||||
<tr><td>allowed_users</td><td>Matrix IDs allowed to interact. Empty list means nobody — always set this.</td></tr>
|
|
||||||
<tr><td>ambient_rooms</td><td>Rooms where the bot responds to all messages (not just addressed ones). Updated automatically by <code>ambient on/off</code>.</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2>Slack</h2>
|
|
||||||
<p>Slack uses event subscriptions over a webhook. You'll need a public HTTPS URL pointing to your huskies server.</p>
|
|
||||||
|
|
||||||
<h3>Setup</h3>
|
|
||||||
<ol class="step-list">
|
|
||||||
<li><div>Create a Slack App at <a href="https://api.slack.com/apps">api.slack.com/apps</a>.</div></li>
|
|
||||||
<li><div>Add OAuth scopes: <code>chat:write</code>, <code>chat:update</code>.</div></li>
|
|
||||||
<li><div>Subscribe to bot events: <code>message.channels</code>, <code>message.groups</code>, <code>message.im</code>.</div></li>
|
|
||||||
<li><div>Install the app to your workspace and copy the bot token.</div></li>
|
|
||||||
<li>
|
|
||||||
<div>
|
|
||||||
Set your webhook URL in Event Subscriptions:
|
|
||||||
<code>https://your-server/webhook/slack</code>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div>
|
|
||||||
Copy the example config:
|
|
||||||
<pre><code>cp .huskies/bot.toml.slack.example .huskies/bot.toml</code></pre>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h3>bot.toml fields</h3>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Key</th><th>Description</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>slack_bot_token</td><td>OAuth bot token starting with <code>xoxb-</code>.</td></tr>
|
|
||||||
<tr><td>slack_signing_secret</td><td>Signing secret from the app's Basic Information page.</td></tr>
|
|
||||||
<tr><td>slack_channel_ids</td><td>List of channel IDs to listen in (e.g. <code>["C01ABCDEF"]</code>).</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2>WhatsApp (Meta Cloud API)</h2>
|
|
||||||
<p>Connects huskies to WhatsApp Business via the Meta Cloud API. Requires a Meta Business account and a public webhook URL.</p>
|
|
||||||
|
|
||||||
<h3>Setup</h3>
|
|
||||||
<ol class="step-list">
|
|
||||||
<li><div>Create a Meta Business App at <a href="https://developers.facebook.com">developers.facebook.com</a>.</div></li>
|
|
||||||
<li><div>Add the WhatsApp product and get a Phone Number ID.</div></li>
|
|
||||||
<li><div>Generate a permanent access token.</div></li>
|
|
||||||
<li>
|
|
||||||
<div>
|
|
||||||
Register your webhook URL in Meta's dashboard:
|
|
||||||
<code>https://your-server/webhook/whatsapp</code>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div>
|
|
||||||
Copy the example config:
|
|
||||||
<pre><code>cp .huskies/bot.toml.whatsapp-meta.example .huskies/bot.toml</code></pre>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h3>bot.toml fields</h3>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Key</th><th>Description</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>whatsapp_provider</td><td>Set to <code>"meta"</code> for the Meta Cloud API.</td></tr>
|
|
||||||
<tr><td>whatsapp_phone_number_id</td><td>Phone Number ID from the Meta dashboard.</td></tr>
|
|
||||||
<tr><td>whatsapp_access_token</td><td>Permanent access token.</td></tr>
|
|
||||||
<tr><td>whatsapp_verify_token</td><td>Webhook verify token — must match what you set in Meta's dashboard.</td></tr>
|
|
||||||
<tr><td>whatsapp_allowed_phones</td><td>Optional. List of phone numbers allowed to interact (e.g. <code>["+15551234567"]</code>). When absent, all numbers are allowed.</td></tr>
|
|
||||||
<tr><td>whatsapp_notification_template</td><td>Optional. Name of the approved Meta message template for out-of-window notifications (default: <code>"pipeline_notification"</code>).</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2>WhatsApp (Twilio)</h2>
|
|
||||||
<p>An alternative WhatsApp integration using Twilio's WhatsApp API. Requires a Twilio account.</p>
|
|
||||||
<pre><code>cp .huskies/bot.toml.whatsapp-twilio.example .huskies/bot.toml</code></pre>
|
|
||||||
<p>Set <code>whatsapp_provider = "twilio"</code> and fill in your Twilio account SID, auth token, and phone numbers. The webhook URL is the same: <code>https://your-server/webhook/whatsapp</code>.</p>
|
|
||||||
|
|
||||||
<h2>Discord</h2>
|
|
||||||
<p>Connects huskies to Discord using the Discord Gateway WebSocket. No public webhook URL required — the bot connects outbound.</p>
|
|
||||||
|
|
||||||
<h3>Setup</h3>
|
|
||||||
<ol class="step-list">
|
|
||||||
<li><div>Create a Discord Application at <a href="https://discord.com/developers/applications">discord.com/developers/applications</a>.</div></li>
|
|
||||||
<li><div>Go to Bot, create a bot, and copy the token.</div></li>
|
|
||||||
<li><div>Enable <strong>Message Content Intent</strong> under Privileged Gateway Intents.</div></li>
|
|
||||||
<li><div>Go to OAuth2 → URL Generator, select the <code>bot</code> scope with permissions: Send Messages, Read Message History, Manage Messages.</div></li>
|
|
||||||
<li><div>Use the generated URL to invite the bot to your server.</div></li>
|
|
||||||
<li><div>Right-click target channels → Copy Channel ID (requires Developer Mode enabled in Discord settings).</div></li>
|
|
||||||
<li>
|
|
||||||
<div>
|
|
||||||
Copy the example config:
|
|
||||||
<pre><code>cp .huskies/bot.toml.discord.example .huskies/bot.toml</code></pre>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h3>bot.toml fields</h3>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Key</th><th>Description</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>discord_bot_token</td><td>Bot token from the Discord developer portal.</td></tr>
|
|
||||||
<tr><td>discord_channel_ids</td><td>List of channel IDs to listen in (e.g. <code>["123456789012345678"]</code>).</td></tr>
|
|
||||||
<tr><td>discord_allowed_users</td><td>Optional. Discord user IDs allowed to interact. When absent, all users in configured channels can interact.</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2 id="gateway-aggregated">Gateway: aggregated notifications</h2>
|
|
||||||
<p>When using <code>huskies --gateway</code>, you can configure the gateway bot to receive notifications from <strong>all</strong> registered projects in a single room. Events are prefixed with <code>[project-name]</code>.</p>
|
|
||||||
<p>No additional transport is required — the gateway aggregated stream works with any of the transports above. Configure the gateway's <code>.huskies/bot.toml</code> with your transport credentials and set <code>aggregated_notifications_enabled = true</code> (the default). See <a href="configuration.html#gateway-aggregated-stream">Configuration → Gateway aggregated stream</a> for the full reference.</p>
|
|
||||||
<div class="note">
|
|
||||||
<strong>No per-project changes needed:</strong> Adding a new project to <code>projects.toml</code> does not require editing per-project bot configs — the gateway picks it up automatically.
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="reveal r3">
|
|
||||||
<span>© 2026 Libby Labs Ltd.</span>
|
|
||||||
<a href="/privacy.html">Privacy Policy</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
@@ -1,34 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none">
|
|
||||||
<!-- Husky head - geometric/angular style -->
|
|
||||||
<!-- Ears -->
|
|
||||||
<path d="M60 85 L45 35 L85 65 Z" fill="#8892a8" stroke="#e8ecf4" stroke-width="1.5"/>
|
|
||||||
<path d="M140 85 L155 35 L115 65 Z" fill="#8892a8" stroke="#e8ecf4" stroke-width="1.5"/>
|
|
||||||
<!-- Inner ears -->
|
|
||||||
<path d="M62 78 L52 45 L80 65 Z" fill="#4a5568"/>
|
|
||||||
<path d="M138 78 L148 45 L120 65 Z" fill="#4a5568"/>
|
|
||||||
<!-- Head shape -->
|
|
||||||
<path d="M55 80 Q55 60 75 60 L125 60 Q145 60 145 80 L145 120 Q145 155 120 165 L110 170 Q100 175 90 170 L80 165 Q55 155 55 120 Z" fill="#8892a8" stroke="#e8ecf4" stroke-width="1.5"/>
|
|
||||||
<!-- Face mask (white marking) -->
|
|
||||||
<path d="M75 70 L100 65 L125 70 L120 110 L110 135 Q100 142 90 135 L80 110 Z" fill="#e8ecf4"/>
|
|
||||||
<!-- Forehead stripe -->
|
|
||||||
<path d="M92 65 L100 62 L108 65 L104 95 L100 100 L96 95 Z" fill="#8892a8"/>
|
|
||||||
<!-- Eyes -->
|
|
||||||
<ellipse cx="82" cy="95" rx="7" ry="7.5" fill="#080c15"/>
|
|
||||||
<ellipse cx="118" cy="95" rx="7" ry="7.5" fill="#080c15"/>
|
|
||||||
<!-- Eye shine - cyan to match brand -->
|
|
||||||
<circle cx="80" cy="93" r="2.5" fill="#22d3ee"/>
|
|
||||||
<circle cx="116" cy="93" r="2.5" fill="#22d3ee"/>
|
|
||||||
<!-- Iris detail -->
|
|
||||||
<ellipse cx="82" cy="95" rx="4" ry="4.5" fill="none" stroke="#22d3ee" stroke-width="0.5" opacity="0.4"/>
|
|
||||||
<ellipse cx="118" cy="95" rx="4" ry="4.5" fill="none" stroke="#22d3ee" stroke-width="0.5" opacity="0.4"/>
|
|
||||||
<!-- Nose -->
|
|
||||||
<path d="M95 120 Q100 115 105 120 Q105 126 100 128 Q95 126 95 120 Z" fill="#080c15"/>
|
|
||||||
<!-- Nose highlight -->
|
|
||||||
<ellipse cx="100" cy="120" rx="2" ry="1" fill="#4a5568"/>
|
|
||||||
<!-- Mouth -->
|
|
||||||
<path d="M100 128 L100 135" stroke="#4a5568" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<path d="M92 138 Q100 143 108 138" stroke="#4a5568" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
|
||||||
<!-- Cheek fur tufts -->
|
|
||||||
<path d="M55 105 Q48 110 50 120" stroke="#e8ecf4" stroke-width="1" fill="none" opacity="0.5"/>
|
|
||||||
<path d="M145 105 Q152 110 150 120" stroke="#e8ecf4" stroke-width="1" fill="none" opacity="0.5"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,157 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Huskies — Story-Driven Development for AI Agents</title>
|
|
||||||
<meta name="description" content="Huskies is an autonomous development pipeline that turns user stories into tested, shipped code using AI agents.">
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;500;600;700;800&family=Karla:ital,wght@0,300;0,400;0,500;1,300;1,400&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
|
|
||||||
<!-- Nav -->
|
|
||||||
<header class="reveal r1">
|
|
||||||
<a href="/" class="logo">huskies</a>
|
|
||||||
<nav>
|
|
||||||
<a href="#how">How it works</a>
|
|
||||||
<a href="#features">Features</a>
|
|
||||||
<div class="nav-dropdown">
|
|
||||||
<a href="#" class="nav-cta nav-dropdown-toggle">Start</a>
|
|
||||||
<div class="nav-dropdown-menu">
|
|
||||||
<a href="/docs/">Docs</a>
|
|
||||||
<a href="https://code.crashlabs.io/crashlabs/huskies">Source</a>
|
|
||||||
<a href="https://code.crashlabs.io/crashlabs/huskies/releases">Releases</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Hero -->
|
|
||||||
<section class="hero">
|
|
||||||
<div class="hero-graphic reveal r1">
|
|
||||||
<img src="husky.png" alt="" class="hero-husky">
|
|
||||||
</div>
|
|
||||||
<p class="hero-kicker reveal r1">Story-driven development</p>
|
|
||||||
<h1 class="reveal r2">Coding agents are huskies,<br>not <span class="glow">labradors.</span></h1>
|
|
||||||
<p class="hero-sub reveal r3">They're enthusiastic, sometimes wild, and they'll happily wander off on their own. But put them in a harness and they'll take you anywhere. Huskies is the harness — a story-driven pipeline that turns coding agents into a disciplined team.</p>
|
|
||||||
|
|
||||||
<!-- Pipeline viz -->
|
|
||||||
<div class="pipeline reveal r4">
|
|
||||||
<div class="pipe-stage">
|
|
||||||
<span class="pipe-dot"></span>
|
|
||||||
<span class="pipe-label">Story</span>
|
|
||||||
</div>
|
|
||||||
<span class="pipe-line"></span>
|
|
||||||
<div class="pipe-stage">
|
|
||||||
<span class="pipe-dot active"></span>
|
|
||||||
<span class="pipe-label">Implement</span>
|
|
||||||
</div>
|
|
||||||
<span class="pipe-line"></span>
|
|
||||||
<div class="pipe-stage">
|
|
||||||
<span class="pipe-dot"></span>
|
|
||||||
<span class="pipe-label">QA</span>
|
|
||||||
</div>
|
|
||||||
<span class="pipe-line"></span>
|
|
||||||
<div class="pipe-stage">
|
|
||||||
<span class="pipe-dot"></span>
|
|
||||||
<span class="pipe-label">Merge</span>
|
|
||||||
</div>
|
|
||||||
<span class="pipe-line"></span>
|
|
||||||
<div class="pipe-stage">
|
|
||||||
<span class="pipe-dot done"></span>
|
|
||||||
<span class="pipe-label">Done</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- How it works -->
|
|
||||||
<section id="how" class="how-section">
|
|
||||||
<h2 class="section-title reveal r5">How it works</h2>
|
|
||||||
<ol class="steps">
|
|
||||||
<li class="step reveal r5">
|
|
||||||
<span class="step-num">01</span>
|
|
||||||
<div class="step-body">
|
|
||||||
<h3>Write a story</h3>
|
|
||||||
<p>Describe what you want with acceptance criteria. From your IDE, a chat room, or WhatsApp.</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li class="step reveal r5">
|
|
||||||
<span class="step-num">02</span>
|
|
||||||
<div class="step-body">
|
|
||||||
<h3>Agent picks it up</h3>
|
|
||||||
<p>A coder agent creates a feature branch, implements the code, and writes tests against your criteria.</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li class="step reveal r6">
|
|
||||||
<span class="step-num">03</span>
|
|
||||||
<div class="step-body">
|
|
||||||
<h3>Quality gates run</h3>
|
|
||||||
<p>Linters, tests, and compilation checks run automatically. Nothing moves forward until everything passes.</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li class="step reveal r6">
|
|
||||||
<span class="step-num">04</span>
|
|
||||||
<div class="step-body">
|
|
||||||
<h3>Merge & land</h3>
|
|
||||||
<p>A merge agent resolves conflicts and squash-merges to your main branch. You review and accept.</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Features -->
|
|
||||||
<section id="features" class="features-section">
|
|
||||||
<h2 class="section-title reveal r7">Features</h2>
|
|
||||||
<div class="feature-grid">
|
|
||||||
<div class="feature reveal r7">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
|
|
||||||
</div>
|
|
||||||
<h3>The Harness</h3>
|
|
||||||
<p>Stories define the change. Tests define the truth. Code defines the reality. Every agent runs on rails — nothing ships without acceptance criteria.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature reveal r7">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98"/></svg>
|
|
||||||
</div>
|
|
||||||
<h3>The Pack</h3>
|
|
||||||
<p>Coder, QA, and merge agents work in parallel across isolated git worktrees. A coordinated pack, not a lone wolf. Configure agent count, models, and budgets.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature reveal r8">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
|
||||||
</div>
|
|
||||||
<h3>Chat Anywhere</h3>
|
|
||||||
<p>Control the pipeline from Matrix, WhatsApp, Slack, or the built-in web UI. Create stories, start agents, check status.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature reveal r8">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
|
||||||
</div>
|
|
||||||
<h3>You're the Musher</h3>
|
|
||||||
<p>Agents implement, test, and merge independently. You set the direction and approve what ships. Every story is traceable from request to release.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- CTA -->
|
|
||||||
<section class="cta-section reveal r8">
|
|
||||||
<h2>Interested?</h2>
|
|
||||||
<p>Huskies is built by <a href="https://crashlabs.io">Crash Labs</a>. Get in touch at <a href="mailto:hello@huskies.dev">hello@huskies.dev</a>.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer class="reveal r8">
|
|
||||||
<span>© 2026 Libby Labs Ltd.</span>
|
|
||||||
<a href="mailto:hello@huskies.dev">Get in touch</a>
|
|
||||||
<a href="privacy.html">Privacy Policy</a>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Privacy Policy — Huskies</title>
|
|
||||||
<link rel="stylesheet" href="style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
|
|
||||||
<header>
|
|
||||||
<h1><a href="/" style="color: inherit;">stor<span>kit</span></a></h1>
|
|
||||||
<p class="tagline">Privacy Policy</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<p><strong>Last updated:</strong> 25 March 2026</p>
|
|
||||||
|
|
||||||
<h2>Who we are</h2>
|
|
||||||
<p>Huskies is operated by Libby Labs Ltd ("we", "us", "our"), trading as Crashlabs. Our contact email is <a href="mailto:hello@huskies.dev">hello@huskies.dev</a>.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>What we collect</h2>
|
|
||||||
<p>When you interact with Huskies via WhatsApp, Slack, Matrix, or the web interface, we may collect:</p>
|
|
||||||
<p><strong>Messaging data:</strong> Your phone number or chat identifier and the content of messages you send to the bot. This is used solely to process your requests and maintain conversation context.</p>
|
|
||||||
<p><strong>Usage data:</strong> Basic server logs including timestamps and request metadata. We do not use analytics trackers on this website.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>How we use your data</h2>
|
|
||||||
<p>We use your data only to provide and improve the Huskies service. Specifically:</p>
|
|
||||||
<p>- To process commands and respond to your messages.<br>
|
|
||||||
- To maintain conversation history within active sessions.<br>
|
|
||||||
- To diagnose and fix technical issues.</p>
|
|
||||||
<p>We do not sell, rent, or share your personal data with third parties for marketing purposes.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Third-party services</h2>
|
|
||||||
<p>Messages sent via WhatsApp are processed through Meta's WhatsApp Business API or Twilio's messaging platform, subject to their respective privacy policies. Messages sent via Slack or Matrix pass through those platforms' infrastructure.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Data retention</h2>
|
|
||||||
<p>Conversation history is stored locally on our servers and retained only for the duration needed to maintain session context. We do not retain message data indefinitely.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Your rights</h2>
|
|
||||||
<p>You may request access to, correction of, or deletion of your personal data at any time by contacting us at <a href="mailto:hello@huskies.dev">hello@huskies.dev</a>.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Changes to this policy</h2>
|
|
||||||
<p>We may update this policy from time to time. Changes will be posted on this page with an updated date.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<p>© 2026 Libby Labs Ltd. All rights reserved. · <a href="/">Home</a></p>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
:root {
|
|
||||||
--bg: #080c15;
|
|
||||||
--surface: #0e1420;
|
|
||||||
--surface-hover: #131a28;
|
|
||||||
--border: #1a2235;
|
|
||||||
--text: #e8ecf4;
|
|
||||||
--text-secondary: #8892a8;
|
|
||||||
--text-dim: #4a5568;
|
|
||||||
--cyan: #22d3ee;
|
|
||||||
--cyan-dim: rgba(34, 211, 238, 0.07);
|
|
||||||
--cyan-glow: rgba(34, 211, 238, 0.15);
|
|
||||||
--display: 'Bricolage Grotesque', sans-serif;
|
|
||||||
--body: 'Karla', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
html { scroll-behavior: smooth; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: var(--body);
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
line-height: 1.6;
|
|
||||||
min-height: 100vh;
|
|
||||||
overflow-x: hidden;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
a { color: var(--cyan); text-decoration: none; transition: opacity 0.2s; }
|
|
||||||
a:hover { opacity: 0.7; }
|
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
@keyframes fadeUp {
|
|
||||||
from { opacity: 0; transform: translateY(18px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { box-shadow: 0 0 0 0 var(--cyan-glow); }
|
|
||||||
50% { box-shadow: 0 0 12px 4px var(--cyan-glow); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal {
|
|
||||||
opacity: 0;
|
|
||||||
animation: fadeUp 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
||||||
}
|
|
||||||
.r1 { animation-delay: 0.05s; }
|
|
||||||
.r2 { animation-delay: 0.15s; }
|
|
||||||
.r3 { animation-delay: 0.3s; }
|
|
||||||
.r4 { animation-delay: 0.5s; }
|
|
||||||
.r5 { animation-delay: 0.65s; }
|
|
||||||
.r6 { animation-delay: 0.8s; }
|
|
||||||
.r7 { animation-delay: 0.95s; }
|
|
||||||
.r8 { animation-delay: 1.1s; }
|
|
||||||
|
|
||||||
/* Layout */
|
|
||||||
.page {
|
|
||||||
max-width: 960px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.page { padding: 0 1.5rem; }
|
|
||||||
header {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
header nav {
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
header {
|
|
||||||
padding: 2rem 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
font-family: var(--display);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
header nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
header nav a {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
header nav a:hover { color: var(--text); opacity: 1; }
|
|
||||||
|
|
||||||
.nav-cta {
|
|
||||||
color: var(--cyan) !important;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-dropdown {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-dropdown-toggle {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Invisible bridge between toggle and menu so hover doesn't break */
|
|
||||||
.nav-dropdown::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
right: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-dropdown-menu {
|
|
||||||
display: none;
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 0.5rem);
|
|
||||||
right: 0;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
min-width: 140px;
|
|
||||||
z-index: 200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-dropdown-menu a {
|
|
||||||
display: block;
|
|
||||||
padding: 0.4rem 1rem;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-dropdown-menu a:hover {
|
|
||||||
background: var(--surface-hover);
|
|
||||||
color: var(--text);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-dropdown:hover .nav-dropdown-menu {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hero */
|
|
||||||
.hero {
|
|
||||||
padding: 10vh 0 6vh;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-graphic {
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-husky {
|
|
||||||
width: 160px;
|
|
||||||
height: auto;
|
|
||||||
filter: drop-shadow(0 0 40px rgba(34, 211, 238, 0.2));
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-kicker {
|
|
||||||
font-family: var(--display);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.18em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--cyan);
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero h1 {
|
|
||||||
font-family: var(--display);
|
|
||||||
font-size: clamp(2.5rem, 6vw, 4.2rem);
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.1;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
margin-bottom: 1.8rem;
|
|
||||||
max-width: 700px;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glow {
|
|
||||||
color: var(--cyan);
|
|
||||||
text-shadow: 0 0 30px var(--cyan-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-sub {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.8;
|
|
||||||
max-width: 520px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pipeline visualisation */
|
|
||||||
.pipeline {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0;
|
|
||||||
margin-top: 4rem;
|
|
||||||
padding: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pipe-stage {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pipe-dot {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 2px solid var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pipe-dot.active {
|
|
||||||
border-color: var(--cyan);
|
|
||||||
background: var(--cyan);
|
|
||||||
animation: pulse 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pipe-dot.done {
|
|
||||||
border-color: var(--text-dim);
|
|
||||||
background: var(--text-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pipe-label {
|
|
||||||
font-family: var(--display);
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pipe-stage:has(.active) .pipe-label {
|
|
||||||
color: var(--cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pipe-line {
|
|
||||||
width: 60px;
|
|
||||||
height: 1px;
|
|
||||||
background: var(--border);
|
|
||||||
margin: 0 0.5rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
|
||||||
.pipe-line { width: 30px; }
|
|
||||||
.pipe-label { font-size: 0.55rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sections */
|
|
||||||
.section-title {
|
|
||||||
font-family: var(--display);
|
|
||||||
font-size: 1.6rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* How it works */
|
|
||||||
.how-section {
|
|
||||||
padding: 5rem 0;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.steps {
|
|
||||||
list-style: none;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 56px 1fr;
|
|
||||||
gap: 1.5rem;
|
|
||||||
padding: 1.8rem 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step:first-child {
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-num {
|
|
||||||
font-family: var(--display);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-dim);
|
|
||||||
padding-top: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-body h3 {
|
|
||||||
font-family: var(--display);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-body p {
|
|
||||||
font-size: 0.88rem;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Features */
|
|
||||||
.features-section {
|
|
||||||
padding: 5rem 0;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1px;
|
|
||||||
background: var(--border);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature {
|
|
||||||
background: var(--surface);
|
|
||||||
padding: 2rem;
|
|
||||||
transition: background 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature:hover {
|
|
||||||
background: var(--surface-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-icon {
|
|
||||||
color: var(--cyan);
|
|
||||||
margin-bottom: 1.2rem;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature h3 {
|
|
||||||
font-family: var(--display);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature p {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.feature-grid { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CTA */
|
|
||||||
.cta-section {
|
|
||||||
padding: 5rem 0;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cta-section h2 {
|
|
||||||
font-family: var(--display);
|
|
||||||
font-size: 1.8rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cta-section p {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
footer {
|
|
||||||
padding: 2rem 0;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
footer a {
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer a:hover { color: var(--text-secondary); }
|
|
||||||
Reference in New Issue
Block a user