@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.
Files changed (53) hide show
  1. package/dist/assets/{AgentProfilesPage-BSM5w3bn.js → AgentProfilesPage-Cvb1KTz5.js} +1 -1
  2. package/dist/assets/ChatPanelHost-C7zV-xE_.js +48 -0
  3. package/dist/assets/{CloudAccountSettingsPage-BJMrfgy9.js → CloudAccountSettingsPage-mAs_nbvw.js} +1 -1
  4. package/dist/assets/{PluginsPage-mJkTmVVV.js → PluginsPage-mLvwvTmL.js} +1 -1
  5. package/dist/assets/{ScheduledTasksPage-DJj_NLm9.js → ScheduledTasksPage-CuUW_9lq.js} +1 -1
  6. package/dist/assets/{SettingsWorkspacePage-7lQ0Wl63.js → SettingsWorkspacePage-0OINfEqK.js} +390 -390
  7. package/dist/assets/{ShareLinksSettingsPage-DjFq_Lnb.js → ShareLinksSettingsPage-DqeyJYoD.js} +1 -1
  8. package/dist/assets/{SharedConversationPage-BbuNpcpP.js → SharedConversationPage-C7CDEoX9.js} +1 -1
  9. package/dist/assets/TerminalDock-CZXcuMpq.js +2 -0
  10. package/dist/assets/{WorkspaceInspector-0i6x-diB.js → WorkspaceInspector-CNSBOEvP.js} +3 -3
  11. package/dist/assets/index-BgMvQW1b.js +66 -0
  12. package/dist/assets/index-CB1g2D0f.css +3 -0
  13. package/dist/assets/local-tools-Dopjg9xq.js +270 -0
  14. package/dist/assets/{mcp-servers-dialog-BG4b67ea.js → mcp-servers-dialog-B2lALtTE.js} +1 -1
  15. package/dist/assets/{skills-dialog-DIzZvQqW.js → skills-dialog-DYp35JM3.js} +1 -1
  16. package/dist/index.html +4 -4
  17. package/package.json +1 -1
  18. package/server/acp/server.mjs +3 -6
  19. package/server/agent-manager.mjs +125 -43
  20. package/server/auto-archive.mjs +55 -8
  21. package/server/auto-compaction.mjs +0 -2
  22. package/server/context-usage.mjs +9 -7
  23. package/server/index.mjs +172 -51
  24. package/server/lan-access-cutover.mjs +21 -4
  25. package/server/maintenance/downgrade-session-state-v1.mjs +50 -87
  26. package/server/maintenance/export-session-state-v1.mjs +5 -13
  27. package/server/routes/backup.mjs +8 -1
  28. package/server/routes/storage.mjs +85 -48
  29. package/server/scheduled-runs-cutover.mjs +15 -1
  30. package/server/session-index-service.mjs +57 -391
  31. package/server/session-persistence-lock.mjs +17 -6
  32. package/server/session-state-backup.mjs +60 -7
  33. package/server/session-state-import.mjs +157 -0
  34. package/server/session-state-maintenance.mjs +126 -0
  35. package/server/session-state-service.mjs +133 -194
  36. package/server/share-cutover.mjs +21 -4
  37. package/server/sqlite/database.mjs +142 -7
  38. package/server/sqlite/lan-access-repository.mjs +6 -4
  39. package/server/sqlite/migrations.mjs +109 -0
  40. package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
  41. package/server/sqlite/session-index-repository.mjs +59 -208
  42. package/server/sqlite/session-state-repository.mjs +359 -289
  43. package/server/sqlite/share-repository.mjs +56 -45
  44. package/server/startup-state.mjs +107 -0
  45. package/server/storage.mjs +240 -448
  46. package/server/utils/logger.mjs +15 -4
  47. package/server/utils/process-tree.mjs +14 -2
  48. package/dist/assets/ChatPanelHost-cd80S4EX.js +0 -48
  49. package/dist/assets/TerminalDock-CczcCJJE.js +0 -2
  50. package/dist/assets/index-D-J_8Smf.css +0 -3
  51. package/dist/assets/index-DFASc15l.js +0 -66
  52. package/dist/assets/local-tools-Dn1Y9aPe.js +0 -270
  53. 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-cutover.mjs'
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
- if (snapshot.count !== integrity.count || snapshot.digest !== integrity.digest) {
90
- throw new Error('Session state backup count/digest verification failed')
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: getSessionStoragePhase(),
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
- }, { storage: options.storage, operation: 'session-state-restore' })
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
- }, { storage, operation: 'session-state-restore-recovery' })
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
+ }