diff --git a/Cargo.lock b/Cargo.lock index 76f2669e..cbe1df47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1716,6 +1716,7 @@ name = "hive-host-sock" version = "0.1.0" dependencies = [ "chrono", + "hive-jobq", "hive-sh4re", "hive-types", "serde", diff --git a/frontend/packages/dashboard/src/builds.js b/frontend/packages/dashboard/src/builds.js index 848694c2..6b161ab7 100644 --- a/frontend/packages/dashboard/src/builds.js +++ b/frontend/packages/dashboard/src/builds.js @@ -25,20 +25,28 @@ let rebuildQueueState = []; // DagView no longer carries top-level `kind`/`state`/`started_at`/`finished_at` // — these are all derived from the NodeView array by the client. -// Rollup state from nodes (failed > cancelled > running > queued). +// Node/DAG states arrive in the wire spelling of `hive_jobq::State` — the +// scheduler's own enum, serialised verbatim, so the names are PascalCase and +// there is no separate display-shaped wire type. Compare against those names; +// lowercase only where a CSS class or human-facing label needs it. +const stateSlug = (s) => String(s || '').toLowerCase(); + +// Rollup state from nodes (Failed > Cancelled > Running > Pending). // `Done` nodes are excluded from the payload, so a fully-done DAG is absent; -// an empty nodes array should not arise in practice — return 'done' defensively. +// an empty nodes array should not arise in practice — return 'Done' defensively. function rollupState(nodes) { const ns = nodes || []; - if (!ns.length) return 'done'; - if (ns.some((n) => n.state === 'failed')) return 'failed'; - if (ns.some((n) => n.state === 'cancelled')) return 'cancelled'; - if (ns.some((n) => n.state === 'running')) return 'running'; + if (!ns.length) return 'Done'; + if (ns.some((n) => n.state === 'Failed')) return 'Failed'; + if (ns.some((n) => n.state === 'Cancelled')) return 'Cancelled'; + // `Finishing` is a node whose own work is done while its sub-nodes still + // run — in flight, so it counts as running. + if (ns.some((n) => n.state === 'Running' || n.state === 'Finishing')) return 'Running'; // A skipped node is a branch the run ruled out, which is expected on a // healthy DAG — it must not make the roll-up read as still-pending. This // mirrors `DagView::rollup_state` in hive-host-sock; edit the two together. - if (ns.every((n) => n.state === 'skipped' || n.state === 'done')) return 'done'; - return 'queued'; + if (ns.every((n) => n.state === 'Skipped' || n.state === 'Done')) return 'Done'; + return 'Pending'; } @@ -168,7 +176,7 @@ const QUEUE_STATE_GLYPH = { }; function firstFailedNode(entry) { - return (entry.nodes || []).find((n) => n.state === 'failed') || null; + return (entry.nodes || []).find((n) => n.state === 'Failed') || null; } // Topo-sort a flat node list using `deps` edges. Nodes whose deps are all @@ -299,22 +307,23 @@ function renderQueueEntry(entry) { const finishedAt = isoToSecs(entry.finished_at); const createdAt = isoToSecs(entry.created_at); + const slug = stateSlug(state); const li = el('li', { - class: 'rebuild-queue-entry rqe-' + state, + class: 'rebuild-queue-entry rqe-' + slug, 'data-id': String(entry.id), }); li.append( - el('span', { class: 'rqe-state', title: state }, QUEUE_STATE_GLYPH[state] || '?'), + el('span', { class: 'rqe-state', title: slug }, QUEUE_STATE_GLYPH[slug] || '?'), ' ', el('span', { class: 'rqe-kind' }, entry.source), ); li.append(' ', el('span', { class: 'rqe-source rqe-source-' + entry.source }, entry.source)); - if (state === 'queued') { + if (state === 'Pending') { li.append(' ', el('span', { class: 'rqe-when', 'data-rqe-enqueued': String(createdAt ?? ''), }, '· queued ' + (createdAt ? fmtAgo(createdAt) : ''))); - } else if (state === 'running' && startedAt) { + } else if (state === 'Running' && startedAt) { const elapsed = Math.max(0, Math.floor(Date.now() / 1000) - startedAt); li.append(' ', el('span', { class: 'rqe-when', @@ -324,8 +333,8 @@ function renderQueueEntry(entry) { li.append(' ', el('span', { class: 'rqe-when', 'data-rqe-finished': String(finishedAt), - 'data-rqe-state': state, - }, '· ' + state + ' ' + fmtAgo(finishedAt))); + 'data-rqe-state': slug, + }, '· ' + slug + ' ' + fmtAgo(finishedAt))); } if (entry.reason) { const r = entry.reason.split('\n')[0]; @@ -359,7 +368,7 @@ function renderQueueEntry(entry) { })); } const chip = el('span', { - class: 'rqe-node rqe-node-' + n.state, + class: 'rqe-node rqe-node-' + stateSlug(n.state), title: (n.agent ? n.agent + ' · ' : '') + n.kind + ' · ' + n.state + (n.error ? ' — ' + n.error : ''), }, (QUEUE_STATE_GLYPH[n.state] || '?') + ' ' + n.kind); @@ -390,7 +399,7 @@ function renderQueueEntry(entry) { if (failed && failed.error) { li.append(el('pre', { class: 'rqe-error', title: failed.error }, truncate(failed.error, 200))); } - if (state === 'queued') { + if (state === 'Pending') { const cancelForm = el('form', { method: 'POST', action: '/api/rebuild-queue/' + entry.id + '/cancel', @@ -421,7 +430,7 @@ function renderQueueEntry(entry) { // rebuild_queue_changed tick; a terminal node's log is static (one last fetch // on transition, then done). let liveLogId = null; // current node id being shown -let liveLogDone = false; // true once the node left 'running' +let liveLogDone = false; // true once the node left 'Running' let liveLogCollapsed = false; let liveLogPollTimer = null; @@ -433,8 +442,8 @@ function clearLiveLogPoll() { // Gate on has_log so lock/noop/store-only nodes don't open a blank panel. function findLiveBuild(queue) { for (const e of queue || []) { - if (rollupState(e.nodes || []) !== 'running') continue; - const node = (e.nodes || []).find((n) => n.state === 'running' && n.has_log); + if (rollupState(e.nodes || []) !== 'Running') continue; + const node = (e.nodes || []).find((n) => n.state === 'Running' && n.has_log); if (node) return { entry: e, node }; } return null; @@ -471,14 +480,14 @@ function renderRebuildLiveLog(queue) { // Same node, already polling — just let the timer tick (or do a final // fetch if the node just went non-running and we haven't marked done yet). if (liveNode.id === liveLogId) { - if (!liveLogDone && liveNode.state !== 'running') { + if (!liveLogDone && liveNode.state !== 'Running') { clearLiveLogPoll(); liveLogDone = true; const pre = root.querySelector('.rebuild-live-log-output'); const badge = root.querySelector('.rebuild-live-log-badge'); if (pre) fetchAndRenderLiveLog(liveNode.id, pre); if (badge) { - const ok = liveNode.state !== 'failed'; + const ok = liveNode.state !== 'Failed'; badge.className = 'rebuild-live-log-badge ' + (ok ? 'rll-ok' : 'rll-fail'); badge.textContent = liveNode.state; } @@ -536,7 +545,7 @@ function updateRebuildCount() { let n = 0; for (const e of rebuildQueueState) { const s = rollupState(e.nodes || []); - if (s === 'queued' || s === 'running') n++; + if (s === 'Pending' || s === 'Running') n++; } if (n > 0) { pill.textContent = String(n); pill.hidden = false; } else { pill.hidden = true; } diff --git a/frontend/packages/dashboard/src/swarm.js b/frontend/packages/dashboard/src/swarm.js index fa498500..eb906ec5 100644 --- a/frontend/packages/dashboard/src/swarm.js +++ b/frontend/packages/dashboard/src/swarm.js @@ -75,7 +75,7 @@ export function applyRebuildQueueChanged(ev) { function inFlightOpsByAgent() { const out = new Map(); for (const e of rebuildQueueState) { - if (e.state !== 'queued' && e.state !== 'running') continue; + if (e.state !== 'Pending' && e.state !== 'Running') continue; // spawn ops target an agent that doesn't exist yet as a // container — the transient store already drives the // pending row for that case. Skip here to avoid double- @@ -85,15 +85,15 @@ function inFlightOpsByAgent() { const perAgentState = new Map(); for (const n of e.nodes || []) { if (!n.agent) continue; - if (n.state !== 'queued' && n.state !== 'running') continue; + if (n.state !== 'Pending' && n.state !== 'Running') continue; const cur = perAgentState.get(n.agent); - if (!cur || (cur === 'queued' && n.state === 'running')) { + if (!cur || (cur === 'Pending' && n.state === 'Running')) { perAgentState.set(n.agent, n.state); } } for (const [agent, state] of perAgentState) { const cur = out.get(agent); - if (!cur || (cur.state === 'queued' && state === 'running')) { + if (!cur || (cur.state === 'Pending' && state === 'Running')) { out.set(agent, { kind: e.kind, state }); } } @@ -774,10 +774,10 @@ export function renderContainers(s) { // running step is already visible per-agent on each card (transient + // in-flight-queue badges), so the top of the tab only needs the summary. const activeQueue = rebuildQueueState.filter( - (e) => e.state === 'queued' || e.state === 'running', + (e) => e.state === 'Pending' || e.state === 'Running', ); if (activeQueue.length) { - const running = activeQueue.filter((e) => e.state === 'running').length; + const running = activeQueue.filter((e) => e.state === 'Running').length; const queued = activeQueue.length - running; const parts = []; if (running) parts.push(`${running} running`); @@ -840,7 +840,7 @@ export function renderContainers(s) { const transientKind = transientsState.get(c.name)?.kind || null; const op = !transientKind ? inFlight.get(c.name) : null; const pending = transientKind - || (op && (op.state === 'running' + || (op && (op.state === 'Running' ? (op.kind === 'meta_update' ? 'meta-updating' : op.kind === 'destroy' ? 'destroying' : op.kind === 'restart' ? 'restarting' @@ -858,7 +858,7 @@ export function renderContainers(s) { : op.kind === 'reconcile' ? 'reconcile queued' : 'rebuild queued'))); const opRunning = transientKind != null - || (op != null && op.state === 'running'); + || (op != null && op.state === 'Running'); const selected = selectionState.has(c.name); // Pending questions where this agent is the asker (awaiting an // answer) or the target (owes a reply). Derived live from diff --git a/hive-c0re/src/job_queue/mod.rs b/hive-c0re/src/job_queue/mod.rs index e392237a..5e7e2616 100644 --- a/hive-c0re/src/job_queue/mod.rs +++ b/hive-c0re/src/job_queue/mod.rs @@ -43,7 +43,7 @@ use chrono::{DateTime, Utc}; use hive_host_sock::jobs::NodeView; use hive_jobq::resources::ResourceTable; use hive_jobq::scheduler::{Outcome, Scheduler}; -use hive_jobq::{Dep, Graph, NodeId, State as JobState}; +use hive_jobq::{Dep, Graph, NodeId}; use hive_sh4re::wire_time::now_unix; use tokio::sync::Notify; @@ -133,19 +133,6 @@ impl Default for JobQueue { } } -/// Map a crate node state onto the wire state (`Pending` ↔ `Queued`; -/// `Finishing` — own logic done, sub-nodes still running — reads as `Running`). -fn to_wire_state(state: JobState) -> State { - match state { - JobState::Pending => State::Queued, - JobState::Running | JobState::Finishing => State::Running, - JobState::Done => State::Done, - JobState::Failed => State::Failed, - JobState::Cancelled => State::Cancelled, - JobState::Skipped => State::Skipped, - } -} - /// Insert `nodes` into the shared graph, honouring the spec's explicit **parent /// axis**: a node with `parent = None` is a top-level group root (re-parented to /// `group_parent`, which is `None` for `submit` and the emitting node for @@ -476,7 +463,7 @@ impl QueueInner { self.sched .graph() .node(id) - .is_some_and(|n| n.state == JobState::Running) + .is_some_and(|n| n.state == State::Running) } /// The container node of `dag_id` — the `NodeKind::Dag` root whose id equals @@ -548,10 +535,10 @@ impl QueueInner { // `Done` nodes drop off the wire — a finished step isn't // interesting. `Skipped` ones stay: which branch a run *didn't* // take is the readable half of an outcome-branched DAG. - if matches!(node.state, JobState::Done) { + if matches!(node.state, State::Done) { continue; } - any_unsettled |= !matches!(node.state, JobState::Skipped); + any_unsettled |= !matches!(node.state, State::Skipped); let deps: Vec = node .deps .iter() @@ -587,7 +574,7 @@ impl QueueInner { agent: node.payload.agent().to_owned(), kind: node.payload.as_str().to_owned(), deps, - state: to_wire_state(node.state), + state: node.state, started_at: node.started_at, finished_at: node.finished_at, error: node.error.clone(), diff --git a/hive-host-sock/Cargo.toml b/hive-host-sock/Cargo.toml index 902b2ab7..ffd069fd 100644 --- a/hive-host-sock/Cargo.toml +++ b/hive-host-sock/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] chrono.workspace = true +hive-jobq.workspace = true hive-sh4re.workspace = true hive-types.workspace = true serde.workspace = true diff --git a/hive-host-sock/src/jobs.rs b/hive-host-sock/src/jobs.rs index 876eda1a..d8d568e4 100644 --- a/hive-host-sock/src/jobs.rs +++ b/hive-host-sock/src/jobs.rs @@ -40,36 +40,7 @@ impl Source { } } -/// 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, - /// The node's own dependency edges ruled it out: an outcome branch that - /// wasn't taken. Distinct from `Cancelled`, which is work actively - /// dropped. A skipped node is an expected part of a healthy run — every - /// approval DAG has two not-taken tails and every rebuild has one — so - /// consumers must not read it as a failure or cancellation signal. - Skipped, -} - -impl State { - /// Whether the node will never change state again. `Skipped` counts: - /// a branch that was ruled out is as final as one that ran, and the - /// wait loops (`hivectl`'s progress display, the daemon's - /// dag-settled check) hang forever if it doesn't. - #[must_use] - pub fn is_terminal(self) -> bool { - matches!( - self, - State::Done | State::Failed | State::Cancelled | State::Skipped - ) - } -} +pub use hive_jobq::State; /// Kind-specific payload for `Template::PermChange` DAGs. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -187,17 +158,20 @@ impl DagView { /// Rust consumer (hivectl, the wait loops, tests) uses so the dashboard's /// JS render and the host agree: `Failed` if any node failed, else /// **`Cancelled` if any cancelled**, else `Running` if any running, else - /// `Queued` if any queued, else `Done`. `Done` nodes are excluded + /// `Pending` if any pending, 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. /// - /// `Cancelled` outranks both `Running` and `Queued` because a cancelled DAG + /// `Cancelled` outranks both `Running` and `Pending` because a cancelled DAG /// still has its weak-edged tail node to run (it reports the cancellation), - /// so `Queued`-then-`Running` would flicker back at the operator who just + /// so `Pending`-then-`Running` would flicker back at the operator who just /// cancelled it and read as "the cancel didn't take". Outside that window /// the states barely co-occur: a cancel *cascade* originates at a `Failed` /// node, which returns early above. /// + /// `Finishing` counts as running: the node's own work is done but its + /// sub-nodes are still going, so the DAG is still in flight. + /// /// `Skipped` contributes nothing: a not-taken branch is an expected part of /// a healthy run, so counting it would make every successful DAG roll up /// non-`Done`. @@ -208,13 +182,13 @@ impl DagView { #[must_use] pub fn rollup_state(&self) -> State { let mut any_running = false; - let mut any_queued = false; + let mut any_pending = 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::Running | State::Finishing => any_running = true, + State::Pending => any_pending = true, State::Cancelled => any_cancelled = true, State::Done | State::Skipped => {} } @@ -223,8 +197,8 @@ impl DagView { State::Cancelled } else if any_running { State::Running - } else if any_queued { - State::Queued + } else if any_pending { + State::Pending } else { State::Done } diff --git a/hive-jobq/src/lib.rs b/hive-jobq/src/lib.rs index 3c374514..4646428f 100644 --- a/hive-jobq/src/lib.rs +++ b/hive-jobq/src/lib.rs @@ -205,6 +205,11 @@ pub enum Dep { } /// A node's lifecycle state. +/// +/// This type *is* the wire representation — `hive-host-sock` hands it to +/// clients verbatim rather than mapping it through a parallel enum — so the +/// serialised names (`"Pending"`, `"Running"`, …) are what every consumer, +/// including the dashboard's JS, matches on. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum State { /// Waiting on dependencies (node or resource). @@ -213,9 +218,9 @@ pub enum State { Running, /// Own logic finished successfully, but the node is *not yet terminal*: it /// waits here until all its sub-nodes ([`Node::parent`] children) are - /// terminal, then rolls up to [`State::Done`] (every child `Done`) or - /// [`State::Failed`] (any child `Failed`/`Cancelled`). A node with no - /// children never rests here — it goes straight to a terminal state. + /// terminal, then rolls up — `Failed` if any child failed, else `Cancelled` + /// if any was dropped, else `Done`. A node with no children never rests + /// here — it goes straight to a terminal state. Finishing, /// Completed successfully — own logic done *and* every sub-node `Done`. Done, diff --git a/hivectl/src/dag_progress.rs b/hivectl/src/dag_progress.rs index 6f8e7d2d..cfe658b1 100644 --- a/hivectl/src/dag_progress.rs +++ b/hivectl/src/dag_progress.rs @@ -286,8 +286,10 @@ fn node_line( fn state_glyph(state: hive_host_sock::jobs::State) -> &'static str { match state { - hive_host_sock::jobs::State::Queued => "⏸", - hive_host_sock::jobs::State::Running => "▶", + hive_host_sock::jobs::State::Pending => "⏸", + // `Finishing` is own-work-done with sub-nodes still going — in flight, + // so it reads the same as running. + hive_host_sock::jobs::State::Running | hive_host_sock::jobs::State::Finishing => "▶", hive_host_sock::jobs::State::Done => "✔", hive_host_sock::jobs::State::Failed => "✖", hive_host_sock::jobs::State::Cancelled => "⊘", @@ -362,7 +364,7 @@ mod tests { node(0, "alice", "prebuild", State::Done), node(1, "alice", "stop_for_update", State::Done), node(2, "alice", "swap", State::Running), - node(3, "alice", "reconcile", State::Queued), + node(3, "alice", "reconcile", State::Pending), ], }; let line = render_dag_line(&dag);