258 lines
9.8 KiB
Rust
258 lines
9.8 KiB
Rust
//! DAG shape builders — every operation as a template over the shared
|
|
//! node primitives — plus submit-time cycle validation (petgraph is
|
|
//! confined to this validation; the runtime store stays the plain
|
|
//! `Vec<Node>` + `deps`).
|
|
//!
|
|
//! Every node carries its own `agent` (there is no DAG-level agent) — the
|
|
//! `node` helper stamps each node's agent. This module holds the *pure*
|
|
//! shape builders (no I/O). The hive-wide **power ops** (`stop` / `start` /
|
|
//! `restart`) are NOT here: their per-agent shape depends on each agent's
|
|
//! live running state (an async `lifecycle::is_running` read), so they are
|
|
//! assembled dynamically in `submit.rs` out of the shared pure primitives
|
|
//! this module exports (`node`, `after_ok`, `rebuild_nodes`) — one
|
|
//! independent per-agent subgraph each, concurrent on its own lease, ONE
|
|
//! DAG for the whole hive-wide op. `stop`/`start` write the durable `wanted`
|
|
//! intent via a head `SetWanted(w)` node (holding the agent lease, so
|
|
//! intent+reconcile is atomic per-agent); `restart` writes no intent — it
|
|
//! bounces the container and lets the tail `Reconcile` converge to the
|
|
//! agent's existing `wanted`.
|
|
//!
|
|
//! ```text
|
|
//! rebuild(a): Prebuild(a) → StopForUpdate(a) → Swap(a) →(ok) PostSwap(a) →(any) Reconcile(a)
|
|
//! spawn(a): Provision(a) → Create(a) → WriteDropin(a) → Reconcile(a) [wanted=Up at approve]
|
|
//! perm-change(a): WritePermFile(a) → «rebuild subgraph»
|
|
//! meta-update(inp): MetaLock(inp) →«in-DAG rebuild subgraph per affected a»
|
|
//! ```
|
|
//!
|
|
//! For the dynamic power-op shapes (`stop` / `start` / `restart`, built from
|
|
//! live online/offline state), see `submit.rs`.
|
|
|
|
use anyhow::{Result, bail};
|
|
|
|
use super::model::{DagSpec, Dep, DepWhen, NodeKind, NodeSpec, PermPayload, Source, Template};
|
|
use crate::coordinator::TransientKind;
|
|
|
|
/// After-ok edge on the previous node — the common chain link. Shared with
|
|
/// the async power-op builders in `submit.rs` (which assemble per-agent
|
|
/// chains dynamically from live container state).
|
|
pub(crate) fn after_ok(on: u32) -> Vec<Dep> {
|
|
vec![Dep {
|
|
on,
|
|
when: DepWhen::AfterOk,
|
|
}]
|
|
}
|
|
|
|
/// Build one node targeting `agent`. The single place a node's agent is
|
|
/// stamped. Shared with `submit.rs`'s dynamic power-op builders.
|
|
pub(crate) fn node(agent: &str, kind: NodeKind, deps: Vec<Dep>) -> NodeSpec {
|
|
NodeSpec {
|
|
agent: agent.to_owned(),
|
|
kind,
|
|
deps,
|
|
}
|
|
}
|
|
|
|
/// The rebuild node chain. `PostSwap` carries the swap's Ok-only
|
|
/// bookkeeping tail (rev marker, forge/matrix sync, kick, rescan) and deps
|
|
/// `Swap` with `AfterOk`. `Reconcile` then deps on `PostSwap` with
|
|
/// `AfterAny`: it must run even when the swap failed, so a previously-up
|
|
/// agent comes back on its old config (today's recovery-start). On swap
|
|
/// failure the `AfterOk` `PostSwap` is cancel-cascaded to a terminal state,
|
|
/// which still satisfies `Reconcile`'s `AfterAny` edge — the only `AfterAny`
|
|
/// edge in v1. Pointing `Reconcile` at `PostSwap` (not `Swap`) also
|
|
/// serializes the tail ahead of the reconcile, so there's no double
|
|
/// rescan/kick race.
|
|
pub(crate) fn rebuild_nodes(agent: &str, relock: bool, base: u32) -> Vec<NodeSpec> {
|
|
vec![
|
|
node(
|
|
agent,
|
|
NodeKind::Prebuild { relock },
|
|
if base == 0 {
|
|
Vec::new()
|
|
} else {
|
|
after_ok(base - 1)
|
|
},
|
|
),
|
|
node(agent, NodeKind::StopForUpdate, after_ok(base)),
|
|
node(agent, NodeKind::Swap, after_ok(base + 1)),
|
|
node(agent, NodeKind::PostSwap, after_ok(base + 2)),
|
|
node(
|
|
agent,
|
|
NodeKind::Reconcile,
|
|
vec![Dep {
|
|
on: base + 3,
|
|
when: DepWhen::AfterAny,
|
|
}],
|
|
),
|
|
]
|
|
}
|
|
|
|
/// One uniform rebuild shape — no `was_running` branch. `StopForUpdate`
|
|
/// noops when already down; the tail `Reconcile` auto-noops the start
|
|
/// when `wanted = Offline` (a rebuild of a deliberately-stopped agent
|
|
/// leaves it stopped). `relock = false` only for meta-update cascade
|
|
/// children.
|
|
pub fn rebuild(agent: &str, source: Source, reason: String, relock: bool) -> DagSpec {
|
|
DagSpec {
|
|
template: Template::Rebuild,
|
|
source,
|
|
reason,
|
|
approval_id: None,
|
|
inputs: Vec::new(),
|
|
perm_payload: None,
|
|
transient: Some(TransientKind::Rebuilding),
|
|
nodes: rebuild_nodes(agent, relock, 0),
|
|
}
|
|
}
|
|
|
|
/// Approval-driven deploy (`MergeConfigPr`): the whole two-phase pipeline
|
|
/// stays one opaque node in v1 (design doc §9) — wire-visible as a `rebuild`
|
|
/// card like today.
|
|
pub fn approval_deploy(agent: &str, approval_id: i64, reason: String) -> DagSpec {
|
|
DagSpec {
|
|
template: Template::Rebuild,
|
|
source: Source::Approval,
|
|
reason,
|
|
approval_id: Some(approval_id),
|
|
inputs: Vec::new(),
|
|
perm_payload: None,
|
|
transient: Some(TransientKind::Rebuilding),
|
|
nodes: vec![node(agent, NodeKind::ApprovalDeploy, Vec::new())],
|
|
}
|
|
}
|
|
|
|
/// A single `Reconcile` node that converges observed power state to the
|
|
/// persisted intent — `wanted` is untouched (no `SetWanted`), unlike the
|
|
/// operator `start`/`stop` templates. Test-only helper now (used to build
|
|
/// single-node lifecycle DAGs that exercise per-agent lease serialization
|
|
/// in the queue tests); production paths no longer emit a bare reconcile.
|
|
#[cfg(test)]
|
|
pub fn reconcile_only(
|
|
template: Template,
|
|
agent: &str,
|
|
source: Source,
|
|
reason: String,
|
|
transient: Option<TransientKind>,
|
|
) -> DagSpec {
|
|
DagSpec {
|
|
template,
|
|
source,
|
|
reason,
|
|
approval_id: None,
|
|
inputs: Vec::new(),
|
|
perm_payload: None,
|
|
transient,
|
|
nodes: vec![node(agent, NodeKind::Reconcile, Vec::new())],
|
|
}
|
|
}
|
|
|
|
/// First-deploy spawn (approval-driven): `Provision` (proposed/applied
|
|
/// repos, state subvolume, meta registration) then `Create`
|
|
/// (`nixos-container create`), drop-in write, then `Reconcile` starts
|
|
/// the container (`wanted = Up` written at approve time).
|
|
pub fn spawn(agent: &str, approval_id: i64, reason: String) -> DagSpec {
|
|
DagSpec {
|
|
template: Template::Spawn,
|
|
source: Source::Approval,
|
|
reason,
|
|
approval_id: Some(approval_id),
|
|
inputs: Vec::new(),
|
|
perm_payload: None,
|
|
transient: Some(TransientKind::Spawning),
|
|
nodes: vec![
|
|
node(agent, NodeKind::Provision, Vec::new()),
|
|
node(agent, NodeKind::Create, after_ok(0)),
|
|
node(agent, NodeKind::WriteDropin, after_ok(1)),
|
|
node(agent, NodeKind::Reconcile, after_ok(2)),
|
|
],
|
|
}
|
|
}
|
|
|
|
/// Perm change: commit the JSON file(s), then the rebuild subgraph so
|
|
/// the updated `HIVE_TOOL_GROUPS` / `HIVE_CAPABILITIES` env var takes
|
|
/// effect in the container.
|
|
pub fn perm_change(agent: &str, source: Source, reason: String, payload: PermPayload) -> DagSpec {
|
|
let mut nodes = vec![node(agent, NodeKind::WritePermFile, Vec::new())];
|
|
nodes.extend(rebuild_nodes(agent, true, 1));
|
|
DagSpec {
|
|
template: Template::PermChange,
|
|
source,
|
|
reason,
|
|
approval_id: None,
|
|
inputs: Vec::new(),
|
|
perm_payload: Some(payload),
|
|
transient: Some(TransientKind::Rebuilding),
|
|
nodes,
|
|
}
|
|
}
|
|
|
|
/// Meta-input lock bump. The `MetaLock` executor grows one rebuild subgraph
|
|
/// per affected agent into *this same* DAG on completion (via
|
|
/// `append_subgraph`) — appended *after* the bump lands so their prebuilds
|
|
/// run against the post-bump lock, and a failed bump appends nothing
|
|
/// (replacing the old fan-out-child-DAGs dance).
|
|
/// `transient = Rebuilding` because those appended subgraphs are rebuilds:
|
|
/// it's applied per-agent at claim time (the `MetaLock` head needs no lease,
|
|
/// 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(
|
|
inputs: Vec<String>,
|
|
source: Source,
|
|
reason: String,
|
|
approval_id: Option<i64>,
|
|
) -> DagSpec {
|
|
DagSpec {
|
|
template: Template::MetaUpdate,
|
|
source,
|
|
reason,
|
|
approval_id,
|
|
inputs,
|
|
perm_payload: None,
|
|
transient: Some(TransientKind::Rebuilding),
|
|
nodes: vec![node(
|
|
"hyperhive",
|
|
NodeKind::MetaLock {
|
|
sweep: false,
|
|
fanout: None,
|
|
},
|
|
Vec::new(),
|
|
)],
|
|
}
|
|
}
|
|
|
|
// The boot is assembled inline in `workers/auto_update.rs::submit_boot_tree`
|
|
// as ONE `Boot` DAG (a sweep `MetaLock` root that grows rebuild subgraphs
|
|
// in-DAG, plus a `Reconcile` root per drifted agent) — no anchor node and no
|
|
// per-agent child DAGs.
|
|
|
|
/// Validate a spec before it enters the queue: node ids are dense
|
|
/// (index = id), deps reference existing nodes, and the dep graph is
|
|
/// acyclic (petgraph `toposort`). Rejecting cycles here fixes the old
|
|
/// queue's documented "circular dep silently deadlocks forever" caveat.
|
|
pub fn validate(spec: &DagSpec) -> Result<()> {
|
|
if spec.nodes.is_empty() {
|
|
bail!("dag spec {:?} has no nodes", spec.template);
|
|
}
|
|
let n = spec.nodes.len();
|
|
let mut graph = petgraph::graph::DiGraph::<u32, ()>::new();
|
|
let idx: Vec<_> = (0..n)
|
|
.map(|i| graph.add_node(u32::try_from(i).unwrap_or(u32::MAX)))
|
|
.collect();
|
|
for (i, node) in spec.nodes.iter().enumerate() {
|
|
for dep in &node.deps {
|
|
let Some(&dep_idx) = idx.get(dep.on as usize) else {
|
|
bail!(
|
|
"dag spec {:?} node {i} depends on unknown node {}",
|
|
spec.template,
|
|
dep.on
|
|
);
|
|
};
|
|
graph.add_edge(dep_idx, idx[i], ());
|
|
}
|
|
}
|
|
if petgraph::algo::toposort(&graph, None).is_err() {
|
|
bail!("dag spec {:?} contains a dependency cycle", spec.template);
|
|
}
|
|
Ok(())
|
|
}
|