bunqueue-dashboard 0.0.38 → 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.
Files changed (107) hide show
  1. package/README.md +16 -8
  2. package/agent/backup/resultValidation.ts +1 -1
  3. package/agent/backup/routes.ts +63 -17
  4. package/agent/db/query.ts +2 -1
  5. package/agent/manager/config.ts +2 -1
  6. package/agent/manager/process.ts +51 -5
  7. package/agent/manager/storageMode.ts +21 -0
  8. package/agent/manager/types.ts +2 -0
  9. package/agent/manager.ts +1 -0
  10. package/agent/queue/routes.ts +1 -2
  11. package/agent/queue/runtime.ts +5 -0
  12. package/agent/queue/types.ts +1 -0
  13. package/agent/server/controlRoutes.ts +109 -8
  14. package/agent/server/databaseRoutes.ts +47 -14
  15. package/agent/server/errors.ts +1 -0
  16. package/agent/server/lifecycle.ts +25 -8
  17. package/agent/server/managedRuntime.ts +27 -0
  18. package/agent/server/router.ts +90 -40
  19. package/agent/server/workflowReadRoutes.ts +28 -11
  20. package/agent/workflows.ts +1 -1
  21. package/dist/.vite/manifest.json +490 -476
  22. package/dist/assets/AddJob-d5KBvmN8.js +3 -0
  23. package/dist/assets/{Alerts-C2bAi-Kj.js → Alerts-5I44QTnQ.js} +1 -1
  24. package/dist/assets/{AreaChart-w0ruozXH.js → AreaChart-DEAalJ6P.js} +1 -1
  25. package/dist/assets/Benchmark-S1afsQRd.js +1 -0
  26. package/dist/assets/{BulkAddJobs-BBWqQpP7.js → BulkAddJobs-BO8LWUHE.js} +1 -1
  27. package/dist/assets/{Button-BPqpRZJD.js → Button-Ch1W94fJ.js} +1 -1
  28. package/dist/assets/{Card-C7NMhqUt.js → Card-CWxrC8OY.js} +1 -1
  29. package/dist/assets/{CopilotPanel-Bhh-uCpN.js → CopilotPanel-DDkRd35p.js} +2 -2
  30. package/dist/assets/{Cron-pJJ5KXFG.js → Cron-X3Y5uISO.js} +1 -1
  31. package/dist/assets/CronManager-ugmYEAwY.js +1 -0
  32. package/dist/assets/{Database-DxiXsUl4.js → Database-koy8haPX.js} +1 -1
  33. package/dist/assets/Diagnostics-D6oqvkOs.js +1 -0
  34. package/dist/assets/{Dlq-Bx4YXTp5.js → Dlq-D70Lzmsa.js} +1 -1
  35. package/dist/assets/{DlqControl-DJGhuyu3.js → DlqControl-nUT5Zzkl.js} +1 -1
  36. package/dist/assets/{DlqPro-BhXIKtrt.js → DlqPro-VMuBKgP-.js} +1 -1
  37. package/dist/assets/Flows-CAG0nDnt.js +2 -0
  38. package/dist/assets/JobInspector-CiS5HOSQ.js +2 -0
  39. package/dist/assets/{Jobs-Blavwd1H.js → Jobs-CkyRvzOT.js} +1 -1
  40. package/dist/assets/{JobsPro-D8dH9um7.js → JobsPro-B4-CSoq3.js} +1 -1
  41. package/dist/assets/{Logs-B3dsGSF8.js → Logs-DBTz2PTz.js} +1 -1
  42. package/dist/assets/{LogsPro-BCB7etG2.js → LogsPro-BCFeegvr.js} +1 -1
  43. package/dist/assets/{McpServer-Cz1-GwD5.js → McpServer-BKlTR1qr.js} +2 -2
  44. package/dist/assets/{Metrics-Bkuy6Ntb.js → Metrics-Du__9Lbn.js} +1 -1
  45. package/dist/assets/{MetricsPro-BbJmy0oO.js → MetricsPro-CqnaO6YX.js} +1 -1
  46. package/dist/assets/{Overview-Ba5odzYe.js → Overview-udSSt7ZB.js} +1 -1
  47. package/dist/assets/{PageHeader-BXlZSBcc.js → PageHeader-CvIyaFOO.js} +1 -1
  48. package/dist/assets/{Pagination-Q-vf4hsX.js → Pagination-x-Cwgrul.js} +1 -1
  49. package/dist/assets/QueueActions-D7cQrGvJ.js +1 -0
  50. package/dist/assets/{QueueControl-CWHsVfWU.js → QueueControl-BF638r_0.js} +1 -1
  51. package/dist/assets/QueueDetail-C-ms6bMe.js +1 -0
  52. package/dist/assets/QueueDetailPro-DXEdsZx_.js +1 -0
  53. package/dist/assets/{Queues-CGPgEkVm.js → Queues-BUMwTVAf.js} +1 -1
  54. package/dist/assets/{QueuesOverview-ChC5Dtqh.js → QueuesOverview-Du8XLgN0.js} +1 -1
  55. package/dist/assets/{S3Backup-5OKa8G13.js → S3Backup-CDICSzxM.js} +1 -1
  56. package/dist/assets/S3BackupPro-CBidbI-s.js +3 -0
  57. package/dist/assets/ServerControl-CPT0LpsC.js +3 -0
  58. package/dist/assets/{Settings-C1HtCK6h.js → Settings-CmxfSX_8.js} +1 -1
  59. package/dist/assets/{StatusBadge-B0I7hNBX.js → StatusBadge-BoOVTnKu.js} +1 -1
  60. package/dist/assets/{Usage-QyZkUhgs.js → Usage-B895tSxi.js} +1 -1
  61. package/dist/assets/{UsagePro-DMxrXxur.js → UsagePro-CASN77iV.js} +1 -1
  62. package/dist/assets/{Webhooks-D0JQQf0q.js → Webhooks-CXbZOFFw.js} +1 -1
  63. package/dist/assets/{Workers-Cn8Bkgy1.js → Workers-BceQlUhX.js} +1 -1
  64. package/dist/assets/WorkersPro-Dtml89jB.js +1 -0
  65. package/dist/assets/Workflows-B8wmQN2X.js +1 -0
  66. package/dist/assets/{api-V_sVpHDo.js → api-81m1TwB7.js} +1 -1
  67. package/dist/assets/bq-BOfwNIlJ.js +1 -0
  68. package/dist/assets/{connectionStore-CKFQrxDo.js → connectionStore-CFuNYZ_8.js} +1 -1
  69. package/dist/assets/{controlConnectionEpoch-JM4omZ6j.js → controlConnectionEpoch-DzbuWOhe.js} +1 -1
  70. package/dist/assets/{feedback-BnGE2oW2.js → feedback-QDN1AsYt.js} +1 -1
  71. package/dist/assets/flowMutationSafety-6cP-l4Pa.js +1 -0
  72. package/dist/assets/{form-tC20r4Ma.js → form-C4oby9pI.js} +1 -1
  73. package/dist/assets/{icons-CIvFfU2b.js → icons-BVzwsfbG.js} +1 -1
  74. package/dist/assets/index-B-QjN7pA.css +1 -0
  75. package/dist/assets/{index-Cue53Myw.js → index-TploGF7u.js} +3 -3
  76. package/dist/assets/install-BPp8nydm.js +3 -0
  77. package/dist/assets/{react-vendor-CyjgmGTq.js → react-vendor-CvwrP0tU.js} +2 -2
  78. package/dist/assets/{responses-CBLyy1Ob.js → responses-C9lP49qu.js} +1 -1
  79. package/dist/assets/{toastStore-B7n0zS0y.js → toastStore-B31o6Z5J.js} +1 -1
  80. package/dist/assets/{upstreamPaths-CDVsu6IM.js → upstreamPaths-ZeijhPPb.js} +1 -1
  81. package/dist/assets/useControlActionGuard-DT7uudrj.js +1 -0
  82. package/dist/assets/{usePolledData-g80Z_UB-.js → usePolledData-CNtNtd0a.js} +1 -1
  83. package/dist/assets/useServerActionGuard-Db7PPgaI.js +1 -0
  84. package/dist/assets/{useThroughputSeries-CrsZYKbT.js → useThroughputSeries-_J7gWbcI.js} +1 -1
  85. package/dist/index.html +21 -18
  86. package/package.json +9 -4
  87. package/scripts/embedded.gen.ts +152 -150
  88. package/scripts/serveHandler.ts +22 -1
  89. package/scripts/servePolicy.ts +12 -3
  90. package/dist/assets/AddJob-CC4bfHLH.js +0 -3
  91. package/dist/assets/Benchmark-ClJmYZoC.js +0 -1
  92. package/dist/assets/CronManager-lNZ4lHh1.js +0 -1
  93. package/dist/assets/Diagnostics-DLvCT7zI.js +0 -1
  94. package/dist/assets/Flows-Bb9j-YhZ.js +0 -2
  95. package/dist/assets/JobInspector-DfqBq4O5.js +0 -2
  96. package/dist/assets/QueueActions-B6M0ByGt.js +0 -1
  97. package/dist/assets/QueueDetail-BClRbWC5.js +0 -1
  98. package/dist/assets/QueueDetailPro-CIimG0pX.js +0 -1
  99. package/dist/assets/S3BackupPro-BkwTXdtw.js +0 -3
  100. package/dist/assets/ServerControl-DJHKMaf9.js +0 -3
  101. package/dist/assets/WorkersPro-BOTuX9EP.js +0 -1
  102. package/dist/assets/Workflows-DqTKWPmT.js +0 -1
  103. package/dist/assets/bq-Bmysbg0r.js +0 -1
  104. package/dist/assets/flowMutationSafety-DObp9nJo.js +0 -1
  105. package/dist/assets/index-DOze4TKg.css +0 -1
  106. package/dist/assets/install-CFiYX86d.js +0 -3
  107. package/dist/assets/useServerActionGuard-OTv6_fU0.js +0 -1
package/README.md CHANGED
@@ -26,7 +26,7 @@ The full dashboard running on sample data, no server needed.
26
26
 
27
27
  ![React 19](https://img.shields.io/badge/React-19-149ECA?logo=react&logoColor=white)
28
28
  ![Vite 8](https://img.shields.io/badge/Vite-8-646CFF?logo=vite&logoColor=white)
29
- ![Bun](https://img.shields.io/badge/Bun-1.3-000?logo=bun&logoColor=white)
29
+ ![Bun](https://img.shields.io/badge/Bun-1.4-000?logo=bun&logoColor=white)
30
30
  ![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white)
31
31
  ![Oxlint + Oxfmt](https://img.shields.io/badge/Oxc-Oxlint%20%2B%20Oxfmt-7C3AED)
32
32
 
@@ -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
- [v2.8.59](https://github.com/egeominotti/bunqueue/releases/tag/v2.8.59) lacks atomic
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 installed Bunqueue 2.8.59 client
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 entries, failure history and CSV exports; retry and purge stay unavailable |
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.8.59 flow contract. Every DLQ retry and completed-job requeue is
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. DLQ `maxAge`/`maxEntries` are read-only, auto-retry can only be disabled, and Copilot's only
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.3 and a reachable bunqueue server (or let the control
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)
@@ -297,9 +299,15 @@ The canonical gate must be green before a change is considered done; CI runs the
297
299
  bun run quality # architecture, lint/build, size, docs, coverage, real E2E, packed-bin smoke, audit
298
300
  ```
299
301
 
300
- The E2E stage starts disposable Bunqueue 2.8.59 processes and exercises the complete FlowProducer,
302
+ The E2E stage starts disposable Bunqueue 2.9.0 processes and exercises the complete FlowProducer,
301
303
  Workflow Engine, and Queue SDK operator bridges. Run it alone with `bun run test:e2e`.
302
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
+
303
311
  CI enforces this on every push and pull request. See [Contributing](#contributing).
304
312
 
305
313
  ## Project structure
@@ -81,5 +81,5 @@ function validIsoDate(value: string): boolean {
81
81
  }
82
82
 
83
83
  function invalid(label: string): never {
84
- throw new Error(`Bunqueue ${label} has an invalid 2.8.59 contract`);
84
+ throw new Error(`Bunqueue ${label} has an invalid 2.9.0 contract`);
85
85
  }
@@ -10,36 +10,82 @@ export interface BackupRouteResponse {
10
10
  body: Record<string, unknown>;
11
11
  }
12
12
 
13
- export async function routeBackupRequest(
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: ServerConfig,
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<BackupRouteResponse | null> {
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
- const next = mergeBackupEnvironment(config.extraEnv, body.environment);
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
- const key = backupKey(body.key);
39
- assertDatabaseSnapshot(body.database, database);
40
- return success(await runner.execute(config, operation, key));
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
- if (statement.columnTypes.length > columns.length) {
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;
@@ -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.8.59 start',
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
 
@@ -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
- setConfig(patch: Partial<ServerConfig>): ServerConfig {
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
- this.config = {
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
- return databaseStats(this.config.dataPath);
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
+ }
@@ -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,
@@ -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,
@@ -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,
@@ -20,6 +20,7 @@ export interface QueueOperationsPort {
20
20
  queue: string,
21
21
  deduplicationId: string
22
22
  ): Promise<number>;
23
+ removeDlqJob(config: ServerConfig, queue: string, jobId: string): Promise<boolean>;
23
24
  metrics(
24
25
  config: ServerConfig,
25
26
  queue: string,
@@ -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(manager: ProcessManager, controlTarget: ServerControlTarget) {
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 database = await manager.dbStats().catch(() => null);
55
- return { ...snapshot, managementMode: 'managed' as const, healthy, version, db: database };
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 { status: 200, body: await statusWithHealth(manager, controlTarget) };
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.getConfig() };
141
+ return { status: 200, body: configSnapshot(manager) };
100
142
  }
101
143
  if (pathname === '/control/config' && method === 'PUT') {
102
144
  assertManagedControl(controlTarget);
103
- const patch = validateConfigPatch(
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 { status: 200, body: manager.setConfig(patch) };
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> {