app_kit/workflow/mod.rs
1//! Workflow authoring, execution, immutable state, and retained history.
2//!
3//! Parse strict versioned JSON with
4//! [`WorkflowDefinition::parse`](crate::workflow::WorkflowDefinition::parse), inspect its
5//! [`GraphDescription`](crate::workflow::GraphDescription), and transfer definitions
6//! through [`crate::AppConfig`].
7//! [`Workflow::start`](crate::workflow::Workflow::start) drives accepted work in the background;
8//! [`Workflow::prepare`](crate::workflow::Workflow::prepare) admits checkpoint zero for explicit
9//! [`WorkflowRun::execution`](crate::workflow::WorkflowRun::execution) or
10//! [`SyncExecution`](crate::workflow::SyncExecution). Synchronous calls require an outside thread and a runtime
11//! that stays driven elsewhere. Both lanes share the same commit authority.
12//!
13//! [`ExecutionEvent::Partial`](crate::workflow::ExecutionEvent::Partial) never commits state.
14//! [`ExecutionEvent::Committed`](crate::workflow::ExecutionEvent::Committed)
15//! identifies a durable boundary; action ordinals and checkpoint sequences are
16//! different coordinates. [`WorkflowRun::history`](crate::workflow::WorkflowRun::history)
17//! reads a fixed cutoff, and [`Workflow::restore`](crate::workflow::Workflow::restore)
18//! copies clean retained state into a new paused run.
19//! History remains until explicit [`WorkflowRun::delete`](crate::workflow::WorkflowRun::delete).
20//!
21//! Concrete behavior belongs to [`ActionBackend`],
22//! [`ConditionBackend`], [`StateTyping`],
23//! [`LifecycleHooks`] and [`ChildExecutor`].
24//! The child executor's
25//! associated future can remain local/non-Send without future erasure.
26//! [`StateSerialization`](crate::workflow::StateSerialization) separately registers JSON field/tag codecs only.
27//! Supporting DTOs describe inputs, results and controls; runtime ownership
28//! stays private. Run observers do not cancel work when dropped; execution owners
29//! revoke uncommitted advancement while preserving admitted native commits.
30
31//! # Execute, observe, restore and delete
32//!
33//! After the [introductory workflow](crate#local-workflow), use these operations
34//! for explicit control and retained results:
35//!
36//! - Prepare a paused run, then drive
37//! [`WorkflowRun::execution`](crate::workflow::WorkflowRun::execution);
38//! reading a snapshot alone never advances it.
39//! - Page immutable [`WorkflowRun::history`](crate::workflow::WorkflowRun::history)
40//! at a fixed cutoff.
41//! - [`Workflow::restore`] copies a checkpoint into an independent paused run;
42//! [`WorkflowRun::delete`](crate::workflow::WorkflowRun::delete) removes only
43//! the selected run's retained history.
44//!
45//! The complete example below joins these three tasks without credentials, an
46//! Agent value or a subprocess.
47//!
48//! ```
49//! use agents_kit::Agent;
50//! use app_kit::{App, AppConfig, AppError, Shutdown, WorkspaceConfig};
51//! use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions,
52//! RestoreOptions, ResumePosition, RunSelector, RunStatus, WorkflowDefinition, WorkflowState};
53//! use storage_kit::StateStore;
54//! use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
55//! use std::{num::NonZeroUsize, time::Duration};
56//! # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
57//! # runtime.block_on(async {
58//! let store = StateStore::connect(StateStoreConfig::lance(LanceStateStoreConfig::memory())).await?;
59//! let definition = WorkflowDefinition::parse(r#"{
60//! "schema":"app-kit.workflow/v2","id":"counter","revision":"r1",
61//! "state":{"count":0},"required":["count"],"reducers":{"count":"reject_concurrent"},
62//! "entry":"increment","nodes":[{"id":"increment","step":{"kind":"state",
63//! "mutations":[{"kind":"increment","target":"count","value":{"kind":"literal","value":1}}]}}],
64//! "routes":{}
65//! }"#)?;
66//! let mut app = App::open(AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition])).await?;
67//! let outcome = async {
68//! let workspace = app.create_workspace(WorkspaceConfig {
69//! name: Some("history example".into()), root: std::env::current_dir()?,
70//! }).await?;
71//! let workflow = workspace.workflow("counter".parse()?).await?;
72//! let mut run = workflow.prepare(WorkflowState::default()).await?;
73//! assert!(matches!(run.snapshot().await?.status, RunStatus::Paused));
74//! assert_eq!(run.checkpoint(CheckpointSelector::Sequence(0)).await?.state.values()["count"], 0);
75//! let mut execution = run.execution(ExecutionOptions::default()).await?;
76//! let mut committed_actions = 0;
77//! while let Some(event) = execution.next().await? {
78//! if let ExecutionEvent::Committed { outcomes, .. } = event {
79//! committed_actions += outcomes.len();
80//! }
81//! }
82//! let terminal = execution.finish().await?;
83//! assert!(matches!(terminal.status, RunStatus::Succeeded));
84//! assert_eq!(terminal.state.values()["count"], 1);
85//! assert_eq!(committed_actions, 1);
86//!
87//! // Read history in bounded pages.
88//! let first = run.history(None, NonZeroUsize::new(1).unwrap()).await?;
89//! assert_eq!(first.checkpoints[0].sequence, 0);
90//! let cursor = first.next.expect("the action has a later checkpoint");
91//! let later = run.history(Some(cursor), NonZeroUsize::new(10).unwrap()).await?;
92//! assert!(later.checkpoints.iter().any(|checkpoint| checkpoint.action_ordinal == Some(0)));
93//! // Restore an independent run, then remove both histories explicitly.
94//! let mut copy = workflow.restore(RestoreOptions {
95//! source: RunSelector::Latest(run.id().clone()), checkpoint: CheckpointSelector::Action(0),
96//! overrides: WorkflowState::default(), position: ResumePosition::AfterCompleted, fallback: None,
97//! }).await?;
98//! assert_ne!(copy.id(), run.id());
99//! assert!(matches!(copy.snapshot().await?.status, RunStatus::Paused));
100//! let copied = copy.checkpoint(CheckpointSelector::Sequence(0)).await?;
101//! assert_eq!(copied.state.values()["count"], 1);
102//! assert_eq!(&copied.lineage.as_ref().unwrap().source, run.id());
103//! let copied_terminal = copy.execution(ExecutionOptions::default()).await?.finish().await?;
104//! assert!(matches!(copied_terminal.status, RunStatus::Succeeded));
105//! copy.delete(copied_terminal.checkpoint).await?;
106//! let original_id = run.id().clone();
107//! run.delete(terminal.checkpoint).await?;
108//! assert!(matches!(workflow.open_run(RunSelector::Latest(original_id)).await, Err(AppError::NotFound(_))));
109//! Ok::<(), Box<dyn std::error::Error>>(())
110//! }.await;
111//! let cleanup = app.shutdown(Shutdown::CancelAndDrain { timeout: Duration::from_secs(5) }).await;
112//! if let Err(error) = &cleanup { eprintln!("App shutdown: {error}"); }
113//! outcome?; // An execution error remains primary if shutdown also reports an error.
114//! cleanup?;
115//! # Ok::<(), Box<dyn std::error::Error>>(())
116//! # })?;
117//! # Ok::<(), Box<dyn std::error::Error>>(())
118//! ```
119//!
120//! A real Agent-backed workflow can stop at an actionable interaction rather than
121//! success: inspect [`crate::workflow::RunSnapshot::interactions`] and [`crate::workflow::RunStatus`] before choosing
122//! an explicit resolution. Caller actions own their external effects; a discarded
123//! uncommitted workflow state change does not undo external I/O.
124//!
125//! # Configure caller behavior
126//!
127//! [`crate::AppConfig`]'s configuration map links the complete implementations of
128//! all five behavior traits and their consuming attachment methods. Actions and
129//! conditions use exact declared keys/revisions; typing validates initial and
130//! resulting state; hooks observe lifecycle boundaries; child executors drive the
131//! already-admitted concrete child futures. None changes the lower Agent or
132//! StateStore associated child types. See [`crate::App::open`] for durable reopen
133//! and the cold migration boundary.
134
135use std::fmt;
136use std::num::NonZeroUsize;
137use std::str::FromStr;
138use std::sync::{Arc, RwLock};
139
140use agents_kit::AgentBackend;
141use agents_kit::notification::{ConversationNotification, NotificationResolution};
142use serde::{Deserialize, Deserializer, Serialize, Serializer};
143use serde_json::{Map, Value};
144use sha2::{Digest, Sha256};
145use storage_kit::StateStoreBackend;
146use uuid::Uuid;
147
148use crate::{AppError, IdentitySyntax};
149
150mod action;
151mod child;
152pub(crate) mod definition;
153mod execution;
154pub(crate) use execution::{MAX_FRONTIER, StagedOutcomeRecord};
155mod graph;
156mod history;
157pub(crate) mod interaction;
158mod lifecycle;
159pub(crate) mod run;
160mod serialization;
161mod state;
162
163pub use action::{
164 ActionBackend, ActionContext, ActionDeclaration, ActionEvent, ActionMode, ActionRequest,
165 ConditionBackend, ConditionDeclaration, NoActions, NoConditions,
166};
167pub use child::{ChildBatch, ChildCompletion, ChildExecutor, InlineExecutor};
168pub use history::{
169 ActionOutcome, Checkpoint, CheckpointKind, CheckpointSelector, HistoryCursor, HistoryPage,
170 RestoreOptions, ResumePosition, RunLineage,
171};
172pub use lifecycle::{LifecycleEvent, LifecycleHooks, NoHooks};
173pub use run::session::{Execution, SyncExecution};
174
175/// Immutable, ordered workflow values validated at each operation boundary.
176///
177/// ```
178/// use app_kit::workflow::{JsonState, StateChange, WorkflowState};
179/// use serde_json::{Map, Value};
180/// let before = WorkflowState::new(Map::from_iter([("count".into(), Value::from(1))]))?;
181/// let after = before.apply(&StateChange::Increment {
182/// values: vec![("count".into(), Value::from(1))],
183/// }, &JsonState::default())?;
184/// assert_eq!(before.values()["count"], 1);
185/// assert_eq!(after.values()["count"], 2);
186/// # Ok::<(), app_kit::AppError>(())
187/// ```
188///
189/// Changes go through the validated state operations:
190///
191/// ```compile_fail
192/// use app_kit::workflow::WorkflowState;
193/// let mut state = WorkflowState::default();
194/// state.values.clear();
195/// ```
196#[derive(Clone, Debug, Default, PartialEq)]
197pub struct WorkflowState {
198 values: Map<String, Value>,
199}
200
201/// Process-global field encoders, field decoders and tagged JSON decoders.
202///
203/// Registrations are exact names and replacements affect the next operation.
204/// Each operation retains one registry snapshot and invokes callbacks without
205/// holding its lock. The only construction entrypoint is [`Self::global`].
206///
207/// ```
208/// use app_kit::workflow::StateSerialization;
209/// StateSerialization::global().register_tag("text".into(), |value| {
210/// value.get("value").cloned().ok_or("missing text value")
211/// })?;
212/// # Ok::<(), app_kit::AppError>(())
213/// ```
214///
215/// Only field/tag JSON codecs enter this registry; actions, hooks, child executors
216/// and lower backends remain concrete configuration values. Registration does not
217/// persist callable identity; callers version their own portable codec payloads.
218pub struct StateSerialization {
219 registry: RwLock<Arc<serialization::SerializationRegistry>>,
220}
221
222/// A resolved immutable state operation; expressions remain private syntax.
223///
224/// Each payload is one atomic operation with at most 4,096 entries. Repeated
225/// Append/Extend/Increment keys apply in order; any later failure discards the
226/// whole candidate. Merge and Subset use full-state validation rather than
227/// primitive transition callbacks. Results never mutate the original state.
228pub enum StateChange {
229 /// Replaces values atomically in payload order, preserving existing positions.
230 Assign {
231 /// Complete values to replace or insert.
232 values: Map<String, Value>,
233 },
234 /// Appends one item per entry, creating arrays for missing keys.
235 Append {
236 /// Ordered entries; repeated keys append repeatedly within one operation.
237 values: Vec<(String, Value)>,
238 },
239 /// Extends arrays atomically without recursively flattening their items.
240 Extend {
241 /// Ordered entries; repeated keys preserve every supplied item.
242 values: Vec<(String, Vec<Value>)>,
243 },
244 /// Adds signed integer deltas to integer values, or zero for missing keys.
245 Increment {
246 /// Ordered deltas, each representable as i64; repeated keys fold in order.
247 values: Vec<(String, Value)>,
248 },
249 /// Removes keys atomically, preserving the order of other keys.
250 Remove {
251 /// Missing and repeated names are harmless.
252 keys: Vec<String>,
253 },
254 /// Keeps keys in their existing order; typing observes resolved Remove keys.
255 Wipe {
256 /// Keys to retain; missing and repeated names are harmless.
257 keep: Vec<String>,
258 },
259 /// Replaces overlapping values and appends new keys in incoming order.
260 Merge {
261 /// Values to merge into the current state.
262 state: WorkflowState,
263 },
264 /// Selects present keys in the requested order.
265 Subset {
266 /// Ordered keys to select; missing keys are ignored.
267 keys: Vec<String>,
268 },
269}
270
271/// Caller-owned typed construction and state validation.
272///
273/// Data is created and consumed locally and need not be Clone, Send or serializable.
274/// The default roundtrip checks successful reconstruction, not raw equality.
275///
276/// # Example
277///
278/// ```
279/// use app_kit::{AppError, workflow::{StateTyping, StateChange, WorkflowState}};
280/// use serde_json::{Map, Value};
281/// struct Counter;
282/// impl StateTyping for Counter {
283/// type Data = i64;
284/// fn construct_data(&self, state: &WorkflowState) -> Result<i64, AppError> {
285/// state.values().get("count").and_then(Value::as_i64)
286/// .ok_or_else(|| AppError::InvalidInput("count must be an integer".into()))
287/// }
288/// fn construct_state(&self, count: &i64) -> Result<WorkflowState, AppError> {
289/// WorkflowState::new(Map::from_iter([("count".into(), Value::from(*count))]))
290/// }
291/// fn validate_pre(&self, _state: &WorkflowState, change: &StateChange) -> Result<(), AppError> {
292/// if let StateChange::Increment { values } = change {
293/// if values.iter().any(|(_, delta)| delta.as_i64().is_some_and(|value| value < 0)) {
294/// return Err(AppError::InvalidInput("count cannot decrease".into()));
295/// }
296/// }
297/// Ok(())
298/// }
299/// fn validate_post(&self, before: &WorkflowState, after: &WorkflowState, _change: &StateChange) -> Result<(), AppError> {
300/// if self.construct_data(after)? < self.construct_data(before)? {
301/// return Err(AppError::InvalidInput("count cannot decrease".into()));
302/// }
303/// Ok(())
304/// }
305/// fn validate_state(&self, state: &WorkflowState) -> Result<(), AppError> {
306/// if self.construct_data(state)? < 0 {
307/// return Err(AppError::InvalidInput("count must be nonnegative".into()));
308/// }
309/// Ok(())
310/// }
311/// }
312/// let before = Counter.construct_state(&2)?;
313/// let change = StateChange::Increment { values: vec![("count".into(), Value::from(1))] };
314/// let after = before.apply(&change, &Counter)?;
315/// assert_eq!(Counter.construct_data(&after)?, 3);
316/// assert!(Counter.validate_state(&Counter.construct_state(&-1)?).is_err());
317/// # Ok::<(), AppError>(())
318/// ```
319pub trait StateTyping: Send + Sync + 'static {
320 /// Caller-specific data produced from workflow values.
321 type Data;
322
323 /// Constructs caller data from a validated state.
324 ///
325 /// # Errors
326 ///
327 /// Return an error when the state cannot be represented as `Self::Data`.
328 ///
329 /// See [the complete typing implementation](StateTyping).
330 fn construct_data(&self, state: &WorkflowState) -> Result<Self::Data, AppError>;
331 /// Reconstructs workflow values from caller data.
332 ///
333 /// # Errors
334 ///
335 /// Return an error when caller data cannot produce a valid workflow state.
336 ///
337 /// See [the complete typing implementation](StateTyping).
338 fn construct_state(&self, data: &Self::Data) -> Result<WorkflowState, AppError>;
339 /// Validates an operation before any candidate mutation.
340 ///
341 /// # Errors
342 ///
343 /// Return an error to reject the complete primitive operation before candidate
344 /// mutation. The default accepts it; Merge and Subset do not invoke this method.
345 ///
346 /// See [the complete typing implementation](StateTyping).
347 fn validate_pre(&self, _state: &WorkflowState, _change: &StateChange) -> Result<(), AppError> {
348 Ok(())
349 }
350 /// Validates the original and candidate states after the operation.
351 ///
352 /// # Errors
353 ///
354 /// Return an error to reject the complete candidate primitive transition. The
355 /// original state remains unchanged. The default accepts it; Merge and Subset
356 /// do not invoke this method.
357 ///
358 /// See [the complete typing implementation](StateTyping).
359 fn validate_post(
360 &self,
361 _before: &WorkflowState,
362 _after: &WorkflowState,
363 _change: &StateChange,
364 ) -> Result<(), AppError> {
365 Ok(())
366 }
367 /// Validates all candidate values after transition validation.
368 ///
369 /// # Errors
370 ///
371 /// Return an error when any full-state invariant is violated. The default accepts
372 /// the state; this method also runs for reconstructed Merge/Subset snapshots.
373 ///
374 /// See [the complete typing implementation](StateTyping).
375 fn validate_state(&self, _state: &WorkflowState) -> Result<(), AppError> {
376 Ok(())
377 }
378 /// Verifies both caller conversions succeed; a different reconstruction is allowed.
379 ///
380 /// # Errors
381 ///
382 /// The default propagates either conversion error. Successful reconstruction is
383 /// sufficient; byte-for-byte or value equality is not required.
384 ///
385 /// See [the complete typing implementation](StateTyping).
386 fn validate_roundtrip(&self, state: &WorkflowState) -> Result<(), AppError> {
387 let data = self.construct_data(state)?;
388 self.construct_state(&data).map(|_| ())
389 }
390}
391
392/// Raw JSON typing with no additional conversion or validation policy.
393///
394/// # Example
395///
396/// ```
397/// use app_kit::workflow::{JsonState, StateTyping, WorkflowState};
398/// let state = WorkflowState::new(serde_json::from_str(r#"{"count":2}"#)?)?;
399/// let data = JsonState.construct_data(&state)?;
400/// assert_eq!(data["count"], 2);
401/// assert_eq!(JsonState.construct_state(&data)?, state);
402/// # Ok::<(), Box<dyn std::error::Error>>(())
403/// ```
404#[derive(Clone, Copy, Debug, Default)]
405pub struct JsonState;
406
407impl StateTyping for JsonState {
408 type Data = Map<String, Value>;
409
410 fn construct_data(&self, state: &WorkflowState) -> Result<Self::Data, AppError> {
411 Ok(state.values.clone())
412 }
413
414 fn construct_state(&self, data: &Self::Data) -> Result<WorkflowState, AppError> {
415 WorkflowState::new(data.clone())
416 }
417
418 fn validate_roundtrip(&self, _state: &WorkflowState) -> Result<(), AppError> {
419 Ok(())
420 }
421}
422
423impl WorkflowState {
424 /// Validates ordered JSON values and takes ownership of them.
425 ///
426 /// ```
427 /// use app_kit::workflow::WorkflowState;
428 /// let values = serde_json::from_str(r#"{"first":1,"second":2}"#)?;
429 /// let state = WorkflowState::new(values)?;
430 /// assert_eq!(state.values().keys().collect::<Vec<_>>(), ["first", "second"]);
431 /// # Ok::<(), Box<dyn std::error::Error>>(())
432 /// ```
433 ///
434 /// # Errors
435 ///
436 /// Returns `InvalidInput` for invalid state keys or exceeded JSON byte, nesting,
437 /// collection, or text bounds. Ownership is accepted only after full validation.
438 pub fn new(values: Map<String, Value>) -> Result<Self, AppError> {
439 definition::JsonBoundary::validate_map(&values)?;
440 Ok(Self { values })
441 }
442
443 /// Borrows values in their insertion order.
444 ///
445 /// ```
446 /// use app_kit::workflow::WorkflowState;
447 /// let state = WorkflowState::default();
448 /// assert!(state.values().get("optional_input").is_none());
449 /// ```
450 pub fn values(&self) -> &Map<String, Value> {
451 &self.values
452 }
453
454 /// Produces a new state with the pinned operation-specific validation order.
455 ///
456 /// Primitive changes run pre/post transition validation, then full-state
457 /// validation and roundtrip. Merge and subset construct new snapshots and
458 /// run only full-state validation and roundtrip.
459 /// Any failure leaves this state and its retained clones unchanged.
460 ///
461 /// ```
462 /// use app_kit::workflow::{JsonState, StateChange, WorkflowState};
463 /// use serde_json::{Map, Value};
464 /// let state = WorkflowState::new(Map::from_iter([
465 /// ("count".into(), Value::from("unknown")),
466 /// ]))?;
467 /// let change = StateChange::Increment {
468 /// values: vec![("count".into(), Value::from(1))],
469 /// };
470 /// assert!(state.apply(&change, &JsonState).is_err());
471 /// assert_eq!(state.values()["count"], "unknown");
472 /// # Ok::<(), app_kit::AppError>(())
473 /// ```
474 ///
475 /// # Errors
476 ///
477 /// Returns `InvalidInput` for incompatible operand types, signed-integer delta or
478 /// range errors, invalid state, or excessive operation payloads. Caller typing
479 /// errors propagate unchanged; failure never mutates the original state.
480 ///
481 /// # Panics
482 ///
483 /// Panics if the caller-provided typing implementation panics. Direct state
484 /// operations do not contain caller panics; App admission/execution has its own
485 /// boundary for classifying them.
486 pub fn apply<T: StateTyping>(
487 &self,
488 change: &StateChange,
489 typing: &T,
490 ) -> Result<Self, AppError> {
491 if let StateChange::Wipe { keep } = change {
492 StateChange::validate_count(keep.len())?;
493 let keys = self
494 .values
495 .keys()
496 .filter(|key| !keep.contains(key))
497 .cloned()
498 .collect();
499 return self.apply(&StateChange::Remove { keys }, typing);
500 }
501 let snapshot = matches!(
502 change,
503 StateChange::Merge { .. } | StateChange::Subset { .. }
504 );
505 if !snapshot {
506 typing.validate_pre(self, change)?;
507 }
508 let candidate = Self::new(change.apply_values(&self.values)?)?;
509 if !snapshot {
510 typing.validate_post(self, &candidate, change)?;
511 }
512 candidate.validate_typing(typing)?;
513 Ok(candidate)
514 }
515
516 fn validate_typing<T: StateTyping>(&self, typing: &T) -> Result<(), AppError> {
517 typing.validate_state(self)?;
518 typing.validate_roundtrip(self)
519 }
520}
521
522/// A workflow selected in one application workspace.
523///
524/// See [execution and retained history](crate::workflow#execute-observe-restore-and-delete).
525pub struct Workflow<
526 CS: StateStoreBackend,
527 A: AgentBackend,
528 X: ActionBackend = NoActions,
529 C: ConditionBackend = NoConditions,
530 T: StateTyping = JsonState,
531 H: LifecycleHooks = NoHooks,
532 E: ChildExecutor = InlineExecutor,
533> {
534 runtime: run::WorkflowRuntime<CS, A, X, C, T, H, E>,
535}
536
537/// A durable workflow-run handle.
538///
539/// This handle observes durable state and requests explicit controls. Dropping it
540/// or a `wait`/snapshot future does not cancel accepted work. [`Self::execution`]
541/// borrows it exclusively and returns an owner whose drop does revoke uncommitted
542/// advancement. Use [`Self::cancel`] for durable, monotonic cancellation.
543///
544/// See [execution and retained history](crate::workflow#execute-observe-restore-and-delete).
545pub struct WorkflowRun<
546 CS: StateStoreBackend,
547 A: AgentBackend,
548 X: ActionBackend = NoActions,
549 C: ConditionBackend = NoConditions,
550 T: StateTyping = JsonState,
551 H: LifecycleHooks = NoHooks,
552 E: ChildExecutor = InlineExecutor,
553> {
554 runtime: run::WorkflowRunRuntime<CS, A, X, C, T, H, E>,
555}
556
557/// One opaque, parsed `app-kit.workflow/v2` definition.
558///
559/// Construct definitions through [`Self::parse`]; their internals cannot be
560/// copied through struct update syntax:
561///
562/// ```compile_fail
563/// use app_kit::workflow::WorkflowDefinition;
564/// fn reconstruct(definition: WorkflowDefinition) -> WorkflowDefinition {
565/// WorkflowDefinition { ..definition }
566/// }
567/// ```
568///
569/// Only the strict versioned language is accepted. Unknown/duplicate fields,
570/// invalid expressions, graph references and finite-bound violations fail before
571/// runtime work. Inspection returns passive metadata; composition runs the same
572/// compiler rather than exposing a mutable executable AST.
573///
574/// # Example
575///
576/// ```
577/// use app_kit::workflow::WorkflowDefinition;
578///
579/// let definition = WorkflowDefinition::parse(r#"{
580/// "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
581/// "state":{"count":0}, "required":["count"],
582/// "reducers":{"count":"reject_concurrent"}, "entry":"increment",
583/// "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
584/// {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
585/// ]}}], "routes":{}
586/// }"#)?;
587/// assert_eq!(definition.graph().entry, "increment");
588/// # Ok::<(), app_kit::AppError>(())
589/// ```
590pub struct WorkflowDefinition {
591 compiled: definition::CompiledDefinition,
592}
593
594/// A deterministic read-only graph export format.
595pub enum GraphFormat {
596 /// Portable graph metadata as JSON, without runtime state.
597 Json,
598 /// Graphviz DOT text; rendering requires no subprocess in this crate.
599 Dot,
600 /// Mermaid flowchart text with escaped caller labels.
601 Mermaid,
602}
603
604/// Passive graph metadata in authored node and transition order.
605///
606/// # Example
607///
608/// ```
609/// use app_kit::workflow::WorkflowDefinition;
610///
611/// let definition = WorkflowDefinition::parse(r#"{
612/// "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
613/// "state":{"count":0}, "required":["count"],
614/// "reducers":{"count":"reject_concurrent"}, "entry":"increment",
615/// "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
616/// {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
617/// ]}}], "routes":{}
618/// }"#)?;
619/// let graph = definition.graph();
620/// assert_eq!(graph.entry, "increment");
621/// assert_eq!(graph.nodes.len(), 1);
622/// # Ok::<(), app_kit::AppError>(())
623/// ```
624#[derive(Serialize)]
625pub struct GraphDescription {
626 /// Logical workflow identity.
627 pub id: WorkflowId,
628 /// Caller-selected definition revision.
629 pub revision: String,
630 /// First node's logical identity.
631 pub entry: String,
632 /// Authored node metadata, without executable bindings.
633 pub nodes: Vec<GraphNode>,
634 /// Ordered outgoing transitions, grouped by authored source-node order.
635 pub edges: Vec<GraphEdge>,
636}
637
638/// One passive node declaration in a graph projection.
639///
640/// # Example
641///
642/// ```
643/// use app_kit::workflow::WorkflowDefinition;
644///
645/// let definition = WorkflowDefinition::parse(r#"{
646/// "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
647/// "state":{"count":0}, "required":["count"],
648/// "reducers":{"count":"reject_concurrent"}, "entry":"increment",
649/// "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
650/// {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
651/// ]}}], "routes":{}
652/// }"#)?;
653/// let graph = definition.graph();
654/// let node = &graph.nodes[0];
655/// assert_eq!(node.kind, "state");
656/// assert_eq!(node.writes, ["count"]);
657/// # Ok::<(), app_kit::AppError>(())
658/// ```
659#[derive(Serialize)]
660pub struct GraphNode {
661 /// Graph-local node identity.
662 pub id: String,
663 /// Optional caller-facing label.
664 pub name: Option<String>,
665 /// Optional authored description.
666 pub description: Option<String>,
667 /// Authored selector tags, in declaration order.
668 pub tags: Vec<String>,
669 /// Closed workflow step kind rendered as text.
670 pub kind: String,
671 /// Top-level state keys read by this node.
672 pub reads: Vec<String>,
673 /// Top-level state keys written by this node.
674 pub writes: Vec<String>,
675 /// Required transient input names.
676 pub required_inputs: Vec<String>,
677 /// Optional transient input names.
678 pub optional_inputs: Vec<String>,
679 /// Actual caller source or canonical authored built-in definition.
680 pub source: Option<String>,
681}
682
683/// One ordered transition; a missing destination denotes path exhaustion.
684///
685/// # Example
686///
687/// ```
688/// use app_kit::workflow::GraphEdge;
689/// let end = GraphEdge { from: "increment".into(), to: None, condition: None };
690/// assert!(end.to.is_none());
691/// # Ok::<(), app_kit::AppError>(())
692/// ```
693#[derive(Serialize)]
694pub struct GraphEdge {
695 /// Source node identity.
696 pub from: String,
697 /// Destination node identity, or explicit end.
698 pub to: Option<String>,
699 /// Display expression, or the default true condition when absent.
700 pub condition: Option<String>,
701}
702
703/// An owned graph piece and its graph-local node-name prefix.
704///
705/// See [`WorkflowDefinition::compose`] for a complete composition example.
706pub struct GraphImport {
707 /// Validated definition to import.
708 pub definition: WorkflowDefinition,
709 /// Prefix applied to nodes and their graph-local references.
710 pub prefix: String,
711}
712
713/// A default transition appended to an assembled graph.
714///
715/// See [`WorkflowDefinition::compose`] for a complete composition example.
716pub struct GraphConnection {
717 /// Already-prefixed source node identity.
718 pub from: String,
719 /// Already-prefixed destination node identity.
720 pub to: String,
721}
722
723/// Definitions and explicit connections for one ordinary compiler invocation.
724///
725/// See [`WorkflowDefinition::compose`] for a complete composition example.
726pub struct GraphComposition {
727 /// Identity of the assembled workflow.
728 pub id: WorkflowId,
729 /// Revision of the assembled workflow.
730 pub revision: String,
731 /// Already-prefixed entrypoint.
732 pub entry: String,
733 /// Graph pieces, preserving their declaration order.
734 pub imports: Vec<GraphImport>,
735 /// Ordered default transitions appended after imported transitions.
736 pub connections: Vec<GraphConnection>,
737}
738
739/// A validated caller-authored workflow identity.
740///
741/// Parsing and deserialization accept only ASCII `[a-z0-9][a-z0-9_-]{0,127}`.
742///
743/// # Example
744///
745/// ```
746/// use app_kit::workflow::WorkflowId;
747/// let id: WorkflowId = "counter".parse()?;
748/// assert_eq!(id.to_string(), "counter");
749/// assert!("invalid identity".parse::<WorkflowId>().is_err());
750/// # Ok::<(), app_kit::AppError>(())
751/// ```
752#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
753pub struct WorkflowId(String);
754
755/// A validated root UUID or stable child SHA-256 identity.
756///
757/// Root identities use `run_` followed by a canonical lowercase UUIDv4. Child
758/// identities also accept exactly 64 lowercase hexadecimal digits. One logical
759/// identity may have multiple independent occurrences; use [`RunSelector::Exact`]
760/// when the occurrence matters.
761///
762/// # Example
763///
764/// ```
765/// use app_kit::workflow::RunId;
766/// let id: RunId = "run_8a7c251e-b5b8-4d9e-8f0b-123456789abc".parse()?;
767/// assert_eq!(id.to_string(), "run_8a7c251e-b5b8-4d9e-8f0b-123456789abc");
768/// assert!("invalid identity".parse::<RunId>().is_err());
769/// # Ok::<(), app_kit::AppError>(())
770/// ```
771#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
772pub struct RunId(String);
773
774/// Selects an exact occurrence or the latest native observation of a logical run.
775#[derive(Clone, Debug, Eq, PartialEq)]
776pub enum RunSelector {
777 /// Last natively saved observation in this workflow and workspace scope.
778 Latest(RunId),
779 /// One admitted occurrence; an absent occurrence never falls back to Latest.
780 Exact {
781 /// Stable logical identity.
782 id: RunId,
783 /// Validated at method ingress as 32 lowercase hexadecimal digits.
784 occurrence: String,
785 },
786}
787
788impl RunSelector {
789 pub(crate) fn validate(&self) -> Result<(), AppError> {
790 match self {
791 Self::Latest(_) => Ok(()),
792 Self::Exact { occurrence, .. } => RunId::validate_occurrence(occurrence),
793 }
794 }
795
796 pub(crate) fn id(&self) -> &RunId {
797 match self {
798 Self::Latest(id) | Self::Exact { id, .. } => id,
799 }
800 }
801}
802
803/// Initial state, transient inputs and boundaries selected for one execution.
804///
805/// `state` patches a new run only through [`Workflow::start`]; an already admitted
806/// run requires an empty state in its execution options. Inputs are transient and
807/// do not become state automatically. Selectors are node ids or `@tag` names and
808/// must match. `max_steps` counts committed action boundaries, not partial results
809/// or control checkpoints; private finite safety bounds always remain.
810///
811/// # Example
812///
813/// ```
814/// use app_kit::workflow::{ExecutionOptions, WorkflowState};
815/// let options = ExecutionOptions {
816/// state: WorkflowState::new(serde_json::from_str(r#"{"count":4}"#)?)?,
817/// halt_after: vec!["increment".into()],
818/// ..ExecutionOptions::default()
819/// };
820/// assert_eq!(options.state.values()["count"], 4);
821/// # Ok::<(), Box<dyn std::error::Error>>(())
822/// ```
823#[derive(Clone, Default)]
824pub struct ExecutionOptions {
825 /// Initial patch for a new run; must be empty on an existing run.
826 pub state: WorkflowState,
827 /// Transient inputs reused by successive actions.
828 pub inputs: Map<String, Value>,
829 /// Node or tag selectors to stop before invocation.
830 pub halt_before: Vec<String>,
831 /// Node or tag selectors to stop after a committed boundary.
832 pub halt_after: Vec<String>,
833 /// Optional maximum number of committed action boundaries.
834 pub max_steps: Option<NonZeroUsize>,
835}
836
837impl ExecutionOptions {
838 pub(crate) fn resolve_selectors(&mut self, graph: &GraphDescription) -> Result<(), AppError> {
839 for selectors in [&mut self.halt_before, &mut self.halt_after] {
840 let mut resolved = Vec::new();
841 for selector in selectors.iter() {
842 let mut matches = graph
843 .nodes
844 .iter()
845 .filter(|node| {
846 selector
847 .strip_prefix('@')
848 .map_or(node.id == *selector, |tag| {
849 node.tags.iter().any(|candidate| candidate == tag)
850 })
851 })
852 .peekable();
853 if matches.peek().is_none() {
854 return Err(AppError::InvalidInput(
855 "execution selector has no matching node".into(),
856 ));
857 }
858 for node in matches {
859 if !resolved.contains(&node.id) {
860 resolved.push(node.id.clone());
861 }
862 }
863 }
864 *selectors = resolved;
865 }
866 Ok(())
867 }
868}
869
870/// One transient result or committed execution boundary.
871#[non_exhaustive]
872pub enum ExecutionEvent {
873 /// A source result which has not changed workflow state.
874 Partial {
875 /// Node whose source emitted this item.
876 node: String,
877 /// Transient output independent of workflow state.
878 result: Map<String, Value>,
879 },
880 /// A durable action boundary with its complete ordered outcomes.
881 Committed {
882 /// Exact durable boundary sequence.
883 checkpoint: u64,
884 /// Complete results in canonical node order.
885 outcomes: Vec<ActionOutcome>,
886 },
887 /// A selected boundary parked for a later execution.
888 Halted {
889 /// Selected next node or last committed node.
890 node: String,
891 /// Present only for a committed after-boundary, including an empty map.
892 result: Option<Map<String, Value>>,
893 },
894}
895
896/// The current caller-visible run truth.
897///
898/// See [execution and retained history](crate::workflow#execute-observe-restore-and-delete).
899#[non_exhaustive]
900pub struct RunSnapshot {
901 /// Identifies the run.
902 pub id: RunId,
903 /// Exact admitted execution occurrence of this logical run.
904 pub occurrence: String,
905 /// Fences explicit controls.
906 pub checkpoint: u64,
907 /// Projects lifecycle; actionable targets remain in `interactions`.
908 pub status: RunStatus,
909 /// Contains the current committed workflow state.
910 pub state: WorkflowState,
911 /// Canonical currently actionable controls.
912 pub interactions: Vec<Interaction>,
913 /// Last successfully committed action ordinal; controls do not advance it.
914 pub last_action_ordinal: Option<u64>,
915 /// Results from the latest committed wave, retained through later controls.
916 pub outcomes: Vec<ActionOutcome>,
917}
918
919/// The current stable run status.
920///
921/// Inspect [`crate::workflow::RunSnapshot::interactions`] alongside this lifecycle projection.
922/// Halted retains the selected before/after boundary and exact optional result;
923/// Paused denotes an ordinary parked control or prepared run. Neither status
924/// alone means that every external effect is safe to retry.
925#[non_exhaustive]
926pub enum RunStatus {
927 /// Execution can still advance without caller input.
928 Running,
929 /// A committed boundary is parked for explicit execution.
930 Paused,
931 /// An explicitly selected node boundary is retained.
932 Halted {
933 /// Selected next node or last committed node.
934 node: String,
935 /// Present only for a committed after-boundary, including an empty map.
936 result: Option<Map<String, Value>>,
937 },
938 /// Explicit cancellation has committed and owned cleanup is still running.
939 Cancelling,
940 /// The terminal frontier succeeded.
941 Succeeded,
942 /// The run terminated with a durable failure.
943 Failed(WorkflowFailure),
944 /// Explicit cancellation converged.
945 Cancelled,
946}
947
948/// A caller-actionable workflow interaction.
949#[derive(Clone)]
950#[non_exhaustive]
951pub enum Interaction {
952 /// Requests one schema-checked caller value.
953 Input {
954 /// Identifies this run-scoped control.
955 sequence: u64,
956 /// Fences this input reservation independently of sibling checkpoints.
957 generation: u64,
958 /// Describes the requested value.
959 message: String,
960 /// Optionally constrains the response.
961 schema: Option<Value>,
962 },
963 /// Exposes one exact lower-agent notification.
964 Notification {
965 /// Identifies this run-scoped control.
966 sequence: u64,
967 /// Fences this exact notification target independently of sibling checkpoints.
968 generation: u64,
969 /// Carries the exact portable lower notification.
970 notification: Arc<ConversationNotification>,
971 },
972 /// Retains an external effect whose outcome has not been established.
973 Unknown {
974 /// Identifies this run-scoped interaction target.
975 sequence: u64,
976 /// Fences this target independently of sibling checkpoints.
977 generation: u64,
978 /// Reports whether authoritative evidence permits another attempt.
979 retry_safe: bool,
980 },
981 /// Requests transient inputs for an action that has not started.
982 Inputs {
983 /// Identifies this run-scoped interaction target.
984 sequence: u64,
985 /// Fences this binding independently of sibling checkpoints.
986 generation: u64,
987 /// Names the exact missing input keys in canonical order.
988 required: Vec<String>,
989 },
990}
991
992/// Settles one current interaction or unknown effect.
993///
994/// Use the interaction sequence and its target generation from the same observed
995/// item. Sibling checkpoints do not invalidate that reservation. Unknown effects
996/// are never automatically retried; Retry requires authoritative retry-safe
997/// evidence, and Accept is an explicit caller settlement.
998pub enum Resolution {
999 /// Supplies one caller-input value.
1000 Input(Value),
1001 /// Binds exact missing inputs in the retained execution, without persisting values.
1002 Inputs(Map<String, Value>),
1003 /// Resolves one exact lower notification.
1004 Notification(NotificationResolution),
1005 /// Accepts an unknown effect and stages ordinary state contribution.
1006 Accept(Map<String, Value>),
1007 /// Fails an unknown effect without claiming its external outcome.
1008 Fail,
1009 /// Retries only after authoritative evidence proves retry safe.
1010 Retry,
1011}
1012
1013/// A stable terminal workflow-failure category.
1014#[non_exhaustive]
1015pub enum WorkflowFailure {
1016 /// Expression or routing evaluation failed.
1017 Evaluation(String),
1018 /// State validation or deterministic reduction failed.
1019 State(String),
1020 /// A lower agent operation failed safely.
1021 Agent(String),
1022 /// A caller-owned action source returned an error.
1023 Action(String),
1024 /// An opt-in lifecycle hook failed, retaining any prior execution failure.
1025 Hook {
1026 /// The caller-supplied hook error context.
1027 message: String,
1028 /// The execution failure observed before the hook failed.
1029 cause: Option<Box<WorkflowFailure>>,
1030 },
1031 /// A child or map relationship failed, or a child executor returned an error.
1032 ///
1033 /// An error returned by [`ChildExecutor::execute`]'s future has this category
1034 /// regardless of its [`AppError`] variant. Partial child results do not reduce
1035 /// the parent, and descendant cleanup retains ownership before publication.
1036 Child(String),
1037 /// Execution could not continue, including a contained child-executor panic.
1038 ///
1039 /// A panic while constructing or polling [`ChildExecutor::execute`]'s future
1040 /// has this category. It does not reduce the parent from partial child results;
1041 /// descendant cleanup retains ownership before terminal publication.
1042 Execution(String),
1043 /// A durable deadline expired.
1044 Timeout(String),
1045 /// A fixed private safety bound was exhausted.
1046 Limit(String),
1047}
1048
1049impl WorkflowDefinition {
1050 /// Parses and validates one strict versioned workflow document.
1051 ///
1052 /// # Errors
1053 ///
1054 /// Returns [`AppError::InvalidInput`] for malformed JSON, unsupported schema,
1055 /// unknown or duplicate members, invalid identities or expressions, inconsistent
1056 /// state/reducers, graph errors, or exceeded source/collection/depth limits.
1057 ///
1058 /// # Example
1059 ///
1060 /// ```
1061 /// use app_kit::workflow::WorkflowDefinition;
1062 ///
1063 /// let definition = WorkflowDefinition::parse(r#"{
1064 /// "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1065 /// "state":{"count":0}, "required":["count"],
1066 /// "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1067 /// "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1068 /// {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1069 /// ]}}], "routes":{}
1070 /// }"#)?;
1071 /// assert_eq!(definition.graph().id.to_string(), "counter");
1072 /// assert!(WorkflowDefinition::parse(r#"{"schema":"app-kit.workflow/v1"}"#).is_err());
1073 /// # Ok::<(), app_kit::AppError>(())
1074 /// ```
1075 pub fn parse(source: &str) -> Result<Self, AppError> {
1076 Ok(Self {
1077 compiled: definition::CompiledDefinition::parse(source)?,
1078 })
1079 }
1080
1081 pub(crate) fn into_compiled(self) -> definition::CompiledDefinition {
1082 self.compiled
1083 }
1084}
1085
1086impl<
1087 CS: StateStoreBackend,
1088 A: AgentBackend,
1089 X: ActionBackend,
1090 C: ConditionBackend,
1091 T: StateTyping,
1092 H: LifecycleHooks,
1093 E: ChildExecutor,
1094> Workflow<CS, A, X, C, T, H, E>
1095{
1096 pub(crate) fn from_runtime(runtime: run::WorkflowRuntime<CS, A, X, C, T, H, E>) -> Self {
1097 Self { runtime }
1098 }
1099
1100 /// Returns this workflow's stable identity.
1101 ///
1102 /// See [execution and retained history](crate::workflow#execute-observe-restore-and-delete).
1103 pub fn id(&self) -> &WorkflowId {
1104 self.runtime.id()
1105 }
1106
1107 /// Atomically admits one run at checkpoint zero.
1108 ///
1109 /// Schedules background advancement after admission. Dropping the returned run
1110 /// observer does not stop it. [`WorkflowRun::wait`] returns at an actionable or
1111 /// terminal boundary, so inspect status and interactions before assuming success.
1112 ///
1113 /// # Errors
1114 ///
1115 /// Returns `InvalidInput` for invalid initial state/options or missing bindings,
1116 /// `Busy` for exhausted admission/capacity, `Closed` for sealed ownership, or
1117 /// `Conflict` for stale workspace/revision evidence. Lower/storage failures use
1118 /// `Integration`; accepted execution failures appear in run status.
1119 ///
1120 /// See [execution and retained history](crate::workflow#execute-observe-restore-and-delete).
1121 pub async fn start(
1122 &self,
1123 options: ExecutionOptions,
1124 ) -> Result<WorkflowRun<CS, A, X, C, T, H, E>, AppError> {
1125 Ok(WorkflowRun {
1126 runtime: self.runtime.start(options).await?,
1127 })
1128 }
1129
1130 /// Admits checkpoint zero without executing the entry node.
1131 ///
1132 /// The initial patch is validated and atomically stored using the same
1133 /// workspace and App admission fences as background execution. Observers
1134 /// can read the parked run, and cancellation uses its checkpoint fence.
1135 ///
1136 /// # Errors
1137 ///
1138 /// Returns `InvalidInput` for invalid initial state or missing bindings,
1139 /// `Busy` for exhausted admission/capacity, `Closed` for sealed ownership, or
1140 /// `Conflict` for stale workspace/revision evidence or exhausted bounded
1141 /// transaction-contention retries. Concurrent preparations are not guaranteed
1142 /// to all succeed. Lower/storage failures use `Integration`; accepted
1143 /// execution failures appear in run status.
1144 ///
1145 /// See [execution and retained history](crate::workflow#execute-observe-restore-and-delete).
1146 pub async fn prepare(
1147 &self,
1148 state: WorkflowState,
1149 ) -> Result<WorkflowRun<CS, A, X, C, T, H, E>, AppError> {
1150 Ok(WorkflowRun {
1151 runtime: self.runtime.prepare(state.values).await?,
1152 })
1153 }
1154
1155 /// Copies a clean retained boundary into a fresh, paused run.
1156 ///
1157 /// Source selection and new admission commit atomically. The source remains
1158 /// unchanged; actions run only after explicit execution of the returned run.
1159 ///
1160 /// # Errors
1161 ///
1162 /// Returns `NotFound` for absent source/checkpoint without fallback, `InvalidInput`
1163 /// for invalid overrides or selectors, `Busy` for exhausted capacity, and
1164 /// `Conflict` when the selected boundary is unsafe to copy. Sealing/revocation
1165 /// returns `Closed`; inconsistent history or storage failures return `Integration`.
1166 ///
1167 /// See [execution and retained history](crate::workflow#execute-observe-restore-and-delete).
1168 pub async fn restore(
1169 &self,
1170 options: RestoreOptions,
1171 ) -> Result<WorkflowRun<CS, A, X, C, T, H, E>, AppError> {
1172 Ok(WorkflowRun {
1173 runtime: self.runtime.restore(options).await?,
1174 })
1175 }
1176
1177 /// Reopens one run by typed identity.
1178 ///
1179 /// # Errors
1180 ///
1181 /// Returns `InvalidInput` for a malformed exact occurrence, `NotFound` when the
1182 /// selected run/occurrence is absent, or `Closed` for sealed scope. Exact selection
1183 /// never falls back to Latest; inconsistent retained evidence is `Integration`.
1184 ///
1185 /// See [execution and retained history](crate::workflow#execute-observe-restore-and-delete).
1186 pub async fn open_run(
1187 &self,
1188 selector: RunSelector,
1189 ) -> Result<WorkflowRun<CS, A, X, C, T, H, E>, AppError> {
1190 Ok(WorkflowRun {
1191 runtime: self.runtime.open_run(selector).await?,
1192 })
1193 }
1194
1195 /// Lists runs by logical identity, then exact occurrence token.
1196 ///
1197 /// An Exact cursor continues after that occurrence; a Latest cursor skips
1198 /// every occurrence of its logical identity. This inventory order does not
1199 /// represent the native save order used when opening Latest.
1200 ///
1201 /// # Errors
1202 ///
1203 /// Returns `InvalidInput` for a limit above 1,000 or malformed cursor, `NotFound`
1204 /// for an unavailable cursor, and `Closed` or `Integration` for revoked or
1205 /// unreadable scope.
1206 ///
1207 /// See [execution and retained history](crate::workflow#execute-observe-restore-and-delete).
1208 pub async fn list_runs(
1209 &self,
1210 after: Option<RunSelector>,
1211 limit: NonZeroUsize,
1212 ) -> Result<Vec<RunSnapshot>, AppError> {
1213 self.runtime.list_runs(after, limit).await
1214 }
1215}
1216
1217impl<
1218 CS: StateStoreBackend,
1219 A: AgentBackend,
1220 X: ActionBackend,
1221 C: ConditionBackend,
1222 T: StateTyping,
1223 H: LifecycleHooks,
1224 E: ChildExecutor,
1225> WorkflowRun<CS, A, X, C, T, H, E>
1226{
1227 /// Returns this run's stable identity.
1228 ///
1229 /// See [execution and retained history](crate::workflow#execute-observe-restore-and-delete).
1230 pub fn id(&self) -> &RunId {
1231 self.runtime.id()
1232 }
1233
1234 /// Acquires exclusive caller-local advancement without constructing a source.
1235 ///
1236 /// A live execution keeps that borrow until its last use:
1237 ///
1238 /// ```compile_fail
1239 /// use agents_kit::AgentBackend;
1240 /// use app_kit::{AppError, workflow::{ExecutionOptions, RunSnapshot, WorkflowRun}};
1241 /// use storage_kit::StateStoreBackend;
1242 /// async fn overlapping<CS: StateStoreBackend, A: AgentBackend>(
1243 /// run: &mut WorkflowRun<CS, A>,
1244 /// ) -> Result<RunSnapshot, AppError> {
1245 /// let execution = run.execution(ExecutionOptions::default()).await?;
1246 /// run.snapshot().await?;
1247 /// execution.finish().await
1248 /// }
1249 /// ```
1250 ///
1251 /// # Errors
1252 ///
1253 /// Returns `InvalidInput` for a nonempty options state or invalid inputs/selectors,
1254 /// `Busy` if advancement or admission is already owned, `Conflict` for stale
1255 /// authority, and `Closed` after revocation. Storage failures use `Integration`.
1256 ///
1257 /// See [execution and retained history](crate::workflow#execute-observe-restore-and-delete).
1258 pub async fn execution(
1259 &mut self,
1260 options: ExecutionOptions,
1261 ) -> Result<Execution<'_, CS, A, X, C, T, H, E>, AppError> {
1262 Execution::open(self, options, ActionMode::Asynchronous).await
1263 }
1264
1265 /// Loads the current run snapshot.
1266 ///
1267 /// # Errors
1268 ///
1269 /// Returns `Closed` for revoked App ownership or sealed/deleted scope, `NotFound`
1270 /// for absent retained evidence, or `Integration` for inconsistent or unreadable
1271 /// records. A newer App generation can invalidate this handle.
1272 ///
1273 /// See [execution and retained history](crate::workflow#execute-observe-restore-and-delete).
1274 pub async fn snapshot(&self) -> Result<RunSnapshot, AppError> {
1275 self.runtime.snapshot().await
1276 }
1277
1278 /// Reads one immutable checkpoint without advancing the workflow.
1279 ///
1280 /// An action selector returns the latest successful or failed observation
1281 /// of that ordinal. Missing or explicitly deleted history returns NotFound.
1282 ///
1283 /// # Errors
1284 ///
1285 /// Returns `Closed` for revoked App ownership or sealed/deleted scope, `NotFound`
1286 /// for absent retained evidence, or `Integration` for inconsistent or unreadable
1287 /// records. A newer App generation can invalidate this handle.
1288 /// An explicit missing sequence/action is `NotFound`; action ordinals are distinct
1289 /// from checkpoint sequences and select the latest matching saved observation.
1290 ///
1291 /// See [execution and retained history](crate::workflow#execute-observe-restore-and-delete).
1292 pub async fn checkpoint(&self, selector: CheckpointSelector) -> Result<Checkpoint, AppError> {
1293 self.runtime.checkpoint(selector).await
1294 }
1295
1296 /// Reads a bounded page from a fixed retained-history cutoff.
1297 ///
1298 /// Limits are 1 through 1,000. A returned cursor preserves its cutoff and
1299 /// is valid only for this run creation and App generation. Reading a page
1300 /// never invokes actions, state typing, codecs or lower-agent recovery.
1301 ///
1302 /// # Errors
1303 ///
1304 /// Returns `Closed` for revoked App ownership or sealed/deleted scope, `NotFound`
1305 /// for absent retained evidence, or `Integration` for inconsistent or unreadable
1306 /// records. A newer App generation can invalidate this handle.
1307 /// An excessive page limit or a cursor from another run/occurrence/generation is
1308 /// `InvalidInput`. A captured cutoff never includes later appended checkpoints.
1309 ///
1310 /// See [execution and retained history](crate::workflow#execute-observe-restore-and-delete).
1311 pub async fn history(
1312 &self,
1313 after: Option<HistoryCursor>,
1314 limit: NonZeroUsize,
1315 ) -> Result<HistoryPage, AppError> {
1316 self.runtime.history(after, limit).await
1317 }
1318
1319 /// Deletes terminal history through bounded, resumable pages.
1320 ///
1321 /// The expected checkpoint fences the terminal boundary. Dropping this
1322 /// operation's observer preserves admitted deletion and native commits.
1323 ///
1324 /// # Errors
1325 ///
1326 /// Returns `Conflict` for a stale checkpoint, `Busy` for nonterminal/unknown work
1327 /// or a child still referenced by an active parent, and `Closed` after sealing or
1328 /// revocation. Storage failures use `Integration`. A successful seal survives
1329 /// observer loss and deletion resumes in bounded pages after reopen.
1330 ///
1331 /// See [execution and retained history](crate::workflow#execute-observe-restore-and-delete).
1332 pub async fn delete(&mut self, expected_checkpoint: u64) -> Result<(), AppError> {
1333 self.runtime.delete(expected_checkpoint).await
1334 }
1335
1336 /// Waits for the next actionable or terminal snapshot.
1337 ///
1338 /// This observes accepted work; it does not acquire execution ownership or resume
1339 /// a prepared run. A caller interaction or unknown effect can return before a
1340 /// terminal result. Dropping the observation future never requests cancellation.
1341 ///
1342 /// # Errors
1343 ///
1344 /// Returns `Closed` for revoked App ownership or sealed/deleted scope, `NotFound`
1345 /// for absent retained evidence, or `Integration` for inconsistent or unreadable
1346 /// records. A newer App generation can invalidate this handle.
1347 ///
1348 /// See [execution and retained history](crate::workflow#execute-observe-restore-and-delete).
1349 pub async fn wait(&self) -> Result<RunSnapshot, AppError> {
1350 self.runtime.wait().await
1351 }
1352
1353 /// Commits explicit checkpoint-fenced cancellation.
1354 ///
1355 /// # Errors
1356 ///
1357 /// Returns `Conflict` for a stale checkpoint, `Busy` for unavailable admission,
1358 /// `Closed` after revocation, or `Integration` for unreadable/failed storage.
1359 /// Cancellation admission is durable; observer loss does not reverse it.
1360 ///
1361 /// # Example
1362 ///
1363 /// ```
1364 /// # use agents_kit::Agent;
1365 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1366 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1367 /// # RunStatus, WorkflowDefinition, WorkflowState};
1368 /// # use storage_kit::StateStore;
1369 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1370 /// #
1371 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1372 /// # runtime.block_on(async {
1373 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1374 /// # let definition = WorkflowDefinition::parse(r#"{
1375 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1376 /// # "state":{"count":0}, "required":["count"],
1377 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1378 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1379 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1380 /// # ]}}], "routes":{}
1381 /// # }"#)?;
1382 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
1383 /// # let mut app = App::open(config).await?;
1384 /// # let outcome = async {
1385 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
1386 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
1387 /// # }).await?;
1388 /// # let workflow = workspace.workflow("counter".parse()?).await?;
1389 /// # let mut run = workflow.prepare(WorkflowState::default()).await?;
1390 /// let before = run.snapshot().await?;
1391 /// run.cancel(before.checkpoint).await?;
1392 /// let cancelled = run.wait().await?;
1393 /// assert!(matches!(cancelled.status, RunStatus::Cancelled));
1394 /// #
1395 /// # Ok::<(), Box<dyn std::error::Error>>(())
1396 /// # }.await;
1397 /// # let cleanup = app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await;
1398 /// # if let Err(error) = &cleanup { eprintln!("App shutdown: {error}"); }
1399 /// # outcome?;
1400 /// # cleanup?;
1401 /// # Ok::<(), Box<dyn std::error::Error>>(())
1402 /// # })?;
1403 /// # Ok::<(), Box<dyn std::error::Error>>(())
1404 /// ```
1405 pub async fn cancel(&self, expected_checkpoint: u64) -> Result<RunSnapshot, AppError> {
1406 self.runtime.cancel(expected_checkpoint).await
1407 }
1408
1409 /// Settles one exact interaction using its original target generation.
1410 ///
1411 /// Sibling checkpoints do not invalidate that generation. An identical
1412 /// decision replays its retained receipt; a different decision conflicts.
1413 /// Transient inputs replay only while their execution retains the values.
1414 ///
1415 /// # Errors
1416 ///
1417 /// Returns `InvalidInput` for a mismatched resolution or invalid schema/input,
1418 /// `Conflict` for a stale target generation, a different repeated decision, or an
1419 /// unsafe retry. Capacity, revocation, lower/storage failure and observation
1420 /// deadlines report `Busy`, `Closed`, `Integration`, or `Timeout`.
1421 ///
1422 /// # Example
1423 ///
1424 /// ```
1425 /// # use agents_kit::Agent;
1426 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1427 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1428 /// # RunStatus, WorkflowDefinition, WorkflowState};
1429 /// # use storage_kit::StateStore;
1430 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1431 /// #
1432 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1433 /// # runtime.block_on(async {
1434 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1435 /// # let definition = WorkflowDefinition::parse(r#"{
1436 /// # "schema":"app-kit.workflow/v2","id":"counter","revision":"r1",
1437 /// # "state":{"answer":""},"required":["answer"],"reducers":{"answer":"reject_concurrent"},
1438 /// # "entry":"ask","nodes":[{"id":"ask","step":{"kind":"input",
1439 /// # "message":[{"kind":"text","text":"Review topic?"}],"target":"answer"}}],"routes":{}
1440 /// # }"#)?;
1441 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
1442 /// # let mut app = App::open(config).await?;
1443 /// # let outcome = async {
1444 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
1445 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
1446 /// # }).await?;
1447 /// # let workflow = workspace.workflow("counter".parse()?).await?;
1448 /// use app_kit::workflow::{Interaction, Resolution};
1449 /// let run = workflow.start(ExecutionOptions::default()).await?;
1450 /// let waiting = run.wait().await?;
1451 /// let Interaction::Input { sequence, generation, .. } = waiting.interactions[0] else {
1452 /// panic!("the input workflow must request a value");
1453 /// };
1454 /// run.resolve(sequence, generation, Resolution::Input(serde_json::json!("ownership"))).await?;
1455 /// assert_eq!(run.wait().await?.state.values()["answer"], "ownership");
1456 /// #
1457 /// # Ok::<(), Box<dyn std::error::Error>>(())
1458 /// # }.await;
1459 /// # let cleanup = app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await;
1460 /// # if let Err(error) = &cleanup { eprintln!("App shutdown: {error}"); }
1461 /// # outcome?;
1462 /// # cleanup?;
1463 /// # Ok::<(), Box<dyn std::error::Error>>(())
1464 /// # })?;
1465 /// # Ok::<(), Box<dyn std::error::Error>>(())
1466 /// ```
1467 pub async fn resolve(
1468 &self,
1469 sequence: u64,
1470 generation: u64,
1471 resolution: Resolution,
1472 ) -> Result<RunSnapshot, AppError> {
1473 self.runtime.resolve(sequence, generation, resolution).await
1474 }
1475}
1476
1477impl WorkflowId {
1478 pub(crate) fn as_str(&self) -> &str {
1479 &self.0
1480 }
1481}
1482
1483impl RunId {
1484 pub(crate) fn validate_occurrence(occurrence: &str) -> Result<(), AppError> {
1485 if occurrence.len() == 32
1486 && occurrence
1487 .bytes()
1488 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1489 {
1490 Ok(())
1491 } else {
1492 Err(AppError::InvalidInput(
1493 "run occurrence must contain 32 lowercase hexadecimal digits".into(),
1494 ))
1495 }
1496 }
1497
1498 pub(crate) fn generate() -> Self {
1499 Self(format!("run_{}", Uuid::new_v4().hyphenated()))
1500 }
1501
1502 fn child(&self, task_key: &str) -> Self {
1503 let mut digest = Sha256::new();
1504 digest.update(self.as_str().as_bytes());
1505 digest.update(b":");
1506 digest.update(task_key.as_bytes());
1507 Self(format!("{:x}", digest.finalize()))
1508 }
1509
1510 pub(crate) fn as_str(&self) -> &str {
1511 &self.0
1512 }
1513}
1514
1515impl fmt::Display for WorkflowId {
1516 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1517 formatter.write_str(&self.0)
1518 }
1519}
1520
1521impl FromStr for WorkflowId {
1522 type Err = AppError;
1523
1524 fn from_str(value: &str) -> Result<Self, Self::Err> {
1525 IdentitySyntax::Named("WorkflowId").parse(value).map(Self)
1526 }
1527}
1528
1529impl Serialize for WorkflowId {
1530 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1531 where
1532 S: Serializer,
1533 {
1534 serializer.serialize_str(&self.0)
1535 }
1536}
1537
1538impl<'de> Deserialize<'de> for WorkflowId {
1539 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1540 where
1541 D: Deserializer<'de>,
1542 {
1543 String::deserialize(deserializer)?
1544 .parse()
1545 .map_err(serde::de::Error::custom)
1546 }
1547}
1548
1549impl fmt::Display for RunId {
1550 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1551 formatter.write_str(&self.0)
1552 }
1553}
1554
1555impl FromStr for RunId {
1556 type Err = AppError;
1557
1558 fn from_str(value: &str) -> Result<Self, Self::Err> {
1559 if value.len() == 64
1560 && value
1561 .bytes()
1562 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1563 {
1564 return Ok(Self(value.to_owned()));
1565 }
1566 IdentitySyntax::Uuid {
1567 name: "RunId",
1568 prefix: "run_",
1569 }
1570 .parse(value)
1571 .map(Self)
1572 }
1573}
1574
1575impl Serialize for RunId {
1576 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1577 where
1578 S: Serializer,
1579 {
1580 serializer.serialize_str(&self.0)
1581 }
1582}
1583
1584impl<'de> Deserialize<'de> for RunId {
1585 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1586 where
1587 D: Deserializer<'de>,
1588 {
1589 String::deserialize(deserializer)?
1590 .parse()
1591 .map_err(serde::de::Error::custom)
1592 }
1593}