@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
package/server/context-usage.mjs
CHANGED
|
@@ -48,7 +48,7 @@ function localMessagesTokens(messages) {
|
|
|
48
48
|
return normalizeMessagesForTokenEstimate(messages).reduce((total, message) => total + estimateTokens(message), 0)
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
export function estimateContextUsage({ systemPrompt, messages, tools, model }) {
|
|
51
|
+
export function estimateContextUsage({ systemPrompt, messages, tools, model, minimumProviderUsageIndex = 0 }) {
|
|
52
52
|
const contextWindow = Number(model?.contextWindow) || 0
|
|
53
53
|
const reservedOutputTokens = Math.max(0, Number(model?.maxTokens) || 4096)
|
|
54
54
|
const normalizedMessages = normalizeMessagesForTokenEstimate(messages)
|
|
@@ -57,7 +57,12 @@ export function estimateContextUsage({ systemPrompt, messages, tools, model }) {
|
|
|
57
57
|
const toolsTokens = textTokens(safeJson(tools))
|
|
58
58
|
const messagesTokens = localMessagesTokens(normalizedMessages)
|
|
59
59
|
const estimatedInputTokens = systemPromptTokens + messagesTokens + toolsTokens
|
|
60
|
-
const
|
|
60
|
+
const normalizedMinimumProviderUsageIndex = Math.max(0, Number(minimumProviderUsageIndex) || 0)
|
|
61
|
+
const providerUsageIndex = Number.isInteger(coreEstimate.lastUsageIndex) ? coreEstimate.lastUsageIndex : -1
|
|
62
|
+
const providerUsageTokens = providerUsageIndex >= normalizedMinimumProviderUsageIndex
|
|
63
|
+
? Math.max(0, Number(coreEstimate.usageTokens) || 0)
|
|
64
|
+
: 0
|
|
65
|
+
const providerBasedContextTokens = providerUsageTokens > 0
|
|
61
66
|
? Math.max(0, Number(coreEstimate.tokens) || 0)
|
|
62
67
|
: 0
|
|
63
68
|
const inputTokens = providerBasedContextTokens > 0
|
|
@@ -84,8 +89,10 @@ export function estimateContextUsage({ systemPrompt, messages, tools, model }) {
|
|
|
84
89
|
messagesTokens,
|
|
85
90
|
toolsTokens,
|
|
86
91
|
reservedOutputTokens,
|
|
87
|
-
providerUsageTokens
|
|
88
|
-
trailingTokens:
|
|
92
|
+
providerUsageTokens,
|
|
93
|
+
trailingTokens: providerUsageTokens > 0
|
|
94
|
+
? Math.max(0, Number(coreEstimate.trailingTokens) || 0)
|
|
95
|
+
: messagesTokens,
|
|
89
96
|
lastUsageIndex: coreEstimate.lastUsageIndex,
|
|
90
97
|
localEstimatedContextTokens: estimatedInputTokens,
|
|
91
98
|
},
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
+
}
|