Core concepts
Steps, dependencies, and results
A pipeline describes a series of steps and the values passed between them. Each step has an ID, a function to run, and any dependencies. A finalizer combines step outputs into the value returned to the caller.
Create steps with one createSteps<TOptions>() factory per pipeline. Take its
step constructor, then pass a step object to another step’s
dependsOn array to require its output;
TypeScript then infers that input’s type.
definePipeline checks the graph immediately. Duplicate or reserved IDs,
missing dependencies, contradictory dependencies, and cycles cause a
PipelineDefinitionError. TypeScript also catches duplicate literal step IDs.
The definition stores a copy of the dependency arrays, so later changes to
those arrays do not alter the graph.
Keep IDs stable: dependencies, output keys, selection, and traces use them.
Set name when a step needs a different label in plans and progress reports.
Changing the label does not change the ID.
Wait for user or application input
Use waitForInput from the same createSteps factory to pause one branch until
an application-owned input adapter replies:
const { step, waitForInput } = createSteps<{ draft: string }>();
const draft = step("draft", { run: (_inputs, context) => context.options.draft });
const feedback = waitForInput("feedback", {
dependsOn: [draft],
read: ({ draft }, context) => readFromYourUI(draft, context.signal),
});
The read(inputs, context) callback can use a terminal, web form, or any asynchronous
input source. Its typed result feeds dependents like an ordinary step. The pipeline
stays active while input is pending, reports Waiting for input, then continues
through its normal dependencies and finalizer. Add outputSchema to validate or
transform untrusted input before dependent work starts.
Input steps skip dry runs by default; a dryRun callback can supply preview input.
They cannot be cached or use policy skip predicates. Gate dependent work with
an ordinary step when an input response should prevent an action.
Cancellation stops waiting and ignores a late reply or
rejection. Forward context.signal into the adapter to release its UI/I/O resources.
The application owns the input source and any persistence across process restarts.
See the review pipeline recipe. Declarative YAML and
JSON pipelines use registered input handlers with output schemas; see
decisions, confirmations, and text
and the YAML input recipe.
Dependency choices
| Field | Supplies data | Blocks after failure | Typical use |
|---|---|---|---|
dependsOn |
Required | Yes | Transformation needs upstream value |
optionalDependsOn |
When present | No | Fallback or partial rerun |
skipAfterFailureOf |
No | Yes | Publish/write safety gate |
Prefer required dependencies unless partial execution is an intentional part of
the workflow. A failure gate blocks after either failed or cancelled; both
mean the guarded prerequisite did not safely complete.
For example, a publish step that needs a validated artifact should require the
validation step with dependsOn. If it needs only the build output but must
stop when validation fails, use skipAfterFailureOf for validation. An
optionalDependsOn entry alone does not block publication after a failure.
Skips and failures
A step can be omitted before its handler starts, intentionally skip its work, or fail while running. These cases have different effects on dependent steps.
| Outcome | What happened | What dependents receive |
|---|---|---|
| Structural skip | A filter, dry-run rule, missing dependency, abort, or earlier failure prevented execution | No output; required dependents cannot run |
| Policy skip | A step’s skip predicate decided that work was unnecessary |
The supplied skip value, or undefined |
| Failure | The step’s work failed | No successful output; required dependents cannot run |
Fail-fast is the default: a failure stops new work. Set continueOnError to
allow independent steps to proceed. The run still reports failure, and
runOrThrow still throws. Use run to inspect any partial result.
Adding skip to a step definition makes it skippable. A string result or
{ reason } publishes no value, so the output is typed as T | undefined.
When every skip branch returns { reason, value }, the output remains T and
dependents do not need an absence guard. For a schema-backed step, the fallback
must satisfy the schema input type and dependents receive the transformed output.
Structural skips still publish no output and prevent required dependents from
running, so they do not widen the input type seen by a dependent that does run.
Execution controls
Pass business inputs as the first argument and execution controls as the second:
await pipeline.run(options, { dryRun: true });
await pipeline.run(options, { maxConcurrency: 4 });
await pipeline.runOrThrow(options, { targets: ["publish"] });
When the input type has no required fields, use pipeline.run() or
pipeline.runOrThrow(). Omitted or undefined input becomes a fresh {} on each
run and still passes through the input schema, including validation and defaults.
Required input fields remain required at compile time. To supply controls or
context while omitting input, use run(undefined, controls, context); the first
argument always represents domain input. PipelineInput<typeof pipeline> remains
the domain object type, without the call’s optional undefined.
See the inherited inputs recipe.
| Control | Default | Effect |
|---|---|---|
dryRun |
false |
Applies each step’s dry-run policy; unmarked steps still run |
maxConcurrency |
1 |
Limits simultaneous steps, including skip predicates and output validation |
continueOnError |
false |
Lets independent work continue after a failure |
targets |
All steps | Selects declared goals and their required dependencies and failure gates |
stepIds |
All steps | Selects exactly the listed steps, without adding dependencies |
targets and stepIds cannot be combined. PipelineRunControls enforces this
with a union type, including child controls and command plans. Omitted fields or
explicit undefined mean no selection. Dynamic inputs still receive a planning
error if both fields are supplied with arrays. Use pipeline.plan(controls) to
check selection before running. Planning requires no business inputs and does
not call step handlers or schema validators.
Concurrency overlaps asynchronous work; synchronous CPU-heavy handlers still block
the event loop. For CPU parallelism in Node, explicitly use a
worker-thread adapter
through fromRemote.
maxConcurrency must be a positive finite integer. plan() rejects invalid values
with TUBELESS_RUN_CONCURRENCY_INVALID; runs reuse that plan validation before
options validation or step execution.
The default of 1 preserves existing serial execution, including the ordering
of independent side effects. Opt in only when independent steps can safely overlap.
Declare ordering constraints as graph edges when side effects must run in sequence.
All three edge types are scheduling prerequisites: a step waits until each required input, optional input, and failure gate is terminal (completed, failed, skipped, or cancelled). Their existing output and failure policies then determine whether it runs. Ready steps dispatch in the compiled topological order, with declaration order breaking graph ties. A freed slot can start a dependent immediately, without waiting for unrelated steps. Skip predicates and output schemas, including schemas for policy-skip values, occupy the same slot as the step handler.
Failure and cancellation have separate dispatch policies:
| Condition | New work | In-flight work | Unstarted steps |
|---|---|---|---|
continueOnError: false (default) |
Stop after the first observed failure | Let every active step settle; do not abort it | Report fail-fast, retaining already planned structural skip reasons |
continueOnError: true |
Continue eligible branches | Let every active step settle | Required descendants skip for unmet dependencies; failure gates skip after an unsuccessful prerequisite; optional inputs may be absent |
External context.signal abort |
Stop regardless of continueOnError |
Forward the same signal and wait for active work to settle | Cancel unstarted selected steps; filtered steps remain skipped |
Fail-fast does not create an abort signal or cancel active work. Cancellation is cooperative: a handler that ignores the signal must still settle before the run returns, and its actual outcome is retained. An external abort while draining after a failure takes precedence for unstarted selected steps, including those with a planned dry-run or unmet-dependency skip. Already-terminal steps keep their outcomes.
Every concurrent failure is recorded. Any non-cancellation error makes the run
failed, even when other steps are cancelled. A run containing only cancellation
errors is cancelled. With continueOnError, the finalizer receives all available
outputs once active work has settled; an aborted signal prevents the finalizer’s
handler from running. Without continueOnError, any step error prevents finalization.
Live hooks and trace events retain actual event order. Final result.steps is in
stable plan order. Final result.errors places run-level errors first, then step
errors in plan order, then finalization errors; errors with the same position retain
their observation order. runOrThrow retains every error in its result and selects
its message and original cause from that ordered list. The first reported step error
therefore need not be the failure that triggered fail-fast; skipped steps retain
that triggering step’s ID in dependencyId.
The default result still comes from the last step in the graph’s stable topological order, regardless of which step finishes last.
The limit applies to one pipeline run. A child wrapper occupies one parent slot;
child runs retain their own limit, defaulting to 1. Set maxConcurrency in child
controls to opt them in separately. Fan-out concurrency independently limits
simultaneous child runs, and must be a positive finite integer; an invalid static
value is rejected when the step is created, and an invalid callback value fails
the step before any child starts. These limits multiply: four parallel fan-out steps with
concurrency: 8 can create 32 active children. Child DAG parallelism can increase
the number of active child steps further. There is no shared semaphore; having
parents hold slots while awaiting children that need those same slots can deadlock.
See the parallel DAG recipe.
Parallel execution exposes implicit ordering between side effects. Declare the
appropriate dependency before increasing concurrency. An ordering-only after or
waitFor edge, which supplies no data and allows work after a failed prerequisite,
is a possible future extension; neither edge exists today.
Reusing deterministic step outputs
An ordinary step can opt into caching with cache: { version: "v1" }, or
cache: true to inherit the pipeline implementation version. Keys, local storage,
and codecs have defaults; pipeline cache.maxAge accepts durations such as
"30 days". Only that step’s raw handler result is reused;
selection, dependencies, skip decisions, and output validation still apply.
Cache hits carry outputSource: "cache" with an ordinary completion status.
See cache contracts and safety rules.
Selection and finalization
Without selection controls, run the whole pipeline. Tubeless does not automatically select a target.
| Run controls | Selected work |
|---|---|
| Omitted | All declared steps |
{ targets: ["publish"] } |
publish and its required inputs and failure gates, recursively |
{ stepIds: ["build"] } |
Exactly build, without adding prerequisites |
Selection determines eligible work; dry-run policies, skips, failures, and cancellation still govern whether each selected step executes.
The targets field on definePipeline declares which goals callers may
select. It does not select work for a run. When that field is omitted, the last
step in execution order is exposed as a selectable public goal; targets: []
exposes none. Neither declaration restricts an unfiltered run.
A pipeline can be definePipeline({ id, steps }). Omitted finalize returns the
last step’s output, or undefined when the run did not publish it. The inferred
public goal and default result use the complete graph’s
topological execution order, so a downstream step remains the goal even if
listed before its prerequisites. Required dependencies, optional inputs, and
failure gates all affect that order. When several steps are ready, the scheduler
uses declaration order to break ties. An empty pipeline has no targets and its
default result is undefined.
Step types do not retain their dependency graph. Implicit target types therefore
include all declared step IDs, and the default result type includes all step
output types plus undefined. Runtime exposes only the final execution-order
target and rejects other IDs. Use explicit targets or finalize when you need
narrower types.
An explicit finalizer retains its own result and can use requireOutputs.
Use finalize: count to require and return one declared step’s output with its
exact type. For example, a string-producing load followed by a number-producing
count returns number with finalize: count, rather than the default
string | number | undefined. This is shorthand for
requireOutputs([count], ({ count }) => count) and does not change targets or
selection. See the precise result recipe.
Pipeline definitions can declare different downstream goals with step references:
definePipeline({
id: "publish",
steps: [build, validate, publish],
targets: [publish],
finalize: publish,
});
At call sites, targets accepts only those declared literal IDs. The planner
recursively selects each target’s required inputs and failure gates, while
leaving optional-only inputs out. pipeline.targetIds and CLI --target
discovery expose the same declared set. Internal steps are not public goals;
stepIds remains an exact filter for partial reruns and other advanced
workflows and does not add prerequisites. Empty selections, unknown,
undeclared, or duplicate IDs, and a run that supplies both fields fail during
planning.
Each planned step includes selectionReasons, which explains why it was
selected or omitted. A target plan can report a direct target, a
required-dependency, a failure-gate, an excluded optional-only input, or
an outside-target-closure omission. Exact filters use exact and
not-selected; unfiltered plans use all.
const plan = pipeline.plan({ targets: ["publish"] });
const load = plan.steps.find((step) => step.id === "load");
// A shared prerequisite can explain every path that selected it.
load?.selectionReasons;
// [{ kind: "required-dependency", dependentId: "build", targetId: "publish" }]
selected tells you whether selection includes the step. skipReason explains
why it will not run, such as filtered, dry-run, or unmet-dependency.
Use the structured plan directly in applications. For terminal or JSON output,
the workbench already renders the same data through tubeless plan.
Required final outputs
A finalizer may receive only some step outputs: dry runs, exact step filters,
and failures can leave others missing. Use finalize: step for one unchanged
output, or use
requireOutputs([stepA, stepB], callback) when a valid result needs those
outputs. It checks their presence and makes them required properties in the
callback. If an output is missing, finalization fails and reports its step ID.
A step that successfully returns undefined still produced an output; that
is different from a step that never supplied one.
Tubeless also checks declared targets, including the implicit last-step target,
against these requirements. Each target
must include the step selected by finalize: step, or all steps needed by
requireOutputs, either directly or through
its dependencies and failure gates. Use a plain finalizer when different
selected goals are allowed to return partial results. The default finalizer
reads only the last step in execution order; it never falls back to an earlier output.
If independent finalizer inputs are outside the implicit target closure, declare
a target that includes them or opt out of public targets with targets: [].
Step statuses and structured errors
A step moves through these states:
planned ──┬──> running ──┬──> complete
│ ├──> skipped
│ ├──> cancelled
│ └──> failed
├──> skipped
└──> cancelled
Hooks let your application respond to these changes. Use onStepStart,
onStepProgress, onStepComplete, onStepSkip, onStepCancel, or onStepFail
for a specific event; TypeScript narrows the callback data to that event.
Use onStepPlan to observe planning. Use onStepStatus when one listener
needs every status, for example to update a progress display. It runs in
addition to any specific hooks you register.
Each hook receives its own copy of lifecycle metadata, including nested plan,
progress, error, and report fields. Mutating that copy does not change execution,
traces, returned reports, or other hooks. The domain value supplied to
onFinalizeComplete and onPipelineComplete retains its original identity and
is not cloned or frozen; treat that value as read-only. Hook exceptions are logged
and do not stop subsequent hooks or fail the run.
A running status may be published repeatedly as progress changes. Every
selected step ends in exactly one terminal report; cancellation is distinct
from failure, including selected steps that were cancelled before they started.
const hooks: PipelineHooks = {
onStepProgress({ progress, step }) {
renderProgress(step.id, progress);
},
onStepSkip({ reason, step }) {
explainSkip(step.id, reason);
},
onStepFail({ error, step }) {
reportError(step.id, error);
},
onStepCancel({ error, step }) {
reportCancellation(step.id, error);
},
};
Every PipelineError has code, phase, and kind fields defined by
Tubeless. Branch on those fields rather than message text. phase identifies
where the error occurred (definition, planning, execution, or finalization); kind
distinguishes definition, selection, step, dependency, cancellation, child, and
finalization problems. If a thrown error supplied its own machine code, it is
retained separately as sourceCode. Native Error.cause chains are copied into
a bounded, cycle-safe PipelineErrorCause tree containing only message, name,
source code, and nested cause fields, so reports and trace events stay JSON-safe.
const result = await pipeline.run(options);
const first = result.errors[0];
if (first?.code === "TUBELESS_STEP_FAILED" && first.sourceCode === "ENOENT") {
// A step failed because its underlying operation could not find a file.
}
runOrThrow raises PipelineExecutionError with the full structured result on
result and the original thrown value on native cause. Its default message
identifies the pipeline, phase, package code, step, and deepest normalized cause.
PipelineDefinitionError uses the same diagnostic summary for every rejected
definition issue. Applications should branch on the structured fields and own
any domain-specific presentation.
PipelineStepReport describes a step’s final status. Successful reports carry
timing, skipped reports carry reason plus optional message and
dependencyId, and failed/cancelled reports carry a structured error.
Hooks and trace events use these same reports.
Versioned run records
run() returns one versioned PipelineRun<TResult>. Every execution has a
package-generated runId, terminal status, start and finish timestamps,
structured errors, and one terminal report per planned step. Pass
context.correlationId when an external orchestrator owns a reusable job or
workflow identifier. Pass context.parentRunId only when linking to another
Tubeless execution ID. The same identities are available in step contexts and
optional trace exports.
finalized is the result-presence discriminant. When it is true, TypeScript
narrows value to TResult; when it is false, value is undefined.
Finalization and terminal status are independent: a failed best-effort run may
still have a finalized partial value. A finalized TResult may itself be
undefined; finalized still distinguishes that result from no result.
const run = await pipeline.run(options, { continueOnError: true });
if (run.finalized) {
consume(run.value);
}
An actual step execution receives one attemptId. It appears on the
PipelineStepContext, its terminal PipelineStepReport, and trace lifecycle
records. Executed reports also carry start and finish timestamps. Structural
skips and steps cancelled before starting have neither an attempt ID nor a start
timestamp because their handlers never ran. context.reportAttempt() records retry progress inside that step execution;
it does not create a new attemptId.
The run report includes errors and cancellation details, but not a stream of
logs or progress updates. Use a logger and hooks for live updates, or a trace
exporter to record events for later inspection. A failing tracing.exporter does not fail the
run: the executor warns once on the first export or flush error for that emitter.
If a single exporter fails, later events are dropped. composeTraceExporters instead retires
a failed destination after reporting the first partial drop and keeps sending to
healthy destinations. Nested child runs each construct their own emitter, so
tracing.onExporterError fires once per nested run rather than once for the
whole parent tree. The run currently has version: 2, also exported
as RUN_MODEL_VERSION; persist that field and branch on it before decoding a
stored run. Projected StoredPipelineRun snapshots stamp the same version.
Durations are derived by subtracting the relevant timestamps.
const result = await pipeline.run(options, undefined, {
correlationId: externalJobId,
});
result.runId;
result.correlationId;
result.status;
result.steps.find((step) => step.attemptId)?.attemptId;
result.finishedAtMs - result.startedAtMs;
New trace events use the version 3 discriminated payload model. Every event carries
a unique execution runId and keeps reusable external correlation separate.
tubeless/tracing intentionally exposes only that typed event union, the exporter
interface, and exporter composition. Concrete JSON and telemetry SDK adapters
belong in the workbench or application integration; see the
tracing recipe. This narrow API does not weaken the
recording contract: existing version 2 NDJSON traces remain readable by
tubeless history and tubeless ui. Version 3 adds iteration relationships for
repeated child runs; their execution IDs remain unique.
Local event store and studio
Record events when you need to inspect runs after the process exits. The CLI
can write a SQLite database with --store, an NDJSON trace with --trace, or
both. NDJSON stores one JSON event per line. Use tubeless history to inspect
recordings in the terminal or tubeless ui to open them in a browser.
Recording is optional. Importing tubeless does not load SQLite or the UI.
Storage readers and projections belong to the workbench rather than the runtime
API. See the studio guide for recording, storage limits, and
browser controls.
Cancellation is determined from an abort error or a cancelled child run. If an unrelated error occurs at the same time as an abort, it remains a failure.
Dry runs
Every step has one normal run handler and an optional dry-run policy:
- Omit
dryRunwhen the normal handler is safe and useful during a dry run. - Set
dryRun: "skip"for filesystem writes, database mutation, publication, email, and other external side effects. The step is reported as structurally skipped and its required dependents do not run. - Provide a
dryRun(inputs, context)handler to substitute a side-effect-free preview. It must return the same output type asrun, so dependents can use the preview normally.
const publish = step("publish", {
dependsOn: [build],
dryRun: ({ build }) => ({ id: `preview:${build.id}` }),
run: ({ build }) => publishArtifact(build),
});
Dry run is not a rollback mechanism. Read-only validation and resolution steps may run normally so the preview remains useful.
Validated boundaries
TypeScript checks types at build time. To check values from files, requests, or
other external sources at runtime, use a validator that supports Standard
Schema V1. Tubeless accepts these schemas without importing a validation library. Pass a domain-options schema to
createSteps(schema), an outputSchema to an ordinary step, or a
resultSchema to definePipeline:
const { step } = createSteps(optionsSchema);
const parse = step("parse", {
outputSchema: parsedRowsSchema,
run: (_inputs, context) => readRows(context.options.source),
});
const pipeline = definePipeline({
id: "validated-import",
steps: [parse],
resultSchema,
finalize: requireOutputs([parse], ({ parse }) => summarize(parse)),
});
Schemas can validate and transform values. TypeScript infers the types on both sides of each validation step:
| Schema | Value it receives | Value available after validation |
|---|---|---|
| Options schema | The caller’s domain options | context.options in each step |
outputSchema |
The step’s return value, including custom dry-run and policy-skip values | Input to dependent steps |
resultSchema |
The finalizer’s return value | The final pipeline result |
Built-in run controls are passed separately from domain options, so strict
object schemas do not need fields for dryRun or targets. Child composition
uses the same boundary: mapOptions returns domain input and controls supplies
execution policy. Domain fields may therefore use names such as targets or
maxConcurrency without being removed before validation. Steps receive the
validated domain object without added control fields.
With no finalize, resultSchema validates the output of the last step in
execution order, including undefined when that output is absent. A schema may
therefore reject a dry run or filtered run. When the inferred union of possible
outputs cannot satisfy the schema input, supply a compatible finalizer. The successful result type remains the
schema’s output type.
Options are validated once after structural planning and before any step
starts. Step values are validated before publication, including values from a
custom dry-run handler or policy skip. Finalized values are validated before a
run is marked successful. Sync and async Standard Schema validators are both
supported during run; plan() stays synchronous and does not invoke schemas.
Failures retain normalized issue messages and paths on PipelineError.issues.
Use the stable codes TUBELESS_OPTIONS_VALIDATION_FAILED,
TUBELESS_STEP_OUTPUT_VALIDATION_FAILED, and
TUBELESS_FINAL_RESULT_VALIDATION_FAILED to distinguish boundaries. A step-output
failure is reported on that step; final-result failure uses
__finalize__. Pipeline definitions cannot mix steps from different
options-schema factory scopes.
Child pipelines
Use fromPipeline to run one reusable child pipeline. Use forEachPipeline to
run that child for each item in a list. The parent
plan contains one wrapper step; child activity appears as progress beneath it.
Read child-pipeline composition for option
mapping, selection, failures, and progress controls.
Add skip to either child-step definition when the entire child step may be
intentionally omitted. Its output can be undefined, which dependent code must handle.
Remote steps
Use fromRemote to call a service or execution engine from one step.
The parent pipeline still runs locally. The step’s remote.engine and optional
remote.target describe the destination for display and inspection.
Omitting a dry-run policy still contacts the service during a dry run. Mark
unsafe calls with dryRun: "skip", provide a local preview, or ensure the
remote service honors the dry-run flag. See
remote-step composition.
Mermaid diagrams
Call pipeline.toMermaid() to generate a static flowchart without run options
or execution. Nodes use name ?? id; generated internal node identifiers keep
arbitrary user-facing text out of Mermaid syntax. Required dependencies render
as solid arrows, while optional inputs and failure gates render as labeled
dotted arrows. Use direction to change layout and includeDescriptions to add
operational descriptions to node labels.
CLI commands
Put schema-backed pipelines in defineProject(id, pipelines), or export one directly
from a file, to give the CLI inferred arguments, validation, help, and progress
reporting without wrappers. Use definePipelineCommand for an advanced adapter. See
cli-job.ts for a complete example and
the CLI guide for all commands and flags.
Pipeline definitions may include an optional display name and description.
Commands inherit both fields, while the pipeline ID remains the stable identity for
project lookup, CLI selection, traces, and history. Command-level presentation wins
when a particular adapter needs different help text.
tubeless inspect, plan, and graph accept a pipeline or command export.
They load the module without executing step handlers or requiring business
inputs. If a module exports both a pipeline and a command, they prefer the
command. Keep module imports free of side effects.
For a direct file, tubeless run prefers an exported definePipelineCommand and
otherwise wraps one uniquely selected schema-backed pipeline automatically. Pipelines
in tubeless.project.ts use the same derivation rules and are addressable by pipeline
ID. Schema-less or unsupported input shapes require an explicit command adapter. A
pipeline can also be invoked from application code through run or runOrThrow. The project example
shows the advanced custom-adapter layout; the Studio guide shows both paths.
Map command arguments to pipeline inputs
Omit mapOptions when validated flags already have the same names and types
as the pipeline’s domain options. Supply it when inputs need to be renamed,
loaded from files, calculated, or prompted for. TypeScript requires the mapper
when the flag values alone cannot satisfy the pipeline’s required options.
Command-only values such as resume, stepIds, and targets are removed from
domain options before execution. Selection and failure controls are applied
separately. The command-line flags are --resume, --step, and --target;
option mappers and hooks use the parsed property names. --resume is present
only when the command configures managed checkpoint support or declares
application-owned handling with resume: true; other commands reject it and
omit it from their descriptors.
A command’s descriptor lists parameter names, descriptions, types, defaults,
choices, numeric constraints, repeatability, paths, and positional support.
Use it to build forms or other tools. Submit arguments through parse or run
to validate them; the descriptor only describes the parameters.
command.plan() accepts selection and dry-run controls without parsing domain
arguments, mapping options, executing steps, or recording a run. Use
command.plan() or tubeless plan; there is no command --plan flag.
Place CLI file-selection flags before -- and pipeline-command arguments after
it:
tubeless run --export PublishCommand ./scripts/publish.ts -- --source input.json --target publish
The CLI forwards SIGINT to the run’s abort signal and uses distinct exit codes
for validation, planning, execution, and cancellation errors. Pass --store
to record SQLite history or --trace to write an NDJSON file.
Runtime context
The step context supplies inputs and services controlled by the caller:
context.optionsfor validated domain options andcontext.dryRunfor the active mode.context.logto write messages through the active logger or reporter.context.signalto pass cancellation to I/O and other asynchronous work.context.sleepfor cancellation-aware, testable delays.context.reportProgressto update progress andcontext.reportAttemptto report retries.context.cwdas the base directory for relative paths.
Callers may inject the logger, clock, sleep function, signal, hooks, and tracing. This makes a pipeline embeddable and deterministic under test.
Deterministic testing
createPipelineTestRuntime from tubeless/testing supplies a test clock, a
logger that captures messages, and hooks that record status changes and
progress. Its default sleep advances the clock immediately. Its run,
runOrThrow, and plan methods call the real pipeline with this context,
so tests exercise the same execution logic as normal runs.
Create one runtime per test. Customize sleep when a test needs to pause,
interleave, or advance time differently; call test.abort() to exercise the
normal cancellation path. Use your test framework to assert on the results. Replace external I/O yourself;
the test runtime does not mock network or filesystem operations.
Supplying step outputs
Use overrideStep from tubeless/testing to supply a typed intermediate value
without modifying the pipeline definition:
const test = createPipelineTestRuntime();
const run = await test.run(pipeline, options, {
stepIds: ["load", "summarize"],
overrides: [overrideStep(load, ["alice", "bob"])],
});
Each override pairs an actual step reference with a resolved handler output.
For schema-backed steps, supply the schema input type: outputSchema still
validates and transforms it, and dependents receive the transformed output.
Invalid values fail with TUBELESS_STEP_OUTPUT_VALIDATION_FAILED. An explicit
undefined is a published value when the step’s type and schema allow it.
Functions can be domain values; they are never called as lazy factories.
Await asynchronous fixture setup before constructing overrides.
A selected overridden step never calls its run, skip, or custom dryRun
handler. It bypasses required-input and failure-gate checks and dryRun: "skip",
since the supplied value replaces its entire computation. Overriding a child,
fan-out, or remote wrapper also bypasses item enumeration, mappings, adapter
calls, and child launches. Overrides belong to this run only and do not flow
into children. Duplicate overrides and foreign references (including same-named
steps from another graph) reject before execution. Custom pipeline wrappers
are unsupported; use the object returned by definePipeline.
Selection and scheduling remain explicit:
- Overrides do not select steps, prune ancestors, or change target closure.
A filtered step stays filtered and does not validate or publish its override.
To isolate a downstream branch, use exact
stepIdsincluding the supplied intermediate and its dependents, excluding upstream I/O. Plans describe the ordinary graph and do not apply overrides. - Scheduling still waits for all prerequisite steps to reach terminal states.
Override validation occupies a concurrency slot. Fail-fast can prevent an
override from starting;
continueOnErrorallows it to publish after upstream failure, while the run retains that failure. - Cancellation prevents unstarted overrides and is checked again after async output validation. Successfully overridden steps unlock required dependents and failure gates and supply optional inputs normally.
- Finalization and
resultSchemause the supplied outputs through the usual boundaries, including required finalizers and the default last-step result.
An override uses the normal lifecycle: running, then completed, failed,
or cancelled. From the start of its validation attempt, reports and hooks
carry outputSource: "override". The same metadata survives validation failure
or cancellation. Successful overrides emit onStepComplete, so tests exercise
the same completion callbacks and dependency rules as handler-produced outputs.
There is no separate override status or hook.
Traces use the existing step.running, step.complete, step.failed, and
step.cancelled events with payload.outputSource: "override"; they do not
record the supplied value. Both CLI reporters and Studio show the ordinary
status with an override annotation, such as completed (overridden) or
failed (overridden). Existing recordings remain readable. Ordinary runs omit
this metadata, as do filtered overrides and overrides cancelled before their
validation attempt starts; absence does not prove a handler ran.
Overrides are available only through the test runtime’s run and runOrThrow.
Ordinary pipeline controls, CLI flags, and Studio launch forms do not expose
them, and trace recordings cannot automatically be replayed as override values.
See the executable override recipe.
Implementation guidelines
- Keep step IDs stable, use
nameonly for a friendlier display label, and keep descriptions operationally useful. - Generate documentation from
pipeline.toMermaid()instead of hand-maintaining a second copy of the graph. - Return small domain results; inspect
run()reports for execution metadata. - Put remote API mechanics in retry/rate-limit helpers, not in the executor.
- Put CLI parsing at the script edge with
definePipelineCommand. - Keep application-owned telemetry SDKs outside the dependency-free core.