diff --git a/devlog/_fin/260905_test_modularization_and_windows/001_test_inventory.md b/devlog/_fin/260905_test_modularization_and_windows/001_test_inventory.md index 44defb0acd..b527373a82 100644 --- a/devlog/_fin/260905_test_modularization_and_windows/001_test_inventory.md +++ b/devlog/_fin/260905_test_modularization_and_windows/001_test_inventory.md @@ -411,6 +411,10 @@ Sum of the table: **1061**. Zero leftover. `fulfill-video.test.ts`, `plan-video.test.ts`, `xai-video-client.test.ts` +#### `tests/telemetry/` (2) + +`telemetry-fingerprint.test.ts`, `telemetry-ledger.test.ts` + #### `tests/e2e-style/` (1) `phase100-native-parity.test.ts` diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0fbe7cf746..385e240bcf 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -127,6 +127,11 @@ "^(?:storage)-" ] }, + "telemetry": { + "match": [ + "^(?:telemetry)-" + ] + }, "vision": { "match": [ "^(?:sidecar)-" @@ -1157,6 +1162,8 @@ "storage-worker-lifecycle.test.ts": "storage", "storage-worker-os-join-settle.test.ts": "storage", "storage-worker-teardown-isolate.test.ts": "storage", + "telemetry-fingerprint.test.ts": "telemetry", + "telemetry-ledger.test.ts": "telemetry", "stream-aborted-marker.test.ts": "server", "strict-semver.test.ts": "lib", "subagent-context-staleness.test.ts": "routing", @@ -1304,6 +1311,7 @@ "server", "service", "storage", + "telemetry", "update", "usage", "vision", diff --git a/src/telemetry/fingerprint.ts b/src/telemetry/fingerprint.ts new file mode 100644 index 0000000000..d03af6e1bd --- /dev/null +++ b/src/telemetry/fingerprint.ts @@ -0,0 +1,62 @@ +import { createHash } from "node:crypto"; +import type { FailureEvent, FailureFingerprint } from "./types"; + +const SENSITIVE_PATTERNS: Array<[RegExp, string]> = [ + [/\b(?:bearer\s+|basic\s+|api[_-]?key\s*[:=]\s*|token\s*[:=]\s*|secret\s*[:=]\s*)[^\s,;]+/gi, "[redacted]"], + [/\bsk-[a-zA-Z0-9_-]{8,}\b/g, "[redacted-key]"], + [/[\p{L}\p{N}._%+-]+@[\p{L}\p{N}.-]+\.[\p{L}]{2,}/gu, "[redacted-email]"], + [/\b(?:request|session)[_-]?id\s*[:=]\s*[^\s,;]+/gi, ""], + [/\b(?:timestamp|time)\s*[:=]\s*[^\s,;]+/gi, ""], + [/\b\d{10,13}\b/g, ""], + [/(:\d+\s*:\s*\d+)(?=\b|\D)/g, ""], + [/\b(?:line|col(?:umn)?)\s*[:=]?\s*\d+/gi, ""], + [/(?:\/[^\s/\\]+(?: +[^\s/\\]+)*(?=\/))+(?:\/[^\s/\\]+)/gu, "[path]"], + [/[a-zA-Z]:\\(?:[^\s/\\]+(?: +[^\s/\\]+)*\\)+[^\s/\\]+/gu, "[path]"], +]; + +const MAX_SIGNATURE_LEN = 1024; +const MAX_FIELD_LEN = 128; + +export function sanitizeSignature(raw: string): string { + if (typeof raw !== "string") return ""; + let text = raw.normalize("NFC"); + for (const [pattern, replacement] of SENSITIVE_PATTERNS) { + text = text.replace(pattern, replacement); + } + return text.replace(/\s+/g, " ").trim().slice(0, MAX_SIGNATURE_LEN); +} + +function sanitizeField(value: unknown, maxLen = MAX_FIELD_LEN): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + return sanitizeSignature(trimmed).slice(0, maxLen); +} + +export interface CanonicalFailurePayload { + v: 1; + k: string; + p?: string; + m?: string; + s: string; +} + +export function canonicalizeFailureEvent(event: FailureEvent): CanonicalFailurePayload { + const k = sanitizeField(event.failureKind) ?? "unknown_failure"; + const p = sanitizeField(event.provider, 64); + const m = sanitizeField(event.model, 64); + const s = sanitizeSignature(event.signature); + + return { + v: 1, + k, + ...(p ? { p } : {}), + ...(m ? { m } : {}), + s, + }; +} + +export function computeFailureFingerprint(event: FailureEvent): FailureFingerprint { + const canonical = canonicalizeFailureEvent(event); + return createHash("sha256").update(JSON.stringify(canonical)).digest("hex"); +} diff --git a/src/telemetry/ledger.ts b/src/telemetry/ledger.ts new file mode 100644 index 0000000000..81916f8623 --- /dev/null +++ b/src/telemetry/ledger.ts @@ -0,0 +1,238 @@ +import { Database } from "bun:sqlite"; +import { mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { getConfigDir } from "../config/paths"; +import { computeFailureFingerprint, sanitizeSignature } from "./fingerprint"; +import type { FailureEvent, FailureFingerprint, LedgerRecord, RemediationStatus } from "./types"; + +export interface TelemetryLedgerOptions { + maxRecords?: number; + maxOccurrences?: number; +} + +interface StoredRow { + fingerprint: string; + first_seen: number; + last_seen: number; + count: number; + status: RemediationStatus; + details: string | null; + occurrences: string; +} + +const ALLOWED_DETAILS_KEYS = new Set(["issueNumber", "resolution", "safeNote"]); + +function parseStoredDetails(raw: string | null): Record | undefined { + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw); + return sanitizeDetails(parsed); + } catch { + return undefined; + } +} + +export function sanitizeDetails(details?: Record): Record | undefined { + if (!details || typeof details !== "object" || Array.isArray(details)) return undefined; + const safe: Record = {}; + for (const [k, v] of Object.entries(details)) { + if (!ALLOWED_DETAILS_KEYS.has(k)) continue; + if (typeof v === "string") { + safe[k] = sanitizeSignature(v).slice(0, 256); + } else if (typeof v === "number" || typeof v === "boolean") { + safe[k] = v; + } + } + return Object.keys(safe).length > 0 ? safe : undefined; +} + +export class TelemetryLedger { + private readonly db: Database; + private readonly maxRecords: number; + private readonly maxOccurrences: number; + + constructor(path?: string, options: TelemetryLedgerOptions = {}) { + const resolvedPath = path ?? join(getConfigDir(), "telemetry-issues.sqlite"); + if (resolvedPath !== ":memory:") { + mkdirSync(dirname(resolvedPath), { recursive: true }); + } + this.db = new Database(resolvedPath, { create: true }); + this.maxRecords = options.maxRecords ?? 1000; + this.maxOccurrences = options.maxOccurrences ?? 100; + this.db.run( + "CREATE TABLE IF NOT EXISTS failure_events (" + + "fingerprint TEXT PRIMARY KEY, " + + "first_seen INTEGER NOT NULL, " + + "last_seen INTEGER NOT NULL, " + + "count INTEGER NOT NULL, " + + "status TEXT NOT NULL, " + + "details TEXT, " + + "occurrences TEXT NOT NULL" + + ")" + ); + } + + recordFailure(event: FailureEvent, windowMs: number, details?: Record): LedgerRecord { + const fingerprint = computeFailureFingerprint(event); + const timestamp = typeof event.timestamp === "number" && Number.isFinite(event.timestamp) ? event.timestamp : Date.now(); + const old = this.db.query("SELECT * FROM failure_events WHERE fingerprint = ?").get(fingerprint) as StoredRow | null; + + let priorOccurrences: number[] = []; + if (old?.occurrences) { + try { + const parsed = JSON.parse(old.occurrences); + if (Array.isArray(parsed)) priorOccurrences = parsed; + } catch { + priorOccurrences = []; + } + } + + const lastSeen = Math.max(old?.last_seen ?? timestamp, timestamp); + const minTimestamp = lastSeen - Math.max(0, windowMs); + const occurrences = [...priorOccurrences, timestamp] + .filter(seen => Number.isFinite(seen) && seen >= minTimestamp && seen <= lastSeen) + .sort((a, b) => a - b) + .slice(-this.maxOccurrences); + + const mergedDetails = { + ...(parseStoredDetails(old?.details ?? null) ?? {}), + ...(sanitizeDetails(details) ?? {}), + }; + const cleanDetails = Object.keys(mergedDetails).length > 0 ? mergedDetails : undefined; + + const record: LedgerRecord = { + fingerprint, + firstSeen: Math.min(old?.first_seen ?? timestamp, timestamp), + lastSeen, + count: occurrences.length, + status: old?.status ?? "monitoring", + ...(cleanDetails ? { details: cleanDetails } : {}), + }; + + this.db.query( + "INSERT INTO failure_events (fingerprint, first_seen, last_seen, count, status, details, occurrences) " + + "VALUES (?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(fingerprint) DO UPDATE SET " + + "first_seen=excluded.first_seen, " + + "last_seen=excluded.last_seen, " + + "count=excluded.count, " + + "status=excluded.status, " + + "details=excluded.details, " + + "occurrences=excluded.occurrences" + ).run( + fingerprint, + record.firstSeen, + record.lastSeen, + record.count, + record.status, + cleanDetails ? JSON.stringify(cleanDetails) : null, + JSON.stringify(occurrences), + ); + + this.pruneIfNeeded(); + return record; + } + + getRecord(fingerprint: FailureFingerprint): LedgerRecord | null { + const row = this.db.query( + "SELECT fingerprint, first_seen, last_seen, count, status, details FROM failure_events WHERE fingerprint = ?" + ).get(fingerprint) as { + fingerprint: string; + first_seen: number; + last_seen: number; + count: number; + status: RemediationStatus; + details: string | null; + } | null; + + if (!row) return null; + const details = parseStoredDetails(row.details); + return { + fingerprint: row.fingerprint, + firstSeen: row.first_seen, + lastSeen: row.last_seen, + count: row.count, + status: row.status, + ...(details ? { details } : {}), + }; + } + + updateStatus(fingerprint: FailureFingerprint, status: RemediationStatus, details?: Record): void { + if (details === undefined) { + this.db.query("UPDATE failure_events SET status = ? WHERE fingerprint = ?").run(status, fingerprint); + return; + } + const row = this.db.query("SELECT details FROM failure_events WHERE fingerprint = ?").get(fingerprint) as { + details: string | null; + } | null; + const mergedDetails = { + ...(parseStoredDetails(row?.details ?? null) ?? {}), + ...(sanitizeDetails(details) ?? {}), + }; + const safeDetails = Object.keys(mergedDetails).length > 0 ? mergedDetails : undefined; + this.db.query("UPDATE failure_events SET status = ?, details = ? WHERE fingerprint = ?").run( + status, + safeDetails ? JSON.stringify(safeDetails) : null, + fingerprint, + ); + } + + shouldDispatch(fingerprint: FailureFingerprint, threshold: number, windowMs: number): boolean { + const row = this.db.query( + "SELECT status, last_seen, occurrences FROM failure_events WHERE fingerprint = ?" + ).get(fingerprint) as { status: RemediationStatus; last_seen: number; occurrences: string } | null; + + if (!row || row.status !== "monitoring") return false; + let occurrences: number[] = []; + try { + const parsed = JSON.parse(row.occurrences); + if (Array.isArray(parsed)) occurrences = parsed; + } catch { + return false; + } + const active = occurrences.filter(seen => seen >= row.last_seen - Math.max(0, windowMs)); + return active.length >= threshold; + } + + listRecords(): LedgerRecord[] { + const rows = this.db.query( + "SELECT fingerprint, first_seen, last_seen, count, status, details FROM failure_events ORDER BY last_seen DESC" + ).all() as Array<{ + fingerprint: string; + first_seen: number; + last_seen: number; + count: number; + status: RemediationStatus; + details: string | null; + }>; + + return rows.map(row => { + const details = parseStoredDetails(row.details); + return { + fingerprint: row.fingerprint, + firstSeen: row.first_seen, + lastSeen: row.last_seen, + count: row.count, + status: row.status, + ...(details ? { details } : {}), + }; + }); + } + + private pruneIfNeeded(): void { + if (this.maxRecords <= 0) return; + const countRow = this.db.query("SELECT COUNT(*) as total FROM failure_events").get() as { total: number } | null; + if (!countRow || countRow.total <= this.maxRecords) return; + const excess = countRow.total - this.maxRecords; + this.db.query( + "DELETE FROM failure_events WHERE fingerprint IN (" + + "SELECT fingerprint FROM failure_events " + + "ORDER BY CASE status WHEN 'fixed' THEN 0 WHEN 'ignored' THEN 0 WHEN 'dispatched' THEN 1 ELSE 2 END, last_seen ASC LIMIT ?" + + ")" + ).run(excess); + } + + close(): void { + this.db.close(); + } +} diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts new file mode 100644 index 0000000000..90765ce6dd --- /dev/null +++ b/src/telemetry/types.ts @@ -0,0 +1,22 @@ +export type RemediationStatus = "monitoring" | "dispatched" | "fixed" | "ignored"; + +export interface FailureEvent { + failureKind: string; + provider?: string; + model?: string; + signature: string; + timestamp?: number; + requestId?: string; + sessionId?: string; +} + +export type FailureFingerprint = string; + +export interface LedgerRecord { + fingerprint: FailureFingerprint; + firstSeen: number; + lastSeen: number; + count: number; + status: RemediationStatus; + details?: Record; +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index db2583b00b..ed25a9fd71 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -992,6 +992,8 @@ "storage-worker-lifecycle.test.ts": "storage", "storage-worker-os-join-settle.test.ts": "storage", "storage-worker-teardown-isolate.test.ts": "storage", + "telemetry-fingerprint.test.ts": "telemetry", + "telemetry-ledger.test.ts": "telemetry", "stream-aborted-marker.test.ts": "server", "strict-semver.test.ts": "lib", "subagent-context-staleness.test.ts": "routing", diff --git a/tests/telemetry/telemetry-fingerprint.test.ts b/tests/telemetry/telemetry-fingerprint.test.ts new file mode 100644 index 0000000000..ea22aac28a --- /dev/null +++ b/tests/telemetry/telemetry-fingerprint.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "bun:test"; +import { computeFailureFingerprint, sanitizeSignature } from "../../src/telemetry/fingerprint"; +import type { FailureEvent } from "../../src/telemetry/types"; + +describe("computeFailureFingerprint", () => { + test("ignores timestamps, request/session IDs, and line/column numbers", () => { + const first: FailureEvent = { + failureKind: "upstream_wire_error", + provider: "openai", + model: "gpt-5", + signature: "Error at file.ts:12:8 request_id=req-123 session_id=s-1", + timestamp: 1000, + requestId: "req-123", + sessionId: "s-1", + }; + const second: FailureEvent = { + ...first, + signature: "Error at file.ts:99:42 request_id=req-999 session_id=s-9", + timestamp: 9000, + requestId: "req-999", + sessionId: "s-9", + }; + expect(computeFailureFingerprint(first)).toBe(computeFailureFingerprint(second)); + expect(computeFailureFingerprint(first)).toMatch(/^[a-f0-9]{64}$/); + }); + + test("changes when stable failure identity changes", () => { + const event: FailureEvent = { failureKind: "websocket_1006", provider: "openai", model: "gpt-5", signature: "closed" }; + expect(computeFailureFingerprint(event)).not.toBe(computeFailureFingerprint({ ...event, provider: "anthropic" })); + expect(computeFailureFingerprint(event)).not.toBe(computeFailureFingerprint({ ...event, model: "gpt-4" })); + expect(computeFailureFingerprint(event)).not.toBe(computeFailureFingerprint({ ...event, failureKind: "tool_loop" })); + }); + + test("excludes forbidden fields such as prompts, bodies, and credentials", () => { + const sampleToken = ["sk", "ant", "fixture", "1234567890"].join("-"); + const baseline: FailureEvent = { + failureKind: "upstream_wire_error", + provider: "openai", + model: "gpt-5", + signature: "Connection timeout", + }; + const withForbidden = { + ...baseline, + prompt: "secret user prompt text", + response: "secret assistant response text", + body: { hidden: "payload" }, + headers: { authorization: "Bearer secret-token" }, + apiKey: sampleToken, + accountId: "acc-secret-999", + }; + expect(computeFailureFingerprint(baseline)).toBe(computeFailureFingerprint(withForbidden)); + }); + + test("sanitizes secrets, credentials, and absolute filesystem paths inside signature", () => { + const homePath = ["", "Users", "alice", "project", "src", "index.ts"].join("/"); + const sampleKey = ["sk", "ant", "fixture", "1234567890abcdef"].join("-"); + const raw = `Error in ${homePath}: bearer secret_token_xyz ${sampleKey}`; + const sanitized = sanitizeSignature(raw); + expect(sanitized).not.toContain(["", "Users", "alice"].join("/")); + expect(sanitized).not.toContain("secret_token_xyz"); + expect(sanitized).not.toContain(sampleKey); + expect(sanitized).toContain("[path]"); + expect(sanitized).toContain("[redacted]"); + }); + + test("redacts Basic credentials and complete paths with spaced components", () => { + const credential = ["YWxpY2U6", "c2VjcmV0"].join(""); + const unixPath = ["", "Users", "Alice Smith", "project", "index.ts"].join("/"); + const windowsPath = ["C:", "Users", "Alice Smith", "project", "index.ts"].join("\\"); + const sanitized = sanitizeSignature(`Basic ${credential}; ${unixPath}; ${windowsPath}`); + expect(sanitized).not.toContain(credential); + expect(sanitized).not.toContain("Alice Smith"); + expect(sanitized.match(/\[path\]/g)?.length).toBe(2); + }); + + test("bounds oversized signatures", () => { + const longSignature = "a".repeat(2000); + const sanitized = sanitizeSignature(longSignature); + expect(sanitized.length).toBeLessThanOrEqual(1024); + }); + + test("handles empty or malformed inputs without throwing", () => { + const event: FailureEvent = { + failureKind: "", + signature: "", + }; + const fp = computeFailureFingerprint(event); + expect(fp).toMatch(/^[a-f0-9]{64}$/); + }); +}); diff --git a/tests/telemetry/telemetry-ledger.test.ts b/tests/telemetry/telemetry-ledger.test.ts new file mode 100644 index 0000000000..4755b39214 --- /dev/null +++ b/tests/telemetry/telemetry-ledger.test.ts @@ -0,0 +1,216 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TelemetryLedger } from "../../src/telemetry/ledger"; +import type { FailureEvent } from "../../src/telemetry/types"; + +const paths: string[] = []; +afterEach(() => { + for (const path of paths.splice(0)) { + try { + rmSync(path, { force: true, recursive: true }); + } catch { + /* best-effort cleanup */ + } + } +}); + +describe("TelemetryLedger", () => { + test("counts only failures in the rolling window and tracks status", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-telemetry-")); + paths.push(dir); + const ledger = new TelemetryLedger(join(dir, "telemetry.sqlite")); + const event: FailureEvent = { failureKind: "upstream_wire_error", provider: "openai", model: "gpt-5", signature: "broken", timestamp: 1000 }; + const fingerprint = ledger.recordFailure(event, 1000).fingerprint; + ledger.recordFailure({ ...event, timestamp: 1500 }, 1000); + ledger.recordFailure({ ...event, timestamp: 2600 }, 1000); + expect(ledger.getRecord(fingerprint)?.count).toBe(1); + expect(ledger.shouldDispatch(fingerprint, 2, 1000)).toBe(false); + ledger.recordFailure({ ...event, timestamp: 3000 }, 1000); + expect(ledger.shouldDispatch(fingerprint, 2, 1000)).toBe(true); + ledger.updateStatus(fingerprint, "dispatched", { issueNumber: 7 }); + expect(ledger.getRecord(fingerprint)).toMatchObject({ status: "dispatched", details: { issueNumber: 7 } }); + ledger.close(); + }); + + test("supports in-memory database and explicit paths", () => { + const memLedger = new TelemetryLedger(":memory:"); + const event: FailureEvent = { failureKind: "test_error", signature: "in-memory test", timestamp: 5000 }; + const record = memLedger.recordFailure(event, 60000); + expect(record.count).toBe(1); + expect(memLedger.getRecord(record.fingerprint)?.status).toBe("monitoring"); + memLedger.close(); + }); + + test("persists only allowlisted sanitized details", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-telemetry-")); + paths.push(dir); + const databasePath = join(dir, "telemetry.sqlite"); + const sampleKey = ["sk", "secret", "fixture", "123"].join("-"); + const email = [`ale${String.fromCharCode(0x301)}`, "example.com"].join("@"); + const punctuatedComponent = ["O", "Connor+Team"].join("'"); + const repeatedSpaceComponent = "Alice Smith"; + const unixPath = ["", "Users", "李", punctuatedComponent, "trace(1).log"].join("/"); + const windowsPath = ["C:", "Users", "李", punctuatedComponent, "trace(1).log"].join("\\"); + const spacedUnixPath = ["", "Users", repeatedSpaceComponent, "trace.log"].join("/"); + const spacedWindowsPath = ["C:", "Users", repeatedSpaceComponent, "trace.log"].join("\\"); + const ledger = new TelemetryLedger(databasePath); + const event: FailureEvent = { failureKind: "sanitized_error", signature: "sensitive details test", timestamp: 1000 }; + const details = { + issueNumber: 42, + prompt: "secret prompt", + response: "secret response", + body: "secret body", + apiKey: sampleKey, + userPrompt: "raw user input", + " prompt": "whitespace-prefixed raw user input", + arbitraryDiagnostic: "not part of the ledger contract", + safeNote: `connection timeout for ${email} at ${unixPath}, ${windowsPath}, ${spacedUnixPath}, or ${spacedWindowsPath}`, + }; + const record = ledger.recordFailure(event, 10000, details); + expect(record.details).toBeDefined(); + expect(record.details?.issueNumber).toBe(42); + expect(record.details?.safeNote).not.toContain(email); + expect(record.details?.safeNote).not.toContain("李"); + expect(record.details?.safeNote).not.toContain(punctuatedComponent); + expect(record.details?.safeNote).not.toContain(repeatedSpaceComponent); + expect(record.details?.safeNote).toContain("[redacted-email]"); + expect(record.details?.safeNote).toContain("[path]"); + expect(record.details?.prompt).toBeUndefined(); + expect(record.details?.response).toBeUndefined(); + expect(record.details?.body).toBeUndefined(); + expect(record.details?.apiKey).toBeUndefined(); + expect(record.details?.userPrompt).toBeUndefined(); + expect(record.details?.[" prompt"]).toBeUndefined(); + expect(record.details?.arbitraryDiagnostic).toBeUndefined(); + const db = new Database(databasePath, { readonly: true }); + const row = db.query("SELECT details FROM failure_events WHERE fingerprint = ?").get(record.fingerprint) as { details: string }; + const persisted = JSON.parse(row.details) as Record; + expect(persisted).toEqual(record.details); + expect(row.details).not.toContain("raw user input"); + expect(row.details).not.toContain("whitespace-prefixed raw user input"); + expect(row.details).not.toContain("not part of the ledger contract"); + expect(persisted).not.toHaveProperty("userPrompt"); + expect(persisted).not.toHaveProperty(" prompt"); + expect(persisted).not.toHaveProperty("arbitraryDiagnostic"); + ledger.updateStatus(record.fingerprint, "fixed", { + resolution: "patched", + userPrompt: "status update raw user input", + " prompt": "status update whitespace bypass", + }); + const updatedRow = db.query("SELECT details FROM failure_events WHERE fingerprint = ?").get(record.fingerprint) as { details: string }; + const updatedPersisted = JSON.parse(updatedRow.details) as Record; + expect(updatedPersisted).toEqual({ + issueNumber: 42, + safeNote: record.details?.safeNote, + resolution: "patched", + }); + expect(updatedRow.details).not.toContain("status update raw user input"); + expect(updatedRow.details).not.toContain("status update whitespace bypass"); + db.close(); + ledger.close(); + }); + + test("bounds stored occurrences and prunes excess records", () => { + const ledger = new TelemetryLedger(":memory:", { maxRecords: 2, maxOccurrences: 3 }); + const event1: FailureEvent = { failureKind: "error_1", signature: "sig 1" }; + const event2: FailureEvent = { failureKind: "error_2", signature: "sig 2" }; + const event3: FailureEvent = { failureKind: "error_3", signature: "sig 3" }; + + const r1 = ledger.recordFailure({ ...event1, timestamp: 100 }, 10000); + ledger.recordFailure({ ...event1, timestamp: 200 }, 10000); + ledger.recordFailure({ ...event1, timestamp: 300 }, 10000); + ledger.recordFailure({ ...event1, timestamp: 400 }, 10000); // 4th occurrence + expect(ledger.getRecord(r1.fingerprint)?.count).toBeLessThanOrEqual(3); + + ledger.recordFailure({ ...event2, timestamp: 500 }, 10000); + ledger.recordFailure({ ...event3, timestamp: 600 }, 10000); // Exceeds maxRecords (2) + + const records = ledger.listRecords(); + expect(records.length).toBeLessThanOrEqual(2); + ledger.close(); + }); + + test("shouldDispatch ignores records not in monitoring status", () => { + const ledger = new TelemetryLedger(":memory:"); + const event: FailureEvent = { failureKind: "status_check", signature: "check dispatch" }; + const r = ledger.recordFailure({ ...event, timestamp: 100 }, 10000); + ledger.recordFailure({ ...event, timestamp: 200 }, 10000); + expect(ledger.shouldDispatch(r.fingerprint, 2, 10000)).toBe(true); + + ledger.updateStatus(r.fingerprint, "fixed"); + expect(ledger.shouldDispatch(r.fingerprint, 2, 10000)).toBe(false); + ledger.close(); + }); + + test("status-only updates preserve existing sanitized details", () => { + const ledger = new TelemetryLedger(":memory:"); + const record = ledger.recordFailure( + { failureKind: "status_details", signature: "preserve details", timestamp: 100 }, + 10000, + { issueNumber: 9, prompt: "must not persist" }, + ); + ledger.updateStatus(record.fingerprint, "fixed"); + expect(ledger.getRecord(record.fingerprint)).toMatchObject({ + status: "fixed", + details: { issueNumber: 9 }, + }); + ledger.close(); + }); + + test("status updates merge sanitized details instead of replacing diagnostics", () => { + const ledger = new TelemetryLedger(":memory:"); + const record = ledger.recordFailure( + { failureKind: "status_details_merge", signature: "preserve diagnostics", timestamp: 100 }, + 10000, + { issueNumber: 9, safeNote: "original" }, + ); + ledger.updateStatus(record.fingerprint, "fixed", { resolution: "patched", prompt: "must not persist" }); + const updated = ledger.getRecord(record.fingerprint); + expect(updated).toMatchObject({ + status: "fixed", + details: { issueNumber: 9, safeNote: "original", resolution: "patched" }, + }); + expect(updated?.details).not.toHaveProperty("prompt"); + ledger.close(); + }); + + test("keeps lastSeen monotonic and excludes delayed events outside the active window", () => { + const ledger = new TelemetryLedger(":memory:"); + const event: FailureEvent = { failureKind: "delayed_event", signature: "arrived late", timestamp: 10000 }; + const fingerprint = ledger.recordFailure(event, 1000).fingerprint; + const record = ledger.recordFailure({ ...event, timestamp: 1000 }, 1000); + expect(record).toMatchObject({ firstSeen: 1000, lastSeen: 10000, count: 1 }); + expect(ledger.shouldDispatch(fingerprint, 2, 1000)).toBe(false); + ledger.close(); + }); + + test("prunes terminal records before active monitoring records", () => { + const ledger = new TelemetryLedger(":memory:", { maxRecords: 2 }); + const fixed = ledger.recordFailure({ failureKind: "fixed_old", signature: "fixed", timestamp: 100 }, 10000); + ledger.updateStatus(fixed.fingerprint, "fixed"); + const monitoring = ledger.recordFailure({ failureKind: "monitoring_old", signature: "active", timestamp: 200 }, 10000); + ledger.recordFailure({ failureKind: "new_record", signature: "new", timestamp: 300 }, 10000); + expect(ledger.getRecord(fixed.fingerprint)).toBeNull(); + expect(ledger.getRecord(monitoring.fingerprint)).not.toBeNull(); + ledger.close(); + }); + + test("malformed stored details fail closed without breaking reads or records", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-telemetry-")); + paths.push(dir); + const databasePath = join(dir, "telemetry.sqlite"); + const ledger = new TelemetryLedger(databasePath); + const event: FailureEvent = { failureKind: "malformed_details", signature: "safe", timestamp: 100 }; + const record = ledger.recordFailure(event, 10000, { issueNumber: 1 }); + const db = new Database(databasePath); + db.run("UPDATE failure_events SET details = ? WHERE fingerprint = ?", "{bad", record.fingerprint); + db.close(); + expect(ledger.getRecord(record.fingerprint)?.details).toBeUndefined(); + expect(ledger.listRecords()[0]?.details).toBeUndefined(); + expect(() => ledger.recordFailure({ ...event, timestamp: 200 }, 10000)).not.toThrow(); + ledger.close(); + }); +});