Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-c0re/src/job_queue/model.rs
atlas 58ef299c3c 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.
2026-10-04 23:55:32 +02:00

248 lines
12 KiB
Rust

//! Data model for the generic job-DAG queue: the node kinds — the primitive
//! operations — and what each one carries. The `State` / `PermPayload` wire
//! enums live in `hive_host_sock::jobs` (they travel on the host admin
//! socket) and are re-exported here for the queue's internal use. The graph
//! itself is served through `hive_jobq_wire`'s generic projection — there is
//! no second, typed view of it any more.
//!
//! **One level, not two.** The node is the unit of everything: scheduling,
//! execution, build-log, cancel, and the dashboard group (a group root's
//! subtree *is* the group). A DAG used to be a second level above it, with
//! its own store and its own id; there is no container node any more, so a
//! job is exactly the nodes it declared. See `docs/scheduler/coordinator.md::Job queue`
//! for the full design.
pub use hive_host_sock::jobs::{PermPayload, State};
use serde::Serialize;
/// 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
/// [`Resource`](super::resource::Resource), declared per node where the node
/// is constructed rather than derived from its kind); the
/// meta *repo* is serialized by `meta::META_LOCK` inside the wrapped
/// functions themselves, which is why there is no `GitCommit` node —
/// a standalone commit node would open a dirty-working-tree window
/// between nodes that the fused `meta.rs` ops deliberately close.
///
/// What each kind does, the resource/lease rules, and the DAG shapes they
/// compose into live in the node-inventory table and surrounding sections
/// of `docs/scheduler/coordinator.md` — this enum is deliberately not a
/// second copy of that; each variant below gets a one-line pointer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, strum::IntoStaticStr)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum NodeKind {
/// The rebuild's meta-repo preamble. `relock = false` only for
/// meta-update cascade rebuilds (re-locking would revert the bump the
/// cascade just committed).
MetaSync { agent: String, relock: bool },
/// Out-of-band toplevel build while the container keeps serving.
Prebuild { agent: String },
/// `nixos-container update` profile-swap.
Swap { agent: String },
/// The post-`Swap` bookkeeping tail as a first-class node.
RebuildBookkeeping { agent: String },
/// First-spawn pre-create provisioning.
Provision { agent: String },
/// First-spawn `nixos-container create` proper. Assumes the
/// upstream `Provision` node already registered the agent in meta.
Create { agent: String },
/// `nixos-container destroy` plus the un-registration that follows it.
///
/// Deliberately excluded from [`NodeKind::takes_container_down`] — see
/// that method for why a node downstream of a `Stop` must not claim the
/// container going down as its own.
DestroyContainer { agent: String },
/// The `purge = true` half of a destroy: delete the agent's state
/// subvolume (via hive-priv, since a subvolume root defeats
/// `remove_dir_all`) plus its state and applied dirs.
PurgeState { agent: String },
/// The post-destroy bookkeeping tail.
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
/// scheduler appends the fan-out `Rebuild` DAGs on completion.
MetaLock {
sweep: bool,
fanout: Option<Vec<String>>,
inputs: Vec<String>,
},
/// Idempotent power converge *planner*: fans a child
/// [`NodeKind::Start`] / [`NodeKind::Stop`] out at runtime rather than
/// doing the mechanical work itself.
Reconcile { agent: String },
/// Mechanical container start. Fanned out by a [`NodeKind::Reconcile`]
/// that observed `wanted = Up` and the container down.
Start { agent: String },
/// Mechanical container stop. Fanned out by a [`NodeKind::Reconcile`]
/// that observed `wanted = Offline` and up.
Stop { agent: String },
/// Mechanical `nixos-container stop` for the profile swap. Never
/// touches `wanted`. Noop if already stopped.
StopForUpdate { agent: String },
/// Set the graceful-stop fence + kick the harness so it runs one
/// stop-checkpoint turn.
Signal { agent: String },
/// Await the harness clearing the fence, bounded by
/// `GRACEFUL_STOP_TIMEOUT`. Resolves ok either way — the
/// downstream `Reconcile` performs the actual stop.
Drain { agent: String },
/// Write the pause marker (`Coordinator::set_paused`) + mark
/// `pause_pending`.
PauseSignal { agent: String },
/// Await the harness reporting `PauseAcknowledged`, bounded by a
/// timeout. Resolves ok either way — pausing is best-effort from
/// the queue's perspective, same as `Drain`.
PauseDrain { agent: String },
/// `set_nspawn_flags` + `set_resource_limits` + daemon-reload.
WriteDropin { agent: String },
/// Commit `tool-groups.json` / `capabilities.json` per its `payload`.
WritePermFile { agent: String, payload: PermPayload },
/// 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 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
/// report).
EmitRebuilt { agent: String, ok: bool },
/// Write the agent's durable power intent (`wanted = Up` when `up`,
/// else `Offline`) as the head node of a power-op DAG.
SetWanted { agent: String, up: bool },
/// One-shot boot-time forge user/token sweep for every existing
/// container (`forge::ensure_all`). Agentless.
ForgeSweep,
/// Matrix user/space sweep (`matrix::ensure_all`): at boot and every 30
/// minutes. Agentless.
MatrixSweep,
/// `/knowledge` pull (`knowledge::pull`): at boot, on the swarm
/// knowledge-changed event, and hourly. Agentless.
KnowledgePull,
/// One-shot boot-time pull of the agent set the swarm controller
/// declares for this hive (`wanted::pull`). Agentless.
WantedPull,
}
/// How a hive-c0re node describes itself to a generic graph viewer.
///
/// Every field in the wire node's `data` representation here used to be a named column on
/// `NodeView`, meaningful for one node kind and `null` on all the others. As
/// free-form data it costs the wire type nothing, and a generic consumer
/// renders it without knowing what any of it means.
impl hive_jobq_wire::WireNode for NodeKind {
fn label(&self) -> String {
<&str>::from(self).to_owned()
}
fn data(&self, id: hive_jobq_wire::WireId) -> serde_json::Value {
let mut data = serde_json::Map::new();
let agent = self.agent();
if !agent.is_empty() {
data.insert("agent".to_owned(), agent.into());
}
if let NodeKind::MetaLock { inputs, .. } = self
&& !inputs.is_empty()
{
data.insert("inputs".to_owned(), inputs.clone().into());
}
// Not in the payload at all — the build log is keyed on node identity
// in a side table, which is why `data` is handed the id.
if let Some(log) = crate::build_logs::global().and_then(|h| h.id_for_node(id)) {
data.insert("build_log_id".to_owned(), log.into());
}
if data.is_empty() {
serde_json::Value::Null
} else {
serde_json::Value::Object(data)
}
}
}
impl NodeKind {
/// The agent this node targets, or `""` for agentless kinds
/// ([`NodeKind::MetaLock`] on the `hyperhive` pseudo-agent, and the
/// swarm-wide sweeps and pulls).
#[must_use]
pub fn agent(&self) -> &str {
match self {
NodeKind::MetaSync { agent, .. }
| NodeKind::Prebuild { agent }
| NodeKind::Swap { agent }
| NodeKind::RebuildBookkeeping { agent }
| NodeKind::Provision { agent }
| NodeKind::Create { agent }
| NodeKind::DestroyContainer { agent }
| NodeKind::PurgeState { agent }
| NodeKind::DestroyBookkeeping { agent, .. }
| NodeKind::Reconcile { agent }
| NodeKind::Start { agent }
| NodeKind::Stop { agent }
| NodeKind::StopForUpdate { agent }
| NodeKind::Signal { agent }
| NodeKind::Drain { agent }
| NodeKind::PauseSignal { agent }
| NodeKind::PauseDrain { agent }
| NodeKind::WriteDropin { agent }
| NodeKind::WritePermFile { agent, .. }
| NodeKind::AgentWindow { agent }
| NodeKind::EmitRebuilt { agent, .. }
| NodeKind::SetWanted { agent, .. } => agent,
NodeKind::MetaLock { .. }
| NodeKind::ForgeSweep
| NodeKind::MatrixSweep
| NodeKind::KnowledgePull
| NodeKind::WantedPull => "",
}
}
/// Whether running this node is *expected* to take the agent's container
/// down. Feeds `RunningTransient::takes_container_down`, which the crash watcher
/// reads to tell an intentional stop from a crash.
///
/// This is a **safety** question, not a display one — it decides whether a
/// vanished container raises an alert. It is deliberately not derived from
/// the pill label: a label is free to be renamed or added without moving
/// the alerting boundary, and only the operation itself knows its intent.
///
/// Default is `false`, and that asymmetry is the point. A wrong `false`
/// costs a spurious crash event; a wrong `true` **swallows a real crash**
/// silently. So a kind earns `true` by being listed here, and anything new
/// is noisy-but-safe until someone decides otherwise.
#[must_use]
pub fn takes_container_down(&self) -> bool {
matches!(
self,
// Explicit stops, and the quiesce steps that precede one.
NodeKind::Stop { .. }
| NodeKind::StopForUpdate { .. }
| NodeKind::Signal { .. }
| NodeKind::Drain { .. }
| NodeKind::SetWanted { up: false, .. }
// The rebuild's own machinery: the container is down across the
// swap and the drop-in write that reconfigures it.
| NodeKind::Swap { .. }
| NodeKind::WriteDropin { .. }
)
// Everything else is `false` on purpose, including the ones that would
// be easy to wave through:
// - `Create` / `Start` / `SetWanted{up}` bring a container UP. A
// container disappearing *while starting* is a genuine crash and has
// to keep reporting as one.
// - `DestroyContainer` looks like the most obvious `true` on this list
// and is the one that must stay `false`. It is edged downstream of a
// `Stop`, so the container is already down when it claims; the stop
// that the operator asked for is accounted for by the node that
// performs it. A container found alive at destroy time is a genuine
// bug, and a `true` here would suppress the alert that says so.
// - `Reconcile` is a planner; it fans out `Start` / `Stop`, which carry
// their own answer.
// - `AgentWindow` brackets a rebuild without itself stopping anything,
// though it *parents* nodes that answer
// `true`. This is per-node, not per-subtree, and each of those
// children reports for itself — so `true` here would only widen
// suppression over the build and tail, where a vanished container is
// still a real crash.
}
}