//! 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` + `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 { 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) -> 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 { 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, ) -> 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, source: Source, reason: String, approval_id: Option, ) -> 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::::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(()) }