Files
codex-market-app/electron/lib/provider_sync.js
T

1176 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// provider_sync.js —— 一键把历史会话同步到「当前账号」的 provider,使其在 Codex 侧栏重新显示。
// 复刻 CodexPlusPlus 的 provider-sync:改 rollout 首行 provider + state DB(provider/has_user_event/cwd)。
// 必须在 Codex 已退出时运行。数据库用纯 JS 的 sql.js(免原生编译,跨平台)。
const fs = require("fs");
const path = require("path");
const os = require("os");
const { spawnSync } = require("child_process");
const initSqlJs = require("sql.js");
const { cleanThreadTitle, cleanUserVisibleText } = require("./title_clean");
const codexDesktop = require("./codex_desktop");
let CODEX_HOME = path.join(os.homedir(), ".codex");
let SESSION_INDEX = path.join(CODEX_HOME, "session_index.jsonl");
let GLOBAL_STATE = path.join(CODEX_HOME, ".codex-global-state.json");
// Codex 桌面端侧栏现在优先读这个本地目录库,而不是直接扫 state_*.sqlite。
// 只改 threads / rollout / session_index 而不同步这里,重启后侧栏仍会空。
let LOCAL_CATALOG_DB = path.join(CODEX_HOME, "sqlite", "codex-dev.db");
function setCodexDataDir(dir) {
if (typeof dir !== "string" || !path.isAbsolute(dir)) throw new Error("Codex 数据目录必须是绝对路径");
CODEX_HOME = path.resolve(dir);
SESSION_INDEX = path.join(CODEX_HOME, "session_index.jsonl");
GLOBAL_STATE = path.join(CODEX_HOME, ".codex-global-state.json");
LOCAL_CATALOG_DB = path.join(CODEX_HOME, "sqlite", "codex-dev.db");
}
// 检测 Codex 是否在运行(尽力而为,跨平台)
function codexRunning() {
return codexDesktop.codexRunning();
}
// 目标 provider = config.toml 根级 model_provider,缺省 openai
function targetProvider() {
try {
const head = fs.readFileSync(path.join(CODEX_HOME, "config.toml"), "utf-8").split("\n[")[0];
const m = head.match(/^\s*model_provider\s*=\s*["']([^"']+)/m);
if (m) return m[1];
} catch {}
return "openai";
}
function findRollouts() {
const out = [];
for (const root of [path.join(CODEX_HOME, "sessions"), path.join(CODEX_HOME, "archived_sessions")]) {
const walk = (dir) => {
let ents;
try {
ents = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of ents) {
const full = path.join(dir, e.name);
if (e.isDirectory()) walk(full);
else if (e.isFile() && e.name.startsWith("rollout-") && e.name.endsWith(".jsonl")) out.push(full);
}
};
walk(root);
}
return out;
}
function isArchivedRollout(file) {
return file.startsWith(path.join(CODEX_HOME, "archived_sessions") + path.sep);
}
function loadIndexTitles() {
const map = {};
try {
for (const line of fs.readFileSync(SESSION_INDEX, "utf-8").split("\n")) {
if (!line.trim()) continue;
try {
const d = JSON.parse(line);
if (d.id && d.thread_name) map[d.id] = cleanThreadTitle(d.thread_name, "", 80);
} catch {}
}
} catch {}
return map;
}
function parseTimeMs(tsText, fallbackMs = Date.now()) {
const n = Date.parse(tsText || "");
return Number.isFinite(n) ? n : fallbackMs;
}
function firstText(value) {
if (typeof value === "string") return value;
if (Array.isArray(value)) {
return value
.map((x) => (typeof x === "string" ? x : x && typeof x.text === "string" ? x.text : ""))
.filter(Boolean)
.join("\n");
}
return "";
}
// rollout payload 里的字段不一定是字符串:subagent 会话的 source 就是对象
// {subagent:{thread_spawn:{...}}}。Codex 自身在 state 库里存的是 JSON 字符串;
// 直接把对象绑给 sql.js 会抛 "unknown type" 并让整批 upsert 回滚(Windows 无
// sqlite3 CLI 时走 sql.js 路径,正是这个原因导致恢复后 state 库 0 线程)。
function payloadText(value, fallback) {
if (typeof value === "string") return value;
if (value === null || value === undefined) return fallback;
if (typeof value === "number" || typeof value === "boolean") return String(value);
try {
const text = JSON.stringify(value);
if (typeof text === "string") return text;
} catch {}
return fallback;
}
function sql(v) {
if (v === null || v === undefined) return "NULL";
if (typeof v === "number") return Number.isFinite(v) ? String(v) : "NULL";
if (typeof v !== "string") {
// 对象(如 subagent 的 source)序列化成 JSON 文本,与 Codex 自身存储一致,
// 避免落入 String(v) 产生无意义的 '[object Object]' 脏行。
try { v = JSON.stringify(v); } catch { v = String(v); }
}
return "'" + String(v).replace(/'/g, "''") + "'";
}
const THREAD_COLUMNS = [
"id", "rollout_path", "created_at", "updated_at", "source", "model_provider", "cwd", "title",
"sandbox_policy", "approval_mode", "tokens_used", "has_user_event", "archived", "archived_at",
"git_sha", "git_branch", "git_origin_url", "cli_version", "first_user_message", "memory_mode",
"model", "reasoning_effort", "created_at_ms", "updated_at_ms", "thread_source", "preview",
"recency_at", "recency_at_ms", "history_mode",
];
// Codex 当前 state_5.sqlite 的完整基础 schema。首次安装工具箱且目标目录还没有任何
// state_*.sqlite 时,不能只创建一个 threads 简表:Codex 启动时还会依赖这些索引、触发器
// 和关联表。migration 记录使用当前 Codex 版本的基线,后续版本可继续追加自己的迁移。
const STATE_DB_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS _sqlx_migrations (
version BIGINT PRIMARY KEY,
description TEXT NOT NULL,
installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
success BOOLEAN NOT NULL,
checksum BLOB NOT NULL,
execution_time BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS thread_sections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS backfill_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
status TEXT NOT NULL,
last_watermark TEXT,
last_success_at INTEGER,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS external_agent_config_imports (
import_id TEXT PRIMARY KEY,
completed_at_ms INTEGER NOT NULL,
successes TEXT NOT NULL,
failures TEXT NOT NULL,
provider_id TEXT
);
CREATE TABLE IF NOT EXISTS remote_control_enrollments (
websocket_url TEXT NOT NULL,
account_id TEXT NOT NULL,
app_server_client_name TEXT NOT NULL,
server_id TEXT NOT NULL,
environment_id TEXT NOT NULL,
server_name TEXT NOT NULL,
updated_at INTEGER NOT NULL,
remote_control_enabled INTEGER,
PRIMARY KEY (websocket_url, account_id, app_server_client_name)
);
CREATE TABLE IF NOT EXISTS threads (
id TEXT PRIMARY KEY,
rollout_path TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
source TEXT NOT NULL,
model_provider TEXT NOT NULL,
cwd TEXT NOT NULL,
title TEXT NOT NULL,
sandbox_policy TEXT NOT NULL,
approval_mode TEXT NOT NULL,
tokens_used INTEGER NOT NULL DEFAULT 0,
has_user_event INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
archived_at INTEGER,
git_sha TEXT,
git_branch TEXT,
git_origin_url TEXT,
cli_version TEXT NOT NULL DEFAULT '',
first_user_message TEXT NOT NULL DEFAULT '',
agent_nickname TEXT,
agent_role TEXT,
memory_mode TEXT NOT NULL DEFAULT 'enabled',
model TEXT,
reasoning_effort TEXT,
agent_path TEXT,
created_at_ms INTEGER,
updated_at_ms INTEGER,
thread_source TEXT,
preview TEXT NOT NULL DEFAULT '',
recency_at INTEGER NOT NULL DEFAULT 0,
recency_at_ms INTEGER NOT NULL DEFAULT 0,
history_mode TEXT NOT NULL DEFAULT 'legacy',
name TEXT,
is_pinned INTEGER NOT NULL DEFAULT 0,
thread_section_id TEXT REFERENCES thread_sections(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS thread_dynamic_tools (
thread_id TEXT NOT NULL,
position INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
input_schema TEXT NOT NULL,
defer_loading INTEGER NOT NULL DEFAULT 0,
namespace TEXT,
PRIMARY KEY(thread_id, position),
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS thread_spawn_edges (
parent_thread_id TEXT NOT NULL,
child_thread_id TEXT NOT NULL PRIMARY KEY,
status TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_thread_dynamic_tools_thread ON thread_dynamic_tools(thread_id);
CREATE INDEX IF NOT EXISTS idx_thread_spawn_edges_parent_status ON thread_spawn_edges(parent_thread_id, status);
CREATE INDEX IF NOT EXISTS idx_threads_archived ON threads(archived);
CREATE INDEX IF NOT EXISTS idx_threads_archived_cwd_created_at_ms ON threads(archived, cwd, created_at_ms DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_threads_archived_cwd_recency_at_ms ON threads(archived, cwd, recency_at_ms DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_threads_archived_cwd_updated_at_ms ON threads(archived, cwd, updated_at_ms DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_threads_created_at ON threads(created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_threads_created_at_ms ON threads(created_at_ms DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_threads_pinned_recency_at_ms ON threads(archived, recency_at_ms DESC, id DESC) WHERE is_pinned = 1 AND preview <> '';
CREATE INDEX IF NOT EXISTS idx_threads_provider ON threads(model_provider);
CREATE INDEX IF NOT EXISTS idx_threads_recency_at_ms ON threads(recency_at_ms DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_threads_section_recency_at_ms ON threads(archived, thread_section_id, recency_at_ms DESC, id DESC) WHERE thread_section_id IS NOT NULL AND preview <> '';
CREATE INDEX IF NOT EXISTS idx_threads_source ON threads(source);
CREATE INDEX IF NOT EXISTS idx_threads_updated_at ON threads(updated_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_threads_updated_at_ms ON threads(updated_at_ms DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_threads_visible_created_at_ms ON threads(archived, created_at_ms DESC) WHERE preview <> '';
CREATE INDEX IF NOT EXISTS idx_threads_visible_recency_at_ms ON threads(archived, recency_at_ms DESC, id DESC) WHERE preview <> '';
CREATE INDEX IF NOT EXISTS idx_threads_visible_updated_at_ms ON threads(archived, updated_at_ms DESC) WHERE preview <> '';
CREATE TRIGGER IF NOT EXISTS threads_created_at_ms_after_insert
AFTER INSERT ON threads WHEN NEW.created_at_ms IS NULL
BEGIN UPDATE threads SET created_at_ms = NEW.created_at * 1000 WHERE id = NEW.id; END;
CREATE TRIGGER IF NOT EXISTS threads_created_at_ms_after_update
AFTER UPDATE OF created_at ON threads
WHEN NEW.created_at != OLD.created_at AND NEW.created_at_ms IS OLD.created_at_ms
BEGIN UPDATE threads SET created_at_ms = NEW.created_at * 1000 WHERE id = NEW.id; END;
CREATE TRIGGER IF NOT EXISTS threads_recency_at_after_insert
AFTER INSERT ON threads WHEN NEW.recency_at_ms = 0
BEGIN UPDATE threads SET recency_at = NEW.updated_at, recency_at_ms = COALESCE(NEW.updated_at_ms, NEW.updated_at * 1000) WHERE id = NEW.id; END;
CREATE TRIGGER IF NOT EXISTS threads_updated_at_ms_after_insert
AFTER INSERT ON threads WHEN NEW.updated_at_ms IS NULL
BEGIN UPDATE threads SET updated_at_ms = NEW.updated_at * 1000 WHERE id = NEW.id; END;
CREATE TRIGGER IF NOT EXISTS threads_updated_at_ms_after_update
AFTER UPDATE OF updated_at ON threads
WHEN NEW.updated_at != OLD.updated_at AND NEW.updated_at_ms IS OLD.updated_at_ms
BEGIN UPDATE threads SET updated_at_ms = NEW.updated_at * 1000 WHERE id = NEW.id; END;
`;
const STATE_DB_MIGRATIONS = [
[1, "threads", "627EF19164C9BB298A0CD99945981C9B7BDA3D9E6CF12EB35145E3B1D3BF7CF8740F0DBAA0B475185FC2993397078049"],
[2, "logs", "521B72CBC04C7D03B1E4AEF8DC0FDEE672F1F7F5881385A55A0E937E4EBE87A3F67BCA4D3F6B59AFBC9A7CA723C82856"],
[3, "logs thread id", "E58A2862BDB66D3274E60144A26DC9D68BBEFFF834CD29B82B740F38FCF95862934F412665D985715167839E8F0EB377"],
[4, "thread dynamic tools", "8ADC251366E4C7E5EA78B620AC71F4C4BDFB8F048BF9F2FD7204226CFBA54C8E83FC9D83FC1EC334BC683BB7F6D0D8A8"],
[5, "threads cli version", "1AC64F85E083E5B49B974BF498FC39C630F0032057FB4BF4CB31708619FAC5A80189F261CF3E38DAFD92DC406E73C220"],
[6, "memories", "A203FC4B597E1420BFFDC4E00E214798B21CF7FC921D8BD6F089C75248FB6A4B4942C97A1F8E0271ECA8CF3B3E5E7A80"],
[7, "threads first user message", "7E35A464DF6D4C82694DE50865BA3FE9104F9E1385A33BE7D633AB4B8D6CF60B7F369FE083D31251FD77B75FA2D61F82"],
[8, "backfill state", "AA3F287F7A448554E7AF01F259F0871CE90ACE1A7D4B8EBBD74C87AE57C3E21119F7EEC8B9C2E9F4A9E76E63A0F0B60A"],
[9, "stage1 outputs rollout slug", "8B469360777E6DD6C21D3F0EB93481251846B0A50E0A44EB0DBEE4A0A32B7206768A5D83A5FCEC30C6FD2E43D755FF1B"],
[10, "logs process id", "8ADAF1365F8E5DEEBA68BDEEE9FD83B094A87BDAB364D95AB5C714F7B8BD95DC3B38576871470312154C9D41E8F10F93"],
[11, "logs partition prune indexes", "31ECECBD07F04452D0DC5127D824ED183056B9900A31AA739387A4AEF232016D975110829814841FB6BF3FD23634B799"],
[12, "logs estimated bytes", "0012A213ED25F2CF806C2530C487EBB00B4F102F5960D1A852154A0F77D25C9E9F7DF29550FAEB30EBAFF15A26C7D604"],
[13, "threads agent nickname", "05526A0900A8430523A9A05932EA61302FCE20782B245498565C1B9A2DFD10CF5C49932089B496AE23AF44F6C5F76AF4"],
[14, "agent jobs", "4D2FBB2442C5C5C7C47B4254B5B8DC2C52129F65C4A952A79F1EC5C40FB07483326429CE5ACDBD372BC9262352CB6277"],
[15, "agent jobs max runtime seconds", "0EECE6DB59C948FAA6608526AF440A9F53F5A982D27F3E866CD9F1401C2E59BC61F8381B5A1C9744ACB232F50DB493E5"],
[16, "memory usage", "4768F743E981293735C6E07B0282EC8ACE98AF720059D68555B17979D28C94B0C893A97A950D5E32758BC6D92D8FE6FF"],
[17, "phase2 selection flag", "4CBDB2EDFEBF6040858FFC193CFFA36E6B3FBF508F8418AD073081BBFCF59D53D423B6EB4C0DED206AB4D4C1A0EFED94"],
[18, "phase2 selection snapshot", "A4363003BC44F0A4AE1EED53B536B7175F97863EA6881A059DC6642E70E2921576204697CEC2345F560E65FF7FBF4455"],
[19, "thread dynamic tools defer loading", "3884C02F080F0184E92B620F870DFBB840A8EC5DFCE2FE6455DD8197FCD27D7DD86E63AD6EC7C4091A1161D9ED199F8D"],
[20, "threads model reasoning effort", "07673F4859F740134569AB9606760D8173ED6DF92DA47C86FBD0CEEB333E18AB7AC91D525AB8C11503C42164FD4167AE"],
[21, "thread spawn edges", "EA1AEBE4F5A1D56C8EFFCC15F705C272275DD8ADFEB01BC97D3F7B283474A3B8C2B0C8C69DE55786E8BDA17CAE6F4DE7"],
[22, "threads agent path", "160FE01B757DD06F9573B9030952D021F2B148732BEA5FC5F5AE09E9227F3F83CA676695EDAF8502669FCB546119DC94"],
[23, "drop logs", "E6C748A9A18286E4C773FFE0DAD659F16FB7E5B7B032235815991C405F2D32C068F3EDDE920D0FCF589B687BFFCFEE2E"],
[24, "remote control enrollments", "E92AA526B1F36E26F6221C04A671F07B27DE2E7A5112D93A8C3AF816A87846E7804D5FBAD689E61CF4EC91FD79D3BD8A"],
[25, "thread timestamps millis", "D6FB51D79941B93C3278FE2A8A0C6FA882407B7B716E32D72329F0B7B60C181A42F49E56CD552315EFB0402A111EDE12"],
[26, "thread dynamic tools namespace", "6C62A325D3B9CA8E424EACB9E3890D8D9DE57C3725E2D1FA30C3743AE779FCA2969387174359DFB26E4920791AE7E831"],
[27, "threads cwd sort indexes", "D84DCA6A039E004C0C7D06C5A16C2CCC363137537F3DD9898F50BB013CE9067140761B87D58CA742D769D35976863FC3"],
[28, "device key bindings", "EF44ADAA291E40DFE6B74E3CD259711ADAE13F01C5594B177D08E2D2041A67ED3813FEECF049662AAED0F636BD3F4AC3"],
[29, "thread goals", "E9EC76F97DBF41CE4949DE324A2A26538C2025C0AC4F272B77A852970FB14B7EC970D6978A765F91AE171DCE9C0E4149"],
[30, "threads thread source", "80C1CDACD4520B42B18B960164912685591A7EE041645F35609A2EE2792EB474B7B0B758366556982D8CDE2ACB583672"],
[31, "drop device key bindings", "943675A9B3DC92D0B2731AE7D2FE324FF1A40B6366B9D5D8B4CB47CA885C08BEB6EBB1D776A5A47C878B4094DDE70EA0"],
[32, "threads preview", "3C01D615C5E3ABA0FD858B7168E73881598D7D33A0ECC41527BDF6595A199D1AEC2FF9F3B3000A57241C500D81B34259"],
[33, "thread goal stopped statuses", "4904AAE354E3D8CE32AC21D2AE579FD708446AE1DFB2649FC9DD22DC9279B91A2BBB02E6F01B3E0ABD1088177BF2F030"],
[34, "drop thread goals", "D6FEA739C76EBDEF35A23F06E54EFB5904D7987FB6B663DB1EBC98EB9B192305F454932BFBAA2231AEDCA6209903A5F3"],
[35, "drop memory tables", "D7B56ACB07C96858BDC7CA4C53DB51EB71C6A19EF2C524FF68C2660A6FE0D23211A6D39CB905F01C42FFB7C8EE913BBC"],
[36, "threads visible sort indexes", "6381F7BB4C36BE41C6106B8523EA6C68F5A00273A5522155B7B84BAD26D152436F3375DF7097CE3ABF2421AEC32641ED"],
[37, "remote control enrollments enabled", "F6A5AB0DB36BB9E45AB5285B56090FB453DF0A4D4D8D62EACE234F0B557F4203C6C455FC67BD7D6E72623C3979EC9405"],
[38, "external agent config imports", "E7778522CBE529F5E2BA179B502AEE8E5C21272AD820E672AB0A565F7EDC9BD0F29171CCF9815C0ACC0F89EAE0A2B1FC"],
[39, "threads recency at", "C217B3D14C08C23603F485AF11452EE2B68F65FA8DBAF215F33F53B608C092BE8738DE3F63D6A38F120EDEA5AF247C17"],
[40, "threads history mode", "A7A99674F90A43184E43D66595C4F1DA50AE8715A5BFCB1579A2B7B10668335D7F7AB6B6F2D7A7A379272773D803A914"],
[41, "threads name", "0DC734DBE4CBCC5AD5A8BBC2FDC1CF770DDEA3CB53EB36C55A4DA6FA2E8BE4CC8E68672F43AC953E727E415E45C50A78"],
[42, "drop agent jobs", "CFDC4FD47328D1B67C0C7555125DE4FB4ADD5B3B57FD31AC1987E4D8B0C99B1F5418DB9EE7EC2158AED8A2E3DFCFD636"],
[43, "threads is pinned", "605128E722BD2521C7E979E9961C16706435E442B3E3A8EFA36F6B16C42E3833090906D9A62FDE097A109B78E29DA830"],
[44, "external agent config imports provider id", "5D29223BD1CAFE456A4AF992A545E8EC420B71331EB12A7427E26512B8A44117D9445B83562F095B3A5A0F11C4F091D2"],
[45, "threads section", "A7FADC8CAEA8B6ABEB108F588A91B8E5D1C9E2645CE990F2251B9F0234E6E53D78FC854F1C88C7217CC143016539D691"],
];
function threadColumns(cols) {
return THREAD_COLUMNS.filter((c) => cols.has(c));
}
function threadValue(record, target, column) {
const created = Math.floor(record.created_at_ms / 1000);
const updated = Math.floor(record.updated_at_ms / 1000);
const values = {
id: record.id,
rollout_path: record.rollout_path,
created_at: created,
updated_at: updated,
source: record.source,
model_provider: target,
cwd: record.cwd,
title: record.title,
sandbox_policy: record.sandbox_policy,
approval_mode: record.approval_mode,
tokens_used: record.tokens_used,
has_user_event: record.has_user_event,
archived: record.archived,
archived_at: record.archived ? updated : null,
git_sha: record.git_sha,
git_branch: record.git_branch,
git_origin_url: record.git_origin_url,
cli_version: record.cli_version,
first_user_message: record.first_user_message,
memory_mode: "enabled",
model: record.model,
reasoning_effort: record.reasoning_effort,
created_at_ms: Math.round(record.created_at_ms),
updated_at_ms: Math.round(record.updated_at_ms),
thread_source: record.thread_source,
preview: record.preview,
recency_at: updated,
recency_at_ms: Math.round(record.updated_at_ms),
history_mode: record.history_mode,
};
return values[column];
}
function threadUpdateSet(columns, target, parameterized = false) {
return columns.filter((c) => c !== "id").map((c) => {
if (c === "model_provider") return `${c}=${parameterized ? "?" : sql(target)}`;
if (c === "has_user_event") return `${c}=CASE WHEN excluded.${c}=1 THEN 1 ELSE threads.${c} END`;
if (["cwd", "first_user_message", "preview", "thread_source"].includes(c)) {
return `${c}=CASE WHEN COALESCE(excluded.${c},'')<>'' THEN excluded.${c} ELSE threads.${c} END`;
}
if (c === "title") {
return `${c}=CASE WHEN COALESCE(threads.${c},'')='' OR threads.${c}='(无标题对话)' THEN excluded.${c} ELSE threads.${c} END`;
}
if (["updated_at", "updated_at_ms", "recency_at", "recency_at_ms"].includes(c)) {
return `${c}=MAX(threads.${c}, excluded.${c})`;
}
return `${c}=excluded.${c}`;
});
}
function sqlite3Available() {
const r = spawnSync("sqlite3", ["--version"], { encoding: "utf-8" });
return !r.error && r.status === 0;
}
function runSqlite3(dbPath, input) {
const r = spawnSync("sqlite3", [dbPath], {
input,
encoding: "utf-8",
maxBuffer: 20 * 1024 * 1024,
});
if (r.error || r.status !== 0) {
throw new Error((r.stderr || r.error && r.error.message || "sqlite3 执行失败").trim());
}
return r.stdout || "";
}
function sqliteCols(dbPath) {
const out = runSqlite3(dbPath, "PRAGMA table_info(threads);\n");
return new Set(out.split("\n").map((line) => line.split("|")[1]).filter(Boolean));
}
function readRolloutRecord(file, titles) {
let content;
try {
content = fs.readFileSync(file, "utf-8");
} catch {
return null;
}
const stat = fs.statSync(file);
const rec = {
id: "",
rollout_path: file,
cwd: "",
source: "vscode",
model_provider: "",
thread_source: "user",
title: "",
first_user_message: "",
preview: "",
has_user_event: content.includes('"user_message"') || content.includes('"user_input"') ? 1 : 0,
archived: isArchivedRollout(file) ? 1 : 0,
archived_at: null,
created_at_ms: stat.birthtimeMs || stat.mtimeMs || Date.now(),
updated_at_ms: stat.mtimeMs || Date.now(),
sandbox_policy: JSON.stringify({ type: "disabled" }),
approval_mode: "never",
tokens_used: 0,
cli_version: "",
git_sha: null,
git_branch: null,
git_origin_url: null,
model: null,
reasoning_effort: null,
history_mode: "legacy",
};
for (const line of content.split("\n")) {
if (!line.trim()) continue;
let d;
try {
d = JSON.parse(line);
} catch {
continue;
}
const p = d.payload || {};
if (d.type === "session_meta" && !rec.id) {
rec.id = p.id || p.session_id || rec.id;
rec.cwd = payloadText(p.cwd, rec.cwd);
rec.source = payloadText(p.source, rec.source);
rec.model_provider = payloadText(p.model_provider, rec.model_provider);
rec.thread_source = payloadText(p.thread_source, rec.thread_source);
rec.cli_version = payloadText(p.cli_version, rec.cli_version);
rec.history_mode = payloadText(p.history_mode, rec.history_mode);
rec.created_at_ms = parseTimeMs(p.timestamp || d.timestamp, rec.created_at_ms);
if (p.git && typeof p.git === "object") {
rec.git_sha = p.git.commit_hash || null;
rec.git_branch = p.git.branch || null;
rec.git_origin_url = p.git.repository_url || null;
}
} else if (d.type === "event_msg" && (p.type === "user_message" || p.type === "user_input")) {
rec.has_user_event = 1;
if (!rec.first_user_message) rec.first_user_message = firstText(p.message || p.input).trim();
}
}
rec.updated_at_ms = Math.max(rec.updated_at_ms, rec.created_at_ms);
rec.title = cleanThreadTitle(titles[rec.id] || "", rec.first_user_message, 80) || "(无标题对话)";
rec.preview = cleanUserVisibleText(rec.first_user_message).replace(/\s+/g, " ").trim().slice(0, 200);
return rec.id ? rec : null;
}
function buildThreadUpsertSql(records, target, cols) {
const desired = threadColumns(cols);
const updateSet = threadUpdateSet(desired, target);
const lines = ["BEGIN;"];
for (const r of records) {
lines.push(
`INSERT INTO threads (${desired.join(",")}) VALUES (${desired.map((c) => sql(threadValue(r, target, c))).join(",")}) ` +
`ON CONFLICT(id) DO UPDATE SET ${updateSet.join(",")};`
);
}
// 【增强】确保数据库中所有会话的 provider 都更新为目标(兜底逻辑)
lines.push(`UPDATE threads SET model_provider=${sql(target)} WHERE COALESCE(model_provider,'')<>${sql(target)};`);
lines.push("COMMIT;");
return lines.join("\n") + "\n";
}
function updateProjectState(records, log = () => {}) {
if (!fs.existsSync(GLOBAL_STATE)) return { addedProjects: 0, removedProjectless: 0 };
let state;
try {
state = JSON.parse(fs.readFileSync(GLOBAL_STATE, "utf-8"));
} catch (e) {
log(`全局项目索引跳过:${e.message || e}`);
return { addedProjects: 0, removedProjectless: 0 };
}
const projects = [...new Set(records.map((r) => r.cwd).filter(Boolean))];
const idsWithProject = new Set(records.filter((r) => r.cwd).map((r) => r.id));
const before = Array.isArray(state["project-order"]) ? state["project-order"] : [];
const seen = new Set(before);
state["project-order"] = before.concat(projects.filter((p) => !seen.has(p)));
const oldProjectless = Array.isArray(state["projectless-thread-ids"]) ? state["projectless-thread-ids"] : [];
state["projectless-thread-ids"] = oldProjectless.filter((id) => !idsWithProject.has(id));
const addedProjects = state["project-order"].length - before.length;
const removedProjectless = oldProjectless.length - state["projectless-thread-ids"].length;
if (addedProjects || removedProjectless) {
fs.writeFileSync(GLOBAL_STATE, JSON.stringify(state, null, 2) + "\n", "utf-8");
log(`项目索引更新:新增项目 ${addedProjects} 个,移出无项目会话 ${removedProjectless} 条`);
}
return { addedProjects, removedProjectless };
}
function ts() {
const d = new Date();
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
}
// 【增强】重建 session_index.jsonl:确保所有会话都在索引中(切账号后必要)
function rebuildSessionIndex(records, log = () => {}) {
if (!records || !records.length) {
log("索引重建:无会话记录,跳过");
return { rebuilt: 0 };
}
// 备份旧索引
if (fs.existsSync(SESSION_INDEX)) {
const backup = SESSION_INDEX + ".bak-" + ts();
try {
fs.copyFileSync(SESSION_INDEX, backup);
log(`已备份旧索引 → ${path.basename(backup)}`);
} catch (e) {
log(`索引备份失败:${e.message || e}`);
}
}
// 读取现有索引,保留用户重命名的标题
const existingTitles = {};
try {
const lines = fs.readFileSync(SESSION_INDEX, "utf-8").split("\n");
for (const line of lines) {
if (!line.trim()) continue;
try {
const d = JSON.parse(line);
if (d.id && d.thread_name) existingTitles[d.id] = d.thread_name;
} catch {}
}
} catch {}
// 重建索引:每个会话一行 JSON
const lines = [];
for (const r of records) {
if (!r.id) continue;
const indexEntry = {
id: r.id,
thread_name: cleanThreadTitle(existingTitles[r.id] || "", r.first_user_message || r.title, 80) || r.title || "(无标题对话)",
project_path: r.cwd || null,
created_at: new Date(r.created_at_ms).toISOString(),
source: r.source || "vscode",
archived: r.archived ? true : false,
};
lines.push(JSON.stringify(indexEntry));
}
// 写入新索引
try {
fs.writeFileSync(SESSION_INDEX, lines.join("\n") + "\n", "utf-8");
log(`索引重建完成:${lines.length} 条会话`);
return { rebuilt: lines.length };
} catch (e) {
log(`索引重建失败:${e.message || e}`);
return { rebuilt: 0, error: e.message || String(e) };
}
}
function secTs(ms) {
const n = Number(ms);
if (!Number.isFinite(n) || n <= 0) return Date.now() / 1000;
// 兼容秒 / 毫秒两种时间戳
return n > 1e12 ? n / 1000 : n;
}
// 重建 Codex 桌面端侧栏目录(~/.codex/sqlite/codex-dev.db 的 local_thread_catalog)。
// 新版 ChatGPT/Codex 启动后侧栏主要读这里;只同步 state_5.sqlite 不会让历史会话重新出现。
function rebuildLocalThreadCatalog(records, target, log = () => {}) {
if (!records || !records.length) {
log("侧栏目录重建:无会话记录,跳过");
return { rebuilt: 0, skipped: true };
}
if (!fs.existsSync(LOCAL_CATALOG_DB)) {
log(`侧栏目录重建跳过:未找到 ${LOCAL_CATALOG_DB}`);
return { rebuilt: 0, skipped: true, missing: true };
}
// 只回填未归档、有标题/预览/用户消息的主会话。
// 注意:新版 Codex 从某版本起大量线程 has_user_event 默认写成 0,不能再只靠这个字段过滤。
const visible = [];
const seen = new Set();
for (const r of records) {
if (!r || !r.id || seen.has(r.id) || r.archived) continue;
const hasText = !!(r.first_user_message || r.preview || (r.title && r.title !== "(无标题对话)"));
const hasUser = !!r.has_user_event || hasText;
if (!hasUser) continue;
seen.add(r.id);
visible.push(r);
}
if (!visible.length) {
log("侧栏目录重建:没有可展示会话,跳过");
return { rebuilt: 0, skipped: true };
}
// 备份目录库
try {
const bak = LOCAL_CATALOG_DB + ".bak-" + ts();
fs.copyFileSync(LOCAL_CATALOG_DB, bak);
log(`已备份侧栏目录库 → ${path.basename(bak)}`);
} catch (e) {
log(`侧栏目录库备份失败:${e.message || e}`);
}
const nowSec = Date.now() / 1000;
let maxUpdated = 0;
const valuesSql = [];
visible.forEach((r, idx) => {
const created = secTs(r.created_at_ms);
const updated = Math.max(secTs(r.updated_at_ms), created);
if (updated > maxUpdated) maxUpdated = updated;
const title = cleanThreadTitle(r.title || "", r.first_user_message || r.preview || "", 80) || "(无标题对话)";
const sourceKind = String(r.source || "vscode").slice(0, 64) || "vscode";
const provider = target || r.model_provider || "";
const cwd = r.cwd || "";
const gitBranch = r.git_branch || null;
// observation_sequence 用递增序号,避免全 0 被后续 reconcile 误判
const obs = idx + 1;
valuesSql.push(
`(${sql("local")},${sql(r.id)},${sql(title)},${sql(created)},${sql(updated)},${sql(cwd)},` +
`${sql(sourceKind)},NULL,${sql(provider)},${sql(gitBranch)},${sql(obs)},0)`
);
});
const sqlText = [
"BEGIN;",
"DELETE FROM local_thread_catalog WHERE host_id='local';",
"INSERT INTO local_thread_catalog (" +
"host_id, thread_id, display_title, source_created_at, source_updated_at, cwd, " +
"source_kind, source_detail, model_provider, git_branch, observation_sequence, missing_candidate" +
") VALUES " +
valuesSql.join(",\n") +
";",
// 标记冷构建完成,避免启动后又被半残状态卡住
"INSERT OR IGNORE INTO local_thread_catalog_sync_state (host_id) VALUES ('local');",
"UPDATE local_thread_catalog_sync_state SET " +
`watermark_updated_at=${sql(maxUpdated || nowSec)}, ` +
"initial_build_complete=1, " +
`observation_sequence=${sql(visible.length)}, ` +
`last_full_reconciled_at=${sql(Math.floor(nowSec))} ` +
"WHERE host_id='local';",
"INSERT OR IGNORE INTO local_thread_catalog_hosts (host_id, host_kind) VALUES ('local','local');",
"INSERT OR IGNORE INTO local_thread_catalog_metadata (id, catalog_revision) VALUES (1, 0);",
"UPDATE local_thread_catalog_metadata SET catalog_revision=COALESCE(catalog_revision,0)+1 WHERE id=1;",
"COMMIT;",
"PRAGMA wal_checkpoint(TRUNCATE);",
].join("\n") + "\n";
try {
if (sqlite3Available()) {
runSqlite3(LOCAL_CATALOG_DB, sqlText);
} else {
// 无 sqlite3 CLI 时走 sql.js(同步路径里已有依赖)
throw new Error("sqlite3 CLI 不可用,改走 sql.js");
}
log(`侧栏目录重建完成:${visible.length} 条(provider=${target})`);
return { rebuilt: visible.length };
} catch (e) {
// sql.js 兜底
try {
// 这里用同步 require 的 initSqlJs 需要 await,调用方是 async,所以抛回给上层处理
return { rebuilt: 0, error: e.message || String(e), needSqlJs: true, sqlText, visibleCount: visible.length };
} catch (e2) {
log(`侧栏目录重建失败:${e.message || e}`);
return { rebuilt: 0, error: e.message || String(e) };
}
}
}
// Codex 桌面端对 codex-dev.db 有一套版本化迁移(PRAGMA user_version,当前迁移链到 v30,
// 依据 ChatGPT.app 0.147.x 的 app.asar 反编译核对)。工具箱自建该库时必须把 user_version
// 对齐到链尾:否则 Codex 会从 v0 重跑全部迁移,其中 v27/v28/v29 会把
// local_thread_catalog_sync_state.last_full_reconciled_at 置 NULL,首次启动即触发
// full reconcile——若 state 库线程没写成功,侧栏会话会"闪现后全部消失"。
// Codex 升级新增 v31+ 迁移时会自然在此基础上继续,不受影响。
const CATALOG_DB_USER_VERSION = 30;
function createLocalCatalogSchema(db) {
db.run(`CREATE TABLE IF NOT EXISTS local_thread_catalog (
host_id TEXT NOT NULL,
thread_id TEXT NOT NULL,
display_title TEXT NOT NULL,
source_created_at REAL NOT NULL,
source_updated_at REAL NOT NULL,
cwd TEXT NOT NULL,
source_kind TEXT NOT NULL,
source_detail TEXT,
model_provider TEXT NOT NULL,
git_branch TEXT,
observation_sequence INTEGER NOT NULL,
missing_candidate INTEGER NOT NULL DEFAULT 0 CHECK (missing_candidate IN (0, 1)),
thread_source TEXT,
source_recency_at REAL NOT NULL DEFAULT 0,
PRIMARY KEY (host_id, thread_id)
)`);
db.run("CREATE INDEX IF NOT EXISTS local_thread_catalog_created_idx ON local_thread_catalog (host_id, source_created_at DESC, source_updated_at DESC, thread_id) WHERE missing_candidate=0");
db.run("CREATE INDEX IF NOT EXISTS local_thread_catalog_cwd_created_idx ON local_thread_catalog (host_id, cwd, source_created_at DESC, source_updated_at DESC, thread_id) WHERE missing_candidate=0");
db.run("CREATE INDEX IF NOT EXISTS local_thread_catalog_updated_idx ON local_thread_catalog (host_id, source_recency_at DESC, source_created_at DESC, thread_id) WHERE missing_candidate=0");
db.run("CREATE INDEX IF NOT EXISTS local_thread_catalog_cwd_updated_idx ON local_thread_catalog (host_id, cwd, source_recency_at DESC, source_created_at DESC, thread_id) WHERE missing_candidate=0");
db.run(`CREATE TABLE IF NOT EXISTS local_thread_catalog_sync_state (
host_id TEXT PRIMARY KEY,
watermark_updated_at REAL,
initial_build_complete INTEGER NOT NULL DEFAULT 0,
observation_sequence INTEGER NOT NULL DEFAULT 0,
last_full_reconciled_at INTEGER
)`);
db.run("CREATE TABLE IF NOT EXISTS local_thread_catalog_hosts (host_id TEXT PRIMARY KEY, host_kind TEXT NOT NULL CHECK (host_kind IN ('local','ssh','wsl','remote-control')))");
db.run("CREATE TABLE IF NOT EXISTS local_thread_catalog_metadata (id INTEGER PRIMARY KEY CHECK (id=1), catalog_revision INTEGER NOT NULL DEFAULT 0)");
}
async function rebuildLocalThreadCatalogAsync(records, target, log = () => {}) {
if (!records || !records.length) {
log("侧栏目录重建:无会话记录,跳过");
return { rebuilt: 0, skipped: true };
}
const visible = [];
const seen = new Set();
for (const r of records) {
if (!r || !r.id || seen.has(r.id) || r.archived) continue;
const hasText = !!(r.first_user_message || r.preview || (r.title && r.title !== "(无标题对话)"));
if (!(r.has_user_event || hasText)) continue;
seen.add(r.id);
visible.push(r);
}
if (!visible.length) {
log("侧栏目录重建:没有可展示会话,跳过");
return { rebuilt: 0, skipped: true };
}
let db;
const existed = fs.existsSync(LOCAL_CATALOG_DB);
try {
const SQL = await initSqlJs({
locateFile: (f) =>
path.join(global.__BUNDLE_ROOT__ || path.join(__dirname, "..", ".."), "node_modules", "sql.js", "dist", f),
});
fs.mkdirSync(path.dirname(LOCAL_CATALOG_DB), { recursive: true });
db = existed ? new SQL.Database(fs.readFileSync(LOCAL_CATALOG_DB)) : new SQL.Database();
createLocalCatalogSchema(db);
if (!existed) db.run(`PRAGMA user_version = ${CATALOG_DB_USER_VERSION}`);
if (existed) {
try {
const bak = LOCAL_CATALOG_DB + ".bak-" + ts();
fs.copyFileSync(LOCAL_CATALOG_DB, bak);
log(`已备份侧栏目录库 → ${path.basename(bak)}`);
} catch (e) {
log(`侧栏目录库备份失败:${e.message || e}`);
}
}
const cols = sqlJsTableColumns(db, "local_thread_catalog");
const insertCols = [
"host_id", "thread_id", "display_title", "source_created_at", "source_updated_at", "cwd",
"source_kind", "source_detail", "model_provider", "git_branch", "observation_sequence",
"missing_candidate", "thread_source", "source_recency_at",
].filter((c) => cols.has(c));
const nowSec = Date.now() / 1000;
let maxUpdated = 0;
const valuesFor = (r, idx) => {
const created = secTs(r.created_at_ms);
const updated = Math.max(secTs(r.updated_at_ms), created);
maxUpdated = Math.max(maxUpdated, updated);
const title = cleanThreadTitle(r.title || "", r.first_user_message || r.preview || "", 80) || "(无标题对话)";
const values = {
host_id: "local",
thread_id: r.id,
display_title: title,
source_created_at: created,
source_updated_at: updated,
cwd: r.cwd || "",
source_kind: String(r.source || "vscode").slice(0, 64) || "vscode",
source_detail: null,
model_provider: target || r.model_provider || "",
git_branch: r.git_branch || null,
observation_sequence: idx + 1,
missing_candidate: 0,
thread_source: r.thread_source || "user",
source_recency_at: updated,
};
return insertCols.map((c) => values[c]);
};
const insert = db.prepare(
`INSERT INTO local_thread_catalog (${insertCols.join(",")}) VALUES (${insertCols.map(() => "?").join(",")})`
);
db.run("BEGIN");
db.run("DELETE FROM local_thread_catalog WHERE host_id='local'");
visible.forEach((r, idx) => insert.run(valuesFor(r, idx)));
insert.free();
db.run("INSERT OR IGNORE INTO local_thread_catalog_sync_state (host_id) VALUES ('local')");
db.run(
"UPDATE local_thread_catalog_sync_state SET watermark_updated_at=?, initial_build_complete=1, observation_sequence=?, last_full_reconciled_at=? WHERE host_id='local'",
[maxUpdated || nowSec, visible.length, Math.floor(nowSec)]
);
db.run("INSERT OR IGNORE INTO local_thread_catalog_hosts (host_id, host_kind) VALUES ('local','local')");
db.run("INSERT OR IGNORE INTO local_thread_catalog_metadata (id, catalog_revision) VALUES (1,0)");
db.run("UPDATE local_thread_catalog_metadata SET catalog_revision=COALESCE(catalog_revision,0)+1 WHERE id=1");
db.run("COMMIT");
const exported = Buffer.from(db.export());
db.close();
db = null;
fs.writeFileSync(LOCAL_CATALOG_DB, exported);
try { fs.rmSync(LOCAL_CATALOG_DB + "-wal", { force: true }); } catch {}
try { fs.rmSync(LOCAL_CATALOG_DB + "-shm", { force: true }); } catch {}
log(`侧栏目录重建完成(sql.js):${visible.length} 条(provider=${target})`);
return { rebuilt: visible.length, created: !existed };
} catch (e) {
if (db) try { db.run("ROLLBACK"); } catch {}
if (db) try { db.close(); } catch {}
log(`侧栏目录重建失败:${e.message || e}`);
return { rebuilt: 0, error: e.message || String(e) };
}
}
// 兜底:把 rollout 已确认有用户消息、但 state DB 仍为 0 的 has_user_event 强制拉起来。
// 新版 Codex 从 7/17 起大量线程默认写成 0,仅靠 upsert 的 CASE 也可能被后续状态覆盖。
function forceHasUserEventSql(userEventIds) {
if (!userEventIds || !userEventIds.size) return "";
const ids = [...userEventIds].map((id) => sql(id));
// 分批,避免单条 SQL 过长
const chunks = [];
for (let i = 0; i < ids.length; i += 200) chunks.push(ids.slice(i, i + 200));
return chunks
.map((part) => `UPDATE threads SET has_user_event=1 WHERE id IN (${part.join(",")}) AND COALESCE(has_user_event,0)<>1;`)
.join("\n");
}
function sqlJsTableColumns(db, table) {
const result = db.exec(`PRAGMA table_info("${String(table).replace(/"/g, '""')}")`);
if (!result.length) return new Set();
return new Set(result[0].values.map((row) => String(row[1] || "")).filter(Boolean));
}
function sqlJsCount(db, table) {
const result = db.exec(`SELECT COUNT(*) FROM "${String(table).replace(/"/g, '""')}"`);
return result.length && result[0].values.length ? Number(result[0].values[0][0] || 0) : 0;
}
function sqlJsLocateFile(file) {
return path.join(
global.__BUNDLE_ROOT__ || path.join(__dirname, "..", ".."),
"node_modules", "sql.js", "dist", file
);
}
async function loadSqlJs() {
return initSqlJs({ locateFile: sqlJsLocateFile });
}
// 创建一个可被当前 Codex 直接打开的完整 state_5.sqlite,而不是只造 threads 表。
// 通过临时文件安装,避免 Windows 下进程中断时留下半个数据库。
function createStateDbSqlJs(SQL, dbPath) {
const db = new SQL.Database();
try {
db.exec(STATE_DB_SCHEMA_SQL);
for (const [version, description, checksum] of STATE_DB_MIGRATIONS) {
db.run(
`INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) ` +
`VALUES (${version}, ${sql(description)}, 1, X'${checksum}', 0)`
);
}
const tmp = `${dbPath}.td-create-${process.pid}-${Date.now()}.tmp`;
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
try {
fs.writeFileSync(tmp, Buffer.from(db.export()));
fs.renameSync(tmp, dbPath);
} finally {
try { fs.rmSync(tmp, { force: true }); } catch {}
}
} finally {
db.close();
}
}
// sql.js 只接受 null/string/number/bigint 绑定值,其余类型直接抛错。
function bindValue(v) {
if (v === null || v === undefined) return null;
if (typeof v === "string" || typeof v === "number") return v;
try {
const text = JSON.stringify(v);
if (typeof text === "string") return text;
} catch {}
return String(v);
}
// Windows 通常没有 sqlite3.exe。这里必须和 sqlite3 分支一样做 UPSERT,不能只 UPDATE 已有行,
// 否则新机器上的 state_*.sqlite 没有 threads 时,rollout 虽然存在,Codex 仍然看不到会话。
// 单条记录失败只跳过该条(记日志),不能让一条异常记录回滚整批——否则 state 库一条都进不去,
// 侧栏 catalog 却重建成功,Codex 启动 full reconcile 后会话会"闪现后全部消失"。
function upsertStateDbSqlJs(db, records, target, userEventIds, cwdById, log = () => {}) {
const tables = db.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='threads'");
if (!tables.length) return { insertedRows: 0, provRows: 0, ueRows: 0, cwdRows: 0, failedRows: 0, skipped: true };
const cols = sqlJsTableColumns(db, "threads");
if (!cols.has("id") || !cols.has("model_provider")) {
return { insertedRows: 0, provRows: 0, ueRows: 0, cwdRows: 0, failedRows: 0, skipped: true };
}
const desired = threadColumns(cols);
const updateSet = threadUpdateSet(desired, target, true);
const statement = db.prepare(
`INSERT INTO threads (${desired.join(",")}) VALUES (${desired.map(() => "?").join(",")}) ` +
`ON CONFLICT(id) DO UPDATE SET ${updateSet.join(",")}`
);
const before = sqlJsCount(db, "threads");
let provRows = 0;
let ueRows = 0;
let cwdRows = 0;
let failedRows = 0;
try {
db.run("BEGIN");
for (const record of records) {
try {
statement.run(desired.map((column) => bindValue(threadValue(record, target, column))).concat([target]));
} catch (e) {
failedRows++;
if (failedRows <= 3) log(` 线程回填跳过 ${record.id || "(无id)"}:${e.message || e}`);
}
}
statement.free();
db.run(`UPDATE threads SET model_provider=? WHERE COALESCE(model_provider,'')<>?`, [target, target]);
provRows = db.getRowsModified();
if (cols.has("has_user_event")) {
for (const id of userEventIds || []) {
db.run("UPDATE threads SET has_user_event=1 WHERE id=? AND COALESCE(has_user_event,0)<>1", [id]);
ueRows += db.getRowsModified();
}
}
if (cols.has("cwd")) {
for (const [id, cwd] of Object.entries(cwdById || {})) {
db.run("UPDATE threads SET cwd=? WHERE id=? AND COALESCE(cwd,'')<>?", [cwd, id, cwd]);
cwdRows += db.getRowsModified();
}
}
db.run("COMMIT");
} catch (e) {
try { statement.free(); } catch {}
try { db.run("ROLLBACK"); } catch {}
throw e;
}
return {
insertedRows: Math.max(0, sqlJsCount(db, "threads") - before),
provRows,
ueRows,
cwdRows,
failedRows,
};
}
async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) {
if (codexRunning()) throw new Error("检测到 Codex 正在运行,请先完全退出 Codex 再同步。");
const target = targetProvider();
log(`目标 provider:${target}`);
onProgress({ phase: "备份中", done: 0, total: 0 });
// 1) 备份
const backup = path.join(CODEX_HOME, "backups_state", "provider-sync", ts());
fs.mkdirSync(backup, { recursive: true });
for (const name of ["config.toml", ".codex-global-state.json", ".codex-global-state.json.bak"]) {
const src = path.join(CODEX_HOME, name);
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(backup, name));
}
for (const db of fs.readdirSync(CODEX_HOME).filter((f) => /^state_.*\.sqlite(?:-(?:wal|shm))?$/.test(f))) {
fs.copyFileSync(path.join(CODEX_HOME, db), path.join(backup, db));
}
// 同时备份侧栏目录库
if (fs.existsSync(LOCAL_CATALOG_DB)) {
try {
fs.copyFileSync(LOCAL_CATALOG_DB, path.join(backup, "codex-dev.db"));
} catch {}
}
log(`已备份到 ${backup}`);
// 2) 遍历 rollout:改首行 provider,收集完整线程元信息
const userEventIds = new Set();
const cwdById = {};
const titles = loadIndexTitles();
const records = [];
let changedFiles = 0;
const rolloutFiles = findRollouts();
const rtotal = rolloutFiles.length;
let ri = 0;
for (const file of rolloutFiles) {
ri++;
if (ri % 5 === 0 || ri === rtotal) onProgress({ phase: "改写会话文件", done: ri, total: rtotal });
let lines;
try {
lines = fs.readFileSync(file, "utf-8").split(/(?<=\n)/); // 保留换行
} catch {
continue;
}
if (!lines.length) continue;
const blob = lines.join("");
const hasUE = blob.includes('"user_message"') || blob.includes('"user_input"');
let tid = null;
let rewrote = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) continue;
let rec;
try {
rec = JSON.parse(line);
} catch {
continue;
}
if (rec.type === "session_meta" && rec.payload && typeof rec.payload === "object") {
tid = rec.payload.id || rec.payload.session_id || null;
const cwd = rec.payload.cwd;
if (tid && cwd) cwdById[tid] = cwd;
if (rec.payload.model_provider !== target) {
rec.payload.model_provider = target;
lines[i] = JSON.stringify(rec) + "\n";
rewrote = true;
}
break;
}
}
if (rewrote) {
fs.writeFileSync(file, lines.join(""), "utf-8");
changedFiles++;
}
if (tid && hasUE) userEventIds.add(tid);
const rec = readRolloutRecord(file, titles);
if (rec) {
rec.model_provider = target;
records.push(rec);
}
}
log(`改写 rollout 文件:${changedFiles} 个;含用户消息的会话:${userEventIds.size} 条;可回填线程:${records.length} 条`);
// 2.5) Codex 项目分组来自全局 state,历史 cwd 需要合并到 project-order。
const projectState = updateProjectState(records, log);
// 3) 首次安装场景:目标目录可能连 state_*.sqlite 都没有。先创建完整的
// state_5.sqlite,再走下面统一的 sqlite3 / sql.js UPSERT 流程。
let SQL = null;
let stateDbCreated = 0;
let stateDbCreateError = "";
let stateDbNames = fs.readdirSync(CODEX_HOME).filter((f) => /^state_.*\.sqlite$/.test(f));
if (!stateDbNames.length) {
const stateDbName = "state_5.sqlite";
try {
SQL = await loadSqlJs();
createStateDbSqlJs(SQL, path.join(CODEX_HOME, stateDbName));
stateDbNames = [stateDbName];
stateDbCreated = 1;
log(`未找到 state_*.sqlite,已创建完整状态库:${stateDbName}`);
} catch (e) {
stateDbCreateError = e.message || String(e);
log(`创建 state_5.sqlite 失败:${stateDbCreateError}`);
}
}
// 4) 更新所有 state_*.sqlite
onProgress({ phase: "更新数据库", done: 0, total: 0 });
if (sqlite3Available()) {
let provRows = 0;
let ueRows = 0;
let cwdRows = 0;
let insertedRows = 0;
const forceUeSql = forceHasUserEventSql(userEventIds);
for (const name of stateDbNames) {
const dbPath = path.join(CODEX_HOME, name);
try {
const cols = sqliteCols(dbPath);
if (!cols.has("model_provider") || !cols.has("has_user_event")) continue;
const beforeProvider = Number(runSqlite3(dbPath, `SELECT COUNT(*) FROM threads WHERE COALESCE(model_provider,'')<>${sql(target)};\n`).trim() || 0);
const beforeUserEvent = Number(runSqlite3(dbPath, "SELECT COUNT(*) FROM threads WHERE COALESCE(has_user_event,0)<>1;\n").trim() || 0);
const beforeRows = Number(runSqlite3(dbPath, "SELECT COUNT(*) FROM threads;\n").trim() || 0);
runSqlite3(dbPath, buildThreadUpsertSql(records, target, cols));
// 兜底:强制把 rollout 已确认有用户消息的线程 has_user_event 拉起来
if (forceUeSql) runSqlite3(dbPath, "BEGIN;\n" + forceUeSql + "\nCOMMIT;\n");
runSqlite3(dbPath, "PRAGMA wal_checkpoint(TRUNCATE);\n");
const afterUserEvent = Number(runSqlite3(dbPath, "SELECT COUNT(*) FROM threads WHERE COALESCE(has_user_event,0)<>1;\n").trim() || 0);
const afterRows = Number(runSqlite3(dbPath, "SELECT COUNT(*) FROM threads;\n").trim() || 0);
provRows += beforeProvider;
ueRows += Math.max(0, beforeUserEvent - afterUserEvent);
insertedRows += Math.max(0, afterRows - beforeRows);
cwdRows += records.filter((r) => r.cwd).length;
} catch (e) {
log(` sqlite3 数据库跳过 ${name}:${e.message || e}`);
}
}
log(`数据库更新:provider ${provRows} 行、has_user_event ${ueRows} 行、新增线程 ${insertedRows} 行、项目索引 +${projectState.addedProjects}`);
// 【增强】4) 重建 session_index.jsonl,确保所有会话都在索引中
onProgress({ phase: "重建索引", done: 0, total: 0 });
const indexResult = rebuildSessionIndex(records, log);
// 【关键修复】5) 重建 Codex 桌面侧栏 local_thread_catalog
// 新版 ChatGPT/Codex 侧栏读的是 ~/.codex/sqlite/codex-dev.db,不是 state_5.sqlite。
onProgress({ phase: "重建侧栏目录", done: 0, total: 0 });
const catalogResult = await rebuildLocalThreadCatalogAsync(records, target, log);
onProgress({ phase: "完成", done: 0, total: 0 });
return {
target,
totalConversations: rtotal,
changedFiles,
userEvents: userEventIds.size,
provRows,
ueRows,
cwdRows,
insertedRows,
projectRows: projectState.addedProjects,
projectlessRemoved: projectState.removedProjectless,
indexRebuilt: indexResult.rebuilt || 0,
catalogRebuilt: catalogResult.rebuilt || 0,
stateDbCreated,
stateDbCreateError,
backup,
};
}
if (!SQL) SQL = await loadSqlJs();
let provRows = 0;
let ueRows = 0;
let cwdRows = 0;
let insertedRows = 0;
let failedRows = 0;
for (const name of stateDbNames) {
const dbPath = path.join(CODEX_HOME, name);
let db;
try {
db = new SQL.Database(fs.readFileSync(dbPath));
const result = upsertStateDbSqlJs(db, records, target, userEventIds, cwdById, log);
if (result.skipped) {
db.close();
db = null;
continue;
}
provRows += result.provRows;
ueRows += result.ueRows;
cwdRows += result.cwdRows;
insertedRows += result.insertedRows;
failedRows += result.failedRows || 0;
fs.writeFileSync(dbPath, Buffer.from(db.export()));
db.close();
db = null;
try { fs.rmSync(dbPath + "-wal", { force: true }); } catch {}
try { fs.rmSync(dbPath + "-shm", { force: true }); } catch {}
} catch (e) {
if (db) try { db.close(); } catch {}
log(` 数据库跳过 ${name}:${e.message || e}`);
}
}
log(`数据库更新:provider ${provRows} 行、has_user_event ${ueRows} 行、cwd ${cwdRows} 行、新增线程 ${insertedRows} 行${failedRows ? `、失败跳过 ${failedRows} 行` : ""}`);
// 【增强】4) 重建 session_index.jsonl,确保所有会话都在索引中
onProgress({ phase: "重建索引", done: 0, total: 0 });
const indexResult = rebuildSessionIndex(records, log);
// 【关键修复】5) 重建 Codex 桌面侧栏 local_thread_catalog
onProgress({ phase: "重建侧栏目录", done: 0, total: 0 });
const catalogResult = await rebuildLocalThreadCatalogAsync(records, target, log);
onProgress({ phase: "完成", done: 0, total: 0 });
return {
target,
totalConversations: rtotal,
changedFiles,
userEvents: userEventIds.size,
provRows,
ueRows,
cwdRows,
insertedRows,
projectRows: projectState.addedProjects,
projectlessRemoved: projectState.removedProjectless,
indexRebuilt: indexResult.rebuilt || 0,
catalogRebuilt: catalogResult.rebuilt || 0,
stateDbCreated,
stateDbCreateError,
backup,
};
}
module.exports = { syncProviderToCurrent, codexRunning, targetProvider, setCodexDataDir };