hive-c0re: remove the approvals subsystem
Nothing submits an approval since scheduled prompts left hive-c0re, so the
queue only acted on rows written before that.
- hive-c0re: the approvals store and its migrations, approve/deny/
resolve actions, the ResolveApproval tail node (and meta_update's
approval_id), POST /api/approve and /api/deny, the approval views on
/api/state, the approval_resolved dashboard event, the orphan GC,
destroy/purge failing pending rows, and the approval rows in
get_loose_ends. DestroyBookkeeping loses the purge flag, which only
worded the approval-failure note.
- The CancelLooseEnd request goes: its only hive-c0re arm was the
approval one. cancel_loose_end handles reminder and todo in the harness.
- Wire types: hive_sh4re::approvals (ReminderStats moves to inbox),
LooseEnd::Approval, CancelLooseEndKind, HelperEvent::ApprovalResolved,
ToolGroup::Approvals, HostRequest::{Pending,Approve,Deny} and
HostResponse.approvals. HOST_SOCK_VERSION goes to 2 for the removed
field.
- hivectl loses the approvals verbs; docs/tools/hivectl-cli.md is
regenerated.
- The system prompt drops approval_resolved.
- The approvals table stays in broker.sqlite, unread, the same way the
operator_questions table was left when its store was removed.
- An `approvals` entry still in tool-groups.json is skipped with a warning
by ToolGroup::parse_list, like any unknown group.
- docs/agent-lifecycle/approvals.md is renamed to config-changes.md so the
code's doc pointers resolve; its content follows in the docs commit.
This commit is contained in:
parent
99bd912ae0
commit
58ef299c3c
60 changed files with 153 additions and 1828 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <COMMAND>`
|
||||
|
||||
###### **Subcommands:**
|
||||
|
||||
* `pending` — List pending approval requests submitted by agents
|
||||
* `approve` — Approve a pending request by id; the action runs immediately
|
||||
* `deny` — Deny a pending request by id
|
||||
|
||||
|
||||
|
||||
## `hivectl approvals pending`
|
||||
|
||||
List pending approval requests submitted by agents
|
||||
|
||||
**Usage:** `hivectl approvals pending`
|
||||
|
||||
|
||||
|
||||
## `hivectl approvals approve`
|
||||
|
||||
Approve a pending request by id; the action runs immediately
|
||||
|
||||
**Usage:** `hivectl approvals approve <ID>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<ID>` — Approval id (from `hivectl approvals pending`)
|
||||
|
||||
|
||||
|
||||
## `hivectl approvals deny`
|
||||
|
||||
Deny a pending request by id
|
||||
|
||||
**Usage:** `hivectl approvals deny <ID>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<ID>` — Approval id
|
||||
|
||||
|
||||
|
||||
## `hivectl wg`
|
||||
|
||||
WireGuard inter-hive mesh setup helpers.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
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 {kind_label} {id}")
|
||||
format!("cancelled reminder {id}")
|
||||
}
|
||||
Some(hive_agent_sock::Response::Acked { .. }) => {
|
||||
format!("cancel_loose_end failed: no pending {kind_label} {id}")
|
||||
format!("cancel_loose_end failed: no pending reminder {id}")
|
||||
}
|
||||
Some(hive_agent_sock::Response::Err { message }) => {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<hive_sh4re::inbox::CancelLooseEndKind, String> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"reminder" | "r" => Ok(hive_sh4re::inbox::CancelLooseEndKind::Reminder),
|
||||
"approval" | "a" => Ok(hive_sh4re::inbox::CancelLooseEndKind::Approval),
|
||||
other => Err(format!(
|
||||
"cancel_loose_end: unknown kind '{other}' \
|
||||
(expected \"reminder\", \"approval\", or \"todo\")"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical user-facing label for a `CancelLooseEndKind` — used in
|
||||
/// the success ack so the caller always sees `"reminder"` instead of
|
||||
/// whatever alias they passed in (`"r"`).
|
||||
pub(super) fn loose_end_kind_label(kind: hive_sh4re::inbox::CancelLooseEndKind) -> &'static str {
|
||||
match kind {
|
||||
hive_sh4re::inbox::CancelLooseEndKind::Reminder => "reminder",
|
||||
hive_sh4re::inbox::CancelLooseEndKind::Approval => "approval",
|
||||
}
|
||||
}
|
||||
|
||||
/// Format helper for `get_agent_meta`: renders an agent's identity +
|
||||
/// current status as a short human-readable block. `name`,
|
||||
/// `hyperhive_rev`, and `running` are always shown; `status` only
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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/<name>` repo, which an operator reviews and merges on the forge; the merge deploys it. The operator only signs off on changes; you run the day-to-day.
|
||||
Operator boundary: starting, stopping and rebuilding containers is the operator's — ask them when any agent needs one. _Creating_ a new agent is not something you can do from here — ask the operator, who scaffolds the new agent's config repo and spawns it from the dashboard. _Changing_ any agent's config is not a tool call at all — it's a forge PR on the agent's `agent-configs/<name>` repo, which an operator reviews and merges on the forge; the merge deploys it. The operator only signs off on changes; you run the day-to-day.
|
||||
|
||||
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `container_crash`, `needs_update`. Use these to react to lifecycle changes:
|
||||
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `container_crash`, `needs_update`. Use these to react to lifecycle changes:
|
||||
|
||||
- `needs_update` — agent's flake rev is stale. Ask the operator to rebuild it.
|
||||
- `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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<hive_sh4re::approvals::ReminderStats> {
|
||||
async fn fetch_reminder_stats(window_secs: u64) -> Option<hive_sh4re::inbox::ReminderStats> {
|
||||
match crate::todo_server::dial(&hive_agent_sock::Request::ReminderRollup {
|
||||
since_secs: window_secs,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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<Coordinator>, id: i64) -> Result<()> {
|
||||
let approval = coord.approvals.mark_approved(id)?;
|
||||
tracing::info!(
|
||||
%approval.id,
|
||||
%approval.agent,
|
||||
kind = ?approval.kind,
|
||||
%approval.commit_ref,
|
||||
"approval: dispatching",
|
||||
);
|
||||
match approval.kind {
|
||||
ApprovalKind::UpdateMetaInputs => {
|
||||
// Inputs JSON-encoded into commit_ref by the manager's
|
||||
// submit path — surface them on the DAG so the dashboard
|
||||
// can show *which* inputs are about to bump. The cascade
|
||||
// rebuilds fan out when the lock bump lands (so they build
|
||||
// against the post-bump lock, and a failed bump fans out
|
||||
// nothing).
|
||||
let inputs: Vec<String> =
|
||||
serde_json::from_str(&approval.commit_ref).unwrap_or_default();
|
||||
let inserted = coord.job_queue.insert_job(|b| {
|
||||
crate::job_queue::templates::meta_update(b, inputs, Some(id));
|
||||
Vec::new()
|
||||
});
|
||||
if let Err(e) = inserted {
|
||||
return Err(e.context("insert meta-update dag"));
|
||||
}
|
||||
coord.emit_rebuild_queue_snapshot();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `applied/main`, and whether `target` descends from it. Fast-forwarding
|
||||
/// `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<Coordinator>,
|
||||
approval_id: i64,
|
||||
outcome: crate::job_queue::TerminalState,
|
||||
error: Option<&str>,
|
||||
) {
|
||||
use crate::job_queue::TerminalState;
|
||||
let approval = match coord.approvals.get(approval_id) {
|
||||
Ok(Some(a)) => a,
|
||||
Ok(None) => {
|
||||
tracing::warn!(approval_id, "approval dag terminal: row no longer exists");
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(approval_id, error = ?e, "approval dag terminal: row read failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let result: Result<()> = match outcome {
|
||||
TerminalState::Done => Ok(()),
|
||||
// `Skipped` never reaches here — no template emits a tail for it — but it
|
||||
// reads the same to an operator either way: the work did not happen.
|
||||
TerminalState::Cancelled | TerminalState::Skipped => {
|
||||
Err(anyhow::anyhow!("cancelled before completion"))
|
||||
}
|
||||
TerminalState::Failed => Err(anyhow::anyhow!("{}", error.unwrap_or("job dag failed"))),
|
||||
};
|
||||
if let Err(e) = finish_approval(coord, &approval, result) {
|
||||
tracing::warn!(approval_id, error = ?e, "approval dag resolved with failure");
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_approval(
|
||||
coord: &Coordinator,
|
||||
approval: &hive_sh4re::approvals::Approval,
|
||||
result: Result<()>,
|
||||
) -> Result<()> {
|
||||
let (status, note, ok) = match &result {
|
||||
Ok(()) => (ApprovalStatus::Approved, None, true),
|
||||
Err(e) => {
|
||||
let note = format!("{e:#}");
|
||||
let _ = coord.approvals.mark_failed(approval.id, ¬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<Coordinator>, 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};
|
||||
|
|
|
|||
|
|
@ -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<Broker>,
|
||||
pub approvals: Arc<Approvals>,
|
||||
/// 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<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
|
|
|
|||
|
|
@ -1,104 +0,0 @@
|
|||
//! Approval endpoints for the dashboard.
|
||||
//!
|
||||
//! Approve/deny actions plus the orphan-approval GC sweep used by the
|
||||
//! `/api/state` builder.
|
||||
|
||||
use axum::{
|
||||
extract::{Form, Path as AxumPath, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use hive_sh4re::approvals::Approval;
|
||||
use serde::Deserialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use super::{AppState, error_response};
|
||||
use crate::actions;
|
||||
use crate::coordinator::Coordinator;
|
||||
|
||||
/// Approve a pending approval row.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/approve/{id}",
|
||||
params(("id" = i64, Path, description = "approval row id")),
|
||||
responses(
|
||||
(status = 200, description = "approved", body = String),
|
||||
(status = 500, description = "approve failed"),
|
||||
),
|
||||
tag = "approvals"
|
||||
)]
|
||||
pub(super) async fn post_approve(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<i64>,
|
||||
) -> Response {
|
||||
match actions::approve(&state.coord, id) {
|
||||
// 200 instead of 303 — `actions::approve` fires
|
||||
// `ApprovalResolved` (success path) or the eventual failure
|
||||
// event, both of which the dashboard's derived store applies
|
||||
// live. The matching form carries `data-no-refresh`.
|
||||
Ok(()) => (StatusCode::OK, "ok").into_response(),
|
||||
Err(e) => error_response(&format!("approve {id} failed: {e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, ToSchema)]
|
||||
pub(super) struct DenyForm {
|
||||
#[serde(default)]
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
/// Deny a pending approval row, with an optional
|
||||
/// note (form field `note`).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/deny/{id}",
|
||||
params(("id" = i64, Path, description = "approval row id")),
|
||||
request_body(content = DenyForm, content_type = "application/x-www-form-urlencoded"),
|
||||
responses(
|
||||
(status = 200, description = "denied", body = String),
|
||||
(status = 500, description = "deny failed"),
|
||||
),
|
||||
tag = "approvals"
|
||||
)]
|
||||
pub(super) async fn post_deny(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<i64>,
|
||||
Form(form): Form<DenyForm>,
|
||||
) -> Response {
|
||||
let note = form
|
||||
.note
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
match actions::deny(&state.coord, id, note) {
|
||||
Ok(()) => (StatusCode::OK, "ok").into_response(),
|
||||
Err(e) => error_response(&format!("deny {id} failed: {e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter out approvals whose agent state dir was wiped out from under us
|
||||
/// (e.g. by a test script's cleanup). Marks them failed so they fall out of
|
||||
/// `pending` on next render.
|
||||
pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<Approval> {
|
||||
approvals
|
||||
.into_iter()
|
||||
.filter(|a| {
|
||||
if Coordinator::agent_proposed_dir(&a.agent).exists() {
|
||||
true
|
||||
} else {
|
||||
let note = "agent state dir missing";
|
||||
let _ = coord.approvals.mark_failed(a.id, note);
|
||||
tracing::info!(id = a.id, agent = %a.agent, "auto-failed orphan approval");
|
||||
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
||||
id: a.id,
|
||||
agent: a.agent.as_str(),
|
||||
approval_kind: <&str>::from(a.kind),
|
||||
status: "failed",
|
||||
note: Some(note.to_owned()),
|
||||
description: a.description.clone(),
|
||||
});
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<Coordinator>) -> 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))
|
||||
|
|
|
|||
|
|
@ -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<ContainerView>,
|
||||
transients: Vec<TransientView>,
|
||||
approvals: Vec<ApprovalView>,
|
||||
/// Last 30 resolved approvals (approved / denied / failed), newest-
|
||||
/// first. Drives the "history" tab on the approvals section.
|
||||
approval_history: Vec<ApprovalHistoryView>,
|
||||
/// State dirs (config history + claude creds + /state/ notes) that
|
||||
/// 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<String>,
|
||||
/// `approved` / `denied` / `failed`.
|
||||
status: &'static str,
|
||||
/// RFC 3339 UTC. Renders as a relative time on the dashboard.
|
||||
resolved_at: DateTime<Utc>,
|
||||
/// Operator-supplied deny reason (for `denied`) or build error
|
||||
/// (for `failed`). None on `approved`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ApprovalView {
|
||||
id: i64,
|
||||
agent: String,
|
||||
kind: &'static str,
|
||||
/// Manager-supplied description shown on the approval card.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
/// Raw `commit_ref` payload for `UpdateMetaInputs` (JSON-encoded
|
||||
/// `Vec<String>` of input names; `"[]"` = all inputs). The
|
||||
/// frontend parses this to render a human-readable card body.
|
||||
commit_ref: String,
|
||||
/// RFC 3339 UTC time the approval was queued. Rendered as a
|
||||
/// relative time on the card so the operator can spot a stale
|
||||
/// request.
|
||||
requested_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Replace silent `.unwrap_or_default()` on the data sources behind
|
||||
/// `/api/state` so that whichever query degrades surfaces in journald
|
||||
/// instead of leaving the operator staring at an empty list. The
|
||||
/// dashboard still degrades to a sensible default value; the warn
|
||||
/// is just the diagnostic breadcrumb the old code swallowed.
|
||||
fn log_default<T, E>(what: &str, result: std::result::Result<T, E>) -> T
|
||||
where
|
||||
T: Default,
|
||||
E: std::fmt::Debug,
|
||||
{
|
||||
match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "api_state", source = %what, error = ?e, "snapshot source failed; using default");
|
||||
T::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Window over which container crashes count toward the `agents_crashing`
|
||||
/// 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<Approval>) -> Vec<ApprovalView> {
|
||||
approvals
|
||||
.into_iter()
|
||||
.map(|a| ApprovalView {
|
||||
id: a.id,
|
||||
agent: a.agent.to_string(),
|
||||
kind: <&str>::from(a.kind),
|
||||
description: a.description,
|
||||
commit_ref: a.commit_ref,
|
||||
requested_at: a.requested_at,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `/api/jobq/graph` query string — the generic jobq/wire query shape (any
|
||||
/// host serving a `GraphWire` projection over HTTP takes the same one; see
|
||||
/// `hive_jobq_wire::parse_states`, which does the actual parsing this side
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -48,25 +48,6 @@ pub enum DashboardEvent {
|
|||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
file_refs: Vec<String>,
|
||||
},
|
||||
/// A pending approval transitioned to a terminal state
|
||||
/// (approved / denied / failed). Clients move the row out of the
|
||||
/// pending list and into history.
|
||||
///
|
||||
/// The approval's own kind (`"update_meta_inputs"`) lives
|
||||
/// on `approval_kind` rather than `kind` because the latter is taken
|
||||
/// by the serde tag identifying which `DashboardEvent` variant
|
||||
/// this is.
|
||||
ApprovalResolved {
|
||||
seq: u64,
|
||||
id: i64,
|
||||
agent: String,
|
||||
approval_kind: &'static str,
|
||||
/// `"approved"` / `"denied"` / `"failed"`.
|
||||
status: &'static str,
|
||||
resolved_at: DateTime<Utc>,
|
||||
note: Option<String>,
|
||||
description: Option<String>,
|
||||
},
|
||||
/// A lifecycle action started for an agent (spawn / start / stop
|
||||
/// / 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<cap_name>`).
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Coordinator>) -> 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<Coordinator>,
|
||||
dag_id: Option<u64>,
|
||||
approval_id: i64,
|
||||
outcome: TerminalState,
|
||||
) {
|
||||
let reason = (outcome == TerminalState::Failed)
|
||||
.then(|| dag_id.and_then(|dag| coord.job_queue.first_error(dag)))
|
||||
.flatten();
|
||||
crate::actions::resolve_approval_dag(coord, approval_id, outcome, reason.as_deref());
|
||||
}
|
||||
|
||||
/// Rerender the meta flake from whatever containers still exist on disk.
|
||||
/// 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<Coordinator>, agent: &str, purge: bool) {
|
||||
async fn run_destroy_bookkeeping(coord: &Arc<Coordinator>, agent: &str) {
|
||||
// Meta flake: drop the agent's input + nixosConfiguration so a future spawn
|
||||
// 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) {
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<hive_jobq
|
|||
/// borrow it. A failure cancel-cascades the rest — unlike rebuild there's no
|
||||
/// recovery-reconcile (nothing to converge if the container was never created).
|
||||
///
|
||||
/// No approval tail: the operator authorised the creation at swarm level, and
|
||||
/// the deploy request carries that authorisation.
|
||||
///
|
||||
/// Returns the group root so a caller can wait on the whole subtree.
|
||||
pub fn first_deploy(builder: &JobBuilder, agent: &str) -> Vec<hive_jobq::NodeGuid> {
|
||||
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<String>, approval_id: Option<i64>) {
|
||||
let lock = builder
|
||||
pub fn meta_update(builder: &JobBuilder, inputs: Vec<String>) {
|
||||
let _lock = builder
|
||||
.node(NodeKind::MetaLock {
|
||||
sweep: false,
|
||||
fanout: None,
|
||||
|
|
@ -467,14 +442,6 @@ pub fn meta_update(builder: &JobBuilder, inputs: Vec<String>, 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`
|
||||
|
|
|
|||
|
|
@ -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<NodeKind> {
|
||||
let sched = q.sched().lock().expect("job_queue mutex poisoned");
|
||||
let graph = sched.graph();
|
||||
graph
|
||||
.nodes()
|
||||
.filter(|n| n.state == State::Pending)
|
||||
.map(|n| n.payload.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pending_kinds_filtered(q: &JobQueue, keep: &dyn Fn(&NodeKind) -> bool) -> Vec<&'static str> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -1,77 +1,34 @@
|
|||
//! Loose-ends aggregator. Walks the `approvals` table once per call and
|
||||
//! assembles a `Vec<LooseEnd>` 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<LooseEnd>` 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<Vec<LooseEnd>> {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -184,15 +184,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> 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
|
||||
|
|
|
|||
|
|
@ -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<Coordinator>, agent: &str) -> Response {
|
|||
}
|
||||
}
|
||||
|
||||
/// Handle `CancelLooseEnd` from a per-agent socket. Dispatches by kind, each
|
||||
/// with its own auth check: reminder cancels are handled entirely in-container
|
||||
/// (this arm should be unreachable in practice, kept only so the match stays
|
||||
/// exhaustive) and approval cancels require the `approvals` tool-group (the
|
||||
/// grantable capability) AND ownership — the canceller must be the approval's
|
||||
/// submitter — so no positional / hardcoded privilege and no cross-agent
|
||||
/// cancellation. (The operator's cancel-anything path is a separate handler.)
|
||||
fn handle_cancel_loose_end(
|
||||
coord: &Arc<Coordinator>,
|
||||
canceller: &str,
|
||||
kind: hive_sh4re::inbox::CancelLooseEndKind,
|
||||
id: i64,
|
||||
) -> Result<(), String> {
|
||||
match kind {
|
||||
hive_sh4re::inbox::CancelLooseEndKind::Reminder => {
|
||||
// Reminders are now agent-local (in-container store) — the
|
||||
// agent-mcp `cancel_loose_end` tool branches on this kind and
|
||||
// dials the agent's own socket directly, never forwarding to
|
||||
// hive-c0re. This arm should be unreachable in practice; kept
|
||||
// only so the match stays exhaustive.
|
||||
Err(format!(
|
||||
"reminder {id}: reminders are handled locally by the agent, \
|
||||
not by hive-c0re"
|
||||
))
|
||||
}
|
||||
hive_sh4re::inbox::CancelLooseEndKind::Approval => {
|
||||
// Withdrawing an approval needs the grantable `approvals`
|
||||
// tool-group (held by any approval-submitting orchestrator)
|
||||
// AND ownership: only the agent that submitted the approval
|
||||
// may withdraw it. Without the ownership check, any
|
||||
// approvals-group agent could cancel any other's approval by
|
||||
// id. A NULL submitter (legacy row predating the column) is
|
||||
// treated as operator-initiated (no agent tracking predates
|
||||
// the column).
|
||||
check_can_cancel_approval(canceller)?;
|
||||
let submitter = coord
|
||||
.approvals
|
||||
.submitter_of(id)
|
||||
.map_err(|e| format!("{e:#}"))?
|
||||
.unwrap_or_else(|| "operator".to_owned());
|
||||
if submitter != canceller {
|
||||
return Err(format!(
|
||||
"cancel_loose_end: approval {id} was submitted by {submitter}, \
|
||||
not {canceller}; only the submitting agent can withdraw it"
|
||||
));
|
||||
}
|
||||
let approval = coord
|
||||
.approvals
|
||||
.mark_cancelled(id, canceller)
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
tracing::info!(%id, %canceller, agent = %approval.agent, "approval cancelled");
|
||||
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
||||
id: approval.id,
|
||||
agent: approval.agent.as_str(),
|
||||
approval_kind: <&str>::from(approval.kind),
|
||||
status: "cancelled",
|
||||
note: approval.note,
|
||||
description: approval.description,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Capability guard on the `Approval` cancel arm: the caller must hold the
|
||||
/// `approvals` tool-group (the grantable capability for approval-submitting
|
||||
/// orchestrators), checked server-side via `tool_groups::groups_for`. Pulled
|
||||
/// out so the auth check has its own focused unit test — exercising the full
|
||||
/// `handle_cancel_loose_end` flow would need a `Coordinator` fixture (broker +
|
||||
/// sqlite). Keys on a grantable capability, not a positional / hardcoded
|
||||
/// privilege.
|
||||
fn check_can_cancel_approval(canceller: &str) -> Result<(), String> {
|
||||
const APPROVALS_GROUP: &str = "approvals";
|
||||
if crate::tool_groups::groups_for(canceller)
|
||||
.map_err(|e| format!("cancel_loose_end: {e}"))?
|
||||
.iter()
|
||||
.any(|g| g == APPROVALS_GROUP)
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(
|
||||
"cancel_loose_end: cancelling approval rows requires the `approvals` tool group"
|
||||
.to_owned(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `Send` handler. Applies the
|
||||
/// 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;
|
||||
|
|
|
|||
|
|
@ -1,575 +0,0 @@
|
|||
//! Approval queue. The user approves/denies via the host admin CLI; on
|
||||
//! approval the host runs the corresponding action.
|
||||
//!
|
||||
//! Nothing submits approvals any more. `UpdateMetaInputs`, the only kind
|
||||
//! left, is legacy: the MCP tool that queued it was removed. The variant
|
||||
//! and its (de)serialization stay so pre-existing rows still read back.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use chrono::Utc;
|
||||
use hive_sh4re::approvals::{Approval, ApprovalKind, ApprovalStatus};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
use crate::db::Migration;
|
||||
|
||||
const SCHEMA: &str = r"
|
||||
CREATE TABLE IF NOT EXISTS approvals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent TEXT NOT NULL,
|
||||
commit_ref TEXT NOT NULL,
|
||||
requested_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
resolved_at INTEGER,
|
||||
note TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_approvals_pending
|
||||
ON approvals (id) WHERE status = 'pending';
|
||||
";
|
||||
|
||||
/// Ordered schema migrations tracked in `schema_versions` (key `"approvals"`).
|
||||
/// Each migration declares the column it adds, so a legacy DB (fully or
|
||||
/// partially migrated before versioning) converges by skipping the migrations
|
||||
/// whose column already exists. New columns go here as v5, v6, …
|
||||
const MIGRATIONS: &[Migration] = &[
|
||||
// v1: `kind` (pre-Phase-8 dbs): legacy rows default to `apply_commit`.
|
||||
Migration {
|
||||
sql: "ALTER TABLE approvals ADD COLUMN \
|
||||
kind TEXT NOT NULL DEFAULT 'apply_commit'",
|
||||
adds_column: Some(("approvals", "kind")),
|
||||
},
|
||||
// v2: `description`: manager-supplied note on the dashboard card.
|
||||
Migration {
|
||||
sql: "ALTER TABLE approvals ADD COLUMN description TEXT",
|
||||
adds_column: Some(("approvals", "description")),
|
||||
},
|
||||
// v3: `fetched_sha`: canonical sha hive-c0re resolved at submit time.
|
||||
Migration {
|
||||
sql: "ALTER TABLE approvals ADD COLUMN fetched_sha TEXT",
|
||||
adds_column: Some(("approvals", "fetched_sha")),
|
||||
},
|
||||
// v4: `submitter`: authenticated agent that submitted the approval.
|
||||
// Legacy rows are NULL → callers fall back to the root agent.
|
||||
Migration {
|
||||
sql: "ALTER TABLE approvals ADD COLUMN submitter TEXT",
|
||||
adds_column: Some(("approvals", "submitter")),
|
||||
},
|
||||
// v5: drop `merge_config_pr` rows. Config PRs merge on the forge, so no
|
||||
// approval of that kind can be acted on, and `row_to_approval` rejects
|
||||
// the kind.
|
||||
Migration {
|
||||
sql: "DELETE FROM approvals WHERE kind = 'merge_config_pr'",
|
||||
adds_column: None,
|
||||
},
|
||||
// v6: drop `schedule_prompt` rows. Scheduled prompts are swarm-level, so
|
||||
// no approval of that kind can be acted on, and `row_to_approval` rejects
|
||||
// the kind.
|
||||
Migration {
|
||||
sql: "DELETE FROM approvals WHERE kind = 'schedule_prompt'",
|
||||
adds_column: None,
|
||||
},
|
||||
];
|
||||
|
||||
pub struct Approvals {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl Approvals {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let conn = crate::db::open(path, "approvals")?;
|
||||
conn.execute_batch(SCHEMA)
|
||||
.context("apply approvals schema")?;
|
||||
crate::db::apply_versioned_migrations(&conn, "approvals", MIGRATIONS)?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// Test fixture: insert a new pending approval row.
|
||||
#[cfg(test)]
|
||||
pub fn submit_kind(
|
||||
&self,
|
||||
agent: &str,
|
||||
kind: ApprovalKind,
|
||||
commit_ref: &str,
|
||||
description: Option<&str>,
|
||||
submitter: &str,
|
||||
) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO approvals
|
||||
(agent, kind, commit_ref, requested_at, status, description, submitter)
|
||||
VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6)",
|
||||
params![
|
||||
agent,
|
||||
<&str>::from(kind),
|
||||
commit_ref,
|
||||
Utc::now().timestamp(),
|
||||
description,
|
||||
submitter,
|
||||
],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// The agent that submitted approval `id` (the authenticated socket
|
||||
/// caller at submit time). `None` for legacy rows predating the
|
||||
/// `submitter` column — callers route those to the root agent.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the sqlite prepare/query fails. A missing row
|
||||
/// or a `NULL` submitter is not an error — both yield `Ok(None)`.
|
||||
pub fn submitter_of(&self, id: i64) -> Result<Option<String>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let submitter: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT submitter FROM approvals WHERE id = ?1",
|
||||
params![id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.flatten();
|
||||
Ok(submitter)
|
||||
}
|
||||
|
||||
/// Last `limit` resolved approvals (approved / denied / failed),
|
||||
/// newest-first. Drives the history tab on the dashboard.
|
||||
pub fn recent_resolved(&self, limit: u64) -> Result<Vec<Approval>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, description
|
||||
FROM approvals
|
||||
WHERE status IN ('approved', 'denied', 'failed', 'cancelled')
|
||||
ORDER BY resolved_at DESC, id DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([limit], row_to_approval)?;
|
||||
Ok(collect_lenient(rows))
|
||||
}
|
||||
|
||||
pub fn pending(&self) -> Result<Vec<Approval>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, description
|
||||
FROM approvals
|
||||
WHERE status = 'pending'
|
||||
ORDER BY id ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], row_to_approval)?;
|
||||
Ok(collect_lenient(rows))
|
||||
}
|
||||
|
||||
pub fn get(&self, id: i64) -> Result<Option<Approval>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, description
|
||||
FROM approvals WHERE id = ?1",
|
||||
params![id],
|
||||
row_to_approval,
|
||||
)
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Mark pending -> approved (or fail if not pending). Returns the (now-updated)
|
||||
/// approval so the caller can run the action and pass the agent name.
|
||||
pub fn mark_approved(&self, id: i64) -> Result<Approval> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let row: Option<ApprovalLookup> = conn
|
||||
.query_row(
|
||||
ApprovalLookup::SELECT,
|
||||
params![id],
|
||||
ApprovalLookup::from_row,
|
||||
)
|
||||
.optional()?;
|
||||
let Some(row) = row else {
|
||||
bail!("approval {id} not found");
|
||||
};
|
||||
if row.status != "pending" {
|
||||
bail!("approval {id} is {}, not pending", row.status);
|
||||
}
|
||||
let resolved_at = Utc::now().timestamp();
|
||||
conn.execute(
|
||||
"UPDATE approvals SET status = 'approved', resolved_at = ?1 WHERE id = ?2",
|
||||
params![resolved_at, id],
|
||||
)?;
|
||||
Ok(Approval {
|
||||
id,
|
||||
agent: row.agent,
|
||||
kind: kind_from_str(&row.kind)?,
|
||||
commit_ref: row.commit_ref,
|
||||
requested_at: hive_sh4re::wire_time::from_secs(row.requested_at),
|
||||
status: ApprovalStatus::Approved,
|
||||
resolved_at: Some(hive_sh4re::wire_time::from_secs(resolved_at)),
|
||||
note: None,
|
||||
description: row.description,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mark_denied(&self, id: i64, note: Option<&str>) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let affected = conn.execute(
|
||||
"UPDATE approvals SET status = 'denied', resolved_at = ?1, note = ?2
|
||||
WHERE id = ?3 AND status = 'pending'",
|
||||
params![Utc::now().timestamp(), note, id],
|
||||
)?;
|
||||
if affected == 0 {
|
||||
bail!("approval {id} not pending");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_failed(&self, id: i64, note: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE approvals SET status = 'failed', resolved_at = ?1, note = ?2 WHERE id = ?3",
|
||||
params![Utc::now().timestamp(), note, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Withdraw a pending approval. Returns the now-updated
|
||||
/// row so the caller can emit `ApprovalResolved` with the right
|
||||
/// kind / agent. Errors if the approval isn't pending — once
|
||||
/// it's approved/denied/failed/cancelled, the resolution is final.
|
||||
pub fn mark_cancelled(&self, id: i64, canceller: &str) -> Result<Approval> {
|
||||
let mut conn = self.conn.lock().unwrap();
|
||||
let tx = conn.transaction()?;
|
||||
let row: Option<ApprovalLookup> = tx
|
||||
.query_row(
|
||||
ApprovalLookup::SELECT,
|
||||
params![id],
|
||||
ApprovalLookup::from_row,
|
||||
)
|
||||
.optional()?;
|
||||
let Some(row) = row else {
|
||||
bail!("approval {id} not found");
|
||||
};
|
||||
if row.status != "pending" {
|
||||
bail!("approval {id} is {}, not pending", row.status);
|
||||
}
|
||||
let resolved_at = Utc::now().timestamp();
|
||||
let note = format!("cancelled by {canceller}");
|
||||
tx.execute(
|
||||
"UPDATE approvals SET status = 'cancelled', resolved_at = ?1, note = ?2 WHERE id = ?3",
|
||||
params![resolved_at, note, id],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(Approval {
|
||||
id,
|
||||
agent: row.agent,
|
||||
kind: kind_from_str(&row.kind)?,
|
||||
commit_ref: row.commit_ref,
|
||||
requested_at: hive_sh4re::wire_time::from_secs(row.requested_at),
|
||||
status: ApprovalStatus::Cancelled,
|
||||
resolved_at: Some(hive_sh4re::wire_time::from_secs(resolved_at)),
|
||||
note: Some(note),
|
||||
description: row.description,
|
||||
})
|
||||
}
|
||||
|
||||
/// Mark every pending approval for `agent` as failed (returns rows affected).
|
||||
/// Used by `destroy` to clear the queue of an agent that no longer exists.
|
||||
pub fn fail_pending_for_agent(&self, agent: &str, note: &str) -> Result<usize> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let n = conn.execute(
|
||||
"UPDATE approvals SET status = 'failed', resolved_at = ?1, note = ?2
|
||||
WHERE agent = ?3 AND status = 'pending'",
|
||||
params![Utc::now().timestamp(), note, agent],
|
||||
)?;
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Columns needed to rebuild an [`Approval`] after a status transition,
|
||||
/// shared by `mark_approved` / `mark_cancelled`. Replaces a 7-field
|
||||
/// tuple that tripped `clippy::type_complexity` and was duplicated
|
||||
/// across both callers (one suppressed the lint, the other aliased the
|
||||
/// tuple) — one named projection + mapper now backs both.
|
||||
struct ApprovalLookup {
|
||||
agent: hive_types::Ident,
|
||||
kind: String,
|
||||
commit_ref: String,
|
||||
requested_at: i64,
|
||||
status: String,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
impl ApprovalLookup {
|
||||
/// The single-row lookup by id (`?1`). Column order matches
|
||||
/// [`ApprovalLookup::from_row`].
|
||||
const SELECT: &str = "SELECT agent, kind, commit_ref, requested_at, status, description \
|
||||
FROM approvals WHERE id = ?1";
|
||||
|
||||
fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
|
||||
let agent: String = row.get(0)?;
|
||||
let agent = hive_types::Ident::parse(&agent).map_err(|e| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
0,
|
||||
rusqlite::types::Type::Text,
|
||||
format!("invalid approval agent {agent:?}: {e}").into(),
|
||||
)
|
||||
})?;
|
||||
Ok(Self {
|
||||
agent,
|
||||
kind: row.get(1)?,
|
||||
commit_ref: row.get(2)?,
|
||||
requested_at: row.get(3)?,
|
||||
status: row.get(4)?,
|
||||
description: row.get(5)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect approval rows, dropping any that fail to deserialize. A single
|
||||
/// malformed / unknown-kind row must never blank the whole list:
|
||||
/// `collect::<Result<Vec>>()` is all-or-nothing, so one bad row used to make
|
||||
/// `pending()` / `recent_resolved()` error out wholesale — the dashboard then
|
||||
/// rendered an empty approvals queue.
|
||||
///
|
||||
/// Drops are aggregated by error message and logged **once per call** rather
|
||||
/// than one line per row: a batch of legacy unknown-kind rows (e.g. the
|
||||
/// retired `apply_commit` approvals from the removed non-PR config flow) sit
|
||||
/// `pending` forever and would otherwise flood the journal with an identical
|
||||
/// warning on every dashboard render. Aggregating keeps the signal (how many,
|
||||
/// which error) without the flood.
|
||||
fn collect_lenient(rows: impl Iterator<Item = rusqlite::Result<Approval>>) -> Vec<Approval> {
|
||||
let mut out = Vec::new();
|
||||
let mut dropped: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
for r in rows {
|
||||
match r {
|
||||
Ok(a) => out.push(a),
|
||||
Err(e) => *dropped.entry(e.to_string()).or_default() += 1,
|
||||
}
|
||||
}
|
||||
if !dropped.is_empty() {
|
||||
let total: usize = dropped.values().sum();
|
||||
tracing::warn!(
|
||||
dropped = total,
|
||||
by_error = ?dropped,
|
||||
"skipped unparseable approval rows"
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
|
||||
// Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note, description.
|
||||
let kind: String = row.get(2)?;
|
||||
let kind = match kind.as_str() {
|
||||
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
|
||||
other => {
|
||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||
2,
|
||||
rusqlite::types::Type::Text,
|
||||
format!("unknown approval kind '{other}'").into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let status: String = row.get(5)?;
|
||||
let status = match status.as_str() {
|
||||
"pending" => ApprovalStatus::Pending,
|
||||
"approved" => ApprovalStatus::Approved,
|
||||
"denied" => ApprovalStatus::Denied,
|
||||
"failed" => ApprovalStatus::Failed,
|
||||
"cancelled" => ApprovalStatus::Cancelled,
|
||||
other => {
|
||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||
5,
|
||||
rusqlite::types::Type::Text,
|
||||
format!("unknown approval status '{other}'").into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let agent: String = row.get(1)?;
|
||||
let agent = hive_types::Ident::parse(&agent).map_err(|e| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
1,
|
||||
rusqlite::types::Type::Text,
|
||||
format!("invalid approval agent {agent:?}: {e}").into(),
|
||||
)
|
||||
})?;
|
||||
Ok(Approval {
|
||||
id: row.get(0)?,
|
||||
agent,
|
||||
kind,
|
||||
commit_ref: row.get(3)?,
|
||||
requested_at: hive_sh4re::wire_time::from_secs(row.get(4)?),
|
||||
status,
|
||||
resolved_at: row
|
||||
.get::<_, Option<i64>>(6)?
|
||||
.map(hive_sh4re::wire_time::from_secs),
|
||||
note: row.get(7)?,
|
||||
description: row.get(8)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn kind_from_str(s: &str) -> Result<ApprovalKind> {
|
||||
Ok(match s {
|
||||
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
|
||||
other => bail!("unknown approval kind '{other}'"),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use hive_sh4re::approvals::ApprovalKind;
|
||||
|
||||
fn open_temp() -> (tempfile::TempDir, std::path::PathBuf, Approvals) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("approvals.sqlite");
|
||||
let db = Approvals::open(&path).expect("open approvals db");
|
||||
(dir, path, db)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_cancelled_transitions_pending_row() {
|
||||
// Manager withdraws a pending approval. Row leaves pending(),
|
||||
// shows up in recent_resolved() with the cancelled status + a
|
||||
// "cancelled by <who>" note.
|
||||
let (_dir, _path, db) = open_temp();
|
||||
let id = db
|
||||
.submit_kind(
|
||||
"bitburner",
|
||||
ApprovalKind::UpdateMetaInputs,
|
||||
"[]",
|
||||
Some("test"),
|
||||
"bitburner",
|
||||
)
|
||||
.unwrap();
|
||||
let row = db.mark_cancelled(id, "manager").expect("cancel");
|
||||
assert_eq!(row.id, id);
|
||||
assert!(matches!(row.status, ApprovalStatus::Cancelled));
|
||||
assert_eq!(row.note.as_deref(), Some("cancelled by manager"));
|
||||
assert!(row.resolved_at.is_some());
|
||||
assert!(db.pending().unwrap().is_empty(), "row leaves pending");
|
||||
let resolved = db.recent_resolved(10).unwrap();
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert!(matches!(resolved[0].status, ApprovalStatus::Cancelled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_cancelled_refuses_already_resolved_row() {
|
||||
// Once approved/denied/failed/cancelled the resolution is
|
||||
// final — re-cancelling errors instead of silently overwriting.
|
||||
let (_dir, _path, db) = open_temp();
|
||||
let id = db
|
||||
.submit_kind("a", ApprovalKind::UpdateMetaInputs, "[]", None, "a")
|
||||
.unwrap();
|
||||
db.mark_cancelled(id, "manager").expect("first cancel");
|
||||
let err = db
|
||||
.mark_cancelled(id, "manager")
|
||||
.expect_err("second cancel must fail");
|
||||
assert!(err.to_string().contains("not pending"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_kind_row_is_skipped_not_fatal() {
|
||||
// A single malformed / future-kind row must not blank the
|
||||
// whole list — collect_lenient skips it instead of failing.
|
||||
let (_dir, path, db) = open_temp();
|
||||
let good = db
|
||||
.submit_kind("good", ApprovalKind::UpdateMetaInputs, "[]", None, "good")
|
||||
.unwrap();
|
||||
let raw = Connection::open(&path).unwrap();
|
||||
raw.execute(
|
||||
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status)
|
||||
VALUES ('weird', 'from_the_future', '', 0, 'pending')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let pending = db
|
||||
.pending()
|
||||
.expect("pending() must survive an unparseable row");
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].id, good);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submitter_round_trips_and_legacy_reads_none() {
|
||||
// A submitted approval records its submitter; a legacy row
|
||||
// (inserted without the column) reads back as None so callers
|
||||
// fall back to the root agent.
|
||||
let (_dir, path, db) = open_temp();
|
||||
let id = db
|
||||
.submit_kind(
|
||||
"child",
|
||||
ApprovalKind::UpdateMetaInputs,
|
||||
"[]",
|
||||
None,
|
||||
"parent",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(db.submitter_of(id).unwrap().as_deref(), Some("parent"));
|
||||
|
||||
let raw = Connection::open(&path).unwrap();
|
||||
raw.execute(
|
||||
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status)
|
||||
VALUES ('old', 'update_meta_inputs', '', 0, 'pending')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let legacy_id = raw.last_insert_rowid();
|
||||
assert_eq!(db.submitter_of(legacy_id).unwrap(), None);
|
||||
}
|
||||
|
||||
/// A database written before v5 still holds `merge_config_pr` rows,
|
||||
/// pending and resolved. Opening it drops them and keeps every other row,
|
||||
/// so the lists and `get` read cleanly.
|
||||
#[test]
|
||||
fn a_pre_v5_merge_config_pr_row_is_dropped_on_open() {
|
||||
let (_dir, path, db) = open_temp();
|
||||
let kept = db
|
||||
.submit_kind("iris", ApprovalKind::UpdateMetaInputs, "[]", None, "iris")
|
||||
.unwrap();
|
||||
drop(db);
|
||||
let raw = Connection::open(&path).unwrap();
|
||||
raw.execute_batch(
|
||||
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status, fetched_sha)
|
||||
VALUES ('iris', 'merge_config_pr', '42', 0, 'pending', 'abc123');
|
||||
INSERT INTO approvals (agent, kind, commit_ref, requested_at, status, resolved_at)
|
||||
VALUES ('iris', 'merge_config_pr', '41', 0, 'approved', 1);
|
||||
UPDATE schema_versions SET version = 4 WHERE store = 'approvals';",
|
||||
)
|
||||
.unwrap();
|
||||
let old: i64 = raw.last_insert_rowid();
|
||||
drop(raw);
|
||||
|
||||
let db = Approvals::open(&path).expect("a pre-v5 db opens");
|
||||
let pending = db.pending().expect("pending");
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].id, kept);
|
||||
assert!(db.recent_resolved(10).unwrap().is_empty());
|
||||
assert!(db.get(old).unwrap().is_none());
|
||||
}
|
||||
|
||||
/// A database written before v6 still holds `schedule_prompt` rows.
|
||||
/// Opening it drops them and keeps every other row.
|
||||
#[test]
|
||||
fn a_pre_v6_schedule_prompt_row_is_dropped_on_open() {
|
||||
let (_dir, path, db) = open_temp();
|
||||
let kept = db
|
||||
.submit_kind("iris", ApprovalKind::UpdateMetaInputs, "[]", None, "iris")
|
||||
.unwrap();
|
||||
drop(db);
|
||||
let raw = Connection::open(&path).unwrap();
|
||||
raw.execute_batch(
|
||||
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status)
|
||||
VALUES ('iris', 'schedule_prompt', '{}', 0, 'pending');
|
||||
UPDATE schema_versions SET version = 5 WHERE store = 'approvals';",
|
||||
)
|
||||
.unwrap();
|
||||
let old: i64 = raw.last_insert_rowid();
|
||||
drop(raw);
|
||||
|
||||
let db = Approvals::open(&path).expect("a pre-v6 db opens");
|
||||
let pending = db.pending().expect("pending");
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].id, kept);
|
||||
assert!(db.get(old).unwrap().is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<bool> {
|
|||
|
||||
/// 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<Connection> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Self> {
|
||||
let conn = crate::db::open(db_path, "agent_power")?;
|
||||
conn.execute_batch(SCHEMA)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -113,8 +113,8 @@ fn plan_root_agent(list_result: anyhow::Result<Vec<String>>) -> RootAgentPlan {
|
|||
|
||||
/// Auto-create the manager container on startup if it isn't already there.
|
||||
/// 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<Coordinator>) -> Result<()> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<Ident>,
|
||||
},
|
||||
/// Cancel an open thread the agent owns. Authorisation +
|
||||
/// per-kind semantics in
|
||||
/// `docs/process/conventions.md::Loose-ends wire shape`.
|
||||
CancelLooseEnd { kind: CancelLooseEndKind, id: i64 },
|
||||
/// Mark every message popped since the last `AckTurn` as handled.
|
||||
/// Harness↔broker pairing fired after `TurnOutcome::Ok`. See
|
||||
/// `docs/process/conventions.md::Broker delivery + ack cycle`.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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<u64> },
|
||||
/// List pending approval requests.
|
||||
Pending,
|
||||
/// Approve a pending request by id; the action runs immediately.
|
||||
Approve { id: i64 },
|
||||
/// Deny a pending request by id.
|
||||
Deny { id: i64 },
|
||||
/// Declare an agent's CPU/memory overrides for the per-container
|
||||
/// 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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agents: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub approvals: Option<Vec<Approval>>,
|
||||
/// `Urls` result — this hive's domain plus the browser-facing
|
||||
/// 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<Approval>) -> 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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
//! The approval queue wire shape: one row (`Approval`) per pending/resolved
|
||||
//! operator decision, its `kind` discriminator, and the terminal-state enum.
|
||||
//! `ReminderStats` lives here too — small enough not to earn its own file,
|
||||
//! and unrelated to any other topic module.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use hive_types::Ident;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One row in the approval queue. `commit_ref` is overloaded per
|
||||
/// `kind` — see `docs/agent-lifecycle/approvals.md::Approval kinds (wire shapes)`
|
||||
/// for the encoding table and lifecycle.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Approval {
|
||||
pub id: i64,
|
||||
pub agent: Ident,
|
||||
pub kind: ApprovalKind,
|
||||
/// Kind-specific payload (git sha / inputs array / empty). See the
|
||||
/// Approval struct doc.
|
||||
pub commit_ref: String,
|
||||
pub requested_at: DateTime<Utc>,
|
||||
pub status: ApprovalStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_at: Option<DateTime<Utc>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
/// Free-text description the manager attached at submission time;
|
||||
/// shown on the dashboard approval card.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// What action the approval, when granted, will trigger.
|
||||
/// Variant-specific payload encoding + flow lives in
|
||||
/// `docs/agent-lifecycle/approvals.md::Approval kinds (wire shapes)`.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, strum::IntoStaticStr)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum ApprovalKind {
|
||||
/// Run `nix flake update [inputs...]` on the meta flake and commit
|
||||
/// the resulting lock changes.
|
||||
UpdateMetaInputs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ApprovalStatus {
|
||||
Pending,
|
||||
Approved,
|
||||
Denied,
|
||||
Failed,
|
||||
/// Manager withdrew the request before the operator acted on it.
|
||||
/// Distinct from `Denied` (operator decision) and `Failed`
|
||||
/// (post-approval lifecycle error). See
|
||||
/// `docs/agent-lifecycle/approvals.md::Withdrawing a pending approval`.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Reminder activity statistics for an agent over a time window.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReminderStats {
|
||||
/// Total reminders scheduled in the window (`created_at` >= cutoff).
|
||||
pub scheduled: u64,
|
||||
/// Reminders that have been delivered in the window (`sent_at` IS NOT NULL).
|
||||
pub delivered: u64,
|
||||
/// Reminders still pending in the window (`sent_at` IS NULL).
|
||||
pub pending: u64,
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
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::*;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
},
|
||||
/// A sub-agent's recorded flake rev is stale relative to hyperhive.
|
||||
NeedsUpdate { agent: String },
|
||||
/// Container exited without an operator-initiated stop (crash).
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
//! `hivectl approvals` — the operator approval queue (pending/approve/deny).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use hive_host_sock::HostRequest;
|
||||
|
||||
use crate::cli::ApprovalsCmd;
|
||||
use crate::util::render;
|
||||
|
||||
/// Dispatch `hivectl approvals <verb>` — the operator approval queue.
|
||||
pub(crate) async fn run_approvals(socket: &Path, cmd: ApprovalsCmd) -> Result<()> {
|
||||
match cmd {
|
||||
ApprovalsCmd::Pending => {
|
||||
render(crate::client::request(socket, HostRequest::Pending).await?)
|
||||
}
|
||||
ApprovalsCmd::Approve { id } => {
|
||||
render(crate::client::request(socket, HostRequest::Approve { id }).await?)
|
||||
}
|
||||
ApprovalsCmd::Deny { id } => {
|
||||
render(crate::client::request(socket, HostRequest::Deny { id }).await?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -82,13 +82,6 @@ pub enum Cmd {
|
|||
/// under `hivectl agent <name>`. Idempotent — safe to re-run. Once
|
||||
/// enabled, `hivectl agent <name> 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
|
||||
|
|
|
|||
|
|
@ -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 <name> <spawn|kill|
|
||||
//! rebuild|restart|choom|…>`, `list-agents`, `approvals <pending|approve|
|
||||
//! deny>`, `stop` / `start`) and provisioning (`forge` / `matrix` /
|
||||
//! Container lifecycle (`agent <name> <spawn|kill|rebuild|restart|choom|…>`,
|
||||
//! `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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<HashMap<String, ConfigPrStatus>>);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue