diff --git a/gui/src/components/storage-workspace/StorageWorkspace.tsx b/gui/src/components/storage-workspace/StorageWorkspace.tsx index fb5ed7808d..46d670a68e 100644 --- a/gui/src/components/storage-workspace/StorageWorkspace.tsx +++ b/gui/src/components/storage-workspace/StorageWorkspace.tsx @@ -73,6 +73,10 @@ export interface CodexLogGuardReport { reclaimableBytes: number; estimatedLogBytes: number | null; }; + metricsSkipped?: null | { + reason: "database_too_large"; + thresholdBytes: number; + }; } export interface StorageReport { @@ -205,6 +209,22 @@ function CodexLogGuardPanel({ )} + {/* + Say WHY the rows are missing (#2605). Skipping the aggregates above the size threshold + is what keeps a cold inspection off the proxy thread, but silently dropping the row + block reads as "this database has no rows" — the exact confusion the null-vs-zero + distinction on the server exists to prevent. A user who sees a 1 GB database and no + row count deserves the reason. + */} + {!metrics && report.metricsSkipped && ( +
+
{t("storage.col.rows")}
+
+ {logGuardLabel(locale, "metricsSkippedLarge") + .replace("{threshold}", formatBytes(report.metricsSkipped.thresholdBytes, locale))} +
+
+ )}
sqlite_home
diff --git a/gui/src/i18n/log-guard-labels.ts b/gui/src/i18n/log-guard-labels.ts index de7ed41af5..a0164259e7 100644 --- a/gui/src/i18n/log-guard-labels.ts +++ b/gui/src/i18n/log-guard-labels.ts @@ -4,6 +4,7 @@ export type LogGuardLabelKey = | "inspectionOnly" | "externalSqliteHome" | "inspectionUnavailable" + | "metricsSkippedLarge" | "protection" | "compat" | "quiet" @@ -37,6 +38,7 @@ const LABELS: Record> = { inspectionOnly: 'Inspection only', externalSqliteHome: 'External SQLite storage', inspectionUnavailable: "Diagnostic log inspection is unavailable.", + metricsSkippedLarge: "Row metrics skipped: the database is above {threshold}, and scanning it would stall the proxy.", protection: "Protection", compat: "Compatibility", quiet: "Quiet", @@ -63,6 +65,7 @@ const LABELS: Record> = { inspectionOnly: 'Nur Inspektion', externalSqliteHome: 'Externer SQLite-Speicher', inspectionUnavailable: "Die Diagnoseprotokoll-Inspektion ist nicht verfügbar.", + metricsSkippedLarge: "Zeilenmetriken übersprungen: Die Datenbank ist größer als {threshold}; ein Scan würde den Proxy blockieren.", protection: "Schutz", compat: "Kompatibilität", quiet: "Leise", @@ -89,6 +92,7 @@ const LABELS: Record> = { inspectionOnly: "Inspection uniquement", externalSqliteHome: "Stockage SQLite externe", inspectionUnavailable: "L’inspection des journaux de diagnostic est indisponible.", + metricsSkippedLarge: "Métriques de lignes ignorées : la base dépasse {threshold} et son analyse bloquerait le proxy.", protection: "Protection", compat: "Compatibilité", quiet: "Silencieux", @@ -115,6 +119,7 @@ const LABELS: Record> = { inspectionOnly: '검사 전용', externalSqliteHome: '외부 SQLite 저장소', inspectionUnavailable: "진단 로그 검사를 사용할 수 없습니다.", + metricsSkippedLarge: "행 지표를 건너뛰었습니다. 데이터베이스가 {threshold}보다 커서 스캔하면 프록시가 멈춥니다.", protection: "보호", compat: "호환 모드", quiet: "조용한 모드", @@ -141,6 +146,7 @@ const LABELS: Record> = { inspectionOnly: '仅检查', externalSqliteHome: '外部 SQLite 存储', inspectionUnavailable: "诊断日志检查当前不可用。", + metricsSkippedLarge: "已跳过行指标:数据库超过 {threshold},扫描会阻塞代理。", protection: "保护", compat: "兼容模式", quiet: "静默模式", @@ -167,6 +173,7 @@ const LABELS: Record> = { inspectionOnly: '僅檢查', externalSqliteHome: '外部 SQLite 儲存空間', inspectionUnavailable: "診斷記錄檢查目前無法使用。", + metricsSkippedLarge: "已略過列指標:資料庫超過 {threshold},掃描會阻塞代理。", protection: "保護", compat: "相容模式", quiet: "靜默模式", @@ -193,6 +200,7 @@ const LABELS: Record> = { inspectionOnly: 'Только проверка', externalSqliteHome: 'Внешнее хранилище SQLite', inspectionUnavailable: "Проверка диагностических журналов недоступна.", + metricsSkippedLarge: "Метрики строк пропущены: база больше {threshold}, и её сканирование заблокировало бы прокси.", protection: "Защита", compat: "Совместимость", quiet: "Тихий режим", @@ -219,6 +227,7 @@ const LABELS: Record> = { inspectionOnly: '検査のみ', externalSqliteHome: '外部 SQLite ストレージ', inspectionUnavailable: "診断ログの検査を利用できません。", + metricsSkippedLarge: "行メトリクスをスキップしました。データベースが {threshold} を超えており、走査するとプロキシが停止します。", protection: "保護", compat: "互換モード", quiet: "静音モード", @@ -245,6 +254,7 @@ const LABELS: Record> = { inspectionOnly: 'Yalnızca inceleme', externalSqliteHome: 'Harici SQLite depolaması', inspectionUnavailable: "Tanılama günlüğü incelemesi kullanılamıyor.", + metricsSkippedLarge: "Satır ölçümleri atlandı: veritabanı {threshold} sınırının üzerinde ve taranması proxy’yi kilitler.", protection: "Koruma", compat: "Uyumluluk", quiet: "Sessiz", diff --git a/gui/tests/storage-log-guard.test.tsx b/gui/tests/storage-log-guard.test.tsx index 272f925b14..fb065ce5f9 100644 --- a/gui/tests/storage-log-guard.test.tsx +++ b/gui/tests/storage-log-guard.test.tsx @@ -129,3 +129,43 @@ test("Storage overview does not render arbitrary Log Guard error strings", () => expect(html).not.toContain("/private/state/logs_2.sqlite"); expect(html).not.toContain("failed"); }); + +/** + * A skipped scan must SAY it was skipped (#2605). + * + * Above the size threshold the server returns `metrics: null` so a cold inspection cannot stall + * the proxy thread. Rendering that as an absent row block reads as "this database has no rows" — + * the exact confusion the server's null-vs-zero distinction exists to prevent, and the more + * misleading the larger the database actually is. + */ +test("a skipped large-database scan states the reason instead of rendering no rows", () => { + const large = report(); + large.codexLogs!.files.databaseBytes = 1_468_923_904; + large.codexLogs!.metrics = null; + large.codexLogs!.metricsSkipped = { reason: "database_too_large", thresholdBytes: 67_108_864 }; + + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain('data-testid="log-guard-metrics-skipped"'); + expect(html).toContain("Row metrics skipped"); + // The threshold is stated, so the reader can tell why this database crossed it. + expect(html).toContain("64 MiB"); + // The file sizes still render: only the row aggregates were skipped, not the inspection. + expect(html).toContain("1.4 GiB"); + // And it must not silently show a row count it never computed. + expect(html).not.toContain(">400<"); +}); + +test("a database under the threshold still renders full row metrics", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).not.toContain('data-testid="log-guard-metrics-skipped"'); + expect(html).toContain("400"); +}); diff --git a/src/codex/log-guard/inspect.ts b/src/codex/log-guard/inspect.ts index d2c1719236..eca4402e25 100644 --- a/src/codex/log-guard/inspect.ts +++ b/src/codex/log-guard/inspect.ts @@ -11,6 +11,9 @@ import { const IMMUTABLE_READONLY_FLAGS = constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI; const KNOWN_LOG_LEVELS = new Set(["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]); +// The issue reporter measured a ~1 GB database taking 17.3s for GROUP BY level +// alone; skipping all row aggregates above 64 MiB reduced /api/storage to 628ms. +const MAX_SYNCHRONOUS_METRICS_DATABASE_BYTES = 64 * 1024 * 1024; interface CurrentLogColumn { name: string; @@ -115,6 +118,10 @@ export interface CodexLogGuardInspection { reclaim: CodexLogGuardCapability; }; metrics: CodexLogGuardMetrics | null; + metricsSkipped: null | { + reason: "database_too_large"; + thresholdBytes: number; + }; } interface ColumnRow { @@ -167,9 +174,8 @@ function fileSize(path: string): number { * 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. + * Memoization bounds repeat cost. The database-size gate below separately bounds + * cold request-thread work by omitting these aggregates for large databases. */ type InspectionCacheEntry = { key: string; @@ -233,6 +239,7 @@ function unavailableInspection(): CodexLogGuardInspection { reclaim: unavailable, }, metrics: null, + metricsSkipped: null, }; } @@ -425,6 +432,7 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard ...common, schema, metrics: null, + metricsSkipped: null, capabilities: { inspection: { state: "supported" }, protection: mutation, @@ -440,6 +448,7 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard ...common, schema, metrics: null, + metricsSkipped: null, capabilities: { inspection: { state: "supported" }, protection: mutation, @@ -458,6 +467,7 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard ...common, schema, metrics: null, + metricsSkipped: null, capabilities: { inspection: { state: "supported" }, protection: mutation, @@ -476,10 +486,17 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard ? { state: "compatible" } : { state: "unsupported", reason: "unknown_schema" }; const mutation = capabilityFor(schema); + const metricsSkipped = files.databaseBytes > MAX_SYNCHRONOUS_METRICS_DATABASE_BYTES + ? { + reason: "database_too_large" as const, + thresholdBytes: MAX_SYNCHRONOUS_METRICS_DATABASE_BYTES, + } + : null; return { ...common, schema, - metrics: readMetrics(db, columns), + metrics: metricsSkipped === null ? readMetrics(db, columns) : null, + metricsSkipped, capabilities: { inspection: { state: "supported" }, protection: mutation, @@ -496,6 +513,7 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard ...common, schema, metrics: null, + metricsSkipped: null, capabilities: { inspection: { state: "supported" }, protection: mutation, diff --git a/tests/codex-log-guard-inspect.test.ts b/tests/codex-log-guard-inspect.test.ts index 96deb06672..03ba72c8c7 100644 --- a/tests/codex-log-guard-inspect.test.ts +++ b/tests/codex-log-guard-inspect.test.ts @@ -7,6 +7,7 @@ import { renameSync, rmSync, statSync, + truncateSync, utimesSync, unlinkSync, writeFileSync, @@ -113,6 +114,26 @@ describe("Codex Log Guard inspection", () => { expect(report.metrics?.traceShare).toBe(0.5); expect(report.metrics?.topTargets[0]).toEqual({ target: "TARGET_1", rows: 2 }); expect(report.metrics?.reclaimableBytes).toBeGreaterThanOrEqual(0); + expect(report.metricsSkipped).toBeNull(); + }); + + test("skips row aggregates for a large database without reporting zero metrics", () => { + const root = makeRoot(); + const databasePath = join(root, "logs_2.sqlite"); + createCurrentLogsDb(databasePath); + truncateSync(databasePath, 64 * 1024 * 1024 + 1); + + const report = inspectCodexLogs({ codexHome: root }); + + expect(report.schema).toEqual({ state: "compatible" }); + expect(report.metrics).toBeNull(); + expect(report.metricsSkipped).toEqual({ + reason: "database_too_large", + thresholdBytes: 64 * 1024 * 1024, + }); + expect(report).not.toMatchObject({ + metrics: { totalRows: 0, rowsByLevel: {}, estimatedLogBytes: 0 }, + }); }); test("never exposes feedback bodies, arbitrary levels, target names, or paths", () => {