Child-pipeline composition
What ships
Compose a child pipeline as one typed parent step with
createSteps<TOptions>().fromPipeline(...). Overloads cover identity and
mapped results. An async fromPipeline mapResult publishes its resolved value;
dependents receive Awaited<TOut> and skippable adapters accept that resolved
shape as their skip value. Child lifecycle hooks stay isolated, and child work reports
through parent-step progress. Interactive reporters display selected child steps
as an indented tree, retaining their terminal states and inner work counts.
Map a runtime item list onto the same child pipeline with
createSteps<TOptions>().forEachPipeline(...). Item keys are stable,
concurrency is bounded, and results keep input order even when children
finish out of order. All running children settle before an aggregate failure
is returned, so the parent cannot finalize while child side effects are still
in flight.
Opaque parent steps pass outputs through typed dependencies, skip side effects in dry-run, and convert domain validation errors into failed parent steps.
Why not call the child from an ordinary step
Invoking a child pipeline from an ordinary parent step and passing the parent
PipelineStepContext gives the child the parent's runtime seams, but it also
gives the child the parent's raw hooks and parent-only reportProgress
callback.
The resulting lifecycle stream is flat. A direct nested run emits the child pipeline, step, finalize, and completion events between the parent wrapper step's start and completion events. Most events have no pipeline path or run identity. The interactive reporter also owns one plan, one step map, and one result. A child start replaces its visible plan, and a child completion disposes the reporter before the parent completes.
The package needs a composition contract that preserves typing and runtime behavior without exposing two pipeline frames through hooks designed for one pipeline.
Goals
- Model a child pipeline as one typed step in the parent DAG.
- Infer parent dependency inputs, parent options, child options, the child result, and any mapped result.
- Preserve cancellation, logging, timing, working-directory, dry-run, progress, and failure behavior.
- Keep raw child lifecycle hooks inside the adapter while reporting useful progress through the opaque parent step.
- Keep the first slice bounded: one opaque child, or a mapped set of the same child.
Supported: policy skip on opaque steps
Policy skip (step.skippable / StepSkipDecision) is part of the shipped API and
applies to ordinary steps, fromPipeline adapters, and mapped
forEachPipeline adapters (the opaque parent step is a normal step under the
hood):
- Return a non-empty string or
{ reason, value? }fromskipto skip without callingrun(or without running the child). Reporters show a yellow skip whose terminal report hasstatus: "skipped"andreason: "policy". - Policy skips unlock required dependents. A bare string (or
{ reason }withoutvalue) publishesundefinedas the step output. - Any step that declares
skipis typed so dependents seeTOut | undefined(includingPipelineResultOf<Child> | undefinedforfromPipelineandreadonly PipelineResultOf<Child>[] | undefinedforforEachPipeline, both withoutmapResult). Prefer{ reason, value }on every skip path when dependents need a real output. WithmapResult, skipvalueis the parent-facing mapped output — oneTOutforfromPipeline, or the completereadonly TOut[]forforEachPipeline. Policy skip does not callmapResult.
Use fromPipeline.skippable or forEachPipeline.skippable to policy-skip
optional child work and publish a concrete disabled result via
{ reason, value }. The ordinary constructors reject skip and retain their
non-optional output types.
Non-goals
- Flatten child steps into the parent plan.
- Add hierarchical or versioned external lifecycle events.
- Support namespaced selection such as
parent.child-step. - Add parallel DAG execution, remote checkpoints, or subcommands.
- Injected runner overrides (substituting a child pipeline implementation at test time via parent options) — separate from policy skip.
Why this shape
| Model | Typing | Planning | Hooks and reporting | Selective execution | Failure | Dry run | Cost |
|---|---|---|---|---|---|---|---|
| Continue manual shared-context calls | Each wrapper can map options and return a typed value, but each wrapper repeats the contract and structural context passing makes unsafe fields easy to forward. | The parent sees only the wrapper step; the child creates a separate plan at runtime. | Raw child events enter the parent's flat hook stream. The current interactive reporter replaces the parent frame and disposes on child completion. | Parent selection can select the wrapper. Child selection depends on ad hoc mapped stepIds. |
runOrThrow can fail the wrapper, but consistent child-identifying messages and progress require repeated wrapper code. |
Each wrapper must remember parent/child dry-run precedence and per-step policy. | Low per call, with continuing duplication and reporter breakage. |
| Flatten the child DAG into the parent DAG | The API must translate child option and dependency types into a parent definition without losing inference. | Child ids need stable namespacing, and runtime option mapping can change which child plan is valid. | A flat plan fits current hooks only after defining pipeline identity, paths, and collision rules. | Namespaced filters need semantics for parent dependencies, child dependencies, and finalization. | The parent must define how child failures, partial outputs, and child finalization affect the flattened DAG. | The parent must reconcile its dry-run rules with every flattened child step. | High. It requires event identity and selection semantics before the first useful slice. |
| Opaque typed child-step adapter | A parent-scoped factory can infer dependency inputs and parent options while a typed child pipeline constrains mapped options and results. | The parent plan contains one stable step. The adapter validates the child plan immediately before running it. | Parent hooks see the opaque step only. An internal bridge translates child terminal events into parent-step progress. | Selecting the opaque parent step runs the child according to mapped child options. Nested selection is deferred. | An unusable child result throws a parent-step error that retains the child pipeline and failing step identity. | Framework-owned parent dryRun overrides the mapper, then the child applies its own per-step rules. |
Medium. It uses existing pipeline and hook primitives without changing the external event model. |
Shared raw hooks are rejected because they already corrupt the interactive reporter's single frame and cannot distinguish nested runs. Immediate flattening is rejected because it needs stable identity, selection, dependency, failure, and finalization rules that the current event and plan models don't provide. The opaque adapter addresses the existing composition case without deciding those broader contracts.
Contract
The parent DAG contains one ordinary step. That step maps its typed parent inputs and context into typed child options, validates the child plan, runs the child with an isolated internal hook bridge, and returns either the child result or a mapped parent result.
The bridge owns child lifecycle events. Parent and custom hooks receive only the parent pipeline and opaque parent-step events. The bridge reports terminal child-step counts through the parent step's reportProgress, so existing reporters keep one live frame.
API
The parent-scoped step factory owns the adapter:
const seedIndexStage = step.fromPipeline("seed-index", {
pipeline: IndexSeedPipeline,
dependsOn: [seedCatalogStage],
description: "Seed a precomputed search index",
mapOptions: (_inputs, context) => ({
indexDir: context.options.indexDir,
syncSchema: false,
}),
mapResult: () => ({ ran: true, stageId: "seed-index" }),
});
StepFactory.fromPipeline and StepFactory.fromPipeline.skippable infer the
child result when mapResult is absent, and infer the mapped result when it
is present.
For runtime-selected sets (any domain — shards, files, jobs, catalog rows):
const processShards = step.forEachPipeline("process-shards", {
pipeline: ShardPipeline,
dependsOn: [resolveShards],
items: ({ "resolve-shards": shards }) => shards,
key: (shard) => shard.id,
concurrency: (_inputs, context) => context.options.concurrency,
// Optional presentation only. Defaults to the noun "items".
progress: { itemNoun: "shards" },
mapOptions: (shard, _index, _inputs, context) => ({
shardPath: shard.path,
outputRoot: context.options.outputRoot,
}),
});
When the whole fan-out may be intentionally unnecessary, opt into the widened output explicitly:
const processShards = step.forEachPipeline.skippable("process-shards", {
pipeline: ShardPipeline,
dependsOn: [resolveShards],
skip: ({ "resolve-shards": shards }) =>
shards.length === 0 ? { reason: "no shards selected", value: [] } : false,
items: ({ "resolve-shards": shards }) => shards,
key: (shard) => shard.id,
mapOptions: (shard) => ({ shardPath: shard.path }),
});
Only .skippable produces readonly ChildResult[] | undefined (or
readonly TOut[] | undefined with mapResult). A valued skip supplies the
complete parent-facing array and bypasses items, child execution, and
mapResult.
The parent sees one opaque process-shards step. Progress is domain-neutral by
default: a one-line item/concurrency summary plus structured details rows for
each item and its selected child steps. Interactive reporters render these as
an indented tree: parent step, item key, child steps, and any deeper composition.
Pending, running, completed, failed, cancelled, and skipped rows retain their
states; filtered steps are omitted. Completed item groups remain in the tree.
fromPipeline provides the same child-step breakdown without an item-key level.
Inner reportProgress counts and details survive both composition boundaries. The
progress bar advances on terminal child steps so long fan-out work does not look
hung at 0%. Override progress.itemNoun or progress.formatMessage for domain
labels without changing scheduling. Duplicate keys fail before any child starts.
Parent dry-run overrides the mapped child run object's dryRun value.
Live fan-out snapshots show up to 32 item groups by default, prioritizing active
items and then failures while keeping displayed groups in input order. Descendants
stay with their item; an overflow row reports omitted groups. All groups are
retained and the complete default tree is emitted once after the fan-out settles.
progress.detailLimit overrides the live cap and also caps the final snapshot;
larger values increase per-event presentation work. Progress payloads are not
constructed when neither hooks nor tracing observe them. The standalone
mappedChildProgressDetails helper continues to format only the active entries
supplied in its snapshot, with no default cap.
Progress details use preorder rows with depth: 0 (or omitted) for direct
children. Each composition level adds one to descendant depths. IDs are stable
within their containing group; name provides optional display text. completed
and total describe inner work independently of the parent's terminal-step count.
Reporters cap visual indentation at 32 levels. Trace snapshots preserve these
fields within 128-row, 4096-character-per-field, and 256 KiB encoded-payload
bounds, with detail_count recording the pre-truncation row count. The byte
budget includes UTF-8 and escaping inside the enclosing JSON event, leaving
room for event metadata below the default 1 MiB NDJSON event limit.
The interactive CLI keeps completed details after their parent settles. Trees larger than the terminal use a live window around active work, with omitted-row counts; the full retained tree prints at completion. Plain/non-TTY reporting continues to emit aggregate progress messages. Presentation does not change parent plans, child selection, hook isolation, scheduling, or result types.
Semantics matrix
| Concern | Contract |
|---|---|
| Parent plan | The child is one opaque parent step. Its nestedPipeline metadata identifies the child pipeline, declared child step ids, and single/fan-out mode; child steps are not added to PipelinePlan.steps and runtime selection is not predicted. |
| Option mapping | mapOptions(inputs, parentContext) returns the child's complete run object: domain fields plus any child-specific controls. The adapter applies parent dryRun last so framework semantics win. |
| Other run controls | The adapter does not automatically propagate parent stepIds, targets, or continueOnError. mapOptions may choose child-specific values; child targets are limited to that child's declared public goals. Mapping parent selection would usually be invalid because parent and child ids occupy different namespaces. Mapping continueOnError lets independent child work finish but does not make a failed child usable by the parent. |
| Runtime context | The adapter forwards cwd, log, now, sleep, and signal field by field. It does not forward parent hooks or the parent-only reportProgress field. |
| Hook isolation | Parent and custom hooks see only the parent pipeline and opaque parent step. Raw child lifecycle events go only to an internal bridge. |
| Progress | Single-child fromPipeline bridges terminal child steps into the opaque step (completed / plan step count, messages prefixed with childPipelineId/childStepId). Mapped forEachPipeline reports domain-neutral fan-out progress via toMappedChildStepProgress: completed counts terminal child steps across items, total is items × stepsPerItem, message is a one-line summary, and details contains a bounded live item tree and a complete default final tree. Presentation is optional (progress.itemNoun / progress.formatMessage / progress.detailLimit); neither path creates another live reporter frame. |
| Result | The default output is the typed child result. mapResult may transform it into a domain-specific parent-step output. A child result is usable only when its status is "completed"; best-effort child results must be handled through an explicit ordinary step that calls run() and inspects the structured result. |
| Failure | An unusable child result fails the opaque parent step. Its error names the child pipeline and the first failing child step and message, with code: "TUBELESS_CHILD_FAILED", phase: "execution", and kind: "child"; its JSON-safe cause chain retains the actionable child failure. When mapped children contain both cancellation and genuine failure, failure takes precedence; the aggregate is cancelled only when every unsuccessful child was cancelled. |
| Cancellation | The child receives the exact parent AbortSignal. An already-aborted or later-aborted signal stops the child and transitions the parent step to cancelled. |
| Dry run | The child receives dryRun: parentContext.dryRun and applies its own per-step policy. Child steps with dryRun: "skip" are skipped; ordinary child steps run normally. |
| Selective execution | Selecting or targeting the opaque parent step runs the child according to its mapped controls. Parent selection is never forwarded into the child's ID namespace. |
| Checkpoints | The adapter adds no checkpoint behavior. The child receives mapped options and the normal runtime context. |
Guarantees
Call sites infer required and optional dependency inputs, parent domain
options, required child options, the default child result, and a transformed
mapResult output without explicit generic arguments. Missing required child
options fail at compile time.
The parent plan contains only the opaque stage. The child receives the parent's working directory, logger, clock, sleep function, and abort signal. Parent hooks see no raw child lifecycle events. The internal bridge converts the child's canonical step statuses into monotonic progress on the opaque parent step.
Parent dry-run precedence prevents a child side-effecting step from running.
An unusable child result fails one opaque parent step and names the child
pipeline, step, and error, including when continueOnError produced a
best-effort final value. Later cancellation interrupts in-flight child work.
An invalid child plan fails before the child starts.
fromPipeline and forEachPipeline identity forms (no mapResult) use
overloads so the parent-facing outputs stay the child result and child-result
array, respectively. Their ordinary constructors remain non-optional;
.skippable alone adds undefined.
Out of scope
Policy skip plus explicit undefined typing cover skip-without-run for opaque
single and mapped child steps. Runner substitution and any richer skipped-output
shape beyond policy skip are not supported.
Hierarchical or versioned external events, flattened or namespaced child selection, and parallel DAG scheduling remain out of scope. Mapped children provide bounded parallelism inside one opaque parent step; they do not make the pipeline DAG executor parallel.
Structured fan-out failures
A failed or cancelled forEachPipeline step exposes error.fanOut on the parent
run error and step report (also available to failure hooks, JSON error rendering, and recorded trace history).
failures contains at most the first 32 failed started items in input order,
with the original index, key, keyTruncated, cancelled, and a JSON-safe
error cause snapshot. failureCount counts all failed started items;
omittedFailureCount counts entries beyond the limit. Scheduler failures appear
separately as schedulerError; unstarted items are not failures. Setup errors,
such as duplicate keys or an exception in items, have no fanOut diagnostic.
Keys and snapshot strings are capped at 1024 UTF-16 code units, and cause chains
use the existing eight-level bound. keyTruncated explicitly marks shortened
keys; use the original input index to recover those identities. Snapshots retain
messages, names, source codes, and causes, without stacks, options, successful
outputs, or nested run objects. Nested fan-outs do not recursively expand here.
Use complete keys to select original inputs for a caller-directed rerun. Check
omittedFailureCount before treating the list as exhaustive, and account for
unstarted work after cancellation. Reruns are ordinary new pipeline runs; the
caller owns retry policy and side-effect safety. See the helper in
fan-out-progress.ts.
This additive diagnostic does not change parent failure/cancellation precedence,
primary causes, scheduling, downstream dependency skips, or continueOnError.
Successful siblings still do not produce a partial parent step output.