feat: 发布热更 1.5.94,修复中转历史会话并支持一键清空
This commit is contained in:
+180
-5
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user