aion_package/structure/model.rs
1//! The workflow graph model: nodes, edges, and per-node correlation identity.
2//!
3//! A [`WorkflowGraph`] is a faithful projection of the typed Gleam source — a
4//! consumer (the dashboard canvas, RM-007) renders it and overlays a run's
5//! recorded events by matching each event onto a node's [`CorrelationKey`]. The
6//! model is never the authoritative artifact; the typed source is the single
7//! source of truth (ADR-014, CN6).
8
9use std::collections::BTreeSet;
10
11/// Stable identifier for a node within one extracted graph.
12///
13/// The id is the node's deterministic position in the order the extractor
14/// discovers nodes while walking the workflow's control flow depth-first from
15/// the entry function (success arms before error arms). It is a stable static
16/// position, not a claim about runtime execution order: under branching, which
17/// nodes a run actually executes is data-dependent. The id is opaque to
18/// consumers beyond identity and ordering.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
20pub struct NodeId(pub usize);
21
22/// The workflow primitive a node represents.
23///
24/// This is the fixed, known vocabulary of `aion/workflow` (the only surface the
25/// extractor understands), plus `Branch` for a `case` over a primitive result
26/// and `Opaque` for control flow the extractor recognises as present but cannot
27/// faithfully model. Adding a primitive to the SDK requires adding it here; an
28/// unrecognised call is never silently dropped, and control flow the walker
29/// cannot resolve is surfaced as an `Opaque` node, never flattened into a false
30/// sequential edge.
31#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
32pub enum NodePrimitive {
33 /// `workflow.run` — a single recorded activity dispatch.
34 Run,
35 /// `workflow.all` — concurrent fan-out, all must succeed.
36 All,
37 /// `workflow.race` — concurrent fan-out, first to settle wins.
38 Race,
39 /// `workflow.map` — concurrent map over a list of activities.
40 Map,
41 /// `workflow.spawn` — start a child workflow without waiting.
42 Spawn,
43 /// `workflow.spawn_and_wait` — start a child workflow and await it.
44 SpawnAndWait,
45 /// `workflow.receive` — await a signal.
46 Receive,
47 /// `workflow.sleep` — a durable timer the workflow blocks on.
48 Sleep,
49 /// `workflow.start_timer` — arm a named durable timer.
50 StartTimer,
51 /// `workflow.cancel_timer` — cancel a previously armed timer.
52 CancelTimer,
53 /// A `case` expression branching on a workflow-primitive result: its arms
54 /// fan out to the real success/error subgraphs via labelled branch edges.
55 Branch,
56 /// Control flow the extractor detects but cannot faithfully model (a `case`
57 /// whose arms it cannot bound, a recursive helper call, an indirect
58 /// dispatch). It is surfaced as an explicit node carrying the offending
59 /// source snippet — the honest alternative to silently flattening an
60 /// unmodellable shape into a false sequence (the loud-on-unmodellable rule).
61 Opaque,
62}
63
64/// The per-node identity a consumer maps recorded events onto.
65///
66/// The event-bearing kinds mirror how AD/AT sequences recorded events:
67/// activities by their static source position plus name, signals by name,
68/// timers by id, and children by spawn ordinal plus name. `ControlFlow` carries
69/// no recorded event of its own; it positions a structural node (a branch) in
70/// the discovery order. `Opaque` carries the source snippet of control flow the
71/// extractor could not model, so a consumer can surface it for review.
72#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
73pub enum CorrelationKey {
74 /// An activity dispatch: its static source-order position and activity name.
75 ActivitySequence {
76 /// Zero-based static source-order position of this activity among all
77 /// activity dispatches the extractor discovers, walking control flow
78 /// depth-first from the entry function (success arms before error
79 /// arms). It is a stable structural index, NOT a claim about the order
80 /// replay re-applies recorded events: under branching, which activities
81 /// a given run executes — and in what order — is data-dependent. A
82 /// consumer aligns a recorded `ActivityCompleted` onto a node by the
83 /// activity name and the branch the run actually took, not by assuming
84 /// this ordinal equals the recorded sequence position.
85 ordinal: usize,
86 /// The engine-facing activity name (the `run` node's activity).
87 activity: String,
88 },
89 /// A signal receive, identified by the signal-reference expression text.
90 ///
91 /// The signal name is not a string literal at the `receive` call site (it
92 /// is a typed `SignalRef`), so the reference identifier is carried as the
93 /// stable correlation token a consumer aligns against the run's signal
94 /// reference.
95 Signal {
96 /// The signal-reference expression text passed to `receive`.
97 reference: String,
98 },
99 /// A timer, identified by its literal name as written at the call site.
100 Timer {
101 /// The timer name string literal (`start_timer`'s first argument).
102 id: String,
103 },
104 /// A child spawn: its deterministic spawn ordinal and the literal child
105 /// workflow name.
106 Child {
107 /// Zero-based position of this spawn among all child spawns, in call
108 /// order — the ordinal a recorded child event carries.
109 ordinal: usize,
110 /// The child workflow name string literal (the spawn's first argument).
111 name: String,
112 },
113 /// A control-flow node (a branch) that records no event of its own.
114 ControlFlow {
115 /// Zero-based position of this control-flow node among all control-flow
116 /// nodes, in discovery order.
117 ordinal: usize,
118 },
119 /// An unmodellable control-flow node: control flow the extractor detected
120 /// but could not faithfully classify, carrying the offending source snippet
121 /// so a consumer can render it for review. It records no event of its own.
122 Opaque {
123 /// Zero-based position of this opaque node among all opaque nodes, in
124 /// discovery order.
125 ordinal: usize,
126 /// The source snippet the extractor could not model.
127 snippet: String,
128 },
129}
130
131/// A single node in the workflow graph.
132#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct GraphNode {
134 /// Stable identity and call-order position of the node.
135 pub id: NodeId,
136 /// The workflow primitive the node represents.
137 pub primitive: NodePrimitive,
138 /// The per-node identity a consumer maps recorded events onto.
139 pub correlation: CorrelationKey,
140}
141
142/// Which arm of a `Branch` node a branch edge belongs to.
143///
144/// A `case` over a workflow primitive's `Result` has an `Ok(..)` arm (the
145/// success continuation) and an `Error(..)` arm (the compensation / failure
146/// continuation). A `case` over another subject the walker recurses through
147/// (a decode, a guard) labels each arm by its leading constructor, or
148/// [`ArmLabel::Wildcard`] for a `_` catch-all.
149#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
150pub enum ArmLabel {
151 /// The `Ok(..)` arm — the success continuation.
152 Ok,
153 /// The `Error(..)` arm — the failure / compensation continuation.
154 Error,
155 /// A `_` catch-all arm.
156 Wildcard,
157 /// Any other arm, labelled by its leading pattern constructor.
158 Pattern(String),
159}
160
161/// The relationship a directed edge expresses between two nodes.
162#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
163pub enum EdgeKind {
164 /// Sequential control flow: `from` precedes `to` in the workflow's order.
165 Sequence,
166 /// A branch arm: `from` is a `Branch` node, `to` is the first node reached
167 /// on the `arm` of that `case`. The label distinguishes the success arm
168 /// from the compensation arm so the extracted edge set matches the
169 /// workflow's actual control flow rather than a linear flatten.
170 Branch {
171 /// Which arm of the branch this edge follows.
172 arm: ArmLabel,
173 },
174 /// A concurrent fan-out member: `from` is an `All` / `Race` / `Map` node,
175 /// `to` is one member activity dispatched concurrently under it. The member
176 /// nodes carry their own `ActivitySequence` keys so a consumer can overlay
177 /// each member's recorded `ActivityCompleted` independently.
178 FanOut,
179}
180
181/// A directed edge between two nodes.
182#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
183pub struct GraphEdge {
184 /// Source node id.
185 pub from: NodeId,
186 /// Target node id.
187 pub to: NodeId,
188 /// The relationship the edge expresses.
189 pub kind: EdgeKind,
190}
191
192/// A workflow's primitive structure as an ordered node/edge graph.
193///
194/// A projection of the typed source: extracted, never authored. The node and
195/// edge vectors are in deterministic call order so two extractions of the same
196/// package are byte-identical.
197#[derive(Clone, Debug, PartialEq, Eq)]
198pub struct WorkflowGraph {
199 /// The logical entry-module name the graph was extracted from.
200 pub entry_module: String,
201 /// Nodes in deterministic workflow call order.
202 pub nodes: Vec<GraphNode>,
203 /// Directed edges in deterministic order.
204 pub edges: Vec<GraphEdge>,
205}
206
207impl WorkflowGraph {
208 /// The logical entry-module name the graph was extracted from.
209 #[must_use]
210 pub fn entry_module(&self) -> &str {
211 &self.entry_module
212 }
213
214 /// The graph's nodes in deterministic call order.
215 #[must_use]
216 pub fn nodes(&self) -> &[GraphNode] {
217 &self.nodes
218 }
219
220 /// The graph's edges in deterministic order.
221 #[must_use]
222 pub fn edges(&self) -> &[GraphEdge] {
223 &self.edges
224 }
225
226 /// Looks up a node by id.
227 #[must_use]
228 pub fn node(&self, id: NodeId) -> Option<&GraphNode> {
229 self.nodes.iter().find(|node| node.id == id)
230 }
231
232 /// Whether this graph and `other` describe the same structure as unordered
233 /// node and edge sets.
234 ///
235 /// This is the diff primitive the C23 test uses: it proves the extracted
236 /// node/edge set matches a workflow's known structure regardless of the
237 /// internal vector order, so a consumer can assert structural equality
238 /// without depending on emission order.
239 #[must_use]
240 pub fn structurally_equals(&self, other: &Self) -> bool {
241 if self.entry_module != other.entry_module {
242 return false;
243 }
244 let self_nodes: BTreeSet<(NodeId, NodePrimitive, CorrelationKey)> = self
245 .nodes
246 .iter()
247 .map(|node| (node.id, node.primitive, node.correlation.clone()))
248 .collect();
249 let other_nodes: BTreeSet<(NodeId, NodePrimitive, CorrelationKey)> = other
250 .nodes
251 .iter()
252 .map(|node| (node.id, node.primitive, node.correlation.clone()))
253 .collect();
254 if self_nodes != other_nodes {
255 return false;
256 }
257 let self_edges: BTreeSet<GraphEdge> = self.edges.iter().cloned().collect();
258 let other_edges: BTreeSet<GraphEdge> = other.edges.iter().cloned().collect();
259 self_edges == other_edges
260 }
261}