diff --git a/.github/pr-assets/usage-ledger-retention-full-page.png b/.github/pr-assets/usage-ledger-retention-full-page.png new file mode 100644 index 00000000000..ea942cccc68 Binary files /dev/null and b/.github/pr-assets/usage-ledger-retention-full-page.png differ diff --git a/.github/pr-assets/usage-ledger-retention-usage-ui.jpg b/.github/pr-assets/usage-ledger-retention-usage-ui.jpg new file mode 100644 index 00000000000..e242da3173f Binary files /dev/null and b/.github/pr-assets/usage-ledger-retention-usage-ui.jpg differ diff --git a/.github/pr-assets/usage-ledger-retention-usage-ui.png b/.github/pr-assets/usage-ledger-retention-usage-ui.png new file mode 100644 index 00000000000..9beb885a7ec Binary files /dev/null and b/.github/pr-assets/usage-ledger-retention-usage-ui.png differ diff --git a/gui/src/components/usage/UsageLedgerRetentionControl.tsx b/gui/src/components/usage/UsageLedgerRetentionControl.tsx new file mode 100644 index 00000000000..001520864b2 --- /dev/null +++ b/gui/src/components/usage/UsageLedgerRetentionControl.tsx @@ -0,0 +1,245 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { clampNumberDraft } from "../../clamp-draft"; +import { formatBytes } from "../../format-bytes"; +import { useI18n } from "../../i18n/shared"; +import { Switch } from "../../ui"; +import { NumberStepper } from "../NumberStepper"; + +const MIB = 1024 ** 2; +const MAX_MIB = Math.floor(Number.MAX_SAFE_INTEGER / MIB); + +interface RetentionStatus { + enabled: boolean; + maxBytes: number; + currentBytes?: number; +} + +function parseStatus(value: unknown): RetentionStatus { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_status"); + const candidate = value as Record; + if (typeof candidate.enabled !== "boolean" || typeof candidate.maxBytes !== "number" + || !Number.isFinite(candidate.maxBytes) || candidate.maxBytes <= 0) { + throw new Error("invalid_status"); + } + return { + enabled: candidate.enabled, + maxBytes: candidate.maxBytes, + currentBytes: typeof candidate.currentBytes === "number" && Number.isFinite(candidate.currentBytes) + ? candidate.currentBytes + : undefined, + }; +} + +function formatMaxMiBDraft(maxBytes: number): string { + return Number.isFinite(maxBytes) && maxBytes > 0 ? String(maxBytes / MIB) : ""; +} + +function parseMaxMiBDraft(raw: string): number | null { + const mib = Number(raw.trim()); + if (!Number.isSafeInteger(mib) || mib < 1 || mib > MAX_MIB) return null; + const bytes = mib * MIB; + return Number.isSafeInteger(bytes) ? bytes : null; +} + +/** + * Usage-page control for the opt-in usage-ledger byte ceiling. + * + * The dashboard keeps the switch as the primary action and exposes a MiB-aligned + * custom editor only while the policy is enabled. Toggling the switch always + * sends the exact server-reported byte value, so existing non-MiB-aligned values + * can never be rounded or silently rewritten. + */ +export default function UsageLedgerRetentionControl({ apiBase }: { apiBase: string }) { + const { locale, t } = useI18n(); + const mibLabel = formatBytes(MIB, locale).replace(/^[\d.,]+\s*/, ""); + const [status, setStatus] = useState(null); + const [customDraft, setCustomDraft] = useState(""); + const [editing, setEditing] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const loadGeneration = useRef(0); + const inputRef = useRef(null); + + const load = useCallback(async (signal?: AbortSignal) => { + const generation = ++loadGeneration.current; + try { + const response = await fetch(`${apiBase}/api/storage/usage-ledger-retention`, { signal }); + if (!response.ok) throw new Error("load_failed"); + const next = parseStatus(await response.json()); + if (signal?.aborted || generation !== loadGeneration.current) return; + setError(null); + setStatus(next); + setCustomDraft(formatMaxMiBDraft(next.maxBytes)); + } catch (errorValue) { + // A successful PUT invalidates reads that started under the old policy. Stale reads + // must be silent whether they eventually succeed, fail HTTP, reject, or parse badly. + if (signal?.aborted || generation !== loadGeneration.current + || (errorValue as { name?: string })?.name === "AbortError") return; + throw errorValue; + } + }, [apiBase]); + + useEffect(() => { + const controller = new AbortController(); + const timeout = window.setTimeout(() => { + void load(controller.signal).catch(errorValue => { + if (!controller.signal.aborted && (errorValue as { name?: string })?.name !== "AbortError") { + setError(t("usage.retention.loadError")); + } + }); + }, 0); + return () => { + window.clearTimeout(timeout); + controller.abort(); + }; + }, [load, t]); + + const persist = useCallback(async (nextEnabled: boolean, maxBytes: number) => { + setBusy(true); + setError(null); + try { + const response = await fetch(`${apiBase}/api/storage/usage-ledger-retention`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: nextEnabled, maxBytes }), + }); + if (!response.ok) throw new Error("save_failed"); + const next = parseStatus(await response.json()); + // A GET may have started before this authoritative mutation completed (for example, + // after a locale change). Do not let that older snapshot repaint the saved state. + loadGeneration.current += 1; + setStatus(next); + setCustomDraft(formatMaxMiBDraft(next.maxBytes)); + setEditing(false); + } catch { + setError(t("usage.retention.error")); + } finally { + setBusy(false); + } + }, [apiBase, t]); + + const toggle = () => { + if (!status || busy) return; + void persist(!status.enabled, status.maxBytes); + }; + + const saveCustom = () => { + if (!status || busy) return; + // If the saved value is not MiB-aligned, leaving the field untouched must + // not force an unrelated rounding write; the switch path remains exact. + if (customDraft === formatMaxMiBDraft(status.maxBytes)) { + setEditing(false); + return; + } + const nextMaxBytes = parseMaxMiBDraft(customDraft); + if (nextMaxBytes === null) { + setError(t("usage.retention.invalid", { max: MAX_MIB })); + return; + } + void persist(true, nextMaxBytes); + }; + + const resetCustom = () => { + if (!status) return; + setCustomDraft(formatMaxMiBDraft(status.maxBytes)); + setEditing(false); + setError(null); + }; + + return ( +
+
+
+

{t("usage.retention.title")}

+

{t("usage.retention.help")}

+
+ +
+ + {status?.enabled && ( +
+ +
+ )} + +

+ {t("usage.retention.current")}: {status?.currentBytes === undefined ? "—" : formatBytes(status.currentBytes, locale)} + {status && ( + <> + {" · "} + {!status.enabled && <>{t("usage.retention.unlimited")}{" · "}} + + {t("usage.retention.limit")}: {formatBytes(status.maxBytes, locale)} + + + )} +

+ {error && } +
+ ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 8a48248ba7e..8c000911a64 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -925,7 +925,19 @@ export const de: Record = { "debug.noLines.provider": "Anbieter-Debug ist an, erfasst aber nur Transport-Anomalien (verworfene oder fehlerhafte Frames sowie Cursor-Dial/Retry-Ereignisse). Eine saubere Anfrage über einen Anbieter wie Anthropic kann null Zeilen erzeugen.", "debug.noLines.usage": "Nutzungserfassung ist an, aber es wurde noch nichts erfasst. Sende einen Chat/eine Anfrage über Codex, dann erscheint es hier.", "debug.noLines.injection": "Injektions-Log ist an, aber es wurde noch nichts erfasst. Es erfasst Multi-Agent-Guidance-Injektion und Effort-Cap-Entscheidungen bei Collab- und Sub-Agent-Turns.", - "usage.title": "Nutzung", + "usage.retention.title": "Größenlimit für Nutzungsverlauf", + "usage.retention.help": "Optional können die neuesten vollständigen Nutzungsdatensätze innerhalb eines Größenlimits behalten werden. Ältere Einträge werden automatisch entfernt, sobald der Verlauf das Limit überschreitet.", + "usage.retention.enabled": "Größe des Nutzungsverlaufs begrenzen", + "usage.retention.current": "Aktuelle Größe", + "usage.retention.limit": "Maximale Größe", + "usage.retention.increase": "Maximale Größe erhöhen", + "usage.retention.decrease": "Maximale Größe verringern", + "usage.retention.unlimited": "Unbegrenzt", + "usage.retention.loadError": "Das Größenlimit für den Nutzungsverlauf konnte nicht geladen werden.", + "usage.retention.invalid": "Geben Sie ein Größenlimit zwischen 1 und {max} MiB ein.", +"usage.retention.error": "Das Größenlimit für den Nutzungsverlauf konnte nicht aktualisiert werden.", + "usage.retention.disabled": "Unbegrenzt — automatische Verlaufsbereinigung ist deaktiviert.", +"usage.title": "Nutzung", "usage.subtitle": "Lokale Token-Buchhaltung deines Proxys. Fehlende Nutzung wird nie als Null angezeigt.", "usage.loading": "Lade Nutzungsdaten…", "usage.empty": "Noch keine Nutzung erfasst. Sende eine Anfrage über den Proxy, um Aktivität hier zu sehen.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 9be7b42263b..720a5ec6beb 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -978,7 +978,19 @@ export const en = { "debug.noLines.injection": "Injection log is on but nothing has been captured yet. It records multi-agent guidance injection and effort-cap decisions on collab and sub-agent turns.", // usage page - "usage.title": "Usage", + "usage.retention.title": "Usage history size limit", + "usage.retention.help": "Optionally keep the newest complete usage records within a size limit. Older rows are removed automatically when the ledger exceeds it.", + "usage.retention.enabled": "Limit usage history size", + "usage.retention.current": "Current size", + "usage.retention.limit": "Maximum size", + "usage.retention.increase": "Increase maximum size", + "usage.retention.decrease": "Decrease maximum size", + "usage.retention.unlimited": "Unlimited", + "usage.retention.loadError": "Could not load the usage history limit.", + "usage.retention.invalid": "Enter a size limit between 1 and {max} MiB.", +"usage.retention.error": "Could not update the usage history limit.", + "usage.retention.disabled": "Unlimited — automatic history compaction is off.", +"usage.title": "Usage", "usage.subtitle": "Local token accounting from your proxy. Missing usage is never shown as zero.", "usage.loading": "Loading usage data…", "usage.empty": "No usage recorded yet. Send a request through the proxy to see activity here.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 9c119d17d3b..bc36c906943 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -955,7 +955,19 @@ export const fr: Record = { "debug.noLines.provider": "Le débogage du fournisseur est activé, mais il n’enregistre que les anomalies de transport (trames abandonnées ou mal formées, et événements de connexion/nouvelle tentative Cursor). Une requête sans anomalie auprès d’un fournisseur comme Anthropic peut ne produire aucune ligne.", "debug.noLines.usage": "L’extraction de l’utilisation est activée, mais rien n’a encore été capturé. Envoyez une conversation ou une requête par Codex pour qu’elle apparaisse ici.", "debug.noLines.injection": "Le journal des injections est activé, mais rien n’a encore été capturé. Il consigne l’injection des directives multi-agents et les décisions de plafonnement du niveau lors des tours collab et des sous-agents.", - "usage.title": "Utilisation", + "usage.retention.title": "Limite de taille de l’historique d’utilisation", + "usage.retention.help": "Conservez facultativement les enregistrements d’utilisation complets les plus récents dans une limite de taille. Les lignes plus anciennes sont supprimées automatiquement lorsque l’historique la dépasse.", + "usage.retention.enabled": "Limiter la taille de l’historique d’utilisation", + "usage.retention.current": "Taille actuelle", + "usage.retention.limit": "Taille maximale", + "usage.retention.increase": "Augmenter la taille maximale", + "usage.retention.decrease": "Réduire la taille maximale", + "usage.retention.unlimited": "Illimitée", + "usage.retention.loadError": "Impossible de charger la limite de l’historique d’utilisation.", + "usage.retention.invalid": "Saisissez une limite de taille comprise entre 1 et {max} MiB.", +"usage.retention.error": "Impossible de mettre à jour la limite de l’historique d’utilisation.", + "usage.retention.disabled": "Illimitée — la suppression automatique de l’historique est désactivée.", +"usage.title": "Utilisation", "usage.subtitle": "Comptabilisation locale des jetons par votre proxy. Une utilisation manquante n’est jamais affichée comme nulle.", "usage.loading": "Chargement des données d’utilisation…", "usage.empty": "Aucune utilisation enregistrée pour le moment. Envoyez une requête par le proxy pour voir l’activité ici.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index dd283a9299c..0b1e55e774a 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -891,7 +891,19 @@ export const ja: Record = { "debug.noLines.injection": "インジェクションログはオンですが、まだ何もキャプチャされていません。コラボおよびサブエージェントのターンでのマルチエージェントガイダンスインジェクションと負荷上限の決定を記録します。", // usage page - "usage.title": "使用量", + "usage.retention.title": "使用履歴のサイズ上限", + "usage.retention.help": "最新の完全な使用記録を、指定したサイズ以内に必要に応じて保持します。履歴が上限を超えると古い行が自動的に削除されます。", + "usage.retention.enabled": "使用履歴のサイズを制限", + "usage.retention.current": "現在のサイズ", + "usage.retention.limit": "最大サイズ", + "usage.retention.increase": "最大サイズを増やす", + "usage.retention.decrease": "最大サイズを減らす", + "usage.retention.unlimited": "無制限", + "usage.retention.loadError": "使用履歴のサイズ上限を読み込めませんでした。", + "usage.retention.invalid": "1 から {max} MiB までのサイズ上限を入力してください。", +"usage.retention.error": "使用履歴のサイズ上限を更新できませんでした。", + "usage.retention.disabled": "無制限 — 使用履歴の自動削除はオフです。", +"usage.title": "使用量", "usage.subtitle": "プロキシからのローカルトークン会計です。欠損した使用量はゼロとして表示されることはありません。", "usage.loading": "使用量データを読み込み中…", "usage.empty": "まだ使用量が記録されていません。プロキシ経由でリクエストを送信するとここにアクティビティが表示されます。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 1a1909a74f1..afa4bb604f2 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -959,7 +959,19 @@ export const ko: Record = { "debug.noLines.injection": "주입 로그는 켜져 있지만 아직 캡처된 항목이 없습니다. Collab 및 서브 에이전트 턴의 멀티 에이전트 가이던스 주입과 effort-cap 결정을 기록합니다.", // usage page - "usage.title": "사용량", + "usage.retention.title": "사용 기록 크기 제한", + "usage.retention.help": "최신의 완전한 사용 기록을 선택적으로 크기 제한 내에 보관합니다. 원장이 제한을 초과하면 오래된 행이 자동으로 삭제됩니다.", + "usage.retention.enabled": "사용 기록 크기 제한", + "usage.retention.current": "현재 크기", + "usage.retention.limit": "최대 크기", + "usage.retention.increase": "최대 크기 늘리기", + "usage.retention.decrease": "최대 크기 줄이기", + "usage.retention.unlimited": "제한 없음", + "usage.retention.loadError": "사용 기록 크기 제한을 불러올 수 없습니다.", + "usage.retention.invalid": "1에서 {max} MiB 사이의 크기 제한을 입력하세요.", +"usage.retention.error": "사용 기록 크기 제한을 업데이트할 수 없습니다.", + "usage.retention.disabled": "제한 없음 — 사용 기록 자동 삭제가 꺼져 있습니다.", +"usage.title": "사용량", "usage.subtitle": "프록시의 로컬 토큰 집계입니다. 누락된 사용량은 0으로 표시하지 않습니다.", "usage.loading": "사용량 데이터를 불러오는 중…", "usage.empty": "아직 기록된 사용량이 없습니다. 프록시로 요청을 보내면 여기에 표시됩니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 485c0b302a7..d8b8efe10d0 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -946,7 +946,19 @@ export const ru: Record = { "debug.noLines.injection": "Лог инъекций включён, но пока ничего не зафиксировано. Он записывает инъекции мультиагентных инструкций и решения об ограничении уровня рассуждений на ходах совместной работы (collab) и подагентов.", // usage page - "usage.title": "Использование", + "usage.retention.title": "Ограничение размера истории использования", + "usage.retention.help": "При желании сохраняйте самые новые полные записи использования в пределах заданного размера. Старые строки автоматически удаляются, когда история превышает лимит.", + "usage.retention.enabled": "Ограничить размер истории использования", + "usage.retention.current": "Текущий размер", + "usage.retention.limit": "Максимальный размер", + "usage.retention.increase": "Увеличить максимальный размер", + "usage.retention.decrease": "Уменьшить максимальный размер", + "usage.retention.unlimited": "Без ограничений", + "usage.retention.loadError": "Не удалось загрузить ограничение размера истории использования.", + "usage.retention.invalid": "Введите ограничение размера от 1 до {max} МиБ.", +"usage.retention.error": "Не удалось обновить ограничение размера истории использования.", + "usage.retention.disabled": "Без ограничений — автоматическая очистка истории выключена.", +"usage.title": "Использование", "usage.subtitle": "Локальный учёт токенов вашего прокси. Отсутствующие данные никогда не показываются как ноль.", "usage.loading": "Загрузка данных об использовании…", "usage.empty": "Данных об использовании пока нет. Отправьте запрос через прокси, чтобы увидеть здесь активность.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index a1d59281c99..e1b94a2e434 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -965,7 +965,19 @@ export const tr: Record = { "debug.noLines.injection": "Enjeksiyon günlüğü açık.", // usage page - "usage.title": "Kullanım", + "usage.retention.title": "Kullanım geçmişi boyut sınırı", + "usage.retention.help": "En yeni eksiksiz kullanım kayıtlarını isteğe bağlı olarak belirlenen boyut sınırı içinde tutar. Geçmiş sınırı aştığında eski satırlar otomatik olarak silinir.", + "usage.retention.enabled": "Kullanım geçmişi boyutunu sınırla", + "usage.retention.current": "Geçerli boyut", + "usage.retention.limit": "Maksimum boyut", + "usage.retention.increase": "Maksimum boyutu artır", + "usage.retention.decrease": "Maksimum boyutu azalt", + "usage.retention.unlimited": "Sınırsız", + "usage.retention.loadError": "Kullanım geçmişi boyut sınırı yüklenemedi.", + "usage.retention.invalid": "1 ile {max} MiB arasında bir boyut sınırı girin.", +"usage.retention.error": "Kullanım geçmişi boyut sınırı güncellenemedi.", + "usage.retention.disabled": "Sınırsız — otomatik geçmiş temizleme kapalı.", +"usage.title": "Kullanım", "usage.subtitle": "Proxy'nizden yerel jeton muhasebesi.", "usage.loading": "Kullanım verileri yükleniyor…", "usage.empty": "Henüz kullanım kaydedilmedi.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index a6ef50b7f8e..a040d44cf42 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -948,7 +948,19 @@ export const vi: Record = { "debug.noLines.provider": "Tính năng Provider debug đang bật, nhưng nó chỉ ghi lại những bất thường trong quá trình truyền dữ liệu (dropped or malformed frames, và các sự kiện quay số/thử lại của Cursor). Một request không lỗi qua provider như Anthropic có thể không tạo ra dòng nào.", "debug.noLines.usage": "Trích xuất mức sử dụng đang bật nhưng chưa thu thập được dữ liệu. Gửi một chat/request thông qua Codex và log sẽ hiện ra ở đây.", "debug.noLines.injection": "Log can thiệp (Injection log) đang bật nhưng chưa thu thập được dữ liệu. Tính năng này ghi lại các can thiệp về hướng dẫn đa agent (multi-agent guidance injection) và quyết định nỗ lực trong các lượt cộng tác (collab) hoặc qua agent con.", - "usage.title": "Mức sử dụng", + "usage.retention.title": "Giới hạn kích thước lịch sử sử dụng", + "usage.retention.help": "Tùy chọn giữ lại các bản ghi sử dụng hoàn chỉnh mới nhất trong giới hạn kích thước. Các dòng cũ hơn sẽ tự động bị xóa khi sổ cái vượt quá giới hạn.", + "usage.retention.enabled": "Giới hạn kích thước lịch sử sử dụng", + "usage.retention.current": "Kích thước hiện tại", + "usage.retention.limit": "Kích thước tối đa", + "usage.retention.increase": "Tăng kích thước tối đa", + "usage.retention.decrease": "Giảm kích thước tối đa", + "usage.retention.unlimited": "Không giới hạn", + "usage.retention.loadError": "Không thể tải giới hạn kích thước lịch sử sử dụng.", + "usage.retention.invalid": "Nhập giới hạn kích thước từ 1 đến {max} MiB.", +"usage.retention.error": "Không thể cập nhật giới hạn kích thước lịch sử sử dụng.", + "usage.retention.disabled": "Không giới hạn — tính năng tự động nén lịch sử đang tắt.", +"usage.title": "Mức sử dụng", "usage.subtitle": "Quản lý token cục bộ (local token accounting) từ proxy của bạn. Mức sử dụng bị thiếu sẽ không bao giờ hiển thị bằng 0 (zero).", "usage.loading": "Đang tải dữ liệu sử dụng…", "usage.empty": "Chưa có mức sử dụng nào được ghi lại. Gửi một request qua proxy để thấy hoạt động ở đây.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 0ca62560f43..da34d138bba 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -770,7 +770,19 @@ export const zhTW: Record = { "debug.noLines.provider": "供應商除錯已開啟,但僅紀錄傳輸異常(捨棄或格式錯誤的幀,以及 Cursor dial/retry 事件)。透過 Anthropic 等供應商的正常請求可能不會產生任何行。", "debug.noLines.usage": "用量提取已開啟但尚未捕獲任何內容。請透過 Codex 傳送請求,隨後會顯示在此處。", "debug.noLines.injection": "注入日誌已開啟但尚未捕獲任何內容。它紀錄協作和子代理回合中的多代理指導注入與 effort-cap 決策。", - "usage.title": "用量", + "usage.retention.title": "用量歷史大小限制", + "usage.retention.help": "可選擇將最新的完整用量紀錄保留在指定大小內。歷史超過上限後,較舊項目會自動刪除。", + "usage.retention.enabled": "限制用量歷史大小", + "usage.retention.current": "目前大小", + "usage.retention.limit": "最大大小", + "usage.retention.increase": "增大最大大小", + "usage.retention.decrease": "減小最大大小", + "usage.retention.unlimited": "無限制", + "usage.retention.loadError": "無法載入用量歷史大小限制。", + "usage.retention.invalid": "請輸入 1 至 {max} MiB 之間的大小限制。", +"usage.retention.error": "無法更新用量歷史大小限制。", + "usage.retention.disabled": "無限制 — 自動清理用量歷史已關閉。", +"usage.title": "用量", "usage.subtitle": "代理本地的 Token 用量統計。缺失的用量不會顯示為零。", "usage.loading": "正在載入用量資料…", "usage.empty": "尚無用量紀錄。透過代理傳送請求後將在此顯示。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 277cda2e64b..4dbd7ff4281 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -940,7 +940,19 @@ export const zh: Record = { "debug.noLines.injection": "注入日志已开启但尚未捕获任何内容。它记录协作和子代理回合中的多代理指导注入与 effort-cap 决策。", // usage page - "usage.title": "用量", + "usage.retention.title": "用量历史大小限制", + "usage.retention.help": "可选择将最新的完整用量记录保留在指定大小以内。历史超过上限后,较旧条目会自动删除。", + "usage.retention.enabled": "限制用量历史大小", + "usage.retention.current": "当前大小", + "usage.retention.limit": "最大大小", + "usage.retention.increase": "增大最大大小", + "usage.retention.decrease": "减小最大大小", + "usage.retention.unlimited": "无限制", + "usage.retention.loadError": "无法加载用量历史大小限制。", + "usage.retention.invalid": "请输入 1 到 {max} MiB 之间的大小限制。", +"usage.retention.error": "无法更新用量历史大小限制。", + "usage.retention.disabled": "无限制 — 自动清理用量历史已关闭。", +"usage.title": "用量", "usage.subtitle": "代理本地的 Token 用量统计。缺失的用量不会显示为零。", "usage.loading": "正在加载用量数据…", "usage.empty": "尚无用量记录。通过代理发送请求后将在此显示。", diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index de96b16e115..d7a6f6b8f85 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -15,6 +15,7 @@ import { DataSurfaceSkeleton } from "../components/data-surface"; import { SectionTabs } from "../components/section-tabs"; import { sectionAnchorId } from "../section-anchors"; import { parseUsageTimeRange, type UsageRangeError, type UsageTimeWindow } from "../usage-time-range"; +import UsageLedgerRetentionControl from "../components/usage/UsageLedgerRetentionControl"; type Range = "all" | "30d" | "7d"; type UsageSurface = "all" | "codex" | "claude" | "grok"; @@ -1193,6 +1194,7 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas /> )} + {!connected && } ); } diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css index aa9e3bb45c6..0735da8165c 100644 --- a/gui/src/styles-usage-workspace.css +++ b/gui/src/styles-usage-workspace.css @@ -150,12 +150,6 @@ min-width: 0; /* Inset table from border+surface so cells aren't flush to the frame. */ padding: var(--space-3); - /* - The model and provider tables are the two long regions on this tab; unbounded they push the - rest of the report far below the fold. Cap them and scroll inside instead. A partially - visible last row is the scroll affordance. `overscroll-behavior: auto` hands the wheel back - to the page at either end, which keeps the sections below reachable by scrolling. - */ max-height: min(574px, 58vh); overflow-y: auto; overscroll-behavior: auto; @@ -165,17 +159,9 @@ /* Keep column labels visible while the body scrolls under them. */ .usw-section .tbl-wrap thead th { position: sticky; - /* - The wrapper's own top padding scrolls with the content, so the header must stick at the - negative padding offset and repaint that strip; otherwise rows show through the gap. - */ top: calc(-1 * var(--space-3)); z-index: 1; background: var(--surface); - /* - A sticky cell keeps its own border only while the table is at rest, so redraw the seam as a - shadow. The first value repaints the padding strip above; the second holds the rule below. - */ border-bottom-color: transparent; box-shadow: 0 calc(-1 * var(--space-3)) 0 var(--surface), @@ -187,7 +173,6 @@ } /* ── Responsive ───────────────────────────────────────── */ -/* The layout is single-column at every width, so these blocks no longer switch the grid. */ @container usage-workspace (max-width: 720px) { .usage-workspace-root { @@ -214,6 +199,69 @@ gap: 6px; } +/* Retention belongs to Usage, but stays a compact setting row rather than a second dashboard card. */ +.usage-retention-control { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin: 0 0 var(--space-4); + min-width: 0; +} + +.usage-retention-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + min-width: 0; +} + +.usage-retention-heading .h-section { + margin: 0; + font-size: var(--text-body); +} + +.usage-retention-heading p { + margin: var(--space-1) 0 0; + max-width: 68ch; +} + +.usage-retention-editor { + display: flex; + align-items: flex-end; + flex-wrap: wrap; + gap: var(--space-3); +} + +.usage-retention-limit { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-1); + margin: 0; +} + +.usage-retention-limit .field-label { + white-space: nowrap; +} + +.usage-retention-editor .codex-auto-switch-input { + width: 104px; +} + +.usage-retention-current { + margin: 0; + white-space: nowrap; +} + +/* Match the Models context-cap cluster: keep the remembered value visible when off, + but visually demote it so Unlimited remains the active state. */ +.usage-retention-limit.is-disabled { + opacity: 0.55; +} + @media (max-width: 640px) { .usage-source-row { align-items: flex-start; flex-direction: column; } + .usage-retention-heading { align-items: flex-start; } + .usage-retention-current { white-space: normal; } } diff --git a/gui/tests/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx index 3ef6373ccfe..f9c02d731c3 100644 --- a/gui/tests/usage-custom-range.test.tsx +++ b/gui/tests/usage-custom-range.test.tsx @@ -35,9 +35,19 @@ beforeEach(() => { // The page also has a held memory cache: each test gets a distinct report identity. apiBase = `http://usage-custom-${++sequence}`; requests = []; - globalThis.fetch = ((input: RequestInfo | URL) => new Promise(resolve => { - requests.push({ url: String(input), resolve }); - })) as typeof fetch; + globalThis.fetch = ((input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/api/storage/usage-ledger-retention")) { + return Promise.resolve(Response.json({ + enabled: false, + maxBytes: 1024 * 1024 * 1024, + currentBytes: 0, + })); + } + return new Promise(resolve => { + requests.push({ url, resolve }); + }); + }) as typeof fetch; }); afterEach(async () => { diff --git a/gui/tests/usage-retention-control.test.ts b/gui/tests/usage-retention-control.test.ts new file mode 100644 index 00000000000..9f38d52d0a5 --- /dev/null +++ b/gui/tests/usage-retention-control.test.ts @@ -0,0 +1,324 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import UsageLedgerRetentionControl from "../src/components/usage/UsageLedgerRetentionControl"; +import { LanguageProvider } from "../src/i18n"; +import { useI18n } from "../src/i18n/shared"; + +const globals = ["document", "window", "navigator", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +type GlobalName = (typeof globals)[number]; + +let previous: Record; +let testWindow: Window; +let root: Root | null = null; +let host: HTMLElement; + +function restoreProperty(target: object, key: PropertyKey, descriptor: PropertyDescriptor | undefined): void { + if (descriptor) Object.defineProperty(target, key, descriptor); + else Reflect.deleteProperty(target, key); +} + +beforeEach(() => { + previous = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previous; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, writable: true, value: true }, + }); + host = testWindow.document.createElement("div") as never as HTMLElement; + testWindow.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + root = null; + for (const key of globals) restoreProperty(globalThis, key, previous[key]); + await testWindow.happyDOM?.close?.(); +}); + +async function settleTimers(): Promise { + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + await Promise.resolve(); + }); +} + +async function mount(apiBase: string): Promise { + await act(async () => { + root = createRoot(host); + root.render(createElement( + LanguageProvider, + null, + createElement(UsageLedgerRetentionControl, { apiBase }), + )); + }); + await settleTimers(); +} + +function LocaleHarness({ apiBase }: { apiBase: string }) { + const { setLocale } = useI18n(); + return createElement( + "div", + null, + createElement("button", { type: "button", id: "locale-switch", onClick: () => setLocale("de") }, "locale"), + createElement(UsageLedgerRetentionControl, { apiBase }), + ); +} + +test("retention control stays on Usage and out of Storage", async () => { + const page = await Bun.file(new URL("../src/pages/Usage.tsx", import.meta.url)).text(); + const storageWorkspace = await Bun.file(new URL("../src/components/storage-workspace/StorageWorkspace.tsx", import.meta.url)).text(); + + expect(page).toContain("UsageLedgerRetentionControl"); + expect(storageWorkspace).not.toContain("UsageLedgerRetentionControl"); +}); + +test("retention control is omitted when connected to remote hub", async () => { + const apiBase = "http://usage-connected-test"; + const { createRoot } = await import("react-dom/client"); + const Usage = (await import("../src/pages/Usage")).default; + + globalThis.fetch = (async () => { + return Response.json({ + range: "30d", + surface: "all", + since: 0, + generatedAt: Date.now(), + summary: { requests: 0, measuredRequests: 0, reportedRequests: 0, unreportedRequests: 0, unsupportedRequests: 0, estimatedRequests: 0, inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, coverageRatio: 0 }, + days: [], + models: [], + providers: [], + historyTruncated: false, + truncatedPrefixBytes: 0, + entriesTruncated: false, + entriesDropped: 0, + }); + }) as typeof fetch; + + await act(async () => { + root = createRoot(host); + root.render(createElement(LanguageProvider, null, createElement(Usage, { apiBase, connected: true }))); + }); + await settleTimers(); + + expect(host.querySelector('[data-testid="usage-ledger-retention"]')).toBeNull(); +}); + +test("renders one switch and toggles without rewriting the saved byte ceiling", async () => { + const apiBase = "http://usage-retention-test"; + const maxBytes = 512 * 1024 * 1024 + 17; + const writes: Array<{ enabled: boolean; maxBytes: number }> = []; + let enabled = false; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url !== `${apiBase}/api/storage/usage-ledger-retention`) throw new Error(`unexpected fetch: ${url}`); + if ((init?.method ?? "GET") === "PUT") { + const body = JSON.parse(String(init?.body)) as { enabled: boolean; maxBytes: number }; + writes.push(body); + enabled = body.enabled; + return Response.json({ enabled, maxBytes, currentBytes: 1234 }); + } + return Response.json({ enabled, maxBytes, currentBytes: 1234 }); + }) as typeof fetch; + + await mount(apiBase); + + const switches = host.querySelectorAll("button.switch"); + expect(switches.length).toBe(1); + expect(host.querySelector('[aria-haspopup="listbox"]')).toBeNull(); + expect(switches[0].disabled).toBe(false); + expect(switches[0].getAttribute("aria-pressed")).toBe("false"); + expect(host.querySelector(".usage-retention-state")?.textContent).toBe("Unlimited"); + expect(host.querySelector(".usage-retention-limit")?.classList.contains("is-disabled")).toBe(true); + + await act(async () => { + switches[0].click(); + await Promise.resolve(); + }); + expect(writes[0]).toEqual({ enabled: true, maxBytes }); + expect(switches[0].getAttribute("aria-pressed")).toBe("true"); + expect(host.querySelector(".usage-retention-state")).toBeNull(); + expect(host.querySelector(".usage-retention-limit")?.classList.contains("is-disabled")).toBe(false); + + await act(async () => { + switches[0].click(); + await Promise.resolve(); + }); + expect(writes[1]).toEqual({ enabled: false, maxBytes }); + expect(switches[0].getAttribute("aria-pressed")).toBe("false"); + expect(host.querySelector(".usage-retention-state")?.textContent).toBe("Unlimited"); + expect(host.querySelector(".usage-retention-limit")?.classList.contains("is-disabled")).toBe(true); +}); + +test("a stale GET cannot repaint policy after a successful toggle", async () => { + const apiBase = "http://usage-retention-stale"; + const maxBytes = 1024 * 1024 * 1024; + let getCount = 0; + let resolveStaleGet: ((response: Response) => void) | undefined; + + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url !== `${apiBase}/api/storage/usage-ledger-retention`) throw new Error(`unexpected fetch: ${url}`); + if ((init?.method ?? "GET") === "PUT") { + return Promise.resolve(Response.json({ enabled: true, maxBytes, currentBytes: 1234 })); + } + getCount += 1; + if (getCount === 1) return Promise.resolve(Response.json({ enabled: false, maxBytes, currentBytes: 1234 })); + return new Promise(resolve => { resolveStaleGet = resolve; }); + }) as typeof fetch; + + await act(async () => { + root = createRoot(host); + root.render(createElement(LanguageProvider, null, createElement(LocaleHarness, { apiBase }))); + }); + await settleTimers(); + + const localeSwitch = host.querySelector("#locale-switch"); + if (!localeSwitch) throw new Error("locale switch missing"); + await act(async () => { localeSwitch.click(); }); + await settleTimers(); + expect(getCount).toBe(2); + + const toggle = host.querySelector("button.switch"); + if (!toggle) throw new Error("retention switch missing"); + await act(async () => { + toggle.click(); + await Promise.resolve(); + }); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + + if (!resolveStaleGet) throw new Error("stale GET was not started"); + await act(async () => { + resolveStaleGet(Response.json({ enabled: false, maxBytes, currentBytes: 1234 })); + await Promise.resolve(); + }); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); +}); + +test("a stale failed GET is silent after a successful toggle", async () => { + const apiBase = "http://usage-retention-stale-failure"; + const maxBytes = 1024 * 1024 * 1024; + let getCount = 0; + let resolveStaleGet: ((response: Response) => void) | undefined; + + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url !== `${apiBase}/api/storage/usage-ledger-retention`) throw new Error(`unexpected fetch: ${url}`); + if ((init?.method ?? "GET") === "PUT") { + return Promise.resolve(Response.json({ enabled: true, maxBytes, currentBytes: 1234 })); + } + getCount += 1; + if (getCount === 1) return Promise.resolve(Response.json({ enabled: false, maxBytes, currentBytes: 1234 })); + return new Promise(resolve => { resolveStaleGet = resolve; }); + }) as typeof fetch; + + await act(async () => { + root = createRoot(host); + root.render(createElement(LanguageProvider, null, createElement(LocaleHarness, { apiBase }))); + }); + await settleTimers(); + + const localeSwitch = host.querySelector("#locale-switch"); + if (!localeSwitch) throw new Error("locale switch missing"); + await act(async () => { localeSwitch.click(); }); + await settleTimers(); + expect(getCount).toBe(2); + + const toggle = host.querySelector("button.switch"); + if (!toggle) throw new Error("retention switch missing"); + await act(async () => { + toggle.click(); + await Promise.resolve(); + }); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + + if (!resolveStaleGet) throw new Error("stale GET was not started"); + await act(async () => { + resolveStaleGet(new Response("", { status: 500 })); + await Promise.resolve(); + }); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + expect(host.querySelector('[role="alert"]')).toBeNull(); +}); + +test("shows a custom MiB editor only when enabled and saves the edited ceiling", async () => { + const apiBase = "http://usage-retention-custom"; + const initialMaxBytes = 768 * 1024 * 1024; + const writes: Array<{ enabled: boolean; maxBytes: number }> = []; + let maxBytes = initialMaxBytes; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url !== `${apiBase}/api/storage/usage-ledger-retention`) throw new Error(`unexpected fetch: ${url}`); + if ((init?.method ?? "GET") === "PUT") { + const body = JSON.parse(String(init?.body)) as { enabled: boolean; maxBytes: number }; + writes.push(body); + maxBytes = body.maxBytes; + return Response.json({ enabled: body.enabled, maxBytes, currentBytes: 1234 }); + } + return Response.json({ enabled: true, maxBytes, currentBytes: 1234 }); + }) as typeof fetch; + + await mount(apiBase); + + const input = host.querySelector('input[type="number"]'); + if (!input) throw new Error("custom retention input missing"); + expect(input.value).toBe("768"); + expect(input.min).toBe("1"); + expect(host.querySelector('[aria-haspopup="listbox"]')).toBeNull(); + + const increment = input.parentElement?.querySelector(".ocx-stepper__btn"); + if (!increment) throw new Error("retention stepper missing"); + await act(async () => { increment.click(); }); + expect(testWindow.document.activeElement).toBe(input); + expect(input.value).toBe("769"); + expect(writes).toEqual([]); + + const outside = testWindow.document.createElement("button") as never as HTMLButtonElement; + outside.type = "button"; + host.appendChild(outside as never); + await act(async () => { + outside.focus(); + await Promise.resolve(); + }); + expect(writes).toEqual([{ enabled: true, maxBytes: 769 * 1024 * 1024 }]); + + const toggle = host.querySelector("button.switch"); + if (!toggle) throw new Error("retention switch missing"); + await act(async () => { + toggle.click(); + await Promise.resolve(); + }); + expect(host.querySelector('input[type="number"]')).toBeNull(); +}); + +test("failed toggle keeps the last server state and surfaces an error", async () => { + const apiBase = "http://usage-retention-failure"; + const maxBytes = 256 * 1024 * 1024; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url !== `${apiBase}/api/storage/usage-ledger-retention`) throw new Error(`unexpected fetch: ${url}`); + if ((init?.method ?? "GET") === "PUT") return new Response("", { status: 500 }); + return Response.json({ enabled: false, maxBytes, currentBytes: 0 }); + }) as typeof fetch; + + await mount(apiBase); + const toggle = host.querySelector("button.switch"); + if (!toggle) throw new Error("retention switch missing"); + + await act(async () => { + toggle.click(); + await Promise.resolve(); + }); + + expect(toggle.getAttribute("aria-pressed")).toBe("false"); + expect(host.querySelector('[role="alert"]')?.textContent?.length).toBeGreaterThan(0); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 906114492db..d6b45cacb3f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1436,6 +1436,7 @@ "usage-debug.test.ts": "usage", "usage-failure-persistence.test.ts": "usage", "usage-ledger-scanner.test.ts": "usage", + "ledger-retention.test.ts": "usage", "usage-log.test.ts": "usage", "usage-provider-label.test.ts": "usage", "usage-shape-extraction.test.ts": "usage", diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 94844bf4ec0..bc0bcf1a85b 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -226,6 +226,12 @@ export const configSchema = z.object({ }).optional().catch(undefined), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), + // Opt-in usage ledger byte ceiling. An invalid hand edit degrades to undefined (no limit) + // rather than failing the parse: a malformed number must not cost the operator their + // providers or trigger the backup-and-defaults repair path. The 1 MiB floor is enforced + // at runtime in the retention module, not here: a value the schema accepts but the + // runtime ignores is safer than one the schema rejects and that triggers a config reset. + usageLedgerMaxBytes: z.number().int().positive().optional().catch(undefined), // Invalid values degrade to undefined ("auto") instead of failing the whole // parse: a hand-edited typo must never trip the backup-and-defaults repair // path below and wipe providers/pool accounts. Warning emitted in loadConfig. diff --git a/src/lib/windows-atomic-replace.ts b/src/lib/windows-atomic-replace.ts index a876c98bca0..9d6c450ad33 100644 --- a/src/lib/windows-atomic-replace.ts +++ b/src/lib/windows-atomic-replace.ts @@ -35,7 +35,8 @@ export type ReplacePublisher = | "lab-ledger" | "remote-workspace" | "storage-cleanup" - | "tray"; + | "tray" + | "usage-ledger-retention"; /** The Windows error codes this module treats as a momentary hold. */ export type ReplaceRetryCode = "EBUSY" | "EPERM" | "EACCES"; diff --git a/src/server/index.ts b/src/server/index.ts index 47323e0f681..e980c6fd068 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -50,6 +50,7 @@ import { setLiveStateStoreConfig, } from "../lib/state-store-registrations"; import { startUserCostOverlayReconciler } from "../usage/user-cost-overlay-reconciler"; +import { setUsageLedgerMaxBytes } from "../usage/ledger-retention"; import { configureAppOwnedMemoryBudget, enforceAppOwnedMemoryBudget, @@ -225,6 +226,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server= MIN_USAGE_LEDGER_MAX_BYTES; + const maxBytes = enabled ? config.usageLedgerMaxBytes! : DEFAULT_USAGE_LEDGER_MAX_BYTES; + return jsonResponse({ enabled, maxBytes, currentBytes }, 200, req, config); + } + + if (url.pathname === "/api/storage/usage-ledger-retention" && req.method === "PUT") { + if (ctx.principal !== "gui-session") { + return jsonResponse({ error: "GUI session required" }, 403, req, config); + } + let body: unknown; + try { + body = await readManagementJsonBody(req); + } catch (error) { + const tooLarge = managementBodyTooLargeResponse(error, req, config); + if (tooLarge) return tooLarge; + return jsonResponse({ error: "invalid_json" }, 400, req, config); + } + if (!body || typeof body !== "object" || Array.isArray(body)) { + return jsonResponse({ error: "invalid_request" }, 400, req, config); + } + const candidate = body as Record; + if (typeof candidate.enabled !== "boolean") { + return jsonResponse({ error: "invalid_enabled" }, 400, req, config); + } + let maxBytes = DEFAULT_USAGE_LEDGER_MAX_BYTES; + if (candidate.maxBytes !== undefined) { + if (typeof candidate.maxBytes !== "number" || !Number.isSafeInteger(candidate.maxBytes) || candidate.maxBytes < MIN_USAGE_LEDGER_MAX_BYTES) { + return jsonResponse({ error: "invalid_max_bytes" }, 400, req, config); + } + maxBytes = candidate.maxBytes; + } else if (typeof config.usageLedgerMaxBytes === "number" && config.usageLedgerMaxBytes >= MIN_USAGE_LEDGER_MAX_BYTES) { + maxBytes = config.usageLedgerMaxBytes; + } + + const previousMaxBytes = config.usageLedgerMaxBytes; + if (candidate.enabled) { + config.usageLedgerMaxBytes = maxBytes; + setUsageLedgerMaxBytes(maxBytes); + } else { + config.usageLedgerMaxBytes = undefined; + setUsageLedgerMaxBytes(undefined); + } + + const persistConfig = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode; + try { + persistConfig(config); + } catch { + config.usageLedgerMaxBytes = previousMaxBytes; + setUsageLedgerMaxBytes(previousMaxBytes); + return jsonResponse({ error: "config_write_failed" }, 500, req, config); + } + + let currentBytes = 0; + try { + currentBytes = statSync(usageLogPath()).size; + } catch { + currentBytes = 0; + } + + return jsonResponse({ enabled: candidate.enabled, maxBytes, currentBytes }, 200, req, config); + } + if (url.pathname === "/api/storage/codex-logs") { if (req.method !== "GET") return null; try { diff --git a/src/types/config.ts b/src/types/config.ts index 2c38aef8e8e..2b7016182e9 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1058,6 +1058,15 @@ export interface OcxConfig { tokenGuardian?: OcxTokenGuardianConfig; /** Additional exact origins allowed for CORS (e.g. HTTPS or chrome-extension://). Loopback origins are always allowed. */ corsAllowOrigins?: string[]; + /** + * Opt-in byte ceiling for the append-only `usage.jsonl` ledger. When the file + * exceeds this limit after a write, the oldest rows are discarded and the file + * is atomically replaced with only the newest complete rows that fit. + * + * Absent or undefined means no limit — the historical default. The floor is + * 1 MiB; values below it are treated as unconfigured. + */ + usageLedgerMaxBytes?: number; } export type OcxAccountPoolRotationStrategy = "quota" | "round-robin" | "fill-first"; diff --git a/src/usage/ledger-retention.ts b/src/usage/ledger-retention.ts new file mode 100644 index 00000000000..f4c453a36be --- /dev/null +++ b/src/usage/ledger-retention.ts @@ -0,0 +1,422 @@ +/** + * Usage ledger size-limit enforcement. + * + * When `usageLedgerMaxBytes` is configured, this module truncates `usage.jsonl` + * after a write causes the file to exceed the limit. Truncation keeps only + * the newest complete JSONL rows that fit within the budget, written to a + * temporary file and atomically renamed over the original. The derived + * `routing-history.sqlite` index is closed and deleted so it auto-rebuilds on next query. + * + * Design constraints (from PR #4042 lessons & CodeRabbit review): + * - Memory usage stays bounded: uses backward chunk scanning (max 64 KiB chunks) + * to find row boundaries, streaming/copying in chunks rather than allocating + * the entire maxBytes buffer in memory. + * - High/Low watermark hysteresis: truncates to 90% of maxBytes (retention headroom) + * to avoid rewriting the entire ledger on every append once the ceiling is reached. + * - Strict I/O robustness: uses readAllSync for all bounded reads to guarantee + * complete range population before advancing offsets, and writeAllSync to retry + * partial writes. + * - Complete row preservation: only complete JSONL rows are retained; partial/torn + * tails are discarded. + * - A valid single oversized row (larger than the limit) is preserved. + * If the newest row is oversized, earlier rows are discarded and the newest + * row is validated: retained if valid, discarded if confirmed corrupt. + * - Crash durability: fsyncSync on the temp descriptor before closing, and parent + * directory fsyncSync on POSIX after atomic rename. + * - SQLite lifecycle: calls closeRequestHistoryIndex before deleting SQLite files. + * - Invalidation: discards in-memory usage snapshot and resets index db handle. + * - Best-effort: failures are logged/swallowed so request paths never fail. + * - No scheduler or background worker: inline enforcement after append. + */ + +import { + closeSync, + fstatSync, + fsyncSync, + openSync, + readSync, + writeSync, + chmodSync, + unlinkSync, + existsSync, +} from "node:fs"; +import { join, dirname } from "node:path"; +import { renameAtomicFile } from "../lib/windows-atomic-replace"; +import { closeRequestHistoryIndex } from "../routing/history/indexer"; +import { discardRetainedUsageSnapshot, normalizePersistedUsageRow } from "./log"; + +/** Floor: retention limits below this are treated as unconfigured. */ +export const MIN_USAGE_LEDGER_MAX_BYTES = 1024 * 1024; // 1 MiB + +/** Default ceiling when enabled through the GUI (1 GiB). */ +export const DEFAULT_USAGE_LEDGER_MAX_BYTES = 1024 * 1024 * 1024; // 1 GiB + +/** Target headroom ratio: when truncating, trim to 90% of maxBytes to prevent rewrite on every append. */ +const RETENTION_LOW_WATERMARK_RATIO = 0.9; + +/** Bounded chunk size for backward scanning and copying (64 KiB). */ +const SCAN_CHUNK_BYTES = 64 * 1024; + +let truncationInProgress = false; + +/** + * Module-level configured limit. Set once from config at startup via + * `setUsageLedgerMaxBytes()`. The default (`undefined`) means no limit. + */ +let configuredMaxBytes: number | undefined; + +/** + * Set the configured max bytes for usage ledger retention. + * Called from the startup path after config is loaded. + */ +export function setUsageLedgerMaxBytes(maxBytes: number | undefined): void { + configuredMaxBytes = (maxBytes !== undefined && maxBytes >= MIN_USAGE_LEDGER_MAX_BYTES) + ? maxBytes + : undefined; +} + +/** Read the currently configured limit (test observability). */ +export function getUsageLedgerMaxBytes(): number | undefined { + return configuredMaxBytes; +} + +/** + * If a limit is configured and the file at `ledgerPath` exceeds it, + * rewrite the file keeping only the newest complete rows that fit. + * + * Called synchronously after `appendUsageEntry`; must never throw into + * the request path. + */ +export function enforceUsageLedgerSizeLimit(ledgerPath: string): void { + if (configuredMaxBytes === undefined) return; + if (truncationInProgress) return; + + let fd: number | undefined; + try { + fd = openSync(ledgerPath, "r"); + const size = Number(fstatSync(fd).size); + if (size <= configuredMaxBytes) return; + closeSync(fd); + fd = undefined; + + truncationInProgress = true; + try { + truncateUsageLedger(ledgerPath, configuredMaxBytes); + } finally { + truncationInProgress = false; + } + } catch { + // Best-effort: a failure here must not block the request that just + // appended its usage row successfully. + } finally { + if (fd !== undefined) { + try { closeSync(fd); } catch { /* ignore */ } + } + } +} + +/** Check if a line is a valid, parseable usage entry. */ +function isValidUsageRow(line: string): boolean { + try { + return normalizePersistedUsageRow(JSON.parse(line)) !== undefined; + } catch { + return false; + } +} + +type RowValidation = "valid" | "invalid" | "unverifiable"; + +/** Read exact byte count from fd at position, looping until filled or EOF. Returns actual bytes read. */ +function readAllSync(fd: number, buf: Buffer, length: number, position: number): number { + let total = 0; + while (total < length) { + const r = readSync(fd, buf, total, length - total, position + total); + if (r === 0) break; + total += r; + } + return total; +} + +/** Write all bytes from buffer to fd, retrying partial writes. */ +function writeAllSync(fd: number, buf: Buffer, length: number): void { + let written = 0; + while (written < length) { + const count = writeSync(fd, buf, written, length - written); + if (count === 0) throw new Error("zero-byte write in ledger retention"); + written += count; + } +} + +/** + * Validate a byte range in an open fd as a complete valid usage row without unbounded memory allocations. + * Returns: + * - "valid": definitively valid and parseable as a normalized usage row. + * - "invalid": definitively malformed JSON or missing required fields. + * - "unverifiable": I/O error or excessive size exceeding memory allocation budget. + */ +function validateRangeUsageRow(fd: number, start: number, end: number): RowValidation { + const len = end - start; + if (len <= 0) return "invalid"; + if (!Number.isSafeInteger(len)) return "unverifiable"; + + // Check start byte to catch non-JSON data without allocations + const peekBuf = Buffer.allocUnsafe(Math.min(len, 64)); + const peekRead = readAllSync(fd, peekBuf, peekBuf.length, start); + if (peekRead === 0) return "unverifiable"; + const firstNonWs = peekBuf.subarray(0, peekRead).find(b => b !== 0x20 && b !== 0x09 && b !== 0x0d && b !== 0x0a); + if (firstNonWs !== 0x7b) return "invalid"; // Must start with '{' + + // If reasonably sized (<= 64 MiB), allocate and parse completely + const MAX_PARSE_ALLOCATION = 64 * 1024 * 1024; + if (len > MAX_PARSE_ALLOCATION) { + // For rows larger than 64 MiB, memory pressure on the request path would be severe. + // Rather than classifying as corrupt and discarding, treat as unverifiable. + return "unverifiable"; + } + + try { + const buf = Buffer.allocUnsafe(len); + const bytesRead = readAllSync(fd, buf, len, start); + if (bytesRead !== len) return "unverifiable"; + const text = buf.toString("utf-8"); + return isValidUsageRow(text) ? "valid" : "invalid"; + } catch { + return "unverifiable"; + } +} + +/** + * Read the ledger with bounded memory to find the newest complete JSONL rows + * fitting within `maxBytes` (aiming for the low watermark), write them to a temp file, + * and atomically replace. + */ +function truncateUsageLedger(ledgerPath: string, maxBytes: number): void { + let inFd: number | undefined; + let outFd: number | undefined; + const tmpPath = join(dirname(ledgerPath), `.usage-retention-${process.pid}.tmp`); + + try { + inFd = openSync(ledgerPath, "r"); + const fileSize = Number(fstatSync(inFd).size); + if (fileSize <= maxBytes) return; + + // Target low watermark budget to leave headroom and avoid rewrite churn + const targetBudget = Math.max(1, Math.floor(maxBytes * RETENTION_LOW_WATERMARK_RATIO)); + + // Phase 1: Determine the valid retained range [retainedStart, retainedEnd) + // First, check the end of the file. If it doesn't end with LF, find the last LF. + let retainedEnd = fileSize; + let needsTrailingLf = false; + const tailCheckSize = Math.min(fileSize, SCAN_CHUNK_BYTES); + const tailBuffer = Buffer.allocUnsafe(tailCheckSize); + const tailRead = readAllSync(inFd, tailBuffer, tailCheckSize, fileSize - tailCheckSize); + + if (tailRead > 0 && tailBuffer[tailRead - 1] !== 0x0a) { + // Missing trailing newline: crash tail or unterminated line. + // Search backward for the last LF in the file. + let foundLastLf = -1; + let checkOffset = fileSize; + + while (checkOffset > 0 && foundLastLf === -1) { + const chunkSize = Math.min(checkOffset, SCAN_CHUNK_BYTES); + const buf = Buffer.allocUnsafe(chunkSize); + const bytesRead = readAllSync(inFd, buf, chunkSize, checkOffset - chunkSize); + if (bytesRead === 0) break; + const lastIdx = buf.subarray(0, bytesRead).lastIndexOf(0x0a); + if (lastIdx >= 0) { + foundLastLf = (checkOffset - chunkSize) + lastIdx + 1; + } else { + checkOffset -= chunkSize; + } + } + + if (foundLastLf === -1) { + // No newline anywhere in the entire file. + const res = validateRangeUsageRow(inFd, 0, fileSize); + if (res === "valid" || res === "unverifiable") { + return; // Valid or unverifiable oversized row: preserve original file intact + } + // Confirmed corrupt/invalid single line: discard by writing empty file + retainedEnd = 0; + } else { + // An older LF-terminated row exists, but the file tail lacks a trailing newline. + // Validate [foundLastLf, fileSize) to see if it is a complete valid JSON record. + const tailValidation = validateRangeUsageRow(inFd, foundLastLf, fileSize); + if (tailValidation === "valid") { + retainedEnd = fileSize; + needsTrailingLf = true; // Append LF to temporary file so subsequent appends do not merge + } else if (tailValidation === "unverifiable") { + return; // Cannot prove invalidity; leave ledger unchanged + } else { + retainedEnd = foundLastLf; // Discard partial/crash tail + } + } + } + + // Now determine retainedStart so that (retainedEnd - retainedStart) <= targetBudget + // and retainedStart sits right after an LF (complete row boundary). + let retainedStart = 0; + const targetLength = retainedEnd; + + if (targetLength > targetBudget) { + const minStart = retainedEnd - targetBudget; + // We scan forward from minStart to find the first LF, + // so the retained region starts at that LF + 1. + let scanOffset = minStart; + let foundFirstLf = -1; + + while (scanOffset < retainedEnd && foundFirstLf === -1) { + const chunkSize = Math.min(retainedEnd - scanOffset, SCAN_CHUNK_BYTES); + const buf = Buffer.allocUnsafe(chunkSize); + const bytesRead = readAllSync(inFd, buf, chunkSize, scanOffset); + if (bytesRead === 0) break; + const firstIdx = buf.subarray(0, bytesRead).indexOf(0x0a); + if (firstIdx >= 0) { + foundFirstLf = scanOffset + firstIdx + 1; + } else { + scanOffset += chunkSize; + } + } + + if (foundFirstLf === -1 || foundFirstLf >= retainedEnd) { + // The newest complete row itself spans more than targetBudget (an oversized row). + // Find the start of this newest row by scanning backward from retainedEnd - 1. + let newestRowStart = 0; + let backOffset = retainedEnd - 1; // skip trailing LF of newest row + while (backOffset > 0) { + const chunkSize = Math.min(backOffset, SCAN_CHUNK_BYTES); + const buf = Buffer.allocUnsafe(chunkSize); + const bytesRead = readAllSync(inFd, buf, chunkSize, backOffset - chunkSize); + if (bytesRead === 0) break; + const lfIdx = buf.subarray(0, bytesRead).lastIndexOf(0x0a); + if (lfIdx >= 0) { + newestRowStart = (backOffset - chunkSize) + lfIdx + 1; + break; + } + backOffset -= chunkSize; + } + + // Validate the newest oversized row + const newestValidation = validateRangeUsageRow(inFd, newestRowStart, retainedEnd); + if (newestValidation === "valid") { + if (newestRowStart === 0 && retainedEnd === fileSize) { + return; // Sole line in file is a valid oversized row; preserve file + } + // The newest row is valid: discard older rows and retain this newest row + retainedStart = newestRowStart; + } else if (newestValidation === "unverifiable") { + // Cannot prove invalidity (e.g. allocation failure or >64MB row). + // Do not delete: leave the original file untouched. + return; + } else { + // Confirmed corrupt/invalid newest row: discard it, keep earlier complete rows + retainedEnd = newestRowStart; + retainedStart = 0; + if (retainedEnd > targetBudget) { + // Re-apply budget ceiling to the earlier valid prefix + retainedStart = Math.max(0, retainedEnd - targetBudget); + let sOffset = retainedStart; + let pFirstLf = -1; + while (sOffset < retainedEnd && pFirstLf === -1) { + const cSize = Math.min(retainedEnd - sOffset, SCAN_CHUNK_BYTES); + const b = Buffer.allocUnsafe(cSize); + const bRead = readAllSync(inFd, b, cSize, sOffset); + if (bRead === 0) break; + const idx = b.subarray(0, bRead).indexOf(0x0a); + if (idx >= 0) pFirstLf = sOffset + idx + 1; + else sOffset += cSize; + } + retainedStart = (pFirstLf !== -1 && pFirstLf < retainedEnd) ? pFirstLf : 0; + } + } + } else { + retainedStart = foundFirstLf; + } + } + + const retainedBytes = retainedEnd - retainedStart; + + // Phase 2: Copy [retainedStart, retainedEnd) to tmpPath in bounded chunks + outFd = openSync(tmpPath, "w", 0o600); + try { chmodSync(tmpPath, 0o600); } catch { /* best-effort */ } + + if (retainedBytes > 0) { + let copyOffset = retainedStart; + const copyBuffer = Buffer.allocUnsafe(SCAN_CHUNK_BYTES); + + while (copyOffset < retainedEnd) { + const toRead = Math.min(retainedEnd - copyOffset, SCAN_CHUNK_BYTES); + const bytesRead = readAllSync(inFd, copyBuffer, toRead, copyOffset); + if (bytesRead === 0) { + // Premature EOF: source file shrank or was modified underneath us. + // Abort operation to avoid writing partial content over original. + throw new Error("premature EOF during ledger retention copy"); + } + writeAllSync(outFd, copyBuffer, bytesRead); + copyOffset += bytesRead; + } + } + + if (needsTrailingLf) { + writeAllSync(outFd, Buffer.from("\n", "utf-8"), 1); + } + + try { fsyncSync(outFd); } catch { /* best-effort */ } + closeSync(outFd); + outFd = undefined; + closeSync(inFd); + inFd = undefined; + + renameAtomicFile(tmpPath, ledgerPath, undefined, "usage-ledger-retention"); + + // Sync parent directory on POSIX platforms for crash durability + if (process.platform !== "win32") { + try { + const dirFd = openSync(dirname(ledgerPath), "r"); + try { fsyncSync(dirFd); } finally { closeSync(dirFd); } + } catch { /* best-effort */ } + } + + discardRetainedUsageSnapshot(); + closeRequestHistoryIndex(); + deleteRoutingHistoryIndex(ledgerPath); + } catch { + // Failure is tolerated; clean up temp file if present + try { unlinkSync(tmpPath); } catch { /* ignore */ } + } finally { + if (inFd !== undefined) { + try { closeSync(inFd); } catch { /* ignore */ } + } + if (outFd !== undefined) { + try { closeSync(outFd); } catch { /* ignore */ } + try { unlinkSync(tmpPath); } catch { /* ignore */ } + } + } +} + +/** + * Best-effort deletion of the derived routing-history SQLite index. + * The indexer auto-rebuilds from `usage.jsonl` on next query. + */ +function deleteRoutingHistoryIndex(ledgerPath: string): void { + const dir = dirname(ledgerPath); + for (const suffix of ["routing-history.sqlite", "routing-history.sqlite-wal", "routing-history.sqlite-shm"]) { + const path = join(dir, suffix); + try { + if (existsSync(path)) unlinkSync(path); + } catch { /* best-effort */ } + } +} + +/** Test-only: expose truncation state for assertions. */ +export function isTruncationInProgressForTests(): boolean { + return truncationInProgress; +} + +/** + * Test-only: bypass the MIN_USAGE_LEDGER_MAX_BYTES floor. + * Production code must use `setUsageLedgerMaxBytes()`. + */ +export function setUsageLedgerMaxBytesUnsafe(maxBytes: number | undefined): void { + configuredMaxBytes = maxBytes; +} diff --git a/src/usage/log.ts b/src/usage/log.ts index 4c1cc58204b..e7e73c20954 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -7,6 +7,7 @@ import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { sanitizeLogMetadataString } from "../lib/redact"; import { usageDisplayTotalTokens } from "./totals"; +import { enforceUsageLedgerSizeLimit } from "./ledger-retention"; import type { AttemptTierOutcome, OcxUsage } from "../types"; import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace"; import { ACCOUNT_LOG_LABEL_RE, CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label"; @@ -941,10 +942,12 @@ export function appendUsageEntry(entry: PersistedUsageEntry): void { ensuredUsageLogDir = null; ensuredUsageLogFile = null; doAppend(); + enforceUsageLedgerSizeLimit(path); return; } throw error; } + enforceUsageLedgerSizeLimit(path); } export type UsageLogRevision = { diff --git a/tests/storage/api-storage.test.ts b/tests/storage/api-storage.test.ts index b5ca548e3d0..82d59cde311 100644 --- a/tests/storage/api-storage.test.ts +++ b/tests/storage/api-storage.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; import { startServer } from "../../src/server"; +import { handleStorageLogGuardRoutes } from "../../src/server/management/storage-log-guard-routes"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -148,3 +149,57 @@ describe("GET /api/storage", () => { } }); }); + +describe("usage ledger retention management route", () => { + test("rejects admin-token principals because the route is GUI-session-only", async () => { + const url = new URL("http://127.0.0.1/api/storage/usage-ledger-retention"); + const response = await handleStorageLogGuardRoutes({ + req: new Request(url), + url, + config: baseConfig(), + deps: {}, + version: "test", + principal: "admin-token", + convergeCodexCatalog: async () => { throw new Error("unused"); }, + syncClaudeAgentDefsBestEffort: async () => {}, + }); + + expect(response?.status).toBe(403); + expect(await response?.json()).toEqual({ error: "GUI session required" }); + }); + + test("supports GET and PUT policy management", async () => { + const url = new URL("http://127.0.0.1/api/storage/usage-ledger-retention"); + const cfg = baseConfig(); + const getRes = await handleStorageLogGuardRoutes({ + req: new Request(url), + url, + config: cfg, + deps: {}, + version: "test", + principal: "gui-session", + convergeCodexCatalog: async () => { throw new Error("unused"); }, + syncClaudeAgentDefsBestEffort: async () => {}, + }); + expect(getRes?.status).toBe(200); + expect(await getRes?.json()).toMatchObject({ enabled: false, maxBytes: expect.any(Number), currentBytes: expect.any(Number) }); + + const putUrl = new URL("http://127.0.0.1/api/storage/usage-ledger-retention"); + const putRes = await handleStorageLogGuardRoutes({ + req: new Request(putUrl, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true, maxBytes: 8 * 1024 * 1024 }), + }), + url: putUrl, + config: cfg, + deps: { saveConfigPreservingClaudeCode: () => {} }, + version: "test", + principal: "gui-session", + convergeCodexCatalog: async () => { throw new Error("unused"); }, + syncClaudeAgentDefsBestEffort: async () => {}, + }); + expect(putRes?.status).toBe(200); + expect(await putRes?.json()).toMatchObject({ enabled: true, maxBytes: 8 * 1024 * 1024 }); + }); +}); diff --git a/tests/usage/ledger-retention.test.ts b/tests/usage/ledger-retention.test.ts new file mode 100644 index 00000000000..dd338aff0be --- /dev/null +++ b/tests/usage/ledger-retention.test.ts @@ -0,0 +1,442 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { + enforceUsageLedgerSizeLimit, + MIN_USAGE_LEDGER_MAX_BYTES, + setUsageLedgerMaxBytes, + getUsageLedgerMaxBytes, + setUsageLedgerMaxBytesUnsafe, +} from "../../src/usage/ledger-retention"; +import { appendUsageEntry, usageLogPath } from "../../src/usage/log"; + +let testDir = ""; +let ledgerPath = ""; + +/** Build a JSONL row of approximately `bytes` total (including the trailing LF). */ +function makeRow(id: string, paddingBytes = 0): string { + const base = JSON.stringify({ requestId: id, timestamp: Date.now(), provider: "openai", model: "gpt-4", totalCost: 0.01 }); + if (paddingBytes <= 0) return base + "\n"; + // Pad with spaces inside the JSON (valid JSON, just has a long string value). + const needed = paddingBytes - base.length - 1; // -1 for the trailing LF + if (needed <= 0) return base + "\n"; + const padded = JSON.stringify({ + requestId: id, + timestamp: Date.now(), + provider: "openai", + model: "gpt-4", + totalCost: 0.01, + _pad: "x".repeat(Math.max(0, needed - 10)), // rough; exact size doesn't matter + }); + return padded + "\n"; +} + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "ledger-retention-test-")); + ledgerPath = join(testDir, "usage.jsonl"); + // Reset module state + setUsageLedgerMaxBytes(undefined); +}); + +afterEach(() => { + setUsageLedgerMaxBytesUnsafe(undefined); + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { /* ignore */ } +}); + +describe("ledger-retention", () => { + describe("setUsageLedgerMaxBytes / getUsageLedgerMaxBytes", () => { + test("undefined by default", () => { + expect(getUsageLedgerMaxBytes()).toBeUndefined(); + }); + + test("accepts values >= MIN_USAGE_LEDGER_MAX_BYTES", () => { + setUsageLedgerMaxBytes(MIN_USAGE_LEDGER_MAX_BYTES); + expect(getUsageLedgerMaxBytes()).toBe(MIN_USAGE_LEDGER_MAX_BYTES); + }); + + test("rejects values below floor as unconfigured", () => { + setUsageLedgerMaxBytes(100); + expect(getUsageLedgerMaxBytes()).toBeUndefined(); + }); + + test("rejects undefined", () => { + setUsageLedgerMaxBytes(5_000_000); + expect(getUsageLedgerMaxBytes()).toBe(5_000_000); + setUsageLedgerMaxBytes(undefined); + expect(getUsageLedgerMaxBytes()).toBeUndefined(); + }); + }); + + describe("enforceUsageLedgerSizeLimit", () => { + test("no-op when unconfigured (no limit set)", () => { + // Write a large file — should NOT be truncated + const rows = Array.from({ length: 50 }, (_, i) => makeRow(`req-${i}`, 200)); + writeFileSync(ledgerPath, rows.join("")); + const sizeBefore = statSync(ledgerPath).size; + + enforceUsageLedgerSizeLimit(ledgerPath); + + expect(statSync(ledgerPath).size).toBe(sizeBefore); + expect(readFileSync(ledgerPath, "utf-8")).toBe(rows.join("")); + }); + + test("no-op when file is under the limit", () => { + setUsageLedgerMaxBytes(MIN_USAGE_LEDGER_MAX_BYTES); + const rows = Array.from({ length: 3 }, (_, i) => makeRow(`req-${i}`)); + writeFileSync(ledgerPath, rows.join("")); + const sizeBefore = statSync(ledgerPath).size; + + enforceUsageLedgerSizeLimit(ledgerPath); + + expect(statSync(ledgerPath).size).toBe(sizeBefore); + }); + + test("truncates when file exceeds limit, keeping newest rows", () => { + // Use a small limit for testing (bypass the floor via test helper) + const limit = 2048; + setUsageLedgerMaxBytesUnsafe(limit); + + // Build rows so total exceeds 2048 bytes + const rows: string[] = []; + for (let i = 0; i < 30; i++) { + rows.push(makeRow(`req-${i}`, 100)); + } + writeFileSync(ledgerPath, rows.join("")); + const sizeBefore = statSync(ledgerPath).size; + expect(sizeBefore).toBeGreaterThan(limit); + + enforceUsageLedgerSizeLimit(ledgerPath); + + const sizeAfter = statSync(ledgerPath).size; + expect(sizeAfter).toBeLessThanOrEqual(limit); + + // Every line should be valid JSON + const retained = readFileSync(ledgerPath, "utf-8"); + const lines = retained.split("\n").filter(l => l.length > 0); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) { + expect(() => JSON.parse(line)).not.toThrow(); + } + + // The last row of the original should be the last row of the retained + const lastOriginal = rows[rows.length - 1].trim(); + expect(lines[lines.length - 1]).toBe(lastOriginal); + }); + + test("every retained line is complete valid JSONL", () => { + const limit = 1500; + setUsageLedgerMaxBytesUnsafe(limit); + + const rows: string[] = []; + for (let i = 0; i < 20; i++) { + rows.push(makeRow(`req-${i}`, 120)); + } + writeFileSync(ledgerPath, rows.join("")); + + enforceUsageLedgerSizeLimit(ledgerPath); + + const content = readFileSync(ledgerPath, "utf-8"); + // Must end with newline + expect(content.endsWith("\n")).toBe(true); + // Every line parses as JSON + const lines = content.split("\n").filter(l => l.length > 0); + for (const line of lines) { + const parsed = JSON.parse(line); + expect(parsed).toHaveProperty("requestId"); + } + }); + + test("handles exact line boundary (file size exactly equals limit)", () => { + // Build rows to exactly hit the limit + const row = makeRow("exact", 0); + const rowBytes = Buffer.byteLength(row, "utf-8"); + // Set limit to exact multiple of row size + const count = 10; + const limit = rowBytes * count; + setUsageLedgerMaxBytesUnsafe(limit); + + // Write exactly `count` rows — should NOT truncate + const rows = Array.from({ length: count }, () => row); + writeFileSync(ledgerPath, rows.join("")); + expect(statSync(ledgerPath).size).toBe(limit); + + enforceUsageLedgerSizeLimit(ledgerPath); + + expect(statSync(ledgerPath).size).toBe(limit); + }); + + test("single row larger than limit is preserved (never produces empty file)", () => { + const limit = MIN_USAGE_LEDGER_MAX_BYTES; // 1 MiB + setUsageLedgerMaxBytes(limit); + + // Write a single row larger than the limit + const bigRow = makeRow("oversized", limit + 1000); + writeFileSync(ledgerPath, bigRow); + const sizeBefore = statSync(ledgerPath).size; + expect(sizeBefore).toBeGreaterThan(limit); + + enforceUsageLedgerSizeLimit(ledgerPath); + + // File should be unchanged — we never produce an empty ledger + expect(statSync(ledgerPath).size).toBe(sizeBefore); + expect(readFileSync(ledgerPath, "utf-8")).toBe(bigRow); + }); + + test("handles incomplete trailing line (crash tail)", () => { + const limit = 2048; + setUsageLedgerMaxBytesUnsafe(limit); + + const rows: string[] = []; + for (let i = 0; i < 30; i++) { + rows.push(makeRow(`req-${i}`, 100)); + } + // Append a partial/corrupt trailing line (no newline) + const content = rows.join("") + '{"requestId":"crash","timesta'; + writeFileSync(ledgerPath, content); + + enforceUsageLedgerSizeLimit(ledgerPath); + + const retained = readFileSync(ledgerPath, "utf-8"); + // Must end with newline (incomplete tail stripped) + expect(retained.endsWith("\n")).toBe(true); + // Must not contain the partial line + expect(retained).not.toContain("crash"); + // All lines must parse + const lines = retained.split("\n").filter(l => l.length > 0); + for (const line of lines) { + expect(() => JSON.parse(line)).not.toThrow(); + } + }); + + test("discards an oversized unterminated partial/corrupt line", () => { + const limit = MIN_USAGE_LEDGER_MAX_BYTES; // 1 MiB + setUsageLedgerMaxBytes(limit); + + // Write a single oversized corrupt line without newline that is NOT valid JSON + const corruptData = "corrupt_data_without_newline_".repeat(50_000); + writeFileSync(ledgerPath, corruptData); + expect(statSync(ledgerPath).size).toBeGreaterThan(limit); + + enforceUsageLedgerSizeLimit(ledgerPath); + + // Should be truncated to an empty file (invalid partial tail discarded) + expect(statSync(ledgerPath).size).toBe(0); + }); + + test("discards older rows and retains valid oversized newest row", () => { + const limit = MIN_USAGE_LEDGER_MAX_BYTES; // 1 MiB + setUsageLedgerMaxBytes(limit); + + const oldRows = [makeRow("old-1", 100), makeRow("old-2", 100)].join(""); + const newestOversized = makeRow("newest-oversized", limit + 10_000); + writeFileSync(ledgerPath, oldRows + newestOversized); + expect(statSync(ledgerPath).size).toBeGreaterThan(limit + 10_000); + + enforceUsageLedgerSizeLimit(ledgerPath); + + // Older rows discarded, only the newest valid oversized row is kept + const content = readFileSync(ledgerPath, "utf-8"); + expect(content).toBe(newestOversized); + expect(content).not.toContain("old-1"); + }); + + test("preserves valid oversized row exceeding 10 MiB", () => { + const limit = MIN_USAGE_LEDGER_MAX_BYTES; // 1 MiB + setUsageLedgerMaxBytes(limit); + + // Create a valid row > 10 MiB (11 MiB) + const hugeRow = makeRow("huge-row", 11 * 1024 * 1024); + writeFileSync(ledgerPath, hugeRow); + expect(statSync(ledgerPath).size).toBeGreaterThan(10 * 1024 * 1024); + + enforceUsageLedgerSizeLimit(ledgerPath); + + // Sole oversized valid row must be preserved + expect(statSync(ledgerPath).size).toBe(Buffer.byteLength(hugeRow, "utf-8")); + }); + + test("preserves valid unterminated final row following older complete rows and appends newline", () => { + const limit = 2048; + setUsageLedgerMaxBytesUnsafe(limit); + + const oldRows = Array.from({ length: 25 }, (_, i) => makeRow(`old-${i}`, 100)).join(""); + // A valid JSON row without a trailing newline + const finalUnterminated = JSON.stringify({ + requestId: "final-valid", + timestamp: Date.now(), + provider: "openai", + model: "gpt-4", + totalCost: 0.01, + }); + writeFileSync(ledgerPath, oldRows + finalUnterminated); + expect(statSync(ledgerPath).size).toBeGreaterThan(limit); + + enforceUsageLedgerSizeLimit(ledgerPath); + + const content = readFileSync(ledgerPath, "utf-8"); + // The valid final row must not be discarded + expect(content).toContain("final-valid"); + // And must now be properly terminated with LF so future appends don't merge + expect(content.endsWith(finalUnterminated + "\n")).toBe(true); + }); + + test("deletes routing-history.sqlite after truncation", () => { + const limit = 2048; + setUsageLedgerMaxBytesUnsafe(limit); + + // Create a fake routing-history.sqlite + const sqlitePath = join(testDir, "routing-history.sqlite"); + writeFileSync(sqlitePath, "fake-db"); + expect(existsSync(sqlitePath)).toBe(true); + + const rows: string[] = []; + for (let i = 0; i < 30; i++) { + rows.push(makeRow(`req-${i}`, 100)); + } + writeFileSync(ledgerPath, rows.join("")); + + enforceUsageLedgerSizeLimit(ledgerPath); + + // The sqlite file should have been deleted + expect(existsSync(sqlitePath)).toBe(false); + }); + + test("concurrent re-entrancy is prevented", () => { + // We can't easily test true concurrency in a sync function, + // but we verify the truncationInProgress flag works by checking + // that two rapid calls don't corrupt the file. + const limit = 2048; + setUsageLedgerMaxBytesUnsafe(limit); + + const rows: string[] = []; + for (let i = 0; i < 30; i++) { + rows.push(makeRow(`req-${i}`, 100)); + } + writeFileSync(ledgerPath, rows.join("")); + + // Call twice — second should be a no-op (the first already truncated) + enforceUsageLedgerSizeLimit(ledgerPath); + const sizeAfterFirst = statSync(ledgerPath).size; + enforceUsageLedgerSizeLimit(ledgerPath); + const sizeAfterSecond = statSync(ledgerPath).size; + + // Both should produce the same result (idempotent) + expect(sizeAfterSecond).toBe(sizeAfterFirst); + + // Verify content integrity + const content = readFileSync(ledgerPath, "utf-8"); + const lines = content.split("\n").filter(l => l.length > 0); + for (const line of lines) { + expect(() => JSON.parse(line)).not.toThrow(); + } + }); + + test("handles missing file gracefully (best-effort)", () => { + setUsageLedgerMaxBytes(MIN_USAGE_LEDGER_MAX_BYTES); + // Should not throw even if file doesn't exist + expect(() => enforceUsageLedgerSizeLimit(join(testDir, "nonexistent.jsonl"))).not.toThrow(); + }); + + test("handles empty file", () => { + setUsageLedgerMaxBytes(MIN_USAGE_LEDGER_MAX_BYTES); + writeFileSync(ledgerPath, ""); + expect(() => enforceUsageLedgerSizeLimit(ledgerPath)).not.toThrow(); + expect(statSync(ledgerPath).size).toBe(0); + }); + + test("no temp files left after truncation", () => { + const limit = 2048; + setUsageLedgerMaxBytesUnsafe(limit); + + const rows: string[] = []; + for (let i = 0; i < 30; i++) { + rows.push(makeRow(`req-${i}`, 100)); + } + writeFileSync(ledgerPath, rows.join("")); + + enforceUsageLedgerSizeLimit(ledgerPath); + + // Check no .tmp files remain + const { readdirSync } = require("node:fs"); + const files = readdirSync(testDir) as string[]; + const tmpFiles = files.filter((f: string) => f.endsWith(".tmp")); + expect(tmpFiles).toHaveLength(0); + }); + + test("appendUsageEntry invokes retention enforcement on normal append", () => { + const limit = 2048; + setUsageLedgerMaxBytesUnsafe(limit); + + const realLog = usageLogPath(); + const previousContent = existsSync(realLog) ? readFileSync(realLog) : null; + try { + // Populate ledger path + const rows = Array.from({ length: 30 }, (_, i) => makeRow(`app-${i}`, 100)); + writeFileSync(realLog, rows.join("")); + expect(statSync(realLog).size).toBeGreaterThan(limit); + + // Appending another entry triggers inline enforcement + appendUsageEntry({ + requestId: "trigger-append", + timestamp: Date.now(), + provider: "openai", + model: "gpt-4", + totalCost: 0.01, + status: 200, + durationMs: 100, + usageStatus: "reported", + }); + + // The file size must have been reduced to within the limit + expect(statSync(realLog).size).toBeLessThanOrEqual(limit); + expect(readFileSync(realLog, "utf-8")).toContain("trigger-append"); + } finally { + if (previousContent !== null) writeFileSync(realLog, previousContent); + else try { rmSync(realLog); } catch { /* ignore */ } + } + }); + + test("appendUsageEntry invokes retention enforcement on ENOENT retry", () => { + const limit = 2048; + setUsageLedgerMaxBytesUnsafe(limit); + + const realLog = usageLogPath(); + const previousContent = existsSync(realLog) ? readFileSync(realLog) : null; + try { + const parentDir = dirname(realLog); + rmSync(parentDir, { recursive: true, force: true }); + + // Appending when directory was removed exercises the ENOENT recovery branch + appendUsageEntry({ + requestId: "enoent-append", + timestamp: Date.now(), + provider: "openai", + model: "gpt-4", + totalCost: 0.01, + status: 200, + durationMs: 100, + usageStatus: "reported", + }); + + expect(existsSync(realLog)).toBe(true); + expect(readFileSync(realLog, "utf-8")).toContain("enoent-append"); + } finally { + if (previousContent !== null) { + mkdirSync(dirname(realLog), { recursive: true }); + writeFileSync(realLog, previousContent); + } + } + }); + }); +});