@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,255 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { promises as fs } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { getSqliteStorage } from './sqlite/database.mjs'
|
|
5
|
+
import { createShareRepository, shareSnapshotDigest } from './sqlite/share-repository.mjs'
|
|
6
|
+
import {
|
|
7
|
+
buildShareJsonSnapshot,
|
|
8
|
+
currentShareMaintenanceContext,
|
|
9
|
+
runShareMaintenance,
|
|
10
|
+
} from './share-cutover.mjs'
|
|
11
|
+
import { getShareStoragePhase, isShareStorageAuthoritative } from './share-service.mjs'
|
|
12
|
+
import { storageDir } from './storage.mjs'
|
|
13
|
+
|
|
14
|
+
// F10 Phase 3 share backup/restore: mirrors the session-state-backup pattern in
|
|
15
|
+
// the independent share storage domain. Exports run under the share maintenance
|
|
16
|
+
// lock with quick_check + verifyIntegrity + exportSnapshot (count/digest fail
|
|
17
|
+
// closed). Restores run under the same lock, persist a crash-recoverable plan
|
|
18
|
+
// file and compensate back to the exact before-state if the apply or its
|
|
19
|
+
// verification fails. Only share_sessions/share_tokens/share_json_mirror_queue
|
|
20
|
+
// are touched; scheduled_task_runs, session_index and session_messages are
|
|
21
|
+
// never modified here.
|
|
22
|
+
const RESTORE_PLAN_FILE = path.join(storageDir, 'share-restore-plan.json')
|
|
23
|
+
const ROLL_FORWARD_STATUSES = new Set(['prepared', 'applying', 'target_applied'])
|
|
24
|
+
const ROLL_BACK_STATUSES = new Set(['compensating', 'compensation_failed'])
|
|
25
|
+
|
|
26
|
+
function repositoryRequired() {
|
|
27
|
+
try {
|
|
28
|
+
return createShareRepository(getSqliteStorage())
|
|
29
|
+
} catch (error) {
|
|
30
|
+
throw new Error('SQLite storage is required for authoritative share state', { cause: error })
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isPlainObject(value) {
|
|
35
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isPlainPlanValue(value) {
|
|
39
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Strip repository-internal fields before writing records into backups or plans
|
|
43
|
+
// so the exported JSON is exactly the v1 conversation-shares.json shape.
|
|
44
|
+
function publicShareRecord(record) {
|
|
45
|
+
if (!isPlainObject(record)) return record
|
|
46
|
+
const rest = { ...record }
|
|
47
|
+
delete rest.revision
|
|
48
|
+
delete rest.deletedAt
|
|
49
|
+
return rest
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function shareValuesFromRecords(records) {
|
|
53
|
+
return {
|
|
54
|
+
shares: Object.fromEntries((Array.isArray(records) ? records : []).map((record) => [record.id, publicShareRecord(record)])),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Public digest helper for backup tooling: returns the exact repository-style
|
|
59
|
+
// digest a restored snapshot must produce, given backup `{ shares }` values.
|
|
60
|
+
export function computeShareSnapshotDigest(values) {
|
|
61
|
+
return shareSnapshotDigest(buildShareJsonSnapshot(values?.shares).records)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Authoritative share state export for the backup route and offline tools.
|
|
66
|
+
* Runs under the share maintenance lock so a concurrent restore can never be
|
|
67
|
+
* observed mid-transaction, and fail-closes when integrity or digest
|
|
68
|
+
* verification fails.
|
|
69
|
+
*/
|
|
70
|
+
export async function exportShareStateForBackup(options = {}) {
|
|
71
|
+
const operation = async () => {
|
|
72
|
+
const repository = options.repository || repositoryRequired()
|
|
73
|
+
const integrity = repository.verifyIntegrity({ quickCheck: true })
|
|
74
|
+
if (!integrity.ok) throw new Error('Share state integrity verification failed during backup export')
|
|
75
|
+
const snapshot = repository.exportSnapshot()
|
|
76
|
+
if (snapshot.count !== integrity.count || snapshot.digest !== integrity.digest) {
|
|
77
|
+
throw new Error('Share backup count/digest verification failed')
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
...shareValuesFromRecords(snapshot.records),
|
|
81
|
+
records: snapshot.records,
|
|
82
|
+
count: snapshot.count,
|
|
83
|
+
digest: snapshot.digest,
|
|
84
|
+
phase: getShareStoragePhase(),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!options.maintenance || currentShareMaintenanceContext()) return operation()
|
|
88
|
+
return runShareMaintenance(operation, { storage: options.storage, operation: 'share-backup-export' })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function writePlan(plan, planFile = RESTORE_PLAN_FILE) {
|
|
92
|
+
await fs.mkdir(path.dirname(planFile), { recursive: true })
|
|
93
|
+
const temporary = `${planFile}.${process.pid}.${randomUUID()}.tmp`
|
|
94
|
+
try {
|
|
95
|
+
await fs.writeFile(temporary, `${JSON.stringify(plan, null, 2)}\n`, 'utf8')
|
|
96
|
+
await fs.rename(temporary, planFile)
|
|
97
|
+
} catch (error) {
|
|
98
|
+
await fs.rm(temporary, { force: true }).catch(() => {})
|
|
99
|
+
throw error
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function countShares(values) {
|
|
104
|
+
return isPlainPlanValue(values) && values.shares && typeof values.shares === 'object' && !Array.isArray(values.shares)
|
|
105
|
+
? Object.keys(values.shares).length
|
|
106
|
+
: 0
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function validatePlan(plan) {
|
|
110
|
+
if (
|
|
111
|
+
!plan || plan.version !== 1 || plan.operation !== 'share_state_restore' ||
|
|
112
|
+
!isPlainPlanValue(plan.before) || !isPlainPlanValue(plan.target)
|
|
113
|
+
) {
|
|
114
|
+
throw new Error('Share restore plan is invalid')
|
|
115
|
+
}
|
|
116
|
+
if (!ROLL_FORWARD_STATUSES.has(plan.status) && !ROLL_BACK_STATUSES.has(plan.status)) {
|
|
117
|
+
throw new Error('Share restore plan status is invalid')
|
|
118
|
+
}
|
|
119
|
+
const beforeCount = countShares(plan.before)
|
|
120
|
+
const targetCount = countShares(plan.target)
|
|
121
|
+
if (plan.beforeCount !== beforeCount || plan.targetCount !== targetCount) {
|
|
122
|
+
throw new Error('Share restore plan count is invalid')
|
|
123
|
+
}
|
|
124
|
+
const beforeDigest = computeShareSnapshotDigest(plan.before)
|
|
125
|
+
const targetDigest = computeShareSnapshotDigest(plan.target)
|
|
126
|
+
if (plan.beforeDigest !== beforeDigest || plan.targetDigest !== targetDigest) {
|
|
127
|
+
throw new Error('Share restore plan digest is invalid')
|
|
128
|
+
}
|
|
129
|
+
return plan
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Restore the authoritative share state from a backup snapshot. Always runs
|
|
134
|
+
* under the share maintenance lock, persists a crash-recoverable plan file, and
|
|
135
|
+
* compensates back to the exact before-state if the apply or its verification
|
|
136
|
+
* fails. replace mode wipes local shares; merge mode keeps local-only records
|
|
137
|
+
* while backup records override same-key entries. `values` is the v1
|
|
138
|
+
* conversation-shares.json shape (`{ shares: { [shareId]: record } }`).
|
|
139
|
+
*/
|
|
140
|
+
export async function restoreShareStateSnapshot(values, options = {}) {
|
|
141
|
+
if (!isShareStorageAuthoritative()) {
|
|
142
|
+
throw new Error('Share state restore requires authoritative SQLite storage')
|
|
143
|
+
}
|
|
144
|
+
if (!isPlainPlanValue(values) || !isPlainObject(values.shares)) {
|
|
145
|
+
throw new TypeError('Share restore values must be a shares object')
|
|
146
|
+
}
|
|
147
|
+
const repository = options.repository || repositoryRequired()
|
|
148
|
+
const mode = options.mode === 'merge' ? 'merge' : 'replace'
|
|
149
|
+
const planFile = options.planFile || RESTORE_PLAN_FILE
|
|
150
|
+
return runShareMaintenance(async () => {
|
|
151
|
+
const before = repository.exportSnapshot()
|
|
152
|
+
const beforeValues = shareValuesFromRecords(before.records)
|
|
153
|
+
const merged = mode === 'merge'
|
|
154
|
+
? { ...beforeValues.shares, ...values.shares }
|
|
155
|
+
: values.shares
|
|
156
|
+
// Normalize once before writing the plan: validation failures (invalid
|
|
157
|
+
// records, duplicate ids) surface here and never leave a plan file.
|
|
158
|
+
const targetRecords = buildShareJsonSnapshot(merged).records
|
|
159
|
+
const targetValues = shareValuesFromRecords(targetRecords)
|
|
160
|
+
const beforeCount = before.count
|
|
161
|
+
const beforeDigest = before.digest
|
|
162
|
+
const targetCount = targetRecords.length
|
|
163
|
+
const targetDigest = shareSnapshotDigest(targetRecords)
|
|
164
|
+
let plan = {
|
|
165
|
+
version: 1,
|
|
166
|
+
operation: 'share_state_restore',
|
|
167
|
+
status: 'prepared',
|
|
168
|
+
createdAt: new Date().toISOString(),
|
|
169
|
+
before: beforeValues,
|
|
170
|
+
target: targetValues,
|
|
171
|
+
beforeCount,
|
|
172
|
+
beforeDigest,
|
|
173
|
+
targetCount,
|
|
174
|
+
targetDigest,
|
|
175
|
+
}
|
|
176
|
+
await writePlan(plan, planFile)
|
|
177
|
+
try {
|
|
178
|
+
plan = { ...plan, status: 'applying' }
|
|
179
|
+
await writePlan(plan, planFile)
|
|
180
|
+
repository.replaceAll(targetRecords, { expectedCount: targetCount, expectedDigest: targetDigest })
|
|
181
|
+
const after = repository.exportSnapshot()
|
|
182
|
+
if (after.count !== targetCount || after.digest !== targetDigest) {
|
|
183
|
+
throw new Error('Share restore count/digest verification failed')
|
|
184
|
+
}
|
|
185
|
+
const integrity = repository.verifyIntegrity()
|
|
186
|
+
if (!integrity.ok) throw new Error('Share restore integrity verification failed')
|
|
187
|
+
plan = { ...plan, status: 'target_applied' }
|
|
188
|
+
await writePlan(plan, planFile)
|
|
189
|
+
await fs.rm(planFile, { force: true })
|
|
190
|
+
return { shares: targetCount }
|
|
191
|
+
} catch (error) {
|
|
192
|
+
try {
|
|
193
|
+
plan = { ...plan, status: 'compensating', failedAt: new Date().toISOString() }
|
|
194
|
+
await writePlan(plan, planFile)
|
|
195
|
+
const beforeRecords = buildShareJsonSnapshot(beforeValues.shares).records
|
|
196
|
+
repository.replaceAll(beforeRecords, { expectedCount: beforeCount, expectedDigest: beforeDigest })
|
|
197
|
+
const restored = repository.exportSnapshot()
|
|
198
|
+
if (restored.count !== beforeCount || restored.digest !== beforeDigest) {
|
|
199
|
+
throw new Error('Share restore compensation verification failed', { cause: error })
|
|
200
|
+
}
|
|
201
|
+
await fs.rm(planFile, { force: true })
|
|
202
|
+
} catch (compensationError) {
|
|
203
|
+
await writePlan({
|
|
204
|
+
...plan,
|
|
205
|
+
status: 'compensation_failed',
|
|
206
|
+
compensationFailedAt: new Date().toISOString(),
|
|
207
|
+
errorName: error?.name || 'Error',
|
|
208
|
+
compensationErrorName: compensationError?.name || 'Error',
|
|
209
|
+
}, planFile).catch(() => {})
|
|
210
|
+
const retained = new Error('Share restore failed and compensation failed', { cause: compensationError })
|
|
211
|
+
retained.retainShareMaintenance = true
|
|
212
|
+
throw retained
|
|
213
|
+
}
|
|
214
|
+
throw error
|
|
215
|
+
}
|
|
216
|
+
}, { storage: options.storage, operation: 'share-restore' })
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Startup recovery for an interrupted share restore plan: roll forward to the
|
|
221
|
+
* target for prepared/applying/target_applied plans and roll back to the before
|
|
222
|
+
* state for compensating/compensation_failed plans, then remove the plan file.
|
|
223
|
+
*/
|
|
224
|
+
export async function recoverShareRestorePlan({
|
|
225
|
+
repository: repositoryOverride,
|
|
226
|
+
planFile = RESTORE_PLAN_FILE,
|
|
227
|
+
storage,
|
|
228
|
+
} = {}) {
|
|
229
|
+
let plan
|
|
230
|
+
try {
|
|
231
|
+
plan = validatePlan(JSON.parse(await fs.readFile(planFile, 'utf8')))
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (error?.code === 'ENOENT') return false
|
|
234
|
+
throw new Error(`Share restore plan is unreadable or invalid: ${error?.message || error}`, { cause: error })
|
|
235
|
+
}
|
|
236
|
+
if (!isShareStorageAuthoritative()) {
|
|
237
|
+
throw new Error('Share restore plan exists outside authoritative mode')
|
|
238
|
+
}
|
|
239
|
+
const repository = repositoryOverride || repositoryRequired()
|
|
240
|
+
return runShareMaintenance(async () => {
|
|
241
|
+
const rollBack = ROLL_BACK_STATUSES.has(plan.status)
|
|
242
|
+
const records = buildShareJsonSnapshot(rollBack ? plan.before.shares : plan.target.shares).records
|
|
243
|
+
const expectedCount = rollBack ? plan.beforeCount : plan.targetCount
|
|
244
|
+
const expectedDigest = rollBack ? plan.beforeDigest : plan.targetDigest
|
|
245
|
+
repository.replaceAll(records, { expectedCount, expectedDigest })
|
|
246
|
+
const after = repository.exportSnapshot()
|
|
247
|
+
if (after.count !== expectedCount || after.digest !== expectedDigest) {
|
|
248
|
+
throw new Error('Share restore plan recovery verification failed')
|
|
249
|
+
}
|
|
250
|
+
const integrity = repository.verifyIntegrity()
|
|
251
|
+
if (!integrity.ok) throw new Error('Share restore plan recovery integrity verification failed')
|
|
252
|
+
await fs.rm(planFile, { force: true })
|
|
253
|
+
return true
|
|
254
|
+
}, { storage, operation: 'share-restore-recovery' })
|
|
255
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { 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 { createShareRepository, normalizeShareRecord, shareSnapshotDigest } from './sqlite/share-repository.mjs'
|
|
7
|
+
import {
|
|
8
|
+
SHARE_STORAGE_PHASES,
|
|
9
|
+
configureShareService,
|
|
10
|
+
drainShareJsonMirror,
|
|
11
|
+
readShareStorageState,
|
|
12
|
+
setShareStoragePhase,
|
|
13
|
+
} from './share-service.mjs'
|
|
14
|
+
import { readSharesJsonFile } from './share-json-file.mjs'
|
|
15
|
+
import { createDefaultShareMirror } from './share-service.mjs'
|
|
16
|
+
import { storageDir } from './storage.mjs'
|
|
17
|
+
|
|
18
|
+
const DEFAULT_LOCK_TTL_MS = 60_000
|
|
19
|
+
const DEFAULT_WAIT_TIMEOUT_MS = 65_000
|
|
20
|
+
const maintenanceContext = new AsyncLocalStorage()
|
|
21
|
+
let maintenanceCount = 0
|
|
22
|
+
|
|
23
|
+
function isPlainObject(value) {
|
|
24
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validate and normalize the whole `shares/conversation-shares.json` store
|
|
29
|
+
* (top-level object keyed by shareId) into canonical share records. Fails
|
|
30
|
+
* closed on: missing sessionId, invalid permission/scope, malformed `tokens`
|
|
31
|
+
* array, inconsistent password hash fields, key/id mismatch and duplicate
|
|
32
|
+
* shareIds across keys. The count/digest are computed with the exact
|
|
33
|
+
* repository digest algorithm so the SQLite replace verification can compare
|
|
34
|
+
* digests 1:1.
|
|
35
|
+
*/
|
|
36
|
+
export function buildShareJsonSnapshot(shares) {
|
|
37
|
+
if (!isPlainObject(shares)) throw new TypeError('Share JSON store must be an object')
|
|
38
|
+
const records = []
|
|
39
|
+
const seen = new Set()
|
|
40
|
+
const diagnostics = { invalidRecords: [], duplicateShareIds: [] }
|
|
41
|
+
for (const [key, raw] of Object.entries(shares)) {
|
|
42
|
+
let record
|
|
43
|
+
try {
|
|
44
|
+
record = normalizeShareRecord(raw.id === undefined ? { ...raw, id: key } : raw)
|
|
45
|
+
} catch (error) {
|
|
46
|
+
diagnostics.invalidRecords.push(key)
|
|
47
|
+
throw error instanceof Error ? error : new TypeError(`Invalid share record: ${key}`)
|
|
48
|
+
}
|
|
49
|
+
if (seen.has(record.id)) {
|
|
50
|
+
diagnostics.duplicateShareIds.push(record.id)
|
|
51
|
+
throw new TypeError(`Duplicate share ids: ${record.id}`)
|
|
52
|
+
}
|
|
53
|
+
seen.add(record.id)
|
|
54
|
+
if (raw.id !== undefined && raw.id !== key) {
|
|
55
|
+
throw new TypeError(`Share record id mismatch: ${key}`)
|
|
56
|
+
}
|
|
57
|
+
records.push(record)
|
|
58
|
+
}
|
|
59
|
+
records.sort((left, right) => left.id.localeCompare(right.id))
|
|
60
|
+
return { records, count: records.length, digest: shareSnapshotDigest(records), diagnostics }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function writeShareCutoverBackup(snapshot, directory = path.join(storageDir, 'backups')) {
|
|
64
|
+
await fs.mkdir(directory, { recursive: true })
|
|
65
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
66
|
+
const finalPath = path.join(directory, `quickforge-shares-cutover-${stamp}.json`)
|
|
67
|
+
const temporaryPath = `${finalPath}.${process.pid}.${randomUUID()}.tmp`
|
|
68
|
+
const backup = {
|
|
69
|
+
app: 'quickforge',
|
|
70
|
+
version: 1,
|
|
71
|
+
exportedAt: new Date().toISOString(),
|
|
72
|
+
scope: 'shares',
|
|
73
|
+
includeSecrets: false,
|
|
74
|
+
shares: { count: snapshot.count, digest: snapshot.digest },
|
|
75
|
+
data: {
|
|
76
|
+
shares: Object.fromEntries(snapshot.records.map((record) => [record.id, record])),
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
await fs.writeFile(temporaryPath, `${JSON.stringify(backup, null, 2)}\n`, 'utf8')
|
|
81
|
+
const verified = JSON.parse(await fs.readFile(temporaryPath, 'utf8'))
|
|
82
|
+
const verifiedSnapshot = buildShareJsonSnapshot(verified.data?.shares)
|
|
83
|
+
if (verified.shares?.count !== snapshot.count || verified.shares?.digest !== snapshot.digest
|
|
84
|
+
|| verifiedSnapshot.count !== snapshot.count || verifiedSnapshot.digest !== snapshot.digest) {
|
|
85
|
+
throw new Error('Share cutover backup verification failed')
|
|
86
|
+
}
|
|
87
|
+
await fs.rename(temporaryPath, finalPath)
|
|
88
|
+
return finalPath
|
|
89
|
+
} catch (error) {
|
|
90
|
+
await fs.rm(temporaryPath, { force: true }).catch(() => {})
|
|
91
|
+
throw error
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ownerPid(row) {
|
|
96
|
+
const value = Number(row?.owner_pid)
|
|
97
|
+
return Number.isInteger(value) && value > 0 ? value : null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function pidAlive(pid) {
|
|
101
|
+
try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function acquireShareMaintenanceLock(storage, options = {}) {
|
|
105
|
+
const nowMs = Number(options.now?.() ?? Date.now())
|
|
106
|
+
const acquiredAt = new Date(nowMs).toISOString()
|
|
107
|
+
const expiresAt = new Date(nowMs + (options.ttlMs ?? DEFAULT_LOCK_TTL_MS)).toISOString()
|
|
108
|
+
const identity = isPlainObject(options.owner)
|
|
109
|
+
? { id: options.owner.id || `${options.owner.pid || process.pid}:${randomUUID()}`, pid: options.owner.pid || process.pid }
|
|
110
|
+
: { id: options.owner || `${process.pid}:${randomUUID()}`, pid: process.pid }
|
|
111
|
+
const isPidAlive = options.pidAlive || pidAlive
|
|
112
|
+
return storage.transaction((database) => {
|
|
113
|
+
const current = database.prepare('SELECT * FROM share_maintenance_lock WHERE singleton = 1').get()
|
|
114
|
+
if (current && current.owner === identity.id) {
|
|
115
|
+
database.prepare(`UPDATE share_maintenance_lock SET owner_pid = ?, operation = ?, heartbeat_at = ?, expires_at = ?
|
|
116
|
+
WHERE singleton = 1 AND owner = ? AND fencing = ?`)
|
|
117
|
+
.run(identity.pid, options.operation || current.operation, acquiredAt, expiresAt, identity.id, current.fencing)
|
|
118
|
+
return { owner: identity.id, ownerPid: identity.pid, fencing: Number(current.fencing), expiresAt }
|
|
119
|
+
}
|
|
120
|
+
if (current) {
|
|
121
|
+
const expired = Date.parse(current.expires_at) <= nowMs
|
|
122
|
+
const pid = ownerPid(current)
|
|
123
|
+
if (!expired || pid === null || isPidAlive(pid)) return null
|
|
124
|
+
}
|
|
125
|
+
const fencing = Number(current?.fencing || 0) + 1
|
|
126
|
+
database.prepare(`INSERT INTO share_maintenance_lock
|
|
127
|
+
(singleton, owner, owner_pid, fencing, operation, acquired_at, heartbeat_at, expires_at)
|
|
128
|
+
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
|
|
129
|
+
ON CONFLICT(singleton) DO UPDATE SET owner=excluded.owner, owner_pid=excluded.owner_pid, fencing=excluded.fencing,
|
|
130
|
+
operation=excluded.operation, acquired_at=excluded.acquired_at, heartbeat_at=excluded.heartbeat_at, expires_at=excluded.expires_at`)
|
|
131
|
+
.run(identity.id, identity.pid, fencing, options.operation || 'share-maintenance', acquiredAt, acquiredAt, expiresAt)
|
|
132
|
+
return { owner: identity.id, ownerPid: identity.pid, fencing, expiresAt }
|
|
133
|
+
}, { mode: 'immediate' })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function renewShareMaintenanceLock(storage, lease, options = {}) {
|
|
137
|
+
const nowMs = Number(options.now?.() ?? Date.now())
|
|
138
|
+
const heartbeatAt = new Date(nowMs).toISOString()
|
|
139
|
+
const expiresAt = new Date(nowMs + (options.ttlMs ?? DEFAULT_LOCK_TTL_MS)).toISOString()
|
|
140
|
+
const result = storage.prepare(`UPDATE share_maintenance_lock SET heartbeat_at = ?, expires_at = ? WHERE singleton = 1 AND owner = ? AND fencing = ?`)
|
|
141
|
+
.run(heartbeatAt, expiresAt, lease.owner, lease.fencing)
|
|
142
|
+
lease.expiresAt = expiresAt
|
|
143
|
+
return Number(result.changes) === 1
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function releaseShareMaintenanceLock(storage, lease) {
|
|
147
|
+
return Number(storage.prepare('DELETE FROM share_maintenance_lock WHERE singleton = 1 AND owner = ? AND fencing = ?').run(lease.owner, lease.fencing).changes) === 1
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function waitForShareLock(storage, options) {
|
|
151
|
+
const deadline = Date.now() + (options.waitTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS)
|
|
152
|
+
while (Date.now() < deadline) {
|
|
153
|
+
const lease = acquireShareMaintenanceLock(storage, options)
|
|
154
|
+
if (lease) return lease
|
|
155
|
+
await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs ?? 100))
|
|
156
|
+
}
|
|
157
|
+
throw new Error('Timed out waiting for share maintenance lock')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function runShareMaintenance(operation, options = {}) {
|
|
161
|
+
const existing = maintenanceContext.getStore()
|
|
162
|
+
if (existing) return operation(existing)
|
|
163
|
+
const storage = options.storage || getSqliteStorage()
|
|
164
|
+
const lease = await waitForShareLock(storage, options)
|
|
165
|
+
const interval = options.heartbeatIntervalMs ?? Math.max(10, Math.floor((options.ttlMs ?? DEFAULT_LOCK_TTL_MS) / 3))
|
|
166
|
+
let rejectHeartbeat
|
|
167
|
+
let heartbeatError = null
|
|
168
|
+
const heartbeatFailure = new Promise((_, reject) => { rejectHeartbeat = reject })
|
|
169
|
+
const timer = setInterval(() => {
|
|
170
|
+
try {
|
|
171
|
+
if (!renewShareMaintenanceLock(storage, lease, options)) throw new Error('Share maintenance lock fencing was lost')
|
|
172
|
+
} catch (error) {
|
|
173
|
+
heartbeatError = error
|
|
174
|
+
rejectHeartbeat(error)
|
|
175
|
+
}
|
|
176
|
+
}, interval)
|
|
177
|
+
timer.unref?.()
|
|
178
|
+
maintenanceCount += 1
|
|
179
|
+
try {
|
|
180
|
+
const context = { storage, lease }
|
|
181
|
+
const result = await Promise.race([maintenanceContext.run(context, () => Promise.resolve().then(() => operation(context))), heartbeatFailure])
|
|
182
|
+
if (heartbeatError) throw heartbeatError
|
|
183
|
+
return result
|
|
184
|
+
} finally {
|
|
185
|
+
clearInterval(timer)
|
|
186
|
+
releaseShareMaintenanceLock(storage, lease)
|
|
187
|
+
maintenanceCount = Math.max(0, maintenanceCount - 1)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function isShareMaintenanceActive(storage) {
|
|
192
|
+
if (!storage) {
|
|
193
|
+
try { storage = getSqliteStorage() } catch { return false }
|
|
194
|
+
}
|
|
195
|
+
return maintenanceCount > 0 || Boolean(storage.prepare('SELECT 1 AS active FROM share_maintenance_lock WHERE singleton = 1').get())
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function currentShareMaintenanceContext() {
|
|
199
|
+
return maintenanceContext.getStore() || null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* JSON → SQLite share cutover. Phase machine:
|
|
204
|
+
* json_authoritative → cutover_running → sqlite_authoritative_json_pending →
|
|
205
|
+
* authoritative, backed by `share_storage_state` and the independent
|
|
206
|
+
* `share_maintenance_lock`. Runs a stable double snapshot (+ backup reread and
|
|
207
|
+
* a third stability read), then commits the SQLite replace and the pending
|
|
208
|
+
* phase in one transaction. Any failure before pending returns to
|
|
209
|
+
* json_authoritative; after pending the phase stays pending (fail-closed,
|
|
210
|
+
* recoverable on the next start by draining the mirror).
|
|
211
|
+
*/
|
|
212
|
+
export async function initializeShareCutover(options = {}) {
|
|
213
|
+
const storage = options.storage || getSqliteStorage()
|
|
214
|
+
const repository = options.repository || createShareRepository(storage)
|
|
215
|
+
configureShareService({ repository, mirror: options.mirror || createDefaultShareMirror() })
|
|
216
|
+
return runShareMaintenance(async () => {
|
|
217
|
+
const current = readShareStorageState()
|
|
218
|
+
if (current.phase === SHARE_STORAGE_PHASES.JSON_PENDING) {
|
|
219
|
+
const integrity = repository.verifyIntegrity({ quickCheck: true })
|
|
220
|
+
if (!integrity.ok) throw new Error('Share state pending integrity verification failed')
|
|
221
|
+
const drained = await drainShareJsonMirror()
|
|
222
|
+
if (drained.pending === 0) setShareStoragePhase(SHARE_STORAGE_PHASES.AUTHORITATIVE, { shareCount: integrity.count, digest: integrity.digest, backupFile: current.backupFile })
|
|
223
|
+
return readShareStorageState()
|
|
224
|
+
}
|
|
225
|
+
if (current.phase === SHARE_STORAGE_PHASES.AUTHORITATIVE) {
|
|
226
|
+
const integrity = repository.verifyIntegrity({ quickCheck: true })
|
|
227
|
+
if (!integrity.ok) throw new Error('Share state authoritative integrity verification failed')
|
|
228
|
+
await drainShareJsonMirror()
|
|
229
|
+
return readShareStorageState()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (current.phase === SHARE_STORAGE_PHASES.CUTOVER_RUNNING && current.backupFile) {
|
|
233
|
+
setShareStoragePhase(SHARE_STORAGE_PHASES.JSON_AUTHORITATIVE, {
|
|
234
|
+
backupFile: current.backupFile,
|
|
235
|
+
diagnostic: { operation: 'cutover_recovery', recoveredFrom: SHARE_STORAGE_PHASES.CUTOVER_RUNNING },
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let backupFile = current.backupFile
|
|
240
|
+
try {
|
|
241
|
+
const readJson = options.readJson || readSharesJsonFile
|
|
242
|
+
const first = buildShareJsonSnapshot(await readJson())
|
|
243
|
+
const second = buildShareJsonSnapshot(await readJson())
|
|
244
|
+
if (first.count !== second.count || first.digest !== second.digest) {
|
|
245
|
+
throw new Error('Share JSON source changed during cutover double read')
|
|
246
|
+
}
|
|
247
|
+
if (!backupFile) backupFile = await writeShareCutoverBackup(first, options.backupDirectory)
|
|
248
|
+
const third = buildShareJsonSnapshot(await readJson())
|
|
249
|
+
if (first.count !== third.count || first.digest !== third.digest) {
|
|
250
|
+
throw new Error('Share JSON source changed before cutover commit')
|
|
251
|
+
}
|
|
252
|
+
setShareStoragePhase(SHARE_STORAGE_PHASES.CUTOVER_RUNNING, {
|
|
253
|
+
shareCount: first.count,
|
|
254
|
+
digest: first.digest,
|
|
255
|
+
backupFile,
|
|
256
|
+
diagnostic: first.diagnostics,
|
|
257
|
+
})
|
|
258
|
+
const pendingValues = {
|
|
259
|
+
phase: SHARE_STORAGE_PHASES.JSON_PENDING,
|
|
260
|
+
backupFile,
|
|
261
|
+
diagnostic: first.diagnostics,
|
|
262
|
+
}
|
|
263
|
+
repository.replaceAll(first.records, {
|
|
264
|
+
expectedCount: first.count,
|
|
265
|
+
expectedDigest: first.digest,
|
|
266
|
+
storageState: pendingValues,
|
|
267
|
+
})
|
|
268
|
+
const integrity = repository.verifyIntegrity({ quickCheck: true })
|
|
269
|
+
if (!integrity.ok || integrity.count !== first.count || integrity.digest !== first.digest) {
|
|
270
|
+
throw new Error('Share SQLite replace verification failed')
|
|
271
|
+
}
|
|
272
|
+
// Refresh the cached phase so the mirror drain runs in pending mode.
|
|
273
|
+
readShareStorageState()
|
|
274
|
+
const drained = await drainShareJsonMirror()
|
|
275
|
+
if (drained.pending === 0) {
|
|
276
|
+
setShareStoragePhase(SHARE_STORAGE_PHASES.AUTHORITATIVE, {
|
|
277
|
+
shareCount: integrity.count,
|
|
278
|
+
digest: integrity.digest,
|
|
279
|
+
backupFile,
|
|
280
|
+
diagnostic: first.diagnostics,
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
return readShareStorageState()
|
|
284
|
+
} catch (error) {
|
|
285
|
+
const state = readShareStorageState()
|
|
286
|
+
if (![SHARE_STORAGE_PHASES.JSON_PENDING, SHARE_STORAGE_PHASES.AUTHORITATIVE].includes(state.phase)) {
|
|
287
|
+
setShareStoragePhase(SHARE_STORAGE_PHASES.JSON_AUTHORITATIVE, {
|
|
288
|
+
backupFile,
|
|
289
|
+
diagnostic: { operation: 'cutover', errorName: error?.name || 'Error', error: error?.message || String(error) },
|
|
290
|
+
})
|
|
291
|
+
return readShareStorageState()
|
|
292
|
+
}
|
|
293
|
+
throw error
|
|
294
|
+
}
|
|
295
|
+
}, { ...options, storage, operation: 'share-cutover' })
|
|
296
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { ensureStorage, storageDir } from './storage.mjs'
|
|
4
|
+
|
|
5
|
+
// F10 mirror/backup file: `shares/conversation-shares.json` stays as the
|
|
6
|
+
// best-effort JSON mirror (and cutover source) once SQLite is authoritative.
|
|
7
|
+
export function sharesJsonPath() {
|
|
8
|
+
return path.join(storageDir, 'shares', 'conversation-shares.json')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function ensureSharesJsonFile() {
|
|
12
|
+
await ensureStorage()
|
|
13
|
+
await fs.mkdir(path.dirname(sharesJsonPath()), { recursive: true })
|
|
14
|
+
try {
|
|
15
|
+
await fs.access(sharesJsonPath())
|
|
16
|
+
} catch {
|
|
17
|
+
await fs.writeFile(sharesJsonPath(), '{}\n', 'utf8')
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function readSharesJsonFile() {
|
|
22
|
+
await ensureSharesJsonFile()
|
|
23
|
+
try {
|
|
24
|
+
const raw = await fs.readFile(sharesJsonPath(), 'utf8')
|
|
25
|
+
const text = raw.trimStart()
|
|
26
|
+
const parsed = text ? JSON.parse(text) : {}
|
|
27
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error?.code === 'ENOENT') return {}
|
|
30
|
+
throw error
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function writeSharesJsonFile(data) {
|
|
35
|
+
await ensureSharesJsonFile()
|
|
36
|
+
const finalPath = sharesJsonPath()
|
|
37
|
+
const temporaryPath = `${finalPath}.${process.pid}.tmp`
|
|
38
|
+
try {
|
|
39
|
+
await fs.writeFile(temporaryPath, `${JSON.stringify(data || {}, null, 2)}\n`, 'utf8')
|
|
40
|
+
await fs.rename(temporaryPath, finalPath)
|
|
41
|
+
} catch (error) {
|
|
42
|
+
await fs.rm(temporaryPath, { force: true }).catch(() => {})
|
|
43
|
+
throw error
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Materialize one mirror entry into the whole-file JSON store.
|
|
48
|
+
export async function materializeShareJsonEntry(entry) {
|
|
49
|
+
if (!entry || typeof entry !== 'object') throw new Error('Share mirror entry is invalid')
|
|
50
|
+
const data = await readSharesJsonFile()
|
|
51
|
+
if (entry.operation === 'delete') {
|
|
52
|
+
if (!entry.shareId) throw new Error('Share mirror delete requires a shareId')
|
|
53
|
+
delete data[entry.shareId]
|
|
54
|
+
} else if (entry.operation === 'upsert' && entry.record?.id) {
|
|
55
|
+
data[entry.record.id] = entry.record
|
|
56
|
+
} else {
|
|
57
|
+
throw new Error('Share mirror entry is invalid')
|
|
58
|
+
}
|
|
59
|
+
await writeSharesJsonFile(data)
|
|
60
|
+
}
|