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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue