@shawnstack/quickforge 1.7.6 → 1.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
- package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
- package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
- package/dist/assets/{PluginsPage-BDKYfLna.js → PluginsPage-mJkTmVVV.js} +1 -1
- package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
- package/dist/assets/{SettingsWorkspacePage-DSy66Tuw.js → SettingsWorkspacePage-7lQ0Wl63.js} +391 -391
- package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
- package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
- package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
- package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
- package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
- package/dist/assets/index-D-J_8Smf.css +3 -0
- package/dist/assets/index-DFASc15l.js +66 -0
- package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
- package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
- package/dist/assets/{mcp-servers-dialog-BEV4_TXC.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
- package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
- package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
- package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
- package/dist/assets/{skills-dialog-BTJ7mzk_.js → skills-dialog-DIzZvQqW.js} +3 -3
- package/dist/index.html +8 -8
- package/package.json +1 -1
- package/server/acp/server.mjs +17 -1
- package/server/agent-manager.mjs +251 -31
- package/server/ai-http-logger.mjs +162 -19
- package/server/ai-provider-options.mjs +4 -1
- package/server/auto-archive.mjs +42 -29
- package/server/auto-compaction.mjs +43 -1
- package/server/cloud/auto-approval.mjs +155 -0
- package/server/cloud/client.mjs +5 -0
- package/server/cloud/config.mjs +8 -16
- package/server/cloud/qf-agent-process.mjs +163 -3
- package/server/cloud/service-config.mjs +1 -1
- package/server/context-usage.mjs +11 -4
- package/server/index.mjs +69 -13
- package/server/lan-access-backup.mjs +256 -0
- package/server/lan-access-cutover.mjs +306 -0
- package/server/lan-access-json-file.mjs +75 -0
- package/server/lan-access-service.mjs +155 -0
- package/server/lan-access-store.mjs +110 -9
- package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
- package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
- package/server/maintenance/downgrade-share-v1.mjs +101 -0
- package/server/maintenance/export-lan-access-v1.mjs +80 -0
- package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
- package/server/maintenance/export-session-state-v1.mjs +67 -0
- package/server/maintenance/export-share-v1.mjs +77 -0
- package/server/routes/agent.mjs +37 -4
- package/server/routes/backup.mjs +221 -39
- package/server/routes/cloud.mjs +28 -1
- package/server/routes/scheduled-tasks.mjs +347 -134
- package/server/routes/shares.mjs +4 -2
- package/server/routes/storage.mjs +138 -8
- package/server/routes/workspace.mjs +265 -4
- package/server/scheduled-runs-backup.mjs +235 -0
- package/server/scheduled-runs-cutover.mjs +497 -0
- package/server/scheduled-task-runs-service.mjs +279 -0
- package/server/session-index-service.mjs +495 -0
- package/server/session-state-backup.mjs +277 -0
- package/server/session-state-cutover.mjs +370 -0
- package/server/session-state-service.mjs +709 -0
- package/server/share-backup.mjs +255 -0
- package/server/share-cutover.mjs +296 -0
- package/server/share-json-file.mjs +60 -0
- package/server/share-service.mjs +163 -0
- package/server/share-store.mjs +196 -19
- package/server/skills.mjs +0 -1
- package/server/sqlite/database.mjs +233 -0
- package/server/sqlite/lan-access-repository.mjs +652 -0
- package/server/sqlite/migrations.mjs +577 -0
- package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
- package/server/sqlite/session-index-repository.mjs +297 -0
- package/server/sqlite/session-state-repository.mjs +786 -0
- package/server/sqlite/share-repository.mjs +854 -0
- package/server/storage.mjs +317 -1
- package/dist/assets/AgentProfilesPage-Dl5Peqov.js +0 -1
- package/dist/assets/ChatPanelHost-D3987TUJ.js +0 -307
- package/dist/assets/CloudAccountSettingsPage-BG5Nn3bq.js +0 -1
- package/dist/assets/ScheduledTasksPage--GAki3lB.js +0 -2
- package/dist/assets/ShareLinksSettingsPage-BQQMsvJo.js +0 -1
- package/dist/assets/SharedConversationPage-B9BUVCri.js +0 -1
- package/dist/assets/TerminalDock-CT1nYo7P.js +0 -2
- package/dist/assets/WorkspaceInspector-BnfDLf9a.js +0 -36
- package/dist/assets/index-1ei6nhs-.css +0 -3
- package/dist/assets/index-BivLuZvX.js +0 -65
- package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
- /package/dist/assets/{plugin-api-Uu2xAB4d.js → plugin-api-BrdQeM4B.js} +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { promises as fs } from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { closeSqliteStorage, initializeSqliteStorage } from '../sqlite/database.mjs'
|
|
6
|
+
import { createSessionStateRepository } from '../sqlite/session-state-repository.mjs'
|
|
7
|
+
import { readSessionStorageState } from '../session-state-service.mjs'
|
|
8
|
+
import { dataDir } from '../storage.mjs'
|
|
9
|
+
|
|
10
|
+
const AUTHORITATIVE_PHASES = new Set(['sqlite_authoritative_json_pending', 'authoritative'])
|
|
11
|
+
|
|
12
|
+
function outputPath() {
|
|
13
|
+
const argument = process.argv[2]
|
|
14
|
+
if (argument) return path.resolve(argument)
|
|
15
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
16
|
+
return path.join(process.cwd(), `quickforge-session-state-${stamp}.json`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const finalPath = outputPath()
|
|
20
|
+
const temporaryPath = `${finalPath}.${process.pid}.${randomUUID()}.tmp`
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const storage = await initializeSqliteStorage({ dataDir })
|
|
24
|
+
storage.health({ quickCheck: true })
|
|
25
|
+
const repository = createSessionStateRepository(storage)
|
|
26
|
+
const state = readSessionStorageState()
|
|
27
|
+
if (state.phase === 'cutover_running') {
|
|
28
|
+
throw new Error('Session state cutover is still running; stop all QuickForge processes and retry')
|
|
29
|
+
}
|
|
30
|
+
if (!AUTHORITATIVE_PHASES.has(state.phase)) {
|
|
31
|
+
throw new Error(`Session state is ${state.phase}; authoritative export requires the SQLite cutover to be complete`)
|
|
32
|
+
}
|
|
33
|
+
const integrity = repository.verifyIntegrity({ quickCheck: true })
|
|
34
|
+
if (!integrity.ok) throw new Error('Session state integrity verification failed')
|
|
35
|
+
const snapshot = repository.exportSnapshot()
|
|
36
|
+
if (snapshot.count !== integrity.count || snapshot.digest !== integrity.digest) {
|
|
37
|
+
throw new Error('Session state backup count/digest verification failed')
|
|
38
|
+
}
|
|
39
|
+
const backup = {
|
|
40
|
+
app: 'quickforge',
|
|
41
|
+
version: 1,
|
|
42
|
+
exportedAt: new Date().toISOString(),
|
|
43
|
+
scope: 'sessions',
|
|
44
|
+
includeSecrets: false,
|
|
45
|
+
sessionState: { phase: state.phase, count: snapshot.count, digest: snapshot.digest },
|
|
46
|
+
data: {
|
|
47
|
+
sessions: Object.fromEntries(snapshot.records.map((record) => [record.sessionId, record.state?.messageStorage === 'split'
|
|
48
|
+
? { ...record.state, messages: record.messages ?? [] }
|
|
49
|
+
: record.state])),
|
|
50
|
+
sessionsMetadata: Object.fromEntries(snapshot.records.map((record) => [record.sessionId, record.metadata])),
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
await fs.mkdir(path.dirname(finalPath), { recursive: true })
|
|
54
|
+
await fs.writeFile(temporaryPath, `${JSON.stringify(backup, null, 2)}\n`, 'utf8')
|
|
55
|
+
const verified = JSON.parse(await fs.readFile(temporaryPath, 'utf8'))
|
|
56
|
+
if (verified.sessionState?.count !== snapshot.count || verified.sessionState?.digest !== snapshot.digest) {
|
|
57
|
+
throw new Error('Session state export verification failed')
|
|
58
|
+
}
|
|
59
|
+
await fs.rename(temporaryPath, finalPath)
|
|
60
|
+
process.stdout.write(`${finalPath}\n`)
|
|
61
|
+
} catch (error) {
|
|
62
|
+
await fs.rm(temporaryPath, { force: true }).catch(() => {})
|
|
63
|
+
process.stderr.write(`${error?.message || error}\n`)
|
|
64
|
+
process.exitCode = 1
|
|
65
|
+
} finally {
|
|
66
|
+
await closeSqliteStorage().catch(() => {})
|
|
67
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { promises as fs } from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { closeSqliteStorage, initializeSqliteStorage } from '../sqlite/database.mjs'
|
|
6
|
+
import { createShareRepository } from '../sqlite/share-repository.mjs'
|
|
7
|
+
import { buildShareJsonSnapshot } from '../share-cutover.mjs'
|
|
8
|
+
import { readShareStorageState } from '../share-service.mjs'
|
|
9
|
+
import { dataDir } from '../storage.mjs'
|
|
10
|
+
|
|
11
|
+
// F10 Phase 3 offline authoritative share export (shutdown-time only). Mirrors
|
|
12
|
+
// export-session-state-v1.mjs in the independent share storage domain: refuses
|
|
13
|
+
// cutover_running and json_authoritative, runs quick_check + verifyIntegrity +
|
|
14
|
+
// exportSnapshot with fail-closed count/digest checks, and only renames the
|
|
15
|
+
// temporary file into place after re-reading and re-verifying what was written.
|
|
16
|
+
const AUTHORITATIVE_PHASES = new Set(['sqlite_authoritative_json_pending', 'authoritative'])
|
|
17
|
+
|
|
18
|
+
function outputPath() {
|
|
19
|
+
const argument = process.argv[2]
|
|
20
|
+
if (argument) return path.resolve(argument)
|
|
21
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
22
|
+
return path.join(process.cwd(), `quickforge-shares-${stamp}.json`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const finalPath = outputPath()
|
|
26
|
+
const temporaryPath = `${finalPath}.${process.pid}.${randomUUID()}.tmp`
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const storage = await initializeSqliteStorage({ dataDir })
|
|
30
|
+
storage.health({ quickCheck: true })
|
|
31
|
+
const repository = createShareRepository(storage)
|
|
32
|
+
const state = readShareStorageState()
|
|
33
|
+
if (state.phase === 'cutover_running') {
|
|
34
|
+
throw new Error('Share cutover is still running; stop all QuickForge processes and retry')
|
|
35
|
+
}
|
|
36
|
+
if (!AUTHORITATIVE_PHASES.has(state.phase)) {
|
|
37
|
+
throw new Error(`Share storage is ${state.phase}; authoritative export requires the SQLite cutover to be complete`)
|
|
38
|
+
}
|
|
39
|
+
const integrity = repository.verifyIntegrity({ quickCheck: true })
|
|
40
|
+
if (!integrity.ok) throw new Error('Share storage integrity verification failed')
|
|
41
|
+
const snapshot = repository.exportSnapshot()
|
|
42
|
+
if (snapshot.count !== integrity.count || snapshot.digest !== integrity.digest) {
|
|
43
|
+
throw new Error('Share backup count/digest verification failed')
|
|
44
|
+
}
|
|
45
|
+
const backup = {
|
|
46
|
+
app: 'quickforge',
|
|
47
|
+
version: 1,
|
|
48
|
+
exportedAt: new Date().toISOString(),
|
|
49
|
+
scope: 'shares',
|
|
50
|
+
includeSecrets: false,
|
|
51
|
+
shareState: { phase: state.phase, count: snapshot.count, digest: snapshot.digest },
|
|
52
|
+
data: {
|
|
53
|
+
shares: Object.fromEntries(snapshot.records.map((record) => {
|
|
54
|
+
const rest = { ...record }
|
|
55
|
+
delete rest.revision
|
|
56
|
+
delete rest.deletedAt
|
|
57
|
+
return [record.id, rest]
|
|
58
|
+
})),
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
await fs.mkdir(path.dirname(finalPath), { recursive: true })
|
|
62
|
+
await fs.writeFile(temporaryPath, `${JSON.stringify(backup, null, 2)}\n`, 'utf8')
|
|
63
|
+
const verified = JSON.parse(await fs.readFile(temporaryPath, 'utf8'))
|
|
64
|
+
const recheck = buildShareJsonSnapshot(verified.data?.shares)
|
|
65
|
+
if (verified.shareState?.count !== snapshot.count || verified.shareState?.digest !== snapshot.digest
|
|
66
|
+
|| recheck.count !== snapshot.count || recheck.digest !== snapshot.digest) {
|
|
67
|
+
throw new Error('Share export verification failed')
|
|
68
|
+
}
|
|
69
|
+
await fs.rename(temporaryPath, finalPath)
|
|
70
|
+
process.stdout.write(`${finalPath}\n`)
|
|
71
|
+
} catch (error) {
|
|
72
|
+
await fs.rm(temporaryPath, { force: true }).catch(() => {})
|
|
73
|
+
process.stderr.write(`${error?.message || error}\n`)
|
|
74
|
+
process.exitCode = 1
|
|
75
|
+
} finally {
|
|
76
|
+
await closeSqliteStorage().catch(() => {})
|
|
77
|
+
}
|
package/server/routes/agent.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
abortToolCall,
|
|
34
34
|
rollbackSessionMessages,
|
|
35
35
|
continueSession,
|
|
36
|
+
stripSplitSessionState,
|
|
36
37
|
agentEvents,
|
|
37
38
|
} from '../agent-manager.mjs'
|
|
38
39
|
|
|
@@ -122,7 +123,7 @@ export async function handleAgentApi(req, res, url, context = {}) {
|
|
|
122
123
|
messageCount: state.messages?.length ?? 0,
|
|
123
124
|
durationMs: Math.round((performance.now() - startedAt) * 10) / 10,
|
|
124
125
|
})
|
|
125
|
-
sendJson(res, 200, state)
|
|
126
|
+
sendJson(res, 200, stripSplitSessionState(state))
|
|
126
127
|
return
|
|
127
128
|
}
|
|
128
129
|
|
|
@@ -140,7 +141,38 @@ export async function handleAgentApi(req, res, url, context = {}) {
|
|
|
140
141
|
error.statusCode = 404
|
|
141
142
|
throw error
|
|
142
143
|
}
|
|
143
|
-
sendJson(res, 200, state)
|
|
144
|
+
sendJson(res, 200, stripSplitSessionState(state))
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// GET /api/agents/:sessionId/messages?after=N — incremental message fetch.
|
|
149
|
+
// Split sessions never embed the full message list in state frames; clients
|
|
150
|
+
// materialize the conversation through this lightweight, paginated channel
|
|
151
|
+
// (after = message index; the response reports the server total count so
|
|
152
|
+
// clients can detect truncation/rollback and refetch from zero).
|
|
153
|
+
if (req.method === 'GET' && subPath === 'messages') {
|
|
154
|
+
let state = getSessionState(sessionId)
|
|
155
|
+
if (!state) {
|
|
156
|
+
await restoreAgent(sessionId)
|
|
157
|
+
state = getSessionState(sessionId)
|
|
158
|
+
}
|
|
159
|
+
if (!state) {
|
|
160
|
+
const error = new Error('Session not found')
|
|
161
|
+
error.statusCode = 404
|
|
162
|
+
throw error
|
|
163
|
+
}
|
|
164
|
+
const messages = Array.isArray(state.messages) ? state.messages : []
|
|
165
|
+
const afterParam = Number(new URLSearchParams(url.search).get('after'))
|
|
166
|
+
const after = Number.isInteger(afterParam) && afterParam >= 0 ? afterParam : 0
|
|
167
|
+
const limitParam = Number(new URLSearchParams(url.search).get('limit'))
|
|
168
|
+
const limit = Number.isInteger(limitParam) && limitParam >= 1 ? Math.min(limitParam, 5000) : 500
|
|
169
|
+
const page = messages.slice(after, after + limit)
|
|
170
|
+
sendJson(res, 200, {
|
|
171
|
+
after,
|
|
172
|
+
count: messages.length,
|
|
173
|
+
hasMore: after + page.length < messages.length,
|
|
174
|
+
messages: page,
|
|
175
|
+
})
|
|
144
176
|
return
|
|
145
177
|
}
|
|
146
178
|
|
|
@@ -463,10 +495,11 @@ async function handleStream(req, res, sessionId) {
|
|
|
463
495
|
'x-accel-buffering': 'no',
|
|
464
496
|
})
|
|
465
497
|
|
|
466
|
-
// Send initial state
|
|
498
|
+
// Send initial state (split sessions send a lightweight summary; clients
|
|
499
|
+
// fetch messages through GET /api/agents/:id/messages)
|
|
467
500
|
const state = getSessionState(sessionId)
|
|
468
501
|
if (state) {
|
|
469
|
-
writeSseEvent(res, 'state', state)
|
|
502
|
+
writeSseEvent(res, 'state', stripSplitSessionState(state))
|
|
470
503
|
}
|
|
471
504
|
|
|
472
505
|
// Keep-alive ping every 15 seconds — also resets idle timer
|
package/server/routes/backup.mjs
CHANGED
|
@@ -2,6 +2,19 @@ import { randomUUID } from 'node:crypto'
|
|
|
2
2
|
import { promises as fs } from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { isAuthenticatedAppClient } from '../access-policy.mjs'
|
|
5
|
+
import { isScheduledRunsMaintenanceActive } from '../scheduled-runs-cutover.mjs'
|
|
6
|
+
import { readScheduledTasksForBackup, restoreScheduledTasks } from '../scheduled-runs-backup.mjs'
|
|
7
|
+
import { isSessionStateAuthoritative } from '../session-state-service.mjs'
|
|
8
|
+
import { isSessionStateMaintenanceActive } from '../session-state-cutover.mjs'
|
|
9
|
+
import { exportSessionStateForBackup, restoreSessionStateSnapshot } from '../session-state-backup.mjs'
|
|
10
|
+
import { isShareStorageAuthoritative } from '../share-service.mjs'
|
|
11
|
+
import { isShareMaintenanceActive } from '../share-cutover.mjs'
|
|
12
|
+
import { exportShareStateForBackup, restoreShareStateSnapshot } from '../share-backup.mjs'
|
|
13
|
+
import { readSharesJsonFile, writeSharesJsonFile } from '../share-json-file.mjs'
|
|
14
|
+
import { isLanAccessStorageAuthoritative } from '../lan-access-service.mjs'
|
|
15
|
+
import { isLanAccessMaintenanceActive } from '../lan-access-cutover.mjs'
|
|
16
|
+
import { exportLanAccessStateForBackup, restoreLanAccessStateSnapshot } from '../lan-access-backup.mjs'
|
|
17
|
+
import { readLanAccessJsonFile, writeLanAccessJsonFile } from '../lan-access-json-file.mjs'
|
|
5
18
|
import { sendJson, readJsonBody } from '../utils/response.mjs'
|
|
6
19
|
import {
|
|
7
20
|
ensureStorage,
|
|
@@ -14,10 +27,10 @@ import {
|
|
|
14
27
|
const BACKUP_VERSION = 1
|
|
15
28
|
const BACKUP_APP = 'quickforge'
|
|
16
29
|
const IMPORT_UPLOAD_MAX_BYTES = Number(process.env.QUICKFORGE_IMPORT_UPLOAD_MAX_BYTES || 1024 * 1024 * 1024)
|
|
17
|
-
const backupScopes = new Set(['all', 'config', 'sessions'])
|
|
30
|
+
const backupScopes = new Set(['all', 'config', 'sessions', 'shares', 'lan-access'])
|
|
18
31
|
const settingsSectionIds = ['settings', 'mcp', 'providerKeys', 'customProviders', 'scheduledTasks']
|
|
19
32
|
const exportSectionIds = new Set(settingsSectionIds)
|
|
20
|
-
const restoreSectionIds = new Set([...settingsSectionIds, 'conversations'])
|
|
33
|
+
const restoreSectionIds = new Set([...settingsSectionIds, 'conversations', 'shares', 'lanAccess'])
|
|
21
34
|
const restoreModes = new Set(['replace', 'merge'])
|
|
22
35
|
|
|
23
36
|
function normalizeMode(value) {
|
|
@@ -151,22 +164,30 @@ function normalizeSessionMetadata(sessions, metadata) {
|
|
|
151
164
|
for (const [sessionId, session] of Object.entries(sessionsObject)) {
|
|
152
165
|
if (!session || typeof session !== 'object' || Array.isArray(session)) continue
|
|
153
166
|
const existing = metadataObject?.[sessionId]
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
167
|
+
if (existing && typeof existing === 'object' && !Array.isArray(existing)) {
|
|
168
|
+
nextMetadata[sessionId] = {
|
|
169
|
+
...existing,
|
|
170
|
+
...(!Number.isInteger(existing.stateVersion) && Number.isInteger(session.stateVersion) && session.stateVersion >= 0
|
|
171
|
+
? { stateVersion: session.stateVersion }
|
|
172
|
+
: {}),
|
|
173
|
+
}
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
nextMetadata[sessionId] = {
|
|
177
|
+
id: sessionId,
|
|
178
|
+
title: typeof session.title === 'string' ? session.title : 'New chat',
|
|
179
|
+
createdAt: typeof session.createdAt === 'string' ? session.createdAt : now,
|
|
180
|
+
lastModified: typeof session.lastModified === 'string' ? session.lastModified : now,
|
|
181
|
+
messageCount: Array.isArray(session.messages) ? session.messages.length : 0,
|
|
182
|
+
stateVersion: Number.isInteger(session.stateVersion) && session.stateVersion >= 0 ? session.stateVersion : undefined,
|
|
183
|
+
thinkingLevel: typeof session.thinkingLevel === 'string' ? session.thinkingLevel : 'off',
|
|
184
|
+
preview: '',
|
|
185
|
+
scope: session.scope === 'project' ? 'project' : 'global',
|
|
186
|
+
projectId: session.scope === 'project' && session.projectId ? String(session.projectId) : undefined,
|
|
187
|
+
taskStatus: session.taskStatus || 'idle',
|
|
188
|
+
taskStartedAt: session.taskStartedAt ?? null,
|
|
189
|
+
taskFinishedAt: session.taskFinishedAt ?? null,
|
|
190
|
+
}
|
|
170
191
|
}
|
|
171
192
|
|
|
172
193
|
return nextMetadata
|
|
@@ -198,8 +219,13 @@ async function buildBackup(scope = 'all', options = {}) {
|
|
|
198
219
|
const selected = normalizeExportSections(options.sections)
|
|
199
220
|
const includeConfig = selected ? true : normalizedScope === 'all' || normalizedScope === 'config'
|
|
200
221
|
const includeSessions = selected ? false : normalizedScope === 'all' || normalizedScope === 'sessions'
|
|
222
|
+
const includeShares = selected ? false : normalizedScope === 'all' || normalizedScope === 'shares'
|
|
223
|
+
const includeLanAccess = selected ? false : normalizedScope === 'all' || normalizedScope === 'lan-access'
|
|
201
224
|
const includeSecrets = selected ? selected.has('providerKeys') : Boolean(options.includeSecrets && includeConfig)
|
|
202
225
|
const data = {}
|
|
226
|
+
let sessionState = null
|
|
227
|
+
let shareState = null
|
|
228
|
+
let lanAccessState = null
|
|
203
229
|
|
|
204
230
|
if (includeConfig) {
|
|
205
231
|
const shouldInclude = (id) => !selected || selected.has(id)
|
|
@@ -208,7 +234,7 @@ async function buildBackup(scope = 'all', options = {}) {
|
|
|
208
234
|
shouldInclude('mcp') ? readStore('mcp').then((value) => ['mcp', value]) : null,
|
|
209
235
|
shouldInclude('providerKeys') ? readStore('provider-keys').then((value) => ['providerKeys', value]) : null,
|
|
210
236
|
shouldInclude('customProviders') ? readStore('custom-providers').then((value) => ['customProviders', value]) : null,
|
|
211
|
-
shouldInclude('scheduledTasks') ?
|
|
237
|
+
shouldInclude('scheduledTasks') ? readScheduledTasksForBackup().then((value) => ['scheduledTasks', value]) : null,
|
|
212
238
|
options.includeLocalProjects ? readProjectConfigData().then((value) => ['projects', value]) : null,
|
|
213
239
|
])
|
|
214
240
|
for (const entry of entries) {
|
|
@@ -217,14 +243,54 @@ async function buildBackup(scope = 'all', options = {}) {
|
|
|
217
243
|
}
|
|
218
244
|
|
|
219
245
|
if (includeSessions) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
246
|
+
if (isSessionStateAuthoritative()) {
|
|
247
|
+
// Authoritative export: integrity verified under the session state
|
|
248
|
+
// maintenance lock; the envelope carries phase + count + digest so the
|
|
249
|
+
// restore path can verify the snapshot round-trips exactly.
|
|
250
|
+
const snapshot = await exportSessionStateForBackup()
|
|
251
|
+
Object.assign(data, {
|
|
252
|
+
sessions: snapshot.sessions,
|
|
253
|
+
sessionsMetadata: snapshot.sessionsMetadata,
|
|
254
|
+
})
|
|
255
|
+
sessionState = { phase: snapshot.phase, count: snapshot.count, digest: snapshot.digest }
|
|
256
|
+
} else {
|
|
257
|
+
const [sessions, sessionsMetadata] = await Promise.all([
|
|
258
|
+
readStore('sessions'),
|
|
259
|
+
readStore('sessions-metadata'),
|
|
260
|
+
])
|
|
261
|
+
Object.assign(data, {
|
|
262
|
+
sessions,
|
|
263
|
+
sessionsMetadata,
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (includeShares) {
|
|
269
|
+
if (isShareStorageAuthoritative()) {
|
|
270
|
+
// Authoritative export: integrity verified under the share maintenance
|
|
271
|
+
// lock; the envelope carries phase + count + digest so the restore path
|
|
272
|
+
// can verify the snapshot round-trips exactly. Records include tokens
|
|
273
|
+
// (hashes only — the raw secrets never leave the issuer).
|
|
274
|
+
const snapshot = await exportShareStateForBackup()
|
|
275
|
+
Object.assign(data, { shares: snapshot.shares })
|
|
276
|
+
shareState = { phase: snapshot.phase, count: snapshot.count, digest: snapshot.digest }
|
|
277
|
+
} else {
|
|
278
|
+
data.shares = await readSharesJsonFile()
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (includeLanAccess) {
|
|
283
|
+
if (isLanAccessStorageAuthoritative()) {
|
|
284
|
+
// Authoritative export: integrity verified under the lan-access
|
|
285
|
+
// maintenance lock; the envelope carries phase + count + digest so the
|
|
286
|
+
// restore path can verify the snapshot round-trips exactly. The config
|
|
287
|
+
// keeps token hashes only and strips the repository-internal revision.
|
|
288
|
+
const snapshot = await exportLanAccessStateForBackup()
|
|
289
|
+
Object.assign(data, { lanAccess: snapshot.lanAccess })
|
|
290
|
+
lanAccessState = { phase: snapshot.phase, count: snapshot.count, digest: snapshot.digest }
|
|
291
|
+
} else {
|
|
292
|
+
data.lanAccess = await readLanAccessJsonFile()
|
|
293
|
+
}
|
|
228
294
|
}
|
|
229
295
|
|
|
230
296
|
return {
|
|
@@ -234,6 +300,9 @@ async function buildBackup(scope = 'all', options = {}) {
|
|
|
234
300
|
scope: selected ? 'config' : normalizedScope,
|
|
235
301
|
exportedSections: selected ? settingsSectionIds.filter((id) => selected.has(id)) : undefined,
|
|
236
302
|
includeSecrets,
|
|
303
|
+
...(sessionState ? { sessionState } : {}),
|
|
304
|
+
...(shareState ? { shareState } : {}),
|
|
305
|
+
...(lanAccessState ? { lanAccessState } : {}),
|
|
237
306
|
data,
|
|
238
307
|
}
|
|
239
308
|
}
|
|
@@ -266,6 +335,8 @@ function normalizeBackupPayload(payload) {
|
|
|
266
335
|
scheduledTasks: section(data, 'scheduledTasks', 'scheduled-tasks'),
|
|
267
336
|
sessions: section(data, 'sessions'),
|
|
268
337
|
sessionsMetadata: section(data, 'sessionsMetadata', 'sessions-metadata'),
|
|
338
|
+
shares: section(data, 'shares'),
|
|
339
|
+
lanAccess: section(data, 'lanAccess'),
|
|
269
340
|
}
|
|
270
341
|
|
|
271
342
|
// Backward compat: older backups stored MCP servers inside settings.mcpServers.
|
|
@@ -348,6 +419,8 @@ function validateBackupPayload(payload) {
|
|
|
348
419
|
scheduledTasks: assertObjectSection(sections.scheduledTasks, 'scheduledTasks'),
|
|
349
420
|
sessions,
|
|
350
421
|
sessionsMetadata,
|
|
422
|
+
shares: assertObjectSection(sections.shares, 'shares'),
|
|
423
|
+
lanAccess: assertObjectSection(sections.lanAccess, 'lanAccess'),
|
|
351
424
|
}
|
|
352
425
|
|
|
353
426
|
if (Object.values(validatedSections).every((value) => value === undefined)) {
|
|
@@ -389,6 +462,8 @@ function normalizeRestoreSections(value, sections) {
|
|
|
389
462
|
|
|
390
463
|
const unavailable = [...selected].filter((id) => {
|
|
391
464
|
if (id === 'conversations') return sections.sessions === undefined && sections.sessionsMetadata === undefined
|
|
465
|
+
if (id === 'shares') return sections.shares === undefined
|
|
466
|
+
if (id === 'lanAccess') return sections.lanAccess === undefined
|
|
392
467
|
return sections[id] === undefined
|
|
393
468
|
})
|
|
394
469
|
if (unavailable.length > 0) {
|
|
@@ -410,6 +485,8 @@ function filterRestoreSections(sections, selected) {
|
|
|
410
485
|
scheduledTasks: selected.has('scheduledTasks') ? sections.scheduledTasks : undefined,
|
|
411
486
|
sessions: selected.has('conversations') ? sections.sessions : undefined,
|
|
412
487
|
sessionsMetadata: selected.has('conversations') ? sections.sessionsMetadata : undefined,
|
|
488
|
+
shares: selected.has('shares') ? sections.shares : undefined,
|
|
489
|
+
lanAccess: selected.has('lanAccess') ? sections.lanAccess : undefined,
|
|
413
490
|
}
|
|
414
491
|
}
|
|
415
492
|
|
|
@@ -478,6 +555,10 @@ function buildSummary(sections) {
|
|
|
478
555
|
if (sections.scheduledTasks !== undefined) summary.scheduledTasks = countKeys(sections.scheduledTasks)
|
|
479
556
|
if (sections.sessions !== undefined) summary.sessions = countKeys(filterSessionsByMetadata(sections.sessions, sections.sessionsMetadata))
|
|
480
557
|
if (sections.sessionsMetadata !== undefined) summary.sessionsMetadata = countKeys(sections.sessionsMetadata)
|
|
558
|
+
if (sections.shares !== undefined) summary.shares = countKeys(sections.shares)
|
|
559
|
+
if (sections.lanAccess !== undefined) {
|
|
560
|
+
summary.lanAccess = sections.lanAccess && Array.isArray(sections.lanAccess.tokens) ? sections.lanAccess.tokens.length : 0
|
|
561
|
+
}
|
|
481
562
|
return summary
|
|
482
563
|
}
|
|
483
564
|
|
|
@@ -497,6 +578,12 @@ async function inspectBackup(payload) {
|
|
|
497
578
|
if (backup.sections.sessions !== undefined || backup.sections.sessionsMetadata !== undefined) {
|
|
498
579
|
warnings.push('Importing conversations will replace local conversation data.')
|
|
499
580
|
}
|
|
581
|
+
if (backup.sections.shares !== undefined) {
|
|
582
|
+
warnings.push('Importing shares will replace local share links (only hashes are exported, raw passwords and tokens stay local).')
|
|
583
|
+
}
|
|
584
|
+
if (backup.sections.lanAccess !== undefined) {
|
|
585
|
+
warnings.push('Importing LAN access configuration will replace the local LAN access config (将替换局域网访问配置,包括 enabled 开关). Only hashes are exported; raw passwords and tokens stay local.')
|
|
586
|
+
}
|
|
500
587
|
if (missingProjectTasks.length > 0) {
|
|
501
588
|
warnings.push(`${missingProjectTasks.length} scheduled task(s) reference projects that are not registered locally and will be paused after import.`)
|
|
502
589
|
}
|
|
@@ -560,22 +647,90 @@ async function restoreValidatedBackup(backup, mode = 'replace') {
|
|
|
560
647
|
if (sections.scheduledTasks !== undefined) {
|
|
561
648
|
const projectIds = await localProjectIds()
|
|
562
649
|
const safeTasks = pauseScheduledTasksWithMissingProjects(sections.scheduledTasks, projectIds)
|
|
563
|
-
const value = merge ? mergeRecordStore(await
|
|
564
|
-
await
|
|
650
|
+
const value = merge ? mergeRecordStore(await readScheduledTasksForBackup(), safeTasks) : safeTasks
|
|
651
|
+
await restoreScheduledTasks(value, { mode: 'replace' })
|
|
565
652
|
summary.scheduledTasks = countKeys(value)
|
|
566
653
|
}
|
|
567
654
|
|
|
568
|
-
if (sections.sessions !== undefined) {
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
655
|
+
if (sections.sessions !== undefined || sections.sessionsMetadata !== undefined) {
|
|
656
|
+
if (isSessionStateAuthoritative()) {
|
|
657
|
+
// Authoritative restore: a single maintenance-locked, compensated SQLite
|
|
658
|
+
// transaction that replaces/merges bodies and metadata together. It never
|
|
659
|
+
// touches scheduled_task_runs or JSON config stores.
|
|
660
|
+
if (sections.sessions === undefined) {
|
|
661
|
+
const error = new Error('Session metadata restore requires session bodies in authoritative mode')
|
|
662
|
+
error.statusCode = 400
|
|
663
|
+
throw error
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
const restored = await restoreSessionStateSnapshot(
|
|
667
|
+
{ sessions: sections.sessions, sessionsMetadata: sections.sessionsMetadata ?? {} },
|
|
668
|
+
{ mode },
|
|
669
|
+
)
|
|
670
|
+
summary.sessions = restored.sessions
|
|
671
|
+
summary.sessionsMetadata = restored.sessionsMetadata
|
|
672
|
+
} catch (error) {
|
|
673
|
+
if (error instanceof TypeError) error.statusCode = 400
|
|
674
|
+
throw error
|
|
675
|
+
}
|
|
676
|
+
} else {
|
|
677
|
+
if (sections.sessions !== undefined) {
|
|
678
|
+
const sessions = filterSessionsByMetadata(sections.sessions, sections.sessionsMetadata)
|
|
679
|
+
const value = merge ? mergeRecordStore(await readStore('sessions'), sessions) : sessions
|
|
680
|
+
await writeStore('sessions', value)
|
|
681
|
+
summary.sessions = countKeys(value)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (sections.sessionsMetadata !== undefined) {
|
|
685
|
+
const value = merge ? mergeRecordStore(await readStore('sessions-metadata'), sections.sessionsMetadata) : sections.sessionsMetadata
|
|
686
|
+
await writeStore('sessions-metadata', value)
|
|
687
|
+
summary.sessionsMetadata = countKeys(value)
|
|
688
|
+
}
|
|
689
|
+
}
|
|
573
690
|
}
|
|
574
691
|
|
|
575
|
-
if (sections.
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
692
|
+
if (sections.shares !== undefined) {
|
|
693
|
+
if (isShareStorageAuthoritative()) {
|
|
694
|
+
// Authoritative restore: a single maintenance-locked, compensated SQLite
|
|
695
|
+
// transaction via replaceAll. Merge keeps local-only records and lets the
|
|
696
|
+
// backup override same-key entries; replace wipes local shares first. The
|
|
697
|
+
// restore is scoped to the share tables only and never touches the
|
|
698
|
+
// scheduled-runs, session-index or message-storage data.
|
|
699
|
+
try {
|
|
700
|
+
const restored = await restoreShareStateSnapshot({ shares: sections.shares }, { mode })
|
|
701
|
+
summary.shares = restored.shares
|
|
702
|
+
} catch (error) {
|
|
703
|
+
if (error instanceof TypeError) error.statusCode = 400
|
|
704
|
+
throw error
|
|
705
|
+
}
|
|
706
|
+
} else {
|
|
707
|
+
const value = merge ? mergeRecordStore(await readSharesJsonFile(), sections.shares) : sections.shares
|
|
708
|
+
await writeSharesJsonFile(value)
|
|
709
|
+
summary.shares = countKeys(value)
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (sections.lanAccess !== undefined) {
|
|
714
|
+
if (isLanAccessStorageAuthoritative()) {
|
|
715
|
+
// Authoritative restore: a single maintenance-locked, compensated SQLite
|
|
716
|
+
// transaction via replaceAll. Merge keeps local config fields (including
|
|
717
|
+
// local tokens) while the backup overrides same-key fields; replace wipes
|
|
718
|
+
// the whole config first. The restore overwrites the `enabled` switch
|
|
719
|
+
// when the backup carries one (inspect warns), and is scoped to the
|
|
720
|
+
// lan-access tables only — never touching scheduled-runs, session-index,
|
|
721
|
+
// message-storage or share data.
|
|
722
|
+
try {
|
|
723
|
+
const restored = await restoreLanAccessStateSnapshot({ lanAccess: sections.lanAccess }, { mode })
|
|
724
|
+
summary.lanAccess = restored.lanAccess
|
|
725
|
+
} catch (error) {
|
|
726
|
+
if (error instanceof TypeError) error.statusCode = 400
|
|
727
|
+
throw error
|
|
728
|
+
}
|
|
729
|
+
} else {
|
|
730
|
+
const value = merge ? mergeRecordStore(await readLanAccessJsonFile(), sections.lanAccess) : sections.lanAccess
|
|
731
|
+
await writeLanAccessJsonFile(value)
|
|
732
|
+
summary.lanAccess = countKeys(value)
|
|
733
|
+
}
|
|
579
734
|
}
|
|
580
735
|
|
|
581
736
|
return summary
|
|
@@ -589,6 +744,12 @@ export async function handleBackupApi(req, res, url, context = { isLocalRequest:
|
|
|
589
744
|
throw error
|
|
590
745
|
}
|
|
591
746
|
if (req.method === 'GET' && url.pathname === '/api/backup/export') {
|
|
747
|
+
if (isScheduledRunsMaintenanceActive()) {
|
|
748
|
+
const error = new Error('Scheduled task maintenance is in progress')
|
|
749
|
+
error.statusCode = 423
|
|
750
|
+
error.errorCode = 'scheduled_runs_maintenance'
|
|
751
|
+
throw error
|
|
752
|
+
}
|
|
592
753
|
await ensureStorage()
|
|
593
754
|
sendJson(res, 200, await buildBackup(url.searchParams.get('scope'), {
|
|
594
755
|
sections: url.searchParams.get('sections'),
|
|
@@ -621,7 +782,28 @@ export async function handleBackupApi(req, res, url, context = { isLocalRequest:
|
|
|
621
782
|
const body = await readJsonBody(req)
|
|
622
783
|
const importBody = body?.importToken ? { ...body, backup: await readPendingImportBackup(body.importToken) } : body
|
|
623
784
|
const { backup, mode } = parseImportPayload(importBody)
|
|
624
|
-
const
|
|
785
|
+
const restoringSessions = backup.sections.sessions !== undefined || backup.sections.sessionsMetadata !== undefined
|
|
786
|
+
const restoringShares = backup.sections.shares !== undefined
|
|
787
|
+
const restoringLanAccess = backup.sections.lanAccess !== undefined
|
|
788
|
+
if (restoringSessions && isSessionStateAuthoritative() && isSessionStateMaintenanceActive()) {
|
|
789
|
+
const error = new Error('Session state maintenance is in progress')
|
|
790
|
+
error.statusCode = 423
|
|
791
|
+
error.errorCode = 'session_state_maintenance'
|
|
792
|
+
throw error
|
|
793
|
+
}
|
|
794
|
+
if (restoringShares && isShareStorageAuthoritative() && isShareMaintenanceActive()) {
|
|
795
|
+
const error = new Error('Share storage maintenance is in progress')
|
|
796
|
+
error.statusCode = 423
|
|
797
|
+
error.errorCode = 'share_maintenance'
|
|
798
|
+
throw error
|
|
799
|
+
}
|
|
800
|
+
if (restoringLanAccess && isLanAccessStorageAuthoritative() && isLanAccessMaintenanceActive()) {
|
|
801
|
+
const error = new Error('LAN access storage maintenance is in progress')
|
|
802
|
+
error.statusCode = 423
|
|
803
|
+
error.errorCode = 'lan_access_maintenance'
|
|
804
|
+
throw error
|
|
805
|
+
}
|
|
806
|
+
const safetyBackupPath = await writeSafetyBackup(restoringSessions || restoringShares || restoringLanAccess ? 'all' : 'config')
|
|
625
807
|
const summary = await restoreValidatedBackup(backup, mode)
|
|
626
808
|
if (body?.importToken) await deletePendingImportBackup(body.importToken)
|
|
627
809
|
sendJson(res, 200, { ok: true, safetyBackupPath, summary })
|
package/server/routes/cloud.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { getQfAgentStatus } from '../cloud/qf-agent-process.mjs'
|
|
|
2
2
|
import { isAuthenticatedAppClient } from '../access-policy.mjs'
|
|
3
3
|
import { CloudClient, CloudApiError } from '../cloud/client.mjs'
|
|
4
4
|
import { parseCloudBaseUrl } from '../cloud/config.mjs'
|
|
5
|
+
import { armAgentAutoApproval, clearAgentAutoApproval, retryAgentAutoApproval } from '../cloud/auto-approval.mjs'
|
|
5
6
|
import { createCloudCredentialStore } from '../cloud/credential-store.mjs'
|
|
6
7
|
import { getCloudRuntime, invalidateCloudRuntime } from '../cloud/runtime.mjs'
|
|
7
8
|
import {
|
|
@@ -22,6 +23,7 @@ const JSON_WRITE_ROUTES = new Set([
|
|
|
22
23
|
'POST /api/cloud/device/start',
|
|
23
24
|
'POST /api/cloud/device/poll',
|
|
24
25
|
'POST /api/cloud/device/cancel',
|
|
26
|
+
'POST /api/cloud/remote/authorize-retry',
|
|
25
27
|
])
|
|
26
28
|
|
|
27
29
|
function routeError(message, statusCode, code) {
|
|
@@ -81,6 +83,9 @@ export function createCloudRouteHandler({
|
|
|
81
83
|
invalidateRuntime = invalidateCloudRuntime,
|
|
82
84
|
cloudClientFactory = (config) => new CloudClient(config),
|
|
83
85
|
qfAgentStatus = getQfAgentStatus,
|
|
86
|
+
armAutoApproval = armAgentAutoApproval,
|
|
87
|
+
clearAutoApproval = clearAgentAutoApproval,
|
|
88
|
+
retryAutoApproval = retryAgentAutoApproval,
|
|
84
89
|
onServiceConfigChanged,
|
|
85
90
|
} = {}) {
|
|
86
91
|
async function runtimeOrError() {
|
|
@@ -108,6 +113,17 @@ export function createCloudRouteHandler({
|
|
|
108
113
|
return
|
|
109
114
|
}
|
|
110
115
|
|
|
116
|
+
// 自动批准失败后由 UI 发起的重试:仍使用服务端内存中保留的 user_code,
|
|
117
|
+
// 不要求用户打开授权页或输入码。
|
|
118
|
+
if (req.method === 'POST' && url.pathname === '/api/cloud/remote/authorize-retry') {
|
|
119
|
+
if (context.isLocalRequest !== true) {
|
|
120
|
+
throw routeError('Remote agent authorization can be retried only on this computer.', 403, 'cloud_local_only')
|
|
121
|
+
}
|
|
122
|
+
await readJsonBody(req, CLOUD_CONFIG_BODY_MAX_BYTES)
|
|
123
|
+
sendJson(res, 200, await retryAutoApproval())
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
111
127
|
if (req.method === 'GET' && url.pathname === '/api/cloud/config') {
|
|
112
128
|
sendJson(res, 200, publicCloudServiceConfig(await readServiceConfig({ strict: false })))
|
|
113
129
|
return
|
|
@@ -138,6 +154,10 @@ export function createCloudRouteHandler({
|
|
|
138
154
|
const currentConfig = await readServiceConfig({ strict: false })
|
|
139
155
|
const nextUrl = parseCloudBaseUrl(body?.cloudUrl === undefined ? currentConfig.cloudUrl : body.cloudUrl).href
|
|
140
156
|
const nextEnabled = body?.enabled === undefined ? currentConfig.enabled === true : body.enabled
|
|
157
|
+
// 仅在本机用户显式将 Cloud 服务从 disabled 切换为 enabled 时立即创建短时、一次性的
|
|
158
|
+
// agent 自动批准意图;其余场景由 qf-agent 在首次 authorizing 时依据本机 desktop 会话
|
|
159
|
+
// 自动 arm(认证远程客户端触发的变更以 'manual' 策略排除,见下方 notify)。
|
|
160
|
+
const enabledTransition = context.isLocalRequest === true && currentConfig.enabled !== true && nextEnabled === true
|
|
141
161
|
const urlChanged = !sameCloudUrl(currentConfig.cloudUrl, nextUrl)
|
|
142
162
|
if (body?.cloudUrl !== undefined) {
|
|
143
163
|
const credentialStore = credentialStoreFactory()
|
|
@@ -152,9 +172,16 @@ export function createCloudRouteHandler({
|
|
|
152
172
|
}
|
|
153
173
|
await saveServiceConfig({ cloudUrl: nextUrl, enabled: nextEnabled })
|
|
154
174
|
invalidateRuntime()
|
|
175
|
+
if (enabledTransition) armAutoApproval()
|
|
176
|
+
else if (!nextEnabled) clearAutoApproval()
|
|
155
177
|
const saved = await readServiceConfig({ strict: false })
|
|
156
178
|
const notify = context.onCloudServiceConfigChanged || onServiceConfigChanged
|
|
157
|
-
|
|
179
|
+
// 认证远程客户端触发的配置变更以 manual 策略通知生命周期:由此启动的 agent
|
|
180
|
+
// 生命周期(含其自动重启)不自动创建批准意图;本机请求保持默认(允许自动 arm)。
|
|
181
|
+
const remoteInitiated = context.isLocalRequest !== true
|
|
182
|
+
if (typeof notify === 'function') {
|
|
183
|
+
await notify(saved, remoteInitiated ? { urlChanged, autoApprovalPolicy: 'manual' } : { urlChanged })
|
|
184
|
+
}
|
|
158
185
|
sendJson(res, 200, publicCloudServiceConfig(saved))
|
|
159
186
|
return
|
|
160
187
|
}
|