@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.
- package/dist/assets/{AgentProfilesPage-BSM5w3bn.js → AgentProfilesPage-Cvb1KTz5.js} +1 -1
- package/dist/assets/ChatPanelHost-C7zV-xE_.js +48 -0
- package/dist/assets/{CloudAccountSettingsPage-BJMrfgy9.js → CloudAccountSettingsPage-mAs_nbvw.js} +1 -1
- package/dist/assets/{PluginsPage-mJkTmVVV.js → PluginsPage-mLvwvTmL.js} +1 -1
- package/dist/assets/{ScheduledTasksPage-DJj_NLm9.js → ScheduledTasksPage-CuUW_9lq.js} +1 -1
- package/dist/assets/{SettingsWorkspacePage-7lQ0Wl63.js → SettingsWorkspacePage-0OINfEqK.js} +390 -390
- package/dist/assets/{ShareLinksSettingsPage-DjFq_Lnb.js → ShareLinksSettingsPage-DqeyJYoD.js} +1 -1
- package/dist/assets/{SharedConversationPage-BbuNpcpP.js → SharedConversationPage-C7CDEoX9.js} +1 -1
- package/dist/assets/TerminalDock-CZXcuMpq.js +2 -0
- package/dist/assets/{WorkspaceInspector-0i6x-diB.js → WorkspaceInspector-CNSBOEvP.js} +3 -3
- package/dist/assets/index-BgMvQW1b.js +66 -0
- package/dist/assets/index-CB1g2D0f.css +3 -0
- package/dist/assets/local-tools-Dopjg9xq.js +270 -0
- package/dist/assets/{mcp-servers-dialog-BG4b67ea.js → mcp-servers-dialog-B2lALtTE.js} +1 -1
- package/dist/assets/{skills-dialog-DIzZvQqW.js → skills-dialog-DYp35JM3.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/server/acp/server.mjs +3 -6
- package/server/agent-manager.mjs +125 -43
- package/server/auto-archive.mjs +55 -8
- package/server/auto-compaction.mjs +0 -2
- package/server/context-usage.mjs +9 -7
- package/server/index.mjs +172 -51
- package/server/lan-access-cutover.mjs +21 -4
- package/server/maintenance/downgrade-session-state-v1.mjs +50 -87
- package/server/maintenance/export-session-state-v1.mjs +5 -13
- package/server/routes/backup.mjs +8 -1
- package/server/routes/storage.mjs +85 -48
- package/server/scheduled-runs-cutover.mjs +15 -1
- package/server/session-index-service.mjs +57 -391
- package/server/session-persistence-lock.mjs +17 -6
- package/server/session-state-backup.mjs +60 -7
- package/server/session-state-import.mjs +157 -0
- package/server/session-state-maintenance.mjs +126 -0
- package/server/session-state-service.mjs +133 -194
- package/server/share-cutover.mjs +21 -4
- package/server/sqlite/database.mjs +142 -7
- package/server/sqlite/lan-access-repository.mjs +6 -4
- package/server/sqlite/migrations.mjs +109 -0
- package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
- package/server/sqlite/session-index-repository.mjs +59 -208
- package/server/sqlite/session-state-repository.mjs +359 -289
- package/server/sqlite/share-repository.mjs +56 -45
- package/server/startup-state.mjs +107 -0
- package/server/storage.mjs +240 -448
- package/server/utils/logger.mjs +15 -4
- package/server/utils/process-tree.mjs +14 -2
- package/dist/assets/ChatPanelHost-cd80S4EX.js +0 -48
- package/dist/assets/TerminalDock-CczcCJJE.js +0 -2
- package/dist/assets/index-D-J_8Smf.css +0 -3
- package/dist/assets/index-DFASc15l.js +0 -66
- package/dist/assets/local-tools-Dn1Y9aPe.js +0 -270
- package/server/session-state-cutover.mjs +0 -370
package/server/storage.mjs
CHANGED
|
@@ -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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
|
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
|
-
|
|
527
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
726
|
-
*
|
|
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
|
-
|
|
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
|
|
737
|
-
*
|
|
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
|
-
|
|
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)
|
|
780
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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 (
|
|
850
|
-
|
|
851
|
-
if (
|
|
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 =
|
|
854
|
-
for (const
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
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
|
-
|
|
921
|
-
|
|
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
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|
|
811
|
+
const current = await readJsonFile(sessionStoreFile(storeName, { scope: 'global' }), {})
|
|
1007
812
|
const merged = { ...legacy, ...current }
|
|
1008
|
-
await
|
|
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 (
|
|
1142
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1292
|
-
|
|
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
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
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
|