@shawnstack/quickforge 1.7.9 → 1.7.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/assets/{AgentProfilesPage-BSM5w3bn.js → AgentProfilesPage-Cvb1KTz5.js} +1 -1
  2. package/dist/assets/ChatPanelHost-C7zV-xE_.js +48 -0
  3. package/dist/assets/{CloudAccountSettingsPage-BJMrfgy9.js → CloudAccountSettingsPage-mAs_nbvw.js} +1 -1
  4. package/dist/assets/{PluginsPage-mJkTmVVV.js → PluginsPage-mLvwvTmL.js} +1 -1
  5. package/dist/assets/{ScheduledTasksPage-DJj_NLm9.js → ScheduledTasksPage-CuUW_9lq.js} +1 -1
  6. package/dist/assets/{SettingsWorkspacePage-7lQ0Wl63.js → SettingsWorkspacePage-0OINfEqK.js} +390 -390
  7. package/dist/assets/{ShareLinksSettingsPage-DjFq_Lnb.js → ShareLinksSettingsPage-DqeyJYoD.js} +1 -1
  8. package/dist/assets/{SharedConversationPage-BbuNpcpP.js → SharedConversationPage-C7CDEoX9.js} +1 -1
  9. package/dist/assets/TerminalDock-CZXcuMpq.js +2 -0
  10. package/dist/assets/{WorkspaceInspector-0i6x-diB.js → WorkspaceInspector-CNSBOEvP.js} +3 -3
  11. package/dist/assets/index-BgMvQW1b.js +66 -0
  12. package/dist/assets/index-CB1g2D0f.css +3 -0
  13. package/dist/assets/local-tools-Dopjg9xq.js +270 -0
  14. package/dist/assets/{mcp-servers-dialog-BG4b67ea.js → mcp-servers-dialog-B2lALtTE.js} +1 -1
  15. package/dist/assets/{skills-dialog-DIzZvQqW.js → skills-dialog-DYp35JM3.js} +1 -1
  16. package/dist/index.html +4 -4
  17. package/package.json +1 -1
  18. package/server/acp/server.mjs +3 -6
  19. package/server/agent-manager.mjs +125 -43
  20. package/server/auto-archive.mjs +55 -8
  21. package/server/auto-compaction.mjs +0 -2
  22. package/server/context-usage.mjs +9 -7
  23. package/server/index.mjs +172 -51
  24. package/server/lan-access-cutover.mjs +21 -4
  25. package/server/maintenance/downgrade-session-state-v1.mjs +50 -87
  26. package/server/maintenance/export-session-state-v1.mjs +5 -13
  27. package/server/routes/backup.mjs +8 -1
  28. package/server/routes/storage.mjs +85 -48
  29. package/server/scheduled-runs-cutover.mjs +15 -1
  30. package/server/session-index-service.mjs +57 -391
  31. package/server/session-persistence-lock.mjs +17 -6
  32. package/server/session-state-backup.mjs +60 -7
  33. package/server/session-state-import.mjs +157 -0
  34. package/server/session-state-maintenance.mjs +126 -0
  35. package/server/session-state-service.mjs +133 -194
  36. package/server/share-cutover.mjs +21 -4
  37. package/server/sqlite/database.mjs +142 -7
  38. package/server/sqlite/lan-access-repository.mjs +6 -4
  39. package/server/sqlite/migrations.mjs +109 -0
  40. package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
  41. package/server/sqlite/session-index-repository.mjs +59 -208
  42. package/server/sqlite/session-state-repository.mjs +359 -289
  43. package/server/sqlite/share-repository.mjs +56 -45
  44. package/server/startup-state.mjs +107 -0
  45. package/server/storage.mjs +240 -448
  46. package/server/utils/logger.mjs +15 -4
  47. package/server/utils/process-tree.mjs +14 -2
  48. package/dist/assets/ChatPanelHost-cd80S4EX.js +0 -48
  49. package/dist/assets/TerminalDock-CczcCJJE.js +0 -2
  50. package/dist/assets/index-D-J_8Smf.css +0 -3
  51. package/dist/assets/index-DFASc15l.js +0 -66
  52. package/dist/assets/local-tools-Dn1Y9aPe.js +0 -270
  53. package/server/session-state-cutover.mjs +0 -370
@@ -1,370 +0,0 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks'
2
- import { createHash, randomUUID } from 'node:crypto'
3
- import { 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
- materializeSessionJsonMirrorEntry,
16
- readPhysicalSessionStateBuckets,
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
- export function buildSessionJsonSnapshot(buckets) {
72
- if (!Array.isArray(buckets)) throw new TypeError('Session buckets must be an array')
73
- const records = []
74
- const diagnostics = { bodyOnly: [], metadataOnly: [], duplicateSessionIds: [] }
75
- const seen = new Set()
76
- for (const rawBucket of buckets) {
77
- const bucket = normalizeBucket(rawBucket)
78
- if (!isPlainObject(rawBucket.sessions) || !isPlainObject(rawBucket.metadata)) throw new TypeError('Session bucket stores must be objects')
79
- const ids = new Set([...Object.keys(rawBucket.sessions), ...Object.keys(rawBucket.metadata)])
80
- for (const sessionId of ids) {
81
- if (seen.has(sessionId)) diagnostics.duplicateSessionIds.push(sessionId)
82
- seen.add(sessionId)
83
- const rawState = rawBucket.sessions[sessionId]
84
- const rawMetadata = rawBucket.metadata[sessionId]
85
- if (!rawState && rawMetadata) {
86
- diagnostics.metadataOnly.push(sessionId)
87
- continue
88
- }
89
- if (!isPlainObject(rawState)) throw new TypeError(`Invalid session state: ${sessionId}`)
90
- if (!Array.isArray(rawState.messages)) throw new TypeError(`Session messages must be an array: ${sessionId}`)
91
- if (rawState.id !== undefined && rawState.id !== sessionId) throw new TypeError(`Session body id mismatch: ${sessionId}`)
92
- if (rawState.scope !== undefined && rawState.scope !== bucket.scope) throw new TypeError(`Session body scope mismatch: ${sessionId}`)
93
- if (bucket.scope === 'project' && rawState.projectId !== undefined && rawState.projectId !== bucket.projectId) throw new TypeError(`Session body project mismatch: ${sessionId}`)
94
- if (bucket.scope === 'global' && rawState.projectId !== undefined && rawState.projectId !== null) throw new TypeError(`Global session body project mismatch: ${sessionId}`)
95
- let metadata
96
- if (rawMetadata === undefined) {
97
- diagnostics.bodyOnly.push(sessionId)
98
- metadata = deriveMetadata(sessionId, rawState, bucket)
99
- } else {
100
- if (!isPlainObject(rawMetadata)) throw new TypeError(`Invalid session metadata: ${sessionId}`)
101
- if (rawMetadata.id !== undefined && rawMetadata.id !== sessionId) throw new TypeError(`Session metadata id mismatch: ${sessionId}`)
102
- if (rawMetadata.scope !== undefined && rawMetadata.scope !== bucket.scope) throw new TypeError(`Session metadata scope mismatch: ${sessionId}`)
103
- if (bucket.scope === 'project' && rawMetadata.projectId !== undefined && rawMetadata.projectId !== bucket.projectId) throw new TypeError(`Session metadata project mismatch: ${sessionId}`)
104
- if (bucket.scope === 'global' && rawMetadata.projectId !== undefined && rawMetadata.projectId !== null) throw new TypeError(`Global session metadata project mismatch: ${sessionId}`)
105
- metadata = structuredClone(rawMetadata)
106
- }
107
- const stateVersion = rawState.stateVersion ?? metadata.stateVersion ?? 0
108
- if (!Number.isInteger(stateVersion) || stateVersion < 0) throw new TypeError(`Invalid session stateVersion: ${sessionId}`)
109
- const state = { ...structuredClone(rawState), id: sessionId, scope: bucket.scope, stateVersion }
110
- metadata = { ...metadata, id: sessionId, scope: bucket.scope, stateVersion }
111
- if (bucket.scope === 'project') {
112
- state.projectId = bucket.projectId
113
- metadata.projectId = bucket.projectId
114
- } else {
115
- delete state.projectId
116
- delete metadata.projectId
117
- }
118
- for (const field of ['pinnedAt', 'archivedAt']) {
119
- if (metadata[field] !== undefined) state[field] = metadata[field]
120
- }
121
- records.push({ ...bucket, sessionId, stateVersion, state, metadata, stateDigest: digestJson(state), metadataDigest: digestJson(metadata) })
122
- }
123
- }
124
- if (diagnostics.duplicateSessionIds.length > 0) throw new TypeError(`Duplicate session ids across buckets: ${[...new Set(diagnostics.duplicateSessionIds)].join(', ')}`)
125
- if (diagnostics.metadataOnly.length > 0) throw new TypeError(`Metadata-only session orphans: ${diagnostics.metadataOnly.join(', ')}`)
126
- records.sort((left, right) => left.sessionId.localeCompare(right.sessionId))
127
- // F9 v7: the canonical snapshot digest line now includes a `messagesDigest`
128
- // slot. JSON imports are always non-split (messages inline in the body), so
129
- // they contribute an empty messages digest — matching the repository digest
130
- // of freshly imported (non-split) SQLite rows.
131
- const digest = createHash('sha256').update(records.map((record) => snapshotDigestLine(record.scope, record.projectId, record.sessionId, record.stateDigest, record.metadataDigest, '')).join('\n')).digest('hex')
132
- return { records, count: records.length, digest, diagnostics }
133
- }
134
-
135
- async function writeCutoverBackup(snapshot, directory = path.join(storageDir, 'backups')) {
136
- await fs.mkdir(directory, { recursive: true })
137
- const stamp = new Date().toISOString().replace(/[:.]/g, '-')
138
- const finalPath = path.join(directory, `quickforge-session-state-cutover-${stamp}.json`)
139
- const temporaryPath = `${finalPath}.${process.pid}.${randomUUID()}.tmp`
140
- const backup = {
141
- app: 'quickforge',
142
- version: 1,
143
- exportedAt: new Date().toISOString(),
144
- scope: 'sessions',
145
- includeSecrets: false,
146
- sessionState: { count: snapshot.count, digest: snapshot.digest },
147
- data: {
148
- sessions: Object.fromEntries(snapshot.records.map((record) => [record.sessionId, record.state])),
149
- sessionsMetadata: Object.fromEntries(snapshot.records.map((record) => [record.sessionId, record.metadata])),
150
- },
151
- }
152
- try {
153
- await fs.writeFile(temporaryPath, `${JSON.stringify(backup, null, 2)}\n`, 'utf8')
154
- const verified = JSON.parse(await fs.readFile(temporaryPath, 'utf8'))
155
- const grouped = new Map()
156
- for (const [sessionId, state] of Object.entries(verified.data.sessions)) {
157
- const scope = state?.scope === 'project' ? 'project' : 'global'
158
- const projectId = scope === 'project' ? state?.projectId : null
159
- const key = scope === 'project' ? `project:${projectId}` : 'global'
160
- if (!grouped.has(key)) grouped.set(key, { scope, projectId, sessions: {}, metadata: {} })
161
- grouped.get(key).sessions[sessionId] = state
162
- if (Object.prototype.hasOwnProperty.call(verified.data.sessionsMetadata, sessionId)) {
163
- grouped.get(key).metadata[sessionId] = verified.data.sessionsMetadata[sessionId]
164
- }
165
- }
166
- for (const [sessionId, metadata] of Object.entries(verified.data.sessionsMetadata)) {
167
- if (!Object.prototype.hasOwnProperty.call(verified.data.sessions, sessionId)) {
168
- const scope = metadata?.scope === 'project' ? 'project' : 'global'
169
- const projectId = scope === 'project' ? metadata?.projectId : null
170
- const key = scope === 'project' ? `project:${projectId}` : 'global'
171
- if (!grouped.has(key)) grouped.set(key, { scope, projectId, sessions: {}, metadata: {} })
172
- grouped.get(key).metadata[sessionId] = metadata
173
- }
174
- }
175
- const verifiedSnapshot = buildSessionJsonSnapshot([...grouped.values()])
176
- if (verified.sessionState?.count !== snapshot.count || verified.sessionState?.digest !== snapshot.digest || verifiedSnapshot.count !== snapshot.count || verifiedSnapshot.digest !== snapshot.digest) {
177
- throw new Error('Session cutover backup verification failed')
178
- }
179
- await fs.rename(temporaryPath, finalPath)
180
- return finalPath
181
- } catch (error) {
182
- await fs.rm(temporaryPath, { force: true }).catch(() => {})
183
- throw error
184
- }
185
- }
186
-
187
- function ownerPid(row) {
188
- const value = Number(row?.owner_pid)
189
- return Number.isInteger(value) && value > 0 ? value : null
190
- }
191
-
192
- function pidAlive(pid) {
193
- try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
194
- }
195
-
196
- export function acquireSessionStateMaintenanceLock(storage, options = {}) {
197
- const nowMs = Number(options.now?.() ?? Date.now())
198
- const acquiredAt = new Date(nowMs).toISOString()
199
- const expiresAt = new Date(nowMs + (options.ttlMs ?? DEFAULT_LOCK_TTL_MS)).toISOString()
200
- const identity = isPlainObject(options.owner)
201
- ? { id: options.owner.id || `${options.owner.pid || process.pid}:${randomUUID()}`, pid: options.owner.pid || process.pid }
202
- : { id: options.owner || `${process.pid}:${randomUUID()}`, pid: process.pid }
203
- const isPidAlive = options.pidAlive || pidAlive
204
- return storage.transaction((database) => {
205
- const current = database.prepare('SELECT * FROM session_state_maintenance_lock WHERE singleton = 1').get()
206
- if (current && current.owner === identity.id) {
207
- database.prepare(`UPDATE session_state_maintenance_lock SET owner_pid = ?, operation = ?, heartbeat_at = ?, expires_at = ?
208
- WHERE singleton = 1 AND owner = ? AND fencing = ?`)
209
- .run(identity.pid, options.operation || current.operation, acquiredAt, expiresAt, identity.id, current.fencing)
210
- return { owner: identity.id, ownerPid: identity.pid, fencing: Number(current.fencing), expiresAt }
211
- }
212
- if (current) {
213
- const expired = Date.parse(current.expires_at) <= nowMs
214
- const pid = ownerPid(current)
215
- if (!expired || pid === null || isPidAlive(pid)) return null
216
- }
217
- const fencing = Number(current?.fencing || 0) + 1
218
- database.prepare(`INSERT INTO session_state_maintenance_lock
219
- (singleton, owner, owner_pid, fencing, operation, acquired_at, heartbeat_at, expires_at)
220
- VALUES (1, ?, ?, ?, ?, ?, ?, ?)
221
- ON CONFLICT(singleton) DO UPDATE SET owner=excluded.owner, owner_pid=excluded.owner_pid, fencing=excluded.fencing,
222
- operation=excluded.operation, acquired_at=excluded.acquired_at, heartbeat_at=excluded.heartbeat_at, expires_at=excluded.expires_at`)
223
- .run(identity.id, identity.pid, fencing, options.operation || 'session-state-maintenance', acquiredAt, acquiredAt, expiresAt)
224
- return { owner: identity.id, ownerPid: identity.pid, fencing, expiresAt }
225
- }, { mode: 'immediate' })
226
- }
227
-
228
- export function renewSessionStateMaintenanceLock(storage, lease, options = {}) {
229
- const nowMs = Number(options.now?.() ?? Date.now())
230
- const heartbeatAt = new Date(nowMs).toISOString()
231
- const expiresAt = new Date(nowMs + (options.ttlMs ?? DEFAULT_LOCK_TTL_MS)).toISOString()
232
- const result = storage.prepare(`UPDATE session_state_maintenance_lock SET heartbeat_at = ?, expires_at = ? WHERE singleton = 1 AND owner = ? AND fencing = ?`)
233
- .run(heartbeatAt, expiresAt, lease.owner, lease.fencing)
234
- lease.expiresAt = expiresAt
235
- return Number(result.changes) === 1
236
- }
237
-
238
- export function releaseSessionStateMaintenanceLock(storage, lease) {
239
- return Number(storage.prepare('DELETE FROM session_state_maintenance_lock WHERE singleton = 1 AND owner = ? AND fencing = ?').run(lease.owner, lease.fencing).changes) === 1
240
- }
241
-
242
- async function waitForLock(storage, options) {
243
- const deadline = Date.now() + (options.waitTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS)
244
- while (Date.now() < deadline) {
245
- const lease = acquireSessionStateMaintenanceLock(storage, options)
246
- if (lease) return lease
247
- await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs ?? 100))
248
- }
249
- throw new Error('Timed out waiting for session state maintenance lock')
250
- }
251
-
252
- export async function runSessionStateMaintenance(operation, options = {}) {
253
- const existing = maintenanceContext.getStore()
254
- if (existing) return operation(existing)
255
- const storage = options.storage || getSqliteStorage()
256
- const lease = await waitForLock(storage, options)
257
- const interval = options.heartbeatIntervalMs ?? Math.max(10, Math.floor((options.ttlMs ?? DEFAULT_LOCK_TTL_MS) / 3))
258
- const timer = setInterval(() => {
259
- try {
260
- if (!renewSessionStateMaintenanceLock(storage, lease, options)) throw new Error('Session state maintenance lock fencing was lost')
261
- } catch (error) {
262
- heartbeatError = error
263
- rejectHeartbeat(error)
264
- }
265
- }, interval)
266
- timer.unref?.()
267
- let rejectHeartbeat
268
- let heartbeatError = null
269
- const heartbeatFailure = new Promise((_, reject) => { rejectHeartbeat = reject })
270
- maintenanceCount += 1
271
- try {
272
- const context = { storage, lease }
273
- const result = await Promise.race([maintenanceContext.run(context, () => Promise.resolve().then(() => operation(context))), heartbeatFailure])
274
- if (heartbeatError) throw heartbeatError
275
- return result
276
- } finally {
277
- clearInterval(timer)
278
- releaseSessionStateMaintenanceLock(storage, lease)
279
- maintenanceCount = Math.max(0, maintenanceCount - 1)
280
- }
281
- }
282
-
283
- export function isSessionStateMaintenanceActive(storage) {
284
- if (!storage) {
285
- try { storage = getSqliteStorage() } catch { return false }
286
- }
287
- return maintenanceCount > 0 || Boolean(storage.prepare('SELECT 1 AS active FROM session_state_maintenance_lock WHERE singleton = 1').get())
288
- }
289
-
290
- export function currentSessionStateMaintenanceContext() {
291
- return maintenanceContext.getStore() || null
292
- }
293
-
294
- function mirrorAdapter() {
295
- async function materialize(entry) {
296
- await materializeSessionJsonMirrorEntry(entry)
297
- }
298
- return { upsert: materialize, delete: materialize }
299
- }
300
-
301
- export async function initializeSessionStateCutover(options = {}) {
302
- const storage = options.storage || getSqliteStorage()
303
- const repository = options.repository || createSessionStateRepository(storage)
304
- configureSessionStateService({ repository, mirror: options.mirror || mirrorAdapter() })
305
- return runSessionStateMaintenance(async () => {
306
- const current = readSessionStorageState()
307
- if (current.phase === SESSION_STORAGE_PHASES.JSON_PENDING) {
308
- const integrity = repository.verifyIntegrity({ quickCheck: true })
309
- if (!integrity.ok) throw new Error('Session state pending integrity verification failed')
310
- const drained = await drainSessionJsonMirror()
311
- if (drained.pending === 0) setSessionStoragePhase(SESSION_STORAGE_PHASES.AUTHORITATIVE, { stateCount: integrity.count, digest: integrity.digest, backupFile: current.backupFile })
312
- return readSessionStorageState()
313
- }
314
- if (current.phase === SESSION_STORAGE_PHASES.AUTHORITATIVE) {
315
- const integrity = repository.verifyIntegrity({ quickCheck: true })
316
- if (!integrity.ok) throw new Error('Session state authoritative integrity verification failed')
317
- await drainSessionJsonMirror()
318
- return readSessionStorageState()
319
- }
320
-
321
- if (current.phase === SESSION_STORAGE_PHASES.CUTOVER_RUNNING && current.backupFile) {
322
- setSessionStoragePhase(SESSION_STORAGE_PHASES.JSON_AUTHORITATIVE, {
323
- backupFile: current.backupFile,
324
- diagnostic: { operation: 'cutover_recovery', recoveredFrom: SESSION_STORAGE_PHASES.CUTOVER_RUNNING },
325
- })
326
- }
327
-
328
- let backupFile = current.backupFile
329
- try {
330
- const first = buildSessionJsonSnapshot(await (options.readBuckets || readPhysicalSessionStateBuckets)())
331
- const second = buildSessionJsonSnapshot(await (options.readBuckets || readPhysicalSessionStateBuckets)())
332
- if (first.count !== second.count || first.digest !== second.digest) throw new Error('Session JSON source changed during cutover double read')
333
- if (!backupFile) backupFile = await writeCutoverBackup(first, options.backupDirectory)
334
- const third = buildSessionJsonSnapshot(await (options.readBuckets || readPhysicalSessionStateBuckets)())
335
- if (first.count !== third.count || first.digest !== third.digest) throw new Error('Session JSON source changed before cutover commit')
336
- setSessionStoragePhase(SESSION_STORAGE_PHASES.CUTOVER_RUNNING, {
337
- stateCount: first.count,
338
- digest: first.digest,
339
- backupFile,
340
- diagnostic: first.diagnostics,
341
- })
342
- const pendingValues = {
343
- phase: SESSION_STORAGE_PHASES.JSON_PENDING,
344
- backupFile,
345
- diagnostic: first.diagnostics,
346
- }
347
- repository.replaceAll(first.records, {
348
- expectedCount: first.count,
349
- expectedDigest: first.digest,
350
- storageState: pendingValues,
351
- })
352
- const integrity = repository.verifyIntegrity({ quickCheck: true })
353
- if (!integrity.ok || integrity.count !== first.count || integrity.digest !== first.digest) throw new Error('Session SQLite replace verification failed')
354
- readSessionStorageState()
355
- const drained = await drainSessionJsonMirror()
356
- if (drained.pending === 0) setSessionStoragePhase(SESSION_STORAGE_PHASES.AUTHORITATIVE, { stateCount: integrity.count, digest: integrity.digest, backupFile, diagnostic: first.diagnostics })
357
- return readSessionStorageState()
358
- } catch (error) {
359
- const state = readSessionStorageState()
360
- if (![SESSION_STORAGE_PHASES.JSON_PENDING, SESSION_STORAGE_PHASES.AUTHORITATIVE].includes(state.phase)) {
361
- setSessionStoragePhase(SESSION_STORAGE_PHASES.JSON_AUTHORITATIVE, {
362
- backupFile,
363
- diagnostic: { operation: 'cutover', errorName: error?.name || 'Error', error: error?.message || String(error) },
364
- })
365
- return readSessionStorageState()
366
- }
367
- throw error
368
- }
369
- }, { ...options, storage, operation: 'session-state-cutover' })
370
- }