Skip to main content

The embeddable console

@bratsos/workflow-engine-console is an optional package that answers "what happened to this run" without you building a screen for it. It is a framework-agnostic (Request) => Promise<Response> handler plus a prebuilt UI, mounted inside your own application.

It is optional in the strict sense: nothing in the engine depends on it, and nothing in it is required to run a workflow.

The runs list: status, workflow, pinned definition version, duration, cost and tokens per run

The Costs tab rolls the same run totals up by workflow or by day:

Cost rollups by workflow

It never opens a connection​

Engine state lives in your Postgres. If you run the kernel inside one transaction per tenant under row-level security, those policies are evaluated against the session the query runs in — so a separate dashboard process with its own connection string is a second session, and therefore a second security context. There is no configuration that recovers the tenant scoping: such a service either connects as a role that bypasses RLS and can see every tenant, or it cannot see anything.

So the console does not dial the database. You construct its read port from the client — or the transaction client — you already have, and its reads happen in your session, inside your transaction, with authentication already decided before the request reaches the handler.

Mounting it​

A catch-all route handler is the whole integration. In the Next.js App Router, at app/admin/console/[[...path]]/route.ts:

import {
createPrismaConsoleReadPort,
createWorkflowConsole,
} from "@bratsos/workflow-engine-console";
import { prisma } from "@/lib/prisma";
import { getSession } from "@/lib/auth";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const handler = createWorkflowConsole({
reader: createPrismaConsoleReadPort(prisma),
authorize: async ({ request }) => {
const session = await getSession(request);
return session?.role === "operator" || session?.role === "admin";
},
});

export { handler as GET, handler as POST };

Hono, Cloudflare Workers, Deno and Bun speak Request/Response natively, so they call the handler directly. Express, Fastify and bare node:http go through toNodeHandler from @bratsos/workflow-engine-console/node. There is no per-framework adapter package, and none is needed.

authorize is not optional in practice: omit it and every action is denied, because a permissive default would leak a tenant's runs to anyone who forgot a line of configuration. It runs after your authentication — answer only "may this principal do this?".

Writes are off by default. actions: true needs a kernel, and every write dispatches a kernel command (run.cancel, run.redrive, step.signal, plugin.replayDLQ) rather than SQL, so the console gets the kernel's leases, idempotency and outbox events and there is still exactly one writer against run state. authorize is asked per action — the vocabulary is runs.read, run.read, queue.read, suspended.read, deadLetters.read, workers.read, costs.read, run.cancel, run.rerun, step.signal and deadLetters.replay — and onAction is told afterwards, which is your audit trail. A custom ConsoleKernel must accept run.redrive and step.signal.

What the run detail shows​

The run detail is the stage timeline plus, per stage, its step ledger: every durable step with its kind (run, wait, signal, sleep), status, attempt, lease and deadline (leaseExpiresAt, deadlineAt), its error, and — on run steps — the externalKey the body was given, so an operator can search a provider for an orphaned batch or charge without the process that made it. Below that: the run's annotations (including the engine-written run.supersededAttempt and step.outcome-conflict), logs and the outbox event timeline.

Run detail for a contract review suspended on a legal sign-off signal: the stage timeline and a pending signal step in the ledger

Delivering a signal​

A stage suspended on ctx.step.waitForSignal — the human-approval case — is finished from the run detail: a step with kind === "signal" and status === "pending" offers Deliver signal with a validated JSON payload box. It dispatches step.signal through the kernel:

POST /api/runs/:runId/stages/:stageId/steps/:stepId/signal
{ "payload": { "approved": true } } // optional; defaults to null

It is gated by authorize like every write (the context carries stageId and stepId alongside runId), and the kernel's refusals become statuses: an unknown run or stage is a 404, a step that is not a pending signal is a 409, both with the kernel's message.

Redriving a run​

Rerun dispatches run.redrive, not the deprecated run.rerunFrom, so a CANCELLED run can be redriven and the superseded attempt is archived rather than deleted. The request takes from: { kind: "lastFailure" | "start" | "stage" } (fromStageId still means "from this stage"), and definitionVersion: "latest" or a registered version; a malformed from is a 400 rather than a silent fall back to one mode. The UI offers Redrive on latest version on any run carrying a pinned version. The action name is still run.rerun, so an existing authorize keeps matching.

A support-triage run that failed on a CRM outage and completed after one redrive: the header shows the redrive count

Without a web application​

A worker-only repository has nothing to mount into. workflow-console serves the same handler on a local HTTP server:

npx workflow-console --config ./console.config.mjs

The config module builds the reader the way your own code does, which is what keeps row-level security intact. It binds to loopback only and ships no authentication — it is a development tool. Connecting it directly with --database-url is possible but requires --allow-direct-connection, which acknowledges that the connection is a second session and will not be scoped by your policies.

Finding a run stranded on an old definition version​

Definition versioning pins a run to the structure it started under, and a run pinned to a version no build serves any more sits there until someone redrives it. The runs list carries each run's definition version — shortened, with the full value on hover — and its redrive count as a +N suffix, and the filter bar takes a version, so "show me everything still pinned to the old version" is one query:

GET /api/runs?definitionVersion=sha256-…

The run detail shows the same two fields next to the run's timings, which is what an operator needs before deciding whether to redrive onto "latest".

The indexes it needs​

The console's list views are "newest first, optionally narrowed", paged on a (createdAt, id) keyset. The reference schema carries the composite indexes for exactly those orderings — see Prisma setup. Without them the runs list sequentially scans workflow_runs.

There is deliberately no total count and no page numbers: counting a large run table is the expensive query the console exists to avoid issuing. A query cancelled by statement_timeout comes back as HTTP 504 with a query_timeout code, which the UI shows as an inline banner while leaving the filter bar interactive, rather than hanging the page.

The full reference — every option, the action vocabulary, custom read ports, and what the console deliberately does not do — is in the package README.