@shawnstack/quickforge 1.7.9 → 1.7.11

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 (53) hide show
  1. package/dist/assets/{AgentProfilesPage-BSM5w3bn.js → AgentProfilesPage-Cvb1KTz5.js} +1 -1
  2. package/dist/assets/ChatPanelHost-C7zV-xE_.js +48 -0
  3. package/dist/assets/{CloudAccountSettingsPage-BJMrfgy9.js → CloudAccountSettingsPage-mAs_nbvw.js} +1 -1
  4. package/dist/assets/{PluginsPage-mJkTmVVV.js → PluginsPage-mLvwvTmL.js} +1 -1
  5. package/dist/assets/{ScheduledTasksPage-DJj_NLm9.js → ScheduledTasksPage-CuUW_9lq.js} +1 -1
  6. package/dist/assets/{SettingsWorkspacePage-7lQ0Wl63.js → SettingsWorkspacePage-0OINfEqK.js} +390 -390
  7. package/dist/assets/{ShareLinksSettingsPage-DjFq_Lnb.js → ShareLinksSettingsPage-DqeyJYoD.js} +1 -1
  8. package/dist/assets/{SharedConversationPage-BbuNpcpP.js → SharedConversationPage-C7CDEoX9.js} +1 -1
  9. package/dist/assets/TerminalDock-CZXcuMpq.js +2 -0
  10. package/dist/assets/{WorkspaceInspector-0i6x-diB.js → WorkspaceInspector-CNSBOEvP.js} +3 -3
  11. package/dist/assets/index-BgMvQW1b.js +66 -0
  12. package/dist/assets/index-CB1g2D0f.css +3 -0
  13. package/dist/assets/local-tools-Dopjg9xq.js +270 -0
  14. package/dist/assets/{mcp-servers-dialog-BG4b67ea.js → mcp-servers-dialog-B2lALtTE.js} +1 -1
  15. package/dist/assets/{skills-dialog-DIzZvQqW.js → skills-dialog-DYp35JM3.js} +1 -1
  16. package/dist/index.html +4 -4
  17. package/package.json +1 -1
  18. package/server/acp/server.mjs +3 -6
  19. package/server/agent-manager.mjs +125 -43
  20. package/server/auto-archive.mjs +55 -8
  21. package/server/auto-compaction.mjs +0 -2
  22. package/server/context-usage.mjs +9 -7
  23. package/server/index.mjs +172 -51
  24. package/server/lan-access-cutover.mjs +21 -4
  25. package/server/maintenance/downgrade-session-state-v1.mjs +50 -87
  26. package/server/maintenance/export-session-state-v1.mjs +5 -13
  27. package/server/routes/backup.mjs +8 -1
  28. package/server/routes/storage.mjs +85 -48
  29. package/server/scheduled-runs-cutover.mjs +15 -1
  30. package/server/session-index-service.mjs +57 -391
  31. package/server/session-persistence-lock.mjs +17 -6
  32. package/server/session-state-backup.mjs +60 -7
  33. package/server/session-state-import.mjs +157 -0
  34. package/server/session-state-maintenance.mjs +126 -0
  35. package/server/session-state-service.mjs +133 -194
  36. package/server/share-cutover.mjs +21 -4
  37. package/server/sqlite/database.mjs +142 -7
  38. package/server/sqlite/lan-access-repository.mjs +6 -4
  39. package/server/sqlite/migrations.mjs +109 -0
  40. package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
  41. package/server/sqlite/session-index-repository.mjs +59 -208
  42. package/server/sqlite/session-state-repository.mjs +359 -289
  43. package/server/sqlite/share-repository.mjs +56 -45
  44. package/server/startup-state.mjs +107 -0
  45. package/server/storage.mjs +240 -448
  46. package/server/utils/logger.mjs +15 -4
  47. package/server/utils/process-tree.mjs +14 -2
  48. package/dist/assets/ChatPanelHost-cd80S4EX.js +0 -48
  49. package/dist/assets/TerminalDock-CczcCJJE.js +0 -2
  50. package/dist/assets/index-D-J_8Smf.css +0 -3
  51. package/dist/assets/index-DFASc15l.js +0 -66
  52. package/dist/assets/local-tools-Dn1Y9aPe.js +0 -270
  53. package/server/session-state-cutover.mjs +0 -370
@@ -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,60 +389,157 @@ 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 })
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)
432
+ }
433
+
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:
438
+ // - listBuckets(): global bucket + one bucket per project directory
439
+ // - listSessionFiles(bucket): session ids from sessions/<id>.json file names
440
+ // - readSessionState(bucket, sessionId): parsed session body (null if absent)
441
+ // - readMetadataBucket(bucket): parsed sessions-metadata bucket (default {})
442
+ export function createPhysicalSessionStateFsAdapter() {
443
+ return {
444
+ async *listBuckets() {
445
+ await ensureStorage()
446
+ yield { scope: 'global', projectId: null }
447
+ for (const projectId of await listProjectIds()) yield { scope: 'project', projectId }
448
+ },
449
+ async *listSessionFiles(bucket) {
450
+ for (const file of await listSessionDataFiles(bucket)) yield path.basename(file, '.json')
451
+ },
452
+ readSessionState(bucket, sessionId) {
453
+ return readJsonFile(sessionDataFile(sessionId, bucket), null)
454
+ },
455
+ readMetadataBucket(bucket) {
456
+ return readJsonFile(sessionStoreFile('sessions-metadata', bucket), {})
457
+ },
462
458
  }
463
- return result
464
459
  }
465
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).
466
464
  async function sessionStateFacade() {
467
465
  const service = await import('./session-state-service.mjs')
468
- 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
469
543
  }
470
544
 
471
545
  async function readConfigFile() {
@@ -523,105 +597,8 @@ async function listSessionDataFiles(bucket) {
523
597
  .map((entry) => path.join(dir, entry.name))
524
598
  }
525
599
 
526
- async function readSessionValuesScoped(scope, projectId) {
527
- const bucket = scope === 'project' ? { scope: 'project', projectId } : { scope: 'global' }
528
- const files = await listSessionDataFiles(bucket)
529
- const result = {}
530
- for (const file of files) {
531
- const value = await readJsonFile(file, null)
532
- if (value?.id) result[value.id] = value
533
- }
534
- return result
535
- }
536
-
537
- async function readAllSessionValues() {
538
- const result = await readSessionValuesScoped('global')
539
- for (const projectId of await listProjectIds()) {
540
- Object.assign(result, await readSessionValuesScoped('project', projectId))
541
- }
542
- return result
543
- }
544
-
545
- function sameSessionBucket(left, right) {
546
- if (!left || !right) return false
547
- return left.scope === right.scope && (left.projectId || undefined) === (right.projectId || undefined)
548
- }
549
-
550
- function updateSessionMetadataBucketIndex(bucket, previousData, nextData) {
551
- const ids = new Set([
552
- ...Object.keys(previousData || {}),
553
- ...Object.keys(nextData || {}),
554
- ])
555
-
556
- for (const sessionId of ids) {
557
- const meta = nextData?.[sessionId]
558
- if (meta && typeof meta === 'object') {
559
- sessionBucketIndex.set(sessionId, sessionBucket(meta))
560
- continue
561
- }
562
-
563
- if (sameSessionBucket(sessionBucketIndex.get(sessionId), bucket)) {
564
- sessionBucketIndex.delete(sessionId)
565
- }
566
- }
567
- }
568
-
569
- async function writeSessionValueFile(sessionId, value) {
570
- await writeJsonAtomic(sessionDataFile(sessionId, sessionBucket(value)), value)
571
- // Keep in-memory index current
572
- if (value) sessionBucketIndex.set(sessionId, sessionBucket(value))
573
- }
574
-
575
- async function writeSessionValues(data) {
576
- const nextIds = new Set(Object.keys(data || {}))
577
- const existingFiles = [
578
- ...(await listSessionDataFiles({ scope: 'global' })),
579
- ...(await Promise.all(
580
- (await listProjectIds()).map((projectId) => listSessionDataFiles({ scope: 'project', projectId })),
581
- )).flat(),
582
- ]
583
-
584
- await Promise.all(
585
- existingFiles.map(async (file) => {
586
- const sessionId = path.basename(file, '.json')
587
- if (!nextIds.has(sessionId)) {
588
- const bucket = sessionBucketIndex.get(sessionId) || await findSessionBucketByDataFile(sessionId)
589
- await fs.rm(file, { force: true })
590
- if (bucket) {
591
- const { deleteSessionAssets } = await import('./session-assets.mjs')
592
- await deleteSessionAssets(bucket, sessionId)
593
- }
594
- sessionBucketIndex.delete(sessionId)
595
- }
596
- }),
597
- )
598
-
599
- await Promise.all(
600
- Object.entries(data || {}).map(([sessionId, value]) => writeSessionValueFile(sessionId, value)),
601
- )
602
- }
603
-
604
- async function rebuildBucketIndex() {
605
- sessionBucketIndex.clear()
606
- // Global bucket
607
- try {
608
- const globalMeta = await readJsonFile(sessionStoreFile('sessions-metadata', { scope: 'global' }), {})
609
- for (const [id, meta] of Object.entries(globalMeta)) {
610
- if (meta && typeof meta === 'object') sessionBucketIndex.set(id, sessionBucket(meta))
611
- }
612
- } catch { /* ignore */ }
613
- // Project buckets
614
- for (const projectId of await listProjectIds()) {
615
- try {
616
- const meta = await readJsonFile(sessionStoreFile('sessions-metadata', { scope: 'project', projectId }), {})
617
- for (const [id, entry] of Object.entries(meta)) {
618
- if (entry && typeof entry === 'object') sessionBucketIndex.set(id, sessionBucket(entry))
619
- }
620
- } catch { /* ignore */ }
621
- }
622
- bucketIndexBuilt = true
623
- }
624
-
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.
625
602
  async function findSessionBucketByDataFile(sessionId) {
626
603
  assertSafePathSegment(sessionId)
627
604
  const buckets = [
@@ -642,249 +619,102 @@ async function findSessionBucketByDataFile(sessionId) {
642
619
  return null
643
620
  }
644
621
 
622
+ // Session bucket lookup for asset sidecars: the authoritative store first,
623
+ // then the legacy JSON body files (offline downgrade escape hatch layout).
645
624
  export async function findSessionBucket(sessionId) {
646
- if (!bucketIndexBuilt) {
647
- await ensureStorage()
648
- await rebuildBucketIndex()
649
- }
650
- const indexed = sessionBucketIndex.get(sessionId)
651
- if (indexed) return indexed
652
-
653
- const recovered = await findSessionBucketByDataFile(sessionId)
654
- if (recovered) sessionBucketIndex.set(sessionId, recovered)
655
- 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)
656
629
  }
657
630
 
658
631
  export async function readSessionValue(sessionId) {
659
632
  const facade = await sessionStateFacade()
660
- if (facade) return facade.readSessionStateValue(sessionId)
661
- const bucket = await findSessionBucket(sessionId)
662
- if (bucket) return readJsonFile(sessionDataFile(sessionId, bucket), null)
663
-
664
- const recoveredBucket = await findSessionBucketByDataFile(sessionId)
665
- if (!recoveredBucket) return null
666
- sessionBucketIndex.set(sessionId, recoveredBucket)
667
- return readJsonFile(sessionDataFile(sessionId, recoveredBucket), null)
633
+ return facade.readSessionStateValue(sessionId)
668
634
  }
669
635
 
670
636
  export async function writeSessionValue(sessionId, value) {
671
637
  const facade = await sessionStateFacade()
672
- if (facade) {
673
- const saved = facade.saveSessionBody(sessionId, value)
674
- void facade.drainSessionJsonMirror()
675
- return saved.state
676
- }
677
- return enqueueWrite('sessions', async () => {
678
- await ensureStorage()
679
- await writeSessionValueFile(sessionId, value)
680
- })
638
+ return saveSessionBodyViaFacade(facade, sessionId, value)
681
639
  }
682
640
 
683
641
  export async function atomicSessionValueUpdate(sessionId, updateFn) {
684
642
  const facade = await sessionStateFacade()
685
- if (facade) {
686
- const updated = await facade.atomicSessionStateUpdate(sessionId, updateFn)
687
- void facade.drainSessionJsonMirror()
688
- return updated
689
- }
690
- return enqueueWrite('sessions', async () => {
691
- await ensureStorage()
692
- const current = await readSessionValue(sessionId)
693
- if (!current) return null
694
- const updated = updateFn(current)
695
- await writeSessionValueFile(sessionId, updated)
696
- return updated
697
- })
643
+ return atomicSessionStateUpdateViaFacade(facade, sessionId, updateFn)
698
644
  }
699
645
 
700
646
  export async function deleteSessionValue(sessionId) {
701
647
  const facade = await sessionStateFacade()
702
- if (facade) {
703
- const deleted = facade.deleteSessionState(sessionId)
704
- await facade.drainSessionJsonMirror()
705
- return deleted
706
- }
707
- return enqueueWrite('sessions', async () => {
708
- const bucket = await findSessionBucket(sessionId)
709
- if (!bucket) {
710
- const { deleteCloudChatIdempotencySession } = await import('./cloud/chat-idempotency-store.mjs')
711
- await deleteCloudChatIdempotencySession(sessionId)
712
- return
713
- }
714
- await fs.rm(sessionDataFile(sessionId, bucket), { force: true })
715
- const { deleteSessionAssets } = await import('./session-assets.mjs')
716
- await deleteSessionAssets(bucket, sessionId)
717
- const { deleteCloudChatIdempotencySession } = await import('./cloud/chat-idempotency-store.mjs')
718
- await deleteCloudChatIdempotencySession(sessionId)
719
- sessionBucketIndex.delete(sessionId)
720
- })
648
+ return deleteSessionStateViaFacade(facade, sessionId)
721
649
  }
722
650
 
723
651
  /**
724
652
  * Whether the session state service is currently authoritative (SQLite-backed).
725
- * Re-exported for callers that must branch write behavior before invoking the
726
- * facade (e.g. auto-archive chooses a single atomic transaction vs the legacy
727
- * 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).
728
655
  */
729
656
  export async function isSessionStateAuthoritative() {
730
- const facade = await sessionStateFacade()
731
- return Boolean(facade)
657
+ return true
732
658
  }
733
659
 
734
660
  /**
735
661
  * Write a session body and ensure its derived metadata exists so a single PUT
736
- * never leaves a body orphaned. Authoritative: one SQLite transaction for
737
- * body + derived/merged metadata. JSON: body file plus derived metadata merged
738
- * 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.
739
664
  */
740
665
  export async function writeSessionValueWithMetadata(sessionId, value) {
741
666
  const facade = await sessionStateFacade()
742
- if (facade) {
743
- const saved = facade.saveSessionBody(sessionId, value)
744
- void facade.drainSessionJsonMirror()
745
- return saved.state
746
- }
747
- const saved = await enqueueWrite('sessions', async () => {
748
- await ensureStorage()
749
- await writeSessionValueFile(sessionId, value)
750
- })
751
- if (value && typeof value === 'object' && !Array.isArray(value)) {
752
- const bucket = sessionBucket(value)
753
- const file = sessionStoreFile('sessions-metadata', bucket)
754
- await enqueueWrite('sessions-metadata', async () => {
755
- const previous = await readJsonFile(file, {})
756
- const existing = previous[sessionId]
757
- const derived = {
758
- id: sessionId,
759
- title: typeof value.title === 'string' ? value.title : (existing?.title ?? 'New chat'),
760
- titleSource: value.titleSource ?? existing?.titleSource,
761
- createdAt: value.createdAt ?? existing?.createdAt ?? new Date().toISOString(),
762
- lastModified: value.lastModified ?? existing?.lastModified ?? value.createdAt ?? new Date().toISOString(),
763
- messageCount: Array.isArray(value.messages) ? value.messages.length : (existing?.messageCount ?? 0),
764
- stateVersion: Number.isInteger(value.stateVersion) && value.stateVersion >= 0 ? value.stateVersion : (existing?.stateVersion ?? 0),
765
- scope: bucket.scope,
766
- ...(bucket.scope === 'project' ? { projectId: bucket.projectId } : {}),
767
- }
768
- const next = { ...previous, [sessionId]: existing ? { ...existing, ...derived } : derived }
769
- await writeJsonAtomic(file, next)
770
- updateSessionMetadataBucketIndex(bucket, previous, next)
771
- bumpStoreRevision('sessions-metadata')
772
- await notifySessionMetadataCommit({ ...bucket, previous, next })
773
- })
774
- }
775
- return saved
667
+ return saveSessionBodyViaFacade(facade, sessionId, value)
776
668
  }
777
669
 
778
670
  /**
779
- * Delete an entire session (body + metadata). Authoritative: one SQLite
780
- * 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
781
673
  * failures never roll back the committed session delete.
782
674
  */
783
675
  export async function deleteSessionWithMetadata(sessionId) {
784
676
  const facade = await sessionStateFacade()
785
- if (facade) {
786
- const deleted = facade.deleteSessionState(sessionId)
787
- await facade.drainSessionJsonMirror()
788
- return deleted
789
- }
790
- await deleteSessionValue(sessionId)
791
- await atomicUpdate('sessions-metadata', (data) => {
792
- delete data[sessionId]
793
- return data
794
- })
677
+ return deleteSessionStateViaFacade(facade, sessionId)
795
678
  }
796
679
 
797
680
  /**
798
- * Atomically update a session record (body + metadata together).
799
- * Authoritative: single SQLite transaction with CAS retry on conflict.
800
- * 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.
801
683
  */
802
684
  export async function atomicSessionRecordUpdate(sessionId, updateFn) {
803
685
  const facade = await sessionStateFacade()
804
- if (facade) {
805
- const updated = await facade.atomicSessionRecordUpdate(sessionId, updateFn)
806
- void facade.drainSessionJsonMirror()
807
- return updated
808
- }
809
- return enqueueWrite('sessions', async () => {
810
- const current = await readSessionValue(sessionId)
811
- if (!current) return null
812
- const bucket = sessionBucket(current)
813
- const metadataFile = sessionStoreFile('sessions-metadata', bucket)
814
- const metadata = (await readJsonFile(metadataFile, {}))[sessionId] || {}
815
- const updated = await updateFn({
816
- state: structuredClone(current),
817
- metadata: structuredClone(metadata),
818
- revision: null,
819
- stateVersion: Number.isInteger(current.stateVersion) ? current.stateVersion : 0,
820
- })
821
- if (!updated) return null
822
- await writeSessionValueFile(sessionId, updated.state)
823
- await enqueueWrite('sessions-metadata', async () => {
824
- const previousMetadata = await readJsonFile(metadataFile, {})
825
- const nextMetadata = { ...previousMetadata, [sessionId]: { ...previousMetadata[sessionId], ...(updated.metadata || {}), id: sessionId } }
826
- await writeJsonAtomic(metadataFile, nextMetadata)
827
- updateSessionMetadataBucketIndex(bucket, previousMetadata, nextMetadata)
828
- bumpStoreRevision('sessions-metadata')
829
- await notifySessionMetadataCommit({ ...bucket, previous: previousMetadata, next: nextMetadata })
830
- })
831
- return { ...updated, state: updated.state }
832
- })
686
+ return atomicSessionRecordUpdateViaFacade(facade, sessionId, updateFn)
833
687
  }
834
688
 
835
689
  /**
836
690
  * Apply a multi-session batch of set/delete operations across
837
- * `sessions`/`sessions-metadata` in a single authoritative SQLite transaction.
838
- * The JSON fallback executes operations sequentially (legacy pre-cutover path).
691
+ * `sessions`/`sessions-metadata` in a single SQLite transaction.
839
692
  */
840
693
  export async function applySessionBatch(operations) {
841
694
  if (!Array.isArray(operations) || operations.length === 0) throw new TypeError('Session batch operations are required')
842
695
  const facade = await sessionStateFacade()
843
- if (facade) {
844
- const result = facade.applySessionBatch(operations)
845
- void facade.drainSessionJsonMirror()
846
- return result
847
- }
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 = []
848
700
  for (const operation of operations) {
849
- if (!['sessions', 'sessions-metadata'].includes(operation?.store)) throw new TypeError('Session batch only accepts sessions and sessions-metadata')
850
- if (!['set', 'delete'].includes(operation?.type)) throw new TypeError('Invalid session batch operation')
851
- 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)
852
704
  }
853
- const result = { saved: 0, deleted: 0 }
854
- for (const operation of operations) {
855
- if (operation.type === 'delete') {
856
- if (operation.store === 'sessions') {
857
- const before = await readSessionValue(operation.key)
858
- await deleteSessionWithMetadata(operation.key)
859
- if (before) result.deleted += 1
860
- } else {
861
- const error = new TypeError('Metadata-only delete is not allowed')
862
- error.errorCode = 'SESSION_FULL_DELETE_REQUIRED'
863
- error.statusCode = 409
864
- throw error
865
- }
866
- continue
867
- }
868
- if (operation.store === 'sessions') {
869
- if (!operation.value || typeof operation.value !== 'object' || Array.isArray(operation.value)) throw new TypeError(`Invalid session body: ${operation.key}`)
870
- await writeSessionValueWithMetadata(operation.key, operation.value)
871
- result.saved += 1
872
- } else {
873
- if (!operation.value || typeof operation.value !== 'object' || Array.isArray(operation.value)) throw new TypeError(`Invalid session metadata: ${operation.key}`)
874
- const existing = await readSessionValue(operation.key)
875
- if (!existing) {
876
- const error = new TypeError(`Session body is required for a new session: ${operation.key}`)
877
- error.errorCode = 'SESSION_STATE_REQUIRED'
878
- error.statusCode = 409
879
- throw error
880
- }
881
- await atomicUpdate('sessions-metadata', (data) => {
882
- data[operation.key] = { ...(data[operation.key] || {}), ...operation.value }
883
- return data
884
- })
885
- result.saved += 1
886
- }
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. */ }
887
716
  }
717
+ bumpStoreRevision('sessions-metadata')
888
718
  return result
889
719
  }
890
720
 
@@ -895,38 +725,29 @@ async function listSessionStoreFiles(storeName) {
895
725
  ]
896
726
  }
897
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).
898
731
  async function readSessionStore(storeName) {
899
- if (storeName === 'sessions') return readAllSessionValues()
900
-
901
732
  const result = { ...await readJsonFile(sessionStoreFile(storeName, { scope: 'global' }), {}) }
902
733
  for (const projectId of await listProjectIds()) {
903
734
  const data = await readJsonFile(sessionStoreFile(storeName, { scope: 'project', projectId }), {})
904
- if (storeName !== 'sessions-metadata') {
905
- Object.assign(result, data)
906
- continue
907
- }
908
-
909
- for (const [id, value] of Object.entries(data)) {
910
- result[id] = value && typeof value === 'object'
911
- ? { ...value, scope: 'project', projectId: value.projectId || projectId }
912
- : value
913
- }
735
+ Object.assign(result, data)
914
736
  }
915
737
  return result
916
738
  }
917
739
 
918
740
  export async function readSessionStoreScoped(storeName, scope, projectId) {
919
741
  await ensureStorage()
920
- const facade = await sessionStateFacade()
921
- if (facade && (storeName === 'sessions' || storeName === 'sessions-metadata')) {
742
+ if (storeName === 'sessions' || storeName === 'sessions-metadata') {
743
+ const facade = await sessionStateFacade()
922
744
  return facade.readSessionStateStore(storeName, { scope, projectId })
923
745
  }
924
- if (storeName === 'sessions') return readSessionValuesScoped(scope, projectId)
925
-
926
746
  const file = sessionStoreFile(storeName, { scope, projectId })
927
747
  return readJsonFile(file, {})
928
748
  }
929
749
 
750
+ // Physical JSON bucket reader (offline tooling / post-downgrade layout).
930
751
  export async function readPhysicalSessionMetadataBuckets() {
931
752
  await ensureStorage()
932
753
  const buckets = [
@@ -946,12 +767,17 @@ export async function readPhysicalSessionMetadataBuckets() {
946
767
  return buckets
947
768
  }
948
769
 
949
- async function writeSessionStore(storeName, data) {
950
- if (storeName === 'sessions') {
951
- await writeSessionValues(data)
952
- return
953
- }
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
+ }
954
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) {
955
781
  const buckets = new Map()
956
782
 
957
783
  for (const [key, value] of Object.entries(data || {})) {
@@ -966,46 +792,25 @@ async function writeSessionStore(storeName, data) {
966
792
  filesToWrite.add(sessionStoreFile(storeName, bucket))
967
793
  }
968
794
 
969
- const previousByFile = new Map()
970
- if (storeName === 'sessions-metadata') {
971
- await Promise.all(
972
- [...filesToWrite].map(async (file) => {
973
- previousByFile.set(file, await readJsonFile(file, {}))
974
- }),
975
- )
976
- }
977
-
978
795
  await Promise.all(
979
796
  [...filesToWrite].map(async (file) => {
980
797
  const bucketEntry = [...buckets.values()].find((entry) => sessionStoreFile(storeName, entry.bucket) === file)
981
798
  await writeJsonAtomic(file, bucketEntry?.data ?? {})
982
799
  }),
983
800
  )
984
-
985
- // Keep in-memory bucket index current for metadata writes
986
- if (storeName === 'sessions-metadata') {
987
- for (const file of filesToWrite) {
988
- const bucketEntry = [...buckets.values()].find((entry) => sessionStoreFile(storeName, entry.bucket) === file)
989
- const bucket = bucketEntry?.bucket ?? (file === sessionStoreFile(storeName, { scope: 'global' })
990
- ? { scope: 'global' }
991
- : { scope: 'project', projectId: path.basename(path.dirname(file)) })
992
- const previous = previousByFile.get(file) ?? {}
993
- const next = bucketEntry?.data ?? {}
994
- updateSessionMetadataBucketIndex(bucket, previous, next)
995
- await notifySessionMetadataCommit({ ...bucket, previous, next })
996
- }
997
- bumpStoreRevision(storeName)
998
- }
999
801
  }
1000
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.
1001
806
  async function migrateLegacySessionStore(storeName) {
1002
807
  const file = legacyFlatStoreFile(storeName)
1003
808
  if (!existsSync(file)) return
1004
809
 
1005
810
  const legacy = await readJsonFile(file, {})
1006
- const current = await readSessionStore(storeName)
811
+ const current = await readJsonFile(sessionStoreFile(storeName, { scope: 'global' }), {})
1007
812
  const merged = { ...legacy, ...current }
1008
- await writeSessionStore(storeName, merged)
813
+ await writeJsonAtomic(sessionStoreFile(storeName, { scope: 'global' }), merged)
1009
814
  }
1010
815
 
1011
816
  async function migrateUnifiedConfig() {
@@ -1117,6 +922,10 @@ async function migrateSplitConfig() {
1117
922
 
1118
923
  const writeQueues = new Map()
1119
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.
1120
929
  function enqueueWrite(queueName, operation) {
1121
930
  const previous = writeQueues.get(queueName) || Promise.resolve()
1122
931
  const next = previous
@@ -1138,21 +947,8 @@ function enqueueWrite(queueName, operation) {
1138
947
  export async function atomicUpdate(storeName, updateFn) {
1139
948
  assertStore(storeName)
1140
949
  const facade = await sessionStateFacade()
1141
- if (facade && storeName === 'sessions-metadata') {
1142
- const updated = facade.updateSessionMetadataBucket('global', null, (currentGlobal) => {
1143
- const current = { ...facade.readSessionStateStore('sessions-metadata'), ...currentGlobal }
1144
- return updateFn(current)
1145
- })
1146
- void facade.drainSessionJsonMirror()
1147
- return updated
1148
- }
1149
- if (facade && storeName === 'sessions') {
1150
- const current = facade.readSessionStateStore('sessions')
1151
- const updated = await updateFn(current)
1152
- facade.replaceSessionStateStore('sessions', updated)
1153
- void facade.drainSessionJsonMirror()
1154
- return updated
1155
- }
950
+ if (storeName === 'sessions-metadata') return atomicSessionMetadataBucketUpdateViaFacade(facade, updateFn)
951
+ if (storeName === 'sessions') return atomicSessionStoreReplaceViaFacade(facade, updateFn)
1156
952
  const queueName = isConfigStore(storeName) ? configStoreLocations[storeName].queue : storeName
1157
953
  return enqueueWrite(queueName, async () => {
1158
954
  await ensureStorage()
@@ -1170,7 +966,8 @@ export async function atomicUpdate(storeName, updateFn) {
1170
966
  }
1171
967
 
1172
968
  /**
1173
- * 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).
1174
971
  *
1175
972
  * @param {string} scope
1176
973
  * @param {string|null|undefined} projectId
@@ -1179,29 +976,7 @@ export async function atomicUpdate(storeName, updateFn) {
1179
976
  */
1180
977
  export async function atomicSessionMetadataUpdate(scope, projectId, updateFn) {
1181
978
  const facade = await sessionStateFacade()
1182
- if (facade) {
1183
- const updated = await facade.atomicSessionMetadataStateUpdate(scope, projectId, updateFn)
1184
- void facade.drainSessionJsonMirror()
1185
- return updated
1186
- }
1187
- const bucket = scope === 'project' ? { scope: 'project', projectId } : { scope: 'global' }
1188
- const file = sessionStoreFile('sessions-metadata', bucket)
1189
- // Scoped session-metadata writes MUST share the single 'sessions-metadata'
1190
- // write queue used by atomicUpdate('sessions-metadata'). Both paths
1191
- // read-modify-write the same physical files; separate queues let them run
1192
- // concurrently and clobber each other — e.g. persistSession (which rebuilds
1193
- // metadata without pinnedAt) racing with a pin update and dropping pinnedAt.
1194
- return enqueueWrite('sessions-metadata', async () => {
1195
- await ensureStorage()
1196
- const data = await readJsonFile(file, {})
1197
- const previousData = { ...data }
1198
- const updated = updateFn(data)
1199
- await writeJsonAtomic(file, updated)
1200
- updateSessionMetadataBucketIndex(bucket, previousData, updated)
1201
- bumpStoreRevision('sessions-metadata')
1202
- await notifySessionMetadataCommit({ ...bucket, previous: previousData, next: updated })
1203
- return updated
1204
- })
979
+ return atomicSessionMetadataUpdateViaFacade(facade, scope, projectId, updateFn)
1205
980
  }
1206
981
 
1207
982
  /**
@@ -1288,8 +1063,8 @@ export async function readStore(storeName) {
1288
1063
  assertStore(storeName)
1289
1064
  await ensureStorage()
1290
1065
 
1291
- const facade = await sessionStateFacade()
1292
- if (facade && (storeName === 'sessions' || storeName === 'sessions-metadata')) {
1066
+ if (storeName === 'sessions' || storeName === 'sessions-metadata') {
1067
+ const facade = await sessionStateFacade()
1293
1068
  return facade.readSessionStateStore(storeName)
1294
1069
  }
1295
1070
 
@@ -1302,10 +1077,27 @@ export async function readStore(storeName) {
1302
1077
 
1303
1078
  export async function writeStore(storeName, data) {
1304
1079
  assertStore(storeName)
1305
- const facade = await sessionStateFacade()
1306
- if (facade && (storeName === 'sessions' || storeName === 'sessions-metadata')) {
1307
- facade.replaceSessionStateStore(storeName, data)
1308
- 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)
1309
1101
  return
1310
1102
  }
1311
1103
  const queueName = isConfigStore(storeName) ? configStoreLocations[storeName].queue : storeName