@shawnstack/quickforge 1.7.8 → 1.7.10
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/bin/quickforge.mjs +10 -1
- package/dist/assets/{AgentProfilesPage-BSM5w3bn.js → AgentProfilesPage-Dw9orPNt.js} +1 -1
- package/dist/assets/{ChatPanelHost-cd80S4EX.js → ChatPanelHost-CW3-MQyL.js} +2 -2
- package/dist/assets/{CloudAccountSettingsPage-BJMrfgy9.js → CloudAccountSettingsPage-CDkKN1Sz.js} +1 -1
- package/dist/assets/{PluginsPage-mJkTmVVV.js → PluginsPage-Dj1bmsEf.js} +1 -1
- package/dist/assets/{ScheduledTasksPage-DJj_NLm9.js → ScheduledTasksPage-C9lOTi75.js} +1 -1
- package/dist/assets/{SettingsWorkspacePage-7lQ0Wl63.js → SettingsWorkspacePage-CFuvSATi.js} +3 -3
- package/dist/assets/{ShareLinksSettingsPage-DjFq_Lnb.js → ShareLinksSettingsPage-CH_t33A3.js} +1 -1
- package/dist/assets/{SharedConversationPage-BbuNpcpP.js → SharedConversationPage-DLo05j7c.js} +1 -1
- package/dist/assets/{TerminalDock-CczcCJJE.js → TerminalDock-CSOVub-m.js} +1 -1
- package/dist/assets/{WorkspaceInspector-0i6x-diB.js → WorkspaceInspector-DPXgLIum.js} +1 -1
- package/dist/assets/index-DQBOm9gt.js +66 -0
- package/dist/assets/index-ps85guTs.css +3 -0
- package/dist/assets/{local-tools-Dn1Y9aPe.js → local-tools-BxfQeIUQ.js} +1 -1
- package/dist/assets/{mcp-servers-dialog-BG4b67ea.js → mcp-servers-dialog-CwHp0K4q.js} +1 -1
- package/dist/assets/{skills-dialog-DIzZvQqW.js → skills-dialog-k9ycekIU.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/server/agent-manager.mjs +68 -2
- package/server/auto-compaction.mjs +0 -2
- package/server/context-usage.mjs +9 -7
- package/server/maintenance/downgrade-session-state-v1.mjs +4 -2
- package/server/maintenance/export-session-state-v1.mjs +4 -2
- package/server/public-api.mjs +21 -2
- package/server/routes/backup.mjs +7 -0
- package/server/routes/storage.mjs +23 -0
- package/server/session-persistence-lock.mjs +17 -6
- package/server/session-state-backup.mjs +4 -2
- package/server/session-state-cutover.mjs +284 -91
- package/server/session-state-service.mjs +38 -17
- package/server/sqlite/session-state-repository.mjs +145 -25
- package/server/storage.mjs +28 -0
- package/server/utils/process-tree.mjs +14 -2
- package/dist/assets/index-D-J_8Smf.css +0 -3
- package/dist/assets/index-DFASc15l.js +0 -66
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto'
|
|
3
|
-
import { promises as fs } from 'node:fs'
|
|
3
|
+
import { createReadStream, createWriteStream, promises as fs } from 'node:fs'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { getSqliteStorage } from './sqlite/database.mjs'
|
|
6
6
|
import { createSessionStateRepository, snapshotDigestLine } from './sqlite/session-state-repository.mjs'
|
|
@@ -12,8 +12,8 @@ import {
|
|
|
12
12
|
setSessionStoragePhase,
|
|
13
13
|
} from './session-state-service.mjs'
|
|
14
14
|
import {
|
|
15
|
+
createPhysicalSessionStateFsAdapter,
|
|
15
16
|
materializeSessionJsonMirrorEntry,
|
|
16
|
-
readPhysicalSessionStateBuckets,
|
|
17
17
|
storageDir,
|
|
18
18
|
} from './storage.mjs'
|
|
19
19
|
|
|
@@ -68,10 +68,57 @@ function deriveMetadata(sessionId, state, bucket) {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// Per-session normalization shared verbatim by the materialized snapshot
|
|
72
|
+
// (buildSessionJsonSnapshot) and the streaming cutover source below —
|
|
73
|
+
// validation, shaping and diagnostics MUST stay identical between the two
|
|
74
|
+
// paths; the snapshot tests are the parity anchor. Returns null for
|
|
75
|
+
// metadata-only orphans (diagnostics already recorded; orphans are excluded
|
|
76
|
+
// from records, backups and the SQLite import).
|
|
77
|
+
function normalizeSessionEntry(bucket, sessionId, rawState, rawMetadata, diagnostics) {
|
|
78
|
+
if (!rawState && rawMetadata) {
|
|
79
|
+
diagnostics.metadataOnly.push(sessionId)
|
|
80
|
+
diagnostics.orphanDeletes.push({ scope: bucket.scope, projectId: bucket.projectId, sessionId })
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
if (!isPlainObject(rawState)) throw new TypeError(`Invalid session state: ${sessionId}`)
|
|
84
|
+
if (!Array.isArray(rawState.messages)) throw new TypeError(`Session messages must be an array: ${sessionId}`)
|
|
85
|
+
if (rawState.id !== undefined && rawState.id !== sessionId) throw new TypeError(`Session body id mismatch: ${sessionId}`)
|
|
86
|
+
if (rawState.scope !== undefined && rawState.scope !== bucket.scope) throw new TypeError(`Session body scope mismatch: ${sessionId}`)
|
|
87
|
+
if (bucket.scope === 'project' && rawState.projectId !== undefined && rawState.projectId !== bucket.projectId) throw new TypeError(`Session body project mismatch: ${sessionId}`)
|
|
88
|
+
if (bucket.scope === 'global' && rawState.projectId !== undefined && rawState.projectId !== null) throw new TypeError(`Global session body project mismatch: ${sessionId}`)
|
|
89
|
+
let metadata
|
|
90
|
+
if (rawMetadata === undefined) {
|
|
91
|
+
diagnostics.bodyOnly.push(sessionId)
|
|
92
|
+
metadata = deriveMetadata(sessionId, rawState, bucket)
|
|
93
|
+
} else {
|
|
94
|
+
if (!isPlainObject(rawMetadata)) throw new TypeError(`Invalid session metadata: ${sessionId}`)
|
|
95
|
+
if (rawMetadata.id !== undefined && rawMetadata.id !== sessionId) throw new TypeError(`Session metadata id mismatch: ${sessionId}`)
|
|
96
|
+
if (rawMetadata.scope !== undefined && rawMetadata.scope !== bucket.scope) throw new TypeError(`Session metadata scope mismatch: ${sessionId}`)
|
|
97
|
+
if (bucket.scope === 'project' && rawMetadata.projectId !== undefined && rawMetadata.projectId !== bucket.projectId) throw new TypeError(`Session metadata project mismatch: ${sessionId}`)
|
|
98
|
+
if (bucket.scope === 'global' && rawMetadata.projectId !== undefined && rawMetadata.projectId !== null) throw new TypeError(`Global session metadata project mismatch: ${sessionId}`)
|
|
99
|
+
metadata = structuredClone(rawMetadata)
|
|
100
|
+
}
|
|
101
|
+
const stateVersion = rawState.stateVersion ?? metadata.stateVersion ?? 0
|
|
102
|
+
if (!Number.isInteger(stateVersion) || stateVersion < 0) throw new TypeError(`Invalid session stateVersion: ${sessionId}`)
|
|
103
|
+
const state = { ...structuredClone(rawState), id: sessionId, scope: bucket.scope, stateVersion }
|
|
104
|
+
metadata = { ...metadata, id: sessionId, scope: bucket.scope, stateVersion }
|
|
105
|
+
if (bucket.scope === 'project') {
|
|
106
|
+
state.projectId = bucket.projectId
|
|
107
|
+
metadata.projectId = bucket.projectId
|
|
108
|
+
} else {
|
|
109
|
+
delete state.projectId
|
|
110
|
+
delete metadata.projectId
|
|
111
|
+
}
|
|
112
|
+
for (const field of ['pinnedAt', 'archivedAt']) {
|
|
113
|
+
if (metadata[field] !== undefined) state[field] = metadata[field]
|
|
114
|
+
}
|
|
115
|
+
return { ...bucket, sessionId, stateVersion, state, metadata, stateDigest: digestJson(state), metadataDigest: digestJson(metadata) }
|
|
116
|
+
}
|
|
117
|
+
|
|
71
118
|
export function buildSessionJsonSnapshot(buckets) {
|
|
72
119
|
if (!Array.isArray(buckets)) throw new TypeError('Session buckets must be an array')
|
|
73
120
|
const records = []
|
|
74
|
-
const diagnostics = { bodyOnly: [], metadataOnly: [], duplicateSessionIds: [] }
|
|
121
|
+
const diagnostics = { bodyOnly: [], metadataOnly: [], orphanDeletes: [], duplicateSessionIds: [] }
|
|
75
122
|
const seen = new Set()
|
|
76
123
|
for (const rawBucket of buckets) {
|
|
77
124
|
const bucket = normalizeBucket(rawBucket)
|
|
@@ -80,49 +127,19 @@ export function buildSessionJsonSnapshot(buckets) {
|
|
|
80
127
|
for (const sessionId of ids) {
|
|
81
128
|
if (seen.has(sessionId)) diagnostics.duplicateSessionIds.push(sessionId)
|
|
82
129
|
seen.add(sessionId)
|
|
83
|
-
const
|
|
84
|
-
|
|
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) })
|
|
130
|
+
const record = normalizeSessionEntry(bucket, sessionId, rawBucket.sessions[sessionId], rawBucket.metadata[sessionId], diagnostics)
|
|
131
|
+
if (record) records.push(record)
|
|
122
132
|
}
|
|
123
133
|
}
|
|
124
134
|
if (diagnostics.duplicateSessionIds.length > 0) throw new TypeError(`Duplicate session ids across buckets: ${[...new Set(diagnostics.duplicateSessionIds)].join(', ')}`)
|
|
125
|
-
|
|
135
|
+
// Startup cutover holds the maintenance lock with no concurrent writes, so
|
|
136
|
+
// metadata-only orphans are stale residue of already-deleted sessions: they
|
|
137
|
+
// are dropped above (excluded from records) and recorded in diagnostics for
|
|
138
|
+
// audit instead of blocking the migration. Orphans ride the mirror delete
|
|
139
|
+
// queue (`orphanDeletes` passed to replaceAll as `mirrorDeletes`) so the
|
|
140
|
+
// drain physically clears their leftover JSON metadata; otherwise
|
|
141
|
+
// initializeSessionIndex could re-import that residue on a later boot and
|
|
142
|
+
// the orphaned index rows would fail the next startup integrity check.
|
|
126
143
|
records.sort((left, right) => left.sessionId.localeCompare(right.sessionId))
|
|
127
144
|
// F9 v7: the canonical snapshot digest line now includes a `messagesDigest`
|
|
128
145
|
// slot. JSON imports are always non-split (messages inline in the body), so
|
|
@@ -132,48 +149,176 @@ export function buildSessionJsonSnapshot(buckets) {
|
|
|
132
149
|
return { records, count: records.length, digest, diagnostics }
|
|
133
150
|
}
|
|
134
151
|
|
|
135
|
-
|
|
152
|
+
// Streaming cutover source: each pass consumes the fsAdapter lazily — one
|
|
153
|
+
// bucket listing, one metadata bucket parse and one session file parse at a
|
|
154
|
+
// time — so a multi-GB library never needs to be fully resident. Only the
|
|
155
|
+
// small per-pass summary (digest lines + diagnostics) survives a full
|
|
156
|
+
// iteration. Records are normalized by the shared normalizeSessionEntry, and
|
|
157
|
+
// the summary digest is computed from digest lines sorted by sessionId, which
|
|
158
|
+
// matches buildSessionJsonSnapshot's snapshot digest exactly. Bucket-local
|
|
159
|
+
// iteration order is sorted session ids; duplicate/orphan detection is
|
|
160
|
+
// order-independent. Returns a factory: each call produces one independent
|
|
161
|
+
// { iterate, getSummary } pass over the source.
|
|
162
|
+
export function createStreamingSessionSource(fsAdapter) {
|
|
163
|
+
return () => {
|
|
164
|
+
const digestEntries = []
|
|
165
|
+
const diagnostics = { bodyOnly: [], metadataOnly: [], orphanDeletes: [], duplicateSessionIds: [] }
|
|
166
|
+
const seen = new Set()
|
|
167
|
+
let count = 0
|
|
168
|
+
let completed = false
|
|
169
|
+
const iterate = async function* streamSessionRecords() {
|
|
170
|
+
for await (const rawBucket of fsAdapter.listBuckets()) {
|
|
171
|
+
const bucket = normalizeBucket(rawBucket)
|
|
172
|
+
const metadata = await fsAdapter.readMetadataBucket(rawBucket)
|
|
173
|
+
if (!isPlainObject(metadata)) throw new TypeError('Session bucket stores must be objects')
|
|
174
|
+
const fileIds = new Set()
|
|
175
|
+
for await (const sessionId of fsAdapter.listSessionFiles(rawBucket)) fileIds.add(sessionId)
|
|
176
|
+
const ids = [...new Set([...fileIds, ...Object.keys(metadata)])].sort((left, right) => left.localeCompare(right))
|
|
177
|
+
for (const sessionId of ids) {
|
|
178
|
+
if (seen.has(sessionId)) diagnostics.duplicateSessionIds.push(sessionId)
|
|
179
|
+
seen.add(sessionId)
|
|
180
|
+
const rawState = fileIds.has(sessionId) ? await fsAdapter.readSessionState(rawBucket, sessionId) : undefined
|
|
181
|
+
const record = normalizeSessionEntry(bucket, sessionId, rawState, metadata[sessionId], diagnostics)
|
|
182
|
+
if (!record) continue
|
|
183
|
+
count += 1
|
|
184
|
+
digestEntries.push({ sessionId, line: snapshotDigestLine(record.scope, record.projectId, record.sessionId, record.stateDigest, record.metadataDigest, '') })
|
|
185
|
+
yield record
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
completed = true
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
iterate,
|
|
192
|
+
getSummary() {
|
|
193
|
+
if (!completed) throw new Error('Session source summary is not ready before the iteration completes')
|
|
194
|
+
if (diagnostics.duplicateSessionIds.length > 0) throw new TypeError(`Duplicate session ids across buckets: ${[...new Set(diagnostics.duplicateSessionIds)].join(', ')}`)
|
|
195
|
+
digestEntries.sort((left, right) => left.sessionId.localeCompare(right.sessionId))
|
|
196
|
+
const digest = createHash('sha256').update(digestEntries.map((entry) => entry.line).join('\n')).digest('hex')
|
|
197
|
+
return { count, digest, diagnostics }
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// readBuckets keeps the historical injection path for tests and small data
|
|
204
|
+
// sets: each pass calls readBuckets() exactly once, materializes one full
|
|
205
|
+
// snapshot (buildSessionJsonSnapshot) and replays its records. The summary is
|
|
206
|
+
// structurally identical to the streaming source, so the cutover flow below
|
|
207
|
+
// is agnostic to which source factory it was given.
|
|
208
|
+
function createReadBucketsSessionSource(readBuckets) {
|
|
209
|
+
return () => {
|
|
210
|
+
let snapshot = null
|
|
211
|
+
const iterate = async function* snapshotSessionRecords() {
|
|
212
|
+
snapshot = buildSessionJsonSnapshot(await readBuckets())
|
|
213
|
+
yield* snapshot.records
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
iterate,
|
|
217
|
+
getSummary() {
|
|
218
|
+
if (!snapshot) throw new Error('Session source summary is not ready before the iteration completes')
|
|
219
|
+
return { count: snapshot.count, digest: snapshot.digest, diagnostics: snapshot.diagnostics }
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function summarizeSessionSource(source) {
|
|
226
|
+
let consumed = 0
|
|
227
|
+
for await (const _record of source.iterate()) consumed += 1
|
|
228
|
+
const summary = source.getSummary()
|
|
229
|
+
if (summary.count !== consumed) throw new Error('Session source summary count mismatch')
|
|
230
|
+
return summary
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function sameSessionSourceSummary(left, right) {
|
|
234
|
+
return left.count === right.count && left.digest === right.digest
|
|
235
|
+
&& JSON.stringify(left.diagnostics) === JSON.stringify(right.diagnostics)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Streaming v1 backup writer: the output JSON shape matches the old
|
|
239
|
+
// materialized writer exactly ({app, version, exportedAt, scope,
|
|
240
|
+
// includeSecrets, sessionState, data:{sessions, sessionsMetadata}}), but
|
|
241
|
+
// states are streamed record-by-record so the library never lives in memory
|
|
242
|
+
// as a whole. The sessions section is written while iterating; metadata JSON
|
|
243
|
+
// strings (small per-session summaries) are buffered for the second section —
|
|
244
|
+
// states themselves are never buffered. Verification is byte-level: the
|
|
245
|
+
// write-side sha256/byte counter is re-checked against a chunked
|
|
246
|
+
// createReadStream re-read plus a first/last byte sanity check, replacing the
|
|
247
|
+
// old "read whole file + re-parse + re-snapshot" verification. The streamed
|
|
248
|
+
// records' digest is also re-accumulated and compared against `summary`, so a
|
|
249
|
+
// source that changed since the double read fails closed here.
|
|
250
|
+
async function writeCutoverBackupStream(createIteration, summary, options = {}) {
|
|
251
|
+
const directory = options.directory || path.join(storageDir, 'backups')
|
|
136
252
|
await fs.mkdir(directory, { recursive: true })
|
|
137
253
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
138
254
|
const finalPath = path.join(directory, `quickforge-session-state-cutover-${stamp}.json`)
|
|
139
255
|
const temporaryPath = `${finalPath}.${process.pid}.${randomUUID()}.tmp`
|
|
140
|
-
const
|
|
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
|
-
}
|
|
256
|
+
const openWriteStream = options.createWriteStream || createWriteStream
|
|
152
257
|
try {
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
258
|
+
const stream = openWriteStream(temporaryPath, { encoding: 'utf8' })
|
|
259
|
+
const failures = []
|
|
260
|
+
stream.on('error', (error) => { failures.push(error) })
|
|
261
|
+
const written = createHash('sha256')
|
|
262
|
+
let bytes = 0
|
|
263
|
+
const write = async (chunk) => {
|
|
264
|
+
written.update(chunk)
|
|
265
|
+
bytes += Buffer.byteLength(chunk, 'utf8')
|
|
266
|
+
if (stream.write(chunk, 'utf8')) {
|
|
267
|
+
if (failures.length > 0) throw failures[0]
|
|
268
|
+
return
|
|
164
269
|
}
|
|
270
|
+
await new Promise((resolve, reject) => {
|
|
271
|
+
const onDrain = () => { cleanup(); resolve() }
|
|
272
|
+
const onError = (error) => { cleanup(); reject(error) }
|
|
273
|
+
const cleanup = () => { stream.off('drain', onDrain); stream.off('error', onError) }
|
|
274
|
+
stream.once('drain', onDrain)
|
|
275
|
+
stream.once('error', onError)
|
|
276
|
+
})
|
|
165
277
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
278
|
+
await write(`{\n "app": "quickforge",\n "version": 1,\n "exportedAt": ${JSON.stringify(new Date().toISOString())},\n "scope": "sessions",\n "includeSecrets": false,\n "sessionState": {\n "count": ${summary.count},\n "digest": ${JSON.stringify(summary.digest)}\n },\n "data": {\n "sessions": {`)
|
|
279
|
+
const digestEntries = []
|
|
280
|
+
let count = 0
|
|
281
|
+
let firstSession = true
|
|
282
|
+
const metadataChunks = []
|
|
283
|
+
for await (const record of createIteration()) {
|
|
284
|
+
digestEntries.push({ sessionId: record.sessionId, line: snapshotDigestLine(record.scope, record.projectId, record.sessionId, record.stateDigest, record.metadataDigest, '') })
|
|
285
|
+
count += 1
|
|
286
|
+
const prefix = firstSession ? '' : ','
|
|
287
|
+
await write(`${prefix}\n ${JSON.stringify(record.sessionId)}: ${JSON.stringify(record.state)}`)
|
|
288
|
+
metadataChunks.push(`${prefix}\n ${JSON.stringify(record.sessionId)}: ${JSON.stringify(record.metadata)}`)
|
|
289
|
+
firstSession = false
|
|
290
|
+
}
|
|
291
|
+
await write(`\n },\n "sessionsMetadata": {`)
|
|
292
|
+
for (const metadataChunk of metadataChunks) {
|
|
293
|
+
await write(metadataChunk)
|
|
174
294
|
}
|
|
175
|
-
|
|
176
|
-
|
|
295
|
+
await write(`\n }\n }\n}`)
|
|
296
|
+
await new Promise((resolve, reject) => {
|
|
297
|
+
const onError = (error) => { cleanup(); reject(error) }
|
|
298
|
+
const cleanup = () => { stream.off('error', onError) }
|
|
299
|
+
stream.once('error', onError)
|
|
300
|
+
stream.end(() => { cleanup(); resolve() })
|
|
301
|
+
})
|
|
302
|
+
if (count !== summary.count) throw new Error('Session JSON source changed before cutover commit')
|
|
303
|
+
digestEntries.sort((left, right) => left.sessionId.localeCompare(right.sessionId))
|
|
304
|
+
const digest = createHash('sha256').update(digestEntries.map((entry) => entry.line).join('\n')).digest('hex')
|
|
305
|
+
if (digest !== summary.digest) throw new Error('Session JSON source changed before cutover commit')
|
|
306
|
+
const reread = createHash('sha256')
|
|
307
|
+
let rereadBytes = 0
|
|
308
|
+
let firstByte = -1
|
|
309
|
+
let lastByte = -1
|
|
310
|
+
await new Promise((resolve, reject) => {
|
|
311
|
+
const reader = createReadStream(temporaryPath)
|
|
312
|
+
reader.on('data', (chunk) => {
|
|
313
|
+
if (firstByte === -1) firstByte = chunk[0]
|
|
314
|
+
lastByte = chunk[chunk.length - 1]
|
|
315
|
+
reread.update(chunk)
|
|
316
|
+
rereadBytes += chunk.length
|
|
317
|
+
})
|
|
318
|
+
reader.once('error', reject)
|
|
319
|
+
reader.once('end', () => resolve())
|
|
320
|
+
})
|
|
321
|
+
if (reread.digest('hex') !== written.digest('hex') || rereadBytes !== bytes || firstByte !== 0x7b || lastByte !== 0x7d) {
|
|
177
322
|
throw new Error('Session cutover backup verification failed')
|
|
178
323
|
}
|
|
179
324
|
await fs.rename(temporaryPath, finalPath)
|
|
@@ -298,6 +443,29 @@ function mirrorAdapter() {
|
|
|
298
443
|
return { upsert: materialize, delete: materialize }
|
|
299
444
|
}
|
|
300
445
|
|
|
446
|
+
function integrityFailureSummary(integrity) {
|
|
447
|
+
const summary = Object.entries(integrity)
|
|
448
|
+
.filter(([key, value]) => !['ok', 'count', 'digest'].includes(key) && Number(value) > 0)
|
|
449
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
450
|
+
.join(', ')
|
|
451
|
+
return summary || 'unknown'
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// session_index is a pure projection of session_states, but
|
|
455
|
+
// initializeSessionIndex rebuilds it from the JSON source, which can drift
|
|
456
|
+
// from authoritative rows (e.g. orphan metadata residue re-imported after a
|
|
457
|
+
// failed mirror drain). Rebuilding the projection from the authoritative
|
|
458
|
+
// states is lossless self-healing: on integrity failure, rebuild the index
|
|
459
|
+
// and re-verify, and only fail closed if the re-check still fails.
|
|
460
|
+
function verifyIntegrityWithIndexSelfHeal(repository, phase) {
|
|
461
|
+
const first = repository.verifyIntegrity({ quickCheck: true })
|
|
462
|
+
if (first.ok) return first
|
|
463
|
+
repository.rebuildIndex()
|
|
464
|
+
const second = repository.verifyIntegrity({ quickCheck: true })
|
|
465
|
+
if (!second.ok) throw new Error(`Session state ${phase} integrity verification failed (${integrityFailureSummary(first)})`)
|
|
466
|
+
return second
|
|
467
|
+
}
|
|
468
|
+
|
|
301
469
|
export async function initializeSessionStateCutover(options = {}) {
|
|
302
470
|
const storage = options.storage || getSqliteStorage()
|
|
303
471
|
const repository = options.repository || createSessionStateRepository(storage)
|
|
@@ -305,15 +473,16 @@ export async function initializeSessionStateCutover(options = {}) {
|
|
|
305
473
|
return runSessionStateMaintenance(async () => {
|
|
306
474
|
const current = readSessionStorageState()
|
|
307
475
|
if (current.phase === SESSION_STORAGE_PHASES.JSON_PENDING) {
|
|
308
|
-
const integrity = repository
|
|
309
|
-
if (!integrity.ok) throw new Error('Session state pending integrity verification failed')
|
|
476
|
+
const integrity = verifyIntegrityWithIndexSelfHeal(repository, 'pending')
|
|
310
477
|
const drained = await drainSessionJsonMirror()
|
|
311
|
-
|
|
478
|
+
// integrity is the lightweight (SQL-level) check — its digest is null.
|
|
479
|
+
// The verified digest persisted by replaceAll's storageState stays
|
|
480
|
+
// authoritative when promoting to authoritative.
|
|
481
|
+
if (drained.pending === 0) setSessionStoragePhase(SESSION_STORAGE_PHASES.AUTHORITATIVE, { stateCount: integrity.count, digest: current.digest, backupFile: current.backupFile })
|
|
312
482
|
return readSessionStorageState()
|
|
313
483
|
}
|
|
314
484
|
if (current.phase === SESSION_STORAGE_PHASES.AUTHORITATIVE) {
|
|
315
|
-
|
|
316
|
-
if (!integrity.ok) throw new Error('Session state authoritative integrity verification failed')
|
|
485
|
+
verifyIntegrityWithIndexSelfHeal(repository, 'authoritative')
|
|
317
486
|
await drainSessionJsonMirror()
|
|
318
487
|
return readSessionStorageState()
|
|
319
488
|
}
|
|
@@ -327,12 +496,30 @@ export async function initializeSessionStateCutover(options = {}) {
|
|
|
327
496
|
|
|
328
497
|
let backupFile = current.backupFile
|
|
329
498
|
try {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
499
|
+
// Streaming cutover (step 2): the json_authoritative migration path runs
|
|
500
|
+
// FOUR independent full iterations of the JSON source — (1) summary,
|
|
501
|
+
// (2) summary re-read, (3) streaming backup write (or summary-only when
|
|
502
|
+
// a backup already exists) and (4) replaceAllStream import. Only the
|
|
503
|
+
// small summaries (digest lines + diagnostics) survive between passes,
|
|
504
|
+
// so peak memory is bounded by the largest single session instead of
|
|
505
|
+
// the whole library. options.readBuckets keeps the materialized
|
|
506
|
+
// injection path for tests; the production default streams the physical
|
|
507
|
+
// layout one file at a time via the fsAdapter.
|
|
508
|
+
const createSource = options.readBuckets
|
|
509
|
+
? createReadBucketsSessionSource(options.readBuckets)
|
|
510
|
+
: createStreamingSessionSource(options.fsAdapter || createPhysicalSessionStateFsAdapter())
|
|
511
|
+
const first = await summarizeSessionSource(createSource())
|
|
512
|
+
const second = await summarizeSessionSource(createSource())
|
|
513
|
+
if (!sameSessionSourceSummary(first, second)) throw new Error('Session JSON source changed during cutover double read')
|
|
514
|
+
if (!backupFile) {
|
|
515
|
+
backupFile = await writeCutoverBackupStream(() => createSource().iterate(), first, {
|
|
516
|
+
directory: options.backupDirectory,
|
|
517
|
+
createWriteStream: options.createBackupWriteStream,
|
|
518
|
+
})
|
|
519
|
+
} else {
|
|
520
|
+
const third = await summarizeSessionSource(createSource())
|
|
521
|
+
if (!sameSessionSourceSummary(first, third)) throw new Error('Session JSON source changed before cutover commit')
|
|
522
|
+
}
|
|
336
523
|
setSessionStoragePhase(SESSION_STORAGE_PHASES.CUTOVER_RUNNING, {
|
|
337
524
|
stateCount: first.count,
|
|
338
525
|
digest: first.digest,
|
|
@@ -344,16 +531,22 @@ export async function initializeSessionStateCutover(options = {}) {
|
|
|
344
531
|
backupFile,
|
|
345
532
|
diagnostic: first.diagnostics,
|
|
346
533
|
}
|
|
347
|
-
|
|
534
|
+
// replaceAllStream consumes the 4th source pass record-by-record inside
|
|
535
|
+
// its own immediate transaction and verifies expectedDigest there.
|
|
536
|
+
await repository.replaceAllStream(createSource().iterate(), {
|
|
348
537
|
expectedCount: first.count,
|
|
349
538
|
expectedDigest: first.digest,
|
|
350
539
|
storageState: pendingValues,
|
|
540
|
+
mirrorDeletes: first.diagnostics.orphanDeletes,
|
|
351
541
|
})
|
|
542
|
+
// replaceAll already verified expectedDigest inside its transaction, so
|
|
543
|
+
// the post-replace check re-asserts SQL-level integrity and the count
|
|
544
|
+
// only (the lightweight verification digest is null by design).
|
|
352
545
|
const integrity = repository.verifyIntegrity({ quickCheck: true })
|
|
353
|
-
if (!integrity.ok || integrity.count !== first.count
|
|
546
|
+
if (!integrity.ok || integrity.count !== first.count) throw new Error('Session SQLite replace verification failed')
|
|
354
547
|
readSessionStorageState()
|
|
355
548
|
const drained = await drainSessionJsonMirror()
|
|
356
|
-
if (drained.pending === 0) setSessionStoragePhase(SESSION_STORAGE_PHASES.AUTHORITATIVE, { stateCount: integrity.count, digest:
|
|
549
|
+
if (drained.pending === 0) setSessionStoragePhase(SESSION_STORAGE_PHASES.AUTHORITATIVE, { stateCount: integrity.count, digest: first.digest, backupFile, diagnostic: first.diagnostics })
|
|
357
550
|
return readSessionStorageState()
|
|
358
551
|
} catch (error) {
|
|
359
552
|
const state = readSessionStorageState()
|
|
@@ -13,6 +13,11 @@ export const SESSION_STORAGE_PHASES = Object.freeze({
|
|
|
13
13
|
// marker). Sessions below the threshold stay inline for backward compatibility.
|
|
14
14
|
export const MESSAGES_SPLIT_THRESHOLD = 200
|
|
15
15
|
|
|
16
|
+
// Mirror drain page size: the queue rows carry full state_json payloads, so
|
|
17
|
+
// the drain pulls a bounded batch at a time instead of loading the whole
|
|
18
|
+
// outbox (large cutover imports can enqueue thousands of entries).
|
|
19
|
+
const MIRROR_DRAIN_BATCH_LIMIT = 8
|
|
20
|
+
|
|
16
21
|
let repositoryInstance = null
|
|
17
22
|
let cachedPhase = SESSION_STORAGE_PHASES.JSON_AUTHORITATIVE
|
|
18
23
|
let jsonAdapter = null
|
|
@@ -667,25 +672,37 @@ export function replaceSessionStateSnapshot({ sessions, sessionsMetadata }, { me
|
|
|
667
672
|
export async function drainSessionJsonMirror() {
|
|
668
673
|
if (drainPromise) return drainPromise
|
|
669
674
|
drainPromise = (async () => {
|
|
670
|
-
if (!mirrorAdapter) return { drained: 0, pending: repository().
|
|
675
|
+
if (!mirrorAdapter) return { drained: 0, pending: repository().countMirrorQueue() }
|
|
671
676
|
let drained = 0
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
677
|
+
// Page through the outbox in small batches. A failed entry gets its
|
|
678
|
+
// updated_at bumped by failMirror, so it sorts behind the remaining
|
|
679
|
+
// entries and the next page drains others first; entries that keep
|
|
680
|
+
// failing are retried by the scheduled drain. A batch with zero
|
|
681
|
+
// acknowledgements stops the loop (no head-of-line livelock).
|
|
682
|
+
for (;;) {
|
|
683
|
+
const batch = repository().listMirrorQueue({ limit: MIRROR_DRAIN_BATCH_LIMIT })
|
|
684
|
+
if (batch.length === 0) break
|
|
685
|
+
let acknowledged = 0
|
|
686
|
+
for (const entry of batch) {
|
|
687
|
+
try {
|
|
688
|
+
if (entry.operation === 'upsert') {
|
|
689
|
+
const state = entry.state?.messageStorage === MESSAGES_SPLIT_VALUE
|
|
690
|
+
? { ...entry.state, messages: allMessages(entry) }
|
|
691
|
+
: entry.state
|
|
692
|
+
await mirrorAdapter.upsert({ ...entry, state })
|
|
693
|
+
} else {
|
|
694
|
+
await mirrorAdapter.delete(entry)
|
|
695
|
+
}
|
|
696
|
+
repository().acknowledgeMirror(entry)
|
|
697
|
+
drained += 1
|
|
698
|
+
acknowledged += 1
|
|
699
|
+
} catch (error) {
|
|
700
|
+
repository().failMirror(entry, error)
|
|
681
701
|
}
|
|
682
|
-
repository().acknowledgeMirror(entry)
|
|
683
|
-
drained += 1
|
|
684
|
-
} catch (error) {
|
|
685
|
-
repository().failMirror(entry, error)
|
|
686
702
|
}
|
|
703
|
+
if (acknowledged === 0) break
|
|
687
704
|
}
|
|
688
|
-
return { drained, pending: repository().
|
|
705
|
+
return { drained, pending: repository().countMirrorQueue() }
|
|
689
706
|
})().finally(() => { drainPromise = null })
|
|
690
707
|
const result = await drainPromise
|
|
691
708
|
if (result.pending > 0) scheduleSessionJsonMirrorDrain()
|
|
@@ -700,8 +717,12 @@ export function getSessionStateDiagnostics() {
|
|
|
700
717
|
let integrity
|
|
701
718
|
let mirrorPending = null
|
|
702
719
|
try {
|
|
703
|
-
|
|
704
|
-
|
|
720
|
+
// Lightweight (SQL-level) integrity: startup diagnostics on large stores
|
|
721
|
+
// must not re-parse every stored body. The result carries
|
|
722
|
+
// `lightweight: true`; full per-row verification stays on maintenance
|
|
723
|
+
// entry points (backup export/restore, downgrade tooling).
|
|
724
|
+
integrity = repository().verifyIntegrity({ quickCheck: true })
|
|
725
|
+
mirrorPending = repository().countMirrorQueue()
|
|
705
726
|
} catch (error) {
|
|
706
727
|
integrity = { ok: false, error: error?.message || String(error) }
|
|
707
728
|
}
|