From d47e21a6a643ca12906abbfcd43bdd5fbdf78168 Mon Sep 17 00:00:00 2001 From: Hccake Date: Tue, 28 Jul 2026 00:46:11 +0800 Subject: [PATCH 01/12] feat(cli): support independent SQLite home --- .github/workflows/ci.yml | 5 +- src/backup.js | 152 ++++++++++++++++++++++------ src/cli.js | 34 +++++-- src/config-file.js | 42 ++++++++ src/launcher.js | 20 ++-- src/service.js | 93 +++++++++++------ src/sqlite-state.js | 63 +++++++----- src/storage-layout.js | 79 +++++++++++++++ src/watch.js | 123 +++++++++++++++-------- src/workspace-roots.js | 20 ++-- test/config-file.test.js | 19 ++++ test/launcher.test.js | 12 +++ test/storage-layout.test.js | 56 +++++++++++ test/sync-service.test.js | 193 ++++++++++++++++++++++++++++++++++++ test/watch.test.js | 83 +++++++++++++++- 15 files changed, 839 insertions(+), 155 deletions(-) create mode 100644 src/storage-layout.js create mode 100644 test/storage-layout.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57340b5..fd85d15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,10 +8,13 @@ on: jobs: test: - runs-on: windows-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: + os: + - windows-latest + - ubuntu-latest node-version: - "16" - "24" diff --git a/src/backup.js b/src/backup.js index cf38bda..125db23 100644 --- a/src/backup.js +++ b/src/backup.js @@ -11,6 +11,7 @@ import { } from "./constants.js"; import { assertSessionFilesWritable, restoreSessionChanges } from "./session-files.js"; import { assertSqliteWritable, detectStateDb } from "./sqlite-state.js"; +import { resolveStorageLayout, withStateDbLocation } from "./storage-layout.js"; function timestampSlug(date = new Date()) { return date.toISOString().replaceAll(":", "").replaceAll("-", "").replace(".", ""); @@ -27,18 +28,33 @@ async function copyIfPresent(sourcePath, destinationPath) { return true; } -function dbBackupRelativePath(codexHome, dbPath, suffix) { - const relativePath = path.relative(codexHome, `${dbPath}${suffix}`); +function restoreDbTargetPath(codexHome, relativePath) { + if (path.isAbsolute(relativePath) || relativePath.split(/[\\/]/).includes("..")) { + throw new Error(`Invalid database backup path: ${relativePath}`); + } + return path.join(codexHome, relativePath); +} + +function safeRelativePath(root, target) { + const relativePath = path.relative(root, target); return relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath) ? relativePath - : `${DB_FILE_BASENAME}${suffix}`; + : null; } -function restoreDbTargetPath(codexHome, relativePath) { +function restoreSqliteTargetPath(sqliteHome, relativePath) { if (path.isAbsolute(relativePath) || relativePath.split(/[\\/]/).includes("..")) { - throw new Error(`Invalid database backup path: ${relativePath}`); + throw new Error(`Invalid SQLite backup path: ${relativePath}`); } - return path.join(codexHome, relativePath); + return path.join(sqliteHome, relativePath); +} + +function storagePathsEqual(left, right) { + const normalizedLeft = path.resolve(left); + const normalizedRight = path.resolve(right); + return process.platform === "win32" + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; } async function removeIfPresent(targetPath) { @@ -58,25 +74,40 @@ export async function restoreGlobalStateFilesFromBackup(backupDir, codexHome) { } export async function createBackup({ + storage, codexHome, targetProvider, sessionChanges, configPath, configBackupText }) { + const effectiveStorage = storage ?? resolveStorageLayout({ codexHome, env: {} }); + codexHome = effectiveStorage.codexHome; const backupRoot = defaultBackupRoot(codexHome); const backupDir = path.join(backupRoot, timestampSlug()); const dbDir = path.join(backupDir, "db"); await fs.mkdir(dbDir, { recursive: true }); const copiedDbFiles = []; - const stateDb = await detectStateDb(codexHome); + const copiedSqliteDbFiles = []; + const stateDb = Object.hasOwn(effectiveStorage, "stateDbLocation") + ? effectiveStorage.stateDbLocation + : await detectStateDb(effectiveStorage); + const actualSqliteHome = stateDb ? path.dirname(stateDb.path) : effectiveStorage.sqliteHome; if (stateDb) { for (const suffix of ["", "-shm", "-wal"]) { - const relativePath = dbBackupRelativePath(codexHome, stateDb.path, suffix); - const copied = await copyIfPresent(`${stateDb.path}${suffix}`, path.join(dbDir, relativePath)); - if (copied) { - copiedDbFiles.push(relativePath); + const sourcePath = `${stateDb.path}${suffix}`; + const sqliteRelativePath = `${DB_FILE_BASENAME}${suffix}`; + const copied = await copyIfPresent(sourcePath, path.join(dbDir, "sqlite-home", sqliteRelativePath)); + if (!copied) { + continue; + } + copiedSqliteDbFiles.push(sqliteRelativePath); + + const legacyRelativePath = safeRelativePath(codexHome, sourcePath); + if (legacyRelativePath) { + await copyIfPresent(sourcePath, path.join(dbDir, legacyRelativePath)); + copiedDbFiles.push(legacyRelativePath); } } } @@ -120,12 +151,14 @@ export async function createBackup({ path.join(backupDir, "metadata.json"), JSON.stringify( { - version: 1, + version: 2, namespace: BACKUP_NAMESPACE, codexHome, + sqliteHome: actualSqliteHome, targetProvider, createdAt: sessionManifest.createdAt, dbFiles: copiedDbFiles, + sqliteDbFiles: copiedSqliteDbFiles, changedSessionFiles: sessionChanges.length }, null, @@ -199,14 +232,22 @@ export async function pruneBackups(codexHome, keepCount = DEFAULT_BACKUP_RETENTI }; } -export async function restoreBackup(backupDir, codexHome, options = {}) { +export async function restoreBackup(backupDir, storageOrCodexHome, options = {}) { const { restoreConfig = true, restoreDatabase = true, - restoreSessions = true + restoreSessions = true, + allowSqliteHomeRelocation = false } = options; + const storage = typeof storageOrCodexHome === "string" + ? resolveStorageLayout({ codexHome: storageOrCodexHome, env: {} }) + : storageOrCodexHome; + const codexHome = storage.codexHome; const metadataPath = path.join(backupDir, "metadata.json"); const metadata = JSON.parse(await fs.readFile(metadataPath, "utf8")); + if (metadata.namespace !== BACKUP_NAMESPACE || ![1, 2].includes(metadata.version)) { + throw new Error(`Unsupported backup metadata in ${metadataPath}.`); + } if (metadata.codexHome !== codexHome) { throw new Error(`Backup was created for ${metadata.codexHome}, not ${codexHome}.`); } @@ -218,30 +259,79 @@ export async function restoreBackup(backupDir, codexHome, options = {}) { await assertSessionFilesWritable(sessionManifest.files ?? []); } + let stateDb = null; + let targetSqliteHome = null; + let databaseRestorePlan = null; + if (restoreDatabase) { + stateDb = Object.hasOwn(storage, "stateDbLocation") + ? storage.stateDbLocation + : await detectStateDb(storage); + if (!stateDb && storage.sqliteHomeSource !== "default") { + throw new Error(`state_5.sqlite not found in SQLite home ${storage.sqliteHome}.`); + } + targetSqliteHome = stateDb ? path.dirname(stateDb.path) : storage.sqliteHome; + if (stateDb + && metadata.version >= 2 + && metadata.sqliteHome + && !storagePathsEqual(metadata.sqliteHome, targetSqliteHome) + && !allowSqliteHomeRelocation) { + throw new Error( + `Backup SQLite home is ${metadata.sqliteHome}, but the current target is ${targetSqliteHome}. ` + + "Use --allow-sqlite-home-relocation with an explicit --sqlite-home to restore to a different location." + ); + } + if (stateDb) { + await assertSqliteWritable(withStateDbLocation(storage, stateDb)); + + const dbDir = path.join(backupDir, "db"); + const databaseFiles = metadata.version >= 2 + ? (metadata.sqliteDbFiles ?? []) + : (metadata.dbFiles ?? []); + const databaseBackupRoot = metadata.version >= 2 + ? path.join(dbDir, "sqlite-home") + : dbDir; + const restoreRoot = metadata.version >= 2 ? targetSqliteHome : codexHome; + const entries = []; + for (const fileName of databaseFiles) { + const targetPath = metadata.version >= 2 + ? restoreSqliteTargetPath(restoreRoot, fileName) + : restoreDbTargetPath(restoreRoot, fileName); + const sourcePath = path.join(databaseBackupRoot, fileName); + await fs.access(sourcePath).catch(() => { + throw new Error(`Backup declares a missing SQLite file: ${sourcePath}`); + }); + entries.push({ fileName, sourcePath, targetPath }); + } + + const backedUpFiles = new Set(databaseFiles); + const sidecarsToRemove = []; + for (const baseFile of databaseFiles.filter((fileName) => path.basename(fileName) === DB_FILE_BASENAME)) { + const basePath = metadata.version >= 2 + ? restoreSqliteTargetPath(restoreRoot, baseFile) + : restoreDbTargetPath(restoreRoot, baseFile); + for (const suffix of ["-shm", "-wal"]) { + if (!backedUpFiles.has(`${baseFile}${suffix}`)) { + sidecarsToRemove.push(`${basePath}${suffix}`); + } + } + } + databaseRestorePlan = { entries, sidecarsToRemove }; + } + } + const configBackupPath = path.join(backupDir, "config.toml"); if (restoreConfig) { await copyIfPresent(configBackupPath, path.join(codexHome, "config.toml")); await restoreGlobalStateFilesFromBackup(backupDir, codexHome); } - if (restoreDatabase) { - await assertSqliteWritable(codexHome); - - const dbDir = path.join(backupDir, "db"); - const backedUpFiles = new Set(metadata.dbFiles ?? []); - const backedUpBaseFiles = (metadata.dbFiles ?? []) - .filter((fileName) => path.basename(fileName) === DB_FILE_BASENAME); - for (const baseFile of backedUpBaseFiles) { - const basePath = restoreDbTargetPath(codexHome, baseFile); - for (const suffix of ["-shm", "-wal"]) { - const sidecarFile = `${baseFile}${suffix}`; - if (!backedUpFiles.has(sidecarFile)) { - await removeIfPresent(`${basePath}${suffix}`); - } - } + if (databaseRestorePlan) { + for (const sidecarPath of databaseRestorePlan.sidecarsToRemove) { + await removeIfPresent(sidecarPath); } - for (const fileName of metadata.dbFiles ?? []) { - await copyIfPresent(path.join(dbDir, fileName), restoreDbTargetPath(codexHome, fileName)); + for (const { sourcePath, targetPath } of databaseRestorePlan.entries) { + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.copyFile(sourcePath, targetPath); } } diff --git a/src/cli.js b/src/cli.js index cae2fe9..836253a 100644 --- a/src/cli.js +++ b/src/cli.js @@ -15,13 +15,13 @@ function printHelp() { console.log(`codex-provider Usage: - codex-provider status [--codex-home PATH] - codex-provider sync [--provider ID] [--keep N] [--codex-home PATH] - codex-provider switch [--model NAME] [--keep-root-model] [--keep N] [--codex-home PATH] - codex-provider watch [--codex-home PATH] [--debounce-ms N] [--once] [--no-state-db] + codex-provider status [--codex-home PATH] [--sqlite-home PATH] + codex-provider sync [--provider ID] [--keep N] [--codex-home PATH] [--sqlite-home PATH] + codex-provider switch [--model NAME] [--keep-root-model] [--keep N] [--codex-home PATH] [--sqlite-home PATH] + codex-provider watch [--codex-home PATH] [--sqlite-home PATH] [--debounce-ms N] [--once] [--no-state-db] codex-provider prune-backups [--keep N] [--codex-home PATH] - codex-provider restore [--no-config] [--no-db] [--no-sessions] [--codex-home PATH] - codex-provider install-windows-launcher [--dir PATH] [--codex-home PATH] + codex-provider restore [--no-config] [--no-db] [--no-sessions] [--allow-sqlite-home-relocation] [--codex-home PATH] [--sqlite-home PATH] + codex-provider install-windows-launcher [--dir PATH] [--codex-home PATH] [--sqlite-home PATH] switch flags: --model NAME override root-level model field with NAME (e.g. "MiniMax-M3") @@ -29,6 +29,7 @@ switch flags: watch flags: --codex-home PATH override CODEX_HOME (default: ~/.codex or $CODEX_HOME) + --sqlite-home PATH override sqlite_home and CODEX_SQLITE_HOME --debounce-ms N wait N milliseconds after a change before syncing (default 750) --once exit after the first successful sync --no-state-db only watch config.toml, ignore SQLite state events @@ -67,6 +68,7 @@ function summarizeSync(result, label) { const lines = [ `${label} provider: ${result.targetProvider}`, `Codex home: ${result.codexHome}`, + `SQLite home: ${result.sqliteHome} (source: ${result.sqliteHomeSource})`, `Backup: ${result.backupDir}`, `Backup creation time: ${formatDuration(result.backupDurationMs ?? 0)}`, `Updated rollout files: ${result.changedSessionFiles}`, @@ -196,7 +198,10 @@ async function main() { if (command === "status") { const { getStatus, renderStatus } = await loadService(); - const status = await getStatus({ codexHome: flags["codex-home"] }); + const status = await getStatus({ + codexHome: flags["codex-home"], + sqliteHome: flags["sqlite-home"] + }); console.log(renderStatus(status)); return; } @@ -219,6 +224,7 @@ async function main() { } const result = await runSync({ codexHome: flags["codex-home"], + sqliteHome: flags["sqlite-home"], provider: flags.provider, keepCount: parseKeepCount(flags.keep), onProgress: createSyncProgressReporter(), @@ -233,6 +239,7 @@ async function main() { const provider = positionals[1] ?? flags.provider; const result = await runSwitch({ codexHome: flags["codex-home"], + sqliteHome: flags["sqlite-home"], provider, model: flags.model, keepRootModel: Boolean(flags["keep-root-model"]), @@ -270,6 +277,7 @@ async function main() { : undefined; const handle = await runWatch({ codexHome: flags["codex-home"], + sqliteHome: flags["sqlite-home"], debounceMs, includeStateDb: !flags["no-state-db"], once: Boolean(flags.once) @@ -310,10 +318,12 @@ async function main() { const backupDir = positionals[1] ?? flags.backup; const result = await runRestore({ codexHome: flags["codex-home"], + sqliteHome: flags["sqlite-home"], backupDir, restoreConfig: !flags["no-config"], restoreDatabase: !flags["no-db"], - restoreSessions: !flags["no-sessions"] + restoreSessions: !flags["no-sessions"], + allowSqliteHomeRelocation: Boolean(flags["allow-sqlite-home-relocation"]) }); console.log(`Restored backup from ${path.resolve(backupDir)}`); console.log(`Codex home: ${result.codexHome}`); @@ -324,7 +334,8 @@ async function main() { if (command === "install-windows-launcher") { const result = await installWindowsLauncher({ dir: flags.dir, - codexHome: flags["codex-home"] + codexHome: flags["codex-home"], + sqliteHome: flags["sqlite-home"] }); console.log("Installed Windows launcher files:"); console.log(` Hidden double-click launcher: ${result.vbsPath}`); @@ -335,6 +346,11 @@ async function main() { } else { console.log(" CODEX_HOME: default current environment / ~/.codex"); } + if (result.sqliteHome) { + console.log(` Fixed SQLite home: ${result.sqliteHome}`); + } else { + console.log(" SQLite home: config / environment / Codex default"); + } return; } diff --git a/src/config-file.js b/src/config-file.js index d3f40f9..479f111 100644 --- a/src/config-file.js +++ b/src/config-file.js @@ -10,6 +10,48 @@ function escapeTomlString(value) { return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\""); } +function decodeTomlBasicString(value) { + return value.replace(/\\(?:[btnfr"\\]|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/g, (escape) => { + const simple = { + "\\b": "\b", + "\\t": "\t", + "\\n": "\n", + "\\f": "\f", + "\\r": "\r", + '\\"': '"', + "\\\\": "\\" + }; + if (simple[escape] !== undefined) { + return simple[escape]; + } + const codePoint = Number.parseInt(escape.slice(2), 16); + return String.fromCodePoint(codePoint); + }); +} + +export function readRootStringFromConfigText(configText, key) { + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const assignment = new RegExp(`^${escapedKey}\\s*=\\s*(?:\"((?:\\\\.|[^\"\\\\])*)\"|'([^']*)')\\s*(?:#.*)?$`); + for (const line of splitLines(configText)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + continue; + } + if (trimmed.startsWith("[")) { + break; + } + const match = trimmed.match(assignment); + if (match) { + return match[1] !== undefined ? decodeTomlBasicString(match[1]) : match[2]; + } + } + return null; +} + +export function readSqliteHomeFromConfigText(configText) { + return readRootStringFromConfigText(configText, "sqlite_home"); +} + export async function readConfigText(configPath) { return fs.readFile(configPath, "utf8"); } diff --git a/src/launcher.js b/src/launcher.js index 3fe03ff..9c25ce4 100644 --- a/src/launcher.js +++ b/src/launcher.js @@ -17,11 +17,12 @@ function quoteForVbs(value) { return String(value).replace(/"/g, "\"\""); } -function buildBatchScript({ codexHome }) { +function buildBatchScript({ codexHome, sqliteHome }) { const command = [ "codex-provider", "sync", - ...(codexHome ? ["--codex-home", quoteForBatch(codexHome)] : []) + ...(codexHome ? ["--codex-home", quoteForBatch(codexHome)] : []), + ...(sqliteHome ? ["--sqlite-home", quoteForBatch(sqliteHome)] : []) ].join(" "); return [ @@ -32,11 +33,12 @@ function buildBatchScript({ codexHome }) { ].join("\r\n") + "\r\n"; } -function buildVbsScript({ codexHome }) { +function buildVbsScript({ codexHome, sqliteHome }) { const syncCommand = [ "codex-provider", "sync", - ...(codexHome ? [`--codex-home ""${quoteForVbs(codexHome)}""`] : []) + ...(codexHome ? [`--codex-home ""${quoteForVbs(codexHome)}""`] : []), + ...(sqliteHome ? [`--sqlite-home ""${quoteForVbs(sqliteHome)}""`] : []) ].join(" "); return [ @@ -92,7 +94,8 @@ function buildVbsScript({ codexHome }) { export async function installWindowsLauncher({ dir, - codexHome + codexHome, + sqliteHome } = {}) { const targetDir = resolveLauncherDirectory(dir); await fs.mkdir(targetDir, { recursive: true }); @@ -100,13 +103,14 @@ export async function installWindowsLauncher({ const cmdPath = path.join(targetDir, WINDOWS_CMD_LAUNCHER_FILENAME); const vbsPath = path.join(targetDir, WINDOWS_VBS_LAUNCHER_FILENAME); - await fs.writeFile(cmdPath, buildBatchScript({ codexHome }), "utf8"); - await fs.writeFile(vbsPath, buildVbsScript({ codexHome }), "utf8"); + await fs.writeFile(cmdPath, buildBatchScript({ codexHome, sqliteHome }), "utf8"); + await fs.writeFile(vbsPath, buildVbsScript({ codexHome, sqliteHome }), "utf8"); return { targetDir, cmdPath, vbsPath, - codexHome: codexHome ? path.resolve(codexHome) : null + codexHome: codexHome ? path.resolve(codexHome) : null, + sqliteHome: sqliteHome ? path.resolve(sqliteHome) : null }; } diff --git a/src/service.js b/src/service.js index ea2b23e..ed1c818 100644 --- a/src/service.js +++ b/src/service.js @@ -1,11 +1,9 @@ -import fs from "node:fs/promises"; import path from "node:path"; import { DEFAULT_BACKUP_RETENTION_COUNT, DEFAULT_PROVIDER, - defaultBackupRoot, - defaultCodexHome + defaultBackupRoot } from "./constants.js"; import { configDeclaresProvider, @@ -46,13 +44,23 @@ import { readThreadCwdStats, syncWorkspaceRoots } from "./workspace-roots.js"; - -function normalizeCodexHome(explicitCodexHome) { - return path.resolve(explicitCodexHome ?? process.env.CODEX_HOME ?? defaultCodexHome()); -} - -async function ensureCodexHome(codexHome) { - await fs.access(codexHome); +import { + ensureCodexHome, + isConfiguredSqliteHome, + missingConfiguredStateDbError, + normalizeCodexHome, + resolveStorageLayout, + withStateDbLocation +} from "./storage-layout.js"; + +async function prepareStorage({ codexHome: explicitCodexHome, sqliteHome, configText, storage }) { + if (storage) { + return storage; + } + const codexHome = normalizeCodexHome(explicitCodexHome); + const layout = resolveStorageLayout({ codexHome, sqliteHome, configText }); + await ensureCodexHome(layout); + return withStateDbLocation(layout, await detectStateDb(layout)); } function formatCounts(counts) { @@ -98,11 +106,11 @@ function buildEncryptedContentWarning(encryptedContentCounts, targetProvider) { return `Encrypted content warning: ${total} rollout file(s) contain encrypted_content from provider(s) ${[...riskyProviders].sort().join(", ")}. Visibility metadata can be synchronized to ${targetProvider}, but continuing or compacting those histories may fail with invalid_encrypted_content. Return to the original provider/account or start a new session if you need reliable continuation.`; } -export async function getStatus({ codexHome: explicitCodexHome } = {}) { +export async function getStatus({ codexHome: explicitCodexHome, sqliteHome } = {}) { const codexHome = normalizeCodexHome(explicitCodexHome); - await ensureCodexHome(codexHome); const configPath = path.join(codexHome, "config.toml"); const configText = await readConfigText(configPath); + const storage = await prepareStorage({ codexHome, sqliteHome, configText }); const current = readCurrentProviderFromConfigText(configText); const configuredProviders = listConfiguredProviderIds(configText); const { @@ -112,18 +120,21 @@ export async function getStatus({ codexHome: explicitCodexHome } = {}) { userEventThreadIds, threadCwdById } = await collectSessionChanges(codexHome, "__status_only__", { skipLockedReads: true }); - const stateDbLocation = await detectStateDb(codexHome); - const sqliteCounts = await readSqliteProviderCounts(codexHome); + const stateDbLocation = storage.stateDbLocation; + const sqliteCounts = await readSqliteProviderCounts(storage); const sqliteRepairStats = sqliteCounts && !sqliteCounts.unreadable - ? await readSqliteRepairStats(codexHome, { userEventThreadIds, threadCwdById }) + ? await readSqliteRepairStats(storage, { userEventThreadIds, threadCwdById }) : null; const projectThreadVisibility = sqliteCounts?.unreadable ? [] - : await readProjectThreadVisibility(codexHome); + : await readProjectThreadVisibility(storage); const backupSummary = await getBackupSummary(codexHome); return { codexHome, + sqliteHome: storage.sqliteHome, + sqliteHomeSource: storage.sqliteHomeSource, + checkedStateDbPaths: storage.stateDbCandidates.map((candidate) => candidate.path), currentProvider: current.provider, currentProviderImplicit: current.implicit, configuredProviders, @@ -143,6 +154,7 @@ export async function getStatus({ codexHome: explicitCodexHome } = {}) { export function renderStatus(status) { const lines = [ `Codex home: ${status.codexHome}`, + `SQLite home: ${status.sqliteHome} (source: ${status.sqliteHomeSource})`, `Current provider: ${status.currentProvider}${status.currentProviderImplicit ? " (implicit default)" : ""}`, `Configured providers: ${status.configuredProviders.join(", ")}`, `Backups: ${status.backupSummary.count} (${formatBytes(status.backupSummary.totalBytes)})`, @@ -170,7 +182,7 @@ export function renderStatus(status) { const legacyNote = status.stateDbLocation.source === "legacy-root" ? " (legacy root)" : ""; lines.push(` database: ${status.stateDbLocation.path}${legacyNote}`); } else { - lines.push(" database: not found (checked sqlite/state_5.sqlite, state_5.sqlite)"); + lines.push(` database: not found (checked ${status.checkedStateDbPaths.join(", ")})`); } if (status.sqliteCounts?.unreadable) { lines.push(` ${status.sqliteCounts.error ?? "state_5.sqlite is malformed or unreadable"}`); @@ -204,6 +216,8 @@ export function renderStatus(status) { export async function runSync({ codexHome: explicitCodexHome, + sqliteHome, + storage: providedStorage, provider, configBackupText, keepCount = DEFAULT_BACKUP_RETENTION_COUNT, @@ -215,10 +229,13 @@ export async function runSync({ throw new Error(`Invalid automatic keep count: ${keepCount}. Expected an integer greater than or equal to 1.`); } - const codexHome = normalizeCodexHome(explicitCodexHome); - await ensureCodexHome(codexHome); + const codexHome = providedStorage?.codexHome ?? normalizeCodexHome(explicitCodexHome); const configPath = path.join(codexHome, "config.toml"); const configText = await readConfigText(configPath); + const storage = await prepareStorage({ codexHome, sqliteHome, configText, storage: providedStorage }); + if (!storage.stateDbLocation && isConfiguredSqliteHome(storage)) { + throw missingConfiguredStateDbError(storage); + } const current = readCurrentProviderFromConfigText(configText); const targetProvider = provider ?? current.provider ?? DEFAULT_PROVIDER; @@ -235,7 +252,7 @@ export async function runSync({ userEventThreadIds, threadCwdById } = await collectSessionChanges(codexHome, targetProvider, { skipLockedReads: true, targetModel: model }); - const cwdStats = await readThreadCwdStats(codexHome); + const cwdStats = await readThreadCwdStats(storage); const encryptedContentWarning = buildEncryptedContentWarning(encryptedContentCounts, targetProvider); emitProgress(onProgress, { stage: "scan_rollout_files", @@ -260,7 +277,7 @@ export async function runSync({ ...lockedReadPaths, ...lockedChanges.map((change) => change.path) ])].sort((left, right) => left.localeCompare(right)); - await assertSqliteWritable(codexHome, { busyTimeoutMs: sqliteBusyTimeoutMs }); + await assertSqliteWritable(storage, { busyTimeoutMs: sqliteBusyTimeoutMs }); emitProgress(onProgress, { stage: "create_backup", @@ -269,6 +286,7 @@ export async function runSync({ }); const backupStartedAt = Date.now(); backupDir = await createBackup({ + storage, codexHome, targetProvider, sessionChanges: writableChanges, @@ -300,7 +318,7 @@ export async function runSync({ writableCount: writableChanges.length }); const sqliteResult = await updateSqliteProvider( - codexHome, + storage, targetProvider, async () => { if (writableChanges.length > 0) { @@ -310,7 +328,7 @@ export async function runSync({ sessionRestoreNeeded = appliedSessionChanges.length > 0; await updateSessionBackupManifest(backupDir, appliedSessionChanges); } - workspaceRootResult = await syncWorkspaceRoots(codexHome, { cwdStats }); + workspaceRootResult = await syncWorkspaceRoots(storage, { cwdStats }); globalStateRestoreNeeded = workspaceRootResult.updated; }, { busyTimeoutMs: sqliteBusyTimeoutMs, userEventThreadIds, threadCwdById, targetModel: model } @@ -350,6 +368,8 @@ export async function runSync({ }); return { codexHome, + sqliteHome: storage.sqliteHome, + sqliteHomeSource: storage.sqliteHomeSource, targetProvider, previousProvider: current.provider, backupDir, @@ -403,6 +423,7 @@ export async function runSync({ export async function runSwitch({ codexHome: explicitCodexHome, + sqliteHome, provider, model, keepRootModel = false, @@ -414,9 +435,12 @@ export async function runSwitch({ } const codexHome = normalizeCodexHome(explicitCodexHome); - await ensureCodexHome(codexHome); const configPath = path.join(codexHome, "config.toml"); const originalConfigText = await readConfigText(configPath); + const storage = await prepareStorage({ codexHome, sqliteHome, configText: originalConfigText }); + if (!storage.stateDbLocation && isConfiguredSqliteHome(storage)) { + throw missingConfiguredStateDbError(storage); + } if (!configDeclaresProvider(originalConfigText, provider)) { throw new Error(`Provider "${provider}" is not available in config.toml. Configure it first or use one of: ${listConfiguredProviderIds(originalConfigText).join(", ")}`); } @@ -473,6 +497,7 @@ export async function runSwitch({ } const syncResult = await runSync({ codexHome, + storage, provider, configBackupText: originalConfigText, keepCount, @@ -492,22 +517,32 @@ export async function runSwitch({ export async function runRestore({ codexHome: explicitCodexHome, + sqliteHome, backupDir, restoreConfig = true, restoreDatabase = true, - restoreSessions = true + restoreSessions = true, + allowSqliteHomeRelocation = false }) { if (!backupDir) { throw new Error("Missing backup path. Usage: codex-provider restore "); } const codexHome = normalizeCodexHome(explicitCodexHome); - await ensureCodexHome(codexHome); + if (allowSqliteHomeRelocation && !(typeof sqliteHome === "string" && sqliteHome.trim())) { + throw new Error("--allow-sqlite-home-relocation requires an explicit --sqlite-home path."); + } + const configText = await readConfigText(path.join(codexHome, "config.toml")); + const storage = await prepareStorage({ codexHome, sqliteHome, configText }); + if (restoreDatabase && !storage.stateDbLocation && isConfiguredSqliteHome(storage)) { + throw missingConfiguredStateDbError(storage); + } const releaseLock = await acquireLock(codexHome, "restore"); try { - return await restoreBackup(path.resolve(backupDir), codexHome, { + return await restoreBackup(path.resolve(backupDir), storage, { restoreConfig, restoreDatabase, - restoreSessions + restoreSessions, + allowSqliteHomeRelocation }); } finally { await releaseLock(); @@ -523,7 +558,7 @@ export async function runPruneBackups({ } const codexHome = normalizeCodexHome(explicitCodexHome); - await ensureCodexHome(codexHome); + await ensureCodexHome(resolveStorageLayout({ codexHome, env: {} })); const releaseLock = await acquireLock(codexHome, "prune-backups"); try { return await pruneBackups(codexHome, keepCount); diff --git a/src/sqlite-state.js b/src/sqlite-state.js index 8556632..fe4e9e7 100644 --- a/src/sqlite-state.js +++ b/src/sqlite-state.js @@ -3,6 +3,7 @@ import path from "node:path"; import { DB_FILE_BASENAME, SESSION_DIRS, SQLITE_DIR_BASENAME } from "./constants.js"; import { openDatabase } from "./sqlite.js"; +import { resolveStorageLayout } from "./storage-layout.js"; const DEFAULT_BUSY_TIMEOUT_MS = 5000; @@ -14,19 +15,15 @@ export function legacyStateDbPath(codexHome) { return path.join(codexHome, DB_FILE_BASENAME); } -export function stateDbCandidates(codexHome) { - return [ - { - path: stateDbPath(codexHome), - relativePath: path.join(SQLITE_DIR_BASENAME, DB_FILE_BASENAME), - source: "sqlite-dir" - }, - { - path: legacyStateDbPath(codexHome), - relativePath: DB_FILE_BASENAME, - source: "legacy-root" - } - ]; +function normalizeStorage(storageOrCodexHome) { + if (typeof storageOrCodexHome === "string") { + return resolveStorageLayout({ codexHome: storageOrCodexHome, env: {} }); + } + return storageOrCodexHome; +} + +export function stateDbCandidates(storageOrCodexHome) { + return normalizeStorage(storageOrCodexHome).stateDbCandidates; } async function countRolloutFilesInDir(rootDir) { @@ -117,9 +114,10 @@ function compareStateDbCandidateStats(a, b) { return a.priority - b.priority; } -export async function detectStateDb(codexHome) { +export async function detectStateDb(storageOrCodexHome) { + const storage = normalizeStorage(storageOrCodexHome); const existingCandidates = []; - const candidates = stateDbCandidates(codexHome); + const candidates = stateDbCandidates(storage); for (const [priority, candidate] of candidates.entries()) { try { await fs.access(candidate.path); @@ -132,7 +130,7 @@ export async function detectStateDb(codexHome) { return null; } - const rolloutCount = await countRolloutFiles(codexHome); + const rolloutCount = await countRolloutFiles(storage.codexHome); const readableCandidates = []; for (const { candidate, priority } of existingCandidates) { try { @@ -154,8 +152,21 @@ export async function detectStateDb(codexHome) { return readableCandidates.sort(compareStateDbCandidateStats)[0].candidate; } -export async function existingStateDbPath(codexHome) { - return (await detectStateDb(codexHome))?.path ?? null; +async function resolveStateDbLocation(storageOrLocation) { + if (!storageOrLocation) { + return null; + } + if (Object.hasOwn(storageOrLocation, "stateDbLocation")) { + return storageOrLocation.stateDbLocation; + } + if (typeof storageOrLocation.path === "string" && typeof storageOrLocation.source === "string") { + return storageOrLocation; + } + return detectStateDb(storageOrLocation); +} + +export async function existingStateDbPath(storageOrLocation) { + return (await resolveStateDbLocation(storageOrLocation))?.path ?? null; } function tableHasColumn(db, tableName, columnName) { @@ -206,8 +217,8 @@ export function wrapSqliteMalformedError(error, action) { ); } -export async function readSqliteProviderCounts(codexHome) { - const dbPath = await existingStateDbPath(codexHome); +export async function readSqliteProviderCounts(storageOrLocation) { + const dbPath = await existingStateDbPath(storageOrLocation); if (!dbPath) { return null; } @@ -259,8 +270,8 @@ export async function readSqliteProviderCounts(codexHome) { } } -export async function readSqliteRepairStats(codexHome, options = {}) { - const dbPath = await existingStateDbPath(codexHome); +export async function readSqliteRepairStats(storageOrLocation, options = {}) { + const dbPath = await existingStateDbPath(storageOrLocation); if (!dbPath) { return null; } @@ -307,8 +318,8 @@ export async function readSqliteRepairStats(codexHome, options = {}) { } } -export async function assertSqliteWritable(codexHome, options = {}) { - const dbPath = await existingStateDbPath(codexHome); +export async function assertSqliteWritable(storageOrLocation, options = {}) { + const dbPath = await existingStateDbPath(storageOrLocation); if (!dbPath) { return { databasePresent: false }; } @@ -330,7 +341,7 @@ export async function assertSqliteWritable(codexHome, options = {}) { } } -export async function updateSqliteProvider(codexHome, targetProvider, afterUpdateOrOptions, maybeOptions) { +export async function updateSqliteProvider(storageOrLocation, targetProvider, afterUpdateOrOptions, maybeOptions) { const afterUpdate = typeof afterUpdateOrOptions === "function" ? afterUpdateOrOptions : null; const options = typeof afterUpdateOrOptions === "function" ? (maybeOptions ?? {}) @@ -341,7 +352,7 @@ export async function updateSqliteProvider(codexHome, targetProvider, afterUpdat // untouched (legacy behaviour for callers that do not track model). const targetModel = options.targetModel ?? null; - const dbPath = await existingStateDbPath(codexHome); + const dbPath = await existingStateDbPath(storageOrLocation); if (!dbPath) { if (afterUpdate) { await afterUpdate({ diff --git a/src/storage-layout.js b/src/storage-layout.js new file mode 100644 index 0000000..81cf684 --- /dev/null +++ b/src/storage-layout.js @@ -0,0 +1,79 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { DB_FILE_BASENAME, defaultCodexHome } from "./constants.js"; +import { readSqliteHomeFromConfigText } from "./config-file.js"; + +function resolvePath(value, cwd) { + return path.resolve(cwd, value); +} + +export function normalizeCodexHome(explicitCodexHome, { env = process.env, cwd = process.cwd() } = {}) { + return resolvePath(explicitCodexHome ?? env.CODEX_HOME ?? defaultCodexHome(), cwd); +} + +export function resolveStorageLayout({ + codexHome: explicitCodexHome, + sqliteHome: explicitSqliteHome, + configText = "", + env = process.env, + cwd = process.cwd() +} = {}) { + const codexHome = normalizeCodexHome(explicitCodexHome, { env, cwd }); + const configuredSqliteHome = readSqliteHomeFromConfigText(configText); + const selected = [ + [explicitSqliteHome, "cli"], + [configuredSqliteHome, "config"], + [env.CODEX_SQLITE_HOME, "env"] + ].find(([value]) => typeof value === "string" && value.trim()); + + const sqliteHomeSource = selected?.[1] ?? "default"; + const sqliteHome = selected + ? resolvePath(selected[0].trim(), cwd) + : path.join(codexHome, "sqlite"); + const allowLegacyRootFallback = sqliteHomeSource === "default"; + const stateDbCandidates = [ + { + path: path.join(sqliteHome, DB_FILE_BASENAME), + relativePath: allowLegacyRootFallback + ? path.join("sqlite", DB_FILE_BASENAME) + : DB_FILE_BASENAME, + source: allowLegacyRootFallback ? "sqlite-dir" : "sqlite-home" + }, + ...(allowLegacyRootFallback + ? [{ + path: path.join(codexHome, DB_FILE_BASENAME), + relativePath: DB_FILE_BASENAME, + source: "legacy-root" + }] + : []) + ]; + + return { + codexHome, + sqliteHome, + sqliteHomeSource, + allowLegacyRootFallback, + stateDbCandidates + }; +} + +export async function ensureCodexHome(storage) { + await fs.access(storage.codexHome).catch(() => { + throw new Error(`Codex home not found at ${storage.codexHome}`); + }); +} + +export function withStateDbLocation(storage, stateDbLocation) { + return { ...storage, stateDbLocation }; +} + +export function isConfiguredSqliteHome(storage) { + return storage.sqliteHomeSource !== "default"; +} + +export function missingConfiguredStateDbError(storage) { + return new Error( + `state_5.sqlite not found in configured SQLite home ${storage.sqliteHome} (source: ${storage.sqliteHomeSource}).` + ); +} diff --git a/src/watch.js b/src/watch.js index 4326b96..31551ce 100644 --- a/src/watch.js +++ b/src/watch.js @@ -14,13 +14,15 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; -import { defaultCodexHome } from "./constants.js"; import { detectStateDb } from "./sqlite-state.js"; import { readConfigText, readRootModelFromConfigText } from "./config-file.js"; - -function normalizeCodexHome(explicitCodexHome) { - return path.resolve(explicitCodexHome ?? process.env.CODEX_HOME ?? defaultCodexHome()); -} +import { + isConfiguredSqliteHome, + missingConfiguredStateDbError, + normalizeCodexHome, + resolveStorageLayout, + withStateDbLocation +} from "./storage-layout.js"; function defaultDebounceMs() { return 750; @@ -52,6 +54,7 @@ function makeDebouncer(delayMs, run) { export async function runWatch({ codexHome: explicitCodexHome, + sqliteHome: explicitSqliteHome, debounceMs = defaultDebounceMs(), includeStateDb = true, once = false, @@ -83,7 +86,17 @@ export async function runWatch({ } }; - const invokeSync = async (reason) => { + const resolveCurrentStorage = async () => { + const configText = await readConfigText(configPath); + const layout = resolveStorageLayout({ + codexHome, + sqliteHome: explicitSqliteHome, + configText + }); + return withStateDbLocation(layout, await detectStateDb(layout)); + }; + + const invokeSync = async (reason, storage) => { // Read the current root-level model on every fire so the per-thread // model rewrite picks up the latest value the user has in config.toml. // We only consider the top-level (root) `model = "..."` line — anything @@ -98,15 +111,16 @@ export async function runWatch({ // Missing/unreadable config; carry on with a null model. } if (typeof onSync === "function") { - return onSync({ reason, codexHome, model: rootModel }); + return onSync({ reason, codexHome, sqliteHome: storage.sqliteHome, storage, model: rootModel }); } if (typeof runSyncImpl === "function") { - return runSyncImpl({ codexHome, reason, model: rootModel }); + return runSyncImpl({ codexHome, sqliteHome: storage.sqliteHome, storage, reason, model: rootModel }); } // Lazy import to avoid pulling in the full service module until needed. const { runSync } = await import("./service.js"); return runSync({ codexHome, + storage, model: rootModel, onProgress: (event) => { if (event?.stage && event.status === "start") { @@ -118,7 +132,10 @@ export async function runWatch({ let stopped = false; let watchers = []; + let stateWatchers = []; + let stateWatchGeneration = 0; let stateDbInfo = null; + let activeStorage = null; // Track the currently-running sync (if any) so that stop()/SIGINT can // wait for it to drain instead of yanking the watcher out from under // a half-written SQLite transaction. @@ -152,7 +169,18 @@ export async function runWatch({ log(`[${new Date().toISOString()}] Detected change (${reason}); running sync...`); const task = (async () => { try { - const result = await invokeSync(reason); + const nextStorage = await resolveCurrentStorage(); + if (includeStateDb && reason === "config.toml") { + await rebindStateWatchers(nextStorage); + } else { + activeStorage = nextStorage; + } + if (!nextStorage.stateDbLocation && isConfiguredSqliteHome(nextStorage)) { + log(`[${new Date().toISOString()}] Sync paused: ${missingConfiguredStateDbError(nextStorage).message} Waiting for config.toml to be fixed.`); + consecutiveNonBusyFailures = 0; + return; + } + const result = await invokeSync(reason, nextStorage); log(`[${new Date().toISOString()}] Sync complete: provider=${result.targetProvider}, rollout_files=${result.changedSessionFiles}, sqlite_rows=${result.sqliteRowsUpdated}${result.skippedLockedRolloutFiles?.length ? `, skipped_locked=${result.skippedLockedRolloutFiles.length}` : ""}`); // A successful sync resets the consecutive-failure counter // so a transient error followed by recovery does not @@ -209,39 +237,12 @@ export async function runWatch({ if (includeStateDb) { try { - stateDbInfo = await detectStateDb(codexHome); + await rebindStateWatchers(await resolveCurrentStorage()); } catch (error) { log(`[${new Date().toISOString()}] Could not locate state database: ${error.message}`); } - if (stateDbInfo?.path) { - // Watch the active database *and* its WAL sidecar. SQLite by - // default runs in WAL journal mode: new transactions are - // appended to `state_5.sqlite-wal` and only checkpointed back - // to the main file on a checkpoint, so watching the main - // file alone would miss every write Codex makes between - // checkpoints. We watch both, plus the -shm shared-memory - // file so a checkpoint still fires the change event for - // long-running sessions. - const stateDbFile = stateDbInfo.path; - const walFile = `${stateDbFile}-wal`; - const shmFile = `${stateDbFile}-shm`; - for (const target of [stateDbFile, walFile, shmFile]) { - // Watch each file directly instead of its parent - // directory. Watching a directory on Windows enters a - // libuv path that asserts in src/win/fs-event.c around - // line 72 when any sibling under the directory is renamed - // during startup (a race condition Node 22 and 24 started - // hitting reliably on Windows runners). Watching a single - // file bypasses that path entirely. The downside is that - // SQLite's atomic-rename of the database (or its WAL) - // can leave us listening on a stale handle; we re-attach - // the watcher on any `rename` event so the next write - // burst still reaches us. - attachStateWatcher(target, target === stateDbFile ? "state_db" : "state_db-wal"); - } - } else { - log(`[${new Date().toISOString()}] No state database found in ${codexHome}; skipping watcher`); - } + } else { + activeStorage = await resolveCurrentStorage(); } log(`[${new Date().toISOString()}] Watching ${configPath}${includeStateDb && stateDbInfo?.path ? `, ${stateDbInfo.path}, ${stateDbInfo.path}-wal, ${stateDbInfo.path}-shm` : ""} (debounce ${debounceMs}ms${once ? ", once" : ""})`); @@ -251,6 +252,7 @@ export async function runWatch({ return; } stopped = true; + stateWatchGeneration += 1; for (const watcher of watchers) { try { watcher.close(); @@ -302,14 +304,41 @@ export async function runWatch({ } } - function attachStateWatcher(stateDbFile, reasonLabel) { + async function rebindStateWatchers(storage) { + stateWatchGeneration += 1; + const generation = stateWatchGeneration; + for (const watcher of stateWatchers) { + try { + watcher.close(); + } catch { + // best-effort + } + const index = watchers.indexOf(watcher); + if (index !== -1) { + watchers.splice(index, 1); + } + } + stateWatchers = []; + activeStorage = storage; + stateDbInfo = storage.stateDbLocation; + if (!stateDbInfo?.path) { + log(`[${new Date().toISOString()}] No state database found in ${storage.sqliteHome}; waiting for config.toml changes`); + return; + } + const stateDbFile = stateDbInfo.path; + for (const target of [stateDbFile, `${stateDbFile}-wal`, `${stateDbFile}-shm`]) { + attachStateWatcher(target, target === stateDbFile ? "state_db" : "state_db-wal", generation); + } + } + + function attachStateWatcher(stateDbFile, reasonLabel, generation) { // Attach (or re-attach after a rename) a single-file fs.watch // for the active SQLite database (or its WAL/SHM sidecar). // Returns when the watcher is attached so we can drive startup // synchronously. let current = null; const tryAttach = () => { - if (stopped || current !== null) { + if (stopped || generation !== stateWatchGeneration || current !== null) { return; } // WAL and SHM sidecars may not exist when the watcher starts. @@ -338,6 +367,10 @@ export async function runWatch({ if (idx !== -1) { watchers.splice(idx, 1); } + const stateIndex = stateWatchers.indexOf(watcher); + if (stateIndex !== -1) { + stateWatchers.splice(stateIndex, 1); + } setTimeout(tryAttach, 50); return; } @@ -360,6 +393,7 @@ export async function runWatch({ return; } watchers.push(watcher); + stateWatchers.push(watcher); current = watcher; }; tryAttach(); @@ -368,7 +402,12 @@ export async function runWatch({ return { codexHome, watchedConfigPath: configPath, - watchedStateDbPath: stateDbInfo?.path ?? null, + get watchedStateDbPath() { + return stateDbInfo?.path ?? null; + }, + get sqliteHome() { + return activeStorage?.sqliteHome ?? null; + }, stop: () => shutdown("external"), signalPromise, done: donePromise diff --git a/src/workspace-roots.js b/src/workspace-roots.js index c073ecc..b2b0dad 100644 --- a/src/workspace-roots.js +++ b/src/workspace-roots.js @@ -20,6 +20,12 @@ export function globalStateBackupPath(codexHome) { return path.join(codexHome, GLOBAL_STATE_BACKUP_FILE_BASENAME); } +function codexHomeFrom(storageOrCodexHome) { + return typeof storageOrCodexHome === "string" + ? storageOrCodexHome + : storageOrCodexHome.codexHome; +} + export function normalizeComparablePath(value) { if (typeof value !== "string") { return null; @@ -163,8 +169,8 @@ function copyResolvedObjectKeys(input, cwdStats) { return result; } -export async function readThreadCwdStats(codexHome) { - const dbPath = await existingStateDbPath(codexHome); +export async function readThreadCwdStats(storageOrCodexHome) { + const dbPath = await existingStateDbPath(storageOrCodexHome); if (!dbPath) { return []; } @@ -236,7 +242,8 @@ function formatRankPreview(ranks, maxCount = 12) { return remaining > 0 ? `${preview} (+${remaining} more)` : preview; } -export async function readProjectThreadVisibility(codexHome, options = {}) { +export async function readProjectThreadVisibility(storageOrCodexHome, options = {}) { + const codexHome = codexHomeFrom(storageOrCodexHome); const pageSize = Number.isInteger(options.pageSize) && options.pageSize > 0 ? options.pageSize : 50; @@ -256,7 +263,7 @@ export async function readProjectThreadVisibility(codexHome, options = {}) { return []; } - const dbPath = await existingStateDbPath(codexHome); + const dbPath = await existingStateDbPath(storageOrCodexHome); if (!dbPath) { return roots.map((root) => ({ root, @@ -353,7 +360,8 @@ export async function readProjectThreadVisibility(codexHome, options = {}) { } } -export async function syncWorkspaceRoots(codexHome, options = {}) { +export async function syncWorkspaceRoots(storageOrCodexHome, options = {}) { + const codexHome = codexHomeFrom(storageOrCodexHome); const filePath = globalStatePath(codexHome); const backupPath = globalStateBackupPath(codexHome); @@ -373,7 +381,7 @@ export async function syncWorkspaceRoots(codexHome, options = {}) { } const state = JSON.parse(originalText); - const cwdStats = options.cwdStats ?? await readThreadCwdStats(codexHome); + const cwdStats = options.cwdStats ?? await readThreadCwdStats(storageOrCodexHome); const existingSavedRoots = toPathArray(state["electron-saved-workspace-roots"]); const existingProjectOrder = toPathArray(state["project-order"]); const existingActiveRoots = toPathArray(state["active-workspace-roots"]); diff --git a/test/config-file.test.js b/test/config-file.test.js index 937ece2..663eb70 100644 --- a/test/config-file.test.js +++ b/test/config-file.test.js @@ -7,10 +7,29 @@ import { readCurrentProviderFromConfigText, readProviderModel, readRootModelFromConfigText, + readSqliteHomeFromConfigText, setRootModelInConfigText, setRootProviderInConfigText } from "../src/config-file.js"; +test("readSqliteHomeFromConfigText reads root basic and literal strings", () => { + assert.equal( + readSqliteHomeFromConfigText('sqlite_home = "C:\\\\Users\\\\Example\\\\.codex\\\\sqlite"\n'), + "C:\\Users\\Example\\.codex\\sqlite" + ); + assert.equal( + readSqliteHomeFromConfigText("sqlite_home = '\\\\wsl.localhost\\Ubuntu\\home\\user\\.codex\\sqlite'\n"), + "\\\\wsl.localhost\\Ubuntu\\home\\user\\.codex\\sqlite" + ); +}); + +test("readSqliteHomeFromConfigText ignores provider-section values", () => { + assert.equal( + readSqliteHomeFromConfigText('[model_providers.custom]\nsqlite_home = "/wrong"\n'), + null + ); +}); + test("readCurrentProviderFromConfigText falls back to implicit openai", () => { const input = ` # comment diff --git a/test/launcher.test.js b/test/launcher.test.js index 46c2d7d..e6b31e7 100644 --- a/test/launcher.test.js +++ b/test/launcher.test.js @@ -29,3 +29,15 @@ test("installWindowsLauncher creates cmd and vbs launchers", async () => { assert.match(vbsText, /Codex Provider Sync/); assert.match(vbsText, /codex-provider sync --codex-home ""C:\\Users\\Example User\\.codex""/); }); + +test("installWindowsLauncher preserves an explicit UNC SQLite home", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-launcher-")); + const sqliteHome = "\\\\wsl.localhost\\Ubuntu\\home\\user\\.codex\\sqlite"; + + const result = await installWindowsLauncher({ dir, sqliteHome }); + const cmdText = await fs.readFile(result.cmdPath, "utf8"); + const vbsText = await fs.readFile(result.vbsPath, "utf8"); + + assert.match(cmdText, /--sqlite-home "\\\\wsl\.localhost\\Ubuntu\\home\\user\\\.codex\\sqlite"/); + assert.match(vbsText, /--sqlite-home ""\\\\wsl\.localhost\\Ubuntu\\home\\user\\\.codex\\sqlite""/); +}); diff --git a/test/storage-layout.test.js b/test/storage-layout.test.js new file mode 100644 index 0000000..38a5697 --- /dev/null +++ b/test/storage-layout.test.js @@ -0,0 +1,56 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; + +import { resolveStorageLayout } from "../src/storage-layout.js"; + +const cwd = path.resolve("/work"); +const codexHome = path.resolve("/codex-home"); + +test("resolveStorageLayout applies override, config, environment, and default precedence", () => { + const explicit = resolveStorageLayout({ + codexHome, + sqliteHome: "explicit-db", + configText: 'sqlite_home = "config-db"', + env: { CODEX_SQLITE_HOME: "env-db" }, + cwd + }); + assert.equal(explicit.sqliteHome, path.resolve(cwd, "explicit-db")); + assert.equal(explicit.sqliteHomeSource, "cli"); + + const configured = resolveStorageLayout({ + codexHome, + configText: "sqlite_home = 'config-db'", + env: { CODEX_SQLITE_HOME: "env-db" }, + cwd + }); + assert.equal(configured.sqliteHome, path.resolve(cwd, "config-db")); + assert.equal(configured.sqliteHomeSource, "config"); + + const environment = resolveStorageLayout({ + codexHome, + env: { CODEX_SQLITE_HOME: "env-db" }, + cwd + }); + assert.equal(environment.sqliteHome, path.resolve(cwd, "env-db")); + assert.equal(environment.sqliteHomeSource, "env"); + + const fallback = resolveStorageLayout({ codexHome, env: {}, cwd }); + assert.equal(fallback.sqliteHome, path.join(codexHome, "sqlite")); + assert.equal(fallback.sqliteHomeSource, "default"); +}); + +test("resolveStorageLayout only enables legacy root fallback for the default layout", () => { + const fallback = resolveStorageLayout({ codexHome, env: {}, cwd }); + assert.deepEqual( + fallback.stateDbCandidates.map((candidate) => candidate.path), + [path.join(codexHome, "sqlite", "state_5.sqlite"), path.join(codexHome, "state_5.sqlite")] + ); + + const explicit = resolveStorageLayout({ codexHome, sqliteHome: "/external", env: {}, cwd }); + assert.deepEqual( + explicit.stateDbCandidates.map((candidate) => candidate.path), + [path.resolve("/external", "state_5.sqlite")] + ); + assert.equal(explicit.allowLegacyRootFallback, false); +}); diff --git a/test/sync-service.test.js b/test/sync-service.test.js index 1c8932c..4031e10 100644 --- a/test/sync-service.test.js +++ b/test/sync-service.test.js @@ -18,6 +18,8 @@ import { getUnsupportedNodeVersionMessage } from "../src/node-version.js"; import { applySessionChanges, collectSessionChanges } from "../src/session-files.js"; import { openDatabase } from "../src/sqlite.js"; +delete process.env.CODEX_SQLITE_HOME; + async function makeTempCodexHome() { const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-")); const codexHome = path.join(root, ".codex"); @@ -331,6 +333,9 @@ test("runSync rewrites rollout files and sqlite, then restore reverts both", asy assert.deepEqual(syncResult.skippedLockedRolloutFiles, []); assert.equal(syncResult.sqliteRowsUpdated, 2); const backupMetadata = JSON.parse(await fs.readFile(path.join(syncResult.backupDir, "metadata.json"), "utf8")); + assert.equal(backupMetadata.version, 2); + assert.equal(backupMetadata.sqliteHome, path.join(codexHome, SQLITE_DIR_BASENAME)); + assert.deepEqual(backupMetadata.sqliteDbFiles, [DB_FILE_BASENAME]); assert.deepEqual( backupMetadata.dbFiles.map((fileName) => fileName.replaceAll("\\", "/")), ["sqlite/state_5.sqlite"] @@ -382,6 +387,9 @@ test("runSync updates legacy root sqlite database when sqlite-dir state is stale assert.equal(syncResult.sqliteRowsUpdated, 2); const backupMetadata = JSON.parse(await fs.readFile(path.join(syncResult.backupDir, "metadata.json"), "utf8")); + assert.equal(backupMetadata.version, 2); + assert.equal(backupMetadata.sqliteHome, codexHome); + assert.deepEqual(backupMetadata.sqliteDbFiles, [DB_FILE_BASENAME]); assert.deepEqual(backupMetadata.dbFiles, [DB_FILE_BASENAME]); const legacyDb = await openDatabase(legacyStateDbPath(codexHome)); @@ -410,6 +418,191 @@ test("runSync updates legacy root sqlite database when sqlite-dir state is stale } }); +test("runSync uses an explicit SQLite home and never touches a stale Codex Home database", async () => { + const { root, codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-external.jsonl"); + await writeRollout(sessionPath, "thread-external", "custom"); + + const sqliteHome = path.join(root, "external-sqlite"); + const externalDbPath = path.join(sqliteHome, DB_FILE_BASENAME); + await writeStateDbAt(externalDbPath, [ + { id: "thread-external", model_provider: "custom", archived: false } + ]); + await writeStateDb(codexHome, [ + { id: "thread-stale", model_provider: "stale", archived: false } + ]); + + const result = await runSync({ codexHome, sqliteHome }); + assert.equal(result.sqliteHome, sqliteHome); + assert.equal(result.sqliteHomeSource, "cli"); + + const externalDb = await openDatabase(externalDbPath); + try { + assert.equal( + externalDb.prepare("SELECT model_provider FROM threads WHERE id = ?").get("thread-external").model_provider, + "openai" + ); + } finally { + externalDb.close(); + } + + const staleDb = await openDatabase(stateDbPath(codexHome)); + try { + assert.equal( + staleDb.prepare("SELECT model_provider FROM threads WHERE id = ?").get("thread-stale").model_provider, + "stale" + ); + } finally { + staleDb.close(); + } + + const metadata = JSON.parse(await fs.readFile(path.join(result.backupDir, "metadata.json"), "utf8")); + assert.equal(metadata.version, 2); + assert.equal(metadata.sqliteHome, sqliteHome); + assert.deepEqual(metadata.dbFiles, []); + assert.deepEqual(metadata.sqliteDbFiles, [DB_FILE_BASENAME]); + await fs.access(path.join(result.backupDir, "db", "sqlite-home", DB_FILE_BASENAME)); + + await runRestore({ codexHome, sqliteHome, backupDir: result.backupDir }); + const restoredDb = await openDatabase(externalDbPath); + try { + assert.equal( + restoredDb.prepare("SELECT model_provider FROM threads WHERE id = ?").get("thread-external").model_provider, + "custom" + ); + } finally { + restoredDb.close(); + } +}); + +test("configured SQLite home reports a missing database and blocks writes without fallback", async () => { + const { root, codexHome } = await makeTempCodexHome(); + const sqliteHome = path.join(root, "missing-sqlite"); + await fs.writeFile( + path.join(codexHome, "config.toml"), + `model_provider = "openai"\nsqlite_home = '${sqliteHome}'\n`, + "utf8" + ); + await writeStateDb(codexHome, [ + { id: "thread-stale", model_provider: "custom", archived: false } + ]); + + const status = await getStatus({ codexHome }); + assert.equal(status.sqliteHome, sqliteHome); + assert.equal(status.sqliteHomeSource, "config"); + assert.equal(status.stateDbLocation, null); + assert.match(renderStatus(status), /database: not found/); + assert.match(renderStatus(status), new RegExp(sqliteHome.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"))); + + await assert.rejects( + () => runSync({ codexHome }), + /not found in configured SQLite home/ + ); +}); + +test("v2 restore rejects SQLite home relocation unless explicitly allowed", async () => { + const { root, codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-relocation.jsonl"); + await writeRollout(sessionPath, "thread-relocation", "custom"); + + const sourceSqliteHome = path.join(root, "source-sqlite"); + const targetSqliteHome = path.join(root, "target-sqlite"); + await writeStateDbAt(path.join(sourceSqliteHome, DB_FILE_BASENAME), [ + { id: "thread-relocation", model_provider: "custom", archived: false } + ]); + await writeStateDbAt(path.join(targetSqliteHome, DB_FILE_BASENAME), [ + { id: "thread-relocation", model_provider: "openai", archived: false } + ]); + + const syncResult = await runSync({ codexHome, sqliteHome: sourceSqliteHome }); + await assert.rejects( + () => runRestore({ codexHome, sqliteHome: targetSqliteHome, backupDir: syncResult.backupDir }), + /Use --allow-sqlite-home-relocation/ + ); + await assert.rejects( + () => runRestore({ codexHome, backupDir: syncResult.backupDir, allowSqliteHomeRelocation: true }), + /requires an explicit --sqlite-home/ + ); + + await runRestore({ + codexHome, + sqliteHome: targetSqliteHome, + backupDir: syncResult.backupDir, + allowSqliteHomeRelocation: true + }); + const targetDb = await openDatabase(path.join(targetSqliteHome, DB_FILE_BASENAME)); + try { + assert.equal( + targetDb.prepare("SELECT model_provider FROM threads WHERE id = ?").get("thread-relocation").model_provider, + "custom" + ); + } finally { + targetDb.close(); + } +}); + +test("restoreBackup keeps metadata v1 database paths compatible", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + await writeStateDb(codexHome, [ + { id: "thread-v1", model_provider: "openai", archived: false } + ]); + + const backupDir = path.join(backupRoot(codexHome), "v1-restore"); + const backupDbPath = path.join(backupDir, "db", SQLITE_DIR_BASENAME, DB_FILE_BASENAME); + await writeStateDbAt(backupDbPath, [ + { id: "thread-v1", model_provider: "custom", archived: false } + ]); + await fs.writeFile( + path.join(backupDir, "metadata.json"), + JSON.stringify({ + version: 1, + namespace: "provider-sync", + codexHome, + targetProvider: "custom", + createdAt: "2026-03-24T00:00:00.000Z", + dbFiles: [path.join(SQLITE_DIR_BASENAME, DB_FILE_BASENAME)], + changedSessionFiles: 0 + }), + "utf8" + ); + + await restoreBackup(backupDir, codexHome, { + restoreConfig: false, + restoreSessions: false + }); + + const restoredDb = await openDatabase(stateDbPath(codexHome)); + try { + assert.equal( + restoredDb.prepare("SELECT model_provider FROM threads WHERE id = ?").get("thread-v1").model_provider, + "custom" + ); + } finally { + restoredDb.close(); + } +}); + +test("restore validates v2 SQLite files before restoring config", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + await writeStateDb(codexHome, [ + { id: "thread-restore-validation", model_provider: "custom", archived: false } + ]); + const syncResult = await runSync({ codexHome }); + await fs.rm(path.join(syncResult.backupDir, "db", "sqlite-home", DB_FILE_BASENAME)); + + const currentConfig = 'model_provider = "sentinel"\n'; + await fs.writeFile(path.join(codexHome, "config.toml"), currentConfig, "utf8"); + await assert.rejects( + () => runRestore({ codexHome, backupDir: syncResult.backupDir }), + /declares a missing SQLite file/ + ); + assert.equal(await fs.readFile(path.join(codexHome, "config.toml"), "utf8"), currentConfig); +}); + test("runSync reports stage progress and backup duration", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); diff --git a/test/watch.test.js b/test/watch.test.js index 96e51fb..db92ab8 100644 --- a/test/watch.test.js +++ b/test/watch.test.js @@ -6,8 +6,12 @@ import path from "node:path"; import { runWatch } from "../src/watch.js"; +delete process.env.CODEX_SQLITE_HOME; + +const testTempDir = process.platform === "win32" ? os.tmpdir() : "/tmp"; + async function makeTempCodexHome() { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-watch-")); + const root = await fs.mkdtemp(path.join(testTempDir, "codex-provider-sync-watch-")); const codexHome = path.join(root, ".codex"); await fs.mkdir(path.join(codexHome, "sqlite"), { recursive: true }); await fs.writeFile( @@ -30,6 +34,17 @@ function deferred() { return { promise, resolve, reject }; } +async function waitUntil(predicate, message, timeoutMs = 3000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(message); +} + test("runWatch rejects invalid debounce-ms values", async () => { const { codexHome } = await makeTempCodexHome(); await assert.rejects( @@ -40,7 +55,7 @@ test("runWatch rejects invalid debounce-ms values", async () => { }); test("runWatch rejects when codex home or config.toml is missing", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-watch-")); + const root = await fs.mkdtemp(path.join(testTempDir, "codex-provider-sync-watch-")); await assert.rejects( () => runWatch({ codexHome: path.join(root, "does-not-exist") }), /Codex home not found/ @@ -244,6 +259,68 @@ test("runWatch observes the active state database chosen by detectStateDb", asyn await fs.rm(codexHome, { recursive: true, force: true }); }); +test("runWatch rebinds SQLite watchers after config changes and drops invalid homes", async () => { + const { root, codexHome } = await makeTempCodexHome(); + const configPath = path.join(codexHome, "config.toml"); + const originalDbPath = path.join(codexHome, "sqlite", "state_5.sqlite"); + const sqliteHomeA = path.join(root, "sqlite-a"); + const sqliteHomeB = path.join(root, "sqlite-b"); + const dbPathA = path.join(sqliteHomeA, "state_5.sqlite"); + const dbPathB = path.join(sqliteHomeB, "state_5.sqlite"); + await fs.mkdir(sqliteHomeA, { recursive: true }); + await fs.mkdir(sqliteHomeB, { recursive: true }); + await fs.writeFile(dbPathA, "", "utf8"); + await fs.writeFile(dbPathB, "", "utf8"); + + const syncCalls = []; + const handle = await runWatch({ + codexHome, + debounceMs: 30, + includeStateDb: true, + onSync: async ({ reason, sqliteHome }) => { + syncCalls.push({ reason, sqliteHome }); + return { targetProvider: "openai", changedSessionFiles: 0, sqliteRowsUpdated: 0 }; + } + }); + + await fs.writeFile( + configPath, + `model_provider = "openai"\nsqlite_home = '${sqliteHomeA}'\n`, + "utf8" + ); + await waitUntil(() => handle.watchedStateDbPath === dbPathA, "watcher did not bind SQLite home A"); + const beforeA = syncCalls.length; + await fs.appendFile(dbPathA, "a", "utf8"); + await waitUntil(() => syncCalls.length > beforeA, "watcher did not observe SQLite home A"); + + const missingSqliteHome = path.join(root, "missing-sqlite"); + await fs.writeFile( + configPath, + `model_provider = "openai"\nsqlite_home = '${missingSqliteHome}'\n`, + "utf8" + ); + await waitUntil(() => handle.watchedStateDbPath === null, "watcher did not drop the invalid SQLite home"); + await new Promise((resolve) => setTimeout(resolve, 100)); + const beforeOldDbWrite = syncCalls.length; + await fs.appendFile(dbPathA, "stale", "utf8"); + await fs.appendFile(originalDbPath, "stale", "utf8"); + await new Promise((resolve) => setTimeout(resolve, 250)); + assert.equal(syncCalls.length, beforeOldDbWrite, "invalid layout must leave only the config watcher active"); + + await fs.writeFile( + configPath, + `model_provider = "openai"\nsqlite_home = '${sqliteHomeB}'\n`, + "utf8" + ); + await waitUntil(() => handle.watchedStateDbPath === dbPathB, "watcher did not bind SQLite home B"); + const beforeB = syncCalls.length; + await fs.appendFile(dbPathB, "b", "utf8"); + await waitUntil(() => syncCalls.length > beforeB, "watcher did not observe SQLite home B"); + + await handle.stop(); + await fs.rm(root, { recursive: true, force: true }); +}); + test("runWatch reacts to writes in the SQLite WAL sidecar", async () => { // Regression guard for owner review: when Codex runs against a // SQLite database in WAL journal mode (the default), new @@ -316,7 +393,7 @@ test("runWatch uses the top-level model field, ignoring provider sections", asyn // inside a `[model_providers.*]` section — otherwise the // provider-section model would be propagated to every // rollout's turn_context.model. - const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-watch-")); + const root = await fs.mkdtemp(path.join(testTempDir, "codex-provider-sync-watch-")); const codexHome = path.join(root, ".codex"); await fs.mkdir(path.join(codexHome, "sqlite"), { recursive: true }); await fs.writeFile( From 6fd9eebfbfd05690ff6e0f5d5d76db5727a9475d Mon Sep 17 00:00:00 2001 From: Hccake Date: Tue, 28 Jul 2026 00:46:34 +0800 Subject: [PATCH 02/12] docs: explain split SQLite home usage --- AGENTS.md | 8 +++++--- README.md | 16 ++++++++++++++-- docs/README_EN.md | 8 ++++++-- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 19ef19d..9bcfec4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,9 @@ For normal Windows users, prefer the GUI app when it is available. Use the CLI w The tool works by updating both: - rollout metadata under `~/.codex/sessions` and `~/.codex/archived_sessions` -- SQLite thread metadata in the detected Codex state database, normally - `~/.codex/sqlite/state_5.sqlite` with legacy fallback to - `~/.codex/state_5.sqlite` +- SQLite thread metadata in the resolved Codex state database + +Resolve SQLite Home in this order: explicit CLI override, root `sqlite_home` in `config.toml`, `CODEX_SQLITE_HOME`, then `/sqlite`. Only the default layout may fall back to `/state_5.sqlite`. Never fall back when an explicit/config/environment SQLite Home is missing. Do not solve this by manually editing rollout files only unless the user explicitly asks for manual intervention. @@ -128,6 +128,7 @@ If `switch ` fails because the provider is missing: - by default the tool keeps the most recent 5 managed backups - use GUI retention settings or CLI `--keep ` when the user wants a different retention count - do not edit `state_5.sqlite` or rollout files manually if the tool can do it +- metadata v2 backups record `sqliteHome` and `sqliteDbFiles`; CLI restore to a different SQLite Home requires explicit relocation flags - GUI settings live in `%AppData%\codex-provider-sync\settings.json` ## Recommended Commands @@ -148,6 +149,7 @@ With an explicit Codex home: ```bash codex-provider status --codex-home C:\Users\you\.codex +codex-provider status --codex-home C:\Users\you\.codex --sqlite-home \\wsl.localhost\Ubuntu\home\you\.codex\sqlite codex-provider sync --codex-home C:\Users\you\.codex codex-provider switch openai --codex-home C:\Users\you\.codex ``` diff --git a/README.md b/README.md index 381f95a..037d102 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Codex 切换 `model_provider` 后,旧会话可能从 Desktop 或 `/resume` 中 ## 它会处理什么 - 同步 `~/.codex/sessions` 和 `~/.codex/archived_sessions` 中的 rollout metadata。 -- 同步 Codex SQLite 线程记录;优先检测 `~/.codex/sqlite/state_5.sqlite`,并兼容旧路径 `~/.codex/state_5.sqlite`。 +- 同步 Codex SQLite 线程记录,并支持 SQLite 与 `Codex Home` 分开存放。 - 修复项目可见性相关路径信息,并在需要时同步相关 model metadata。 - 每次同步前自动备份,支持恢复和清理旧备份。 - 大型 rollout 文件在满足条件时原地更新,否则自动使用完整安全重写。 @@ -75,7 +75,18 @@ codex-provider sync | `codex-provider watch` | 监听配置、SQLite 和 WAL 变化并自动同步 | | `codex-provider watch --once` | 第一次变化并成功同步后退出 | -`switch` 支持 `--model ` 显式设置根级 model,或使用 `--keep-root-model` 只切换 Provider。所有主要命令都支持 `--codex-home `。 +`switch` 支持 `--model ` 显式设置根级 model,或使用 `--keep-root-model` 只切换 Provider。所有主要命令都支持 `--codex-home ` 和 `--sqlite-home `。 + +SQLite Home 按以下顺序解析:命令行 override → `config.toml` 根级 `sqlite_home` → `CODEX_SQLITE_HOME` → `/sqlite`。只有最后一种默认布局会继续检查旧路径 `/state_5.sqlite`;一旦显式指定 SQLite Home,就不会回退到 Codex Home 中的旧数据库。 + +例如 Codex App 使用 Windows 配置、app-server 与 SQLite 位于 WSL 时,可在 WSL CLI 中直接传入: + +```bash +codex-provider status --codex-home /mnt/c/Users/you/.codex --sqlite-home /home/you/.codex/sqlite +codex-provider sync --codex-home /mnt/c/Users/you/.codex --sqlite-home /home/you/.codex/sqlite +``` + +`status` 会显示 effective SQLite Home 和来源。显式路径缺少 `state_5.sqlite` 时,状态查询只报告诊断,`sync`、`switch` 和数据库恢复不会偷偷回退到其它位置。 ## 安全与限制 @@ -88,6 +99,7 @@ codex-provider sync - 不修改消息历史、会话标题、认证信息、`auth.json` 或 `updated_at`。 - 不在多台设备之间复制配置或会话文件;它只修复当前 Codex Home 的 metadata。 - SQLite 被占用时,需要先关闭 Codex、Codex App 和 app-server 后重试。 +- 新备份使用 metadata v2 记录独立 SQLite Home;恢复到其它 SQLite Home 默认拒绝。CLI 需要同时传入 `--sqlite-home` 和 `--allow-sqlite-home-relocation`。 - 活跃会话锁住 rollout 文件时,工具会跳过该文件并继续处理其它会话;结束活跃会话后可再次同步。 - 含 `encrypted_content` 的会话跨 Provider/account 后,可能只能恢复列表可见性,继续对话或 compact 仍可能报 `invalid_encrypted_content`。 - Codex Desktop 首屏目前只显示最近 50 条会话。若 `/resume` 可见但项目侧仍不显示,请查看状态中的 `first page` / `ranks` 诊断;本工具不会修改时间戳来绕过此限制。 diff --git a/docs/README_EN.md b/docs/README_EN.md index ae655a4..75e8cd4 100644 --- a/docs/README_EN.md +++ b/docs/README_EN.md @@ -30,7 +30,7 @@ The tool does not sign in, manage accounts, or switch authentication. Switch Pro ## What It Updates - Rollout metadata under `~/.codex/sessions` and `~/.codex/archived_sessions`. -- Codex SQLite thread records. It prefers `~/.codex/sqlite/state_5.sqlite` and supports the legacy `~/.codex/state_5.sqlite` location. +- Codex SQLite thread records, including layouts where SQLite is stored outside Codex Home. - Project-visibility path information and related model metadata when required. - Managed backups before each synchronization, with restore and pruning support. - Large rollout files in place when safe, with automatic fallback to a full safe rewrite. @@ -75,7 +75,11 @@ Common commands: | `codex-provider watch` | Watch config, SQLite, and WAL changes and synchronize automatically | | `codex-provider watch --once` | Exit after the first change is synchronized successfully | -`switch` accepts `--model ` to set the root model explicitly, or `--keep-root-model` to change only the Provider. All main commands accept `--codex-home `. +`switch` accepts `--model ` to set the root model explicitly, or `--keep-root-model` to change only the Provider. All main commands accept `--codex-home ` and `--sqlite-home `. + +SQLite Home precedence is: CLI override, root-level `sqlite_home` in `config.toml`, `CODEX_SQLITE_HOME`, then `/sqlite`. The legacy `/state_5.sqlite` fallback is enabled only for the default layout. An explicit SQLite Home never falls back to a stale database under Codex Home. + +`status` reports the effective SQLite Home and its source. If an explicit location has no `state_5.sqlite`, read-only status reports the diagnostic while write operations fail. New metadata v2 backups record the separate SQLite Home. Restoring a v2 backup to a different SQLite Home is rejected unless relocation is explicitly confirmed; the CLI requires both `--sqlite-home` and `--allow-sqlite-home-relocation`. Node.js 24+ uses the built-in `node:sqlite` module. Older supported Node.js releases use the optional `better-sqlite3` dependency. From 5093d086118c973da0fc56bc2131d1b3e2048851 Mon Sep 17 00:00:00 2001 From: Hccake Date: Tue, 28 Jul 2026 10:04:56 +0800 Subject: [PATCH 03/12] feat(desktop): support independent SQLite home in core --- .../CoreIntegrationTests.cs | 148 +++++++++++++ .../SettingsAndDiscoveryTests.cs | 67 ++++++ .../TestEnvironment.cs | 12 + .../CodexProviderSync.Core/BackupService.cs | 209 +++++++++++++++--- .../CodexStorageLayoutService.cs | 80 +++++++ .../CodexSyncService.cs | 96 ++++++-- .../ConfigFileService.cs | 57 +++++ .../GlobalStateService.cs | 31 ++- desktop/CodexProviderSync.Core/Models.cs | 28 +++ .../CodexProviderSync.Core/SettingsService.cs | 64 ++++++ .../SqliteStateService.cs | 76 +++++-- .../CodexProviderSync.Core/TextFormatter.cs | 13 +- 12 files changed, 806 insertions(+), 75 deletions(-) create mode 100644 desktop/CodexProviderSync.Core.Tests/TestEnvironment.cs create mode 100644 desktop/CodexProviderSync.Core/CodexStorageLayoutService.cs diff --git a/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs b/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs index 98748e5..9085960 100644 --- a/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs +++ b/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs @@ -1402,4 +1402,152 @@ await service.RunRestoreAsync( Assert.Equal(originalFirstLine, (await File.ReadAllLinesAsync(sessionPath))[0]); } + + [Fact] + public async Task RunSync_UsesExplicitSqliteHomeWithoutTouchingDefaultDatabase() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + await fixture.WriteStateDbAsync([("default-thread", "default-provider", false)]); + string sqliteHome = Path.Combine(fixture.Root, "external-sqlite"); + string externalDbPath = Path.Combine(sqliteHome, AppConstants.DbFileBasename); + await fixture.WriteStateDbAtAsync( + externalDbPath, + [("external-thread", "custom", false)], + model: "old-model"); + + CodexSyncService service = new(); + SyncResult result = await service.RunSyncAsync( + fixture.CodexHome, + model: "new-model", + explicitSqliteHome: sqliteHome); + + Assert.Equal(Path.GetFullPath(sqliteHome), result.SqliteHome); + Assert.Equal("gui", result.SqliteHomeSource); + Assert.Equal("openai", await ReadProviderAsync(externalDbPath, "external-thread")); + Assert.Equal("default-provider", await ReadProviderAsync(fixture.StateDbPath(), "default-thread")); + + BackupMetadataFile metadata = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(Path.Combine(result.BackupDir, "metadata.json")), + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })!; + Assert.Equal(2, metadata.Version); + Assert.Equal(Path.GetFullPath(sqliteHome), metadata.SqliteHome); + Assert.Empty(metadata.DbFiles); + Assert.Equal([AppConstants.DbFileBasename], metadata.SqliteDbFiles); + } + + [Fact] + public async Task ConfiguredSqliteHomeWithoutDatabase_IsDiagnosticForStatusButBlocksSync() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + string sqliteHome = Path.Combine(fixture.Root, "missing-sqlite"); + await fixture.WriteConfigAsync($"model_provider = \"openai\"\nsqlite_home = '{sqliteHome}'"); + await fixture.WriteStateDbAsync([("stale-thread", "custom", false)]); + + CodexSyncService service = new(); + StatusSnapshot status = await service.GetStatusAsync(fixture.CodexHome); + + Assert.Equal(Path.GetFullPath(sqliteHome), status.SqliteHome); + Assert.Equal("config", status.SqliteHomeSource); + Assert.Null(status.StateDbLocation); + Assert.Single(status.CheckedStateDbPaths); + await Assert.ThrowsAsync(() => service.RunSyncAsync(fixture.CodexHome)); + Assert.Equal("custom", await ReadProviderAsync(fixture.StateDbPath(), "stale-thread")); + } + + [Fact] + public async Task RestoreVersionTwo_RequiresExplicitRelocationConfirmation() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sourceSqliteHome = Path.Combine(fixture.Root, "source-sqlite"); + string sourceDbPath = Path.Combine(sourceSqliteHome, AppConstants.DbFileBasename); + await fixture.WriteStateDbAtAsync(sourceDbPath, [("thread-a", "custom", false)], model: null); + + CodexSyncService service = new(); + SyncResult syncResult = await service.RunSyncAsync( + fixture.CodexHome, + explicitSqliteHome: sourceSqliteHome); + + string targetSqliteHome = Path.Combine(fixture.Root, "target-sqlite"); + string targetDbPath = Path.Combine(targetSqliteHome, AppConstants.DbFileBasename); + await fixture.WriteStateDbAtAsync(targetDbPath, [("thread-a", "target", false)], model: null); + RestoreBackupOptions deniedOptions = new() + { + RestoreConfig = false, + RestoreDatabase = true, + RestoreSessions = false + }; + + await Assert.ThrowsAsync(() => service.RunRestoreAsync( + fixture.CodexHome, + syncResult.BackupDir, + deniedOptions, + targetSqliteHome)); + Assert.Equal("target", await ReadProviderAsync(targetDbPath, "thread-a")); + + await service.RunRestoreAsync( + fixture.CodexHome, + syncResult.BackupDir, + new RestoreBackupOptions + { + RestoreConfig = false, + RestoreDatabase = true, + RestoreSessions = false, + AllowSqliteHomeRelocation = true + }, + targetSqliteHome); + Assert.Equal("custom", await ReadProviderAsync(targetDbPath, "thread-a")); + } + + [Fact] + public async Task RestoreVersionTwo_ValidatesDatabaseFilesBeforeRestoringConfig() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"current\""); + await fixture.WriteStateDbAsync([("thread-a", "current", false)]); + string backupDir = fixture.BackupPath("20260728T000000000Z"); + string metadata = JsonSerializer.Serialize(new + { + version = 2, + @namespace = AppConstants.BackupNamespace, + codexHome = fixture.CodexHome, + sqliteHome = Path.GetDirectoryName(fixture.StateDbPath()), + targetProvider = "backup", + createdAt = DateTimeOffset.UtcNow, + dbFiles = Array.Empty(), + sqliteDbFiles = new[] { AppConstants.DbFileBasename }, + changedSessionFiles = 0 + }); + await fixture.WriteBackupAsync( + "20260728T000000000Z", + ("metadata.json", metadata), + ("config.toml", "model_provider = \"backup\"\n")); + + CodexSyncService service = new(); + await Assert.ThrowsAsync(() => service.RunRestoreAsync( + fixture.CodexHome, + backupDir, + new RestoreBackupOptions { RestoreSessions = false })); + + Assert.Contains( + "model_provider = \"current\"", + await File.ReadAllTextAsync(Path.Combine(fixture.CodexHome, "config.toml"))); + } + + private static async Task ReadProviderAsync(string dbPath, string threadId) + { + SqliteConnectionStringBuilder builder = new() + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadOnly, + Pooling = false + }; + await using SqliteConnection connection = new(builder.ConnectionString); + await connection.OpenAsync(); + SqliteCommand command = connection.CreateCommand(); + command.CommandText = "SELECT model_provider FROM threads WHERE id = $id"; + command.Parameters.AddWithValue("$id", threadId); + return Convert.ToString(await command.ExecuteScalarAsync())!; + } } diff --git a/desktop/CodexProviderSync.Core.Tests/SettingsAndDiscoveryTests.cs b/desktop/CodexProviderSync.Core.Tests/SettingsAndDiscoveryTests.cs index 01bfbb0..5164e9e 100644 --- a/desktop/CodexProviderSync.Core.Tests/SettingsAndDiscoveryTests.cs +++ b/desktop/CodexProviderSync.Core.Tests/SettingsAndDiscoveryTests.cs @@ -2,6 +2,55 @@ namespace CodexProviderSync.Core.Tests; public sealed class SettingsAndDiscoveryTests { + [Fact] + public void ConfigFileService_ReadsBasicAndLiteralSqliteHome() + { + ConfigFileService service = new(); + + Assert.Equal( + @"C:\Users\cheng\.codex\sqlite", + service.ReadSqliteHomeFromConfigText("sqlite_home = 'C:\\Users\\cheng\\.codex\\sqlite'\n[model_providers.custom]\n")); + Assert.Equal( + "C:\\Users\\cheng\\.codex\\sqlite", + service.ReadSqliteHomeFromConfigText("sqlite_home = \"C:\\\\Users\\\\cheng\\\\.codex\\\\sqlite\" # comment\n")); + } + + [Fact] + public void StorageLayout_UsesExplicitConfigEnvironmentAndDefaultPrecedence() + { + string root = Path.Combine(Path.GetTempPath(), $"storage-layout-{Guid.NewGuid():N}"); + string codexHome = Path.Combine(root, ".codex"); + CodexStorageLayoutService service = new(); + Dictionary environment = new() + { + ["CODEX_SQLITE_HOME"] = Path.Combine(root, "env") + }; + + CodexStorageLayout explicitLayout = service.Resolve( + codexHome, + Path.Combine(root, "explicit"), + $"sqlite_home = '{Path.Combine(root, "config")}'\n", + environment); + CodexStorageLayout configLayout = service.Resolve( + codexHome, + null, + $"sqlite_home = '{Path.Combine(root, "config")}'\n", + environment); + CodexStorageLayout environmentLayout = service.Resolve(codexHome, null, string.Empty, environment); + CodexStorageLayout defaultLayout = service.Resolve( + codexHome, + null, + string.Empty, + new Dictionary()); + + Assert.Equal("gui", explicitLayout.SqliteHomeSource); + Assert.Equal("config", configLayout.SqliteHomeSource); + Assert.Equal("env", environmentLayout.SqliteHomeSource); + Assert.Equal("default", defaultLayout.SqliteHomeSource); + Assert.Single(explicitLayout.StateDbCandidates); + Assert.Equal(2, defaultLayout.StateDbCandidates.Count); + } + [Fact] public void ConfigFileService_UpdatesCompactRootModelAssignment() { @@ -52,6 +101,24 @@ public async Task SettingsService_PersistsRecentPathsAndProviders() Assert.Equal(new DateOnly(2026, 7, 23), loaded.LastAutomaticUpdateCheckDate); } + [Fact] + public async Task SettingsService_PersistsSqliteHomeOverridePerCodexHome() + { + string root = Path.Combine(Path.GetTempPath(), $"codex-provider-settings-{Guid.NewGuid():N}"); + SettingsService service = new(Path.Combine(root, "settings.json")); + string codexHomeA = Path.Combine(root, "codex-a"); + string codexHomeB = Path.Combine(root, "codex-b"); + string sqliteHomeA = Path.Combine(root, "sqlite-a"); + + AppSettings settings = service.RecordSqliteHomeOverride(new AppSettings(), codexHomeA, sqliteHomeA); + settings = service.RecordCodexHome(settings, codexHomeB); + await service.SaveAsync(settings); + AppSettings loaded = await service.LoadAsync(); + + Assert.Equal(Path.GetFullPath(sqliteHomeA), service.GetSqliteHomeOverride(loaded, codexHomeA)); + Assert.Null(service.GetSqliteHomeOverride(loaded, codexHomeB)); + } + [Fact] public async Task SettingsService_OldSettingsDefaultToNoAutomaticUpdateCheck() { diff --git a/desktop/CodexProviderSync.Core.Tests/TestEnvironment.cs b/desktop/CodexProviderSync.Core.Tests/TestEnvironment.cs new file mode 100644 index 0000000..6e7a31f --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/TestEnvironment.cs @@ -0,0 +1,12 @@ +using System.Runtime.CompilerServices; + +namespace CodexProviderSync.Core.Tests; + +internal static class TestEnvironment +{ + [ModuleInitializer] + internal static void Initialize() + { + Environment.SetEnvironmentVariable("CODEX_SQLITE_HOME", null); + } +} diff --git a/desktop/CodexProviderSync.Core/BackupService.cs b/desktop/CodexProviderSync.Core/BackupService.cs index 62f407e..ea9ca84 100644 --- a/desktop/CodexProviderSync.Core/BackupService.cs +++ b/desktop/CodexProviderSync.Core/BackupService.cs @@ -20,21 +20,51 @@ public async Task CreateBackupAsync( string configPath, string? configBackupText = null) { + return await CreateBackupAsync( + new CodexStorageLayoutService().CreateDefault(codexHome), + targetProvider, + sessionChanges, + configPath, + configBackupText); + } + + public async Task CreateBackupAsync( + CodexStorageLayout storage, + string targetProvider, + IReadOnlyList sessionChanges, + string configPath, + string? configBackupText = null) + { + string codexHome = storage.CodexHome; string backupRoot = AppConstants.DefaultBackupRoot(codexHome); string backupDir = Path.Combine(backupRoot, DateTimeOffset.UtcNow.ToString("yyyyMMdd'T'HHmmssfff'Z'")); string dbDir = Path.Combine(backupDir, "db"); Directory.CreateDirectory(dbDir); List copiedDbFiles = []; - StateDbLocation? stateDb = _sqliteStateService.DetectStateDb(codexHome); + List copiedSqliteDbFiles = []; + StateDbLocation? stateDb = storage.StateDbLocation ?? _sqliteStateService.DetectStateDb(storage); + string actualSqliteHome = stateDb is null ? storage.SqliteHome : Path.GetDirectoryName(stateDb.Path)!; if (stateDb is not null) { foreach (string suffix in new[] { string.Empty, "-shm", "-wal" }) { - string relativePath = DbBackupRelativePath(codexHome, stateDb.Path, suffix); - if (await CopyIfPresentAsync(stateDb.Path + suffix, Path.Combine(dbDir, relativePath), overwrite: false)) + string sourcePath = stateDb.Path + suffix; + string sqliteRelativePath = AppConstants.DbFileBasename + suffix; + if (!await CopyIfPresentAsync( + sourcePath, + Path.Combine(dbDir, "sqlite-home", sqliteRelativePath), + overwrite: false)) { - copiedDbFiles.Add(relativePath); + continue; + } + + copiedSqliteDbFiles.Add(sqliteRelativePath); + string? legacyRelativePath = SafeRelativePath(codexHome, sourcePath); + if (legacyRelativePath is not null) + { + await CopyIfPresentAsync(sourcePath, Path.Combine(dbDir, legacyRelativePath), overwrite: false); + copiedDbFiles.Add(legacyRelativePath); } } } @@ -81,12 +111,14 @@ await File.WriteAllTextAsync( BackupMetadataFile metadata = new() { - Version = 1, + Version = 2, Namespace = AppConstants.BackupNamespace, CodexHome = codexHome, + SqliteHome = actualSqliteHome, TargetProvider = targetProvider, CreatedAt = createdAt, DbFiles = copiedDbFiles, + SqliteDbFiles = copiedSqliteDbFiles, ChangedSessionFiles = sessionChanges.Count }; await File.WriteAllTextAsync( @@ -100,14 +132,33 @@ public async Task RestoreBackupAsync( string backupDir, string codexHome, RestoreBackupOptions? options = null) + { + return await RestoreBackupAsync( + backupDir, + new CodexStorageLayoutService().CreateDefault(codexHome), + options); + } + + public async Task RestoreBackupAsync( + string backupDir, + CodexStorageLayout storage, + RestoreBackupOptions? options = null) { options ??= new RestoreBackupOptions(); + string codexHome = storage.CodexHome; string normalizedBackupDir = Path.GetFullPath(backupDir); + string metadataPath = Path.Combine(normalizedBackupDir, "metadata.json"); BackupMetadataFile metadata = JsonSerializer.Deserialize( - await File.ReadAllTextAsync(Path.Combine(normalizedBackupDir, "metadata.json")), + await File.ReadAllTextAsync(metadataPath), JsonOptions()) ?? throw new InvalidOperationException($"Backup metadata is invalid: {backupDir}"); - if (!string.Equals(metadata.CodexHome, codexHome, StringComparison.Ordinal)) + if (!string.Equals(metadata.Namespace, AppConstants.BackupNamespace, StringComparison.Ordinal) + || metadata.Version is not (1 or 2)) + { + throw new InvalidOperationException($"Unsupported backup metadata in {metadataPath}."); + } + + if (!PathsEqual(metadata.CodexHome, codexHome)) { throw new InvalidOperationException($"Backup was created for {metadata.CodexHome}, not {codexHome}."); } @@ -123,45 +174,95 @@ await _sessionRolloutService.AssertSessionFilesWritableAsync( sessionManifest.Files.Select(static entry => entry.Path)); } + List<(string SourcePath, string TargetPath)> databaseEntries = []; + List sidecarsToRemove = []; + if (options.RestoreDatabase) + { + StateDbLocation? stateDb = storage.StateDbLocation ?? _sqliteStateService.DetectStateDb(storage); + if (stateDb is null && storage.HasConfiguredSqliteHome) + { + throw new InvalidOperationException( + $"state_5.sqlite not found in SQLite home {storage.SqliteHome}."); + } + + string targetSqliteHome = stateDb is null ? storage.SqliteHome : Path.GetDirectoryName(stateDb.Path)!; + if (stateDb is not null + && metadata.Version >= 2 + && !string.IsNullOrWhiteSpace(metadata.SqliteHome) + && !PathsEqual(metadata.SqliteHome, targetSqliteHome) + && !options.AllowSqliteHomeRelocation) + { + throw new InvalidOperationException( + $"Backup SQLite home is {metadata.SqliteHome}, but the current target is {targetSqliteHome}. " + + "Confirm SQLite Home relocation before restoring to a different location."); + } + + if (stateDb is not null) + { + CodexStorageLayout detectedStorage = storage with { StateDbLocation = stateDb }; + await _sqliteStateService.AssertSqliteWritableAsync(detectedStorage); + + IReadOnlyList databaseFiles = metadata.Version >= 2 + ? metadata.SqliteDbFiles ?? [] + : metadata.DbFiles ?? []; + string databaseBackupRoot = metadata.Version >= 2 + ? Path.Combine(normalizedBackupDir, "db", "sqlite-home") + : Path.Combine(normalizedBackupDir, "db"); + string restoreRoot = metadata.Version >= 2 ? targetSqliteHome : codexHome; + foreach (string fileName in databaseFiles) + { + string targetPath = metadata.Version >= 2 + ? RestoreSqliteTargetPath(restoreRoot, fileName) + : RestoreDbTargetPath(restoreRoot, fileName); + string sourcePath = Path.Combine(databaseBackupRoot, fileName); + if (!File.Exists(sourcePath)) + { + throw new InvalidOperationException($"Backup declares a missing SQLite file: {sourcePath}"); + } + databaseEntries.Add((sourcePath, targetPath)); + } + + HashSet backedUpFiles = new(databaseFiles, StringComparer.Ordinal); + foreach (string baseFile in databaseFiles.Where( + static fileName => Path.GetFileName(fileName) == AppConstants.DbFileBasename)) + { + string basePath = metadata.Version >= 2 + ? RestoreSqliteTargetPath(restoreRoot, baseFile) + : RestoreDbTargetPath(restoreRoot, baseFile); + foreach (string suffix in new[] { "-shm", "-wal" }) + { + if (!backedUpFiles.Contains(baseFile + suffix)) + { + sidecarsToRemove.Add(basePath + suffix); + } + } + } + } + } + if (options.RestoreConfig) { await CopyIfPresentAsync( Path.Combine(normalizedBackupDir, "config.toml"), Path.Combine(codexHome, "config.toml"), overwrite: true); - await CopyIfPresentAsync( - Path.Combine(normalizedBackupDir, AppConstants.GlobalStateFileBasename), - Path.Combine(codexHome, AppConstants.GlobalStateFileBasename), - overwrite: true); - await CopyIfPresentAsync( - Path.Combine(normalizedBackupDir, AppConstants.GlobalStateBackupFileBasename), - Path.Combine(codexHome, AppConstants.GlobalStateBackupFileBasename), - overwrite: true); + await RestoreGlobalStateFilesAsync(normalizedBackupDir, codexHome); } if (options.RestoreDatabase) { - await _sqliteStateService.AssertSqliteWritableAsync(codexHome); - string dbDir = Path.Combine(normalizedBackupDir, "db"); - HashSet backedUpFiles = new(metadata.DbFiles, StringComparer.Ordinal); - - foreach (string baseFile in metadata.DbFiles.Where(static fileName => Path.GetFileName(fileName) == AppConstants.DbFileBasename)) + foreach (string sidecarPath in sidecarsToRemove) { - string basePath = RestoreDbTargetPath(codexHome, baseFile); - foreach (string suffix in new[] { "-shm", "-wal" }) + if (File.Exists(sidecarPath)) { - string sidecarFile = baseFile + suffix; - string sidecarPath = basePath + suffix; - if (!backedUpFiles.Contains(sidecarFile) && File.Exists(sidecarPath)) - { - File.Delete(sidecarPath); - } + File.Delete(sidecarPath); } } - foreach (string fileName in metadata.DbFiles) + foreach ((string sourcePath, string targetPath) in databaseEntries) { - await CopyIfPresentAsync(Path.Combine(dbDir, fileName), RestoreDbTargetPath(codexHome, fileName), overwrite: true); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.Copy(sourcePath, targetPath, overwrite: true); } } @@ -215,9 +316,11 @@ await File.ReadAllTextAsync(metadataPath), Version = metadata.Version, Namespace = metadata.Namespace, CodexHome = metadata.CodexHome, + SqliteHome = metadata.SqliteHome, TargetProvider = metadata.TargetProvider, CreatedAt = metadata.CreatedAt, DbFiles = metadata.DbFiles, + SqliteDbFiles = metadata.SqliteDbFiles, ChangedSessionFiles = sessionChanges.Count }; @@ -238,6 +341,24 @@ await CopyIfPresentAsync( overwrite: true); } + public async Task GetBackupStorageInfoAsync(string backupDir) + { + string metadataPath = Path.Combine(Path.GetFullPath(backupDir), "metadata.json"); + BackupMetadataFile metadata = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(metadataPath), + JsonOptions()) ?? throw new InvalidOperationException($"Backup metadata is invalid: {backupDir}"); + if (!string.Equals(metadata.Namespace, AppConstants.BackupNamespace, StringComparison.Ordinal) + || metadata.Version is not (1 or 2)) + { + throw new InvalidOperationException($"Unsupported backup metadata in {metadataPath}."); + } + return new BackupStorageInfo + { + Version = metadata.Version, + SqliteHome = metadata.SqliteHome + }; + } + public Task GetBackupSummaryAsync(string codexHome) { string backupRoot = AppConstants.DefaultBackupRoot(codexHome); @@ -317,13 +438,14 @@ private static async Task CopyIfPresentAsync(string sourcePath, string des return true; } - private static string DbBackupRelativePath(string codexHome, string dbPath, string suffix) + private static string? SafeRelativePath(string root, string target) { - string relativePath = Path.GetRelativePath(codexHome, dbPath + suffix); - return !relativePath.StartsWith("..", StringComparison.Ordinal) + string relativePath = Path.GetRelativePath(root, target); + return !string.IsNullOrEmpty(relativePath) + && !relativePath.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(relativePath) ? relativePath - : AppConstants.DbFileBasename + suffix; + : null; } private static string RestoreDbTargetPath(string codexHome, string relativePath) @@ -337,6 +459,25 @@ private static string RestoreDbTargetPath(string codexHome, string relativePath) return Path.Combine(codexHome, relativePath); } + private static string RestoreSqliteTargetPath(string sqliteHome, string relativePath) + { + if (Path.IsPathRooted(relativePath) + || relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Contains("..", StringComparer.Ordinal)) + { + throw new InvalidOperationException($"Invalid SQLite backup path: {relativePath}"); + } + + return Path.Combine(sqliteHome, relativePath); + } + + private static bool PathsEqual(string left, string right) + { + StringComparison comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), comparison); + } + private static JsonSerializerOptions JsonOptions() { return new JsonSerializerOptions diff --git a/desktop/CodexProviderSync.Core/CodexStorageLayoutService.cs b/desktop/CodexProviderSync.Core/CodexStorageLayoutService.cs new file mode 100644 index 0000000..ee4aad3 --- /dev/null +++ b/desktop/CodexProviderSync.Core/CodexStorageLayoutService.cs @@ -0,0 +1,80 @@ +namespace CodexProviderSync.Core; + +public sealed class CodexStorageLayoutService +{ + private readonly CodexHomeService _codexHomeService; + private readonly ConfigFileService _configFileService; + + public CodexStorageLayoutService( + CodexHomeService? codexHomeService = null, + ConfigFileService? configFileService = null) + { + _codexHomeService = codexHomeService ?? new CodexHomeService(); + _configFileService = configFileService ?? new ConfigFileService(); + } + + public CodexStorageLayout Resolve( + string? explicitCodexHome, + string? explicitSqliteHome, + string configText, + IReadOnlyDictionary? environment = null, + string explicitSource = "gui") + { + string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome); + string? configuredSqliteHome = _configFileService.ReadSqliteHomeFromConfigText(configText); + environment ??= Environment.GetEnvironmentVariables() + .Cast() + .ToDictionary( + static entry => Convert.ToString(entry.Key) ?? string.Empty, + static entry => Convert.ToString(entry.Value), + StringComparer.OrdinalIgnoreCase); + environment.TryGetValue("CODEX_SQLITE_HOME", out string? environmentSqliteHome); + + (string? Value, string Source) selected = !string.IsNullOrWhiteSpace(explicitSqliteHome) + ? (explicitSqliteHome, explicitSource) + : !string.IsNullOrWhiteSpace(configuredSqliteHome) + ? (configuredSqliteHome, "config") + : !string.IsNullOrWhiteSpace(environmentSqliteHome) + ? (environmentSqliteHome, "env") + : (null, "default"); + + string sqliteHome = selected.Value is null + ? Path.Combine(codexHome, AppConstants.SqliteDirBasename) + : Path.GetFullPath(selected.Value.Trim()); + bool allowLegacyRootFallback = string.Equals(selected.Source, "default", StringComparison.Ordinal); + List candidates = + [ + new StateDbLocation( + Path.Combine(sqliteHome, AppConstants.DbFileBasename), + allowLegacyRootFallback + ? Path.Combine(AppConstants.SqliteDirBasename, AppConstants.DbFileBasename) + : AppConstants.DbFileBasename, + allowLegacyRootFallback ? "sqlite-dir" : "sqlite-home") + ]; + if (allowLegacyRootFallback) + { + candidates.Add(new StateDbLocation( + Path.Combine(codexHome, AppConstants.DbFileBasename), + AppConstants.DbFileBasename, + "legacy-root")); + } + + return new CodexStorageLayout + { + CodexHome = codexHome, + SqliteHome = sqliteHome, + SqliteHomeSource = selected.Source, + AllowLegacyRootFallback = allowLegacyRootFallback, + StateDbCandidates = candidates + }; + } + + public CodexStorageLayout CreateDefault(string codexHome) + { + return Resolve( + codexHome, + explicitSqliteHome: null, + configText: string.Empty, + environment: new Dictionary()); + } +} diff --git a/desktop/CodexProviderSync.Core/CodexSyncService.cs b/desktop/CodexProviderSync.Core/CodexSyncService.cs index b6e9d47..37508e8 100644 --- a/desktop/CodexProviderSync.Core/CodexSyncService.cs +++ b/desktop/CodexProviderSync.Core/CodexSyncService.cs @@ -10,6 +10,7 @@ public sealed class CodexSyncService private readonly BackupService _backupService; private readonly LockService _lockService; private readonly ProviderDiscoveryService _providerDiscoveryService; + private readonly CodexStorageLayoutService _storageLayoutService; public CodexSyncService() : this( @@ -39,33 +40,40 @@ public CodexSyncService( _globalStateService = globalStateService; _lockService = lockService; _providerDiscoveryService = providerDiscoveryService; + _storageLayoutService = new CodexStorageLayoutService(codexHomeService, configFileService); _backupService = new BackupService(sessionRolloutService, sqliteStateService); } - public async Task GetStatusAsync(string? explicitCodexHome = null) + public async Task GetStatusAsync( + string? explicitCodexHome = null, + string? explicitSqliteHome = null) { string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome); await _codexHomeService.EnsureCodexHomeAsync(codexHome); string configText = await _configFileService.ReadConfigTextAsync(_codexHomeService.ConfigPath(codexHome)); + CodexStorageLayout storage = await PrepareStorageAsync(codexHome, explicitSqliteHome, configText); CurrentProviderInfo currentProvider = _configFileService.ReadCurrentProviderFromConfigText(configText); IReadOnlyList configuredProviders = _configFileService.ListConfiguredProviderIds(configText); SessionChangeCollection rolloutInfo = await _sessionRolloutService.CollectSessionChangesAsync(codexHome, "__status_only__", skipLockedReads: true); - StateDbLocation? stateDbLocation = _sqliteStateService.DetectStateDb(codexHome); - ProviderCounts? sqliteCounts = await _sqliteStateService.ReadSqliteProviderCountsAsync(codexHome); + StateDbLocation? stateDbLocation = storage.StateDbLocation; + ProviderCounts? sqliteCounts = await _sqliteStateService.ReadSqliteProviderCountsAsync(storage); SqliteRepairStats? sqliteRepairStats = sqliteCounts is not null && !sqliteCounts.Unreadable ? await _sqliteStateService.ReadSqliteRepairStatsAsync( - codexHome, + storage, rolloutInfo.UserEventThreadIds, rolloutInfo.ThreadCwdsById) : null; IReadOnlyList projectThreadVisibility = sqliteCounts?.Unreadable == true ? [] - : await _globalStateService.ReadProjectThreadVisibilityAsync(codexHome); + : await _globalStateService.ReadProjectThreadVisibilityAsync(storage); BackupSummary backupSummary = await _backupService.GetBackupSummaryAsync(codexHome); return new StatusSnapshot { CodexHome = codexHome, + SqliteHome = storage.SqliteHome, + SqliteHomeSource = storage.SqliteHomeSource, + CheckedStateDbPaths = storage.StateDbCandidates.Select(static candidate => candidate.Path).ToList(), CurrentProvider = currentProvider, ConfiguredProviders = configuredProviders, RolloutCounts = rolloutInfo.ProviderCounts, @@ -98,7 +106,8 @@ public async Task RunSyncAsync( string? configBackupText = null, int keepCount = AppConstants.DefaultBackupRetentionCount, int? sqliteBusyTimeoutMs = null, - string? model = null) + string? model = null, + string? explicitSqliteHome = null) { if (keepCount < 1) { @@ -109,6 +118,8 @@ public async Task RunSyncAsync( await _codexHomeService.EnsureCodexHomeAsync(codexHome); string configPath = _codexHomeService.ConfigPath(codexHome); string configText = await _configFileService.ReadConfigTextAsync(configPath); + CodexStorageLayout storage = await PrepareStorageAsync(codexHome, explicitSqliteHome, configText); + EnsureWritableStorage(storage); CurrentProviderInfo current = _configFileService.ReadCurrentProviderFromConfigText(configText); string targetProvider = provider ?? current.Provider ?? AppConstants.DefaultProvider; @@ -130,7 +141,7 @@ public async Task RunSyncAsync( targetProvider, skipLockedReads: true, targetModel: targetModel); - IReadOnlyList workspaceCwdStats = await _globalStateService.ReadThreadCwdStatsAsync(codexHome); + IReadOnlyList workspaceCwdStats = await _globalStateService.ReadThreadCwdStatsAsync(storage); string? encryptedContentWarning = BuildEncryptedContentWarning(sessionInfo.EncryptedContentCounts, targetProvider); (IReadOnlyList writableChanges, IReadOnlyList lockedChanges) = await _sessionRolloutService.SplitLockedSessionChangesAsync(sessionInfo.Changes); @@ -141,8 +152,8 @@ public async Task RunSyncAsync( .Order(StringComparer.Ordinal) .ToList(); - await _sqliteStateService.AssertSqliteWritableAsync(codexHome, sqliteBusyTimeoutMs); - string backupDir = await _backupService.CreateBackupAsync(codexHome, targetProvider, writableChanges, configPath, configBackupText); + await _sqliteStateService.AssertSqliteWritableAsync(storage, sqliteBusyTimeoutMs); + string backupDir = await _backupService.CreateBackupAsync(storage, targetProvider, writableChanges, configPath, configBackupText); bool sessionRestoreNeeded = false; List appliedSessionChanges = []; @@ -158,7 +169,7 @@ public async Task RunSyncAsync( { SessionApplyResult? applyResult = null; (int updatedRows, int providerRowsUpdated, int modelRowsUpdated, int userEventRowsUpdated, int cwdRowsUpdated, bool databasePresent) = await _sqliteStateService.UpdateSqliteProviderAsync( - codexHome, + storage, targetProvider, targetModel, async _ => @@ -171,7 +182,7 @@ public async Task RunSyncAsync( sessionRestoreNeeded = appliedSessionChanges.Count > 0; await _backupService.UpdateSessionBackupManifestAsync(backupDir, appliedSessionChanges); } - workspaceRootResult = await _globalStateService.SyncWorkspaceRootsAsync(codexHome, workspaceCwdStats); + workspaceRootResult = await _globalStateService.SyncWorkspaceRootsAsync(storage, workspaceCwdStats); globalStateRestoreNeeded = workspaceRootResult.Updated; }, sqliteBusyTimeoutMs, @@ -195,6 +206,8 @@ public async Task RunSyncAsync( return new SyncResult { CodexHome = codexHome, + SqliteHome = storage.SqliteHome, + SqliteHomeSource = storage.SqliteHomeSource, TargetProvider = targetProvider, PreviousProvider = current.Provider ?? AppConstants.DefaultProvider, BackupDir = backupDir, @@ -258,7 +271,8 @@ public async Task RunSwitchAsync( string provider, int keepCount = AppConstants.DefaultBackupRetentionCount, string? model = null, - bool keepRootModel = false) + bool keepRootModel = false, + string? explicitSqliteHome = null) { if (string.IsNullOrWhiteSpace(provider)) { @@ -269,6 +283,8 @@ public async Task RunSwitchAsync( await _codexHomeService.EnsureCodexHomeAsync(codexHome); string configPath = _codexHomeService.ConfigPath(codexHome); string originalConfigText = await _configFileService.ReadConfigTextAsync(configPath); + CodexStorageLayout storage = await PrepareStorageAsync(codexHome, explicitSqliteHome, originalConfigText); + EnsureWritableStorage(storage); if (!_configFileService.ConfigDeclaresProvider(originalConfigText, provider)) { string configuredProviders = string.Join(", ", _configFileService.ListConfiguredProviderIds(originalConfigText)); @@ -292,10 +308,18 @@ public async Task RunSwitchAsync( string? modelForThreads = modelSync.Applied ? modelSync.Model : _configFileService.ReadRootModelFromConfigText(nextConfigText); - SyncResult result = await RunSyncAsync(codexHome, provider, originalConfigText, keepCount, model: modelForThreads); + SyncResult result = await RunSyncAsync( + codexHome, + provider, + originalConfigText, + keepCount, + model: modelForThreads, + explicitSqliteHome: explicitSqliteHome); return new SyncResult { CodexHome = result.CodexHome, + SqliteHome = result.SqliteHome, + SqliteHomeSource = result.SqliteHomeSource, TargetProvider = result.TargetProvider, PreviousProvider = result.PreviousProvider, BackupDir = result.BackupDir, @@ -364,12 +388,19 @@ private ModelSyncOutcome ResolveModelSyncOutcome( return ModelSyncOutcome.CreateSkipped("none", warning: null); } - public async Task RunRestoreAsync(string? explicitCodexHome, string backupDir) + public async Task RunRestoreAsync( + string? explicitCodexHome, + string backupDir, + string? explicitSqliteHome = null) { - return await RunRestoreAsync(explicitCodexHome, backupDir, new RestoreBackupOptions()); + return await RunRestoreAsync(explicitCodexHome, backupDir, new RestoreBackupOptions(), explicitSqliteHome); } - public async Task RunRestoreAsync(string? explicitCodexHome, string backupDir, RestoreBackupOptions options) + public async Task RunRestoreAsync( + string? explicitCodexHome, + string backupDir, + RestoreBackupOptions options, + string? explicitSqliteHome = null) { if (string.IsNullOrWhiteSpace(backupDir)) { @@ -378,9 +409,11 @@ public async Task RunRestoreAsync(string? explicitCodexHome, stri string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome); await _codexHomeService.EnsureCodexHomeAsync(codexHome); + string configText = await _configFileService.ReadConfigTextAsync(_codexHomeService.ConfigPath(codexHome)); + CodexStorageLayout storage = await PrepareStorageAsync(codexHome, explicitSqliteHome, configText); await using LockHandle _ = await _lockService.AcquireLockAsync(codexHome, "restore"); - return await _backupService.RestoreBackupAsync(Path.GetFullPath(backupDir), codexHome, options); + return await _backupService.RestoreBackupAsync(Path.GetFullPath(backupDir), storage, options); } public async Task RunPruneBackupsAsync( @@ -394,6 +427,11 @@ public async Task RunPruneBackupsAsync( return await _backupService.PruneBackupsAsync(codexHome, keepCount); } + public Task GetBackupStorageInfoAsync(string backupDir) + { + return _backupService.GetBackupStorageInfoAsync(backupDir); + } + private static string? BuildEncryptedContentWarning(ProviderCounts encryptedContentCounts, string targetProvider) { int total = encryptedContentCounts.Sessions.Values.Sum() + encryptedContentCounts.ArchivedSessions.Values.Sum(); @@ -412,4 +450,28 @@ public async Task RunPruneBackupsAsync( return $"Encrypted content warning: {total} rollout file(s) contain encrypted_content from provider(s) {string.Join(", ", riskyProviders)}. Visibility metadata can be synchronized to {targetProvider}, but continuing or compacting those histories may fail with invalid_encrypted_content. Return to the original provider/account or start a new session if you need reliable continuation."; } + + private async Task PrepareStorageAsync( + string codexHome, + string? explicitSqliteHome, + string configText) + { + CodexStorageLayout storage = _storageLayoutService.Resolve( + codexHome, + explicitSqliteHome, + configText, + explicitSource: "gui"); + StateDbLocation? stateDb = _sqliteStateService.DetectStateDb(storage); + return storage with { StateDbLocation = stateDb }; + } + + private static void EnsureWritableStorage(CodexStorageLayout storage) + { + if (storage.StateDbLocation is null && storage.HasConfiguredSqliteHome) + { + throw new InvalidOperationException( + $"state_5.sqlite not found in configured SQLite home {storage.SqliteHome} " + + $"(source: {storage.SqliteHomeSource})."); + } + } } diff --git a/desktop/CodexProviderSync.Core/ConfigFileService.cs b/desktop/CodexProviderSync.Core/ConfigFileService.cs index 524b73a..9889599 100644 --- a/desktop/CodexProviderSync.Core/ConfigFileService.cs +++ b/desktop/CodexProviderSync.Core/ConfigFileService.cs @@ -19,6 +19,45 @@ public Task ReadConfigTextAsync(string configPath) return File.ReadAllTextAsync(configPath); } + public string? ReadSqliteHomeFromConfigText(string configText) + { + return ReadRootStringFromConfigText(configText, "sqlite_home"); + } + + public string? ReadRootStringFromConfigText(string configText, string key) + { + string escapedKey = Regex.Escape(key); + Regex assignment = new( + $"^{escapedKey}\\s*=\\s*(?:\"((?:\\\\.|[^\"\\\\])*)\"|'([^']*)')\\s*(?:#.*)?$", + RegexOptions.CultureInvariant); + + foreach (string rawLine in SplitLines(configText)) + { + string trimmed = rawLine.Trim(); + if (string.IsNullOrWhiteSpace(trimmed) || trimmed.StartsWith('#')) + { + continue; + } + if (trimmed.StartsWith('[')) + { + break; + } + + Match match = assignment.Match(trimmed); + if (!match.Success) + { + continue; + } + if (match.Groups[1].Success) + { + return DecodeTomlBasicString(match.Groups[1].Value); + } + return match.Groups[2].Value; + } + + return null; + } + public async Task WriteConfigTextAsync(string configPath, string configText) { await File.WriteAllTextAsync(configPath, configText); @@ -243,4 +282,22 @@ private static string EscapeTomlString(string value) return value.Replace("\\", "\\\\", StringComparison.Ordinal) .Replace("\"", "\\\"", StringComparison.Ordinal); } + + private static string DecodeTomlBasicString(string value) + { + return Regex.Replace( + value, + "\\\\(?:[btnfr\"\\\\]|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})", + static match => match.Value switch + { + @"\b" => "\b", + @"\t" => "\t", + @"\n" => "\n", + @"\f" => "\f", + @"\r" => "\r", + "\\\"" => "\"", + @"\\" => "\\", + _ => char.ConvertFromUtf32(Convert.ToInt32(match.Value[2..], 16)) + }); + } } diff --git a/desktop/CodexProviderSync.Core/GlobalStateService.cs b/desktop/CodexProviderSync.Core/GlobalStateService.cs index d48e4b9..416bae6 100644 --- a/desktop/CodexProviderSync.Core/GlobalStateService.cs +++ b/desktop/CodexProviderSync.Core/GlobalStateService.cs @@ -20,7 +20,12 @@ public string BackupPath(string codexHome) public async Task> ReadThreadCwdStatsAsync(string codexHome) { - string? dbPath = _sqliteStateService.ExistingStateDbPath(codexHome); + return await ReadThreadCwdStatsAsync(new CodexStorageLayoutService().CreateDefault(codexHome)); + } + + public async Task> ReadThreadCwdStatsAsync(CodexStorageLayout storage) + { + string? dbPath = _sqliteStateService.ExistingStateDbPath(storage); if (dbPath is null) { return []; @@ -90,6 +95,16 @@ public async Task SyncWorkspaceRootsAsync( string codexHome, IReadOnlyList? cwdStats = null) { + return await SyncWorkspaceRootsAsync( + new CodexStorageLayoutService().CreateDefault(codexHome), + cwdStats); + } + + public async Task SyncWorkspaceRootsAsync( + CodexStorageLayout storage, + IReadOnlyList? cwdStats = null) + { + string codexHome = storage.CodexHome; string statePath = StatePath(codexHome); if (!File.Exists(statePath)) { @@ -104,7 +119,7 @@ public async Task SyncWorkspaceRootsAsync( JsonObject state = JsonNode.Parse(await File.ReadAllTextAsync(statePath))?.AsObject() ?? throw new InvalidOperationException($"Global state file is invalid: {statePath}"); - IReadOnlyList effectiveCwdStats = cwdStats ?? await ReadThreadCwdStatsAsync(codexHome); + IReadOnlyList effectiveCwdStats = cwdStats ?? await ReadThreadCwdStatsAsync(storage); List existingSavedRoots = ToPathList(state["electron-saved-workspace-roots"]); List existingProjectOrder = ToPathList(state["project-order"]); @@ -179,6 +194,16 @@ public async Task> ReadProjectThreadVisib string codexHome, int pageSize = 50) { + return await ReadProjectThreadVisibilityAsync( + new CodexStorageLayoutService().CreateDefault(codexHome), + pageSize); + } + + public async Task> ReadProjectThreadVisibilityAsync( + CodexStorageLayout storage, + int pageSize = 50) + { + string codexHome = storage.CodexHome; string statePath = StatePath(codexHome); if (!File.Exists(statePath)) { @@ -193,7 +218,7 @@ public async Task> ReadProjectThreadVisib return []; } - string? dbPath = _sqliteStateService.ExistingStateDbPath(codexHome); + string? dbPath = _sqliteStateService.ExistingStateDbPath(storage); if (dbPath is null) { return roots diff --git a/desktop/CodexProviderSync.Core/Models.cs b/desktop/CodexProviderSync.Core/Models.cs index 4c9715c..6a59ea8 100644 --- a/desktop/CodexProviderSync.Core/Models.cs +++ b/desktop/CodexProviderSync.Core/Models.cs @@ -16,6 +16,9 @@ public sealed class ProviderCounts public sealed class StatusSnapshot { public required string CodexHome { get; init; } + public string SqliteHome { get; init; } = string.Empty; + public string SqliteHomeSource { get; init; } = "default"; + public IReadOnlyList CheckedStateDbPaths { get; init; } = []; public required CurrentProviderInfo CurrentProvider { get; init; } public required IReadOnlyList ConfiguredProviders { get; init; } public required ProviderCounts RolloutCounts { get; init; } @@ -33,6 +36,18 @@ public sealed class StatusSnapshot public sealed record StateDbLocation(string Path, string RelativePath, string Source); +public sealed record CodexStorageLayout +{ + public required string CodexHome { get; init; } + public required string SqliteHome { get; init; } + public string SqliteHomeSource { get; init; } = "default"; + public required bool AllowLegacyRootFallback { get; init; } + public required IReadOnlyList StateDbCandidates { get; init; } + public StateDbLocation? StateDbLocation { get; init; } + + public bool HasConfiguredSqliteHome => !string.Equals(SqliteHomeSource, "default", StringComparison.Ordinal); +} + public sealed class SqliteRepairStats { public required int UserEventRowsNeedingRepair { get; init; } @@ -102,6 +117,8 @@ public sealed class SessionChangeCollection public sealed class SyncResult { public required string CodexHome { get; init; } + public string SqliteHome { get; init; } = string.Empty; + public string SqliteHomeSource { get; init; } = "default"; public required string TargetProvider { get; init; } public required string PreviousProvider { get; init; } public required string BackupDir { get; init; } @@ -169,6 +186,12 @@ public sealed class RestoreResult public int ChangedSessionFiles { get; init; } } +public sealed class BackupStorageInfo +{ + public required int Version { get; init; } + public string? SqliteHome { get; init; } +} + public enum ProviderSource { Config, @@ -199,6 +222,8 @@ public sealed class AppSettings { public List RecentCodexHomes { get; init; } = []; public string? LastCodexHome { get; init; } + public Dictionary SqliteHomeOverrides { get; init; } = new( + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); public List SavedProviders { get; init; } = []; public List ManualProviders { get; init; } = []; public string? LastSelectedProvider { get; init; } @@ -214,6 +239,7 @@ public sealed class RestoreBackupOptions public bool RestoreConfig { get; init; } = true; public bool RestoreDatabase { get; init; } = true; public bool RestoreSessions { get; init; } = true; + public bool AllowSqliteHomeRelocation { get; init; } } internal sealed class BackupMetadataFile @@ -221,9 +247,11 @@ internal sealed class BackupMetadataFile public int Version { get; init; } public required string Namespace { get; init; } public required string CodexHome { get; init; } + public string? SqliteHome { get; init; } public required string TargetProvider { get; init; } public required DateTimeOffset CreatedAt { get; init; } public required List DbFiles { get; init; } + public List SqliteDbFiles { get; init; } = []; public int ChangedSessionFiles { get; init; } } diff --git a/desktop/CodexProviderSync.Core/SettingsService.cs b/desktop/CodexProviderSync.Core/SettingsService.cs index ce71082..95f33a7 100644 --- a/desktop/CodexProviderSync.Core/SettingsService.cs +++ b/desktop/CodexProviderSync.Core/SettingsService.cs @@ -61,6 +61,7 @@ public AppSettings RecordCodexHome(AppSettings settings, string codexHome) { RecentCodexHomes = recents, LastCodexHome = Path.GetFullPath(codexHome), + SqliteHomeOverrides = NormalizeSqliteHomeOverrides(settings.SqliteHomeOverrides), SavedProviders = Deduplicate(settings.SavedProviders).ToList(), ManualProviders = Deduplicate(settings.ManualProviders).ToList(), LastSelectedProvider = settings.LastSelectedProvider, @@ -78,6 +79,7 @@ public AppSettings MergeDetectedProviders(AppSettings settings, IEnumerable !string.Equals(provider, providerId, StringComparison.Ordinal)).Order(StringComparer.Ordinal).ToList(), ManualProviders = settings.ManualProviders.Where(provider => !string.Equals(provider, providerId, StringComparison.Ordinal)).Order(StringComparer.Ordinal).ToList(), LastSelectedProvider = string.Equals(settings.LastSelectedProvider, providerId, StringComparison.Ordinal) ? null : settings.LastSelectedProvider, @@ -129,6 +133,7 @@ public AppSettings UpdateUiLanguage(AppSettings settings, string uiLanguage) { RecentCodexHomes = Deduplicate(settings.RecentCodexHomes).ToList(), LastCodexHome = settings.LastCodexHome, + SqliteHomeOverrides = NormalizeSqliteHomeOverrides(settings.SqliteHomeOverrides), SavedProviders = Deduplicate(settings.SavedProviders).ToList(), ManualProviders = Deduplicate(settings.ManualProviders).ToList(), LastSelectedProvider = settings.LastSelectedProvider, @@ -146,6 +151,7 @@ public AppSettings RecordAutomaticUpdateCheck(AppSettings settings, DateOnly dat { RecentCodexHomes = Deduplicate(settings.RecentCodexHomes).ToList(), LastCodexHome = settings.LastCodexHome, + SqliteHomeOverrides = NormalizeSqliteHomeOverrides(settings.SqliteHomeOverrides), SavedProviders = Deduplicate(settings.SavedProviders).ToList(), ManualProviders = Deduplicate(settings.ManualProviders).ToList(), LastSelectedProvider = settings.LastSelectedProvider, @@ -168,6 +174,7 @@ public AppSettings UpdateState( { RecentCodexHomes = Deduplicate(settings.RecentCodexHomes).ToList(), LastCodexHome = settings.LastCodexHome, + SqliteHomeOverrides = NormalizeSqliteHomeOverrides(settings.SqliteHomeOverrides), SavedProviders = Deduplicate(settings.SavedProviders).ToList(), ManualProviders = Deduplicate(settings.ManualProviders).ToList(), LastSelectedProvider = string.IsNullOrWhiteSpace(selectedProvider) ? settings.LastSelectedProvider : selectedProvider.Trim(), @@ -179,12 +186,54 @@ public AppSettings UpdateState( }; } + public string? GetSqliteHomeOverride(AppSettings settings, string codexHome) + { + Dictionary overrides = NormalizeSqliteHomeOverrides(settings.SqliteHomeOverrides); + return overrides.TryGetValue(Path.GetFullPath(codexHome), out string? sqliteHome) + ? sqliteHome + : null; + } + + public AppSettings RecordSqliteHomeOverride( + AppSettings settings, + string codexHome, + string? sqliteHome) + { + Dictionary overrides = NormalizeSqliteHomeOverrides(settings.SqliteHomeOverrides); + string normalizedCodexHome = Path.GetFullPath(codexHome); + if (string.IsNullOrWhiteSpace(sqliteHome)) + { + overrides.Remove(normalizedCodexHome); + } + else + { + overrides[normalizedCodexHome] = Path.GetFullPath(sqliteHome.Trim()); + } + + AppSettings normalized = Normalize(settings); + return new AppSettings + { + RecentCodexHomes = normalized.RecentCodexHomes, + LastCodexHome = normalized.LastCodexHome, + SqliteHomeOverrides = overrides, + SavedProviders = normalized.SavedProviders, + ManualProviders = normalized.ManualProviders, + LastSelectedProvider = normalized.LastSelectedProvider, + LastBackupDirectory = normalized.LastBackupDirectory, + BackupRetentionCount = normalized.BackupRetentionCount, + UiLanguage = normalized.UiLanguage, + LastAutomaticUpdateCheckDate = normalized.LastAutomaticUpdateCheckDate, + WindowBounds = normalized.WindowBounds + }; + } + private static AppSettings Normalize(AppSettings settings) { return new AppSettings { RecentCodexHomes = Deduplicate(settings.RecentCodexHomes.Select(Path.GetFullPath)).Take(10).ToList(), LastCodexHome = string.IsNullOrWhiteSpace(settings.LastCodexHome) ? null : Path.GetFullPath(settings.LastCodexHome), + SqliteHomeOverrides = NormalizeSqliteHomeOverrides(settings.SqliteHomeOverrides), SavedProviders = Deduplicate(settings.SavedProviders).ToList(), ManualProviders = Deduplicate(settings.ManualProviders).ToList(), LastSelectedProvider = string.IsNullOrWhiteSpace(settings.LastSelectedProvider) ? null : settings.LastSelectedProvider.Trim(), @@ -205,6 +254,21 @@ private static IEnumerable Deduplicate(IEnumerable values) .Order(StringComparer.Ordinal); } + private static Dictionary NormalizeSqliteHomeOverrides( + IReadOnlyDictionary? overrides) + { + Dictionary normalized = new( + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + foreach ((string codexHome, string sqliteHome) in overrides ?? new Dictionary()) + { + if (!string.IsNullOrWhiteSpace(codexHome) && !string.IsNullOrWhiteSpace(sqliteHome)) + { + normalized[Path.GetFullPath(codexHome)] = Path.GetFullPath(sqliteHome); + } + } + return normalized; + } + private static JsonSerializerOptions JsonSerializerOptions() { return new JsonSerializerOptions diff --git a/desktop/CodexProviderSync.Core/SqliteStateService.cs b/desktop/CodexProviderSync.Core/SqliteStateService.cs index 210e303..a653c27 100644 --- a/desktop/CodexProviderSync.Core/SqliteStateService.cs +++ b/desktop/CodexProviderSync.Core/SqliteStateService.cs @@ -31,23 +31,18 @@ public string LegacyStateDbPath(string codexHome) public IReadOnlyList StateDbCandidates(string codexHome) { - return - [ - new StateDbLocation( - StateDbPath(codexHome), - Path.Combine(AppConstants.SqliteDirBasename, AppConstants.DbFileBasename), - "sqlite-dir"), - new StateDbLocation( - LegacyStateDbPath(codexHome), - AppConstants.DbFileBasename, - "legacy-root") - ]; + return new CodexStorageLayoutService().CreateDefault(codexHome).StateDbCandidates; } public StateDbLocation? DetectStateDb(string codexHome) + { + return DetectStateDb(new CodexStorageLayoutService().CreateDefault(codexHome)); + } + + public StateDbLocation? DetectStateDb(CodexStorageLayout storage) { List<(StateDbLocation Location, int Priority)> existingCandidates = []; - IReadOnlyList candidates = StateDbCandidates(codexHome); + IReadOnlyList candidates = storage.StateDbCandidates; for (int index = 0; index < candidates.Count; index += 1) { StateDbLocation candidate = candidates[index]; @@ -62,7 +57,7 @@ public IReadOnlyList StateDbCandidates(string codexHome) return null; } - long rolloutCount = CountRolloutFiles(codexHome); + long rolloutCount = CountRolloutFiles(storage.CodexHome); List readableCandidates = []; foreach ((StateDbLocation candidate, int priority) in existingCandidates) { @@ -98,9 +93,19 @@ public IReadOnlyList StateDbCandidates(string codexHome) return DetectStateDb(codexHome)?.Path; } + public string? ExistingStateDbPath(CodexStorageLayout storage) + { + return storage.StateDbLocation?.Path ?? DetectStateDb(storage)?.Path; + } + public async Task ReadSqliteProviderCountsAsync(string codexHome) { - string? dbPath = ExistingStateDbPath(codexHome); + return await ReadSqliteProviderCountsAsync(new CodexStorageLayoutService().CreateDefault(codexHome)); + } + + public async Task ReadSqliteProviderCountsAsync(CodexStorageLayout storage) + { + string? dbPath = ExistingStateDbPath(storage); if (dbPath is null) { return null; @@ -165,7 +170,18 @@ FROM threads IReadOnlyCollection? userEventThreadIds = null, IReadOnlyDictionary? threadCwdsById = null) { - string? dbPath = ExistingStateDbPath(codexHome); + return await ReadSqliteRepairStatsAsync( + new CodexStorageLayoutService().CreateDefault(codexHome), + userEventThreadIds, + threadCwdsById); + } + + public async Task ReadSqliteRepairStatsAsync( + CodexStorageLayout storage, + IReadOnlyCollection? userEventThreadIds = null, + IReadOnlyDictionary? threadCwdsById = null) + { + string? dbPath = ExistingStateDbPath(storage); if (dbPath is null) { return null; @@ -233,7 +249,14 @@ FROM threads public async Task AssertSqliteWritableAsync(string codexHome, int? busyTimeoutMs = null) { - string? dbPath = ExistingStateDbPath(codexHome); + return await AssertSqliteWritableAsync( + new CodexStorageLayoutService().CreateDefault(codexHome), + busyTimeoutMs); + } + + public async Task AssertSqliteWritableAsync(CodexStorageLayout storage, int? busyTimeoutMs = null) + { + string? dbPath = ExistingStateDbPath(storage); if (dbPath is null) { return false; @@ -265,7 +288,26 @@ public async Task AssertSqliteWritableAsync(string codexHome, int? busyTim IReadOnlyCollection? userEventThreadIds = null, IReadOnlyDictionary? threadCwdsById = null) { - string? dbPath = ExistingStateDbPath(codexHome); + return await UpdateSqliteProviderAsync( + new CodexStorageLayoutService().CreateDefault(codexHome), + targetProvider, + targetModel, + afterUpdate, + busyTimeoutMs, + userEventThreadIds, + threadCwdsById); + } + + public async Task<(int UpdatedRows, int ProviderRowsUpdated, int ModelRowsUpdated, int UserEventRowsUpdated, int CwdRowsUpdated, bool DatabasePresent)> UpdateSqliteProviderAsync( + CodexStorageLayout storage, + string targetProvider, + string? targetModel = null, + Func<(int UpdatedRows, int ProviderRowsUpdated, int ModelRowsUpdated, int UserEventRowsUpdated, int CwdRowsUpdated, bool DatabasePresent), Task>? afterUpdate = null, + int? busyTimeoutMs = null, + IReadOnlyCollection? userEventThreadIds = null, + IReadOnlyDictionary? threadCwdsById = null) + { + string? dbPath = ExistingStateDbPath(storage); if (dbPath is null) { if (afterUpdate is not null) diff --git a/desktop/CodexProviderSync.Core/TextFormatter.cs b/desktop/CodexProviderSync.Core/TextFormatter.cs index 580ea9b..20144b0 100644 --- a/desktop/CodexProviderSync.Core/TextFormatter.cs +++ b/desktop/CodexProviderSync.Core/TextFormatter.cs @@ -104,6 +104,7 @@ private static string FormatStatusEnglish(StatusSnapshot status) List lines = [ $"Codex home: {status.CodexHome}", + $"SQLite home: {status.SqliteHome} (source: {status.SqliteHomeSource})", $"Current provider: {status.CurrentProvider.Provider}{(status.CurrentProvider.Implicit ? " (implicit default)" : string.Empty)}", $"Configured providers: {string.Join(", ", status.ConfiguredProviders)}", $"Backups: {status.BackupSummary.Count} ({FormatBytes(status.BackupSummary.TotalBytes)})", @@ -131,7 +132,7 @@ private static string FormatStatusEnglish(StatusSnapshot status) { rolloutNotes.Add($" {status.EncryptedContentWarning}"); } - lines.InsertRange(11, rolloutNotes); + lines.InsertRange(12, rolloutNotes); AppendSqliteStatus(lines, status, chinese: false); AppendProjectVisibility(lines, status, chinese: false); @@ -143,6 +144,7 @@ private static string FormatStatusChinese(StatusSnapshot status) List lines = [ $"Codex Home: {status.CodexHome}", + $"SQLite Home: {status.SqliteHome}(来源: {status.SqliteHomeSource})", $"当前 Provider: {status.CurrentProvider.Provider}{(status.CurrentProvider.Implicit ? "(隐式默认)" : string.Empty)}", $"配置中的 Provider: {string.Join(", ", status.ConfiguredProviders)}", $"备份: {status.BackupSummary.Count}({FormatBytes(status.BackupSummary.TotalBytes)})", @@ -172,7 +174,7 @@ private static string FormatStatusChinese(StatusSnapshot status) status.EncryptedContentCounts, status.CurrentProvider.Provider)}"); } - lines.InsertRange(11, rolloutNotes); + lines.InsertRange(12, rolloutNotes); AppendSqliteStatus(lines, status, chinese: true); AppendProjectVisibility(lines, status, chinese: true); @@ -192,9 +194,10 @@ private static void AppendSqliteStatus(List lines, StatusSnapshot status } else { + string checkedPaths = string.Join(", ", status.CheckedStateDbPaths); lines.Add(chinese - ? " 未找到数据库(已检查 sqlite/state_5.sqlite 和 state_5.sqlite)" - : " database: not found (checked sqlite/state_5.sqlite, state_5.sqlite)"); + ? $" 未找到数据库(已检查: {checkedPaths})" + : $" database: not found (checked: {checkedPaths})"); } if (status.SqliteCounts?.Unreadable == true) @@ -252,6 +255,7 @@ private static string FormatSyncResultEnglish(SyncResult result, string label) [ $"{label} provider: {result.TargetProvider}", $"Codex home: {result.CodexHome}", + $"SQLite home: {result.SqliteHome} (source: {result.SqliteHomeSource})", $"Backup: {result.BackupDir}", $"Updated rollout files: {result.ChangedSessionFiles}", $"Updated SQLite rows: {result.SqliteRowsUpdated}{(result.SqlitePresent ? string.Empty : " (state_5.sqlite not found)")}" @@ -267,6 +271,7 @@ private static string FormatSyncResultChinese(SyncResult result, string label) [ $"{label} Provider: {result.TargetProvider}", $"Codex Home: {result.CodexHome}", + $"SQLite Home: {result.SqliteHome}(来源: {result.SqliteHomeSource})", $"备份目录: {result.BackupDir}", $"已更新 rollout 文件: {result.ChangedSessionFiles}", $"已更新 SQLite 行: {result.SqliteRowsUpdated}{(result.SqlitePresent ? string.Empty : "(未找到 state_5.sqlite)")}" From bb03dbc6f5a7efd99a168b81078df577fa4d24e3 Mon Sep 17 00:00:00 2001 From: Hccake Date: Tue, 28 Jul 2026 10:05:06 +0800 Subject: [PATCH 04/12] feat(gui): configure SQLite home per Codex home --- .../MainFormPresentationTests.cs | 2 + desktop/CodexProviderSync.App/MainForm.cs | 150 ++++++++++++++++-- desktop/CodexProviderSync.Mac/MacUiText.cs | 7 + desktop/CodexProviderSync.Mac/MainWindow.cs | 150 +++++++++++++++++- 4 files changed, 293 insertions(+), 16 deletions(-) diff --git a/desktop/CodexProviderSync.App.Tests/MainFormPresentationTests.cs b/desktop/CodexProviderSync.App.Tests/MainFormPresentationTests.cs index 9027d58..37f9414 100644 --- a/desktop/CodexProviderSync.App.Tests/MainFormPresentationTests.cs +++ b/desktop/CodexProviderSync.App.Tests/MainFormPresentationTests.cs @@ -25,6 +25,8 @@ public void MainForm_UsesChineseChromeAndGreenPrimaryAction() Assert.Equal(FlatStyle.Flat, execute.FlatStyle); Assert.Equal("浏览...", Field