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}