From 7475198093ea612b7b162856d4045527eda84e6a Mon Sep 17 00:00:00 2001 From: mayii2001 Date: Thu, 16 Apr 2026 19:54:47 +0800 Subject: [PATCH] feat: add session delete command with backup and sqlite cleanup --- AGENTS.md | 6 + README.md | 15 +++ README_EN.md | 15 +++ README_ZH.md | 15 +++ src/backup.js | 61 +++++++++- src/cli.js | 108 ++++++++++++++++++ src/service.js | 257 +++++++++++++++++++++++++++++++++++++++++++ src/session-files.js | 89 +++++++++++++++ src/sqlite-state.js | 146 ++++++++++++++++++++++++ 9 files changed, 708 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 97f57cb..b96feab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,11 @@ Use `codex-provider restore ` when: - the user wants to roll back a previous sync - the user synced to the wrong provider +Use `codex-provider delete [...]` when: + +- the user wants to permanently remove specific sessions by id +- the user expects rollout files and SQLite thread rows to be removed together + Use `codex-provider status` only when: - the user asks for inspection only @@ -126,6 +131,7 @@ codex-provider sync codex-provider sync --keep 5 codex-provider sync --provider openai codex-provider switch apigather +codex-provider delete 019d95ee-abd6-7de3-b1f8-63b13c725f17 codex-provider prune-backups --keep 5 codex-provider restore C:\Users\you\.codex\backups_state\provider-sync\20260319T042708906Z ``` diff --git a/README.md b/README.md index c262cb8..b4bfda1 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,13 @@ codex-provider restore C:\Users\you\.codex\backups_state\provider-sync\ +codex-provider delete +``` + ## AI 一键处理 如果你想直接交给 AI 助手处理,把下面这段原样发给 AI: @@ -144,6 +151,7 @@ codex-provider prune-backups --keep 5 - 只想查看状态:`codex-provider status` - 当前 provider 不变,只修复历史会话可见性:`codex-provider sync` - 切 provider 并同步历史:`codex-provider switch openai` +- 删除指定会话:`codex-provider delete ` - 安装桌面双击启动器:`codex-provider install-windows-launcher` - 回滚误操作:`codex-provider restore ` @@ -159,6 +167,11 @@ codex-provider prune-backups --keep 5 - 修改 `config.toml` 根级 `model_provider` - 然后立即执行一次同步 - `--keep ` 可覆盖这次执行后的备份保留数量 +- `codex-provider delete [more-session-ids...]` + - 按 session id 删除会话 + - 会同时删除 rollout 文件中的目标会话文件,以及 SQLite `threads` 对应行 + - 如果 rollout 文件被占用,会跳过对应会话并在结果里提示 + - 删除前会自动创建备份,可用 `restore` 回滚 - `codex-provider prune-backups` - 手动清理旧备份,只保留最近 `n` 份由本工具管理的备份 - `codex-provider restore ` @@ -178,6 +191,8 @@ codex-provider sync codex-provider sync --keep 5 codex-provider sync --provider openai codex-provider switch apigather +codex-provider delete 019d95ee-abd6-7de3-b1f8-63b13c725f17 +codex-provider delete 019d95ee-abd6-7de3-b1f8-63b13c725f17 019d95ef-c7fd-74b0-956c-6110fd8ff314 codex-provider prune-backups --keep 5 codex-provider install-windows-launcher codex-provider install-windows-launcher --dir D:\Tools diff --git a/README_EN.md b/README_EN.md index 34c8142..8dcf90a 100644 --- a/README_EN.md +++ b/README_EN.md @@ -112,6 +112,13 @@ Clean old managed backups manually: codex-provider prune-backups --keep 5 ``` +Delete one or more sessions by id (with backup): + +```bash +codex-provider delete +codex-provider delete +``` + ## AI Quick Run If you want an AI assistant to handle this in one shot, copy this prompt: @@ -143,6 +150,7 @@ Quick mapping: - inspect only: `codex-provider status` - fix visibility under current provider: `codex-provider sync` - switch provider and sync: `codex-provider switch openai` +- delete a specific session: `codex-provider delete ` - install a desktop double-click launcher: `codex-provider install-windows-launcher` - roll back a mistake: `codex-provider restore ` @@ -158,6 +166,11 @@ Quick mapping: - updates root `model_provider` in `config.toml` - immediately runs a sync - `--keep ` overrides how many managed backups are retained after the run +- `codex-provider delete [more-session-ids...]` + - deletes sessions by id + - removes matching rollout session files and matching rows in SQLite `threads` + - skips sessions whose rollout files are currently locked, and reports them + - creates a backup before deletion so `restore` can roll back - `codex-provider prune-backups` - manually removes older managed backups and keeps the newest `n` - `codex-provider restore ` @@ -176,6 +189,8 @@ codex-provider sync --keep 5 codex-provider sync --provider openai codex-provider switch openai codex-provider switch apigather +codex-provider delete 019d95ee-abd6-7de3-b1f8-63b13c725f17 +codex-provider delete 019d95ee-abd6-7de3-b1f8-63b13c725f17 019d95ef-c7fd-74b0-956c-6110fd8ff314 codex-provider prune-backups --keep 5 codex-provider install-windows-launcher codex-provider install-windows-launcher --dir D:\Tools diff --git a/README_ZH.md b/README_ZH.md index c262cb8..b4bfda1 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -113,6 +113,13 @@ codex-provider restore C:\Users\you\.codex\backups_state\provider-sync\ +codex-provider delete +``` + ## AI 一键处理 如果你想直接交给 AI 助手处理,把下面这段原样发给 AI: @@ -144,6 +151,7 @@ codex-provider prune-backups --keep 5 - 只想查看状态:`codex-provider status` - 当前 provider 不变,只修复历史会话可见性:`codex-provider sync` - 切 provider 并同步历史:`codex-provider switch openai` +- 删除指定会话:`codex-provider delete ` - 安装桌面双击启动器:`codex-provider install-windows-launcher` - 回滚误操作:`codex-provider restore ` @@ -159,6 +167,11 @@ codex-provider prune-backups --keep 5 - 修改 `config.toml` 根级 `model_provider` - 然后立即执行一次同步 - `--keep ` 可覆盖这次执行后的备份保留数量 +- `codex-provider delete [more-session-ids...]` + - 按 session id 删除会话 + - 会同时删除 rollout 文件中的目标会话文件,以及 SQLite `threads` 对应行 + - 如果 rollout 文件被占用,会跳过对应会话并在结果里提示 + - 删除前会自动创建备份,可用 `restore` 回滚 - `codex-provider prune-backups` - 手动清理旧备份,只保留最近 `n` 份由本工具管理的备份 - `codex-provider restore ` @@ -178,6 +191,8 @@ codex-provider sync codex-provider sync --keep 5 codex-provider sync --provider openai codex-provider switch apigather +codex-provider delete 019d95ee-abd6-7de3-b1f8-63b13c725f17 +codex-provider delete 019d95ee-abd6-7de3-b1f8-63b13c725f17 019d95ef-c7fd-74b0-956c-6110fd8ff314 codex-provider prune-backups --keep 5 codex-provider install-windows-launcher codex-provider install-windows-launcher --dir D:\Tools diff --git a/src/backup.js b/src/backup.js index 7f37af5..689784f 100644 --- a/src/backup.js +++ b/src/backup.js @@ -32,6 +32,7 @@ export async function createBackup({ codexHome, targetProvider, sessionChanges, + deletedSessionFiles = [], configPath, configBackupText }) { @@ -55,6 +56,32 @@ export async function createBackup({ await copyIfPresent(configPath, path.join(backupDir, "config.toml")); } + const deletedFilesDir = path.join(backupDir, "deleted-session-files"); + await fs.mkdir(deletedFilesDir, { recursive: true }); + const deletedFilesManifest = []; + let deletedFileCounter = 0; + for (const rawFilePath of deletedSessionFiles ?? []) { + const absolutePath = path.resolve(rawFilePath); + const relativePath = path.relative(codexHome, absolutePath); + if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + continue; + } + + const backupFileName = `${String(deletedFileCounter).padStart(4, "0")}${path.extname(absolutePath) || ".jsonl"}`; + const backupRelativePath = path.join("deleted-session-files", backupFileName); + const copied = await copyIfPresent(absolutePath, path.join(backupDir, backupRelativePath)); + if (!copied) { + continue; + } + + deletedFilesManifest.push({ + path: absolutePath, + relativePath, + backupRelativePath + }); + deletedFileCounter += 1; + } + const sessionManifest = { version: 1, namespace: BACKUP_NAMESPACE, @@ -65,7 +92,8 @@ export async function createBackup({ path: change.path, originalFirstLine: change.originalFirstLine, originalSeparator: change.originalSeparator - })) + })), + deletedFiles: deletedFilesManifest }; await fs.writeFile( path.join(backupDir, "session-meta-backup.json"), @@ -83,7 +111,8 @@ export async function createBackup({ targetProvider, createdAt: sessionManifest.createdAt, dbFiles: copiedDbFiles, - changedSessionFiles: sessionChanges.length + changedSessionFiles: sessionChanges.length, + deletedSessionFiles: deletedFilesManifest.length }, null, 2 @@ -151,7 +180,8 @@ export async function restoreBackup(backupDir, codexHome, options = {}) { const { restoreConfig = true, restoreDatabase = true, - restoreSessions = true + restoreSessions = true, + restoreDeletedSessionFiles = true } = options; const metadataPath = path.join(backupDir, "metadata.json"); const metadata = JSON.parse(await fs.readFile(metadataPath, "utf8")); @@ -159,10 +189,14 @@ export async function restoreBackup(backupDir, codexHome, options = {}) { throw new Error(`Backup was created for ${metadata.codexHome}, not ${codexHome}.`); } + const needsSessionManifest = restoreSessions || restoreDeletedSessionFiles; let sessionManifest = null; - if (restoreSessions) { + if (needsSessionManifest) { const sessionManifestPath = path.join(backupDir, "session-meta-backup.json"); sessionManifest = JSON.parse(await fs.readFile(sessionManifestPath, "utf8")); + } + + if (restoreSessions) { await assertSessionFilesWritable(sessionManifest.files ?? []); } @@ -191,9 +225,28 @@ export async function restoreBackup(backupDir, codexHome, options = {}) { await restoreSessionChanges(sessionManifest.files ?? []); } + if (restoreDeletedSessionFiles) { + await restoreDeletedSessionFilesFromBackup(backupDir, codexHome, sessionManifest?.deletedFiles ?? []); + } + return metadata; } +async function restoreDeletedSessionFilesFromBackup(backupDir, codexHome, deletedFiles) { + for (const entry of deletedFiles ?? []) { + const relativePath = String(entry?.relativePath ?? "").trim(); + const backupRelativePath = String(entry?.backupRelativePath ?? "").trim(); + if (!relativePath || !backupRelativePath) { + continue; + } + + const sourcePath = path.join(backupDir, backupRelativePath); + const destinationPath = path.join(codexHome, relativePath); + await fs.mkdir(path.dirname(destinationPath), { recursive: true }); + await copyIfPresent(sourcePath, destinationPath); + } +} + async function listManagedBackupDirectories(backupRoot) { let entries; try { diff --git a/src/cli.js b/src/cli.js index aadcd04..aae4487 100644 --- a/src/cli.js +++ b/src/cli.js @@ -6,6 +6,7 @@ import { DEFAULT_BACKUP_RETENTION_COUNT } from "./constants.js"; import { installWindowsLauncher } from "./launcher.js"; import { getStatus, + runDeleteSessions, renderStatus, runPruneBackups, runRestore, @@ -20,6 +21,7 @@ Usage: codex-provider status [--codex-home PATH] codex-provider sync [--provider ID] [--keep N] [--codex-home PATH] codex-provider switch [--keep N] [--codex-home PATH] + codex-provider delete [more-session-ids...] [--keep N] [--codex-home PATH] codex-provider prune-backups [--keep N] [--codex-home PATH] codex-provider restore [--codex-home PATH] codex-provider install-windows-launcher [--dir PATH] [--codex-home PATH] @@ -89,6 +91,52 @@ function summarizePrune(result) { ].join("\n"); } +function summarizeDelete(result) { + const lines = [ + `Deleted sessions under provider: ${result.currentProvider}`, + `Codex home: ${result.codexHome}`, + `Backup: ${result.backupDir ?? "(not created, no deletable targets found)"}`, + `Backup creation time: ${result.backupDir ? formatDuration(result.backupDurationMs ?? 0) : "0 ms"}`, + `Requested session ids: ${result.requestedSessionIds.length}`, + `Deleted session ids: ${result.deletedSessionIds.length}`, + `Deleted rollout files: ${result.deletedRolloutFiles}`, + `Deleted SQLite rows: ${result.sqliteRowsDeleted}${result.sqlitePresent ? "" : " (state_5.sqlite not found)"}` + ]; + if (result.missingSessionIds?.length) { + const preview = result.missingSessionIds.slice(0, 5).join(", "); + const extraCount = result.missingSessionIds.length - Math.min(result.missingSessionIds.length, 5); + lines.push(`Missing session ids: ${result.missingSessionIds.length}`); + lines.push(`Missing id(s): ${preview}${extraCount > 0 ? ` (+${extraCount} more)` : ""}`); + } + if (result.skippedLockedSessionIds?.length) { + const preview = result.skippedLockedSessionIds.slice(0, 5).join(", "); + const extraCount = result.skippedLockedSessionIds.length - Math.min(result.skippedLockedSessionIds.length, 5); + lines.push(`Skipped locked session ids: ${result.skippedLockedSessionIds.length}`); + lines.push(`Locked id(s): ${preview}${extraCount > 0 ? ` (+${extraCount} more)` : ""}`); + } + if (result.skippedRolloutSessionIds?.length) { + const preview = result.skippedRolloutSessionIds.slice(0, 5).join(", "); + const extraCount = result.skippedRolloutSessionIds.length - Math.min(result.skippedRolloutSessionIds.length, 5); + lines.push(`Skipped session ids due to rollout delete failure: ${result.skippedRolloutSessionIds.length}`); + lines.push(`Skipped id(s): ${preview}${extraCount > 0 ? ` (+${extraCount} more)` : ""}`); + } + if (result.skippedRolloutFiles?.length) { + const preview = result.skippedRolloutFiles.slice(0, 5).join(", "); + const extraCount = result.skippedRolloutFiles.length - Math.min(result.skippedRolloutFiles.length, 5); + lines.push(`Skipped locked rollout files: ${result.skippedRolloutFiles.length}`); + lines.push(`Locked file(s): ${preview}${extraCount > 0 ? ` (+${extraCount} more)` : ""}`); + } + if (result.autoPruneResult) { + lines.push( + `Backup cleanup: deleted ${result.autoPruneResult.deletedCount}, remaining ${result.autoPruneResult.remainingCount}, freed ${formatBytes(result.autoPruneResult.freedBytes)}` + ); + } + if (result.autoPruneWarning) { + lines.push(`Backup cleanup warning: ${result.autoPruneWarning}`); + } + return lines.join("\n"); +} + function formatBytes(bytes) { const units = ["B", "KB", "MB", "GB", "TB"]; let value = bytes; @@ -128,6 +176,19 @@ const SYNC_PROGRESS_STAGE_INDEX = new Map( SYNC_PROGRESS_STAGES.map(([stage], index) => [stage, index + 1]) ); +const DELETE_PROGRESS_STAGES = [ + ["scan_rollout_files", "Scanning rollout files..."], + ["check_locked_rollout_files", "Checking locked rollout files..."], + ["create_backup", "Creating backup..."], + ["rewrite_rollout_files", "Deleting rollout files..."], + ["update_sqlite", "Deleting SQLite rows..."], + ["clean_backups", "Cleaning backups..."] +]; + +const DELETE_PROGRESS_STAGE_INDEX = new Map( + DELETE_PROGRESS_STAGES.map(([stage], index) => [stage, index + 1]) +); + function createSyncProgressReporter() { return (event) => { if (event?.stage === "update_config" && event.status === "start") { @@ -147,6 +208,20 @@ function createSyncProgressReporter() { }; } +function createDeleteProgressReporter() { + return (event) => { + const stageIndex = DELETE_PROGRESS_STAGE_INDEX.get(event?.stage); + if (!stageIndex || event.status !== "start") { + if (event?.stage === "create_backup" && event.status === "complete") { + console.log(` Backup created in ${formatDuration(event.durationMs)}: ${event.backupDir}`); + } + return; + } + + console.log(`[${stageIndex}/${DELETE_PROGRESS_STAGES.length}] ${DELETE_PROGRESS_STAGES[stageIndex - 1][1]}`); + }; +} + function parseKeepCount(rawValue, { allowZero = false } = {}) { if (rawValue === undefined) { return DEFAULT_BACKUP_RETENTION_COUNT; @@ -164,6 +239,27 @@ function parseKeepCount(rawValue, { allowZero = false } = {}) { return keepCount; } +function parseSessionIds(rawPositionalIds, rawFlagValue) { + const values = [ + ...(rawPositionalIds ?? []), + ...(rawFlagValue === undefined ? [] : [rawFlagValue]) + ]; + const normalized = []; + const seen = new Set(); + for (const value of values) { + const tokens = String(value ?? "").split(","); + for (const token of tokens) { + const sessionId = token.trim(); + if (!sessionId || seen.has(sessionId)) { + continue; + } + seen.add(sessionId); + normalized.push(sessionId); + } + } + return normalized; +} + async function main() { const { positionals, flags } = parseArgs(process.argv.slice(2)); const command = positionals[0]; @@ -202,6 +298,18 @@ async function main() { return; } + if (command === "delete") { + const sessionIds = parseSessionIds(positionals.slice(1), flags.id); + const result = await runDeleteSessions({ + codexHome: flags["codex-home"], + sessionIds, + keepCount: parseKeepCount(flags.keep), + onProgress: createDeleteProgressReporter() + }); + console.log(summarizeDelete(result)); + return; + } + if (command === "prune-backups") { const result = await runPruneBackups({ codexHome: flags["codex-home"], diff --git a/src/service.js b/src/service.js index 1251822..bd0f5c9 100644 --- a/src/service.js +++ b/src/service.js @@ -25,13 +25,17 @@ import { import { acquireLock } from "./locking.js"; import { applySessionChanges, + collectSessionFilesByThreadIds, collectSessionChanges, + deleteSessionFiles, restoreSessionChanges, splitLockedSessionChanges, summarizeProviderCounts } from "./session-files.js"; import { assertSqliteWritable, + deleteSqliteThreadsByIds, + listSqliteThreadsByIds, readSqliteProviderCounts, updateSqliteProvider } from "./sqlite-state.js"; @@ -67,6 +71,23 @@ function emitProgress(onProgress, event) { } } +function normalizeSessionIds(sessionIds) { + const normalized = []; + const seen = new Set(); + for (const rawValue of sessionIds ?? []) { + const parts = String(rawValue ?? "").split(","); + for (const part of parts) { + const id = part.trim(); + if (!id || seen.has(id)) { + continue; + } + seen.add(id); + normalized.push(id); + } + } + return normalized; +} + export async function getStatus({ codexHome: explicitCodexHome } = {}) { const codexHome = normalizeCodexHome(explicitCodexHome); await ensureCodexHome(codexHome); @@ -284,6 +305,242 @@ export async function runSync({ } } +export async function runDeleteSessions({ + codexHome: explicitCodexHome, + sessionIds, + keepCount = DEFAULT_BACKUP_RETENTION_COUNT, + onProgress +} = {}) { + if (!Number.isInteger(keepCount) || keepCount < 1) { + throw new Error(`Invalid automatic keep count: ${keepCount}. Expected an integer greater than or equal to 1.`); + } + + const requestedSessionIds = normalizeSessionIds(sessionIds); + if (requestedSessionIds.length === 0) { + throw new Error("Missing session id. Usage: codex-provider delete [more-session-ids...]"); + } + + const codexHome = normalizeCodexHome(explicitCodexHome); + await ensureCodexHome(codexHome); + const configPath = path.join(codexHome, "config.toml"); + const configText = await readConfigText(configPath); + const current = readCurrentProviderFromConfigText(configText); + + const releaseLock = await acquireLock(codexHome, "delete-sessions"); + let backupDir = null; + let backupDurationMs = 0; + try { + emitProgress(onProgress, { stage: "scan_rollout_files", status: "start" }); + const [fileScan, sqliteThreadResult] = await Promise.all([ + collectSessionFilesByThreadIds(codexHome, requestedSessionIds, { skipLockedReads: true }), + listSqliteThreadsByIds(codexHome, requestedSessionIds) + ]); + emitProgress(onProgress, { + stage: "scan_rollout_files", + status: "complete", + matchedRolloutFiles: fileScan.matches.length, + sqliteMatches: sqliteThreadResult.threads.length, + lockedReadCount: fileScan.lockedPaths.length + }); + + const sqliteThreadsById = new Map(sqliteThreadResult.threads.map((row) => [row.id, row])); + const fileMatchesById = new Map(); + for (const match of fileScan.matches) { + if (!fileMatchesById.has(match.threadId)) { + fileMatchesById.set(match.threadId, []); + } + fileMatchesById.get(match.threadId).push(match); + } + + emitProgress(onProgress, { stage: "check_locked_rollout_files", status: "start" }); + const splitInput = fileScan.matches.map((match) => ({ + path: match.path, + threadId: match.threadId + })); + const { writableChanges, lockedChanges } = await splitLockedSessionChanges(splitInput); + const lockedPathSet = new Set([ + ...fileScan.lockedPaths, + ...lockedChanges.map((change) => change.path) + ]); + const lockedThreadIdSet = new Set(lockedChanges.map((change) => change.threadId)); + const sqlitePathToId = new Map( + sqliteThreadResult.threads + .filter((row) => row.rollout_path) + .map((row) => [path.resolve(row.rollout_path), row.id]) + ); + for (const lockedPath of fileScan.lockedPaths) { + const matchedThreadId = sqlitePathToId.get(path.resolve(lockedPath)); + if (matchedThreadId) { + lockedThreadIdSet.add(matchedThreadId); + } + } + emitProgress(onProgress, { + stage: "check_locked_rollout_files", + status: "complete", + writableCount: writableChanges.length, + lockedCount: lockedPathSet.size + }); + + const eligibleSessionIds = requestedSessionIds.filter((sessionId) => !lockedThreadIdSet.has(sessionId)); + const eligiblePaths = writableChanges.map((change) => change.path); + const eligiblePathSet = new Set(eligiblePaths); + const requestedSessionIdSet = new Set(requestedSessionIds); + const fileMatchedIdSet = new Set(fileScan.matches.map((match) => match.threadId)); + const sqliteMatchedIdSet = new Set(sqliteThreadResult.threads.map((row) => row.id)); + const touchedIdSet = new Set([...fileMatchedIdSet, ...sqliteMatchedIdSet]); + const missingSessionIds = requestedSessionIds.filter((sessionId) => !touchedIdSet.has(sessionId)); + const skippedLockedSessionIds = requestedSessionIds.filter((sessionId) => lockedThreadIdSet.has(sessionId)); + const deletableSqliteIds = eligibleSessionIds.filter((sessionId) => sqliteMatchedIdSet.has(sessionId)); + + if (deletableSqliteIds.length === 0 && eligiblePaths.length === 0) { + return { + codexHome, + currentProvider: current.provider, + backupDir: null, + backupDurationMs: 0, + requestedSessionIds, + deletedSessionIds: [], + missingSessionIds, + skippedLockedSessionIds, + skippedRolloutSessionIds: [], + deletedRolloutFiles: 0, + skippedRolloutFiles: [...new Set(fileScan.lockedPaths)].sort((left, right) => left.localeCompare(right)), + sqliteRowsDeleted: 0, + sqlitePresent: sqliteThreadResult.databasePresent, + autoPruneResult: null, + autoPruneWarning: null + }; + } + + emitProgress(onProgress, { + stage: "create_backup", + status: "start", + eligibleSessionCount: eligibleSessionIds.length, + eligibleRolloutFileCount: eligiblePaths.length + }); + const backupStartedAt = Date.now(); + backupDir = await createBackup({ + codexHome, + targetProvider: current.provider, + sessionChanges: [], + deletedSessionFiles: eligiblePaths, + configPath, + configBackupText: configText + }); + backupDurationMs = Date.now() - backupStartedAt; + emitProgress(onProgress, { + stage: "create_backup", + status: "complete", + backupDir, + durationMs: backupDurationMs + }); + + emitProgress(onProgress, { + stage: "rewrite_rollout_files", + status: "start", + eligibleRolloutFileCount: eligiblePaths.length + }); + const deletedFileResult = await deleteSessionFiles(eligiblePaths); + const deletedPathSet = new Set(deletedFileResult.deletedPaths); + const skippedPathSet = new Set(deletedFileResult.skippedPaths); + emitProgress(onProgress, { + stage: "rewrite_rollout_files", + status: "complete", + deletedRolloutFiles: deletedFileResult.deletedPaths.length, + skippedRolloutFiles: deletedFileResult.skippedPaths.length + }); + + const skippedAfterDeleteIds = new Set(); + for (const [threadId, matches] of fileMatchesById.entries()) { + if (lockedThreadIdSet.has(threadId)) { + continue; + } + for (const match of matches) { + if (!eligiblePathSet.has(match.path)) { + continue; + } + if (skippedPathSet.has(match.path)) { + skippedAfterDeleteIds.add(threadId); + } + } + } + + const sqliteDeleteIds = deletableSqliteIds.filter((sessionId) => !skippedAfterDeleteIds.has(sessionId)); + emitProgress(onProgress, { + stage: "update_sqlite", + status: "start", + eligibleSessionCount: sqliteDeleteIds.length + }); + const sqliteDeleteResult = await deleteSqliteThreadsByIds(codexHome, sqliteDeleteIds); + emitProgress(onProgress, { + stage: "update_sqlite", + status: "complete", + deletedRows: sqliteDeleteResult.deletedThreads + }); + + let autoPruneResult = null; + let autoPruneWarning = null; + emitProgress(onProgress, { + stage: "clean_backups", + status: "start", + keepCount + }); + try { + autoPruneResult = await pruneBackups(codexHome, keepCount); + } catch (pruneError) { + autoPruneWarning = `Automatic backup cleanup failed: ${pruneError instanceof Error ? pruneError.message : String(pruneError)}`; + } + emitProgress(onProgress, { + stage: "clean_backups", + status: "complete", + deletedCount: autoPruneResult?.deletedCount ?? 0, + warning: autoPruneWarning + }); + + const skippedRolloutSessionIds = requestedSessionIds.filter((sessionId) => skippedAfterDeleteIds.has(sessionId)); + const deletedSessionIdSet = new Set(sqliteDeleteResult.deletedThreadIds); + for (const threadId of fileMatchedIdSet) { + if (!requestedSessionIdSet.has(threadId)) { + continue; + } + if (lockedThreadIdSet.has(threadId) || skippedAfterDeleteIds.has(threadId)) { + continue; + } + if (deletedSessionIdSet.has(threadId)) { + continue; + } + const matches = fileMatchesById.get(threadId) ?? []; + const hasRemaining = matches.some((match) => !deletedPathSet.has(match.path)); + if (!hasRemaining && matches.length > 0) { + deletedSessionIdSet.add(threadId); + } + } + + return { + codexHome, + currentProvider: current.provider, + backupDir, + backupDurationMs, + requestedSessionIds, + deletedSessionIds: [...deletedSessionIdSet].sort((left, right) => left.localeCompare(right)), + missingSessionIds, + skippedLockedSessionIds, + skippedRolloutSessionIds, + deletedRolloutFiles: deletedFileResult.deletedPaths.length, + skippedRolloutFiles: [...new Set([ + ...fileScan.lockedPaths, + ...deletedFileResult.skippedPaths + ])].sort((left, right) => left.localeCompare(right)), + sqliteRowsDeleted: sqliteDeleteResult.deletedThreads, + sqlitePresent: sqliteDeleteResult.databasePresent, + autoPruneResult, + autoPruneWarning + }; + } finally { + await releaseLock(); + } +} + export async function runSwitch({ codexHome: explicitCodexHome, provider, diff --git a/src/session-files.js b/src/session-files.js index ee7e10a..229b993 100644 --- a/src/session-files.js +++ b/src/session-files.js @@ -511,6 +511,68 @@ export async function collectSessionChanges(codexHome, targetProvider, options = return { changes: summaries, lockedPaths, providerCounts }; } +export async function collectSessionFilesByThreadIds(codexHome, threadIds, options = {}) { + const targetIds = new Set( + (threadIds ?? []) + .map((value) => String(value ?? "").trim()) + .filter(Boolean) + ); + if (targetIds.size === 0) { + return { + matches: [], + lockedPaths: [] + }; + } + + const { skipLockedReads = false } = options; + const matches = []; + const lockedPaths = []; + + for (const dirName of SESSION_DIRS) { + const rootDir = path.join(codexHome, dirName); + try { + await fsp.access(rootDir); + } catch { + continue; + } + + const rolloutPaths = await listJsonlFiles(rootDir); + for (const rolloutPath of rolloutPaths) { + let record; + try { + record = await readFirstLineRecord(rolloutPath); + } catch (error) { + if (skipLockedReads && isRolloutFileBusyError(error)) { + lockedPaths.push(rolloutPath); + continue; + } + throw error; + } + + const parsed = parseSessionMetaRecord(record.firstLine); + if (!parsed) { + continue; + } + + const threadId = String(parsed.payload.id ?? "").trim(); + if (!threadId || !targetIds.has(threadId)) { + continue; + } + + matches.push({ + path: rolloutPath, + threadId, + directory: dirName + }); + } + } + + return { + matches, + lockedPaths + }; +} + export async function applySessionChanges(changes) { const normalizedChanges = changes ?? []; const skippedPaths = []; @@ -624,6 +686,33 @@ export async function restoreSessionChanges(manifestEntries) { } } +export async function deleteSessionFiles(filePaths) { + const normalizedPaths = [...new Set((filePaths ?? []) + .map((value) => String(value ?? "").trim()) + .filter(Boolean))] + .sort((left, right) => left.localeCompare(right)); + + const deletedPaths = []; + const skippedPaths = []; + for (const filePath of normalizedPaths) { + try { + await fsp.rm(filePath, { force: true }); + deletedPaths.push(filePath); + } catch (error) { + if (isRolloutFileBusyError(error)) { + skippedPaths.push(filePath); + continue; + } + throw wrapRolloutFileBusyError(error, filePath, "delete"); + } + } + + return { + deletedPaths, + skippedPaths + }; +} + export function summarizeProviderCounts(providerCounts) { const result = {}; for (const [scope, counts] of Object.entries(providerCounts)) { diff --git a/src/sqlite-state.js b/src/sqlite-state.js index 9456669..04a8a13 100644 --- a/src/sqlite-state.js +++ b/src/sqlite-state.js @@ -5,6 +5,7 @@ import { DatabaseSync } from "node:sqlite"; import { DB_FILE_BASENAME } from "./constants.js"; const DEFAULT_BUSY_TIMEOUT_MS = 5000; +const SQLITE_IN_CLAUSE_CHUNK_SIZE = 400; export function stateDbPath(codexHome) { return path.join(codexHome, DB_FILE_BASENAME); @@ -24,6 +25,32 @@ function setBusyTimeout(db, busyTimeoutMs) { db.exec(`PRAGMA busy_timeout = ${normalizeBusyTimeoutMs(busyTimeoutMs)}`); } +function chunkArray(values, chunkSize = SQLITE_IN_CLAUSE_CHUNK_SIZE) { + const chunks = []; + for (let index = 0; index < values.length; index += chunkSize) { + chunks.push(values.slice(index, index + chunkSize)); + } + return chunks; +} + +function uniqueNonEmptyStrings(values) { + const result = []; + const seen = new Set(); + for (const value of values ?? []) { + const normalized = String(value ?? "").trim(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(normalized); + } + return result; +} + +function makeInClause(chunkLength) { + return new Array(chunkLength).fill("?").join(", "); +} + function isSqliteBusyError(error) { const message = `${error?.code ?? ""} ${error?.message ?? ""}`.toLowerCase(); return message.includes("database is locked") || message.includes("sqlite_busy") || message.includes("busy"); @@ -145,3 +172,122 @@ export async function updateSqliteProvider(codexHome, targetProvider, afterUpdat db.close(); } } + +export async function listSqliteThreadsByIds(codexHome, sessionIds, options = {}) { + const normalizedIds = uniqueNonEmptyStrings(sessionIds); + if (normalizedIds.length === 0) { + return { databasePresent: false, threads: [] }; + } + + const dbPath = stateDbPath(codexHome); + try { + await fs.access(dbPath); + } catch { + return { databasePresent: false, threads: [] }; + } + + const db = openDatabase(dbPath); + try { + setBusyTimeout(db, options.busyTimeoutMs); + const rows = []; + for (const chunk of chunkArray(normalizedIds)) { + const stmt = db.prepare(` + SELECT + id, + rollout_path, + archived, + model_provider + FROM threads + WHERE id IN (${makeInClause(chunk.length)}) + `); + rows.push(...stmt.all(...chunk)); + } + return { + databasePresent: true, + threads: rows + }; + } catch (error) { + throw wrapSqliteBusyError(error, "read session metadata"); + } finally { + db.close(); + } +} + +export async function deleteSqliteThreadsByIds(codexHome, sessionIds, options = {}) { + const normalizedIds = uniqueNonEmptyStrings(sessionIds); + if (normalizedIds.length === 0) { + return { + databasePresent: false, + deletedThreads: 0, + deletedThreadIds: [] + }; + } + + const dbPath = stateDbPath(codexHome); + try { + await fs.access(dbPath); + } catch { + return { + databasePresent: false, + deletedThreads: 0, + deletedThreadIds: [] + }; + } + + const db = openDatabase(dbPath); + let transactionOpen = false; + try { + setBusyTimeout(db, options.busyTimeoutMs); + db.exec("BEGIN IMMEDIATE"); + transactionOpen = true; + + const deletedThreadIds = []; + for (const chunk of chunkArray(normalizedIds)) { + const selectStmt = db.prepare(` + SELECT id + FROM threads + WHERE id IN (${makeInClause(chunk.length)}) + `); + const selectedRows = selectStmt.all(...chunk); + deletedThreadIds.push(...selectedRows.map((row) => row.id)); + } + + if (deletedThreadIds.length > 0) { + for (const chunk of chunkArray(deletedThreadIds)) { + const inClause = makeInClause(chunk.length); + db.prepare(` + DELETE FROM thread_dynamic_tools + WHERE thread_id IN (${inClause}) + `).run(...chunk); + db.prepare(` + DELETE FROM thread_spawn_edges + WHERE child_thread_id IN (${inClause}) + OR parent_thread_id IN (${inClause}) + `).run(...chunk, ...chunk); + db.prepare(` + DELETE FROM threads + WHERE id IN (${inClause}) + `).run(...chunk); + } + } + + db.exec("COMMIT"); + transactionOpen = false; + return { + databasePresent: true, + deletedThreads: deletedThreadIds.length, + deletedThreadIds + }; + } catch (error) { + if (transactionOpen) { + try { + db.exec("ROLLBACK"); + } catch { + // Ignore rollback failures and surface the original error. + } + } + throw wrapSqliteBusyError(error, "delete sessions from SQLite"); + } finally { + db.close(); + } +}