refactor(#2808): the wire state enum is the scheduler's own
`hive_host_sock::jobs::State` was a hand-maintained copy of `hive_jobq::State` — five variants spelled the same in both, kept in sync by whoever remembered. Adding `Skipped` last week meant adding it twice. The wire crate now re-exports the scheduler's enum and `to_wire_state` is gone. Two states that were hidden now reach clients. `to_wire_state` renamed `Pending` to `Queued` and folded `Finishing` into `Running`, so the dashboard could not distinguish a node waiting on its dependencies from one whose own work is done while its sub-nodes still run. Both are now visible, and consumers say which they mean. Every consumer had to move with it, and only the Rust ones said so: the exhaustive matches in `hivectl` and `DagView::rollup_state` failed to compile, while the dashboard's fourteen string comparisons would have gone quietly wrong — a `finishing` node no longer counting as running, a `pending` node no longer as queued. The frontend also builds CSS class names out of the state string (`rqe-` + state, `rqe-node-` + state) and keys its glyph map on it, all lowercase. Those go through a `stateSlug` helper now; comparisons use the wire spelling, presentation lowercases. Without that split every queue entry and node chip would have silently lost its styling. Dropping the `State as JobState` alias in hive-c0re falls out of this: the alias only existed to tell two `State` types apart, and there is one now.
This commit is contained in:
parent
6fd91ccf6a
commit
b9aab7e923
8 changed files with 72 additions and 93 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1716,6 +1716,7 @@ name = "hive-host-sock"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"hive-jobq",
|
||||||
"hive-sh4re",
|
"hive-sh4re",
|
||||||
"hive-types",
|
"hive-types",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
|
|
@ -25,20 +25,28 @@ let rebuildQueueState = [];
|
||||||
// DagView no longer carries top-level `kind`/`state`/`started_at`/`finished_at`
|
// DagView no longer carries top-level `kind`/`state`/`started_at`/`finished_at`
|
||||||
// — these are all derived from the NodeView array by the client.
|
// — 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;
|
// `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) {
|
function rollupState(nodes) {
|
||||||
const ns = nodes || [];
|
const ns = nodes || [];
|
||||||
if (!ns.length) return 'done';
|
if (!ns.length) return 'Done';
|
||||||
if (ns.some((n) => n.state === 'failed')) return 'failed';
|
if (ns.some((n) => n.state === 'Failed')) return 'Failed';
|
||||||
if (ns.some((n) => n.state === 'cancelled')) return 'cancelled';
|
if (ns.some((n) => n.state === 'Cancelled')) return 'Cancelled';
|
||||||
if (ns.some((n) => n.state === 'running')) return 'running';
|
// `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
|
// 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
|
// 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.
|
// mirrors `DagView::rollup_state` in hive-host-sock; edit the two together.
|
||||||
if (ns.every((n) => n.state === 'skipped' || n.state === 'done')) return 'done';
|
if (ns.every((n) => n.state === 'Skipped' || n.state === 'Done')) return 'Done';
|
||||||
return 'queued';
|
return 'Pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -168,7 +176,7 @@ const QUEUE_STATE_GLYPH = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function firstFailedNode(entry) {
|
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
|
// 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 finishedAt = isoToSecs(entry.finished_at);
|
||||||
const createdAt = isoToSecs(entry.created_at);
|
const createdAt = isoToSecs(entry.created_at);
|
||||||
|
|
||||||
|
const slug = stateSlug(state);
|
||||||
const li = el('li', {
|
const li = el('li', {
|
||||||
class: 'rebuild-queue-entry rqe-' + state,
|
class: 'rebuild-queue-entry rqe-' + slug,
|
||||||
'data-id': String(entry.id),
|
'data-id': String(entry.id),
|
||||||
});
|
});
|
||||||
li.append(
|
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),
|
el('span', { class: 'rqe-kind' }, entry.source),
|
||||||
);
|
);
|
||||||
li.append(' ', el('span', { class: 'rqe-source rqe-source-' + entry.source }, 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', {
|
li.append(' ', el('span', {
|
||||||
class: 'rqe-when',
|
class: 'rqe-when',
|
||||||
'data-rqe-enqueued': String(createdAt ?? ''),
|
'data-rqe-enqueued': String(createdAt ?? ''),
|
||||||
}, '· queued ' + (createdAt ? fmtAgo(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);
|
const elapsed = Math.max(0, Math.floor(Date.now() / 1000) - startedAt);
|
||||||
li.append(' ', el('span', {
|
li.append(' ', el('span', {
|
||||||
class: 'rqe-when',
|
class: 'rqe-when',
|
||||||
|
|
@ -324,8 +333,8 @@ function renderQueueEntry(entry) {
|
||||||
li.append(' ', el('span', {
|
li.append(' ', el('span', {
|
||||||
class: 'rqe-when',
|
class: 'rqe-when',
|
||||||
'data-rqe-finished': String(finishedAt),
|
'data-rqe-finished': String(finishedAt),
|
||||||
'data-rqe-state': state,
|
'data-rqe-state': slug,
|
||||||
}, '· ' + state + ' ' + fmtAgo(finishedAt)));
|
}, '· ' + slug + ' ' + fmtAgo(finishedAt)));
|
||||||
}
|
}
|
||||||
if (entry.reason) {
|
if (entry.reason) {
|
||||||
const r = entry.reason.split('\n')[0];
|
const r = entry.reason.split('\n')[0];
|
||||||
|
|
@ -359,7 +368,7 @@ function renderQueueEntry(entry) {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
const chip = el('span', {
|
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
|
title: (n.agent ? n.agent + ' · ' : '') + n.kind + ' · ' + n.state
|
||||||
+ (n.error ? ' — ' + n.error : ''),
|
+ (n.error ? ' — ' + n.error : ''),
|
||||||
}, (QUEUE_STATE_GLYPH[n.state] || '?') + ' ' + n.kind);
|
}, (QUEUE_STATE_GLYPH[n.state] || '?') + ' ' + n.kind);
|
||||||
|
|
@ -390,7 +399,7 @@ function renderQueueEntry(entry) {
|
||||||
if (failed && failed.error) {
|
if (failed && failed.error) {
|
||||||
li.append(el('pre', { class: 'rqe-error', title: failed.error }, truncate(failed.error, 200)));
|
li.append(el('pre', { class: 'rqe-error', title: failed.error }, truncate(failed.error, 200)));
|
||||||
}
|
}
|
||||||
if (state === 'queued') {
|
if (state === 'Pending') {
|
||||||
const cancelForm = el('form', {
|
const cancelForm = el('form', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
action: '/api/rebuild-queue/' + entry.id + '/cancel',
|
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
|
// rebuild_queue_changed tick; a terminal node's log is static (one last fetch
|
||||||
// on transition, then done).
|
// on transition, then done).
|
||||||
let liveLogId = null; // current node id being shown
|
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 liveLogCollapsed = false;
|
||||||
let liveLogPollTimer = null;
|
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.
|
// Gate on has_log so lock/noop/store-only nodes don't open a blank panel.
|
||||||
function findLiveBuild(queue) {
|
function findLiveBuild(queue) {
|
||||||
for (const e of queue || []) {
|
for (const e of queue || []) {
|
||||||
if (rollupState(e.nodes || []) !== 'running') continue;
|
if (rollupState(e.nodes || []) !== 'Running') continue;
|
||||||
const node = (e.nodes || []).find((n) => n.state === 'running' && n.has_log);
|
const node = (e.nodes || []).find((n) => n.state === 'Running' && n.has_log);
|
||||||
if (node) return { entry: e, node };
|
if (node) return { entry: e, node };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -471,14 +480,14 @@ function renderRebuildLiveLog(queue) {
|
||||||
// Same node, already polling — just let the timer tick (or do a final
|
// 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).
|
// fetch if the node just went non-running and we haven't marked done yet).
|
||||||
if (liveNode.id === liveLogId) {
|
if (liveNode.id === liveLogId) {
|
||||||
if (!liveLogDone && liveNode.state !== 'running') {
|
if (!liveLogDone && liveNode.state !== 'Running') {
|
||||||
clearLiveLogPoll();
|
clearLiveLogPoll();
|
||||||
liveLogDone = true;
|
liveLogDone = true;
|
||||||
const pre = root.querySelector('.rebuild-live-log-output');
|
const pre = root.querySelector('.rebuild-live-log-output');
|
||||||
const badge = root.querySelector('.rebuild-live-log-badge');
|
const badge = root.querySelector('.rebuild-live-log-badge');
|
||||||
if (pre) fetchAndRenderLiveLog(liveNode.id, pre);
|
if (pre) fetchAndRenderLiveLog(liveNode.id, pre);
|
||||||
if (badge) {
|
if (badge) {
|
||||||
const ok = liveNode.state !== 'failed';
|
const ok = liveNode.state !== 'Failed';
|
||||||
badge.className = 'rebuild-live-log-badge ' + (ok ? 'rll-ok' : 'rll-fail');
|
badge.className = 'rebuild-live-log-badge ' + (ok ? 'rll-ok' : 'rll-fail');
|
||||||
badge.textContent = liveNode.state;
|
badge.textContent = liveNode.state;
|
||||||
}
|
}
|
||||||
|
|
@ -536,7 +545,7 @@ function updateRebuildCount() {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const e of rebuildQueueState) {
|
for (const e of rebuildQueueState) {
|
||||||
const s = rollupState(e.nodes || []);
|
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; }
|
if (n > 0) { pill.textContent = String(n); pill.hidden = false; }
|
||||||
else { pill.hidden = true; }
|
else { pill.hidden = true; }
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ export function applyRebuildQueueChanged(ev) {
|
||||||
function inFlightOpsByAgent() {
|
function inFlightOpsByAgent() {
|
||||||
const out = new Map();
|
const out = new Map();
|
||||||
for (const e of rebuildQueueState) {
|
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
|
// spawn ops target an agent that doesn't exist yet as a
|
||||||
// container — the transient store already drives the
|
// container — the transient store already drives the
|
||||||
// pending row for that case. Skip here to avoid double-
|
// pending row for that case. Skip here to avoid double-
|
||||||
|
|
@ -85,15 +85,15 @@ function inFlightOpsByAgent() {
|
||||||
const perAgentState = new Map();
|
const perAgentState = new Map();
|
||||||
for (const n of e.nodes || []) {
|
for (const n of e.nodes || []) {
|
||||||
if (!n.agent) continue;
|
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);
|
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);
|
perAgentState.set(n.agent, n.state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [agent, state] of perAgentState) {
|
for (const [agent, state] of perAgentState) {
|
||||||
const cur = out.get(agent);
|
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 });
|
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 +
|
// 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.
|
// in-flight-queue badges), so the top of the tab only needs the summary.
|
||||||
const activeQueue = rebuildQueueState.filter(
|
const activeQueue = rebuildQueueState.filter(
|
||||||
(e) => e.state === 'queued' || e.state === 'running',
|
(e) => e.state === 'Pending' || e.state === 'Running',
|
||||||
);
|
);
|
||||||
if (activeQueue.length) {
|
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 queued = activeQueue.length - running;
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (running) parts.push(`${running} running`);
|
if (running) parts.push(`${running} running`);
|
||||||
|
|
@ -840,7 +840,7 @@ export function renderContainers(s) {
|
||||||
const transientKind = transientsState.get(c.name)?.kind || null;
|
const transientKind = transientsState.get(c.name)?.kind || null;
|
||||||
const op = !transientKind ? inFlight.get(c.name) : null;
|
const op = !transientKind ? inFlight.get(c.name) : null;
|
||||||
const pending = transientKind
|
const pending = transientKind
|
||||||
|| (op && (op.state === 'running'
|
|| (op && (op.state === 'Running'
|
||||||
? (op.kind === 'meta_update' ? 'meta-updating'
|
? (op.kind === 'meta_update' ? 'meta-updating'
|
||||||
: op.kind === 'destroy' ? 'destroying'
|
: op.kind === 'destroy' ? 'destroying'
|
||||||
: op.kind === 'restart' ? 'restarting'
|
: op.kind === 'restart' ? 'restarting'
|
||||||
|
|
@ -858,7 +858,7 @@ export function renderContainers(s) {
|
||||||
: op.kind === 'reconcile' ? 'reconcile queued'
|
: op.kind === 'reconcile' ? 'reconcile queued'
|
||||||
: 'rebuild queued')));
|
: 'rebuild queued')));
|
||||||
const opRunning = transientKind != null
|
const opRunning = transientKind != null
|
||||||
|| (op != null && op.state === 'running');
|
|| (op != null && op.state === 'Running');
|
||||||
const selected = selectionState.has(c.name);
|
const selected = selectionState.has(c.name);
|
||||||
// Pending questions where this agent is the asker (awaiting an
|
// Pending questions where this agent is the asker (awaiting an
|
||||||
// answer) or the target (owes a reply). Derived live from
|
// answer) or the target (owes a reply). Derived live from
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ use chrono::{DateTime, Utc};
|
||||||
use hive_host_sock::jobs::NodeView;
|
use hive_host_sock::jobs::NodeView;
|
||||||
use hive_jobq::resources::ResourceTable;
|
use hive_jobq::resources::ResourceTable;
|
||||||
use hive_jobq::scheduler::{Outcome, Scheduler};
|
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 hive_sh4re::wire_time::now_unix;
|
||||||
use tokio::sync::Notify;
|
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
|
/// 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
|
/// 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
|
/// `group_parent`, which is `None` for `submit` and the emitting node for
|
||||||
|
|
@ -476,7 +463,7 @@ impl QueueInner {
|
||||||
self.sched
|
self.sched
|
||||||
.graph()
|
.graph()
|
||||||
.node(id)
|
.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
|
/// 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
|
// `Done` nodes drop off the wire — a finished step isn't
|
||||||
// interesting. `Skipped` ones stay: which branch a run *didn't*
|
// interesting. `Skipped` ones stay: which branch a run *didn't*
|
||||||
// take is the readable half of an outcome-branched DAG.
|
// take is the readable half of an outcome-branched DAG.
|
||||||
if matches!(node.state, JobState::Done) {
|
if matches!(node.state, State::Done) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
any_unsettled |= !matches!(node.state, JobState::Skipped);
|
any_unsettled |= !matches!(node.state, State::Skipped);
|
||||||
let deps: Vec<u64> = node
|
let deps: Vec<u64> = node
|
||||||
.deps
|
.deps
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -587,7 +574,7 @@ impl QueueInner {
|
||||||
agent: node.payload.agent().to_owned(),
|
agent: node.payload.agent().to_owned(),
|
||||||
kind: node.payload.as_str().to_owned(),
|
kind: node.payload.as_str().to_owned(),
|
||||||
deps,
|
deps,
|
||||||
state: to_wire_state(node.state),
|
state: node.state,
|
||||||
started_at: node.started_at,
|
started_at: node.started_at,
|
||||||
finished_at: node.finished_at,
|
finished_at: node.finished_at,
|
||||||
error: node.error.clone(),
|
error: node.error.clone(),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
|
hive-jobq.workspace = true
|
||||||
hive-sh4re.workspace = true
|
hive-sh4re.workspace = true
|
||||||
hive-types.workspace = true
|
hive-types.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -40,36 +40,7 @@ impl Source {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lifecycle state of a node — and, rolled up, of a DAG.
|
pub use hive_jobq::State;
|
||||||
#[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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Kind-specific payload for `Template::PermChange` DAGs.
|
/// Kind-specific payload for `Template::PermChange` DAGs.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[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
|
/// Rust consumer (hivectl, the wait loops, tests) uses so the dashboard's
|
||||||
/// JS render and the host agree: `Failed` if any node failed, else
|
/// JS render and the host agree: `Failed` if any node failed, else
|
||||||
/// **`Cancelled` if any cancelled**, else `Running` if any running, 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 —
|
/// from the wire, so a DAG that is *entirely* done isn't sent at all —
|
||||||
/// its absence from the snapshot is what signals completion.
|
/// 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),
|
/// 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
|
/// 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`
|
/// the states barely co-occur: a cancel *cascade* originates at a `Failed`
|
||||||
/// node, which returns early above.
|
/// 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
|
/// `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
|
/// a healthy run, so counting it would make every successful DAG roll up
|
||||||
/// non-`Done`.
|
/// non-`Done`.
|
||||||
|
|
@ -208,13 +182,13 @@ impl DagView {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn rollup_state(&self) -> State {
|
pub fn rollup_state(&self) -> State {
|
||||||
let mut any_running = false;
|
let mut any_running = false;
|
||||||
let mut any_queued = false;
|
let mut any_pending = false;
|
||||||
let mut any_cancelled = false;
|
let mut any_cancelled = false;
|
||||||
for n in &self.nodes {
|
for n in &self.nodes {
|
||||||
match n.state {
|
match n.state {
|
||||||
State::Failed => return State::Failed,
|
State::Failed => return State::Failed,
|
||||||
State::Running => any_running = true,
|
State::Running | State::Finishing => any_running = true,
|
||||||
State::Queued => any_queued = true,
|
State::Pending => any_pending = true,
|
||||||
State::Cancelled => any_cancelled = true,
|
State::Cancelled => any_cancelled = true,
|
||||||
State::Done | State::Skipped => {}
|
State::Done | State::Skipped => {}
|
||||||
}
|
}
|
||||||
|
|
@ -223,8 +197,8 @@ impl DagView {
|
||||||
State::Cancelled
|
State::Cancelled
|
||||||
} else if any_running {
|
} else if any_running {
|
||||||
State::Running
|
State::Running
|
||||||
} else if any_queued {
|
} else if any_pending {
|
||||||
State::Queued
|
State::Pending
|
||||||
} else {
|
} else {
|
||||||
State::Done
|
State::Done
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,11 @@ pub enum Dep<R> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A node's lifecycle state.
|
/// 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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
pub enum State {
|
pub enum State {
|
||||||
/// Waiting on dependencies (node or resource).
|
/// Waiting on dependencies (node or resource).
|
||||||
|
|
@ -213,9 +218,9 @@ pub enum State {
|
||||||
Running,
|
Running,
|
||||||
/// Own logic finished successfully, but the node is *not yet terminal*: it
|
/// Own logic finished successfully, but the node is *not yet terminal*: it
|
||||||
/// waits here until all its sub-nodes ([`Node::parent`] children) are
|
/// waits here until all its sub-nodes ([`Node::parent`] children) are
|
||||||
/// terminal, then rolls up to [`State::Done`] (every child `Done`) or
|
/// terminal, then rolls up — `Failed` if any child failed, else `Cancelled`
|
||||||
/// [`State::Failed`] (any child `Failed`/`Cancelled`). A node with no
|
/// if any was dropped, else `Done`. A node with no children never rests
|
||||||
/// children never rests here — it goes straight to a terminal state.
|
/// here — it goes straight to a terminal state.
|
||||||
Finishing,
|
Finishing,
|
||||||
/// Completed successfully — own logic done *and* every sub-node `Done`.
|
/// Completed successfully — own logic done *and* every sub-node `Done`.
|
||||||
Done,
|
Done,
|
||||||
|
|
|
||||||
|
|
@ -286,8 +286,10 @@ fn node_line(
|
||||||
|
|
||||||
fn state_glyph(state: hive_host_sock::jobs::State) -> &'static str {
|
fn state_glyph(state: hive_host_sock::jobs::State) -> &'static str {
|
||||||
match state {
|
match state {
|
||||||
hive_host_sock::jobs::State::Queued => "⏸",
|
hive_host_sock::jobs::State::Pending => "⏸",
|
||||||
hive_host_sock::jobs::State::Running => "▶",
|
// `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::Done => "✔",
|
||||||
hive_host_sock::jobs::State::Failed => "✖",
|
hive_host_sock::jobs::State::Failed => "✖",
|
||||||
hive_host_sock::jobs::State::Cancelled => "⊘",
|
hive_host_sock::jobs::State::Cancelled => "⊘",
|
||||||
|
|
@ -362,7 +364,7 @@ mod tests {
|
||||||
node(0, "alice", "prebuild", State::Done),
|
node(0, "alice", "prebuild", State::Done),
|
||||||
node(1, "alice", "stop_for_update", State::Done),
|
node(1, "alice", "stop_for_update", State::Done),
|
||||||
node(2, "alice", "swap", State::Running),
|
node(2, "alice", "swap", State::Running),
|
||||||
node(3, "alice", "reconcile", State::Queued),
|
node(3, "alice", "reconcile", State::Pending),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
let line = render_dag_line(&dag);
|
let line = render_dag_line(&dag);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue