From 07a636a720c8773f7294454aa723804f5004032a Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:16:34 +0200 Subject: [PATCH 1/5] rebase(log-guard): restack inspect on latest dev --- .../content/docs/guides/codex-log-guard.md | 69 +++ .../storage-workspace/StorageWorkspace.tsx | 119 +++++ gui/src/format-bytes.ts | 4 +- gui/src/i18n/log-guard-labels.ts | 50 +++ gui/src/i18n/log-guard-state-labels.ts | 58 +++ gui/tests/storage-log-guard.test.tsx | 131 ++++++ src/cli/codex-log-guard-doctor.ts | 67 +++ src/cli/dispatch.ts | 8 +- src/cli/observe.ts | 21 +- src/codex/log-guard/inspect.ts | 410 +++++++++++++++++ src/codex/paths.ts | 5 + src/server/management-api.ts | 4 +- .../management/storage-log-guard-routes.ts | 69 +++ tests/api-codex-log-guard.test.ts | 140 ++++++ tests/cli-codex-log-guard.test.ts | 48 ++ tests/codex-log-guard-doctor.test.ts | 129 ++++++ tests/codex-log-guard-inspect.test.ts | 411 ++++++++++++++++++ tests/codex-sqlite-home.test.ts | 3 +- 18 files changed, 1739 insertions(+), 7 deletions(-) create mode 100644 docs-site/src/content/docs/guides/codex-log-guard.md create mode 100644 gui/src/i18n/log-guard-labels.ts create mode 100644 gui/src/i18n/log-guard-state-labels.ts create mode 100644 gui/tests/storage-log-guard.test.tsx create mode 100644 src/cli/codex-log-guard-doctor.ts create mode 100644 src/codex/log-guard/inspect.ts create mode 100644 src/server/management/storage-log-guard-routes.ts create mode 100644 tests/api-codex-log-guard.test.ts create mode 100644 tests/cli-codex-log-guard.test.ts create mode 100644 tests/codex-log-guard-doctor.test.ts create mode 100644 tests/codex-log-guard-inspect.test.ts diff --git a/docs-site/src/content/docs/guides/codex-log-guard.md b/docs-site/src/content/docs/guides/codex-log-guard.md new file mode 100644 index 0000000000..b162b4820f --- /dev/null +++ b/docs-site/src/content/docs/guides/codex-log-guard.md @@ -0,0 +1,69 @@ +--- +title: Codex Log Guard +description: Inspect Codex diagnostic-log storage safely before enabling future protection or reclaim actions. +--- + +OpenCodex can inspect Codex's persistent diagnostic-log database from the **Storage** page and from the CLI. The inspection surface is deliberately read-only: it does not install triggers, delete logs, checkpoint SQLite, vacuum the database, or change Codex configuration. + +## What Inspect reports + +OpenCodex resolves Codex's effective `sqlite_home` using Codex's existing precedence and inspects the canonical `logs_2.sqlite` database there. A higher-numbered or legacy `logs_N.sqlite` file is never substituted as the mutation-capable target. + +The Storage view reports: + +- the main database, WAL, and SHM file sizes; +- total log rows and the share stored at `TRACE` level; +- the largest log-target buckets by row count, using rank labels instead of target names; +- SQLite freelist space that may be reclaimable later; and +- whether the observed schema is compatible with the currently known Codex log schema. + +If `sqlite_home` is outside `CODEX_HOME`, the diagnostic database is shown separately. Its bytes are not silently folded into the existing `CODEX_HOME` storage total. + +OpenCodex does not select or expose `feedback_log_body` while producing these diagnostics. Log levels are reduced to the fixed known level set plus `OTHER`, and target names are not serialized. + +## CLI + +```bash +ocx storage codex-logs status +ocx storage codex-logs status --json +ocx doctor +``` + +The existing command remains unchanged: + +```bash +ocx storage --json +``` + +Its response now also carries the same Codex-log inspection report used by the Storage page. + +## Management API + +```text +GET /api/storage/codex-logs +``` + +`GET /api/storage` also includes the report as `codexLogs` so the dashboard can refresh the normal storage breakdown and Codex-log diagnostics from one snapshot request. + +## Read-only snapshot semantics + +Inspection opens the database read-only with SQLite `immutable=1`. This prevents the diagnostic read itself from creating or updating `-wal` or `-shm` sidecars. + +The trade-off is important: SQL aggregates describe the last checkpointed database snapshot. If Codex is actively writing, the live WAL can contain newer rows than the aggregate counts. OpenCodex therefore reports the WAL file size separately and does **not** label the result as SSD write rate, NAND writes, or drive-wear/TBW consumption. + +## Compatibility states + +A known schema reports inspection, future protection, and future reclaim capabilities as supported. A missing, unreadable, or unknown future schema remains inspectable as metadata but is reported as unsupported for mutation-capable operations. + +An unknown schema is not guessed into compatibility. This lets a newer Codex version remain observable while preventing later Log Guard releases from treating an unreviewed database layout as safe to modify. + +## What is not in this stage + +This is **Inspect**, the first Log Guard stage. It does not reduce Codex writes by itself and does not reclaim database pages. + +Later stages are intentionally separate: + +- **Protect** will add explicit write-reduction modes after safety checks and Codex-process quiescence. +- **Reclaim** will add explicit, offline, bounded SQLite space reclamation. + +Neither action is automatically enabled by Inspect. diff --git a/gui/src/components/storage-workspace/StorageWorkspace.tsx b/gui/src/components/storage-workspace/StorageWorkspace.tsx index dedcce01e2..9ae40cf0b3 100644 --- a/gui/src/components/storage-workspace/StorageWorkspace.tsx +++ b/gui/src/components/storage-workspace/StorageWorkspace.tsx @@ -8,6 +8,8 @@ import { useMemo, useState } from "react"; import { IconChevron, IconHardDrive } from "../../icons"; import { useT, type TFn, type TKey, type Locale } from "../../i18n/shared"; +import { logGuardLabel } from "../../i18n/log-guard-labels"; +import { logGuardSchemaStateLabel } from "../../i18n/log-guard-state-labels"; import { formatBytes } from "../../format-bytes"; export interface StorageLargestEntry { @@ -26,11 +28,46 @@ export interface StorageBucket { rows?: number | null; } +type LogGuardReason = "database_missing" | "database_unreadable" | "unknown_schema"; +type LogGuardCapability = { state: "supported" } | { state: "unsupported"; reason: LogGuardReason }; +type LogGuardSchema = + | { state: "compatible" } + | { state: "missing"; reason: "database_missing" } + | { state: "unreadable"; reason: "database_unreadable" } + | { state: "unsupported"; reason: "unknown_schema" }; + +export interface CodexLogGuardReport { + generatedAt: number; + externalSqliteHome: boolean; + snapshot: "checkpointed"; + files: { databaseBytes: number; walBytes: number; shmBytes: number }; + schema: LogGuardSchema; + capabilities: { + inspection: LogGuardCapability; + protection: LogGuardCapability; + reclaim: LogGuardCapability; + }; + metrics: null | { + totalRows: number; + rowsByLevel: Record; + traceRows: number; + traceShare: number; + topTargets: Array<{ target: string; rows: number }>; + pageSize: number; + pageCount: number; + freelistPages: number; + reclaimableBytes: number; + estimatedLogBytes: number | null; + }; +} + export interface StorageReport { codexHome: string; generatedAt: number; total: { bytes: number; fileCount: number }; buckets: StorageBucket[]; + codexLogs?: CodexLogGuardReport | null; + codexLogsError?: string; error?: string; } @@ -60,6 +97,82 @@ function rowsDisplay(bucket: StorageBucket, locale: Locale, t: TFn): string { return bucket.rows.toLocaleString(locale); } +function CodexLogGuardPanel({ report, locale, t }: { report: CodexLogGuardReport; locale: Locale; t: TFn }) { + const metrics = report.metrics; + const inspectOnly = report.capabilities.protection.state === "unsupported" + || report.capabilities.reclaim.state === "unsupported"; + + return ( +
+

{t("storage.bucket.logs_db")}

+
+
+
{t("dash.status")}
+
+ {logGuardSchemaStateLabel(locale, report.schema.state)} + {inspectOnly && report.schema.state === "unsupported" ? <>{logGuardLabel(locale, "inspectionOnly")} : null} +
+
+
+
{t("storage.bucket.logs_db")}
+
{formatBytes(report.files.databaseBytes, locale)}
+
+
+
WAL
+
{formatBytes(report.files.walBytes, locale)}
+
+
+
SHM
+
{formatBytes(report.files.shmBytes, locale)}
+
+ {metrics && ( + <> +
+
{t("storage.col.rows")}
+
{metrics.totalRows.toLocaleString(locale)}
+
+
+
TRACE
+
{(metrics.traceShare * 100).toFixed(1)}%
+
+
+
freelist
+
{formatBytes(metrics.reclaimableBytes, locale)}
+
+ + )} +
+
sqlite_home
+
+ {report.externalSqliteHome ? logGuardLabel(locale, "externalSqliteHome") : "CODEX_HOME"} +
+
+
+ {metrics && metrics.topTargets.length > 0 && ( +
+

target

+ {metrics.topTargets.slice(0, 5).map(target => ( +
+ {target.target} + {target.rows.toLocaleString(locale)} +
+ ))} +
+ )} +

immutable=1 · snapshot={report.snapshot}

+
+ ); +} + +function CodexLogGuardUnavailablePanel({ locale, t }: { locale: Locale; t: TFn }) { + return ( +
+

{t("storage.bucket.logs_db")}

+

{logGuardLabel(locale, "inspectionUnavailable")}

+
+ ); +} + export interface StorageWorkspaceProps { report: StorageReport; locale: Locale; @@ -184,6 +297,12 @@ export default function StorageWorkspace({ report, locale }: StorageWorkspacePro + {report.codexLogs ? ( + + ) : report.codexLogsError === "inspect_failed" ? ( + + ) : null} + {largestAcross.length > 0 ? (

{t("storage.section.largest")}

diff --git a/gui/src/format-bytes.ts b/gui/src/format-bytes.ts index a418414b66..559cbfaca3 100644 --- a/gui/src/format-bytes.ts +++ b/gui/src/format-bytes.ts @@ -1,9 +1,9 @@ import type { Locale } from "./i18n/shared"; -/** Human-readable byte size (1.5 MB, 320 KB). Unit symbols are locale-invariant like model ids. */ +/** Human-readable byte size (1.5 MiB, 320 KiB). Binary unit symbols are locale-invariant like model ids. */ export function formatBytes(bytes: number, locale: Locale): string { if (bytes < 1024) return `${bytes} B`; - const units = ["KB", "MB", "GB", "TB"]; + const units = ["KiB", "MiB", "GiB", "TiB"]; let value = bytes; let unit = -1; do { diff --git a/gui/src/i18n/log-guard-labels.ts b/gui/src/i18n/log-guard-labels.ts new file mode 100644 index 0000000000..059a3d30f8 --- /dev/null +++ b/gui/src/i18n/log-guard-labels.ts @@ -0,0 +1,50 @@ +import type { Locale } from "./catalogs"; + +export type LogGuardLabelKey = "inspectionOnly" | "externalSqliteHome" | "inspectionUnavailable"; + +const LABELS: Record> = { + en: { + inspectionOnly: "Inspection only", + externalSqliteHome: "External SQLite storage", + inspectionUnavailable: "Diagnostic log inspection is unavailable.", + }, + de: { + inspectionOnly: "Nur Inspektion", + externalSqliteHome: "Externer SQLite-Speicher", + inspectionUnavailable: "Die Diagnoseprotokoll-Inspektion ist nicht verfügbar.", + }, + ko: { + inspectionOnly: "검사 전용", + externalSqliteHome: "외부 SQLite 저장소", + inspectionUnavailable: "진단 로그 검사를 사용할 수 없습니다.", + }, + zh: { + inspectionOnly: "仅检查", + externalSqliteHome: "外部 SQLite 存储", + inspectionUnavailable: "诊断日志检查当前不可用。", + }, + "zh-TW": { + inspectionOnly: "僅檢查", + externalSqliteHome: "外部 SQLite 儲存空間", + inspectionUnavailable: "診斷記錄檢查目前無法使用。", + }, + ru: { + inspectionOnly: "Только проверка", + externalSqliteHome: "Внешнее хранилище SQLite", + inspectionUnavailable: "Проверка диагностических журналов недоступна.", + }, + ja: { + inspectionOnly: "検査のみ", + externalSqliteHome: "外部 SQLite ストレージ", + inspectionUnavailable: "診断ログの検査を利用できません。", + }, + tr: { + inspectionOnly: "Yalnızca inceleme", + externalSqliteHome: "Harici SQLite depolaması", + inspectionUnavailable: "Tanılama günlüğü incelemesi kullanılamıyor.", + }, +}; + +export function logGuardLabel(locale: Locale, key: LogGuardLabelKey): string { + return LABELS[locale][key]; +} diff --git a/gui/src/i18n/log-guard-state-labels.ts b/gui/src/i18n/log-guard-state-labels.ts new file mode 100644 index 0000000000..a7e6c39292 --- /dev/null +++ b/gui/src/i18n/log-guard-state-labels.ts @@ -0,0 +1,58 @@ +import type { Locale } from "./catalogs"; + +export type LogGuardSchemaState = "compatible" | "missing" | "unreadable" | "unsupported"; + +const SCHEMA_LABELS: Record> = { + en: { + compatible: "Compatible", + missing: "Database not found", + unreadable: "Database unavailable", + unsupported: "Unsupported", + }, + de: { + compatible: "Kompatibel", + missing: "Datenbank nicht gefunden", + unreadable: "Datenbank nicht lesbar", + unsupported: "Nicht unterstützt", + }, + ko: { + compatible: "호환됨", + missing: "데이터베이스 없음", + unreadable: "데이터베이스를 읽을 수 없음", + unsupported: "지원되지 않음", + }, + zh: { + compatible: "兼容", + missing: "未找到数据库", + unreadable: "无法读取数据库", + unsupported: "不受支持", + }, + "zh-TW": { + compatible: "相容", + missing: "找不到資料庫", + unreadable: "無法讀取資料庫", + unsupported: "不支援", + }, + ru: { + compatible: "Совместимо", + missing: "База не найдена", + unreadable: "База недоступна", + unsupported: "Не поддерживается", + }, + ja: { + compatible: "互換", + missing: "データベースが見つかりません", + unreadable: "データベースを読み取れません", + unsupported: "未対応", + }, + tr: { + compatible: "Uyumlu", + missing: "Veritabanı bulunamadı", + unreadable: "Veritabanı okunamıyor", + unsupported: "Desteklenmiyor", + }, +}; + +export function logGuardSchemaStateLabel(locale: Locale, state: LogGuardSchemaState): string { + return SCHEMA_LABELS[locale][state]; +} diff --git a/gui/tests/storage-log-guard.test.tsx b/gui/tests/storage-log-guard.test.tsx new file mode 100644 index 0000000000..272f925b14 --- /dev/null +++ b/gui/tests/storage-log-guard.test.tsx @@ -0,0 +1,131 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import StorageWorkspace, { type StorageReport } from "../src/components/storage-workspace/StorageWorkspace"; +import { LanguageProvider } from "../src/i18n/provider"; +import { DICTS, I18nContext, interpolate, type TFn } from "../src/i18n/shared"; + +function report(): StorageReport { + return { + codexHome: "/home/user/.codex", + generatedAt: 1, + total: { bytes: 1024, fileCount: 1 }, + buckets: [], + codexLogs: { + generatedAt: 1, + externalSqliteHome: true, + snapshot: "checkpointed", + files: { databaseBytes: 8192, walBytes: 2048, shmBytes: 0 }, + schema: { state: "compatible" }, + capabilities: { + inspection: { state: "supported" }, + protection: { state: "supported" }, + reclaim: { state: "supported" }, + }, + metrics: { + totalRows: 400, + rowsByLevel: { TRACE: 200, INFO: 200 }, + traceRows: 200, + traceShare: 0.5, + topTargets: [{ target: "TARGET_1", rows: 180 }], + pageSize: 4096, + pageCount: 2, + freelistPages: 1, + reclaimableBytes: 4096, + estimatedLogBytes: 350, + }, + }, + }; +} + +function germanT(): TFn { + return (key, vars) => interpolate(DICTS.de[key] ?? DICTS.en[key] ?? key, vars); +} + +test("Storage overview renders read-only Codex diagnostic log health", () => { + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain('data-testid="codex-log-guard"'); + expect(html).toContain("Logs database"); + expect(html).toContain("Compatible"); + expect(html).not.toContain(">compatible<"); + expect(html).toContain("400"); + expect(html).toContain("50.0%"); + expect(html).toContain("8 KiB"); + expect(html).toContain("2 KiB"); + expect(html).toContain("4 KiB"); + expect(html).toContain("WAL"); + expect(html).toContain("SHM"); + expect(html).toContain("0 B"); + expect(html).toContain("TARGET_1"); + expect(html).not.toContain("codex_api::sse"); + expect(html).toContain("External SQLite storage"); + expect(html).toContain("snapshot=checkpointed"); + expect(html).not.toContain("/state/codex"); + expect(html).not.toContain("Protect"); + expect(html).not.toContain("Compact"); + expect(html).not.toContain("High write activity"); +}); + +test("Storage overview localizes schema, external, and inspect-only labels", () => { + const value = report(); + value.codexLogs = { + ...value.codexLogs!, + schema: { state: "unsupported", reason: "unknown_schema" }, + capabilities: { + inspection: { state: "supported" }, + protection: { state: "unsupported", reason: "unknown_schema" }, + reclaim: { state: "unsupported", reason: "unknown_schema" }, + }, + }; + + const html = renderToStaticMarkup( + {}, t: germanT() }}> + + , + ); + + expect(html).toContain("Nicht unterstützt"); + expect(html).not.toContain(">unsupported<"); + expect(html).toContain("Nur Inspektion"); + expect(html).toContain("Externer SQLite-Speicher"); + expect(html).not.toContain("inspection-only"); + expect(html).not.toContain("external sqlite_home"); + expect(html).not.toContain("/state/codex"); +}); + +test("Storage overview renders a fixed localized inspection-failure state", () => { + const value = report(); + value.codexLogs = null; + value.codexLogsError = "inspect_failed"; + + const html = renderToStaticMarkup( + {}, t: germanT() }}> + + , + ); + + expect(html).toContain('data-testid="codex-log-guard-unavailable"'); + expect(html).toContain("Die Diagnoseprotokoll-Inspektion ist nicht verfügbar."); + expect(html).not.toContain("inspect_failed"); +}); + +test("Storage overview does not render arbitrary Log Guard error strings", () => { + const value = report(); + value.codexLogs = null; + value.codexLogsError = "/private/state/logs_2.sqlite failed"; + + const html = renderToStaticMarkup( + + + , + ); + + expect(html).not.toContain('data-testid="codex-log-guard-unavailable"'); + expect(html).not.toContain("/private/state/logs_2.sqlite"); + expect(html).not.toContain("failed"); +}); diff --git a/src/cli/codex-log-guard-doctor.ts b/src/cli/codex-log-guard-doctor.ts new file mode 100644 index 0000000000..655a58fdad --- /dev/null +++ b/src/cli/codex-log-guard-doctor.ts @@ -0,0 +1,67 @@ +import { inspectCodexLogs, type CodexLogGuardInspection } from "../codex/log-guard/inspect"; + +function kib(bytes: number): string { + return `${(bytes / 1024).toFixed(1)} KiB`; +} + +function fileMetadataLines(report: CodexLogGuardInspection): string[] { + if (report.externalSqliteHome === null) return []; + const location = report.externalSqliteHome ? "external sqlite_home" : "CODEX_HOME sqlite_home"; + return [ + ` ${location}; DB ${kib(report.files.databaseBytes)}, WAL ${kib(report.files.walBytes)}, SHM ${kib(report.files.shmBytes)}`, + ]; +} + +export function formatCodexLogGuardDoctor(report: CodexLogGuardInspection): string[] { + const lines = ["Codex diagnostic logs"]; + + if (report.schema.state === "unavailable") { + lines.push(" -- inspection unavailable"); + return lines; + } + if (report.schema.state === "missing") { + lines.push(" -- logs_2.sqlite is not present"); + return lines; + } + if (report.schema.state === "unreadable") { + lines.push(" -- logs_2.sqlite is unreadable; inspection metadata only"); + lines.push(...fileMetadataLines(report)); + lines.push(" checkpointed read-only snapshot; activity rate not measured"); + return lines; + } + if (report.schema.state === "unsupported") { + lines.push(" -- unknown schema; inspection only"); + } else { + lines.push(" ok schema compatible"); + } + + lines.push(...fileMetadataLines(report)); + + if (report.metrics) { + lines.push( + ` ${report.metrics.totalRows} rows; TRACE ${(report.metrics.traceShare * 100).toFixed(1)}%; reclaimable ${kib(report.metrics.reclaimableBytes)}`, + ); + const top = report.metrics.topTargets[0]; + if (top) lines.push(` top target ${top.target} (${top.rows} rows)`); + } + + lines.push(" checkpointed read-only snapshot; activity rate not measured"); + return lines; +} + +export interface CodexLogGuardDoctorDeps { + inspect?: () => CodexLogGuardInspection; + log?: (line: string) => void; +} + +/** Observe-only doctor section. Inspection failures are reported without mutating or failing doctor. */ +export function printCodexLogGuardDoctor(deps: CodexLogGuardDoctorDeps = {}): void { + const inspect = deps.inspect ?? inspectCodexLogs; + const log = deps.log ?? console.log; + try { + for (const line of formatCodexLogGuardDoctor(inspect())) log(line); + } catch { + log("Codex diagnostic logs"); + log(" -- inspection unavailable"); + } +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 854c565646..f40f60c1e7 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -171,8 +171,14 @@ const commandRunners: Record = { return Number(process.exitCode ?? 0); }, doctor: async deps => { + const doctorArgs = deps.args.slice(1); const { runDoctor } = await import("./doctor"); - await runDoctor(deps.args.slice(1)); + await runDoctor(doctorArgs); + if (!doctorArgs.includes("--fix-codex-runtime")) { + console.log(""); + const { printCodexLogGuardDoctor } = await import("./codex-log-guard-doctor"); + printCodexLogGuardDoctor(); + } return 0; }, debug: async deps => { diff --git a/src/cli/observe.ts b/src/cli/observe.ts index a1e852cdd3..b6494d8924 100644 --- a/src/cli/observe.ts +++ b/src/cli/observe.ts @@ -18,7 +18,7 @@ const USAGE = `Usage: ocx logs rebuild-index ocx logs index-status ocx observe usage [--range <7d|30d|all>] [--surface ] [--json] - ocx observe storage [--json] + ocx observe storage [codex-logs [status]] [--json] ocx observe memory [--json] ocx observe debug [--json] ocx observe claude-inbound [--limit ] [--json] @@ -147,6 +147,23 @@ async function simple(path: string, argv: string[], deps: RuntimeApiDeps): Promi printData(result, wantsJson, summaryLines(result)); } +async function storage(argv: string[], deps: RuntimeApiDeps): Promise { + if (argv[0] !== "codex-logs") { + await simple("/api/storage", argv, deps); + return; + } + + const args = argv.slice(1); + if (args[0] === "status") args.shift(); + else if (args[0] && !args[0].startsWith("-")) { + throw new CliUsageError(`unknown codex-logs action ${args[0]}`, USAGE); + } + const wantsJson = takeFlag(args, "--json"); + rejectArgs(args, USAGE); + const result = await runtimeRequest("/api/storage/codex-logs", {}, deps); + printData(result, wantsJson, summaryLines(result)); +} + export async function handleObserveCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { return runCliAction(async () => { const [sub = "logs", ...rest] = argv; @@ -158,7 +175,7 @@ export async function handleObserveCommand(argv: string[], deps: RuntimeApiDeps else await logs(rest, deps); } else if (sub === "usage") await usage(rest, deps); - else if (sub === "storage") await simple("/api/storage", rest, deps); + else if (sub === "storage") await storage(rest, deps); else if (sub === "memory") await simple("/api/system/memory", rest, deps); else if (sub === "debug") await simple("/api/debug", rest, deps); else if (sub === "claude-inbound") await simple("/api/claude/inbound-debug", rest, deps); diff --git a/src/codex/log-guard/inspect.ts b/src/codex/log-guard/inspect.ts new file mode 100644 index 0000000000..342f95e87f --- /dev/null +++ b/src/codex/log-guard/inspect.ts @@ -0,0 +1,410 @@ +import { statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { Database, constants } from "bun:sqlite"; + +import { + getCodexHome, + resolveCodexSqliteHome, + type CodexSqliteHomeDeps, +} from "../paths"; + +const IMMUTABLE_READONLY_FLAGS = constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI; +const KNOWN_LOG_LEVELS = new Set(["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]); + +interface CurrentLogColumn { + name: string; + type: string; + notnull: number; + defaultValue: string | null; + pk: number; +} + +// Pinned to Codex logs migration 0002. Keep this schema private: inspection reports +// compatibility, not column names, so sensitive payload-bearing fields never leak through +// the management API. Any additive/rebuilt future schema is monitor-only until reviewed. +const CURRENT_LOG_SCHEMA: readonly CurrentLogColumn[] = [ + { name: "id", type: "INTEGER", notnull: 0, defaultValue: null, pk: 1 }, + { name: "ts", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 }, + { name: "ts_nanos", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 }, + { name: "level", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 }, + { name: "target", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 }, + { name: "feedback_log_body", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 }, + { name: "module_path", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 }, + { name: "file", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 }, + { name: "line", type: "INTEGER", notnull: 0, defaultValue: null, pk: 0 }, + { name: "thread_id", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 }, + { name: "process_uuid", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 }, + { name: "estimated_bytes", type: "INTEGER", notnull: 1, defaultValue: "0", pk: 0 }, +] as const; + +const CURRENT_LOG_TABLE_SQL = `CREATE TABLE logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + ts_nanos INTEGER NOT NULL, + level TEXT NOT NULL, + target TEXT NOT NULL, + feedback_log_body TEXT, + module_path TEXT, + file TEXT, + line INTEGER, + thread_id TEXT, + process_uuid TEXT, + estimated_bytes INTEGER NOT NULL DEFAULT 0 +)`; + +const CURRENT_LOG_INDEX_SQL = { + idx_logs_ts: "CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC)", + idx_logs_thread_id: "CREATE INDEX idx_logs_thread_id ON logs(thread_id)", + idx_logs_thread_id_ts: "CREATE INDEX idx_logs_thread_id_ts ON logs(thread_id, ts DESC, ts_nanos DESC, id DESC)", + idx_logs_process_uuid_threadless_ts: `CREATE INDEX idx_logs_process_uuid_threadless_ts + ON logs(process_uuid, ts DESC, ts_nanos DESC, id DESC) + WHERE thread_id IS NULL`, +} as const; + +export type CodexLogGuardCapabilityReason = + | "database_missing" + | "database_unreadable" + | "unknown_schema" + | "inspect_failed"; + +export type CodexLogGuardSchemaState = + | { state: "compatible" } + | { state: "missing"; reason: "database_missing" } + | { state: "unreadable"; reason: "database_unreadable" } + | { state: "unsupported"; reason: "unknown_schema" } + | { state: "unavailable"; reason: "inspect_failed" }; + +export type CodexLogGuardCapability = + | { state: "supported" } + | { state: "unsupported"; reason: CodexLogGuardCapabilityReason }; + +export interface CodexLogGuardMetrics { + totalRows: number; + rowsByLevel: Record; + traceRows: number; + traceShare: number; + topTargets: Array<{ target: string; rows: number }>; + pageSize: number; + pageCount: number; + freelistPages: number; + reclaimableBytes: number; + estimatedLogBytes: number | null; +} + +/** + * Serializable, privacy-safe Log Guard inspection result. + * + * Canonical filesystem paths are deliberately kept local to the inspector. Consumers get + * only the coarse location relation (`externalSqliteHome`) and aggregate file/SQLite data. + * `externalSqliteHome` is null only when config resolution itself is unavailable. + */ +export interface CodexLogGuardInspection { + generatedAt: number; + externalSqliteHome: boolean | null; + snapshot: "checkpointed"; + files: { + databaseBytes: number; + walBytes: number; + shmBytes: number; + }; + schema: CodexLogGuardSchemaState; + capabilities: { + inspection: CodexLogGuardCapability; + protection: CodexLogGuardCapability; + reclaim: CodexLogGuardCapability; + }; + metrics: CodexLogGuardMetrics | null; +} + +interface ColumnRow { + cid: number; + name: string; + type: string; + notnull: number; + dflt_value: string | null; + pk: number; +} +interface SchemaObjectRow { name: string; type: string; sql: string | null } +interface CountRow { n: number } +interface LevelRow { level: string; rows: number } +interface TargetCountRow { rows: number } +interface EstimatedBytesRow { bytes: number | null } + +type CanonicalTargetState = "missing" | "file" | "unusable"; + +function canonicalTargetState(path: string): CanonicalTargetState { + try { + return statSync(path).isFile() ? "file" : "unusable"; + } catch (error) { + return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT" ? "missing" : "unusable"; + } +} + +function fileSize(path: string): number { + try { + const stat = statSync(path); + return stat.isFile() ? stat.size : 0; + } catch { + return 0; + } +} + +function capabilityFor(schema: CodexLogGuardSchemaState): CodexLogGuardCapability { + if (schema.state === "compatible") return { state: "supported" }; + return { state: "unsupported", reason: schema.reason }; +} + +function unavailableInspection(): CodexLogGuardInspection { + const schema: CodexLogGuardSchemaState = { state: "unavailable", reason: "inspect_failed" }; + const unavailable: CodexLogGuardCapability = { state: "unsupported", reason: "inspect_failed" }; + return { + generatedAt: Date.now(), + externalSqliteHome: null, + snapshot: "checkpointed", + files: { databaseBytes: 0, walBytes: 0, shmBytes: 0 }, + schema, + capabilities: { + inspection: unavailable, + protection: unavailable, + reclaim: unavailable, + }, + metrics: null, + }; +} + +function normalizeDeclaredType(type: string): string { + return String(type ?? "").trim().toUpperCase(); +} + +function normalizeDefault(value: string | null): string | null { + return value === null ? null : String(value).trim(); +} + +function normalizeSchemaSql(sql: string | null | undefined): string { + return (sql ?? "").trim().replace(/;\s*$/, "").replace(/\s+/g, " "); +} + +function sameColumns(columns: ColumnRow[]): boolean { + if (columns.length !== CURRENT_LOG_SCHEMA.length) return false; + return columns.every((column, index) => { + const expected = CURRENT_LOG_SCHEMA[index]; + return column.cid === index + && column.name === expected.name + && normalizeDeclaredType(column.type) === expected.type + && Number(column.notnull) === expected.notnull + && normalizeDefault(column.dflt_value) === expected.defaultValue + && Number(column.pk) === expected.pk; + }); +} + +function hasCurrentLogsTable(db: Database, columns: ColumnRow[]): boolean { + const table = db.query( + "SELECT name, type, sql FROM sqlite_schema WHERE name = 'logs' LIMIT 1", + ).get(); + if (table?.type !== "table" + || !sameColumns(columns) + || normalizeSchemaSql(table.sql) !== normalizeSchemaSql(CURRENT_LOG_TABLE_SQL)) { + return false; + } + + const indexes = db.query(` + SELECT name, type, sql FROM sqlite_schema + WHERE name IN ( + 'idx_logs_ts', + 'idx_logs_thread_id', + 'idx_logs_thread_id_ts', + 'idx_logs_process_uuid_threadless_ts' + ) + `).all(); + const byName = new Map(indexes.map(row => [row.name, row])); + for (const [name, expectedSql] of Object.entries(CURRENT_LOG_INDEX_SQL)) { + const row = byName.get(name); + if (row?.type !== "index" || normalizeSchemaSql(row.sql) !== normalizeSchemaSql(expectedSql)) { + return false; + } + } + + // Extra indexes and triggers do not redefine the table contract. In particular, + // Protect intentionally installs OpenCodex-owned triggers and unrelated user triggers + // are supported, so compatibility is based on the canonical table plus required indexes. + return true; +} + +function pragmaNumber(db: Database, pragma: "page_size" | "page_count" | "freelist_count"): number { + const row = db.query, []>(`PRAGMA ${pragma}`).get(); + return Number(row?.[pragma] ?? 0); +} + +function readMetrics(db: Database, columns: string[]): CodexLogGuardMetrics | null { + if (!columns.includes("level") || !columns.includes("target")) return null; + + try { + const totalRows = Number(db.query("SELECT count(*) AS n FROM logs").get()?.n ?? 0); + const rowsByLevel: Record = {}; + for (const row of db.query( + "SELECT level, count(*) AS rows FROM logs GROUP BY level ORDER BY level", + ).all()) { + const rawLevel = String(row.level ?? "").toUpperCase(); + const level = KNOWN_LOG_LEVELS.has(rawLevel) ? rawLevel : "OTHER"; + rowsByLevel[level] = (rowsByLevel[level] ?? 0) + Number(row.rows ?? 0); + } + + // Preserve the useful top-target distribution without serializing target names from + // the foreign database. Rank labels are fixed output values and cannot carry local + // paths, credentials, or other injected diagnostic content. + const topTargets = db.query( + "SELECT count(*) AS rows FROM logs GROUP BY target ORDER BY rows DESC, target ASC LIMIT 10", + ).all().map((row, index) => ({ target: `TARGET_${index + 1}`, rows: Number(row.rows ?? 0) })); + + const pageSize = pragmaNumber(db, "page_size"); + const pageCount = pragmaNumber(db, "page_count"); + const freelistPages = pragmaNumber(db, "freelist_count"); + const traceRows = rowsByLevel.TRACE ?? 0; + const estimatedLogBytes = columns.includes("estimated_bytes") + ? Number(db.query( + "SELECT COALESCE(sum(estimated_bytes), 0) AS bytes FROM logs", + ).get()?.bytes ?? 0) + : null; + + return { + totalRows, + rowsByLevel, + traceRows, + traceShare: totalRows > 0 ? traceRows / totalRows : 0, + topTargets, + pageSize, + pageCount, + freelistPages, + reclaimableBytes: pageSize * freelistPages, + estimatedLogBytes, + }; + } catch { + // A future schema may still have a `logs` table but change aggregate-compatible + // columns or virtual-table behaviour. Metadata inspection remains valid; metrics do + // not become a reason to throw or to open a writable connection. + return null; + } +} + +/** + * Inspect the canonical Codex diagnostic log database without participating in SQLite's + * write/locking protocol. `immutable=1` intentionally observes the last checkpointed + * snapshot: file sizes include live sidecars, while SQL aggregates may lag a live WAL. + * This is a zero-write health view, not an SSD/NAND write-rate measurement. + */ +export function inspectCodexLogs(deps: CodexSqliteHomeDeps = {}): CodexLogGuardInspection { + let codexHome: string; + let sqliteHome: string; + try { + codexHome = deps.codexHome ?? getCodexHome(); + sqliteHome = resolveCodexSqliteHome({ ...deps, codexHome }); + } catch { + // Resolution errors can include config paths. Convert them to a fixed, non-fatal + // inspection state before they cross any diagnostic/API boundary. + return unavailableInspection(); + } + + // Resolve sqlite_home exactly once so the location flag and inspected file cannot refer + // to different roots if config changes during one inspection. + const databasePath = join(sqliteHome, "logs_2.sqlite"); + const targetState = canonicalTargetState(databasePath); + const files = { + databaseBytes: fileSize(databasePath), + walBytes: fileSize(`${databasePath}-wal`), + shmBytes: fileSize(`${databasePath}-shm`), + }; + + const common = { + generatedAt: Date.now(), + externalSqliteHome: resolve(sqliteHome) !== resolve(codexHome), + snapshot: "checkpointed" as const, + files, + }; + + if (targetState === "missing") { + const schema: CodexLogGuardSchemaState = { state: "missing", reason: "database_missing" }; + const mutation = capabilityFor(schema); + return { + ...common, + schema, + metrics: null, + capabilities: { + inspection: { state: "supported" }, + protection: mutation, + reclaim: mutation, + }, + }; + } + + if (targetState === "unusable") { + const schema: CodexLogGuardSchemaState = { state: "unreadable", reason: "database_unreadable" }; + const mutation = capabilityFor(schema); + return { + ...common, + schema, + metrics: null, + capabilities: { + inspection: { state: "supported" }, + protection: mutation, + reclaim: mutation, + }, + }; + } + + // SQLite accepts a zero-byte file as an empty database. For Log Guard this is not a + // compatible future schema: Codex's canonical logs database must already contain its + // migrated `logs` table before any future mutation capability can be considered safe. + if (files.databaseBytes === 0) { + const schema: CodexLogGuardSchemaState = { state: "unreadable", reason: "database_unreadable" }; + const mutation = capabilityFor(schema); + return { + ...common, + schema, + metrics: null, + capabilities: { + inspection: { state: "supported" }, + protection: mutation, + reclaim: mutation, + }, + }; + } + + try { + const uri = `${pathToFileURL(databasePath).href}?immutable=1`; + const db = new Database(uri, IMMUTABLE_READONLY_FLAGS); + try { + const columnRows = db.query("PRAGMA table_info(logs)").all(); + const columns = columnRows.map(row => row.name); + const schema: CodexLogGuardSchemaState = hasCurrentLogsTable(db, columnRows) + ? { state: "compatible" } + : { state: "unsupported", reason: "unknown_schema" }; + const mutation = capabilityFor(schema); + return { + ...common, + schema, + metrics: readMetrics(db, columns), + capabilities: { + inspection: { state: "supported" }, + protection: mutation, + reclaim: mutation, + }, + }; + } finally { + db.close(); + } + } catch { + const schema: CodexLogGuardSchemaState = { state: "unreadable", reason: "database_unreadable" }; + const mutation = capabilityFor(schema); + return { + ...common, + schema, + metrics: null, + capabilities: { + inspection: { state: "supported" }, + protection: mutation, + reclaim: mutation, + }, + }; + } +} diff --git a/src/codex/paths.ts b/src/codex/paths.ts index 503c57e55e..5ee3a4cdba 100644 --- a/src/codex/paths.ts +++ b/src/codex/paths.ts @@ -108,6 +108,11 @@ export function resolveCodexStateDbPath(deps: CodexSqliteHomeDeps = {}): string return join(resolveCodexSqliteHome(deps), "state_5.sqlite"); } +/** Active Codex diagnostic-log database, derived from the call-time SQLite root. */ +export function resolveCodexLogsDbPath(deps: CodexSqliteHomeDeps = {}): string { + return join(resolveCodexSqliteHome(deps), "logs_2.sqlite"); +} + export function tomlString(value: string): string { return JSON.stringify(value); } diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 3f32e51d09..4110f07255 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -59,6 +59,7 @@ import { applySystemEnvToggle } from "./system-env"; import type { ManagementApiDeps } from "./management/context"; import { handleConfigRoutes } from "./management/config-routes"; import { handleLogsUsageRoutes } from "./management/logs-usage-routes"; +import { handleStorageLogGuardRoutes } from "./management/storage-log-guard-routes"; import { handleRequestHistoryRoutes } from "./management/request-history-routes"; import { handleRoutingAnalyticsRoutes } from "./management/routing-analytics-routes"; import { handleProviderRoutes } from "./management/provider-routes"; @@ -209,6 +210,7 @@ export async function handleManagementAPI( let routed: Response | null; try { routed = (await handleConfigRoutes(ctx)) + ?? (await handleStorageLogGuardRoutes(ctx)) ?? (await handleLogsUsageRoutes(ctx)) ?? (await handleRequestHistoryRoutes(ctx)) ?? (await handleRoutingAnalyticsRoutes(ctx)) @@ -300,4 +302,4 @@ export async function handleManagementAPI( } -export { buildClaudeDesktopState, fetchAllModels } from "./management/shared"; +export { buildClaudeDesktopState, fetchAllModels } from "./management/shared"; \ No newline at end of file diff --git a/src/server/management/storage-log-guard-routes.ts b/src/server/management/storage-log-guard-routes.ts new file mode 100644 index 0000000000..3d22d608b7 --- /dev/null +++ b/src/server/management/storage-log-guard-routes.ts @@ -0,0 +1,69 @@ +import { resolveCodexHomeDir } from "../../codex/home"; +import { inspectCodexLogs, type CodexLogGuardInspection } from "../../codex/log-guard/inspect"; +import { scanStorage } from "../../storage/scanner"; +import { jsonResponse } from "../auth-cors"; +import type { ManagementContext } from "./context"; + +const INSPECTION_FAILED_MESSAGE = "Codex log inspection failed"; + +function inspectionUnavailable(report: CodexLogGuardInspection): boolean { + return report.schema.state === "unavailable"; +} + +/** Read-only Codex Log Guard management surface. Mutation endpoints arrive in PR 2/3. */ +export async function handleStorageLogGuardRoutes(ctx: ManagementContext): Promise { + const { req, url, config } = ctx; + if (req.method !== "GET") return null; + + if (url.pathname === "/api/storage/codex-logs") { + try { + const report = inspectCodexLogs(); + if (inspectionUnavailable(report)) { + return jsonResponse({ error: "inspect_failed", message: INSPECTION_FAILED_MESSAGE }, 500, req, config); + } + return jsonResponse(report, 200, req, config); + } catch { + return jsonResponse({ + error: "inspect_failed", + message: INSPECTION_FAILED_MESSAGE, + }, 500, req, config); + } + } + + if (url.pathname !== "/api/storage") return null; + + // Keep the existing CODEX_HOME scan as the primary storage contract. The Log Guard + // report is attached separately so an external sqlite_home is visible without being + // silently folded into CODEX_HOME totals. + let storage; + try { + storage = scanStorage(); + } catch { + const fallback = { + codexHome: resolveCodexHomeDir(), + generatedAt: Date.now(), + total: { bytes: 0, fileCount: 0 }, + buckets: [], + error: "scan_failed", + }; + try { + const report = inspectCodexLogs(); + return inspectionUnavailable(report) + ? jsonResponse({ ...fallback, codexLogs: null, codexLogsError: "inspect_failed" }, 200, req, config) + : jsonResponse({ ...fallback, codexLogs: report }, 200, req, config); + } catch { + return jsonResponse({ ...fallback, codexLogs: null, codexLogsError: "inspect_failed" }, 200, req, config); + } + } + + try { + const report = inspectCodexLogs(); + return inspectionUnavailable(report) + ? jsonResponse({ ...storage, codexLogs: null, codexLogsError: "inspect_failed" }, 200, req, config) + : jsonResponse({ ...storage, codexLogs: report }, 200, req, config); + } catch { + // Log Guard inspection is auxiliary to the existing Storage page. A config/path + // resolution failure must not take the legacy read-only storage report down with it. + return jsonResponse({ ...storage, codexLogs: null, codexLogsError: "inspect_failed" }, 200, req, config); + } +} diff --git a/tests/api-codex-log-guard.test.ts b/tests/api-codex-log-guard.test.ts new file mode 100644 index 0000000000..3c386fabf6 --- /dev/null +++ b/tests/api-codex-log-guard.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { handleManagementAPI } from "../src/server/management-api"; +import type { OcxConfig } from "../src/types"; +import { ManagementRequest } from "./helpers/management-auth"; + +const roots: string[] = []; +const originalCodexHome = process.env.CODEX_HOME; + +function makeLogsDb(path: string): void { + const db = new Database(path); + db.exec("PRAGMA journal_mode=WAL"); + db.exec(` + CREATE TABLE logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + ts_nanos INTEGER NOT NULL, + level TEXT NOT NULL, + target TEXT NOT NULL, + feedback_log_body TEXT, + module_path TEXT, + file TEXT, + line INTEGER, + thread_id TEXT, + process_uuid TEXT, + estimated_bytes INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC); + CREATE INDEX idx_logs_thread_id ON logs(thread_id); + CREATE INDEX idx_logs_thread_id_ts ON logs(thread_id, ts DESC, ts_nanos DESC, id DESC); + CREATE INDEX idx_logs_process_uuid_threadless_ts + ON logs(process_uuid, ts DESC, ts_nanos DESC, id DESC) + WHERE thread_id IS NULL; + INSERT INTO logs (ts, ts_nanos, level, target, feedback_log_body, estimated_bytes) + VALUES (1, 0, 'TRACE', 'codex_api::sse', 'PRIVATE API BODY', 100); + `); + db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + db.close(); + for (const suffix of ["-wal", "-shm"]) { + try { + unlinkSync(`${path}${suffix}`); + } catch (error) { + if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") throw error; + } + } +} + +function config(): OcxConfig { + return { port: 0, defaultProvider: "openai", providers: {} } as OcxConfig; +} + +afterEach(() => { + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("Codex Log Guard management API", () => { + test("GET /api/storage/codex-logs returns privacy-safe read-only diagnostics", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-log-guard-api-")); + roots.push(root); + const codexHome = join(root, "codex-home"); + const sqliteHome = join(root, "sqlite-home"); + mkdirSync(codexHome); + mkdirSync(sqliteHome); + writeFileSync(join(codexHome, "config.toml"), `sqlite_home = ${JSON.stringify(sqliteHome)}\n`); + makeLogsDb(join(sqliteHome, "logs_2.sqlite")); + process.env.CODEX_HOME = codexHome; + + const req = new ManagementRequest("http://localhost/api/storage/codex-logs", { method: "GET" }); + const response = await handleManagementAPI(req, new URL(req.url), config(), { refreshCodexCatalog: async () => {} }); + + expect(response).not.toBeNull(); + expect(response!.status).toBe(200); + const body = await response!.json() as Record; + expect(body.externalSqliteHome).toBe(true); + expect(body).not.toHaveProperty("sqliteHome"); + expect(body).not.toHaveProperty("databasePath"); + expect(body).not.toHaveProperty("codexHome"); + expect(JSON.stringify(body)).not.toContain(sqliteHome); + expect(JSON.stringify(body)).not.toContain(codexHome); + expect(JSON.stringify(body)).not.toContain("PRIVATE API BODY"); + }); + + test("GET /api/storage carries path-safe diagnostics without folding external SQLite into CODEX_HOME totals", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-log-guard-storage-")); + roots.push(root); + const codexHome = join(root, "codex-home"); + const sqliteHome = join(root, "sqlite-home"); + mkdirSync(codexHome); + mkdirSync(sqliteHome); + writeFileSync(join(codexHome, "config.toml"), `sqlite_home = ${JSON.stringify(sqliteHome)}\n`); + makeLogsDb(join(sqliteHome, "logs_2.sqlite")); + process.env.CODEX_HOME = codexHome; + + const req = new ManagementRequest("http://localhost/api/storage", { method: "GET" }); + const response = await handleManagementAPI(req, new URL(req.url), config(), { refreshCodexCatalog: async () => {} }); + + expect(response).not.toBeNull(); + expect(response!.status).toBe(200); + const body = await response!.json() as { + total: { bytes: number }; + codexLogs?: { + externalSqliteHome: boolean; + files: { databaseBytes: number }; + }; + }; + expect(body.codexLogs?.externalSqliteHome).toBe(true); + expect(body.codexLogs!.files.databaseBytes).toBeGreaterThan(body.total.bytes); + expect(body.codexLogs).not.toHaveProperty("sqliteHome"); + expect(body.codexLogs).not.toHaveProperty("databasePath"); + expect(body.codexLogs).not.toHaveProperty("codexHome"); + expect(JSON.stringify(body.codexLogs)).not.toContain(sqliteHome); + expect(JSON.stringify(body.codexLogs)).not.toContain(codexHome); + expect(JSON.stringify(body.codexLogs)).not.toContain("PRIVATE API BODY"); + }); + + test("inspection failures return a stable message without leaking the config path", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-log-guard-api-error-")); + roots.push(root); + const codexHome = join(root, "private-codex-home"); + mkdirSync(codexHome); + writeFileSync(join(codexHome, "config.toml"), "sqlite_home = 42\n"); + process.env.CODEX_HOME = codexHome; + + const req = new ManagementRequest("http://localhost/api/storage/codex-logs", { method: "GET" }); + const response = await handleManagementAPI(req, new URL(req.url), config(), { refreshCodexCatalog: async () => {} }); + + expect(response).not.toBeNull(); + expect(response!.status).toBe(500); + expect(await response!.json()).toEqual({ + error: "inspect_failed", + message: "Codex log inspection failed", + }); + }); +}); diff --git a/tests/cli-codex-log-guard.test.ts b/tests/cli-codex-log-guard.test.ts new file mode 100644 index 0000000000..aef6b2e790 --- /dev/null +++ b/tests/cli-codex-log-guard.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; + +import { handleObserveCommand } from "../src/cli/observe"; + +describe("Codex Log Guard CLI", () => { + test("ocx storage codex-logs status uses the dedicated diagnostics endpoint", async () => { + const seen: string[] = []; + const fetchImpl: typeof fetch = async input => { + seen.push(String(input)); + return new Response(JSON.stringify({ schema: { state: "compatible" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + const originalLog = console.log; + console.log = () => {}; + try { + const code = await handleObserveCommand( + ["storage", "codex-logs", "status", "--json"], + { baseUrl: "http://runtime", fetchImpl }, + ); + expect(code).toBe(0); + expect(seen).toEqual(["http://runtime/api/storage/codex-logs"]); + } finally { + console.log = originalLog; + } + }); + + test("ocx storage remains an alias for the existing storage report", async () => { + const seen: string[] = []; + const fetchImpl: typeof fetch = async input => { + seen.push(String(input)); + return new Response(JSON.stringify({ total: { bytes: 0, fileCount: 0 }, buckets: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + const originalLog = console.log; + console.log = () => {}; + try { + const code = await handleObserveCommand(["storage", "--json"], { baseUrl: "http://runtime", fetchImpl }); + expect(code).toBe(0); + expect(seen).toEqual(["http://runtime/api/storage"]); + } finally { + console.log = originalLog; + } + }); +}); diff --git a/tests/codex-log-guard-doctor.test.ts b/tests/codex-log-guard-doctor.test.ts new file mode 100644 index 0000000000..de9be399d0 --- /dev/null +++ b/tests/codex-log-guard-doctor.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test"; + +import { formatCodexLogGuardDoctor, printCodexLogGuardDoctor } from "../src/cli/codex-log-guard-doctor"; +import type { CodexLogGuardInspection } from "../src/codex/log-guard/inspect"; + +function report(overrides: Partial = {}): CodexLogGuardInspection { + return { + generatedAt: 1, + externalSqliteHome: true, + snapshot: "checkpointed", + files: { databaseBytes: 10 * 1024, walBytes: 2 * 1024, shmBytes: 3 * 1024 }, + schema: { state: "compatible" }, + capabilities: { + inspection: { state: "supported" }, + protection: { state: "supported" }, + reclaim: { state: "supported" }, + }, + metrics: { + totalRows: 4, + rowsByLevel: { TRACE: 2, INFO: 2 }, + traceRows: 2, + traceShare: 0.5, + topTargets: [{ target: "codex_api::sse", rows: 2 }], + pageSize: 4096, + pageCount: 3, + freelistPages: 1, + reclaimableBytes: 4096, + estimatedLogBytes: 350, + }, + ...overrides, + }; +} + +describe("Codex Log Guard doctor output", () => { + test("summarizes compatible diagnostics without inventing a write-rate threshold", () => { + const lines = formatCodexLogGuardDoctor(report()); + const text = lines.join("\n"); + + expect(lines[0]).toBe("Codex diagnostic logs"); + expect(text).toContain("schema compatible"); + expect(text).toContain("4 rows"); + expect(text).toContain("TRACE 50.0%"); + expect(text).toContain("reclaimable 4.0 KiB"); + expect(text).toContain("external sqlite_home"); + expect(text).toContain("DB 10.0 KiB"); + expect(text).toContain("WAL 2.0 KiB"); + expect(text).toContain("SHM 3.0 KiB"); + expect(text).not.toContain("high write activity"); + expect(text).not.toContain("TBW"); + expect(text).not.toContain("NAND"); + }); + + test("warns that a future schema is inspect-only", () => { + const lines = formatCodexLogGuardDoctor(report({ + schema: { state: "unsupported", reason: "unknown_schema" }, + capabilities: { + inspection: { state: "supported" }, + protection: { state: "unsupported", reason: "unknown_schema" }, + reclaim: { state: "unsupported", reason: "unknown_schema" }, + }, + })); + + expect(lines.join("\n")).toContain("unknown schema; inspection only"); + }); + + test("reports file metadata for unreadable databases", () => { + const lines = formatCodexLogGuardDoctor(report({ + files: { databaseBytes: 1024, walBytes: 2048, shmBytes: 4096 }, + schema: { state: "unreadable", reason: "database_unreadable" }, + capabilities: { + inspection: { state: "supported" }, + protection: { state: "unsupported", reason: "database_unreadable" }, + reclaim: { state: "unsupported", reason: "database_unreadable" }, + }, + metrics: null, + })); + const text = lines.join("\n"); + + expect(text).toContain("logs_2.sqlite is unreadable"); + expect(text).toContain("DB 1.0 KiB"); + expect(text).toContain("WAL 2.0 KiB"); + expect(text).toContain("SHM 4.0 KiB"); + }); + + test("reports a missing database as an informational absence", () => { + const lines = formatCodexLogGuardDoctor(report({ + files: { databaseBytes: 0, walBytes: 0, shmBytes: 0 }, + schema: { state: "missing", reason: "database_missing" }, + capabilities: { + inspection: { state: "supported" }, + protection: { state: "unsupported", reason: "database_missing" }, + reclaim: { state: "unsupported", reason: "database_missing" }, + }, + metrics: null, + })); + + expect(lines.join("\n")).toContain("logs_2.sqlite is not present"); + }); + + test("printer obtains the report through an injectable read-only inspector", () => { + let inspections = 0; + const lines: string[] = []; + + printCodexLogGuardDoctor({ + inspect: () => { + inspections += 1; + return report(); + }, + log: line => lines.push(line), + }); + + expect(inspections).toBe(1); + expect(lines[0]).toBe("Codex diagnostic logs"); + expect(lines.join("\n")).toContain("TRACE 50.0%"); + }); + + test("printer redacts path-bearing inspection failures", () => { + const lines: string[] = []; + printCodexLogGuardDoctor({ + inspect: () => { throw new Error("failed at /private/state/logs_2.sqlite"); }, + log: line => lines.push(line), + }); + + const text = lines.join("\n"); + expect(text).toContain("inspection unavailable"); + expect(text).not.toContain("/private/state"); + expect(text).not.toContain("failed at"); + }); +}); diff --git a/tests/codex-log-guard-inspect.test.ts b/tests/codex-log-guard-inspect.test.ts new file mode 100644 index 0000000000..6b72a32da8 --- /dev/null +++ b/tests/codex-log-guard-inspect.test.ts @@ -0,0 +1,411 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { inspectCodexLogs } from "../src/codex/log-guard/inspect"; + +const roots: string[] = []; + +function makeRoot(): string { + const root = mkdtempSync(join(tmpdir(), "ocx-log-guard-")); + roots.push(root); + return root; +} + +function createCurrentLogsDb(path: string): void { + const db = new Database(path); + db.exec("PRAGMA journal_mode=WAL"); + db.exec(` + CREATE TABLE logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + ts_nanos INTEGER NOT NULL, + level TEXT NOT NULL, + target TEXT NOT NULL, + feedback_log_body TEXT, + module_path TEXT, + file TEXT, + line INTEGER, + thread_id TEXT, + process_uuid TEXT, + estimated_bytes INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC); + CREATE INDEX idx_logs_thread_id ON logs(thread_id); + CREATE INDEX idx_logs_thread_id_ts ON logs(thread_id, ts DESC, ts_nanos DESC, id DESC); + CREATE INDEX idx_logs_process_uuid_threadless_ts + ON logs(process_uuid, ts DESC, ts_nanos DESC, id DESC) + WHERE thread_id IS NULL; + `); + const insert = db.query(` + INSERT INTO logs ( + ts, ts_nanos, level, target, feedback_log_body, + module_path, file, line, thread_id, process_uuid, estimated_bytes + ) VALUES (?, 0, ?, ?, ?, NULL, NULL, NULL, NULL, ?, ?) + `); + insert.run(1, "TRACE", "codex_api::sse", "PRIVATE prompt alpha", "proc-a", 100); + insert.run(2, "TRACE", "codex_api::sse", "PRIVATE prompt beta", "proc-a", 200); + insert.run(3, "INFO", "codex_core", "PRIVATE info body", "proc-a", 50); + insert.run(4, "WARN", "codex_core", "PRIVATE warning body", "proc-b", 75); + db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + db.close(); + for (const suffix of ["-wal", "-shm"]) { + try { + unlinkSync(`${path}${suffix}`); + } catch (error) { + if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") throw error; + } + } +} + +function snapshotDir(path: string): Map { + const snapshot = new Map(); + for (const name of readdirSync(path)) { + const full = join(path, name); + const stat = statSync(full); + snapshot.set(name, { size: stat.size, mtimeMs: stat.mtimeMs }); + } + return snapshot; +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("Codex Log Guard inspection", () => { + test("inspects only canonical logs_2.sqlite from resolved sqlite_home", () => { + const root = makeRoot(); + const codexHome = join(root, "codex-home"); + const sqliteHome = join(root, "sqlite-home"); + mkdirSync(codexHome); + mkdirSync(sqliteHome); + writeFileSync(join(codexHome, "config.toml"), `sqlite_home = ${JSON.stringify(sqliteHome)}\n`); + createCurrentLogsDb(join(sqliteHome, "logs_2.sqlite")); + writeFileSync(join(sqliteHome, "logs_99.sqlite"), "not a database"); + + const report = inspectCodexLogs({ codexHome }); + + expect(report.externalSqliteHome).toBe(true); + expect(report).not.toHaveProperty("sqliteHome"); + expect(report).not.toHaveProperty("databasePath"); + expect(report).not.toHaveProperty("codexHome"); + expect(report.schema.state).toBe("compatible"); + expect(report.capabilities).toEqual({ + inspection: { state: "supported" }, + protection: { state: "supported" }, + reclaim: { state: "supported" }, + }); + expect(report.metrics?.totalRows).toBe(4); + expect(report.metrics?.rowsByLevel).toEqual({ INFO: 1, TRACE: 2, WARN: 1 }); + expect(report.metrics?.traceRows).toBe(2); + expect(report.metrics?.traceShare).toBe(0.5); + expect(report.metrics?.topTargets[0]).toEqual({ target: "TARGET_1", rows: 2 }); + expect(report.metrics?.reclaimableBytes).toBeGreaterThanOrEqual(0); + }); + + test("never exposes feedback bodies, arbitrary levels, target names, or paths", () => { + const root = makeRoot(); + const codexHome = join(root, "codex-home"); + const databasePath = join(codexHome, "logs_2.sqlite"); + const sensitiveTarget = "token=abc@diagnostic-target"; + mkdirSync(codexHome); + writeFileSync(join(codexHome, "config.toml"), ""); + createCurrentLogsDb(databasePath); + + const db = new Database(databasePath); + db.query(` + INSERT INTO logs ( + ts, ts_nanos, level, target, feedback_log_body, + module_path, file, line, thread_id, process_uuid, estimated_bytes + ) VALUES (?, 0, ?, ?, ?, NULL, NULL, NULL, NULL, ?, ?) + `).run(5, "SECRET_LEVEL_TOKEN", sensitiveTarget, "PRIVATE injected body", "proc-c", 25); + db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + db.close(); + + const report = inspectCodexLogs({ codexHome }); + const serialized = JSON.stringify(report); + + expect(serialized).not.toContain("PRIVATE prompt alpha"); + expect(serialized).not.toContain("PRIVATE prompt beta"); + expect(serialized).not.toContain("PRIVATE info body"); + expect(serialized).not.toContain("PRIVATE injected body"); + expect(serialized).not.toContain("feedback_log_body"); + expect(serialized).not.toContain("SECRET_LEVEL_TOKEN"); + expect(serialized).not.toContain(sensitiveTarget); + expect(serialized).not.toContain(codexHome); + expect(report.metrics?.rowsByLevel.OTHER).toBe(1); + expect(report.metrics?.topTargets.every(item => /^TARGET_\d+$/.test(item.target))).toBe(true); + }); + + test("performs zero filesystem writes and does not create WAL/SHM sidecars", () => { + const root = makeRoot(); + const codexHome = join(root, "codex-home"); + mkdirSync(codexHome); + writeFileSync(join(codexHome, "config.toml"), ""); + createCurrentLogsDb(join(codexHome, "logs_2.sqlite")); + const before = snapshotDir(codexHome); + + inspectCodexLogs({ codexHome }); + + const after = snapshotDir(codexHome); + expect(after).toEqual(before); + expect(readdirSync(codexHome)).not.toContain("logs_2.sqlite-wal"); + expect(readdirSync(codexHome)).not.toContain("logs_2.sqlite-shm"); + }); + + test("monitors an unknown future schema but refuses mutation capabilities", () => { + const root = makeRoot(); + const codexHome = join(root, "codex-home"); + mkdirSync(codexHome); + writeFileSync(join(codexHome, "config.toml"), ""); + createCurrentLogsDb(join(codexHome, "logs_2.sqlite")); + const db = new Database(join(codexHome, "logs_2.sqlite")); + db.exec("ALTER TABLE logs ADD COLUMN future_field TEXT"); + db.close(); + + const report = inspectCodexLogs({ codexHome }); + + expect(report.schema.state).toBe("unsupported"); + expect(report.schema.reason).toBe("unknown_schema"); + expect(report.metrics?.totalRows).toBe(4); + expect(report.capabilities.inspection).toEqual({ state: "supported" }); + expect(report.capabilities.protection).toEqual({ state: "unsupported", reason: "unknown_schema" }); + expect(report.capabilities.reclaim).toEqual({ state: "unsupported", reason: "unknown_schema" }); + }); + + test("refuses a same-name logs view as mutation-compatible", () => { + const root = makeRoot(); + const codexHome = join(root, "codex-home"); + const databasePath = join(codexHome, "logs_2.sqlite"); + mkdirSync(codexHome); + writeFileSync(join(codexHome, "config.toml"), ""); + createCurrentLogsDb(databasePath); + const db = new Database(databasePath); + db.exec(` + ALTER TABLE logs RENAME TO logs_source; + CREATE VIEW logs AS + SELECT id, ts, ts_nanos, level, target, feedback_log_body, module_path, + file, line, thread_id, process_uuid, estimated_bytes + FROM logs_source; + `); + db.close(); + + const report = inspectCodexLogs({ codexHome }); + + expect(report.schema).toEqual({ state: "unsupported", reason: "unknown_schema" }); + expect(report.capabilities.protection).toEqual({ state: "unsupported", reason: "unknown_schema" }); + expect(report.capabilities.reclaim).toEqual({ state: "unsupported", reason: "unknown_schema" }); + }); + + test("refuses changed column types or constraints despite matching names", () => { + const root = makeRoot(); + const codexHome = join(root, "codex-home"); + const databasePath = join(codexHome, "logs_2.sqlite"); + mkdirSync(codexHome); + writeFileSync(join(codexHome, "config.toml"), ""); + createCurrentLogsDb(databasePath); + const db = new Database(databasePath); + db.exec(` + ALTER TABLE logs RENAME TO logs_source; + CREATE TABLE logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + ts_nanos INTEGER NOT NULL, + level BLOB NOT NULL, + target TEXT NOT NULL, + feedback_log_body TEXT, + module_path TEXT, + file TEXT, + line INTEGER, + thread_id TEXT, + process_uuid TEXT, + estimated_bytes INTEGER NOT NULL DEFAULT 0 + ); + `); + db.close(); + + const report = inspectCodexLogs({ codexHome }); + + expect(report.schema).toEqual({ state: "unsupported", reason: "unknown_schema" }); + expect(report.capabilities.protection).toEqual({ state: "unsupported", reason: "unknown_schema" }); + expect(report.capabilities.reclaim).toEqual({ state: "unsupported", reason: "unknown_schema" }); + }); + + test("refuses same columns when table-level DDL differs", () => { + const root = makeRoot(); + const codexHome = join(root, "codex-home"); + const databasePath = join(codexHome, "logs_2.sqlite"); + mkdirSync(codexHome); + writeFileSync(join(codexHome, "config.toml"), ""); + createCurrentLogsDb(databasePath); + const db = new Database(databasePath); + db.exec(` + ALTER TABLE logs RENAME TO logs_source; + CREATE TABLE logs ( + id INTEGER PRIMARY KEY, + ts INTEGER NOT NULL, + ts_nanos INTEGER NOT NULL, + level TEXT NOT NULL, + target TEXT NOT NULL, + feedback_log_body TEXT, + module_path TEXT, + file TEXT, + line INTEGER, + thread_id TEXT, + process_uuid TEXT, + estimated_bytes INTEGER NOT NULL DEFAULT 0 + ); + `); + db.close(); + + const report = inspectCodexLogs({ codexHome }); + + expect(report.schema).toEqual({ state: "unsupported", reason: "unknown_schema" }); + expect(report.capabilities.protection).toEqual({ state: "unsupported", reason: "unknown_schema" }); + expect(report.capabilities.reclaim).toEqual({ state: "unsupported", reason: "unknown_schema" }); + }); + + test("requires every canonical Codex logs index", () => { + const root = makeRoot(); + const codexHome = join(root, "codex-home"); + const databasePath = join(codexHome, "logs_2.sqlite"); + mkdirSync(codexHome); + writeFileSync(join(codexHome, "config.toml"), ""); + createCurrentLogsDb(databasePath); + const db = new Database(databasePath); + db.exec("DROP INDEX idx_logs_thread_id_ts"); + db.close(); + + const report = inspectCodexLogs({ codexHome }); + + expect(report.schema).toEqual({ state: "unsupported", reason: "unknown_schema" }); + expect(report.capabilities.protection).toEqual({ state: "unsupported", reason: "unknown_schema" }); + expect(report.capabilities.reclaim).toEqual({ state: "unsupported", reason: "unknown_schema" }); + }); + + test("allows unrelated user triggers without weakening schema validation", () => { + const root = makeRoot(); + const codexHome = join(root, "codex-home"); + const databasePath = join(codexHome, "logs_2.sqlite"); + mkdirSync(codexHome); + writeFileSync(join(codexHome, "config.toml"), ""); + createCurrentLogsDb(databasePath); + const db = new Database(databasePath); + db.exec(` + CREATE TRIGGER user_logs_observer + AFTER INSERT ON logs + BEGIN + SELECT 1; + END; + `); + db.close(); + + const report = inspectCodexLogs({ codexHome }); + + expect(report.schema).toEqual({ state: "compatible" }); + expect(report.capabilities.protection).toEqual({ state: "supported" }); + expect(report.capabilities.reclaim).toEqual({ state: "supported" }); + }); + + test("returns path-private unavailable state when sqlite_home resolution fails", () => { + const root = makeRoot(); + const codexHome = join(root, "codex-home"); + const privatePath = join(root, "private-config-location"); + mkdirSync(codexHome); + + const report = inspectCodexLogs({ + codexHome, + readConfig: () => { + const error = new Error(`cannot read ${privatePath}`) as NodeJS.ErrnoException; + error.code = "EACCES"; + throw error; + }, + }); + const serialized = JSON.stringify(report); + + expect(report.externalSqliteHome).toBeNull(); + expect(report.schema).toEqual({ state: "unavailable", reason: "inspect_failed" }); + expect(report.capabilities).toEqual({ + inspection: { state: "unsupported", reason: "inspect_failed" }, + protection: { state: "unsupported", reason: "inspect_failed" }, + reclaim: { state: "unsupported", reason: "inspect_failed" }, + }); + expect(report.files).toEqual({ databaseBytes: 0, walBytes: 0, shmBytes: 0 }); + expect(report.metrics).toBeNull(); + expect(serialized).not.toContain(root); + expect(serialized).not.toContain(privatePath); + }); + + test("reports a missing canonical database without falling back to logs_N", () => { + const root = makeRoot(); + const codexHome = join(root, "codex-home"); + mkdirSync(codexHome); + writeFileSync(join(codexHome, "config.toml"), ""); + writeFileSync(join(codexHome, "logs_3.sqlite"), "future-looking file"); + + const report = inspectCodexLogs({ codexHome }); + + expect(report.schema).toEqual({ state: "missing", reason: "database_missing" }); + expect(report.metrics).toBeNull(); + expect(report.files).toEqual({ databaseBytes: 0, walBytes: 0, shmBytes: 0 }); + expect(report.capabilities.protection).toEqual({ state: "unsupported", reason: "database_missing" }); + expect(report.capabilities.reclaim).toEqual({ state: "unsupported", reason: "database_missing" }); + }); + + test("treats a canonical database directory as unreadable, not missing", () => { + const root = makeRoot(); + const codexHome = join(root, "codex-home"); + mkdirSync(codexHome); + writeFileSync(join(codexHome, "config.toml"), ""); + mkdirSync(join(codexHome, "logs_2.sqlite")); + + const report = inspectCodexLogs({ codexHome }); + + expect(report.schema).toEqual({ state: "unreadable", reason: "database_unreadable" }); + expect(report.capabilities.protection).toEqual({ state: "unsupported", reason: "database_unreadable" }); + expect(report.capabilities.reclaim).toEqual({ state: "unsupported", reason: "database_unreadable" }); + }); + + test("treats an existing empty canonical database as unreadable, not missing", () => { + const root = makeRoot(); + const codexHome = join(root, "codex-home"); + mkdirSync(codexHome); + writeFileSync(join(codexHome, "config.toml"), ""); + writeFileSync(join(codexHome, "logs_2.sqlite"), ""); + + const report = inspectCodexLogs({ codexHome }); + + expect(report.schema).toEqual({ state: "unreadable", reason: "database_unreadable" }); + expect(report.files.databaseBytes).toBe(0); + expect(report.capabilities.inspection).toEqual({ state: "supported" }); + expect(report.capabilities.protection).toEqual({ state: "unsupported", reason: "database_unreadable" }); + expect(report.capabilities.reclaim).toEqual({ state: "unsupported", reason: "database_unreadable" }); + }); + + test("degrades unreadable databases to inspectable metadata instead of throwing", () => { + const root = makeRoot(); + const codexHome = join(root, "codex-home"); + mkdirSync(codexHome); + writeFileSync(join(codexHome, "config.toml"), ""); + writeFileSync(join(codexHome, "logs_2.sqlite"), "not sqlite"); + + const report = inspectCodexLogs({ codexHome }); + + expect(report.schema).toEqual({ state: "unreadable", reason: "database_unreadable" }); + expect(report.files.databaseBytes).toBeGreaterThan(0); + expect(report.metrics).toBeNull(); + expect(report.capabilities.inspection).toEqual({ state: "supported" }); + expect(report.capabilities.protection).toEqual({ state: "unsupported", reason: "database_unreadable" }); + expect(report.capabilities.reclaim).toEqual({ state: "unsupported", reason: "database_unreadable" }); + }); +}); diff --git a/tests/codex-sqlite-home.test.ts b/tests/codex-sqlite-home.test.ts index e751a6db9a..973d8f6237 100644 --- a/tests/codex-sqlite-home.test.ts +++ b/tests/codex-sqlite-home.test.ts @@ -5,7 +5,7 @@ import { join, resolve } from "node:path"; import { resolveCodexHistoryJobTarget } from "../src/codex/history-job"; import { historyBackupPathFor } from "../src/codex/history-provider"; -import { resolveCodexSqliteHome, resolveCodexStateDbPath } from "../src/codex/paths"; +import { resolveCodexLogsDbPath, resolveCodexSqliteHome, resolveCodexStateDbPath } from "../src/codex/paths"; const originalCodexHome = process.env.CODEX_HOME; const originalSqliteHome = process.env.CODEX_SQLITE_HOME; @@ -84,6 +84,7 @@ describe("Codex SQLite home resolution", () => { }; expect(resolveCodexSqliteHome(deps)).toBe("/work/sqlite"); expect(resolveCodexStateDbPath(deps)).toBe("/work/sqlite/state_5.sqlite"); + expect(resolveCodexLogsDbPath(deps)).toBe("/work/sqlite/logs_2.sqlite"); }); test("history jobs resolve the selected database and backup identity at call time", () => { From 50188a3c64313d0f68ae3b32251052dc8e0334c1 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:20:10 +0200 Subject: [PATCH 2/5] fix(i18n): add French Log Guard inspect labels --- gui/src/i18n/log-guard-labels.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gui/src/i18n/log-guard-labels.ts b/gui/src/i18n/log-guard-labels.ts index 059a3d30f8..dc453b5818 100644 --- a/gui/src/i18n/log-guard-labels.ts +++ b/gui/src/i18n/log-guard-labels.ts @@ -13,6 +13,11 @@ const LABELS: Record> = { externalSqliteHome: "Externer SQLite-Speicher", inspectionUnavailable: "Die Diagnoseprotokoll-Inspektion ist nicht verfügbar.", }, + fr: { + inspectionOnly: "Inspection uniquement", + externalSqliteHome: "Stockage SQLite externe", + inspectionUnavailable: "L’inspection des journaux de diagnostic est indisponible.", + }, ko: { inspectionOnly: "검사 전용", externalSqliteHome: "외부 SQLite 저장소", From 50cbd2868da4bc394f9e5f2bd1d53427799e2791 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:20:19 +0200 Subject: [PATCH 3/5] fix(i18n): add French Log Guard schema labels --- gui/src/i18n/log-guard-state-labels.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/gui/src/i18n/log-guard-state-labels.ts b/gui/src/i18n/log-guard-state-labels.ts index a7e6c39292..8ba6d7711d 100644 --- a/gui/src/i18n/log-guard-state-labels.ts +++ b/gui/src/i18n/log-guard-state-labels.ts @@ -15,6 +15,12 @@ const SCHEMA_LABELS: Record> = { unreadable: "Datenbank nicht lesbar", unsupported: "Nicht unterstützt", }, + fr: { + compatible: "Compatible", + missing: "Base de données introuvable", + unreadable: "Base de données indisponible", + unsupported: "Non pris en charge", + }, ko: { compatible: "호환됨", missing: "데이터베이스 없음", From 1784170fb1d1248749564257d658f6aae06923b8 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 16 Aug 2026 09:30:47 +0900 Subject: [PATCH 4/5] perf(log-guard): memoize inspection so repeat scans stop blocking the proxy readMetrics runs four unbounded aggregates over the whole logs table - count(*), two GROUP BYs, and a sum. bun:sqlite is synchronous, so that work occupies the proxy thread for its full duration. Measured on a real 15.6 GB / 302k-row database: {"elapsedMs":48848.1,"totalRows":302726,"databaseBytes":15602388992} Both /api/storage and /api/storage/codex-logs call inspectCodexLogs inline, so opening or refreshing the Storage page could stall routing and health responses for tens of seconds - on exactly the large fragmented database this feature exists to diagnose. Inspection is now memoized on the identity of the database, WAL, and shm files (size + mtime). A dashboard refresh, a page rendering both panels, and a poll loop all repeat an identical scan; those repeats are now free. Any Codex write changes the WAL stamp and invalidates the entry, so a cached answer is never staler than "nothing has been written since", and generatedAt is part of the memoized value so it reports when the numbers were measured rather than served. Measured on a 60k-row fixture: cold 14.3ms, warm 0.1ms, and 13.9ms again after resetCodexLogGuardInspectionCache(). Scope, stated honestly: this bounds the REPEAT cost, not the first one. A cold inspection of a very large database still blocks the thread. Moving that work onto a Worker is the real fix and is left for its own change; this removes the repeated stalls that make the page unusable in practice. Regressions cover memoization, write invalidation, and the explicit reset. --- src/codex/log-guard/inspect.ts | 66 +++++++++++++++++++++++++++ tests/codex-log-guard-inspect.test.ts | 39 +++++++++++++++- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/codex/log-guard/inspect.ts b/src/codex/log-guard/inspect.ts index 342f95e87f..053418b592 100644 --- a/src/codex/log-guard/inspect.ts +++ b/src/codex/log-guard/inspect.ts @@ -150,6 +150,56 @@ function fileSize(path: string): number { } } +/** + * Memoized inspection, keyed by the database and WAL identity. + * + * `readMetrics` runs four unbounded aggregates (`count(*)`, two `GROUP BY`s and + * a `sum`) over the whole `logs` table. `bun:sqlite` is synchronous, so that + * work occupies the proxy thread for its full duration — measured at ~49s on a + * 15.6 GB / 302k-row database, exactly the large fragmented case this feature + * exists to diagnose. Both `/api/storage` and `/api/storage/codex-logs` call it + * inline, so merely opening or refreshing the Storage page could stall routing + * and health responses. + * + * A dashboard refresh, a page that renders both panels, and a poll loop all + * repeat an identical scan. Keying on size+mtime of the database and WAL means a + * repeat inspection is free until Codex actually writes, which removes the + * repeated stalls without ever serving stale numbers: any write changes the WAL + * and invalidates the entry. + * + * This bounds the repeat cost, not the first one. A cold inspection of a huge + * database still blocks; moving that work off-thread needs a Worker and is + * tracked separately. + */ +type InspectionCacheEntry = { + key: string; + value: CodexLogGuardInspection; +}; + +let inspectionCache: InspectionCacheEntry | null = null; + +function inspectionCacheKey(databasePath: string): string { + const stamp = (path: string): string => { + try { + const stat = statSync(path); + return `${stat.size}:${stat.mtimeMs}`; + } catch { + return "-"; + } + }; + return [ + databasePath, + stamp(databasePath), + stamp(`${databasePath}-wal`), + stamp(`${databasePath}-shm`), + ].join("|"); +} + +/** Drop the memoized inspection. Exported for tests and for post-mutation refresh. */ +export function resetCodexLogGuardInspectionCache(): void { + inspectionCache = null; +} + function capabilityFor(schema: CodexLogGuardSchemaState): CodexLogGuardCapability { if (schema.state === "compatible") return { state: "supported" }; return { state: "unsupported", reason: schema.reason }; @@ -294,6 +344,22 @@ function readMetrics(db: Database, columns: string[]): CodexLogGuardMetrics | nu * This is a zero-write health view, not an SSD/NAND write-rate measurement. */ export function inspectCodexLogs(deps: CodexSqliteHomeDeps = {}): CodexLogGuardInspection { + let cacheKey: string | null = null; + try { + const home = deps.codexHome ?? getCodexHome(); + cacheKey = inspectionCacheKey(join(resolveCodexSqliteHome({ ...deps, codexHome: home }), "logs_2.sqlite")); + if (inspectionCache?.key === cacheKey) return inspectionCache.value; + } catch { + cacheKey = null; + } + const value = inspectCodexLogsUncached(deps); + // `generatedAt` is part of the memoized value, so a cached response reports the + // time the numbers were actually measured rather than the time they were served. + if (cacheKey !== null) inspectionCache = { key: cacheKey, value }; + return value; +} + +function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuardInspection { let codexHome: string; let sqliteHome: string; try { diff --git a/tests/codex-log-guard-inspect.test.ts b/tests/codex-log-guard-inspect.test.ts index 6b72a32da8..df544df6d3 100644 --- a/tests/codex-log-guard-inspect.test.ts +++ b/tests/codex-log-guard-inspect.test.ts @@ -12,7 +12,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; -import { inspectCodexLogs } from "../src/codex/log-guard/inspect"; +import { inspectCodexLogs, resetCodexLogGuardInspectionCache } from "../src/codex/log-guard/inspect"; const roots: string[] = []; @@ -408,4 +408,41 @@ describe("Codex Log Guard inspection", () => { expect(report.capabilities.protection).toEqual({ state: "unsupported", reason: "database_unreadable" }); expect(report.capabilities.reclaim).toEqual({ state: "unsupported", reason: "database_unreadable" }); }); + test("repeat inspection is memoized and invalidated by a write", () => { + // readMetrics runs four unbounded aggregates over the whole logs table, and + // bun:sqlite is synchronous, so every repeat scan occupies the proxy thread. + // A dashboard refresh, a page rendering both panels, and a poll loop all + // repeat an identical scan; memoizing on database+WAL identity removes that. + const root = makeRoot(); + const databasePath = join(root, "logs_2.sqlite"); + createCurrentLogsDb(databasePath); + + resetCodexLogGuardInspectionCache(); + const first = inspectCodexLogs({ codexHome: root }); + const second = inspectCodexLogs({ codexHome: root }); + // Same object identity proves the aggregates did not run a second time. + expect(second).toBe(first); + + // A write must invalidate: a cached answer is never staler than + // "nothing has been written since". + const db = new Database(databasePath); + db.run("INSERT INTO logs (ts, ts_nanos, level, target, estimated_bytes) VALUES (1, 1, 'INFO', 'later', 64)"); + db.close(); + + const third = inspectCodexLogs({ codexHome: root }); + expect(third).not.toBe(first); + expect(third.metrics?.totalRows).toBe((first.metrics?.totalRows ?? 0) + 1); + }); + + test("resetCodexLogGuardInspectionCache forces a fresh scan", () => { + const root = makeRoot(); + createCurrentLogsDb(join(root, "logs_2.sqlite")); + + resetCodexLogGuardInspectionCache(); + const first = inspectCodexLogs({ codexHome: root }); + expect(inspectCodexLogs({ codexHome: root })).toBe(first); + + resetCodexLogGuardInspectionCache(); + expect(inspectCodexLogs({ codexHome: root })).not.toBe(first); + }); }); From d00a51b8c5fe5efdca53b8600b12b9db7bbc2e23 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 16 Aug 2026 09:43:26 +0900 Subject: [PATCH 5/5] fix(log-guard): key the inspection cache on file identity, not size+mtime Re-review showed size:mtimeMs is not an identity: an atomic replace (write a new file, rename over the old) can preserve both, and the cache then served the previous schema/capability verdict indefinitely. That trades a repeated-scan cost for a persistent wrong answer, which is the worse failure. The key now includes dev+ino (a replaced file is a different inode) and nanosecond mtime/ctime (an in-place rewrite inside one millisecond still invalidates), via statSync(path, { bigint: true }). Regression drives the exact case: replace the database with a same-size non-database file, restore the original mtime, and assert the next inspection is neither the cached object nor still 'compatible'. --- src/codex/log-guard/inspect.ts | 17 +++++++++++++++-- tests/codex-log-guard-inspect.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/codex/log-guard/inspect.ts b/src/codex/log-guard/inspect.ts index 053418b592..f7696c6d83 100644 --- a/src/codex/log-guard/inspect.ts +++ b/src/codex/log-guard/inspect.ts @@ -181,8 +181,21 @@ let inspectionCache: InspectionCacheEntry | null = null; function inspectionCacheKey(databasePath: string): string { const stamp = (path: string): string => { try { - const stat = statSync(path); - return `${stat.size}:${stat.mtimeMs}`; + // `size:mtimeMs` alone is not an identity. An atomic replace (write a new + // file, rename over the old one) can preserve both, and the cache then + // served the previous schema/capability verdict indefinitely — trading a + // repeated-scan cost for a persistent wrong answer. Include the inode and + // device so a replaced file is a different key even at identical size and + // mtime, plus nanosecond ctime/mtime so an in-place rewrite inside one + // millisecond still invalidates. + const stat = statSync(path, { bigint: true }); + return [ + stat.dev, + stat.ino, + stat.size, + stat.mtimeNs, + stat.ctimeNs, + ].join(":"); } catch { return "-"; } diff --git a/tests/codex-log-guard-inspect.test.ts b/tests/codex-log-guard-inspect.test.ts index df544df6d3..96deb06672 100644 --- a/tests/codex-log-guard-inspect.test.ts +++ b/tests/codex-log-guard-inspect.test.ts @@ -4,8 +4,10 @@ import { mkdirSync, mkdtempSync, readdirSync, + renameSync, rmSync, statSync, + utimesSync, unlinkSync, writeFileSync, } from "node:fs"; @@ -445,4 +447,28 @@ describe("Codex Log Guard inspection", () => { resetCodexLogGuardInspectionCache(); expect(inspectCodexLogs({ codexHome: root })).not.toBe(first); }); + test("an atomic replacement at identical size and mtime still invalidates", () => { + // size:mtimeMs is not an identity. Writing a new file and renaming it over + // the old one can preserve both, and the cache then served the previous + // schema verdict indefinitely - a persistent wrong answer, which is worse + // than the repeated scan the cache exists to avoid. + const root = makeRoot(); + const databasePath = join(root, "logs_2.sqlite"); + createCurrentLogsDb(databasePath); + + resetCodexLogGuardInspectionCache(); + const before = inspectCodexLogs({ codexHome: root }); + expect(before.schema.state).toBe("compatible"); + + const original = statSync(databasePath); + const replacement = join(root, "replacement.tmp"); + // A file of the same byte length that is NOT a usable database. + writeFileSync(replacement, Buffer.alloc(original.size, 0x41)); + renameSync(replacement, databasePath); + utimesSync(databasePath, original.atime, original.mtime); + + const after = inspectCodexLogs({ codexHome: root }); + expect(after).not.toBe(before); + expect(after.schema.state).not.toBe("compatible"); + }); });