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),

View file

@ -1,415 +0,0 @@
//! Pre-apply validation for agent `flake.lock` files.
//!
//! Every `request_apply_commit` lands a `proposal/<id>` tag in the
//! agent's applied repo before the operator sees the approval. We
//! parse `flake.lock` from that tag's tree and reject the request if
//! two or more nodes share an identical `original` field — that
//! signals a missing `inputs.<X>.inputs.nixpkgs.follows = "nixpkgs"`
//! directive in `flake.nix` and would inflate meta's lock with
//! duplicates after deploy.
//!
//! The check runs on the agent repo, not meta, and catches *new*
//! violations only. Existing agents whose lock already has duplicates
//! are out of scope here and get a coordinated config-change pass via
//! the manager instead.
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::Path;
use anyhow::{Context, Result};
use serde_json::Value;
use tokio::process::Command;
use crate::lifecycle::git_command;
/// One group of `flake.lock` nodes that all share the same canonical
/// `original` reference. Surfaced in the rejection message so the
/// operator (and the manager that submitted the apply) can see
/// exactly which input pair needs a `follows` directive.
#[derive(Debug, Clone)]
pub struct DuplicateGroup {
/// One of the original `Value`s from the lock — used for
/// pretty-printing in the error message. Canonical equivalence
/// is enforced by the `BTreeMap` key in `duplicate_groups`, so
/// we don't need to keep the canonicalised form on the struct.
pub original: Value,
/// Names of the flake.lock nodes that share this `original`,
/// sorted for stable error output.
pub keys: Vec<String>,
}
/// Read `flake.lock` from `<tag>:flake.lock` in `repo`. Returns
/// `Ok(None)` when the file isn't tracked in that tag (no inputs ⇒
/// nothing to dedup); `Err` only on real git plumbing failures.
async fn read_lock_at_tag(repo: &Path, tag: &str) -> Result<Option<String>> {
let spec = format!("{tag}:flake.lock");
let out = git_command()
.current_dir(repo)
.args(["show", &spec])
.output()
.await
.with_context(|| format!("git show {spec} in {}", repo.display()))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
// git uses two different messages for "path not in tree"
// depending on whether the path also collides with an on-disk
// file. Both translate to "no flake.lock in this commit" —
// a legitimate, dedup-clean state for an agent with empty
// `inputs = { }`. Any other git failure (permission denied,
// ref-not-found, etc.) propagates as a hard error rather than
// being silently swallowed.
if stderr.contains("does not exist") || stderr.contains("exists on disk, but not in") {
return Ok(None);
}
anyhow::bail!("git show {spec} failed: {}", stderr.trim());
}
Ok(Some(String::from_utf8_lossy(&out.stdout).into_owned()))
}
/// Recursively serialise `v` with object keys sorted, so two
/// JSON values that differ only in key insertion order produce the
/// same string. `serde_json::Value` preserves `IndexMap` order by
/// default, which is fine for parsing but breaks our group-by-key
/// idea — hence this hand-rolled canonicaliser.
fn canonical_json(v: &Value) -> String {
match v {
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
let mut s = String::from("{");
for (i, k) in keys.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str(&serde_json::to_string(k).unwrap_or_default());
s.push(':');
s.push_str(&canonical_json(&map[*k]));
}
s.push('}');
s
}
Value::Array(arr) => {
let mut s = String::from("[");
for (i, x) in arr.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str(&canonical_json(x));
}
s.push(']');
s
}
_ => serde_json::to_string(v).unwrap_or_default(),
}
}
/// Parse `raw` (a `flake.lock` JSON document) and return every group
/// of nodes whose `original` field is identical. Nodes without an
/// `original` (the synthetic `root`, or anomalous entries) are
/// skipped. Groups with only one member are filtered out — only true
/// duplicates surface.
///
/// Pure function, no I/O — covered by the unit tests below.
pub fn duplicate_groups(raw: &str) -> Result<Vec<DuplicateGroup>> {
let json: Value = serde_json::from_str(raw).context("parse flake.lock")?;
let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else {
return Ok(Vec::new());
};
let mut groups: BTreeMap<String, DuplicateGroup> = BTreeMap::new();
for (name, node) in nodes {
let Some(original) = node.get("original") else {
continue;
};
let key = canonical_json(original);
let entry = groups.entry(key).or_insert_with(|| DuplicateGroup {
original: original.clone(),
keys: Vec::new(),
});
entry.keys.push(name.clone());
}
let mut dups: Vec<DuplicateGroup> = groups.into_values().filter(|g| g.keys.len() > 1).collect();
for g in &mut dups {
g.keys.sort();
}
Ok(dups)
}
/// Re-derive the agent's `flake.lock` from its `flake.nix` (in a
/// throw-away worktree at the proposal tag) and reject the apply when
/// the result differs from what's committed — that means the manager
/// edited `flake.nix` but didn't commit the regenerated lock, so the
/// shipped state lies about what nix will actually fetch.
///
/// Plain `nix flake lock` (no `--update-input` flags) only fills in
/// MISSING entries; it never refreshes existing ones. So a lock that
/// matches its `flake.nix` round-trips to a no-op, and any diff is a
/// real "stale lock" signal.
///
/// Materialises the proposal tag into a temp worktree under
/// `std::env::temp_dir()` to avoid touching `applied/<n>/main` while
/// the check runs. Cleanup is unconditional via the inner-fn pattern.
///
/// Returns `Ok(())` when in sync (or there's no `flake.nix` at all);
/// `Err` with a human-readable message on stale lock or nix tooling
/// failure.
pub async fn check_lock_in_sync(repo: &Path, tag: &str, approval_id: i64) -> Result<()> {
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let tmp_dir = std::env::temp_dir().join(format!("hive-flake-check-{approval_id}-{suffix}"));
// Detached worktree at the proposal tag — gives us a clean, mutable
// copy of the agent's tree without disturbing whatever's currently
// checked out on `applied/<n>/main`.
let out = git_command()
.current_dir(repo)
.args([
"worktree",
"add",
"--detach",
&tmp_dir.to_string_lossy(),
tag,
])
.output()
.await
.with_context(|| format!("git worktree add {} {tag}", tmp_dir.display()))?;
if !out.status.success() {
anyhow::bail!(
"git worktree add failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let result = lock_in_sync_inner(&tmp_dir).await;
// Best-effort cleanup. `git worktree remove --force` handles the
// common case; `remove_dir_all` mops up if git decided the worktree
// is half-gone (or if the inner work bailed before nix touched the
// tree). Failures here are logged, not propagated — the check's
// result is what matters.
if let Err(e) = remove_worktree(repo, &tmp_dir).await {
tracing::warn!(
worktree = %tmp_dir.display(),
error = %format!("{e:#}"),
"flake_check: temp worktree cleanup failed"
);
}
result
}
async fn lock_in_sync_inner(worktree: &Path) -> Result<()> {
// No `flake.nix` means there's nothing for nix to lock — skip the
// check cleanly (the dedup check will likewise no-op).
if !worktree.join("flake.nix").exists() {
return Ok(());
}
let committed = tokio::fs::read_to_string(worktree.join("flake.lock"))
.await
.ok();
// `--extra-experimental-features` mirrors `meta::nix` for hosts
// that haven't already enabled flakes in `nix.conf`. Plain
// `nix flake lock` (no `--update-input`) fills missing entries but
// never refreshes existing ones — exactly the semantics we want.
let out = Command::new("nix")
.current_dir(worktree)
.args([
"--extra-experimental-features",
"nix-command flakes",
"flake",
"lock",
])
.output()
.await
.with_context(|| format!("nix flake lock in {}", worktree.display()))?;
if !out.status.success() {
anyhow::bail!(
"nix flake lock failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let regenerated = tokio::fs::read_to_string(worktree.join("flake.lock"))
.await
.ok();
// An agent that declares inputs in flake.nix but ships no
// flake.lock at all hits this branch (committed = None,
// regenerated = Some(...)). That's a deliberate reject: every
// agent with inputs MUST commit its lock, otherwise meta's
// dedup pass has nothing to introspect and the broken state
// leaks downstream. Treated identically to a stale lock.
if committed.as_deref() != regenerated.as_deref() {
anyhow::bail!(
"flake.lock is out of sync with flake.nix — `nix flake lock` produces a different lock. \
Run `nix flake lock` in your agent config, commit the result, and re-submit request_apply_commit."
);
}
Ok(())
}
async fn remove_worktree(repo: &Path, worktree: &Path) -> Result<()> {
let out = git_command()
.current_dir(repo)
.args(["worktree", "remove", "--force", &worktree.to_string_lossy()])
.output()
.await
.with_context(|| format!("git worktree remove {}", worktree.display()))?;
if !out.status.success() {
// `git worktree remove` already errored — still try the raw
// rmdir so we don't leak the dir on disk. Surface the original
// git stderr for context.
let _ = tokio::fs::remove_dir_all(worktree).await;
anyhow::bail!(
"git worktree remove failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
// git removed the worktree's metadata but the dir itself may
// linger on stripped-down git versions — best-effort clean.
let _ = tokio::fs::remove_dir_all(worktree).await;
Ok(())
}
/// Run the dedup check against the agent's freshly-applied tree.
///
/// `Ok(())` means either the commit doesn't carry a `flake.lock` (no
/// inputs declared) or every node has a unique `original`. `Err`
/// carries a multi-line message listing every duplicate group with
/// the offending node names, suitable for surfacing on the failed
/// approval row.
pub async fn check_no_duplicate_inputs(repo: &Path, tag: &str) -> Result<()> {
let Some(raw) = read_lock_at_tag(repo, tag).await? else {
return Ok(());
};
let dups = duplicate_groups(&raw)?;
if dups.is_empty() {
return Ok(());
}
let mut msg = String::from(
"flake.lock has duplicate flake inputs — add a `follows` directive in flake.nix to collapse them:\n",
);
for g in &dups {
let original = serde_json::to_string(&g.original).unwrap_or_else(|_| "?".into());
let _ = writeln!(msg, " - {original} → nodes [{}]", g.keys.join(", "));
}
anyhow::bail!("{}", msg.trim_end());
}
#[cfg(test)]
mod tests {
use super::*;
const CLEAN_LOCK: &str = r#"{
"nodes": {
"nixpkgs": {
"locked": {"rev": "aaa"},
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-26.05", "type": "github"}
},
"root": {"inputs": {"nixpkgs": "nixpkgs"}}
},
"root": "root",
"version": 7
}"#;
const DUPLICATE_LOCK: &str = r#"{
"nodes": {
"nixpkgs": {
"locked": {"rev": "aaa"},
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-26.05", "type": "github"}
},
"nixpkgs_2": {
"locked": {"rev": "ccc"},
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-26.05", "type": "github"}
},
"nixpkgs_3": {
"locked": {"rev": "ddd"},
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-26.05", "type": "github"}
},
"treefmt-nix": {
"locked": {"rev": "eee"},
"original": {"owner": "numtide", "repo": "treefmt-nix", "type": "github"}
},
"treefmt-nix_2": {
"locked": {"rev": "fff"},
"original": {"type": "github", "owner": "numtide", "repo": "treefmt-nix"}
},
"root": {"inputs": {"nixpkgs": "nixpkgs"}}
},
"root": "root",
"version": 7
}"#;
#[test]
fn clean_lock_has_no_duplicates() {
let dups = duplicate_groups(CLEAN_LOCK).expect("parse");
assert!(dups.is_empty(), "expected no dups, got {dups:#?}");
}
#[test]
fn duplicate_lock_reports_groups() {
let dups = duplicate_groups(DUPLICATE_LOCK).expect("parse");
assert_eq!(dups.len(), 2, "expected nixpkgs + treefmt-nix groups");
// Order is BTreeMap-stable: sorted by canonical_json key. The
// numtide treefmt-nix key sorts before the NixOS nixpkgs one
// because `numtide` < `NixOS` lexicographically (case-sensitive,
// capitals come first... wait — capital N is 0x4e, lowercase n
// is 0x6e, so capitals come first). So nixpkgs group sorts
// first. Verify by content instead of position to avoid coupling
// to that subtlety.
let nixpkgs_group = dups
.iter()
.find(|g| g.original.get("ref").is_some())
.expect("nixpkgs group present");
assert_eq!(
nixpkgs_group.keys,
vec!["nixpkgs", "nixpkgs_2", "nixpkgs_3"]
);
let treefmt_group = dups
.iter()
.find(|g| g.original.get("ref").is_none())
.expect("treefmt-nix group present");
assert_eq!(treefmt_group.keys, vec!["treefmt-nix", "treefmt-nix_2"]);
}
#[test]
fn key_order_in_original_does_not_matter() {
// The treefmt-nix and treefmt-nix_2 entries above use different
// key orderings for `original` ({owner,repo,type} vs
// {type,owner,repo}); duplicate_groups should still merge them.
let dups = duplicate_groups(DUPLICATE_LOCK).expect("parse");
let treefmt = dups
.iter()
.find(|g| g.keys.iter().any(|k| k == "treefmt-nix"))
.expect("treefmt-nix group");
assert!(treefmt.keys.contains(&"treefmt-nix_2".to_owned()));
}
#[test]
fn nodes_without_original_are_ignored() {
// The synthetic `root` node has no `original` and must not be
// grouped against anything.
let dups = duplicate_groups(CLEAN_LOCK).expect("parse");
assert!(dups.iter().all(|g| !g.keys.iter().any(|k| k == "root")));
}
#[test]
fn missing_nodes_object_is_not_an_error() {
// A lock file that's syntactically JSON but lacks `nodes` (e.g.
// a partial test fixture) should fail open — no dups reported.
let raw = r#"{"root": "root", "version": 7}"#;
let dups = duplicate_groups(raw).expect("parse");
assert!(dups.is_empty());
}
#[test]
fn invalid_json_is_a_hard_error() {
let err = duplicate_groups("not-json-at-all").unwrap_err();
assert!(format!("{err:#}").contains("parse flake.lock"));
}
}

View file

@ -500,11 +500,10 @@ async fn run_write_perm_file(
Ok(NodeOutput::default()) Ok(NodeOutput::default())
} }
/// Opaque approval deploy pipeline: `ApplyCommit` and `MergeConfigPr` /// Opaque approval deploy pipeline for `MergeConfigPr`: verify + ff-merge the
/// both end in a container rebuild; branch on the approval row's kind /// reviewed PR head, then the container rebuild. The two-phase
/// (the authoritative source). The two-phase prepare/finalize/abort /// prepare/finalize/abort meta deploy — and the approval resolution — stay
/// meta deploy — and the approval resolution — stay inside /// inside `actions.rs` in v1 (design doc §9).
/// `actions.rs` in v1 (design doc §9).
async fn run_approval_deploy(coord: &Arc<Coordinator>, claim: &Claim) -> Result<NodeOutput> { async fn run_approval_deploy(coord: &Arc<Coordinator>, claim: &Claim) -> Result<NodeOutput> {
let approval_id = claim let approval_id = claim
.approval_id .approval_id
@ -514,18 +513,9 @@ async fn run_approval_deploy(coord: &Arc<Coordinator>, claim: &Claim) -> Result<
// container build, and no other meta mutation may land inside that // container build, and no other meta mutation may land inside that
// window (it would sweep the staged lock and neuter `abort_deploy`). // window (it would sweep the staged lock and neuter `abort_deploy`).
let _window = crate::meta::exclusive().await; let _window = crate::meta::exclusive().await;
let kind = coord crate::actions::run_approval_merge_config_pr(coord, Some(claim.dag_id), approval_id)
.approvals .await
.get(approval_id) .map(|()| NodeOutput::default())
.ok()
.flatten()
.map(|a| a.kind);
let result = if kind == Some(hive_sh4re::ApprovalKind::MergeConfigPr) {
crate::actions::run_approval_merge_config_pr(coord, Some(claim.dag_id), approval_id).await
} else {
crate::actions::run_approval_apply_commit(coord, Some(claim.dag_id), approval_id).await
};
result.map(|()| NodeOutput::default())
} }
/// Terminal-roll-up hook, fired exactly once per DAG (node completion /// Terminal-roll-up hook, fired exactly once per DAG (node completion

View file

@ -110,9 +110,9 @@ pub enum NodeKind {
/// Commit `tool-groups.json` / `capabilities.json` per the DAG's /// Commit `tool-groups.json` / `capabilities.json` per the DAG's
/// `perm_payload` (commit fused under `META_LOCK`). /// `perm_payload` (commit fused under `META_LOCK`).
WritePermFile, WritePermFile,
/// Opaque approval deploy pipeline (`ApplyCommit` / /// Opaque approval deploy pipeline (`MergeConfigPr`): the two-phase
/// `MergeConfigPr`): the two-phase prepare/finalize/abort meta /// prepare/finalize/abort meta deploy stays inside `actions.rs` in v1 —
/// deploy stays inside `actions.rs` in v1 — deliberately not /// deliberately not
/// modeled as scheduler nodes (see the design doc §9). /// modeled as scheduler nodes (see the design doc §9).
ApprovalDeploy, ApprovalDeploy,
/// Write the agent's durable power intent (`wanted = Up` when `up`, else /// Write the agent's durable power intent (`wanted = Up` when `up`, else

View file

@ -96,9 +96,9 @@ pub fn rebuild(agent: &str, source: Source, reason: String, relock: bool) -> Dag
} }
} }
/// Approval-driven deploy (`ApplyCommit` / `MergeConfigPr`): the whole /// Approval-driven deploy (`MergeConfigPr`): the whole two-phase pipeline
/// two-phase pipeline stays one opaque node in v1 (design doc §9) — /// stays one opaque node in v1 (design doc §9) — wire-visible as a `rebuild`
/// wire-visible as a `rebuild` card like today. /// card like today.
pub fn approval_deploy(agent: &str, approval_id: i64, reason: String) -> DagSpec { pub fn approval_deploy(agent: &str, approval_id: i64, reason: String) -> DagSpec {
DagSpec { DagSpec {
template: Template::Rebuild, template: Template::Rebuild,

View file

@ -24,7 +24,6 @@ pub mod container_view;
pub mod coordinator; pub mod coordinator;
pub mod dashboard; pub mod dashboard;
pub mod dashboard_events; pub mod dashboard_events;
pub mod flake_check;
pub mod forge; pub mod forge;
pub mod gateway_nginx; pub mod gateway_nginx;
pub mod job_queue; pub mod job_queue;

View file

@ -12,7 +12,7 @@ const GIT_EMAIL: &str = "c0re@hyperhive.local";
/// Return the SHA of the root (oldest, no-parent) commit in a repo. /// Return the SHA of the root (oldest, no-parent) commit in a repo.
/// Used to seed the applied repo at the template baseline rather than at /// Used to seed the applied repo at the template baseline rather than at
/// `main`, so the first `ApplyCommit` diff shows the manager's real changes. /// `main`, so `deployed/0` records the template, not the manager's first commit.
pub(super) async fn git_root_commit(dir: &Path) -> Result<String> { pub(super) async fn git_root_commit(dir: &Path) -> Result<String> {
let out = git_command() let out = git_command()
.current_dir(dir) .current_dir(dir)
@ -71,51 +71,6 @@ pub async fn git(dir: &Path, args: &[&str]) -> Result<()> {
Ok(()) Ok(())
} }
/// Fetch the commit `sha` from the `src` git repo into `dst` and pin
/// it as `refs/tags/<tag>`. Used at `request_apply_commit` time so
/// hive-c0re captures an immutable handle on the manager's commit;
/// subsequent amendments / force-pushes in `src` no longer affect
/// what gets built. Returns the resolved full sha.
///
/// `sha` must be a commit sha (short or full) — the caller
/// (`submit_apply_commit`) shape-checks it first. We resolve it
/// LOCALLY against `src` rather than asking the remote to resolve
/// it: `git fetch <remote> <sha>:<dst>` treats the left side as a
/// remote *ref name*, and a bare sha is not one ("couldn't find
/// remote ref ..."). Fetching by sha would need a full 40-hex sha
/// plus `uploadpack.allow*SHA1InWant` on the remote, which the
/// proposed repos don't set. hive-c0re has direct read access to
/// `src`, so a local `rev-parse` + a branch-glob fetch sidesteps
/// the whole sha-want negotiation.
pub async fn git_fetch_to_tag(dst: &Path, src: &Path, sha: &str, tag: &str) -> Result<String> {
let src_str = src.display().to_string();
// Resolve the (short-or-full) sha to a full sha against the
// source repo. The `^{commit}` peel + non-zero exit on a missing
// object means a typo'd / stale sha fails loudly right here.
let full = git_rev_parse(src, &format!("{sha}^{{commit}}"))
.await
.with_context(|| format!("commit '{sha}' not found in proposed repo {src_str}"))?;
// Bring src's objects into dst. Fetching every head pulls the
// wanted commit's history (always reachable from a branch in the
// manager's flow) into dst's object db without sha-want.
git(
dst,
&[
"fetch",
"--no-tags",
&src_str,
"+refs/heads/*:refs/remotes/proposal-src/*",
],
)
.await?;
// Pin the exact commit as the proposal tag. The objects are now
// local so this resolves without touching the remote.
git(dst, &["tag", tag, &full]).await.with_context(|| {
format!("tag {tag} at {full}: commit not reachable from any branch in proposed repo")
})?;
Ok(full)
}
/// Resolve `refname` (a tag, branch, or sha) in `dir` to its full sha. /// Resolve `refname` (a tag, branch, or sha) in `dir` to its full sha.
pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result<String> { pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result<String> {
let out = git_command() let out = git_command()

View file

@ -7,8 +7,8 @@ mod setup;
mod tests; mod tests;
pub use git::{ pub use git::{
git, git_command, git_fetch_to_tag, git_read_tree_reset, git_rev_parse, git_tag, git, git_command, git_read_tree_reset, git_rev_parse, git_tag, git_tag_annotated,
git_tag_annotated, git_update_ref, git_update_ref,
}; };
pub use host_config::{ pub use host_config::{
CONTAINER_MANAGER_AGENTS_MOUNT, CONTAINER_MANAGER_APPLIED_MOUNT, write_dropins, CONTAINER_MANAGER_AGENTS_MOUNT, CONTAINER_MANAGER_APPLIED_MOUNT, write_dropins,
@ -345,14 +345,6 @@ async fn agents_after_spawn(name: &str) -> Result<Vec<crate::meta::AgentSpec>> {
agents_for_meta(Some(name)).await agents_for_meta(Some(name)).await
} }
/// Like `agents_for_meta_listing` but with an extra agent added (for a
/// container that doesn't exist yet). Used by the first-spawn path in
/// `actions::run_apply_commit` to register the new agent in meta before
/// `prepare_deploy` tries to update its input lock.
pub async fn agents_for_meta_listing_with(extra: &str) -> Result<Vec<crate::meta::AgentSpec>> {
agents_for_meta(Some(extra)).await
}
/// Public enumeration of currently-existing agents (whatever /// Public enumeration of currently-existing agents (whatever
/// `nixos-container list` says), sorted, no extras. For callers /// `nixos-container list` says), sorted, no extras. For callers
/// outside this module that need to reseed meta after lifecycle /// outside this module that need to reseed meta after lifecycle

View file

@ -44,8 +44,7 @@ pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> {
// manager's ergonomics. The URL is the path inside the manager // manager's ergonomics. The URL is the path inside the manager
// container (`/applied/<n>/.git`), where the RO bind in // container (`/applied/<n>/.git`), where the RO bind in
// `set_nspawn_flags` makes it real. hive-c0re itself never // `set_nspawn_flags` makes it real. hive-c0re itself never
// dereferences this remote; the host-side fetch in // dereferences this remote.
// `request_apply_commit` uses absolute host paths.
ensure_applied_remote(proposed_dir, name).await ensure_applied_remote(proposed_dir, name).await
} }
@ -71,9 +70,8 @@ async fn ensure_applied_remote(proposed_dir: &Path, name: &str) -> Result<()> {
/// Set up the applied repo. First-spawn only: init the repo, pull /// Set up the applied repo. First-spawn only: init the repo, pull
/// proposed's initial commit in via `git fetch`, tag it `deployed/0`. /// proposed's initial commit in via `git fetch`, tag it `deployed/0`.
/// This is the *only* time hive-c0re reads from `proposed` for an /// This is the *only* time hive-c0re reads from `proposed` for an
/// agent — subsequent proposals are fetched at `request_apply_commit` /// agent — subsequent config changes are fetched from the reviewed
/// time and tagged `proposal/<id>` (see `actions::approve` for the /// forge PR head at merge time (see `actions::run_merge_config_pr`).
/// tag state machine).
/// ///
/// `proposed_dir` is `None` on rebuild paths where the repo already /// `proposed_dir` is `None` on rebuild paths where the repo already
/// exists — we just verify it's the right shape and bail otherwise. /// exists — we just verify it's the right shape and bail otherwise.
@ -100,10 +98,8 @@ pub async fn setup_applied(
git(applied_dir, &["init", "--initial-branch=main"]).await?; git(applied_dir, &["init", "--initial-branch=main"]).await?;
let proposed_str = proposed.display().to_string(); let proposed_str = proposed.display().to_string();
// Seed the applied repo at the root (template) commit of proposed, // Seed the applied repo at the root (template) commit of proposed,
// not at `main`. This ensures `deployed/0` is the template baseline // not at `main`, so `deployed/0` is the template baseline rather
// so the first ApplyCommit diff shows the manager's real changes // than whatever the manager may have already committed on top.
// rather than an empty diff (which happens when the manager has
// already committed their config and proposed/main == proposal/<id>).
let root_sha = git_root_commit(proposed).await?; let root_sha = git_root_commit(proposed).await?;
git( git(
applied_dir, applied_dir,

View file

@ -168,7 +168,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
HostResponse::success() HostResponse::success()
} }
HostRequest::Deny { id } => { HostRequest::Deny { id } => {
actions::deny(&coord, *id, None).await?; actions::deny(&coord, *id, None)?;
HostResponse::success() HostResponse::success()
} }
HostRequest::SetParent { child, new_parent } => { HostRequest::SetParent { child, new_parent } => {

View file

@ -1,17 +1,14 @@
//! Config-approval request handlers: `RequestInitConfig` / //! Config-approval request handlers: `RequestInitConfig` /
//! `RequestApplyCommit` / `RequestUpdateMetaInputs`, //! `RequestUpdateMetaInputs`, plus the shared submit helpers
//! plus the shared submit helpers (`submit_init_config` / `submit_apply_commit` //! (`submit_init_config` / `submit_merge_config_pr`).
//! / `submit_merge_config_pr`) and the commit-sha shape check
//! (`validate_commit_ref`).
//! //!
//! `submit_merge_config_pr` is called from the dashboard webhook handler //! `submit_merge_config_pr` is called from the dashboard webhook handler
//! (`dashboard::webhook`) — agents no longer need an MCP tool for this; //! (`dashboard::webhook`) — agents no longer need an MCP tool for config
//! opening a config PR on `agent-configs/<agent>` is enough to trigger //! changes; opening a config PR on `agent-configs/<agent>` is enough to
//! hive-c0re's webhook-driven queue path. //! trigger hive-c0re's webhook-driven queue path.
use std::sync::Arc; use std::sync::Arc;
use anyhow::{Context, Result};
use hive_sh4re::AgentResponse; use hive_sh4re::AgentResponse;
use super::require_new_child; use super::require_new_child;
@ -40,30 +37,6 @@ pub(super) fn handle_request_init_config(
} }
} }
/// `RequestApplyCommit` — queue an apply-commit approval for an agent. The
/// target must be in the caller's subtree (the root covers every agent).
pub(super) async fn handle_request_apply_commit(
coord: &Arc<Coordinator>,
agent: &str,
target_agent: &str,
commit_ref: &str,
description: Option<&str>,
) -> AgentResponse {
if let Some(err) = require_new_child(agent, target_agent, "request_apply_commit for") {
return err;
}
tracing::info!(%agent, %target_agent, %commit_ref, "request_apply_commit");
match submit_apply_commit(coord, target_agent, commit_ref, description, agent).await {
Ok((id, sha)) => {
tracing::info!(%id, %target_agent, %sha, "apply_commit approval queued");
AgentResponse::Ok
}
Err(e) => AgentResponse::Err {
message: format!("{e:#}"),
},
}
}
/// `RequestUpdateMetaInputs` — queue an `UpdateMetaInputs` approval /// `RequestUpdateMetaInputs` — queue an `UpdateMetaInputs` approval
/// carrying the JSON-encoded input list in `commit_ref` (no git commit /// carrying the JSON-encoded input list in `commit_ref` (no git commit
/// is involved; the field is the payload the approval handler decodes). /// is involved; the field is the payload the approval handler decodes).
@ -105,7 +78,6 @@ pub(super) fn handle_request_update_meta_inputs(
agent: requester, agent: requester,
approval_kind: "update_meta_inputs", approval_kind: "update_meta_inputs",
sha_short: None, sha_short: None,
diff: None,
description: description.map(str::to_owned), description: description.map(str::to_owned),
pr_number: None, pr_number: None,
}); });
@ -118,10 +90,10 @@ pub(super) fn handle_request_update_meta_inputs(
/// ///
/// The PR head sha is stored as `fetched_sha` on the approval row — the /// The PR head sha is stored as `fetched_sha` on the approval row — the
/// "reviewed sha" the approve handler (`run_merge_config_pr`) drift-gates /// "reviewed sha" the approve handler (`run_merge_config_pr`) drift-gates
/// against before doing anything irreversible. Unlike `submit_apply_commit` /// against before doing anything irreversible. This does NOT fetch the commit
/// this does NOT fetch the commit into the applied repo at submission time /// into the applied repo at submission time (that happens inside the approve
/// (that happens inside the approve handler, step 2, after the drift check). /// handler, step 2, after the drift check). No flake pre-flight either —
/// No flake pre-flight either — eval-verify happens at approval time too. /// eval-verify happens at approval time too.
pub(crate) async fn submit_merge_config_pr( pub(crate) async fn submit_merge_config_pr(
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
agent: &str, agent: &str,
@ -134,7 +106,7 @@ pub(crate) async fn submit_merge_config_pr(
anyhow::bail!( anyhow::bail!(
"applied repo missing for agent '{agent}' (expected at {}) — \ "applied repo missing for agent '{agent}' (expected at {}) — \
merge_config_pr requires the agent to be fully provisioned; \ merge_config_pr requires the agent to be fully provisioned; \
use request_apply_commit for the first config deploy", spawn the agent first (operator spawn) before opening config PRs",
applied_dir.display() applied_dir.display()
); );
} }
@ -199,32 +171,12 @@ pub(crate) async fn submit_merge_config_pr(
agent, agent,
approval_kind: "merge_config_pr", approval_kind: "merge_config_pr",
sha_short: Some(sha_short), sha_short: Some(sha_short),
diff: None, // diff is not pre-computed; the dashboard fetches it on demand
description: description.map(str::to_owned), description: description.map(str::to_owned),
pr_number: Some(pr_number), pr_number: Some(pr_number),
}); });
Ok(id) Ok(id)
} }
/// `request_apply_commit` takes a commit SHA only — not a branch or
/// tag name. A branch is mutable; pinning the proposal to a concrete
/// sha keeps "what the manager asked to deploy" unambiguous and means
/// the `proposal/<id>` tag is a faithful record of the request.
/// Accepts a 7..=40 char hex string (short or full sha); the exact
/// commit is resolved + existence-checked against the proposed repo
/// later in `lifecycle::git_fetch_to_tag`.
pub(crate) fn validate_commit_ref(commit_ref: &str) -> Result<()> {
let n = commit_ref.len();
let hex = commit_ref.chars().all(|c| c.is_ascii_hexdigit());
if !(7..=40).contains(&n) || !hex {
anyhow::bail!(
"commit_ref '{commit_ref}' is not a commit sha — request_apply_commit \
takes a 7-40 char hex sha, not a branch or tag name"
);
}
Ok(())
}
/// Queue an `InitConfig` approval for a brand-new agent whose config repo /// Queue an `InitConfig` approval for a brand-new agent whose config repo
/// does not yet exist. Shared between the manager and agent sockets. /// does not yet exist. Shared between the manager and agent sockets.
/// ///
@ -247,7 +199,8 @@ pub(crate) fn submit_init_config(
if proposed_dir.join(".git").exists() { if proposed_dir.join(".git").exists() {
anyhow::bail!( anyhow::bail!(
"proposed config repo for '{name}' already exists at {} - \ "proposed config repo for '{name}' already exists at {} - \
use request_apply_commit to update an existing agent's config", nothing to init; config changes go through a forge PR on \
agent-configs/{name}",
proposed_dir.display() proposed_dir.display()
); );
} }
@ -271,176 +224,8 @@ pub(crate) fn submit_init_config(
agent: name, agent: name,
approval_kind: "init_config", approval_kind: "init_config",
sha_short: None, sha_short: None,
diff: None,
description, description,
pr_number: None, pr_number: None,
}); });
Ok(id) Ok(id)
} }
/// Submit-time half of the apply flow: queue the approval row, then
/// fetch the manager's commit from the proposed repo into applied and
/// pin it as `refs/tags/proposal/<id>`. From this point on the manager
/// repo is irrelevant for this approval — even if the manager amends
/// or force-pushes, the canonical sha hive-c0re will eventually
/// approve/deny lives in applied's object DB.
///
/// If anything fails after the row is inserted (sha missing in
/// proposed, fs error, git plumbing crash) we mark the row failed and
/// surface the error to the manager. We don't try to roll the row
/// back — the failure is part of the audit trail.
pub(crate) async fn submit_apply_commit(
coord: &Arc<Coordinator>,
agent: &str,
commit_ref: &str,
description: Option<&str>,
submitter: &str,
) -> anyhow::Result<(i64, String)> {
validate_commit_ref(commit_ref)?;
let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(agent);
let applied_dir = crate::paths::applied_dir(agent);
if !proposed_dir.exists() {
anyhow::bail!(
"proposed repo missing for agent '{agent}' (expected at {})",
proposed_dir.display()
);
}
if !applied_dir.join(".git").exists() {
// First deploy: seed the applied repo from proposed so we can plant
// the proposal/<id> tag below. setup_applied seeds at the root
// (template) commit of proposed, not at main, so deployed/0 is the
// template baseline. This makes the diff mara sees on approval
// show the manager's actual changes rather than an empty diff.
crate::lifecycle::setup_applied(&applied_dir, Some(&proposed_dir), agent)
.await
.context("seed applied repo for first spawn")?;
}
let id = coord
.approvals
.submit_kind(
agent,
hive_sh4re::ApprovalKind::ApplyCommit,
commit_ref,
description,
submitter,
None, // sha resolved after git_fetch_to_tag below; set via set_fetched_sha
)
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
let tag = format!("proposal/{id}");
let sha =
match crate::lifecycle::git_fetch_to_tag(&applied_dir, &proposed_dir, commit_ref, &tag)
.await
{
Ok(s) => s,
Err(e) => {
// Surface the failure on the approval row so the
// dashboard reflects it instead of leaving a phantom
// pending entry. The note doubles as the operator-visible
// explanation of why the approval can't be approved.
let note = format!("{e:#}");
let _ = coord.approvals.mark_failed(id, &note);
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id,
agent,
approval_kind: "apply_commit",
sha_short: None,
status: "failed",
note: Some(note),
description: description.map(str::to_owned),
});
return Err(anyhow::anyhow!("git_fetch_to_tag: {e:#}"));
}
};
coord
.approvals
.set_fetched_sha(id, &sha)
.map_err(|e| anyhow::anyhow!("persist fetched_sha: {e:#}"))?;
// Pre-flight gates: both reject the apply before approval if
// the agent's flake state would inflate meta's lock with duplicates
// or lie about what nix will fetch. Both checks independently read
// `<tag>:flake.lock` via git — they don't share state. Order matters
// only for early-exit + messaging: sync first means a stale lock
// bails with the actionable "run `nix flake lock`" hint rather than
// a dedup pass on a lock nix would never produce.
//
// Runs after `set_fetched_sha` so the failed row carries the sha
// that broke. Both failure paths mark + emit, then bail.
let sha_short = sha[..sha.len().min(12)].to_owned();
if let Err(e) = crate::flake_check::check_lock_in_sync(&applied_dir, &tag, id).await {
let note = format!("{e:#}");
let _ = coord.approvals.mark_failed(id, &note);
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id,
agent,
approval_kind: "apply_commit",
sha_short: Some(sha_short.clone()),
status: "failed",
note: Some(note),
description: description.map(str::to_owned),
});
return Err(anyhow::anyhow!("flake lock-sync check: {e:#}"));
}
if let Err(e) = crate::flake_check::check_no_duplicate_inputs(&applied_dir, &tag).await {
let note = format!("{e:#}");
let _ = coord.approvals.mark_failed(id, &note);
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id,
agent,
approval_kind: "apply_commit",
sha_short: Some(sha_short.clone()),
status: "failed",
note: Some(note),
description: description.map(str::to_owned),
});
return Err(anyhow::anyhow!("flake dedup check: {e:#}"));
}
// Mirror the freshly-planted proposal/<id> tag to the forge.
if let Err(e) = crate::forge::push_config(agent).await {
tracing::warn!(%agent, %id, error = ?e, "forge: push_config after submit failed");
}
// Phase 5b: surface the new pending approval on the dashboard
// event channel. Compute the diff once here so live subscribers
// get a fully-formed row without a snapshot refetch. `sha_short`
// is reused from the dedup gate above.
let diff = crate::dashboard::approval_diff(agent, id).await;
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
id,
agent,
approval_kind: "apply_commit",
sha_short: Some(sha_short),
diff: Some(diff),
description: description.map(str::to_owned),
pr_number: None,
});
Ok((id, sha))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_short_and_full_sha() {
assert!(validate_commit_ref("e194f78").is_ok());
assert!(validate_commit_ref("e194f7812ab").is_ok());
assert!(validate_commit_ref(&"a".repeat(40)).is_ok());
// Uppercase hex resolves fine through `git rev-parse`.
assert!(validate_commit_ref("E194F78").is_ok());
}
#[test]
fn rejects_branch_and_tag_names() {
// The exact bug class this guard exists for.
assert!(validate_commit_ref("main").is_err());
assert!(validate_commit_ref("HEAD").is_err());
assert!(validate_commit_ref("deployed/0").is_err());
assert!(validate_commit_ref("feature-branch").is_err());
}
#[test]
fn rejects_too_short_too_long_and_empty() {
assert!(validate_commit_ref("").is_err());
assert!(validate_commit_ref("abc123").is_err()); // 6 chars
assert!(validate_commit_ref(&"a".repeat(41)).is_err());
}
}

View file

@ -29,9 +29,7 @@ pub(crate) use config_approvals::submit_merge_config_pr;
pub(crate) use schedules::filter_ghost_schedule_targets; pub(crate) use schedules::filter_ghost_schedule_targets;
pub use schedules::schedule_to_wire_public; pub use schedules::schedule_to_wire_public;
use config_approvals::{ use config_approvals::{handle_request_init_config, handle_request_update_meta_inputs};
handle_request_apply_commit, handle_request_init_config, handle_request_update_meta_inputs,
};
use lifecycle_handlers::{ use lifecycle_handlers::{
handle_kill, handle_list_descendants, handle_restart, handle_start, handle_update, handle_kill, handle_list_descendants, handle_restart, handle_start, handle_update,
}; };
@ -565,20 +563,6 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
AgentRequest::RequestInitConfig { name, description } => { AgentRequest::RequestInitConfig { name, description } => {
handle_request_init_config(coord, agent, name, description.clone()) handle_request_init_config(coord, agent, name, description.clone())
} }
AgentRequest::RequestApplyCommit {
agent: target_agent,
commit_ref,
description,
} => {
handle_request_apply_commit(
coord,
agent,
target_agent,
commit_ref,
description.as_deref(),
)
.await
}
// Agent-state queries: own subtree is free; other agents + the // Agent-state queries: own subtree is free; other agents + the
// hive-wide `"*"` sweep require `QueryAgentState`. // hive-wide `"*"` sweep require `QueryAgentState`.
AgentRequest::GetLooseEnds { agent: target } => { AgentRequest::GetLooseEnds { agent: target } => {
@ -722,9 +706,9 @@ fn require_group(agent: &str, group: &str, action: &str) -> Option<AgentResponse
} }
} }
/// Topology guard for `request_init_config` / `request_apply_commit`, /// Topology guard for `request_init_config`, which may legitimately target a
/// which may legitimately target a child that does not exist *yet* /// child that does not exist *yet* (seeding a brand-new sub-agent's config
/// (spawning a brand-new sub-agent). The caller may act on a /// repo). The caller may act on a
/// `target` that is EITHER already its direct child (re-init / config /// `target` that is EITHER already its direct child (re-init / config
/// update of an existing child) OR brand-new (absent from the topology /// update of an existing child) OR brand-new (absent from the topology
/// tree — the requester becomes its parent). A name that already /// tree — the requester becomes its parent). A name that already

View file

@ -84,7 +84,6 @@ pub(super) fn handle_request_schedule_prompt(
agent: requester, agent: requester,
approval_kind: "schedule_prompt", approval_kind: "schedule_prompt",
sha_short: None, sha_short: None,
diff: None,
description: payload.description.clone(), description: payload.description.clone(),
pr_number: None, pr_number: None,
}); });

View file

@ -1,6 +1,7 @@
//! Approval queue. Manager submits via `RequestApplyCommit`; the user //! Approval queue. Requests are submitted by the manager (`RequestInitConfig`
//! approves/denies via the host admin CLI; on approval the host runs the //! / `RequestUpdateMetaInputs`), the config-PR webhook (`MergeConfigPr`), or
//! corresponding action (Phase 5a: `lifecycle::rebuild(agent)`). //! the operator (`Spawn`); the user approves/denies via the host admin CLI;
//! on approval the host runs the corresponding action.
use std::path::Path; use std::path::Path;
use std::sync::Mutex; use std::sync::Mutex;
@ -73,9 +74,9 @@ impl Approvals {
/// Insert a new pending approval row. `fetched_sha` may be supplied /// Insert a new pending approval row. `fetched_sha` may be supplied
/// when the sha is already known at submission time (e.g. `MergeConfigPr` /// when the sha is already known at submission time (e.g. `MergeConfigPr`
/// fetches the PR head before inserting), making the insert + sha-set /// fetches the PR head before inserting), making the insert + sha-set
/// atomic. Pass `None` when the sha is resolved after insertion (e.g. /// atomic. Pass `None` when the kind carries no sha (e.g. `Spawn` /
/// `ApplyCommit`'s `git_fetch_to_tag` step) and call [`set_fetched_sha`] /// `InitConfig`) or the sha is resolved after insertion, then call
/// separately. /// [`set_fetched_sha`] separately.
pub fn submit_kind( pub fn submit_kind(
&self, &self,
agent: &str, agent: &str,
@ -366,7 +367,6 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
// Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description. // Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description.
let kind: String = row.get(2)?; let kind: String = row.get(2)?;
let kind = match kind.as_str() { let kind = match kind.as_str() {
"apply_commit" => ApprovalKind::ApplyCommit,
"spawn" => ApprovalKind::Spawn, "spawn" => ApprovalKind::Spawn,
"init_config" => ApprovalKind::InitConfig, "init_config" => ApprovalKind::InitConfig,
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs, "update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
@ -413,7 +413,6 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
fn kind_from_str(s: &str) -> Result<ApprovalKind> { fn kind_from_str(s: &str) -> Result<ApprovalKind> {
Ok(match s { Ok(match s {
"apply_commit" => ApprovalKind::ApplyCommit,
"spawn" => ApprovalKind::Spawn, "spawn" => ApprovalKind::Spawn,
"init_config" => ApprovalKind::InitConfig, "init_config" => ApprovalKind::InitConfig,
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs, "update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
@ -463,8 +462,15 @@ mod tests {
#[test] #[test]
fn mixed_kinds_all_listed() { fn mixed_kinds_all_listed() {
let (_dir, _path, db) = open_temp(); let (_dir, _path, db) = open_temp();
db.submit_kind("a", ApprovalKind::ApplyCommit, "deadbeef", None, "a", None) db.submit_kind(
.unwrap(); "a",
ApprovalKind::MergeConfigPr,
"deadbeef",
None,
"a",
None,
)
.unwrap();
db.submit_kind("b", ApprovalKind::Spawn, "", None, "b", None) db.submit_kind("b", ApprovalKind::Spawn, "", None, "b", None)
.unwrap(); .unwrap();
db.submit_kind("c", ApprovalKind::InitConfig, "", None, "c", None) db.submit_kind("c", ApprovalKind::InitConfig, "", None, "c", None)
@ -482,7 +488,7 @@ mod tests {
let id = db let id = db
.submit_kind( .submit_kind(
"bitburner", "bitburner",
ApprovalKind::ApplyCommit, ApprovalKind::MergeConfigPr,
"cafef00d", "cafef00d",
Some("test"), Some("test"),
"bitburner", "bitburner",
@ -523,7 +529,7 @@ mod tests {
let good = db let good = db
.submit_kind( .submit_kind(
"good", "good",
ApprovalKind::ApplyCommit, ApprovalKind::MergeConfigPr,
"cafe", "cafe",
None, None,
"good", "good",
@ -553,7 +559,7 @@ mod tests {
let id = db let id = db
.submit_kind( .submit_kind(
"child", "child",
ApprovalKind::ApplyCommit, ApprovalKind::MergeConfigPr,
"cafe", "cafe",
None, None,
"parent", "parent",
@ -565,7 +571,7 @@ mod tests {
let raw = Connection::open(&path).unwrap(); let raw = Connection::open(&path).unwrap();
raw.execute( raw.execute(
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status) "INSERT INTO approvals (agent, kind, commit_ref, requested_at, status)
VALUES ('old', 'apply_commit', '', 0, 'pending')", VALUES ('old', 'spawn', '', 0, 'pending')",
[], [],
) )
.unwrap(); .unwrap();

View file

@ -58,12 +58,10 @@ pub struct Approval {
/// Kind-specific payload (git sha / inputs array / schedule /// Kind-specific payload (git sha / inputs array / schedule
/// payload / empty). See the Approval struct doc. /// payload / empty). See the Approval struct doc.
pub commit_ref: String, pub commit_ref: String,
/// The canonical hive-c0re-vouched sha. For `ApplyCommit`: the sha /// The canonical hive-c0re-vouched sha. For `MergeConfigPr`: the
/// after the proposal fetch, tagged `proposal/<id>` (stable for the /// reviewed PR head pinned at submit; if the PR head drifts off it
/// approval's lifetime — manager amends in proposed don't change /// before merge, hive-c0re cancels the stale approval and re-queues a
/// what gets built). For `MergeConfigPr`: the reviewed PR head /// fresh one for re-review.
/// pinned at submit; if the PR head drifts off it before merge,
/// hive-c0re refreshes this + re-renders the card for re-review.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub fetched_sha: Option<String>, pub fetched_sha: Option<String>,
pub requested_at: DateTime<Utc>, pub requested_at: DateTime<Utc>,
@ -84,9 +82,6 @@ pub struct Approval {
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ApprovalKind { pub enum ApprovalKind {
/// Apply a manager-proposed config commit.
#[default]
ApplyCommit,
/// Create + start a new sub-agent container with the given name /// Create + start a new sub-agent container with the given name
/// (under the default `agent.nix` template). /// (under the default `agent.nix` template).
Spawn, Spawn,
@ -100,9 +95,12 @@ pub enum ApprovalKind {
SchedulePrompt, SchedulePrompt,
/// Merge an operator-reviewed config PR: hive-c0re verifies the /// Merge an operator-reviewed config PR: hive-c0re verifies the
/// reviewed PR head, fast-forwards the forge config repo's `main` /// reviewed PR head, fast-forwards the forge config repo's `main`
/// to it, marks the PR merged, then runs the same deploy tail as /// to it, marks the PR merged, then runs the deploy tail. This is the
/// `ApplyCommit`. `commit_ref` = PR number; `fetched_sha` = the /// sole config-change flow — a manager opens a PR on its
/// reviewed PR head pinned at submit. See `docs/approvals.md`. /// `agent-configs/<agent>` repo and the operator reviews + approves it.
/// `commit_ref` = PR number; `fetched_sha` = the reviewed PR head
/// pinned at submit. See `docs/approvals.md`.
#[default]
MergeConfigPr, MergeConfigPr,
} }
@ -114,7 +112,6 @@ impl ApprovalKind {
#[must_use] #[must_use]
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
ApprovalKind::ApplyCommit => "apply_commit",
ApprovalKind::Spawn => "spawn", ApprovalKind::Spawn => "spawn",
ApprovalKind::InitConfig => "init_config", ApprovalKind::InitConfig => "init_config",
ApprovalKind::UpdateMetaInputs => "update_meta_inputs", ApprovalKind::UpdateMetaInputs => "update_meta_inputs",
@ -544,13 +541,6 @@ pub enum Request {
/// *(privileged)* Rebuild a sub-agent against the current hyperhive /// *(privileged)* Rebuild a sub-agent against the current hyperhive
/// flake + agent.nix. No approval required. /// flake + agent.nix. No approval required.
Update { name: String }, Update { name: String },
/// *(privileged)* Submit a config commit for the operator to approve.
RequestApplyCommit {
agent: String,
commit_ref: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
/// *(privileged)* Fetch recent journal lines for a sub-agent container. /// *(privileged)* Fetch recent journal lines for a sub-agent container.
GetLogs { GetLogs {
agent: String, agent: String,
@ -923,8 +913,7 @@ pub enum ToolGroup {
Inbox, Inbox,
/// `kill`, `start`, `restart`, `update` - *(privileged)* /// `kill`, `start`, `restart`, `update` - *(privileged)*
Lifecycle, Lifecycle,
/// `request_init_config`, `request_apply_commit`, /// `request_init_config`, `request_update_meta_inputs` - *(privileged)*
/// `request_update_meta_inputs` - *(privileged)*
Approvals, Approvals,
/// `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`, /// `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`,
/// `edit_schedule`, `list_schedules` - *(privileged)* /// `edit_schedule`, `list_schedules` - *(privileged)*
@ -961,11 +950,7 @@ impl ToolGroup {
"request_next_turn", "request_next_turn",
], ],
Self::Lifecycle => &["kill", "start", "restart", "update", "list_containers"], Self::Lifecycle => &["kill", "start", "restart", "update", "list_containers"],
Self::Approvals => &[ Self::Approvals => &["request_init_config", "request_update_meta_inputs"],
"request_init_config",
"request_apply_commit",
"request_update_meta_inputs",
],
Self::Scheduling => &[ Self::Scheduling => &[
"request_schedule_prompt", "request_schedule_prompt",
"fire_schedule_now", "fire_schedule_now",
@ -1066,7 +1051,7 @@ impl ToolGroup {
"kill, start, restart, update, list_containers — container lifecycle (privileged)" "kill, start, restart, update, list_containers — container lifecycle (privileged)"
} }
Self::Approvals => { Self::Approvals => {
"request_init_config, request_apply_commit, request_update_meta_inputs — config change flow (privileged)" "request_init_config, request_update_meta_inputs — config change flow (privileged)"
} }
Self::Scheduling => { Self::Scheduling => {
"request_schedule_prompt and related — operator-visible scheduled prompts (privileged)" "request_schedule_prompt and related — operator-visible scheduled prompts (privileged)"