botmux 3.18.7 → 3.18.8
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/dist/.runtime-build-id +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.d.ts +57 -0
- package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.js +200 -21
- package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
- package/dist/adapters/cli/pi-turn-boundary-extension.d.ts +66 -0
- package/dist/adapters/cli/pi-turn-boundary-extension.d.ts.map +1 -0
- package/dist/adapters/cli/pi-turn-boundary-extension.js +106 -0
- package/dist/adapters/cli/pi-turn-boundary-extension.js.map +1 -0
- package/dist/adapters/cli/pi.d.ts +29 -0
- package/dist/adapters/cli/pi.d.ts.map +1 -1
- package/dist/adapters/cli/pi.js +61 -10
- package/dist/adapters/cli/pi.js.map +1 -1
- package/dist/bot-registry.d.ts +9 -0
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +5 -0
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +8 -1
- package/dist/cli.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +24 -0
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +134 -17
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +5 -3
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/bot-payload.d.ts +2 -0
- package/dist/dashboard/bot-payload.d.ts.map +1 -1
- package/dist/dashboard/bot-payload.js +2 -0
- package/dist/dashboard/bot-payload.js.map +1 -1
- package/dist/dashboard/groups-matrix-snapshot.d.ts +27 -0
- package/dist/dashboard/groups-matrix-snapshot.d.ts.map +1 -1
- package/dist/dashboard/groups-matrix-snapshot.js +16 -0
- package/dist/dashboard/groups-matrix-snapshot.js.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.d.ts +4 -0
- package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.js +52 -9
- package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
- package/dist/dashboard/web/bot-defaults.d.ts +29 -0
- package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults.js +45 -1
- package/dist/dashboard/web/bot-defaults.js.map +1 -1
- package/dist/dashboard/web/bot-onboarding.d.ts.map +1 -1
- package/dist/dashboard/web/bot-onboarding.js +5 -0
- package/dist/dashboard/web/bot-onboarding.js.map +1 -1
- package/dist/dashboard/web/dashboard-components.d.ts +29 -0
- package/dist/dashboard/web/dashboard-components.d.ts.map +1 -1
- package/dist/dashboard/web/dashboard-components.js +96 -4
- package/dist/dashboard/web/dashboard-components.js.map +1 -1
- package/dist/dashboard/web/groups-api.d.ts +11 -0
- package/dist/dashboard/web/groups-api.d.ts.map +1 -1
- package/dist/dashboard/web/groups-api.js +51 -0
- package/dist/dashboard/web/groups-api.js.map +1 -1
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +10 -0
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/overview.d.ts.map +1 -1
- package/dist/dashboard/web/overview.js +11 -2
- package/dist/dashboard/web/overview.js.map +1 -1
- package/dist/dashboard/web/ui.d.ts.map +1 -1
- package/dist/dashboard/web/ui.js +12 -2
- package/dist/dashboard/web/ui.js.map +1 -1
- package/dist/dashboard-web/app.js +1 -1
- package/dist/dashboard-web/chunks/{agent-workbench-dock-page-6LC422N6.js → agent-workbench-dock-page-FAL2YGY5.js} +1 -1
- package/dist/dashboard-web/chunks/{agent-workbench-page-RSAWY7O2.js → agent-workbench-page-G65IOPLY.js} +1 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-3BNFNZEO.js +1 -0
- package/dist/dashboard-web/chunks/chunk-2XHWGUTB.js +10 -0
- package/dist/dashboard-web/chunks/{chunk-R3LJUCEO.js → chunk-3OJQKYM3.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-K232GT3M.js → chunk-IRFGJSPL.js} +5 -5
- package/dist/dashboard-web/chunks/{chunk-IA5KMD5O.js → chunk-IYVXEFKU.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-YJA3KY3J.js → chunk-MYLVEXYF.js} +1 -1
- package/dist/dashboard-web/chunks/chunk-QALRQEKD.js +1 -0
- package/dist/dashboard-web/chunks/{connectors-page-DJCTHSFB.js → connectors-page-K6VZNYUQ.js} +1 -1
- package/dist/dashboard-web/chunks/{customization-page-VQJAEDZY.js → customization-page-FRP6RFOI.js} +1 -1
- package/dist/dashboard-web/chunks/{feedback-page-O7SZRKNM.js → feedback-page-4X7N5J33.js} +1 -1
- package/dist/dashboard-web/chunks/{groups-page-7PONUYHE.js → groups-page-RFQVUO6K.js} +1 -1
- package/dist/dashboard-web/chunks/{insights-page-J6DAAJ6D.js → insights-page-XWH77GX7.js} +1 -1
- package/dist/dashboard-web/chunks/{monitor-room-F3QNEW7N.js → monitor-room-WFU3745R.js} +1 -1
- package/dist/dashboard-web/chunks/{monitoring-page-JHDD2VS2.js → monitoring-page-3NYPZEQT.js} +1 -1
- package/dist/dashboard-web/chunks/{office-page-MMTN4IKD.js → office-page-4I3X6PTF.js} +1 -1
- package/dist/dashboard-web/chunks/overview-page-OQWLVTKT.js +1 -0
- package/dist/dashboard-web/chunks/{roles-page-FNI4MGX6.js → roles-page-RKUGNQOW.js} +1 -1
- package/dist/dashboard-web/chunks/{schedules-page-6PUU63KS.js → schedules-page-SAJKYRZ6.js} +1 -1
- package/dist/dashboard-web/chunks/{sessions-page-O4N72EXT.js → sessions-page-IU6VNSX4.js} +1 -1
- package/dist/dashboard-web/chunks/{settings-page-G7B3STUF.js → settings-page-WTIPX7BQ.js} +1 -1
- package/dist/dashboard-web/chunks/{skills-page-CEWKQOIG.js → skills-page-MKG3HJIU.js} +1 -1
- package/dist/dashboard-web/chunks/{team-federation-page-NUP4VK4H.js → team-federation-page-7SMZJWPT.js} +1 -1
- package/dist/dashboard-web/chunks/{v3-page-M4XF6D5Z.js → v3-page-JMCFLY4Y.js} +1 -1
- package/dist/dashboard-web/chunks/{whiteboards-page-L755NZX6.js → whiteboards-page-5YTWY5EB.js} +1 -1
- package/dist/dashboard-web/style.css +65 -3
- package/dist/dashboard.js +50 -4
- package/dist/dashboard.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +4 -1
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +4 -1
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +141 -38
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/services/card-prefs-store.d.ts +2 -0
- package/dist/services/card-prefs-store.d.ts.map +1 -1
- package/dist/services/card-prefs-store.js +9 -1
- package/dist/services/card-prefs-store.js.map +1 -1
- package/dist/services/pi-transcript.d.ts +21 -1
- package/dist/services/pi-transcript.d.ts.map +1 -1
- package/dist/services/pi-transcript.js +188 -8
- package/dist/services/pi-transcript.js.map +1 -1
- package/dist/services/session-store.d.ts.map +1 -1
- package/dist/services/session-store.js +495 -11
- package/dist/services/session-store.js.map +1 -1
- package/dist/services/under-review-notify-store.d.ts +18 -0
- package/dist/services/under-review-notify-store.d.ts.map +1 -0
- package/dist/services/under-review-notify-store.js +67 -0
- package/dist/services/under-review-notify-store.js.map +1 -0
- package/dist/setup/open-platform-automation.d.ts +210 -2
- package/dist/setup/open-platform-automation.d.ts.map +1 -1
- package/dist/setup/open-platform-automation.js +538 -44
- package/dist/setup/open-platform-automation.js.map +1 -1
- package/dist/setup/open-platform-outcome.d.ts.map +1 -1
- package/dist/setup/open-platform-outcome.js +5 -0
- package/dist/setup/open-platform-outcome.js.map +1 -1
- package/dist/workflows/events/payloads.d.ts +6 -6
- package/dist/workflows/events/schema.d.ts +12 -12
- package/package.json +9 -7
- package/dist/dashboard-web/chunks/bot-defaults-page-67RAWCSQ.js +0 -1
- package/dist/dashboard-web/chunks/chunk-MMLTCXHP.js +0 -10
- package/dist/dashboard-web/chunks/chunk-NYY66DNC.js +0 -1
- package/dist/dashboard-web/chunks/overview-page-IQ2UJAJP.js +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, readdirSync, unlinkSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, readdirSync, unlinkSync, copyFileSync } from 'node:fs';
|
|
2
2
|
import { join, dirname, basename } from 'node:path';
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
4
|
import { config } from '../config.js';
|
|
5
5
|
import { logger } from '../utils/logger.js';
|
|
6
6
|
import { withFileLockSync } from '../utils/file-lock.js';
|
|
@@ -41,6 +41,17 @@ export function stripLegacyPendingCardFields(session) {
|
|
|
41
41
|
}
|
|
42
42
|
const SQLITE_BUSY_TIMEOUT_MS = 3000;
|
|
43
43
|
const SQLITE_NODE_VERSION_HINT = 'Node ≥ 22.13.0(23.x 需 ≥ 23.4.0)';
|
|
44
|
+
// Recovery receipts: written in the SAME transaction as the merge, so
|
|
45
|
+
// "this orphan's rows are already in the main file" becomes a durable fact
|
|
46
|
+
// instead of something re-derived from a replay whose observations are
|
|
47
|
+
// timing-dependent. Keyed by the orphan WAL's content digest.
|
|
48
|
+
const RECOVERY_RECEIPTS_SCHEMA_SQL = `
|
|
49
|
+
CREATE TABLE IF NOT EXISTS import_recovery_receipts (
|
|
50
|
+
orphan_digest TEXT PRIMARY KEY,
|
|
51
|
+
merged_at TEXT NOT NULL,
|
|
52
|
+
merged_rows INTEGER NOT NULL
|
|
53
|
+
);
|
|
54
|
+
`;
|
|
44
55
|
const SESSIONS_SCHEMA_SQL = `
|
|
45
56
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
46
57
|
session_id TEXT PRIMARY KEY,
|
|
@@ -400,26 +411,28 @@ function parseSessionsProjectionStrict(raw, fp) {
|
|
|
400
411
|
}
|
|
401
412
|
return value;
|
|
402
413
|
}
|
|
403
|
-
/** The JSON rows today's load()/migration would have produced for this store
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
|
|
408
|
-
function readJsonEntriesForImport(jsonFp) {
|
|
414
|
+
/** The JSON rows today's load()/migration would have produced for this store,
|
|
415
|
+
* plus WHICH file they came from. The source matters to recovery: a legacy
|
|
416
|
+
* `sessions.json` that parses fine but filters down to zero rows for this bot
|
|
417
|
+
* proves the store held nothing, whereas a missing file proves nothing. */
|
|
418
|
+
function readFrozenSnapshotForImport(jsonFp) {
|
|
409
419
|
let entries = [];
|
|
420
|
+
let source = 'none';
|
|
410
421
|
if (existsSync(jsonFp)) {
|
|
411
422
|
const data = parseSessionsProjectionStrict(readFileSync(jsonFp, 'utf-8'), jsonFp);
|
|
412
423
|
entries = Object.entries(data);
|
|
424
|
+
source = 'per-bot';
|
|
413
425
|
}
|
|
414
426
|
else if (currentAppId) {
|
|
415
427
|
const legacyFp = join(config.session.dataDir, 'sessions.json');
|
|
416
428
|
if (!existsSync(legacyFp))
|
|
417
|
-
return [];
|
|
429
|
+
return { entries: [], source: 'none' };
|
|
418
430
|
const data = parseSessionsProjectionStrict(readFileSync(legacyFp, 'utf-8'), legacyFp);
|
|
419
431
|
entries = Object.entries(data).filter(([, v]) => v?.larkAppId === currentAppId);
|
|
432
|
+
source = 'legacy';
|
|
420
433
|
}
|
|
421
434
|
else {
|
|
422
|
-
return [];
|
|
435
|
+
return { entries: [], source: 'none' };
|
|
423
436
|
}
|
|
424
437
|
for (const [, value] of entries) {
|
|
425
438
|
if (value && typeof value === 'object') {
|
|
@@ -427,7 +440,15 @@ function readJsonEntriesForImport(jsonFp) {
|
|
|
427
440
|
stripLegacyPendingCardFields(value);
|
|
428
441
|
}
|
|
429
442
|
}
|
|
430
|
-
return entries;
|
|
443
|
+
return { entries, source };
|
|
444
|
+
}
|
|
445
|
+
/** The JSON rows today's load()/migration would have produced for this store:
|
|
446
|
+
* the per-bot file's entries when it exists, else the legacy `sessions.json`
|
|
447
|
+
* rows belonging to this bot; scope repair applied, legacy card fields
|
|
448
|
+
* stripped, closed rows included. Parse failures degrade to an empty store —
|
|
449
|
+
* exactly like the previous loader. */
|
|
450
|
+
function readJsonEntriesForImport(jsonFp) {
|
|
451
|
+
return readFrozenSnapshotForImport(jsonFp).entries;
|
|
431
452
|
}
|
|
432
453
|
/** One-shot deterministic import: build the store at `<db>.tmp`, commit, then
|
|
433
454
|
* rename into place so readers only ever see a complete database. The caller
|
|
@@ -484,6 +505,422 @@ function importJsonStoreToSqlite(dbFp, jsonFp) {
|
|
|
484
505
|
throw err;
|
|
485
506
|
}
|
|
486
507
|
}
|
|
508
|
+
// ─── Orphaned import sidecar recovery ────────────────────────────────────────
|
|
509
|
+
// A pre-fix import built `<db>.tmp` in WAL mode and published only the main
|
|
510
|
+
// file with `renameSync`. Under Bun, `close()` skips the WAL checkpoint while a
|
|
511
|
+
// prepared statement is still alive, so the schema and every row stayed in
|
|
512
|
+
// `<db>.tmp-wal` while the published `.db` was a bare 4096-byte header. That
|
|
513
|
+
// import path now uses DELETE mode (it cannot produce this shape any more), but
|
|
514
|
+
// stores already poisoned by it stay broken forever: the import/cleanup branch
|
|
515
|
+
// below is gated on `!existsSync(dbFp)` and the poisoned `.db` DOES exist, so
|
|
516
|
+
// nothing ever looks at the orphans again.
|
|
517
|
+
//
|
|
518
|
+
// DETECTION uses the orphaned `<db>.tmp*` sidecars and nothing else. Verified
|
|
519
|
+
// alternatives and why they are unusable:
|
|
520
|
+
// • `PRAGMA quick_check` / `integrity_check` return `ok` on a poisoned store
|
|
521
|
+
// (the file is structurally fine, its content simply never merged) — zero
|
|
522
|
+
// discriminating power against a legitimately empty store.
|
|
523
|
+
// • "the `sessions` table is missing" self-erases: `openDbForOwnStore` runs
|
|
524
|
+
// `CREATE TABLE IF NOT EXISTS`, so the very first open destroys the
|
|
525
|
+
// evidence. Measured going false→true across two opens while the `.tmp*`
|
|
526
|
+
// orphans persisted.
|
|
527
|
+
// The orphan predicate cannot fire on a healthy store: the import builds on
|
|
528
|
+
// `<db>.tmp` and only `renameSync`s it into place as the last step, under the
|
|
529
|
+
// same lock, and its branch requires `.db` to be ABSENT. So ".db exists AND
|
|
530
|
+
// .tmp* exists" is unreachable in a normal timeline — it is always crash
|
|
531
|
+
// residue. A scan of 56 live production stores found zero `.tmp*` leftovers
|
|
532
|
+
// (healthy stores carry only `-wal`/`-shm`), i.e. no false-positive surface.
|
|
533
|
+
const IMPORT_TMP_SIDECAR_SUFFIXES = ['', '-journal', '-wal', '-shm'];
|
|
534
|
+
/** No source could attest what a poisoned store held, so recovery refused to
|
|
535
|
+
* touch it. Distinct class so the fail-closed path reads as a deliberate
|
|
536
|
+
* refusal rather than an I/O accident. */
|
|
537
|
+
class SessionStoreRecoveryUnattestedError extends Error {
|
|
538
|
+
name = 'SessionStoreRecoveryUnattestedError';
|
|
539
|
+
}
|
|
540
|
+
/** Content digest of an orphaned WAL, used as its recovery-receipt key. The
|
|
541
|
+
* bytes are what identify it: a different crash produces different frames, and
|
|
542
|
+
* a WAL we already merged keeps the same digest until it is finally removed. */
|
|
543
|
+
function orphanWalDigest(walFp) {
|
|
544
|
+
try {
|
|
545
|
+
return createHash('sha256').update(readFileSync(walFp)).digest('hex');
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
return undefined;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
/** Whether a previous recovery pass already committed THIS orphan's rows.
|
|
552
|
+
*
|
|
553
|
+
* STRICTLY read-only: SELECT and nothing else. It must not create the receipts
|
|
554
|
+
* table — a bare `CREATE TABLE IF NOT EXISTS` grows the main file (measured
|
|
555
|
+
* 12288 → 20480 bytes), and this runs BEFORE the archive is taken, so writing
|
|
556
|
+
* here would leave the archived shell no longer paired with the WAL frames it
|
|
557
|
+
* is meant to preserve. A missing table simply means "no proof". */
|
|
558
|
+
function hasPriorReceipt(dbFp, walDigest) {
|
|
559
|
+
if (!walDigest)
|
|
560
|
+
return false;
|
|
561
|
+
try {
|
|
562
|
+
const db = openDatabaseSyncOrThrow(dbFp, { readOnly: true });
|
|
563
|
+
try {
|
|
564
|
+
return db.prepare('SELECT 1 FROM import_recovery_receipts WHERE orphan_digest = ?')
|
|
565
|
+
.get(walDigest) !== undefined;
|
|
566
|
+
}
|
|
567
|
+
finally {
|
|
568
|
+
db.close();
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
// No table yet, or an unreadable store: either way, nothing is proven.
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/** Every orphaned `<db>.tmp*` path a crashed pre-fix import may have left: the
|
|
577
|
+
* temporary shell itself plus any of its journals. */
|
|
578
|
+
function orphanedImportSidecars(dbFp) {
|
|
579
|
+
return IMPORT_TMP_SIDECAR_SUFFIXES
|
|
580
|
+
.map(suffix => `${dbFp}.tmp${suffix}`)
|
|
581
|
+
.filter(path => existsSync(path));
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Rows stranded in an orphaned import WAL, read WITHOUT touching the originals.
|
|
585
|
+
*
|
|
586
|
+
* ⚠️ DO NOT "recover" by renaming `<db>.tmp-wal` onto `<db>-wal` in place. A
|
|
587
|
+
* `-wal` is REPLACE semantics, not merge: once anything has opened the poisoned
|
|
588
|
+
* store, `CREATE TABLE IF NOT EXISTS` gives it a usable empty table and new
|
|
589
|
+
* sessions accumulate in the store's OWN `-wal`. Measured on Bun 1.4.0 — the
|
|
590
|
+
* in-place rename overwrites that live WAL and ALSO fails to replay (the
|
|
591
|
+
* orphan's frames describe the original bare shell, which the live writes have
|
|
592
|
+
* since moved past): a store holding 3 fresh sessions went to 0 rows and the 40
|
|
593
|
+
* stranded ones did not come back either. Net data destruction.
|
|
594
|
+
*
|
|
595
|
+
* So replay happens on a private COPY, and the caller merges the result without
|
|
596
|
+
* overwriting anything live. A damaged orphan never yields half-parsed rows, but
|
|
597
|
+
* it does NOT reliably announce itself either: measured shapes include throwing
|
|
598
|
+
* `no such table` (no usable shell at all), replaying zero rows (frames accepted
|
|
599
|
+
* but the transaction never committed), and — the dangerous one — quietly echoing
|
|
600
|
+
* whatever the MAIN file already holds. Damage is therefore not detectable from
|
|
601
|
+
* the returned rows; see the composite warning below.
|
|
602
|
+
*
|
|
603
|
+
* ⚠️ THE SCRATCH VIEW IS A COMPOSITE, not a picture of the WAL. It is "current
|
|
604
|
+
* main file + orphan WAL", and SQLite silently IGNORES an orphan whose header is
|
|
605
|
+
* invalid. So rows coming back prove nothing about the orphan: a store whose old
|
|
606
|
+
* code wrote new sessions and checkpointed them into the main file replays those
|
|
607
|
+
* live rows even when the orphan is entirely unreadable. Counting rows (or
|
|
608
|
+
* checking they all parse) therefore cannot answer "did the WAL replay" — it
|
|
609
|
+
* measures the wrong file.
|
|
610
|
+
*
|
|
611
|
+
* `walReplayed` answers that question with `PRAGMA wal_checkpoint(PASSIVE)`,
|
|
612
|
+
* which reports how many WAL frames the engine actually ACCEPTED. Measured on
|
|
613
|
+
* Bun 1.4.0 and Node 22.21.1 alike, against a 40-row orphan beside 3 live rows
|
|
614
|
+
* already checkpointed into the main file:
|
|
615
|
+
*
|
|
616
|
+
* intact orphan → `{busy:0, log:17, checkpointed:17}`, SELECT sees 40
|
|
617
|
+
* header zeroed → `{busy:0, log:0, checkpointed:0}`, SELECT sees 3 (live only)
|
|
618
|
+
* truncated to 20 KiB → `{busy:0, log:3, checkpointed:3}`, SELECT sees 0
|
|
619
|
+
*
|
|
620
|
+
* `log > 0` is what rules out the dangerous blind spot — the middle row, where
|
|
621
|
+
* the WAL contributed NOTHING and the rows on screen are pure live main. It is
|
|
622
|
+
* still not a completeness proof (the third row accepted 3 frames yet lost every
|
|
623
|
+
* row), so it is paired with "the replay produced parseable rows". Anything not
|
|
624
|
+
* proven replayed is archived rather than deleted.
|
|
625
|
+
*
|
|
626
|
+
* A differential replay of the shell WITHOUT the orphan is layered on top, so a
|
|
627
|
+
* future engine that reports frames it then discards still cannot pass. That
|
|
628
|
+
* comparison uses whole rows rather than ids: a valid orphan may UPDATE a row the
|
|
629
|
+
* shell already carries, which an id-only diff would miss.
|
|
630
|
+
*/
|
|
631
|
+
function readStrandedImportRows(dbFp) {
|
|
632
|
+
const walFp = `${dbFp}.tmp-wal`;
|
|
633
|
+
if (!existsSync(walFp))
|
|
634
|
+
return { entries: [], walReplayed: false };
|
|
635
|
+
const scratchFp = `${dbFp}.recover-${process.pid}-${randomUUID()}`;
|
|
636
|
+
const baseFp = `${dbFp}.recoverbase-${process.pid}-${randomUUID()}`;
|
|
637
|
+
const scratchPaths = ['', '-journal', '-wal', '-shm'].flatMap(suffix => [
|
|
638
|
+
`${scratchFp}${suffix}`,
|
|
639
|
+
`${baseFp}${suffix}`,
|
|
640
|
+
]);
|
|
641
|
+
const dropScratch = () => {
|
|
642
|
+
for (const path of scratchPaths) {
|
|
643
|
+
try {
|
|
644
|
+
unlinkSync(path);
|
|
645
|
+
}
|
|
646
|
+
catch { /* nothing to drop */ }
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
/** session_id → row the shell exposes on its own (no orphan attached). Full
|
|
650
|
+
* rows, not just ids: a valid orphan may UPDATE an id the shell already has,
|
|
651
|
+
* and comparing ids alone would score that as "the WAL contributed nothing". */
|
|
652
|
+
const readBaselineRows = () => {
|
|
653
|
+
copyFileSync(dbFp, baseFp);
|
|
654
|
+
try {
|
|
655
|
+
const db = openDatabaseSyncOrThrow(baseFp);
|
|
656
|
+
try {
|
|
657
|
+
db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS};`);
|
|
658
|
+
const rows = db.prepare('SELECT session_id, row FROM sessions').all();
|
|
659
|
+
return new Map(rows.map(r => [r.session_id, r.row]));
|
|
660
|
+
}
|
|
661
|
+
finally {
|
|
662
|
+
db.close();
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
// A bare shell with no table is the normal baseline for a poisoned store.
|
|
667
|
+
return new Map();
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
try {
|
|
671
|
+
const baselineRows = readBaselineRows();
|
|
672
|
+
// The published `.db` is the exact shell those WAL frames were written
|
|
673
|
+
// against, so it is the shell the replay must run on.
|
|
674
|
+
copyFileSync(dbFp, scratchFp);
|
|
675
|
+
copyFileSync(walFp, `${scratchFp}-wal`);
|
|
676
|
+
const db = openDatabaseSyncOrThrow(scratchFp);
|
|
677
|
+
try {
|
|
678
|
+
db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS};`);
|
|
679
|
+
// Must run BEFORE the SELECT: it reports the frames the engine accepted
|
|
680
|
+
// from this orphan, which is the only direct evidence the WAL was used.
|
|
681
|
+
let acceptedFrames = 0;
|
|
682
|
+
try {
|
|
683
|
+
const checkpoint = db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get();
|
|
684
|
+
acceptedFrames = Number(checkpoint?.log ?? 0);
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
// Treat an unavailable pragma as "cannot prove the WAL replayed".
|
|
688
|
+
acceptedFrames = 0;
|
|
689
|
+
}
|
|
690
|
+
const rows = db.prepare('SELECT session_id, row FROM sessions').all();
|
|
691
|
+
const entries = [];
|
|
692
|
+
let unparseable = 0;
|
|
693
|
+
let beyondBaseline = 0;
|
|
694
|
+
for (const r of rows) {
|
|
695
|
+
if (baselineRows.get(r.session_id) !== r.row)
|
|
696
|
+
beyondBaseline++;
|
|
697
|
+
try {
|
|
698
|
+
entries.push([r.session_id, JSON.parse(r.row)]);
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
unparseable++;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
// "The orphan demonstrably replayed": SQLite accepted frames from it AND
|
|
705
|
+
// it contributed rows the shell did not already have, with nothing corrupt.
|
|
706
|
+
//
|
|
707
|
+
// Deliberately NOT extended to "the row sets happen to match, so a previous
|
|
708
|
+
// pass must have merged it". That inference is timing-dependent — a
|
|
709
|
+
// truncated orphan beside live rows can produce an identical-looking
|
|
710
|
+
// observation while its real rows are unaccounted for — so the retry path
|
|
711
|
+
// is answered by a durable RECEIPT instead (see recoverPoisonedSqliteStore).
|
|
712
|
+
const walReplayed = acceptedFrames > 0 && unparseable === 0 && beyondBaseline > 0;
|
|
713
|
+
return { entries, walReplayed };
|
|
714
|
+
}
|
|
715
|
+
finally {
|
|
716
|
+
db.close();
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
finally {
|
|
720
|
+
dropScratch();
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Repair a store poisoned by a crashed pre-fix import, in place, and report how
|
|
725
|
+
* many rows were rescued. MUST be called with the store's JSON file lock held —
|
|
726
|
+
* the same lock the import, daemon saves and offline CLI mutations use.
|
|
727
|
+
*
|
|
728
|
+
* TWO sources are merged, because neither alone is sufficient:
|
|
729
|
+
* • the orphaned WAL — the only copy of anything written after the JSON was
|
|
730
|
+
* frozen, and the only source at all if the JSON has since been removed;
|
|
731
|
+
* • the frozen JSON — the import read exclusively from it, so it is normally a
|
|
732
|
+
* superset of the stranded rows, and the only source that survives a DAMAGED
|
|
733
|
+
* orphan. "Normally": the file can later be trimmed or partially restored,
|
|
734
|
+
* so it is not treated as authoritative on its own.
|
|
735
|
+
* A partially-written orphan is precisely why both are needed: truncating one
|
|
736
|
+
* replays as "schema, zero rows" without any error, so trusting the orphan alone
|
|
737
|
+
* would delete it and report a healthy EMPTY store — the very silent-loss bug
|
|
738
|
+
* this function exists to end.
|
|
739
|
+
*
|
|
740
|
+
* Two independent decisions come out of that, and conflating them is what makes
|
|
741
|
+
* this subtle:
|
|
742
|
+
*
|
|
743
|
+
* 1. MAY WE PROCEED AT ALL? Only with POSITIVE ATTESTATION of what the store
|
|
744
|
+
* held. Three things can supply it:
|
|
745
|
+
* • the orphan demonstrably replayed rows;
|
|
746
|
+
* • a snapshot file was actually READ — about the source, not the row
|
|
747
|
+
* count: a legacy `sessions.json` that parses and filters down to zero
|
|
748
|
+
* rows for this bot proves the store held nothing, while a MISSING file
|
|
749
|
+
* proves nothing at all;
|
|
750
|
+
* • a RECEIPT for this exact orphan digest, written by an earlier pass in
|
|
751
|
+
* the same transaction as its merge — the only proof that survives a
|
|
752
|
+
* crash, and the one that lets an interrupted cleanup finish.
|
|
753
|
+
* With none of them, "zero rows" is indistinguishable from "damaged, contents
|
|
754
|
+
* unknown", so recovery refuses and keeps the orphans for manual rescue.
|
|
755
|
+
*
|
|
756
|
+
* 2. MAY WE DESTROY THE ORPHAN? Only when its contents are accounted for. Frame
|
|
757
|
+
* counts cannot establish that: a WAL truncated mid-transaction still gets
|
|
758
|
+
* frames ACCEPTED (its schema prefix) while contributing no data rows at all,
|
|
759
|
+
* because the missing commit frame means SQLite exposes none of that
|
|
760
|
+
* transaction. Equally, "we merged something" is not proof nothing was lost —
|
|
761
|
+
* with a trimmed snapshot beside a damaged orphan, both sources can be missing
|
|
762
|
+
* the same session and the merge silently converges on an incomplete store.
|
|
763
|
+
* When completeness cannot be proven, the
|
|
764
|
+
* pair is ARCHIVED rather than deleted — `<db>.unrecovered-<ts>.db` plus its
|
|
765
|
+
* `-wal`: the ORIGINAL bytes, kept beside the shell they belong to, for
|
|
766
|
+
* forensics or a manual salvage attempt.
|
|
767
|
+
*
|
|
768
|
+
* It is deliberately NOT a promise that the couple replays. Measured: a WAL
|
|
769
|
+
* truncated mid-transaction hands back zero rows, because the missing commit
|
|
770
|
+
* frame means SQLite exposes no partial transaction at all — the damage lost
|
|
771
|
+
* those rows, not the archiving. What archiving guarantees is that nothing is
|
|
772
|
+
* thrown away: whatever a human can still extract remains extractable.
|
|
773
|
+
*
|
|
774
|
+
* Archiving, rather than leaving the file in place, is also what makes the
|
|
775
|
+
* store usable again. The orphan path IS the poison predicate, so keeping
|
|
776
|
+
* `<db>.tmp-wal` there would re-enter recovery on every single start and leave
|
|
777
|
+
* every `owner:false` worker permanently fail-closed. And the shell is copied
|
|
778
|
+
* BEFORE the merge, since the merge advances the live database — a shell
|
|
779
|
+
* copied afterwards would no longer be the one those frames were written
|
|
780
|
+
* against.
|
|
781
|
+
*
|
|
782
|
+
* Merge policy is `INSERT OR IGNORE`: rows that exist live always win. Both
|
|
783
|
+
* sources predate every live write by construction, so preferring live rows
|
|
784
|
+
* cannot lose newer state. Verified: 40 stranded + 3 live → 43, both kept.
|
|
785
|
+
*
|
|
786
|
+
* Orphans are removed only after the merge commits, so a crash mid-recovery
|
|
787
|
+
* leaves the store exactly as recoverable as it was before. The `.tmp-wal` is
|
|
788
|
+
* deleted LAST, so a crash mid-cleanup always leaves the still-authoritative
|
|
789
|
+
* file behind rather than a stray sidecar with the evidence already gone.
|
|
790
|
+
*/
|
|
791
|
+
function recoverPoisonedSqliteStore(dbFp, jsonFp) {
|
|
792
|
+
const walFp = `${dbFp}.tmp-wal`;
|
|
793
|
+
const walPresent = existsSync(walFp);
|
|
794
|
+
const walDigest = walPresent ? orphanWalDigest(walFp) : undefined;
|
|
795
|
+
let stranded = [];
|
|
796
|
+
let walReplayed = false;
|
|
797
|
+
try {
|
|
798
|
+
const replay = readStrandedImportRows(dbFp);
|
|
799
|
+
stranded = replay.entries;
|
|
800
|
+
walReplayed = replay.walReplayed;
|
|
801
|
+
}
|
|
802
|
+
catch (err) {
|
|
803
|
+
logger.error(`Could not replay the orphaned import WAL for ${dbFp}: ${err}`);
|
|
804
|
+
}
|
|
805
|
+
let frozen = [];
|
|
806
|
+
let frozenAttests = false;
|
|
807
|
+
try {
|
|
808
|
+
const snapshot = readFrozenSnapshotForImport(jsonFp);
|
|
809
|
+
frozen = snapshot.entries;
|
|
810
|
+
// A snapshot that was actually READ attests, even when it resolves to zero
|
|
811
|
+
// rows for this bot — that is a positive statement about the store. Only
|
|
812
|
+
// `none` (no readable file anywhere) fails to attest.
|
|
813
|
+
frozenAttests = snapshot.source !== 'none';
|
|
814
|
+
}
|
|
815
|
+
catch (err) {
|
|
816
|
+
logger.error(`Could not read the frozen JSON snapshot for ${dbFp}: ${err}`);
|
|
817
|
+
}
|
|
818
|
+
// The composite view can echo rows that live only in the MAIN file, so
|
|
819
|
+
// `stranded.length` is not evidence about the orphan. Proceeding requires the
|
|
820
|
+
// orphan to have demonstrably replayed, or a snapshot to have been read.
|
|
821
|
+
const priorReceipt = hasPriorReceipt(dbFp, walDigest);
|
|
822
|
+
if (!walReplayed && !frozenAttests && !priorReceipt) {
|
|
823
|
+
throw new SessionStoreRecoveryUnattestedError(`cannot recover ${dbFp}: the orphaned import WAL could not be proven to have replayed and no frozen `
|
|
824
|
+
+ 'JSON snapshot could attest the store contents');
|
|
825
|
+
}
|
|
826
|
+
// Archive BEFORE the merge: the shell must be the one those WAL frames were
|
|
827
|
+
// written against, and the merge is about to change it. A receipt (checked in
|
|
828
|
+
// the transaction below) can still spare an archive on the retry path, so this
|
|
829
|
+
// decision is revisited there rather than being final here.
|
|
830
|
+
let archivedEvidence;
|
|
831
|
+
if (walPresent && !walReplayed && !priorReceipt) {
|
|
832
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
833
|
+
const archiveFp = `${dbFp}.unrecovered-${stamp}.db`;
|
|
834
|
+
try {
|
|
835
|
+
copyFileSync(dbFp, archiveFp);
|
|
836
|
+
copyFileSync(walFp, `${archiveFp}-wal`);
|
|
837
|
+
archivedEvidence = archiveFp;
|
|
838
|
+
}
|
|
839
|
+
catch (err) {
|
|
840
|
+
// Could not preserve the couple — do NOT delete the original below.
|
|
841
|
+
logger.error(`Could not archive the unrecovered import WAL for ${dbFp}: ${err}`);
|
|
842
|
+
throw err;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
const db = openDbForOwnStore(dbFp);
|
|
846
|
+
let merged = 0;
|
|
847
|
+
try {
|
|
848
|
+
db.exec('BEGIN IMMEDIATE');
|
|
849
|
+
try {
|
|
850
|
+
// Inside the transaction, so the schema write cannot advance the shell
|
|
851
|
+
// before the archive above was taken.
|
|
852
|
+
db.exec(RECOVERY_RECEIPTS_SCHEMA_SQL);
|
|
853
|
+
const insert = db.prepare('INSERT OR IGNORE INTO sessions (session_id, status, row) VALUES (?, ?, ?)');
|
|
854
|
+
for (const [key, value] of [...stranded, ...frozen]) {
|
|
855
|
+
const result = insert.run(key, sessionStatusText(value), JSON.stringify(value));
|
|
856
|
+
if (Number(result.changes) > 0)
|
|
857
|
+
merged++;
|
|
858
|
+
}
|
|
859
|
+
// Record the receipt in the SAME transaction as the rows: either both land
|
|
860
|
+
// or neither does, so a receipt can never claim a merge that did not commit.
|
|
861
|
+
if (walDigest && walReplayed) {
|
|
862
|
+
db.prepare('INSERT OR IGNORE INTO import_recovery_receipts (orphan_digest, merged_at, merged_rows) VALUES (?, ?, ?)')
|
|
863
|
+
.run(walDigest, new Date().toISOString(), merged);
|
|
864
|
+
}
|
|
865
|
+
db.exec('COMMIT');
|
|
866
|
+
}
|
|
867
|
+
catch (err) {
|
|
868
|
+
try {
|
|
869
|
+
db.exec('ROLLBACK');
|
|
870
|
+
}
|
|
871
|
+
catch { /* txn already gone */ }
|
|
872
|
+
throw err;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
finally {
|
|
876
|
+
db.close();
|
|
877
|
+
}
|
|
878
|
+
// Committed, and anything unproven is archived under a name the poison
|
|
879
|
+
// predicate ignores, so the originals can go.
|
|
880
|
+
//
|
|
881
|
+
// ORDER IS A CORRECTNESS PROPERTY, not tidiness. `<db>.tmp-wal` is the only
|
|
882
|
+
// sidecar that carries rows, so it is deleted LAST and only once every other
|
|
883
|
+
// sidecar is provably gone. That makes "just a lone `.tmp-shm`" unreachable —
|
|
884
|
+
// neither a successful recovery nor a crash midway through cleanup can produce
|
|
885
|
+
// it — which is what lets a lone `-shm` keep counting as a poisoned store
|
|
886
|
+
// instead of being waved through as a healthy empty one. If any earlier unlink
|
|
887
|
+
// fails, the WAL stays: the store still tests as poisoned and the next start
|
|
888
|
+
// re-runs an idempotent merge.
|
|
889
|
+
let sidecarsCleared = true;
|
|
890
|
+
for (const path of orphanedImportSidecars(dbFp)) {
|
|
891
|
+
if (path === walFp)
|
|
892
|
+
continue;
|
|
893
|
+
try {
|
|
894
|
+
unlinkSync(path);
|
|
895
|
+
}
|
|
896
|
+
catch (err) {
|
|
897
|
+
sidecarsCleared = false;
|
|
898
|
+
logger.error(`Could not remove orphaned import sidecar ${path}: ${err}`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
// The WAL may go once its contents are accounted for, which happens three ways:
|
|
902
|
+
// • this pass replayed it (its rows are now merged);
|
|
903
|
+
// • a receipt proves an earlier pass committed them (interrupted-cleanup retry);
|
|
904
|
+
// • it was ARCHIVED — the original bytes are preserved elsewhere, which is
|
|
905
|
+
// the whole point of archiving. (A failed copy throws above, so reaching
|
|
906
|
+
// here with `archivedEvidence` set means the copy succeeded.)
|
|
907
|
+
const walAccountedFor = walReplayed || priorReceipt || archivedEvidence !== undefined;
|
|
908
|
+
if (walPresent && sidecarsCleared && walAccountedFor) {
|
|
909
|
+
try {
|
|
910
|
+
unlinkSync(walFp);
|
|
911
|
+
}
|
|
912
|
+
catch (err) {
|
|
913
|
+
logger.error(`Could not remove the orphaned import WAL ${walFp}: ${err}`);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
else if (walPresent && !archivedEvidence) {
|
|
917
|
+
// Neither replayed nor receipted, and not archived either: leave it exactly
|
|
918
|
+
// where it is. The store keeps testing as poisoned, which is the honest
|
|
919
|
+
// state — its contents are unaccounted for.
|
|
920
|
+
logger.warn(`Leaving ${walFp} in place: its rows are not accounted for.`);
|
|
921
|
+
}
|
|
922
|
+
return { merged, archivedEvidence };
|
|
923
|
+
}
|
|
487
924
|
// Sessions persisted before 2026-04-29 lack `cliId`; consumers must fall back to 'unknown' at the render boundary.
|
|
488
925
|
function load() {
|
|
489
926
|
if (loaded)
|
|
@@ -491,6 +928,53 @@ function load() {
|
|
|
491
928
|
ensureDir();
|
|
492
929
|
const dbFp = getDbPath();
|
|
493
930
|
const jsonFp = getFilePath();
|
|
931
|
+
// A poisoned store must never be mistaken for an empty one. Recover it before
|
|
932
|
+
// anything reads it, or fail closed so listSessionsStrict() throws instead of
|
|
933
|
+
// answering "there are no durable sessions".
|
|
934
|
+
if (existsSync(dbFp) && orphanedImportSidecars(dbFp).length > 0) {
|
|
935
|
+
if (!sqliteBootstrapAllowed) {
|
|
936
|
+
// A worker must not repair a store its still-running daemon owns. Report
|
|
937
|
+
// unavailable rather than serve the truncated view.
|
|
938
|
+
loadFailure = new Error(`session store ${dbFp} has orphaned import sidecars (${orphanedImportSidecars(dbFp).join(', ')}); `
|
|
939
|
+
+ 'a non-owning process may not recover it');
|
|
940
|
+
logger.error(`Refusing to load poisoned session store as a non-owner: ${loadFailure.message}`);
|
|
941
|
+
sessions = new Map();
|
|
942
|
+
loaded = true;
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
try {
|
|
946
|
+
withFileLockSync(jsonFp, () => {
|
|
947
|
+
// Re-check under the lock: another owning process may have just fixed it.
|
|
948
|
+
if (orphanedImportSidecars(dbFp).length === 0)
|
|
949
|
+
return;
|
|
950
|
+
const { merged, archivedEvidence } = recoverPoisonedSqliteStore(dbFp, jsonFp);
|
|
951
|
+
if (archivedEvidence) {
|
|
952
|
+
// The merge committed, but the orphan could not be proven to have
|
|
953
|
+
// replayed, so a matched shell+WAL couple was archived under a name
|
|
954
|
+
// the poison predicate ignores. Say so loudly: the store is usable,
|
|
955
|
+
// yet a human may still want to replay that couple by hand.
|
|
956
|
+
logger.warn(`Recovered ${merged} session row(s) stranded by a crashed SQLite import into ${dbFp}, but the `
|
|
957
|
+
+ `orphaned WAL could not be proven to have replayed — archived the matching shell+WAL couple to `
|
|
958
|
+
+ `${archivedEvidence}(-wal) for manual inspection. Delete it once you are satisfied nothing is missing.`);
|
|
959
|
+
}
|
|
960
|
+
else {
|
|
961
|
+
logger.warn(`Recovered ${merged} session row(s) stranded by a crashed SQLite import into ${dbFp}; `
|
|
962
|
+
+ 'removed the orphaned .tmp sidecars');
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
catch (err) {
|
|
967
|
+
if (isTransientStoreContentionError(err))
|
|
968
|
+
throw err;
|
|
969
|
+
// Fail closed: the rows are still on disk, but this process cannot prove
|
|
970
|
+
// what the store holds, so it must not report an empty projection.
|
|
971
|
+
logger.error(`Failed to recover poisoned session store ${dbFp}: ${err}`);
|
|
972
|
+
loadFailure = err instanceof Error ? err : new Error(String(err));
|
|
973
|
+
sessions = new Map();
|
|
974
|
+
loaded = true;
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
494
978
|
if (!existsSync(dbFp) && sqliteBootstrapAllowed) {
|
|
495
979
|
// First start on the SQLite engine: import this store's JSON rows (or
|
|
496
980
|
// create an empty store) under the same lock every JSON writer uses.
|