Skip to main content

acativa/runtime/
driver.rs

1//! Continuous node driver with graceful lifecycle and source-identity result re-entry.
2//!
3//! Coordinates queue items, proxy receipts, node facades, feature indexes, and subscriptions.
4//! Ensures independent node concurrency (A->C executes while independent B runs).
5//! Collects per-node outcomes for graph pause/stop.
6//! Re-enters execution results with source identity and pre-effect validity checks.
7
8use 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/// Admitted message payload carried inside `QueuedItem.content`.
25#[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/// Result re-entry with source identity.
35///
36/// When an execution completes or fails, results re-enter through source identity
37/// (principal, graph_id, node_id, invocation_id, generation, durable_cursor).
38/// Stale generations are rejected and cannot satisfy new dependencies.
39#[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/// Report for an individual recipient's effect dispatch.
53#[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/// Per-node outcome when pausing a graph.
64#[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/// Graph-wide pause report collecting individual node outcomes truthfully.
74#[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/// Per-node outcome when stopping a graph.
83#[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/// Graph-wide stop report collecting individual node outcomes truthfully.
94#[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/// Outcome of one step of the continuous driver.
103#[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/// Errors originating in continuous driver operations.
115#[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
218/// Continuous driver orchestrating node facades, feature indexing, queues, and result re-entry.
219pub struct ContinuousNodeDriver {
220    pub graph_id: String,
221    /// Facades are crate-internal: mutable node access would hand callers a
222    /// `submit` path around `InterventionProxy::admit`. Hosts register
223    /// facades; the driver owns their dispatch from there.
224    nodes: BTreeMap<String, NodeFacade>,
225    pub index: NodeFeatureIndex,
226    /// The dispatch queue is private: admitted work enters only through
227    /// `InterventionProxy` — handing the queue to callers would hand them a
228    /// path around the admission boundary.
229    queue: FairDispatchQueue,
230    pub subscriptions: SubscriptionRegistry,
231    pub active_invocations: BTreeMap<String, String>, // invocation_id -> node_id
232    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    /// Register a new node facade into the driver and feature index.
255    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    /// Access a node facade by ID.
263    #[cfg(test)]
264    pub(crate) fn get_node(&self, node_id: &str) -> Option<&NodeFacade> {
265        self.nodes.get(node_id)
266    }
267
268    /// Mutably access a node facade by ID.
269    #[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    /// Test-only helper to enqueue an admitted message payload.
275    ///
276    /// Admission flows through `InterventionProxy`, the only boundary that
277    /// verifies the principal; this helper is test plumbing, not a host API.
278    #[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    /// Remove driver entries whose facade has already settled the invocation.
292    /// A direct stop/cancel may settle a node before the next driver poll; the
293    /// index must not turn that settled observation into a second re-entry.
294    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    /// Refresh the live metadata used by pre-effect validation for the
314    /// recipients in one admitted publication. This keeps the feature index
315    /// authoritative even when a caller has just observed or controlled a
316    /// facade through the narrow mutable access port.
317    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    /// Turn one terminal adapter observation into a source-authenticated
339    /// result re-entry. The active invocation and attempt token are copied
340    /// from the live facade, never from an untrusted observation.
341    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    /// Perform a single drive step:
380    /// 1. Pull next queue item.
381    /// 2. Validate live recipient states and generations.
382    /// 3. Dispatch to node facades.
383    /// 4. Poll and re-enter settled invocations.
384    pub fn drive_step(&mut self) -> Result<DriverStepOutcome, DriverError> {
385        let mut outcome = DriverStepOutcome::default();
386        self.prune_settled_invocations();
387
388        // 1. Process one queued item if available
389        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                                        // Other control operations handled at graph level
461                                    }
462                                }
463
464                                // Update feature index with updated metadata
465                                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        // 2. Poll active invocations for backend completions
516        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    /// Re-enter an execution result carrying full source identity.
553    /// Stale generations are rejected and cannot satisfy new dependencies.
554    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        // The adapter identity is the authenticated result port for this
571        // facade. A serialized source label alone cannot authorize re-entry.
572        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        // Verify generation matches live node generation.
582        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        // Settle invocation completion on facade
632        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        // Update index with new lifecycle state
638        let meta = facade.metadata();
639        self.index.register_or_update(meta);
640
641        // Match subscriptions against this transition / event
642        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                    // Authorized auto-resume rule resumes paused node
656                    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    /// Graceful graph pause: establishes scope admission barrier and requests pause on all active nodes.
674    /// Collects per-node outcomes without fabricating success for unavailable backends.
675    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        // Update all metadata in index
694        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    /// Graceful graph stop: establishes scope admission barrier and cooperatively cancels all active nodes.
706    /// Collects per-node outcomes and preserves requested, acknowledged, and effect-unknown facts.
707    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        // Update all metadata in index
727        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        // Verify index has node-1 as Ready
778        let matches = driver
779            .index
780            .query(&TargetSelector::ExactNode("node-1".into()));
781        assert_eq!(matches.len(), 1);
782
783        // Enqueue task
784        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        // Drive one step
812        let outcome = driver.drive_step().unwrap();
813        assert_eq!(outcome.items_dispatched, 1);
814        assert_eq!(outcome.effects_succeeded, 1);
815
816        // Node is now Running and feature index reflects Running
817        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        // Registered at generation 2
833        let facade = NodeFacade::new("node-recycled", "graph-1", 2, "worker", adapter, None, None);
834        driver.register_node(facade);
835
836        // Target was frozen with stale generation 1
837        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, // Stale!
844                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        // Drive step
865        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        // Node remains Ready; did NOT start work!
871        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        // Start task on node
963        let inv = driver
964            .get_node_mut("node-reentry")
965            .unwrap()
966            .submit(json!({"compute": 123}), 1)
967            .unwrap();
968
969        // Re-enter successful result with matching source identity
970        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        // Re-entering result with stale generation is rejected
989        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, // Stale!
995            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        // In a graph with A->C and independent B:
1096        // C can start when A finishes while B is still running!
1097        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        // Submit to A and B
1134        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        // Both A and B are Running
1146        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        // Settle A
1160        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        // A is Ready (idle), B is STILL Running, C starts!
1176        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        // Now C is Running concurrently while B is still Running!
1192        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        // Start node-1 running, leave node-2 ready
1290        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        // Pause graph
1300        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        // Node 1 was running -> entered PauseRequested (draining to safe boundary)
1305        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        // Node 2 was ready -> directly entered Paused
1314        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        // Stop graph
1323        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        // The acknowledged stop settled node-1 before the next poll; the
1336        // driver must prune that map entry rather than re-entering twice.
1337        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        // Register auto-resume subscription on node-paused when it enters Paused
1347        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        // Submit task to node
1368        let inv = driver
1369            .get_node_mut("node-paused")
1370            .unwrap()
1371            .submit(json!({"task": 1}), 1)
1372            .unwrap();
1373
1374        // Request pause on running node: enters PauseRequested
1375        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        // An invocation completion arrives: settles invocation, reaches safe boundary -> Paused,
1383        // and triggers the registered auto-resume subscription!
1384        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        // The auto-resume rule triggered on Paused, resuming the node to Ready!
1402        let node = driver.get_node("node-paused").unwrap();
1403        assert_eq!(node.lifecycle_state, NodeLifecycleState::Ready);
1404    }
1405}