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