bunqueue-dashboard 0.0.45 → 0.0.47

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.
@@ -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
@@ -0,0 +1,65 @@
1
+ import { closeSync, constants, fstatSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { copyConfig, validateServerConfig } from './config';
4
+ import type { ServerConfig } from './types';
5
+
6
+ export interface ConfigStore {
7
+ load(fallback: ServerConfig): ServerConfig;
8
+ save(config: ServerConfig): void;
9
+ }
10
+
11
+ const MAX_CONFIG_BYTES = 1024 * 1024;
12
+
13
+ /** Private atomic snapshots. Errors never include configuration secrets. */
14
+ export class FileConfigStore implements ConfigStore {
15
+ constructor(readonly path: string) {}
16
+
17
+ load(fallback: ServerConfig): ServerConfig {
18
+ let descriptor: number;
19
+ try {
20
+ descriptor = openSync(this.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
21
+ } catch (error) {
22
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return copyConfig(fallback);
23
+ throw new Error('Cannot read saved agent configuration');
24
+ }
25
+ try {
26
+ if (fstatSync(descriptor).size > MAX_CONFIG_BYTES) throw new Error('oversize');
27
+ const raw = readFileSync(descriptor);
28
+ if (raw.byteLength > MAX_CONFIG_BYTES) throw new Error('oversize');
29
+ const saved = JSON.parse(raw.toString()) as { version?: unknown; config?: unknown };
30
+ if (saved?.version !== 1) throw new Error('unsupported format');
31
+ return copyConfig(validateServerConfig(saved.config));
32
+ } catch {
33
+ throw new Error('Saved agent configuration is invalid; repair or remove AGENT_CONFIG_PATH before restarting');
34
+ } finally {
35
+ closeSync(descriptor);
36
+ }
37
+ }
38
+
39
+ save(config: ServerConfig): void {
40
+ const content = JSON.stringify({ version: 1, config: validateServerConfig(config) }) + '\n';
41
+ if (Buffer.byteLength(content) > MAX_CONFIG_BYTES) throw new Error('Agent configuration exceeds 1 MiB');
42
+ const directory = dirname(this.path);
43
+ const temporary = `${this.path}.${process.pid}.${crypto.randomUUID()}.tmp`;
44
+ let descriptor: number | undefined;
45
+ try {
46
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
47
+ descriptor = openSync(temporary, 'wx', 0o600);
48
+ writeFileSync(descriptor, content);
49
+ fsyncSync(descriptor);
50
+ closeSync(descriptor);
51
+ descriptor = undefined;
52
+ renameSync(temporary, this.path);
53
+ } catch {
54
+ throw new Error('Cannot persist agent configuration; the change was not applied');
55
+ } finally {
56
+ if (descriptor !== undefined) closeSync(descriptor);
57
+ rmSync(temporary, { force: true });
58
+ }
59
+ }
60
+ }
61
+
62
+ /** Paths resolve against the launch directory, just like the database path. Use one path per agent. */
63
+ export function agentConfigStore(env: NodeJS.ProcessEnv = process.env): FileConfigStore {
64
+ return new FileConfigStore(resolve(env.AGENT_CONFIG_PATH || '.bunqueue-dashboard/config.json'));
65
+ }
@@ -1,5 +1,6 @@
1
1
  import { copyConfig, defaultConfig, validateConfigPatch, validateServerConfig } from './config';
2
2
  import { ProcessLogs } from './logs';
3
+ import type { ConfigStore } from './configStore';
3
4
  import { databaseStats } from './storage';
4
5
  import { removeSqlitePaths, validateManagedStorage } from './storageMode';
5
6
  import type {
@@ -40,7 +41,12 @@ export class ProcessManager {
40
41
  private maintenance: string | null = null;
41
42
  private output = new ProcessLogs();
42
43
 
43
- constructor(private readonly stopTimeoutMs = STOP_TIMEOUT_MS) {}
44
+ constructor(
45
+ private readonly stopTimeoutMs = STOP_TIMEOUT_MS,
46
+ private readonly configStore?: ConfigStore
47
+ ) {
48
+ this.config = configStore?.load(this.config) ?? this.config;
49
+ }
44
50
 
45
51
  getConfig(): ServerConfig {
46
52
  return copyConfig(this.config);
@@ -63,6 +69,7 @@ export class ProcessManager {
63
69
  extraEnv: valid.extraEnv ?? this.config.extraEnv,
64
70
  });
65
71
  if (!sameConfig(this.config, next)) {
72
+ this.configStore?.save(next);
66
73
  this.config = next;
67
74
  this.configRevision = nextConfigRevision(this.configRevision);
68
75
  }
@@ -1,10 +1,6 @@
1
+ import { readWithTimeout } from '../db/queryTimeout';
1
2
  import {
2
3
  type DbFilter,
3
- dbCell,
4
- dbInfo,
5
- dbRows,
6
- dbSchema,
7
- dbTables,
8
4
  exportWithTimeout,
9
5
  queryWithTimeout,
10
6
  } from '../db';
@@ -105,15 +101,15 @@ export async function routeDatabaseRequest(
105
101
  admission?: ManagedDatabaseAdmission
106
102
  ): Promise<RouteResponse | Response | null> {
107
103
  if (pathname === '/db/info' && method === 'GET') {
108
- return withDatabase(dataPath, admission, (path) => ({
104
+ return withDatabase(dataPath, admission, async (path) => ({
109
105
  status: 200,
110
- body: { ok: true, ...dbInfo(path) },
106
+ body: { ok: true, ...(await readWithTimeout('dbInfo', [path], request.signal)) },
111
107
  }));
112
108
  }
113
109
  if (pathname === '/db/tables' && method === 'GET') {
114
- return withDatabase(dataPath, admission, (path) => ({
110
+ return withDatabase(dataPath, admission, async (path) => ({
115
111
  status: 200,
116
- body: { ok: true, tables: dbTables(path) },
112
+ body: { ok: true, tables: await readWithTimeout('dbTables', [path], request.signal) },
117
113
  }));
118
114
  }
119
115
  if (pathname.startsWith('/db/tables/') && method === 'GET') {
@@ -126,20 +122,20 @@ export async function routeDatabaseRequest(
126
122
  );
127
123
  }
128
124
  if (segments.length === 2 && subresource === 'schema') {
129
- return withDatabase(dataPath, admission, (path) => ({
125
+ return withDatabase(dataPath, admission, async (path) => ({
130
126
  status: 200,
131
- body: { ok: true, ...dbSchema(path, table) },
127
+ body: { ok: true, ...(await readWithTimeout('dbSchema', [path, table], request.signal)) },
132
128
  }));
133
129
  }
134
130
  if (segments.length === 2 && subresource === 'cell') {
135
131
  const query = new URL(request.url).searchParams;
136
132
  const rowid = query.get('rowid');
137
133
  if (rowid === null) throw new Error('rowid is required');
138
- return withDatabase(dataPath, admission, (path) => ({
134
+ return withDatabase(dataPath, admission, async (path) => ({
139
135
  status: 200,
140
136
  body: {
141
137
  ok: true,
142
- ...dbCell(path, table, rowid, query.get('column') ?? ''),
138
+ ...(await readWithTimeout('dbCell', [path, table, rowid, query.get('column') ?? ''], request.signal)),
143
139
  },
144
140
  }));
145
141
  }
@@ -147,11 +143,11 @@ export async function routeDatabaseRequest(
147
143
  return { status: 404, body: { ok: false, error: 'Not found' } };
148
144
  }
149
145
  const query = new URL(request.url).searchParams;
150
- return withDatabase(dataPath, admission, (path) => ({
146
+ return withDatabase(dataPath, admission, async (path) => ({
151
147
  status: 200,
152
148
  body: {
153
149
  ok: true,
154
- ...dbRows(
150
+ ...(await readWithTimeout('dbRows', [
155
151
  path,
156
152
  table,
157
153
  Number(query.get('limit')) || 50,
@@ -159,7 +155,7 @@ export async function routeDatabaseRequest(
159
155
  query.get('orderBy') || undefined,
160
156
  query.get('dir') === 'desc' ? 'desc' : 'asc',
161
157
  browseFilter(query)
162
- ),
158
+ ], request.signal)),
163
159
  },
164
160
  }));
165
161
  }
@@ -171,7 +167,7 @@ export async function routeDatabaseRequest(
171
167
  })) as { sql?: string };
172
168
  return withDatabase(dataPath, admission, async (path) => ({
173
169
  status: 200,
174
- body: { ok: true, ...(await queryWithTimeout(path, sql ?? '')) },
170
+ body: { ok: true, ...(await queryWithTimeout(path, sql ?? '', request.signal)) },
175
171
  }));
176
172
  }
177
173
  return null;
@@ -3,6 +3,7 @@ import {
3
3
  DbExportUnavailableError,
4
4
  MissingDbError,
5
5
  } from '../db';
6
+ import { DbReadBusyError, DbReadUnavailableError } from '../db/types';
6
7
  import { safeErrorMessage } from '../errorMessage';
7
8
  import { QueueOperationsUnavailableError } from '../queue/types';
8
9
  import { WorkflowRuntimeUnavailableError } from '../workflow/runtime';
@@ -26,6 +27,8 @@ export function errorStatus(error: unknown): number {
26
27
  try {
27
28
  if (error instanceof AgentLifecycleClosedError) return 503;
28
29
  if (error instanceof MissingDbError) return 404;
30
+ if (error instanceof DbReadBusyError) return 429;
31
+ if (error instanceof DbReadUnavailableError) return 503;
29
32
  if (error instanceof DbExportBusyError) return 429;
30
33
  if (error instanceof DbExportUnavailableError) return 503;
31
34
  if (error instanceof QueueOperationsUnavailableError) {
@@ -1,3 +1,4 @@
1
+ import { terminateDatabaseProcesses } from '../db/processWorker';
1
2
  import { BunqueueBackupRunner, type BackupRunnerPort } from '../backup/runner';
2
3
  import type { ProcessManager } from '../manager';
3
4
  import { QueueOperationsRuntime } from '../queue/runtime';
@@ -110,7 +111,7 @@ export function createFetchHandler(
110
111
  shuttingDown ??= closeThenShutdown(beginShutdown, manager);
111
112
  return shuttingDown;
112
113
  };
113
- const forceShutdown = () => manager.forceShutdown();
114
+ const forceShutdown = () => { terminateDatabaseProcesses(); manager.forceShutdown(); };
114
115
  return Object.assign(handle, { close, beginShutdown, shutdown, forceShutdown });
115
116
  }
116
117
 
@@ -1,9 +1,7 @@
1
+ import { readWithTimeout } from '../db/queryTimeout';
1
2
  import {
2
3
  type WorkflowStateFilter,
3
4
  type WorkflowStoreKind,
4
- workflowExecution,
5
- workflowExecutions,
6
- workflowStats,
7
5
  WORKFLOW_STATES,
8
6
  } from '../workflows';
9
7
  import type { RouteResponse } from './types';
@@ -32,9 +30,9 @@ export async function routeWorkflowReadRequest(
32
30
  admission?: ManagedDatabaseAdmission
33
31
  ): Promise<RouteResponse | null> {
34
32
  if (pathname === '/workflows/stats' && method === 'GET') {
35
- return withDatabase(dataPath, admission, (path) => ({
33
+ return withDatabase(dataPath, admission, async (path) => ({
36
34
  status: 200,
37
- body: { ok: true, ...workflowStats(path) },
35
+ body: { ok: true, ...(await readWithTimeout('workflowStats', [path], request.signal)) },
38
36
  }));
39
37
  }
40
38
  if (pathname === '/workflows' && method === 'GET') {
@@ -52,17 +50,17 @@ export async function routeWorkflowReadRequest(
52
50
  ) {
53
51
  throw new Error('Unknown workflow execution state');
54
52
  }
55
- return withDatabase(dataPath, admission, (path) => ({
53
+ return withDatabase(dataPath, admission, async (path) => ({
56
54
  status: 200,
57
55
  body: {
58
56
  ok: true,
59
- ...workflowExecutions(path, {
57
+ ...(await readWithTimeout('workflowExecutions', [path, {
60
58
  kind: workflowKind(query),
61
59
  workflowName: query.get('workflowName') || undefined,
62
60
  state: state as WorkflowStateFilter | undefined,
63
61
  limit: integer(query, 'limit', 50),
64
62
  offset: integer(query, 'offset', 0),
65
- }),
63
+ }], request.signal)),
66
64
  },
67
65
  }));
68
66
  }
@@ -78,8 +76,8 @@ export async function routeWorkflowReadRequest(
78
76
  throw new Error(`Duplicate workflow detail option: ${key}`);
79
77
  }
80
78
  }
81
- return withDatabase(dataPath, admission, (path) => {
82
- const execution = workflowExecution(path, decodeURIComponent(rawId), workflowKind(query));
79
+ return withDatabase(dataPath, admission, async (path) => {
80
+ const execution = await readWithTimeout('workflowExecution', [path, decodeURIComponent(rawId), workflowKind(query)], request.signal);
83
81
  return execution
84
82
  ? { status: 200, body: { ok: true, execution } }
85
83
  : { status: 404, body: { ok: false, error: 'Workflow execution not found' } };
@@ -89,7 +87,7 @@ export async function routeWorkflowReadRequest(
89
87
  function withDatabase<T>(
90
88
  dataPath: string,
91
89
  admission: ManagedDatabaseAdmission | undefined,
92
- operation: (path: string) => T
90
+ operation: (path: string) => T | Promise<T>
93
91
  ): Promise<T> {
94
92
  return admission
95
93
  ? admission((snapshot) => operation(snapshot.dataPath))
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { Database } from 'bun:sqlite';
3
- import { MissingDbError } from '../db';
3
+ import { MissingDbError } from '../db/types';
4
4
  import type { WorkflowStoreKind } from './types';
5
5
 
6
6
  export interface WorkflowSchema {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunqueue-dashboard",
3
- "version": "0.0.45",
3
+ "version": "0.0.47",
4
4
  "type": "module",
5
5
  "description": "Web dashboard for a bunqueue server (queues, jobs, DLQ, cron, metrics, workers, live activity). Run with `bunx bunqueue-dashboard` (requires Bun 1.4.2).",
6
6
  "packageManager": "bun@1.4.2",
@@ -66,7 +66,7 @@
66
66
  "docs:dev": "vitepress dev docs",
67
67
  "docs:build": "bun run scripts/build-docs.ts",
68
68
  "docs:preview": "vitepress preview docs",
69
- "build:bin": "bun run build && bun run scripts/gen-embed.ts && bun build scripts/serve.ts agent/dbQueryWorker.ts agent/backup/standaloneWorker.ts --compile --minify --outfile bunqueue-dashboard",
69
+ "build:bin": "bun run build && bun run scripts/gen-embed.ts && bun build scripts/serve.ts agent/dbReadWorker.ts agent/backup/standaloneWorker.ts --compile --minify --outfile bunqueue-dashboard",
70
70
  "preview": "vite preview",
71
71
  "test": "bun test",
72
72
  "test:e2e:flow": "bun run scripts/validate-flow-runtime.ts",
@@ -80,8 +80,11 @@
80
80
  "test:e2e:browser:serve": "bun e2e/runtime.ts",
81
81
  "test:e2e:browser:postgres-fleet": "bun run scripts/validate-postgres-fleet-dashboard-runtime.ts",
82
82
  "test:e2e:browser": "bun run build && bun scripts/gen-embed.ts && playwright test",
83
+ "test:binary": "bun scripts/validate-standalone-runtime.ts ./bunqueue-dashboard",
84
+ "test:runtime": "bun scripts/validate-standalone-runtime.ts --source",
85
+ "test:soak": "bun scripts/validate-resilience.ts",
83
86
  "test:package": "bun run scripts/validate-package-runtime.ts",
84
- "test:coverage": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir=coverage && bun scripts/check-coverage.ts && bun scripts/check-test-inventory.ts",
87
+ "test:coverage": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir=coverage && bun scripts/check-coverage.ts && bun scripts/check-critical-coverage.ts && bun scripts/check-test-inventory.ts",
85
88
  "lint": "oxlint . && bun run scripts/check-implicit-any-let.ts",
86
89
  "lint:fix": "oxlint --fix . && bun run scripts/check-implicit-any-let.ts",
87
90
  "format": "oxfmt .",
@@ -89,7 +92,7 @@
89
92
  "check": "bun run lint && bun run format:check",
90
93
  "check:fix": "bun run lint:fix && bun run format",
91
94
  "audit:high": "bun audit --audit-level=high --ignore=GHSA-qwww-vcr4-c8h2",
92
- "quality": "bun run version:check && bun run architecture && bun run check && bun run build && bun run size && bun run docs:build && bun run test:coverage && bun run test:e2e && bun run test:package && bun run audit:high",
95
+ "quality": "bun run version:check && bun run architecture && bun run check && bun run build && bun run size && bun run docs:build && bun run test:coverage && bun run test:e2e && bun run test:package && bun run test:runtime && bun run test:soak && bun run audit:high",
93
96
  "typecheck:e2e": "tsc -p tsconfig.e2e.json",
94
97
  "test:e2e:browser:managed": "bun run build && bun scripts/gen-embed.ts && playwright test -c playwright.managed.config.ts",
95
98
  "test:e2e:docs": "bun run docs:build && playwright test -c playwright.docs.config.ts"
package/scripts/serve.ts CHANGED
@@ -4,9 +4,10 @@
4
4
  * local control agent through a same-origin /agent bridge.
5
5
  */
6
6
  import { logger } from '../agent/logger';
7
+ import { compiledWorkerUrl } from '../agent/compiledRuntime';
8
+ import { agentConfigStore } from '../agent/manager/configStore';
7
9
  import { BunqueueBackupRunner } from '../agent/backup/runner';
8
10
  import { setBackupWorkerUrl } from '../agent/backup/workerFactory';
9
- import { setQueryWorkerUrl } from '../agent/db';
10
11
  import { QueueOperationsRuntime } from '../agent/queue/runtime';
11
12
  import {
12
13
  AgentLifecycleGate,
@@ -50,18 +51,7 @@ export {
50
51
  } from './servePolicy';
51
52
 
52
53
  async function main(): Promise<void> {
53
- // Compiled Bun entrypoints load the query worker from the embedded bundle.
54
- const compiled = import.meta.url.includes('/$bunfs/');
55
- setQueryWorkerUrl(
56
- compiled
57
- ? new URL('/$bunfs/root/agent/dbQueryWorker.js', 'file:///').href
58
- : new URL('../agent/dbQueryWorker.ts', import.meta.url).href
59
- );
60
- setBackupWorkerUrl(
61
- compiled
62
- ? new URL('/$bunfs/root/agent/backup/standaloneWorker.js', 'file:///').href
63
- : null
64
- );
54
+ setBackupWorkerUrl(compiledWorkerUrl(import.meta.url, 'agent/backup/standaloneWorker.js'));
65
55
 
66
56
  const port = Number(process.env.PORT) || 8080;
67
57
  const host = process.env.BIND_ADDR || '127.0.0.1';
@@ -109,7 +99,7 @@ async function main(): Promise<void> {
109
99
 
110
100
  // Keep ProcessManager out of policy-only test imports.
111
101
  const { ProcessManager } = await import('../agent/manager');
112
- const mgr = new ProcessManager();
102
+ const mgr = new ProcessManager(undefined, agentConfigStore());
113
103
  const workflowRuntime = new WorkflowRuntime();
114
104
  const backupRunner = new BunqueueBackupRunner();
115
105
  const queueRuntime = new QueueOperationsRuntime();
@@ -198,4 +188,9 @@ async function main(): Promise<void> {
198
188
  );
199
189
  }
200
190
 
201
- if (import.meta.main) await main();
191
+ if (import.meta.main) {
192
+ if (process.argv.includes('--bq-db-read')) {
193
+ const { runDatabaseReadProcess } = await import('../agent/db/readProcessMain');
194
+ await runDatabaseReadProcess();
195
+ } else await main();
196
+ }