The event stream
Every observable fact about a run is one api.Event, appended to an ordered, append-only stream.
This page covers the envelope, the event types senro sends today, and Apply, the function that
turns the stream into something a client can render. The types live in
github.com/xavidop/senro/api.
Every view of a run reads this one stream:
flowchart LR Engine["engine"] -->|"api.Event"| Stream["append-only stream"] Stream --> TUI["senro attach (TUI)"] Stream --> UI["senro ui (browser)"] Stream --> Disk["events.jsonl on disk"] Disk --> Replay["offline replay"]
The envelope
type Event struct {
V int `json:"v"`
Seq uint64 `json:"seq"`
TS time.Time `json:"ts"`
Type Type `json:"type"`
Run string `json:"run,omitempty"`
Step string `json:"step,omitempty"` // stable base ID, never "id@2"
Attempt int `json:"attempt,omitempty"` // 0 when not step-scoped
Group string `json:"group,omitempty"` // expansion parent, for aggregation
TraceID string `json:"trace_id,omitempty"` // W3C trace, identical on every event
Payload json.RawMessage `json:"payload,omitempty"`
}
The routing fields (Type, Step, Attempt, Group) sit outside Payload so you can filter
events without decoding the payload first. (Event).Decode(v any) unmarshals Payload into a
typed struct; it’s safe to call even when there’s no payload, so just call it every time.
The trace
TraceID identifies the W3C Trace Context trace the run
belongs to: 32 lowercase hex characters, the same on every event of a run, and different for every
run.
If the run was started by a CI job or a webhook delivery that carried a traceparent header, the
run joins that job’s trace instead of starting its own.
The span details live in the payloads, not in the envelope, because they change per event:
run.startedcarriesspan_id,parent_span_id,trace_flagsandtracestate.step.started,step.finishedand thehandler.*events carry their ownspan_idandparent_span_id.
The same trace context is also passed into every step’s command as environment variables, so a traced tool running inside a step joins the run’s trace. See Writing a trace exporter for how this turns into spans.
Types sent today
All thirty-four, from api.DeclaredTypes():
run.started run.finished
plan.resolved plan.expanded plan.expansion_skipped
step.created step.started step.finished
step.retried step.log.appended
cache.hit cache.miss cache.saved
cache.degraded
ws.snapshot ws.restored ws.evicted
binary.staged
secret.resolved secret.redacted
control.applied breakpoint.hit
handler.started handler.succeeded handler.failed
handler.superseded
shell.opened shell.closed
notify.delivered notify.failed notify.dropped
analysis.proposed analysis.applied analysis.rejected
Some only show up in certain situations:
notify.*: the outcome of one outbound notification. A run with no notifier configured emits none. See Notifications.cache.degraded: a shared cache stopped working (unreachable, bad credentials, or an unexpected response) and the run kept going anyway. It’s not a failure or a miss, just slower than it should have been. See Shared cache.breakpoint.hit: fires once, when the scheduler first holds back a step that has a breakpoint set on it. It’s the only way to tell “stopped on purpose” apart from “hung,” since a held step never gets astep.startedorstep.finished. Clients show it asStepState.Paused. See Control operations.shell.opened/shell.closed: mark the start and end of an interactive session on a step’s workspaces (senro shell, or the TUI’sskey). See The shell.ws.evicted: a persistent workspace was cleared out because it went unused past itsMaxAge, or grew past itsMaxSize. The event carries the measurement and which limit it hit.binary.staged: senro copied its own binary onto a target host so a func step off the coordinator can run there. Itsreusedfield tells you whether that copy was already there: on SSH, watch forfalseon every step, which means you’re paying for a fresh transfer each time instead of once per host. On the container executorreusedis alwaystrue, since the binary is mounted straight from the coordinator.
Reserved names
api.Type.Known() also recognizes a few names reserved for future use:
plan.generated
client.attached client.detached
These exist so that when the matching feature ships, adding the event is a compatible change for
existing clients rather than one they need to update for. breakpoint.hit, shell.opened,
shell.closed and binary.staged all went through this before their features landed.
Clients should ignore types they don’t recognize. A newer engine may emit event types this build has never seen. Skip them instead of erroring, so your client keeps working across versions.
Turning events into RunState
func (s *RunState) Apply(e Event) error
Apply takes each event, one at a time, and updates RunState with it: the run’s status, every
step’s state, every expansion, every handler. A renderer never has to replay the raw stream
itself; it just keeps calling Apply. Its rules:
- A sequence number that goes backwards is an error:
api: out-of-order event: seq 4 after 7. - The same sequence number twice is fine. If a client resumes one event early, replaying an event it already applied doesn’t change anything.
- A forward gap is accepted silently.
Applydoesn’t detect missing events; check sequence numbers yourself if you need that. - An unknown
Typeis ignored, and so is an unknown field inside a payload. - A malformed payload on a type
Applydoes recognize is an error, not something it skips.
The same function backs every client: the attach server’s own state, the terminal UI, offline
replay, and the browser UI. They all call Apply, so they can’t disagree
about what a stream means.
Attach is a different layer
This page covers the event stream: what’s written to disk and turned into RunState. Attach
is the live protocol a second process uses to talk to a running engine: sending commands,
subscribing to events, and resuming a stream after a disconnect. See Attach, and
Step states for what a step’s final State means.