Watch
0
0
Fork
You've already forked hyperhive
0

hive-c0re: remove the approvals subsystem

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

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

View file

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

View file

@ -103,11 +103,8 @@ pub(super) async fn post_kill(
}
// Manager is stoppable from the dashboard like any other
// 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

View file

@ -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");

View file

@ -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))

View file

@ -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

View file

@ -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