@shawnstack/quickforge 1.7.7 → 1.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
- package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
- package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
- package/dist/assets/{PluginsPage-CPE5tbbf.js → PluginsPage-mJkTmVVV.js} +1 -1
- package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
- package/dist/assets/{SettingsWorkspacePage-BE2T4d3P.js → SettingsWorkspacePage-7lQ0Wl63.js} +389 -389
- package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
- package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
- package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
- package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
- package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
- package/dist/assets/{index-BiCTgUlY.css → index-D-J_8Smf.css} +1 -1
- package/dist/assets/index-DFASc15l.js +66 -0
- package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
- package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
- package/dist/assets/{mcp-servers-dialog-GY7sdfIr.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
- package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
- package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
- package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
- package/dist/assets/{skills-dialog-C1fJ1Jl7.js → skills-dialog-DIzZvQqW.js} +3 -3
- package/dist/index.html +8 -8
- package/package.json +1 -1
- package/server/acp/server.mjs +17 -1
- package/server/agent-manager.mjs +251 -31
- package/server/ai-http-logger.mjs +162 -19
- package/server/ai-provider-options.mjs +4 -1
- package/server/auto-archive.mjs +42 -29
- package/server/cloud/auto-approval.mjs +155 -0
- package/server/cloud/client.mjs +5 -0
- package/server/cloud/qf-agent-process.mjs +163 -3
- package/server/cloud/service-config.mjs +1 -1
- package/server/index.mjs +69 -13
- package/server/lan-access-backup.mjs +256 -0
- package/server/lan-access-cutover.mjs +306 -0
- package/server/lan-access-json-file.mjs +75 -0
- package/server/lan-access-service.mjs +155 -0
- package/server/lan-access-store.mjs +110 -9
- package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
- package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
- package/server/maintenance/downgrade-share-v1.mjs +101 -0
- package/server/maintenance/export-lan-access-v1.mjs +80 -0
- package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
- package/server/maintenance/export-session-state-v1.mjs +67 -0
- package/server/maintenance/export-share-v1.mjs +77 -0
- package/server/routes/agent.mjs +37 -4
- package/server/routes/backup.mjs +221 -39
- package/server/routes/cloud.mjs +28 -1
- package/server/routes/scheduled-tasks.mjs +347 -134
- package/server/routes/shares.mjs +4 -2
- package/server/routes/storage.mjs +138 -8
- package/server/routes/workspace.mjs +265 -4
- package/server/scheduled-runs-backup.mjs +235 -0
- package/server/scheduled-runs-cutover.mjs +497 -0
- package/server/scheduled-task-runs-service.mjs +279 -0
- package/server/session-index-service.mjs +495 -0
- package/server/session-state-backup.mjs +277 -0
- package/server/session-state-cutover.mjs +370 -0
- package/server/session-state-service.mjs +709 -0
- package/server/share-backup.mjs +255 -0
- package/server/share-cutover.mjs +296 -0
- package/server/share-json-file.mjs +60 -0
- package/server/share-service.mjs +163 -0
- package/server/share-store.mjs +196 -19
- package/server/skills.mjs +0 -1
- package/server/sqlite/database.mjs +233 -0
- package/server/sqlite/lan-access-repository.mjs +652 -0
- package/server/sqlite/migrations.mjs +577 -0
- package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
- package/server/sqlite/session-index-repository.mjs +297 -0
- package/server/sqlite/session-state-repository.mjs +786 -0
- package/server/sqlite/share-repository.mjs +854 -0
- package/server/storage.mjs +317 -1
- package/dist/assets/AgentProfilesPage-BfA_sQ3A.js +0 -1
- package/dist/assets/ChatPanelHost-Dul1ahKj.js +0 -307
- package/dist/assets/CloudAccountSettingsPage-qZh0lTUN.js +0 -1
- package/dist/assets/ScheduledTasksPage-PTxL_PPg.js +0 -2
- package/dist/assets/ShareLinksSettingsPage-Bl8fP62X.js +0 -1
- package/dist/assets/SharedConversationPage-BK9XJnEe.js +0 -1
- package/dist/assets/TerminalDock-CL7HU9DI.js +0 -2
- package/dist/assets/WorkspaceInspector-MNBLIKMn.js +0 -36
- package/dist/assets/index-BTPzyq7d.js +0 -65
- package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
- /package/dist/assets/{plugin-api-Uu2xAB4d.js → plugin-api-BrdQeM4B.js} +0 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { promises as fs } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { getSqliteStorage } from './sqlite/database.mjs'
|
|
5
|
+
import { createSessionStateRepository, messagesDigestFromValues, snapshotDigestLine, splitStateForStorage } from './sqlite/session-state-repository.mjs'
|
|
6
|
+
import {
|
|
7
|
+
currentSessionStateMaintenanceContext,
|
|
8
|
+
runSessionStateMaintenance,
|
|
9
|
+
} from './session-state-cutover.mjs'
|
|
10
|
+
import {
|
|
11
|
+
getSessionStoragePhase,
|
|
12
|
+
isSessionStateAuthoritative,
|
|
13
|
+
normalizeSessionSnapshotValues,
|
|
14
|
+
replaceSessionStateSnapshot,
|
|
15
|
+
} from './session-state-service.mjs'
|
|
16
|
+
import { storageDir } from './storage.mjs'
|
|
17
|
+
|
|
18
|
+
const RESTORE_PLAN_FILE = path.join(storageDir, 'session-state-restore-plan.json')
|
|
19
|
+
const ROLL_FORWARD_STATUSES = new Set(['prepared', 'applying', 'target_applied'])
|
|
20
|
+
const ROLL_BACK_STATUSES = new Set(['compensating', 'compensation_failed'])
|
|
21
|
+
|
|
22
|
+
function repositoryRequired() {
|
|
23
|
+
try {
|
|
24
|
+
return createSessionStateRepository(getSqliteStorage())
|
|
25
|
+
} catch (error) {
|
|
26
|
+
throw new Error('SQLite storage is required for authoritative session state', { cause: error })
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isPlainObject(value) {
|
|
31
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function canonicalize(value) {
|
|
35
|
+
if (Array.isArray(value)) return value.map(canonicalize)
|
|
36
|
+
if (!isPlainObject(value)) return value
|
|
37
|
+
return Object.fromEntries(Object.keys(value).sort().filter((key) => value[key] !== undefined).map((key) => [key, canonicalize(value[key])]))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function jsonDigest(value) {
|
|
41
|
+
return createHash('sha256').update(JSON.stringify(canonicalize(value))).digest('hex')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Replicates the repository snapshot digest algorithm (verificationDigest) over
|
|
45
|
+
// records that have already been normalized by normalizeSessionSnapshotValues,
|
|
46
|
+
// so the plan's target digest can be compared against the repository digest
|
|
47
|
+
// after the restore transaction commits. Split states are digested over their
|
|
48
|
+
// stored (stripped) body plus the messages aggregate, exactly like stored rows.
|
|
49
|
+
function recordsDigest(records) {
|
|
50
|
+
const rows = records.map((record) => {
|
|
51
|
+
const { storedState, messages } = splitStateForStorage(record.state)
|
|
52
|
+
return {
|
|
53
|
+
scope: record.scope,
|
|
54
|
+
project_id: record.projectId || '',
|
|
55
|
+
session_id: record.sessionId,
|
|
56
|
+
state_digest: jsonDigest(storedState),
|
|
57
|
+
metadata_digest: jsonDigest(record.metadata),
|
|
58
|
+
messages_digest: messages === undefined ? '' : messagesDigestFromValues(messages),
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
const values = rows.map((row) => snapshotDigestLine(row.scope, row.project_id, row.session_id, row.state_digest, row.metadata_digest, row.messages_digest)).sort()
|
|
62
|
+
return createHash('sha256').update(values.join('\n')).digest('hex')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function snapshotValues(snapshot) {
|
|
66
|
+
return {
|
|
67
|
+
sessions: Object.fromEntries(snapshot.records.map((record) => [record.sessionId, record.state?.messageStorage === 'split'
|
|
68
|
+
// Records from exportSnapshot carry the stripped body + a separate
|
|
69
|
+
// `messages` array; records from normalizeSessionSnapshotValues carry the
|
|
70
|
+
// already-assembled body (marker + messages inline). Keep whichever
|
|
71
|
+
// representation is present — never drop the messages.
|
|
72
|
+
? { ...record.state, messages: Array.isArray(record.state.messages) ? record.state.messages : (record.messages ?? []) }
|
|
73
|
+
: record.state])),
|
|
74
|
+
sessionsMetadata: Object.fromEntries(snapshot.records.map((record) => [record.sessionId, record.metadata])),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Authoritative session state export for the backup route. Runs under the
|
|
80
|
+
* session state maintenance lock so a concurrent restore can never be observed
|
|
81
|
+
* mid-transaction, and fail-closes when integrity or digest verification fails.
|
|
82
|
+
*/
|
|
83
|
+
export async function exportSessionStateForBackup(options = {}) {
|
|
84
|
+
const operation = async () => {
|
|
85
|
+
const repository = options.repository || repositoryRequired()
|
|
86
|
+
const integrity = repository.verifyIntegrity({ quickCheck: true })
|
|
87
|
+
if (!integrity.ok) throw new Error('Session state integrity verification failed during backup export')
|
|
88
|
+
const snapshot = repository.exportSnapshot()
|
|
89
|
+
if (snapshot.count !== integrity.count || snapshot.digest !== integrity.digest) {
|
|
90
|
+
throw new Error('Session state backup count/digest verification failed')
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
...snapshotValues(snapshot),
|
|
94
|
+
count: snapshot.count,
|
|
95
|
+
digest: snapshot.digest,
|
|
96
|
+
phase: getSessionStoragePhase(),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!options.maintenance || currentSessionStateMaintenanceContext()) return operation()
|
|
100
|
+
return runSessionStateMaintenance(operation, { storage: options.storage, operation: 'session-state-backup-export' })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function writePlan(plan, planFile = RESTORE_PLAN_FILE) {
|
|
104
|
+
await fs.mkdir(path.dirname(planFile), { recursive: true })
|
|
105
|
+
const temporary = `${planFile}.${process.pid}.${randomUUID()}.tmp`
|
|
106
|
+
try {
|
|
107
|
+
await fs.writeFile(temporary, `${JSON.stringify(plan, null, 2)}\n`, 'utf8')
|
|
108
|
+
await fs.rename(temporary, planFile)
|
|
109
|
+
} catch (error) {
|
|
110
|
+
await fs.rm(temporary, { force: true }).catch(() => {})
|
|
111
|
+
throw error
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function planValuesDigest(values) {
|
|
116
|
+
return recordsDigest(normalizeSessionSnapshotValues(values))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Public digest helper for backup tooling: returns the exact repository-style
|
|
120
|
+
// digest a restored snapshot must produce, given backup values.
|
|
121
|
+
export function computeSessionSnapshotDigest(values) {
|
|
122
|
+
return planValuesDigest(values)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function validatePlan(plan) {
|
|
126
|
+
if (
|
|
127
|
+
!plan || plan.version !== 1 || plan.operation !== 'session_state_restore' ||
|
|
128
|
+
!isPlainPlanValue(plan.before) || !isPlainPlanValue(plan.target)
|
|
129
|
+
) {
|
|
130
|
+
throw new Error('Session state restore plan is invalid')
|
|
131
|
+
}
|
|
132
|
+
if (!ROLL_FORWARD_STATUSES.has(plan.status) && !ROLL_BACK_STATUSES.has(plan.status)) {
|
|
133
|
+
throw new Error('Session state restore plan status is invalid')
|
|
134
|
+
}
|
|
135
|
+
const beforeCount = countSessions(plan.before)
|
|
136
|
+
const targetCount = countSessions(plan.target)
|
|
137
|
+
if (plan.beforeCount !== beforeCount || plan.targetCount !== targetCount) {
|
|
138
|
+
throw new Error('Session state restore plan count is invalid')
|
|
139
|
+
}
|
|
140
|
+
const beforeDigest = planValuesDigest(plan.before)
|
|
141
|
+
const targetDigest = planValuesDigest(plan.target)
|
|
142
|
+
if (plan.beforeDigest !== beforeDigest || plan.targetDigest !== targetDigest) {
|
|
143
|
+
throw new Error('Session state restore plan digest is invalid')
|
|
144
|
+
}
|
|
145
|
+
return plan
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function countSessions(values) {
|
|
149
|
+
return isPlainPlanValue(values) && values.sessions && typeof values.sessions === 'object' && !Array.isArray(values.sessions)
|
|
150
|
+
? Object.keys(values.sessions).length
|
|
151
|
+
: 0
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isPlainPlanValue(value) {
|
|
155
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Restore the authoritative session state from a backup snapshot. Always runs
|
|
160
|
+
* under the session state maintenance lock, persists a crash-recoverable plan
|
|
161
|
+
* file, and compensates back to the exact before-state if the apply or its
|
|
162
|
+
* verification fails. Only touches session_states/session_index/mirror tables;
|
|
163
|
+
* scheduled_task_runs and JSON config stores are never modified here.
|
|
164
|
+
*/
|
|
165
|
+
export async function restoreSessionStateSnapshot(values, options = {}) {
|
|
166
|
+
if (!isSessionStateAuthoritative()) {
|
|
167
|
+
throw new Error('Session state restore requires authoritative SQLite storage')
|
|
168
|
+
}
|
|
169
|
+
const repository = options.repository || repositoryRequired()
|
|
170
|
+
const mode = options.mode === 'merge' ? 'merge' : 'replace'
|
|
171
|
+
const planFile = options.planFile || RESTORE_PLAN_FILE
|
|
172
|
+
return runSessionStateMaintenance(async () => {
|
|
173
|
+
const before = repository.exportSnapshot()
|
|
174
|
+
const beforeValues = snapshotValues(before)
|
|
175
|
+
const merged = mode === 'merge'
|
|
176
|
+
? {
|
|
177
|
+
sessions: { ...beforeValues.sessions, ...values.sessions },
|
|
178
|
+
sessionsMetadata: { ...beforeValues.sessionsMetadata, ...values.sessionsMetadata },
|
|
179
|
+
}
|
|
180
|
+
: values
|
|
181
|
+
// Normalize once before writing the plan: validation failures (invalid
|
|
182
|
+
// bodies, metadata-only orphans) surface here and never leave a plan file.
|
|
183
|
+
const targetRecords = normalizeSessionSnapshotValues(merged)
|
|
184
|
+
const targetValues = snapshotValues({ records: targetRecords })
|
|
185
|
+
const beforeCount = before.count
|
|
186
|
+
const beforeDigest = before.digest
|
|
187
|
+
const targetCount = targetRecords.length
|
|
188
|
+
const targetDigest = recordsDigest(targetRecords)
|
|
189
|
+
let plan = {
|
|
190
|
+
version: 1,
|
|
191
|
+
operation: 'session_state_restore',
|
|
192
|
+
status: 'prepared',
|
|
193
|
+
createdAt: new Date().toISOString(),
|
|
194
|
+
before: beforeValues,
|
|
195
|
+
target: targetValues,
|
|
196
|
+
beforeCount,
|
|
197
|
+
beforeDigest,
|
|
198
|
+
targetCount,
|
|
199
|
+
targetDigest,
|
|
200
|
+
}
|
|
201
|
+
await writePlan(plan, planFile)
|
|
202
|
+
try {
|
|
203
|
+
plan = { ...plan, status: 'applying' }
|
|
204
|
+
await writePlan(plan, planFile)
|
|
205
|
+
replaceSessionStateSnapshot(targetValues, { merge: false })
|
|
206
|
+
const after = repository.exportSnapshot()
|
|
207
|
+
if (after.count !== targetCount || after.digest !== targetDigest) {
|
|
208
|
+
throw new Error('Session state restore count/digest verification failed')
|
|
209
|
+
}
|
|
210
|
+
const integrity = repository.verifyIntegrity()
|
|
211
|
+
if (!integrity.ok) throw new Error('Session state restore integrity verification failed')
|
|
212
|
+
plan = { ...plan, status: 'target_applied' }
|
|
213
|
+
await writePlan(plan, planFile)
|
|
214
|
+
await fs.rm(planFile, { force: true })
|
|
215
|
+
return { sessions: targetCount, sessionsMetadata: targetCount }
|
|
216
|
+
} catch (error) {
|
|
217
|
+
try {
|
|
218
|
+
plan = { ...plan, status: 'compensating', failedAt: new Date().toISOString() }
|
|
219
|
+
await writePlan(plan, planFile)
|
|
220
|
+
replaceSessionStateSnapshot(beforeValues, { merge: false })
|
|
221
|
+
const restored = repository.exportSnapshot()
|
|
222
|
+
if (restored.count !== beforeCount || restored.digest !== beforeDigest) {
|
|
223
|
+
throw new Error('Session state restore compensation verification failed', { cause: error })
|
|
224
|
+
}
|
|
225
|
+
await fs.rm(planFile, { force: true })
|
|
226
|
+
} catch (compensationError) {
|
|
227
|
+
await writePlan({
|
|
228
|
+
...plan,
|
|
229
|
+
status: 'compensation_failed',
|
|
230
|
+
compensationFailedAt: new Date().toISOString(),
|
|
231
|
+
errorName: error?.name || 'Error',
|
|
232
|
+
compensationErrorName: compensationError?.name || 'Error',
|
|
233
|
+
}, planFile).catch(() => {})
|
|
234
|
+
const retained = new Error('Session state restore failed and compensation failed', { cause: compensationError })
|
|
235
|
+
retained.retainSessionStateMaintenance = true
|
|
236
|
+
throw retained
|
|
237
|
+
}
|
|
238
|
+
throw error
|
|
239
|
+
}
|
|
240
|
+
}, { storage: options.storage, operation: 'session-state-restore' })
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Startup recovery for an interrupted restore plan: roll forward to the target
|
|
245
|
+
* for prepared/applying/target_applied plans and roll back to the before state
|
|
246
|
+
* for compensating/compensation_failed plans, then remove the plan file.
|
|
247
|
+
*/
|
|
248
|
+
export async function recoverSessionStateRestorePlan({
|
|
249
|
+
repository: repositoryOverride,
|
|
250
|
+
planFile = RESTORE_PLAN_FILE,
|
|
251
|
+
storage,
|
|
252
|
+
} = {}) {
|
|
253
|
+
let plan
|
|
254
|
+
try {
|
|
255
|
+
plan = validatePlan(JSON.parse(await fs.readFile(planFile, 'utf8')))
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (error?.code === 'ENOENT') return false
|
|
258
|
+
throw new Error(`Session state restore plan is unreadable or invalid: ${error?.message || error}`, { cause: error })
|
|
259
|
+
}
|
|
260
|
+
if (!isSessionStateAuthoritative()) {
|
|
261
|
+
throw new Error('Session state restore plan exists outside authoritative mode')
|
|
262
|
+
}
|
|
263
|
+
const repository = repositoryOverride || repositoryRequired()
|
|
264
|
+
return runSessionStateMaintenance(async () => {
|
|
265
|
+
const rollBack = ROLL_BACK_STATUSES.has(plan.status)
|
|
266
|
+
const value = rollBack ? plan.before : plan.target
|
|
267
|
+
replaceSessionStateSnapshot(value, { merge: false })
|
|
268
|
+
const after = repository.exportSnapshot()
|
|
269
|
+
if (after.count !== (rollBack ? plan.beforeCount : plan.targetCount) || after.digest !== (rollBack ? plan.beforeDigest : plan.targetDigest)) {
|
|
270
|
+
throw new Error('Session state restore plan recovery verification failed')
|
|
271
|
+
}
|
|
272
|
+
const integrity = repository.verifyIntegrity()
|
|
273
|
+
if (!integrity.ok) throw new Error('Session state restore plan recovery integrity verification failed')
|
|
274
|
+
await fs.rm(planFile, { force: true })
|
|
275
|
+
return true
|
|
276
|
+
}, { storage, operation: 'session-state-restore-recovery' })
|
|
277
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
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
|
+
}
|