From c2bd7db998473335854351e3607eedcc9a4cd99d Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 15 Jul 2026 20:45:29 +0200 Subject: [PATCH] refactor(#2416): remove the non-pr config-change flow (request_apply_commit / applycommit) --- docs/agent-hierarchy.md | 3 +- docs/approvals.md | 243 +++++----- docs/conventions.md | 2 +- docs/coordinator.md | 4 +- docs/persistence.md | 2 +- docs/setup.md | 15 +- docs/terminal-rendering.md | 1 - docs/tools/lifecycle.md | 26 +- docs/web-ui/dashboard.md | 23 +- frontend/packages/agent/src/app.js | 4 +- frontend/packages/dashboard/src/call.js | 95 +--- hive-agent-mcp/src/mcp/args.rs | 20 +- hive-agent-mcp/src/mcp/mod.rs | 53 +-- hive-c0re/src/actions.rs | 317 ++----------- hive-c0re/src/coordinator.rs | 6 +- hive-c0re/src/dashboard/approvals.rs | 159 +------ hive-c0re/src/dashboard/misc_api.rs | 3 +- hive-c0re/src/dashboard/mod.rs | 6 - hive-c0re/src/dashboard/state_snapshot.rs | 67 +-- hive-c0re/src/dashboard_events.rs | 13 +- hive-c0re/src/flake_check.rs | 415 ------------------ hive-c0re/src/job_queue/exec.rs | 24 +- hive-c0re/src/job_queue/model.rs | 6 +- hive-c0re/src/job_queue/templates.rs | 6 +- hive-c0re/src/lib.rs | 1 - hive-c0re/src/lifecycle/git.rs | 47 +- hive-c0re/src/lifecycle/mod.rs | 12 +- hive-c0re/src/lifecycle/setup.rs | 14 +- hive-c0re/src/server.rs | 2 +- .../src/socket_server/config_approvals.rs | 239 +--------- hive-c0re/src/socket_server/mod.rs | 24 +- hive-c0re/src/socket_server/schedules.rs | 1 - hive-c0re/src/stores/approvals.rs | 34 +- hive-sh4re/src/lib.rs | 41 +- 34 files changed, 293 insertions(+), 1635 deletions(-) delete mode 100644 hive-c0re/src/flake_check.rs diff --git a/docs/agent-hierarchy.md b/docs/agent-hierarchy.md index a9ff2b9d..a88374f5 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 | -| `request_apply_commit` (any descendant's config) | any ancestor | +| config change via forge PR (any descendant's config) | any ancestor | | `get_logs` (any descendant) | any ancestor | | 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,7 +133,6 @@ 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 dfb6c486..8e5a5d6e 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -10,67 +10,55 @@ 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) - and commits with its own git identity. 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), + 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 `/agents//config/` (topology-driven via `set_nspawn_flags`; the agent's *own* config at `/agents//config/` is read-only). -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`). + 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). ### Withdrawing a pending approval @@ -88,53 +76,47 @@ agent that lacks the `approvals` tool group: only an agent with that group submits approvals (for its direct children), so an agent without it has nothing of its own to withdraw. -`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. +`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. ### Approval kinds (wire shapes) -`ApprovalKind` carries six variants; each maps to a different +`ApprovalKind` carries five variants; each maps to a different `commit_ref` encoding because that field is overloaded as the kind-specific payload carrier. -- `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, +- `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, `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 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. + 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. - `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. @@ -239,14 +221,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_apply_commit` → `meta::{prepare,finalize,abort} -_deploy`): +`actions::run_merge_config_pr` → `deploy_applied_target` → +`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 `run_apply_commit` already - fast-forwarded to `proposal/`). + `applied//main` (which the deploy already fast-forwarded to + the reviewed PR head). 2. `lifecycle::rebuild_no_meta` runs `nixos-container update --flake meta#`. Nix evaluates against the staged lock. @@ -323,34 +305,29 @@ wraps it with identity + `HIVE_PORT` / `HIVE_LABEL` / ### Tag state machine -Every approval id walks through a fixed set of tags on the -underlying commit inside the applied repo: +Each deploy leaves a tag 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) | -`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. +`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. ### Dispatch via the job queue -Long-running approval work — `ApplyCommit`, `UpdateMetaInputs`, +Long-running approval work — `MergeConfigPr`, `UpdateMetaInputs`, `Spawn` — no longer runs inline inside `actions::approve`. Instead 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` | @@ -358,9 +335,8 @@ 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 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 +node runs `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). @@ -391,22 +367,25 @@ The bundled `hive-forge` container is mandatory (it deploys with hyperhive), and hive-c0re mirrors every agent's applied repo into a 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 `request_apply_commit` -(which plants `proposal/`), every approve / deny, and a +the spawn that seeds `deployed/0`, every successful deploy (which +plants `deployed/`) or failed build (`failed/`), and a sweep at startup. Pushes are best-effort — a missing or stopped forge never blocks a deploy. -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. +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 dashboard deep-links into this org — a `config repo` link -per container row and a `commit on forge` link per approval -card. See `docs/web-ui.md`. +per container row and a `review PR on forge` link per config-PR +approval card. See `docs/web-ui.md`. ### Submitting agent's view of config repos @@ -484,8 +463,8 @@ hive-c0re creates it. The root agent's flake lives at `/var/lib/hyperhive/applied/ruth/`; its proposed config at `/var/lib/hyperhive/agents/ruth/config/`. The root agent can edit its own `agent.nix` (visible inside the container at `/agents/ruth/config/`) -and submit `request_apply_commit("ruth", )` for operator -approval. +and open a config PR on `agent-configs/ruth` for operator approval, +same as any other agent. Differences from sub-agents: @@ -559,11 +538,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 }` — `actions::approve` (first-time - ApplyCommit-kind) + admin `HostRequest::Spawn` (deprecated). +- `Spawned { agent, ok, note }` — the `Spawn` approval DAG + + admin `HostRequest::Spawn`. - `Rebuilt { agent, ok, note }` — `auto_update::rebuild_agent` (covers startup scan + manual `/rebuild` from dashboard) + - `actions::approve` (ApplyCommit). + the `MergeConfigPr` deploy. - `Killed { agent }` — admin `HostRequest::Kill` + dashboard `/kill` + the `Kill` MCP tool. - `Destroyed { agent }` — `actions::destroy`. @@ -582,9 +561,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 submit `request_apply_commit` with the commit sha to create - the container (first ApplyCommit also triggers spawn bookkeeping). + 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). - `NeedsUpdate { agent }` — sub-agent's recorded flake rev is stale. The root agent calls `update(name)` to rebuild — idempotent, no approval required. diff --git a/docs/conventions.md b/docs/conventions.md index 2c43c4af..c2918082 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_apply_commit`, `request_update_meta_inputs` *(privileged)* | +| `approvals` | `request_init_config`, `request_update_meta_inputs` *(privileged)* | | `scheduling` | `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`, `edit_schedule`, `list_schedules` *(privileged)* | | `diagnostics` | `get_logs` *(privileged)* | diff --git a/docs/coordinator.md b/docs/coordinator.md index 454f17ee..23762e3e 100644 --- a/docs/coordinator.md +++ b/docs/coordinator.md @@ -201,7 +201,7 @@ per template. ### Approvals -`ApplyCommit` / `MergeConfigPr` approvals ride as single-node +`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 - `RequestApplyCommit` path so a failed `nixos-container update` leaves no orphan + `MergeConfigPr` deploy path so a failed `nixos-container update` leaves no orphan commit in meta. Prepare writes the new lock without committing; finalize commits 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 3aee55f3..6f1dcccf 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 (apply_commit | spawn | +- `approvals` — the queue. `agent / kind (merge_config_pr | 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 c288d99b..349f8a01 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -65,12 +65,15 @@ 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, commit it, then: -request_apply_commit(agent: "iris", commit_ref: "") -# → operator approves → container built + started +# 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. ``` -See [`approvals.md`](approvals.md) for the full two-step flow. +See [`approvals.md`](approvals.md) for the full flow. ### 5 · Useful host commands @@ -97,8 +100,8 @@ See [`tools/hivectl.md`](tools/hivectl.md) for every `hivectl` verb. - **No forge admin token is stored in any agent state dir.** Agents 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 (`request_apply_commit`) go through operator - approval — agents can't unilaterally rebuild containers, by design. +- All config changes (forge PRs on `agent-configs/`) 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 d535cc3c..14a2e22e 100644 --- a/docs/terminal-rendering.md +++ b/docs/terminal-rendering.md @@ -164,7 +164,6 @@ 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 16fe257a..4e0e2681 100644 --- a/docs/tools/lifecycle.md +++ b/docs/tools/lifecycle.md @@ -45,24 +45,17 @@ Step 1 of spawning a new direct child agent. Queues an `InitConfig` approval; on operator approve, hive-c0re seeds the proposed config repo at `/agents//config/agent.nix` with a default template and delivers a `config_ready` system event. Then edit `agent.nix`, commit, -and call `request_apply_commit` with the commit sha — the first -`ApplyCommit` on a freshly-init'd config creates the container. +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`. Fails if a proposed config repo for `name` already exists. -`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. +`name` is ≤ 9 characters. ### `request_update_meta_inputs(inputs?, description?)` @@ -81,7 +74,6 @@ 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 2cbcd685..cdb0dd89 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -1066,7 +1066,6 @@ 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` | — | @@ -1080,26 +1079,16 @@ 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). No inline diff - side-panel (apply_commit-only for now). + only when `forge_present` and `pr_number` is set). The config diff + lives on the forge PR itself — no inline diff side-panel. - `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. @@ -1199,12 +1188,8 @@ 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 /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 /static/marked.js` serves the vendored `marked` bundle used + 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 6e6ff5da..48d33a6c 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_apply_commit / request_init_config / request_update_meta_inputs + // request_init_config / request_update_meta_inputs if (name.startsWith('mcp__hyperhive__request_')) return '📦'; } return '🔧'; @@ -1632,8 +1632,6 @@ 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 e79e5928..601682a6 100644 --- a/frontend/packages/dashboard/src/call.js +++ b/frontend/packages/dashboard/src/call.js @@ -130,7 +130,6 @@ 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 @@ -167,68 +166,6 @@ 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); @@ -313,7 +250,6 @@ 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'; @@ -322,13 +258,13 @@ export function renderApprovals() { // ── identity header ────────────────────────────────────────── const head = el('div', { class: 'approval-head' }, - el('span', { class: 'glyph' }, isApply ? '→' : isMergePr ? '⇒' : isUpdateMeta ? '↻' : isSchedule ? '⏱' : '⊕'), + el('span', { class: 'glyph' }, isMergePr ? '⇒' : isUpdateMeta ? '↻' : isSchedule ? '⏱' : '⊕'), el('span', { class: 'id' }, '#' + a.id), el('span', { class: 'agent' }, a.agent), - el('span', { class: 'kind' + ((isApply || isMergePr || isUpdateMeta || isSchedule) ? '' : ' kind-spawn') }, - isApply ? 'apply' : isMergePr ? 'merge-pr' : isUpdateMeta ? 'meta-update' : isSchedule ? 'schedule' : isInit ? 'init' : 'spawn'), + el('span', { class: 'kind' + ((isMergePr || isUpdateMeta || isSchedule) ? '' : ' kind-spawn') }, + isMergePr ? 'merge-pr' : isUpdateMeta ? 'meta-update' : isSchedule ? 'schedule' : isInit ? 'init' : 'spawn'), ); - if ((isApply || isMergePr) && a.sha_short) head.append(el('code', {}, a.sha_short)); + if (isMergePr && a.sha_short) head.append(el('code', {}, a.sha_short)); // When the approval was requested — relative time, right-aligned. // 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). @@ -348,24 +284,9 @@ export function renderApprovals() { if (a.description) { body.append(el('div', { class: 'approval-description' }, a.description)); } - 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. + if (isMergePr) { + // PR-based config deploy: link to the reviewed PR on the forge. + // The config diff lives on the forge PR itself. const drill = el('div', { class: 'drill-ins' }); if (forgeBase && a.pr_number != null) { drill.append(el('a', { @@ -443,7 +364,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 === 'apply_commit' ? 'apply' : a.kind === 'merge_config_pr' ? 'merge-pr' : a.kind === 'update_meta_inputs' ? 'meta-update' : a.kind === 'schedule_prompt' ? 'schedule' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ', + el('span', { class: 'kind' }, a.kind === 'merge_config_pr' ? 'merge-pr' : a.kind === 'update_meta_inputs' ? 'meta-update' : a.kind === 'schedule_prompt' ? 'schedule' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ', ); if (a.sha_short) row.append(el('code', {}, a.sha_short), ' '); row.append( diff --git a/hive-agent-mcp/src/mcp/args.rs b/hive-agent-mcp/src/mcp/args.rs index 007504cf..24ed05c3 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 and calls - /// `request_apply_commit` to pin the customised sha for the container's - /// first build. + /// 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. pub name: String, /// Optional description shown on the dashboard approval card. #[serde(default)] @@ -208,20 +208,6 @@ 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 d5837327..deb06454 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, RequestApplyCommitArgs, - RequestInitConfigArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs, - StartArgs, UpdateArgs, UpdateMetaInputsArgs, + GetHostJournalArgs, GetLogsArgs, KillArgs, RecvArgs, RemindArgs, 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 — 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." + 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." )] async fn request_init_config( &self, @@ -716,45 +716,6 @@ 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 68b6bb66..c8abf5d4 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 `ApplyCommit`). +/// (operator no longer blocks on a 30-90s spinner for `MergeConfigPr`). /// /// Dispatch: -/// - `ApplyCommit` / `MergeConfigPr` → a single-node `ApprovalDeploy` +/// - `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,15 +46,6 @@ 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 @@ -109,11 +100,11 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { result } ApprovalKind::MergeConfigPr => { - // 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). + // 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). enqueue_approval_rebuild( &coord, &approval.agent, @@ -126,10 +117,9 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { } /// Submit the single-node `ApprovalDeploy` DAG tied to an approval id. -/// 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. +/// 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`. fn enqueue_approval_rebuild( coord: &Arc, agent: &str, @@ -149,40 +139,9 @@ fn enqueue_approval_rebuild( coord.emit_rebuild_queue_snapshot(); } -/// 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 +/// 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 /// `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 @@ -218,7 +177,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, false) + finish_approval(coord, &approval, result, terminal_tag) } /// Max stderr bytes to inline in a PR failure comment. Keeps the comment @@ -298,7 +257,7 @@ fn tail_bytes(s: &str, max_bytes: usize) -> String { /// 2. fetch the reviewed head into the applied repo so later git ops resolve /// it locally; /// 3. eval-verify the reviewed commit against the meta flake BEFORE the -/// irreversible push (same gate `run_apply_commit` uses); +/// irreversible push; /// 4. fast-forward the forge repo's `main` to the reviewed head — THE merge; /// 5. mark the PR merged (best-effort: `main` is already at the head, so a /// failure here is logged, not fatal); @@ -399,8 +358,7 @@ async fn run_merge_config_pr( Err(e) => return (Err(anyhow::anyhow!("ff-merge PR #{pr}: {e}")), None), } - // 5. Shared deploy tail. target == finalize == the reviewed head; - // never a first spawn (the agent already exists). + // 5. Deploy tail. target == finalize == the reviewed head. deploy_applied_target( coord, &approval.agent, @@ -410,7 +368,6 @@ async fn run_merge_config_pr( &reviewed, id, &prev_main_sha, - false, queue_entry_id, ) .await @@ -443,7 +400,7 @@ async fn run_approval_schedule_prompt( .context("insert scheduled prompt") } .await; - finish_approval(coord, &approval, result, None, false) + finish_approval(coord, &approval, result, None) } /// Terminal hook for approval-carrying DAGs — the job queue's @@ -497,7 +454,7 @@ pub(crate) async fn resolve_approval_dag( crate::dashboard::emit_tombstones_snapshot(coord).await; } } - if let Err(e) = finish_approval(coord, &approval, result, None, false) { + if let Err(e) = finish_approval(coord, &approval, result, None) { tracing::warn!(approval_id, error = ?e, "approval dag resolved with failure"); } } @@ -590,7 +547,7 @@ async fn run_approval_init_config( { tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_meta_remote after init_config failed"); } - finish_approval(coord, &approval, result, None, false) + finish_approval(coord, &approval, result, None) } fn finish_approval( @@ -598,7 +555,6 @@ 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), @@ -665,22 +621,10 @@ fn finish_approval( sha: approval.fetched_sha.clone(), }, ), - 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 => { + // 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 => { coord.notify_submitter( approval.id, &HelperEvent::Rebuilt { @@ -699,148 +643,25 @@ fn finish_approval( result } -/// 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. +/// 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. /// -/// 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). +/// 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. #[allow( clippy::too_many_arguments, clippy::too_many_lines, - reason = "one sequential ff/deploy/rebuild/finalize pipeline shared by both \ - config-apply callers; splitting it would obscure the linear flow" + reason = "one sequential ff/deploy/rebuild/finalize pipeline; splitting it \ + would obscure the linear flow" )] async fn deploy_applied_target( coord: &Arc, @@ -851,7 +672,6 @@ 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; @@ -872,33 +692,6 @@ 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 { @@ -1066,42 +859,14 @@ async fn sync_meta_after_lifecycle(coord: &Coordinator) -> Result<()> { crate::meta::sync_agents(&coord.hive_env(), &agents).await } -pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> { +pub fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> { let approval = coord.approvals.get(id)?; coord.approvals.mark_denied(id, note)?; tracing::info!(%id, note, "approval denied"); - let mut tag = None; + // MergeConfigPr denials carry no git tag — the PR stays open on the forge. + let tag: Option = 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(); diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index ab62c459..66e3fd48 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -423,7 +423,6 @@ 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, } @@ -789,15 +788,13 @@ impl Coordinator { } /// Emit `ApprovalAdded` immediately after the row is inserted in - /// sqlite. Caller passes the diff text it already computed (or - /// `None` for spawn approvals which carry no diff). + /// sqlite. pub fn emit_approval_added(&self, ev: ApprovalAdded<'_>) { let ApprovalAdded { id, agent, approval_kind, sha_short, - diff, description, pr_number, } = ev; @@ -807,7 +804,6 @@ 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 9581b9a2..8c82ab5c 100644 --- a/hive-c0re/src/dashboard/approvals.rs +++ b/hive-c0re/src/dashboard/approvals.rs @@ -1,13 +1,8 @@ -//! Approval endpoints + diff machinery for the dashboard. +//! Approval endpoints for the dashboard. //! -//! Approve/deny actions, the orphan-approval GC sweep used by the -//! `/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). +//! Approve/deny actions plus the orphan-approval GC sweep used by the +//! `/api/state` builder. -use std::path::Path; - -use anyhow::{Context, Result}; use axum::{ extract::{Form, Path as AxumPath, State}, http::StatusCode, @@ -16,12 +11,9 @@ use axum::{ use hive_sh4re::Approval; use serde::Deserialize; -use problem_details::ProblemDetails; - -use super::{AppState, error_problem, error_response}; +use super::{AppState, error_response}; use crate::actions; use crate::coordinator::Coordinator; -use crate::lifecycle; pub(super) async fn post_approve( State(state): State, @@ -53,7 +45,7 @@ pub(super) async fn post_deny( .as_deref() .map(str::trim) .filter(|s| !s.is_empty()); - match actions::deny(&state.coord, id, note).await { + match actions::deny(&state.coord, id, note) { Ok(()) => (StatusCode::OK, "ok").into_response(), Err(e) => error_response(&format!("deny {id} failed: {e:#}")), } @@ -87,7 +79,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 eb8fcadf..8f5fde99 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 diff/sha. + // refetch. Spawn approvals carry no sha. state .coord .emit_approval_added(crate::coordinator::ApprovalAdded { @@ -210,7 +210,6 @@ 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 e7d44c05..85fb0aa2 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -35,11 +35,6 @@ 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. @@ -83,7 +78,6 @@ 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 fb807474..f46c5f2f 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, approval_diff, approvals, error_response, scan_validated_paths}; +use super::{AppState, approvals, error_response, scan_validated_paths}; #[allow(clippy::struct_excessive_bools)] #[derive(Serialize)] @@ -233,31 +233,16 @@ struct ApprovalView { id: i64, agent: String, kind: &'static str, - /// First 12 chars of the `commit_ref`, for `ApplyCommit` only. - /// Display-only (the short chip on the card). + /// First 12 chars of the reviewed PR head sha, for `MergeConfigPr` + /// 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}`) the same - /// way it builds the `apply_commit` "commit on forge" link from the - /// sha. `None` for every other kind. + /// (`{forgeBase}/agent-configs/{agent}/pulls/{pr_number}`). `None` + /// for every other kind. #[serde(skip_serializing_if = "Option::is_none")] pr_number: Option, /// Raw `commit_ref` payload for `UpdateMetaInputs` (JSON-encoded @@ -352,7 +337,7 @@ pub(super) async fn api_state( log_default("approvals.pending", state.coord.approvals.pending()), ); let transients = build_transient_views(&containers, &transient_snapshot); - let approvals = build_approval_views(pending_approvals).await; + let approvals = build_approval_views(pending_approvals); let approval_history = log_default( "approvals.recent_resolved", state.coord.approvals.recent_resolved(30), @@ -544,8 +529,8 @@ fn transient_label(k: crate::coordinator::TransientKind) -> &'static str { } } -/// Render each pending approval into its dashboard view (short sha + -/// unified diff for `ApplyCommit`, just the name for `Spawn`). +/// Render each pending approval into its dashboard view (short sha for +/// `MergeConfigPr`, 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). @@ -576,37 +561,15 @@ fn history_view(a: Approval) -> ApprovalHistoryView { } } -async fn build_approval_views(approvals: Vec) -> Vec { +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, @@ -617,8 +580,6 @@ async 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, @@ -629,8 +590,6 @@ async 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), @@ -641,8 +600,6 @@ async 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), @@ -650,8 +607,8 @@ async 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 forge PR diff surface is - // a later phase of the PR-based config flow — None for now. + // head. Show the head sha; the config diff surface lives + // on the forge PR itself. let sha = a .fetched_sha .as_deref() @@ -664,8 +621,6 @@ async 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 264f30e2..d3fb9c92 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -62,11 +62,10 @@ pub enum DashboardEvent { }, /// A new approval landed in the pending queue. Payload carries /// enough to render the dashboard row without a `/api/state` - /// refetch (`diff` is the raw unified diff text, same shape the - /// snapshot ships). + /// refetch. /// - /// The approval's own kind (`"apply_commit"` / `"spawn"`) lives on - /// `approval_kind` rather than `kind` because the latter is taken + /// The approval's own kind (`"merge_config_pr"` / `"spawn"`) lives + /// on `approval_kind` rather than `kind` because the latter is taken /// by the serde tag identifying which `DashboardEvent` variant /// this is. ApprovalAdded { @@ -75,7 +74,6 @@ 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 @@ -350,9 +348,8 @@ mod tests { seq: 1, id: 1, agent: "x".into(), - approval_kind: "apply_commit", + approval_kind: "merge_config_pr", sha_short: None, - diff: None, description: None, pr_number: None, }, @@ -360,7 +357,7 @@ mod tests { seq: 1, id: 1, agent: "x".into(), - approval_kind: "apply_commit", + approval_kind: "merge_config_pr", 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 deleted file mode 100644 index b8d16179..00000000 --- a/hive-c0re/src/flake_check.rs +++ /dev/null @@ -1,415 +0,0 @@ -//! 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 f2ec537c..7d1bdc6d 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -500,11 +500,10 @@ async fn run_write_perm_file( Ok(NodeOutput::default()) } -/// 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). +/// 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). async fn run_approval_deploy(coord: &Arc, claim: &Claim) -> Result { let approval_id = claim .approval_id @@ -514,18 +513,9 @@ 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; - 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()) + crate::actions::run_approval_merge_config_pr(coord, Some(claim.dag_id), approval_id) + .await + .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 cf088df9..fed808a4 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 (`ApplyCommit` / - /// `MergeConfigPr`): the two-phase prepare/finalize/abort meta - /// deploy stays inside `actions.rs` in v1 — deliberately not + /// Opaque approval deploy pipeline (`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 31b2693c..2d20d0df 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 (`ApplyCommit` / `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 (`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 384b8669..bba068cf 100644 --- a/hive-c0re/src/lib.rs +++ b/hive-c0re/src/lib.rs @@ -24,7 +24,6 @@ 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 66567779..fa35696c 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 the first `ApplyCommit` diff shows the manager's real changes. +/// `main`, so `deployed/0` records the template, not the manager's first commit. pub(super) async fn git_root_commit(dir: &Path) -> Result { let out = git_command() .current_dir(dir) @@ -71,51 +71,6 @@ 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 8c81f0bf..29ea64f8 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_fetch_to_tag, git_read_tree_reset, git_rev_parse, git_tag, - git_tag_annotated, git_update_ref, + git, git_command, 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,14 +345,6 @@ 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 d1f0aaf0..aeeb9cc2 100644 --- a/hive-c0re/src/lifecycle/setup.rs +++ b/hive-c0re/src/lifecycle/setup.rs @@ -44,8 +44,7 @@ pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> { // manager's ergonomics. The URL is the path inside the manager // container (`/applied//.git`), where the RO bind in // `set_nspawn_flags` makes it real. hive-c0re itself never - // dereferences this remote; the host-side fetch in - // `request_apply_commit` uses absolute host paths. + // dereferences this remote. ensure_applied_remote(proposed_dir, name).await } @@ -71,9 +70,8 @@ async fn ensure_applied_remote(proposed_dir: &Path, name: &str) -> Result<()> { /// Set up the applied repo. First-spawn only: init the repo, pull /// 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 proposals are fetched at `request_apply_commit` -/// time and tagged `proposal/` (see `actions::approve` for the -/// tag state machine). +/// agent — subsequent config changes are fetched from the reviewed +/// forge PR head at merge time (see `actions::run_merge_config_pr`). /// /// `proposed_dir` is `None` on rebuild paths where the repo already /// exists — we just verify it's the right shape and bail otherwise. @@ -100,10 +98,8 @@ pub async fn setup_applied( git(applied_dir, &["init", "--initial-branch=main"]).await?; let proposed_str = proposed.display().to_string(); // Seed the applied repo at the root (template) commit of proposed, - // 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/). + // not at `main`, so `deployed/0` is the template baseline rather + // than whatever the manager may have already committed on top. 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 678f7f49..5897db2a 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).await?; + actions::deny(&coord, *id, None)?; HostResponse::success() } HostRequest::SetParent { child, new_parent } => { diff --git a/hive-c0re/src/socket_server/config_approvals.rs b/hive-c0re/src/socket_server/config_approvals.rs index 6a3ea8f3..a0aaa987 100644 --- a/hive-c0re/src/socket_server/config_approvals.rs +++ b/hive-c0re/src/socket_server/config_approvals.rs @@ -1,17 +1,14 @@ //! Config-approval request handlers: `RequestInitConfig` / -//! `RequestApplyCommit` / `RequestUpdateMetaInputs`, -//! plus the shared submit helpers (`submit_init_config` / `submit_apply_commit` -//! / `submit_merge_config_pr`) and the commit-sha shape check -//! (`validate_commit_ref`). +//! `RequestUpdateMetaInputs`, plus the shared submit helpers +//! (`submit_init_config` / `submit_merge_config_pr`). //! //! `submit_merge_config_pr` is called from the dashboard webhook handler -//! (`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. +//! (`dashboard::webhook`) — agents no longer need an MCP tool for config +//! changes; 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; @@ -40,30 +37,6 @@ pub(super) fn handle_request_init_config( } } -/// `RequestApplyCommit` — queue an apply-commit approval for an agent. The -/// target must be in the caller's subtree (the root covers every agent). -pub(super) async fn handle_request_apply_commit( - coord: &Arc, - 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). @@ -105,7 +78,6 @@ 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, }); @@ -118,10 +90,10 @@ pub(super) fn handle_request_update_meta_inputs( /// /// The PR head sha is stored as `fetched_sha` on the approval row — the /// "reviewed sha" the approve handler (`run_merge_config_pr`) drift-gates -/// 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. +/// 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. pub(crate) async fn submit_merge_config_pr( coord: &Arc, agent: &str, @@ -134,7 +106,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; \ - use request_apply_commit for the first config deploy", + spawn the agent first (operator spawn) before opening config PRs", applied_dir.display() ); } @@ -199,32 +171,12 @@ 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. /// @@ -247,7 +199,8 @@ pub(crate) fn submit_init_config( if proposed_dir.join(".git").exists() { anyhow::bail!( "proposed config repo for '{name}' already exists at {} - \ - use request_apply_commit to update an existing agent's config", + nothing to init; config changes go through a forge PR on \ + agent-configs/{name}", proposed_dir.display() ); } @@ -271,176 +224,8 @@ 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 27a2ec15..e23b411f 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -29,9 +29,7 @@ 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_apply_commit, handle_request_init_config, handle_request_update_meta_inputs, -}; +use config_approvals::{handle_request_init_config, handle_request_update_meta_inputs}; use lifecycle_handlers::{ handle_kill, handle_list_descendants, handle_restart, handle_start, handle_update, }; @@ -565,20 +563,6 @@ 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 } => { @@ -722,9 +706,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,7 +413,6 @@ 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, @@ -463,8 +462,15 @@ mod tests { #[test] fn mixed_kinds_all_listed() { let (_dir, _path, db) = open_temp(); - db.submit_kind("a", ApprovalKind::ApplyCommit, "deadbeef", None, "a", None) - .unwrap(); + db.submit_kind( + "a", + ApprovalKind::MergeConfigPr, + "deadbeef", + None, + "a", + None, + ) + .unwrap(); db.submit_kind("b", ApprovalKind::Spawn, "", None, "b", None) .unwrap(); db.submit_kind("c", ApprovalKind::InitConfig, "", None, "c", None) @@ -482,7 +488,7 @@ mod tests { let id = db .submit_kind( "bitburner", - ApprovalKind::ApplyCommit, + ApprovalKind::MergeConfigPr, "cafef00d", Some("test"), "bitburner", @@ -523,7 +529,7 @@ mod tests { let good = db .submit_kind( "good", - ApprovalKind::ApplyCommit, + ApprovalKind::MergeConfigPr, "cafe", None, "good", @@ -553,7 +559,7 @@ mod tests { let id = db .submit_kind( "child", - ApprovalKind::ApplyCommit, + ApprovalKind::MergeConfigPr, "cafe", None, "parent", @@ -565,7 +571,7 @@ mod tests { let raw = Connection::open(&path).unwrap(); raw.execute( "INSERT INTO approvals (agent, kind, commit_ref, requested_at, status) - VALUES ('old', 'apply_commit', '', 0, 'pending')", + VALUES ('old', 'spawn', '', 0, 'pending')", [], ) .unwrap(); diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 46bad353..d23248de 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -58,12 +58,10 @@ 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 `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. + /// 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. #[serde(default, skip_serializing_if = "Option::is_none")] pub fetched_sha: Option, pub requested_at: DateTime, @@ -84,9 +82,6 @@ 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, @@ -100,9 +95,12 @@ 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 same deploy tail as - /// `ApplyCommit`. `commit_ref` = PR number; `fetched_sha` = the - /// reviewed PR head pinned at submit. See `docs/approvals.md`. + /// 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] MergeConfigPr, } @@ -114,7 +112,6 @@ 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", @@ -544,13 +541,6 @@ 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, @@ -923,8 +913,7 @@ pub enum ToolGroup { Inbox, /// `kill`, `start`, `restart`, `update` - *(privileged)* Lifecycle, - /// `request_init_config`, `request_apply_commit`, - /// `request_update_meta_inputs` - *(privileged)* + /// `request_init_config`, `request_update_meta_inputs` - *(privileged)* Approvals, /// `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`, /// `edit_schedule`, `list_schedules` - *(privileged)* @@ -961,11 +950,7 @@ impl ToolGroup { "request_next_turn", ], Self::Lifecycle => &["kill", "start", "restart", "update", "list_containers"], - Self::Approvals => &[ - "request_init_config", - "request_apply_commit", - "request_update_meta_inputs", - ], + Self::Approvals => &["request_init_config", "request_update_meta_inputs"], Self::Scheduling => &[ "request_schedule_prompt", "fire_schedule_now", @@ -1066,7 +1051,7 @@ impl ToolGroup { "kill, start, restart, update, list_containers — container lifecycle (privileged)" } Self::Approvals => { - "request_init_config, request_apply_commit, request_update_meta_inputs — config change flow (privileged)" + "request_init_config, request_update_meta_inputs — config change flow (privileged)" } Self::Scheduling => { "request_schedule_prompt and related — operator-visible scheduled prompts (privileged)"