Skip to main content

app_kit/workflow/
child.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::task::{Context, Poll};
4
5use crate::AppError;
6use crate::workflow::ActionMode;
7use crate::workflow::definition::CompiledDefinition;
8use crate::workflow::run::RunRecord;
9
10/// Caller-selected execution of a complete, already-admitted child batch.
11///
12/// App owns admission; the executor chooses polling order and returns each
13/// admitted completion exactly once or reports failure. App validates membership
14/// before reducing results in original child order.
15///
16/// The associated future preserves concrete child and execution types, both of
17/// which may be non-Send and non-Unpin. This GAT is the App-specific exception
18/// to ordinary RPITIT methods.
19///
20/// # Example
21///
22/// ```
23/// use agents_kit::Agent;
24/// use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
25/// use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
26///     RunStatus, WorkflowDefinition, WorkflowState};
27/// use storage_kit::StateStore;
28/// use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
29/// use app_kit::{AppError, workflow::{ActionMode, ChildBatch, ChildCompletion, ChildExecutor}};
30/// use futures_util::FutureExt;
31/// use std::future::Future;
32/// struct InspectChildren;
33/// impl InspectChildren {
34///     fn inspect(result: Result<Vec<ChildCompletion>, AppError>) -> Result<Vec<ChildCompletion>, AppError> {
35///         let completed = result?;
36///         assert_eq!(completed.len(), 1);
37///         assert_eq!(completed[0].ordinal(), 0);
38///         Ok(completed)
39///     }
40/// }
41/// impl ChildExecutor for InspectChildren {
42///     type Execution<'a, F> = futures_util::future::Map<ChildBatch<F>,
43///         fn(Result<Vec<ChildCompletion>, AppError>) -> Result<Vec<ChildCompletion>, AppError>>
44///     where Self: 'a, F: Future<Output = Result<ChildCompletion, AppError>> + 'a;
45///     fn execute<'a, F>(&'a self, batch: ChildBatch<F>) -> Self::Execution<'a, F>
46///     where F: Future<Output = Result<ChildCompletion, AppError>> + 'a {
47///         assert_eq!(batch.mode(), ActionMode::Asynchronous);
48///         assert_eq!(batch.len(), 1);
49///         assert!(!batch.is_empty());
50///         batch.map(Self::inspect as fn(_) -> _)
51///     }
52/// }
53/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
54/// # runtime.block_on(async {
55/// let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
56/// let definition = WorkflowDefinition::parse(r#"{
57///  "schema":"app-kit.workflow/v2","id":"counter","revision":"r1",
58///  "state":{"sum":0},"required":["sum"],"reducers":{"sum":"sum_integer"},
59///  "entry":"child","nodes":[{"id":"child","step":{"kind":"child","workflow":"child",
60///  "input":{"count":{"kind":"literal","value":4}},
61///  "reductions":[{"source":"count","target":"sum","reducer":"sum_integer"}]}}],"routes":{}
62/// }"#)?;
63/// let child = WorkflowDefinition::parse(r#"{
64///     "schema":"app-kit.workflow/v2", "id":"child", "revision":"r1",
65///     "state":{"count":0}, "required":["count"],
66///     "reducers":{"count":"reject_concurrent"}, "entry":"increment",
67///     "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
68///         {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
69///     ]}}], "routes":{}
70/// }"#)?;
71/// let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition, child]).with_child_executor(InspectChildren);
72/// let mut app = App::open(config).await?;
73/// let outcome = async {
74///     let mut workspace = app.create_workspace(WorkspaceConfig {
75///         name: Some("local counter".into()), root: std::env::current_dir()?,
76///     }).await?;
77///     let workflow = workspace.workflow("counter".parse()?).await?;
78///     let mut run = workflow.prepare(WorkflowState::default()).await?;
79///     let terminal = run.execution(ExecutionOptions::default()).await?.finish().await?;
80///     assert_eq!(terminal.state.values()["sum"], 5);
81///     #
82///     Ok::<(), Box<dyn std::error::Error>>(())
83/// }.await;
84/// let cleanup = app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await;
85/// if let Err(error) = &cleanup { eprintln!("App shutdown: {error}"); }
86/// outcome?;
87/// cleanup?;
88/// # Ok::<(), Box<dyn std::error::Error>>(())
89/// # })?;
90/// # Ok::<(), Box<dyn std::error::Error>>(())
91/// ```
92pub trait ChildExecutor: Send + Sync + 'static {
93    /// The exact caller future for this batch, without future erasure.
94    type Execution<'a, F>: Future<Output = Result<Vec<ChildCompletion>, AppError>> + 'a
95    where
96        Self: 'a,
97        F: Future<Output = Result<ChildCompletion, AppError>> + 'a;
98
99    /// Selects how the admitted child futures are polled and awaited.
100    ///
101    /// App supplies already admitted work; this method chooses polling and collection,
102    /// not admission or new child identity. Pipeline nodes use their own synchronous
103    /// state handoff through the same kernel and bypass this seam.
104    ///
105    /// # Errors
106    ///
107    /// Any `Err` returned by the execution future becomes
108    /// [`WorkflowFailure::Child`](crate::workflow::WorkflowFailure::Child),
109    /// regardless of the [`AppError`] variant. Input and admission operations may
110    /// classify the same variant differently. A panic while constructing or
111    /// polling the future is contained as
112    /// [`WorkflowFailure::Execution`](crate::workflow::WorkflowFailure::Execution).
113    /// Neither failure reduces the parent from partial child results. Existing
114    /// descendant cleanup retains ownership before terminal publication.
115    ///
116    /// See [the complete child executor workflow](ChildExecutor).
117    fn execute<'a, F>(&'a self, batch: ChildBatch<F>) -> Self::Execution<'a, F>
118    where
119        F: Future<Output = Result<ChildCompletion, AppError>> + 'a;
120}
121
122/// A non-Clone owner of concrete, pinned, admitted child futures.
123///
124/// Async execution polls all admitted children and awaits every result before
125/// collecting in original order. Sync execution polls sequentially and stops
126/// on the first error. Before its first poll a custom executor can transfer the exact pins with
127/// [`Self::into_tasks`]. No child future needs Send or Unpin.
128///
129/// See [the complete child executor workflow](ChildExecutor).
130pub struct ChildBatch<F> {
131    mode: ActionMode,
132    tasks: Vec<ChildSlot<F>>,
133    state: ChildBatchState,
134}
135
136enum ChildSlot<F> {
137    Pending(Pin<Box<F>>),
138    Completed(Result<ChildCompletion, AppError>),
139}
140
141enum ChildBatchState {
142    Unpolled,
143    Polling,
144    Finished,
145}
146
147/// Exact terminal child evidence produced only by an admitted child future.
148///
149/// This value cannot be constructed or cloned by a caller. The private record
150/// retains the original run and parent membership for App's validation.
151///
152/// ```compile_fail
153/// use app_kit::workflow::ChildCompletion;
154///
155/// fn duplicate(completion: ChildCompletion) -> (ChildCompletion, ChildCompletion) {
156/// (completion.clone(), completion)
157/// }
158/// ```
159///
160/// Struct update syntax also cannot construct completion evidence:
161///
162/// ```compile_fail
163/// use app_kit::workflow::ChildCompletion;
164/// fn reconstruct(completion: ChildCompletion) -> ChildCompletion {
165/// ChildCompletion { ..completion }
166/// }
167/// ```
168///
169/// See [the complete child executor workflow](ChildExecutor).
170pub struct ChildCompletion {
171    ordinal: usize,
172    record: Box<RunRecord>,
173}
174
175impl<F> ChildBatch<F> {
176    pub(super) fn new(tasks: Vec<F>, mode: ActionMode) -> Result<Self, AppError> {
177        if mode == ActionMode::Both {
178            return Err(AppError::InvalidInput(
179                "child execution requires an actual mode".into(),
180            ));
181        }
182        Ok(Self {
183            mode,
184            tasks: tasks
185                .into_iter()
186                .map(|task| ChildSlot::Pending(Box::pin(task)))
187                .collect(),
188            state: ChildBatchState::Unpolled,
189        })
190    }
191
192    /// Returns the actual synchronous or asynchronous execution mode.
193    ///
194    /// This never returns the declaration-only [`ActionMode::Both`] value.
195    ///
196    /// See [the complete child executor workflow](ChildExecutor).
197    pub fn mode(&self) -> ActionMode {
198        self.mode
199    }
200
201    /// Returns the number of child tasks or completed results still owned here.
202    ///
203    /// After the Future returns its results or an error, the batch owns none.
204    ///
205    /// See [the complete child executor workflow](ChildExecutor).
206    pub fn len(&self) -> usize {
207        self.tasks.len()
208    }
209
210    /// Reports whether this batch still owns any child task or result.
211    ///
212    /// See [the complete child executor workflow](ChildExecutor).
213    pub fn is_empty(&self) -> bool {
214        self.len() == 0
215    }
216
217    /// Transfers the exact pinned child futures before this batch is polled.
218    ///
219    /// Once polling begins, returns Conflict and drops the consumed batch,
220    /// including its remaining futures and retained completion evidence. This
221    /// never moves a child out of its pin or creates a second execution owner.
222    ///
223    /// # Errors
224    ///
225    /// Returns [`AppError::Conflict`] once any batch polling has begun, even if no
226    /// child has completed. The consumed batch is dropped on error, preserving pin
227    /// safety and preventing duplicate completion ownership.
228    ///
229    /// # Example
230    ///
231    /// ```
232    /// # use agents_kit::Agent;
233    /// # use app_kit::{App, AppConfig, Shutdown, WorkspaceConfig};
234    /// # use app_kit::workflow::{CheckpointSelector, ExecutionEvent, ExecutionOptions, RunSelector,
235    /// #     RunStatus, WorkflowDefinition, WorkflowState};
236    /// # use storage_kit::StateStore;
237    /// # use storage_kit::state::config::{LanceStateStoreConfig, StateStoreConfig};
238    /// use app_kit::{AppError, workflow::{ChildBatch, ChildCompletion, ChildExecutor}};
239    /// use futures_util::FutureExt;
240    /// use std::{future::Future, pin::Pin};
241    /// struct InspectChildren;
242    /// impl InspectChildren {
243    ///     fn collect(results: Vec<Result<ChildCompletion, AppError>>) -> Result<Vec<ChildCompletion>, AppError> {
244    ///         results.into_iter().collect()
245    ///     }
246    /// }
247    /// impl ChildExecutor for InspectChildren {
248    ///     type Execution<'a, F> = futures_util::future::Map<futures_util::future::JoinAll<Pin<Box<F>>>,
249    ///         fn(Vec<Result<ChildCompletion, AppError>>) -> Result<Vec<ChildCompletion>, AppError>>
250    ///     where Self: 'a, F: Future<Output = Result<ChildCompletion, AppError>> + 'a;
251    ///     fn execute<'a, F>(&'a self, batch: ChildBatch<F>) -> Self::Execution<'a, F>
252    ///     where F: Future<Output = Result<ChildCompletion, AppError>> + 'a {
253    ///         // App delivers an unpolled batch. Keep each concrete future in its original pin.
254    ///         let tasks = batch.into_tasks().expect("batch has not been polled");
255    ///         futures_util::future::join_all(tasks).map(Self::collect as fn(_) -> _)
256    ///     }
257    /// }
258    /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
259    /// # runtime.block_on(async {
260    /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
261    /// # let definition = WorkflowDefinition::parse(r#"{
262    /// #  "schema":"app-kit.workflow/v2","id":"counter","revision":"r1",
263    /// #  "state":{"sum":0},"required":["sum"],"reducers":{"sum":"sum_integer"},
264    /// #  "entry":"child","nodes":[{"id":"child","step":{"kind":"child","workflow":"child",
265    /// #  "input":{"count":{"kind":"literal","value":4}},
266    /// #  "reductions":[{"source":"count","target":"sum","reducer":"sum_integer"}]}}],"routes":{}
267    /// # }"#)?;
268    /// # let child = WorkflowDefinition::parse(r#"{
269    /// #     "schema":"app-kit.workflow/v2", "id":"child", "revision":"r1",
270    /// #     "state":{"count":0}, "required":["count"],
271    /// #     "reducers":{"count":"reject_concurrent"}, "entry":"increment",
272    /// #     "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
273    /// #         {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
274    /// #     ]}}], "routes":{}
275    /// # }"#)?;
276    /// # let config = AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition, child]).with_child_executor(InspectChildren);
277    /// # let mut app = App::open(config).await?;
278    /// # let outcome = async {
279    /// #     let mut workspace = app.create_workspace(WorkspaceConfig {
280    /// #         name: Some("local counter".into()), root: std::env::current_dir()?,
281    /// #     }).await?;
282    /// #     let workflow = workspace.workflow("counter".parse()?).await?;
283    /// #     let mut run = workflow.prepare(WorkflowState::default()).await?;
284    ///     let terminal = run.execution(ExecutionOptions::default()).await?.finish().await?;
285    ///     assert_eq!(terminal.state.values()["sum"], 5);
286    ///     #
287    /// #     Ok::<(), Box<dyn std::error::Error>>(())
288    /// # }.await;
289    /// # let cleanup = app.shutdown(Shutdown::CancelAndDrain { timeout: std::time::Duration::from_secs(5) }).await;
290    /// # if let Err(error) = &cleanup { eprintln!("App shutdown: {error}"); }
291    /// # outcome?;
292    /// # cleanup?;
293    /// # Ok::<(), Box<dyn std::error::Error>>(())
294    /// # })?;
295    /// # Ok::<(), Box<dyn std::error::Error>>(())
296    /// ```
297    pub fn into_tasks(self) -> Result<Vec<Pin<Box<F>>>, AppError> {
298        match self.state {
299            ChildBatchState::Unpolled => self
300                .tasks
301                .into_iter()
302                .map(|slot| match slot {
303                    ChildSlot::Pending(task) => Ok(task),
304                    ChildSlot::Completed(_) => Err(AppError::Conflict(
305                        "child batch polling already began".into(),
306                    )),
307                })
308                .collect(),
309            ChildBatchState::Polling | ChildBatchState::Finished => Err(AppError::Conflict(
310                "child batch polling already began".into(),
311            )),
312        }
313    }
314}
315
316impl<F: Future<Output = Result<ChildCompletion, AppError>>> Future for ChildBatch<F> {
317    type Output = Result<Vec<ChildCompletion>, AppError>;
318
319    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
320        let batch = self.get_mut();
321        if matches!(batch.state, ChildBatchState::Unpolled) {
322            batch.state = ChildBatchState::Polling;
323        }
324        let ChildBatchState::Polling = batch.state else {
325            return Poll::Ready(Err(AppError::Conflict(
326                "child batch already finished".into(),
327            )));
328        };
329        let mut pending = false;
330        for slot in &mut batch.tasks {
331            let ChildSlot::Pending(task) = slot else {
332                continue;
333            };
334            let result = match task.as_mut().poll(cx) {
335                Poll::Pending if batch.mode == ActionMode::Synchronous => return Poll::Pending,
336                Poll::Pending => {
337                    pending = true;
338                    continue;
339                }
340                Poll::Ready(result) => result,
341            };
342            // Dropping the completed future releases its resources while its
343            // exact completion evidence remains owned until the batch returns.
344            match (batch.mode, result) {
345                (ActionMode::Synchronous, Err(error)) => {
346                    batch.tasks.clear();
347                    batch.state = ChildBatchState::Finished;
348                    return Poll::Ready(Err(error));
349                }
350                (_, result) => *slot = ChildSlot::Completed(result),
351            }
352        }
353        if pending {
354            return Poll::Pending;
355        }
356        let results = std::mem::take(&mut batch.tasks)
357            .into_iter()
358            .map(|slot| match slot {
359                ChildSlot::Completed(result) => result,
360                ChildSlot::Pending(_) => Err(AppError::Integration(
361                    "child completion is unavailable".into(),
362                )),
363            })
364            .collect();
365        batch.state = ChildBatchState::Finished;
366        Poll::Ready(results)
367    }
368}
369
370impl ChildCompletion {
371    pub(super) fn admitted(
372        record: RunRecord,
373        parent: &RunRecord,
374        definition: &CompiledDefinition,
375    ) -> Result<Self, AppError> {
376        record.validate_child(parent, definition)?;
377        if !record.checkpoint.is_terminal() {
378            return Err(AppError::Conflict(
379                "child has no terminal completion".into(),
380            ));
381        }
382        let ordinal = record
383            .parent
384            .as_ref()
385            .ok_or_else(|| AppError::Integration("child parent evidence unavailable".into()))?
386            .index;
387        Ok(Self {
388            ordinal,
389            record: Box::new(record),
390        })
391    }
392
393    /// Returns this child's ordinal in the original admitted batch.
394    ///
395    /// See [the complete child executor workflow](ChildExecutor).
396    pub fn ordinal(&self) -> usize {
397        self.ordinal
398    }
399
400    pub(super) fn record(&self) -> &RunRecord {
401        &self.record
402    }
403
404    pub(super) fn into_record(self) -> RunRecord {
405        *self.record
406    }
407}
408
409/// Default executor that applies the batch's actual execution-mode policy.
410///
411/// See [the complete child executor workflow](ChildExecutor).
412#[derive(Default)]
413pub struct InlineExecutor;
414
415impl ChildExecutor for InlineExecutor {
416    type Execution<'a, F>
417        = ChildBatch<F>
418    where
419        Self: 'a,
420        F: Future<Output = Result<ChildCompletion, AppError>> + 'a;
421
422    fn execute<'a, F>(&'a self, batch: ChildBatch<F>) -> Self::Execution<'a, F>
423    where
424        F: Future<Output = Result<ChildCompletion, AppError>> + 'a,
425    {
426        batch
427    }
428}