bunqueue-dashboard 0.0.33 → 0.0.35

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.
Files changed (60) hide show
  1. package/README.md +2 -2
  2. package/agent/manager.ts +3 -3
  3. package/agent/server.ts +72 -0
  4. package/agent/workflows.ts +491 -0
  5. package/dist/.vite/manifest.json +93 -81
  6. package/dist/assets/AddJob-CG28MSxU.js +3 -0
  7. package/dist/assets/{Alerts-C39RrGbQ.js → Alerts-D9JojJrj.js} +1 -1
  8. package/dist/assets/{Benchmark-CM60tHbf.js → Benchmark-BVjUH9dG.js} +1 -1
  9. package/dist/assets/{BulkAddJobs-BjL-xcQy.js → BulkAddJobs-BHzUCMj_.js} +1 -1
  10. package/dist/assets/{CopilotPanel-BGK1rw-V.js → CopilotPanel-COLw-atS.js} +1 -1
  11. package/dist/assets/{Cron-s1e-jgOo.js → Cron-DoEHDHwy.js} +1 -1
  12. package/dist/assets/CronManager-vulcesqJ.js +1 -0
  13. package/dist/assets/{Database-ZluStWMz.js → Database-DlB_t04Z.js} +1 -1
  14. package/dist/assets/{Diagnostics-C0N5z5j-.js → Diagnostics-DddCUgeR.js} +1 -1
  15. package/dist/assets/{Dlq-CtcLzs-b.js → Dlq-DMKrsSMP.js} +1 -1
  16. package/dist/assets/{DlqControl-B_Z8KKn5.js → DlqControl-9m0Re_TH.js} +1 -1
  17. package/dist/assets/{DlqPro-B68en6AG.js → DlqPro-Dmw-0wta.js} +1 -1
  18. package/dist/assets/{Flows-CD_bkCBM.js → Flows-C3-9nXW4.js} +1 -1
  19. package/dist/assets/JobInspector-DwIfABmI.js +2 -0
  20. package/dist/assets/{Jobs-BUtcBKKk.js → Jobs-j8tTS3NC.js} +1 -1
  21. package/dist/assets/JobsPro-CS__CJJl.js +1 -0
  22. package/dist/assets/{Logs-CmVODYCI.js → Logs-BB-VDPA1.js} +1 -1
  23. package/dist/assets/{LogsPro-CJ-Il3LP.js → LogsPro-RNtSQZRT.js} +1 -1
  24. package/dist/assets/{McpServer-dMjyJKgT.js → McpServer-CbaU52l2.js} +2 -2
  25. package/dist/assets/{Metrics-Dsbeplz-.js → Metrics-ivcddPwT.js} +1 -1
  26. package/dist/assets/{MetricsPro-BqxurxrJ.js → MetricsPro-xjZno4vg.js} +1 -1
  27. package/dist/assets/{Overview-DH9C2dsf.js → Overview-D84aD2ph.js} +1 -1
  28. package/dist/assets/{Pagination-DpxiBnt8.js → Pagination-Bfi4vkpp.js} +1 -1
  29. package/dist/assets/{QueueActions-D_QGBaVm.js → QueueActions-DTbD6uek.js} +1 -1
  30. package/dist/assets/{QueueControl-V0DJ6qk6.js → QueueControl-B5xK_SQ-.js} +1 -1
  31. package/dist/assets/{QueueDetail-BgKAMJqf.js → QueueDetail-BH59kwm9.js} +1 -1
  32. package/dist/assets/{QueueDetailPro-BkHYzGiq.js → QueueDetailPro-DoN25ndy.js} +1 -1
  33. package/dist/assets/{Queues-2_v7DcNi.js → Queues-CKMJqFHX.js} +1 -1
  34. package/dist/assets/{QueuesOverview-BySSkmB4.js → QueuesOverview-BX1lzojl.js} +1 -1
  35. package/dist/assets/{S3Backup-CPADzKQE.js → S3Backup-Bz3GfoZ9.js} +1 -1
  36. package/dist/assets/{S3BackupPro-CSrQYIDL.js → S3BackupPro-BC_6-uX-.js} +1 -1
  37. package/dist/assets/{ServerControl-C0tI1VDx.js → ServerControl-CSSrN8G5.js} +2 -2
  38. package/dist/assets/{Settings-CSzoM5up.js → Settings-DkqmZUIS.js} +1 -1
  39. package/dist/assets/{Usage-B4B7tmrc.js → Usage-Ba20p0xd.js} +1 -1
  40. package/dist/assets/{UsagePro-CCBpgSDy.js → UsagePro-C2pQwjN9.js} +1 -1
  41. package/dist/assets/{Webhooks-aJz-GisQ.js → Webhooks-CVuuvb0L.js} +1 -1
  42. package/dist/assets/{Workers-Bg8xKjkg.js → Workers-C1wPU6CS.js} +1 -1
  43. package/dist/assets/{WorkersPro-BOOCrXf8.js → WorkersPro-B0vEkC0T.js} +1 -1
  44. package/dist/assets/Workflows-C8Qmkflf.js +1 -0
  45. package/dist/assets/{flowMutationSafety-DVD1IMr-.js → flowMutationSafety-CoOp94wS.js} +1 -1
  46. package/dist/assets/index-BCNzbhhD.css +1 -0
  47. package/dist/assets/index-CAtsVmyh.js +8 -0
  48. package/dist/assets/install-D0eRLf9F.js +3 -0
  49. package/dist/assets/{upstreamPaths-nG8N1XAz.js → upstreamPaths-D_LPsv7b.js} +1 -1
  50. package/dist/assets/{useThroughputSeries-tb26_lkb.js → useThroughputSeries-DjxGN8ob.js} +1 -1
  51. package/dist/index.html +3 -3
  52. package/package.json +4 -1
  53. package/scripts/embedded.gen.ts +122 -120
  54. package/dist/assets/AddJob-ytpBLTCJ.js +0 -3
  55. package/dist/assets/CronManager-BostU7BX.js +0 -1
  56. package/dist/assets/JobInspector-DwRtyMz0.js +0 -2
  57. package/dist/assets/JobsPro-BK_xd4ji.js +0 -1
  58. package/dist/assets/index-BWoUj86M.css +0 -1
  59. package/dist/assets/index-Bn2R06qm.js +0 -8
  60. package/dist/assets/install-CUS8Kijb.js +0 -3
package/README.md CHANGED
@@ -90,7 +90,7 @@ The full dashboard running on sample data, no server needed.
90
90
  bunqueue exposes a rich HTTP API, but operating it by hand (curl, ad-hoc scripts) is slow and
91
91
  error-prone. This dashboard is a production-oriented operator console: it exposes the verified,
92
92
  safe subset of the API, fails closed where
93
- [v2.8.55](https://github.com/egeominotti/bunqueue/releases/tag/v2.8.55) lacks atomic
93
+ [v2.8.57](https://github.com/egeominotti/bunqueue/releases/tag/v2.8.57) lacks atomic
94
94
  flow-safety guarantees, and also manages the
95
95
  server *process* through a separate guarded agent.
96
96
 
@@ -112,7 +112,7 @@ never imports or modifies bunqueue itself, so it tracks any bunqueue server you
112
112
  | **Ops** | Control ▸ Diagnostics | Health, ping, storage, memory, connections, totals |
113
113
  | **Browse** | Queues / Jobs / DLQ / Cron / Metrics / Workers / Logs | Read-only browsing with basic actions |
114
114
 
115
- > Job actions are gated by the v2.8.55 flow contract. Every DLQ retry and completed-job requeue is
115
+ > Job actions are gated by the v2.8.57 flow contract. Every DLQ retry and completed-job requeue is
116
116
  > unavailable: the DLQ GET + POST sequence has no atomic generation/state/topology precondition and
117
117
  > can target a job recreated under the same ID, while `retryCompleted` does not rebuild dependency
118
118
  > registration or flow order. Cancel, Discard, Drain, Clean, Obliterate and DLQ Purge also fail
package/agent/manager.ts CHANGED
@@ -3,13 +3,13 @@
3
3
  * can start / stop / restart it. Runs under Bun (uses Bun.spawn).
4
4
  *
5
5
  * It does NOT import or touch bunqueue source — it just launches a configurable
6
- * command (default `bunqueue start`) with the ports/data-path passed as env.
6
+ * command (default `bunx bunqueue@2.8.57 start`) with the ports/data-path passed as env.
7
7
  */
8
8
 
9
9
  export type Status = 'running' | 'stopped' | 'starting' | 'stopping';
10
10
 
11
11
  export interface ServerConfig {
12
- /** Command to launch the server, e.g. "bunqueue start" or "bun run src/main.ts". */
12
+ /** Command to launch the server, e.g. "bunx bunqueue@2.8.57 start" or "bun run src/main.ts". */
13
13
  command: string;
14
14
  httpPort: number;
15
15
  tcpPort: number;
@@ -62,7 +62,7 @@ interface CancelablePipeReader {
62
62
 
63
63
  function defaultConfig(): ServerConfig {
64
64
  return {
65
- command: process.env.BUNQUEUE_START_CMD || 'bunqueue start',
65
+ command: process.env.BUNQUEUE_START_CMD || 'bunx bunqueue@2.8.57 start',
66
66
  httpPort: Number(process.env.HTTP_PORT) || 6790,
67
67
  tcpPort: Number(process.env.TCP_PORT) || 6789,
68
68
  dataPath: process.env.BUNQUEUE_DATA_PATH || './data/bunq.db',
package/agent/server.ts CHANGED
@@ -42,6 +42,14 @@ import {
42
42
  queryWithTimeout,
43
43
  } from './db';
44
44
  import { type ProcessManager, validateConfigPatch } from './manager';
45
+ import {
46
+ type WorkflowStateFilter,
47
+ type WorkflowStoreKind,
48
+ workflowExecution,
49
+ workflowExecutions,
50
+ workflowStats,
51
+ WORKFLOW_STATES,
52
+ } from './workflows';
45
53
 
46
54
  export interface AgentOptions {
47
55
  allowedOrigins: string[];
@@ -241,6 +249,70 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
241
249
  return json(mgr.setConfig(patch), 200, origin);
242
250
  }
243
251
 
252
+ // Bunqueue 2.8.57 persists Workflow Engine executions in the same
253
+ // dataPath. These endpoints are intentionally read-only: control methods
254
+ // need the live Engine instance and its registered workflow definitions.
255
+ if (pathname === '/workflows/stats' && method === 'GET') {
256
+ return json({ ok: true, ...workflowStats(mgr.getConfig().dataPath) }, 200, origin);
257
+ }
258
+ if (pathname === '/workflows' && method === 'GET') {
259
+ const sp = new URL(req.url).searchParams;
260
+ const allowed = new Set(['kind', 'workflowName', 'state', 'limit', 'offset']);
261
+ for (const key of sp.keys()) {
262
+ if (!allowed.has(key)) throw new Error(`Unknown workflow option: ${key}`);
263
+ if (sp.getAll(key).length !== 1) throw new Error(`Duplicate workflow option: ${key}`);
264
+ }
265
+ const kind = sp.get('kind') ?? 'active';
266
+ if (kind !== 'active' && kind !== 'archive') {
267
+ throw new Error('Workflow kind must be "active" or "archive"');
268
+ }
269
+ const state = sp.get('state') || undefined;
270
+ if (state && state !== 'compensation' && !(WORKFLOW_STATES as readonly string[]).includes(state)) {
271
+ throw new Error('Unknown workflow execution state');
272
+ }
273
+ const integer = (name: 'limit' | 'offset', fallback: number): number => {
274
+ const raw = sp.get(name);
275
+ if (raw === null) return fallback;
276
+ if (!/^\d+$/.test(raw)) throw new Error(`Workflow ${name} must be an integer`);
277
+ return Number(raw);
278
+ };
279
+ return json(
280
+ {
281
+ ok: true,
282
+ ...workflowExecutions(mgr.getConfig().dataPath, {
283
+ kind: kind as WorkflowStoreKind,
284
+ workflowName: sp.get('workflowName') || undefined,
285
+ state: state as WorkflowStateFilter | undefined,
286
+ limit: integer('limit', 50),
287
+ offset: integer('offset', 0),
288
+ }),
289
+ },
290
+ 200,
291
+ origin
292
+ );
293
+ }
294
+ if (pathname.startsWith('/workflows/') && method === 'GET') {
295
+ const rawId = pathname.slice('/workflows/'.length);
296
+ if (!rawId || rawId.includes('/')) return json({ ok: false, error: 'Not found' }, 404, origin);
297
+ const sp = new URL(req.url).searchParams;
298
+ for (const key of sp.keys()) {
299
+ if (key !== 'kind') throw new Error(`Unknown workflow detail option: ${key}`);
300
+ if (sp.getAll(key).length !== 1) throw new Error(`Duplicate workflow detail option: ${key}`);
301
+ }
302
+ const kind = sp.get('kind') ?? 'active';
303
+ if (kind !== 'active' && kind !== 'archive') {
304
+ throw new Error('Workflow kind must be "active" or "archive"');
305
+ }
306
+ const execution = workflowExecution(
307
+ mgr.getConfig().dataPath,
308
+ decodeURIComponent(rawId),
309
+ kind
310
+ );
311
+ return execution
312
+ ? json({ ok: true, execution }, 200, origin)
313
+ : json({ ok: false, error: 'Workflow execution not found' }, 404, origin);
314
+ }
315
+
244
316
  // Read-only SQLite inspector (agent/db.ts opens every connection
245
317
  // readonly, so none of these can mutate the store). The POST query
246
318
  // endpoint rides the same token gate as other mutating methods.
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Read-only observability adapter for Bunqueue 2.8.57's Workflow Engine store.
3
+ *
4
+ * Workflow control belongs to the live `Engine` instance because it owns the
5
+ * registered handlers. The dashboard agent therefore reads persisted state but
6
+ * never writes rows or pretends that a SQLite edit is equivalent to signal(),
7
+ * recover(), resumeCompensation(), or abandonCompensation().
8
+ */
9
+ import { existsSync } from 'node:fs';
10
+ import { Database } from 'bun:sqlite';
11
+ import { Unpackr } from 'msgpackr';
12
+ import { MissingDbError } from './db';
13
+
14
+ export const WORKFLOW_STATES = [
15
+ 'running',
16
+ 'waiting',
17
+ 'completed',
18
+ 'failed',
19
+ 'compensating',
20
+ 'compensation-stuck',
21
+ ] as const;
22
+
23
+ export type WorkflowExecutionState = (typeof WORKFLOW_STATES)[number];
24
+ export type WorkflowStateFilter = WorkflowExecutionState | 'compensation';
25
+ export type WorkflowStoreKind = 'active' | 'archive';
26
+
27
+ const MAX_PAGE = 100;
28
+ const MAX_OFFSET = 1_000_000;
29
+ const MAX_FILTER = 256;
30
+ const MAX_DETAIL_BLOB = 4 * 1024 * 1024;
31
+ const MAX_META_BLOB = 256 * 1024;
32
+ const MAX_JSON_NODES = 25_000;
33
+ const MAX_JSON_DEPTH = 40;
34
+ const MAX_STRING = 250_000;
35
+
36
+ const unpackr = new Unpackr({ structuredClone: true });
37
+
38
+ interface WorkflowRowBase {
39
+ id: string;
40
+ workflow_name: string;
41
+ state: string;
42
+ current_node_index: number;
43
+ created_at: number;
44
+ updated_at: number;
45
+ archived_at?: number | null;
46
+ meta?: Uint8Array | null;
47
+ }
48
+
49
+ export interface WorkflowExecutionSummary {
50
+ id: string;
51
+ workflowName: string;
52
+ state: WorkflowExecutionState;
53
+ currentNodeIndex: number;
54
+ createdAt: number;
55
+ updatedAt: number;
56
+ archivedAt?: number;
57
+ rollbackStatus?: string;
58
+ failureReason?: string;
59
+ parentExecutionId?: string;
60
+ definitionHash?: string;
61
+ }
62
+
63
+ export interface WorkflowExecutionDetail extends WorkflowExecutionSummary {
64
+ input: unknown;
65
+ steps: Record<string, unknown>;
66
+ resolvedSteps?: string[];
67
+ signals: Record<string, unknown>;
68
+ decisions?: Record<string, unknown>;
69
+ committedAt?: number;
70
+ }
71
+
72
+ export interface WorkflowStats {
73
+ available: boolean;
74
+ activeTotal: number;
75
+ archiveTotal: number;
76
+ states: Record<WorkflowExecutionState, number>;
77
+ workflowNames: string[];
78
+ }
79
+
80
+ function open(path: string): Database {
81
+ if (!existsSync(path)) {
82
+ throw new MissingDbError(
83
+ `Database not found at "${path}" — configure the Workflow Engine dataPath and start it once.`
84
+ );
85
+ }
86
+ try {
87
+ return new Database(path, { readonly: true });
88
+ } catch (error) {
89
+ throw new Error(`Could not open workflow database: ${(error as Error).message}`);
90
+ }
91
+ }
92
+
93
+ function tableName(kind: WorkflowStoreKind): string {
94
+ return kind === 'archive' ? 'workflow_executions_archive' : 'workflow_executions';
95
+ }
96
+
97
+ function columns(db: Database, table: string): Set<string> {
98
+ return new Set(
99
+ (db.query(`PRAGMA table_info("${table}")`).all() as { name: string }[]).map((row) => row.name)
100
+ );
101
+ }
102
+
103
+ function hasTable(db: Database, table: string): boolean {
104
+ return Boolean(
105
+ db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table)
106
+ );
107
+ }
108
+
109
+ const REQUIRED_COLUMNS = [
110
+ 'id',
111
+ 'workflow_name',
112
+ 'state',
113
+ 'input',
114
+ 'steps',
115
+ 'current_node_index',
116
+ 'resolved_steps',
117
+ 'signals',
118
+ 'created_at',
119
+ 'updated_at',
120
+ ];
121
+
122
+ function schema(db: Database, kind: WorkflowStoreKind): { table: string; hasMeta: boolean } | null {
123
+ const table = tableName(kind);
124
+ if (!hasTable(db, table)) return null;
125
+ const found = columns(db, table);
126
+ const missing = REQUIRED_COLUMNS.filter((name) => !found.has(name));
127
+ if (kind === 'archive' && !found.has('archived_at')) missing.push('archived_at');
128
+ if (missing.length > 0) {
129
+ throw new Error(`Invalid ${table} schema: missing ${missing.join(', ')}`);
130
+ }
131
+ return { table, hasMeta: found.has('meta') };
132
+ }
133
+
134
+ function validState(value: string): value is WorkflowExecutionState {
135
+ return (WORKFLOW_STATES as readonly string[]).includes(value);
136
+ }
137
+
138
+ function assertRow(row: WorkflowRowBase): WorkflowExecutionSummary {
139
+ if (
140
+ typeof row.id !== 'string' ||
141
+ typeof row.workflow_name !== 'string' ||
142
+ row.workflow_name.length < 1 ||
143
+ row.workflow_name.length > MAX_FILTER ||
144
+ !validState(row.state) ||
145
+ !Number.isSafeInteger(row.current_node_index) ||
146
+ !Number.isFinite(row.created_at) ||
147
+ !Number.isFinite(row.updated_at)
148
+ ) {
149
+ throw new Error('Workflow store contains an invalid execution row');
150
+ }
151
+ const meta = decodeObject(row.meta, MAX_META_BLOB, 'meta');
152
+ return {
153
+ id: row.id,
154
+ workflowName: row.workflow_name,
155
+ state: row.state,
156
+ currentNodeIndex: row.current_node_index,
157
+ createdAt: row.created_at,
158
+ updatedAt: row.updated_at,
159
+ ...(typeof row.archived_at === 'number' ? { archivedAt: row.archived_at } : {}),
160
+ ...(typeof meta.rollbackStatus === 'string' ? { rollbackStatus: meta.rollbackStatus } : {}),
161
+ ...(typeof meta.failureReason === 'string' ? { failureReason: meta.failureReason } : {}),
162
+ ...(typeof meta.parentExecutionId === 'string'
163
+ ? { parentExecutionId: meta.parentExecutionId }
164
+ : {}),
165
+ ...(typeof meta.definitionHash === 'string' ? { definitionHash: meta.definitionHash } : {}),
166
+ };
167
+ }
168
+
169
+ const STEP_STATES = new Set(['pending', 'running', 'completed', 'failed']);
170
+ const COMPENSATION_STATES = new Set([
171
+ 'compensated',
172
+ 'compensation-failed',
173
+ 'compensation-skipped',
174
+ ]);
175
+
176
+ function optionalFinite(record: Record<string, unknown>, key: string): boolean {
177
+ return !Object.hasOwn(record, key) ||
178
+ (typeof record[key] === 'number' && Number.isFinite(record[key]));
179
+ }
180
+
181
+ function optionalInteger(record: Record<string, unknown>, key: string): boolean {
182
+ return !Object.hasOwn(record, key) ||
183
+ (typeof record[key] === 'number' && Number.isSafeInteger(record[key]) && record[key] >= 0);
184
+ }
185
+
186
+ function assertSteps(value: unknown): Record<string, unknown> {
187
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
188
+ throw new Error('Workflow steps must decode to an object');
189
+ }
190
+ for (const [name, candidate] of Object.entries(value)) {
191
+ if (!name || candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)) {
192
+ throw new Error('Workflow store contains an invalid step record');
193
+ }
194
+ const step = candidate as Record<string, unknown>;
195
+ if (
196
+ typeof step.status !== 'string' ||
197
+ !STEP_STATES.has(step.status) ||
198
+ (Object.hasOwn(step, 'error') && typeof step.error !== 'string') ||
199
+ (Object.hasOwn(step, 'compensatable') && typeof step.compensatable !== 'boolean') ||
200
+ (Object.hasOwn(step, 'idempotencyKey') && typeof step.idempotencyKey !== 'string') ||
201
+ (Object.hasOwn(step, 'childExecutionId') && typeof step.childExecutionId !== 'string') ||
202
+ !optionalFinite(step, 'startedAt') ||
203
+ !optionalFinite(step, 'completedAt') ||
204
+ !optionalInteger(step, 'attempts') ||
205
+ !optionalInteger(step, 'loopIndex') ||
206
+ !optionalInteger(step, 'occurrence')
207
+ ) {
208
+ throw new Error('Workflow store contains an invalid step record');
209
+ }
210
+ if (Object.hasOwn(step, 'compensation')) {
211
+ const candidateCompensation = step.compensation;
212
+ if (
213
+ candidateCompensation === null ||
214
+ typeof candidateCompensation !== 'object' ||
215
+ Array.isArray(candidateCompensation)
216
+ ) {
217
+ throw new Error('Workflow store contains an invalid compensation record');
218
+ }
219
+ const compensation = candidateCompensation as Record<string, unknown>;
220
+ if (
221
+ typeof compensation.status !== 'string' ||
222
+ !COMPENSATION_STATES.has(compensation.status) ||
223
+ typeof compensation.at !== 'number' ||
224
+ !Number.isFinite(compensation.at) ||
225
+ (Object.hasOwn(compensation, 'error') && typeof compensation.error !== 'string')
226
+ ) {
227
+ throw new Error('Workflow store contains an invalid compensation record');
228
+ }
229
+ }
230
+ }
231
+ return value as Record<string, unknown>;
232
+ }
233
+
234
+ function decode(blob: Uint8Array | null | undefined, cap: number, label: string): unknown {
235
+ if (!blob) return null;
236
+ if (!(blob instanceof Uint8Array)) throw new Error(`Workflow ${label} is not a BLOB`);
237
+ if (blob.byteLength > cap) {
238
+ throw new Error(`Workflow ${label} exceeds the ${cap}-byte inspection limit`);
239
+ }
240
+ try {
241
+ return unpackr.unpack(blob);
242
+ } catch (error) {
243
+ throw new Error(`Could not decode workflow ${label}: ${(error as Error).message}`);
244
+ }
245
+ }
246
+
247
+ function decodeObject(
248
+ blob: Uint8Array | null | undefined,
249
+ cap: number,
250
+ label: string
251
+ ): Record<string, unknown> {
252
+ const value = decode(blob, cap, label);
253
+ if (value == null) return {};
254
+ if (typeof value !== 'object' || Array.isArray(value)) {
255
+ throw new Error(`Workflow ${label} must decode to an object`);
256
+ }
257
+ return value as Record<string, unknown>;
258
+ }
259
+
260
+ /** Convert decoded MessagePack extensions to a bounded, JSON-safe value. */
261
+ function jsonSafe(value: unknown): unknown {
262
+ let nodes = 0;
263
+ const seen = new WeakSet<object>();
264
+ const visit = (entry: unknown, depth: number): unknown => {
265
+ nodes++;
266
+ if (nodes > MAX_JSON_NODES) throw new Error('Workflow detail exceeds the inspection node limit');
267
+ if (depth > MAX_JSON_DEPTH) throw new Error('Workflow detail exceeds the inspection depth limit');
268
+ if (entry == null || typeof entry === 'boolean' || typeof entry === 'number') return entry;
269
+ if (typeof entry === 'string') {
270
+ if (entry.length > MAX_STRING) throw new Error('Workflow detail contains an oversized string');
271
+ return entry;
272
+ }
273
+ if (typeof entry === 'bigint') return entry.toString();
274
+ if (typeof entry === 'undefined') return null;
275
+ if (typeof entry !== 'object') return String(entry);
276
+ if (seen.has(entry)) throw new Error('Workflow detail contains a cyclic value');
277
+ seen.add(entry);
278
+ try {
279
+ if (entry instanceof Date) return entry.toISOString();
280
+ if (entry instanceof Uint8Array) return `<binary ${entry.byteLength} B>`;
281
+ if (entry instanceof Map) {
282
+ const out = Object.create(null) as Record<string, unknown>;
283
+ for (const [key, item] of entry) out[String(key)] = visit(item, depth + 1);
284
+ return out;
285
+ }
286
+ if (entry instanceof Set) return [...entry].map((item) => visit(item, depth + 1));
287
+ if (Array.isArray(entry)) return entry.map((item) => visit(item, depth + 1));
288
+ const out = Object.create(null) as Record<string, unknown>;
289
+ for (const [key, item] of Object.entries(entry)) out[key] = visit(item, depth + 1);
290
+ return out;
291
+ } finally {
292
+ seen.delete(entry);
293
+ }
294
+ };
295
+ return visit(value, 0);
296
+ }
297
+
298
+ export function workflowStats(
299
+ path: string,
300
+ /** @internal Deterministic concurrency seam used by the SQLite snapshot regression test. */
301
+ afterActiveTotals?: () => void
302
+ ): WorkflowStats {
303
+ const db = open(path);
304
+ try {
305
+ const active = schema(db, 'active');
306
+ const archive = schema(db, 'archive');
307
+ const states = Object.fromEntries(WORKFLOW_STATES.map((state) => [state, 0])) as Record<
308
+ WorkflowExecutionState,
309
+ number
310
+ >;
311
+ if (!active) {
312
+ return { available: false, activeTotal: 0, archiveTotal: 0, states, workflowNames: [] };
313
+ }
314
+ const snapshot = db.transaction((): WorkflowStats => {
315
+ const stateColumns = WORKFLOW_STATES.map(
316
+ (state, index) => `SUM(CASE WHEN state = '${state}' THEN 1 ELSE 0 END) AS state_${index}`
317
+ ).join(', ');
318
+ const totals = db
319
+ .query(`SELECT COUNT(*) AS total, ${stateColumns} FROM workflow_executions`)
320
+ .get() as { total: number } & Record<`state_${number}`, number | null>;
321
+ for (const [index, state] of WORKFLOW_STATES.entries()) {
322
+ states[state] = totals[`state_${index}`] ?? 0;
323
+ }
324
+ const recognizedTotal = Object.values(states).reduce((sum, count) => sum + count, 0);
325
+ if (recognizedTotal !== totals.total) {
326
+ throw new Error('Workflow store contains an unknown execution state');
327
+ }
328
+ afterActiveTotals?.();
329
+ const names = db
330
+ .query(
331
+ archive
332
+ ? `SELECT workflow_name FROM (
333
+ SELECT workflow_name FROM workflow_executions
334
+ UNION
335
+ SELECT workflow_name FROM workflow_executions_archive
336
+ ) ORDER BY workflow_name LIMIT 500`
337
+ : 'SELECT DISTINCT workflow_name FROM workflow_executions ORDER BY workflow_name LIMIT 500'
338
+ )
339
+ .all() as { workflow_name: unknown }[];
340
+ const workflowNames = names.map((row) => {
341
+ if (
342
+ typeof row.workflow_name !== 'string' ||
343
+ row.workflow_name.length < 1 ||
344
+ row.workflow_name.length > MAX_FILTER
345
+ ) {
346
+ throw new Error(`Workflow store contains an invalid workflow name`);
347
+ }
348
+ return row.workflow_name;
349
+ });
350
+ const archiveTotal = archive
351
+ ? ((db.query('SELECT COUNT(*) AS count FROM workflow_executions_archive').get() as {
352
+ count: number;
353
+ }).count ?? 0)
354
+ : 0;
355
+ return {
356
+ available: true,
357
+ activeTotal: totals.total,
358
+ archiveTotal,
359
+ states,
360
+ workflowNames,
361
+ };
362
+ });
363
+ return snapshot.deferred();
364
+ } finally {
365
+ db.close();
366
+ }
367
+ }
368
+
369
+ export function workflowExecutions(
370
+ path: string,
371
+ options: {
372
+ kind?: WorkflowStoreKind;
373
+ workflowName?: string;
374
+ state?: WorkflowStateFilter;
375
+ limit?: number;
376
+ offset?: number;
377
+ } = {},
378
+ /** @internal Deterministic concurrency seam used by the SQLite snapshot regression test. */
379
+ afterRowsRead?: () => void
380
+ ): { available: boolean; executions: WorkflowExecutionSummary[]; total: number; limit: number; offset: number } {
381
+ const kind = options.kind ?? 'active';
382
+ const limit = options.limit ?? 50;
383
+ const offset = options.offset ?? 0;
384
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_PAGE) {
385
+ throw new Error(`Workflow limit must be an integer between 1 and ${MAX_PAGE}`);
386
+ }
387
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > MAX_OFFSET) {
388
+ throw new Error(`Workflow offset must be an integer between 0 and ${MAX_OFFSET}`);
389
+ }
390
+ if (options.workflowName !== undefined && (!options.workflowName || options.workflowName.length > MAX_FILTER)) {
391
+ throw new Error(`Workflow name must contain 1–${MAX_FILTER} characters`);
392
+ }
393
+ if (options.state !== undefined && options.state !== 'compensation' && !validState(options.state)) {
394
+ throw new Error('Unknown workflow execution state');
395
+ }
396
+
397
+ const db = open(path);
398
+ try {
399
+ const info = schema(db, kind);
400
+ if (!info) return { available: false, executions: [], total: 0, limit, offset };
401
+ const where: string[] = [];
402
+ const bindings: (string | number)[] = [];
403
+ if (options.workflowName) {
404
+ where.push('workflow_name = ?');
405
+ bindings.push(options.workflowName);
406
+ }
407
+ if (options.state) {
408
+ if (options.state === 'compensation') {
409
+ where.push("state IN ('compensating', 'compensation-stuck')");
410
+ } else {
411
+ where.push('state = ?');
412
+ bindings.push(options.state);
413
+ }
414
+ }
415
+ const predicate = where.length ? ` WHERE ${where.join(' AND ')}` : '';
416
+ const archiveColumn = kind === 'archive' ? ', archived_at' : '';
417
+ const metaColumn = info.hasMeta ? ', meta' : '';
418
+ db.run('BEGIN');
419
+ let transactionOpen = true;
420
+ try {
421
+ const rows = db
422
+ .query(
423
+ `SELECT id, workflow_name, state, current_node_index, created_at, updated_at${archiveColumn}${metaColumn}
424
+ FROM ${info.table}${predicate} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`
425
+ )
426
+ .all(...bindings, limit, offset) as WorkflowRowBase[];
427
+ afterRowsRead?.();
428
+ const count = db.query(`SELECT COUNT(*) AS count FROM ${info.table}${predicate}`).get(...bindings) as {
429
+ count: number;
430
+ };
431
+ const executions = rows.map(assertRow);
432
+ db.run('COMMIT');
433
+ transactionOpen = false;
434
+ return { available: true, executions, total: count.count, limit, offset };
435
+ } finally {
436
+ if (transactionOpen) {
437
+ try {
438
+ db.run('ROLLBACK');
439
+ } catch {
440
+ // Preserve the original query/decoding error if SQLite already closed the transaction.
441
+ }
442
+ }
443
+ }
444
+ } finally {
445
+ db.close();
446
+ }
447
+ }
448
+
449
+ export function workflowExecution(
450
+ path: string,
451
+ id: string,
452
+ kind: WorkflowStoreKind = 'active'
453
+ ): WorkflowExecutionDetail | null {
454
+ if (!id || id.length > 1024) throw new Error('Workflow execution id must contain 1–1024 characters');
455
+ const db = open(path);
456
+ try {
457
+ const info = schema(db, kind);
458
+ if (!info) return null;
459
+ const row = db.query(`SELECT * FROM ${info.table} WHERE id = ?`).get(id) as
460
+ | (WorkflowRowBase & {
461
+ input: Uint8Array | null;
462
+ steps: Uint8Array | null;
463
+ resolved_steps: Uint8Array | null;
464
+ signals: Uint8Array | null;
465
+ })
466
+ | null;
467
+ if (!row) return null;
468
+ const summary = assertRow(row);
469
+ const input = jsonSafe(decode(row.input, MAX_DETAIL_BLOB, 'input'));
470
+ const steps = assertSteps(jsonSafe(decodeObject(row.steps, MAX_DETAIL_BLOB, 'steps')));
471
+ const resolved = jsonSafe(decode(row.resolved_steps, MAX_DETAIL_BLOB, 'resolved steps'));
472
+ const signals = jsonSafe(decodeObject(row.signals, MAX_DETAIL_BLOB, 'signals'));
473
+ const meta = decodeObject(row.meta, MAX_META_BLOB, 'meta');
474
+ if (resolved != null && (!Array.isArray(resolved) || resolved.some((item) => typeof item !== 'string'))) {
475
+ throw new Error('Workflow resolved steps must decode to a string array');
476
+ }
477
+ return {
478
+ ...summary,
479
+ input,
480
+ steps,
481
+ ...(resolved ? { resolvedSteps: resolved as string[] } : {}),
482
+ signals: signals as Record<string, unknown>,
483
+ ...(meta.decisions && typeof meta.decisions === 'object'
484
+ ? { decisions: jsonSafe(meta.decisions) as Record<string, unknown> }
485
+ : {}),
486
+ ...(typeof meta.committedAt === 'number' ? { committedAt: meta.committedAt } : {}),
487
+ };
488
+ } finally {
489
+ db.close();
490
+ }
491
+ }