ADR-053 — Unified, store-agnostic run streaming (events + logs, local + cloud)
Status: accepted (2026-07-03; amended same day — see Amendment). The immediate asymmetry that motivated it is already fixed (EnsureLogSource, commit d4f12e39d — see Context); this ADR records the target architecture and the migration so the next streaming change lands on one seam instead of the current four.
Context
The studio's run console streams two byte-streams to the browser over the run WebSocket: the structured event timeline (events.jsonl) and the raw log (run.log). Today there are four different wirings for getting those bytes to a subscriber, chosen by how the run was produced rather than by a single contract:
- In-process launch (studio/CLI
Launch) — the runtime observer feeds the broker directly and the per-run logger tees into aRunLogBuffer(pkg/runview/service_launch.go, service_log.go). - Detached subprocess (managed runner) — the subprocess owns the files; the parent tails them with fsnotify (
startEventSource+startLogSource, file_event_source.go, file_log_source.go). - External / dispatcher run, not produced in this process — events got an on-demand fsnotify tailer via
EnsureEventSource(service_eventsource.go); logs had no equivalent, sohandleSubscribeLogstreated a nil live buffer as "terminated" and did a one-shot replay ofrun.log. Result: an active external run's logs never streamed — the studio needed a full page refresh to see new lines. Fixed byEnsureLogSource, the run.log twin ofEnsureEventSource(commitd4f12e39d): on subscribe, if the run is active and not in-process, stand up a refcounted in-memory buffer + fsnotify tailer. - Cloud — runs execute on runner pods pulling from the NATS queue and writing to the Mongo store; the server pod shares no filesystem, so fsnotify (which all of 1–3 ultimately rely on) does not apply. Events already stream via a Mongo change-stream source (pkg/runview/runstream, wired in
cmd/iterion/server.go); logs have no cloud path at all — the runner logs to pod stderr and the Mongo store persists no run.log.
A fifth wiring hides in the WS layer itself: cross-store (?store=, observing a run owned by another local daemon) re-implements the fsnotify tailers a third time inside pkg/server (tailCrossStoreEvents / streamLogsCrossStore).
Structural problems: (a) fsnotify is filesystem-only, so cloud can never reuse the local tailers; (b) events and logs drifted apart (events had the on-demand path, logs didn't — the #3 bug; events have a cloud path, logs don't), because there is no single "subscribe to this run's stream" seam that both go through.
Decision
Introduce one store-agnostic streaming source behind a small per-store interface, and route both events and logs, in all production modes (including cross-store), through it.
// pkg/runview/runstream
type Source interface {
// Persisted replay + live tail, gap-free, at-least-once, batched.
SubscribeEvents(ctx context.Context, runID string, fromSeq int64) (EventSubscription, error)
// Offset-tagged log bytes from fromOffset; Chunks() closing = stream over.
SubscribeLogs(ctx context.Context, runID string, fromOffset int64) (LogSubscription, error)
Capabilities() Capabilities // {LiveTail, HistoricalRange, Logs}
Close() error
}Key contract points:
- Replay lives inside the subscription. "Deliver everything ≥ fromSeq/fromOffset, no gap, then tail live" is the source's job, so the WS handler does
snapshot → Subscribe(effectiveFrom) → pumpidentically in every mode. Dedup guards (event seq high-water mark + the unpersistedEventAlertSeq=0 bypass; log offset cutoff slicing) move inside the filesystem subscription — the same mitigations as before, relocated to the only place double delivery can occur. - Backends, selected by the store the run lives in:
- Filesystem store (local) → the Service's broker/
RunLogBuffermachinery becomes the internal fan-out of this backend; the existing fsnotify tailers (with polling fallback) are its feeders. The refcountedensureEventSource/ensureLogSourceattach points become backend internals whose release is folded intoSubscription.Close(). - Cross-store (foreign local root) → the same filesystem tailers, parametrized by store root (
runstream.FileSource), with a run.json terminal poll (a foreign run has no in-process completion signal). The third tailer copy inpkg/serveris deleted. - Mongo store (cloud) → change streams, symmetric for both flows: the existing events change-stream source moves under this interface, and logs gain a twin — a new append-only
run_logschunk collection ({tenant_id, run_id, offset, data, ts}, unique(run_id, offset), TTL shared with events) written by the runner through a batching tee on its per-run logger, tailed by the server pod via a change stream on inserts.
- Filesystem store (local) → the Service's broker/
- In-process
Launchkeeps feeding the broker/buffer directly (fast path); detached runs keep their eager tailers (run-health alerts consume the event flow even with zero WS subscribers). A subscriber never branches on "did this process launch it?". - Tenancy (cloud): subscriptions are per-subscriber and scope replay + change-stream by the caller's tenant ctx (
store.TenantFromContext); authz happens before subscribe (tenant-filtered run load at WS upgrade). Refcount-sharing exists only in the filesystem backend, which is single-tenant by construction. - Log persistence API: a new optional store interface (same pattern as
PIDStore/SpendStore) —RunLogStore{AppendRunLog, ReadRunLogRange, RunLogSize}— implemented by the filesystem store overrun.log(becoming the one read path for the scattered direct-file readers) and by the Mongo store overrun_logs. The runner seeds its offset counter fromRunLogSizeat claim time (resume/redelivery-safe; the unique index is the race safety net). Runner-side write failures degrade loudly (bounded retry, then drop the batch with an ERROR log + counter) but never block or kill the run: the log stream is a derived observability view, not run correctness — the degradation is explicit, never silent. Event.LogOffset(the event↔log-position correlation behind the per-node Logs tab) gets stamped in cloud too: the Mongo store gains the sameSetLogPositionFnhook as the filesystem store, fed by the runner's log writer total.
The immediate EnsureLogSource fix was the first step of this convergence (logs symmetric with events on the filesystem backend). The remaining work: (a) lift the tailers + the events change-stream source behind runstream.Source, (b) add run_logs persistence + the Mongo log source, (c) delete the mode-specific branches in handleSubscribe / handleSubscribeLogs and the duplicated cross-store tailers.
Amendment (2026-07-03) — change streams, not the eventbus
The original proposal routed the cloud backend over pkg/eventbus (NATSBus on the ITERION_EVENTS stream). Implementation review reversed that choice in favour of Mongo change streams:
NATSBusdoes not exist. OnlyInProcBusis implemented; theITERION_EVENTSstream constants are declared but never provisioned or used. The change-stream tailer, listed below as the alternative, already exists and works for cloud events.- Wrong delivery semantics. The eventbus is a deliberately lossy notification spine carrying
trigger.Event(ADR-046). Run streaming needs lossless resume — which means the store must persist log chunks anyway for backfill, at which point the bus would be a second delivery path to keep consistent with the first. - Store-derived streaming keeps one source of truth. The persisted
(run_id, seq)/(run_id, offset)anchors are exactly what the wire protocol already exposes (from_seq/from_offset); change streams give lossless live delivery from the same data the replay reads, with no new infrastructure (a replica set is already required).
Alternatives considered
- Keep the four wirings, fix bugs case-by-case. Rejected — the #3 log bug was invisible for exactly this reason; the next mode (or the next stream) will grow its own gap.
- Ride
pkg/eventbus(NATSBus onITERION_EVENTS) for cloud — the original decision. Rejected on implementation review; see Amendment. - Per-run
RunStreamSource{Events(),Logs(),Close()}handles (the original interface sketch). Superseded by the per-storeSourcewith per-stream subscribe methods: it matches the existing events source shape, avoids a handle object per (run, subscriber), and keeps refcounting a filesystem-backend internal instead of part of the contract. - Poll the store everywhere (drop fsnotify). Simpler but strictly worse latency locally, and wasteful at scale — the local fsnotify fast path is worth keeping behind the interface.
Consequences
- One seam to reason about; a new stream (e.g. a metrics channel) or a new store backend is one implementation, not four.
- Cloud gets true live streaming (events + logs) without a filesystem, closing the local/cloud parity gap — including per-node log slicing via
Event.LogOffset. - ~550 lines of duplicated cross-store tailers in
pkg/serverare deleted; cross-storeunsubscribe_logs(previously not honoured) works for free. - Migration is incremental and non-breaking:
EnsureLogSourcealready shipped; the interface, therun_logspersistence and the Mongo log source land behind it without changing the WS wire protocol (log_chunk/ event envelopes are unchanged, pinned by the contract tests inpkg/server/runs_ws_logs_test.go). - Risk: double-delivery if a mode both tees directly and attaches a source — mitigated by the seq/offset dedup that moves inside the filesystem subscriptions (the same guard that made
EnsureEventSourcesafe to over-subscribe).
