Skip to main content

Execution plan

pipeline.ts is not what runs. Running it once — the plan pass — produces a JSON document, and everything after that consumes only the JSON. That document is the execution plan, or IR. See plan time vs exec time for why it works that way; this page is the field list.

Print yours with:

flow explain --json

Versioning

export const IR_VERSION = 2;

The version bumps only on a breaking change — a removed or renamed field, or changed semantics. Optional additive fields don't bump it. v2 added the third step kind (write), which is breaking because a v1-only runtime cannot execute one.

A reader rejects a plan only for being newer than it understands:

plan version 3 is newer than supported (2) — produced by a newer FlowWright

There is no lower bound. An older plan is accepted if its shape still validates.

ExecutionPlan

FieldTypeNotes
version2Required
namestringRequired, non-empty
stagesIRStage[]Required
concurrencynumber?Positive integer. Absent or 1 = strictly sequential
postobject?{ always?, success?, failure? }, each an IRStage

post handlers are synthetic stages that reuse the stage machinery but sit outside the needs graph — their needs must be [], and the validator enforces it. always runs on every outcome; success and failure only on theirs.

IRStage

FieldTypeNotes
idstringUnique in the plan, matches ^[a-z0-9][a-z0-9-]*$
namestringHuman label; the id is the slug of it unless set explicitly
needsstring[]Stage ids. [] = no dependencies. Must be acyclic
containerstring?Image for this stage's steps. Absent = run on the host
servicesIRService[]?Sidecars. Absent, not [], when none
credentialsIRCredentialRef[]?References only. Absent, not [], when none
whenWhenDescriptorRequired
timeoutstring | nullDuration string, e.g. "10m". Required — null means unbounded
continueOnErrorbooleanRequired
retriesnumberRequired, >= 0
stepsIRStep[]Required
producesstring[]Declared artifact paths
cacheKeysIRCacheKey[]Required (may be [])
stashesIRStash[]?Absent, not [], when none
dynamicbooleanAlways false — see below

Three optional arrays are omitted rather than emptied. services, credentials and stashes are absent when the stage declares none, which keeps plans byte-stable: adding the feature to the engine doesn't change the serialization of a plan that doesn't use it. Read them as "absent or non-empty", never as "always an array".

dynamic is always false. It's a compatibility field, not something you control. A stage body that throws now fails the build instead of recording a partial stage, but removing the field would have forced IR_VERSION = 3 and a migration for stored plans.

Nested types

WhenDescriptor

| { kind: "always" }
| { kind: "branch"; equals: string }
| { kind: "tag"; matches: string }
| { kind: "dynamic"; source?: string }

The first three are fully explainable — flow explain prints the condition. dynamic is an opaque closure evaluated at run time, and explain says so rather than guessing.

IRStep

Three variants, distinguished by shell and cmd:

Kindshellcmdrawwrite
execfalsestring[]nullabsent
shelltruenullstringabsent
writefalsenullnullobject

All three also carry id (formatted "<stageId>:<index>"), cwd, env and timeout. Exec is the safe default — argv goes straight to the executor with no shell. A shell step comes from sh.raw() or a plain dynamic string and carries quoting and injection risk. A write step materializes bytes directly, so its content never passes through a shell; write is { path, content, mode? }.

Mixing those columns is the single largest source of step_inconsistent errors.

IRCacheKey

{ op: "restore" | "save", path, key, resolvedKey }

key preserves the template as you wrote it, so flow explain stays readable. resolvedKey is the content-addressed key computed during the plan pass, or null when it depends on a runtime value — in which case the runtime falls back to key.

IRStash

{ op: "stash" | "unstash", name, includes?, excludes? }

Like cacheKeys, a stage-boundary operation: unstash runs before the steps, stash after they succeed. Unlike cache, a stash is run-scoped and deleted when the run ends. includes/excludes apply to stash only.

IRService

{ name, image, env?, args? }

name matches ^[a-z0-9][a-z0-9-]*$ and doubles as the hostname on the stage-scoped network, so a step reaches it at http://<name>.

IRCredentialRef

{ id, type, binding }, where binding is one of:

| { kind: "env"; name: string }
| { kind: "file"; pathVar: string }
| { kind: "usernamePassword"; userEnv: string; passEnv: string }
| { kind: "sshKey"; pathVar: string; passphraseEnv?: string }

The plan carries references and binding hints only — never values, encrypted blobs, provider tokens, file contents, or resolved paths. The runtime resolves each value just-in-time, immediately before the declaring stage. A plan is safe to log and store. Adding a binding kind is additive and does not bump IR_VERSION.

A real plan

From the pipeline on Authoring pipelines, truncated to one stage:

{
"version": 2,
"name": "acme-api",
"stages": [
{
"id": "install",
"name": "install",
"needs": [],
"when": { "kind": "always" },
"timeout": null,
"continueOnError": false,
"retries": 0,
"steps": [
{
"id": "install:0",
"shell": true,
"cmd": null,
"raw": "npm ci",
"cwd": ".",
"env": {},
"timeout": null
}
],
"produces": [],
"cacheKeys": [
{ "op": "restore", "path": "node_modules", "key": "deps-v1", "resolvedKey": null },
{ "op": "save", "path": "node_modules", "key": "deps-v1", "resolvedKey": null }
],
"dynamic": false
}
]
}

Note what's not there: no services, credentials or stashes key at all, because this stage declares none.

The human view is a summary

flow explain without --json prints stages, dependencies, conditions, steps, services, artifacts, caches and stashes — but not retries, timeout, step env/cwd, or the credential list. That's deliberate; it's a picture of the graph, not a dump.

--json is the complete IR. If a field isn't in the human view, it's in the JSON.

Both forms validate the plan first and exit 1 if it's invalid, so flow explain --json never emits a plan that wouldn't run. See Validation errors.