@shawnstack/quickforge 1.7.6 → 1.7.8
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 +1 -0
- package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
- package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
- package/dist/assets/{PluginsPage-BDKYfLna.js → PluginsPage-mJkTmVVV.js} +1 -1
- package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
- package/dist/assets/{SettingsWorkspacePage-DSy66Tuw.js → SettingsWorkspacePage-7lQ0Wl63.js} +391 -391
- package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
- package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
- package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
- package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
- package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
- package/dist/assets/index-D-J_8Smf.css +3 -0
- package/dist/assets/index-DFASc15l.js +66 -0
- package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
- package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
- package/dist/assets/{mcp-servers-dialog-BEV4_TXC.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
- package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
- package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
- package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
- package/dist/assets/{skills-dialog-BTJ7mzk_.js → skills-dialog-DIzZvQqW.js} +3 -3
- package/dist/index.html +8 -8
- package/package.json +1 -1
- package/server/acp/server.mjs +17 -1
- package/server/agent-manager.mjs +251 -31
- package/server/ai-http-logger.mjs +162 -19
- package/server/ai-provider-options.mjs +4 -1
- package/server/auto-archive.mjs +42 -29
- package/server/auto-compaction.mjs +43 -1
- package/server/cloud/auto-approval.mjs +155 -0
- package/server/cloud/client.mjs +5 -0
- package/server/cloud/config.mjs +8 -16
- package/server/cloud/qf-agent-process.mjs +163 -3
- package/server/cloud/service-config.mjs +1 -1
- package/server/context-usage.mjs +11 -4
- package/server/index.mjs +69 -13
- package/server/lan-access-backup.mjs +256 -0
- package/server/lan-access-cutover.mjs +306 -0
- package/server/lan-access-json-file.mjs +75 -0
- package/server/lan-access-service.mjs +155 -0
- package/server/lan-access-store.mjs +110 -9
- package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
- package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
- package/server/maintenance/downgrade-share-v1.mjs +101 -0
- package/server/maintenance/export-lan-access-v1.mjs +80 -0
- package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
- package/server/maintenance/export-session-state-v1.mjs +67 -0
- package/server/maintenance/export-share-v1.mjs +77 -0
- package/server/routes/agent.mjs +37 -4
- package/server/routes/backup.mjs +221 -39
- package/server/routes/cloud.mjs +28 -1
- package/server/routes/scheduled-tasks.mjs +347 -134
- package/server/routes/shares.mjs +4 -2
- package/server/routes/storage.mjs +138 -8
- package/server/routes/workspace.mjs +265 -4
- package/server/scheduled-runs-backup.mjs +235 -0
- package/server/scheduled-runs-cutover.mjs +497 -0
- package/server/scheduled-task-runs-service.mjs +279 -0
- package/server/session-index-service.mjs +495 -0
- package/server/session-state-backup.mjs +277 -0
- package/server/session-state-cutover.mjs +370 -0
- package/server/session-state-service.mjs +709 -0
- package/server/share-backup.mjs +255 -0
- package/server/share-cutover.mjs +296 -0
- package/server/share-json-file.mjs +60 -0
- package/server/share-service.mjs +163 -0
- package/server/share-store.mjs +196 -19
- package/server/skills.mjs +0 -1
- package/server/sqlite/database.mjs +233 -0
- package/server/sqlite/lan-access-repository.mjs +652 -0
- package/server/sqlite/migrations.mjs +577 -0
- package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
- package/server/sqlite/session-index-repository.mjs +297 -0
- package/server/sqlite/session-state-repository.mjs +786 -0
- package/server/sqlite/share-repository.mjs +854 -0
- package/server/storage.mjs +317 -1
- package/dist/assets/AgentProfilesPage-Dl5Peqov.js +0 -1
- package/dist/assets/ChatPanelHost-D3987TUJ.js +0 -307
- package/dist/assets/CloudAccountSettingsPage-BG5Nn3bq.js +0 -1
- package/dist/assets/ScheduledTasksPage--GAki3lB.js +0 -2
- package/dist/assets/ShareLinksSettingsPage-BQQMsvJo.js +0 -1
- package/dist/assets/SharedConversationPage-B9BUVCri.js +0 -1
- package/dist/assets/TerminalDock-CT1nYo7P.js +0 -2
- package/dist/assets/WorkspaceInspector-BnfDLf9a.js +0 -36
- package/dist/assets/index-1ei6nhs-.css +0 -3
- package/dist/assets/index-BivLuZvX.js +0 -65
- package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
- /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
|
+
}
|