diff --git a/README.md b/README.md index 55d64d2..a37d5f8 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,10 @@ shlog read-page --offset 0 --limit 20 If `find` prints `next:` or JSON includes `nextAction`, refresh the suggested coverage and retry before treating the results as complete. Codex active-session tail drift is softer: `status` may report `freshness: "stale"` with `staleReason: "source_content_changed"` and `recommendedAction: "query"` when an existing JSONL is still growing; query/read first, and sync only when the latest tail or a strict completeness claim matters. +The same distinction applies during `sync`: a Codex JSONL that only appends after its bounded read no longer aborts the whole run. Stable sources and the bounded prefix are committed, the successful sync summary marks `coverage.staleReason: "source_content_changed"`, and the next sync fills the tail. Truncation, prefix rewrite/replacement, and source-set changes still fail strict sync. + +If a new, unindexed Codex file already changed before its bounded read opened, Sherlog cannot prove that the old snapshot prefix was not rewritten. It conservatively defers that file and complete coverage, commits other stable sources, and returns `coverage.reason: "active_source_deferred"` with `recommendedAction: "sync"`. + For project-scoped agent work, check and refresh only that coverage: ```bash diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 932f8bb..9db7e17 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -50,7 +50,7 @@ Codex adapter 会把原有 `sessionUuid` 映射为 source-aware identity: [indexer.ts](/Users/envvar/work/repos/cxs/src/indexer.ts) 按显式 selector 扫描选定 source 的 session snapshot。当前公开 source 可以是 Codex、Claude Code 或 Pi;增量判断仍基于文件 `mtime`、`size` 和 `indexVersion`。 -strict sync 默认只更新当前 source snapshot 中仍可见的文件,并保留已经进入 SQLite 的旧 session。这样 raw JSONL 的维护、移动或删除不会让 Sherlog 的历史查询丢失。只有显式传 `--prune` 时,sync 才会把 selector 范围收敛成当前 source snapshot,并删除同一 source 中已不存在的旧 index row。一个 source 的 sync/prune 不会删除另一个 source 的数据。当前 source 中仍存在但被过滤或不能解析成 session 的文件仍按当前状态处理。 +strict sync 默认只更新当前 source snapshot 中仍可见的文件,并保留已经进入 SQLite 的旧 session。Codex adapter 的读取固定在本轮捕获的 byte 边界;若相同 file set 中某个活跃 JSONL 在读后只追加,indexer 校验已读前缀摘要与既有投影后允许提交起始边界,并把 coverage 标为 `source_content_changed` soft stale。尚未索引的新文件若在 parser 打开前已变化,无法证明 snapshot 前缀安全,indexer 会延后该文件和 complete coverage(`active_source_deferred`),但在同一事务提交其他稳定 operation。截断、可证明的前缀改写/替换、file set 变化和其他 source 的中途变化仍阻断事务。这样既不会因当前对话增长阻塞稳定 source,也不会把未读或未证明的尾部发布成 fresh coverage。只有显式传 `--prune` 时,sync 才会把 selector 范围收敛成当前 source snapshot,并删除同一 source 中已不存在的旧 index row。一个 source 的 sync/prune 不会删除另一个 source 的数据。当前 source 中仍存在但被过滤或不能解析成 session 的文件仍按当前状态处理。 [parser.ts](/Users/envvar/work/repos/cxs/src/parser.ts) 只抽取 `event_msg` 里的: diff --git a/docs/INDEX_COVERAGE_DESIGN.md b/docs/INDEX_COVERAGE_DESIGN.md index 767b49a..96a7f2b 100644 --- a/docs/INDEX_COVERAGE_DESIGN.md +++ b/docs/INDEX_COVERAGE_DESIGN.md @@ -227,6 +227,8 @@ Coverage 可以蕴含更窄 selector。 - 不使用 source inventory 作为召回来源 - coverage missing 或 source file 集合变化时即使已有结果也必须返回 `nextAction`,避免 agent 把旧索引结果当完整历史结论 - coverage 仅因既有 source file 内容变化而 stale 时归类为 `source_content_changed`;该判断必须基于 source file set fingerprint / path set,而不是 file count。Codex 当前会话 JSONL 尾部追加可把它降级为 `recommendedAction: "query"` 的软 stale,非空 `find` 不应强制 agent 每次同步。其他 source 仍可保守推荐 `sync`。 +- strict sync 读取 Codex 文件时固定本轮 byte 边界。相同 file set 中经前缀摘要与既有索引投影证明的纯追加可以提交该边界,并在成功摘要中返回 `coverage.staleReason: "source_content_changed"` / `recommendedAction: "query"`;未读尾部不进入本轮 coverage。截断、前缀改写、替换、file set 变化和非 Codex 中途变化仍失败。 +- 尚未索引的新 Codex 文件若在 parser 建立有界读取前已变化,旧 snapshot 前缀无法自证;本轮延后该文件且不写 complete coverage,成功摘要返回 `coverage.reason: "active_source_deferred"` / `recommendedAction: "sync"`,其他稳定 operation 仍原子提交。 ### list diff --git a/docs/USAGE.md b/docs/USAGE.md index 33fbf6a..7f8cdab 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -100,7 +100,7 @@ By default, source-scoped commands read Codex sessions from `~/.codex/sessions`. export SHLOG_DATA_DIR="$HOME/.config/shlog" ``` -Sync is strict by default. If any selected file fails to parse or write, `sync` exits non-zero with per-file diagnostics and does not commit partial coverage. On success, strict sync updates the selected index slice for files that are currently visible in the source snapshot and writes complete coverage for that snapshot. Previously indexed sessions whose source JSONL later disappears are retained by default, so raw log maintenance does not make historical `shlog find` or `read-*` results disappear. +Sync is strict by default. If any selected file fails to parse or write, `sync` exits non-zero with per-file diagnostics and does not commit partial coverage. Codex active-session append is the narrow exception to the old “source must stay byte-for-byte static” rule: each Codex file is read only through the byte boundary captured for the sync, and a verified append after that boundary does not fail the command. Stable sources and the bounded active prefix are committed together; JSON reports `coverage.staleReason: "source_content_changed"` and `recommendedAction: "query"`, and a later sync fills the tail. If an unindexed Codex file already changed before its bounded read opened, sync cannot prove that the old prefix was append-only; it defers that file and complete coverage while committing other stable operations, and reports `coverage.reason: "active_source_deferred"` with `recommendedAction: "sync"`. Truncation, verified prefix rewrite/replacement, source-file-set changes, and mid-sync changes from other sources remain strict failures. Previously indexed sessions whose source JSONL later disappears are retained by default, so raw log maintenance does not make historical `shlog find` or `read-*` results disappear. Pass `--prune` only when you explicitly want to delete indexed sessions that are no longer present in the selected source snapshot. Pass `--best-effort` only when you explicitly want successful files written despite failures; best-effort sync does not record complete coverage. diff --git a/package-lock.json b/package-lock.json index 2e94f49..f43313e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@act0r/sherlog", - "version": "0.3.17", + "version": "0.3.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@act0r/sherlog", - "version": "0.3.17", + "version": "0.3.18", "license": "MIT", "os": [ "darwin", diff --git a/package.json b/package.json index 2955620..1b77926 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@act0r/sherlog", - "version": "0.3.17", + "version": "0.3.18", "type": "module", "description": "Progressive search CLI for local Codex, Claude Code, and Pi session logs", "license": "MIT", diff --git a/skill-packages/sherlog/SKILL.md b/skill-packages/sherlog/SKILL.md index fa99723..4ef01ef 100644 --- a/skill-packages/sherlog/SKILL.md +++ b/skill-packages/sherlog/SKILL.md @@ -41,6 +41,8 @@ description: "Use proactively for local Codex history and personal setup archaeo - `index_unavailable`: 普通首次安装可 `sync`;明确项目范围优先 `sync --cwd `。 - `session_not_found`: 只说明当前 index 没有这个 `sessionRef`;按 `nextAction` 检查 source/id/coverage,必要时同 source scoped sync 后重试。 - `stale_or_missing_coverage`: 先判断是否需要完整结论。Codex `source_content_changed` + `recommendedAction: "query"` 常是活跃尾部软 stale,可先 query/read;coverage 缺失、source set 变化、非 Codex 保守同步、零结果可疑或用户要求完整性时,按提示同范围 sync 后重试。 +- `sync` 成功但 `coverage.staleReason: "source_content_changed"`: Codex 活跃 JSONL 在读取后继续追加;已读边界和其他稳定 source 已安全落库,可继续 query/read,稍后再 sync 补尾部。截断、前缀改写和 source set 变化仍是失败,不要把它们当成同一类软 stale。 +- `sync` 成功但 `coverage.reason: "active_source_deferred"`: 尚未索引的新 Codex 文件在有界读取前已变化,无法证明是纯追加;该文件和 complete coverage 被保守延后,其他稳定 source 已落库。按 `recommendedAction: "sync"` 重试,不要把本轮结果当完整覆盖。 - fresh coverage 下仍无结果,才说没找到。 ## 不适用 @@ -66,4 +68,4 @@ description: "Use proactively for local Codex history and personal setup archaeo - `references/advanced-queries.md`: metadata SQLite projection、CJK/query 语义、缩范围策略。 - `references/json-schema.md`: 需要解析完整 JSON 字段或 error shape。 -# skill-sync: distributable sherlog skill package, compressed entrypoint, 2026-06-22 +# skill-sync: distributable sherlog skill package, compressed entrypoint, 2026-07-12 diff --git a/skill-packages/sherlog/references/cli-surface.md b/skill-packages/sherlog/references/cli-surface.md index 97c35e4..59a8eac 100644 --- a/skill-packages/sherlog/references/cli-surface.md +++ b/skill-packages/sherlog/references/cli-surface.md @@ -81,7 +81,7 @@ Options: | `--prune` | 显式删除所选 source 中已经消失的旧索引记录 | | `--json` | 成功时把 `SyncSummary` 打到 stdout | -严格模式成功时,`sync` 会更新当前 source snapshot 中仍可见的文件,并写 complete coverage。默认保留已经索引过、但 raw JSONL 后来从 source 中消失的旧 session;查询时不需要切换 root 去追 raw 文件位置。 +严格模式成功时,`sync` 会更新当前 source snapshot 中仍可见的文件,并写 coverage。Codex JSONL 若在读取后仅继续追加,命令仍成功:coverage 对应已读 byte 边界,并返回 `staleReason: "source_content_changed"`、`recommendedAction: "query"`;其他稳定 source 同时落库,后续 sync 再补活跃尾部。尚未索引的新 Codex 文件若在有界读取前已变化、无法证明前缀安全,本轮会保守延后该文件和 coverage,成功摘要返回 `reason: "active_source_deferred"`、`recommendedAction: "sync"`,其他稳定 source 仍落库。截断、可证明的前缀改写、source file set 变化以及非 Codex source 的同步中变化仍保持严格失败。默认保留已经索引过、但 raw JSONL 后来从 source 中消失的旧 session;查询时不需要切换 root 去追 raw 文件位置。 只有显式传 `--prune` 时,`sync` 才把 selector 范围内的 index 与当前 source snapshot 对齐;源文件已删除的旧 row 会被移除,并计入 `removed`。源文件仍存在但被过滤或不再能解析成 session 时,仍按当前文件状态删除或报错。 diff --git a/skill-packages/sherlog/references/failure-cookbook.md b/skill-packages/sherlog/references/failure-cookbook.md index baec918..7ac0e15 100644 --- a/skill-packages/sherlog/references/failure-cookbook.md +++ b/skill-packages/sherlog/references/failure-cookbook.md @@ -184,6 +184,8 @@ sqlite3 -readonly "$DB_PATH" \ | 旧安装版 `sync` 缺 selector | stdout | `{ "error": { "code": "selector_required", "message": "..." } }` | | `sync` invalid selector | stdout | `{ "error": { "code": "invalid_selector", "message": "..." } }` | | `sync` per-file 错 | stderr | `SyncSummary`,看 `errors / errorDetails[]` | +| `sync` Codex 活跃尾部软 stale | stdout | 成功的 `SyncSummary`;`coverage.written=true`、`staleReason=source_content_changed`、`recommendedAction=query`,稍后重试补尾部 | +| `sync` Codex 新活跃文件读取前已变化 | stdout | 成功的 `SyncSummary`;`coverage.written=false`、`reason=active_source_deferred`、`recommendedAction=sync`,稳定 source 已落库,重试补该文件 | | `sync` 锁超时 | stderr | `{ "error": }` | | `status` invalid selector | stdout | `{ "error": { "code": "invalid_selector", "message": "..." } }` | | `find / read-range / read-page / list / stats` 索引不存在 | stdout | `{ "error": { "code": "index_unavailable", "message": "...", "dbPath": "...", "hint": "...", "nextAction": { "kind": "bootstrap_index", "commands": [...] } } }` | diff --git a/skill-packages/sherlog/references/json-schema.md b/skill-packages/sherlog/references/json-schema.md index 25218e0..74752f2 100644 --- a/skill-packages/sherlog/references/json-schema.md +++ b/skill-packages/sherlog/references/json-schema.md @@ -228,10 +228,16 @@ errors in `--json` mode for expected index setup and read failures: sourceFileCount: number; indexedSessionCount: number; reason?: string; + staleReason?: "source_content_changed"; + recommendedAction?: "query" | "sync"; }; } ``` +当 Codex JSONL 在 strict sync 读取其起始 byte 边界后仅继续追加时,sync 仍以成功结束,稳定 source 和已读前缀一起提交;`coverage.staleReason` 标记当前活跃尾部尚未包含,`recommendedAction: "query"` 表示现有 index 可查询,稍后再 sync 可补齐尾部。截断、前缀摘要不一致、同大小替换或 source file set 变化不会得到这两个字段,而是继续走非零失败与 `errorDetails`。 + +若尚未索引的新 Codex 文件在 parser 建立有界读取前已经变化,sync 无法证明旧 snapshot 前缀未被改写,因此不写该文件,也不写 complete coverage;其他稳定 operation 仍在同一事务提交。此时 `coverage.written=false`、`reason="active_source_deferred"`、`recommendedAction="sync"`,下一次 sync 再补该文件。 + ## Shared Records `SessionRecord`: diff --git a/src/db.ts b/src/db.ts index d95374a..fef732e 100644 --- a/src/db.ts +++ b/src/db.ts @@ -7,7 +7,14 @@ export { withReadDb, withSourceAwareReadDb, } from "./db/connection"; -export { getIndexedSessionMeta, getIndexedSessionMetas, deleteSessionByFilePath, replaceSession, getSessionRecord } from "./db/session-store"; +export { + getIndexedSessionMeta, + getIndexedSessionMetas, + getIndexedSessionProjection, + deleteSessionByFilePath, + replaceSession, + getSessionRecord, +} from "./db/session-store"; export { getMessagesForPage, getMessagesForRange } from "./db/message-store"; export { listSessions } from "./db/list-store"; export { getStatsCounts, getTopCwds } from "./db/stats-store"; diff --git a/src/db/session-store.ts b/src/db/session-store.ts index e144fd8..c7602c2 100644 --- a/src/db/session-store.ts +++ b/src/db/session-store.ts @@ -1,5 +1,5 @@ import { tokenizedText } from "../tokenize"; -import { DEFAULT_SESSION_SOURCE_ID, isSessionSourceId, type ParsedSession, type SessionRecord, type SessionSourceId } from "../types"; +import { DEFAULT_SESSION_SOURCE_ID, isSessionSourceId, type ParsedMessage, type ParsedSession, type SessionRecord, type SessionSourceId } from "../types"; import type { Db } from "./shared"; import { sessionRootFromFile } from "./sql"; @@ -54,6 +54,42 @@ export function getIndexedSessionMetas( return map; } +export interface IndexedSessionProjection { + sessionUuid: string; + title: string; + summaryText: string; + compactText: string; + reasoningSummaryText: string; + cwd: string; + startedAt: string; + endedAt: string; + messages: ParsedMessage[]; +} + +export function getIndexedSessionProjection( + db: Db, + filePath: string, + sourceId: SessionSourceId = DEFAULT_SESSION_SOURCE_ID, +): IndexedSessionProjection | null { + const row = db.prepare<[SessionSourceId, string], Omit & { id: number }>(` + SELECT id, session_uuid AS sessionUuid, title, summary_text AS summaryText, + compact_text AS compactText, reasoning_summary_text AS reasoningSummaryText, + cwd, started_at AS startedAt, ended_at AS endedAt + FROM sessions + WHERE source_id = ? AND file_path = ? + LIMIT 1 + `).get(sourceId, filePath); + if (!row) return null; + const messages = db.prepare<[number], ParsedMessage>(` + SELECT role, content_text AS contentText, timestamp, seq, source_kind AS sourceKind + FROM messages + WHERE session_id = ? + ORDER BY seq ASC + `).all(row.id); + const { id: _id, ...session } = row; + return { ...session, messages }; +} + export function deleteSessionByFilePath(db: Db, filePath: string, sourceId: SessionSourceId = DEFAULT_SESSION_SOURCE_ID): void { const row = db .prepare<[SessionSourceId, string], { id: number }>("SELECT id FROM sessions WHERE source_id = ? AND file_path = ? LIMIT 1") diff --git a/src/format.test.ts b/src/format.test.ts index fcf2598..2e17e88 100644 --- a/src/format.test.ts +++ b/src/format.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { printFindResults, printReadPage, printReadRangeResult, printStats } from "./format"; -import type { FindResult, MessageRecord, SessionRecord } from "./types"; +import { printFindResults, printReadPage, printReadRangeResult, printStats, printSyncSummary } from "./format"; +import type { FindResult, MessageRecord, SessionRecord, SyncSummary } from "./types"; import chalk from "chalk"; const stripAnsi = (value: string): string => value.replace(/\[[0-9;]*m/g, ""); @@ -63,6 +63,68 @@ function makeMessage(seq: number): MessageRecord { }; } +describe("printSyncSummary", () => { + afterEach(() => vi.restoreAllMocks()); + + test("distinguishes an active Codex soft-stale tail from failure", () => { + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const selector = { source: "codex" as const, kind: "all" as const, root: "/tmp/sessions" }; + const summary: SyncSummary = { + scanned: 2, + added: 2, + updated: 0, + skipped: 0, + filtered: 0, + removed: 0, + errors: 0, + errorDetails: [], + selector, + coverage: { + written: true, + selector, + sourceFingerprint: "before", + sourceFileSetFingerprint: "same-files", + sourceFileCount: 2, + indexedSessionCount: 2, + staleReason: "source_content_changed", + recommendedAction: "query", + }, + }; + + printSyncSummary(summary); + + expect(captured(consoleLogSpy)).toContain("coverage: written (soft stale: active Codex tail changed; query is available, retry sync later)"); + }); + + test("explains when an unproven active Codex source was deferred", () => { + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const selector = { source: "codex" as const, kind: "all" as const, root: "/tmp/sessions" }; + printSyncSummary({ + scanned: 2, + added: 1, + updated: 0, + skipped: 0, + filtered: 0, + removed: 0, + errors: 0, + errorDetails: [], + selector, + coverage: { + written: false, + selector, + sourceFingerprint: "before", + sourceFileSetFingerprint: "same-files", + sourceFileCount: 2, + indexedSessionCount: 0, + reason: "active_source_deferred", + recommendedAction: "sync", + }, + }); + + expect(captured(consoleLogSpy)).toContain("coverage: not written (active Codex source changed before read; stable sources committed, retry sync)"); + }); +}); + describe("printStats", () => { let consoleLogSpy: any; diff --git a/src/format.ts b/src/format.ts index a093362..f30127f 100644 --- a/src/format.ts +++ b/src/format.ts @@ -25,7 +25,13 @@ export function printSyncSummary(summary: SyncSummary): void { console.log(`filtered: ${summary.filtered}`); console.log(`removed: ${summary.removed}`); console.log(`errors: ${summary.errors}`); - console.log(`coverage: ${summary.coverage.written ? "written" : `not written (${summary.coverage.reason ?? "unknown"})`}`); + const writtenCoverage = summary.coverage.staleReason === "source_content_changed" + ? "written (soft stale: active Codex tail changed; query is available, retry sync later)" + : "written"; + const unwrittenCoverage = summary.coverage.reason === "active_source_deferred" + ? "not written (active Codex source changed before read; stable sources committed, retry sync)" + : `not written (${summary.coverage.reason ?? "unknown"})`; + console.log(`coverage: ${summary.coverage.written ? writtenCoverage : unwrittenCoverage}`); if (summary.errorDetails.length > 0) { console.log(); console.log(chalk.bold.red("sync errors")); diff --git a/src/indexer.test.ts b/src/indexer.test.ts index 3339df3..159f7e0 100644 --- a/src/indexer.test.ts +++ b/src/indexer.test.ts @@ -1,17 +1,21 @@ -import { afterEach, describe, expect, test } from "vitest"; -import { chmodSync, existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { appendFileSync, chmodSync, existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawn } from "node:child_process"; import { openReadDb, openWriteDb } from "./db"; import { SyncError, syncSessions } from "./indexer"; -import { findSessions } from "./query"; +import { findSessions, getMessagePage } from "./query"; +import { codexSourceAdapter } from "./sources/codex"; +import { parseCodexSession } from "./sources/codex-parser"; +import { collectStatus } from "./status"; import { syncLockPath } from "./sync-lock"; const tempDirs: string[] = []; const unreadableFiles: string[] = []; afterEach(() => { + vi.restoreAllMocks(); for (const filePath of unreadableFiles.splice(0)) { try { chmodSync(filePath, 0o644); @@ -25,6 +29,279 @@ afterEach(() => { }); describe("syncSessions", () => { + test("commits a stable Codex snapshot when an active JSONL appends during sync", async () => { + const base = mkdtempSync(join(tmpdir(), "cxs-indexer-active-append-")); + tempDirs.push(base); + const root = join(base, "sessions"); + const day = join(root, "2026", "07", "12"); + mkdirSync(day, { recursive: true }); + + const activePath = join(day, "rollout-2026-07-12T10-00-00-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.jsonl"); + const stablePath = join(day, "rollout-2026-07-12T09-00-00-bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb.jsonl"); + writeFileSync( + activePath, + [ + line("session_meta", { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", cwd: "/tmp/active-append" }), + line("event_msg", { type: "user_message", message: "active prefix" }), + ].join("\n"), + ); + writeFileSync( + stablePath, + [ + line("session_meta", { id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", cwd: "/tmp/active-append" }), + line("event_msg", { type: "user_message", message: "stable source" }), + ].join("\n"), + ); + + const originalParseFile = codexSourceAdapter.parseFile.bind(codexSourceAdapter); + let appended = false; + vi.spyOn(codexSourceAdapter, "parseFile").mockImplementation(async (file) => { + const parsed = await originalParseFile(file); + if (file.filePath === activePath && !appended) { + appended = true; + appendFileSync(activePath, `\n${line("event_msg", { type: "agent_message", message: "appended tail" })}`); + } + return parsed; + }); + + const dbPath = join(base, "index.sqlite"); + const selector = { kind: "all" as const, root }; + const first = await syncSessions({ dbPath, selector }); + + expect(first.errors).toBe(0); + expect(first.added).toBe(2); + expect(first.coverage.written).toBe(true); + expect(first.coverage.staleReason).toBe("source_content_changed"); + expect(first.coverage.recommendedAction).toBe("query"); + const stableFind = findSessions(dbPath, "stable source", 5, selector); + expect(stableFind.results).toHaveLength(1); + expect(stableFind.nextAction).toBeUndefined(); + expect(findSessions(dbPath, "appended tail", 5, selector).results).toHaveLength(0); + expect(getMessagePage(dbPath, "codex:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", 0, 10).messages) + .toEqual(expect.arrayContaining([expect.objectContaining({ contentText: "stable source" })])); + + const status = await collectStatus({ dbPath, selector }); + expect(status.requestedCoverage).toMatchObject({ + freshness: "stale", + staleReason: "source_content_changed", + recommendedAction: "query", + complete: false, + }); + + vi.restoreAllMocks(); + const second = await syncSessions({ dbPath, selector }); + + expect(second.updated).toBe(1); + expect(findSessions(dbPath, "appended tail", 5, selector).results).toHaveLength(1); + }); + + test("updates another stable source when an already-indexed Codex file starts appending mid-sync", async () => { + const base = mkdtempSync(join(tmpdir(), "cxs-indexer-existing-active-append-")); + tempDirs.push(base); + const root = join(base, "sessions"); + const day = join(root, "2026", "07", "12"); + mkdirSync(day, { recursive: true }); + const activePath = join(day, "rollout-2026-07-12T10-00-00-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.jsonl"); + const stablePath = join(day, "rollout-2026-07-12T09-00-00-bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb.jsonl"); + writeFileSync(activePath, [ + line("session_meta", { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", cwd: "/tmp/existing-active" }), + line("event_msg", { type: "user_message", message: "existing active prefix" }), + ].join("\n")); + writeFileSync(stablePath, [ + line("session_meta", { id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", cwd: "/tmp/existing-active" }), + line("event_msg", { type: "user_message", message: "stable version one" }), + ].join("\n")); + + const dbPath = join(base, "index.sqlite"); + const selector = { kind: "all" as const, root }; + await syncSessions({ dbPath, selector }); + appendFileSync(stablePath, `\n${line("event_msg", { type: "agent_message", message: "stable version two" })}`); + + const originalParseFile = codexSourceAdapter.parseFile.bind(codexSourceAdapter); + let appended = false; + vi.spyOn(codexSourceAdapter, "parseFile").mockImplementation(async (file) => { + const parsed = await originalParseFile(file); + if (file.filePath === stablePath && !appended) { + appended = true; + appendFileSync(activePath, `\n${line("event_msg", { type: "agent_message", message: "new active tail" })}`); + } + return parsed; + }); + + const summary = await syncSessions({ dbPath, selector }); + + expect(summary.updated).toBe(1); + expect(summary.skipped).toBe(1); + expect(summary.coverage).toMatchObject({ + written: true, + staleReason: "source_content_changed", + recommendedAction: "query", + }); + expect(findSessions(dbPath, "stable version two", 5, selector).results).toHaveLength(1); + expect(findSessions(dbPath, "new active tail", 5, selector).results).toHaveLength(0); + }); + + test("defers an unindexed Codex file that changed before its bounded read while committing stable sources", async () => { + const base = mkdtempSync(join(tmpdir(), "cxs-indexer-pre-read-change-")); + tempDirs.push(base); + const root = join(base, "sessions"); + const day = join(root, "2026", "07", "12"); + mkdirSync(day, { recursive: true }); + const activePath = join(day, "rollout-2026-07-12T10-00-00-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.jsonl"); + const stablePath = join(day, "rollout-2026-07-12T09-00-00-bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb.jsonl"); + writeFileSync(activePath, [ + line("session_meta", { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", cwd: "/tmp/pre-read-change" }), + line("event_msg", { type: "user_message", message: "unproven original prefix" }), + ].join("\n")); + writeFileSync(stablePath, [ + line("session_meta", { id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", cwd: "/tmp/pre-read-change" }), + line("event_msg", { type: "user_message", message: "stable source survives" }), + ].join("\n")); + + const originalParseFile = codexSourceAdapter.parseFile.bind(codexSourceAdapter); + let changed = false; + vi.spyOn(codexSourceAdapter, "parseFile").mockImplementation(async (file) => { + if (file.filePath === activePath && !changed) { + changed = true; + writeFileSync(activePath, [ + line("session_meta", { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", cwd: "/tmp/pre-read-change" }), + line("event_msg", { type: "user_message", message: "rewritten before read" }), + line("event_msg", { type: "agent_message", message: "larger unproven tail" }), + ].join("\n")); + } + return originalParseFile(file); + }); + + const dbPath = join(base, "index.sqlite"); + const selector = { kind: "all" as const, root }; + const first = await syncSessions({ dbPath, selector }); + + expect(first.errors).toBe(0); + expect(first.added).toBe(1); + expect(first.coverage).toMatchObject({ + written: false, + reason: "active_source_deferred", + recommendedAction: "sync", + }); + expect(findSessions(dbPath, "stable source survives", 5, selector).results).toHaveLength(1); + expect(findSessions(dbPath, "rewritten before read", 5, selector).results).toHaveLength(0); + + vi.restoreAllMocks(); + const second = await syncSessions({ dbPath, selector }); + expect(second.added).toBe(1); + expect(second.coverage.written).toBe(true); + expect(findSessions(dbPath, "rewritten before read", 5, selector).results).toHaveLength(1); + }); + + test("defers an unindexed Codex file rewritten after fd open but before stream read", async () => { + const base = mkdtempSync(join(tmpdir(), "cxs-indexer-open-read-window-")); + tempDirs.push(base); + const root = join(base, "sessions"); + const day = join(root, "2026", "07", "12"); + mkdirSync(day, { recursive: true }); + const activePath = join(day, "rollout-2026-07-12T10-00-00-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.jsonl"); + const stablePath = join(day, "rollout-2026-07-12T09-00-00-bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb.jsonl"); + writeFileSync(activePath, [ + line("session_meta", { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", cwd: "/tmp/open-read-window" }), + line("event_msg", { type: "user_message", message: "fd-open original prefix" }), + ].join("\n")); + writeFileSync(stablePath, [ + line("session_meta", { id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", cwd: "/tmp/open-read-window" }), + line("event_msg", { type: "user_message", message: "fd-open stable source" }), + ].join("\n")); + + const originalParseFile = codexSourceAdapter.parseFile.bind(codexSourceAdapter); + let changed = false; + vi.spyOn(codexSourceAdapter, "parseFile").mockImplementation((file) => { + if (file.filePath !== activePath || changed) return originalParseFile(file); + changed = true; + return parseCodexSession(file, { + beforeRead() { + writeFileSync(activePath, [ + line("session_meta", { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", cwd: "/tmp/open-read-window" }), + line("event_msg", { type: "user_message", message: "rewritten after fd open" }), + line("event_msg", { type: "agent_message", message: "larger fd-open tail" }), + ].join("\n")); + }, + }); + }); + + const dbPath = join(base, "index.sqlite"); + const selector = { kind: "all" as const, root }; + const summary = await syncSessions({ dbPath, selector }); + + expect(summary).toMatchObject({ + added: 1, + errors: 0, + coverage: { + written: false, + reason: "active_source_deferred", + recommendedAction: "sync", + }, + }); + expect(findSessions(dbPath, "fd-open stable source", 5, selector).results).toHaveLength(1); + expect(findSessions(dbPath, "rewritten after fd open", 5, selector).results).toHaveLength(0); + }); + + test.each([ + { + name: "truncates the file", + mutate(filePath: string) { + writeFileSync(filePath, line("session_meta", { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", cwd: "/tmp/destructive-change" })); + }, + }, + { + name: "rewrites the indexed prefix before appending", + mutate(filePath: string) { + writeFileSync( + filePath, + [ + line("session_meta", { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", cwd: "/tmp/destructive-change" }), + line("event_msg", { type: "user_message", message: "rewritten prefix" }), + line("event_msg", { type: "agent_message", message: "larger replacement tail" }), + ].join("\n"), + ); + }, + }, + ])("strict sync still fails when a Codex source $name during sync", async ({ mutate }) => { + const base = mkdtempSync(join(tmpdir(), "cxs-indexer-destructive-change-")); + tempDirs.push(base); + const root = join(base, "sessions"); + const day = join(root, "2026", "07", "12"); + mkdirSync(day, { recursive: true }); + const activePath = join(day, "rollout-2026-07-12T10-00-00-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.jsonl"); + writeFileSync( + activePath, + [ + line("session_meta", { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", cwd: "/tmp/destructive-change" }), + line("event_msg", { type: "user_message", message: "original prefix" }), + ].join("\n"), + ); + + const originalParseFile = codexSourceAdapter.parseFile.bind(codexSourceAdapter); + let changed = false; + vi.spyOn(codexSourceAdapter, "parseFile").mockImplementation(async (file) => { + const parsed = await originalParseFile(file); + if (file.filePath === activePath && !changed) { + changed = true; + mutate(activePath); + } + return parsed; + }); + + const dbPath = join(base, "index.sqlite"); + const failure = await syncSessions({ dbPath, selector: { kind: "all", root } }).catch((error) => error); + + expect(failure).toBeInstanceOf(SyncError); + expect(failure.summary.errorDetails).toEqual([ + { filePath: "(selector)", message: "source changed during strict sync" }, + ]); + const db = openReadDb(dbPath); + const counts = db.prepare("SELECT COUNT(*) AS count FROM sessions").get() as { count: number }; + db.close(); + expect(counts.count).toBe(0); + }); + test("fails loudly with per-file diagnostics and leaves no partial index by default", async () => { const { base, dbPath, sessionsRoot, badFilePath } = createFixture(); diff --git a/src/indexer.ts b/src/indexer.ts index cc7bcde..fbcfc30 100644 --- a/src/indexer.ts +++ b/src/indexer.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; import { DEFAULT_DB_PATH, INDEX_VERSION, ensureDataDir, isCurrentIndexVersion } from "./env"; import { cleanupMismatchedMessagesForSelector, @@ -6,6 +8,7 @@ import { deleteSessionByFilePath, getIndexedSessionMeta, getIndexedSessionMetas, + getIndexedSessionProjection, openWriteDb, replaceCoverage, replaceSession, @@ -14,7 +17,7 @@ import { canonicalizeSelector, selectorSource } from "./selector"; import { getSessionSourceAdapter } from "./sources"; import { SourceInventoryError } from "./source-inventory"; import { withSyncLock } from "./sync-lock"; -import type { CoverageWriteSummary, ParsedSession, Selector, SessionSourceId, SourceFileMeta, SyncErrorDetail, SyncSummary } from "./types"; +import type { CoverageWriteSummary, ParsedSession, ParseSessionResult, Selector, SessionSourceId, SourceFileMeta, SyncErrorDetail, SyncSummary } from "./types"; interface SyncOptions { dbPath?: string; @@ -70,6 +73,7 @@ export async function syncSessions(options: SyncOptions = {}): Promise(); + const readResults = new Map(); const summary: SyncSummary = { scanned: sourceSnapshot.fileCount, @@ -91,6 +95,7 @@ export async function syncSessions(options: SyncOptions = {}): Promise 0 && !options.bestEffort) { throw new SyncError(summary); } @@ -148,6 +176,7 @@ async function collectSyncOperations( files: readonly SourceFileMeta[], operations: SyncOperation[], unchangedFilePaths: Set, + readResults: Map, summary: SyncSummary ): Promise { // Pre-fetch indexed session metadata for all files using batching to avoid N+1 queries @@ -172,6 +201,7 @@ async function collectSyncOperations( } const parsed = await source.parseFile(file); + readResults.set(filePath, parsed); if (parsed.kind === "filtered") { operations.push({ kind: "filtered", filePath }); continue; @@ -200,6 +230,107 @@ async function collectSyncOperations( await Promise.all(workers); } +type CodexSourceChange = + | { kind: "append" } + | { kind: "deferred"; filePaths: Set } + | { kind: "reject" }; + +async function classifyCodexSourceChange( + source: ReturnType, + db: ReturnType, + before: { fileSetFingerprint: string; fileCount: number; files: SourceFileMeta[] }, + after: { fileSetFingerprint: string; fileCount: number; files: SourceFileMeta[] }, + readResults: Map, +): Promise { + if (source.id !== "codex") return { kind: "reject" }; + if (after.fileCount !== before.fileCount || after.fileSetFingerprint !== before.fileSetFingerprint) return { kind: "reject" }; + + const afterByPath = new Map(after.files.map((file) => [file.filePath, file])); + const deferred = new Set(); + for (const beforeFile of before.files) { + const afterFile = afterByPath.get(beforeFile.filePath); + if (!afterFile) return { kind: "reject" }; + if (sameSourceFileMeta(beforeFile, afterFile)) continue; + if (afterFile.size <= beforeFile.size) return { kind: "reject" }; + + const hadReadResult = readResults.has(beforeFile.filePath); + const parsed = readResults.get(beforeFile.filePath) ?? await source.parseFile(beforeFile); + const proof = parsed.sourceRead; + if (!proof || proof.byteCount !== beforeFile.size) return { kind: "reject" }; + if (await fingerprintFilePrefix(beforeFile.filePath, beforeFile.size) !== proof.contentFingerprint) return { kind: "reject" }; + const projection = indexedProjectionAppendStatus(db, beforeFile.filePath, parsed, !hadReadResult); + const changedBeforeRead = proof.openedMtimeMs !== beforeFile.mtimeMs || proof.openedSize !== beforeFile.size; + const changedDuringRead = proof.completedMtimeMs !== proof.openedMtimeMs || proof.completedSize !== proof.openedSize; + if (projection === "missing" && (changedBeforeRead || changedDuringRead)) { + deferred.add(beforeFile.filePath); + continue; + } + if (projection === "unsafe") return { kind: "reject" }; + } + return deferred.size > 0 ? { kind: "deferred", filePaths: deferred } : { kind: "append" }; +} + +function removeOperationsForPaths(operations: SyncOperation[], filePaths: Set): void { + for (let index = operations.length - 1; index >= 0; index -= 1) { + if (filePaths.has(operations[index].filePath)) operations.splice(index, 1); + } +} + +function sameSourceFileMeta(left: SourceFileMeta, right: SourceFileMeta): boolean { + return left.mtimeMs === right.mtimeMs + && left.size === right.size + && left.pathDate === right.pathDate + && left.cwd === right.cwd; +} + +async function fingerprintFilePrefix(filePath: string, byteCount: number): Promise { + const hash = createHash("sha256"); + if (byteCount === 0) return hash.digest("hex"); + let read = 0; + for await (const chunk of createReadStream(filePath, { start: 0, end: byteCount - 1 })) { + hash.update(chunk as Buffer); + read += (chunk as Buffer).length; + } + if (read !== byteCount) return ""; + return hash.digest("hex"); +} + +function indexedProjectionAppendStatus( + db: ReturnType, + filePath: string, + parsed: ParseSessionResult, + requireExact: boolean, +): "safe" | "unsafe" | "missing" { + const existing = getIndexedSessionProjection(db, filePath, "codex"); + if (!existing) return "missing"; + if (parsed.kind !== "parsed" || parsed.session.sessionUuid !== existing.sessionUuid) return "unsafe"; + + const messages = existing.messages; + const candidate = parsed.session; + if (candidate.messages.length < messages.length) return "unsafe"; + for (let index = 0; index < messages.length; index += 1) { + const left = messages[index]; + const right = candidate.messages[index]; + if ( + left.seq !== right.seq + || left.role !== right.role + || left.contentText !== right.contentText + || left.timestamp !== right.timestamp + || left.sourceKind !== right.sourceKind + ) return "unsafe"; + } + if (candidate.title !== existing.title || candidate.cwd !== existing.cwd || candidate.startedAt !== existing.startedAt) return "unsafe"; + if (!candidate.compactText.startsWith(existing.compactText) || !candidate.reasoningSummaryText.startsWith(existing.reasoningSummaryText)) return "unsafe"; + if (!requireExact) return "safe"; + return candidate.messages.length === messages.length + && candidate.summaryText === existing.summaryText + && candidate.compactText === existing.compactText + && candidate.reasoningSummaryText === existing.reasoningSummaryText + && candidate.endedAt === existing.endedAt + ? "safe" + : "unsafe"; +} + function isUnchanged( indexed: { rawFileMtime: number; rawFileSize: number; indexVersion: string } | null, mtimeMs: number, @@ -220,6 +351,7 @@ function applyOperations( selector: Selector, sourceSnapshot: { fingerprint: string; fileSetFingerprint: string; fileCount: number }, retainedFilePaths: Set, + writeCoverage: boolean, ): CoverageWriteSummary { if (bestEffort) { for (const operation of operations) { @@ -245,6 +377,16 @@ function applyOperations( summary.removed += deleteSessionsForSelectorExceptFilePaths(db, selector, retainedFilePaths); } const indexedSessionCount = countSessionsForSelector(db, selector); + if (!writeCoverage) { + coverage = skippedCoverage( + selector, + sourceSnapshot.fingerprint, + sourceSnapshot.fileSetFingerprint, + sourceSnapshot.fileCount, + "active_source_deferred", + ); + return; + } const record = replaceCoverage( db, selector, diff --git a/src/parser.test.ts b/src/parser.test.ts index f55d599..6a9c076 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -103,7 +103,8 @@ describe("parseCodexSession", () => { ].join("\n")); const parsed = await parseCodexSession(filePath); - expect(parsed).toEqual({ kind: "filtered" }); + expect(parsed).toMatchObject({ kind: "filtered" }); + expect(parsed.sourceRead?.byteCount).toBeGreaterThan(0); }); }); diff --git a/src/sources/codex-parser.ts b/src/sources/codex-parser.ts index 1e3f636..0c67cb2 100644 --- a/src/sources/codex-parser.ts +++ b/src/sources/codex-parser.ts @@ -1,7 +1,9 @@ -import { createReadStream } from "node:fs"; +import { createHash } from "node:crypto"; +import { open, stat } from "node:fs/promises"; import { basename } from "node:path"; import { createInterface } from "node:readline"; -import { DEFAULT_SESSION_SOURCE_ID, type ParsedMessage, type ParseSessionResult } from "../types"; +import { Transform } from "node:stream"; +import { DEFAULT_SESSION_SOURCE_ID, type ParsedMessage, type ParseSessionResult, type SourceFileMeta, type SourceReadProof } from "../types"; const INTERNAL_MARKERS = [ "The following is the Codex agent history whose request action you are assessing", @@ -20,7 +22,16 @@ interface ParseState { filteredMessageCount: number; } -export async function parseCodexSession(filePath: string): Promise { +interface ParseCodexSessionOptions { + beforeRead?: () => void | Promise; +} + +export async function parseCodexSession( + input: string | SourceFileMeta, + options: ParseCodexSessionOptions = {}, +): Promise { + const file = typeof input === "string" ? await sourceFileMeta(input) : input; + const filePath = file.filePath; const state: ParseState = { eventMessages: [], compactMessages: [], @@ -31,38 +42,69 @@ export async function parseCodexSession(filePath: string): Promise hashingStream.destroy(error)); + const lineReader = createInterface({ + input: inputStream ? inputStream.pipe(hashingStream) : hashingStream, + crlfDelay: Infinity, + }); + if (!inputStream) hashingStream.end(); + + for await (const line of lineReader) { + // Fast path: avoid expensive JSON.parse for lines that clearly don't contain relevant events + if ( + !line.includes('"event_msg"') && + !line.includes('"session_meta"') && + !line.includes('"turn_context"') && + !line.includes('"compacted"') && + !line.includes('"response_item"') + ) { + continue; + } - for await (const line of lineReader) { - // Fast path: avoid expensive JSON.parse for lines that clearly don't contain relevant events - if ( - !line.includes('"event_msg"') && - !line.includes('"session_meta"') && - !line.includes('"turn_context"') && - !line.includes('"compacted"') && - !line.includes('"response_item"') - ) { - continue; - } + const trimmed = line.trim(); + if (!trimmed) continue; - const trimmed = line.trim(); - if (!trimmed) continue; + let record: Record; + try { + record = JSON.parse(trimmed) as Record; + } catch { + continue; + } - let record: Record; - try { - record = JSON.parse(trimmed) as Record; - } catch { - continue; + processRecord(record, state); } - - processRecord(record, state); + completed = await fileHandle.stat(); + } finally { + await fileHandle.close(); } - if (state.filteredMessageCount > 0 && state.eventMessages.length === 0) return { kind: "filtered" }; - if (!state.sessionUuid || state.eventMessages.length === 0) return { kind: "skipped" }; + const sourceRead: SourceReadProof = { + byteCount, + contentFingerprint: hash.digest("hex"), + openedMtimeMs: opened.mtimeMs, + openedSize: opened.size, + completedMtimeMs: completed.mtimeMs, + completedSize: completed.size, + }; + if (state.filteredMessageCount > 0 && state.eventMessages.length === 0) return { kind: "filtered", sourceRead }; + if (!state.sessionUuid || state.eventMessages.length === 0) return { kind: "skipped", sourceRead }; const title = firstUserMessage(state.eventMessages) ?? "(no title)"; @@ -79,6 +121,7 @@ export async function parseCodexSession(filePath: string): Promise { + const stats = await stat(filePath); + return { + filePath, + pathDate: null, + cwd: "", + mtimeMs: stats.mtimeMs, + size: stats.size, + }; +} + function processRecord(record: Record, state: ParseState): void { const timestamp = typeof record.timestamp === "string" ? record.timestamp : ""; const type = typeof record.type === "string" ? record.type : ""; diff --git a/src/sources/codex.test.ts b/src/sources/codex.test.ts index d25f897..0002e73 100644 --- a/src/sources/codex.test.ts +++ b/src/sources/codex.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "vitest"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { codexSourceAdapter, getSessionSourceAdapter, listSessionSourceAdapters } from "."; @@ -92,7 +92,7 @@ describe("codex source adapter", () => { cwd: "/tmp/fallback", pathDate: "2026-04-23", mtimeMs: 0, - size: 0, + size: statSync(filePath).size, }); expect(parsed.kind).toBe("parsed"); diff --git a/src/sources/codex.ts b/src/sources/codex.ts index a20e711..a9b575e 100644 --- a/src/sources/codex.ts +++ b/src/sources/codex.ts @@ -36,6 +36,6 @@ export const codexSourceAdapter: SessionSourceAdapter = { return collectCodexSourceSnapshot(selector, options); }, parseFile(file: SourceFileMeta) { - return parseCodexSession(file.filePath); + return parseCodexSession(file); }, }; diff --git a/src/types.ts b/src/types.ts index ee13867..81e6d30 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,10 +35,20 @@ export interface ParsedSession { messages: ParsedMessage[]; } -export type ParseSessionResult = +export interface SourceReadProof { + byteCount: number; + contentFingerprint: string; + openedMtimeMs: number; + openedSize: number; + completedMtimeMs: number; + completedSize: number; +} + +export type ParseSessionResult = ( | { kind: "parsed"; session: ParsedSession } | { kind: "filtered" } - | { kind: "skipped" }; + | { kind: "skipped" } +) & { sourceRead?: SourceReadProof }; export interface SyncErrorDetail { filePath: string; @@ -115,6 +125,8 @@ export interface CoverageWriteSummary { sourceFileCount: number; indexedSessionCount: number; reason?: string; + staleReason?: "source_content_changed"; + recommendedAction?: "query" | "sync"; } export interface CoverageStatus {