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//! This local workflow admits a paused run, explicitly drives its lazy execution,
34//! and then reads immutable history. Merely obtaining a run snapshot does not
35//! advance a prepared run. Restore copies a retained checkpoint into another paused
36//! run; deletion removes only the selected terminal history, not its independent
37//! copy. The example needs no credentials, Agent value or subprocess.
38//!
39//! ```
40//! use agents_kit::Agent;
41//! use app_kit::{App, AppConfig, AppError, Shutdown, WorkspaceConfig};
42//! use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions,
43//! RestoreOptions, ResumePosition, RunSelector, RunStatus, WorkflowDefinition, WorkflowState};
44//! use storage_kit::StateStore;
45//! use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
46//! use std::{num::NonZeroUsize, time::Duration};
47//! # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
48//! # runtime.block_on(async {
49//! let store = StateStore::connect(StateStoreConfig::lance(LanceStateStoreConfig::memory())).await?;
50//! let definition = WorkflowDefinition::parse(r#"{
51//! "schema":"app-kit.workflow/v2","id":"counter","revision":"r1",
52//! "state":{"count":0},"required":["count"],"reducers":{"count":"reject_concurrent"},
53//! "entry":"increment","nodes":[{"id":"increment","step":{"kind":"state",
54//! "mutations":[{"kind":"increment","target":"count","value":{"kind":"literal","value":1}}]}}],
55//! "routes":{}
56//! }"#)?;
57//! let mut app = App::open(AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition])).await?;
58//! let outcome = async {
59//! let workspace = app.create_workspace(WorkspaceConfig {
60//! name: Some("history example".into()), root: std::env::current_dir()?,
61//! }).await?;
62//! let workflow = workspace.workflow("counter".parse()?).await?;
63//! let mut run = workflow.prepare(WorkflowState::default()).await?;
64//! assert!(matches!(run.snapshot().await?.status, RunStatus::Paused));
65//! assert_eq!(run.checkpoint(CheckpointSelector::Sequence(0)).await?.state.values()["count"], 0);
66//! let mut execution = run.execution(ExecutionOptions::default()).await?;
67//! let mut committed_actions = 0;
68//! while let Some(event) = execution.next().await? {
69//! if let ExecutionEvent::Committed { outcomes, .. } = event {
70//! committed_actions += outcomes.len();
71//! }
72//! }
73//! let terminal = execution.finish().await?;
74//! assert!(matches!(terminal.status, RunStatus::Succeeded));
75//! assert_eq!(terminal.state.values()["count"], 1);
76//! assert_eq!(committed_actions, 1);
77//!
78//! let first = run.history(None, NonZeroUsize::new(1).unwrap()).await?;
79//! assert_eq!(first.checkpoints[0].sequence, 0);
80//! let cursor = first.next.expect("the action has a later checkpoint");
81//! let later = run.history(Some(cursor), NonZeroUsize::new(10).unwrap()).await?;
82//! assert!(later.checkpoints.iter().any(|checkpoint| checkpoint.action_ordinal == Some(0)));
83//! let mut copy = workflow.restore(RestoreOptions {
84//! source: RunSelector::Latest(run.id().clone()), checkpoint: CheckpointSelector::Action(0),
85//! overrides: WorkflowState::default(), position: ResumePosition::AfterCompleted, fallback: None,
86//! }).await?;
87//! assert_ne!(copy.id(), run.id());
88//! assert!(matches!(copy.snapshot().await?.status, RunStatus::Paused));
89//! let copied = copy.checkpoint(CheckpointSelector::Sequence(0)).await?;
90//! assert_eq!(copied.state.values()["count"], 1);
91//! assert_eq!(&copied.lineage.as_ref().unwrap().source, run.id());
92//! let copied_terminal = copy.execution(ExecutionOptions::default()).await?.finish().await?;
93//! assert!(matches!(copied_terminal.status, RunStatus::Succeeded));
94//! copy.delete(copied_terminal.checkpoint).await?;
95//! let original_id = run.id().clone();
96//! run.delete(terminal.checkpoint).await?;
97//! assert!(matches!(workflow.open_run(RunSelector::Latest(original_id)).await, Err(AppError::NotFound(_))));
98//! Ok::<(), Box<dyn std::error::Error>>(())
99//! }.await;
100//! let cleanup = app.shutdown(Shutdown::CancelAndDrain { timeout: Duration::from_secs(5) }).await;
101//! outcome?; // An execution error remains primary if shutdown also reports an error.
102//! cleanup?;
103//! # Ok::<(), Box<dyn std::error::Error>>(())
104//! # })?;
105//! # Ok::<(), Box<dyn std::error::Error>>(())
106//! ```
107//!
108//! A real Agent-backed workflow can stop at an actionable interaction rather than
109//! success: inspect [`crate::workflow::RunSnapshot::interactions`] and [`crate::workflow::RunStatus`] before choosing
110//! an explicit resolution. Caller actions own their external effects; a discarded
111//! uncommitted workflow state change does not undo external I/O.
112//!
113//! # Configure caller behavior
114//!
115//! [`crate::AppConfig`]'s configuration map links the complete implementations of
116//! all five behavior traits and their consuming attachment methods. Actions and
117//! conditions use exact declared keys/revisions; typing validates initial and
118//! resulting state; hooks observe lifecycle boundaries; child executors drive the
119//! already-admitted concrete child futures. None changes the lower Agent or
120//! StateStore associated child types. See [`crate::App::open`] for durable reopen
121//! and the cold migration boundary.
122
123use std::fmt;
124use std::num::NonZeroUsize;
125use std::str::FromStr;
126use std::sync::{Arc, RwLock};
127
128use agents_kit::AgentBackend;
129use agents_kit::notification::{ConversationNotification, NotificationResolution};
130use serde::{Deserialize, Deserializer, Serialize, Serializer};
131use serde_json::{Map, Value};
132use sha2::{Digest, Sha256};
133use storage_kit::StateStoreBackend;
134use uuid::Uuid;
135
136use crate::{AppError, IdentitySyntax};
137
138mod action;
139mod child;
140pub(crate) mod definition;
141mod execution;
142pub(crate) use execution::{MAX_FRONTIER, StagedOutcomeRecord};
143mod graph;
144mod history;
145pub(crate) mod interaction;
146mod lifecycle;
147pub(crate) mod run;
148mod serialization;
149mod state;
150
151pub use action::{
152 ActionBackend, ActionContext, ActionDeclaration, ActionEvent, ActionMode, ActionRequest,
153 ConditionBackend, ConditionDeclaration, NoActions, NoConditions,
154};
155pub use child::{ChildBatch, ChildCompletion, ChildExecutor, InlineExecutor};
156pub use history::{
157 ActionOutcome, Checkpoint, CheckpointKind, CheckpointSelector, HistoryCursor, HistoryPage,
158 RestoreOptions, ResumePosition, RunLineage,
159};
160pub use lifecycle::{LifecycleEvent, LifecycleHooks, NoHooks};
161pub use run::session::{Execution, SyncExecution};
162
163/// Immutable, ordered workflow values validated at each operation boundary.
164///
165/// ```
166/// use app_kit::workflow::{JsonState, StateChange, WorkflowState};
167/// use serde_json::{Map, Value};
168/// let before = WorkflowState::new(Map::from_iter([("count".into(), Value::from(1))]))?;
169/// let after = before.apply(&StateChange::Increment {
170/// values: vec![("count".into(), Value::from(1))],
171/// }, &JsonState::default())?;
172/// assert_eq!(before.values()["count"], 1);
173/// assert_eq!(after.values()["count"], 2);
174/// # Ok::<(), app_kit::AppError>(())
175/// ```
176///
177/// Changes go through the validated state operations:
178///
179/// ```compile_fail
180/// use app_kit::workflow::WorkflowState;
181/// let mut state = WorkflowState::default();
182/// state.values.clear();
183/// ```
184#[derive(Clone, Debug, Default, PartialEq)]
185pub struct WorkflowState {
186 values: Map<String, Value>,
187}
188
189/// Process-global field encoders, field decoders and tagged JSON decoders.
190///
191/// Registrations are exact names and replacements affect the next operation.
192/// Each operation retains one registry snapshot and invokes callbacks without
193/// holding its lock. The only construction entrypoint is [`Self::global`].
194///
195/// ```
196/// use app_kit::workflow::StateSerialization;
197/// StateSerialization::global().register_tag("text".into(), |value| {
198/// value.get("value").cloned().ok_or("missing text value")
199/// })?;
200/// # Ok::<(), app_kit::AppError>(())
201/// ```
202///
203/// Only field/tag JSON codecs enter this registry; actions, hooks, child executors
204/// and lower backends remain concrete configuration values. Registration does not
205/// persist callable identity; callers version their own portable codec payloads.
206pub struct StateSerialization {
207 registry: RwLock<Arc<serialization::SerializationRegistry>>,
208}
209
210/// A resolved immutable state operation; expressions remain private syntax.
211///
212/// Each payload is one atomic operation with at most 4,096 entries. Repeated
213/// Append/Extend/Increment keys apply in order; any later failure discards the
214/// whole candidate. Merge and Subset use full-state validation rather than
215/// primitive transition callbacks. Results never mutate the original state.
216pub enum StateChange {
217 /// Replaces values atomically in payload order, preserving existing positions.
218 Assign {
219 /// Complete values to replace or insert.
220 values: Map<String, Value>,
221 },
222 /// Appends one item per entry, creating arrays for missing keys.
223 Append {
224 /// Ordered entries; repeated keys append repeatedly within one operation.
225 values: Vec<(String, Value)>,
226 },
227 /// Extends arrays atomically without recursively flattening their items.
228 Extend {
229 /// Ordered entries; repeated keys preserve every supplied item.
230 values: Vec<(String, Vec<Value>)>,
231 },
232 /// Adds signed integer deltas to integer values, or zero for missing keys.
233 Increment {
234 /// Ordered deltas, each representable as i64; repeated keys fold in order.
235 values: Vec<(String, Value)>,
236 },
237 /// Removes keys atomically, preserving the order of other keys.
238 Remove {
239 /// Missing and repeated names are harmless.
240 keys: Vec<String>,
241 },
242 /// Keeps keys in their existing order; typing observes resolved Remove keys.
243 Wipe {
244 /// Keys to retain; missing and repeated names are harmless.
245 keep: Vec<String>,
246 },
247 /// Replaces overlapping values and appends new keys in incoming order.
248 Merge {
249 /// Values to merge into the current state.
250 state: WorkflowState,
251 },
252 /// Selects present keys in the requested order.
253 Subset {
254 /// Ordered keys to select; missing keys are ignored.
255 keys: Vec<String>,
256 },
257}
258
259/// Caller-owned typed construction and state validation.
260///
261/// Data is created and consumed locally and need not be Clone, Send or serializable.
262/// The default roundtrip checks successful reconstruction, not raw equality.
263///
264/// # Example
265///
266/// ```
267/// use app_kit::{AppError, workflow::{StateTyping, StateChange, WorkflowState}};
268/// use serde_json::{Map, Value};
269/// struct Counter;
270/// impl StateTyping for Counter {
271/// type Data = i64;
272/// fn construct_data(&self, state: &WorkflowState) -> Result<i64, AppError> {
273/// state.values().get("count").and_then(Value::as_i64)
274/// .ok_or_else(|| AppError::InvalidInput("count must be an integer".into()))
275/// }
276/// fn construct_state(&self, count: &i64) -> Result<WorkflowState, AppError> {
277/// WorkflowState::new(Map::from_iter([("count".into(), Value::from(*count))]))
278/// }
279/// fn validate_pre(&self, _state: &WorkflowState, change: &StateChange) -> Result<(), AppError> {
280/// if let StateChange::Increment { values } = change {
281/// if values.iter().any(|(_, delta)| delta.as_i64().is_some_and(|value| value < 0)) {
282/// return Err(AppError::InvalidInput("count cannot decrease".into()));
283/// }
284/// }
285/// Ok(())
286/// }
287/// fn validate_post(&self, before: &WorkflowState, after: &WorkflowState, _change: &StateChange) -> Result<(), AppError> {
288/// if self.construct_data(after)? < self.construct_data(before)? {
289/// return Err(AppError::InvalidInput("count cannot decrease".into()));
290/// }
291/// Ok(())
292/// }
293/// fn validate_state(&self, state: &WorkflowState) -> Result<(), AppError> {
294/// if self.construct_data(state)? < 0 {
295/// return Err(AppError::InvalidInput("count must be nonnegative".into()));
296/// }
297/// Ok(())
298/// }
299/// }
300/// let before = Counter.construct_state(&2)?;
301/// let change = StateChange::Increment { values: vec![("count".into(), Value::from(1))] };
302/// let after = before.apply(&change, &Counter)?;
303/// assert_eq!(Counter.construct_data(&after)?, 3);
304/// assert!(Counter.validate_state(&Counter.construct_state(&-1)?).is_err());
305/// # Ok::<(), AppError>(())
306/// ```
307pub trait StateTyping: Send + Sync + 'static {
308 /// Caller-specific data produced from workflow values.
309 type Data;
310
311 /// Constructs caller data from a validated state.
312 ///
313 /// # Errors
314 ///
315 /// Return an error when the state cannot be represented as `Self::Data`.
316 ///
317 /// # Example
318 ///
319 /// ```
320 /// use app_kit::{AppError, workflow::{StateTyping, StateChange, WorkflowState}};
321 /// use serde_json::{Map, Value};
322 /// struct Counter;
323 /// impl StateTyping for Counter {
324 /// type Data = i64;
325 /// fn construct_data(&self, state: &WorkflowState) -> Result<i64, AppError> {
326 /// state.values().get("count").and_then(Value::as_i64)
327 /// .ok_or_else(|| AppError::InvalidInput("count must be an integer".into()))
328 /// }
329 /// fn construct_state(&self, count: &i64) -> Result<WorkflowState, AppError> {
330 /// WorkflowState::new(Map::from_iter([("count".into(), Value::from(*count))]))
331 /// }
332 /// fn validate_pre(&self, _state: &WorkflowState, change: &StateChange) -> Result<(), AppError> {
333 /// if let StateChange::Increment { values } = change {
334 /// if values.iter().any(|(_, delta)| delta.as_i64().is_some_and(|value| value < 0)) {
335 /// return Err(AppError::InvalidInput("count cannot decrease".into()));
336 /// }
337 /// }
338 /// Ok(())
339 /// }
340 /// fn validate_post(&self, before: &WorkflowState, after: &WorkflowState, _change: &StateChange) -> Result<(), AppError> {
341 /// if self.construct_data(after)? < self.construct_data(before)? {
342 /// return Err(AppError::InvalidInput("count cannot decrease".into()));
343 /// }
344 /// Ok(())
345 /// }
346 /// fn validate_state(&self, state: &WorkflowState) -> Result<(), AppError> {
347 /// if self.construct_data(state)? < 0 {
348 /// return Err(AppError::InvalidInput("count must be nonnegative".into()));
349 /// }
350 /// Ok(())
351 /// }
352 /// }
353 /// let before = Counter.construct_state(&2)?;
354 /// let change = StateChange::Increment { values: vec![("count".into(), Value::from(1))] };
355 /// let after = before.apply(&change, &Counter)?;
356 /// assert_eq!(Counter.construct_data(&after)?, 3);
357 /// # Ok::<(), AppError>(())
358 /// ```
359 fn construct_data(&self, state: &WorkflowState) -> Result<Self::Data, AppError>;
360 /// Reconstructs workflow values from caller data.
361 ///
362 /// # Errors
363 ///
364 /// Return an error when caller data cannot produce a valid workflow state.
365 ///
366 /// # Example
367 ///
368 /// ```
369 /// use app_kit::{AppError, workflow::{StateTyping, StateChange, WorkflowState}};
370 /// use serde_json::{Map, Value};
371 /// struct Counter;
372 /// impl StateTyping for Counter {
373 /// type Data = i64;
374 /// fn construct_data(&self, state: &WorkflowState) -> Result<i64, AppError> {
375 /// state.values().get("count").and_then(Value::as_i64)
376 /// .ok_or_else(|| AppError::InvalidInput("count must be an integer".into()))
377 /// }
378 /// fn construct_state(&self, count: &i64) -> Result<WorkflowState, AppError> {
379 /// WorkflowState::new(Map::from_iter([("count".into(), Value::from(*count))]))
380 /// }
381 /// fn validate_pre(&self, _state: &WorkflowState, change: &StateChange) -> Result<(), AppError> {
382 /// if let StateChange::Increment { values } = change {
383 /// if values.iter().any(|(_, delta)| delta.as_i64().is_some_and(|value| value < 0)) {
384 /// return Err(AppError::InvalidInput("count cannot decrease".into()));
385 /// }
386 /// }
387 /// Ok(())
388 /// }
389 /// fn validate_post(&self, before: &WorkflowState, after: &WorkflowState, _change: &StateChange) -> Result<(), AppError> {
390 /// if self.construct_data(after)? < self.construct_data(before)? {
391 /// return Err(AppError::InvalidInput("count cannot decrease".into()));
392 /// }
393 /// Ok(())
394 /// }
395 /// fn validate_state(&self, state: &WorkflowState) -> Result<(), AppError> {
396 /// if self.construct_data(state)? < 0 {
397 /// return Err(AppError::InvalidInput("count must be nonnegative".into()));
398 /// }
399 /// Ok(())
400 /// }
401 /// }
402 /// let before = Counter.construct_state(&2)?;
403 /// let change = StateChange::Increment { values: vec![("count".into(), Value::from(1))] };
404 /// let after = before.apply(&change, &Counter)?;
405 /// let reconstructed = Counter.construct_state(&3)?;
406 /// assert_eq!(reconstructed, after);
407 /// # Ok::<(), AppError>(())
408 /// ```
409 fn construct_state(&self, data: &Self::Data) -> Result<WorkflowState, AppError>;
410 /// Validates an operation before any candidate mutation.
411 ///
412 /// # Errors
413 ///
414 /// Return an error to reject the complete primitive operation before candidate
415 /// mutation. The default accepts it; Merge and Subset do not invoke this method.
416 ///
417 /// # Example
418 ///
419 /// ```
420 /// use app_kit::{AppError, workflow::{StateTyping, StateChange, WorkflowState}};
421 /// use serde_json::{Map, Value};
422 /// struct Counter;
423 /// impl StateTyping for Counter {
424 /// type Data = i64;
425 /// fn construct_data(&self, state: &WorkflowState) -> Result<i64, AppError> {
426 /// state.values().get("count").and_then(Value::as_i64)
427 /// .ok_or_else(|| AppError::InvalidInput("count must be an integer".into()))
428 /// }
429 /// fn construct_state(&self, count: &i64) -> Result<WorkflowState, AppError> {
430 /// WorkflowState::new(Map::from_iter([("count".into(), Value::from(*count))]))
431 /// }
432 /// fn validate_pre(&self, _state: &WorkflowState, change: &StateChange) -> Result<(), AppError> {
433 /// if let StateChange::Increment { values } = change {
434 /// if values.iter().any(|(_, delta)| delta.as_i64().is_some_and(|value| value < 0)) {
435 /// return Err(AppError::InvalidInput("count cannot decrease".into()));
436 /// }
437 /// }
438 /// Ok(())
439 /// }
440 /// fn validate_post(&self, before: &WorkflowState, after: &WorkflowState, _change: &StateChange) -> Result<(), AppError> {
441 /// if self.construct_data(after)? < self.construct_data(before)? {
442 /// return Err(AppError::InvalidInput("count cannot decrease".into()));
443 /// }
444 /// Ok(())
445 /// }
446 /// fn validate_state(&self, state: &WorkflowState) -> Result<(), AppError> {
447 /// if self.construct_data(state)? < 0 {
448 /// return Err(AppError::InvalidInput("count must be nonnegative".into()));
449 /// }
450 /// Ok(())
451 /// }
452 /// }
453 /// let before = Counter.construct_state(&2)?;
454 /// let change = StateChange::Increment { values: vec![("count".into(), Value::from(1))] };
455 /// let after = before.apply(&change, &Counter)?;
456 /// Counter.validate_pre(&before, &change)?;
457 /// let decreasing = StateChange::Increment { values: vec![("count".into(), Value::from(-1))] };
458 /// assert!(Counter.validate_pre(&before, &decreasing).is_err());
459 /// assert_eq!(before.values()["count"], 2);
460 /// # Ok::<(), AppError>(())
461 /// ```
462 fn validate_pre(&self, _state: &WorkflowState, _change: &StateChange) -> Result<(), AppError> {
463 Ok(())
464 }
465 /// Validates the original and candidate states after the operation.
466 ///
467 /// # Errors
468 ///
469 /// Return an error to reject the complete candidate primitive transition. The
470 /// original state remains unchanged. The default accepts it; Merge and Subset
471 /// do not invoke this method.
472 ///
473 /// # Example
474 ///
475 /// ```
476 /// use app_kit::{AppError, workflow::{StateTyping, StateChange, WorkflowState}};
477 /// use serde_json::{Map, Value};
478 /// struct Counter;
479 /// impl StateTyping for Counter {
480 /// type Data = i64;
481 /// fn construct_data(&self, state: &WorkflowState) -> Result<i64, AppError> {
482 /// state.values().get("count").and_then(Value::as_i64)
483 /// .ok_or_else(|| AppError::InvalidInput("count must be an integer".into()))
484 /// }
485 /// fn construct_state(&self, count: &i64) -> Result<WorkflowState, AppError> {
486 /// WorkflowState::new(Map::from_iter([("count".into(), Value::from(*count))]))
487 /// }
488 /// fn validate_pre(&self, _state: &WorkflowState, change: &StateChange) -> Result<(), AppError> {
489 /// if let StateChange::Increment { values } = change {
490 /// if values.iter().any(|(_, delta)| delta.as_i64().is_some_and(|value| value < 0)) {
491 /// return Err(AppError::InvalidInput("count cannot decrease".into()));
492 /// }
493 /// }
494 /// Ok(())
495 /// }
496 /// fn validate_post(&self, before: &WorkflowState, after: &WorkflowState, _change: &StateChange) -> Result<(), AppError> {
497 /// if self.construct_data(after)? < self.construct_data(before)? {
498 /// return Err(AppError::InvalidInput("count cannot decrease".into()));
499 /// }
500 /// Ok(())
501 /// }
502 /// fn validate_state(&self, state: &WorkflowState) -> Result<(), AppError> {
503 /// if self.construct_data(state)? < 0 {
504 /// return Err(AppError::InvalidInput("count must be nonnegative".into()));
505 /// }
506 /// Ok(())
507 /// }
508 /// }
509 /// let before = Counter.construct_state(&2)?;
510 /// let change = StateChange::Increment { values: vec![("count".into(), Value::from(1))] };
511 /// let after = before.apply(&change, &Counter)?;
512 /// Counter.validate_post(&before, &after, &change)?;
513 /// let decreasing = Counter.construct_state(&1)?;
514 /// assert!(Counter.validate_post(&before, &decreasing, &change).is_err());
515 /// assert_eq!(after.values()["count"], 3);
516 /// # Ok::<(), AppError>(())
517 /// ```
518 fn validate_post(
519 &self,
520 _before: &WorkflowState,
521 _after: &WorkflowState,
522 _change: &StateChange,
523 ) -> Result<(), AppError> {
524 Ok(())
525 }
526 /// Validates all candidate values after transition validation.
527 ///
528 /// # Errors
529 ///
530 /// Return an error when any full-state invariant is violated. The default accepts
531 /// the state; this method also runs for reconstructed Merge/Subset snapshots.
532 ///
533 /// # Example
534 ///
535 /// ```
536 /// use app_kit::{AppError, workflow::{StateTyping, StateChange, WorkflowState}};
537 /// use serde_json::{Map, Value};
538 /// struct Counter;
539 /// impl StateTyping for Counter {
540 /// type Data = i64;
541 /// fn construct_data(&self, state: &WorkflowState) -> Result<i64, AppError> {
542 /// state.values().get("count").and_then(Value::as_i64)
543 /// .ok_or_else(|| AppError::InvalidInput("count must be an integer".into()))
544 /// }
545 /// fn construct_state(&self, count: &i64) -> Result<WorkflowState, AppError> {
546 /// WorkflowState::new(Map::from_iter([("count".into(), Value::from(*count))]))
547 /// }
548 /// fn validate_pre(&self, _state: &WorkflowState, change: &StateChange) -> Result<(), AppError> {
549 /// if let StateChange::Increment { values } = change {
550 /// if values.iter().any(|(_, delta)| delta.as_i64().is_some_and(|value| value < 0)) {
551 /// return Err(AppError::InvalidInput("count cannot decrease".into()));
552 /// }
553 /// }
554 /// Ok(())
555 /// }
556 /// fn validate_post(&self, before: &WorkflowState, after: &WorkflowState, _change: &StateChange) -> Result<(), AppError> {
557 /// if self.construct_data(after)? < self.construct_data(before)? {
558 /// return Err(AppError::InvalidInput("count cannot decrease".into()));
559 /// }
560 /// Ok(())
561 /// }
562 /// fn validate_state(&self, state: &WorkflowState) -> Result<(), AppError> {
563 /// if self.construct_data(state)? < 0 {
564 /// return Err(AppError::InvalidInput("count must be nonnegative".into()));
565 /// }
566 /// Ok(())
567 /// }
568 /// }
569 /// let before = Counter.construct_state(&2)?;
570 /// let change = StateChange::Increment { values: vec![("count".into(), Value::from(1))] };
571 /// let after = before.apply(&change, &Counter)?;
572 /// assert!(Counter.validate_state(&Counter.construct_state(&-1)?).is_err());
573 /// # Ok::<(), AppError>(())
574 /// ```
575 fn validate_state(&self, _state: &WorkflowState) -> Result<(), AppError> {
576 Ok(())
577 }
578 /// Verifies both caller conversions succeed; a different reconstruction is allowed.
579 ///
580 /// # Errors
581 ///
582 /// The default propagates either conversion error. Successful reconstruction is
583 /// sufficient; byte-for-byte or value equality is not required.
584 ///
585 /// # Example
586 ///
587 /// ```
588 /// use app_kit::{AppError, workflow::{StateTyping, StateChange, WorkflowState}};
589 /// use serde_json::{Map, Value};
590 /// struct Counter;
591 /// impl StateTyping for Counter {
592 /// type Data = i64;
593 /// fn construct_data(&self, state: &WorkflowState) -> Result<i64, AppError> {
594 /// state.values().get("count").and_then(Value::as_i64)
595 /// .ok_or_else(|| AppError::InvalidInput("count must be an integer".into()))
596 /// }
597 /// fn construct_state(&self, count: &i64) -> Result<WorkflowState, AppError> {
598 /// WorkflowState::new(Map::from_iter([("count".into(), Value::from(*count))]))
599 /// }
600 /// fn validate_pre(&self, _state: &WorkflowState, change: &StateChange) -> Result<(), AppError> {
601 /// if let StateChange::Increment { values } = change {
602 /// if values.iter().any(|(_, delta)| delta.as_i64().is_some_and(|value| value < 0)) {
603 /// return Err(AppError::InvalidInput("count cannot decrease".into()));
604 /// }
605 /// }
606 /// Ok(())
607 /// }
608 /// fn validate_post(&self, before: &WorkflowState, after: &WorkflowState, _change: &StateChange) -> Result<(), AppError> {
609 /// if self.construct_data(after)? < self.construct_data(before)? {
610 /// return Err(AppError::InvalidInput("count cannot decrease".into()));
611 /// }
612 /// Ok(())
613 /// }
614 /// fn validate_state(&self, state: &WorkflowState) -> Result<(), AppError> {
615 /// if self.construct_data(state)? < 0 {
616 /// return Err(AppError::InvalidInput("count must be nonnegative".into()));
617 /// }
618 /// Ok(())
619 /// }
620 /// }
621 /// let before = Counter.construct_state(&2)?;
622 /// let change = StateChange::Increment { values: vec![("count".into(), Value::from(1))] };
623 /// let after = before.apply(&change, &Counter)?;
624 /// Counter.validate_roundtrip(&after)?;
625 /// assert_eq!(Counter.construct_data(&after)?, 3);
626 /// # Ok::<(), AppError>(())
627 /// ```
628 fn validate_roundtrip(&self, state: &WorkflowState) -> Result<(), AppError> {
629 let data = self.construct_data(state)?;
630 self.construct_state(&data).map(|_| ())
631 }
632}
633
634/// Raw JSON typing with no additional conversion or validation policy.
635///
636/// # Example
637///
638/// ```
639/// use app_kit::workflow::{JsonState, StateTyping, WorkflowState};
640/// let state = WorkflowState::new(serde_json::from_str(r#"{"count":2}"#)?)?;
641/// let data = JsonState.construct_data(&state)?;
642/// assert_eq!(data["count"], 2);
643/// assert_eq!(JsonState.construct_state(&data)?, state);
644/// # Ok::<(), Box<dyn std::error::Error>>(())
645/// ```
646#[derive(Clone, Copy, Debug, Default)]
647pub struct JsonState;
648
649impl StateTyping for JsonState {
650 type Data = Map<String, Value>;
651
652 fn construct_data(&self, state: &WorkflowState) -> Result<Self::Data, AppError> {
653 Ok(state.values.clone())
654 }
655
656 fn construct_state(&self, data: &Self::Data) -> Result<WorkflowState, AppError> {
657 WorkflowState::new(data.clone())
658 }
659
660 fn validate_roundtrip(&self, _state: &WorkflowState) -> Result<(), AppError> {
661 Ok(())
662 }
663}
664
665impl WorkflowState {
666 /// Validates ordered JSON values and takes ownership of them.
667 ///
668 /// ```
669 /// use app_kit::workflow::WorkflowState;
670 /// let values = serde_json::from_str(r#"{"first":1,"second":2}"#)?;
671 /// let state = WorkflowState::new(values)?;
672 /// assert_eq!(state.values().keys().collect::<Vec<_>>(), ["first", "second"]);
673 /// # Ok::<(), Box<dyn std::error::Error>>(())
674 /// ```
675 ///
676 /// # Errors
677 ///
678 /// Returns `InvalidInput` for invalid state keys or exceeded JSON byte, nesting,
679 /// collection, or text bounds. Ownership is accepted only after full validation.
680 pub fn new(values: Map<String, Value>) -> Result<Self, AppError> {
681 definition::JsonBoundary::validate_map(&values)?;
682 Ok(Self { values })
683 }
684
685 /// Borrows values in their insertion order.
686 ///
687 /// ```
688 /// use app_kit::workflow::WorkflowState;
689 /// let state = WorkflowState::default();
690 /// assert!(state.values().get("optional_input").is_none());
691 /// ```
692 pub fn values(&self) -> &Map<String, Value> {
693 &self.values
694 }
695
696 /// Produces a new state with the pinned operation-specific validation order.
697 ///
698 /// Primitive changes run pre/post transition validation, then full-state
699 /// validation and roundtrip. Merge and subset construct new snapshots and
700 /// run only full-state validation and roundtrip.
701 /// Any failure leaves this state and its retained clones unchanged.
702 ///
703 /// ```
704 /// use app_kit::workflow::{JsonState, StateChange, WorkflowState};
705 /// use serde_json::{Map, Value};
706 /// let state = WorkflowState::new(Map::from_iter([
707 /// ("count".into(), Value::from("unknown")),
708 /// ]))?;
709 /// let change = StateChange::Increment {
710 /// values: vec![("count".into(), Value::from(1))],
711 /// };
712 /// assert!(state.apply(&change, &JsonState).is_err());
713 /// assert_eq!(state.values()["count"], "unknown");
714 /// # Ok::<(), app_kit::AppError>(())
715 /// ```
716 ///
717 /// # Errors
718 ///
719 /// Returns `InvalidInput` for incompatible operand types, signed-integer delta or
720 /// range errors, invalid state, or excessive operation payloads. Caller typing
721 /// errors propagate unchanged; failure never mutates the original state.
722 ///
723 /// # Panics
724 ///
725 /// Panics if the caller-provided typing implementation panics. Direct state
726 /// operations do not contain caller panics; App admission/execution has its own
727 /// boundary for classifying them.
728 pub fn apply<T: StateTyping>(
729 &self,
730 change: &StateChange,
731 typing: &T,
732 ) -> Result<Self, AppError> {
733 if let StateChange::Wipe { keep } = change {
734 StateChange::validate_count(keep.len())?;
735 let keys = self
736 .values
737 .keys()
738 .filter(|key| !keep.contains(key))
739 .cloned()
740 .collect();
741 return self.apply(&StateChange::Remove { keys }, typing);
742 }
743 let snapshot = matches!(
744 change,
745 StateChange::Merge { .. } | StateChange::Subset { .. }
746 );
747 if !snapshot {
748 typing.validate_pre(self, change)?;
749 }
750 let candidate = Self::new(change.apply_values(&self.values)?)?;
751 if !snapshot {
752 typing.validate_post(self, &candidate, change)?;
753 }
754 candidate.validate_typing(typing)?;
755 Ok(candidate)
756 }
757
758 fn validate_typing<T: StateTyping>(&self, typing: &T) -> Result<(), AppError> {
759 typing.validate_state(self)?;
760 typing.validate_roundtrip(self)
761 }
762}
763
764/// A workflow selected in one application workspace.
765///
766/// # Example
767///
768/// ```
769/// # use agents_kit::Agent;
770/// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
771/// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
772/// # RunStatus, WorkflowDefinition, WorkflowState};
773/// # use storage_kit::StateStore;
774/// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
775/// #
776/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
777/// # runtime.block_on(async {
778/// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
779/// # let definition = WorkflowDefinition::parse(r#"{
780/// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
781/// # "state":{"count":0}, "required":["count"],
782/// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
783/// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
784/// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
785/// # ]}}], "routes":{}
786/// # }"#)?;
787/// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
788/// # let mut app = App::open(config).await?;
789/// # let mut workspace = app.create_workspace(WorkspaceConfig {
790/// # name: Some("local counter".into()), root: std::env::current_dir()?,
791/// # }).await?;
792/// # let workflow = workspace.workflow("counter".parse()?).await?;
793/// let run = workflow.start(ExecutionOptions::default()).await?;
794/// assert!(matches!(run.wait().await?.status, RunStatus::Succeeded));
795/// #
796/// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
797/// # Ok::<(), Box<dyn std::error::Error>>(())
798/// # })?;
799/// # Ok::<(), Box<dyn std::error::Error>>(())
800/// ```
801pub struct Workflow<
802 CS: StateStoreBackend,
803 A: AgentBackend,
804 X: ActionBackend = NoActions,
805 C: ConditionBackend = NoConditions,
806 T: StateTyping = JsonState,
807 H: LifecycleHooks = NoHooks,
808 E: ChildExecutor = InlineExecutor,
809> {
810 runtime: run::WorkflowRuntime<CS, A, X, C, T, H, E>,
811}
812
813/// A durable workflow-run handle.
814///
815/// This handle observes durable state and requests explicit controls. Dropping it
816/// or a `wait`/snapshot future does not cancel accepted work. [`Self::execution`]
817/// borrows it exclusively and returns an owner whose drop does revoke uncommitted
818/// advancement. Use [`Self::cancel`] for durable, monotonic cancellation.
819///
820/// # Example
821///
822/// ```
823/// # use agents_kit::Agent;
824/// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
825/// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
826/// # RunStatus, WorkflowDefinition, WorkflowState};
827/// # use storage_kit::StateStore;
828/// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
829/// #
830/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
831/// # runtime.block_on(async {
832/// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
833/// # let definition = WorkflowDefinition::parse(r#"{
834/// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
835/// # "state":{"count":0}, "required":["count"],
836/// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
837/// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
838/// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
839/// # ]}}], "routes":{}
840/// # }"#)?;
841/// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
842/// # let mut app = App::open(config).await?;
843/// # let mut workspace = app.create_workspace(WorkspaceConfig {
844/// # name: Some("local counter".into()), root: std::env::current_dir()?,
845/// # }).await?;
846/// # let workflow = workspace.workflow("counter".parse()?).await?;
847/// # let mut run = workflow.prepare(WorkflowState::default()).await?;
848/// let terminal = run.execution(ExecutionOptions::default()).await?.finish().await?;
849/// assert!(matches!(terminal.status, RunStatus::Succeeded));
850/// assert_eq!(run.snapshot().await?.state.values()["count"], 1);
851/// #
852/// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
853/// # Ok::<(), Box<dyn std::error::Error>>(())
854/// # })?;
855/// # Ok::<(), Box<dyn std::error::Error>>(())
856/// ```
857pub struct WorkflowRun<
858 CS: StateStoreBackend,
859 A: AgentBackend,
860 X: ActionBackend = NoActions,
861 C: ConditionBackend = NoConditions,
862 T: StateTyping = JsonState,
863 H: LifecycleHooks = NoHooks,
864 E: ChildExecutor = InlineExecutor,
865> {
866 runtime: run::WorkflowRunRuntime<CS, A, X, C, T, H, E>,
867}
868
869/// One opaque, parsed `app-kit.workflow/v2` definition.
870///
871/// Construct definitions through [`Self::parse`]; their internals cannot be
872/// copied through struct update syntax:
873///
874/// ```compile_fail
875/// use app_kit::workflow::WorkflowDefinition;
876/// fn reconstruct(definition: WorkflowDefinition) -> WorkflowDefinition {
877/// WorkflowDefinition { ..definition }
878/// }
879/// ```
880///
881/// Only the strict versioned language is accepted. Unknown/duplicate fields,
882/// invalid expressions, graph references and finite-bound violations fail before
883/// runtime work. Inspection returns passive metadata; composition runs the same
884/// compiler rather than exposing a mutable executable AST.
885///
886/// # Example
887///
888/// ```
889/// use app_kit::workflow::WorkflowDefinition;
890///
891/// let definition = WorkflowDefinition::parse(r#"{
892/// "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
893/// "state":{"count":0}, "required":["count"],
894/// "reducers":{"count":"reject_concurrent"}, "entry":"increment",
895/// "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
896/// {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
897/// ]}}], "routes":{}
898/// }"#)?;
899/// assert_eq!(definition.graph().entry, "increment");
900/// # Ok::<(), app_kit::AppError>(())
901/// ```
902pub struct WorkflowDefinition {
903 compiled: definition::CompiledDefinition,
904}
905
906/// A deterministic read-only graph export format.
907pub enum GraphFormat {
908 /// Portable graph metadata as JSON, without runtime state.
909 Json,
910 /// Graphviz DOT text; rendering requires no subprocess in this crate.
911 Dot,
912 /// Mermaid flowchart text with escaped caller labels.
913 Mermaid,
914}
915
916/// Passive graph metadata in authored node and transition order.
917///
918/// # Example
919///
920/// ```
921/// use app_kit::workflow::WorkflowDefinition;
922///
923/// let definition = WorkflowDefinition::parse(r#"{
924/// "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
925/// "state":{"count":0}, "required":["count"],
926/// "reducers":{"count":"reject_concurrent"}, "entry":"increment",
927/// "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
928/// {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
929/// ]}}], "routes":{}
930/// }"#)?;
931/// let graph = definition.graph();
932/// assert_eq!(graph.entry, "increment");
933/// assert_eq!(graph.nodes.len(), 1);
934/// # Ok::<(), app_kit::AppError>(())
935/// ```
936#[derive(Serialize)]
937pub struct GraphDescription {
938 /// Logical workflow identity.
939 pub id: WorkflowId,
940 /// Caller-selected definition revision.
941 pub revision: String,
942 /// First node's logical identity.
943 pub entry: String,
944 /// Authored node metadata, without executable bindings.
945 pub nodes: Vec<GraphNode>,
946 /// Ordered outgoing transitions, grouped by authored source-node order.
947 pub edges: Vec<GraphEdge>,
948}
949
950/// One passive node declaration in a graph projection.
951///
952/// # Example
953///
954/// ```
955/// use app_kit::workflow::WorkflowDefinition;
956///
957/// let definition = WorkflowDefinition::parse(r#"{
958/// "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
959/// "state":{"count":0}, "required":["count"],
960/// "reducers":{"count":"reject_concurrent"}, "entry":"increment",
961/// "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
962/// {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
963/// ]}}], "routes":{}
964/// }"#)?;
965/// let graph = definition.graph();
966/// let node = &graph.nodes[0];
967/// assert_eq!(node.kind, "state");
968/// assert_eq!(node.writes, ["count"]);
969/// # Ok::<(), app_kit::AppError>(())
970/// ```
971#[derive(Serialize)]
972pub struct GraphNode {
973 /// Graph-local node identity.
974 pub id: String,
975 /// Optional caller-facing label.
976 pub name: Option<String>,
977 /// Optional authored description.
978 pub description: Option<String>,
979 /// Authored selector tags, in declaration order.
980 pub tags: Vec<String>,
981 /// Closed workflow step kind rendered as text.
982 pub kind: String,
983 /// Top-level state keys read by this node.
984 pub reads: Vec<String>,
985 /// Top-level state keys written by this node.
986 pub writes: Vec<String>,
987 /// Required transient input names.
988 pub required_inputs: Vec<String>,
989 /// Optional transient input names.
990 pub optional_inputs: Vec<String>,
991 /// Actual caller source or canonical authored built-in definition.
992 pub source: Option<String>,
993}
994
995/// One ordered transition; a missing destination denotes path exhaustion.
996///
997/// # Example
998///
999/// ```
1000/// use app_kit::workflow::GraphEdge;
1001/// let end = GraphEdge { from: "increment".into(), to: None, condition: None };
1002/// assert!(end.to.is_none());
1003/// # Ok::<(), app_kit::AppError>(())
1004/// ```
1005#[derive(Serialize)]
1006pub struct GraphEdge {
1007 /// Source node identity.
1008 pub from: String,
1009 /// Destination node identity, or explicit end.
1010 pub to: Option<String>,
1011 /// Display expression, or the default true condition when absent.
1012 pub condition: Option<String>,
1013}
1014
1015/// An owned graph piece and its graph-local node-name prefix.
1016///
1017/// # Example
1018///
1019/// ```
1020/// use app_kit::workflow::WorkflowDefinition;
1021/// const SOURCE: &str = r#"{
1022/// "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1023/// "state":{"count":0}, "required":["count"],
1024/// "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1025/// "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1026/// {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1027/// ]}}], "routes":{}
1028/// }"#;
1029/// let definition = WorkflowDefinition::parse(SOURCE)?;
1030/// use app_kit::workflow::{GraphComposition, GraphConnection, GraphImport};
1031/// let composition = GraphComposition {
1032/// id: "composed".parse()?, revision: "r1".into(), entry: "a_increment".into(),
1033/// imports: vec![
1034/// GraphImport { definition, prefix: "a_".into() },
1035/// GraphImport { definition: WorkflowDefinition::parse(SOURCE)?, prefix: "b_".into() },
1036/// ],
1037/// connections: vec![GraphConnection { from: "a_increment".into(), to: "b_increment".into() }],
1038/// };
1039/// let composed = WorkflowDefinition::compose(composition)?;
1040/// assert_eq!(composed.graph().nodes.len(), 2);
1041/// assert_eq!(composed.graph().entry, "a_increment");
1042/// # Ok::<(), app_kit::AppError>(())
1043/// ```
1044pub struct GraphImport {
1045 /// Validated definition to import.
1046 pub definition: WorkflowDefinition,
1047 /// Prefix applied to nodes and their graph-local references.
1048 pub prefix: String,
1049}
1050
1051/// A default transition appended to an assembled graph.
1052///
1053/// # Example
1054///
1055/// ```
1056/// use app_kit::workflow::WorkflowDefinition;
1057/// const SOURCE: &str = r#"{
1058/// "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1059/// "state":{"count":0}, "required":["count"],
1060/// "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1061/// "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1062/// {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1063/// ]}}], "routes":{}
1064/// }"#;
1065/// let definition = WorkflowDefinition::parse(SOURCE)?;
1066/// use app_kit::workflow::{GraphComposition, GraphConnection, GraphImport};
1067/// let composition = GraphComposition {
1068/// id: "composed".parse()?, revision: "r1".into(), entry: "a_increment".into(),
1069/// imports: vec![
1070/// GraphImport { definition, prefix: "a_".into() },
1071/// GraphImport { definition: WorkflowDefinition::parse(SOURCE)?, prefix: "b_".into() },
1072/// ],
1073/// connections: vec![GraphConnection { from: "a_increment".into(), to: "b_increment".into() }],
1074/// };
1075/// let composed = WorkflowDefinition::compose(composition)?;
1076/// assert_eq!(composed.graph().nodes.len(), 2);
1077/// assert_eq!(composed.graph().entry, "a_increment");
1078/// # Ok::<(), app_kit::AppError>(())
1079/// ```
1080pub struct GraphConnection {
1081 /// Already-prefixed source node identity.
1082 pub from: String,
1083 /// Already-prefixed destination node identity.
1084 pub to: String,
1085}
1086
1087/// Definitions and explicit connections for one ordinary compiler invocation.
1088///
1089/// # Example
1090///
1091/// ```
1092/// use app_kit::workflow::WorkflowDefinition;
1093/// const SOURCE: &str = r#"{
1094/// "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1095/// "state":{"count":0}, "required":["count"],
1096/// "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1097/// "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1098/// {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1099/// ]}}], "routes":{}
1100/// }"#;
1101/// let definition = WorkflowDefinition::parse(SOURCE)?;
1102/// use app_kit::workflow::{GraphComposition, GraphConnection, GraphImport};
1103/// let composition = GraphComposition {
1104/// id: "composed".parse()?, revision: "r1".into(), entry: "a_increment".into(),
1105/// imports: vec![
1106/// GraphImport { definition, prefix: "a_".into() },
1107/// GraphImport { definition: WorkflowDefinition::parse(SOURCE)?, prefix: "b_".into() },
1108/// ],
1109/// connections: vec![GraphConnection { from: "a_increment".into(), to: "b_increment".into() }],
1110/// };
1111/// let composed = WorkflowDefinition::compose(composition)?;
1112/// assert_eq!(composed.graph().nodes.len(), 2);
1113/// assert_eq!(composed.graph().entry, "a_increment");
1114/// # Ok::<(), app_kit::AppError>(())
1115/// ```
1116pub struct GraphComposition {
1117 /// Identity of the assembled workflow.
1118 pub id: WorkflowId,
1119 /// Revision of the assembled workflow.
1120 pub revision: String,
1121 /// Already-prefixed entrypoint.
1122 pub entry: String,
1123 /// Graph pieces, preserving their declaration order.
1124 pub imports: Vec<GraphImport>,
1125 /// Ordered default transitions appended after imported transitions.
1126 pub connections: Vec<GraphConnection>,
1127}
1128
1129/// A validated caller-authored workflow identity.
1130///
1131/// Parsing and deserialization accept only ASCII `[a-z0-9][a-z0-9_-]{0,127}`.
1132///
1133/// # Example
1134///
1135/// ```
1136/// use app_kit::workflow::WorkflowId;
1137/// let id: WorkflowId = "counter".parse()?;
1138/// assert_eq!(id.to_string(), "counter");
1139/// assert!("invalid identity".parse::<WorkflowId>().is_err());
1140/// # Ok::<(), app_kit::AppError>(())
1141/// ```
1142#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
1143pub struct WorkflowId(String);
1144
1145/// A validated root UUID or stable child SHA-256 identity.
1146///
1147/// Root identities use `run_` followed by a canonical lowercase UUIDv4. Child
1148/// identities also accept exactly 64 lowercase hexadecimal digits. One logical
1149/// identity may have multiple independent occurrences; use [`RunSelector::Exact`]
1150/// when the occurrence matters.
1151///
1152/// # Example
1153///
1154/// ```
1155/// use app_kit::workflow::RunId;
1156/// let id: RunId = "run_8a7c251e-b5b8-4d9e-8f0b-123456789abc".parse()?;
1157/// assert_eq!(id.to_string(), "run_8a7c251e-b5b8-4d9e-8f0b-123456789abc");
1158/// assert!("invalid identity".parse::<RunId>().is_err());
1159/// # Ok::<(), app_kit::AppError>(())
1160/// ```
1161#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
1162pub struct RunId(String);
1163
1164/// Selects an exact occurrence or the latest native observation of a logical run.
1165#[derive(Clone, Debug, Eq, PartialEq)]
1166pub enum RunSelector {
1167 /// Last natively saved observation in this workflow and workspace scope.
1168 Latest(RunId),
1169 /// One admitted occurrence; an absent occurrence never falls back to Latest.
1170 Exact {
1171 /// Stable logical identity.
1172 id: RunId,
1173 /// Validated at method ingress as 32 lowercase hexadecimal digits.
1174 occurrence: String,
1175 },
1176}
1177
1178impl RunSelector {
1179 pub(crate) fn validate(&self) -> Result<(), AppError> {
1180 match self {
1181 Self::Latest(_) => Ok(()),
1182 Self::Exact { occurrence, .. } => RunId::validate_occurrence(occurrence),
1183 }
1184 }
1185
1186 pub(crate) fn id(&self) -> &RunId {
1187 match self {
1188 Self::Latest(id) | Self::Exact { id, .. } => id,
1189 }
1190 }
1191}
1192
1193/// Initial state, transient inputs and boundaries selected for one execution.
1194///
1195/// `state` patches a new run only through [`Workflow::start`]; an already admitted
1196/// run requires an empty state in its execution options. Inputs are transient and
1197/// do not become state automatically. Selectors are node ids or `@tag` names and
1198/// must match. `max_steps` counts committed action boundaries, not partial results
1199/// or control checkpoints; private finite safety bounds always remain.
1200///
1201/// # Example
1202///
1203/// ```
1204/// use app_kit::workflow::{ExecutionOptions, WorkflowState};
1205/// let options = ExecutionOptions {
1206/// state: WorkflowState::new(serde_json::from_str(r#"{"count":4}"#)?)?,
1207/// halt_after: vec!["increment".into()],
1208/// ..ExecutionOptions::default()
1209/// };
1210/// assert_eq!(options.state.values()["count"], 4);
1211/// # Ok::<(), Box<dyn std::error::Error>>(())
1212/// ```
1213#[derive(Clone, Default)]
1214pub struct ExecutionOptions {
1215 /// Initial patch for a new run; must be empty on an existing run.
1216 pub state: WorkflowState,
1217 /// Transient inputs reused by successive actions.
1218 pub inputs: Map<String, Value>,
1219 /// Node or tag selectors to stop before invocation.
1220 pub halt_before: Vec<String>,
1221 /// Node or tag selectors to stop after a committed boundary.
1222 pub halt_after: Vec<String>,
1223 /// Optional maximum number of committed action boundaries.
1224 pub max_steps: Option<NonZeroUsize>,
1225}
1226
1227impl ExecutionOptions {
1228 pub(crate) fn resolve_selectors(&mut self, graph: &GraphDescription) -> Result<(), AppError> {
1229 for selectors in [&mut self.halt_before, &mut self.halt_after] {
1230 let mut resolved = Vec::new();
1231 for selector in selectors.iter() {
1232 let mut matches = graph
1233 .nodes
1234 .iter()
1235 .filter(|node| {
1236 selector
1237 .strip_prefix('@')
1238 .map_or(node.id == *selector, |tag| {
1239 node.tags.iter().any(|candidate| candidate == tag)
1240 })
1241 })
1242 .peekable();
1243 if matches.peek().is_none() {
1244 return Err(AppError::InvalidInput(
1245 "execution selector has no matching node".into(),
1246 ));
1247 }
1248 for node in matches {
1249 if !resolved.contains(&node.id) {
1250 resolved.push(node.id.clone());
1251 }
1252 }
1253 }
1254 *selectors = resolved;
1255 }
1256 Ok(())
1257 }
1258}
1259
1260/// One transient result or committed execution boundary.
1261#[non_exhaustive]
1262pub enum ExecutionEvent {
1263 /// A source result which has not changed workflow state.
1264 Partial {
1265 /// Node whose source emitted this item.
1266 node: String,
1267 /// Transient output independent of workflow state.
1268 result: Map<String, Value>,
1269 },
1270 /// A durable action boundary with its complete ordered outcomes.
1271 Committed {
1272 /// Exact durable boundary sequence.
1273 checkpoint: u64,
1274 /// Complete results in canonical node order.
1275 outcomes: Vec<ActionOutcome>,
1276 },
1277 /// A selected boundary parked for a later execution.
1278 Halted {
1279 /// Selected next node or last committed node.
1280 node: String,
1281 /// Present only for a committed after-boundary, including an empty map.
1282 result: Option<Map<String, Value>>,
1283 },
1284}
1285
1286/// The current caller-visible run truth.
1287///
1288/// # Example
1289///
1290/// ```
1291/// # use agents_kit::Agent;
1292/// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1293/// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1294/// # RunStatus, WorkflowDefinition, WorkflowState};
1295/// # use storage_kit::StateStore;
1296/// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1297/// #
1298/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1299/// # runtime.block_on(async {
1300/// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1301/// # let definition = WorkflowDefinition::parse(r#"{
1302/// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1303/// # "state":{"count":0}, "required":["count"],
1304/// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1305/// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1306/// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1307/// # ]}}], "routes":{}
1308/// # }"#)?;
1309/// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
1310/// # let mut app = App::open(config).await?;
1311/// # let mut workspace = app.create_workspace(WorkspaceConfig {
1312/// # name: Some("local counter".into()), root: std::env::current_dir()?,
1313/// # }).await?;
1314/// # let workflow = workspace.workflow("counter".parse()?).await?;
1315/// # let mut run = workflow.prepare(WorkflowState::default()).await?;
1316/// let terminal = run.execution(ExecutionOptions::default()).await?.finish().await?;
1317/// assert_eq!(terminal.state.values()["count"], 1);
1318/// assert_eq!(terminal.last_action_ordinal, Some(0));
1319/// #
1320/// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
1321/// # Ok::<(), Box<dyn std::error::Error>>(())
1322/// # })?;
1323/// # Ok::<(), Box<dyn std::error::Error>>(())
1324/// ```
1325#[non_exhaustive]
1326pub struct RunSnapshot {
1327 /// Identifies the run.
1328 pub id: RunId,
1329 /// Exact admitted execution occurrence of this logical run.
1330 pub occurrence: String,
1331 /// Fences explicit controls.
1332 pub checkpoint: u64,
1333 /// Projects lifecycle; actionable targets remain in `interactions`.
1334 pub status: RunStatus,
1335 /// Contains the current committed workflow state.
1336 pub state: WorkflowState,
1337 /// Canonical currently actionable controls.
1338 pub interactions: Vec<Interaction>,
1339 /// Last successfully committed action ordinal; controls do not advance it.
1340 pub last_action_ordinal: Option<u64>,
1341 /// Results from the latest committed wave, retained through later controls.
1342 pub outcomes: Vec<ActionOutcome>,
1343}
1344
1345/// The current stable run status.
1346///
1347/// Inspect [`crate::workflow::RunSnapshot::interactions`] alongside this lifecycle projection.
1348/// Halted retains the selected before/after boundary and exact optional result;
1349/// Paused denotes an ordinary parked control or prepared run. Neither status
1350/// alone means that every external effect is safe to retry.
1351#[non_exhaustive]
1352pub enum RunStatus {
1353 /// Execution can still advance without caller input.
1354 Running,
1355 /// A committed boundary is parked for explicit execution.
1356 Paused,
1357 /// An explicitly selected node boundary is retained.
1358 Halted {
1359 /// Selected next node or last committed node.
1360 node: String,
1361 /// Present only for a committed after-boundary, including an empty map.
1362 result: Option<Map<String, Value>>,
1363 },
1364 /// Explicit cancellation has committed and owned cleanup is still running.
1365 Cancelling,
1366 /// The terminal frontier succeeded.
1367 Succeeded,
1368 /// The run terminated with a durable failure.
1369 Failed(WorkflowFailure),
1370 /// Explicit cancellation converged.
1371 Cancelled,
1372}
1373
1374/// A caller-actionable workflow interaction.
1375#[derive(Clone)]
1376#[non_exhaustive]
1377pub enum Interaction {
1378 /// Requests one schema-checked caller value.
1379 Input {
1380 /// Identifies this run-scoped control.
1381 sequence: u64,
1382 /// Fences this input reservation independently of sibling checkpoints.
1383 generation: u64,
1384 /// Describes the requested value.
1385 message: String,
1386 /// Optionally constrains the response.
1387 schema: Option<Value>,
1388 },
1389 /// Exposes one exact lower-agent notification.
1390 Notification {
1391 /// Identifies this run-scoped control.
1392 sequence: u64,
1393 /// Fences this exact notification target independently of sibling checkpoints.
1394 generation: u64,
1395 /// Carries the exact portable lower notification.
1396 notification: Arc<ConversationNotification>,
1397 },
1398 /// Retains an external effect whose outcome has not been established.
1399 Unknown {
1400 /// Identifies this run-scoped interaction target.
1401 sequence: u64,
1402 /// Fences this target independently of sibling checkpoints.
1403 generation: u64,
1404 /// Reports whether authoritative evidence permits another attempt.
1405 retry_safe: bool,
1406 },
1407 /// Requests transient inputs for an action that has not started.
1408 Inputs {
1409 /// Identifies this run-scoped interaction target.
1410 sequence: u64,
1411 /// Fences this binding independently of sibling checkpoints.
1412 generation: u64,
1413 /// Names the exact missing input keys in canonical order.
1414 required: Vec<String>,
1415 },
1416}
1417
1418/// Settles one current interaction or unknown effect.
1419///
1420/// Use the interaction sequence and its target generation from the same observed
1421/// item. Sibling checkpoints do not invalidate that reservation. Unknown effects
1422/// are never automatically retried; Retry requires authoritative retry-safe
1423/// evidence, and Accept is an explicit caller settlement.
1424pub enum Resolution {
1425 /// Supplies one caller-input value.
1426 Input(Value),
1427 /// Binds exact missing inputs in the retained execution, without persisting values.
1428 Inputs(Map<String, Value>),
1429 /// Resolves one exact lower notification.
1430 Notification(NotificationResolution),
1431 /// Accepts an unknown effect and stages ordinary state contribution.
1432 Accept(Map<String, Value>),
1433 /// Fails an unknown effect without claiming its external outcome.
1434 Fail,
1435 /// Retries only after authoritative evidence proves retry safe.
1436 Retry,
1437}
1438
1439/// A stable terminal workflow-failure category.
1440#[non_exhaustive]
1441pub enum WorkflowFailure {
1442 /// Expression or routing evaluation failed.
1443 Evaluation(String),
1444 /// State validation or deterministic reduction failed.
1445 State(String),
1446 /// A lower agent operation failed safely.
1447 Agent(String),
1448 /// A caller-owned action source returned an error.
1449 Action(String),
1450 /// An opt-in lifecycle hook failed, retaining any prior execution failure.
1451 Hook {
1452 /// The caller-supplied hook error context.
1453 message: String,
1454 /// The execution failure observed before the hook failed.
1455 cause: Option<Box<WorkflowFailure>>,
1456 },
1457 /// A child or map relationship failed, or a child executor returned an error.
1458 ///
1459 /// An error returned by [`ChildExecutor::execute`]'s future has this category
1460 /// regardless of its [`AppError`] variant. Partial child results do not reduce
1461 /// the parent, and descendant cleanup retains ownership before publication.
1462 Child(String),
1463 /// Execution could not continue, including a contained child-executor panic.
1464 ///
1465 /// A panic while constructing or polling [`ChildExecutor::execute`]'s future
1466 /// has this category. It does not reduce the parent from partial child results;
1467 /// descendant cleanup retains ownership before terminal publication.
1468 Execution(String),
1469 /// A durable deadline expired.
1470 Timeout(String),
1471 /// A fixed private safety bound was exhausted.
1472 Limit(String),
1473}
1474
1475impl WorkflowDefinition {
1476 /// Parses and validates one strict versioned workflow document.
1477 ///
1478 /// # Errors
1479 ///
1480 /// Returns [`AppError::InvalidInput`] for malformed JSON, unsupported schema,
1481 /// unknown or duplicate members, invalid identities or expressions, inconsistent
1482 /// state/reducers, graph errors, or exceeded source/collection/depth limits.
1483 ///
1484 /// # Example
1485 ///
1486 /// ```
1487 /// use app_kit::workflow::WorkflowDefinition;
1488 ///
1489 /// let definition = WorkflowDefinition::parse(r#"{
1490 /// "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1491 /// "state":{"count":0}, "required":["count"],
1492 /// "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1493 /// "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1494 /// {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1495 /// ]}}], "routes":{}
1496 /// }"#)?;
1497 /// assert_eq!(definition.graph().id.to_string(), "counter");
1498 /// assert!(WorkflowDefinition::parse(r#"{"schema":"app-kit.workflow/v1"}"#).is_err());
1499 /// # Ok::<(), app_kit::AppError>(())
1500 /// ```
1501 pub fn parse(source: &str) -> Result<Self, AppError> {
1502 Ok(Self {
1503 compiled: definition::CompiledDefinition::parse(source)?,
1504 })
1505 }
1506
1507 pub(crate) fn into_compiled(self) -> definition::CompiledDefinition {
1508 self.compiled
1509 }
1510}
1511
1512impl<
1513 CS: StateStoreBackend,
1514 A: AgentBackend,
1515 X: ActionBackend,
1516 C: ConditionBackend,
1517 T: StateTyping,
1518 H: LifecycleHooks,
1519 E: ChildExecutor,
1520> Workflow<CS, A, X, C, T, H, E>
1521{
1522 pub(crate) fn from_runtime(runtime: run::WorkflowRuntime<CS, A, X, C, T, H, E>) -> Self {
1523 Self { runtime }
1524 }
1525
1526 /// Returns this workflow's stable identity.
1527 ///
1528 /// # Example
1529 ///
1530 /// ```
1531 /// # use agents_kit::Agent;
1532 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1533 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1534 /// # RunStatus, WorkflowDefinition, WorkflowState};
1535 /// # use storage_kit::StateStore;
1536 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1537 /// #
1538 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1539 /// # runtime.block_on(async {
1540 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1541 /// # let definition = WorkflowDefinition::parse(r#"{
1542 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1543 /// # "state":{"count":0}, "required":["count"],
1544 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1545 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1546 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1547 /// # ]}}], "routes":{}
1548 /// # }"#)?;
1549 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
1550 /// # let mut app = App::open(config).await?;
1551 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
1552 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
1553 /// # }).await?;
1554 /// # let workflow = workspace.workflow("counter".parse()?).await?;
1555 /// assert_eq!(workflow.id().to_string(), "counter");
1556 /// #
1557 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
1558 /// # Ok::<(), Box<dyn std::error::Error>>(())
1559 /// # })?;
1560 /// # Ok::<(), Box<dyn std::error::Error>>(())
1561 /// ```
1562 pub fn id(&self) -> &WorkflowId {
1563 self.runtime.id()
1564 }
1565
1566 /// Atomically admits one run at checkpoint zero.
1567 ///
1568 /// Schedules background advancement after admission. Dropping the returned run
1569 /// observer does not stop it. [`WorkflowRun::wait`] returns at an actionable or
1570 /// terminal boundary, so inspect status and interactions before assuming success.
1571 ///
1572 /// # Errors
1573 ///
1574 /// Returns `InvalidInput` for invalid initial state/options or missing bindings,
1575 /// `Busy` for exhausted admission/capacity, `Closed` for sealed ownership, or
1576 /// `Conflict` for stale workspace/revision evidence. Lower/storage failures use
1577 /// `Integration`; accepted execution failures appear in run status.
1578 ///
1579 /// # Example
1580 ///
1581 /// ```
1582 /// # use agents_kit::Agent;
1583 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1584 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1585 /// # RunStatus, WorkflowDefinition, WorkflowState};
1586 /// # use storage_kit::StateStore;
1587 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1588 /// #
1589 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1590 /// # runtime.block_on(async {
1591 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1592 /// # let definition = WorkflowDefinition::parse(r#"{
1593 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1594 /// # "state":{"count":0}, "required":["count"],
1595 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1596 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1597 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1598 /// # ]}}], "routes":{}
1599 /// # }"#)?;
1600 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
1601 /// # let mut app = App::open(config).await?;
1602 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
1603 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
1604 /// # }).await?;
1605 /// # let workflow = workspace.workflow("counter".parse()?).await?;
1606 /// let run = workflow.start(ExecutionOptions::default()).await?;
1607 /// let snapshot = run.wait().await?;
1608 /// assert_eq!(snapshot.state.values()["count"], 1);
1609 /// #
1610 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
1611 /// # Ok::<(), Box<dyn std::error::Error>>(())
1612 /// # })?;
1613 /// # Ok::<(), Box<dyn std::error::Error>>(())
1614 /// ```
1615 pub async fn start(
1616 &self,
1617 options: ExecutionOptions,
1618 ) -> Result<WorkflowRun<CS, A, X, C, T, H, E>, AppError> {
1619 Ok(WorkflowRun {
1620 runtime: self.runtime.start(options).await?,
1621 })
1622 }
1623
1624 /// Admits checkpoint zero without executing the entry node.
1625 ///
1626 /// The initial patch is validated and atomically stored using the same
1627 /// workspace and App admission fences as background execution. Observers
1628 /// can read the parked run, and cancellation uses its checkpoint fence.
1629 ///
1630 /// # Errors
1631 ///
1632 /// Returns `InvalidInput` for invalid initial state or missing bindings,
1633 /// `Busy` for exhausted admission/capacity, `Closed` for sealed ownership, or
1634 /// `Conflict` for stale workspace/revision evidence or exhausted bounded
1635 /// transaction-contention retries. Concurrent preparations are not guaranteed
1636 /// to all succeed. Lower/storage failures use `Integration`; accepted
1637 /// execution failures appear in run status.
1638 ///
1639 /// # Example
1640 ///
1641 /// ```
1642 /// # use agents_kit::Agent;
1643 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1644 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1645 /// # RunStatus, WorkflowDefinition, WorkflowState};
1646 /// # use storage_kit::StateStore;
1647 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1648 /// #
1649 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1650 /// # runtime.block_on(async {
1651 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1652 /// # let definition = WorkflowDefinition::parse(r#"{
1653 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1654 /// # "state":{"count":0}, "required":["count"],
1655 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1656 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1657 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1658 /// # ]}}], "routes":{}
1659 /// # }"#)?;
1660 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
1661 /// # let mut app = App::open(config).await?;
1662 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
1663 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
1664 /// # }).await?;
1665 /// # let workflow = workspace.workflow("counter".parse()?).await?;
1666 /// let run = workflow.prepare(WorkflowState::default()).await?;
1667 /// let checkpoint = run.checkpoint(CheckpointSelector::Sequence(0)).await?;
1668 /// assert_eq!(checkpoint.state.values()["count"], 0);
1669 /// assert!(checkpoint.action_ordinal.is_none());
1670 /// #
1671 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
1672 /// # Ok::<(), Box<dyn std::error::Error>>(())
1673 /// # })?;
1674 /// # Ok::<(), Box<dyn std::error::Error>>(())
1675 /// ```
1676 pub async fn prepare(
1677 &self,
1678 state: WorkflowState,
1679 ) -> Result<WorkflowRun<CS, A, X, C, T, H, E>, AppError> {
1680 Ok(WorkflowRun {
1681 runtime: self.runtime.prepare(state.values).await?,
1682 })
1683 }
1684
1685 /// Copies a clean retained boundary into a fresh, paused run.
1686 ///
1687 /// Source selection and new admission commit atomically. The source remains
1688 /// unchanged; actions run only after explicit execution of the returned run.
1689 ///
1690 /// # Errors
1691 ///
1692 /// Returns `NotFound` for absent source/checkpoint without fallback, `InvalidInput`
1693 /// for invalid overrides or selectors, `Busy` for exhausted capacity, and
1694 /// `Conflict` when the selected boundary is unsafe to copy. Sealing/revocation
1695 /// returns `Closed`; inconsistent history or storage failures return `Integration`.
1696 ///
1697 /// # Example
1698 ///
1699 /// ```
1700 /// # use agents_kit::Agent;
1701 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1702 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1703 /// # RunStatus, WorkflowDefinition, WorkflowState};
1704 /// # use storage_kit::StateStore;
1705 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1706 /// #
1707 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1708 /// # runtime.block_on(async {
1709 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1710 /// # let definition = WorkflowDefinition::parse(r#"{
1711 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1712 /// # "state":{"count":0}, "required":["count"],
1713 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1714 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1715 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1716 /// # ]}}], "routes":{}
1717 /// # }"#)?;
1718 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
1719 /// # let mut app = App::open(config).await?;
1720 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
1721 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
1722 /// # }).await?;
1723 /// # let workflow = workspace.workflow("counter".parse()?).await?;
1724 /// # let mut run = workflow.prepare(WorkflowState::default()).await?;
1725 /// let terminal = run.execution(ExecutionOptions::default()).await?.finish().await?;
1726 /// use app_kit::workflow::{RestoreOptions, ResumePosition};
1727 /// let options = RestoreOptions {
1728 /// source: RunSelector::Latest(run.id().clone()),
1729 /// checkpoint: CheckpointSelector::Action(0),
1730 /// overrides: WorkflowState::default(), position: ResumePosition::AfterCompleted,
1731 /// fallback: None,
1732 /// };
1733 /// let copy = workflow.restore(options).await?;
1734 /// assert_ne!(copy.id(), run.id());
1735 /// let copied = copy.checkpoint(CheckpointSelector::Sequence(0)).await?;
1736 /// assert_eq!(copied.state.values()["count"], 1);
1737 /// assert_eq!(&copied.lineage.as_ref().unwrap().source, run.id());
1738 /// #
1739 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
1740 /// # Ok::<(), Box<dyn std::error::Error>>(())
1741 /// # })?;
1742 /// # Ok::<(), Box<dyn std::error::Error>>(())
1743 /// ```
1744 pub async fn restore(
1745 &self,
1746 options: RestoreOptions,
1747 ) -> Result<WorkflowRun<CS, A, X, C, T, H, E>, AppError> {
1748 Ok(WorkflowRun {
1749 runtime: self.runtime.restore(options).await?,
1750 })
1751 }
1752
1753 /// Reopens one run by typed identity.
1754 ///
1755 /// # Errors
1756 ///
1757 /// Returns `InvalidInput` for a malformed exact occurrence, `NotFound` when the
1758 /// selected run/occurrence is absent, or `Closed` for sealed scope. Exact selection
1759 /// never falls back to Latest; inconsistent retained evidence is `Integration`.
1760 ///
1761 /// # Example
1762 ///
1763 /// ```
1764 /// # use agents_kit::Agent;
1765 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1766 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1767 /// # RunStatus, WorkflowDefinition, WorkflowState};
1768 /// # use storage_kit::StateStore;
1769 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1770 /// #
1771 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1772 /// # runtime.block_on(async {
1773 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1774 /// # let definition = WorkflowDefinition::parse(r#"{
1775 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1776 /// # "state":{"count":0}, "required":["count"],
1777 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1778 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1779 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1780 /// # ]}}], "routes":{}
1781 /// # }"#)?;
1782 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
1783 /// # let mut app = App::open(config).await?;
1784 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
1785 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
1786 /// # }).await?;
1787 /// # let workflow = workspace.workflow("counter".parse()?).await?;
1788 /// # let mut run = workflow.prepare(WorkflowState::default()).await?;
1789 /// let id = run.id().clone();
1790 /// let reopened = workflow.open_run(RunSelector::Latest(id.clone())).await?;
1791 /// assert_eq!(reopened.id(), &id);
1792 /// #
1793 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
1794 /// # Ok::<(), Box<dyn std::error::Error>>(())
1795 /// # })?;
1796 /// # Ok::<(), Box<dyn std::error::Error>>(())
1797 /// ```
1798 pub async fn open_run(
1799 &self,
1800 selector: RunSelector,
1801 ) -> Result<WorkflowRun<CS, A, X, C, T, H, E>, AppError> {
1802 Ok(WorkflowRun {
1803 runtime: self.runtime.open_run(selector).await?,
1804 })
1805 }
1806
1807 /// Lists runs by logical identity, then exact occurrence token.
1808 ///
1809 /// An Exact cursor continues after that occurrence; a Latest cursor skips
1810 /// every occurrence of its logical identity. This inventory order does not
1811 /// represent the native save order used when opening Latest.
1812 ///
1813 /// # Errors
1814 ///
1815 /// Returns `InvalidInput` for a limit above 1,000 or malformed cursor, `NotFound`
1816 /// for an unavailable cursor, and `Closed` or `Integration` for revoked or
1817 /// unreadable scope.
1818 ///
1819 /// # Example
1820 ///
1821 /// ```
1822 /// # use agents_kit::Agent;
1823 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1824 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1825 /// # RunStatus, WorkflowDefinition, WorkflowState};
1826 /// # use storage_kit::StateStore;
1827 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1828 /// #
1829 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1830 /// # runtime.block_on(async {
1831 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1832 /// # let definition = WorkflowDefinition::parse(r#"{
1833 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1834 /// # "state":{"count":0}, "required":["count"],
1835 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1836 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1837 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1838 /// # ]}}], "routes":{}
1839 /// # }"#)?;
1840 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
1841 /// # let mut app = App::open(config).await?;
1842 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
1843 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
1844 /// # }).await?;
1845 /// # let workflow = workspace.workflow("counter".parse()?).await?;
1846 /// # let mut run = workflow.prepare(WorkflowState::default()).await?;
1847 /// let runs = workflow.list_runs(None, std::num::NonZeroUsize::new(10).unwrap()).await?;
1848 /// assert_eq!(runs.len(), 1);
1849 /// assert_eq!(&runs[0].id, run.id());
1850 /// #
1851 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
1852 /// # Ok::<(), Box<dyn std::error::Error>>(())
1853 /// # })?;
1854 /// # Ok::<(), Box<dyn std::error::Error>>(())
1855 /// ```
1856 pub async fn list_runs(
1857 &self,
1858 after: Option<RunSelector>,
1859 limit: NonZeroUsize,
1860 ) -> Result<Vec<RunSnapshot>, AppError> {
1861 self.runtime.list_runs(after, limit).await
1862 }
1863}
1864
1865impl<
1866 CS: StateStoreBackend,
1867 A: AgentBackend,
1868 X: ActionBackend,
1869 C: ConditionBackend,
1870 T: StateTyping,
1871 H: LifecycleHooks,
1872 E: ChildExecutor,
1873> WorkflowRun<CS, A, X, C, T, H, E>
1874{
1875 /// Returns this run's stable identity.
1876 ///
1877 /// # Example
1878 ///
1879 /// ```
1880 /// # use agents_kit::Agent;
1881 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1882 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1883 /// # RunStatus, WorkflowDefinition, WorkflowState};
1884 /// # use storage_kit::StateStore;
1885 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1886 /// #
1887 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1888 /// # runtime.block_on(async {
1889 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1890 /// # let definition = WorkflowDefinition::parse(r#"{
1891 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1892 /// # "state":{"count":0}, "required":["count"],
1893 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1894 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1895 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1896 /// # ]}}], "routes":{}
1897 /// # }"#)?;
1898 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
1899 /// # let mut app = App::open(config).await?;
1900 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
1901 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
1902 /// # }).await?;
1903 /// # let workflow = workspace.workflow("counter".parse()?).await?;
1904 /// # let mut run = workflow.prepare(WorkflowState::default()).await?;
1905 /// assert_eq!(run.snapshot().await?.id, *run.id());
1906 /// #
1907 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
1908 /// # Ok::<(), Box<dyn std::error::Error>>(())
1909 /// # })?;
1910 /// # Ok::<(), Box<dyn std::error::Error>>(())
1911 /// ```
1912 pub fn id(&self) -> &RunId {
1913 self.runtime.id()
1914 }
1915
1916 /// Acquires exclusive caller-local advancement without constructing a source.
1917 ///
1918 /// A live execution keeps that borrow until its last use:
1919 ///
1920 /// ```compile_fail
1921 /// use agents_kit::AgentBackend;
1922 /// use app_kit::{AppError, workflow::{ExecutionOptions, RunSnapshot, WorkflowRun}};
1923 /// use storage_kit::StateStoreBackend;
1924 /// async fn overlapping<CS: StateStoreBackend, A: AgentBackend>(
1925 /// run: &mut WorkflowRun<CS, A>,
1926 /// ) -> Result<RunSnapshot, AppError> {
1927 /// let execution = run.execution(ExecutionOptions::default()).await?;
1928 /// run.snapshot().await?;
1929 /// execution.finish().await
1930 /// }
1931 /// ```
1932 ///
1933 /// # Errors
1934 ///
1935 /// Returns `InvalidInput` for a nonempty options state or invalid inputs/selectors,
1936 /// `Busy` if advancement or admission is already owned, `Conflict` for stale
1937 /// authority, and `Closed` after revocation. Storage failures use `Integration`.
1938 ///
1939 /// # Example
1940 ///
1941 /// ```
1942 /// # use agents_kit::Agent;
1943 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1944 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1945 /// # RunStatus, WorkflowDefinition, WorkflowState};
1946 /// # use storage_kit::StateStore;
1947 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1948 /// #
1949 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1950 /// # runtime.block_on(async {
1951 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1952 /// # let definition = WorkflowDefinition::parse(r#"{
1953 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1954 /// # "state":{"count":0}, "required":["count"],
1955 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1956 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1957 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1958 /// # ]}}], "routes":{}
1959 /// # }"#)?;
1960 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
1961 /// # let mut app = App::open(config).await?;
1962 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
1963 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
1964 /// # }).await?;
1965 /// # let workflow = workspace.workflow("counter".parse()?).await?;
1966 /// # let mut run = workflow.prepare(WorkflowState::default()).await?;
1967 /// let terminal = run.execution(ExecutionOptions::default()).await?.finish().await?;
1968 /// assert_eq!(run.snapshot().await?.checkpoint, terminal.checkpoint);
1969 /// #
1970 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
1971 /// # Ok::<(), Box<dyn std::error::Error>>(())
1972 /// # })?;
1973 /// # Ok::<(), Box<dyn std::error::Error>>(())
1974 /// ```
1975 pub async fn execution(
1976 &mut self,
1977 options: ExecutionOptions,
1978 ) -> Result<Execution<'_, CS, A, X, C, T, H, E>, AppError> {
1979 Execution::open(self, options, ActionMode::Asynchronous).await
1980 }
1981
1982 /// Loads the current run snapshot.
1983 ///
1984 /// # Errors
1985 ///
1986 /// Returns `Closed` for revoked App ownership or sealed/deleted scope, `NotFound`
1987 /// for absent retained evidence, or `Integration` for inconsistent or unreadable
1988 /// records. A newer App generation can invalidate this handle.
1989 ///
1990 /// # Example
1991 ///
1992 /// ```
1993 /// # use agents_kit::Agent;
1994 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1995 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1996 /// # RunStatus, WorkflowDefinition, WorkflowState};
1997 /// # use storage_kit::StateStore;
1998 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1999 /// #
2000 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
2001 /// # runtime.block_on(async {
2002 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
2003 /// # let definition = WorkflowDefinition::parse(r#"{
2004 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
2005 /// # "state":{"count":0}, "required":["count"],
2006 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
2007 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
2008 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
2009 /// # ]}}], "routes":{}
2010 /// # }"#)?;
2011 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
2012 /// # let mut app = App::open(config).await?;
2013 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
2014 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
2015 /// # }).await?;
2016 /// # let workflow = workspace.workflow("counter".parse()?).await?;
2017 /// # let mut run = workflow.prepare(WorkflowState::default()).await?;
2018 /// let snapshot = run.snapshot().await?;
2019 /// assert_eq!(snapshot.checkpoint, 0);
2020 /// assert!(matches!(snapshot.status, RunStatus::Paused));
2021 /// #
2022 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
2023 /// # Ok::<(), Box<dyn std::error::Error>>(())
2024 /// # })?;
2025 /// # Ok::<(), Box<dyn std::error::Error>>(())
2026 /// ```
2027 pub async fn snapshot(&self) -> Result<RunSnapshot, AppError> {
2028 self.runtime.snapshot().await
2029 }
2030
2031 /// Reads one immutable checkpoint without advancing the workflow.
2032 ///
2033 /// An action selector returns the latest successful or failed observation
2034 /// of that ordinal. Missing or explicitly deleted history returns NotFound.
2035 ///
2036 /// # Errors
2037 ///
2038 /// Returns `Closed` for revoked App ownership or sealed/deleted scope, `NotFound`
2039 /// for absent retained evidence, or `Integration` for inconsistent or unreadable
2040 /// records. A newer App generation can invalidate this handle.
2041 /// An explicit missing sequence/action is `NotFound`; action ordinals are distinct
2042 /// from checkpoint sequences and select the latest matching saved observation.
2043 ///
2044 /// # Example
2045 ///
2046 /// ```
2047 /// # use agents_kit::Agent;
2048 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
2049 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
2050 /// # RunStatus, WorkflowDefinition, WorkflowState};
2051 /// # use storage_kit::StateStore;
2052 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
2053 /// #
2054 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
2055 /// # runtime.block_on(async {
2056 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
2057 /// # let definition = WorkflowDefinition::parse(r#"{
2058 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
2059 /// # "state":{"count":0}, "required":["count"],
2060 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
2061 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
2062 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
2063 /// # ]}}], "routes":{}
2064 /// # }"#)?;
2065 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
2066 /// # let mut app = App::open(config).await?;
2067 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
2068 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
2069 /// # }).await?;
2070 /// # let workflow = workspace.workflow("counter".parse()?).await?;
2071 /// # let mut run = workflow.prepare(WorkflowState::default()).await?;
2072 /// let terminal = run.execution(ExecutionOptions::default()).await?.finish().await?;
2073 /// let initial = run.checkpoint(CheckpointSelector::Sequence(0)).await?;
2074 /// let action = run.checkpoint(CheckpointSelector::Action(0)).await?;
2075 /// assert_eq!(initial.state.values()["count"], 0);
2076 /// assert_eq!(action.state.values()["count"], 1);
2077 /// #
2078 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
2079 /// # Ok::<(), Box<dyn std::error::Error>>(())
2080 /// # })?;
2081 /// # Ok::<(), Box<dyn std::error::Error>>(())
2082 /// ```
2083 pub async fn checkpoint(&self, selector: CheckpointSelector) -> Result<Checkpoint, AppError> {
2084 self.runtime.checkpoint(selector).await
2085 }
2086
2087 /// Reads a bounded page from a fixed retained-history cutoff.
2088 ///
2089 /// Limits are 1 through 1,000. A returned cursor preserves its cutoff and
2090 /// is valid only for this run creation and App generation. Reading a page
2091 /// never invokes actions, state typing, codecs or lower-agent recovery.
2092 ///
2093 /// # Errors
2094 ///
2095 /// Returns `Closed` for revoked App ownership or sealed/deleted scope, `NotFound`
2096 /// for absent retained evidence, or `Integration` for inconsistent or unreadable
2097 /// records. A newer App generation can invalidate this handle.
2098 /// An excessive page limit or a cursor from another run/occurrence/generation is
2099 /// `InvalidInput`. A captured cutoff never includes later appended checkpoints.
2100 ///
2101 /// # Example
2102 ///
2103 /// ```
2104 /// # use agents_kit::Agent;
2105 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
2106 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
2107 /// # RunStatus, WorkflowDefinition, WorkflowState};
2108 /// # use storage_kit::StateStore;
2109 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
2110 /// #
2111 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
2112 /// # runtime.block_on(async {
2113 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
2114 /// # let definition = WorkflowDefinition::parse(r#"{
2115 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
2116 /// # "state":{"count":0}, "required":["count"],
2117 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
2118 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
2119 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
2120 /// # ]}}], "routes":{}
2121 /// # }"#)?;
2122 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
2123 /// # let mut app = App::open(config).await?;
2124 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
2125 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
2126 /// # }).await?;
2127 /// # let workflow = workspace.workflow("counter".parse()?).await?;
2128 /// # let mut run = workflow.prepare(WorkflowState::default()).await?;
2129 /// let terminal = run.execution(ExecutionOptions::default()).await?.finish().await?;
2130 /// let page = run.history(None, std::num::NonZeroUsize::new(1).unwrap()).await?;
2131 /// assert_eq!(page.checkpoints[0].sequence, 0);
2132 /// let next = run.history(page.next, std::num::NonZeroUsize::new(100).unwrap()).await?;
2133 /// assert!(next.checkpoints.iter().all(|checkpoint| checkpoint.sequence > 0));
2134 /// #
2135 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
2136 /// # Ok::<(), Box<dyn std::error::Error>>(())
2137 /// # })?;
2138 /// # Ok::<(), Box<dyn std::error::Error>>(())
2139 /// ```
2140 pub async fn history(
2141 &self,
2142 after: Option<HistoryCursor>,
2143 limit: NonZeroUsize,
2144 ) -> Result<HistoryPage, AppError> {
2145 self.runtime.history(after, limit).await
2146 }
2147
2148 /// Deletes terminal history through bounded, resumable pages.
2149 ///
2150 /// The expected checkpoint fences the terminal boundary. Dropping this
2151 /// operation's observer preserves admitted deletion and native commits.
2152 ///
2153 /// # Errors
2154 ///
2155 /// Returns `Conflict` for a stale checkpoint, `Busy` for nonterminal/unknown work
2156 /// or a child still referenced by an active parent, and `Closed` after sealing or
2157 /// revocation. Storage failures use `Integration`. A successful seal survives
2158 /// observer loss and deletion resumes in bounded pages after reopen.
2159 ///
2160 /// # Example
2161 ///
2162 /// ```
2163 /// # use agents_kit::Agent;
2164 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
2165 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
2166 /// # RunStatus, WorkflowDefinition, WorkflowState};
2167 /// # use storage_kit::StateStore;
2168 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
2169 /// #
2170 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
2171 /// # runtime.block_on(async {
2172 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
2173 /// # let definition = WorkflowDefinition::parse(r#"{
2174 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
2175 /// # "state":{"count":0}, "required":["count"],
2176 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
2177 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
2178 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
2179 /// # ]}}], "routes":{}
2180 /// # }"#)?;
2181 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
2182 /// # let mut app = App::open(config).await?;
2183 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
2184 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
2185 /// # }).await?;
2186 /// # let workflow = workspace.workflow("counter".parse()?).await?;
2187 /// # let mut run = workflow.prepare(WorkflowState::default()).await?;
2188 /// let terminal = run.execution(ExecutionOptions::default()).await?.finish().await?;
2189 /// let id = run.id().clone();
2190 /// run.delete(terminal.checkpoint).await?;
2191 /// assert!(matches!(workflow.open_run(RunSelector::Latest(id)).await, Err(app_kit::AppError::NotFound(_))));
2192 /// #
2193 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
2194 /// # Ok::<(), Box<dyn std::error::Error>>(())
2195 /// # })?;
2196 /// # Ok::<(), Box<dyn std::error::Error>>(())
2197 /// ```
2198 pub async fn delete(&mut self, expected_checkpoint: u64) -> Result<(), AppError> {
2199 self.runtime.delete(expected_checkpoint).await
2200 }
2201
2202 /// Waits for the next actionable or terminal snapshot.
2203 ///
2204 /// This observes accepted work; it does not acquire execution ownership or resume
2205 /// a prepared run. A caller interaction or unknown effect can return before a
2206 /// terminal result. Dropping the observation future never requests cancellation.
2207 ///
2208 /// # Errors
2209 ///
2210 /// Returns `Closed` for revoked App ownership or sealed/deleted scope, `NotFound`
2211 /// for absent retained evidence, or `Integration` for inconsistent or unreadable
2212 /// records. A newer App generation can invalidate this handle.
2213 ///
2214 /// # Example
2215 ///
2216 /// ```
2217 /// # use agents_kit::Agent;
2218 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
2219 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
2220 /// # RunStatus, WorkflowDefinition, WorkflowState};
2221 /// # use storage_kit::StateStore;
2222 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
2223 /// #
2224 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
2225 /// # runtime.block_on(async {
2226 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
2227 /// # let definition = WorkflowDefinition::parse(r#"{
2228 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
2229 /// # "state":{"count":0}, "required":["count"],
2230 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
2231 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
2232 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
2233 /// # ]}}], "routes":{}
2234 /// # }"#)?;
2235 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
2236 /// # let mut app = App::open(config).await?;
2237 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
2238 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
2239 /// # }).await?;
2240 /// # let workflow = workspace.workflow("counter".parse()?).await?;
2241 /// let background = workflow.start(ExecutionOptions::default()).await?;
2242 /// let observed = background.wait().await?;
2243 /// assert!(matches!(observed.status, RunStatus::Succeeded));
2244 /// #
2245 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
2246 /// # Ok::<(), Box<dyn std::error::Error>>(())
2247 /// # })?;
2248 /// # Ok::<(), Box<dyn std::error::Error>>(())
2249 /// ```
2250 pub async fn wait(&self) -> Result<RunSnapshot, AppError> {
2251 self.runtime.wait().await
2252 }
2253
2254 /// Commits explicit checkpoint-fenced cancellation.
2255 ///
2256 /// # Errors
2257 ///
2258 /// Returns `Conflict` for a stale checkpoint, `Busy` for unavailable admission,
2259 /// `Closed` after revocation, or `Integration` for unreadable/failed storage.
2260 /// Cancellation admission is durable; observer loss does not reverse it.
2261 ///
2262 /// # Example
2263 ///
2264 /// ```
2265 /// # use agents_kit::Agent;
2266 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
2267 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
2268 /// # RunStatus, WorkflowDefinition, WorkflowState};
2269 /// # use storage_kit::StateStore;
2270 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
2271 /// #
2272 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
2273 /// # runtime.block_on(async {
2274 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
2275 /// # let definition = WorkflowDefinition::parse(r#"{
2276 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
2277 /// # "state":{"count":0}, "required":["count"],
2278 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
2279 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
2280 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
2281 /// # ]}}], "routes":{}
2282 /// # }"#)?;
2283 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
2284 /// # let mut app = App::open(config).await?;
2285 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
2286 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
2287 /// # }).await?;
2288 /// # let workflow = workspace.workflow("counter".parse()?).await?;
2289 /// # let mut run = workflow.prepare(WorkflowState::default()).await?;
2290 /// let before = run.snapshot().await?;
2291 /// run.cancel(before.checkpoint).await?;
2292 /// let cancelled = run.wait().await?;
2293 /// assert!(matches!(cancelled.status, RunStatus::Cancelled));
2294 /// #
2295 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
2296 /// # Ok::<(), Box<dyn std::error::Error>>(())
2297 /// # })?;
2298 /// # Ok::<(), Box<dyn std::error::Error>>(())
2299 /// ```
2300 pub async fn cancel(&self, expected_checkpoint: u64) -> Result<RunSnapshot, AppError> {
2301 self.runtime.cancel(expected_checkpoint).await
2302 }
2303
2304 /// Settles one exact interaction using its original target generation.
2305 ///
2306 /// Sibling checkpoints do not invalidate that generation. An identical
2307 /// decision replays its retained receipt; a different decision conflicts.
2308 /// Transient inputs replay only while their execution retains the values.
2309 ///
2310 /// # Errors
2311 ///
2312 /// Returns `InvalidInput` for a mismatched resolution or invalid schema/input,
2313 /// `Conflict` for a stale target generation, a different repeated decision, or an
2314 /// unsafe retry. Capacity, revocation, lower/storage failure and observation
2315 /// deadlines report `Busy`, `Closed`, `Integration`, or `Timeout`.
2316 ///
2317 /// # Example
2318 ///
2319 /// ```
2320 /// # use agents_kit::Agent;
2321 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
2322 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
2323 /// # RunStatus, WorkflowDefinition, WorkflowState};
2324 /// # use storage_kit::StateStore;
2325 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
2326 /// #
2327 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
2328 /// # runtime.block_on(async {
2329 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
2330 /// # let definition = WorkflowDefinition::parse(r#"{
2331 /// # "schema":"app-kit.workflow/v2","id":"counter","revision":"r1",
2332 /// # "state":{"answer":""},"required":["answer"],"reducers":{"answer":"reject_concurrent"},
2333 /// # "entry":"ask","nodes":[{"id":"ask","step":{"kind":"input",
2334 /// # "message":[{"kind":"text","text":"Review topic?"}],"target":"answer"}}],"routes":{}
2335 /// # }"#)?;
2336 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
2337 /// # let mut app = App::open(config).await?;
2338 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
2339 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
2340 /// # }).await?;
2341 /// # let workflow = workspace.workflow("counter".parse()?).await?;
2342 /// use app_kit::workflow::{Interaction, Resolution};
2343 /// let run = workflow.start(ExecutionOptions::default()).await?;
2344 /// let waiting = run.wait().await?;
2345 /// let Interaction::Input { sequence, generation, .. } = waiting.interactions[0] else {
2346 /// panic!("the input workflow must request a value");
2347 /// };
2348 /// run.resolve(sequence, generation, Resolution::Input(serde_json::json!("ownership"))).await?;
2349 /// assert_eq!(run.wait().await?.state.values()["answer"], "ownership");
2350 /// #
2351 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
2352 /// # Ok::<(), Box<dyn std::error::Error>>(())
2353 /// # })?;
2354 /// # Ok::<(), Box<dyn std::error::Error>>(())
2355 /// ```
2356 pub async fn resolve(
2357 &self,
2358 sequence: u64,
2359 generation: u64,
2360 resolution: Resolution,
2361 ) -> Result<RunSnapshot, AppError> {
2362 self.runtime.resolve(sequence, generation, resolution).await
2363 }
2364}
2365
2366impl WorkflowId {
2367 pub(crate) fn as_str(&self) -> &str {
2368 &self.0
2369 }
2370}
2371
2372impl RunId {
2373 pub(crate) fn validate_occurrence(occurrence: &str) -> Result<(), AppError> {
2374 if occurrence.len() == 32
2375 && occurrence
2376 .bytes()
2377 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
2378 {
2379 Ok(())
2380 } else {
2381 Err(AppError::InvalidInput(
2382 "run occurrence must contain 32 lowercase hexadecimal digits".into(),
2383 ))
2384 }
2385 }
2386
2387 pub(crate) fn generate() -> Self {
2388 Self(format!("run_{}", Uuid::new_v4().hyphenated()))
2389 }
2390
2391 fn child(&self, task_key: &str) -> Self {
2392 let mut digest = Sha256::new();
2393 digest.update(self.as_str().as_bytes());
2394 digest.update(b":");
2395 digest.update(task_key.as_bytes());
2396 Self(format!("{:x}", digest.finalize()))
2397 }
2398
2399 pub(crate) fn as_str(&self) -> &str {
2400 &self.0
2401 }
2402}
2403
2404impl fmt::Display for WorkflowId {
2405 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2406 formatter.write_str(&self.0)
2407 }
2408}
2409
2410impl FromStr for WorkflowId {
2411 type Err = AppError;
2412
2413 fn from_str(value: &str) -> Result<Self, Self::Err> {
2414 IdentitySyntax::Named("WorkflowId").parse(value).map(Self)
2415 }
2416}
2417
2418impl Serialize for WorkflowId {
2419 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2420 where
2421 S: Serializer,
2422 {
2423 serializer.serialize_str(&self.0)
2424 }
2425}
2426
2427impl<'de> Deserialize<'de> for WorkflowId {
2428 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2429 where
2430 D: Deserializer<'de>,
2431 {
2432 String::deserialize(deserializer)?
2433 .parse()
2434 .map_err(serde::de::Error::custom)
2435 }
2436}
2437
2438impl fmt::Display for RunId {
2439 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2440 formatter.write_str(&self.0)
2441 }
2442}
2443
2444impl FromStr for RunId {
2445 type Err = AppError;
2446
2447 fn from_str(value: &str) -> Result<Self, Self::Err> {
2448 if value.len() == 64
2449 && value
2450 .bytes()
2451 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
2452 {
2453 return Ok(Self(value.to_owned()));
2454 }
2455 IdentitySyntax::Uuid {
2456 name: "RunId",
2457 prefix: "run_",
2458 }
2459 .parse(value)
2460 .map(Self)
2461 }
2462}
2463
2464impl Serialize for RunId {
2465 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2466 where
2467 S: Serializer,
2468 {
2469 serializer.serialize_str(&self.0)
2470 }
2471}
2472
2473impl<'de> Deserialize<'de> for RunId {
2474 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2475 where
2476 D: Deserializer<'de>,
2477 {
2478 String::deserialize(deserializer)?
2479 .parse()
2480 .map_err(serde::de::Error::custom)
2481 }
2482}