diff --git a/docs/agent-hierarchy.md b/docs/agent-hierarchy.md index a88374f5..a9ff2b9d 100644 --- a/docs/agent-hierarchy.md +++ b/docs/agent-hierarchy.md @@ -98,7 +98,7 @@ Once enforcement lands the rules collapse into: | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | `kill` / `start` / `restart` / `update` (any descendant) | any ancestor | | `request_init_config` (spawn a new child) | any agent, child added under self | -| config change via forge PR (any descendant's config) | any ancestor | +| `request_apply_commit` (any descendant's config) | any ancestor | | `get_logs` (any 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 | @@ -133,6 +133,7 @@ can't: | 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 | +| `RequestApplyCommit` | submit a commit sha for operator approval | **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) | | `GetLogs` | journalctl scrape of a sub-agent | **topology** — descendants only | diff --git a/docs/approvals.md b/docs/approvals.md index 9bf08ebd..dfb6c486 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -10,55 +10,67 @@ informed about what happens after a decision lands. ## End-to-end approval flow -Config changes flow through a **forge pull request** on the agent's -`agent-configs/` 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` tool group) edits files in the child's proposed config repo - (any tracked path, but `agent.nix` is the contract entry point), - commits with its own git identity, and pushes a branch + opens a PR - on `agent-configs/` with `hive-forge`. The parent's container - has the child's proposed config repo bind-mounted read-write at + (any tracked path, but `agent.nix` is the contract entry point) + and commits with its own git identity. The parent's container has + the child's proposed config repo bind-mounted read-write at `/agents//config/` (topology-driven via `set_nspawn_flags`; the agent's *own* config at `/agents//config/` is read-only). - Branch protection (push/merge whitelist = `core`, approvals whitelist - = operator team; see "Forge mirror" and #1787) makes the agent a - write collaborator that **cannot merge its own config PR**. -2. hive-c0re's `/webhook/config-pr` endpoint receives the Forgejo - `pull_request` event (opened / synchronized / reopened) and queues a - `MergeConfigPr` approval; a poll fallback catches any missed webhook. - The approval row stores the PR **number** (`commit_ref`) and the PR - **head sha at queue time** (`fetched_sha` — the "reviewed" sha). If - the PR head later moves, the stale approval is superseded by a fresh - one pinned to the new head, so the operator always reviews what will - actually deploy. -3. The operator reviews the PR **on the forge** (native diff, threaded - comments, CI status) and sees a matching card on the dashboard with a - "review PR on forge" deep link. They click ◆ APPR0VE (or - `hive-c0re approve ` on the CLI) once satisfied. -4. On approve, `run_merge_config_pr`: - - re-reads the live PR head and **aborts if it drifted** from the - reviewed `fetched_sha` (the submitter must push again, which queues - a fresh approval); - - fetches that head into the applied repo and **eval-verifies** it — - a flake eval on a throwaway checkout. This is the trust gate: it - relies on c0re's own eval, not on any in-repo (agent-forgeable) - signal like a CI status; - - fast-forwards the forge config repo's `main` to the reviewed head - (this IS the merge — a `core`-authenticated ff-push, so a moved PR - head can't substitute bytes) and marks the PR merged (best-effort; - `main` is already there); - - runs the shared deploy tail (`deploy_applied_target`): builds under - the tag sequence below, and on success `applied/main` - fast-forwards to the reviewed commit + the meta lock bumps. On - failure, main stays put and the working tree resets to the previous - deployed commit. -5. `HelperEvent::ApprovalResolved` (and `Rebuilt`) land in the - **submitting agent's** inbox via `notify_submitter`, carrying both the - canonical sha and the terminal tag (the approval row carries a - `submitter` column recording the agent the change is for). +2. The submitting agent submits the commit sha via `request_apply_commit(agent, + commit_ref)`. `commit_ref` must be a commit **sha** (7-40 hex + chars, short or full) — a branch or tag name is rejected so the + approval pins an immutable commit. +3. **hive-c0re immediately fetches that commit from the proposed + repo into the applied repo and tags it `proposal/`.** It + resolves the sha locally against the proposed repo, fetches all + of proposed's heads into applied's object db, then tags the + resolved commit — `git fetch :` can't fetch + by a bare sha (the left side of a refspec is a remote *ref + name*), so the resolution happens on hive-c0re's side. The + approval row stores both the submitted sha and the + canonical hive-c0re-vouched sha. From here on the proposed + repo is irrelevant for this approval — the submitter can amend, + force-push, or `rm -rf` the proposed repo and the queued + approval still points at an immutable git object inside + applied. +3a. **Flake validation (ApplyCommit only):** after the proposal tag + is planted, hive-c0re reads `proposal/:flake.lock` and + runs two checks. If either check fails, no + pending approval is created for the operator — the row is + marked failed and surfaces on the dashboard with the + validation message: + - **Stale lock** — materialises the commit in a temp worktree, + runs `nix flake lock` (no `--update-input` flags, so it only + fills missing entries), and rejects if the committed + `flake.lock` differs from the result. Triggered when the + submitter added or removed `inputs` in `flake.nix` without + re-running `nix flake lock`. Fix: run `nix flake lock` in + the config repo, commit, and re-submit. + - **Duplicate inputs** — groups lock nodes by their canonical + `original` field; rejects if two or more nodes share the same + source. This usually means an input is missing + `inputs..inputs.nixpkgs.follows = "nixpkgs"`. Fix: add + the `follows` directive, re-lock, and re-submit. + Both checks only flag *new* violations — agents whose lock + 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 ` on the CLI). +5. hive-c0re moves the working tree to `proposal/` 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 @@ -76,47 +88,53 @@ agent that lacks the `approvals` tool group: only an agent with that group submits approvals (for its direct children), so an agent without it has nothing of its own to withdraw. -`InitConfig` approvals seed a brand-new agent's proposed config repo. -On approve, hive-c0re seeds it with a default `agent.nix` template and -sends `HelperEvent::ConfigReady { agent }` to the submitting agent's -inbox via `notify_submitter`. The submitting agent reviews, edits, and -commits the template; the operator then **spawns** the agent (the -`Spawn` approval / `◆ R3QU3ST SP4WN` button), which creates the -container from that config. From then on, config changes go through a -forge PR (`MergeConfigPr`) — never a fresh spawn. This gives the -submitting agent (and operator) an explicit review gate on the initial -configuration before any container is created. +`InitConfig` approvals are the first step in a two-step spawn +flow. On approve, hive-c0re seeds the proposed config repo with +a default `agent.nix` template and sends `HelperEvent::ConfigReady { agent }` +to the submitting agent's inbox via `notify_submitter`. The submitting +agent then reviews, +edits, and commits the template before calling `request_apply_commit` +to proceed to an `ApplyCommit` approval. The first `ApplyCommit` +creates the container; subsequent ones rebuild it with new config. +This gives the submitting agent (and operator) an explicit review gate on the +initial configuration before any container is created. ### Approval kinds (wire shapes) -`ApprovalKind` carries five variants; each maps to a different +`ApprovalKind` carries six variants; each maps to a different `commit_ref` encoding because that field is overloaded as the kind-specific payload carrier. -- `MergeConfigPr` — the config-change flow. Triggered automatically: - when an agent opens (or force-pushes) a PR on its - `agent-configs/` 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, +- `ApplyCommit` — `commit_ref` is the submitted git sha + (7-40 hex chars). The canonical, hive-c0re-vouched sha after the + proposal fetch lives in `fetched_sha` on the same `Approval` + row (only `ApplyCommit` populates it). See the End-to-end flow + above. +- `MergeConfigPr` — the PR-based config flow's counterpart to + `ApplyCommit`. Triggered automatically: when an agent opens (or + force-pushes) a PR on its `agent-configs/` 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 drifted from `fetched_sha` (submitter must push again to re-trigger), then fetches that head into the applied repo, eval-verifies it, fast-forwards the forge config repo's `main` to it (the merge), marks the PR merged (best-effort — `main` is - already there), and runs the shared deploy tail - (`deploy_applied_target`). Never a first spawn. -- `Spawn` — direct container creation from the agent's config repo. - `commit_ref` is empty. Submitted via `HostRequest::RequestSpawn` - (operator-gated, the `◆ R3QU3ST SP4WN` dashboard button + - `hive-c0re request-spawn` CLI). The host-level `HostRequest::Spawn` - variant bypasses the approval queue entirely — privileged-context use - only (operator on the host shell, test scripts, one-off recoveries). - This is the **canonical first-spawn**: a new agent's `InitConfig` - seeds its config repo, the submitting agent customises it, then the - operator spawns to create the container. Subsequent config changes go - through a `MergeConfigPr` PR. + already there), and runs the same shared deploy tail as + `ApplyCommit` (`deploy_applied_target`). Never a first spawn. +- `Spawn` — direct container creation under the default + `agent.nix` template. `commit_ref` is empty. Submitted via + `HostRequest::RequestSpawn` (operator-gated, the + `◆ R3QU3ST SP4WN` dashboard button + `hive-c0re request-spawn` + CLI). The host-level `HostRequest::Spawn` variant bypasses the + approval queue entirely — privileged-context use only (operator + on the host shell, test scripts, one-off recoveries). The + agent-side `RequestSpawn` is gone; the submitting agent goes through the + `InitConfig` → `ApplyCommit` two-step instead so the spawn + captures the customised config. - `InitConfig` — `commit_ref` is empty; the variant just gates "seed the proposed repo with the default template" against operator approval. Step 1 of the two-step spawn flow above. @@ -221,14 +239,14 @@ module that `setup_applied` used to generate inline. Containers run against `--flake /var/lib/hyperhive/meta#`. Per-deploy lock flow (two-phase, owned by -`actions::run_merge_config_pr` → `deploy_applied_target` → -`meta::{prepare,finalize,abort}_deploy`): +`actions::run_apply_commit` → `meta::{prepare,finalize,abort} +_deploy`): 1. `meta::prepare_deploy(name)` runs `nix flake lock --update-input agent-` without committing. Working tree of meta now points the input at - `applied//main` (which the deploy already fast-forwarded to - the reviewed PR head). + `applied//main` (which `run_apply_commit` already + fast-forwarded to `proposal/`). 2. `lifecycle::rebuild_no_meta` runs `nixos-container update --flake meta#`. Nix evaluates against the staged lock. @@ -305,29 +323,34 @@ wraps it with identity + `HIVE_PORT` / `HIVE_LABEL` / ### Tag state machine -Each deploy leaves a tag on the underlying commit inside the applied -repo: +Every approval id walks through a fixed set of tags on the +underlying commit inside the applied repo: | Tag | When | Annotated? | |---|---|---| +| `proposal/` | request_apply_commit, after fetch | no | +| `approved/` | operator approve | no | +| `building/` | rebuild started | no | | `deployed/` | rebuild succeeded — `main` ff's here | no | | `failed/` | rebuild failed | yes (body = error) | +| `denied/` | operator deny | yes (body = operator note) | -`deployed/0` is planted at first spawn. `applied/main` is always the -latest `deployed/*`. A `failed/` tree stays browsable forever — `git log ---tags` in the applied repo is the audit trail. A denied or failed config -PR carries no extra state on the forge side: the PR stays open, and the -submitter pushes again (or closes it) to retry. +`applied/main` is always the latest `deployed/*`. `denied/` and +`failed/` are terminal; the submitting agent submits a new commit + new +approval id to retry. Because tags are first-class git objects, +rejected and failed trees stay browsable forever — `git log +--tags` in the applied repo is the audit trail. ### Dispatch via the job queue -Long-running approval work — `MergeConfigPr`, `UpdateMetaInputs`, +Long-running approval work — `ApplyCommit`, `UpdateMetaInputs`, `Spawn` — no longer runs inline inside `actions::approve`. Instead the approval handler submits a DAG to the global job queue (`docs/coordinator.md::Job queue`): | `ApprovalKind` | DAG submitted | source | |---|---|---| +| `ApplyCommit` | `rebuild` (single opaque `ApprovalDeploy` node) | `approval` | | `MergeConfigPr` | `rebuild` (single opaque `ApprovalDeploy` node) | `approval` | | `UpdateMetaInputs` | `meta_update` (`MetaLock` + rebuild fan-out) | `approval` | | `Spawn` | `spawn` (`Create → WriteDropin → Reconcile`) | `approval` | @@ -335,8 +358,9 @@ the approval handler submits a DAG to the global job queue | `SchedulePrompt` | — runs inline (single sqlite insert) | — | The DAG carries the originating `approval_id`. The `ApprovalDeploy` -node runs `run_approval_merge_config_pr` (the two-phase meta deploy -stays inside `actions.rs`) and fires the matching `HelperEvent::*` via +node runs the kind-specific pipeline (`run_approval_apply_commit` / +`run_approval_merge_config_pr` — the two-phase meta deploy stays +inside `actions.rs`) and fires the matching `HelperEvent::*` via `finish_approval` itself; `Spawn` and `UpdateMetaInputs` DAGs resolve through `actions::resolve_approval_dag` when the DAG settles terminal (a spawn additionally runs the post-spawn forge bookkeeping there). @@ -367,25 +391,22 @@ The bundled `hive-forge` container is mandatory (it deploys with hyperhive), and hive-c0re mirrors every agent's applied repo into a private `agent-configs` Forgejo org. `forge::push_config()` pushes `applied/main` plus every tag to `agent-configs/` after each ref mutation: -the spawn that seeds `deployed/0`, every successful deploy (which -plants `deployed/`) or failed build (`failed/`), and a +the spawn that seeds `deployed/0`, every `request_apply_commit` +(which plants `proposal/`), every approve / deny, and a sweep at startup. Pushes are best-effort — a missing or stopped forge never blocks a deploy. -Each agent is a **write collaborator on its own** `agent-configs/` -repo — so it can push a branch and open a config PR — but not a member -of any other agent's, so it can't reach another agent's config through -the forge. Branch protection keeps `main` push/merge `core`-only with -operator-team approval, so an agent can't fast-forward its own config or -self-merge its PR (see the End-to-end flow + #1787). The tokenised push -URL is passed inline to `git push`, never written into -`applied//.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 org is private and agents are not members, so only the +`core` user (a Forgejo site admin) can read it: an agent can't +reach another agent's config — or even its own — through the +forge. The tokenised push URL is passed inline to `git push`, +never written into `applied//.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 -per container row and a `review PR on forge` link per config-PR -approval card. See `docs/web-ui.md`. +per container row and a `commit on forge` link per approval +card. See `docs/web-ui.md`. ### Submitting agent's view of config repos @@ -463,8 +484,8 @@ hive-c0re creates it. The root agent's flake lives at `/var/lib/hyperhive/applied/ruth/`; its proposed config at `/var/lib/hyperhive/agents/ruth/config/`. The root agent can edit its own `agent.nix` (visible inside the container at `/agents/ruth/config/`) -and open a config PR on `agent-configs/ruth` for operator approval, -same as any other agent. +and submit `request_apply_commit("ruth", )` for operator +approval. Differences from sub-agents: @@ -538,11 +559,11 @@ root agent. Variants (`hive_sh4re::HelperEvent`): - `ApprovalResolved { id, agent, commit_ref, status, note }` — fired by `actions::approve` + `actions::deny` whenever an approval transitions to its terminal state. -- `Spawned { agent, ok, note }` — the `Spawn` approval DAG + - admin `HostRequest::Spawn`. +- `Spawned { agent, ok, note }` — `actions::approve` (first-time + ApplyCommit-kind) + admin `HostRequest::Spawn` (deprecated). - `Rebuilt { agent, ok, note }` — `auto_update::rebuild_agent` (covers startup scan + manual `/rebuild` from dashboard) + - the `MergeConfigPr` deploy. + `actions::approve` (ApplyCommit). - `Killed { agent }` — admin `HostRequest::Kill` + dashboard `/kill` + the `Kill` MCP tool. - `Destroyed { agent }` — `actions::destroy`. @@ -561,9 +582,9 @@ root agent. Variants (`hive_sh4re::HelperEvent`): often greets the agent on this event. - `ConfigReady { agent }` — a new agent's proposed config repo was just seeded (post-`InitConfig` approval). The root agent can now - edit `/agents//config/agent.nix`, commit the changes, and - hand off to the operator to **spawn** the agent (the `Spawn` - approval creates the container from that config). + edit `/agents//config/agent.nix`, commit the changes, + and submit `request_apply_commit` with the commit sha to create + the container (first ApplyCommit also triggers spawn bookkeeping). - `NeedsUpdate { agent }` — sub-agent's recorded flake rev is stale. The root agent calls `update(name)` to rebuild — idempotent, no approval required. @@ -576,12 +597,15 @@ root agent. Variants (`hive_sh4re::HelperEvent`): The recipient responds via `Answer { id, answer }` and the asker sees the matching `QuestionAnswered`. -Optional `sha` field on `ApprovalResolved` and `Rebuilt` carries the -canonical hive-c0re-vouched commit sha. Optional `tag` on the same two -carries the deploy bookkeeping tag — `deployed/` on a successful -build or `failed/` on a failed one, planted by the `MergeConfigPr` -deploy. Both fields are `Option`: `None` on the paths that don't deploy -a new commit (spawn / init_config / meta-update / deny, and +Optional `sha` field on `ApprovalResolved`, `Spawned`, and `Rebuilt` +carries the canonical hive-c0re-vouched commit sha. Optional `tag` +on `ApprovalResolved` and `Rebuilt` only — the spawn path always +lands at `deployed/0`, so the tag is implicit and not echoed. The +tag values for the variants that do carry it: `deployed/` / +`failed/` / `denied/` for approval-driven flows; +`approved/` for the rare bare-approval case where no underlying +action runs. Both fields are `Option`: `None` on the rebuild paths +that don't change the deployed commit (e.g. `auto_update::rebuild_agent` reapplying the existing main, or the dashboard `↻ R3BU1LD` button when the lock didn't move). When set, `git show ` against `/agents//applied.git` inside the diff --git a/docs/conventions.md b/docs/conventions.md index c2918082..2c43c4af 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -325,7 +325,7 @@ binary flavor. | `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`. | | `lifecycle` | `kill`, `start`, `restart`, `update` *(privileged)* | -| `approvals` | `request_init_config`, `request_update_meta_inputs` *(privileged)* | +| `approvals` | `request_init_config`, `request_apply_commit`, `request_update_meta_inputs` *(privileged)* | | `scheduling` | `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`, `edit_schedule`, `list_schedules` *(privileged)* | | `diagnostics` | `get_logs` *(privileged)* | diff --git a/docs/coordinator.md b/docs/coordinator.md index 23762e3e..454f17ee 100644 --- a/docs/coordinator.md +++ b/docs/coordinator.md @@ -201,7 +201,7 @@ per template. ### Approvals -`MergeConfigPr` approvals ride as single-node +`ApplyCommit` / `MergeConfigPr` approvals ride as single-node `ApprovalDeploy` DAGs: the two-phase `prepare_deploy` / `finalize_deploy` / `abort_deploy` meta orchestration stays inside `actions.rs` in v1 (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. Called by spawn / destroy / startup migration. - **`prepare_deploy` + `finalize_deploy` / `abort_deploy`** — two-phase for the - `MergeConfigPr` deploy path so a failed `nixos-container update` leaves no orphan + `RequestApplyCommit` path so a failed `nixos-container update` leaves no orphan commit in meta. Prepare writes the new lock without committing; finalize commits with the deploy message; abort restores the lock. - **`lock_update_hyperhive`** — one-shot for the boot-reconcile path (the diff --git a/docs/persistence.md b/docs/persistence.md index 6f1dcccf..3aee55f3 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -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 pointer instead. `attempt_count` / `last_error` accumulate on delivery-failed retries. -- `approvals` — the queue. `agent / kind (merge_config_pr | spawn | +- `approvals` — the queue. `agent / kind (apply_commit | spawn | init_config | update_meta_inputs | schedule_prompt) / commit_ref / requested_at / status / resolved_at / note`. - `operator_questions` — `ask` / `answer` queue (despite the diff --git a/docs/setup.md b/docs/setup.md index 349f8a01..c288d99b 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -65,15 +65,12 @@ the container, via MCP tools): request_init_config(name: "iris") # → operator approves → config_ready event lands in the inbox -# Step 2: edit /agents/iris/config/agent.nix and commit it. Then the -# operator spawns iris (dashboard ◆ R3QU3ST SP4WN / Spawn approval), -# 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. +# Step 2: edit /agents/iris/config/agent.nix, commit it, then: +request_apply_commit(agent: "iris", commit_ref: "") +# → operator approves → container built + started ``` -See [`approvals.md`](approvals.md) for the full flow. +See [`approvals.md`](approvals.md) for the full two-step flow. ### 5 · Useful host commands @@ -100,8 +97,8 @@ See [`tools/hivectl.md`](tools/hivectl.md) for every `hivectl` verb. - **No forge admin token is stored in any agent state dir.** Agents hold a regular agent token in their `forge-token` file; sensitive creds (the core token, the matrix admin token) live on the host. -- All config changes (forge PRs on `agent-configs/`) go through - operator approval — agents can't unilaterally rebuild containers, by design. +- All config changes (`request_apply_commit`) go through operator + approval — agents can't unilaterally rebuild containers, by design. See [`boundary.md`](boundary.md) and [`security.md`](security.md). Once the hive is running, ruth records anything it needs to remember diff --git a/docs/terminal-rendering.md b/docs/terminal-rendering.md index 14a2e22e..d535cc3c 100644 --- a/docs/terminal-rendering.md +++ b/docs/terminal-rendering.md @@ -164,6 +164,7 @@ name as-is. | `get_logs*` | `get_logs* name` or `get_logs* name NL` | | `get_host_journal*` | `get_host_journal*()` or with `[container] · [/grep/] · NL` | | **Approvals / config** | | +| `request_apply_commit*` | `request_apply_commit* agent @ sha12` | | `request_init_config*` | `request_init_config* name` | | `request_update_meta_inputs*` | `request_update_meta_inputs* [inp1, …]` or `all` | | **Scheduling** | | diff --git a/docs/tools/lifecycle.md b/docs/tools/lifecycle.md index 4e0e2681..16fe257a 100644 --- a/docs/tools/lifecycle.md +++ b/docs/tools/lifecycle.md @@ -45,17 +45,24 @@ Step 1 of spawning a new direct child agent. Queues an `InitConfig` approval; on operator approve, hive-c0re seeds the proposed config repo at `/agents//config/agent.nix` with a default template and delivers a `config_ready` system event. Then edit `agent.nix`, commit, -and the operator **spawns** the agent (the dashboard `◆ R3QU3ST SP4WN` -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/` repo (queues a `MergeConfigPr` approval on -open/update — no MCP tool involved), not a tool call. See -`docs/approvals.md`. +and call `request_apply_commit` with the commit sha — the first +`ApplyCommit` on a freshly-init'd config creates the container. Fails if a proposed config repo for `name` already exists. -`name` is ≤ 9 characters. +`name` is ≤ 9 characters. The operator can also spawn an empty agent +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?)` @@ -74,6 +81,7 @@ agents after the approval resolves. | `kill` / `start` / `restart` / `update` | No | Direct children | | `list_containers` | No | All descendants | | `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) | ## See also diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index cdb0dd89..2cbcd685 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -1066,6 +1066,7 @@ renderApprovals`) with three stacked sections: | kind | glyph | chip | sha shown | |---|---|---|---| + | `apply_commit` | `→` | `apply` | proposal sha (`sha_short`) | | `merge_config_pr` | `⇒` | `merge-pr` | PR-head sha (`sha_short`) | | `update_meta_inputs` | `↻` | `meta-update` | — | | `schedule_prompt` | `⏱` | `schedule` | — | @@ -1079,16 +1080,26 @@ renderApprovals`) with three stacked sections: than at the next `renderApprovals` call. - **what-changed body** — the submitting agent's description, then 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/` (shown only when `forge_present`). - `merge_config_pr`: `↳ review PR on forge ↗` deep-links the config PR into `agent-configs//pulls/` (shown - only when `forge_present` and `pr_number` is set). The config diff - lives on the forge PR itself — no inline diff side-panel. + only when `forge_present` and `pr_number` is set). No inline diff + side-panel (apply_commit-only for now). - `init_config` / `spawn`: a one-line "container will be created" note instead. - **decision actions** — `◆ APPR0VE` and `DENY`. Deny pops a `prompt()` for an optional reason carried to the submitting agent as `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 between the live queue and the last 30 resolved approvals. @@ -1188,8 +1199,12 @@ that's a browser-level decision, not ours. `"ok"`/`"err"`, `detail` nullable), newest first, server-clamped to 500; `total` is the full row count for a "latest 500 of N" header. Backs the LOGS page AUDIT sub-tab. -- `GET /static/marked.js` serves the vendored `marked` bundle used - for markdown previews. +- `GET /api/approval-diff/{id}?base=applied|approved|previous` — + on-demand unified diff for an `ApplyCommit` approval against + 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=` — bounded text read of a file under the per-agent `state/` subtree or the shared `/var/lib/hyperhive/shared/`. Accepts the diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index 48d33a6c..6e6ff5da 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -1546,7 +1546,7 @@ window.marked = marked; if (name.startsWith('mcp__matrix__')) return '💬'; if (name.startsWith('mcp__bash__')) return '🖥️'; if (name.includes('schedule')) return '⏱️'; - // request_init_config / request_update_meta_inputs + // request_apply_commit / request_init_config / request_update_meta_inputs if (name.startsWith('mcp__hyperhive__request_')) return '📦'; } return '🔧'; @@ -1632,6 +1632,8 @@ window.marked = marked; const msg = String(input.message || input.file_path || '').replace(/\s+/g, ' ').trim(); 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': return short + ' ' + (input.name || '?'); case 'mcp__hyperhive__request_update_meta_inputs': { diff --git a/frontend/packages/dashboard/src/call.js b/frontend/packages/dashboard/src/call.js index 601682a6..e79e5928 100644 --- a/frontend/packages/dashboard/src/call.js +++ b/frontend/packages/dashboard/src/call.js @@ -130,6 +130,7 @@ export function applyApprovalAdded(ev) { kind: ev.approval_kind, sha_short: ev.sha_short || null, pr_number: ev.pr_number ?? null, + diff: ev.diff || null, description: ev.description || null, // The ApprovalAdded event carries no requested_at; a live-added // approval was queued just now, so client-now is accurate — and @@ -166,6 +167,68 @@ export function applyApprovalResolved(ev) { } 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() { const root = $('approvals-section'); // #approvals-section only lives on /dashboard.html (Y3R C4LL tab); @@ -250,6 +313,7 @@ export function renderApprovals() { const ul = el('ul', { class: 'approvals' }); for (const a of pending) { + const isApply = a.kind === 'apply_commit'; const isInit = a.kind === 'init_config'; const isMergePr = a.kind === 'merge_config_pr'; const isUpdateMeta = a.kind === 'update_meta_inputs'; @@ -258,13 +322,13 @@ export function renderApprovals() { // ── identity header ────────────────────────────────────────── const head = el('div', { class: 'approval-head' }, - el('span', { class: 'glyph' }, isMergePr ? '⇒' : isUpdateMeta ? '↻' : isSchedule ? '⏱' : '⊕'), + el('span', { class: 'glyph' }, isApply ? '→' : isMergePr ? '⇒' : isUpdateMeta ? '↻' : isSchedule ? '⏱' : '⊕'), el('span', { class: 'id' }, '#' + a.id), el('span', { class: 'agent' }, a.agent), - el('span', { class: 'kind' + ((isMergePr || isUpdateMeta || isSchedule) ? '' : ' kind-spawn') }, - isMergePr ? 'merge-pr' : isUpdateMeta ? 'meta-update' : isSchedule ? 'schedule' : isInit ? 'init' : 'spawn'), + el('span', { class: 'kind' + ((isApply || isMergePr || isUpdateMeta || isSchedule) ? '' : ' kind-spawn') }, + isApply ? 'apply' : isMergePr ? 'merge-pr' : isUpdateMeta ? 'meta-update' : isSchedule ? 'schedule' : isInit ? 'init' : 'spawn'), ); - if (isMergePr && a.sha_short) head.append(el('code', {}, a.sha_short)); + if ((isApply || isMergePr) && a.sha_short) head.append(el('code', {}, a.sha_short)); // When the approval was requested — relative time, right-aligned. // 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). @@ -284,9 +348,24 @@ export function renderApprovals() { if (a.description) { body.append(el('div', { class: 'approval-description' }, a.description)); } - if (isMergePr) { - // PR-based config deploy: link to the reviewed PR on the forge. - // The config diff lives on the forge PR itself. + if (isApply) { + const drill = el('div', { class: 'drill-ins' }); + const diffBtn = el('button', { type: 'button', class: 'panel-trigger' }, + '↳ 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' }); if (forgeBase && a.pr_number != null) { drill.append(el('a', { @@ -364,7 +443,7 @@ function renderApprovalHistory(root, history) { el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ', el('span', { class: 'id' }, '#' + a.id), ' ', el('span', { class: 'agent' }, a.agent), ' ', - 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'), ' ', + 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'), ' ', ); if (a.sha_short) row.append(el('code', {}, a.sha_short), ' '); row.append( diff --git a/hive-agent-mcp/src/mcp/args.rs b/hive-agent-mcp/src/mcp/args.rs index 24ed05c3..007504cf 100644 --- a/hive-agent-mcp/src/mcp/args.rs +++ b/hive-agent-mcp/src/mcp/args.rs @@ -88,9 +88,9 @@ pub struct RequestInitConfigArgs { /// New sub-agent name (≤9 chars). Queues an `InitConfig` approval; on /// approval hive-c0re seeds the proposed config repo at /// `/agents//config/agent.nix` with the default template. After - /// the approval the manager edits + commits the config, then the operator - /// spawns the agent; later config changes go through a PR on the child's - /// `agent-configs/` repo. + /// the approval the manager edits + commits the config and calls + /// `request_apply_commit` to pin the customised sha for the container's + /// first build. pub name: String, /// Optional description shown on the dashboard approval card. #[serde(default)] @@ -208,6 +208,20 @@ pub struct AgentGetLooseEndsArgs { pub agent: Option, } +#[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, +} + #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct UpdateMetaInputsArgs { /// Flake input names to update (e.g. `["bitburner-agent", "nixpkgs"]`). diff --git a/hive-agent-mcp/src/mcp/mod.rs b/hive-agent-mcp/src/mcp/mod.rs index deb06454..d5837327 100644 --- a/hive-agent-mcp/src/mcp/mod.rs +++ b/hive-agent-mcp/src/mcp/mod.rs @@ -28,9 +28,9 @@ mod render; pub use args::{ AckUntilArgs, AgentGetLooseEndsArgs, AnswerArgs, AskArgs, CancelLooseEndArgs, CancelScheduleArgs, CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs, - GetHostJournalArgs, GetLogsArgs, KillArgs, RecvArgs, RemindArgs, RequestInitConfigArgs, - RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs, StartArgs, UpdateArgs, - UpdateMetaInputsArgs, + GetHostJournalArgs, GetLogsArgs, KillArgs, RecvArgs, RemindArgs, RequestApplyCommitArgs, + RequestInitConfigArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs, + StartArgs, UpdateArgs, UpdateMetaInputsArgs, }; 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 \ 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. \ - Fails if a config repo for that child already exists. On approval hive-c0re seeds \ - `/agents//config/agent.nix` with the default template; customise + commit it, \ - then the operator spawns the agent. Later config changes go through a PR on the \ - child's `agent-configs/` repo, reviewed + approved by the operator." + Fails if a config repo for that child already exists — use `request_apply_commit` \ + to update an existing agent's config. On approval hive-c0re seeds \ + `/agents//config/agent.nix` with the default template so you can \ + customise it and then call `request_apply_commit` with the commit sha." )] async fn request_init_config( &self, @@ -716,6 +716,45 @@ impl AgentServer { .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, + ) -> 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 // is granted to this agent. hive-c0re enforces the topology check // server-side: the call is rejected unless `name` is a direct child. diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index b689f196..68b6bb66 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -15,10 +15,10 @@ use crate::lifecycle; /// either runs the work inline (`InitConfig`, sub-second git ops) or /// submits it to the job queue so the dashboard POST returns /// immediately while the long-running pipeline runs off-thread -/// (operator no longer blocks on a 30-90s spinner for `MergeConfigPr`). +/// (operator no longer blocks on a 30-90s spinner for `ApplyCommit`). /// /// Dispatch: -/// - `MergeConfigPr` → a single-node `ApprovalDeploy` +/// - `ApplyCommit` / `MergeConfigPr` → a single-node `ApprovalDeploy` /// DAG (the two-phase meta deploy stays opaque in v1; ~30-90s) /// - `UpdateMetaInputs` → a `MetaUpdate` DAG (fan-out on completion) /// - `Spawn` → a `Spawn` DAG (`Create → WriteDropin → Reconcile`) @@ -46,6 +46,15 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { let notes_dir = Coordinator::agent_notes_dir(&approval.agent); 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 => { // Inputs JSON-encoded into commit_ref by the manager's // submit path — surface them on the DAG so the dashboard @@ -100,11 +109,11 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { result } ApprovalKind::MergeConfigPr => { - // The work ends in a container rebuild, so route it through the - // rebuild queue. The queue worker dispatches MergeConfigPr - // approvals to `run_merge_config_pr` (verify the reviewed PR head, - // ff the forge config repo's main to it, mark merged, then the - // deploy tail). + // Like ApplyCommit, the work ends in a container rebuild, so + // route it through the rebuild queue. The queue worker + // dispatches MergeConfigPr approvals to `run_merge_config_pr` + // (verify the reviewed PR head, ff the forge config repo's + // main to it, mark merged, then the shared deploy tail). enqueue_approval_rebuild( &coord, &approval.agent, @@ -117,9 +126,10 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { } /// Submit the single-node `ApprovalDeploy` DAG tied to an approval id. -/// Used by the `MergeConfigPr` dispatch arm — the work ends in a container -/// rebuild routed through the queue; the node executor runs -/// `run_merge_config_pr`. +/// Shared by the `ApplyCommit` and `MergeConfigPr` dispatch arms — both +/// end in a container rebuild routed through the queue, differing only +/// in the `reason`. The node executor branches on the approval's kind +/// to pick the right handler. fn enqueue_approval_rebuild( coord: &Arc, agent: &str, @@ -139,9 +149,40 @@ fn enqueue_approval_rebuild( coord.emit_rebuild_queue_snapshot(); } -/// Worker entry point for `ApprovalKind::MergeConfigPr` queue entries — the -/// config-change flow's deploy worker. Re-fetches the approval row, runs the -/// merge pipeline, and fires +/// Worker entry point for `ApprovalKind::ApplyCommit` queue entries. +/// Re-fetches the approval row, runs the commit pipeline, and fires +/// `ApprovalResolved` + the lifecycle event (`Rebuilt` / `Spawned` +/// for first-spawn). +pub async fn run_approval_apply_commit( + coord: &Arc, + queue_entry_id: Option, + 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`. /// `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 @@ -177,7 +218,7 @@ pub async fn run_approval_merge_config_pr( if let Err(e) = &result { post_merge_failure_to_pr(coord, &approval, since_ts, e).await; } - finish_approval(coord, &approval, result, terminal_tag) + finish_approval(coord, &approval, result, terminal_tag, false) } /// Max stderr bytes to inline in a PR failure comment. Keeps the comment @@ -257,7 +298,7 @@ fn tail_bytes(s: &str, max_bytes: usize) -> String { /// 2. fetch the reviewed head into the applied repo so later git ops resolve /// it locally; /// 3. eval-verify the reviewed commit against the meta flake BEFORE the -/// irreversible push; +/// irreversible push (same gate `run_apply_commit` uses); /// 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 /// failure here is logged, not fatal); @@ -358,7 +399,8 @@ async fn run_merge_config_pr( Err(e) => return (Err(anyhow::anyhow!("ff-merge PR #{pr}: {e}")), None), } - // 5. Deploy tail. target == finalize == the reviewed head. + // 5. Shared deploy tail. target == finalize == the reviewed head; + // never a first spawn (the agent already exists). deploy_applied_target( coord, &approval.agent, @@ -368,6 +410,7 @@ async fn run_merge_config_pr( &reviewed, id, &prev_main_sha, + false, queue_entry_id, ) .await @@ -400,7 +443,7 @@ async fn run_approval_schedule_prompt( .context("insert scheduled prompt") } .await; - finish_approval(coord, &approval, result, None) + finish_approval(coord, &approval, result, None, false) } /// Terminal hook for approval-carrying DAGs — the job queue's @@ -454,7 +497,7 @@ pub(crate) async fn resolve_approval_dag( crate::dashboard::emit_tombstones_snapshot(coord).await; } } - if let Err(e) = finish_approval(coord, &approval, result, None) { + if let Err(e) = finish_approval(coord, &approval, result, None, false) { tracing::warn!(approval_id, error = ?e, "approval dag resolved with failure"); } } @@ -547,7 +590,7 @@ async fn run_approval_init_config( { tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_meta_remote after init_config failed"); } - finish_approval(coord, &approval, result, None) + finish_approval(coord, &approval, result, None, false) } fn finish_approval( @@ -555,6 +598,7 @@ fn finish_approval( approval: &hive_sh4re::Approval, result: Result<()>, terminal_tag: Option, + is_first_spawn: bool, ) -> Result<()> { let (status, note, ok) = match &result { Ok(()) => (ApprovalStatus::Approved, None, true), @@ -618,12 +662,25 @@ fn finish_approval( agent: approval.agent.clone(), ok, note, + sha: approval.fetched_sha.clone(), }, ), - // MergeConfigPr ends in a container rebuild — surface a Rebuilt - // lifecycle event. (It is never a first spawn — the agent already - // exists — so it never needs the Spawned arm above.) - ApprovalKind::MergeConfigPr => { + ApprovalKind::ApplyCommit if is_first_spawn => { + coord.notify_submitter( + approval.id, + &HelperEvent::Spawned { + 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( approval.id, &HelperEvent::Rebuilt { @@ -642,25 +699,148 @@ fn finish_approval( result } -/// Deploy tail for the config-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/` / -/// `failed/` 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. +/// Tag-driven `ApplyCommit` handler. Walks the approval through the tag +/// state machine documented in `docs/approvals.md`: stamp +/// `approved/` and `building/` first so the audit trail +/// captures intent, then drop the candidate tree into the working dir +/// without moving HEAD, run the rebuild, and either fast-forward +/// `applied/main` to the proposal commit on success +/// (`deployed/`) or annotate `failed/` with the build error +/// 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, + approval: &hive_sh4re::Approval, + agent_dir: &std::path::Path, + applied_dir: &std::path::Path, + queue_entry_id: Option, +) -> (Result<()>, Option, 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/, 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-` 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/` / `failed/` 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: fetching the PR head, the -/// `verify_commit` gate, the ff-merge, and forge mark-merged. `finalize_sha` -/// is the sha recorded by `meta::finalize_deploy`; `target_ref` is what -/// `applied/main` fast-forwards to. The agent always already exists here (a -/// merge is never a first spawn), so there's no `sync_agents` step — the -/// operator `Spawn` flow owns first-time meta registration. +/// Caller-specific bits stay OUT of here: the source fetch (proposal tag vs +/// forge fetch), the `approved/building` tags, `verify_commit`, and any forge +/// ff-push / mark-merged. `is_first_spawn` gates the one-time meta +/// `sync_agents` step (only `ApplyCommit`'s first spawn passes `true`; +/// the PR-merge flow always passes `false` — the agent already exists). +/// `finalize_sha` is the sha recorded by `meta::finalize_deploy`; `target_ref` +/// is what `applied/main` fast-forwards to (a proposal ref or a commit sha). #[allow( clippy::too_many_arguments, clippy::too_many_lines, - reason = "one sequential ff/deploy/rebuild/finalize pipeline; splitting it \ - would obscure the linear flow" + reason = "one sequential ff/deploy/rebuild/finalize pipeline shared by both \ + config-apply callers; splitting it would obscure the linear flow" )] async fn deploy_applied_target( coord: &Arc, @@ -671,6 +851,7 @@ async fn deploy_applied_target( finalize_sha: &str, tag_base: i64, prev_main_sha: &str, + is_first_spawn: bool, queue_entry_id: Option, ) -> (Result<()>, Option) { let id = tag_base; @@ -691,6 +872,33 @@ async fn deploy_applied_target( 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"); // Phase 1 of the meta two-phase deploy: relock without committing. if let Err(e) = crate::meta::prepare_deploy(agent).await { @@ -858,12 +1066,42 @@ async fn sync_meta_after_lifecycle(coord: &Coordinator) -> Result<()> { crate::meta::sync_agents(&coord.hive_env(), &agents).await } -pub fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> { +pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> { let approval = coord.approvals.get(id)?; coord.approvals.mark_denied(id, note)?; tracing::info!(%id, note, "approval denied"); + let mut tag = None; if let Some(a) = approval { let sha = a.fetched_sha.clone(); + // ApplyCommit approvals leave a `denied/` tag on the + // proposal commit so rejected configs are first-class git + // objects — `git show denied/` 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/ 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 sha_short = sha.as_deref().map(|s| s[..s.len().min(12)].to_owned()); let description = a.description.clone(); @@ -877,8 +1115,7 @@ pub fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> { status: ApprovalStatus::Denied, note: note.map(String::from), sha, - // A denied config PR carries no git tag — it stays open on the forge. - tag: None, + tag, }, ); coord.emit_approval_resolved(crate::coordinator::ApprovalResolved { diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 66e3fd48..ab62c459 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -423,6 +423,7 @@ pub struct ApprovalAdded<'a> { pub agent: &'a str, pub approval_kind: &'static str, pub sha_short: Option, + pub diff: Option, pub description: Option, pub pr_number: Option, } @@ -788,13 +789,15 @@ impl Coordinator { } /// Emit `ApprovalAdded` immediately after the row is inserted in - /// sqlite. + /// sqlite. Caller passes the diff text it already computed (or + /// `None` for spawn approvals which carry no diff). pub fn emit_approval_added(&self, ev: ApprovalAdded<'_>) { let ApprovalAdded { id, agent, approval_kind, sha_short, + diff, description, pr_number, } = ev; @@ -804,6 +807,7 @@ impl Coordinator { agent: agent.to_owned(), approval_kind, sha_short, + diff, description, pr_number, }); diff --git a/hive-c0re/src/dashboard/approvals.rs b/hive-c0re/src/dashboard/approvals.rs index 8c82ab5c..9581b9a2 100644 --- a/hive-c0re/src/dashboard/approvals.rs +++ b/hive-c0re/src/dashboard/approvals.rs @@ -1,8 +1,13 @@ -//! Approval endpoints for the dashboard. +//! Approval endpoints + diff machinery for the dashboard. //! -//! Approve/deny actions plus the orphan-approval GC sweep used by the -//! `/api/state` builder. +//! Approve/deny actions, the orphan-approval GC sweep used by the +//! `/api/state` builder, and the unified-diff endpoints (on-demand +//! `/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::{ extract::{Form, Path as AxumPath, State}, http::StatusCode, @@ -11,9 +16,12 @@ use axum::{ use hive_sh4re::Approval; use serde::Deserialize; -use super::{AppState, error_response}; +use problem_details::ProblemDetails; + +use super::{AppState, error_problem, error_response}; use crate::actions; use crate::coordinator::Coordinator; +use crate::lifecycle; pub(super) async fn post_approve( State(state): State, @@ -45,7 +53,7 @@ pub(super) async fn post_deny( .as_deref() .map(str::trim) .filter(|s| !s.is_empty()); - match actions::deny(&state.coord, id, note) { + match actions::deny(&state.coord, id, note).await { Ok(()) => (StatusCode::OK, "ok").into_response(), Err(e) => error_response(&format!("deny {id} failed: {e:#}")), } @@ -79,7 +87,7 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec) -> Vec 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 { + 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 `/` 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 { + 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::().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, +} + +/// On-demand unified diff for one `ApplyCommit` approval against a +/// chosen base. `applied` = `applied/main` (what's running); +/// `approved` = the most recent earlier `approved/` tag (the last +/// proposal the operator OK'd, even if its build then failed); +/// `previous` = the prior queued `proposal/` (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, + AxumPath(id): AxumPath, + axum::extract::Query(q): axum::extract::Query, +) -> Result { + 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() +} diff --git a/hive-c0re/src/dashboard/misc_api.rs b/hive-c0re/src/dashboard/misc_api.rs index 8f5fde99..eb8fcadf 100644 --- a/hive-c0re/src/dashboard/misc_api.rs +++ b/hive-c0re/src/dashboard/misc_api.rs @@ -202,7 +202,7 @@ pub(super) async fn post_request_spawn( tracing::info!(%id, %name, "operator: spawn approval queued via dashboard"); // Phase 5b: notify the dashboard event channel so live // subscribers can append the row without a snapshot - // refetch. Spawn approvals carry no sha. + // refetch. Spawn approvals carry no diff/sha. state .coord .emit_approval_added(crate::coordinator::ApprovalAdded { @@ -210,6 +210,7 @@ pub(super) async fn post_request_spawn( agent: &name, approval_kind: "spawn", sha_short: None, + diff: None, description: None, pr_number: None, }); diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index 85fb0aa2..e7d44c05 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -35,6 +35,11 @@ mod tombstones; mod topology; 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 // type feeds `DashboardEvent::MetaInputsChanged` (`dashboard_events.rs`). // Re-exported to preserve the `crate::dashboard::*` paths across the split. @@ -78,6 +83,7 @@ pub async fn serve( .route("/api/state", get(state_snapshot::api_state)) .route("/api/journal/{name}", get(journal::get_journal)) .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/matrix-accounts", diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index f46c5f2f..fb807474 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -23,7 +23,7 @@ use crate::container_view::ContainerView; use super::meta_inputs::{MetaInputView, read_meta_inputs}; use super::tombstones::{TombstoneView, build_tombstone_views}; -use super::{AppState, approvals, error_response, scan_validated_paths}; +use super::{AppState, approval_diff, approvals, error_response, scan_validated_paths}; #[allow(clippy::struct_excessive_bools)] #[derive(Serialize)] @@ -233,16 +233,31 @@ struct ApprovalView { id: i64, agent: String, kind: &'static str, - /// First 12 chars of the reviewed PR head sha, for `MergeConfigPr` - /// only. Display-only (the short chip on the card). + /// First 12 chars of the `commit_ref`, for `ApplyCommit` only. + /// Display-only (the short chip on the card). sha_short: Option, + /// 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/` 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, + /// 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 `` markup) and removes the only HTML-escape + /// surface from the snapshot. + diff: Option, /// Manager-supplied description shown on the approval card. #[serde(skip_serializing_if = "Option::is_none")] description: Option, /// Forge PR number, for `MergeConfigPr` only. Lets the frontend /// build a "review PR on forge" link - /// (`{forgeBase}/agent-configs/{agent}/pulls/{pr_number}`). `None` - /// for every other kind. + /// (`{forgeBase}/agent-configs/{agent}/pulls/{pr_number}`) the same + /// way it builds the `apply_commit` "commit on forge" link from the + /// sha. `None` for every other kind. #[serde(skip_serializing_if = "Option::is_none")] pr_number: Option, /// Raw `commit_ref` payload for `UpdateMetaInputs` (JSON-encoded @@ -337,7 +352,7 @@ pub(super) async fn api_state( log_default("approvals.pending", state.coord.approvals.pending()), ); let transients = build_transient_views(&containers, &transient_snapshot); - let approvals = build_approval_views(pending_approvals); + let approvals = build_approval_views(pending_approvals).await; let approval_history = log_default( "approvals.recent_resolved", state.coord.approvals.recent_resolved(30), @@ -529,8 +544,8 @@ fn transient_label(k: crate::coordinator::TransientKind) -> &'static str { } } -/// Render each pending approval into its dashboard view (short sha for -/// `MergeConfigPr`, just the name for `Spawn`). +/// Render each pending approval into its dashboard view (short sha + +/// unified diff for `ApplyCommit`, just the name for `Spawn`). /// Project a resolved sqlite row into the lean shape the dashboard /// history tab consumes — no `diff_html` (rendering 30 of them /// per /api/state poll would mean 30 git diffs per refresh). @@ -561,15 +576,37 @@ fn history_view(a: Approval) -> ApprovalHistoryView { } } -fn build_approval_views(approvals: Vec) -> Vec { +async fn build_approval_views(approvals: Vec) -> Vec { let mut out = Vec::with_capacity(approvals.len()); for a in approvals { 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 { id: a.id, agent: a.agent, kind: "spawn", sha_short: None, + sha_full: None, + diff: None, description: a.description, pr_number: None, commit_ref: None, @@ -580,6 +617,8 @@ fn build_approval_views(approvals: Vec) -> Vec { agent: a.agent, kind: "init_config", sha_short: None, + sha_full: None, + diff: None, description: a.description, pr_number: None, commit_ref: None, @@ -590,6 +629,8 @@ fn build_approval_views(approvals: Vec) -> Vec { agent: a.agent, kind: "update_meta_inputs", sha_short: None, + sha_full: None, + diff: None, description: a.description, pr_number: None, commit_ref: Some(a.commit_ref), @@ -600,6 +641,8 @@ fn build_approval_views(approvals: Vec) -> Vec { agent: a.agent, kind: "schedule_prompt", sha_short: None, + sha_full: None, + diff: None, description: a.description, pr_number: None, commit_ref: Some(a.commit_ref), @@ -607,8 +650,8 @@ fn build_approval_views(approvals: Vec) -> Vec { }, hive_sh4re::ApprovalKind::MergeConfigPr => { // commit_ref = PR number; fetched_sha = the reviewed PR - // head. Show the head sha; the config diff surface lives - // on the forge PR itself. + // head. Show the head sha; the forge PR diff surface is + // a later phase of the PR-based config flow — None for now. let sha = a .fetched_sha .as_deref() @@ -621,6 +664,8 @@ fn build_approval_views(approvals: Vec) -> Vec { agent: a.agent, kind: "merge_config_pr", sha_short: sha, + sha_full: None, + diff: None, description: a.description, pr_number, commit_ref: None, diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index d3fb9c92..264f30e2 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -62,10 +62,11 @@ pub enum DashboardEvent { }, /// A new approval landed in the pending queue. Payload carries /// enough to render the dashboard row without a `/api/state` - /// refetch. + /// refetch (`diff` is the raw unified diff text, same shape the + /// snapshot ships). /// - /// The approval's own kind (`"merge_config_pr"` / `"spawn"`) lives - /// on `approval_kind` rather than `kind` because the latter is taken + /// The approval's own kind (`"apply_commit"` / `"spawn"`) lives on + /// `approval_kind` rather than `kind` because the latter is taken /// by the serde tag identifying which `DashboardEvent` variant /// this is. ApprovalAdded { @@ -74,6 +75,7 @@ pub enum DashboardEvent { agent: String, approval_kind: &'static str, sha_short: Option, + diff: Option, description: Option, /// Forge PR number, for `merge_config_pr` approvals only — lets /// the live `applyApprovalAdded` path build the "review PR on @@ -348,8 +350,9 @@ mod tests { seq: 1, id: 1, agent: "x".into(), - approval_kind: "merge_config_pr", + approval_kind: "apply_commit", sha_short: None, + diff: None, description: None, pr_number: None, }, @@ -357,7 +360,7 @@ mod tests { seq: 1, id: 1, agent: "x".into(), - approval_kind: "merge_config_pr", + approval_kind: "apply_commit", sha_short: None, status: "approved", resolved_at: hive_sh4re::wire_time::from_secs(0), diff --git a/hive-c0re/src/flake_check.rs b/hive-c0re/src/flake_check.rs new file mode 100644 index 00000000..b8d16179 --- /dev/null +++ b/hive-c0re/src/flake_check.rs @@ -0,0 +1,415 @@ +//! Pre-apply validation for agent `flake.lock` files. +//! +//! Every `request_apply_commit` lands a `proposal/` 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..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, +} + +/// Read `flake.lock` from `: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> { + 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> { + 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 = 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 = 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//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//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")); + } +} diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 7d1bdc6d..f2ec537c 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -500,10 +500,11 @@ async fn run_write_perm_file( Ok(NodeOutput::default()) } -/// Opaque approval deploy pipeline for `MergeConfigPr`: verify + ff-merge the -/// reviewed PR head, then the container rebuild. The two-phase -/// prepare/finalize/abort meta deploy — and the approval resolution — stay -/// inside `actions.rs` in v1 (design doc §9). +/// Opaque approval deploy pipeline: `ApplyCommit` and `MergeConfigPr` +/// both end in a container rebuild; branch on the approval row's kind +/// (the authoritative source). The two-phase prepare/finalize/abort +/// meta deploy — and the approval resolution — stay inside +/// `actions.rs` in v1 (design doc §9). async fn run_approval_deploy(coord: &Arc, claim: &Claim) -> Result { let approval_id = claim .approval_id @@ -513,9 +514,18 @@ async fn run_approval_deploy(coord: &Arc, claim: &Claim) -> Result< // container build, and no other meta mutation may land inside that // window (it would sweep the staged lock and neuter `abort_deploy`). let _window = crate::meta::exclusive().await; - crate::actions::run_approval_merge_config_pr(coord, Some(claim.dag_id), approval_id) - .await - .map(|()| NodeOutput::default()) + let kind = coord + .approvals + .get(approval_id) + .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 diff --git a/hive-c0re/src/job_queue/model.rs b/hive-c0re/src/job_queue/model.rs index fed808a4..cf088df9 100644 --- a/hive-c0re/src/job_queue/model.rs +++ b/hive-c0re/src/job_queue/model.rs @@ -110,9 +110,9 @@ pub enum NodeKind { /// Commit `tool-groups.json` / `capabilities.json` per the DAG's /// `perm_payload` (commit fused under `META_LOCK`). WritePermFile, - /// Opaque approval deploy pipeline (`MergeConfigPr`): the two-phase - /// prepare/finalize/abort meta deploy stays inside `actions.rs` in v1 — - /// deliberately not + /// Opaque approval deploy pipeline (`ApplyCommit` / + /// `MergeConfigPr`): the two-phase prepare/finalize/abort meta + /// deploy stays inside `actions.rs` in v1 — deliberately not /// modeled as scheduler nodes (see the design doc §9). ApprovalDeploy, /// Write the agent's durable power intent (`wanted = Up` when `up`, else diff --git a/hive-c0re/src/job_queue/templates.rs b/hive-c0re/src/job_queue/templates.rs index 2d20d0df..31b2693c 100644 --- a/hive-c0re/src/job_queue/templates.rs +++ b/hive-c0re/src/job_queue/templates.rs @@ -96,9 +96,9 @@ pub fn rebuild(agent: &str, source: Source, reason: String, relock: bool) -> Dag } } -/// Approval-driven deploy (`MergeConfigPr`): the whole two-phase pipeline -/// stays one opaque node in v1 (design doc §9) — wire-visible as a `rebuild` -/// card like today. +/// Approval-driven deploy (`ApplyCommit` / `MergeConfigPr`): the whole +/// two-phase pipeline stays one opaque node in v1 (design doc §9) — +/// wire-visible as a `rebuild` card like today. pub fn approval_deploy(agent: &str, approval_id: i64, reason: String) -> DagSpec { DagSpec { template: Template::Rebuild, diff --git a/hive-c0re/src/lib.rs b/hive-c0re/src/lib.rs index bba068cf..384b8669 100644 --- a/hive-c0re/src/lib.rs +++ b/hive-c0re/src/lib.rs @@ -24,6 +24,7 @@ pub mod container_view; pub mod coordinator; pub mod dashboard; pub mod dashboard_events; +pub mod flake_check; pub mod forge; pub mod gateway_nginx; pub mod job_queue; diff --git a/hive-c0re/src/lifecycle/git.rs b/hive-c0re/src/lifecycle/git.rs index fa35696c..66567779 100644 --- a/hive-c0re/src/lifecycle/git.rs +++ b/hive-c0re/src/lifecycle/git.rs @@ -12,7 +12,7 @@ const GIT_EMAIL: &str = "c0re@hyperhive.local"; /// 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 -/// `main`, so `deployed/0` records the template, not the manager's first commit. +/// `main`, so the first `ApplyCommit` diff shows the manager's real changes. pub(super) async fn git_root_commit(dir: &Path) -> Result { let out = git_command() .current_dir(dir) @@ -71,6 +71,51 @@ pub async fn git(dir: &Path, args: &[&str]) -> Result<()> { Ok(()) } +/// Fetch the commit `sha` from the `src` git repo into `dst` and pin +/// it as `refs/tags/`. 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 :` 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 { + 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. pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result { let out = git_command() diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index 29ea64f8..8c81f0bf 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -7,8 +7,8 @@ mod setup; mod tests; pub use git::{ - git, git_command, git_read_tree_reset, git_rev_parse, git_tag, git_tag_annotated, - git_update_ref, + git, git_command, git_fetch_to_tag, git_read_tree_reset, git_rev_parse, git_tag, + git_tag_annotated, git_update_ref, }; pub use host_config::{ CONTAINER_MANAGER_AGENTS_MOUNT, CONTAINER_MANAGER_APPLIED_MOUNT, write_dropins, @@ -345,6 +345,14 @@ async fn agents_after_spawn(name: &str) -> Result> { 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> { + agents_for_meta(Some(extra)).await +} + /// Public enumeration of currently-existing agents (whatever /// `nixos-container list` says), sorted, no extras. For callers /// outside this module that need to reseed meta after lifecycle diff --git a/hive-c0re/src/lifecycle/setup.rs b/hive-c0re/src/lifecycle/setup.rs index aeeb9cc2..d1f0aaf0 100644 --- a/hive-c0re/src/lifecycle/setup.rs +++ b/hive-c0re/src/lifecycle/setup.rs @@ -44,7 +44,8 @@ pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> { // manager's ergonomics. The URL is the path inside the manager // container (`/applied//.git`), where the RO bind in // `set_nspawn_flags` makes it real. hive-c0re itself never - // dereferences this remote. + // dereferences this remote; the host-side fetch in + // `request_apply_commit` uses absolute host paths. ensure_applied_remote(proposed_dir, name).await } @@ -70,8 +71,9 @@ async fn ensure_applied_remote(proposed_dir: &Path, name: &str) -> Result<()> { /// Set up the applied repo. First-spawn only: init the repo, pull /// proposed's initial commit in via `git fetch`, tag it `deployed/0`. /// This is the *only* time hive-c0re reads from `proposed` for an -/// agent — subsequent config changes are fetched from the reviewed -/// forge PR head at merge time (see `actions::run_merge_config_pr`). +/// agent — subsequent proposals are fetched at `request_apply_commit` +/// time and tagged `proposal/` (see `actions::approve` for the +/// tag state machine). /// /// `proposed_dir` is `None` on rebuild paths where the repo already /// exists — we just verify it's the right shape and bail otherwise. @@ -98,8 +100,10 @@ pub async fn setup_applied( git(applied_dir, &["init", "--initial-branch=main"]).await?; let proposed_str = proposed.display().to_string(); // Seed the applied repo at the root (template) commit of proposed, - // not at `main`, so `deployed/0` is the template baseline rather - // than whatever the manager may have already committed on top. + // not at `main`. This ensures `deployed/0` is the template baseline + // so the first ApplyCommit diff shows the manager's real changes + // rather than an empty diff (which happens when the manager has + // already committed their config and proposed/main == proposal/). let root_sha = git_root_commit(proposed).await?; git( applied_dir, diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index ac2e35e5..678f7f49 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -168,7 +168,7 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { HostResponse::success() } HostRequest::Deny { id } => { - actions::deny(&coord, *id, None)?; + actions::deny(&coord, *id, None).await?; HostResponse::success() } HostRequest::SetParent { child, new_parent } => { @@ -247,6 +247,7 @@ async fn handle_spawn(coord: &Arc, name: &str) -> Result, name: &str) -> Result` is enough to -//! trigger hive-c0re's webhook-driven queue path. +//! (`dashboard::webhook`) — agents no longer need an MCP tool for this; +//! opening a config PR on `agent-configs/` is enough to trigger +//! hive-c0re's webhook-driven queue path. use std::sync::Arc; +use anyhow::{Context, Result}; use hive_sh4re::AgentResponse; use super::require_new_child; @@ -37,6 +40,30 @@ 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, + 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 /// carrying the JSON-encoded input list in `commit_ref` (no git commit /// is involved; the field is the payload the approval handler decodes). @@ -78,6 +105,7 @@ pub(super) fn handle_request_update_meta_inputs( agent: requester, approval_kind: "update_meta_inputs", sha_short: None, + diff: None, description: description.map(str::to_owned), pr_number: None, }); @@ -90,10 +118,10 @@ pub(super) fn handle_request_update_meta_inputs( /// /// 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 -/// against before doing anything irreversible. This does NOT fetch the commit -/// into the applied repo at submission time (that happens inside the approve -/// handler, step 2, after the drift check). No flake pre-flight either — -/// eval-verify happens at approval time too. +/// against before doing anything irreversible. Unlike `submit_apply_commit` +/// this does NOT fetch the commit into the applied repo at submission time +/// (that happens inside the approve handler, step 2, after the drift check). +/// No flake pre-flight either — eval-verify happens at approval time too. pub(crate) async fn submit_merge_config_pr( coord: &Arc, agent: &str, @@ -106,7 +134,7 @@ pub(crate) async fn submit_merge_config_pr( anyhow::bail!( "applied repo missing for agent '{agent}' (expected at {}) — \ merge_config_pr requires the agent to be fully provisioned; \ - spawn the agent first (operator spawn) before opening config PRs", + use request_apply_commit for the first config deploy", applied_dir.display() ); } @@ -171,12 +199,32 @@ pub(crate) async fn submit_merge_config_pr( agent, approval_kind: "merge_config_pr", sha_short: Some(sha_short), + diff: None, // diff is not pre-computed; the dashboard fetches it on demand description: description.map(str::to_owned), pr_number: Some(pr_number), }); 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/` 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 /// does not yet exist. Shared between the manager and agent sockets. /// @@ -199,8 +247,7 @@ pub(crate) fn submit_init_config( if proposed_dir.join(".git").exists() { anyhow::bail!( "proposed config repo for '{name}' already exists at {} - \ - nothing to init; config changes go through a forge PR on \ - agent-configs/{name}", + use request_apply_commit to update an existing agent's config", proposed_dir.display() ); } @@ -224,8 +271,176 @@ pub(crate) fn submit_init_config( agent: name, approval_kind: "init_config", sha_short: None, + diff: None, description, pr_number: None, }); 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/`. 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, + 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/ 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, ¬e); + 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 + // `: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, ¬e); + 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, ¬e); + 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/ 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()); + } +} diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index e23b411f..27a2ec15 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -29,7 +29,9 @@ pub(crate) use config_approvals::submit_merge_config_pr; pub(crate) use schedules::filter_ghost_schedule_targets; pub use schedules::schedule_to_wire_public; -use config_approvals::{handle_request_init_config, handle_request_update_meta_inputs}; +use config_approvals::{ + handle_request_apply_commit, handle_request_init_config, handle_request_update_meta_inputs, +}; use lifecycle_handlers::{ handle_kill, handle_list_descendants, handle_restart, handle_start, handle_update, }; @@ -563,6 +565,20 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> AgentRequest::RequestInitConfig { name, description } => { 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 // hive-wide `"*"` sweep require `QueryAgentState`. AgentRequest::GetLooseEnds { agent: target } => { @@ -706,9 +722,9 @@ fn require_group(agent: &str, group: &str, action: &str) -> Option) -> rusqlite::Result { // Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description. let kind: String = row.get(2)?; let kind = match kind.as_str() { + "apply_commit" => ApprovalKind::ApplyCommit, "spawn" => ApprovalKind::Spawn, "init_config" => ApprovalKind::InitConfig, "update_meta_inputs" => ApprovalKind::UpdateMetaInputs, @@ -413,6 +413,7 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result { fn kind_from_str(s: &str) -> Result { Ok(match s { + "apply_commit" => ApprovalKind::ApplyCommit, "spawn" => ApprovalKind::Spawn, "init_config" => ApprovalKind::InitConfig, "update_meta_inputs" => ApprovalKind::UpdateMetaInputs, @@ -462,15 +463,8 @@ mod tests { #[test] fn mixed_kinds_all_listed() { let (_dir, _path, db) = open_temp(); - db.submit_kind( - "a", - ApprovalKind::MergeConfigPr, - "deadbeef", - None, - "a", - None, - ) - .unwrap(); + db.submit_kind("a", ApprovalKind::ApplyCommit, "deadbeef", None, "a", None) + .unwrap(); db.submit_kind("b", ApprovalKind::Spawn, "", None, "b", None) .unwrap(); db.submit_kind("c", ApprovalKind::InitConfig, "", None, "c", None) @@ -488,7 +482,7 @@ mod tests { let id = db .submit_kind( "bitburner", - ApprovalKind::MergeConfigPr, + ApprovalKind::ApplyCommit, "cafef00d", Some("test"), "bitburner", @@ -529,7 +523,7 @@ mod tests { let good = db .submit_kind( "good", - ApprovalKind::MergeConfigPr, + ApprovalKind::ApplyCommit, "cafe", None, "good", @@ -559,7 +553,7 @@ mod tests { let id = db .submit_kind( "child", - ApprovalKind::MergeConfigPr, + ApprovalKind::ApplyCommit, "cafe", None, "parent", @@ -571,7 +565,7 @@ mod tests { let raw = Connection::open(&path).unwrap(); raw.execute( "INSERT INTO approvals (agent, kind, commit_ref, requested_at, status) - VALUES ('old', 'spawn', '', 0, 'pending')", + VALUES ('old', 'apply_commit', '', 0, 'pending')", [], ) .unwrap(); diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index f373bfdb..46bad353 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -58,10 +58,12 @@ pub struct Approval { /// Kind-specific payload (git sha / inputs array / schedule /// payload / empty). See the Approval struct doc. pub commit_ref: String, - /// The canonical hive-c0re-vouched sha. For `MergeConfigPr`: the - /// reviewed PR head pinned at submit; if the PR head drifts off it - /// before merge, hive-c0re cancels the stale approval and re-queues a - /// fresh one for re-review. + /// The canonical hive-c0re-vouched sha. For `ApplyCommit`: the sha + /// after the proposal fetch, tagged `proposal/` (stable for the + /// approval's lifetime — manager amends in proposed don't change + /// what gets built). For `MergeConfigPr`: the reviewed PR head + /// 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")] pub fetched_sha: Option, pub requested_at: DateTime, @@ -82,6 +84,9 @@ pub struct Approval { #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ApprovalKind { + /// Apply a manager-proposed config commit. + #[default] + ApplyCommit, /// Create + start a new sub-agent container with the given name /// (under the default `agent.nix` template). Spawn, @@ -95,12 +100,9 @@ pub enum ApprovalKind { SchedulePrompt, /// Merge an operator-reviewed config PR: hive-c0re verifies the /// reviewed PR head, fast-forwards the forge config repo's `main` - /// to it, marks the PR merged, then runs the deploy tail. This is the - /// sole config-change flow — a manager opens a PR on its - /// `agent-configs/` 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] + /// to it, marks the PR merged, then runs the same deploy tail as + /// `ApplyCommit`. `commit_ref` = PR number; `fetched_sha` = the + /// reviewed PR head pinned at submit. See `docs/approvals.md`. MergeConfigPr, } @@ -112,6 +114,7 @@ impl ApprovalKind { #[must_use] pub fn as_str(self) -> &'static str { match self { + ApprovalKind::ApplyCommit => "apply_commit", ApprovalKind::Spawn => "spawn", ApprovalKind::InitConfig => "init_config", ApprovalKind::UpdateMetaInputs => "update_meta_inputs", @@ -541,6 +544,13 @@ pub enum Request { /// *(privileged)* Rebuild a sub-agent against the current hyperhive /// flake + agent.nix. No approval required. 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, + }, /// *(privileged)* Fetch recent journal lines for a sub-agent container. GetLogs { agent: String, @@ -817,6 +827,8 @@ pub enum HelperEvent { ok: bool, #[serde(default, skip_serializing_if = "Option::is_none")] note: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + sha: Option, }, /// A container was rebuilt (auto-update or manual). Rebuilt { @@ -911,7 +923,8 @@ pub enum ToolGroup { Inbox, /// `kill`, `start`, `restart`, `update` - *(privileged)* Lifecycle, - /// `request_init_config`, `request_update_meta_inputs` - *(privileged)* + /// `request_init_config`, `request_apply_commit`, + /// `request_update_meta_inputs` - *(privileged)* Approvals, /// `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`, /// `edit_schedule`, `list_schedules` - *(privileged)* @@ -948,7 +961,11 @@ impl ToolGroup { "request_next_turn", ], Self::Lifecycle => &["kill", "start", "restart", "update", "list_containers"], - Self::Approvals => &["request_init_config", "request_update_meta_inputs"], + Self::Approvals => &[ + "request_init_config", + "request_apply_commit", + "request_update_meta_inputs", + ], Self::Scheduling => &[ "request_schedule_prompt", "fire_schedule_now", @@ -1049,7 +1066,7 @@ impl ToolGroup { "kill, start, restart, update, list_containers — container lifecycle (privileged)" } Self::Approvals => { - "request_init_config, request_update_meta_inputs — config change flow (privileged)" + "request_init_config, request_apply_commit, request_update_meta_inputs — config change flow (privileged)" } Self::Scheduling => { "request_schedule_prompt and related — operator-visible scheduled prompts (privileged)"