bunqueue-dashboard 0.0.37 → 0.0.39
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 +21 -11
- package/agent/backup/resultValidation.ts +1 -1
- package/agent/backup/routes.ts +63 -17
- package/agent/db/query.ts +2 -1
- package/agent/manager/config.ts +2 -1
- package/agent/manager/process.ts +51 -5
- package/agent/manager/storageMode.ts +21 -0
- package/agent/manager/types.ts +2 -0
- package/agent/manager.ts +1 -0
- package/agent/queue/routes.ts +1 -2
- package/agent/queue/runtime.ts +5 -0
- package/agent/queue/types.ts +1 -0
- package/agent/server/controlRoutes.ts +109 -8
- package/agent/server/databaseRoutes.ts +47 -14
- package/agent/server/errors.ts +1 -0
- package/agent/server/lifecycle.ts +25 -8
- package/agent/server/managedRuntime.ts +27 -0
- package/agent/server/router.ts +90 -40
- package/agent/server/workflowReadRoutes.ts +28 -11
- package/agent/workflows.ts +1 -1
- package/dist/.vite/manifest.json +490 -476
- package/dist/assets/AddJob-d5KBvmN8.js +3 -0
- package/dist/assets/{Alerts-Gh1pVbqj.js → Alerts-5I44QTnQ.js} +1 -1
- package/dist/assets/{AreaChart-w0ruozXH.js → AreaChart-DEAalJ6P.js} +1 -1
- package/dist/assets/Benchmark-S1afsQRd.js +1 -0
- package/dist/assets/{BulkAddJobs-Di_IgRSH.js → BulkAddJobs-BO8LWUHE.js} +1 -1
- package/dist/assets/{Button-BPqpRZJD.js → Button-Ch1W94fJ.js} +1 -1
- package/dist/assets/{Card-C7NMhqUt.js → Card-CWxrC8OY.js} +1 -1
- package/dist/assets/{CopilotPanel-Db5nYpo1.js → CopilotPanel-DDkRd35p.js} +4 -4
- package/dist/assets/{Cron-BRgWcBGk.js → Cron-X3Y5uISO.js} +1 -1
- package/dist/assets/CronManager-ugmYEAwY.js +1 -0
- package/dist/assets/{Database-BeoEreW0.js → Database-koy8haPX.js} +1 -1
- package/dist/assets/Diagnostics-D6oqvkOs.js +1 -0
- package/dist/assets/{Dlq-DfCg04Jd.js → Dlq-D70Lzmsa.js} +1 -1
- package/dist/assets/{DlqControl-BRCkUXvb.js → DlqControl-nUT5Zzkl.js} +1 -1
- package/dist/assets/{DlqPro-BHJwBYsN.js → DlqPro-VMuBKgP-.js} +1 -1
- package/dist/assets/Flows-CAG0nDnt.js +2 -0
- package/dist/assets/JobInspector-CiS5HOSQ.js +2 -0
- package/dist/assets/{Jobs-URpnFjC6.js → Jobs-CkyRvzOT.js} +1 -1
- package/dist/assets/{JobsPro-pQ_RFSU3.js → JobsPro-B4-CSoq3.js} +1 -1
- package/dist/assets/{Logs-C5jX2OPE.js → Logs-DBTz2PTz.js} +1 -1
- package/dist/assets/{LogsPro-Cq_npmmS.js → LogsPro-BCFeegvr.js} +1 -1
- package/dist/assets/{McpServer-DtCTmQBu.js → McpServer-BKlTR1qr.js} +2 -2
- package/dist/assets/{Metrics-BdxitePr.js → Metrics-Du__9Lbn.js} +1 -1
- package/dist/assets/{MetricsPro-orHfW0mg.js → MetricsPro-CqnaO6YX.js} +1 -1
- package/dist/assets/{Overview-DaNns1aj.js → Overview-udSSt7ZB.js} +1 -1
- package/dist/assets/{PageHeader-BXlZSBcc.js → PageHeader-CvIyaFOO.js} +1 -1
- package/dist/assets/{Pagination-Q-vf4hsX.js → Pagination-x-Cwgrul.js} +1 -1
- package/dist/assets/QueueActions-D7cQrGvJ.js +1 -0
- package/dist/assets/{QueueControl-l5bSdMgb.js → QueueControl-BF638r_0.js} +1 -1
- package/dist/assets/QueueDetail-C-ms6bMe.js +1 -0
- package/dist/assets/QueueDetailPro-DXEdsZx_.js +1 -0
- package/dist/assets/{Queues-DeLXlww2.js → Queues-BUMwTVAf.js} +1 -1
- package/dist/assets/{QueuesOverview-DoQ-MUfT.js → QueuesOverview-Du8XLgN0.js} +1 -1
- package/dist/assets/{S3Backup-D2agV9_z.js → S3Backup-CDICSzxM.js} +1 -1
- package/dist/assets/S3BackupPro-CBidbI-s.js +3 -0
- package/dist/assets/ServerControl-CPT0LpsC.js +3 -0
- package/dist/assets/{Settings-CiJRj_SM.js → Settings-CmxfSX_8.js} +1 -1
- package/dist/assets/{StatusBadge-B0I7hNBX.js → StatusBadge-BoOVTnKu.js} +1 -1
- package/dist/assets/{Usage-DbHJgA4t.js → Usage-B895tSxi.js} +1 -1
- package/dist/assets/{UsagePro-LoPo3k1L.js → UsagePro-CASN77iV.js} +1 -1
- package/dist/assets/{Webhooks-CuzIJHwk.js → Webhooks-CXbZOFFw.js} +1 -1
- package/dist/assets/{Workers-BpUgpwUH.js → Workers-BceQlUhX.js} +1 -1
- package/dist/assets/WorkersPro-Dtml89jB.js +1 -0
- package/dist/assets/Workflows-B8wmQN2X.js +1 -0
- package/dist/assets/{api-V_sVpHDo.js → api-81m1TwB7.js} +1 -1
- package/dist/assets/bq-BOfwNIlJ.js +1 -0
- package/dist/assets/{connectionStore-CKFQrxDo.js → connectionStore-CFuNYZ_8.js} +1 -1
- package/dist/assets/{controlConnectionEpoch-JM4omZ6j.js → controlConnectionEpoch-DzbuWOhe.js} +1 -1
- package/dist/assets/{feedback-BnGE2oW2.js → feedback-QDN1AsYt.js} +1 -1
- package/dist/assets/flowMutationSafety-6cP-l4Pa.js +1 -0
- package/dist/assets/{form-tC20r4Ma.js → form-C4oby9pI.js} +1 -1
- package/dist/assets/{icons-CIvFfU2b.js → icons-BVzwsfbG.js} +1 -1
- package/dist/assets/index-B-QjN7pA.css +1 -0
- package/dist/assets/index-TploGF7u.js +8 -0
- package/dist/assets/install-BPp8nydm.js +3 -0
- package/dist/assets/{react-vendor-CyjgmGTq.js → react-vendor-CvwrP0tU.js} +2 -2
- package/dist/assets/{responses-CBLyy1Ob.js → responses-C9lP49qu.js} +1 -1
- package/dist/assets/{toastStore-B7n0zS0y.js → toastStore-B31o6Z5J.js} +1 -1
- package/dist/assets/{upstreamPaths-CDVsu6IM.js → upstreamPaths-ZeijhPPb.js} +1 -1
- package/dist/assets/useControlActionGuard-DT7uudrj.js +1 -0
- package/dist/assets/{usePolledData-c53tx1jw.js → usePolledData-CNtNtd0a.js} +1 -1
- package/dist/assets/useServerActionGuard-Db7PPgaI.js +1 -0
- package/dist/assets/useThroughputSeries-_J7gWbcI.js +1 -0
- package/dist/index.html +21 -18
- package/package.json +18 -8
- package/scripts/embedded.gen.ts +152 -150
- package/scripts/serveHandler.ts +22 -1
- package/scripts/servePolicy.ts +12 -3
- package/dist/assets/AddJob-CWkkVHmf.js +0 -3
- package/dist/assets/Benchmark-Dir2A1CQ.js +0 -1
- package/dist/assets/CronManager-CbM0x_s0.js +0 -1
- package/dist/assets/Diagnostics-Cw9iJ84f.js +0 -1
- package/dist/assets/Flows-BAzbFP_W.js +0 -2
- package/dist/assets/JobInspector-qvW_APO6.js +0 -2
- package/dist/assets/QueueActions-jiTzndPH.js +0 -1
- package/dist/assets/QueueDetail-URK2pjtH.js +0 -1
- package/dist/assets/QueueDetailPro-C9M5CV4K.js +0 -1
- package/dist/assets/S3BackupPro--WSZWTQn.js +0 -3
- package/dist/assets/ServerControl-QVd6m_iR.js +0 -3
- package/dist/assets/WorkersPro-BYY0P1Pt.js +0 -1
- package/dist/assets/Workflows-ByJw4FFH.js +0 -1
- package/dist/assets/bq-DYmbVkgR.js +0 -1
- package/dist/assets/flowMutationSafety-DObp9nJo.js +0 -1
- package/dist/assets/index-D64v2vNO.js +0 -8
- package/dist/assets/index-DOze4TKg.css +0 -1
- package/dist/assets/install-jXi1SOYY.js +0 -3
- package/dist/assets/useServerActionGuard-OTv6_fU0.js +0 -1
- package/dist/assets/useThroughputSeries-DWHQl7cM.js +0 -1
package/README.md
CHANGED
|
@@ -26,9 +26,9 @@ The full dashboard running on sample data, no server needed.
|
|
|
26
26
|
|
|
27
27
|

|
|
28
28
|

|
|
29
|
-

|
|
30
30
|

|
|
31
|
-

|
|
32
32
|
|
|
33
33
|
<br />
|
|
34
34
|
|
|
@@ -90,13 +90,13 @@ The full dashboard running on sample data, no server needed.
|
|
|
90
90
|
bunqueue exposes a rich HTTP API, but operating it by hand (curl, ad-hoc scripts) is slow and
|
|
91
91
|
error-prone. This dashboard is a production-oriented operator console: it exposes the verified,
|
|
92
92
|
safe subset of the API, fails closed where
|
|
93
|
-
[
|
|
93
|
+
[Bunqueue 2.9.x](https://github.com/egeominotti/bunqueue/releases/tag/v2.9.2) lacks atomic
|
|
94
94
|
flow-safety guarantees, and also manages the
|
|
95
95
|
server *process* through a separate guarded agent.
|
|
96
96
|
|
|
97
97
|
It uses Bunqueue's public HTTP API (`:6790`) for ordinary remote observability and a small local
|
|
98
98
|
**control agent** for process, FlowProducer, Workflow Engine, database, and backup operations. The
|
|
99
|
-
agent is pinned to the server it manages and uses the exact
|
|
99
|
+
agent is pinned to the server it manages and uses the exact installable Bunqueue 2.9.0 npm client
|
|
100
100
|
contracts; it never patches Bunqueue internals.
|
|
101
101
|
|
|
102
102
|
## Features
|
|
@@ -111,22 +111,24 @@ contracts; it never patches Bunqueue internals.
|
|
|
111
111
|
| **Cron** | Control ▸ Cron Manager | Submit acknowledged cron/interval upserts and delete schedules |
|
|
112
112
|
| **Workflow Engine** | Workflow ▸ Overview / Executions / Waiting / Compensation / Archive | Start, recover, signal, inspect, compensate, archive and clean durable executions |
|
|
113
113
|
| **Job Flows** | Workflow ▸ Job Flows | Explore DAGs, run all FlowProducer create modes and operate safe Flow Job methods |
|
|
114
|
-
| **DLQ** | Control ▸ DLQ | Inspect
|
|
114
|
+
| **DLQ** | Control ▸ DLQ | Inspect/export failures; removal, retry and purge stay unavailable without atomic generation/topology preconditions |
|
|
115
115
|
| **Webhooks** | Control ▸ Webhooks | Create / enable / delete job-event webhooks |
|
|
116
116
|
| **Ops** | Control ▸ Diagnostics | Health, ping, storage, memory, connections, totals |
|
|
117
117
|
| **S3 backup** | Management ▸ S3 Backup | Configure, inspect, list, create and guarded-restore official snapshots |
|
|
118
118
|
| **Browse** | Queues / Jobs / DLQ / Cron / Metrics / Workers / Logs | Read-only browsing with basic actions |
|
|
119
119
|
|
|
120
|
-
> Job actions are gated by the v2.
|
|
120
|
+
> Job actions are gated by the v2.9.0 flow contract. Every DLQ retry and completed-job requeue is
|
|
121
121
|
> unavailable: the DLQ GET + POST sequence has no atomic generation/state/topology precondition and
|
|
122
122
|
> can target a job recreated under the same ID, while `retryCompleted` does not rebuild dependency
|
|
123
123
|
> registration or flow order. Cancel, Discard, Drain, Clean, Obliterate and DLQ Purge also fail
|
|
124
|
-
> closed.
|
|
124
|
+
> closed. Bunqueue 2.9's `Queue.removeDlqJob()` also accepts only queue + job ID, so custom-ID reuse
|
|
125
|
+
> prevents the dashboard from proving that a later deletion still targets the observed generation.
|
|
126
|
+
> DLQ `maxAge`/`maxEntries` are read-only, auto-retry can only be disabled, and Copilot's only
|
|
125
127
|
> mutations are Promote, Pause and Resume.
|
|
126
128
|
|
|
127
129
|
## Quick start
|
|
128
130
|
|
|
129
|
-
**Prerequisites:** [Bun](https://bun.sh) ≥ 1.
|
|
131
|
+
**Prerequisites:** [Bun](https://bun.sh) ≥ 1.4.0 and a reachable bunqueue server (or let the control
|
|
130
132
|
agent start one for you from the **Server** page).
|
|
131
133
|
|
|
132
134
|
### Run from npm (no clone)
|
|
@@ -246,8 +248,10 @@ target-pinned Flow, Workflow, Queue and Backup agent operations.
|
|
|
246
248
|
| `bun run agent` | Control agent only |
|
|
247
249
|
| `bun run build` | Typecheck (`tsc --noEmit`) + production build → `dist/` |
|
|
248
250
|
| `bun run preview` | Preview the production build |
|
|
249
|
-
| `bun run
|
|
250
|
-
| `bun run check
|
|
251
|
+
| `bun run lint` | Lint with Oxlint, including type-aware rules and the implicit-`any` parity guard |
|
|
252
|
+
| `bun run format:check` | Check formatting with Oxfmt |
|
|
253
|
+
| `bun run check` | Oxlint + Oxfmt validation (the CI gate) |
|
|
254
|
+
| `bun run check:fix` | Apply safe Oxlint fixes, then format with Oxfmt |
|
|
251
255
|
| `bun test` | Unit + agent-lifecycle tests |
|
|
252
256
|
|
|
253
257
|
## Docker
|
|
@@ -295,9 +299,15 @@ The canonical gate must be green before a change is considered done; CI runs the
|
|
|
295
299
|
bun run quality # architecture, lint/build, size, docs, coverage, real E2E, packed-bin smoke, audit
|
|
296
300
|
```
|
|
297
301
|
|
|
298
|
-
The E2E stage starts disposable Bunqueue 2.
|
|
302
|
+
The E2E stage starts disposable Bunqueue 2.9.0 processes and exercises the complete FlowProducer,
|
|
299
303
|
Workflow Engine, and Queue SDK operator bridges. Run it alone with `bun run test:e2e`.
|
|
300
304
|
|
|
305
|
+
The blocking browser job builds the production bundle under `/e2e/dashboard`, starts an
|
|
306
|
+
authenticated disposable Bunqueue server, and drives the UI with Playwright on Chromium, Firefox,
|
|
307
|
+
and WebKit. It covers the token gate, every sidebar route, SSE recovery across a real server
|
|
308
|
+
restart, confirmed Cron creation/deletion, and automated WCAG A/AA checks. Install the browser
|
|
309
|
+
binaries once with `bun run test:e2e:browser:install`, then run `bun run test:e2e:browser`.
|
|
310
|
+
|
|
301
311
|
CI enforces this on every push and pull request. See [Contributing](#contributing).
|
|
302
312
|
|
|
303
313
|
## Project structure
|
package/agent/backup/routes.ts
CHANGED
|
@@ -10,36 +10,82 @@ export interface BackupRouteResponse {
|
|
|
10
10
|
body: Record<string, unknown>;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export
|
|
13
|
+
export type PreparedBackupRequest =
|
|
14
|
+
| { operation: 'status' | 'list' | 'now'; query: URLSearchParams }
|
|
15
|
+
| { operation: 'configure'; query: URLSearchParams; environment: unknown }
|
|
16
|
+
| { operation: 'restore'; query: URLSearchParams; key: string; database: unknown };
|
|
17
|
+
|
|
18
|
+
/** Read and validate the bounded HTTP payload before acquiring a lifecycle lease. */
|
|
19
|
+
export async function prepareBackupRequest(
|
|
14
20
|
request: Request,
|
|
15
21
|
pathname: string,
|
|
16
22
|
method: string,
|
|
17
|
-
config
|
|
18
|
-
serverRunning: boolean,
|
|
19
|
-
database: DbStats,
|
|
20
|
-
runner: BackupRunnerPort,
|
|
21
|
-
configure?: (extraEnv: Record<string, string>) => void,
|
|
23
|
+
config?: ServerConfig,
|
|
22
24
|
targetPolicy?: ManagedTargetPolicy
|
|
23
|
-
): Promise<
|
|
25
|
+
): Promise<PreparedBackupRequest | null> {
|
|
24
26
|
const operation = routeOperation(pathname, method);
|
|
25
27
|
if (!operation) return null;
|
|
26
28
|
const query = exactTargetQuery(request.url);
|
|
27
|
-
assertManagedTarget(query, config, targetPolicy);
|
|
29
|
+
if (config) assertManagedTarget(query, config, targetPolicy);
|
|
28
30
|
if (operation === 'configure') {
|
|
29
|
-
if (!configure) throw new Error('Backup configuration is unavailable');
|
|
30
31
|
const body = await boundedRecord(request, ['environment']);
|
|
31
|
-
|
|
32
|
-
configure(next);
|
|
33
|
-
return success({ configured: true, enabled: next.S3_BACKUP_ENABLED === 'true' });
|
|
32
|
+
return { operation, query, environment: body.environment };
|
|
34
33
|
}
|
|
35
34
|
if (operation === 'restore') {
|
|
36
|
-
if (serverRunning) throw new Error('Stop the managed Bunqueue server before restoring a backup');
|
|
37
35
|
const body = await boundedRecord(request, ['key', 'database']);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
return { operation, query, key: backupKey(body.key), database: body.database };
|
|
37
|
+
}
|
|
38
|
+
return { operation, query };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Execute a prepared operation against one lifecycle-pinned config/database snapshot. */
|
|
42
|
+
export async function executePreparedBackupRequest(
|
|
43
|
+
prepared: PreparedBackupRequest,
|
|
44
|
+
config: ServerConfig,
|
|
45
|
+
serverRunning: boolean,
|
|
46
|
+
database: DbStats,
|
|
47
|
+
runner: BackupRunnerPort,
|
|
48
|
+
configure?: (extraEnv: Record<string, string>) => void | Promise<void>,
|
|
49
|
+
targetPolicy?: ManagedTargetPolicy
|
|
50
|
+
): Promise<BackupRouteResponse> {
|
|
51
|
+
assertManagedTarget(prepared.query, config, targetPolicy);
|
|
52
|
+
if (prepared.operation === 'configure') {
|
|
53
|
+
if (!configure) throw new Error('Backup configuration is unavailable');
|
|
54
|
+
const next = mergeBackupEnvironment(config.extraEnv, prepared.environment);
|
|
55
|
+
await configure(next);
|
|
56
|
+
return success({ configured: true, enabled: next.S3_BACKUP_ENABLED === 'true' });
|
|
57
|
+
}
|
|
58
|
+
if (prepared.operation === 'restore') {
|
|
59
|
+
if (serverRunning) throw new Error('Stop the managed Bunqueue server before restoring a backup');
|
|
60
|
+
assertDatabaseSnapshot(prepared.database, database);
|
|
61
|
+
return success(await runner.execute(config, prepared.operation, prepared.key));
|
|
41
62
|
}
|
|
42
|
-
return success(await runner.execute(config, operation));
|
|
63
|
+
return success(await runner.execute(config, prepared.operation));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function routeBackupRequest(
|
|
67
|
+
request: Request,
|
|
68
|
+
pathname: string,
|
|
69
|
+
method: string,
|
|
70
|
+
config: ServerConfig,
|
|
71
|
+
serverRunning: boolean,
|
|
72
|
+
database: DbStats,
|
|
73
|
+
runner: BackupRunnerPort,
|
|
74
|
+
configure?: (extraEnv: Record<string, string>) => void,
|
|
75
|
+
targetPolicy?: ManagedTargetPolicy
|
|
76
|
+
): Promise<BackupRouteResponse | null> {
|
|
77
|
+
const prepared = await prepareBackupRequest(request, pathname, method, config, targetPolicy);
|
|
78
|
+
return prepared
|
|
79
|
+
? executePreparedBackupRequest(
|
|
80
|
+
prepared,
|
|
81
|
+
config,
|
|
82
|
+
serverRunning,
|
|
83
|
+
database,
|
|
84
|
+
runner,
|
|
85
|
+
configure,
|
|
86
|
+
targetPolicy
|
|
87
|
+
)
|
|
88
|
+
: null;
|
|
43
89
|
}
|
|
44
90
|
|
|
45
91
|
function routeOperation(pathname: string, method: string) {
|
package/agent/db/query.ts
CHANGED
|
@@ -47,7 +47,8 @@ export function dbQuery(path: string, sql: string): DbQueryResult {
|
|
|
47
47
|
throw translateDbError(error);
|
|
48
48
|
}
|
|
49
49
|
let columns = statement.columnNames;
|
|
50
|
-
|
|
50
|
+
const hasDuplicateNames = new Set(columns).size !== columns.length;
|
|
51
|
+
if (statement.columnTypes.length > columns.length || hasDuplicateNames) {
|
|
51
52
|
const disambiguated = disambiguateColumns(db, trimmed);
|
|
52
53
|
if (disambiguated) {
|
|
53
54
|
statement = disambiguated.stmt;
|
package/agent/manager/config.ts
CHANGED
|
@@ -10,7 +10,7 @@ const CONFIG_KEYS = new Set<keyof ServerConfig>([
|
|
|
10
10
|
|
|
11
11
|
export function defaultConfig(): ServerConfig {
|
|
12
12
|
return {
|
|
13
|
-
command: process.env.BUNQUEUE_START_CMD || 'bunx bunqueue@2.
|
|
13
|
+
command: process.env.BUNQUEUE_START_CMD || 'bunx bunqueue@2.9.0 start',
|
|
14
14
|
httpPort: Number(process.env.HTTP_PORT) || 6790,
|
|
15
15
|
tcpPort: Number(process.env.TCP_PORT) || 6789,
|
|
16
16
|
dataPath: process.env.BUNQUEUE_DATA_PATH || './data/bunq.db',
|
|
@@ -76,6 +76,7 @@ export function validateServerConfig(value: unknown): ServerConfig {
|
|
|
76
76
|
for (const key of CONFIG_KEYS) {
|
|
77
77
|
if (!Object.hasOwn(value, key)) throw new Error(`Missing config key: ${key}`);
|
|
78
78
|
}
|
|
79
|
+
if (config.httpPort === config.tcpPort) throw new Error('httpPort and tcpPort must differ');
|
|
79
80
|
return config as ServerConfig;
|
|
80
81
|
}
|
|
81
82
|
|
package/agent/manager/process.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { copyConfig, defaultConfig, validateConfigPatch, validateServerConfig } from './config';
|
|
2
2
|
import { ProcessLogs } from './logs';
|
|
3
3
|
import { databaseStats } from './storage';
|
|
4
|
+
import { managedStorageMode, removeSqlitePaths } from './storageMode';
|
|
4
5
|
import type {
|
|
5
6
|
DbStats,
|
|
6
7
|
LogLine,
|
|
@@ -29,6 +30,9 @@ export class ProcessManager {
|
|
|
29
30
|
private startedAt: number | null = null;
|
|
30
31
|
private exitCode: number | null = null;
|
|
31
32
|
private config: ServerConfig = defaultConfig();
|
|
33
|
+
// A per-agent random seed prevents stale compare-and-set tokens from a
|
|
34
|
+
// previous control-agent process matching again after a restart (ABA).
|
|
35
|
+
private configRevision = configRevisionSeed();
|
|
32
36
|
private runningConfig: ServerConfig | null = null;
|
|
33
37
|
private procToken = 0;
|
|
34
38
|
private stopping: Promise<StatusSnapshot> | null = null;
|
|
@@ -42,13 +46,26 @@ export class ProcessManager {
|
|
|
42
46
|
return copyConfig(this.config);
|
|
43
47
|
}
|
|
44
48
|
|
|
45
|
-
|
|
49
|
+
getConfigRevision(): number {
|
|
50
|
+
return this.configRevision;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
setConfig(patch: Partial<ServerConfig>, expectedRevision?: number): ServerConfig {
|
|
54
|
+
if (expectedRevision !== undefined && expectedRevision !== this.configRevision) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Configuration changed since revision ${expectedRevision}; current revision is ${this.configRevision}`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
46
59
|
const valid = validateConfigPatch(patch);
|
|
47
|
-
|
|
60
|
+
const next = validateServerConfig({
|
|
48
61
|
...this.config,
|
|
49
62
|
...valid,
|
|
50
63
|
extraEnv: valid.extraEnv ?? this.config.extraEnv,
|
|
51
|
-
};
|
|
64
|
+
});
|
|
65
|
+
if (!sameConfig(this.config, next)) {
|
|
66
|
+
this.config = next;
|
|
67
|
+
this.configRevision = nextConfigRevision(this.configRevision);
|
|
68
|
+
}
|
|
52
69
|
return copyConfig(this.config);
|
|
53
70
|
}
|
|
54
71
|
|
|
@@ -56,6 +73,7 @@ export class ProcessManager {
|
|
|
56
73
|
return {
|
|
57
74
|
status: this.status,
|
|
58
75
|
generation: this.procToken,
|
|
76
|
+
configRevision: this.configRevision,
|
|
59
77
|
pid: this.proc?.pid ?? null,
|
|
60
78
|
startedAt: this.startedAt,
|
|
61
79
|
exitCode: this.exitCode,
|
|
@@ -64,8 +82,10 @@ export class ProcessManager {
|
|
|
64
82
|
};
|
|
65
83
|
}
|
|
66
84
|
|
|
67
|
-
dbStats(): Promise<DbStats> {
|
|
68
|
-
|
|
85
|
+
dbStats(dataPath?: string): Promise<DbStats> {
|
|
86
|
+
const effectivePath =
|
|
87
|
+
dataPath ?? (this.runningConfig ?? this.config).dataPath;
|
|
88
|
+
return databaseStats(effectivePath);
|
|
69
89
|
}
|
|
70
90
|
|
|
71
91
|
getLogs(): LogLine[] {
|
|
@@ -90,6 +110,7 @@ export class ProcessManager {
|
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
const launchConfig = validateServerConfig(this.config);
|
|
113
|
+
const storageMode = managedStorageMode(launchConfig);
|
|
93
114
|
const [command, ...args] = launchConfig.command.trim().split(/\s+/);
|
|
94
115
|
this.status = 'starting';
|
|
95
116
|
this.exitCode = null;
|
|
@@ -101,6 +122,7 @@ export class ProcessManager {
|
|
|
101
122
|
TCP_PORT: String(launchConfig.tcpPort),
|
|
102
123
|
BUNQUEUE_DATA_PATH: launchConfig.dataPath,
|
|
103
124
|
};
|
|
125
|
+
if (storageMode !== 'sqlite') removeSqlitePaths(env);
|
|
104
126
|
|
|
105
127
|
try {
|
|
106
128
|
this.proc = Bun.spawn([command, ...args], {
|
|
@@ -231,3 +253,27 @@ export class ProcessManager {
|
|
|
231
253
|
}
|
|
232
254
|
}
|
|
233
255
|
}
|
|
256
|
+
|
|
257
|
+
function sameConfig(left: ServerConfig, right: ServerConfig): boolean {
|
|
258
|
+
const leftEnv = Object.entries(left.extraEnv).sort(([a], [b]) => a.localeCompare(b));
|
|
259
|
+
const rightEnv = Object.entries(right.extraEnv).sort(([a], [b]) => a.localeCompare(b));
|
|
260
|
+
return (
|
|
261
|
+
left.command === right.command &&
|
|
262
|
+
left.httpPort === right.httpPort &&
|
|
263
|
+
left.tcpPort === right.tcpPort &&
|
|
264
|
+
left.dataPath === right.dataPath &&
|
|
265
|
+
JSON.stringify(leftEnv) === JSON.stringify(rightEnv)
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function configRevisionSeed(): number {
|
|
270
|
+
const words = crypto.getRandomValues(new Uint32Array(2));
|
|
271
|
+
return ((words[0] ?? 0) & 0x1f_ffff) * 0x1_0000_0000 + (words[1] ?? 0);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function nextConfigRevision(current: number): number {
|
|
275
|
+
if (current < Number.MAX_SAFE_INTEGER) return current + 1;
|
|
276
|
+
let next = configRevisionSeed();
|
|
277
|
+
while (next === current) next = configRevisionSeed();
|
|
278
|
+
return next;
|
|
279
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ServerConfig } from './types';
|
|
2
|
+
|
|
3
|
+
export type ManagedStorageMode = 'memory' | 'sqlite' | 'postgres';
|
|
4
|
+
|
|
5
|
+
/** Mirrors Bunqueue 2.9's explicit-driver, PostgreSQL-URL, then data-path precedence. */
|
|
6
|
+
export function managedStorageMode(config: ServerConfig): ManagedStorageMode {
|
|
7
|
+
const explicit = config.extraEnv.BUNQUEUE_STORAGE_DRIVER ?? process.env.BUNQUEUE_STORAGE_DRIVER;
|
|
8
|
+
if (explicit === 'postgres' || explicit === 'memory' || explicit === 'sqlite') return explicit;
|
|
9
|
+
if (explicit) throw new Error(`Unsupported storage driver: ${explicit}`);
|
|
10
|
+
const postgresUrl = config.extraEnv.BUNQUEUE_POSTGRES_URL ?? process.env.BUNQUEUE_POSTGRES_URL;
|
|
11
|
+
if (postgresUrl) return 'postgres';
|
|
12
|
+
return config.dataPath ? 'sqlite' : 'memory';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Non-SQLite drivers cannot be combined with Bunqueue's legacy SQLite path aliases. */
|
|
16
|
+
export function removeSqlitePaths(env: Record<string, string>): void {
|
|
17
|
+
delete env.BUNQUEUE_DATA_PATH;
|
|
18
|
+
delete env.BQ_DATA_PATH;
|
|
19
|
+
delete env.DATA_PATH;
|
|
20
|
+
delete env.SQLITE_PATH;
|
|
21
|
+
}
|
package/agent/manager/types.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface StatusSnapshot {
|
|
|
30
30
|
status: Status;
|
|
31
31
|
/** Monotonic identity of the managed process generation. */
|
|
32
32
|
generation: number;
|
|
33
|
+
/** Monotonic desired-config revision used for optimistic updates. */
|
|
34
|
+
configRevision?: number;
|
|
33
35
|
pid: number | null;
|
|
34
36
|
startedAt: number | null;
|
|
35
37
|
exitCode: number | null;
|
package/agent/manager.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Public compatibility entrypoint for managed bunqueue process supervision. */
|
|
2
2
|
export { validateConfigPatch } from './manager/config';
|
|
3
3
|
export { ProcessManager } from './manager/process';
|
|
4
|
+
export { managedStorageMode } from './manager/storageMode';
|
|
4
5
|
export type {
|
|
5
6
|
DbStats,
|
|
6
7
|
LogLine,
|
package/agent/queue/routes.ts
CHANGED
|
@@ -19,8 +19,7 @@ import {
|
|
|
19
19
|
} from './validation';
|
|
20
20
|
|
|
21
21
|
const READ_ROUTE = /^\/queue-operations\/([^/]+)\/(limits|deduplication|metrics)$/;
|
|
22
|
-
const MUTATION_ROUTE =
|
|
23
|
-
/^\/queue-operations\/([^/]+)\/(deduplication\/remove|events\/trim)$/;
|
|
22
|
+
const MUTATION_ROUTE = /^\/queue-operations\/([^/]+)\/(deduplication\/remove|events\/trim)$/;
|
|
24
23
|
|
|
25
24
|
export async function routeQueueOperationsRequest(
|
|
26
25
|
request: Request,
|
package/agent/queue/runtime.ts
CHANGED
|
@@ -13,6 +13,7 @@ export type QueueOperationsClient = Pick<
|
|
|
13
13
|
| 'isMaxed'
|
|
14
14
|
| 'getDeduplicationJobId'
|
|
15
15
|
| 'removeDeduplicationKey'
|
|
16
|
+
| 'removeDlqJob'
|
|
16
17
|
| 'getMetrics'
|
|
17
18
|
| 'trimEvents'
|
|
18
19
|
| 'close'
|
|
@@ -59,6 +60,10 @@ export class QueueOperationsRuntime implements QueueOperationsPort {
|
|
|
59
60
|
return this.withQueue(config, queue, (client) => client.removeDeduplicationKey(deduplicationId));
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
removeDlqJob(config: ServerConfig, queue: string, jobId: string): Promise<boolean> {
|
|
64
|
+
return this.withQueue(config, queue, (client) => client.removeDlqJob(jobId));
|
|
65
|
+
}
|
|
66
|
+
|
|
62
67
|
metrics(
|
|
63
68
|
config: ServerConfig,
|
|
64
69
|
queue: string,
|
package/agent/queue/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ProcessManager, validateConfigPatch } from '../manager';
|
|
1
|
+
import { managedStorageMode, type ProcessManager, validateConfigPatch } from '../manager';
|
|
2
2
|
import { safeErrorMessage } from '../errorMessage';
|
|
3
3
|
import {
|
|
4
4
|
WorkflowRuntimeUnavailableError,
|
|
@@ -15,12 +15,18 @@ import type { RouteResponse } from './types';
|
|
|
15
15
|
|
|
16
16
|
const MAX_CONFIG_BODY_BYTES = 64 * 1024;
|
|
17
17
|
|
|
18
|
-
async function statusWithHealth(
|
|
18
|
+
async function statusWithHealth(
|
|
19
|
+
manager: ProcessManager,
|
|
20
|
+
controlTarget: ServerControlTarget,
|
|
21
|
+
retryOnProcessChange = true
|
|
22
|
+
) {
|
|
19
23
|
const snapshot = manager.getStatus();
|
|
24
|
+
const configRevision = snapshot.configRevision ?? manager.getConfigRevision?.() ?? 0;
|
|
20
25
|
if (controlTarget.mode === 'external') {
|
|
21
26
|
const health = await probeExternalHealth(controlTarget);
|
|
22
27
|
return {
|
|
23
28
|
...snapshot,
|
|
29
|
+
configRevision,
|
|
24
30
|
managementMode: 'external' as const,
|
|
25
31
|
healthy: health.healthy,
|
|
26
32
|
version: health.version,
|
|
@@ -51,8 +57,41 @@ async function statusWithHealth(manager: ProcessManager, controlTarget: ServerCo
|
|
|
51
57
|
// The managed process is not healthy yet.
|
|
52
58
|
}
|
|
53
59
|
}
|
|
54
|
-
const
|
|
55
|
-
|
|
60
|
+
const effectiveConfig = snapshot.runningConfig ?? snapshot.config;
|
|
61
|
+
const database = await statusDatabase(manager, effectiveConfig);
|
|
62
|
+
const current = manager.getStatus();
|
|
63
|
+
if (!sameProcessSnapshot(snapshot, current)) {
|
|
64
|
+
if (retryOnProcessChange) return statusWithHealth(manager, controlTarget, false);
|
|
65
|
+
const currentConfig = current.runningConfig ?? current.config;
|
|
66
|
+
return {
|
|
67
|
+
...current,
|
|
68
|
+
configRevision: current.configRevision ?? manager.getConfigRevision?.() ?? 0,
|
|
69
|
+
managementMode: 'managed' as const,
|
|
70
|
+
healthy: false,
|
|
71
|
+
version: undefined,
|
|
72
|
+
db: await statusDatabase(manager, currentConfig),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
...snapshot,
|
|
77
|
+
configRevision,
|
|
78
|
+
managementMode: 'managed' as const,
|
|
79
|
+
healthy,
|
|
80
|
+
version,
|
|
81
|
+
db: database,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function statusDatabase(
|
|
86
|
+
manager: ProcessManager,
|
|
87
|
+
config: ReturnType<ProcessManager['getConfig']>
|
|
88
|
+
) {
|
|
89
|
+
try {
|
|
90
|
+
return managedStorageMode(config) === 'sqlite' ? await manager.dbStats(config.dataPath) : null;
|
|
91
|
+
} catch {
|
|
92
|
+
// Status must remain reachable so an invalid desired storage driver can be corrected.
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
56
95
|
}
|
|
57
96
|
|
|
58
97
|
export async function routeControlRequest(
|
|
@@ -65,7 +104,10 @@ export async function routeControlRequest(
|
|
|
65
104
|
controlTarget: ServerControlTarget = MANAGED_CONTROL_TARGET
|
|
66
105
|
): Promise<RouteResponse | null> {
|
|
67
106
|
if (pathname === '/control/status') {
|
|
68
|
-
return
|
|
107
|
+
return coordinatedLease(lifecycle, async () => ({
|
|
108
|
+
status: 200,
|
|
109
|
+
body: await statusWithHealth(manager, controlTarget),
|
|
110
|
+
}));
|
|
69
111
|
}
|
|
70
112
|
if (pathname === '/control/logs') {
|
|
71
113
|
return { status: 200, body: { lines: manager.getLogs() } };
|
|
@@ -96,22 +138,62 @@ export async function routeControlRequest(
|
|
|
96
138
|
});
|
|
97
139
|
}
|
|
98
140
|
if (pathname === '/control/config' && method === 'GET') {
|
|
99
|
-
return { status: 200, body: manager
|
|
141
|
+
return { status: 200, body: configSnapshot(manager) };
|
|
100
142
|
}
|
|
101
143
|
if (pathname === '/control/config' && method === 'PUT') {
|
|
102
144
|
assertManagedControl(controlTarget);
|
|
103
|
-
const
|
|
145
|
+
const update = configUpdate(
|
|
104
146
|
await readLimitedJsonBody(request, {
|
|
105
147
|
scope: 'Agent configuration',
|
|
106
148
|
maxBytes: MAX_CONFIG_BODY_BYTES,
|
|
107
149
|
limitLabel: '64 KiB',
|
|
108
150
|
})
|
|
109
151
|
);
|
|
110
|
-
return
|
|
152
|
+
return coordinated(lifecycle, async () => {
|
|
153
|
+
manager.setConfig(update.patch, update.expectedRevision);
|
|
154
|
+
return { status: 200, body: configSnapshot(manager) };
|
|
155
|
+
});
|
|
111
156
|
}
|
|
112
157
|
return null;
|
|
113
158
|
}
|
|
114
159
|
|
|
160
|
+
function configSnapshot(manager: ProcessManager) {
|
|
161
|
+
return { ...manager.getConfig(), configRevision: manager.getConfigRevision?.() ?? 0 };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function configUpdate(value: unknown): {
|
|
165
|
+
patch: ReturnType<typeof validateConfigPatch>;
|
|
166
|
+
expectedRevision?: number;
|
|
167
|
+
} {
|
|
168
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
169
|
+
return { patch: validateConfigPatch(value) };
|
|
170
|
+
}
|
|
171
|
+
const input = { ...(value as Record<string, unknown>) };
|
|
172
|
+
const hasExpectedRevision = Object.hasOwn(input, 'expectedRevision');
|
|
173
|
+
const hasConfigRevision = Object.hasOwn(input, 'configRevision');
|
|
174
|
+
const expected = hasExpectedRevision
|
|
175
|
+
? input.expectedRevision
|
|
176
|
+
: hasConfigRevision
|
|
177
|
+
? input.configRevision
|
|
178
|
+
: undefined;
|
|
179
|
+
if (
|
|
180
|
+
hasExpectedRevision &&
|
|
181
|
+
hasConfigRevision &&
|
|
182
|
+
input.expectedRevision !== input.configRevision
|
|
183
|
+
) {
|
|
184
|
+
throw new Error('expectedRevision and configRevision must match');
|
|
185
|
+
}
|
|
186
|
+
delete input.expectedRevision;
|
|
187
|
+
delete input.configRevision;
|
|
188
|
+
if (expected !== undefined && (!Number.isSafeInteger(expected) || (expected as number) < 0)) {
|
|
189
|
+
throw new Error('expectedRevision must be a non-negative safe integer');
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
patch: validateConfigPatch(input),
|
|
193
|
+
...(expected === undefined ? {} : { expectedRevision: expected as number }),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
115
197
|
export function assertManagedControl(controlTarget: ServerControlTarget): void {
|
|
116
198
|
if (controlTarget.mode === 'external') {
|
|
117
199
|
throw new Error(
|
|
@@ -173,6 +255,25 @@ function coordinated<T>(
|
|
|
173
255
|
return lifecycle ? lifecycle.run(operation) : operation();
|
|
174
256
|
}
|
|
175
257
|
|
|
258
|
+
function coordinatedLease<T>(
|
|
259
|
+
lifecycle: AgentLifecyclePort | undefined,
|
|
260
|
+
operation: () => Promise<T>
|
|
261
|
+
): Promise<T> {
|
|
262
|
+
return lifecycle ? lifecycle.lease(operation) : operation();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function sameProcessSnapshot(
|
|
266
|
+
initial: ReturnType<ProcessManager['getStatus']>,
|
|
267
|
+
current: ReturnType<ProcessManager['getStatus']>
|
|
268
|
+
): boolean {
|
|
269
|
+
return (
|
|
270
|
+
initial.status === current.status &&
|
|
271
|
+
initial.generation === current.generation &&
|
|
272
|
+
initial.pid === current.pid &&
|
|
273
|
+
initial.configRevision === current.configRevision
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
176
277
|
type SettledOperation = { ok: true } | { ok: false; error: unknown };
|
|
177
278
|
|
|
178
279
|
async function settle(operation: () => Promise<unknown>): Promise<SettledOperation> {
|