Compare commits

...

42 Commits

Author SHA1 Message Date
Timmy ce688fc0bf fix: drop package-lock.json + node_modules before npm install in Dockerfile
Previous attempt (c1318964) used npm ci + npm install --include=optional
--no-save, which still missed rolldown's platform-specific native
binding (@rolldown/binding-linux-arm64-gnu) — the runtime build still
fails with `Cannot find native binding`.

Wipe both the lockfile and node_modules so npm install resolves the
dependency tree fresh for the build platform.  The lockfile mutation
stays inside the container image.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:47:43 +01:00
Timmy c131896432 fix: work around npm optional-deps bug in frontend npm install
`npm ci` alone hits npm/cli#4828: optional platform-specific bindings
(e.g. @rolldown/binding-linux-arm64-gnu introduced by 1119's vite 5→8
upgrade) listed in package-lock.json for the lockfile author's
platform are not fetched for the build platform.  The sled rebuild
fails with `Cannot find native binding`.

Follow `npm ci` with `npm install --include=optional --no-save` so the
build platform's native binding is fetched without mutating the
lockfile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:46:55 +01:00
Timmy 42e6eec9e9 Bump version to 0.12.1 2026-05-17 23:46:50 +01:00
dave fe00fe6a25 huskies: merge 1127 story Migrate all LLM-invoking transports onto assemble_prompt_context; delete legacy Vec 2026-05-17 22:28:01 +00:00
dave c97b7c841f huskies: regen source-map.json 2026-05-17 21:02:08 +00:00
dave 2d0387fe63 huskies: merge 1126 story Gateway event aggregator with per-session scope filters (Timmy=All, Sally=single sled) 2026-05-17 21:02:08 +00:00
dave 71d3047ef0 huskies: regen source-map.json 2026-05-17 20:30:02 +00:00
dave d86cc38b2a huskies: merge 1128 story Bounded event queues + EventStreamGap sentinel + observability for context assembly 2026-05-17 20:30:02 +00:00
dave 21b2efd268 huskies: regen source-map.json 2026-05-17 20:09:33 +00:00
dave badd522d60 huskies: merge 1125 story LLM session entity + assemble_prompt_context helper, wired into Matrix bot 2026-05-17 20:09:33 +00:00
dave ecd3f600d9 huskies: merge 1130 story Adopted/launched project containers bind huskies to 127.0.0.1, unreachable from host MCP 2026-05-17 20:02:22 +00:00
Timmy 099df17e77 chore: gitignore /pipeline.db at repo root (phantom stale file)
A 0-byte pipeline.db sometimes appears at the repo root, left over
from old code paths. Current master correctly opens it at
.huskies/pipeline.db via project_root.join() in
server/src/startup/project.rs:280 — no relative-path opener exists.
This is purely defensive so any future regression doesn't sneak into
commits. Stops 1123 from being a coder task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:51:48 +01:00
dave c88e42eba2 huskies: regen source-map.json 2026-05-17 19:37:50 +00:00
dave 89058ebd49 huskies: merge 1124 story Persist TransitionFired into a per-sled CRDT event log 2026-05-17 19:37:50 +00:00
dave d8204ab7ed huskies: merge 1129 story find_free_port fallback returns unbindable port silently when range is exhausted 2026-05-17 19:24:29 +00:00
dave e2ea1af4c8 huskies: merge 1120 story Silence intentional-error stderr in frontend tests so failures stand out 2026-05-17 19:19:08 +00:00
dave 08780475d0 huskies: merge 1119 story Address npm audit moderate+ vulnerabilities in frontend/ 2026-05-17 19:00:55 +00:00
dave 6eb2742e7d huskies: regen source-map.json 2026-05-17 18:49:58 +00:00
dave c1b7e12b0b huskies: merge 1122 story Chat-bot switch command reads stale gateway_projects Vec instead of live gateway_projects_store 2026-05-17 18:49:58 +00:00
dave 53d44ff42a huskies: regen source-map.json 2026-05-17 18:43:43 +00:00
dave 6331dea8b0 huskies: merge 1121 story Remove the marketing website from the huskies OSS repo (now lives in huskies-server) 2026-05-17 18:43:43 +00:00
dave 240beec7de huskies: regen source-map.json 2026-05-17 17:48:44 +00:00
dave 7de167b21b huskies: merge 1116 story rebuild_and_restart loses pending CRDT ops by calling exec() before persistence channel drains 2026-05-17 17:48:44 +00:00
Timmy 49af014a84 fix: build frontend before cargo in script/test (merge gate self-heal)
Story 1113 added `#[derive(RustEmbed)] #[folder = "../frontend/dist"]`
plus a unit test that calls `EmbeddedAssets::iter()`.  The macro only
generates `iter()` when the folder exists at compile time, so the Rust
build now has a hard compile-time dependency on `frontend/dist/`.

`script/test` ran `cargo clippy` (line 48) before the frontend build
(line 53+).  In a fresh merge worktree with no `frontend/dist/`, clippy
failed immediately on the `iter()` call and the script exited before
`npm run build` ever ran — the gate could never self-heal.  Blocked
1116's merge today; would block every future merge.

Move the frontend build above all cargo invocations.  Verified by
running script/test in a fresh worktree with `node_modules` and
`frontend/dist` removed: 385/385 frontend tests + cargo tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:40:24 +01:00
dave 73cf1c6ff9 huskies: merge 1117 story MCP tool for adopt: expose new project --adopt as an MCP call 2026-05-17 16:42:06 +00:00
dave f8b1e14b74 huskies: merge 1118 story Automate per-project docker image builds (huskies-project-base + per-stack overlays) 2026-05-17 16:30:08 +00:00
Timmy 265e6f9a15 fix(1101): strip passing-test lines before classify() lint check; remove diagnostic
The merge gate classifier was matching trigger keywords like
`missing_doc_comments` inside passing-test name lines
(e.g. `test agents::gates::tests::classify_lint_from_missing_doc_comments ... ok`),
causing every gate failure to be mis-classified as Lint and bounced
back to a fixup coder. Strip `test … … ok` lines before scanning for
lint triggers. Also removes the temporary diagnostic block in
runner.rs that confirmed the bug.

Applied directly to master because the 1101 feature branch carried
stale work from an earlier incarnation of the story that semantically
conflicted with master's later diagnostic commit (`is_fixup` deleted
on the branch, referenced on master).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:52:26 +01:00
dave 40e995da88 huskies: regen source-map.json 2026-05-17 15:51:38 +00:00
dave 6e4fb7fd4b huskies: merge 1113 story [huskies-server repo] Convert static website to Next.js with static rendering 2026-05-17 15:51:37 +00:00
dave 0695ad7ae6 huskies: merge 1115 story new project: --adopt flow to wrap a container around an existing checkout 2026-05-17 15:17:12 +00:00
dave eb6b07531a huskies: merge 1114 story new project: --path flag to override default host directory 2026-05-17 14:48:49 +00:00
dave 2d6846fe03 huskies: merge 1112 story Remove static website from huskies OSS repo (moved to huskies-server) 2026-05-17 14:43:46 +00:00
Timmy a5bfd40233 Bump version to 0.12.0 2026-05-17 02:10:31 +01:00
dave a40500eea9 huskies: merge 1111 bug Test isolation: init_for_test() and ensure_content_store() are once-per-thread, not once-per-test, polluting CRDT state across tests 2026-05-17 00:33:45 +00:00
dave f8212f102f huskies: merge 1109 story Chat bootstrap Phase 4: --git clones an existing repo and configures push credentials 2026-05-17 00:18:25 +00:00
dave 59302b465d huskies: merge 1108 story Chat bootstrap Phase 3: SSH-remote editor access into the project container (any editor) 2026-05-16 23:37:59 +00:00
dave efafe44db1 huskies: merge 1110 story Chat bootstrap Phase 2b: additional stack overlays (Go, Python, Ruby, JVM) 2026-05-16 23:20:31 +00:00
dave 6a2f81e873 huskies: regen source-map.json 2026-05-16 23:01:49 +00:00
dave 3a43337735 huskies: merge 1107 story Chat bootstrap Phase 2a: stack-overlay framework + Rust and Node stack overlays 2026-05-16 23:01:49 +00:00
dave b6df89d24c huskies: regen source-map.json 2026-05-16 22:39:20 +00:00
dave 10d992a7e4 huskies: merge 1106 story Chat bootstrap Phase 1: new project chat command spawns a bare project container and registers it with the gateway 2026-05-16 22:39:20 +00:00
Timmy 5c63618b30 docs: chat-driven project bootstrap design overview
Captures the architecture for going from "new project" chat command to
a running, container-isolated, editor-accessible huskies project.
Covers the three personas (chat-only / editor-using / multi-project),
the container template (base + stack overlay + project bind mount),
build sandbox model (host stays clean, all dep-code in container),
editor-agnostic SSH access, git integration, and a 5-phase rollout.

Source for upcoming bootstrap stories.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:40:54 +01:00
96 changed files with 5525 additions and 4366 deletions
+2
View File
@@ -15,6 +15,8 @@ _merge_parsed.json
.huskies_port
.huskies/bot.toml.bak
.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)
PLAN.md
+1 -1
View File
@@ -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.
## 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
- Agent config: `.huskies/agents.toml` (preferred) or `[[agent]]` blocks in `.huskies/project.toml`
+51 -10
View File
@@ -856,6 +856,9 @@
"server/src/chat/commands/move_story.rs": [
"fn handle_move"
],
"server/src/chat/commands/new_project.rs": [
"fn handle_new_project_fallback"
],
"server/src/chat/commands/overview.rs": [
"fn handle_overview"
],
@@ -996,10 +999,10 @@
"fn handle_message"
],
"server/src/chat/transport/matrix/bot/messages/mod.rs": [
"fn format_user_prompt",
"fn format_drained_events"
"fn format_user_prompt"
],
"server/src/chat/transport/matrix/bot/messages/on_room_message.rs": [
"fn eval_switch_command",
"fn on_room_message"
],
"server/src/chat/transport/matrix/bot/mod.rs": [
@@ -1070,6 +1073,7 @@
"mod config",
"mod delete",
"mod htop",
"mod new_project",
"mod rebuild",
"mod reset",
"mod rmtree",
@@ -1077,6 +1081,13 @@
"mod transport_impl",
"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": [
"struct RebuildCommand",
"fn extract_rebuild_command",
@@ -1282,6 +1293,13 @@
"fn delete_agent_throttle",
"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": [
"fn write_gateway_project",
"fn read_all_gateway_projects",
@@ -1289,6 +1307,12 @@
"fn delete_gateway_project",
"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": [
"fn write_merge_job",
"fn read_all_merge_jobs",
@@ -1364,10 +1388,13 @@
"fn rebuild_active_agent_index",
"fn rebuild_test_job_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": [
"fn init"
"enum PersistMsg",
"fn init",
"fn flush_persistence"
],
"server/src/crdt_state/state/mod.rs": [
"fn subscribe",
@@ -1378,6 +1405,7 @@
"fn init_for_test"
],
"server/src/crdt_state/state/statics.rs": [
"static PERSIST_PENDING",
"static CRDT_EVENT_TX",
"static SYNC_TX",
"static ALL_OPS",
@@ -1393,6 +1421,12 @@
"struct CrdtEvent",
"struct GatewayConfigCrdt",
"struct PipelineDoc",
"struct EventLogEntryCrdt",
"struct LlmSessionCrdt",
"enum ScopeFilter",
"fn from_scope_str",
"fn to_scope_str",
"struct LlmSessionView",
"struct PipelineItemCrdt",
"struct NodePresenceCrdt",
"struct EpicId",
@@ -1583,6 +1617,14 @@
"fn backup_pre_pipeline_status",
"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": [
"fn build_gateway_route",
"fn run"
@@ -1594,11 +1636,6 @@
"server/src/http/agents_sse.rs": [
"fn agent_stream"
],
"server/src/http/assets.rs": [
"fn embedded_asset",
"fn embedded_file",
"fn embedded_index"
],
"server/src/http/context.rs": [
"enum PermissionDecision",
"struct PermissionForward",
@@ -1831,7 +1868,6 @@
],
"server/src/http/mod.rs": [
"mod agents_sse",
"mod assets",
"mod context",
"mod events",
"mod identity",
@@ -2164,6 +2200,9 @@
"struct CompletionResponse",
"trait ModelProvider"
],
"server/src/llm_session/mod.rs": [
"fn assemble_prompt_context"
],
"server/src/log_buffer.rs": [
"enum LogLevel",
"fn as_str",
@@ -2184,7 +2223,9 @@
"mod crdt_state",
"mod crdt_sync",
"mod crdt_wire",
"mod event_log",
"mod gateway",
"mod llm_session",
"mod log_buffer",
"mod mesh",
"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
View File
@@ -1911,7 +1911,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "huskies"
version = "0.11.1"
version = "0.12.1"
dependencies = [
"ammonia",
"async-stream",
@@ -1931,7 +1931,6 @@ dependencies = [
"libc",
"libsqlite3-sys",
"matrix-sdk",
"mime_guess",
"mockito",
"notify",
"nutype",
@@ -1941,7 +1940,6 @@ dependencies = [
"rand 0.10.1",
"regex",
"reqwest",
"rust-embed",
"serde",
"serde_json",
"serde_urlencoded",
@@ -2978,16 +2976,6 @@ version = "0.1.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "miniz_oxide"
version = "0.8.9"
@@ -4206,40 +4194,6 @@ dependencies = [
"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]]
name = "rustc-hash"
version = "2.1.2"
+4
View File
@@ -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.
## Website
The huskies.dev website source has moved to [crashlabs/huskies-server](https://code.crashlabs.io/crashlabs/huskies-server).
## Architecture
Internal architecture documentation lives in [`docs/architecture/`](docs/architecture/):
+11 -2
View File
@@ -46,8 +46,17 @@ WORKDIR /app
# build.rs) can produce the release binary with embedded frontend assets.
COPY . .
# Build frontend deps first (better layer caching)
RUN cd frontend && npm ci
# Build frontend deps first (better layer caching).
# 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)
RUN cargo build --release \
+67
View File
@@ -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"]
+30
View File
@@ -1,6 +1,22 @@
#!/bin/sh
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 ─────────────────────────────────────────────────────
# Agents commit code inside the container. Without a git identity,
# 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_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 ────────────────────────────────────────────
# The project repo is bind-mounted from the host, so node_modules/
# may contain native binaries for the wrong platform (e.g. darwin
+28
View File
@@ -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
+4
View File
@@ -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
+50
View File
@@ -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
+6
View File
@@ -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
+26
View File
@@ -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
+7
View File
@@ -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
+27
View File
@@ -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
+6
View File
@@ -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
+28
View File
@@ -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
+4
View File
@@ -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
+37
View File
@@ -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
+4
View File
@@ -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
+798 -1068
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "huskies",
"private": true,
"version": "0.11.1",
"version": "0.12.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -32,11 +32,11 @@
"@types/node": "^25.0.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"@vitest/coverage-v8": "^2.1.9",
"@vitejs/plugin-react": "^5.2.0",
"@vitest/coverage-v8": "^4.1.6",
"jsdom": "^28.1.0",
"typescript": "~5.8.3",
"vite": "^5.4.21",
"vitest": "^2.1.4"
"vite": "^8.0.13",
"vitest": "^4.1.6"
}
}
+2
View File
@@ -160,6 +160,7 @@ describe("App", () => {
});
it("shows error when openProject fails", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockedApi.openProject.mockRejectedValue(new Error("Path does not exist"));
await renderApp();
@@ -182,6 +183,7 @@ describe("App", () => {
await waitFor(() => {
expect(screen.getByText(/Path does not exist/)).toBeInTheDocument();
});
errorSpy.mockRestore();
});
it("shows known projects list", async () => {
+2
View File
@@ -266,6 +266,8 @@ describe("subscribeAgentStream", () => {
});
it("handles malformed JSON without throwing", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
subscribeAgentStream("42_story_test", "coder", vi.fn());
expect(() => {
@@ -472,9 +472,16 @@ describe("Slash command handling (Story 374)", () => {
});
describe("Story 1058: WebSocket errors do not appear in chat", () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
capturedWsHandlers = null;
setupMocks();
consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
});
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 () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockListDir.mockRejectedValue(new Error("Permission denied"));
const { result } = renderHook(() =>
@@ -242,9 +243,13 @@ describe("usePathCompletion hook", () => {
await waitFor(() => {
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 () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockListDir.mockRejectedValue("some string error");
const { result } = renderHook(() =>
@@ -262,6 +267,9 @@ describe("usePathCompletion hook", () => {
"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 () => {
+37
View File
@@ -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."
+2
View File
@@ -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 up -d
script/build-project-images $CACHE_FLAG
echo "Rebuild complete. Logs: docker compose -f docker/docker-compose.yml logs -f"
+12 -10
View File
@@ -11,10 +11,12 @@ export GIT_CONFIG_VALUE_0=master
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Ordered fail-fast: cheapest deterministic checks first, slowest builds and
# test suites last. `set -euo pipefail` aborts at the first failure, so a fmt
# or clippy drift never wastes time on a frontend build or a multi-minute
# test run.
# Ordered fail-fast: cheapest deterministic checks first. The frontend build
# must run *before* anything that compiles Rust, because story 1113 introduced
# a compile-time dependency on `frontend/dist/` via `rust-embed` — a fresh
# 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 ==="
if cargo fmt --version &>/dev/null; then
@@ -44,12 +46,6 @@ if [ "$_dup_found" -eq 1 ]; then
exit 1
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 ==="
if [ -d "$PROJECT_ROOT/frontend" ]; then
cd "$PROJECT_ROOT/frontend"
@@ -75,6 +71,12 @@ else
echo "Skipping frontend build (no frontend directory)"
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 ==="
cargo test --manifest-path "$PROJECT_ROOT/Cargo.toml" --bin huskies
cargo test --manifest-path "$PROJECT_ROOT/Cargo.toml" -p source-map-gen
+1 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "huskies"
version = "0.11.1"
version = "0.12.1"
edition = "2024"
build = "build.rs"
@@ -13,12 +13,10 @@ chrono-tz = { workspace = true }
futures = { workspace = true }
homedir = { workspace = true }
ignore = { workspace = true }
mime_guess = { workspace = true }
notify = { workspace = true }
poem = { workspace = true, features = ["websocket"] }
portable-pty = { workspace = true }
reqwest = { workspace = true, features = ["json", "stream", "form"] }
rust-embed = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_urlencoded = { workspace = true }
+29 -4
View File
@@ -33,16 +33,28 @@ impl GateFailureKind {
/// 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.
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:") {
GateFailureKind::ContentConflict
} else if output.contains("Diff in ") || output.contains("would reformat") {
GateFailureKind::Fmt
} else if output.contains("missing-docs direction") {
GateFailureKind::SourceMapCheck
} else if output.contains("error[clippy::")
|| output.contains("warning[clippy::")
|| output.contains("missing_doc_comments")
{
} else if is_lint {
GateFailureKind::Lint
} else if output.contains("error[E") {
// 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]
fn classify_source_map_check_from_missing_docs_direction() {
assert_eq!(
@@ -186,50 +186,6 @@ impl AgentPool {
.map(|k| k.is_self_evident_fix())
.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 {
let reason = kind.display_reason();
if let Err(e) = crate::agents::lifecycle::transition_to_blocked(&sid, &reason) {
+6
View File
@@ -19,6 +19,7 @@ mod help;
pub(crate) mod loc;
mod logs;
mod move_story;
mod new_project;
mod overview;
mod run_tests;
mod setup;
@@ -262,6 +263,11 @@ pub fn commands() -> &'static [BotCommand] {
description: "List orphaned worktrees (dry run), or `cleanup_worktrees --confirm` to remove them",
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,
},
]
}
+19
View File
@@ -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
}
+55 -2
View File
@@ -300,6 +300,20 @@ pub(super) async fn handle_incoming_message(
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.
async fn handle_llm_message(ctx: &DiscordContext, channel: &str, user: &str, user_message: &str) {
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 prompt = format!(
"[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n\n{user}: {user_message}"
let prompt = build_discord_llm_prompt(
resume_session_id.as_deref().unwrap_or(channel),
bot_name,
user,
user_message,
);
let provider = ClaudeCodeProvider::new();
@@ -604,4 +621,40 @@ mod tests {
assert!(conv.session_id.is_none(), "session_id 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).
use crate::chat::ChatTransport;
use crate::service::gateway::config::ProjectEntry;
use crate::service::timer::TimerStore;
use crate::services::Services;
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).
/// `None` in standalone single-project mode.
pub gateway_active_project: Option<Arc<RwLock<String>>>,
/// In gateway mode: valid project names accepted by the `switch` command.
/// Empty in standalone mode.
pub gateway_projects: Vec<String>,
/// 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`).
/// Empty in standalone mode.
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
/// transition. `handle_message` drains this buffer and injects it as a
/// `<system-reminder>` block at the head of the next user prompt so Timmy
/// 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>>>,
/// The `new project` command writes here so HTTP handlers see the new entry
/// immediately without requiring a gateway restart. `None` in standalone mode.
pub gateway_projects_store: Option<Arc<RwLock<BTreeMap<String, ProjectEntry>>>>,
/// Bounded FIFO set of already-handled incoming event IDs.
///
/// The Matrix sync loop can replay events on reconnect. This set ensures
@@ -277,7 +266,6 @@ mod tests {
fn test_bot_context(
services: Arc<Services>,
gateway_active_project: Option<Arc<RwLock<String>>>,
gateway_projects: Vec<String>,
gateway_project_urls: BTreeMap<String, String>,
) -> BotContext {
BotContext {
@@ -298,10 +286,8 @@ mod tests {
std::path::PathBuf::from("/tmp/timers.json"),
)),
gateway_active_project,
gateway_projects,
gateway_project_urls,
pending_pipeline_events: Arc::new(TokioMutex::new(Vec::new())),
pending_gateway_events: Arc::new(TokioMutex::new(Vec::new())),
gateway_projects_store: None,
handled_incoming_event_ids: Arc::new(TokioMutex::new(SeenEventIds::new(
SEEN_EVENT_IDS_CAP,
))),
@@ -318,7 +304,7 @@ mod tests {
#[tokio::test]
async fn effective_project_root_standalone_returns_project_root() {
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!(
ctx.effective_project_root().await,
PathBuf::from("/projects/myapp")
@@ -332,7 +318,6 @@ mod tests {
let ctx = test_bot_context(
services,
Some(Arc::clone(&active)),
vec!["huskies".into(), "robot-studio".into()],
BTreeMap::from([
("huskies".into(), "http://localhost:3001".into()),
("robot-studio".into(), "http://localhost:3002".into()),
@@ -351,7 +336,6 @@ mod tests {
let ctx = test_bot_context(
services,
Some(Arc::clone(&active)),
vec!["huskies".into(), "robot-studio".into()],
BTreeMap::from([
("huskies".into(), "http://localhost:3001".into()),
("robot-studio".into(), "http://localhost:3002".into()),
@@ -432,7 +416,7 @@ mod tests {
#[test]
fn bot_context_has_no_require_verified_devices_field() {
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();
}
@@ -482,7 +466,6 @@ mod tests {
let ctx = test_bot_context(
services,
Some(Arc::clone(&active)),
vec!["huskies".into()],
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::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(
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())
};
// Drain pipeline and gateway transition events buffered since the last LLM
// turn and prepend them as a passive <system-reminder> block so Timmy sees
// pipeline activity without requiring a separate message. Sled events come
// from `pending_pipeline_events`; gateway events from `pending_gateway_events`.
// 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
};
// Pull new pipeline-transition events from the CRDT event log for this
// session and atomically advance the high-water marks so the same events
// are not re-injected on the next turn.
let event_log_ctx = crate::llm_session::assemble_prompt_context(&room_id_str);
// The prompt is just the current message with sender attribution.
// 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()
};
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)
);
@@ -11,27 +11,6 @@ pub(super) fn format_user_prompt(sender: &str, message: &str) -> String {
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
#[cfg(test)]
mod tests {
@@ -72,49 +51,6 @@ mod tests {
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 -------------------------------------------
#[test]
@@ -19,6 +19,31 @@ use super::super::verification::check_sender_verified;
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(
ev: OriginalSyncRoomMessageEvent,
room: Room,
@@ -193,7 +218,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
if ctx.is_gateway() {
// Commands that are meaningful on the gateway itself (no project state needed).
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(
&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.
}
// 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
// the LLM. All commands are registered in commands.rs — no special-casing
// 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") {
let response = if arg.is_empty() {
let available = ctx.gateway_projects.join(", ");
format!("Usage: `switch <project>`. Available projects: {available}")
} else if ctx.gateway_projects.iter().any(|p| p == &arg) {
*active_project.write().await = arg.clone();
crate::crdt_state::write_gateway_active_project(&arg);
format!("Switched to project **{arg}**.")
let response = if let Some(ref store) = ctx.gateway_projects_store {
eval_switch_command(&arg, active_project, store).await
} else {
let available = ctx.gateway_projects.join(", ");
format!("Unknown project `{arg}`. Available: {available}")
"Switch is unavailable: project store not initialised.".to_string()
};
let html = markdown_to_html(&response);
if let Ok(msg_id) = ctx
@@ -661,3 +723,80 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
.chat_dispatcher
.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}"
);
}
}
+12 -175
View File
@@ -28,8 +28,14 @@ pub async fn run_bot(
watcher_tx: tokio::sync::broadcast::Sender<crate::io::watcher::WatcherEvent>,
shutdown_rx: watch::Receiver<Option<crate::rebuild::ShutdownReason>>,
gateway_active_project: Option<Arc<RwLock<String>>>,
gateway_projects: Vec<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>,
gateway_event_rx: Option<
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
// between Timmy's turns. Replay events (before == after stage label) are
// silently dropped — only real transitions are recorded.
let pending_pipeline_events: Arc<TokioMutex<Vec<String>>> =
Arc::new(TokioMutex::new(Vec::new()));
{
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 (~530 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
};
// Pipeline-transition context is now delivered to the LLM via
// `assemble_prompt_context` (CRDT event log) rather than these in-memory
// buffers, so the buffer tasks are gone; only the forwarder remains.
let gateway_event_rx_for_forwarder = gateway_event_rx.map(|rx| rx.resubscribe());
let ctx = BotContext {
services,
@@ -397,10 +321,8 @@ pub async fn run_bot(
transport: Arc::clone(&transport),
timer_store,
gateway_active_project,
gateway_projects,
gateway_project_urls,
pending_pipeline_events,
pending_gateway_events,
gateway_projects_store,
handled_incoming_event_ids: Arc::new(TokioMutex::new(super::context::SeenEventIds::new(
super::context::SEEN_EVENT_IDS_CAP,
))),
@@ -620,89 +542,4 @@ mod tests {
assert_eq!(steps[2], 20);
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).
#[serde(default = "default_coalesce_window_ms")]
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>,
}
+10 -2
View File
@@ -27,6 +27,8 @@ pub(crate) mod config;
pub mod delete;
/// htop-style agent monitor command — renders a live process table in Matrix.
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.
pub mod rebuild;
/// Reset command — handles `!reset` bot commands to restart the server state.
@@ -79,8 +81,14 @@ pub fn spawn_bot(
services: Arc<Services>,
shutdown_rx: watch::Receiver<Option<ShutdownReason>>,
gateway_active_project: Option<Arc<RwLock<String>>>,
gateway_projects: Vec<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>,
gateway_event_rx: Option<
tokio::sync::broadcast::Receiver<crate::service::gateway::GatewayStatusEvent>,
@@ -120,8 +128,8 @@ pub fn spawn_bot(
watcher_tx,
shutdown_rx,
gateway_active_project,
gateway_projects,
gateway_project_urls,
gateway_projects_store,
timer_store,
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 event_ctx = crate::llm_session::assemble_prompt_context(
resume_session_id.as_deref().unwrap_or(channel),
);
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();
@@ -27,8 +27,10 @@ pub(super) async fn handle_llm_message(
};
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!(
"[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();
+176
View File
@@ -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, &current_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,
})
}
+7
View File
@@ -14,7 +14,9 @@ use bft_json_crdt::op::OpId;
mod active_agents;
mod agent_throttle;
mod event_log;
mod gateway_projects;
mod llm_sessions;
mod merge_jobs;
mod test_jobs;
mod tokens;
@@ -28,9 +30,14 @@ pub use active_agents::{
pub use 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::{
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 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};
+13 -9
View File
@@ -28,12 +28,14 @@ mod write;
pub use gateway_config::{read_gateway_active_project, write_gateway_active_project};
pub use lww_maps::{
delete_active_agent, delete_agent_throttle, delete_gateway_project, delete_merge_job,
delete_test_job, delete_token_usage, read_active_agent, read_agent_throttle,
read_all_active_agents, read_all_agent_throttles, read_all_gateway_projects,
read_all_merge_jobs, read_all_test_jobs, read_all_token_usage, read_gateway_project,
read_merge_job, read_test_job, read_token_usage, write_active_agent, write_agent_throttle,
write_gateway_project, write_merge_job, write_test_job, write_token_usage,
EventLogEntryRaw, GAP_PIPELINE_EVENT, append_event_log_entry, append_gap_log_entry,
assemble_and_advance_session, delete_active_agent, delete_agent_throttle,
delete_gateway_project, delete_merge_job, delete_test_job, delete_token_usage,
read_active_agent, read_agent_throttle, read_all_active_agents, read_all_agent_throttles,
read_all_event_log_entries, read_all_gateway_projects, read_all_merge_jobs, read_all_test_jobs,
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 presence::{
@@ -45,12 +47,14 @@ pub use read::{
dep_is_archived_crdt, dep_is_done_crdt, dump_crdt_state, evict_item, is_tombstoned,
read_all_items, read_item, tombstoned_ids,
};
pub(crate) use state::flush_persistence;
pub use state::{init, subscribe};
pub use types::{
ActiveAgentCrdt, ActiveAgentView, AgentThrottleCrdt, AgentThrottleView, CrdtEvent, EpicId,
GatewayConfigCrdt, GatewayProjectCrdt, GatewayProjectView, MergeJobCrdt, MergeJobView,
NodePresenceCrdt, NodePresenceView, PipelineDoc, PipelineItemCrdt, PipelineItemView,
TestJobCrdt, TestJobView, TokenUsageCrdt, TokenUsageView, WorkItem,
EventLogEntryCrdt, GatewayConfigCrdt, GatewayProjectCrdt, GatewayProjectView, LlmSessionCrdt,
LlmSessionView, MergeJobCrdt, MergeJobView, NodePresenceCrdt, NodePresenceView, PipelineDoc,
PipelineItemCrdt, PipelineItemView, ScopeFilter, TestJobCrdt, TestJobView, TokenUsageCrdt,
TokenUsageView, WorkItem,
};
pub use write::{
bump_retry_count, migrate_legacy_stage_strings, migrate_merge_job, migrate_names_from_slugs,
+13 -5
View File
@@ -2,6 +2,7 @@
#![allow(unused_imports, dead_code)]
use std::collections::HashMap;
use std::sync::atomic::Ordering;
use super::hex;
use bft_json_crdt::json_crdt::*;
@@ -10,9 +11,10 @@ use tokio::sync::broadcast;
use super::VectorClock;
use super::state::{
SYNC_TX, all_ops_lock, apply_and_persist, emit_event, get_crdt, rebuild_active_agent_index,
rebuild_agent_throttle_index, rebuild_index, rebuild_merge_job_index, rebuild_node_index,
rebuild_test_job_index, rebuild_token_index, track_op, vector_clock_lock,
PERSIST_PENDING, PersistMsg, SYNC_TX, all_ops_lock, apply_and_persist, emit_event, get_crdt,
rebuild_active_agent_index, rebuild_agent_throttle_index, rebuild_index,
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 crate::slog;
@@ -116,9 +118,15 @@ pub fn apply_remote_op(op: SignedOp) -> bool {
}
// 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!(
"[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."
);
}
+9 -1
View File
@@ -6,7 +6,9 @@ use std::collections::HashMap;
use bft_json_crdt::json_crdt::*;
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};
// ── Debug dump ───────────────────────────────────────────────────────
@@ -44,6 +46,8 @@ pub struct CrdtStateDump {
pub max_seq_in_list: u64,
/// Count of ops in the ALL_OPS journal (persisted ops replayed at startup).
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>,
}
@@ -61,6 +65,7 @@ pub fn dump_crdt_state(story_id_filter: Option<&str>) -> CrdtStateDump {
let persisted_ops_count = all_ops_lock()
.and_then(|m| m.lock().ok().map(|v| v.len()))
.unwrap_or(0);
let pending_persist_ops_count = PERSIST_PENDING.load(Ordering::Relaxed);
let Some(state_mutex) = get_crdt() else {
return CrdtStateDump {
@@ -69,6 +74,7 @@ pub fn dump_crdt_state(story_id_filter: Option<&str>) -> CrdtStateDump {
total_ops_in_list: 0,
max_seq_in_list: 0,
persisted_ops_count,
pending_persist_ops_count,
items: Vec::new(),
};
};
@@ -80,6 +86,7 @@ pub fn dump_crdt_state(story_id_filter: Option<&str>) -> CrdtStateDump {
total_ops_in_list: 0,
max_seq_in_list: 0,
persisted_ops_count,
pending_persist_ops_count,
items: Vec::new(),
};
};
@@ -179,6 +186,7 @@ pub fn dump_crdt_state(story_id_filter: Option<&str>) -> CrdtStateDump {
total_ops_in_list,
max_seq_in_list,
persisted_ops_count,
pending_persist_ops_count,
items,
}
}
+10 -2
View File
@@ -5,11 +5,13 @@
//! it to the live document, sends it to the persistence channel, and broadcasts
//! it to sync peers via [`super::SYNC_TX`].
use std::sync::atomic::Ordering;
use bft_json_crdt::json_crdt::JsonValue;
use bft_json_crdt::op::Op;
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
/// persistence channel. The closure receives `&mut CrdtState` so it can
@@ -21,7 +23,13 @@ where
let raw_op = op_fn(state);
let signed = raw_op.sign(&state.keypair);
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 {
"Delete"
} else {
+13
View File
@@ -113,3 +113,16 @@ pub(in crate::crdt_state) fn rebuild_gateway_project_index(
}
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
}
+66 -6
View File
@@ -8,25 +8,34 @@
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::Mutex;
use std::sync::atomic::Ordering;
use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue, SignedOp};
use bft_json_crdt::keypair::{Ed25519KeyPair, make_keypair};
use sqlx::SqlitePool;
use sqlx::sqlite::SqliteConnectOptions;
use tokio::sync::{broadcast, mpsc};
use tokio::sync::{broadcast, mpsc, oneshot};
use super::super::VectorClock;
use super::super::hex;
use super::super::types::{CrdtEvent, PipelineDoc};
use super::indices::{
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_token_index,
rebuild_index, rebuild_llm_session_index, rebuild_merge_job_index, rebuild_node_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 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.
///
/// 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 agent_throttle_index = rebuild_agent_throttle_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
// 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.agent_throttle.advance_seq(lamport_floor);
crdt.doc.gateway_projects.advance_seq(lamport_floor);
crdt.doc.llm_sessions.advance_seq(lamport_floor);
crdt.doc
.gateway_config
.active_project
@@ -119,14 +130,18 @@ pub async fn init(db_path: &Path) -> Result<(), sqlx::Error> {
);
// 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 {
while let Some(op) = persist_rx.recv().await {
while let Some(msg) = persist_rx.recv().await {
match msg {
PersistMsg::Op(op) => {
let op = *op;
let op_json = match serde_json::to_string(&op) {
Ok(j) => j,
Err(e) => {
slog!("[crdt] Failed to serialize op: {e}");
PERSIST_PENDING.fetch_sub(1, Ordering::Relaxed);
continue;
}
};
@@ -149,6 +164,13 @@ pub async fn init(db_path: &Path) -> Result<(), sqlx::Error> {
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(());
}
}
}
});
@@ -163,6 +185,7 @@ pub async fn init(db_path: &Path) -> Result<(), sqlx::Error> {
test_job_index,
agent_throttle_index,
gateway_project_index,
llm_session_index,
persist_tx,
lamport_floor,
tombstones,
@@ -181,6 +204,43 @@ pub async fn init(db_path: &Path) -> Result<(), sqlx::Error> {
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.
async fn load_or_create_keypair(pool: &SqlitePool) -> Result<Ed25519KeyPair, sqlx::Error> {
let row: Option<(Vec<u8>,)> =
+30 -18
View File
@@ -27,6 +27,7 @@ mod tests;
// ── Re-exports for crdt_state siblings ──────────────────────────────
pub use init::init;
pub(crate) use init::{PersistMsg, flush_persistence};
/// 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 indices::{
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_token_index,
rebuild_index, rebuild_llm_session_index, rebuild_merge_job_index, rebuild_node_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(crate) use statics::{all_ops_lock, vector_clock_lock};
// ── CrdtState struct ─────────────────────────────────────────────────
@@ -66,8 +67,10 @@ pub(super) struct CrdtState {
pub(super) agent_throttle_index: HashMap<String, usize>,
/// Maps project name → index in the gateway_projects ListCrdt for O(1) lookup.
pub(super) gateway_project_index: HashMap<String, usize>,
/// Channel sender for fire-and-forget op persistence.
pub(super) persist_tx: mpsc::UnboundedSender<SignedOp>,
/// Maps session_id → index in the llm_sessions ListCrdt for O(1) lookup.
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.
///
/// Newly-created registers (post-init) must have their Lamport clock
@@ -122,23 +125,18 @@ pub(super) fn get_crdt() -> Option<&'static Mutex<CrdtState>> {
/// 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
/// 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)]
pub fn init_for_test() {
// Initialise thread-local CRDT for test isolation.
// Only creates a new CRDT if one isn't set yet on this thread;
// subsequent calls are no-ops (matching the old OnceLock semantics
// while keeping each thread isolated).
CRDT_STATE_TL.with(|lock| {
if lock.get().is_none() {
let keypair = make_keypair();
let crdt = BaseCrdt::<PipelineDoc>::new(&keypair);
let (persist_tx, rx) = mpsc::unbounded_channel();
let (persist_tx, rx) = mpsc::unbounded_channel::<init::PersistMsg>();
// 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 state = CrdtState {
let fresh = CrdtState {
crdt,
keypair,
index: HashMap::new(),
@@ -149,22 +147,36 @@ pub fn init_for_test() {
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(),
};
let _ = lock.set(Mutex::new(state));
CRDT_STATE_TL.with(|lock| {
if let Some(mutex) = lock.get() {
// Already set on this thread — replace contents so the second
// (and subsequent) test on the same thread starts clean.
*mutex.lock().unwrap() = fresh;
} else {
let _ = lock.set(Mutex::new(fresh));
}
});
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);
// Per-thread op journal + vector clock — keeps parallel tests' writes
// from corrupting each other's view of ALL_OPS (notably, one thread's
// `apply_compaction` could otherwise prune another thread's ops).
// Per-thread op journal + vector clock — always cleared so a second test
// on the same thread cannot see ops written by the first.
statics::ALL_OPS_TL.with(|lock| {
if let Some(mutex) = lock.get() {
mutex.lock().unwrap().clear();
} else {
let _ = lock.set(Mutex::new(Vec::new()));
}
});
statics::VECTOR_CLOCK_TL.with(|lock| {
if let Some(mutex) = lock.get() {
mutex.lock().unwrap().clear();
} else {
let _ = lock.set(Mutex::new(VectorClock::new()));
}
});
}
+9
View File
@@ -10,6 +10,7 @@
//! tests do not share `ALL_OPS` — preventing one test's `apply_compaction`
//! from pruning another test's freshly-written ops.
use std::sync::atomic::AtomicUsize;
use std::sync::{Mutex, OnceLock};
use bft_json_crdt::json_crdt::SignedOp;
@@ -19,6 +20,14 @@ use super::super::VectorClock;
use super::super::hex;
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.).
pub(super) static CRDT_EVENT_TX: OnceLock<broadcast::Sender<CrdtEvent>> = OnceLock::new();
+104 -2
View File
@@ -6,6 +6,7 @@
use super::super::hex;
use super::super::read::extract_item_view;
use super::super::types::PipelineDoc;
use super::init::PersistMsg;
use super::*;
use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue, SignedOp};
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() {
let kp = make_keypair();
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 {
crdt,
@@ -235,6 +236,7 @@ fn persist_tx_send_failure_logs_warn_with_op_type_and_seq() {
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: 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() {
let kp = make_keypair();
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 {
crdt,
@@ -309,6 +311,7 @@ fn persist_tx_send_success_emits_no_warn() {
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: std::collections::HashSet::new(),
@@ -485,3 +488,102 @@ async fn restart_new_register_resumes_from_lamport_floor() {
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}"
);
}
+115
View File
@@ -46,6 +46,121 @@ pub struct PipelineDoc {
pub agent_throttle: ListCrdt<AgentThrottleCrdt>,
pub gateway_projects: ListCrdt<GatewayProjectCrdt>,
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.
+43 -2
View File
@@ -165,7 +165,9 @@ pub fn delete_content(key: ContentKey<'_>) {
/// 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() {
#[cfg(not(test))]
{
@@ -175,7 +177,11 @@ pub fn ensure_content_store() {
#[cfg(test)]
{
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()));
}
});
@@ -203,6 +209,41 @@ pub(super) fn init_content_store(map: HashMap<String, String>) {
mod tests {
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
/// `ContentKey::GateOutput` (and vice versa). The typed key namespace, not
/// runtime substring matching, enforces the separation.
+6
View File
@@ -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());
// 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();
write_content(ContentKey::Story(story_id), content);
+320
View File
@@ -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}");
}
}
+1 -9
View File
@@ -62,13 +62,6 @@ pub fn build_gateway_route(state_arc: Arc<GatewayState>) -> impl poem::Endpoint
"/gateway/agents/:id/assign",
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)
}
@@ -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.
let gateway_projects: Vec<String> = state_arc.projects.read().await.keys().cloned().collect();
let gateway_project_urls: std::collections::BTreeMap<String, String> = state_arc
.projects
.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(
&config_dir,
Arc::clone(&state_arc.active_project),
gateway_projects,
gateway_project_urls,
Arc::clone(&state_arc.projects),
port,
Some(state_arc.event_tx.clone()),
Arc::clone(&state_arc.perm_rx),
+6
View File
@@ -1175,6 +1175,8 @@ async fn ws_only_sled_handles_tools_list_and_tools_call() {
ProjectEntry {
url: None,
auth_token: Some("secret".into()),
ssh_port: None,
host_path: None,
},
);
let config = GatewayConfig {
@@ -1244,6 +1246,8 @@ async fn two_concurrent_sleds_are_routed_by_active_project() {
ProjectEntry {
url: None,
auth_token: Some("alpha-tok".into()),
ssh_port: None,
host_path: None,
},
);
projects.insert(
@@ -1251,6 +1255,8 @@ async fn two_concurrent_sleds_are_routed_by_active_project() {
ProjectEntry {
url: None,
auth_token: Some("beta-tok".into()),
ssh_port: None,
host_path: None,
},
);
let config = GatewayConfig {
-149
View File
@@ -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);
}
}
+219
View File
@@ -20,6 +20,7 @@ const GATEWAY_TOOLS: &[&str] = &[
"gateway_status",
"gateway_health",
"init_project",
"adopt_project",
"aggregate_pipeline_status",
"agents.list",
// 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"]
}
}),
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!({
"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.",
@@ -358,6 +381,7 @@ async fn handle_gateway_tool(
"gateway_status" => handle_gateway_status_tool(state, id).await,
"gateway_health" => handle_gateway_health_tool(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,
"agents.list" => handle_agents_list_tool(id),
"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(
state: &GatewayState,
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 }))
}
// ── 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(&params, &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(&params, &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(&params, &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(&params, &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(&params, &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}"
);
}
}
+1 -1
View File
@@ -126,7 +126,7 @@ pub(crate) fn tool_dump_crdt(args: &Value) -> Result<String, String> {
"total_ops_in_list": dump.total_ops_in_list,
"max_seq_in_list": dump.max_seq_in_list,
"persisted_ops_count": dump.persisted_ops_count,
"pending_persist_ops_count": null,
"pending_persist_ops_count": dump.pending_persist_ops_count,
},
"items": items,
}))
+1
View File
@@ -86,6 +86,7 @@ mod tests {
use crate::http::test_helpers::test_ctx;
fn setup_git_repo_in(dir: &std::path::Path) {
crate::db::ensure_content_store();
std::process::Command::new("git")
.args(["init"])
.current_dir(dir)
@@ -115,6 +115,7 @@ mod tests {
#[test]
fn tool_create_refactor_accepts_single_criterion() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_create_refactor(
@@ -146,6 +147,7 @@ mod tests {
#[test]
fn tool_create_refactor_accepts_mixed_junk_and_real_acceptance_criteria() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_create_refactor(
+4
View File
@@ -118,6 +118,7 @@ mod tests {
#[test]
fn tool_create_spike_creates_file() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
@@ -147,6 +148,7 @@ mod tests {
#[test]
fn tool_create_spike_creates_file_without_description() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
@@ -202,6 +204,7 @@ mod tests {
#[test]
fn tool_create_spike_accepts_single_criterion() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_create_spike(
@@ -233,6 +236,7 @@ mod tests {
#[test]
fn tool_create_spike_accepts_mixed_junk_and_real_acceptance_criteria() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_create_spike(
@@ -256,6 +256,7 @@ mod tests {
#[test]
fn tool_create_story_accepts_single_criterion() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_create_story(
@@ -283,6 +284,7 @@ mod tests {
#[test]
fn tool_create_story_accepts_mixed_junk_and_real_acceptance_criteria() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_create_story(
@@ -299,6 +301,7 @@ mod tests {
#[test]
fn tool_create_story_description_is_written_to_file() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
@@ -368,6 +371,7 @@ mod tests {
#[test]
fn tool_create_story_html_sanitised_in_name() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
// HTML in name is sanitised (not rejected)
@@ -124,6 +124,7 @@ mod tests {
#[test]
fn tool_create_story_and_list_upcoming() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
// No git repo needed: spike 61 — create_story just writes the file;
// the filesystem watcher handles the commit asynchronously.
+2 -7
View File
@@ -1,8 +1,6 @@
//! HTTP server — module declarations for all REST, MCP, WebSocket, and SSE endpoints.
/// Server-sent event stream for real-time agent output.
pub mod agents_sse;
/// Static asset serving (embedded frontend files).
pub mod assets;
/// Shared application context threaded through handlers.
pub mod context;
/// Server-sent event stream for pipeline/watcher events.
@@ -100,10 +98,7 @@ pub fn build_routes(
get(oauth::oauth_callback).data(oauth_state.clone()),
)
.at("/oauth/status", get(oauth::oauth_status))
.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));
.at("/debug/crdt", get(debug_crdt_handler));
if let Some(buf) = event_buffer {
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,
"max_seq_in_list": dump.max_seq_in_list,
"persisted_ops_count": dump.persisted_ops_count,
"pending_persist_ops_count": null,
"pending_persist_ops_count": dump.pending_persist_ops_count,
},
"items": items,
});
@@ -6,6 +6,7 @@ use super::spike::create_spike_file;
use std::fs;
fn setup_git_repo(root: &std::path::Path) {
crate::db::ensure_content_store();
std::process::Command::new("git")
.args(["init"])
.current_dir(root)
@@ -166,6 +167,7 @@ fn extract_bug_name_from_content_parses_heading() {
#[test]
fn create_bug_file_writes_correct_content() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
setup_git_repo(tmp.path());
@@ -257,6 +259,7 @@ fn create_bug_file_rejects_empty_acceptance_criteria() {
#[test]
fn create_spike_file_writes_correct_content() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let spike_id = create_spike_file(
@@ -294,6 +297,7 @@ fn create_spike_file_writes_correct_content() {
#[test]
fn create_spike_file_uses_description_when_provided() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let description = "What is the best approach for watching filesystem events?";
@@ -319,6 +323,7 @@ fn create_spike_file_uses_description_when_provided() {
#[test]
fn create_spike_file_uses_placeholder_when_no_description() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let spike_id = create_spike_file(
tmp.path(),
@@ -350,6 +355,7 @@ fn create_spike_file_rejects_empty_name() {
#[test]
fn create_spike_file_with_special_chars_in_name_produces_valid_yaml() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let name = "Spike: compare \"fast\" vs slow encoders";
let result = create_spike_file(
@@ -423,6 +429,7 @@ fn create_bug_file_with_depends_on_persists_to_crdt() {
#[test]
fn create_bug_file_without_depends_on_omits_field() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
setup_git_repo(tmp.path());
@@ -474,6 +481,7 @@ fn create_refactor_file_with_depends_on_persists_to_crdt() {
#[test]
fn create_refactor_file_without_depends_on_omits_field() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
setup_git_repo(tmp.path());
+21 -1
View File
@@ -139,6 +139,14 @@ where
let received_at = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
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 mut cancel_rx = state.cancel_rx.clone();
cancel_rx.borrow_and_update();
@@ -177,10 +185,14 @@ where
// would be lost because Claude Code only receives a single prompt
// string. In that case, prepend the conversation history so the LLM
// 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() {
latest_user_content
format!("{event_ctx}{latest_user_content}")
} else {
format!(
"{event_ctx}{}",
build_claude_code_context_prompt(&messages, &latest_user_content)
)
};
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();
// Build the system prompt — append onboarding instructions when the
+277
View File
@@ -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
View File
@@ -20,12 +20,16 @@ pub mod crdt_sync;
/// CRDT wire format — on-wire message types for the crdt-sync protocol.
pub mod crdt_wire;
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.
pub mod gateway;
mod gateway_relay;
mod http;
mod io;
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.
pub mod log_buffer;
/// Mesh — peer discovery and multi-hop CRDT replication over WebSocket.
@@ -364,8 +368,8 @@ async fn main() -> Result<(), std::io::Error> {
Arc::clone(&services),
matrix_shutdown_rx,
None,
vec![],
std::collections::BTreeMap::new(),
None,
timer_store_for_bot,
None,
);
+5
View File
@@ -189,6 +189,11 @@ pub async fn rebuild_and_restart(
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.
// Use the cargo output path rather than current_exe() so that rebuilds
// inside Docker work correctly — the running binary may be installed at
+64
View File
@@ -26,6 +26,20 @@ pub struct ProjectEntry {
/// `[sled_tokens]` table for projects that set this field.
#[serde(default, skip_serializing_if = "Option::is_none")]
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 {
@@ -36,6 +50,8 @@ impl ProjectEntry {
Self {
url: Some(url.into()),
auth_token: None,
ssh_port: None,
host_path: None,
}
}
@@ -205,6 +221,8 @@ auth_token = "secret"
ProjectEntry {
url: None,
auth_token: Some("secret".into()),
ssh_port: None,
host_path: None,
},
);
let config = GatewayConfig {
@@ -238,6 +256,8 @@ auth_token = "secret"
ProjectEntry {
url: None,
auth_token: Some("tok".into()),
ssh_port: None,
host_path: None,
},
);
assert_eq!(validate_project_exists(&projects, "ws").unwrap(), "");
@@ -256,6 +276,8 @@ auth_token = "secret"
let e = ProjectEntry {
url: None,
auth_token: Some("tok".into()),
ssh_port: None,
host_path: None,
};
assert!(!e.has_url());
}
@@ -297,6 +319,8 @@ auth_token = "secret"
let entry = ProjectEntry {
url: Some("http://a:3001".into()),
auth_token: Some("mysecret".into()),
ssh_port: None,
host_path: None,
};
let mut projects = BTreeMap::new();
projects.insert("myproj".into(), entry);
@@ -315,4 +339,44 @@ auth_token = "secret"
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}"
);
}
}
+6 -3
View File
@@ -500,11 +500,12 @@ pub type ActiveProject = std::sync::Arc<tokio::sync::RwLock<String>>;
/// Returns `(abort_handle, shutdown_tx)`. The caller **must** hold
/// `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.
#[allow(clippy::too_many_arguments)]
pub fn spawn_gateway_bot(
config_dir: &Path,
active_project: ActiveProject,
gateway_projects: Vec<String>,
gateway_project_urls: BTreeMap<String, String>,
gateway_projects_store: std::sync::Arc<tokio::sync::RwLock<BTreeMap<String, ProjectEntry>>>,
port: u16,
gateway_event_tx: Option<tokio::sync::broadcast::Sender<super::GatewayStatusEvent>>,
perm_rx: std::sync::Arc<
@@ -576,8 +577,8 @@ pub fn spawn_gateway_bot(
services,
shutdown_rx,
Some(active_project),
gateway_projects,
gateway_project_urls,
Some(gateway_projects_store),
timer_store,
gateway_event_rx,
);
@@ -602,11 +603,13 @@ mod tests {
let (_perm_tx, perm_rx) =
tokio::sync::mpsc::unbounded_channel::<crate::http::context::PermissionForward>();
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(
tmp.path(),
active,
vec!["proj".to_string()],
std::collections::BTreeMap::new(),
projects_store,
3001,
Some(event_tx),
perm_rx,
+31 -2
View File
@@ -613,6 +613,12 @@ pub async fn init_project(
/// 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.
/// A return value of zero means no subscribers are currently connected.
pub fn broadcast_status_event(
@@ -620,6 +626,26 @@ pub fn broadcast_status_event(
project: String,
event: crate::service::events::StoredEvent,
) -> 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 };
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() {
h.abort();
}
let gateway_projects: Vec<String> = state.projects.read().await.keys().cloned().collect();
let gateway_project_urls: BTreeMap<String, String> = state
.projects
.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(
&state.config_dir,
Arc::clone(&state.active_project),
gateway_projects,
gateway_project_urls,
Arc::clone(&state.projects),
state.port,
Some(state.event_tx.clone()),
Arc::clone(&state.perm_rx),
@@ -746,6 +771,8 @@ mod tests {
ProjectEntry {
url: None,
auth_token: Some("tok".into()),
ssh_port: None,
host_path: None,
},
);
let config = GatewayConfig {
@@ -884,6 +911,8 @@ mod tests {
ProjectEntry {
url: Some("http://huskies:3001".into()),
auth_token: Some("secret-token".into()),
ssh_port: None,
host_path: None,
},
);
let config = GatewayConfig {
+3 -1
View File
@@ -4,7 +4,7 @@
//! with `[project-name]` prefixes. The actual I/O (HTTP polling, spawning
//! 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::notifications::{
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
/// the project name, mirroring the sled-side `format_audit_entry` format.
#[cfg(test)]
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 = chrono::DateTime::from_timestamp_millis(ts_ms as i64)
.unwrap_or_else(chrono::Utc::now)
+4
View File
@@ -28,6 +28,10 @@ pub(crate) fn spawn_event_bridges(
// Audit log subscriber: write one structured line per pipeline transition.
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
// WatcherEvent::WorkItem so downstream consumers (WebSocket, auto-assign)
// see a uniform stream regardless of whether the event originated from the
-225
View File
@@ -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 &lt;PORT&gt;</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 &lt;PATH&gt;</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 &lt;PORT&gt;</td><td>3000</td><td>Port written into <code>.mcp.json</code> for MCP tool discovery.</td></tr>
<tr><td>--project &lt;PATH&gt;</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 &lt;number&gt;</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 &lt;ID&gt;</td><td>Story ID slug to work on (e.g. <code>42_story_add_login</code>).</td></tr>
<tr><td>--agent &lt;NAME&gt;</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 &lt;PATH&gt;</td><td>Path to the git worktree the agent should work in.</td></tr>
<tr><td>--port &lt;PORT&gt;</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&amp;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&amp;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>&copy; 2026 Libby Labs Ltd.</span>
<a href="/privacy.html">Privacy Policy</a>
</footer>
</div>
</body>
</html>
-189
View File
@@ -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 &lt;number&gt;</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 &lt;number&gt;</code>. To use the opus model: <code>start &lt;number&gt; 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 &lt;number&gt; &lt;stage&gt;</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 &lt;number&gt;</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 &lt;number&gt;</code>.</div>
</div>
<div class="cmd-row">
<div class="cmd-name">unblock</div>
<div class="cmd-desc">Reset a blocked story: <code>unblock &lt;number&gt;</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 &lt;number&gt; &lt;model&gt;</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 &lt;number&gt; [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 &lt;number&gt; HH:MM</code>. List all timers: <code>timer list</code>. Cancel: <code>timer cancel &lt;number&gt;</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 &lt;number&gt;</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 &lt;number&gt;</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 &lt;N&gt;</code> for N files, or <code>loc &lt;filepath&gt;</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 &lt;number&gt;</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 &amp; 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>&copy; 2026 Libby Labs Ltd.</span>
<a href="/privacy.html">Privacy Policy</a>
</footer>
</div>
</body>
</html>
-315
View File
@@ -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>&#123;&#123;story_id&#125;&#125;</td><td>The story's ID slug (e.g. <code>42_story_add_login</code>).</td></tr>
<tr><td>&#123;&#123;worktree_path&#125;&#125;</td><td>Absolute path to the agent's git worktree.</td></tr>
<tr><td>&#123;&#123;base_branch&#125;&#125;</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 &lt;number&gt; 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>&lt;gateway-config-dir&gt;/.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># &lt;gateway-config-dir&gt;/.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>&copy; 2026 Libby Labs Ltd.</span>
<a href="/privacy.html">Privacy Policy</a>
</footer>
</div>
</body>
</html>
-447
View File
@@ -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); }
-135
View File
@@ -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 &lt;number&gt;</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 &amp; 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>&copy; 2026 Libby Labs Ltd.</span>
<a href="/privacy.html">Privacy Policy</a>
</footer>
</div>
</body>
</html>
-231
View File
@@ -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 &lt;number&gt;</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 &lt;number&gt;</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 &lt;number&gt;</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 &lt;number&gt;</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 &lt;number&gt;</code> to reset the retry counter.</li>
<li>Run <code>start &lt;number&gt;</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>&copy; 2026 Libby Labs Ltd.</span>
<a href="/privacy.html">Privacy Policy</a>
</footer>
</div>
</body>
</html>
-165
View File
@@ -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 &lt;story-number&gt; 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 &lt;story-number&gt;</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 &lt;story-number&gt;</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>&copy; 2026 Libby Labs Ltd.</span>
<a href="/privacy.html">Privacy Policy</a>
</footer>
</div>
</body>
</html>
-220
View File
@@ -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:&lt;port&gt;</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>&copy; 2026 Libby Labs Ltd.</span>
<a href="/privacy.html">Privacy Policy</a>
</footer>
</div>
</body>
</html>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

-34
View File
@@ -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

-157
View File
@@ -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 &mdash; 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 &amp; 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 &mdash; 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>&copy; 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>
-68
View File
@@ -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>&copy; 2026 Libby Labs Ltd. All rights reserved. &middot; <a href="/">Home</a></p>
</footer>
</div>
</body>
</html>
-416
View File
@@ -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); }