@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.
Files changed (83) hide show
  1. package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
  2. package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
  3. package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
  4. package/dist/assets/{PluginsPage-CPE5tbbf.js → PluginsPage-mJkTmVVV.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-BE2T4d3P.js → SettingsWorkspacePage-7lQ0Wl63.js} +389 -389
  7. package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
  8. package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
  9. package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
  10. package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
  11. package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
  12. package/dist/assets/{index-BiCTgUlY.css → index-D-J_8Smf.css} +1 -1
  13. package/dist/assets/index-DFASc15l.js +66 -0
  14. package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
  15. package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
  16. package/dist/assets/{mcp-servers-dialog-GY7sdfIr.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
  17. package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
  18. package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
  19. package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
  20. package/dist/assets/{skills-dialog-C1fJ1Jl7.js → skills-dialog-DIzZvQqW.js} +3 -3
  21. package/dist/index.html +8 -8
  22. package/package.json +1 -1
  23. package/server/acp/server.mjs +17 -1
  24. package/server/agent-manager.mjs +251 -31
  25. package/server/ai-http-logger.mjs +162 -19
  26. package/server/ai-provider-options.mjs +4 -1
  27. package/server/auto-archive.mjs +42 -29
  28. package/server/cloud/auto-approval.mjs +155 -0
  29. package/server/cloud/client.mjs +5 -0
  30. package/server/cloud/qf-agent-process.mjs +163 -3
  31. package/server/cloud/service-config.mjs +1 -1
  32. package/server/index.mjs +69 -13
  33. package/server/lan-access-backup.mjs +256 -0
  34. package/server/lan-access-cutover.mjs +306 -0
  35. package/server/lan-access-json-file.mjs +75 -0
  36. package/server/lan-access-service.mjs +155 -0
  37. package/server/lan-access-store.mjs +110 -9
  38. package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
  39. package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
  40. package/server/maintenance/downgrade-share-v1.mjs +101 -0
  41. package/server/maintenance/export-lan-access-v1.mjs +80 -0
  42. package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
  43. package/server/maintenance/export-session-state-v1.mjs +67 -0
  44. package/server/maintenance/export-share-v1.mjs +77 -0
  45. package/server/routes/agent.mjs +37 -4
  46. package/server/routes/backup.mjs +221 -39
  47. package/server/routes/cloud.mjs +28 -1
  48. package/server/routes/scheduled-tasks.mjs +347 -134
  49. package/server/routes/shares.mjs +4 -2
  50. package/server/routes/storage.mjs +138 -8
  51. package/server/routes/workspace.mjs +265 -4
  52. package/server/scheduled-runs-backup.mjs +235 -0
  53. package/server/scheduled-runs-cutover.mjs +497 -0
  54. package/server/scheduled-task-runs-service.mjs +279 -0
  55. package/server/session-index-service.mjs +495 -0
  56. package/server/session-state-backup.mjs +277 -0
  57. package/server/session-state-cutover.mjs +370 -0
  58. package/server/session-state-service.mjs +709 -0
  59. package/server/share-backup.mjs +255 -0
  60. package/server/share-cutover.mjs +296 -0
  61. package/server/share-json-file.mjs +60 -0
  62. package/server/share-service.mjs +163 -0
  63. package/server/share-store.mjs +196 -19
  64. package/server/skills.mjs +0 -1
  65. package/server/sqlite/database.mjs +233 -0
  66. package/server/sqlite/lan-access-repository.mjs +652 -0
  67. package/server/sqlite/migrations.mjs +577 -0
  68. package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
  69. package/server/sqlite/session-index-repository.mjs +297 -0
  70. package/server/sqlite/session-state-repository.mjs +786 -0
  71. package/server/sqlite/share-repository.mjs +854 -0
  72. package/server/storage.mjs +317 -1
  73. package/dist/assets/AgentProfilesPage-BfA_sQ3A.js +0 -1
  74. package/dist/assets/ChatPanelHost-Dul1ahKj.js +0 -307
  75. package/dist/assets/CloudAccountSettingsPage-qZh0lTUN.js +0 -1
  76. package/dist/assets/ScheduledTasksPage-PTxL_PPg.js +0 -2
  77. package/dist/assets/ShareLinksSettingsPage-Bl8fP62X.js +0 -1
  78. package/dist/assets/SharedConversationPage-BK9XJnEe.js +0 -1
  79. package/dist/assets/TerminalDock-CL7HU9DI.js +0 -2
  80. package/dist/assets/WorkspaceInspector-MNBLIKMn.js +0 -36
  81. package/dist/assets/index-BTPzyq7d.js +0 -65
  82. package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
  83. /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
+ }