Skip to main content

Events

Execution emits one ordered stream of typed events. Everything else — the terminal renderer, the JUnit writer, run history, the CI reporters, the web UI — is a subscriber. None of them drives execution; they all watch the same stream.

--reporter json hands you that stream verbatim.

The wire format​

Newline-delimited JSON: one event per line, no envelope, no wrapper. Fields are the event's own, unrenamed. A new event variant is additive, so parse defensively and ignore types you don't know.

flow run --reporter json
{"type":"run.started","plan":{ … },"at":1785132186586}
{"type":"stage.started","stageId":"build","name":"build","at":1785132186587}
{"type":"cache.restored","stageId":"build","path":"node_modules","key":"deps-v1","hit":false,"at":1785132186587}
{"type":"step.started","stageId":"build","stepId":"build:0","display":"echo compiled","at":1785132186588}
{"type":"step.output","stageId":"build","stepId":"build:0","stream":"stdout","chunk":"[08:03:06] compiled\n","at":1785132186592}
{"type":"step.finished","stageId":"build","stepId":"build:0","status":"success","exitCode":0,"durationMs":5,"at":1785132186593}
{"type":"cache.saved","stageId":"build","path":"node_modules","key":"deps-v1","at":1785132186593}
{"type":"stash.saved","stageId":"build","name":"dist","at":1785132186596}
{"type":"stage.finished","stageId":"build","status":"success","durationMs":9,"attempts":1,"at":1785132186596}
{"type":"run.finished","status":"success","exitCode":0,"durationMs":16,"at":1785132186602}

Every event carries at, a millisecond epoch timestamp. Every event except the two run.* ones carries stageId, so everything joins back to the plan.

Guarantees​

  • run.started is always first and run.finished always last — including on failure and on cancellation. A cancelled run still gets its terminal event, which is why a cancelled CI job still produces a JUnit file.
  • A stage produces either stage.finished or stage.skipped, never both.
  • Under -j, events from different stages interleave. Order within a stage holds.
  • A retried stage emits step.started/step.finished once per attempt, then a single stage.finished carrying the total in attempts.

Shared field types​

RunStatus = "success" | "failed" | "cancelled";
StageStatus = "success" | "failed" | "skipped" | "cancelled";
StepStatus = "success" | "failed" | "cancelled";
OutputStream = "stdout" | "stderr";
SkipReason = "when" | "upstream-failed" | "cancelled";

The events​

run.started​

FieldType
planExecutionPlan
atnumber

First event of every run. Carries the whole execution plan, so a subscriber that joins at the start needs nothing else to render the graph.

run.finished​

FieldType
statusRunStatus
exitCodenumber
durationMsnumber
atnumber

Last event, emitted on every outcome including cancellation. durationMs is wall clock for the whole run — under -j it is less than the sum of the stage durations.

stage.started​

FieldType
stageIdstring
namestring
atnumber

stage.skipped​

FieldType
stageIdstring
reasonSkipReason
atnumber

when — the stage's condition evaluated false. upstream-failed — something it needed failed. cancelled — the run was aborted before this stage got to run. A skipped stage never emits stage.finished.

stage.finished​

FieldType
stageIdstring
statusStageStatus
durationMsnumber
attemptsnumber
atnumber

attempts can be 0. Normally it's >= 1, but a stage that failed before its first attempt — credential resolution failed, or its service containers wouldn't start — reports 0, because no command ever ran. Don't assume attempts - 1 is the retry count without checking for zero.

step.started​

FieldType
stageIdstring
stepIdstring
displaystring
atnumber

display is the human-readable command — argv joined, or the raw shell string. It's what the terminal prints after $.

step.output​

FieldType
stageIdstring
stepIdstring
streamOutputStream
chunkstring
atnumber

A chunk of output, already line-timestamped. Chunks are not guaranteed to be whole lines, though a final unterminated line is flushed when the step ends.

Service container logs arrive as step.output too, under a synthetic step id formatted "<stageId>:services" on the stderr stream. There is no other marker — if you're attributing output to real steps, filter that suffix.

step.finished​

FieldType
stageIdstring
stepIdstring
statusStepStatus
exitCodenumber | null
durationMsnumber
atnumber

exitCode is null when the step never produced one — killed by a timeout, or cancelled.

cache.restored​

FieldType
stageIdstring
pathstring
keystring
hitboolean
atnumber

Emitted on a miss as well as a hit — hit: false means the key wasn't found, not that nothing happened. See Artifacts, cache and stash.

cache.saved​

FieldType
stageIdstring
pathstring
keystring
atnumber

stash.saved​

FieldType
stageIdstring
namestring
atnumber

stash.restored​

FieldType
stageIdstring
namestring
hitboolean
atnumber

hit: false means the named stash didn't exist. Like a cache miss, that isn't an error on its own.

Which reporters show what​

Eventpretty / cigithubgitlabjunit
run.startedheaderheaderheader✓
run.finished✓✓✓✓
stage.started✓groupsection—
stage.skipped✓notice✓✓
stage.finished✓✓✓✓
step.started✓✓✓✓
step.output✓✓✓—
step.finishedfooter✓✓✓
cache.*✓✓✓—
stash.*✓✓✓—

JUnit has no representation for cache or stash, and its test cases are per stage rather than per step, so it consumes a deliberate subset. Everything else handles all twelve.

Writing a subscriber​

The union is exhaustive and each reporter switches over it with a never check, so adding a variant breaks the build rather than silently vanishing from a reporter. Do the same in your own consumer:

import type { ExecEvent } from "@flowwright/core";

function subscribe(e: ExecEvent): void {
switch (e.type) {
case "stage.finished":
// …
return;
// …
default: {
const never: never = e;
return never;
}
}
}

A default: return compiles today and quietly drops whatever gets added tomorrow. That exact pattern is how the GitLab reporter lost three event types.