Control plane
The embedded dashboard — a React SPA served by NestJS that lists runs and renders each one as a graph across local and remote steps, with retry and cancel.
@dudousxd/nestjs-durable-dashboard mounts a control plane at /durable: a bundled React SPA
plus its JSON API. It reads straight from the state store, so it works without an
OpenTelemetry collector.
import { DurableDashboardModule } from '@dudousxd/nestjs-durable-dashboard';
@Module({
imports: [DurableModule.forRoot({ store, transport }), DurableDashboardModule.forRoot()],
})
export class AppModule {}DurableModule is global, so the dashboard resolves the engine and store automatically. Front the
base route with your own guard to protect it.
Where it mounts
forRoot() defaults to /durable (SPA at /durable, JSON API at /durable/api). Pass basePath
to move it — e.g. under your app's /api prefix so the same auth/proxy rules cover the dashboard API:
DurableDashboardModule.forRoot({ basePath: '/api/durable' });
// SPA at /api/durable, API at /api/durable/apiThe SPA's asset URLs and its API base are derived from basePath at serve time, so the bundle works
at any mount point. If your app sets a global prefix (app.setGlobalPrefix('api')), exclude the base
route so the path isn't prefixed twice — or fold it in by setting basePath to include the prefix.
Serving it from an API pod (without processing there)
When you split your app into API and driving pods, you usually want the dashboard served from
the API (it handles HTTP) while only the driving pods process runs. Set drive: false on the API
instance's DurableModule:
DurableModule.forRoot({
store,
transport,
drive: process.env.APP_TYPE === 'API' ? false : true, // driving pods process; the API only serves the dashboard
});With drive: false the engine is still fully available — the dashboard can dispatch retries and
cancels, and reads work — but the instance does not drive: it won't register @Step
handlers (so it never consumes the task queue), won't recover incomplete runs on boot, and won't poll
due timers. Leave that to the driving pods. So a retry clicked in the UI on an API pod still
works: the API dispatches the step, a driving pod picks it up and runs it. Only the processing is
gated, not the control plane. (Default is drive: true, so single-process apps need nothing.)
Console auth
Three ways to gate the console (the SPA at basePath and its JSON API at apiBasePath), from
least to most work — pick one, or compose two:
| Open (default) | guards | dashboardAuth | |
|---|---|---|---|
| Setup | none | you write (or already have) a guard | one config object, zero extra code |
| Auth mechanism | whatever fronts the mount otherwise (reverse proxy, global guard) | whatever CanActivate you bring — typically your app's existing auth | a built-in signed session cookie, minted either by your own host auth (Mode A, session) or a server-rendered login page (Mode B, login) |
| Fronts the page (SPA shell + assets)? | no | yes, when set (see below) | yes — a missing/invalid session redirects the page to a login screen (Mode B) or a "sign in through the host app" instruction page (Mode A only) |
| Best for | internal/dev use, or a mount already behind SSO/proxy | hosts that already authenticate the rest of the app and want ONE gate to reuse | Mode A: hosts with their own SSO/OIDC that want the dashboard to ride it. Mode B: standalone hosts with no existing admin auth |
guards — front it with your own auth
guards (+ imports for its dependencies) stamps a guard class onto BOTH dashboard controllers —
the SPA at basePath and its JSON API at apiBasePath:
DurableDashboardModule.forRoot({
guards: [ConsoleAuthGuard],
imports: [AuthModule], // resolves ConsoleAuthGuard's own dependencies
});A guard here must handle cookies, not just headers: the JSON API is fetched by the SPA's own
fetch/XHR (a header-based guard works fine there), but the SPA shell itself is loaded by a full
browser navigation — the browser sends whatever ambient cookie it has, never a custom
Authorization header. A guard that only checks a bearer token will 403 the page shell for an
already-logged-in admin whose next fetch to the API would have passed. A cookie-session guard
that optionally redirects unauthenticated page navigations to your own sign-in screen:
import { type CanActivate, type ExecutionContext, Inject, Injectable } from '@nestjs/common';
import { SessionService } from './session.service';
@Injectable()
export class ConsoleAuthGuard implements CanActivate {
constructor(@Inject(SessionService) private readonly sessions: SessionService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
const user = await this.sessions.verifyFromCookie(request);
if (user?.isAdmin) return true;
// A real browser navigation (loading the shell) vs the SPA's own fetch calls.
if (request.headers?.['sec-fetch-mode'] === 'navigate') {
response.redirect(302, '/signin?next=' + encodeURIComponent(request.originalUrl));
} else {
response.status(401).send({ message: 'Unauthorized' });
}
return false;
}
}dashboardAuth — the built-in session cookie
dashboardAuth gates the console behind the dashboard's own stateless, signed session cookie —
no infra, and no changes to the bundled React SPA (unlike @dudousxd/nestjs-telescope, whose
dashboardAuth renders its login screen inside its own SPA bundle, the durable dashboard's
auth-adjacent pages are plain HTML served directly by their own controller, so gating the SPA
shell never touches the Vite build). There are two ways to mint that cookie — Mode A
(session) and Mode B (login) — used alone or together; at least one is required, or
forRoot/forRootAsync throws at boot (an un-mintable gate is a boot error, not a silently-open
or silently-stuck console).
Mode A — session, ride your host's own auth
Your host app already has its own auth (SSO/OIDC/whatever). The host frontend, carrying that
auth, POSTs to <basePath>/session; your session hook validates the raw request and returns
the session user (or null to deny), and the library mints its cookie from that. No credential
this library understands ever exists — your identity provider stays the source of truth:
DurableDashboardModule.forRoot({
dashboardAuth: {
secret: process.env.DURABLE_AUTH_SECRET, // REQUIRED HMAC-SHA256 key, 32+ bytes recommended
ttl: '8h', // optional, default 8h
session: (request) => {
const user = requireAuthenticatedUser(request); // however your host already authenticates
return user?.isAdmin ? { id: user.id, name: user.name, roles: ['admin'] } : null;
},
},
});Your own console launcher (a page in your app, gated by your own auth) POSTs
<basePath>/session before sending the visitor to /durable — a successful response mints the
durable_dashboard_session cookie (HttpOnly, SameSite=Lax, Secure over https) exactly like
Mode B's login does. With only Mode A configured there is no login page to redirect to, so a
page-level request (a full-page navigation) with a missing/invalid/expired cookie instead renders
a small, static instruction page telling the visitor to sign in through the host app; an API
request (apiBasePath, fetched by the SPA's own JS) still gets a plain 401, same as Mode B.
Opening the console from your app
Your launcher has to mint the Mode A cookie before navigating (see Mode A). The package ships that at three levels — take the one that fits and drop a level whenever it stops fitting:
// 1. Drop-in. Unstyled; forwards className/style/every button prop.
import { OpenDurableConsoleButton } from '@dudousxd/nestjs-durable-dashboard/react';
<OpenDurableConsoleButton className="btn" headers={() => authHeaders()} />;
// 2. Hook. You own the markup, it owns the states.
import { useOpenDurableConsole } from '@dudousxd/nestjs-durable-dashboard/react';
const { open, isPending, error } = useOpenDurableConsole({ headers });
// 3. Headless. No React at all.
import { openDurableConsole } from '@dudousxd/nestjs-durable-dashboard/client';
await openDurableConsole({ headers });Already on TanStack Query? openDurableConsoleMutationOptions() returns the object useMutation takes — this package does not depend on TanStack, so you get the integration and a host that doesn't use Query pays nothing.
Do not hand-roll the fetch. fetch follows redirects by default, so an app whose auth layer rewrites a 401 into a sign-in redirect gets a resolved 200 against the sign-in HTML — response.ok reads true, your code navigates, and the user lands in a console with no session, which looks exactly like a permissions bug. These helpers use redirect: 'manual' and turn that case into an error naming the likely cause. They also derive the session path from basePath, so a route change here can't silently 404 your launcher.
React and react-dom are optional peer dependencies and the React code lives behind the ./react subpath, so mounting only the NestJS module pulls in neither.
unauthenticatedPage — render that page yourself
The built-in instruction page cannot know who hosts the console, so it can only say "open this
console from your application" in the abstract — it can't name your launcher, link to it, or look
like the rest of your product. Pass unauthenticatedPage and the whole response is yours:
DurableDashboardModule.forRoot({
dashboardAuth: {
secret: process.env.DURABLE_AUTH_SECRET,
session: (request) => resolveAdmin(request),
unauthenticatedPage: ({ request, response, basePath }) => {
// `request`/`response` are your platform's own objects (Express here). Render however you
// already render — a template engine, @dudousxd/nestjs-inertia, a plain string.
(response as Response).status(401).render('console-locked', { returnTo: basePath });
},
},
});The hook owns the response: it must write AND end it. Serving happens at the console's own URL,
so /durable stays /durable (no redirect, no second route for you to own).
It cannot open the console — it only ever runs on a request that has already been denied. If it
throws, or returns without writing anything, the library logs a warning once and falls back to the
built-in page, so a broken page can't hang the request or turn a denial into a 500.
This is not a replacement for Mode B's login form. To ship your own login UI, combine Mode A
with this hook and POST <basePath>/session from your page — the mint endpoint is the supported
primitive for exactly that.
Mode B — login, the built-in login page
No host frontend/IdP to lean on. The dashboard serves its own small, dependency-free,
server-rendered login page and your login hook validates submitted username/password:
DurableDashboardModule.forRoot({
dashboardAuth: {
secret: process.env.DURABLE_AUTH_SECRET, // REQUIRED HMAC-SHA256 key, 32+ bytes recommended
ttl: '8h', // optional, default 8h
login: (username, password) =>
username === process.env.DURABLE_USER && password === process.env.DURABLE_PASS
? { id: 'ops' }
: null, // return null to deny — a uniform 401, no user-enumeration
},
});Open /durable, and an unauthenticated visit redirects to GET /durable/login?returnTo=/durable
— a plain username/password form. The password field is never required: the page passes it
through to your login hook verbatim (an empty string when left blank), so a host that gates on
username alone (e.g. any active admin's email, password ignored) works with this same built-in
page. On success it mints the same durable_dashboard_session cookie as Mode A and sends the
browser back to returnTo. GET /durable/logout clears the cookie and returns to the login page
when Mode B is configured, or to basePath (re-triggering whichever page-level gate applies)
when it isn't.
Use forRootAsync when a hook needs injected services (e.g. an EntityManager to look up a real
admin user instead of an env var pair) — the same factory shape covers session, login, or
both:
DurableDashboardModule.forRootAsync({
imports: [UsersModule],
inject: [UsersService],
useDashboardAuth: (users: UsersService) => ({
secret: process.env.DURABLE_AUTH_SECRET,
login: async (username, password) => {
const user = await users.verifyPassword(username, password);
return user?.isAdmin ? { id: user.id, name: user.name, roles: ['admin'] } : null;
},
}),
});Returning undefined from useDashboardAuth leaves the console open, exactly like omitting
dashboardAuth from forRoot.
Sliding renewal and revalidate
Sliding renewal keeps an active session alive: a cookie past 50% of its TTL is transparently
re-issued on the response, so a working operator isn't forced to re-authenticate mid-session.
Add revalidate to have that renewal re-check the user instead of blindly extending them:
DurableDashboardModule.forRoot({
dashboardAuth: {
secret: process.env.DURABLE_AUTH_SECRET,
session: verifyHostSession,
revalidate: async (session) => {
const user = await users.findById(session.id);
return user?.isActive === true; // false/throw revokes — the cookie is cleared, request denied
},
},
});revalidate receives the already-minted session (not a fresh request — a console XHR carries no
host auth to re-check, unlike Mode A's session hook), and runs at most once per ttl/2 per
session, so a DB round-trip here is cheap — this holds even under real console traffic: a page load
firing several parallel API calls with the same past-half-life cookie triggers exactly one
revalidate call, not one per call, in-flight renewals of the same session share the result.
Returning false, or throwing, clears the cookie and denies the request in place, the same
treatment as an absent cookie. This closes the gap a sliding cookie otherwise leaves open — but
it isn't instant: revalidate only runs on the renewal path, so a deactivated or demoted
operator keeps console access for up to ttl/2 (4 hours at the default 8h TTL) after their last
renewal, not the moment they're deactivated. revalidate alone can't mint a session, so it doesn't
count toward the session/login "at least one" requirement — pair it with one of those.
A revalidate denial (or any other mid-session 401 from the JSON API) sends the console SPA to the
matching auth surface automatically — the login page under Mode B, the session-required page under
Mode A — instead of surfacing a raw 401 as a query error.
Composes with guards: when both are set, a request must pass the built-in session check AND
every guard in guards — the built-in guard runs first, so an invalid session never even reaches
your own guard.
DurableDashboardModule.forRoot({
dashboardAuth: { secret: process.env.DURABLE_AUTH_SECRET, login: verifyOpsCredentials },
guards: [IpAllowlistGuard], // an extra restriction on top of the built-in login
imports: [NetworkModule],
});Auth here is deliberately mount-level and role-agnostic: neither guards nor dashboardAuth
has any notion of the durable dashboard's control-plane vs tenant topology (the GET topology
route behind the header badge, described below) — logging in only answers "is this a legitimate
operator", nothing about what they can see changes based on who they are.
What it shows
- Runs — every run with its stored status (pending / running / suspended / cancelling /
completed / failed / cancelled / dead), filterable.
pendingis a run that's been created and enqueued but not yet picked up by a worker — you'll see it briefly on every run, and persistently on an API pod (drive: false) whose runs execute on separate driving pods.cancellingis a transient, durable status for acancel(runId, { compensate: true })whose saga undo is still running in the background — it survives a crash mid-compensation and finalizes tocancelled. The status filter is a row of chips, including adeadchip for the dead-letter state; a dead run carries a distinct badge so a poison pill is easy to spot. The genericsuspendedstatus covers very different situations — sleeping on a timer, waiting on a signal, blocked with no worker, queued behind a singleton leader — so the list and detail views refine it into a more legible display state; see Waiting states below. Alongside status the list narrows by tag, search attribute, tenant and the library that declared the workflow — the last two answer "whose run is this" and "whose code is this", and both have a default worth understanding; see Tenant and origin filters. The tag, tenant and attribute controls are pickers over what the runs actually contain, and each takes several values — see Filter pickers. - Timeline as a graph — the selected run rendered with
react-flow: a node pipeline from start through each step to the terminal state, tagged by kind (local / remote / sleep / signal), with worker group and duration. This is the "see the whole flow" view — a workflow whose steps span apps shown as one rail. In-flight steps show up here while they run, not only on completion: a remote step in flight reads aspendingand a local step's executing body asrunning(both rendered as an in-flight node). Local in-flight visibility is on by default — it's the engine'strackStepStartoption — so a longctx.stepis visible the moment it begins. - Step timing — click a step to inspect it. A remote step shows its queued time (how long it sat in the queue before a worker picked it up) next to its processing duration, so you can tell a slow handler apart from a backed-up worker pool. Queue-wait is reported by every transport and every language SDK (including the Python worker).
- Live-tail — the run view streams new lifecycle events as they happen (see below) instead of polling, so a running workflow updates in place.
- Actions — retry a failed/suspended/dead run, cancel a running one, cancel + undo (cancel
with saga compensation), and continue a run paused at a breakpoint.
These are non-blocking: retry re-enqueues the run (
engine.requeue(runId)→pending+ dispatch) and cancel + undo runs the saga compensation in the background — neither replays the workflow inline in the HTTP request, so the dashboard action returns immediately and a worker does the work. (Previously an inline replay could hang the request.)
Filter pickers
The tag, tenant and search-attribute filters list the values the runs actually carry, with a count each, and take several of them.
They used to be free-text boxes, and that made them usable only by an operator who already knew a tenant name or a tag: a typo returned an empty list, which reads exactly like "no runs match". Search attributes were worst — both the key and the value are data nobody memorises.
# [ etl ×] [ nightly ×] ← several values, ORed
@ [ acme ×] ← tenant
⛃ [ tier is any of pro, enterprise ×] [+ attribute]Three things make them worth the machinery:
- The options are counted over the runs the OTHER filters already select. Pick a tenant, and the tag picker offers that tenant's tags. A picker never offers a value whose result set is empty.
- Several values on one axis are ORed, and the axes are ANDed — "these two tenants, tagged either
of these two ways". On search attributes that needs a set operator (
is any of), because two equality predicates on one key are ANDed like every other pair and no run has one attribute with two values. - Typed text still works. The offered list is a bounded top-N (tag cardinality grows with the
data — a
singleton:<key>tag is minted per key), so a rare value can be real and absent; Enter takes it as typed.
A tag or tenant chip on a run row adds to the selection rather than replacing it, which is how a "these two kinds of run" view gets built by clicking.
The values come from GET runs/values, backed by StateStore.runValueFacets. A store that does not
implement it answers [], and the pickers degrade to free-text entry — no error, no empty console.
Counts on the run-table axes (tenant, workflow, status, origin) are exact GROUP BYs over the whole
matching set. Tag and search-attribute counts are taken over a bounded scan of recent matching
runs — their values live inside a JSON array column and a side table rather than in the row being
counted — so treat those numbers as "how common, roughly", not as a total.
Tenant and origin filters
Two of the run-list filters are about provenance rather than state, and they are wired differently on purpose — one pushes down to the store, the other cannot.
Tenant is a sidebar picker matched exactly against the run's namespace — the same field the engine
partitions worker pools by, conventionally fed from DURABLE_TENANT (see
Tenancy). It is sent to the store as the namespace param on GET runs
and POST bulk/:action, so a narrowed list is narrowed in SQL.
Its default is every namespace, and that is a decision rather than an omission: read paths (the
dashboard, getRun) are deliberately not namespace-scoped, so defaulting the console to one tenant
would have silently removed runs that every existing operator had been looking at. This is a filter an
operator chooses, not a scope imposed on them — and it is not an authorization boundary either
(Console auth is mount-level and role-agnostic).
An empty selection is treated as absent, not as an exact match on a tenant named "", so clearing
it returns the all-tenants view rather than an empty one. Selecting several compares them side by
side (RunQuery.namespaces). The tenant chip on a run row adds itself to the selection, like the tag
chips.
Origin — which package declared the run's workflow — is a row of facet chips above the list:
all, one chip per package (labelled without the npm scope, full name in the tooltip), and unknown
when any run in the list has none, each with a count. It is derived, never declared: @Workflow
resolves the file it is applied in to the nearest enclosing package.json, so a library is attributed
without opting into anything and nothing a caller passes to start can claim another library's name.
The registrar warns once at boot naming whatever it could not attribute, because a wrong origin is
worse than an absent one.
Which makes undefined a real value meaning UNKNOWN, not a blank to be tidied away. Runs created
before the field existed have none; so do registerRemote, convention routing, and any workflow whose
declaring package couldn't be resolved with confidence. RunQuery.origin is plain equality, so those
runs match no origin value at all — picking a package hides them rather than folding them into a
bucket. Hence the shape of the facet: all is the default and never goes away, the unknown count
stays on screen while a package is selected, and an origin filter that comes back empty says which of
the two things happened ("nothing matched" vs "these N runs cannot be matched by any package filter")
and offers a jump into the unclassified ones. A run's detail header states origin unknown outright
for the same reason, instead of leaving the field blank and letting it read as "the app".
Both filters push down to the store, including the unknown one. RunQuery.origin accepts null
for exactly that bucket (origin IS NULL), which the console sends as ?unattributed=true — its own
param rather than a reserved origin value, because any reserved string is a package name someone can
legitimately publish. The chips' counts come from GET runs/facets, not
from the rows on screen, so the unknown count stays visible and exact while a package is selected —
the property the facet exists for. Retry all / cancel all send the same predicate, so a bulk
action is scoped to precisely the set the list was showing, unattributed runs included.
The Drizzle adapter needs the origin column added by hand. It has no auto-schema, and Drizzle
SELECTs every column its schema declares, so on an existing database every run query fails until
ALTER TABLE durable_workflow_runs ADD COLUMN origin TEXT; has run — see
Drizzle · Schema is owned by drizzle-kit. The
MikroORM and TypeORM adapters add it on boot, and Prisma emits it through Migrate.
At scale: paging, facets and what the numbers mean
A control plane accumulates runs for as long as its retention policy keeps them, so "list the runs" is not a bounded request. The console treats it as a page:
GET runstakeslimit/offset, and the SPA asks for 100 rows at a time, extending the page as the last rows scroll into view. Every store adapter implements both. The load starts a lookahead before the bottom, so the list reads as continuous rather than stalling at each boundary, and it is gated on the fetch in flight and on the count the page was last requested at — otherwise a control plane whosetotalbriefly runs ahead of its own listing would walk the page size up on every render, which is the failure paging exists to prevent.- The list is virtualised: only the rows in the viewport are mounted, so a deep page costs the same DOM as a shallow one.
GET runsreturns list rows, not whole runs —input,outputanderrorare omitted, because no row renders them and on a busy deployment they are most of the bytes.GET runs/:idis unchanged and still carries all three; that is where they are displayed.
The numbers behind the design, measured against a deployment holding 9,533 runs: the unpaged listing
was 12.24 MB every three seconds, of which 63% was error — a stack trace on every failed run,
which the list never shows. Rendering it mounted 115,636 DOM nodes and blocked the tab's main thread
for 26.9 out of every 30 seconds.
The list endpoint is unbounded when you send no bound, so an existing API consumer is never silently
truncated. It is the console that always sends a limit.
Counting without listing
Paging the list raises an obvious question: if the console holds 100 rows, where do the status and
origin chips get their numbers? Not from the page — from GET runs/facets, one
GROUP BY status, origin aggregate over the same tag / tenant / attribute predicates the listing uses:
const facets = await durableClient.facets(tag, attrs, namespace);
// → [{ status: 'failed', origin: '@acme/billing', count: 3658 }, { status: 'failed', origin: null, count: 12 }, …]origin: null is the unattributed bucket — a real, countable cell rather than the absence a value
match cannot express. status and origin are deliberately not accepted as params: they are the
axes being counted, so narrowing by them would report the answer back to itself.
It is backed by StateStore.runFacets (optional on the port; a store without it falls back to counting
a listing) and RunGateway.runFacets, which a tenant deployment round-trips to the control plane the
same way it does listRuns.
Waiting states
The engine only ever persists one generic suspended status for a durably-parked run — that's what
drives recovery, timers, and queries, and it never changes. But why a run is parked reads very
differently depending on what it's waiting on, so the run list and the run detail header refine it into
a more legible display state (both derive it the same way, so they always agree):
| Display state | Means |
|---|---|
sleeping | Parked on a durable ctx.sleep timer. |
awaiting | Waiting on a signal, webhook, awaited child, or a ctx.breakpoint() pause — named e.g. signal approve, webhook stripe-cb, child run-xyz, or breakpoint. |
no-worker | Blocked: the run's next step is queued but its worker group has a real backlog (depth > 0) with zero live workers to consume it. |
queued | A singleton run gated behind another run holding its key's slot — shown as "behind leader <id>". |
running | Everything else open/in-flight, including a settled step waiting to be replayed with nothing currently blocking it. |
The awaiting label is a RunWaiting ({ on: 'signal' | 'webhook' | 'child' | 'breakpoint', name }),
resolved from the run's pending signal waiters and classified by the waiter token's prefix — wh: →
webhook, child: → child, bp: → breakpoint, anything else → a named signal. A ctx.breakpoint()
pause is implemented as a signal wait under the hood, so it's classified and shown as breakpoint
rather than a raw signal bp:<runId>:<seq> token.
no-worker is gated on a real backlog, not bare zero live workers. A worker only heartbeats for a
group while it's actively serving it, so a group that's simply idle right now — a suspended run parked
on its reconcile timer with nothing enqueued, or a scheduled workflow between cron runs — legitimately
reports zero live workers without anything being blocked. Flagging that no-worker was a false
positive, fixed in dashboard 0.29.6: the check is depth > 0 && liveWorkers.length === 0 (the same
alert condition GroupHealth documents, see below) — a real backlog
with no consumer. A parked/settled run with nothing enqueued now reads running (open, in flight) and
only flips to no-worker once its resume actually enqueues behind a stalled queue. This also fixed the
header banner, which used to count completed-work orphans as stalled.
Bulk-resolving waiting state — RunGateway.waitingFor: if you're building your own filtered/
paginated run list and need to know which of your runs are parked on a breakpoint (or any other
event wait) without re-deriving the waiter scan yourself or querying checkpoints directly, call
waitingFor on the injected RunGateway:
import { RunGateway } from '@dudousxd/nestjs-durable';
constructor(private readonly runGateway: RunGateway) {}
async whichAreStuck(runIds: string[]) {
return this.runGateway.waitingFor(runIds);
// { 'run-1': { on: 'breakpoint', name: 'breakpoint' }, 'run-3': { on: 'signal', name: 'approve' } }
}It resolves in two bulk store scans (never one query per id), and on a tenant deployment it proxies over the control-plane transport in a single request for the whole id list, scoped to runs the requesting tenant actually owns.
Live-tail over SSE
The run view tails a run's lifecycle events over Server-Sent Events rather than re-fetching. The
server exposes an @Sse route, GET runs/:id/stream, and the client subscribes with
durableClient.streamRun(id, onEvent):
import { durableClient } from '@dudousxd/nestjs-durable-dashboard/client';
const close = durableClient.streamRun(runId, (event) => {
// event: { type: 'step.completed' | 'run.failed' | …, runId, seq, name, … }
console.log(event.type, event.name);
});
// later: close();This is cross-pod when the engine has a control plane: an event produced by a worker pod is broadcast over the control plane, and a dashboard-only API pod re-delivers it to its SSE subscribers. So you can live-tail a run on the API pod even though the work runs elsewhere. Without a control plane, the stream still works for runs executing on the same instance (single-process apps).
Cancel + Undo (saga compensation)
The run actions include both a plain Cancel and a Cancel + Undo. Plain cancel is immediate —
the run is marked cancelled and a late worker result is dropped. Cancel + Undo instead asks the
engine to compensate the saga — in the background, not in the HTTP request: the run is resumed
on a worker so its completed steps' compensate callbacks run in reverse order (each visible as a
compensate:<step> step event in the timeline), then the run is marked cancelled. The HTTP action
returns immediately. The UI sends this as cancel with ?compensate=true:
await durableClient.cancel(runId); // immediate, no undo
await durableClient.cancel(runId, { compensate: true }); // ?compensate=true — undo firstUse Cancel + Undo when the run has already done outside-world work (a charge, a reservation) that must be reversed; use plain Cancel to just stop it.
Dead-letter runs and their links
A run that crash-recovery gives up on — after exceeding maxRecoveryAttempts — is moved to the
dead dead-letter state (a poison pill that would otherwise crash the process every boot). The
dashboard surfaces these as a first-class status: a dead filter chip and a badge on the run.
A dead run is terminal but inspectable, and still retriable from the UI once you've fixed the
cause.
When you configure a deadLetterWorkflow, dead-lettering also starts a handler run (idempotent by
a dlq:<runId> id) carrying { deadRunId, workflow, input, error }. The dashboard renders the
relationship both ways: the dead run links forward to its dlq:<id> handler, and the handler links
back to the dead run it was started for — so you can jump between the failure and whatever your DLQ
workflow did about it (alert, compensate, queue for review).
Recovering a lost remote step dispatch
A remote step with no timeoutMs dispatches its task, persists a pending checkpoint, and suspends
until the worker's result resumes it. If the worker crashed mid-step or the transport dropped the job
(a Redis flush/eviction, or a stalled BullMQ job moved to failed and removed), the result never
comes — and reconcile re-drives, by design, just re-suspend a still-pending step rather than
re-dispatching it (so a merely-slow worker is never double-run). Left alone, the run hangs on pending
forever.
The dashboard flags this instead of masquerading it as a healthy in-flight step: a remote step
pending past 10 minutes (STALE_PENDING_MS) shows in the timeline as "awaiting worker result —
dispatched Nm ago (possibly lost)". From there:
-
Re-dispatch action —
POST runs/:id/redispatch(durableClient.redispatch(id), or the dashboard's "Re-dispatch" button) re-enqueues the run's stuckpendingremote steps, bumping theirattemptsso the idempotent step re-runs and its result resumes the run — the manual escape hatch, equivalent toWorkflowEngine.redispatchPending(runId). -
BullMQ self-heals for free. The BullMQ transport bridges a crashed/stalled task job's terminal failure into a synthetic failed
StepResult(via the worker's'failed'event), so the engine marks the checkpointfailedand its normal durable retry re-dispatches — no config needed, and the run never gets stuck onpendingin the first place on this transport. -
Opt-in self-heal —
remoteRedispatchMs(any transport). Set this engine option and a reconcile re-drive that finds a remote step stillpendingpast the window automatically re-dispatches it, bounded byremoteRedispatchMax(default 10) so a step that never settles fails as aremote_step_losterror instead of looping forever:DurableModule.forRoot({ store, transport, remoteRedispatchMs: 15 * 60_000, // self-heal a remote step still pending after 15 minutes remoteRedispatchMax: 10, });Off by default — re-dispatch can double-run a step whose original job is merely slow, so the window must exceed the longest such step's real duration and the step must be idempotent. Prefer a per-step
timeoutMswhere you can (tighter, heartbeat-aware — see Retries & backoff);remoteRedispatchMsis the store-driven net for the no-timeout steps that must still survive a lost dispatch.
Webhook callbacks
A ctx.webhook() hands a third party a callback URL (built by your webhookUrl option). When they
POST it, the dashboard's POST webhooks/:token endpoint turns that body into the signal the waiting
run resumes on. The token embeds runId:seq, so treat it as a secret — this endpoint is reachable by
external systems; front it with signature verification in your own middleware.
Live queries and updates
Two more endpoints expose a run's query/update surface over HTTP:
GET runs/:id/events/:key— read the latest value a run published withctx.setEvent(key, …), a side-effect-free live query of an in-flight (or finished) run's state (progress, a partial result). Returnsundefinedif the run never published that key.POST runs/:id/updates/:name— deliver a validated update to a run waiting atctx.onUpdate(name); the request body is the update argument. Any validator registered viaengine.registerUpdateValidatorruns first, so a rejected update never reaches the run.
Fleet health
When you run a split or polyglot cluster, the dashboard grows a health / compatibility panel fed by the handshake descriptors and diagnostics events:
- Per pod — its protocol version, negotiated level (compatible / degraded / incompatible), and a red flag with the exact reason on a mismatch (e.g. "worker speaks protocol 2, control-plane speaks 1 → stopped").
- Blocked runs — runs parked because no live worker advertises a required capability ("requires capability X, no worker"), so a stuck fleet is diagnosable at a glance rather than a silent hang.
A store-less tenant api/dashboard pod serves this same view — its reads proxy to the control
plane over the wire, so the panel looks identical whether or not the pod owns a store.
Engine health & admin surface
The dashboard's Workers panel and its retry/cancel actions are a thin layer over methods
WorkflowEngine exposes directly — useful when you're scripting an operational task instead of
clicking through the SPA.
Worker liveness and adaptive concurrency — engine.workerHealth(extra?: string[]) returns one
GroupHealth per worker group (the same call the dashboard's /durable/api/workers route and the
Telescope worker panels use):
interface GroupHealth {
group: string;
/** Outstanding jobs in the group's task queue (waiting + active + delayed + prioritized). */
depth: number;
/** Workers with a non-expired heartbeat for this group. */
liveWorkers: WorkerHeartbeat[];
}
interface WorkerHeartbeat {
group: string;
/** Stable per-process id (host + pid) — N replicas of a group each show as a distinct worker. */
instanceId: string;
lastBeatAt: number;
/** Present on newer SDKs: concurrency mode/limit, in-flight, RAM %/CPU %, throughput, p95, last adjust. */
status?: WorkerStatus;
}depth > 0 && liveWorkers.length === 0 is the actionable alert — work queued with nothing consuming
it. Pass extra to include local-step groups the engine can't derive from its registrations (they
have no heartbeat to discover otherwise). Only a groupHealth-capable transport (BullMQ) returns
non-empty results.
Bulk operations:
engine.cancelWhere(filter, opts?)— cancel every run matching aRunQuery(workflow / status / tag / search-attribute predicates) — the same matching the dashboard's run list uses. Each match runs through the normalcancelpath (child cascade, optional sagacompensate, cancel listeners, control-plane broadcast), so it's the scriptable form of selecting rows and hitting Cancel. Returns oneRunResultper matched run.engine.deleteRun(runId)— hard-delete a run and its entire subtree (checkpoints, signal waiters, search-attribute rows), cascading depth-first viagetRunChildren. Unlikecancel, the run vanishes fromgetRun/listRunsentirely. Returns the number of runs deleted. Prefercancelfor a still-live run — deleting one mid-flight orphans its worker.engine.getRunChildren(parentRunId)— the ids a run spawned viactx.child(awaited) orctx.startChild(fire-and-forget), the same parent→children edgecancelWhere's cascade and the dashboard's run-tree both walk.
Graceful shutdown — engine.drain(timeoutMs = 10_000) stops the engine picking up new runs
(recovery and the timer poller become no-ops) and waits for in-flight executions to settle, up to
timeoutMs. Call it from your app's shutdown hook so a deploy hands off cleanly instead of leaving
runs to time out their lease.
Cross-instance hooks — onEnqueued/onCancel let a worker react to activity from other
instances over the control plane, without polling:
// Wire a worker to pick up a run the instant another pod enqueues it.
const stopEnqueued = engine.onEnqueued((runId) => engine.runOne(runId));
// Cooperative cancellation: abort in-flight work instead of finishing it just to
// have the result discarded.
const stopCancel = engine.onCancel((runId) => myWorkerBridge.abort(runId));Both return an unsubscribe function. onEnqueued is only useful on instances that should execute
runs (workers); onCancel is for a worker bridge doing cooperative cancellation of long in-flight
work (a subprocess, a remote call) that a plain status flip can't interrupt.
API
The SPA is backed by a small JSON API you can also call directly. A codegen-emitted typed client
ships at the ./client subpath as durableClient, so an external dashboard or script can call the
same routes with full types instead of hand-rolling fetch:
import { durableClient } from '@dudousxd/nestjs-durable-dashboard/client';
const runs = await durableClient.runs('failed', undefined, undefined, { limit: 100 });
const detail = await durableClient.run(runs[0].id);
await durableClient.retry(detail.run.id);| Method | Route | |
|---|---|---|
GET | /durable/api/runs | list runs — ?status= (repeat any of these to match a SET), ?workflow=, ?tag=, ?attr=, ?namespace=, ?origin=, ?unattributed=true, ?limit=, ?offset=. Also takes nestjs-filter's structured form (?filter[where][0][field]=tag&…), which is what the console sends. Returns list ROWS: no input / output / error |
GET | /durable/api/runs/facets | (status, origin) counts over the same tag / tenant / attribute predicates — the chips' numbers, whole-set rather than per-page |
GET | /durable/api/runs/values | the distinct values of one filter field over the runs the other predicates select — ?groupByCount[field]=tag&groupByCount[limit]=20. Answers { value, count }[]; this is what fills the pickers |
GET | /durable/api/runs/:id | run + step timeline (the whole run, payloads included) |
GET (SSE) | /durable/api/runs/:id/stream | live-tail the run's lifecycle events |
POST | /durable/api/runs/:id/retry | re-enqueue the run (→ pending, a worker resumes it) |
POST | /durable/api/runs/:id/retry-with-input | fix-and-replay: start a fresh linked run from a dead/failed run with a corrected input (body: { input }) |
POST | /durable/api/runs/:id/redispatch | re-enqueue a run's stuck pending remote steps (a lost dispatch) |
POST | /durable/api/runs/:id/cancel | cancel the run (?compensate=true to undo the saga first) |
POST | /durable/api/runs/:id/continue | resume a run paused at a breakpoint |
POST | /durable/api/webhooks/:token | deliver a ctx.webhook() callback (body resumes the run) |
GET | /durable/api/runs/:id/events/:key | read a live query value (ctx.setEvent) |
POST | /durable/api/runs/:id/updates/:name | deliver an update (ctx.onUpdate); body is the arg |
Observability
Seeing what your workflows are doing — the embedded control-plane dashboard, OpenTelemetry spans, dependency-free metrics, the Telescope watcher, and the raw diagnostics bus underneath them.
OpenTelemetry
One span per step, one root span per run's first turn. Bridge the engine's lifecycle events to OpenTelemetry and see workflows in Jaeger, Grafana or Datadog.