@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.
- package/dist/assets/{AgentProfilesPage-BSM5w3bn.js → AgentProfilesPage-Cvb1KTz5.js} +1 -1
- package/dist/assets/ChatPanelHost-C7zV-xE_.js +48 -0
- package/dist/assets/{CloudAccountSettingsPage-BJMrfgy9.js → CloudAccountSettingsPage-mAs_nbvw.js} +1 -1
- package/dist/assets/{PluginsPage-mJkTmVVV.js → PluginsPage-mLvwvTmL.js} +1 -1
- package/dist/assets/{ScheduledTasksPage-DJj_NLm9.js → ScheduledTasksPage-CuUW_9lq.js} +1 -1
- package/dist/assets/{SettingsWorkspacePage-7lQ0Wl63.js → SettingsWorkspacePage-0OINfEqK.js} +390 -390
- package/dist/assets/{ShareLinksSettingsPage-DjFq_Lnb.js → ShareLinksSettingsPage-DqeyJYoD.js} +1 -1
- package/dist/assets/{SharedConversationPage-BbuNpcpP.js → SharedConversationPage-C7CDEoX9.js} +1 -1
- package/dist/assets/TerminalDock-CZXcuMpq.js +2 -0
- package/dist/assets/{WorkspaceInspector-0i6x-diB.js → WorkspaceInspector-CNSBOEvP.js} +3 -3
- package/dist/assets/index-BgMvQW1b.js +66 -0
- package/dist/assets/index-CB1g2D0f.css +3 -0
- package/dist/assets/local-tools-Dopjg9xq.js +270 -0
- package/dist/assets/{mcp-servers-dialog-BG4b67ea.js → mcp-servers-dialog-B2lALtTE.js} +1 -1
- package/dist/assets/{skills-dialog-DIzZvQqW.js → skills-dialog-DYp35JM3.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/server/acp/server.mjs +3 -6
- package/server/agent-manager.mjs +125 -43
- package/server/auto-archive.mjs +55 -8
- package/server/auto-compaction.mjs +0 -2
- package/server/context-usage.mjs +9 -7
- package/server/index.mjs +172 -51
- package/server/lan-access-cutover.mjs +21 -4
- package/server/maintenance/downgrade-session-state-v1.mjs +50 -87
- package/server/maintenance/export-session-state-v1.mjs +5 -13
- package/server/routes/backup.mjs +8 -1
- package/server/routes/storage.mjs +85 -48
- package/server/scheduled-runs-cutover.mjs +15 -1
- package/server/session-index-service.mjs +57 -391
- package/server/session-persistence-lock.mjs +17 -6
- package/server/session-state-backup.mjs +60 -7
- package/server/session-state-import.mjs +157 -0
- package/server/session-state-maintenance.mjs +126 -0
- package/server/session-state-service.mjs +133 -194
- package/server/share-cutover.mjs +21 -4
- package/server/sqlite/database.mjs +142 -7
- package/server/sqlite/lan-access-repository.mjs +6 -4
- package/server/sqlite/migrations.mjs +109 -0
- package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
- package/server/sqlite/session-index-repository.mjs +59 -208
- package/server/sqlite/session-state-repository.mjs +359 -289
- package/server/sqlite/share-repository.mjs +56 -45
- package/server/startup-state.mjs +107 -0
- package/server/storage.mjs +240 -448
- package/server/utils/logger.mjs +15 -4
- package/server/utils/process-tree.mjs +14 -2
- package/dist/assets/ChatPanelHost-cd80S4EX.js +0 -48
- package/dist/assets/TerminalDock-CczcCJJE.js +0 -2
- package/dist/assets/index-D-J_8Smf.css +0 -3
- package/dist/assets/index-DFASc15l.js +0 -66
- package/dist/assets/local-tools-Dn1Y9aPe.js +0 -270
- 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,
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
await
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
//
|
|
801
|
-
//
|
|
802
|
-
//
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
await
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
await
|
|
819
|
-
await
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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 =
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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:
|
|
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 ?? [] }
|
package/server/routes/backup.mjs
CHANGED
|
@@ -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-
|
|
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
|
|