Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
897e7c07ae | ||
|
|
c9647f4106 | ||
|
|
5ee65d2f15 |
11 changed files with 428 additions and 86 deletions
144
CLAUDE.md
144
CLAUDE.md
|
|
@ -10,15 +10,19 @@ Operator + dev notes: conventions, gotchas, per-subsystem design.
|
||||||
```
|
```
|
||||||
hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched)
|
hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched)
|
||||||
src/main.rs clap setup; serve / spawn / kill / rebuild / list /
|
src/main.rs clap setup; serve / spawn / kill / rebuild / list /
|
||||||
pending / approve / deny / destroy / request-spawn
|
pending / approve / deny / destroy [--purge] /
|
||||||
|
request-spawn; periodic broker vacuum task
|
||||||
src/server.rs host admin socket (HostRequest → dispatch)
|
src/server.rs host admin socket (HostRequest → dispatch)
|
||||||
src/client.rs admin-socket client
|
src/client.rs admin-socket client
|
||||||
src/manager_server.rs manager-privileged socket (ManagerRequest)
|
src/manager_server.rs manager-privileged socket (ManagerRequest)
|
||||||
src/agent_server.rs per-sub-agent socket listener (long-poll Recv)
|
src/agent_server.rs per-sub-agent socket listener (long-poll Recv)
|
||||||
src/broker.rs sqlite Message store + broadcast channel for SSE
|
src/broker.rs sqlite Message store + broadcast channel for SSE +
|
||||||
|
hourly vacuum of delivered>30d
|
||||||
src/approvals.rs sqlite Approval queue + kinds
|
src/approvals.rs sqlite Approval queue + kinds
|
||||||
src/coordinator.rs shared state (broker/approvals/transient/sockets)
|
src/operator_questions.rs sqlite question queue backing `ask_operator`
|
||||||
src/actions.rs approve/deny/destroy
|
src/coordinator.rs shared state (broker/approvals/questions/transient/
|
||||||
|
sockets) + tombstone enumeration
|
||||||
|
src/actions.rs approve/deny/destroy (transient-aware)
|
||||||
src/auto_update.rs startup rebuild scan + ensure_manager
|
src/auto_update.rs startup rebuild scan + ensure_manager
|
||||||
src/lifecycle.rs `nixos-container` shellouts, per-agent flake generator
|
src/lifecycle.rs `nixos-container` shellouts, per-agent flake generator
|
||||||
src/dashboard.rs axum HTTP: static shell + /api/state JSON + actions
|
src/dashboard.rs axum HTTP: static shell + /api/state JSON + actions
|
||||||
|
|
@ -27,14 +31,16 @@ hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched)
|
||||||
hive-ag3nt/ in-container harness crate; produces TWO binaries
|
hive-ag3nt/ in-container harness crate; produces TWO binaries
|
||||||
src/lib.rs re-exports + DEFAULT_SOCKET, DEFAULT_WEB_PORT
|
src/lib.rs re-exports + DEFAULT_SOCKET, DEFAULT_WEB_PORT
|
||||||
src/client.rs generic JSON-line request/response over unix socket
|
src/client.rs generic JSON-line request/response over unix socket
|
||||||
src/web_ui.rs per-container axum HTTP page
|
src/web_ui.rs per-container axum HTTP page (incl /api/cancel,
|
||||||
src/events.rs LiveEvent + broadcast Bus for the SSE stream
|
/api/compact, /events/history)
|
||||||
|
src/events.rs LiveEvent + broadcast Bus + sqlite-backed history
|
||||||
|
(/state/hyperhive-events.sqlite) + hourly vacuum
|
||||||
src/turn.rs claude --print + stream-json pump; --compact retry
|
src/turn.rs claude --print + stream-json pump; --compact retry
|
||||||
src/mcp.rs embedded MCP server (rmcp): AgentServer + ManagerServer
|
src/mcp.rs embedded MCP server (rmcp): AgentServer + ManagerServer
|
||||||
src/login.rs probe /root/.claude/ for a valid session
|
src/login.rs probe /root/.claude/ for a valid session
|
||||||
src/login_session.rs drives `claude auth login` over stdio pipes
|
src/login_session.rs drives `claude auth login` over stdio pipes
|
||||||
src/bin/hive-ag3nt.rs sub-agent main
|
src/bin/hive-ag3nt.rs sub-agent main (Serve + Mcp subcommands)
|
||||||
src/bin/hive-m1nd.rs manager main
|
src/bin/hive-m1nd.rs manager main (Serve + Mcp subcommands)
|
||||||
assets/ index.html, agent.css, app.js (include_str!)
|
assets/ index.html, agent.css, app.js (include_str!)
|
||||||
prompts/ static role/tools/settings for claude (include_str!):
|
prompts/ static role/tools/settings for claude (include_str!):
|
||||||
agent.md — sub-agent system prompt
|
agent.md — sub-agent system prompt
|
||||||
|
|
@ -138,16 +144,23 @@ Both the dashboard (port 7000) and the per-agent web UIs (8000 /
|
||||||
- `GET /static/*.css` + `GET /static/*.js` → static assets shipped via
|
- `GET /static/*.css` + `GET /static/*.js` → static assets shipped via
|
||||||
`include_str!` so there's no runtime file dependency.
|
`include_str!` so there's no runtime file dependency.
|
||||||
- `GET /api/state` → JSON snapshot the JS app renders into the DOM.
|
- `GET /api/state` → JSON snapshot the JS app renders into the DOM.
|
||||||
- `POST /<action>` (approve, deny, kill, restart, rebuild, destroy,
|
|
||||||
request-spawn, update-all, send, login/*) → idempotent action endpoints.
|
|
||||||
- `GET /events/stream` (per-agent) and `GET /messages/stream` (dashboard)
|
- `GET /events/stream` (per-agent) and `GET /messages/stream` (dashboard)
|
||||||
are `text/event-stream` SSE for live updates.
|
are `text/event-stream` SSE for live updates.
|
||||||
|
|
||||||
|
Per-agent endpoints: `POST /send`, `POST /login/{start,code,cancel}`,
|
||||||
|
`POST /api/cancel`, `POST /api/compact`, `GET /events/history`.
|
||||||
|
|
||||||
|
Dashboard endpoints: `POST /{approve,deny}/{id}`, `POST
|
||||||
|
/{rebuild,kill,restart,start,destroy}/{name}`, `POST
|
||||||
|
/purge-tombstone/{name}`, `POST /answer-question/{id}`, `POST
|
||||||
|
/request-spawn`, `POST /update-all`.
|
||||||
|
|
||||||
The JS app handles all `form[data-async]` submissions via a delegated
|
The JS app handles all `form[data-async]` submissions via a delegated
|
||||||
listener: read `data-confirm`, swap the button to a spinner, POST
|
listener: read `data-confirm`, swap the button to a spinner, POST
|
||||||
`application/x-www-form-urlencoded` (axum's `Form` extractor rejects
|
`application/x-www-form-urlencoded` (axum's `Form` extractor rejects
|
||||||
multipart), then on success call `refreshState()` (re-fetch `/api/state`
|
multipart), then on success re-enable the button (refreshState often
|
||||||
and re-render). No full-page reloads.
|
keeps the form mounted) and call `refreshState()` (re-fetch
|
||||||
|
`/api/state` and re-render). No full-page reloads.
|
||||||
|
|
||||||
Per-agent + dashboard state shapes live in `dashboard.rs::StateSnapshot`
|
Per-agent + dashboard state shapes live in `dashboard.rs::StateSnapshot`
|
||||||
and `web_ui.rs::StateSnapshot`. When adding new state fields, plumb
|
and `web_ui.rs::StateSnapshot`. When adding new state fields, plumb
|
||||||
|
|
@ -226,12 +239,25 @@ into the wake prompt + UI header. New tools call this helper.
|
||||||
`Bash` is on the allow-list pending a finer-grained pattern allow-list
|
`Bash` is on the allow-list pending a finer-grained pattern allow-list
|
||||||
(`Bash(git *)`-style) — see [TODO.md](TODO.md).
|
(`Bash(git *)`-style) — see [TODO.md](TODO.md).
|
||||||
|
|
||||||
**Live view.** Each agent runs an `events::Bus` (a
|
**Live view.** Each agent runs an `events::Bus` (broadcast channel +
|
||||||
`tokio::sync::broadcast<LiveEvent>` wrapper). The harness emits
|
sqlite-backed history at `/state/hyperhive-events.sqlite`). The harness
|
||||||
`TurnStart { from, body, unread }`, `Stream(value)` (one per parsed
|
emits `TurnStart { from, body, unread }`, `Stream(value)` (one per
|
||||||
stream-json line), `Note`, `TurnEnd { ok, note }`. The web UI subscribes
|
parsed stream-json line), `Note`, `TurnEnd { ok, note }`. The web UI:
|
||||||
via `/events/stream` (SSE) and a JS panel (terminal-themed: Crust bg, inset
|
|
||||||
shadow, monospace) renders rows:
|
- fetches `GET /events/history` on page load and replays the last
|
||||||
|
2000 events (oldest first, `.no-anim` so they don't stagger),
|
||||||
|
- then subscribes to `GET /events/stream` (SSE) for live tail,
|
||||||
|
- shows a granular state badge above the terminal (`💤 idle / 🧠
|
||||||
|
thinking / ○ offline · <age>`) driven from `turn_start`/`turn_end`,
|
||||||
|
with a flash animation on transition,
|
||||||
|
- sticky-bottom auto-scroll: scrolling up parks the view; new rows
|
||||||
|
surface a "↓ N new" pill instead of yanking. Scrolling back to
|
||||||
|
bottom clears the counter,
|
||||||
|
- terminal-themed: phosphor mauve glow, Crust bg, backdrop-filter
|
||||||
|
blur, row fade-in slide-up, banner gradient shimmer while
|
||||||
|
state=thinking.
|
||||||
|
|
||||||
|
Per-tool rendering:
|
||||||
|
|
||||||
- `TurnStart` → `◆ TURN ← <from> · N unread` header + indented body.
|
- `TurnStart` → `◆ TURN ← <from> · N unread` header + indented body.
|
||||||
- `Stream` `tool_use` → `→ Read /path` / `→ Bash $ cmd` /
|
- `Stream` `tool_use` → `→ Read /path` / `→ Bash $ cmd` /
|
||||||
|
|
@ -247,8 +273,20 @@ shadow, monospace) renders rows:
|
||||||
`refreshState()` so the page form view reflects state transitions
|
`refreshState()` so the page form view reflects state transitions
|
||||||
(e.g. login just landed).
|
(e.g. login just landed).
|
||||||
|
|
||||||
The operator send form sits below the live panel, so the tail is what
|
The operator input lives *inside* the terminal-wrap as a prompt-style
|
||||||
you read first.
|
textarea below the live tail: multi-line (Enter sends, Shift+Enter
|
||||||
|
newlines), tab-completes slash commands. Available slash commands:
|
||||||
|
|
||||||
|
- `/help` — list commands locally
|
||||||
|
- `/clear` — wipe the visible terminal (server history kept)
|
||||||
|
- `/cancel` — POST `/api/cancel` (host shellouts `pkill -INT
|
||||||
|
claude`, emits a Note); also surfaces as a `■ cancel turn` button
|
||||||
|
in the state row while state=thinking
|
||||||
|
- `/compact` — POST `/api/compact` (host spawns
|
||||||
|
`turn::compact_session` in the background; output streams into the
|
||||||
|
live panel)
|
||||||
|
|
||||||
|
Unknown `/foo` shows an error row instead of being silently sent.
|
||||||
|
|
||||||
## Manager (hm1nd) is hive-c0re-managed
|
## Manager (hm1nd) is hive-c0re-managed
|
||||||
|
|
||||||
|
|
@ -334,19 +372,40 @@ loops over every stale container.
|
||||||
|
|
||||||
## Dashboard action surface
|
## Dashboard action surface
|
||||||
|
|
||||||
Container row buttons (rendered per-state by `assets/app.js`):
|
Page sections (top to bottom):
|
||||||
|
|
||||||
- Always: `↻ R3BU1LD` (calls `lifecycle::rebuild`), and for sub-agents
|
1. **C0NTAINERS** — live containers with their action surface (below).
|
||||||
`DESTR0Y` (container removed, state + creds kept) + `PURG3`
|
2. **K3PT ST4T3** — destroyed-but-state-kept tombstones (size +
|
||||||
(DESTR0Y plus wipes `/var/lib/hyperhive/{agents,applied}/<name>/`;
|
age + claude-creds badge). Two actions: `⊕ R3V1V3` (queues a
|
||||||
no undo).
|
Spawn approval; existing state is reused), `PURG3` (wipes
|
||||||
- Running: `↺ R3ST4RT` + (sub-agents only) `■ ST0P`.
|
state + applied dirs; `POST /purge-tombstone/{name}`).
|
||||||
- Stopped: `▶ ST4RT`.
|
3. **M1ND H4S QU3STI0NS** — pending `ask_operator` questions
|
||||||
- Stale marker: clickable `needs update ↻` badge (same target as rebuild
|
(amber pulsing border). Always renders a free-text fallback
|
||||||
but only shown when out of date).
|
alongside any option list; `multi=true` renders options as
|
||||||
|
checkboxes; submit merges selections + free text comma-joined.
|
||||||
|
4. **0PER4T0R 1NB0X** — recent messages addressed to `operator`
|
||||||
|
(last 50, from the broker).
|
||||||
|
5. **P3NDING APPR0VALS** — the queue. The R3QU3ST SP4WN form
|
||||||
|
lives at the top of this section since submitting it immediately
|
||||||
|
queues an approval that lands directly below.
|
||||||
|
6. **MESS4GE FL0W** — live broker SSE tail.
|
||||||
|
|
||||||
Top of the containers list: `↻ UPD4TE 4LL` (when any stale) + the
|
Container row (two-line layout, `assets/app.js::renderContainers`):
|
||||||
"R3QU3ST SP4WN" form for queuing a new agent through the approval flow.
|
|
||||||
|
- Line 1: agent name (link → new tab), m1nd/ag3nt chip, `needs
|
||||||
|
login` / `needs update` warning badges, in-flight `◐ pending-state…`
|
||||||
|
pill (replaces buttons during start/stop/restart/rebuild/destroy),
|
||||||
|
container name + port.
|
||||||
|
- Line 2: action buttons — `↻ R3BU1LD` always, `DESTR0Y` + `PURG3`
|
||||||
|
on sub-agents, `↺ R3ST4RT` + (sub-agents) `■ ST0P` when running,
|
||||||
|
`▶ ST4RT` when stopped. Buttons dim + disable while a transient
|
||||||
|
lifecycle action is in flight.
|
||||||
|
|
||||||
|
`↻ UPD4TE 4LL` button appears above the containers list when any
|
||||||
|
agent is stale.
|
||||||
|
|
||||||
|
Banner pulses on each broker SSE event (`pulseBanner` with a 4s
|
||||||
|
grace timer).
|
||||||
|
|
||||||
## Approval flow
|
## Approval flow
|
||||||
|
|
||||||
|
|
@ -374,3 +433,26 @@ The container's `--flake` ref is `<applied_dir>#default`. The flake extends
|
||||||
an inline module setting `programs.git.config.user` (committer identity =
|
an inline module setting `programs.git.config.user` (committer identity =
|
||||||
the agent's name) and `systemd.services.<harness>.environment` (HIVE_PORT,
|
the agent's name) and `systemd.services.<harness>.environment` (HIVE_PORT,
|
||||||
HIVE_LABEL, HIVE_DASHBOARD_PORT).
|
HIVE_LABEL, HIVE_DASHBOARD_PORT).
|
||||||
|
|
||||||
|
## Persistence + retention
|
||||||
|
|
||||||
|
Two sqlite files; both autovacuum on a 1h tokio task:
|
||||||
|
|
||||||
|
- **`/var/lib/hyperhive/broker.sqlite`** (host) — `messages` +
|
||||||
|
`approvals` + `operator_questions` tables. `Broker::vacuum_delivered`
|
||||||
|
drops delivered messages older than 30 days; undelivered rows are
|
||||||
|
always kept. Approvals + questions are kept indefinitely (auditable).
|
||||||
|
- **`/state/hyperhive-events.sqlite`** (per-container, bind-mounted
|
||||||
|
from `/var/lib/hyperhive/agents/<name>/state/`) — every `LiveEvent`
|
||||||
|
emitted on the per-agent `Bus`. Hourly vacuum drops rows older than
|
||||||
|
7 days, then trims to the most recent 2000. Path overridable via
|
||||||
|
`HYPERHIVE_EVENTS_DB` (for dev / no-`/state` setups; on open failure
|
||||||
|
the Bus falls back to no-store mode rather than crashing the
|
||||||
|
harness). Survives destroy/recreate; gone on `--purge`.
|
||||||
|
|
||||||
|
State dirs (per agent, under `/var/lib/hyperhive/agents/<name>/`):
|
||||||
|
`config/` (proposed nix repo), `claude/` (creds, bind-mounted RW to
|
||||||
|
`/root/.claude`), `state/` (durable notes + events db, bind-mounted to
|
||||||
|
`/state`). Wiped only on explicit `--purge`. Tombstones (state dir
|
||||||
|
without a live container) surface in the dashboard's K3PT ST4T3
|
||||||
|
section so the operator can either revive or purge.
|
||||||
|
|
|
||||||
19
README.md
19
README.md
|
|
@ -29,8 +29,9 @@ host (NixOS, runs hive-c0re.service)
|
||||||
│ manager additionally gets /agents RW)
|
│ manager additionally gets /agents RW)
|
||||||
│
|
│
|
||||||
├── hm1nd hive-m1nd serve : claude turn loop +
|
├── hm1nd hive-m1nd serve : claude turn loop +
|
||||||
│ MCP (send / recv / request_spawn / kill /
|
│ MCP (send / recv / request_spawn / kill / start /
|
||||||
│ request_apply_commit) + web UI on :8000
|
│ restart / request_apply_commit / ask_operator)
|
||||||
|
│ + web UI on :8000
|
||||||
│
|
│
|
||||||
└── h-<name> hive-ag3nt serve : claude turn loop +
|
└── h-<name> hive-ag3nt serve : claude turn loop +
|
||||||
MCP (send / recv) + web UI on a hashed :8100-8999
|
MCP (send / recv) + web UI on a hashed :8100-8999
|
||||||
|
|
@ -39,13 +40,23 @@ host (NixOS, runs hive-c0re.service)
|
||||||
Each turn: harness pops one inbox message (Recv long-polls server-side and
|
Each turn: harness pops one inbox message (Recv long-polls server-side and
|
||||||
wakes on a broker Sent event) → builds a wake prompt → spawns
|
wakes on a broker Sent event) → builds a wake prompt → spawns
|
||||||
`claude --print --continue --output-format stream-json --mcp-config …` →
|
`claude --print --continue --output-format stream-json --mcp-config …` →
|
||||||
streams JSON events into the per-agent SSE bus → claude drives any further
|
streams JSON events into the per-agent SSE bus + a sqlite history db →
|
||||||
`recv`/`send` itself via the embedded MCP server.
|
claude drives any further `recv`/`send` itself via the embedded MCP server.
|
||||||
|
|
||||||
|
Operator surface per agent: terminal-themed live tail with a textarea
|
||||||
|
prompt; slash commands `/help` `/clear` `/cancel` `/compact`; granular
|
||||||
|
state badge (idle / thinking / offline) with age timer; cancel-turn
|
||||||
|
button while thinking; sticky-bottom auto-scroll with "↓ N new" pill;
|
||||||
|
event history backfilled on page load.
|
||||||
|
|
||||||
Config changes flow the other way: manager edits `/agents/<name>/config/agent.nix`
|
Config changes flow the other way: manager edits `/agents/<name>/config/agent.nix`
|
||||||
(bind-mounted from the host's proposed repo) → commits → submits the sha as
|
(bind-mounted from the host's proposed repo) → commits → submits the sha as
|
||||||
an approval → operator clicks ◆ APPR0VE on the dashboard → hive-c0re copies
|
an approval → operator clicks ◆ APPR0VE on the dashboard → hive-c0re copies
|
||||||
the file into the applied repo and `nixos-container update`s the agent.
|
the file into the applied repo and `nixos-container update`s the agent.
|
||||||
|
For decisions the manager needs human signal on, `ask_operator(question,
|
||||||
|
options?, multi?)` queues a free-text/checkbox/radio form on the
|
||||||
|
dashboard; the answer arrives later as a `HelperEvent::OperatorAnswered`
|
||||||
|
in the manager's inbox.
|
||||||
|
|
||||||
## Host config
|
## Host config
|
||||||
|
|
||||||
|
|
|
||||||
45
TODO.md
45
TODO.md
|
|
@ -34,28 +34,10 @@ Pick anything from here when relevant. Cross-cutting design notes live in
|
||||||
`napping 😴` once the `/compact` trigger and `nap` tool exist —
|
`napping 😴` once the `/compact` trigger and `nap` tool exist —
|
||||||
both need a harness signal (an explicit `LiveEvent::StateChange`
|
both need a harness signal (an explicit `LiveEvent::StateChange`
|
||||||
variant or piggyback on Note).
|
variant or piggyback on Note).
|
||||||
- **Terminal: slash commands beyond /help and /clear.** Operator-facing
|
- **Terminal: `/model` slash command.** Operator-typeable model
|
||||||
in-terminal commands still to add: `/model`, `/compact`, `/cancel`.
|
override from the terminal. Depends on the model-override work
|
||||||
Each needs harness-side support (model override, force compaction,
|
above; once an override mechanism exists, wire a `/model <name>`
|
||||||
cancel current claude turn).
|
command that POSTs to a new endpoint.
|
||||||
- **Terminal: bigger.** The 32em max-height is cramped on a 1080p+
|
|
||||||
screen. Grow it (e.g. `min(70vh, 60em)`) so the live tail is the
|
|
||||||
main visual element of the page rather than a strip.
|
|
||||||
- **Terminal: sticky-bottom auto-scroll.** Today every appended row
|
|
||||||
scrolls to bottom, so the view shifts while the operator is reading
|
|
||||||
scrolled-up. Track whether the user is *already* at the bottom
|
|
||||||
(within a small threshold), and only auto-scroll when that's true.
|
|
||||||
Show a small "↓ N new" indicator when not at bottom; click to jump.
|
|
||||||
- **Terminal: cancel-current-turn button.** Explicit "kill claude
|
|
||||||
process for this turn" control. Harness needs to track the
|
|
||||||
in-flight claude child PID and offer a `/cancel` endpoint that sends
|
|
||||||
SIGTERM; UI surfaces a button while the state badge is `thinking`.
|
|
||||||
Slash-command equivalent: `/cancel`.
|
|
||||||
- **`/compact` trigger.** Operator-initiated compaction of the current
|
|
||||||
claude session — `claude --print --continue` with `/compact` over the
|
|
||||||
same session id. Surfaces as a slash command in the terminal + a
|
|
||||||
toolbar button while the state badge is `idle`. Sets state to
|
|
||||||
`compacting` during the run.
|
|
||||||
- **xterm.js terminal** embedded per-agent, attached to a PTY exposed by
|
- **xterm.js terminal** embedded per-agent, attached to a PTY exposed by
|
||||||
the harness. Pairs well with the unprivileged-container work — would let
|
the harness. Pairs well with the unprivileged-container work — would let
|
||||||
the operator drop into the container without `nixos-container root-login`.
|
the operator drop into the container without `nixos-container root-login`.
|
||||||
|
|
@ -87,6 +69,25 @@ Pick anything from here when relevant. Cross-cutting design notes live in
|
||||||
manager fall back. Wire the timeout into `OperatorQuestions::wait_answered`
|
manager fall back. Wire the timeout into `OperatorQuestions::wait_answered`
|
||||||
and surface remaining-time on the dashboard.
|
and surface remaining-time on the dashboard.
|
||||||
|
|
||||||
|
## Spawn flow
|
||||||
|
|
||||||
|
- **Two-step spawn.** Today `request_spawn(name)` is one shot: manager
|
||||||
|
asks → operator approves → container is created with a default
|
||||||
|
`agent.nix` and empty `/state/`. Manager has no way to pre-stage
|
||||||
|
per-agent prompt material, package additions, or initial notes before
|
||||||
|
the agent first wakes. Split into:
|
||||||
|
1. `request_spawn_draft(name)` — host creates the per-agent
|
||||||
|
`proposed/` repo (initial commit) and `state/` dir with no
|
||||||
|
container; manager now has `/agents/<name>/{config,state}/` to
|
||||||
|
edit + commit just like an existing agent.
|
||||||
|
2. `request_spawn_commit(name, commit_ref)` — submits the queued
|
||||||
|
approval; operator sees the diff in the dashboard like a normal
|
||||||
|
`apply_commit`; on approve the container is created from that
|
||||||
|
commit.
|
||||||
|
Backwards-compat: keep the existing one-shot `request_spawn` for
|
||||||
|
trivial agents (operator can still type a name in the dashboard).
|
||||||
|
Surface "drafts" as a new section between K3PT ST4T3 and approvals.
|
||||||
|
|
||||||
## Loop substance
|
## Loop substance
|
||||||
|
|
||||||
- **`nap` tool.** Agent-side MCP tool `mcp__hyperhive__nap(seconds)` that
|
- **`nap` tool.** Agent-side MCP tool `mcp__hyperhive__nap(seconds)` that
|
||||||
|
|
|
||||||
|
|
@ -164,20 +164,23 @@
|
||||||
{ name: '/help', desc: 'list slash commands' },
|
{ name: '/help', desc: 'list slash commands' },
|
||||||
{ name: '/clear', desc: 'wipe the terminal panel (local-only)' },
|
{ name: '/clear', desc: 'wipe the terminal panel (local-only)' },
|
||||||
{ name: '/cancel', desc: 'SIGINT the in-flight claude turn' },
|
{ name: '/cancel', desc: 'SIGINT the in-flight claude turn' },
|
||||||
|
{ name: '/compact', desc: 'compact the persistent claude session' },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function postCancelTurn() {
|
async function postSimple(url, label) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/cancel', { method: 'POST', redirect: 'manual' });
|
const resp = await fetch(url, { method: 'POST', redirect: 'manual' });
|
||||||
const ok = resp.ok || resp.type === 'opaqueredirect'
|
const ok = resp.ok || resp.type === 'opaqueredirect'
|
||||||
|| (resp.status >= 200 && resp.status < 400);
|
|| (resp.status >= 200 && resp.status < 400);
|
||||||
if (!ok && termAPI) {
|
if (!ok && termAPI) {
|
||||||
termAPI.row('turn-end-fail', '✗ /cancel failed: http ' + resp.status);
|
termAPI.row('turn-end-fail', '✗ ' + label + ' failed: http ' + resp.status);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (termAPI) termAPI.row('turn-end-fail', '✗ /cancel failed: ' + err);
|
if (termAPI) termAPI.row('turn-end-fail', '✗ ' + label + ' failed: ' + err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const postCancelTurn = () => postSimple('/api/cancel', '/cancel');
|
||||||
|
const postCompact = () => postSimple('/api/compact', '/compact');
|
||||||
|
|
||||||
function handleSlashCommand(line) {
|
function handleSlashCommand(line) {
|
||||||
if (!termAPI) return false;
|
if (!termAPI) return false;
|
||||||
|
|
@ -198,6 +201,9 @@
|
||||||
case '/cancel':
|
case '/cancel':
|
||||||
postCancelTurn();
|
postCancelTurn();
|
||||||
return true;
|
return true;
|
||||||
|
case '/compact':
|
||||||
|
postCompact();
|
||||||
|
return true;
|
||||||
default:
|
default:
|
||||||
termAPI.row('turn-end-fail', '✗ unknown slash command: ' + cmd + ' — try /help');
|
termAPI.row('turn-end-fail', '✗ unknown slash command: ' + cmd + ' — try /help');
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ pub async fn serve(
|
||||||
.route("/login/code", post(post_login_code))
|
.route("/login/code", post(post_login_code))
|
||||||
.route("/login/cancel", post(post_login_cancel))
|
.route("/login/cancel", post(post_login_cancel))
|
||||||
.route("/api/cancel", post(post_cancel_turn))
|
.route("/api/cancel", post(post_cancel_turn))
|
||||||
|
.route("/api/compact", post(post_compact))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
let listener = tokio::net::TcpListener::bind(addr)
|
let listener = tokio::net::TcpListener::bind(addr)
|
||||||
|
|
@ -274,6 +275,37 @@ async fn post_login_cancel(State(state): State<AppState>) -> Response {
|
||||||
Redirect::to("/").into_response()
|
Redirect::to("/").into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Operator-initiated session compaction. Spawns `turn::compact_session`
|
||||||
|
/// in the background — the HTTP handler returns immediately so the
|
||||||
|
/// async-form spinner can clear. Output (claude's compaction stream,
|
||||||
|
/// the "/compact done" note) lands in the live event panel like any
|
||||||
|
/// other turn. If a regular turn is in flight, claude's own session
|
||||||
|
/// lock will reject this one and we surface the error as a Note.
|
||||||
|
async fn post_compact(State(state): State<AppState>) -> Response {
|
||||||
|
let bus = state.bus.clone();
|
||||||
|
let socket = state.socket.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
bus.emit(crate::events::LiveEvent::Note(
|
||||||
|
"operator: /compact — running on persistent session".into(),
|
||||||
|
));
|
||||||
|
let settings = match crate::turn::write_settings(&socket).await {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
bus.emit(crate::events::LiveEvent::Note(format!(
|
||||||
|
"/compact failed: settings write — {e:#}"
|
||||||
|
)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = crate::turn::compact_session(&settings, &bus).await {
|
||||||
|
bus.emit(crate::events::LiveEvent::Note(format!(
|
||||||
|
"/compact failed: {e:#}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
/// Cancel the in-flight claude turn. Coarse-grained: shells out
|
/// Cancel the in-flight claude turn. Coarse-grained: shells out
|
||||||
/// `pkill -INT claude` since there's at most one claude per container.
|
/// `pkill -INT claude` since there's at most one claude per container.
|
||||||
/// SIGINT (not SIGTERM) so claude flushes anything in-flight and emits a
|
/// SIGINT (not SIGTERM) so claude flushes anything in-flight and emits a
|
||||||
|
|
|
||||||
|
|
@ -83,23 +83,6 @@
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
const spawn = el('form', {
|
|
||||||
method: 'POST', action: '/request-spawn',
|
|
||||||
class: 'spawnform', 'data-async': '',
|
|
||||||
});
|
|
||||||
spawn.append(
|
|
||||||
el('input', {
|
|
||||||
name: 'name',
|
|
||||||
placeholder: 'new agent name (≤9 chars)',
|
|
||||||
maxlength: '9', required: '', autocomplete: 'off',
|
|
||||||
}),
|
|
||||||
el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'),
|
|
||||||
);
|
|
||||||
root.append(spawn);
|
|
||||||
root.append(el('p', { class: 'meta' },
|
|
||||||
'spawn requests queue as approvals. operator approves below to actually create the container.',
|
|
||||||
));
|
|
||||||
|
|
||||||
if (s.transients.length) {
|
if (s.transients.length) {
|
||||||
const ul = el('ul');
|
const ul = el('ul');
|
||||||
for (const t of s.transients) {
|
for (const t of s.transients) {
|
||||||
|
|
@ -126,7 +109,7 @@
|
||||||
// ── line 1: identity ─────────────────────────────────────────
|
// ── line 1: identity ─────────────────────────────────────────
|
||||||
const head = el('div', { class: 'head' });
|
const head = el('div', { class: 'head' });
|
||||||
head.append(
|
head.append(
|
||||||
el('a', { class: 'name', href: url }, c.name),
|
el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
|
||||||
el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' },
|
el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' },
|
||||||
c.is_manager ? 'm1nd' : 'ag3nt'),
|
c.is_manager ? 'm1nd' : 'ag3nt'),
|
||||||
);
|
);
|
||||||
|
|
@ -135,7 +118,8 @@
|
||||||
el('span', { class: 'spinner' }, '◐'), ' ', c.pending + '…'));
|
el('span', { class: 'spinner' }, '◐'), ' ', c.pending + '…'));
|
||||||
} else if (c.needs_login) {
|
} else if (c.needs_login) {
|
||||||
head.append(el('a',
|
head.append(el('a',
|
||||||
{ class: 'badge badge-warn', href: url }, 'needs login →'));
|
{ class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' },
|
||||||
|
'needs login →'));
|
||||||
}
|
}
|
||||||
if (c.needs_update) {
|
if (c.needs_update) {
|
||||||
head.append(form(
|
head.append(form(
|
||||||
|
|
@ -182,6 +166,66 @@
|
||||||
root.append(ul);
|
root.append(ul);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderTombstones(s) {
|
||||||
|
const root = $('tombstones-section');
|
||||||
|
root.innerHTML = '';
|
||||||
|
if (!s.tombstones || !s.tombstones.length) {
|
||||||
|
root.append(el('p', { class: 'empty' }, 'no kept state — clean'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fmtBytes = (n) => {
|
||||||
|
if (n < 1024) return n + ' B';
|
||||||
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||||
|
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||||
|
};
|
||||||
|
const fmtAge = (ts) => {
|
||||||
|
if (!ts) return '?';
|
||||||
|
const d = Math.floor((Date.now() / 1000 - ts) / 86400);
|
||||||
|
if (d <= 0) return 'today';
|
||||||
|
if (d === 1) return '1 day ago';
|
||||||
|
return d + ' days ago';
|
||||||
|
};
|
||||||
|
const ul = el('ul', { class: 'containers' });
|
||||||
|
for (const t of s.tombstones) {
|
||||||
|
const li = el('li', { class: 'container-row tombstone' });
|
||||||
|
const head = el('div', { class: 'head' });
|
||||||
|
head.append(
|
||||||
|
el('span', { class: 'name' }, t.name),
|
||||||
|
el('span', { class: 'badge badge-muted' }, 'destroyed'),
|
||||||
|
);
|
||||||
|
if (t.has_creds) {
|
||||||
|
head.append(el('span', { class: 'badge badge-muted' }, 'creds kept'));
|
||||||
|
}
|
||||||
|
head.append(el('span', { class: 'meta' },
|
||||||
|
`${fmtBytes(t.state_bytes)} · ${fmtAge(t.last_seen)}`));
|
||||||
|
li.append(head);
|
||||||
|
|
||||||
|
const actions = el('div', { class: 'actions' });
|
||||||
|
// Reuse the existing spawn form pattern via /request-spawn — operator
|
||||||
|
// can queue an approval that recreates the agent with the same name
|
||||||
|
// and reuses the kept state.
|
||||||
|
const respawn = el('form', {
|
||||||
|
method: 'POST', action: '/request-spawn',
|
||||||
|
class: 'inline', 'data-async': '',
|
||||||
|
'data-confirm': 'queue spawn approval for ' + t.name + '? state will be reused.',
|
||||||
|
});
|
||||||
|
respawn.append(
|
||||||
|
el('input', { type: 'hidden', name: 'name', value: t.name }),
|
||||||
|
el('button', { type: 'submit', class: 'btn btn-start' }, '⊕ R3V1V3'),
|
||||||
|
);
|
||||||
|
actions.append(respawn);
|
||||||
|
actions.append(form(
|
||||||
|
'/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3',
|
||||||
|
'PURGE ' + t.name + '? config history, claude creds, /state/ notes '
|
||||||
|
+ 'are all WIPED. no undo.',
|
||||||
|
));
|
||||||
|
li.append(actions);
|
||||||
|
ul.append(li);
|
||||||
|
}
|
||||||
|
root.append(ul);
|
||||||
|
}
|
||||||
|
|
||||||
function renderQuestions(s) {
|
function renderQuestions(s) {
|
||||||
const root = $('questions-section');
|
const root = $('questions-section');
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
|
|
@ -276,6 +320,24 @@
|
||||||
function renderApprovals(s) {
|
function renderApprovals(s) {
|
||||||
const root = $('approvals-section');
|
const root = $('approvals-section');
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
|
|
||||||
|
// Spawn request form: submitting it queues a Spawn approval that
|
||||||
|
// lands in this same list, so the form belongs here rather than on
|
||||||
|
// the containers list (the agent doesn't exist yet).
|
||||||
|
const spawn = el('form', {
|
||||||
|
method: 'POST', action: '/request-spawn',
|
||||||
|
class: 'spawnform', 'data-async': '',
|
||||||
|
});
|
||||||
|
spawn.append(
|
||||||
|
el('input', {
|
||||||
|
name: 'name',
|
||||||
|
placeholder: 'new agent name (≤9 chars)',
|
||||||
|
maxlength: '9', required: '', autocomplete: 'off',
|
||||||
|
}),
|
||||||
|
el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'),
|
||||||
|
);
|
||||||
|
root.append(spawn);
|
||||||
|
|
||||||
if (!s.approvals.length) {
|
if (!s.approvals.length) {
|
||||||
root.append(el('p', { class: 'empty' }, 'queue empty'));
|
root.append(el('p', { class: 'empty' }, 'queue empty'));
|
||||||
return;
|
return;
|
||||||
|
|
@ -331,6 +393,7 @@
|
||||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||||
const s = await resp.json();
|
const s = await resp.json();
|
||||||
renderContainers(s);
|
renderContainers(s);
|
||||||
|
renderTombstones(s);
|
||||||
renderQuestions(s);
|
renderQuestions(s);
|
||||||
renderInbox(s);
|
renderInbox(s);
|
||||||
renderApprovals(s);
|
renderApprovals(s);
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,16 @@ a:hover {
|
||||||
color: var(--amber); border-color: var(--amber);
|
color: var(--amber); border-color: var(--amber);
|
||||||
text-shadow: 0 0 6px rgba(250, 179, 135, 0.5);
|
text-shadow: 0 0 6px rgba(250, 179, 135, 0.5);
|
||||||
}
|
}
|
||||||
|
.badge-muted {
|
||||||
|
color: var(--muted); border-color: var(--purple-dim);
|
||||||
|
background: rgba(127, 132, 156, 0.08);
|
||||||
|
}
|
||||||
|
.container-row.tombstone {
|
||||||
|
border-style: dashed;
|
||||||
|
background: rgba(24, 24, 37, 0.35);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.container-row.tombstone .name { color: var(--muted); }
|
||||||
.pending-state {
|
.pending-state {
|
||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@
|
||||||
<p class="meta">loading…</p>
|
<p class="meta">loading…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2>◆ K3PT ST4T3 ◆</h2>
|
||||||
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
|
<div id="tombstones-section">
|
||||||
|
<p class="meta">loading…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>◆ M1ND H4S QU3STI0NS ◆</h2>
|
<h2>◆ M1ND H4S QU3STI0NS ◆</h2>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
<div id="questions-section">
|
<div id="questions-section">
|
||||||
|
|
|
||||||
|
|
@ -204,4 +204,23 @@ impl Coordinator {
|
||||||
pub fn agent_applied_dir(name: &str) -> PathBuf {
|
pub fn agent_applied_dir(name: &str) -> PathBuf {
|
||||||
PathBuf::from(format!("{APPLIED_STATE_ROOT}/{name}"))
|
PathBuf::from(format!("{APPLIED_STATE_ROOT}/{name}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enumerate names that have a persistent state dir under
|
||||||
|
/// `/var/lib/hyperhive/agents/` (i.e. config / claude creds /
|
||||||
|
/// notes survive). Includes both currently-existing containers and
|
||||||
|
/// destroyed-but-kept tombstones; callers filter the latter by
|
||||||
|
/// subtracting `lifecycle::list()`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn kept_state_names() -> Vec<String> {
|
||||||
|
let Ok(rd) = std::fs::read_dir(AGENT_STATE_ROOT) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut out: Vec<String> = rd
|
||||||
|
.flatten()
|
||||||
|
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
|
||||||
|
.filter_map(|e| e.file_name().into_string().ok())
|
||||||
|
.collect();
|
||||||
|
out.sort();
|
||||||
|
out
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
.route("/rebuild/{name}", post(post_rebuild))
|
.route("/rebuild/{name}", post(post_rebuild))
|
||||||
.route("/update-all", post(post_update_all))
|
.route("/update-all", post(post_update_all))
|
||||||
.route("/answer-question/{id}", post(post_answer_question))
|
.route("/answer-question/{id}", post(post_answer_question))
|
||||||
|
.route("/purge-tombstone/{name}", post(post_purge_tombstone))
|
||||||
.route("/request-spawn", post(post_request_spawn))
|
.route("/request-spawn", post(post_request_spawn))
|
||||||
.route("/messages/stream", get(messages_stream))
|
.route("/messages/stream", get(messages_stream))
|
||||||
.with_state(AppState { coord });
|
.with_state(AppState { coord });
|
||||||
|
|
@ -106,6 +107,21 @@ struct StateSnapshot {
|
||||||
/// we mark the row answered and fire `HelperEvent::OperatorAnswered`
|
/// we mark the row answered and fire `HelperEvent::OperatorAnswered`
|
||||||
/// into the manager's inbox.
|
/// into the manager's inbox.
|
||||||
questions: Vec<crate::operator_questions::OpQuestion>,
|
questions: Vec<crate::operator_questions::OpQuestion>,
|
||||||
|
/// State dirs (config history + claude creds + /state/ notes) that
|
||||||
|
/// survive after a destroy-without-purge. The operator can re-spawn
|
||||||
|
/// with the same name to resume, or PURG3 to wipe them.
|
||||||
|
tombstones: Vec<TombstoneView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct TombstoneView {
|
||||||
|
name: String,
|
||||||
|
/// Bytes used by the state dir tree. Cheap-ish to compute; let the
|
||||||
|
/// operator know how much they're holding onto.
|
||||||
|
state_bytes: u64,
|
||||||
|
/// Mtime (unix seconds) of the state dir; rough "last seen".
|
||||||
|
last_seen: i64,
|
||||||
|
has_creds: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -145,6 +161,7 @@ struct ApprovalView {
|
||||||
diff_html: Option<String>,
|
diff_html: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
||||||
let host = headers
|
let host = headers
|
||||||
.get("host")
|
.get("host")
|
||||||
|
|
@ -242,6 +259,35 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let questions = state.coord.questions.pending().unwrap_or_default();
|
let questions = state.coord.questions.pending().unwrap_or_default();
|
||||||
|
|
||||||
|
// Tombstones: state-dir names that don't appear in the live container
|
||||||
|
// list (and aren't the manager). Operator can re-spawn or PURG3.
|
||||||
|
let live: std::collections::HashSet<String> = containers
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
.chain(state.coord.transient_snapshot().into_keys())
|
||||||
|
.collect();
|
||||||
|
let tombstones: Vec<TombstoneView> = Coordinator::kept_state_names()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|name| name != MANAGER_NAME && !live.contains(name))
|
||||||
|
.map(|name| {
|
||||||
|
let root = Coordinator::agent_state_root(&name);
|
||||||
|
let state_bytes = dir_size_bytes(&root);
|
||||||
|
let last_seen = std::fs::metadata(&root)
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let has_creds = claude_has_session(&Coordinator::agent_claude_dir(&name));
|
||||||
|
TombstoneView {
|
||||||
|
name,
|
||||||
|
state_bytes,
|
||||||
|
last_seen,
|
||||||
|
has_creds,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
axum::Json(StateSnapshot {
|
axum::Json(StateSnapshot {
|
||||||
hostname,
|
hostname,
|
||||||
manager_port: MANAGER_PORT,
|
manager_port: MANAGER_PORT,
|
||||||
|
|
@ -251,9 +297,33 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
|
||||||
approvals: approval_views,
|
approvals: approval_views,
|
||||||
operator_inbox,
|
operator_inbox,
|
||||||
questions,
|
questions,
|
||||||
|
tombstones,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sum the byte size of every regular file under `root`. Cheap to compute
|
||||||
|
/// for typical agent state (config repo + claude creds + notes file —
|
||||||
|
/// usually a few MB); fine to do inline on each /api/state. Returns 0 on
|
||||||
|
/// any error.
|
||||||
|
fn dir_size_bytes(root: &Path) -> u64 {
|
||||||
|
fn walk(p: &Path, acc: &mut u64) {
|
||||||
|
let Ok(rd) = std::fs::read_dir(p) else { return };
|
||||||
|
for entry in rd.flatten() {
|
||||||
|
let Ok(ft) = entry.file_type() else { continue };
|
||||||
|
if ft.is_dir() {
|
||||||
|
walk(&entry.path(), acc);
|
||||||
|
} else if ft.is_file()
|
||||||
|
&& let Ok(meta) = entry.metadata()
|
||||||
|
{
|
||||||
|
*acc += meta.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut total = 0u64;
|
||||||
|
walk(root, &mut total);
|
||||||
|
total
|
||||||
|
}
|
||||||
|
|
||||||
async fn messages_stream(
|
async fn messages_stream(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
||||||
|
|
@ -316,6 +386,48 @@ async fn post_answer_question(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn post_purge_tombstone(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(name): AxumPath<String>,
|
||||||
|
) -> Response {
|
||||||
|
if name == lifecycle::MANAGER_NAME {
|
||||||
|
return error_response("refusing to purge the manager's state");
|
||||||
|
}
|
||||||
|
// Sanity: refuse to purge if a live container still exists with this
|
||||||
|
// name. The dashboard already filters tombstones to non-live names,
|
||||||
|
// but the operator could send a stale POST.
|
||||||
|
let live = lifecycle::list().await.unwrap_or_default();
|
||||||
|
if live
|
||||||
|
.iter()
|
||||||
|
.any(|c| c == &format!("{}{name}", lifecycle::AGENT_PREFIX) || c == &name)
|
||||||
|
{
|
||||||
|
return error_response(&format!(
|
||||||
|
"refusing to purge {name}: container still exists — use DESTR0Y first"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
for dir in [
|
||||||
|
Coordinator::agent_state_root(&name),
|
||||||
|
Coordinator::agent_applied_dir(&name),
|
||||||
|
] {
|
||||||
|
if dir.exists()
|
||||||
|
&& let Err(e) = std::fs::remove_dir_all(&dir)
|
||||||
|
{
|
||||||
|
errors.push(format!("{}: {e}", dir.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = state
|
||||||
|
.coord
|
||||||
|
.approvals
|
||||||
|
.fail_pending_for_agent(&name, "agent state purged");
|
||||||
|
if errors.is_empty() {
|
||||||
|
tracing::info!(%name, "tombstone purged");
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
} else {
|
||||||
|
error_response(&format!("purge {name} partial: {}", errors.join(", ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn post_request_spawn(
|
async fn post_request_spawn(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Form(form): Form<RequestSpawnForm>,
|
Form(form): Form<RequestSpawnForm>,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ CREATE INDEX IF NOT EXISTS idx_operator_questions_pending
|
||||||
";
|
";
|
||||||
|
|
||||||
/// Add the `multi` column to pre-existing databases. `ALTER TABLE ADD COLUMN`
|
/// Add the `multi` column to pre-existing databases. `ALTER TABLE ADD COLUMN`
|
||||||
/// has no `IF NOT EXISTS` form in sqlite, so we check pragma_table_info first.
|
/// has no `IF NOT EXISTS` form in sqlite, so we check `pragma_table_info` first.
|
||||||
fn ensure_multi_column(conn: &Connection) -> Result<()> {
|
fn ensure_multi_column(conn: &Connection) -> Result<()> {
|
||||||
let has: bool = conn
|
let has: bool = conn
|
||||||
.prepare("SELECT 1 FROM pragma_table_info('operator_questions') WHERE name = 'multi'")?
|
.prepare("SELECT 1 FROM pragma_table_info('operator_questions') WHERE name = 'multi'")?
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue