bunqueue-dashboard 0.0.35 → 0.0.37
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 +36 -16
- package/agent/backup/processExecutor.ts +90 -0
- package/agent/backup/resultValidation.ts +85 -0
- package/agent/backup/routes.ts +161 -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 +251 -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 +22 -21
- package/agent/logger.ts +121 -17
- package/agent/managedTarget.ts +82 -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 +135 -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 +185 -0
- package/agent/server/controlTarget.ts +116 -0
- package/agent/server/databaseRoutes.ts +165 -0
- package/agent/server/errors.ts +49 -0
- package/agent/server/handler.ts +155 -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 +161 -0
- package/agent/server/types.ts +46 -0
- package/agent/server/workflowReadRoutes.ts +80 -0
- package/agent/server.ts +26 -441
- 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 +163 -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 +14 -489
- package/dist/.vite/manifest.json +611 -250
- package/dist/assets/AddJob-CWkkVHmf.js +3 -0
- package/dist/assets/Alerts-Gh1pVbqj.js +1 -0
- package/dist/assets/{AreaChart-REK4ZsTC.js → AreaChart-w0ruozXH.js} +1 -1
- package/dist/assets/Benchmark-Dir2A1CQ.js +1 -0
- package/dist/assets/BulkAddJobs-Di_IgRSH.js +5 -0
- package/dist/assets/Button-BPqpRZJD.js +1 -0
- package/dist/assets/Card-C7NMhqUt.js +1 -0
- package/dist/assets/{CopilotPanel-COLw-atS.js → CopilotPanel-Db5nYpo1.js} +3 -3
- package/dist/assets/Cron-BRgWcBGk.js +1 -0
- package/dist/assets/CronManager-CbM0x_s0.js +1 -0
- package/dist/assets/Database-BeoEreW0.js +3 -0
- package/dist/assets/Diagnostics-Cw9iJ84f.js +1 -0
- package/dist/assets/Dlq-DfCg04Jd.js +1 -0
- package/dist/assets/DlqControl-BRCkUXvb.js +1 -0
- package/dist/assets/DlqPro-BHJwBYsN.js +1 -0
- package/dist/assets/Flows-BAzbFP_W.js +2 -0
- package/dist/assets/JobInspector-qvW_APO6.js +2 -0
- package/dist/assets/Jobs-URpnFjC6.js +1 -0
- package/dist/assets/JobsPro-pQ_RFSU3.js +1 -0
- package/dist/assets/Logs-C5jX2OPE.js +1 -0
- package/dist/assets/LogsPro-Cq_npmmS.js +1 -0
- package/dist/assets/{McpServer-CbaU52l2.js → McpServer-DtCTmQBu.js} +2 -2
- package/dist/assets/Metrics-BdxitePr.js +1 -0
- package/dist/assets/MetricsPro-orHfW0mg.js +1 -0
- package/dist/assets/Overview-DaNns1aj.js +1 -0
- package/dist/assets/PageHeader-BXlZSBcc.js +1 -0
- package/dist/assets/Pagination-Q-vf4hsX.js +1 -0
- package/dist/assets/QueueActions-jiTzndPH.js +1 -0
- package/dist/assets/QueueControl-l5bSdMgb.js +1 -0
- package/dist/assets/QueueDetail-URK2pjtH.js +1 -0
- package/dist/assets/QueueDetailPro-C9M5CV4K.js +1 -0
- package/dist/assets/Queues-DeLXlww2.js +1 -0
- package/dist/assets/QueuesOverview-DoQ-MUfT.js +1 -0
- package/dist/assets/S3Backup-D2agV9_z.js +1 -0
- package/dist/assets/S3BackupPro--WSZWTQn.js +3 -0
- package/dist/assets/ServerControl-QVd6m_iR.js +3 -0
- package/dist/assets/Settings-CiJRj_SM.js +1 -0
- package/dist/assets/StatusBadge-B0I7hNBX.js +1 -0
- package/dist/assets/Usage-DbHJgA4t.js +1 -0
- package/dist/assets/UsagePro-LoPo3k1L.js +1 -0
- package/dist/assets/Webhooks-CuzIJHwk.js +1 -0
- package/dist/assets/Workers-BpUgpwUH.js +1 -0
- package/dist/assets/WorkersPro-BYY0P1Pt.js +1 -0
- package/dist/assets/Workflows-ByJw4FFH.js +1 -0
- package/dist/assets/api-V_sVpHDo.js +1 -0
- package/dist/assets/bq-DYmbVkgR.js +1 -0
- package/dist/assets/cn-zFEt5SO8.js +1 -0
- package/dist/assets/connectionStore-CKFQrxDo.js +1 -0
- package/dist/assets/controlConnectionEpoch-JM4omZ6j.js +1 -0
- package/dist/assets/feedback-BnGE2oW2.js +1 -0
- package/dist/assets/{flowMutationSafety-CoOp94wS.js → flowMutationSafety-DObp9nJo.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-D64v2vNO.js +8 -0
- package/dist/assets/index-DOze4TKg.css +1 -0
- package/dist/assets/install-jXi1SOYY.js +3 -0
- package/dist/assets/{react-vendor-Bjui4wS4.js → react-vendor-CyjgmGTq.js} +3 -3
- package/dist/assets/responses-CBLyy1Ob.js +1 -0
- package/dist/assets/toastStore-B7n0zS0y.js +1 -0
- package/dist/assets/{upstreamPaths-D_LPsv7b.js → upstreamPaths-CDVsu6IM.js} +1 -1
- package/dist/assets/usePolledData-c53tx1jw.js +1 -0
- package/dist/assets/{useServerActionGuard-CItj3hvh.js → useServerActionGuard-OTv6_fU0.js} +1 -1
- package/dist/assets/useThroughputSeries-DWHQl7cM.js +1 -0
- package/dist/index.html +17 -6
- package/package.json +15 -4
- package/scripts/embedded.gen.ts +160 -134
- package/scripts/serve.ts +127 -484
- package/scripts/serveHandler.ts +237 -0
- package/scripts/servePolicy.ts +187 -0
- package/dist/assets/AddJob-CG28MSxU.js +0 -3
- package/dist/assets/Alerts-D9JojJrj.js +0 -1
- package/dist/assets/Benchmark-BVjUH9dG.js +0 -1
- package/dist/assets/BulkAddJobs-BHzUCMj_.js +0 -5
- package/dist/assets/Cron-DoEHDHwy.js +0 -1
- package/dist/assets/CronManager-vulcesqJ.js +0 -1
- package/dist/assets/Database-DlB_t04Z.js +0 -3
- package/dist/assets/Diagnostics-DddCUgeR.js +0 -1
- package/dist/assets/Dlq-DMKrsSMP.js +0 -1
- package/dist/assets/DlqControl-9m0Re_TH.js +0 -1
- package/dist/assets/DlqPro-Dmw-0wta.js +0 -1
- package/dist/assets/Flows-C3-9nXW4.js +0 -1
- package/dist/assets/JobInspector-DwIfABmI.js +0 -2
- package/dist/assets/Jobs-j8tTS3NC.js +0 -1
- package/dist/assets/JobsPro-CS__CJJl.js +0 -1
- package/dist/assets/Logs-BB-VDPA1.js +0 -1
- package/dist/assets/LogsPro-RNtSQZRT.js +0 -1
- package/dist/assets/Metrics-ivcddPwT.js +0 -1
- package/dist/assets/MetricsPro-xjZno4vg.js +0 -1
- package/dist/assets/Overview-D84aD2ph.js +0 -1
- package/dist/assets/Pagination-Bfi4vkpp.js +0 -1
- package/dist/assets/QueueActions-DTbD6uek.js +0 -1
- package/dist/assets/QueueControl-B5xK_SQ-.js +0 -1
- package/dist/assets/QueueDetail-BH59kwm9.js +0 -1
- package/dist/assets/QueueDetailPro-DoN25ndy.js +0 -1
- package/dist/assets/Queues-CKMJqFHX.js +0 -1
- package/dist/assets/QueuesOverview-BX1lzojl.js +0 -1
- package/dist/assets/S3Backup-Bz3GfoZ9.js +0 -1
- package/dist/assets/S3BackupPro-BC_6-uX-.js +0 -2
- package/dist/assets/ServerControl-CSSrN8G5.js +0 -3
- package/dist/assets/Settings-DkqmZUIS.js +0 -1
- package/dist/assets/Usage-Ba20p0xd.js +0 -1
- package/dist/assets/UsagePro-C2pQwjN9.js +0 -1
- package/dist/assets/Webhooks-CVuuvb0L.js +0 -1
- package/dist/assets/Workers-C1wPU6CS.js +0 -1
- package/dist/assets/WorkersPro-B0vEkC0T.js +0 -1
- package/dist/assets/Workflows-C8Qmkflf.js +0 -1
- package/dist/assets/cn-CMFYpumL.js +0 -1
- package/dist/assets/connectionStore-Cd9-3Fw0.js +0 -1
- package/dist/assets/index-BCNzbhhD.css +0 -1
- package/dist/assets/index-CAtsVmyh.js +0 -8
- package/dist/assets/install-D0eRLf9F.js +0 -3
- package/dist/assets/useThroughputSeries-DjxGN8ob.js +0 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Unpackr } from 'msgpackr';
|
|
2
|
+
import { WORKFLOW_LIMITS } from './types';
|
|
3
|
+
|
|
4
|
+
const unpackr = new Unpackr({ structuredClone: true });
|
|
5
|
+
|
|
6
|
+
export function decodeWorkflowValue(
|
|
7
|
+
blob: Uint8Array | null | undefined,
|
|
8
|
+
cap: number,
|
|
9
|
+
label: string
|
|
10
|
+
): unknown {
|
|
11
|
+
if (!blob) return null;
|
|
12
|
+
if (!(blob instanceof Uint8Array)) throw new Error(`Workflow ${label} is not a BLOB`);
|
|
13
|
+
if (blob.byteLength > cap) {
|
|
14
|
+
throw new Error(`Workflow ${label} exceeds the ${cap}-byte inspection limit`);
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
return unpackr.unpack(blob);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
throw new Error(`Could not decode workflow ${label}: ${(error as Error).message}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function decodeWorkflowObject(
|
|
24
|
+
blob: Uint8Array | null | undefined,
|
|
25
|
+
cap: number,
|
|
26
|
+
label: string
|
|
27
|
+
): Record<string, unknown> {
|
|
28
|
+
const value = decodeWorkflowValue(blob, cap, label);
|
|
29
|
+
if (value == null) return {};
|
|
30
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
31
|
+
throw new Error(`Workflow ${label} must decode to an object`);
|
|
32
|
+
}
|
|
33
|
+
return value as Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Convert decoded MessagePack extensions to a bounded, JSON-safe value. */
|
|
37
|
+
export function jsonSafeWorkflowValue(value: unknown): unknown {
|
|
38
|
+
let nodes = 0;
|
|
39
|
+
const seen = new WeakSet<object>();
|
|
40
|
+
const visit = (entry: unknown, depth: number): unknown => {
|
|
41
|
+
nodes++;
|
|
42
|
+
if (nodes > WORKFLOW_LIMITS.jsonNodes) throw new Error('Workflow detail exceeds the inspection node limit');
|
|
43
|
+
if (depth > WORKFLOW_LIMITS.jsonDepth) throw new Error('Workflow detail exceeds the inspection depth limit');
|
|
44
|
+
if (entry == null || typeof entry === 'boolean' || typeof entry === 'number') return entry;
|
|
45
|
+
if (typeof entry === 'string') {
|
|
46
|
+
if (entry.length > WORKFLOW_LIMITS.string) throw new Error('Workflow detail contains an oversized string');
|
|
47
|
+
return entry;
|
|
48
|
+
}
|
|
49
|
+
if (typeof entry === 'bigint') return entry.toString();
|
|
50
|
+
if (typeof entry === 'undefined') return null;
|
|
51
|
+
if (typeof entry !== 'object') return String(entry);
|
|
52
|
+
if (seen.has(entry)) throw new Error('Workflow detail contains a cyclic value');
|
|
53
|
+
seen.add(entry);
|
|
54
|
+
try {
|
|
55
|
+
if (entry instanceof Date) return entry.toISOString();
|
|
56
|
+
if (entry instanceof Uint8Array) return `<binary ${entry.byteLength} B>`;
|
|
57
|
+
if (entry instanceof Map) return mapValue(entry, visit, depth);
|
|
58
|
+
if (entry instanceof Set) return [...entry].map((item) => visit(item, depth + 1));
|
|
59
|
+
if (Array.isArray(entry)) return entry.map((item) => visit(item, depth + 1));
|
|
60
|
+
const out = Object.create(null) as Record<string, unknown>;
|
|
61
|
+
for (const [key, item] of Object.entries(entry)) out[key] = visit(item, depth + 1);
|
|
62
|
+
return out;
|
|
63
|
+
} finally {
|
|
64
|
+
seen.delete(entry);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
return visit(value, 0);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function mapValue(
|
|
71
|
+
map: Map<unknown, unknown>,
|
|
72
|
+
visit: (entry: unknown, depth: number) => unknown,
|
|
73
|
+
depth: number
|
|
74
|
+
): Record<string, unknown> {
|
|
75
|
+
const out = Object.create(null) as Record<string, unknown>;
|
|
76
|
+
for (const [key, item] of map) out[String(key)] = visit(item, depth + 1);
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { decodeWorkflowObject, decodeWorkflowValue, jsonSafeWorkflowValue } from './codec';
|
|
2
|
+
import { openWorkflowDatabase, workflowSchema } from './sqlite';
|
|
3
|
+
import {
|
|
4
|
+
WORKFLOW_LIMITS,
|
|
5
|
+
type WorkflowExecutionDetail,
|
|
6
|
+
type WorkflowRow,
|
|
7
|
+
type WorkflowStoreKind,
|
|
8
|
+
} from './types';
|
|
9
|
+
import { assertWorkflowRow, assertWorkflowSteps } from './validation';
|
|
10
|
+
|
|
11
|
+
interface WorkflowDetailRow extends WorkflowRow {
|
|
12
|
+
input: Uint8Array | null;
|
|
13
|
+
steps: Uint8Array | null;
|
|
14
|
+
resolved_steps: Uint8Array | null;
|
|
15
|
+
signals: Uint8Array | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function workflowExecution(
|
|
19
|
+
path: string,
|
|
20
|
+
id: string,
|
|
21
|
+
kind: WorkflowStoreKind = 'active'
|
|
22
|
+
): WorkflowExecutionDetail | null {
|
|
23
|
+
if (!id || id.length > 1024) throw new Error('Workflow execution id must contain 1–1024 characters');
|
|
24
|
+
const db = openWorkflowDatabase(path);
|
|
25
|
+
try {
|
|
26
|
+
const info = workflowSchema(db, kind);
|
|
27
|
+
if (!info) return null;
|
|
28
|
+
const row = db.query(`SELECT * FROM ${info.table} WHERE id = ?`).get(id) as WorkflowDetailRow | null;
|
|
29
|
+
if (!row) return null;
|
|
30
|
+
const summary = assertWorkflowRow(row);
|
|
31
|
+
const input = safeDecode(row.input, 'input');
|
|
32
|
+
const steps = assertWorkflowSteps(safeObject(row.steps, 'steps'));
|
|
33
|
+
const resolved = safeDecode(row.resolved_steps, 'resolved steps');
|
|
34
|
+
const signals = safeObject(row.signals, 'signals');
|
|
35
|
+
const meta = decodeWorkflowObject(row.meta, WORKFLOW_LIMITS.metaBlob, 'meta');
|
|
36
|
+
if (resolved != null && (!Array.isArray(resolved) || resolved.some((item) => typeof item !== 'string'))) {
|
|
37
|
+
throw new Error('Workflow resolved steps must decode to a string array');
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
...summary,
|
|
41
|
+
input,
|
|
42
|
+
steps,
|
|
43
|
+
...(resolved ? { resolvedSteps: resolved as string[] } : {}),
|
|
44
|
+
signals,
|
|
45
|
+
...(meta.decisions && typeof meta.decisions === 'object'
|
|
46
|
+
? { decisions: jsonSafeWorkflowValue(meta.decisions) as Record<string, unknown> }
|
|
47
|
+
: {}),
|
|
48
|
+
...(typeof meta.committedAt === 'number' ? { committedAt: meta.committedAt } : {}),
|
|
49
|
+
};
|
|
50
|
+
} finally {
|
|
51
|
+
db.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function safeDecode(blob: Uint8Array | null, label: string): unknown {
|
|
56
|
+
return jsonSafeWorkflowValue(decodeWorkflowValue(blob, WORKFLOW_LIMITS.detailBlob, label));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function safeObject(blob: Uint8Array | null, label: string): Record<string, unknown> {
|
|
60
|
+
return jsonSafeWorkflowValue(
|
|
61
|
+
decodeWorkflowObject(blob, WORKFLOW_LIMITS.detailBlob, label)
|
|
62
|
+
) as Record<string, unknown>;
|
|
63
|
+
}
|
|
@@ -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
|
+
}
|