@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
package/server/index.mjs CHANGED
@@ -6,7 +6,16 @@ import { randomUUID } from 'node:crypto'
6
6
  import { fileURLToPath } from 'node:url'
7
7
  import { sendJson, sendError } from './utils/response.mjs'
8
8
  import { openBrowser, openPathInFileManager } from './utils/platform.mjs'
9
- import { ensureStorage, dataDir, configDir, storageDir, cacheDir, logsDir } from './storage.mjs'
9
+ import { ensureStorage, dataDir, configDir, storageDir, cacheDir, logsDir, readPhysicalSessionMetadataBuckets, registerSessionMetadataCommitHook } from './storage.mjs'
10
+ import { initializeSessionStateCutover } from './session-state-cutover.mjs'
11
+ import { initializeSessionStateService, drainSessionJsonMirror, stopSessionStateService } from './session-state-service.mjs'
12
+ import { initializeShareCutover } from './share-cutover.mjs'
13
+ import { drainShareJsonMirror, initializeShareService, stopShareService } from './share-service.mjs'
14
+ import { initializeLanAccessCutover } from './lan-access-cutover.mjs'
15
+ import { drainLanAccessJsonMirror, initializeLanAccessService, stopLanAccessService } from './lan-access-service.mjs'
16
+ import { recoverSessionStateRestorePlan } from './session-state-backup.mjs'
17
+ import { recoverShareRestorePlan } from './share-backup.mjs'
18
+ import { recoverLanAccessRestorePlan } from './lan-access-backup.mjs'
10
19
  import { getNetworkProxyConfig, initializeNetworkProxy, refreshSystemProxy, updateNetworkProxyConfig } from './network-proxy.mjs'
11
20
  import { setDefaultWorkspaceRoot, initializeActiveProject, readProjectConfig, getActiveProject, readTerminalShellSetting, updateTerminalShellSetting, readTerminalShellConfig, updateTerminalShellConfig } from './project-config.mjs'
12
21
  import { getWorkspaceRoot } from './utils/workspace.mjs'
@@ -20,7 +29,7 @@ import { handleSkillsApi } from './routes/skills.mjs'
20
29
  import { ensureDefaultGlobalSkills } from './skills.mjs'
21
30
  import { handleAgentApi } from './routes/agent.mjs'
22
31
  import { handleAgentProfilesApi } from './routes/agent-profiles.mjs'
23
- import { handleScheduledTasksApi, startScheduledTaskRunner, stopScheduledTaskRunner } from './routes/scheduled-tasks.mjs'
32
+ import { handleScheduledTasksApi, recoverStaleScheduledTaskRuns, startScheduledTaskRunner, stopScheduledTaskRunner } from './routes/scheduled-tasks.mjs'
24
33
  import { startAutoArchiveRunner, stopAutoArchiveRunner } from './auto-archive.mjs'
25
34
  import { handleBackupApi } from './routes/backup.mjs'
26
35
  import { handleSystemApi } from './routes/system.mjs'
@@ -48,6 +57,10 @@ import { shutdown as shutdownAgentManager, resetStaleTaskStatuses } from './agen
48
57
  import { initializeChannels, shutdownChannels } from './channels/registry.mjs'
49
58
  import { shutdownMcpConnections } from './mcp/registry.mjs'
50
59
  import { shutdownTerminalSessions } from './terminal/terminal-manager.mjs'
60
+ import { closeSqliteStorage, getSqliteStorageSummary, initializeSqliteStorage } from './sqlite/database.mjs'
61
+ import { initializeScheduledRunsCutover } from './scheduled-runs-cutover.mjs'
62
+ import { recoverScheduledRunsRestorePlan } from './scheduled-runs-backup.mjs'
63
+ import { configureSessionIndex, getSessionIndexDiagnostics, initializeSessionIndex, syncSessionMetadataCommit } from './session-index-service.mjs'
51
64
  import { createNodeProcessEnv } from './utils/process-env.mjs'
52
65
  import { isAuthenticatedAppClient } from './access-policy.mjs'
53
66
 
@@ -108,6 +121,8 @@ async function getSystemStatus({ isLocalRequest = true, remoteAuthorized = false
108
121
  storageDir,
109
122
  cacheDir,
110
123
  logsDir,
124
+ sqlite: getSqliteStorageSummary(),
125
+ sessionIndex: getSessionIndexDiagnostics(),
111
126
  workspaceRoot: getWorkspaceRoot(),
112
127
  host,
113
128
  port,
@@ -161,15 +176,22 @@ function closeHttpServer() {
161
176
 
162
177
  async function shutdownRuntime() {
163
178
  shutdownStarted = true
164
- await stopQfAgent()
165
- stopScheduledTaskRunner()
166
- stopAutoArchiveRunner()
167
- stopVite()
168
- await shutdownAgentManager()
169
- await shutdownMcpConnections()
170
- await shutdownChannels()
171
- shutdownTerminalSessions()
172
- await closeHttpServer()
179
+ try {
180
+ await stopQfAgent()
181
+ stopScheduledTaskRunner()
182
+ stopAutoArchiveRunner()
183
+ stopVite()
184
+ await shutdownAgentManager()
185
+ await shutdownMcpConnections()
186
+ await shutdownChannels()
187
+ shutdownTerminalSessions()
188
+ await closeHttpServer()
189
+ } finally {
190
+ stopSessionStateService()
191
+ stopShareService()
192
+ stopLanAccessService()
193
+ await closeSqliteStorage()
194
+ }
173
195
  }
174
196
 
175
197
  export function stopQuickForgeServer() {
@@ -296,7 +318,7 @@ async function updateQuickForge() {
296
318
  }
297
319
  }
298
320
 
299
- async function applyCloudServiceConfig(cloudConfig, { urlChanged = false } = {}) {
321
+ async function applyCloudServiceConfig(cloudConfig, { urlChanged = false, autoApprovalPolicy } = {}) {
300
322
  if (shutdownStarted) return null
301
323
  if (!cloudConfig.enabled || !cloudConfig.valid) {
302
324
  if (!cloudConfig.valid) logger.warn(`QuickForge remote agent was not started: ${cloudConfig.configurationError || 'invalid Cloud configuration'}`)
@@ -309,6 +331,9 @@ async function applyCloudServiceConfig(cloudConfig, { urlChanged = false } = {})
309
331
  serverUrl: `http://127.0.0.1:${boundServerPort}/`,
310
332
  ownerPid: process.pid,
311
333
  cloudUrl: cloudConfig.cloudUrl,
334
+ // undefined → 'auto':server 启动恢复等本机生命周期允许自动 arm;
335
+ // 'manual':认证远程客户端触发的配置变更不自动批准。
336
+ autoApprovalPolicy,
312
337
  })
313
338
  }
314
339
 
@@ -422,7 +447,7 @@ async function handleApi(req, res, url, requestContext = {}) {
422
447
  }
423
448
 
424
449
  // Project workspace inspector routes
425
- if (pathname === '/api/workspace/tree' || pathname === '/api/workspace/file' || pathname === '/api/workspace/resolve-path' || pathname === '/api/workspace/open-external' || pathname.startsWith('/api/workspace/preview/')) {
450
+ if (pathname === '/api/workspace/tree' || pathname === '/api/workspace/children' || pathname === '/api/workspace/search' || pathname === '/api/workspace/file' || pathname === '/api/workspace/resolve-path' || pathname === '/api/workspace/open-external' || pathname.startsWith('/api/workspace/preview/')) {
426
451
  await handleWorkspaceApi(req, res, url, requestContext)
427
452
  return
428
453
  }
@@ -751,6 +776,34 @@ server.on('upgrade', (req, socket, head) => {
751
776
  })
752
777
 
753
778
  await ensureStorage()
779
+ await initializeSqliteStorage({ dataDir })
780
+ await initializeScheduledRunsCutover()
781
+ await recoverScheduledRunsRestorePlan()
782
+ await recoverStaleScheduledTaskRuns()
783
+ // Session state cutover: authoritative integrity failures fail closed and
784
+ // block startup; json_authoritative failures keep the legacy JSON path.
785
+ await initializeSessionStateCutover()
786
+ await initializeSessionStateService()
787
+ await recoverSessionStateRestorePlan()
788
+ await drainSessionJsonMirror()
789
+ // Share storage cutover: share-store writes become SQLite-authoritative once
790
+ // pending/authoritative; integrity failures fail closed and block startup,
791
+ // json_authoritative failures keep the legacy JSON path. The JSON file stays
792
+ // as the best-effort mirror drained through the share mirror queue.
793
+ await initializeShareCutover()
794
+ await initializeShareService()
795
+ await recoverShareRestorePlan()
796
+ await drainShareJsonMirror()
797
+ // LAN access storage cutover: JSON → SQLite with the same phase machine as
798
+ // share storage. Integrity failures while pending/authoritative fail closed
799
+ // and block startup; json_authoritative failures keep the legacy JSON store
800
+ // path. The JSON file stays as the best-effort mirror drained through the
801
+ // lan-access mirror queue. Interrupted lan-access restore plans are recovered
802
+ // (roll-forward/rollback) before the mirror drain.
803
+ await initializeLanAccessCutover()
804
+ await initializeLanAccessService()
805
+ await recoverLanAccessRestorePlan()
806
+ await drainLanAccessJsonMirror()
754
807
  await ensureDefaultGlobalSkills()
755
808
  await initializeNetworkProxy()
756
809
  installAiHttpLogger()
@@ -760,6 +813,9 @@ initializeChannels({
760
813
  logsDir,
761
814
  })
762
815
  await resetStaleTaskStatuses()
816
+ configureSessionIndex({ readBuckets: readPhysicalSessionMetadataBuckets })
817
+ registerSessionMetadataCommitHook(syncSessionMetadataCommit)
818
+ await initializeSessionIndex()
763
819
  await initializeActiveProject()
764
820
  setActiveWorkspaceRootForFilesystem(getWorkspaceRoot())
765
821
  startScheduledTaskRunner()
@@ -0,0 +1,256 @@
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 { createLanAccessRepository, lanAccessConfigDigest } from './sqlite/lan-access-repository.mjs'
6
+ import {
7
+ buildLanAccessJsonSnapshot,
8
+ currentLanAccessMaintenanceContext,
9
+ runLanAccessMaintenance,
10
+ } from './lan-access-cutover.mjs'
11
+ import { getLanAccessStoragePhase, isLanAccessStorageAuthoritative } from './lan-access-service.mjs'
12
+ import { storageDir } from './storage.mjs'
13
+
14
+ // F11 Phase 3 lan-access backup/restore: mirrors the session-state/share backup
15
+ // pattern in the independent lan-access storage domain. Exports run under the
16
+ // lan-access maintenance lock with quick_check + verifyIntegrity + exportSnapshot
17
+ // (count/digest fail closed). Restores run under the same lock, persist a
18
+ // crash-recoverable plan file and compensate back to the exact before-state if
19
+ // the apply or its verification fails. Only lan_access_state /
20
+ // lan_access_tokens / lan_access_json_mirror_queue are touched;
21
+ // scheduled_task_runs, session_index, session_messages and share_sessions are
22
+ // never modified here. The exported config keeps token hashes only (raw secrets
23
+ // never leave the issuer) and strips the repository-internal revision.
24
+ const RESTORE_PLAN_FILE = path.join(storageDir, 'lan-access-restore-plan.json')
25
+ const ROLL_FORWARD_STATUSES = new Set(['prepared', 'applying', 'target_applied'])
26
+ const ROLL_BACK_STATUSES = new Set(['compensating', 'compensation_failed'])
27
+
28
+ function repositoryRequired() {
29
+ try {
30
+ return createLanAccessRepository(getSqliteStorage())
31
+ } catch (error) {
32
+ throw new Error('SQLite storage is required for authoritative LAN access state', { cause: error })
33
+ }
34
+ }
35
+
36
+ function isPlainObject(value) {
37
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
38
+ }
39
+
40
+ function isPlainPlanValue(value) {
41
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
42
+ }
43
+
44
+ // Strip repository-internal fields before writing the config into backups or
45
+ // plans so the exported JSON is exactly the v1 lan-access.json shape. Token
46
+ // hashes stay (they are already hash-only — never plaintext secrets).
47
+ function publicLanAccessConfig(config) {
48
+ if (!isPlainObject(config)) return config
49
+ const rest = { ...config }
50
+ delete rest.revision
51
+ return rest
52
+ }
53
+
54
+ // Public digest helper for backup tooling: returns the exact repository-style
55
+ // digest a restored snapshot must produce, given backup `{ lanAccess: config }`.
56
+ export function computeLanAccessSnapshotDigest(values) {
57
+ if (!isPlainPlanValue(values) || !isPlainObject(values.lanAccess)) return ''
58
+ return lanAccessConfigDigest(buildLanAccessJsonSnapshot(values.lanAccess).config)
59
+ }
60
+
61
+ function countLanAccessTokens(values) {
62
+ if (!isPlainPlanValue(values) || !isPlainObject(values.lanAccess)) return 0
63
+ return buildLanAccessJsonSnapshot(values.lanAccess).tokenCount
64
+ }
65
+
66
+ /**
67
+ * Authoritative lan-access state export for the backup route and offline tools.
68
+ * Runs under the lan-access maintenance lock so a concurrent restore can never
69
+ * be observed mid-transaction, and fail-closes when integrity or digest
70
+ * verification fails.
71
+ */
72
+ export async function exportLanAccessStateForBackup(options = {}) {
73
+ const operation = async () => {
74
+ const repository = options.repository || repositoryRequired()
75
+ const integrity = repository.verifyIntegrity({ quickCheck: true })
76
+ if (!integrity.ok) throw new Error('LAN access state integrity verification failed during backup export')
77
+ const snapshot = repository.exportSnapshot()
78
+ if (snapshot.tokenCount !== integrity.count || snapshot.digest !== integrity.digest) {
79
+ throw new Error('LAN access backup count/digest verification failed')
80
+ }
81
+ return {
82
+ lanAccess: publicLanAccessConfig(snapshot.config),
83
+ config: snapshot.config,
84
+ count: snapshot.tokenCount,
85
+ digest: snapshot.digest,
86
+ phase: getLanAccessStoragePhase(),
87
+ }
88
+ }
89
+ if (!options.maintenance || currentLanAccessMaintenanceContext()) return operation()
90
+ return runLanAccessMaintenance(operation, { storage: options.storage, operation: 'lan-access-backup-export' })
91
+ }
92
+
93
+ async function writePlan(plan, planFile = RESTORE_PLAN_FILE) {
94
+ await fs.mkdir(path.dirname(planFile), { recursive: true })
95
+ const temporary = `${planFile}.${process.pid}.${randomUUID()}.tmp`
96
+ try {
97
+ await fs.writeFile(temporary, `${JSON.stringify(plan, null, 2)}\n`, 'utf8')
98
+ await fs.rename(temporary, planFile)
99
+ } catch (error) {
100
+ await fs.rm(temporary, { force: true }).catch(() => {})
101
+ throw error
102
+ }
103
+ }
104
+
105
+ function validatePlan(plan) {
106
+ if (
107
+ !plan || plan.version !== 1 || plan.operation !== 'lan_access_state_restore' ||
108
+ !isPlainPlanValue(plan.before) || !isPlainPlanValue(plan.target)
109
+ ) {
110
+ throw new Error('LAN access restore plan is invalid')
111
+ }
112
+ if (!ROLL_FORWARD_STATUSES.has(plan.status) && !ROLL_BACK_STATUSES.has(plan.status)) {
113
+ throw new Error('LAN access restore plan status is invalid')
114
+ }
115
+ const beforeCount = countLanAccessTokens(plan.before)
116
+ const targetCount = countLanAccessTokens(plan.target)
117
+ if (plan.beforeCount !== beforeCount || plan.targetCount !== targetCount) {
118
+ throw new Error('LAN access restore plan count is invalid')
119
+ }
120
+ const beforeDigest = computeLanAccessSnapshotDigest(plan.before)
121
+ const targetDigest = computeLanAccessSnapshotDigest(plan.target)
122
+ if (plan.beforeDigest !== beforeDigest || plan.targetDigest !== targetDigest) {
123
+ throw new Error('LAN access restore plan digest is invalid')
124
+ }
125
+ return plan
126
+ }
127
+
128
+ /**
129
+ * Restore the authoritative lan-access state from a backup snapshot. Always
130
+ * runs under the lan-access maintenance lock, persists a crash-recoverable plan
131
+ * file, and compensates back to the exact before-state if the apply or its
132
+ * verification fails. replace mode wipes the local config; merge mode keeps
133
+ * local config fields (including local tokens) while backup fields override
134
+ * same-key entries. `values` is the v1 lan-access.json shape
135
+ * (`{ lanAccess: config }`). The restore overwrites the `enabled` switch when
136
+ * the backup carries one, so import UIs warn before applying.
137
+ */
138
+ export async function restoreLanAccessStateSnapshot(values, options = {}) {
139
+ if (!isLanAccessStorageAuthoritative()) {
140
+ throw new Error('LAN access state restore requires authoritative SQLite storage')
141
+ }
142
+ if (!isPlainPlanValue(values) || !isPlainObject(values.lanAccess)) {
143
+ throw new TypeError('LAN access restore values must be a lanAccess config object')
144
+ }
145
+ const repository = options.repository || repositoryRequired()
146
+ const mode = options.mode === 'merge' ? 'merge' : 'replace'
147
+ const planFile = options.planFile || RESTORE_PLAN_FILE
148
+ return runLanAccessMaintenance(async () => {
149
+ const before = repository.exportSnapshot()
150
+ const beforeConfig = publicLanAccessConfig(before.config)
151
+ const merged = mode === 'merge'
152
+ ? { ...beforeConfig, ...values.lanAccess }
153
+ : values.lanAccess
154
+ // Normalize once before writing the plan: validation failures (invalid
155
+ // config, enabled-without-password, malformed tokens) surface here and
156
+ // never leave a plan file.
157
+ const targetSnapshot = buildLanAccessJsonSnapshot(merged)
158
+ const targetConfig = publicLanAccessConfig(targetSnapshot.config)
159
+ const beforeCount = before.tokenCount
160
+ const beforeDigest = before.digest
161
+ const targetCount = targetSnapshot.tokenCount
162
+ const targetDigest = targetSnapshot.digest
163
+ let plan = {
164
+ version: 1,
165
+ operation: 'lan_access_state_restore',
166
+ status: 'prepared',
167
+ createdAt: new Date().toISOString(),
168
+ before: { lanAccess: beforeConfig },
169
+ target: { lanAccess: targetConfig },
170
+ beforeCount,
171
+ beforeDigest,
172
+ targetCount,
173
+ targetDigest,
174
+ }
175
+ await writePlan(plan, planFile)
176
+ try {
177
+ plan = { ...plan, status: 'applying' }
178
+ await writePlan(plan, planFile)
179
+ repository.replaceAll(targetConfig, { expectedCount: targetCount, expectedDigest: targetDigest })
180
+ const after = repository.exportSnapshot()
181
+ if (after.tokenCount !== targetCount || after.digest !== targetDigest) {
182
+ throw new Error('LAN access restore count/digest verification failed')
183
+ }
184
+ const integrity = repository.verifyIntegrity()
185
+ if (!integrity.ok) throw new Error('LAN access restore integrity verification failed')
186
+ plan = { ...plan, status: 'target_applied' }
187
+ await writePlan(plan, planFile)
188
+ await fs.rm(planFile, { force: true })
189
+ return { lanAccess: targetCount }
190
+ } catch (error) {
191
+ try {
192
+ plan = { ...plan, status: 'compensating', failedAt: new Date().toISOString() }
193
+ await writePlan(plan, planFile)
194
+ const beforeSnapshot = buildLanAccessJsonSnapshot(beforeConfig)
195
+ repository.replaceAll(beforeSnapshot.config, { expectedCount: beforeCount, expectedDigest: beforeDigest })
196
+ const restored = repository.exportSnapshot()
197
+ if (restored.tokenCount !== beforeCount || restored.digest !== beforeDigest) {
198
+ throw new Error('LAN access restore compensation verification failed', { cause: error })
199
+ }
200
+ await fs.rm(planFile, { force: true })
201
+ } catch (compensationError) {
202
+ await writePlan({
203
+ ...plan,
204
+ status: 'compensation_failed',
205
+ compensationFailedAt: new Date().toISOString(),
206
+ errorName: error?.name || 'Error',
207
+ compensationErrorName: compensationError?.name || 'Error',
208
+ }, planFile).catch(() => {})
209
+ const retained = new Error('LAN access restore failed and compensation failed', { cause: compensationError })
210
+ retained.retainLanAccessMaintenance = true
211
+ throw retained
212
+ }
213
+ throw error
214
+ }
215
+ }, { storage: options.storage, operation: 'lan-access-restore' })
216
+ }
217
+
218
+ /**
219
+ * Startup recovery for an interrupted lan-access restore plan: roll forward to
220
+ * the target for prepared/applying/target_applied plans and roll back to the
221
+ * before state for compensating/compensation_failed plans, then remove the plan
222
+ * file.
223
+ */
224
+ export async function recoverLanAccessRestorePlan({
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(`LAN access restore plan is unreadable or invalid: ${error?.message || error}`, { cause: error })
235
+ }
236
+ if (!isLanAccessStorageAuthoritative()) {
237
+ throw new Error('LAN access restore plan exists outside authoritative mode')
238
+ }
239
+ const repository = repositoryOverride || repositoryRequired()
240
+ return runLanAccessMaintenance(async () => {
241
+ const rollBack = ROLL_BACK_STATUSES.has(plan.status)
242
+ const values = rollBack ? plan.before : plan.target
243
+ const snapshot = buildLanAccessJsonSnapshot(values.lanAccess)
244
+ const expectedCount = rollBack ? plan.beforeCount : plan.targetCount
245
+ const expectedDigest = rollBack ? plan.beforeDigest : plan.targetDigest
246
+ repository.replaceAll(snapshot.config, { expectedCount, expectedDigest })
247
+ const after = repository.exportSnapshot()
248
+ if (after.tokenCount !== expectedCount || after.digest !== expectedDigest) {
249
+ throw new Error('LAN access restore plan recovery verification failed')
250
+ }
251
+ const integrity = repository.verifyIntegrity()
252
+ if (!integrity.ok) throw new Error('LAN access restore plan recovery integrity verification failed')
253
+ await fs.rm(planFile, { force: true })
254
+ return true
255
+ }, { storage, operation: 'lan-access-restore-recovery' })
256
+ }