refactor(#2416): remove the non-pr config-change flow (request_apply_commit / applycommit)
This commit is contained in:
parent
fbbd5d921c
commit
c2bd7db998
34 changed files with 293 additions and 1635 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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/<name>` repo — the same surface agents use for code PRs.
|
||||
There is no bespoke MCP tool for config changes: opening the PR IS the
|
||||
request.
|
||||
|
||||
1. The submitting agent (the child's parent, holding the `approvals`
|
||||
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/<name>` with `hive-forge`. The parent's container
|
||||
has the child's proposed config repo bind-mounted read-write at
|
||||
`/agents/<name>/config/` (topology-driven via `set_nspawn_flags`;
|
||||
the agent's *own* config at `/agents/<self>/config/` is read-only).
|
||||
2. The submitting agent submits the commit sha via `request_apply_commit(agent,
|
||||
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/<id>`.** 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 <remote> <sha>:<dst>` 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/<id>: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.<x>.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 <id>` on the CLI).
|
||||
5. hive-c0re moves the working tree to `proposal/<id>` and runs
|
||||
the build under a sequence of tags (see below). On success,
|
||||
`applied/main` fast-forwards to the proposal commit. On
|
||||
failure, main stays put and the working tree resets back to
|
||||
the previous deployed commit.
|
||||
6. `HelperEvent::ApprovalResolved` (and `Rebuilt` for the
|
||||
ApplyCommit kind) land in the **submitting agent's** inbox, carrying
|
||||
both the canonical sha and the terminal tag. Helper events route to
|
||||
the submitting agent via `notify_submitter` (the approval row carries
|
||||
a `submitter` column recording which agent called `request_apply_commit`
|
||||
or `request_init_config`).
|
||||
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 <id>` 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/<agent>` forge repo,
|
||||
hive-c0re's `/webhook/config-pr` endpoint receives the Forgejo
|
||||
pull_request event and queues this approval row. No MCP tool call
|
||||
needed — the forge PR IS the request. `commit_ref` stores the
|
||||
**PR number** (decimal), and `fetched_sha` is the PR **head sha
|
||||
at queue time** (the "reviewed" sha). On approve,
|
||||
- `MergeConfigPr` — the config-change flow. Triggered automatically:
|
||||
when an agent opens (or force-pushes) a PR on its
|
||||
`agent-configs/<agent>` forge repo, hive-c0re's `/webhook/config-pr`
|
||||
endpoint receives the Forgejo pull_request event and queues this
|
||||
approval row. No MCP tool call needed — the forge PR IS the request.
|
||||
`commit_ref` stores the **PR number** (decimal), and `fetched_sha` is
|
||||
the PR **head sha at queue time** (the "reviewed" sha). On approve,
|
||||
`run_merge_config_pr` re-reads the live PR head and aborts if it
|
||||
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#<n>`.
|
||||
|
||||
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-<n>` without
|
||||
committing. Working tree of meta now points the input at
|
||||
`applied/<n>/main` (which `run_apply_commit` already
|
||||
fast-forwarded to `proposal/<id>`).
|
||||
`applied/<n>/main` (which the deploy already fast-forwarded to
|
||||
the reviewed PR head).
|
||||
2. `lifecycle::rebuild_no_meta` runs
|
||||
`nixos-container update <c> --flake meta#<name>`. 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/<id>` | request_apply_commit, after fetch | no |
|
||||
| `approved/<id>` | operator approve | no |
|
||||
| `building/<id>` | rebuild started | no |
|
||||
| `deployed/<id>` | rebuild succeeded — `main` ff's here | no |
|
||||
| `failed/<id>` | rebuild failed | yes (body = error) |
|
||||
| `denied/<id>` | 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(<name>)` pushes `applied/main` plus
|
||||
every tag to `agent-configs/<name>` after each ref mutation:
|
||||
the spawn that seeds `deployed/0`, every `request_apply_commit`
|
||||
(which plants `proposal/<id>`), every approve / deny, and a
|
||||
the spawn that seeds `deployed/0`, every successful deploy (which
|
||||
plants `deployed/<id>`) or failed build (`failed/<id>`), 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/<n>/.git/config`; that repo is
|
||||
RO-bind-mounted into the root agent, and a stored token would leak
|
||||
core's admin credential to an agent.
|
||||
Each agent is a **write collaborator on its own** `agent-configs/<name>`
|
||||
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/<n>/.git/config`; that repo is RO-bind-mounted into the root
|
||||
agent, and a stored token would leak core's admin credential to an
|
||||
agent.
|
||||
|
||||
The dashboard deep-links into this org — a `config repo` link
|
||||
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", <sha>)` 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/<agent>/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/<agent>/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.
|
||||
|
|
|
|||
|
|
@ -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)* |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: "<sha>")
|
||||
# → 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/<name>`) 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
|
||||
|
|
|
|||
|
|
@ -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** | |
|
||||
|
|
|
|||
|
|
@ -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/<name>/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/<name>` repo (queues a `MergeConfigPr` approval on
|
||||
open/update — no MCP tool involved), not a tool call. See
|
||||
`docs/approvals.md`.
|
||||
|
||||
Fails if a proposed config repo for `name` already exists.
|
||||
`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
|
||||
|
|
|
|||
|
|
@ -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/<agent>` (shown only when `forge_present`).
|
||||
- `merge_config_pr`: `↳ review PR on forge ↗` deep-links the
|
||||
config PR into `agent-configs/<agent>/pulls/<pr_number>` (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=<host-or-container-path>` — bounded
|
||||
text read of a file under the per-agent `state/` subtree or
|
||||
the shared `/var/lib/hyperhive/shared/`. Accepts the
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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/<name>/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/<name>` repo.
|
||||
pub name: String,
|
||||
/// Optional description shown on the dashboard approval card.
|
||||
#[serde(default)]
|
||||
|
|
@ -208,20 +208,6 @@ pub struct AgentGetLooseEndsArgs {
|
|||
pub agent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct RequestApplyCommitArgs {
|
||||
/// Logical agent name whose config repo the commit lives in.
|
||||
pub agent: String,
|
||||
/// Commit sha (full or short, 7-40 hex chars) in that agent's
|
||||
/// proposed config repo. Must be a sha — a branch or tag name
|
||||
/// (e.g. `main`) is rejected; the approval pins the exact commit.
|
||||
pub commit_ref: String,
|
||||
/// Optional description shown on the dashboard approval card so the
|
||||
/// operator knows what the change does without opening the diff.
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct UpdateMetaInputsArgs {
|
||||
/// Flake input names to update (e.g. `["bitburner-agent", "nixpkgs"]`).
|
||||
|
|
|
|||
|
|
@ -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/<name>/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/<name>/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/<name>` 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<RequestApplyCommitArgs>,
|
||||
) -> String {
|
||||
let log = format!("{args:?}");
|
||||
let agent = args.agent.clone();
|
||||
let commit_ref = args.commit_ref.clone();
|
||||
run_tool_envelope("request_apply_commit", log, async move {
|
||||
let (resp, retries) = self
|
||||
.dispatch(hive_sh4re::Request::RequestApplyCommit {
|
||||
agent: args.agent,
|
||||
commit_ref: args.commit_ref,
|
||||
description: args.description,
|
||||
})
|
||||
.await;
|
||||
annotate_retries(
|
||||
format_ack(
|
||||
resp,
|
||||
"request_apply_commit",
|
||||
format!("apply approval queued for {agent} @ {commit_ref}"),
|
||||
),
|
||||
retries,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// IMPORTANT: this tool is only available when the `lifecycle` tool group
|
||||
// is granted to this agent. hive-c0re enforces the topology check
|
||||
// server-side: the call is rejected unless `name` is a direct child.
|
||||
|
|
|
|||
|
|
@ -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<Coordinator>, 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<Coordinator>, 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<Coordinator>, 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<Coordinator>,
|
||||
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<Coordinator>,
|
||||
queue_entry_id: Option<u64>,
|
||||
approval_id: i64,
|
||||
) -> Result<()> {
|
||||
let approval = fetch_approval_for_worker(coord, approval_id, ApprovalKind::ApplyCommit)?;
|
||||
// Runtime dir creation is handled inside lifecycle::rebuild_no_meta's
|
||||
// spawn path (first-spawn) or is already present for rebuilds.
|
||||
let agent_dir = crate::paths::agent_runtime_dir(&approval.agent);
|
||||
let applied_dir = crate::paths::applied_dir(&approval.agent);
|
||||
coord.set_queue_step(queue_entry_id, "apply commit");
|
||||
let (result, terminal_tag, is_first_spawn) =
|
||||
run_apply_commit(coord, &approval, &agent_dir, &applied_dir, queue_entry_id).await;
|
||||
coord.set_queue_step(queue_entry_id, "forge push");
|
||||
if let Err(e) = crate::forge::push_config(&approval.agent).await {
|
||||
tracing::warn!(agent = %approval.agent, error = ?e, "forge: push_config after apply failed");
|
||||
}
|
||||
if is_first_spawn && result.is_ok() {
|
||||
coord.set_queue_step(queue_entry_id, "first-spawn forge bootstrap");
|
||||
forge_after_first_spawn(coord, &approval.agent).await;
|
||||
}
|
||||
// `finish_approval` returns the original `result` so the queue
|
||||
// worker sees Ok/Err and marks the queue entry accordingly. The
|
||||
// approval row + helper events have already been fanned out.
|
||||
finish_approval(coord, &approval, result, terminal_tag, is_first_spawn)
|
||||
}
|
||||
|
||||
/// Worker entry point for `ApprovalKind::MergeConfigPr` queue entries —
|
||||
/// the PR-based config flow's counterpart to `run_approval_apply_commit`.
|
||||
/// Re-fetches the approval row, runs the merge pipeline, and fires
|
||||
/// 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<String>,
|
||||
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/<id>` and `building/<id>` 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/<id>`) or annotate `failed/<id>` 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<Coordinator>,
|
||||
approval: &hive_sh4re::Approval,
|
||||
agent_dir: &std::path::Path,
|
||||
applied_dir: &std::path::Path,
|
||||
queue_entry_id: Option<u64>,
|
||||
) -> (Result<()>, Option<String>, bool) {
|
||||
let id = approval.id;
|
||||
let proposal_ref = format!("refs/tags/proposal/{id}");
|
||||
|
||||
// Detect first spawn before we touch anything so we can branch on it
|
||||
// throughout this function.
|
||||
let is_first_spawn = !lifecycle::container_exists(&approval.agent).await;
|
||||
|
||||
// Defensive: submit-time should have planted proposal/<id>, but if
|
||||
// the row was migrated from an older schema or the tag got pruned
|
||||
// we fail early with a clear note rather than building a stale
|
||||
// tree.
|
||||
if let Err(e) = lifecycle::git_rev_parse(applied_dir, &proposal_ref).await {
|
||||
return (
|
||||
Err(anyhow::anyhow!(
|
||||
"missing proposal tag {proposal_ref}: {e:#}"
|
||||
)),
|
||||
None,
|
||||
is_first_spawn,
|
||||
);
|
||||
}
|
||||
|
||||
// Capture the currently-deployed sha so we can roll applied/main
|
||||
// (and the meta lock indirectly) back if the build fails.
|
||||
let prev_main_sha = match lifecycle::git_rev_parse(applied_dir, "refs/heads/main").await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return (
|
||||
Err(anyhow::anyhow!("read applied/main: {e:#}")),
|
||||
None,
|
||||
is_first_spawn,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Pre-flight eval-verify the proposal commit against the meta flake
|
||||
// WITHOUT mutating applied/main or the meta lock, so an evaluation
|
||||
// error (bad nix, missing module option, unresolvable lock) fails
|
||||
// fast here instead of after we've fast-forwarded main and have to
|
||||
// roll it back. Skipped on first spawn: the agent has no
|
||||
// `agent-<name>` meta input to override yet (sync_agents adds it
|
||||
// below). This is the reusable verify primitive the PR-based config
|
||||
// flow gates its irreversible ff-push on.
|
||||
if !is_first_spawn {
|
||||
let proposal_sha = match lifecycle::git_rev_parse(applied_dir, &proposal_ref).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return (
|
||||
Err(anyhow::anyhow!("rev-parse {proposal_ref}: {e:#}")),
|
||||
None,
|
||||
is_first_spawn,
|
||||
);
|
||||
}
|
||||
};
|
||||
coord.set_queue_step(queue_entry_id, "verify proposal (eval)");
|
||||
if let Err(e) =
|
||||
crate::meta::verify_commit(&approval.agent, applied_dir, &proposal_sha).await
|
||||
{
|
||||
return (
|
||||
Err(anyhow::anyhow!("verify proposal {proposal_ref}: {e:#}")),
|
||||
None,
|
||||
is_first_spawn,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
coord.set_queue_step(queue_entry_id, "plant tags");
|
||||
if let Err(e) = lifecycle::git_tag(applied_dir, &format!("approved/{id}"), &proposal_ref).await
|
||||
{
|
||||
return (
|
||||
Err(anyhow::anyhow!("plant approved/{id}: {e:#}")),
|
||||
None,
|
||||
is_first_spawn,
|
||||
);
|
||||
}
|
||||
if let Err(e) = lifecycle::git_tag(applied_dir, &format!("building/{id}"), &proposal_ref).await
|
||||
{
|
||||
return (
|
||||
Err(anyhow::anyhow!("plant building/{id}: {e:#}")),
|
||||
None,
|
||||
is_first_spawn,
|
||||
);
|
||||
}
|
||||
|
||||
// Fast-forward applied/main to the proposal, run the meta deploy +
|
||||
// container rebuild, and finalize/roll-back — the tail shared with the
|
||||
// PR-merge flow. ApplyCommit's target == finalize sha source is
|
||||
// `fetched_sha` (or the proposal ref when unset), matching the prior
|
||||
// inline behavior exactly.
|
||||
let (result, tag) = deploy_applied_target(
|
||||
coord,
|
||||
&approval.agent,
|
||||
agent_dir,
|
||||
applied_dir,
|
||||
&proposal_ref,
|
||||
approval.fetched_sha.as_deref().unwrap_or(&proposal_ref),
|
||||
id,
|
||||
&prev_main_sha,
|
||||
is_first_spawn,
|
||||
queue_entry_id,
|
||||
)
|
||||
.await;
|
||||
(result, tag, is_first_spawn)
|
||||
}
|
||||
|
||||
/// Shared deploy tail for config-applying approvals (`ApplyCommit` + the
|
||||
/// PR-merge flow). Fast-forwards `applied/main` to `target_ref`, syncs the
|
||||
/// working tree, runs the meta two-phase deploy + container rebuild, and
|
||||
/// plants the `deployed/<tag_base>` / `failed/<tag_base>` bookkeeping tags.
|
||||
/// On build failure it rolls `applied/main` back to `prev_main_sha` and aborts
|
||||
/// the staged meta lock so the agent stays on its last-good tree. Returns the
|
||||
/// build result + the terminal tag name.
|
||||
/// 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/<tag_base>` /
|
||||
/// `failed/<tag_base>` bookkeeping tags. On build failure it rolls
|
||||
/// `applied/main` back to `prev_main_sha` and aborts the staged meta lock so
|
||||
/// the agent stays on its last-good tree. Returns the build result + the
|
||||
/// terminal tag name.
|
||||
///
|
||||
/// Caller-specific bits stay OUT of here: the source fetch (proposal tag vs
|
||||
/// 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<Coordinator>,
|
||||
|
|
@ -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<u64>,
|
||||
) -> (Result<()>, Option<String>) {
|
||||
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<String> = None;
|
||||
if let Some(a) = approval {
|
||||
let sha = a.fetched_sha.clone();
|
||||
// ApplyCommit approvals leave a `denied/<id>` tag on the
|
||||
// proposal commit so rejected configs are first-class git
|
||||
// objects — `git show denied/<id>` in the manager's applied
|
||||
// mount yields both the tree the operator rejected and (in
|
||||
// the annotated body) the reason. Spawn approvals have no
|
||||
// commit to tag, so they fall through unannotated.
|
||||
if matches!(a.kind, ApprovalKind::ApplyCommit) {
|
||||
let applied_dir = crate::paths::applied_dir(&a.agent);
|
||||
let proposal_ref = format!("refs/tags/proposal/{id}");
|
||||
if lifecycle::git_rev_parse(&applied_dir, &proposal_ref)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
let tag_name = format!("denied/{id}");
|
||||
let body = note.unwrap_or("").to_owned();
|
||||
if let Err(e) =
|
||||
lifecycle::git_tag_annotated(&applied_dir, &tag_name, &proposal_ref, &body)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(%id, error = ?e, "plant denied tag failed");
|
||||
} else {
|
||||
tag = Some(tag_name);
|
||||
}
|
||||
}
|
||||
// Mirror the denied/<id> tag to the forge.
|
||||
if let Err(e) = crate::forge::push_config(&a.agent).await {
|
||||
tracing::warn!(%id, agent = %a.agent, error = ?e, "forge: push_config after deny failed");
|
||||
}
|
||||
}
|
||||
let approval_kind = a.kind.as_str();
|
||||
let sha_short = sha.as_deref().map(|s| s[..s.len().min(12)].to_owned());
|
||||
let description = a.description.clone();
|
||||
|
|
|
|||
|
|
@ -423,7 +423,6 @@ pub struct ApprovalAdded<'a> {
|
|||
pub agent: &'a str,
|
||||
pub approval_kind: &'static str,
|
||||
pub sha_short: Option<String>,
|
||||
pub diff: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub pr_number: Option<u64>,
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<AppState>,
|
||||
|
|
@ -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<Approval>) -> Vec<A
|
|||
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
||||
id: a.id,
|
||||
agent: &a.agent,
|
||||
approval_kind: "apply_commit",
|
||||
approval_kind: a.kind.as_str(),
|
||||
sha_short,
|
||||
status: "failed",
|
||||
note: Some(note.to_owned()),
|
||||
|
|
@ -98,142 +90,3 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<A
|
|||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Multi-file unified diff between the currently-deployed tree and
|
||||
/// the proposal for this approval. Runs against the applied repo
|
||||
/// since the canonical proposal commit lives there (manager-side
|
||||
/// amendments don't move it). Empty output means proposal == main —
|
||||
/// a no-op approval.
|
||||
///
|
||||
/// `pub(crate)` so the manager-socket handler can pre-compute the
|
||||
/// diff once at submission time and embed it in the `ApprovalAdded`
|
||||
/// dashboard event (instead of forcing the dashboard to wait a
|
||||
/// `/api/state` cycle to see the diff for newly-queued approvals).
|
||||
pub(crate) async fn approval_diff(agent: &str, approval_id: i64) -> String {
|
||||
let applied = crate::paths::applied_dir(agent);
|
||||
if !applied.join(".git").exists() {
|
||||
return format!("(no applied git repo at {})", applied.display());
|
||||
}
|
||||
let proposal_ref = format!("refs/tags/proposal/{approval_id}");
|
||||
match git_diff_refs(&applied, "refs/heads/main", &proposal_ref).await {
|
||||
Ok(s) if s.is_empty() => "(proposal matches currently-deployed tree)".to_owned(),
|
||||
Ok(s) => s,
|
||||
Err(e) => format!("(error: {e:#})"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn git_diff_refs(applied_dir: &Path, base_ref: &str, target_ref: &str) -> Result<String> {
|
||||
let out = lifecycle::git_command()
|
||||
.current_dir(applied_dir)
|
||||
.args(["diff", &format!("{base_ref}..{target_ref}")])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("spawn `git diff` in {}", applied_dir.display()))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"git diff {base_ref}..{target_ref} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// Numeric ids of `<prefix>/<n>` tags in the applied repo (e.g.
|
||||
/// `proposal/3` → `3`). Unparseable suffixes are skipped. Used to
|
||||
/// resolve the `approved` / `previous` diff bases for an approval.
|
||||
async fn tag_ids(applied_dir: &Path, prefix: &str) -> Vec<i64> {
|
||||
let Ok(out) = lifecycle::git_command()
|
||||
.current_dir(applied_dir)
|
||||
.args(["tag", "-l", &format!("{prefix}/*")])
|
||||
.output()
|
||||
.await
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !out.status.success() {
|
||||
return Vec::new();
|
||||
}
|
||||
let strip = format!("{prefix}/");
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| l.trim().strip_prefix(&strip))
|
||||
.filter_map(|s| s.parse::<i64>().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct DiffBaseQuery {
|
||||
/// `applied` (running tree — default), `approved` (most recent
|
||||
/// earlier approved proposal), or `previous` (the prior queued
|
||||
/// proposal for this agent).
|
||||
base: Option<String>,
|
||||
}
|
||||
|
||||
/// On-demand unified diff for one `ApplyCommit` approval against a
|
||||
/// chosen base. `applied` = `applied/main` (what's running);
|
||||
/// `approved` = the most recent earlier `approved/<n>` tag (the last
|
||||
/// proposal the operator OK'd, even if its build then failed);
|
||||
/// `previous` = the prior queued `proposal/<n>` (the incremental
|
||||
/// delta when the manager chains proposals). Returns the raw diff
|
||||
/// text — the dashboard classifies lines client-side.
|
||||
pub(super) async fn get_approval_diff(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<i64>,
|
||||
axum::extract::Query(q): axum::extract::Query<DiffBaseQuery>,
|
||||
) -> Result<Response, ProblemDetails> {
|
||||
let base = q.base.as_deref().unwrap_or("applied");
|
||||
let approval = match state.coord.approvals.get(id) {
|
||||
Ok(Some(a)) => a,
|
||||
Ok(None) => return Err(error_problem(&format!("approval {id} not found"))),
|
||||
Err(e) => return Err(error_problem(&format!("approval {id}: {e:#}"))),
|
||||
};
|
||||
if !matches!(approval.kind, hive_sh4re::ApprovalKind::ApplyCommit) {
|
||||
return Err(error_problem("spawn approvals carry no commit to diff"));
|
||||
}
|
||||
let applied = crate::paths::applied_dir(&approval.agent);
|
||||
if !applied.join(".git").exists() {
|
||||
return Ok(plain_text(format!(
|
||||
"(no applied git repo at {})",
|
||||
applied.display()
|
||||
)));
|
||||
}
|
||||
let target = format!("refs/tags/proposal/{id}");
|
||||
let base_ref = match base {
|
||||
"applied" => Some("refs/heads/main".to_owned()),
|
||||
"approved" => {
|
||||
let ids = tag_ids(&applied, "approved").await;
|
||||
ids.into_iter()
|
||||
.filter(|&n| n != id)
|
||||
.max()
|
||||
.map(|n| format!("refs/tags/approved/{n}"))
|
||||
}
|
||||
"previous" => {
|
||||
let ids = tag_ids(&applied, "proposal").await;
|
||||
ids.into_iter()
|
||||
.filter(|&n| n < id)
|
||||
.max()
|
||||
.map(|n| format!("refs/tags/proposal/{n}"))
|
||||
}
|
||||
other => {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail(format!("unknown diff base {other:?}")));
|
||||
}
|
||||
};
|
||||
let Some(base_ref) = base_ref else {
|
||||
return Ok(plain_text(match base {
|
||||
"approved" => "(no earlier approved proposal to diff against)".to_owned(),
|
||||
_ => "(no previous proposal to diff against)".to_owned(),
|
||||
}));
|
||||
};
|
||||
match git_diff_refs(&applied, &base_ref, &target).await {
|
||||
Ok(s) if s.is_empty() => Ok(plain_text(
|
||||
"(identical — no changes vs this base)".to_owned(),
|
||||
)),
|
||||
Ok(s) => Ok(plain_text(s)),
|
||||
Err(e) => Err(error_problem(&format!("git diff: {e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn plain_text(body: String) -> Response {
|
||||
(StatusCode::OK, body).into_response()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// Full commit sha, for `ApplyCommit` only. The frontend builds the
|
||||
/// "commit on forge" link from this rather than `sha_short`: forgejo
|
||||
/// 404s an abbreviated sha for the proposal commit (it lives on a
|
||||
/// `proposal/<id>` tag ref, which forgejo won't disambiguate a short
|
||||
/// hash against), but resolves the full 40-char sha by direct lookup.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
sha_full: Option<String>,
|
||||
/// Raw unified diff text, for `ApplyCommit` only. The client splits
|
||||
/// on `\n` and per-line classifies (`+` / `-` / `@@` / `--- ` / `+++ `
|
||||
/// → diff-add / diff-del / diff-hunk / diff-file). Shipping raw
|
||||
/// instead of pre-rendered HTML saves bytes on the wire (no
|
||||
/// per-line `<span>` markup) and removes the only HTML-escape
|
||||
/// surface from the snapshot.
|
||||
diff: Option<String>,
|
||||
/// Manager-supplied description shown on the approval card.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
/// 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<u64>,
|
||||
/// 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<Approval>) -> Vec<ApprovalView> {
|
||||
fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
|
||||
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<Approval>) -> Vec<ApprovalView> {
|
|||
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<Approval>) -> Vec<ApprovalView> {
|
|||
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<Approval>) -> Vec<ApprovalView> {
|
|||
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<Approval>) -> Vec<ApprovalView> {
|
|||
},
|
||||
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<Approval>) -> Vec<ApprovalView> {
|
|||
agent: a.agent,
|
||||
kind: "merge_config_pr",
|
||||
sha_short: sha,
|
||||
sha_full: None,
|
||||
diff: None,
|
||||
description: a.description,
|
||||
pr_number,
|
||||
commit_ref: None,
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
diff: Option<String>,
|
||||
description: Option<String>,
|
||||
/// 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),
|
||||
|
|
|
|||
|
|
@ -1,415 +0,0 @@
|
|||
//! Pre-apply validation for agent `flake.lock` files.
|
||||
//!
|
||||
//! Every `request_apply_commit` lands a `proposal/<id>` tag in the
|
||||
//! agent's applied repo before the operator sees the approval. We
|
||||
//! parse `flake.lock` from that tag's tree and reject the request if
|
||||
//! two or more nodes share an identical `original` field — that
|
||||
//! signals a missing `inputs.<X>.inputs.nixpkgs.follows = "nixpkgs"`
|
||||
//! directive in `flake.nix` and would inflate meta's lock with
|
||||
//! duplicates after deploy.
|
||||
//!
|
||||
//! The check runs on the agent repo, not meta, and catches *new*
|
||||
//! violations only. Existing agents whose lock already has duplicates
|
||||
//! are out of scope here and get a coordinated config-change pass via
|
||||
//! the manager instead.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::lifecycle::git_command;
|
||||
|
||||
/// One group of `flake.lock` nodes that all share the same canonical
|
||||
/// `original` reference. Surfaced in the rejection message so the
|
||||
/// operator (and the manager that submitted the apply) can see
|
||||
/// exactly which input pair needs a `follows` directive.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DuplicateGroup {
|
||||
/// One of the original `Value`s from the lock — used for
|
||||
/// pretty-printing in the error message. Canonical equivalence
|
||||
/// is enforced by the `BTreeMap` key in `duplicate_groups`, so
|
||||
/// we don't need to keep the canonicalised form on the struct.
|
||||
pub original: Value,
|
||||
/// Names of the flake.lock nodes that share this `original`,
|
||||
/// sorted for stable error output.
|
||||
pub keys: Vec<String>,
|
||||
}
|
||||
|
||||
/// Read `flake.lock` from `<tag>:flake.lock` in `repo`. Returns
|
||||
/// `Ok(None)` when the file isn't tracked in that tag (no inputs ⇒
|
||||
/// nothing to dedup); `Err` only on real git plumbing failures.
|
||||
async fn read_lock_at_tag(repo: &Path, tag: &str) -> Result<Option<String>> {
|
||||
let spec = format!("{tag}:flake.lock");
|
||||
let out = git_command()
|
||||
.current_dir(repo)
|
||||
.args(["show", &spec])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("git show {spec} in {}", repo.display()))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
// git uses two different messages for "path not in tree"
|
||||
// depending on whether the path also collides with an on-disk
|
||||
// file. Both translate to "no flake.lock in this commit" —
|
||||
// a legitimate, dedup-clean state for an agent with empty
|
||||
// `inputs = { }`. Any other git failure (permission denied,
|
||||
// ref-not-found, etc.) propagates as a hard error rather than
|
||||
// being silently swallowed.
|
||||
if stderr.contains("does not exist") || stderr.contains("exists on disk, but not in") {
|
||||
return Ok(None);
|
||||
}
|
||||
anyhow::bail!("git show {spec} failed: {}", stderr.trim());
|
||||
}
|
||||
Ok(Some(String::from_utf8_lossy(&out.stdout).into_owned()))
|
||||
}
|
||||
|
||||
/// Recursively serialise `v` with object keys sorted, so two
|
||||
/// JSON values that differ only in key insertion order produce the
|
||||
/// same string. `serde_json::Value` preserves `IndexMap` order by
|
||||
/// default, which is fine for parsing but breaks our group-by-key
|
||||
/// idea — hence this hand-rolled canonicaliser.
|
||||
fn canonical_json(v: &Value) -> String {
|
||||
match v {
|
||||
Value::Object(map) => {
|
||||
let mut keys: Vec<&String> = map.keys().collect();
|
||||
keys.sort();
|
||||
let mut s = String::from("{");
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push(',');
|
||||
}
|
||||
s.push_str(&serde_json::to_string(k).unwrap_or_default());
|
||||
s.push(':');
|
||||
s.push_str(&canonical_json(&map[*k]));
|
||||
}
|
||||
s.push('}');
|
||||
s
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
let mut s = String::from("[");
|
||||
for (i, x) in arr.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push(',');
|
||||
}
|
||||
s.push_str(&canonical_json(x));
|
||||
}
|
||||
s.push(']');
|
||||
s
|
||||
}
|
||||
_ => serde_json::to_string(v).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `raw` (a `flake.lock` JSON document) and return every group
|
||||
/// of nodes whose `original` field is identical. Nodes without an
|
||||
/// `original` (the synthetic `root`, or anomalous entries) are
|
||||
/// skipped. Groups with only one member are filtered out — only true
|
||||
/// duplicates surface.
|
||||
///
|
||||
/// Pure function, no I/O — covered by the unit tests below.
|
||||
pub fn duplicate_groups(raw: &str) -> Result<Vec<DuplicateGroup>> {
|
||||
let json: Value = serde_json::from_str(raw).context("parse flake.lock")?;
|
||||
let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut groups: BTreeMap<String, DuplicateGroup> = BTreeMap::new();
|
||||
for (name, node) in nodes {
|
||||
let Some(original) = node.get("original") else {
|
||||
continue;
|
||||
};
|
||||
let key = canonical_json(original);
|
||||
let entry = groups.entry(key).or_insert_with(|| DuplicateGroup {
|
||||
original: original.clone(),
|
||||
keys: Vec::new(),
|
||||
});
|
||||
entry.keys.push(name.clone());
|
||||
}
|
||||
let mut dups: Vec<DuplicateGroup> = groups.into_values().filter(|g| g.keys.len() > 1).collect();
|
||||
for g in &mut dups {
|
||||
g.keys.sort();
|
||||
}
|
||||
Ok(dups)
|
||||
}
|
||||
|
||||
/// Re-derive the agent's `flake.lock` from its `flake.nix` (in a
|
||||
/// throw-away worktree at the proposal tag) and reject the apply when
|
||||
/// the result differs from what's committed — that means the manager
|
||||
/// edited `flake.nix` but didn't commit the regenerated lock, so the
|
||||
/// shipped state lies about what nix will actually fetch.
|
||||
///
|
||||
/// Plain `nix flake lock` (no `--update-input` flags) only fills in
|
||||
/// MISSING entries; it never refreshes existing ones. So a lock that
|
||||
/// matches its `flake.nix` round-trips to a no-op, and any diff is a
|
||||
/// real "stale lock" signal.
|
||||
///
|
||||
/// Materialises the proposal tag into a temp worktree under
|
||||
/// `std::env::temp_dir()` to avoid touching `applied/<n>/main` while
|
||||
/// the check runs. Cleanup is unconditional via the inner-fn pattern.
|
||||
///
|
||||
/// Returns `Ok(())` when in sync (or there's no `flake.nix` at all);
|
||||
/// `Err` with a human-readable message on stale lock or nix tooling
|
||||
/// failure.
|
||||
pub async fn check_lock_in_sync(repo: &Path, tag: &str, approval_id: i64) -> Result<()> {
|
||||
let suffix = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos());
|
||||
let tmp_dir = std::env::temp_dir().join(format!("hive-flake-check-{approval_id}-{suffix}"));
|
||||
|
||||
// Detached worktree at the proposal tag — gives us a clean, mutable
|
||||
// copy of the agent's tree without disturbing whatever's currently
|
||||
// checked out on `applied/<n>/main`.
|
||||
let out = git_command()
|
||||
.current_dir(repo)
|
||||
.args([
|
||||
"worktree",
|
||||
"add",
|
||||
"--detach",
|
||||
&tmp_dir.to_string_lossy(),
|
||||
tag,
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("git worktree add {} {tag}", tmp_dir.display()))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"git worktree add failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
let result = lock_in_sync_inner(&tmp_dir).await;
|
||||
|
||||
// Best-effort cleanup. `git worktree remove --force` handles the
|
||||
// common case; `remove_dir_all` mops up if git decided the worktree
|
||||
// is half-gone (or if the inner work bailed before nix touched the
|
||||
// tree). Failures here are logged, not propagated — the check's
|
||||
// result is what matters.
|
||||
if let Err(e) = remove_worktree(repo, &tmp_dir).await {
|
||||
tracing::warn!(
|
||||
worktree = %tmp_dir.display(),
|
||||
error = %format!("{e:#}"),
|
||||
"flake_check: temp worktree cleanup failed"
|
||||
);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn lock_in_sync_inner(worktree: &Path) -> Result<()> {
|
||||
// No `flake.nix` means there's nothing for nix to lock — skip the
|
||||
// check cleanly (the dedup check will likewise no-op).
|
||||
if !worktree.join("flake.nix").exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let committed = tokio::fs::read_to_string(worktree.join("flake.lock"))
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// `--extra-experimental-features` mirrors `meta::nix` for hosts
|
||||
// that haven't already enabled flakes in `nix.conf`. Plain
|
||||
// `nix flake lock` (no `--update-input`) fills missing entries but
|
||||
// never refreshes existing ones — exactly the semantics we want.
|
||||
let out = Command::new("nix")
|
||||
.current_dir(worktree)
|
||||
.args([
|
||||
"--extra-experimental-features",
|
||||
"nix-command flakes",
|
||||
"flake",
|
||||
"lock",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("nix flake lock in {}", worktree.display()))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"nix flake lock failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
let regenerated = tokio::fs::read_to_string(worktree.join("flake.lock"))
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// An agent that declares inputs in flake.nix but ships no
|
||||
// flake.lock at all hits this branch (committed = None,
|
||||
// regenerated = Some(...)). That's a deliberate reject: every
|
||||
// agent with inputs MUST commit its lock, otherwise meta's
|
||||
// dedup pass has nothing to introspect and the broken state
|
||||
// leaks downstream. Treated identically to a stale lock.
|
||||
if committed.as_deref() != regenerated.as_deref() {
|
||||
anyhow::bail!(
|
||||
"flake.lock is out of sync with flake.nix — `nix flake lock` produces a different lock. \
|
||||
Run `nix flake lock` in your agent config, commit the result, and re-submit request_apply_commit."
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_worktree(repo: &Path, worktree: &Path) -> Result<()> {
|
||||
let out = git_command()
|
||||
.current_dir(repo)
|
||||
.args(["worktree", "remove", "--force", &worktree.to_string_lossy()])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("git worktree remove {}", worktree.display()))?;
|
||||
if !out.status.success() {
|
||||
// `git worktree remove` already errored — still try the raw
|
||||
// rmdir so we don't leak the dir on disk. Surface the original
|
||||
// git stderr for context.
|
||||
let _ = tokio::fs::remove_dir_all(worktree).await;
|
||||
anyhow::bail!(
|
||||
"git worktree remove failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
// git removed the worktree's metadata but the dir itself may
|
||||
// linger on stripped-down git versions — best-effort clean.
|
||||
let _ = tokio::fs::remove_dir_all(worktree).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the dedup check against the agent's freshly-applied tree.
|
||||
///
|
||||
/// `Ok(())` means either the commit doesn't carry a `flake.lock` (no
|
||||
/// inputs declared) or every node has a unique `original`. `Err`
|
||||
/// carries a multi-line message listing every duplicate group with
|
||||
/// the offending node names, suitable for surfacing on the failed
|
||||
/// approval row.
|
||||
pub async fn check_no_duplicate_inputs(repo: &Path, tag: &str) -> Result<()> {
|
||||
let Some(raw) = read_lock_at_tag(repo, tag).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let dups = duplicate_groups(&raw)?;
|
||||
if dups.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut msg = String::from(
|
||||
"flake.lock has duplicate flake inputs — add a `follows` directive in flake.nix to collapse them:\n",
|
||||
);
|
||||
for g in &dups {
|
||||
let original = serde_json::to_string(&g.original).unwrap_or_else(|_| "?".into());
|
||||
let _ = writeln!(msg, " - {original} → nodes [{}]", g.keys.join(", "));
|
||||
}
|
||||
anyhow::bail!("{}", msg.trim_end());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const CLEAN_LOCK: &str = r#"{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {"rev": "aaa"},
|
||||
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-26.05", "type": "github"}
|
||||
},
|
||||
"root": {"inputs": {"nixpkgs": "nixpkgs"}}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}"#;
|
||||
|
||||
const DUPLICATE_LOCK: &str = r#"{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {"rev": "aaa"},
|
||||
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-26.05", "type": "github"}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {"rev": "ccc"},
|
||||
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-26.05", "type": "github"}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {"rev": "ddd"},
|
||||
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-26.05", "type": "github"}
|
||||
},
|
||||
"treefmt-nix": {
|
||||
"locked": {"rev": "eee"},
|
||||
"original": {"owner": "numtide", "repo": "treefmt-nix", "type": "github"}
|
||||
},
|
||||
"treefmt-nix_2": {
|
||||
"locked": {"rev": "fff"},
|
||||
"original": {"type": "github", "owner": "numtide", "repo": "treefmt-nix"}
|
||||
},
|
||||
"root": {"inputs": {"nixpkgs": "nixpkgs"}}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn clean_lock_has_no_duplicates() {
|
||||
let dups = duplicate_groups(CLEAN_LOCK).expect("parse");
|
||||
assert!(dups.is_empty(), "expected no dups, got {dups:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_lock_reports_groups() {
|
||||
let dups = duplicate_groups(DUPLICATE_LOCK).expect("parse");
|
||||
assert_eq!(dups.len(), 2, "expected nixpkgs + treefmt-nix groups");
|
||||
|
||||
// Order is BTreeMap-stable: sorted by canonical_json key. The
|
||||
// numtide treefmt-nix key sorts before the NixOS nixpkgs one
|
||||
// because `numtide` < `NixOS` lexicographically (case-sensitive,
|
||||
// capitals come first... wait — capital N is 0x4e, lowercase n
|
||||
// is 0x6e, so capitals come first). So nixpkgs group sorts
|
||||
// first. Verify by content instead of position to avoid coupling
|
||||
// to that subtlety.
|
||||
let nixpkgs_group = dups
|
||||
.iter()
|
||||
.find(|g| g.original.get("ref").is_some())
|
||||
.expect("nixpkgs group present");
|
||||
assert_eq!(
|
||||
nixpkgs_group.keys,
|
||||
vec!["nixpkgs", "nixpkgs_2", "nixpkgs_3"]
|
||||
);
|
||||
|
||||
let treefmt_group = dups
|
||||
.iter()
|
||||
.find(|g| g.original.get("ref").is_none())
|
||||
.expect("treefmt-nix group present");
|
||||
assert_eq!(treefmt_group.keys, vec!["treefmt-nix", "treefmt-nix_2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_order_in_original_does_not_matter() {
|
||||
// The treefmt-nix and treefmt-nix_2 entries above use different
|
||||
// key orderings for `original` ({owner,repo,type} vs
|
||||
// {type,owner,repo}); duplicate_groups should still merge them.
|
||||
let dups = duplicate_groups(DUPLICATE_LOCK).expect("parse");
|
||||
let treefmt = dups
|
||||
.iter()
|
||||
.find(|g| g.keys.iter().any(|k| k == "treefmt-nix"))
|
||||
.expect("treefmt-nix group");
|
||||
assert!(treefmt.keys.contains(&"treefmt-nix_2".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nodes_without_original_are_ignored() {
|
||||
// The synthetic `root` node has no `original` and must not be
|
||||
// grouped against anything.
|
||||
let dups = duplicate_groups(CLEAN_LOCK).expect("parse");
|
||||
assert!(dups.iter().all(|g| !g.keys.iter().any(|k| k == "root")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_nodes_object_is_not_an_error() {
|
||||
// A lock file that's syntactically JSON but lacks `nodes` (e.g.
|
||||
// a partial test fixture) should fail open — no dups reported.
|
||||
let raw = r#"{"root": "root", "version": 7}"#;
|
||||
let dups = duplicate_groups(raw).expect("parse");
|
||||
assert!(dups.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_is_a_hard_error() {
|
||||
let err = duplicate_groups("not-json-at-all").unwrap_err();
|
||||
assert!(format!("{err:#}").contains("parse flake.lock"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Coordinator>, claim: &Claim) -> Result<NodeOutput> {
|
||||
let approval_id = claim
|
||||
.approval_id
|
||||
|
|
@ -514,18 +513,9 @@ async fn run_approval_deploy(coord: &Arc<Coordinator>, claim: &Claim) -> Result<
|
|||
// container build, and no other meta mutation may land inside that
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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/<tag>`. Used at `request_apply_commit` time so
|
||||
/// hive-c0re captures an immutable handle on the manager's commit;
|
||||
/// subsequent amendments / force-pushes in `src` no longer affect
|
||||
/// what gets built. Returns the resolved full sha.
|
||||
///
|
||||
/// `sha` must be a commit sha (short or full) — the caller
|
||||
/// (`submit_apply_commit`) shape-checks it first. We resolve it
|
||||
/// LOCALLY against `src` rather than asking the remote to resolve
|
||||
/// it: `git fetch <remote> <sha>:<dst>` treats the left side as a
|
||||
/// remote *ref name*, and a bare sha is not one ("couldn't find
|
||||
/// remote ref ..."). Fetching by sha would need a full 40-hex sha
|
||||
/// plus `uploadpack.allow*SHA1InWant` on the remote, which the
|
||||
/// proposed repos don't set. hive-c0re has direct read access to
|
||||
/// `src`, so a local `rev-parse` + a branch-glob fetch sidesteps
|
||||
/// the whole sha-want negotiation.
|
||||
pub async fn git_fetch_to_tag(dst: &Path, src: &Path, sha: &str, tag: &str) -> Result<String> {
|
||||
let src_str = src.display().to_string();
|
||||
// Resolve the (short-or-full) sha to a full sha against the
|
||||
// source repo. The `^{commit}` peel + non-zero exit on a missing
|
||||
// object means a typo'd / stale sha fails loudly right here.
|
||||
let full = git_rev_parse(src, &format!("{sha}^{{commit}}"))
|
||||
.await
|
||||
.with_context(|| format!("commit '{sha}' not found in proposed repo {src_str}"))?;
|
||||
// Bring src's objects into dst. Fetching every head pulls the
|
||||
// wanted commit's history (always reachable from a branch in the
|
||||
// manager's flow) into dst's object db without sha-want.
|
||||
git(
|
||||
dst,
|
||||
&[
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
&src_str,
|
||||
"+refs/heads/*:refs/remotes/proposal-src/*",
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
// Pin the exact commit as the proposal tag. The objects are now
|
||||
// local so this resolves without touching the remote.
|
||||
git(dst, &["tag", tag, &full]).await.with_context(|| {
|
||||
format!("tag {tag} at {full}: commit not reachable from any branch in proposed repo")
|
||||
})?;
|
||||
Ok(full)
|
||||
}
|
||||
|
||||
/// Resolve `refname` (a tag, branch, or sha) in `dir` to its full sha.
|
||||
pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result<String> {
|
||||
let out = git_command()
|
||||
|
|
|
|||
|
|
@ -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<Vec<crate::meta::AgentSpec>> {
|
|||
agents_for_meta(Some(name)).await
|
||||
}
|
||||
|
||||
/// Like `agents_for_meta_listing` but with an extra agent added (for a
|
||||
/// container that doesn't exist yet). Used by the first-spawn path in
|
||||
/// `actions::run_apply_commit` to register the new agent in meta before
|
||||
/// `prepare_deploy` tries to update its input lock.
|
||||
pub async fn agents_for_meta_listing_with(extra: &str) -> Result<Vec<crate::meta::AgentSpec>> {
|
||||
agents_for_meta(Some(extra)).await
|
||||
}
|
||||
|
||||
/// Public enumeration of currently-existing agents (whatever
|
||||
/// `nixos-container list` says), sorted, no extras. For callers
|
||||
/// outside this module that need to reseed meta after lifecycle
|
||||
|
|
|
|||
|
|
@ -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/<n>/.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/<id>` (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/<id>).
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
|||
HostResponse::success()
|
||||
}
|
||||
HostRequest::Deny { id } => {
|
||||
actions::deny(&coord, *id, None).await?;
|
||||
actions::deny(&coord, *id, None)?;
|
||||
HostResponse::success()
|
||||
}
|
||||
HostRequest::SetParent { child, new_parent } => {
|
||||
|
|
|
|||
|
|
@ -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/<agent>` 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/<agent>` 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<Coordinator>,
|
||||
agent: &str,
|
||||
target_agent: &str,
|
||||
commit_ref: &str,
|
||||
description: Option<&str>,
|
||||
) -> AgentResponse {
|
||||
if let Some(err) = require_new_child(agent, target_agent, "request_apply_commit for") {
|
||||
return err;
|
||||
}
|
||||
tracing::info!(%agent, %target_agent, %commit_ref, "request_apply_commit");
|
||||
match submit_apply_commit(coord, target_agent, commit_ref, description, agent).await {
|
||||
Ok((id, sha)) => {
|
||||
tracing::info!(%id, %target_agent, %sha, "apply_commit approval queued");
|
||||
AgentResponse::Ok
|
||||
}
|
||||
Err(e) => AgentResponse::Err {
|
||||
message: format!("{e:#}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `RequestUpdateMetaInputs` — queue an `UpdateMetaInputs` approval
|
||||
/// 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<Coordinator>,
|
||||
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/<id>` tag is a faithful record of the request.
|
||||
/// Accepts a 7..=40 char hex string (short or full sha); the exact
|
||||
/// commit is resolved + existence-checked against the proposed repo
|
||||
/// later in `lifecycle::git_fetch_to_tag`.
|
||||
pub(crate) fn validate_commit_ref(commit_ref: &str) -> Result<()> {
|
||||
let n = commit_ref.len();
|
||||
let hex = commit_ref.chars().all(|c| c.is_ascii_hexdigit());
|
||||
if !(7..=40).contains(&n) || !hex {
|
||||
anyhow::bail!(
|
||||
"commit_ref '{commit_ref}' is not a commit sha — request_apply_commit \
|
||||
takes a 7-40 char hex sha, not a branch or tag name"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Queue an `InitConfig` approval for a brand-new agent whose config repo
|
||||
/// 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/<id>`. From this point on the manager
|
||||
/// repo is irrelevant for this approval — even if the manager amends
|
||||
/// or force-pushes, the canonical sha hive-c0re will eventually
|
||||
/// approve/deny lives in applied's object DB.
|
||||
///
|
||||
/// If anything fails after the row is inserted (sha missing in
|
||||
/// proposed, fs error, git plumbing crash) we mark the row failed and
|
||||
/// surface the error to the manager. We don't try to roll the row
|
||||
/// back — the failure is part of the audit trail.
|
||||
pub(crate) async fn submit_apply_commit(
|
||||
coord: &Arc<Coordinator>,
|
||||
agent: &str,
|
||||
commit_ref: &str,
|
||||
description: Option<&str>,
|
||||
submitter: &str,
|
||||
) -> anyhow::Result<(i64, String)> {
|
||||
validate_commit_ref(commit_ref)?;
|
||||
let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(agent);
|
||||
let applied_dir = crate::paths::applied_dir(agent);
|
||||
if !proposed_dir.exists() {
|
||||
anyhow::bail!(
|
||||
"proposed repo missing for agent '{agent}' (expected at {})",
|
||||
proposed_dir.display()
|
||||
);
|
||||
}
|
||||
if !applied_dir.join(".git").exists() {
|
||||
// First deploy: seed the applied repo from proposed so we can plant
|
||||
// the proposal/<id> tag below. setup_applied seeds at the root
|
||||
// (template) commit of proposed, not at main, so deployed/0 is the
|
||||
// template baseline. This makes the diff mara sees on approval
|
||||
// show the manager's actual changes rather than an empty diff.
|
||||
crate::lifecycle::setup_applied(&applied_dir, Some(&proposed_dir), agent)
|
||||
.await
|
||||
.context("seed applied repo for first spawn")?;
|
||||
}
|
||||
let id = coord
|
||||
.approvals
|
||||
.submit_kind(
|
||||
agent,
|
||||
hive_sh4re::ApprovalKind::ApplyCommit,
|
||||
commit_ref,
|
||||
description,
|
||||
submitter,
|
||||
None, // sha resolved after git_fetch_to_tag below; set via set_fetched_sha
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
|
||||
let tag = format!("proposal/{id}");
|
||||
let sha =
|
||||
match crate::lifecycle::git_fetch_to_tag(&applied_dir, &proposed_dir, commit_ref, &tag)
|
||||
.await
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
// Surface the failure on the approval row so the
|
||||
// dashboard reflects it instead of leaving a phantom
|
||||
// pending entry. The note doubles as the operator-visible
|
||||
// explanation of why the approval can't be approved.
|
||||
let note = format!("{e:#}");
|
||||
let _ = coord.approvals.mark_failed(id, ¬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
|
||||
// `<tag>:flake.lock` via git — they don't share state. Order matters
|
||||
// only for early-exit + messaging: sync first means a stale lock
|
||||
// bails with the actionable "run `nix flake lock`" hint rather than
|
||||
// a dedup pass on a lock nix would never produce.
|
||||
//
|
||||
// Runs after `set_fetched_sha` so the failed row carries the sha
|
||||
// that broke. Both failure paths mark + emit, then bail.
|
||||
let sha_short = sha[..sha.len().min(12)].to_owned();
|
||||
if let Err(e) = crate::flake_check::check_lock_in_sync(&applied_dir, &tag, id).await {
|
||||
let note = format!("{e:#}");
|
||||
let _ = coord.approvals.mark_failed(id, ¬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/<id> tag to the forge.
|
||||
if let Err(e) = crate::forge::push_config(agent).await {
|
||||
tracing::warn!(%agent, %id, error = ?e, "forge: push_config after submit failed");
|
||||
}
|
||||
// Phase 5b: surface the new pending approval on the dashboard
|
||||
// event channel. Compute the diff once here so live subscribers
|
||||
// get a fully-formed row without a snapshot refetch. `sha_short`
|
||||
// is reused from the dedup gate above.
|
||||
let diff = crate::dashboard::approval_diff(agent, id).await;
|
||||
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
|
||||
id,
|
||||
agent,
|
||||
approval_kind: "apply_commit",
|
||||
sha_short: Some(sha_short),
|
||||
diff: Some(diff),
|
||||
description: description.map(str::to_owned),
|
||||
pr_number: None,
|
||||
});
|
||||
Ok((id, sha))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_short_and_full_sha() {
|
||||
assert!(validate_commit_ref("e194f78").is_ok());
|
||||
assert!(validate_commit_ref("e194f7812ab").is_ok());
|
||||
assert!(validate_commit_ref(&"a".repeat(40)).is_ok());
|
||||
// Uppercase hex resolves fine through `git rev-parse`.
|
||||
assert!(validate_commit_ref("E194F78").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_branch_and_tag_names() {
|
||||
// The exact bug class this guard exists for.
|
||||
assert!(validate_commit_ref("main").is_err());
|
||||
assert!(validate_commit_ref("HEAD").is_err());
|
||||
assert!(validate_commit_ref("deployed/0").is_err());
|
||||
assert!(validate_commit_ref("feature-branch").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_short_too_long_and_empty() {
|
||||
assert!(validate_commit_ref("").is_err());
|
||||
assert!(validate_commit_ref("abc123").is_err()); // 6 chars
|
||||
assert!(validate_commit_ref(&"a".repeat(41)).is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Coordinator>) ->
|
|||
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<AgentResponse
|
|||
}
|
||||
}
|
||||
|
||||
/// Topology guard for `request_init_config` / `request_apply_commit`,
|
||||
/// which may legitimately target a child that does not exist *yet*
|
||||
/// (spawning a brand-new sub-agent). The caller may act on a
|
||||
/// Topology guard for `request_init_config`, which may legitimately target a
|
||||
/// child that does not exist *yet* (seeding a brand-new sub-agent's config
|
||||
/// repo). The caller may act on a
|
||||
/// `target` that is EITHER already its direct child (re-init / config
|
||||
/// update of an existing child) OR brand-new (absent from the topology
|
||||
/// tree — the requester becomes its parent). A name that already
|
||||
|
|
|
|||
|
|
@ -84,7 +84,6 @@ pub(super) fn handle_request_schedule_prompt(
|
|||
agent: requester,
|
||||
approval_kind: "schedule_prompt",
|
||||
sha_short: None,
|
||||
diff: None,
|
||||
description: payload.description.clone(),
|
||||
pr_number: None,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Approval queue. Manager submits via `RequestApplyCommit`; the user
|
||||
//! approves/denies via the host admin CLI; on approval the host runs the
|
||||
//! corresponding action (Phase 5a: `lifecycle::rebuild(agent)`).
|
||||
//! Approval queue. Requests are submitted by the manager (`RequestInitConfig`
|
||||
//! / `RequestUpdateMetaInputs`), the config-PR webhook (`MergeConfigPr`), or
|
||||
//! the operator (`Spawn`); the user approves/denies via the host admin CLI;
|
||||
//! on approval the host runs the corresponding action.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
|
@ -73,9 +74,9 @@ impl Approvals {
|
|||
/// Insert a new pending approval row. `fetched_sha` may be supplied
|
||||
/// when the sha is already known at submission time (e.g. `MergeConfigPr`
|
||||
/// fetches the PR head before inserting), making the insert + sha-set
|
||||
/// atomic. Pass `None` when the sha is resolved after insertion (e.g.
|
||||
/// `ApplyCommit`'s `git_fetch_to_tag` step) and call [`set_fetched_sha`]
|
||||
/// separately.
|
||||
/// atomic. Pass `None` when the kind carries no sha (e.g. `Spawn` /
|
||||
/// `InitConfig`) or the sha is resolved after insertion, then call
|
||||
/// [`set_fetched_sha`] separately.
|
||||
pub fn submit_kind(
|
||||
&self,
|
||||
agent: &str,
|
||||
|
|
@ -366,7 +367,6 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
|
|||
// Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description.
|
||||
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<Approval> {
|
|||
|
||||
fn kind_from_str(s: &str) -> Result<ApprovalKind> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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/<id>` (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<String>,
|
||||
pub requested_at: DateTime<Utc>,
|
||||
|
|
@ -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/<agent>` repo and the operator reviews + approves it.
|
||||
/// `commit_ref` = PR number; `fetched_sha` = the reviewed PR head
|
||||
/// pinned at submit. See `docs/approvals.md`.
|
||||
#[default]
|
||||
MergeConfigPr,
|
||||
}
|
||||
|
||||
|
|
@ -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<String>,
|
||||
},
|
||||
/// *(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)"
|
||||
|
|
|
|||
Loading…
Reference in a new issue