@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.
- package/dist/assets/{AgentProfilesPage-Dw9orPNt.js → AgentProfilesPage-CApCpsDd.js} +1 -1
- package/dist/assets/ChatPanelHost-COTO7swS.js +48 -0
- package/dist/assets/CloudAccountSettingsPage-Be5r3dtv.js +1 -0
- package/dist/assets/{PluginsPage-Dj1bmsEf.js → PluginsPage-BIbIDfWY.js} +1 -1
- package/dist/assets/ScheduledTasksPage-BOqmBNCx.js +2 -0
- package/dist/assets/{SettingsWorkspacePage-CFuvSATi.js → SettingsWorkspacePage-BCRtycRN.js} +304 -304
- package/dist/assets/ShareLinksSettingsPage-BrxQacJn.js +1 -0
- package/dist/assets/{SharedConversationPage-DLo05j7c.js → SharedConversationPage-DJa0M0El.js} +1 -1
- package/dist/assets/TerminalDock-Cqz_9mTC.js +2 -0
- package/dist/assets/WorkspaceInspector-C-GOK81D.js +36 -0
- package/dist/assets/{abnfDiagram-VRR7QNED-CMhVoYUh.js → abnfDiagram-VRR7QNED-DGklo3PO.js} +1 -1
- package/dist/assets/{architectureDiagram-ZJ3FMSHR-C52qL6Hg.js → architectureDiagram-ZJ3FMSHR-CioWLVTp.js} +1 -1
- package/dist/assets/{blockDiagram-677ZJIJ3-CM3XbLol.js → blockDiagram-677ZJIJ3-BJWBq5e9.js} +1 -1
- package/dist/assets/{c4Diagram-LMCZKHZV-D7Bh1ui1.js → c4Diagram-LMCZKHZV-BacRvqFK.js} +1 -1
- package/dist/assets/channel-CeXY-wZo.js +1 -0
- package/dist/assets/{chunk-32BRIVSS-xgrtbT8e.js → chunk-32BRIVSS-B53BNE2C.js} +1 -1
- package/dist/assets/{chunk-52WLFC77-CDbaHw80.js → chunk-52WLFC77-CYq4hT4f.js} +1 -1
- package/dist/assets/{chunk-C7G6YPKG-ZMLUvfLy.js → chunk-C7G6YPKG-KFaGpP_Z.js} +1 -1
- package/dist/assets/{chunk-EX3LRPZG-BcQhaqt7.js → chunk-EX3LRPZG-DqVd_ihG.js} +1 -1
- package/dist/assets/{chunk-FWX5IMBZ-DrnrdrSu.js → chunk-FWX5IMBZ-BxRjZkOP.js} +2 -2
- package/dist/assets/{chunk-HOUHSVGY-tdc-Z9eW.js → chunk-HOUHSVGY-C9QO8omv.js} +1 -1
- package/dist/assets/{chunk-ICXQ74PX-Cd7igVGt.js → chunk-ICXQ74PX-ClbGPgLV.js} +1 -1
- package/dist/assets/{chunk-MOJQB5TN-CLTjV5ov.js → chunk-MOJQB5TN-Ba4wlF1J.js} +1 -1
- package/dist/assets/{chunk-OGEWGWER-TgvkXkFX.js → chunk-OGEWGWER-DczhQmcx.js} +1 -1
- package/dist/assets/{chunk-PUDLZKDR-C5GMVYup.js → chunk-PUDLZKDR-CPWpcKUF.js} +1 -1
- package/dist/assets/{chunk-Q4XR5HBZ-fo5-HsqY.js → chunk-Q4XR5HBZ-CiF6T--X.js} +1 -1
- package/dist/assets/{chunk-V7JOEXUC-CEDazV6w.js → chunk-V7JOEXUC-Dxv0zwmw.js} +1 -1
- package/dist/assets/{chunk-VAUOI2AC-CWYjg8zE.js → chunk-VAUOI2AC-D3J5bw1P.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-C_-eEj-9.js → chunk-VR4S4FIN-BXQSwwII.js} +1 -1
- package/dist/assets/{chunk-WYO6CB5R-NF-MsJJR.js → chunk-WYO6CB5R-C0azl-fk.js} +2 -2
- package/dist/assets/{chunk-ZGVPDNZ5-CRrAYi4g.js → chunk-ZGVPDNZ5-D_8F4Akx.js} +1 -1
- package/dist/assets/classDiagram-OUVF2IWQ-B8M6sf49.js +1 -0
- package/dist/assets/classDiagram-v2-EOCWNBFH-BY7fzBDH.js +1 -0
- package/dist/assets/{cynefinDiagram-TSTJHNR4-D4sZ7LYI.js → cynefinDiagram-TSTJHNR4-BFlsWz0B.js} +1 -1
- package/dist/assets/{dagre-VKFMJZFB-BlsnSh-Q.js → dagre-VKFMJZFB-DXhpjlEs.js} +1 -1
- package/dist/assets/{diagram-FQU43EPY-DW1yQiRO.js → diagram-FQU43EPY-DRVVOD9k.js} +1 -1
- package/dist/assets/{diagram-G47NLZAW-BTUAsFjb.js → diagram-G47NLZAW-BV9Gw1tL.js} +1 -1
- package/dist/assets/{diagram-NH7WQ7WH-BcaomqSd.js → diagram-NH7WQ7WH-CrUkXfRY.js} +1 -1
- package/dist/assets/{diagram-OA4YK3LP-DDLX7Xwr.js → diagram-OA4YK3LP-CuILjn7v.js} +1 -1
- package/dist/assets/{diagram-WEI45ONY-CIave0An.js → diagram-WEI45ONY-nlNN3tWB.js} +1 -1
- package/dist/assets/{ebnfDiagram-CCIWWBDH-Cr1Y6lPu.js → ebnfDiagram-CCIWWBDH-BuDgZt9n.js} +1 -1
- package/dist/assets/{erDiagram-Q63AITRT-D3EgiVVH.js → erDiagram-Q63AITRT-MEwxjmtP.js} +1 -1
- package/dist/assets/flowDiagram-23GEKE2U-BiUP-Scq.js +1 -0
- package/dist/assets/{ganttDiagram-NO4QXBWP-B0oyDIqK.js → ganttDiagram-NO4QXBWP-DNw5qghE.js} +1 -1
- package/dist/assets/{gitGraphDiagram-IHSO6WYX-uQtr6IqK.js → gitGraphDiagram-IHSO6WYX-CaTcakAt.js} +1 -1
- package/dist/assets/index-BWf6y3Kh.css +3 -0
- package/dist/assets/index-D3GWkXJR.js +66 -0
- package/dist/assets/{infoDiagram-FWYZ7A6U-B_GzlLIM.js → infoDiagram-FWYZ7A6U-DwfyP7dU.js} +1 -1
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-CKQIsSdU.js → ishikawaDiagram-FXEZZL3T-CmrzbOCV.js} +1 -1
- package/dist/assets/{journeyDiagram-5HDEW3XC-DrrvzZL1.js → journeyDiagram-5HDEW3XC-CYqe3sgr.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-CJQRTdXz.js → kanban-definition-HUTT4EX6-BMDO_mv0.js} +1 -1
- package/dist/assets/{line-B4DxX7tC.js → line-SbxJR8ZT.js} +1 -1
- package/dist/assets/lit-vendor-CinchGmI.js +2 -0
- package/dist/assets/local-tools-DY0yEU3T.js +350 -0
- package/dist/assets/{mcp-servers-dialog-CwHp0K4q.js → mcp-servers-dialog-7Xwu2RVW.js} +2 -2
- package/dist/assets/{mermaid.core-DWpShXCs.js → mermaid.core-CwXTzYjs.js} +3 -3
- package/dist/assets/{mindmap-definition-LN4V7U3C-CEVwZD78.js → mindmap-definition-LN4V7U3C-CLyJ78mO.js} +1 -1
- package/dist/assets/{pegDiagram-2B236MQR-CBLkBdnQ.js → pegDiagram-2B236MQR-4qFznrSw.js} +1 -1
- package/dist/assets/{pi-web-ui-DV5jKjSg.js → pi-web-ui-BNuvqKDT.js} +289 -289
- package/dist/assets/{pieDiagram-ENE6RG2P-8tCnwtWS.js → pieDiagram-ENE6RG2P-141sGxVe.js} +1 -1
- package/dist/assets/{quadrantDiagram-ABIIQ3AL-CWd28cHF.js → quadrantDiagram-ABIIQ3AL-SUI44pAX.js} +1 -1
- package/dist/assets/{railroadDiagram-RFXS5EU6-CI4ouOBs.js → railroadDiagram-RFXS5EU6-1oiZuYBB.js} +1 -1
- package/dist/assets/{requirementDiagram-TGXJPOKE-DtFz7sw4.js → requirementDiagram-TGXJPOKE-CfVishLi.js} +1 -1
- package/dist/assets/{sankeyDiagram-HTMAVEWB--VeOwwk9.js → sankeyDiagram-HTMAVEWB-DVu7J_Z9.js} +1 -1
- package/dist/assets/{sequenceDiagram-DBY2YBRQ-B8AHk5Oe.js → sequenceDiagram-DBY2YBRQ-D-Ip2yiS.js} +1 -1
- package/dist/assets/{skills-dialog-k9ycekIU.js → skills-dialog-C_JvWbAa.js} +3 -3
- package/dist/assets/{stateDiagram-2N3HPSRC-etXnJ8X8.js → stateDiagram-2N3HPSRC-Cg0hWwrp.js} +1 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-DhmzNQd9.js +1 -0
- package/dist/assets/{swimlanes-5IMT3BWC-pabIKAmn.js → swimlanes-5IMT3BWC-Co5D-cGU.js} +1 -1
- package/dist/assets/swimlanesDiagram-G3AALYLV-BEvomQNa.js +8 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-C55MKm3L.js → timeline-definition-FHXFAJF6-CBk4c_Ua.js} +1 -1
- package/dist/assets/{vennDiagram-L72KCM5P-CgNf1Q9W.js → vennDiagram-L72KCM5P-BII7wdRx.js} +1 -1
- package/dist/assets/{wardleyDiagram-EHGQE667-Drt5ytec.js → wardleyDiagram-EHGQE667-CkyJJIXP.js} +1 -1
- package/dist/assets/{xychartDiagram-FW5EYKEG-hhoEo_Ar.js → xychartDiagram-FW5EYKEG-B1A66Ks0.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +1 -1
- package/server/acp/server.mjs +3 -6
- package/server/agent-manager.mjs +192 -48
- package/server/approval-store.mjs +1 -0
- package/server/ask-store.mjs +78 -0
- package/server/auto-archive.mjs +55 -8
- package/server/index.mjs +172 -51
- package/server/lan-access-cutover.mjs +21 -4
- package/server/maintenance/downgrade-session-state-v1.mjs +46 -85
- package/server/maintenance/export-session-state-v1.mjs +1 -11
- package/server/routes/agent.mjs +9 -0
- package/server/routes/backup.mjs +1 -1
- package/server/routes/storage.mjs +63 -49
- package/server/scheduled-runs-cutover.mjs +15 -1
- package/server/session-index-service.mjs +57 -391
- package/server/session-state-backup.mjs +56 -5
- package/server/session-state-import.mjs +174 -0
- package/server/session-state-maintenance.mjs +126 -0
- package/server/session-state-service.mjs +128 -210
- 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 +337 -387
- package/server/sqlite/share-repository.mjs +56 -45
- package/server/startup-state.mjs +107 -0
- package/server/storage.mjs +218 -454
- package/server/tools/definitions.mjs +18 -0
- package/server/tools/index.mjs +14 -2
- package/server/utils/logger.mjs +15 -4
- package/dist/assets/ChatPanelHost-CW3-MQyL.js +0 -48
- package/dist/assets/CloudAccountSettingsPage-CDkKN1Sz.js +0 -1
- package/dist/assets/ScheduledTasksPage-C9lOTi75.js +0 -2
- package/dist/assets/ShareLinksSettingsPage-CH_t33A3.js +0 -1
- package/dist/assets/TerminalDock-CSOVub-m.js +0 -2
- package/dist/assets/WorkspaceInspector-DPXgLIum.js +0 -36
- package/dist/assets/channel-Duu1CuH9.js +0 -1
- package/dist/assets/classDiagram-OUVF2IWQ-Cc63fLWP.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-CoXU0qT6.js +0 -1
- package/dist/assets/flowDiagram-23GEKE2U-BS7KXcqn.js +0 -1
- package/dist/assets/index-DQBOm9gt.js +0 -66
- package/dist/assets/index-ps85guTs.css +0 -3
- package/dist/assets/lit-vendor-DvaQX_UQ.js +0 -2
- package/dist/assets/local-tools-BxfQeIUQ.js +0 -270
- package/dist/assets/stateDiagram-v2-6OUMAXLB-Dms3pICj.js +0 -1
- package/dist/assets/swimlanesDiagram-G3AALYLV-B14aWjv6.js +0 -8
- package/server/session-state-cutover.mjs +0 -563
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,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
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
return
|
|
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
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
// multi-GB library never needs to be fully resident
|
|
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
|
|
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
|
-
|
|
555
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
754
|
-
*
|
|
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
|
-
|
|
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
|
|
765
|
-
*
|
|
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
|
-
|
|
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)
|
|
808
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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 (
|
|
878
|
-
|
|
879
|
-
if (
|
|
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 =
|
|
882
|
-
for (const
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
|
|
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
|
-
|
|
949
|
-
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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
|
|
811
|
+
const current = await readJsonFile(sessionStoreFile(storeName, { scope: 'global' }), {})
|
|
1035
812
|
const merged = { ...legacy, ...current }
|
|
1036
|
-
await
|
|
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 (
|
|
1170
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1320
|
-
|
|
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
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
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
|