Skip to main content

app_kit/workflow/
history.rs

1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use serde_json::{Map, Value};
3
4use crate::workflow::run::RunRecord;
5use crate::workflow::{RunId, RunSelector, RunStatus, WorkflowId, WorkflowState};
6use crate::{AppError, WorkspaceId};
7
8/// The result of one successfully committed action, distinct from user state.
9///
10/// # Example
11///
12/// ```
13/// # use agents_kit::Agent;
14/// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
15/// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
16/// #     RunStatus, WorkflowDefinition, WorkflowState};
17/// # use storage_kit::StateStore;
18/// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
19/// #
20/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
21/// # runtime.block_on(async {
22/// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
23/// # let definition = WorkflowDefinition::parse(r#"{
24/// #     "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
25/// #     "state":{"count":0}, "required":["count"],
26/// #     "reducers":{"count":"reject_concurrent"}, "entry":"increment",
27/// #     "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
28/// #         {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
29/// #     ]}}], "routes":{}
30/// # }"#)?;
31/// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
32/// # let mut app = App::open(config).await?;
33/// # let mut workspace = app.create_workspace(WorkspaceConfig {
34/// #     name: Some("local counter".into()), root: std::env::current_dir()?,
35/// # }).await?;
36/// # let workflow = workspace.workflow("counter".parse()?).await?;
37/// # let mut run = workflow.prepare(WorkflowState::default()).await?;
38/// let terminal = run.execution(ExecutionOptions::default()).await?.finish().await?;
39/// let checkpoint = run.checkpoint(CheckpointSelector::Action(0)).await?;
40/// let outcome = &checkpoint.outcomes[0];
41/// assert_eq!(outcome.node, "increment");
42/// assert_eq!(outcome.ordinal, 0);
43/// #
44/// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
45/// # Ok::<(), Box<dyn std::error::Error>>(())
46/// # })?;
47/// # Ok::<(), Box<dyn std::error::Error>>(())
48/// ```
49#[non_exhaustive]
50#[derive(Clone, Deserialize, PartialEq, Serialize)]
51pub struct ActionOutcome {
52    /// Node that produced this result.
53    pub node: String,
54    /// Semantic action ordinal, independent of control-checkpoint sequence.
55    pub ordinal: u64,
56    /// Exact result fields, distinct from durable workflow state.
57    pub result: Map<String, Value>,
58}
59
60/// The boundary represented by one retained checkpoint.
61pub enum CheckpointKind {
62    /// Initial state admitted before any action executes.
63    Initial,
64    /// State-preserving control transition.
65    Control,
66    /// Successful action commit.
67    Action,
68    /// A failed action observation; committed state and position remain unchanged.
69    Failed {
70        /// Failed activation, or the canonical representative of a failed BSP wave.
71        node: String,
72        /// Proposed action ordinal of that failed activation.
73        ordinal: u64,
74    },
75    /// Terminal workflow transition.
76    Terminal,
77}
78
79/// A committed workflow boundary and its original run identity.
80///
81/// # Example
82///
83/// ```
84/// # use agents_kit::Agent;
85/// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
86/// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
87/// #     RunStatus, WorkflowDefinition, WorkflowState};
88/// # use storage_kit::StateStore;
89/// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
90/// #
91/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
92/// # runtime.block_on(async {
93/// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
94/// # let definition = WorkflowDefinition::parse(r#"{
95/// #     "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
96/// #     "state":{"count":0}, "required":["count"],
97/// #     "reducers":{"count":"reject_concurrent"}, "entry":"increment",
98/// #     "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
99/// #         {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
100/// #     ]}}], "routes":{}
101/// # }"#)?;
102/// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
103/// # let mut app = App::open(config).await?;
104/// # let mut workspace = app.create_workspace(WorkspaceConfig {
105/// #     name: Some("local counter".into()), root: std::env::current_dir()?,
106/// # }).await?;
107/// # let workflow = workspace.workflow("counter".parse()?).await?;
108/// # let mut run = workflow.prepare(WorkflowState::default()).await?;
109/// let terminal = run.execution(ExecutionOptions::default()).await?.finish().await?;
110/// let checkpoint = run.checkpoint(CheckpointSelector::Action(0)).await?;
111/// assert_eq!(checkpoint.action_ordinal, Some(0));
112/// assert_eq!(checkpoint.position.as_deref(), Some("increment"));
113/// #
114/// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
115/// # Ok::<(), Box<dyn std::error::Error>>(())
116/// # })?;
117/// # Ok::<(), Box<dyn std::error::Error>>(())
118/// ```
119#[non_exhaustive]
120pub struct Checkpoint {
121    /// Run containing this committed boundary.
122    pub run: RunId,
123    /// Exact admitted occurrence containing this boundary.
124    pub occurrence: String,
125    /// Monotonic checkpoint sequence within the run.
126    pub sequence: u64,
127    /// Kind of committed boundary.
128    pub kind: CheckpointKind,
129    /// Logical workflow identity.
130    pub workflow: WorkflowId,
131    /// Exact workflow revision used by this run.
132    pub revision: String,
133    /// Last successfully committed action ordinal, if any.
134    pub action_ordinal: Option<u64>,
135    /// Last successfully committed action position, if any.
136    pub position: Option<String>,
137    /// Immutable committed user state.
138    pub state: WorkflowState,
139    /// Durable lifecycle at this boundary. Historical reads do not reconstruct
140    /// actionable targets; a running boundary may contain a pending control.
141    pub status: RunStatus,
142    /// Results committed at this boundary; control transitions invent none.
143    pub outcomes: Vec<ActionOutcome>,
144    /// Source boundary when this run was restored into a new identity.
145    pub lineage: Option<RunLineage>,
146}
147
148/// The source boundary from which a fresh run was restored.
149///
150/// # Example
151///
152/// ```
153/// # use agents_kit::Agent;
154/// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
155/// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
156/// #     RunStatus, WorkflowDefinition, WorkflowState};
157/// # use storage_kit::StateStore;
158/// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
159/// #
160/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
161/// # runtime.block_on(async {
162/// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
163/// # let definition = WorkflowDefinition::parse(r#"{
164/// #     "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
165/// #     "state":{"count":0}, "required":["count"],
166/// #     "reducers":{"count":"reject_concurrent"}, "entry":"increment",
167/// #     "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
168/// #         {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
169/// #     ]}}], "routes":{}
170/// # }"#)?;
171/// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
172/// # let mut app = App::open(config).await?;
173/// # let mut workspace = app.create_workspace(WorkspaceConfig {
174/// #     name: Some("local counter".into()), root: std::env::current_dir()?,
175/// # }).await?;
176/// # let workflow = workspace.workflow("counter".parse()?).await?;
177/// # let mut run = workflow.prepare(WorkflowState::default()).await?;
178/// let terminal = run.execution(ExecutionOptions::default()).await?.finish().await?;
179/// use app_kit::workflow::{RestoreOptions, ResumePosition};
180/// let options = RestoreOptions {
181///     source: RunSelector::Latest(run.id().clone()),
182///     checkpoint: CheckpointSelector::Action(0),
183///     overrides: WorkflowState::default(), position: ResumePosition::AfterCompleted,
184///     fallback: None,
185/// };
186/// let copy = workflow.restore(options).await?;
187/// assert_ne!(copy.id(), run.id());
188/// let copied = copy.checkpoint(CheckpointSelector::Sequence(0)).await?;
189/// assert_eq!(copied.state.values()["count"], 1);
190/// assert_eq!(&copied.lineage.as_ref().unwrap().source, run.id());
191/// #
192/// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
193/// # Ok::<(), Box<dyn std::error::Error>>(())
194/// # })?;
195/// # Ok::<(), Box<dyn std::error::Error>>(())
196/// ```
197#[non_exhaustive]
198pub struct RunLineage {
199    /// Original run; restoring does not overwrite this identity.
200    pub source: RunId,
201    /// Exact occurrence of the selected source boundary.
202    pub source_occurrence: String,
203    /// Selected committed source checkpoint.
204    pub checkpoint: u64,
205    /// Selected action ordinal, distinct from the source's last committed ordinal.
206    ///
207    /// An explicit action selector preserves its ordinal. Other selectors use
208    /// a failed observation's attempted ordinal or the last committed ordinal.
209    pub action_ordinal: Option<u64>,
210}
211
212/// One scoped retained-history selection, independent of storage revisions.
213///
214/// Its serialized form uses `kind` and, for sequence/action selections, `value`.
215#[derive(Clone, Deserialize, PartialEq, Serialize)]
216#[serde(
217    tag = "kind",
218    content = "value",
219    rename_all = "snake_case",
220    deny_unknown_fields
221)]
222pub enum CheckpointSelector {
223    /// The latest eligible observation in the selected operation.
224    ///
225    /// Run checkpoint reads use the current App checkpoint. Restore and child
226    /// initializers use the latest saved successful or failed action.
227    Latest,
228    /// One exact immutable checkpoint sequence.
229    Sequence(u64),
230    /// The latest successful or failed observation of this action ordinal.
231    Action(u64),
232}
233
234/// The saved execution boundary used when restoring a fresh run.
235///
236/// Serializes as `after_completed`, `at_saved`, or `entrypoint`.
237#[derive(Clone, Deserialize, PartialEq, Serialize)]
238#[serde(rename_all = "snake_case")]
239pub enum ResumePosition {
240    /// Continue after the selected successful action or complete BSP wave.
241    AfterCompleted,
242    /// Retry the saved action or complete uncommitted BSP wave.
243    AtSaved,
244    /// Restart from the definition's entrypoint without prior-action selection.
245    Entrypoint,
246}
247
248/// Selects and copies clean retained state into a new, parked run.
249///
250/// A fallback is used only when the source or selected checkpoint is absent; it
251/// does not silently replace uncertainty or receive overrides intended for loaded
252/// state. A successful restore copies clean state into a fresh paused run, so
253/// later source deletion cannot strand the copy.
254///
255/// # Example
256///
257/// ```
258/// # use agents_kit::Agent;
259/// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
260/// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
261/// #     RunStatus, WorkflowDefinition, WorkflowState};
262/// # use storage_kit::StateStore;
263/// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
264/// #
265/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
266/// # runtime.block_on(async {
267/// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
268/// # let definition = WorkflowDefinition::parse(r#"{
269/// #     "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
270/// #     "state":{"count":0}, "required":["count"],
271/// #     "reducers":{"count":"reject_concurrent"}, "entry":"increment",
272/// #     "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
273/// #         {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
274/// #     ]}}], "routes":{}
275/// # }"#)?;
276/// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
277/// # let mut app = App::open(config).await?;
278/// # let mut workspace = app.create_workspace(WorkspaceConfig {
279/// #     name: Some("local counter".into()), root: std::env::current_dir()?,
280/// # }).await?;
281/// # let workflow = workspace.workflow("counter".parse()?).await?;
282/// # let mut run = workflow.prepare(WorkflowState::default()).await?;
283/// let terminal = run.execution(ExecutionOptions::default()).await?.finish().await?;
284/// use app_kit::workflow::{RestoreOptions, ResumePosition};
285/// let options = RestoreOptions {
286///     source: RunSelector::Latest(run.id().clone()),
287///     checkpoint: CheckpointSelector::Action(0),
288///     overrides: WorkflowState::default(), position: ResumePosition::AfterCompleted,
289///     fallback: None,
290/// };
291/// let copy = workflow.restore(options).await?;
292/// assert_ne!(copy.id(), run.id());
293/// let copied = copy.checkpoint(CheckpointSelector::Sequence(0)).await?;
294/// assert_eq!(copied.state.values()["count"], 1);
295/// assert_eq!(&copied.lineage.as_ref().unwrap().source, run.id());
296/// #
297/// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
298/// # Ok::<(), Box<dyn std::error::Error>>(())
299/// # })?;
300/// # Ok::<(), Box<dyn std::error::Error>>(())
301/// ```
302pub struct RestoreOptions {
303    /// Original run, which is never overwritten by restore.
304    pub source: RunSelector,
305    /// Exact source selection, or the latest saved successful or failed action.
306    pub checkpoint: CheckpointSelector,
307    /// State overrides validated before admission of the copy.
308    pub overrides: WorkflowState,
309    /// Where explicit execution of the copied run begins.
310    pub position: ResumePosition,
311    /// Explicit initial state used only when the requested checkpoint is absent.
312    pub fallback: Option<WorkflowState>,
313}
314
315/// One bounded page of immutable checkpoints in ascending sequence order.
316///
317/// # Example
318///
319/// ```
320/// # use agents_kit::Agent;
321/// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
322/// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
323/// #     RunStatus, WorkflowDefinition, WorkflowState};
324/// # use storage_kit::StateStore;
325/// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
326/// #
327/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
328/// # runtime.block_on(async {
329/// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
330/// # let definition = WorkflowDefinition::parse(r#"{
331/// #     "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
332/// #     "state":{"count":0}, "required":["count"],
333/// #     "reducers":{"count":"reject_concurrent"}, "entry":"increment",
334/// #     "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
335/// #         {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
336/// #     ]}}], "routes":{}
337/// # }"#)?;
338/// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
339/// # let mut app = App::open(config).await?;
340/// # let mut workspace = app.create_workspace(WorkspaceConfig {
341/// #     name: Some("local counter".into()), root: std::env::current_dir()?,
342/// # }).await?;
343/// # let workflow = workspace.workflow("counter".parse()?).await?;
344/// # let mut run = workflow.prepare(WorkflowState::default()).await?;
345/// let terminal = run.execution(ExecutionOptions::default()).await?.finish().await?;
346/// let page = run.history(None, std::num::NonZeroUsize::new(100).unwrap()).await?;
347/// assert_eq!(page.checkpoints.first().unwrap().sequence, 0);
348/// assert!(page.next.is_none());
349/// #
350/// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
351/// # Ok::<(), Box<dyn std::error::Error>>(())
352/// # })?;
353/// # Ok::<(), Box<dyn std::error::Error>>(())
354/// ```
355#[non_exhaustive]
356pub struct HistoryPage {
357    /// Complete retained checkpoints from the captured head cutoff.
358    pub checkpoints: Vec<Checkpoint>,
359    /// Continues the same cutoff and scope; absent when that history is exhausted.
360    pub next: Option<HistoryCursor>,
361}
362
363/// An opaque continuation of a captured run-history view.
364///
365/// Serialization preserves its App generation, run creation scope and committed
366/// head cutoff. It is not a native storage cursor and cannot cross run scopes.
367///
368/// # Example
369///
370/// ```
371/// # use agents_kit::Agent;
372/// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
373/// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
374/// #     RunStatus, WorkflowDefinition, WorkflowState};
375/// # use storage_kit::StateStore;
376/// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
377/// #
378/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
379/// # runtime.block_on(async {
380/// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
381/// # let definition = WorkflowDefinition::parse(r#"{
382/// #     "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
383/// #     "state":{"count":0}, "required":["count"],
384/// #     "reducers":{"count":"reject_concurrent"}, "entry":"increment",
385/// #     "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
386/// #         {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
387/// #     ]}}], "routes":{}
388/// # }"#)?;
389/// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
390/// # let mut app = App::open(config).await?;
391/// # let mut workspace = app.create_workspace(WorkspaceConfig {
392/// #     name: Some("local counter".into()), root: std::env::current_dir()?,
393/// # }).await?;
394/// # let workflow = workspace.workflow("counter".parse()?).await?;
395/// # let mut run = workflow.prepare(WorkflowState::default()).await?;
396/// let terminal = run.execution(ExecutionOptions::default()).await?.finish().await?;
397/// let page = run.history(None, std::num::NonZeroUsize::new(1).unwrap()).await?;
398/// let cursor = page.next.expect("an action has committed after checkpoint zero");
399/// let encoded = serde_json::to_string(&cursor)?;
400/// let restored = serde_json::from_str(&encoded)?;
401/// let page = run.history(Some(restored), std::num::NonZeroUsize::new(100).unwrap()).await?;
402/// assert!(page.checkpoints.iter().all(|checkpoint| checkpoint.sequence > 0));
403/// #
404/// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
405/// # Ok::<(), Box<dyn std::error::Error>>(())
406/// # })?;
407/// # Ok::<(), Box<dyn std::error::Error>>(())
408/// ```
409#[derive(Clone, Debug)]
410pub struct HistoryCursor {
411    workspace: WorkspaceId,
412    workflow: WorkflowId,
413    run: RunId,
414    generation: u64,
415    creation: String,
416    cutoff: u64,
417    after: u64,
418}
419
420impl HistoryCursor {
421    pub(crate) fn issue(
422        head: &RunRecord,
423        generation: u64,
424        creation: &str,
425        after: u64,
426    ) -> Result<Self, AppError> {
427        Self {
428            workspace: head.workspace_id.clone(),
429            workflow: head.workflow_id.clone(),
430            run: head.id.clone(),
431            generation,
432            creation: creation.to_owned(),
433            cutoff: head.checkpoint.sequence,
434            after,
435        }
436        .validated()
437    }
438
439    pub(crate) fn continue_from(
440        &self,
441        head: &RunRecord,
442        generation: u64,
443        creation: &str,
444    ) -> Result<(u64, u64), AppError> {
445        if self.workspace != head.workspace_id
446            || self.workflow != head.workflow_id
447            || self.run != head.id
448            || self.generation != generation
449            || self.creation != creation
450            || self.cutoff > head.checkpoint.sequence
451        {
452            return Err(AppError::InvalidInput(
453                "history cursor scope is invalid".into(),
454            ));
455        }
456        Ok((self.cutoff, self.after))
457    }
458
459    pub(crate) fn advance_to(&self, after: u64) -> Result<Self, AppError> {
460        if after <= self.after {
461            return Err(AppError::Integration("history page did not advance".into()));
462        }
463        Self {
464            after,
465            ..self.clone()
466        }
467        .validated()
468    }
469
470    fn validated(self) -> Result<Self, AppError> {
471        if self.generation == 0
472            || self.creation.len() != 32
473            || !self
474                .creation
475                .bytes()
476                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
477            || self.after >= self.cutoff
478        {
479            return Err(AppError::InvalidInput("invalid history cursor".into()));
480        }
481        Ok(self)
482    }
483}
484
485impl Serialize for HistoryCursor {
486    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
487        (
488            1u8,
489            &self.workspace,
490            &self.workflow,
491            &self.run,
492            self.generation,
493            &self.creation,
494            self.cutoff,
495            self.after,
496        )
497            .serialize(serializer)
498    }
499}
500
501impl<'de> Deserialize<'de> for HistoryCursor {
502    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
503        let (version, workspace, workflow, run, generation, creation, cutoff, after) =
504            <(u8, WorkspaceId, WorkflowId, RunId, u64, String, u64, u64)>::deserialize(
505                deserializer,
506            )?;
507        if version != 1 {
508            return Err(serde::de::Error::custom("invalid history cursor"));
509        }
510        Self {
511            workspace,
512            workflow,
513            run,
514            generation,
515            creation,
516            cutoff,
517            after,
518        }
519        .validated()
520        .map_err(serde::de::Error::custom)
521    }
522}