Watch
0
0
Fork
You've already forked hyperhive
0

hive-c0re: remove the approvals subsystem

Nothing submits an approval since scheduled prompts left hive-c0re, so the
queue only acted on rows written before that.

- hive-c0re: the approvals store and its migrations, approve/deny/
  resolve actions, the ResolveApproval tail node (and meta_update's
  approval_id), POST /api/approve and /api/deny, the approval views on
  /api/state, the approval_resolved dashboard event, the orphan GC,
  destroy/purge failing pending rows, and the approval rows in
  get_loose_ends. DestroyBookkeeping loses the purge flag, which only
  worded the approval-failure note.
- The CancelLooseEnd request goes: its only hive-c0re arm was the
  approval one. cancel_loose_end handles reminder and todo in the harness.
- Wire types: hive_sh4re::approvals (ReminderStats moves to inbox),
  LooseEnd::Approval, CancelLooseEndKind, HelperEvent::ApprovalResolved,
  ToolGroup::Approvals, HostRequest::{Pending,Approve,Deny} and
  HostResponse.approvals. HOST_SOCK_VERSION goes to 2 for the removed
  field.
- hivectl loses the approvals verbs; docs/tools/hivectl-cli.md is
  regenerated.
- The system prompt drops approval_resolved.
- The approvals table stays in broker.sqlite, unread, the same way the
  operator_questions table was left when its store was removed.
- An `approvals` entry still in tool-groups.json is skipped with a warning
  by ToolGroup::parse_list, like any unknown group.
- docs/agent-lifecycle/approvals.md is renamed to config-changes.md so the
  code's doc pointers resolve; its content follows in the docs commit.
This commit is contained in:
atlas 2026-10-04 23:55:03 +02:00
commit 58ef299c3c
60 changed files with 153 additions and 1828 deletions

View file

@ -14,7 +14,7 @@ docs/tools/swarm-logs-cli.md
# indentation normalisation, so these files need manual re-wrap before # indentation normalisation, so these files need manual re-wrap before
# adding them back to the formatter scope. # adding them back to the formatter scope.
CLAUDE.md CLAUDE.md
docs/agent-lifecycle/approvals.md docs/agent-lifecycle/config-changes.md
docs/process/conventions.md docs/process/conventions.md
docs/networking/gateway.md docs/networking/gateway.md
docs/integrations/matrix.md docs/integrations/matrix.md

View file

@ -37,10 +37,6 @@ This document contains the help content for the `hivectl` command-line program.
* [`hivectl agent subvol snapshot push`↴](#hivectl-agent-subvol-snapshot-push) * [`hivectl agent subvol snapshot push`↴](#hivectl-agent-subvol-snapshot-push)
* [`hivectl list-agents`↴](#hivectl-list-agents) * [`hivectl list-agents`↴](#hivectl-list-agents)
* [`hivectl quota-enable`↴](#hivectl-quota-enable) * [`hivectl quota-enable`↴](#hivectl-quota-enable)
* [`hivectl approvals`↴](#hivectl-approvals)
* [`hivectl approvals pending`↴](#hivectl-approvals-pending)
* [`hivectl approvals approve`↴](#hivectl-approvals-approve)
* [`hivectl approvals deny`↴](#hivectl-approvals-deny)
* [`hivectl wg`↴](#hivectl-wg) * [`hivectl wg`↴](#hivectl-wg)
* [`hivectl wg init`↴](#hivectl-wg-init) * [`hivectl wg init`↴](#hivectl-wg-init)
* [`hivectl wg peer`↴](#hivectl-wg-peer) * [`hivectl wg peer`↴](#hivectl-wg-peer)
@ -66,7 +62,6 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that
* `agent` — Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running * `agent` — Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running
* `list-agents` — Show all managed agents with their status and technical state * `list-agents` — Show all managed agents with their status and technical state
* `quota-enable` — Enable btrfs qgroup accounting on the agent-state filesystem * `quota-enable` — Enable btrfs qgroup accounting on the agent-state filesystem
* `approvals` — Operator approval queue: list, approve, or deny pending requests
* `wg` — WireGuard inter-hive mesh setup helpers * `wg` — WireGuard inter-hive mesh setup helpers
* `peer-config` — Generate the federation peer-config block for THIS hive * `peer-config` — Generate the federation peer-config block for THIS hive
* `stop` — Stop containers hive-wide in one operator action * `stop` — Stop containers hive-wide in one operator action
@ -524,54 +519,6 @@ Global one-shot toggle (not per-agent), hence top-level rather than under `hivec
## `hivectl approvals`
Operator approval queue: list, approve, or deny pending requests.
Needs the hive-c0re daemon running.
**Usage:** `hivectl approvals <COMMAND>`
###### **Subcommands:**
* `pending` — List pending approval requests submitted by agents
* `approve` — Approve a pending request by id; the action runs immediately
* `deny` — Deny a pending request by id
## `hivectl approvals pending`
List pending approval requests submitted by agents
**Usage:** `hivectl approvals pending`
## `hivectl approvals approve`
Approve a pending request by id; the action runs immediately
**Usage:** `hivectl approvals approve <ID>`
###### **Arguments:**
* `<ID>` — Approval id (from `hivectl approvals pending`)
## `hivectl approvals deny`
Deny a pending request by id
**Usage:** `hivectl approvals deny <ID>`
###### **Arguments:**
* `<ID>` — Approval id
## `hivectl wg` ## `hivectl wg`
WireGuard inter-hive mesh setup helpers. WireGuard inter-hive mesh setup helpers.

View file

@ -94,7 +94,7 @@ pub struct CompactArgs {
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Privileged tool arg types (lifecycle, approvals) // Privileged tool arg types (lifecycle)
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
@ -115,8 +115,7 @@ pub struct GetAgentMetaArgs {
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct CancelLooseEndArgs { pub struct CancelLooseEndArgs {
/// Which kind of thread to cancel — `"reminder"` for a scheduled /// Which kind of thread to cancel — `"reminder"` for a scheduled
/// `remind` that hasn't fired yet, `"approval"` for a pending /// `remind` that hasn't fired yet, or `"todo"` for a loose-ends-v2 todo
/// approval you submitted, or `"todo"` for a loose-ends-v2 todo
/// (bash/matrix/forge). Use the `kind` field straight off the /// (bash/matrix/forge). Use the `kind` field straight off the
/// `get_loose_ends` row. /// `get_loose_ends` row.
pub kind: String, pub kind: String,

View file

@ -30,9 +30,8 @@ pub use args::{
pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv}; pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv};
use render::{ use render::{
dial_agent_socket, format_matrix_summary, local_reminders, local_todos, loose_end_kind_label, dial_agent_socket, format_matrix_summary, local_reminders, local_todos, mark_local_todo_done,
mark_local_todo_done, mark_local_todos_done, matrix_unread_summary, parse_loose_end_kind, mark_local_todos_done, matrix_unread_summary, render_loose_ends, reply_err,
render_loose_ends, reply_err,
}; };
/// Write (or remove) the status file in the agent's own `state/` directory. /// Write (or remove) the status file in the agent's own `state/` directory.
@ -231,9 +230,7 @@ impl AgentServer {
#[tool( #[tool(
description = "List loose ends pending against this agent: pending reminders \ description = "List loose ends pending against this agent: pending reminders \
you scheduled, plus — for the manager only — pending \ you scheduled. Also lists active \
approvals you submitted that the operator hasn't acted on yet (agents with the \
`approvals` tool group also see their own pending approvals). Also lists active \
local tasks published by external MCP daemons (e.g. running bash tasks). Cheap sweep, no args. Useful \ local tasks published by external MCP daemons (e.g. running bash tasks). Cheap sweep, no args. Useful \
at turn start to remember what you owe / what's owed to you without scrolling \ at turn start to remember what you owe / what's owed to you without scrolling \
inbox history. Output is a short bulleted list with ids, ages in seconds, and \ inbox history. Output is a short bulleted list with ids, ages in seconds, and \
@ -346,9 +343,6 @@ impl AgentServer {
you scheduled (hard-deleted before it fires). `kind` is `\"reminder\"`; \ you scheduled (hard-deleted before it fires). `kind` is `\"reminder\"`; \
`id` is the row id from the matching `get_loose_ends` entry. Auth: you can only \ `id` is the row id from the matching `get_loose_ends` entry. Auth: you can only \
cancel rows where you're the owner. Returns `ok` or an error string.\n\ cancel rows where you're the owner. Returns `ok` or an error string.\n\
`kind` may also be `\"approval\"` to withdraw a pending approval you submitted \
(before the operator acts on it) — root agent (`ruth`) only; the server rejects \
`approval` kind for all other callers.\n\
`kind` may also be `\"todo\"` to clear one of your own loose-ends-v2 todos (the id \ `kind` may also be `\"todo\"` to clear one of your own loose-ends-v2 todos (the id \
from a `get_loose_ends` `todo #N` line) — dials the in-agent socket directly, safe \ from a `get_loose_ends` `todo #N` line) — dials the in-agent socket directly, safe \
to call repeatedly.")] to call repeatedly.")]
@ -373,42 +367,26 @@ impl AgentServer {
.to_owned(), .to_owned(),
}; };
} }
let kind = match parse_loose_end_kind(&args.kind) { let kind = args.kind.trim().to_ascii_lowercase();
Ok(k) => k, if !matches!(kind.as_str(), "reminder" | "r") {
Err(e) => return e, return format!(
}; "cancel_loose_end: unknown kind '{kind}' (expected \"reminder\" or \"todo\")"
let kind_label = loose_end_kind_label(kind); );
// Reminders are harness-local — dial the in-agent socket }
// directly instead of the broker; the other kind (approval) // Reminders are harness-local — dial the in-agent socket directly.
// still lives in c0re. match dial_agent_socket(&hive_agent_sock::Request::CancelReminder { id }).await {
if kind == hive_sh4re::inbox::CancelLooseEndKind::Reminder {
return match dial_agent_socket(&hive_agent_sock::Request::CancelReminder { id })
.await
{
Some(hive_agent_sock::Response::Acked { count }) if count > 0 => { Some(hive_agent_sock::Response::Acked { count }) if count > 0 => {
format!("cancelled {kind_label} {id}") format!("cancelled reminder {id}")
} }
Some(hive_agent_sock::Response::Acked { .. }) => { Some(hive_agent_sock::Response::Acked { .. }) => {
format!("cancel_loose_end failed: no pending {kind_label} {id}") format!("cancel_loose_end failed: no pending reminder {id}")
} }
Some(hive_agent_sock::Response::Err { message }) => { Some(hive_agent_sock::Response::Err { message }) => {
format!("cancel_loose_end failed: {message}") format!("cancel_loose_end failed: {message}")
} }
Some(other) => format!("cancel_loose_end unexpected response: {other:?}"), Some(other) => format!("cancel_loose_end unexpected response: {other:?}"),
None => "cancel_loose_end failed: in-agent socket unavailable".to_owned(), None => "cancel_loose_end failed: in-agent socket unavailable".to_owned(),
};
} }
let (resp, retries) = self
.dispatch(hive_core_agent_sock::Request::CancelLooseEnd { kind, id })
.await;
annotate_retries(
format_ack(
resp,
"cancel_loose_end",
format!("cancelled {kind_label} {id}"),
),
retries,
)
}) })
.await .await
} }

View file

@ -152,8 +152,8 @@ fn msg_id_tag(id: i64) -> String {
} }
/// Hard cap on how many individual `Todo` lines `render_loose_ends` will /// Hard cap on how many individual `Todo` lines `render_loose_ends` will
/// emit. Approvals/reminders stay naturally bounded (they're /// emit. Reminders stay naturally bounded (they're triaged interactively
/// triaged interactively and don't self-multiply), but todos are pushed by /// and don't self-multiply), but todos are pushed by
/// unattended producers — matrix/bash/forge are the built-in ones, but any /// unattended producers — matrix/bash/forge are the built-in ones, but any
/// user-configured MCP server can push its own too — an agent that goes a long /// user-configured MCP server can push its own too — an agent that goes a long
/// stretch without calling `get_loose_ends`, or whose producers outpace its /// stretch without calling `get_loose_ends`, or whose producers outpace its
@ -178,22 +178,6 @@ const MAX_RENDERED_TODOS: usize = 40;
fn render_one_loose_end(out: &mut String, t: &hive_sh4re::inbox::LooseEnd) { fn render_one_loose_end(out: &mut String, t: &hive_sh4re::inbox::LooseEnd) {
use std::fmt::Write as _; use std::fmt::Write as _;
match t { match t {
hive_sh4re::inbox::LooseEnd::Approval {
id,
agent,
commit_ref,
description,
age_seconds,
} => {
let desc = description
.as_deref()
.map(|d| format!(" — {d}"))
.unwrap_or_default();
let _ = writeln!(
out,
"- approval #{id} ({agent} @ {commit_ref}, {age_seconds}s old){desc}"
);
}
hive_sh4re::inbox::LooseEnd::Reminder { hive_sh4re::inbox::LooseEnd::Reminder {
id, id,
owner, owner,
@ -438,33 +422,6 @@ pub(super) fn format_matrix_summary(rooms: &[MatrixRoomUnread]) -> String {
out out
} }
/// Parse the user-facing `kind` string for `cancel_loose_end` into the
/// wire enum. Accepts a small alias set so claude doesn't have to
/// remember the exact spelling (`"q"` / `"r"` shorthand falls out
/// for free).
pub(super) fn parse_loose_end_kind(
raw: &str,
) -> Result<hive_sh4re::inbox::CancelLooseEndKind, String> {
match raw.trim().to_ascii_lowercase().as_str() {
"reminder" | "r" => Ok(hive_sh4re::inbox::CancelLooseEndKind::Reminder),
"approval" | "a" => Ok(hive_sh4re::inbox::CancelLooseEndKind::Approval),
other => Err(format!(
"cancel_loose_end: unknown kind '{other}' \
(expected \"reminder\", \"approval\", or \"todo\")"
)),
}
}
/// Canonical user-facing label for a `CancelLooseEndKind` — used in
/// the success ack so the caller always sees `"reminder"` instead of
/// whatever alias they passed in (`"r"`).
pub(super) fn loose_end_kind_label(kind: hive_sh4re::inbox::CancelLooseEndKind) -> &'static str {
match kind {
hive_sh4re::inbox::CancelLooseEndKind::Reminder => "reminder",
hive_sh4re::inbox::CancelLooseEndKind::Approval => "approval",
}
}
/// Format helper for `get_agent_meta`: renders an agent's identity + /// Format helper for `get_agent_meta`: renders an agent's identity +
/// current status as a short human-readable block. `name`, /// current status as a short human-readable block. `name`,
/// `hyperhive_rev`, and `running` are always shown; `status` only /// `hyperhive_rev`, and `running` are always shown; `status` only

View file

@ -149,7 +149,7 @@ pub enum Response {
PendingRemindersCount { count: u64 }, PendingRemindersCount { count: u64 },
/// `ReminderRollup` result. /// `ReminderRollup` result.
ReminderRollup { ReminderRollup {
stats: hive_sh4re::approvals::ReminderStats, stats: hive_sh4re::inbox::ReminderStats,
}, },
/// Op failed; `message` is operator-facing. /// Op failed; `message` is operator-facing.
Err { message: String }, Err { message: String },

View file

@ -9,13 +9,12 @@ Need new packages, env vars, or other NixOS config for yourself? You can't edit
Your config repo is mounted **read-only** at `/agents/{label}/config/` — `agent.nix` plus whatever extra files define you (declared packages, env vars, MCP servers). Read it to see exactly what defines you before asking for a change, so you can point at the precise file and line. Your config repo is mounted **read-only** at `/agents/{label}/config/` — `agent.nix` plus whatever extra files define you (declared packages, env vars, MCP servers). Read it to see exactly what defines you before asking for a change, so you can point at the precise file and line.
Approval boundary: starting, stopping and rebuilding containers is the operator's — ask them when any agent needs one. _Creating_ a new agent is not something you can do from here — ask the operator, who scaffolds the new agent's config repo and spawns it from the dashboard. _Changing_ any agent's config is not a tool call at all — it's a forge PR on the agent's `agent-configs/<name>` repo, which an operator reviews and merges on the forge; the merge deploys it. The operator only signs off on changes; you run the day-to-day. Operator boundary: starting, stopping and rebuilding containers is the operator's — ask them when any agent needs one. _Creating_ a new agent is not something you can do from here — ask the operator, who scaffolds the new agent's config repo and spawns it from the dashboard. _Changing_ any agent's config is not a tool call at all — it's a forge PR on the agent's `agent-configs/<name>` repo, which an operator reviews and merges on the forge; the merge deploys it. The operator only signs off on changes; you run the day-to-day.
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `container_crash`, `needs_update`. Use these to react to lifecycle changes: Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `container_crash`, `needs_update`. Use these to react to lifecycle changes:
- `needs_update` — agent's flake rev is stale. Ask the operator to rebuild it. - `needs_update` — agent's flake rev is stale. Ask the operator to rebuild it.
- `container_crash` — ask the operator to start it again; if it keeps crashing, say so with what you saw. - `container_crash` — ask the operator to start it again; if it keeps crashing, say so with what you saw.
- `approval_resolved` — one of your own submitted approvals was approved, denied, or failed; the body carries the resolution.
Lifecycle notices that don't need an immediate turn — a new agent spawned, a container rebuilt/killed/destroyed, or its login state changing — surface as todos instead of messages now. Call `get_loose_ends` to see them. Lifecycle notices that don't need an immediate turn — a new agent spawned, a container rebuilt/killed/destroyed, or its login state changing — surface as todos instead of messages now. Call `get_loose_ends` to see them.

View file

@ -18,7 +18,7 @@ use std::sync::Mutex;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use hive_sh4re::approvals::ReminderStats; use hive_sh4re::inbox::ReminderStats;
use hive_sh4re::wire_time; use hive_sh4re::wire_time;
use rusqlite::{Connection, params}; use rusqlite::{Connection, params};

View file

@ -15,7 +15,7 @@ use chrono::Utc;
use rusqlite::{Connection, OpenFlags}; use rusqlite::{Connection, OpenFlags};
use serde::Serialize; use serde::Serialize;
use hive_sh4re::approvals::ReminderStats; use hive_sh4re::inbox::ReminderStats;
/// Window param accepted by `/api/stats?window=`. Each maps to a /// Window param accepted by `/api/stats?window=`. Each maps to a
/// total span + the bucket width used to roll up trend series. /// total span + the bucket width used to roll up trend series.

View file

@ -29,7 +29,7 @@ pub(super) async fn api_stats(
/// `HIVE_AGENT_SOCKET` — was a broker RPC before reminders moved /// `HIVE_AGENT_SOCKET` — was a broker RPC before reminders moved
/// in-container. Returns `None` on any transport / decode failure or /// in-container. Returns `None` on any transport / decode failure or
/// when the socket is unset — the stats are decorative, not authoritative. /// when the socket is unset — the stats are decorative, not authoritative.
async fn fetch_reminder_stats(window_secs: u64) -> Option<hive_sh4re::approvals::ReminderStats> { async fn fetch_reminder_stats(window_secs: u64) -> Option<hive_sh4re::inbox::ReminderStats> {
match crate::todo_server::dial(&hive_agent_sock::Request::ReminderRollup { match crate::todo_server::dial(&hive_agent_sock::Request::ReminderRollup {
since_secs: window_secs, since_secs: window_secs,
}) })

View file

@ -1,7 +1,7 @@
# hive-c0re # hive-c0re
The unprivileged host daemon (runs as `hive-core`). Owns the sqlite The unprivileged host daemon (runs as `hive-core`). Owns the sqlite
broker, the approval/reminder queues, the generic job-DAG broker, the reminder queue, the generic job-DAG
queue, container lifecycle, gateway vhost wiring, forge/matrix config queue, container lifecycle, gateway vhost wiring, forge/matrix config
reconciliation, per-container stats, and the axum operator dashboard. reconciliation, per-container stats, and the axum operator dashboard.
Largest crate in the workspace — bin-only, no separate lib. Largest crate in the workspace — bin-only, no separate lib.
@ -9,7 +9,7 @@ Largest crate in the workspace — bin-only, no separate lib.
## When to use it ## When to use it
Host-level, cross-container orchestration: spawning/rebuilding/ Host-level, cross-container orchestration: spawning/rebuilding/
destroying agent containers, the approval flow, dashboard-visible destroying agent containers, dashboard-visible
state, wiring each agent's gateway vhost and reconciling its forge state, wiring each agent's gateway vhost and reconciling its forge
config-repo access and matrix room membership. The accounts themselves config-repo access and matrix room membership. The accounts themselves
are swarm-controller's (`docs/integrations/forge.md`). Agent-side are swarm-controller's (`docs/integrations/forge.md`). Agent-side
@ -25,7 +25,7 @@ subdirectory a module actually lives in). One line each — read the
module's own `//!` doc-comment for real detail, don't expect this file module's own `//!` doc-comment for real detail, don't expect this file
to track it: to track it:
- **`dashboard/`** — the operator dashboard (containers, approvals, - **`dashboard/`** — the operator dashboard (containers,
schedules, logs, topology). schedules, logs, topology).
- **`job_queue/`** — the job-DAG queue + desired-state reconciliation - **`job_queue/`** — the job-DAG queue + desired-state reconciliation
(`docs/scheduler/coordinator.md`). (`docs/scheduler/coordinator.md`).

View file

@ -6,53 +6,10 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::{Context as _, Result, bail}; use anyhow::{Context as _, Result, bail};
use hive_sh4re::approvals::{ApprovalKind, ApprovalStatus};
use hive_sh4re::manager::HelperEvent;
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
use crate::lifecycle; use crate::lifecycle;
/// Approve a pending request. Marks the approval row durably, then submits
/// the work to the job queue so the dashboard POST returns immediately while
/// the long-running pipeline runs off-thread.
///
/// Dispatch:
/// - `UpdateMetaInputs` → a `MetaUpdate` DAG (fan-out on completion)
///
/// Every queued kind resolves its approval row via
/// [`resolve_approval_dag`] when the DAG settles terminal.
pub fn approve(coord: &Arc<Coordinator>, id: i64) -> Result<()> {
let approval = coord.approvals.mark_approved(id)?;
tracing::info!(
%approval.id,
%approval.agent,
kind = ?approval.kind,
%approval.commit_ref,
"approval: dispatching",
);
match approval.kind {
ApprovalKind::UpdateMetaInputs => {
// Inputs JSON-encoded into commit_ref by the manager's
// submit path — surface them on the DAG so the dashboard
// can show *which* inputs are about to bump. The cascade
// rebuilds fan out when the lock bump lands (so they build
// against the post-bump lock, and a failed bump fans out
// nothing).
let inputs: Vec<String> =
serde_json::from_str(&approval.commit_ref).unwrap_or_default();
let inserted = coord.job_queue.insert_job(|b| {
crate::job_queue::templates::meta_update(b, inputs, Some(id));
Vec::new()
});
if let Err(e) = inserted {
return Err(e.context("insert meta-update dag"));
}
coord.emit_rebuild_queue_snapshot();
Ok(())
}
}
}
/// `applied/main`, and whether `target` descends from it. Fast-forwarding /// `applied/main`, and whether `target` descends from it. Fast-forwarding
/// `main` to a `target` that does not is a rewind: it drops every commit on /// `main` to a `target` that does not is a rewind: it drops every commit on
/// `main` that `target` lacks. /// `main` that `target` lacks.
@ -149,90 +106,6 @@ pub(crate) async fn post_rev_deploy_failure_to_pr(agent: &str, rev: &str, err: &
} }
} }
/// Resolve an approval row from how its DAG's work ended — the body of the
/// [`NodeKind::ResolveApproval`] tail node. Every approval-carrying template
/// resolves here: the pipeline is ordinary queue nodes,
/// so the work's terminal state is the authoritative outcome and there's no
/// in-node resolution to skip around.
///
/// `outcome` is the one the calling node was built to report — a template emits
/// one tail per outcome, so success, failure and cancel each arrive here from
/// their own node rather than from one node branching.
///
/// [`NodeKind::ResolveApproval`]: crate::job_queue::NodeKind::ResolveApproval
pub(crate) fn resolve_approval_dag(
coord: &Arc<Coordinator>,
approval_id: i64,
outcome: crate::job_queue::TerminalState,
error: Option<&str>,
) {
use crate::job_queue::TerminalState;
let approval = match coord.approvals.get(approval_id) {
Ok(Some(a)) => a,
Ok(None) => {
tracing::warn!(approval_id, "approval dag terminal: row no longer exists");
return;
}
Err(e) => {
tracing::warn!(approval_id, error = ?e, "approval dag terminal: row read failed");
return;
}
};
let result: Result<()> = match outcome {
TerminalState::Done => Ok(()),
// `Skipped` never reaches here — no template emits a tail for it — but it
// reads the same to an operator either way: the work did not happen.
TerminalState::Cancelled | TerminalState::Skipped => {
Err(anyhow::anyhow!("cancelled before completion"))
}
TerminalState::Failed => Err(anyhow::anyhow!("{}", error.unwrap_or("job dag failed"))),
};
if let Err(e) = finish_approval(coord, &approval, result) {
tracing::warn!(approval_id, error = ?e, "approval dag resolved with failure");
}
}
fn finish_approval(
coord: &Coordinator,
approval: &hive_sh4re::approvals::Approval,
result: Result<()>,
) -> Result<()> {
let (status, note, ok) = match &result {
Ok(()) => (ApprovalStatus::Approved, None, true),
Err(e) => {
let note = format!("{e:#}");
let _ = coord.approvals.mark_failed(approval.id, &note);
(ApprovalStatus::Failed, Some(note), false)
}
};
coord.notify_submitter(
approval.id,
&HelperEvent::ApprovalResolved {
id: approval.id,
agent: approval.agent.to_string(),
commit_ref: approval.commit_ref.clone(),
status,
note: note.clone(),
},
);
// Phase 5b: also fire on the dashboard event channel so the
// browser moves the row out of pending into history without a
// snapshot refetch. `approved` rows that succeed get the
// approval's logged resolved_at indirectly via `Utc::now()`;
// failures already wrote it via mark_failed above.
let approval_kind = <&str>::from(approval.kind);
let status_str = if ok { "approved" } else { "failed" };
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id: approval.id,
agent: approval.agent.as_str(),
approval_kind,
status: status_str,
note: note.clone(),
description: approval.description.clone(),
});
result
}
/// Fast-forward `applied/main` to `target` and sync the working tree. /// Fast-forward `applied/main` to `target` and sync the working tree.
/// ///
/// Compare-and-swap, not a bare set: `main` must still be `expected_main`, the /// Compare-and-swap, not a bare set: `main` must still be `expected_main`, the
@ -284,36 +157,6 @@ pub fn destroy(coord: &Arc<Coordinator>, name: &str, purge: bool) {
coord.emit_rebuild_queue_snapshot(); coord.emit_rebuild_queue_snapshot();
} }
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");
if let Some(a) = approval {
let approval_kind = <&str>::from(a.kind);
let description = a.description.clone();
let agent_owned = a.agent.clone();
coord.notify_submitter(
a.id,
&HelperEvent::ApprovalResolved {
id: a.id,
agent: a.agent.to_string(),
commit_ref: a.commit_ref,
status: ApprovalStatus::Denied,
note: note.map(String::from),
},
);
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id,
agent: agent_owned.as_str(),
approval_kind,
status: "denied",
note: note.map(String::from),
description,
});
}
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{RevAdvance, advance_applied_main}; use super::{RevAdvance, advance_applied_main};

View file

@ -10,7 +10,6 @@ use std::sync::{Arc, Mutex};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use tokio::sync::{broadcast, watch}; use tokio::sync::{broadcast, watch};
use crate::approvals::Approvals;
use crate::broker::Broker; use crate::broker::Broker;
use crate::container_view::{self, ContainerView}; use crate::container_view::{self, ContainerView};
use crate::dashboard_events::DashboardEvent; use crate::dashboard_events::DashboardEvent;
@ -30,7 +29,6 @@ const LAST_STOPPED_RUNNING_KEY: &str = "last_stopped_running";
pub struct Coordinator { pub struct Coordinator {
pub broker: Arc<Broker>, pub broker: Arc<Broker>,
pub approvals: Arc<Approvals>,
/// Scheduled-prompt rows, read-only: the dashboard lists them so the /// Scheduled-prompt rows, read-only: the dashboard lists them so the
/// operator can recreate them with `swarmctl schedule`. Nothing on /// operator can recreate them with `swarmctl schedule`. Nothing on
/// the hive writes or fires them. /// the hive writes or fires them.
@ -170,7 +168,7 @@ pub struct Coordinator {
/// Unified wire-facing event channel feeding the dashboard SSE /// Unified wire-facing event channel feeding the dashboard SSE
/// stream. Carries broker messages (mirrored from `broker.subscribe` /// stream. Carries broker messages (mirrored from `broker.subscribe`
/// by the forwarder task in `main.rs`) and dashboard-only mutation /// by the forwarder task in `main.rs`) and dashboard-only mutation
/// events (approval added/resolved, etc.). /// events (container state, rebuild queue, etc.).
/// Snapshot endpoints capture `event_seq` before reading state so /// Snapshot endpoints capture `event_seq` before reading state so
/// the client can dedupe its buffered live traffic against the /// the client can dedupe its buffered live traffic against the
/// snapshot. /// snapshot.
@ -413,19 +411,6 @@ impl Drop for MetaUpdateGuard {
} }
} }
/// Field-named payload for [`Coordinator::emit_approval_resolved`].
/// Mirrors the `ApprovalResolved` dashboard-event fields. `agent`
/// borrows from the caller; `approval_kind` / `status` are
/// compile-time constants.
pub struct ApprovalResolved<'a> {
pub id: i64,
pub agent: &'a str,
pub approval_kind: &'static str,
pub status: &'static str,
pub note: Option<String>,
pub description: Option<String>,
}
/// The two ways [`Coordinator::set_paused_by_name`] can fail. See its own /// The two ways [`Coordinator::set_paused_by_name`] can fail. See its own
/// doc comment for why this is a distinct type rather than one /// doc comment for why this is a distinct type rather than one
/// `anyhow::Error` (`Write` already wraps one, `BadName` never needs to). /// `anyhow::Error` (`Write` already wraps one, `BadName` never needs to).
@ -467,7 +452,6 @@ impl Coordinator {
agent_io_weight, agent_io_weight,
} = env; } = env;
let broker = Broker::open(db_path).context("open broker")?; let broker = Broker::open(db_path).context("open broker")?;
let approvals = Approvals::open(db_path).context("open approvals")?;
let scheduled_prompts = crate::scheduled_prompts::ScheduledPrompts::open(db_path) let scheduled_prompts = crate::scheduled_prompts::ScheduledPrompts::open(db_path)
.context("open scheduled_prompts")?; .context("open scheduled_prompts")?;
// BuildLogs wants a directory (it picks its own `build_logs.sqlite` // BuildLogs wants a directory (it picks its own `build_logs.sqlite`
@ -488,7 +472,6 @@ impl Coordinator {
let (shutdown_tx, _) = watch::channel(false); let (shutdown_tx, _) = watch::channel(false);
Ok(Self { Ok(Self {
broker: Arc::new(broker), broker: Arc::new(broker),
approvals: Arc::new(approvals),
scheduled_prompts: Arc::new(scheduled_prompts), scheduled_prompts: Arc::new(scheduled_prompts),
build_logs, build_logs,
hyperhive_flake, hyperhive_flake,
@ -730,41 +713,6 @@ impl Coordinator {
self.meta_updates_active.load(Ordering::SeqCst) > 0 self.meta_updates_active.load(Ordering::SeqCst) > 0
} }
/// Emit `ApprovalResolved` after `mark_approved` / `mark_denied` /
/// `mark_failed` lands. `resolved_at` is stamped from the system
/// clock here so call sites don't repeat the conversion; if you
/// already have an authoritative timestamp from the db update,
/// the tiny skew between "row updated" and "event emitted" is
/// presentation-only and doesn't matter to clients.
///
/// Takes [`ApprovalResolved`] rather than a positional arg list so
/// the seven fields are named at every call site.
pub fn emit_approval_resolved(&self, ev: ApprovalResolved<'_>) {
let ApprovalResolved {
id,
agent,
approval_kind,
status,
note,
description,
} = ev;
let resolved_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
self.emit_dashboard_event(DashboardEvent::ApprovalResolved {
seq: self.next_seq(),
id,
agent: agent.to_owned(),
approval_kind,
status,
resolved_at: hive_sh4re::wire_time::from_secs(resolved_at),
note,
description,
});
}
/// Rebuild the per-container snapshot, diff it against the last /// Rebuild the per-container snapshot, diff it against the last
/// one cached on `self`, and emit one /// one cached on `self`, and emit one
/// `DashboardEvent::ContainerStateChanged` per added/changed row /// `DashboardEvent::ContainerStateChanged` per added/changed row
@ -844,7 +792,7 @@ impl Coordinator {
.with_context(|| format!("create agent dir {}", agent_dir.display()))?; .with_context(|| format!("create agent dir {}", agent_dir.display()))?;
let socket_path = Self::socket_path(name); let socket_path = Self::socket_path(name);
// Hand the full Coordinator to the per-agent socket — it needs // Hand the full Coordinator to the per-agent socket — it needs
// more than just the broker (approvals, ...). // more than just the broker.
let socket = socket_server::start(name, &socket_path, self.clone())?; let socket = socket_server::start(name, &socket_path, self.clone())?;
self.agents.lock().unwrap().insert(name.to_owned(), socket); self.agents.lock().unwrap().insert(name.to_owned(), socket);
Ok(agent_dir) Ok(agent_dir)
@ -1094,28 +1042,6 @@ impl Coordinator {
self.notify_agent(hive_sh4re::manager::MANAGER_AGENT, event); self.notify_agent(hive_sh4re::manager::MANAGER_AGENT, event);
} }
/// Route an approval-scoped helper event to the agent that submitted
/// approval `approval_id` (the authenticated socket caller at submit
/// time). Legacy rows with no recorded submitter — and any lookup
/// failure — fall back to the root agent, preserving the prior
/// always-root behaviour.
pub fn notify_submitter(&self, approval_id: i64, event: &hive_sh4re::manager::HelperEvent) {
let target = self.submitter_or_manager(approval_id);
self.notify_agent(&target, event);
}
/// Shared resolution for "which agent should hear about approval
/// `approval_id`" — the authenticated socket caller at submit time,
/// falling back to the manager for legacy rows with no recorded
/// submitter (or any lookup failure). Used by both `notify_submitter`
/// and `push_todo_submitter` so the fallback rule lives in one place.
fn submitter_or_manager(&self, approval_id: i64) -> String {
self.approvals
.submitter_of(approval_id)
.unwrap_or_default()
.unwrap_or_else(|| hive_sh4re::manager::MANAGER_AGENT.to_owned())
}
/// Push a `HelperEvent` into an arbitrary agent's inbox. Encoded /// Push a `HelperEvent` into an arbitrary agent's inbox. Encoded
/// the same way as `notify_manager` (sender = `SYSTEM_SENDER`, /// the same way as `notify_manager` (sender = `SYSTEM_SENDER`,
/// body = JSON-encoded event) — e.g. `ContainerCrash`, `NeedsUpdate`. /// body = JSON-encoded event) — e.g. `ContainerCrash`, `NeedsUpdate`.

View file

@ -1,104 +0,0 @@
//! Approval endpoints for the dashboard.
//!
//! Approve/deny actions plus the orphan-approval GC sweep used by the
//! `/api/state` builder.
use axum::{
extract::{Form, Path as AxumPath, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use hive_sh4re::approvals::Approval;
use serde::Deserialize;
use utoipa::ToSchema;
use super::{AppState, error_response};
use crate::actions;
use crate::coordinator::Coordinator;
/// Approve a pending approval row.
#[utoipa::path(
post,
path = "/api/approve/{id}",
params(("id" = i64, Path, description = "approval row id")),
responses(
(status = 200, description = "approved", body = String),
(status = 500, description = "approve failed"),
),
tag = "approvals"
)]
pub(super) async fn post_approve(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
) -> Response {
match actions::approve(&state.coord, id) {
// 200 instead of 303 — `actions::approve` fires
// `ApprovalResolved` (success path) or the eventual failure
// event, both of which the dashboard's derived store applies
// live. The matching form carries `data-no-refresh`.
Ok(()) => (StatusCode::OK, "ok").into_response(),
Err(e) => error_response(&format!("approve {id} failed: {e:#}")),
}
}
#[derive(Deserialize, Default, ToSchema)]
pub(super) struct DenyForm {
#[serde(default)]
note: Option<String>,
}
/// Deny a pending approval row, with an optional
/// note (form field `note`).
#[utoipa::path(
post,
path = "/api/deny/{id}",
params(("id" = i64, Path, description = "approval row id")),
request_body(content = DenyForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "denied", body = String),
(status = 500, description = "deny failed"),
),
tag = "approvals"
)]
pub(super) async fn post_deny(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
Form(form): Form<DenyForm>,
) -> Response {
let note = form
.note
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
match actions::deny(&state.coord, id, note) {
Ok(()) => (StatusCode::OK, "ok").into_response(),
Err(e) => error_response(&format!("deny {id} failed: {e:#}")),
}
}
/// Filter out approvals whose agent state dir was wiped out from under us
/// (e.g. by a test script's cleanup). Marks them failed so they fall out of
/// `pending` on next render.
pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<Approval> {
approvals
.into_iter()
.filter(|a| {
if Coordinator::agent_proposed_dir(&a.agent).exists() {
true
} else {
let note = "agent state dir missing";
let _ = coord.approvals.mark_failed(a.id, note);
tracing::info!(id = a.id, agent = %a.agent, "auto-failed orphan approval");
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id: a.id,
agent: a.agent.as_str(),
approval_kind: <&str>::from(a.kind),
status: "failed",
note: Some(note.to_owned()),
description: a.description.clone(),
});
false
}
})
.collect()
}

View file

@ -103,11 +103,8 @@ pub(super) async fn post_kill(
} }
// Manager is stoppable from the dashboard like any other // Manager is stoppable from the dashboard like any other
// agent. The host's dashboard server keeps running (it's // agent. The host's dashboard server keeps running (it's
// hive-c0re, not the manager container), per-agent approvals // hive-c0re, not the manager container), and operator-driven
// submitted by other sub-agents still process through the // meta-input updates work from the dashboard either way.
// host-side approval queue without the manager up, and
// operator-driven meta-input updates work from the dashboard
// either way.
if let Err(e) = if let Err(e) =
crate::job_queue::power::stop_many(&state.coord, std::slice::from_ref(&logical), false) crate::job_queue::power::stop_many(&state.coord, std::slice::from_ref(&logical), false)
.await .await

View file

@ -215,7 +215,7 @@ pub(super) async fn post_meta_update(
.coord .coord
.job_queue .job_queue
.insert_job(|b| { .insert_job(|b| {
crate::job_queue::templates::meta_update(b, inputs, None); crate::job_queue::templates::meta_update(b, inputs);
Vec::new() Vec::new()
}) })
.expect("template-declared shapes are acyclic"); .expect("template-declared shapes are acyclic");

View file

@ -1,6 +1,5 @@
//! Hyperhive dashboard. Lists managed containers (with deep-links to each //! Hyperhive dashboard. Lists managed containers (with deep-links to each
//! container's web UI), pending approvals (with unified diff vs the applied //! container's web UI) and the manager.
//! repo, plus approve/deny buttons), and the manager.
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
@ -35,7 +34,6 @@ use crate::lifecycle;
tags( tags(
(name = "health", description = "hive-wide liveness/readiness probes"), (name = "health", description = "hive-wide liveness/readiness probes"),
(name = "journal", description = "container + host journal reads"), (name = "journal", description = "container + host journal reads"),
(name = "approvals", description = "approve/deny pending approval rows"),
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"), (name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"), (name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"), (name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
@ -49,7 +47,6 @@ use crate::lifecycle;
)] )]
struct ApiDoc; struct ApiDoc;
mod approvals;
mod build_logs; mod build_logs;
// The single validated identifier type — homed in `hive-host-sock` (the crate // The single validated identifier type — homed in `hive-host-sock` (the crate
// owning agent-path facts) so every dashboard path-param validates through the // owning agent-path facts) so every dashboard path-param validates through the
@ -142,8 +139,6 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.routes(routes!(permissions::delete_agent_permissions)) .routes(routes!(permissions::delete_agent_permissions))
.routes(routes!(schedules::api_schedules)) .routes(routes!(schedules::api_schedules))
.routes(routes!(schedules::post_rebuild_queue_cancel)) .routes(routes!(schedules::post_rebuild_queue_cancel))
.routes(routes!(approvals::post_approve))
.routes(routes!(approvals::post_deny))
.routes(routes!(lifecycle_ops::post_destroy)) .routes(routes!(lifecycle_ops::post_destroy))
.routes(routes!(lifecycle_ops::post_kill)) .routes(routes!(lifecycle_ops::post_kill))
.routes(routes!(lifecycle_ops::post_restart)) .routes(routes!(lifecycle_ops::post_restart))
@ -360,8 +355,6 @@ mod router_build_probe {
.routes(routes!(permissions::delete_agent_permissions)) .routes(routes!(permissions::delete_agent_permissions))
.routes(routes!(schedules::api_schedules)) .routes(routes!(schedules::api_schedules))
.routes(routes!(schedules::post_rebuild_queue_cancel)) .routes(routes!(schedules::post_rebuild_queue_cancel))
.routes(routes!(approvals::post_approve))
.routes(routes!(approvals::post_deny))
.routes(routes!(lifecycle_ops::post_destroy)) .routes(routes!(lifecycle_ops::post_destroy))
.routes(routes!(lifecycle_ops::post_kill)) .routes(routes!(lifecycle_ops::post_kill))
.routes(routes!(lifecycle_ops::post_restart)) .routes(routes!(lifecycle_ops::post_restart))

View file

@ -13,8 +13,6 @@ use axum::{
sse::{Event, KeepAlive, Sse}, sse::{Event, KeepAlive, Sse},
}, },
}; };
use chrono::{DateTime, Utc};
use hive_sh4re::approvals::Approval;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::{Stream, StreamExt}; use tokio_stream::{Stream, StreamExt};
@ -24,7 +22,7 @@ use crate::container_view::ContainerView;
use super::meta_inputs::{MetaInputView, read_meta_inputs}; use super::meta_inputs::{MetaInputView, read_meta_inputs};
use super::tombstones::{TombstoneView, build_tombstone_views}; use super::tombstones::{TombstoneView, build_tombstone_views};
use super::{AppState, approvals, error_response, scan_validated_paths}; use super::{AppState, error_response, scan_validated_paths};
#[allow(clippy::struct_excessive_bools)] #[allow(clippy::struct_excessive_bools)]
#[derive(Serialize)] #[derive(Serialize)]
@ -48,10 +46,6 @@ pub(super) struct StateSnapshot {
any_stale: bool, any_stale: bool,
containers: Vec<ContainerView>, containers: Vec<ContainerView>,
transients: Vec<TransientView>, transients: Vec<TransientView>,
approvals: Vec<ApprovalView>,
/// Last 30 resolved approvals (approved / denied / failed), newest-
/// first. Drives the "history" tab on the approvals section.
approval_history: Vec<ApprovalHistoryView>,
/// State dirs (config history + claude creds + /state/ notes) that /// State dirs (config history + claude creds + /state/ notes) that
/// survive after a destroy-without-purge. The operator can re-spawn /// survive after a destroy-without-purge. The operator can re-spawn
/// with the same name to resume, or PURG3 to wipe them. /// with the same name to resume, or PURG3 to wipe them.
@ -70,8 +64,8 @@ pub(super) struct StateSnapshot {
/// `MetaUpdateRunning` event. /// `MetaUpdateRunning` event.
meta_update_running: bool, meta_update_running: bool,
/// Whether the hive-forge container is up. When true the dashboard /// Whether the hive-forge container is up. When true the dashboard
/// links each container's config + each approval's commit into the /// links each container's config into the forge's `agent-configs`
/// forge's `agent-configs` repos. /// repos.
forge_present: bool, forge_present: bool,
/// Whether `hive-gateway` is in front of this dashboard. Sourced /// Whether `hive-gateway` is in front of this dashboard. Sourced
/// from the `HIVE_GATEWAY_ENABLED` env var, which the c0re NixOS /// from the `HIVE_GATEWAY_ENABLED` env var, which the c0re NixOS
@ -126,60 +120,6 @@ struct TransientView {
secs: u64, secs: u64,
} }
#[derive(Serialize)]
struct ApprovalHistoryView {
id: i64,
agent: String,
kind: &'static str,
/// First 12 chars of the manager-supplied ref. None when it is empty.
sha_short: Option<String>,
/// `approved` / `denied` / `failed`.
status: &'static str,
/// RFC 3339 UTC. Renders as a relative time on the dashboard.
resolved_at: DateTime<Utc>,
/// Operator-supplied deny reason (for `denied`) or build error
/// (for `failed`). None on `approved`.
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<String>,
}
#[derive(Serialize)]
struct ApprovalView {
id: i64,
agent: String,
kind: &'static str,
/// Manager-supplied description shown on the approval card.
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
/// Raw `commit_ref` payload for `UpdateMetaInputs` (JSON-encoded
/// `Vec<String>` of input names; `"[]"` = all inputs). The
/// frontend parses this to render a human-readable card body.
commit_ref: String,
/// RFC 3339 UTC time the approval was queued. Rendered as a
/// relative time on the card so the operator can spot a stale
/// request.
requested_at: DateTime<Utc>,
}
/// Replace silent `.unwrap_or_default()` on the data sources behind
/// `/api/state` so that whichever query degrades surfaces in journald
/// instead of leaving the operator staring at an empty list. The
/// dashboard still degrades to a sensible default value; the warn
/// is just the diagnostic breadcrumb the old code swallowed.
fn log_default<T, E>(what: &str, result: std::result::Result<T, E>) -> T
where
T: Default,
E: std::fmt::Debug,
{
match result {
Ok(v) => v,
Err(e) => {
tracing::warn!(target: "api_state", source = %what, error = ?e, "snapshot source failed; using default");
T::default()
}
}
}
/// Window over which container crashes count toward the `agents_crashing` /// Window over which container crashes count toward the `agents_crashing`
/// banner warning. Wide enough that a crash-looping container (restarted /// banner warning. Wide enough that a crash-looping container (restarted
/// by `Restart=on-failure` every few seconds) keeps the warning lit /// by `Restart=on-failure` every few seconds) keeps the warning lit
@ -189,12 +129,11 @@ const CRASH_WARNING_WINDOW: std::time::Duration = std::time::Duration::from_mins
/// Cold-load snapshot of the whole dashboard. /// Cold-load snapshot of the whole dashboard.
/// ///
/// Includes the roster, approvals (+ history), /// Includes the roster, tombstones, job queue, meta inputs, and more. Live
/// tombstones, job queue, meta inputs, and more. Live clients then /// clients then follow `/api/dashboard/stream` (SSE) for incremental updates
/// follow `/api/dashboard/stream` (SSE) for incremental updates keyed /// keyed off `seq`.
/// off `seq`.
// `StateSnapshot` is a large tree of nested view types (`ContainerView`, // `StateSnapshot` is a large tree of nested view types (`ContainerView`,
// `ApprovalView`, ...) with no `ToSchema` anywhere in that // `TransientView`, ...) with no `ToSchema` anywhere in that
// graph; wiring it up is a schema-modelling project of its own, well past // graph; wiring it up is a schema-modelling project of its own, well past
// "annotate what's reachable". `serde_json::Value` placeholder for now — // "annotate what's reachable". `serde_json::Value` placeholder for now —
// see the batch report. // see the batch report.
@ -252,19 +191,7 @@ pub(super) async fn api_state(
let containers = state.coord.containers_snapshot().await; let containers = state.coord.containers_snapshot().await;
let any_stale = containers.iter().any(|c| c.needs_update); let any_stale = containers.iter().any(|c| c.needs_update);
let transient_snapshot = state.coord.transient_snapshot(); let transient_snapshot = state.coord.transient_snapshot();
let pending_approvals = approvals::gc_orphans(
&state.coord,
log_default("approvals.pending", state.coord.approvals.pending()),
);
let transients = build_transient_views(&containers, &transient_snapshot); let transients = build_transient_views(&containers, &transient_snapshot);
let approvals = build_approval_views(pending_approvals);
let approval_history = log_default(
"approvals.recent_resolved",
state.coord.approvals.recent_resolved(30),
)
.into_iter()
.map(history_view)
.collect();
let tombstones = build_tombstone_views(&state.coord, &containers); let tombstones = build_tombstone_views(&state.coord, &containers);
let port_conflicts = build_port_conflicts(&containers); let port_conflicts = build_port_conflicts(&containers);
@ -287,8 +214,6 @@ pub(super) async fn api_state(
any_stale, any_stale,
containers, containers,
transients, transients,
approvals,
approval_history,
meta_inputs: read_meta_inputs(), meta_inputs: read_meta_inputs(),
meta_update_running: state.coord.meta_update_in_progress(), meta_update_running: state.coord.meta_update_in_progress(),
tombstones, tombstones,
@ -375,50 +300,6 @@ fn build_transient_views(
out out
} }
/// 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).
fn history_view(a: Approval) -> ApprovalHistoryView {
let displayed = a.commit_ref.as_str();
let sha_short = if displayed.is_empty() {
None
} else {
Some(displayed[..displayed.len().min(12)].to_owned())
};
let status = match a.status {
hive_sh4re::approvals::ApprovalStatus::Approved => "approved",
hive_sh4re::approvals::ApprovalStatus::Denied => "denied",
hive_sh4re::approvals::ApprovalStatus::Failed => "failed",
hive_sh4re::approvals::ApprovalStatus::Cancelled => "cancelled",
// Pending shouldn't appear in recent_resolved, but be defensive.
hive_sh4re::approvals::ApprovalStatus::Pending => "pending",
};
let kind = <&str>::from(a.kind);
ApprovalHistoryView {
id: a.id,
agent: a.agent.to_string(),
kind,
sha_short,
status,
resolved_at: a.resolved_at.unwrap_or_default(),
note: a.note,
}
}
fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
approvals
.into_iter()
.map(|a| ApprovalView {
id: a.id,
agent: a.agent.to_string(),
kind: <&str>::from(a.kind),
description: a.description,
commit_ref: a.commit_ref,
requested_at: a.requested_at,
})
.collect()
}
/// `/api/jobq/graph` query string — the generic jobq/wire query shape (any /// `/api/jobq/graph` query string — the generic jobq/wire query shape (any
/// host serving a `GraphWire` projection over HTTP takes the same one; see /// host serving a `GraphWire` projection over HTTP takes the same one; see
/// `hive_jobq_wire::parse_states`, which does the actual parsing this side /// `hive_jobq_wire::parse_states`, which does the actual parsing this side

View file

@ -189,13 +189,6 @@ pub(super) async fn post_purge_tombstone(
errors.push(format!("{}: {e}", dir.display())); errors.push(format!("{}: {e}", dir.display()));
} }
} }
if let Err(e) = state
.coord
.approvals
.fail_pending_for_agent(name.as_str(), "agent state purged")
{
tracing::warn!(%name, error = ?e, "approvals: failing pending on tombstone purge failed");
}
if errors.is_empty() { if errors.is_empty() {
tracing::info!(%name, "tombstone purged"); tracing::info!(%name, "tombstone purged");
// Fire the post-purge tombstones snapshot so dashboards // Fire the post-purge tombstones snapshot so dashboards

View file

@ -48,25 +48,6 @@ pub enum DashboardEvent {
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
file_refs: Vec<String>, file_refs: Vec<String>,
}, },
/// A pending approval transitioned to a terminal state
/// (approved / denied / failed). Clients move the row out of the
/// pending list and into history.
///
/// The approval's own kind (`"update_meta_inputs"`) lives
/// on `approval_kind` rather than `kind` because the latter is taken
/// by the serde tag identifying which `DashboardEvent` variant
/// this is.
ApprovalResolved {
seq: u64,
id: i64,
agent: String,
approval_kind: &'static str,
/// `"approved"` / `"denied"` / `"failed"`.
status: &'static str,
resolved_at: DateTime<Utc>,
note: Option<String>,
description: Option<String>,
},
/// A lifecycle action started for an agent (spawn / start / stop /// A lifecycle action started for an agent (spawn / start / stop
/// / restart / rebuild / destroy). Clients render a spinner next /// / restart / rebuild / destroy). Clients render a spinner next
/// to the row; the client computes "seconds in this state" /// to the row; the client computes "seconds in this state"
@ -109,7 +90,7 @@ pub enum DashboardEvent {
/// Fired by `Coordinator::rescan_containers_and_emit`, which diffs /// Fired by `Coordinator::rescan_containers_and_emit`, which diffs
/// a fresh `nixos-container list`–derived snapshot against the /// a fresh `nixos-container list`–derived snapshot against the
/// last one cached on the coordinator. Mutation sites (lifecycle /// last one cached on the coordinator. Mutation sites (lifecycle
/// endpoints, `actions::destroy` / approve, `crash_watch`'s poll loop) /// endpoints, `actions::destroy`, `crash_watch`'s poll loop)
/// call the rescan after their work lands. /// call the rescan after their work lands.
ContainerStateChanged { seq: u64, container: ContainerView }, ContainerStateChanged { seq: u64, container: ContainerView },
/// A container that was in the previous snapshot is gone. Clients /// A container that was in the previous snapshot is gone. Clients
@ -157,7 +138,7 @@ pub enum DashboardEvent {
/// ///
/// Kept as an event rather than deleted in favour of polling because /// Kept as an event rather than deleted in favour of polling because
/// push-on-change is what every other live surface here does /// push-on-change is what every other live surface here does
/// (transients, container state, approvals), and a poll loop would be /// (transients, container state), and a poll loop would be
/// slower for the same information. /// slower for the same information.
RebuildQueueChanged { seq: u64 }, RebuildQueueChanged { seq: u64 },
/// Full snapshot of capability grants (per-agent `Vec<cap_name>`). /// Full snapshot of capability grants (per-agent `Vec<cap_name>`).
@ -214,7 +195,6 @@ impl DashboardEvent {
match self { match self {
DashboardEvent::Sent { .. } => "sent", DashboardEvent::Sent { .. } => "sent",
DashboardEvent::Delivered { .. } => "delivered", DashboardEvent::Delivered { .. } => "delivered",
DashboardEvent::ApprovalResolved { .. } => "approval_resolved",
DashboardEvent::TransientSet { .. } => "transient_set", DashboardEvent::TransientSet { .. } => "transient_set",
DashboardEvent::TransientCleared { .. } => "transient_cleared", DashboardEvent::TransientCleared { .. } => "transient_cleared",
DashboardEvent::ContainerStateChanged { .. } => "container_state_changed", DashboardEvent::ContainerStateChanged { .. } => "container_state_changed",
@ -271,16 +251,6 @@ mod tests {
in_reply_to: None, in_reply_to: None,
file_refs: Vec::new(), file_refs: Vec::new(),
}, },
DashboardEvent::ApprovalResolved {
seq: 1,
id: 1,
agent: "x".into(),
approval_kind: "update_meta_inputs",
status: "approved",
resolved_at: hive_sh4re::wire_time::from_secs(0),
note: None,
description: None,
},
DashboardEvent::TransientSet { DashboardEvent::TransientSet {
seq: 1, seq: 1,
name: "x".into(), name: "x".into(),

View file

@ -368,7 +368,7 @@ pub async fn fetch_config_main_into_applied(name: &str) -> bool {
/// already exists and just needs to catch up: force-sets the local ref to /// already exists and just needs to catch up: force-sets the local ref to
/// match live main rather than requiring a fast-forward or any review /// match live main rather than requiring a fast-forward or any review
/// gate — there is no PR to review on this path, so nothing to check /// gate — there is no PR to review on this path, so nothing to check
/// ancestry against. Contrast the approval-deploy flow's CAS'd, /// ancestry against. Contrast the merged-PR deploy flow's CAS'd,
/// ancestry-gated advance for a reviewed PR merge, which this is not. /// ancestry-gated advance for a reviewed PR merge, which this is not.
/// ///
/// Best-effort, same shape as [`fetch_config_main_into_applied`]: returns /// Best-effort, same shape as [`fetch_config_main_into_applied`]: returns

View file

@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex, OnceLock};
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use hive_jobq::{NodeId, TerminalState}; use hive_jobq::NodeId;
use super::model::NodeKind; use super::model::NodeKind;
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
@ -79,8 +79,8 @@ pub(super) async fn run_node(
run_purge_state(agent).await; run_purge_state(agent).await;
Ok(()) Ok(())
} }
NodeKind::DestroyBookkeeping { purge, .. } => { NodeKind::DestroyBookkeeping { .. } => {
run_destroy_bookkeeping(coord, agent, *purge).await; run_destroy_bookkeeping(coord, agent).await;
Ok(()) Ok(())
} }
NodeKind::MetaLock { NodeKind::MetaLock {
@ -121,13 +121,6 @@ pub(super) async fn run_node(
// takes it directly instead of re-matching the kind behind a `bail!` // takes it directly instead of re-matching the kind behind a `bail!`
// that could never fire. // that could never fire.
NodeKind::WritePermFile { payload, .. } => run_write_perm_file(coord, agent, payload).await, NodeKind::WritePermFile { payload, .. } => run_write_perm_file(coord, agent, payload).await,
NodeKind::ResolveApproval {
approval_id,
outcome,
} => {
run_resolve_approval(coord, coord.job_queue.root_of(id), *approval_id, *outcome);
Ok(())
}
NodeKind::EmitRebuilt { ok, .. } => { NodeKind::EmitRebuilt { ok, .. } => {
run_emit_rebuilt(coord, agent, coord.job_queue.root_of(id), *ok).await; run_emit_rebuilt(coord, agent, coord.job_queue.root_of(id), *ok).await;
Ok(()) Ok(())
@ -266,25 +259,6 @@ async fn run_wanted_pull(coord: &Arc<Coordinator>) -> Result<()> {
crate::workers::wanted::pull(coord).await crate::workers::wanted::pull(coord).await
} }
/// Resolve the DAG's approval row the way this node's own `outcome` says.
///
/// Nothing is inspected: a template emits one of these per outcome, each edged to
/// accept only that one, so *which* node the scheduler let run already is the
/// answer. Best-effort — a resolution failure is logged inside
/// [`crate::actions::resolve_approval_dag`], never surfaced as a node failure,
/// since the work already happened and failing the tail would only misreport it.
fn run_resolve_approval(
coord: &Arc<Coordinator>,
dag_id: Option<u64>,
approval_id: i64,
outcome: TerminalState,
) {
let reason = (outcome == TerminalState::Failed)
.then(|| dag_id.and_then(|dag| coord.job_queue.first_error(dag)))
.flatten();
crate::actions::resolve_approval_dag(coord, approval_id, outcome, reason.as_deref());
}
/// Rerender the meta flake from whatever containers still exist on disk. /// Rerender the meta flake from whatever containers still exist on disk.
/// Idempotent — a no-op when nothing changed. Lives here because the destroy /// Idempotent — a no-op when nothing changed. Lives here because the destroy
/// tail is its only caller; it moved with `destroy` when that became a DAG. /// tail is its only caller; it moved with `destroy` when that became a DAG.
@ -349,23 +323,13 @@ async fn run_purge_state(agent: &str) {
/// none of this is undoable — a failed meta sync or power-store write is a /// none of this is undoable — a failed meta sync or power-store write is a
/// bookkeeping drift to log, not a reason to red a DAG whose container is /// bookkeeping drift to log, not a reason to red a DAG whose container is
/// already gone. /// already gone.
async fn run_destroy_bookkeeping(coord: &Arc<Coordinator>, agent: &str, purge: bool) { async fn run_destroy_bookkeeping(coord: &Arc<Coordinator>, agent: &str) {
// Meta flake: drop the agent's input + nixosConfiguration so a future spawn // Meta flake: drop the agent's input + nixosConfiguration so a future spawn
// under the same name re-seeds cleanly, and so the meta lock doesn't // under the same name re-seeds cleanly, and so the meta lock doesn't
// reference a vanished applied repo. // reference a vanished applied repo.
if let Err(e) = sync_meta_after_lifecycle(coord).await { if let Err(e) = sync_meta_after_lifecycle(coord).await {
tracing::warn!(error = ?e, %agent, "meta sync after destroy failed"); tracing::warn!(error = ?e, %agent, "meta sync after destroy failed");
} }
if let Err(e) = coord.approvals.fail_pending_for_agent(
agent,
if purge {
"agent purged"
} else {
"agent destroyed"
},
) {
tracing::warn!(%agent, error = ?e, "approvals: failing pending on destroy failed");
}
// Drop the durable power intent — a future agent of the same name seeds // Drop the durable power intent — a future agent of the same name seeds
// fresh from its observed state. // fresh from its observed state.
if let Err(e) = coord.power.remove(agent) { if let Err(e) = coord.power.remove(agent) {

View file

@ -15,11 +15,10 @@
//! parent axis (a root's rolled-up state *is* its subtree's), so membership is //! parent axis (a root's rolled-up state *is* its subtree's), so membership is
//! a graph walk with no host-side side-tables. The lease is owned by a subtree //! a graph walk with no host-side side-tables. The lease is owned by a subtree
//! root and borrowed by its descendants (continuity); //! root and borrowed by its descendants (continuity);
//! - terminal work is an ordinary **tail node** //! - terminal work is an ordinary **tail node** ([`NodeKind::EmitRebuilt`])
//! ([`NodeKind::ResolveApproval`] / [`NodeKind::EmitRebuilt`]) that the builder //! that the builder appends in [`templates`], edged onto the job's group
//! appends in [`templates`], edged onto the job's group roots by the outcome it //! roots by the outcome it reports. Templates emit one tail per outcome and
//! reports. Templates emit one tail per outcome and the graph runs exactly one, //! the graph runs exactly one, so nothing branches at runtime.
//! so nothing branches at runtime.
//! //!
//! The queue is runtime-only (no persistence): an empty graph on boot; desired //! The queue is runtime-only (no persistence): an empty graph on boot; desired
//! state is re-derived by the reconcile sweep. A single scheduler task //! state is re-derived by the reconcile sweep. A single scheduler task
@ -44,7 +43,6 @@ use hive_jobq::{Graph, NodeId};
use hive_jobq_wire::{GraphNode, GraphWire}; use hive_jobq_wire::{GraphNode, GraphWire};
use tokio::sync::Notify; use tokio::sync::Notify;
pub use hive_jobq::TerminalState;
pub use model::{NodeKind, PermPayload, State}; pub use model::{NodeKind, PermPayload, State};
use resource::Resource; use resource::Resource;
@ -222,9 +220,7 @@ impl JobQueue {
/// ///
/// **Nodes that explicitly observe cancellation are spared** — a node whose /// **Nodes that explicitly observe cancellation are spared** — a node whose
/// edge names [`hive_jobq::TerminalState::Cancelled`] is asking to run when /// edge names [`hive_jobq::TerminalState::Cancelled`] is asking to run when
/// the work it follows was dropped, which is exactly what an approval tail /// the work it follows was dropped, so it stays pending and runs.
/// needs: cancel the work, and the tail still fires to resolve the approval
/// row rather than leaving it dangling forever.
/// ///
/// Nothing is special-cased by node kind. `AFTER_ANY` deliberately does *not* /// Nothing is special-cased by node kind. `AFTER_ANY` deliberately does *not*
/// accept `Cancelled`, so an ordinary weak-edged step (rebuild's `Reconcile`, /// accept `Cancelled`, so an ordinary weak-edged step (rebuild's `Reconcile`,

View file

@ -15,8 +15,6 @@
pub use hive_host_sock::jobs::{PermPayload, State}; pub use hive_host_sock::jobs::{PermPayload, State};
use serde::Serialize; use serde::Serialize;
use hive_jobq::TerminalState;
/// The primitive operations — each kind maps to one executor fn in /// The primitive operations — each kind maps to one executor fn in
/// `exec.rs`, a thin wrapper over existing `lifecycle.rs` / `meta.rs` /// `exec.rs`, a thin wrapper over existing `lifecycle.rs` / `meta.rs`
/// code. Concurrency is gated by two resource classes (see /// code. Concurrency is gated by two resource classes (see
@ -61,7 +59,7 @@ pub enum NodeKind {
/// `remove_dir_all`) plus its state and applied dirs. /// `remove_dir_all`) plus its state and applied dirs.
PurgeState { agent: String }, PurgeState { agent: String },
/// The post-destroy bookkeeping tail. /// The post-destroy bookkeeping tail.
DestroyBookkeeping { agent: String, purge: bool }, DestroyBookkeeping { agent: String },
/// Meta flake lock bump. `sweep = false`: `meta::lock_update` with this /// Meta flake lock bump. `sweep = false`: `meta::lock_update` with this
/// node's own `inputs`; `sweep = true`: `meta::lock_update_hyperhive` /// node's own `inputs`; `sweep = true`: `meta::lock_update_hyperhive`
/// (boot sweep, `inputs` empty). See _Notable collapses_ for how the /// (boot sweep, `inputs` empty). See _Notable collapses_ for how the
@ -105,17 +103,6 @@ pub enum NodeKind {
/// Group root of a rebuild subtree — the **brace**, scoped to one /// Group root of a rebuild subtree — the **brace**, scoped to one
/// agent. Why braces exist and what they cost: _Braces_. /// agent. Why braces exist and what they cost: _Braces_.
AgentWindow { agent: String }, AgentWindow { agent: String },
/// Tail node of an approval-carrying DAG (a meta-inputs update): resolve
/// the approval row from how the work ended.
ResolveApproval {
approval_id: i64,
/// Which outcome this node reports. A template emits **one per
/// outcome**, each edged to accept only that one, so exactly one is
/// ever runnable. The `Cancelled` one is also the node
/// [`super::JobQueue::cancel`] spares, since its edge is the only
/// one that accepts a dropped dependency.
outcome: TerminalState,
},
/// Tail node of a rebuild / perm-change: emit this agent's `Rebuilt` /// Tail node of a rebuild / perm-change: emit this agent's `Rebuilt`
/// manager event. One node per agent *and* per outcome; no cancel /// manager event. One node per agent *and* per outcome; no cancel
/// variant (a cancelled DAG never ran, so there is no rebuild to /// variant (a cancelled DAG never ran, so there is no rebuild to
@ -175,8 +162,8 @@ impl hive_jobq_wire::WireNode for NodeKind {
impl NodeKind { impl NodeKind {
/// The agent this node targets, or `""` for agentless kinds /// The agent this node targets, or `""` for agentless kinds
/// ([`NodeKind::MetaLock`] on the `hyperhive` pseudo-agent, /// ([`NodeKind::MetaLock`] on the `hyperhive` pseudo-agent, and the
/// [`NodeKind::ResolveApproval`] which acts on an approval row). /// swarm-wide sweeps and pulls).
#[must_use] #[must_use]
pub fn agent(&self) -> &str { pub fn agent(&self) -> &str {
match self { match self {
@ -203,7 +190,6 @@ impl NodeKind {
| NodeKind::EmitRebuilt { agent, .. } | NodeKind::EmitRebuilt { agent, .. }
| NodeKind::SetWanted { agent, .. } => agent, | NodeKind::SetWanted { agent, .. } => agent,
NodeKind::MetaLock { .. } NodeKind::MetaLock { .. }
| NodeKind::ResolveApproval { .. }
| NodeKind::ForgeSweep | NodeKind::ForgeSweep
| NodeKind::MatrixSweep | NodeKind::MatrixSweep
| NodeKind::KnowledgePull | NodeKind::KnowledgePull

View file

@ -10,10 +10,9 @@
//! label tracks the DAG's progress (signal → swap → reconcile) instead of //! label tracks the DAG's progress (signal → swap → reconcile) instead of
//! repeating one intent the template declared before any of it started. //! repeating one intent the template declared before any of it started.
//! //!
//! Per-DAG terminal work (approval resolution, `Rebuilt`) is not drained here: //! Per-DAG terminal work (`Rebuilt`) is not drained here: it runs as the DAG's
//! it runs as the DAG's focused terminal node (`ResolveApproval` / //! focused terminal node (`EmitRebuilt`), dispatched through `exec::run_node`
//! `EmitRebuilt`), dispatched through `exec::run_node` like any other node once //! like any other node once the DAG settles.
//! the DAG settles.
//! //!
//! In-DAG growth (a `MetaLock` growing rebuild subgraphs, a `Reconcile` fanning //! In-DAG growth (a `MetaLock` growing rebuild subgraphs, a `Reconcile` fanning
//! its `Start`/`Stop`) is declared onto the builder each node is handed, and //! its `Start`/`Stop`) is declared onto the builder each node is handed, and
@ -55,7 +54,7 @@ type TransientSeen = HashMap<(String, String), bool>;
/// `nixos-container update`, which is itself declarative). That's a property /// `nixos-container update`, which is itself declarative). That's a property
/// of the node set, not of the queue — it holds today but isn't enforced by /// of the node set, not of the queue — it holds today but isn't enforced by
/// the type system, so it's worth re-checking whenever a new `NodeKind` is /// the type system, so it's worth re-checking whenever a new `NodeKind` is
/// added. The side-effect tails (`EmitRebuilt`, `ResolveApproval`) sit /// added. The side-effect tail (`EmitRebuilt`) sits
/// closest to the edge: dropping or re-running one affects something outside /// closest to the edge: dropping or re-running one affects something outside
/// the graph (today, a missed or duplicated notification) rather than /// the graph (today, a missed or duplicated notification) rather than
/// reconverging silently. /// reconverging silently.

View file

@ -20,8 +20,6 @@
//! `lifecycle::is_running` read), so [`super::power`] assembles them out of //! `lifecycle::is_running` read), so [`super::power`] assembles them out of
//! the primitives this module exports ([`rebuild_nodes`]). //! the primitives this module exports ([`rebuild_nodes`]).
use hive_jobq::TerminalState;
use super::model::{NodeKind, PermPayload}; use super::model::{NodeKind, PermPayload};
use super::resource::Resource; use super::resource::Resource;
use super::{Handle, JobBuilder}; use super::{Handle, JobBuilder};
@ -57,26 +55,6 @@ fn emit_rebuilt_tails(builder: &JobBuilder, agent: &str, roots: &[Handle<'_>]) {
); );
} }
/// The approval-resolving tails for an approval-carrying DAG: one per outcome of
/// the DAG's single group-root `root`, each accepting only its own.
///
/// The `Cancelled` node is what keeps a dropped approval DAG from dangling its
/// row forever — its edge is the only one [`super::JobQueue::cancel`] spares.
fn resolve_approval_tails(builder: &JobBuilder, approval_id: i64, root: Handle<'_>) {
for outcome in [
TerminalState::Done,
TerminalState::Failed,
TerminalState::Cancelled,
] {
let _ = builder
.node(NodeKind::ResolveApproval {
approval_id,
outcome,
})
.on_outcome(root, &[outcome]);
}
}
/// Declare one rebuild subgraph per agent onto the builder a running /// Declare one rebuild subgraph per agent onto the builder a running
/// [`NodeKind::MetaLock`] was handed. /// [`NodeKind::MetaLock`] was handed.
/// ///
@ -349,9 +327,6 @@ pub fn rebuild(builder: &JobBuilder, agent: &str, relock: bool) -> Vec<hive_jobq
/// borrow it. A failure cancel-cascades the rest — unlike rebuild there's no /// borrow it. A failure cancel-cascades the rest — unlike rebuild there's no
/// recovery-reconcile (nothing to converge if the container was never created). /// recovery-reconcile (nothing to converge if the container was never created).
/// ///
/// No approval tail: the operator authorised the creation at swarm level, and
/// the deploy request carries that authorisation.
///
/// Returns the group root so a caller can wait on the whole subtree. /// Returns the group root so a caller can wait on the whole subtree.
pub fn first_deploy(builder: &JobBuilder, agent: &str) -> Vec<hive_jobq::NodeGuid> { pub fn first_deploy(builder: &JobBuilder, agent: &str) -> Vec<hive_jobq::NodeGuid> {
let a = || agent.to_owned(); let a = || agent.to_owned();
@ -423,7 +398,7 @@ pub fn destroy(builder: &JobBuilder, agent: &str, purge: bool) {
destroy destroy
}; };
let _tail = builder let _tail = builder
.node(NodeKind::DestroyBookkeeping { agent: a(), purge }) .node(NodeKind::DestroyBookkeeping { agent: a() })
.part_of(stop) .part_of(stop)
.after_ok(last); .after_ok(last);
} }
@ -458,8 +433,8 @@ pub fn perm_change(builder: &JobBuilder, agent: &str, payload: PermPayload) {
/// so the "hyperhive" pseudo-agent gets no pill), giving each cascade agent /// so the "hyperhive" pseudo-agent gets no pill), giving each cascade agent
/// crash-watch suppression during its `Swap` — the property the old child /// crash-watch suppression during its `Swap` — the property the old child
/// `Rebuild` DAGs carried via their own transient. /// `Rebuild` DAGs carried via their own transient.
pub fn meta_update(builder: &JobBuilder, inputs: Vec<String>, approval_id: Option<i64>) { pub fn meta_update(builder: &JobBuilder, inputs: Vec<String>) {
let lock = builder let _lock = builder
.node(NodeKind::MetaLock { .node(NodeKind::MetaLock {
sweep: false, sweep: false,
fanout: None, fanout: None,
@ -467,14 +442,6 @@ pub fn meta_update(builder: &JobBuilder, inputs: Vec<String>, approval_id: Optio
}) })
.needs(Resource::BuildSlot) .needs(Resource::BuildSlot)
.needs(Resource::MetaWindow); .needs(Resource::MetaWindow);
// The bump itself has no side effect, so an operator-driven one ends
// at the `MetaLock`; an approval-driven one still has its row to
// resolve and gets the per-outcome tails edged onto that single
// group-root — whose roll-up covers the rebuild subgraphs `MetaLock`
// grows into itself.
if let Some(approval_id) = approval_id {
resolve_approval_tails(builder, approval_id, lock);
}
} }
// The boot is assembled inline in `workers/auto_update.rs::submit_boot_tree` // The boot is assembled inline in `workers/auto_update.rs::submit_boot_tree`

View file

@ -16,6 +16,7 @@
use super::model::NodeKind; use super::model::NodeKind;
use super::*; use super::*;
use hive_jobq::TerminalState;
/// Insert a declared job, naming nothing — the shape assertions read the whole /// Insert a declared job, naming nothing — the shape assertions read the whole
/// graph. A test that needs a handle calls `q.insert` directly and names the /// graph. A test that needs a handle calls `q.insert` directly and names the
@ -101,10 +102,9 @@ struct Declared {
/// Render a dep's outcome set as the outcomes it actually accepts. /// Render a dep's outcome set as the outcomes it actually accepts.
/// ///
/// ⚠️ Spelled out rather than bucketed into `ok` / `any` / other. The first /// ⚠️ Spelled out rather than bucketed into `ok` / `any` / other: tails that
/// version of this did bucket, and a template's three `ResolveApproval` tails — /// differ ONLY in their accepted outcomes would render identically, and a
/// which differ ONLY in their accepted outcomes — all rendered as `"other"`. /// helper that prints two structurally different nodes identically turns an
/// A helper that prints two structurally different nodes identically turns an
/// assertion into a tautology. /// assertion into a tautology.
fn when_tag(when: hive_jobq::DepWhen) -> String { fn when_tag(when: hive_jobq::DepWhen) -> String {
[ [
@ -194,19 +194,6 @@ fn pending_kinds_for(q: &JobQueue, agent: &str) -> Vec<&'static str> {
pending_kinds_filtered(q, &|kind: &NodeKind| kind.agent() == agent) pending_kinds_filtered(q, &|kind: &NodeKind| kind.agent() == agent)
} }
/// The payloads of every node still `Pending`, for the cases where *which* of a
/// family of same-kind nodes survived is the assertion — a template emits one
/// tail per outcome and they differ only in what they carry.
fn pending_payloads(q: &JobQueue) -> Vec<NodeKind> {
let sched = q.sched().lock().expect("job_queue mutex poisoned");
let graph = sched.graph();
graph
.nodes()
.filter(|n| n.state == State::Pending)
.map(|n| n.payload.clone())
.collect()
}
fn pending_kinds_filtered(q: &JobQueue, keep: &dyn Fn(&NodeKind) -> bool) -> Vec<&'static str> { fn pending_kinds_filtered(q: &JobQueue, keep: &dyn Fn(&NodeKind) -> bool) -> Vec<&'static str> {
let sched = q.sched().lock().expect("job_queue mutex poisoned"); let sched = q.sched().lock().expect("job_queue mutex poisoned");
let graph = sched.graph(); let graph = sched.graph();
@ -1189,43 +1176,6 @@ fn cancelled_power_op_runs_no_compensating_node() {
} }
} }
// ---- terminal reporting + lease release ----
/// A DAG cancelled while fully queued must still **run its tail**, or a queued
/// approval DAG cancelled by the operator would dangle its approval forever.
///
/// This is the load-bearing case for sparing tails in [`JobQueue::cancel`]: the
/// work nodes all cancel, but `ResolveApproval` is weak-edged, so a `Cancelled`
/// dep satisfies its edge and it becomes claimable instead of being cancelled
/// along with everything else. It reads `Cancelled` off its own deps and resolves
/// the approval as "cancelled before completion".
#[test]
fn cancelled_dag_still_runs_its_approval_tail() {
let q = JobQueue::new(1);
insert(&q, |builder| {
templates::meta_update(builder, Vec::new(), Some(7));
});
// Found by kind, not returned: `meta_update` names nothing, because the
// approval row is how an operator follows it.
let lock = node_of(&q, "meta_lock").get();
assert!(q.cancel(lock), "fully-queued dag cancels");
// The `Cancelled` tail is the only node whose edge accepts a dropped
// dependency, so it is the only one `cancel` spares — and *which* tail
// survives names how the approval row is about to be resolved.
let spared = pending_payloads(&q);
assert!(
matches!(
spared.as_slice(),
[NodeKind::ResolveApproval {
approval_id: 7,
outcome: TerminalState::Cancelled
}]
),
"only the cancelled-outcome tail is spared, got {spared:?}"
);
assert_eq!(state_of(&q, lock), State::Cancelled);
}
// ---- history ---- // ---- history ----
// //
// The node → build-log link is no longer queue state: the log row carries // The node → build-log link is no longer queue state: the log row carries

View file

@ -364,7 +364,7 @@ async fn set_nspawn_flags(
// Own state, harness, and config dirs — same for every agent including // Own state, harness, and config dirs — same for every agent including
// the manager. Config is RO: an agent must not edit its own config; changes // the manager. Config is RO: an agent must not edit its own config; changes
// only ever flow through the approval queue. // only ever flow through a config PR on the forge.
binds.push(BindMount { binds.push(BindMount {
host_path: notes_dir.to_string_lossy().into_owned(), host_path: notes_dir.to_string_lossy().into_owned(),
container_path: format!("/agents/{agent_name}/state"), container_path: format!("/agents/{agent_name}/state"),

View file

@ -1,77 +1,34 @@
//! Loose-ends aggregator. Walks the `approvals` table once per call and //! Loose-ends aggregator. Assembles a `Vec<LooseEnd>` for a single agent
//! assembles a `Vec<LooseEnd>` for a single agent (`for_agent`) — always //! (`for_agent`) — always the caller's own. `Request::GetLooseEnds` from
//! the caller's own. `Request::GetLooseEnds` from either the agent or //! either the agent or manager socket lands here. Reminders and todos are
//! manager socket lands here so the age-seconds derivation stays in one //! agent-local (in-container `hive-agent` stores) and never sourced from
//! place. Reminders are agent-local (in-container //! here.
//! `hive-agent::reminders` store) and never sourced from here. The
//! `ask`/`answer` MCP tools, their wire protocol (`hive-c0re::questions`,
//! `stores::operator_questions`), and the operator dashboard's questions
//! pane have all been removed entirely — this file never had a
//! `Question` loose-end path to begin with (it only ever emitted
//! `PendingMessages`/`Approval`), so nothing here changed shape when
//! that removal landed.
//! //!
//! Call frequency is low (an agent doing self-introspection between //! Call frequency is low (an agent doing self-introspection between
//! turns), so the sweep happens fresh every time — no caching, no //! turns), so the sweep happens fresh every time — no caching, no
//! mutation events. If the sweep ever shows up in a profile, the sqlite //! mutation events.
//! queries already filter on the same index (`idx_approvals_pending`)
//! that the dashboard uses, so the bottleneck would be json
//! (de)serialisation, not the read.
use anyhow::Result; use anyhow::Result;
use chrono::Utc; use hive_sh4re::inbox::LooseEnd;
use hive_sh4re::inbox::{LooseEnd, saturating_age};
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
/// Open threads pending against `agent`: /// Open threads pending against `agent`: undelivered inbox messages this
/// - undelivered inbox messages this agent still owes itself a `recv` /// agent still owes itself a `recv` for, only when the count is non-zero.
/// for (only when the count is non-zero);
/// - pending approvals where this agent is the submitter (a parent
/// agent with the `approvals` group submits for its children; the
/// root submits for top-level agents). Legacy rows with no recorded
/// submitter count as the root's.
///
/// Ordered `pending_messages` (when non-zero) → approvals within the
/// returned vector. Within each kind, source-of-truth ordering (sqlite's
/// `pending()` query returns newest-first within its index).
/// ///
/// # Errors /// # Errors
/// ///
/// Propagates errors from `count_pending` and the pending-approval /// Propagates errors from `count_pending`.
/// sqlite query.
pub fn for_agent(coord: &Coordinator, agent: &str) -> Result<Vec<LooseEnd>> { pub fn for_agent(coord: &Coordinator, agent: &str) -> Result<Vec<LooseEnd>> {
let now = Utc::now().timestamp();
let mut out = Vec::new(); let mut out = Vec::new();
// Undelivered inbox messages this agent still owes itself a `recv` // Only emitted when non-zero so a clean inbox doesn't add noise. The
// for. Surfaced first (most actionable) and only when non-zero so a // wake-message that drove the current turn is already delivered, so
// clean inbox doesn't add noise. The wake-message that drove the // this counts only what's genuinely still queued.
// current turn is already delivered, so this counts only what's
// genuinely still queued.
let pending_messages = coord.broker.count_pending(agent)?; let pending_messages = coord.broker.count_pending(agent)?;
if pending_messages > 0 { if pending_messages > 0 {
out.push(LooseEnd::PendingMessages { out.push(LooseEnd::PendingMessages {
count: pending_messages, count: pending_messages,
}); });
} }
// Show each pending approval to the agent that submitted it. The
// submitter column is NULL for rows predating it; those count as
// operator-initiated (no agent tracking predates the column).
for a in coord.approvals.pending()? {
let submitter = coord
.approvals
.submitter_of(a.id)?
.unwrap_or_else(|| "operator".to_owned());
if submitter != agent {
continue;
}
out.push(LooseEnd::Approval {
id: a.id,
agent: a.agent.to_string(),
commit_ref: a.commit_ref,
description: a.description,
age_seconds: saturating_age(now, a.requested_at.timestamp()),
});
}
Ok(out) Ok(out)
} }

View file

@ -44,7 +44,7 @@ mod workers;
pub(crate) use agent_config::{capabilities, limits, resource_limits, tool_groups, topology}; pub(crate) use agent_config::{capabilities, limits, resource_limits, tool_groups, topology};
pub(crate) use stats::{container_stats, hive_stats, host_stats, otel_metrics, warnings}; pub(crate) use stats::{container_stats, hive_stats, host_stats, otel_metrics, warnings};
pub(crate) use stores::{approvals, broker, build_logs, db, power, scheduled_prompts}; pub(crate) use stores::{broker, build_logs, db, power, scheduled_prompts};
pub(crate) use workers::{agent_sockets, auto_update, crash_watch, knowledge, mcp_sockets}; pub(crate) use workers::{agent_sockets, auto_update, crash_watch, knowledge, mcp_sockets};
use coordinator::{Coordinator, HiveEnv, ServeConfig}; use coordinator::{Coordinator, HiveEnv, ServeConfig};
@ -436,7 +436,7 @@ async fn cmd_serve(
// build-slot count + per-agent leases inside the queue, not from // build-slot count + per-agent leases inside the queue, not from
// multiple workers — cheap nodes (graceful signals, drains, // multiple workers — cheap nodes (graceful signals, drains,
// reconciles) overlap nix-heavy ones structurally. Call sites // reconciles) overlap nix-heavy ones structurally. Call sites
// (auto_update, dashboard, manager, approval handler) submit DAGs // (auto_update, dashboard, manager) submit DAGs
// instead of awaiting lifecycle work inline. See `job_queue/`. // instead of awaiting lifecycle work inline. See `job_queue/`.
{ {
let q_coord = coord.clone(); let q_coord = coord.clone();

View file

@ -2,7 +2,7 @@
//! exports one `nixosConfiguration` per agent and drives the system-wide //! exports one `nixosConfiguration` per agent and drives the system-wide
//! deploy audit trail. Flow (`sync_agents`, `lock_update_for_rebuild`, //! deploy audit trail. Flow (`sync_agents`, `lock_update_for_rebuild`,
//! `lock_update_hyperhive`): //! `lock_update_hyperhive`):
//! `docs/agent-lifecycle/approvals.md::Meta flake`. //! `docs/agent-lifecycle/config-changes.md::Meta flake`.
use std::path::Path; use std::path::Path;

View file

@ -16,7 +16,7 @@
//! from birth does not need re-asserting each morning. //! from birth does not need re-asserting each morning.
//! //!
//! Kill-switch: `HIVE_SKIP_META_MIGRATION=1`. Full sequence and phase //! Kill-switch: `HIVE_SKIP_META_MIGRATION=1`. Full sequence and phase
//! details: `docs/agent-lifecycle/approvals.md::Startup migrations (older hosts)`. //! details: `docs/agent-lifecycle/config-changes.md::Startup migrations (older hosts)`.
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;

View file

@ -184,15 +184,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
// federation peer-config block) and open the web surfaces // federation peer-config block) and open the web surfaces
// (`hivectl open`). // (`hivectl open`).
HostRequest::Urls => HostResponse::urls(hive_urls()), HostRequest::Urls => HostResponse::urls(hive_urls()),
HostRequest::Pending => HostResponse::pending(coord.approvals.pending()?),
HostRequest::Approve { id } => {
actions::approve(&coord, *id)?;
HostResponse::success()
}
HostRequest::Deny { id } => {
actions::deny(&coord, *id, None)?;
HostResponse::success()
}
HostRequest::SetResourceLimits { HostRequest::SetResourceLimits {
name, name,
cpu_quota, cpu_quota,
@ -342,7 +333,7 @@ async fn stream_agent_status(
write_response(&mut write, &HostResponse::agent_statuses(vec![row])).await?; write_response(&mut write, &HostResponse::agent_statuses(vec![row])).await?;
} }
// Every other event kind on this channel is dashboard-only // Every other event kind on this channel is dashboard-only
// (approvals, broker traffic, the queue, …) — nothing this // (broker traffic, the queue, …) — nothing this
// subscriber asked for. // subscriber asked for.
Ok(_) => {} Ok(_) => {}
// Best-effort, same contract the dashboard's own live channel // Best-effort, same contract the dashboard's own live channel

View file

@ -3,10 +3,9 @@
//! authenticates the caller: connecting to `<.../agents/foo/mcp.sock>` means //! authenticates the caller: connecting to `<.../agents/foo/mcp.sock>` means
//! you are `foo`; the manager socket simply serves as `ruth`. There is no //! you are `foo`; the manager socket simply serves as `ruth`. There is no
//! privilege flag — both transports run the same [`serve`] / [`dispatch`] //! privilege flag — both transports run the same [`serve`] / [`dispatch`]
//! code, and authority derives uniformly from the caller's identity //! code, and authority derives uniformly from the caller's identity. An
//! (tool-group membership for cancelling an approval). An agent-targeting //! agent-targeting verb may name any agent, so `ruth` reaches every agent
//! verb may name any agent, so `ruth` reaches every agent exactly the way //! exactly the way every other agent does, not via any hardcoded name match.
//! every other agent does, not via any hardcoded name match.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@ -292,12 +291,6 @@ async fn dispatch(
hive_core_agent_sock::Request::GetAgentMeta { name } => { hive_core_agent_sock::Request::GetAgentMeta { name } => {
handle_get_agent_meta(coord, agent, name.as_ref()).await handle_get_agent_meta(coord, agent, name.as_ref()).await
} }
hive_core_agent_sock::Request::CancelLooseEnd { kind, id } => {
handle_cancel_loose_end(coord, agent, *kind, *id).map_or_else(
|message| hive_core_agent_sock::Response::Err { message },
|()| hive_core_agent_sock::Response::Ok,
)
}
hive_core_agent_sock::Request::AckTurn => handle_ack_turn(coord, agent), hive_core_agent_sock::Request::AckTurn => handle_ack_turn(coord, agent),
hive_core_agent_sock::Request::AckUntil { up_to } => handle_ack_until(coord, agent, *up_to), hive_core_agent_sock::Request::AckUntil { up_to } => handle_ack_until(coord, agent, *up_to),
hive_core_agent_sock::Request::RequeueInflight => handle_requeue_inflight(coord, agent), hive_core_agent_sock::Request::RequeueInflight => handle_requeue_inflight(coord, agent),
@ -565,93 +558,6 @@ fn handle_get_loose_ends(coord: &Arc<Coordinator>, agent: &str) -> Response {
} }
} }
/// Handle `CancelLooseEnd` from a per-agent socket. Dispatches by kind, each
/// with its own auth check: reminder cancels are handled entirely in-container
/// (this arm should be unreachable in practice, kept only so the match stays
/// exhaustive) and approval cancels require the `approvals` tool-group (the
/// grantable capability) AND ownership — the canceller must be the approval's
/// submitter — so no positional / hardcoded privilege and no cross-agent
/// cancellation. (The operator's cancel-anything path is a separate handler.)
fn handle_cancel_loose_end(
coord: &Arc<Coordinator>,
canceller: &str,
kind: hive_sh4re::inbox::CancelLooseEndKind,
id: i64,
) -> Result<(), String> {
match kind {
hive_sh4re::inbox::CancelLooseEndKind::Reminder => {
// Reminders are now agent-local (in-container store) — the
// agent-mcp `cancel_loose_end` tool branches on this kind and
// dials the agent's own socket directly, never forwarding to
// hive-c0re. This arm should be unreachable in practice; kept
// only so the match stays exhaustive.
Err(format!(
"reminder {id}: reminders are handled locally by the agent, \
not by hive-c0re"
))
}
hive_sh4re::inbox::CancelLooseEndKind::Approval => {
// Withdrawing an approval needs the grantable `approvals`
// tool-group (held by any approval-submitting orchestrator)
// AND ownership: only the agent that submitted the approval
// may withdraw it. Without the ownership check, any
// approvals-group agent could cancel any other's approval by
// id. A NULL submitter (legacy row predating the column) is
// treated as operator-initiated (no agent tracking predates
// the column).
check_can_cancel_approval(canceller)?;
let submitter = coord
.approvals
.submitter_of(id)
.map_err(|e| format!("{e:#}"))?
.unwrap_or_else(|| "operator".to_owned());
if submitter != canceller {
return Err(format!(
"cancel_loose_end: approval {id} was submitted by {submitter}, \
not {canceller}; only the submitting agent can withdraw it"
));
}
let approval = coord
.approvals
.mark_cancelled(id, canceller)
.map_err(|e| format!("{e:#}"))?;
tracing::info!(%id, %canceller, agent = %approval.agent, "approval cancelled");
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id: approval.id,
agent: approval.agent.as_str(),
approval_kind: <&str>::from(approval.kind),
status: "cancelled",
note: approval.note,
description: approval.description,
});
Ok(())
}
}
}
/// Capability guard on the `Approval` cancel arm: the caller must hold the
/// `approvals` tool-group (the grantable capability for approval-submitting
/// orchestrators), checked server-side via `tool_groups::groups_for`. Pulled
/// out so the auth check has its own focused unit test — exercising the full
/// `handle_cancel_loose_end` flow would need a `Coordinator` fixture (broker +
/// sqlite). Keys on a grantable capability, not a positional / hardcoded
/// privilege.
fn check_can_cancel_approval(canceller: &str) -> Result<(), String> {
const APPROVALS_GROUP: &str = "approvals";
if crate::tool_groups::groups_for(canceller)
.map_err(|e| format!("cancel_loose_end: {e}"))?
.iter()
.any(|g| g == APPROVALS_GROUP)
{
Ok(())
} else {
Err(
"cancel_loose_end: cancelling approval rows requires the `approvals` tool group"
.to_owned(),
)
}
}
/// `Send` handler. Applies the /// `Send` handler. Applies the
/// 4 KiB body cap, then routes broadcast (`to == "*"`) / unicast through /// 4 KiB body cap, then routes broadcast (`to == "*"`) / unicast through
/// their respective broker calls. /// their respective broker calls.
@ -736,17 +642,6 @@ mod tests {
(dir, Arc::new(coord)) (dir, Arc::new(coord))
} }
#[test]
fn approval_cancel_rejects_callers_without_the_approvals_group() {
// A caller that doesn't hold the `approvals` tool-group must not be
// able to cancel approval rows even if it invents an id. The guard is
// server-side so client cooperation is irrelevant — and it keys on a
// grantable capability (the tool-group), not on any agent name.
// `groups_for` of a name with no tool_groups.json entry is empty.
let err = check_can_cancel_approval("nobody-with-no-groups").unwrap_err();
assert!(err.contains("approvals` tool group"), "{err}");
}
#[tokio::test] #[tokio::test]
async fn over_long_line_is_rejected_after_reading_only_the_bound() { async fn over_long_line_is_rejected_after_reading_only_the_bound() {
let total = 2 * REQUEST_LINE_MAX_BYTES as u64; let total = 2 * REQUEST_LINE_MAX_BYTES as u64;

View file

@ -1,575 +0,0 @@
//! Approval queue. The user approves/denies via the host admin CLI; on
//! approval the host runs the corresponding action.
//!
//! Nothing submits approvals any more. `UpdateMetaInputs`, the only kind
//! left, is legacy: the MCP tool that queued it was removed. The variant
//! and its (de)serialization stay so pre-existing rows still read back.
use std::path::Path;
use std::sync::Mutex;
use anyhow::{Context, Result, bail};
use chrono::Utc;
use hive_sh4re::approvals::{Approval, ApprovalKind, ApprovalStatus};
use rusqlite::{Connection, OptionalExtension, params};
use crate::db::Migration;
const SCHEMA: &str = r"
CREATE TABLE IF NOT EXISTS approvals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent TEXT NOT NULL,
commit_ref TEXT NOT NULL,
requested_at INTEGER NOT NULL,
status TEXT NOT NULL,
resolved_at INTEGER,
note TEXT
);
CREATE INDEX IF NOT EXISTS idx_approvals_pending
ON approvals (id) WHERE status = 'pending';
";
/// Ordered schema migrations tracked in `schema_versions` (key `"approvals"`).
/// Each migration declares the column it adds, so a legacy DB (fully or
/// partially migrated before versioning) converges by skipping the migrations
/// whose column already exists. New columns go here as v5, v6, …
const MIGRATIONS: &[Migration] = &[
// v1: `kind` (pre-Phase-8 dbs): legacy rows default to `apply_commit`.
Migration {
sql: "ALTER TABLE approvals ADD COLUMN \
kind TEXT NOT NULL DEFAULT 'apply_commit'",
adds_column: Some(("approvals", "kind")),
},
// v2: `description`: manager-supplied note on the dashboard card.
Migration {
sql: "ALTER TABLE approvals ADD COLUMN description TEXT",
adds_column: Some(("approvals", "description")),
},
// v3: `fetched_sha`: canonical sha hive-c0re resolved at submit time.
Migration {
sql: "ALTER TABLE approvals ADD COLUMN fetched_sha TEXT",
adds_column: Some(("approvals", "fetched_sha")),
},
// v4: `submitter`: authenticated agent that submitted the approval.
// Legacy rows are NULL → callers fall back to the root agent.
Migration {
sql: "ALTER TABLE approvals ADD COLUMN submitter TEXT",
adds_column: Some(("approvals", "submitter")),
},
// v5: drop `merge_config_pr` rows. Config PRs merge on the forge, so no
// approval of that kind can be acted on, and `row_to_approval` rejects
// the kind.
Migration {
sql: "DELETE FROM approvals WHERE kind = 'merge_config_pr'",
adds_column: None,
},
// v6: drop `schedule_prompt` rows. Scheduled prompts are swarm-level, so
// no approval of that kind can be acted on, and `row_to_approval` rejects
// the kind.
Migration {
sql: "DELETE FROM approvals WHERE kind = 'schedule_prompt'",
adds_column: None,
},
];
pub struct Approvals {
conn: Mutex<Connection>,
}
impl Approvals {
pub fn open(path: &Path) -> Result<Self> {
let conn = crate::db::open(path, "approvals")?;
conn.execute_batch(SCHEMA)
.context("apply approvals schema")?;
crate::db::apply_versioned_migrations(&conn, "approvals", MIGRATIONS)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
/// Test fixture: insert a new pending approval row.
#[cfg(test)]
pub fn submit_kind(
&self,
agent: &str,
kind: ApprovalKind,
commit_ref: &str,
description: Option<&str>,
submitter: &str,
) -> Result<i64> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO approvals
(agent, kind, commit_ref, requested_at, status, description, submitter)
VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6)",
params![
agent,
<&str>::from(kind),
commit_ref,
Utc::now().timestamp(),
description,
submitter,
],
)?;
Ok(conn.last_insert_rowid())
}
/// The agent that submitted approval `id` (the authenticated socket
/// caller at submit time). `None` for legacy rows predating the
/// `submitter` column — callers route those to the root agent.
///
/// # Errors
///
/// Returns an error if the sqlite prepare/query fails. A missing row
/// or a `NULL` submitter is not an error — both yield `Ok(None)`.
pub fn submitter_of(&self, id: i64) -> Result<Option<String>> {
let conn = self.conn.lock().unwrap();
let submitter: Option<String> = conn
.query_row(
"SELECT submitter FROM approvals WHERE id = ?1",
params![id],
|row| row.get(0),
)
.optional()?
.flatten();
Ok(submitter)
}
/// Last `limit` resolved approvals (approved / denied / failed),
/// newest-first. Drives the history tab on the dashboard.
pub fn recent_resolved(&self, limit: u64) -> Result<Vec<Approval>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, description
FROM approvals
WHERE status IN ('approved', 'denied', 'failed', 'cancelled')
ORDER BY resolved_at DESC, id DESC
LIMIT ?1",
)?;
let rows = stmt.query_map([limit], row_to_approval)?;
Ok(collect_lenient(rows))
}
pub fn pending(&self) -> Result<Vec<Approval>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, description
FROM approvals
WHERE status = 'pending'
ORDER BY id ASC",
)?;
let rows = stmt.query_map([], row_to_approval)?;
Ok(collect_lenient(rows))
}
pub fn get(&self, id: i64) -> Result<Option<Approval>> {
let conn = self.conn.lock().unwrap();
conn.query_row(
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, description
FROM approvals WHERE id = ?1",
params![id],
row_to_approval,
)
.optional()
.map_err(Into::into)
}
/// Mark pending -> approved (or fail if not pending). Returns the (now-updated)
/// approval so the caller can run the action and pass the agent name.
pub fn mark_approved(&self, id: i64) -> Result<Approval> {
let conn = self.conn.lock().unwrap();
let row: Option<ApprovalLookup> = conn
.query_row(
ApprovalLookup::SELECT,
params![id],
ApprovalLookup::from_row,
)
.optional()?;
let Some(row) = row else {
bail!("approval {id} not found");
};
if row.status != "pending" {
bail!("approval {id} is {}, not pending", row.status);
}
let resolved_at = Utc::now().timestamp();
conn.execute(
"UPDATE approvals SET status = 'approved', resolved_at = ?1 WHERE id = ?2",
params![resolved_at, id],
)?;
Ok(Approval {
id,
agent: row.agent,
kind: kind_from_str(&row.kind)?,
commit_ref: row.commit_ref,
requested_at: hive_sh4re::wire_time::from_secs(row.requested_at),
status: ApprovalStatus::Approved,
resolved_at: Some(hive_sh4re::wire_time::from_secs(resolved_at)),
note: None,
description: row.description,
})
}
pub fn mark_denied(&self, id: i64, note: Option<&str>) -> Result<()> {
let conn = self.conn.lock().unwrap();
let affected = conn.execute(
"UPDATE approvals SET status = 'denied', resolved_at = ?1, note = ?2
WHERE id = ?3 AND status = 'pending'",
params![Utc::now().timestamp(), note, id],
)?;
if affected == 0 {
bail!("approval {id} not pending");
}
Ok(())
}
pub fn mark_failed(&self, id: i64, note: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE approvals SET status = 'failed', resolved_at = ?1, note = ?2 WHERE id = ?3",
params![Utc::now().timestamp(), note, id],
)?;
Ok(())
}
/// Withdraw a pending approval. Returns the now-updated
/// row so the caller can emit `ApprovalResolved` with the right
/// kind / agent. Errors if the approval isn't pending — once
/// it's approved/denied/failed/cancelled, the resolution is final.
pub fn mark_cancelled(&self, id: i64, canceller: &str) -> Result<Approval> {
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
let row: Option<ApprovalLookup> = tx
.query_row(
ApprovalLookup::SELECT,
params![id],
ApprovalLookup::from_row,
)
.optional()?;
let Some(row) = row else {
bail!("approval {id} not found");
};
if row.status != "pending" {
bail!("approval {id} is {}, not pending", row.status);
}
let resolved_at = Utc::now().timestamp();
let note = format!("cancelled by {canceller}");
tx.execute(
"UPDATE approvals SET status = 'cancelled', resolved_at = ?1, note = ?2 WHERE id = ?3",
params![resolved_at, note, id],
)?;
tx.commit()?;
Ok(Approval {
id,
agent: row.agent,
kind: kind_from_str(&row.kind)?,
commit_ref: row.commit_ref,
requested_at: hive_sh4re::wire_time::from_secs(row.requested_at),
status: ApprovalStatus::Cancelled,
resolved_at: Some(hive_sh4re::wire_time::from_secs(resolved_at)),
note: Some(note),
description: row.description,
})
}
/// Mark every pending approval for `agent` as failed (returns rows affected).
/// Used by `destroy` to clear the queue of an agent that no longer exists.
pub fn fail_pending_for_agent(&self, agent: &str, note: &str) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let n = conn.execute(
"UPDATE approvals SET status = 'failed', resolved_at = ?1, note = ?2
WHERE agent = ?3 AND status = 'pending'",
params![Utc::now().timestamp(), note, agent],
)?;
Ok(n)
}
}
/// Columns needed to rebuild an [`Approval`] after a status transition,
/// shared by `mark_approved` / `mark_cancelled`. Replaces a 7-field
/// tuple that tripped `clippy::type_complexity` and was duplicated
/// across both callers (one suppressed the lint, the other aliased the
/// tuple) — one named projection + mapper now backs both.
struct ApprovalLookup {
agent: hive_types::Ident,
kind: String,
commit_ref: String,
requested_at: i64,
status: String,
description: Option<String>,
}
impl ApprovalLookup {
/// The single-row lookup by id (`?1`). Column order matches
/// [`ApprovalLookup::from_row`].
const SELECT: &str = "SELECT agent, kind, commit_ref, requested_at, status, description \
FROM approvals WHERE id = ?1";
fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
let agent: String = row.get(0)?;
let agent = hive_types::Ident::parse(&agent).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
0,
rusqlite::types::Type::Text,
format!("invalid approval agent {agent:?}: {e}").into(),
)
})?;
Ok(Self {
agent,
kind: row.get(1)?,
commit_ref: row.get(2)?,
requested_at: row.get(3)?,
status: row.get(4)?,
description: row.get(5)?,
})
}
}
/// Collect approval rows, dropping any that fail to deserialize. A single
/// malformed / unknown-kind row must never blank the whole list:
/// `collect::<Result<Vec>>()` is all-or-nothing, so one bad row used to make
/// `pending()` / `recent_resolved()` error out wholesale — the dashboard then
/// rendered an empty approvals queue.
///
/// Drops are aggregated by error message and logged **once per call** rather
/// than one line per row: a batch of legacy unknown-kind rows (e.g. the
/// retired `apply_commit` approvals from the removed non-PR config flow) sit
/// `pending` forever and would otherwise flood the journal with an identical
/// warning on every dashboard render. Aggregating keeps the signal (how many,
/// which error) without the flood.
fn collect_lenient(rows: impl Iterator<Item = rusqlite::Result<Approval>>) -> Vec<Approval> {
let mut out = Vec::new();
let mut dropped: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for r in rows {
match r {
Ok(a) => out.push(a),
Err(e) => *dropped.entry(e.to_string()).or_default() += 1,
}
}
if !dropped.is_empty() {
let total: usize = dropped.values().sum();
tracing::warn!(
dropped = total,
by_error = ?dropped,
"skipped unparseable approval rows"
);
}
out
}
fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
// Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note, description.
let kind: String = row.get(2)?;
let kind = match kind.as_str() {
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
other => {
return Err(rusqlite::Error::FromSqlConversionFailure(
2,
rusqlite::types::Type::Text,
format!("unknown approval kind '{other}'").into(),
));
}
};
let status: String = row.get(5)?;
let status = match status.as_str() {
"pending" => ApprovalStatus::Pending,
"approved" => ApprovalStatus::Approved,
"denied" => ApprovalStatus::Denied,
"failed" => ApprovalStatus::Failed,
"cancelled" => ApprovalStatus::Cancelled,
other => {
return Err(rusqlite::Error::FromSqlConversionFailure(
5,
rusqlite::types::Type::Text,
format!("unknown approval status '{other}'").into(),
));
}
};
let agent: String = row.get(1)?;
let agent = hive_types::Ident::parse(&agent).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
1,
rusqlite::types::Type::Text,
format!("invalid approval agent {agent:?}: {e}").into(),
)
})?;
Ok(Approval {
id: row.get(0)?,
agent,
kind,
commit_ref: row.get(3)?,
requested_at: hive_sh4re::wire_time::from_secs(row.get(4)?),
status,
resolved_at: row
.get::<_, Option<i64>>(6)?
.map(hive_sh4re::wire_time::from_secs),
note: row.get(7)?,
description: row.get(8)?,
})
}
fn kind_from_str(s: &str) -> Result<ApprovalKind> {
Ok(match s {
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
other => bail!("unknown approval kind '{other}'"),
})
}
#[cfg(test)]
mod tests {
use super::*;
use hive_sh4re::approvals::ApprovalKind;
fn open_temp() -> (tempfile::TempDir, std::path::PathBuf, Approvals) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("approvals.sqlite");
let db = Approvals::open(&path).expect("open approvals db");
(dir, path, db)
}
#[test]
fn mark_cancelled_transitions_pending_row() {
// Manager withdraws a pending approval. Row leaves pending(),
// shows up in recent_resolved() with the cancelled status + a
// "cancelled by <who>" note.
let (_dir, _path, db) = open_temp();
let id = db
.submit_kind(
"bitburner",
ApprovalKind::UpdateMetaInputs,
"[]",
Some("test"),
"bitburner",
)
.unwrap();
let row = db.mark_cancelled(id, "manager").expect("cancel");
assert_eq!(row.id, id);
assert!(matches!(row.status, ApprovalStatus::Cancelled));
assert_eq!(row.note.as_deref(), Some("cancelled by manager"));
assert!(row.resolved_at.is_some());
assert!(db.pending().unwrap().is_empty(), "row leaves pending");
let resolved = db.recent_resolved(10).unwrap();
assert_eq!(resolved.len(), 1);
assert!(matches!(resolved[0].status, ApprovalStatus::Cancelled));
}
#[test]
fn mark_cancelled_refuses_already_resolved_row() {
// Once approved/denied/failed/cancelled the resolution is
// final — re-cancelling errors instead of silently overwriting.
let (_dir, _path, db) = open_temp();
let id = db
.submit_kind("a", ApprovalKind::UpdateMetaInputs, "[]", None, "a")
.unwrap();
db.mark_cancelled(id, "manager").expect("first cancel");
let err = db
.mark_cancelled(id, "manager")
.expect_err("second cancel must fail");
assert!(err.to_string().contains("not pending"), "{err}");
}
#[test]
fn unknown_kind_row_is_skipped_not_fatal() {
// A single malformed / future-kind row must not blank the
// whole list — collect_lenient skips it instead of failing.
let (_dir, path, db) = open_temp();
let good = db
.submit_kind("good", ApprovalKind::UpdateMetaInputs, "[]", None, "good")
.unwrap();
let raw = Connection::open(&path).unwrap();
raw.execute(
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status)
VALUES ('weird', 'from_the_future', '', 0, 'pending')",
[],
)
.unwrap();
let pending = db
.pending()
.expect("pending() must survive an unparseable row");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, good);
}
#[test]
fn submitter_round_trips_and_legacy_reads_none() {
// A submitted approval records its submitter; a legacy row
// (inserted without the column) reads back as None so callers
// fall back to the root agent.
let (_dir, path, db) = open_temp();
let id = db
.submit_kind(
"child",
ApprovalKind::UpdateMetaInputs,
"[]",
None,
"parent",
)
.unwrap();
assert_eq!(db.submitter_of(id).unwrap().as_deref(), Some("parent"));
let raw = Connection::open(&path).unwrap();
raw.execute(
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status)
VALUES ('old', 'update_meta_inputs', '', 0, 'pending')",
[],
)
.unwrap();
let legacy_id = raw.last_insert_rowid();
assert_eq!(db.submitter_of(legacy_id).unwrap(), None);
}
/// A database written before v5 still holds `merge_config_pr` rows,
/// pending and resolved. Opening it drops them and keeps every other row,
/// so the lists and `get` read cleanly.
#[test]
fn a_pre_v5_merge_config_pr_row_is_dropped_on_open() {
let (_dir, path, db) = open_temp();
let kept = db
.submit_kind("iris", ApprovalKind::UpdateMetaInputs, "[]", None, "iris")
.unwrap();
drop(db);
let raw = Connection::open(&path).unwrap();
raw.execute_batch(
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status, fetched_sha)
VALUES ('iris', 'merge_config_pr', '42', 0, 'pending', 'abc123');
INSERT INTO approvals (agent, kind, commit_ref, requested_at, status, resolved_at)
VALUES ('iris', 'merge_config_pr', '41', 0, 'approved', 1);
UPDATE schema_versions SET version = 4 WHERE store = 'approvals';",
)
.unwrap();
let old: i64 = raw.last_insert_rowid();
drop(raw);
let db = Approvals::open(&path).expect("a pre-v5 db opens");
let pending = db.pending().expect("pending");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, kept);
assert!(db.recent_resolved(10).unwrap().is_empty());
assert!(db.get(old).unwrap().is_none());
}
/// A database written before v6 still holds `schedule_prompt` rows.
/// Opening it drops them and keeps every other row.
#[test]
fn a_pre_v6_schedule_prompt_row_is_dropped_on_open() {
let (_dir, path, db) = open_temp();
let kept = db
.submit_kind("iris", ApprovalKind::UpdateMetaInputs, "[]", None, "iris")
.unwrap();
drop(db);
let raw = Connection::open(&path).unwrap();
raw.execute_batch(
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status)
VALUES ('iris', 'schedule_prompt', '{}', 0, 'pending');
UPDATE schema_versions SET version = 5 WHERE store = 'approvals';",
)
.unwrap();
let old: i64 = raw.last_insert_rowid();
drop(raw);
let db = Approvals::open(&path).expect("a pre-v6 db opens");
let pending = db.pending().expect("pending");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, kept);
assert!(db.get(old).unwrap().is_none());
}
}

View file

@ -62,7 +62,7 @@ pub struct Migration {
/// the shared `schema_versions` table. /// the shared `schema_versions` table.
/// ///
/// - `subsystem` — unique key for this store in `schema_versions` /// - `subsystem` — unique key for this store in `schema_versions`
/// (e.g. `"approvals"`, `"broker"`). /// (e.g. `"broker"`, `"agent_power"`).
/// - `migrations` — ordered list of [`Migration`]s; migration `i` runs when /// - `migrations` — ordered list of [`Migration`]s; migration `i` runs when
/// the stored version is `i`. A pre-versioning DB (no `schema_versions` /// the stored version is `i`. A pre-versioning DB (no `schema_versions`
/// row) starts at version 0 and re-walks every migration, but each /// row) starts at version 0 and re-walks every migration, but each
@ -143,7 +143,7 @@ fn column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool> {
/// Open a connection to the sqlite file at `path`, creating the parent /// Open a connection to the sqlite file at `path`, creating the parent
/// directory if needed. `subsystem` labels error contexts (`"broker"`, /// directory if needed. `subsystem` labels error contexts (`"broker"`,
/// `"approvals"`, …). /// `"agent_power"`, …).
pub fn open(path: &Path, subsystem: &str) -> Result<Connection> { pub fn open(path: &Path, subsystem: &str) -> Result<Connection> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent)

View file

@ -1,9 +1,8 @@
//! Sqlite-backed host-side stores (broker, approval queue, read-only //! Sqlite-backed host-side stores (broker, read-only scheduled prompts,
//! scheduled prompts, build logs, power intent) plus the shared connection open/migration //! build logs, power intent) plus the shared connection open/migration
//! helper (`db`). Each submodule is re-exported at the crate root, so //! helper (`db`). Each submodule is re-exported at the crate root, so
//! `crate::broker::…` etc. keep working unchanged. //! `crate::broker::…` etc. keep working unchanged.
pub mod approvals;
pub mod broker; pub mod broker;
pub mod build_logs; pub mod build_logs;
pub mod db; pub mod db;

View file

@ -5,7 +5,7 @@
//! //!
//! Stored as the `agent_power` table in the coordinator DB //! Stored as the `agent_power` table in the coordinator DB
//! (`/var/lib/hyperhive/db/broker.sqlite`, one tiny row per agent) — //! (`/var/lib/hyperhive/db/broker.sqlite`, one tiny row per agent) —
//! same one-file-many-modules pattern as `approvals` / //! same one-file-many-modules pattern as `broker` /
//! `scheduled_prompts`, each with its own connection. Intent persists //! `scheduled_prompts`, each with its own connection. Intent persists
//! across hive-c0re restarts; in-flight //! across hive-c0re restarts; in-flight
//! queue work deliberately does not. Setting `wanted` is never a //! queue work deliberately does not. Setting `wanted` is never a
@ -72,7 +72,7 @@ pub struct PowerStore {
impl PowerStore { impl PowerStore {
/// Open (a connection to) the shared coordinator DB and ensure the /// Open (a connection to) the shared coordinator DB and ensure the
/// `agent_power` table exists. `db_path` is the same sqlite file /// `agent_power` table exists. `db_path` is the same sqlite file
/// the broker / approvals stores open. /// the broker / scheduled-prompts stores open.
pub fn open(db_path: &Path) -> Result<Self> { pub fn open(db_path: &Path) -> Result<Self> {
let conn = crate::db::open(db_path, "agent_power")?; let conn = crate::db::open(db_path, "agent_power")?;
conn.execute_batch(SCHEMA) conn.execute_batch(SCHEMA)

View file

@ -339,8 +339,8 @@ async fn apply_rev(agent: &str, rev: &str) -> bool {
/// power intent, then insert the DAG. /// power intent, then insert the DAG.
/// ///
/// The swarm has already created the identity, the forge repo and its config; /// The swarm has already created the identity, the forge repo and its config;
/// what is left is hive-local, and there is no approval to wait on because the /// what is left is hive-local, and the operator's click at swarm level is the
/// operator's click at swarm level is the authorisation. /// authorisation.
/// ///
/// Shared with [`crate::workers::wanted`] for the **seeding**, not the insert. /// Shared with [`crate::workers::wanted`] for the **seeding**, not the insert.
/// `first_deploy`'s tail `Reconcile` seeds `Wanted` from the container's /// `first_deploy`'s tail `Reconcile` seeds `Wanted` from the container's

View file

@ -113,8 +113,8 @@ fn plan_root_agent(list_result: anyhow::Result<Vec<String>>) -> RootAgentPlan {
/// Auto-create the manager container on startup if it isn't already there. /// Auto-create the manager container on startup if it isn't already there.
/// hive-c0re manages the manager end-to-end: operators no longer declare /// hive-c0re manages the manager end-to-end: operators no longer declare
/// `containers.h-ruth` in their host NixOS config. Bypasses the approval /// `containers.h-ruth` in their host NixOS config. The root/manager is
/// queue — the root/manager is auto-managed by default. Operators who /// auto-managed by default. Operators who
/// don't want a root agent at all set `services.hyperhive.ruthless = true`, /// don't want a root agent at all set `services.hyperhive.ruthless = true`,
/// which short-circuits this whole function. Idempotent. /// which short-circuits this whole function. Idempotent.
pub async fn ensure_root_agent(coord: &Arc<Coordinator>) -> Result<()> { pub async fn ensure_root_agent(coord: &Arc<Coordinator>) -> Result<()> {

View file

@ -1,7 +1,7 @@
//! Per-container crash and login-state watcher. Polls every managed //! Per-container crash and login-state watcher. Polls every managed
//! container on a 10s interval. Fires `ContainerCrash`, `LoggedIn`, //! container on a 10s interval. Fires `ContainerCrash`, `LoggedIn`,
//! and `NeedsLogin` helper events. Event semantics and the //! and `NeedsLogin` helper events. Event semantics and the
//! `RECENT_TRANSIENT_GRACE` window: `docs/agent-lifecycle/approvals.md::Helper events`. //! `RECENT_TRANSIENT_GRACE` window: `docs/agent-lifecycle/config-changes.md::Helper events`.
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;

View file

@ -8,14 +8,14 @@ harness (and the manager) and the `hive-c0re` daemon.
Re-homed out of `hive-sh4re` so this one socket owns its protocol crate, Re-homed out of `hive-sh4re` so this one socket owns its protocol crate,
mirroring the `hive-host-sock` / `hive-priv-sock` splits. The shared payload mirroring the `hive-host-sock` / `hive-priv-sock` splits. The shared payload
types it references (`Message`, `LooseEnd`, `Approval`, …) stay in `hive-sh4re`, types it references (`Message`, `LooseEnd`, …) stay in `hive-sh4re`,
which this crate depends on — this crate is just the request/response envelope which this crate depends on — this crate is just the request/response envelope
for this socket. for this socket.
## Not to be confused with `hive-agent-sock` ## Not to be confused with `hive-agent-sock`
This is the **host-served** protocol: the harness talks _out_ to `hive-c0re` This is the **host-served** protocol: the harness talks _out_ to `hive-c0re`
over `/run/hive/mcp.sock` (broker sends, approvals, lifecycle). over `/run/hive/mcp.sock` (broker sends, lifecycle).
`hive-agent-sock` is the separate _in-container_ socket the harness serves to `hive-agent-sock` is the separate _in-container_ socket the harness serves to
its own local producers — that one never leaves the container. See its own local producers — that one never leaves the container. See
`docs/trust-boundary/boundary.md` for the socket topology. `docs/trust-boundary/boundary.md` for the socket topology.

View file

@ -4,11 +4,11 @@
//! in-container harness (and the manager) and the `hive-c0re` daemon over the //! in-container harness (and the manager) and the `hive-c0re` daemon over the
//! per-agent mcp socket. Re-homed out of `hive-sh4re` so this one socket owns //! per-agent mcp socket. Re-homed out of `hive-sh4re` so this one socket owns
//! its own protocol crate, mirroring `hive-host-sock` / `hive-priv-sock`. The //! its own protocol crate, mirroring `hive-host-sock` / `hive-priv-sock`. The
//! shared payload types it references (`Message`, `LooseEnd`, `Approval`, …) //! shared payload types it references (`Message`, `LooseEnd`, …)
//! stay in `hive-sh4re`, which this crate depends on. //! stay in `hive-sh4re`, which this crate depends on.
use hive_sh4re::container::MatrixIdentity; use hive_sh4re::container::MatrixIdentity;
use hive_sh4re::inbox::{CancelLooseEndKind, DeliveredMessage, InboxRow, LooseEnd}; use hive_sh4re::inbox::{DeliveredMessage, InboxRow, LooseEnd};
use hive_types::Ident; use hive_types::Ident;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -76,10 +76,6 @@ pub enum Request {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<Ident>, name: Option<Ident>,
}, },
/// Cancel an open thread the agent owns. Authorisation +
/// per-kind semantics in
/// `docs/process/conventions.md::Loose-ends wire shape`.
CancelLooseEnd { kind: CancelLooseEndKind, id: i64 },
/// Mark every message popped since the last `AckTurn` as handled. /// Mark every message popped since the last `AckTurn` as handled.
/// Harness↔broker pairing fired after `TurnOutcome::Ok`. See /// Harness↔broker pairing fired after `TurnOutcome::Ok`. See
/// `docs/process/conventions.md::Broker delivery + ack cycle`. /// `docs/process/conventions.md::Broker delivery + ack cycle`.

View file

@ -15,8 +15,8 @@ thin dependency possible.
## Shape ## Shape
Serde-derived request/response enums for the host admin protocol. The larger Serde-derived request/response enums for the host admin protocol. The larger
shared payload types some variants reference (`Approval`, `AgentStatusRow`) shared payload type some variants reference (`AgentStatusRow`) stays in
stay in `hive-sh4re` — this crate is only the protocol envelope, no server or `hive-sh4re` — this crate is only the protocol envelope, no server or
client implementation. client implementation.
Its own `jobs` module is the exception: the job-queue vocabulary `hivectl` Its own `jobs` module is the exception: the job-queue vocabulary `hivectl`

View file

@ -6,13 +6,11 @@
//! job-queue wire types ([`jobs`]) travel on this socket and the dashboard //! job-queue wire types ([`jobs`]) travel on this socket and the dashboard
//! channels `hive-c0re` serves off the same snapshot, and on nothing else — //! channels `hive-c0re` serves off the same snapshot, and on nothing else —
//! their whole consumer set is `hive-c0re` + `hivectl` + this crate — so they //! their whole consumer set is `hive-c0re` + `hivectl` + this crate — so they
//! live here rather than in the shared crate. The payload types genuinely //! live here rather than in the shared crate. The payload type genuinely
//! shared with the *agent* sockets (`Approval`, `AgentStatusRow`) stay in //! shared with the *agent* sockets (`AgentStatusRow`) stays in `hive-sh4re`.
//! `hive-sh4re`.
use std::path::PathBuf; use std::path::PathBuf;
use hive_sh4re::approvals::Approval;
use hive_sh4re::container::AgentStatusRow; use hive_sh4re::container::AgentStatusRow;
use hive_types::Ident; use hive_types::Ident;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -111,7 +109,7 @@ pub enum HostRequest {
/// `Kill`. /// `Kill`.
Kill { name: Ident }, Kill { name: Ident },
/// Tear down a sub-agent container, optionally purging state. /// Tear down a sub-agent container, optionally purging state.
/// See `docs/agent-lifecycle/approvals.md::Destroy semantics`. /// See `docs/agent-lifecycle/config-changes.md::Destroy semantics`.
Destroy { Destroy {
name: Ident, name: Ident,
#[serde(default)] #[serde(default)]
@ -213,12 +211,6 @@ pub enum HostRequest {
/// this request just answers whatever it's asked. Result: /// this request just answers whatever it's asked. Result:
/// [`HostResponse::nodes`]. /// [`HostResponse::nodes`].
QueueNodes { ids: Vec<u64> }, QueueNodes { ids: Vec<u64> },
/// List pending approval requests.
Pending,
/// Approve a pending request by id; the action runs immediately.
Approve { id: i64 },
/// Deny a pending request by id.
Deny { id: i64 },
/// Declare an agent's CPU/memory overrides for the per-container /// Declare an agent's CPU/memory overrides for the per-container
/// systemd drop-in, persisted to `meta/resource-limits.json`. /// systemd drop-in, persisted to `meta/resource-limits.json`.
/// ///
@ -464,7 +456,7 @@ pub struct HiveUrls {
/// client compares this against its own compiled-in copy of the constant /// client compares this against its own compiled-in copy of the constant
/// and warns (does not fail the request) on mismatch — see /// and warns (does not fail the request) on mismatch — see
/// `hivectl::client::version_mismatch_hint`, the one place that reads it. /// `hivectl::client::version_mismatch_hint`, the one place that reads it.
pub const HOST_SOCK_VERSION: u32 = 1; pub const HOST_SOCK_VERSION: u32 = 2;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostResponse { pub struct HostResponse {
@ -483,8 +475,6 @@ pub struct HostResponse {
pub error: Option<String>, pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub agents: Option<Vec<String>>, pub agents: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approvals: Option<Vec<Approval>>,
/// `Urls` result — this hive's domain plus the browser-facing /// `Urls` result — this hive's domain plus the browser-facing
/// home / forge / matrix URLs. `None` for every other request kind. /// home / forge / matrix URLs. `None` for every other request kind.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@ -539,7 +529,6 @@ impl Default for HostResponse {
ok: false, ok: false,
error: None, error: None,
agents: None, agents: None,
approvals: None,
urls: None, urls: None,
agent_statuses: None, agent_statuses: None,
agent_exists: None, agent_exists: None,
@ -578,15 +567,6 @@ impl HostResponse {
} }
} }
#[must_use]
pub fn pending(approvals: Vec<Approval>) -> Self {
Self {
ok: true,
approvals: Some(approvals),
..Self::default()
}
}
/// `Urls` result — this hive's domain + browser-facing web URLs. /// `Urls` result — this hive's domain + browser-facing web URLs.
#[must_use] #[must_use]
pub fn urls(urls: HiveUrls) -> Self { pub fn urls(urls: HiveUrls) -> Self {

View file

@ -1,7 +1,7 @@
# hive-sh4re # hive-sh4re
The **shared payload vocabulary** between `hive-c0re` and the in-container The **shared payload vocabulary** between `hive-c0re` and the in-container
harness — the common types (`Message`, `Approval`, `LooseEnd`, `HelperEvent`, …) harness — the common types (`Message`, `LooseEnd`, `HelperEvent`, …)
that the per-socket wire protocols are built from. The request/response that the per-socket wire protocols are built from. The request/response
_envelopes_ themselves now live in the per-socket crates (below); this crate _envelopes_ themselves now live in the per-socket crates (below); this crate
holds the payloads they carry. holds the payloads they carry.
@ -17,7 +17,7 @@ in all of `hive-sh4re`:
- `hive-priv-sock` — the privileged-helper socket - `hive-priv-sock` — the privileged-helper socket
Those crates re-export or reference the payload types that still live here Those crates re-export or reference the payload types that still live here
(`Approval`, `Message`, `LooseEnd`, …). (`Message`, `LooseEnd`, …).
## Modules ## Modules

View file

@ -1,68 +0,0 @@
//! The approval queue wire shape: one row (`Approval`) per pending/resolved
//! operator decision, its `kind` discriminator, and the terminal-state enum.
//! `ReminderStats` lives here too — small enough not to earn its own file,
//! and unrelated to any other topic module.
use chrono::{DateTime, Utc};
use hive_types::Ident;
use serde::{Deserialize, Serialize};
/// One row in the approval queue. `commit_ref` is overloaded per
/// `kind` — see `docs/agent-lifecycle/approvals.md::Approval kinds (wire shapes)`
/// for the encoding table and lifecycle.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Approval {
pub id: i64,
pub agent: Ident,
pub kind: ApprovalKind,
/// Kind-specific payload (git sha / inputs array / empty). See the
/// Approval struct doc.
pub commit_ref: String,
pub requested_at: DateTime<Utc>,
pub status: ApprovalStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
/// Free-text description the manager attached at submission time;
/// shown on the dashboard approval card.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
/// What action the approval, when granted, will trigger.
/// Variant-specific payload encoding + flow lives in
/// `docs/agent-lifecycle/approvals.md::Approval kinds (wire shapes)`.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, strum::IntoStaticStr)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum ApprovalKind {
/// Run `nix flake update [inputs...]` on the meta flake and commit
/// the resulting lock changes.
UpdateMetaInputs,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalStatus {
Pending,
Approved,
Denied,
Failed,
/// Manager withdrew the request before the operator acted on it.
/// Distinct from `Denied` (operator decision) and `Failed`
/// (post-approval lifecycle error). See
/// `docs/agent-lifecycle/approvals.md::Withdrawing a pending approval`.
Cancelled,
}
/// Reminder activity statistics for an agent over a time window.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReminderStats {
/// Total reminders scheduled in the window (`created_at` >= cutoff).
pub scheduled: u64,
/// Reminders that have been delivered in the window (`sent_at` IS NOT NULL).
pub delivered: u64,
/// Reminders still pending in the window (`sent_at` IS NULL).
pub pending: u64,
}

View file

@ -117,6 +117,17 @@ pub enum ReminderTiming {
At { unix_timestamp: i64 }, At { unix_timestamp: i64 },
} }
/// Reminder activity statistics for an agent over a time window.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReminderStats {
/// Total reminders scheduled in the window (`created_at` >= cutoff).
pub scheduled: u64,
/// Reminders that have been delivered in the window (`sent_at` IS NOT NULL).
pub delivered: u64,
/// Reminders still pending in the window (`sent_at` IS NULL).
pub pending: u64,
}
/// One row in the response to `GetLooseEnds`. Tagged enum so new /// One row in the response to `GetLooseEnds`. Tagged enum so new
/// thread kinds can land without breaking existing handlers. /// thread kinds can land without breaking existing handlers.
/// Per-flavour scoping + per-variant fields + clock-anomaly /// Per-flavour scoping + per-variant fields + clock-anomaly
@ -125,15 +136,6 @@ pub enum ReminderTiming {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum LooseEnd { pub enum LooseEnd {
/// A pending approval row.
Approval {
id: i64,
agent: String,
commit_ref: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
age_seconds: u64,
},
/// A scheduled but un-delivered reminder row. /// A scheduled but un-delivered reminder row.
Reminder { Reminder {
id: i64, id: i64,
@ -190,9 +192,8 @@ pub enum LooseEnd {
/// Age in seconds between two unix timestamps, clamped to 0 when `then` /// Age in seconds between two unix timestamps, clamped to 0 when `then`
/// is in the future. This is what backs the `age_seconds` promise on /// is in the future. This is what backs the `age_seconds` promise on
/// every [`LooseEnd`] variant above, so it lives beside the enum rather /// every [`LooseEnd`] variant above, so it lives beside the enum;
/// than in either producer: hive-c0re derives approval ages here, and /// hive-agent's in-agent socket server derives reminder + todo ages with it.
/// hive-agent's in-agent socket server derives reminder + todo ages.
/// ///
/// Both clamps are load-bearing and neither substitutes for the other: /// Both clamps are load-bearing and neither substitutes for the other:
/// `saturating_sub` keeps the subtraction itself from overflowing on /// `saturating_sub` keeps the subtraction itself from overflowing on
@ -205,17 +206,6 @@ pub fn saturating_age(now: i64, then: i64) -> u64 {
u64::try_from(delta).unwrap_or(0) u64::try_from(delta).unwrap_or(0)
} }
/// Kind discriminator for `CancelLooseEnd`. Per-kind store +
/// authorisation rules live in
/// `docs/process/conventions.md::Loose-ends wire shape`.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CancelLooseEndKind {
Reminder,
/// Withdraw a pending approval (manager surface only).
Approval,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -1,6 +1,5 @@
//! Wire types shared between `hive-c0re` and the in-container harness. //! Wire types shared between `hive-c0re` and the in-container harness.
pub mod approvals;
pub mod assets; pub mod assets;
pub mod bash_task; pub mod bash_task;
pub mod container; pub mod container;

View file

@ -6,8 +6,6 @@
use hive_types::Ident; use hive_types::Ident;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::approvals::ApprovalStatus;
/// Logical name the broker uses for the manager. /// Logical name the broker uses for the manager.
pub const MANAGER_AGENT: &str = "ruth"; pub const MANAGER_AGENT: &str = "ruth";
@ -42,19 +40,10 @@ pub fn trusted_sender(s: &str) -> Ident {
/// Out-of-band events the host-side daemon pushes to the manager's inbox. /// Out-of-band events the host-side daemon pushes to the manager's inbox.
/// Serialised as JSON in `Message::body` (sender = `SYSTEM_SENDER`). /// Serialised as JSON in `Message::body` (sender = `SYSTEM_SENDER`).
/// Per-variant triggers + the optional `sha`/`tag` semantics live in /// Per-variant triggers + the optional `sha`/`tag` semantics live in
/// `docs/agent-lifecycle/approvals.md::Helper events to the submitting agent`. /// `docs/agent-lifecycle/config-changes.md::Helper events`.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")] #[serde(tag = "event", rename_all = "snake_case")]
pub enum HelperEvent { pub enum HelperEvent {
/// An approval transitioned to a terminal state.
ApprovalResolved {
id: i64,
agent: String,
commit_ref: String,
status: ApprovalStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
/// A sub-agent's recorded flake rev is stale relative to hyperhive. /// A sub-agent's recorded flake rev is stale relative to hyperhive.
NeedsUpdate { agent: String }, NeedsUpdate { agent: String },
/// Container exited without an operator-initiated stop (crash). /// Container exited without an operator-initiated stop (crash).

View file

@ -157,11 +157,6 @@ pub enum ToolGroup {
/// `meta/capabilities.json` grants still parse; `tools()` returns /// `meta/capabilities.json` grants still parse; `tools()` returns
/// `&[]`. /// `&[]`.
Lifecycle, Lifecycle,
/// Grants no MCP tool today — `request_update_meta_inputs`, its only
/// member, was removed with no replacement. Still a live
/// server-side gate: `cancel_loose_end`'s approval-cancel arm
/// requires it (`socket_server::check_can_cancel_approval`).
Approvals,
/// Gates no tool today — `create_repo`, its only member, was removed /// Gates no tool today — `create_repo`, its only member, was removed
/// with no replacement. Kept so existing `meta/capabilities.json` /// with no replacement. Kept so existing `meta/capabilities.json`
/// grants still parse; `tools()` returns `&[]`. /// grants still parse; `tools()` returns `&[]`.
@ -219,15 +214,15 @@ impl ToolGroup {
/// The MCP tool names (without the `mcp__hyperhive__` prefix) in this group. /// The MCP tool names (without the `mcp__hyperhive__` prefix) in this group.
/// Returns `&[]` for `WebTools` (it enables Claude built-in tools, not MCP /// Returns `&[]` for `WebTools` (it enables Claude built-in tools, not MCP
/// tools — see `builtin_tools()`) and for `Lifecycle` / `Approvals` / /// tools — see `builtin_tools()`) and for `Lifecycle` / `Forge` (their
/// `Forge` (their tools were removed); see each variant's doc comment. /// tools were removed); see each variant's doc comment.
#[must_use] #[must_use]
pub fn tools(self) -> &'static [&'static str] { pub fn tools(self) -> &'static [&'static str] {
match self { match self {
Self::Messaging => &["send", "recv", "ack_until"], Self::Messaging => &["send", "recv", "ack_until"],
Self::Meta => &["get_agent_meta"], Self::Meta => &["get_agent_meta"],
Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"], Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"],
// All five empty, for different reasons — see each // All four empty, for different reasons — see each
// variant's own doc comment above. `Execution` grants the // variant's own doc comment above. `Execution` grants the
// out-of-process `bash` MCP server // out-of-process `bash` MCP server
// (`mcp__bash__run`/`status`/`kill`), gated at config-render // (`mcp__bash__run`/`status`/`kill`), gated at config-render
@ -236,15 +231,11 @@ impl ToolGroup {
// out-of-process server has no later enforcement point, so // out-of-process server has no later enforcement point, so
// that gate is the actual security boundary. `WebTools` // that gate is the actual security boundary. `WebTools`
// grants Claude built-in tools, not MCP ones; see // grants Claude built-in tools, not MCP ones; see
// `builtin_tools()`. `Lifecycle`, `Approvals` and `Forge` each // `builtin_tools()`. `Lifecycle` and `Forge` each listed
// listed exactly one tool — `list_containers`, // exactly one tool — `list_containers` and `create_repo`
// `request_update_meta_inputs` and `create_repo` respectively // respectively — and both tools were removed outright; the
// — and all three tools were removed outright; the variants // variants stay so existing grants parse.
// stay so existing grants parse, and `Approvals` still gates Self::Lifecycle | Self::Forge | Self::Execution | Self::WebTools => &[],
// `cancel_loose_end`'s approval-cancel arm server-side.
Self::Lifecycle | Self::Approvals | Self::Forge | Self::Execution | Self::WebTools => {
&[]
}
} }
} }
@ -297,7 +288,6 @@ impl ToolGroup {
Self::Meta, Self::Meta,
Self::Inbox, Self::Inbox,
Self::Lifecycle, Self::Lifecycle,
Self::Approvals,
Self::Execution, Self::Execution,
]; ];
@ -309,7 +299,6 @@ impl ToolGroup {
Self::Meta, Self::Meta,
Self::Inbox, Self::Inbox,
Self::Lifecycle, Self::Lifecycle,
Self::Approvals,
Self::Forge, Self::Forge,
Self::Execution, Self::Execution,
Self::WebTools, Self::WebTools,
@ -325,9 +314,6 @@ impl ToolGroup {
} }
Self::Inbox => "get_loose_ends, cancel_loose_end, remind — self-scheduling", Self::Inbox => "get_loose_ends, cancel_loose_end, remind — self-scheduling",
Self::Lifecycle => "no tools — vestigial since list_containers was removed", Self::Lifecycle => "no tools — vestigial since list_containers was removed",
Self::Approvals => {
"no tools — grants cancel_loose_end's approval-cancel arm (privileged)"
}
Self::Forge => "no tools — vestigial since create_repo was removed", Self::Forge => "no tools — vestigial since create_repo was removed",
Self::Execution => { Self::Execution => {
"run, status — run shell commands via mcp__bash__run / mcp__bash__status" "run, status — run shell commands via mcp__bash__run / mcp__bash__status"

View file

@ -2,9 +2,8 @@
The operator-facing host CLI. A thin client for the `hive-c0re` The operator-facing host CLI. A thin client for the `hive-c0re`
daemon — speaks the host admin socket protocol (`hive-host-sock`) and daemon — speaks the host admin socket protocol (`hive-host-sock`) and
does not link the daemon crate. Container lifecycle, the approval does not link the daemon crate. Container lifecycle and the
queue, and the matrix/gateway verbs all forward to the daemon matrix/gateway verbs all forward to the daemon and need it running; a few (`wg`/`peer-config`, `choom`) work off local
and need it running; a few (`wg`/`peer-config`, `choom`) work off local
host state instead. host state instead.
## When to use it ## When to use it
@ -22,7 +21,6 @@ dispatch:
- **`agents.rs`** — container lifecycle for an EXISTING agent - **`agents.rs`** — container lifecycle for an EXISTING agent
(start/stop/kill/rebuild/restart/…); creation is swarm-level (start/stop/kill/rebuild/restart/…); creation is swarm-level
(`swarmctl agent create`). (`swarmctl agent create`).
- **`approvals.rs`** — the config/spawn/meta-input approval queue.
- **`dag_progress.rs`** — rebuild-queue progress rendering. - **`dag_progress.rs`** — rebuild-queue progress rendering.
- **`power.rs`** — restart/start/stop at the container level. - **`power.rs`** — restart/start/stop at the container level.
- **`choom.rs`** — drop into an interactive claude session in a - **`choom.rs`** — drop into an interactive claude session in a

View file

@ -1,24 +0,0 @@
//! `hivectl approvals` — the operator approval queue (pending/approve/deny).
use std::path::Path;
use anyhow::Result;
use hive_host_sock::HostRequest;
use crate::cli::ApprovalsCmd;
use crate::util::render;
/// Dispatch `hivectl approvals <verb>` — the operator approval queue.
pub(crate) async fn run_approvals(socket: &Path, cmd: ApprovalsCmd) -> Result<()> {
match cmd {
ApprovalsCmd::Pending => {
render(crate::client::request(socket, HostRequest::Pending).await?)
}
ApprovalsCmd::Approve { id } => {
render(crate::client::request(socket, HostRequest::Approve { id }).await?)
}
ApprovalsCmd::Deny { id } => {
render(crate::client::request(socket, HostRequest::Deny { id }).await?)
}
}
}

View file

@ -82,13 +82,6 @@ pub enum Cmd {
/// under `hivectl agent <name>`. Idempotent — safe to re-run. Once /// under `hivectl agent <name>`. Idempotent — safe to re-run. Once
/// enabled, `hivectl agent <name> quota show`/`quota set` work. /// enabled, `hivectl agent <name> quota show`/`quota set` work.
QuotaEnable, QuotaEnable,
/// Operator approval queue: list, approve, or deny pending requests.
///
/// Needs the hive-c0re daemon running.
Approvals {
#[command(subcommand)]
cmd: ApprovalsCmd,
},
/// WireGuard inter-hive mesh setup helpers. /// WireGuard inter-hive mesh setup helpers.
/// ///
/// Generate this hive's mesh key and print the nix to enable the mesh, /// Generate this hive's mesh key and print the nix to enable the mesh,
@ -476,23 +469,6 @@ pub enum AgentQuotaCmd {
}, },
} }
/// Operator approval queue: list, approve, or deny pending requests.
#[derive(Subcommand)]
pub enum ApprovalsCmd {
/// List pending approval requests submitted by agents.
Pending,
/// Approve a pending request by id; the action runs immediately.
Approve {
/// Approval id (from `hivectl approvals pending`).
id: i64,
},
/// Deny a pending request by id.
Deny {
/// Approval id.
id: i64,
},
}
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum SubvolCmd { pub enum SubvolCmd {
/// Convert a plain-dir agent state root into a btrfs subvolume in /// Convert a plain-dir agent state root into a btrfs subvolume in

View file

@ -2,9 +2,8 @@
//! //!
//! A thin client for the `hive-c0re` daemon: it speaks the host admin //! A thin client for the `hive-c0re` daemon: it speaks the host admin
//! socket protocol (`hive-host-sock`) and does NOT link the daemon crate. //! socket protocol (`hive-host-sock`) and does NOT link the daemon crate.
//! Container lifecycle + the approval queue (`agent <name> <spawn|kill| //! Container lifecycle (`agent <name> <spawn|kill|rebuild|restart|choom|…>`,
//! rebuild|restart|choom|…>`, `list-agents`, `approvals <pending|approve| //! `list-agents`, `stop` / `start`) and provisioning (`forge` / `matrix` /
//! deny>`, `stop` / `start`) and provisioning (`forge` / `matrix` /
//! `gateway`) all forward to the daemon, which owns the broker, //! `gateway`) all forward to the daemon, which owns the broker,
//! the credentials, and the provisioning logic — a running daemon is //! the credentials, and the provisioning logic — a running daemon is
//! required for those. A couple of verbs work off local host state //! required for those. A couple of verbs work off local host state
@ -48,9 +47,7 @@ mod agents;
use agents::{agents_list, run_agent}; use agents::{agents_list, run_agent};
mod power; mod power;
use power::{restart, start, stop}; use power::{restart, start, stop};
mod approvals;
mod subvol; mod subvol;
use approvals::run_approvals;
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
@ -83,7 +80,6 @@ async fn main() -> Result<()> {
Cmd::Agent { name, cmd } => run_agent(&socket, &name, cmd).await, Cmd::Agent { name, cmd } => run_agent(&socket, &name, cmd).await,
Cmd::ListAgents { json } => agents_list(&socket, json).await, Cmd::ListAgents { json } => agents_list(&socket, json).await,
Cmd::QuotaEnable => quota::quota_enable(&socket).await, Cmd::QuotaEnable => quota::quota_enable(&socket).await,
Cmd::Approvals { cmd } => run_approvals(&socket, cmd).await,
Cmd::Wg { cmd } => match cmd { Cmd::Wg { cmd } => match cmd {
WgCmd::Init { address } => wg_init(&socket, address.as_deref()).await, WgCmd::Init { address } => wg_init(&socket, address.as_deref()).await,
WgCmd::Peer { WgCmd::Peer {

View file

@ -70,7 +70,7 @@ pub(crate) fn resolve_password(
} }
/// Pretty-print a `HostResponse` as JSON and bail on failure. Used by the /// Pretty-print a `HostResponse` as JSON and bail on failure. Used by the
/// agent-lifecycle + approval verbs that just relay a daemon result verbatim. /// agent-lifecycle verbs that just relay a daemon result verbatim.
pub(crate) fn render(resp: hive_host_sock::HostResponse) -> Result<()> { pub(crate) fn render(resp: hive_host_sock::HostResponse) -> Result<()> {
println!("{}", serde_json::to_string_pretty(&resp)?); println!("{}", serde_json::to_string_pretty(&resp)?);
if !resp.ok { if !resp.ok {

View file

@ -117,9 +117,9 @@ in
example = "https://forge.example.com"; example = "https://forge.example.com";
description = '' description = ''
Browser-facing forge URL the dashboard uses to build clickable Browser-facing forge URL the dashboard uses to build clickable
forge links (the H0M3 Forge tile, per-agent-row forge links, forge links (the H0M3 Forge tile, per-agent-row forge links) —
the approval-queue's "review PR on forge" link) — sourced into sourced into every agent container + hive-c0re as
every agent container + hive-c0re as `HIVE_FORGE_PUBLIC_URL`. `HIVE_FORGE_PUBLIC_URL`.
Defaults to `https://''${cfg.domain}`, the gateway vhost. When Defaults to `https://''${cfg.domain}`, the gateway vhost. When
`null`, the dashboard **hides** forge links rather than `null`, the dashboard **hides** forge links rather than

View file

@ -34,7 +34,7 @@
# `{from: "operator", ...}`) and the fallback attribution for an answered # `{from: "operator", ...}`) and the fallback attribution for an answered
# question. # question.
"operator" "operator"
# Helper events (`approval_resolved`, `container_crash`, ...) — the sender an # Helper events (`container_crash`, `needs_update`) — the sender an
# agent is told to treat as hyperhive itself rather than as a peer. # agent is told to treat as hyperhive itself rather than as a peer.
# `hive_sh4re::manager::SYSTEM_SENDER`. # `hive_sh4re::manager::SYSTEM_SENDER`.
"system" "system"

View file

@ -119,9 +119,8 @@ const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
/// A full replace rather than an incremental merge: `Client::list_open_config_prs` /// A full replace rather than an incremental merge: `Client::list_open_config_prs`
/// already returns the complete current set (an agent with no open PR is /// already returns the complete current set (an agent with no open PR is
/// simply absent), so merging would need its own stale-entry eviction to /// simply absent), so merging would need its own stale-entry eviction to
/// avoid an agent's long-closed PR lingering forever — the same reconcile /// avoid an agent's long-closed PR lingering forever. A full replace
/// problem `hive-c0re`'s poller solves for its approvals. A full replace /// sidesteps needing that logic: the map at any moment is nothing more
/// sidesteps needing that logic twice: the map at any moment is nothing more
/// than "the last successful scan's answer." /// than "the last successful scan's answer."
pub struct ConfigPrCache(Mutex<HashMap<String, ConfigPrStatus>>); pub struct ConfigPrCache(Mutex<HashMap<String, ConfigPrStatus>>);