@shawnstack/quickforge 1.7.7 → 1.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
  2. package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
  3. package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
  4. package/dist/assets/{PluginsPage-CPE5tbbf.js → PluginsPage-mJkTmVVV.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-BE2T4d3P.js → SettingsWorkspacePage-7lQ0Wl63.js} +389 -389
  7. package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
  8. package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
  9. package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
  10. package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
  11. package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
  12. package/dist/assets/{index-BiCTgUlY.css → index-D-J_8Smf.css} +1 -1
  13. package/dist/assets/index-DFASc15l.js +66 -0
  14. package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
  15. package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
  16. package/dist/assets/{mcp-servers-dialog-GY7sdfIr.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
  17. package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
  18. package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
  19. package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
  20. package/dist/assets/{skills-dialog-C1fJ1Jl7.js → skills-dialog-DIzZvQqW.js} +3 -3
  21. package/dist/index.html +8 -8
  22. package/package.json +1 -1
  23. package/server/acp/server.mjs +17 -1
  24. package/server/agent-manager.mjs +251 -31
  25. package/server/ai-http-logger.mjs +162 -19
  26. package/server/ai-provider-options.mjs +4 -1
  27. package/server/auto-archive.mjs +42 -29
  28. package/server/cloud/auto-approval.mjs +155 -0
  29. package/server/cloud/client.mjs +5 -0
  30. package/server/cloud/qf-agent-process.mjs +163 -3
  31. package/server/cloud/service-config.mjs +1 -1
  32. package/server/index.mjs +69 -13
  33. package/server/lan-access-backup.mjs +256 -0
  34. package/server/lan-access-cutover.mjs +306 -0
  35. package/server/lan-access-json-file.mjs +75 -0
  36. package/server/lan-access-service.mjs +155 -0
  37. package/server/lan-access-store.mjs +110 -9
  38. package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
  39. package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
  40. package/server/maintenance/downgrade-share-v1.mjs +101 -0
  41. package/server/maintenance/export-lan-access-v1.mjs +80 -0
  42. package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
  43. package/server/maintenance/export-session-state-v1.mjs +67 -0
  44. package/server/maintenance/export-share-v1.mjs +77 -0
  45. package/server/routes/agent.mjs +37 -4
  46. package/server/routes/backup.mjs +221 -39
  47. package/server/routes/cloud.mjs +28 -1
  48. package/server/routes/scheduled-tasks.mjs +347 -134
  49. package/server/routes/shares.mjs +4 -2
  50. package/server/routes/storage.mjs +138 -8
  51. package/server/routes/workspace.mjs +265 -4
  52. package/server/scheduled-runs-backup.mjs +235 -0
  53. package/server/scheduled-runs-cutover.mjs +497 -0
  54. package/server/scheduled-task-runs-service.mjs +279 -0
  55. package/server/session-index-service.mjs +495 -0
  56. package/server/session-state-backup.mjs +277 -0
  57. package/server/session-state-cutover.mjs +370 -0
  58. package/server/session-state-service.mjs +709 -0
  59. package/server/share-backup.mjs +255 -0
  60. package/server/share-cutover.mjs +296 -0
  61. package/server/share-json-file.mjs +60 -0
  62. package/server/share-service.mjs +163 -0
  63. package/server/share-store.mjs +196 -19
  64. package/server/skills.mjs +0 -1
  65. package/server/sqlite/database.mjs +233 -0
  66. package/server/sqlite/lan-access-repository.mjs +652 -0
  67. package/server/sqlite/migrations.mjs +577 -0
  68. package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
  69. package/server/sqlite/session-index-repository.mjs +297 -0
  70. package/server/sqlite/session-state-repository.mjs +786 -0
  71. package/server/sqlite/share-repository.mjs +854 -0
  72. package/server/storage.mjs +317 -1
  73. package/dist/assets/AgentProfilesPage-BfA_sQ3A.js +0 -1
  74. package/dist/assets/ChatPanelHost-Dul1ahKj.js +0 -307
  75. package/dist/assets/CloudAccountSettingsPage-qZh0lTUN.js +0 -1
  76. package/dist/assets/ScheduledTasksPage-PTxL_PPg.js +0 -2
  77. package/dist/assets/ShareLinksSettingsPage-Bl8fP62X.js +0 -1
  78. package/dist/assets/SharedConversationPage-BK9XJnEe.js +0 -1
  79. package/dist/assets/TerminalDock-CL7HU9DI.js +0 -2
  80. package/dist/assets/WorkspaceInspector-MNBLIKMn.js +0 -36
  81. package/dist/assets/index-BTPzyq7d.js +0 -65
  82. package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
  83. /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
+ }
@@ -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
@@ -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
- nextMetadata[sessionId] = existing && typeof existing === 'object' && !Array.isArray(existing)
155
- ? existing
156
- : {
157
- id: sessionId,
158
- title: typeof session.title === 'string' ? session.title : 'New chat',
159
- createdAt: typeof session.createdAt === 'string' ? session.createdAt : now,
160
- lastModified: typeof session.lastModified === 'string' ? session.lastModified : now,
161
- messageCount: Array.isArray(session.messages) ? session.messages.length : 0,
162
- thinkingLevel: typeof session.thinkingLevel === 'string' ? session.thinkingLevel : 'off',
163
- preview: '',
164
- scope: session.scope === 'project' ? 'project' : 'global',
165
- projectId: session.scope === 'project' && session.projectId ? String(session.projectId) : undefined,
166
- taskStatus: session.taskStatus || 'idle',
167
- taskStartedAt: session.taskStartedAt ?? null,
168
- taskFinishedAt: session.taskFinishedAt ?? null,
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') ? readStore('scheduled-tasks').then((value) => ['scheduledTasks', value]) : null,
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
- const [sessions, sessionsMetadata] = await Promise.all([
221
- readStore('sessions'),
222
- readStore('sessions-metadata'),
223
- ])
224
- Object.assign(data, {
225
- sessions,
226
- sessionsMetadata,
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 readStore('scheduled-tasks'), safeTasks) : safeTasks
564
- await writeStore('scheduled-tasks', value)
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
- const sessions = filterSessionsByMetadata(sections.sessions, sections.sessionsMetadata)
570
- const value = merge ? mergeRecordStore(await readStore('sessions'), sessions) : sessions
571
- await writeStore('sessions', value)
572
- summary.sessions = countKeys(value)
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.sessionsMetadata !== undefined) {
576
- const value = merge ? mergeRecordStore(await readStore('sessions-metadata'), sections.sessionsMetadata) : sections.sessionsMetadata
577
- await writeStore('sessions-metadata', value)
578
- summary.sessionsMetadata = countKeys(value)
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 safetyBackupPath = await writeSafetyBackup(backup.sections.sessions !== undefined || backup.sections.sessionsMetadata !== undefined ? 'all' : 'config')
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 })
@@ -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
- if (typeof notify === 'function') await notify(saved, { urlChanged })
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
  }