Skip to main content

pe_graph/
compiled.rs

1//! Compiled graph — the user-facing execution handle.
2//!
3//! `CompiledGraph` wraps a validated `StateGraph` and provides the
4//! execution API: `invoke()`, `resume()`, `get_state()`, `get_state_history()`.
5
6use crate::checkpoint_data::CheckpointData;
7use crate::checkpointer::Checkpointer;
8use crate::command::Command;
9use crate::config::GraphConfig;
10use crate::graph::StateGraph;
11use crate::pregel::PregelEngine;
12use crate::snapshot::StateSnapshot;
13use pe_core::error::PeError;
14use pe_core::lobe::LobeRuntimeServiceFactory;
15use pe_core::node::InterruptRequest;
16use pe_core::state::State;
17use std::any::Any;
18use std::sync::Arc;
19
20/// Outcome of a graph execution.
21#[derive(Debug, Clone)]
22#[non_exhaustive]
23pub enum ExecutionOutcome<S: State> {
24    /// Graph ran to END — contains final state.
25    Completed(S),
26
27    /// Graph hit an interrupt — contains state at pause point.
28    Interrupted {
29        /// State at the moment of interruption.
30        state: S,
31        /// The interrupt request from the node.
32        request: InterruptRequest<S::Update>,
33    },
34}
35
36/// A validated, executable graph. Produced by `StateGraph::compile()`.
37///
38/// This is the primary execution handle. Use `invoke()` to run a graph
39/// from initial state, `resume()` to continue after an interrupt, and
40/// `get_state()` to inspect the current checkpoint.
41///
42/// # Example
43///
44/// ```ignore
45/// let outcome = graph.invoke(initial_state, GraphConfig::default()).await?;
46/// match outcome {
47///     ExecutionOutcome::Completed(state) => println!("Done: {:?}", state),
48///     ExecutionOutcome::Interrupted { state, request } => {
49///         println!("Paused: {}", request.reason);
50///     }
51/// }
52/// ```
53pub struct CompiledGraph<S: State> {
54    pub(crate) graph: Arc<StateGraph<S>>,
55    checkpointer: Option<Arc<dyn Checkpointer>>,
56    /// Optional matrix layer hook for convergence tracking and learned routing.
57    matrix_hook: Option<crate::matrix_hook::MatrixHookHandle>,
58    /// Optional agent bound to this graph (set by GraphBuilder).
59    agent: Option<pe_core::agent::Agent>,
60}
61
62impl<S: State> std::fmt::Debug for CompiledGraph<S> {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.debug_struct("CompiledGraph")
65            .field("nodes", &self.graph.nodes.keys().collect::<Vec<_>>())
66            .field("has_checkpointer", &self.checkpointer.is_some())
67            .field("has_matrix_hook", &self.matrix_hook.is_some())
68            .field("has_agent", &self.agent.is_some())
69            .finish()
70    }
71}
72
73impl<S: State> CompiledGraph<S> {
74    /// Create a new compiled graph (called by `StateGraph::compile()`).
75    pub(crate) fn new(graph: Arc<StateGraph<S>>) -> Self {
76        Self {
77            graph,
78            checkpointer: None,
79            agent: None,
80            matrix_hook: None,
81        }
82    }
83
84    /// Attach a checkpointer for durable state persistence.
85    ///
86    /// Without a checkpointer, `resume()` and `get_state()` will return errors.
87    pub fn with_checkpointer(mut self, cp: impl Checkpointer + 'static) -> Self {
88        self.checkpointer = Some(Arc::new(cp));
89        self
90    }
91
92    /// Bind an agent to this compiled graph.
93    ///
94    /// The agent's identity, system prompt, and boundaries are preserved
95    /// on the compiled graph for runtime inspection and enforcement.
96    pub fn with_agent(mut self, agent: pe_core::agent::Agent) -> Self {
97        self.agent = Some(agent);
98        self
99    }
100
101    /// Get the bound agent, if any.
102    pub fn agent(&self) -> Option<&pe_core::agent::Agent> {
103        self.agent.as_ref()
104    }
105
106    /// Attach a shared checkpointer (already behind `Arc`).
107    pub fn with_checkpointer_arc(mut self, cp: Arc<dyn Checkpointer>) -> Self {
108        self.checkpointer = Some(cp);
109        self
110    }
111
112    /// Attach a matrix layer hook for convergence tracking and learned routing.
113    ///
114    /// When attached, the Pregel engine will:
115    /// - Record `ConvergenceSignal` metadata via the hook
116    /// - Consult the hook for conditional edge routing decisions
117    /// - Record transitions for learning
118    ///
119    /// Without a hook, `NodeResult::Converge` degrades to `Update` and
120    /// conditional edges use the user's router function directly.
121    pub fn with_matrix_hook(mut self, hook: crate::matrix_hook::MatrixHookHandle) -> Self {
122        self.matrix_hook = Some(hook);
123        self
124    }
125
126    /// Get a reference to the matrix hook (if attached).
127    pub fn matrix_hook(&self) -> Option<&crate::matrix_hook::MatrixHookHandle> {
128        self.matrix_hook.as_ref()
129    }
130
131    /// Run graph from START with initial state.
132    ///
133    /// If `config.checkpoint_id` is set and a checkpointer is attached,
134    /// the graph resumes from that specific checkpoint (time travel)
135    /// instead of running from the provided `state`.
136    ///
137    /// Executes the BSP loop until END, interrupt, or recursion limit.
138    #[must_use = "the execution outcome contains the final state"]
139    pub async fn invoke(
140        &self,
141        state: S,
142        config: GraphConfig,
143    ) -> Result<ExecutionOutcome<S>, PeError> {
144        self.invoke_with_lobe_runtime_services(state, config, None)
145            .await
146    }
147
148    /// Run graph from START with optional runtime-owned services for lobes.
149    #[must_use = "the execution outcome contains the final state"]
150    pub async fn invoke_with_lobe_runtime_services(
151        &self,
152        state: S,
153        config: GraphConfig,
154        lobe_runtime_service_factory: Option<Arc<dyn LobeRuntimeServiceFactory>>,
155    ) -> Result<ExecutionOutcome<S>, PeError> {
156        self.invoke_with_observer_and_lobe_runtime_services(
157            state,
158            config,
159            None,
160            None,
161            lobe_runtime_service_factory,
162        )
163        .await
164    }
165
166    /// Run graph from START with optional observer, tool observer, and runtime-owned services.
167    #[must_use = "the execution outcome contains the final state"]
168    pub async fn invoke_with_observer_and_lobe_runtime_services(
169        &self,
170        state: S,
171        config: GraphConfig,
172        observer: Option<Arc<dyn pe_core::node::NodeObserver>>,
173        tool_observer: Option<Arc<dyn pe_core::node::ToolObserver>>,
174        lobe_runtime_service_factory: Option<Arc<dyn LobeRuntimeServiceFactory>>,
175    ) -> Result<ExecutionOutcome<S>, PeError> {
176        // Time travel: if checkpoint_id is specified, resume from that checkpoint
177        if let Some(ref cp_id) = config.checkpoint_id {
178            let data = self.load_checkpoint_data(&config.thread_id, cp_id).await?;
179            let mut engine = self.make_engine(config);
180            if let Some(obs) = observer.clone() {
181                engine = engine.with_observer(obs);
182            }
183            if let Some(tobs) = tool_observer.clone() {
184                engine = engine.with_tool_observer(tobs);
185            }
186            if let Some(factory) = lobe_runtime_service_factory {
187                engine = engine.with_lobe_runtime_service_factory(factory);
188            }
189            return engine.run_from_checkpoint(data).await;
190        }
191
192        let mut engine = self.make_engine(config);
193        if let Some(obs) = observer {
194            engine = engine.with_observer(obs);
195        }
196        if let Some(tobs) = tool_observer {
197            engine = engine.with_tool_observer(tobs);
198        }
199        if let Some(factory) = lobe_runtime_service_factory {
200            engine = engine.with_lobe_runtime_service_factory(factory);
201        }
202        engine.run(state).await
203    }
204
205    /// Run graph from START with streaming support.
206    ///
207    /// Like [`invoke`](Self::invoke), but injects a type-erased stream
208    /// sender into every [`NodeContext`](pe_core::node::NodeContext)
209    /// and an optional [`NodeObserver`](pe_core::node::NodeObserver) for phase lifecycle events.
210    ///
211    /// Supports time-travel: if `config.checkpoint_id` is set, loads and
212    /// resumes from that checkpoint (mirroring [`invoke`](Self::invoke)).
213    ///
214    /// Called by pe-runtime's streaming layer — not typically used directly.
215    #[must_use = "the execution outcome contains the final state"]
216    pub async fn invoke_with_stream(
217        &self,
218        state: S,
219        config: GraphConfig,
220        stream_sender: Arc<dyn Any + Send + Sync>,
221    ) -> Result<ExecutionOutcome<S>, PeError> {
222        self.invoke_with_stream_and_observer(state, config, stream_sender, None, None)
223            .await
224    }
225
226    /// Run graph with streaming and a [`NodeObserver`](pe_core::node::NodeObserver) for lifecycle events.
227    ///
228    /// The observer receives `on_node_start` / `on_node_complete` /
229    /// `on_node_error` callbacks. pe-runtime provides `StreamingObserver`
230    /// which converts these to `StreamEvent`.
231    #[must_use = "the execution outcome contains the final state"]
232    pub async fn invoke_with_stream_and_observer(
233        &self,
234        state: S,
235        config: GraphConfig,
236        stream_sender: Arc<dyn Any + Send + Sync>,
237        observer: Option<Arc<dyn pe_core::node::NodeObserver>>,
238        tool_observer: Option<Arc<dyn pe_core::node::ToolObserver>>,
239    ) -> Result<ExecutionOutcome<S>, PeError> {
240        self.invoke_with_stream_observer_and_lobe_runtime_services(
241            state,
242            config,
243            stream_sender,
244            observer,
245            tool_observer,
246            None,
247        )
248        .await
249    }
250
251    /// Run graph with streaming, observers, and optional runtime-owned lobe services.
252    #[must_use = "the execution outcome contains the final state"]
253    pub async fn invoke_with_stream_observer_and_lobe_runtime_services(
254        &self,
255        state: S,
256        config: GraphConfig,
257        stream_sender: Arc<dyn Any + Send + Sync>,
258        observer: Option<Arc<dyn pe_core::node::NodeObserver>>,
259        tool_observer: Option<Arc<dyn pe_core::node::ToolObserver>>,
260        lobe_runtime_service_factory: Option<Arc<dyn LobeRuntimeServiceFactory>>,
261    ) -> Result<ExecutionOutcome<S>, PeError> {
262        // Time-travel: mirror the checkpoint-based resume from invoke()
263        if let Some(ref cp_id) = config.checkpoint_id {
264            let data = self.load_checkpoint_data(&config.thread_id, cp_id).await?;
265            let mut engine = self.make_engine(config).with_stream_sender(stream_sender);
266            if let Some(obs) = observer {
267                engine = engine.with_observer(obs);
268            }
269            if let Some(tobs) = tool_observer {
270                engine = engine.with_tool_observer(tobs);
271            }
272            if let Some(factory) = lobe_runtime_service_factory {
273                engine = engine.with_lobe_runtime_service_factory(factory);
274            }
275            return engine.run_from_checkpoint(data).await;
276        }
277
278        let mut engine = self.make_engine(config).with_stream_sender(stream_sender);
279        if let Some(obs) = observer {
280            engine = engine.with_observer(obs);
281        }
282        if let Some(tobs) = tool_observer {
283            engine = engine.with_tool_observer(tobs);
284        }
285        if let Some(factory) = lobe_runtime_service_factory {
286            engine = engine.with_lobe_runtime_service_factory(factory);
287        }
288        engine.run(state).await
289    }
290
291    /// Resume a previously interrupted graph with human input.
292    ///
293    /// Loads the latest checkpoint for the thread, applies the input update,
294    /// and continues execution from where it paused.
295    ///
296    #[must_use = "the execution outcome contains the final state"]
297    pub async fn resume(
298        &self,
299        thread_id: &str,
300        input: S::Update,
301        config: GraphConfig,
302    ) -> Result<ExecutionOutcome<S>, PeError> {
303        if thread_id != config.thread_id {
304            return Err(PeError::GraphValue {
305                details: format!(
306                    "resume() thread_id '{}' does not match config.thread_id '{}'",
307                    thread_id, config.thread_id
308                ),
309            });
310        }
311
312        let cp = self.checkpointer.as_ref().ok_or(PeError::Storage {
313            details: "Cannot resume without a checkpointer".into(),
314        })?;
315
316        let (bytes, meta) =
317            cp.load_latest(thread_id)
318                .await?
319                .ok_or(PeError::CheckpointNotFound {
320                    thread_id: thread_id.to_string(),
321                })?;
322
323        let mut data: CheckpointData<S> =
324            serde_json::from_slice(&bytes).map_err(|e| PeError::Storage {
325                details: format!("Checkpoint deserialization failed: {e}"),
326            })?;
327        // Seed lineage — checkpoint_id is #[serde(skip)] so we inject it here
328        data.checkpoint_id = Some(meta.id.clone());
329
330        // Apply human input to the checkpointed state
331        data.state.apply(input);
332
333        // The old resume() API continues from successors, not re-running the
334        // interrupted node. Resolve fixed-edge successors.
335        if let Some(ref interrupted) = data.interrupted_node {
336            let successors = self.graph.fixed_successors(interrupted);
337            if !successors.is_empty() {
338                data.next_nodes = successors;
339            }
340        }
341
342        self.make_engine(config).run_from_checkpoint(data).await
343    }
344
345    /// Resume a previously interrupted graph using a [`Command`].
346    ///
347    /// This is the preferred resume API. It loads the latest checkpoint,
348    /// applies the command (human input, goto, or state update), and
349    /// continues execution.
350    ///
351    /// For `Command::Resume`, the human input is stored in the phase state
352    /// so nodes can access it via `PhaseStateStore::get::<HumanInput>()`.
353    ///
354    /// # Example
355    ///
356    /// ```ignore
357    /// let cmd = Command::resume(HumanInput { approved: true, feedback: None, data: None });
358    /// let outcome = graph.resume_with("thread-1", cmd, config).await?;
359    /// ```
360    #[must_use = "the execution outcome contains the final state"]
361    pub async fn resume_with(
362        &self,
363        thread_id: &str,
364        command: Command,
365        config: GraphConfig,
366    ) -> Result<ExecutionOutcome<S>, PeError> {
367        if thread_id != config.thread_id {
368            return Err(PeError::GraphValue {
369                details: format!(
370                    "resume() thread_id '{}' does not match config.thread_id '{}'",
371                    thread_id, config.thread_id
372                ),
373            });
374        }
375
376        let data = self.load_and_apply_command(thread_id, command).await?;
377        self.make_engine(config).run_from_checkpoint(data).await
378    }
379
380    /// Resume with streaming and observer support.
381    ///
382    /// Like [`resume_with`](Self::resume_with), but injects a stream sender
383    /// and optional observer for lifecycle events.
384    #[must_use = "the execution outcome contains the final state"]
385    pub async fn resume_with_stream(
386        &self,
387        thread_id: &str,
388        command: Command,
389        config: GraphConfig,
390        stream_sender: Arc<dyn Any + Send + Sync>,
391        observer: Option<Arc<dyn pe_core::node::NodeObserver>>,
392        tool_observer: Option<Arc<dyn pe_core::node::ToolObserver>>,
393    ) -> Result<ExecutionOutcome<S>, PeError> {
394        if thread_id != config.thread_id {
395            return Err(PeError::GraphValue {
396                details: format!(
397                    "resume() thread_id '{}' does not match config.thread_id '{}'",
398                    thread_id, config.thread_id
399                ),
400            });
401        }
402
403        let data = self.load_and_apply_command(thread_id, command).await?;
404        let mut engine = self.make_engine(config).with_stream_sender(stream_sender);
405        if let Some(obs) = observer {
406            engine = engine.with_observer(obs);
407        }
408        if let Some(tobs) = tool_observer {
409            engine = engine.with_tool_observer(tobs);
410        }
411        engine.run_from_checkpoint(data).await
412    }
413
414    /// Create a base Pregel engine with the matrix hook applied (if attached).
415    fn make_engine(&self, config: GraphConfig) -> PregelEngine<S> {
416        let mut engine =
417            PregelEngine::new(Arc::clone(&self.graph), config, self.checkpointer.clone());
418        if let Some(ref hook) = self.matrix_hook {
419            engine = engine.with_matrix_hook(hook.clone());
420        }
421        engine
422    }
423
424    /// Load a specific checkpoint by ID (for time-travel).
425    ///
426    /// Shared between `invoke` and `invoke_with_stream_and_observer` to
427    /// avoid duplicating the checkpoint loading + deserialization logic.
428    async fn load_checkpoint_data(
429        &self,
430        thread_id: &str,
431        checkpoint_id: &str,
432    ) -> Result<CheckpointData<S>, PeError> {
433        let cp = self.checkpointer.as_ref().ok_or(PeError::Storage {
434            details: "Cannot time-travel without a checkpointer".into(),
435        })?;
436        let bytes =
437            cp.load_by_id(thread_id, checkpoint_id)
438                .await?
439                .ok_or(PeError::CheckpointNotFound {
440                    thread_id: format!("{}@{}", thread_id, checkpoint_id),
441                })?;
442        serde_json::from_slice(&bytes).map_err(|e| PeError::Storage {
443            details: format!("Checkpoint deserialization failed: {e}"),
444        })
445    }
446
447    /// Load checkpoint and apply a command to it.
448    ///
449    /// Shared logic between `resume_with` and `resume_with_stream`.
450    /// Handles all three command variants: Resume (stores HumanInput in
451    /// phase state), Goto (overrides next_nodes), Update (deserializes
452    /// JSON and applies to state, then resolves successors).
453    async fn load_and_apply_command(
454        &self,
455        thread_id: &str,
456        command: Command,
457    ) -> Result<CheckpointData<S>, PeError> {
458        let cp = self.checkpointer.as_ref().ok_or(PeError::Storage {
459            details: "Cannot resume without a checkpointer".into(),
460        })?;
461
462        let (bytes, meta) =
463            cp.load_latest(thread_id)
464                .await?
465                .ok_or(PeError::CheckpointNotFound {
466                    thread_id: thread_id.to_string(),
467                })?;
468
469        let mut data: CheckpointData<S> =
470            serde_json::from_slice(&bytes).map_err(|e| PeError::Storage {
471                details: format!("Checkpoint deserialization failed: {e}"),
472            })?;
473        // Seed lineage for the engine
474        data.checkpoint_id = Some(meta.id.clone());
475
476        match command {
477            Command::Resume { human_input } => {
478                // Store human input in the phase state so resumed nodes can
479                // access it via PhaseStateStore::get::<HumanInput>().
480                data.phase_state
481                    .set(&human_input)
482                    .map_err(|e| PeError::Storage {
483                        details: format!("Failed to store human input: {e}"),
484                    })?;
485            }
486            Command::Goto { node } => {
487                if !self.graph.nodes.contains_key(&node) {
488                    return Err(PeError::GraphValue {
489                        details: format!("Goto target node '{}' does not exist", node),
490                    });
491                }
492                data.next_nodes = vec![node];
493            }
494            Command::Update { update } => {
495                let typed_update: S::Update =
496                    serde_json::from_value(update).map_err(|e| PeError::InvalidUpdate {
497                        details: format!("Command::Update deserialization failed: {e}"),
498                    })?;
499                data.state.apply(typed_update);
500                // After applying the update, skip re-running the interrupted node.
501                // Resolve its successors using fixed edges so execution continues.
502                if let Some(ref interrupted) = data.interrupted_node {
503                    let successors = self.graph.fixed_successors(interrupted);
504                    if !successors.is_empty() {
505                        data.next_nodes = successors;
506                    }
507                }
508            }
509        }
510
511        Ok(data)
512    }
513
514    /// Get the current state snapshot for a thread.
515    ///
516    /// Returns `None` if no checkpoints exist for this thread.
517    #[must_use = "the snapshot contains the state — inspect it"]
518    pub async fn get_state(&self, thread_id: &str) -> Result<Option<StateSnapshot<S>>, PeError> {
519        let Some(ref cp) = self.checkpointer else {
520            return Ok(None);
521        };
522
523        let Some((bytes, meta)) = cp.load_latest(thread_id).await? else {
524            return Ok(None);
525        };
526
527        deserialize_snapshot(bytes, meta)
528    }
529
530    /// Get full history of all checkpoints for a thread (time travel).
531    ///
532    /// Returns snapshots oldest-first. Each snapshot contains the full
533    /// state at that point, which nodes were scheduled next, and metadata.
534    #[must_use = "the history contains all past states"]
535    pub async fn get_state_history(
536        &self,
537        thread_id: &str,
538    ) -> Result<Vec<StateSnapshot<S>>, PeError> {
539        let Some(ref cp) = self.checkpointer else {
540            return Ok(Vec::new());
541        };
542
543        let metas = cp.list(thread_id).await?;
544        let mut snapshots = Vec::with_capacity(metas.len());
545
546        for meta in metas {
547            let Some(bytes) = cp.load_by_id(thread_id, &meta.id).await? else {
548                continue;
549            };
550            if let Ok(Some(snapshot)) = deserialize_snapshot(bytes, meta) {
551                snapshots.push(snapshot);
552            }
553        }
554
555        Ok(snapshots)
556    }
557}
558
559/// Deserialize checkpoint bytes into a StateSnapshot.
560fn deserialize_snapshot<S: State>(
561    bytes: Vec<u8>,
562    meta: crate::checkpointer::CheckpointMeta,
563) -> Result<Option<StateSnapshot<S>>, PeError> {
564    let data: CheckpointData<S> = serde_json::from_slice(&bytes).map_err(|e| PeError::Storage {
565        details: format!("Checkpoint deserialization failed: {e}"),
566    })?;
567
568    Ok(Some(StateSnapshot {
569        state: data.state,
570        checkpoint_id: meta.id.clone(),
571        step: data.step,
572        thread_id: meta.thread_id,
573        parent_checkpoint_id: meta.parent_id.clone(),
574        created_at: meta.created_at, // Use checkpoint time, not current time
575        next_nodes: data.next_nodes,
576    }))
577}