Concepts

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:

kindFields beyond seq / ts / runId
run_statusstatus, currentStepName?, attempt?
step_statusname, status, attempt, index?, error?
loglevel, 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.

On this page