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.startedis always first andrun.finishedalways 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.finishedorstage.skipped, never both. - Under
-j, events from different stages interleave. Order within a stage holds. - A retried stage emits
step.started/step.finishedonce per attempt, then a singlestage.finishedcarrying the total inattempts.
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
| Field | Type |
|---|---|
plan | ExecutionPlan |
at | number |
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
| Field | Type |
|---|---|
status | RunStatus |
exitCode | number |
durationMs | number |
at | number |
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
| Field | Type |
|---|---|
stageId | string |
name | string |
at | number |
stage.skipped
| Field | Type |
|---|---|
stageId | string |
reason | SkipReason |
at | number |
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
| Field | Type |
|---|---|
stageId | string |
status | StageStatus |
durationMs | number |
attempts | number |
at | number |
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
| Field | Type |
|---|---|
stageId | string |
stepId | string |
display | string |
at | number |
display is the human-readable command — argv joined, or the raw shell string. It's what
the terminal prints after $.
step.output
| Field | Type |
|---|---|
stageId | string |
stepId | string |
stream | OutputStream |
chunk | string |
at | number |
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
| Field | Type |
|---|---|
stageId | string |
stepId | string |
status | StepStatus |
exitCode | number | null |
durationMs | number |
at | number |
exitCode is null when the step never produced one — killed by a timeout, or cancelled.
cache.restored
| Field | Type |
|---|---|
stageId | string |
path | string |
key | string |
hit | boolean |
at | number |
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
| Field | Type |
|---|---|
stageId | string |
path | string |
key | string |
at | number |
stash.saved
| Field | Type |
|---|---|
stageId | string |
name | string |
at | number |
stash.restored
| Field | Type |
|---|---|
stageId | string |
name | string |
hit | boolean |
at | number |
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
| Event | pretty / ci | github | gitlab | junit |
|---|---|---|---|---|
run.started | header | header | header | ✓ |
run.finished | ✓ | ✓ | ✓ | ✓ |
stage.started | ✓ | group | section | — |
stage.skipped | ✓ | notice | ✓ | ✓ |
stage.finished | ✓ | ✓ | ✓ | ✓ |
step.started | ✓ | ✓ | ✓ | ✓ |
step.output | ✓ | ✓ | ✓ | — |
step.finished | footer | ✓ | ✓ | ✓ |
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.