@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.
Files changed (87) hide show
  1. package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
  2. package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
  3. package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
  4. package/dist/assets/{PluginsPage-BDKYfLna.js → PluginsPage-mJkTmVVV.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-DSy66Tuw.js → SettingsWorkspacePage-7lQ0Wl63.js} +391 -391
  7. package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
  8. package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
  9. package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
  10. package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
  11. package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
  12. package/dist/assets/index-D-J_8Smf.css +3 -0
  13. package/dist/assets/index-DFASc15l.js +66 -0
  14. package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
  15. package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
  16. package/dist/assets/{mcp-servers-dialog-BEV4_TXC.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
  17. package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
  18. package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
  19. package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
  20. package/dist/assets/{skills-dialog-BTJ7mzk_.js → skills-dialog-DIzZvQqW.js} +3 -3
  21. package/dist/index.html +8 -8
  22. package/package.json +1 -1
  23. package/server/acp/server.mjs +17 -1
  24. package/server/agent-manager.mjs +251 -31
  25. package/server/ai-http-logger.mjs +162 -19
  26. package/server/ai-provider-options.mjs +4 -1
  27. package/server/auto-archive.mjs +42 -29
  28. package/server/auto-compaction.mjs +43 -1
  29. package/server/cloud/auto-approval.mjs +155 -0
  30. package/server/cloud/client.mjs +5 -0
  31. package/server/cloud/config.mjs +8 -16
  32. package/server/cloud/qf-agent-process.mjs +163 -3
  33. package/server/cloud/service-config.mjs +1 -1
  34. package/server/context-usage.mjs +11 -4
  35. package/server/index.mjs +69 -13
  36. package/server/lan-access-backup.mjs +256 -0
  37. package/server/lan-access-cutover.mjs +306 -0
  38. package/server/lan-access-json-file.mjs +75 -0
  39. package/server/lan-access-service.mjs +155 -0
  40. package/server/lan-access-store.mjs +110 -9
  41. package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
  42. package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
  43. package/server/maintenance/downgrade-share-v1.mjs +101 -0
  44. package/server/maintenance/export-lan-access-v1.mjs +80 -0
  45. package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
  46. package/server/maintenance/export-session-state-v1.mjs +67 -0
  47. package/server/maintenance/export-share-v1.mjs +77 -0
  48. package/server/routes/agent.mjs +37 -4
  49. package/server/routes/backup.mjs +221 -39
  50. package/server/routes/cloud.mjs +28 -1
  51. package/server/routes/scheduled-tasks.mjs +347 -134
  52. package/server/routes/shares.mjs +4 -2
  53. package/server/routes/storage.mjs +138 -8
  54. package/server/routes/workspace.mjs +265 -4
  55. package/server/scheduled-runs-backup.mjs +235 -0
  56. package/server/scheduled-runs-cutover.mjs +497 -0
  57. package/server/scheduled-task-runs-service.mjs +279 -0
  58. package/server/session-index-service.mjs +495 -0
  59. package/server/session-state-backup.mjs +277 -0
  60. package/server/session-state-cutover.mjs +370 -0
  61. package/server/session-state-service.mjs +709 -0
  62. package/server/share-backup.mjs +255 -0
  63. package/server/share-cutover.mjs +296 -0
  64. package/server/share-json-file.mjs +60 -0
  65. package/server/share-service.mjs +163 -0
  66. package/server/share-store.mjs +196 -19
  67. package/server/skills.mjs +0 -1
  68. package/server/sqlite/database.mjs +233 -0
  69. package/server/sqlite/lan-access-repository.mjs +652 -0
  70. package/server/sqlite/migrations.mjs +577 -0
  71. package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
  72. package/server/sqlite/session-index-repository.mjs +297 -0
  73. package/server/sqlite/session-state-repository.mjs +786 -0
  74. package/server/sqlite/share-repository.mjs +854 -0
  75. package/server/storage.mjs +317 -1
  76. package/dist/assets/AgentProfilesPage-Dl5Peqov.js +0 -1
  77. package/dist/assets/ChatPanelHost-D3987TUJ.js +0 -307
  78. package/dist/assets/CloudAccountSettingsPage-BG5Nn3bq.js +0 -1
  79. package/dist/assets/ScheduledTasksPage--GAki3lB.js +0 -2
  80. package/dist/assets/ShareLinksSettingsPage-BQQMsvJo.js +0 -1
  81. package/dist/assets/SharedConversationPage-B9BUVCri.js +0 -1
  82. package/dist/assets/TerminalDock-CT1nYo7P.js +0 -2
  83. package/dist/assets/WorkspaceInspector-BnfDLf9a.js +0 -36
  84. package/dist/assets/index-1ei6nhs-.css +0 -3
  85. package/dist/assets/index-BivLuZvX.js +0 -65
  86. package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
  87. /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
+ }