//! Wire shapes of hive-c0re's job-DAG queue: what a queued job looks //! like on the dashboard SSE channel (`rebuild_queue_changed`), the //! `/api/state.rebuild_queue` snapshot, and the host admin socket's //! `QueueDag` polling surface (`hivectl`'s wait/progress loop). The //! queue *internals* — node kinds, dependency edges, scheduling state — //! live in `hive-c0re::job_queue`; these are the serialized views it //! produces. Semantics: `docs/coordinator.md::Job queue`. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; /// Where the submit request originated — drives the "why" chip on the /// dashboard. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Source { /// Operator action (dashboard button, CLI, manager tool). Manual, /// Meta-update cascade rebuild (grown into the meta-update DAG). MetaUpdate, /// Boot-time submission (the boot sweep DAG + boot reconciles). AutoUpdate, /// Crash recovery path (future use). CrashRecover, /// Operator approved a pending `Approval` row; `approval_id` on /// the DAG points back at the source row. Approval, } impl Source { #[must_use] pub fn as_str(self) -> &'static str { match self { Source::Manual => "manual", Source::MetaUpdate => "meta_update", Source::AutoUpdate => "auto_update", Source::CrashRecover => "crash_recover", Source::Approval => "approval", } } } /// Lifecycle state of a node — and, rolled up, of a DAG. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum State { Queued, Running, Done, Failed, Cancelled, } impl State { #[must_use] pub fn is_terminal(self) -> bool { matches!(self, State::Done | State::Failed | State::Cancelled) } } /// Kind-specific payload for `Template::PermChange` DAGs. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum PermPayload { /// Set the tool groups for one agent (`tool-groups.json`). ToolGroups { groups: Vec }, /// Set the capabilities for one agent (`capabilities.json`). Capabilities { caps: Vec }, /// Set both perm-types in one entry — the batch /// `POST /api/permissions` path. `None` leaves that file untouched; /// present fields commit together and rebuild once. Combined { groups: Option>, caps: Option>, }, } /// Node id. Carries the scheduler crate's globally-monotonic node id /// (`hive_jobq::NodeId`) verbatim on the wire — unique across all DAGs, not /// just within one. Consumers treat it opaquely (grouping + dep matching), /// so the widening from the old dag-local `u32` is transparent. pub type NodeId = u64; /// One node of a queued DAG, serialized near-raw from the scheduler /// graph. Lifecycle (`state` / `started_at` / `finished_at` / `error`) /// comes straight off the `hive_jobq::Node`. The client derives DAG-level /// roll-ups (label, state, timestamps) from the node set — nothing is /// rolled up host-side. Build logs are fetched on demand by node id /// (`GET /api/build-log/`), not carried inline. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NodeView { pub id: NodeId, /// The agent whose container (or meta repo, for `hyperhive` meta-level /// nodes) this node operates on. Agent is per-node — a single DAG can /// span multiple agents (e.g. a hive-wide restart), so there is no /// DAG-level agent field; consumers group by this. pub agent: String, /// Node primitive tag: `"prebuild"`, `"stop_for_update"`, /// `"swap"`, `"create"`, `"meta_lock"`, `"reconcile"`, `"signal"`, /// `"drain"`, `"write_dropin"`, `"write_perm_file"`, /// `"approval_deploy"`. pub kind: String, /// Ids of the nodes this one waits for. May reference an already-`Done` /// node that's been filtered out of the wire — the client treats a dep /// on an absent node as satisfied. #[serde(default)] pub deps: Vec, pub state: State, #[serde(default, skip_serializing_if = "Option::is_none")] pub started_at: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub finished_at: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, /// Approval-queue row id — present only on the `approval_deploy` node. /// The client links a DAG to its pending approval through this (it is /// not derivable from the graph, so it rides the node that owns it). #[serde(default, skip_serializing_if = "Option::is_none")] pub approval_id: Option, /// Meta-flake inputs being bumped — present only on the `meta_lock` /// node. Display-only payload, not derivable from the graph. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub inputs: Vec, /// Whether this node has a captured build log fetchable at /// `GET /api/build-log/`. Only the nix-heavy nodes that stream build /// output set one; the client gates its log link on this so lock / noop / /// store-only nodes don't render a link that 404s. #[serde(default)] pub has_log: bool, } /// A queued / running / failed DAG — a thin projection of one container /// node plus its (non-`Done`) subtree from the scheduler graph. Only /// non-derivable facts live here: `id`, `source`, `reason`, `created_at`, /// and the node set. The client derives the card label, roll-up state, and /// DAG timestamps from `nodes` (per-node `kind` + lifecycle) — nothing is /// rolled up host-side. There is no DAG-level `agent`: a DAG can span /// agents, so agent is per-[`NodeView`]; consumers group by `NodeView::agent`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DagView { pub id: u64, pub source: Source, pub reason: String, /// When the DAG was enqueued. pub created_at: DateTime, /// When the DAG's first node started (min over *all* its nodes) — computed /// host-side, **not** derived on the client: `Done` nodes are excluded from /// `nodes` below, so the earliest-started node is usually absent from the /// wire and the client can't take the min itself. `None` until a node runs. #[serde(default, skip_serializing_if = "Option::is_none")] pub started_at: Option>, /// When the DAG finished (max `finished_at` over all its nodes), set only /// once the DAG has settled terminal. Host-computed for the same reason as /// `started_at`. `None` while the DAG is still live. #[serde(default, skip_serializing_if = "Option::is_none")] pub finished_at: Option>, /// Nodes of this DAG with `Done` ones excluded. A DAG whose nodes are /// all `Done` is omitted from the snapshot entirely; a `Failed` DAG /// lingers until aged out by the history cap. pub nodes: Vec, } impl DagView { /// Roll-up state derived from the node set — the shared derivation every /// Rust consumer (hivectl, the wait loops, tests) uses so the dashboard's /// JS render and the host agree: `Failed` if any node failed, else /// `Running` if any running, else `Queued` if any queued, else /// `Cancelled` if any cancelled, else `Done`. `Done` nodes are excluded /// from the wire, so a DAG that is *entirely* done isn't sent at all — /// its absence from the snapshot is what signals completion. #[must_use] pub fn rollup_state(&self) -> State { let mut any_running = false; let mut any_queued = false; let mut any_cancelled = false; for n in &self.nodes { match n.state { State::Failed => return State::Failed, State::Running => any_running = true, State::Queued => any_queued = true, State::Cancelled => any_cancelled = true, State::Done => {} } } if any_running { State::Running } else if any_queued { State::Queued } else if any_cancelled { State::Cancelled } else { State::Done } } }