jobq-wire: move the generic graph projection into its own crate

The wire types were in hive-host-sock, which is the host *socket* crate — so
anything living there is core-shaped by construction, and the projection had
quietly grown two core dependencies to match: it selected roots by matching
NodeKind::Dag, and rendered payloads through free functions in hive-c0re that
nothing obliged a second host to write.

hive-jobq is the wrong home too. That crate is the scheduler — logic — and
folding presentation in means every consumer of it carries a JSON vocabulary
it may never serve.

So: a new hive-jobq-wire. A host implements WireNode for its payload N and
WireResource for its resource name R; GraphWire::wire_snapshot is
blanket-implemented for Graph<N, R> when both hold, and for nothing else. A
payload that has never said how it displays has no way onto the wire.

wire_snapshot takes the roots to serve rather than reading Graph::roots
itself. Nothing is ever removed from a Graph, so retention is a policy only
the host can hold; hive-c0re passes visible_roots(), which is the existing
MAX_HISTORY_DAGS bound selected structurally (a root is a node with no
parent) instead of by node kind.
This commit is contained in:
atlas 2026-08-02 23:52:32 +02:00 committed by mara
commit 7966d5eb66
14 changed files with 564 additions and 297 deletions

View file

@ -1,192 +0,0 @@
//! 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,7 +16,6 @@ use hive_sh4re::{AgentStatusRow, Approval};
use hive_types::Ident;
use serde::{Deserialize, Serialize};
pub mod graph;
pub mod jobs;
// ── Shared hive layout facts ──────────────────────────────────────────────