remove hive and agent apis nothing calls: build-log routes, /api/compact, host.sock list, mcp.sock wake
This commit is contained in:
parent
cbfd7b2174
commit
36c7781245
28 changed files with 115 additions and 801 deletions
|
|
@ -152,13 +152,12 @@ Capturing the full stream (rather than a short tail buffer) matters
|
||||||
because real eval errors routinely run long — "tried alternatives"
|
because real eval errors routinely run long — "tried alternatives"
|
||||||
blocks alone are often 30+ lines — so a truncated tail would cut off
|
blocks alone are often 30+ lines — so a truncated tail would cut off
|
||||||
the actual failure and leave only the host journal holding the
|
the actual failure and leave only the host journal holding the
|
||||||
complete output. With this table the dashboard can surface the entire
|
complete output. This table holds the entire log; no HTTP route serves
|
||||||
log.
|
it.
|
||||||
|
|
||||||
Three indices:
|
Three indices:
|
||||||
|
|
||||||
- `(agent, started_at)` — backs the per-agent latest-N lookup used
|
- `(agent, started_at)` — per-agent lookup by recency.
|
||||||
by the agent card chip.
|
|
||||||
- `(status, finished_at)` — backs the retention sweep that runs
|
- `(status, finished_at)` — backs the retention sweep that runs
|
||||||
as part of the existing hourly vacuum.
|
as part of the existing hourly vacuum.
|
||||||
- `(node_id)` — added by a later migration so a build log row can be
|
- `(node_id)` — added by a later migration so a build log row can be
|
||||||
|
|
|
||||||
|
|
@ -101,10 +101,8 @@ state dirs work as for every other agent.
|
||||||
- **Wire-protocol** — `hive-core-agent-sock`'s unified `Request` enum
|
- **Wire-protocol** — `hive-core-agent-sock`'s unified `Request` enum
|
||||||
is the same on both socket flavours. Container lifecycle
|
is the same on both socket flavours. Container lifecycle
|
||||||
ops (kill/start/restart/rebuild) go through the separate host-admin
|
ops (kill/start/restart/rebuild) go through the separate host-admin
|
||||||
socket `hivectl` speaks. `Wake` (inject a `from: <X>` message straight
|
socket `hivectl` speaks. Matrix, bash and forge wake the agent by pushing a todo on the
|
||||||
into the caller's own inbox) is on this socket too, unprivileged on
|
harness's in-agent socket (see
|
||||||
both flavours; matrix, bash and forge don't call it — their
|
|
||||||
notifications push a todo on the harness's in-agent socket (see
|
|
||||||
[`docs/4-implementation/turn-loop/mcp.md`](../turn-loop/mcp.md#waking-the-agent-from-inside-the-container)).
|
[`docs/4-implementation/turn-loop/mcp.md`](../turn-loop/mcp.md#waking-the-agent-from-inside-the-container)).
|
||||||
- **Storage/mounts** — the `manage_root_agent` permission, which the
|
- **Storage/mounts** — the `manage_root_agent` permission, which the
|
||||||
[`ruth` archetype](../../2-handbook/swarm/archetypes.md#the-built-in-archetypes) grants, gets a container
|
[`ruth` archetype](../../2-handbook/swarm/archetypes.md#the-built-in-archetypes) grants, gets a container
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,8 @@ Its own long-running per-agent daemon (`hive-forge-notify`, a sibling of
|
||||||
`hive-bash-daemon` / `hive-matrix-daemon`) — it used
|
`hive-bash-daemon` / `hive-matrix-daemon`) — it used
|
||||||
to be a background task inside the `hive-agent` serve loop. Polls
|
to be a background task inside the `hive-agent` serve loop. Polls
|
||||||
`GET /api/v1/notifications?all=false` every 30 seconds (Forgejo's
|
`GET /api/v1/notifications?all=false` every 30 seconds (Forgejo's
|
||||||
unread-only filter), formats each notification as a broker
|
unread-only filter), formats each notification as a todo and pushes it to the agent's own
|
||||||
`Wake { from: "forge" }` message, and delivers it to the agent's own
|
in-agent socket so claude's normal turn loop picks it up.
|
||||||
inbox so claude's normal turn loop picks it up.
|
|
||||||
|
|
||||||
The daemon waits about ten minutes for the agent's forge token, then exits 0,
|
The daemon waits about ten minutes for the agent's forge token, then exits 0,
|
||||||
which its `Restart=on-failure` doesn't restart. A path unit
|
which its `Restart=on-failure` doesn't restart. A path unit
|
||||||
|
|
|
||||||
|
|
@ -276,7 +276,7 @@ Solution: an `nginx http`-context `map $http_accept $matrix_spa_target { ... }`
|
||||||
|
|
||||||
hive-c0re serves exactly two prefixes, so the dashboard routes by **path** — deterministic, where a content-type split would let one URL resolve differently by the caller's `Accept` header:
|
hive-c0re serves exactly two prefixes, so the dashboard routes by **path** — deterministic, where a content-type split would let one URL resolve differently by the caller's `Accept` header:
|
||||||
|
|
||||||
- `location /api/` → hive-c0re (`7000`): all dashboard data, actions, and the two SSE streams (`/api/dashboard/stream`, `/api/build-logs/id/{id}/stream`). `proxy_buffering off` and a 1d read timeout keep the streams live.
|
- `location /api/` → hive-c0re (`7000`): all dashboard data, actions, and the SSE stream (`/api/dashboard/stream`). `proxy_buffering off` and a 1d read timeout keep the stream live.
|
||||||
- `location /health/` → hive-c0re: liveness and readiness.
|
- `location /health/` → hive-c0re: liveness and readiness.
|
||||||
- `location /` → the dashboard dist (from the `servedFrontend` nix-store path) with `try_files $uri /index.html`.
|
- `location /` → the dashboard dist (from the `servedFrontend` nix-store path) with `try_files $uri /index.html`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -172,12 +172,12 @@ best-effort — a failed checkpoint or `/compact` never fails the turn that
|
||||||
already succeeded. An ACP agent gets the same policy; how it compacts is in
|
already succeeded. An ACP agent gets the same policy; how it compacts is in
|
||||||
[`hive-runtime/README.md`](../../../hive-runtime/README.md#acp-backend-compaction).
|
[`hive-runtime/README.md`](../../../hive-runtime/README.md#acp-backend-compaction).
|
||||||
|
|
||||||
The operator can force a compaction any time via `POST /api/compact`. It's
|
The agent can force a compaction via its `compact` MCP tool. It's
|
||||||
**deferred**: the handler sets `Bus::request_compact()` and returns
|
**deferred**: the tool sets `Bus::request_compact()` and returns
|
||||||
immediately; the harness runs the `/compact` at the next turn boundary
|
immediately; the harness runs the `/compact` at the next turn boundary
|
||||||
(end of the in-flight turn in `drive_turn`, or — when the agent is idle —
|
(end of the in-flight turn in `drive_turn`, or — when the agent is idle —
|
||||||
in `turn::run_pending_compact` on the serve loop's next empty poll). This
|
in `turn::run_pending_compact` on the serve loop's next empty poll). This
|
||||||
lets `/api/compact` work mid-turn instead of only when idle, without racing
|
lets the request work mid-turn instead of only when idle, without racing
|
||||||
a live claude process.
|
a live claude process.
|
||||||
|
|
||||||
To disable proactive compaction for a specific agent, use the nix option:
|
To disable proactive compaction for a specific agent, use the nix option:
|
||||||
|
|
|
||||||
|
|
@ -102,12 +102,12 @@ hive_name?, swarm_name?, matrix_accounts? }`. `matrix_accounts` is a
|
||||||
`{state_dir}/hyperhive-status`. Pass `""` to clear. Not gated by the
|
`{state_dir}/hyperhive-status`. Pass `""` to clear. Not gated by the
|
||||||
`meta` group — every agent needs to keep its dashboard status chip
|
`meta` group — every agent needs to keep its dashboard status chip
|
||||||
current regardless of which optional groups it holds.
|
current regardless of which optional groups it holds.
|
||||||
- `compact` — agent self-service equivalent of the operator dashboard's
|
- `compact` — queues a compaction of the current session; the only way to
|
||||||
`/compact` button. No args. Gated server-side on the last completed
|
request one. No args. Gated server-side on the last completed
|
||||||
turn's context usage: refused (with an explanation, no side effect)
|
turn's context usage: refused (with an explanation, no side effect)
|
||||||
unless usage is above 66% of the effective context window. On a
|
unless usage is above 66% of the effective context window. On a
|
||||||
pass, queues the same deferred `compact_pending` flag the dashboard
|
pass, queues the deferred `compact_pending` flag — consumed at the end
|
||||||
button sets — consumed at the end of the current turn, so it never
|
of the current turn, so it never
|
||||||
races a live claude process, and the usual pre-compaction
|
races a live claude process, and the usual pre-compaction
|
||||||
notes-checkpoint turn still fires first. Dispatched through the
|
notes-checkpoint turn still fires first. Dispatched through the
|
||||||
in-agent socket (`hive-agent-sock::Request::Compact`), not the
|
in-agent socket (`hive-agent-sock::Request::Compact`), not the
|
||||||
|
|
@ -159,15 +159,6 @@ makes the harness signal its own turn loop, with no hive-c0re
|
||||||
round-trip. An external MCP server (or any other in-container process)
|
round-trip. An external MCP server (or any other in-container process)
|
||||||
can push its own todo the same way — `subsystem` is any string.
|
can push its own todo the same way — `subsystem` is any string.
|
||||||
|
|
||||||
The host-served per-agent socket at `/run/hive/mcp.sock` carries a
|
|
||||||
lower-level `Wake` request (`hive_core_agent_sock::Request::Wake {
|
|
||||||
from, body }` — JSON-line, `{"cmd":"wake","from":"matrix","body":"new dm
|
|
||||||
from @alice"}\n`) that drops the body straight into the broker inbox as
|
|
||||||
`{from:<label>, to:<agent>, body}`, bypassing the todo layer entirely.
|
|
||||||
`hive-c0re` handles it; matrix, bash and forge don't call it. Identity = socket: anything that can connect to
|
|
||||||
`/run/hive/mcp.sock` is implicitly trusted to inject one — the
|
|
||||||
bind-mount is the agent's own container only.
|
|
||||||
|
|
||||||
## Authoritative state
|
## Authoritative state
|
||||||
|
|
||||||
`hive-agent`'s `events::Bus` carries the current turn-loop state in
|
`hive-agent`'s `events::Bus` carries the current turn-loop state in
|
||||||
|
|
@ -175,7 +166,7 @@ addition to the broadcast channel and the events history. Variants:
|
||||||
|
|
||||||
- `Idle` — sitting on `Recv` waiting for mail.
|
- `Idle` — sitting on `Recv` waiting for mail.
|
||||||
- `Thinking` — `claude --print` is running for a turn.
|
- `Thinking` — `claude --print` is running for a turn.
|
||||||
- `Compacting` — operator-triggered `/compact` is in flight.
|
- `Compacting` — a `/compact` is in flight.
|
||||||
|
|
||||||
The harness flips state at the relevant transitions
|
The harness flips state at the relevant transitions
|
||||||
(`set_state(Thinking)` before `drive_turn`, `set_state(Idle)`
|
(`set_state(Thinking)` before `drive_turn`, `set_state(Idle)`
|
||||||
|
|
|
||||||
|
|
@ -233,20 +233,19 @@ The state badge's menu carries the actions on the agent's claude:
|
||||||
credential files, parks the agent in `needs_login`, and preserves
|
credential files, parks the agent in `needs_login`, and preserves
|
||||||
session history (`~/.claude/projects/`).
|
session history (`~/.claude/projects/`).
|
||||||
|
|
||||||
`POST /api/compact` sets the deferred `Bus::request_compact()` flag and
|
The agent's `compact` MCP tool sets the deferred `Bus::request_compact()` flag and
|
||||||
returns immediately. The harness runs the `/compact` at the next turn
|
returns immediately. The harness runs the `/compact` at the next turn
|
||||||
boundary (end of the in-flight turn, or `turn::run_pending_compact` when
|
boundary (end of the in-flight turn, or `turn::run_pending_compact` when
|
||||||
idle); output streams into the live panel, which ends with a `/compact done`
|
idle); output streams into the live panel, which ends with a `/compact done`
|
||||||
or `/compact failed: …` note. It works mid-turn, not only when idle. Nothing
|
or `/compact failed: …` note. It works mid-turn, not only when idle.
|
||||||
on this page calls it now.
|
|
||||||
|
|
||||||
## Per-agent endpoints
|
## Per-agent endpoints
|
||||||
|
|
||||||
Successful POSTs return 200 (no 303 redirects). Error responses
|
Successful POSTs return 200 (no 303 redirects). Error responses
|
||||||
use semantic status codes: **400** for missing/malformed input
|
use semantic status codes: **400** for missing/malformed input
|
||||||
(`/api/todos/mark-done` with no todo ids), **500** only for genuine
|
(`/api/todos/mark-done` with no todo ids), **500** only for genuine
|
||||||
server/transport failures. `/compact` never conflicts: it queues
|
server/transport failures. A queued `/compact` never conflicts: it
|
||||||
and runs at the end of the current turn. The matching
|
runs at the end of the current turn. The matching
|
||||||
mutations fire `LiveEvent` variants on the per-agent bus, so the
|
mutations fire `LiveEvent` variants on the per-agent bus, so the
|
||||||
client doesn't refetch `/api/state` on submit — the SSE stream
|
client doesn't refetch `/api/state` on submit — the SSE stream
|
||||||
delivers the new state faster anyway.
|
delivers the new state faster anyway.
|
||||||
|
|
@ -257,11 +256,6 @@ credentials arrive from the swarm secret store.
|
||||||
|
|
||||||
- `POST /api/cancel` — SIGINT the in-flight claude turn and the
|
- `POST /api/cancel` — SIGINT the in-flight claude turn and the
|
||||||
commands it started. Emits a `LiveEvent::Note`.
|
commands it started. Emits a `LiveEvent::Note`.
|
||||||
- `POST /api/compact` — run `/compact` on the persistent session
|
|
||||||
(same MCP config + system prompt + allowed tools as a normal
|
|
||||||
turn — only the stdin payload differs). Flips state to
|
|
||||||
`Compacting` via `Bus::set_state`, which emits
|
|
||||||
`TurnStateChanged`.
|
|
||||||
- `POST /api/new-session` — arm a one-shot for the next turn to
|
- `POST /api/new-session` — arm a one-shot for the next turn to
|
||||||
drop `--continue`. Emits a `LiveEvent::Note`.
|
drop `--continue`. Emits a `LiveEvent::Note`.
|
||||||
- `POST /api/logout` — three-step teardown that re-uses the
|
- `POST /api/logout` — three-step teardown that re-uses the
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ conflicting agent.
|
||||||
|
|
||||||
Passive / rare-interaction state. `kept.js` renders it from the
|
Passive / rare-interaction state. `kept.js` renders it from the
|
||||||
`/api/state` snapshot and keeps it live from the `tombstones_changed`
|
`/api/state` snapshot and keeps it live from the `tombstones_changed`
|
||||||
stream event. (The rebuild queue and build logs are dashboard tabs too —
|
stream event. (The rebuild queue is a dashboard tab too —
|
||||||
see below.)
|
see below.)
|
||||||
|
|
||||||
Per-container CPU, memory and disk load isn't shown in the hive UI. The
|
Per-container CPU, memory and disk load isn't shown in the hive UI. The
|
||||||
|
|
@ -433,27 +433,6 @@ below — some endpoints aren't in it yet.
|
||||||
not yet delivered and sets `acked_at = now`. Returns
|
not yet delivered and sets `acked_at = now`. Returns
|
||||||
`{ "marked": N }`. Agent name validated against
|
`{ "marked": N }`. Agent name validated against
|
||||||
`[a-z0-9_-]`, 1-63 chars; 400 on bad input.
|
`[a-z0-9_-]`, 1-63 chars; 400 on bad input.
|
||||||
- `GET /api/build-logs?limit=N` — most-recent build log headers across
|
|
||||||
all agents (`limit` defaults to 30).
|
|
||||||
- `GET /api/build-log/{node_id}` and `/api/build-log/{node_id}/raw` — the
|
|
||||||
build log of one job-queue node, as JSON and as `text/plain`.
|
|
||||||
- `GET /api/build-logs/{agent}?limit=N` — most-recent build log
|
|
||||||
headers for one agent, newest first. Returns
|
|
||||||
`Vec<BuildLogHeader>` (JSON): `id`, `agent`, `kind`, `cmdline`,
|
|
||||||
`started_at`, `finished_at`, `status` (`"ok"` / `"fail"` /
|
|
||||||
`null` while in-progress). `limit` defaults to 10, server-side
|
|
||||||
cap at 50. Agent name validated (`[a-z0-9_-]`, 1-63 chars).
|
|
||||||
- `GET /api/build-logs/id/{id}` — full build log by id. Returns
|
|
||||||
`BuildLogFull` (JSON): all header fields plus `stdout` and
|
|
||||||
`stderr` as plain text (newline-terminated lines, utf-8). HTTP
|
|
||||||
404 when the row is missing (vacuum-reaped or stale id).
|
|
||||||
- `GET /api/build-logs/id/{id}/stream` — live SSE stream of
|
|
||||||
stdout+stderr for an in-progress build (newline-delimited JSON
|
|
||||||
frames). The
|
|
||||||
stream closes when the build finishes or errors.
|
|
||||||
- `GET /api/build-logs/id/{id}/raw` — raw concatenated
|
|
||||||
stdout+stderr as `text/plain`. Works on both finished and in-progress
|
|
||||||
build logs.
|
|
||||||
|
|
||||||
- `GET /api/dashboard/stream` — unified live event channel:
|
- `GET /api/dashboard/stream` — unified live event channel:
|
||||||
broker `sent` / `delivered`, plus the mutation events listed
|
broker `sent` / `delivered`, plus the mutation events listed
|
||||||
|
|
|
||||||
|
|
@ -66,24 +66,17 @@ No auth tokens exist on the per-agent unix sockets. The socket
|
||||||
bind-mount." A sub-agent only sees its own `/run/hive/mcp.sock`;
|
bind-mount." A sub-agent only sees its own `/run/hive/mcp.sock`;
|
||||||
hive-c0re owns the host admin socket.
|
hive-c0re owns the host admin socket.
|
||||||
|
|
||||||
### Wake injection
|
Matrix, bash and forge wake the agent by upserting a todo on the harness's
|
||||||
|
in-agent socket (`HIVE_AGENT_SOCKET`), which signals the turn loop without a
|
||||||
`hive_core_agent_sock::Request::Wake { from, body }` is the
|
|
||||||
wake-event-injection surface on the host-served per-agent socket.
|
|
||||||
Recipient is implicit — the agent the socket belongs to — and `from`
|
|
||||||
is caller-chosen so the wake prompt can label the source verbatim
|
|
||||||
(`"matrix: new message in #general"`, `"forge: PR #42 opened"`, etc.). <!-- lint:allow: example strings, not real tags -->
|
|
||||||
`hive-c0re` handles it; matrix, bash and forge don't call it — their
|
|
||||||
notifications upsert a todo on the harness's in-agent socket
|
|
||||||
(`HIVE_AGENT_SOCKET`), which signals the same turn loop without a
|
|
||||||
hive-c0re round-trip. See
|
hive-c0re round-trip. See
|
||||||
[`docs/4-implementation/turn-loop/mcp.md` § Waking the agent from inside the
|
[`docs/4-implementation/turn-loop/mcp.md` § Waking the agent from inside the
|
||||||
container](../../4-implementation/turn-loop/mcp.md#waking-the-agent-from-inside-the-container).
|
container](../../4-implementation/turn-loop/mcp.md#waking-the-agent-from-inside-the-container).
|
||||||
|
The host-served `/run/hive/mcp.sock` has no verb that injects a message into
|
||||||
|
the agent's own inbox.
|
||||||
|
|
||||||
Identity = socket means anything that can connect to
|
Anything that can connect to `/run/hive/mcp.sock` is implicitly trusted to
|
||||||
`/run/hive/mcp.sock` is implicitly trusted to inject wakes. That's
|
call its verbs. That's fine: the bind-mount only exposes the socket inside the
|
||||||
fine: the bind-mount only exposes the socket inside the agent's own
|
agent's own container, so the trust boundary is the container's process
|
||||||
container, so the trust boundary is the container's process
|
|
||||||
namespace, not the wire surface.
|
namespace, not the wire surface.
|
||||||
|
|
||||||
## Recipient sentinels
|
## Recipient sentinels
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ const STATE_TOOLTIPS: Record<string, string> = {
|
||||||
offline: "harness unreachable or claude not logged in",
|
offline: "harness unreachable or claude not logged in",
|
||||||
idle: "turn loop running, no claude invocation in flight",
|
idle: "turn loop running, no claude invocation in flight",
|
||||||
thinking: "claude is executing the current turn",
|
thinking: "claude is executing the current turn",
|
||||||
compacting: "operator-triggered /compact running on the persistent session",
|
compacting: "agent-requested /compact running on the persistent session",
|
||||||
};
|
};
|
||||||
|
|
||||||
// `total` here matches app.js's `renderOneUsage` exactly: input + both
|
// `total` here matches app.js's `renderOneUsage` exactly: input + both
|
||||||
|
|
|
||||||
|
|
@ -123,11 +123,11 @@ pub enum Request {
|
||||||
/// `ReminderRollup` against the broker; now served from the local store).
|
/// `ReminderRollup` against the broker; now served from the local store).
|
||||||
ReminderRollup { since_secs: u64 },
|
ReminderRollup { since_secs: u64 },
|
||||||
/// Agent self-service request to compact the current session, mirroring
|
/// Agent self-service request to compact the current session, mirroring
|
||||||
/// the operator's `/compact` dashboard button. Gated server-side: only
|
/// the `compact` MCP tool. Gated server-side: only
|
||||||
/// honoured when the last completed turn's context usage is above 66%
|
/// honoured when the last completed turn's context usage is above 66%
|
||||||
/// of the effective context window — below that, `Response::Err`
|
/// of the effective context window — below that, `Response::Err`
|
||||||
/// explains why and takes no action. On a pass, queues the same
|
/// explains why and takes no action. On a pass, queues the same
|
||||||
/// deferred `compact_pending` flag the operator's button sets (consumed
|
/// deferred `compact_pending` flag (consumed
|
||||||
/// at the next turn boundary), so it never races a live claude process.
|
/// at the next turn boundary), so it never races a live claude process.
|
||||||
/// `wake_prompt`, when set, is driven as a synthetic follow-up turn once
|
/// `wake_prompt`, when set, is driven as a synthetic follow-up turn once
|
||||||
/// the compaction actually finishes — the dashboard button's own
|
/// the compaction actually finishes — the dashboard button's own
|
||||||
|
|
|
||||||
|
|
@ -251,8 +251,7 @@ pub enum TurnState {
|
||||||
Idle,
|
Idle,
|
||||||
/// `claude --print` is running for a turn.
|
/// `claude --print` is running for a turn.
|
||||||
Thinking,
|
Thinking,
|
||||||
/// Operator-triggered `/compact` is running on the persistent
|
/// A `/compact` is running on the persistent session.
|
||||||
/// session.
|
|
||||||
Compacting,
|
Compacting,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,9 +315,8 @@ pub struct Bus {
|
||||||
/// boundary keeps compaction from racing a live claude process mid-turn.
|
/// boundary keeps compaction from racing a live claude process mid-turn.
|
||||||
/// `Some(request)` when a compact is pending; `request.wake_prompt` is
|
/// `Some(request)` when a compact is pending; `request.wake_prompt` is
|
||||||
/// what to drive as a synthetic follow-up turn once the compaction
|
/// what to drive as a synthetic follow-up turn once the compaction
|
||||||
/// actually completes (`None` = pending but no follow-up wake wanted,
|
/// actually completes (`None` = pending but no follow-up wake wanted). `None` = no
|
||||||
/// e.g. the operator dashboard's `/compact` button). `None` = no compact
|
/// compact pending. Wrapped in [`CompactRequest`] rather than
|
||||||
/// pending. Wrapped in [`CompactRequest`] rather than
|
|
||||||
/// `Option<Option<String>>` (clippy pedantic's `option_option` lint,
|
/// `Option<Option<String>>` (clippy pedantic's `option_option` lint,
|
||||||
/// and the named field reads clearer at call sites than a bare nested
|
/// and the named field reads clearer at call sites than a bare nested
|
||||||
/// `Option`) so "pending" and "what to wake with" can never desync.
|
/// `Option`) so "pending" and "what to wake with" can never desync.
|
||||||
|
|
@ -491,8 +489,7 @@ impl Bus {
|
||||||
/// serviced just overwrites `wake_prompt` with the latest ask. `Some
|
/// serviced just overwrites `wake_prompt` with the latest ask. `Some
|
||||||
/// (wake_prompt)` schedules a synthetic follow-up turn (driven with
|
/// (wake_prompt)` schedules a synthetic follow-up turn (driven with
|
||||||
/// `wake_prompt` as its body) once the compaction actually completes;
|
/// `wake_prompt` as its body) once the compaction actually completes;
|
||||||
/// `None` requests a plain compact with no follow-up wake (the operator
|
/// `None` requests a plain compact with no follow-up wake.
|
||||||
/// dashboard's `/compact` button).
|
|
||||||
pub fn request_compact(&self, wake_prompt: Option<String>) {
|
pub fn request_compact(&self, wake_prompt: Option<String>) {
|
||||||
*self.compact_pending.lock().unwrap() = Some(CompactRequest { wake_prompt });
|
*self.compact_pending.lock().unwrap() = Some(CompactRequest { wake_prompt });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,8 @@
|
||||||
//! reminder op then returns `Response::Err`); a reminder *firing* is a
|
//! reminder op then returns `Response::Err`); a reminder *firing* is a
|
||||||
//! separate path (`reminder_timer`), not driven through this socket.
|
//! separate path (`reminder_timer`), not driven through this socket.
|
||||||
//! `Request::Compact` is the odd one out — it doesn't touch any store, just
|
//! `Request::Compact` is the odd one out — it doesn't touch any store, just
|
||||||
//! the harness's [`Bus`] (gate-checked context usage, then the same
|
//! the harness's [`Bus`] (gate-checked context usage, then the deferred
|
||||||
//! deferred `compact_pending` flag the operator dashboard's `/compact`
|
//! `compact_pending` flag). No hive-c0re round-trip, no broker long-poll, no marker
|
||||||
//! button sets). No hive-c0re round-trip, no broker long-poll, no marker
|
|
||||||
//! files.
|
//! files.
|
||||||
//!
|
//!
|
||||||
//! One request/response line per connection, matching the producers'
|
//! One request/response line per connection, matching the producers'
|
||||||
|
|
@ -39,8 +38,7 @@ use crate::todos::{Todo, Todos};
|
||||||
/// Context-usage floor for `Request::Compact`: below this fraction of the
|
/// Context-usage floor for `Request::Compact`: below this fraction of the
|
||||||
/// effective context window, an agent-initiated compact is refused (nothing
|
/// effective context window, an agent-initiated compact is refused (nothing
|
||||||
/// meaningful to reclaim yet, and it'd just burn a compaction pass on a
|
/// meaningful to reclaim yet, and it'd just burn a compaction pass on a
|
||||||
/// near-empty session). Same number a human operator would eyeball off the
|
/// near-empty session).
|
||||||
/// dashboard's `ctx·Nk` badge before hitting the `/compact` button.
|
|
||||||
const COMPACT_MIN_USAGE_FRACTION: f64 = 0.66;
|
const COMPACT_MIN_USAGE_FRACTION: f64 = 0.66;
|
||||||
|
|
||||||
/// Pause after a failed `accept`, so a failure that persists (fd exhaustion)
|
/// Pause after a failed `accept`, so a failure that persists (fd exhaustion)
|
||||||
|
|
@ -434,13 +432,10 @@ fn mark_todos_done(store: &Todos, ids: &[i64]) -> Response {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `Request::Compact` handler: gate on context usage, then queue the same
|
/// `Request::Compact` handler: gate on context usage, then queue the
|
||||||
/// deferred `compact_pending` flag the operator's `/compact` button sets.
|
/// deferred `compact_pending` flag. Refuses below
|
||||||
/// Mirrors `hive-agent::web_ui::actions::post_compact` but reachable from
|
/// [`COMPACT_MIN_USAGE_FRACTION`] because an agent can call this
|
||||||
/// the agent's own MCP tool instead of the dashboard, and refuses below
|
/// speculatively. `wake_prompt`, when set, is
|
||||||
/// [`COMPACT_MIN_USAGE_FRACTION`] instead of always honouring the request —
|
|
||||||
/// an agent can call this speculatively, a human clicking the dashboard
|
|
||||||
/// button already made the judgment call. `wake_prompt`, when set, is
|
|
||||||
/// forwarded to `Bus::request_compact` so the turn loop drives a synthetic
|
/// forwarded to `Bus::request_compact` so the turn loop drives a synthetic
|
||||||
/// follow-up turn once the compaction actually finishes.
|
/// follow-up turn once the compaction actually finishes.
|
||||||
fn compact(bus: &Bus, wake_prompt: Option<String>) -> Response {
|
fn compact(bus: &Bus, wake_prompt: Option<String>) -> Response {
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ and end the turn.";
|
||||||
/// passed here — they live at `/etc/claude-code/managed-settings.json`
|
/// passed here — they live at `/etc/claude-code/managed-settings.json`
|
||||||
/// and claude auto-discovers them.
|
/// and claude auto-discovers them.
|
||||||
/// Materialised at harness startup; shared between the turn loop and the
|
/// Materialised at harness startup; shared between the turn loop and the
|
||||||
/// operator-driven `/compact` path so both invocations look identical to
|
/// `/compact` path so both invocations look identical to
|
||||||
/// claude (same MCP surface, same allowed tools, same role prompt — only the
|
/// claude (same MCP surface, same allowed tools, same role prompt — only the
|
||||||
/// stdin payload differs). The MCP config is rewritten before each
|
/// stdin payload differs). The MCP config is rewritten before each
|
||||||
/// invocation ([`TurnFiles::refresh_mcp_config`]), since which servers it
|
/// invocation ([`TurnFiles::refresh_mcp_config`]), since which servers it
|
||||||
|
|
@ -161,7 +161,7 @@ pub async fn write_system_prompt(socket: &Path, label: &str) -> Result<PathBuf>
|
||||||
/// One claude turn's outcome: `Ok(compacted)` on success, or a [`TurnError`]
|
/// One claude turn's outcome: `Ok(compacted)` on success, or a [`TurnError`]
|
||||||
/// the serve loop must act on. The `compacted` bool is `true` when a
|
/// the serve loop must act on. The `compacted` bool is `true` when a
|
||||||
/// compaction ran this turn (reactively on overflow, or proactively per the
|
/// compaction ran this turn (reactively on overflow, or proactively per the
|
||||||
/// policy — or a successful operator `/compact` at turn end); it's recorded as
|
/// policy — or a successful agent-requested `/compact` at turn end); it's recorded as
|
||||||
/// `result_kind = "compacted"` in turn stats so the stats page can distinguish
|
/// `result_kind = "compacted"` in turn stats so the stats page can distinguish
|
||||||
/// those turns. Both `Ok(true)` and `Ok(false)` are ack'd; the error cases
|
/// those turns. Both `Ok(true)` and `Ok(false)` are ack'd; the error cases
|
||||||
/// each map to a distinct serve-loop action (see [`emit_turn_end`] and the
|
/// each map to a distinct serve-loop action (see [`emit_turn_end`] and the
|
||||||
|
|
@ -452,8 +452,7 @@ pub async fn drive_turn(
|
||||||
archive_session(bus, session);
|
archive_session(bus, session);
|
||||||
return Err(TurnError::PromptTooLong);
|
return Err(TurnError::PromptTooLong);
|
||||||
}
|
}
|
||||||
// Operator `/compact` (`POST /api/compact`) or an agent's own `compact`
|
// An agent's own `compact` MCP tool call, deferred to the turn boundary: run it now that the turn
|
||||||
// MCP tool call, deferred to the turn boundary: run it now that the turn
|
|
||||||
// is done, so it works mid-turn rather than only when the agent is idle.
|
// is done, so it works mid-turn rather than only when the agent is idle.
|
||||||
// Only on a healthy turn — no point spawning a compaction after a
|
// Only on a healthy turn — no point spawning a compaction after a
|
||||||
// rate-limited / auth-failed / crashed one. `is_ok()` first: `take_compact()`
|
// rate-limited / auth-failed / crashed one. `is_ok()` first: `take_compact()`
|
||||||
|
|
@ -485,7 +484,7 @@ pub async fn drive_turn(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit the `/compact done` / `/compact failed: …` note for a finished
|
/// Emit the `/compact done` / `/compact failed: …` note for a finished
|
||||||
/// operator or agent `/compact` and return whether it succeeded. Shared by the
|
/// agent-requested `/compact` and return whether it succeeded. Shared by the
|
||||||
/// turn-end path in [`drive_turn`] and the idle path in [`run_pending_compact`].
|
/// turn-end path in [`drive_turn`] and the idle path in [`run_pending_compact`].
|
||||||
fn report_compact<E: std::fmt::Display>(bus: &Bus, result: Result<(), E>) -> bool {
|
fn report_compact<E: std::fmt::Display>(bus: &Bus, result: Result<(), E>) -> bool {
|
||||||
match result {
|
match result {
|
||||||
|
|
@ -620,7 +619,7 @@ pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Service a pending operator `/compact` (`Bus::request_compact`) while the
|
/// Service a pending `/compact` (`Bus::request_compact`) while the
|
||||||
/// agent is idle — the serve loop calls this when a `recv` returns no message,
|
/// agent is idle — the serve loop calls this when a `recv` returns no message,
|
||||||
/// so a queued `/compact` runs even when no turn is driving. (The in-flight
|
/// so a queued `/compact` runs even when no turn is driving. (The in-flight
|
||||||
/// case is handled at the end of [`drive_turn`].) Resume-only via
|
/// case is handled at the end of [`drive_turn`].) Resume-only via
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! Operator action POST handlers (cancel, compact, reset, todos mark-done).
|
//! Operator action POST handlers (cancel, reset, todos mark-done).
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Form,
|
Form,
|
||||||
|
|
@ -16,22 +16,6 @@ pub(super) async fn post_cancel_turn(State(state): State<AppState>) -> Response
|
||||||
(axum::http::StatusCode::OK, "ok").into_response()
|
(axum::http::StatusCode::OK, "ok").into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Operator-initiated `/compact`. Deferred: sets the `compact_pending` flag
|
|
||||||
/// that `turn::drive_turn` consumes at the end of the current/next turn, so it
|
|
||||||
/// works while a turn is in flight (a mid-turn compaction would race the live
|
|
||||||
/// claude process) rather than only when the agent is idle. Returns 200
|
|
||||||
/// immediately; the compaction stream lands in the live panel when it runs.
|
|
||||||
pub(super) async fn post_compact(State(state): State<AppState>) -> Response {
|
|
||||||
// No wake prompt: the operator is watching the dashboard, not waiting on
|
|
||||||
// an inbox message — `request_compact`'s wake-prompt arg exists for the
|
|
||||||
// agent's own `compact` MCP tool (`todo_server.rs::compact`).
|
|
||||||
state.bus.request_compact(None);
|
|
||||||
state.bus.emit(crate::events::LiveEvent::Note {
|
|
||||||
text: "operator: /compact queued — runs at the end of the current turn".into(),
|
|
||||||
});
|
|
||||||
(axum::http::StatusCode::OK, "ok").into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request a session reset. The current session is archived (its backing
|
/// Request a session reset. The current session is archived (its backing
|
||||||
/// `<uuid>.jsonl` renamed out of claude's resolution glob) at the next turn
|
/// `<uuid>.jsonl` renamed out of claude's resolution glob) at the next turn
|
||||||
/// boundary, so the following turn's `--resume` misses and self-heals into a
|
/// boundary, so the following turn's `--resume` misses and self-heals into a
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,6 @@ fn routes() -> Router<AppState> {
|
||||||
.route("/events/stream", get(stream::events_stream))
|
.route("/events/stream", get(stream::events_stream))
|
||||||
.route("/events/history", get(stream::events_history))
|
.route("/events/history", get(stream::events_history))
|
||||||
.route("/api/cancel", post(actions::post_cancel_turn))
|
.route("/api/cancel", post(actions::post_cancel_turn))
|
||||||
.route("/api/compact", post(actions::post_compact))
|
|
||||||
.route("/api/new-session", post(actions::post_new_session))
|
.route("/api/new-session", post(actions::post_new_session))
|
||||||
.route("/api/logout", post(auth::post_logout))
|
.route("/api/logout", post(auth::post_logout))
|
||||||
.route("/api/todos", get(stats::api_todos))
|
.route("/api/todos", get(stats::api_todos))
|
||||||
|
|
@ -532,7 +531,7 @@ mod tests {
|
||||||
/// either.
|
/// either.
|
||||||
#[test]
|
#[test]
|
||||||
fn neither_model_nor_effort_has_a_route() {
|
fn neither_model_nor_effort_has_a_route() {
|
||||||
let (model, effort, compact) = crate::paths::with_harness_dir(|| {
|
let (model, effort, existing) = crate::paths::with_harness_dir(|| {
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
label: "test".to_owned(),
|
label: "test".to_owned(),
|
||||||
login: Arc::new(Mutex::new(LoginState::Online)),
|
login: Arc::new(Mutex::new(LoginState::Online)),
|
||||||
|
|
@ -550,17 +549,17 @@ mod tests {
|
||||||
let effort = app.clone().oneshot(form("/api/effort", "effort=high"));
|
let effort = app.clone().oneshot(form("/api/effort", "effort=high"));
|
||||||
// A route the router does serve, so the 404s above are not
|
// A route the router does serve, so the 404s above are not
|
||||||
// the router refusing everything.
|
// the router refusing everything.
|
||||||
let compact = app.oneshot(form("/api/compact", ""));
|
let existing = app.oneshot(form("/api/new-session", ""));
|
||||||
(
|
(
|
||||||
model.await.unwrap(),
|
model.await.unwrap(),
|
||||||
effort.await.unwrap(),
|
effort.await.unwrap(),
|
||||||
compact.await.unwrap(),
|
existing.await.unwrap(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
assert_eq!(model.status(), StatusCode::NOT_FOUND);
|
assert_eq!(model.status(), StatusCode::NOT_FOUND);
|
||||||
assert_eq!(effort.status(), StatusCode::NOT_FOUND);
|
assert_eq!(effort.status(), StatusCode::NOT_FOUND);
|
||||||
assert_eq!(compact.status(), StatusCode::OK);
|
assert_eq!(existing.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll `path` until it holds a complete, newline-terminated pid.
|
/// Poll `path` until it holds a complete, newline-terminated pid.
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,7 @@ pub struct Coordinator {
|
||||||
pub broker: Arc<Broker>,
|
pub broker: Arc<Broker>,
|
||||||
/// Full build-log capture. `lifecycle::run` `start()`s a row per attempt,
|
/// Full build-log capture. `lifecycle::run` `start()`s a row per attempt,
|
||||||
/// pipes every stdout/stderr line into it, and `finish()`es it on
|
/// pipes every stdout/stderr line into it, and `finish()`es it on
|
||||||
/// child exit. Dashboard reads it via `list_recent_for_agent` /
|
/// child exit. See `build_logs.rs` for retention.
|
||||||
/// `get_full` for the per-card chip + side-panel viewer. See
|
|
||||||
/// `build_logs.rs` for retention.
|
|
||||||
pub build_logs: Arc<crate::build_logs::BuildLogs>,
|
pub build_logs: Arc<crate::build_logs::BuildLogs>,
|
||||||
/// Store path of the hyperhive flake source (no fragment). Used to
|
/// Store path of the hyperhive flake source (no fragment). Used to
|
||||||
/// identify the running revision (`current_flake_rev`).
|
/// identify the running revision (`current_flake_rev`).
|
||||||
|
|
@ -1061,7 +1059,7 @@ mod open_tests {
|
||||||
|
|
||||||
let coord = open(&db);
|
let coord = open(&db);
|
||||||
let row = coord.build_logs.get_full(id).expect("get").expect("Some");
|
let row = coord.build_logs.get_full(id).expect("get").expect("Some");
|
||||||
assert_eq!(row.header.status.as_deref(), Some("fail"));
|
assert_eq!(row.status.as_deref(), Some("fail"));
|
||||||
assert!(row.header.finished_at.is_some());
|
assert!(row.finished_at.is_some());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,365 +0,0 @@
|
||||||
//! Build-log endpoints for the dashboard.
|
|
||||||
//!
|
|
||||||
//! Header lists (all-agents + per-agent), the full row by id, a `text/plain`
|
|
||||||
//! download, and an SSE stream that delivers incremental stdout/stderr while
|
|
||||||
//! a build runs (closing once it finishes or the row is vacuum-reaped).
|
|
||||||
|
|
||||||
use std::convert::Infallible;
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
extract::{Path as AxumPath, State},
|
|
||||||
http::StatusCode,
|
|
||||||
response::{
|
|
||||||
IntoResponse, Response,
|
|
||||||
sse::{Event, KeepAlive, Sse},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tokio_stream::Stream;
|
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
|
||||||
use utoipa::IntoParams;
|
|
||||||
|
|
||||||
use super::{AppState, Ident, error_response};
|
|
||||||
use crate::build_logs::{BuildLogFull, BuildLogHeader};
|
|
||||||
|
|
||||||
#[derive(Deserialize, IntoParams)]
|
|
||||||
pub(super) struct BuildLogsAllQuery {
|
|
||||||
/// Max rows to return. Capped at 100. Default 30.
|
|
||||||
#[serde(default)]
|
|
||||||
limit: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Most-recent build log headers across
|
|
||||||
/// all agents, newest first.
|
|
||||||
///
|
|
||||||
/// Same JSON shape as the per-agent endpoint.
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/build-logs",
|
|
||||||
params(BuildLogsAllQuery),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "recent build log headers, newest first", body = Vec<BuildLogHeader>),
|
|
||||||
(status = 500, description = "sqlite read failed"),
|
|
||||||
),
|
|
||||||
tag = "build_logs"
|
|
||||||
)]
|
|
||||||
pub(super) async fn get_build_logs_all(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
axum::extract::Query(q): axum::extract::Query<BuildLogsAllQuery>,
|
|
||||||
) -> Response {
|
|
||||||
let limit = q.limit.unwrap_or(30);
|
|
||||||
match state.coord.build_logs.list_recent_all(limit) {
|
|
||||||
Ok(rows) => axum::Json(rows).into_response(),
|
|
||||||
Err(e) => error_response(&format!("build-logs all: {e:#}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, IntoParams)]
|
|
||||||
pub(super) struct BuildLogsQuery {
|
|
||||||
/// Maximum number of rows to return. Capped server-side at 50
|
|
||||||
/// (see `build_logs::list_recent_for_agent`). Default 10.
|
|
||||||
#[serde(default)]
|
|
||||||
limit: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Most-recent build log
|
|
||||||
/// headers for one agent, newest first.
|
|
||||||
///
|
|
||||||
/// Returns `Vec<BuildLogHeader>` (JSON). Backs the per-agent log chip
|
|
||||||
/// in the agent card and the side-panel header list.
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/build-logs/{agent}",
|
|
||||||
params(
|
|
||||||
("agent" = String, Path, description = "agent name"),
|
|
||||||
BuildLogsQuery,
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "recent build log headers for the agent, newest first", body = Vec<BuildLogHeader>),
|
|
||||||
(status = 400, description = "bad agent name"),
|
|
||||||
(status = 500, description = "sqlite read failed"),
|
|
||||||
),
|
|
||||||
tag = "build_logs"
|
|
||||||
)]
|
|
||||||
pub(super) async fn get_build_logs_agent(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumPath(name): AxumPath<String>,
|
|
||||||
axum::extract::Query(q): axum::extract::Query<BuildLogsQuery>,
|
|
||||||
) -> Response {
|
|
||||||
let name = match Ident::parse(&name) {
|
|
||||||
Ok(n) => n,
|
|
||||||
Err(reason) => {
|
|
||||||
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let limit = q.limit.unwrap_or(10);
|
|
||||||
match state
|
|
||||||
.coord
|
|
||||||
.build_logs
|
|
||||||
.list_recent_for_agent(name.as_str(), limit)
|
|
||||||
{
|
|
||||||
Ok(rows) => axum::Json(rows).into_response(),
|
|
||||||
Err(e) => error_response(&format!("build-logs {name}: {e:#}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full build log row (stdout +
|
|
||||||
/// stderr concatenated) by id.
|
|
||||||
///
|
|
||||||
/// Returns `BuildLogFull` (JSON), or HTTP 404 when the id doesn't
|
|
||||||
/// exist (vacuum-reaped, or the operator passed a stale id from a
|
|
||||||
/// refresh race).
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/build-logs/id/{id}",
|
|
||||||
params(("id" = i64, Path, description = "build log row id")),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "full build log row", body = BuildLogFull),
|
|
||||||
(status = 404, description = "no such build log row"),
|
|
||||||
(status = 500, description = "sqlite read failed"),
|
|
||||||
),
|
|
||||||
tag = "build_logs"
|
|
||||||
)]
|
|
||||||
pub(super) async fn get_build_log_full(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumPath(id): AxumPath<i64>,
|
|
||||||
) -> Response {
|
|
||||||
match state.coord.build_logs.get_full(id) {
|
|
||||||
Ok(Some(log)) => axum::Json(log).into_response(),
|
|
||||||
Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(),
|
|
||||||
Err(e) => error_response(&format!("build-log {id}: {e:#}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The build log for a **queue node**,
|
|
||||||
/// resolved node id → log-row id → full log.
|
|
||||||
///
|
|
||||||
/// Same `BuildLogFull` JSON (`stdout` / `stderr` + header) as
|
|
||||||
/// `get_build_log_full`; HTTP 404 when the node has no linked log (the
|
|
||||||
/// client gates the request on the wire node's `build_log_id`, but a vacuum
|
|
||||||
/// race can still 404). This is the on-demand live-log-panel fetch,
|
|
||||||
/// distinct from the `build_log_id` on the wire — that id is for
|
|
||||||
/// deep-linking to the BUILD L0GS tab's full history view, not for
|
|
||||||
/// fetching the log content itself.
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/build-log/{node_id}",
|
|
||||||
params(("node_id" = u64, Path, description = "job-queue node id")),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "full build log row for the node's linked log", body = BuildLogFull),
|
|
||||||
(status = 404, description = "node has no linked build log, or the log row is gone"),
|
|
||||||
(status = 500, description = "sqlite read failed"),
|
|
||||||
),
|
|
||||||
tag = "build_logs"
|
|
||||||
)]
|
|
||||||
pub(super) async fn get_build_log_for_node(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumPath(node_id): AxumPath<u64>,
|
|
||||||
) -> Response {
|
|
||||||
match state.coord.build_logs.id_for_node(node_id) {
|
|
||||||
Some(log_id) => get_build_log_full(State(state), AxumPath(log_id)).await,
|
|
||||||
None => (
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
format!("node #{node_id} has no build log"),
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The node's build log as `text/plain`
|
|
||||||
/// for download (delegates to `get_build_log_raw` after resolving the node id).
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/build-log/{node_id}/raw",
|
|
||||||
params(("node_id" = u64, Path, description = "job-queue node id")),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "build log text for download", body = String, content_type = "text/plain"),
|
|
||||||
(status = 404, description = "node has no linked build log, or the log row is gone"),
|
|
||||||
(status = 500, description = "sqlite read failed"),
|
|
||||||
),
|
|
||||||
tag = "build_logs"
|
|
||||||
)]
|
|
||||||
pub(super) async fn get_build_log_raw_for_node(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumPath(node_id): AxumPath<u64>,
|
|
||||||
) -> Response {
|
|
||||||
match state.coord.build_logs.id_for_node(node_id) {
|
|
||||||
Some(log_id) => get_build_log_raw(State(state), AxumPath(log_id)).await,
|
|
||||||
None => (
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
format!("node #{node_id} has no build log"),
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// JSON frame sent on the `/api/build-logs/id/{id}/stream` SSE channel.
|
|
||||||
/// `stdout_append` / `stderr_append` carry only the new bytes since the
|
|
||||||
/// last frame; `done = true` means the build finished and the stream
|
|
||||||
/// will close after this frame.
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct BuildLogFrame {
|
|
||||||
stdout_append: String,
|
|
||||||
stderr_append: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
status: Option<String>,
|
|
||||||
done: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SSE stream that delivers
|
|
||||||
/// incremental stdout/stderr as a build runs.
|
|
||||||
///
|
|
||||||
/// The client connects when it opens a running-build panel; the
|
|
||||||
/// stream closes automatically once the build finishes (or the row
|
|
||||||
/// disappears due to a vacuum). Each frame is a JSON-serialised
|
|
||||||
/// `BuildLogFrame`. The first frame always carries the full
|
|
||||||
/// accumulated log so far (cursors start at 0); subsequent frames
|
|
||||||
/// carry only new bytes. `done: true` on the final frame signals the
|
|
||||||
/// browser to close the `EventSource`.
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/build-logs/id/{id}/stream",
|
|
||||||
params(("id" = i64, Path, description = "build log row id")),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "server-sent event stream; each event's \
|
|
||||||
`data` is a JSON-serialised `BuildLogFrame` \
|
|
||||||
(stdout_append/stderr_append/status/done)",
|
|
||||||
body = String, content_type = "text/event-stream"),
|
|
||||||
),
|
|
||||||
tag = "build_logs"
|
|
||||||
)]
|
|
||||||
pub(super) async fn get_build_log_stream(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumPath(id): AxumPath<i64>,
|
|
||||||
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(32);
|
|
||||||
let logs = state.coord.build_logs.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut notify_rx = logs.subscribe_notifications();
|
|
||||||
let mut stdout_cursor = 0usize;
|
|
||||||
let mut stderr_cursor = 0usize;
|
|
||||||
|
|
||||||
// ── initial snapshot ──────────────────────────────────────────
|
|
||||||
match logs.get_progress(id, stdout_cursor, stderr_cursor) {
|
|
||||||
Ok(Some(prog)) => {
|
|
||||||
stdout_cursor += prog.stdout_append.len();
|
|
||||||
stderr_cursor += prog.stderr_append.len();
|
|
||||||
let done = prog.finished_at.is_some();
|
|
||||||
if let Ok(json) = serde_json::to_string(&BuildLogFrame {
|
|
||||||
stdout_append: prog.stdout_append,
|
|
||||||
stderr_append: prog.stderr_append,
|
|
||||||
status: prog.status,
|
|
||||||
done,
|
|
||||||
}) {
|
|
||||||
let _ = tx.send(Ok(Event::default().data(json))).await;
|
|
||||||
}
|
|
||||||
if done {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
// Row missing — send a single error event and exit.
|
|
||||||
let _ = tx
|
|
||||||
.send(Ok(Event::default()
|
|
||||||
.event("error")
|
|
||||||
.data(format!("build log #{id} not found"))))
|
|
||||||
.await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = tx
|
|
||||||
.send(Ok(Event::default()
|
|
||||||
.event("error")
|
|
||||||
.data(format!("build log #{id}: {e:#}"))))
|
|
||||||
.await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── live delta loop ───────────────────────────────────────────
|
|
||||||
loop {
|
|
||||||
match notify_rx.recv().await {
|
|
||||||
// Notification for a different build — ignore and wait
|
|
||||||
// for the next one.
|
|
||||||
Ok(notif_id) if notif_id != id => {}
|
|
||||||
Ok(_) => {
|
|
||||||
match logs.get_progress(id, stdout_cursor, stderr_cursor) {
|
|
||||||
Ok(Some(prog)) => {
|
|
||||||
stdout_cursor += prog.stdout_append.len();
|
|
||||||
stderr_cursor += prog.stderr_append.len();
|
|
||||||
let done = prog.finished_at.is_some();
|
|
||||||
if let Ok(json) = serde_json::to_string(&BuildLogFrame {
|
|
||||||
stdout_append: prog.stdout_append,
|
|
||||||
stderr_append: prog.stderr_append,
|
|
||||||
status: prog.status,
|
|
||||||
done,
|
|
||||||
}) && tx.send(Ok(Event::default().data(json))).await.is_err()
|
|
||||||
{
|
|
||||||
return; // browser disconnected
|
|
||||||
}
|
|
||||||
if done {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) | Err(_) => return, // vacuum reaped row / channel closed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full log as `text/plain` for
|
|
||||||
/// download.
|
|
||||||
///
|
|
||||||
/// Stdout and stderr are concatenated with a `--- stderr ---`
|
|
||||||
/// separator (same layout the JS side-panel renders). The
|
|
||||||
/// `Content-Disposition` header triggers a browser download with a
|
|
||||||
/// descriptive filename so the operator can save and share the log.
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/build-logs/id/{id}/raw",
|
|
||||||
params(("id" = i64, Path, description = "build log row id")),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "build log text for download", body = String, content_type = "text/plain"),
|
|
||||||
(status = 404, description = "no such build log row"),
|
|
||||||
(status = 500, description = "sqlite read failed"),
|
|
||||||
),
|
|
||||||
tag = "build_logs"
|
|
||||||
)]
|
|
||||||
pub(super) async fn get_build_log_raw(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumPath(id): AxumPath<i64>,
|
|
||||||
) -> Response {
|
|
||||||
match state.coord.build_logs.get_full(id) {
|
|
||||||
Ok(Some(log)) => {
|
|
||||||
let mut text = log.stdout;
|
|
||||||
if !log.stderr.is_empty() {
|
|
||||||
text.push_str("\n--- stderr ---\n");
|
|
||||||
text.push_str(&log.stderr);
|
|
||||||
}
|
|
||||||
(
|
|
||||||
StatusCode::OK,
|
|
||||||
[
|
|
||||||
("content-type", "text/plain; charset=utf-8".to_string()),
|
|
||||||
(
|
|
||||||
"content-disposition",
|
|
||||||
format!(
|
|
||||||
"attachment; filename=\"build-log-{}-{}.txt\"",
|
|
||||||
log.header.agent, id
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
text,
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(),
|
|
||||||
Err(e) => error_response(&format!("build-log {id}: {e:#}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -35,7 +35,6 @@ use crate::lifecycle;
|
||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
(name = "health", description = "hive-wide liveness/readiness probes"),
|
(name = "health", description = "hive-wide liveness/readiness probes"),
|
||||||
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
|
|
||||||
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
|
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
|
||||||
|
|
||||||
(name = "misc_api", description = "mark-all-read, compose, spawn-request"),
|
(name = "misc_api", description = "mark-all-read, compose, spawn-request"),
|
||||||
|
|
@ -46,7 +45,6 @@ use crate::lifecycle;
|
||||||
)]
|
)]
|
||||||
struct ApiDoc;
|
struct ApiDoc;
|
||||||
|
|
||||||
mod build_logs;
|
|
||||||
// The single validated identifier type — homed in `hive-host-sock` (the crate
|
// The single validated identifier type — homed in `hive-host-sock` (the crate
|
||||||
// owning agent-path facts) so every dashboard path-param validates through the
|
// owning agent-path facts) so every dashboard path-param validates through the
|
||||||
// same type used to build agent paths. Re-exported so submodules + the socket
|
// same type used to build agent paths. Re-exported so submodules + the socket
|
||||||
|
|
@ -104,12 +102,6 @@ fn app(coord: Arc<Coordinator>) -> axum::Router {
|
||||||
.routes(routes!(health::get_health_ready))
|
.routes(routes!(health::get_health_ready))
|
||||||
.routes(routes!(state_snapshot::api_state))
|
.routes(routes!(state_snapshot::api_state))
|
||||||
.routes(routes!(misc_api::post_mark_all_read))
|
.routes(routes!(misc_api::post_mark_all_read))
|
||||||
.routes(routes!(build_logs::get_build_logs_all))
|
|
||||||
.routes(routes!(build_logs::get_build_log_for_node))
|
|
||||||
.routes(routes!(build_logs::get_build_log_raw_for_node))
|
|
||||||
.routes(routes!(build_logs::get_build_logs_agent))
|
|
||||||
.routes(routes!(build_logs::get_build_log_full))
|
|
||||||
.routes(routes!(build_logs::get_build_log_raw))
|
|
||||||
.routes(routes!(rebuild_queue::post_rebuild_queue_cancel))
|
.routes(routes!(rebuild_queue::post_rebuild_queue_cancel))
|
||||||
.routes(routes!(lifecycle_ops::post_destroy))
|
.routes(routes!(lifecycle_ops::post_destroy))
|
||||||
.routes(routes!(lifecycle_ops::post_kill))
|
.routes(routes!(lifecycle_ops::post_kill))
|
||||||
|
|
@ -119,7 +111,6 @@ fn app(coord: Arc<Coordinator>) -> axum::Router {
|
||||||
.routes(routes!(lifecycle_ops::post_pause))
|
.routes(routes!(lifecycle_ops::post_pause))
|
||||||
.routes(routes!(lifecycle_ops::post_resume))
|
.routes(routes!(lifecycle_ops::post_resume))
|
||||||
.routes(routes!(tombstones::post_purge_tombstone))
|
.routes(routes!(tombstones::post_purge_tombstone))
|
||||||
.routes(routes!(build_logs::get_build_log_stream))
|
|
||||||
.routes(routes!(state_snapshot::dashboard_stream))
|
.routes(routes!(state_snapshot::dashboard_stream))
|
||||||
.routes(routes!(state_snapshot::jobq_graph))
|
.routes(routes!(state_snapshot::jobq_graph))
|
||||||
.routes(routes!(state_snapshot::jobq_rollup))
|
.routes(routes!(state_snapshot::jobq_rollup))
|
||||||
|
|
@ -351,12 +342,6 @@ mod router_build_probe {
|
||||||
.routes(routes!(health::get_health_ready))
|
.routes(routes!(health::get_health_ready))
|
||||||
.routes(routes!(state_snapshot::api_state))
|
.routes(routes!(state_snapshot::api_state))
|
||||||
.routes(routes!(misc_api::post_mark_all_read))
|
.routes(routes!(misc_api::post_mark_all_read))
|
||||||
.routes(routes!(build_logs::get_build_logs_all))
|
|
||||||
.routes(routes!(build_logs::get_build_log_for_node))
|
|
||||||
.routes(routes!(build_logs::get_build_log_raw_for_node))
|
|
||||||
.routes(routes!(build_logs::get_build_logs_agent))
|
|
||||||
.routes(routes!(build_logs::get_build_log_full))
|
|
||||||
.routes(routes!(build_logs::get_build_log_raw))
|
|
||||||
.routes(routes!(rebuild_queue::post_rebuild_queue_cancel))
|
.routes(routes!(rebuild_queue::post_rebuild_queue_cancel))
|
||||||
.routes(routes!(lifecycle_ops::post_destroy))
|
.routes(routes!(lifecycle_ops::post_destroy))
|
||||||
.routes(routes!(lifecycle_ops::post_kill))
|
.routes(routes!(lifecycle_ops::post_kill))
|
||||||
|
|
@ -366,7 +351,6 @@ mod router_build_probe {
|
||||||
.routes(routes!(lifecycle_ops::post_pause))
|
.routes(routes!(lifecycle_ops::post_pause))
|
||||||
.routes(routes!(lifecycle_ops::post_resume))
|
.routes(routes!(lifecycle_ops::post_resume))
|
||||||
.routes(routes!(tombstones::post_purge_tombstone))
|
.routes(routes!(tombstones::post_purge_tombstone))
|
||||||
.routes(routes!(build_logs::get_build_log_stream))
|
|
||||||
.routes(routes!(state_snapshot::dashboard_stream))
|
.routes(routes!(state_snapshot::dashboard_stream))
|
||||||
.routes(routes!(state_snapshot::jobq_graph))
|
.routes(routes!(state_snapshot::jobq_graph))
|
||||||
.routes(routes!(state_snapshot::jobq_rollup));
|
.routes(routes!(state_snapshot::jobq_rollup));
|
||||||
|
|
|
||||||
|
|
@ -882,8 +882,7 @@ async fn priv_run(kind: &str, name: &str) -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Like `priv_run` but stamps `node_id` onto the build-log row it opens, so
|
/// Like `priv_run` but stamps `node_id` onto the build-log row it opens, so
|
||||||
/// the dashboard can find the log from the queue node (and link to
|
/// the log can be found from the queue node.
|
||||||
/// `/api/build-logs/id/{id}/stream`).
|
|
||||||
///
|
///
|
||||||
/// This used to be a `Fn(i64)` callback that handed the row id *back* to the
|
/// This used to be a `Fn(i64)` callback that handed the row id *back* to the
|
||||||
/// queue, which then held it in a side map. The row carries the link itself
|
/// queue, which then held it in a side map. The row carries the link itself
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue