1use std::collections::{BTreeSet, HashMap, HashSet};
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::{Result, SdkError};
13use crate::residual::ResidualEventRef;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum NodeClass {
19 Explore,
20 Plan,
21 Implement,
22 Verify,
23 Test,
24 Integrate,
25 Repair,
26 Interface,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum WorkNodeState {
33 Pending,
35 Ready,
37 Running,
39 Stable,
41 Stopped { certificate_id: String },
43 Retired { reason: String },
45 BlockedOnSensor { sensor: String },
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum EdgeKind {
53 RequiresArtifact,
55 RequiresInterface,
57 Tests,
59 Integrates,
61 ConflictsWith,
63 DerivedFrom,
65 BlocksOnSensor,
67}
68
69impl EdgeKind {
70 pub fn is_dependency(self) -> bool {
75 matches!(
76 self,
77 EdgeKind::RequiresArtifact
78 | EdgeKind::RequiresInterface
79 | EdgeKind::Tests
80 | EdgeKind::Integrates
81 )
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
87pub struct WorkEdge {
88 pub src: String,
89 pub dst: String,
90 pub kind: EdgeKind,
91}
92
93impl WorkEdge {
94 pub fn new(src: impl Into<String>, dst: impl Into<String>, kind: EdgeKind) -> Self {
95 Self {
96 src: src.into(),
97 dst: dst.into(),
98 kind,
99 }
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct WorkNode {
106 pub node_id: String,
107 pub generation: u32,
108 pub goal: String,
109 pub node_class: NodeClass,
110 pub owner_domains: Vec<String>,
111 pub output_targets: Vec<String>,
112 pub required_sensors: Vec<String>,
114 pub state: WorkNodeState,
115}
116
117impl WorkNode {
118 pub fn new(node_id: impl Into<String>, goal: impl Into<String>, node_class: NodeClass) -> Self {
119 Self {
120 node_id: node_id.into(),
121 generation: 0,
122 goal: goal.into(),
123 node_class,
124 owner_domains: Vec::new(),
125 output_targets: Vec::new(),
126 required_sensors: Vec::new(),
127 state: WorkNodeState::Pending,
128 }
129 }
130
131 pub fn with_outputs(mut self, outputs: Vec<String>) -> Self {
132 self.output_targets = outputs;
133 self
134 }
135
136 pub fn next_generation(&self) -> Self {
138 Self {
139 generation: self.generation + 1,
140 state: WorkNodeState::Pending,
141 ..self.clone()
142 }
143 }
144
145 pub fn is_terminal(&self) -> bool {
146 matches!(
147 self.state,
148 WorkNodeState::Stable | WorkNodeState::Stopped { .. } | WorkNodeState::Retired { .. }
149 )
150 }
151
152 pub fn is_accepted(&self) -> bool {
153 matches!(self.state, WorkNodeState::Stable)
154 }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum GraphRevisionReason {
161 InitialPlan,
162 ExecutionUpdate,
163 LocalRepair,
164 ScopeExpansion,
165 UserEdit,
166 Replan,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct GraphValidationReport {
172 pub acyclic: bool,
173 pub topo_order: Vec<String>,
175 pub dangling_edges: Vec<WorkEdge>,
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct WorkGraphRevision {
181 pub revision_id: String,
182 pub sequence: u32,
183 pub parent_revision_id: Option<String>,
184 pub reason: GraphRevisionReason,
185 pub nodes: Vec<WorkNode>,
186 pub edges: Vec<WorkEdge>,
187 pub validation: GraphValidationReport,
188 pub evidence: Vec<ResidualEventRef>,
189}
190
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195#[serde(tag = "edit", rename_all = "snake_case")]
196pub enum GraphEdit {
197 AddNode { node: WorkNode },
198 ReplaceNode { node: WorkNode },
199 RetireNode { node_id: String, reason: String },
200 AddEdge { edge: WorkEdge },
201 RemoveEdge { edge: WorkEdge },
202}
203
204impl WorkGraphRevision {
205 pub fn build(
209 sequence: u32,
210 parent_revision_id: Option<String>,
211 reason: GraphRevisionReason,
212 nodes: Vec<WorkNode>,
213 edges: Vec<WorkEdge>,
214 ) -> Result<Self> {
215 let validation = validate(&nodes, &edges)?;
216 if !validation.acyclic {
217 return Err(SdkError::Domain("graph revision is not acyclic".into()));
218 }
219 if !validation.dangling_edges.is_empty() {
220 return Err(SdkError::Domain(format!(
221 "graph revision has {} dangling edge(s)",
222 validation.dangling_edges.len()
223 )));
224 }
225 Ok(Self {
226 revision_id: uuid::Uuid::new_v4().to_string(),
227 sequence,
228 parent_revision_id,
229 reason,
230 nodes,
231 edges,
232 validation,
233 evidence: Vec::new(),
234 })
235 }
236
237 pub fn node(&self, node_id: &str) -> Option<&WorkNode> {
238 self.nodes.iter().find(|n| n.node_id == node_id)
239 }
240
241 pub fn dependencies_of(&self, node_id: &str) -> Vec<&str> {
243 self.edges
244 .iter()
245 .filter(|e| e.dst == node_id && e.kind.is_dependency())
246 .map(|e| e.src.as_str())
247 .collect()
248 }
249
250 pub fn explicit_conflicts_of(&self, node_id: &str) -> Vec<&str> {
254 self.edges
255 .iter()
256 .filter(|e| e.kind == EdgeKind::ConflictsWith && (e.src == node_id || e.dst == node_id))
257 .map(|e| {
258 if e.src == node_id {
259 e.dst.as_str()
260 } else {
261 e.src.as_str()
262 }
263 })
264 .collect()
265 }
266
267 pub fn revise(
272 &self,
273 reason: GraphRevisionReason,
274 edits: &[GraphEdit],
275 evidence: Vec<ResidualEventRef>,
276 ) -> Result<Self> {
277 if edits.is_empty() {
278 return Err(SdkError::Domain("graph revision has no edits".into()));
279 }
280 let mut nodes = self.nodes.clone();
281 let mut edges = self.edges.clone();
282 for edit in edits {
283 match edit {
284 GraphEdit::AddNode { node } => add_node(&mut nodes, node)?,
285 GraphEdit::ReplaceNode { node } => {
286 let existing = nodes
287 .iter_mut()
288 .find(|existing| existing.node_id == node.node_id)
289 .ok_or_else(|| {
290 SdkError::Domain(format!("unknown graph node {:?}", node.node_id))
291 })?;
292 if node.generation < existing.generation {
293 return Err(SdkError::Domain(format!(
294 "stale generation {} for node {:?} at generation {}",
295 node.generation, node.node_id, existing.generation
296 )));
297 }
298 if node.generation == existing.generation && *existing != *node {
301 return Err(SdkError::Domain(format!(
302 "node {:?} changed without a new generation",
303 node.node_id
304 )));
305 }
306 *existing = node.clone();
307 }
308 GraphEdit::RetireNode { node_id, reason } => {
309 let node = nodes
310 .iter_mut()
311 .find(|node| &node.node_id == node_id)
312 .ok_or_else(|| {
313 SdkError::Domain(format!("unknown graph node {node_id:?}"))
314 })?;
315 node.state = WorkNodeState::Retired {
316 reason: reason.clone(),
317 };
318 edges.retain(|edge| edge.src != *node_id && edge.dst != *node_id);
322 }
323 GraphEdit::AddEdge { edge } => {
324 if !edges.contains(edge) {
325 edges.push(edge.clone());
326 }
327 }
328 GraphEdit::RemoveEdge { edge } => edges.retain(|existing| existing != edge),
329 }
330 }
331 let mut revision = Self::build(
332 self.sequence.saturating_add(1),
333 Some(self.revision_id.clone()),
334 reason,
335 nodes,
336 edges,
337 )?;
338 revision.evidence = evidence;
339 Ok(revision)
340 }
341}
342
343fn add_node(nodes: &mut Vec<WorkNode>, node: &WorkNode) -> Result<()> {
344 if nodes
345 .iter()
346 .any(|existing| existing.node_id == node.node_id)
347 {
348 return Err(SdkError::Domain(format!(
349 "graph already contains node {:?}",
350 node.node_id
351 )));
352 }
353 nodes.push(node.clone());
354 Ok(())
355}
356
357pub fn validate(nodes: &[WorkNode], edges: &[WorkEdge]) -> Result<GraphValidationReport> {
360 let ids: HashSet<&str> = nodes.iter().map(|n| n.node_id.as_str()).collect();
361 let dangling: Vec<WorkEdge> = edges
362 .iter()
363 .filter(|e| !ids.contains(e.src.as_str()) || !ids.contains(e.dst.as_str()))
364 .cloned()
365 .collect();
366
367 let mut indegree: HashMap<&str, usize> =
369 nodes.iter().map(|n| (n.node_id.as_str(), 0)).collect();
370 let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
371 for e in edges.iter().filter(|e| e.kind.is_dependency()) {
372 if !ids.contains(e.src.as_str()) || !ids.contains(e.dst.as_str()) {
373 continue;
374 }
375 adj.entry(e.src.as_str()).or_default().push(e.dst.as_str());
376 *indegree.get_mut(e.dst.as_str()).unwrap() += 1;
377 }
378
379 let mut queue: BTreeSet<&str> = indegree
380 .iter()
381 .filter(|(_, &d)| d == 0)
382 .map(|(&n, _)| n)
383 .collect();
384 let mut topo_order = Vec::new();
385 while let Some(&n) = queue.iter().next() {
386 queue.remove(n);
387 topo_order.push(n.to_string());
388 if let Some(succs) = adj.get(n) {
389 for &s in succs {
390 let d = indegree.get_mut(s).unwrap();
391 *d -= 1;
392 if *d == 0 {
393 queue.insert(s);
394 }
395 }
396 }
397 }
398
399 let acyclic = topo_order.len() == nodes.len();
400 Ok(GraphValidationReport {
401 acyclic,
402 topo_order: if acyclic { topo_order } else { Vec::new() },
403 dangling_edges: dangling,
404 })
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 fn node(id: &str) -> WorkNode {
412 WorkNode::new(id, format!("goal {id}"), NodeClass::Implement)
413 }
414
415 #[test]
416 fn acyclic_graph_validates_with_topo_order() {
417 let nodes = vec![node("a"), node("b"), node("c")];
418 let edges = vec![
419 WorkEdge::new("a", "b", EdgeKind::RequiresArtifact),
420 WorkEdge::new("b", "c", EdgeKind::RequiresInterface),
421 ];
422 let rev = WorkGraphRevision::build(0, None, GraphRevisionReason::InitialPlan, nodes, edges)
423 .unwrap();
424 assert!(rev.validation.acyclic);
425 assert_eq!(rev.validation.topo_order, vec!["a", "b", "c"]);
426 }
427
428 #[test]
429 fn cyclic_graph_is_rejected() {
430 let nodes = vec![node("a"), node("b")];
431 let edges = vec![
432 WorkEdge::new("a", "b", EdgeKind::RequiresArtifact),
433 WorkEdge::new("b", "a", EdgeKind::RequiresArtifact),
434 ];
435 assert!(
436 WorkGraphRevision::build(0, None, GraphRevisionReason::InitialPlan, nodes, edges)
437 .is_err()
438 );
439 }
440
441 #[test]
442 fn dangling_edge_is_rejected() {
443 let nodes = vec![node("a")];
444 let edges = vec![WorkEdge::new("a", "ghost", EdgeKind::RequiresArtifact)];
445 assert!(
446 WorkGraphRevision::build(0, None, GraphRevisionReason::InitialPlan, nodes, edges)
447 .is_err()
448 );
449 }
450
451 #[test]
452 fn conflicts_with_does_not_create_dependency_cycle() {
453 let nodes = vec![node("a"), node("b")];
456 let edges = vec![WorkEdge::new("a", "b", EdgeKind::ConflictsWith)];
457 let rev = WorkGraphRevision::build(0, None, GraphRevisionReason::InitialPlan, nodes, edges)
458 .unwrap();
459 assert!(rev.validation.acyclic);
460 assert_eq!(rev.explicit_conflicts_of("a"), vec!["b"]);
461 }
462
463 #[test]
464 fn next_generation_resets_state() {
465 let mut n = node("a");
466 n.state = WorkNodeState::Stable;
467 let g1 = n.next_generation();
468 assert_eq!(g1.generation, 1);
469 assert_eq!(g1.state, WorkNodeState::Pending);
470 }
471
472 #[test]
473 fn dependencies_exclude_audit_and_conflict_edges() {
474 let nodes = vec![node("a"), node("b")];
475 let edges = vec![
476 WorkEdge::new("a", "b", EdgeKind::DerivedFrom),
477 WorkEdge::new("a", "b", EdgeKind::ConflictsWith),
478 ];
479 let rev = WorkGraphRevision::build(0, None, GraphRevisionReason::LocalRepair, nodes, edges)
480 .unwrap();
481 assert!(rev.dependencies_of("b").is_empty());
482 }
483
484 #[test]
485 fn evolving_revisions_can_add_work_after_execution_starts() {
486 let original = WorkGraphRevision::build(
487 0,
488 None,
489 GraphRevisionReason::InitialPlan,
490 vec![node("a")],
491 vec![],
492 )
493 .unwrap();
494 let revised = original
495 .revise(
496 GraphRevisionReason::LocalRepair,
497 &[
498 GraphEdit::AddNode { node: node("b") },
499 GraphEdit::AddEdge {
500 edge: WorkEdge::new("a", "b", EdgeKind::RequiresArtifact),
501 },
502 ],
503 vec![],
504 )
505 .unwrap();
506 assert_eq!(revised.parent_revision_id, Some(original.revision_id));
507 assert_eq!(revised.sequence, 1);
508 assert_eq!(revised.validation.topo_order, vec!["a", "b"]);
509 }
510
511 #[test]
512 fn a_failed_dynamic_revision_leaves_the_parent_valid() {
513 let original = WorkGraphRevision::build(
514 0,
515 None,
516 GraphRevisionReason::InitialPlan,
517 vec![node("a"), node("b")],
518 vec![WorkEdge::new("a", "b", EdgeKind::RequiresArtifact)],
519 )
520 .unwrap();
521 let result = original.revise(
522 GraphRevisionReason::LocalRepair,
523 &[GraphEdit::AddEdge {
524 edge: WorkEdge::new("b", "a", EdgeKind::RequiresArtifact),
525 }],
526 vec![],
527 );
528 assert!(result.is_err());
529 assert!(original.validation.acyclic);
530 }
531}