@shawnstack/quickforge 1.7.7 → 1.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/bin/quickforge.mjs +10 -1
  2. package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
  3. package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
  4. package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
  5. package/dist/assets/{PluginsPage-CPE5tbbf.js → PluginsPage-mJkTmVVV.js} +1 -1
  6. package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
  7. package/dist/assets/{SettingsWorkspacePage-BE2T4d3P.js → SettingsWorkspacePage-7lQ0Wl63.js} +389 -389
  8. package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
  9. package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
  10. package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
  11. package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
  12. package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
  13. package/dist/assets/{index-BiCTgUlY.css → index-D-J_8Smf.css} +1 -1
  14. package/dist/assets/index-DFASc15l.js +66 -0
  15. package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
  16. package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
  17. package/dist/assets/{mcp-servers-dialog-GY7sdfIr.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
  18. package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
  19. package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
  20. package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
  21. package/dist/assets/{skills-dialog-C1fJ1Jl7.js → skills-dialog-DIzZvQqW.js} +3 -3
  22. package/dist/index.html +8 -8
  23. package/package.json +1 -1
  24. package/server/acp/server.mjs +17 -1
  25. package/server/agent-manager.mjs +251 -31
  26. package/server/ai-http-logger.mjs +162 -19
  27. package/server/ai-provider-options.mjs +4 -1
  28. package/server/auto-archive.mjs +42 -29
  29. package/server/cloud/auto-approval.mjs +155 -0
  30. package/server/cloud/client.mjs +5 -0
  31. package/server/cloud/qf-agent-process.mjs +163 -3
  32. package/server/cloud/service-config.mjs +1 -1
  33. package/server/index.mjs +69 -13
  34. package/server/lan-access-backup.mjs +256 -0
  35. package/server/lan-access-cutover.mjs +306 -0
  36. package/server/lan-access-json-file.mjs +75 -0
  37. package/server/lan-access-service.mjs +155 -0
  38. package/server/lan-access-store.mjs +110 -9
  39. package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
  40. package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
  41. package/server/maintenance/downgrade-share-v1.mjs +101 -0
  42. package/server/maintenance/export-lan-access-v1.mjs +80 -0
  43. package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
  44. package/server/maintenance/export-session-state-v1.mjs +67 -0
  45. package/server/maintenance/export-share-v1.mjs +77 -0
  46. package/server/public-api.mjs +21 -2
  47. package/server/routes/agent.mjs +37 -4
  48. package/server/routes/backup.mjs +221 -39
  49. package/server/routes/cloud.mjs +28 -1
  50. package/server/routes/scheduled-tasks.mjs +347 -134
  51. package/server/routes/shares.mjs +4 -2
  52. package/server/routes/storage.mjs +138 -8
  53. package/server/routes/workspace.mjs +265 -4
  54. package/server/scheduled-runs-backup.mjs +235 -0
  55. package/server/scheduled-runs-cutover.mjs +497 -0
  56. package/server/scheduled-task-runs-service.mjs +279 -0
  57. package/server/session-index-service.mjs +495 -0
  58. package/server/session-state-backup.mjs +277 -0
  59. package/server/session-state-cutover.mjs +370 -0
  60. package/server/session-state-service.mjs +709 -0
  61. package/server/share-backup.mjs +255 -0
  62. package/server/share-cutover.mjs +296 -0
  63. package/server/share-json-file.mjs +60 -0
  64. package/server/share-service.mjs +163 -0
  65. package/server/share-store.mjs +196 -19
  66. package/server/skills.mjs +0 -1
  67. package/server/sqlite/database.mjs +233 -0
  68. package/server/sqlite/lan-access-repository.mjs +652 -0
  69. package/server/sqlite/migrations.mjs +577 -0
  70. package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
  71. package/server/sqlite/session-index-repository.mjs +297 -0
  72. package/server/sqlite/session-state-repository.mjs +786 -0
  73. package/server/sqlite/share-repository.mjs +854 -0
  74. package/server/storage.mjs +317 -1
  75. package/dist/assets/AgentProfilesPage-BfA_sQ3A.js +0 -1
  76. package/dist/assets/ChatPanelHost-Dul1ahKj.js +0 -307
  77. package/dist/assets/CloudAccountSettingsPage-qZh0lTUN.js +0 -1
  78. package/dist/assets/ScheduledTasksPage-PTxL_PPg.js +0 -2
  79. package/dist/assets/ShareLinksSettingsPage-Bl8fP62X.js +0 -1
  80. package/dist/assets/SharedConversationPage-BK9XJnEe.js +0 -1
  81. package/dist/assets/TerminalDock-CL7HU9DI.js +0 -2
  82. package/dist/assets/WorkspaceInspector-MNBLIKMn.js +0 -36
  83. package/dist/assets/index-BTPzyq7d.js +0 -65
  84. package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
  85. /package/dist/assets/{plugin-api-Uu2xAB4d.js → plugin-api-BrdQeM4B.js} +0 -0
@@ -0,0 +1,306 @@
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 { createLanAccessRepository, lanAccessConfigDigest, normalizeLanAccessConfig } from './sqlite/lan-access-repository.mjs'
7
+ import {
8
+ LAN_ACCESS_STORAGE_PHASES,
9
+ configureLanAccessService,
10
+ createDefaultLanAccessMirror,
11
+ drainLanAccessJsonMirror,
12
+ readLanAccessStorageState,
13
+ setLanAccessStoragePhase,
14
+ } from './lan-access-service.mjs'
15
+ import {
16
+ defaultLanAccessConfig,
17
+ ensureLanAccessJsonFile,
18
+ readLanAccessJsonFile,
19
+ writeLanAccessJsonFile,
20
+ } from './lan-access-json-file.mjs'
21
+ import { storageDir } from './storage.mjs'
22
+
23
+ const DEFAULT_LOCK_TTL_MS = 60_000
24
+ const DEFAULT_WAIT_TIMEOUT_MS = 65_000
25
+ const maintenanceContext = new AsyncLocalStorage()
26
+ let maintenanceCount = 0
27
+
28
+ function isPlainObject(value) {
29
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
30
+ }
31
+
32
+ /**
33
+ * Validate and normalize the whole LAN access JSON config (`security/
34
+ * lan-access.json`) into a canonical config. Fails closed on: malformed config
35
+ * object, password hash/salt inconsistency, enabled-without-password, malformed
36
+ * `tokens` array. Expired tokens are pruned exactly like `pruneTokens` on the
37
+ * JSON side and the ≤100 cap keeps the newest entries. The count/digest are
38
+ * computed with the exact repository digest algorithm so the SQLite replace
39
+ * verification can compare digests 1:1.
40
+ */
41
+ export function buildLanAccessJsonSnapshot(input, { now = () => new Date().toISOString() } = {}) {
42
+ if (!isPlainObject(input)) throw new TypeError('LAN access JSON config must be an object')
43
+ const config = normalizeLanAccessConfig(
44
+ input.updatedAt ? input : { ...input, updatedAt: now() },
45
+ { now },
46
+ )
47
+ return {
48
+ config,
49
+ tokenCount: config.tokens.length,
50
+ digest: lanAccessConfigDigest(config),
51
+ diagnostics: { source: 'security/lan-access.json' },
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Default cutover JSON source reader. Missing files (ENOENT) and unreadable or
57
+ * corrupt JSON both fall back to the default disabled config — the same
58
+ * semantics lan-access-store applies to a missing file — so the server can
59
+ * continue and LAN access simply starts disabled (fail-closed). The fallback is
60
+ * materialized so repeated reads stay stable for the double-snapshot check.
61
+ */
62
+ export async function readLanAccessJsonSource() {
63
+ await ensureLanAccessJsonFile()
64
+ try {
65
+ return await readLanAccessJsonFile()
66
+ } catch {
67
+ const fallback = defaultLanAccessConfig()
68
+ await writeLanAccessJsonFile(fallback)
69
+ return fallback
70
+ }
71
+ }
72
+
73
+ async function writeLanAccessCutoverBackup(snapshot, directory = path.join(storageDir, 'backups')) {
74
+ await fs.mkdir(directory, { recursive: true })
75
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
76
+ const finalPath = path.join(directory, `quickforge-lan-access-cutover-${stamp}.json`)
77
+ const temporaryPath = `${finalPath}.${process.pid}.${randomUUID()}.tmp`
78
+ const backup = {
79
+ app: 'quickforge',
80
+ version: 1,
81
+ exportedAt: new Date().toISOString(),
82
+ scope: 'lan-access',
83
+ includeSecrets: false,
84
+ lanAccess: { tokenCount: snapshot.tokenCount, digest: snapshot.digest },
85
+ data: {
86
+ lanAccess: snapshot.config,
87
+ },
88
+ }
89
+ try {
90
+ await fs.writeFile(temporaryPath, `${JSON.stringify(backup, null, 2)}\n`, 'utf8')
91
+ const verified = JSON.parse(await fs.readFile(temporaryPath, 'utf8'))
92
+ const verifiedSnapshot = buildLanAccessJsonSnapshot(verified.data?.lanAccess)
93
+ if (verified.lanAccess?.tokenCount !== snapshot.tokenCount || verified.lanAccess?.digest !== snapshot.digest
94
+ || verifiedSnapshot.tokenCount !== snapshot.tokenCount || verifiedSnapshot.digest !== snapshot.digest) {
95
+ throw new Error('LAN access cutover backup verification failed')
96
+ }
97
+ await fs.rename(temporaryPath, finalPath)
98
+ return finalPath
99
+ } catch (error) {
100
+ await fs.rm(temporaryPath, { force: true }).catch(() => {})
101
+ throw error
102
+ }
103
+ }
104
+
105
+ function ownerPid(row) {
106
+ const value = Number(row?.owner_pid)
107
+ return Number.isInteger(value) && value > 0 ? value : null
108
+ }
109
+
110
+ function pidAlive(pid) {
111
+ try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
112
+ }
113
+
114
+ export function acquireLanAccessMaintenanceLock(storage, options = {}) {
115
+ const nowMs = Number(options.now?.() ?? Date.now())
116
+ const acquiredAt = new Date(nowMs).toISOString()
117
+ const expiresAt = new Date(nowMs + (options.ttlMs ?? DEFAULT_LOCK_TTL_MS)).toISOString()
118
+ const identity = isPlainObject(options.owner)
119
+ ? { id: options.owner.id || `${options.owner.pid || process.pid}:${randomUUID()}`, pid: options.owner.pid || process.pid }
120
+ : { id: options.owner || `${process.pid}:${randomUUID()}`, pid: process.pid }
121
+ const isPidAlive = options.pidAlive || pidAlive
122
+ return storage.transaction((database) => {
123
+ const current = database.prepare('SELECT * FROM lan_access_maintenance_lock WHERE singleton = 1').get()
124
+ if (current && current.owner === identity.id) {
125
+ database.prepare(`UPDATE lan_access_maintenance_lock SET owner_pid = ?, operation = ?, heartbeat_at = ?, expires_at = ?
126
+ WHERE singleton = 1 AND owner = ? AND fencing = ?`)
127
+ .run(identity.pid, options.operation || current.operation, acquiredAt, expiresAt, identity.id, current.fencing)
128
+ return { owner: identity.id, ownerPid: identity.pid, fencing: Number(current.fencing), expiresAt }
129
+ }
130
+ if (current) {
131
+ const expired = Date.parse(current.expires_at) <= nowMs
132
+ const pid = ownerPid(current)
133
+ if (!expired || pid === null || isPidAlive(pid)) return null
134
+ }
135
+ const fencing = Number(current?.fencing || 0) + 1
136
+ database.prepare(`INSERT INTO lan_access_maintenance_lock
137
+ (singleton, owner, owner_pid, fencing, operation, acquired_at, heartbeat_at, expires_at)
138
+ VALUES (1, ?, ?, ?, ?, ?, ?, ?)
139
+ ON CONFLICT(singleton) DO UPDATE SET owner=excluded.owner, owner_pid=excluded.owner_pid, fencing=excluded.fencing,
140
+ operation=excluded.operation, acquired_at=excluded.acquired_at, heartbeat_at=excluded.heartbeat_at, expires_at=excluded.expires_at`)
141
+ .run(identity.id, identity.pid, fencing, options.operation || 'lan-access-maintenance', acquiredAt, acquiredAt, expiresAt)
142
+ return { owner: identity.id, ownerPid: identity.pid, fencing, expiresAt }
143
+ }, { mode: 'immediate' })
144
+ }
145
+
146
+ export function renewLanAccessMaintenanceLock(storage, lease, options = {}) {
147
+ const nowMs = Number(options.now?.() ?? Date.now())
148
+ const heartbeatAt = new Date(nowMs).toISOString()
149
+ const expiresAt = new Date(nowMs + (options.ttlMs ?? DEFAULT_LOCK_TTL_MS)).toISOString()
150
+ const result = storage.prepare(`UPDATE lan_access_maintenance_lock SET heartbeat_at = ?, expires_at = ? WHERE singleton = 1 AND owner = ? AND fencing = ?`)
151
+ .run(heartbeatAt, expiresAt, lease.owner, lease.fencing)
152
+ lease.expiresAt = expiresAt
153
+ return Number(result.changes) === 1
154
+ }
155
+
156
+ export function releaseLanAccessMaintenanceLock(storage, lease) {
157
+ return Number(storage.prepare('DELETE FROM lan_access_maintenance_lock WHERE singleton = 1 AND owner = ? AND fencing = ?').run(lease.owner, lease.fencing).changes) === 1
158
+ }
159
+
160
+ async function waitForLanAccessLock(storage, options) {
161
+ const deadline = Date.now() + (options.waitTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS)
162
+ while (Date.now() < deadline) {
163
+ const lease = acquireLanAccessMaintenanceLock(storage, options)
164
+ if (lease) return lease
165
+ await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs ?? 100))
166
+ }
167
+ throw new Error('Timed out waiting for LAN access maintenance lock')
168
+ }
169
+
170
+ export async function runLanAccessMaintenance(operation, options = {}) {
171
+ const existing = maintenanceContext.getStore()
172
+ if (existing) return operation(existing)
173
+ const storage = options.storage || getSqliteStorage()
174
+ const lease = await waitForLanAccessLock(storage, options)
175
+ const interval = options.heartbeatIntervalMs ?? Math.max(10, Math.floor((options.ttlMs ?? DEFAULT_LOCK_TTL_MS) / 3))
176
+ let rejectHeartbeat
177
+ let heartbeatError = null
178
+ const heartbeatFailure = new Promise((_, reject) => { rejectHeartbeat = reject })
179
+ const timer = setInterval(() => {
180
+ try {
181
+ if (!renewLanAccessMaintenanceLock(storage, lease, options)) throw new Error('LAN access maintenance lock fencing was lost')
182
+ } catch (error) {
183
+ heartbeatError = error
184
+ rejectHeartbeat(error)
185
+ }
186
+ }, interval)
187
+ timer.unref?.()
188
+ maintenanceCount += 1
189
+ try {
190
+ const context = { storage, lease }
191
+ const result = await Promise.race([maintenanceContext.run(context, () => Promise.resolve().then(() => operation(context))), heartbeatFailure])
192
+ if (heartbeatError) throw heartbeatError
193
+ return result
194
+ } finally {
195
+ clearInterval(timer)
196
+ releaseLanAccessMaintenanceLock(storage, lease)
197
+ maintenanceCount = Math.max(0, maintenanceCount - 1)
198
+ }
199
+ }
200
+
201
+ export function isLanAccessMaintenanceActive(storage) {
202
+ if (!storage) {
203
+ try { storage = getSqliteStorage() } catch { return false }
204
+ }
205
+ return maintenanceCount > 0 || Boolean(storage.prepare('SELECT 1 AS active FROM lan_access_maintenance_lock WHERE singleton = 1').get())
206
+ }
207
+
208
+ export function currentLanAccessMaintenanceContext() {
209
+ return maintenanceContext.getStore() || null
210
+ }
211
+
212
+ /**
213
+ * JSON → SQLite LAN access cutover. Phase machine:
214
+ * json_authoritative → cutover_running → sqlite_authoritative_json_pending →
215
+ * authoritative, backed by `lan_access_storage_state` and the independent
216
+ * `lan_access_maintenance_lock`. Runs a stable double snapshot (+ backup reread
217
+ * and a third stability read), then commits the SQLite replace and the pending
218
+ * phase in one transaction. Any failure before pending returns to
219
+ * json_authoritative; after pending the phase stays pending (fail-closed,
220
+ * recoverable on the next start by draining the mirror).
221
+ */
222
+ export async function initializeLanAccessCutover(options = {}) {
223
+ const storage = options.storage || getSqliteStorage()
224
+ const repository = options.repository || createLanAccessRepository(storage)
225
+ configureLanAccessService({ repository, mirror: options.mirror || createDefaultLanAccessMirror() })
226
+ return runLanAccessMaintenance(async () => {
227
+ const current = readLanAccessStorageState()
228
+ if (current.phase === LAN_ACCESS_STORAGE_PHASES.JSON_PENDING) {
229
+ const integrity = repository.verifyIntegrity({ quickCheck: true })
230
+ if (!integrity.ok) throw new Error('LAN access state pending integrity verification failed')
231
+ const drained = await drainLanAccessJsonMirror()
232
+ if (drained.pending === 0) setLanAccessStoragePhase(LAN_ACCESS_STORAGE_PHASES.AUTHORITATIVE, { lanTokenCount: integrity.count, digest: integrity.digest, backupFile: current.backupFile })
233
+ return readLanAccessStorageState()
234
+ }
235
+ if (current.phase === LAN_ACCESS_STORAGE_PHASES.AUTHORITATIVE) {
236
+ const integrity = repository.verifyIntegrity({ quickCheck: true })
237
+ if (!integrity.ok) throw new Error('LAN access state authoritative integrity verification failed')
238
+ await drainLanAccessJsonMirror()
239
+ return readLanAccessStorageState()
240
+ }
241
+
242
+ if (current.phase === LAN_ACCESS_STORAGE_PHASES.CUTOVER_RUNNING && current.backupFile) {
243
+ setLanAccessStoragePhase(LAN_ACCESS_STORAGE_PHASES.JSON_AUTHORITATIVE, {
244
+ backupFile: current.backupFile,
245
+ diagnostic: { operation: 'cutover_recovery', recoveredFrom: LAN_ACCESS_STORAGE_PHASES.CUTOVER_RUNNING },
246
+ })
247
+ }
248
+
249
+ let backupFile = current.backupFile
250
+ try {
251
+ const readJson = options.readJson || readLanAccessJsonSource
252
+ const first = buildLanAccessJsonSnapshot(await readJson())
253
+ const second = buildLanAccessJsonSnapshot(await readJson())
254
+ if (first.tokenCount !== second.tokenCount || first.digest !== second.digest) {
255
+ throw new Error('LAN access JSON source changed during cutover double read')
256
+ }
257
+ if (!backupFile) backupFile = await writeLanAccessCutoverBackup(first, options.backupDirectory)
258
+ const third = buildLanAccessJsonSnapshot(await readJson())
259
+ if (first.tokenCount !== third.tokenCount || first.digest !== third.digest) {
260
+ throw new Error('LAN access JSON source changed before cutover commit')
261
+ }
262
+ setLanAccessStoragePhase(LAN_ACCESS_STORAGE_PHASES.CUTOVER_RUNNING, {
263
+ lanTokenCount: first.tokenCount,
264
+ digest: first.digest,
265
+ backupFile,
266
+ diagnostic: first.diagnostics,
267
+ })
268
+ const pendingValues = {
269
+ phase: LAN_ACCESS_STORAGE_PHASES.JSON_PENDING,
270
+ backupFile,
271
+ diagnostic: first.diagnostics,
272
+ }
273
+ repository.replaceAll(first.config, {
274
+ expectedCount: first.tokenCount,
275
+ expectedDigest: first.digest,
276
+ storageState: pendingValues,
277
+ })
278
+ const integrity = repository.verifyIntegrity({ quickCheck: true })
279
+ if (!integrity.ok || integrity.count !== first.tokenCount || integrity.digest !== first.digest) {
280
+ throw new Error('LAN access SQLite replace verification failed')
281
+ }
282
+ // Refresh the cached phase so the mirror drain runs in pending mode.
283
+ readLanAccessStorageState()
284
+ const drained = await drainLanAccessJsonMirror()
285
+ if (drained.pending === 0) {
286
+ setLanAccessStoragePhase(LAN_ACCESS_STORAGE_PHASES.AUTHORITATIVE, {
287
+ lanTokenCount: integrity.count,
288
+ digest: integrity.digest,
289
+ backupFile,
290
+ diagnostic: first.diagnostics,
291
+ })
292
+ }
293
+ return readLanAccessStorageState()
294
+ } catch (error) {
295
+ const state = readLanAccessStorageState()
296
+ if (![LAN_ACCESS_STORAGE_PHASES.JSON_PENDING, LAN_ACCESS_STORAGE_PHASES.AUTHORITATIVE].includes(state.phase)) {
297
+ setLanAccessStoragePhase(LAN_ACCESS_STORAGE_PHASES.JSON_AUTHORITATIVE, {
298
+ backupFile,
299
+ diagnostic: { operation: 'cutover', errorName: error?.name || 'Error', error: error?.message || String(error) },
300
+ })
301
+ return readLanAccessStorageState()
302
+ }
303
+ throw error
304
+ }
305
+ }, { ...options, storage, operation: 'lan-access-cutover' })
306
+ }
@@ -0,0 +1,75 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { ensureStorage, storageDir } from './storage.mjs'
4
+ import { normalizeLanAccessConfig } from './sqlite/lan-access-repository.mjs'
5
+
6
+ // F11 mirror file: `security/lan-access.json` stays as the best-effort JSON
7
+ // mirror (and cutover source) once SQLite is authoritative. Writes use an
8
+ // atomic tmp+rename and always normalize through the repository so the file
9
+ // shape exactly matches what lan-access-store.mjs writes.
10
+ export function lanAccessJsonPath() {
11
+ return path.join(storageDir, 'security', 'lan-access.json')
12
+ }
13
+
14
+ export function defaultLanAccessConfig() {
15
+ return {
16
+ enabled: false,
17
+ passwordHash: undefined,
18
+ passwordSalt: undefined,
19
+ passwordVersion: undefined,
20
+ authVersion: 1,
21
+ sessionTtlHours: 12,
22
+ updatedAt: new Date().toISOString(),
23
+ tokens: [],
24
+ }
25
+ }
26
+
27
+ export async function ensureLanAccessJsonFile() {
28
+ await ensureStorage()
29
+ await fs.mkdir(path.dirname(lanAccessJsonPath()), { recursive: true })
30
+ try {
31
+ await fs.access(lanAccessJsonPath())
32
+ } catch {
33
+ await fs.writeFile(lanAccessJsonPath(), `${JSON.stringify(normalizeLanAccessConfig(defaultLanAccessConfig()), null, 2)}\n`, 'utf8')
34
+ }
35
+ }
36
+
37
+ export async function readLanAccessJsonFile() {
38
+ await ensureLanAccessJsonFile()
39
+ try {
40
+ const raw = await fs.readFile(lanAccessJsonPath(), 'utf8')
41
+ const text = raw.trimStart()
42
+ const parsed = text ? JSON.parse(text) : {}
43
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
44
+ } catch (error) {
45
+ if (error?.code === 'ENOENT') return defaultLanAccessConfig()
46
+ throw error
47
+ }
48
+ }
49
+
50
+ export async function writeLanAccessJsonFile(config) {
51
+ await ensureLanAccessJsonFile()
52
+ const finalPath = lanAccessJsonPath()
53
+ const temporaryPath = `${finalPath}.${process.pid}.tmp`
54
+ try {
55
+ await fs.writeFile(temporaryPath, `${JSON.stringify(normalizeLanAccessConfig(config), null, 2)}\n`, 'utf8')
56
+ await fs.rename(temporaryPath, finalPath)
57
+ } catch (error) {
58
+ await fs.rm(temporaryPath, { force: true }).catch(() => {})
59
+ throw error
60
+ }
61
+ }
62
+
63
+ // Materialize one mirror entry into the whole-file JSON store. `delete` resets
64
+ // the file to the default disabled config (the same fallback semantics the
65
+ // store applies to a missing file).
66
+ export async function materializeLanAccessJsonEntry(entry) {
67
+ if (!entry || typeof entry !== 'object') throw new Error('LAN access mirror entry is invalid')
68
+ if (entry.operation === 'delete') {
69
+ await writeLanAccessJsonFile(defaultLanAccessConfig())
70
+ } else if (entry.operation === 'upsert' && entry.config) {
71
+ await writeLanAccessJsonFile(entry.config)
72
+ } else {
73
+ throw new Error('LAN access mirror entry is invalid')
74
+ }
75
+ }
@@ -0,0 +1,155 @@
1
+ import { createLanAccessRepository } from './sqlite/lan-access-repository.mjs'
2
+ import { getSqliteStorage } from './sqlite/database.mjs'
3
+ import { materializeLanAccessJsonEntry } from './lan-access-json-file.mjs'
4
+
5
+ // F11 LAN access storage phases — the same state machine as F8 session state /
6
+ // F10 share storage but in its own storage domain (`lan_access_storage_state`,
7
+ // independent maintenance lock).
8
+ export const LAN_ACCESS_STORAGE_PHASES = Object.freeze({
9
+ JSON_AUTHORITATIVE: 'json_authoritative',
10
+ CUTOVER_RUNNING: 'cutover_running',
11
+ JSON_PENDING: 'sqlite_authoritative_json_pending',
12
+ AUTHORITATIVE: 'authoritative',
13
+ })
14
+
15
+ let repositoryInstance = null
16
+ let cachedPhase = LAN_ACCESS_STORAGE_PHASES.JSON_AUTHORITATIVE
17
+ let mirrorAdapter = null
18
+ let drainPromise = null
19
+ let mirrorTimer = null
20
+ const MIRROR_DRAIN_INTERVAL_MS = 1000
21
+
22
+ function storage() {
23
+ return getSqliteStorage()
24
+ }
25
+
26
+ function repository() {
27
+ if (!repositoryInstance) repositoryInstance = createLanAccessRepository(storage())
28
+ return repositoryInstance
29
+ }
30
+
31
+ function sqliteReadable() {
32
+ return cachedPhase === LAN_ACCESS_STORAGE_PHASES.JSON_PENDING || cachedPhase === LAN_ACCESS_STORAGE_PHASES.AUTHORITATIVE
33
+ }
34
+
35
+ export function createDefaultLanAccessMirror() {
36
+ return {
37
+ async upsert(config) {
38
+ await materializeLanAccessJsonEntry({ operation: 'upsert', config })
39
+ },
40
+ async delete() {
41
+ await materializeLanAccessJsonEntry({ operation: 'delete' })
42
+ },
43
+ }
44
+ }
45
+
46
+ export function configureLanAccessService({ repository: configuredRepository, mirror, phase } = {}) {
47
+ if (configuredRepository !== undefined) repositoryInstance = configuredRepository
48
+ if (mirror !== undefined) mirrorAdapter = mirror
49
+ if (phase !== undefined) {
50
+ if (!Object.values(LAN_ACCESS_STORAGE_PHASES).includes(phase)) throw new TypeError(`Invalid LAN access storage phase: ${phase}`)
51
+ cachedPhase = phase
52
+ }
53
+ }
54
+
55
+ /**
56
+ * The active LAN access repository (lazily created from the SQLite storage
57
+ * handle). Only callable while the LAN access storage is SQLite-readable
58
+ * (pending or authoritative); JSON-authoritative phases keep the legacy JSON
59
+ * store path.
60
+ */
61
+ export function getLanAccessRepository() {
62
+ return repository()
63
+ }
64
+
65
+ export function setLanAccessStoragePhase(phase, values = {}) {
66
+ if (!Object.values(LAN_ACCESS_STORAGE_PHASES).includes(phase)) throw new TypeError(`Invalid LAN access storage phase: ${phase}`)
67
+ const updatedAt = new Date().toISOString()
68
+ storage().prepare(`UPDATE lan_access_storage_state SET phase = ?, lan_token_count = ?, digest = ?, backup_file = ?, diagnostic_json = ?, updated_at = ? WHERE singleton = 1`)
69
+ .run(phase, values.lanTokenCount ?? null, values.digest ?? null, values.backupFile ?? null, values.diagnostic ? JSON.stringify(values.diagnostic) : null, updatedAt)
70
+ cachedPhase = phase
71
+ return readLanAccessStorageState()
72
+ }
73
+
74
+ export function readLanAccessStorageState() {
75
+ const row = storage().prepare('SELECT * FROM lan_access_storage_state WHERE singleton = 1').get()
76
+ if (!row) throw new Error('LAN access storage state is missing')
77
+ cachedPhase = row.phase
78
+ return {
79
+ phase: row.phase,
80
+ lanTokenCount: row.lan_token_count === null ? null : Number(row.lan_token_count),
81
+ digest: row.digest,
82
+ backupFile: row.backup_file,
83
+ diagnostic: row.diagnostic_json ? JSON.parse(row.diagnostic_json) : null,
84
+ updatedAt: row.updated_at,
85
+ }
86
+ }
87
+
88
+ export function initializeLanAccessService() {
89
+ return readLanAccessStorageState()
90
+ }
91
+
92
+ export function getLanAccessStoragePhase() {
93
+ return cachedPhase
94
+ }
95
+
96
+ export function isLanAccessStorageAuthoritative() {
97
+ return sqliteReadable()
98
+ }
99
+
100
+ function scheduleLanAccessJsonMirrorDrain() {
101
+ if (!sqliteReadable() || !mirrorAdapter || mirrorTimer) return
102
+ mirrorTimer = setTimeout(() => {
103
+ mirrorTimer = null
104
+ void drainLanAccessJsonMirror().then(({ pending }) => {
105
+ if (pending > 0) scheduleLanAccessJsonMirrorDrain()
106
+ }).catch(() => {
107
+ scheduleLanAccessJsonMirrorDrain()
108
+ })
109
+ }, MIRROR_DRAIN_INTERVAL_MS)
110
+ mirrorTimer.unref?.()
111
+ }
112
+
113
+ export function requestLanAccessJsonMirrorDrain() {
114
+ scheduleLanAccessJsonMirrorDrain()
115
+ }
116
+
117
+ export function stopLanAccessService() {
118
+ if (mirrorTimer) clearTimeout(mirrorTimer)
119
+ mirrorTimer = null
120
+ }
121
+
122
+ export async function drainLanAccessJsonMirror() {
123
+ if (!sqliteReadable() || !mirrorAdapter) return { pending: 0, drained: 0, failed: 0 }
124
+ if (drainPromise) return drainPromise
125
+ drainPromise = (async () => {
126
+ let drained = 0
127
+ let failed = 0
128
+ for (;;) {
129
+ const entries = repository().listMirrorQueue()
130
+ if (entries.length === 0) break
131
+ let progressed = false
132
+ for (const entry of entries) {
133
+ try {
134
+ if (entry.operation === 'upsert') await mirrorAdapter.upsert(entry.config)
135
+ else await mirrorAdapter.delete()
136
+ repository().acknowledgeMirror(entry)
137
+ drained += 1
138
+ progressed = true
139
+ } catch (error) {
140
+ repository().failMirror(entry, error)
141
+ failed += 1
142
+ }
143
+ }
144
+ if (!progressed) break
145
+ }
146
+ return { pending: repository().listMirrorQueue().length, drained, failed }
147
+ })().finally(() => {
148
+ drainPromise = null
149
+ })
150
+ return drainPromise
151
+ }
152
+
153
+ export function listLanAccessMirrorQueue() {
154
+ return repository().listMirrorQueue()
155
+ }