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 {}