@shawnstack/quickforge 1.7.7 → 1.7.9

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 (85) hide show
  1. package/bin/quickforge.mjs +10 -1
  2. package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
  3. package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
  4. package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
  5. package/dist/assets/{PluginsPage-CPE5tbbf.js → PluginsPage-mJkTmVVV.js} +1 -1
  6. package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
  7. package/dist/assets/{SettingsWorkspacePage-BE2T4d3P.js → SettingsWorkspacePage-7lQ0Wl63.js} +389 -389
  8. package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
  9. package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
  10. package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
  11. package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
  12. package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
  13. package/dist/assets/{index-BiCTgUlY.css → index-D-J_8Smf.css} +1 -1
  14. package/dist/assets/index-DFASc15l.js +66 -0
  15. package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
  16. package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
  17. package/dist/assets/{mcp-servers-dialog-GY7sdfIr.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
  18. package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
  19. package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
  20. package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
  21. package/dist/assets/{skills-dialog-C1fJ1Jl7.js → skills-dialog-DIzZvQqW.js} +3 -3
  22. package/dist/index.html +8 -8
  23. package/package.json +1 -1
  24. package/server/acp/server.mjs +17 -1
  25. package/server/agent-manager.mjs +251 -31
  26. package/server/ai-http-logger.mjs +162 -19
  27. package/server/ai-provider-options.mjs +4 -1
  28. package/server/auto-archive.mjs +42 -29
  29. package/server/cloud/auto-approval.mjs +155 -0
  30. package/server/cloud/client.mjs +5 -0
  31. package/server/cloud/qf-agent-process.mjs +163 -3
  32. package/server/cloud/service-config.mjs +1 -1
  33. package/server/index.mjs +69 -13
  34. package/server/lan-access-backup.mjs +256 -0
  35. package/server/lan-access-cutover.mjs +306 -0
  36. package/server/lan-access-json-file.mjs +75 -0
  37. package/server/lan-access-service.mjs +155 -0
  38. package/server/lan-access-store.mjs +110 -9
  39. package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
  40. package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
  41. package/server/maintenance/downgrade-share-v1.mjs +101 -0
  42. package/server/maintenance/export-lan-access-v1.mjs +80 -0
  43. package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
  44. package/server/maintenance/export-session-state-v1.mjs +67 -0
  45. package/server/maintenance/export-share-v1.mjs +77 -0
  46. package/server/public-api.mjs +21 -2
  47. package/server/routes/agent.mjs +37 -4
  48. package/server/routes/backup.mjs +221 -39
  49. package/server/routes/cloud.mjs +28 -1
  50. package/server/routes/scheduled-tasks.mjs +347 -134
  51. package/server/routes/shares.mjs +4 -2
  52. package/server/routes/storage.mjs +138 -8
  53. package/server/routes/workspace.mjs +265 -4
  54. package/server/scheduled-runs-backup.mjs +235 -0
  55. package/server/scheduled-runs-cutover.mjs +497 -0
  56. package/server/scheduled-task-runs-service.mjs +279 -0
  57. package/server/session-index-service.mjs +495 -0
  58. package/server/session-state-backup.mjs +277 -0
  59. package/server/session-state-cutover.mjs +370 -0
  60. package/server/session-state-service.mjs +709 -0
  61. package/server/share-backup.mjs +255 -0
  62. package/server/share-cutover.mjs +296 -0
  63. package/server/share-json-file.mjs +60 -0
  64. package/server/share-service.mjs +163 -0
  65. package/server/share-store.mjs +196 -19
  66. package/server/skills.mjs +0 -1
  67. package/server/sqlite/database.mjs +233 -0
  68. package/server/sqlite/lan-access-repository.mjs +652 -0
  69. package/server/sqlite/migrations.mjs +577 -0
  70. package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
  71. package/server/sqlite/session-index-repository.mjs +297 -0
  72. package/server/sqlite/session-state-repository.mjs +786 -0
  73. package/server/sqlite/share-repository.mjs +854 -0
  74. package/server/storage.mjs +317 -1
  75. package/dist/assets/AgentProfilesPage-BfA_sQ3A.js +0 -1
  76. package/dist/assets/ChatPanelHost-Dul1ahKj.js +0 -307
  77. package/dist/assets/CloudAccountSettingsPage-qZh0lTUN.js +0 -1
  78. package/dist/assets/ScheduledTasksPage-PTxL_PPg.js +0 -2
  79. package/dist/assets/ShareLinksSettingsPage-Bl8fP62X.js +0 -1
  80. package/dist/assets/SharedConversationPage-BK9XJnEe.js +0 -1
  81. package/dist/assets/TerminalDock-CL7HU9DI.js +0 -2
  82. package/dist/assets/WorkspaceInspector-MNBLIKMn.js +0 -36
  83. package/dist/assets/index-BTPzyq7d.js +0 -65
  84. package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
  85. /package/dist/assets/{plugin-api-Uu2xAB4d.js → plugin-api-BrdQeM4B.js} +0 -0
@@ -0,0 +1,497 @@
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 { createScheduledTaskRunsRepository } from './sqlite/scheduled-task-runs-repository.mjs'
7
+ import { readStore, storageDir, writeStore } from './storage.mjs'
8
+ import { logger } from './utils/logger.mjs'
9
+
10
+ export const SCHEDULED_RUNS_PHASES = Object.freeze({
11
+ HYBRID: 'hybrid',
12
+ CUTOVER_RUNNING: 'cutover_running',
13
+ JSON_PENDING: 'sqlite_authoritative_json_pending',
14
+ AUTHORITATIVE: 'authoritative',
15
+ })
16
+
17
+ const DEFAULT_LOCK_TTL_MS = 60_000
18
+ const DEFAULT_WAIT_TIMEOUT_MS = 65_000
19
+ let cachedPhase = SCHEDULED_RUNS_PHASES.HYBRID
20
+ let localMaintenanceCount = 0
21
+ let retainedMaintenance = null
22
+ let runtimeHooks = Object.freeze({})
23
+ const maintenanceContext = new AsyncLocalStorage()
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().map((key) => [key, canonicalize(value[key])]))
33
+ }
34
+
35
+ export function scheduledRunsDigest(entries) {
36
+ const canonical = [...entries]
37
+ .map(({ taskId, run }) => {
38
+ const normalizedRun = { ...run }
39
+ delete normalizedRun.taskId
40
+ delete normalizedRun.source
41
+ delete normalizedRun.updatedAt
42
+ for (const field of ['agentId', 'agentLabel', 'agentSnapshot']) {
43
+ if (normalizedRun[field] === undefined) normalizedRun[field] = null
44
+ }
45
+ return { taskId, run: canonicalize(normalizedRun) }
46
+ })
47
+ .sort((a, b) => String(a.taskId).localeCompare(String(b.taskId)) || String(a.run.id).localeCompare(String(b.run.id)))
48
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex')
49
+ }
50
+
51
+ export function splitScheduledTasksRuns(tasksValue) {
52
+ const tasks = isPlainObject(tasksValue) ? tasksValue : {}
53
+ const metadata = {}
54
+ const entries = []
55
+ for (const [recordId, task] of Object.entries(tasks)) {
56
+ if (!isPlainObject(task)) throw new TypeError(`Invalid scheduled task: ${recordId}`)
57
+ const taskId = typeof task.id === 'string' && task.id.trim() ? task.id : recordId
58
+ if (!taskId || taskId !== recordId) throw new TypeError(`Scheduled task id mismatch: ${recordId}`)
59
+ const seen = new Set()
60
+ const runs = task.runs === undefined ? [] : task.runs
61
+ if (!Array.isArray(runs)) throw new TypeError(`Scheduled task runs must be an array: ${taskId}`)
62
+ for (const run of runs) {
63
+ if (!isPlainObject(run)) throw new TypeError(`Invalid scheduled task run: ${taskId}`)
64
+ if (typeof run.id !== 'string' || !run.id.trim()) throw new TypeError(`Scheduled task run id is required: ${taskId}`)
65
+ if (seen.has(run.id)) throw new TypeError(`Duplicate scheduled task run: ${taskId}/${run.id}`)
66
+ if (!['running', 'success', 'failed'].includes(run.status)) throw new TypeError(`Invalid scheduled task run status: ${taskId}/${run.id}`)
67
+ if (typeof run.startedAt !== 'string' || !run.startedAt.trim() || Number.isNaN(new Date(run.startedAt).getTime())) {
68
+ throw new TypeError(`Invalid scheduled task run startedAt: ${taskId}/${run.id}`)
69
+ }
70
+ seen.add(run.id)
71
+ entries.push({ taskId, run: structuredClone(run), source: 'json_cutover' })
72
+ }
73
+ const { runs: _runs, ...taskMetadata } = task
74
+ metadata[recordId] = taskMetadata
75
+ }
76
+ return { metadata, entries, count: entries.length, digest: scheduledRunsDigest(entries) }
77
+ }
78
+
79
+ export function getScheduledRunsPhase() {
80
+ return cachedPhase
81
+ }
82
+
83
+ export function isScheduledRunsAuthoritative() {
84
+ return cachedPhase === SCHEDULED_RUNS_PHASES.JSON_PENDING || cachedPhase === SCHEDULED_RUNS_PHASES.AUTHORITATIVE
85
+ }
86
+
87
+ export function configureScheduledRunsRuntimeHooks(hooks = {}) {
88
+ runtimeHooks = Object.freeze({ ...hooks })
89
+ }
90
+
91
+ export function currentScheduledRunsMaintenanceContext() {
92
+ return maintenanceContext.getStore() || null
93
+ }
94
+
95
+ function lockStorageOrNull(storage) {
96
+ if (storage) return storage
97
+ try { return getSqliteStorage() } catch { return null }
98
+ }
99
+
100
+ export function readScheduledRunsMaintenanceLock(storage) {
101
+ const handle = lockStorageOrNull(storage)
102
+ if (!handle) return null
103
+ try {
104
+ ensureScheduledRunsMaintenanceLockSchema(handle)
105
+ return handle.prepare('SELECT * FROM scheduled_runs_maintenance_lock WHERE singleton = 1').get() || null
106
+ } catch {
107
+ return null
108
+ }
109
+ }
110
+
111
+ function localMaintenanceActive() {
112
+ return localMaintenanceCount > 0 || retainedMaintenance !== null
113
+ }
114
+
115
+ export function isScheduledRunsMaintenanceActive(storage) {
116
+ return localMaintenanceActive() || readScheduledRunsMaintenanceLock(storage) !== null
117
+ }
118
+
119
+ export function canStartScheduledRun(storage) {
120
+ return !isScheduledRunsMaintenanceActive(storage)
121
+ }
122
+
123
+ export function recordScheduledRunsDiagnostic(operation, error, details = {}, storage = getSqliteStorage()) {
124
+ const diagnostic = {
125
+ operation,
126
+ ...details,
127
+ errorName: error?.name || 'Error',
128
+ errorCode: error?.code,
129
+ recordedAt: new Date().toISOString(),
130
+ }
131
+ storage.prepare('UPDATE scheduled_runs_state SET diagnostic_json = ?, updated_at = ? WHERE singleton = 1')
132
+ .run(JSON.stringify(diagnostic), diagnostic.recordedAt)
133
+ return diagnostic
134
+ }
135
+
136
+ export function assertScheduledRunsAvailable(storage) {
137
+ if (!localMaintenanceActive()) {
138
+ const lock = readScheduledRunsMaintenanceLock(storage)
139
+ if (!lock || lock.operation === 'scheduled-runs-backup-export') return
140
+ }
141
+ const error = new Error('Scheduled task maintenance is in progress')
142
+ error.statusCode = 423
143
+ error.errorCode = 'scheduled_runs_maintenance'
144
+ throw error
145
+ }
146
+
147
+ function readState(storage) {
148
+ const row = storage.prepare('SELECT * FROM scheduled_runs_state WHERE singleton = 1').get()
149
+ if (!row) throw new Error('Scheduled runs state is missing')
150
+ return {
151
+ phase: row.phase,
152
+ runCount: row.run_count === null ? null : Number(row.run_count),
153
+ digest: row.digest,
154
+ backupFile: row.backup_file,
155
+ diagnostic: row.diagnostic_json ? JSON.parse(row.diagnostic_json) : null,
156
+ updatedAt: row.updated_at,
157
+ }
158
+ }
159
+
160
+ export function ensureScheduledRunsMaintenanceLockSchema(storage = getSqliteStorage()) {
161
+ const columns = new Set(storage.prepare('PRAGMA table_info(scheduled_runs_maintenance_lock)').all().map((column) => column.name))
162
+ if (!columns.has('owner_pid')) storage.exec('ALTER TABLE scheduled_runs_maintenance_lock ADD COLUMN owner_pid INTEGER')
163
+ if (!columns.has('fencing')) storage.exec('ALTER TABLE scheduled_runs_maintenance_lock ADD COLUMN fencing INTEGER NOT NULL DEFAULT 1')
164
+ if (!columns.has('heartbeat_at')) storage.exec('ALTER TABLE scheduled_runs_maintenance_lock ADD COLUMN heartbeat_at TEXT')
165
+ storage.prepare(`UPDATE scheduled_runs_maintenance_lock SET
166
+ owner_pid = COALESCE(owner_pid, CAST(substr(owner, 1, instr(owner || ':', ':') - 1) AS INTEGER)),
167
+ fencing = COALESCE(fencing, 1), heartbeat_at = COALESCE(heartbeat_at, acquired_at)
168
+ WHERE singleton = 1`).run()
169
+ }
170
+
171
+ export function readScheduledRunsState(storage = getSqliteStorage()) {
172
+ ensureScheduledRunsMaintenanceLockSchema(storage)
173
+ const state = readState(storage)
174
+ cachedPhase = state.phase
175
+ return state
176
+ }
177
+
178
+ function updateState(database, phase, values = {}) {
179
+ database.prepare(`
180
+ UPDATE scheduled_runs_state SET
181
+ phase = ?, run_count = ?, digest = ?, backup_file = ?, diagnostic_json = ?, updated_at = ?
182
+ WHERE singleton = 1
183
+ `).run(
184
+ phase,
185
+ values.runCount ?? null,
186
+ values.digest ?? null,
187
+ values.backupFile ?? null,
188
+ values.diagnostic ? JSON.stringify(values.diagnostic) : null,
189
+ new Date().toISOString(),
190
+ )
191
+ cachedPhase = phase
192
+ }
193
+
194
+ function ownerPidFromRow(row) {
195
+ const value = Number(row?.owner_pid)
196
+ if (Number.isInteger(value) && value > 0) return value
197
+ const parsed = Number(String(row?.owner || '').split(':', 1)[0])
198
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null
199
+ }
200
+
201
+ function defaultPidAlive(pid) {
202
+ if (!Number.isInteger(pid) || pid <= 0) return false
203
+ try {
204
+ process.kill(pid, 0)
205
+ return true
206
+ } catch (error) {
207
+ return error?.code === 'EPERM'
208
+ }
209
+ }
210
+
211
+ function normalizedLockOwner(owner) {
212
+ if (isPlainObject(owner)) {
213
+ return {
214
+ id: typeof owner.id === 'string' && owner.id ? owner.id : `${owner.pid || process.pid}:${randomUUID()}`,
215
+ pid: Number.isInteger(owner.pid) && owner.pid > 0 ? owner.pid : process.pid,
216
+ }
217
+ }
218
+ return { id: typeof owner === 'string' && owner ? owner : `${process.pid}:${randomUUID()}`, pid: process.pid }
219
+ }
220
+
221
+ export function acquireScheduledRunsMaintenanceLock(storage, {
222
+ owner,
223
+ operation,
224
+ now = () => Date.now(),
225
+ ttlMs = DEFAULT_LOCK_TTL_MS,
226
+ pidAlive = defaultPidAlive,
227
+ } = {}) {
228
+ ensureScheduledRunsMaintenanceLockSchema(storage)
229
+ const identity = normalizedLockOwner(owner)
230
+ const nowMs = Number(now())
231
+ const acquiredAt = new Date(nowMs).toISOString()
232
+ const expiresAt = new Date(nowMs + ttlMs).toISOString()
233
+ return storage.transaction((database) => {
234
+ const current = database.prepare('SELECT * FROM scheduled_runs_maintenance_lock WHERE singleton = 1').get()
235
+ if (current && current.owner === identity.id) {
236
+ const fencing = Number(current.fencing || 1)
237
+ database.prepare(`UPDATE scheduled_runs_maintenance_lock
238
+ SET operation = ?, owner_pid = ?, heartbeat_at = ?, expires_at = ?
239
+ WHERE singleton = 1 AND owner = ? AND fencing = ?`)
240
+ .run(operation || current.operation, identity.pid, acquiredAt, expiresAt, identity.id, fencing)
241
+ return { owner: identity.id, ownerPid: identity.pid, fencing, expiresAt }
242
+ }
243
+ if (current) {
244
+ const stalePid = ownerPidFromRow(current)
245
+ if (stalePid === null || pidAlive(stalePid)) return null
246
+ }
247
+ const fencing = Number(current?.fencing || 0) + 1
248
+ database.prepare(`
249
+ INSERT INTO scheduled_runs_maintenance_lock
250
+ (singleton, owner, owner_pid, fencing, operation, acquired_at, heartbeat_at, expires_at)
251
+ VALUES (1, ?, ?, ?, ?, ?, ?, ?)
252
+ ON CONFLICT(singleton) DO UPDATE SET owner = excluded.owner, owner_pid = excluded.owner_pid,
253
+ fencing = excluded.fencing, operation = excluded.operation, acquired_at = excluded.acquired_at,
254
+ heartbeat_at = excluded.heartbeat_at, expires_at = excluded.expires_at
255
+ `).run(identity.id, identity.pid, fencing, operation || 'scheduled-runs-maintenance', acquiredAt, acquiredAt, expiresAt)
256
+ return { owner: identity.id, ownerPid: identity.pid, fencing, expiresAt }
257
+ })
258
+ }
259
+
260
+ export function renewScheduledRunsMaintenanceLock(storage, lease, {
261
+ now = () => Date.now(),
262
+ ttlMs = DEFAULT_LOCK_TTL_MS,
263
+ } = {}) {
264
+ if (!lease) return false
265
+ const nowMs = Number(now())
266
+ const heartbeatAt = new Date(nowMs).toISOString()
267
+ const expiresAt = new Date(nowMs + ttlMs).toISOString()
268
+ const result = storage.prepare(`UPDATE scheduled_runs_maintenance_lock
269
+ SET heartbeat_at = ?, expires_at = ?
270
+ WHERE singleton = 1 AND owner = ? AND fencing = ?`)
271
+ .run(heartbeatAt, expiresAt, lease.owner, lease.fencing)
272
+ if (Number(result.changes) !== 1) return false
273
+ lease.expiresAt = expiresAt
274
+ return true
275
+ }
276
+
277
+ export function releaseScheduledRunsMaintenanceLock(storage, lease) {
278
+ if (!lease) return false
279
+ return Number(storage.prepare(`DELETE FROM scheduled_runs_maintenance_lock
280
+ WHERE singleton = 1 AND owner = ? AND fencing = ?`).run(lease.owner, lease.fencing).changes) === 1
281
+ }
282
+
283
+ async function waitForMaintenanceLock(storage, lockOptions) {
284
+ const waitTimeoutMs = lockOptions.waitTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS
285
+ const pollIntervalMs = lockOptions.pollIntervalMs ?? 100
286
+ const deadline = Date.now() + waitTimeoutMs
287
+ while (Date.now() < deadline) {
288
+ const lease = acquireScheduledRunsMaintenanceLock(storage, lockOptions)
289
+ if (lease) return lease
290
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
291
+ }
292
+ throw new Error('Timed out waiting for scheduled runs maintenance lock')
293
+ }
294
+
295
+ export async function runScheduledRunsMaintenance(operation, options = {}) {
296
+ const existing = maintenanceContext.getStore()
297
+ if (existing) return operation(existing)
298
+ const storage = options.storage || getSqliteStorage()
299
+ const lockOptions = {
300
+ owner: options.owner,
301
+ operation: options.operation,
302
+ now: options.now,
303
+ ttlMs: options.ttlMs,
304
+ pidAlive: options.pidAlive,
305
+ waitTimeoutMs: options.waitTimeoutMs,
306
+ pollIntervalMs: options.pollIntervalMs,
307
+ }
308
+ const lease = await waitForMaintenanceLock(storage, lockOptions)
309
+ const heartbeatIntervalMs = options.heartbeatIntervalMs ?? Math.max(10, Math.floor((options.ttlMs ?? DEFAULT_LOCK_TTL_MS) / 3))
310
+ let rejectHeartbeat
311
+ let heartbeatError = null
312
+ const heartbeatFailure = new Promise((_, reject) => { rejectHeartbeat = reject })
313
+ const timer = setInterval(() => {
314
+ try {
315
+ if (!renewScheduledRunsMaintenanceLock(storage, lease, { now: options.now, ttlMs: options.ttlMs })) {
316
+ throw new Error('Scheduled runs maintenance lock fencing was lost')
317
+ }
318
+ } catch (error) {
319
+ heartbeatError = error
320
+ rejectHeartbeat(error)
321
+ }
322
+ }, heartbeatIntervalMs)
323
+ timer.unref?.()
324
+ localMaintenanceCount += 1
325
+ let paused = false
326
+ try {
327
+ if (options.pauseRuntime !== false && typeof runtimeHooks.pause === 'function') {
328
+ await runtimeHooks.pause()
329
+ paused = true
330
+ }
331
+ const context = { storage, lease }
332
+ const result = await Promise.race([maintenanceContext.run(context, () => Promise.resolve().then(() => operation(context))), heartbeatFailure])
333
+ if (heartbeatError) throw heartbeatError
334
+ return result
335
+ } catch (error) {
336
+ if (error?.retainScheduledRunsMaintenance === true) {
337
+ retainedMaintenance = { storage, lease, timer, paused }
338
+ localMaintenanceCount = Math.max(0, localMaintenanceCount - 1)
339
+ throw error
340
+ }
341
+ throw error
342
+ } finally {
343
+ if (retainedMaintenance?.lease !== lease) {
344
+ clearInterval(timer)
345
+ releaseScheduledRunsMaintenanceLock(storage, lease)
346
+ localMaintenanceCount = Math.max(0, localMaintenanceCount - 1)
347
+ if (paused && options.resumeRuntime !== false && typeof runtimeHooks.resume === 'function') runtimeHooks.resume()
348
+ }
349
+ }
350
+ }
351
+
352
+ async function writeLogicalCutoverBackup(tasks, snapshot, backupDirectory) {
353
+ const directory = backupDirectory || path.join(storageDir, 'backups')
354
+ await fs.mkdir(directory, { recursive: true })
355
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
356
+ const finalPath = path.join(directory, `quickforge-scheduled-runs-cutover-${stamp}.json`)
357
+ const temporaryPath = `${finalPath}.${process.pid}.${randomUUID()}.tmp`
358
+ const backup = {
359
+ app: 'quickforge',
360
+ version: 1,
361
+ exportedAt: new Date().toISOString(),
362
+ scope: 'config',
363
+ includeSecrets: false,
364
+ scheduledRuns: { count: snapshot.count, digest: snapshot.digest },
365
+ data: { scheduledTasks: tasks },
366
+ }
367
+ try {
368
+ await fs.writeFile(temporaryPath, `${JSON.stringify(backup, null, 2)}\n`, 'utf8')
369
+ const verified = JSON.parse(await fs.readFile(temporaryPath, 'utf8'))
370
+ const verifiedSnapshot = splitScheduledTasksRuns(verified.data.scheduledTasks)
371
+ if (verifiedSnapshot.count !== snapshot.count || verifiedSnapshot.digest !== snapshot.digest) throw new Error('Scheduled runs cutover backup verification failed')
372
+ await fs.rename(temporaryPath, finalPath)
373
+ return finalPath
374
+ } catch (error) {
375
+ await fs.rm(temporaryPath, { force: true }).catch(() => {})
376
+ throw error
377
+ }
378
+ }
379
+
380
+ async function slimJson(readTasks, writeTasks, metadata) {
381
+ await writeTasks(metadata)
382
+ const verified = splitScheduledTasksRuns(await readTasks())
383
+ if (verified.count !== 0) throw new Error('Scheduled task JSON slimming verification failed')
384
+ }
385
+
386
+ export function verifyScheduledRunsRepository(repository, snapshot) {
387
+ const taskIds = Object.keys(snapshot.metadata)
388
+ const first = repository.list({ page: 1, pageSize: 100, taskIds })
389
+ let entries = first.runs.map((run) => {
390
+ const { taskId, source: _source, ...value } = run
391
+ return { taskId, run: value }
392
+ })
393
+ for (let page = 2; entries.length < first.total; page += 1) {
394
+ const next = repository.list({ page, pageSize: 100, taskIds })
395
+ entries = entries.concat(next.runs.map((run) => {
396
+ const { taskId, source: _source, ...value } = run
397
+ return { taskId, run: value }
398
+ }))
399
+ }
400
+ const digest = scheduledRunsDigest(entries)
401
+ if (entries.length !== snapshot.count || digest !== snapshot.digest) throw new Error('Scheduled runs SQLite count/digest verification failed')
402
+ }
403
+
404
+ export async function initializeScheduledRunsCutover(options = {}) {
405
+ const storage = options.storage || getSqliteStorage()
406
+ const repository = options.repository || createScheduledTaskRunsRepository(storage)
407
+ const readTasks = options.readTasks || (() => readStore('scheduled-tasks'))
408
+ const writeTasks = options.writeTasks || ((tasks) => writeStore('scheduled-tasks', tasks))
409
+ const log = options.logger || logger
410
+
411
+ return runScheduledRunsMaintenance(async () => {
412
+ let state = readState(storage)
413
+ cachedPhase = state.phase
414
+ if (state.phase === SCHEDULED_RUNS_PHASES.JSON_PENDING) {
415
+ try {
416
+ const snapshot = splitScheduledTasksRuns(await readTasks())
417
+ await slimJson(readTasks, writeTasks, snapshot.metadata)
418
+ storage.transaction((database) => updateState(database, SCHEDULED_RUNS_PHASES.AUTHORITATIVE, state))
419
+ } catch (error) {
420
+ recordScheduledRunsDiagnostic('json_slim_retry', error, {}, storage)
421
+ log.warn('Scheduled runs JSON slimming remains pending', { errorName: error?.name || 'Error' })
422
+ }
423
+ return readState(storage)
424
+ }
425
+
426
+ if (state.phase === SCHEDULED_RUNS_PHASES.AUTHORITATIVE) {
427
+ try {
428
+ const snapshot = splitScheduledTasksRuns(await readTasks())
429
+ if (snapshot.count > 0) await slimJson(readTasks, writeTasks, snapshot.metadata)
430
+ storage.health({ quickCheck: true })
431
+ } catch (error) {
432
+ recordScheduledRunsDiagnostic('authoritative_validation', error, {}, storage)
433
+ const blocked = new Error('Scheduled runs authoritative startup validation failed', { cause: error })
434
+ throw blocked
435
+ }
436
+ return readState(storage)
437
+ }
438
+
439
+ let tasks
440
+ let snapshot
441
+ let backupFile = state.backupFile
442
+ try {
443
+ tasks = await readTasks()
444
+ snapshot = splitScheduledTasksRuns(tasks)
445
+ if (!backupFile) backupFile = await writeLogicalCutoverBackup(tasks, snapshot, options.backupDirectory)
446
+ storage.transaction((database) => updateState(database, SCHEDULED_RUNS_PHASES.CUTOVER_RUNNING, {
447
+ runCount: snapshot.count,
448
+ digest: snapshot.digest,
449
+ backupFile,
450
+ }))
451
+ storage.transaction((database) => {
452
+ repository.replaceAll(snapshot.entries, { source: 'json_cutover' })
453
+ verifyScheduledRunsRepository(repository, snapshot)
454
+ updateState(database, SCHEDULED_RUNS_PHASES.JSON_PENDING, {
455
+ runCount: snapshot.count,
456
+ digest: snapshot.digest,
457
+ backupFile,
458
+ })
459
+ })
460
+ } catch (error) {
461
+ const current = readState(storage)
462
+ if (![SCHEDULED_RUNS_PHASES.JSON_PENDING, SCHEDULED_RUNS_PHASES.AUTHORITATIVE].includes(current.phase)) {
463
+ storage.transaction((database) => updateState(database, SCHEDULED_RUNS_PHASES.HYBRID, {
464
+ backupFile,
465
+ diagnostic: { operation: 'cutover', error: error?.message || String(error) },
466
+ }))
467
+ log.warn('Scheduled runs cutover stayed in hybrid mode', { errorName: error?.name || 'Error' })
468
+ return readState(storage)
469
+ }
470
+ throw error
471
+ }
472
+
473
+ try {
474
+ await slimJson(readTasks, writeTasks, snapshot.metadata)
475
+ storage.transaction((database) => updateState(database, SCHEDULED_RUNS_PHASES.AUTHORITATIVE, {
476
+ runCount: snapshot.count,
477
+ digest: snapshot.digest,
478
+ backupFile,
479
+ }))
480
+ } catch (error) {
481
+ recordScheduledRunsDiagnostic('authoritative_json_slim', error, {}, storage)
482
+ log.warn('Scheduled runs JSON slimming is pending', { errorName: error?.name || 'Error' })
483
+ }
484
+ return readState(storage)
485
+ }, {
486
+ storage,
487
+ owner: options.owner,
488
+ operation: 'scheduled-runs-cutover',
489
+ now: options.now,
490
+ ttlMs: options.ttlMs,
491
+ heartbeatIntervalMs: options.heartbeatIntervalMs,
492
+ waitTimeoutMs: options.waitTimeoutMs,
493
+ pollIntervalMs: options.pollIntervalMs,
494
+ pidAlive: options.pidAlive,
495
+ pauseRuntime: false,
496
+ })
497
+ }