bunqueue-dashboard 0.0.30 → 0.0.32

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 (84) hide show
  1. package/agent/db.ts +178 -40
  2. package/agent/dbQueryWorker.ts +14 -6
  3. package/agent/index.ts +4 -1
  4. package/agent/manager.ts +97 -12
  5. package/agent/server.ts +11 -5
  6. package/dist/.vite/manifest.json +200 -151
  7. package/dist/assets/AddJob-Dvpp--yq.js +3 -0
  8. package/dist/assets/{Alerts-GhRfE6uG.js → Alerts-CR9jgCCs.js} +1 -1
  9. package/dist/assets/{AreaChart-Dl_9G6GH.js → AreaChart-2gM0YoZT.js} +1 -1
  10. package/dist/assets/Benchmark-CkL-Ee-M.js +1 -0
  11. package/dist/assets/BulkAddJobs-wI6cRZnX.js +5 -0
  12. package/dist/assets/CopilotPanel-DPZV9opm.js +44 -0
  13. package/dist/assets/{Cron-BqKxXt_G.js → Cron-gWVkYWad.js} +1 -1
  14. package/dist/assets/CronManager-BvfW219u.js +1 -0
  15. package/dist/assets/Database-D2T_ZIGA.js +3 -0
  16. package/dist/assets/Diagnostics-C2goqq5h.js +1 -0
  17. package/dist/assets/Dlq-Q4TmmZrK.js +1 -0
  18. package/dist/assets/{DlqControl-B0VILOwI.js → DlqControl-C5BfE7Gq.js} +1 -1
  19. package/dist/assets/{DlqPro-D8POex8v.js → DlqPro-ClPNmw-X.js} +1 -1
  20. package/dist/assets/Flows-DbhMbvSG.js +1 -0
  21. package/dist/assets/JobInspector-CO_zKjSX.js +2 -0
  22. package/dist/assets/Jobs-D2rDHPM_.js +1 -0
  23. package/dist/assets/JobsPro-Drdgdj3s.js +1 -0
  24. package/dist/assets/Logs-DjwGuRLi.js +1 -0
  25. package/dist/assets/LogsPro-7uTBjPWX.js +1 -0
  26. package/dist/assets/{McpServer-CI9Z61Lm.js → McpServer-DAklQDB5.js} +1 -1
  27. package/dist/assets/{Metrics-qsVJt17r.js → Metrics-Dup9Zinh.js} +1 -1
  28. package/dist/assets/{MetricsPro-BMNObO4L.js → MetricsPro-PP3TymeP.js} +1 -1
  29. package/dist/assets/Overview-C7RCDE6r.js +1 -0
  30. package/dist/assets/{Pagination-EJ2HGoqv.js → Pagination-B9JDi5MI.js} +1 -1
  31. package/dist/assets/QueueActions-DJTw0h2g.js +1 -0
  32. package/dist/assets/{QueueControl-2NXIrJqi.js → QueueControl-CxKjUjF-.js} +1 -1
  33. package/dist/assets/{QueueDetail-7EEYk51J.js → QueueDetail-bf2n-r2h.js} +1 -1
  34. package/dist/assets/{QueueDetailPro-DDB6mrh6.js → QueueDetailPro-C-XLWFBa.js} +1 -1
  35. package/dist/assets/Queues-CMQW89J9.js +1 -0
  36. package/dist/assets/{QueuesOverview-CzbHoeuS.js → QueuesOverview-x7-akDzf.js} +1 -1
  37. package/dist/assets/{S3Backup-ETwjL2TD.js → S3Backup-1jAySC9R.js} +1 -1
  38. package/dist/assets/{S3BackupPro-DwX9b_1u.js → S3BackupPro-BG_0OHW7.js} +1 -1
  39. package/dist/assets/{ServerControl-BOh2ncLo.js → ServerControl-DohTB--h.js} +3 -3
  40. package/dist/assets/{Settings-_aPq2yDv.js → Settings-CnS5PjNe.js} +1 -1
  41. package/dist/assets/Usage-C3H0hMSA.js +1 -0
  42. package/dist/assets/{UsagePro-Dv0izJVc.js → UsagePro-CdnQkP9o.js} +1 -1
  43. package/dist/assets/Webhooks-CAwVmIEZ.js +1 -0
  44. package/dist/assets/{Workers-CXwdSXup.js → Workers-lvujKZwF.js} +1 -1
  45. package/dist/assets/{WorkersPro-BBnrgBbn.js → WorkersPro-CIJKl7U1.js} +1 -1
  46. package/dist/assets/{connectionStore-B-hNUqwK.js → connectionStore-DO6xQY7h.js} +1 -1
  47. package/dist/assets/dist-1f1fMHVO.js +11 -0
  48. package/dist/assets/dist-BlWBaCN6.js +11 -0
  49. package/dist/assets/dist-DZL8soAh.js +79 -0
  50. package/dist/assets/dist-DjvZiKhB.js +2 -0
  51. package/dist/assets/dist-DpZcbP9W.js +11 -0
  52. package/dist/assets/{exportFile-tdUeW5m5.js → exportFile-C7xhE8Yh.js} +1 -1
  53. package/dist/assets/{form-C-zZyKfB.js → form-DGinTEEb.js} +1 -1
  54. package/dist/assets/index-DcVOpnSj.css +1 -0
  55. package/dist/assets/index-r1ZMLIZ9.js +8 -0
  56. package/dist/assets/{install-DhTYJCUC.js → install-BIVfix28.js} +1 -1
  57. package/dist/assets/{react-vendor-B68RYDeC.js → react-vendor-BcouBxpZ.js} +2 -2
  58. package/dist/assets/useThroughputSeries-CVKzWzdv.js +1 -0
  59. package/dist/index.html +4 -4
  60. package/package.json +5 -5
  61. package/scripts/embedded.gen.ts +122 -112
  62. package/scripts/serve.ts +300 -88
  63. package/dist/assets/AddJob-Bsc2w6F0.js +0 -3
  64. package/dist/assets/Benchmark-Bc_ZEM87.js +0 -1
  65. package/dist/assets/BulkAddJobs-B9q81JAq.js +0 -5
  66. package/dist/assets/CopilotPanel-DL61f5jW.js +0 -152
  67. package/dist/assets/CronManager-DuWur13m.js +0 -1
  68. package/dist/assets/Database-DSpCA3w7.js +0 -3
  69. package/dist/assets/Diagnostics-DsBFg_-t.js +0 -1
  70. package/dist/assets/Dlq-BAQfqBgy.js +0 -1
  71. package/dist/assets/Flows-BEafLVu_.js +0 -1
  72. package/dist/assets/JobInspector-Du4zhHVo.js +0 -2
  73. package/dist/assets/Jobs-9VzulNrm.js +0 -1
  74. package/dist/assets/JobsPro-YebB40C-.js +0 -1
  75. package/dist/assets/Logs-DtV3k05m.js +0 -1
  76. package/dist/assets/LogsPro-C9VmsZL-.js +0 -1
  77. package/dist/assets/Overview-DtVeV_uf.js +0 -1
  78. package/dist/assets/QueueActions-qd4d3da3.js +0 -1
  79. package/dist/assets/Queues-ClEE2_8g.js +0 -1
  80. package/dist/assets/Usage-B6ESpkgj.js +0 -1
  81. package/dist/assets/Webhooks-Cs-DqyhB.js +0 -1
  82. package/dist/assets/index-BFRKPhvf.js +0 -8
  83. package/dist/assets/index-mF63w920.css +0 -1
  84. package/dist/assets/useThroughputSeries-BzUriOcf.js +0 -1
package/agent/db.ts CHANGED
@@ -9,14 +9,21 @@
9
9
  *
10
10
  * Defense in depth for the arbitrary-query endpoint (dbQuery):
11
11
  * - a positive statement allowlist rejects ATTACH/DETACH/CREATE-TEMP/etc.
12
- * BEFORE execution, so store safety never relies on Bun's single-statement
13
- * compilation quirk;
12
+ * before execution — but it inspects only the FIRST keyword, so it does NOT
13
+ * stop a trailing statement smuggled in after a legal one. Everything here
14
+ * must therefore compile SQL through db.query() (one statement, the rest
15
+ * inert) and never db.run() (executes them all). Do not weaken this comment
16
+ * into "the allowlist makes the store safe": believing that is what once
17
+ * turned the dup-column recovery path into an arbitrary file write;
14
18
  * - results are streamed with an early break at MAX_ROWS, so a huge or
15
19
  * row-bomb query can't materialize the whole set in agent memory;
16
20
  * - queryWithTimeout() runs it in a disposable Worker raced against a wall
17
21
  * clock, so an O(N^k) scan can't pin the agent's only thread and freeze the
18
22
  * process-control endpoints (falls back to synchronous run if a Worker can't
19
- * be spawned, e.g. inside a compiled single-file binary).
23
+ * be spawned, e.g. inside a compiled single-file binary). The timeout aborts
24
+ * the response, not the query — a synchronous sqlite3_step never yields to
25
+ * the terminate() request — so live worker threads are counted and capped by
26
+ * MAX_CONCURRENT_QUERIES instead of accumulating one pinned core per request.
20
27
  */
21
28
  import { existsSync } from 'node:fs';
22
29
  import { Database } from 'bun:sqlite';
@@ -144,10 +151,29 @@ function isTruncated(v: unknown): boolean {
144
151
  function cell(v: unknown): unknown {
145
152
  if (v instanceof Uint8Array) return `<blob ${v.byteLength} B>`;
146
153
  if (typeof v === 'string' && v.length > MAX_CELL) return `${v.slice(0, MAX_CELL)}…`;
147
- if (typeof v === 'bigint') return v.toString();
154
+ // Read statements run with safeIntegers, so INTEGERs arrive as bigint: keep the
155
+ // familiar number shape when it round-trips exactly, and fall back to a string
156
+ // beyond 2^53 (where a double would silently round the stored value).
157
+ if (typeof v === 'bigint') return bigintCell(v);
148
158
  return v;
149
159
  }
150
160
 
161
+ /** Exact-value-preserving JSON form of a SQLite INTEGER read with safeIntegers. */
162
+ function bigintCell(v: bigint): number | string {
163
+ return v >= BigInt(Number.MIN_SAFE_INTEGER) && v <= BigInt(Number.MAX_SAFE_INTEGER)
164
+ ? Number(v)
165
+ : v.toString();
166
+ }
167
+
168
+ /**
169
+ * bun:sqlite exposes Statement.safeIntegers() at runtime but does not type it.
170
+ * Enabled per statement (never connection-wide) so the COUNT(*) reads that are
171
+ * consumed as numbers keep returning numbers.
172
+ */
173
+ function safeIntegers(stmt: unknown): void {
174
+ (stmt as { safeIntegers?: (on: boolean) => void }).safeIntegers?.(true);
175
+ }
176
+
151
177
  const sanitize = (rows: unknown[][]): unknown[][] => rows.map((r) => r.map(cell));
152
178
 
153
179
  /** All user tables with row + column counts. */
@@ -189,8 +215,14 @@ function buildFilter(
189
215
  const q = ident(col);
190
216
  if (filter.op === 'eq') return { clause: ` WHERE ${q} = ?`, params: [filter.value], applied: filter };
191
217
  if (filter.op === 'ne') return { clause: ` WHERE ${q} <> ?`, params: [filter.value], applied: filter };
192
- // contains (default)
193
- return { clause: ` WHERE ${q} LIKE ?`, params: [`%${filter.value}%`], applied: { ...filter, op: 'contains' } };
218
+ // contains (default) — escape LIKE metacharacters so a value containing % or _
219
+ // matches literally instead of turning into a wildcard pattern.
220
+ const needle = filter.value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_');
221
+ return {
222
+ clause: ` WHERE ${q} LIKE ? ESCAPE '\\'`,
223
+ params: [`%${needle}%`],
224
+ applied: { ...filter, op: 'contains' },
225
+ };
194
226
  }
195
227
 
196
228
  /**
@@ -208,7 +240,11 @@ export function dbRows(
208
240
  filter?: DbFilter
209
241
  ): DbRowsPage {
210
242
  const lim = Math.max(1, Math.min(MAX_ROWS, Math.floor(limit) || 50));
211
- const off = Math.max(0, Math.floor(offset) || 0);
243
+ // Non-finite / out-of-range offsets must be clamped here: SQLite rejects a
244
+ // bound non-safe-integer double with a raw "datatype mismatch".
245
+ const off = Number.isFinite(offset)
246
+ ? Math.max(0, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(offset) || 0))
247
+ : 0;
212
248
  const db = open(path);
213
249
  try {
214
250
  const name = knownTable(db, table);
@@ -235,15 +271,19 @@ export function dbRows(
235
271
  let rowids: (number | null)[] = [];
236
272
  let rawRows: unknown[][];
237
273
  try {
238
- const withRowid = db
239
- .query(`SELECT rowid AS __rid, * FROM ${ident(name)}${clause}${order} LIMIT ? OFFSET ?`)
240
- .values(...(params as []), lim, off) as unknown[][];
241
- rowids = withRowid.map((r) => (typeof r[0] === 'number' ? r[0] : null));
274
+ const stmt = db.query(
275
+ `SELECT rowid AS __rid, * FROM ${ident(name)}${clause}${order} LIMIT ? OFFSET ?`
276
+ );
277
+ safeIntegers(stmt);
278
+ const withRowid = stmt.values(...(params as []), lim, off) as unknown[][];
279
+ rowids = withRowid.map((r) =>
280
+ typeof r[0] === 'bigint' ? Number(r[0]) : typeof r[0] === 'number' ? r[0] : null
281
+ );
242
282
  rawRows = withRowid.map((r) => r.slice(1));
243
283
  } catch {
244
- rawRows = db
245
- .query(`SELECT * FROM ${ident(name)}${clause}${order} LIMIT ? OFFSET ?`)
246
- .values(...(params as []), lim, off) as unknown[][];
284
+ const stmt = db.query(`SELECT * FROM ${ident(name)}${clause}${order} LIMIT ? OFFSET ?`);
285
+ safeIntegers(stmt);
286
+ rawRows = stmt.values(...(params as []), lim, off) as unknown[][];
247
287
  rowids = rawRows.map(() => null);
248
288
  }
249
289
 
@@ -276,13 +316,13 @@ export function dbCell(path: string, table: string, rowid: number, column: strin
276
316
  );
277
317
  const col = columns.find((c) => c === column);
278
318
  if (!col) throw new Error(`No such column: ${column}`);
279
- const row = db
280
- .query(`SELECT ${ident(col)} AS v FROM ${ident(name)} WHERE rowid = ?`)
281
- .get(rowid) as { v: unknown } | null;
319
+ const stmt = db.query(`SELECT ${ident(col)} AS v FROM ${ident(name)} WHERE rowid = ?`);
320
+ safeIntegers(stmt);
321
+ const row = stmt.get(rowid) as { v: unknown } | null;
282
322
  if (!row) throw new Error('Row not found');
283
323
  let v = row.v;
284
324
  if (v instanceof Uint8Array) v = `<blob ${v.byteLength} B — binary, not shown>`;
285
- else if (typeof v === 'bigint') v = v.toString();
325
+ else if (typeof v === 'bigint') v = bigintCell(v);
286
326
  else if (typeof v === 'string' && v.length > MAX_FULL_CELL) v = `${v.slice(0, MAX_FULL_CELL)}…`;
287
327
  return { value: v };
288
328
  } finally {
@@ -375,6 +415,38 @@ function leadingKeyword(sql: string): string {
375
415
 
376
416
  const READ_ONLY_LEAD = /^(?:SELECT|WITH|EXPLAIN|VALUES|PRAGMA)\b/;
377
417
 
418
+ /** Temp-view name used to recover colliding output column names (see dbQuery). */
419
+ const DUP_VIEW = '__bq_query_columns';
420
+
421
+ /**
422
+ * Re-prepare a statement whose output column names collide through a TEMP VIEW,
423
+ * so SQLite disambiguates them itself ("id", "id:1") and every column survives
424
+ * the by-name row rebuild. The view lives in the private temp schema (nothing is
425
+ * written to the readonly store) and dies with the connection. Returns null when
426
+ * the statement can't be a view (PRAGMA / EXPLAIN — neither can collide anyway).
427
+ */
428
+ function disambiguateColumns(
429
+ db: Database,
430
+ sql: string
431
+ ): { stmt: ReturnType<Database['query']>; cols: string[] } | null {
432
+ try {
433
+ // MUST be query().run() (single prepared statement), never db.run(): db.run
434
+ // executes EVERY statement in the string, and the front-door allowlist only
435
+ // inspects the FIRST keyword. `SELECT 1 a, 2 a; VACUUM INTO '/tmp/x'` would
436
+ // otherwise reach a readonly connection as an arbitrary-file WRITE, and
437
+ // `…; ATTACH '/any.db' AS l; DROP VIEW …; CREATE TEMP VIEW … AS SELECT * FROM l.x`
438
+ // as an arbitrary-SQLite-file READ. query() compiles only the first
439
+ // statement, so the trailing payload is inert.
440
+ db.query(`CREATE TEMP VIEW ${ident(DUP_VIEW)} AS ${sql.replace(/;\s*$/, '')}`).run();
441
+ const cols = (
442
+ db.query(`PRAGMA table_info(${ident(DUP_VIEW)})`).all() as { name: string }[]
443
+ ).map((c) => c.name);
444
+ return { stmt: db.query(`SELECT * FROM ${ident(DUP_VIEW)}`), cols };
445
+ } catch {
446
+ return null;
447
+ }
448
+ }
449
+
378
450
  /**
379
451
  * Run an arbitrary read-only query. Rejects non-read statements up front,
380
452
  * streams rows with an early break at MAX_ROWS, and translates the raw SQLite
@@ -396,7 +468,18 @@ export function dbQuery(path: string, sql: string): DbQueryResult {
396
468
  } catch (e) {
397
469
  throw translateError(e);
398
470
  }
399
- const cols = stmt.columnNames;
471
+ let cols = stmt.columnNames;
472
+ // Bun de-duplicates columnNames while columnTypes keeps the true arity, and
473
+ // rows are rebuilt BY NAME below — so without this, a statement with colliding
474
+ // output names (`SELECT a.id, b.id`, any self-join) silently loses columns.
475
+ if (stmt.columnTypes.length > cols.length) {
476
+ const wide = disambiguateColumns(db, trimmed);
477
+ if (wide) {
478
+ stmt = wide.stmt;
479
+ cols = wide.cols;
480
+ }
481
+ }
482
+ safeIntegers(stmt);
400
483
  const rows: unknown[][] = [];
401
484
  let truncated = false;
402
485
  try {
@@ -436,52 +519,107 @@ function translateError(e: unknown): Error {
436
519
  /**
437
520
  * Time-boxed, off-thread execution of an arbitrary query. Spawns a disposable
438
521
  * readonly Worker and races it against QUERY_TIMEOUT_MS so a runaway scan can't
439
- * freeze the agent's control endpoints. Falls back to a synchronous run when a
440
- * Worker cannot be created (e.g. a compiled single-file binary) — still bounded
441
- * in memory and statement type, just not interruptible.
522
+ * freeze the agent's control endpoints.
523
+ *
524
+ * The timeout aborts the RESPONSE, not the query: terminate() is delivered to
525
+ * the worker's event loop, which a synchronous sqlite3_step never yields back
526
+ * to, so a runaway scan keeps burning its own thread until SQLite finishes it.
527
+ * That thread is therefore accounted for (a slot is held until the worker really
528
+ * exits) and capped by MAX_CONCURRENT_QUERIES, so N timed-out queries can pin at
529
+ * most MAX_CONCURRENT_QUERIES cores instead of one core per request, forever.
442
530
  */
531
+ let queryWorkerUrl = new URL('./dbQueryWorker.ts', import.meta.url).href;
532
+
533
+ /** Hard cap on query worker threads alive at once (see queryWithTimeout). */
534
+ export const MAX_CONCURRENT_QUERIES = 2;
535
+ let liveQueryWorkers = 0;
536
+
537
+ /** Query worker threads currently alive — includes timed-out, still-running ones. */
538
+ export const queryWorkerLoad = (): number => liveQueryWorkers;
539
+
540
+ /**
541
+ * The standalone executable's bundle root is scripts/serve.ts, so it supplies
542
+ * the embedded worker URL relative to that entrypoint. Source-mode agent runs
543
+ * keep the module-relative default above.
544
+ */
545
+ export function setQueryWorkerUrl(url: string): void {
546
+ queryWorkerUrl = url;
547
+ }
548
+
549
+ const spawnRealWorker = (): Worker => new Worker(queryWorkerUrl, { type: 'module' });
550
+ let queryWorkerFactory: () => Worker = spawnRealWorker;
551
+
552
+ /**
553
+ * Swap how a query worker is constructed. Tests only: spawning a real Bun
554
+ * Worker in a process where happy-dom's globals are installed panics the
555
+ * runtime (allocator abort), so the timeout/accounting behaviour below is
556
+ * exercised through a stand-in worker instead of a real thread. Pass null to
557
+ * restore the real one.
558
+ */
559
+ export function setQueryWorkerFactory(factory: (() => Worker) | null): void {
560
+ queryWorkerFactory = factory ?? spawnRealWorker;
561
+ }
562
+
443
563
  export async function queryWithTimeout(path: string, sql: string): Promise<DbQueryResult> {
564
+ if (liveQueryWorkers >= MAX_CONCURRENT_QUERIES) {
565
+ throw new Error(
566
+ `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.`
567
+ );
568
+ }
444
569
  let worker: Worker;
445
570
  try {
446
- worker = new Worker(new URL('./dbQueryWorker.ts', import.meta.url).href, { type: 'module' });
447
- } catch {
448
- return dbQuery(path, sql); // no Worker available — degrade to synchronous
571
+ worker = queryWorkerFactory();
572
+ } catch (e) {
573
+ throw new Error(`Query worker unavailable: ${(e as Error).message ?? String(e)}`);
449
574
  }
575
+ liveQueryWorkers++;
576
+ let released = false;
577
+ const release = () => {
578
+ if (released) return;
579
+ released = true;
580
+ liveQueryWorkers--;
581
+ };
582
+ // A worker that never answers still holds its slot until the thread really
583
+ // exits ('close' fires when SQLite finally returns) — that is the whole point.
584
+ worker.addEventListener('close', release);
450
585
  try {
451
586
  return await new Promise<DbQueryResult>((resolve, reject) => {
452
- // The Worker module is not always available at runtime — notably a
453
- // `bun build --compile` single-file binary does not embed it, and there
454
- // `new Worker(...)` does NOT throw synchronously: it resolves and then
455
- // fails asynchronously via the `error` event (ModuleNotFound). Guard both
456
- // paths and degrade to a synchronous run rather than failing the request.
457
587
  let settled = false;
458
588
  const timer = setTimeout(() => {
459
589
  if (settled) return;
460
590
  settled = true;
461
591
  worker.terminate();
462
- reject(new Error(`Query exceeded the ${QUERY_TIMEOUT_MS / 1000}s time limit and was aborted.`));
592
+ reject(
593
+ new Error(
594
+ `Query exceeded the ${QUERY_TIMEOUT_MS / 1000}s time limit and was abandoned (it may keep running inside SQLite until it completes).`
595
+ )
596
+ );
463
597
  }, QUERY_TIMEOUT_MS);
464
598
  worker.addEventListener('message', (ev: MessageEvent) => {
465
599
  if (settled) return;
466
600
  settled = true;
467
601
  clearTimeout(timer);
468
- const d = ev.data as { ok: boolean; result?: DbQueryResult; error?: string };
602
+ // The query is done — its thread is idle, so free the slot immediately.
603
+ release();
604
+ const d = ev.data as {
605
+ ok: boolean;
606
+ result?: DbQueryResult;
607
+ error?: string;
608
+ missing?: boolean;
609
+ };
469
610
  if (d.ok && d.result) resolve(d.result);
611
+ // The worker boundary is a string channel, so the error CLASS is carried
612
+ // as a flag — server.ts maps MissingDbError to 404, everything else 400.
613
+ else if (d.missing) reject(new MissingDbError(d.error ?? 'Database not found'));
470
614
  else reject(new Error(d.error ?? 'Query failed'));
471
615
  });
472
616
  worker.addEventListener('error', (ev: ErrorEvent) => {
473
617
  if (settled) return;
474
618
  settled = true;
475
619
  clearTimeout(timer);
476
- // Worker infra itself failed to load/run (e.g. not embedded in the
477
- // compiled binary). Fall back to the synchronous path — still readonly,
478
- // allowlisted and row-capped, just not interruptible by the wall clock.
620
+ release();
479
621
  ev.preventDefault?.();
480
- try {
481
- resolve(dbQuery(path, sql));
482
- } catch (e) {
483
- reject(translateError(e));
484
- }
622
+ reject(new Error(`Query worker failed: ${ev.message || 'unknown worker error'}`));
485
623
  });
486
624
  worker.postMessage({ path, sql });
487
625
  });
@@ -2,12 +2,14 @@
2
2
  * Disposable worker that runs ONE read-only query and posts the result back.
3
3
  *
4
4
  * queryWithTimeout() (agent/db.ts) spawns one of these per /db/query request and
5
- * races it against a wall clock; on timeout the parent terminate()s this worker,
6
- * so a runaway O(N^k) scan is killed instead of pinning the agent's only thread
7
- * and freezing the process-control endpoints. The connection is opened readonly
8
- * inside dbQuery, so nothing here can mutate the store.
5
+ * races it against a wall clock, so a runaway O(N^k) scan can't freeze the
6
+ * agent's process-control endpoints. terminate() does NOT preempt a synchronous
7
+ * sqlite3_step, though — it abandons the response, and this thread keeps running
8
+ * until SQLite finishes, which is why db.ts caps how many can be alive at once.
9
+ * The connection is opened readonly inside dbQuery, so nothing here can mutate
10
+ * the store.
9
11
  */
10
- import { dbQuery } from './db';
12
+ import { dbQuery, MissingDbError } from './db';
11
13
 
12
14
  declare const self: Worker;
13
15
 
@@ -16,6 +18,12 @@ self.addEventListener('message', (ev: MessageEvent) => {
16
18
  try {
17
19
  self.postMessage({ ok: true, result: dbQuery(path, sql) });
18
20
  } catch (e) {
19
- self.postMessage({ ok: false, error: (e as Error).message ?? String(e) });
21
+ // postMessage can't carry the error class, so flag the one the route branches
22
+ // on (MissingDbError → 404) and let db.ts rebuild it on the parent side.
23
+ self.postMessage({
24
+ ok: false,
25
+ error: (e as Error).message ?? String(e),
26
+ missing: e instanceof MissingDbError,
27
+ });
20
28
  }
21
29
  });
package/agent/index.ts CHANGED
@@ -41,7 +41,10 @@ const shutdown = (signal: string) => {
41
41
  if (shuttingDown) return;
42
42
  shuttingDown = true;
43
43
  logger.info({ signal }, 'signal received, stopping managed server');
44
- void mgr.stop().finally(() => process.exit(0));
44
+ // shutdown() (not stop()) latches the manager closed first: a plain stop()
45
+ // racing an in-flight restart() returns successfully *because* restart's
46
+ // start() already spawned a replacement — which process.exit() would orphan.
47
+ void mgr.shutdown().finally(() => process.exit(0));
45
48
  };
46
49
  process.on('SIGINT', () => shutdown('SIGINT'));
47
50
  process.on('SIGTERM', () => shutdown('SIGTERM'));
package/agent/manager.ts CHANGED
@@ -48,6 +48,13 @@ export interface StatusSnapshot {
48
48
 
49
49
  const MAX_LOGS = 800;
50
50
  const STOP_TIMEOUT_MS = 8000;
51
+ /**
52
+ * Hard byte cap for a single log line. The ring buffer trims by COUNT, so a
53
+ * child that dumps a large blob without a newline (binary file, one-line JSON)
54
+ * would otherwise grow the reader's in-flight buffer — and then one retained
55
+ * LogLine — without limit.
56
+ */
57
+ const MAX_LINE = 8192;
51
58
 
52
59
  function defaultConfig(): ServerConfig {
53
60
  return {
@@ -75,6 +82,15 @@ export class ProcessManager {
75
82
  * a concurrent start() brought up (and vice-versa).
76
83
  */
77
84
  private procToken = 0;
85
+ /**
86
+ * The stop() currently in flight (null when none). A stop() still owns a live
87
+ * child until its await resolves, so start() waits on this before spawning —
88
+ * otherwise a start racing a stop leaves two managed servers alive at once,
89
+ * fighting over the ports and the SQLite db.
90
+ */
91
+ private stopping: Promise<StatusSnapshot> | null = null;
92
+ /** Latched by shutdown(): once set, start() refuses to spawn a new child. */
93
+ private shuttingDown = false;
78
94
 
79
95
  getConfig(): ServerConfig {
80
96
  return this.config;
@@ -135,31 +151,52 @@ export class ProcessManager {
135
151
  }
136
152
 
137
153
  private push(stream: LogLine['stream'], line: string): void {
138
- this.logs.push({ seq: this.seq++, ts: Date.now(), stream, line });
154
+ // Backstop byte cap (the reader also splits oversized chunks) so no single
155
+ // entry can pin an unbounded amount of memory in the ring buffer.
156
+ const capped = line.length > MAX_LINE ? `${line.slice(0, MAX_LINE)}…[truncated]` : line;
157
+ this.logs.push({ seq: this.seq++, ts: Date.now(), stream, line: capped });
139
158
  // Amortized trim: only splice once the buffer overshoots by a slack margin, so
140
159
  // steady-state logging isn't an O(n) array shift on every single line.
141
160
  if (this.logs.length > MAX_LOGS + 256) this.logs.splice(0, this.logs.length - MAX_LOGS);
142
161
  }
143
162
 
144
163
  async start(): Promise<StatusSnapshot> {
164
+ if (this.shuttingDown) return this.getStatus();
145
165
  if (this.status === 'running' || this.status === 'starting') return this.getStatus();
166
+ // Never spawn while a stop() still owns a live child: wait it out, then
167
+ // re-check the guards (a concurrent start may have won the race meanwhile).
168
+ if (this.stopping) await this.stopping;
169
+ if (this.shuttingDown) return this.getStatus();
170
+ // Read through a local: TS narrowed `this.status` at the guard above and
171
+ // doesn't widen it across the await, but a concurrent start() can have
172
+ // moved it in the meantime — that interleaving is exactly what this checks.
173
+ const statusAfterWait = this.status as Status;
174
+ if (statusAfterWait === 'running' || statusAfterWait === 'starting') return this.getStatus();
175
+
176
+ // Validate BEFORE mutating status / burning a proc token. Doing it after
177
+ // (as this once did) means a bad command leaves the manager wedged: an
178
+ // empty command stranded the previous generation's dead pid + runningConfig
179
+ // (its stop()/onExit finalizers were already token-stale), and a non-string
180
+ // `command` from PUT /control/config threw out of `.trim()` with status
181
+ // pinned at 'starting', disabling every UI control until the agent restarts.
182
+ const command = typeof this.config.command === 'string' ? this.config.command : '';
183
+ const parts = command.trim().split(/\s+/);
184
+ const [cmd, ...args] = parts;
185
+ if (!cmd) throw new Error('Empty command');
186
+
146
187
  this.status = 'starting';
147
188
  this.exitCode = null;
148
189
  const token = ++this.procToken;
149
190
 
150
- const parts = this.config.command.trim().split(/\s+/);
151
- const [cmd, ...args] = parts;
152
- if (!cmd) {
153
- this.status = 'stopped';
154
- throw new Error('Empty command');
155
- }
156
-
191
+ // extraEnv is spread FIRST so the explicit port/data-path config always
192
+ // wins: otherwise a user env var named HTTP_PORT silently moved the child
193
+ // while runningConfig (and the agent's /health probe) reported the old port.
157
194
  const env: Record<string, string> = {
158
195
  ...(process.env as Record<string, string>),
196
+ ...this.config.extraEnv,
159
197
  HTTP_PORT: String(this.config.httpPort),
160
198
  TCP_PORT: String(this.config.tcpPort),
161
199
  BUNQUEUE_DATA_PATH: this.config.dataPath,
162
- ...this.config.extraEnv,
163
200
  };
164
201
 
165
202
  try {
@@ -214,6 +251,12 @@ export class ProcessManager {
214
251
  buffer = buffer.slice(i + 1);
215
252
  if (line.trim()) this.push(name, line);
216
253
  }
254
+ // No newline in sight: flush in MAX_LINE slices instead of letting the
255
+ // buffer (and the eventual single LogLine) grow to the whole output.
256
+ while (buffer.length > MAX_LINE) {
257
+ this.push(name, buffer.slice(0, MAX_LINE));
258
+ buffer = buffer.slice(MAX_LINE);
259
+ }
217
260
  }
218
261
  } catch {
219
262
  /* stream closed */
@@ -226,6 +269,29 @@ export class ProcessManager {
226
269
  }
227
270
 
228
271
  async stop(): Promise<StatusSnapshot> {
272
+ // Publish the in-flight stop so a racing start() can await it (see
273
+ // `stopping`). stopOnce() runs its synchronous prefix — including
274
+ // status='stopping' and the SIGTERM — before we return the promise.
275
+ const p = this.stopOnce();
276
+ this.stopping = p;
277
+ try {
278
+ return await p;
279
+ } finally {
280
+ if (this.stopping === p) this.stopping = null;
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Stop the managed server and latch the manager closed, so a start() racing
286
+ * process shutdown (e.g. an in-flight restart() whose stop() resolves after
287
+ * SIGINT) cannot spawn a child that would be orphaned by process.exit().
288
+ */
289
+ async shutdown(): Promise<StatusSnapshot> {
290
+ this.shuttingDown = true;
291
+ return this.stop();
292
+ }
293
+
294
+ private async stopOnce(): Promise<StatusSnapshot> {
229
295
  if (!this.proc) {
230
296
  this.status = 'stopped';
231
297
  this.runningConfig = null;
@@ -236,17 +302,36 @@ export class ProcessManager {
236
302
  const proc = this.proc;
237
303
  const token = this.procToken;
238
304
  proc.kill();
305
+ // Timer cleared either way (this is the COMMON path): a pending one keeps
306
+ // the event loop alive for the full timeout after the race is decided.
307
+ let stopTimer: ReturnType<typeof setTimeout> | undefined;
239
308
  const timedOut = await Promise.race([
240
309
  proc.exited.then(() => false),
241
- new Promise<boolean>((r) => setTimeout(() => r(true), STOP_TIMEOUT_MS)),
242
- ]);
310
+ new Promise<boolean>((r) => {
311
+ stopTimer = setTimeout(() => r(true), STOP_TIMEOUT_MS);
312
+ }),
313
+ ]).finally(() => clearTimeout(stopTimer));
243
314
  // Always escalate to SIGKILL on the CAPTURED proc (never `this.proc`) so a
244
315
  // child that ignores SIGTERM is still reaped even if a concurrent start()
245
316
  // has since swapped in a new process — `proc` targets this generation only.
246
317
  if (timedOut) {
247
318
  this.push('sys', 'SIGTERM timed out, sending SIGKILL');
248
319
  proc.kill(9);
249
- await proc.exited;
320
+ // Bounded: a child wedged in uninterruptible sleep never resolves
321
+ // `exited` even after SIGKILL, and start() now awaits this promise, so an
322
+ // unbounded wait here would pin the manager at 'stopping' and hang every
323
+ // later start() forever. Give up on the wait (not on the kill) and let the
324
+ // state below finalize — the pid is already unusable. The timer is always
325
+ // cleared: a pending one keeps the event loop alive after the race is
326
+ // decided, which delays process exit for the whole timeout.
327
+ let reapTimer: ReturnType<typeof setTimeout> | undefined;
328
+ const reaped = await Promise.race([
329
+ proc.exited.then(() => true),
330
+ new Promise<boolean>((r) => {
331
+ reapTimer = setTimeout(() => r(false), STOP_TIMEOUT_MS);
332
+ }),
333
+ ]).finally(() => clearTimeout(reapTimer));
334
+ if (!reaped) this.push('sys', `pid ${proc.pid} did not exit after SIGKILL — giving up on it`);
250
335
  }
251
336
  // Only finalize shared state if no newer start() replaced this generation
252
337
  // while we awaited — otherwise we'd null out the wrong (live) process.
package/agent/server.ts CHANGED
@@ -65,7 +65,13 @@ export function hostnameOf(host: string): string {
65
65
  const v6 = s.match(/^\[([^\]]+)\]/); // [::1]:6800 → ::1
66
66
  if (v6) return v6[1].toLowerCase();
67
67
  const i = s.indexOf(':');
68
- return (i === -1 ? s : s.slice(0, i)).toLowerCase();
68
+ if (i === -1) return s.toLowerCase();
69
+ // A bare (unbracketed) IPv6 literal has several colons and no port delimiter:
70
+ // splitting on the first one would both lock the real host out and allowlist a
71
+ // bogus single label ('2001:db8::5' → '2001'). Keep it whole — that also makes
72
+ // hostnameOf idempotent over its own bracket-stripped output.
73
+ if (s.indexOf(':') !== s.lastIndexOf(':')) return s.toLowerCase();
74
+ return s.slice(0, i).toLowerCase();
69
75
  }
70
76
 
71
77
  /**
@@ -180,10 +186,10 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
180
186
  // GETs (including the /db/* inspector, which can expose job payloads) are
181
187
  // protected by the Origin allowlist alone — a drive-by cross-origin page
182
188
  // can't read the response (no ACAO for its origin), which is the real
183
- // threat. Gating reads behind the token would break the dashboard itself:
184
- // the browser client sends no agent token (see bq.ts `agent()`), so the
185
- // whole point of loopback + allowlisted CORS is to make browser reads work
186
- // without one. Non-browser local callers on loopback are trusted for reads.
189
+ // threat. Reads intentionally remain available without a token for local
190
+ // dashboards that don't configure AGENT_TOKEN; when a token is configured,
191
+ // the browser client does send it, but only POST/PUT require it.
192
+ // Non-browser local callers on loopback are trusted for reads.
187
193
  const mutating = method === 'POST' || method === 'PUT';
188
194
  if (mutating && !tokenOk(req, token)) {
189
195
  return json({ ok: false, error: 'Unauthorized' }, 401, origin);