Watch
0
0
Fork
You've already forked hyperhive
0

refactor(#2416): remove the non-pr config-change flow (request_apply_commit / applycommit)

This commit is contained in:
damocles 2026-07-15 20:45:29 +02:00 • committed by mara
commit c2bd7db998
34 changed files with 293 additions and 1635 deletions

View file

@ -98,7 +98,7 @@ Once enforcement lands the rules collapse into:
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `kill` / `start` / `restart` / `update` (any descendant) | any ancestor | | `kill` / `start` / `restart` / `update` (any descendant) | any ancestor |
| `request_init_config` (spawn a new child) | any agent, child added under self | | `request_init_config` (spawn a new child) | any agent, child added under self |
| `request_apply_commit` (any descendant's config) | any ancestor | | config change via forge PR (any descendant's config) | any ancestor |
| `get_logs` (any descendant) | any ancestor | | `get_logs` (any descendant) | any ancestor |
| moderate questions / reminders (cancel any open thread of a descendant) | any ancestor | | moderate questions / reminders (cancel any open thread of a descendant) | any ancestor |
| `send` / `recv` routing | parent ↔ same-parent siblings ↔ self ↔ descendants; explicit allow-list for anyone else | | `send` / `recv` routing | parent ↔ same-parent siblings ↔ self ↔ descendants; explicit allow-list for anyone else |
@ -133,7 +133,6 @@ can't:
| variant | semantic | post-milestone | | variant | semantic | post-milestone |
| --------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `RequestInitConfig` | seed an agent's proposed config repo | **topology** — existing direct child (re-init) or a brand-new name (child added under self on approval); a name owned by a different parent is refused | | `RequestInitConfig` | seed an agent's proposed config repo | **topology** — existing direct child (re-init) or a brand-new name (child added under self on approval); a name owned by a different parent is refused |
| `RequestApplyCommit` | submit a commit sha for operator approval | **topology** — descendants only |
| `Kill` / `Start` / `Restart` / `Update` | container lifecycle on an existing agent | **topology** — descendants only | | `Kill` / `Start` / `Restart` / `Update` | container lifecycle on an existing agent | **topology** — descendants only |
| `RequestUpdateMetaInputs` | bump meta `flake.lock` | **per-agent cap** (root-only today; a future "let coder bump its own input" might grant it) | | `RequestUpdateMetaInputs` | bump meta `flake.lock` | **per-agent cap** (root-only today; a future "let coder bump its own input" might grant it) |
| `GetLogs` | journalctl scrape of a sub-agent | **topology** — descendants only | | `GetLogs` | journalctl scrape of a sub-agent | **topology** — descendants only |

View file

@ -10,67 +10,55 @@ informed about what happens after a decision lands.
## End-to-end approval flow ## End-to-end approval flow
Config changes flow through a **forge pull request** on the agent's
`agent-configs/<name>` repo — the same surface agents use for code PRs.
There is no bespoke MCP tool for config changes: opening the PR IS the
request.
1. The submitting agent (the child's parent, holding the `approvals` 1. The submitting agent (the child's parent, holding the `approvals`
tool group) edits files in the child's proposed config repo tool group) edits files in the child's proposed config repo
(any tracked path, but `agent.nix` is the contract entry point) (any tracked path, but `agent.nix` is the contract entry point),
and commits with its own git identity. The parent's container has commits with its own git identity, and pushes a branch + opens a PR
the child's proposed config repo bind-mounted read-write at on `agent-configs/<name>` with `hive-forge`. The parent's container
has the child's proposed config repo bind-mounted read-write at
`/agents/<name>/config/` (topology-driven via `set_nspawn_flags`; `/agents/<name>/config/` (topology-driven via `set_nspawn_flags`;
the agent's *own* config at `/agents/<self>/config/` is read-only). the agent's *own* config at `/agents/<self>/config/` is read-only).
2. The submitting agent submits the commit sha via `request_apply_commit(agent, Branch protection (push/merge whitelist = `core`, approvals whitelist
commit_ref)`. `commit_ref` must be a commit **sha** (7-40 hex = operator team; see "Forge mirror" and #1787) makes the agent a
chars, short or full) — a branch or tag name is rejected so the write collaborator that **cannot merge its own config PR**.
approval pins an immutable commit. 2. hive-c0re's `/webhook/config-pr` endpoint receives the Forgejo
3. **hive-c0re immediately fetches that commit from the proposed `pull_request` event (opened / synchronized / reopened) and queues a
repo into the applied repo and tags it `proposal/<id>`.** It `MergeConfigPr` approval; a poll fallback catches any missed webhook.
resolves the sha locally against the proposed repo, fetches all The approval row stores the PR **number** (`commit_ref`) and the PR
of proposed's heads into applied's object db, then tags the **head sha at queue time** (`fetched_sha` — the "reviewed" sha). If
resolved commit — `git fetch <remote> <sha>:<dst>` can't fetch the PR head later moves, the stale approval is superseded by a fresh
by a bare sha (the left side of a refspec is a remote *ref one pinned to the new head, so the operator always reviews what will
name*), so the resolution happens on hive-c0re's side. The actually deploy.
approval row stores both the submitted sha and the 3. The operator reviews the PR **on the forge** (native diff, threaded
canonical hive-c0re-vouched sha. From here on the proposed comments, CI status) and sees a matching card on the dashboard with a
repo is irrelevant for this approval — the submitter can amend, "review PR on forge" deep link. They click ◆ APPR0VE (or
force-push, or `rm -rf` the proposed repo and the queued `hive-c0re approve <id>` on the CLI) once satisfied.
approval still points at an immutable git object inside 4. On approve, `run_merge_config_pr`:
applied. - re-reads the live PR head and **aborts if it drifted** from the
3a. **Flake validation (ApplyCommit only):** after the proposal tag reviewed `fetched_sha` (the submitter must push again, which queues
is planted, hive-c0re reads `proposal/<id>:flake.lock` and a fresh approval);
runs two checks. If either check fails, no - fetches that head into the applied repo and **eval-verifies** it —
pending approval is created for the operator — the row is a flake eval on a throwaway checkout. This is the trust gate: it
marked failed and surfaces on the dashboard with the relies on c0re's own eval, not on any in-repo (agent-forgeable)
validation message: signal like a CI status;
- **Stale lock** — materialises the commit in a temp worktree, - fast-forwards the forge config repo's `main` to the reviewed head
runs `nix flake lock` (no `--update-input` flags, so it only (this IS the merge — a `core`-authenticated ff-push, so a moved PR
fills missing entries), and rejects if the committed head can't substitute bytes) and marks the PR merged (best-effort;
`flake.lock` differs from the result. Triggered when the `main` is already there);
submitter added or removed `inputs` in `flake.nix` without - runs the shared deploy tail (`deploy_applied_target`): builds under
re-running `nix flake lock`. Fix: run `nix flake lock` in the tag sequence below, and on success `applied/main`
the config repo, commit, and re-submit. fast-forwards to the reviewed commit + the meta lock bumps. On
- **Duplicate inputs** — groups lock nodes by their canonical failure, main stays put and the working tree resets to the previous
`original` field; rejects if two or more nodes share the same deployed commit.
source. This usually means an input is missing 5. `HelperEvent::ApprovalResolved` (and `Rebuilt`) land in the
`inputs.<x>.inputs.nixpkgs.follows = "nixpkgs"`. Fix: add **submitting agent's** inbox via `notify_submitter`, carrying both the
the `follows` directive, re-lock, and re-submit. canonical sha and the terminal tag (the approval row carries a
Both checks only flag *new* violations — agents whose lock `submitter` column recording the agent the change is for).
already carried duplicates before this check was added are
unaffected until a coordinated config-change pass.
4. Operator sees the proposal as a card on the dashboard — a
full multi-file diff, toggleable between three bases (vs the
running tree / vs the last approved proposal / vs the
previous queued proposal) — and clicks ◆ APPR0VE (or
`hive-c0re approve <id>` on the CLI).
5. hive-c0re moves the working tree to `proposal/<id>` and runs
the build under a sequence of tags (see below). On success,
`applied/main` fast-forwards to the proposal commit. On
failure, main stays put and the working tree resets back to
the previous deployed commit.
6. `HelperEvent::ApprovalResolved` (and `Rebuilt` for the
ApplyCommit kind) land in the **submitting agent's** inbox, carrying
both the canonical sha and the terminal tag. Helper events route to
the submitting agent via `notify_submitter` (the approval row carries
a `submitter` column recording which agent called `request_apply_commit`
or `request_init_config`).
### Withdrawing a pending approval ### Withdrawing a pending approval
@ -88,53 +76,47 @@ agent that lacks the `approvals` tool group: only an agent with that
group submits approvals (for its direct children), so an agent group submits approvals (for its direct children), so an agent
without it has nothing of its own to withdraw. without it has nothing of its own to withdraw.
`InitConfig` approvals are the first step in a two-step spawn `InitConfig` approvals seed a brand-new agent's proposed config repo.
flow. On approve, hive-c0re seeds the proposed config repo with On approve, hive-c0re seeds it with a default `agent.nix` template and
a default `agent.nix` template and sends `HelperEvent::ConfigReady { agent }` sends `HelperEvent::ConfigReady { agent }` to the submitting agent's
to the submitting agent's inbox via `notify_submitter`. The submitting inbox via `notify_submitter`. The submitting agent reviews, edits, and
agent then reviews, commits the template; the operator then **spawns** the agent (the
edits, and commits the template before calling `request_apply_commit` `Spawn` approval / `◆ R3QU3ST SP4WN` button), which creates the
to proceed to an `ApplyCommit` approval. The first `ApplyCommit` container from that config. From then on, config changes go through a
creates the container; subsequent ones rebuild it with new config. forge PR (`MergeConfigPr`) — never a fresh spawn. This gives the
This gives the submitting agent (and operator) an explicit review gate on the submitting agent (and operator) an explicit review gate on the initial
initial configuration before any container is created. configuration before any container is created.
### Approval kinds (wire shapes) ### Approval kinds (wire shapes)
`ApprovalKind` carries six variants; each maps to a different `ApprovalKind` carries five variants; each maps to a different
`commit_ref` encoding because that field is overloaded as the `commit_ref` encoding because that field is overloaded as the
kind-specific payload carrier. kind-specific payload carrier.
- `ApplyCommit` — `commit_ref` is the submitted git sha - `MergeConfigPr` — the config-change flow. Triggered automatically:
(7-40 hex chars). The canonical, hive-c0re-vouched sha after the when an agent opens (or force-pushes) a PR on its
proposal fetch lives in `fetched_sha` on the same `Approval` `agent-configs/<agent>` forge repo, hive-c0re's `/webhook/config-pr`
row (only `ApplyCommit` populates it). See the End-to-end flow endpoint receives the Forgejo pull_request event and queues this
above. approval row. No MCP tool call needed — the forge PR IS the request.
- `MergeConfigPr` — the PR-based config flow's counterpart to `commit_ref` stores the **PR number** (decimal), and `fetched_sha` is
`ApplyCommit`. Triggered automatically: when an agent opens (or the PR **head sha at queue time** (the "reviewed" sha). On approve,
force-pushes) a PR on its `agent-configs/<agent>` forge repo,
hive-c0re's `/webhook/config-pr` endpoint receives the Forgejo
pull_request event and queues this approval row. No MCP tool call
needed — the forge PR IS the request. `commit_ref` stores the
**PR number** (decimal), and `fetched_sha` is the PR **head sha
at queue time** (the "reviewed" sha). On approve,
`run_merge_config_pr` re-reads the live PR head and aborts if it `run_merge_config_pr` re-reads the live PR head and aborts if it
drifted from `fetched_sha` (submitter must push again to drifted from `fetched_sha` (submitter must push again to
re-trigger), then fetches that head into the applied repo, re-trigger), then fetches that head into the applied repo,
eval-verifies it, fast-forwards the forge config repo's `main` to eval-verifies it, fast-forwards the forge config repo's `main` to
it (the merge), marks the PR merged (best-effort — `main` is it (the merge), marks the PR merged (best-effort — `main` is
already there), and runs the same shared deploy tail as already there), and runs the shared deploy tail
`ApplyCommit` (`deploy_applied_target`). Never a first spawn. (`deploy_applied_target`). Never a first spawn.
- `Spawn` — direct container creation under the default - `Spawn` — direct container creation from the agent's config repo.
`agent.nix` template. `commit_ref` is empty. Submitted via `commit_ref` is empty. Submitted via `HostRequest::RequestSpawn`
`HostRequest::RequestSpawn` (operator-gated, the (operator-gated, the `◆ R3QU3ST SP4WN` dashboard button +
`◆ R3QU3ST SP4WN` dashboard button + `hive-c0re request-spawn` `hive-c0re request-spawn` CLI). The host-level `HostRequest::Spawn`
CLI). The host-level `HostRequest::Spawn` variant bypasses the variant bypasses the approval queue entirely — privileged-context use
approval queue entirely — privileged-context use only (operator only (operator on the host shell, test scripts, one-off recoveries).
on the host shell, test scripts, one-off recoveries). The This is the **canonical first-spawn**: a new agent's `InitConfig`
agent-side `RequestSpawn` is gone; the submitting agent goes through the seeds its config repo, the submitting agent customises it, then the
`InitConfig` → `ApplyCommit` two-step instead so the spawn operator spawns to create the container. Subsequent config changes go
captures the customised config. through a `MergeConfigPr` PR.
- `InitConfig` — `commit_ref` is empty; the variant just gates - `InitConfig` — `commit_ref` is empty; the variant just gates
"seed the proposed repo with the default template" against "seed the proposed repo with the default template" against
operator approval. Step 1 of the two-step spawn flow above. operator approval. Step 1 of the two-step spawn flow above.
@ -239,14 +221,14 @@ module that `setup_applied` used to generate inline.
Containers run against `--flake /var/lib/hyperhive/meta#<n>`. Containers run against `--flake /var/lib/hyperhive/meta#<n>`.
Per-deploy lock flow (two-phase, owned by Per-deploy lock flow (two-phase, owned by
`actions::run_apply_commit` → `meta::{prepare,finalize,abort} `actions::run_merge_config_pr` → `deploy_applied_target` →
_deploy`): `meta::{prepare,finalize,abort}_deploy`):
1. `meta::prepare_deploy(name)` runs 1. `meta::prepare_deploy(name)` runs
`nix flake lock --update-input agent-<n>` without `nix flake lock --update-input agent-<n>` without
committing. Working tree of meta now points the input at committing. Working tree of meta now points the input at
`applied/<n>/main` (which `run_apply_commit` already `applied/<n>/main` (which the deploy already fast-forwarded to
fast-forwarded to `proposal/<id>`). the reviewed PR head).
2. `lifecycle::rebuild_no_meta` runs 2. `lifecycle::rebuild_no_meta` runs
`nixos-container update <c> --flake meta#<name>`. Nix `nixos-container update <c> --flake meta#<name>`. Nix
evaluates against the staged lock. evaluates against the staged lock.
@ -323,34 +305,29 @@ wraps it with identity + `HIVE_PORT` / `HIVE_LABEL` /
### Tag state machine ### Tag state machine
Every approval id walks through a fixed set of tags on the Each deploy leaves a tag on the underlying commit inside the applied
underlying commit inside the applied repo: repo:
| Tag | When | Annotated? | | Tag | When | Annotated? |
|---|---|---| |---|---|---|
| `proposal/<id>` | request_apply_commit, after fetch | no |
| `approved/<id>` | operator approve | no |
| `building/<id>` | rebuild started | no |
| `deployed/<id>` | rebuild succeeded — `main` ff's here | no | | `deployed/<id>` | rebuild succeeded — `main` ff's here | no |
| `failed/<id>` | rebuild failed | yes (body = error) | | `failed/<id>` | rebuild failed | yes (body = error) |
| `denied/<id>` | operator deny | yes (body = operator note) |
`applied/main` is always the latest `deployed/*`. `denied/` and `deployed/0` is planted at first spawn. `applied/main` is always the
`failed/` are terminal; the submitting agent submits a new commit + new latest `deployed/*`. A `failed/` tree stays browsable forever — `git log
approval id to retry. Because tags are first-class git objects, --tags` in the applied repo is the audit trail. A denied or failed config
rejected and failed trees stay browsable forever — `git log PR carries no extra state on the forge side: the PR stays open, and the
--tags` in the applied repo is the audit trail. submitter pushes again (or closes it) to retry.
### Dispatch via the job queue ### Dispatch via the job queue
Long-running approval work — `ApplyCommit`, `UpdateMetaInputs`, Long-running approval work — `MergeConfigPr`, `UpdateMetaInputs`,
`Spawn` — no longer runs inline inside `actions::approve`. Instead `Spawn` — no longer runs inline inside `actions::approve`. Instead
the approval handler submits a DAG to the global job queue the approval handler submits a DAG to the global job queue
(`docs/coordinator.md::Job queue`): (`docs/coordinator.md::Job queue`):
| `ApprovalKind` | DAG submitted | source | | `ApprovalKind` | DAG submitted | source |
|---|---|---| |---|---|---|
| `ApplyCommit` | `rebuild` (single opaque `ApprovalDeploy` node) | `approval` |
| `MergeConfigPr` | `rebuild` (single opaque `ApprovalDeploy` node) | `approval` | | `MergeConfigPr` | `rebuild` (single opaque `ApprovalDeploy` node) | `approval` |
| `UpdateMetaInputs` | `meta_update` (`MetaLock` + rebuild fan-out) | `approval` | | `UpdateMetaInputs` | `meta_update` (`MetaLock` + rebuild fan-out) | `approval` |
| `Spawn` | `spawn` (`Create → WriteDropin → Reconcile`) | `approval` | | `Spawn` | `spawn` (`Create → WriteDropin → Reconcile`) | `approval` |
@ -358,9 +335,8 @@ the approval handler submits a DAG to the global job queue
| `SchedulePrompt` | — runs inline (single sqlite insert) | — | | `SchedulePrompt` | — runs inline (single sqlite insert) | — |
The DAG carries the originating `approval_id`. The `ApprovalDeploy` The DAG carries the originating `approval_id`. The `ApprovalDeploy`
node runs the kind-specific pipeline (`run_approval_apply_commit` / node runs `run_approval_merge_config_pr` (the two-phase meta deploy
`run_approval_merge_config_pr` — the two-phase meta deploy stays stays inside `actions.rs`) and fires the matching `HelperEvent::*` via
inside `actions.rs`) and fires the matching `HelperEvent::*` via
`finish_approval` itself; `Spawn` and `UpdateMetaInputs` DAGs resolve `finish_approval` itself; `Spawn` and `UpdateMetaInputs` DAGs resolve
through `actions::resolve_approval_dag` when the DAG settles terminal through `actions::resolve_approval_dag` when the DAG settles terminal
(a spawn additionally runs the post-spawn forge bookkeeping there). (a spawn additionally runs the post-spawn forge bookkeeping there).
@ -391,22 +367,25 @@ The bundled `hive-forge` container is mandatory (it deploys with
hyperhive), and hive-c0re mirrors every agent's applied repo into a hyperhive), and hive-c0re mirrors every agent's applied repo into a
private `agent-configs` Forgejo org. `forge::push_config(<name>)` pushes `applied/main` plus private `agent-configs` Forgejo org. `forge::push_config(<name>)` pushes `applied/main` plus
every tag to `agent-configs/<name>` after each ref mutation: every tag to `agent-configs/<name>` after each ref mutation:
the spawn that seeds `deployed/0`, every `request_apply_commit` the spawn that seeds `deployed/0`, every successful deploy (which
(which plants `proposal/<id>`), every approve / deny, and a plants `deployed/<id>`) or failed build (`failed/<id>`), and a
sweep at startup. Pushes are best-effort — a missing or stopped sweep at startup. Pushes are best-effort — a missing or stopped
forge never blocks a deploy. forge never blocks a deploy.
The org is private and agents are not members, so only the Each agent is a **write collaborator on its own** `agent-configs/<name>`
`core` user (a Forgejo site admin) can read it: an agent can't repo — so it can push a branch and open a config PR — but not a member
reach another agent's config — or even its own — through the of any other agent's, so it can't reach another agent's config through
forge. The tokenised push URL is passed inline to `git push`, the forge. Branch protection keeps `main` push/merge `core`-only with
never written into `applied/<n>/.git/config`; that repo is operator-team approval, so an agent can't fast-forward its own config or
RO-bind-mounted into the root agent, and a stored token would leak self-merge its PR (see the End-to-end flow + #1787). The tokenised push
core's admin credential to an agent. URL is passed inline to `git push`, never written into
`applied/<n>/.git/config`; that repo is RO-bind-mounted into the root
agent, and a stored token would leak core's admin credential to an
agent.
The dashboard deep-links into this org — a `config repo` link The dashboard deep-links into this org — a `config repo` link
per container row and a `commit on forge` link per approval per container row and a `review PR on forge` link per config-PR
card. See `docs/web-ui.md`. approval card. See `docs/web-ui.md`.
### Submitting agent's view of config repos ### Submitting agent's view of config repos
@ -484,8 +463,8 @@ hive-c0re creates it. The root agent's flake lives at
`/var/lib/hyperhive/applied/ruth/`; its proposed config at `/var/lib/hyperhive/applied/ruth/`; its proposed config at
`/var/lib/hyperhive/agents/ruth/config/`. The root agent can edit its own `/var/lib/hyperhive/agents/ruth/config/`. The root agent can edit its own
`agent.nix` (visible inside the container at `/agents/ruth/config/`) `agent.nix` (visible inside the container at `/agents/ruth/config/`)
and submit `request_apply_commit("ruth", <sha>)` for operator and open a config PR on `agent-configs/ruth` for operator approval,
approval. same as any other agent.
Differences from sub-agents: Differences from sub-agents:
@ -559,11 +538,11 @@ root agent. Variants (`hive_sh4re::HelperEvent`):
- `ApprovalResolved { id, agent, commit_ref, status, note }` — - `ApprovalResolved { id, agent, commit_ref, status, note }` —
fired by `actions::approve` + `actions::deny` whenever an fired by `actions::approve` + `actions::deny` whenever an
approval transitions to its terminal state. approval transitions to its terminal state.
- `Spawned { agent, ok, note }` — `actions::approve` (first-time - `Spawned { agent, ok, note }` — the `Spawn` approval DAG +
ApplyCommit-kind) + admin `HostRequest::Spawn` (deprecated). admin `HostRequest::Spawn`.
- `Rebuilt { agent, ok, note }` — `auto_update::rebuild_agent` - `Rebuilt { agent, ok, note }` — `auto_update::rebuild_agent`
(covers startup scan + manual `/rebuild` from dashboard) + (covers startup scan + manual `/rebuild` from dashboard) +
`actions::approve` (ApplyCommit). the `MergeConfigPr` deploy.
- `Killed { agent }` — admin `HostRequest::Kill` + dashboard - `Killed { agent }` — admin `HostRequest::Kill` + dashboard
`/kill` + the `Kill` MCP tool. `/kill` + the `Kill` MCP tool.
- `Destroyed { agent }` — `actions::destroy`. - `Destroyed { agent }` — `actions::destroy`.
@ -582,9 +561,9 @@ root agent. Variants (`hive_sh4re::HelperEvent`):
often greets the agent on this event. often greets the agent on this event.
- `ConfigReady { agent }` — a new agent's proposed config repo was - `ConfigReady { agent }` — a new agent's proposed config repo was
just seeded (post-`InitConfig` approval). The root agent can now just seeded (post-`InitConfig` approval). The root agent can now
edit `/agents/<agent>/config/agent.nix`, commit the changes, edit `/agents/<agent>/config/agent.nix`, commit the changes, and
and submit `request_apply_commit` with the commit sha to create hand off to the operator to **spawn** the agent (the `Spawn`
the container (first ApplyCommit also triggers spawn bookkeeping). approval creates the container from that config).
- `NeedsUpdate { agent }` — sub-agent's recorded flake rev is - `NeedsUpdate { agent }` — sub-agent's recorded flake rev is
stale. The root agent calls `update(name)` to rebuild — idempotent, stale. The root agent calls `update(name)` to rebuild — idempotent,
no approval required. no approval required.

View file

@ -325,7 +325,7 @@ binary flavor.
| `inbox` | `get_loose_ends`, `cancel_loose_end`, `remind`, `request_next_turn` | | `inbox` | `get_loose_ends`, `cancel_loose_end`, `remind`, `request_next_turn` |
| `execution` | vestigial — `mcp__bash__run` / `mcp__bash__status` are always available unconditionally via `extraMcpServers`; this group's entries expand to non-existent `mcp__hyperhive__run` / `mcp__hyperhive__status` and have no effect. See `docs/tools/bash.md`. | | `execution` | vestigial — `mcp__bash__run` / `mcp__bash__status` are always available unconditionally via `extraMcpServers`; this group's entries expand to non-existent `mcp__hyperhive__run` / `mcp__hyperhive__status` and have no effect. See `docs/tools/bash.md`. |
| `lifecycle` | `kill`, `start`, `restart`, `update` *(privileged)* | | `lifecycle` | `kill`, `start`, `restart`, `update` *(privileged)* |
| `approvals` | `request_init_config`, `request_apply_commit`, `request_update_meta_inputs` *(privileged)* | | `approvals` | `request_init_config`, `request_update_meta_inputs` *(privileged)* |
| `scheduling` | `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`, `edit_schedule`, `list_schedules` *(privileged)* | | `scheduling` | `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`, `edit_schedule`, `list_schedules` *(privileged)* |
| `diagnostics` | `get_logs` *(privileged)* | | `diagnostics` | `get_logs` *(privileged)* |

View file

@ -201,7 +201,7 @@ per template.
### Approvals ### Approvals
`ApplyCommit` / `MergeConfigPr` approvals ride as single-node `MergeConfigPr` approvals ride as single-node
`ApprovalDeploy` DAGs: the two-phase `prepare_deploy` / `finalize_deploy` / `ApprovalDeploy` DAGs: the two-phase `prepare_deploy` / `finalize_deploy` /
`abort_deploy` meta orchestration stays inside `actions.rs` in v1 `abort_deploy` meta orchestration stays inside `actions.rs` in v1
(deliberately not modeled as scheduler nodes) and resolves the approval (deliberately not modeled as scheduler nodes) and resolves the approval
@ -274,7 +274,7 @@ Key operations:
init the repo on first call, relock if the rendered contents changed, commit. init the repo on first call, relock if the rendered contents changed, commit.
Called by spawn / destroy / startup migration. Called by spawn / destroy / startup migration.
- **`prepare_deploy` + `finalize_deploy` / `abort_deploy`** — two-phase for the - **`prepare_deploy` + `finalize_deploy` / `abort_deploy`** — two-phase for the
`RequestApplyCommit` path so a failed `nixos-container update` leaves no orphan `MergeConfigPr` deploy path so a failed `nixos-container update` leaves no orphan
commit in meta. Prepare writes the new lock without committing; finalize commits commit in meta. Prepare writes the new lock without committing; finalize commits
with the deploy message; abort restores the lock. with the deploy message; abort restores the lock.
- **`lock_update_hyperhive`** — one-shot for the boot-reconcile path (the - **`lock_update_hyperhive`** — one-shot for the boot-reconcile path (the

View file

@ -20,7 +20,7 @@ header/targets split, and the per-agent power-intent registry:
under the agent's state dir; the worker delivers a short under the agent's state dir; the worker delivers a short
pointer instead. `attempt_count` / `last_error` accumulate pointer instead. `attempt_count` / `last_error` accumulate
on delivery-failed retries. on delivery-failed retries.
- `approvals` — the queue. `agent / kind (apply_commit | spawn | - `approvals` — the queue. `agent / kind (merge_config_pr | spawn |
init_config | update_meta_inputs | schedule_prompt) / init_config | update_meta_inputs | schedule_prompt) /
commit_ref / requested_at / status / resolved_at / note`. commit_ref / requested_at / status / resolved_at / note`.
- `operator_questions` — `ask` / `answer` queue (despite the - `operator_questions` — `ask` / `answer` queue (despite the

View file

@ -65,12 +65,15 @@ the container, via MCP tools):
request_init_config(name: "iris") request_init_config(name: "iris")
# → operator approves → config_ready event lands in the inbox # → operator approves → config_ready event lands in the inbox
# Step 2: edit /agents/iris/config/agent.nix, commit it, then: # Step 2: edit /agents/iris/config/agent.nix and commit it. Then the
request_apply_commit(agent: "iris", commit_ref: "<sha>") # operator spawns iris (dashboard ◆ R3QU3ST SP4WN / Spawn approval),
# → operator approves → container built + started # which builds + starts the container from that config.
# Later config changes: open a PR on agent-configs/iris (hive-forge);
# the operator reviews + approves it — no MCP tool call.
``` ```
See [`approvals.md`](approvals.md) for the full two-step flow. See [`approvals.md`](approvals.md) for the full flow.
### 5 · Useful host commands ### 5 · Useful host commands
@ -97,8 +100,8 @@ See [`tools/hivectl.md`](tools/hivectl.md) for every `hivectl` verb.
- **No forge admin token is stored in any agent state dir.** Agents - **No forge admin token is stored in any agent state dir.** Agents
hold a regular agent token in their `forge-token` file; sensitive hold a regular agent token in their `forge-token` file; sensitive
creds (the core token, the matrix admin token) live on the host. creds (the core token, the matrix admin token) live on the host.
- All config changes (`request_apply_commit`) go through operator - All config changes (forge PRs on `agent-configs/<name>`) go through
approval — agents can't unilaterally rebuild containers, by design. operator approval — agents can't unilaterally rebuild containers, by design.
See [`boundary.md`](boundary.md) and [`security.md`](security.md). See [`boundary.md`](boundary.md) and [`security.md`](security.md).
Once the hive is running, ruth records anything it needs to remember Once the hive is running, ruth records anything it needs to remember

View file

@ -164,7 +164,6 @@ name as-is.
| `get_logs*` | `get_logs* name` or `get_logs* name NL` | | `get_logs*` | `get_logs* name` or `get_logs* name NL` |
| `get_host_journal*` | `get_host_journal*()` or with `[container] · [/grep/] · NL` | | `get_host_journal*` | `get_host_journal*()` or with `[container] · [/grep/] · NL` |
| **Approvals / config** | | | **Approvals / config** | |
| `request_apply_commit*` | `request_apply_commit* agent @ sha12` |
| `request_init_config*` | `request_init_config* name` | | `request_init_config*` | `request_init_config* name` |
| `request_update_meta_inputs*` | `request_update_meta_inputs* [inp1, …]` or `all` | | `request_update_meta_inputs*` | `request_update_meta_inputs* [inp1, …]` or `all` |
| **Scheduling** | | | **Scheduling** | |

View file

@ -45,24 +45,17 @@ Step 1 of spawning a new direct child agent. Queues an `InitConfig`
approval; on operator approve, hive-c0re seeds the proposed config approval; on operator approve, hive-c0re seeds the proposed config
repo at `/agents/<name>/config/agent.nix` with a default template and repo at `/agents/<name>/config/agent.nix` with a default template and
delivers a `config_ready` system event. Then edit `agent.nix`, commit, delivers a `config_ready` system event. Then edit `agent.nix`, commit,
and call `request_apply_commit` with the commit sha — the first and the operator **spawns** the agent (the dashboard `◆ R3QU3ST SP4WN`
`ApplyCommit` on a freshly-init'd config creates the container. button / `Spawn` approval, routed via `HostRequest::RequestSpawn`),
which creates the container from that config.
Subsequent config changes go through a **forge PR** on the agent's
`agent-configs/<name>` repo (queues a `MergeConfigPr` approval on
open/update — no MCP tool involved), not a tool call. See
`docs/approvals.md`.
Fails if a proposed config repo for `name` already exists. Fails if a proposed config repo for `name` already exists.
`name` is ≤ 9 characters. The operator can also spawn an empty agent `name` is ≤ 9 characters.
via the dashboard `◆ R3QU3ST SP4WN` button, which routes via
`HostRequest::RequestSpawn`.
### `request_apply_commit(agent, commit_ref, description?)`
Step 2 of spawning (or updating an existing child's config). Submit a
commit sha from the child's proposed config repo for operator
approval. On approve, hive-c0re rebuilds the container with the
pinned commit.
`commit_ref` must be a 7-40 char hex sha (branch/tag names are
rejected — the approval pins the exact commit). `agent` must be a
direct child. Topology-enforced.
### `request_update_meta_inputs(inputs?, description?)` ### `request_update_meta_inputs(inputs?, description?)`
@ -81,7 +74,6 @@ agents after the approval resolves.
| `kill` / `start` / `restart` / `update` | No | Direct children | | `kill` / `start` / `restart` / `update` | No | Direct children |
| `list_containers` | No | All descendants | | `list_containers` | No | All descendants |
| `request_init_config` | Yes (InitConfig) | New direct child only | | `request_init_config` | Yes (InitConfig) | New direct child only |
| `request_apply_commit` | Yes (ApplyCommit) | Direct children |
| `request_update_meta_inputs` | Yes (MetaUpdate) | Meta flake (global) | | `request_update_meta_inputs` | Yes (MetaUpdate) | Meta flake (global) |
## See also ## See also

View file

@ -1066,7 +1066,6 @@ renderApprovals`) with three stacked sections:
| kind | glyph | chip | sha shown | | kind | glyph | chip | sha shown |
|---|---|---|---| |---|---|---|---|
| `apply_commit` | `→` | `apply` | proposal sha (`sha_short`) |
| `merge_config_pr` | `⇒` | `merge-pr` | PR-head sha (`sha_short`) | | `merge_config_pr` | `⇒` | `merge-pr` | PR-head sha (`sha_short`) |
| `update_meta_inputs` | `↻` | `meta-update` | — | | `update_meta_inputs` | `↻` | `meta-update` | — |
| `schedule_prompt` | `⏱` | `schedule` | — | | `schedule_prompt` | `⏱` | `schedule` | — |
@ -1080,26 +1079,16 @@ renderApprovals`) with three stacked sections:
than at the next `renderApprovals` call. than at the next `renderApprovals` call.
- **what-changed body** — the submitting agent's description, then - **what-changed body** — the submitting agent's description, then
kind-specific drill-in triggers: kind-specific drill-in triggers:
- `apply_commit`: `↳ view diff` opens the inline diff side-panel;
`↳ commit on forge ↗` deep-links the proposal commit into
`agent-configs/<agent>` (shown only when `forge_present`).
- `merge_config_pr`: `↳ review PR on forge ↗` deep-links the - `merge_config_pr`: `↳ review PR on forge ↗` deep-links the
config PR into `agent-configs/<agent>/pulls/<pr_number>` (shown config PR into `agent-configs/<agent>/pulls/<pr_number>` (shown
only when `forge_present` and `pr_number` is set). No inline diff only when `forge_present` and `pr_number` is set). The config diff
side-panel (apply_commit-only for now). lives on the forge PR itself — no inline diff side-panel.
- `init_config` / `spawn`: a one-line "container will be created" - `init_config` / `spawn`: a one-line "container will be created"
note instead. note instead.
- **decision actions** — `◆ APPR0VE` and `DENY`. Deny pops a - **decision actions** — `◆ APPR0VE` and `DENY`. Deny pops a
`prompt()` for an optional reason carried to the submitting agent as `prompt()` for an optional reason carried to the submitting agent as
`HelperEvent::ApprovalResolved.note`. `HelperEvent::ApprovalResolved.note`.
The diff panel has a 3-way base toggle — **vs applied** (the
running tree, served instantly from the diff already on the
approval), **vs last-approved**, **vs previous proposal** — the
latter two fetched on click from `GET /api/approval-diff/{id}
?base=approved|previous`. Each line is classified client-side
(`+` / `-` / `@@` / `--- ` / `+++ ` → add / del / hunk / file).
A `pending · N` / `history · N` tab pair switches the section A `pending · N` / `history · N` tab pair switches the section
between the live queue and the last 30 resolved approvals. between the live queue and the last 30 resolved approvals.
@ -1199,12 +1188,8 @@ that's a browser-level decision, not ours.
`"ok"`/`"err"`, `detail` nullable), newest first, server-clamped to `"ok"`/`"err"`, `detail` nullable), newest first, server-clamped to
500; `total` is the full row count for a "latest 500 of N" header. 500; `total` is the full row count for a "latest 500 of N" header.
Backs the LOGS page AUDIT sub-tab. Backs the LOGS page AUDIT sub-tab.
- `GET /api/approval-diff/{id}?base=applied|approved|previous` — - `GET /static/marked.js` serves the vendored `marked` bundle used
on-demand unified diff for an `ApplyCommit` approval against for markdown previews.
the chosen base (running tree / last approved proposal /
previous queued proposal). Raw diff text, classified
client-side. `GET /static/marked.js` serves the vendored
`marked` bundle the side panel uses for markdown previews.
- `GET /api/state-file?path=<host-or-container-path>` — bounded - `GET /api/state-file?path=<host-or-container-path>` — bounded
text read of a file under the per-agent `state/` subtree or text read of a file under the per-agent `state/` subtree or
the shared `/var/lib/hyperhive/shared/`. Accepts the the shared `/var/lib/hyperhive/shared/`. Accepts the

View file

@ -1546,7 +1546,7 @@ window.marked = marked;
if (name.startsWith('mcp__matrix__')) return '💬'; if (name.startsWith('mcp__matrix__')) return '💬';
if (name.startsWith('mcp__bash__')) return '🖥️'; if (name.startsWith('mcp__bash__')) return '🖥️';
if (name.includes('schedule')) return '⏱️'; if (name.includes('schedule')) return '⏱️';
// request_apply_commit / request_init_config / request_update_meta_inputs // request_init_config / request_update_meta_inputs
if (name.startsWith('mcp__hyperhive__request_')) return '📦'; if (name.startsWith('mcp__hyperhive__request_')) return '📦';
} }
return '🔧'; return '🔧';
@ -1632,8 +1632,6 @@ window.marked = marked;
const msg = String(input.message || input.file_path || '').replace(/\s+/g, ' ').trim(); const msg = String(input.message || input.file_path || '').replace(/\s+/g, ' ').trim();
return short + (when ? ' ' + when : '') + (msg ? ' "' + trim(msg, 60) + '"' : ''); return short + (when ? ' ' + when : '') + (msg ? ' "' + trim(msg, 60) + '"' : '');
} }
case 'mcp__hyperhive__request_apply_commit':
return short + ' ' + (input.agent || '') + ' @ ' + (input.commit_ref || '').slice(0, 12);
case 'mcp__hyperhive__request_init_config': case 'mcp__hyperhive__request_init_config':
return short + ' ' + (input.name || '?'); return short + ' ' + (input.name || '?');
case 'mcp__hyperhive__request_update_meta_inputs': { case 'mcp__hyperhive__request_update_meta_inputs': {

View file

@ -130,7 +130,6 @@ export function applyApprovalAdded(ev) {
kind: ev.approval_kind, kind: ev.approval_kind,
sha_short: ev.sha_short || null, sha_short: ev.sha_short || null,
pr_number: ev.pr_number ?? null, pr_number: ev.pr_number ?? null,
diff: ev.diff || null,
description: ev.description || null, description: ev.description || null,
// The ApprovalAdded event carries no requested_at; a live-added // The ApprovalAdded event carries no requested_at; a live-added
// approval was queued just now, so client-now is accurate — and // approval was queued just now, so client-now is accurate — and
@ -167,68 +166,6 @@ export function applyApprovalResolved(ev) {
} }
renderApprovals(); renderApprovals();
} }
// Classify each unified-diff line by its leading char so
// `.diff-add` / `.diff-del` / `.diff-hunk` / `.diff-file` /
// `.diff-ctx` colour the output. Built as text-only spans (no
// innerHTML) so there's no HTML-escape surface.
function buildDiffPre(text) {
const pre = el('pre', { class: 'diff' });
for (const raw of String(text).split('\n')) {
let cls = 'diff-ctx';
if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file';
else if (raw.startsWith('@')) cls = 'diff-hunk';
else if (raw.startsWith('+')) cls = 'diff-add';
else if (raw.startsWith('-')) cls = 'diff-del';
const span = document.createElement('span');
span.className = cls;
span.textContent = raw + '\n';
pre.appendChild(span);
}
return pre;
}
// Open an approval's diff in the side panel with a 3-way base
// toggle: vs applied (running tree), vs last-approved, vs previous
// proposal. `applied` uses the diff already shipped on the approval
// for instant paint; the other two fetch /api/approval-diff.
function openDiffPanel(a) {
const bases = [
['applied', 'vs applied'],
['approved', 'vs last-approved'],
['previous', 'vs previous proposal'],
];
const tabs = el('div', { class: 'diff-base-tabs' });
const host = el('div', { class: 'diff-host' });
async function selectBase(base) {
for (const btn of tabs.children) {
btn.classList.toggle('active', btn.dataset.base === base);
}
if (base === 'applied' && a.diff != null) {
host.replaceChildren(buildDiffPre(a.diff));
return;
}
host.replaceChildren(el('div', { class: 'meta' }, 'loading…'));
try {
const resp = await fetch('/api/approval-diff/' + a.id + '?base=' + base);
const text = await resp.text();
host.replaceChildren(resp.ok
? buildDiffPre(text)
: el('div', { class: 'meta' }, 'error: ' + text));
} catch (e) {
host.replaceChildren(el('div', { class: 'meta' }, 'error: ' + e));
}
}
for (const [base, label] of bases) {
const btn = el('button',
{ type: 'button', class: 'diff-base-tab', 'data-base': base }, label);
btn.addEventListener('click', () => selectBase(base));
tabs.append(btn);
}
const wrap = el('div', { class: 'diff-panel' }, tabs, host);
Panel.open('diff · ' + a.agent + ' #' + a.id, wrap);
selectBase('applied');
}
export function renderApprovals() { export function renderApprovals() {
const root = $('approvals-section'); const root = $('approvals-section');
// #approvals-section only lives on /dashboard.html (Y3R C4LL tab); // #approvals-section only lives on /dashboard.html (Y3R C4LL tab);
@ -313,7 +250,6 @@ export function renderApprovals() {
const ul = el('ul', { class: 'approvals' }); const ul = el('ul', { class: 'approvals' });
for (const a of pending) { for (const a of pending) {
const isApply = a.kind === 'apply_commit';
const isInit = a.kind === 'init_config'; const isInit = a.kind === 'init_config';
const isMergePr = a.kind === 'merge_config_pr'; const isMergePr = a.kind === 'merge_config_pr';
const isUpdateMeta = a.kind === 'update_meta_inputs'; const isUpdateMeta = a.kind === 'update_meta_inputs';
@ -322,13 +258,13 @@ export function renderApprovals() {
// ── identity header ────────────────────────────────────────── // ── identity header ──────────────────────────────────────────
const head = el('div', { class: 'approval-head' }, const head = el('div', { class: 'approval-head' },
el('span', { class: 'glyph' }, isApply ? '→' : isMergePr ? '⇒' : isUpdateMeta ? '↻' : isSchedule ? '⏱' : '⊕'), el('span', { class: 'glyph' }, isMergePr ? '⇒' : isUpdateMeta ? '↻' : isSchedule ? '⏱' : '⊕'),
el('span', { class: 'id' }, '#' + a.id), el('span', { class: 'id' }, '#' + a.id),
el('span', { class: 'agent' }, a.agent), el('span', { class: 'agent' }, a.agent),
el('span', { class: 'kind' + ((isApply || isMergePr || isUpdateMeta || isSchedule) ? '' : ' kind-spawn') }, el('span', { class: 'kind' + ((isMergePr || isUpdateMeta || isSchedule) ? '' : ' kind-spawn') },
isApply ? 'apply' : isMergePr ? 'merge-pr' : isUpdateMeta ? 'meta-update' : isSchedule ? 'schedule' : isInit ? 'init' : 'spawn'), isMergePr ? 'merge-pr' : isUpdateMeta ? 'meta-update' : isSchedule ? 'schedule' : isInit ? 'init' : 'spawn'),
); );
if ((isApply || isMergePr) && a.sha_short) head.append(el('code', {}, a.sha_short)); if (isMergePr && a.sha_short) head.append(el('code', {}, a.sha_short));
// When the approval was requested — relative time, right-aligned. // When the approval was requested — relative time, right-aligned.
// Goes amber once it's been pending an hour so a stale request is // Goes amber once it's been pending an hour so a stale request is
// obvious at a glance (see docs/web-ui.md::Approval card). // obvious at a glance (see docs/web-ui.md::Approval card).
@ -348,24 +284,9 @@ export function renderApprovals() {
if (a.description) { if (a.description) {
body.append(el('div', { class: 'approval-description' }, a.description)); body.append(el('div', { class: 'approval-description' }, a.description));
} }
if (isApply) { if (isMergePr) {
const drill = el('div', { class: 'drill-ins' }); // PR-based config deploy: link to the reviewed PR on the forge.
const diffBtn = el('button', { type: 'button', class: 'panel-trigger' }, // The config diff lives on the forge PR itself.
'↳ view diff');
diffBtn.addEventListener('click', () => openDiffPanel(a));
drill.append(diffBtn);
if (forgeBase && a.sha_full) {
drill.append(el('a', {
class: 'panel-trigger', target: '_blank', rel: 'noopener',
href: `${forgeBase}/agent-configs/${a.agent}/commit/${a.sha_full}`,
title: 'this proposal commit on the hive forge',
}, '↳ commit on forge ↗'));
}
body.append(drill);
} else if (isMergePr) {
// PR-based config deploy: link to the reviewed PR on the forge
// (mirrors the apply_commit "commit on forge" link). The config
// diff side-panel is apply_commit-only for now.
const drill = el('div', { class: 'drill-ins' }); const drill = el('div', { class: 'drill-ins' });
if (forgeBase && a.pr_number != null) { if (forgeBase && a.pr_number != null) {
drill.append(el('a', { drill.append(el('a', {
@ -443,7 +364,7 @@ function renderApprovalHistory(root, history) {
el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ', el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ',
el('span', { class: 'id' }, '#' + a.id), ' ', el('span', { class: 'id' }, '#' + a.id), ' ',
el('span', { class: 'agent' }, a.agent), ' ', el('span', { class: 'agent' }, a.agent), ' ',
el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : a.kind === 'merge_config_pr' ? 'merge-pr' : a.kind === 'update_meta_inputs' ? 'meta-update' : a.kind === 'schedule_prompt' ? 'schedule' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ', el('span', { class: 'kind' }, a.kind === 'merge_config_pr' ? 'merge-pr' : a.kind === 'update_meta_inputs' ? 'meta-update' : a.kind === 'schedule_prompt' ? 'schedule' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ',
); );
if (a.sha_short) row.append(el('code', {}, a.sha_short), ' '); if (a.sha_short) row.append(el('code', {}, a.sha_short), ' ');
row.append( row.append(

View file

@ -88,9 +88,9 @@ pub struct RequestInitConfigArgs {
/// New sub-agent name (≤9 chars). Queues an `InitConfig` approval; on /// New sub-agent name (≤9 chars). Queues an `InitConfig` approval; on
/// approval hive-c0re seeds the proposed config repo at /// approval hive-c0re seeds the proposed config repo at
/// `/agents/<name>/config/agent.nix` with the default template. After /// `/agents/<name>/config/agent.nix` with the default template. After
/// the approval the manager edits + commits the config and calls /// the approval the manager edits + commits the config, then the operator
/// `request_apply_commit` to pin the customised sha for the container's /// spawns the agent; later config changes go through a PR on the child's
/// first build. /// `agent-configs/<name>` repo.
pub name: String, pub name: String,
/// Optional description shown on the dashboard approval card. /// Optional description shown on the dashboard approval card.
#[serde(default)] #[serde(default)]
@ -208,20 +208,6 @@ pub struct AgentGetLooseEndsArgs {
pub agent: Option<String>, pub agent: Option<String>,
} }
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct RequestApplyCommitArgs {
/// Logical agent name whose config repo the commit lives in.
pub agent: String,
/// Commit sha (full or short, 7-40 hex chars) in that agent's
/// proposed config repo. Must be a sha — a branch or tag name
/// (e.g. `main`) is rejected; the approval pins the exact commit.
pub commit_ref: String,
/// Optional description shown on the dashboard approval card so the
/// operator knows what the change does without opening the diff.
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct UpdateMetaInputsArgs { pub struct UpdateMetaInputsArgs {
/// Flake input names to update (e.g. `["bitburner-agent", "nixpkgs"]`). /// Flake input names to update (e.g. `["bitburner-agent", "nixpkgs"]`).

View file

@ -28,9 +28,9 @@ mod render;
pub use args::{ pub use args::{
AckUntilArgs, AgentGetLooseEndsArgs, AnswerArgs, AskArgs, CancelLooseEndArgs, AckUntilArgs, AgentGetLooseEndsArgs, AnswerArgs, AskArgs, CancelLooseEndArgs,
CancelScheduleArgs, CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs, CancelScheduleArgs, CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs,
GetHostJournalArgs, GetLogsArgs, KillArgs, RecvArgs, RemindArgs, RequestApplyCommitArgs, GetHostJournalArgs, GetLogsArgs, KillArgs, RecvArgs, RemindArgs, RequestInitConfigArgs,
RequestInitConfigArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs, StartArgs, UpdateArgs,
StartArgs, UpdateArgs, UpdateMetaInputsArgs, UpdateMetaInputsArgs,
}; };
pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv}; pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv};
@ -686,10 +686,10 @@ impl AgentServer {
description = "Initialise a brand-new direct child agent's proposed config repo and \ description = "Initialise a brand-new direct child agent's proposed config repo and \
queue an `InitConfig` approval for the operator to review. Requires the `approvals` \ queue an `InitConfig` approval for the operator to review. Requires the `approvals` \
tool group. `name` must be a direct child of this agent in the topology tree. \ tool group. `name` must be a direct child of this agent in the topology tree. \
Fails if a config repo for that child already exists — use `request_apply_commit` \ Fails if a config repo for that child already exists. On approval hive-c0re seeds \
to update an existing agent's config. On approval hive-c0re seeds \ `/agents/<name>/config/agent.nix` with the default template; customise + commit it, \
`/agents/<name>/config/agent.nix` with the default template so you can \ then the operator spawns the agent. Later config changes go through a PR on the \
customise it and then call `request_apply_commit` with the commit sha." child's `agent-configs/<name>` repo, reviewed + approved by the operator."
)] )]
async fn request_init_config( async fn request_init_config(
&self, &self,
@ -716,45 +716,6 @@ impl AgentServer {
.await .await
} }
// IMPORTANT: this tool is only available when the `approvals` tool group
// is configured for the agent (`HIVE_TOOL_GROUPS` contains `approvals`).
// hive-c0re performs a topology check server-side: only direct children
// of the calling agent are accepted; all other names are rejected.
#[tool(
description = "Submit a config change for a direct child agent, queued for operator \
approval. Requires the `approvals` tool group. `agent` must be a direct child \
of this agent in the topology tree. Pass a commit sha (7-40 hex chars, full or \
short) from that agent's proposed config repo — branch/tag names like `main` are \
rejected, the approval pins the exact commit. On approval hive-c0re rebuilds \
the container with the new config."
)]
async fn request_apply_commit(
&self,
Parameters(args): Parameters<RequestApplyCommitArgs>,
) -> String {
let log = format!("{args:?}");
let agent = args.agent.clone();
let commit_ref = args.commit_ref.clone();
run_tool_envelope("request_apply_commit", log, async move {
let (resp, retries) = self
.dispatch(hive_sh4re::Request::RequestApplyCommit {
agent: args.agent,
commit_ref: args.commit_ref,
description: args.description,
})
.await;
annotate_retries(
format_ack(
resp,
"request_apply_commit",
format!("apply approval queued for {agent} @ {commit_ref}"),
),
retries,
)
})
.await
}
// IMPORTANT: this tool is only available when the `lifecycle` tool group // IMPORTANT: this tool is only available when the `lifecycle` tool group
// is granted to this agent. hive-c0re enforces the topology check // is granted to this agent. hive-c0re enforces the topology check
// server-side: the call is rejected unless `name` is a direct child. // server-side: the call is rejected unless `name` is a direct child.

View file

@ -15,10 +15,10 @@ use crate::lifecycle;
/// either runs the work inline (`InitConfig`, sub-second git ops) or /// either runs the work inline (`InitConfig`, sub-second git ops) or
/// submits it to the job queue so the dashboard POST returns /// submits it to the job queue so the dashboard POST returns
/// immediately while the long-running pipeline runs off-thread /// immediately while the long-running pipeline runs off-thread
/// (operator no longer blocks on a 30-90s spinner for `ApplyCommit`). /// (operator no longer blocks on a 30-90s spinner for `MergeConfigPr`).
/// ///
/// Dispatch: /// Dispatch:
/// - `ApplyCommit` / `MergeConfigPr` → a single-node `ApprovalDeploy` /// - `MergeConfigPr` → a single-node `ApprovalDeploy`
/// DAG (the two-phase meta deploy stays opaque in v1; ~30-90s) /// DAG (the two-phase meta deploy stays opaque in v1; ~30-90s)
/// - `UpdateMetaInputs` → a `MetaUpdate` DAG (fan-out on completion) /// - `UpdateMetaInputs` → a `MetaUpdate` DAG (fan-out on completion)
/// - `Spawn` → a `Spawn` DAG (`Create → WriteDropin → Reconcile`) /// - `Spawn` → a `Spawn` DAG (`Create → WriteDropin → Reconcile`)
@ -46,15 +46,6 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
let notes_dir = Coordinator::agent_notes_dir(&approval.agent); let notes_dir = Coordinator::agent_notes_dir(&approval.agent);
run_approval_init_config(&coord, approval, proposed_dir, claude_dir, notes_dir).await run_approval_init_config(&coord, approval, proposed_dir, claude_dir, notes_dir).await
} }
ApprovalKind::ApplyCommit => {
enqueue_approval_rebuild(
&coord,
&approval.agent,
id,
format!("approval #{id} apply commit"),
);
Ok(())
}
ApprovalKind::UpdateMetaInputs => { ApprovalKind::UpdateMetaInputs => {
// Inputs JSON-encoded into commit_ref by the manager's // Inputs JSON-encoded into commit_ref by the manager's
// submit path — surface them on the DAG so the dashboard // submit path — surface them on the DAG so the dashboard
@ -109,11 +100,11 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
result result
} }
ApprovalKind::MergeConfigPr => { ApprovalKind::MergeConfigPr => {
// Like ApplyCommit, the work ends in a container rebuild, so // The work ends in a container rebuild, so route it through the
// route it through the rebuild queue. The queue worker // rebuild queue. The queue worker dispatches MergeConfigPr
// dispatches MergeConfigPr approvals to `run_merge_config_pr` // approvals to `run_merge_config_pr` (verify the reviewed PR head,
// (verify the reviewed PR head, ff the forge config repo's // ff the forge config repo's main to it, mark merged, then the
// main to it, mark merged, then the shared deploy tail). // deploy tail).
enqueue_approval_rebuild( enqueue_approval_rebuild(
&coord, &coord,
&approval.agent, &approval.agent,
@ -126,10 +117,9 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
} }
/// Submit the single-node `ApprovalDeploy` DAG tied to an approval id. /// Submit the single-node `ApprovalDeploy` DAG tied to an approval id.
/// Shared by the `ApplyCommit` and `MergeConfigPr` dispatch arms — both /// Used by the `MergeConfigPr` dispatch arm — the work ends in a container
/// end in a container rebuild routed through the queue, differing only /// rebuild routed through the queue; the node executor runs
/// in the `reason`. The node executor branches on the approval's kind /// `run_merge_config_pr`.
/// to pick the right handler.
fn enqueue_approval_rebuild( fn enqueue_approval_rebuild(
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
agent: &str, agent: &str,
@ -149,40 +139,9 @@ fn enqueue_approval_rebuild(
coord.emit_rebuild_queue_snapshot(); coord.emit_rebuild_queue_snapshot();
} }
/// Worker entry point for `ApprovalKind::ApplyCommit` queue entries. /// Worker entry point for `ApprovalKind::MergeConfigPr` queue entries — the
/// Re-fetches the approval row, runs the commit pipeline, and fires /// config-change flow's deploy worker. Re-fetches the approval row, runs the
/// `ApprovalResolved` + the lifecycle event (`Rebuilt` / `Spawned` /// merge pipeline, and fires
/// for first-spawn).
pub async fn run_approval_apply_commit(
coord: &Arc<Coordinator>,
queue_entry_id: Option<u64>,
approval_id: i64,
) -> Result<()> {
let approval = fetch_approval_for_worker(coord, approval_id, ApprovalKind::ApplyCommit)?;
// Runtime dir creation is handled inside lifecycle::rebuild_no_meta's
// spawn path (first-spawn) or is already present for rebuilds.
let agent_dir = crate::paths::agent_runtime_dir(&approval.agent);
let applied_dir = crate::paths::applied_dir(&approval.agent);
coord.set_queue_step(queue_entry_id, "apply commit");
let (result, terminal_tag, is_first_spawn) =
run_apply_commit(coord, &approval, &agent_dir, &applied_dir, queue_entry_id).await;
coord.set_queue_step(queue_entry_id, "forge push");
if let Err(e) = crate::forge::push_config(&approval.agent).await {
tracing::warn!(agent = %approval.agent, error = ?e, "forge: push_config after apply failed");
}
if is_first_spawn && result.is_ok() {
coord.set_queue_step(queue_entry_id, "first-spawn forge bootstrap");
forge_after_first_spawn(coord, &approval.agent).await;
}
// `finish_approval` returns the original `result` so the queue
// worker sees Ok/Err and marks the queue entry accordingly. The
// approval row + helper events have already been fanned out.
finish_approval(coord, &approval, result, terminal_tag, is_first_spawn)
}
/// Worker entry point for `ApprovalKind::MergeConfigPr` queue entries —
/// the PR-based config flow's counterpart to `run_approval_apply_commit`.
/// Re-fetches the approval row, runs the merge pipeline, and fires
/// `ApprovalResolved` + the `Rebuilt` lifecycle event via `finish_approval`. /// `ApprovalResolved` + the `Rebuilt` lifecycle event via `finish_approval`.
/// `run_merge_config_pr` already fast-forwarded the forge repo's `main` to the /// `run_merge_config_pr` already fast-forwarded the forge repo's `main` to the
/// reviewed head (that IS the merge), so `push_config`'s `main` refspec is a /// reviewed head (that IS the merge), so `push_config`'s `main` refspec is a
@ -218,7 +177,7 @@ pub async fn run_approval_merge_config_pr(
if let Err(e) = &result { if let Err(e) = &result {
post_merge_failure_to_pr(coord, &approval, since_ts, e).await; post_merge_failure_to_pr(coord, &approval, since_ts, e).await;
} }
finish_approval(coord, &approval, result, terminal_tag, false) finish_approval(coord, &approval, result, terminal_tag)
} }
/// Max stderr bytes to inline in a PR failure comment. Keeps the comment /// Max stderr bytes to inline in a PR failure comment. Keeps the comment
@ -298,7 +257,7 @@ fn tail_bytes(s: &str, max_bytes: usize) -> String {
/// 2. fetch the reviewed head into the applied repo so later git ops resolve /// 2. fetch the reviewed head into the applied repo so later git ops resolve
/// it locally; /// it locally;
/// 3. eval-verify the reviewed commit against the meta flake BEFORE the /// 3. eval-verify the reviewed commit against the meta flake BEFORE the
/// irreversible push (same gate `run_apply_commit` uses); /// irreversible push;
/// 4. fast-forward the forge repo's `main` to the reviewed head — THE merge; /// 4. fast-forward the forge repo's `main` to the reviewed head — THE merge;
/// 5. mark the PR merged (best-effort: `main` is already at the head, so a /// 5. mark the PR merged (best-effort: `main` is already at the head, so a
/// failure here is logged, not fatal); /// failure here is logged, not fatal);
@ -399,8 +358,7 @@ async fn run_merge_config_pr(
Err(e) => return (Err(anyhow::anyhow!("ff-merge PR #{pr}: {e}")), None), Err(e) => return (Err(anyhow::anyhow!("ff-merge PR #{pr}: {e}")), None),
} }
// 5. Shared deploy tail. target == finalize == the reviewed head; // 5. Deploy tail. target == finalize == the reviewed head.
// never a first spawn (the agent already exists).
deploy_applied_target( deploy_applied_target(
coord, coord,
&approval.agent, &approval.agent,
@ -410,7 +368,6 @@ async fn run_merge_config_pr(
&reviewed, &reviewed,
id, id,
&prev_main_sha, &prev_main_sha,
false,
queue_entry_id, queue_entry_id,
) )
.await .await
@ -443,7 +400,7 @@ async fn run_approval_schedule_prompt(
.context("insert scheduled prompt") .context("insert scheduled prompt")
} }
.await; .await;
finish_approval(coord, &approval, result, None, false) finish_approval(coord, &approval, result, None)
} }
/// Terminal hook for approval-carrying DAGs — the job queue's /// Terminal hook for approval-carrying DAGs — the job queue's
@ -497,7 +454,7 @@ pub(crate) async fn resolve_approval_dag(
crate::dashboard::emit_tombstones_snapshot(coord).await; crate::dashboard::emit_tombstones_snapshot(coord).await;
} }
} }
if let Err(e) = finish_approval(coord, &approval, result, None, false) { if let Err(e) = finish_approval(coord, &approval, result, None) {
tracing::warn!(approval_id, error = ?e, "approval dag resolved with failure"); tracing::warn!(approval_id, error = ?e, "approval dag resolved with failure");
} }
} }
@ -590,7 +547,7 @@ async fn run_approval_init_config(
{ {
tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_meta_remote after init_config failed"); tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_meta_remote after init_config failed");
} }
finish_approval(coord, &approval, result, None, false) finish_approval(coord, &approval, result, None)
} }
fn finish_approval( fn finish_approval(
@ -598,7 +555,6 @@ fn finish_approval(
approval: &hive_sh4re::Approval, approval: &hive_sh4re::Approval,
result: Result<()>, result: Result<()>,
terminal_tag: Option<String>, terminal_tag: Option<String>,
is_first_spawn: bool,
) -> Result<()> { ) -> Result<()> {
let (status, note, ok) = match &result { let (status, note, ok) = match &result {
Ok(()) => (ApprovalStatus::Approved, None, true), Ok(()) => (ApprovalStatus::Approved, None, true),
@ -665,22 +621,10 @@ fn finish_approval(
sha: approval.fetched_sha.clone(), sha: approval.fetched_sha.clone(),
}, },
), ),
ApprovalKind::ApplyCommit if is_first_spawn => { // MergeConfigPr ends in a container rebuild — surface a Rebuilt
coord.notify_submitter( // lifecycle event. (It is never a first spawn — the agent already
approval.id, // exists — so it never needs the Spawned arm above.)
&HelperEvent::Spawned { ApprovalKind::MergeConfigPr => {
agent: approval.agent.clone(),
ok,
note,
sha: approval.fetched_sha.clone(),
},
);
}
// MergeConfigPr ends in a container rebuild just like a
// non-first-spawn ApplyCommit, so both surface the same Rebuilt
// lifecycle event. (MergeConfigPr is never a first spawn — the
// agent already exists — so it never hits the Spawned arm above.)
ApprovalKind::ApplyCommit | ApprovalKind::MergeConfigPr => {
coord.notify_submitter( coord.notify_submitter(
approval.id, approval.id,
&HelperEvent::Rebuilt { &HelperEvent::Rebuilt {
@ -699,148 +643,25 @@ fn finish_approval(
result result
} }
/// Tag-driven `ApplyCommit` handler. Walks the approval through the tag /// Deploy tail for the config-PR merge flow. Fast-forwards `applied/main` to
/// state machine documented in `docs/approvals.md`: stamp /// `target_ref`, syncs the working tree, runs the meta two-phase deploy +
/// `approved/<id>` and `building/<id>` first so the audit trail /// container rebuild, and plants the `deployed/<tag_base>` /
/// captures intent, then drop the candidate tree into the working dir /// `failed/<tag_base>` bookkeeping tags. On build failure it rolls
/// without moving HEAD, run the rebuild, and either fast-forward /// `applied/main` back to `prev_main_sha` and aborts the staged meta lock so
/// `applied/main` to the proposal commit on success /// the agent stays on its last-good tree. Returns the build result + the
/// (`deployed/<id>`) or annotate `failed/<id>` with the build error /// terminal tag name.
/// and reset the working tree back to the last known-good main. main
/// never advances on a failed build, so a crash-and-recover doesn't
/// leave the agent pointing at a tree it can't evaluate. The shared
/// ff/deploy/rebuild/finalize tail lives in `deploy_applied_target`.
async fn run_apply_commit(
coord: &Arc<Coordinator>,
approval: &hive_sh4re::Approval,
agent_dir: &std::path::Path,
applied_dir: &std::path::Path,
queue_entry_id: Option<u64>,
) -> (Result<()>, Option<String>, bool) {
let id = approval.id;
let proposal_ref = format!("refs/tags/proposal/{id}");
// Detect first spawn before we touch anything so we can branch on it
// throughout this function.
let is_first_spawn = !lifecycle::container_exists(&approval.agent).await;
// Defensive: submit-time should have planted proposal/<id>, but if
// the row was migrated from an older schema or the tag got pruned
// we fail early with a clear note rather than building a stale
// tree.
if let Err(e) = lifecycle::git_rev_parse(applied_dir, &proposal_ref).await {
return (
Err(anyhow::anyhow!(
"missing proposal tag {proposal_ref}: {e:#}"
)),
None,
is_first_spawn,
);
}
// Capture the currently-deployed sha so we can roll applied/main
// (and the meta lock indirectly) back if the build fails.
let prev_main_sha = match lifecycle::git_rev_parse(applied_dir, "refs/heads/main").await {
Ok(s) => s,
Err(e) => {
return (
Err(anyhow::anyhow!("read applied/main: {e:#}")),
None,
is_first_spawn,
);
}
};
// Pre-flight eval-verify the proposal commit against the meta flake
// WITHOUT mutating applied/main or the meta lock, so an evaluation
// error (bad nix, missing module option, unresolvable lock) fails
// fast here instead of after we've fast-forwarded main and have to
// roll it back. Skipped on first spawn: the agent has no
// `agent-<name>` meta input to override yet (sync_agents adds it
// below). This is the reusable verify primitive the PR-based config
// flow gates its irreversible ff-push on.
if !is_first_spawn {
let proposal_sha = match lifecycle::git_rev_parse(applied_dir, &proposal_ref).await {
Ok(s) => s,
Err(e) => {
return (
Err(anyhow::anyhow!("rev-parse {proposal_ref}: {e:#}")),
None,
is_first_spawn,
);
}
};
coord.set_queue_step(queue_entry_id, "verify proposal (eval)");
if let Err(e) =
crate::meta::verify_commit(&approval.agent, applied_dir, &proposal_sha).await
{
return (
Err(anyhow::anyhow!("verify proposal {proposal_ref}: {e:#}")),
None,
is_first_spawn,
);
}
}
coord.set_queue_step(queue_entry_id, "plant tags");
if let Err(e) = lifecycle::git_tag(applied_dir, &format!("approved/{id}"), &proposal_ref).await
{
return (
Err(anyhow::anyhow!("plant approved/{id}: {e:#}")),
None,
is_first_spawn,
);
}
if let Err(e) = lifecycle::git_tag(applied_dir, &format!("building/{id}"), &proposal_ref).await
{
return (
Err(anyhow::anyhow!("plant building/{id}: {e:#}")),
None,
is_first_spawn,
);
}
// Fast-forward applied/main to the proposal, run the meta deploy +
// container rebuild, and finalize/roll-back — the tail shared with the
// PR-merge flow. ApplyCommit's target == finalize sha source is
// `fetched_sha` (or the proposal ref when unset), matching the prior
// inline behavior exactly.
let (result, tag) = deploy_applied_target(
coord,
&approval.agent,
agent_dir,
applied_dir,
&proposal_ref,
approval.fetched_sha.as_deref().unwrap_or(&proposal_ref),
id,
&prev_main_sha,
is_first_spawn,
queue_entry_id,
)
.await;
(result, tag, is_first_spawn)
}
/// Shared deploy tail for config-applying approvals (`ApplyCommit` + the
/// PR-merge flow). Fast-forwards `applied/main` to `target_ref`, syncs the
/// working tree, runs the meta two-phase deploy + container rebuild, and
/// plants the `deployed/<tag_base>` / `failed/<tag_base>` bookkeeping tags.
/// On build failure it rolls `applied/main` back to `prev_main_sha` and aborts
/// the staged meta lock so the agent stays on its last-good tree. Returns the
/// build result + the terminal tag name.
/// ///
/// Caller-specific bits stay OUT of here: the source fetch (proposal tag vs /// Caller-specific bits stay OUT of here: fetching the PR head, the
/// forge fetch), the `approved/building` tags, `verify_commit`, and any forge /// `verify_commit` gate, the ff-merge, and forge mark-merged. `finalize_sha`
/// ff-push / mark-merged. `is_first_spawn` gates the one-time meta /// is the sha recorded by `meta::finalize_deploy`; `target_ref` is what
/// `sync_agents` step (only `ApplyCommit`'s first spawn passes `true`; /// `applied/main` fast-forwards to. The agent always already exists here (a
/// the PR-merge flow always passes `false` — the agent already exists). /// merge is never a first spawn), so there's no `sync_agents` step — the
/// `finalize_sha` is the sha recorded by `meta::finalize_deploy`; `target_ref` /// operator `Spawn` flow owns first-time meta registration.
/// is what `applied/main` fast-forwards to (a proposal ref or a commit sha).
#[allow( #[allow(
clippy::too_many_arguments, clippy::too_many_arguments,
clippy::too_many_lines, clippy::too_many_lines,
reason = "one sequential ff/deploy/rebuild/finalize pipeline shared by both \ reason = "one sequential ff/deploy/rebuild/finalize pipeline; splitting it \
config-apply callers; splitting it would obscure the linear flow" would obscure the linear flow"
)] )]
async fn deploy_applied_target( async fn deploy_applied_target(
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
@ -851,7 +672,6 @@ async fn deploy_applied_target(
finalize_sha: &str, finalize_sha: &str,
tag_base: i64, tag_base: i64,
prev_main_sha: &str, prev_main_sha: &str,
is_first_spawn: bool,
queue_entry_id: Option<u64>, queue_entry_id: Option<u64>,
) -> (Result<()>, Option<String>) { ) -> (Result<()>, Option<String>) {
let id = tag_base; let id = tag_base;
@ -872,33 +692,6 @@ async fn deploy_applied_target(
return (Err(anyhow::anyhow!("read-tree to main: {e:#}")), None); return (Err(anyhow::anyhow!("read-tree to main: {e:#}")), None);
} }
// First spawn: sync_agents must add this agent to the meta flake
// before prepare_deploy can update its input lock (which won't
// exist yet if this is the agent's first deploy).
if is_first_spawn {
coord.set_queue_step(queue_entry_id, "meta sync_agents (first spawn)");
let agents = match lifecycle::agents_for_meta_listing_with(agent).await {
Ok(a) => a,
Err(e) => {
let _ =
lifecycle::git_update_ref(applied_dir, "refs/heads/main", prev_main_sha).await;
let _ = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await;
return (
Err(anyhow::anyhow!("agents_for_meta_listing_with: {e:#}")),
None,
);
}
};
if let Err(e) = crate::meta::sync_agents(&coord.hive_env(), &agents).await {
let _ = lifecycle::git_update_ref(applied_dir, "refs/heads/main", prev_main_sha).await;
let _ = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await;
return (
Err(anyhow::anyhow!("meta sync_agents for first spawn: {e:#}")),
None,
);
}
}
coord.set_queue_step(queue_entry_id, "meta prepare_deploy"); coord.set_queue_step(queue_entry_id, "meta prepare_deploy");
// Phase 1 of the meta two-phase deploy: relock without committing. // Phase 1 of the meta two-phase deploy: relock without committing.
if let Err(e) = crate::meta::prepare_deploy(agent).await { if let Err(e) = crate::meta::prepare_deploy(agent).await {
@ -1066,42 +859,14 @@ async fn sync_meta_after_lifecycle(coord: &Coordinator) -> Result<()> {
crate::meta::sync_agents(&coord.hive_env(), &agents).await crate::meta::sync_agents(&coord.hive_env(), &agents).await
} }
pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> { pub fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> {
let approval = coord.approvals.get(id)?; let approval = coord.approvals.get(id)?;
coord.approvals.mark_denied(id, note)?; coord.approvals.mark_denied(id, note)?;
tracing::info!(%id, note, "approval denied"); tracing::info!(%id, note, "approval denied");
let mut tag = None; // MergeConfigPr denials carry no git tag — the PR stays open on the forge.
let tag: Option<String> = None;
if let Some(a) = approval { if let Some(a) = approval {
let sha = a.fetched_sha.clone(); let sha = a.fetched_sha.clone();
// ApplyCommit approvals leave a `denied/<id>` tag on the
// proposal commit so rejected configs are first-class git
// objects — `git show denied/<id>` in the manager's applied
// mount yields both the tree the operator rejected and (in
// the annotated body) the reason. Spawn approvals have no
// commit to tag, so they fall through unannotated.
if matches!(a.kind, ApprovalKind::ApplyCommit) {
let applied_dir = crate::paths::applied_dir(&a.agent);
let proposal_ref = format!("refs/tags/proposal/{id}");
if lifecycle::git_rev_parse(&applied_dir, &proposal_ref)
.await
.is_ok()
{
let tag_name = format!("denied/{id}");
let body = note.unwrap_or("").to_owned();
if let Err(e) =
lifecycle::git_tag_annotated(&applied_dir, &tag_name, &proposal_ref, &body)
.await
{
tracing::warn!(%id, error = ?e, "plant denied tag failed");
} else {
tag = Some(tag_name);
}
}
// Mirror the denied/<id> tag to the forge.
if let Err(e) = crate::forge::push_config(&a.agent).await {
tracing::warn!(%id, agent = %a.agent, error = ?e, "forge: push_config after deny failed");
}
}
let approval_kind = a.kind.as_str(); let approval_kind = a.kind.as_str();
let sha_short = sha.as_deref().map(|s| s[..s.len().min(12)].to_owned()); let sha_short = sha.as_deref().map(|s| s[..s.len().min(12)].to_owned());
let description = a.description.clone(); let description = a.description.clone();

View file

@ -423,7 +423,6 @@ pub struct ApprovalAdded<'a> {
pub agent: &'a str, pub agent: &'a str,
pub approval_kind: &'static str, pub approval_kind: &'static str,
pub sha_short: Option<String>, pub sha_short: Option<String>,
pub diff: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub pr_number: Option<u64>, pub pr_number: Option<u64>,
} }
@ -789,15 +788,13 @@ impl Coordinator {
} }
/// Emit `ApprovalAdded` immediately after the row is inserted in /// Emit `ApprovalAdded` immediately after the row is inserted in
/// sqlite. Caller passes the diff text it already computed (or /// sqlite.
/// `None` for spawn approvals which carry no diff).
pub fn emit_approval_added(&self, ev: ApprovalAdded<'_>) { pub fn emit_approval_added(&self, ev: ApprovalAdded<'_>) {
let ApprovalAdded { let ApprovalAdded {
id, id,
agent, agent,
approval_kind, approval_kind,
sha_short, sha_short,
diff,
description, description,
pr_number, pr_number,
} = ev; } = ev;
@ -807,7 +804,6 @@ impl Coordinator {
agent: agent.to_owned(), agent: agent.to_owned(),
approval_kind, approval_kind,
sha_short, sha_short,
diff,
description, description,
pr_number, pr_number,
}); });

View file

@ -1,13 +1,8 @@
//! Approval endpoints + diff machinery for the dashboard. //! Approval endpoints for the dashboard.
//! //!
//! Approve/deny actions, the orphan-approval GC sweep used by the //! Approve/deny actions plus the orphan-approval GC sweep used by the
//! `/api/state` builder, and the unified-diff endpoints (on-demand //! `/api/state` builder.
//! `/api/approval-diff/{id}` against a chosen base, plus the `pub(crate)`
//! `approval_diff` the manager-socket handler pre-computes at submit time).
use std::path::Path;
use anyhow::{Context, Result};
use axum::{ use axum::{
extract::{Form, Path as AxumPath, State}, extract::{Form, Path as AxumPath, State},
http::StatusCode, http::StatusCode,
@ -16,12 +11,9 @@ use axum::{
use hive_sh4re::Approval; use hive_sh4re::Approval;
use serde::Deserialize; use serde::Deserialize;
use problem_details::ProblemDetails; use super::{AppState, error_response};
use super::{AppState, error_problem, error_response};
use crate::actions; use crate::actions;
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
use crate::lifecycle;
pub(super) async fn post_approve( pub(super) async fn post_approve(
State(state): State<AppState>, State(state): State<AppState>,
@ -53,7 +45,7 @@ pub(super) async fn post_deny(
.as_deref() .as_deref()
.map(str::trim) .map(str::trim)
.filter(|s| !s.is_empty()); .filter(|s| !s.is_empty());
match actions::deny(&state.coord, id, note).await { match actions::deny(&state.coord, id, note) {
Ok(()) => (StatusCode::OK, "ok").into_response(), Ok(()) => (StatusCode::OK, "ok").into_response(),
Err(e) => error_response(&format!("deny {id} failed: {e:#}")), Err(e) => error_response(&format!("deny {id} failed: {e:#}")),
} }
@ -87,7 +79,7 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<A
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved { coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id: a.id, id: a.id,
agent: &a.agent, agent: &a.agent,
approval_kind: "apply_commit", approval_kind: a.kind.as_str(),
sha_short, sha_short,
status: "failed", status: "failed",
note: Some(note.to_owned()), note: Some(note.to_owned()),
@ -98,142 +90,3 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<A
}) })
.collect() .collect()
} }
/// Multi-file unified diff between the currently-deployed tree and
/// the proposal for this approval. Runs against the applied repo
/// since the canonical proposal commit lives there (manager-side
/// amendments don't move it). Empty output means proposal == main —
/// a no-op approval.
///
/// `pub(crate)` so the manager-socket handler can pre-compute the
/// diff once at submission time and embed it in the `ApprovalAdded`
/// dashboard event (instead of forcing the dashboard to wait a
/// `/api/state` cycle to see the diff for newly-queued approvals).
pub(crate) async fn approval_diff(agent: &str, approval_id: i64) -> String {
let applied = crate::paths::applied_dir(agent);
if !applied.join(".git").exists() {
return format!("(no applied git repo at {})", applied.display());
}
let proposal_ref = format!("refs/tags/proposal/{approval_id}");
match git_diff_refs(&applied, "refs/heads/main", &proposal_ref).await {
Ok(s) if s.is_empty() => "(proposal matches currently-deployed tree)".to_owned(),
Ok(s) => s,
Err(e) => format!("(error: {e:#})"),
}
}
async fn git_diff_refs(applied_dir: &Path, base_ref: &str, target_ref: &str) -> Result<String> {
let out = lifecycle::git_command()
.current_dir(applied_dir)
.args(["diff", &format!("{base_ref}..{target_ref}")])
.output()
.await
.with_context(|| format!("spawn `git diff` in {}", applied_dir.display()))?;
if !out.status.success() {
anyhow::bail!(
"git diff {base_ref}..{target_ref} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// Numeric ids of `<prefix>/<n>` tags in the applied repo (e.g.
/// `proposal/3` → `3`). Unparseable suffixes are skipped. Used to
/// resolve the `approved` / `previous` diff bases for an approval.
async fn tag_ids(applied_dir: &Path, prefix: &str) -> Vec<i64> {
let Ok(out) = lifecycle::git_command()
.current_dir(applied_dir)
.args(["tag", "-l", &format!("{prefix}/*")])
.output()
.await
else {
return Vec::new();
};
if !out.status.success() {
return Vec::new();
}
let strip = format!("{prefix}/");
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| l.trim().strip_prefix(&strip))
.filter_map(|s| s.parse::<i64>().ok())
.collect()
}
#[derive(Deserialize)]
pub(super) struct DiffBaseQuery {
/// `applied` (running tree — default), `approved` (most recent
/// earlier approved proposal), or `previous` (the prior queued
/// proposal for this agent).
base: Option<String>,
}
/// On-demand unified diff for one `ApplyCommit` approval against a
/// chosen base. `applied` = `applied/main` (what's running);
/// `approved` = the most recent earlier `approved/<n>` tag (the last
/// proposal the operator OK'd, even if its build then failed);
/// `previous` = the prior queued `proposal/<n>` (the incremental
/// delta when the manager chains proposals). Returns the raw diff
/// text — the dashboard classifies lines client-side.
pub(super) async fn get_approval_diff(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
axum::extract::Query(q): axum::extract::Query<DiffBaseQuery>,
) -> Result<Response, ProblemDetails> {
let base = q.base.as_deref().unwrap_or("applied");
let approval = match state.coord.approvals.get(id) {
Ok(Some(a)) => a,
Ok(None) => return Err(error_problem(&format!("approval {id} not found"))),
Err(e) => return Err(error_problem(&format!("approval {id}: {e:#}"))),
};
if !matches!(approval.kind, hive_sh4re::ApprovalKind::ApplyCommit) {
return Err(error_problem("spawn approvals carry no commit to diff"));
}
let applied = crate::paths::applied_dir(&approval.agent);
if !applied.join(".git").exists() {
return Ok(plain_text(format!(
"(no applied git repo at {})",
applied.display()
)));
}
let target = format!("refs/tags/proposal/{id}");
let base_ref = match base {
"applied" => Some("refs/heads/main".to_owned()),
"approved" => {
let ids = tag_ids(&applied, "approved").await;
ids.into_iter()
.filter(|&n| n != id)
.max()
.map(|n| format!("refs/tags/approved/{n}"))
}
"previous" => {
let ids = tag_ids(&applied, "proposal").await;
ids.into_iter()
.filter(|&n| n < id)
.max()
.map(|n| format!("refs/tags/proposal/{n}"))
}
other => {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail(format!("unknown diff base {other:?}")));
}
};
let Some(base_ref) = base_ref else {
return Ok(plain_text(match base {
"approved" => "(no earlier approved proposal to diff against)".to_owned(),
_ => "(no previous proposal to diff against)".to_owned(),
}));
};
match git_diff_refs(&applied, &base_ref, &target).await {
Ok(s) if s.is_empty() => Ok(plain_text(
"(identical — no changes vs this base)".to_owned(),
)),
Ok(s) => Ok(plain_text(s)),
Err(e) => Err(error_problem(&format!("git diff: {e:#}"))),
}
}
fn plain_text(body: String) -> Response {
(StatusCode::OK, body).into_response()
}

View file

@ -202,7 +202,7 @@ pub(super) async fn post_request_spawn(
tracing::info!(%id, %name, "operator: spawn approval queued via dashboard"); tracing::info!(%id, %name, "operator: spawn approval queued via dashboard");
// Phase 5b: notify the dashboard event channel so live // Phase 5b: notify the dashboard event channel so live
// subscribers can append the row without a snapshot // subscribers can append the row without a snapshot
// refetch. Spawn approvals carry no diff/sha. // refetch. Spawn approvals carry no sha.
state state
.coord .coord
.emit_approval_added(crate::coordinator::ApprovalAdded { .emit_approval_added(crate::coordinator::ApprovalAdded {
@ -210,7 +210,6 @@ pub(super) async fn post_request_spawn(
agent: &name, agent: &name,
approval_kind: "spawn", approval_kind: "spawn",
sha_short: None, sha_short: None,
diff: None,
description: None, description: None,
pr_number: None, pr_number: None,
}); });

View file

@ -35,11 +35,6 @@ mod tombstones;
mod topology; mod topology;
mod webhook; mod webhook;
// Pre-computed at approval-submit time by the manager-socket handler
// (`socket_server.rs`) and embedded in the `ApprovalAdded` event, so
// re-exported at the module root to preserve the `crate::dashboard::approval_diff`
// path across the submodule split.
pub(crate) use approvals::approval_diff;
// Run after lock bumps by the job queue (`job_queue/exec.rs`); the view // Run after lock bumps by the job queue (`job_queue/exec.rs`); the view
// type feeds `DashboardEvent::MetaInputsChanged` (`dashboard_events.rs`). // type feeds `DashboardEvent::MetaInputsChanged` (`dashboard_events.rs`).
// Re-exported to preserve the `crate::dashboard::*` paths across the split. // Re-exported to preserve the `crate::dashboard::*` paths across the split.
@ -83,7 +78,6 @@ pub async fn serve(
.route("/api/state", get(state_snapshot::api_state)) .route("/api/state", get(state_snapshot::api_state))
.route("/api/journal/{name}", get(journal::get_journal)) .route("/api/journal/{name}", get(journal::get_journal))
.route("/api/journal-host", get(journal::get_journal_host)) .route("/api/journal-host", get(journal::get_journal_host))
.route("/api/approval-diff/{id}", get(approvals::get_approval_diff))
.route("/api/state-file", get(state_files::get_state_file)) .route("/api/state-file", get(state_files::get_state_file))
.route( .route(
"/api/matrix-accounts", "/api/matrix-accounts",

View file

@ -23,7 +23,7 @@ use crate::container_view::ContainerView;
use super::meta_inputs::{MetaInputView, read_meta_inputs}; use super::meta_inputs::{MetaInputView, read_meta_inputs};
use super::tombstones::{TombstoneView, build_tombstone_views}; use super::tombstones::{TombstoneView, build_tombstone_views};
use super::{AppState, approval_diff, approvals, error_response, scan_validated_paths}; use super::{AppState, approvals, error_response, scan_validated_paths};
#[allow(clippy::struct_excessive_bools)] #[allow(clippy::struct_excessive_bools)]
#[derive(Serialize)] #[derive(Serialize)]
@ -233,31 +233,16 @@ struct ApprovalView {
id: i64, id: i64,
agent: String, agent: String,
kind: &'static str, kind: &'static str,
/// First 12 chars of the `commit_ref`, for `ApplyCommit` only. /// First 12 chars of the reviewed PR head sha, for `MergeConfigPr`
/// Display-only (the short chip on the card). /// only. Display-only (the short chip on the card).
sha_short: Option<String>, sha_short: Option<String>,
/// Full commit sha, for `ApplyCommit` only. The frontend builds the
/// "commit on forge" link from this rather than `sha_short`: forgejo
/// 404s an abbreviated sha for the proposal commit (it lives on a
/// `proposal/<id>` tag ref, which forgejo won't disambiguate a short
/// hash against), but resolves the full 40-char sha by direct lookup.
#[serde(skip_serializing_if = "Option::is_none")]
sha_full: Option<String>,
/// Raw unified diff text, for `ApplyCommit` only. The client splits
/// on `\n` and per-line classifies (`+` / `-` / `@@` / `--- ` / `+++ `
/// → diff-add / diff-del / diff-hunk / diff-file). Shipping raw
/// instead of pre-rendered HTML saves bytes on the wire (no
/// per-line `<span>` markup) and removes the only HTML-escape
/// surface from the snapshot.
diff: Option<String>,
/// Manager-supplied description shown on the approval card. /// Manager-supplied description shown on the approval card.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>, description: Option<String>,
/// Forge PR number, for `MergeConfigPr` only. Lets the frontend /// Forge PR number, for `MergeConfigPr` only. Lets the frontend
/// build a "review PR on forge" link /// build a "review PR on forge" link
/// (`{forgeBase}/agent-configs/{agent}/pulls/{pr_number}`) the same /// (`{forgeBase}/agent-configs/{agent}/pulls/{pr_number}`). `None`
/// way it builds the `apply_commit` "commit on forge" link from the /// for every other kind.
/// sha. `None` for every other kind.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pr_number: Option<u64>, pr_number: Option<u64>,
/// Raw `commit_ref` payload for `UpdateMetaInputs` (JSON-encoded /// Raw `commit_ref` payload for `UpdateMetaInputs` (JSON-encoded
@ -352,7 +337,7 @@ pub(super) async fn api_state(
log_default("approvals.pending", state.coord.approvals.pending()), log_default("approvals.pending", state.coord.approvals.pending()),
); );
let transients = build_transient_views(&containers, &transient_snapshot); let transients = build_transient_views(&containers, &transient_snapshot);
let approvals = build_approval_views(pending_approvals).await; let approvals = build_approval_views(pending_approvals);
let approval_history = log_default( let approval_history = log_default(
"approvals.recent_resolved", "approvals.recent_resolved",
state.coord.approvals.recent_resolved(30), state.coord.approvals.recent_resolved(30),
@ -544,8 +529,8 @@ fn transient_label(k: crate::coordinator::TransientKind) -> &'static str {
} }
} }
/// Render each pending approval into its dashboard view (short sha + /// Render each pending approval into its dashboard view (short sha for
/// unified diff for `ApplyCommit`, just the name for `Spawn`). /// `MergeConfigPr`, just the name for `Spawn`).
/// Project a resolved sqlite row into the lean shape the dashboard /// Project a resolved sqlite row into the lean shape the dashboard
/// history tab consumes — no `diff_html` (rendering 30 of them /// history tab consumes — no `diff_html` (rendering 30 of them
/// per /api/state poll would mean 30 git diffs per refresh). /// per /api/state poll would mean 30 git diffs per refresh).
@ -576,37 +561,15 @@ fn history_view(a: Approval) -> ApprovalHistoryView {
} }
} }
async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> { fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
let mut out = Vec::with_capacity(approvals.len()); let mut out = Vec::with_capacity(approvals.len());
for a in approvals { for a in approvals {
out.push(match a.kind { out.push(match a.kind {
hive_sh4re::ApprovalKind::ApplyCommit => {
// Prefer the canonical fetched sha from applied;
// commit_ref is only the manager's claim and may be
// amended out from under us.
let displayed = a.fetched_sha.as_deref().unwrap_or(&a.commit_ref);
let sha = displayed[..displayed.len().min(12)].to_owned();
let diff = approval_diff(&a.agent, a.id).await;
ApprovalView {
id: a.id,
agent: a.agent.clone(),
kind: "apply_commit",
sha_short: Some(sha),
sha_full: Some(displayed.to_owned()),
diff: Some(diff),
description: a.description,
pr_number: None,
commit_ref: None,
requested_at: a.requested_at,
}
}
hive_sh4re::ApprovalKind::Spawn => ApprovalView { hive_sh4re::ApprovalKind::Spawn => ApprovalView {
id: a.id, id: a.id,
agent: a.agent, agent: a.agent,
kind: "spawn", kind: "spawn",
sha_short: None, sha_short: None,
sha_full: None,
diff: None,
description: a.description, description: a.description,
pr_number: None, pr_number: None,
commit_ref: None, commit_ref: None,
@ -617,8 +580,6 @@ async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
agent: a.agent, agent: a.agent,
kind: "init_config", kind: "init_config",
sha_short: None, sha_short: None,
sha_full: None,
diff: None,
description: a.description, description: a.description,
pr_number: None, pr_number: None,
commit_ref: None, commit_ref: None,
@ -629,8 +590,6 @@ async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
agent: a.agent, agent: a.agent,
kind: "update_meta_inputs", kind: "update_meta_inputs",
sha_short: None, sha_short: None,
sha_full: None,
diff: None,
description: a.description, description: a.description,
pr_number: None, pr_number: None,
commit_ref: Some(a.commit_ref), commit_ref: Some(a.commit_ref),
@ -641,8 +600,6 @@ async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
agent: a.agent, agent: a.agent,
kind: "schedule_prompt", kind: "schedule_prompt",
sha_short: None, sha_short: None,
sha_full: None,
diff: None,
description: a.description, description: a.description,
pr_number: None, pr_number: None,
commit_ref: Some(a.commit_ref), commit_ref: Some(a.commit_ref),
@ -650,8 +607,8 @@ async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
}, },
hive_sh4re::ApprovalKind::MergeConfigPr => { hive_sh4re::ApprovalKind::MergeConfigPr => {
// commit_ref = PR number; fetched_sha = the reviewed PR // commit_ref = PR number; fetched_sha = the reviewed PR
// head. Show the head sha; the forge PR diff surface is // head. Show the head sha; the config diff surface lives
// a later phase of the PR-based config flow — None for now. // on the forge PR itself.
let sha = a let sha = a
.fetched_sha .fetched_sha
.as_deref() .as_deref()
@ -664,8 +621,6 @@ async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
agent: a.agent, agent: a.agent,
kind: "merge_config_pr", kind: "merge_config_pr",
sha_short: sha, sha_short: sha,
sha_full: None,
diff: None,
description: a.description, description: a.description,
pr_number, pr_number,
commit_ref: None, commit_ref: None,

View file

@ -62,11 +62,10 @@ pub enum DashboardEvent {
}, },
/// A new approval landed in the pending queue. Payload carries /// A new approval landed in the pending queue. Payload carries
/// enough to render the dashboard row without a `/api/state` /// enough to render the dashboard row without a `/api/state`
/// refetch (`diff` is the raw unified diff text, same shape the /// refetch.
/// snapshot ships).
/// ///
/// The approval's own kind (`"apply_commit"` / `"spawn"`) lives on /// The approval's own kind (`"merge_config_pr"` / `"spawn"`) lives
/// `approval_kind` rather than `kind` because the latter is taken /// on `approval_kind` rather than `kind` because the latter is taken
/// by the serde tag identifying which `DashboardEvent` variant /// by the serde tag identifying which `DashboardEvent` variant
/// this is. /// this is.
ApprovalAdded { ApprovalAdded {
@ -75,7 +74,6 @@ pub enum DashboardEvent {
agent: String, agent: String,
approval_kind: &'static str, approval_kind: &'static str,
sha_short: Option<String>, sha_short: Option<String>,
diff: Option<String>,
description: Option<String>, description: Option<String>,
/// Forge PR number, for `merge_config_pr` approvals only — lets /// Forge PR number, for `merge_config_pr` approvals only — lets
/// the live `applyApprovalAdded` path build the "review PR on /// the live `applyApprovalAdded` path build the "review PR on
@ -350,9 +348,8 @@ mod tests {
seq: 1, seq: 1,
id: 1, id: 1,
agent: "x".into(), agent: "x".into(),
approval_kind: "apply_commit", approval_kind: "merge_config_pr",
sha_short: None, sha_short: None,
diff: None,
description: None, description: None,
pr_number: None, pr_number: None,
}, },
@ -360,7 +357,7 @@ mod tests {
seq: 1, seq: 1,
id: 1, id: 1,
agent: "x".into(), agent: "x".into(),
approval_kind: "apply_commit", approval_kind: "merge_config_pr",
sha_short: None, sha_short: None,
status: "approved", status: "approved",
resolved_at: hive_sh4re::wire_time::from_secs(0), resolved_at: hive_sh4re::wire_time::from_secs(0),

Some files were not shown because too many files have changed in this diff Show more