app_kit/lib.rs
1//! Durable applications over caller-owned storage and configured agents.
2//!
3//! Start with [`AppConfig`] and [`App::open`], create a [`Workspace`], then select
4//! a [`workflow::Workflow`]. The [`App`] example runs a local state workflow with
5//! caller-created embedded Lance StateStore and no Agent value or external service.
6//!
7//! [`workflow`] contains definitions, execution controls, state operations,
8//! graph inspection, immutable checkpoints, and the five concrete App behavior
9//! seams. [`AgentSpec`] transfers an already-built Agent; App never constructs
10//! providers, tools or lower storage from declarations. Exact associated lower
11//! Workspace, Conversation and StateTransaction types remain attached.
12//!
13//! Call [`App::shutdown`] while the captured Tokio runtime and lower runtimes
14//! are alive and driven. Run handles observe accepted work; [`workflow::Execution`]
15//! owns lazy advancement. Dropping an execution can revoke uncommitted work,
16//! while observation loss cannot revoke an admitted native commit.
17//!
18//! Public supporting types live under [`workflow`]. The state serialization
19//! registry stores only field/tag codecs; it is not a runtime behavior registry.
20
21//!
22//! # Choose a workflow
23//!
24//! - Application builders: the [local workflow](#local-workflow),
25//! [explicit execution and retained history](workflow), then
26//! [durable reopen and migration prerequisites](App::open).
27//! - Custom implementers: [configure concrete behavior](AppConfig#configure-behavior)
28//! with the five workflow traits. Their examples run locally; lower
29//! [`agents_kit::AgentBackend`] and [`storage_kit::StateStoreBackend`]
30//! implementations remain direct, statically selected values.
31//! - Agent-backed workflows: transfer already-built values through [`AgentSpec`].
32//! App owns eventual consuming Agent shutdown, without rebuilding credentials,
33//! tools, Providers or persistence from declarations.
34//!
35//! # Local workflow
36//!
37//! ```
38//! use agents_kit::Agent;
39//! use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
40//! use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
41//! RunStatus, WorkflowDefinition, WorkflowState};
42//! use storage_kit::StateStore;
43//! use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
44//!
45//! let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
46//! runtime.block_on(async {
47//! let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
48//! let definition = WorkflowDefinition::parse(r#"{
49//! "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
50//! "state":{"count":0}, "required":["count"],
51//! "reducers":{"count":"reject_concurrent"}, "entry":"increment",
52//! "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
53//! {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
54//! ]}}], "routes":{}
55//! }"#)?;
56//! let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
57//! let mut app = App::open(config).await?;
58//! let mut workspace = app.create_workspace(WorkspaceConfig {
59//! name: Some("local counter".into()), root: std::env::current_dir()?,
60//! }).await?;
61//! let workflow = workspace.workflow("counter".parse()?).await?;
62//! let run = workflow.start(ExecutionOptions::default()).await?;
63//! let snapshot = run.wait().await?;
64//! assert!(matches!(snapshot.status, RunStatus::Succeeded));
65//! assert_eq!(snapshot.state.values()["count"], 1);
66//! #
67//! app.shutdown(Shutdown::Drain { timeout: std::time::Duration::from_secs(5) }).await?;
68//! # Ok::<(), Box<dyn std::error::Error>>(())
69//! })?;
70//! # Ok::<(), Box<dyn std::error::Error>>(())
71//! ```
72
73#![forbid(unsafe_code)]
74#![warn(missing_docs)]
75
76use std::fmt;
77use std::num::NonZeroUsize;
78use std::path::PathBuf;
79use std::str::FromStr;
80use std::sync::Arc;
81use std::time::Duration;
82
83use agents_kit::AgentBackend;
84use serde::{Deserialize, Deserializer, Serialize, Serializer};
85use storage_kit::StateStoreBackend;
86use uuid::{Uuid, Variant, Version};
87
88use crate::workflow::{
89 ActionBackend, ChildExecutor, ConditionBackend, InlineExecutor, JsonState, LifecycleHooks,
90 NoActions, NoConditions, NoHooks, StateTyping, Workflow, WorkflowDefinition, WorkflowId,
91};
92
93mod app;
94mod storage;
95mod workspace;
96
97/// Opaque workflow definitions and runtime handles.
98pub mod workflow;
99
100/// An open application generation.
101///
102/// Owns one durable application generation, its transferred agents and accepted work.
103///
104/// Supply a concrete [`storage_kit::StateStoreBackend`] through [`AppConfig`].
105/// Each [`AgentSpec`] preserves its concrete [`agents_kit::AgentBackend`] and its
106/// associated Workspace/Conversation types. An empty agent inventory can execute
107/// state-only workflows; the explicit `Agent` type below constructs no Agent value.
108///
109/// Keep the Tokio runtime and any caller-owned lower runtimes alive and driven
110/// through [`Self::shutdown`]. Root drop revokes admission and requests supervised
111/// best-effort cleanup; it cannot substitute for awaited shutdown. Workspace and
112/// run observers do not keep admission alive after the App is revoked.
113///
114/// # Example
115///
116/// ```
117/// use agents_kit::Agent;
118/// use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
119/// use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
120/// RunStatus, WorkflowDefinition, WorkflowState};
121/// use storage_kit::StateStore;
122/// use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
123///
124/// let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
125/// runtime.block_on(async {
126/// let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
127/// let definition = WorkflowDefinition::parse(r#"{
128/// "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
129/// "state":{"count":0}, "required":["count"],
130/// "reducers":{"count":"reject_concurrent"}, "entry":"increment",
131/// "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
132/// {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
133/// ]}}], "routes":{}
134/// }"#)?;
135/// let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
136/// let mut app = App::open(config).await?;
137/// let mut workspace = app.create_workspace(WorkspaceConfig {
138/// name: Some("local counter".into()), root: std::env::current_dir()?,
139/// }).await?;
140/// let workflow = workspace.workflow("counter".parse()?).await?;
141/// let run = workflow.start(ExecutionOptions::default()).await?;
142/// let snapshot = run.wait().await?;
143/// assert!(matches!(snapshot.status, RunStatus::Succeeded));
144/// assert_eq!(snapshot.state.values()["count"], 1);
145/// #
146/// app.shutdown(Shutdown::Drain { timeout: std::time::Duration::from_secs(5) }).await?;
147/// # Ok::<(), Box<dyn std::error::Error>>(())
148/// })?;
149/// # Ok::<(), Box<dyn std::error::Error>>(())
150/// ```
151pub struct App<
152 CS: StateStoreBackend,
153 A: AgentBackend,
154 X: ActionBackend = NoActions,
155 C: ConditionBackend = NoConditions,
156 T: StateTyping = JsonState,
157 H: LifecycleHooks = NoHooks,
158 E: ChildExecutor = InlineExecutor,
159> {
160 runtime: Arc<app::AppRuntime<CS, A, X, C, T, H, E>>,
161}
162
163/// Complete application configuration.
164///
165/// App does not offer a separate built-in storage construction path:
166///
167/// ```compile_fail
168/// let _ = app_kit::AppState::<storage_kit::StateStore>::Ephemeral;
169/// ```
170///
171/// Owns caller-built storage, a homogeneous agent inventory, and workflow definitions.
172///
173/// The consuming `with_*` methods change their concrete App behavior parameter.
174/// They do not erase backends or alter already admitted runs. Heterogeneous agent
175/// inventories use a caller-owned exhaustive hierarchy. Dropping configuration
176/// before open does not perform asynchronous Agent shutdown.
177///
178/// # Configure behavior
179///
180/// Start with ready storage, ready Agents, and parsed definitions. Apply the
181/// consuming configuration methods before [`App::open`]; they retain exact
182/// implementation types, including associated lower children and transaction
183/// ownership. None registers a runtime callback or erases a backend.
184///
185/// | Behavior | Complete implementation example | Attach before open |
186/// | --- | --- | --- |
187/// | Produce lazy action events and writes | [`workflow::ActionBackend`] | [`Self::with_actions`] |
188/// | Evaluate declared routing predicates | [`workflow::ConditionBackend`] | [`Self::with_conditions`] |
189/// | Construct and validate application state | [`workflow::StateTyping`] | [`Self::with_state_typing`] |
190/// | Observe authoritative lifecycle events | [`workflow::LifecycleHooks`] | [`Self::with_hooks`] |
191/// | Drive concrete local child futures | [`workflow::ChildExecutor`] | [`Self::with_child_executor`] |
192///
193/// The `with_*` examples exercise the complete path from a concrete implementation
194/// to opening App. Action and condition declarations must match the workflow's
195/// exact logical keys and revisions. A custom child executor must return every
196/// admitted completion exactly once; it never creates child identities. App's
197/// built-in `NoActions`, `NoConditions`, `JsonState`, `NoHooks` and `InlineExecutor`
198/// are suitable defaults only where their actual behavior satisfies the workflow.
199///
200/// Lower composition is independent: `A::Workspace` fixes its Conversation,
201/// while `CS::Transaction` fixes consuming commit/rollback. For heterogeneous
202/// Agent inventories implement matching caller-owned exhaustive hierarchies;
203/// do not substitute public trait objects or independent child parameters.
204///
205/// # Example
206///
207/// ```
208/// # use agents_kit::Agent;
209/// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
210/// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
211/// # RunStatus, WorkflowDefinition, WorkflowState};
212/// # use storage_kit::StateStore;
213/// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
214/// #
215/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
216/// # runtime.block_on(async {
217/// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
218/// # let definition = WorkflowDefinition::parse(r#"{
219/// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
220/// # "state":{"count":0}, "required":["count"],
221/// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
222/// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
223/// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
224/// # ]}}], "routes":{}
225/// # }"#)?;
226/// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
227/// let mut app = App::open(config).await?;
228/// app.shutdown(Shutdown::RejectActive).await?;
229/// #
230/// # Ok::<(), Box<dyn std::error::Error>>(())
231/// # })?;
232/// # Ok::<(), Box<dyn std::error::Error>>(())
233/// ```
234pub struct AppConfig<
235 CS: StateStoreBackend,
236 A: AgentBackend,
237 X: ActionBackend = NoActions,
238 C: ConditionBackend = NoConditions,
239 T: StateTyping = JsonState,
240 H: LifecycleHooks = NoHooks,
241 E: ChildExecutor = InlineExecutor,
242> {
243 state: CS,
244 agents: Vec<AgentSpec<A>>,
245 workflows: Vec<WorkflowDefinition>,
246 actions: Vec<X>,
247 conditions: Vec<C>,
248 typing: T,
249 hooks: H,
250 executor: E,
251}
252
253/// Declares one configured lower agent.
254///
255/// The key and revision are caller-owned logical binding metadata, validated by
256/// [`App::open`]. Keep the same revision only while the configuration is compatible.
257/// App never derives compatibility from the opaque lower Agent identity. A
258/// successful open takes responsibility for consuming Agent shutdown.
259///
260/// # Example
261///
262/// This example requires an already-running OpenCode service at the shown endpoint.
263///
264/// ```no_run
265/// use std::{num::NonZeroUsize, time::Duration};
266/// use agents_kit::{Agent, AgentBuilder, AgentStorage};
267/// use agents_kit::config::{AgentConfig, OpenCodeConfig};
268/// use app_kit::{AgentSpec, App, AppConfig, Shutdown};
269/// use storage_kit::{StateStore, state::config::{LanceStateStoreConfig, StateStoreConfig}};
270/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
271/// # runtime.block_on(async {
272/// let agent_store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
273/// let backend = OpenCodeConfig::external("http://127.0.0.1:4096", Duration::from_secs(10),
274/// Duration::from_secs(60), Duration::from_secs(10))?;
275/// let agent_config = AgentConfig::new(backend.into(), Duration::from_secs(10),
276/// NonZeroUsize::new(1).unwrap())?;
277/// let agent: Agent = AgentBuilder::create(agent_config, AgentStorage::new(agent_store)).build().await?;
278/// let spec = AgentSpec::new("reviewer".into(), "r1".into(), agent);
279/// let app_store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
280/// let mut app = App::open(AppConfig::new(app_store, vec![spec], Vec::new())).await?;
281/// // App now owns consuming shutdown of the transferred Agent.
282/// app.shutdown(Shutdown::Drain { timeout: Duration::from_secs(30) }).await?;
283/// # Ok::<(), Box<dyn std::error::Error>>(())
284/// # })?;
285/// # Ok::<(), Box<dyn std::error::Error>>(())
286/// ```
287pub struct AgentSpec<A: AgentBackend> {
288 key: String,
289 revision: String,
290 agent: Arc<A>,
291}
292
293/// Selects application shutdown behavior.
294///
295/// Drain timeouts limit observation only. Once admission is sealed, a timed-out
296/// or dropped shutdown observer does not cancel the finalizer or reopen App.
297/// Keep all owning runtimes driven until shutdown completes.
298pub enum Shutdown {
299 /// Rejects shutdown while accepted work remains.
300 RejectActive,
301 /// Seals admission and drains accepted work.
302 Drain {
303 /// Bounds this caller's observation of finalization.
304 timeout: Duration,
305 },
306 /// Seals admission, cancels accepted work, and drains it.
307 CancelAndDrain {
308 /// Bounds this caller's observation of finalization.
309 timeout: Duration,
310 },
311}
312
313/// An application workspace handle.
314///
315/// A typed projection into the owning App generation. Dropping it does not delete
316/// or close lower workspaces. Replacement uses a complete configuration and a
317/// revision fence; successful deletion revokes this projection. Retained terminal
318/// history can still be opened through its workflow after workspace deletion.
319///
320/// # Example
321///
322/// ```
323/// # use agents_kit::Agent;
324/// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
325/// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
326/// # RunStatus, WorkflowDefinition, WorkflowState};
327/// # use storage_kit::StateStore;
328/// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
329/// #
330/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
331/// # runtime.block_on(async {
332/// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
333/// # let definition = WorkflowDefinition::parse(r#"{
334/// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
335/// # "state":{"count":0}, "required":["count"],
336/// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
337/// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
338/// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
339/// # ]}}], "routes":{}
340/// # }"#)?;
341/// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
342/// # let mut app = App::open(config).await?;
343/// # let mut workspace = app.create_workspace(WorkspaceConfig {
344/// # name: Some("local counter".into()), root: std::env::current_dir()?,
345/// # }).await?;
346/// let snapshot = workspace.snapshot().await?;
347/// assert_eq!(&snapshot.id, workspace.id());
348/// assert_eq!(snapshot.revision, 1);
349/// #
350/// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
351/// # Ok::<(), Box<dyn std::error::Error>>(())
352/// # })?;
353/// # Ok::<(), Box<dyn std::error::Error>>(())
354/// ```
355pub struct Workspace<
356 CS: StateStoreBackend,
357 A: AgentBackend,
358 X: ActionBackend = NoActions,
359 C: ConditionBackend = NoConditions,
360 T: StateTyping = JsonState,
361 H: LifecycleHooks = NoHooks,
362 E: ChildExecutor = InlineExecutor,
363> {
364 runtime: workspace::WorkspaceRuntime<CS, A, X, C, T, H, E>,
365}
366
367/// A validated application workspace identity.
368///
369/// Parsing and deserialization require `appws_` followed by a canonical lowercase,
370/// hyphenated UUIDv4. Identity equality does not establish lower-agent compatibility.
371///
372/// # Example
373///
374/// ```
375/// use app_kit::WorkspaceId;
376/// let id: WorkspaceId = "appws_8a7c251e-b5b8-4d9e-8f0b-123456789abc".parse()?;
377/// assert_eq!(id.to_string(), "appws_8a7c251e-b5b8-4d9e-8f0b-123456789abc");
378/// assert!("appws_not-a-uuid".parse::<WorkspaceId>().is_err());
379/// # Ok::<(), app_kit::AppError>(())
380/// ```
381#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
382pub struct WorkspaceId(String);
383
384/// Complete caller-authored workspace configuration.
385///
386/// The root is an absolute, lexical UTF-8 path, 1–4,096 bytes without control
387/// characters. Validation does not create directories or resolve symlinks. A present
388/// name is trimmed and must be nonempty, control-free, and at most 256 UTF-8 bytes.
389///
390/// # Example
391///
392/// ```
393/// use app_kit::WorkspaceConfig;
394/// let config = WorkspaceConfig { name: Some("review".into()), root: std::env::current_dir()? };
395/// assert!(config.root.is_absolute());
396/// # Ok::<(), std::io::Error>(())
397/// ```
398pub struct WorkspaceConfig {
399 /// Optionally names the lower workspace.
400 pub name: Option<String>,
401 /// Selects the absolute workspace root.
402 pub root: PathBuf,
403}
404
405/// The current caller-visible workspace truth.
406///
407/// # Example
408///
409/// ```
410/// # use agents_kit::Agent;
411/// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
412/// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
413/// # RunStatus, WorkflowDefinition, WorkflowState};
414/// # use storage_kit::StateStore;
415/// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
416/// #
417/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
418/// # runtime.block_on(async {
419/// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
420/// # let definition = WorkflowDefinition::parse(r#"{
421/// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
422/// # "state":{"count":0}, "required":["count"],
423/// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
424/// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
425/// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
426/// # ]}}], "routes":{}
427/// # }"#)?;
428/// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
429/// # let mut app = App::open(config).await?;
430/// # let mut workspace = app.create_workspace(WorkspaceConfig {
431/// # name: Some("local counter".into()), root: std::env::current_dir()?,
432/// # }).await?;
433/// let snapshot = workspace.snapshot().await?;
434/// let updated = workspace.replace(snapshot.revision, WorkspaceConfig {
435/// name: Some("renamed".into()), root: snapshot.config.root,
436/// }).await?;
437/// assert_eq!(updated.revision, snapshot.revision + 1);
438/// #
439/// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
440/// # Ok::<(), Box<dyn std::error::Error>>(())
441/// # })?;
442/// # Ok::<(), Box<dyn std::error::Error>>(())
443/// ```
444#[non_exhaustive]
445pub struct WorkspaceSnapshot {
446 /// Identifies the workspace.
447 pub id: WorkspaceId,
448 /// Fences complete replacement.
449 pub revision: u64,
450 /// Contains the committed configuration.
451 pub config: WorkspaceConfig,
452}
453
454/// A safe caller-visible application error.
455///
456/// Operational messages are bounded and redact lower payloads and identities.
457/// Errors supplied by explicitly registered codecs or lifecycle hooks retain
458/// caller context at that opt-in boundary. Match variants (with a fallback for
459/// this non-exhaustive enum), rather than parsing Display text. An error from an
460/// observer does not by itself prove an accepted native operation rolled back.
461#[non_exhaustive]
462#[derive(Debug)]
463pub enum AppError {
464 /// Caller-authored input is invalid.
465 InvalidInput(String),
466 /// Exact scoped evidence proves a resource absent.
467 NotFound(String),
468 /// A durable fact, fence, or transition conflicts.
469 Conflict(String),
470 /// Finite capacity or an exclusive owner prevents progress.
471 Busy(String),
472 /// Revocation or terminal invalidation rejects new work.
473 Closed(String),
474 /// An application-owned deadline elapsed.
475 Timeout(String),
476 /// Caller behavior, a lower public operation, or a private durable record failed.
477 Integration(String),
478}
479
480enum IdentitySyntax {
481 Named(&'static str),
482 Uuid {
483 name: &'static str,
484 prefix: &'static str,
485 },
486}
487
488impl<
489 CS: StateStoreBackend,
490 A: AgentBackend,
491 X: ActionBackend,
492 C: ConditionBackend,
493 T: StateTyping,
494 H: LifecycleHooks,
495 E: ChildExecutor,
496> App<CS, A, X, C, T, H, E>
497{
498 /// Opens or reconciles one application generation.
499 ///
500 /// Validates bindings and retained records, claims one durable generation, and
501 /// reconstructs recovery ownership before returning. A successful newer opener
502 /// fences handles from the previous generation. Configuration is consumed.
503 ///
504 /// # Durable reopen and deployment
505 ///
506 /// Reopen with a fresh handle to the same caller-owned StateStore, the same
507 /// compatible workflow definitions, and freshly constructed or reopened lower
508 /// integrations. Never reuse a consumed Agent or infer a new logical identity
509 /// from its display representation. The state-only example below needs no Agent
510 /// value and demonstrates that shutdown retains history until explicit deletion.
511 ///
512 /// Existing App v6 records require a **cold, one-way v6-to-v7 migration**.
513 /// Stop older clients, settle all their owners and take a whole-store physical
514 /// backup before opening with this version. Opening performs the supported
515 /// migration before claiming the new generation. Mixed-version operation,
516 /// in-place downgrade, reset and corruption repair are not supported. Restore
517 /// the physical backup for a rollback deployment; do not open migrated data
518 /// with an older client. Unsupported epochs and corrupt current records fail
519 /// rather than falling back to another decoder.
520 ///
521 /// After Agent ownership transfer, ordinary failure awaits consuming Agent cleanup
522 /// and preserves the primary failure. Cancelling this open future during transfer
523 /// does not promise graceful cleanup: reopen the durable lower products and App to
524 /// reconcile their retained facts.
525 ///
526 /// # Errors
527 ///
528 /// Returns [`AppError::InvalidInput`] for invalid or conflicting construction
529 /// metadata, [`AppError::Conflict`] for incompatible retained revisions or lost
530 /// ownership, and [`AppError::Integration`] for unsupported durable versions,
531 /// invalid retained records, or lower failures. Recovery can also report capacity,
532 /// closure, or deadline errors; no live App is returned on failure.
533 ///
534 /// # Example
535 ///
536 /// ```
537 /// use agents_kit::Agent;
538 /// use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
539 /// use app_kit::workflow::{ExecutionOptions, RunSelector, RunStatus, WorkflowDefinition};
540 /// use storage_kit::StateStore;
541 /// use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
542 /// use std::time::Duration;
543 /// let directory = std::env::temp_dir().join(format!("ai-kit-app-doc-{}", uuid::Uuid::new_v4()));
544 /// std::fs::create_dir(&directory)?;
545 /// let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
546 /// let result = runtime.block_on(async {
547 /// let definition_source = r#"{
548 /// "schema":"app-kit.workflow/v2","id":"counter","revision":"r1",
549 /// "state":{"count":0},"required":["count"],"reducers":{"count":"reject_concurrent"},
550 /// "entry":"increment","nodes":[{"id":"increment","step":{"kind":"state",
551 /// "mutations":[{"kind":"increment","target":"count","value":{"kind":"literal","value":1}}]}}],
552 /// "routes":{}
553 /// }"#;
554 /// let config = StateStoreConfig::lance(LanceStateStoreConfig::directory(&directory));
555 /// let store = StateStore::connect(config.clone()).await?;
556 /// let mut app = App::open(AppConfig::<_, Agent>::new(store, Vec::new(), vec![WorkflowDefinition::parse(definition_source)?])).await?;
557 /// let first = async {
558 /// let workspace = app.create_workspace(WorkspaceConfig { name: None, root: directory.clone() }).await?;
559 /// let workflow = workspace.workflow("counter".parse()?).await?;
560 /// let run = workflow.start(ExecutionOptions::default()).await?;
561 /// let terminal = run.wait().await?;
562 /// assert!(matches!(terminal.status, RunStatus::Succeeded));
563 /// Ok::<_, app_kit::AppError>((workspace.id().clone(), run.id().clone()))
564 /// }.await;
565 /// let shutdown = app.shutdown(Shutdown::CancelAndDrain { timeout: Duration::from_secs(5) }).await;
566 /// drop(app);
567 /// let (workspace_id, run_id) = first?;
568 /// shutdown?;
569 ///
570 /// let fresh_store = StateStore::connect(config).await?;
571 /// let mut reopened = App::open(AppConfig::<_, Agent>::new(fresh_store, Vec::new(), vec![WorkflowDefinition::parse(definition_source)?])).await?;
572 /// let readback = async {
573 /// let workspace = reopened.workspace(workspace_id).await?;
574 /// let workflow = workspace.workflow("counter".parse()?).await?;
575 /// let mut run = workflow.open_run(RunSelector::Latest(run_id.clone())).await?;
576 /// let terminal = run.snapshot().await?;
577 /// assert!(matches!(terminal.status, RunStatus::Succeeded));
578 /// assert_eq!(terminal.state.values()["count"], 1);
579 /// run.delete(terminal.checkpoint).await?;
580 /// assert!(matches!(workflow.open_run(RunSelector::Latest(run_id)).await,
581 /// Err(app_kit::AppError::NotFound(_))));
582 /// Ok::<(), app_kit::AppError>(())
583 /// }.await;
584 /// let shutdown = reopened.shutdown(Shutdown::CancelAndDrain { timeout: Duration::from_secs(5) }).await;
585 /// drop(reopened);
586 /// readback?;
587 /// shutdown?;
588 /// Ok::<(), Box<dyn std::error::Error>>(())
589 /// });
590 /// // A shutdown timeout bounds the waiter, not necessarily the owned finalizer.
591 /// // Remove the directory only after both generations demonstrably settled.
592 /// if result.is_ok() {
593 /// std::fs::remove_dir_all(&directory)?;
594 /// } else {
595 /// eprintln!("Retain {} for cleanup reconciliation", directory.display());
596 /// }
597 /// result?;
598 /// # Ok::<(), Box<dyn std::error::Error>>(())
599 /// ```
600 pub async fn open(config: AppConfig<CS, A, X, C, T, H, E>) -> Result<Self, AppError> {
601 Ok(Self {
602 runtime: app::AppTrace::span(|| tracing::info_span!(target: "app_kit", "app.open"))
603 .observe(std::pin::pin!(app::AppRuntime::open(config)))
604 .await?,
605 })
606 }
607
608 /// Creates and materializes one workspace.
609 ///
610 /// Materializes the complete configured Agent key/revision inventory before
611 /// publishing the App workspace. Durable intent precedes each lower operation;
612 /// unresolved partial progress remains recoverable rather than a visible success.
613 ///
614 /// # Errors
615 ///
616 /// Returns [`AppError::InvalidInput`] for invalid configuration, [`AppError::Busy`]
617 /// when admission is full, or [`AppError::Closed`] after revocation. Conflicting
618 /// materialization evidence, lower/storage failures, and bounded waits report
619 /// `Conflict`, `Integration`, or `Timeout` respectively.
620 ///
621 /// # Example
622 ///
623 /// ```
624 /// # use agents_kit::Agent;
625 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
626 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
627 /// # RunStatus, WorkflowDefinition, WorkflowState};
628 /// # use storage_kit::StateStore;
629 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
630 /// #
631 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
632 /// # runtime.block_on(async {
633 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
634 /// # let definition = WorkflowDefinition::parse(r#"{
635 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
636 /// # "state":{"count":0}, "required":["count"],
637 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
638 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
639 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
640 /// # ]}}], "routes":{}
641 /// # }"#)?;
642 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
643 /// # let mut app = App::open(config).await?;
644 /// let workspace = app.create_workspace(WorkspaceConfig {
645 /// name: Some("review".into()), root: std::env::current_dir()?,
646 /// }).await?;
647 /// assert_eq!(workspace.snapshot().await?.revision, 1);
648 /// #
649 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
650 /// # Ok::<(), Box<dyn std::error::Error>>(())
651 /// # })?;
652 /// # Ok::<(), Box<dyn std::error::Error>>(())
653 /// ```
654 pub async fn create_workspace(
655 &self,
656 config: WorkspaceConfig,
657 ) -> Result<Workspace<CS, A, X, C, T, H, E>, AppError> {
658 Ok(Workspace {
659 runtime: self.runtime.create_workspace(config).await?,
660 })
661 }
662
663 /// Reopens one workspace by typed identity.
664 ///
665 /// A retained terminal head can establish a history-only scope after coordination
666 /// is deleted. That handle permits retained-run lookup, not fresh admission.
667 ///
668 /// # Errors
669 ///
670 /// Returns [`AppError::NotFound`] when neither active coordination nor readable
671 /// retained history exists in this scope. Revocation, incompatible lifecycle
672 /// progress, and unreadable durable evidence report `Closed`, `Conflict`, or
673 /// `Integration`.
674 ///
675 /// # Example
676 ///
677 /// ```
678 /// # use agents_kit::Agent;
679 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
680 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
681 /// # RunStatus, WorkflowDefinition, WorkflowState};
682 /// # use storage_kit::StateStore;
683 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
684 /// #
685 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
686 /// # runtime.block_on(async {
687 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
688 /// # let definition = WorkflowDefinition::parse(r#"{
689 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
690 /// # "state":{"count":0}, "required":["count"],
691 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
692 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
693 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
694 /// # ]}}], "routes":{}
695 /// # }"#)?;
696 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
697 /// # let mut app = App::open(config).await?;
698 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
699 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
700 /// # }).await?;
701 /// let id = workspace.id().clone();
702 /// let reopened = app.workspace(id.clone()).await?;
703 /// assert_eq!(reopened.id(), &id);
704 /// #
705 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
706 /// # Ok::<(), Box<dyn std::error::Error>>(())
707 /// # })?;
708 /// # Ok::<(), Box<dyn std::error::Error>>(())
709 /// ```
710 pub async fn workspace(
711 &self,
712 id: WorkspaceId,
713 ) -> Result<Workspace<CS, A, X, C, T, H, E>, AppError> {
714 Ok(Workspace {
715 runtime: self.runtime.workspace(id).await?,
716 })
717 }
718
719 /// Lists workspaces in canonical identity order.
720 ///
721 /// The cursor is exclusive and pages contain active workspaces only. Limits are
722 /// 1–1,000; larger requests are rejected rather than silently clamped.
723 ///
724 /// # Errors
725 ///
726 /// Returns [`AppError::InvalidInput`] for an excessive limit, [`AppError::NotFound`]
727 /// for an unknown cursor, and `Closed` or `Integration` for revoked or unreadable
728 /// application state.
729 ///
730 /// # Example
731 ///
732 /// ```
733 /// # use agents_kit::Agent;
734 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
735 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
736 /// # RunStatus, WorkflowDefinition, WorkflowState};
737 /// # use storage_kit::StateStore;
738 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
739 /// #
740 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
741 /// # runtime.block_on(async {
742 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
743 /// # let definition = WorkflowDefinition::parse(r#"{
744 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
745 /// # "state":{"count":0}, "required":["count"],
746 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
747 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
748 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
749 /// # ]}}], "routes":{}
750 /// # }"#)?;
751 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
752 /// # let mut app = App::open(config).await?;
753 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
754 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
755 /// # }).await?;
756 /// let page = app.list_workspaces(None, std::num::NonZeroUsize::new(1).unwrap()).await?;
757 /// assert_eq!(&page[0].id, workspace.id());
758 /// let next = app.list_workspaces(Some(page[0].id.clone()), std::num::NonZeroUsize::new(1).unwrap()).await?;
759 /// assert!(next.is_empty());
760 /// #
761 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
762 /// # Ok::<(), Box<dyn std::error::Error>>(())
763 /// # })?;
764 /// # Ok::<(), Box<dyn std::error::Error>>(())
765 /// ```
766 pub async fn list_workspaces(
767 &self,
768 after: Option<WorkspaceId>,
769 limit: NonZeroUsize,
770 ) -> Result<Vec<WorkspaceSnapshot>, AppError> {
771 self.runtime.list_workspaces(after, limit).await
772 }
773
774 /// Finalizes this application according to `policy`.
775 ///
776 /// `RejectActive` leaves admission open if work remains. Drain policies seal
777 /// admission; `CancelAndDrain` also requests explicit cancellation. Successful
778 /// completion joins App-owned work and consumes each transferred Agent once.
779 /// The timeout bounds this observer, not the continuing finalizer. Cancelling the
780 /// observer or timing out never reopens admission or detaches accepted cleanup.
781 ///
782 /// # Errors
783 ///
784 /// Returns [`AppError::Busy`] when `RejectActive` encounters accepted work,
785 /// [`AppError::InvalidInput`] for an invalid timeout, or [`AppError::Timeout`] when
786 /// observation expires. Durable conflicts and lower cleanup failures remain
787 /// `Conflict` or `Integration`; cleanup does not erase an earlier failure.
788 ///
789 /// # Example
790 ///
791 /// ```
792 /// # use agents_kit::Agent;
793 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
794 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
795 /// # RunStatus, WorkflowDefinition, WorkflowState};
796 /// # use storage_kit::StateStore;
797 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
798 /// #
799 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
800 /// # runtime.block_on(async {
801 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
802 /// # let definition = WorkflowDefinition::parse(r#"{
803 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
804 /// # "state":{"count":0}, "required":["count"],
805 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
806 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
807 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
808 /// # ]}}], "routes":{}
809 /// # }"#)?;
810 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
811 /// # let mut app = App::open(config).await?;
812 /// app.shutdown(Shutdown::Drain { timeout: std::time::Duration::from_secs(5) }).await?;
813 /// #
814 /// # Ok::<(), Box<dyn std::error::Error>>(())
815 /// # })?;
816 /// # Ok::<(), Box<dyn std::error::Error>>(())
817 /// ```
818 pub async fn shutdown(&mut self, policy: Shutdown) -> Result<(), AppError> {
819 self.runtime.shutdown(policy).await
820 }
821}
822
823impl<CS: StateStoreBackend, A: AgentBackend> AppConfig<CS, A> {
824 /// Constructs configuration from a caller-owned control-plane store and agents.
825 ///
826 /// The store is required even for process-local operation. App preserves its
827 /// concrete transaction type and never constructs lower products. Ownership
828 /// transfers to App when [`App::open`] is called.
829 ///
830 /// # Example
831 ///
832 /// ```
833 /// # use agents_kit::Agent;
834 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
835 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
836 /// # RunStatus, WorkflowDefinition, WorkflowState};
837 /// # use storage_kit::StateStore;
838 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
839 /// #
840 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
841 /// # runtime.block_on(async {
842 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
843 /// # let definition = WorkflowDefinition::parse(r#"{
844 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
845 /// # "state":{"count":0}, "required":["count"],
846 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
847 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
848 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
849 /// # ]}}], "routes":{}
850 /// # }"#)?;
851 /// let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
852 /// let mut app = App::open(config).await?;
853 /// app.shutdown(Shutdown::RejectActive).await?;
854 /// #
855 /// # Ok::<(), Box<dyn std::error::Error>>(())
856 /// # })?;
857 /// # Ok::<(), Box<dyn std::error::Error>>(())
858 /// ```
859 #[must_use]
860 pub fn new(state: CS, agents: Vec<AgentSpec<A>>, workflows: Vec<WorkflowDefinition>) -> Self {
861 Self {
862 state,
863 agents,
864 workflows,
865 actions: Vec::new(),
866 conditions: Vec::new(),
867 typing: JsonState,
868 hooks: NoHooks,
869 executor: InlineExecutor,
870 }
871 }
872}
873
874impl<
875 CS: StateStoreBackend,
876 A: AgentBackend,
877 X: ActionBackend,
878 C: ConditionBackend,
879 T: StateTyping,
880 H: LifecycleHooks,
881 E: ChildExecutor,
882> AppConfig<CS, A, X, C, T, H, E>
883{
884 /// Replaces the complete caller-owned action inventory before opening App.
885 ///
886 /// Values retain their concrete type and need not implement `Clone`. App
887 /// validates their stable declarations before accepting any workflow work.
888 /// Replacing an inventory drops its previous values without invoking them.
889 ///
890 /// # Example
891 ///
892 /// ```
893 /// use agents_kit::Agent;
894 /// use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
895 /// use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
896 /// RunStatus, WorkflowDefinition, WorkflowState};
897 /// use storage_kit::StateStore;
898 /// use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
899 /// use app_kit::{AppError, workflow::{ActionBackend, ActionDeclaration, ActionEvent,
900 /// ActionMode, ActionRequest, StateChange}};
901 /// use serde_json::{Map, Value};
902 /// struct Increment { declaration: ActionDeclaration }
903 /// impl ActionBackend for Increment {
904 /// type Stream = futures_util::stream::Iter<std::vec::IntoIter<Result<ActionEvent, AppError>>>;
905 /// fn declaration(&self) -> &ActionDeclaration { &self.declaration }
906 /// fn start(&self, request: ActionRequest) -> Self::Stream {
907 /// let count = request.state.values()["count"].as_i64().unwrap() + 1;
908 /// futures_util::stream::iter(vec![Ok(ActionEvent::Completed {
909 /// result: Map::from_iter([("new_count".into(), Value::from(count))]),
910 /// writes: vec![StateChange::Increment { values: vec![("count".into(), Value::from(1))] }],
911 /// })])
912 /// }
913 /// }
914 /// let action = Increment { declaration: ActionDeclaration {
915 /// key: "increment".into(), revision: "r1".into(), reads: vec!["count".into()],
916 /// writes: vec!["count".into()], required_inputs: Vec::new(), optional_inputs: Vec::new(),
917 /// bound_inputs: Map::new(), default_inputs: Map::new(), tags: vec!["counter".into()],
918 /// source: Some("increment count by one".into()), mode: ActionMode::Both,
919 /// } };
920 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
921 /// # runtime.block_on(async {
922 /// let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
923 /// let definition = WorkflowDefinition::parse(r#"{
924 /// "schema":"app-kit.workflow/v2","id":"counter","revision":"r1",
925 /// "state":{"count":0},"required":["count"],"reducers":{"count":"reject_concurrent"},
926 /// "entry":"increment","nodes":[{"id":"increment","step":{"kind":"action",
927 /// "action":"increment","revision":"r1"}}],"routes":{}
928 /// }"#)?;
929 /// let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
930 /// let config = config.with_actions(vec![action]);
931 /// let mut app = App::open(config).await?;
932 /// let workspace = app.create_workspace(WorkspaceConfig { name: None, root: std::env::current_dir()? }).await?;
933 /// let workflow = workspace.workflow("counter".parse()?).await?;
934 /// let run = workflow.start(ExecutionOptions::default()).await?;
935 /// assert_eq!(run.wait().await?.state.values()["count"], 1);
936 /// app.shutdown(Shutdown::Drain { timeout: std::time::Duration::from_secs(5) }).await?;
937 /// #
938 /// # Ok::<(), Box<dyn std::error::Error>>(())
939 /// # })?;
940 /// # Ok::<(), Box<dyn std::error::Error>>(())
941 /// ```
942 #[must_use]
943 pub fn with_actions<N: ActionBackend>(
944 self,
945 actions: Vec<N>,
946 ) -> AppConfig<CS, A, N, C, T, H, E> {
947 AppConfig {
948 state: self.state,
949 agents: self.agents,
950 workflows: self.workflows,
951 actions,
952 conditions: self.conditions,
953 typing: self.typing,
954 hooks: self.hooks,
955 executor: self.executor,
956 }
957 }
958
959 /// Replaces the complete caller-owned condition inventory before opening App.
960 ///
961 /// App validates stable logical keys and revisions without evaluating the
962 /// predicates. Replacing an inventory drops its previous values.
963 ///
964 /// # Example
965 ///
966 /// ```
967 /// use agents_kit::Agent;
968 /// use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
969 /// use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
970 /// RunStatus, WorkflowDefinition, WorkflowState};
971 /// use storage_kit::StateStore;
972 /// use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
973 /// use app_kit::{AppError, workflow::{ConditionBackend, ConditionDeclaration}};
974 /// struct Ready { declaration: ConditionDeclaration }
975 /// impl ConditionBackend for Ready {
976 /// fn declaration(&self) -> &ConditionDeclaration { &self.declaration }
977 /// fn evaluate(&self, state: &WorkflowState) -> Result<bool, AppError> {
978 /// let count = state.values().get("count").and_then(serde_json::Value::as_i64)
979 /// .ok_or_else(|| AppError::InvalidInput("count must be an integer".into()))?;
980 /// Ok(count >= 3)
981 /// }
982 /// }
983 /// let condition = Ready { declaration: ConditionDeclaration {
984 /// key: "ready".into(), revision: "r1".into(), reads: vec!["count".into()],
985 /// } };
986 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
987 /// # runtime.block_on(async {
988 /// let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
989 /// let definition = WorkflowDefinition::parse(r#"{
990 /// "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
991 /// "state":{"count":0}, "required":["count"],
992 /// "reducers":{"count":"reject_concurrent"}, "entry":"increment",
993 /// "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
994 /// {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
995 /// ]}}], "routes":{"increment":{"mode":"first","branches":[
996 /// {"when":{"kind":"custom","condition":"ready","revision":"r1"},"target":{"kind":"end"}},
997 /// {"target":{"kind":"node","id":"increment"}}
998 /// ]}}
999 /// }"#)?;
1000 /// let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
1001 /// let config = config.with_conditions(vec![condition]);
1002 /// let mut app = App::open(config).await?;
1003 /// let workspace = app.create_workspace(WorkspaceConfig { name: None, root: std::env::current_dir()? }).await?;
1004 /// let workflow = workspace.workflow("counter".parse()?).await?;
1005 /// let terminal = workflow.start(ExecutionOptions::default()).await?.wait().await?;
1006 /// assert!(matches!(terminal.status, RunStatus::Succeeded));
1007 /// assert_eq!(terminal.state.values()["count"], 3);
1008 /// app.shutdown(Shutdown::Drain { timeout: std::time::Duration::from_secs(5) }).await?;
1009 /// #
1010 /// # Ok::<(), Box<dyn std::error::Error>>(())
1011 /// # })?;
1012 /// # Ok::<(), Box<dyn std::error::Error>>(())
1013 /// ```
1014 #[must_use]
1015 pub fn with_conditions<N: ConditionBackend>(
1016 self,
1017 conditions: Vec<N>,
1018 ) -> AppConfig<CS, A, X, N, T, H, E> {
1019 AppConfig {
1020 state: self.state,
1021 agents: self.agents,
1022 workflows: self.workflows,
1023 actions: self.actions,
1024 conditions,
1025 typing: self.typing,
1026 hooks: self.hooks,
1027 executor: self.executor,
1028 }
1029 }
1030
1031 /// Replaces the caller-owned workflow typing policy before opening App.
1032 ///
1033 /// The policy remains concrete and need not implement `Clone`. Its data
1034 /// values are local to validation or execution and need not be `Send`.
1035 ///
1036 /// # Example
1037 ///
1038 /// ```
1039 /// use agents_kit::Agent;
1040 /// use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1041 /// use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1042 /// RunStatus, WorkflowDefinition, WorkflowState};
1043 /// use storage_kit::StateStore;
1044 /// use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1045 /// use app_kit::{AppError, workflow::{StateTyping, StateChange}};
1046 /// use serde_json::{Map, Value};
1047 /// struct Counter;
1048 /// impl StateTyping for Counter {
1049 /// type Data = i64;
1050 /// fn construct_data(&self, state: &WorkflowState) -> Result<i64, AppError> {
1051 /// state.values().get("count").and_then(Value::as_i64)
1052 /// .ok_or_else(|| AppError::InvalidInput("count must be an integer".into()))
1053 /// }
1054 /// fn construct_state(&self, count: &i64) -> Result<WorkflowState, AppError> {
1055 /// WorkflowState::new(Map::from_iter([("count".into(), Value::from(*count))]))
1056 /// }
1057 /// fn validate_pre(&self, _state: &WorkflowState, change: &StateChange) -> Result<(), AppError> {
1058 /// if let StateChange::Increment { values } = change {
1059 /// if values.iter().any(|(_, delta)| delta.as_i64().is_some_and(|value| value < 0)) {
1060 /// return Err(AppError::InvalidInput("count cannot decrease".into()));
1061 /// }
1062 /// }
1063 /// Ok(())
1064 /// }
1065 /// fn validate_post(&self, before: &WorkflowState, after: &WorkflowState, _change: &StateChange) -> Result<(), AppError> {
1066 /// if self.construct_data(after)? < self.construct_data(before)? {
1067 /// return Err(AppError::InvalidInput("count cannot decrease".into()));
1068 /// }
1069 /// Ok(())
1070 /// }
1071 /// fn validate_state(&self, state: &WorkflowState) -> Result<(), AppError> {
1072 /// if self.construct_data(state)? < 0 {
1073 /// return Err(AppError::InvalidInput("count must be nonnegative".into()));
1074 /// }
1075 /// Ok(())
1076 /// }
1077 /// }
1078 /// #
1079 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1080 /// # runtime.block_on(async {
1081 /// let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1082 /// let definition = WorkflowDefinition::parse(r#"{
1083 /// "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1084 /// "state":{"count":0}, "required":["count"],
1085 /// "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1086 /// "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1087 /// {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1088 /// ]}}], "routes":{}
1089 /// }"#)?;
1090 /// let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
1091 /// let config = config.with_state_typing(Counter);
1092 /// let mut app = App::open(config).await?;
1093 /// let workspace = app.create_workspace(WorkspaceConfig { name: None, root: std::env::current_dir()? }).await?;
1094 /// let workflow = workspace.workflow("counter".parse()?).await?;
1095 /// let invalid = WorkflowState::new(serde_json::from_str(r#"{"count":-1}"#)?)?;
1096 /// assert!(workflow.prepare(invalid).await.is_err());
1097 /// app.shutdown(Shutdown::RejectActive).await?;
1098 /// #
1099 /// # Ok::<(), Box<dyn std::error::Error>>(())
1100 /// # })?;
1101 /// # Ok::<(), Box<dyn std::error::Error>>(())
1102 /// ```
1103 #[must_use]
1104 pub fn with_state_typing<N: StateTyping>(self, typing: N) -> AppConfig<CS, A, X, C, N, H, E> {
1105 AppConfig {
1106 state: self.state,
1107 agents: self.agents,
1108 workflows: self.workflows,
1109 actions: self.actions,
1110 conditions: self.conditions,
1111 typing,
1112 hooks: self.hooks,
1113 executor: self.executor,
1114 }
1115 }
1116
1117 /// Replaces the caller-owned lifecycle observer before opening App.
1118 ///
1119 /// Replacement drops the prior observer without invoking it. The new
1120 /// observer retains its concrete type and need not implement `Clone`.
1121 ///
1122 /// # Example
1123 ///
1124 /// ```
1125 /// use agents_kit::Agent;
1126 /// use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1127 /// use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1128 /// RunStatus, WorkflowDefinition, WorkflowState};
1129 /// use storage_kit::StateStore;
1130 /// use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1131 /// use app_kit::{AppError, workflow::{LifecycleEvent, LifecycleHooks}};
1132 /// use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};
1133 /// struct CountEvents(Arc<AtomicUsize>);
1134 /// impl LifecycleHooks for CountEvents {
1135 /// fn observe(&self, event: &LifecycleEvent<'_>) -> Result<(), AppError> {
1136 /// if matches!(event, LifecycleEvent::ExecuteStarted { .. }) {
1137 /// self.0.fetch_add(1, Ordering::SeqCst);
1138 /// }
1139 /// Ok(())
1140 /// }
1141 /// fn observe_async<'a>(&'a self, event: &'a LifecycleEvent<'a>)
1142 /// -> impl std::future::Future<Output = Result<(), AppError>> + Send + 'a {
1143 /// async move {
1144 /// if matches!(event, LifecycleEvent::ExecuteStarted { .. }) {
1145 /// self.0.fetch_add(10, Ordering::SeqCst);
1146 /// }
1147 /// Ok(())
1148 /// }
1149 /// }
1150 /// }
1151 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1152 /// # runtime.block_on(async {
1153 /// let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1154 /// let definition = WorkflowDefinition::parse(r#"{
1155 /// "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1156 /// "state":{"count":0}, "required":["count"],
1157 /// "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1158 /// "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1159 /// {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1160 /// ]}}], "routes":{}
1161 /// }"#)?;
1162 /// let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
1163 /// let count = Arc::new(AtomicUsize::new(0));
1164 /// let config = config.with_hooks(CountEvents(Arc::clone(&count)));
1165 /// let mut app = App::open(config).await?;
1166 /// let workspace = app.create_workspace(WorkspaceConfig { name: None, root: std::env::current_dir()? }).await?;
1167 /// let workflow = workspace.workflow("counter".parse()?).await?;
1168 /// let mut run = workflow.prepare(WorkflowState::default()).await?;
1169 /// run.execution(ExecutionOptions::default()).await?.finish().await?;
1170 /// assert_eq!(count.load(Ordering::SeqCst), 11);
1171 /// app.shutdown(Shutdown::Drain { timeout: std::time::Duration::from_secs(5) }).await?;
1172 /// #
1173 /// # Ok::<(), Box<dyn std::error::Error>>(())
1174 /// # })?;
1175 /// # Ok::<(), Box<dyn std::error::Error>>(())
1176 /// ```
1177 #[must_use]
1178 pub fn with_hooks<N: LifecycleHooks>(self, hooks: N) -> AppConfig<CS, A, X, C, T, N, E> {
1179 AppConfig {
1180 state: self.state,
1181 agents: self.agents,
1182 workflows: self.workflows,
1183 actions: self.actions,
1184 conditions: self.conditions,
1185 typing: self.typing,
1186 hooks,
1187 executor: self.executor,
1188 }
1189 }
1190
1191 /// Replaces the caller-owned admitted child executor before opening App.
1192 ///
1193 /// The executor remains concrete. Its associated execution futures are
1194 /// created only for admitted child work and need not implement `Send`.
1195 ///
1196 /// # Example
1197 ///
1198 /// ```
1199 /// use agents_kit::Agent;
1200 /// use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1201 /// use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1202 /// RunStatus, WorkflowDefinition, WorkflowState};
1203 /// use storage_kit::StateStore;
1204 /// use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1205 /// #
1206 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1207 /// # runtime.block_on(async {
1208 /// let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1209 /// let definition = WorkflowDefinition::parse(r#"{
1210 /// "schema":"app-kit.workflow/v2","id":"counter","revision":"r1",
1211 /// "state":{"sum":0},"required":["sum"],"reducers":{"sum":"sum_integer"},
1212 /// "entry":"child","nodes":[{"id":"child","step":{"kind":"child","workflow":"child",
1213 /// "input":{"count":{"kind":"literal","value":4}},
1214 /// "reductions":[{"source":"count","target":"sum","reducer":"sum_integer"}]}}],"routes":{}
1215 /// }"#)?;
1216 /// let child = WorkflowDefinition::parse(r#"{
1217 /// "schema":"app-kit.workflow/v2", "id":"child", "revision":"r1",
1218 /// "state":{"count":0}, "required":["count"],
1219 /// "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1220 /// "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1221 /// {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1222 /// ]}}], "routes":{}
1223 /// }"#)?;
1224 /// let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition, child])
1225 /// .with_child_executor(app_kit::workflow::InlineExecutor);
1226 /// let mut app = App::open(config).await?;
1227 /// let mut workspace = app.create_workspace(WorkspaceConfig {
1228 /// name: Some("local counter".into()), root: std::env::current_dir()?,
1229 /// }).await?;
1230 /// let workflow = workspace.workflow("counter".parse()?).await?;
1231 /// let mut run = workflow.prepare(WorkflowState::default()).await?;
1232 /// let terminal = run.execution(ExecutionOptions::default()).await?.finish().await?;
1233 /// assert_eq!(terminal.state.values()["sum"], 5);
1234 /// #
1235 /// app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
1236 /// # Ok::<(), Box<dyn std::error::Error>>(())
1237 /// # })?;
1238 /// # Ok::<(), Box<dyn std::error::Error>>(())
1239 /// ```
1240 #[must_use]
1241 pub fn with_child_executor<N: ChildExecutor>(
1242 self,
1243 executor: N,
1244 ) -> AppConfig<CS, A, X, C, T, H, N> {
1245 AppConfig {
1246 state: self.state,
1247 agents: self.agents,
1248 workflows: self.workflows,
1249 actions: self.actions,
1250 conditions: self.conditions,
1251 typing: self.typing,
1252 hooks: self.hooks,
1253 executor,
1254 }
1255 }
1256}
1257
1258impl<A: AgentBackend> AgentSpec<A> {
1259 /// Constructs one configured lower-agent entry.
1260 ///
1261 /// The key and revision are caller-owned logical binding metadata, validated by
1262 /// [`App::open`]. Keep the same revision only while the configuration is compatible.
1263 /// App never derives compatibility from the opaque lower Agent identity. A
1264 /// successful open takes responsibility for consuming Agent shutdown.
1265 ///
1266 /// # Example
1267 ///
1268 /// This example requires an already-running OpenCode service at the shown endpoint.
1269 ///
1270 /// ```no_run
1271 /// use std::{num::NonZeroUsize, time::Duration};
1272 /// use agents_kit::{Agent, AgentBuilder, AgentStorage};
1273 /// use agents_kit::config::{AgentConfig, OpenCodeConfig};
1274 /// use app_kit::{AgentSpec, App, AppConfig, Shutdown};
1275 /// use storage_kit::{StateStore, state::config::{LanceStateStoreConfig, StateStoreConfig}};
1276 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1277 /// # runtime.block_on(async {
1278 /// let agent_store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1279 /// let backend = OpenCodeConfig::external("http://127.0.0.1:4096", Duration::from_secs(10),
1280 /// Duration::from_secs(60), Duration::from_secs(10))?;
1281 /// let agent_config = AgentConfig::new(backend.into(), Duration::from_secs(10),
1282 /// NonZeroUsize::new(1).unwrap())?;
1283 /// let agent: Agent = AgentBuilder::create(agent_config, AgentStorage::new(agent_store)).build().await?;
1284 /// let spec = AgentSpec::new("reviewer".into(), "r1".into(), agent);
1285 /// let app_store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1286 /// let mut app = App::open(AppConfig::new(app_store, vec![spec], Vec::new())).await?;
1287 /// // App now owns consuming shutdown of the transferred Agent.
1288 /// app.shutdown(Shutdown::Drain { timeout: Duration::from_secs(30) }).await?;
1289 /// # Ok::<(), Box<dyn std::error::Error>>(())
1290 /// # })?;
1291 /// # Ok::<(), Box<dyn std::error::Error>>(())
1292 /// ```
1293 #[must_use]
1294 pub fn new(key: String, revision: String, agent: A) -> Self {
1295 Self {
1296 key,
1297 revision,
1298 agent: Arc::new(agent),
1299 }
1300 }
1301}
1302
1303impl<
1304 CS: StateStoreBackend,
1305 A: AgentBackend,
1306 X: ActionBackend,
1307 C: ConditionBackend,
1308 T: StateTyping,
1309 H: LifecycleHooks,
1310 E: ChildExecutor,
1311> Drop for App<CS, A, X, C, T, H, E>
1312{
1313 fn drop(&mut self) {
1314 self.runtime.request_drop();
1315 }
1316}
1317
1318impl<
1319 CS: StateStoreBackend,
1320 A: AgentBackend,
1321 X: ActionBackend,
1322 C: ConditionBackend,
1323 T: StateTyping,
1324 H: LifecycleHooks,
1325 E: ChildExecutor,
1326> Workspace<CS, A, X, C, T, H, E>
1327{
1328 /// Returns this workspace's stable identity.
1329 ///
1330 /// # Example
1331 ///
1332 /// ```
1333 /// # use agents_kit::Agent;
1334 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1335 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1336 /// # RunStatus, WorkflowDefinition, WorkflowState};
1337 /// # use storage_kit::StateStore;
1338 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1339 /// #
1340 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1341 /// # runtime.block_on(async {
1342 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1343 /// # let definition = WorkflowDefinition::parse(r#"{
1344 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1345 /// # "state":{"count":0}, "required":["count"],
1346 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1347 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1348 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1349 /// # ]}}], "routes":{}
1350 /// # }"#)?;
1351 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
1352 /// # let mut app = App::open(config).await?;
1353 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
1354 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
1355 /// # }).await?;
1356 /// let id = workspace.id().clone();
1357 /// assert_eq!(workspace.snapshot().await?.id, id);
1358 /// #
1359 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
1360 /// # Ok::<(), Box<dyn std::error::Error>>(())
1361 /// # })?;
1362 /// # Ok::<(), Box<dyn std::error::Error>>(())
1363 /// ```
1364 pub fn id(&self) -> &WorkspaceId {
1365 self.runtime.id()
1366 }
1367
1368 /// Loads the current workspace snapshot.
1369 ///
1370 /// # Errors
1371 ///
1372 /// Returns `Closed` after revocation or sealing, `NotFound` for absent coordination,
1373 /// `Conflict` during unresolved lifecycle progress, or `Integration` for unreadable
1374 /// committed evidence. A history-only workspace has no configuration snapshot.
1375 ///
1376 /// # Example
1377 ///
1378 /// ```
1379 /// # use agents_kit::Agent;
1380 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1381 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1382 /// # RunStatus, WorkflowDefinition, WorkflowState};
1383 /// # use storage_kit::StateStore;
1384 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1385 /// #
1386 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1387 /// # runtime.block_on(async {
1388 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1389 /// # let definition = WorkflowDefinition::parse(r#"{
1390 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1391 /// # "state":{"count":0}, "required":["count"],
1392 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1393 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1394 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1395 /// # ]}}], "routes":{}
1396 /// # }"#)?;
1397 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
1398 /// # let mut app = App::open(config).await?;
1399 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
1400 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
1401 /// # }).await?;
1402 /// let snapshot = workspace.snapshot().await?;
1403 /// assert_eq!(snapshot.revision, 1);
1404 /// assert_eq!(snapshot.config.name.as_deref(), Some("local counter"));
1405 /// #
1406 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
1407 /// # Ok::<(), Box<dyn std::error::Error>>(())
1408 /// # })?;
1409 /// # Ok::<(), Box<dyn std::error::Error>>(())
1410 /// ```
1411 pub async fn snapshot(&self) -> Result<WorkspaceSnapshot, AppError> {
1412 self.runtime.snapshot().await
1413 }
1414
1415 /// Atomically replaces the complete workspace configuration.
1416 ///
1417 /// This is full replacement, including the name and root. Preflight checks all
1418 /// lower workspaces before fan-out. A partial lower failure retains the old App
1419 /// revision and durable reverse compensation; preflight never reserves a revision.
1420 ///
1421 /// # Errors
1422 ///
1423 /// Returns `Conflict` for a stale revision, a nonterminal run pinned to the current
1424 /// revision, or partial lower fan-out (even if compensation succeeds). Invalid
1425 /// configuration, capacity, revocation, storage/lower failures and bounded waits
1426 /// report `InvalidInput`, `Busy`, `Closed`, `Integration`, or `Timeout`.
1427 ///
1428 /// # Example
1429 ///
1430 /// ```
1431 /// # use agents_kit::Agent;
1432 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1433 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1434 /// # RunStatus, WorkflowDefinition, WorkflowState};
1435 /// # use storage_kit::StateStore;
1436 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1437 /// #
1438 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1439 /// # runtime.block_on(async {
1440 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1441 /// # let definition = WorkflowDefinition::parse(r#"{
1442 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1443 /// # "state":{"count":0}, "required":["count"],
1444 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1445 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1446 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1447 /// # ]}}], "routes":{}
1448 /// # }"#)?;
1449 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
1450 /// # let mut app = App::open(config).await?;
1451 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
1452 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
1453 /// # }).await?;
1454 /// let before = workspace.snapshot().await?;
1455 /// let after = workspace.replace(before.revision, WorkspaceConfig {
1456 /// name: Some("reviewed".into()), root: before.config.root,
1457 /// }).await?;
1458 /// assert_eq!(after.revision, before.revision + 1);
1459 /// assert_eq!(after.config.name.as_deref(), Some("reviewed"));
1460 /// #
1461 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
1462 /// # Ok::<(), Box<dyn std::error::Error>>(())
1463 /// # })?;
1464 /// # Ok::<(), Box<dyn std::error::Error>>(())
1465 /// ```
1466 pub async fn replace(
1467 &self,
1468 expected_revision: u64,
1469 config: WorkspaceConfig,
1470 ) -> Result<WorkspaceSnapshot, AppError> {
1471 self.runtime.replace(expected_revision, config).await
1472 }
1473
1474 /// Opens a configured workflow in this workspace.
1475 ///
1476 /// Selects the current configured definition for new work. A deleted workspace
1477 /// may retain a history-only workflow when exact terminal evidence still exists.
1478 ///
1479 /// # Errors
1480 ///
1481 /// Returns `NotFound` for an unavailable definition or retained scope, `Closed`
1482 /// for revoked admission, or `Integration` for inconsistent durable evidence.
1483 ///
1484 /// # Example
1485 ///
1486 /// ```
1487 /// # use agents_kit::Agent;
1488 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1489 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1490 /// # RunStatus, WorkflowDefinition, WorkflowState};
1491 /// # use storage_kit::StateStore;
1492 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1493 /// #
1494 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1495 /// # runtime.block_on(async {
1496 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1497 /// # let definition = WorkflowDefinition::parse(r#"{
1498 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1499 /// # "state":{"count":0}, "required":["count"],
1500 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1501 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1502 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1503 /// # ]}}], "routes":{}
1504 /// # }"#)?;
1505 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
1506 /// # let mut app = App::open(config).await?;
1507 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
1508 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
1509 /// # }).await?;
1510 /// let workflow = workspace.workflow("counter".parse()?).await?;
1511 /// assert_eq!(workflow.id().to_string(), "counter");
1512 /// #
1513 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
1514 /// # Ok::<(), Box<dyn std::error::Error>>(())
1515 /// # })?;
1516 /// # Ok::<(), Box<dyn std::error::Error>>(())
1517 /// ```
1518 pub async fn workflow(
1519 &self,
1520 id: WorkflowId,
1521 ) -> Result<Workflow<CS, A, X, C, T, H, E>, AppError> {
1522 Ok(Workflow::from_runtime(self.runtime.workflow(id).await?))
1523 }
1524
1525 /// Deletes this workspace after proving its revision fence.
1526 ///
1527 /// Seals new admission, requests cancellation, and deletes lower workspaces only
1528 /// after every run is terminal with no unresolved effect. Progress is durable and
1529 /// resumable; terminal run history is retained until explicit run deletion.
1530 ///
1531 /// # Errors
1532 ///
1533 /// Returns `Conflict` for a stale revision or incompatible deletion intent, `Busy`
1534 /// while unresolved work prevents deletion, and `Closed` for revoked ownership.
1535 /// Invalid fences, storage/lower failures, or expired observation return
1536 /// `InvalidInput`, `Integration`, or `Timeout`.
1537 ///
1538 /// # Example
1539 ///
1540 /// ```
1541 /// # use agents_kit::Agent;
1542 /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
1543 /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
1544 /// # RunStatus, WorkflowDefinition, WorkflowState};
1545 /// # use storage_kit::StateStore;
1546 /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
1547 /// #
1548 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1549 /// # runtime.block_on(async {
1550 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1551 /// # let definition = WorkflowDefinition::parse(r#"{
1552 /// # "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
1553 /// # "state":{"count":0}, "required":["count"],
1554 /// # "reducers":{"count":"reject_concurrent"}, "entry":"increment",
1555 /// # "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
1556 /// # {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
1557 /// # ]}}], "routes":{}
1558 /// # }"#)?;
1559 /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition]);
1560 /// # let mut app = App::open(config).await?;
1561 /// # let mut workspace = app.create_workspace(WorkspaceConfig {
1562 /// # name: Some("local counter".into()), root: std::env::current_dir()?,
1563 /// # }).await?;
1564 /// let revision = workspace.snapshot().await?.revision;
1565 /// workspace.delete(revision).await?;
1566 /// assert!(app.list_workspaces(None, std::num::NonZeroUsize::new(10).unwrap()).await?.is_empty());
1567 /// #
1568 /// # app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await?;
1569 /// # Ok::<(), Box<dyn std::error::Error>>(())
1570 /// # })?;
1571 /// # Ok::<(), Box<dyn std::error::Error>>(())
1572 /// ```
1573 pub async fn delete(&mut self, expected_revision: u64) -> Result<(), AppError> {
1574 self.runtime.delete(expected_revision).await
1575 }
1576}
1577
1578impl WorkspaceId {
1579 pub(crate) fn generate() -> Self {
1580 Self(format!("appws_{}", Uuid::new_v4().hyphenated()))
1581 }
1582
1583 pub(crate) fn as_str(&self) -> &str {
1584 &self.0
1585 }
1586}
1587
1588impl IdentitySyntax {
1589 fn parse(&self, value: &str) -> Result<String, AppError> {
1590 match self {
1591 Self::Named(name) => {
1592 let valid = !value.is_empty()
1593 && value.len() <= 128
1594 && value.bytes().enumerate().all(|(index, byte)| {
1595 byte.is_ascii_lowercase()
1596 || byte.is_ascii_digit()
1597 || (index > 0 && matches!(byte, b'_' | b'-'))
1598 });
1599 if valid {
1600 Ok(value.to_owned())
1601 } else {
1602 Err(AppError::InvalidInput(format!(
1603 "{name} must match [a-z0-9][a-z0-9_-]{{0,127}}"
1604 )))
1605 }
1606 }
1607 Self::Uuid { name, prefix } => {
1608 let Some(encoded) = value.strip_prefix(prefix) else {
1609 return Err(AppError::InvalidInput(format!(
1610 "{name} has an invalid prefix"
1611 )));
1612 };
1613 let parsed = Uuid::parse_str(encoded)
1614 .map_err(|_| AppError::InvalidInput(format!("{name} is not a UUIDv4")))?;
1615 if parsed.get_version() != Some(Version::Random)
1616 || parsed.get_variant() != Variant::RFC4122
1617 || parsed.hyphenated().to_string() != encoded
1618 {
1619 return Err(AppError::InvalidInput(format!(
1620 "{name} must contain a canonical lowercase UUIDv4"
1621 )));
1622 }
1623 Ok(value.to_owned())
1624 }
1625 }
1626 }
1627}
1628
1629impl fmt::Display for WorkspaceId {
1630 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1631 formatter.write_str(&self.0)
1632 }
1633}
1634
1635impl FromStr for WorkspaceId {
1636 type Err = AppError;
1637
1638 fn from_str(value: &str) -> Result<Self, Self::Err> {
1639 IdentitySyntax::Uuid {
1640 name: "WorkspaceId",
1641 prefix: "appws_",
1642 }
1643 .parse(value)
1644 .map(Self)
1645 }
1646}
1647
1648impl Serialize for WorkspaceId {
1649 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1650 where
1651 S: Serializer,
1652 {
1653 serializer.serialize_str(&self.0)
1654 }
1655}
1656
1657impl<'de> Deserialize<'de> for WorkspaceId {
1658 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1659 where
1660 D: Deserializer<'de>,
1661 {
1662 String::deserialize(deserializer)?
1663 .parse()
1664 .map_err(serde::de::Error::custom)
1665 }
1666}
1667
1668impl fmt::Display for AppError {
1669 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1670 match self {
1671 Self::InvalidInput(message) => write!(formatter, "invalid input: {message}"),
1672 Self::NotFound(message) => write!(formatter, "not found: {message}"),
1673 Self::Conflict(message) => write!(formatter, "conflict: {message}"),
1674 Self::Busy(message) => write!(formatter, "busy: {message}"),
1675 Self::Closed(message) => write!(formatter, "closed: {message}"),
1676 Self::Timeout(message) => write!(formatter, "timeout: {message}"),
1677 Self::Integration(message) => write!(formatter, "integration: {message}"),
1678 }
1679 }
1680}
1681
1682impl std::error::Error for AppError {}