bunqueue-dashboard 0.0.46 → 1.0.1

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 (148) hide show
  1. package/README.md +6 -6
  2. package/agent/compiledRuntime.ts +27 -0
  3. package/agent/db/boundedStream.ts +18 -0
  4. package/agent/db/exportTimeout.ts +8 -6
  5. package/agent/db/processWorker.ts +121 -0
  6. package/agent/db/queryTimeout.ts +62 -50
  7. package/agent/db/readAdmission.ts +53 -0
  8. package/agent/db/readOperations.ts +27 -0
  9. package/agent/db/readProcessMain.ts +64 -0
  10. package/agent/db/types.ts +3 -0
  11. package/agent/db/workerFactory.ts +12 -10
  12. package/agent/dbReadWorker.ts +21 -0
  13. package/agent/index.ts +2 -1
  14. package/agent/managedTarget.ts +3 -1
  15. package/agent/manager/commandLine.ts +33 -0
  16. package/agent/manager/configStore.ts +65 -0
  17. package/agent/manager/errors.ts +4 -0
  18. package/agent/manager/process.ts +12 -3
  19. package/agent/manager.ts +1 -0
  20. package/agent/server/controlRoutes.ts +10 -0
  21. package/agent/server/controlStatus.ts +26 -5
  22. package/agent/server/databaseRoutes.ts +13 -17
  23. package/agent/server/errors.ts +6 -0
  24. package/agent/server/handler.ts +2 -1
  25. package/agent/server/router.ts +2 -1
  26. package/agent/server/workflowReadRoutes.ts +9 -11
  27. package/agent/shutdown.ts +3 -2
  28. package/agent/workflow/sqlite.ts +1 -1
  29. package/dist/.vite/manifest.json +537 -575
  30. package/dist/assets/AddJob-CiwWycYG.js +3 -0
  31. package/dist/assets/Alerts-DzKKoA9U.js +1 -0
  32. package/dist/assets/AreaChart-Db2UVnaK.js +1 -0
  33. package/dist/assets/Benchmark-CHYDL31K.js +1 -0
  34. package/dist/assets/BulkAddJobs-GRMVnf0j.js +5 -0
  35. package/dist/assets/Button-_aSJvRoW.js +1 -0
  36. package/dist/assets/Card-egIDLwVk.js +1 -0
  37. package/dist/assets/Cron-XL0PdUEc.js +1 -0
  38. package/dist/assets/CronManager-Cqtg9Zr8.js +1 -0
  39. package/dist/assets/{Database-DV2pcDLL.js → Database-BoxVpjts.js} +3 -3
  40. package/dist/assets/Diagnostics-B3QSQuFF.js +1 -0
  41. package/dist/assets/Dlq-DHOfgORa.js +1 -0
  42. package/dist/assets/DlqControl-BY1e55-x.js +1 -0
  43. package/dist/assets/DlqPro-CfwKDcpc.js +1 -0
  44. package/dist/assets/Fleet-Bkbq0dFB.js +1 -0
  45. package/dist/assets/Flows-CDCQrPit.js +2 -0
  46. package/dist/assets/JobInspector-Bw3Gs1Iy.js +2 -0
  47. package/dist/assets/Jobs-IrlneOy6.js +1 -0
  48. package/dist/assets/JobsPro-vlPd6Ows.js +1 -0
  49. package/dist/assets/Logs-DG7cK-tF.js +1 -0
  50. package/dist/assets/LogsPro-ClSqFKys.js +1 -0
  51. package/dist/assets/{McpServer-fVy27Mof.js → McpServer-DoNFY0Pj.js} +1 -1
  52. package/dist/assets/{Metrics-Ba46pdzT.js → Metrics-BkePZiCZ.js} +1 -1
  53. package/dist/assets/MetricsPro-ooSvpQ3H.js +1 -0
  54. package/dist/assets/{Overview-DV2qo5v3.js → Overview-CuWmPRra.js} +1 -1
  55. package/dist/assets/{PageHeader-CvIyaFOO.js → PageHeader-CQzNsw8r.js} +1 -1
  56. package/dist/assets/Pagination-CSkzhNW6.js +1 -0
  57. package/dist/assets/QueueActions-BBxRt-t0.js +1 -0
  58. package/dist/assets/QueueControl-C01l6bBJ.js +1 -0
  59. package/dist/assets/QueueDetail-gSf9Tb7G.js +1 -0
  60. package/dist/assets/QueueDetailPro-BKszqI0M.js +1 -0
  61. package/dist/assets/Queues-BAg2nSz0.js +1 -0
  62. package/dist/assets/QueuesOverview-BLSjiYsA.js +1 -0
  63. package/dist/assets/{S3Backup-DNABHR2Q.js → S3Backup-Ci4dJPP_.js} +1 -1
  64. package/dist/assets/S3BackupPro-DcjlrzC5.js +3 -0
  65. package/dist/assets/ServerControl-SiZBNo9P.js +3 -0
  66. package/dist/assets/Settings-DR_iqW0T.js +1 -0
  67. package/dist/assets/StatusBadge-DereLzOj.js +1 -0
  68. package/dist/assets/{Usage-1KxG1mJ9.js → Usage-ZWfB7PcR.js} +1 -1
  69. package/dist/assets/UsagePro-BjdeS2zb.js +1 -0
  70. package/dist/assets/Webhooks-BonYUgMo.js +1 -0
  71. package/dist/assets/Workers-C1h6hCPp.js +1 -0
  72. package/dist/assets/WorkersPro-B0f_Nfdz.js +1 -0
  73. package/dist/assets/Workflows-CqZZ96EK.js +1 -0
  74. package/dist/assets/{api-BgWE-705.js → api-D9aT0ND7.js} +1 -1
  75. package/dist/assets/bq-DR2BTISO.js +1 -0
  76. package/dist/assets/classicJobsData-Csj4iihY.js +1 -0
  77. package/dist/assets/connectionStore-CVjQyIZv.js +1 -0
  78. package/dist/assets/controlConnectionEpoch-ChAeMMLk.js +1 -0
  79. package/dist/assets/feedback-CtGN_kqB.js +1 -0
  80. package/dist/assets/form-DG3X6cLL.js +1 -0
  81. package/dist/assets/{icons-BVzwsfbG.js → icons-DH9SS1DA.js} +1 -1
  82. package/dist/assets/index-Bat1DN5U.js +8 -0
  83. package/dist/assets/react-vendor-CFL9l8LK.js +11 -0
  84. package/dist/assets/{cn-zFEt5SO8.js → rolldown-runtime-QTnfLwEv.js} +1 -1
  85. package/dist/assets/{toastStore-IZn6OPLZ.js → toastStore-XmMX7A5W.js} +1 -1
  86. package/dist/assets/useControlActionGuard-1i7aFA7E.js +1 -0
  87. package/dist/assets/usePolledData-CEyyLRYa.js +1 -0
  88. package/dist/assets/useServerActionGuard-CUx8TOAC.js +1 -0
  89. package/dist/assets/useThroughputSeries-CeFx-Mwq.js +1 -0
  90. package/dist/index.html +14 -14
  91. package/package.json +16 -10
  92. package/scripts/embedded.gen.ts +152 -162
  93. package/scripts/serve.ts +10 -15
  94. package/scripts/serveHandler.ts +28 -1
  95. package/dist/assets/AddJob-FNaPbs_V.js +0 -3
  96. package/dist/assets/Alerts-vzIYtFpW.js +0 -1
  97. package/dist/assets/AreaChart-DEAalJ6P.js +0 -1
  98. package/dist/assets/Benchmark-C16tT4pF.js +0 -1
  99. package/dist/assets/BulkAddJobs-IiL1uud0.js +0 -5
  100. package/dist/assets/Button-Ch1W94fJ.js +0 -1
  101. package/dist/assets/Card-CWxrC8OY.js +0 -1
  102. package/dist/assets/CopilotPanel-FlAW3RlL.js +0 -44
  103. package/dist/assets/Cron-1DZTZWXO.js +0 -1
  104. package/dist/assets/CronManager-B6xLLgI5.js +0 -1
  105. package/dist/assets/Diagnostics-B6apZrC5.js +0 -1
  106. package/dist/assets/Dlq-MRmkQrNC.js +0 -1
  107. package/dist/assets/DlqControl-DNJPVOTF.js +0 -1
  108. package/dist/assets/DlqPro-CwtTDwGN.js +0 -1
  109. package/dist/assets/Fleet-Bdrj9bLI.js +0 -1
  110. package/dist/assets/Flows-1dC8F_wx.js +0 -2
  111. package/dist/assets/JobInspector-Ct9Ga-UT.js +0 -2
  112. package/dist/assets/Jobs-Chqv0RuL.js +0 -1
  113. package/dist/assets/JobsPro-CLzOhKAL.js +0 -1
  114. package/dist/assets/Logs-C5PNP9Wq.js +0 -1
  115. package/dist/assets/LogsPro-B3KwTSsn.js +0 -1
  116. package/dist/assets/MetricsPro-B9rD_KQc.js +0 -1
  117. package/dist/assets/Pagination-x-Cwgrul.js +0 -1
  118. package/dist/assets/QueueActions-BWnmNEqV.js +0 -1
  119. package/dist/assets/QueueControl-DqBvuDnF.js +0 -1
  120. package/dist/assets/QueueDetail-COSH9bJl.js +0 -1
  121. package/dist/assets/QueueDetailPro-ClKtyHIf.js +0 -1
  122. package/dist/assets/Queues-D2-R8nYh.js +0 -1
  123. package/dist/assets/QueuesOverview-tokquA3g.js +0 -1
  124. package/dist/assets/S3BackupPro-CDX5WQUY.js +0 -3
  125. package/dist/assets/ServerControl-BmccMNW9.js +0 -3
  126. package/dist/assets/Settings-UcujwvKv.js +0 -1
  127. package/dist/assets/StatusBadge-BoOVTnKu.js +0 -1
  128. package/dist/assets/UsagePro-BGLufsE7.js +0 -1
  129. package/dist/assets/Webhooks-DT2LK17V.js +0 -1
  130. package/dist/assets/Workers-BX1qspaQ.js +0 -1
  131. package/dist/assets/WorkersPro-BsUQVgQZ.js +0 -1
  132. package/dist/assets/Workflows-DZZA6aGW.js +0 -1
  133. package/dist/assets/bq-F5BbTG1T.js +0 -1
  134. package/dist/assets/connectionStore-BJLihGaC.js +0 -1
  135. package/dist/assets/controlConnectionEpoch-CZsS0wAb.js +0 -1
  136. package/dist/assets/dist-1f1fMHVO.js +0 -11
  137. package/dist/assets/dist-BlWBaCN6.js +0 -11
  138. package/dist/assets/dist-DZL8soAh.js +0 -79
  139. package/dist/assets/dist-DjvZiKhB.js +0 -2
  140. package/dist/assets/dist-DpZcbP9W.js +0 -11
  141. package/dist/assets/feedback-QDN1AsYt.js +0 -1
  142. package/dist/assets/form-C4oby9pI.js +0 -1
  143. package/dist/assets/index-8Ii-_Rml.js +0 -8
  144. package/dist/assets/react-vendor-CvwrP0tU.js +0 -11
  145. package/dist/assets/useControlActionGuard-B-bzKZKI.js +0 -1
  146. package/dist/assets/usePolledData-0CVelkl2.js +0 -1
  147. package/dist/assets/useServerActionGuard-uGePilQQ.js +0 -1
  148. package/dist/assets/useThroughputSeries-DgO8uicf.js +0 -1
package/README.md CHANGED
@@ -26,7 +26,7 @@ The live demo uses sample data. The docs explain how to connect and operate a re
26
26
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
27
27
  [![Documentation](https://img.shields.io/badge/docs-online-db2777)](https://egeominotti.github.io/bunqueue-dashboard/docs/)
28
28
  [![Live demo](https://img.shields.io/badge/live%20demo-online-22c55e)](https://egeominotti.github.io/bunqueue-dashboard/)
29
- ![Status: Beta](https://img.shields.io/badge/status-beta-f59e0b)
29
+ ![Status: Stable](https://img.shields.io/badge/status-stable-22c55e)
30
30
 
31
31
  ![React 19](https://img.shields.io/badge/React-19-149ECA?logo=react&logoColor=white)
32
32
  ![Vite 8](https://img.shields.io/badge/Vite-8-646CFF?logo=vite&logoColor=white)
@@ -58,9 +58,9 @@ The live demo uses sample data. The docs explain how to connect and operate a re
58
58
 
59
59
  ---
60
60
 
61
- > ⚠️ **Beta.** bunqueue dashboard is under active development, interfaces and
62
- > behavior may change between releases. Review it before relying on it for
63
- > unattended production use.
61
+ > ✅ **Stable (1.x).** The dashboard follows [Semantic Versioning](https://semver.org/):
62
+ > breaking changes to its configuration, routes or agent API only ship in a new
63
+ > major version.
64
64
 
65
65
  ## 📚 Documentation
66
66
 
@@ -129,8 +129,8 @@ contracts; it never patches Bunqueue internals.
129
129
  > registration or flow order. Cancel, Discard, Drain, Clean, Obliterate and DLQ Purge also fail
130
130
  > closed. Bunqueue 2.9's `Queue.removeDlqJob()` also accepts only queue + job ID, so custom-ID reuse
131
131
  > prevents the dashboard from proving that a later deletion still targets the observed generation.
132
- > DLQ `maxAge`/`maxEntries` are read-only, auto-retry can only be disabled, and Copilot's only
133
- > mutations are Promote, Pause and Resume.
132
+ > DLQ `maxAge`/`maxEntries` are read-only, and auto-retry can only be disabled. The in-app Copilot
133
+ > is switched off in official builds (`VITE_ENABLE_COPILOT=1` opts a custom build back in).
134
134
 
135
135
  ## Quick start
136
136
 
@@ -0,0 +1,27 @@
1
+ /** Bun uses a POSIX virtual root and a drive-qualified Windows virtual root. */
2
+ function compiledRoot(moduleUrl: string): URL | null {
3
+ const url = new URL(moduleUrl);
4
+ if (url.protocol !== 'file:') return null;
5
+ // Windows import.meta.url escapes the virtual root as /B:/%7EBUN/root/.
6
+ let pathname: string;
7
+ try { pathname = decodeURIComponent(url.pathname); } catch { return null; }
8
+ for (const marker of ['/$bunfs/root/', '/~BUN/root/']) {
9
+ const index = pathname.indexOf(marker);
10
+ if (index !== -1) {
11
+ url.pathname = pathname.slice(0, index + marker.length);
12
+ url.search = '';
13
+ url.hash = '';
14
+ return url;
15
+ }
16
+ }
17
+ return null;
18
+ }
19
+
20
+ export function isCompiledModule(moduleUrl: string): boolean {
21
+ return compiledRoot(moduleUrl) !== null;
22
+ }
23
+
24
+ export function compiledWorkerUrl(moduleUrl: string, path: string): string | null {
25
+ const root = compiledRoot(moduleUrl);
26
+ return root ? new URL(path, root).href : null;
27
+ }
@@ -0,0 +1,18 @@
1
+ /** Bound both pipe buffering and the final allocation before decoding a child response. */
2
+ export async function readBoundedBytes(stream: ReadableStream<Uint8Array>, limit: number): Promise<Buffer> {
3
+ const reader = stream.getReader();
4
+ const chunks: Uint8Array[] = [];
5
+ let bytes = 0;
6
+ try {
7
+ for (;;) {
8
+ const next = await reader.read();
9
+ if (next.done) break;
10
+ bytes += next.value.byteLength;
11
+ if (bytes > limit) throw new Error('Database process output exceeded its byte limit');
12
+ chunks.push(next.value);
13
+ }
14
+ return Buffer.concat(chunks, bytes);
15
+ } finally {
16
+ reader.releaseLock();
17
+ }
18
+ }
@@ -1,4 +1,5 @@
1
1
  import { validateWorkerBounds } from './core';
2
+ import type { DatabaseWorker } from './processWorker';
2
3
  import { createExportWorker } from './workerFactory';
3
4
  import {
4
5
  DB_EXPORT_MAX_ROWS,
@@ -14,7 +15,7 @@ import {
14
15
 
15
16
  let liveExportWorkers = 0;
16
17
 
17
- /** Export workers alive now, including abandoned SQLite calls still unwinding. */
18
+ /** Export workers alive now, including cancelled children being reaped. */
18
19
  export const exportWorkerLoad = (): number => liveExportWorkers;
19
20
 
20
21
  function validatedWorkerExport(value: unknown, expectedTable: string): DbCsvExport {
@@ -67,11 +68,11 @@ export async function exportWithTimeout(
67
68
  signal?.throwIfAborted();
68
69
  if (liveExportWorkers >= MAX_CONCURRENT_EXPORTS) {
69
70
  throw new DbExportBusyError(
70
- 'A database export is already running. Wait for it to finish, or restart the agent if it was abandoned.'
71
+ 'A database export is already running. Retry after it finishes.'
71
72
  );
72
73
  }
73
74
 
74
- let worker: Worker;
75
+ let worker: DatabaseWorker;
75
76
  try {
76
77
  worker = createExportWorker();
77
78
  } catch (error) {
@@ -117,7 +118,7 @@ export async function exportWithTimeout(
117
118
  reject(new DbExportUnavailableError('Database export worker exited unexpectedly'));
118
119
  }
119
120
  });
120
- worker.addEventListener('message', (event: MessageEvent) => {
121
+ worker.addEventListener('message', (event) => {
121
122
  if (settled) return;
122
123
  settled = true;
123
124
  cleanup();
@@ -147,7 +148,7 @@ export async function exportWithTimeout(
147
148
  );
148
149
  }
149
150
  });
150
- worker.addEventListener('error', (event: ErrorEvent) => {
151
+ worker.addEventListener('error', (event) => {
151
152
  if (settled) return;
152
153
  event.preventDefault?.();
153
154
  abandon(
@@ -161,7 +162,7 @@ export async function exportWithTimeout(
161
162
  () =>
162
163
  abandon(
163
164
  new DbExportUnavailableError(
164
- `Database export exceeded the ${DB_EXPORT_TIMEOUT_MS / 1000}s time limit and was abandoned (SQLite may keep scanning until the worker exits).`
165
+ `Database export exceeded the ${DB_EXPORT_TIMEOUT_MS / 1000}s time limit and was terminated.`
165
166
  )
166
167
  ),
167
168
  DB_EXPORT_TIMEOUT_MS
@@ -186,5 +187,6 @@ export async function exportWithTimeout(
186
187
  });
187
188
  } finally {
188
189
  worker.terminate();
190
+ if (worker.exited) { await worker.exited; release(); }
189
191
  }
190
192
  }
@@ -0,0 +1,121 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { deserialize, serialize } from 'node:v8';
3
+ import { readBoundedBytes } from './boundedStream';
4
+ import { isCompiledModule } from '../compiledRuntime';
5
+
6
+ type WorkerEvents = {
7
+ message: { data: unknown };
8
+ error: { message: string; preventDefault?: () => void };
9
+ close: Record<string, never>;
10
+ };
11
+
12
+ /** Minimal Worker-compatible seam retained for existing integrations and fault injection. */
13
+ export interface DatabaseWorker {
14
+ addEventListener<K extends keyof WorkerEvents>(type: K, listener: (event: WorkerEvents[K]) => void): void;
15
+ postMessage(request: unknown): void;
16
+ terminate(): void;
17
+ /** Production workers resolve only after the OS has reaped the child. */
18
+ exited?: Promise<void>;
19
+ }
20
+
21
+ const active = new Set<DatabaseProcessWorker>();
22
+ export const databaseProcessLoad = (): number => active.size;
23
+ export function terminateDatabaseProcesses(): void {
24
+ for (const worker of active) worker.terminate();
25
+ }
26
+ process.once('exit', terminateDatabaseProcesses);
27
+
28
+ /** Unlike Worker.terminate(), SIGKILL interrupts synchronous sqlite3_step. */
29
+ export class DatabaseProcessWorker implements DatabaseWorker {
30
+ private listeners = new Map<string, Array<(event: unknown) => void>>();
31
+ private child?: Bun.Subprocess<'ignore', 'pipe', 'ignore' | 'inherit'>;
32
+ private stopped = false;
33
+ private started = false;
34
+ private closed = false;
35
+ private resolveExit!: () => void;
36
+ readonly exited = new Promise<void>((resolve) => { this.resolveExit = resolve; });
37
+ get pid(): number | undefined { return this.child?.pid; }
38
+
39
+ constructor(private readonly workerUrl?: string) {}
40
+
41
+ addEventListener<K extends keyof WorkerEvents>(type: K, listener: (event: WorkerEvents[K]) => void): void {
42
+ this.listeners.set(type, [...(this.listeners.get(type) ?? []), (event) => listener(event as WorkerEvents[K])]);
43
+ }
44
+
45
+ postMessage(request: unknown): void {
46
+ if (this.started || this.stopped) throw new Error('Database process is already used');
47
+ this.started = true;
48
+ active.add(this);
49
+ void this.run(request);
50
+ }
51
+
52
+ terminate(): void {
53
+ this.stopped = true;
54
+ if (this.child && this.child.exitCode === null) this.child.kill('SIGKILL');
55
+ if (!this.started) this.close();
56
+ }
57
+
58
+ private emit<K extends keyof WorkerEvents>(type: K, event: WorkerEvents[K]): void {
59
+ for (const listener of this.listeners.get(type) ?? []) listener(event);
60
+ }
61
+
62
+ private close(): void {
63
+ if (this.closed) return;
64
+ this.closed = true;
65
+ active.delete(this);
66
+ this.resolveExit();
67
+ this.emit('close', {});
68
+ this.listeners.clear();
69
+ }
70
+
71
+ private async run(request: unknown): Promise<void> {
72
+ try {
73
+ const message = request as Record<string, unknown>;
74
+ const operation = message.operation ? request : message.kind === 'export'
75
+ ? { operation: 'dbExportCsv', args: [message.path, message.table, message.orderBy, message.dir, message.filter] }
76
+ : { operation: 'dbQuery', args: [message.path, message.sql] };
77
+ // Legacy overrides never handled the newly isolated browse/workflow operations.
78
+ const input = serialize(this.workerUrl !== undefined && !message.operation
79
+ ? { customWorkerUrl: this.workerUrl, request }
80
+ : operation);
81
+ if (input.byteLength > 128 * 1024) throw new Error('Database request exceeds 128 KiB');
82
+ const compiled = isCompiledModule(import.meta.url);
83
+ const trace = process.env.BQ_DB_PROCESS_TRACE === '1';
84
+ if (trace) console.error(JSON.stringify({ component: 'db-process', phase: 'spawn', module: import.meta.url, compiled, executable: process.execPath }));
85
+ let delivered = false;
86
+ this.child = Bun.spawn(compiled
87
+ ? [process.execPath, '--bq-db-read']
88
+ : [process.execPath, fileURLToPath(new URL('./readProcessMain.ts', import.meta.url))], {
89
+ // Database readers need no managed-server tokens or cloud credentials.
90
+ env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, TEMP: process.env.TEMP, TMPDIR: process.env.TMPDIR, BQ_DB_PROCESS_TRACE: trace ? '1' : undefined },
91
+ stdin: 'ignore', stdout: 'pipe', stderr: trace ? 'inherit' : 'ignore',
92
+ ipc: (message, subprocess) => {
93
+ if (message !== 'ready' || delivered || this.stopped) return;
94
+ delivered = true;
95
+ if (trace) console.error(JSON.stringify({ component: 'db-process', phase: 'ready', pid: subprocess.pid }));
96
+ try { subprocess.send(input); } catch { this.terminate(); }
97
+ },
98
+ });
99
+ const [output, code] = await Promise.all([
100
+ readBoundedBytes(this.child.stdout, 32 * 1024 * 1024), this.child.exited,
101
+ ]);
102
+ if (trace) console.error(JSON.stringify({ component: 'db-process', phase: 'exited', code, bytes: output.byteLength }));
103
+ if (this.stopped) return;
104
+ if (code !== 0) throw new Error(`Database process exited with code ${code}`);
105
+ const response = deserialize(output) as Record<string, unknown>;
106
+ if (message.kind === 'export' && response.ok && !Object.hasOwn(response, 'export') && Object.hasOwn(response, 'result')) {
107
+ response.export = response.result;
108
+ }
109
+ // The process has exited before any caller can release its lifecycle lease.
110
+ this.emit('message', { data: response });
111
+ } catch (error) {
112
+ this.terminate();
113
+ if (this.child) await this.child.exited;
114
+ this.emit('error', { message: error instanceof Error ? error.message : 'Database process failed' });
115
+ } finally {
116
+ try { this.child?.disconnect(); } catch { /* child already closed its IPC channel */ }
117
+ this.child = undefined;
118
+ this.close();
119
+ }
120
+ }
121
+ }
@@ -1,76 +1,88 @@
1
+ import { ReadAdmission } from './readAdmission';
1
2
  import { createQueryWorker } from './workerFactory';
3
+ import type { ReadArguments, ReadOperation, ReadResult } from './readOperations';
2
4
  import {
3
- MAX_CONCURRENT_QUERIES,
4
- MissingDbError,
5
- QUERY_TIMEOUT_MS,
6
- type DbQueryResult,
5
+ DbReadUnavailableError,
6
+ MissingDbError, QUERY_TIMEOUT_MS, type DbQueryResult,
7
7
  } from './types';
8
8
 
9
- let liveQueryWorkers = 0;
9
+ const admission = new ReadAdmission();
10
+ /** Active database read processes, including children being reaped after cancellation. */
11
+ export const queryWorkerLoad = (): number => admission.load;
10
12
 
11
- /** Query worker threads currently alive, including timed-out queries. */
12
- export const queryWorkerLoad = (): number => liveQueryWorkers;
13
+ export function queryWithTimeout(path: string, sql: string, signal?: AbortSignal): Promise<DbQueryResult> {
14
+ return runReadWorker({ path, sql }, signal);
15
+ }
13
16
 
14
- export async function queryWithTimeout(path: string, sql: string): Promise<DbQueryResult> {
15
- if (liveQueryWorkers >= MAX_CONCURRENT_QUERIES) {
16
- throw new Error(
17
- `Too many queries running (${liveQueryWorkers}/${MAX_CONCURRENT_QUERIES}). A previous query timed out and is still running inside SQLite — wait for it to finish, or restart the agent.`
18
- );
19
- }
20
- let worker: Worker;
17
+ /** Every HTTP SQLite read shares the same bounded admission pool. */
18
+ export function readWithTimeout<K extends ReadOperation>(
19
+ operation: K, args: ReadArguments<K>, signal?: AbortSignal,
20
+ timeoutMs = QUERY_TIMEOUT_MS
21
+ ): Promise<ReadResult<K>> {
22
+ return runReadWorker({ operation, args }, signal, timeoutMs, true);
23
+ }
24
+
25
+ async function runReadWorker<T>(request: unknown, signal?: AbortSignal, timeoutMs = QUERY_TIMEOUT_MS, queue = false): Promise<T> {
26
+ const limit = Math.max(1, Math.min(timeoutMs, QUERY_TIMEOUT_MS));
27
+ const deadline = performance.now() + limit;
28
+ const ticket = admission.acquire(queue, deadline, signal);
29
+ const release = typeof ticket === 'function' ? ticket : await ticket;
30
+ let worker;
21
31
  try {
32
+ signal?.throwIfAborted();
33
+ if (performance.now() >= deadline) throw new Error('Database read exceeded the time limit while waiting for capacity');
22
34
  worker = createQueryWorker();
23
35
  } catch (error) {
24
- throw new Error(`Query worker unavailable: ${(error as Error).message ?? String(error)}`);
36
+ release();
37
+ throw new DbReadUnavailableError(`Query worker unavailable: ${(error as Error).message}`);
25
38
  }
26
- liveQueryWorkers++;
27
- let released = false;
28
- const release = () => {
29
- if (released) return;
30
- released = true;
31
- liveQueryWorkers--;
32
- };
33
- worker.addEventListener('close', release);
39
+ let cleanup = () => {};
34
40
  try {
35
- return await new Promise<DbQueryResult>((resolve, reject) => {
41
+ return await new Promise<T>((resolve, reject) => {
36
42
  let settled = false;
37
- const timer = setTimeout(() => {
43
+ const fail = (error: Error) => {
38
44
  if (settled) return;
39
45
  settled = true;
46
+ cleanup();
40
47
  worker.terminate();
41
- reject(
42
- new Error(
43
- `Query exceeded the ${QUERY_TIMEOUT_MS / 1000}s time limit and was abandoned (it may keep running inside SQLite until it completes).`
44
- )
45
- );
46
- }, QUERY_TIMEOUT_MS);
47
- worker.addEventListener('message', (event: MessageEvent) => {
48
- if (settled) return;
49
- settled = true;
50
- clearTimeout(timer);
48
+ reject(error);
49
+ };
50
+ const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error('Database read aborted'));
51
+ const timer = setTimeout(() => fail(new DbReadUnavailableError(
52
+ `Query exceeded the ${limit / 1000}s time limit and was terminated.`
53
+ )), Math.max(1, deadline - performance.now()));
54
+ cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); };
55
+ worker.addEventListener('close', () => {
51
56
  release();
52
- const response = event.data as {
53
- ok: boolean;
54
- result?: DbQueryResult;
55
- error?: string;
56
- missing?: boolean;
57
- };
58
- if (response.ok && response.result) resolve(response.result);
59
- else if (response.missing) {
60
- reject(new MissingDbError(response.error ?? 'Database not found'));
61
- } else reject(new Error(response.error ?? 'Query failed'));
57
+ if (!settled) fail(new DbReadUnavailableError('Query worker exited unexpectedly'));
62
58
  });
63
- worker.addEventListener('error', (event: ErrorEvent) => {
59
+ worker.addEventListener('message', (event) => {
64
60
  if (settled) return;
61
+ const response = event.data as { ok?: unknown; result?: unknown; missing?: unknown; error?: unknown } | null;
62
+ if (!response || typeof response !== 'object' || typeof response.ok !== 'boolean') {
63
+ fail(new DbReadUnavailableError('Query worker returned a malformed response'));
64
+ return;
65
+ }
65
66
  settled = true;
66
- clearTimeout(timer);
67
- release();
67
+ cleanup();
68
+ if (!worker.exited) release();
69
+ if (response.ok && Object.hasOwn(response, 'result')) resolve(response.result as T);
70
+ else if (response.missing === true) reject(new MissingDbError(typeof response.error === 'string' ? response.error : 'Database not found'));
71
+ else reject(new Error(typeof response.error === 'string' ? response.error : 'Query failed'));
72
+ });
73
+ worker.addEventListener('error', (event) => {
68
74
  event.preventDefault?.();
69
- reject(new Error(`Query worker failed: ${event.message || 'unknown worker error'}`));
75
+ fail(new DbReadUnavailableError(`Query worker failed: ${event.message || 'unknown worker error'}`));
70
76
  });
71
- worker.postMessage({ path, sql });
77
+ signal?.addEventListener('abort', onAbort, { once: true });
78
+ if (signal?.aborted) { onAbort(); return; }
79
+ try { worker.postMessage(request); } catch (error) {
80
+ fail(new DbReadUnavailableError(`Could not start database read: ${(error as Error).message}`));
81
+ }
72
82
  });
73
83
  } finally {
84
+ cleanup();
74
85
  worker.terminate();
86
+ if (worker.exited) { await worker.exited; release(); }
75
87
  }
76
88
  }
@@ -0,0 +1,53 @@
1
+ import { DbReadBusyError, DbReadUnavailableError, MAX_CONCURRENT_QUERIES } from './types';
2
+
3
+ type Release = () => void;
4
+ interface Waiter { admit(): void; cancel(error: unknown): void }
5
+ const MAX_PENDING_READS = 32;
6
+
7
+ /** Fair, bounded admission absorbs a page's parallel polls without spawning more SQLite processes. */
8
+ export class ReadAdmission {
9
+ private active = 0;
10
+ private waiting: Waiter[] = [];
11
+ get load(): number { return this.active; }
12
+ get pending(): number { return this.waiting.length; }
13
+
14
+ acquire(queue: boolean, deadline: number, signal?: AbortSignal): Release | Promise<Release> {
15
+ signal?.throwIfAborted();
16
+ if (this.active < MAX_CONCURRENT_QUERIES) return this.reserve();
17
+ if (!queue || this.waiting.length >= MAX_PENDING_READS) {
18
+ throw new DbReadBusyError(`Too many queries running (${this.active}/${MAX_CONCURRENT_QUERIES}). Retry after a current read finishes.`);
19
+ }
20
+ return new Promise<Release>((resolve, reject) => {
21
+ let settled = false;
22
+ const cleanup = () => {
23
+ settled = true;
24
+ clearTimeout(timer);
25
+ signal?.removeEventListener('abort', onAbort);
26
+ const index = this.waiting.indexOf(waiter);
27
+ if (index !== -1) this.waiting.splice(index, 1);
28
+ };
29
+ const waiter: Waiter = {
30
+ admit: () => { if (!settled) { cleanup(); resolve(this.reserve()); } },
31
+ cancel: (error) => { if (!settled) { cleanup(); reject(error); } },
32
+ };
33
+ const onAbort = () => waiter.cancel(signal?.reason instanceof Error ? signal.reason : new Error('Database read aborted'));
34
+ const timer = setTimeout(() => waiter.cancel(new DbReadUnavailableError(
35
+ 'Database read exceeded the time limit while waiting for capacity'
36
+ )), Math.max(1, deadline - performance.now()));
37
+ this.waiting.push(waiter);
38
+ signal?.addEventListener('abort', onAbort, { once: true });
39
+ if (signal?.aborted) onAbort();
40
+ });
41
+ }
42
+
43
+ private reserve(): Release {
44
+ this.active++;
45
+ let released = false;
46
+ return () => {
47
+ if (released) return;
48
+ released = true;
49
+ this.active--;
50
+ this.waiting[0]?.admit();
51
+ };
52
+ }
53
+ }
@@ -0,0 +1,27 @@
1
+ import { dbExportCsv } from './csvExport';
2
+ import { dbQuery } from './query';
3
+ import { dbInfo, dbSchema } from './schema';
4
+ import { dbCell, dbRows, dbTables } from './tables';
5
+ import { workflowExecution } from '../workflow/detail';
6
+ import { workflowExecutions } from '../workflow/list';
7
+ import { workflowStats } from '../workflow/stats';
8
+
9
+ /** Fixed allowlist shared by the typed caller and isolated SQLite process. */
10
+ export const READ_OPERATIONS = {
11
+ dbQuery, dbExportCsv, dbInfo, dbSchema, dbCell, dbRows, dbTables,
12
+ workflowExecution, workflowExecutions, workflowStats,
13
+ };
14
+
15
+ export type ReadOperation = keyof typeof READ_OPERATIONS;
16
+ export type ReadArguments<K extends ReadOperation> = Parameters<(typeof READ_OPERATIONS)[K]>;
17
+ export type ReadResult<K extends ReadOperation> = ReturnType<(typeof READ_OPERATIONS)[K]>;
18
+
19
+ export function dispatchRead(request: unknown): unknown {
20
+ if (!request || typeof request !== 'object') throw new Error('Invalid database read request');
21
+ const { operation, args } = request as { operation: string; args: unknown[] };
22
+ if (!Object.hasOwn(READ_OPERATIONS, operation) || !Array.isArray(args)) {
23
+ throw new Error('Unknown database read operation');
24
+ }
25
+ const execute = READ_OPERATIONS[operation as ReadOperation] as (...args: unknown[]) => unknown;
26
+ return execute(...args);
27
+ }
@@ -0,0 +1,64 @@
1
+ import { deserialize, serialize } from 'node:v8';
2
+ import { compiledWorkerUrl } from '../compiledRuntime';
3
+
4
+ /** One request, one readonly connection, one process. Also bundled into the standalone server. */
5
+ export async function runDatabaseReadProcess(): Promise<void> {
6
+ let response: unknown;
7
+ let worker: Worker | undefined;
8
+ const trace = (phase: string, details: Record<string, unknown> = {}): void => {
9
+ if (process.env.BQ_DB_PROCESS_TRACE === '1') console.error(JSON.stringify({ component: 'db-supervisor', phase, ...details }));
10
+ };
11
+ try {
12
+ trace('started', { module: import.meta.url, ipc: Boolean(process.send), connected: process.connected });
13
+ if (!process.send || !process.connected) throw new Error('Database process requires an IPC parent');
14
+ // Keep request delivery and parent liveness separate from standard streams.
15
+ // Disconnect also covers abrupt parent death.
16
+ process.once('disconnect', () => process.exit(1));
17
+ const pending = new Promise<unknown>((resolve) => process.once('message', resolve));
18
+ process.send('ready');
19
+ const message = await pending;
20
+ trace('request-received');
21
+ if (!(message instanceof Uint8Array) || message.byteLength > 128 * 1024) {
22
+ throw new Error('Invalid database request or request exceeds 128 KiB');
23
+ }
24
+ let input: unknown = deserialize(message);
25
+ let workerUrl = compiledWorkerUrl(import.meta.url, 'agent/dbReadWorker.js')
26
+ ?? new URL('../dbReadWorker.ts', import.meta.url).href;
27
+ let customWorker = false;
28
+ if (input && typeof input === 'object' && 'customWorkerUrl' in input) {
29
+ if (typeof input.customWorkerUrl !== 'string' || !input.customWorkerUrl || !('request' in input)) {
30
+ throw new Error('Invalid database worker override');
31
+ }
32
+ workerUrl = input.customWorkerUrl;
33
+ input = input.request;
34
+ customWorker = true;
35
+ }
36
+ trace('creating-worker', customWorker ? { customWorker: true } : { workerUrl });
37
+ worker = new Worker(workerUrl, { type: 'module' });
38
+ trace('worker-created');
39
+ response = await new Promise<unknown>((resolve, reject) => {
40
+ worker!.addEventListener('message', (event: MessageEvent) => { trace('worker-result'); resolve(event.data); });
41
+ worker!.addEventListener('error', (event: ErrorEvent) => {
42
+ event.preventDefault?.();
43
+ reject(new Error(event.message || 'Database worker failed'));
44
+ });
45
+ worker!.postMessage(input);
46
+ });
47
+ } catch (error) {
48
+ trace('failed');
49
+ response = {
50
+ ok: false,
51
+ error: error instanceof Error ? error.message : 'Database read failed',
52
+ };
53
+ }
54
+ const output = serialize(response);
55
+ await Bun.write(Bun.stdout, output.byteLength <= 32 * 1024 * 1024
56
+ ? output
57
+ : serialize({ ok: false, error: 'Database result exceeded 32 MiB' }));
58
+ trace('output-written', { bytes: output.byteLength });
59
+ // Exit the whole process, including its SQLite thread and IPC channel.
60
+ worker?.terminate();
61
+ process.exit(0);
62
+ }
63
+
64
+ if (import.meta.main) await runDatabaseReadProcess();
package/agent/db/types.ts CHANGED
@@ -19,6 +19,9 @@ export class MissingDbError extends Error {
19
19
  readonly missing = true;
20
20
  }
21
21
 
22
+ export class DbReadBusyError extends Error {}
23
+ export class DbReadUnavailableError extends Error {}
24
+
22
25
  /** Maps a saturated export worker pool to HTTP 429. */
23
26
  export class DbExportBusyError extends Error {}
24
27
 
@@ -1,25 +1,27 @@
1
- let workerUrl = new URL('../dbQueryWorker.ts', import.meta.url).href;
1
+ import { DatabaseProcessWorker, type DatabaseWorker } from './processWorker';
2
2
 
3
- const spawnWorker = (): Worker => new Worker(workerUrl, { type: 'module' });
4
- let queryFactory: () => Worker = spawnWorker;
5
- let exportFactory: () => Worker = spawnWorker;
3
+ let workerUrl: string | undefined;
4
+ const spawnWorker = (): DatabaseWorker => new DatabaseProcessWorker(workerUrl);
5
+ let queryFactory: () => DatabaseWorker = spawnWorker;
6
+ let exportFactory: () => DatabaseWorker = spawnWorker;
6
7
 
7
- export function createQueryWorker(): Worker {
8
+ export function createQueryWorker(): DatabaseWorker {
8
9
  return queryFactory();
9
10
  }
10
11
 
11
- export function createExportWorker(): Worker {
12
+ export function createExportWorker(): DatabaseWorker {
12
13
  return exportFactory();
13
14
  }
14
15
 
15
- export function setQueryWorkerUrl(url: string): void {
16
- workerUrl = url;
16
+ /** Optional legacy query/export worker override, still hosted in a killable process. */
17
+ export function setQueryWorkerUrl(url: string | null): void {
18
+ workerUrl = url ?? undefined;
17
19
  }
18
20
 
19
- export function setQueryWorkerFactory(factory: (() => Worker) | null): void {
21
+ export function setQueryWorkerFactory(factory: (() => DatabaseWorker) | null): void {
20
22
  queryFactory = factory ?? spawnWorker;
21
23
  }
22
24
 
23
- export function setExportWorkerFactory(factory: (() => Worker) | null): void {
25
+ export function setExportWorkerFactory(factory: (() => DatabaseWorker) | null): void {
24
26
  exportFactory = factory ?? spawnWorker;
25
27
  }
@@ -0,0 +1,21 @@
1
+ import { dispatchRead } from './db/readOperations';
2
+ import { MissingDbError } from './db/types';
3
+
4
+ declare const self: Worker;
5
+
6
+ if (process.env.BQ_DB_PROCESS_TRACE === '1') console.error(JSON.stringify({ component: 'db-thread', phase: 'listening' }));
7
+
8
+ // Runs inside a disposable PROCESS. Its supervisor remains responsive to IPC
9
+ // disconnects when the agent dies, even while this thread is in sqlite3_step.
10
+ self.addEventListener('message', (event: MessageEvent) => {
11
+ try {
12
+ const result = dispatchRead(event.data);
13
+ const content = result && typeof result === 'object' && 'content' in result ? result.content : null;
14
+ self.postMessage({ ok: true, result }, content instanceof Uint8Array && content.buffer instanceof ArrayBuffer ? [content.buffer] : []);
15
+ } catch (error) {
16
+ self.postMessage({ ok: false,
17
+ error: error instanceof Error ? error.message : 'Database read failed',
18
+ missing: error instanceof MissingDbError,
19
+ });
20
+ }
21
+ });
package/agent/index.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  import { assertRequiredBunVersion } from '../scripts/bunVersion';
16
16
  import { logger } from './logger';
17
17
  import { ProcessManager } from './manager';
18
+ import { agentConfigStore } from './manager/configStore';
18
19
  import {
19
20
  createFetchHandler,
20
21
  resolveAllowedHosts,
@@ -25,7 +26,7 @@ import { installAgentShutdown } from './shutdown';
25
26
 
26
27
  assertRequiredBunVersion();
27
28
 
28
- const mgr = new ProcessManager();
29
+ const mgr = new ProcessManager(undefined, agentConfigStore());
29
30
  const PORT = Number(process.env.AGENT_PORT) || 6800;
30
31
  const allowedOrigins = resolveAllowedOrigins();
31
32
  // The agent binds loopback only, so a legitimate Host is always a loopback
@@ -72,7 +72,9 @@ function assertLocalTarget(target: string, config: ServerConfig, label: string):
72
72
  } catch {
73
73
  throw new Error(`${label} must be an absolute local HTTP URL`);
74
74
  }
75
- const local = ['localhost', '127.0.0.1', '::1'].includes(url.hostname);
75
+ // URL keeps IPv6 brackets in hostname (`[::1]`), so compare without them.
76
+ const host = url.hostname.replace(/^\[(.*)\]$/, '$1');
77
+ const local = ['localhost', '127.0.0.1', '::1'].includes(host);
76
78
  const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80));
77
79
  if (!local || port !== config.httpPort || (url.protocol !== 'http:' && url.protocol !== 'https:')) {
78
80
  throw new Error(