Skip to main content

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 [local example](#local-workflow) runs 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::{ExecutionOptions, RunStatus, WorkflowDefinition};
41//! use storage_kit::{StateStore, state::config::{LanceStateStoreConfig, StateStoreConfig}};
42//! # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
43//! # runtime.block_on(async {
44//! let store = StateStore::connect(StateStoreConfig::lance(LanceStateStoreConfig::memory())).await?;
45//! let definition = WorkflowDefinition::parse(r#"{
46//!     "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
47//!     "state":{"count":0}, "required":["count"],
48//!     "reducers":{"count":"reject_concurrent"}, "entry":"increment",
49//!     "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
50//!         {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
51//!     ]}}], "routes":{}
52//! }"#)?;
53//! let mut app = App::open(AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition])).await?;
54//! let outcome = async {
55//!     let workspace = app.create_workspace(WorkspaceConfig {
56//!         name: Some("local counter".into()), root: std::env::current_dir()?,
57//!     }).await?;
58//!     let workflow = workspace.workflow("counter".parse()?).await?;
59//!     let run = workflow.start(ExecutionOptions::default()).await?;
60//!     Ok::<_, Box<dyn std::error::Error>>(run.wait().await?)
61//! }.await;
62//! let cleanup = app.shutdown(Shutdown::CancelAndDrain {
63//!     timeout: std::time::Duration::from_secs(5),
64//! }).await;
65//! if let Err(error) = &cleanup { eprintln!("App shutdown: {error}"); }
66//! let snapshot = outcome?; // Preserve the operation error after attempting shutdown.
67//! cleanup?;
68//! assert!(matches!(snapshot.status, RunStatus::Succeeded));
69//! assert_eq!(snapshot.state.values()["count"], 1);
70//! # Ok::<(), Box<dyn std::error::Error>>(())
71//! # })?;
72//! # Ok::<(), Box<dyn std::error::Error>>(())
73//! ```
74
75#![forbid(unsafe_code)]
76#![warn(missing_docs)]
77
78use std::fmt;
79use std::num::NonZeroUsize;
80use std::path::PathBuf;
81use std::str::FromStr;
82use std::sync::Arc;
83use std::time::Duration;
84
85use agents_kit::AgentBackend;
86use serde::{Deserialize, Deserializer, Serialize, Serializer};
87use storage_kit::StateStoreBackend;
88use uuid::{Uuid, Variant, Version};
89
90use crate::workflow::{
91    ActionBackend, ChildExecutor, ConditionBackend, InlineExecutor, JsonState, LifecycleHooks,
92    NoActions, NoConditions, NoHooks, StateTyping, Workflow, WorkflowDefinition, WorkflowId,
93};
94
95mod app;
96mod storage;
97mod workspace;
98
99/// Opaque workflow definitions and runtime handles.
100pub mod workflow;
101
102/// An open application generation.
103///
104/// Owns one durable application generation, its transferred agents and accepted work.
105///
106/// Supply a concrete [`storage_kit::StateStoreBackend`] through [`AppConfig`].
107/// Each [`AgentSpec`] preserves its concrete [`agents_kit::AgentBackend`] and its
108/// associated Workspace/Conversation types. An empty agent inventory can execute
109/// state-only workflows; the `Agent` type in the [local example](crate#local-workflow)
110/// constructs no Agent value.
111///
112/// Keep the Tokio runtime and any caller-owned lower runtimes alive and driven
113/// through [`Self::shutdown`]. Root drop revokes admission and requests supervised
114/// best-effort cleanup; it cannot substitute for awaited shutdown. Workspace and
115/// run observers do not keep admission alive after the App is revoked.
116///
117/// See [the local workflow](crate#local-workflow).
118pub struct App<
119    CS: StateStoreBackend,
120    A: AgentBackend,
121    X: ActionBackend = NoActions,
122    C: ConditionBackend = NoConditions,
123    T: StateTyping = JsonState,
124    H: LifecycleHooks = NoHooks,
125    E: ChildExecutor = InlineExecutor,
126> {
127    runtime: Arc<app::AppRuntime<CS, A, X, C, T, H, E>>,
128}
129
130/// Complete application configuration.
131///
132/// App does not offer a separate built-in storage construction path:
133///
134/// ```compile_fail
135/// let _ = app_kit::AppState::<storage_kit::StateStore>::Ephemeral;
136/// ```
137///
138/// Owns caller-built storage, a homogeneous agent inventory, and workflow definitions.
139///
140/// The consuming `with_*` methods change their concrete App behavior parameter.
141/// They do not erase backends or alter already admitted runs. Heterogeneous agent
142/// inventories use a caller-owned exhaustive hierarchy. Dropping configuration
143/// before open does not perform asynchronous Agent shutdown.
144///
145/// # Configure behavior
146///
147/// Start with ready storage, ready Agents, and parsed definitions. Apply the
148/// consuming configuration methods before [`App::open`]; they retain exact
149/// implementation types, including associated lower children and transaction
150/// ownership. None registers a runtime callback or erases a backend.
151///
152/// | Behavior | Complete implementation example | Attach before open |
153/// | --- | --- | --- |
154/// | Produce lazy action events and writes | [`workflow::ActionBackend`] | [`Self::with_actions`] |
155/// | Evaluate declared routing predicates | [`workflow::ConditionBackend`] | [`Self::with_conditions`] |
156/// | Construct and validate application state | [`workflow::StateTyping`] | [`Self::with_state_typing`] |
157/// | Observe authoritative lifecycle events | [`workflow::LifecycleHooks`] | [`Self::with_hooks`] |
158/// | Drive concrete local child futures | [`workflow::ChildExecutor`] | [`Self::with_child_executor`] |
159///
160/// The linked examples exercise each concrete behavior directly. Attach it before
161/// opening App. Action and condition declarations must match the workflow's
162/// exact logical keys and revisions. A custom child executor must return every
163/// admitted completion exactly once; it never creates child identities. App's
164/// built-in `NoActions`, `NoConditions`, `JsonState`, `NoHooks` and `InlineExecutor`
165/// are suitable defaults only where their actual behavior satisfies the workflow.
166///
167/// Lower composition is independent: `A::Workspace` fixes its Conversation,
168/// while `CS::Transaction` fixes consuming commit/rollback. For heterogeneous
169/// Agent inventories implement matching caller-owned exhaustive hierarchies;
170/// do not substitute public trait objects or independent child parameters.
171///
172/// See [the local workflow](crate#local-workflow).
173pub struct AppConfig<
174    CS: StateStoreBackend,
175    A: AgentBackend,
176    X: ActionBackend = NoActions,
177    C: ConditionBackend = NoConditions,
178    T: StateTyping = JsonState,
179    H: LifecycleHooks = NoHooks,
180    E: ChildExecutor = InlineExecutor,
181> {
182    state: CS,
183    agents: Vec<AgentSpec<A>>,
184    workflows: Vec<WorkflowDefinition>,
185    actions: Vec<X>,
186    conditions: Vec<C>,
187    typing: T,
188    hooks: H,
189    executor: E,
190}
191
192/// Declares one configured lower agent.
193///
194/// The key and revision are caller-owned logical binding metadata, validated by
195/// [`App::open`]. Keep the same revision only while the configuration is compatible.
196/// App never derives compatibility from the opaque lower Agent identity. A
197/// successful open takes responsibility for consuming Agent shutdown.
198///
199/// The [caller-owned Agent recipe](https://github.com/jecsand838/ai-kit/blob/main/app-kit/tests/workflow_mock_composition.rs)
200/// executes a real App with private, direct-trait Agent and StateStore mocks.
201/// Its `caller_agent_struct_materializes_and_executes_without_supplied_backends`
202/// test asserts the prompt, workflow result and consuming Agent shutdown. Run it
203/// with `cargo test --locked -p app-kit --test workflow_mock_composition`.
204/// This is an integration-tested recipe, not an imported doctest fixture; it
205/// demonstrates process-local composition rather than native durability.
206pub struct AgentSpec<A: AgentBackend> {
207    key: String,
208    revision: String,
209    agent: Arc<A>,
210}
211
212/// Selects application shutdown behavior.
213///
214/// Drain timeouts limit observation only. Once admission is sealed, a timed-out
215/// or dropped shutdown observer does not cancel the finalizer or reopen App.
216/// Keep all owning runtimes driven until shutdown completes.
217pub enum Shutdown {
218    /// Rejects shutdown while accepted work remains.
219    RejectActive,
220    /// Seals admission and drains accepted work.
221    Drain {
222        /// Bounds this caller's observation of finalization.
223        timeout: Duration,
224    },
225    /// Seals admission, cancels accepted work, and drains it.
226    CancelAndDrain {
227        /// Bounds this caller's observation of finalization.
228        timeout: Duration,
229    },
230}
231
232/// An application workspace handle.
233///
234/// A typed projection into the owning App generation. Dropping it does not delete
235/// or close lower workspaces. Replacement uses a complete configuration and a
236/// revision fence; successful deletion revokes this projection. Retained terminal
237/// history can still be opened through its workflow after workspace deletion.
238///
239/// # Workspace lifecycle
240///
241/// Replacement and deletion use the latest observed revision as their fence.
242/// This example uses real process-local storage without configuring Agents or workflows.
243///
244/// ```
245/// use agents_kit::Agent;
246/// use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
247/// use storage_kit::{StateStore, state::config::{LanceStateStoreConfig, StateStoreConfig}};
248/// use std::{num::NonZeroUsize, time::Duration};
249/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
250/// # runtime.block_on(async {
251/// let store = StateStore::connect(
252///     StateStoreConfig::lance(LanceStateStoreConfig::memory()),
253/// ).await?;
254/// let mut app = App::open(AppConfig::<_, Agent>::new(store, Vec::new(), Vec::new())).await?;
255/// let outcome = async {
256///     let root = std::env::current_dir()?;
257///     let mut workspace = app.create_workspace(WorkspaceConfig {
258///         name: Some("draft".into()), root: root.clone(),
259///     }).await?;
260///     let before = workspace.snapshot().await?;
261///     let reopened = app.workspace(workspace.id().clone()).await?;
262///     assert_eq!(reopened.snapshot().await?.revision, before.revision);
263///     let updated = workspace.replace(before.revision, WorkspaceConfig {
264///         name: Some("reviewed".into()), root,
265///     }).await?;
266///     assert!(updated.revision > before.revision);
267///     assert_eq!(updated.config.name.as_deref(), Some("reviewed"));
268///     workspace.delete(updated.revision).await?;
269///     assert!(app.list_workspaces(None, NonZeroUsize::new(10).unwrap()).await?.is_empty());
270///     Ok::<(), Box<dyn std::error::Error>>(())
271/// }.await;
272/// let cleanup = app.shutdown(Shutdown::CancelAndDrain {
273///     timeout: Duration::from_secs(5),
274/// }).await;
275/// if let Err(error) = &cleanup { eprintln!("App shutdown: {error}"); }
276/// outcome?;
277/// cleanup?;
278/// # Ok::<(), Box<dyn std::error::Error>>(())
279/// # })?;
280/// # Ok::<(), Box<dyn std::error::Error>>(())
281/// ```
282pub struct Workspace<
283    CS: StateStoreBackend,
284    A: AgentBackend,
285    X: ActionBackend = NoActions,
286    C: ConditionBackend = NoConditions,
287    T: StateTyping = JsonState,
288    H: LifecycleHooks = NoHooks,
289    E: ChildExecutor = InlineExecutor,
290> {
291    runtime: workspace::WorkspaceRuntime<CS, A, X, C, T, H, E>,
292}
293
294/// A validated application workspace identity.
295///
296/// Parsing and deserialization require `appws_` followed by a canonical lowercase,
297/// hyphenated UUIDv4. Identity equality does not establish lower-agent compatibility.
298///
299/// # Example
300///
301/// ```
302/// use app_kit::WorkspaceId;
303/// let id: WorkspaceId = "appws_8a7c251e-b5b8-4d9e-8f0b-123456789abc".parse()?;
304/// assert_eq!(id.to_string(), "appws_8a7c251e-b5b8-4d9e-8f0b-123456789abc");
305/// assert!("appws_not-a-uuid".parse::<WorkspaceId>().is_err());
306/// # Ok::<(), app_kit::AppError>(())
307/// ```
308#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
309pub struct WorkspaceId(String);
310
311/// Complete caller-authored workspace configuration.
312///
313/// The root is an absolute, lexical UTF-8 path, 1–4,096 bytes without control
314/// characters. Validation does not create directories or resolve symlinks. A present
315/// name is trimmed and must be nonempty, control-free, and at most 256 UTF-8 bytes.
316///
317/// # Example
318///
319/// ```
320/// use app_kit::WorkspaceConfig;
321/// let config = WorkspaceConfig { name: Some("review".into()), root: std::env::current_dir()? };
322/// assert!(config.root.is_absolute());
323/// # Ok::<(), std::io::Error>(())
324/// ```
325pub struct WorkspaceConfig {
326    /// Optionally names the lower workspace.
327    pub name: Option<String>,
328    /// Selects the absolute workspace root.
329    pub root: PathBuf,
330}
331
332/// The current caller-visible workspace truth.
333///
334/// See [the workspace lifecycle](Workspace).
335#[non_exhaustive]
336pub struct WorkspaceSnapshot {
337    /// Identifies the workspace.
338    pub id: WorkspaceId,
339    /// Fences complete replacement.
340    pub revision: u64,
341    /// Contains the committed configuration.
342    pub config: WorkspaceConfig,
343}
344
345/// A safe caller-visible application error.
346///
347/// Operational messages are bounded and redact lower payloads and identities.
348/// Errors supplied by explicitly registered codecs or lifecycle hooks retain
349/// caller context at that opt-in boundary. Match variants (with a fallback for
350/// this non-exhaustive enum), rather than parsing Display text. An error from an
351/// observer does not by itself prove an accepted native operation rolled back.
352#[non_exhaustive]
353#[derive(Debug)]
354pub enum AppError {
355    /// Caller-authored input is invalid.
356    InvalidInput(String),
357    /// Exact scoped evidence proves a resource absent.
358    NotFound(String),
359    /// A durable fact, fence, or transition conflicts.
360    Conflict(String),
361    /// Finite capacity or an exclusive owner prevents progress.
362    Busy(String),
363    /// Revocation or terminal invalidation rejects new work.
364    Closed(String),
365    /// An application-owned deadline elapsed.
366    Timeout(String),
367    /// Caller behavior, a lower public operation, or a private durable record failed.
368    Integration(String),
369}
370
371enum IdentitySyntax {
372    Named(&'static str),
373    Uuid {
374        name: &'static str,
375        prefix: &'static str,
376    },
377}
378
379impl<
380    CS: StateStoreBackend,
381    A: AgentBackend,
382    X: ActionBackend,
383    C: ConditionBackend,
384    T: StateTyping,
385    H: LifecycleHooks,
386    E: ChildExecutor,
387> App<CS, A, X, C, T, H, E>
388{
389    /// Opens or reconciles one application generation.
390    ///
391    /// Validates bindings and retained records, claims one durable generation, and
392    /// reconstructs recovery ownership before returning. A successful newer opener
393    /// fences handles from the previous generation. Configuration is consumed.
394    ///
395    /// # Durable reopen and deployment
396    ///
397    /// Reopen with a fresh handle to the same caller-owned StateStore, the same
398    /// compatible workflow definitions, and freshly constructed or reopened lower
399    /// integrations. Never reuse a consumed Agent or infer a new logical identity
400    /// from its display representation. The state-only example below needs no Agent
401    /// value and demonstrates that shutdown retains history until explicit deletion.
402    ///
403    /// Existing App v6 records require a **cold, one-way v6-to-v7 migration**.
404    /// Stop older clients, settle all their owners and take a whole-store physical
405    /// backup before opening with this version. Opening performs the supported
406    /// migration before claiming the new generation. Mixed-version operation,
407    /// in-place downgrade, reset and corruption repair are not supported. Restore
408    /// the physical backup for a rollback deployment; do not open migrated data
409    /// with an older client. Unsupported epochs and corrupt current records fail
410    /// rather than falling back to another decoder.
411    ///
412    /// After Agent ownership transfer, ordinary failure awaits consuming Agent cleanup
413    /// and preserves the primary failure. Cancelling this open future during transfer
414    /// does not promise graceful cleanup: reopen the durable lower products and App to
415    /// reconcile their retained facts.
416    ///
417    /// # Errors
418    ///
419    /// Returns [`AppError::InvalidInput`] for invalid or conflicting construction
420    /// metadata, [`AppError::Conflict`] for incompatible retained revisions or lost
421    /// ownership, and [`AppError::Integration`] for unsupported durable versions,
422    /// invalid retained records, or lower failures. Recovery can also report capacity,
423    /// closure, or deadline errors; no live App is returned on failure.
424    ///
425    /// # Example
426    ///
427    /// ```
428    /// use agents_kit::Agent;
429    /// use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
430    /// use app_kit::workflow::{ExecutionOptions, RunSelector, RunStatus, WorkflowDefinition};
431    /// use storage_kit::StateStore;
432    /// use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
433    /// use std::time::Duration;
434    /// let directory = std::env::temp_dir().join(format!("ai-kit-app-doc-{}", uuid::Uuid::new_v4()));
435    /// std::fs::create_dir(&directory)?;
436    /// let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
437    /// let result = runtime.block_on(async {
438    ///     let definition_source = r#"{
439    ///         "schema":"app-kit.workflow/v2","id":"counter","revision":"r1",
440    ///         "state":{"count":0},"required":["count"],"reducers":{"count":"reject_concurrent"},
441    ///         "entry":"increment","nodes":[{"id":"increment","step":{"kind":"state",
442    ///         "mutations":[{"kind":"increment","target":"count","value":{"kind":"literal","value":1}}]}}],
443    ///         "routes":{}
444    ///     }"#;
445    ///     let config = StateStoreConfig::lance(LanceStateStoreConfig::directory(&directory));
446    ///     let store = StateStore::connect(config.clone()).await?;
447    ///     let mut app = App::open(AppConfig::<_, Agent>::new(store, Vec::new(), vec![WorkflowDefinition::parse(definition_source)?])).await?;
448    ///     let first = async {
449    ///         let workspace = app.create_workspace(WorkspaceConfig { name: None, root: directory.clone() }).await?;
450    ///         let workflow = workspace.workflow("counter".parse()?).await?;
451    ///         let run = workflow.start(ExecutionOptions::default()).await?;
452    ///         let terminal = run.wait().await?;
453    ///         assert!(matches!(terminal.status, RunStatus::Succeeded));
454    ///         Ok::<_, app_kit::AppError>((workspace.id().clone(), run.id().clone()))
455    ///     }.await;
456    ///     let shutdown = app.shutdown(Shutdown::CancelAndDrain { timeout: Duration::from_secs(5) }).await;
457    ///     if let Err(error) = &shutdown { eprintln!("App shutdown: {error}"); }
458    ///     drop(app);
459    ///     let (workspace_id, run_id) = first?;
460    ///     shutdown?;
461    ///
462    ///     let fresh_store = StateStore::connect(config).await?;
463    ///     let mut reopened = App::open(AppConfig::<_, Agent>::new(fresh_store, Vec::new(), vec![WorkflowDefinition::parse(definition_source)?])).await?;
464    ///     let readback = async {
465    ///         let workspace = reopened.workspace(workspace_id).await?;
466    ///         let workflow = workspace.workflow("counter".parse()?).await?;
467    ///         let mut run = workflow.open_run(RunSelector::Latest(run_id.clone())).await?;
468    ///         let terminal = run.snapshot().await?;
469    ///         assert!(matches!(terminal.status, RunStatus::Succeeded));
470    ///         assert_eq!(terminal.state.values()["count"], 1);
471    ///         run.delete(terminal.checkpoint).await?;
472    ///         assert!(matches!(workflow.open_run(RunSelector::Latest(run_id)).await,
473    ///             Err(app_kit::AppError::NotFound(_))));
474    ///         Ok::<(), app_kit::AppError>(())
475    ///     }.await;
476    ///     let shutdown = reopened.shutdown(Shutdown::CancelAndDrain { timeout: Duration::from_secs(5) }).await;
477    ///     if let Err(error) = &shutdown { eprintln!("App shutdown: {error}"); }
478    ///     drop(reopened);
479    ///     readback?;
480    ///     shutdown?;
481    ///     Ok::<(), Box<dyn std::error::Error>>(())
482    /// });
483    /// // A shutdown timeout bounds the waiter, not necessarily the owned finalizer.
484    /// // Remove the directory only after both generations demonstrably settled.
485    /// if result.is_ok() {
486    ///     std::fs::remove_dir_all(&directory)?;
487    /// } else {
488    ///     eprintln!("Retain {} for cleanup reconciliation", directory.display());
489    /// }
490    /// result?;
491    /// # Ok::<(), Box<dyn std::error::Error>>(())
492    /// ```
493    pub async fn open(config: AppConfig<CS, A, X, C, T, H, E>) -> Result<Self, AppError> {
494        Ok(Self {
495            runtime: app::AppTrace::span(|| tracing::info_span!(target: "app_kit", "app.open"))
496                .observe(std::pin::pin!(app::AppRuntime::open(config)))
497                .await?,
498        })
499    }
500
501    /// Creates and materializes one workspace.
502    ///
503    /// Materializes the complete configured Agent key/revision inventory before
504    /// publishing the App workspace. Durable intent precedes each lower operation;
505    /// unresolved partial progress remains recoverable rather than a visible success.
506    ///
507    /// # Errors
508    ///
509    /// Returns [`AppError::InvalidInput`] for invalid configuration, [`AppError::Busy`]
510    /// when admission is full, or [`AppError::Closed`] after revocation. Conflicting
511    /// materialization evidence, lower/storage failures, and bounded waits report
512    /// `Conflict`, `Integration`, or `Timeout` respectively.
513    ///
514    /// See [the workspace lifecycle](Workspace).
515    pub async fn create_workspace(
516        &self,
517        config: WorkspaceConfig,
518    ) -> Result<Workspace<CS, A, X, C, T, H, E>, AppError> {
519        Ok(Workspace {
520            runtime: self.runtime.create_workspace(config).await?,
521        })
522    }
523
524    /// Reopens one workspace by typed identity.
525    ///
526    /// A retained terminal head can establish a history-only scope after coordination
527    /// is deleted. That handle permits retained-run lookup, not fresh admission.
528    ///
529    /// # Errors
530    ///
531    /// Returns [`AppError::NotFound`] when neither active coordination nor readable
532    /// retained history exists in this scope. Revocation, incompatible lifecycle
533    /// progress, and unreadable durable evidence report `Closed`, `Conflict`, or
534    /// `Integration`.
535    ///
536    /// See [durable workspace reopen](App::open).
537    pub async fn workspace(
538        &self,
539        id: WorkspaceId,
540    ) -> Result<Workspace<CS, A, X, C, T, H, E>, AppError> {
541        Ok(Workspace {
542            runtime: self.runtime.workspace(id).await?,
543        })
544    }
545
546    /// Lists workspaces in canonical identity order.
547    ///
548    /// The cursor is exclusive and pages contain active workspaces only. Limits are
549    /// 1–1,000; larger requests are rejected rather than silently clamped.
550    ///
551    /// # Errors
552    ///
553    /// Returns [`AppError::InvalidInput`] for an excessive limit, [`AppError::NotFound`]
554    /// for an unknown cursor, and `Closed` or `Integration` for revoked or unreadable
555    /// application state.
556    ///
557    /// See [the workspace lifecycle](Workspace).
558    pub async fn list_workspaces(
559        &self,
560        after: Option<WorkspaceId>,
561        limit: NonZeroUsize,
562    ) -> Result<Vec<WorkspaceSnapshot>, AppError> {
563        self.runtime.list_workspaces(after, limit).await
564    }
565
566    /// Finalizes this application according to `policy`.
567    ///
568    /// `RejectActive` leaves admission open if work remains. Drain policies seal
569    /// admission; `CancelAndDrain` also requests explicit cancellation. Successful
570    /// completion joins App-owned work and consumes each transferred Agent once.
571    /// The timeout bounds this observer, not the continuing finalizer. Cancelling the
572    /// observer or timing out never reopens admission or detaches accepted cleanup.
573    ///
574    /// # Errors
575    ///
576    /// Returns [`AppError::Busy`] when `RejectActive` encounters accepted work,
577    /// [`AppError::InvalidInput`] for an invalid timeout, or [`AppError::Timeout`] when
578    /// observation expires. Durable conflicts and lower cleanup failures remain
579    /// `Conflict` or `Integration`; cleanup does not erase an earlier failure.
580    ///
581    /// See [the local workflow and error cleanup](crate#local-workflow).
582    pub async fn shutdown(&mut self, policy: Shutdown) -> Result<(), AppError> {
583        self.runtime.shutdown(policy).await
584    }
585}
586
587impl<CS: StateStoreBackend, A: AgentBackend> AppConfig<CS, A> {
588    /// Constructs configuration from a caller-owned control-plane store and agents.
589    ///
590    /// The store is required even for process-local operation. App preserves its
591    /// concrete transaction type and never constructs lower products. Ownership
592    /// transfers to App when [`App::open`] is called.
593    ///
594    /// See [the local workflow](crate#local-workflow).
595    #[must_use]
596    pub fn new(state: CS, agents: Vec<AgentSpec<A>>, workflows: Vec<WorkflowDefinition>) -> Self {
597        Self {
598            state,
599            agents,
600            workflows,
601            actions: Vec::new(),
602            conditions: Vec::new(),
603            typing: JsonState,
604            hooks: NoHooks,
605            executor: InlineExecutor,
606        }
607    }
608}
609
610impl<
611    CS: StateStoreBackend,
612    A: AgentBackend,
613    X: ActionBackend,
614    C: ConditionBackend,
615    T: StateTyping,
616    H: LifecycleHooks,
617    E: ChildExecutor,
618> AppConfig<CS, A, X, C, T, H, E>
619{
620    /// Replaces the complete caller-owned action inventory before opening App.
621    ///
622    /// Values retain their concrete type and need not implement `Clone`. App
623    /// validates their stable declarations before accepting any workflow work.
624    /// Replacing an inventory drops its previous values without invoking them.
625    ///
626    /// See [the complete action implementation](workflow::ActionBackend).
627    #[must_use]
628    pub fn with_actions<N: ActionBackend>(
629        self,
630        actions: Vec<N>,
631    ) -> AppConfig<CS, A, N, C, T, H, E> {
632        AppConfig {
633            state: self.state,
634            agents: self.agents,
635            workflows: self.workflows,
636            actions,
637            conditions: self.conditions,
638            typing: self.typing,
639            hooks: self.hooks,
640            executor: self.executor,
641        }
642    }
643
644    /// Replaces the complete caller-owned condition inventory before opening App.
645    ///
646    /// App validates stable logical keys and revisions without evaluating the
647    /// predicates. Replacing an inventory drops its previous values.
648    ///
649    /// See [the complete condition implementation](workflow::ConditionBackend).
650    #[must_use]
651    pub fn with_conditions<N: ConditionBackend>(
652        self,
653        conditions: Vec<N>,
654    ) -> AppConfig<CS, A, X, N, T, H, E> {
655        AppConfig {
656            state: self.state,
657            agents: self.agents,
658            workflows: self.workflows,
659            actions: self.actions,
660            conditions,
661            typing: self.typing,
662            hooks: self.hooks,
663            executor: self.executor,
664        }
665    }
666
667    /// Replaces the caller-owned workflow typing policy before opening App.
668    ///
669    /// The policy remains concrete and need not implement `Clone`. Its data
670    /// values are local to validation or execution and need not be `Send`.
671    ///
672    /// See [the complete state typing implementation](workflow::StateTyping).
673    #[must_use]
674    pub fn with_state_typing<N: StateTyping>(self, typing: N) -> AppConfig<CS, A, X, C, N, H, E> {
675        AppConfig {
676            state: self.state,
677            agents: self.agents,
678            workflows: self.workflows,
679            actions: self.actions,
680            conditions: self.conditions,
681            typing,
682            hooks: self.hooks,
683            executor: self.executor,
684        }
685    }
686
687    /// Replaces the caller-owned lifecycle observer before opening App.
688    ///
689    /// Replacement drops the prior observer without invoking it. The new
690    /// observer retains its concrete type and need not implement `Clone`.
691    ///
692    /// See [the complete lifecycle hook implementation](workflow::LifecycleHooks).
693    #[must_use]
694    pub fn with_hooks<N: LifecycleHooks>(self, hooks: N) -> AppConfig<CS, A, X, C, T, N, E> {
695        AppConfig {
696            state: self.state,
697            agents: self.agents,
698            workflows: self.workflows,
699            actions: self.actions,
700            conditions: self.conditions,
701            typing: self.typing,
702            hooks,
703            executor: self.executor,
704        }
705    }
706
707    /// Replaces the caller-owned admitted child executor before opening App.
708    ///
709    /// The executor remains concrete. Its associated execution futures are
710    /// created only for admitted child work and need not implement `Send`.
711    ///
712    /// See [the complete child executor workflow](workflow::ChildExecutor).
713    #[must_use]
714    pub fn with_child_executor<N: ChildExecutor>(
715        self,
716        executor: N,
717    ) -> AppConfig<CS, A, X, C, T, H, N> {
718        AppConfig {
719            state: self.state,
720            agents: self.agents,
721            workflows: self.workflows,
722            actions: self.actions,
723            conditions: self.conditions,
724            typing: self.typing,
725            hooks: self.hooks,
726            executor,
727        }
728    }
729}
730
731impl<A: AgentBackend> AgentSpec<A> {
732    /// Constructs one configured lower-agent entry.
733    ///
734    /// The key and revision are caller-owned logical binding metadata, validated by
735    /// [`App::open`]. Keep the same revision only while the configuration is compatible.
736    /// App never derives compatibility from the opaque lower Agent identity. A
737    /// successful open takes responsibility for consuming Agent shutdown.
738    ///
739    /// Construction is local and does not start a service. See [caller-owned Agent
740    /// composition](AgentSpec) for an executable recipe using direct trait mocks.
741    #[must_use]
742    pub fn new(key: String, revision: String, agent: A) -> Self {
743        Self {
744            key,
745            revision,
746            agent: Arc::new(agent),
747        }
748    }
749}
750
751impl<
752    CS: StateStoreBackend,
753    A: AgentBackend,
754    X: ActionBackend,
755    C: ConditionBackend,
756    T: StateTyping,
757    H: LifecycleHooks,
758    E: ChildExecutor,
759> Drop for App<CS, A, X, C, T, H, E>
760{
761    fn drop(&mut self) {
762        self.runtime.request_drop();
763    }
764}
765
766impl<
767    CS: StateStoreBackend,
768    A: AgentBackend,
769    X: ActionBackend,
770    C: ConditionBackend,
771    T: StateTyping,
772    H: LifecycleHooks,
773    E: ChildExecutor,
774> Workspace<CS, A, X, C, T, H, E>
775{
776    /// Returns this workspace's stable identity.
777    ///
778    /// See [the workspace lifecycle](Workspace).
779    pub fn id(&self) -> &WorkspaceId {
780        self.runtime.id()
781    }
782
783    /// Loads the current workspace snapshot.
784    ///
785    /// # Errors
786    ///
787    /// Returns `Closed` after revocation or sealing, `NotFound` for absent coordination,
788    /// `Conflict` during unresolved lifecycle progress, or `Integration` for unreadable
789    /// committed evidence. A history-only workspace has no configuration snapshot.
790    ///
791    /// See [the workspace lifecycle](Workspace).
792    pub async fn snapshot(&self) -> Result<WorkspaceSnapshot, AppError> {
793        self.runtime.snapshot().await
794    }
795
796    /// Atomically replaces the complete workspace configuration.
797    ///
798    /// This is full replacement, including the name and root. Preflight checks all
799    /// lower workspaces before fan-out. A partial lower failure retains the old App
800    /// revision and durable reverse compensation; preflight never reserves a revision.
801    ///
802    /// # Errors
803    ///
804    /// Returns `Conflict` for a stale revision, a nonterminal run pinned to the current
805    /// revision, or partial lower fan-out (even if compensation succeeds). Invalid
806    /// configuration, capacity, revocation, storage/lower failures and bounded waits
807    /// report `InvalidInput`, `Busy`, `Closed`, `Integration`, or `Timeout`.
808    ///
809    /// See [the workspace lifecycle](Workspace).
810    pub async fn replace(
811        &self,
812        expected_revision: u64,
813        config: WorkspaceConfig,
814    ) -> Result<WorkspaceSnapshot, AppError> {
815        self.runtime.replace(expected_revision, config).await
816    }
817
818    /// Opens a configured workflow in this workspace.
819    ///
820    /// Selects the current configured definition for new work. A deleted workspace
821    /// may retain a history-only workflow when exact terminal evidence still exists.
822    ///
823    /// # Errors
824    ///
825    /// Returns `NotFound` for an unavailable definition or retained scope, `Closed`
826    /// for revoked admission, or `Integration` for inconsistent durable evidence.
827    ///
828    /// See [the local workflow](crate#local-workflow).
829    pub async fn workflow(
830        &self,
831        id: WorkflowId,
832    ) -> Result<Workflow<CS, A, X, C, T, H, E>, AppError> {
833        Ok(Workflow::from_runtime(self.runtime.workflow(id).await?))
834    }
835
836    /// Deletes this workspace after proving its revision fence.
837    ///
838    /// Seals new admission, requests cancellation, and deletes lower workspaces only
839    /// after every run is terminal with no unresolved effect. Progress is durable and
840    /// resumable; terminal run history is retained until explicit run deletion.
841    ///
842    /// # Errors
843    ///
844    /// Returns `Conflict` for a stale revision or incompatible deletion intent, `Busy`
845    /// while unresolved work prevents deletion, and `Closed` for revoked ownership.
846    /// Invalid fences, storage/lower failures, or expired observation return
847    /// `InvalidInput`, `Integration`, or `Timeout`.
848    ///
849    /// See [the workspace lifecycle](Workspace).
850    pub async fn delete(&mut self, expected_revision: u64) -> Result<(), AppError> {
851        self.runtime.delete(expected_revision).await
852    }
853}
854
855impl WorkspaceId {
856    pub(crate) fn generate() -> Self {
857        Self(format!("appws_{}", Uuid::new_v4().hyphenated()))
858    }
859
860    pub(crate) fn as_str(&self) -> &str {
861        &self.0
862    }
863}
864
865impl IdentitySyntax {
866    fn parse(&self, value: &str) -> Result<String, AppError> {
867        match self {
868            Self::Named(name) => {
869                let valid = !value.is_empty()
870                    && value.len() <= 128
871                    && value.bytes().enumerate().all(|(index, byte)| {
872                        byte.is_ascii_lowercase()
873                            || byte.is_ascii_digit()
874                            || (index > 0 && matches!(byte, b'_' | b'-'))
875                    });
876                if valid {
877                    Ok(value.to_owned())
878                } else {
879                    Err(AppError::InvalidInput(format!(
880                        "{name} must match [a-z0-9][a-z0-9_-]{{0,127}}"
881                    )))
882                }
883            }
884            Self::Uuid { name, prefix } => {
885                let Some(encoded) = value.strip_prefix(prefix) else {
886                    return Err(AppError::InvalidInput(format!(
887                        "{name} has an invalid prefix"
888                    )));
889                };
890                let parsed = Uuid::parse_str(encoded)
891                    .map_err(|_| AppError::InvalidInput(format!("{name} is not a UUIDv4")))?;
892                if parsed.get_version() != Some(Version::Random)
893                    || parsed.get_variant() != Variant::RFC4122
894                    || parsed.hyphenated().to_string() != encoded
895                {
896                    return Err(AppError::InvalidInput(format!(
897                        "{name} must contain a canonical lowercase UUIDv4"
898                    )));
899                }
900                Ok(value.to_owned())
901            }
902        }
903    }
904}
905
906impl fmt::Display for WorkspaceId {
907    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
908        formatter.write_str(&self.0)
909    }
910}
911
912impl FromStr for WorkspaceId {
913    type Err = AppError;
914
915    fn from_str(value: &str) -> Result<Self, Self::Err> {
916        IdentitySyntax::Uuid {
917            name: "WorkspaceId",
918            prefix: "appws_",
919        }
920        .parse(value)
921        .map(Self)
922    }
923}
924
925impl Serialize for WorkspaceId {
926    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
927    where
928        S: Serializer,
929    {
930        serializer.serialize_str(&self.0)
931    }
932}
933
934impl<'de> Deserialize<'de> for WorkspaceId {
935    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
936    where
937        D: Deserializer<'de>,
938    {
939        String::deserialize(deserializer)?
940            .parse()
941            .map_err(serde::de::Error::custom)
942    }
943}
944
945impl fmt::Display for AppError {
946    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
947        match self {
948            Self::InvalidInput(message) => write!(formatter, "invalid input: {message}"),
949            Self::NotFound(message) => write!(formatter, "not found: {message}"),
950            Self::Conflict(message) => write!(formatter, "conflict: {message}"),
951            Self::Busy(message) => write!(formatter, "busy: {message}"),
952            Self::Closed(message) => write!(formatter, "closed: {message}"),
953            Self::Timeout(message) => write!(formatter, "timeout: {message}"),
954            Self::Integration(message) => write!(formatter, "integration: {message}"),
955        }
956    }
957}
958
959impl std::error::Error for AppError {}