feat: 发布热更 1.5.94,修复中转历史会话并支持一键清空

This commit is contained in:
mac
2026-09-11 09:06:41 +08:00
parent 0446d09b3b
commit a74576c3e1
28 changed files with 3217 additions and 155 deletions
+180 -5
View File
@@ -8,6 +8,9 @@ const { cleanThreadTitle, cleanUserVisibleText, stripInternalThoughtBlocks, look
const codexDataDir = require("./codex_data_dir");
let permanentDeleteBusy = false;
let clearAllBusy = false;
const DEFAULT_LIST_LIMIT = 500;
const ALL_LIST_LIMIT = 5000;
function paths() {
const codexHome = codexDataDir.get();
@@ -95,7 +98,8 @@ function peekMeta(file) {
}
const p = d.payload || {};
if (d.type === "session_meta") {
meta.id = p.session_id || p.id || "";
// 新版 subagent 的 session_id 指向父线程,id 才是当前子线程。
meta.id = p.id || p.session_id || "";
meta.cwd = p.cwd || "";
meta.ts = p.timestamp || d.timestamp || "";
meta.source = p.source || "";
@@ -363,6 +367,171 @@ async function deleteConversation(filePath) {
}
}
// 清空全部 Codex 会话:删除 rollout 正文、状态库线程关联记录、桌面侧栏目录和会话索引。
// 配置、登录态、项目、插件、自动化任务均保留;执行前由调用方保证 Codex 已完全退出。
function deleteAllThreadLinkedRows(SQL, dbPath, { requireThreads = false } = {}) {
if (!fs.existsSync(dbPath)) return { changed: false, rows: 0 };
let db;
try {
db = new SQL.Database(sqliteBufferWithCommittedWal(dbPath));
const tables = sqlJsRows(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
.map((row) => row.name).filter(Boolean);
if (requireThreads && !tables.includes("threads")) return { changed: false, rows: 0 };
db.run("PRAGMA foreign_keys=ON");
db.run("PRAGMA secure_delete=ON");
db.run("BEGIN");
let rows = 0;
const deferred = [];
for (const table of tables) {
// 自动化定义不是会话正文;即使它记录了 target_thread_id,也必须保留。
if (table === "automations") continue;
const columns = sqlJsRows(db, "PRAGMA table_info(" + quoteIdent(table) + ")").map((row) => String(row.name || ""));
const fks = sqlJsRows(db, "PRAGMA foreign_key_list(" + quoteIdent(table) + ")");
const linked = columns.filter((name) => /(^|_)(thread_id|parent_thread_id|child_thread_id)$/i.test(name));
const referencesThreads = fks
.filter((fk) => String(fk.table || "").toLowerCase() === "threads")
.map((fk) => String(fk.from || ""));
const targets = Array.from(new Set(linked.concat(referencesThreads))).filter(Boolean);
if (table === "threads") {
deferred.push(table);
continue;
}
for (const column of targets) {
db.run("DELETE FROM " + quoteIdent(table) + " WHERE " + quoteIdent(column) + " IS NOT NULL");
rows += db.getRowsModified();
}
}
if (tables.includes("threads")) {
db.run("DELETE FROM " + quoteIdent("threads"));
rows += db.getRowsModified();
}
db.run("COMMIT");
if (!rows) return { changed: false, rows: 0 };
db.run("VACUUM");
const fkErrors = sqlJsRows(db, "PRAGMA foreign_key_check");
const integrity = sqlJsRows(db, "PRAGMA integrity_check");
if (fkErrors.length || !integrity.length || integrity.some((row) => String(Object.values(row)[0] || "").toLowerCase() !== "ok")) {
throw new Error("SQLite 清空会话后完整性校验失败,已停止替换");
}
replaceStateDb(dbPath, Buffer.from(db.export()));
return { changed: true, rows };
} catch (e) {
if (db) try { db.run("ROLLBACK"); } catch {}
throw e;
} finally {
if (db) try { db.close(); } catch {}
}
}
function copyIfExists(source, target) {
if (!fs.existsSync(source)) return false;
fs.mkdirSync(path.dirname(target), { recursive: true });
const stat = fs.statSync(source);
if (stat.isDirectory()) {
fs.cpSync(source, target, { recursive: true, force: true });
} else {
fs.copyFileSync(source, target);
}
return true;
}
function pruneEmptyDirs(root) {
if (!fs.existsSync(root)) return;
let entries;
try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
if (entry.isDirectory()) pruneEmptyDirs(path.join(root, entry.name));
}
try {
if (!fs.readdirSync(root).length) fs.rmdirSync(root);
} catch {}
}
async function clearAllConversations() {
if (clearAllBusy) throw new Error("已有清空会话任务正在执行,请稍后重试");
clearAllBusy = true;
let backup = "";
const stagedDirs = [];
const copiedFiles = [];
try {
const { codexHome, sessionsDir, archivedSessionsDir, sessionIndex } = paths();
const rolloutFiles = sessionRoots().flatMap((root) => findRolloutFiles(root));
const timestamp = new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14);
backup = path.join(codexHome, "backups_state", "session-clear", timestamp);
fs.mkdirSync(backup, { recursive: true });
// 会话正文用同盘 rename 暂存,不复制 700+ 个大 JSONL;清空成功后保留备份,便于恢复。
for (const [name, source] of [["sessions", sessionsDir], ["archived_sessions", archivedSessionsDir]]) {
if (!fs.existsSync(source)) continue;
const target = path.join(backup, name);
fs.renameSync(source, target);
stagedDirs.push({ source, target });
fs.mkdirSync(source, { recursive: true });
}
if (fs.existsSync(sessionIndex)) {
copyIfExists(sessionIndex, path.join(backup, "session_index.jsonl"));
copiedFiles.push([sessionIndex, path.join(backup, "session_index.jsonl")]);
fs.writeFileSync(sessionIndex, "", "utf-8");
}
for (const name of fs.readdirSync(codexHome)) {
if (!/^state_.*\.sqlite(?:-(?:wal|shm))?$/.test(name)) continue;
const source = path.join(codexHome, name);
const target = path.join(backup, name);
copyIfExists(source, target);
copiedFiles.push([source, target]);
}
const catalogDb = path.join(codexHome, "sqlite", "codex-dev.db");
for (const suffix of ["", "-wal", "-shm"]) {
const source = catalogDb + suffix;
if (!fs.existsSync(source)) continue;
const target = path.join(backup, "codex-dev.db" + suffix);
copyIfExists(source, target);
copiedFiles.push([source, target]);
}
const indexLines = copiedFiles.some(([source]) => source === sessionIndex)
? fs.readFileSync(path.join(backup, "session_index.jsonl"), "utf-8").split("\n").filter((line) => line.trim()).length
: 0;
const SQL = await initSqlJs({
locateFile: (file) => path.join(global.__BUNDLE_ROOT__ || path.join(__dirname, "..", ".."), "node_modules", "sql.js", "dist", file),
});
let stateRowsRemoved = 0;
for (const name of fs.readdirSync(codexHome).filter((item) => /^state_.*\.sqlite$/.test(item))) {
const result = deleteAllThreadLinkedRows(SQL, path.join(codexHome, name), { requireThreads: true });
stateRowsRemoved += result.rows;
}
const catalog = deleteAllThreadLinkedRows(SQL, catalogDb);
return {
ok: true,
filesRemoved: rolloutFiles.length,
indexEntriesRemoved: indexLines,
stateRowsRemoved,
catalogRowsRemoved: catalog.rows,
backup,
};
} catch (error) {
// 任一 SQLite/文件步骤失败,恢复正文目录、索引和数据库备份,避免半清空状态。
for (const [source, target] of copiedFiles) {
try {
if (fs.existsSync(target)) {
fs.mkdirSync(path.dirname(source), { recursive: true });
fs.copyFileSync(target, source);
}
} catch {}
}
for (const { source, target } of stagedDirs.reverse()) {
try {
fs.rmSync(source, { recursive: true, force: true });
if (fs.existsSync(target)) fs.renameSync(target, source);
} catch {}
}
throw error;
} finally {
clearAllBusy = false;
}
}
function findConversationById(id) {
const target = String(id || "").trim();
if (!target || target.length > 160) return null;
@@ -469,16 +638,21 @@ function repairSkillMentionTitles(log = () => {}) {
}
function normalizeListOptions(opts) {
if (typeof opts === "boolean") return { includeSubagent: opts, includeArchived: false };
if (typeof opts === "boolean") return { includeSubagent: opts, includeArchived: false, limit: DEFAULT_LIST_LIMIT };
const requestedLimit = Number(opts && opts.limit);
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0
? Math.min(Math.floor(requestedLimit), ALL_LIST_LIMIT)
: DEFAULT_LIST_LIMIT;
return {
includeSubagent: !!(opts && opts.includeSubagent),
includeArchived: !!(opts && opts.includeArchived),
limit,
};
}
// 列出会话(默认只列当前 sessions,并排除子 agent 派生线程;全量模式会带 archived_sessions)
function listConversations(opts = {}) {
const { includeSubagent, includeArchived } = normalizeListOptions(opts);
const { includeSubagent, includeArchived, limit } = normalizeListOptions(opts);
const { sessionsDir, archivedSessionsDir } = paths();
const roots = [{ dir: sessionsDir, archived: false }];
if (includeArchived) roots.push({ dir: archivedSessionsDir, archived: true });
@@ -510,7 +684,7 @@ function listConversations(opts = {}) {
}
}
items.sort((a, b) => (b.ts || "").localeCompare(a.ts || ""));
return items;
return items.slice(0, limit);
}
// 解析单个会话文件为可读消息列表
@@ -534,7 +708,7 @@ function readConversation(file) {
const p = d.payload || {};
const ts = d.timestamp || "";
if (d.type === "session_meta") {
meta = { id: p.session_id || p.id || "", cwd: p.cwd || "", ts: p.timestamp || ts, provider: p.model_provider || "" };
meta = { id: p.id || p.session_id || "", cwd: p.cwd || "", ts: p.timestamp || ts, provider: p.model_provider || "" };
} else if (d.type === "event_msg" && p.type === "user_message") {
messages.push({ role: "user", text: cleanUserVisibleText(String(p.message || "")), ts });
} else if (d.type === "event_msg" && p.type === "agent_message") {
@@ -677,6 +851,7 @@ module.exports = {
backupAll,
importSessions,
deleteConversation,
clearAllConversations,
findConversationById,
repairSkillMentionTitles,
};