cxs is a local-first CLI for searching Codex session logs. It is designed for progressive retrieval: find the right session first, then read
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(core): 修复 P0-2/3/4 (DB 释放 / lock race / state db schema)

- query: 引入 withReadDb 统一收口读连接生命周期,findSessions /
getMessageRange / getMessagePage / listSessionSummaries / collectStats
在异常路径下不再泄漏连接 (P0-2)
- sync-lock: 抽出 tryRemoveStaleLock,删除 stale lock 前二次比对
pid+createdAt,避免 TOCTOU 误删另一进程刚抢到的新 lock (P0-3)
- current: codex state db 缺 threads 表时抛 CurrentStateDbError,CLI
在 --json 下输出 {error:{code,message}} 并以非零退出 (P0-4)

测试: query.test.ts +1, cli.test.ts +2, 新建 sync-lock.test.ts (4 cases),
bun run check 通过 (37 pass)。
文档: 标记 P0-1 已在 be75d87 修复。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Entire-Checkpoint: dd6f619c0078

cat 187c5d93 be75d877

+243 -49
+31
cli.test.ts
··· 82 82 expect(payload.candidates[0]?.filePath).toBe("/tmp/two.jsonl"); 83 83 }); 84 84 85 + test("current --json emits structured error when state db file is missing", async () => { 86 + const base = mkdtempSync(join(tmpdir(), "cxs-cli-current-missing-")); 87 + tempDirs.push(base); 88 + const stateDbPath = join(base, "does-not-exist.sqlite"); 89 + 90 + const result = await runCli(["current", "--state-db", stateDbPath, "--json"]); 91 + expect(result.exitCode).toBe(1); 92 + const payload = JSON.parse(result.stdout) as { 93 + error: { code: string; message: string }; 94 + }; 95 + expect(payload.error.code).toBe("state_db_unavailable"); 96 + expect(payload.error.message).toContain(stateDbPath); 97 + }); 98 + 99 + test("current --json emits structured error when state db schema is unexpected", async () => { 100 + const base = mkdtempSync(join(tmpdir(), "cxs-cli-current-schema-")); 101 + tempDirs.push(base); 102 + const stateDbPath = join(base, "state.sqlite"); 103 + const db = new Database(stateDbPath); 104 + db.run("CREATE TABLE other (id INTEGER PRIMARY KEY)"); 105 + db.close(); 106 + 107 + const result = await runCli(["current", "--state-db", stateDbPath, "--json"]); 108 + expect(result.exitCode).toBe(1); 109 + const payload = JSON.parse(result.stdout) as { 110 + error: { code: string; message: string }; 111 + }; 112 + expect(payload.error.code).toBe("state_db_unavailable"); 113 + expect(payload.error.message).toContain("threads"); 114 + }); 115 + 85 116 test("find text output points to read-range", async () => { 86 117 const base = mkdtempSync(join(tmpdir(), "cxs-cli-")); 87 118 tempDirs.push(base);
+33 -9
cli.ts
··· 15 15 import { SyncError, syncSessions } from "./indexer"; 16 16 import { 17 17 collectStats, 18 + CurrentStateDbError, 18 19 findSessions, 19 20 getCurrentSessions, 20 21 getMessagePage, ··· 40 41 .option("--json", "输出 JSON") 41 42 .action((options) => { 42 43 const cwd = options.cwd ?? process.cwd(); 43 - if (!existsSync(options.stateDb)) { 44 - throw new Error(`state db not found: ${options.stateDb}`); 45 - } 46 - 47 - const result = getCurrentSessions(options.stateDb, cwd, parsePositiveInt(options.limit, 100)); 48 - if (options.json) { 49 - console.log(JSON.stringify(result, null, 2)); 50 - return; 44 + const jsonMode = Boolean(options.json); 45 + try { 46 + if (!existsSync(options.stateDb)) { 47 + throw new CurrentStateDbError(`state db not found: ${options.stateDb}`); 48 + } 49 + const result = getCurrentSessions(options.stateDb, cwd, parsePositiveInt(options.limit, 100)); 50 + if (jsonMode) { 51 + console.log(JSON.stringify(result, null, 2)); 52 + return; 53 + } 54 + printCurrentSessions(result.cwd, result.candidates); 55 + } catch (error) { 56 + if (error instanceof CurrentStateDbError) { 57 + emitCurrentError(error, jsonMode); 58 + return; 59 + } 60 + throw error; 51 61 } 52 - printCurrentSessions(result.cwd, result.candidates); 53 62 }); 54 63 55 64 program ··· 226 235 if (value === "started" || value === "messages") return value; 227 236 return "ended"; 228 237 } 238 + 239 + function emitCurrentError(error: CurrentStateDbError, jsonMode: boolean): void { 240 + if (jsonMode) { 241 + console.log( 242 + JSON.stringify( 243 + { error: { code: "state_db_unavailable", message: error.message } }, 244 + null, 245 + 2, 246 + ), 247 + ); 248 + } else { 249 + console.error(error.message); 250 + } 251 + process.exitCode = 1; 252 + }
+12
db.ts
··· 31 31 return db; 32 32 } 33 33 34 + // Why: callers used to do `const db = openReadDb(...); ... db.close();` which 35 + // leaks the connection if work in between throws. Wrapping in try/finally at 36 + // every callsite is noise — fold it once. 37 + export function withReadDb<T>(dbPath: string, fn: (db: Database) => T): T { 38 + const db = openReadDb(dbPath); 39 + try { 40 + return fn(db); 41 + } finally { 42 + db.close(); 43 + } 44 + } 45 + 34 46 export function openWriteDb(dbPath: string): Database { 35 47 const db = new Database(dbPath); 36 48 db.run(`PRAGMA busy_timeout=${BUSY_TIMEOUT_MS}`);
+4 -2
docs/CODE_QUALITY_REVIEW_2026-04-27.md
··· 85 85 86 86 ## 主要问题与风险 87 87 88 - ### P0-1:缺少真正的 TypeScript 类型检查 88 + ### P0-1:缺少真正的 TypeScript 类型检查 ✅ 已修复 89 89 90 - `package.json` 中 `check` 当前只是 `bun test`,没有 `tsc --noEmit`。Bun 可以执行 TypeScript,但不等于有完整类型检查。 90 + > 状态:已在 commit `be75d87 chore: add TypeScript check` 修复(写本报告之后)。`tsconfig.json` 已就绪、`check` 已改为 `tsc --noEmit && bun test`。 91 + 92 + 原文(保留作为背景):`package.json` 中 `check` 当前只是 `bun test`,没有 `tsc --noEmit`。Bun 可以执行 TypeScript,但不等于有完整类型检查。 91 93 92 94 当前类型已经开始变复杂,例如: 93 95
+26 -1
query.test.ts
··· 8 8 import { openReadDb, openWriteDb, replaceSession } from "./db"; 9 9 import { INDEX_VERSION } from "./env"; 10 10 import { syncSessions } from "./indexer"; 11 - import { classifyQueryProfile, findSessions, getCurrentSessions, getMessagePage, getMessageRange } from "./query"; 11 + import { 12 + classifyQueryProfile, 13 + CurrentStateDbError, 14 + findSessions, 15 + getCurrentSessions, 16 + getMessagePage, 17 + getMessageRange, 18 + } from "./query"; 12 19 13 20 const tempDirs: string[] = []; 14 21 ··· 54 61 "11111111-1111-4111-8111-111111111111", 55 62 ]); 56 63 expect(result.candidates[0]?.filePath).toBe("/tmp/b.jsonl"); 64 + }); 65 + 66 + test("current throws CurrentStateDbError when state db lacks 'threads' table", () => { 67 + const base = mkdtempSync(join(tmpdir(), "cxs-current-schema-")); 68 + tempDirs.push(base); 69 + const stateDbPath = join(base, "state.sqlite"); 70 + const db = new Database(stateDbPath); 71 + db.run("CREATE TABLE other (id INTEGER PRIMARY KEY)"); 72 + db.close(); 73 + 74 + let caught: unknown = null; 75 + try { 76 + getCurrentSessions(stateDbPath, "/tmp/project", 10); 77 + } catch (error) { 78 + caught = error; 79 + } 80 + expect(caught).toBeInstanceOf(CurrentStateDbError); 81 + expect((caught as Error).message).toContain("threads"); 57 82 }); 58 83 59 84 test("sync -> find -> read-range -> read-page works on fixture sessions", async () => {
+50 -35
query.ts
··· 8 8 getStatsCounts, 9 9 getTopCwds, 10 10 listSessions, 11 - openReadDb, 11 + withReadDb, 12 12 } from "./db"; 13 13 import { INDEX_VERSION } from "./env"; 14 14 import { classifyQueryProfile, rerankHits } from "./ranking"; ··· 26 26 export { classifyQueryProfile } from "./ranking"; 27 27 type SqlParams = SQLQueryBindings[]; 28 28 29 + // Why: Codex state db lives outside cxs's control — its schema can drift 30 + // across upstream releases. CLI translates this into a structured --json 31 + // error instead of leaking SQLite's raw exception. 32 + export class CurrentStateDbError extends Error { 33 + constructor(message: string) { 34 + super(message); 35 + this.name = "CurrentStateDbError"; 36 + } 37 + } 38 + 29 39 export function findSessions( 30 40 dbPath: string, 31 41 query: string, 32 42 limit: number, 33 43 ): { query: string; results: FindResult[] } { 34 - const db = openReadDb(dbPath); 35 - const recallLimit = Math.max(limit * 12, 50); 36 - const rawRows = [ 37 - ...searchMessageHits(db, query, recallLimit), 38 - ...searchSessionHits(db, query, recallLimit), 39 - ]; 40 - const results = rerankHits(rawRows, query, limit); 41 - db.close(); 42 - return { query, results }; 44 + return withReadDb(dbPath, (db) => { 45 + const recallLimit = Math.max(limit * 12, 50); 46 + const rawRows = [ 47 + ...searchMessageHits(db, query, recallLimit), 48 + ...searchSessionHits(db, query, recallLimit), 49 + ]; 50 + const results = rerankHits(rawRows, query, limit); 51 + return { query, results }; 52 + }); 43 53 } 44 54 45 55 export function getMessageRange( ··· 53 63 rangeEndSeq: number; 54 64 messages: ReturnType<typeof getMessagesForRange>; 55 65 } { 56 - const db = openReadDb(dbPath); 57 - const anchorSeq = resolveAnchorSeq(db, sessionUuid, options.seq, options.query); 58 - const session = getSessionRecord(db, sessionUuid); 59 - if (!session) throw new Error(`session not found: ${sessionUuid}`); 66 + return withReadDb(dbPath, (db) => { 67 + const anchorSeq = resolveAnchorSeq(db, sessionUuid, options.seq, options.query); 68 + const session = getSessionRecord(db, sessionUuid); 69 + if (!session) throw new Error(`session not found: ${sessionUuid}`); 60 70 61 - const rangeStartSeq = Math.max(0, anchorSeq - options.before); 62 - const rangeEndSeq = anchorSeq + options.after; 63 - const messages = getMessagesForRange(db, sessionUuid, rangeStartSeq, rangeEndSeq); 64 - db.close(); 65 - return { session, anchorSeq, rangeStartSeq, rangeEndSeq, messages }; 71 + const rangeStartSeq = Math.max(0, anchorSeq - options.before); 72 + const rangeEndSeq = anchorSeq + options.after; 73 + const messages = getMessagesForRange(db, sessionUuid, rangeStartSeq, rangeEndSeq); 74 + return { session, anchorSeq, rangeStartSeq, rangeEndSeq, messages }; 75 + }); 66 76 } 67 77 68 78 export function getMessagePage( ··· 78 88 hasMore: boolean; 79 89 messages: ReturnType<typeof getMessagesForPage>; 80 90 } { 81 - const db = openReadDb(dbPath); 82 - const session = getSessionRecord(db, sessionUuid); 83 - if (!session) throw new Error(`session not found: ${sessionUuid}`); 84 - const messages = getMessagesForPage(db, sessionUuid, offset, limit); 85 - const totalCount = session.messageCount; 86 - const hasMore = offset + messages.length < totalCount; 87 - db.close(); 88 - return { session, offset, limit, totalCount, hasMore, messages }; 91 + return withReadDb(dbPath, (db) => { 92 + const session = getSessionRecord(db, sessionUuid); 93 + if (!session) throw new Error(`session not found: ${sessionUuid}`); 94 + const messages = getMessagesForPage(db, sessionUuid, offset, limit); 95 + const totalCount = session.messageCount; 96 + const hasMore = offset + messages.length < totalCount; 97 + return { session, offset, limit, totalCount, hasMore, messages }; 98 + }); 89 99 } 90 100 91 101 export function listSessionSummaries( 92 102 dbPath: string, 93 103 query: SessionListQuery, 94 104 ): { query: SessionListQuery; results: SessionListEntry[] } { 95 - const db = openReadDb(dbPath); 96 - const results = listSessions(db, query); 97 - db.close(); 98 - return { query, results }; 105 + return withReadDb(dbPath, (db) => { 106 + const results = listSessions(db, query); 107 + return { query, results }; 108 + }); 99 109 } 100 110 101 111 export function getCurrentSessions( ··· 110 120 111 121 const db = new Database(stateDbPath, { readonly: true }); 112 122 try { 123 + if (!tableExists(db, "threads")) { 124 + throw new CurrentStateDbError( 125 + `unexpected codex state db schema: missing 'threads' table at ${stateDbPath}`, 126 + ); 127 + } 113 128 const candidates = db 114 129 .query<CurrentSessionCandidate, [string, number]>(` 115 130 SELECT ··· 131 146 } 132 147 133 148 export function collectStats(dbPath: string): StatsSummary { 134 - const db = openReadDb(dbPath); 135 - const counts = getStatsCounts(db); 136 - const topCwds = getTopCwds(db, 10); 137 - db.close(); 149 + const { counts, topCwds } = withReadDb(dbPath, (db) => ({ 150 + counts: getStatsCounts(db), 151 + topCwds: getTopCwds(db, 10), 152 + })); 138 153 139 154 let dbSizeBytes = 0; 140 155 try {
+69
sync-lock.test.ts
··· 1 + import { afterEach, describe, expect, test } from "bun:test"; 2 + import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; 3 + import { tmpdir } from "node:os"; 4 + import { join } from "node:path"; 5 + import { tryRemoveStaleLock } from "./sync-lock"; 6 + 7 + const tempDirs: string[] = []; 8 + 9 + afterEach(() => { 10 + for (const dir of tempDirs.splice(0)) { 11 + rmSync(dir, { recursive: true, force: true }); 12 + } 13 + }); 14 + 15 + describe("tryRemoveStaleLock", () => { 16 + test("removes the lock when it still matches the expected pid+createdAt", () => { 17 + const lockPath = makeLockPath(); 18 + const expected = { pid: 4242, createdAt: "2026-04-27T00:00:00.000Z" }; 19 + writeFileSync(lockPath, JSON.stringify(expected)); 20 + 21 + const removed = tryRemoveStaleLock(lockPath, expected); 22 + 23 + expect(removed).toBe(true); 24 + expect(existsSync(lockPath)).toBe(false); 25 + }); 26 + 27 + test("returns true when the lock file is already gone", () => { 28 + const lockPath = makeLockPath(); 29 + const expected = { pid: 4242, createdAt: "2026-04-27T00:00:00.000Z" }; 30 + // Lock never existed (cleaned up by another process between our reads). 31 + 32 + const removed = tryRemoveStaleLock(lockPath, expected); 33 + 34 + expect(removed).toBe(true); 35 + expect(existsSync(lockPath)).toBe(false); 36 + }); 37 + 38 + test("leaves a freshly written lock alone when pid changed", () => { 39 + const lockPath = makeLockPath(); 40 + const expected = { pid: 4242, createdAt: "2026-04-27T00:00:00.000Z" }; 41 + const fresh = { pid: 9999, createdAt: "2026-04-27T01:00:00.000Z" }; 42 + writeFileSync(lockPath, JSON.stringify(fresh)); 43 + 44 + const removed = tryRemoveStaleLock(lockPath, expected); 45 + 46 + expect(removed).toBe(false); 47 + expect(existsSync(lockPath)).toBe(true); 48 + const onDisk = JSON.parse(readFileSync(lockPath, "utf8")) as typeof fresh; 49 + expect(onDisk).toEqual(fresh); 50 + }); 51 + 52 + test("leaves a lock alone when only createdAt differs (same-pid retry)", () => { 53 + const lockPath = makeLockPath(); 54 + const expected = { pid: 4242, createdAt: "2026-04-27T00:00:00.000Z" }; 55 + const refreshed = { pid: 4242, createdAt: "2026-04-27T00:00:05.000Z" }; 56 + writeFileSync(lockPath, JSON.stringify(refreshed)); 57 + 58 + const removed = tryRemoveStaleLock(lockPath, expected); 59 + 60 + expect(removed).toBe(false); 61 + expect(existsSync(lockPath)).toBe(true); 62 + }); 63 + }); 64 + 65 + function makeLockPath(): string { 66 + const base = mkdtempSync(join(tmpdir(), "cxs-lock-")); 67 + tempDirs.push(base); 68 + return join(base, "index.sqlite.sync.lock"); 69 + }
+18 -2
sync-lock.ts
··· 47 47 48 48 const existing = readLockInfo(lockPath); 49 49 if (existing && !isProcessAlive(existing.pid)) { 50 - removeLockIfPresent(lockPath); 51 - continue; 50 + // If tryRemoveStaleLock returns false, another process took over the 51 + // lock between our read and our cleanup attempt — fall through to the 52 + // poll/timeout branch instead of clobbering its lock file. 53 + if (tryRemoveStaleLock(lockPath, existing)) continue; 52 54 } 53 55 54 56 if (Date.now() >= deadline) { ··· 64 66 if (!existing) return; 65 67 if (existing.pid !== lockInfo.pid || existing.createdAt !== lockInfo.createdAt) return; 66 68 removeLockIfPresent(lockPath); 69 + } 70 + 71 + // Why: avoid TOCTOU when clearing a stale lock. Between our `existing` read 72 + // and the actual `rmSync` call, another sync process can re-create the lock. 73 + // Re-read and compare pid+createdAt: only delete the lock file if it's still 74 + // the same one we judged dead. Exported for unit tests. 75 + export function tryRemoveStaleLock(lockPath: string, expected: SyncLockInfo): boolean { 76 + const reChecked = readLockInfo(lockPath); 77 + if (!reChecked) return true; 78 + if (reChecked.pid !== expected.pid || reChecked.createdAt !== expected.createdAt) { 79 + return false; 80 + } 81 + removeLockIfPresent(lockPath); 82 + return true; 67 83 } 68 84 69 85 function readLockInfo(lockPath: string): SyncLockInfo | null {