diff --git a/docs/USAGE.md b/docs/USAGE.md index 7f8cdab..247b3c2 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -101,6 +101,8 @@ 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. 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. + +`find --json` evaluates selector freshness against the current raw source and reports coverage from that same assessment. `complete=false` with `freshness="stale"` does not make existing indexed hits unavailable: a non-empty Codex result remains usable when only the active tail changed, while a zero result receives a retry-oriented `nextAction`. Library-level synchronous queries that only read SQLite return `complete=false` / `freshness="not_checked"` and preserve `coveringSelectors` rather than claiming current completeness without inspecting raw files. 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 f43313e..dcac6f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@act0r/sherlog", - "version": "0.3.18", + "version": "0.3.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@act0r/sherlog", - "version": "0.3.18", + "version": "0.3.19", "license": "MIT", "os": [ "darwin", diff --git a/package.json b/package.json index 1b77926..88ba29d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@act0r/sherlog", - "version": "0.3.18", + "version": "0.3.19", "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 4ef01ef..6f447e3 100644 --- a/skill-packages/sherlog/SKILL.md +++ b/skill-packages/sherlog/SKILL.md @@ -40,7 +40,7 @@ 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 后重试。 +- `stale_or_missing_coverage`: 先判断是否需要完整结论。`find --json` 的 coverage 会做 live freshness 评估;`complete=false` 只表示不能证明当前 raw snapshot 已完整覆盖,不表示已有索引不可查询。Codex `source_content_changed` 常是活跃尾部软 stale,非空结果可先 query/read;coverage 缺失、source set 变化、非 Codex 保守同步、零结果可疑或用户要求完整性时,按 `nextAction` 同范围 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 下仍无结果,才说没找到。 diff --git a/skill-packages/sherlog/references/cli-surface.md b/skill-packages/sherlog/references/cli-surface.md index 59a8eac..1f8f0bd 100644 --- a/skill-packages/sherlog/references/cli-surface.md +++ b/skill-packages/sherlog/references/cli-surface.md @@ -104,7 +104,7 @@ text header 带效率回述:`shlog find "q" · 检索 ~N 条 · 结果 R · Xms` 效率回述默认开,环境变量 `SHLOG_STATS=0`(或 `off`/`false`/`no`)可关闭文本 header 里的注解(`检索 ~N 条 / 读取 K 条 / Xms`);`--json` 的 `scannedMessageCount` / `elapsedMs` 与 `read-page` 的 `total/hasMore` 等功能字段始终保留。关闭时文本里没有可锚的数字,直接省掉效率尾注、别硬编。 -零结果不是结束条件。`--json` 下如果返回 `nextAction`,按它选择/检查同一 selector;text 输出也会打印 `next:` 步骤。新版 Codex `find` 对非空结果会忽略 `source_content_changed` / `recommendedAction: "query"` 软 stale,避免当前会话尾部变化反复逼 agent 同步。若非空结果仍返回 `nextAction.reason=stale_or_missing_coverage`,通常是 coverage 缺失、source file 集合变化或非 Codex source 保守同步;需要完整结论时按 `nextAction.commands` 同步并重试。否则只有 `status.requestedCoverage.recommendedAction === "sync"` 时才跑同范围 `sync`。fresh coverage 下仍无结果,才可以说没找到。 +零结果不是结束条件。`find --json` 会在同一次调用中评估 raw source freshness,因此 `coverage.complete` / `freshness` / `staleReason` 与 `nextAction` 使用同一 snapshot;纯 SQLite query facade 无法检查 raw 时则诚实返回 `complete=false` / `freshness=not_checked`,但保留 `coveringSelectors`。`--json` 下如果返回 `nextAction`,按它选择/检查同一 selector;text 输出也会打印 `next:` 步骤。Codex `find` 对非空结果会忽略 `source_content_changed` 软 stale,避免当前会话尾部变化反复逼 agent 同步;此时 `complete=false` 表示最新尾部未获证明,不表示已有索引不可查询。若非空结果仍返回 `nextAction.reason=stale_or_missing_coverage`,通常是 coverage 缺失、source file 集合变化或非 Codex source 保守同步;需要完整结论时按 `nextAction.commands` 同步并重试。fresh coverage 下仍无结果,才可以说没找到。 Example: diff --git a/skill-packages/sherlog/references/json-schema.md b/skill-packages/sherlog/references/json-schema.md index 74752f2..1db4cb6 100644 --- a/skill-packages/sherlog/references/json-schema.md +++ b/skill-packages/sherlog/references/json-schema.md @@ -293,11 +293,21 @@ Input selector JSON may omit `source`; canonical selectors returned by public CL { requested: Selector | null; complete: boolean; - freshness: "not_checked"; + freshness: "fresh" | "stale" | "missing" | "not_checked"; + staleReason?: "none" | "missing" | "source_content_changed" | "source_set_changed"; coveringSelectors: CoverageRecord[]; } ``` +Public `find --json` evaluates raw source freshness and keeps these fields aligned +with `nextAction`. The synchronous SQLite query facade cannot inspect raw source +state, so it returns `complete=false` / `freshness="not_checked"` while retaining +historical `coveringSelectors`; that means freshness is unconfirmed, not that the +indexed rows are unusable. For a Codex active-tail soft stale, non-empty results +remain queryable with `complete=false`, `freshness="stale"`, and +`staleReason="source_content_changed"`; a suspicious zero result also carries a +retry-oriented `nextAction`. + `RequestedCoverageStatus`: ```ts diff --git a/src/cli.test.ts b/src/cli.test.ts index f55025b..f093f0b 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "vitest"; import { spawn as childSpawn } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, chmodSync, existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import Database from "better-sqlite3"; @@ -858,10 +858,71 @@ describe("shlog cli", { timeout: 20_000 }, () => { coverage: { complete: boolean; freshness: string }; }; expect(findPayload.coverage.complete).toBe(true); - expect(findPayload.coverage.freshness).toBe("not_checked"); + expect(findPayload.coverage.freshness).toBe("fresh"); expect(findPayload.results.map((result) => result.cwd)).toEqual(["/tmp/alpha"]); }); + test("find reports live soft-stale coverage without hiding usable indexed results", async () => { + const base = mkdtempSync(join(tmpdir(), "cxs-cli-find-soft-stale-")); + tempDirs.push(base); + const root = join(base, "sessions"); + const day = join(root, "2026", "07", "12"); + mkdirSync(day, { recursive: true }); + const sessionPath = join(day, "rollout-2026-07-12T10-00-00-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.jsonl"); + writeFileSync(sessionPath, [ + line("session_meta", { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", cwd: "/tmp/find-soft-stale" }), + line("event_msg", { type: "user_message", message: "indexed active prefix" }), + ].join("\n")); + + const dbPath = join(base, "index.sqlite"); + const synced = await runCli(["sync", "--source", "codex", "--root", root, "--db", dbPath, "--json"]); + expect(synced.exitCode).toBe(0); + + const freshMiss = await runCli(["find", "unindexed active tail", "--source", "codex", "--root", root, "--db", dbPath, "--json"]); + expect(freshMiss.exitCode).toBe(0); + const freshMissPayload = JSON.parse(freshMiss.stdout) as { + results: unknown[]; + coverage: { complete: boolean; freshness: string; staleReason?: string }; + nextAction?: unknown; + }; + expect(freshMissPayload.results).toHaveLength(0); + expect(freshMissPayload.coverage).toMatchObject({ complete: true, freshness: "fresh", staleReason: "none" }); + expect(freshMissPayload.nextAction).toBeUndefined(); + + appendFileSync(sessionPath, `\n${line("event_msg", { type: "agent_message", message: "unindexed active tail" })}`); + + const found = await runCli(["find", "indexed active prefix", "--source", "codex", "--root", root, "--db", dbPath, "--json"]); + expect(found.exitCode).toBe(0); + const foundPayload = JSON.parse(found.stdout) as { + results: unknown[]; + coverage: { complete: boolean; freshness: string; staleReason?: string }; + nextAction?: unknown; + }; + expect(foundPayload.results).toHaveLength(1); + expect(foundPayload.coverage).toMatchObject({ + complete: false, + freshness: "stale", + staleReason: "source_content_changed", + }); + expect(foundPayload.nextAction).toBeUndefined(); + + const missedTail = await runCli(["find", "unindexed active tail", "--source", "codex", "--root", root, "--db", dbPath, "--json"]); + expect(missedTail.exitCode).toBe(0); + const missedTailPayload = JSON.parse(missedTail.stdout) as { + results: unknown[]; + coverage: { complete: boolean; freshness: string; staleReason?: string }; + nextAction?: { reason: string; steps: string[] }; + }; + expect(missedTailPayload.results).toHaveLength(0); + expect(missedTailPayload.coverage).toMatchObject({ + complete: false, + freshness: "stale", + staleReason: "source_content_changed", + }); + expect(missedTailPayload.nextAction?.reason).toBe("stale_or_missing_coverage"); + expect(missedTailPayload.nextAction?.steps[0]).toContain("existing source-file content"); + }); + test("sync and find support --cwd/--root without handwritten selector JSON", async () => { const base = mkdtempSync(join(tmpdir(), "cxs-cli-cwd-shortcut-")); tempDirs.push(base); diff --git a/src/cli.ts b/src/cli.ts index aadccc0..f27febc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,12 +4,12 @@ import { Command } from "commander"; import packageJson from "../package.json" with { type: "json" }; import { DEFAULT_DB_PATH, - isCurrentIndexVersion, migrateLegacyDataDirIfNeeded, PROGRAM_NAME, statsReadoutEnabled, } from "./env"; import { IndexSchemaUpgradeRequiredError, IndexUnavailableError, listCoverageRecords, withReadDb } from "./db"; +import { evaluateCoverageRecord, evaluateRequestedCoverage } from "./coverage-freshness"; import { buildEvidenceReadAction } from "./evidence-read"; import { getSessionSourceAdapter, listSessionSourceAdapters } from "./sources"; @@ -35,10 +35,11 @@ import { listSessionSummaries, SessionNotFoundError, } from "./query"; -import { canonicalizeSelector, parseSelectorJson, SelectorParseError, selectorImplies, selectorSource } from "./selector"; +import { canonicalizeSelector, parseSelectorJson, SelectorParseError, selectorSource } from "./selector"; import { collectStatus } from "./status"; import { SyncLockTimeoutError } from "./sync-lock"; import type { + CoverageStatus, FindResult, FindSort, FindSummary, @@ -171,8 +172,19 @@ program excludeSessions: options.excludeSession ?? [], }); const coverageSelector = selector ?? defaultAllSelector(sourceId); - const coverageAction = await buildFindCoverageNextAction(options.db, coverageSelector, "this find", summary.results.length); - return coverageAction ? { ...summary, nextAction: coverageAction } : summary; + const coverageAssessment = await assessFindCoverage( + options.db, + coverageSelector, + "this find", + summary.results.length, + summary.coverage, + ); + return { + ...summary, + coverage: coverageAssessment.coverage, + coverageBySource: [{ sourceId, coverage: coverageAssessment.coverage }], + nextAction: coverageAssessment.nextAction, + }; })); const result = mergeFindSummaries(query, sort, options.excludeSession ?? [], summaries, limit); // performance.now() 自 timeOrigin(进程启动)起算 ≈ 本次端到端耗时, @@ -469,7 +481,7 @@ function mergeFindSummaries( const coverage = { requested: null, complete: coverageBySource.every((entry) => entry.coverage.complete), - freshness: "not_checked" as const, + freshness: mergedCoverageFreshness(coverageBySource.map((entry) => entry.coverage)), coveringSelectors: coverageBySource.flatMap((entry) => entry.coverage.coveringSelectors), }; const coverageActions = summaries @@ -564,60 +576,68 @@ function buildCrossSourceZeroResultsNextAction(): QueryNextAction { }; } -async function buildFindCoverageNextAction( +async function assessFindCoverage( dbPath: string, selector: Selector, commandLabel: string, resultCount: number, -): Promise { - if (!existsSync(dbPath)) return undefined; + unconfirmedCoverage: CoverageStatus, +): Promise<{ coverage: CoverageStatus; nextAction?: QueryNextAction }> { + if (!existsSync(dbPath)) return { coverage: unconfirmedCoverage }; const sourceId = selectorSource(selector); const source = getSessionSourceAdapter(sourceId); const files = await source.collectFiles(selector.root); const snapshot = await source.snapshotFromFiles(selector, files); - const coveringSelectors = withReadDb(dbPath, (db) => - listCoverageRecords(db, sourceId).filter((entry) => - isCurrentIndexVersion(entry.indexVersion) && selectorImplies(entry.selector, snapshot.selector) - ) - ); - let staleReason: RequestedCoverageStatus["staleReason"] = coveringSelectors.length > 0 ? "source_set_changed" : "missing"; - for (const entry of coveringSelectors) { + const coverageRecords = withReadDb(dbPath, (db) => listCoverageRecords(db, sourceId)); + const coverageInventory = []; + for (const entry of coverageRecords) { const entrySnapshot = await source.snapshotFromFiles(entry.selector, files); - if ( - entrySnapshot.fingerprint === entry.sourceFingerprint - && (entry.sourceFileSetFingerprint === "" || entrySnapshot.fileSetFingerprint === entry.sourceFileSetFingerprint) - && entrySnapshot.fileCount === entry.sourceFileCount - ) { - return undefined; - } - if (entry.sourceFileSetFingerprint !== "" && entrySnapshot.fileSetFingerprint === entry.sourceFileSetFingerprint) { - staleReason = "source_content_changed"; - } + coverageInventory.push(evaluateCoverageRecord(entry, entrySnapshot)); } - if (sourceId === "codex" && staleReason === "source_content_changed" && resultCount > 0) return undefined; + const requestedCoverage = evaluateRequestedCoverage(snapshot, coverageInventory); + const staleReason = requestedCoverage.staleReason; + const coverage: CoverageStatus = { + requested: requestedCoverage.requested, + complete: requestedCoverage.complete, + freshness: requestedCoverage.freshness, + staleReason, + coveringSelectors: requestedCoverage.coveringSelectors, + }; + if (requestedCoverage.freshness === "fresh") return { coverage }; + if (sourceId === "codex" && staleReason === "source_content_changed" && resultCount > 0) return { coverage }; const syncArgv = syncArgvForSelector(snapshot.selector); return { - kind: "check_coverage_then_retry", - reason: "stale_or_missing_coverage", - selector: snapshot.selector, - steps: [ - indexedCoverageStep(staleReason), - `Run ${syncArgv.join(" ")}.`, - `Retry ${commandLabel} before treating current results as complete.`, - ], - commands: [ - { - label: "refresh selector coverage", - recommended: true, - argv: syncArgv, - selector: snapshot.selector, - }, - ], + coverage, + nextAction: { + kind: "check_coverage_then_retry", + reason: "stale_or_missing_coverage", + selector: snapshot.selector, + steps: [ + indexedCoverageStep(staleReason), + `Run ${syncArgv.join(" ")}.`, + `Retry ${commandLabel} before treating current results as complete.`, + ], + commands: [ + { + label: "refresh selector coverage", + recommended: true, + argv: syncArgv, + selector: snapshot.selector, + }, + ], + }, }; } +function mergedCoverageFreshness(coverages: CoverageStatus[]): CoverageStatus["freshness"] { + if (coverages.every((coverage) => coverage.freshness === "fresh")) return "fresh"; + if (coverages.some((coverage) => coverage.freshness === "missing")) return "missing"; + if (coverages.some((coverage) => coverage.freshness === "stale")) return "stale"; + return "not_checked"; +} + function indexedCoverageStep(staleReason: RequestedCoverageStatus["staleReason"]): string { if (staleReason === "missing") { return "Indexed coverage for this selector is missing; results may be incomplete or misleading."; diff --git a/src/coverage-freshness.ts b/src/coverage-freshness.ts new file mode 100644 index 0000000..e77f11c --- /dev/null +++ b/src/coverage-freshness.ts @@ -0,0 +1,79 @@ +import { isCurrentIndexVersion } from "./env"; +import { selectorImplies, selectorSource } from "./selector"; +import type { + CoverageInventoryStatus, + CoverageRecord, + RequestedCoverageStatus, + SourceSnapshot, +} from "./types"; + +export function evaluateCoverageRecord( + record: CoverageRecord, + snapshot: SourceSnapshot, +): CoverageInventoryStatus { + const fresh = snapshot.fingerprint === record.sourceFingerprint + && (record.sourceFileSetFingerprint === "" || snapshot.fileSetFingerprint === record.sourceFileSetFingerprint) + && snapshot.fileCount === record.sourceFileCount + && isCurrentIndexVersion(record.indexVersion); + const staleReason: CoverageInventoryStatus["staleReason"] = fresh + ? "none" + : record.sourceFileSetFingerprint !== "" && snapshot.fileSetFingerprint === record.sourceFileSetFingerprint + ? "source_content_changed" + : "source_set_changed"; + return { + ...record, + freshness: fresh ? "fresh" : "stale", + staleReason, + advisory: !fresh && isAdvisorySourceContentStale(record.selector, staleReason), + currentSourceFingerprint: snapshot.fingerprint, + currentSourceFileSetFingerprint: snapshot.fileSetFingerprint, + currentSourceFileCount: snapshot.fileCount, + }; +} + +export function evaluateRequestedCoverage( + snapshot: SourceSnapshot, + coverage: CoverageInventoryStatus[], +): RequestedCoverageStatus { + const coveringSelectors = coverage.filter((entry) => + isCurrentIndexVersion(entry.indexVersion) && selectorImplies(entry.selector, snapshot.selector) + ); + const hasFreshCovering = coveringSelectors.some((entry) => entry.freshness === "fresh"); + const freshness: RequestedCoverageStatus["freshness"] = hasFreshCovering + ? "fresh" + : coveringSelectors.length > 0 + ? "stale" + : "missing"; + const staleReason = requestedCoverageStaleReason(freshness, coveringSelectors); + return { + requested: snapshot.selector, + complete: freshness === "fresh", + freshness, + staleReason, + sourceFingerprint: snapshot.fingerprint, + sourceFileSetFingerprint: snapshot.fileSetFingerprint, + sourceFileCount: snapshot.fileCount, + coveringSelectors, + recommendedAction: freshness === "fresh" || isAdvisorySourceContentStale(snapshot.selector, staleReason) ? "query" : "sync", + }; +} + +function isAdvisorySourceContentStale( + selector: SourceSnapshot["selector"], + staleReason: RequestedCoverageStatus["staleReason"], +): boolean { + return selectorSource(selector) === "codex" && staleReason === "source_content_changed"; +} + +function requestedCoverageStaleReason( + freshness: RequestedCoverageStatus["freshness"], + coveringSelectors: CoverageInventoryStatus[], +): RequestedCoverageStatus["staleReason"] { + if (freshness === "fresh") return "none"; + if (freshness === "missing") return "missing"; + return coveringSelectors.some((entry) => + entry.sourceFileSetFingerprint !== "" && entry.currentSourceFileSetFingerprint === entry.sourceFileSetFingerprint + ) + ? "source_content_changed" + : "source_set_changed"; +} diff --git a/src/indexer.test.ts b/src/indexer.test.ts index 159f7e0..be2a641 100644 --- a/src/indexer.test.ts +++ b/src/indexer.test.ts @@ -578,7 +578,8 @@ describe("syncSessions", () => { expect(summary.coverage.indexedSessionCount).toBe(1); const found = findSessions(dbPath, "dated needle", 5, dateSelector); - expect(found.coverage.complete).toBe(true); + expect(found.coverage.complete).toBe(false); + expect(found.coverage.freshness).toBe("not_checked"); expect(found.results.map((result) => result.sessionUuid)).toEqual([ "33333333-3333-4333-8333-333333333333", ]); diff --git a/src/query-session-fields.test.ts b/src/query-session-fields.test.ts index a28da54..797e896 100644 --- a/src/query-session-fields.test.ts +++ b/src/query-session-fields.test.ts @@ -336,7 +336,7 @@ describe("cxs session-level fields", () => { const found = findSessions(dbPath, "definitely missing needle", 5, selector); - expect(found.coverage.complete).toBe(true); + expect(found.coverage.complete).toBe(false); expect(found.coverage.freshness).toBe("not_checked"); expect(found.results).toHaveLength(0); expect(found.nextAction?.reason).toBe("zero_results_with_unconfirmed_selector_coverage"); diff --git a/src/query/coverage.ts b/src/query/coverage.ts index 45da145..ed14343 100644 --- a/src/query/coverage.ts +++ b/src/query/coverage.ts @@ -6,7 +6,7 @@ export function buildCoverageStatus(db: Db, selector: Selector | null): Coverage const status = coverageStatusForSelector(db, selector); return { requested: selector, - complete: status.complete, + complete: false, freshness: "not_checked", coveringSelectors: status.coveringSelectors, }; diff --git a/src/status.ts b/src/status.ts index 00c0b55..5382dae 100644 --- a/src/status.ts +++ b/src/status.ts @@ -1,7 +1,8 @@ import { existsSync, statSync } from "node:fs"; -import { INDEX_VERSION, DEFAULT_DB_PATH, isCurrentIndexVersion } from "./env"; +import { INDEX_VERSION, DEFAULT_DB_PATH } from "./env"; import { getStatsCounts, listCoverageRecords, withReadDb, type Db } from "./db"; -import { selectorImplies, selectorSource } from "./selector"; +import { evaluateCoverageRecord, evaluateRequestedCoverage } from "./coverage-freshness"; +import { selectorSource } from "./selector"; import { getSessionSourceAdapter } from "./sources"; import type { SessionSourceAdapter } from "./sources/types"; import type { @@ -27,7 +28,9 @@ export async function collectStatus(options: { sourceId?: SessionSourceId; rootD const coverage = existsSync(dbPath) ? withReadDb(dbPath, (db) => listCoverageRecordsForStatus(db, source.id)) : []; const coverageStatus: CoverageInventoryStatus[] = []; for (const record of coverage) { - coverageStatus.push(await toCoverageInventoryStatus(record, getContext)); + const context = await getContext(selectorSource(record.selector), record.selector.root); + const snapshot = await context.source.snapshotFromFiles(record.selector, context.files); + coverageStatus.push(evaluateCoverageRecord(record, snapshot)); } const summary: StatusSummary = { context: { @@ -41,7 +44,9 @@ export async function collectStatus(options: { sourceId?: SessionSourceId; rootD coverage: coverageStatus, }; if (options.selector) { - summary.requestedCoverage = await requestedCoverageStatus(options.selector, coverageStatus, getContext); + const context = await getContext(selectorSource(options.selector), options.selector.root); + const snapshot = await context.source.snapshotFromFiles(options.selector, context.files); + summary.requestedCoverage = evaluateRequestedCoverage(snapshot, coverageStatus); } return summary; } @@ -161,80 +166,3 @@ function tableExists(db: Db, tableName: string): boolean { .prepare<[string], unknown>("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1") .get(tableName) !== undefined; } - -async function toCoverageInventoryStatus( - record: CoverageRecord, - getContext: (sourceId: SessionSourceId, root: string) => Promise, -): Promise { - const context = await getContext(selectorSource(record.selector), record.selector.root); - const snapshot = await context.source.snapshotFromFiles(record.selector, context.files); - const fresh = snapshot.fingerprint === record.sourceFingerprint - && (record.sourceFileSetFingerprint === "" || snapshot.fileSetFingerprint === record.sourceFileSetFingerprint) - && snapshot.fileCount === record.sourceFileCount - && isCurrentIndexVersion(record.indexVersion); - const staleReason: CoverageInventoryStatus["staleReason"] = fresh - ? "none" - : record.sourceFileSetFingerprint !== "" && snapshot.fileSetFingerprint === record.sourceFileSetFingerprint - ? "source_content_changed" - : "source_set_changed"; - const advisory = !fresh && isAdvisorySourceContentStale(record.selector, staleReason); - return { - ...record, - freshness: fresh ? "fresh" : "stale", - staleReason, - advisory, - currentSourceFingerprint: snapshot.fingerprint, - currentSourceFileSetFingerprint: snapshot.fileSetFingerprint, - currentSourceFileCount: snapshot.fileCount, - }; -} - -async function requestedCoverageStatus( - selector: Selector, - coverage: CoverageInventoryStatus[], - getContext: (sourceId: SessionSourceId, root: string) => Promise, -): Promise { - const context = await getContext(selectorSource(selector), selector.root); - const snapshot = await context.source.snapshotFromFiles(selector, context.files); - const coveringSelectors = coverage.filter((entry) => - isCurrentIndexVersion(entry.indexVersion) && selectorImplies(entry.selector, selector) - ); - const hasFreshCovering = coveringSelectors.some((entry) => entry.freshness === "fresh"); - const freshness: RequestedCoverageStatus["freshness"] = hasFreshCovering - ? "fresh" - : coveringSelectors.length > 0 - ? "stale" - : "missing"; - const staleReason = requestedCoverageStaleReason(freshness, coveringSelectors); - return { - requested: snapshot.selector, - complete: freshness === "fresh", - freshness, - staleReason, - sourceFingerprint: snapshot.fingerprint, - sourceFileSetFingerprint: snapshot.fileSetFingerprint, - sourceFileCount: snapshot.fileCount, - coveringSelectors, - recommendedAction: freshness === "fresh" || isAdvisorySourceContentStale(snapshot.selector, staleReason) ? "query" : "sync", - }; -} - -function isAdvisorySourceContentStale( - selector: Selector, - staleReason: RequestedCoverageStatus["staleReason"], -): boolean { - return selectorSource(selector) === "codex" && staleReason === "source_content_changed"; -} - -function requestedCoverageStaleReason( - freshness: RequestedCoverageStatus["freshness"], - coveringSelectors: CoverageInventoryStatus[], -): RequestedCoverageStatus["staleReason"] { - if (freshness === "fresh") return "none"; - if (freshness === "missing") return "missing"; - return coveringSelectors.some((entry) => - entry.sourceFileSetFingerprint !== "" && entry.currentSourceFileSetFingerprint === entry.sourceFileSetFingerprint - ) - ? "source_content_changed" - : "source_set_changed"; -} diff --git a/src/types.ts b/src/types.ts index 81e6d30..6a6e9e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -132,7 +132,8 @@ export interface CoverageWriteSummary { export interface CoverageStatus { requested: Selector | null; complete: boolean; - freshness: "not_checked"; + freshness: "fresh" | "stale" | "missing" | "not_checked"; + staleReason?: "none" | "missing" | "source_content_changed" | "source_set_changed"; coveringSelectors: CoverageRecord[]; }