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.
- package/agent/db.ts +178 -40
- package/agent/dbQueryWorker.ts +14 -6
- package/agent/index.ts +4 -1
- package/agent/manager.ts +97 -12
- package/agent/server.ts +11 -5
- package/dist/.vite/manifest.json +200 -151
- package/dist/assets/AddJob-Dvpp--yq.js +3 -0
- package/dist/assets/{Alerts-GhRfE6uG.js → Alerts-CR9jgCCs.js} +1 -1
- package/dist/assets/{AreaChart-Dl_9G6GH.js → AreaChart-2gM0YoZT.js} +1 -1
- package/dist/assets/Benchmark-CkL-Ee-M.js +1 -0
- package/dist/assets/BulkAddJobs-wI6cRZnX.js +5 -0
- package/dist/assets/CopilotPanel-DPZV9opm.js +44 -0
- package/dist/assets/{Cron-BqKxXt_G.js → Cron-gWVkYWad.js} +1 -1
- package/dist/assets/CronManager-BvfW219u.js +1 -0
- package/dist/assets/Database-D2T_ZIGA.js +3 -0
- package/dist/assets/Diagnostics-C2goqq5h.js +1 -0
- package/dist/assets/Dlq-Q4TmmZrK.js +1 -0
- package/dist/assets/{DlqControl-B0VILOwI.js → DlqControl-C5BfE7Gq.js} +1 -1
- package/dist/assets/{DlqPro-D8POex8v.js → DlqPro-ClPNmw-X.js} +1 -1
- package/dist/assets/Flows-DbhMbvSG.js +1 -0
- package/dist/assets/JobInspector-CO_zKjSX.js +2 -0
- package/dist/assets/Jobs-D2rDHPM_.js +1 -0
- package/dist/assets/JobsPro-Drdgdj3s.js +1 -0
- package/dist/assets/Logs-DjwGuRLi.js +1 -0
- package/dist/assets/LogsPro-7uTBjPWX.js +1 -0
- package/dist/assets/{McpServer-CI9Z61Lm.js → McpServer-DAklQDB5.js} +1 -1
- package/dist/assets/{Metrics-qsVJt17r.js → Metrics-Dup9Zinh.js} +1 -1
- package/dist/assets/{MetricsPro-BMNObO4L.js → MetricsPro-PP3TymeP.js} +1 -1
- package/dist/assets/Overview-C7RCDE6r.js +1 -0
- package/dist/assets/{Pagination-EJ2HGoqv.js → Pagination-B9JDi5MI.js} +1 -1
- package/dist/assets/QueueActions-DJTw0h2g.js +1 -0
- package/dist/assets/{QueueControl-2NXIrJqi.js → QueueControl-CxKjUjF-.js} +1 -1
- package/dist/assets/{QueueDetail-7EEYk51J.js → QueueDetail-bf2n-r2h.js} +1 -1
- package/dist/assets/{QueueDetailPro-DDB6mrh6.js → QueueDetailPro-C-XLWFBa.js} +1 -1
- package/dist/assets/Queues-CMQW89J9.js +1 -0
- package/dist/assets/{QueuesOverview-CzbHoeuS.js → QueuesOverview-x7-akDzf.js} +1 -1
- package/dist/assets/{S3Backup-ETwjL2TD.js → S3Backup-1jAySC9R.js} +1 -1
- package/dist/assets/{S3BackupPro-DwX9b_1u.js → S3BackupPro-BG_0OHW7.js} +1 -1
- package/dist/assets/{ServerControl-BOh2ncLo.js → ServerControl-DohTB--h.js} +3 -3
- package/dist/assets/{Settings-_aPq2yDv.js → Settings-CnS5PjNe.js} +1 -1
- package/dist/assets/Usage-C3H0hMSA.js +1 -0
- package/dist/assets/{UsagePro-Dv0izJVc.js → UsagePro-CdnQkP9o.js} +1 -1
- package/dist/assets/Webhooks-CAwVmIEZ.js +1 -0
- package/dist/assets/{Workers-CXwdSXup.js → Workers-lvujKZwF.js} +1 -1
- package/dist/assets/{WorkersPro-BBnrgBbn.js → WorkersPro-CIJKl7U1.js} +1 -1
- package/dist/assets/{connectionStore-B-hNUqwK.js → connectionStore-DO6xQY7h.js} +1 -1
- package/dist/assets/dist-1f1fMHVO.js +11 -0
- package/dist/assets/dist-BlWBaCN6.js +11 -0
- package/dist/assets/dist-DZL8soAh.js +79 -0
- package/dist/assets/dist-DjvZiKhB.js +2 -0
- package/dist/assets/dist-DpZcbP9W.js +11 -0
- package/dist/assets/{exportFile-tdUeW5m5.js → exportFile-C7xhE8Yh.js} +1 -1
- package/dist/assets/{form-C-zZyKfB.js → form-DGinTEEb.js} +1 -1
- package/dist/assets/index-DcVOpnSj.css +1 -0
- package/dist/assets/index-r1ZMLIZ9.js +8 -0
- package/dist/assets/{install-DhTYJCUC.js → install-BIVfix28.js} +1 -1
- package/dist/assets/{react-vendor-B68RYDeC.js → react-vendor-BcouBxpZ.js} +2 -2
- package/dist/assets/useThroughputSeries-CVKzWzdv.js +1 -0
- package/dist/index.html +4 -4
- package/package.json +5 -5
- package/scripts/embedded.gen.ts +122 -112
- package/scripts/serve.ts +300 -88
- package/dist/assets/AddJob-Bsc2w6F0.js +0 -3
- package/dist/assets/Benchmark-Bc_ZEM87.js +0 -1
- package/dist/assets/BulkAddJobs-B9q81JAq.js +0 -5
- package/dist/assets/CopilotPanel-DL61f5jW.js +0 -152
- package/dist/assets/CronManager-DuWur13m.js +0 -1
- package/dist/assets/Database-DSpCA3w7.js +0 -3
- package/dist/assets/Diagnostics-DsBFg_-t.js +0 -1
- package/dist/assets/Dlq-BAQfqBgy.js +0 -1
- package/dist/assets/Flows-BEafLVu_.js +0 -1
- package/dist/assets/JobInspector-Du4zhHVo.js +0 -2
- package/dist/assets/Jobs-9VzulNrm.js +0 -1
- package/dist/assets/JobsPro-YebB40C-.js +0 -1
- package/dist/assets/Logs-DtV3k05m.js +0 -1
- package/dist/assets/LogsPro-C9VmsZL-.js +0 -1
- package/dist/assets/Overview-DtVeV_uf.js +0 -1
- package/dist/assets/QueueActions-qd4d3da3.js +0 -1
- package/dist/assets/Queues-ClEE2_8g.js +0 -1
- package/dist/assets/Usage-B6ESpkgj.js +0 -1
- package/dist/assets/Webhooks-Cs-DqyhB.js +0 -1
- package/dist/assets/index-BFRKPhvf.js +0 -8
- package/dist/assets/index-mF63w920.css +0 -1
- 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
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
440
|
-
*
|
|
441
|
-
*
|
|
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 =
|
|
447
|
-
} catch {
|
|
448
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/agent/dbQueryWorker.ts
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
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);
|