@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
|
@@ -6,14 +6,15 @@ import { createSessionStateRepository, messagesDigestFromValues, snapshotDigestL
|
|
|
6
6
|
import {
|
|
7
7
|
currentSessionStateMaintenanceContext,
|
|
8
8
|
runSessionStateMaintenance,
|
|
9
|
-
} from './session-state-
|
|
9
|
+
} from './session-state-maintenance.mjs'
|
|
10
10
|
import {
|
|
11
|
-
getSessionStoragePhase,
|
|
12
11
|
isSessionStateAuthoritative,
|
|
13
12
|
normalizeSessionSnapshotValues,
|
|
13
|
+
readSessionStorageState,
|
|
14
14
|
replaceSessionStateSnapshot,
|
|
15
15
|
} from './session-state-service.mjs'
|
|
16
16
|
import { storageDir } from './storage.mjs'
|
|
17
|
+
import { logger } from './utils/logger.mjs'
|
|
17
18
|
|
|
18
19
|
const RESTORE_PLAN_FILE = path.join(storageDir, 'session-state-restore-plan.json')
|
|
19
20
|
const ROLL_FORWARD_STATUSES = new Set(['prepared', 'applying', 'target_applied'])
|
|
@@ -86,20 +87,58 @@ export async function exportSessionStateForBackup(options = {}) {
|
|
|
86
87
|
const integrity = repository.verifyIntegrity({ quickCheck: true })
|
|
87
88
|
if (!integrity.ok) throw new Error('Session state integrity verification failed during backup export')
|
|
88
89
|
const snapshot = repository.exportSnapshot()
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
// The lightweight integrity result has no digest (null); the count
|
|
91
|
+
// cross-check plus exportSnapshot's own digest computation stay.
|
|
92
|
+
if (snapshot.count !== integrity.count) {
|
|
93
|
+
throw new Error('Session state backup count verification failed')
|
|
91
94
|
}
|
|
92
95
|
return {
|
|
93
96
|
...snapshotValues(snapshot),
|
|
94
97
|
count: snapshot.count,
|
|
95
98
|
digest: snapshot.digest,
|
|
96
|
-
phase:
|
|
99
|
+
phase: readSessionStorageState().phase,
|
|
97
100
|
}
|
|
98
101
|
}
|
|
99
102
|
if (!options.maintenance || currentSessionStateMaintenanceContext()) return operation()
|
|
100
103
|
return runSessionStateMaintenance(operation, { storage: options.storage, operation: 'session-state-backup-export' })
|
|
101
104
|
}
|
|
102
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Manual integrity verification entry point for the maintenance route
|
|
108
|
+
* (design review suggestion 9): the startup chain only runs the lightweight
|
|
109
|
+
* SQL-level check, so silent per-row digest rot would stay invisible until
|
|
110
|
+
* an offline full verification. `{ full: true }` recomputes every stored
|
|
111
|
+
* digest (quickCheck: false); the default is the lightweight check. Runs
|
|
112
|
+
* under the session state maintenance lock (same pattern as the backup
|
|
113
|
+
* export above) so the scan serializes with cutover/restore, and returns
|
|
114
|
+
* only summary counters plus the elapsed time — never row payloads.
|
|
115
|
+
*/
|
|
116
|
+
export async function verifySessionStateIntegrityForMaintenance(options = {}) {
|
|
117
|
+
const operation = async () => {
|
|
118
|
+
const repository = options.repository || repositoryRequired()
|
|
119
|
+
const startedAt = Date.now()
|
|
120
|
+
// forceQuickCheck: a manual maintenance trigger must run a real quick_check
|
|
121
|
+
// scan, never the process-cache/marker shortcut.
|
|
122
|
+
const integrity = repository.verifyIntegrity({ quickCheck: options.full !== true, forceQuickCheck: options.forceQuickCheck === true })
|
|
123
|
+
return { ...integrity, full: options.full === true, durationMs: Date.now() - startedAt }
|
|
124
|
+
}
|
|
125
|
+
if (!options.maintenance || currentSessionStateMaintenanceContext()) return operation()
|
|
126
|
+
return runSessionStateMaintenance(operation, { storage: options.storage, operation: 'session-state-verify-integrity' })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// A restore rewrites every session row inside one transaction, so the WAL
|
|
130
|
+
// grows by roughly the library size before converging; truncating right
|
|
131
|
+
// after success reclaims that space (same rationale as the post-promote
|
|
132
|
+
// checkpoint in the cutover). Best-effort: a busy or failed checkpoint
|
|
133
|
+
// leaves the WAL to a later auto-checkpoint and must never fail the restore.
|
|
134
|
+
function checkpointWalAfterRestore(repository, operation) {
|
|
135
|
+
try {
|
|
136
|
+
repository.checkpointWal()
|
|
137
|
+
} catch (error) {
|
|
138
|
+
logger.warn('Session state WAL checkpoint failed after restore', { domain: 'session-state', operation, errorName: error?.name || 'Error', errorMessage: error?.message })
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
103
142
|
async function writePlan(plan, planFile = RESTORE_PLAN_FILE) {
|
|
104
143
|
await fs.mkdir(path.dirname(planFile), { recursive: true })
|
|
105
144
|
const temporary = `${planFile}.${process.pid}.${randomUUID()}.tmp`
|
|
@@ -212,6 +251,7 @@ export async function restoreSessionStateSnapshot(values, options = {}) {
|
|
|
212
251
|
plan = { ...plan, status: 'target_applied' }
|
|
213
252
|
await writePlan(plan, planFile)
|
|
214
253
|
await fs.rm(planFile, { force: true })
|
|
254
|
+
checkpointWalAfterRestore(repository, 'session-state-restore')
|
|
215
255
|
return { sessions: targetCount, sessionsMetadata: targetCount }
|
|
216
256
|
} catch (error) {
|
|
217
257
|
try {
|
|
@@ -237,7 +277,12 @@ export async function restoreSessionStateSnapshot(values, options = {}) {
|
|
|
237
277
|
}
|
|
238
278
|
throw error
|
|
239
279
|
}
|
|
240
|
-
}, {
|
|
280
|
+
}, {
|
|
281
|
+
storage: options.storage,
|
|
282
|
+
operation: 'session-state-restore',
|
|
283
|
+
waitTimeoutMs: options.waitTimeoutMs,
|
|
284
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
285
|
+
})
|
|
241
286
|
}
|
|
242
287
|
|
|
243
288
|
/**
|
|
@@ -249,6 +294,8 @@ export async function recoverSessionStateRestorePlan({
|
|
|
249
294
|
repository: repositoryOverride,
|
|
250
295
|
planFile = RESTORE_PLAN_FILE,
|
|
251
296
|
storage,
|
|
297
|
+
waitTimeoutMs,
|
|
298
|
+
pollIntervalMs,
|
|
252
299
|
} = {}) {
|
|
253
300
|
let plan
|
|
254
301
|
try {
|
|
@@ -272,6 +319,12 @@ export async function recoverSessionStateRestorePlan({
|
|
|
272
319
|
const integrity = repository.verifyIntegrity()
|
|
273
320
|
if (!integrity.ok) throw new Error('Session state restore plan recovery integrity verification failed')
|
|
274
321
|
await fs.rm(planFile, { force: true })
|
|
322
|
+
checkpointWalAfterRestore(repository, 'session-state-restore-recovery')
|
|
275
323
|
return true
|
|
276
|
-
}, {
|
|
324
|
+
}, {
|
|
325
|
+
storage,
|
|
326
|
+
operation: 'session-state-restore-recovery',
|
|
327
|
+
waitTimeoutMs,
|
|
328
|
+
pollIntervalMs,
|
|
329
|
+
})
|
|
277
330
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { getSqliteStorage } from './sqlite/database.mjs'
|
|
2
|
+
import { createSessionStateRepository } from './sqlite/session-state-repository.mjs'
|
|
3
|
+
import { logger as defaultLogger } from './utils/logger.mjs'
|
|
4
|
+
|
|
5
|
+
function isPlainObject(value) {
|
|
6
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Bucket validation mirrors session-state-cutover.mjs's normalizeBucket: the
|
|
10
|
+
// importer walks the same physical JSON tree the cutover source did.
|
|
11
|
+
function normalizeBucket(raw) {
|
|
12
|
+
if (raw.scope === 'global') {
|
|
13
|
+
if (raw.projectId !== undefined && raw.projectId !== null && raw.projectId !== '') throw new TypeError('Global session bucket cannot have projectId')
|
|
14
|
+
return { scope: 'global', projectId: null }
|
|
15
|
+
}
|
|
16
|
+
if (raw.scope !== 'project' || typeof raw.projectId !== 'string' || !raw.projectId.trim() || raw.projectId === '.' || raw.projectId === '..' || /[\\/]/.test(raw.projectId)) {
|
|
17
|
+
throw new TypeError('Invalid session bucket')
|
|
18
|
+
}
|
|
19
|
+
return { scope: 'project', projectId: raw.projectId }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Metadata derivation for body-only files (no sessions-metadata entry) —
|
|
23
|
+
// identical shaping to the cutover's deriveMetadata.
|
|
24
|
+
function deriveMetadata(sessionId, state, bucket) {
|
|
25
|
+
const createdAt = typeof state.createdAt === 'string'
|
|
26
|
+
? state.createdAt
|
|
27
|
+
: (typeof state.lastModified === 'string' ? state.lastModified : '1970-01-01T00:00:00.000Z')
|
|
28
|
+
return {
|
|
29
|
+
id: sessionId,
|
|
30
|
+
title: typeof state.title === 'string' ? state.title : 'New chat',
|
|
31
|
+
titleSource: state.titleSource,
|
|
32
|
+
createdAt,
|
|
33
|
+
lastModified: typeof state.lastModified === 'string' ? state.lastModified : createdAt,
|
|
34
|
+
messageCount: state.messages.length,
|
|
35
|
+
stateVersion: Number.isInteger(state.stateVersion) && state.stateVersion >= 0 ? state.stateVersion : 0,
|
|
36
|
+
thinkingLevel: typeof state.thinkingLevel === 'string' ? state.thinkingLevel : 'off',
|
|
37
|
+
scope: bucket.scope,
|
|
38
|
+
...(bucket.scope === 'project' ? { projectId: bucket.projectId } : {}),
|
|
39
|
+
taskStatus: state.taskStatus || 'idle',
|
|
40
|
+
taskStartedAt: state.taskStartedAt ?? null,
|
|
41
|
+
taskFinishedAt: state.taskFinishedAt ?? null,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Per-session normalization with the cutover's normalizeSessionEntry
|
|
46
|
+
// validation semantics: id/scope/projectId consistency between bucket, body
|
|
47
|
+
// and metadata; orphan metadata-only entries are dropped (diagnosed by the
|
|
48
|
+
// caller); body-only files derive their metadata. Returns the repository
|
|
49
|
+
// record, or null for a metadata-only orphan.
|
|
50
|
+
function normalizeSessionEntry(bucket, sessionId, rawState, rawMetadata, diagnostics) {
|
|
51
|
+
if (!rawState && rawMetadata) {
|
|
52
|
+
diagnostics.push({ kind: 'metadata-only-orphan', scope: bucket.scope, projectId: bucket.projectId, sessionId })
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
if (!isPlainObject(rawState)) throw new TypeError(`Invalid session state: ${sessionId}`)
|
|
56
|
+
if (!Array.isArray(rawState.messages)) throw new TypeError(`Session messages must be an array: ${sessionId}`)
|
|
57
|
+
if (rawState.id !== undefined && rawState.id !== sessionId) throw new TypeError(`Session body id mismatch: ${sessionId}`)
|
|
58
|
+
if (rawState.scope !== undefined && rawState.scope !== bucket.scope) throw new TypeError(`Session body scope mismatch: ${sessionId}`)
|
|
59
|
+
if (bucket.scope === 'project' && rawState.projectId !== undefined && rawState.projectId !== bucket.projectId) throw new TypeError(`Session body project mismatch: ${sessionId}`)
|
|
60
|
+
if (bucket.scope === 'global' && rawState.projectId !== undefined && rawState.projectId !== null) throw new TypeError(`Global session body project mismatch: ${sessionId}`)
|
|
61
|
+
let metadata
|
|
62
|
+
if (rawMetadata === undefined) {
|
|
63
|
+
diagnostics.push({ kind: 'body-only', scope: bucket.scope, projectId: bucket.projectId, sessionId })
|
|
64
|
+
metadata = deriveMetadata(sessionId, rawState, bucket)
|
|
65
|
+
} else {
|
|
66
|
+
if (!isPlainObject(rawMetadata)) throw new TypeError(`Invalid session metadata: ${sessionId}`)
|
|
67
|
+
if (rawMetadata.id !== undefined && rawMetadata.id !== sessionId) throw new TypeError(`Session metadata id mismatch: ${sessionId}`)
|
|
68
|
+
if (rawMetadata.scope !== undefined && rawMetadata.scope !== bucket.scope) throw new TypeError(`Session metadata scope mismatch: ${sessionId}`)
|
|
69
|
+
if (bucket.scope === 'project' && rawMetadata.projectId !== undefined && rawMetadata.projectId !== bucket.projectId) throw new TypeError(`Session metadata project mismatch: ${sessionId}`)
|
|
70
|
+
if (bucket.scope === 'global' && rawMetadata.projectId !== undefined && rawMetadata.projectId !== null) throw new TypeError(`Global session metadata project mismatch: ${sessionId}`)
|
|
71
|
+
metadata = structuredClone(rawMetadata)
|
|
72
|
+
}
|
|
73
|
+
const stateVersion = rawState.stateVersion ?? metadata.stateVersion ?? 0
|
|
74
|
+
if (!Number.isInteger(stateVersion) || stateVersion < 0) throw new TypeError(`Invalid session stateVersion: ${sessionId}`)
|
|
75
|
+
const state = { ...structuredClone(rawState), id: sessionId, scope: bucket.scope, stateVersion }
|
|
76
|
+
metadata = { ...metadata, id: sessionId, scope: bucket.scope, stateVersion }
|
|
77
|
+
if (bucket.scope === 'project') {
|
|
78
|
+
state.projectId = bucket.projectId
|
|
79
|
+
metadata.projectId = bucket.projectId
|
|
80
|
+
} else {
|
|
81
|
+
delete state.projectId
|
|
82
|
+
delete metadata.projectId
|
|
83
|
+
}
|
|
84
|
+
for (const field of ['pinnedAt', 'archivedAt']) {
|
|
85
|
+
if (metadata[field] !== undefined) state[field] = metadata[field]
|
|
86
|
+
}
|
|
87
|
+
return { scope: bucket.scope, projectId: bucket.projectId ?? null, sessionId, state, metadata }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Schema v11 JSON importer: streams the physical session-state JSON tree
|
|
91
|
+
// (the same layout createPhysicalSessionStateFsAdapter walks for the old
|
|
92
|
+
// cutover) and re-imports every session into the v2 SQLite store, one
|
|
93
|
+
// transaction per session — repository.save extracts the messages (inline or
|
|
94
|
+
// legacy split-marked bodies alike), so the import is idempotent and can be
|
|
95
|
+
// re-run after any interruption. JSON files are read-only here: nothing is
|
|
96
|
+
// written back, no mirror, no phase state.
|
|
97
|
+
//
|
|
98
|
+
// Resilience: a failing entry (unreadable file, validation mismatch,
|
|
99
|
+
// cross-bucket duplicate) never aborts the run — it is counted in `skipped`
|
|
100
|
+
// and described in `diagnostics` so the operator can inspect and fix the
|
|
101
|
+
// source tree, then simply re-run.
|
|
102
|
+
export async function importSessionStateFromJson({ storage = null, logger = defaultLogger } = {}) {
|
|
103
|
+
const storageModule = storage ?? await import('./storage.mjs')
|
|
104
|
+
const fsAdapter = storageModule.createPhysicalSessionStateFsAdapter()
|
|
105
|
+
const repository = createSessionStateRepository(getSqliteStorage())
|
|
106
|
+
const diagnostics = []
|
|
107
|
+
let imported = 0
|
|
108
|
+
let skipped = 0
|
|
109
|
+
|
|
110
|
+
for await (const rawBucket of fsAdapter.listBuckets()) {
|
|
111
|
+
const bucket = normalizeBucket(rawBucket)
|
|
112
|
+
const metadata = await fsAdapter.readMetadataBucket(rawBucket)
|
|
113
|
+
if (!isPlainObject(metadata)) throw new TypeError('Session bucket stores must be objects')
|
|
114
|
+
const fileIds = new Set()
|
|
115
|
+
for await (const sessionId of fsAdapter.listSessionFiles(rawBucket)) fileIds.add(sessionId)
|
|
116
|
+
const ids = [...new Set([...fileIds, ...Object.keys(metadata)])].sort((left, right) => left.localeCompare(right))
|
|
117
|
+
for (const sessionId of ids) {
|
|
118
|
+
let record
|
|
119
|
+
try {
|
|
120
|
+
const rawState = fileIds.has(sessionId) ? await fsAdapter.readSessionState(rawBucket, sessionId) : undefined
|
|
121
|
+
record = normalizeSessionEntry(bucket, sessionId, rawState, metadata[sessionId], diagnostics)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
skipped += 1
|
|
124
|
+
diagnostics.push({ kind: 'invalid-entry', scope: bucket.scope, projectId: bucket.projectId, sessionId, message: error instanceof Error ? error.message : String(error) })
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
if (!record) {
|
|
128
|
+
skipped += 1
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
repository.save(record)
|
|
133
|
+
imported += 1
|
|
134
|
+
} catch (error) {
|
|
135
|
+
skipped += 1
|
|
136
|
+
diagnostics.push({
|
|
137
|
+
kind: 'save-error',
|
|
138
|
+
scope: record.scope,
|
|
139
|
+
projectId: record.projectId,
|
|
140
|
+
sessionId,
|
|
141
|
+
errorCode: error?.errorCode ?? null,
|
|
142
|
+
message: error instanceof Error ? error.message : String(error),
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
repository.checkpointWal()
|
|
150
|
+
} catch (error) {
|
|
151
|
+
logger.warn('Session state import WAL checkpoint failed', {
|
|
152
|
+
error: error instanceof Error ? error.message : String(error),
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
logger.info('Session state JSON import finished', { imported, skipped })
|
|
156
|
+
return { imported, skipped, diagnostics }
|
|
157
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { getSqliteStorage } from './sqlite/database.mjs'
|
|
4
|
+
|
|
5
|
+
// Session-domain maintenance lock (extracted from the retired
|
|
6
|
+
// session-state-cutover.mjs when the JSON→SQLite phase machine was removed):
|
|
7
|
+
// restore/verify operations serialize across processes through the
|
|
8
|
+
// `session_state_maintenance_lock` table (created by SQLite migration v6 and
|
|
9
|
+
// kept by schema v11), with owner liveness, fencing tokens and a TTL renewed
|
|
10
|
+
// by heartbeat while an operation runs.
|
|
11
|
+
|
|
12
|
+
const DEFAULT_LOCK_TTL_MS = 60_000
|
|
13
|
+
const DEFAULT_WAIT_TIMEOUT_MS = 65_000
|
|
14
|
+
const maintenanceContext = new AsyncLocalStorage()
|
|
15
|
+
let maintenanceCount = 0
|
|
16
|
+
|
|
17
|
+
function isPlainObject(value) {
|
|
18
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ownerPid(current) {
|
|
22
|
+
const value = Number(current.owner_pid)
|
|
23
|
+
return Number.isInteger(value) && value > 0 ? value : null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function pidAlive(pid) {
|
|
27
|
+
try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function acquireSessionStateMaintenanceLock(storage, options = {}) {
|
|
31
|
+
const nowMs = Number(options.now?.() ?? Date.now())
|
|
32
|
+
const acquiredAt = new Date(nowMs).toISOString()
|
|
33
|
+
const expiresAt = new Date(nowMs + (options.ttlMs ?? DEFAULT_LOCK_TTL_MS)).toISOString()
|
|
34
|
+
const identity = isPlainObject(options.owner)
|
|
35
|
+
? { id: options.owner.id || `${options.owner.pid || process.pid}:${randomUUID()}`, pid: options.owner.pid || process.pid }
|
|
36
|
+
: { id: options.owner || `${process.pid}:${randomUUID()}`, pid: process.pid }
|
|
37
|
+
const isPidAlive = options.pidAlive || pidAlive
|
|
38
|
+
return storage.transaction((database) => {
|
|
39
|
+
const current = database.prepare('SELECT * FROM session_state_maintenance_lock WHERE singleton = 1').get()
|
|
40
|
+
if (current && current.owner === identity.id) {
|
|
41
|
+
database.prepare(`UPDATE session_state_maintenance_lock SET owner_pid = ?, operation = ?, heartbeat_at = ?, expires_at = ?
|
|
42
|
+
WHERE singleton = 1 AND owner = ? AND fencing = ?`)
|
|
43
|
+
.run(identity.pid, options.operation || current.operation, acquiredAt, expiresAt, identity.id, current.fencing)
|
|
44
|
+
return { owner: identity.id, ownerPid: identity.pid, fencing: Number(current.fencing), expiresAt }
|
|
45
|
+
}
|
|
46
|
+
if (current) {
|
|
47
|
+
const expired = Date.parse(current.expires_at) <= nowMs
|
|
48
|
+
const pid = ownerPid(current)
|
|
49
|
+
if (!expired || pid === null || isPidAlive(pid)) return null
|
|
50
|
+
}
|
|
51
|
+
const fencing = Number(current?.fencing || 0) + 1
|
|
52
|
+
database.prepare(`INSERT INTO session_state_maintenance_lock
|
|
53
|
+
(singleton, owner, owner_pid, fencing, operation, acquired_at, heartbeat_at, expires_at)
|
|
54
|
+
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
|
|
55
|
+
ON CONFLICT(singleton) DO UPDATE SET owner=excluded.owner, owner_pid=excluded.owner_pid, fencing=excluded.fencing,
|
|
56
|
+
operation=excluded.operation, acquired_at=excluded.acquired_at, heartbeat_at=excluded.heartbeat_at, expires_at=excluded.expires_at`)
|
|
57
|
+
.run(identity.id, identity.pid, fencing, options.operation || 'session-state-maintenance', acquiredAt, acquiredAt, expiresAt)
|
|
58
|
+
return { owner: identity.id, ownerPid: identity.pid, fencing, expiresAt }
|
|
59
|
+
}, { mode: 'immediate' })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function renewSessionStateMaintenanceLock(storage, lease, options = {}) {
|
|
63
|
+
const nowMs = Number(options.now?.() ?? Date.now())
|
|
64
|
+
const heartbeatAt = new Date(nowMs).toISOString()
|
|
65
|
+
const expiresAt = new Date(nowMs + (options.ttlMs ?? DEFAULT_LOCK_TTL_MS)).toISOString()
|
|
66
|
+
const result = storage.prepare(`UPDATE session_state_maintenance_lock SET heartbeat_at = ?, expires_at = ? WHERE singleton = 1 AND owner = ? AND fencing = ?`)
|
|
67
|
+
.run(heartbeatAt, expiresAt, lease.owner, lease.fencing)
|
|
68
|
+
lease.expiresAt = expiresAt
|
|
69
|
+
return Number(result.changes) === 1
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function releaseSessionStateMaintenanceLock(storage, lease) {
|
|
73
|
+
return Number(storage.prepare('DELETE FROM session_state_maintenance_lock WHERE singleton = 1 AND owner = ? AND fencing = ?').run(lease.owner, lease.fencing).changes) === 1
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function waitForLock(storage, options) {
|
|
77
|
+
const deadline = Date.now() + (options.waitTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS)
|
|
78
|
+
while (Date.now() < deadline) {
|
|
79
|
+
const lease = acquireSessionStateMaintenanceLock(storage, options)
|
|
80
|
+
if (lease) return lease
|
|
81
|
+
await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs ?? 100))
|
|
82
|
+
}
|
|
83
|
+
throw new Error('Timed out waiting for session state maintenance lock')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function runSessionStateMaintenance(operation, options = {}) {
|
|
87
|
+
const existing = maintenanceContext.getStore()
|
|
88
|
+
if (existing) return operation(existing)
|
|
89
|
+
const storage = options.storage || getSqliteStorage()
|
|
90
|
+
const lease = await waitForLock(storage, options)
|
|
91
|
+
const interval = options.heartbeatIntervalMs ?? Math.max(10, Math.floor((options.ttlMs ?? DEFAULT_LOCK_TTL_MS) / 3))
|
|
92
|
+
const timer = setInterval(() => {
|
|
93
|
+
try {
|
|
94
|
+
if (!renewSessionStateMaintenanceLock(storage, lease, options)) throw new Error('Session state maintenance lock fencing was lost')
|
|
95
|
+
} catch (error) {
|
|
96
|
+
heartbeatError = error
|
|
97
|
+
rejectHeartbeat(error)
|
|
98
|
+
}
|
|
99
|
+
}, interval)
|
|
100
|
+
timer.unref?.()
|
|
101
|
+
let rejectHeartbeat
|
|
102
|
+
let heartbeatError = null
|
|
103
|
+
const heartbeatFailure = new Promise((_, reject) => { rejectHeartbeat = reject })
|
|
104
|
+
maintenanceCount += 1
|
|
105
|
+
try {
|
|
106
|
+
const context = { storage, lease }
|
|
107
|
+
const result = await Promise.race([maintenanceContext.run(context, () => Promise.resolve().then(() => operation(context))), heartbeatFailure])
|
|
108
|
+
if (heartbeatError) throw heartbeatError
|
|
109
|
+
return result
|
|
110
|
+
} finally {
|
|
111
|
+
clearInterval(timer)
|
|
112
|
+
releaseSessionStateMaintenanceLock(storage, lease)
|
|
113
|
+
maintenanceCount = Math.max(0, maintenanceCount - 1)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function isSessionStateMaintenanceActive(storage) {
|
|
118
|
+
if (!storage) {
|
|
119
|
+
try { storage = getSqliteStorage() } catch { return false }
|
|
120
|
+
}
|
|
121
|
+
return maintenanceCount > 0 || Boolean(storage.prepare('SELECT 1 AS active FROM session_state_maintenance_lock WHERE singleton = 1').get())
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function currentSessionStateMaintenanceContext() {
|
|
125
|
+
return maintenanceContext.getStore() || null
|
|
126
|
+
}
|