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.started carries span_id, parent_span_id, trace_flags and tracestate.
  • step.started, step.finished and the handler.* events carry their own span_id and parent_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.

Resource usage

step.finished may carry a usage object for the attempt that concluded:

{"seq":9,"type":"step.finished","step":"test","payload":{"state":"succeeded","usage":{"cpu_user_ns":812000000,"cpu_system_ns":96000000,"max_rss_bytes":52428800}}}

cpu_user_ns and cpu_system_ns are CPU time in nanoseconds; max_rss_bytes is peak resident memory. The local executor reports them from rusage and the container executor from the daemon’s stats stream, so a container that exits before the daemon’s first one-second sample may report nothing. Kubernetes and SSH report nothing, and then usage is absent. A client folding the stream sets StepState.Usage on step.finished and clears it on step.retried, so it always describes the attempt that concluded.

Types sent today

All thirty-seven, from api.DeclaredTypes():

run.started              run.finished
plan.resolved            plan.expanded            plan.expansion_skipped
plan.generated
step.created             step.started             step.finished
step.retried             step.log.appended
test.finished            service.ready
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.
  • test.finished: one test’s verdict for one attempt of a step that declares Tests, sent once per test and always before that attempt’s step.finished. It carries the test’s name and suite, an outcome of passed, failed or skipped, its duration_ns, a file and line when the report has them, and, for a failed test only, the redacted last 4096 bytes of its output. A step that declares no Tests emits none. Apply folds them into StepState.Tests, and a test that passes on a later attempt after failing becomes recovered there. That fourth outcome exists only in the fold, never on the wire, because no single event sees both attempts.
  • service.ready: a service step’s readiness probe passed, so the steps that need it may start. Step-scoped, once per attempt that becomes ready, and always before any consumer’s step.started. Its payload is endpoints, each a declared port and the address it is published on (127.0.0.1:54321), and duration_ns, how long it took to become ready. The service keeps running: it has no step.finished until it is stopped or dies. Which steps are services is on step.created, as "service":true. Apply folds the event into StepState.Service, and step.created’s flag into StepState.IsService.
  • 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 a step.started or step.finished. Clients show it as StepState.Paused. See Control operations. Under --break-on-failure it also fires for a step held after a failed attempt, with "reason":"failure" in its payload; an armed breakpoint has no reason. Apply copies it to StepState.Held, so a client can offer a shell rather than a resume. See The shell.
  • shell.opened / shell.closed: mark the start and end of an interactive session on a step’s workspaces (senro shell, or the TUI’s s key). See The shell.
  • ws.evicted: a persistent workspace was cleared out because it went unused past its MaxAge, or grew past its MaxSize. 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. Its reused field tells you whether that copy was already there: on SSH, watch for false on every step, which means you’re paying for a fresh transfer each time instead of once per host. On the container executor reused is always true, since the binary is mounted straight from the coordinator.

Reserved names

api.Type.Known() also recognizes two names reserved for future use:

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, binary.staged and plan.generated 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. Apply doesn’t detect missing events; check sequence numbers yourself if you need that.
  • An unknown Type is ignored, and so is an unknown field inside a payload.
  • A malformed payload on a type Apply does 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.