@bratsos/workflow-engine-console 1.0.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +464 -0
- package/dist/actions-DOP8plrl.d.ts +52 -0
- package/dist/chunk-4RL6UAMS.js +486 -0
- package/dist/chunk-4RL6UAMS.js.map +1 -0
- package/dist/chunk-HUHLRNNM.js +517 -0
- package/dist/chunk-HUHLRNNM.js.map +1 -0
- package/dist/chunk-RCVZ3AP4.js +62 -0
- package/dist/chunk-RCVZ3AP4.js.map +1 -0
- package/dist/chunk-THOZTVDT.js +85 -0
- package/dist/chunk-THOZTVDT.js.map +1 -0
- package/dist/cli.d.ts +33 -0
- package/dist/cli.js +230 -0
- package/dist/cli.js.map +1 -0
- package/dist/handler-Cq2cVh7p.d.ts +47 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/node.d.ts +15 -0
- package/dist/node.js +3 -0
- package/dist/node.js.map +1 -0
- package/dist/prisma-read-port-GPAHJEVC.js +4 -0
- package/dist/prisma-read-port-GPAHJEVC.js.map +1 -0
- package/dist/read-port-CRmaGAKb.d.ts +257 -0
- package/dist/testing.d.ts +50 -0
- package/dist/testing.js +346 -0
- package/dist/testing.js.map +1 -0
- package/package.json +64 -0
package/README.md
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
# @bratsos/workflow-engine-console
|
|
2
|
+
|
|
3
|
+
An optional, embeddable operational console for [`@bratsos/workflow-engine`](../workflow-engine): a framework-agnostic `(Request) => Promise<Response>` handler plus a prebuilt UI, mounted directly inside your application. It opens no database connections and runs no background daemons of its own. Reads go through a read port constructed from your existing database client or transaction, running inside your session under your row-level security policies with authentication decided upstream. Writes dispatch engine kernel commands rather than issuing SQL. Nothing in the workflow engine depends on this package, and nothing here is required to execute workflows.
|
|
4
|
+
|
|
5
|
+
## Why it mounts instead of running as a service
|
|
6
|
+
|
|
7
|
+
Workflow engine state lives in your application's Postgres database. In multi-tenant environments, architectures frequently run the engine kernel inside a single database transaction per tenant under PostgreSQL row-level security (RLS). Row-level security policies are evaluated against the specific session in which the query executes — typically configured via session variables like `SET LOCAL app.current_tenant_id = '...'`.
|
|
8
|
+
|
|
9
|
+
A standalone dashboard service running as a separate process with its own database connection pool creates an entirely separate database session. In that architecture, there is no configuration that recovers tenant scoping: the external service either runs as a superuser or privileged role that bypasses RLS entirely — exposing all tenant data to any operator with dashboard access — or it cannot inspect runs at all.
|
|
10
|
+
|
|
11
|
+
For that reason, `@bratsos/workflow-engine-console` never opens a database connection. It is constructed from the client, or the active transaction client, your application already owns. It executes queries within your existing session, under your existing RLS policies, with user authentication already verified by your application framework before the handler is invoked. This is the same embedded operational model that Bull Board, Oban Web, and River UI converged on independently.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add @bratsos/workflow-engine-console
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`@bratsos/workflow-engine-console` declares `@bratsos/workflow-engine` as a peer dependency and is released in lockstep with it.
|
|
20
|
+
|
|
21
|
+
## Quick start — Next.js App Router
|
|
22
|
+
|
|
23
|
+
The primary mounting pattern is a catch-all route handler in the Next.js App Router at `app/admin/console/[[...path]]/route.ts`:
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import {
|
|
27
|
+
createPrismaConsoleReadPort,
|
|
28
|
+
createWorkflowConsole,
|
|
29
|
+
} from "@bratsos/workflow-engine-console";
|
|
30
|
+
import { prisma } from "@/lib/prisma";
|
|
31
|
+
import { getSession } from "@/lib/auth";
|
|
32
|
+
|
|
33
|
+
export const runtime = "nodejs";
|
|
34
|
+
export const dynamic = "force-dynamic";
|
|
35
|
+
|
|
36
|
+
const reader = createPrismaConsoleReadPort(prisma);
|
|
37
|
+
|
|
38
|
+
const handler = createWorkflowConsole({
|
|
39
|
+
reader,
|
|
40
|
+
authorize: async ({ action, request }) => {
|
|
41
|
+
const session = await getSession(request);
|
|
42
|
+
if (!session) return false;
|
|
43
|
+
|
|
44
|
+
// Writes require admin role; reads permit operators
|
|
45
|
+
if (
|
|
46
|
+
action === "run.cancel" ||
|
|
47
|
+
action === "run.rerun" ||
|
|
48
|
+
action === "step.signal" ||
|
|
49
|
+
action === "deadLetters.replay"
|
|
50
|
+
) {
|
|
51
|
+
return session.user.role === "admin";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return session.user.role === "admin" || session.user.role === "operator";
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const GET = handler;
|
|
59
|
+
export const POST = handler;
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Mounting elsewhere
|
|
63
|
+
|
|
64
|
+
Because the console handler implements the standard Web Fetch signature `(Request) => Promise<Response>`, it mounts into any modern JavaScript runtime without dedicated adapter packages.
|
|
65
|
+
|
|
66
|
+
### Hono
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
import { Hono } from "hono";
|
|
70
|
+
import {
|
|
71
|
+
createPrismaConsoleReadPort,
|
|
72
|
+
createWorkflowConsole,
|
|
73
|
+
} from "@bratsos/workflow-engine-console";
|
|
74
|
+
|
|
75
|
+
const app = new Hono();
|
|
76
|
+
const reader = createPrismaConsoleReadPort(prisma);
|
|
77
|
+
const handler = createWorkflowConsole({ reader, authorize });
|
|
78
|
+
|
|
79
|
+
app.all("/admin/console/*", (c) => handler(c.req.raw));
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Cloudflare Workers, Deno, and Bun
|
|
83
|
+
|
|
84
|
+
The console handler matches the native fetch handler contract directly:
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
import {
|
|
88
|
+
createPrismaConsoleReadPort,
|
|
89
|
+
createWorkflowConsole,
|
|
90
|
+
} from "@bratsos/workflow-engine-console";
|
|
91
|
+
|
|
92
|
+
const reader = createPrismaConsoleReadPort(prisma);
|
|
93
|
+
const handler = createWorkflowConsole({ reader, authorize });
|
|
94
|
+
|
|
95
|
+
export default {
|
|
96
|
+
fetch(request: Request) {
|
|
97
|
+
return handler(request);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Express, Fastify, and bare `node:http`
|
|
103
|
+
|
|
104
|
+
Runtimes using Node.js `IncomingMessage` and `ServerResponse` streams use the `toNodeHandler` bridge from the `@bratsos/workflow-engine-console/node` subpath export:
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
import express from "express";
|
|
108
|
+
import {
|
|
109
|
+
createPrismaConsoleReadPort,
|
|
110
|
+
createWorkflowConsole,
|
|
111
|
+
} from "@bratsos/workflow-engine-console";
|
|
112
|
+
import { toNodeHandler } from "@bratsos/workflow-engine-console/node";
|
|
113
|
+
|
|
114
|
+
const app = express();
|
|
115
|
+
const reader = createPrismaConsoleReadPort(prisma);
|
|
116
|
+
const handler = createWorkflowConsole({ reader, authorize });
|
|
117
|
+
|
|
118
|
+
app.use("/admin/console", toNodeHandler(handler));
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
There are no per-framework wrapper packages; `Request` and `Response` serve as the universal lowest common denominator.
|
|
122
|
+
|
|
123
|
+
## Mounting inside a transaction (row-level security)
|
|
124
|
+
|
|
125
|
+
When running multi-tenant engines under PostgreSQL row-level security, instantiate the console reader over the transaction client inside your existing transaction block:
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
import {
|
|
129
|
+
createPrismaConsoleReadPort,
|
|
130
|
+
createWorkflowConsole,
|
|
131
|
+
} from "@bratsos/workflow-engine-console";
|
|
132
|
+
import { prisma } from "@/lib/prisma";
|
|
133
|
+
import { getSession } from "@/lib/auth";
|
|
134
|
+
|
|
135
|
+
export async function GET(request: Request): Promise<Response> {
|
|
136
|
+
const session = await getSession(request);
|
|
137
|
+
if (!session) {
|
|
138
|
+
return new Response("Unauthorized", { status: 401 });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return prisma.$transaction(async (tx) => {
|
|
142
|
+
await tx.$executeRawUnsafe(
|
|
143
|
+
`SET LOCAL app.current_tenant_id = '${session.tenantId}'`,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const reader = createPrismaConsoleReadPort(tx);
|
|
147
|
+
const handler = createWorkflowConsole({
|
|
148
|
+
reader,
|
|
149
|
+
authorize: () => true, // Already verified via session
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return handler(request);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const POST = GET;
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
When `createPrismaConsoleReadPort` is passed a transaction client (a client instance where `$transaction` is absent), it deliberately does not issue `SET LOCAL statement_timeout`. The reader must not reconfigure the timeout budget of a transaction it did not open. In that scenario, timeout enforcement belongs to the caller who opened the transaction. When handed a root Prisma client, the reader manages its own bounded transaction per request.
|
|
160
|
+
|
|
161
|
+
## Authorisation
|
|
162
|
+
|
|
163
|
+
The `authorize` callback is required in production. If omitted, every console action is denied by default. Permissive defaults risk exposing operational runs if configuration is omitted.
|
|
164
|
+
|
|
165
|
+
Authorisation runs *after* your host application has handled authentication. The console assumes identity is established and evaluates permissions for specific operations. If the `authorize` callback throws an error, the request fails closed and access is denied.
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
import {
|
|
169
|
+
createWorkflowConsole,
|
|
170
|
+
isWriteAction,
|
|
171
|
+
type ConsoleAuthorizeContext,
|
|
172
|
+
} from "@bratsos/workflow-engine-console";
|
|
173
|
+
|
|
174
|
+
const handler = createWorkflowConsole({
|
|
175
|
+
reader,
|
|
176
|
+
authorize: async ({ action, request, runId }: ConsoleAuthorizeContext) => {
|
|
177
|
+
const user = await authenticateUser(request);
|
|
178
|
+
if (!user) return false;
|
|
179
|
+
|
|
180
|
+
// Read access for authenticated users, mutations restricted to administrators
|
|
181
|
+
if (isWriteAction(action)) {
|
|
182
|
+
return user.role === "admin";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return true;
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The action vocabulary uses discrete verbs rather than HTTP methods, enabling fine-grained capability checks:
|
|
191
|
+
|
|
192
|
+
| Action | Kind | Description |
|
|
193
|
+
|---|---|---|
|
|
194
|
+
| `runs.read` | Read | List workflow runs with filters and keyset pagination |
|
|
195
|
+
| `run.read` | Read | View run details (stages, steps, annotations, logs) and incremental event timeline |
|
|
196
|
+
| `queue.read` | Read | View queue health, job counts by status, oldest pending job, and oldest lease |
|
|
197
|
+
| `suspended.read` | Read | List suspended stages awaiting resumption or polling deadlines |
|
|
198
|
+
| `deadLetters.read` | Read | List undeliverable outbox events routed to the dead-letter queue |
|
|
199
|
+
| `workers.read` | Read | List active worker instances inferred from active job leases |
|
|
200
|
+
| `costs.read` | Read | View aggregate execution costs and token usage by workflow or by day |
|
|
201
|
+
| `run.cancel` | Write | Cancel an active workflow run |
|
|
202
|
+
| `run.rerun` | Write | Rerun a workflow run from a designated stage |
|
|
203
|
+
| `step.signal` | Write | Complete a pending `waitForSignal` durable step with a payload |
|
|
204
|
+
| `deadLetters.replay` | Write | Replay failed outbox events from the dead-letter queue |
|
|
205
|
+
|
|
206
|
+
## Actions
|
|
207
|
+
|
|
208
|
+
The console defaults to read-only mode (`actions: false`). Enabling write actions requires `actions: true` and a `kernel` instance:
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
import { createKernel } from "@bratsos/workflow-engine/kernel";
|
|
212
|
+
import { createWorkflowConsole } from "@bratsos/workflow-engine-console";
|
|
213
|
+
|
|
214
|
+
const kernel = createKernel({ /* ... */ });
|
|
215
|
+
|
|
216
|
+
const handler = createWorkflowConsole({
|
|
217
|
+
reader,
|
|
218
|
+
kernel,
|
|
219
|
+
actions: true,
|
|
220
|
+
authorize: async ({ action }) => {
|
|
221
|
+
// Return boolean based on permissions
|
|
222
|
+
return true;
|
|
223
|
+
},
|
|
224
|
+
onAction: async ({ action, runId, request, result, at }) => {
|
|
225
|
+
auditLogger.info({
|
|
226
|
+
action,
|
|
227
|
+
runId,
|
|
228
|
+
result,
|
|
229
|
+
timestamp: at.toISOString(),
|
|
230
|
+
});
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Mutations never issue direct SQL updates against run records. Instead, writes dispatch standard engine commands through the kernel:
|
|
236
|
+
- `run.cancel` dispatches `{ type: "run.cancel", workflowRunId, reason }`
|
|
237
|
+
- `run.rerun` dispatches `{ type: "run.redrive", workflowRunId, from, definitionVersion? }` — `fromStageId` in the request body still means "from this stage"; pass `from: { kind: "lastFailure" | "start" | "stage" }` and `definitionVersion: "latest"` to move a run stranded on a version nobody serves
|
|
238
|
+
- `step.signal` dispatches `{ type: "step.signal", workflowRunId, stageId, stepId, payload }` — `POST /api/runs/:runId/stages/:stageId/steps/:stepId/signal` with `{ payload }` (optional, defaults to `null`); an unknown run or stage is a 404 and a step that is not a pending signal is a 409, both carrying the kernel's message
|
|
239
|
+
- `deadLetters.replay` dispatches `{ type: "plugin.replayDLQ", maxEvents }`
|
|
240
|
+
|
|
241
|
+
Routing writes through the kernel ensures that execution leases, idempotency guarantees, transition invariants, and outbox publication events remain enforced by the authoritative state machine. The `onAction` hook provides an audit callback invoked immediately after each successful mutation.
|
|
242
|
+
|
|
243
|
+
## The local dev command
|
|
244
|
+
|
|
245
|
+
Repositories containing only background workers lack an HTTP application into which the console can be mounted. The package provides a local CLI command to bridge this gap:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
npx workflow-console --config ./console.config.mjs
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`workflow-console` hosts the identical fetch handler over a local Node.js HTTP server. It is strictly a development tool: it binds exclusively to loopback (`127.0.0.1`) and refuses external interfaces. It includes no authentication mechanisms.
|
|
252
|
+
|
|
253
|
+
The configuration module must export a `ConsoleDevConfig` object (or a function returning one) via default export or named export `console`:
|
|
254
|
+
|
|
255
|
+
```javascript
|
|
256
|
+
// console.config.mjs
|
|
257
|
+
import { PrismaClient } from "@prisma/client";
|
|
258
|
+
import { createPrismaConsoleReadPort } from "@bratsos/workflow-engine-console";
|
|
259
|
+
import { createKernel } from "@bratsos/workflow-engine/kernel";
|
|
260
|
+
|
|
261
|
+
const prisma = new PrismaClient();
|
|
262
|
+
const kernel = createKernel({ /* ... */ });
|
|
263
|
+
|
|
264
|
+
export default {
|
|
265
|
+
reader: createPrismaConsoleReadPort(prisma),
|
|
266
|
+
kernel,
|
|
267
|
+
dispose: async () => {
|
|
268
|
+
await prisma.$disconnect();
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Direct connection escape hatch
|
|
274
|
+
|
|
275
|
+
For rapid local inspection without writing a config file, `--database-url` can be paired with `--allow-direct-connection`:
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
npx workflow-console --database-url postgres://postgres:postgres@localhost:5432/mydb --allow-direct-connection
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
The `--allow-direct-connection` flag requires explicit acknowledgement that connecting directly establishes a separate database session. Application-level row-level security policies will not apply, and the console will read any record visible to the connecting PostgreSQL database role across all tenants.
|
|
282
|
+
|
|
283
|
+
### CLI options
|
|
284
|
+
|
|
285
|
+
| Flag | Type | Default | Description |
|
|
286
|
+
|---|---|---|---|
|
|
287
|
+
| `--config <path>` | string | — | Path to module exporting `{ reader, kernel?, dispose? }` |
|
|
288
|
+
| `--database-url <url>` | string | `DATABASE_URL` | Direct PostgreSQL connection string (requires `--allow-direct-connection`) |
|
|
289
|
+
| `--allow-direct-connection` | boolean | `false` | Acknowledge that direct connections bypass session-scoped row-level security |
|
|
290
|
+
| `--port <n>` | number | `7799` | Port to bind (1–65535) |
|
|
291
|
+
| `--host <addr>` | string | `127.0.0.1` | Loopback address to bind (`127.0.0.1`, `localhost`, `::1`) |
|
|
292
|
+
| `--actions` | boolean | `false` | Enable write actions; requires `kernel` in `--config` |
|
|
293
|
+
| `--poll <ms>` | number | `5000` | UI poll interval in milliseconds |
|
|
294
|
+
| `--help`, `-h` | boolean | `false` | Display command help |
|
|
295
|
+
|
|
296
|
+
## What it shows
|
|
297
|
+
|
|
298
|
+
The console provides seven operational views:
|
|
299
|
+
- **Runs list**: Keyset-paginated list filtered by execution status, workflow ID, workflow type, definition version, and date ranges. Each row carries the run's definition version (shortened, full value on hover) with its redrive count as a `+N` suffix — filtering by version is how "show me everything still pinned to the old version" is answered.
|
|
300
|
+
- **Run detail**: Definition version and redrive count alongside the run's timings and cost, plus the complete execution hierarchy including stage timelines, durable step ledger entries (kind, status, attempt, deadline, and the `externalKey` of `run` steps, so an orphaned provider-side effect can be searched for), annotations, execution logs, and outbox event streams. With actions enabled, a step with `kind === "signal"` and `status === "pending"` offers *Deliver signal* with a validated JSON payload box, and a run carrying a pinned version offers *Redrive on latest version*.
|
|
301
|
+
- **Queue health**: Job distribution by status, enqueue timestamp of the oldest pending job, oldest active lock duration, and overdue polling stages.
|
|
302
|
+
- **Suspended stages**: Stages awaiting external event resumption or deferred polling schedules.
|
|
303
|
+
- **Workers**: Active worker instances derived from heartbeat locks on `job_queue`, reporting active execution count, oldest active lock, and last heartbeat age.
|
|
304
|
+
- **Dead letters**: Outbox events diverted to the dead-letter queue following repeated publication failures.
|
|
305
|
+
- **Costs**: Aggregate execution financial costs and token usage grouped by workflow or by calendar day.
|
|
306
|
+
|
|
307
|
+
## Performance and the indexes it needs
|
|
308
|
+
|
|
309
|
+
To maintain performant queries against operational tables without degraded index scans, the workflow engine's Prisma schema includes composite indexes aligned with console queries:
|
|
310
|
+
- `workflow_runs(createdAt DESC, id DESC)`
|
|
311
|
+
- `workflow_runs(status, createdAt DESC, id DESC)`
|
|
312
|
+
- `workflow_runs(workflowId, createdAt DESC, id DESC)`
|
|
313
|
+
- `workflow_runs(definitionVersion)` and `workflow_runs(status, workflowId, definitionVersion)` for the version filter
|
|
314
|
+
- `job_queue(status, createdAt)`
|
|
315
|
+
- `outbox_events(dlqAt)`
|
|
316
|
+
|
|
317
|
+
When upgrading existing deployments, apply these database migrations before enabling the console to avoid sequential scans over high-volume tables.
|
|
318
|
+
|
|
319
|
+
### Keyset pagination
|
|
320
|
+
|
|
321
|
+
Pagination over workflow runs uses keyset pagination via opaque cursors encoding `(createdAt, id)` tuples. Keyset queries translate directly into index range scans:
|
|
322
|
+
|
|
323
|
+
```sql
|
|
324
|
+
WHERE ("createdAt", id) < ($1::timestamptz AT TIME ZONE 'UTC', $2)
|
|
325
|
+
ORDER BY "createdAt" DESC, id DESC
|
|
326
|
+
LIMIT 51
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
The console deliberately does not compute total record counts and provides no arbitrary page jumping. Executing `COUNT(*)` over multi-million row run tables creates excessive query load; keyset pagination guarantees identical query plans regardless of pagination depth.
|
|
330
|
+
|
|
331
|
+
### Query timeouts
|
|
332
|
+
|
|
333
|
+
The read port enforces a query timeout via `statementTimeoutMs` (default: 15000 ms). If a query exceeds this limit, the database cancels execution and the console API responds with an HTTP 504 status:
|
|
334
|
+
|
|
335
|
+
```json
|
|
336
|
+
{
|
|
337
|
+
"error": {
|
|
338
|
+
"code": "query_timeout",
|
|
339
|
+
"message": "runs.list timed out after 15000ms"
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
The frontend catches this error and renders an inline warning banner while maintaining filter bar interactivity, allowing operators to narrow search parameters rather than experiencing an unresponsive interface.
|
|
345
|
+
|
|
346
|
+
## The UI
|
|
347
|
+
|
|
348
|
+
The frontend is a single-page application built with Preact, compiled at package build time directly into string literals within the distribution bundle. Consumers run no frontend build pipelines, install no client dependencies, introduce no additional packages into their dependency tree, and load no assets from external CDNs.
|
|
349
|
+
|
|
350
|
+
- **Bundle size**: Approximately 40 kB of JavaScript and 13 kB of CSS uncompressed (roughly 15 kB combined under gzip).
|
|
351
|
+
- **Path-agnostic routing**: Navigation routes entirely via the URL fragment (`#/runs`, `#/runs/:id`). The server handler serves identical assets regardless of the mount path without requiring URL rewriting configuration.
|
|
352
|
+
- **Adaptive polling**: Refreshes data automatically every 5 seconds by default (`pollIntervalMs`). Polling suspends when browser tabs lose focus. Polling rates can be fixed via `forcePollInterval: true`.
|
|
353
|
+
- **Styling isolation**: All stylesheet rules are scoped beneath the `#workflow-console-root` container selector to prevent interference with host application layouts. Both dark and light colour schemes are supported.
|
|
354
|
+
- **Content Security Policy**: Hosts enforcing strict CSP rules can provide a nonce via the `cspNonce` configuration option:
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
const handler = createWorkflowConsole({
|
|
358
|
+
reader,
|
|
359
|
+
cspNonce: (request) => request.headers.get("x-csp-nonce") ?? undefined,
|
|
360
|
+
});
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
## Serving your own UI / API only
|
|
364
|
+
|
|
365
|
+
The operational console separates storage queries, API serialization, and UI presentation. `ConsoleReadPort` is a decoupled interface, and the JSON API can be mounted independently if you choose to build custom operational dashboards:
|
|
366
|
+
|
|
367
|
+
- `GET /api/meta`: System capabilities and runtime configuration
|
|
368
|
+
- `GET /api/runs`: Keyset-paginated workflow runs
|
|
369
|
+
- `GET /api/runs/:id`: Complete run detail graph
|
|
370
|
+
- `GET /api/runs/:id/events`: Incremental outbox event stream
|
|
371
|
+
- `GET /api/queue`: Queue metrics and lease telemetry
|
|
372
|
+
- `GET /api/suspended`: Stages waiting on external triggers
|
|
373
|
+
- `GET /api/dead-letters`: Dead-letter queue messages
|
|
374
|
+
- `GET /api/workers`: Active worker instances
|
|
375
|
+
- `GET /api/costs`: Token and financial cost aggregations
|
|
376
|
+
- `POST /api/runs/:id/cancel`: Cancel an execution
|
|
377
|
+
- `POST /api/runs/:id/rerun`: Rerun execution from a stage
|
|
378
|
+
- `POST /api/runs/:id/stages/:stageId/steps/:stepId/signal`: Deliver a payload to a pending signal step
|
|
379
|
+
- `POST /api/dead-letters/replay`: Replay failed events
|
|
380
|
+
|
|
381
|
+
## Custom adapters
|
|
382
|
+
|
|
383
|
+
`ConsoleReadPort` is defined within `@bratsos/workflow-engine-console` rather than added to `@bratsos/workflow-engine`'s `PersistenceCore`. This architecture prevents mandatory console read requirements from complicating third-party storage adapters.
|
|
384
|
+
|
|
385
|
+
Custom storage backends implement `ConsoleReadPort` and declare supported features via `capabilities`:
|
|
386
|
+
|
|
387
|
+
```typescript
|
|
388
|
+
import type {
|
|
389
|
+
ConsoleCapabilities,
|
|
390
|
+
ConsoleReadPort,
|
|
391
|
+
} from "@bratsos/workflow-engine-console";
|
|
392
|
+
|
|
393
|
+
export class CustomConsoleReadPort implements ConsoleReadPort {
|
|
394
|
+
readonly capabilities: ConsoleCapabilities = {
|
|
395
|
+
runs: true,
|
|
396
|
+
steps: true,
|
|
397
|
+
annotations: false,
|
|
398
|
+
queue: true,
|
|
399
|
+
suspended: false,
|
|
400
|
+
deadLetters: false,
|
|
401
|
+
workers: false,
|
|
402
|
+
costs: false,
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// Implement required read methods...
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
The user interface inspects the reader's declared capabilities and omits navigation tabs for unsupported views.
|
|
410
|
+
|
|
411
|
+
## Testing
|
|
412
|
+
|
|
413
|
+
For unit and integration testing without a running PostgreSQL instance, import `createInMemoryConsoleReadPort` from the `@bratsos/workflow-engine-console/testing` subpath:
|
|
414
|
+
|
|
415
|
+
```typescript
|
|
416
|
+
import {
|
|
417
|
+
createInMemoryConsoleReadPort,
|
|
418
|
+
type ConsoleFixtures,
|
|
419
|
+
} from "@bratsos/workflow-engine-console/testing";
|
|
420
|
+
import { createWorkflowConsole } from "@bratsos/workflow-engine-console";
|
|
421
|
+
|
|
422
|
+
const fixtures: ConsoleFixtures = {
|
|
423
|
+
runs: [
|
|
424
|
+
{
|
|
425
|
+
id: "run-1",
|
|
426
|
+
createdAt: new Date(),
|
|
427
|
+
updatedAt: new Date(),
|
|
428
|
+
workflowId: "order-processing",
|
|
429
|
+
workflowName: "Order Processing",
|
|
430
|
+
workflowType: "order",
|
|
431
|
+
status: "COMPLETED",
|
|
432
|
+
startedAt: new Date(),
|
|
433
|
+
completedAt: new Date(),
|
|
434
|
+
duration: 1200,
|
|
435
|
+
totalCost: 0.04,
|
|
436
|
+
totalTokens: 1500,
|
|
437
|
+
priority: 0,
|
|
438
|
+
},
|
|
439
|
+
],
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const reader = createInMemoryConsoleReadPort(fixtures);
|
|
443
|
+
const handler = createWorkflowConsole({
|
|
444
|
+
reader,
|
|
445
|
+
authorize: () => true,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
const response = await handler(new Request("http://localhost/api/runs"));
|
|
449
|
+
const data = await response.json();
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
## What it deliberately does not do
|
|
453
|
+
|
|
454
|
+
The console's scope is explicitly bounded:
|
|
455
|
+
- **No authentication system**: Identity verification is delegated entirely to the host application framework.
|
|
456
|
+
- **No standalone daemon or container image**: It exists as an embedded handler mounted within an existing application.
|
|
457
|
+
- **No separate projection or rollup tables**: Queries execute directly against engine operational tables; historic reporting is limited to retained run rows.
|
|
458
|
+
- **No total run counts**: Aggregate counting queries are avoided to preserve database query performance.
|
|
459
|
+
- **No streaming transports**: Telemetry updates through interval polling rather than persistent WebSocket or Server-Sent Events connections.
|
|
460
|
+
- **No full-text search over payloads**: Arbitrary payload queries across historical runs are omitted to avoid unindexed table scans on operational databases.
|
|
461
|
+
|
|
462
|
+
## License
|
|
463
|
+
|
|
464
|
+
MIT
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The console's write vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* Modelled on verbs rather than HTTP methods, so an `authorize` callback
|
|
5
|
+
* grants "may cancel a run" rather than "may POST". Every write dispatches
|
|
6
|
+
* a kernel command — the console never issues SQL of its own. That is not
|
|
7
|
+
* squeamishness: the kernel owns run state, and a console that wrote
|
|
8
|
+
* `UPDATE workflow_runs SET status='CANCELLED'` would be a second,
|
|
9
|
+
* unaudited writer against state the kernel believes it is alone in
|
|
10
|
+
* changing, skipping the leases, idempotency and outbox events every other
|
|
11
|
+
* path goes through.
|
|
12
|
+
*/
|
|
13
|
+
/** Every action the console can be asked to perform, read or write. */
|
|
14
|
+
declare const CONSOLE_ACTIONS: readonly ["runs.read", "run.read", "queue.read", "suspended.read", "deadLetters.read", "workers.read", "costs.read", "run.cancel", "run.rerun", "step.signal", "deadLetters.replay"];
|
|
15
|
+
type ConsoleAction = (typeof CONSOLE_ACTIONS)[number];
|
|
16
|
+
/** The subset that changes state. Each is refused unless `actions` is enabled. */
|
|
17
|
+
declare const WRITE_ACTIONS: readonly ConsoleAction[];
|
|
18
|
+
declare function isWriteAction(action: ConsoleAction): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* The minimum of the engine's kernel the console needs: a dispatcher.
|
|
21
|
+
*
|
|
22
|
+
* Declared structurally rather than imported so the package carries no
|
|
23
|
+
* runtime dependency on the engine and nothing in the engine can come to
|
|
24
|
+
* depend on the console.
|
|
25
|
+
*/
|
|
26
|
+
interface ConsoleKernel {
|
|
27
|
+
dispatch(command: {
|
|
28
|
+
readonly type: string;
|
|
29
|
+
readonly [key: string]: unknown;
|
|
30
|
+
}): Promise<unknown>;
|
|
31
|
+
}
|
|
32
|
+
/** What `authorize` is asked, and what `onAction` is told afterwards. */
|
|
33
|
+
interface ConsoleAuthorizeContext {
|
|
34
|
+
action: ConsoleAction;
|
|
35
|
+
request: Request;
|
|
36
|
+
/** Present for the actions scoped to one run. */
|
|
37
|
+
runId?: string;
|
|
38
|
+
/** Present for the actions scoped to one durable step (`step.signal`). */
|
|
39
|
+
stageId?: string;
|
|
40
|
+
stepId?: string;
|
|
41
|
+
}
|
|
42
|
+
interface ConsoleActionEvent {
|
|
43
|
+
action: ConsoleAction;
|
|
44
|
+
runId?: string;
|
|
45
|
+
stageId?: string;
|
|
46
|
+
stepId?: string;
|
|
47
|
+
request: Request;
|
|
48
|
+
result: unknown;
|
|
49
|
+
at: Date;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { type ConsoleKernel as C, WRITE_ACTIONS as W, CONSOLE_ACTIONS as a, type ConsoleAction as b, type ConsoleActionEvent as c, type ConsoleAuthorizeContext as d, isWriteAction as i };
|