bunqueue-dashboard 0.0.34 → 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.
- package/README.md +20 -13
- package/agent/backup/processExecutor.ts +90 -0
- package/agent/backup/resultValidation.ts +85 -0
- package/agent/backup/routes.ts +160 -0
- package/agent/backup/runner.ts +91 -0
- package/agent/backup/standaloneWorker.ts +23 -0
- package/agent/backup/workerExecutor.ts +40 -0
- package/agent/backup/workerFactory.ts +21 -0
- package/agent/backup/workerProtocol.ts +10 -0
- package/agent/db/core.ts +141 -0
- package/agent/db/csvExport.ts +172 -0
- package/agent/db/exportTimeout.ts +190 -0
- package/agent/db/query.ts +82 -0
- package/agent/db/queryTimeout.ts +76 -0
- package/agent/db/schema.ts +79 -0
- package/agent/db/tables.ts +157 -0
- package/agent/db/types.ts +118 -0
- package/agent/db/workerFactory.ts +25 -0
- package/agent/db.ts +43 -1099
- package/agent/errorMessage.ts +9 -0
- package/agent/flow/routes.ts +250 -0
- package/agent/flow/service.ts +266 -0
- package/agent/flow/types.ts +34 -0
- package/agent/flow/validation.ts +230 -0
- package/agent/index.ts +5 -17
- package/agent/logger.ts +121 -17
- package/agent/managedTarget.ts +43 -0
- package/agent/manager/config.ts +84 -0
- package/agent/manager/logs.ts +83 -0
- package/agent/manager/process.ts +233 -0
- package/agent/manager/storage.ts +28 -0
- package/agent/manager/types.ts +38 -0
- package/agent/manager.ts +10 -490
- package/agent/queue/routes.ts +128 -0
- package/agent/queue/runtime.ts +119 -0
- package/agent/queue/types.ts +34 -0
- package/agent/queue/validation.ts +100 -0
- package/agent/server/controlRoutes.ts +153 -0
- package/agent/server/databaseRoutes.ts +165 -0
- package/agent/server/errors.ts +48 -0
- package/agent/server/handler.ts +146 -0
- package/agent/server/jsonBody.ts +109 -0
- package/agent/server/lifecycle.ts +72 -0
- package/agent/server/managedRuntime.ts +41 -0
- package/agent/server/policy.ts +60 -0
- package/agent/server/router.ts +150 -0
- package/agent/server/types.ts +39 -0
- package/agent/server/workflowReadRoutes.ts +80 -0
- package/agent/server.ts +16 -369
- package/agent/shutdown.ts +156 -0
- package/agent/workflow/codec.ts +78 -0
- package/agent/workflow/detail.ts +63 -0
- package/agent/workflow/list.ts +87 -0
- package/agent/workflow/runtime.ts +274 -0
- package/agent/workflow/runtimeConfig.ts +103 -0
- package/agent/workflow/runtimeRoutes.ts +162 -0
- package/agent/workflow/sqlite.ts +52 -0
- package/agent/workflow/stats.ts +79 -0
- package/agent/workflow/types.ts +81 -0
- package/agent/workflow/validation.ts +114 -0
- package/agent/workflows.ts +16 -0
- package/dist/.vite/manifest.json +619 -246
- package/dist/assets/AddJob-UUa2fSXE.js +3 -0
- package/dist/assets/Alerts-7oGYfQMb.js +1 -0
- package/dist/assets/{AreaChart-REK4ZsTC.js → AreaChart-w0ruozXH.js} +1 -1
- package/dist/assets/Benchmark-C2NFp_kj.js +1 -0
- package/dist/assets/BulkAddJobs-CL4yBae-.js +5 -0
- package/dist/assets/Button-BPqpRZJD.js +1 -0
- package/dist/assets/Card-C7NMhqUt.js +1 -0
- package/dist/assets/{CopilotPanel-CIXpcwkH.js → CopilotPanel-BQ2Cgo9p.js} +3 -3
- package/dist/assets/Cron-DfWBhoBf.js +1 -0
- package/dist/assets/CronManager-DAK7RSOX.js +1 -0
- package/dist/assets/Database-DqL7giEj.js +3 -0
- package/dist/assets/Diagnostics-D98YwN-6.js +1 -0
- package/dist/assets/Dlq-BHmA5PTI.js +1 -0
- package/dist/assets/DlqControl-DihxvP-i.js +1 -0
- package/dist/assets/DlqPro-ncgnDtpE.js +1 -0
- package/dist/assets/Flows-B5N5Cil0.js +2 -0
- package/dist/assets/JobInspector-B40nFiGh.js +2 -0
- package/dist/assets/Jobs-Bla3IEef.js +1 -0
- package/dist/assets/JobsPro-CnczVx7x.js +1 -0
- package/dist/assets/Logs-T91Q3cH0.js +1 -0
- package/dist/assets/LogsPro-CMn6-Wwv.js +1 -0
- package/dist/assets/{McpServer-D5rwBg74.js → McpServer-BQBabwW-.js} +2 -2
- package/dist/assets/Metrics-DxT_RI2B.js +1 -0
- package/dist/assets/MetricsPro-Cjen-nAP.js +1 -0
- package/dist/assets/Overview-XN2ej8ph.js +1 -0
- package/dist/assets/PageHeader-BXlZSBcc.js +1 -0
- package/dist/assets/Pagination-Q-vf4hsX.js +1 -0
- package/dist/assets/QueueActions-C-Aferd6.js +1 -0
- package/dist/assets/QueueControl-B8Q9NX6t.js +1 -0
- package/dist/assets/QueueDetail-BMhG0jUB.js +1 -0
- package/dist/assets/QueueDetailPro-BKTTyLR4.js +1 -0
- package/dist/assets/Queues-COqPMoY9.js +1 -0
- package/dist/assets/QueuesOverview-pWdrPy8Z.js +1 -0
- package/dist/assets/S3Backup-BmsZ8xtq.js +1 -0
- package/dist/assets/S3BackupPro-AtlsPUJL.js +3 -0
- package/dist/assets/ServerControl-D3XwHr72.js +3 -0
- package/dist/assets/Settings-BofKZoHn.js +1 -0
- package/dist/assets/StatusBadge-B0I7hNBX.js +1 -0
- package/dist/assets/Usage-DAR9E22v.js +1 -0
- package/dist/assets/UsagePro-Bc30N2Lo.js +1 -0
- package/dist/assets/Webhooks-Bg2k1PU4.js +1 -0
- package/dist/assets/Workers-BqUZMJVQ.js +1 -0
- package/dist/assets/WorkersPro-C9aSadUh.js +1 -0
- package/dist/assets/Workflows-BEFMfHmo.js +1 -0
- package/dist/assets/api-CQ5Z7xR7.js +1 -0
- package/dist/assets/bq-BeBxa743.js +1 -0
- package/dist/assets/cn-zFEt5SO8.js +1 -0
- package/dist/assets/connectionStore-DbBYqHEY.js +1 -0
- package/dist/assets/controlConnectionEpoch-CFSO6237.js +1 -0
- package/dist/assets/feedback-BnGE2oW2.js +1 -0
- package/dist/assets/{flowMutationSafety-DVD1IMr-.js → flowMutationSafety-CoOp94wS.js} +1 -1
- package/dist/assets/{form-DsUq0w8s.js → form-tC20r4Ma.js} +1 -1
- package/dist/assets/format-Cg7VCIZW.js +1 -0
- package/dist/assets/icons-CIvFfU2b.js +1 -0
- package/dist/assets/index-Cui9IwWy.js +8 -0
- package/dist/assets/index-DOze4TKg.css +1 -0
- package/dist/assets/install-Cd9Is10G.js +3 -0
- package/dist/assets/{react-vendor-Bjui4wS4.js → react-vendor-CyjgmGTq.js} +3 -3
- package/dist/assets/responses-kPO4rSu5.js +1 -0
- package/dist/assets/toastStore-CS_9Xndn.js +1 -0
- package/dist/assets/{upstreamPaths-nG8N1XAz.js → upstreamPaths-D_LPsv7b.js} +1 -1
- package/dist/assets/usePolledData-DzlUpBOO.js +1 -0
- package/dist/assets/{useServerActionGuard-CItj3hvh.js → useServerActionGuard-BlH-Kso1.js} +1 -1
- package/dist/assets/useThroughputSeries-C2pa1Wd_.js +1 -0
- package/dist/index.html +17 -6
- package/package.json +15 -3
- package/scripts/embedded.gen.ts +152 -124
- package/scripts/serve.ts +96 -484
- package/scripts/serveHandler.ts +217 -0
- package/scripts/servePolicy.ts +133 -0
- package/dist/assets/AddJob-Cih9F84t.js +0 -3
- package/dist/assets/Alerts-Dp4Vclez.js +0 -1
- package/dist/assets/Benchmark-X_gdZun5.js +0 -1
- package/dist/assets/BulkAddJobs-CXTpYMZf.js +0 -5
- package/dist/assets/Cron-DUHkse77.js +0 -1
- package/dist/assets/CronManager-BeYrLK2E.js +0 -1
- package/dist/assets/Database-CXPb3XGr.js +0 -3
- package/dist/assets/Diagnostics-DDtoiDYT.js +0 -1
- package/dist/assets/Dlq-CX_uRblW.js +0 -1
- package/dist/assets/DlqControl-CazFmSEd.js +0 -1
- package/dist/assets/DlqPro-HSffdtLJ.js +0 -1
- package/dist/assets/Flows-CGmyghKF.js +0 -1
- package/dist/assets/JobInspector-B9i1HnG5.js +0 -2
- package/dist/assets/Jobs-BZGtMnJI.js +0 -1
- package/dist/assets/JobsPro-tmEN8f-P.js +0 -1
- package/dist/assets/Logs-DxUD9V2A.js +0 -1
- package/dist/assets/LogsPro-BTQhvPKj.js +0 -1
- package/dist/assets/Metrics-CSPpBf1m.js +0 -1
- package/dist/assets/MetricsPro-DSn-3qNT.js +0 -1
- package/dist/assets/Overview-StvHYRZ5.js +0 -1
- package/dist/assets/Pagination-eotgGQf9.js +0 -1
- package/dist/assets/QueueActions-CibRLzrz.js +0 -1
- package/dist/assets/QueueControl-DwYNcMfI.js +0 -1
- package/dist/assets/QueueDetail-NMfKwj6E.js +0 -1
- package/dist/assets/QueueDetailPro-C8SBjX09.js +0 -1
- package/dist/assets/Queues-BeTAi8V8.js +0 -1
- package/dist/assets/QueuesOverview-BfQO8NS4.js +0 -1
- package/dist/assets/S3Backup-BYkAtONl.js +0 -1
- package/dist/assets/S3BackupPro-BP1Ch54R.js +0 -2
- package/dist/assets/ServerControl-C85jYgyN.js +0 -3
- package/dist/assets/Settings-DjP28c_5.js +0 -1
- package/dist/assets/Usage-DECeEsO2.js +0 -1
- package/dist/assets/UsagePro-CD7dgt3d.js +0 -1
- package/dist/assets/Webhooks-Ct-x8DD1.js +0 -1
- package/dist/assets/Workers-lP1BXHFq.js +0 -1
- package/dist/assets/WorkersPro-CAUqy3Cp.js +0 -1
- package/dist/assets/cn-CMFYpumL.js +0 -1
- package/dist/assets/connectionStore-Cd9-3Fw0.js +0 -1
- package/dist/assets/index-BWoUj86M.css +0 -1
- package/dist/assets/index-Dr__4DsT.js +0 -8
- package/dist/assets/install-DPCcfOYo.js +0 -3
- package/dist/assets/useThroughputSeries-Bpyg6Jaq.js +0 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { openWorkflowDatabase, workflowSchema } from './sqlite';
|
|
2
|
+
import {
|
|
3
|
+
WORKFLOW_LIMITS,
|
|
4
|
+
type WorkflowListOptions,
|
|
5
|
+
type WorkflowPage,
|
|
6
|
+
type WorkflowRow,
|
|
7
|
+
} from './types';
|
|
8
|
+
import { assertWorkflowRow, validWorkflowState } from './validation';
|
|
9
|
+
|
|
10
|
+
export function workflowExecutions(
|
|
11
|
+
path: string,
|
|
12
|
+
options: WorkflowListOptions = {},
|
|
13
|
+
/** @internal Deterministic seam for the SQLite snapshot regression test. */
|
|
14
|
+
afterRowsRead?: () => void
|
|
15
|
+
): WorkflowPage {
|
|
16
|
+
const kind = options.kind ?? 'active';
|
|
17
|
+
const limit = options.limit ?? 50;
|
|
18
|
+
const offset = options.offset ?? 0;
|
|
19
|
+
validateListOptions(options, limit, offset);
|
|
20
|
+
const db = openWorkflowDatabase(path);
|
|
21
|
+
try {
|
|
22
|
+
const info = workflowSchema(db, kind);
|
|
23
|
+
if (!info) return { available: false, executions: [], total: 0, limit, offset };
|
|
24
|
+
const { predicate, bindings } = listPredicate(options);
|
|
25
|
+
const archiveColumn = kind === 'archive' ? ', archived_at' : '';
|
|
26
|
+
const metaColumn = info.hasMeta ? ', meta' : '';
|
|
27
|
+
db.run('BEGIN');
|
|
28
|
+
let transactionOpen = true;
|
|
29
|
+
try {
|
|
30
|
+
const rows = db
|
|
31
|
+
.query(
|
|
32
|
+
`SELECT id, workflow_name, state, current_node_index, created_at, updated_at${archiveColumn}${metaColumn}
|
|
33
|
+
FROM ${info.table}${predicate} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`
|
|
34
|
+
)
|
|
35
|
+
.all(...bindings, limit, offset) as WorkflowRow[];
|
|
36
|
+
afterRowsRead?.();
|
|
37
|
+
const count = db.query(`SELECT COUNT(*) AS count FROM ${info.table}${predicate}`).get(...bindings) as { count: number };
|
|
38
|
+
const executions = rows.map(assertWorkflowRow);
|
|
39
|
+
db.run('COMMIT');
|
|
40
|
+
transactionOpen = false;
|
|
41
|
+
return { available: true, executions, total: count.count, limit, offset };
|
|
42
|
+
} finally {
|
|
43
|
+
if (transactionOpen) rollbackQuietly(db);
|
|
44
|
+
}
|
|
45
|
+
} finally {
|
|
46
|
+
db.close();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function validateListOptions(options: WorkflowListOptions, limit: number, offset: number): void {
|
|
51
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > WORKFLOW_LIMITS.page) {
|
|
52
|
+
throw new Error(`Workflow limit must be an integer between 1 and ${WORKFLOW_LIMITS.page}`);
|
|
53
|
+
}
|
|
54
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > WORKFLOW_LIMITS.offset) {
|
|
55
|
+
throw new Error(`Workflow offset must be an integer between 0 and ${WORKFLOW_LIMITS.offset}`);
|
|
56
|
+
}
|
|
57
|
+
if (options.workflowName !== undefined && (!options.workflowName || options.workflowName.length > WORKFLOW_LIMITS.filter)) {
|
|
58
|
+
throw new Error(`Workflow name must contain 1–${WORKFLOW_LIMITS.filter} characters`);
|
|
59
|
+
}
|
|
60
|
+
if (options.state !== undefined && options.state !== 'compensation' && !validWorkflowState(options.state)) {
|
|
61
|
+
throw new Error('Unknown workflow execution state');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function listPredicate(options: WorkflowListOptions): { predicate: string; bindings: string[] } {
|
|
66
|
+
const where: string[] = [];
|
|
67
|
+
const bindings: string[] = [];
|
|
68
|
+
if (options.workflowName) {
|
|
69
|
+
where.push('workflow_name = ?');
|
|
70
|
+
bindings.push(options.workflowName);
|
|
71
|
+
}
|
|
72
|
+
if (options.state === 'compensation') {
|
|
73
|
+
where.push("state IN ('compensating', 'compensation-stuck')");
|
|
74
|
+
} else if (options.state) {
|
|
75
|
+
where.push('state = ?');
|
|
76
|
+
bindings.push(options.state);
|
|
77
|
+
}
|
|
78
|
+
return { predicate: where.length ? ` WHERE ${where.join(' AND ')}` : '', bindings };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function rollbackQuietly(db: import('bun:sqlite').Database): void {
|
|
82
|
+
try {
|
|
83
|
+
db.run('ROLLBACK');
|
|
84
|
+
} catch {
|
|
85
|
+
// Preserve the original query or decoding error if SQLite already closed the transaction.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import { basename, isAbsolute } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import {
|
|
5
|
+
Engine,
|
|
6
|
+
type EngineOptions,
|
|
7
|
+
type ExecutionState,
|
|
8
|
+
type Workflow,
|
|
9
|
+
} from 'bunqueue/workflow';
|
|
10
|
+
import { managedAuthToken } from '../managedTarget';
|
|
11
|
+
import type { ServerConfig } from '../manager';
|
|
12
|
+
import {
|
|
13
|
+
configuredWorkflowModule,
|
|
14
|
+
messageOf,
|
|
15
|
+
readyRuntimeStatus,
|
|
16
|
+
stoppedRuntimeStatus,
|
|
17
|
+
WorkflowRuntimeUnavailableError,
|
|
18
|
+
workflowRuntimeOptions,
|
|
19
|
+
workflowRuntimeSignature,
|
|
20
|
+
} from './runtimeConfig';
|
|
21
|
+
|
|
22
|
+
export { WorkflowRuntimeUnavailableError } from './runtimeConfig';
|
|
23
|
+
|
|
24
|
+
export interface WorkflowRuntimeStatus {
|
|
25
|
+
configured: boolean;
|
|
26
|
+
ready: boolean;
|
|
27
|
+
moduleName?: string;
|
|
28
|
+
workflowNames: string[];
|
|
29
|
+
queueName?: string;
|
|
30
|
+
concurrency?: number;
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WorkflowRuntimePort {
|
|
35
|
+
status(config: ServerConfig, activate?: boolean): Promise<WorkflowRuntimeStatus>;
|
|
36
|
+
reload(config: ServerConfig): Promise<WorkflowRuntimeStatus>;
|
|
37
|
+
start(config: ServerConfig, workflowName: string, input?: unknown): Promise<unknown>;
|
|
38
|
+
signal(config: ServerConfig, id: string, event: string, payload?: unknown): Promise<void>;
|
|
39
|
+
recover(config: ServerConfig): Promise<unknown>;
|
|
40
|
+
resumeCompensation(config: ServerConfig, id: string): Promise<void>;
|
|
41
|
+
abandonCompensation(config: ServerConfig, id: string): Promise<void>;
|
|
42
|
+
archive(config: ServerConfig, maxAgeMs: number, states: ExecutionState[]): Promise<number>;
|
|
43
|
+
cleanup(config: ServerConfig, maxAgeMs: number, states: ExecutionState[]): Promise<number>;
|
|
44
|
+
close(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface ActiveRuntime {
|
|
48
|
+
engine: Engine;
|
|
49
|
+
signature: string;
|
|
50
|
+
moduleName: string;
|
|
51
|
+
workflowNames: string[];
|
|
52
|
+
queueName: string;
|
|
53
|
+
concurrency: number;
|
|
54
|
+
initializationFailure?: string;
|
|
55
|
+
closeFailure?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface RuntimeModule {
|
|
59
|
+
workflows?: Workflow[];
|
|
60
|
+
workflowNames?: string[];
|
|
61
|
+
registerWorkflowRuntime?: (engine: Engine) => unknown | Promise<unknown>;
|
|
62
|
+
default?: (engine: Engine) => unknown | Promise<unknown>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type WorkflowEngineFactory = (options: EngineOptions) => Engine;
|
|
66
|
+
|
|
67
|
+
export class WorkflowRuntime implements WorkflowRuntimePort {
|
|
68
|
+
private active: ActiveRuntime | null = null;
|
|
69
|
+
private gate: Promise<void> = Promise.resolve();
|
|
70
|
+
|
|
71
|
+
constructor(
|
|
72
|
+
private readonly createEngine: WorkflowEngineFactory = (options) => new Engine(options)
|
|
73
|
+
) {}
|
|
74
|
+
|
|
75
|
+
status(config: ServerConfig, activate = true): Promise<WorkflowRuntimeStatus> {
|
|
76
|
+
return this.serial(async () => {
|
|
77
|
+
const modulePath = configuredWorkflowModule(config);
|
|
78
|
+
try {
|
|
79
|
+
if (!modulePath) {
|
|
80
|
+
await this.closeActive();
|
|
81
|
+
return { configured: false, ready: false, workflowNames: [] };
|
|
82
|
+
}
|
|
83
|
+
if (!activate) {
|
|
84
|
+
await this.closeActive();
|
|
85
|
+
return stoppedRuntimeStatus(config, modulePath);
|
|
86
|
+
}
|
|
87
|
+
const active = await this.ensure(config);
|
|
88
|
+
return readyRuntimeStatus(active);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const active = this.active;
|
|
91
|
+
return {
|
|
92
|
+
configured: Boolean(modulePath),
|
|
93
|
+
ready: false,
|
|
94
|
+
moduleName: modulePath ? basename(modulePath) : active?.moduleName,
|
|
95
|
+
workflowNames: [],
|
|
96
|
+
queueName: active?.queueName,
|
|
97
|
+
concurrency: active?.concurrency,
|
|
98
|
+
error: messageOf(error),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
reload(config: ServerConfig): Promise<WorkflowRuntimeStatus> {
|
|
105
|
+
return this.serial(async () => {
|
|
106
|
+
await this.closeActive();
|
|
107
|
+
return readyRuntimeStatus(await this.ensure(config));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
start(config: ServerConfig, workflowName: string, input?: unknown): Promise<unknown> {
|
|
112
|
+
return this.run(config, (engine) => engine.start(workflowName, input));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
signal(config: ServerConfig, id: string, event: string, payload?: unknown): Promise<void> {
|
|
116
|
+
return this.run(config, (engine) => engine.signal(id, event, payload));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
recover(config: ServerConfig): Promise<unknown> {
|
|
120
|
+
return this.run(config, (engine) => engine.recover());
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
resumeCompensation(config: ServerConfig, id: string): Promise<void> {
|
|
124
|
+
return this.run(config, (engine) => engine.resumeCompensation(id));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
abandonCompensation(config: ServerConfig, id: string): Promise<void> {
|
|
128
|
+
return this.run(config, (engine) => engine.abandonCompensation(id));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
archive(
|
|
132
|
+
config: ServerConfig,
|
|
133
|
+
maxAgeMs: number,
|
|
134
|
+
states: ExecutionState[]
|
|
135
|
+
): Promise<number> {
|
|
136
|
+
return this.run(config, (engine) => engine.archive(maxAgeMs, states));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
cleanup(
|
|
140
|
+
config: ServerConfig,
|
|
141
|
+
maxAgeMs: number,
|
|
142
|
+
states: ExecutionState[]
|
|
143
|
+
): Promise<number> {
|
|
144
|
+
return this.run(config, (engine) => engine.cleanup(maxAgeMs, states));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
close(): Promise<void> {
|
|
148
|
+
return this.serial(() => this.closeActive());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private run<T>(config: ServerConfig, operation: (engine: Engine) => Promise<T> | T): Promise<T> {
|
|
152
|
+
return this.serial(async () => operation((await this.ensure(config)).engine));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async ensure(config: ServerConfig): Promise<ActiveRuntime> {
|
|
156
|
+
if (this.active?.closeFailure !== undefined) {
|
|
157
|
+
throw quarantinedRuntimeError(this.active);
|
|
158
|
+
}
|
|
159
|
+
const requested = configuredWorkflowModule(config);
|
|
160
|
+
if (!requested) {
|
|
161
|
+
throw new WorkflowRuntimeUnavailableError(
|
|
162
|
+
'Set BUNQUEUE_WORKFLOW_MODULE in Server extraEnv to an absolute runtime module path.'
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (!isAbsolute(requested)) {
|
|
166
|
+
throw new WorkflowRuntimeUnavailableError('BUNQUEUE_WORKFLOW_MODULE must be absolute.');
|
|
167
|
+
}
|
|
168
|
+
const path = await realpath(requested).catch(() => {
|
|
169
|
+
throw new WorkflowRuntimeUnavailableError(
|
|
170
|
+
'The configured workflow runtime module was not found.'
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
const modified = (await stat(path)).mtimeMs;
|
|
174
|
+
const options = workflowRuntimeOptions(config);
|
|
175
|
+
const signature = workflowRuntimeSignature(config, path, modified, options);
|
|
176
|
+
if (this.active?.signature === signature) return this.active;
|
|
177
|
+
|
|
178
|
+
await this.closeActive();
|
|
179
|
+
const imported = (await import(`${pathToFileURL(path).href}?v=${modified}`)) as RuntimeModule;
|
|
180
|
+
const definitions = Array.isArray(imported.workflows) ? imported.workflows : [];
|
|
181
|
+
const register = imported.registerWorkflowRuntime ?? imported.default;
|
|
182
|
+
if (!register && definitions.length === 0) {
|
|
183
|
+
throw new WorkflowRuntimeUnavailableError(
|
|
184
|
+
'Workflow module must export workflows[] or registerWorkflowRuntime(engine).'
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const engine = this.createEngine({
|
|
188
|
+
dataPath: config.dataPath,
|
|
189
|
+
queueName: options.queueName,
|
|
190
|
+
concurrency: options.concurrency,
|
|
191
|
+
connection: {
|
|
192
|
+
host: '127.0.0.1',
|
|
193
|
+
port: config.tcpPort,
|
|
194
|
+
token: managedAuthToken(config),
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
const active: ActiveRuntime = {
|
|
198
|
+
engine,
|
|
199
|
+
signature,
|
|
200
|
+
moduleName: basename(path),
|
|
201
|
+
workflowNames: [],
|
|
202
|
+
...options,
|
|
203
|
+
};
|
|
204
|
+
this.active = active;
|
|
205
|
+
try {
|
|
206
|
+
const names = new Set<string>();
|
|
207
|
+
const originalRegister = engine.register.bind(engine);
|
|
208
|
+
engine.register = ((workflow: Workflow) => {
|
|
209
|
+
const registered = originalRegister(workflow);
|
|
210
|
+
names.add(workflowName(workflow.name));
|
|
211
|
+
return registered;
|
|
212
|
+
}) as Engine['register'];
|
|
213
|
+
try {
|
|
214
|
+
for (const workflow of definitions) engine.register(workflow);
|
|
215
|
+
if (register) await register(engine);
|
|
216
|
+
} finally {
|
|
217
|
+
engine.register = originalRegister as Engine['register'];
|
|
218
|
+
}
|
|
219
|
+
if (imported.workflowNames !== undefined && !Array.isArray(imported.workflowNames)) {
|
|
220
|
+
throw new WorkflowRuntimeUnavailableError('workflowNames must be an array of strings.');
|
|
221
|
+
}
|
|
222
|
+
for (const name of imported.workflowNames ?? []) names.add(workflowName(name));
|
|
223
|
+
active.workflowNames = Array.from(names).sort();
|
|
224
|
+
return active;
|
|
225
|
+
} catch (error) {
|
|
226
|
+
try {
|
|
227
|
+
active.initializationFailure ??= messageOf(error);
|
|
228
|
+
} finally {
|
|
229
|
+
await this.closeActive();
|
|
230
|
+
}
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private async closeActive(): Promise<void> {
|
|
236
|
+
const previous = this.active;
|
|
237
|
+
if (!previous) return;
|
|
238
|
+
try {
|
|
239
|
+
await previous.engine.close(true);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
previous.closeFailure ??= messageOf(error);
|
|
242
|
+
throw quarantinedRuntimeError(previous);
|
|
243
|
+
}
|
|
244
|
+
if (this.active === previous) this.active = null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private serial<T>(operation: () => Promise<T>): Promise<T> {
|
|
248
|
+
const result = this.gate.then(operation, operation);
|
|
249
|
+
this.gate = result.then(
|
|
250
|
+
() => undefined,
|
|
251
|
+
() => undefined
|
|
252
|
+
);
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function quarantinedRuntimeError(active: ActiveRuntime): WorkflowRuntimeUnavailableError {
|
|
258
|
+
const initialization = active.initializationFailure !== undefined
|
|
259
|
+
? ` Initialization failed: ${active.initializationFailure}.`
|
|
260
|
+
: '';
|
|
261
|
+
return new WorkflowRuntimeUnavailableError(
|
|
262
|
+
`Workflow Engine is quarantined.${initialization} Shutdown failed: ${active.closeFailure}. ` +
|
|
263
|
+
'Restart the control agent before running or reloading workflows.'
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function workflowName(value: unknown): string {
|
|
268
|
+
if (typeof value !== 'string' || value.length < 1 || value.length > 256) {
|
|
269
|
+
throw new WorkflowRuntimeUnavailableError(
|
|
270
|
+
'workflowNames must contain strings between 1 and 256 characters.'
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return value;
|
|
274
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { safeErrorMessage } from '../errorMessage';
|
|
3
|
+
import { managedAuthToken } from '../managedTarget';
|
|
4
|
+
import type { ServerConfig } from '../manager';
|
|
5
|
+
import type { WorkflowRuntimeStatus } from './runtime';
|
|
6
|
+
|
|
7
|
+
export class WorkflowRuntimeUnavailableError extends Error {}
|
|
8
|
+
|
|
9
|
+
export interface WorkflowRuntimeOptions {
|
|
10
|
+
queueName: string;
|
|
11
|
+
concurrency: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RuntimeStatusSource extends WorkflowRuntimeOptions {
|
|
15
|
+
moduleName: string;
|
|
16
|
+
workflowNames: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function configuredWorkflowModule(config: ServerConfig): string | undefined {
|
|
20
|
+
return effectiveEnvironment(config, 'BUNQUEUE_WORKFLOW_MODULE')?.trim() || undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function workflowRuntimeOptions(config: ServerConfig): WorkflowRuntimeOptions {
|
|
24
|
+
const queueName = effectiveEnvironment(config, 'BUNQUEUE_WORKFLOW_QUEUE_NAME')?.trim() || '__wf:steps';
|
|
25
|
+
if (queueName.length > 256 || /[\u0000-\u001f\u007f]/.test(queueName)) {
|
|
26
|
+
throw new WorkflowRuntimeUnavailableError(
|
|
27
|
+
'BUNQUEUE_WORKFLOW_QUEUE_NAME must contain 1–256 printable characters.'
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const rawConcurrency = effectiveEnvironment(config, 'BUNQUEUE_WORKFLOW_CONCURRENCY')?.trim();
|
|
31
|
+
const concurrency = rawConcurrency ? Number(rawConcurrency) : 5;
|
|
32
|
+
if (
|
|
33
|
+
(rawConcurrency !== undefined && !/^\d+$/.test(rawConcurrency)) ||
|
|
34
|
+
!Number.isSafeInteger(concurrency) ||
|
|
35
|
+
concurrency < 1 ||
|
|
36
|
+
concurrency > 1_000
|
|
37
|
+
) {
|
|
38
|
+
throw new WorkflowRuntimeUnavailableError(
|
|
39
|
+
'BUNQUEUE_WORKFLOW_CONCURRENCY must be an integer between 1 and 1000.'
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return { queueName, concurrency };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function workflowRuntimeSignature(
|
|
46
|
+
config: ServerConfig,
|
|
47
|
+
path: string,
|
|
48
|
+
modified: number,
|
|
49
|
+
options: WorkflowRuntimeOptions
|
|
50
|
+
): string {
|
|
51
|
+
return JSON.stringify([
|
|
52
|
+
path,
|
|
53
|
+
modified,
|
|
54
|
+
config.dataPath,
|
|
55
|
+
config.tcpPort,
|
|
56
|
+
managedAuthToken(config),
|
|
57
|
+
options.queueName,
|
|
58
|
+
options.concurrency,
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function stoppedRuntimeStatus(
|
|
63
|
+
config: ServerConfig,
|
|
64
|
+
modulePath: string
|
|
65
|
+
): WorkflowRuntimeStatus {
|
|
66
|
+
try {
|
|
67
|
+
return {
|
|
68
|
+
configured: true,
|
|
69
|
+
ready: false,
|
|
70
|
+
moduleName: basename(modulePath),
|
|
71
|
+
workflowNames: [],
|
|
72
|
+
...workflowRuntimeOptions(config),
|
|
73
|
+
error: 'Managed Bunqueue server is stopped.',
|
|
74
|
+
};
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return {
|
|
77
|
+
configured: true,
|
|
78
|
+
ready: false,
|
|
79
|
+
moduleName: basename(modulePath),
|
|
80
|
+
workflowNames: [],
|
|
81
|
+
error: messageOf(error),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function readyRuntimeStatus(active: RuntimeStatusSource): WorkflowRuntimeStatus {
|
|
87
|
+
return {
|
|
88
|
+
configured: true,
|
|
89
|
+
ready: true,
|
|
90
|
+
moduleName: active.moduleName,
|
|
91
|
+
workflowNames: active.workflowNames,
|
|
92
|
+
queueName: active.queueName,
|
|
93
|
+
concurrency: active.concurrency,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function messageOf(error: unknown): string {
|
|
98
|
+
return safeErrorMessage(error);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function effectiveEnvironment(config: ServerConfig, key: string): string | undefined {
|
|
102
|
+
return Object.hasOwn(config.extraEnv, key) ? config.extraEnv[key] : process.env[key];
|
|
103
|
+
}
|
|
@@ -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
|
+
}
|