@shawnstack/quickforge 1.7.9 → 1.7.11

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 (53) hide show
  1. package/dist/assets/{AgentProfilesPage-BSM5w3bn.js → AgentProfilesPage-Cvb1KTz5.js} +1 -1
  2. package/dist/assets/ChatPanelHost-C7zV-xE_.js +48 -0
  3. package/dist/assets/{CloudAccountSettingsPage-BJMrfgy9.js → CloudAccountSettingsPage-mAs_nbvw.js} +1 -1
  4. package/dist/assets/{PluginsPage-mJkTmVVV.js → PluginsPage-mLvwvTmL.js} +1 -1
  5. package/dist/assets/{ScheduledTasksPage-DJj_NLm9.js → ScheduledTasksPage-CuUW_9lq.js} +1 -1
  6. package/dist/assets/{SettingsWorkspacePage-7lQ0Wl63.js → SettingsWorkspacePage-0OINfEqK.js} +390 -390
  7. package/dist/assets/{ShareLinksSettingsPage-DjFq_Lnb.js → ShareLinksSettingsPage-DqeyJYoD.js} +1 -1
  8. package/dist/assets/{SharedConversationPage-BbuNpcpP.js → SharedConversationPage-C7CDEoX9.js} +1 -1
  9. package/dist/assets/TerminalDock-CZXcuMpq.js +2 -0
  10. package/dist/assets/{WorkspaceInspector-0i6x-diB.js → WorkspaceInspector-CNSBOEvP.js} +3 -3
  11. package/dist/assets/index-BgMvQW1b.js +66 -0
  12. package/dist/assets/index-CB1g2D0f.css +3 -0
  13. package/dist/assets/local-tools-Dopjg9xq.js +270 -0
  14. package/dist/assets/{mcp-servers-dialog-BG4b67ea.js → mcp-servers-dialog-B2lALtTE.js} +1 -1
  15. package/dist/assets/{skills-dialog-DIzZvQqW.js → skills-dialog-DYp35JM3.js} +1 -1
  16. package/dist/index.html +4 -4
  17. package/package.json +1 -1
  18. package/server/acp/server.mjs +3 -6
  19. package/server/agent-manager.mjs +125 -43
  20. package/server/auto-archive.mjs +55 -8
  21. package/server/auto-compaction.mjs +0 -2
  22. package/server/context-usage.mjs +9 -7
  23. package/server/index.mjs +172 -51
  24. package/server/lan-access-cutover.mjs +21 -4
  25. package/server/maintenance/downgrade-session-state-v1.mjs +50 -87
  26. package/server/maintenance/export-session-state-v1.mjs +5 -13
  27. package/server/routes/backup.mjs +8 -1
  28. package/server/routes/storage.mjs +85 -48
  29. package/server/scheduled-runs-cutover.mjs +15 -1
  30. package/server/session-index-service.mjs +57 -391
  31. package/server/session-persistence-lock.mjs +17 -6
  32. package/server/session-state-backup.mjs +60 -7
  33. package/server/session-state-import.mjs +157 -0
  34. package/server/session-state-maintenance.mjs +126 -0
  35. package/server/session-state-service.mjs +133 -194
  36. package/server/share-cutover.mjs +21 -4
  37. package/server/sqlite/database.mjs +142 -7
  38. package/server/sqlite/lan-access-repository.mjs +6 -4
  39. package/server/sqlite/migrations.mjs +109 -0
  40. package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
  41. package/server/sqlite/session-index-repository.mjs +59 -208
  42. package/server/sqlite/session-state-repository.mjs +359 -289
  43. package/server/sqlite/share-repository.mjs +56 -45
  44. package/server/startup-state.mjs +107 -0
  45. package/server/storage.mjs +240 -448
  46. package/server/utils/logger.mjs +15 -4
  47. package/server/utils/process-tree.mjs +14 -2
  48. package/dist/assets/ChatPanelHost-cd80S4EX.js +0 -48
  49. package/dist/assets/TerminalDock-CczcCJJE.js +0 -2
  50. package/dist/assets/index-D-J_8Smf.css +0 -3
  51. package/dist/assets/index-DFASc15l.js +0 -66
  52. package/dist/assets/local-tools-Dn1Y9aPe.js +0 -270
  53. package/server/session-state-cutover.mjs +0 -370
package/server/index.mjs CHANGED
@@ -6,9 +6,9 @@ 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, readPhysicalSessionMetadataBuckets, registerSessionMetadataCommitHook } from './storage.mjs'
10
- import { initializeSessionStateCutover } from './session-state-cutover.mjs'
11
- import { initializeSessionStateService, drainSessionJsonMirror, stopSessionStateService } from './session-state-service.mjs'
9
+ import { ensureStorage, dataDir, configDir, storageDir, cacheDir, logsDir, createPhysicalSessionStateFsAdapter } from './storage.mjs'
10
+ import { initializeSessionStateService, stopSessionStateService } from './session-state-service.mjs'
11
+ import { importSessionStateFromJson } from './session-state-import.mjs'
12
12
  import { initializeShareCutover } from './share-cutover.mjs'
13
13
  import { drainShareJsonMirror, initializeShareService, stopShareService } from './share-service.mjs'
14
14
  import { initializeLanAccessCutover } from './lan-access-cutover.mjs'
@@ -57,12 +57,13 @@ import { shutdown as shutdownAgentManager, resetStaleTaskStatuses } from './agen
57
57
  import { initializeChannels, shutdownChannels } from './channels/registry.mjs'
58
58
  import { shutdownMcpConnections } from './mcp/registry.mjs'
59
59
  import { shutdownTerminalSessions } from './terminal/terminal-manager.mjs'
60
- import { closeSqliteStorage, getSqliteStorageSummary, initializeSqliteStorage } from './sqlite/database.mjs'
60
+ import { closeSqliteStorage, getSqliteStorage, getSqliteStorageSummary, initializeSqliteStorage } from './sqlite/database.mjs'
61
61
  import { initializeScheduledRunsCutover } from './scheduled-runs-cutover.mjs'
62
62
  import { recoverScheduledRunsRestorePlan } from './scheduled-runs-backup.mjs'
63
- import { configureSessionIndex, getSessionIndexDiagnostics, initializeSessionIndex, syncSessionMetadataCommit } from './session-index-service.mjs'
63
+ import { getSessionIndexDiagnostics, initializeSessionIndex } from './session-index-service.mjs'
64
64
  import { createNodeProcessEnv } from './utils/process-env.mjs'
65
65
  import { isAuthenticatedAppClient } from './access-policy.mjs'
66
+ import { STARTUP_STATES, getStartupError, getStartupState, readMigrationStatus, resolveMaintenanceGate, setStartupState, withStartupRecoveryGuidance } from './startup-state.mjs'
66
67
 
67
68
  const __filename = fileURLToPath(import.meta.url)
68
69
  const __dirname = path.dirname(__filename)
@@ -87,6 +88,7 @@ let shutdownPromise = null
87
88
  let shutdownStarted = false
88
89
  let qfAgentStartPromise = null
89
90
  let boundServerPort = null
91
+ let startupInitializationPromise = null
90
92
 
91
93
  setDefaultWorkspaceRoot(process.env.QUICKFORGE_WORKSPACE_DIR || path.join(dataDir, 'workspace'))
92
94
 
@@ -368,10 +370,36 @@ async function handleApi(req, res, url, requestContext = {}) {
368
370
 
369
371
  // Health check
370
372
  if (req.method === 'GET' && pathname === '/api/health') {
373
+ const startupState = getStartupState()
374
+ if (startupState === STARTUP_STATES.FAILED) {
375
+ // Startup failed but the process stays alive: waitForQuickForge keeps
376
+ // polling (payload.ok is false) until its timeout, preserving the
377
+ // "startup failed" semantics for CLI/Desktop.
378
+ sendJson(res, 200, { ok: false, startupError: getStartupError() })
379
+ return
380
+ }
381
+ if (startupState === STARTUP_STATES.MIGRATING) {
382
+ // Maintenance window: keep the existing fields (pid, sqlite summary,
383
+ // ...) so clients can bind early, plus maintenance:true for the UI.
384
+ // Fall back to the minimal shape if the full status is not readable.
385
+ try {
386
+ sendJson(res, 200, { maintenance: true, ...(await getSystemStatus(requestContext)) })
387
+ return
388
+ } catch {
389
+ sendJson(res, 200, { ok: true, maintenance: true })
390
+ return
391
+ }
392
+ }
371
393
  sendJson(res, 200, await getSystemStatus(requestContext))
372
394
  return
373
395
  }
374
396
 
397
+ // Migration progress during the startup maintenance window
398
+ if (req.method === 'GET' && pathname === '/api/migration-status') {
399
+ sendJson(res, 200, readMigrationStatus())
400
+ return
401
+ }
402
+
375
403
  if (req.method === 'GET' && pathname === '/api/project/commands') {
376
404
  await handleProjectApi(req, res, url, requestContext)
377
405
  return
@@ -707,6 +735,20 @@ const server = createServer(async (req, res) => {
707
735
  }
708
736
 
709
737
  if (url.pathname.startsWith('/api/')) {
738
+ // Startup maintenance gate: while the background startup chain runs (or
739
+ // failed), refuse every business API except the whitelisted readiness
740
+ // endpoints. Non-/api paths (static assets, /share/) stay ungated so the
741
+ // frontend can load and show migration progress.
742
+ const gate = resolveMaintenanceGate(req.method, url.pathname)
743
+ if (gate) {
744
+ res.writeHead(gate.status, {
745
+ 'content-type': 'application/json; charset=utf-8',
746
+ 'cache-control': 'no-store',
747
+ 'retry-after': '5',
748
+ })
749
+ res.end(JSON.stringify({ ok: false, maintenance: true, state: gate.state }))
750
+ return
751
+ }
710
752
  await handleApi(req, res, url, {
711
753
  isLocalRequest: !isRemoteRequest,
712
754
  remoteAddress,
@@ -775,51 +817,124 @@ server.on('upgrade', (req, socket, head) => {
775
817
  }
776
818
  })
777
819
 
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()
807
- await ensureDefaultGlobalSkills()
808
- await initializeNetworkProxy()
809
- installAiHttpLogger()
810
- initializeChannels({
811
- projectRoot,
812
- channelEventsUrl: `http://127.0.0.1:${port}/api/channels/events`,
813
- logsDir,
814
- })
815
- await resetStaleTaskStatuses()
816
- configureSessionIndex({ readBuckets: readPhysicalSessionMetadataBuckets })
817
- registerSessionMetadataCommitHook(syncSessionMetadataCommit)
818
- await initializeSessionIndex()
819
- await initializeActiveProject()
820
- setActiveWorkspaceRootForFilesystem(getWorkspaceRoot())
821
- startScheduledTaskRunner()
822
- startAutoArchiveRunner()
820
+ // --- Startup chain (P1: listen early + maintenance window) ---
821
+ // Storage + SQLite initialization stay in front of listen (the maintenance
822
+ // gate and /api/migration-status rely on the database). The remaining
823
+ // migration/initialization chain runs in the background behind the gate
824
+ // while /api/health and /api/migration-status already answer and the
825
+ // frontend page is served, so the UI can display migration progress.
826
+ //
827
+ // Fail-closed semantics changed from "abort the process" to "stay alive and
828
+ // refuse business APIs": on failure the startup state becomes 'failed',
829
+ // /api/health answers {ok:false, startupError} (waitForQuickForge keeps
830
+ // polling until its timeout, preserving the "startup failed" result) and
831
+ // every non-whitelisted /api/* route keeps answering 503 maintenance until
832
+ // the operator intervenes (fix data / restart).
833
+ try {
834
+ await ensureStorage()
835
+ await initializeSqliteStorage({ dataDir })
836
+ } catch (error) {
837
+ logger.error('QuickForge startup failed', { errorName: error?.name || 'Error', errorMessage: error?.message, stack: error?.stack })
838
+ flushLogger()
839
+ setStartupState(STARTUP_STATES.FAILED, withStartupRecoveryGuidance(error?.message))
840
+ }
841
+
842
+ // Startup-chain step timing: logs any single initialization step that takes
843
+ // >= 500ms with its duration, so slow steps on real machines (AV scanning,
844
+ // large libraries, network calls) surface directly in server logs instead of
845
+ // hiding inside the listen→complete gap.
846
+ const STARTUP_STEP_SLOW_MS = 500
847
+ async function timedStartupStep(label, fn) {
848
+ const start = Date.now()
849
+ try {
850
+ return await fn()
851
+ } finally {
852
+ const durationMs = Date.now() - start
853
+ if (durationMs >= STARTUP_STEP_SLOW_MS) {
854
+ logger.info('Startup step slow', { startupStep: label, durationMs })
855
+ }
856
+ }
857
+ }
858
+
859
+ async function runStartupInitialization() {
860
+ await timedStartupStep('scheduled-runs-cutover', () => initializeScheduledRunsCutover())
861
+ await timedStartupStep('scheduled-runs-restore-plan', () => recoverScheduledRunsRestorePlan())
862
+ await timedStartupStep('stale-scheduled-task-runs', () => recoverStaleScheduledTaskRuns())
863
+ // Session state (storage v2): SQLite is authoritative by construction. The
864
+ // only startup work is the one-shot JSON import — when the v2 store is empty
865
+ // and legacy JSON session files exist, importSessionStateFromJson re-imports
866
+ // them (idempotent, per-session transactions, WAL checkpoint at the end);
867
+ // otherwise the store is ready as-is. The import runs inside the maintenance
868
+ // window, so /api/* stays gated (process state 'migrating') until it
869
+ // completes; the sessionState domain reports 'authoritative' throughout.
870
+ await timedStartupStep('session-state-service', () => initializeSessionStateService())
871
+ await timedStartupStep('session-state-import', async () => {
872
+ const { createSessionStateRepository } = await import('./sqlite/session-state-repository.mjs')
873
+ const repository = createSessionStateRepository(getSqliteStorage())
874
+ if (repository.count() > 0) return
875
+ const adapter = createPhysicalSessionStateFsAdapter()
876
+ let hasJsonSessions = false
877
+ for await (const bucket of adapter.listBuckets()) {
878
+ for await (const sessionId of adapter.listSessionFiles(bucket)) {
879
+ if (sessionId) {
880
+ hasJsonSessions = true
881
+ break
882
+ }
883
+ }
884
+ if (hasJsonSessions) break
885
+ }
886
+ if (!hasJsonSessions) return
887
+ logger.info('Session state JSON import starting (empty SQLite store, legacy files detected)', { domain: 'session-state' })
888
+ const { imported, skipped } = await importSessionStateFromJson({ logger })
889
+ logger.info('Session state JSON import finished', { domain: 'session-state', imported, skipped })
890
+ })
891
+ await timedStartupStep('session-state-restore-plan', () => recoverSessionStateRestorePlan())
892
+ // Share storage cutover: share-store writes become SQLite-authoritative once
893
+ // pending/authoritative; integrity failures fail closed and block startup,
894
+ // json_authoritative failures keep the legacy JSON path. The JSON file stays
895
+ // as the best-effort mirror drained through the share mirror queue.
896
+ await timedStartupStep('share-cutover', () => initializeShareCutover())
897
+ await timedStartupStep('share-service', () => initializeShareService())
898
+ await timedStartupStep('share-restore-plan', () => recoverShareRestorePlan())
899
+ await timedStartupStep('share-json-mirror-drain', () => drainShareJsonMirror())
900
+ // LAN access storage cutover: JSON → SQLite with the same phase machine as
901
+ // share storage. Integrity failures while pending/authoritative fail closed
902
+ // and block startup; json_authoritative failures keep the legacy JSON store
903
+ // path. The JSON file stays as the best-effort mirror drained through the
904
+ // lan-access mirror queue. Interrupted lan-access restore plans are recovered
905
+ // (roll-forward/rollback) before the mirror drain.
906
+ await timedStartupStep('lan-access-cutover', () => initializeLanAccessCutover())
907
+ await timedStartupStep('lan-access-service', () => initializeLanAccessService())
908
+ await timedStartupStep('lan-access-restore-plan', () => recoverLanAccessRestorePlan())
909
+ await timedStartupStep('lan-access-json-mirror-drain', () => drainLanAccessJsonMirror())
910
+ await timedStartupStep('default-global-skills', () => ensureDefaultGlobalSkills())
911
+ await timedStartupStep('network-proxy', () => initializeNetworkProxy())
912
+ installAiHttpLogger()
913
+ initializeChannels({
914
+ projectRoot,
915
+ channelEventsUrl: `http://127.0.0.1:${port}/api/channels/events`,
916
+ logsDir,
917
+ })
918
+ await timedStartupStep('reset-stale-task-statuses', () => resetStaleTaskStatuses())
919
+ await timedStartupStep('session-index', () => initializeSessionIndex())
920
+ await timedStartupStep('active-project', () => initializeActiveProject())
921
+ setActiveWorkspaceRootForFilesystem(getWorkspaceRoot())
922
+ startScheduledTaskRunner()
923
+ startAutoArchiveRunner()
924
+ }
925
+
926
+ if (getStartupState() !== STARTUP_STATES.FAILED) {
927
+ // Fire-and-forget: never blocks listen; the promise always settles and the
928
+ // handlers below flip the startup state for the gate and health endpoint.
929
+ startupInitializationPromise = runStartupInitialization().then(() => {
930
+ setStartupState(STARTUP_STATES.READY)
931
+ logger.info('QuickForge startup initialization complete.')
932
+ }).catch((error) => {
933
+ logger.error('QuickForge startup failed', { errorName: error?.name || 'Error', errorMessage: error?.message, stack: error?.stack })
934
+ flushLogger()
935
+ setStartupState(STARTUP_STATES.FAILED, withStartupRecoveryGuidance(error?.message))
936
+ })
937
+ }
823
938
 
824
939
  server.on('error', (error) => {
825
940
  // Handle listen errors (most commonly EADDRINUSE). Without this, Node would
@@ -847,7 +962,13 @@ server.listen(port, host, () => {
847
962
  logger.info(`QuickForge data dir: ${dataDir}`)
848
963
  logger.info(`QuickForge project: ${getWorkspaceRoot()}`)
849
964
 
850
- const pending = readCloudServiceConfig({ strict: false }).then((cloudConfig) => applyCloudServiceConfig(cloudConfig)).catch((error) => {
965
+ const pending = (async () => {
966
+ // The remote agent talks to the local business API: hold it back until the
967
+ // background startup chain settles so it never races the maintenance gate.
968
+ if (startupInitializationPromise) await startupInitializationPromise
969
+ if (getStartupState() === STARTUP_STATES.FAILED) return null
970
+ return applyCloudServiceConfig(await readCloudServiceConfig({ strict: false }))
971
+ })().catch((error) => {
851
972
  if (!shutdownStarted) logger.warn(`QuickForge remote agent failed to start: ${error?.message || error}`)
852
973
  return null
853
974
  }).finally(() => {
@@ -19,6 +19,7 @@ import {
19
19
  writeLanAccessJsonFile,
20
20
  } from './lan-access-json-file.mjs'
21
21
  import { storageDir } from './storage.mjs'
22
+ import { logger } from './utils/logger.mjs'
22
23
 
23
24
  const DEFAULT_LOCK_TTL_MS = 60_000
24
25
  const DEFAULT_WAIT_TIMEOUT_MS = 65_000
@@ -223,23 +224,29 @@ export async function initializeLanAccessCutover(options = {}) {
223
224
  const storage = options.storage || getSqliteStorage()
224
225
  const repository = options.repository || createLanAccessRepository(storage)
225
226
  configureLanAccessService({ repository, mirror: options.mirror || createDefaultLanAccessMirror() })
226
- return runLanAccessMaintenance(async () => {
227
+ const log = options.logger || logger
228
+ const migrate = async () => {
227
229
  const current = readLanAccessStorageState()
228
230
  if (current.phase === LAN_ACCESS_STORAGE_PHASES.JSON_PENDING) {
229
231
  const integrity = repository.verifyIntegrity({ quickCheck: true })
230
232
  if (!integrity.ok) throw new Error('LAN access state pending integrity verification failed')
231
233
  const drained = await drainLanAccessJsonMirror()
232
- if (drained.pending === 0) setLanAccessStoragePhase(LAN_ACCESS_STORAGE_PHASES.AUTHORITATIVE, { lanTokenCount: integrity.count, digest: integrity.digest, backupFile: current.backupFile })
234
+ if (drained.pending === 0) {
235
+ setLanAccessStoragePhase(LAN_ACCESS_STORAGE_PHASES.AUTHORITATIVE, { lanTokenCount: integrity.count, digest: integrity.digest, backupFile: current.backupFile })
236
+ log.info('LAN access cutover promoted to authoritative', { domain: 'lan-access', phase: 'authoritative', tokenCount: integrity.count })
237
+ }
233
238
  return readLanAccessStorageState()
234
239
  }
235
240
  if (current.phase === LAN_ACCESS_STORAGE_PHASES.AUTHORITATIVE) {
236
241
  const integrity = repository.verifyIntegrity({ quickCheck: true })
237
242
  if (!integrity.ok) throw new Error('LAN access state authoritative integrity verification failed')
238
243
  await drainLanAccessJsonMirror()
244
+ log.info('LAN access cutover startup check passed', { domain: 'lan-access', phase: 'authoritative' })
239
245
  return readLanAccessStorageState()
240
246
  }
241
247
 
242
248
  if (current.phase === LAN_ACCESS_STORAGE_PHASES.CUTOVER_RUNNING && current.backupFile) {
249
+ log.warn('LAN access cutover recovery: rerunning the JSON migration', { domain: 'lan-access', phase: 'cutover_running', backupFile: current.backupFile })
243
250
  setLanAccessStoragePhase(LAN_ACCESS_STORAGE_PHASES.JSON_AUTHORITATIVE, {
244
251
  backupFile: current.backupFile,
245
252
  diagnostic: { operation: 'cutover_recovery', recoveredFrom: LAN_ACCESS_STORAGE_PHASES.CUTOVER_RUNNING },
@@ -254,6 +261,7 @@ export async function initializeLanAccessCutover(options = {}) {
254
261
  if (first.tokenCount !== second.tokenCount || first.digest !== second.digest) {
255
262
  throw new Error('LAN access JSON source changed during cutover double read')
256
263
  }
264
+ log.info('LAN access cutover migration started', { domain: 'lan-access', tokenCount: first.tokenCount })
257
265
  if (!backupFile) backupFile = await writeLanAccessCutoverBackup(first, options.backupDirectory)
258
266
  const third = buildLanAccessJsonSnapshot(await readJson())
259
267
  if (first.tokenCount !== third.tokenCount || first.digest !== third.digest) {
@@ -290,7 +298,9 @@ export async function initializeLanAccessCutover(options = {}) {
290
298
  diagnostic: first.diagnostics,
291
299
  })
292
300
  }
293
- return readLanAccessStorageState()
301
+ const result = readLanAccessStorageState()
302
+ log.info('LAN access cutover migration complete', { domain: 'lan-access', phase: result.phase, tokenCount: first.tokenCount })
303
+ return result
294
304
  } catch (error) {
295
305
  const state = readLanAccessStorageState()
296
306
  if (![LAN_ACCESS_STORAGE_PHASES.JSON_PENDING, LAN_ACCESS_STORAGE_PHASES.AUTHORITATIVE].includes(state.phase)) {
@@ -298,9 +308,16 @@ export async function initializeLanAccessCutover(options = {}) {
298
308
  backupFile,
299
309
  diagnostic: { operation: 'cutover', errorName: error?.name || 'Error', error: error?.message || String(error) },
300
310
  })
311
+ log.warn('LAN access cutover failed; keeping the JSON store path', { domain: 'lan-access', phase: 'json_authoritative', errorName: error?.name || 'Error', errorMessage: error?.message })
301
312
  return readLanAccessStorageState()
302
313
  }
303
314
  throw error
304
315
  }
305
- }, { ...options, storage, operation: 'lan-access-cutover' })
316
+ }
317
+ try {
318
+ return await runLanAccessMaintenance(migrate, { ...options, storage, operation: 'lan-access-cutover' })
319
+ } catch (error) {
320
+ log.error('LAN access cutover failed and blocked startup', { domain: 'lan-access', errorName: error?.name || 'Error', errorMessage: error?.message })
321
+ throw error
322
+ }
306
323
  }
@@ -1,126 +1,89 @@
1
1
  #!/usr/bin/env node
2
- import { createHash } from 'node:crypto'
2
+ // Offline downgrade escape hatch (storage v2): materializes the authoritative
3
+ // SQLite session store back into the legacy v1 JSON layout (per-session body
4
+ // files + per-bucket sessions-metadata.json). The old phase-machine semantics
5
+ // are gone — SQLite stays authoritative and this tool NEVER flips authority;
6
+ // it only exports data so an older QuickForge build (or a human) can read it.
7
+ //
8
+ // node downgrade-session-state-v1.mjs --dry-run # report only, no writes
9
+ // node downgrade-session-state-v1.mjs # write the JSON layout
3
10
  import path from 'node:path'
4
11
  import { closeSqliteStorage, initializeSqliteStorage } from '../sqlite/database.mjs'
5
- import { createSessionStateRepository, snapshotDigestLine } from '../sqlite/session-state-repository.mjs'
6
- import { buildSessionJsonSnapshot } from '../session-state-cutover.mjs'
7
- import {
8
- SESSION_STORAGE_PHASES,
9
- configureSessionStateService,
10
- drainSessionJsonMirror,
11
- readSessionStorageState,
12
- setSessionStoragePhase,
13
- } from '../session-state-service.mjs'
12
+ import { createSessionStateRepository } from '../sqlite/session-state-repository.mjs'
14
13
  import {
14
+ createPhysicalSessionStateFsAdapter,
15
15
  dataDir,
16
- materializeSessionJsonMirrorEntry,
17
- readPhysicalSessionStateBuckets,
16
+ removeSessionStateJsonBody,
17
+ writeSessionStateJsonBody,
18
+ writeSessionStateMetadataBucket,
18
19
  } from '../storage.mjs'
19
20
 
20
- const AUTHORITATIVE_PHASES = new Set(['sqlite_authoritative_json_pending', 'authoritative'])
21
-
22
21
  const args = new Set(process.argv.slice(2).filter((arg) => arg.startsWith('--')))
23
22
  const dryRun = args.has('--dry-run')
24
- const commit = args.has('--commit')
25
-
26
- function mirrorAdapter() {
27
- async function materialize(entry) {
28
- await materializeSessionJsonMirrorEntry(entry)
29
- }
30
- return { upsert: materialize, delete: materialize }
31
- }
32
23
 
33
24
  function exitError(message) {
34
25
  process.stderr.write(`${message}\n`)
35
26
  process.exitCode = 1
36
27
  }
37
28
 
38
- function isPlainObject(value) {
39
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
40
- }
41
-
42
- function canonicalize(value) {
43
- if (Array.isArray(value)) return value.map(canonicalize)
44
- if (!isPlainObject(value)) return value
45
- return Object.fromEntries(Object.keys(value).sort().filter((key) => value[key] !== undefined).map((key) => [key, canonicalize(value[key])]))
46
- }
47
-
48
- function digestJson(value) {
49
- return createHash('sha256').update(JSON.stringify(canonicalize(value))).digest('hex')
50
- }
51
-
52
- // F9 split sessions: the downgrade materializes the JSON mirror as full bodies
53
- // (split marker + messages inline) via drainSessionJsonMirror, so the SQLite
54
- // side must be digested over the ASSEMBLED representation — not the stored
55
- // stripped body + messages digest — to compare against buildSessionJsonSnapshot.
56
- function assembledSnapshotDigest(snapshot) {
57
- const rows = snapshot.records.map((record) => {
58
- const state = record.state?.messageStorage === 'split'
59
- ? { ...record.state, messages: record.messages ?? [] }
60
- : record.state
61
- return snapshotDigestLine(record.scope, record.projectId || '', record.sessionId, digestJson(state), digestJson(record.metadata), '')
62
- })
63
- return createHash('sha256').update(rows.sort().join('\n')).digest('hex')
64
- }
65
-
66
29
  try {
67
30
  const storage = await initializeSqliteStorage({ dataDir })
68
31
  storage.health({ quickCheck: true })
69
32
  const repository = createSessionStateRepository(storage)
70
- configureSessionStateService({ repository, mirror: mirrorAdapter() })
71
- const state = readSessionStorageState()
72
- if (state.phase === 'cutover_running') {
73
- throw new Error('Session state cutover is still running; stop all QuickForge processes and retry')
74
- }
75
- if (state.phase === SESSION_STORAGE_PHASES.JSON_AUTHORITATIVE) {
76
- throw new Error('Session state is already JSON authoritative; nothing to downgrade')
77
- }
78
- if (!AUTHORITATIVE_PHASES.has(state.phase)) {
79
- throw new Error(`Session state is ${state.phase}; downgrade requires pending or authoritative phase`)
80
- }
81
33
  const integrity = repository.verifyIntegrity({ quickCheck: true })
82
34
  if (!integrity.ok) throw new Error('Session state integrity verification failed')
83
35
  const snapshot = repository.exportSnapshot()
84
- if (snapshot.count !== integrity.count || snapshot.digest !== integrity.digest) {
85
- throw new Error('Session state snapshot count/digest verification failed')
36
+ // Lightweight integrity has no digest; the count cross-check plus the
37
+ // snapshot's own digest stay authoritative.
38
+ if (snapshot.count !== integrity.count) {
39
+ throw new Error('Session state snapshot count verification failed')
86
40
  }
41
+
42
+ // Reassemble every record into a full v1 JSON body (split sessions carry
43
+ // their messages inline again) and group metadata per bucket.
44
+ const bodiesByBucket = new Map()
45
+ const metadataByBucket = new Map()
46
+ for (const record of snapshot.records) {
47
+ const bucket = record.scope === 'project' ? { scope: 'project', projectId: record.projectId } : { scope: 'global' }
48
+ const key = `${bucket.scope}\0${bucket.projectId || ''}`
49
+ const state = record.state?.messageStorage === 'split'
50
+ ? { ...record.state, messages: record.messages ?? [] }
51
+ : record.state
52
+ if (!bodiesByBucket.has(key)) {
53
+ bodiesByBucket.set(key, { bucket, bodies: new Map() })
54
+ metadataByBucket.set(key, { bucket, metadata: {} })
55
+ }
56
+ bodiesByBucket.get(key).bodies.set(record.sessionId, state)
57
+ metadataByBucket.get(key).metadata[record.sessionId] = record.metadata
58
+ }
59
+
87
60
  const report = {
88
61
  ok: true,
89
- phase: state.phase,
90
62
  count: snapshot.count,
91
63
  digest: snapshot.digest,
92
64
  dryRun,
93
- commit,
94
65
  sessionsDirectory: path.join(dataDir, 'storage', 'conversations'),
66
+ message: 'SQLite stays authoritative; the JSON layout is an exported copy (escape hatch), not a phase switch.',
95
67
  }
96
68
 
97
69
  if (dryRun) {
98
- // Read-only: report what a real run would materialize without writing
99
- // anything or changing the phase.
100
70
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
101
71
  } else {
102
- await drainSessionJsonMirror()
103
- // Verify the on-disk JSON mirror exactly matches the authoritative SQLite
104
- // snapshot before allowing any phase change. Split sessions are compared
105
- // over their assembled representation (full body with messages), which is
106
- // what the JSON mirror materializes.
107
- const mirror = buildSessionJsonSnapshot(await readPhysicalSessionStateBuckets())
108
- const targetDigest = assembledSnapshotDigest(snapshot)
109
- if (mirror.count !== snapshot.count || mirror.digest !== targetDigest) {
110
- throw new Error(`JSON mirror verification failed: SQLite ${snapshot.count}/${targetDigest} vs JSON ${mirror.count}/${mirror.digest}`)
72
+ const adapter = createPhysicalSessionStateFsAdapter()
73
+ for (const { bucket, bodies } of bodiesByBucket.values()) {
74
+ // Remove stale body files that no longer exist in the store before
75
+ // writing the fresh snapshot.
76
+ for await (const sessionId of adapter.listSessionFiles(bucket)) {
77
+ if (!bodies.has(sessionId)) await removeSessionStateJsonBody(bucket, sessionId)
78
+ }
79
+ for (const [sessionId, state] of bodies) {
80
+ await writeSessionStateJsonBody(bucket, sessionId, state)
81
+ }
111
82
  }
112
- if (commit) {
113
- setSessionStoragePhase(SESSION_STORAGE_PHASES.JSON_AUTHORITATIVE, {
114
- stateCount: snapshot.count,
115
- digest: snapshot.digest,
116
- diagnostic: { operation: 'downgrade', materialized: mirror.count },
117
- })
118
- report.phase = SESSION_STORAGE_PHASES.JSON_AUTHORITATIVE
119
- report.phaseChanged = true
120
- } else {
121
- report.phaseChanged = false
122
- report.message = 'JSON mirror materialized; run with --commit after stopping all QuickForge processes to switch authority back to JSON'
83
+ for (const { bucket, metadata } of metadataByBucket.values()) {
84
+ await writeSessionStateMetadataBucket(bucket, metadata)
123
85
  }
86
+ report.materialized = snapshot.count
124
87
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
125
88
  }
126
89
  } catch (error) {
@@ -4,11 +4,8 @@ import { promises as fs } from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { closeSqliteStorage, initializeSqliteStorage } from '../sqlite/database.mjs'
6
6
  import { createSessionStateRepository } from '../sqlite/session-state-repository.mjs'
7
- import { readSessionStorageState } from '../session-state-service.mjs'
8
7
  import { dataDir } from '../storage.mjs'
9
8
 
10
- const AUTHORITATIVE_PHASES = new Set(['sqlite_authoritative_json_pending', 'authoritative'])
11
-
12
9
  function outputPath() {
13
10
  const argument = process.argv[2]
14
11
  if (argument) return path.resolve(argument)
@@ -23,18 +20,13 @@ try {
23
20
  const storage = await initializeSqliteStorage({ dataDir })
24
21
  storage.health({ quickCheck: true })
25
22
  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
23
  const integrity = repository.verifyIntegrity({ quickCheck: true })
34
24
  if (!integrity.ok) throw new Error('Session state integrity verification failed')
35
25
  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')
26
+ // Lightweight integrity has no digest; the count cross-check plus the
27
+ // snapshot's own digest stay authoritative.
28
+ if (snapshot.count !== integrity.count) {
29
+ throw new Error('Session state backup count verification failed')
38
30
  }
39
31
  const backup = {
40
32
  app: 'quickforge',
@@ -42,7 +34,7 @@ try {
42
34
  exportedAt: new Date().toISOString(),
43
35
  scope: 'sessions',
44
36
  includeSecrets: false,
45
- sessionState: { phase: state.phase, count: snapshot.count, digest: snapshot.digest },
37
+ sessionState: { phase: 'authoritative', count: snapshot.count, digest: snapshot.digest },
46
38
  data: {
47
39
  sessions: Object.fromEntries(snapshot.records.map((record) => [record.sessionId, record.state?.messageStorage === 'split'
48
40
  ? { ...record.state, messages: record.messages ?? [] }
@@ -5,7 +5,7 @@ import { isAuthenticatedAppClient } from '../access-policy.mjs'
5
5
  import { isScheduledRunsMaintenanceActive } from '../scheduled-runs-cutover.mjs'
6
6
  import { readScheduledTasksForBackup, restoreScheduledTasks } from '../scheduled-runs-backup.mjs'
7
7
  import { isSessionStateAuthoritative } from '../session-state-service.mjs'
8
- import { isSessionStateMaintenanceActive } from '../session-state-cutover.mjs'
8
+ import { isSessionStateMaintenanceActive } from '../session-state-maintenance.mjs'
9
9
  import { exportSessionStateForBackup, restoreSessionStateSnapshot } from '../session-state-backup.mjs'
10
10
  import { isShareStorageAuthoritative } from '../share-service.mjs'
11
11
  import { isShareMaintenanceActive } from '../share-cutover.mjs'
@@ -15,6 +15,8 @@ import { isLanAccessStorageAuthoritative } from '../lan-access-service.mjs'
15
15
  import { isLanAccessMaintenanceActive } from '../lan-access-cutover.mjs'
16
16
  import { exportLanAccessStateForBackup, restoreLanAccessStateSnapshot } from '../lan-access-backup.mjs'
17
17
  import { readLanAccessJsonFile, writeLanAccessJsonFile } from '../lan-access-json-file.mjs'
18
+ import { refreshAllSessionModels } from '../agent-manager.mjs'
19
+ import { logger } from '../utils/logger.mjs'
18
20
  import { sendJson, readJsonBody } from '../utils/response.mjs'
19
21
  import {
20
22
  ensureStorage,
@@ -641,6 +643,11 @@ async function restoreValidatedBackup(backup, mode = 'replace') {
641
643
  if (sections.customProviders !== undefined) {
642
644
  const value = merge ? mergeRecordStore(await readStore('custom-providers'), sections.customProviders) : sections.customProviders
643
645
  await writeStore('custom-providers', value)
646
+ try {
647
+ await refreshAllSessionModels()
648
+ } catch (error) {
649
+ logger.error('Failed to refresh session models after restoring custom providers:', error)
650
+ }
644
651
  summary.customProviders = countKeys(value)
645
652
  }
646
653