@shawnstack/quickforge 1.7.10 → 1.7.12

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 (124) hide show
  1. package/dist/assets/{AgentProfilesPage-Dw9orPNt.js → AgentProfilesPage-CApCpsDd.js} +1 -1
  2. package/dist/assets/ChatPanelHost-COTO7swS.js +48 -0
  3. package/dist/assets/CloudAccountSettingsPage-Be5r3dtv.js +1 -0
  4. package/dist/assets/{PluginsPage-Dj1bmsEf.js → PluginsPage-BIbIDfWY.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-BOqmBNCx.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-CFuvSATi.js → SettingsWorkspacePage-BCRtycRN.js} +304 -304
  7. package/dist/assets/ShareLinksSettingsPage-BrxQacJn.js +1 -0
  8. package/dist/assets/{SharedConversationPage-DLo05j7c.js → SharedConversationPage-DJa0M0El.js} +1 -1
  9. package/dist/assets/TerminalDock-Cqz_9mTC.js +2 -0
  10. package/dist/assets/WorkspaceInspector-C-GOK81D.js +36 -0
  11. package/dist/assets/{abnfDiagram-VRR7QNED-CMhVoYUh.js → abnfDiagram-VRR7QNED-DGklo3PO.js} +1 -1
  12. package/dist/assets/{architectureDiagram-ZJ3FMSHR-C52qL6Hg.js → architectureDiagram-ZJ3FMSHR-CioWLVTp.js} +1 -1
  13. package/dist/assets/{blockDiagram-677ZJIJ3-CM3XbLol.js → blockDiagram-677ZJIJ3-BJWBq5e9.js} +1 -1
  14. package/dist/assets/{c4Diagram-LMCZKHZV-D7Bh1ui1.js → c4Diagram-LMCZKHZV-BacRvqFK.js} +1 -1
  15. package/dist/assets/channel-CeXY-wZo.js +1 -0
  16. package/dist/assets/{chunk-32BRIVSS-xgrtbT8e.js → chunk-32BRIVSS-B53BNE2C.js} +1 -1
  17. package/dist/assets/{chunk-52WLFC77-CDbaHw80.js → chunk-52WLFC77-CYq4hT4f.js} +1 -1
  18. package/dist/assets/{chunk-C7G6YPKG-ZMLUvfLy.js → chunk-C7G6YPKG-KFaGpP_Z.js} +1 -1
  19. package/dist/assets/{chunk-EX3LRPZG-BcQhaqt7.js → chunk-EX3LRPZG-DqVd_ihG.js} +1 -1
  20. package/dist/assets/{chunk-FWX5IMBZ-DrnrdrSu.js → chunk-FWX5IMBZ-BxRjZkOP.js} +2 -2
  21. package/dist/assets/{chunk-HOUHSVGY-tdc-Z9eW.js → chunk-HOUHSVGY-C9QO8omv.js} +1 -1
  22. package/dist/assets/{chunk-ICXQ74PX-Cd7igVGt.js → chunk-ICXQ74PX-ClbGPgLV.js} +1 -1
  23. package/dist/assets/{chunk-MOJQB5TN-CLTjV5ov.js → chunk-MOJQB5TN-Ba4wlF1J.js} +1 -1
  24. package/dist/assets/{chunk-OGEWGWER-TgvkXkFX.js → chunk-OGEWGWER-DczhQmcx.js} +1 -1
  25. package/dist/assets/{chunk-PUDLZKDR-C5GMVYup.js → chunk-PUDLZKDR-CPWpcKUF.js} +1 -1
  26. package/dist/assets/{chunk-Q4XR5HBZ-fo5-HsqY.js → chunk-Q4XR5HBZ-CiF6T--X.js} +1 -1
  27. package/dist/assets/{chunk-V7JOEXUC-CEDazV6w.js → chunk-V7JOEXUC-Dxv0zwmw.js} +1 -1
  28. package/dist/assets/{chunk-VAUOI2AC-CWYjg8zE.js → chunk-VAUOI2AC-D3J5bw1P.js} +1 -1
  29. package/dist/assets/{chunk-VR4S4FIN-C_-eEj-9.js → chunk-VR4S4FIN-BXQSwwII.js} +1 -1
  30. package/dist/assets/{chunk-WYO6CB5R-NF-MsJJR.js → chunk-WYO6CB5R-C0azl-fk.js} +2 -2
  31. package/dist/assets/{chunk-ZGVPDNZ5-CRrAYi4g.js → chunk-ZGVPDNZ5-D_8F4Akx.js} +1 -1
  32. package/dist/assets/classDiagram-OUVF2IWQ-B8M6sf49.js +1 -0
  33. package/dist/assets/classDiagram-v2-EOCWNBFH-BY7fzBDH.js +1 -0
  34. package/dist/assets/{cynefinDiagram-TSTJHNR4-D4sZ7LYI.js → cynefinDiagram-TSTJHNR4-BFlsWz0B.js} +1 -1
  35. package/dist/assets/{dagre-VKFMJZFB-BlsnSh-Q.js → dagre-VKFMJZFB-DXhpjlEs.js} +1 -1
  36. package/dist/assets/{diagram-FQU43EPY-DW1yQiRO.js → diagram-FQU43EPY-DRVVOD9k.js} +1 -1
  37. package/dist/assets/{diagram-G47NLZAW-BTUAsFjb.js → diagram-G47NLZAW-BV9Gw1tL.js} +1 -1
  38. package/dist/assets/{diagram-NH7WQ7WH-BcaomqSd.js → diagram-NH7WQ7WH-CrUkXfRY.js} +1 -1
  39. package/dist/assets/{diagram-OA4YK3LP-DDLX7Xwr.js → diagram-OA4YK3LP-CuILjn7v.js} +1 -1
  40. package/dist/assets/{diagram-WEI45ONY-CIave0An.js → diagram-WEI45ONY-nlNN3tWB.js} +1 -1
  41. package/dist/assets/{ebnfDiagram-CCIWWBDH-Cr1Y6lPu.js → ebnfDiagram-CCIWWBDH-BuDgZt9n.js} +1 -1
  42. package/dist/assets/{erDiagram-Q63AITRT-D3EgiVVH.js → erDiagram-Q63AITRT-MEwxjmtP.js} +1 -1
  43. package/dist/assets/flowDiagram-23GEKE2U-BiUP-Scq.js +1 -0
  44. package/dist/assets/{ganttDiagram-NO4QXBWP-B0oyDIqK.js → ganttDiagram-NO4QXBWP-DNw5qghE.js} +1 -1
  45. package/dist/assets/{gitGraphDiagram-IHSO6WYX-uQtr6IqK.js → gitGraphDiagram-IHSO6WYX-CaTcakAt.js} +1 -1
  46. package/dist/assets/index-BWf6y3Kh.css +3 -0
  47. package/dist/assets/index-D3GWkXJR.js +66 -0
  48. package/dist/assets/{infoDiagram-FWYZ7A6U-B_GzlLIM.js → infoDiagram-FWYZ7A6U-DwfyP7dU.js} +1 -1
  49. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CKQIsSdU.js → ishikawaDiagram-FXEZZL3T-CmrzbOCV.js} +1 -1
  50. package/dist/assets/{journeyDiagram-5HDEW3XC-DrrvzZL1.js → journeyDiagram-5HDEW3XC-CYqe3sgr.js} +1 -1
  51. package/dist/assets/{kanban-definition-HUTT4EX6-CJQRTdXz.js → kanban-definition-HUTT4EX6-BMDO_mv0.js} +1 -1
  52. package/dist/assets/{line-B4DxX7tC.js → line-SbxJR8ZT.js} +1 -1
  53. package/dist/assets/lit-vendor-CinchGmI.js +2 -0
  54. package/dist/assets/local-tools-DY0yEU3T.js +350 -0
  55. package/dist/assets/{mcp-servers-dialog-CwHp0K4q.js → mcp-servers-dialog-7Xwu2RVW.js} +2 -2
  56. package/dist/assets/{mermaid.core-DWpShXCs.js → mermaid.core-CwXTzYjs.js} +3 -3
  57. package/dist/assets/{mindmap-definition-LN4V7U3C-CEVwZD78.js → mindmap-definition-LN4V7U3C-CLyJ78mO.js} +1 -1
  58. package/dist/assets/{pegDiagram-2B236MQR-CBLkBdnQ.js → pegDiagram-2B236MQR-4qFznrSw.js} +1 -1
  59. package/dist/assets/{pi-web-ui-DV5jKjSg.js → pi-web-ui-BNuvqKDT.js} +289 -289
  60. package/dist/assets/{pieDiagram-ENE6RG2P-8tCnwtWS.js → pieDiagram-ENE6RG2P-141sGxVe.js} +1 -1
  61. package/dist/assets/{quadrantDiagram-ABIIQ3AL-CWd28cHF.js → quadrantDiagram-ABIIQ3AL-SUI44pAX.js} +1 -1
  62. package/dist/assets/{railroadDiagram-RFXS5EU6-CI4ouOBs.js → railroadDiagram-RFXS5EU6-1oiZuYBB.js} +1 -1
  63. package/dist/assets/{requirementDiagram-TGXJPOKE-DtFz7sw4.js → requirementDiagram-TGXJPOKE-CfVishLi.js} +1 -1
  64. package/dist/assets/{sankeyDiagram-HTMAVEWB--VeOwwk9.js → sankeyDiagram-HTMAVEWB-DVu7J_Z9.js} +1 -1
  65. package/dist/assets/{sequenceDiagram-DBY2YBRQ-B8AHk5Oe.js → sequenceDiagram-DBY2YBRQ-D-Ip2yiS.js} +1 -1
  66. package/dist/assets/{skills-dialog-k9ycekIU.js → skills-dialog-C_JvWbAa.js} +3 -3
  67. package/dist/assets/{stateDiagram-2N3HPSRC-etXnJ8X8.js → stateDiagram-2N3HPSRC-Cg0hWwrp.js} +1 -1
  68. package/dist/assets/stateDiagram-v2-6OUMAXLB-DhmzNQd9.js +1 -0
  69. package/dist/assets/{swimlanes-5IMT3BWC-pabIKAmn.js → swimlanes-5IMT3BWC-Co5D-cGU.js} +1 -1
  70. package/dist/assets/swimlanesDiagram-G3AALYLV-BEvomQNa.js +8 -0
  71. package/dist/assets/{timeline-definition-FHXFAJF6-C55MKm3L.js → timeline-definition-FHXFAJF6-CBk4c_Ua.js} +1 -1
  72. package/dist/assets/{vennDiagram-L72KCM5P-CgNf1Q9W.js → vennDiagram-L72KCM5P-BII7wdRx.js} +1 -1
  73. package/dist/assets/{wardleyDiagram-EHGQE667-Drt5ytec.js → wardleyDiagram-EHGQE667-CkyJJIXP.js} +1 -1
  74. package/dist/assets/{xychartDiagram-FW5EYKEG-hhoEo_Ar.js → xychartDiagram-FW5EYKEG-B1A66Ks0.js} +1 -1
  75. package/dist/index.html +6 -6
  76. package/package.json +1 -1
  77. package/server/acp/server.mjs +3 -6
  78. package/server/agent-manager.mjs +192 -48
  79. package/server/approval-store.mjs +1 -0
  80. package/server/ask-store.mjs +78 -0
  81. package/server/auto-archive.mjs +55 -8
  82. package/server/index.mjs +172 -51
  83. package/server/lan-access-cutover.mjs +21 -4
  84. package/server/maintenance/downgrade-session-state-v1.mjs +46 -85
  85. package/server/maintenance/export-session-state-v1.mjs +1 -11
  86. package/server/routes/agent.mjs +9 -0
  87. package/server/routes/backup.mjs +1 -1
  88. package/server/routes/storage.mjs +63 -49
  89. package/server/scheduled-runs-cutover.mjs +15 -1
  90. package/server/session-index-service.mjs +57 -391
  91. package/server/session-state-backup.mjs +56 -5
  92. package/server/session-state-import.mjs +174 -0
  93. package/server/session-state-maintenance.mjs +126 -0
  94. package/server/session-state-service.mjs +128 -210
  95. package/server/share-cutover.mjs +21 -4
  96. package/server/sqlite/database.mjs +142 -7
  97. package/server/sqlite/lan-access-repository.mjs +6 -4
  98. package/server/sqlite/migrations.mjs +109 -0
  99. package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
  100. package/server/sqlite/session-index-repository.mjs +59 -208
  101. package/server/sqlite/session-state-repository.mjs +337 -387
  102. package/server/sqlite/share-repository.mjs +56 -45
  103. package/server/startup-state.mjs +107 -0
  104. package/server/storage.mjs +218 -454
  105. package/server/tools/definitions.mjs +18 -0
  106. package/server/tools/index.mjs +14 -2
  107. package/server/utils/logger.mjs +15 -4
  108. package/dist/assets/ChatPanelHost-CW3-MQyL.js +0 -48
  109. package/dist/assets/CloudAccountSettingsPage-CDkKN1Sz.js +0 -1
  110. package/dist/assets/ScheduledTasksPage-C9lOTi75.js +0 -2
  111. package/dist/assets/ShareLinksSettingsPage-CH_t33A3.js +0 -1
  112. package/dist/assets/TerminalDock-CSOVub-m.js +0 -2
  113. package/dist/assets/WorkspaceInspector-DPXgLIum.js +0 -36
  114. package/dist/assets/channel-Duu1CuH9.js +0 -1
  115. package/dist/assets/classDiagram-OUVF2IWQ-Cc63fLWP.js +0 -1
  116. package/dist/assets/classDiagram-v2-EOCWNBFH-CoXU0qT6.js +0 -1
  117. package/dist/assets/flowDiagram-23GEKE2U-BS7KXcqn.js +0 -1
  118. package/dist/assets/index-DQBOm9gt.js +0 -66
  119. package/dist/assets/index-ps85guTs.css +0 -3
  120. package/dist/assets/lit-vendor-DvaQX_UQ.js +0 -2
  121. package/dist/assets/local-tools-BxfQeIUQ.js +0 -270
  122. package/dist/assets/stateDiagram-v2-6OUMAXLB-Dms3pICj.js +0 -1
  123. package/dist/assets/swimlanesDiagram-G3AALYLV-B14aWjv6.js +0 -8
  124. package/server/session-state-cutover.mjs +0 -563
@@ -57,31 +57,8 @@ export const stores = new Set([
57
57
  'agent-profile-overrides',
58
58
  ])
59
59
 
60
- // --- In-memory session bucket index ---
61
- // Avoids O(n) directory scanning in findSessionBucket() by caching
62
- // sessionId → { scope, projectId } lookups. Populated lazily on first
63
- // lookup and kept up-to-date by write/delete paths.
64
- /** @type {Map<string, { scope: string, projectId?: string }>} */
65
- const sessionBucketIndex = new Map()
66
- let bucketIndexBuilt = false
67
-
68
60
  // Monotonic in-process revisions for cache invalidation in route-level indexes.
69
61
  const storeRevisions = new Map()
70
- let sessionMetadataCommitHook = null
71
-
72
- export function registerSessionMetadataCommitHook(hook) {
73
- if (hook !== null && typeof hook !== 'function') throw new TypeError('Session metadata commit hook must be a function or null')
74
- sessionMetadataCommitHook = hook
75
- }
76
-
77
- async function notifySessionMetadataCommit(change) {
78
- if (!sessionMetadataCommitHook) return
79
- try {
80
- await sessionMetadataCommitHook(change)
81
- } catch {
82
- // JSON is authoritative. Index failures must never change commit semantics.
83
- }
84
- }
85
62
 
86
63
  function bumpStoreRevision(storeName) {
87
64
  storeRevisions.set(storeName, (storeRevisions.get(storeName) || 0) + 1)
@@ -412,62 +389,52 @@ async function writeJsonAtomic(file, data) {
412
389
  await fs.mkdir(path.dirname(file), { recursive: true })
413
390
  const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
414
391
  await fs.writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
415
- await fs.rename(tmp, file)
416
- }
417
-
418
- export async function materializeSessionJsonMirrorEntry(entry) {
419
- const bucket = entry.scope === 'project' ? { scope: 'project', projectId: entry.projectId } : { scope: 'global' }
420
- if (entry.operation === 'upsert') {
421
- await writeJsonAtomic(sessionDataFile(entry.sessionId, bucket), entry.state)
422
- const file = sessionStoreFile('sessions-metadata', bucket)
423
- const metadata = await readJsonFile(file, {})
424
- metadata[entry.sessionId] = entry.metadata
425
- await writeJsonAtomic(file, metadata)
426
- sessionBucketIndex.set(entry.sessionId, bucket)
427
- bumpStoreRevision('sessions-metadata')
428
- return
392
+ // Windows AV (e.g. Defender real-time scan) can briefly hold the freshly
393
+ // written tmp file between writeFile and rename, surfacing as EPERM/EBUSY.
394
+ // A bounded retry closes that window; the rename stays atomic either way.
395
+ const delays = [25, 50, 100]
396
+ for (let attempt = 0; ; attempt += 1) {
397
+ try {
398
+ await fs.rename(tmp, file)
399
+ return
400
+ } catch (error) {
401
+ if (attempt >= delays.length || !['EPERM', 'EBUSY', 'EACCES'].includes(error?.code)) {
402
+ try { await fs.unlink(tmp).catch(() => {}) } catch { /* best effort cleanup */ }
403
+ throw error
404
+ }
405
+ await new Promise((resolve) => { setTimeout(resolve, delays[attempt]) })
406
+ }
429
407
  }
430
- const file = sessionStoreFile('sessions-metadata', bucket)
431
- const metadata = await readJsonFile(file, {})
432
- delete metadata[entry.sessionId]
433
- await writeJsonAtomic(file, metadata)
434
- await fs.rm(sessionDataFile(entry.sessionId, bucket), { force: true })
435
- sessionBucketIndex.delete(entry.sessionId)
436
- bumpStoreRevision('sessions-metadata')
437
- try {
438
- const { deleteSessionAssets } = await import('./session-assets.mjs')
439
- await deleteSessionAssets(bucket, entry.sessionId)
440
- } catch { /* Sidecars are best-effort after the authoritative commit. */ }
441
- try {
442
- const { deleteCloudChatIdempotencySession } = await import('./cloud/chat-idempotency-store.mjs')
443
- await deleteCloudChatIdempotencySession(entry.sessionId)
444
- } catch { /* Sidecars are best-effort after the authoritative commit. */ }
445
408
  }
446
409
 
447
- export async function readPhysicalSessionStateBuckets() {
410
+ // --- Offline JSON export helpers (v1 downgrade escape hatch) ---
411
+ // Storage v2 keeps SQLite authoritative; nothing here runs on the live write
412
+ // path. The offline downgrade tool writes the legacy v1 JSON layout (per
413
+ // session body file + per-bucket sessions-metadata.json) through these
414
+ // helpers so the file layout and path-safety rules stay owned by storage.mjs.
415
+ export async function writeSessionStateJsonBody(bucket, sessionId, state) {
448
416
  await ensureStorage()
449
- const buckets = [
450
- { scope: 'global', projectId: null },
451
- ...(await listProjectIds()).map((projectId) => ({ scope: 'project', projectId })),
452
- ]
453
- const result = []
454
- for (const bucket of buckets) {
455
- const metadata = await readJsonFile(sessionStoreFile('sessions-metadata', bucket), {})
456
- const sessions = {}
457
- for (const file of await listSessionDataFiles(bucket)) {
458
- const fileSessionId = path.basename(file, '.json')
459
- sessions[fileSessionId] = await readJsonFile(file, null)
460
- }
461
- result.push({ ...bucket, sessions, metadata })
462
- }
463
- return result
417
+ await writeJsonAtomic(sessionDataFile(sessionId, bucket), state)
418
+ }
419
+
420
+ export async function writeSessionStateMetadataBucket(bucket, metadata) {
421
+ await ensureStorage()
422
+ await writeJsonAtomic(sessionStoreFile('sessions-metadata', bucket), metadata ?? {})
423
+ }
424
+
425
+ export async function removeSessionStateJsonBody(bucket, sessionId) {
426
+ await fs.rm(sessionDataFile(sessionId, bucket), { force: true })
427
+ }
428
+
429
+ export async function readSessionStateJsonBody(bucket, sessionId) {
430
+ await ensureStorage()
431
+ return readJsonFile(sessionDataFile(sessionId, bucket), null)
464
432
  }
465
433
 
466
- // Streaming filesystem adapter for the session-state JSON cutover: unlike
467
- // readPhysicalSessionStateBuckets (which materializes every bucket into
468
- // memory at once), the cutover consumes sessions one file at a time so a
469
- // multi-GB library never needs to be fully resident. It reuses the same
470
- // physical helpers, so the layout and read semantics stay identical:
434
+ // Streaming filesystem adapter over the legacy session-state JSON layout,
435
+ // kept for the one-shot startup import (importSessionStateFromJson) and the
436
+ // offline downgrade/export tools. It consumes sessions one file at a time so a
437
+ // multi-GB library never needs to be fully resident:
471
438
  // - listBuckets(): global bucket + one bucket per project directory
472
439
  // - listSessionFiles(bucket): session ids from sessions/<id>.json file names
473
440
  // - readSessionState(bucket, sessionId): parsed session body (null if absent)
@@ -491,9 +458,88 @@ export function createPhysicalSessionStateFsAdapter() {
491
458
  }
492
459
  }
493
460
 
461
+ // Storage v2: SQLite is always authoritative, so every session-domain facade
462
+ // call routes straight to the session state service (single dynamic import,
463
+ // shared process singleton).
494
464
  async function sessionStateFacade() {
495
465
  const service = await import('./session-state-service.mjs')
496
- return service.isSessionStateAuthoritative() ? service : null
466
+ return service
467
+ }
468
+
469
+ function saveSessionBodyViaFacade(facade, sessionId, value) {
470
+ const saved = facade.saveSessionBody(sessionId, value)
471
+ bumpStoreRevision('sessions-metadata')
472
+ return saved.state
473
+ }
474
+
475
+ async function atomicSessionStateUpdateViaFacade(facade, sessionId, updateFn) {
476
+ const updated = await facade.atomicSessionStateUpdate(sessionId, updateFn)
477
+ bumpStoreRevision('sessions-metadata')
478
+ return updated
479
+ }
480
+
481
+ // Session delete: the authoritative row removal commits first, then the
482
+ // sidecar cleanup (assets + cloud chat idempotency keys) runs best-effort and
483
+ // never rolls the delete back — the ordering the JSON mirror drain used to
484
+ // provide, inlined now that there is no mirror.
485
+ async function deleteSessionStateViaFacade(facade, sessionId) {
486
+ const record = facade.readSessionStateRecord(sessionId)
487
+ const bucket = record ? (record.scope === 'project' ? { scope: 'project', projectId: record.projectId } : { scope: 'global' }) : null
488
+ const deleted = facade.deleteSessionState(sessionId)
489
+ if (deleted) {
490
+ bumpStoreRevision('sessions-metadata')
491
+ if (bucket) {
492
+ try {
493
+ const { deleteSessionAssets } = await import('./session-assets.mjs')
494
+ await deleteSessionAssets(bucket, sessionId)
495
+ } catch { /* Sidecars are best-effort after the authoritative commit. */ }
496
+ try {
497
+ const { deleteCloudChatIdempotencySession } = await import('./cloud/chat-idempotency-store.mjs')
498
+ await deleteCloudChatIdempotencySession(sessionId)
499
+ } catch { /* Best-effort after the authoritative commit. */ }
500
+ }
501
+ }
502
+ return deleted
503
+ }
504
+
505
+ async function atomicSessionMetadataUpdateViaFacade(facade, scope, projectId, updateFn) {
506
+ const updated = await facade.atomicSessionMetadataStateUpdate(scope, projectId, updateFn)
507
+ bumpStoreRevision('sessions-metadata')
508
+ return updated
509
+ }
510
+
511
+ async function atomicSessionRecordUpdateViaFacade(facade, sessionId, updateFn) {
512
+ const updated = await facade.atomicSessionRecordUpdate(sessionId, updateFn)
513
+ bumpStoreRevision('sessions-metadata')
514
+ return updated
515
+ }
516
+
517
+ // Legacy 'sessions-metadata' semantics: a set of per-bucket stores, each an
518
+ // independent read-modify-write unit. The authoritative equivalent applies
519
+ // updateFn to each bucket's metadata map and writes every bucket back through
520
+ // updateSessionMetadataBucket — never merging all scopes into one global map.
521
+ // Session ids are unique across buckets, so the per-bucket maps partition the
522
+ // legacy merged-store view and per-entry updateFns see exactly the same
523
+ // sessions, one bucket at a time. Empty store: updateFn still runs once
524
+ // against the (empty) global bucket, matching the legacy empty-file behavior.
525
+ function atomicSessionMetadataBucketUpdateViaFacade(facade, updateFn) {
526
+ const buckets = facade.readSessionMetadataBuckets()
527
+ const targets = buckets.length > 0 ? buckets : [{ scope: 'global', projectId: null, metadata: {} }]
528
+ let updated = {}
529
+ for (const bucket of targets) {
530
+ const updatedBucket = updateFn({ ...bucket.metadata })
531
+ updated = facade.updateSessionMetadataBucket(bucket.scope, bucket.projectId, () => updatedBucket)
532
+ }
533
+ bumpStoreRevision('sessions-metadata')
534
+ return updated
535
+ }
536
+
537
+ async function atomicSessionStoreReplaceViaFacade(facade, updateFn) {
538
+ const current = facade.readSessionStateStore('sessions')
539
+ const updated = await updateFn(current)
540
+ facade.replaceSessionStateStore('sessions', updated)
541
+ bumpStoreRevision('sessions-metadata')
542
+ return updated
497
543
  }
498
544
 
499
545
  async function readConfigFile() {
@@ -551,105 +597,8 @@ async function listSessionDataFiles(bucket) {
551
597
  .map((entry) => path.join(dir, entry.name))
552
598
  }
553
599
 
554
- async function readSessionValuesScoped(scope, projectId) {
555
- const bucket = scope === 'project' ? { scope: 'project', projectId } : { scope: 'global' }
556
- const files = await listSessionDataFiles(bucket)
557
- const result = {}
558
- for (const file of files) {
559
- const value = await readJsonFile(file, null)
560
- if (value?.id) result[value.id] = value
561
- }
562
- return result
563
- }
564
-
565
- async function readAllSessionValues() {
566
- const result = await readSessionValuesScoped('global')
567
- for (const projectId of await listProjectIds()) {
568
- Object.assign(result, await readSessionValuesScoped('project', projectId))
569
- }
570
- return result
571
- }
572
-
573
- function sameSessionBucket(left, right) {
574
- if (!left || !right) return false
575
- return left.scope === right.scope && (left.projectId || undefined) === (right.projectId || undefined)
576
- }
577
-
578
- function updateSessionMetadataBucketIndex(bucket, previousData, nextData) {
579
- const ids = new Set([
580
- ...Object.keys(previousData || {}),
581
- ...Object.keys(nextData || {}),
582
- ])
583
-
584
- for (const sessionId of ids) {
585
- const meta = nextData?.[sessionId]
586
- if (meta && typeof meta === 'object') {
587
- sessionBucketIndex.set(sessionId, sessionBucket(meta))
588
- continue
589
- }
590
-
591
- if (sameSessionBucket(sessionBucketIndex.get(sessionId), bucket)) {
592
- sessionBucketIndex.delete(sessionId)
593
- }
594
- }
595
- }
596
-
597
- async function writeSessionValueFile(sessionId, value) {
598
- await writeJsonAtomic(sessionDataFile(sessionId, sessionBucket(value)), value)
599
- // Keep in-memory index current
600
- if (value) sessionBucketIndex.set(sessionId, sessionBucket(value))
601
- }
602
-
603
- async function writeSessionValues(data) {
604
- const nextIds = new Set(Object.keys(data || {}))
605
- const existingFiles = [
606
- ...(await listSessionDataFiles({ scope: 'global' })),
607
- ...(await Promise.all(
608
- (await listProjectIds()).map((projectId) => listSessionDataFiles({ scope: 'project', projectId })),
609
- )).flat(),
610
- ]
611
-
612
- await Promise.all(
613
- existingFiles.map(async (file) => {
614
- const sessionId = path.basename(file, '.json')
615
- if (!nextIds.has(sessionId)) {
616
- const bucket = sessionBucketIndex.get(sessionId) || await findSessionBucketByDataFile(sessionId)
617
- await fs.rm(file, { force: true })
618
- if (bucket) {
619
- const { deleteSessionAssets } = await import('./session-assets.mjs')
620
- await deleteSessionAssets(bucket, sessionId)
621
- }
622
- sessionBucketIndex.delete(sessionId)
623
- }
624
- }),
625
- )
626
-
627
- await Promise.all(
628
- Object.entries(data || {}).map(([sessionId, value]) => writeSessionValueFile(sessionId, value)),
629
- )
630
- }
631
-
632
- async function rebuildBucketIndex() {
633
- sessionBucketIndex.clear()
634
- // Global bucket
635
- try {
636
- const globalMeta = await readJsonFile(sessionStoreFile('sessions-metadata', { scope: 'global' }), {})
637
- for (const [id, meta] of Object.entries(globalMeta)) {
638
- if (meta && typeof meta === 'object') sessionBucketIndex.set(id, sessionBucket(meta))
639
- }
640
- } catch { /* ignore */ }
641
- // Project buckets
642
- for (const projectId of await listProjectIds()) {
643
- try {
644
- const meta = await readJsonFile(sessionStoreFile('sessions-metadata', { scope: 'project', projectId }), {})
645
- for (const [id, entry] of Object.entries(meta)) {
646
- if (entry && typeof entry === 'object') sessionBucketIndex.set(id, sessionBucket(entry))
647
- }
648
- } catch { /* ignore */ }
649
- }
650
- bucketIndexBuilt = true
651
- }
652
-
600
+ // Legacy JSON body file scan: after the offline downgrade escape hatch the
601
+ // authoritative SQLite store can be empty while body files exist on disk.
653
602
  async function findSessionBucketByDataFile(sessionId) {
654
603
  assertSafePathSegment(sessionId)
655
604
  const buckets = [
@@ -670,249 +619,102 @@ async function findSessionBucketByDataFile(sessionId) {
670
619
  return null
671
620
  }
672
621
 
622
+ // Session bucket lookup for asset sidecars: the authoritative store first,
623
+ // then the legacy JSON body files (offline downgrade escape hatch layout).
673
624
  export async function findSessionBucket(sessionId) {
674
- if (!bucketIndexBuilt) {
675
- await ensureStorage()
676
- await rebuildBucketIndex()
677
- }
678
- const indexed = sessionBucketIndex.get(sessionId)
679
- if (indexed) return indexed
680
-
681
- const recovered = await findSessionBucketByDataFile(sessionId)
682
- if (recovered) sessionBucketIndex.set(sessionId, recovered)
683
- return recovered
625
+ const facade = await sessionStateFacade()
626
+ const record = facade.readSessionStateRecord(sessionId)
627
+ if (record) return record.scope === 'project' ? { scope: 'project', projectId: record.projectId } : { scope: 'global' }
628
+ return findSessionBucketByDataFile(sessionId)
684
629
  }
685
630
 
686
631
  export async function readSessionValue(sessionId) {
687
632
  const facade = await sessionStateFacade()
688
- if (facade) return facade.readSessionStateValue(sessionId)
689
- const bucket = await findSessionBucket(sessionId)
690
- if (bucket) return readJsonFile(sessionDataFile(sessionId, bucket), null)
691
-
692
- const recoveredBucket = await findSessionBucketByDataFile(sessionId)
693
- if (!recoveredBucket) return null
694
- sessionBucketIndex.set(sessionId, recoveredBucket)
695
- return readJsonFile(sessionDataFile(sessionId, recoveredBucket), null)
633
+ return facade.readSessionStateValue(sessionId)
696
634
  }
697
635
 
698
636
  export async function writeSessionValue(sessionId, value) {
699
637
  const facade = await sessionStateFacade()
700
- if (facade) {
701
- const saved = facade.saveSessionBody(sessionId, value)
702
- void facade.drainSessionJsonMirror()
703
- return saved.state
704
- }
705
- return enqueueWrite('sessions', async () => {
706
- await ensureStorage()
707
- await writeSessionValueFile(sessionId, value)
708
- })
638
+ return saveSessionBodyViaFacade(facade, sessionId, value)
709
639
  }
710
640
 
711
641
  export async function atomicSessionValueUpdate(sessionId, updateFn) {
712
642
  const facade = await sessionStateFacade()
713
- if (facade) {
714
- const updated = await facade.atomicSessionStateUpdate(sessionId, updateFn)
715
- void facade.drainSessionJsonMirror()
716
- return updated
717
- }
718
- return enqueueWrite('sessions', async () => {
719
- await ensureStorage()
720
- const current = await readSessionValue(sessionId)
721
- if (!current) return null
722
- const updated = updateFn(current)
723
- await writeSessionValueFile(sessionId, updated)
724
- return updated
725
- })
643
+ return atomicSessionStateUpdateViaFacade(facade, sessionId, updateFn)
726
644
  }
727
645
 
728
646
  export async function deleteSessionValue(sessionId) {
729
647
  const facade = await sessionStateFacade()
730
- if (facade) {
731
- const deleted = facade.deleteSessionState(sessionId)
732
- await facade.drainSessionJsonMirror()
733
- return deleted
734
- }
735
- return enqueueWrite('sessions', async () => {
736
- const bucket = await findSessionBucket(sessionId)
737
- if (!bucket) {
738
- const { deleteCloudChatIdempotencySession } = await import('./cloud/chat-idempotency-store.mjs')
739
- await deleteCloudChatIdempotencySession(sessionId)
740
- return
741
- }
742
- await fs.rm(sessionDataFile(sessionId, bucket), { force: true })
743
- const { deleteSessionAssets } = await import('./session-assets.mjs')
744
- await deleteSessionAssets(bucket, sessionId)
745
- const { deleteCloudChatIdempotencySession } = await import('./cloud/chat-idempotency-store.mjs')
746
- await deleteCloudChatIdempotencySession(sessionId)
747
- sessionBucketIndex.delete(sessionId)
748
- })
648
+ return deleteSessionStateViaFacade(facade, sessionId)
749
649
  }
750
650
 
751
651
  /**
752
652
  * Whether the session state service is currently authoritative (SQLite-backed).
753
- * Re-exported for callers that must branch write behavior before invoking the
754
- * facade (e.g. auto-archive chooses a single atomic transaction vs the legacy
755
- * two-step JSON compensation flow).
653
+ * Storage v2: always true — kept for callers that branch write behavior
654
+ * (e.g. auto-archive's single atomic transaction path).
756
655
  */
757
656
  export async function isSessionStateAuthoritative() {
758
- const facade = await sessionStateFacade()
759
- return Boolean(facade)
657
+ return true
760
658
  }
761
659
 
762
660
  /**
763
661
  * Write a session body and ensure its derived metadata exists so a single PUT
764
- * never leaves a body orphaned. Authoritative: one SQLite transaction for
765
- * body + derived/merged metadata. JSON: body file plus derived metadata merged
766
- * onto the existing metadata record within the shared metadata write queue.
662
+ * never leaves a body orphaned: one SQLite transaction for body + derived/
663
+ * merged metadata.
767
664
  */
768
665
  export async function writeSessionValueWithMetadata(sessionId, value) {
769
666
  const facade = await sessionStateFacade()
770
- if (facade) {
771
- const saved = facade.saveSessionBody(sessionId, value)
772
- void facade.drainSessionJsonMirror()
773
- return saved.state
774
- }
775
- const saved = await enqueueWrite('sessions', async () => {
776
- await ensureStorage()
777
- await writeSessionValueFile(sessionId, value)
778
- })
779
- if (value && typeof value === 'object' && !Array.isArray(value)) {
780
- const bucket = sessionBucket(value)
781
- const file = sessionStoreFile('sessions-metadata', bucket)
782
- await enqueueWrite('sessions-metadata', async () => {
783
- const previous = await readJsonFile(file, {})
784
- const existing = previous[sessionId]
785
- const derived = {
786
- id: sessionId,
787
- title: typeof value.title === 'string' ? value.title : (existing?.title ?? 'New chat'),
788
- titleSource: value.titleSource ?? existing?.titleSource,
789
- createdAt: value.createdAt ?? existing?.createdAt ?? new Date().toISOString(),
790
- lastModified: value.lastModified ?? existing?.lastModified ?? value.createdAt ?? new Date().toISOString(),
791
- messageCount: Array.isArray(value.messages) ? value.messages.length : (existing?.messageCount ?? 0),
792
- stateVersion: Number.isInteger(value.stateVersion) && value.stateVersion >= 0 ? value.stateVersion : (existing?.stateVersion ?? 0),
793
- scope: bucket.scope,
794
- ...(bucket.scope === 'project' ? { projectId: bucket.projectId } : {}),
795
- }
796
- const next = { ...previous, [sessionId]: existing ? { ...existing, ...derived } : derived }
797
- await writeJsonAtomic(file, next)
798
- updateSessionMetadataBucketIndex(bucket, previous, next)
799
- bumpStoreRevision('sessions-metadata')
800
- await notifySessionMetadataCommit({ ...bucket, previous, next })
801
- })
802
- }
803
- return saved
667
+ return saveSessionBodyViaFacade(facade, sessionId, value)
804
668
  }
805
669
 
806
670
  /**
807
- * Delete an entire session (body + metadata). Authoritative: one SQLite
808
- * transaction; JSON mirror materialization runs best-effort afterwards and
671
+ * Delete an entire session (body + metadata): one SQLite transaction; sidecar
672
+ * cleanup (assets, cloud chat idempotency) runs best-effort afterwards and
809
673
  * failures never roll back the committed session delete.
810
674
  */
811
675
  export async function deleteSessionWithMetadata(sessionId) {
812
676
  const facade = await sessionStateFacade()
813
- if (facade) {
814
- const deleted = facade.deleteSessionState(sessionId)
815
- await facade.drainSessionJsonMirror()
816
- return deleted
817
- }
818
- await deleteSessionValue(sessionId)
819
- await atomicUpdate('sessions-metadata', (data) => {
820
- delete data[sessionId]
821
- return data
822
- })
677
+ return deleteSessionStateViaFacade(facade, sessionId)
823
678
  }
824
679
 
825
680
  /**
826
- * Atomically update a session record (body + metadata together).
827
- * Authoritative: single SQLite transaction with CAS retry on conflict.
828
- * JSON: body write plus scoped metadata merge (legacy two-step).
681
+ * Atomically update a session record (body + metadata together): single
682
+ * SQLite transaction with CAS retry on conflict.
829
683
  */
830
684
  export async function atomicSessionRecordUpdate(sessionId, updateFn) {
831
685
  const facade = await sessionStateFacade()
832
- if (facade) {
833
- const updated = await facade.atomicSessionRecordUpdate(sessionId, updateFn)
834
- void facade.drainSessionJsonMirror()
835
- return updated
836
- }
837
- return enqueueWrite('sessions', async () => {
838
- const current = await readSessionValue(sessionId)
839
- if (!current) return null
840
- const bucket = sessionBucket(current)
841
- const metadataFile = sessionStoreFile('sessions-metadata', bucket)
842
- const metadata = (await readJsonFile(metadataFile, {}))[sessionId] || {}
843
- const updated = await updateFn({
844
- state: structuredClone(current),
845
- metadata: structuredClone(metadata),
846
- revision: null,
847
- stateVersion: Number.isInteger(current.stateVersion) ? current.stateVersion : 0,
848
- })
849
- if (!updated) return null
850
- await writeSessionValueFile(sessionId, updated.state)
851
- await enqueueWrite('sessions-metadata', async () => {
852
- const previousMetadata = await readJsonFile(metadataFile, {})
853
- const nextMetadata = { ...previousMetadata, [sessionId]: { ...previousMetadata[sessionId], ...(updated.metadata || {}), id: sessionId } }
854
- await writeJsonAtomic(metadataFile, nextMetadata)
855
- updateSessionMetadataBucketIndex(bucket, previousMetadata, nextMetadata)
856
- bumpStoreRevision('sessions-metadata')
857
- await notifySessionMetadataCommit({ ...bucket, previous: previousMetadata, next: nextMetadata })
858
- })
859
- return { ...updated, state: updated.state }
860
- })
686
+ return atomicSessionRecordUpdateViaFacade(facade, sessionId, updateFn)
861
687
  }
862
688
 
863
689
  /**
864
690
  * Apply a multi-session batch of set/delete operations across
865
- * `sessions`/`sessions-metadata` in a single authoritative SQLite transaction.
866
- * The JSON fallback executes operations sequentially (legacy pre-cutover path).
691
+ * `sessions`/`sessions-metadata` in a single SQLite transaction.
867
692
  */
868
693
  export async function applySessionBatch(operations) {
869
694
  if (!Array.isArray(operations) || operations.length === 0) throw new TypeError('Session batch operations are required')
870
695
  const facade = await sessionStateFacade()
871
- if (facade) {
872
- const result = facade.applySessionBatch(operations)
873
- void facade.drainSessionJsonMirror()
874
- return result
875
- }
696
+ // Resolve buckets for the deletions before the transaction so sidecar
697
+ // cleanup (assets, cloud chat idempotency) can run after the commit —
698
+ // best-effort, never rolling the batch back.
699
+ const deletedRecords = []
876
700
  for (const operation of operations) {
877
- if (!['sessions', 'sessions-metadata'].includes(operation?.store)) throw new TypeError('Session batch only accepts sessions and sessions-metadata')
878
- if (!['set', 'delete'].includes(operation?.type)) throw new TypeError('Invalid session batch operation')
879
- if (typeof operation.key !== 'string' || !operation.key) throw new TypeError('Session batch key is required')
701
+ if (operation?.type !== 'delete' || operation?.store !== 'sessions') continue
702
+ const record = facade.readSessionStateRecord(operation.key)
703
+ if (record) deletedRecords.push(record)
880
704
  }
881
- const result = { saved: 0, deleted: 0 }
882
- for (const operation of operations) {
883
- if (operation.type === 'delete') {
884
- if (operation.store === 'sessions') {
885
- const before = await readSessionValue(operation.key)
886
- await deleteSessionWithMetadata(operation.key)
887
- if (before) result.deleted += 1
888
- } else {
889
- const error = new TypeError('Metadata-only delete is not allowed')
890
- error.errorCode = 'SESSION_FULL_DELETE_REQUIRED'
891
- error.statusCode = 409
892
- throw error
893
- }
894
- continue
895
- }
896
- if (operation.store === 'sessions') {
897
- if (!operation.value || typeof operation.value !== 'object' || Array.isArray(operation.value)) throw new TypeError(`Invalid session body: ${operation.key}`)
898
- await writeSessionValueWithMetadata(operation.key, operation.value)
899
- result.saved += 1
900
- } else {
901
- if (!operation.value || typeof operation.value !== 'object' || Array.isArray(operation.value)) throw new TypeError(`Invalid session metadata: ${operation.key}`)
902
- const existing = await readSessionValue(operation.key)
903
- if (!existing) {
904
- const error = new TypeError(`Session body is required for a new session: ${operation.key}`)
905
- error.errorCode = 'SESSION_STATE_REQUIRED'
906
- error.statusCode = 409
907
- throw error
908
- }
909
- await atomicUpdate('sessions-metadata', (data) => {
910
- data[operation.key] = { ...(data[operation.key] || {}), ...operation.value }
911
- return data
912
- })
913
- result.saved += 1
914
- }
705
+ const result = facade.applySessionBatch(operations)
706
+ for (const record of deletedRecords) {
707
+ const bucket = record.scope === 'project' ? { scope: 'project', projectId: record.projectId } : { scope: 'global' }
708
+ try {
709
+ const { deleteSessionAssets } = await import('./session-assets.mjs')
710
+ await deleteSessionAssets(bucket, record.sessionId)
711
+ } catch { /* Sidecars are best-effort after the authoritative commit. */ }
712
+ try {
713
+ const { deleteCloudChatIdempotencySession } = await import('./cloud/chat-idempotency-store.mjs')
714
+ await deleteCloudChatIdempotencySession(record.sessionId)
715
+ } catch { /* Best-effort after the authoritative commit. */ }
915
716
  }
717
+ bumpStoreRevision('sessions-metadata')
916
718
  return result
917
719
  }
918
720
 
@@ -923,38 +725,29 @@ async function listSessionStoreFiles(storeName) {
923
725
  ]
924
726
  }
925
727
 
728
+ // JSON bucket-store reader for the non-SQLite stores that still live as
729
+ // per-bucket files under conversations/ (scheduled-tasks, custom-agents).
730
+ // Session stores read through the SQLite facade (see readStore).
926
731
  async function readSessionStore(storeName) {
927
- if (storeName === 'sessions') return readAllSessionValues()
928
-
929
732
  const result = { ...await readJsonFile(sessionStoreFile(storeName, { scope: 'global' }), {}) }
930
733
  for (const projectId of await listProjectIds()) {
931
734
  const data = await readJsonFile(sessionStoreFile(storeName, { scope: 'project', projectId }), {})
932
- if (storeName !== 'sessions-metadata') {
933
- Object.assign(result, data)
934
- continue
935
- }
936
-
937
- for (const [id, value] of Object.entries(data)) {
938
- result[id] = value && typeof value === 'object'
939
- ? { ...value, scope: 'project', projectId: value.projectId || projectId }
940
- : value
941
- }
735
+ Object.assign(result, data)
942
736
  }
943
737
  return result
944
738
  }
945
739
 
946
740
  export async function readSessionStoreScoped(storeName, scope, projectId) {
947
741
  await ensureStorage()
948
- const facade = await sessionStateFacade()
949
- if (facade && (storeName === 'sessions' || storeName === 'sessions-metadata')) {
742
+ if (storeName === 'sessions' || storeName === 'sessions-metadata') {
743
+ const facade = await sessionStateFacade()
950
744
  return facade.readSessionStateStore(storeName, { scope, projectId })
951
745
  }
952
- if (storeName === 'sessions') return readSessionValuesScoped(scope, projectId)
953
-
954
746
  const file = sessionStoreFile(storeName, { scope, projectId })
955
747
  return readJsonFile(file, {})
956
748
  }
957
749
 
750
+ // Physical JSON bucket reader (offline tooling / post-downgrade layout).
958
751
  export async function readPhysicalSessionMetadataBuckets() {
959
752
  await ensureStorage()
960
753
  const buckets = [
@@ -974,12 +767,17 @@ export async function readPhysicalSessionMetadataBuckets() {
974
767
  return buckets
975
768
  }
976
769
 
977
- async function writeSessionStore(storeName, data) {
978
- if (storeName === 'sessions') {
979
- await writeSessionValues(data)
980
- return
981
- }
770
+ // Authoritative metadata bucket summaries straight from the sessions table.
771
+ export async function readAuthoritativeSessionMetadataBuckets() {
772
+ const facade = await sessionStateFacade()
773
+ return facade.readSessionMetadataBuckets()
774
+ }
982
775
 
776
+ // JSON bucket-store writer for the non-SQLite stores that still live as
777
+ // per-bucket files under conversations/ (scheduled-tasks, custom-agents) and
778
+ // for the one-shot legacy layout migration. Session bodies/metadata go
779
+ // through the SQLite facade instead (see writeStore).
780
+ async function writeSessionStore(storeName, data) {
983
781
  const buckets = new Map()
984
782
 
985
783
  for (const [key, value] of Object.entries(data || {})) {
@@ -994,46 +792,25 @@ async function writeSessionStore(storeName, data) {
994
792
  filesToWrite.add(sessionStoreFile(storeName, bucket))
995
793
  }
996
794
 
997
- const previousByFile = new Map()
998
- if (storeName === 'sessions-metadata') {
999
- await Promise.all(
1000
- [...filesToWrite].map(async (file) => {
1001
- previousByFile.set(file, await readJsonFile(file, {}))
1002
- }),
1003
- )
1004
- }
1005
-
1006
795
  await Promise.all(
1007
796
  [...filesToWrite].map(async (file) => {
1008
797
  const bucketEntry = [...buckets.values()].find((entry) => sessionStoreFile(storeName, entry.bucket) === file)
1009
798
  await writeJsonAtomic(file, bucketEntry?.data ?? {})
1010
799
  }),
1011
800
  )
1012
-
1013
- // Keep in-memory bucket index current for metadata writes
1014
- if (storeName === 'sessions-metadata') {
1015
- for (const file of filesToWrite) {
1016
- const bucketEntry = [...buckets.values()].find((entry) => sessionStoreFile(storeName, entry.bucket) === file)
1017
- const bucket = bucketEntry?.bucket ?? (file === sessionStoreFile(storeName, { scope: 'global' })
1018
- ? { scope: 'global' }
1019
- : { scope: 'project', projectId: path.basename(path.dirname(file)) })
1020
- const previous = previousByFile.get(file) ?? {}
1021
- const next = bucketEntry?.data ?? {}
1022
- updateSessionMetadataBucketIndex(bucket, previous, next)
1023
- await notifySessionMetadataCommit({ ...bucket, previous, next })
1024
- }
1025
- bumpStoreRevision(storeName)
1026
- }
1027
801
  }
1028
802
 
803
+ // One-shot legacy layout migration (ancient flat storage/*.json stores):
804
+ // reads/writes the physical JSON bucket files only. Modern session data is
805
+ // imported into SQLite by importSessionStateFromJson during startup.
1029
806
  async function migrateLegacySessionStore(storeName) {
1030
807
  const file = legacyFlatStoreFile(storeName)
1031
808
  if (!existsSync(file)) return
1032
809
 
1033
810
  const legacy = await readJsonFile(file, {})
1034
- const current = await readSessionStore(storeName)
811
+ const current = await readJsonFile(sessionStoreFile(storeName, { scope: 'global' }), {})
1035
812
  const merged = { ...legacy, ...current }
1036
- await writeSessionStore(storeName, merged)
813
+ await writeJsonAtomic(sessionStoreFile(storeName, { scope: 'global' }), merged)
1037
814
  }
1038
815
 
1039
816
  async function migrateUnifiedConfig() {
@@ -1145,6 +922,10 @@ async function migrateSplitConfig() {
1145
922
 
1146
923
  const writeQueues = new Map()
1147
924
 
925
+ // Serialized FIFO write queues for the config stores and the JSON bucket
926
+ // stores (projects, providers, scheduled-tasks, custom-agents, …). Session
927
+ // state writes go through the SQLite facade (synchronous transactions with
928
+ // per-row CAS), so no queue is needed for them any more.
1148
929
  function enqueueWrite(queueName, operation) {
1149
930
  const previous = writeQueues.get(queueName) || Promise.resolve()
1150
931
  const next = previous
@@ -1166,21 +947,8 @@ function enqueueWrite(queueName, operation) {
1166
947
  export async function atomicUpdate(storeName, updateFn) {
1167
948
  assertStore(storeName)
1168
949
  const facade = await sessionStateFacade()
1169
- if (facade && storeName === 'sessions-metadata') {
1170
- const updated = facade.updateSessionMetadataBucket('global', null, (currentGlobal) => {
1171
- const current = { ...facade.readSessionStateStore('sessions-metadata'), ...currentGlobal }
1172
- return updateFn(current)
1173
- })
1174
- void facade.drainSessionJsonMirror()
1175
- return updated
1176
- }
1177
- if (facade && storeName === 'sessions') {
1178
- const current = facade.readSessionStateStore('sessions')
1179
- const updated = await updateFn(current)
1180
- facade.replaceSessionStateStore('sessions', updated)
1181
- void facade.drainSessionJsonMirror()
1182
- return updated
1183
- }
950
+ if (storeName === 'sessions-metadata') return atomicSessionMetadataBucketUpdateViaFacade(facade, updateFn)
951
+ if (storeName === 'sessions') return atomicSessionStoreReplaceViaFacade(facade, updateFn)
1184
952
  const queueName = isConfigStore(storeName) ? configStoreLocations[storeName].queue : storeName
1185
953
  return enqueueWrite(queueName, async () => {
1186
954
  await ensureStorage()
@@ -1198,7 +966,8 @@ export async function atomicUpdate(storeName, updateFn) {
1198
966
  }
1199
967
 
1200
968
  /**
1201
- * Atomically read-modify-write the scoped sessions metadata file within its serialized write queue.
969
+ * Atomically read-modify-write one bucket's session metadata through the
970
+ * authoritative store (updateSessionMetadataBucket under the hood).
1202
971
  *
1203
972
  * @param {string} scope
1204
973
  * @param {string|null|undefined} projectId
@@ -1207,29 +976,7 @@ export async function atomicUpdate(storeName, updateFn) {
1207
976
  */
1208
977
  export async function atomicSessionMetadataUpdate(scope, projectId, updateFn) {
1209
978
  const facade = await sessionStateFacade()
1210
- if (facade) {
1211
- const updated = await facade.atomicSessionMetadataStateUpdate(scope, projectId, updateFn)
1212
- void facade.drainSessionJsonMirror()
1213
- return updated
1214
- }
1215
- const bucket = scope === 'project' ? { scope: 'project', projectId } : { scope: 'global' }
1216
- const file = sessionStoreFile('sessions-metadata', bucket)
1217
- // Scoped session-metadata writes MUST share the single 'sessions-metadata'
1218
- // write queue used by atomicUpdate('sessions-metadata'). Both paths
1219
- // read-modify-write the same physical files; separate queues let them run
1220
- // concurrently and clobber each other — e.g. persistSession (which rebuilds
1221
- // metadata without pinnedAt) racing with a pin update and dropping pinnedAt.
1222
- return enqueueWrite('sessions-metadata', async () => {
1223
- await ensureStorage()
1224
- const data = await readJsonFile(file, {})
1225
- const previousData = { ...data }
1226
- const updated = updateFn(data)
1227
- await writeJsonAtomic(file, updated)
1228
- updateSessionMetadataBucketIndex(bucket, previousData, updated)
1229
- bumpStoreRevision('sessions-metadata')
1230
- await notifySessionMetadataCommit({ ...bucket, previous: previousData, next: updated })
1231
- return updated
1232
- })
979
+ return atomicSessionMetadataUpdateViaFacade(facade, scope, projectId, updateFn)
1233
980
  }
1234
981
 
1235
982
  /**
@@ -1316,8 +1063,8 @@ export async function readStore(storeName) {
1316
1063
  assertStore(storeName)
1317
1064
  await ensureStorage()
1318
1065
 
1319
- const facade = await sessionStateFacade()
1320
- if (facade && (storeName === 'sessions' || storeName === 'sessions-metadata')) {
1066
+ if (storeName === 'sessions' || storeName === 'sessions-metadata') {
1067
+ const facade = await sessionStateFacade()
1321
1068
  return facade.readSessionStateStore(storeName)
1322
1069
  }
1323
1070
 
@@ -1330,10 +1077,27 @@ export async function readStore(storeName) {
1330
1077
 
1331
1078
  export async function writeStore(storeName, data) {
1332
1079
  assertStore(storeName)
1333
- const facade = await sessionStateFacade()
1334
- if (facade && (storeName === 'sessions' || storeName === 'sessions-metadata')) {
1335
- facade.replaceSessionStateStore(storeName, data)
1336
- void facade.drainSessionJsonMirror()
1080
+ if (storeName === 'sessions' || storeName === 'sessions-metadata') {
1081
+ const facade = await sessionStateFacade()
1082
+ if (storeName === 'sessions') {
1083
+ // Whole-store replace: resolve removed sessions for best-effort sidecar
1084
+ // cleanup, then swap the store in one verified transaction.
1085
+ const removed = []
1086
+ for (const record of Object.values(facade.readSessionStateStore('sessions'))) {
1087
+ if (!Object.hasOwn(data || {}, record.id)) removed.push(record)
1088
+ }
1089
+ facade.replaceSessionStateStore(storeName, data)
1090
+ for (const record of removed) {
1091
+ const bucket = record.scope === 'project' ? { scope: 'project', projectId: record.projectId } : { scope: 'global' }
1092
+ try {
1093
+ const { deleteSessionAssets } = await import('./session-assets.mjs')
1094
+ await deleteSessionAssets(bucket, record.id)
1095
+ } catch { /* Sidecars are best-effort after the authoritative commit. */ }
1096
+ }
1097
+ } else {
1098
+ facade.replaceSessionStateStore(storeName, data)
1099
+ }
1100
+ bumpStoreRevision(storeName === 'sessions' ? 'sessions-metadata' : storeName)
1337
1101
  return
1338
1102
  }
1339
1103
  const queueName = isConfigStore(storeName) ? configStoreLocations[storeName].queue : storeName