@shawnstack/quickforge 1.7.10 → 1.7.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/dist/assets/{AgentProfilesPage-Dw9orPNt.js → AgentProfilesPage-CApCpsDd.js} +1 -1
  2. package/dist/assets/ChatPanelHost-COTO7swS.js +48 -0
  3. package/dist/assets/CloudAccountSettingsPage-Be5r3dtv.js +1 -0
  4. package/dist/assets/{PluginsPage-Dj1bmsEf.js → PluginsPage-BIbIDfWY.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-BOqmBNCx.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-CFuvSATi.js → SettingsWorkspacePage-BCRtycRN.js} +304 -304
  7. package/dist/assets/ShareLinksSettingsPage-BrxQacJn.js +1 -0
  8. package/dist/assets/{SharedConversationPage-DLo05j7c.js → SharedConversationPage-DJa0M0El.js} +1 -1
  9. package/dist/assets/TerminalDock-Cqz_9mTC.js +2 -0
  10. package/dist/assets/WorkspaceInspector-C-GOK81D.js +36 -0
  11. package/dist/assets/{abnfDiagram-VRR7QNED-CMhVoYUh.js → abnfDiagram-VRR7QNED-DGklo3PO.js} +1 -1
  12. package/dist/assets/{architectureDiagram-ZJ3FMSHR-C52qL6Hg.js → architectureDiagram-ZJ3FMSHR-CioWLVTp.js} +1 -1
  13. package/dist/assets/{blockDiagram-677ZJIJ3-CM3XbLol.js → blockDiagram-677ZJIJ3-BJWBq5e9.js} +1 -1
  14. package/dist/assets/{c4Diagram-LMCZKHZV-D7Bh1ui1.js → c4Diagram-LMCZKHZV-BacRvqFK.js} +1 -1
  15. package/dist/assets/channel-CeXY-wZo.js +1 -0
  16. package/dist/assets/{chunk-32BRIVSS-xgrtbT8e.js → chunk-32BRIVSS-B53BNE2C.js} +1 -1
  17. package/dist/assets/{chunk-52WLFC77-CDbaHw80.js → chunk-52WLFC77-CYq4hT4f.js} +1 -1
  18. package/dist/assets/{chunk-C7G6YPKG-ZMLUvfLy.js → chunk-C7G6YPKG-KFaGpP_Z.js} +1 -1
  19. package/dist/assets/{chunk-EX3LRPZG-BcQhaqt7.js → chunk-EX3LRPZG-DqVd_ihG.js} +1 -1
  20. package/dist/assets/{chunk-FWX5IMBZ-DrnrdrSu.js → chunk-FWX5IMBZ-BxRjZkOP.js} +2 -2
  21. package/dist/assets/{chunk-HOUHSVGY-tdc-Z9eW.js → chunk-HOUHSVGY-C9QO8omv.js} +1 -1
  22. package/dist/assets/{chunk-ICXQ74PX-Cd7igVGt.js → chunk-ICXQ74PX-ClbGPgLV.js} +1 -1
  23. package/dist/assets/{chunk-MOJQB5TN-CLTjV5ov.js → chunk-MOJQB5TN-Ba4wlF1J.js} +1 -1
  24. package/dist/assets/{chunk-OGEWGWER-TgvkXkFX.js → chunk-OGEWGWER-DczhQmcx.js} +1 -1
  25. package/dist/assets/{chunk-PUDLZKDR-C5GMVYup.js → chunk-PUDLZKDR-CPWpcKUF.js} +1 -1
  26. package/dist/assets/{chunk-Q4XR5HBZ-fo5-HsqY.js → chunk-Q4XR5HBZ-CiF6T--X.js} +1 -1
  27. package/dist/assets/{chunk-V7JOEXUC-CEDazV6w.js → chunk-V7JOEXUC-Dxv0zwmw.js} +1 -1
  28. package/dist/assets/{chunk-VAUOI2AC-CWYjg8zE.js → chunk-VAUOI2AC-D3J5bw1P.js} +1 -1
  29. package/dist/assets/{chunk-VR4S4FIN-C_-eEj-9.js → chunk-VR4S4FIN-BXQSwwII.js} +1 -1
  30. package/dist/assets/{chunk-WYO6CB5R-NF-MsJJR.js → chunk-WYO6CB5R-C0azl-fk.js} +2 -2
  31. package/dist/assets/{chunk-ZGVPDNZ5-CRrAYi4g.js → chunk-ZGVPDNZ5-D_8F4Akx.js} +1 -1
  32. package/dist/assets/classDiagram-OUVF2IWQ-B8M6sf49.js +1 -0
  33. package/dist/assets/classDiagram-v2-EOCWNBFH-BY7fzBDH.js +1 -0
  34. package/dist/assets/{cynefinDiagram-TSTJHNR4-D4sZ7LYI.js → cynefinDiagram-TSTJHNR4-BFlsWz0B.js} +1 -1
  35. package/dist/assets/{dagre-VKFMJZFB-BlsnSh-Q.js → dagre-VKFMJZFB-DXhpjlEs.js} +1 -1
  36. package/dist/assets/{diagram-FQU43EPY-DW1yQiRO.js → diagram-FQU43EPY-DRVVOD9k.js} +1 -1
  37. package/dist/assets/{diagram-G47NLZAW-BTUAsFjb.js → diagram-G47NLZAW-BV9Gw1tL.js} +1 -1
  38. package/dist/assets/{diagram-NH7WQ7WH-BcaomqSd.js → diagram-NH7WQ7WH-CrUkXfRY.js} +1 -1
  39. package/dist/assets/{diagram-OA4YK3LP-DDLX7Xwr.js → diagram-OA4YK3LP-CuILjn7v.js} +1 -1
  40. package/dist/assets/{diagram-WEI45ONY-CIave0An.js → diagram-WEI45ONY-nlNN3tWB.js} +1 -1
  41. package/dist/assets/{ebnfDiagram-CCIWWBDH-Cr1Y6lPu.js → ebnfDiagram-CCIWWBDH-BuDgZt9n.js} +1 -1
  42. package/dist/assets/{erDiagram-Q63AITRT-D3EgiVVH.js → erDiagram-Q63AITRT-MEwxjmtP.js} +1 -1
  43. package/dist/assets/flowDiagram-23GEKE2U-BiUP-Scq.js +1 -0
  44. package/dist/assets/{ganttDiagram-NO4QXBWP-B0oyDIqK.js → ganttDiagram-NO4QXBWP-DNw5qghE.js} +1 -1
  45. package/dist/assets/{gitGraphDiagram-IHSO6WYX-uQtr6IqK.js → gitGraphDiagram-IHSO6WYX-CaTcakAt.js} +1 -1
  46. package/dist/assets/index-BWf6y3Kh.css +3 -0
  47. package/dist/assets/index-D3GWkXJR.js +66 -0
  48. package/dist/assets/{infoDiagram-FWYZ7A6U-B_GzlLIM.js → infoDiagram-FWYZ7A6U-DwfyP7dU.js} +1 -1
  49. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CKQIsSdU.js → ishikawaDiagram-FXEZZL3T-CmrzbOCV.js} +1 -1
  50. package/dist/assets/{journeyDiagram-5HDEW3XC-DrrvzZL1.js → journeyDiagram-5HDEW3XC-CYqe3sgr.js} +1 -1
  51. package/dist/assets/{kanban-definition-HUTT4EX6-CJQRTdXz.js → kanban-definition-HUTT4EX6-BMDO_mv0.js} +1 -1
  52. package/dist/assets/{line-B4DxX7tC.js → line-SbxJR8ZT.js} +1 -1
  53. package/dist/assets/lit-vendor-CinchGmI.js +2 -0
  54. package/dist/assets/local-tools-DY0yEU3T.js +350 -0
  55. package/dist/assets/{mcp-servers-dialog-CwHp0K4q.js → mcp-servers-dialog-7Xwu2RVW.js} +2 -2
  56. package/dist/assets/{mermaid.core-DWpShXCs.js → mermaid.core-CwXTzYjs.js} +3 -3
  57. package/dist/assets/{mindmap-definition-LN4V7U3C-CEVwZD78.js → mindmap-definition-LN4V7U3C-CLyJ78mO.js} +1 -1
  58. package/dist/assets/{pegDiagram-2B236MQR-CBLkBdnQ.js → pegDiagram-2B236MQR-4qFznrSw.js} +1 -1
  59. package/dist/assets/{pi-web-ui-DV5jKjSg.js → pi-web-ui-BNuvqKDT.js} +289 -289
  60. package/dist/assets/{pieDiagram-ENE6RG2P-8tCnwtWS.js → pieDiagram-ENE6RG2P-141sGxVe.js} +1 -1
  61. package/dist/assets/{quadrantDiagram-ABIIQ3AL-CWd28cHF.js → quadrantDiagram-ABIIQ3AL-SUI44pAX.js} +1 -1
  62. package/dist/assets/{railroadDiagram-RFXS5EU6-CI4ouOBs.js → railroadDiagram-RFXS5EU6-1oiZuYBB.js} +1 -1
  63. package/dist/assets/{requirementDiagram-TGXJPOKE-DtFz7sw4.js → requirementDiagram-TGXJPOKE-CfVishLi.js} +1 -1
  64. package/dist/assets/{sankeyDiagram-HTMAVEWB--VeOwwk9.js → sankeyDiagram-HTMAVEWB-DVu7J_Z9.js} +1 -1
  65. package/dist/assets/{sequenceDiagram-DBY2YBRQ-B8AHk5Oe.js → sequenceDiagram-DBY2YBRQ-D-Ip2yiS.js} +1 -1
  66. package/dist/assets/{skills-dialog-k9ycekIU.js → skills-dialog-C_JvWbAa.js} +3 -3
  67. package/dist/assets/{stateDiagram-2N3HPSRC-etXnJ8X8.js → stateDiagram-2N3HPSRC-Cg0hWwrp.js} +1 -1
  68. package/dist/assets/stateDiagram-v2-6OUMAXLB-DhmzNQd9.js +1 -0
  69. package/dist/assets/{swimlanes-5IMT3BWC-pabIKAmn.js → swimlanes-5IMT3BWC-Co5D-cGU.js} +1 -1
  70. package/dist/assets/swimlanesDiagram-G3AALYLV-BEvomQNa.js +8 -0
  71. package/dist/assets/{timeline-definition-FHXFAJF6-C55MKm3L.js → timeline-definition-FHXFAJF6-CBk4c_Ua.js} +1 -1
  72. package/dist/assets/{vennDiagram-L72KCM5P-CgNf1Q9W.js → vennDiagram-L72KCM5P-BII7wdRx.js} +1 -1
  73. package/dist/assets/{wardleyDiagram-EHGQE667-Drt5ytec.js → wardleyDiagram-EHGQE667-CkyJJIXP.js} +1 -1
  74. package/dist/assets/{xychartDiagram-FW5EYKEG-hhoEo_Ar.js → xychartDiagram-FW5EYKEG-B1A66Ks0.js} +1 -1
  75. package/dist/index.html +6 -6
  76. package/package.json +1 -1
  77. package/server/acp/server.mjs +3 -6
  78. package/server/agent-manager.mjs +192 -48
  79. package/server/approval-store.mjs +1 -0
  80. package/server/ask-store.mjs +78 -0
  81. package/server/auto-archive.mjs +55 -8
  82. package/server/index.mjs +172 -51
  83. package/server/lan-access-cutover.mjs +21 -4
  84. package/server/maintenance/downgrade-session-state-v1.mjs +46 -85
  85. package/server/maintenance/export-session-state-v1.mjs +1 -11
  86. package/server/routes/agent.mjs +9 -0
  87. package/server/routes/backup.mjs +1 -1
  88. package/server/routes/storage.mjs +63 -49
  89. package/server/scheduled-runs-cutover.mjs +15 -1
  90. package/server/session-index-service.mjs +57 -391
  91. package/server/session-state-backup.mjs +56 -5
  92. package/server/session-state-import.mjs +174 -0
  93. package/server/session-state-maintenance.mjs +126 -0
  94. package/server/session-state-service.mjs +128 -210
  95. package/server/share-cutover.mjs +21 -4
  96. package/server/sqlite/database.mjs +142 -7
  97. package/server/sqlite/lan-access-repository.mjs +6 -4
  98. package/server/sqlite/migrations.mjs +109 -0
  99. package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
  100. package/server/sqlite/session-index-repository.mjs +59 -208
  101. package/server/sqlite/session-state-repository.mjs +337 -387
  102. package/server/sqlite/share-repository.mjs +56 -45
  103. package/server/startup-state.mjs +107 -0
  104. package/server/storage.mjs +218 -454
  105. package/server/tools/definitions.mjs +18 -0
  106. package/server/tools/index.mjs +14 -2
  107. package/server/utils/logger.mjs +15 -4
  108. package/dist/assets/ChatPanelHost-CW3-MQyL.js +0 -48
  109. package/dist/assets/CloudAccountSettingsPage-CDkKN1Sz.js +0 -1
  110. package/dist/assets/ScheduledTasksPage-C9lOTi75.js +0 -2
  111. package/dist/assets/ShareLinksSettingsPage-CH_t33A3.js +0 -1
  112. package/dist/assets/TerminalDock-CSOVub-m.js +0 -2
  113. package/dist/assets/WorkspaceInspector-DPXgLIum.js +0 -36
  114. package/dist/assets/channel-Duu1CuH9.js +0 -1
  115. package/dist/assets/classDiagram-OUVF2IWQ-Cc63fLWP.js +0 -1
  116. package/dist/assets/classDiagram-v2-EOCWNBFH-CoXU0qT6.js +0 -1
  117. package/dist/assets/flowDiagram-23GEKE2U-BS7KXcqn.js +0 -1
  118. package/dist/assets/index-DQBOm9gt.js +0 -66
  119. package/dist/assets/index-ps85guTs.css +0 -3
  120. package/dist/assets/lit-vendor-DvaQX_UQ.js +0 -2
  121. package/dist/assets/local-tools-BxfQeIUQ.js +0 -270
  122. package/dist/assets/stateDiagram-v2-6OUMAXLB-Dms3pICj.js +0 -1
  123. package/dist/assets/swimlanesDiagram-G3AALYLV-B14aWjv6.js +0 -8
  124. package/server/session-state-cutover.mjs +0 -563
@@ -1,563 +0,0 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks'
2
- import { createHash, randomUUID } from 'node:crypto'
3
- import { createReadStream, createWriteStream, promises as fs } from 'node:fs'
4
- import path from 'node:path'
5
- import { getSqliteStorage } from './sqlite/database.mjs'
6
- import { createSessionStateRepository, snapshotDigestLine } from './sqlite/session-state-repository.mjs'
7
- import {
8
- SESSION_STORAGE_PHASES,
9
- configureSessionStateService,
10
- drainSessionJsonMirror,
11
- readSessionStorageState,
12
- setSessionStoragePhase,
13
- } from './session-state-service.mjs'
14
- import {
15
- createPhysicalSessionStateFsAdapter,
16
- materializeSessionJsonMirrorEntry,
17
- storageDir,
18
- } from './storage.mjs'
19
-
20
- const DEFAULT_LOCK_TTL_MS = 60_000
21
- const DEFAULT_WAIT_TIMEOUT_MS = 65_000
22
- const maintenanceContext = new AsyncLocalStorage()
23
- let maintenanceCount = 0
24
-
25
- function isPlainObject(value) {
26
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
27
- }
28
-
29
- function canonicalize(value) {
30
- if (Array.isArray(value)) return value.map(canonicalize)
31
- if (!isPlainObject(value)) return value
32
- return Object.fromEntries(Object.keys(value).sort().filter((key) => value[key] !== undefined).map((key) => [key, canonicalize(value[key])]))
33
- }
34
-
35
- function digestJson(value) {
36
- return createHash('sha256').update(JSON.stringify(canonicalize(value))).digest('hex')
37
- }
38
-
39
- function normalizeBucket(raw) {
40
- if (raw.scope === 'global') {
41
- if (raw.projectId !== undefined && raw.projectId !== null && raw.projectId !== '') throw new TypeError('Global session bucket cannot have projectId')
42
- return { scope: 'global', projectId: null }
43
- }
44
- if (raw.scope !== 'project' || typeof raw.projectId !== 'string' || !raw.projectId.trim() || raw.projectId === '.' || raw.projectId === '..' || /[\\/]/.test(raw.projectId)) {
45
- throw new TypeError('Invalid session bucket')
46
- }
47
- return { scope: 'project', projectId: raw.projectId }
48
- }
49
-
50
- function deriveMetadata(sessionId, state, bucket) {
51
- const createdAt = typeof state.createdAt === 'string'
52
- ? state.createdAt
53
- : (typeof state.lastModified === 'string' ? state.lastModified : '1970-01-01T00:00:00.000Z')
54
- return {
55
- id: sessionId,
56
- title: typeof state.title === 'string' ? state.title : 'New chat',
57
- titleSource: state.titleSource,
58
- createdAt,
59
- lastModified: typeof state.lastModified === 'string' ? state.lastModified : createdAt,
60
- messageCount: state.messages.length,
61
- stateVersion: Number.isInteger(state.stateVersion) && state.stateVersion >= 0 ? state.stateVersion : 0,
62
- thinkingLevel: typeof state.thinkingLevel === 'string' ? state.thinkingLevel : 'off',
63
- scope: bucket.scope,
64
- ...(bucket.scope === 'project' ? { projectId: bucket.projectId } : {}),
65
- taskStatus: state.taskStatus || 'idle',
66
- taskStartedAt: state.taskStartedAt ?? null,
67
- taskFinishedAt: state.taskFinishedAt ?? null,
68
- }
69
- }
70
-
71
- // Per-session normalization shared verbatim by the materialized snapshot
72
- // (buildSessionJsonSnapshot) and the streaming cutover source below —
73
- // validation, shaping and diagnostics MUST stay identical between the two
74
- // paths; the snapshot tests are the parity anchor. Returns null for
75
- // metadata-only orphans (diagnostics already recorded; orphans are excluded
76
- // from records, backups and the SQLite import).
77
- function normalizeSessionEntry(bucket, sessionId, rawState, rawMetadata, diagnostics) {
78
- if (!rawState && rawMetadata) {
79
- diagnostics.metadataOnly.push(sessionId)
80
- diagnostics.orphanDeletes.push({ scope: bucket.scope, projectId: bucket.projectId, sessionId })
81
- return null
82
- }
83
- if (!isPlainObject(rawState)) throw new TypeError(`Invalid session state: ${sessionId}`)
84
- if (!Array.isArray(rawState.messages)) throw new TypeError(`Session messages must be an array: ${sessionId}`)
85
- if (rawState.id !== undefined && rawState.id !== sessionId) throw new TypeError(`Session body id mismatch: ${sessionId}`)
86
- if (rawState.scope !== undefined && rawState.scope !== bucket.scope) throw new TypeError(`Session body scope mismatch: ${sessionId}`)
87
- if (bucket.scope === 'project' && rawState.projectId !== undefined && rawState.projectId !== bucket.projectId) throw new TypeError(`Session body project mismatch: ${sessionId}`)
88
- if (bucket.scope === 'global' && rawState.projectId !== undefined && rawState.projectId !== null) throw new TypeError(`Global session body project mismatch: ${sessionId}`)
89
- let metadata
90
- if (rawMetadata === undefined) {
91
- diagnostics.bodyOnly.push(sessionId)
92
- metadata = deriveMetadata(sessionId, rawState, bucket)
93
- } else {
94
- if (!isPlainObject(rawMetadata)) throw new TypeError(`Invalid session metadata: ${sessionId}`)
95
- if (rawMetadata.id !== undefined && rawMetadata.id !== sessionId) throw new TypeError(`Session metadata id mismatch: ${sessionId}`)
96
- if (rawMetadata.scope !== undefined && rawMetadata.scope !== bucket.scope) throw new TypeError(`Session metadata scope mismatch: ${sessionId}`)
97
- if (bucket.scope === 'project' && rawMetadata.projectId !== undefined && rawMetadata.projectId !== bucket.projectId) throw new TypeError(`Session metadata project mismatch: ${sessionId}`)
98
- if (bucket.scope === 'global' && rawMetadata.projectId !== undefined && rawMetadata.projectId !== null) throw new TypeError(`Global session metadata project mismatch: ${sessionId}`)
99
- metadata = structuredClone(rawMetadata)
100
- }
101
- const stateVersion = rawState.stateVersion ?? metadata.stateVersion ?? 0
102
- if (!Number.isInteger(stateVersion) || stateVersion < 0) throw new TypeError(`Invalid session stateVersion: ${sessionId}`)
103
- const state = { ...structuredClone(rawState), id: sessionId, scope: bucket.scope, stateVersion }
104
- metadata = { ...metadata, id: sessionId, scope: bucket.scope, stateVersion }
105
- if (bucket.scope === 'project') {
106
- state.projectId = bucket.projectId
107
- metadata.projectId = bucket.projectId
108
- } else {
109
- delete state.projectId
110
- delete metadata.projectId
111
- }
112
- for (const field of ['pinnedAt', 'archivedAt']) {
113
- if (metadata[field] !== undefined) state[field] = metadata[field]
114
- }
115
- return { ...bucket, sessionId, stateVersion, state, metadata, stateDigest: digestJson(state), metadataDigest: digestJson(metadata) }
116
- }
117
-
118
- export function buildSessionJsonSnapshot(buckets) {
119
- if (!Array.isArray(buckets)) throw new TypeError('Session buckets must be an array')
120
- const records = []
121
- const diagnostics = { bodyOnly: [], metadataOnly: [], orphanDeletes: [], duplicateSessionIds: [] }
122
- const seen = new Set()
123
- for (const rawBucket of buckets) {
124
- const bucket = normalizeBucket(rawBucket)
125
- if (!isPlainObject(rawBucket.sessions) || !isPlainObject(rawBucket.metadata)) throw new TypeError('Session bucket stores must be objects')
126
- const ids = new Set([...Object.keys(rawBucket.sessions), ...Object.keys(rawBucket.metadata)])
127
- for (const sessionId of ids) {
128
- if (seen.has(sessionId)) diagnostics.duplicateSessionIds.push(sessionId)
129
- seen.add(sessionId)
130
- const record = normalizeSessionEntry(bucket, sessionId, rawBucket.sessions[sessionId], rawBucket.metadata[sessionId], diagnostics)
131
- if (record) records.push(record)
132
- }
133
- }
134
- if (diagnostics.duplicateSessionIds.length > 0) throw new TypeError(`Duplicate session ids across buckets: ${[...new Set(diagnostics.duplicateSessionIds)].join(', ')}`)
135
- // Startup cutover holds the maintenance lock with no concurrent writes, so
136
- // metadata-only orphans are stale residue of already-deleted sessions: they
137
- // are dropped above (excluded from records) and recorded in diagnostics for
138
- // audit instead of blocking the migration. Orphans ride the mirror delete
139
- // queue (`orphanDeletes` passed to replaceAll as `mirrorDeletes`) so the
140
- // drain physically clears their leftover JSON metadata; otherwise
141
- // initializeSessionIndex could re-import that residue on a later boot and
142
- // the orphaned index rows would fail the next startup integrity check.
143
- records.sort((left, right) => left.sessionId.localeCompare(right.sessionId))
144
- // F9 v7: the canonical snapshot digest line now includes a `messagesDigest`
145
- // slot. JSON imports are always non-split (messages inline in the body), so
146
- // they contribute an empty messages digest — matching the repository digest
147
- // of freshly imported (non-split) SQLite rows.
148
- const digest = createHash('sha256').update(records.map((record) => snapshotDigestLine(record.scope, record.projectId, record.sessionId, record.stateDigest, record.metadataDigest, '')).join('\n')).digest('hex')
149
- return { records, count: records.length, digest, diagnostics }
150
- }
151
-
152
- // Streaming cutover source: each pass consumes the fsAdapter lazily — one
153
- // bucket listing, one metadata bucket parse and one session file parse at a
154
- // time — so a multi-GB library never needs to be fully resident. Only the
155
- // small per-pass summary (digest lines + diagnostics) survives a full
156
- // iteration. Records are normalized by the shared normalizeSessionEntry, and
157
- // the summary digest is computed from digest lines sorted by sessionId, which
158
- // matches buildSessionJsonSnapshot's snapshot digest exactly. Bucket-local
159
- // iteration order is sorted session ids; duplicate/orphan detection is
160
- // order-independent. Returns a factory: each call produces one independent
161
- // { iterate, getSummary } pass over the source.
162
- export function createStreamingSessionSource(fsAdapter) {
163
- return () => {
164
- const digestEntries = []
165
- const diagnostics = { bodyOnly: [], metadataOnly: [], orphanDeletes: [], duplicateSessionIds: [] }
166
- const seen = new Set()
167
- let count = 0
168
- let completed = false
169
- const iterate = async function* streamSessionRecords() {
170
- for await (const rawBucket of fsAdapter.listBuckets()) {
171
- const bucket = normalizeBucket(rawBucket)
172
- const metadata = await fsAdapter.readMetadataBucket(rawBucket)
173
- if (!isPlainObject(metadata)) throw new TypeError('Session bucket stores must be objects')
174
- const fileIds = new Set()
175
- for await (const sessionId of fsAdapter.listSessionFiles(rawBucket)) fileIds.add(sessionId)
176
- const ids = [...new Set([...fileIds, ...Object.keys(metadata)])].sort((left, right) => left.localeCompare(right))
177
- for (const sessionId of ids) {
178
- if (seen.has(sessionId)) diagnostics.duplicateSessionIds.push(sessionId)
179
- seen.add(sessionId)
180
- const rawState = fileIds.has(sessionId) ? await fsAdapter.readSessionState(rawBucket, sessionId) : undefined
181
- const record = normalizeSessionEntry(bucket, sessionId, rawState, metadata[sessionId], diagnostics)
182
- if (!record) continue
183
- count += 1
184
- digestEntries.push({ sessionId, line: snapshotDigestLine(record.scope, record.projectId, record.sessionId, record.stateDigest, record.metadataDigest, '') })
185
- yield record
186
- }
187
- }
188
- completed = true
189
- }
190
- return {
191
- iterate,
192
- getSummary() {
193
- if (!completed) throw new Error('Session source summary is not ready before the iteration completes')
194
- if (diagnostics.duplicateSessionIds.length > 0) throw new TypeError(`Duplicate session ids across buckets: ${[...new Set(diagnostics.duplicateSessionIds)].join(', ')}`)
195
- digestEntries.sort((left, right) => left.sessionId.localeCompare(right.sessionId))
196
- const digest = createHash('sha256').update(digestEntries.map((entry) => entry.line).join('\n')).digest('hex')
197
- return { count, digest, diagnostics }
198
- },
199
- }
200
- }
201
- }
202
-
203
- // readBuckets keeps the historical injection path for tests and small data
204
- // sets: each pass calls readBuckets() exactly once, materializes one full
205
- // snapshot (buildSessionJsonSnapshot) and replays its records. The summary is
206
- // structurally identical to the streaming source, so the cutover flow below
207
- // is agnostic to which source factory it was given.
208
- function createReadBucketsSessionSource(readBuckets) {
209
- return () => {
210
- let snapshot = null
211
- const iterate = async function* snapshotSessionRecords() {
212
- snapshot = buildSessionJsonSnapshot(await readBuckets())
213
- yield* snapshot.records
214
- }
215
- return {
216
- iterate,
217
- getSummary() {
218
- if (!snapshot) throw new Error('Session source summary is not ready before the iteration completes')
219
- return { count: snapshot.count, digest: snapshot.digest, diagnostics: snapshot.diagnostics }
220
- },
221
- }
222
- }
223
- }
224
-
225
- async function summarizeSessionSource(source) {
226
- let consumed = 0
227
- for await (const _record of source.iterate()) consumed += 1
228
- const summary = source.getSummary()
229
- if (summary.count !== consumed) throw new Error('Session source summary count mismatch')
230
- return summary
231
- }
232
-
233
- function sameSessionSourceSummary(left, right) {
234
- return left.count === right.count && left.digest === right.digest
235
- && JSON.stringify(left.diagnostics) === JSON.stringify(right.diagnostics)
236
- }
237
-
238
- // Streaming v1 backup writer: the output JSON shape matches the old
239
- // materialized writer exactly ({app, version, exportedAt, scope,
240
- // includeSecrets, sessionState, data:{sessions, sessionsMetadata}}), but
241
- // states are streamed record-by-record so the library never lives in memory
242
- // as a whole. The sessions section is written while iterating; metadata JSON
243
- // strings (small per-session summaries) are buffered for the second section —
244
- // states themselves are never buffered. Verification is byte-level: the
245
- // write-side sha256/byte counter is re-checked against a chunked
246
- // createReadStream re-read plus a first/last byte sanity check, replacing the
247
- // old "read whole file + re-parse + re-snapshot" verification. The streamed
248
- // records' digest is also re-accumulated and compared against `summary`, so a
249
- // source that changed since the double read fails closed here.
250
- async function writeCutoverBackupStream(createIteration, summary, options = {}) {
251
- const directory = options.directory || path.join(storageDir, 'backups')
252
- await fs.mkdir(directory, { recursive: true })
253
- const stamp = new Date().toISOString().replace(/[:.]/g, '-')
254
- const finalPath = path.join(directory, `quickforge-session-state-cutover-${stamp}.json`)
255
- const temporaryPath = `${finalPath}.${process.pid}.${randomUUID()}.tmp`
256
- const openWriteStream = options.createWriteStream || createWriteStream
257
- try {
258
- const stream = openWriteStream(temporaryPath, { encoding: 'utf8' })
259
- const failures = []
260
- stream.on('error', (error) => { failures.push(error) })
261
- const written = createHash('sha256')
262
- let bytes = 0
263
- const write = async (chunk) => {
264
- written.update(chunk)
265
- bytes += Buffer.byteLength(chunk, 'utf8')
266
- if (stream.write(chunk, 'utf8')) {
267
- if (failures.length > 0) throw failures[0]
268
- return
269
- }
270
- await new Promise((resolve, reject) => {
271
- const onDrain = () => { cleanup(); resolve() }
272
- const onError = (error) => { cleanup(); reject(error) }
273
- const cleanup = () => { stream.off('drain', onDrain); stream.off('error', onError) }
274
- stream.once('drain', onDrain)
275
- stream.once('error', onError)
276
- })
277
- }
278
- await write(`{\n "app": "quickforge",\n "version": 1,\n "exportedAt": ${JSON.stringify(new Date().toISOString())},\n "scope": "sessions",\n "includeSecrets": false,\n "sessionState": {\n "count": ${summary.count},\n "digest": ${JSON.stringify(summary.digest)}\n },\n "data": {\n "sessions": {`)
279
- const digestEntries = []
280
- let count = 0
281
- let firstSession = true
282
- const metadataChunks = []
283
- for await (const record of createIteration()) {
284
- digestEntries.push({ sessionId: record.sessionId, line: snapshotDigestLine(record.scope, record.projectId, record.sessionId, record.stateDigest, record.metadataDigest, '') })
285
- count += 1
286
- const prefix = firstSession ? '' : ','
287
- await write(`${prefix}\n ${JSON.stringify(record.sessionId)}: ${JSON.stringify(record.state)}`)
288
- metadataChunks.push(`${prefix}\n ${JSON.stringify(record.sessionId)}: ${JSON.stringify(record.metadata)}`)
289
- firstSession = false
290
- }
291
- await write(`\n },\n "sessionsMetadata": {`)
292
- for (const metadataChunk of metadataChunks) {
293
- await write(metadataChunk)
294
- }
295
- await write(`\n }\n }\n}`)
296
- await new Promise((resolve, reject) => {
297
- const onError = (error) => { cleanup(); reject(error) }
298
- const cleanup = () => { stream.off('error', onError) }
299
- stream.once('error', onError)
300
- stream.end(() => { cleanup(); resolve() })
301
- })
302
- if (count !== summary.count) throw new Error('Session JSON source changed before cutover commit')
303
- digestEntries.sort((left, right) => left.sessionId.localeCompare(right.sessionId))
304
- const digest = createHash('sha256').update(digestEntries.map((entry) => entry.line).join('\n')).digest('hex')
305
- if (digest !== summary.digest) throw new Error('Session JSON source changed before cutover commit')
306
- const reread = createHash('sha256')
307
- let rereadBytes = 0
308
- let firstByte = -1
309
- let lastByte = -1
310
- await new Promise((resolve, reject) => {
311
- const reader = createReadStream(temporaryPath)
312
- reader.on('data', (chunk) => {
313
- if (firstByte === -1) firstByte = chunk[0]
314
- lastByte = chunk[chunk.length - 1]
315
- reread.update(chunk)
316
- rereadBytes += chunk.length
317
- })
318
- reader.once('error', reject)
319
- reader.once('end', () => resolve())
320
- })
321
- if (reread.digest('hex') !== written.digest('hex') || rereadBytes !== bytes || firstByte !== 0x7b || lastByte !== 0x7d) {
322
- throw new Error('Session cutover backup verification failed')
323
- }
324
- await fs.rename(temporaryPath, finalPath)
325
- return finalPath
326
- } catch (error) {
327
- await fs.rm(temporaryPath, { force: true }).catch(() => {})
328
- throw error
329
- }
330
- }
331
-
332
- function ownerPid(row) {
333
- const value = Number(row?.owner_pid)
334
- return Number.isInteger(value) && value > 0 ? value : null
335
- }
336
-
337
- function pidAlive(pid) {
338
- try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
339
- }
340
-
341
- export function acquireSessionStateMaintenanceLock(storage, options = {}) {
342
- const nowMs = Number(options.now?.() ?? Date.now())
343
- const acquiredAt = new Date(nowMs).toISOString()
344
- const expiresAt = new Date(nowMs + (options.ttlMs ?? DEFAULT_LOCK_TTL_MS)).toISOString()
345
- const identity = isPlainObject(options.owner)
346
- ? { id: options.owner.id || `${options.owner.pid || process.pid}:${randomUUID()}`, pid: options.owner.pid || process.pid }
347
- : { id: options.owner || `${process.pid}:${randomUUID()}`, pid: process.pid }
348
- const isPidAlive = options.pidAlive || pidAlive
349
- return storage.transaction((database) => {
350
- const current = database.prepare('SELECT * FROM session_state_maintenance_lock WHERE singleton = 1').get()
351
- if (current && current.owner === identity.id) {
352
- database.prepare(`UPDATE session_state_maintenance_lock SET owner_pid = ?, operation = ?, heartbeat_at = ?, expires_at = ?
353
- WHERE singleton = 1 AND owner = ? AND fencing = ?`)
354
- .run(identity.pid, options.operation || current.operation, acquiredAt, expiresAt, identity.id, current.fencing)
355
- return { owner: identity.id, ownerPid: identity.pid, fencing: Number(current.fencing), expiresAt }
356
- }
357
- if (current) {
358
- const expired = Date.parse(current.expires_at) <= nowMs
359
- const pid = ownerPid(current)
360
- if (!expired || pid === null || isPidAlive(pid)) return null
361
- }
362
- const fencing = Number(current?.fencing || 0) + 1
363
- database.prepare(`INSERT INTO session_state_maintenance_lock
364
- (singleton, owner, owner_pid, fencing, operation, acquired_at, heartbeat_at, expires_at)
365
- VALUES (1, ?, ?, ?, ?, ?, ?, ?)
366
- ON CONFLICT(singleton) DO UPDATE SET owner=excluded.owner, owner_pid=excluded.owner_pid, fencing=excluded.fencing,
367
- operation=excluded.operation, acquired_at=excluded.acquired_at, heartbeat_at=excluded.heartbeat_at, expires_at=excluded.expires_at`)
368
- .run(identity.id, identity.pid, fencing, options.operation || 'session-state-maintenance', acquiredAt, acquiredAt, expiresAt)
369
- return { owner: identity.id, ownerPid: identity.pid, fencing, expiresAt }
370
- }, { mode: 'immediate' })
371
- }
372
-
373
- export function renewSessionStateMaintenanceLock(storage, lease, options = {}) {
374
- const nowMs = Number(options.now?.() ?? Date.now())
375
- const heartbeatAt = new Date(nowMs).toISOString()
376
- const expiresAt = new Date(nowMs + (options.ttlMs ?? DEFAULT_LOCK_TTL_MS)).toISOString()
377
- const result = storage.prepare(`UPDATE session_state_maintenance_lock SET heartbeat_at = ?, expires_at = ? WHERE singleton = 1 AND owner = ? AND fencing = ?`)
378
- .run(heartbeatAt, expiresAt, lease.owner, lease.fencing)
379
- lease.expiresAt = expiresAt
380
- return Number(result.changes) === 1
381
- }
382
-
383
- export function releaseSessionStateMaintenanceLock(storage, lease) {
384
- return Number(storage.prepare('DELETE FROM session_state_maintenance_lock WHERE singleton = 1 AND owner = ? AND fencing = ?').run(lease.owner, lease.fencing).changes) === 1
385
- }
386
-
387
- async function waitForLock(storage, options) {
388
- const deadline = Date.now() + (options.waitTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS)
389
- while (Date.now() < deadline) {
390
- const lease = acquireSessionStateMaintenanceLock(storage, options)
391
- if (lease) return lease
392
- await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs ?? 100))
393
- }
394
- throw new Error('Timed out waiting for session state maintenance lock')
395
- }
396
-
397
- export async function runSessionStateMaintenance(operation, options = {}) {
398
- const existing = maintenanceContext.getStore()
399
- if (existing) return operation(existing)
400
- const storage = options.storage || getSqliteStorage()
401
- const lease = await waitForLock(storage, options)
402
- const interval = options.heartbeatIntervalMs ?? Math.max(10, Math.floor((options.ttlMs ?? DEFAULT_LOCK_TTL_MS) / 3))
403
- const timer = setInterval(() => {
404
- try {
405
- if (!renewSessionStateMaintenanceLock(storage, lease, options)) throw new Error('Session state maintenance lock fencing was lost')
406
- } catch (error) {
407
- heartbeatError = error
408
- rejectHeartbeat(error)
409
- }
410
- }, interval)
411
- timer.unref?.()
412
- let rejectHeartbeat
413
- let heartbeatError = null
414
- const heartbeatFailure = new Promise((_, reject) => { rejectHeartbeat = reject })
415
- maintenanceCount += 1
416
- try {
417
- const context = { storage, lease }
418
- const result = await Promise.race([maintenanceContext.run(context, () => Promise.resolve().then(() => operation(context))), heartbeatFailure])
419
- if (heartbeatError) throw heartbeatError
420
- return result
421
- } finally {
422
- clearInterval(timer)
423
- releaseSessionStateMaintenanceLock(storage, lease)
424
- maintenanceCount = Math.max(0, maintenanceCount - 1)
425
- }
426
- }
427
-
428
- export function isSessionStateMaintenanceActive(storage) {
429
- if (!storage) {
430
- try { storage = getSqliteStorage() } catch { return false }
431
- }
432
- return maintenanceCount > 0 || Boolean(storage.prepare('SELECT 1 AS active FROM session_state_maintenance_lock WHERE singleton = 1').get())
433
- }
434
-
435
- export function currentSessionStateMaintenanceContext() {
436
- return maintenanceContext.getStore() || null
437
- }
438
-
439
- function mirrorAdapter() {
440
- async function materialize(entry) {
441
- await materializeSessionJsonMirrorEntry(entry)
442
- }
443
- return { upsert: materialize, delete: materialize }
444
- }
445
-
446
- function integrityFailureSummary(integrity) {
447
- const summary = Object.entries(integrity)
448
- .filter(([key, value]) => !['ok', 'count', 'digest'].includes(key) && Number(value) > 0)
449
- .map(([key, value]) => `${key}=${value}`)
450
- .join(', ')
451
- return summary || 'unknown'
452
- }
453
-
454
- // session_index is a pure projection of session_states, but
455
- // initializeSessionIndex rebuilds it from the JSON source, which can drift
456
- // from authoritative rows (e.g. orphan metadata residue re-imported after a
457
- // failed mirror drain). Rebuilding the projection from the authoritative
458
- // states is lossless self-healing: on integrity failure, rebuild the index
459
- // and re-verify, and only fail closed if the re-check still fails.
460
- function verifyIntegrityWithIndexSelfHeal(repository, phase) {
461
- const first = repository.verifyIntegrity({ quickCheck: true })
462
- if (first.ok) return first
463
- repository.rebuildIndex()
464
- const second = repository.verifyIntegrity({ quickCheck: true })
465
- if (!second.ok) throw new Error(`Session state ${phase} integrity verification failed (${integrityFailureSummary(first)})`)
466
- return second
467
- }
468
-
469
- export async function initializeSessionStateCutover(options = {}) {
470
- const storage = options.storage || getSqliteStorage()
471
- const repository = options.repository || createSessionStateRepository(storage)
472
- configureSessionStateService({ repository, mirror: options.mirror || mirrorAdapter() })
473
- return runSessionStateMaintenance(async () => {
474
- const current = readSessionStorageState()
475
- if (current.phase === SESSION_STORAGE_PHASES.JSON_PENDING) {
476
- const integrity = verifyIntegrityWithIndexSelfHeal(repository, 'pending')
477
- const drained = await drainSessionJsonMirror()
478
- // integrity is the lightweight (SQL-level) check — its digest is null.
479
- // The verified digest persisted by replaceAll's storageState stays
480
- // authoritative when promoting to authoritative.
481
- if (drained.pending === 0) setSessionStoragePhase(SESSION_STORAGE_PHASES.AUTHORITATIVE, { stateCount: integrity.count, digest: current.digest, backupFile: current.backupFile })
482
- return readSessionStorageState()
483
- }
484
- if (current.phase === SESSION_STORAGE_PHASES.AUTHORITATIVE) {
485
- verifyIntegrityWithIndexSelfHeal(repository, 'authoritative')
486
- await drainSessionJsonMirror()
487
- return readSessionStorageState()
488
- }
489
-
490
- if (current.phase === SESSION_STORAGE_PHASES.CUTOVER_RUNNING && current.backupFile) {
491
- setSessionStoragePhase(SESSION_STORAGE_PHASES.JSON_AUTHORITATIVE, {
492
- backupFile: current.backupFile,
493
- diagnostic: { operation: 'cutover_recovery', recoveredFrom: SESSION_STORAGE_PHASES.CUTOVER_RUNNING },
494
- })
495
- }
496
-
497
- let backupFile = current.backupFile
498
- try {
499
- // Streaming cutover (step 2): the json_authoritative migration path runs
500
- // FOUR independent full iterations of the JSON source — (1) summary,
501
- // (2) summary re-read, (3) streaming backup write (or summary-only when
502
- // a backup already exists) and (4) replaceAllStream import. Only the
503
- // small summaries (digest lines + diagnostics) survive between passes,
504
- // so peak memory is bounded by the largest single session instead of
505
- // the whole library. options.readBuckets keeps the materialized
506
- // injection path for tests; the production default streams the physical
507
- // layout one file at a time via the fsAdapter.
508
- const createSource = options.readBuckets
509
- ? createReadBucketsSessionSource(options.readBuckets)
510
- : createStreamingSessionSource(options.fsAdapter || createPhysicalSessionStateFsAdapter())
511
- const first = await summarizeSessionSource(createSource())
512
- const second = await summarizeSessionSource(createSource())
513
- if (!sameSessionSourceSummary(first, second)) throw new Error('Session JSON source changed during cutover double read')
514
- if (!backupFile) {
515
- backupFile = await writeCutoverBackupStream(() => createSource().iterate(), first, {
516
- directory: options.backupDirectory,
517
- createWriteStream: options.createBackupWriteStream,
518
- })
519
- } else {
520
- const third = await summarizeSessionSource(createSource())
521
- if (!sameSessionSourceSummary(first, third)) throw new Error('Session JSON source changed before cutover commit')
522
- }
523
- setSessionStoragePhase(SESSION_STORAGE_PHASES.CUTOVER_RUNNING, {
524
- stateCount: first.count,
525
- digest: first.digest,
526
- backupFile,
527
- diagnostic: first.diagnostics,
528
- })
529
- const pendingValues = {
530
- phase: SESSION_STORAGE_PHASES.JSON_PENDING,
531
- backupFile,
532
- diagnostic: first.diagnostics,
533
- }
534
- // replaceAllStream consumes the 4th source pass record-by-record inside
535
- // its own immediate transaction and verifies expectedDigest there.
536
- await repository.replaceAllStream(createSource().iterate(), {
537
- expectedCount: first.count,
538
- expectedDigest: first.digest,
539
- storageState: pendingValues,
540
- mirrorDeletes: first.diagnostics.orphanDeletes,
541
- })
542
- // replaceAll already verified expectedDigest inside its transaction, so
543
- // the post-replace check re-asserts SQL-level integrity and the count
544
- // only (the lightweight verification digest is null by design).
545
- const integrity = repository.verifyIntegrity({ quickCheck: true })
546
- if (!integrity.ok || integrity.count !== first.count) throw new Error('Session SQLite replace verification failed')
547
- readSessionStorageState()
548
- const drained = await drainSessionJsonMirror()
549
- if (drained.pending === 0) setSessionStoragePhase(SESSION_STORAGE_PHASES.AUTHORITATIVE, { stateCount: integrity.count, digest: first.digest, backupFile, diagnostic: first.diagnostics })
550
- return readSessionStorageState()
551
- } catch (error) {
552
- const state = readSessionStorageState()
553
- if (![SESSION_STORAGE_PHASES.JSON_PENDING, SESSION_STORAGE_PHASES.AUTHORITATIVE].includes(state.phase)) {
554
- setSessionStoragePhase(SESSION_STORAGE_PHASES.JSON_AUTHORITATIVE, {
555
- backupFile,
556
- diagnostic: { operation: 'cutover', errorName: error?.name || 'Error', error: error?.message || String(error) },
557
- })
558
- return readSessionStorageState()
559
- }
560
- throw error
561
- }
562
- }, { ...options, storage, operation: 'session-state-cutover' })
563
- }