//! Data model for the generic job-DAG queue: node kinds (the primitive //! operations), dependency edges, and the runtime `Dag` / `Node` store. //! The serialized *views* — `DagView` / `NodeView` plus the `Template` //! / `Source` / `State` / `PermPayload` wire enums — live in //! `hive_sh4re::jobs` (wire types belong to the shared crate) and are //! re-exported here for the queue's internal use. //! //! Two levels: the **DAG** is the unit of cancel / approval-resolution //! and the dashboard group; the **node** is the unit of scheduling / //! execution / build-log / step label, and carries its own `agent` (a //! DAG can span agents). See `docs/coordinator.md::Job queue` for the //! full design. pub use hive_sh4re::jobs::{DagView, NodeId, PermPayload, Source, State, Template}; use serde::Serialize; use crate::coordinator::TransientKind; /// When a dependency edge is considered satisfied. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum DepWhen { /// Dep must reach `Done`. A `Failed` / `Cancelled` dep cancels this /// node (cancel-downstream). AfterOk, /// Dep must merely reach a terminal state (ok *or* fail). Used only /// by `rebuild`'s tail `Reconcile` so the recovery-start runs even /// when `Swap` failed. AfterAny, } /// A dependency edge (intra-DAG only — cross-DAG ordering comes from /// the per-agent lease + dedup, never from edges between DAGs). #[derive(Debug, Clone, Copy, Serialize)] pub struct Dep { pub on: NodeId, pub when: DepWhen, } /// 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 /// [`NodeKind::needs_build_slot`] / [`NodeKind::needs_lease`]); 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. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum NodeKind { /// Out-of-band toplevel build while the container keeps serving: /// meta `sync_agents`, optional per-agent relock, then /// `lifecycle::prebuild_toplevel`. `relock = false` only for /// meta-update cascade rebuilds (re-locking would revert the bump /// the cascade just committed). The `prebuild_toplevel` warm is /// skipped when the container is already down — it only exists to /// shrink the swap's downtime, which a stopped agent doesn't need /// (the sync + dir prep still run; `Swap` builds inline). Prebuild { relock: bool }, /// `nixos-container update` profile-swap (requires the container /// stopped). Re-applies nspawn flags + resource limits first — /// rebuild is the reconcile verb. The post-rebuild bookkeeping tail /// lives in the sibling `PostSwap` node. Swap, /// The post-`Swap` bookkeeping tail as a first-class node: rev marker, /// forge + matrix sync, manager kick, container rescan, meta-inputs /// snapshot. Split out of `Swap` for dashboard visibility + retry /// granularity. Deps `AfterOk(Swap)`, so it runs only when the profile /// swap succeeded; the tail `Reconcile` deps `AfterAny(PostSwap)`, so on /// swap failure this node is cancel-cascaded (a terminal state) and /// recovery still runs. Store/forge/matrix work only — no nix build, so /// build-slot-exempt; the agent lease taken at `Swap` is held across the /// whole chain until `Reconcile` settles, so it's not re-declared here. PostSwap, /// First-spawn pre-create provisioning: proposed/applied repos, /// state subvolume, and meta registration (`sync_agents`). Runs /// ahead of `Create` so the `nixos-container create --flake /// meta#` ref resolves. Store/meta-only — no container yet — /// so it's lease- and build-slot-exempt like `Prebuild`. Provision, /// First-spawn `nixos-container create` proper. Assumes the /// upstream `Provision` node already registered the agent in meta. Create, /// Meta flake lock bump. `sweep = false`: `meta::lock_update` /// (commit fused, under `META_LOCK`) with the DAG's `inputs`; /// `sweep = true`: `meta::lock_update_hyperhive`, *non-fatal* (a /// failed boot-time bump must not cancel the fan-out rebuilds). /// On success the scheduler appends child `Rebuild` DAGs: the /// precomputed `fanout` list when present (boot sweep), else the /// post-bump affected set (`meta_update_cascade_agents`). MetaLock { sweep: bool, fanout: Option>, }, /// Idempotent power converge *planner*: read `wanted` + observed /// state and decide the action (start if `Up` & down, stop if /// `Offline` & up, else noop). The mechanical work is not done in /// this node — it fans a child [`NodeKind::Start`] / [`NodeKind::Stop`] /// DAG out at runtime so the sub-step is a first-class DAG node. Reconcile, /// Mechanical container start: the start preamble (runtime dir + /// drop-ins), `start_with_fallback`, MCP listener registration, and /// the manager kick. Fanned out by a [`NodeKind::Reconcile`] that /// observed `wanted = Up` and the container down. Start, /// Mechanical container stop: `nixos-container` kill, MCP listener /// unregister, and the `Killed` manager notify. Fanned out by a /// [`NodeKind::Reconcile`] that observed `wanted = Offline` and up. Stop, /// Mechanical `nixos-container stop` for the profile swap. Never /// touches `wanted`. Noop if already stopped. StopForUpdate, /// Set the graceful-stop fence + kick the harness so it runs one /// stop-checkpoint turn. Signal, /// Await the harness clearing the fence, bounded by /// `GRACEFUL_STOP_TIMEOUT`. Resolves ok either way — the /// downstream `Reconcile` performs the actual stop. Drain, /// `set_nspawn_flags` + `set_resource_limits` + daemon-reload. WriteDropin, /// Commit `tool-groups.json` / `capabilities.json` per the DAG's /// `perm_payload` (commit fused under `META_LOCK`). WritePermFile, /// Opaque approval deploy pipeline (`MergeConfigPr`): the two-phase /// prepare/finalize/abort meta deploy stays inside `actions.rs` in v1 — /// deliberately not /// modeled as scheduler nodes (see the design doc §9). ApprovalDeploy, /// Write the agent's durable power intent (`wanted = Up` when `up`, else /// `Offline`) as a first-class DAG node, at the head of a power-op /// template so the downstream `Reconcile` reads it. Replaces the old /// pre-submit `set_wanted` side effect: the intent write is now part of /// the atomic DAG (crash-safe, per-agent — a multi-agent DAG carries one /// `SetWanted` per agent). Build-slot-exempt (a store write), but /// **lease-needing**: it takes the agent's lifecycle lease so the whole /// power-op DAG (intent write → reconcile) is atomic per-agent — two /// racing ops (e.g. restart vs stop) can't clobber each other's intent /// before either reconciles, which is the point of moving the write into /// the DAG. (In `stale_start` the lease is thus held across the head /// `Prebuild`, but that's a no-op there — the agent is down, so prebuild /// is skipped.) SetWanted { up: bool }, /// Per-DAG terminal hook (approval-driven DAGs — spawn / opaque deploy): /// resolve the DAG's approval row from the rolled-up outcome. Appended once /// with a weak (`AfterAny`) edge on the DAG's tails, so it runs exactly when /// the DAG has settled (any outcome, including a cancel before starting). /// Build-slot- and lease-exempt; always runs (weak edge ⇒ never cascaded). ResolveApproval, /// Per-DAG terminal hook (rebuild / perm-change DAGs): emit one `Rebuilt` /// manager event per targeted agent — `ok` on `Done`, `!ok` on `Failed`, /// none on cancel. Appended weak-dep on the tails; slot/lease-exempt. EmitRebuilt, /// Per-DAG terminal hook (power-op DAGs): on a *cancelled* DAG, revert each /// agent's `wanted` intent to its observed state — the operator's cancel /// means "don't do it". Noop on any non-cancelled outcome. Appended weak-dep /// on the tails; slot/lease-exempt. RevertIntent, /// The **DAG container** node: one per submitted DAG, carrying the group's /// domain metadata. Every template node hangs *under* it (its subtree), so /// the container's `NodeId` **is** the DAG id, its rolled-up state **is** the /// DAG state, and it reaching terminal **is** the completion signal that /// fires the DAG's inline hook (approval-resolve / rebuilt-emit / /// intent-revert, dispatched off `template`). Pure grouping — lease- and /// build-slot-exempt; the executor instant-completes it (`Done`) so it /// reaches `Finishing` and its children start. Dag { template: Template, source: Source, reason: String, transient: Option, approval_id: Option, inputs: Vec, perm_payload: Option, created_at: i64, }, } impl NodeKind { /// Wire string for `NodeView.kind`. pub fn as_str(&self) -> &'static str { match self { NodeKind::Prebuild { .. } => "prebuild", NodeKind::Swap => "swap", NodeKind::PostSwap => "post_swap", NodeKind::Provision => "provision", NodeKind::Create => "create", NodeKind::MetaLock { .. } => "meta_lock", NodeKind::Reconcile => "reconcile", NodeKind::Start => "start", NodeKind::Stop => "stop", NodeKind::StopForUpdate => "stop_for_update", NodeKind::Signal => "signal", NodeKind::Drain => "drain", NodeKind::WriteDropin => "write_dropin", NodeKind::WritePermFile => "write_perm_file", NodeKind::ApprovalDeploy => "approval_deploy", NodeKind::SetWanted { .. } => "set_wanted", NodeKind::ResolveApproval => "resolve_approval", NodeKind::EmitRebuilt => "emit_rebuilt", NodeKind::RevertIntent => "revert_intent", NodeKind::Dag { .. } => "dag", } } /// Nix-heavy kinds hold one of the `buildSlots` semaphore permits /// for the node's duration. pub fn needs_build_slot(&self) -> bool { matches!( self, NodeKind::Prebuild { .. } | NodeKind::Swap | NodeKind::Create | NodeKind::MetaLock { .. } | NodeKind::ApprovalDeploy ) } /// Container-affecting kinds require the DAG to hold the agent's /// lifecycle lease (acquired at the first such node, held until the /// DAG is terminal). Lease-exempt kinds (`Prebuild`, `Provision`, /// `MetaLock`, `WritePermFile`) touch the store / meta repo, not the /// running container — which is exactly why a `Prebuild` can overlap /// another DAG's work on the same agent. `Provision` precedes the /// container's existence entirely, so the lease is first taken at the /// `Create` node it feeds. pub fn needs_lease(&self) -> bool { matches!( self, NodeKind::Swap | NodeKind::Create | NodeKind::Reconcile | NodeKind::StopForUpdate | NodeKind::Signal | NodeKind::Drain | NodeKind::WriteDropin | NodeKind::ApprovalDeploy | NodeKind::SetWanted { .. } ) } } /// Submit-time spec for one node. #[derive(Debug, Clone)] pub struct NodeSpec { /// The agent this node's work targets. Built by the `templates.rs` `node` /// helper, which stamps the template's agent onto every node. pub agent: String, pub kind: NodeKind, pub deps: Vec, /// The **structural parent** axis — the spec-local index of this node's /// group parent, or `None` for a top-level (group-root) node. Independent /// of `deps`: `deps` order execution, `parent` groups nodes into a subtree /// whose resource the whole subtree borrows (the agent lease is owned by a /// group root and re-entered by its descendants for continuity). A child /// runs once its parent reaches `Finishing` (the parent gate), so a child /// never `deps` on its own parent (that would deadlock — dep-scope /// validation rejects it). pub parent: Option, } /// Submit-time spec for a whole DAG. Built by `templates.rs`; validated /// (cycle rejection) by `JobQueue::submit`. No DAG-level `agent` — every /// node carries its own (a DAG can span agents), and the queue derives /// per-agent leasing from [`NodeSpec::agent`]. #[derive(Debug, Clone)] pub struct DagSpec { pub template: Template, pub source: Source, /// Free-form "why". pub reason: String, /// Fires the approval-resolution hook on DAG terminal. pub approval_id: Option, /// `MetaUpdate`-only: the inputs to bump (also part of the dedup /// key for that template). Display copy lives on the DAG. pub inputs: Vec, /// `PermChange`-only payload. pub perm_payload: Option, /// Dashboard transient pill (and crash-watch suppression) held for /// the lease window — from lease acquisition to DAG terminal. pub transient: Option, pub nodes: Vec, }