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.
Files changed (133) hide show
  1. package/dist/.runtime-build-id +1 -1
  2. package/dist/adapters/backend/tmux-pipe-backend.d.ts +57 -0
  3. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  4. package/dist/adapters/backend/tmux-pipe-backend.js +200 -21
  5. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  6. package/dist/adapters/cli/pi-turn-boundary-extension.d.ts +66 -0
  7. package/dist/adapters/cli/pi-turn-boundary-extension.d.ts.map +1 -0
  8. package/dist/adapters/cli/pi-turn-boundary-extension.js +106 -0
  9. package/dist/adapters/cli/pi-turn-boundary-extension.js.map +1 -0
  10. package/dist/adapters/cli/pi.d.ts +29 -0
  11. package/dist/adapters/cli/pi.d.ts.map +1 -1
  12. package/dist/adapters/cli/pi.js +61 -10
  13. package/dist/adapters/cli/pi.js.map +1 -1
  14. package/dist/bot-registry.d.ts +9 -0
  15. package/dist/bot-registry.d.ts.map +1 -1
  16. package/dist/bot-registry.js +5 -0
  17. package/dist/bot-registry.js.map +1 -1
  18. package/dist/cli.d.ts.map +1 -1
  19. package/dist/cli.js +8 -1
  20. package/dist/cli.js.map +1 -1
  21. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  22. package/dist/core/dashboard-ipc-server.js +24 -0
  23. package/dist/core/dashboard-ipc-server.js.map +1 -1
  24. package/dist/core/worker-pool.d.ts.map +1 -1
  25. package/dist/core/worker-pool.js +134 -17
  26. package/dist/core/worker-pool.js.map +1 -1
  27. package/dist/daemon.d.ts.map +1 -1
  28. package/dist/daemon.js +5 -3
  29. package/dist/daemon.js.map +1 -1
  30. package/dist/dashboard/bot-payload.d.ts +2 -0
  31. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  32. package/dist/dashboard/bot-payload.js +2 -0
  33. package/dist/dashboard/bot-payload.js.map +1 -1
  34. package/dist/dashboard/groups-matrix-snapshot.d.ts +27 -0
  35. package/dist/dashboard/groups-matrix-snapshot.d.ts.map +1 -1
  36. package/dist/dashboard/groups-matrix-snapshot.js +16 -0
  37. package/dist/dashboard/groups-matrix-snapshot.js.map +1 -1
  38. package/dist/dashboard/web/bot-defaults-page.d.ts +4 -0
  39. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  40. package/dist/dashboard/web/bot-defaults-page.js +52 -9
  41. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  42. package/dist/dashboard/web/bot-defaults.d.ts +29 -0
  43. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  44. package/dist/dashboard/web/bot-defaults.js +45 -1
  45. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  46. package/dist/dashboard/web/bot-onboarding.d.ts.map +1 -1
  47. package/dist/dashboard/web/bot-onboarding.js +5 -0
  48. package/dist/dashboard/web/bot-onboarding.js.map +1 -1
  49. package/dist/dashboard/web/dashboard-components.d.ts +29 -0
  50. package/dist/dashboard/web/dashboard-components.d.ts.map +1 -1
  51. package/dist/dashboard/web/dashboard-components.js +96 -4
  52. package/dist/dashboard/web/dashboard-components.js.map +1 -1
  53. package/dist/dashboard/web/groups-api.d.ts +11 -0
  54. package/dist/dashboard/web/groups-api.d.ts.map +1 -1
  55. package/dist/dashboard/web/groups-api.js +51 -0
  56. package/dist/dashboard/web/groups-api.js.map +1 -1
  57. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  58. package/dist/dashboard/web/i18n.js +10 -0
  59. package/dist/dashboard/web/i18n.js.map +1 -1
  60. package/dist/dashboard/web/overview.d.ts.map +1 -1
  61. package/dist/dashboard/web/overview.js +11 -2
  62. package/dist/dashboard/web/overview.js.map +1 -1
  63. package/dist/dashboard/web/ui.d.ts.map +1 -1
  64. package/dist/dashboard/web/ui.js +12 -2
  65. package/dist/dashboard/web/ui.js.map +1 -1
  66. package/dist/dashboard-web/app.js +1 -1
  67. package/dist/dashboard-web/chunks/{agent-workbench-dock-page-6LC422N6.js → agent-workbench-dock-page-FAL2YGY5.js} +1 -1
  68. package/dist/dashboard-web/chunks/{agent-workbench-page-RSAWY7O2.js → agent-workbench-page-G65IOPLY.js} +1 -1
  69. package/dist/dashboard-web/chunks/bot-defaults-page-3BNFNZEO.js +1 -0
  70. package/dist/dashboard-web/chunks/chunk-2XHWGUTB.js +10 -0
  71. package/dist/dashboard-web/chunks/{chunk-R3LJUCEO.js → chunk-3OJQKYM3.js} +1 -1
  72. package/dist/dashboard-web/chunks/{chunk-K232GT3M.js → chunk-IRFGJSPL.js} +5 -5
  73. package/dist/dashboard-web/chunks/{chunk-IA5KMD5O.js → chunk-IYVXEFKU.js} +1 -1
  74. package/dist/dashboard-web/chunks/{chunk-YJA3KY3J.js → chunk-MYLVEXYF.js} +1 -1
  75. package/dist/dashboard-web/chunks/chunk-QALRQEKD.js +1 -0
  76. package/dist/dashboard-web/chunks/{connectors-page-DJCTHSFB.js → connectors-page-K6VZNYUQ.js} +1 -1
  77. package/dist/dashboard-web/chunks/{customization-page-VQJAEDZY.js → customization-page-FRP6RFOI.js} +1 -1
  78. package/dist/dashboard-web/chunks/{feedback-page-O7SZRKNM.js → feedback-page-4X7N5J33.js} +1 -1
  79. package/dist/dashboard-web/chunks/{groups-page-7PONUYHE.js → groups-page-RFQVUO6K.js} +1 -1
  80. package/dist/dashboard-web/chunks/{insights-page-J6DAAJ6D.js → insights-page-XWH77GX7.js} +1 -1
  81. package/dist/dashboard-web/chunks/{monitor-room-F3QNEW7N.js → monitor-room-WFU3745R.js} +1 -1
  82. package/dist/dashboard-web/chunks/{monitoring-page-JHDD2VS2.js → monitoring-page-3NYPZEQT.js} +1 -1
  83. package/dist/dashboard-web/chunks/{office-page-MMTN4IKD.js → office-page-4I3X6PTF.js} +1 -1
  84. package/dist/dashboard-web/chunks/overview-page-OQWLVTKT.js +1 -0
  85. package/dist/dashboard-web/chunks/{roles-page-FNI4MGX6.js → roles-page-RKUGNQOW.js} +1 -1
  86. package/dist/dashboard-web/chunks/{schedules-page-6PUU63KS.js → schedules-page-SAJKYRZ6.js} +1 -1
  87. package/dist/dashboard-web/chunks/{sessions-page-O4N72EXT.js → sessions-page-IU6VNSX4.js} +1 -1
  88. package/dist/dashboard-web/chunks/{settings-page-G7B3STUF.js → settings-page-WTIPX7BQ.js} +1 -1
  89. package/dist/dashboard-web/chunks/{skills-page-CEWKQOIG.js → skills-page-MKG3HJIU.js} +1 -1
  90. package/dist/dashboard-web/chunks/{team-federation-page-NUP4VK4H.js → team-federation-page-7SMZJWPT.js} +1 -1
  91. package/dist/dashboard-web/chunks/{v3-page-M4XF6D5Z.js → v3-page-JMCFLY4Y.js} +1 -1
  92. package/dist/dashboard-web/chunks/{whiteboards-page-L755NZX6.js → whiteboards-page-5YTWY5EB.js} +1 -1
  93. package/dist/dashboard-web/style.css +65 -3
  94. package/dist/dashboard.js +50 -4
  95. package/dist/dashboard.js.map +1 -1
  96. package/dist/i18n/en.d.ts.map +1 -1
  97. package/dist/i18n/en.js +4 -1
  98. package/dist/i18n/en.js.map +1 -1
  99. package/dist/i18n/zh.d.ts.map +1 -1
  100. package/dist/i18n/zh.js +4 -1
  101. package/dist/i18n/zh.js.map +1 -1
  102. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  103. package/dist/im/lark/event-dispatcher.js +141 -38
  104. package/dist/im/lark/event-dispatcher.js.map +1 -1
  105. package/dist/services/card-prefs-store.d.ts +2 -0
  106. package/dist/services/card-prefs-store.d.ts.map +1 -1
  107. package/dist/services/card-prefs-store.js +9 -1
  108. package/dist/services/card-prefs-store.js.map +1 -1
  109. package/dist/services/pi-transcript.d.ts +21 -1
  110. package/dist/services/pi-transcript.d.ts.map +1 -1
  111. package/dist/services/pi-transcript.js +188 -8
  112. package/dist/services/pi-transcript.js.map +1 -1
  113. package/dist/services/session-store.d.ts.map +1 -1
  114. package/dist/services/session-store.js +495 -11
  115. package/dist/services/session-store.js.map +1 -1
  116. package/dist/services/under-review-notify-store.d.ts +18 -0
  117. package/dist/services/under-review-notify-store.d.ts.map +1 -0
  118. package/dist/services/under-review-notify-store.js +67 -0
  119. package/dist/services/under-review-notify-store.js.map +1 -0
  120. package/dist/setup/open-platform-automation.d.ts +210 -2
  121. package/dist/setup/open-platform-automation.d.ts.map +1 -1
  122. package/dist/setup/open-platform-automation.js +538 -44
  123. package/dist/setup/open-platform-automation.js.map +1 -1
  124. package/dist/setup/open-platform-outcome.d.ts.map +1 -1
  125. package/dist/setup/open-platform-outcome.js +5 -0
  126. package/dist/setup/open-platform-outcome.js.map +1 -1
  127. package/dist/workflows/events/payloads.d.ts +6 -6
  128. package/dist/workflows/events/schema.d.ts +12 -12
  129. package/package.json +9 -7
  130. package/dist/dashboard-web/chunks/bot-defaults-page-67RAWCSQ.js +0 -1
  131. package/dist/dashboard-web/chunks/chunk-MMLTCXHP.js +0 -10
  132. package/dist/dashboard-web/chunks/chunk-NYY66DNC.js +0 -1
  133. 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
- * the per-bot file's entries when it exists, else the legacy `sessions.json`
405
- * rows belonging to this bot; scope repair applied, legacy card fields
406
- * stripped, closed rows included. Parse failures degrade to an empty store
407
- * exactly like the previous loader. */
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.