Now that Template is internal to hive-c0re (not a pub wire enum), the dead-code lint flags the two variants nothing ever constructs. Removed them + their as_str arms; terminal_hook's catch-all arm is unaffected.
341 lines
16 KiB
Rust
341 lines
16 KiB
Rust
//! 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};
|
|
use serde::Serialize;
|
|
|
|
use crate::coordinator::TransientKind;
|
|
|
|
/// What a DAG *means* — the request-level shape. Internal to the queue now:
|
|
/// it drives the terminal-hook dispatch ([`crate::job_queue`]'s `dag_hook`)
|
|
/// and the meta-update dedup key, and is **no longer sent on the wire** — the
|
|
/// dashboard derives a DAG's label from its node kinds (see `DagView`). The
|
|
/// `NodeKind::Dag` container carries it in its payload.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Template {
|
|
/// Rebuild one agent's container (prebuild → stop → profile-swap →
|
|
/// reconcile).
|
|
Rebuild,
|
|
/// Bump meta flake locks; grows a rebuild subgraph per affected
|
|
/// agent into the same DAG on completion.
|
|
MetaUpdate,
|
|
/// First-deploy spawn (approval-driven).
|
|
Spawn,
|
|
/// Mechanical stop + converge to `wanted = Up` (a restart).
|
|
Restart,
|
|
/// Signal → drain → mechanical stop → converge to `wanted = Up` — a
|
|
/// graceful restart as one atomic DAG.
|
|
GracefulRestart,
|
|
/// Perm-file commit followed by the rebuild subgraph.
|
|
PermChange,
|
|
/// Quiesce the harness, drain, then stop (`wanted = Offline`).
|
|
GracefulStop,
|
|
/// Converge to `wanted = Up`.
|
|
Start,
|
|
/// Converge to `wanted = Offline`.
|
|
Stop,
|
|
/// Boot-time config sweep as one DAG.
|
|
Boot,
|
|
}
|
|
|
|
impl Template {
|
|
#[must_use]
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Template::Rebuild => "rebuild",
|
|
Template::MetaUpdate => "meta_update",
|
|
Template::Spawn => "spawn",
|
|
Template::Restart => "restart",
|
|
Template::GracefulRestart => "graceful_restart",
|
|
Template::PermChange => "perm_change",
|
|
Template::GracefulStop => "graceful_stop",
|
|
Template::Start => "start",
|
|
Template::Stop => "stop",
|
|
Template::Boot => "boot",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 { agent: String, 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 { agent: String },
|
|
/// 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 { agent: String },
|
|
/// 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#<name>` ref resolves. Store/meta-only — no container yet —
|
|
/// so it's lease- and build-slot-exempt like `Prebuild`.
|
|
Provision { agent: String },
|
|
/// First-spawn `nixos-container create` proper. Assumes the
|
|
/// upstream `Provision` node already registered the agent in meta.
|
|
Create { agent: String },
|
|
/// 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<Vec<String>>,
|
|
},
|
|
/// 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 { agent: String },
|
|
/// 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 { agent: String },
|
|
/// 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 { 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 },
|
|
/// `set_nspawn_flags` + `set_resource_limits` + daemon-reload.
|
|
WriteDropin { agent: String },
|
|
/// Commit `tool-groups.json` / `capabilities.json` per its `payload`
|
|
/// (commit fused under `META_LOCK`). The payload rides this node — the only
|
|
/// consumer — rather than the generic DAG container.
|
|
WritePermFile { agent: String, payload: PermPayload },
|
|
/// 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 { agent: String },
|
|
/// 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 { agent: String, up: bool },
|
|
/// 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<TransientKind>,
|
|
approval_id: Option<i64>,
|
|
inputs: Vec<String>,
|
|
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::Dag { .. } => "dag",
|
|
}
|
|
}
|
|
|
|
/// The agent this node targets, or `""` for agentless kinds
|
|
/// ([`NodeKind::MetaLock`] on the `hyperhive` pseudo-agent, and the
|
|
/// [`NodeKind::Dag`] container).
|
|
#[must_use]
|
|
pub fn agent(&self) -> &str {
|
|
match self {
|
|
NodeKind::Prebuild { agent, .. }
|
|
| NodeKind::Swap { agent }
|
|
| NodeKind::PostSwap { agent }
|
|
| NodeKind::Provision { agent }
|
|
| NodeKind::Create { agent }
|
|
| NodeKind::Reconcile { agent }
|
|
| NodeKind::Start { agent }
|
|
| NodeKind::Stop { agent }
|
|
| NodeKind::StopForUpdate { agent }
|
|
| NodeKind::Signal { agent }
|
|
| NodeKind::Drain { agent }
|
|
| NodeKind::WriteDropin { agent }
|
|
| NodeKind::WritePermFile { agent, .. }
|
|
| NodeKind::ApprovalDeploy { agent }
|
|
| NodeKind::SetWanted { agent, .. } => agent,
|
|
NodeKind::MetaLock { .. } | NodeKind::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 node's payload — [`NodeKind`] is the queue's payload type directly,
|
|
/// and each variant carries the agent it targets (a DAG can span agents;
|
|
/// the queue derives per-agent leasing from [`NodeKind::agent`]).
|
|
pub kind: NodeKind,
|
|
pub deps: Vec<Dep>,
|
|
/// 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<u64>,
|
|
}
|
|
|
|
/// 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 [`NodeKind::agent`]. Type-specific payloads
|
|
/// (`PermChange`'s file payload) ride the node that consumes them
|
|
/// ([`NodeKind::WritePermFile`]), not this generic spec.
|
|
#[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<i64>,
|
|
/// `MetaUpdate`-only: the inputs to bump (also part of the dedup
|
|
/// key for that template). Display copy lives on the DAG.
|
|
pub inputs: Vec<String>,
|
|
/// Dashboard transient pill (and crash-watch suppression) held for
|
|
/// the lease window — from lease acquisition to DAG terminal.
|
|
pub transient: Option<crate::coordinator::TransientKind>,
|
|
pub nodes: Vec<NodeSpec>,
|
|
}
|