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}