diff --git a/Cargo.lock b/Cargo.lock index 644146c2..00118c83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,6 +1727,7 @@ dependencies = [ "hive-sh4re", "hive-types", "serde", + "serde_json", ] [[package]] diff --git a/hive-host-sock/Cargo.toml b/hive-host-sock/Cargo.toml index ffd069fd..425d890d 100644 --- a/hive-host-sock/Cargo.toml +++ b/hive-host-sock/Cargo.toml @@ -13,3 +13,4 @@ hive-jobq.workspace = true hive-sh4re.workspace = true hive-types.workspace = true serde.workspace = true +serde_json.workspace = true diff --git a/hive-host-sock/src/graph.rs b/hive-host-sock/src/graph.rs new file mode 100644 index 00000000..d98853ed --- /dev/null +++ b/hive-host-sock/src/graph.rs @@ -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, + pub state: State, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub deps: Vec, + /// 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>, + /// When the node reached a terminal state. `None` while non-terminal. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finished_at: Option>, + /// 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, + 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, + }, + /// 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, state: State, deps: Vec) -> 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}"); + } +} diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 2363e3db..8524d14f 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -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 ──────────────────────────────────────────────