1use crate::adapter::AdapterExecutionStatus;
9use crate::control::ControlOperation;
10use crate::node::{
11 CancellationFacts, NodeExecutionError, NodeExecutionOutcome, NodeExecutionReceipt, NodeFacade,
12 PauseResult, SteerResult, StopResult,
13};
14use crate::routing::{
15 ActivationRule, FairDispatchQueue, FrozenTargets, NodeFeatureIndex, NodeLifecycleState,
16 RecipientEffectStatus, SubscriptionPredicate, SubscriptionRegistry, SubscriptionScope,
17};
18#[cfg(test)]
19use crate::routing::{ChannelKind, QueueCapacityExceeded};
20use serde::{Deserialize, Serialize};
21use std::collections::BTreeMap;
22use std::fmt::{Display, Formatter};
23
24#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct AdmittedMessagePayload {
28 pub source_principal_id: String,
29 pub graph_id: String,
30 pub operation: ControlOperation,
31 pub targets: FrozenTargets,
32}
33
34#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase")]
41pub struct ResultReentry {
42 pub source_principal_id: String,
43 pub graph_id: String,
44 pub node_id: String,
45 pub invocation_id: String,
46 pub generation: u64,
47 pub cursor: u64,
48 pub attempt_token: String,
49 pub outcome: NodeExecutionOutcome,
50}
51
52#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct DispatchedEffectReport {
56 pub delivery_id: String,
57 pub recipient_node_id: String,
58 pub status: RecipientEffectStatus,
59 pub operation: String,
60 pub invocation_id: Option<String>,
61}
62
63#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct PerNodePauseOutcome {
67 pub node_id: String,
68 pub state_before: NodeLifecycleState,
69 pub state_after: NodeLifecycleState,
70 pub result: Result<PauseResult, String>,
71}
72
73#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
75#[serde(rename_all = "camelCase")]
76pub struct GraphPauseReport {
77 pub graph_id: String,
78 pub node_outcomes: Vec<PerNodePauseOutcome>,
79 pub scope_barrier_established: bool,
80}
81
82#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct PerNodeStopOutcome {
86 pub node_id: String,
87 pub state_before: NodeLifecycleState,
88 pub state_after: NodeLifecycleState,
89 pub cancellation_facts: Option<CancellationFacts>,
90 pub result: Result<StopResult, String>,
91}
92
93#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct GraphStopReport {
97 pub graph_id: String,
98 pub node_outcomes: Vec<PerNodeStopOutcome>,
99 pub scope_barrier_established: bool,
100}
101
102#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct DriverStepOutcome {
106 pub items_dispatched: usize,
107 pub effects_attempted: usize,
108 pub effects_succeeded: usize,
109 pub stale_recipients_skipped: usize,
110 pub completions_settled: usize,
111 pub reports: Vec<DispatchedEffectReport>,
112}
113
114#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase")]
117pub enum DriverError {
118 GraphMismatch {
119 expected: String,
120 received: String,
121 },
122 NodeNotFound(String),
123 StaleGenerationResult {
124 node_id: String,
125 expected: u64,
126 received: u64,
127 },
128 ResultSourceMismatch {
129 node_id: String,
130 expected: String,
131 received: String,
132 },
133 InvocationMismatch {
134 node_id: String,
135 expected: String,
136 received: String,
137 },
138 AttemptTokenMismatch {
139 node_id: String,
140 expected: String,
141 received: String,
142 },
143 StaleResultCursor {
144 node_id: String,
145 current: u64,
146 received: u64,
147 },
148 ScopeAdmissionBarrierActive,
149 NodeExecution(NodeExecutionError),
150}
151
152impl Display for DriverError {
153 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
154 match self {
155 Self::GraphMismatch { expected, received } => {
156 write!(
157 f,
158 "Graph mismatch: expected {expected}, received {received}"
159 )
160 }
161 Self::NodeNotFound(id) => write!(f, "Node {id} not found in driver"),
162 Self::StaleGenerationResult {
163 node_id,
164 expected,
165 received,
166 } => write!(
167 f,
168 "Stale generation result for node {node_id}: expected gen {expected}, got gen {received}"
169 ),
170 Self::ResultSourceMismatch {
171 node_id,
172 expected,
173 received,
174 } => write!(
175 f,
176 "Unverified result source for node {node_id}: expected {expected}, got {received}"
177 ),
178 Self::InvocationMismatch {
179 node_id,
180 expected,
181 received,
182 } => write!(
183 f,
184 "Invocation mismatch for node {node_id}: expected {expected}, got {received}"
185 ),
186 Self::AttemptTokenMismatch {
187 node_id,
188 expected,
189 received,
190 } => write!(
191 f,
192 "Attempt token mismatch for node {node_id}: expected {expected}, got {received}"
193 ),
194 Self::StaleResultCursor {
195 node_id,
196 current,
197 received,
198 } => write!(
199 f,
200 "Stale result cursor for node {node_id}: current {current}, got {received}"
201 ),
202 Self::ScopeAdmissionBarrierActive => {
203 write!(f, "Scope admission barrier active; new work rejected")
204 }
205 Self::NodeExecution(err) => write!(f, "Node execution error: {err}"),
206 }
207 }
208}
209
210impl std::error::Error for DriverError {}
211
212impl From<NodeExecutionError> for DriverError {
213 fn from(err: NodeExecutionError) -> Self {
214 Self::NodeExecution(err)
215 }
216}
217
218pub struct ContinuousNodeDriver {
220 pub graph_id: String,
221 nodes: BTreeMap<String, NodeFacade>,
225 pub index: NodeFeatureIndex,
226 queue: FairDispatchQueue,
230 pub subscriptions: SubscriptionRegistry,
231 pub active_invocations: BTreeMap<String, String>, pub scope_barrier_active: bool,
233 pub durable_cursor: u64,
234}
235
236impl ContinuousNodeDriver {
237 pub fn new(
238 graph_id: impl Into<String>,
239 queue: FairDispatchQueue,
240 subscriptions: SubscriptionRegistry,
241 ) -> Self {
242 Self {
243 graph_id: graph_id.into(),
244 nodes: BTreeMap::new(),
245 index: NodeFeatureIndex::new(),
246 queue,
247 subscriptions,
248 active_invocations: BTreeMap::new(),
249 scope_barrier_active: false,
250 durable_cursor: 0,
251 }
252 }
253
254 pub fn register_node(&mut self, facade: NodeFacade) {
256 let meta = facade.metadata();
257 let node_id = facade.node_id.clone();
258 self.nodes.insert(node_id, facade);
259 self.index.register_or_update(meta);
260 }
261
262 #[cfg(test)]
264 pub(crate) fn get_node(&self, node_id: &str) -> Option<&NodeFacade> {
265 self.nodes.get(node_id)
266 }
267
268 #[cfg(test)]
270 pub(crate) fn get_node_mut(&mut self, node_id: &str) -> Option<&mut NodeFacade> {
271 self.nodes.get_mut(node_id)
272 }
273
274 #[cfg(test)]
279 fn enqueue_admitted(
280 &mut self,
281 channel: ChannelKind,
282 item_id: impl Into<String>,
283 payload: AdmittedMessagePayload,
284 timestamp_unix_ms: i64,
285 ) -> Result<u64, QueueCapacityExceeded> {
286 let content = serde_json::to_value(&payload).unwrap_or_default();
287 self.queue
288 .enqueue(channel, item_id, content, timestamp_unix_ms)
289 }
290
291 fn prune_settled_invocations(&mut self) {
295 let stale_ids: Vec<String> = self
296 .active_invocations
297 .iter()
298 .filter_map(|(invocation_id, node_id)| {
299 let active_id = self
300 .nodes
301 .get(node_id)
302 .and_then(|facade| facade.active_invocation.as_ref())
303 .map(|invocation| invocation.invocation_id.as_str());
304 (active_id != Some(invocation_id.as_str())).then(|| invocation_id.clone())
305 })
306 .collect();
307
308 for invocation_id in stale_ids {
309 self.active_invocations.remove(&invocation_id);
310 }
311 }
312
313 fn refresh_recipient_index(&mut self, targets: &FrozenTargets) {
318 let metadata: Vec<(String, Option<_>)> = targets
319 .recipients
320 .iter()
321 .map(|recipient| {
322 (
323 recipient.node_id.clone(),
324 self.nodes.get(&recipient.node_id).map(NodeFacade::metadata),
325 )
326 })
327 .collect();
328
329 for (node_id, metadata) in metadata {
330 if let Some(metadata) = metadata {
331 self.index.register_or_update(metadata);
332 } else {
333 self.index.remove(&node_id);
334 }
335 }
336 }
337
338 fn result_reentry_from_status(
342 &self,
343 invocation_id: &str,
344 node_id: &str,
345 cursor: u64,
346 status: AdapterExecutionStatus,
347 ) -> Option<ResultReentry> {
348 let facade = self.nodes.get(node_id)?;
349 let active = facade.active_invocation.as_ref()?;
350 if active.invocation_id != invocation_id {
351 return None;
352 }
353
354 let outcome = match status {
355 AdapterExecutionStatus::Completed { output } => {
356 NodeExecutionOutcome::Success { output }
357 }
358 AdapterExecutionStatus::Failed { error, retryable } => {
359 NodeExecutionOutcome::Failure { error, retryable }
360 }
361 AdapterExecutionStatus::Cancelled { acknowledged } => {
362 NodeExecutionOutcome::Cancelled { acknowledged }
363 }
364 AdapterExecutionStatus::Running | AdapterExecutionStatus::Suspended => return None,
365 };
366
367 Some(ResultReentry {
368 source_principal_id: facade.adapter.adapter_identity().to_string(),
369 graph_id: self.graph_id.clone(),
370 node_id: node_id.to_string(),
371 invocation_id: invocation_id.to_string(),
372 generation: facade.generation,
373 cursor,
374 attempt_token: active.attempt_token.clone(),
375 outcome,
376 })
377 }
378
379 pub fn drive_step(&mut self) -> Result<DriverStepOutcome, DriverError> {
385 let mut outcome = DriverStepOutcome::default();
386 self.prune_settled_invocations();
387
388 if let Some(item) = self.queue.dequeue() {
390 outcome.items_dispatched += 1;
391 self.durable_cursor = self.durable_cursor.max(item.cursor);
392
393 if let Ok(payload) = serde_json::from_value::<AdmittedMessagePayload>(item.content) {
394 if payload.graph_id != self.graph_id {
395 return Err(DriverError::GraphMismatch {
396 expected: self.graph_id.clone(),
397 received: payload.graph_id,
398 });
399 }
400 if self.scope_barrier_active
401 && matches!(&payload.operation, ControlOperation::SubmitTask { .. })
402 {
403 return Err(DriverError::ScopeAdmissionBarrierActive);
404 }
405
406 self.refresh_recipient_index(&payload.targets);
407 let validations = payload.targets.validate_before_effect(&self.index);
408 let op_str = format!("{:?}", payload.operation);
409
410 for (recipient, status) in validations {
411 outcome.effects_attempted += 1;
412 match status {
413 RecipientEffectStatus::Valid => {
414 let mut inv_id = None;
415
416 if let Some(facade) = self.nodes.get_mut(&recipient.node_id) {
417 match &payload.operation {
418 ControlOperation::SubmitTask { input } => {
419 if let Ok(inv) = facade.submit(input.clone(), item.cursor) {
420 inv_id = Some(inv.invocation_id.clone());
421 self.active_invocations.insert(
422 inv.invocation_id.clone(),
423 recipient.node_id.clone(),
424 );
425 outcome.effects_succeeded += 1;
426 }
427 }
428 ControlOperation::Steer {
429 invocation_id,
430 instruction,
431 follow_up,
432 } => {
433 match facade.steer(
434 invocation_id.as_str(),
435 instruction.as_str(),
436 *follow_up,
437 ) {
438 Ok(
439 SteerResult::AppliedInFlight
440 | SteerResult::FollowUpQueued { .. },
441 ) => {
442 outcome.effects_succeeded += 1;
443 }
444 Ok(SteerResult::Unsupported) | Err(_) => {}
445 }
446 }
447 ControlOperation::Pause { .. } => match facade.pause() {
448 Ok(PauseResult::Unsupported) | Err(_) => {}
449 Ok(_) => outcome.effects_succeeded += 1,
450 },
451 ControlOperation::Resume { .. } if facade.resume().is_ok() => {
452 outcome.effects_succeeded += 1;
453 }
454 ControlOperation::Stop { reason, .. }
455 if facade.stop(reason.as_str()).is_ok() =>
456 {
457 outcome.effects_succeeded += 1;
458 }
459 _ => {
460 }
462 }
463
464 let meta = facade.metadata();
466 self.index.register_or_update(meta);
467 }
468
469 outcome.reports.push(DispatchedEffectReport {
470 delivery_id: recipient.delivery_id,
471 recipient_node_id: recipient.node_id,
472 status: RecipientEffectStatus::Valid,
473 operation: op_str.clone(),
474 invocation_id: inv_id,
475 });
476 }
477 RecipientEffectStatus::StateChanged { expected, current } => {
478 outcome.stale_recipients_skipped += 1;
479 outcome.reports.push(DispatchedEffectReport {
480 delivery_id: recipient.delivery_id,
481 recipient_node_id: recipient.node_id,
482 status: RecipientEffectStatus::StateChanged { expected, current },
483 operation: op_str.clone(),
484 invocation_id: None,
485 });
486 }
487 RecipientEffectStatus::GenerationStale { expected, current } => {
488 outcome.stale_recipients_skipped += 1;
489 outcome.reports.push(DispatchedEffectReport {
490 delivery_id: recipient.delivery_id,
491 recipient_node_id: recipient.node_id,
492 status: RecipientEffectStatus::GenerationStale {
493 expected,
494 current,
495 },
496 operation: op_str.clone(),
497 invocation_id: None,
498 });
499 }
500 RecipientEffectStatus::NodeMissing => {
501 outcome.stale_recipients_skipped += 1;
502 outcome.reports.push(DispatchedEffectReport {
503 delivery_id: recipient.delivery_id,
504 recipient_node_id: recipient.node_id,
505 status: RecipientEffectStatus::NodeMissing,
506 operation: op_str.clone(),
507 invocation_id: None,
508 });
509 }
510 }
511 }
512 }
513 }
514
515 let mut settled_reentries = Vec::new();
517 let mut next_result_cursor = self.durable_cursor;
518 for (inv_id, node_id) in &self.active_invocations {
519 if let Some(facade) = self.nodes.get(node_id) {
520 next_result_cursor = next_result_cursor.max(facade.durable_cursor);
521 let active_matches = facade
522 .active_invocation
523 .as_ref()
524 .is_some_and(|invocation| invocation.invocation_id.as_str() == inv_id.as_str());
525 if active_matches
526 && let Ok(status) = facade.adapter.poll_status(inv_id)
527 && matches!(
528 &status,
529 AdapterExecutionStatus::Completed { .. }
530 | AdapterExecutionStatus::Failed { .. }
531 | AdapterExecutionStatus::Cancelled { .. }
532 )
533 {
534 next_result_cursor = next_result_cursor.saturating_add(1);
535 if let Some(reentry) =
536 self.result_reentry_from_status(inv_id, node_id, next_result_cursor, status)
537 {
538 settled_reentries.push(reentry);
539 }
540 }
541 }
542 }
543
544 for reentry in settled_reentries {
545 self.reenter_result(reentry)?;
546 outcome.completions_settled += 1;
547 }
548
549 Ok(outcome)
550 }
551
552 pub fn reenter_result(
555 &mut self,
556 reentry: ResultReentry,
557 ) -> Result<NodeExecutionReceipt, DriverError> {
558 if reentry.graph_id != self.graph_id {
559 return Err(DriverError::GraphMismatch {
560 expected: self.graph_id.clone(),
561 received: reentry.graph_id,
562 });
563 }
564
565 let facade = self
566 .nodes
567 .get(&reentry.node_id)
568 .ok_or_else(|| DriverError::NodeNotFound(reentry.node_id.clone()))?;
569
570 let expected_source = facade.adapter.adapter_identity().to_string();
573 if expected_source != reentry.source_principal_id {
574 return Err(DriverError::ResultSourceMismatch {
575 node_id: reentry.node_id.clone(),
576 expected: expected_source,
577 received: reentry.source_principal_id,
578 });
579 }
580
581 if facade.generation != reentry.generation {
583 return Err(DriverError::StaleGenerationResult {
584 node_id: reentry.node_id.clone(),
585 expected: facade.generation,
586 received: reentry.generation,
587 });
588 }
589
590 let active = facade.active_invocation.as_ref().ok_or_else(|| {
591 DriverError::NodeExecution(
592 if facade
593 .invocation_history
594 .iter()
595 .any(|record| record.invocation_id == reentry.invocation_id)
596 {
597 NodeExecutionError::InvocationSettled(reentry.invocation_id.clone())
598 } else {
599 NodeExecutionError::InvocationNotFound(reentry.invocation_id.clone())
600 },
601 )
602 })?;
603
604 if active.invocation_id != reentry.invocation_id {
605 return Err(DriverError::InvocationMismatch {
606 node_id: reentry.node_id.clone(),
607 expected: active.invocation_id.clone(),
608 received: reentry.invocation_id,
609 });
610 }
611 if active.attempt_token != reentry.attempt_token {
612 return Err(DriverError::AttemptTokenMismatch {
613 node_id: reentry.node_id.clone(),
614 expected: active.attempt_token.clone(),
615 received: reentry.attempt_token,
616 });
617 }
618 if reentry.cursor < facade.durable_cursor {
619 return Err(DriverError::StaleResultCursor {
620 node_id: reentry.node_id.clone(),
621 current: facade.durable_cursor,
622 received: reentry.cursor,
623 });
624 }
625
626 let facade = self
627 .nodes
628 .get_mut(&reentry.node_id)
629 .ok_or_else(|| DriverError::NodeNotFound(reentry.node_id.clone()))?;
630
631 let receipt =
633 facade.settle_invocation_completion(reentry.outcome.clone(), reentry.cursor)?;
634 self.active_invocations.remove(&reentry.invocation_id);
635 self.durable_cursor = self.durable_cursor.max(reentry.cursor);
636
637 let meta = facade.metadata();
639 self.index.register_or_update(meta);
640
641 let scope = SubscriptionScope::Node {
643 graph_id: self.graph_id.clone(),
644 node_id: reentry.node_id.clone(),
645 };
646 let matched = self.subscriptions.match_event(
647 &scope,
648 &SubscriptionPredicate::OnLifecycleState(receipt.resulting_state),
649 Some(receipt.resulting_state),
650 );
651
652 for sub in matched {
653 match sub.activation_rule {
654 ActivationRule::AutoResume => {
655 if !self.scope_barrier_active
657 && let Some(facade) = self.nodes.get_mut(&reentry.node_id)
658 && facade.lifecycle_state == NodeLifecycleState::Paused
659 {
660 let _ = facade.resume();
661 let updated_meta = facade.metadata();
662 self.index.register_or_update(updated_meta);
663 }
664 }
665 ActivationRule::NotifyOnly => {}
666 ActivationRule::TriggerTransition { .. } => {}
667 }
668 }
669
670 Ok(receipt)
671 }
672
673 pub fn pause_graph(&mut self) -> Result<GraphPauseReport, DriverError> {
676 self.scope_barrier_active = true;
677 let mut outcomes = Vec::new();
678
679 for (node_id, facade) in &mut self.nodes {
680 if facade.lifecycle_state.is_active() {
681 let before = facade.lifecycle_state;
682 let res = facade.pause().map_err(|e| e.to_string());
683 let after = facade.lifecycle_state;
684 outcomes.push(PerNodePauseOutcome {
685 node_id: node_id.clone(),
686 state_before: before,
687 state_after: after,
688 result: res,
689 });
690 }
691 }
692
693 for facade in self.nodes.values() {
695 self.index.register_or_update(facade.metadata());
696 }
697
698 Ok(GraphPauseReport {
699 graph_id: self.graph_id.clone(),
700 node_outcomes: outcomes,
701 scope_barrier_established: true,
702 })
703 }
704
705 pub fn stop_graph(&mut self, reason: &str) -> Result<GraphStopReport, DriverError> {
708 self.scope_barrier_active = true;
709 let mut outcomes = Vec::new();
710
711 for (node_id, facade) in &mut self.nodes {
712 if facade.lifecycle_state.is_active() {
713 let before = facade.lifecycle_state;
714 let res = facade.stop(reason).map_err(|e| e.to_string());
715 let after = facade.lifecycle_state;
716 outcomes.push(PerNodeStopOutcome {
717 node_id: node_id.clone(),
718 state_before: before,
719 state_after: after,
720 cancellation_facts: facade.cancellation_facts.clone(),
721 result: res,
722 });
723 }
724 }
725
726 for facade in self.nodes.values() {
728 self.index.register_or_update(facade.metadata());
729 }
730
731 Ok(GraphStopReport {
732 graph_id: self.graph_id.clone(),
733 node_outcomes: outcomes,
734 scope_barrier_established: true,
735 })
736 }
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742 use crate::adapter::{CooperativeDrainAdapter, SyntheticCapabilityAdapter};
743 use crate::routing::{
744 DeliveryMode, FrozenTargetRecipient, QueueBounds, Subscription, TargetSelector,
745 };
746 use serde_json::json;
747 use std::sync::Arc;
748
749 fn test_queue() -> FairDispatchQueue {
750 FairDispatchQueue::new(
751 QueueBounds {
752 max_entries: 100,
753 max_bytes: 1024 * 1024,
754 },
755 QueueBounds {
756 max_entries: 100,
757 max_bytes: 1024 * 1024,
758 },
759 QueueBounds {
760 max_entries: 100,
761 max_bytes: 1024 * 1024,
762 },
763 4,
764 )
765 }
766
767 #[test]
768 fn test_driver_step_dispatches_queued_item_and_updates_index() {
769 let queue = test_queue();
770 let subs = SubscriptionRegistry::new();
771 let mut driver = ContinuousNodeDriver::new("graph-1", queue, subs);
772
773 let adapter = Arc::new(SyntheticCapabilityAdapter::new("adapt-1"));
774 let facade = NodeFacade::new("node-1", "graph-1", 1, "worker", adapter, None, None);
775 driver.register_node(facade);
776
777 let matches = driver
779 .index
780 .query(&TargetSelector::ExactNode("node-1".into()));
781 assert_eq!(matches.len(), 1);
782
783 let targets = FrozenTargets {
785 frozen_at_cursor: 1,
786 mode: DeliveryMode::DispatchOne,
787 recipients: vec![FrozenTargetRecipient {
788 delivery_id: "del-1".into(),
789 node_id: "node-1".into(),
790 generation: 1,
791 admitted_state: NodeLifecycleState::Ready,
792 }],
793 };
794
795 driver
796 .enqueue_admitted(
797 ChannelKind::Data,
798 "msg-1",
799 AdmittedMessagePayload {
800 source_principal_id: "admin".into(),
801 graph_id: "graph-1".into(),
802 operation: ControlOperation::SubmitTask {
803 input: json!({"run": true}),
804 },
805 targets,
806 },
807 1000,
808 )
809 .unwrap();
810
811 let outcome = driver.drive_step().unwrap();
813 assert_eq!(outcome.items_dispatched, 1);
814 assert_eq!(outcome.effects_succeeded, 1);
815
816 let node = driver.get_node("node-1").unwrap();
818 assert_eq!(node.lifecycle_state, NodeLifecycleState::Running);
819 let running_nodes = driver
820 .index
821 .query(&TargetSelector::Lifecycle(NodeLifecycleState::Running));
822 assert!(running_nodes.contains("node-1"));
823 }
824
825 #[test]
826 fn test_driver_pre_effect_validation_skips_stale_or_missing_recipients() {
827 let queue = test_queue();
828 let subs = SubscriptionRegistry::new();
829 let mut driver = ContinuousNodeDriver::new("graph-1", queue, subs);
830
831 let adapter = Arc::new(SyntheticCapabilityAdapter::new("adapt-1"));
832 let facade = NodeFacade::new("node-recycled", "graph-1", 2, "worker", adapter, None, None);
834 driver.register_node(facade);
835
836 let targets = FrozenTargets {
838 frozen_at_cursor: 1,
839 mode: DeliveryMode::DispatchOne,
840 recipients: vec![FrozenTargetRecipient {
841 delivery_id: "del-stale".into(),
842 node_id: "node-recycled".into(),
843 generation: 1, admitted_state: NodeLifecycleState::Ready,
845 }],
846 };
847
848 driver
849 .enqueue_admitted(
850 ChannelKind::Data,
851 "msg-stale",
852 AdmittedMessagePayload {
853 source_principal_id: "admin".into(),
854 graph_id: "graph-1".into(),
855 operation: ControlOperation::SubmitTask {
856 input: json!({"work": 1}),
857 },
858 targets,
859 },
860 1000,
861 )
862 .unwrap();
863
864 let outcome = driver.drive_step().unwrap();
866 assert_eq!(outcome.items_dispatched, 1);
867 assert_eq!(outcome.stale_recipients_skipped, 1);
868 assert_eq!(outcome.effects_succeeded, 0);
869
870 let node = driver.get_node("node-recycled").unwrap();
872 assert_eq!(node.lifecycle_state, NodeLifecycleState::Ready);
873 }
874
875 #[test]
876 fn test_driver_rejects_wrong_graph_and_scope_barrier_submissions() {
877 let queue = test_queue();
878 let subs = SubscriptionRegistry::new();
879 let mut driver = ContinuousNodeDriver::new("graph-guard", queue, subs);
880 driver.register_node(NodeFacade::new(
881 "node-guard",
882 "graph-guard",
883 1,
884 "worker",
885 Arc::new(SyntheticCapabilityAdapter::new("guard-adapter")),
886 None,
887 None,
888 ));
889
890 let targets = FrozenTargets {
891 frozen_at_cursor: 1,
892 mode: DeliveryMode::DispatchOne,
893 recipients: vec![FrozenTargetRecipient {
894 delivery_id: "del-guard".into(),
895 node_id: "node-guard".into(),
896 generation: 1,
897 admitted_state: NodeLifecycleState::Ready,
898 }],
899 };
900
901 driver
902 .enqueue_admitted(
903 ChannelKind::Data,
904 "msg-barrier",
905 AdmittedMessagePayload {
906 source_principal_id: "admin".into(),
907 graph_id: "graph-guard".into(),
908 operation: ControlOperation::SubmitTask {
909 input: json!({"blocked": true}),
910 },
911 targets: targets.clone(),
912 },
913 1000,
914 )
915 .unwrap();
916 driver.scope_barrier_active = true;
917 assert!(matches!(
918 driver.drive_step(),
919 Err(DriverError::ScopeAdmissionBarrierActive)
920 ));
921 assert_eq!(
922 driver.get_node("node-guard").unwrap().lifecycle_state,
923 NodeLifecycleState::Ready
924 );
925
926 driver.scope_barrier_active = false;
927 driver
928 .enqueue_admitted(
929 ChannelKind::Data,
930 "msg-wrong-graph",
931 AdmittedMessagePayload {
932 source_principal_id: "admin".into(),
933 graph_id: "other-graph".into(),
934 operation: ControlOperation::SubmitTask {
935 input: json!({"blocked": true}),
936 },
937 targets,
938 },
939 1001,
940 )
941 .unwrap();
942 assert!(matches!(
943 driver.drive_step(),
944 Err(DriverError::GraphMismatch { .. })
945 ));
946 assert_eq!(
947 driver.get_node("node-guard").unwrap().lifecycle_state,
948 NodeLifecycleState::Ready
949 );
950 }
951
952 #[test]
953 fn test_driver_result_reentry_with_source_identity() {
954 let queue = test_queue();
955 let subs = SubscriptionRegistry::new();
956 let mut driver = ContinuousNodeDriver::new("graph-1", queue, subs);
957
958 let adapter = Arc::new(SyntheticCapabilityAdapter::new("adapt-1"));
959 let facade = NodeFacade::new("node-reentry", "graph-1", 1, "worker", adapter, None, None);
960 driver.register_node(facade);
961
962 let inv = driver
964 .get_node_mut("node-reentry")
965 .unwrap()
966 .submit(json!({"compute": 123}), 1)
967 .unwrap();
968
969 let receipt = driver
971 .reenter_result(ResultReentry {
972 source_principal_id: "adapt-1".into(),
973 graph_id: "graph-1".into(),
974 node_id: "node-reentry".into(),
975 invocation_id: inv.invocation_id.clone(),
976 generation: 1,
977 cursor: 2,
978 attempt_token: inv.attempt_token.clone(),
979 outcome: NodeExecutionOutcome::Success {
980 output: json!({"result": 456}),
981 },
982 })
983 .unwrap();
984
985 assert_eq!(receipt.resulting_state, NodeLifecycleState::Ready);
986 assert_eq!(driver.durable_cursor, 2);
987
988 let stale_res = driver.reenter_result(ResultReentry {
990 source_principal_id: "adapt-1".into(),
991 graph_id: "graph-1".into(),
992 node_id: "node-reentry".into(),
993 invocation_id: "inv-old".into(),
994 generation: 99, cursor: 3,
996 attempt_token: "att-old".into(),
997 outcome: NodeExecutionOutcome::Success { output: json!({}) },
998 });
999
1000 assert!(matches!(
1001 stale_res,
1002 Err(DriverError::StaleGenerationResult {
1003 expected: 1,
1004 received: 99,
1005 ..
1006 })
1007 ));
1008 }
1009
1010 #[test]
1011 fn test_driver_result_reentry_rejects_unverified_invocation_identity() {
1012 let queue = test_queue();
1013 let subs = SubscriptionRegistry::new();
1014 let mut driver = ContinuousNodeDriver::new("graph-identity", queue, subs);
1015 let adapter = Arc::new(SyntheticCapabilityAdapter::new("verified-adapter"));
1016 driver.register_node(NodeFacade::new(
1017 "node-identity",
1018 "graph-identity",
1019 1,
1020 "worker",
1021 adapter,
1022 None,
1023 None,
1024 ));
1025 let invocation = driver
1026 .get_node_mut("node-identity")
1027 .unwrap()
1028 .submit(json!({"work": true}), 1)
1029 .unwrap();
1030
1031 let make_reentry =
1032 |source: &str, invocation_id: &str, attempt_token: &str, cursor: u64| ResultReentry {
1033 source_principal_id: source.to_string(),
1034 graph_id: "graph-identity".to_string(),
1035 node_id: "node-identity".to_string(),
1036 invocation_id: invocation_id.to_string(),
1037 generation: 1,
1038 cursor,
1039 attempt_token: attempt_token.to_string(),
1040 outcome: NodeExecutionOutcome::Success {
1041 output: json!({"ok": true}),
1042 },
1043 };
1044
1045 assert!(matches!(
1046 driver.reenter_result(make_reentry(
1047 "forged-adapter",
1048 &invocation.invocation_id,
1049 &invocation.attempt_token,
1050 2,
1051 )),
1052 Err(DriverError::ResultSourceMismatch { .. })
1053 ));
1054 assert!(matches!(
1055 driver.reenter_result(make_reentry(
1056 "verified-adapter",
1057 "inv-other",
1058 &invocation.attempt_token,
1059 2,
1060 )),
1061 Err(DriverError::InvocationMismatch { .. })
1062 ));
1063 assert!(matches!(
1064 driver.reenter_result(make_reentry(
1065 "verified-adapter",
1066 &invocation.invocation_id,
1067 "attempt-other",
1068 2,
1069 )),
1070 Err(DriverError::AttemptTokenMismatch { .. })
1071 ));
1072 assert!(matches!(
1073 driver.reenter_result(make_reentry(
1074 "verified-adapter",
1075 &invocation.invocation_id,
1076 &invocation.attempt_token,
1077 0,
1078 )),
1079 Err(DriverError::StaleResultCursor { .. })
1080 ));
1081
1082 let receipt = driver
1083 .reenter_result(make_reentry(
1084 "verified-adapter",
1085 &invocation.invocation_id,
1086 &invocation.attempt_token,
1087 2,
1088 ))
1089 .unwrap();
1090 assert_eq!(receipt.resulting_state, NodeLifecycleState::Ready);
1091 }
1092
1093 #[test]
1094 fn test_driver_independent_node_concurrency() {
1095 let queue = test_queue();
1098 let subs = SubscriptionRegistry::new();
1099 let mut driver = ContinuousNodeDriver::new("graph-concur", queue, subs);
1100
1101 let adapt_a = Arc::new(SyntheticCapabilityAdapter::new("adapt-a"));
1102 let adapt_b = Arc::new(SyntheticCapabilityAdapter::new("adapt-b"));
1103 let adapt_c = Arc::new(SyntheticCapabilityAdapter::new("adapt-c"));
1104
1105 driver.register_node(NodeFacade::new(
1106 "node-A",
1107 "graph-concur",
1108 1,
1109 "task-a",
1110 adapt_a,
1111 None,
1112 None,
1113 ));
1114 driver.register_node(NodeFacade::new(
1115 "node-B",
1116 "graph-concur",
1117 1,
1118 "task-b",
1119 adapt_b,
1120 None,
1121 None,
1122 ));
1123 driver.register_node(NodeFacade::new(
1124 "node-C",
1125 "graph-concur",
1126 1,
1127 "task-c",
1128 adapt_c,
1129 None,
1130 None,
1131 ));
1132
1133 let inv_a = driver
1135 .get_node_mut("node-A")
1136 .unwrap()
1137 .submit(json!({"step": "A"}), 1)
1138 .unwrap();
1139 let _inv_b = driver
1140 .get_node_mut("node-B")
1141 .unwrap()
1142 .submit(json!({"step": "B"}), 2)
1143 .unwrap();
1144
1145 assert_eq!(
1147 driver.get_node("node-A").unwrap().lifecycle_state,
1148 NodeLifecycleState::Running
1149 );
1150 assert_eq!(
1151 driver.get_node("node-B").unwrap().lifecycle_state,
1152 NodeLifecycleState::Running
1153 );
1154 assert_eq!(
1155 driver.get_node("node-C").unwrap().lifecycle_state,
1156 NodeLifecycleState::Ready
1157 );
1158
1159 driver
1161 .reenter_result(ResultReentry {
1162 source_principal_id: "adapt-a".into(),
1163 graph_id: "graph-concur".into(),
1164 node_id: "node-A".into(),
1165 invocation_id: inv_a.invocation_id,
1166 generation: 1,
1167 cursor: 3,
1168 attempt_token: inv_a.attempt_token,
1169 outcome: NodeExecutionOutcome::Success {
1170 output: json!({"a": "done"}),
1171 },
1172 })
1173 .unwrap();
1174
1175 assert_eq!(
1177 driver.get_node("node-A").unwrap().lifecycle_state,
1178 NodeLifecycleState::Ready
1179 );
1180 assert_eq!(
1181 driver.get_node("node-B").unwrap().lifecycle_state,
1182 NodeLifecycleState::Running
1183 );
1184
1185 let _inv_c = driver
1186 .get_node_mut("node-C")
1187 .unwrap()
1188 .submit(json!({"step": "C", "input_from_a": "done"}), 4)
1189 .unwrap();
1190
1191 assert_eq!(
1193 driver.get_node("node-C").unwrap().lifecycle_state,
1194 NodeLifecycleState::Running
1195 );
1196 assert_eq!(
1197 driver.get_node("node-B").unwrap().lifecycle_state,
1198 NodeLifecycleState::Running
1199 );
1200 }
1201
1202 #[test]
1203 fn test_driver_assigns_distinct_cursors_to_independent_completions() {
1204 let queue = test_queue();
1205 let subs = SubscriptionRegistry::new();
1206 let mut driver = ContinuousNodeDriver::new("graph-cursors", queue, subs);
1207 let adapter_a = Arc::new(SyntheticCapabilityAdapter::new("cursor-a"));
1208 let adapter_b = Arc::new(SyntheticCapabilityAdapter::new("cursor-b"));
1209 driver.register_node(NodeFacade::new(
1210 "node-a",
1211 "graph-cursors",
1212 1,
1213 "worker",
1214 adapter_a.clone(),
1215 None,
1216 None,
1217 ));
1218 driver.register_node(NodeFacade::new(
1219 "node-b",
1220 "graph-cursors",
1221 1,
1222 "worker",
1223 adapter_b.clone(),
1224 None,
1225 None,
1226 ));
1227 let invocation_a = driver
1228 .get_node_mut("node-a")
1229 .unwrap()
1230 .submit(json!({"node": "a"}), 1)
1231 .unwrap();
1232 let invocation_b = driver
1233 .get_node_mut("node-b")
1234 .unwrap()
1235 .submit(json!({"node": "b"}), 1)
1236 .unwrap();
1237 driver
1238 .active_invocations
1239 .insert(invocation_a.invocation_id.clone(), "node-a".to_string());
1240 driver
1241 .active_invocations
1242 .insert(invocation_b.invocation_id.clone(), "node-b".to_string());
1243 adapter_a.set_status(
1244 &invocation_a.invocation_id,
1245 AdapterExecutionStatus::Completed {
1246 output: json!({"done": "a"}),
1247 },
1248 );
1249 adapter_b.set_status(
1250 &invocation_b.invocation_id,
1251 AdapterExecutionStatus::Completed {
1252 output: json!({"done": "b"}),
1253 },
1254 );
1255
1256 let outcome = driver.drive_step().unwrap();
1257 assert_eq!(outcome.completions_settled, 2);
1258 assert_eq!(driver.durable_cursor, 3);
1259 assert!(driver.active_invocations.is_empty());
1260 }
1261
1262 #[test]
1263 fn test_driver_graceful_graph_pause_and_stop_collects_per_node_outcomes() {
1264 let queue = test_queue();
1265 let subs = SubscriptionRegistry::new();
1266 let mut driver = ContinuousNodeDriver::new("graph-lifecycle", queue, subs);
1267
1268 let adapt_1 = Arc::new(CooperativeDrainAdapter::new("coop-1"));
1269 let adapt_2 = Arc::new(CooperativeDrainAdapter::new("coop-2"));
1270
1271 driver.register_node(NodeFacade::new(
1272 "node-1",
1273 "graph-lifecycle",
1274 1,
1275 "worker",
1276 adapt_1,
1277 None,
1278 None,
1279 ));
1280 driver.register_node(NodeFacade::new(
1281 "node-2",
1282 "graph-lifecycle",
1283 1,
1284 "worker",
1285 adapt_2,
1286 None,
1287 None,
1288 ));
1289 let invocation = driver
1291 .get_node_mut("node-1")
1292 .unwrap()
1293 .submit(json!({}), 1)
1294 .unwrap();
1295 driver
1296 .active_invocations
1297 .insert(invocation.invocation_id, "node-1".to_string());
1298
1299 let pause_report = driver.pause_graph().unwrap();
1301 assert!(pause_report.scope_barrier_established);
1302 assert_eq!(pause_report.node_outcomes.len(), 2);
1303
1304 let o1 = pause_report
1306 .node_outcomes
1307 .iter()
1308 .find(|o| o.node_id == "node-1")
1309 .unwrap();
1310 assert_eq!(o1.state_before, NodeLifecycleState::Running);
1311 assert_eq!(o1.state_after, NodeLifecycleState::PauseRequested);
1312
1313 let o2 = pause_report
1315 .node_outcomes
1316 .iter()
1317 .find(|o| o.node_id == "node-2")
1318 .unwrap();
1319 assert_eq!(o2.state_before, NodeLifecycleState::Ready);
1320 assert_eq!(o2.state_after, NodeLifecycleState::Paused);
1321
1322 let stop_report = driver.stop_graph("shutdown").unwrap();
1324 assert!(stop_report.scope_barrier_established);
1325 assert_eq!(stop_report.node_outcomes.len(), 2);
1326
1327 for outcome in stop_report.node_outcomes {
1328 assert_eq!(outcome.state_after, NodeLifecycleState::Stopped);
1329 let facts = outcome.cancellation_facts.unwrap();
1330 assert!(facts.requested);
1331 assert!(facts.acknowledged);
1332 assert_eq!(facts.reason, "shutdown");
1333 }
1334
1335 assert!(driver.drive_step().is_ok());
1338 assert!(driver.active_invocations.is_empty());
1339 }
1340
1341 #[test]
1342 fn test_driver_subscription_auto_resume_on_matching_event() {
1343 let queue = test_queue();
1344 let mut subs = SubscriptionRegistry::new();
1345
1346 subs.subscribe(Subscription {
1348 subscription_id: "sub-auto-resume".into(),
1349 subscriber_id: "subscriber-1".into(),
1350 scope: SubscriptionScope::Node {
1351 graph_id: "graph-sub".into(),
1352 node_id: "node-paused".into(),
1353 },
1354 predicate: SubscriptionPredicate::OnLifecycleState(NodeLifecycleState::Paused),
1355 activation_rule: ActivationRule::AutoResume,
1356 durable_cursor: 0,
1357 is_control: false,
1358 active: true,
1359 });
1360
1361 let mut driver = ContinuousNodeDriver::new("graph-sub", queue, subs);
1362
1363 let adapter = Arc::new(CooperativeDrainAdapter::new("coop"));
1364 let facade = NodeFacade::new("node-paused", "graph-sub", 1, "worker", adapter, None, None);
1365 driver.register_node(facade);
1366
1367 let inv = driver
1369 .get_node_mut("node-paused")
1370 .unwrap()
1371 .submit(json!({"task": 1}), 1)
1372 .unwrap();
1373
1374 let pause_res = driver.get_node_mut("node-paused").unwrap().pause().unwrap();
1376 assert!(matches!(pause_res, PauseResult::DrainingToSafeBoundary));
1377 assert_eq!(
1378 driver.get_node("node-paused").unwrap().lifecycle_state,
1379 NodeLifecycleState::PauseRequested
1380 );
1381
1382 let receipt = driver
1385 .reenter_result(ResultReentry {
1386 source_principal_id: "coop".into(),
1387 graph_id: "graph-sub".into(),
1388 node_id: "node-paused".into(),
1389 invocation_id: inv.invocation_id,
1390 generation: 1,
1391 cursor: 5,
1392 attempt_token: inv.attempt_token,
1393 outcome: NodeExecutionOutcome::Success {
1394 output: json!({"wake": true}),
1395 },
1396 })
1397 .unwrap();
1398
1399 assert_eq!(receipt.resulting_state, NodeLifecycleState::Paused);
1400
1401 let node = driver.get_node("node-paused").unwrap();
1403 assert_eq!(node.lifecycle_state, NodeLifecycleState::Ready);
1404 }
1405}