hive-host-sock: a jobq graph wire type that doesn't know what a node is

`jobs::NodeView` can only ever display hive-c0re's queue. Five of its
fields are domain knowledge: `approval_id` is only ever on a
`DeployWindow`, `inputs` only on a `MetaLock`, `build_log_id` only on the
nix-heavy kinds, `agent` is derived from the payload, and `kind` is a
payload tag consumers branch on. A component built against that shape
cannot render a second jobq.

`graph::GraphNode` is `hive_jobq::Node` with both generics erased: the
crate's own field set, with everything domain-specific in one opaque
`payload.data` slot the consumer renders without branching on. That is
the crate boundary made visible — hive-jobq owns structure, its host owns
meaning — and it is the same split #2957 drew inside the code.

Two details that are easy to get wrong and are pinned by tests:

`GraphDep::Node` carries `accepts` as the **set** of terminal outcomes,
not a strong/weak flag. A template emits its tails as a pair edged on the
same upstream node, and the only thing telling them apart is which
outcomes each accepts; collapsing that renders two structurally different
nodes identically.

There is **no roll-up field**. A group root ships as an ordinary node
with `parent: None`, and its own `state` is its subtree's answer —
`Finishing` means "own logic done, children still running", the terminal
states are the rolled-up outcome. A separate field would be a lossier
copy: `DagView::rollup_state` flattens `Running` and `Finishing` into
one, which is exactly the distinction a viewer wants.

`State` and `TerminalState` are re-exported from `hive-jobq` rather than
redeclared, so they cannot drift from the scheduler that produces them.
This commit is contained in:
atlas 2026-08-02 22:45:29 +02:00 committed by mara
commit 792d7cb304
4 changed files with 195 additions and 0 deletions

View file

@ -13,3 +13,4 @@ hive-jobq.workspace = true
hive-sh4re.workspace = true
hive-types.workspace = true
serde.workspace = true
serde_json.workspace = true

192
hive-host-sock/src/graph.rs Normal file
View file

@ -0,0 +1,192 @@
//! Generic jobq graph wire types — a `hive_jobq` graph serialised without
//! knowing what its nodes mean.
//!
//! [`super::jobs`]'s `DagView` / `NodeView` are hive-c0re's *domain*
//! projection: they carry `approval_id`, `inputs`, `build_log_id` and an
//! `agent`, each meaningful for a subset of one specific node kind. Anything
//! built on those can only ever display hive-c0re's queue.
//!
//! These types carry what `hive_jobq::Node` itself carries — identity, the
//! parent tree, dependency edges, lifecycle — and push everything
//! domain-specific into one opaque [`NodePayload::data`] slot the consumer
//! renders without branching on. That is the crate boundary made visible:
//! `hive-jobq` owns structure, its host owns meaning.
//!
//! There is deliberately **no roll-up field**. A group root's own [`State`]
//! *is* its subtree's answer: `Finishing` means "own logic done, children
//! still running", and the terminal states are the rolled-up outcome. A
//! separate field would be a lossier copy of a value already on the wire —
//! lossier because it would have to flatten `Running` and `Finishing` together.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub use hive_jobq::{State, TerminalState};
/// Node id, carried verbatim from `hive_jobq::NodeId`: globally unique across
/// the whole graph, not per group. Opaque to consumers — they group by
/// [`GraphNode::parent`] and match dependency edges, nothing more.
pub type NodeId = u64;
/// One node, serialised near-raw from `hive_jobq::Node`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphNode {
pub id: NodeId,
/// Structural parent, or `None` for a group root.
///
/// A group root is an **ordinary node here** — nothing is hidden, so a
/// consumer needs no special case for "the container", and the root's
/// own `state` answers "how is this whole group doing".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<NodeId>,
pub state: State,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deps: Vec<GraphDep>,
/// When the node entered `Running`. `None` until it starts; a node that
/// never ran keeps `None`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started_at: Option<DateTime<Utc>>,
/// When the node reached a terminal state. `None` while non-terminal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finished_at: Option<DateTime<Utc>>,
/// Failure reason, set only when this node's *own* logic failed — a node
/// that rolled up `Failed` from a child carries none of its own.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub payload: NodePayload,
}
/// What a node *is*, in terms the graph layer does not interpret.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodePayload {
/// Short tag the consumer displays verbatim. **Not** for matching on: a
/// generic viewer that branches on this has stopped being generic.
pub label: String,
/// Domain data, rendered generically (as key/value, a details pane, a
/// tooltip — the consumer's choice). Everything a specific host wants to
/// say about a node beyond its label lives here, so adding a field costs
/// the wire type nothing.
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub data: serde_json::Value,
}
/// What must hold before a node runs.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum GraphDep {
/// Depend on another node finishing acceptably.
Node {
/// The node depended on. May name a node the consumer has not been
/// sent (a filtered view); treat an absent target as satisfied.
id: NodeId,
/// **The outcomes that satisfy this edge, as a set** — not a
/// strong/weak flag.
///
/// A template routinely emits several tails edged on the *same*
/// upstream node, distinguished only by which outcomes each accepts
/// (one for `Done`, one for `Failed`/`Cancelled`, …). Collapsing this
/// to a boolean renders those as identical nodes.
accepts: Vec<TerminalState>,
},
/// Need `count` units of a named resource. Named, not typed: the resource
/// vocabulary belongs to the host, so it rides as a string.
Resource { name: String, count: u32 },
}
#[cfg(test)]
mod tests {
use super::{GraphDep, GraphNode, NodePayload, State, TerminalState};
fn node(id: u64, parent: Option<u64>, state: State, deps: Vec<GraphDep>) -> GraphNode {
GraphNode {
id,
parent,
state,
deps,
started_at: None,
finished_at: None,
error: None,
payload: NodePayload {
label: "reconcile".to_owned(),
data: serde_json::Value::Null,
},
}
}
#[test]
fn a_node_round_trips_through_json() {
let before = node(
7,
Some(3),
State::Running,
vec![
GraphDep::Node {
id: 3,
accepts: vec![TerminalState::Done],
},
GraphDep::Resource {
name: "build-slot".to_owned(),
count: 1,
},
],
);
let json = serde_json::to_string(&before).expect("serialises");
let after: GraphNode = serde_json::from_str(&json).expect("round trips");
assert_eq!(format!("{after:?}"), format!("{before:?}"));
}
#[test]
fn two_tails_on_one_upstream_are_distinguishable_by_their_accepted_set() {
// The reason `accepts` is a set and not a strong/weak bool: these two
// are the same shape and the same upstream, and *only* the outcome
// set tells them apart.
let ok_tail = node(
10,
None,
State::Pending,
vec![GraphDep::Node {
id: 4,
accepts: vec![TerminalState::Done],
}],
);
let fail_tail = node(
11,
None,
State::Pending,
vec![GraphDep::Node {
id: 4,
accepts: vec![TerminalState::Failed, TerminalState::Cancelled],
}],
);
let render = |n: &GraphNode| match n.deps.first() {
Some(GraphDep::Node { accepts, .. }) => format!("{accepts:?}"),
_ => "none".to_owned(),
};
assert_ne!(render(&ok_tail), render(&fail_tail));
}
#[test]
fn a_group_root_is_an_ordinary_node_and_carries_the_groups_answer() {
// No roll-up field: `Finishing` on the root says "own logic done,
// children still running", and a terminal root state is the roll-up.
let root = node(1, None, State::Finishing, Vec::new());
assert!(root.parent.is_none(), "a group root is just parentless");
assert_eq!(root.state, State::Finishing);
let done_root = node(2, None, State::Failed, Vec::new());
assert_eq!(
done_root.state,
State::Failed,
"the root's own state is the subtree's rolled-up outcome"
);
}
#[test]
fn an_empty_payload_slot_is_omitted_from_the_wire() {
// The opaque slot costs nothing when a host has nothing to say.
let json =
serde_json::to_string(&node(1, None, State::Pending, Vec::new())).expect("serialises");
assert!(!json.contains("\"data\""), "null data is skipped: {json}");
assert!(json.contains("\"label\""), "the label always rides: {json}");
}
}

View file

@ -16,6 +16,7 @@ use hive_sh4re::{AgentStatusRow, Approval};
use hive_types::Ident;
use serde::{Deserialize, Serialize};
pub mod graph;
pub mod jobs;
// ── Shared hive layout facts ──────────────────────────────────────────────