From 58ef299c3c357ad635729e2659578de214b8643d Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 4 Oct 2026 23:55:03 +0200 Subject: [PATCH] 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. --- .prettierignore | 2 +- .../{approvals.md => config-changes.md} | 0 docs/tools/hivectl-cli.md | 53 -- hive-agent-mcp/src/mcp/args.rs | 5 +- hive-agent-mcp/src/mcp/mod.rs | 66 +- hive-agent-mcp/src/mcp/render.rs | 47 +- hive-agent-sock/src/lib.rs | 2 +- hive-agent/prompts/system.md | 5 +- hive-agent/src/reminders.rs | 2 +- hive-agent/src/stats.rs | 2 +- hive-agent/src/web_ui/stats.rs | 2 +- hive-c0re/README.md | 6 +- hive-c0re/src/actions.rs | 157 ----- hive-c0re/src/coordinator.rs | 78 +-- hive-c0re/src/dashboard/approvals.rs | 104 ---- hive-c0re/src/dashboard/lifecycle_ops.rs | 7 +- hive-c0re/src/dashboard/meta_inputs.rs | 2 +- hive-c0re/src/dashboard/mod.rs | 9 +- hive-c0re/src/dashboard/state_snapshot.rs | 133 +--- hive-c0re/src/dashboard/tombstones.rs | 7 - hive-c0re/src/dashboard_events.rs | 34 +- hive-c0re/src/forge/repos.rs | 2 +- hive-c0re/src/job_queue/exec.rs | 44 +- hive-c0re/src/job_queue/mod.rs | 14 +- hive-c0re/src/job_queue/model.rs | 20 +- hive-c0re/src/job_queue/scheduler.rs | 9 +- hive-c0re/src/job_queue/templates.rs | 39 +- hive-c0re/src/job_queue/tests.rs | 58 +- hive-c0re/src/lifecycle/host_config.rs | 2 +- hive-c0re/src/loose_ends.rs | 69 +-- hive-c0re/src/main.rs | 4 +- hive-c0re/src/meta.rs | 2 +- hive-c0re/src/migrate.rs | 2 +- hive-c0re/src/server.rs | 11 +- hive-c0re/src/socket_server/mod.rs | 111 +--- hive-c0re/src/stores/approvals.rs | 575 ------------------ hive-c0re/src/stores/db.rs | 4 +- hive-c0re/src/stores/mod.rs | 5 +- hive-c0re/src/stores/power.rs | 4 +- hive-c0re/src/swarm_status.rs | 4 +- hive-c0re/src/workers/auto_update.rs | 4 +- hive-c0re/src/workers/crash_watch.rs | 2 +- hive-core-agent-sock/README.md | 4 +- hive-core-agent-sock/src/lib.rs | 8 +- hive-host-sock/README.md | 4 +- hive-host-sock/src/lib.rs | 28 +- hive-sh4re/README.md | 4 +- hive-sh4re/src/approvals.rs | 68 --- hive-sh4re/src/inbox.rs | 36 +- hive-sh4re/src/lib.rs | 1 - hive-sh4re/src/manager.rs | 13 +- hive-sh4re/src/permissions.rs | 30 +- hivectl/README.md | 6 +- hivectl/src/approvals.rs | 24 - hivectl/src/cli.rs | 24 - hivectl/src/main.rs | 8 +- hivectl/src/util.rs | 2 +- nix/host-modules/hive-forge/service.nix | 6 +- nix/reserved-names.nix | 2 +- swarm-controller/src/config_pr.rs | 5 +- 60 files changed, 153 insertions(+), 1828 deletions(-) rename docs/agent-lifecycle/{approvals.md => config-changes.md} (100%) delete mode 100644 hive-c0re/src/dashboard/approvals.rs delete mode 100644 hive-c0re/src/stores/approvals.rs delete mode 100644 hive-sh4re/src/approvals.rs delete mode 100644 hivectl/src/approvals.rs diff --git a/.prettierignore b/.prettierignore index 6a9b2fb4..cda4a07a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -14,7 +14,7 @@ docs/tools/swarm-logs-cli.md # indentation normalisation, so these files need manual re-wrap before # adding them back to the formatter scope. CLAUDE.md -docs/agent-lifecycle/approvals.md +docs/agent-lifecycle/config-changes.md docs/process/conventions.md docs/networking/gateway.md docs/integrations/matrix.md diff --git a/docs/agent-lifecycle/approvals.md b/docs/agent-lifecycle/config-changes.md similarity index 100% rename from docs/agent-lifecycle/approvals.md rename to docs/agent-lifecycle/config-changes.md diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 223bfdaa..5bf96253 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -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 list-agents`↴](#hivectl-list-agents) * [`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 init`↴](#hivectl-wg-init) * [`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 * `list-agents` — Show all managed agents with their status and technical state * `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 * `peer-config` — Generate the federation peer-config block for THIS hive * `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 ` - -###### **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 ` - -###### **Arguments:** - -* `` — Approval id (from `hivectl approvals pending`) - - - -## `hivectl approvals deny` - -Deny a pending request by id - -**Usage:** `hivectl approvals deny ` - -###### **Arguments:** - -* `` — Approval id - - - ## `hivectl wg` WireGuard inter-hive mesh setup helpers. diff --git a/hive-agent-mcp/src/mcp/args.rs b/hive-agent-mcp/src/mcp/args.rs index 3a7270ad..00ffb768 100644 --- a/hive-agent-mcp/src/mcp/args.rs +++ b/hive-agent-mcp/src/mcp/args.rs @@ -94,7 +94,7 @@ pub struct CompactArgs { } // ----------------------------------------------------------------------------- -// Privileged tool arg types (lifecycle, approvals) +// Privileged tool arg types (lifecycle) // ----------------------------------------------------------------------------- #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -115,8 +115,7 @@ pub struct GetAgentMetaArgs { #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct CancelLooseEndArgs { /// Which kind of thread to cancel — `"reminder"` for a scheduled - /// `remind` that hasn't fired yet, `"approval"` for a pending - /// approval you submitted, or `"todo"` for a loose-ends-v2 todo + /// `remind` that hasn't fired yet, or `"todo"` for a loose-ends-v2 todo /// (bash/matrix/forge). Use the `kind` field straight off the /// `get_loose_ends` row. pub kind: String, diff --git a/hive-agent-mcp/src/mcp/mod.rs b/hive-agent-mcp/src/mcp/mod.rs index 11a966e9..aabd7b6e 100644 --- a/hive-agent-mcp/src/mcp/mod.rs +++ b/hive-agent-mcp/src/mcp/mod.rs @@ -30,9 +30,8 @@ pub use args::{ pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv}; use render::{ - dial_agent_socket, format_matrix_summary, local_reminders, local_todos, loose_end_kind_label, - mark_local_todo_done, mark_local_todos_done, matrix_unread_summary, parse_loose_end_kind, - render_loose_ends, reply_err, + dial_agent_socket, format_matrix_summary, local_reminders, local_todos, mark_local_todo_done, + mark_local_todos_done, matrix_unread_summary, render_loose_ends, reply_err, }; /// Write (or remove) the status file in the agent's own `state/` directory. @@ -231,9 +230,7 @@ impl AgentServer { #[tool( description = "List loose ends pending against this agent: pending reminders \ - you scheduled, plus — for the manager only — pending \ - 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 \ + you scheduled. Also lists active \ 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 \ 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\"`; \ `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\ - `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 \ from a `get_loose_ends` `todo #N` line) — dials the in-agent socket directly, safe \ to call repeatedly.")] @@ -373,42 +367,26 @@ impl AgentServer { .to_owned(), }; } - let kind = match parse_loose_end_kind(&args.kind) { - Ok(k) => k, - Err(e) => return e, - }; - 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) - // still lives in c0re. - 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 => { - format!("cancelled {kind_label} {id}") - } - Some(hive_agent_sock::Response::Acked { .. }) => { - format!("cancel_loose_end failed: no pending {kind_label} {id}") - } - Some(hive_agent_sock::Response::Err { message }) => { - format!("cancel_loose_end failed: {message}") - } - Some(other) => format!("cancel_loose_end unexpected response: {other:?}"), - None => "cancel_loose_end failed: in-agent socket unavailable".to_owned(), - }; + let kind = args.kind.trim().to_ascii_lowercase(); + if !matches!(kind.as_str(), "reminder" | "r") { + return format!( + "cancel_loose_end: unknown kind '{kind}' (expected \"reminder\" or \"todo\")" + ); + } + // Reminders are harness-local — dial the in-agent socket directly. + match dial_agent_socket(&hive_agent_sock::Request::CancelReminder { id }).await { + Some(hive_agent_sock::Response::Acked { count }) if count > 0 => { + format!("cancelled reminder {id}") + } + Some(hive_agent_sock::Response::Acked { .. }) => { + format!("cancel_loose_end failed: no pending reminder {id}") + } + Some(hive_agent_sock::Response::Err { message }) => { + format!("cancel_loose_end failed: {message}") + } + Some(other) => format!("cancel_loose_end unexpected response: {other:?}"), + 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 } diff --git a/hive-agent-mcp/src/mcp/render.rs b/hive-agent-mcp/src/mcp/render.rs index 2f384565..08186002 100644 --- a/hive-agent-mcp/src/mcp/render.rs +++ b/hive-agent-mcp/src/mcp/render.rs @@ -152,8 +152,8 @@ fn msg_id_tag(id: i64) -> String { } /// Hard cap on how many individual `Todo` lines `render_loose_ends` will -/// emit. Approvals/reminders stay naturally bounded (they're -/// triaged interactively and don't self-multiply), but todos are pushed by +/// emit. Reminders stay naturally bounded (they're triaged interactively +/// and don't self-multiply), but todos are pushed by /// 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 /// 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) { use std::fmt::Write as _; 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 { id, owner, @@ -438,33 +422,6 @@ pub(super) fn format_matrix_summary(rooms: &[MatrixRoomUnread]) -> String { 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 { - 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 + /// current status as a short human-readable block. `name`, /// `hyperhive_rev`, and `running` are always shown; `status` only diff --git a/hive-agent-sock/src/lib.rs b/hive-agent-sock/src/lib.rs index e818f08e..0edec714 100644 --- a/hive-agent-sock/src/lib.rs +++ b/hive-agent-sock/src/lib.rs @@ -149,7 +149,7 @@ pub enum Response { PendingRemindersCount { count: u64 }, /// `ReminderRollup` result. ReminderRollup { - stats: hive_sh4re::approvals::ReminderStats, + stats: hive_sh4re::inbox::ReminderStats, }, /// Op failed; `message` is operator-facing. Err { message: String }, diff --git a/hive-agent/prompts/system.md b/hive-agent/prompts/system.md index 46f69969..b55f8559 100644 --- a/hive-agent/prompts/system.md +++ b/hive-agent/prompts/system.md @@ -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. -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/` 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/` 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. - `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. diff --git a/hive-agent/src/reminders.rs b/hive-agent/src/reminders.rs index df3da591..c18efea3 100644 --- a/hive-agent/src/reminders.rs +++ b/hive-agent/src/reminders.rs @@ -18,7 +18,7 @@ use std::sync::Mutex; use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; -use hive_sh4re::approvals::ReminderStats; +use hive_sh4re::inbox::ReminderStats; use hive_sh4re::wire_time; use rusqlite::{Connection, params}; diff --git a/hive-agent/src/stats.rs b/hive-agent/src/stats.rs index 20dbc370..e761753c 100644 --- a/hive-agent/src/stats.rs +++ b/hive-agent/src/stats.rs @@ -15,7 +15,7 @@ use chrono::Utc; use rusqlite::{Connection, OpenFlags}; use serde::Serialize; -use hive_sh4re::approvals::ReminderStats; +use hive_sh4re::inbox::ReminderStats; /// Window param accepted by `/api/stats?window=`. Each maps to a /// total span + the bucket width used to roll up trend series. diff --git a/hive-agent/src/web_ui/stats.rs b/hive-agent/src/web_ui/stats.rs index 9dd6796d..04427ed3 100644 --- a/hive-agent/src/web_ui/stats.rs +++ b/hive-agent/src/web_ui/stats.rs @@ -29,7 +29,7 @@ pub(super) async fn api_stats( /// `HIVE_AGENT_SOCKET` — was a broker RPC before reminders moved /// in-container. Returns `None` on any transport / decode failure or /// when the socket is unset — the stats are decorative, not authoritative. -async fn fetch_reminder_stats(window_secs: u64) -> Option { +async fn fetch_reminder_stats(window_secs: u64) -> Option { match crate::todo_server::dial(&hive_agent_sock::Request::ReminderRollup { since_secs: window_secs, }) diff --git a/hive-c0re/README.md b/hive-c0re/README.md index 269bf672..ee121130 100644 --- a/hive-c0re/README.md +++ b/hive-c0re/README.md @@ -1,7 +1,7 @@ # hive-c0re 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 reconciliation, per-container stats, and the axum operator dashboard. 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 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 config-repo access and matrix room membership. The accounts themselves 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 to track it: -- **`dashboard/`** — the operator dashboard (containers, approvals, +- **`dashboard/`** — the operator dashboard (containers, schedules, logs, topology). - **`job_queue/`** — the job-DAG queue + desired-state reconciliation (`docs/scheduler/coordinator.md`). diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 1d4fade0..ea3f0548 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -6,53 +6,10 @@ use std::sync::Arc; use anyhow::{Context as _, Result, bail}; -use hive_sh4re::approvals::{ApprovalKind, ApprovalStatus}; -use hive_sh4re::manager::HelperEvent; use crate::coordinator::Coordinator; 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, 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 = - 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 /// `main` to a `target` that does not is a rewind: it drops every commit on /// `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, - 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, ¬e); - (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. /// /// Compare-and-swap, not a bare set: `main` must still be `expected_main`, the @@ -284,36 +157,6 @@ pub fn destroy(coord: &Arc, name: &str, purge: bool) { 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)] mod tests { use super::{RevAdvance, advance_applied_main}; diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 0e5fc88f..d06f5969 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -10,7 +10,6 @@ use std::sync::{Arc, Mutex}; use anyhow::{Context, Result}; use tokio::sync::{broadcast, watch}; -use crate::approvals::Approvals; use crate::broker::Broker; use crate::container_view::{self, ContainerView}; use crate::dashboard_events::DashboardEvent; @@ -30,7 +29,6 @@ const LAST_STOPPED_RUNNING_KEY: &str = "last_stopped_running"; pub struct Coordinator { pub broker: Arc, - pub approvals: Arc, /// Scheduled-prompt rows, read-only: the dashboard lists them so the /// operator can recreate them with `swarmctl schedule`. Nothing on /// the hive writes or fires them. @@ -170,7 +168,7 @@ pub struct Coordinator { /// Unified wire-facing event channel feeding the dashboard SSE /// stream. Carries broker messages (mirrored from `broker.subscribe` /// 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 /// the client can dedupe its buffered live traffic against the /// 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, - pub description: Option, -} - /// 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 /// `anyhow::Error` (`Write` already wraps one, `BadName` never needs to). @@ -467,7 +452,6 @@ impl Coordinator { agent_io_weight, } = env; 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) .context("open scheduled_prompts")?; // BuildLogs wants a directory (it picks its own `build_logs.sqlite` @@ -488,7 +472,6 @@ impl Coordinator { let (shutdown_tx, _) = watch::channel(false); Ok(Self { broker: Arc::new(broker), - approvals: Arc::new(approvals), scheduled_prompts: Arc::new(scheduled_prompts), build_logs, hyperhive_flake, @@ -730,41 +713,6 @@ impl Coordinator { 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 /// one cached on `self`, and emit one /// `DashboardEvent::ContainerStateChanged` per added/changed row @@ -844,7 +792,7 @@ impl Coordinator { .with_context(|| format!("create agent dir {}", agent_dir.display()))?; let socket_path = Self::socket_path(name); // 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())?; self.agents.lock().unwrap().insert(name.to_owned(), socket); Ok(agent_dir) @@ -1094,28 +1042,6 @@ impl Coordinator { 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 /// the same way as `notify_manager` (sender = `SYSTEM_SENDER`, /// body = JSON-encoded event) — e.g. `ContainerCrash`, `NeedsUpdate`. diff --git a/hive-c0re/src/dashboard/approvals.rs b/hive-c0re/src/dashboard/approvals.rs deleted file mode 100644 index ced85ce2..00000000 --- a/hive-c0re/src/dashboard/approvals.rs +++ /dev/null @@ -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, - AxumPath(id): AxumPath, -) -> 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, -} - -/// 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, - AxumPath(id): AxumPath, - Form(form): Form, -) -> 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) -> Vec { - 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() -} diff --git a/hive-c0re/src/dashboard/lifecycle_ops.rs b/hive-c0re/src/dashboard/lifecycle_ops.rs index bef88cd1..c26995e6 100644 --- a/hive-c0re/src/dashboard/lifecycle_ops.rs +++ b/hive-c0re/src/dashboard/lifecycle_ops.rs @@ -103,11 +103,8 @@ pub(super) async fn post_kill( } // Manager is stoppable from the dashboard like any other // agent. The host's dashboard server keeps running (it's - // hive-c0re, not the manager container), per-agent approvals - // submitted by other sub-agents still process through the - // host-side approval queue without the manager up, and - // operator-driven meta-input updates work from the dashboard - // either way. + // hive-c0re, not the manager container), and operator-driven + // meta-input updates work from the dashboard either way. if let Err(e) = crate::job_queue::power::stop_many(&state.coord, std::slice::from_ref(&logical), false) .await diff --git a/hive-c0re/src/dashboard/meta_inputs.rs b/hive-c0re/src/dashboard/meta_inputs.rs index 3f6705d8..af15cc6b 100644 --- a/hive-c0re/src/dashboard/meta_inputs.rs +++ b/hive-c0re/src/dashboard/meta_inputs.rs @@ -215,7 +215,7 @@ pub(super) async fn post_meta_update( .coord .job_queue .insert_job(|b| { - crate::job_queue::templates::meta_update(b, inputs, None); + crate::job_queue::templates::meta_update(b, inputs); Vec::new() }) .expect("template-declared shapes are acyclic"); diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index 52dd7a5f..fdb02d0a 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -1,6 +1,5 @@ //! Hyperhive dashboard. Lists managed containers (with deep-links to each -//! container's web UI), pending approvals (with unified diff vs the applied -//! repo, plus approve/deny buttons), and the manager. +//! container's web UI) and the manager. use std::net::SocketAddr; use std::sync::Arc; @@ -35,7 +34,6 @@ use crate::lifecycle; tags( (name = "health", description = "hive-wide liveness/readiness probes"), (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 = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"), (name = "meta_inputs", description = "bulk flake-input update for the meta flake"), @@ -49,7 +47,6 @@ use crate::lifecycle; )] struct ApiDoc; -mod approvals; mod build_logs; // The single validated identifier type — homed in `hive-host-sock` (the crate // owning agent-path facts) so every dashboard path-param validates through the @@ -142,8 +139,6 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .routes(routes!(permissions::delete_agent_permissions)) .routes(routes!(schedules::api_schedules)) .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_kill)) .routes(routes!(lifecycle_ops::post_restart)) @@ -360,8 +355,6 @@ mod router_build_probe { .routes(routes!(permissions::delete_agent_permissions)) .routes(routes!(schedules::api_schedules)) .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_kill)) .routes(routes!(lifecycle_ops::post_restart)) diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index 56265f2b..40d756e3 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -13,8 +13,6 @@ use axum::{ sse::{Event, KeepAlive, Sse}, }, }; -use chrono::{DateTime, Utc}; -use hive_sh4re::approvals::Approval; use serde::{Deserialize, Serialize}; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::{Stream, StreamExt}; @@ -24,7 +22,7 @@ use crate::container_view::ContainerView; use super::meta_inputs::{MetaInputView, read_meta_inputs}; use super::tombstones::{TombstoneView, build_tombstone_views}; -use super::{AppState, approvals, error_response, scan_validated_paths}; +use super::{AppState, error_response, scan_validated_paths}; #[allow(clippy::struct_excessive_bools)] #[derive(Serialize)] @@ -48,10 +46,6 @@ pub(super) struct StateSnapshot { any_stale: bool, containers: Vec, transients: Vec, - approvals: Vec, - /// Last 30 resolved approvals (approved / denied / failed), newest- - /// first. Drives the "history" tab on the approvals section. - approval_history: Vec, /// State dirs (config history + claude creds + /state/ notes) that /// survive after a destroy-without-purge. The operator can re-spawn /// with the same name to resume, or PURG3 to wipe them. @@ -70,8 +64,8 @@ pub(super) struct StateSnapshot { /// `MetaUpdateRunning` event. meta_update_running: bool, /// Whether the hive-forge container is up. When true the dashboard - /// links each container's config + each approval's commit into the - /// forge's `agent-configs` repos. + /// links each container's config into the forge's `agent-configs` + /// repos. forge_present: bool, /// Whether `hive-gateway` is in front of this dashboard. Sourced /// from the `HIVE_GATEWAY_ENABLED` env var, which the c0re NixOS @@ -126,60 +120,6 @@ struct TransientView { 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, - /// `approved` / `denied` / `failed`. - status: &'static str, - /// RFC 3339 UTC. Renders as a relative time on the dashboard. - resolved_at: DateTime, - /// Operator-supplied deny reason (for `denied`) or build error - /// (for `failed`). None on `approved`. - #[serde(skip_serializing_if = "Option::is_none")] - note: Option, -} - -#[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, - /// Raw `commit_ref` payload for `UpdateMetaInputs` (JSON-encoded - /// `Vec` 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, -} - -/// 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(what: &str, result: std::result::Result) -> 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` /// banner warning. Wide enough that a crash-looping container (restarted /// 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. /// -/// Includes the roster, approvals (+ history), -/// tombstones, job queue, meta inputs, and more. Live clients then -/// follow `/api/dashboard/stream` (SSE) for incremental updates keyed -/// off `seq`. +/// Includes the roster, tombstones, job queue, meta inputs, and more. Live +/// clients then follow `/api/dashboard/stream` (SSE) for incremental updates +/// keyed off `seq`. // `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 // "annotate what's reachable". `serde_json::Value` placeholder for now — // see the batch report. @@ -252,19 +191,7 @@ pub(super) async fn api_state( let containers = state.coord.containers_snapshot().await; let any_stale = containers.iter().any(|c| c.needs_update); 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 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 port_conflicts = build_port_conflicts(&containers); @@ -287,8 +214,6 @@ pub(super) async fn api_state( any_stale, containers, transients, - approvals, - approval_history, meta_inputs: read_meta_inputs(), meta_update_running: state.coord.meta_update_in_progress(), tombstones, @@ -375,50 +300,6 @@ fn build_transient_views( 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) -> Vec { - 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 /// host serving a `GraphWire` projection over HTTP takes the same one; see /// `hive_jobq_wire::parse_states`, which does the actual parsing this side diff --git a/hive-c0re/src/dashboard/tombstones.rs b/hive-c0re/src/dashboard/tombstones.rs index 22a105c6..35c3a22c 100644 --- a/hive-c0re/src/dashboard/tombstones.rs +++ b/hive-c0re/src/dashboard/tombstones.rs @@ -189,13 +189,6 @@ pub(super) async fn post_purge_tombstone( 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() { tracing::info!(%name, "tombstone purged"); // Fire the post-purge tombstones snapshot so dashboards diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index 7be92dcf..7cfd5804 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -48,25 +48,6 @@ pub enum DashboardEvent { #[serde(default, skip_serializing_if = "Vec::is_empty")] file_refs: Vec, }, - /// 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, - note: Option, - description: Option, - }, /// A lifecycle action started for an agent (spawn / start / stop /// / restart / rebuild / destroy). Clients render a spinner next /// 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 /// a fresh `nixos-container list`–derived snapshot against the /// 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. ContainerStateChanged { seq: u64, container: ContainerView }, /// 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 /// 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. RebuildQueueChanged { seq: u64 }, /// Full snapshot of capability grants (per-agent `Vec`). @@ -214,7 +195,6 @@ impl DashboardEvent { match self { DashboardEvent::Sent { .. } => "sent", DashboardEvent::Delivered { .. } => "delivered", - DashboardEvent::ApprovalResolved { .. } => "approval_resolved", DashboardEvent::TransientSet { .. } => "transient_set", DashboardEvent::TransientCleared { .. } => "transient_cleared", DashboardEvent::ContainerStateChanged { .. } => "container_state_changed", @@ -271,16 +251,6 @@ mod tests { in_reply_to: None, 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 { seq: 1, name: "x".into(), diff --git a/hive-c0re/src/forge/repos.rs b/hive-c0re/src/forge/repos.rs index 7405a82f..8f26c16d 100644 --- a/hive-c0re/src/forge/repos.rs +++ b/hive-c0re/src/forge/repos.rs @@ -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 /// 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 -/// 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. /// /// Best-effort, same shape as [`fetch_config_main_into_applied`]: returns diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 710cfa98..1d406eb1 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use anyhow::{Context as _, Result}; -use hive_jobq::{NodeId, TerminalState}; +use hive_jobq::NodeId; use super::model::NodeKind; use crate::coordinator::Coordinator; @@ -79,8 +79,8 @@ pub(super) async fn run_node( run_purge_state(agent).await; Ok(()) } - NodeKind::DestroyBookkeeping { purge, .. } => { - run_destroy_bookkeeping(coord, agent, *purge).await; + NodeKind::DestroyBookkeeping { .. } => { + run_destroy_bookkeeping(coord, agent).await; Ok(()) } NodeKind::MetaLock { @@ -121,13 +121,6 @@ pub(super) async fn run_node( // takes it directly instead of re-matching the kind behind a `bail!` // that could never fire. 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, .. } => { run_emit_rebuilt(coord, agent, coord.job_queue.root_of(id), *ok).await; Ok(()) @@ -266,25 +259,6 @@ async fn run_wanted_pull(coord: &Arc) -> Result<()> { 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, - dag_id: Option, - 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. /// 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. @@ -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 /// bookkeeping drift to log, not a reason to red a DAG whose container is /// already gone. -async fn run_destroy_bookkeeping(coord: &Arc, agent: &str, purge: bool) { +async fn run_destroy_bookkeeping(coord: &Arc, agent: &str) { // 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 // reference a vanished applied repo. if let Err(e) = sync_meta_after_lifecycle(coord).await { 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 // fresh from its observed state. if let Err(e) = coord.power.remove(agent) { diff --git a/hive-c0re/src/job_queue/mod.rs b/hive-c0re/src/job_queue/mod.rs index 91a44c57..cce7adb6 100644 --- a/hive-c0re/src/job_queue/mod.rs +++ b/hive-c0re/src/job_queue/mod.rs @@ -15,11 +15,10 @@ //! 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 //! root and borrowed by its descendants (continuity); -//! - terminal work is an ordinary **tail node** -//! ([`NodeKind::ResolveApproval`] / [`NodeKind::EmitRebuilt`]) that the builder -//! appends in [`templates`], edged onto the job's group roots by the outcome it -//! reports. Templates emit one tail per outcome and the graph runs exactly one, -//! so nothing branches at runtime. +//! - terminal work is an ordinary **tail node** ([`NodeKind::EmitRebuilt`]) +//! that the builder appends in [`templates`], edged onto the job's group +//! roots by the outcome it reports. Templates emit one tail per outcome and +//! the graph runs exactly one, so nothing branches at runtime. //! //! 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 @@ -44,7 +43,6 @@ use hive_jobq::{Graph, NodeId}; use hive_jobq_wire::{GraphNode, GraphWire}; use tokio::sync::Notify; -pub use hive_jobq::TerminalState; pub use model::{NodeKind, PermPayload, State}; use resource::Resource; @@ -222,9 +220,7 @@ impl JobQueue { /// /// **Nodes that explicitly observe cancellation are spared** — a node whose /// edge names [`hive_jobq::TerminalState::Cancelled`] is asking to run when - /// the work it follows was dropped, which is exactly what an approval tail - /// needs: cancel the work, and the tail still fires to resolve the approval - /// row rather than leaving it dangling forever. + /// the work it follows was dropped, so it stays pending and runs. /// /// Nothing is special-cased by node kind. `AFTER_ANY` deliberately does *not* /// accept `Cancelled`, so an ordinary weak-edged step (rebuild's `Reconcile`, diff --git a/hive-c0re/src/job_queue/model.rs b/hive-c0re/src/job_queue/model.rs index e22badbb..33e0ce87 100644 --- a/hive-c0re/src/job_queue/model.rs +++ b/hive-c0re/src/job_queue/model.rs @@ -15,8 +15,6 @@ pub use hive_host_sock::jobs::{PermPayload, State}; use serde::Serialize; -use hive_jobq::TerminalState; - /// The primitive operations — each kind maps to one executor fn in /// `exec.rs`, a thin wrapper over existing `lifecycle.rs` / `meta.rs` /// 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. PurgeState { agent: String }, /// The post-destroy bookkeeping tail. - DestroyBookkeeping { agent: String, purge: bool }, + DestroyBookkeeping { agent: String }, /// Meta flake lock bump. `sweep = false`: `meta::lock_update` with this /// node's own `inputs`; `sweep = true`: `meta::lock_update_hyperhive` /// (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 /// agent. Why braces exist and what they cost: _Braces_. 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` /// manager event. One node per agent *and* per outcome; no cancel /// 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 { /// The agent this node targets, or `""` for agentless kinds - /// ([`NodeKind::MetaLock`] on the `hyperhive` pseudo-agent, - /// [`NodeKind::ResolveApproval`] which acts on an approval row). + /// ([`NodeKind::MetaLock`] on the `hyperhive` pseudo-agent, and the + /// swarm-wide sweeps and pulls). #[must_use] pub fn agent(&self) -> &str { match self { @@ -203,7 +190,6 @@ impl NodeKind { | NodeKind::EmitRebuilt { agent, .. } | NodeKind::SetWanted { agent, .. } => agent, NodeKind::MetaLock { .. } - | NodeKind::ResolveApproval { .. } | NodeKind::ForgeSweep | NodeKind::MatrixSweep | NodeKind::KnowledgePull diff --git a/hive-c0re/src/job_queue/scheduler.rs b/hive-c0re/src/job_queue/scheduler.rs index ff75dddb..6c4bbfab 100644 --- a/hive-c0re/src/job_queue/scheduler.rs +++ b/hive-c0re/src/job_queue/scheduler.rs @@ -10,10 +10,9 @@ //! label tracks the DAG's progress (signal → swap → reconcile) instead of //! repeating one intent the template declared before any of it started. //! -//! Per-DAG terminal work (approval resolution, `Rebuilt`) is not drained here: -//! it runs as the DAG's focused terminal node (`ResolveApproval` / -//! `EmitRebuilt`), dispatched through `exec::run_node` like any other node once -//! the DAG settles. +//! Per-DAG terminal work (`Rebuilt`) is not drained here: it runs as the DAG's +//! focused terminal node (`EmitRebuilt`), dispatched through `exec::run_node` +//! like any other node once the DAG settles. //! //! In-DAG growth (a `MetaLock` growing rebuild subgraphs, a `Reconcile` fanning //! 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 /// 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 -/// 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 /// the graph (today, a missed or duplicated notification) rather than /// reconverging silently. diff --git a/hive-c0re/src/job_queue/templates.rs b/hive-c0re/src/job_queue/templates.rs index 4ec7bdd0..b7d4e7e4 100644 --- a/hive-c0re/src/job_queue/templates.rs +++ b/hive-c0re/src/job_queue/templates.rs @@ -20,8 +20,6 @@ //! `lifecycle::is_running` read), so [`super::power`] assembles them out of //! the primitives this module exports ([`rebuild_nodes`]). -use hive_jobq::TerminalState; - use super::model::{NodeKind, PermPayload}; use super::resource::Resource; 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 /// [`NodeKind::MetaLock`] was handed. /// @@ -349,9 +327,6 @@ pub fn rebuild(builder: &JobBuilder, agent: &str, relock: bool) -> Vec Vec { let a = || agent.to_owned(); @@ -423,7 +398,7 @@ pub fn destroy(builder: &JobBuilder, agent: &str, purge: bool) { destroy }; let _tail = builder - .node(NodeKind::DestroyBookkeeping { agent: a(), purge }) + .node(NodeKind::DestroyBookkeeping { agent: a() }) .part_of(stop) .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 /// crash-watch suppression during its `Swap` — the property the old child /// `Rebuild` DAGs carried via their own transient. -pub fn meta_update(builder: &JobBuilder, inputs: Vec, approval_id: Option) { - let lock = builder +pub fn meta_update(builder: &JobBuilder, inputs: Vec) { + let _lock = builder .node(NodeKind::MetaLock { sweep: false, fanout: None, @@ -467,14 +442,6 @@ pub fn meta_update(builder: &JobBuilder, inputs: Vec, approval_id: Optio }) .needs(Resource::BuildSlot) .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` diff --git a/hive-c0re/src/job_queue/tests.rs b/hive-c0re/src/job_queue/tests.rs index 4a085d29..8ad97b8d 100644 --- a/hive-c0re/src/job_queue/tests.rs +++ b/hive-c0re/src/job_queue/tests.rs @@ -16,6 +16,7 @@ use super::model::NodeKind; use super::*; +use hive_jobq::TerminalState; /// 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 @@ -101,10 +102,9 @@ struct Declared { /// Render a dep's outcome set as the outcomes it actually accepts. /// -/// ⚠️ Spelled out rather than bucketed into `ok` / `any` / other. The first -/// version of this did bucket, and a template's three `ResolveApproval` tails — -/// which differ ONLY in their accepted outcomes — all rendered as `"other"`. -/// A helper that prints two structurally different nodes identically turns an +/// ⚠️ Spelled out rather than bucketed into `ok` / `any` / other: tails that +/// differ ONLY in their accepted outcomes would render identically, and a +/// helper that prints two structurally different nodes identically turns an /// assertion into a tautology. 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) } -/// 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 { - 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> { let sched = q.sched().lock().expect("job_queue mutex poisoned"); 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 ---- // // The node → build-log link is no longer queue state: the log row carries diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index fef9f98f..e12df5d2 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -364,7 +364,7 @@ async fn set_nspawn_flags( // 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 - // only ever flow through the approval queue. + // only ever flow through a config PR on the forge. binds.push(BindMount { host_path: notes_dir.to_string_lossy().into_owned(), container_path: format!("/agents/{agent_name}/state"), diff --git a/hive-c0re/src/loose_ends.rs b/hive-c0re/src/loose_ends.rs index 6cc8ad03..28c57329 100644 --- a/hive-c0re/src/loose_ends.rs +++ b/hive-c0re/src/loose_ends.rs @@ -1,77 +1,34 @@ -//! Loose-ends aggregator. Walks the `approvals` table once per call and -//! assembles a `Vec` for a single agent (`for_agent`) — always -//! the caller's own. `Request::GetLooseEnds` from either the agent or -//! manager socket lands here so the age-seconds derivation stays in one -//! place. Reminders are agent-local (in-container -//! `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. +//! Loose-ends aggregator. Assembles a `Vec` for a single agent +//! (`for_agent`) — always the caller's own. `Request::GetLooseEnds` from +//! either the agent or manager socket lands here. Reminders and todos are +//! agent-local (in-container `hive-agent` stores) and never sourced from +//! here. //! //! Call frequency is low (an agent doing self-introspection between //! turns), so the sweep happens fresh every time — no caching, no -//! mutation events. If the sweep ever shows up in a profile, the sqlite -//! 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. +//! mutation events. use anyhow::Result; -use chrono::Utc; -use hive_sh4re::inbox::{LooseEnd, saturating_age}; +use hive_sh4re::inbox::LooseEnd; use crate::coordinator::Coordinator; -/// Open threads pending against `agent`: -/// - undelivered inbox messages this agent still owes itself a `recv` -/// 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). +/// Open threads pending against `agent`: undelivered inbox messages this +/// agent still owes itself a `recv` for, only when the count is non-zero. /// /// # Errors /// -/// Propagates errors from `count_pending` and the pending-approval -/// sqlite query. +/// Propagates errors from `count_pending`. pub fn for_agent(coord: &Coordinator, agent: &str) -> Result> { - let now = Utc::now().timestamp(); let mut out = Vec::new(); - // Undelivered inbox messages this agent still owes itself a `recv` - // for. Surfaced first (most actionable) and only when non-zero so a - // clean inbox doesn't add noise. The wake-message that drove the - // current turn is already delivered, so this counts only what's - // genuinely still queued. + // Only emitted when non-zero so a clean inbox doesn't add noise. The + // wake-message that drove the current turn is already delivered, so + // this counts only what's genuinely still queued. let pending_messages = coord.broker.count_pending(agent)?; if pending_messages > 0 { out.push(LooseEnd::PendingMessages { 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) } diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 9d1a6278..34eddc09 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -44,7 +44,7 @@ mod workers; 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 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}; use coordinator::{Coordinator, HiveEnv, ServeConfig}; @@ -436,7 +436,7 @@ async fn cmd_serve( // build-slot count + per-agent leases inside the queue, not from // multiple workers — cheap nodes (graceful signals, drains, // 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/`. { let q_coord = coord.clone(); diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 998e667b..1e977e58 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -2,7 +2,7 @@ //! exports one `nixosConfiguration` per agent and drives the system-wide //! deploy audit trail. Flow (`sync_agents`, `lock_update_for_rebuild`, //! `lock_update_hyperhive`): -//! `docs/agent-lifecycle/approvals.md::Meta flake`. +//! `docs/agent-lifecycle/config-changes.md::Meta flake`. use std::path::Path; diff --git a/hive-c0re/src/migrate.rs b/hive-c0re/src/migrate.rs index 39b3d50c..b47171b4 100644 --- a/hive-c0re/src/migrate.rs +++ b/hive-c0re/src/migrate.rs @@ -16,7 +16,7 @@ //! from birth does not need re-asserting each morning. //! //! 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::sync::Arc; diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index ef17b7ba..7a63dc35 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -184,15 +184,6 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { // federation peer-config block) and open the web surfaces // (`hivectl open`). 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 { name, cpu_quota, @@ -342,7 +333,7 @@ async fn stream_agent_status( write_response(&mut write, &HostResponse::agent_statuses(vec![row])).await?; } // 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. Ok(_) => {} // Best-effort, same contract the dashboard's own live channel diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index 69bd38d9..54737131 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -3,10 +3,9 @@ //! authenticates the caller: connecting to `<.../agents/foo/mcp.sock>` means //! you are `foo`; the manager socket simply serves as `ruth`. There is no //! privilege flag — both transports run the same [`serve`] / [`dispatch`] -//! code, and authority derives uniformly from the caller's identity -//! (tool-group membership for cancelling an approval). An agent-targeting -//! verb may name any agent, so `ruth` reaches every agent exactly the way -//! every other agent does, not via any hardcoded name match. +//! code, and authority derives uniformly from the caller's identity. An +//! agent-targeting verb may name any agent, so `ruth` reaches every agent +//! exactly the way every other agent does, not via any hardcoded name match. use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -292,12 +291,6 @@ async fn dispatch( hive_core_agent_sock::Request::GetAgentMeta { name } => { 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::AckUntil { up_to } => handle_ack_until(coord, agent, *up_to), hive_core_agent_sock::Request::RequeueInflight => handle_requeue_inflight(coord, agent), @@ -565,93 +558,6 @@ fn handle_get_loose_ends(coord: &Arc, 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, - 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 /// 4 KiB body cap, then routes broadcast (`to == "*"`) / unicast through /// their respective broker calls. @@ -736,17 +642,6 @@ mod tests { (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] async fn over_long_line_is_rejected_after_reading_only_the_bound() { let total = 2 * REQUEST_LINE_MAX_BYTES as u64; diff --git a/hive-c0re/src/stores/approvals.rs b/hive-c0re/src/stores/approvals.rs deleted file mode 100644 index c8b56c4e..00000000 --- a/hive-c0re/src/stores/approvals.rs +++ /dev/null @@ -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, -} - -impl Approvals { - pub fn open(path: &Path) -> Result { - 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 { - 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> { - let conn = self.conn.lock().unwrap(); - let submitter: Option = 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> { - 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> { - 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> { - 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 { - let conn = self.conn.lock().unwrap(); - let row: Option = 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 { - let mut conn = self.conn.lock().unwrap(); - let tx = conn.transaction()?; - let row: Option = 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 { - 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, -} - -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 { - 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::>()` 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>) -> Vec { - let mut out = Vec::new(); - let mut dropped: std::collections::HashMap = 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 { - // 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>(6)? - .map(hive_sh4re::wire_time::from_secs), - note: row.get(7)?, - description: row.get(8)?, - }) -} - -fn kind_from_str(s: &str) -> Result { - 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 " 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()); - } -} diff --git a/hive-c0re/src/stores/db.rs b/hive-c0re/src/stores/db.rs index 9980a8d9..2bd5e89d 100644 --- a/hive-c0re/src/stores/db.rs +++ b/hive-c0re/src/stores/db.rs @@ -62,7 +62,7 @@ pub struct Migration { /// the shared `schema_versions` table. /// /// - `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 /// the stored version is `i`. A pre-versioning DB (no `schema_versions` /// 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 { /// Open a connection to the sqlite file at `path`, creating the parent /// directory if needed. `subsystem` labels error contexts (`"broker"`, -/// `"approvals"`, …). +/// `"agent_power"`, …). pub fn open(path: &Path, subsystem: &str) -> Result { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) diff --git a/hive-c0re/src/stores/mod.rs b/hive-c0re/src/stores/mod.rs index 000f3e03..d5040938 100644 --- a/hive-c0re/src/stores/mod.rs +++ b/hive-c0re/src/stores/mod.rs @@ -1,9 +1,8 @@ -//! Sqlite-backed host-side stores (broker, approval queue, read-only -//! scheduled prompts, build logs, power intent) plus the shared connection open/migration +//! Sqlite-backed host-side stores (broker, read-only scheduled prompts, +//! build logs, power intent) plus the shared connection open/migration //! helper (`db`). Each submodule is re-exported at the crate root, so //! `crate::broker::…` etc. keep working unchanged. -pub mod approvals; pub mod broker; pub mod build_logs; pub mod db; diff --git a/hive-c0re/src/stores/power.rs b/hive-c0re/src/stores/power.rs index 9d5bb708..337a2ef6 100644 --- a/hive-c0re/src/stores/power.rs +++ b/hive-c0re/src/stores/power.rs @@ -5,7 +5,7 @@ //! //! Stored as the `agent_power` table in the coordinator DB //! (`/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 //! across hive-c0re restarts; in-flight //! queue work deliberately does not. Setting `wanted` is never a @@ -72,7 +72,7 @@ pub struct PowerStore { impl PowerStore { /// Open (a connection to) the shared coordinator DB and ensure the /// `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 { let conn = crate::db::open(db_path, "agent_power")?; conn.execute_batch(SCHEMA) diff --git a/hive-c0re/src/swarm_status.rs b/hive-c0re/src/swarm_status.rs index 06b244c7..d3feb0cf 100644 --- a/hive-c0re/src/swarm_status.rs +++ b/hive-c0re/src/swarm_status.rs @@ -339,8 +339,8 @@ async fn apply_rev(agent: &str, rev: &str) -> bool { /// power intent, then insert the DAG. /// /// 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 -/// operator's click at swarm level is the authorisation. +/// what is left is hive-local, and the operator's click at swarm level is the +/// authorisation. /// /// Shared with [`crate::workers::wanted`] for the **seeding**, not the insert. /// `first_deploy`'s tail `Reconcile` seeds `Wanted` from the container's diff --git a/hive-c0re/src/workers/auto_update.rs b/hive-c0re/src/workers/auto_update.rs index ed46f59a..65a474b7 100644 --- a/hive-c0re/src/workers/auto_update.rs +++ b/hive-c0re/src/workers/auto_update.rs @@ -113,8 +113,8 @@ fn plan_root_agent(list_result: anyhow::Result>) -> RootAgentPlan { /// 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 -/// `containers.h-ruth` in their host NixOS config. Bypasses the approval -/// queue — the root/manager is auto-managed by default. Operators who +/// `containers.h-ruth` in their host NixOS config. The root/manager is +/// auto-managed by default. Operators who /// don't want a root agent at all set `services.hyperhive.ruthless = true`, /// which short-circuits this whole function. Idempotent. pub async fn ensure_root_agent(coord: &Arc) -> Result<()> { diff --git a/hive-c0re/src/workers/crash_watch.rs b/hive-c0re/src/workers/crash_watch.rs index c3b2a0c1..b85fda4b 100644 --- a/hive-c0re/src/workers/crash_watch.rs +++ b/hive-c0re/src/workers/crash_watch.rs @@ -1,7 +1,7 @@ //! Per-container crash and login-state watcher. Polls every managed //! container on a 10s interval. Fires `ContainerCrash`, `LoggedIn`, //! 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::sync::Arc; diff --git a/hive-core-agent-sock/README.md b/hive-core-agent-sock/README.md index 38cd6230..37bb158b 100644 --- a/hive-core-agent-sock/README.md +++ b/hive-core-agent-sock/README.md @@ -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, 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 for this socket. ## Not to be confused with `hive-agent-sock` 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 its own local producers — that one never leaves the container. See `docs/trust-boundary/boundary.md` for the socket topology. diff --git a/hive-core-agent-sock/src/lib.rs b/hive-core-agent-sock/src/lib.rs index 4e67629a..426d430d 100644 --- a/hive-core-agent-sock/src/lib.rs +++ b/hive-core-agent-sock/src/lib.rs @@ -4,11 +4,11 @@ //! 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 //! 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. 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 serde::{Deserialize, Serialize}; @@ -76,10 +76,6 @@ pub enum Request { #[serde(default, skip_serializing_if = "Option::is_none")] name: Option, }, - /// 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. /// Harness↔broker pairing fired after `TurnOutcome::Ok`. See /// `docs/process/conventions.md::Broker delivery + ack cycle`. diff --git a/hive-host-sock/README.md b/hive-host-sock/README.md index 2ff6dfd5..ec76badb 100644 --- a/hive-host-sock/README.md +++ b/hive-host-sock/README.md @@ -15,8 +15,8 @@ thin dependency possible. ## Shape Serde-derived request/response enums for the host admin protocol. The larger -shared payload types some variants reference (`Approval`, `AgentStatusRow`) -stay in `hive-sh4re` — this crate is only the protocol envelope, no server or +shared payload type some variants reference (`AgentStatusRow`) stays in +`hive-sh4re` — this crate is only the protocol envelope, no server or client implementation. Its own `jobs` module is the exception: the job-queue vocabulary `hivectl` diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 442cf788..d670854d 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -6,13 +6,11 @@ //! job-queue wire types ([`jobs`]) travel on this socket and the dashboard //! channels `hive-c0re` serves off the same snapshot, and on nothing else — //! their whole consumer set is `hive-c0re` + `hivectl` + this crate — so they -//! live here rather than in the shared crate. The payload types genuinely -//! shared with the *agent* sockets (`Approval`, `AgentStatusRow`) stay in -//! `hive-sh4re`. +//! live here rather than in the shared crate. The payload type genuinely +//! shared with the *agent* sockets (`AgentStatusRow`) stays in `hive-sh4re`. use std::path::PathBuf; -use hive_sh4re::approvals::Approval; use hive_sh4re::container::AgentStatusRow; use hive_types::Ident; use serde::{Deserialize, Serialize}; @@ -111,7 +109,7 @@ pub enum HostRequest { /// `Kill`. Kill { name: Ident }, /// 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 { name: Ident, #[serde(default)] @@ -213,12 +211,6 @@ pub enum HostRequest { /// this request just answers whatever it's asked. Result: /// [`HostResponse::nodes`]. QueueNodes { ids: Vec }, - /// 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 /// 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 /// and warns (does not fail the request) on mismatch — see /// `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)] pub struct HostResponse { @@ -483,8 +475,6 @@ pub struct HostResponse { pub error: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub agents: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub approvals: Option>, /// `Urls` result — this hive's domain plus the browser-facing /// home / forge / matrix URLs. `None` for every other request kind. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -539,7 +529,6 @@ impl Default for HostResponse { ok: false, error: None, agents: None, - approvals: None, urls: None, agent_statuses: None, agent_exists: None, @@ -578,15 +567,6 @@ impl HostResponse { } } - #[must_use] - pub fn pending(approvals: Vec) -> Self { - Self { - ok: true, - approvals: Some(approvals), - ..Self::default() - } - } - /// `Urls` result — this hive's domain + browser-facing web URLs. #[must_use] pub fn urls(urls: HiveUrls) -> Self { diff --git a/hive-sh4re/README.md b/hive-sh4re/README.md index 5a1c4b3a..8d4c01da 100644 --- a/hive-sh4re/README.md +++ b/hive-sh4re/README.md @@ -1,7 +1,7 @@ # hive-sh4re 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 _envelopes_ themselves now live in the per-socket crates (below); this crate holds the payloads they carry. @@ -17,7 +17,7 @@ in all of `hive-sh4re`: - `hive-priv-sock` — the privileged-helper socket Those crates re-export or reference the payload types that still live here -(`Approval`, `Message`, `LooseEnd`, …). +(`Message`, `LooseEnd`, …). ## Modules diff --git a/hive-sh4re/src/approvals.rs b/hive-sh4re/src/approvals.rs deleted file mode 100644 index d35a4f79..00000000 --- a/hive-sh4re/src/approvals.rs +++ /dev/null @@ -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, - pub status: ApprovalStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub resolved_at: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub note: Option, - /// 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, -} - -/// 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, -} diff --git a/hive-sh4re/src/inbox.rs b/hive-sh4re/src/inbox.rs index 05d12eb0..db4512df 100644 --- a/hive-sh4re/src/inbox.rs +++ b/hive-sh4re/src/inbox.rs @@ -117,6 +117,17 @@ pub enum ReminderTiming { 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 /// thread kinds can land without breaking existing handlers. /// Per-flavour scoping + per-variant fields + clock-anomaly @@ -125,15 +136,6 @@ pub enum ReminderTiming { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] 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, - age_seconds: u64, - }, /// A scheduled but un-delivered reminder row. Reminder { id: i64, @@ -190,9 +192,8 @@ pub enum LooseEnd { /// 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 -/// every [`LooseEnd`] variant above, so it lives beside the enum rather -/// than in either producer: hive-c0re derives approval ages here, and -/// hive-agent's in-agent socket server derives reminder + todo ages. +/// every [`LooseEnd`] variant above, so it lives beside the enum; +/// hive-agent's in-agent socket server derives reminder + todo ages with it. /// /// Both clamps are load-bearing and neither substitutes for the other: /// `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) } -/// 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)] mod tests { use super::*; diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 85270971..e8c6a0eb 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -1,6 +1,5 @@ //! Wire types shared between `hive-c0re` and the in-container harness. -pub mod approvals; pub mod assets; pub mod bash_task; pub mod container; diff --git a/hive-sh4re/src/manager.rs b/hive-sh4re/src/manager.rs index ccccf6ee..8b3e0e6c 100644 --- a/hive-sh4re/src/manager.rs +++ b/hive-sh4re/src/manager.rs @@ -6,8 +6,6 @@ use hive_types::Ident; use serde::{Deserialize, Serialize}; -use crate::approvals::ApprovalStatus; - /// Logical name the broker uses for the manager. 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. /// Serialised as JSON in `Message::body` (sender = `SYSTEM_SENDER`). /// 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)] #[serde(tag = "event", rename_all = "snake_case")] 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, - }, /// A sub-agent's recorded flake rev is stale relative to hyperhive. NeedsUpdate { agent: String }, /// Container exited without an operator-initiated stop (crash). diff --git a/hive-sh4re/src/permissions.rs b/hive-sh4re/src/permissions.rs index 3e1213ff..88247574 100644 --- a/hive-sh4re/src/permissions.rs +++ b/hive-sh4re/src/permissions.rs @@ -157,11 +157,6 @@ pub enum ToolGroup { /// `meta/capabilities.json` grants still parse; `tools()` returns /// `&[]`. 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 /// with no replacement. Kept so existing `meta/capabilities.json` /// grants still parse; `tools()` returns `&[]`. @@ -219,15 +214,15 @@ impl ToolGroup { /// The MCP tool names (without the `mcp__hyperhive__` prefix) in this group. /// Returns `&[]` for `WebTools` (it enables Claude built-in tools, not MCP - /// tools — see `builtin_tools()`) and for `Lifecycle` / `Approvals` / - /// `Forge` (their tools were removed); see each variant's doc comment. + /// tools — see `builtin_tools()`) and for `Lifecycle` / `Forge` (their + /// tools were removed); see each variant's doc comment. #[must_use] pub fn tools(self) -> &'static [&'static str] { match self { Self::Messaging => &["send", "recv", "ack_until"], Self::Meta => &["get_agent_meta"], 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 // out-of-process `bash` MCP server // (`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 // that gate is the actual security boundary. `WebTools` // grants Claude built-in tools, not MCP ones; see - // `builtin_tools()`. `Lifecycle`, `Approvals` and `Forge` each - // listed exactly one tool — `list_containers`, - // `request_update_meta_inputs` and `create_repo` respectively - // — and all three tools were removed outright; the variants - // stay so existing grants parse, and `Approvals` still gates - // `cancel_loose_end`'s approval-cancel arm server-side. - Self::Lifecycle | Self::Approvals | Self::Forge | Self::Execution | Self::WebTools => { - &[] - } + // `builtin_tools()`. `Lifecycle` and `Forge` each listed + // exactly one tool — `list_containers` and `create_repo` + // respectively — and both tools were removed outright; the + // variants stay so existing grants parse. + Self::Lifecycle | Self::Forge | Self::Execution | Self::WebTools => &[], } } @@ -297,7 +288,6 @@ impl ToolGroup { Self::Meta, Self::Inbox, Self::Lifecycle, - Self::Approvals, Self::Execution, ]; @@ -309,7 +299,6 @@ impl ToolGroup { Self::Meta, Self::Inbox, Self::Lifecycle, - Self::Approvals, Self::Forge, Self::Execution, Self::WebTools, @@ -325,9 +314,6 @@ impl ToolGroup { } Self::Inbox => "get_loose_ends, cancel_loose_end, remind — self-scheduling", 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::Execution => { "run, status — run shell commands via mcp__bash__run / mcp__bash__status" diff --git a/hivectl/README.md b/hivectl/README.md index 6f74a21b..8628f2e7 100644 --- a/hivectl/README.md +++ b/hivectl/README.md @@ -2,9 +2,8 @@ The operator-facing host CLI. A thin client for the `hive-c0re` daemon — speaks the host admin socket protocol (`hive-host-sock`) and -does not link the daemon crate. Container lifecycle, the approval -queue, and the matrix/gateway verbs all forward to the daemon -and need it running; a few (`wg`/`peer-config`, `choom`) work off local +does not link the daemon crate. Container lifecycle and the +matrix/gateway verbs all forward to the daemon and need it running; a few (`wg`/`peer-config`, `choom`) work off local host state instead. ## When to use it @@ -22,7 +21,6 @@ dispatch: - **`agents.rs`** — container lifecycle for an EXISTING agent (start/stop/kill/rebuild/restart/…); creation is swarm-level (`swarmctl agent create`). -- **`approvals.rs`** — the config/spawn/meta-input approval queue. - **`dag_progress.rs`** — rebuild-queue progress rendering. - **`power.rs`** — restart/start/stop at the container level. - **`choom.rs`** — drop into an interactive claude session in a diff --git a/hivectl/src/approvals.rs b/hivectl/src/approvals.rs deleted file mode 100644 index f09771da..00000000 --- a/hivectl/src/approvals.rs +++ /dev/null @@ -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 ` — 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?) - } - } -} diff --git a/hivectl/src/cli.rs b/hivectl/src/cli.rs index 456dca2f..93499ddb 100644 --- a/hivectl/src/cli.rs +++ b/hivectl/src/cli.rs @@ -82,13 +82,6 @@ pub enum Cmd { /// under `hivectl agent `. Idempotent — safe to re-run. Once /// enabled, `hivectl agent quota show`/`quota set` work. 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. /// /// 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)] pub enum SubvolCmd { /// Convert a plain-dir agent state root into a btrfs subvolume in diff --git a/hivectl/src/main.rs b/hivectl/src/main.rs index acd80293..2580e5e9 100644 --- a/hivectl/src/main.rs +++ b/hivectl/src/main.rs @@ -2,9 +2,8 @@ //! //! 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. -//! Container lifecycle + the approval queue (`agent `, `list-agents`, `approvals `, `stop` / `start`) and provisioning (`forge` / `matrix` / +//! Container lifecycle (`agent `, +//! `list-agents`, `stop` / `start`) and provisioning (`forge` / `matrix` / //! `gateway`) all forward to the daemon, which owns the broker, //! the credentials, and the provisioning logic — a running daemon is //! required for those. A couple of verbs work off local host state @@ -48,9 +47,7 @@ mod agents; use agents::{agents_list, run_agent}; mod power; use power::{restart, start, stop}; -mod approvals; mod subvol; -use approvals::run_approvals; #[tokio::main] async fn main() -> Result<()> { @@ -83,7 +80,6 @@ async fn main() -> Result<()> { Cmd::Agent { name, cmd } => run_agent(&socket, &name, cmd).await, Cmd::ListAgents { json } => agents_list(&socket, json).await, Cmd::QuotaEnable => quota::quota_enable(&socket).await, - Cmd::Approvals { cmd } => run_approvals(&socket, cmd).await, Cmd::Wg { cmd } => match cmd { WgCmd::Init { address } => wg_init(&socket, address.as_deref()).await, WgCmd::Peer { diff --git a/hivectl/src/util.rs b/hivectl/src/util.rs index 033343ac..e9cdb0ef 100644 --- a/hivectl/src/util.rs +++ b/hivectl/src/util.rs @@ -70,7 +70,7 @@ pub(crate) fn resolve_password( } /// 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<()> { println!("{}", serde_json::to_string_pretty(&resp)?); if !resp.ok { diff --git a/nix/host-modules/hive-forge/service.nix b/nix/host-modules/hive-forge/service.nix index afdbe83a..4353dd7b 100644 --- a/nix/host-modules/hive-forge/service.nix +++ b/nix/host-modules/hive-forge/service.nix @@ -117,9 +117,9 @@ in example = "https://forge.example.com"; description = '' Browser-facing forge URL the dashboard uses to build clickable - forge links (the H0M3 Forge tile, per-agent-row forge links, - the approval-queue's "review PR on forge" link) — sourced into - every agent container + hive-c0re as `HIVE_FORGE_PUBLIC_URL`. + forge links (the H0M3 Forge tile, per-agent-row forge links) — + sourced into every agent container + hive-c0re as + `HIVE_FORGE_PUBLIC_URL`. Defaults to `https://''${cfg.domain}`, the gateway vhost. When `null`, the dashboard **hides** forge links rather than diff --git a/nix/reserved-names.nix b/nix/reserved-names.nix index 70d9b449..d1136d01 100644 --- a/nix/reserved-names.nix +++ b/nix/reserved-names.nix @@ -34,7 +34,7 @@ # `{from: "operator", ...}`) and the fallback attribution for an answered # question. "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. # `hive_sh4re::manager::SYSTEM_SENDER`. "system" diff --git a/swarm-controller/src/config_pr.rs b/swarm-controller/src/config_pr.rs index 61ef56c3..2d772d7a 100644 --- a/swarm-controller/src/config_pr.rs +++ b/swarm-controller/src/config_pr.rs @@ -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` /// 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 -/// avoid an agent's long-closed PR lingering forever — the same reconcile -/// problem `hive-c0re`'s poller solves for its approvals. A full replace -/// sidesteps needing that logic twice: the map at any moment is nothing more +/// avoid an agent's long-closed PR lingering forever. A full replace +/// sidesteps needing that logic: the map at any moment is nothing more /// than "the last successful scan's answer." pub struct ConfigPrCache(Mutex>);