bunqueue-dashboard 0.0.35 → 0.0.36

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 (173) hide show
  1. package/README.md +18 -11
  2. package/agent/backup/processExecutor.ts +90 -0
  3. package/agent/backup/resultValidation.ts +85 -0
  4. package/agent/backup/routes.ts +160 -0
  5. package/agent/backup/runner.ts +91 -0
  6. package/agent/backup/standaloneWorker.ts +23 -0
  7. package/agent/backup/workerExecutor.ts +40 -0
  8. package/agent/backup/workerFactory.ts +21 -0
  9. package/agent/backup/workerProtocol.ts +10 -0
  10. package/agent/db/core.ts +141 -0
  11. package/agent/db/csvExport.ts +172 -0
  12. package/agent/db/exportTimeout.ts +190 -0
  13. package/agent/db/query.ts +82 -0
  14. package/agent/db/queryTimeout.ts +76 -0
  15. package/agent/db/schema.ts +79 -0
  16. package/agent/db/tables.ts +157 -0
  17. package/agent/db/types.ts +118 -0
  18. package/agent/db/workerFactory.ts +25 -0
  19. package/agent/db.ts +43 -1099
  20. package/agent/errorMessage.ts +9 -0
  21. package/agent/flow/routes.ts +250 -0
  22. package/agent/flow/service.ts +266 -0
  23. package/agent/flow/types.ts +34 -0
  24. package/agent/flow/validation.ts +230 -0
  25. package/agent/index.ts +5 -17
  26. package/agent/logger.ts +121 -17
  27. package/agent/managedTarget.ts +43 -0
  28. package/agent/manager/config.ts +84 -0
  29. package/agent/manager/logs.ts +83 -0
  30. package/agent/manager/process.ts +233 -0
  31. package/agent/manager/storage.ts +28 -0
  32. package/agent/manager/types.ts +38 -0
  33. package/agent/manager.ts +10 -490
  34. package/agent/queue/routes.ts +128 -0
  35. package/agent/queue/runtime.ts +119 -0
  36. package/agent/queue/types.ts +34 -0
  37. package/agent/queue/validation.ts +100 -0
  38. package/agent/server/controlRoutes.ts +153 -0
  39. package/agent/server/databaseRoutes.ts +165 -0
  40. package/agent/server/errors.ts +48 -0
  41. package/agent/server/handler.ts +146 -0
  42. package/agent/server/jsonBody.ts +109 -0
  43. package/agent/server/lifecycle.ts +72 -0
  44. package/agent/server/managedRuntime.ts +41 -0
  45. package/agent/server/policy.ts +60 -0
  46. package/agent/server/router.ts +150 -0
  47. package/agent/server/types.ts +39 -0
  48. package/agent/server/workflowReadRoutes.ts +80 -0
  49. package/agent/server.ts +16 -441
  50. package/agent/shutdown.ts +156 -0
  51. package/agent/workflow/codec.ts +78 -0
  52. package/agent/workflow/detail.ts +63 -0
  53. package/agent/workflow/list.ts +87 -0
  54. package/agent/workflow/runtime.ts +274 -0
  55. package/agent/workflow/runtimeConfig.ts +103 -0
  56. package/agent/workflow/runtimeRoutes.ts +162 -0
  57. package/agent/workflow/sqlite.ts +52 -0
  58. package/agent/workflow/stats.ts +79 -0
  59. package/agent/workflow/types.ts +81 -0
  60. package/agent/workflow/validation.ts +114 -0
  61. package/agent/workflows.ts +14 -489
  62. package/dist/.vite/manifest.json +595 -234
  63. package/dist/assets/AddJob-UUa2fSXE.js +3 -0
  64. package/dist/assets/Alerts-7oGYfQMb.js +1 -0
  65. package/dist/assets/{AreaChart-REK4ZsTC.js → AreaChart-w0ruozXH.js} +1 -1
  66. package/dist/assets/Benchmark-C2NFp_kj.js +1 -0
  67. package/dist/assets/BulkAddJobs-CL4yBae-.js +5 -0
  68. package/dist/assets/Button-BPqpRZJD.js +1 -0
  69. package/dist/assets/Card-C7NMhqUt.js +1 -0
  70. package/dist/assets/{CopilotPanel-COLw-atS.js → CopilotPanel-BQ2Cgo9p.js} +3 -3
  71. package/dist/assets/Cron-DfWBhoBf.js +1 -0
  72. package/dist/assets/CronManager-DAK7RSOX.js +1 -0
  73. package/dist/assets/Database-DqL7giEj.js +3 -0
  74. package/dist/assets/Diagnostics-D98YwN-6.js +1 -0
  75. package/dist/assets/Dlq-BHmA5PTI.js +1 -0
  76. package/dist/assets/DlqControl-DihxvP-i.js +1 -0
  77. package/dist/assets/DlqPro-ncgnDtpE.js +1 -0
  78. package/dist/assets/Flows-B5N5Cil0.js +2 -0
  79. package/dist/assets/JobInspector-B40nFiGh.js +2 -0
  80. package/dist/assets/Jobs-Bla3IEef.js +1 -0
  81. package/dist/assets/JobsPro-CnczVx7x.js +1 -0
  82. package/dist/assets/Logs-T91Q3cH0.js +1 -0
  83. package/dist/assets/LogsPro-CMn6-Wwv.js +1 -0
  84. package/dist/assets/{McpServer-CbaU52l2.js → McpServer-BQBabwW-.js} +2 -2
  85. package/dist/assets/Metrics-DxT_RI2B.js +1 -0
  86. package/dist/assets/MetricsPro-Cjen-nAP.js +1 -0
  87. package/dist/assets/Overview-XN2ej8ph.js +1 -0
  88. package/dist/assets/PageHeader-BXlZSBcc.js +1 -0
  89. package/dist/assets/Pagination-Q-vf4hsX.js +1 -0
  90. package/dist/assets/QueueActions-C-Aferd6.js +1 -0
  91. package/dist/assets/QueueControl-B8Q9NX6t.js +1 -0
  92. package/dist/assets/QueueDetail-BMhG0jUB.js +1 -0
  93. package/dist/assets/QueueDetailPro-BKTTyLR4.js +1 -0
  94. package/dist/assets/Queues-COqPMoY9.js +1 -0
  95. package/dist/assets/QueuesOverview-pWdrPy8Z.js +1 -0
  96. package/dist/assets/S3Backup-BmsZ8xtq.js +1 -0
  97. package/dist/assets/S3BackupPro-AtlsPUJL.js +3 -0
  98. package/dist/assets/ServerControl-D3XwHr72.js +3 -0
  99. package/dist/assets/Settings-BofKZoHn.js +1 -0
  100. package/dist/assets/StatusBadge-B0I7hNBX.js +1 -0
  101. package/dist/assets/Usage-DAR9E22v.js +1 -0
  102. package/dist/assets/UsagePro-Bc30N2Lo.js +1 -0
  103. package/dist/assets/Webhooks-Bg2k1PU4.js +1 -0
  104. package/dist/assets/Workers-BqUZMJVQ.js +1 -0
  105. package/dist/assets/WorkersPro-C9aSadUh.js +1 -0
  106. package/dist/assets/Workflows-BEFMfHmo.js +1 -0
  107. package/dist/assets/api-CQ5Z7xR7.js +1 -0
  108. package/dist/assets/bq-BeBxa743.js +1 -0
  109. package/dist/assets/cn-zFEt5SO8.js +1 -0
  110. package/dist/assets/connectionStore-DbBYqHEY.js +1 -0
  111. package/dist/assets/controlConnectionEpoch-CFSO6237.js +1 -0
  112. package/dist/assets/feedback-BnGE2oW2.js +1 -0
  113. package/dist/assets/{form-DsUq0w8s.js → form-tC20r4Ma.js} +1 -1
  114. package/dist/assets/format-Cg7VCIZW.js +1 -0
  115. package/dist/assets/icons-CIvFfU2b.js +1 -0
  116. package/dist/assets/index-Cui9IwWy.js +8 -0
  117. package/dist/assets/index-DOze4TKg.css +1 -0
  118. package/dist/assets/install-Cd9Is10G.js +3 -0
  119. package/dist/assets/{react-vendor-Bjui4wS4.js → react-vendor-CyjgmGTq.js} +3 -3
  120. package/dist/assets/responses-kPO4rSu5.js +1 -0
  121. package/dist/assets/toastStore-CS_9Xndn.js +1 -0
  122. package/dist/assets/usePolledData-DzlUpBOO.js +1 -0
  123. package/dist/assets/{useServerActionGuard-CItj3hvh.js → useServerActionGuard-BlH-Kso1.js} +1 -1
  124. package/dist/assets/useThroughputSeries-C2pa1Wd_.js +1 -0
  125. package/dist/index.html +16 -5
  126. package/package.json +12 -3
  127. package/scripts/embedded.gen.ts +152 -126
  128. package/scripts/serve.ts +96 -484
  129. package/scripts/serveHandler.ts +217 -0
  130. package/scripts/servePolicy.ts +133 -0
  131. package/dist/assets/AddJob-CG28MSxU.js +0 -3
  132. package/dist/assets/Alerts-D9JojJrj.js +0 -1
  133. package/dist/assets/Benchmark-BVjUH9dG.js +0 -1
  134. package/dist/assets/BulkAddJobs-BHzUCMj_.js +0 -5
  135. package/dist/assets/Cron-DoEHDHwy.js +0 -1
  136. package/dist/assets/CronManager-vulcesqJ.js +0 -1
  137. package/dist/assets/Database-DlB_t04Z.js +0 -3
  138. package/dist/assets/Diagnostics-DddCUgeR.js +0 -1
  139. package/dist/assets/Dlq-DMKrsSMP.js +0 -1
  140. package/dist/assets/DlqControl-9m0Re_TH.js +0 -1
  141. package/dist/assets/DlqPro-Dmw-0wta.js +0 -1
  142. package/dist/assets/Flows-C3-9nXW4.js +0 -1
  143. package/dist/assets/JobInspector-DwIfABmI.js +0 -2
  144. package/dist/assets/Jobs-j8tTS3NC.js +0 -1
  145. package/dist/assets/JobsPro-CS__CJJl.js +0 -1
  146. package/dist/assets/Logs-BB-VDPA1.js +0 -1
  147. package/dist/assets/LogsPro-RNtSQZRT.js +0 -1
  148. package/dist/assets/Metrics-ivcddPwT.js +0 -1
  149. package/dist/assets/MetricsPro-xjZno4vg.js +0 -1
  150. package/dist/assets/Overview-D84aD2ph.js +0 -1
  151. package/dist/assets/Pagination-Bfi4vkpp.js +0 -1
  152. package/dist/assets/QueueActions-DTbD6uek.js +0 -1
  153. package/dist/assets/QueueControl-B5xK_SQ-.js +0 -1
  154. package/dist/assets/QueueDetail-BH59kwm9.js +0 -1
  155. package/dist/assets/QueueDetailPro-DoN25ndy.js +0 -1
  156. package/dist/assets/Queues-CKMJqFHX.js +0 -1
  157. package/dist/assets/QueuesOverview-BX1lzojl.js +0 -1
  158. package/dist/assets/S3Backup-Bz3GfoZ9.js +0 -1
  159. package/dist/assets/S3BackupPro-BC_6-uX-.js +0 -2
  160. package/dist/assets/ServerControl-CSSrN8G5.js +0 -3
  161. package/dist/assets/Settings-DkqmZUIS.js +0 -1
  162. package/dist/assets/Usage-Ba20p0xd.js +0 -1
  163. package/dist/assets/UsagePro-C2pQwjN9.js +0 -1
  164. package/dist/assets/Webhooks-CVuuvb0L.js +0 -1
  165. package/dist/assets/Workers-C1wPU6CS.js +0 -1
  166. package/dist/assets/WorkersPro-B0vEkC0T.js +0 -1
  167. package/dist/assets/Workflows-C8Qmkflf.js +0 -1
  168. package/dist/assets/cn-CMFYpumL.js +0 -1
  169. package/dist/assets/connectionStore-Cd9-3Fw0.js +0 -1
  170. package/dist/assets/index-BCNzbhhD.css +0 -1
  171. package/dist/assets/index-CAtsVmyh.js +0 -8
  172. package/dist/assets/install-D0eRLf9F.js +0 -3
  173. package/dist/assets/useThroughputSeries-DjxGN8ob.js +0 -1
@@ -0,0 +1,162 @@
1
+ import type { ExecutionState } from 'bunqueue/workflow';
2
+ import { assertManagedTarget } from '../managedTarget';
3
+ import type { ServerConfig } from '../manager';
4
+ import { readLimitedJsonBody } from '../server/jsonBody';
5
+ import type {
6
+ ManagedRuntimeAdmission,
7
+ ManagedRuntimeSnapshot,
8
+ } from '../server/managedRuntime';
9
+ import type { WorkflowRuntimePort } from './runtime';
10
+
11
+ const MAX_WORKFLOW_BODY_BYTES = 1024 * 1024;
12
+
13
+ export interface WorkflowRuntimeRouteResponse {
14
+ status: number;
15
+ body: Record<string, unknown>;
16
+ }
17
+
18
+ const TERMINAL_STATES = new Set<ExecutionState>(['completed', 'failed']);
19
+
20
+ export async function routeWorkflowRuntimeRequest(
21
+ request: Request,
22
+ pathname: string,
23
+ method: string,
24
+ config: ServerConfig,
25
+ runtime: WorkflowRuntimePort,
26
+ serverRunning: boolean,
27
+ admission?: ManagedRuntimeAdmission
28
+ ): Promise<WorkflowRuntimeRouteResponse | null> {
29
+ if (!isRuntimePath(pathname, method)) return null;
30
+ const query = exactTargetQuery(request.url);
31
+ assertManagedTarget(query, config);
32
+ const admitted = <T>(
33
+ requiresRunning: boolean,
34
+ operation: (snapshot: ManagedRuntimeSnapshot) => Promise<T>
35
+ ): Promise<T> => {
36
+ const execute = async (snapshot: ManagedRuntimeSnapshot) => {
37
+ assertManagedTarget(query, snapshot.config);
38
+ if (requiresRunning && !snapshot.running) {
39
+ throw new Error('Start the managed Bunqueue server before workflow control.');
40
+ }
41
+ return operation(snapshot);
42
+ };
43
+ return admission ? admission(execute) : execute({ config, running: serverRunning });
44
+ };
45
+ if (pathname === '/workflows/runtime' && method === 'GET') {
46
+ return success(
47
+ await admitted(false, ({ config: current, running }) => runtime.status(current, running))
48
+ );
49
+ }
50
+ if (!serverRunning) throw new Error('Start the managed Bunqueue server before workflow control.');
51
+ if (pathname === '/workflows/runtime/reload') {
52
+ return success(await admitted(true, ({ config: current }) => runtime.reload(current)));
53
+ }
54
+ if (pathname === '/workflows/start') {
55
+ const body = record(await workflowBody(request), ['workflowName', 'input']);
56
+ const workflowName = bounded(body.workflowName, 'workflowName', 256);
57
+ return success({
58
+ run: await admitted(true, ({ config: current }) =>
59
+ runtime.start(current, workflowName, body.input)
60
+ ),
61
+ });
62
+ }
63
+ if (pathname === '/workflows/recover') {
64
+ return success({
65
+ recovered: await admitted(true, ({ config: current }) => runtime.recover(current)),
66
+ });
67
+ }
68
+ if (pathname === '/workflows/archive' || pathname === '/workflows/cleanup') {
69
+ const body = record(await workflowBody(request), ['maxAgeMs', 'states']);
70
+ const maxAgeMs = boundedAge(body.maxAgeMs);
71
+ const states = terminalStates(body.states);
72
+ const affected = await admitted(true, ({ config: current }) =>
73
+ pathname === '/workflows/archive'
74
+ ? runtime.archive(current, maxAgeMs, states)
75
+ : runtime.cleanup(current, maxAgeMs, states)
76
+ );
77
+ return success({ affected });
78
+ }
79
+ const match = pathname.match(/^\/workflows\/([^/]+)\/(signal|resume-compensation|abandon-compensation)$/);
80
+ if (!match) return null;
81
+ const id = bounded(decodeURIComponent(match[1]), 'executionId', 1024);
82
+ if (match[2] === 'signal') {
83
+ const body = record(await workflowBody(request), ['event', 'payload']);
84
+ const event = bounded(body.event, 'event', 256);
85
+ await admitted(true, ({ config: current }) =>
86
+ runtime.signal(current, id, event, body.payload)
87
+ );
88
+ } else if (match[2] === 'resume-compensation') {
89
+ await admitted(true, ({ config: current }) => runtime.resumeCompensation(current, id));
90
+ } else {
91
+ await admitted(true, ({ config: current }) => runtime.abandonCompensation(current, id));
92
+ }
93
+ return success({ applied: true });
94
+ }
95
+
96
+ function isRuntimePath(pathname: string, method: string): boolean {
97
+ if (method === 'GET') return pathname === '/workflows/runtime';
98
+ if (method !== 'POST') return false;
99
+ return (
100
+ ['/workflows/runtime/reload', '/workflows/start', '/workflows/recover', '/workflows/archive', '/workflows/cleanup'].includes(pathname) ||
101
+ /^\/workflows\/[^/]+\/(signal|resume-compensation|abandon-compensation)$/.test(pathname)
102
+ );
103
+ }
104
+
105
+ function exactTargetQuery(url: string): URLSearchParams {
106
+ const query = new URL(url).searchParams;
107
+ for (const key of query.keys()) {
108
+ if (key !== 'target') throw new Error(`Unknown workflow control option: ${key}`);
109
+ if (query.getAll(key).length !== 1) throw new Error(`Duplicate workflow control option: ${key}`);
110
+ }
111
+ return query;
112
+ }
113
+
114
+ function success(value: unknown): WorkflowRuntimeRouteResponse {
115
+ return { status: 200, body: { ok: true, result: value } };
116
+ }
117
+
118
+ function record(value: unknown, allowed: string[]): Record<string, unknown> {
119
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
120
+ throw new Error('Workflow control body must be an object');
121
+ }
122
+ const body = value as Record<string, unknown>;
123
+ const unknown = Object.keys(body).find((key) => !allowed.includes(key));
124
+ if (unknown) throw new Error(`Unknown workflow control option: ${unknown}`);
125
+ return body;
126
+ }
127
+
128
+ function workflowBody(request: Request): Promise<unknown> {
129
+ return readLimitedJsonBody(request, {
130
+ scope: 'Workflow control',
131
+ maxBytes: MAX_WORKFLOW_BODY_BYTES,
132
+ limitLabel: '1 MiB',
133
+ });
134
+ }
135
+
136
+ function bounded(value: unknown, label: string, maximum: number): string {
137
+ if (typeof value !== 'string' || value.length < 1 || value.length > maximum) {
138
+ throw new Error(`Workflow ${label} must contain 1–${maximum} characters`);
139
+ }
140
+ return value;
141
+ }
142
+
143
+ function boundedAge(value: unknown): number {
144
+ const tenYears = 10 * 365 * 24 * 60 * 60 * 1_000;
145
+ if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) > tenYears) {
146
+ throw new Error('Workflow maxAgeMs must be an integer between 0 and ten years');
147
+ }
148
+ return value as number;
149
+ }
150
+
151
+ function terminalStates(value: unknown): ExecutionState[] {
152
+ if (!Array.isArray(value) || value.length < 1 || value.length > TERMINAL_STATES.size) {
153
+ throw new Error('Workflow maintenance states must contain completed and/or failed');
154
+ }
155
+ const states = value.map((state) => {
156
+ if (!TERMINAL_STATES.has(state as ExecutionState)) {
157
+ throw new Error('Workflow maintenance accepts only terminal completed/failed states');
158
+ }
159
+ return state as ExecutionState;
160
+ });
161
+ return Array.from(new Set(states));
162
+ }
@@ -0,0 +1,52 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { Database } from 'bun:sqlite';
3
+ import { MissingDbError } from '../db';
4
+ import type { WorkflowStoreKind } from './types';
5
+
6
+ export interface WorkflowSchema {
7
+ table: string;
8
+ hasMeta: boolean;
9
+ }
10
+
11
+ const REQUIRED_COLUMNS = [
12
+ 'id',
13
+ 'workflow_name',
14
+ 'state',
15
+ 'input',
16
+ 'steps',
17
+ 'current_node_index',
18
+ 'resolved_steps',
19
+ 'signals',
20
+ 'created_at',
21
+ 'updated_at',
22
+ ];
23
+
24
+ export function openWorkflowDatabase(path: string): Database {
25
+ if (!existsSync(path)) {
26
+ throw new MissingDbError(
27
+ `Database not found at "${path}" — configure the Workflow Engine dataPath and start it once.`
28
+ );
29
+ }
30
+ try {
31
+ return new Database(path, { readonly: true });
32
+ } catch (error) {
33
+ throw new Error(`Could not open workflow database: ${(error as Error).message}`);
34
+ }
35
+ }
36
+
37
+ export function workflowSchema(
38
+ db: Database,
39
+ kind: WorkflowStoreKind
40
+ ): WorkflowSchema | null {
41
+ const table = kind === 'archive' ? 'workflow_executions_archive' : 'workflow_executions';
42
+ if (!db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table)) {
43
+ return null;
44
+ }
45
+ const found = new Set(
46
+ (db.query(`PRAGMA table_info("${table}")`).all() as { name: string }[]).map((row) => row.name)
47
+ );
48
+ const missing = REQUIRED_COLUMNS.filter((name) => !found.has(name));
49
+ if (kind === 'archive' && !found.has('archived_at')) missing.push('archived_at');
50
+ if (missing.length > 0) throw new Error(`Invalid ${table} schema: missing ${missing.join(', ')}`);
51
+ return { table, hasMeta: found.has('meta') };
52
+ }
@@ -0,0 +1,79 @@
1
+ import { openWorkflowDatabase, workflowSchema } from './sqlite';
2
+ import {
3
+ WORKFLOW_LIMITS,
4
+ WORKFLOW_STATES,
5
+ type WorkflowExecutionState,
6
+ type WorkflowStats,
7
+ } from './types';
8
+
9
+ export function workflowStats(
10
+ path: string,
11
+ /** @internal Deterministic seam for the SQLite snapshot regression test. */
12
+ afterActiveTotals?: () => void
13
+ ): WorkflowStats {
14
+ const db = openWorkflowDatabase(path);
15
+ try {
16
+ const active = workflowSchema(db, 'active');
17
+ const archive = workflowSchema(db, 'archive');
18
+ const states = Object.fromEntries(WORKFLOW_STATES.map((state) => [state, 0])) as Record<
19
+ WorkflowExecutionState,
20
+ number
21
+ >;
22
+ if (!active) {
23
+ return { available: false, activeTotal: 0, archiveTotal: 0, states, workflowNames: [] };
24
+ }
25
+ const snapshot = db.transaction((): WorkflowStats => {
26
+ const stateColumns = WORKFLOW_STATES.map(
27
+ (state, index) => `SUM(CASE WHEN state = '${state}' THEN 1 ELSE 0 END) AS state_${index}`
28
+ ).join(', ');
29
+ const totals = db
30
+ .query(`SELECT COUNT(*) AS total, ${stateColumns} FROM workflow_executions`)
31
+ .get() as { total: number } & Record<`state_${number}`, number | null>;
32
+ for (const [index, state] of WORKFLOW_STATES.entries()) {
33
+ states[state] = totals[`state_${index}`] ?? 0;
34
+ }
35
+ if (Object.values(states).reduce((sum, count) => sum + count, 0) !== totals.total) {
36
+ throw new Error('Workflow store contains an unknown execution state');
37
+ }
38
+ afterActiveTotals?.();
39
+ const names = workflowNames(db, Boolean(archive));
40
+ const archiveTotal = archive
41
+ ? ((db.query('SELECT COUNT(*) AS count FROM workflow_executions_archive').get() as { count: number }).count ?? 0)
42
+ : 0;
43
+ return {
44
+ available: true,
45
+ activeTotal: totals.total,
46
+ archiveTotal,
47
+ states,
48
+ workflowNames: names,
49
+ };
50
+ });
51
+ return snapshot.deferred();
52
+ } finally {
53
+ db.close();
54
+ }
55
+ }
56
+
57
+ function workflowNames(db: import('bun:sqlite').Database, hasArchive: boolean): string[] {
58
+ const rows = db
59
+ .query(
60
+ hasArchive
61
+ ? `SELECT workflow_name FROM (
62
+ SELECT workflow_name FROM workflow_executions
63
+ UNION
64
+ SELECT workflow_name FROM workflow_executions_archive
65
+ ) ORDER BY workflow_name LIMIT 500`
66
+ : 'SELECT DISTINCT workflow_name FROM workflow_executions ORDER BY workflow_name LIMIT 500'
67
+ )
68
+ .all() as { workflow_name: unknown }[];
69
+ return rows.map((row) => {
70
+ if (
71
+ typeof row.workflow_name !== 'string' ||
72
+ row.workflow_name.length < 1 ||
73
+ row.workflow_name.length > WORKFLOW_LIMITS.filter
74
+ ) {
75
+ throw new Error('Workflow store contains an invalid workflow name');
76
+ }
77
+ return row.workflow_name;
78
+ });
79
+ }
@@ -0,0 +1,81 @@
1
+ export const WORKFLOW_STATES = [
2
+ 'running',
3
+ 'waiting',
4
+ 'completed',
5
+ 'failed',
6
+ 'compensating',
7
+ 'compensation-stuck',
8
+ ] as const;
9
+
10
+ export type WorkflowExecutionState = (typeof WORKFLOW_STATES)[number];
11
+ export type WorkflowStateFilter = WorkflowExecutionState | 'compensation';
12
+ export type WorkflowStoreKind = 'active' | 'archive';
13
+
14
+ export const WORKFLOW_LIMITS = {
15
+ page: 100,
16
+ offset: 1_000_000,
17
+ filter: 256,
18
+ detailBlob: 4 * 1024 * 1024,
19
+ metaBlob: 256 * 1024,
20
+ jsonNodes: 25_000,
21
+ jsonDepth: 40,
22
+ string: 250_000,
23
+ } as const;
24
+
25
+ export interface WorkflowRow {
26
+ id: string;
27
+ workflow_name: string;
28
+ state: string;
29
+ current_node_index: number;
30
+ created_at: number;
31
+ updated_at: number;
32
+ archived_at?: number | null;
33
+ meta?: Uint8Array | null;
34
+ }
35
+
36
+ export interface WorkflowExecutionSummary {
37
+ id: string;
38
+ workflowName: string;
39
+ state: WorkflowExecutionState;
40
+ currentNodeIndex: number;
41
+ createdAt: number;
42
+ updatedAt: number;
43
+ archivedAt?: number;
44
+ rollbackStatus?: string;
45
+ failureReason?: string;
46
+ parentExecutionId?: string;
47
+ definitionHash?: string;
48
+ }
49
+
50
+ export interface WorkflowExecutionDetail extends WorkflowExecutionSummary {
51
+ input: unknown;
52
+ steps: Record<string, unknown>;
53
+ resolvedSteps?: string[];
54
+ signals: Record<string, unknown>;
55
+ decisions?: Record<string, unknown>;
56
+ committedAt?: number;
57
+ }
58
+
59
+ export interface WorkflowStats {
60
+ available: boolean;
61
+ activeTotal: number;
62
+ archiveTotal: number;
63
+ states: Record<WorkflowExecutionState, number>;
64
+ workflowNames: string[];
65
+ }
66
+
67
+ export interface WorkflowListOptions {
68
+ kind?: WorkflowStoreKind;
69
+ workflowName?: string;
70
+ state?: WorkflowStateFilter;
71
+ limit?: number;
72
+ offset?: number;
73
+ }
74
+
75
+ export interface WorkflowPage {
76
+ available: boolean;
77
+ executions: WorkflowExecutionSummary[];
78
+ total: number;
79
+ limit: number;
80
+ offset: number;
81
+ }
@@ -0,0 +1,114 @@
1
+ import { decodeWorkflowObject } from './codec';
2
+ import {
3
+ WORKFLOW_LIMITS,
4
+ WORKFLOW_STATES,
5
+ type WorkflowExecutionState,
6
+ type WorkflowExecutionSummary,
7
+ type WorkflowRow,
8
+ } from './types';
9
+
10
+ const STEP_STATES = new Set(['pending', 'running', 'completed', 'failed']);
11
+ const COMPENSATION_STATES = new Set([
12
+ 'compensated',
13
+ 'compensation-failed',
14
+ 'compensation-skipped',
15
+ ]);
16
+
17
+ export function validWorkflowState(value: string): value is WorkflowExecutionState {
18
+ return (WORKFLOW_STATES as readonly string[]).includes(value);
19
+ }
20
+
21
+ export function assertWorkflowRow(row: WorkflowRow): WorkflowExecutionSummary {
22
+ if (
23
+ typeof row.id !== 'string' ||
24
+ typeof row.workflow_name !== 'string' ||
25
+ row.workflow_name.length < 1 ||
26
+ row.workflow_name.length > WORKFLOW_LIMITS.filter ||
27
+ !validWorkflowState(row.state) ||
28
+ !Number.isSafeInteger(row.current_node_index) ||
29
+ !Number.isFinite(row.created_at) ||
30
+ !Number.isFinite(row.updated_at)
31
+ ) {
32
+ throw new Error('Workflow store contains an invalid execution row');
33
+ }
34
+ const meta = decodeWorkflowObject(row.meta, WORKFLOW_LIMITS.metaBlob, 'meta');
35
+ return {
36
+ id: row.id,
37
+ workflowName: row.workflow_name,
38
+ state: row.state,
39
+ currentNodeIndex: row.current_node_index,
40
+ createdAt: row.created_at,
41
+ updatedAt: row.updated_at,
42
+ ...(typeof row.archived_at === 'number' ? { archivedAt: row.archived_at } : {}),
43
+ ...(typeof meta.rollbackStatus === 'string' ? { rollbackStatus: meta.rollbackStatus } : {}),
44
+ ...(typeof meta.failureReason === 'string' ? { failureReason: meta.failureReason } : {}),
45
+ ...(typeof meta.parentExecutionId === 'string' ? { parentExecutionId: meta.parentExecutionId } : {}),
46
+ ...(typeof meta.definitionHash === 'string' ? { definitionHash: meta.definitionHash } : {}),
47
+ };
48
+ }
49
+
50
+ export function assertWorkflowSteps(value: unknown): Record<string, unknown> {
51
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
52
+ throw new Error('Workflow steps must decode to an object');
53
+ }
54
+ for (const [name, candidate] of Object.entries(value)) {
55
+ if (!name || candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)) {
56
+ throw new Error('Workflow store contains an invalid step record');
57
+ }
58
+ assertStep(candidate as Record<string, unknown>);
59
+ }
60
+ return value as Record<string, unknown>;
61
+ }
62
+
63
+ function assertStep(step: Record<string, unknown>): void {
64
+ if (
65
+ typeof step.status !== 'string' ||
66
+ !STEP_STATES.has(step.status) ||
67
+ invalidOptionalType(step, 'error', 'string') ||
68
+ invalidOptionalType(step, 'compensatable', 'boolean') ||
69
+ invalidOptionalType(step, 'idempotencyKey', 'string') ||
70
+ invalidOptionalType(step, 'childExecutionId', 'string') ||
71
+ !optionalFinite(step, 'startedAt') ||
72
+ !optionalFinite(step, 'completedAt') ||
73
+ !optionalInteger(step, 'attempts') ||
74
+ !optionalInteger(step, 'loopIndex') ||
75
+ !optionalInteger(step, 'occurrence')
76
+ ) {
77
+ throw new Error('Workflow store contains an invalid step record');
78
+ }
79
+ if (Object.hasOwn(step, 'compensation')) assertCompensation(step.compensation);
80
+ }
81
+
82
+ function assertCompensation(candidate: unknown): void {
83
+ if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)) {
84
+ throw new Error('Workflow store contains an invalid compensation record');
85
+ }
86
+ const compensation = candidate as Record<string, unknown>;
87
+ if (
88
+ typeof compensation.status !== 'string' ||
89
+ !COMPENSATION_STATES.has(compensation.status) ||
90
+ typeof compensation.at !== 'number' ||
91
+ !Number.isFinite(compensation.at) ||
92
+ invalidOptionalType(compensation, 'error', 'string')
93
+ ) {
94
+ throw new Error('Workflow store contains an invalid compensation record');
95
+ }
96
+ }
97
+
98
+ function invalidOptionalType(
99
+ record: Record<string, unknown>,
100
+ key: string,
101
+ type: 'string' | 'boolean'
102
+ ): boolean {
103
+ return Object.hasOwn(record, key) && typeof record[key] !== type;
104
+ }
105
+
106
+ function optionalFinite(record: Record<string, unknown>, key: string): boolean {
107
+ return !Object.hasOwn(record, key) ||
108
+ (typeof record[key] === 'number' && Number.isFinite(record[key]));
109
+ }
110
+
111
+ function optionalInteger(record: Record<string, unknown>, key: string): boolean {
112
+ return !Object.hasOwn(record, key) ||
113
+ (typeof record[key] === 'number' && Number.isSafeInteger(record[key]) && record[key] >= 0);
114
+ }