Realtime
Tail a run's status transitions and logs live with runs.watch over a durable per-run timeline - no polling, no extra infrastructure.
Every run has a durable timeline: an append-only, ordered record of its status transitions and
ctx.log lines. runs.watch streams that timeline live, so you can
follow a run as it executes instead of polling GET /runs/{id} in a loop.
Watching a run
runs.watch(id) is an async generator of timeline frames. The stream replays the run's history on
connect, tails new frames as they happen, and ends on its own when the run reaches a terminal state:
import { createClient } from "@durablex/sdk/client";
const dx = createClient({ engineUrl: process.env.DURABLEX_ENGINE_URL! });
for await (const frame of dx.runs.watch(runId)) {
switch (frame.kind) {
case "run_status":
console.log("run ->", frame.status);
break;
case "step_status":
console.log(" step", frame.name, "->", frame.status);
break;
case "log":
console.log(" log", frame.level, frame.message);
break;
}
}A frame is a discriminated union on kind, so narrowing on kind gives you the right fields:
kind | Fields beyond seq / ts / runId |
|---|---|
run_status | status, currentStepName?, attempt? |
step_status | name, status, attempt, index?, error? |
log | level, message, fields?, scope, attempt |
Reading log history
runs.watch replays a run's whole timeline on connect, so for a live view you rarely need anything
else. To read just the persisted ctx.log lines without opening a stream - for a one-shot dump, a
report, or paging through a long run - use runs.logs:
const lines = await dx.runs.logs(runId); // oldest first
const more = await dx.runs.logs(runId, { from: lines.at(-1)?.seq, limit: 200 });Each line carries seq, ts, level, message, fields?, scope, and attempt. Pass the last
seq you saw as from to page forward. This is the same data the log timeline frames carry, served
as plain history from GET /runs/{id}/logs.
The per-run timeline
Status transitions and log lines append to one stream, ordered by a per-run seq. That single cursor
is all any consumer needs: one query tails the whole activity feed of a run, rather than stitching
together separate status, step, and log sources. Each transition's timeline row is written in the same
transaction as the status change, so the stream never disagrees with the run.
Lossless reconnect
Every frame carries a monotonic seq. If a connection drops, remember the last seq you saw and
resume past it:
let last = 0;
for await (const frame of dx.runs.watch(runId, { from: last })) {
last = frame.seq;
// ...handle frame
}{ from } replays strictly after that seq, so a reconnect picks up exactly where it left off - no
gaps, no duplicates.
How it streams
runs.watch opens a Server-Sent Events
connection to GET /runs/{id}/stream over native fetch - no extra dependency. The stream replays
every row after ?from=, then delivers new ones as they commit. Because it is backed by durable
storage, a reconnect with { from } resumes exactly where it left off.
Updates land sub-second. Live push is a latency optimization over a durable fallback, never a replacement: a frame is published only after its row commits, so a subscriber always finds the row already durable, and a slower poll backfills anything a dropped frame missed. Streaming is never lossy - worst case, an update lands at poll latency instead of instantly.
In the console
The console's run-detail view is built on this stream. Opening a run tails GET /runs/{id}/stream,
so status and steps update live as the run executes, driven by the stream rather than by polling on a
timer. ctx.log lines surface where they were emitted: a step's logs appear under a Logs tab on
that step (beside Output and Error), and handler-level logs appear under a Logs tab on the run
(beside Input and Result). It is the same durable timeline the REST API exposes.
The runs list and stats are live too, off a second stream. runs.watchAll (GET /runs/stream)
tails run_status transitions across the whole namespace; the console refetches the list on each one
instead of polling. That stream is best-effort - there is no global cursor across runs, so it tails
by timestamp and a frame is a "refetch" signal rather than a lossless log; a missed frame self-corrects
on the next transition. Use runs.watch when you need one run's exact, ordered
timeline, and runs.watchAll when you just need to know the namespace had activity.
Differentiation
Other engines expose status polling and, separately, a logs query. Durablex unifies both into one durable, replayable, streamable per-run timeline - a first-class API you can build a live run-trace or log-tail UI on directly.