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.startedgroupsection
stage.skippednotice
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.