From 51fd30e5ae1845b3cab278fc62f545ac802b8fdf Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:01:16 +0800 Subject: [PATCH 01/61] feat(usage): add safe ledger retention compaction core --- src/usage/ledger-retention.ts | 239 ++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 src/usage/ledger-retention.ts diff --git a/src/usage/ledger-retention.ts b/src/usage/ledger-retention.ts new file mode 100644 index 0000000000..a811c9b16f --- /dev/null +++ b/src/usage/ledger-retention.ts @@ -0,0 +1,239 @@ +import { + chmodSync, + closeSync, + existsSync, + fstatSync, + fsyncSync, + openSync, + readSync, + unlinkSync, + writeSync, +} from "node:fs"; + +export const DEFAULT_USAGE_LEDGER_MAX_BYTES = 512 * 1024 * 1024; +export const MIN_USAGE_LEDGER_MAX_BYTES = 1024 * 1024; +const SCAN_CHUNK_BYTES = 1024 * 1024; + +/** Persisted, user-authored config. Every key is optional on disk. */ +export interface PersistedUsageLedgerRetention { + enabled?: boolean; + maxBytes?: number; +} + +/** Fully normalized policy used by the mutation path. */ +export interface UsageLedgerRetention { + enabled: boolean; + maxBytes: number; +} + +export interface UsageLedgerRevision { + dev: number; + ino: number; + size: number; + mtimeMs: number; + ctimeMs: number; +} + +export interface PreparedUsageLedgerCompaction { + changed: true; + path: string; + tempPath: string; + beforeBytes: number; + afterBytes: number; + droppedBytes: number; + sourceRevision: UsageLedgerRevision; +} + +export interface SkippedUsageLedgerCompaction { + changed: false; + path: string; + beforeBytes: number; + afterBytes: number; + droppedBytes: 0; + reason: "missing" | "within_limit"; +} + +export type UsageLedgerCompactionPreparation = + | PreparedUsageLedgerCompaction + | SkippedUsageLedgerCompaction; + +/** + * Normalize the destructive retention policy fail-closed. + * + * Unknown keys disable the feature rather than being silently stripped: a typo + * such as `maxByets` must never turn an intended large limit into the default. + * Invalid/unsafe byte values likewise disable the feature. + */ +export function normalizeUsageLedgerRetention(raw: unknown): UsageLedgerRetention { + const disabled = { enabled: false, maxBytes: DEFAULT_USAGE_LEDGER_MAX_BYTES } as const; + if (raw === undefined || raw === null) return disabled; + if (typeof raw !== "object" || Array.isArray(raw)) return disabled; + + const row = raw as Record; + const allowed = new Set(["enabled", "maxBytes"]); + if (Object.keys(row).some(key => !allowed.has(key))) return disabled; + if (row.enabled !== undefined && typeof row.enabled !== "boolean") return disabled; + if (row.enabled !== true) return disabled; + + const maxBytes = row.maxBytes ?? DEFAULT_USAGE_LEDGER_MAX_BYTES; + if ( + typeof maxBytes !== "number" + || !Number.isSafeInteger(maxBytes) + || maxBytes < MIN_USAGE_LEDGER_MAX_BYTES + ) { + return disabled; + } + return { enabled: true, maxBytes }; +} + +/** Snapshot the identity fields used to prove the source did not change. */ +export function usageLedgerRevisionFromStat(stat: { + dev: number | bigint; + ino: number | bigint; + size: number | bigint; + mtimeMs: number; + ctimeMs: number; +}): UsageLedgerRevision { + return { + dev: Number(stat.dev), + ino: Number(stat.ino), + size: Number(stat.size), + mtimeMs: Number(stat.mtimeMs), + ctimeMs: Number(stat.ctimeMs), + }; +} + +/** Exact revision comparison used immediately before the atomic replace. */ +export function usageLedgerRevisionMatches( + left: UsageLedgerRevision, + right: UsageLedgerRevision, +): boolean { + return left.dev === right.dev + && left.ino === right.ino + && left.size === right.size + && left.mtimeMs === right.mtimeMs + && left.ctimeMs === right.ctimeMs; +} + +function findLastNewline(fd: number, endExclusive: number): number { + const buffer = Buffer.allocUnsafe(SCAN_CHUNK_BYTES); + let end = endExclusive; + while (end > 0) { + const start = Math.max(0, end - buffer.length); + const length = end - start; + const read = readSync(fd, buffer, 0, length, start); + for (let index = read - 1; index >= 0; index -= 1) { + if (buffer[index] === 0x0a) return start + index; + } + end = start; + } + return -1; +} + +function findFirstNewline(fd: number, startInclusive: number, endExclusive: number): number { + const buffer = Buffer.allocUnsafe(SCAN_CHUNK_BYTES); + let start = startInclusive; + while (start < endExclusive) { + const length = Math.min(buffer.length, endExclusive - start); + const read = readSync(fd, buffer, 0, length, start); + if (read <= 0) return -1; + for (let index = 0; index < read; index += 1) { + if (buffer[index] === 0x0a) return start + index; + } + start += read; + } + return -1; +} + +function copyRange(sourceFd: number, targetFd: number, start: number, endExclusive: number): number { + const buffer = Buffer.allocUnsafe(SCAN_CHUNK_BYTES); + let offset = start; + let written = 0; + while (offset < endExclusive) { + const wanted = Math.min(buffer.length, endExclusive - offset); + const read = readSync(sourceFd, buffer, 0, wanted, offset); + if (read <= 0) break; + let cursor = 0; + while (cursor < read) { + cursor += writeSync(targetFd, buffer, cursor, read - cursor); + } + offset += read; + written += read; + } + return written; +} + +/** + * Build a compacted candidate without mutating the live ledger. + * + * The candidate contains only complete JSONL rows. The start scan has no fixed + * probe ceiling, so a single row larger than the copy chunk cannot leak a + * partial prefix. The backward scan drops an unterminated crash tail. If one + * complete row itself exceeds maxBytes it is dropped, preserving the hard cap. + */ +export function prepareUsageLedgerCompaction( + path: string, + maxBytes: number, +): UsageLedgerCompactionPreparation { + if (!Number.isSafeInteger(maxBytes) || maxBytes < MIN_USAGE_LEDGER_MAX_BYTES) { + throw new RangeError(`maxBytes must be a safe integer >= ${MIN_USAGE_LEDGER_MAX_BYTES}`); + } + if (!existsSync(path)) { + return { changed: false, path, beforeBytes: 0, afterBytes: 0, droppedBytes: 0, reason: "missing" }; + } + + const sourceFd = openSync(path, "r"); + let tempPath: string | null = null; + try { + const sourceStat = fstatSync(sourceFd); + const sourceRevision = usageLedgerRevisionFromStat(sourceStat); + const beforeBytes = sourceRevision.size; + if (beforeBytes <= maxBytes) { + return { + changed: false, + path, + beforeBytes, + afterBytes: beforeBytes, + droppedBytes: 0, + reason: "within_limit", + }; + } + + const lastNewline = findLastNewline(sourceFd, beforeBytes); + const completeEnd = lastNewline < 0 ? 0 : lastNewline + 1; + const desiredStart = Math.max(0, completeEnd - maxBytes); + let retainedStart = 0; + if (desiredStart > 0) { + const newline = findFirstNewline(sourceFd, desiredStart, completeEnd); + retainedStart = newline < 0 ? completeEnd : newline + 1; + } + + tempPath = `${path}.retention-${process.pid}-${crypto.randomUUID()}.tmp`; + const targetFd = openSync(tempPath, "wx", 0o600); + let afterBytes = 0; + try { + afterBytes = copyRange(sourceFd, targetFd, retainedStart, completeEnd); + fsyncSync(targetFd); + } finally { + closeSync(targetFd); + } + try { chmodSync(tempPath, 0o600); } catch { /* best-effort on platforms that ignore chmod */ } + + const result: PreparedUsageLedgerCompaction = { + changed: true, + path, + tempPath, + beforeBytes, + afterBytes, + droppedBytes: beforeBytes - afterBytes, + sourceRevision, + }; + tempPath = null; + return result; + } finally { + closeSync(sourceFd); + if (tempPath) { + try { unlinkSync(tempPath); } catch { /* best-effort cleanup */ } + } + } +} From 5ef1ba43ad0718fc8d15f8f849c069d8eb1cebec Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:01:45 +0800 Subject: [PATCH 02/61] test(usage): cover safe ledger retention boundaries --- tests/usage-ledger-retention-v2.test.ts | 93 +++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/usage-ledger-retention-v2.test.ts diff --git a/tests/usage-ledger-retention-v2.test.ts b/tests/usage-ledger-retention-v2.test.ts new file mode 100644 index 0000000000..7e4a06b237 --- /dev/null +++ b/tests/usage-ledger-retention-v2.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + DEFAULT_USAGE_LEDGER_MAX_BYTES, + MIN_USAGE_LEDGER_MAX_BYTES, + normalizeUsageLedgerRetention, + prepareUsageLedgerCompaction, + usageLedgerRevisionMatches, +} from "../src/usage/ledger-retention"; + +const homes: string[] = []; + +function home(): string { + const dir = mkdtempSync(join(tmpdir(), "ocx-ledger-retention-")); + homes.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of homes.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("usage ledger retention v2", () => { + test("unknown config keys disable destructive retention", () => { + expect(normalizeUsageLedgerRetention({ enabled: true, maxByets: 8 * 1024 * 1024 })).toEqual({ + enabled: false, + maxBytes: DEFAULT_USAGE_LEDGER_MAX_BYTES, + }); + }); + + test("unsafe or below-floor byte limits disable destructive retention", () => { + for (const maxBytes of [Number.MAX_SAFE_INTEGER + 1, MIN_USAGE_LEDGER_MAX_BYTES - 1, 1.5 * MIN_USAGE_LEDGER_MAX_BYTES]) { + expect(normalizeUsageLedgerRetention({ enabled: true, maxBytes }).enabled).toBe(false); + } + }); + + test("normalizes an explicitly enabled safe byte limit", () => { + const maxBytes = 8 * 1024 * 1024; + expect(normalizeUsageLedgerRetention({ enabled: true, maxBytes })).toEqual({ enabled: true, maxBytes }); + }); + + test("drops an oversized single row instead of retaining a partial JSONL fragment", () => { + const dir = home(); + const path = join(dir, "usage.jsonl"); + const huge = `${JSON.stringify({ requestId: "huge", payload: "x".repeat(MIN_USAGE_LEDGER_MAX_BYTES + 1024) })}\n`; + writeFileSync(path, huge); + + const prepared = prepareUsageLedgerCompaction(path, MIN_USAGE_LEDGER_MAX_BYTES); + expect(prepared.changed).toBe(true); + if (!prepared.changed) throw new Error("expected compaction"); + expect(prepared.afterBytes).toBe(0); + expect(readFileSync(prepared.tempPath, "utf8")).toBe(""); + }); + + test("drops an unterminated crash tail", () => { + const dir = home(); + const path = join(dir, "usage.jsonl"); + const filler = "x".repeat(MIN_USAGE_LEDGER_MAX_BYTES); + const complete = `${JSON.stringify({ requestId: "complete", filler })}\n`; + const partial = JSON.stringify({ requestId: "partial", filler: "y".repeat(1024) }); + writeFileSync(path, complete + partial); + + const prepared = prepareUsageLedgerCompaction(path, MIN_USAGE_LEDGER_MAX_BYTES); + expect(prepared.changed).toBe(true); + if (!prepared.changed) throw new Error("expected compaction"); + const retained = readFileSync(prepared.tempPath, "utf8"); + expect(retained.endsWith("\n")).toBe(true); + expect(retained).not.toContain("partial"); + }); + + test("never starts the candidate in the middle of a long row", () => { + const dir = home(); + const path = join(dir, "usage.jsonl"); + const first = `${JSON.stringify({ requestId: "old", filler: "a".repeat(MIN_USAGE_LEDGER_MAX_BYTES + 128) })}\n`; + const second = `${JSON.stringify({ requestId: "new", filler: "b".repeat(64 * 1024) })}\n`; + writeFileSync(path, first + second); + + const prepared = prepareUsageLedgerCompaction(path, MIN_USAGE_LEDGER_MAX_BYTES); + expect(prepared.changed).toBe(true); + if (!prepared.changed) throw new Error("expected compaction"); + const retained = readFileSync(prepared.tempPath, "utf8"); + expect(retained).toBe(second); + expect(() => JSON.parse(retained.trim())).not.toThrow(); + }); + + test("revision comparator detects a source mutation before commit", () => { + const revision = { dev: 1, ino: 2, size: 3, mtimeMs: 4, ctimeMs: 5 }; + expect(usageLedgerRevisionMatches(revision, revision)).toBe(true); + expect(usageLedgerRevisionMatches(revision, { ...revision, size: 4 })).toBe(false); + }); +}); From 5dd4e819bf3d2ca76aa0b296bfe3d373c27dfb46 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:14:33 +0800 Subject: [PATCH 03/61] feat(usage): persist strict ledger retention policy --- src/usage/ledger-retention-config.ts | 106 +++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/usage/ledger-retention-config.ts diff --git a/src/usage/ledger-retention-config.ts b/src/usage/ledger-retention-config.ts new file mode 100644 index 0000000000..f9ad0c3d94 --- /dev/null +++ b/src/usage/ledger-retention-config.ts @@ -0,0 +1,106 @@ +import { statSync } from "node:fs"; +import { loadConfig, saveConfigPreservingClaudeCode } from "../config"; +import type { OcxConfig } from "../types"; +import { usageLogPath } from "./log"; +import { + DEFAULT_USAGE_LEDGER_MAX_BYTES, + MIN_USAGE_LEDGER_MAX_BYTES, + normalizeUsageLedgerRetention, + type PersistedUsageLedgerRetention, + type UsageLedgerRetention, +} from "./ledger-retention"; + +type ConfigWithUsageLedgerRetention = OcxConfig & { + usageLedgerRetention?: PersistedUsageLedgerRetention; +}; + +export type UsageLedgerRetentionStatus = UsageLedgerRetention & { + currentBytes: number; + overLimit: boolean; +}; + +/** Read the opt-in policy from config. Unknown/malformed persisted keys fail closed. */ +export function readUsageLedgerRetentionFromConfig(config?: OcxConfig): UsageLedgerRetention { + const source = (config ?? loadConfig()) as ConfigWithUsageLedgerRetention; + return normalizeUsageLedgerRetention(source.usageLedgerRetention); +} + +/** Strict live-write parser. Destructive settings reject unknown keys instead of ignoring typos. */ +export function parseUsageLedgerRetentionInput( + raw: unknown, + previous: UsageLedgerRetention = { enabled: false, maxBytes: DEFAULT_USAGE_LEDGER_MAX_BYTES }, +): { ok: true; policy: UsageLedgerRetention } | { ok: false; error: string } { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return { ok: false, error: "body must be a JSON object" }; + } + const row = raw as Record; + const allowed = new Set(["enabled", "maxBytes"]); + const unknownKey = Object.keys(row).find(key => !allowed.has(key)); + if (unknownKey) return { ok: false, error: `unknown field: ${unknownKey}` }; + + if (row.enabled !== undefined && typeof row.enabled !== "boolean") { + return { ok: false, error: "enabled must be a boolean" }; + } + if (row.maxBytes !== undefined) { + if ( + typeof row.maxBytes !== "number" + || !Number.isSafeInteger(row.maxBytes) + || row.maxBytes < MIN_USAGE_LEDGER_MAX_BYTES + ) { + return { + ok: false, + error: `maxBytes must be a safe integer >= ${MIN_USAGE_LEDGER_MAX_BYTES}`, + }; + } + } + + return { + ok: true, + policy: { + enabled: row.enabled === undefined ? previous.enabled : row.enabled, + maxBytes: row.maxBytes === undefined ? previous.maxBytes : row.maxBytes, + }, + }; +} + +/** Persist a complete normalized policy. The feature is never enabled implicitly. */ +export function writeUsageLedgerRetentionToConfig(policy: UsageLedgerRetention): UsageLedgerRetention { + const normalized = normalizeUsageLedgerRetention({ + enabled: policy.enabled, + maxBytes: policy.maxBytes, + }); + const config = loadConfig() as ConfigWithUsageLedgerRetention; + config.usageLedgerRetention = { + enabled: normalized.enabled, + maxBytes: normalized.maxBytes, + }; + saveConfigPreservingClaudeCode(config); + return normalized; +} + +/** Mirror a persisted policy into the live server config after a management PUT. */ +export function applyUsageLedgerRetentionToLiveConfig( + config: OcxConfig, + policy: UsageLedgerRetention, +): void { + (config as ConfigWithUsageLedgerRetention).usageLedgerRetention = { + enabled: policy.enabled, + maxBytes: policy.maxBytes, + }; +} + +/** Bounded status projection for API/UI; missing ledger is reported as zero bytes. */ +export function getUsageLedgerRetentionStatus(config?: OcxConfig): UsageLedgerRetentionStatus { + const policy = readUsageLedgerRetentionFromConfig(config); + let currentBytes = 0; + try { + currentBytes = statSync(usageLogPath()).size; + } catch { + currentBytes = 0; + } + return { + ...policy, + currentBytes, + overLimit: policy.enabled && currentBytes > policy.maxBytes, + }; +} From 5963d42ceca140dc74451f5e51f4eee1a8d1410c Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:14:48 +0800 Subject: [PATCH 04/61] feat(usage): move ledger compaction preparation to worker --- src/usage/ledger-retention-worker.ts | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/usage/ledger-retention-worker.ts diff --git a/src/usage/ledger-retention-worker.ts b/src/usage/ledger-retention-worker.ts new file mode 100644 index 0000000000..24d8b44d46 --- /dev/null +++ b/src/usage/ledger-retention-worker.ts @@ -0,0 +1,37 @@ +import { prepareUsageLedgerCompaction } from "./ledger-retention"; + +interface RunMessage { + type: "run"; + requestId: string; + path: string; + maxBytes: number; + env?: { OPENCODEX_HOME?: string }; +} + +function isRunMessage(data: unknown): data is RunMessage { + if (!data || typeof data !== "object" || Array.isArray(data)) return false; + const row = data as Record; + return row.type === "run" + && typeof row.requestId === "string" + && typeof row.path === "string" + && typeof row.maxBytes === "number"; +} + +declare const self: Worker; + +self.onmessage = (event: MessageEvent) => { + if (!isRunMessage(event.data)) return; + const { requestId, path, maxBytes, env } = event.data; + try { + if (env?.OPENCODEX_HOME) process.env.OPENCODEX_HOME = env.OPENCODEX_HOME; + const result = prepareUsageLedgerCompaction(path, maxBytes); + self.postMessage({ type: "done", requestId, result }); + } catch { + // Keep worker errors fixed and path-free: OPENCODEX_HOME may contain user information. + self.postMessage({ type: "error", requestId, message: "usage_ledger_retention_failed" }); + } finally { + try { + (self as unknown as { close?: () => void }).close?.(); + } catch { /* already closing */ } + } +}; From 3a7e126890632ba204cfd4d1d3ba339cfe44c9b1 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:15:39 +0800 Subject: [PATCH 05/61] feat(usage): add background ledger retention job --- src/usage/ledger-retention-job.ts | 331 ++++++++++++++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 src/usage/ledger-retention-job.ts diff --git a/src/usage/ledger-retention-job.ts b/src/usage/ledger-retention-job.ts new file mode 100644 index 0000000000..7daed9765c --- /dev/null +++ b/src/usage/ledger-retention-job.ts @@ -0,0 +1,331 @@ +import { chmodSync, statSync, renameSync, unlinkSync } from "node:fs"; +import { closeRequestHistoryIndex } from "../routing/history/indexer"; +import { getActiveTurnCount } from "../server/lifecycle"; +import { + StorageWorkerAdmissionBusyError, + terminateStorageWorker, + tryReserveStorageWorker, + withStorageWorkerSpawnGate, +} from "../storage/worker-lifecycle"; +import { usageLogPath } from "./log"; +import { + usageLedgerRevisionFromStat, + usageLedgerRevisionMatches, + type PreparedUsageLedgerCompaction, + type UsageLedgerCompactionPreparation, +} from "./ledger-retention"; +import { readUsageLedgerRetentionFromConfig } from "./ledger-retention-config"; + +export type UsageLedgerRetentionDeferredReason = "active_turns" | "source_changed"; + +export interface UsageLedgerRetentionJobOutcome { + ok: boolean; + skipped?: "disabled" | "missing" | "within_limit"; + deferred?: UsageLedgerRetentionDeferredReason; + error?: "worker_busy" | "worker_failed" | "commit_failed"; + beforeBytes?: number; + afterBytes?: number; + droppedBytes?: number; +} + +export interface UsageLedgerRetentionJobState { + status: "idle" | "running"; + startedAt?: number; + finishedAt?: number; + lastError?: string; + lastOutcome?: UsageLedgerRetentionJobOutcome; +} + +export interface UsageLedgerRetentionCommitDeps { + activeTurnCount?: () => number; + closeHistoryIndex?: () => void; + stat?: typeof statSync; + rename?: typeof renameSync; + chmod?: typeof chmodSync; + unlink?: typeof unlinkSync; +} + +let state: UsageLedgerRetentionJobState = { status: "idle" }; +let inflight: Promise | null = null; +let activeWorker: Worker | null = null; +let cancelActiveRun: (() => void) | null = null; +let runGeneration = 0; +let lastWarningAt = 0; +const WARNING_INTERVAL_MS = 60_000; +const WORKER_TIMEOUT_MS = 10 * 60 * 1000; + +function discardCandidate(path: string, unlink: typeof unlinkSync = unlinkSync): void { + try { unlink(path); } catch { /* already absent / best effort */ } +} + +function warnRetentionFailure(): void { + const now = Date.now(); + if (now - lastWarningAt < WARNING_INTERVAL_MS) return; + lastWarningAt = now; + console.warn("[usage] usage ledger retention failed; it will be retried later"); +} + +/** + * Commit a Worker-prepared candidate only while no data-plane turn is active and + * only if the canonical ledger is byte-for-byte the same filesystem revision the + * Worker inspected. This function is intentionally synchronous: after the idle + * and revision checks, no request callback can interleave before the rename. + */ +export function commitPreparedUsageLedgerCompaction( + prepared: PreparedUsageLedgerCompaction, + deps: UsageLedgerRetentionCommitDeps = {}, +): UsageLedgerRetentionJobOutcome { + const activeTurnCount = deps.activeTurnCount ?? getActiveTurnCount; + const closeHistoryIndex = deps.closeHistoryIndex ?? closeRequestHistoryIndex; + const stat = deps.stat ?? statSync; + const rename = deps.rename ?? renameSync; + const chmod = deps.chmod ?? chmodSync; + const unlink = deps.unlink ?? unlinkSync; + + if (activeTurnCount() !== 0) { + discardCandidate(prepared.tempPath, unlink); + return { + ok: true, + deferred: "active_turns", + beforeBytes: prepared.beforeBytes, + afterBytes: prepared.beforeBytes, + droppedBytes: 0, + }; + } + + let currentRevision; + try { + currentRevision = usageLedgerRevisionFromStat(stat(prepared.path)); + } catch { + discardCandidate(prepared.tempPath, unlink); + return { + ok: true, + deferred: "source_changed", + beforeBytes: prepared.beforeBytes, + afterBytes: prepared.beforeBytes, + droppedBytes: 0, + }; + } + + if (!usageLedgerRevisionMatches(prepared.sourceRevision, currentRevision)) { + discardCandidate(prepared.tempPath, unlink); + return { + ok: true, + deferred: "source_changed", + beforeBytes: prepared.beforeBytes, + afterBytes: currentRevision.size, + droppedBytes: 0, + }; + } + + try { + // The index is a disposable projection of usage.jsonl. Drop its live handle + // before replacing the canonical source; the next query reopens/rebuilds it. + closeHistoryIndex(); + rename(prepared.tempPath, prepared.path); + try { chmod(prepared.path, 0o600); } catch { /* platform may ignore chmod */ } + return { + ok: true, + beforeBytes: prepared.beforeBytes, + afterBytes: prepared.afterBytes, + droppedBytes: prepared.droppedBytes, + }; + } catch { + discardCandidate(prepared.tempPath, unlink); + return { + ok: false, + error: "commit_failed", + beforeBytes: prepared.beforeBytes, + afterBytes: prepared.beforeBytes, + droppedBytes: 0, + }; + } +} + +export function getUsageLedgerRetentionJobState(): UsageLedgerRetentionJobState { + return { + ...state, + ...(state.lastOutcome ? { lastOutcome: { ...state.lastOutcome } } : {}), + }; +} + +function runInWorker(path: string, maxBytes: number): Promise { + const reservation = tryReserveStorageWorker(); + if (!reservation) return Promise.reject(new StorageWorkerAdmissionBusyError()); + + return withStorageWorkerSpawnGate(() => new Promise((resolve, reject) => { + const requestId = crypto.randomUUID(); + let settled = false; + let worker: Worker; + try { + worker = new Worker(new URL("./ledger-retention-worker.ts", import.meta.url).href); + reservation.bind(worker); + } catch (error) { + reservation.release(); + reject(error); + return; + } + activeWorker = worker; + + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + cancelActiveRun = null; + clearTimeout(timer); + if (activeWorker === worker) activeWorker = null; + void terminateStorageWorker(worker).then(fn, fn); + }; + + const timer = setTimeout(() => { + finish(() => reject(new Error("usage_ledger_retention_worker_timeout"))); + }, WORKER_TIMEOUT_MS); + + cancelActiveRun = () => { + finish(() => reject(new Error("aborted"))); + }; + + worker.onmessage = (event: MessageEvent) => { + const data = event.data; + if (!data || typeof data !== "object" || Array.isArray(data)) return; + const message = data as Record; + if (message.requestId !== requestId) return; + if (message.type === "done" && message.result && typeof message.result === "object") { + finish(() => resolve(message.result as UsageLedgerCompactionPreparation)); + return; + } + if (message.type === "error") { + finish(() => reject(new Error("usage_ledger_retention_worker_failed"))); + } + }; + worker.onerror = () => { + finish(() => reject(new Error("usage_ledger_retention_worker_failed"))); + }; + + worker.postMessage({ + type: "run", + requestId, + path, + maxBytes, + env: { + ...(process.env.OPENCODEX_HOME ? { OPENCODEX_HOME: process.env.OPENCODEX_HOME } : {}), + }, + }); + })).catch(error => { + reservation.release(); + throw error; + }); +} + +async function executeJob(generation: number): Promise { + const policy = readUsageLedgerRetentionFromConfig(); + if (!policy.enabled) { + if (generation === runGeneration) { + state = { + status: "idle", + startedAt: state.startedAt, + finishedAt: Date.now(), + lastOutcome: { ok: true, skipped: "disabled" }, + }; + } + return; + } + + try { + const prepared = await runInWorker(usageLogPath(), policy.maxBytes); + if (generation !== runGeneration) { + if (prepared.changed) discardCandidate(prepared.tempPath); + return; + } + const outcome: UsageLedgerRetentionJobOutcome = prepared.changed + ? commitPreparedUsageLedgerCompaction(prepared) + : { + ok: true, + skipped: prepared.reason, + beforeBytes: prepared.beforeBytes, + afterBytes: prepared.afterBytes, + droppedBytes: 0, + }; + if (!outcome.ok) warnRetentionFailure(); + state = { + status: "idle", + startedAt: state.startedAt, + finishedAt: Date.now(), + ...(outcome.ok ? {} : { lastError: outcome.error }), + lastOutcome: outcome, + }; + } catch (error) { + if (generation !== runGeneration) return; + const workerBusy = error instanceof StorageWorkerAdmissionBusyError; + const outcome: UsageLedgerRetentionJobOutcome = { + ok: false, + error: workerBusy ? "worker_busy" : "worker_failed", + }; + warnRetentionFailure(); + state = { + status: "idle", + startedAt: state.startedAt, + finishedAt: Date.now(), + lastError: outcome.error, + lastOutcome: outcome, + }; + } +} + +/** Start one asynchronous retention evaluation. */ +export function requestUsageLedgerRetentionRun(): + | { accepted: true; state: UsageLedgerRetentionJobState } + | { accepted: false; error: "already_running"; state: UsageLedgerRetentionJobState } { + if (inflight || state.status === "running") { + return { accepted: false, error: "already_running", state: getUsageLedgerRetentionJobState() }; + } + const generation = ++runGeneration; + state = { + status: "running", + startedAt: Date.now(), + ...(state.lastOutcome ? { lastOutcome: state.lastOutcome } : {}), + }; + const job = executeJob(generation); + inflight = job; + void job.finally(() => { + if (inflight === job) inflight = null; + }); + return { accepted: true, state: getUsageLedgerRetentionJobState() }; +} + +/** Cheap scheduler entry: disabled policies never reserve a Worker. */ +export function maybeRequestUsageLedgerRetentionRun(): void { + try { + if (!readUsageLedgerRetentionFromConfig().enabled) return; + requestUsageLedgerRetentionRun(); + } catch { + warnRetentionFailure(); + } +} + +/** Join an active retention Worker during final server teardown. */ +export async function abortUsageLedgerRetentionJobAsync(): Promise { + runGeneration += 1; + const cancel = cancelActiveRun; + cancelActiveRun = null; + cancel?.(); + const worker = activeWorker; + activeWorker = null; + inflight = null; + if (state.status === "running") { + state = { + status: "idle", + startedAt: state.startedAt, + finishedAt: Date.now(), + lastError: "aborted", + lastOutcome: { ok: false, error: "worker_failed" }, + }; + } + if (worker) await terminateStorageWorker(worker); +} + +/** Test reset for the module-local controller state. */ +export async function resetUsageLedgerRetentionJobForTests(): Promise { + await abortUsageLedgerRetentionJobAsync(); + state = { status: "idle" }; + lastWarningAt = 0; +} From 94cfeb341ba63c21bb9d6a7a4d14619089a62a86 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:15:57 +0800 Subject: [PATCH 06/61] feat(usage): schedule background ledger retention checks --- src/usage/ledger-retention-scheduler.ts | 44 +++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/usage/ledger-retention-scheduler.ts diff --git a/src/usage/ledger-retention-scheduler.ts b/src/usage/ledger-retention-scheduler.ts new file mode 100644 index 0000000000..b6b4ba70e8 --- /dev/null +++ b/src/usage/ledger-retention-scheduler.ts @@ -0,0 +1,44 @@ +import { getUsageLedgerRetentionStatus } from "./ledger-retention-config"; +import { requestUsageLedgerRetentionRun } from "./ledger-retention-job"; + +const DEFAULT_INTERVAL_MS = 60_000; +let timer: ReturnType | null = null; +let startupTimer: ReturnType | null = null; + +function requestIfOverLimit(): void { + try { + const status = getUsageLedgerRetentionStatus(); + if (!status.enabled || !status.overLimit) return; + requestUsageLedgerRetentionRun(); + } catch { + // A later tick retries; scheduler failures never block the proxy. + } +} + +/** Poll only metadata on the main thread; file scanning/copying stays in the Worker job. */ +export function startUsageLedgerRetentionScheduler(intervalMs = DEFAULT_INTERVAL_MS): void { + if (timer) return; + timer = setInterval(requestIfOverLimit, intervalMs); + timer.unref?.(); +} + +/** Evaluate once after listeners bind so oversized ledgers are handled after startup. */ +export function scheduleUsageLedgerRetentionStartupRun(): void { + if (startupTimer) return; + startupTimer = setTimeout(() => { + startupTimer = null; + requestIfOverLimit(); + }, 0); + startupTimer.unref?.(); +} + +export function stopUsageLedgerRetentionScheduler(): void { + if (timer) { + clearInterval(timer); + timer = null; + } + if (startupTimer) { + clearTimeout(startupTimer); + startupTimer = null; + } +} From fb3f7bb0b2de7478e988e8c28bcb8c4ca27dd837 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:16:25 +0800 Subject: [PATCH 07/61] feat(usage): wire retention into server lifecycle --- src/server/background-lifecycle.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index 17a7a7fe57..908d9932aa 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -10,6 +10,12 @@ import { startStorageCleanupScheduler, stopStorageCleanupScheduler, } from "../storage/policy-scheduler"; +import { abortUsageLedgerRetentionJobAsync } from "../usage/ledger-retention-job"; +import { + scheduleUsageLedgerRetentionStartupRun, + startUsageLedgerRetentionScheduler, + stopUsageLedgerRetentionScheduler, +} from "../usage/ledger-retention-scheduler"; import { startQuotaResetPoller, stopQuotaResetPoller } from "../quota/reset-poller"; import { cancelQueuedStorageWorkerSpawns, @@ -60,6 +66,7 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { stateStoreSweeper = startStateStoreSweeper(); setLivePolicyOwner(applyPolicy); startStorageCleanupScheduler(); + startUsageLedgerRetentionScheduler(); // Opt-in: the tick itself is a no-op unless config.quotaResetNotify is enabled with a // sink, and the interval is unref'd, so a default install pays one dormant timer. startQuotaResetPoller(); @@ -84,6 +91,7 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { memoryWatchdog?.stop(); stateStoreSweeper?.stop(); stopStorageCleanupScheduler(); + stopUsageLedgerRetentionScheduler(); stopQuotaResetPoller(); setLivePolicyOwner(null); throw error; @@ -96,19 +104,22 @@ function stopProcessLoops(): void { loops?.memoryWatchdog.stop(); loops?.stateStoreSweeper.stop(); stopStorageCleanupScheduler(); + stopUsageLedgerRetentionScheduler(); stopQuotaResetPoller(); setLivePolicyOwner(null); } async function stopStoragePolicyWorker(): Promise { cancelQueuedStorageWorkerSpawns(); - const abortResult = await Promise.allSettled([abortStorageCleanupPolicyJobAsync()]); - if (abortResult[0]?.status === "rejected") { + const abortResult = await Promise.allSettled([ + abortStorageCleanupPolicyJobAsync(), + abortUsageLedgerRetentionJobAsync(), + ]); + for (const result of abortResult) { + if (result.status !== "rejected") continue; console.warn( - "[storage] policy worker abort during server stop failed:", - abortResult[0].reason instanceof Error - ? abortResult[0].reason.message - : abortResult[0].reason, + "[storage] worker abort during server stop failed:", + result.reason instanceof Error ? result.reason.message : result.reason, ); } try { @@ -177,6 +188,7 @@ export function acquireServerBackgroundLifecycle( scheduleStartupRun() { if (owners.some(candidate => candidate.token === owner.token)) { scheduleStorageCleanupStartupRun(); + scheduleUsageLedgerRetentionStartupRun(); } }, release() { From b8ecf64de5647fdfaad6cf85ffa9e2147d7b7e4c Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:16:59 +0800 Subject: [PATCH 08/61] feat(storage): expose usage ledger retention controls --- .../management/storage-log-guard-routes.ts | 74 ++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/src/server/management/storage-log-guard-routes.ts b/src/server/management/storage-log-guard-routes.ts index 324e746bd7..83bc4364cc 100644 --- a/src/server/management/storage-log-guard-routes.ts +++ b/src/server/management/storage-log-guard-routes.ts @@ -12,6 +12,16 @@ import { type CodexLogGuardStatus, } from "../../codex/log-guard/protection"; import { scanStorage } from "../../storage/scanner"; +import { + applyUsageLedgerRetentionToLiveConfig, + getUsageLedgerRetentionStatus, + parseUsageLedgerRetentionInput, + writeUsageLedgerRetentionToConfig, +} from "../../usage/ledger-retention-config"; +import { + getUsageLedgerRetentionJobState, + requestUsageLedgerRetentionRun, +} from "../../usage/ledger-retention-job"; import { jsonResponse } from "../auth-cors"; import { managementBodyTooLargeResponse, @@ -104,11 +114,73 @@ async function readProtectMode(ctx: ManagementContext): Promise<"compat" | "quie return mode; } -/** Codex Log Guard diagnostics plus explicit protection and maintenance mutations. */ +/** Storage diagnostics plus explicit protection, retention, and maintenance mutations. */ export async function handleStorageLogGuardRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps } = ctx; const protectionDeps = deps.codexLogGuardProtectionDeps; + if (url.pathname === "/api/storage/usage-ledger-retention") { + if (req.method === "GET") { + return jsonResponse({ + ...getUsageLedgerRetentionStatus(config), + job: getUsageLedgerRetentionJobState(), + }, 200, req, config); + } + if (req.method === "PUT") { + 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); + } + const previous = getUsageLedgerRetentionStatus(config); + const parsed = parseUsageLedgerRetentionInput(body, previous); + if (!parsed.ok) return jsonResponse({ error: parsed.error }, 400, req, config); + try { + const saved = writeUsageLedgerRetentionToConfig(parsed.policy); + applyUsageLedgerRetentionToLiveConfig(config, saved); + const run = saved.enabled ? requestUsageLedgerRetentionRun() : null; + return jsonResponse({ + ok: true, + ...getUsageLedgerRetentionStatus(config), + job: run?.state ?? getUsageLedgerRetentionJobState(), + }, 200, req, config); + } catch { + return jsonResponse({ error: "config_write_failed" }, 500, req, config); + } + } + return null; + } + + if (url.pathname === "/api/storage/usage-ledger-retention/run" && req.method === "POST") { + const status = getUsageLedgerRetentionStatus(config); + if (!status.enabled) { + return jsonResponse({ + ok: false, + error: "retention_disabled", + ...status, + job: getUsageLedgerRetentionJobState(), + }, 409, req, config); + } + const run = requestUsageLedgerRetentionRun(); + if (!run.accepted) { + return jsonResponse({ + ok: false, + error: "already_running", + ...status, + job: run.state, + }, 409, req, config); + } + return jsonResponse({ + ok: true, + started: true, + ...status, + job: run.state, + }, 202, req, config); + } + if (url.pathname === "/api/storage/codex-logs") { if (req.method !== "GET") return null; try { From 590a80a2e39d6f5ddcec870aeda70d79d1536e72 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:18:13 +0800 Subject: [PATCH 09/61] feat(storage): add usage history size limit panel --- .../UsageLedgerRetentionPanel.tsx | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx diff --git a/gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx b/gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx new file mode 100644 index 0000000000..8fe1b8b3af --- /dev/null +++ b/gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx @@ -0,0 +1,238 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { formatBytes } from "../../format-bytes"; +import type { Locale } from "../../i18n/shared"; + +const MIB = 1024 ** 2; +const PRESETS_MIB = [128, 512, 1024, 2048] as const; + +type LabelKey = + | "title" + | "help" + | "enabled" + | "current" + | "limit" + | "save" + | "apply" + | "saving" + | "running" + | "saved" + | "disabled" + | "error"; + +const EN: Record = { + title: "Usage history size limit", + help: "When enabled, OpenCodex keeps the newest complete usage records and permanently removes older rows after the ledger exceeds this limit.", + enabled: "Limit usage history size", + current: "Current size", + limit: "Maximum size", + save: "Save", + apply: "Apply now", + saving: "Saving…", + running: "Applying…", + saved: "Saved", + disabled: "Disabled", + error: "Could not update the usage history limit.", +}; + +const ZH: Record = { + title: "Usage 历史大小限制", + help: "启用后,OpenCodex 会保留最新的完整 usage 记录,并在日志超过上限后永久删除较旧记录。", + enabled: "限制 Usage 历史大小", + current: "当前大小", + limit: "最大大小", + save: "保存", + apply: "立即应用", + saving: "正在保存…", + running: "正在应用…", + saved: "已保存", + disabled: "已关闭", + error: "无法更新 Usage 历史大小限制。", +}; + +function label(locale: Locale, key: LabelKey): string { + return (locale === "zh" || locale === "zh-TW") ? ZH[key] : EN[key]; +} + +interface RetentionJobState { + status: "idle" | "running"; + lastOutcome?: { + ok: boolean; + skipped?: string; + deferred?: string; + error?: string; + beforeBytes?: number; + afterBytes?: number; + droppedBytes?: number; + }; +} + +interface RetentionStatus { + enabled: boolean; + maxBytes: number; + currentBytes: number; + overLimit: boolean; + job: RetentionJobState; +} + +export default function UsageLedgerRetentionPanel({ + apiBase, + locale, +}: { + apiBase: string; + locale: Locale; +}) { + const [status, setStatus] = useState(null); + const [enabled, setEnabled] = useState(false); + const [limitMiB, setLimitMiB] = useState(512); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const load = useCallback(async (signal?: AbortSignal) => { + const response = await fetch(`${apiBase}/api/storage/usage-ledger-retention`, { signal }); + if (!response.ok) throw new Error("load_failed"); + const next = await response.json() as RetentionStatus; + setStatus(next); + setEnabled(next.enabled); + setLimitMiB(Math.max(1, Math.round(next.maxBytes / MIB))); + return next; + }, [apiBase]); + + useEffect(() => { + const controller = new AbortController(); + void load(controller.signal).catch(errorValue => { + if ((errorValue as { name?: string })?.name !== "AbortError") { + setError(label(locale, "error")); + } + }); + return () => controller.abort(); + }, [load, locale]); + + useEffect(() => { + if (status?.job.status !== "running") return; + const timer = window.setInterval(() => { + void load().catch(() => undefined); + }, 750); + return () => window.clearInterval(timer); + }, [load, status?.job.status]); + + const normalizedLimitMiB = useMemo( + () => Math.max(1, Math.floor(Number.isFinite(limitMiB) ? limitMiB : 1)), + [limitMiB], + ); + + const save = async () => { + setBusy(true); + setError(null); + setMessage(null); + try { + const response = await fetch(`${apiBase}/api/storage/usage-ledger-retention`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + enabled, + maxBytes: normalizedLimitMiB * MIB, + }), + }); + if (!response.ok) throw new Error("save_failed"); + const next = await response.json() as RetentionStatus; + setStatus(next); + setEnabled(next.enabled); + setLimitMiB(Math.max(1, Math.round(next.maxBytes / MIB))); + setMessage(label(locale, "saved")); + } catch { + setError(label(locale, "error")); + } finally { + setBusy(false); + } + }; + + const applyNow = async () => { + setBusy(true); + setError(null); + setMessage(null); + try { + const response = await fetch(`${apiBase}/api/storage/usage-ledger-retention/run`, { + method: "POST", + }); + if (!response.ok && response.status !== 409) throw new Error("run_failed"); + await load(); + } catch { + setError(label(locale, "error")); + } finally { + setBusy(false); + } + }; + + const jobRunning = status?.job.status === "running"; + + return ( +
+

{label(locale, "title")}

+

{label(locale, "help")}

+ +
+ {label(locale, "current")} + + {status ? formatBytes(status.currentBytes, locale) : "—"} + +
+ + + +
+ {label(locale, "limit")} + + setLimitMiB(Number(event.target.value))} + aria-label={label(locale, "limit")} + style={{ width: 96 }} + /> + MiB + +
+ +
+ {PRESETS_MIB.map(value => ( + + ))} + + +
+ + {status && !status.enabled &&

{label(locale, "disabled")}

} + {message &&

{message}

} + {error &&

{error}

} +
+ ); +} From e13fa24fe08f162e808251177f9cef08b3076d6f Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:20:01 +0800 Subject: [PATCH 10/61] feat(storage): surface usage retention in storage workspace --- gui/src/components/storage-workspace/StorageWorkspace.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gui/src/components/storage-workspace/StorageWorkspace.tsx b/gui/src/components/storage-workspace/StorageWorkspace.tsx index 311a3faa73..0deaf53e92 100644 --- a/gui/src/components/storage-workspace/StorageWorkspace.tsx +++ b/gui/src/components/storage-workspace/StorageWorkspace.tsx @@ -16,6 +16,7 @@ import { logGuardSchemaStateLabel, } from "../../i18n/log-guard-state-labels"; import { formatBytes } from "../../format-bytes"; +import UsageLedgerRetentionPanel from "./UsageLedgerRetentionPanel"; export interface StorageLargestEntry { path: string; @@ -626,6 +627,8 @@ export default function StorageWorkspace({ + + {displayedLogGuard ? ( Date: Tue, 8 Sep 2026 23:21:01 +0800 Subject: [PATCH 11/61] test(usage): cover retention config and commit races --- tests/usage-ledger-retention-v2.test.ts | 81 ++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/tests/usage-ledger-retention-v2.test.ts b/tests/usage-ledger-retention-v2.test.ts index 7e4a06b237..640e5f88c8 100644 --- a/tests/usage-ledger-retention-v2.test.ts +++ b/tests/usage-ledger-retention-v2.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -9,6 +9,8 @@ import { prepareUsageLedgerCompaction, usageLedgerRevisionMatches, } from "../src/usage/ledger-retention"; +import { parseUsageLedgerRetentionInput } from "../src/usage/ledger-retention-config"; +import { commitPreparedUsageLedgerCompaction } from "../src/usage/ledger-retention-job"; const homes: string[] = []; @@ -23,13 +25,31 @@ afterEach(() => { }); describe("usage ledger retention v2", () => { - test("unknown config keys disable destructive retention", () => { + test("unknown persisted config keys disable destructive retention", () => { expect(normalizeUsageLedgerRetention({ enabled: true, maxByets: 8 * 1024 * 1024 })).toEqual({ enabled: false, maxBytes: DEFAULT_USAGE_LEDGER_MAX_BYTES, }); }); + test("live writes reject unknown config keys instead of silently stripping them", () => { + const parsed = parseUsageLedgerRetentionInput( + { enabled: true, maxByets: 8 * 1024 * 1024 }, + { enabled: false, maxBytes: DEFAULT_USAGE_LEDGER_MAX_BYTES }, + ); + expect(parsed.ok).toBe(false); + if (parsed.ok) throw new Error("expected strict parser failure"); + expect(parsed.error).toContain("maxByets"); + }); + + test("partial live writes preserve the previous enabled state", () => { + const maxBytes = 8 * 1024 * 1024; + expect(parseUsageLedgerRetentionInput( + { maxBytes }, + { enabled: true, maxBytes: DEFAULT_USAGE_LEDGER_MAX_BYTES }, + )).toEqual({ ok: true, policy: { enabled: true, maxBytes } }); + }); + test("unsafe or below-floor byte limits disable destructive retention", () => { for (const maxBytes of [Number.MAX_SAFE_INTEGER + 1, MIN_USAGE_LEDGER_MAX_BYTES - 1, 1.5 * MIN_USAGE_LEDGER_MAX_BYTES]) { expect(normalizeUsageLedgerRetention({ enabled: true, maxBytes }).enabled).toBe(false); @@ -90,4 +110,61 @@ describe("usage ledger retention v2", () => { expect(usageLedgerRevisionMatches(revision, revision)).toBe(true); expect(usageLedgerRevisionMatches(revision, { ...revision, size: 4 })).toBe(false); }); + + test("defers commit while a request turn is active and discards the candidate", () => { + const dir = home(); + const path = join(dir, "usage.jsonl"); + const old = `${JSON.stringify({ requestId: "old", filler: "a".repeat(MIN_USAGE_LEDGER_MAX_BYTES) })}\n`; + const latest = `${JSON.stringify({ requestId: "new" })}\n`; + writeFileSync(path, old + latest); + const prepared = prepareUsageLedgerCompaction(path, MIN_USAGE_LEDGER_MAX_BYTES); + if (!prepared.changed) throw new Error("expected compaction"); + + const result = commitPreparedUsageLedgerCompaction(prepared, { activeTurnCount: () => 1 }); + expect(result.deferred).toBe("active_turns"); + expect(existsSync(prepared.tempPath)).toBe(false); + expect(readFileSync(path, "utf8")).toBe(old + latest); + }); + + test("does not overwrite an append that landed after Worker preparation", () => { + const dir = home(); + const path = join(dir, "usage.jsonl"); + const old = `${JSON.stringify({ requestId: "old", filler: "a".repeat(MIN_USAGE_LEDGER_MAX_BYTES) })}\n`; + const latest = `${JSON.stringify({ requestId: "new" })}\n`; + writeFileSync(path, old + latest); + const prepared = prepareUsageLedgerCompaction(path, MIN_USAGE_LEDGER_MAX_BYTES); + if (!prepared.changed) throw new Error("expected compaction"); + + const appended = `${JSON.stringify({ requestId: "after-prepare" })}\n`; + appendFileSync(path, appended); + const result = commitPreparedUsageLedgerCompaction(prepared, { activeTurnCount: () => 0 }); + expect(result.deferred).toBe("source_changed"); + expect(existsSync(prepared.tempPath)).toBe(false); + expect(readFileSync(path, "utf8")).toBe(old + latest + appended); + }); + + test("closes the derived history index before replacing an unchanged ledger", () => { + const dir = home(); + const path = join(dir, "usage.jsonl"); + const old = `${JSON.stringify({ requestId: "old", filler: "a".repeat(MIN_USAGE_LEDGER_MAX_BYTES) })}\n`; + const latest = `${JSON.stringify({ requestId: "new" })}\n`; + writeFileSync(path, old + latest); + const prepared = prepareUsageLedgerCompaction(path, MIN_USAGE_LEDGER_MAX_BYTES); + if (!prepared.changed) throw new Error("expected compaction"); + const expected = readFileSync(prepared.tempPath, "utf8"); + let closed = false; + + const result = commitPreparedUsageLedgerCompaction(prepared, { + activeTurnCount: () => 0, + closeHistoryIndex: () => { closed = true; }, + rename: (from, to) => { + expect(closed).toBe(true); + const { renameSync } = require("node:fs") as typeof import("node:fs"); + renameSync(from, to); + }, + }); + expect(result.ok).toBe(true); + expect(result.droppedBytes).toBeGreaterThan(0); + expect(readFileSync(path, "utf8")).toBe(expected); + }); }); From 51748758bf89c3c7beb671ec57e41539feb73a2d Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:21:38 +0800 Subject: [PATCH 12/61] fix(usage): preserve configured ceiling while retention is off --- src/usage/ledger-retention.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/usage/ledger-retention.ts b/src/usage/ledger-retention.ts index a811c9b16f..4dbe8e2186 100644 --- a/src/usage/ledger-retention.ts +++ b/src/usage/ledger-retention.ts @@ -62,7 +62,8 @@ export type UsageLedgerCompactionPreparation = * * Unknown keys disable the feature rather than being silently stripped: a typo * such as `maxByets` must never turn an intended large limit into the default. - * Invalid/unsafe byte values likewise disable the feature. + * Invalid/unsafe byte values likewise disable the feature. A valid maxBytes is + * retained while disabled so toggling the feature off does not erase user choice. */ export function normalizeUsageLedgerRetention(raw: unknown): UsageLedgerRetention { const disabled = { enabled: false, maxBytes: DEFAULT_USAGE_LEDGER_MAX_BYTES } as const; @@ -73,7 +74,6 @@ export function normalizeUsageLedgerRetention(raw: unknown): UsageLedgerRetentio const allowed = new Set(["enabled", "maxBytes"]); if (Object.keys(row).some(key => !allowed.has(key))) return disabled; if (row.enabled !== undefined && typeof row.enabled !== "boolean") return disabled; - if (row.enabled !== true) return disabled; const maxBytes = row.maxBytes ?? DEFAULT_USAGE_LEDGER_MAX_BYTES; if ( @@ -83,7 +83,7 @@ export function normalizeUsageLedgerRetention(raw: unknown): UsageLedgerRetentio ) { return disabled; } - return { enabled: true, maxBytes }; + return { enabled: row.enabled === true, maxBytes }; } /** Snapshot the identity fields used to prove the source did not change. */ From b94e2f825ac8dcf46243378bff15c5db92df19a5 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:25:12 +0800 Subject: [PATCH 13/61] feat(storage): declare usage retention management routes --- src/server/management/route-registry.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 6c7d57547b..84d7e923ec 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -312,10 +312,13 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "POST", path: "/api/github/star", module: "server/management/sidebar-routes", mutates: true, exempt: { reason: "session-only", why: "User-consent boundary in AGENTS_INSTALL.md: starring spends the user's identity. Must never gain a CLI verb." } }, // server/management/storage-log-guard-routes { method: "GET", path: "/api/storage/codex-logs", module: "server/management/storage-log-guard-routes", mutates: false }, + { method: "GET", path: "/api/storage/usage-ledger-retention", module: "server/management/storage-log-guard-routes", mutates: false }, { method: "POST", path: "/api/storage/codex-logs/compact", module: "server/management/storage-log-guard-routes", mutates: true }, { method: "POST", path: "/api/storage/codex-logs/protect", module: "server/management/storage-log-guard-routes", mutates: true }, { method: "POST", path: "/api/storage/codex-logs/repair", module: "server/management/storage-log-guard-routes", mutates: true }, { method: "POST", path: "/api/storage/codex-logs/unprotect", module: "server/management/storage-log-guard-routes", mutates: true }, + { method: "POST", path: "/api/storage/usage-ledger-retention/run", module: "server/management/storage-log-guard-routes", mutates: true }, + { method: "PUT", path: "/api/storage/usage-ledger-retention", module: "server/management/storage-log-guard-routes", mutates: true }, // server/management/system-routes { method: "GET", path: "/api/system/health", module: "server/management/system-routes", mutates: false }, { method: "GET", path: "/api/system/memory", module: "server/management/system-routes", mutates: false }, @@ -342,4 +345,4 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/lab/events/{id}", module: "server/management/lab-routes", mutates: false, mechanism: "regex", exempt: { reason: "local-transport", why: "ocx lab reads the same rows from the local SQLite projection; src/cli/lab.ts imports ../lab/query directly and never fetches /api/lab." } }, { method: "GET", path: "/api/lab/artifacts/{digest}", module: "server/management/lab-routes", mutates: false, mechanism: "regex", exempt: { reason: "local-transport", why: "ocx lab reads the same rows from the local SQLite projection; src/cli/lab.ts imports ../lab/query directly and never fetches /api/lab." } }, { method: "POST", path: "/api/lab/automation/runs/{id}/cancel", module: "server/management/lab-automation-routes", mutates: true, mechanism: "regex", exempt: { reason: "deferred-verb", why: "Lab automation run cancellation has no CLI verb yet. A local SQLite read cannot drive it, so local-transport does not apply.", owner: "wp7", ownerDoc: "devlog/_plan/260828_ocx_agentic_control/060_phase_gui_parity.md" } }, -]; +]; \ No newline at end of file From 96d52d4b09a9db0875c06e9c88a5b654058562b9 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:29:57 +0800 Subject: [PATCH 14/61] feat(storage): add usage history limit CLI --- src/cli/storage.ts | 102 +++++++++++++++++++++++++++++---------------- 1 file changed, 66 insertions(+), 36 deletions(-) diff --git a/src/cli/storage.ts b/src/cli/storage.ts index ed13aa6710..dd9bf93c49 100644 --- a/src/cli/storage.ts +++ b/src/cli/storage.ts @@ -1,19 +1,9 @@ /** - * `ocx storage` — the archived-session cleanup, trash, and cleanup-policy surface (wp7). + * `ocx storage` — the archived-session cleanup, trash, cleanup-policy, and usage-ledger surface. * - * Every route here existed with no CLI caller, so reclaiming disk space was dashboard-only. - * Three of them delete or move operator data, and the rules for those are deliberate: - * - * 1. **Default to preview.** `ocx storage cleanup --percent N` runs the preview route and prints - * what WOULD be freed, then exits 0 having mutated nothing. - * 2. **`--yes` is required to mutate.** There is no interactive prompt: an agent cannot answer - * one, and a prompt an agent can answer is not a safety boundary. - * 3. **`--json` on the preview emits the candidate list**, so an agent can decide from data - * rather than from a sentence. - * - * This is the opposite of the GitHub star POST, which no flag can authorize: cleanup spends the - * operator's DATA, which they can delegate, while starring spends their IDENTITY, which they - * cannot delegate to an agent. + * Destructive actions are explicit. Session cleanup defaults to preview, restores require + * confirmation, and a manual usage-ledger trim requires --yes because it permanently drops + * older request-history rows. */ import { CliUsageError, @@ -28,6 +18,8 @@ import { type RuntimeApiDeps, } from "./runtime-api"; +const MIB = 1024 * 1024; + const USAGE = `Usage: ocx storage report [--json] ocx storage cleanup --percent <0-100> [--mode ] [--yes] [--json] @@ -37,8 +29,11 @@ const USAGE = `Usage: ocx storage policy set [--enabled ] [--percent <0-100>] [--mode ] [--schedule ] [--json] ocx storage policy run [--yes] [--json] + ocx storage usage-limit [show] [--json] + ocx storage usage-limit set [--enabled ] [--mib ] [--json] + ocx storage usage-limit run [--yes] [--json] -Cleanup and restore MUTATE operator data and require --yes. +Cleanup, restore, and usage-limit run MUTATE operator data and require --yes where noted. Without --yes, cleanup prints the preview and changes nothing.`; /** The digest binds a run to the preview it was authorized against. */ @@ -52,7 +47,7 @@ interface CleanupPreview { function mib(bytes: number | undefined): string { if (typeof bytes !== "number" || !Number.isFinite(bytes)) return "unknown size"; - return `${(bytes / 1024 / 1024).toFixed(1)} MiB`; + return `${(bytes / MIB).toFixed(1)} MiB`; } function previewLines(preview: CleanupPreview): string[] { @@ -97,8 +92,6 @@ async function cleanup(argv: string[], deps: RuntimeApiDeps): Promise { } if (!preview.digest) { - // Refuse rather than send an empty digest: the server would reject it, but a clear local - // message beats a 400 that looks like a bug in the verb. throw new CliUsageError("the preview returned no digest, so the cleanup cannot be authorized", USAGE); } @@ -132,8 +125,6 @@ async function trash(argv: string[], deps: RuntimeApiDeps): Promise { rejectArgs(args, USAGE); if (!id) throw new CliUsageError("a trash entry id is required", USAGE); - // Restore moves files back and reconciles database rows, and can collide with an existing - // destination, so it is gated like cleanup rather than treated as a read. if (!confirmed) { throw new CliUsageError(`restoring ${id} modifies stored sessions; pass --yes to confirm`, USAGE); } @@ -173,25 +164,12 @@ async function policy(argv: string[], deps: RuntimeApiDeps): Promise { } const body: Record = {}; if (enabled !== undefined) body.enabled = enabled === "true"; - // The policy target is nested. A top-level `percent` is not part of the PUT contract: - // `normalizeStorageCleanupPolicy` reads only `target`, so the field was dropped and the - // previously stored target survived. `--percent 10` on a policy still holding the - // default 25% therefore reported success while leaving cleanup authorized to delete - // more than the operator asked for. - // - // An out-of-range value is deliberately still sent: the server owns the 1-100 - // vocabulary and answers with a named 400, which is a rejected write rather than the - // silent wrong write this replaces. if (percent !== undefined) body.target = { removeOldestPercent: percent }; if (mode !== undefined) body.mode = mode; if (schedule !== undefined) body.schedule = schedule; if (Object.keys(body).length === 0) { throw new CliUsageError("policy set needs at least one of --enabled, --percent, --mode, --schedule", USAGE); } - // Values are NOT re-validated here beyond --enabled's shape. The server owns the mode and - // schedule vocabularies and returns a named 400; duplicating them is a second thing to - // keep in sync. `enabled` is checked because "--enabled maybe" would otherwise be sent as - // `false`, which is a wrong write rather than a rejected one. const result = await runtimeRequest("/api/storage/cleanup-policy", { method: "PUT", headers: { "content-type": "application/json" }, @@ -207,7 +185,6 @@ async function policy(argv: string[], deps: RuntimeApiDeps): Promise { const wantsJson = takeFlag(args, "--json"); const confirmed = takeFlag(args, "--yes"); rejectArgs(args, USAGE); - // `force: true` server-side: this run ignores the schedule and deletes now. if (!confirmed) { throw new CliUsageError("policy run deletes archived sessions now; pass --yes to confirm", USAGE); } @@ -215,13 +192,65 @@ async function policy(argv: string[], deps: RuntimeApiDeps): Promise { printData(result, wantsJson, summaryLines(result)); } +async function usageLimit(argv: string[], deps: RuntimeApiDeps): Promise { + const action = argv[0] && !argv[0].startsWith("-") ? argv[0] : "show"; + const rest = argv[0] && !argv[0].startsWith("-") ? argv.slice(1) : argv; + + if (action === "show") { + const args = [...rest]; + const wantsJson = takeFlag(args, "--json"); + rejectArgs(args, USAGE); + const result = await runtimeRequest("/api/storage/usage-ledger-retention", {}, deps); + printData(result, wantsJson, summaryLines(result)); + return; + } + + if (action === "set") { + const args = [...rest]; + const wantsJson = takeFlag(args, "--json"); + const enabled = takeOption(args, "--enabled"); + const maxMiB = takeIntegerOption(args, "--mib", { min: 1 }); + rejectArgs(args, USAGE); + + if (enabled !== undefined && enabled !== "true" && enabled !== "false") { + throw new CliUsageError("--enabled must be true or false", USAGE); + } + if (maxMiB !== undefined && !Number.isSafeInteger(maxMiB * MIB)) { + throw new CliUsageError("--mib is too large", USAGE); + } + const body: Record = {}; + if (enabled !== undefined) body.enabled = enabled === "true"; + if (maxMiB !== undefined) body.maxBytes = maxMiB * MIB; + if (Object.keys(body).length === 0) { + throw new CliUsageError("usage-limit set needs at least one of --enabled or --mib", USAGE); + } + + const result = await runtimeRequest("/api/storage/usage-ledger-retention", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, deps); + printData(result, wantsJson, summaryLines(result)); + return; + } + + if (action !== "run") throw new CliUsageError(`unknown usage-limit action ${action}`, USAGE); + const args = [...rest]; + const wantsJson = takeFlag(args, "--json"); + const confirmed = takeFlag(args, "--yes"); + rejectArgs(args, USAGE); + if (!confirmed) { + throw new CliUsageError("usage-limit run permanently removes older usage history; pass --yes to confirm", USAGE); + } + const result = await runtimeRequest("/api/storage/usage-ledger-retention/run", { method: "POST" }, deps); + printData(result, wantsJson, summaryLines(result)); +} + export async function handleStorageCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { const hasSub = argv[0] !== undefined && !argv[0].startsWith("-"); const sub = hasSub ? argv[0]! : "report"; const rest = hasSub ? argv.slice(1) : argv; if (sub === "codex-logs") { - // Doctor and the Log Guard guides still document `ocx storage codex-logs …`. - // This module owns cleanup/trash/policy; log-guard stays on the observe handler. const { handleObserveCommand } = await import("./observe"); return handleObserveCommand(["storage", "codex-logs", ...rest], deps); } @@ -236,6 +265,7 @@ export async function handleStorageCommand(argv: string[], deps: RuntimeApiDeps else if (sub === "cleanup") await cleanup(rest, deps); else if (sub === "trash") await trash(rest, deps); else if (sub === "policy") await policy(rest, deps); + else if (sub === "usage-limit") await usageLimit(rest, deps); else throw new CliUsageError(`unknown storage command ${sub}`, USAGE); }); } From a492ecbde779b87b8933af4e1305b37baa979b6d Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:30:16 +0800 Subject: [PATCH 15/61] test(storage): cover usage history limit CLI --- tests/cli/cli-storage-usage-limit.test.ts | 113 ++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/cli/cli-storage-usage-limit.test.ts diff --git a/tests/cli/cli-storage-usage-limit.test.ts b/tests/cli/cli-storage-usage-limit.test.ts new file mode 100644 index 0000000000..9b55e310ee --- /dev/null +++ b/tests/cli/cli-storage-usage-limit.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test"; +import { handleStorageCommand } from "../../src/cli/storage"; + +interface Call { method: string; path: string; body: unknown } + +function harness(respond: (call: Call) => { status?: number; json: unknown }) { + const calls: Call[] = []; + const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { + const parsed = new URL(String(url)); + const call = { + method: init?.method ?? "GET", + path: parsed.pathname + parsed.search, + body: init?.body === undefined ? undefined : JSON.parse(String(init.body)), + }; + calls.push(call); + const { status = 200, json } = respond(call); + return new Response(JSON.stringify(json), { status, headers: { "content-type": "application/json" } }); + }) as unknown as typeof fetch; + return { calls, deps: { baseUrl: "http://cli.test", fetchImpl } }; +} + +function capture(): { restore: () => void } { + const log = console.log; + const error = console.error; + console.log = () => undefined; + console.error = () => undefined; + return { restore: () => { console.log = log; console.error = error; } }; +} + +const STATUS = { + enabled: false, + maxBytes: 512 * 1024 * 1024, + currentBytes: 64 * 1024 * 1024, + overLimit: false, + job: { status: "idle" }, +}; + +describe("ocx storage usage-limit", () => { + test("show reads the usage-ledger retention status", async () => { + const { calls, deps } = harness(() => ({ json: STATUS })); + const cap = capture(); + try { + expect(await handleStorageCommand(["usage-limit", "show"], deps)).toBe(0); + } finally { + cap.restore(); + } + expect(calls).toEqual([{ method: "GET", path: "/api/storage/usage-ledger-retention", body: undefined }]); + }); + + test("set sends only the fields explicitly given", async () => { + const { calls, deps } = harness(() => ({ json: { ok: true, ...STATUS } })); + const cap = capture(); + try { + expect(await handleStorageCommand(["usage-limit", "set", "--mib", "1024"], deps)).toBe(0); + } finally { + cap.restore(); + } + expect(calls[0]).toMatchObject({ + method: "PUT", + path: "/api/storage/usage-ledger-retention", + body: { maxBytes: 1024 * 1024 * 1024 }, + }); + expect(calls[0]?.body).not.toHaveProperty("enabled"); + }); + + test("set can explicitly enable without changing the saved ceiling", async () => { + const { calls, deps } = harness(() => ({ json: { ok: true, ...STATUS, enabled: true } })); + const cap = capture(); + try { + expect(await handleStorageCommand(["usage-limit", "set", "--enabled", "true"], deps)).toBe(0); + } finally { + cap.restore(); + } + expect(calls[0]?.body).toEqual({ enabled: true }); + }); + + test("set with no fields is rejected locally", async () => { + const { calls, deps } = harness(() => ({ json: STATUS })); + const cap = capture(); + let code: number; + try { + code = await handleStorageCommand(["usage-limit", "set"], deps); + } finally { + cap.restore(); + } + expect(code).not.toBe(0); + expect(calls).toHaveLength(0); + }); + + test("manual run requires --yes and sends no mutation without it", async () => { + const { calls, deps } = harness(() => ({ json: { ok: true, started: true } })); + const cap = capture(); + let code: number; + try { + code = await handleStorageCommand(["usage-limit", "run"], deps); + } finally { + cap.restore(); + } + expect(code).not.toBe(0); + expect(calls).toHaveLength(0); + }); + + test("manual run with --yes reaches the destructive route", async () => { + const { calls, deps } = harness(() => ({ json: { ok: true, started: true } })); + const cap = capture(); + try { + expect(await handleStorageCommand(["usage-limit", "run", "--yes"], deps)).toBe(0); + } finally { + cap.restore(); + } + expect(calls).toEqual([{ method: "POST", path: "/api/storage/usage-ledger-retention/run", body: undefined }]); + }); +}); From d7fa8a9078ccad37e6de651a896b96a9969caf0f Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:32:58 +0800 Subject: [PATCH 16/61] feat(storage): declare usage history limit capability --- src/cli/capabilities.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 86aa5438df..0670ad5c49 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -397,6 +397,28 @@ export const CAPABILITIES: readonly Capability[] = [ "`policy run` forces a run regardless of schedule, so it needs `--yes`.", ], }, + { + command: ["storage", "usage-limit"], + summary: "Show, change, or run the usage-history size limit.", + routes: [ + { method: "GET", path: "/api/storage/usage-ledger-retention" }, + { method: "PUT", path: "/api/storage/usage-ledger-retention" }, + { method: "POST", path: "/api/storage/usage-ledger-retention/run" }, + ], + flags: [ + { name: "--enabled", value: "string", summary: "true or false." }, + { name: "--mib", value: "number", summary: "Maximum usage-ledger size in MiB; minimum 1." }, + { name: "--yes", value: "boolean", summary: "Required for `usage-limit run`, which permanently removes older history." }, + { name: "--json", value: "boolean", summary: "Emit the policy, status, or run state as JSON." }, + ], + mutates: true, + json: "payload", + details: [ + "The limit is opt-in; a bare invocation only reads status.", + "Changing the MiB value without `--enabled` preserves the saved enabled state.", + "A manual run permanently removes older usage rows, so it requires `--yes`.", + ], + }, { command: ["inspect", "config"], summary: "The effective merged configuration the proxy is running.", From c5ec00ad46ebff6e21b4f8a46f3876b082336540 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:50:14 +0800 Subject: [PATCH 17/61] fix(usage): join retention worker during abort --- src/usage/ledger-retention-job.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/usage/ledger-retention-job.ts b/src/usage/ledger-retention-job.ts index 7daed9765c..035149245a 100644 --- a/src/usage/ledger-retention-job.ts +++ b/src/usage/ledger-retention-job.ts @@ -305,10 +305,13 @@ export function maybeRequestUsageLedgerRetentionRun(): void { /** Join an active retention Worker during final server teardown. */ export async function abortUsageLedgerRetentionJobAsync(): Promise { runGeneration += 1; + const worker = activeWorker; + const job = inflight; const cancel = cancelActiveRun; cancelActiveRun = null; cancel?.(); - const worker = activeWorker; + if (worker) await terminateStorageWorker(worker); + if (job) await job.catch(() => undefined); activeWorker = null; inflight = null; if (state.status === "running") { @@ -320,7 +323,6 @@ export async function abortUsageLedgerRetentionJobAsync(): Promise { lastOutcome: { ok: false, error: "worker_failed" }, }; } - if (worker) await terminateStorageWorker(worker); } /** Test reset for the module-local controller state. */ From df690d8e7c329acc4d0b3ad6f8d45fe48595e4dc Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:52:21 +0800 Subject: [PATCH 18/61] feat(storage): tag usage retention atomic replaces --- src/lib/windows-atomic-replace.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/windows-atomic-replace.ts b/src/lib/windows-atomic-replace.ts index 0f3ba94552..bca35f8a14 100644 --- a/src/lib/windows-atomic-replace.ts +++ b/src/lib/windows-atomic-replace.ts @@ -34,6 +34,7 @@ export type ReplacePublisher = | "lab-automation" | "lab-ledger" | "storage-cleanup" + | "usage-retention" | "tray"; /** The Windows error codes this module treats as a momentary hold. */ From ffbf2f870beb44eb3e702d7a53f364836795b2a3 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:53:11 +0800 Subject: [PATCH 19/61] fix(usage): use Windows-tolerant atomic ledger replace --- src/usage/ledger-retention-job.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/usage/ledger-retention-job.ts b/src/usage/ledger-retention-job.ts index 035149245a..6d66603024 100644 --- a/src/usage/ledger-retention-job.ts +++ b/src/usage/ledger-retention-job.ts @@ -1,4 +1,5 @@ -import { chmodSync, statSync, renameSync, unlinkSync } from "node:fs"; +import { chmodSync, statSync, unlinkSync } from "node:fs"; +import { renameAtomicFile } from "../lib/windows-atomic-replace"; import { closeRequestHistoryIndex } from "../routing/history/indexer"; import { getActiveTurnCount } from "../server/lifecycle"; import { @@ -40,7 +41,7 @@ export interface UsageLedgerRetentionCommitDeps { activeTurnCount?: () => number; closeHistoryIndex?: () => void; stat?: typeof statSync; - rename?: typeof renameSync; + rename?: (source: string, destination: string) => void; chmod?: typeof chmodSync; unlink?: typeof unlinkSync; } @@ -78,7 +79,12 @@ export function commitPreparedUsageLedgerCompaction( const activeTurnCount = deps.activeTurnCount ?? getActiveTurnCount; const closeHistoryIndex = deps.closeHistoryIndex ?? closeRequestHistoryIndex; const stat = deps.stat ?? statSync; - const rename = deps.rename ?? renameSync; + // Keep the final publication synchronous. The shared helper retries the short + // Windows sharing-violation window with sleepSync, so no request callback can + // interleave after the revision check and publish a newer append underneath us. + const rename = deps.rename ?? ((source: string, destination: string) => { + renameAtomicFile(source, destination, undefined, "usage-retention"); + }); const chmod = deps.chmod ?? chmodSync; const unlink = deps.unlink ?? unlinkSync; From e2c1e0d7224dc89933dc9ab18781b646196fa0ad Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:55:10 +0800 Subject: [PATCH 20/61] fix(storage): keep policy save separate from immediate trim --- src/server/management/storage-log-guard-routes.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/server/management/storage-log-guard-routes.ts b/src/server/management/storage-log-guard-routes.ts index 83bc4364cc..7cdecd695d 100644 --- a/src/server/management/storage-log-guard-routes.ts +++ b/src/server/management/storage-log-guard-routes.ts @@ -141,11 +141,12 @@ export async function handleStorageLogGuardRoutes(ctx: ManagementContext): Promi try { const saved = writeUsageLedgerRetentionToConfig(parsed.policy); applyUsageLedgerRetentionToLiveConfig(config, saved); - const run = saved.enabled ? requestUsageLedgerRetentionRun() : null; + // PUT changes policy only. Automatic enforcement belongs to the scheduler; + // the explicit /run route is the operator's immediate destructive action. return jsonResponse({ ok: true, ...getUsageLedgerRetentionStatus(config), - job: run?.state ?? getUsageLedgerRetentionJobState(), + job: getUsageLedgerRetentionJobState(), }, 200, req, config); } catch { return jsonResponse({ error: "config_write_failed" }, 500, req, config); From b5fea122637be1647875b150977b2cd529372d11 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:03:02 +0800 Subject: [PATCH 21/61] fix(usage): preserve exact row boundaries and owned candidates --- src/usage/ledger-retention.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/usage/ledger-retention.ts b/src/usage/ledger-retention.ts index 4dbe8e2186..979c479b20 100644 --- a/src/usage/ledger-retention.ts +++ b/src/usage/ledger-retention.ts @@ -115,6 +115,7 @@ export function usageLedgerRevisionMatches( && left.ctimeMs === right.ctimeMs; } +/** Find the final complete-line delimiter before `endExclusive`. */ function findLastNewline(fd: number, endExclusive: number): number { const buffer = Buffer.allocUnsafe(SCAN_CHUNK_BYTES); let end = endExclusive; @@ -130,6 +131,7 @@ function findLastNewline(fd: number, endExclusive: number): number { return -1; } +/** Find the next complete-line delimiter at or after `startInclusive`. */ function findFirstNewline(fd: number, startInclusive: number, endExclusive: number): number { const buffer = Buffer.allocUnsafe(SCAN_CHUNK_BYTES); let start = startInclusive; @@ -145,6 +147,7 @@ function findFirstNewline(fd: number, startInclusive: number, endExclusive: numb return -1; } +/** Copy an exact byte range while tolerating short reads/writes. */ function copyRange(sourceFd: number, targetFd: number, start: number, endExclusive: number): number { const buffer = Buffer.allocUnsafe(SCAN_CHUNK_BYTES); let offset = start; @@ -170,10 +173,15 @@ function copyRange(sourceFd: number, targetFd: number, start: number, endExclusi * probe ceiling, so a single row larger than the copy chunk cannot leak a * partial prefix. The backward scan drops an unterminated crash tail. If one * complete row itself exceeds maxBytes it is dropped, preserving the hard cap. + * + * `candidatePath` lets the parent process own the temporary path before a Worker + * starts. That ownership is required so timeout/shutdown can remove a candidate + * even when the Worker produced it but its completion message was never claimed. */ export function prepareUsageLedgerCompaction( path: string, maxBytes: number, + candidatePath?: string, ): UsageLedgerCompactionPreparation { if (!Number.isSafeInteger(maxBytes) || maxBytes < MIN_USAGE_LEDGER_MAX_BYTES) { throw new RangeError(`maxBytes must be a safe integer >= ${MIN_USAGE_LEDGER_MAX_BYTES}`); @@ -204,11 +212,19 @@ export function prepareUsageLedgerCompaction( const desiredStart = Math.max(0, completeEnd - maxBytes); let retainedStart = 0; if (desiredStart > 0) { - const newline = findFirstNewline(sourceFd, desiredStart, completeEnd); - retainedStart = newline < 0 ? completeEnd : newline + 1; + const previousByte = Buffer.allocUnsafe(1); + const startsAtRowBoundary = + readSync(sourceFd, previousByte, 0, 1, desiredStart - 1) === 1 + && previousByte[0] === 0x0a; + if (startsAtRowBoundary) { + retainedStart = desiredStart; + } else { + const newline = findFirstNewline(sourceFd, desiredStart, completeEnd); + retainedStart = newline < 0 ? completeEnd : newline + 1; + } } - tempPath = `${path}.retention-${process.pid}-${crypto.randomUUID()}.tmp`; + tempPath = candidatePath ?? `${path}.retention-${process.pid}-${crypto.randomUUID()}.tmp`; const targetFd = openSync(tempPath, "wx", 0o600); let afterBytes = 0; try { From e796a64aa1275c4c259aa35a422ade195466c339 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:03:16 +0800 Subject: [PATCH 22/61] fix(usage): pass parent-owned retention candidate path --- src/usage/ledger-retention-worker.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/usage/ledger-retention-worker.ts b/src/usage/ledger-retention-worker.ts index 24d8b44d46..59a01d061a 100644 --- a/src/usage/ledger-retention-worker.ts +++ b/src/usage/ledger-retention-worker.ts @@ -4,27 +4,31 @@ interface RunMessage { type: "run"; requestId: string; path: string; + tempPath: string; maxBytes: number; env?: { OPENCODEX_HOME?: string }; } +/** Validate the fixed-shape message accepted by the retention Worker. */ function isRunMessage(data: unknown): data is RunMessage { if (!data || typeof data !== "object" || Array.isArray(data)) return false; const row = data as Record; return row.type === "run" && typeof row.requestId === "string" && typeof row.path === "string" + && typeof row.tempPath === "string" && typeof row.maxBytes === "number"; } declare const self: Worker; +/** Prepare one candidate and return only fixed, path-free failures to the parent. */ self.onmessage = (event: MessageEvent) => { if (!isRunMessage(event.data)) return; - const { requestId, path, maxBytes, env } = event.data; + const { requestId, path, tempPath, maxBytes, env } = event.data; try { if (env?.OPENCODEX_HOME) process.env.OPENCODEX_HOME = env.OPENCODEX_HOME; - const result = prepareUsageLedgerCompaction(path, maxBytes); + const result = prepareUsageLedgerCompaction(path, maxBytes, tempPath); self.postMessage({ type: "done", requestId, result }); } catch { // Keep worker errors fixed and path-free: OPENCODEX_HOME may contain user information. From d1cd58514e20cf6c5f39f023d6b297e165536bc5 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:04:08 +0800 Subject: [PATCH 23/61] fix(usage): invalidate stale retention runs and clean candidates --- src/usage/ledger-retention-job.ts | 50 ++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/src/usage/ledger-retention-job.ts b/src/usage/ledger-retention-job.ts index 6d66603024..2a1f9ee2d1 100644 --- a/src/usage/ledger-retention-job.ts +++ b/src/usage/ledger-retention-job.ts @@ -55,10 +55,12 @@ let lastWarningAt = 0; const WARNING_INTERVAL_MS = 60_000; const WORKER_TIMEOUT_MS = 10 * 60 * 1000; +/** Remove a Worker candidate without surfacing path-bearing filesystem errors. */ function discardCandidate(path: string, unlink: typeof unlinkSync = unlinkSync): void { try { unlink(path); } catch { /* already absent / best effort */ } } +/** Emit at most one fixed, path-free retention warning per minute. */ function warnRetentionFailure(): void { const now = Date.now(); if (now - lastWarningAt < WARNING_INTERVAL_MS) return; @@ -148,6 +150,7 @@ export function commitPreparedUsageLedgerCompaction( } } +/** Return a detached snapshot of the process-local retention controller state. */ export function getUsageLedgerRetentionJobState(): UsageLedgerRetentionJobState { return { ...state, @@ -155,9 +158,16 @@ export function getUsageLedgerRetentionJobState(): UsageLedgerRetentionJobState }; } +/** Allocate the candidate name in the parent before the Worker can create it. */ +function retentionCandidatePath(path: string): string { + return `${path}.retention-${process.pid}-${crypto.randomUUID()}.tmp`; +} + +/** Run the expensive scan/copy phase in the shared, admission-controlled Worker lane. */ function runInWorker(path: string, maxBytes: number): Promise { const reservation = tryReserveStorageWorker(); if (!reservation) return Promise.reject(new StorageWorkerAdmissionBusyError()); + const tempPath = retentionCandidatePath(path); return withStorageWorkerSpawnGate(() => new Promise((resolve, reject) => { const requestId = crypto.randomUUID(); @@ -173,21 +183,25 @@ function runInWorker(path: string, maxBytes: number): Promise void) => { + const finish = (fn: () => void, cleanupCandidate = false) => { if (settled) return; settled = true; cancelActiveRun = null; clearTimeout(timer); if (activeWorker === worker) activeWorker = null; - void terminateStorageWorker(worker).then(fn, fn); + const afterTerminate = () => { + if (cleanupCandidate) discardCandidate(tempPath); + fn(); + }; + void terminateStorageWorker(worker).then(afterTerminate, afterTerminate); }; const timer = setTimeout(() => { - finish(() => reject(new Error("usage_ledger_retention_worker_timeout"))); + finish(() => reject(new Error("usage_ledger_retention_worker_timeout")), true); }, WORKER_TIMEOUT_MS); cancelActiveRun = () => { - finish(() => reject(new Error("aborted"))); + finish(() => reject(new Error("aborted")), true); }; worker.onmessage = (event: MessageEvent) => { @@ -200,17 +214,18 @@ function runInWorker(path: string, maxBytes: number): Promise reject(new Error("usage_ledger_retention_worker_failed"))); + finish(() => reject(new Error("usage_ledger_retention_worker_failed")), true); } }; worker.onerror = () => { - finish(() => reject(new Error("usage_ledger_retention_worker_failed"))); + finish(() => reject(new Error("usage_ledger_retention_worker_failed")), true); }; worker.postMessage({ type: "run", requestId, path, + tempPath, maxBytes, env: { ...(process.env.OPENCODEX_HOME ? { OPENCODEX_HOME: process.env.OPENCODEX_HOME } : {}), @@ -218,10 +233,12 @@ function runInWorker(path: string, maxBytes: number): Promise { reservation.release(); + discardCandidate(tempPath); throw error; }); } +/** Execute one policy snapshot and discard its candidate if that snapshot becomes stale. */ async function executeJob(generation: number): Promise { const policy = readUsageLedgerRetentionFromConfig(); if (!policy.enabled) { @@ -277,6 +294,16 @@ async function executeJob(generation: number): Promise { } } +/** + * Invalidate the policy snapshot owned by any current run. + * + * Policy PUTs call this after persisting/applying the new settings. The old Worker + * may finish its read-only preparation, but its generation can no longer commit. + */ +export function invalidateUsageLedgerRetentionRun(): void { + runGeneration += 1; +} + /** Start one asynchronous retention evaluation. */ export function requestUsageLedgerRetentionRun(): | { accepted: true; state: UsageLedgerRetentionJobState } @@ -293,7 +320,16 @@ export function requestUsageLedgerRetentionRun(): const job = executeJob(generation); inflight = job; void job.finally(() => { - if (inflight === job) inflight = null; + if (inflight !== job) return; + inflight = null; + if (generation !== runGeneration && state.status === "running") { + state = { + status: "idle", + startedAt: state.startedAt, + finishedAt: Date.now(), + ...(state.lastOutcome ? { lastOutcome: state.lastOutcome } : {}), + }; + } }); return { accepted: true, state: getUsageLedgerRetentionJobState() }; } From 3352d90b73c371499c490d8bac7acb7f62b7414b Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:04:41 +0800 Subject: [PATCH 24/61] fix(storage): invalidate retention work after policy changes --- src/server/management/storage-log-guard-routes.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/server/management/storage-log-guard-routes.ts b/src/server/management/storage-log-guard-routes.ts index 7cdecd695d..2d4b48a7c3 100644 --- a/src/server/management/storage-log-guard-routes.ts +++ b/src/server/management/storage-log-guard-routes.ts @@ -20,6 +20,7 @@ import { } from "../../usage/ledger-retention-config"; import { getUsageLedgerRetentionJobState, + invalidateUsageLedgerRetentionRun, requestUsageLedgerRetentionRun, } from "../../usage/ledger-retention-job"; import { jsonResponse } from "../auth-cors"; @@ -31,10 +32,12 @@ import type { ManagementContext } from "./context"; const INSPECTION_FAILED_MESSAGE = "Codex log inspection failed"; +/** Report whether the Log Guard schema cannot be inspected on this install. */ function inspectionUnavailable(report: CodexLogGuardStatus): boolean { return report.schema.state === "unavailable"; } +/** Map a Log Guard mutation result to its management HTTP status. */ function mutationStatus(result: CodexLogGuardMutationResult): number { if (result.ok) return 200; switch (result.error) { @@ -52,6 +55,7 @@ function mutationStatus(result: CodexLogGuardMutationResult): number { } } +/** Map a Log Guard compaction result to its management HTTP status. */ function compactStatus(result: CodexLogGuardCompactionResult): number { if (result.ok) return 200; switch (result.error) { @@ -69,6 +73,7 @@ function compactStatus(result: CodexLogGuardCompactionResult): number { } } +/** Serialize a Log Guard mutation result through the shared CORS-aware JSON helper. */ function mutationResponse( result: CodexLogGuardMutationResult, ctx: ManagementContext, @@ -78,6 +83,7 @@ function mutationResponse( : jsonResponse({ error: result.error }, mutationStatus(result), ctx.req, ctx.config); } +/** Serialize a Log Guard compaction result through the shared CORS-aware JSON helper. */ function compactResponse( result: CodexLogGuardCompactionResult, ctx: ManagementContext, @@ -95,6 +101,7 @@ function compactResponse( ); } +/** Parse the explicit Log Guard protection mode from a bounded management body. */ async function readProtectMode(ctx: ManagementContext): Promise<"compat" | "quiet" | Response> { let body: unknown; try { @@ -141,6 +148,9 @@ export async function handleStorageLogGuardRoutes(ctx: ManagementContext): Promi try { const saved = writeUsageLedgerRetentionToConfig(parsed.policy); applyUsageLedgerRetentionToLiveConfig(config, saved); + // Every policy change invalidates the snapshot captured by an older Worker. + // The old preparation may finish, but its generation can no longer commit. + invalidateUsageLedgerRetentionRun(); // PUT changes policy only. Automatic enforcement belongs to the scheduler; // the explicit /run route is the operator's immediate destructive action. return jsonResponse({ From 2b6b10cc99ed3bc39b9344cf9af1a06afa4f1108 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:05:24 +0800 Subject: [PATCH 25/61] test(usage): cover exact retention row boundaries --- tests/usage-ledger-retention-v2.test.ts | 44 +++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/tests/usage-ledger-retention-v2.test.ts b/tests/usage-ledger-retention-v2.test.ts index 640e5f88c8..da30cab02d 100644 --- a/tests/usage-ledger-retention-v2.test.ts +++ b/tests/usage-ledger-retention-v2.test.ts @@ -14,12 +14,23 @@ import { commitPreparedUsageLedgerCompaction } from "../src/usage/ledger-retenti const homes: string[] = []; +/** Allocate one isolated filesystem home and remember it for teardown. */ function home(): string { const dir = mkdtempSync(join(tmpdir(), "ocx-ledger-retention-")); homes.push(dir); return dir; } +/** Build one JSONL row whose encoded byte length is exactly `totalBytes`. */ +function jsonlRowOfSize(requestId: string, totalBytes: number, fill = "x"): string { + const empty = `${JSON.stringify({ requestId, filler: "" })}\n`; + const overhead = Buffer.byteLength(empty); + if (totalBytes < overhead) throw new Error("row target is smaller than JSONL overhead"); + const row = `${JSON.stringify({ requestId, filler: fill.repeat(totalBytes - overhead) })}\n`; + if (Buffer.byteLength(row) !== totalBytes) throw new Error("row byte sizing drifted"); + return row; +} + afterEach(() => { for (const dir of homes.splice(0)) rmSync(dir, { recursive: true, force: true }); }); @@ -74,11 +85,10 @@ describe("usage ledger retention v2", () => { expect(readFileSync(prepared.tempPath, "utf8")).toBe(""); }); - test("drops an unterminated crash tail", () => { + test("drops an unterminated crash tail while retaining a complete row at the ceiling", () => { const dir = home(); const path = join(dir, "usage.jsonl"); - const filler = "x".repeat(MIN_USAGE_LEDGER_MAX_BYTES); - const complete = `${JSON.stringify({ requestId: "complete", filler })}\n`; + const complete = jsonlRowOfSize("complete", MIN_USAGE_LEDGER_MAX_BYTES); const partial = JSON.stringify({ requestId: "partial", filler: "y".repeat(1024) }); writeFileSync(path, complete + partial); @@ -86,10 +96,25 @@ describe("usage ledger retention v2", () => { expect(prepared.changed).toBe(true); if (!prepared.changed) throw new Error("expected compaction"); const retained = readFileSync(prepared.tempPath, "utf8"); + expect(retained).toBe(complete); expect(retained.endsWith("\n")).toBe(true); expect(retained).not.toContain("partial"); }); + test("retains the row when the byte ceiling lands exactly on its start boundary", () => { + const dir = home(); + const path = join(dir, "usage.jsonl"); + const old = `${JSON.stringify({ requestId: "old" })}\n`; + const newest = jsonlRowOfSize("new", MIN_USAGE_LEDGER_MAX_BYTES, "b"); + writeFileSync(path, old + newest); + + const prepared = prepareUsageLedgerCompaction(path, MIN_USAGE_LEDGER_MAX_BYTES); + expect(prepared.changed).toBe(true); + if (!prepared.changed) throw new Error("expected compaction"); + expect(prepared.afterBytes).toBe(MIN_USAGE_LEDGER_MAX_BYTES); + expect(readFileSync(prepared.tempPath, "utf8")).toBe(newest); + }); + test("never starts the candidate in the middle of a long row", () => { const dir = home(); const path = join(dir, "usage.jsonl"); @@ -105,6 +130,19 @@ describe("usage ledger retention v2", () => { expect(() => JSON.parse(retained.trim())).not.toThrow(); }); + test("uses a parent-owned candidate path when one is supplied", () => { + const dir = home(); + const path = join(dir, "usage.jsonl"); + const tempPath = join(dir, "owned-retention.tmp"); + writeFileSync(path, jsonlRowOfSize("old", MIN_USAGE_LEDGER_MAX_BYTES) + `${JSON.stringify({ requestId: "new" })}\n`); + + const prepared = prepareUsageLedgerCompaction(path, MIN_USAGE_LEDGER_MAX_BYTES, tempPath); + expect(prepared.changed).toBe(true); + if (!prepared.changed) throw new Error("expected compaction"); + expect(prepared.tempPath).toBe(tempPath); + expect(existsSync(tempPath)).toBe(true); + }); + test("revision comparator detects a source mutation before commit", () => { const revision = { dev: 1, ino: 2, size: 3, mtimeMs: 4, ctimeMs: 5 }; expect(usageLedgerRevisionMatches(revision, revision)).toBe(true); From cc473b6ca1da24ee2310edfc20521e1783a96725 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:06:31 +0800 Subject: [PATCH 26/61] feat(gui): localize usage retention controls --- gui/src/i18n/usage-retention-translations.ts | 166 +++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 gui/src/i18n/usage-retention-translations.ts diff --git a/gui/src/i18n/usage-retention-translations.ts b/gui/src/i18n/usage-retention-translations.ts new file mode 100644 index 0000000000..1945d34dc8 --- /dev/null +++ b/gui/src/i18n/usage-retention-translations.ts @@ -0,0 +1,166 @@ +import type { LabLocale } from "./lab-translations"; + +export type UsageRetentionCatalogKey = + | "storage.usageRetention.title" + | "storage.usageRetention.help" + | "storage.usageRetention.enabled" + | "storage.usageRetention.current" + | "storage.usageRetention.limit" + | "storage.usageRetention.save" + | "storage.usageRetention.apply" + | "storage.usageRetention.saving" + | "storage.usageRetention.running" + | "storage.usageRetention.saved" + | "storage.usageRetention.disabled" + | "storage.usageRetention.error"; + +const en: Record = { + "storage.usageRetention.title": "Usage history size limit", + "storage.usageRetention.help": "When enabled, OpenCodex keeps the newest complete usage records and permanently removes older rows after the ledger exceeds this limit.", + "storage.usageRetention.enabled": "Limit usage history size", + "storage.usageRetention.current": "Current size", + "storage.usageRetention.limit": "Maximum size", + "storage.usageRetention.save": "Save", + "storage.usageRetention.apply": "Apply now", + "storage.usageRetention.saving": "Saving…", + "storage.usageRetention.running": "Applying…", + "storage.usageRetention.saved": "Saved", + "storage.usageRetention.disabled": "Disabled", + "storage.usageRetention.error": "Could not update the usage history limit.", +}; + +const de: Record = { + "storage.usageRetention.title": "Größenlimit für Nutzungsverlauf", + "storage.usageRetention.help": "Wenn aktiviert, behält OpenCodex die neuesten vollständigen Nutzungsdatensätze und entfernt ältere Einträge dauerhaft, sobald das Limit überschritten wird.", + "storage.usageRetention.enabled": "Größe des Nutzungsverlaufs begrenzen", + "storage.usageRetention.current": "Aktuelle Größe", + "storage.usageRetention.limit": "Maximale Größe", + "storage.usageRetention.save": "Speichern", + "storage.usageRetention.apply": "Jetzt anwenden", + "storage.usageRetention.saving": "Wird gespeichert…", + "storage.usageRetention.running": "Wird angewendet…", + "storage.usageRetention.saved": "Gespeichert", + "storage.usageRetention.disabled": "Deaktiviert", + "storage.usageRetention.error": "Das Größenlimit für den Nutzungsverlauf konnte nicht aktualisiert werden.", +}; + +const fr: Record = { + "storage.usageRetention.title": "Limite de taille de l’historique d’utilisation", + "storage.usageRetention.help": "Lorsque cette option est activée, OpenCodex conserve les enregistrements d’utilisation complets les plus récents et supprime définitivement les plus anciens lorsque la limite est dépassée.", + "storage.usageRetention.enabled": "Limiter la taille de l’historique d’utilisation", + "storage.usageRetention.current": "Taille actuelle", + "storage.usageRetention.limit": "Taille maximale", + "storage.usageRetention.save": "Enregistrer", + "storage.usageRetention.apply": "Appliquer maintenant", + "storage.usageRetention.saving": "Enregistrement…", + "storage.usageRetention.running": "Application…", + "storage.usageRetention.saved": "Enregistré", + "storage.usageRetention.disabled": "Désactivé", + "storage.usageRetention.error": "Impossible de mettre à jour la limite de l’historique d’utilisation.", +}; + +const ko: Record = { + "storage.usageRetention.title": "사용 기록 크기 제한", + "storage.usageRetention.help": "활성화하면 OpenCodex는 가장 최근의 완전한 사용 기록을 유지하고 원장이 제한을 초과하면 오래된 행을 영구 삭제합니다.", + "storage.usageRetention.enabled": "사용 기록 크기 제한", + "storage.usageRetention.current": "현재 크기", + "storage.usageRetention.limit": "최대 크기", + "storage.usageRetention.save": "저장", + "storage.usageRetention.apply": "지금 적용", + "storage.usageRetention.saving": "저장 중…", + "storage.usageRetention.running": "적용 중…", + "storage.usageRetention.saved": "저장됨", + "storage.usageRetention.disabled": "비활성화됨", + "storage.usageRetention.error": "사용 기록 크기 제한을 업데이트할 수 없습니다.", +}; + +const zh: Record = { + "storage.usageRetention.title": "Usage 历史大小限制", + "storage.usageRetention.help": "启用后,OpenCodex 会保留最新的完整 Usage 记录,并在日志超过上限后永久删除较旧记录。", + "storage.usageRetention.enabled": "限制 Usage 历史大小", + "storage.usageRetention.current": "当前大小", + "storage.usageRetention.limit": "最大大小", + "storage.usageRetention.save": "保存", + "storage.usageRetention.apply": "立即应用", + "storage.usageRetention.saving": "正在保存…", + "storage.usageRetention.running": "正在应用…", + "storage.usageRetention.saved": "已保存", + "storage.usageRetention.disabled": "已关闭", + "storage.usageRetention.error": "无法更新 Usage 历史大小限制。", +}; + +const zhTW: Record = { + "storage.usageRetention.title": "Usage 歷史大小限制", + "storage.usageRetention.help": "啟用後,OpenCodex 會保留最新的完整 Usage 記錄,並在日誌超過上限後永久刪除較舊記錄。", + "storage.usageRetention.enabled": "限制 Usage 歷史大小", + "storage.usageRetention.current": "目前大小", + "storage.usageRetention.limit": "最大大小", + "storage.usageRetention.save": "儲存", + "storage.usageRetention.apply": "立即套用", + "storage.usageRetention.saving": "正在儲存…", + "storage.usageRetention.running": "正在套用…", + "storage.usageRetention.saved": "已儲存", + "storage.usageRetention.disabled": "已關閉", + "storage.usageRetention.error": "無法更新 Usage 歷史大小限制。", +}; + +const ru: Record = { + "storage.usageRetention.title": "Ограничение размера истории использования", + "storage.usageRetention.help": "Если включено, OpenCodex сохраняет самые новые полные записи использования и безвозвратно удаляет старые строки после превышения лимита.", + "storage.usageRetention.enabled": "Ограничить размер истории использования", + "storage.usageRetention.current": "Текущий размер", + "storage.usageRetention.limit": "Максимальный размер", + "storage.usageRetention.save": "Сохранить", + "storage.usageRetention.apply": "Применить сейчас", + "storage.usageRetention.saving": "Сохранение…", + "storage.usageRetention.running": "Применение…", + "storage.usageRetention.saved": "Сохранено", + "storage.usageRetention.disabled": "Отключено", + "storage.usageRetention.error": "Не удалось обновить ограничение размера истории использования.", +}; + +const ja: Record = { + "storage.usageRetention.title": "使用履歴のサイズ上限", + "storage.usageRetention.help": "有効にすると、OpenCodex は最新の完全な使用記録を保持し、台帳が上限を超えた場合に古い行を完全に削除します。", + "storage.usageRetention.enabled": "使用履歴のサイズを制限", + "storage.usageRetention.current": "現在のサイズ", + "storage.usageRetention.limit": "最大サイズ", + "storage.usageRetention.save": "保存", + "storage.usageRetention.apply": "今すぐ適用", + "storage.usageRetention.saving": "保存中…", + "storage.usageRetention.running": "適用中…", + "storage.usageRetention.saved": "保存しました", + "storage.usageRetention.disabled": "無効", + "storage.usageRetention.error": "使用履歴のサイズ上限を更新できませんでした。", +}; + +const tr: Record = { + "storage.usageRetention.title": "Kullanım geçmişi boyut sınırı", + "storage.usageRetention.help": "Etkinleştirildiğinde OpenCodex en yeni eksiksiz kullanım kayıtlarını tutar ve günlük sınırı aştığında eski satırları kalıcı olarak siler.", + "storage.usageRetention.enabled": "Kullanım geçmişi boyutunu sınırla", + "storage.usageRetention.current": "Geçerli boyut", + "storage.usageRetention.limit": "Maksimum boyut", + "storage.usageRetention.save": "Kaydet", + "storage.usageRetention.apply": "Şimdi uygula", + "storage.usageRetention.saving": "Kaydediliyor…", + "storage.usageRetention.running": "Uygulanıyor…", + "storage.usageRetention.saved": "Kaydedildi", + "storage.usageRetention.disabled": "Devre dışı", + "storage.usageRetention.error": "Kullanım geçmişi boyut sınırı güncellenemedi.", +}; + +/** Closed multi-locale catalog for the storage usage-retention panel. */ +export const USAGE_RETENTION_CATALOG_OVERRIDES: Record< + LabLocale, + Record +> = { + en, + de, + fr, + ko, + zh, + "zh-TW": zhTW, + ru, + ja, + tr, +}; From 9878f3de7a3ced605795ccdbab9b7880ca8be9aa Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:06:48 +0800 Subject: [PATCH 27/61] feat(gui): register usage retention translations --- gui/src/i18n/catalogs.ts | 42 ++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/gui/src/i18n/catalogs.ts b/gui/src/i18n/catalogs.ts index 5bb16d1320..3631cc9223 100644 --- a/gui/src/i18n/catalogs.ts +++ b/gui/src/i18n/catalogs.ts @@ -1,4 +1,4 @@ -import { en, type TKey } from "./en"; +import { en, type TKey as BaseTKey } from "./en"; import { de } from "./de"; import { fr } from "./fr"; import { ko } from "./ko"; @@ -8,29 +8,38 @@ import { ru } from "./ru"; import { ja } from "./ja"; import { tr } from "./tr"; import { LAB_CATALOG_OVERRIDES, type LabLocale } from "./lab-translations"; +import { + USAGE_RETENTION_CATALOG_OVERRIDES, + type UsageRetentionCatalogKey, +} from "./usage-retention-translations"; /** React-free locale catalog registry for formatters and other shared helpers. */ export type Locale = LabLocale; +export type TKey = BaseTKey | UsageRetentionCatalogKey; -function withLabTranslations(locale: Locale, catalog: Record): Record { - return { ...catalog, ...LAB_CATALOG_OVERRIDES[locale] }; +/** Apply centrally maintained closed-surface translations to one base locale catalog. */ +function withCatalogOverlays(locale: Locale, catalog: Record): Record { + return { + ...catalog, + ...LAB_CATALOG_OVERRIDES[locale], + ...USAGE_RETENTION_CATALOG_OVERRIDES[locale], + }; } /** - * CL-05 translations are overlaid centrally so the compatibility surface cannot regress to - * copied English values in a locale catalog. The locale parity test still validates the base - * catalogs; this overlay is deliberately limited to the closed `lab.*` namespace. + * Closed-surface translations are overlaid centrally so specialized panels cannot regress to + * copied English values. Base locale parity remains compile-checked by the locale modules. */ export const DICTS: Record> = { - en: withLabTranslations("en", en), - de: withLabTranslations("de", de), - fr: withLabTranslations("fr", fr), - ko: withLabTranslations("ko", ko), - zh: withLabTranslations("zh", zh), - "zh-TW": withLabTranslations("zh-TW", zhTW), - ru: withLabTranslations("ru", ru), - ja: withLabTranslations("ja", ja), - tr: withLabTranslations("tr", tr), + en: withCatalogOverlays("en", en), + de: withCatalogOverlays("de", de), + fr: withCatalogOverlays("fr", fr), + ko: withCatalogOverlays("ko", ko), + zh: withCatalogOverlays("zh", zh), + "zh-TW": withCatalogOverlays("zh-TW", zhTW), + ru: withCatalogOverlays("ru", ru), + ja: withCatalogOverlays("ja", ja), + tr: withCatalogOverlays("tr", tr), }; /** Native language names shown by the language picker, kept inside i18n rather than UI metadata. */ @@ -38,8 +47,7 @@ export function localeDisplayName(locale: Locale): string { return DICTS[locale]["lang.nativeName"]; } +/** Read one localized string without requiring React context. */ export function catalogValue(locale: Locale, key: TKey): string { return DICTS[locale][key]; } - -export type { TKey }; From 955c4a76a02e7b7e6e3a44ccf6c761f8ef05e961 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:07:15 +0800 Subject: [PATCH 28/61] fix(gui): route usage retention copy through i18n --- .../UsageLedgerRetentionPanel.tsx | 96 ++++++------------- 1 file changed, 28 insertions(+), 68 deletions(-) diff --git a/gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx b/gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx index 8fe1b8b3af..062c04e9d6 100644 --- a/gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx +++ b/gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx @@ -1,58 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { formatBytes } from "../../format-bytes"; -import type { Locale } from "../../i18n/shared"; +import { useT, type Locale } from "../../i18n/shared"; const MIB = 1024 ** 2; const PRESETS_MIB = [128, 512, 1024, 2048] as const; -type LabelKey = - | "title" - | "help" - | "enabled" - | "current" - | "limit" - | "save" - | "apply" - | "saving" - | "running" - | "saved" - | "disabled" - | "error"; - -const EN: Record = { - title: "Usage history size limit", - help: "When enabled, OpenCodex keeps the newest complete usage records and permanently removes older rows after the ledger exceeds this limit.", - enabled: "Limit usage history size", - current: "Current size", - limit: "Maximum size", - save: "Save", - apply: "Apply now", - saving: "Saving…", - running: "Applying…", - saved: "Saved", - disabled: "Disabled", - error: "Could not update the usage history limit.", -}; - -const ZH: Record = { - title: "Usage 历史大小限制", - help: "启用后,OpenCodex 会保留最新的完整 usage 记录,并在日志超过上限后永久删除较旧记录。", - enabled: "限制 Usage 历史大小", - current: "当前大小", - limit: "最大大小", - save: "保存", - apply: "立即应用", - saving: "正在保存…", - running: "正在应用…", - saved: "已保存", - disabled: "已关闭", - error: "无法更新 Usage 历史大小限制。", -}; - -function label(locale: Locale, key: LabelKey): string { - return (locale === "zh" || locale === "zh-TW") ? ZH[key] : EN[key]; -} - interface RetentionJobState { status: "idle" | "running"; lastOutcome?: { @@ -74,6 +26,7 @@ interface RetentionStatus { job: RetentionJobState; } +/** Storage-workspace controls for the opt-in usage-ledger byte ceiling. */ export default function UsageLedgerRetentionPanel({ apiBase, locale, @@ -81,13 +34,15 @@ export default function UsageLedgerRetentionPanel({ apiBase: string; locale: Locale; }) { + const t = useT(); const [status, setStatus] = useState(null); const [enabled, setEnabled] = useState(false); const [limitMiB, setLimitMiB] = useState(512); - const [busy, setBusy] = useState(false); + const [busyAction, setBusyAction] = useState<"save" | "apply" | null>(null); const [message, setMessage] = useState(null); const [error, setError] = useState(null); + /** Refresh policy, byte usage, and current retention-job state from management API. */ const load = useCallback(async (signal?: AbortSignal) => { const response = await fetch(`${apiBase}/api/storage/usage-ledger-retention`, { signal }); if (!response.ok) throw new Error("load_failed"); @@ -102,11 +57,11 @@ export default function UsageLedgerRetentionPanel({ const controller = new AbortController(); void load(controller.signal).catch(errorValue => { if ((errorValue as { name?: string })?.name !== "AbortError") { - setError(label(locale, "error")); + setError(t("storage.usageRetention.error")); } }); return () => controller.abort(); - }, [load, locale]); + }, [load, t]); useEffect(() => { if (status?.job.status !== "running") return; @@ -121,8 +76,9 @@ export default function UsageLedgerRetentionPanel({ [limitMiB], ); + /** Persist policy only; destructive work remains behind scheduler or explicit run. */ const save = async () => { - setBusy(true); + setBusyAction("save"); setError(null); setMessage(null); try { @@ -139,16 +95,17 @@ export default function UsageLedgerRetentionPanel({ setStatus(next); setEnabled(next.enabled); setLimitMiB(Math.max(1, Math.round(next.maxBytes / MIB))); - setMessage(label(locale, "saved")); + setMessage(t("storage.usageRetention.saved")); } catch { - setError(label(locale, "error")); + setError(t("storage.usageRetention.error")); } finally { - setBusy(false); + setBusyAction(null); } }; + /** Request the explicit immediate destructive run, then refresh its job state. */ const applyNow = async () => { - setBusy(true); + setBusyAction("apply"); setError(null); setMessage(null); try { @@ -158,28 +115,29 @@ export default function UsageLedgerRetentionPanel({ if (!response.ok && response.status !== 409) throw new Error("run_failed"); await load(); } catch { - setError(label(locale, "error")); + setError(t("storage.usageRetention.error")); } finally { - setBusy(false); + setBusyAction(null); } }; + const busy = busyAction !== null; const jobRunning = status?.job.status === "running"; return (
-

{label(locale, "title")}

-

{label(locale, "help")}

+

{t("storage.usageRetention.title")}

+

{t("storage.usageRetention.help")}

- {label(locale, "current")} + {t("storage.usageRetention.current")} {status ? formatBytes(status.currentBytes, locale) : "—"}
From d2d587d08742ffdbae5abc6b8fda5d85c3200a9b Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:13:55 +0800 Subject: [PATCH 29/61] docs(cli): preserve storage safety rationale --- src/cli/storage.ts | 44 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/src/cli/storage.ts b/src/cli/storage.ts index dd9bf93c49..c89a64118c 100644 --- a/src/cli/storage.ts +++ b/src/cli/storage.ts @@ -1,9 +1,18 @@ /** - * `ocx storage` — the archived-session cleanup, trash, cleanup-policy, and usage-ledger surface. + * `ocx storage` — archived-session cleanup, trash, cleanup-policy, and usage-ledger controls. * - * Destructive actions are explicit. Session cleanup defaults to preview, restores require - * confirmation, and a manual usage-ledger trim requires --yes because it permanently drops - * older request-history rows. + * Every route here existed with no CLI caller, so reclaiming disk space was dashboard-only. + * Destructive operations keep the original delegation boundary: + * + * 1. **Default to preview.** `ocx storage cleanup --percent N` runs the preview route and prints + * what WOULD be freed, then exits 0 having mutated nothing. + * 2. **`--yes` is required to mutate.** There is no interactive prompt: an agent cannot answer + * one, and a prompt an agent can answer is not a safety boundary. + * 3. **`--json` on the preview emits the candidate list**, so an agent can decide from data + * rather than from a sentence. + * + * Usage-limit policy writes are non-destructive; only `usage-limit run` immediately removes + * older history and therefore carries the same explicit `--yes` boundary. */ import { CliUsageError, @@ -45,11 +54,13 @@ interface CleanupPreview { candidates?: { relPath?: string; bytes?: number }[]; } +/** Format a byte count for CLI summaries without changing the API representation. */ function mib(bytes: number | undefined): string { if (typeof bytes !== "number" || !Number.isFinite(bytes)) return "unknown size"; return `${(bytes / MIB).toFixed(1)} MiB`; } +/** Render the non-mutating archive-cleanup preview used before any confirmed deletion. */ function previewLines(preview: CleanupPreview): string[] { const lines = [ `Would remove ${preview.count ?? 0} archived session file(s), freeing ${mib(preview.bytes)}.`, @@ -63,6 +74,7 @@ function previewLines(preview: CleanupPreview): string[] { return lines; } +/** Preview or explicitly execute archived-session cleanup. */ async function cleanup(argv: string[], deps: RuntimeApiDeps): Promise { const args = [...argv]; const wantsJson = takeFlag(args, "--json"); @@ -92,6 +104,8 @@ async function cleanup(argv: string[], deps: RuntimeApiDeps): Promise { } if (!preview.digest) { + // Refuse rather than send an empty digest: the server would reject it, but a clear local + // message beats a 400 that looks like a bug in the verb. throw new CliUsageError("the preview returned no digest, so the cleanup cannot be authorized", USAGE); } @@ -103,6 +117,7 @@ async function cleanup(argv: string[], deps: RuntimeApiDeps): Promise { printData(result, wantsJson, summaryLines(result)); } +/** List quarantine entries or explicitly restore one. */ async function trash(argv: string[], deps: RuntimeApiDeps): Promise { const action = argv[0] && !argv[0].startsWith("-") ? argv[0] : "list"; const rest = argv[0] && !argv[0].startsWith("-") ? argv.slice(1) : argv; @@ -125,6 +140,8 @@ async function trash(argv: string[], deps: RuntimeApiDeps): Promise { rejectArgs(args, USAGE); if (!id) throw new CliUsageError("a trash entry id is required", USAGE); + // Restore moves files back and reconciles database rows, and can collide with an existing + // destination, so it is gated like cleanup rather than treated as a read. if (!confirmed) { throw new CliUsageError(`restoring ${id} modifies stored sessions; pass --yes to confirm`, USAGE); } @@ -137,6 +154,7 @@ async function trash(argv: string[], deps: RuntimeApiDeps): Promise { printData(result, wantsJson, summaryLines(result)); } +/** Show, edit, or explicitly run archived-session cleanup policy. */ async function policy(argv: string[], deps: RuntimeApiDeps): Promise { const action = argv[0] && !argv[0].startsWith("-") ? argv[0] : "show"; const rest = argv[0] && !argv[0].startsWith("-") ? argv.slice(1) : argv; @@ -164,12 +182,25 @@ async function policy(argv: string[], deps: RuntimeApiDeps): Promise { } const body: Record = {}; if (enabled !== undefined) body.enabled = enabled === "true"; + // The policy target is nested. A top-level `percent` is not part of the PUT contract: + // `normalizeStorageCleanupPolicy` reads only `target`, so the field was dropped and the + // previously stored target survived. `--percent 10` on a policy still holding the + // default 25% therefore reported success while leaving cleanup authorized to delete + // more than the operator asked for. + // + // An out-of-range value is deliberately still sent: the server owns the 1-100 + // vocabulary and answers with a named 400, which is a rejected write rather than the + // silent wrong write this replaces. if (percent !== undefined) body.target = { removeOldestPercent: percent }; if (mode !== undefined) body.mode = mode; if (schedule !== undefined) body.schedule = schedule; if (Object.keys(body).length === 0) { throw new CliUsageError("policy set needs at least one of --enabled, --percent, --mode, --schedule", USAGE); } + // Values are NOT re-validated here beyond --enabled's shape. The server owns the mode and + // schedule vocabularies and returns a named 400; duplicating them is a second thing to + // keep in sync. `enabled` is checked because "--enabled maybe" would otherwise be sent as + // `false`, which is a wrong write rather than a rejected one. const result = await runtimeRequest("/api/storage/cleanup-policy", { method: "PUT", headers: { "content-type": "application/json" }, @@ -185,6 +216,7 @@ async function policy(argv: string[], deps: RuntimeApiDeps): Promise { const wantsJson = takeFlag(args, "--json"); const confirmed = takeFlag(args, "--yes"); rejectArgs(args, USAGE); + // `force: true` server-side: this run ignores the schedule and deletes now. if (!confirmed) { throw new CliUsageError("policy run deletes archived sessions now; pass --yes to confirm", USAGE); } @@ -192,6 +224,7 @@ async function policy(argv: string[], deps: RuntimeApiDeps): Promise { printData(result, wantsJson, summaryLines(result)); } +/** Show or edit the usage-history ceiling; only `run` performs immediate deletion. */ async function usageLimit(argv: string[], deps: RuntimeApiDeps): Promise { const action = argv[0] && !argv[0].startsWith("-") ? argv[0] : "show"; const rest = argv[0] && !argv[0].startsWith("-") ? argv.slice(1) : argv; @@ -246,11 +279,14 @@ async function usageLimit(argv: string[], deps: RuntimeApiDeps): Promise { printData(result, wantsJson, summaryLines(result)); } +/** Dispatch `ocx storage` while preserving explicit confirmation boundaries for mutations. */ export async function handleStorageCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { const hasSub = argv[0] !== undefined && !argv[0].startsWith("-"); const sub = hasSub ? argv[0]! : "report"; const rest = hasSub ? argv.slice(1) : argv; if (sub === "codex-logs") { + // Doctor and the Log Guard guides still document `ocx storage codex-logs …`. + // This module owns cleanup/trash/policy; log-guard stays on the observe handler. const { handleObserveCommand } = await import("./observe"); return handleObserveCommand(["storage", "codex-logs", ...rest], deps); } From bc228b1f07e4543b9d973db40109084bc69c0170 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:18:12 +0800 Subject: [PATCH 30/61] docs(usage): document retention scheduler helpers --- src/usage/ledger-retention-scheduler.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/usage/ledger-retention-scheduler.ts b/src/usage/ledger-retention-scheduler.ts index b6b4ba70e8..6a6a825952 100644 --- a/src/usage/ledger-retention-scheduler.ts +++ b/src/usage/ledger-retention-scheduler.ts @@ -5,6 +5,7 @@ const DEFAULT_INTERVAL_MS = 60_000; let timer: ReturnType | null = null; let startupTimer: ReturnType | null = null; +/** Request one background run only when the current persisted policy is enabled and over limit. */ function requestIfOverLimit(): void { try { const status = getUsageLedgerRetentionStatus(); @@ -32,6 +33,7 @@ export function scheduleUsageLedgerRetentionStartupRun(): void { startupTimer.unref?.(); } +/** Stop both periodic and pending startup evaluations without touching an active Worker. */ export function stopUsageLedgerRetentionScheduler(): void { if (timer) { clearInterval(timer); From 040d4fcbeaeed0c48817e9d185b482c925d2814d Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:23:55 +0800 Subject: [PATCH 31/61] docs(server): document shared background lifecycle helpers --- src/server/background-lifecycle.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index 908d9932aa..13c3fa7259 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -53,11 +53,13 @@ const owners: LeaseOwner[] = []; let processLoops: ProcessLoops | null = null; let cleanupInProgress = false; +/** Route cleanup-policy state updates to the newest live server owner, or detach the sink. */ function setLivePolicyOwner(applyPolicy: PolicyApply | null): void { setStorageCleanupPolicyLiveSink(applyPolicy); setStorageCleanupPolicyJobLiveApply(applyPolicy); } +/** Start the process-wide watchdogs, sweepers, schedulers, and optional quota background hooks. */ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { let memoryWatchdog: MemoryWatchdog | null = null; let stateStoreSweeper: ReturnType | null = null; @@ -98,6 +100,7 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { } } +/** Stop process-wide timer loops and detach the current live-policy sink. */ function stopProcessLoops(): void { const loops = processLoops; processLoops = null; @@ -109,6 +112,7 @@ function stopProcessLoops(): void { setLivePolicyOwner(null); } +/** Cancel both storage Worker controllers, then join every shared storage Worker before exit. */ async function stopStoragePolicyWorker(): Promise { cancelQueuedStorageWorkerSpawns(); const abortResult = await Promise.allSettled([ @@ -132,6 +136,7 @@ async function stopStoragePolicyWorker(): Promise { } } +/** Remove one lifecycle owner by token and report whether it was still active. */ function removeOwner(owner: LeaseOwner): boolean { const index = owners.findIndex(candidate => candidate.token === owner.token); if (index === -1) return false; @@ -139,6 +144,7 @@ function removeOwner(owner: LeaseOwner): boolean { return true; } +/** Release one owner synchronously and classify whether shared process resources remain. */ function releaseOwnerSynchronously(owner: LeaseOwner): "inactive" | "shared" | "last" { if (!removeOwner(owner)) return "inactive"; owner.resources.release(); From 2420126b925c90c4cfbbf20094226fd151cb5314 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:26:06 +0800 Subject: [PATCH 32/61] fix(usage): discard stale history projection after retention --- src/routing/history/discard-index.ts | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/routing/history/discard-index.ts diff --git a/src/routing/history/discard-index.ts b/src/routing/history/discard-index.ts new file mode 100644 index 0000000000..af591ec09c --- /dev/null +++ b/src/routing/history/discard-index.ts @@ -0,0 +1,44 @@ +import { unlinkSync } from "node:fs"; +import { getConfigDir } from "../../config"; +import { closeRequestHistoryIndex } from "./indexer"; +import { historyIndexPath } from "./schema"; + +const DELETE_RETRY_DELAYS_MS = [25, 50] as const; + +/** Return true only for Windows-style transient sharing violations worth retrying briefly. */ +function isTransientDeleteError(error: unknown): boolean { + if (process.platform !== "win32") return false; + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === "EBUSY" || code === "EPERM" || code === "EACCES"; +} + +/** Remove one derived-index file, treating absence as success and retrying short Windows holds. */ +function unlinkDerivedFile(path: string): boolean { + for (let attempt = 0; ; attempt += 1) { + try { + unlinkSync(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return true; + if (!isTransientDeleteError(error) || attempt >= DELETE_RETRY_DELAYS_MS.length) return false; + Bun.sleepSync(DELETE_RETRY_DELAYS_MS[attempt]!); + } + } +} + +/** + * Close and best-effort delete the disposable request-history projection and WAL sidecars. + * + * Retention replaces the canonical `usage.jsonl` with a new filesystem identity. The indexer + * would detect that identity change on its next query and rebuild automatically, but deleting + * the old projection here reclaims its disk immediately even when no later history query occurs. + * Failure is non-fatal: the next index open still validates source identity and recreates it. + */ +export function discardRequestHistoryProjection(): boolean { + closeRequestHistoryIndex(); + const path = historyIndexPath(getConfigDir()); + const wal = unlinkDerivedFile(`${path}-wal`); + const shm = unlinkDerivedFile(`${path}-shm`); + const main = unlinkDerivedFile(path); + return main && wal && shm; +} From e27e3fc108ff031604b3aecce35504c0e615a073 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:33:00 +0800 Subject: [PATCH 33/61] fix(usage): parameterize derived history cleanup --- src/routing/history/discard-index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/routing/history/discard-index.ts b/src/routing/history/discard-index.ts index af591ec09c..7df2e19806 100644 --- a/src/routing/history/discard-index.ts +++ b/src/routing/history/discard-index.ts @@ -33,10 +33,12 @@ function unlinkDerivedFile(path: string): boolean { * would detect that identity change on its next query and rebuild automatically, but deleting * the old projection here reclaims its disk immediately even when no later history query occurs. * Failure is non-fatal: the next index open still validates source identity and recreates it. + * + * `configDir` is injectable so isolated retention tests never touch the process' real config home. */ -export function discardRequestHistoryProjection(): boolean { +export function discardRequestHistoryProjection(configDir = getConfigDir()): boolean { closeRequestHistoryIndex(); - const path = historyIndexPath(getConfigDir()); + const path = historyIndexPath(configDir); const wal = unlinkDerivedFile(`${path}-wal`); const shm = unlinkDerivedFile(`${path}-shm`); const main = unlinkDerivedFile(path); From a5edf916261ef870da100e4807cffe6eda248618 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:33:42 +0800 Subject: [PATCH 34/61] fix(usage): reclaim derived history index after retention --- src/usage/ledger-retention-job.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/usage/ledger-retention-job.ts b/src/usage/ledger-retention-job.ts index 2a1f9ee2d1..6926d8415b 100644 --- a/src/usage/ledger-retention-job.ts +++ b/src/usage/ledger-retention-job.ts @@ -1,5 +1,7 @@ import { chmodSync, statSync, unlinkSync } from "node:fs"; +import { dirname } from "node:path"; import { renameAtomicFile } from "../lib/windows-atomic-replace"; +import { discardRequestHistoryProjection } from "../routing/history/discard-index"; import { closeRequestHistoryIndex } from "../routing/history/indexer"; import { getActiveTurnCount } from "../server/lifecycle"; import { @@ -40,6 +42,7 @@ export interface UsageLedgerRetentionJobState { export interface UsageLedgerRetentionCommitDeps { activeTurnCount?: () => number; closeHistoryIndex?: () => void; + discardHistoryProjection?: (configDir: string) => boolean; stat?: typeof statSync; rename?: (source: string, destination: string) => void; chmod?: typeof chmodSync; @@ -80,6 +83,7 @@ export function commitPreparedUsageLedgerCompaction( ): UsageLedgerRetentionJobOutcome { const activeTurnCount = deps.activeTurnCount ?? getActiveTurnCount; const closeHistoryIndex = deps.closeHistoryIndex ?? closeRequestHistoryIndex; + const discardHistoryProjection = deps.discardHistoryProjection ?? discardRequestHistoryProjection; const stat = deps.stat ?? statSync; // Keep the final publication synchronous. The shared helper retries the short // Windows sharing-violation window with sleepSync, so no request callback can @@ -128,10 +132,24 @@ export function commitPreparedUsageLedgerCompaction( try { // The index is a disposable projection of usage.jsonl. Drop its live handle - // before replacing the canonical source; the next query reopens/rebuilds it. + // before replacing the canonical source so Windows cannot hold the source-adjacent + // projection open during publication. closeHistoryIndex(); rename(prepared.tempPath, prepared.path); try { chmod(prepared.path, 0o600); } catch { /* platform may ignore chmod */ } + + // Publication succeeded. Reclaim the now-stale derived SQLite projection immediately + // instead of waiting for a later history query to notice the source identity change. + // This cleanup must never reverse a successful canonical-ledger commit. + try { + const discarded = discardHistoryProjection(dirname(prepared.path)); + if (!discarded) { + console.warn("[usage] request-history projection cleanup was incomplete; a later history access will rebuild it"); + } + } catch { + console.warn("[usage] request-history projection cleanup failed; a later history access will rebuild it"); + } + return { ok: true, beforeBytes: prepared.beforeBytes, From e8b1df8e33ade1aaa724c7b93bdeb8d93c260607 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:34:34 +0800 Subject: [PATCH 35/61] test(usage): cover derived history cleanup ordering --- tests/usage-ledger-retention-v2.test.ts | 89 ++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/tests/usage-ledger-retention-v2.test.ts b/tests/usage-ledger-retention-v2.test.ts index da30cab02d..bc6af15e5f 100644 --- a/tests/usage-ledger-retention-v2.test.ts +++ b/tests/usage-ledger-retention-v2.test.ts @@ -1,7 +1,17 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { appendFileSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + appendFileSync, + existsSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { discardRequestHistoryProjection } from "../src/routing/history/discard-index"; +import { historyIndexPath } from "../src/routing/history/schema"; import { DEFAULT_USAGE_LEDGER_MAX_BYTES, MIN_USAGE_LEDGER_MAX_BYTES, @@ -143,6 +153,19 @@ describe("usage ledger retention v2", () => { expect(existsSync(tempPath)).toBe(true); }); + test("discards the derived request-history database and sidecars from an isolated config home", () => { + const dir = home(); + const path = historyIndexPath(dir); + writeFileSync(path, "main"); + writeFileSync(`${path}-wal`, "wal"); + writeFileSync(`${path}-shm`, "shm"); + + expect(discardRequestHistoryProjection(dir)).toBe(true); + expect(existsSync(path)).toBe(false); + expect(existsSync(`${path}-wal`)).toBe(false); + expect(existsSync(`${path}-shm`)).toBe(false); + }); + test("revision comparator detects a source mutation before commit", () => { const revision = { dev: 1, ino: 2, size: 3, mtimeMs: 4, ctimeMs: 5 }; expect(usageLedgerRevisionMatches(revision, revision)).toBe(true); @@ -181,7 +204,7 @@ describe("usage ledger retention v2", () => { expect(readFileSync(path, "utf8")).toBe(old + latest + appended); }); - test("closes the derived history index before replacing an unchanged ledger", () => { + test("closes the derived history index before replace and discards it only after publication", () => { const dir = home(); const path = join(dir, "usage.jsonl"); const old = `${JSON.stringify({ requestId: "old", filler: "a".repeat(MIN_USAGE_LEDGER_MAX_BYTES) })}\n`; @@ -191,18 +214,78 @@ describe("usage ledger retention v2", () => { if (!prepared.changed) throw new Error("expected compaction"); const expected = readFileSync(prepared.tempPath, "utf8"); let closed = false; + let replaced = false; + let discarded = false; const result = commitPreparedUsageLedgerCompaction(prepared, { activeTurnCount: () => 0, closeHistoryIndex: () => { closed = true; }, rename: (from, to) => { expect(closed).toBe(true); - const { renameSync } = require("node:fs") as typeof import("node:fs"); renameSync(from, to); + replaced = true; + }, + discardHistoryProjection: configDir => { + expect(replaced).toBe(true); + expect(configDir).toBe(dir); + discarded = true; + return true; }, }); expect(result.ok).toBe(true); expect(result.droppedBytes).toBeGreaterThan(0); + expect(discarded).toBe(true); expect(readFileSync(path, "utf8")).toBe(expected); }); + + test("derived projection cleanup failure does not reverse a successful canonical commit", () => { + const dir = home(); + const path = join(dir, "usage.jsonl"); + const old = `${JSON.stringify({ requestId: "old", filler: "a".repeat(MIN_USAGE_LEDGER_MAX_BYTES) })}\n`; + const latest = `${JSON.stringify({ requestId: "new" })}\n`; + writeFileSync(path, old + latest); + const prepared = prepareUsageLedgerCompaction(path, MIN_USAGE_LEDGER_MAX_BYTES); + if (!prepared.changed) throw new Error("expected compaction"); + const expected = readFileSync(prepared.tempPath, "utf8"); + const warn = console.warn; + console.warn = () => undefined; + try { + const result = commitPreparedUsageLedgerCompaction(prepared, { + activeTurnCount: () => 0, + rename: renameSync, + discardHistoryProjection: () => { throw new Error("projection busy"); }, + }); + expect(result.ok).toBe(true); + expect(readFileSync(path, "utf8")).toBe(expected); + } finally { + console.warn = warn; + } + }); + + test("does not discard the derived projection when canonical publication fails", () => { + const dir = home(); + const path = join(dir, "usage.jsonl"); + const old = `${JSON.stringify({ requestId: "old", filler: "a".repeat(MIN_USAGE_LEDGER_MAX_BYTES) })}\n`; + const latest = `${JSON.stringify({ requestId: "new" })}\n`; + const original = old + latest; + writeFileSync(path, original); + const prepared = prepareUsageLedgerCompaction(path, MIN_USAGE_LEDGER_MAX_BYTES); + if (!prepared.changed) throw new Error("expected compaction"); + let discarded = false; + + const result = commitPreparedUsageLedgerCompaction(prepared, { + activeTurnCount: () => 0, + closeHistoryIndex: () => undefined, + rename: () => { throw new Error("rename failed"); }, + discardHistoryProjection: () => { + discarded = true; + return true; + }, + }); + expect(result.ok).toBe(false); + expect(result.error).toBe("commit_failed"); + expect(discarded).toBe(false); + expect(existsSync(prepared.tempPath)).toBe(false); + expect(readFileSync(path, "utf8")).toBe(original); + }); }); From f06f1f3e4c59630453678dac7031370252ea5860 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:43:33 +0800 Subject: [PATCH 36/61] fix(usage): preserve history db when sidecar cleanup is blocked --- src/routing/history/discard-index.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/routing/history/discard-index.ts b/src/routing/history/discard-index.ts index 7df2e19806..18fe594777 100644 --- a/src/routing/history/discard-index.ts +++ b/src/routing/history/discard-index.ts @@ -34,13 +34,16 @@ function unlinkDerivedFile(path: string): boolean { * the old projection here reclaims its disk immediately even when no later history query occurs. * Failure is non-fatal: the next index open still validates source identity and recreates it. * + * Sidecars are removed before the main database. If either sidecar remains locked, leave the + * main file in place too; the indexer can later discard the complete stale set rather than + * opening a fresh main database beside an old same-name WAL/SHM file. + * * `configDir` is injectable so isolated retention tests never touch the process' real config home. */ export function discardRequestHistoryProjection(configDir = getConfigDir()): boolean { closeRequestHistoryIndex(); const path = historyIndexPath(configDir); - const wal = unlinkDerivedFile(`${path}-wal`); - const shm = unlinkDerivedFile(`${path}-shm`); - const main = unlinkDerivedFile(path); - return main && wal && shm; + if (!unlinkDerivedFile(`${path}-wal`)) return false; + if (!unlinkDerivedFile(`${path}-shm`)) return false; + return unlinkDerivedFile(path); } From 47f7e454d152e6835e4f6d48c80141d50796539f Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:44:19 +0800 Subject: [PATCH 37/61] fix(storage): gate retention apply on saved policy --- gui/src/i18n/usage-retention-translations.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/gui/src/i18n/usage-retention-translations.ts b/gui/src/i18n/usage-retention-translations.ts index 1945d34dc8..2fa8297a0a 100644 --- a/gui/src/i18n/usage-retention-translations.ts +++ b/gui/src/i18n/usage-retention-translations.ts @@ -11,6 +11,7 @@ export type UsageRetentionCatalogKey = | "storage.usageRetention.saving" | "storage.usageRetention.running" | "storage.usageRetention.saved" + | "storage.usageRetention.saveBeforeApply" | "storage.usageRetention.disabled" | "storage.usageRetention.error"; @@ -25,6 +26,7 @@ const en: Record = { "storage.usageRetention.saving": "Saving…", "storage.usageRetention.running": "Applying…", "storage.usageRetention.saved": "Saved", + "storage.usageRetention.saveBeforeApply": "Save these changes before applying the limit now.", "storage.usageRetention.disabled": "Disabled", "storage.usageRetention.error": "Could not update the usage history limit.", }; @@ -40,6 +42,7 @@ const de: Record = { "storage.usageRetention.saving": "Wird gespeichert…", "storage.usageRetention.running": "Wird angewendet…", "storage.usageRetention.saved": "Gespeichert", + "storage.usageRetention.saveBeforeApply": "Speichern Sie diese Änderungen, bevor Sie das Limit sofort anwenden.", "storage.usageRetention.disabled": "Deaktiviert", "storage.usageRetention.error": "Das Größenlimit für den Nutzungsverlauf konnte nicht aktualisiert werden.", }; @@ -55,6 +58,7 @@ const fr: Record = { "storage.usageRetention.saving": "Enregistrement…", "storage.usageRetention.running": "Application…", "storage.usageRetention.saved": "Enregistré", + "storage.usageRetention.saveBeforeApply": "Enregistrez ces modifications avant d’appliquer la limite maintenant.", "storage.usageRetention.disabled": "Désactivé", "storage.usageRetention.error": "Impossible de mettre à jour la limite de l’historique d’utilisation.", }; @@ -70,6 +74,7 @@ const ko: Record = { "storage.usageRetention.saving": "저장 중…", "storage.usageRetention.running": "적용 중…", "storage.usageRetention.saved": "저장됨", + "storage.usageRetention.saveBeforeApply": "지금 제한을 적용하기 전에 변경 사항을 저장하세요.", "storage.usageRetention.disabled": "비활성화됨", "storage.usageRetention.error": "사용 기록 크기 제한을 업데이트할 수 없습니다.", }; @@ -85,6 +90,7 @@ const zh: Record = { "storage.usageRetention.saving": "正在保存…", "storage.usageRetention.running": "正在应用…", "storage.usageRetention.saved": "已保存", + "storage.usageRetention.saveBeforeApply": "请先保存这些改动,再立即应用限制。", "storage.usageRetention.disabled": "已关闭", "storage.usageRetention.error": "无法更新 Usage 历史大小限制。", }; @@ -100,6 +106,7 @@ const zhTW: Record = { "storage.usageRetention.saving": "正在儲存…", "storage.usageRetention.running": "正在套用…", "storage.usageRetention.saved": "已儲存", + "storage.usageRetention.saveBeforeApply": "請先儲存這些變更,再立即套用限制。", "storage.usageRetention.disabled": "已關閉", "storage.usageRetention.error": "無法更新 Usage 歷史大小限制。", }; @@ -115,6 +122,7 @@ const ru: Record = { "storage.usageRetention.saving": "Сохранение…", "storage.usageRetention.running": "Применение…", "storage.usageRetention.saved": "Сохранено", + "storage.usageRetention.saveBeforeApply": "Сохраните изменения перед немедленным применением лимита.", "storage.usageRetention.disabled": "Отключено", "storage.usageRetention.error": "Не удалось обновить ограничение размера истории использования.", }; @@ -130,6 +138,7 @@ const ja: Record = { "storage.usageRetention.saving": "保存中…", "storage.usageRetention.running": "適用中…", "storage.usageRetention.saved": "保存しました", + "storage.usageRetention.saveBeforeApply": "今すぐ上限を適用する前に、この変更を保存してください。", "storage.usageRetention.disabled": "無効", "storage.usageRetention.error": "使用履歴のサイズ上限を更新できませんでした。", }; @@ -145,6 +154,7 @@ const tr: Record = { "storage.usageRetention.saving": "Kaydediliyor…", "storage.usageRetention.running": "Uygulanıyor…", "storage.usageRetention.saved": "Kaydedildi", + "storage.usageRetention.saveBeforeApply": "Sınırı şimdi uygulamadan önce bu değişiklikleri kaydedin.", "storage.usageRetention.disabled": "Devre dışı", "storage.usageRetention.error": "Kullanım geçmişi boyut sınırı güncellenemedi.", }; From 4b3f2a7cf39bbe4664a1ffc0c22b7067915f8daa Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:44:43 +0800 Subject: [PATCH 38/61] fix(storage): prevent applying unsaved usage limit --- .../storage-workspace/UsageLedgerRetentionPanel.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx b/gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx index 062c04e9d6..44df939bc6 100644 --- a/gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx +++ b/gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx @@ -75,6 +75,9 @@ export default function UsageLedgerRetentionPanel({ () => Math.max(1, Math.floor(Number.isFinite(limitMiB) ? limitMiB : 1)), [limitMiB], ); + const hasUnsavedChanges = status !== null && ( + enabled !== status.enabled || normalizedLimitMiB * MIB !== status.maxBytes + ); /** Persist policy only; destructive work remains behind scheduler or explicit run. */ const save = async () => { @@ -105,6 +108,7 @@ export default function UsageLedgerRetentionPanel({ /** Request the explicit immediate destructive run, then refresh its job state. */ const applyNow = async () => { + if (hasUnsavedChanges) return; setBusyAction("apply"); setError(null); setMessage(null); @@ -181,7 +185,7 @@ export default function UsageLedgerRetentionPanel({ + {hasUnsavedChanges &&

{t("storage.usageRetention.saveBeforeApply")}

} {status && !status.enabled &&

{t("storage.usageRetention.disabled")}

} {message &&

{message}

} {error &&

{error}

} From 00aa097c2b718f12f36fce0e879f6f770fbeb22a Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:34:53 +0800 Subject: [PATCH 39/61] test: cover usage retention replace publisher --- tests/server/system-routes.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/server/system-routes.test.ts b/tests/server/system-routes.test.ts index 45907fb6ab..7b499c57a2 100644 --- a/tests/server/system-routes.test.ts +++ b/tests/server/system-routes.test.ts @@ -118,6 +118,7 @@ describe("windows replace retry counters", () => { "lab-automation", "lab-ledger", "storage-cleanup", + "usage-retention", "tray", ]; for (const publisher of publishers) renameAtomicFile("a", "b", flakyIo(1), publisher); @@ -130,6 +131,7 @@ describe("windows replace retry counters", () => { "prompt-journal:EBUSY", "storage-cleanup:EBUSY", "tray:EBUSY", + "usage-retention:EBUSY", ]); // @ts-expect-error a path is not a ReplacePublisher renameAtomicFile("a", "b", flakyIo(0), "C:\\Users\\someone\\.opencodex"); From 050b00a8224b17dde0d39979534ec71488d0ca0d Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:35:25 +0800 Subject: [PATCH 40/61] test(usage): cover stale retention policy generation --- tests/usage-ledger-retention-v2.test.ts | 59 +++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/tests/usage-ledger-retention-v2.test.ts b/tests/usage-ledger-retention-v2.test.ts index bc6af15e5f..bbb34f9746 100644 --- a/tests/usage-ledger-retention-v2.test.ts +++ b/tests/usage-ledger-retention-v2.test.ts @@ -3,6 +3,7 @@ import { appendFileSync, existsSync, mkdtempSync, + readdirSync, readFileSync, renameSync, rmSync, @@ -20,7 +21,14 @@ import { usageLedgerRevisionMatches, } from "../src/usage/ledger-retention"; import { parseUsageLedgerRetentionInput } from "../src/usage/ledger-retention-config"; -import { commitPreparedUsageLedgerCompaction } from "../src/usage/ledger-retention-job"; +import { + commitPreparedUsageLedgerCompaction, + getUsageLedgerRetentionJobState, + invalidateUsageLedgerRetentionRun, + requestUsageLedgerRetentionRun, + resetUsageLedgerRetentionJobForTests, +} from "../src/usage/ledger-retention-job"; +import { getConfigPath, getDefaultConfig, saveConfig } from "../src/config"; const homes: string[] = []; @@ -41,10 +49,20 @@ function jsonlRowOfSize(requestId: string, totalBytes: number, fill = "x"): stri return row; } -afterEach(() => { +afterEach(async () => { + await resetUsageLedgerRetentionJobForTests(); for (const dir of homes.splice(0)) rmSync(dir, { recursive: true, force: true }); }); +async function waitForRetentionIdle(timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (getUsageLedgerRetentionJobState().status === "idle") return; + await Bun.sleep(10); + } + throw new Error("timed out waiting for usage ledger retention job"); +} + describe("usage ledger retention v2", () => { test("unknown persisted config keys disable destructive retention", () => { expect(normalizeUsageLedgerRetention({ enabled: true, maxByets: 8 * 1024 * 1024 })).toEqual({ @@ -72,7 +90,7 @@ describe("usage ledger retention v2", () => { }); test("unsafe or below-floor byte limits disable destructive retention", () => { - for (const maxBytes of [Number.MAX_SAFE_INTEGER + 1, MIN_USAGE_LEDGER_MAX_BYTES - 1, 1.5 * MIN_USAGE_LEDGER_MAX_BYTES]) { + for (const maxBytes of [Number.MAX_SAFE_INTEGER + 1, MIN_USAGE_LEDGER_MAX_BYTES - 1, MIN_USAGE_LEDGER_MAX_BYTES + 0.5]) { expect(normalizeUsageLedgerRetention({ enabled: true, maxBytes }).enabled).toBe(false); } }); @@ -288,4 +306,39 @@ describe("usage ledger retention v2", () => { expect(existsSync(prepared.tempPath)).toBe(false); expect(readFileSync(path, "utf8")).toBe(original); }); + + test("invalidating a policy generation prevents a prepared Worker candidate from publishing", async () => { + const dir = home(); + const previousHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = dir; + try { + const maxBytes = MIN_USAGE_LEDGER_MAX_BYTES; + const config = { + ...getDefaultConfig(), + usageLedgerRetention: { enabled: true, maxBytes }, + }; + saveConfig(config); + + const path = join(dir, "usage.jsonl"); + const original = jsonlRowOfSize("old", maxBytes) + jsonlRowOfSize("new", 256); + writeFileSync(path, original); + + const started = requestUsageLedgerRetentionRun(); + expect(started.accepted).toBe(true); + // The generation is invalidated while the Worker is still preparing its read-only + // candidate. The stale result must be discarded before the atomic publish step. + invalidateUsageLedgerRetentionRun(); + await waitForRetentionIdle(); + + expect(readFileSync(path, "utf8")).toBe(original); + expect(getUsageLedgerRetentionJobState().lastOutcome).toBeUndefined(); + expect(readdirSync(dir).filter(name => name.includes(".retention-")).length).toBe(0); + } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + // Keep the config path import exercised against the isolated home and ensure no + // accidental write escaped into the test process's default configuration. + expect(getConfigPath()).not.toBe(join(dir, "config.json")); + } + }); }); From ede183e2fa2d07c6371d020414c6a6d195362c67 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:36:03 +0800 Subject: [PATCH 41/61] fix(config): type and validate usage ledger retention --- scripts/test-layout/layout.json | 1 + src/config.ts | 6 ++ src/types.ts | 1 + src/types/config.ts | 15 +++++ src/usage/ledger-retention-config.ts | 11 +--- src/usage/ledger-retention.ts | 10 +-- .../settings-usage-ledger-retention.test.ts | 66 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 8 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 tests/config/settings-usage-ledger-retention.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d0eee3c739..be44fd0bcd 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1139,6 +1139,7 @@ "settings-oauth-open-browser.test.ts": "config", "settings-startup-health-seam.test.ts": "config", "settings-stream-mode.test.ts": "config", + "settings-usage-ledger-retention.test.ts": "config", "shutdown-drain.test.ts": "service", "shutdown-launcher.test.ts": "service", "sidebar-routes.test.ts": "server", diff --git a/src/config.ts b/src/config.ts index 8da89cbfdf..c2a8d5271f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1208,6 +1208,12 @@ const configSchema = z.object({ enabled: z.boolean().optional(), leadTimeMinutes: z.number().int().min(1).max(60).optional(), }).optional().catch(undefined), + // Opt-in usage.jsonl byte ceiling. Reject unknown nested keys and degrade the + // whole optional section so a misspelled policy can never enable retention. + usageLedgerRetention: z.object({ + enabled: z.boolean().optional(), + maxBytes: z.number().int().min(1024 * 1024).optional(), + }).strict().optional().catch(undefined), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), // Invalid values degrade to undefined ("auto") instead of failing the whole diff --git a/src/types.ts b/src/types.ts index f759406fe0..2e74e8a1d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -61,6 +61,7 @@ export type { OcxClaudeDesktopAssignment, OcxClaudeDesktopProfile, StorageCleanupPolicy, + UsageLedgerRetentionConfig, OcxCustomModel, OcxApiKeyEntry, OcxClientIntegrationsConfig, diff --git a/src/types/config.ts b/src/types/config.ts index fc9a55a8fa..78bb28257b 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -180,6 +180,19 @@ export interface StorageCleanupPolicy { nextRun?: number; } +/** + * Opt-in byte ceiling for the canonical `usage.jsonl` ledger. + * Persisted under `OcxConfig.usageLedgerRetention`; the feature is disabled by default. + * When enabled, older complete JSONL rows are dropped permanently so the file stays within + * `maxBytes`. The derived routing-history SQLite projection is disposable and rebuilt later. + */ +export interface UsageLedgerRetentionConfig { + /** When false/unset, the ledger is never rewritten. Default false. */ + enabled?: boolean; + /** Keep the newest complete JSONL rows within this many bytes. Floor 1 MiB. */ + maxBytes?: number; +} + /** 사용자가 대시보드에서 직접 추가한 커스텀 모델 정의. */ export interface OcxCustomModel { /** 고유 ID (crypto.randomUUID()) */ @@ -680,6 +693,8 @@ export interface OcxConfig { * See `src/storage/policy.ts`. */ storageCleanupPolicy?: StorageCleanupPolicy; + /** Opt-in cap for `usage.jsonl` and its disposable SQLite projection. Default OFF. */ + usageLedgerRetention?: UsageLedgerRetentionConfig; /** Generated API keys for external access to the proxy's /v1/responses endpoint. */ apiKeys?: OcxApiKeyEntry[]; /** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */ diff --git a/src/usage/ledger-retention-config.ts b/src/usage/ledger-retention-config.ts index f9ad0c3d94..eee6f582d9 100644 --- a/src/usage/ledger-retention-config.ts +++ b/src/usage/ledger-retention-config.ts @@ -6,14 +6,9 @@ import { DEFAULT_USAGE_LEDGER_MAX_BYTES, MIN_USAGE_LEDGER_MAX_BYTES, normalizeUsageLedgerRetention, - type PersistedUsageLedgerRetention, type UsageLedgerRetention, } from "./ledger-retention"; -type ConfigWithUsageLedgerRetention = OcxConfig & { - usageLedgerRetention?: PersistedUsageLedgerRetention; -}; - export type UsageLedgerRetentionStatus = UsageLedgerRetention & { currentBytes: number; overLimit: boolean; @@ -21,7 +16,7 @@ export type UsageLedgerRetentionStatus = UsageLedgerRetention & { /** Read the opt-in policy from config. Unknown/malformed persisted keys fail closed. */ export function readUsageLedgerRetentionFromConfig(config?: OcxConfig): UsageLedgerRetention { - const source = (config ?? loadConfig()) as ConfigWithUsageLedgerRetention; + const source = config ?? loadConfig(); return normalizeUsageLedgerRetention(source.usageLedgerRetention); } @@ -69,7 +64,7 @@ export function writeUsageLedgerRetentionToConfig(policy: UsageLedgerRetention): enabled: policy.enabled, maxBytes: policy.maxBytes, }); - const config = loadConfig() as ConfigWithUsageLedgerRetention; + const config = loadConfig(); config.usageLedgerRetention = { enabled: normalized.enabled, maxBytes: normalized.maxBytes, @@ -83,7 +78,7 @@ export function applyUsageLedgerRetentionToLiveConfig( config: OcxConfig, policy: UsageLedgerRetention, ): void { - (config as ConfigWithUsageLedgerRetention).usageLedgerRetention = { + config.usageLedgerRetention = { enabled: policy.enabled, maxBytes: policy.maxBytes, }; diff --git a/src/usage/ledger-retention.ts b/src/usage/ledger-retention.ts index 979c479b20..847ddc9835 100644 --- a/src/usage/ledger-retention.ts +++ b/src/usage/ledger-retention.ts @@ -10,15 +10,17 @@ import { writeSync, } from "node:fs"; +import type { UsageLedgerRetentionConfig } from "../types/config"; + export const DEFAULT_USAGE_LEDGER_MAX_BYTES = 512 * 1024 * 1024; export const MIN_USAGE_LEDGER_MAX_BYTES = 1024 * 1024; const SCAN_CHUNK_BYTES = 1024 * 1024; /** Persisted, user-authored config. Every key is optional on disk. */ -export interface PersistedUsageLedgerRetention { - enabled?: boolean; - maxBytes?: number; -} +export type PersistedUsageLedgerRetention = UsageLedgerRetentionConfig; + +/** Compatibility alias for the first-class persisted OcxConfig section. */ +export type PersistedUsageLedgerRetentionConfig = UsageLedgerRetentionConfig; /** Fully normalized policy used by the mutation path. */ export interface UsageLedgerRetention { diff --git a/tests/config/settings-usage-ledger-retention.test.ts b/tests/config/settings-usage-ledger-retention.test.ts new file mode 100644 index 0000000000..87a12796fa --- /dev/null +++ b/tests/config/settings-usage-ledger-retention.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + getConfigPath, + getDefaultConfig, + loadConfig, + saveConfig, + validateConfigCandidate, +} from "../../src/config"; + +let testHome = ""; +const previousOpenCodexHome = process.env.OPENCODEX_HOME; + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), "ocx-usage-ledger-config-")); + process.env.OPENCODEX_HOME = testHome; +}); + +afterEach(() => { + if (previousOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpenCodexHome; + rmSync(testHome, { recursive: true, force: true }); +}); + +test("usageLedgerRetention is accepted as a first-class config section", () => { + const candidate = { + ...getDefaultConfig(), + usageLedgerRetention: { enabled: true, maxBytes: 8 * 1024 * 1024 }, + }; + + const result = validateConfigCandidate(candidate); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.usageLedgerRetention).toEqual(candidate.usageLedgerRetention); + } +}); + +test("a malformed usageLedgerRetention section degrades without dropping providers", () => { + saveConfig({ + ...getDefaultConfig(), + usageLedgerRetention: { enabled: true, maxBytes: 8 * 1024 * 1024 }, + }); + const raw = JSON.parse(readFileSync(getConfigPath(), "utf8")) as Record; + raw.usageLedgerRetention = { enabled: true, maxByets: 8 * 1024 * 1024 }; + writeFileSync(getConfigPath(), JSON.stringify(raw, null, 2), "utf8"); + + const loaded = loadConfig(); + + expect(loaded.usageLedgerRetention).toBeUndefined(); + expect(loaded.providers.openai).toBeDefined(); +}); + +test("partial usageLedgerRetention config remains valid for hand-edited files", () => { + saveConfig({ + ...getDefaultConfig(), + usageLedgerRetention: { enabled: true, maxBytes: 8 * 1024 * 1024 }, + }); + const raw = JSON.parse(readFileSync(getConfigPath(), "utf8")) as Record; + raw.usageLedgerRetention = { enabled: true }; + writeFileSync(getConfigPath(), JSON.stringify(raw, null, 2), "utf8"); + + expect(loadConfig().usageLedgerRetention).toEqual({ enabled: true }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 568fd6f6ee..f8359180f9 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -974,6 +974,7 @@ "settings-oauth-open-browser.test.ts": "config", "settings-startup-health-seam.test.ts": "config", "settings-stream-mode.test.ts": "config", + "settings-usage-ledger-retention.test.ts": "config", "shutdown-drain.test.ts": "service", "shutdown-launcher.test.ts": "service", "sidebar-routes.test.ts": "server", From 71694efcff7768d90b0fde324fd6c7effe209faf Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:45:48 +0800 Subject: [PATCH 42/61] docs(cli): document usage ledger retention surface --- .../src/content/docs/guides/web-dashboard.md | 2 +- .../src/content/docs/reference/cli/agents.md | 18 +++++++++++ .../docs/reference/configuration/server.md | 32 +++++++++++++++++++ .../content/docs/reference/management-api.md | 11 +++++++ skills/ocx/SKILL.md | 2 +- .../ocx/references/01_management_surface.md | 27 ++++++++++++++-- skills/ocx/references/02_json_shapes.md | 14 +++++++- skills/ocx/references/03_recipes.md | 11 +++++++ src/cli/help.ts | 2 +- src/cli/registry.ts | 5 +-- 10 files changed, 116 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 72adcdcd70..3ad786b132 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -93,7 +93,7 @@ badge or the version value to read the full value. | **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose v1/base/v2, and configure the v2 thread limit. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. | | **Logs** | Auto-refresh recent requests with tokens, requested effort and (when available) effective outbound effort, resolved model, provider, status, request id, duration, and error details. The detail view includes the exact reasoning wire field when the adapter emits one. Filter by opaque conversation/session id (when the client sends one) to total tokens and estimated list-price cost for the currently loaded Logs ring. | | **Usage / Debug** | Inspect token-usage coverage and trends, or enable opt-in provider transport and usage-extraction diagnostics. | -| **Storage** | Read-only CODEX_HOME disk breakdown (sessions, archives, DBs, attachments). Optional archived cleanup: preview the oldest N%, then quarantine to `CODEX_HOME/.trash` (default) or permanently delete behind an explicit checkbox. **Auto-cleanup policy** is opt-in and **default OFF** (`storageCleanupPolicy.enabled`); configure threshold/target/schedule/mode on the Storage page, or trigger **Run now**. Quarantined entries can be restored from the Storage page (JSONL + threads). Active sessions stay read-only. Cleanup and restore are refused while Codex holds the newest/active `state_*.sqlite` locked. | +| **Storage** | Read-only CODEX_HOME disk breakdown (sessions, archives, DBs, attachments). Optional archived cleanup: preview the oldest N%, then quarantine to `CODEX_HOME/.trash` (default) or permanently delete behind an explicit checkbox. **Auto-cleanup policy** is opt-in and **default OFF** (`storageCleanupPolicy.enabled`); configure threshold/target/schedule/mode on the Storage page, or trigger **Run now**. **Usage history retention** is a separate opt-in ceiling for `usage.jsonl` (`usageLedgerRetention.enabled` / `maxBytes`); Save persists the limit, while **Apply now** starts compaction only when there are no unsaved edits. Quarantined entries can be restored from the Storage page (JSONL + threads). Active sessions stay read-only. Cleanup and restore are refused while Codex holds the newest/active `state_*.sqlite` locked. | | **Stop** | Gracefully stop the proxy and installed background service, restore native Codex, and exit (`POST /api/stop`). On Windows with the Task Scheduler backend the dashboard refuses and asks you to run `ocx stop` instead: that wrapper can respawn the proxy after the task ends, and only a stop running outside this process can verify the restart window before restoring your client config. Nothing is changed when it refuses. | ### Account selection diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index ab96881d11..369695fe74 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -162,6 +162,24 @@ separately, and requests with no matching price row are counted as ocx usage --range today --provider xai ``` +### `ocx storage usage-limit` + +Inspect or change the opt-in `usage.jsonl` size ceiling, or start an explicit compaction run. +The setting is also available in the dashboard under **Storage → Usage history**. + +```bash +ocx storage usage-limit show --json +ocx storage usage-limit set --enabled true --mib 512 --json +ocx storage usage-limit run --yes --json +``` + +`set` sends only the fields supplied, so changing `--mib` preserves the saved enabled state. +The minimum ceiling is 1 MiB. A bare `usage-limit` invocation is read-only. The background +scheduler compacts complete JSONL rows after the ledger exceeds the configured ceiling; `run` +requests an immediate compaction and requires `--yes` because older usage rows are permanently +removed. The command drives `GET`/`PUT /api/storage/usage-ledger-retention` and +`POST /api/storage/usage-ledger-retention/run` on the running proxy. + ### `ocx debug ` Read or change runtime debug overrides through the running proxy's management API. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index cdc8d6af52..63f58dded0 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -23,6 +23,7 @@ runs helper features around provider requests. | `corsAllowOrigins?` | `string[]` | `[]` | Additional exact origins allowed by CORS. Loopback origins are always allowed. Authority-based browser extension origins such as `chrome-extension://` are supported; `*` is not a wildcard. Firefox and Safari regenerate the extension UUID (per install / per browser launch), so update the entry when the origin changes. | | `apiKeys?` | `OcxApiKey[]` | `[]` | Generated `ocx_…` credentials accepted by management and data-plane auth on non-loopback binds. Dashboard-managed. | | `storageCleanupPolicy?` | `StorageCleanupPolicy` | disabled | Opt-in archived-session cleanup policy. Never enabled implicitly. | +| `usageLedgerRetention?` | `UsageLedgerRetention` | disabled | Opt-in cap for the append-only `usage.jsonl` usage ledger. When enabled, the background scheduler compacts complete rows after the ledger exceeds `maxBytes`. | | `appOwnedMemoryBudgetMb?` | `number` | `256` | Cap in MiB for evictable app-owned logs, caches, blobs, and continuation payloads. Range 64–4096; not an RSS cap. | | `codexAutoStart?` | `boolean` | `true` | Let the Codex shim run `ocx ensure` before launching Codex. False makes ensure a no-op. | | `codexShimAutoRestore?` | `boolean` | `true` | Restore an installed shim after a completed external Codex update replaces it. Environment opt-out: `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | @@ -221,6 +222,37 @@ either `target.reduceToBytes` or `target.removeOldestPercent`. `mode` defaults t Configure it on the Storage page or with `GET`/`PUT /api/storage/cleanup-policy`; trigger a manual run with `POST /api/storage/cleanup-policy/run`. +## Usage-ledger retention + +`usageLedgerRetention` is disabled by default. It is an opt-in size ceiling for +`$OPENCODEX_HOME/usage.jsonl`, the append-only usage ledger used by the Usage page and +`GET /api/usage`. Enabling it lets the proxy compact older rows in a background Worker once +the file exceeds the configured limit; normal request handling is not blocked by the scan. + +```json +{ + "usageLedgerRetention": { + "enabled": true, + "maxBytes": 536870912 + } +} +``` + +`maxBytes` defaults to 512 MiB when omitted and must be a safe integer of at least 1 MiB +(`1048576`). The saved ceiling is retained when `enabled` is set to `false`, so an operator can +pause retention without losing the selected limit. Unknown keys and malformed values fail closed +and leave retention disabled. + +Compaction publishes a complete JSONL-row candidate only after the source revision and active-turn +checks still match. An unterminated crash tail is discarded; a single row larger than the ceiling +is dropped so the published ledger remains bounded. The derived request-history projection is +recreated after a successful publish. A policy change invalidates an in-flight candidate, and a +source append during scanning defers the commit for a later run. + +The dashboard exposes the same status under **Storage → Usage history**. For headless operation, +use `ocx storage usage-limit` or the management routes below. Setting the policy is non-destructive; +only an explicit manual run removes older rows. + ## Quota-reset notifications (`quotaResetNotify`) Off by default. When the section is absent, no detection runs, no timer starts, and no state diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index c0dd38f1fb..fd0d424e72 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -184,6 +184,9 @@ by the current window size. | `GET /api/claude/inbound-debug` | Read Claude inbound debug state and entries | — | | `GET /api/usage` | Stream the complete usage ledger into compact aggregates, then incrementally fold verified appends; summarize by preset or inclusive custom window and client surface, with a Codex `accounts` breakdown keyed by stable non-PII log labels | 400 invalid custom bounds; returns an `error: "read_failed"` summary if storage cannot be read | | `GET /api/storage` | Scan Codex storage usage by bucket | Returns an `error: "scan_failed"` payload on scan failure | +| `GET /api/storage/usage-ledger-retention` | Read the usage-ledger retention policy, current `usage.jsonl` size, over-limit state, and the last background job state | — | +| `PUT /api/storage/usage-ledger-retention` | Replace the retention fields supplied in `{ "enabled"?: boolean, "maxBytes"?: integer }`; omitted fields keep their saved values | 400 malformed body, unknown field, or `maxBytes` below 1 MiB; 500 `config_write_failed` | +| `POST /api/storage/usage-ledger-retention/run` | Start one immediate compaction when the policy is enabled | 202 `{ "ok": true, "started": true }`; 409 `retention_disabled` or `already_running` | | `POST /api/storage/cleanup/preview` | Preview archived-session cleanup and return a binding digest | 400 `invalid_json` or `invalid_percent` | | `POST /api/storage/cleanup` | Quarantine or permanently remove the previewed archived set | 400 invalid input; 409 stale/busy/referenced state; 500 filesystem/database failure | | `GET /api/storage/trash` | List quarantined cleanup entries | 500 `trash_list_failed` | @@ -193,6 +196,14 @@ by the current window size. | `POST /api/storage/cleanup-policy/run` | Start a manual cleanup-policy run | 409 `already_running`; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | Test-only policy stream hook | 404 `not_found` when unavailable | +The retention status response is shaped as `{ enabled, maxBytes, currentBytes, overLimit, job }`; +`job` reports the process-local background state (`idle` or `running`) and the last outcome when +one exists. `PUT` accepts only `enabled` and `maxBytes`, and merges the supplied fields with the +saved policy. It never starts a compaction by itself. A successful `POST .../run` queues a Worker +and returns `202`; the canonical ledger is replaced only after complete-row, active-turn, and +source-revision checks pass. If the policy is disabled, or another run already owns the Worker, +the route returns `409` without changing the ledger. + New xAI attempts in `usage.jsonl` include a request-time `credentialSource`: `grok-oauth` for the resolved Grok CLI OAuth transport, or `xai-api-key` for the public xAI API key transport. This fixed label contains no credential or account identifier. It belongs to diff --git a/skills/ocx/SKILL.md b/skills/ocx/SKILL.md index a5f19d861f..7f99c4bada 100644 --- a/skills/ocx/SKILL.md +++ b/skills/ocx/SKILL.md @@ -113,7 +113,7 @@ but still require authority for their state changes. Follow ## Destructive verbs -`storage trash restore` and `storage policy run` refuse without `--yes` (exit 2, nothing sent). +`storage trash restore`, `storage policy run`, and `storage usage-limit run` refuse without `--yes` (exit 2, nothing sent). `storage cleanup` without `--yes` is a preview that exits 0 having mutated nothing — do not treat that 0 as a delete. There is no interactive prompt. diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 512aa3a7e2..25bcc9f2f4 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -583,6 +583,29 @@ JSON mode: `payload`. - `policy set` never enables implicitly: omitting `--enabled` keeps the stored value. - `policy run` forces a run regardless of schedule, so it needs `--yes`. +### `ocx storage usage-limit` + +Show, change, or run the usage-history size limit. + +| Method | Route | +|---|---| +| GET | `/api/storage/usage-ledger-retention` | +| PUT | `/api/storage/usage-ledger-retention` | +| POST | `/api/storage/usage-ledger-retention/run` | + +| Flag | Value | Meaning | +|---|---|---| +| `--enabled` | string | true or false. | +| `--mib` | number | Maximum usage-ledger size in MiB; minimum 1. | +| `--yes` | boolean | Required for `usage-limit run`, which permanently removes older history. | +| `--json` | boolean | Emit the policy, status, or run state as JSON. | + +JSON mode: `payload`. + +- The limit is opt-in; a bare invocation only reads status. +- Changing the MiB value without `--enabled` preserves the saved enabled state. +- A manual run permanently removes older usage rows, so it requires `--yes`. + ### `ocx system codex-restart` Restart the Codex app-server. @@ -687,6 +710,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 37 -- of those, state-changing: 16 +- declared capabilities: 38 +- of those, state-changing: 17 - head-resolved invocations: 2 diff --git a/skills/ocx/references/02_json_shapes.md b/skills/ocx/references/02_json_shapes.md index 261c8be5a2..5a9076ab28 100644 --- a/skills/ocx/references/02_json_shapes.md +++ b/skills/ocx/references/02_json_shapes.md @@ -114,6 +114,19 @@ The value returned is the **applied** one after server normalization, not what y `digest` binds a run to this preview; the mutating call must carry it and the server rejects a stale one with 409. The CLI handles that for you — it always previews first. +## `ocx storage usage-limit --json` + +The status response is the management payload: + +```json +{"enabled":false,"maxBytes":536870912,"currentBytes":67108864,"overLimit":false,"job":{"status":"idle"}} +``` + +`set` returns the same fields with `ok: true`; it merges only the fields supplied by +`--enabled` and `--mib`. `run --yes` returns `{ "ok": true, "started": true, ... }` with HTTP +202 when a Worker is queued. A disabled policy or an already-running Worker is a named 409, not +an indication that the ledger was changed. + ## Error shape A management error prints up to three lines and returns a non-zero code: @@ -127,4 +140,3 @@ hint: Branch on `reason` in those stderr lines, never on the message prose. `--json` does **not** wrap API failures in `{error:{type,code,message}}`; `runCliAction` still prints the three-liner on stderr and returns 4/5/1. Do not parse stdout for an error envelope that is not there. - diff --git a/skills/ocx/references/03_recipes.md b/skills/ocx/references/03_recipes.md index 9159751424..ff1ad33d4c 100644 --- a/skills/ocx/references/03_recipes.md +++ b/skills/ocx/references/03_recipes.md @@ -214,6 +214,17 @@ ocx storage trash restore --yes --json The preview runs in both paths because the mutating route requires the `digest` the preview returns and rejects a stale one with 409. So the two invocations agree about what is being authorized. +For the append-only usage ledger, inspect the saved ceiling before changing it: + +```bash +ocx storage usage-limit show --json +ocx storage usage-limit set --enabled true --mib 512 --json +``` + +The scheduler compacts complete `usage.jsonl` rows in the background once `maxBytes` is exceeded. +To request an immediate compaction, use `ocx storage usage-limit run --yes --json`; it permanently +removes older usage rows and refuses without the explicit confirmation flag. + ## 9. Read Muse Code usage, and know why it can be old `meta-muse` reports usage differently from every other provider, and the difference changes what diff --git a/src/cli/help.ts b/src/cli/help.ts index 43916695b6..0c77ef6e06 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -75,7 +75,7 @@ Usage: ocx logs [filters] Alias of ocx observe logs ocx usage [--range ] [--provider ] [--model ] Token and estimated-cost report (alias of ocx observe usage) - ocx storage Storage report, cleanup, trash, and the cleanup policy + ocx storage Storage report, cleanup, trash, cleanup policy, and usage retention ocx memory [--json] Alias of ocx observe memory ocx api-key Alias of ocx access key ocx access External API keys and endpoint information diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 73bbd68e31..1e32739dd6 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -267,11 +267,12 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "storage", - usage: "ocx storage ...", - summary: "Storage report, archived-session cleanup, trash restore, and the cleanup policy.", + usage: "ocx storage ...", + summary: "Storage report, archived-session cleanup, trash restore, cleanup policy, and usage-ledger retention.", details: [ "A bare `ocx storage` prints the report, as it did when this was an alias of `observe storage`.", "`cleanup` previews by default and only deletes under --yes; `trash restore` and `policy run` also require --yes.", + "`usage-limit` shows or changes the opt-in usage-history ceiling; `usage-limit run` requires --yes.", ], }, { name: "memory", usage: "ocx memory [--json]", summary: "Alias of ocx observe memory." }, From e100054701783fcb050a34f4e7ab21c23eedee13 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:01:26 +0800 Subject: [PATCH 43/61] fix(gui): localize retention units and type fallback --- .../UsageLedgerRetentionPanel.tsx | 10 ++++++++-- gui/src/i18n/provider.tsx | 5 ++++- gui/src/i18n/usage-retention-translations.ts | 20 +++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx b/gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx index 44df939bc6..97362f5ad0 100644 --- a/gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx +++ b/gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx @@ -55,6 +55,10 @@ export default function UsageLedgerRetentionPanel({ useEffect(() => { const controller = new AbortController(); + // `load` awaits the management API before committing its snapshot, so this + // is an external subscription update rather than a synchronous render + // cascade. Keep the initial fetch in the effect to preserve cancellation. + // eslint-disable-next-line react-hooks/set-state-in-effect, react/react-compiler void load(controller.signal).catch(errorValue => { if ((errorValue as { name?: string })?.name !== "AbortError") { setError(t("storage.usageRetention.error")); @@ -163,7 +167,7 @@ export default function UsageLedgerRetentionPanel({ aria-label={t("storage.usageRetention.limit")} style={{ width: 96 }} /> - MiB + {t("storage.usageRetention.unitMiB")} @@ -176,7 +180,9 @@ export default function UsageLedgerRetentionPanel({ disabled={busy} onClick={() => setLimitMiB(value)} > - {value >= 1024 ? `${value / 1024} GiB` : `${value} MiB`} + {value >= 1024 + ? `${value / 1024} ${t("storage.usageRetention.unitGiB")}` + : `${value} ${t("storage.usageRetention.unitMiB")}`} ))} - ))} +
+
+ {PRESETS_MIB.map(value => ( + + ))} +
diff --git a/gui/src/styles-storage-workspace.css b/gui/src/styles-storage-workspace.css index 1585ac0f56..3378a5daba 100644 --- a/gui/src/styles-storage-workspace.css +++ b/gui/src/styles-storage-workspace.css @@ -235,6 +235,113 @@ padding: 4px 0 12px; } +/* Usage-ledger retention stays on one compact control line when there is room. + The number field remains the exact-value control; the native range is a quick + way to move through the usual sizes without turning presets into a second + row of button chrome. */ +.storage-retention-controls { + display: flex; + align-items: center; + gap: 10px 16px; + flex-wrap: wrap; + min-width: 0; +} + +.storage-retention-current, +.storage-retention-enable { + display: inline-flex; + align-items: center; + gap: 7px; + flex: 0 0 auto; + min-height: var(--control-sm); + white-space: nowrap; +} + +.storage-retention-enable { + cursor: pointer; +} + +.storage-retention-enable:has(input:disabled) { + cursor: default; +} + +.storage-retention-limit { + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 20rem; + min-width: min(100%, 15rem); +} + +.storage-retention-range { + flex: 1 1 auto; + min-width: 6rem; + accent-color: var(--accent); +} + +.storage-retention-number { + display: inline-flex; + align-items: center; + gap: 5px; + flex: 0 0 auto; +} + +.storage-retention-number input { + width: 5.5rem; + padding: 5px 8px; + font-variant-numeric: tabular-nums; +} + +.storage-retention-actions { + gap: 8px 12px; + margin-top: 6px; +} + +.storage-retention-presets { + display: inline-flex; + align-items: center; + gap: 2px; + flex: 1 1 auto; + min-width: 0; + flex-wrap: wrap; +} + +.storage-retention-preset { + appearance: none; + border: 0; + border-radius: var(--radius-pill); + background: transparent; + color: var(--muted); + cursor: pointer; + font: inherit; + font-size: var(--text-label); + line-height: var(--leading-ui); + padding: 4px 7px; + white-space: nowrap; + transition: background var(--motion-fast), color var(--motion-fast); +} + +.storage-retention-preset:hover:not(:disabled) { + background: var(--accent-soft); + color: var(--text); +} + +.storage-retention-preset.active { + background: var(--accent-soft); + color: var(--text); + font-weight: var(--weight-semibold); +} + +.storage-retention-preset:focus-visible { + outline: 2px solid var(--accent-ring); + outline-offset: 1px; +} + +.storage-retention-preset:disabled { + cursor: default; + opacity: 0.5; +} + /* Largest-files rows — flat list, no card-in-card */ .stw-file-row { display: flex; diff --git a/src/usage/ledger-retention.ts b/src/usage/ledger-retention.ts index 847ddc9835..94dc15b7b0 100644 --- a/src/usage/ledger-retention.ts +++ b/src/usage/ledger-retention.ts @@ -10,18 +10,10 @@ import { writeSync, } from "node:fs"; -import type { UsageLedgerRetentionConfig } from "../types/config"; - export const DEFAULT_USAGE_LEDGER_MAX_BYTES = 512 * 1024 * 1024; export const MIN_USAGE_LEDGER_MAX_BYTES = 1024 * 1024; const SCAN_CHUNK_BYTES = 1024 * 1024; -/** Persisted, user-authored config. Every key is optional on disk. */ -export type PersistedUsageLedgerRetention = UsageLedgerRetentionConfig; - -/** Compatibility alias for the first-class persisted OcxConfig section. */ -export type PersistedUsageLedgerRetentionConfig = UsageLedgerRetentionConfig; - /** Fully normalized policy used by the mutation path. */ export interface UsageLedgerRetention { enabled: boolean; From f469735ff6218b549ac5aace91181afc47c3e9ec Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:11:28 +0800 Subject: [PATCH 46/61] fix(gui): label retention preset controls --- .../storage-workspace/UsageLedgerRetentionPanel.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx b/gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx index a4ab33767f..57c6983318 100644 --- a/gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx +++ b/gui/src/components/storage-workspace/UsageLedgerRetentionPanel.tsx @@ -191,7 +191,11 @@ export default function UsageLedgerRetentionPanel({
-
+
{PRESETS_MIB.map(value => (
- - {displayedLogGuard ? ( (null); - const [enabled, setEnabled] = useState(false); - const [limitMiB, setLimitMiB] = useState(512); - const [busyAction, setBusyAction] = useState<"save" | "apply" | null>(null); - const [message, setMessage] = useState(null); - const [error, setError] = useState(null); - - /** Refresh policy, byte usage, and current retention-job state from management API. */ - const load = useCallback(async (signal?: AbortSignal) => { - const response = await fetch(`${apiBase}/api/storage/usage-ledger-retention`, { signal }); - if (!response.ok) throw new Error("load_failed"); - const next = await response.json() as RetentionStatus; - setStatus(next); - setEnabled(next.enabled); - setLimitMiB(Math.max(1, Math.round(next.maxBytes / MIB))); - return next; - }, [apiBase]); - - useEffect(() => { - const controller = new AbortController(); - const timeout = window.setTimeout(() => { - void load(controller.signal).catch(errorValue => { - if ((errorValue as { name?: string })?.name !== "AbortError") { - setError(t("storage.usageRetention.error")); - } - }); - }, 0); - return () => { - window.clearTimeout(timeout); - controller.abort(); - }; - }, [load, t]); - - useEffect(() => { - if (status?.job.status !== "running") return; - const timer = window.setInterval(() => { - void load().catch(() => undefined); - }, 750); - return () => window.clearInterval(timer); - }, [load, status?.job.status]); - - const normalizedLimitMiB = useMemo( - () => Math.max(1, Math.floor(Number.isFinite(limitMiB) ? limitMiB : 1)), - [limitMiB], - ); - const hasUnsavedChanges = status !== null && ( - enabled !== status.enabled || normalizedLimitMiB * MIB !== status.maxBytes - ); - - /** Persist policy only; destructive work remains behind scheduler or explicit run. */ - const save = async () => { - setBusyAction("save"); - setError(null); - setMessage(null); - try { - const response = await fetch(`${apiBase}/api/storage/usage-ledger-retention`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - enabled, - maxBytes: normalizedLimitMiB * MIB, - }), - }); - if (!response.ok) throw new Error("save_failed"); - const next = await response.json() as RetentionStatus; - setStatus(next); - setEnabled(next.enabled); - setLimitMiB(Math.max(1, Math.round(next.maxBytes / MIB))); - setMessage(t("storage.usageRetention.saved")); - } catch { - setError(t("storage.usageRetention.error")); - } finally { - setBusyAction(null); - } - }; - - /** Request the explicit immediate destructive run, then refresh its job state. */ - const applyNow = async () => { - if (hasUnsavedChanges) return; - setBusyAction("apply"); - setError(null); - setMessage(null); - try { - const response = await fetch(`${apiBase}/api/storage/usage-ledger-retention/run`, { - method: "POST", - }); - if (!response.ok && response.status !== 409) throw new Error("run_failed"); - await load(); - } catch { - setError(t("storage.usageRetention.error")); - } finally { - setBusyAction(null); - } - }; - - const busy = busyAction !== null; - const jobRunning = status?.job.status === "running"; - - return ( -
-

{t("storage.usageRetention.title")}

-

{t("storage.usageRetention.help")}

- -
-
- {t("storage.usageRetention.current")} - - {status ? formatBytes(status.currentBytes, locale) : "—"} - -
- - - -
- {t("storage.usageRetention.limit")} - setLimitMiB(Number(event.target.value))} - aria-label={t("storage.usageRetention.limit")} - /> - - setLimitMiB(Number(event.target.value))} - aria-label={t("storage.usageRetention.limit")} - /> - {t("storage.usageRetention.unitMiB")} - -
-
- -
-
- {PRESETS_MIB.map(value => ( - - ))} -
- - -
- - {hasUnsavedChanges &&

{t("storage.usageRetention.saveBeforeApply")}

} - {status && !status.enabled &&

{t("storage.usageRetention.disabled")}

} - {message &&

{message}

} - {error &&

{error}

} -
- ); -} diff --git a/gui/src/components/usage/UsageLedgerRetentionControl.tsx b/gui/src/components/usage/UsageLedgerRetentionControl.tsx new file mode 100644 index 0000000000..23535b78de --- /dev/null +++ b/gui/src/components/usage/UsageLedgerRetentionControl.tsx @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { formatBytes } from "../../format-bytes"; +import { useI18n } from "../../i18n/shared"; +import { Select, Switch } from "../../ui"; + +const MIB = 1024 ** 2; +const UNLIMITED_OPTION = "unlimited"; +const CUSTOM_OPTION = "custom"; +const COMMON_LIMITS_MIB = [128, 512, 1024, 2048] as const; + +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 limitMiBFromBytes(bytes: number): number | null { + if (!Number.isFinite(bytes) || bytes <= 0) return null; + const value = Math.round(bytes / MIB); + return Number.isSafeInteger(value) && value > 0 ? value : null; +} + +function parseCustomLimit(raw: string): number | null { + const value = Number(raw.replace(/[_,\s]/g, "")); + return Number.isSafeInteger(value) && value > 0 ? value : null; +} + +/** + * Compact Usage-page control for the opt-in usage-ledger byte ceiling. + * + * The server status is the only policy source. Selecting Unlimited or a common + * value persists immediately; Custom is the sole two-step path so an input can + * be checked before it is sent. The switch is a convenient reflection/shortcut + * to turn the same `enabled` value off, not a second draft state; bounded values + * are enabled through the Select so Unlimited remains the only off state. + */ +export default function UsageLedgerRetentionControl({ apiBase }: { apiBase: string }) { + const { locale, t } = useI18n(); + const [status, setStatus] = useState(null); + const [customOpen, setCustomOpen] = useState(false); + const [customDraft, setCustomDraft] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async (signal?: AbortSignal) => { + 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) return; + setStatus(next); + }, [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.error")); + } + }); + }, 0); + return () => { + window.clearTimeout(timeout); + controller.abort(); + }; + }, [load, t]); + + const limitMiB = status ? limitMiBFromBytes(status.maxBytes) : null; + const enabled = status?.enabled === true; + // Until GET resolves (and whenever the policy is off), the visible value is + // explicitly Unlimited. This avoids inventing a 512 MiB default in the UI. + const selectedValue = !enabled + ? UNLIMITED_OPTION + : customOpen + ? CUSTOM_OPTION + : limitMiB === null + ? CUSTOM_OPTION + : String(limitMiB); + const commonLimitSet = useMemo(() => new Set(COMMON_LIMITS_MIB), []); + const options = useMemo(() => [ + { value: UNLIMITED_OPTION, label: t("usage.retention.unlimited") }, + ...(enabled && limitMiB !== null && !commonLimitSet.has(limitMiB) && !customOpen + ? [{ value: String(limitMiB), label: formatBytes(limitMiB * MIB, locale) }] + : []), + ...COMMON_LIMITS_MIB.map(value => ({ value: String(value), label: formatBytes(value * MIB, locale) })), + { value: CUSTOM_OPTION, label: t("models.custom") }, + ], [commonLimitSet, customOpen, enabled, limitMiB, locale, t]); + + const persist = useCallback(async (nextEnabled: boolean, nextLimitMiB: 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: nextLimitMiB * MIB }), + }); + if (!response.ok) throw new Error("save_failed"); + setStatus(parseStatus(await response.json())); + setCustomOpen(false); + } catch { + setError(t("usage.retention.error")); + } finally { + setBusy(false); + } + }, [apiBase, t]); + + const switchEnabled = () => { + if (!status || !enabled || limitMiB === null || busy) return; + void persist(false, limitMiB); + }; + + const selectLimit = (value: string) => { + if (!status || busy) return; + setError(null); + if (value === UNLIMITED_OPTION) { + if (enabled && limitMiB !== null) void persist(false, limitMiB); + return; + } + if (value === CUSTOM_OPTION) { + setCustomOpen(true); + // A disabled policy is Unlimited, so do not surface the compatibility + // fallback ceiling as a made-up custom default. Bounded values can still + // be selected explicitly from the list before opening Custom. + setCustomDraft(enabled && limitMiB !== null ? String(limitMiB) : ""); + return; + } + const nextLimitMiB = parseCustomLimit(value); + if (nextLimitMiB !== null) void persist(true, nextLimitMiB); + }; + + const applyCustom = () => { + const nextLimitMiB = parseCustomLimit(customDraft); + if (nextLimitMiB === null) { + setError(t("usage.retention.error")); + return; + } + void persist(true, nextLimitMiB); + }; + + const controlsDisabled = busy || status === null || limitMiB === null; + + return ( +
+
+
+

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

+

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

+
+ + {t("usage.retention.current")}: {status?.currentBytes === undefined ? "—" : formatBytes(status.currentBytes, locale)} + +
+ +
+ + {t("usage.retention.limit")} + setCustomDraft(event.target.value)} + onKeyDown={event => { if (event.key === "Enter") applyCustom(); }} + disabled={busy} + aria-label={t("usage.retention.limit")} + /> + + + )} +
+ + {!enabled && status &&

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

} + {error &&

{error}

} +
+ ); +} diff --git a/gui/src/i18n/catalogs.ts b/gui/src/i18n/catalogs.ts index 3631cc9223..461f97448c 100644 --- a/gui/src/i18n/catalogs.ts +++ b/gui/src/i18n/catalogs.ts @@ -8,27 +8,22 @@ import { ru } from "./ru"; import { ja } from "./ja"; import { tr } from "./tr"; import { LAB_CATALOG_OVERRIDES, type LabLocale } from "./lab-translations"; -import { - USAGE_RETENTION_CATALOG_OVERRIDES, - type UsageRetentionCatalogKey, -} from "./usage-retention-translations"; /** React-free locale catalog registry for formatters and other shared helpers. */ export type Locale = LabLocale; -export type TKey = BaseTKey | UsageRetentionCatalogKey; +export type TKey = BaseTKey; -/** Apply centrally maintained closed-surface translations to one base locale catalog. */ +/** Apply the centrally maintained Lab closed-surface translations to one base locale catalog. */ function withCatalogOverlays(locale: Locale, catalog: Record): Record { return { ...catalog, ...LAB_CATALOG_OVERRIDES[locale], - ...USAGE_RETENTION_CATALOG_OVERRIDES[locale], }; } /** - * Closed-surface translations are overlaid centrally so specialized panels cannot regress to - * copied English values. Base locale parity remains compile-checked by the locale modules. + * Lab translations are overlaid centrally so the compatibility surface cannot regress to copied + * English values. Base locale parity remains compile-checked by the locale modules. */ export const DICTS: Record> = { en: withCatalogOverlays("en", en), diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 2a889b8f2b..6e05204c40 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -861,6 +861,14 @@ export const de: Record = { "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.unlimited": "Unbegrenzt", + "usage.retention.error": "Das Größenlimit für den Nutzungsverlauf konnte nicht aktualisiert werden.", + "usage.retention.disabled": "Unbegrenzt — automatische Verlaufskomprimierung ist deaktiviert.", "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 0cf7469f56..65274de1fd 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -912,6 +912,14 @@ export const en = { // 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.unlimited": "Unlimited", + "usage.retention.error": "Could not update the usage history limit.", + "usage.retention.disabled": "Unlimited — automatic history compaction is off.", "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 576c3b7f23..77913fd399 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -889,6 +889,14 @@ export const fr: Record = { "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.unlimited": "Illimitée", + "usage.retention.error": "Impossible de mettre à jour la limite de l’historique d’utilisation.", + "usage.retention.disabled": "Illimitée — la compression automatique de l’historique est désactivée.", "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 1e01aea545..60797a105f 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -827,6 +827,14 @@ export const ja: Record = { // usage page "usage.title": "使用量", + "usage.retention.title": "使用履歴のサイズ上限", + "usage.retention.help": "最新の完全な使用記録を、指定したサイズ以内に必要に応じて保持します。履歴が上限を超えると古い行が自動的に削除されます。", + "usage.retention.enabled": "使用履歴のサイズを制限", + "usage.retention.current": "現在のサイズ", + "usage.retention.limit": "最大サイズ", + "usage.retention.unlimited": "無制限", + "usage.retention.error": "使用履歴のサイズ上限を更新できませんでした。", + "usage.retention.disabled": "無制限 — 使用履歴の自動圧縮はオフです。", "usage.subtitle": "プロキシからのローカルトークン会計です。欠損した使用量はゼロとして表示されることはありません。", "usage.loading": "使用量データを読み込み中…", "usage.empty": "まだ使用量が記録されていません。プロキシ経由でリクエストを送信するとここにアクティビティが表示されます。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index f1d20bf65e..fd6962ec23 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -895,6 +895,14 @@ export const ko: Record = { // usage page "usage.title": "사용량", + "usage.retention.title": "사용 기록 크기 제한", + "usage.retention.help": "최신의 완전한 사용 기록을 선택적으로 크기 제한 내에 보관합니다. 원장이 제한을 초과하면 오래된 행이 자동으로 삭제됩니다.", + "usage.retention.enabled": "사용 기록 크기 제한", + "usage.retention.current": "현재 크기", + "usage.retention.limit": "최대 크기", + "usage.retention.unlimited": "제한 없음", + "usage.retention.error": "사용 기록 크기 제한을 업데이트할 수 없습니다.", + "usage.retention.disabled": "제한 없음 — 자동 사용 기록 압축이 꺼져 있습니다.", "usage.subtitle": "프록시의 로컬 토큰 집계입니다. 누락된 사용량은 0으로 표시하지 않습니다.", "usage.loading": "사용량 데이터를 불러오는 중…", "usage.empty": "아직 기록된 사용량이 없습니다. 프록시로 요청을 보내면 여기에 표시됩니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index c583bccb99..b40c42451d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -882,6 +882,14 @@ export const ru: Record = { // usage page "usage.title": "Использование", + "usage.retention.title": "Ограничение размера истории использования", + "usage.retention.help": "При желании сохраняйте самые новые полные записи использования в пределах заданного размера. Старые строки автоматически удаляются, когда история превышает лимит.", + "usage.retention.enabled": "Ограничить размер истории использования", + "usage.retention.current": "Текущий размер", + "usage.retention.limit": "Максимальный размер", + "usage.retention.unlimited": "Без ограничений", + "usage.retention.error": "Не удалось обновить ограничение размера истории использования.", + "usage.retention.disabled": "Без ограничений — автоматическое сжатие истории выключено.", "usage.subtitle": "Локальный учёт токенов вашего прокси. Отсутствующие данные никогда не показываются как ноль.", "usage.loading": "Загрузка данных об использовании…", "usage.empty": "Данных об использовании пока нет. Отправьте запрос через прокси, чтобы увидеть здесь активность.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index ca39260677..1a954c5959 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -901,6 +901,14 @@ export const tr: Record = { // 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.unlimited": "Sınırsız", + "usage.retention.error": "Kullanım geçmişi boyut sınırı güncellenemedi.", + "usage.retention.disabled": "Sınırsız — otomatik geçmiş sıkıştırması kapalı.", "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/usage-retention-translations.ts b/gui/src/i18n/usage-retention-translations.ts deleted file mode 100644 index 09bbc25ba6..0000000000 --- a/gui/src/i18n/usage-retention-translations.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type { LabLocale } from "./lab-translations"; - -export type UsageRetentionCatalogKey = - | "storage.usageRetention.title" - | "storage.usageRetention.help" - | "storage.usageRetention.enabled" - | "storage.usageRetention.current" - | "storage.usageRetention.limit" - | "storage.usageRetention.unitMiB" - | "storage.usageRetention.unitGiB" - | "storage.usageRetention.save" - | "storage.usageRetention.apply" - | "storage.usageRetention.saving" - | "storage.usageRetention.running" - | "storage.usageRetention.saved" - | "storage.usageRetention.saveBeforeApply" - | "storage.usageRetention.disabled" - | "storage.usageRetention.error"; - -const en: Record = { - "storage.usageRetention.title": "Usage history size limit", - "storage.usageRetention.help": "When enabled, OpenCodex keeps the newest complete usage records and permanently removes older rows after the ledger exceeds this limit.", - "storage.usageRetention.enabled": "Limit usage history size", - "storage.usageRetention.current": "Current size", - "storage.usageRetention.limit": "Maximum size", - "storage.usageRetention.unitMiB": "MiB", - "storage.usageRetention.unitGiB": "GiB", - "storage.usageRetention.save": "Save", - "storage.usageRetention.apply": "Apply now", - "storage.usageRetention.saving": "Saving…", - "storage.usageRetention.running": "Applying…", - "storage.usageRetention.saved": "Saved", - "storage.usageRetention.saveBeforeApply": "Save these changes before applying the limit now.", - "storage.usageRetention.disabled": "Disabled", - "storage.usageRetention.error": "Could not update the usage history limit.", -}; - -const de: Record = { - "storage.usageRetention.title": "Größenlimit für Nutzungsverlauf", - "storage.usageRetention.help": "Wenn aktiviert, behält OpenCodex die neuesten vollständigen Nutzungsdatensätze und entfernt ältere Einträge dauerhaft, sobald das Limit überschritten wird.", - "storage.usageRetention.enabled": "Größe des Nutzungsverlaufs begrenzen", - "storage.usageRetention.current": "Aktuelle Größe", - "storage.usageRetention.limit": "Maximale Größe", - "storage.usageRetention.unitMiB": "MiB", - "storage.usageRetention.unitGiB": "GiB", - "storage.usageRetention.save": "Speichern", - "storage.usageRetention.apply": "Jetzt anwenden", - "storage.usageRetention.saving": "Wird gespeichert…", - "storage.usageRetention.running": "Wird angewendet…", - "storage.usageRetention.saved": "Gespeichert", - "storage.usageRetention.saveBeforeApply": "Speichern Sie diese Änderungen, bevor Sie das Limit sofort anwenden.", - "storage.usageRetention.disabled": "Deaktiviert", - "storage.usageRetention.error": "Das Größenlimit für den Nutzungsverlauf konnte nicht aktualisiert werden.", -}; - -const fr: Record = { - "storage.usageRetention.title": "Limite de taille de l’historique d’utilisation", - "storage.usageRetention.help": "Lorsque cette option est activée, OpenCodex conserve les enregistrements d’utilisation complets les plus récents et supprime définitivement les plus anciens lorsque la limite est dépassée.", - "storage.usageRetention.enabled": "Limiter la taille de l’historique d’utilisation", - "storage.usageRetention.current": "Taille actuelle", - "storage.usageRetention.limit": "Taille maximale", - "storage.usageRetention.unitMiB": "MiB", - "storage.usageRetention.unitGiB": "GiB", - "storage.usageRetention.save": "Enregistrer", - "storage.usageRetention.apply": "Appliquer maintenant", - "storage.usageRetention.saving": "Enregistrement…", - "storage.usageRetention.running": "Application…", - "storage.usageRetention.saved": "Enregistré", - "storage.usageRetention.saveBeforeApply": "Enregistrez ces modifications avant d’appliquer la limite maintenant.", - "storage.usageRetention.disabled": "Désactivé", - "storage.usageRetention.error": "Impossible de mettre à jour la limite de l’historique d’utilisation.", -}; - -const ko: Record = { - "storage.usageRetention.title": "사용 기록 크기 제한", - "storage.usageRetention.help": "활성화하면 OpenCodex는 가장 최근의 완전한 사용 기록을 유지하고 원장이 제한을 초과하면 오래된 행을 영구 삭제합니다.", - "storage.usageRetention.enabled": "사용 기록 크기 제한", - "storage.usageRetention.current": "현재 크기", - "storage.usageRetention.limit": "최대 크기", - "storage.usageRetention.unitMiB": "MiB", - "storage.usageRetention.unitGiB": "GiB", - "storage.usageRetention.save": "저장", - "storage.usageRetention.apply": "지금 적용", - "storage.usageRetention.saving": "저장 중…", - "storage.usageRetention.running": "적용 중…", - "storage.usageRetention.saved": "저장됨", - "storage.usageRetention.saveBeforeApply": "지금 제한을 적용하기 전에 변경 사항을 저장하세요.", - "storage.usageRetention.disabled": "비활성화됨", - "storage.usageRetention.error": "사용 기록 크기 제한을 업데이트할 수 없습니다.", -}; - -const zh: Record = { - "storage.usageRetention.title": "Usage 历史大小限制", - "storage.usageRetention.help": "启用后,OpenCodex 会保留最新的完整 Usage 记录,并在日志超过上限后永久删除较旧记录。", - "storage.usageRetention.enabled": "限制 Usage 历史大小", - "storage.usageRetention.current": "当前大小", - "storage.usageRetention.limit": "最大大小", - "storage.usageRetention.unitMiB": "MiB", - "storage.usageRetention.unitGiB": "GiB", - "storage.usageRetention.save": "保存", - "storage.usageRetention.apply": "立即应用", - "storage.usageRetention.saving": "正在保存…", - "storage.usageRetention.running": "正在应用…", - "storage.usageRetention.saved": "已保存", - "storage.usageRetention.saveBeforeApply": "请先保存这些改动,再立即应用限制。", - "storage.usageRetention.disabled": "已关闭", - "storage.usageRetention.error": "无法更新 Usage 历史大小限制。", -}; - -const zhTW: Record = { - "storage.usageRetention.title": "Usage 歷史大小限制", - "storage.usageRetention.help": "啟用後,OpenCodex 會保留最新的完整 Usage 記錄,並在日誌超過上限後永久刪除較舊記錄。", - "storage.usageRetention.enabled": "限制 Usage 歷史大小", - "storage.usageRetention.current": "目前大小", - "storage.usageRetention.limit": "最大大小", - "storage.usageRetention.unitMiB": "MiB", - "storage.usageRetention.unitGiB": "GiB", - "storage.usageRetention.save": "儲存", - "storage.usageRetention.apply": "立即套用", - "storage.usageRetention.saving": "正在儲存…", - "storage.usageRetention.running": "正在套用…", - "storage.usageRetention.saved": "已儲存", - "storage.usageRetention.saveBeforeApply": "請先儲存這些變更,再立即套用限制。", - "storage.usageRetention.disabled": "已關閉", - "storage.usageRetention.error": "無法更新 Usage 歷史大小限制。", -}; - -const ru: Record = { - "storage.usageRetention.title": "Ограничение размера истории использования", - "storage.usageRetention.help": "Если включено, OpenCodex сохраняет самые новые полные записи использования и безвозвратно удаляет старые строки после превышения лимита.", - "storage.usageRetention.enabled": "Ограничить размер истории использования", - "storage.usageRetention.current": "Текущий размер", - "storage.usageRetention.limit": "Максимальный размер", - "storage.usageRetention.unitMiB": "MiB", - "storage.usageRetention.unitGiB": "GiB", - "storage.usageRetention.save": "Сохранить", - "storage.usageRetention.apply": "Применить сейчас", - "storage.usageRetention.saving": "Сохранение…", - "storage.usageRetention.running": "Применение…", - "storage.usageRetention.saved": "Сохранено", - "storage.usageRetention.saveBeforeApply": "Сохраните изменения перед немедленным применением лимита.", - "storage.usageRetention.disabled": "Отключено", - "storage.usageRetention.error": "Не удалось обновить ограничение размера истории использования.", -}; - -const ja: Record = { - "storage.usageRetention.title": "使用履歴のサイズ上限", - "storage.usageRetention.help": "有効にすると、OpenCodex は最新の完全な使用記録を保持し、台帳が上限を超えた場合に古い行を完全に削除します。", - "storage.usageRetention.enabled": "使用履歴のサイズを制限", - "storage.usageRetention.current": "現在のサイズ", - "storage.usageRetention.limit": "最大サイズ", - "storage.usageRetention.unitMiB": "MiB", - "storage.usageRetention.unitGiB": "GiB", - "storage.usageRetention.save": "保存", - "storage.usageRetention.apply": "今すぐ適用", - "storage.usageRetention.saving": "保存中…", - "storage.usageRetention.running": "適用中…", - "storage.usageRetention.saved": "保存しました", - "storage.usageRetention.saveBeforeApply": "今すぐ上限を適用する前に、この変更を保存してください。", - "storage.usageRetention.disabled": "無効", - "storage.usageRetention.error": "使用履歴のサイズ上限を更新できませんでした。", -}; - -const tr: Record = { - "storage.usageRetention.title": "Kullanım geçmişi boyut sınırı", - "storage.usageRetention.help": "Etkinleştirildiğinde OpenCodex en yeni eksiksiz kullanım kayıtlarını tutar ve günlük sınırı aştığında eski satırları kalıcı olarak siler.", - "storage.usageRetention.enabled": "Kullanım geçmişi boyutunu sınırla", - "storage.usageRetention.current": "Geçerli boyut", - "storage.usageRetention.limit": "Maksimum boyut", - "storage.usageRetention.unitMiB": "MiB", - "storage.usageRetention.unitGiB": "GiB", - "storage.usageRetention.save": "Kaydet", - "storage.usageRetention.apply": "Şimdi uygula", - "storage.usageRetention.saving": "Kaydediliyor…", - "storage.usageRetention.running": "Uygulanıyor…", - "storage.usageRetention.saved": "Kaydedildi", - "storage.usageRetention.saveBeforeApply": "Sınırı şimdi uygulamadan önce bu değişiklikleri kaydedin.", - "storage.usageRetention.disabled": "Devre dışı", - "storage.usageRetention.error": "Kullanım geçmişi boyut sınırı güncellenemedi.", -}; - -/** Closed multi-locale catalog for the storage usage-retention panel. */ -export const USAGE_RETENTION_CATALOG_OVERRIDES: Record< - LabLocale, - Record -> = { - en, - de, - fr, - ko, - zh, - "zh-TW": zhTW, - ru, - ja, - tr, -}; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 6462f6c4b5..f20d970ae1 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -707,6 +707,14 @@ export const zhTW: Record = { "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.unlimited": "無限制", + "usage.retention.error": "無法更新用量歷史大小限制。", + "usage.retention.disabled": "無限制 — 自動壓縮用量歷史已關閉。", "usage.subtitle": "代理本地的 Token 用量統計。缺失的用量不會顯示為零。", "usage.loading": "正在載入用量資料…", "usage.empty": "尚無用量紀錄。透過代理傳送請求後將在此顯示。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 685227abc0..5d2f8b1251 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -876,6 +876,14 @@ export const zh: Record = { // usage page "usage.title": "用量", + "usage.retention.title": "用量历史大小限制", + "usage.retention.help": "可选择将最新的完整用量记录保留在指定大小以内。历史超过上限后,较旧条目会自动删除。", + "usage.retention.enabled": "限制用量历史大小", + "usage.retention.current": "当前大小", + "usage.retention.limit": "最大大小", + "usage.retention.unlimited": "无限制", + "usage.retention.error": "无法更新用量历史大小限制。", + "usage.retention.disabled": "无限制 — 自动压缩用量历史已关闭。", "usage.subtitle": "代理本地的 Token 用量统计。缺失的用量不会显示为零。", "usage.loading": "正在加载用量数据…", "usage.empty": "尚无用量记录。通过代理发送请求后将在此显示。", diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index cfcf00e578..a8235ca139 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -11,6 +11,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"; @@ -942,6 +943,8 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas
)} + + {state.showSkeleton && !data ? ( ) : state.kind === "failed-cold" ? ( diff --git a/gui/src/styles-storage-workspace.css b/gui/src/styles-storage-workspace.css index 3378a5daba..1585ac0f56 100644 --- a/gui/src/styles-storage-workspace.css +++ b/gui/src/styles-storage-workspace.css @@ -235,113 +235,6 @@ padding: 4px 0 12px; } -/* Usage-ledger retention stays on one compact control line when there is room. - The number field remains the exact-value control; the native range is a quick - way to move through the usual sizes without turning presets into a second - row of button chrome. */ -.storage-retention-controls { - display: flex; - align-items: center; - gap: 10px 16px; - flex-wrap: wrap; - min-width: 0; -} - -.storage-retention-current, -.storage-retention-enable { - display: inline-flex; - align-items: center; - gap: 7px; - flex: 0 0 auto; - min-height: var(--control-sm); - white-space: nowrap; -} - -.storage-retention-enable { - cursor: pointer; -} - -.storage-retention-enable:has(input:disabled) { - cursor: default; -} - -.storage-retention-limit { - display: flex; - align-items: center; - gap: 8px; - flex: 1 1 20rem; - min-width: min(100%, 15rem); -} - -.storage-retention-range { - flex: 1 1 auto; - min-width: 6rem; - accent-color: var(--accent); -} - -.storage-retention-number { - display: inline-flex; - align-items: center; - gap: 5px; - flex: 0 0 auto; -} - -.storage-retention-number input { - width: 5.5rem; - padding: 5px 8px; - font-variant-numeric: tabular-nums; -} - -.storage-retention-actions { - gap: 8px 12px; - margin-top: 6px; -} - -.storage-retention-presets { - display: inline-flex; - align-items: center; - gap: 2px; - flex: 1 1 auto; - min-width: 0; - flex-wrap: wrap; -} - -.storage-retention-preset { - appearance: none; - border: 0; - border-radius: var(--radius-pill); - background: transparent; - color: var(--muted); - cursor: pointer; - font: inherit; - font-size: var(--text-label); - line-height: var(--leading-ui); - padding: 4px 7px; - white-space: nowrap; - transition: background var(--motion-fast), color var(--motion-fast); -} - -.storage-retention-preset:hover:not(:disabled) { - background: var(--accent-soft); - color: var(--text); -} - -.storage-retention-preset.active { - background: var(--accent-soft); - color: var(--text); - font-weight: var(--weight-semibold); -} - -.storage-retention-preset:focus-visible { - outline: 2px solid var(--accent-ring); - outline-offset: 1px; -} - -.storage-retention-preset:disabled { - cursor: default; - opacity: 0.5; -} - /* Largest-files rows — flat list, no card-in-card */ .stw-file-row { display: flex; diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css index aa9e3bb45c..d0123e4fff 100644 --- a/gui/src/styles-usage-workspace.css +++ b/gui/src/styles-usage-workspace.css @@ -214,6 +214,51 @@ 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: baseline; + 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-current { + flex: 0 0 auto; + white-space: nowrap; +} + +.usage-retention-controls { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.usage-retention-custom-input { + width: 120px; +} + @media (max-width: 640px) { .usage-source-row { align-items: flex-start; flex-direction: column; } + .usage-retention-heading { align-items: flex-start; flex-direction: column; gap: var(--space-1); } + .usage-retention-current { white-space: normal; } } diff --git a/gui/tests/i18n-locales.test.ts b/gui/tests/i18n-locales.test.ts index e8e2d40af0..28e18d2c46 100644 --- a/gui/tests/i18n-locales.test.ts +++ b/gui/tests/i18n-locales.test.ts @@ -15,7 +15,6 @@ import { ru } from "../src/i18n/ru"; import { ja } from "../src/i18n/ja"; import { tr } from "../src/i18n/tr"; import { LAB_CATALOG_OVERRIDES } from "../src/i18n/lab-translations"; -import { USAGE_RETENTION_CATALOG_OVERRIDES } from "../src/i18n/usage-retention-translations"; import { formatUptime } from "../src/formatUptime"; const BASE_DICTS = { en, de, fr, ko, zh, "zh-TW": zhTW, ru, ja, tr }; @@ -52,11 +51,8 @@ describe("i18n locale contracts", () => { } }); - test("catalog overlays preserve their key sets in every locale", () => { - const overlays = [ - ["lab", LAB_CATALOG_OVERRIDES], - ["usage retention", USAGE_RETENTION_CATALOG_OVERRIDES], - ] as const; + test("lab catalog overlay preserves its key set in every locale", () => { + const overlays = [["lab", LAB_CATALOG_OVERRIDES]] as const; for (const [name, catalog] of overlays) { const expectedKeys = Object.keys(catalog.en).sort(); @@ -64,18 +60,38 @@ describe("i18n locale contracts", () => { for (const { code } of LOCALES) { expect(Object.keys(catalog[code]).sort(), `${name}.${code}`).toEqual(expectedKeys); - const prefix = name === "lab" ? "lab." : "storage.usageRetention."; + const prefix = "lab."; const composedKeys = Object.keys(DICTS[code]) - .filter(key => - key.startsWith(prefix) && - !(name === "lab" && key.startsWith("lab.production.")), - ) + .filter(key => key.startsWith(prefix) && !key.startsWith("lab.production.")) .sort(); expect(composedKeys, `DICTS.${code}.${name}`).toEqual(expectedKeys); } } }); + test("usage retention strings are ordinary base catalog keys", () => { + const expectedKeys = [ + "usage.retention.title", + "usage.retention.help", + "usage.retention.enabled", + "usage.retention.current", + "usage.retention.limit", + "usage.retention.unlimited", + "usage.retention.error", + "usage.retention.disabled", + ].sort(); + + expect(Object.keys(en).filter(key => key.startsWith("storage.usageRetention.")).sort()).toEqual([]); + expect(Object.keys(en).filter(key => key.startsWith("usage.retention.")).sort()).toEqual(expectedKeys); + + for (const { code } of LOCALES) { + expect( + Object.keys(BASE_DICTS[code]).filter(key => key.startsWith("usage.retention.")).sort(), + code, + ).toEqual(expectedKeys); + } + }); + test("every locale preserves interpolation placeholders exactly", () => { const placeholderRe = /\{([a-zA-Z0-9_]+)\}/g; const mismatches: string[] = []; diff --git a/gui/tests/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx index db257ba6b2..0def27546a 100644 --- a/gui/tests/usage-custom-range.test.tsx +++ b/gui/tests/usage-custom-range.test.tsx @@ -35,9 +35,24 @@ 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); + // Usage now mounts its compact retention control alongside the report. Keep that + // independent status read out of the report request gates so the range assertions + // continue to describe only `/api/usage` generation ordering. + if (url.includes("/api/storage/usage-ledger-retention")) { + return Promise.resolve(Response.json({ + enabled: false, + maxBytes: 128 * 1024 * 1024, + currentBytes: 0, + overLimit: false, + job: { status: "idle" }, + })); + } + 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 0000000000..34ac3660bc --- /dev/null +++ b/gui/tests/usage-retention-control.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; + +test("Usage retention control stays a small native-control surface", async () => { + const component = await Bun.file(new URL("../src/components/usage/UsageLedgerRetentionControl.tsx", import.meta.url)).text(); + 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("UsageLedgerRetentionPanel"); + expect(component).toContain("] [--mib ] [--json] - ocx storage usage-limit run [--yes] [--json] -Cleanup, restore, and usage-limit run MUTATE operator data and require --yes where noted. +Cleanup, restore, and policy run MUTATE operator data and require --yes where noted. Without --yes, cleanup prints the preview and changes nothing.`; /** The digest binds a run to the preview it was authorized against. */ @@ -224,7 +223,7 @@ async function policy(argv: string[], deps: RuntimeApiDeps): Promise { printData(result, wantsJson, summaryLines(result)); } -/** Show or edit the usage-history ceiling; only `run` performs immediate deletion. */ +/** Show or edit the usage-history ceiling; enforcement is performed by the scheduler. */ async function usageLimit(argv: string[], deps: RuntimeApiDeps): Promise { const action = argv[0] && !argv[0].startsWith("-") ? argv[0] : "show"; const rest = argv[0] && !argv[0].startsWith("-") ? argv.slice(1) : argv; @@ -267,16 +266,7 @@ async function usageLimit(argv: string[], deps: RuntimeApiDeps): Promise { return; } - if (action !== "run") throw new CliUsageError(`unknown usage-limit action ${action}`, USAGE); - const args = [...rest]; - const wantsJson = takeFlag(args, "--json"); - const confirmed = takeFlag(args, "--yes"); - rejectArgs(args, USAGE); - if (!confirmed) { - throw new CliUsageError("usage-limit run permanently removes older usage history; pass --yes to confirm", USAGE); - } - const result = await runtimeRequest("/api/storage/usage-ledger-retention/run", { method: "POST" }, deps); - printData(result, wantsJson, summaryLines(result)); + throw new CliUsageError(`unknown usage-limit action ${action}`, USAGE); } /** Dispatch `ocx storage` while preserving explicit confirmation boundaries for mutations. */ diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 84d7e923ec..2252bb051e 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -317,7 +317,6 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "POST", path: "/api/storage/codex-logs/protect", module: "server/management/storage-log-guard-routes", mutates: true }, { method: "POST", path: "/api/storage/codex-logs/repair", module: "server/management/storage-log-guard-routes", mutates: true }, { method: "POST", path: "/api/storage/codex-logs/unprotect", module: "server/management/storage-log-guard-routes", mutates: true }, - { method: "POST", path: "/api/storage/usage-ledger-retention/run", module: "server/management/storage-log-guard-routes", mutates: true }, { method: "PUT", path: "/api/storage/usage-ledger-retention", module: "server/management/storage-log-guard-routes", mutates: true }, // server/management/system-routes { method: "GET", path: "/api/system/health", module: "server/management/system-routes", mutates: false }, @@ -345,4 +344,4 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/lab/events/{id}", module: "server/management/lab-routes", mutates: false, mechanism: "regex", exempt: { reason: "local-transport", why: "ocx lab reads the same rows from the local SQLite projection; src/cli/lab.ts imports ../lab/query directly and never fetches /api/lab." } }, { method: "GET", path: "/api/lab/artifacts/{digest}", module: "server/management/lab-routes", mutates: false, mechanism: "regex", exempt: { reason: "local-transport", why: "ocx lab reads the same rows from the local SQLite projection; src/cli/lab.ts imports ../lab/query directly and never fetches /api/lab." } }, { method: "POST", path: "/api/lab/automation/runs/{id}/cancel", module: "server/management/lab-automation-routes", mutates: true, mechanism: "regex", exempt: { reason: "deferred-verb", why: "Lab automation run cancellation has no CLI verb yet. A local SQLite read cannot drive it, so local-transport does not apply.", owner: "wp7", ownerDoc: "devlog/_plan/260828_ocx_agentic_control/060_phase_gui_parity.md" } }, -]; \ No newline at end of file +]; diff --git a/src/server/management/storage-log-guard-routes.ts b/src/server/management/storage-log-guard-routes.ts index a9e9883367..cfa6de7c0a 100644 --- a/src/server/management/storage-log-guard-routes.ts +++ b/src/server/management/storage-log-guard-routes.ts @@ -21,7 +21,6 @@ import { import { getUsageLedgerRetentionJobState, invalidateUsageLedgerRetentionRun, - requestUsageLedgerRetentionRun, } from "../../usage/ledger-retention-job"; import { jsonResponse } from "../auth-cors"; import { @@ -152,7 +151,7 @@ export async function handleStorageLogGuardRoutes(ctx: ManagementContext): Promi // The old preparation may finish, but its generation can no longer commit. invalidateUsageLedgerRetentionRun(); // PUT changes policy only. Automatic enforcement belongs to the scheduler; - // the explicit /run route is the operator's immediate destructive action. + // there is no public manual trigger for destructive compaction. return jsonResponse({ ok: true, ...getUsageLedgerRetentionStatus(config), @@ -163,33 +162,6 @@ export async function handleStorageLogGuardRoutes(ctx: ManagementContext): Promi } } - if (url.pathname === "/api/storage/usage-ledger-retention/run" && req.method === "POST") { - const status = getUsageLedgerRetentionStatus(config); - if (!status.enabled) { - return jsonResponse({ - ok: false, - error: "retention_disabled", - ...status, - job: getUsageLedgerRetentionJobState(), - }, 409, req, config); - } - const run = requestUsageLedgerRetentionRun(); - if (!run.accepted) { - return jsonResponse({ - ok: false, - error: "already_running", - ...status, - job: run.state, - }, 409, req, config); - } - return jsonResponse({ - ok: true, - started: true, - ...status, - job: run.state, - }, 202, req, config); - } - if (url.pathname === "/api/storage/codex-logs") { if (req.method !== "GET") return null; try { diff --git a/src/usage/ledger-retention-job.ts b/src/usage/ledger-retention-job.ts index 6926d8415b..f395464848 100644 --- a/src/usage/ledger-retention-job.ts +++ b/src/usage/ledger-retention-job.ts @@ -352,16 +352,6 @@ export function requestUsageLedgerRetentionRun(): return { accepted: true, state: getUsageLedgerRetentionJobState() }; } -/** Cheap scheduler entry: disabled policies never reserve a Worker. */ -export function maybeRequestUsageLedgerRetentionRun(): void { - try { - if (!readUsageLedgerRetentionFromConfig().enabled) return; - requestUsageLedgerRetentionRun(); - } catch { - warnRetentionFailure(); - } -} - /** Join an active retention Worker during final server teardown. */ export async function abortUsageLedgerRetentionJobAsync(): Promise { runGeneration += 1; diff --git a/tests/cli/cli-storage-usage-limit.test.ts b/tests/cli/cli-storage-usage-limit.test.ts index 9b55e310ee..27bf1120b4 100644 --- a/tests/cli/cli-storage-usage-limit.test.ts +++ b/tests/cli/cli-storage-usage-limit.test.ts @@ -29,7 +29,7 @@ function capture(): { restore: () => void } { const STATUS = { enabled: false, - maxBytes: 512 * 1024 * 1024, + maxBytes: 128 * 1024 * 1024, currentBytes: 64 * 1024 * 1024, overLimit: false, job: { status: "idle" }, @@ -87,7 +87,7 @@ describe("ocx storage usage-limit", () => { expect(calls).toHaveLength(0); }); - test("manual run requires --yes and sends no mutation without it", async () => { + test("manual run is no longer exposed", async () => { const { calls, deps } = harness(() => ({ json: { ok: true, started: true } })); const cap = capture(); let code: number; @@ -99,15 +99,4 @@ describe("ocx storage usage-limit", () => { expect(code).not.toBe(0); expect(calls).toHaveLength(0); }); - - test("manual run with --yes reaches the destructive route", async () => { - const { calls, deps } = harness(() => ({ json: { ok: true, started: true } })); - const cap = capture(); - try { - expect(await handleStorageCommand(["usage-limit", "run", "--yes"], deps)).toBe(0); - } finally { - cap.restore(); - } - expect(calls).toEqual([{ method: "POST", path: "/api/storage/usage-ledger-retention/run", body: undefined }]); - }); }); diff --git a/tests/storage/api-storage.test.ts b/tests/storage/api-storage.test.ts index b5ca548e3d..561bd2c041 100644 --- a/tests/storage/api-storage.test.ts +++ b/tests/storage/api-storage.test.ts @@ -148,3 +148,28 @@ describe("GET /api/storage", () => { } }); }); + +describe("usage ledger retention management route", () => { + test("keeps GET/PUT policy management while removing the manual run endpoint", async () => { + const server = startServer(0); + try { + const status = await fetch(new URL("/api/storage/usage-ledger-retention", server.url)); + expect(status.status).toBe(200); + expect(await status.json()).toMatchObject({ enabled: false, maxBytes: expect.any(Number), currentBytes: expect.any(Number) }); + + const updated = await fetch(new URL("/api/storage/usage-ledger-retention", server.url), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true, maxBytes: 8 * 1024 * 1024 }), + }); + expect(updated.status).toBe(200); + expect(await updated.json()).toMatchObject({ enabled: true, maxBytes: 8 * 1024 * 1024 }); + + const removed = await fetch(new URL("/api/storage/usage-ledger-retention/run", server.url), { method: "POST" }); + expect(removed.status).toBe(404); + expect(await removed.json()).toMatchObject({ error: { type: "not_found", code: "not_found" } }); + } finally { + await server.stop(true); + } + }); +}); From b22444f8602cc009083ecee80bf8966422d34cbf Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 03:13:25 +0800 Subject: [PATCH 50/61] docs(pr): add Usage retention preview --- .../usage-ledger-retention-usage-ui.svg | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/pr-assets/usage-ledger-retention-usage-ui.svg diff --git a/.github/pr-assets/usage-ledger-retention-usage-ui.svg b/.github/pr-assets/usage-ledger-retention-usage-ui.svg new file mode 100644 index 0000000000..7dca9145ae --- /dev/null +++ b/.github/pr-assets/usage-ledger-retention-usage-ui.svg @@ -0,0 +1,30 @@ + + OpenCodex Usage page usage history size limit + A compact Usage page control with Unlimited selected, a disabled retention switch, current size information, and a native limit selector. + + + Usage + Local token accounting from your proxy + + Usage history size limit + Optionally keep the newest complete usage records within a size limit. + Current size + 64 KiB + + + Limit usage history size + Maximum size + + Unlimited + + Unlimited — automatic history compaction is off. + + Requests + 1,248 + Total tokens + 18.4M + Available history + 30d + Status + Unlimited + From 27304bdc0a5f43124db500f5fc843be823600804 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 03:17:07 +0800 Subject: [PATCH 51/61] chore(gui): drop unused retention overlay plumbing --- gui/src/i18n/provider.tsx | 5 +---- gui/tests/fr-localization.test.ts | 7 +++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/gui/src/i18n/provider.tsx b/gui/src/i18n/provider.tsx index b3661e9909..d2973afc18 100644 --- a/gui/src/i18n/provider.tsx +++ b/gui/src/i18n/provider.tsx @@ -22,10 +22,7 @@ export function LanguageProvider({ children }: { children: ReactNode }) { }, [locale]); const t: TFn = useCallback( - (key, vars) => interpolate( - DICTS[locale][key] ?? (key in en ? en[key as keyof typeof en] : undefined) ?? key, - vars, - ), + (key, vars) => interpolate(DICTS[locale][key] ?? en[key] ?? key, vars), [locale], ); const value = useMemo(() => ({ locale, setLocale, t }), [locale, t]); diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 618f22fc2c..87250bb74b 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"; import { formatResetFuture } from "../src/components/QuotaBars"; import { formatUptime } from "../src/formatUptime"; import type { TKey } from "../src/i18n"; -import { en, type TKey as BaseTKey } from "../src/i18n/en"; import { DICTS, LOCALES } from "../src/i18n/shared"; import { labSupplement } from "../src/i18n/lab-translations"; import { ROUTING_COMPATIBILITY_FIELD_LABELS } from "../src/i18n/routing-compatibility-labels"; @@ -190,10 +189,10 @@ describe("French base catalog", () => { if (!(await Bun.file(FR_CATALOG_URL).exists())) return; const french = (await import("../src/i18n/fr")).fr; - const english = en; + const english = DICTS.en; expect(Object.keys(french).sort()).toEqual(Object.keys(english).sort()); - for (const key of Object.keys(english) as BaseTKey[]) { + for (const key of Object.keys(english) as TKey[]) { expect(french[key].trim().length, key).toBeGreaterThan(0); expect(placeholders(french[key]), key).toEqual(placeholders(english[key])); } @@ -204,7 +203,7 @@ describe("French base catalog", () => { if (!(await Bun.file(FR_CATALOG_URL).exists())) return; const french = (await import("../src/i18n/fr")).fr; - const accidental = (Object.keys(en) as BaseTKey[]).filter(key => + const accidental = (Object.keys(DICTS.en) as TKey[]).filter(key => french[key] === DICTS.en[key] && !INTENTIONAL_ENGLISH.has(key) ); From bb622497f98a3de2ef87eab2747860c87e1cec95 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 03:20:02 +0800 Subject: [PATCH 52/61] test(usage): cover Unlimited default --- tests/usage-ledger-retention-v2.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/usage-ledger-retention-v2.test.ts b/tests/usage-ledger-retention-v2.test.ts index bbb34f9746..38ed87e4c2 100644 --- a/tests/usage-ledger-retention-v2.test.ts +++ b/tests/usage-ledger-retention-v2.test.ts @@ -64,7 +64,8 @@ async function waitForRetentionIdle(timeoutMs = 10_000): Promise { } describe("usage ledger retention v2", () => { - test("unknown persisted config keys disable destructive retention", () => { + test("missing or unknown persisted config keys stay Unlimited", () => { + expect(normalizeUsageLedgerRetention(undefined).enabled).toBe(false); expect(normalizeUsageLedgerRetention({ enabled: true, maxByets: 8 * 1024 * 1024 })).toEqual({ enabled: false, maxBytes: DEFAULT_USAGE_LEDGER_MAX_BYTES, From 3d672203f16be8ca0fa0f14914f6bb8c2629d3d9 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 03:26:03 +0800 Subject: [PATCH 53/61] fix(usage): reflect custom retention draft --- .../components/usage/UsageLedgerRetentionControl.tsx | 11 ++++++----- gui/tests/usage-retention-control.test.ts | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/gui/src/components/usage/UsageLedgerRetentionControl.tsx b/gui/src/components/usage/UsageLedgerRetentionControl.tsx index 23535b78de..51854712ee 100644 --- a/gui/src/components/usage/UsageLedgerRetentionControl.tsx +++ b/gui/src/components/usage/UsageLedgerRetentionControl.tsx @@ -84,11 +84,12 @@ export default function UsageLedgerRetentionControl({ apiBase }: { apiBase: stri const limitMiB = status ? limitMiBFromBytes(status.maxBytes) : null; const enabled = status?.enabled === true; // Until GET resolves (and whenever the policy is off), the visible value is - // explicitly Unlimited. This avoids inventing a 512 MiB default in the UI. - const selectedValue = !enabled - ? UNLIMITED_OPTION - : customOpen - ? CUSTOM_OPTION + // explicitly Unlimited. The only exception is an explicitly opened Custom + // draft, which mirrors the native Models control until the user applies it. + const selectedValue = customOpen + ? CUSTOM_OPTION + : !enabled + ? UNLIMITED_OPTION : limitMiB === null ? CUSTOM_OPTION : String(limitMiB); diff --git a/gui/tests/usage-retention-control.test.ts b/gui/tests/usage-retention-control.test.ts index 34ac3660bc..149c403ac2 100644 --- a/gui/tests/usage-retention-control.test.ts +++ b/gui/tests/usage-retention-control.test.ts @@ -9,7 +9,8 @@ test("Usage retention control stays a small native-control surface", async () => expect(storageWorkspace).not.toContain("UsageLedgerRetentionPanel"); expect(component).toContain(" Date: Wed, 9 Sep 2026 03:44:11 +0800 Subject: [PATCH 54/61] refactor(usage): reduce retention UI to one toggle --- .../usage/UsageLedgerRetentionControl.tsx | 138 +++--------------- 1 file changed, 17 insertions(+), 121 deletions(-) diff --git a/gui/src/components/usage/UsageLedgerRetentionControl.tsx b/gui/src/components/usage/UsageLedgerRetentionControl.tsx index 51854712ee..c854ffa322 100644 --- a/gui/src/components/usage/UsageLedgerRetentionControl.tsx +++ b/gui/src/components/usage/UsageLedgerRetentionControl.tsx @@ -1,12 +1,7 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { formatBytes } from "../../format-bytes"; import { useI18n } from "../../i18n/shared"; -import { Select, Switch } from "../../ui"; - -const MIB = 1024 ** 2; -const UNLIMITED_OPTION = "unlimited"; -const CUSTOM_OPTION = "custom"; -const COMMON_LIMITS_MIB = [128, 512, 1024, 2048] as const; +import { Switch } from "../../ui"; interface RetentionStatus { enabled: boolean; @@ -30,31 +25,16 @@ function parseStatus(value: unknown): RetentionStatus { }; } -function limitMiBFromBytes(bytes: number): number | null { - if (!Number.isFinite(bytes) || bytes <= 0) return null; - const value = Math.round(bytes / MIB); - return Number.isSafeInteger(value) && value > 0 ? value : null; -} - -function parseCustomLimit(raw: string): number | null { - const value = Number(raw.replace(/[_,\s]/g, "")); - return Number.isSafeInteger(value) && value > 0 ? value : null; -} - /** - * Compact Usage-page control for the opt-in usage-ledger byte ceiling. + * Minimal Usage-page toggle for the opt-in usage-ledger byte ceiling. * - * The server status is the only policy source. Selecting Unlimited or a common - * value persists immediately; Custom is the sole two-step path so an input can - * be checked before it is sent. The switch is a convenient reflection/shortcut - * to turn the same `enabled` value off, not a second draft state; bounded values - * are enabled through the Select so Unlimited remains the only off state. + * The concrete ceiling remains an API/CLI setting. The dashboard only enables or + * disables the exact value already reported by the server, so a non-MiB-aligned + * value can never be rounded or silently rewritten by the UI. */ export default function UsageLedgerRetentionControl({ apiBase }: { apiBase: string }) { const { locale, t } = useI18n(); const [status, setStatus] = useState(null); - const [customOpen, setCustomOpen] = useState(false); - const [customDraft, setCustomDraft] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -81,40 +61,17 @@ export default function UsageLedgerRetentionControl({ apiBase }: { apiBase: stri }; }, [load, t]); - const limitMiB = status ? limitMiBFromBytes(status.maxBytes) : null; - const enabled = status?.enabled === true; - // Until GET resolves (and whenever the policy is off), the visible value is - // explicitly Unlimited. The only exception is an explicitly opened Custom - // draft, which mirrors the native Models control until the user applies it. - const selectedValue = customOpen - ? CUSTOM_OPTION - : !enabled - ? UNLIMITED_OPTION - : limitMiB === null - ? CUSTOM_OPTION - : String(limitMiB); - const commonLimitSet = useMemo(() => new Set(COMMON_LIMITS_MIB), []); - const options = useMemo(() => [ - { value: UNLIMITED_OPTION, label: t("usage.retention.unlimited") }, - ...(enabled && limitMiB !== null && !commonLimitSet.has(limitMiB) && !customOpen - ? [{ value: String(limitMiB), label: formatBytes(limitMiB * MIB, locale) }] - : []), - ...COMMON_LIMITS_MIB.map(value => ({ value: String(value), label: formatBytes(value * MIB, locale) })), - { value: CUSTOM_OPTION, label: t("models.custom") }, - ], [commonLimitSet, customOpen, enabled, limitMiB, locale, t]); - - const persist = useCallback(async (nextEnabled: boolean, nextLimitMiB: number) => { + 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: nextLimitMiB * MIB }), + body: JSON.stringify({ enabled: nextEnabled, maxBytes }), }); if (!response.ok) throw new Error("save_failed"); setStatus(parseStatus(await response.json())); - setCustomOpen(false); } catch { setError(t("usage.retention.error")); } finally { @@ -122,41 +79,11 @@ export default function UsageLedgerRetentionControl({ apiBase }: { apiBase: stri } }, [apiBase, t]); - const switchEnabled = () => { - if (!status || !enabled || limitMiB === null || busy) return; - void persist(false, limitMiB); - }; - - const selectLimit = (value: string) => { + const toggle = () => { if (!status || busy) return; - setError(null); - if (value === UNLIMITED_OPTION) { - if (enabled && limitMiB !== null) void persist(false, limitMiB); - return; - } - if (value === CUSTOM_OPTION) { - setCustomOpen(true); - // A disabled policy is Unlimited, so do not surface the compatibility - // fallback ceiling as a made-up custom default. Bounded values can still - // be selected explicitly from the list before opening Custom. - setCustomDraft(enabled && limitMiB !== null ? String(limitMiB) : ""); - return; - } - const nextLimitMiB = parseCustomLimit(value); - if (nextLimitMiB !== null) void persist(true, nextLimitMiB); - }; - - const applyCustom = () => { - const nextLimitMiB = parseCustomLimit(customDraft); - if (nextLimitMiB === null) { - setError(t("usage.retention.error")); - return; - } - void persist(true, nextLimitMiB); + void persist(!status.enabled, status.maxBytes); }; - const controlsDisabled = busy || status === null || limitMiB === null; - return (
@@ -164,49 +91,18 @@ export default function UsageLedgerRetentionControl({ apiBase }: { apiBase: stri

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

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

- - {t("usage.retention.current")}: {status?.currentBytes === undefined ? "—" : formatBytes(status.currentBytes, locale)} - - - -
- {t("usage.retention.limit")} - setCustomDraft(event.target.value)} - onKeyDown={event => { if (event.key === "Enter") applyCustom(); }} - disabled={busy} - aria-label={t("usage.retention.limit")} - /> - - - )}
- {!enabled && status &&

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

} +

+ {t("usage.retention.current")}: {status?.currentBytes === undefined ? "—" : formatBytes(status.currentBytes, locale)} + {status ? ` · ${t("usage.retention.limit")}: ${formatBytes(status.maxBytes, locale)}` : ""} +

{error &&

{error}

}
); From 0cd5e0e0a12373de0e39c06a954dbcb5ccc03b8f Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 03:44:38 +0800 Subject: [PATCH 55/61] test(gui): exercise usage retention toggle --- gui/tests/usage-retention-control.test.ts | 139 +++++++++++++++++++--- 1 file changed, 123 insertions(+), 16 deletions(-) diff --git a/gui/tests/usage-retention-control.test.ts b/gui/tests/usage-retention-control.test.ts index 149c403ac2..72da3cbd72 100644 --- a/gui/tests/usage-retention-control.test.ts +++ b/gui/tests/usage-retention-control.test.ts @@ -1,23 +1,130 @@ -import { expect, test } from "bun:test"; +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"; -test("Usage retention control stays a small native-control surface", async () => { - const component = await Bun.file(new URL("../src/components/usage/UsageLedgerRetentionControl.tsx", import.meta.url)).text(); +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, 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 mount(apiBase: string): Promise { + await act(async () => { + root = createRoot(host); + root.render(createElement( + LanguageProvider, + null, + createElement(UsageLedgerRetentionControl, { apiBase }), + )); + }); + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + await Promise.resolve(); + }); +} + +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("UsageLedgerRetentionPanel"); - expect(component).toContain(" { + 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"); + + await act(async () => { + switches[0].click(); + await Promise.resolve(); + }); + expect(writes[0]).toEqual({ enabled: true, maxBytes }); + expect(switches[0].getAttribute("aria-pressed")).toBe("true"); + + await act(async () => { + switches[0].click(); + await Promise.resolve(); + }); + expect(writes[1]).toEqual({ enabled: false, maxBytes }); + expect(switches[0].getAttribute("aria-pressed")).toBe("false"); +}); + +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); }); From 8b60d220b3de3907e45ef64a7d09e98b9f59791b Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 03:44:50 +0800 Subject: [PATCH 56/61] docs(pr): remove synthetic usage preview --- .../usage-ledger-retention-usage-ui.svg | 30 ------------------- 1 file changed, 30 deletions(-) delete mode 100644 .github/pr-assets/usage-ledger-retention-usage-ui.svg diff --git a/.github/pr-assets/usage-ledger-retention-usage-ui.svg b/.github/pr-assets/usage-ledger-retention-usage-ui.svg deleted file mode 100644 index 7dca9145ae..0000000000 --- a/.github/pr-assets/usage-ledger-retention-usage-ui.svg +++ /dev/null @@ -1,30 +0,0 @@ - - OpenCodex Usage page usage history size limit - A compact Usage page control with Unlimited selected, a disabled retention switch, current size information, and a native limit selector. - - - Usage - Local token accounting from your proxy - - Usage history size limit - Optionally keep the newest complete usage records within a size limit. - Current size - 64 KiB - - - Limit usage history size - Maximum size - - Unlimited - - Unlimited — automatic history compaction is off. - - Requests - 1,248 - Total tokens - 18.4M - Available history - 30d - Status - Unlimited - From 97f31857a43eef2c7df394efa6009feec00fa3bd Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 03:50:12 +0800 Subject: [PATCH 57/61] style(usage): match compact retention toggle --- gui/src/styles-usage-workspace.css | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css index d0123e4fff..05798359f9 100644 --- a/gui/src/styles-usage-workspace.css +++ b/gui/src/styles-usage-workspace.css @@ -225,7 +225,7 @@ .usage-retention-heading { display: flex; - align-items: baseline; + align-items: center; justify-content: space-between; gap: var(--space-3); min-width: 0; @@ -242,23 +242,12 @@ } .usage-retention-current { - flex: 0 0 auto; + margin: 0; white-space: nowrap; } -.usage-retention-controls { - display: flex; - align-items: center; - gap: var(--space-2); - flex-wrap: wrap; -} - -.usage-retention-custom-input { - width: 120px; -} - @media (max-width: 640px) { .usage-source-row { align-items: flex-start; flex-direction: column; } - .usage-retention-heading { align-items: flex-start; flex-direction: column; gap: var(--space-1); } + .usage-retention-heading { align-items: flex-start; } .usage-retention-current { white-space: normal; } } From 71276dfacf1177ee750af24953bf0bfeb49cfec8 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:33:22 +0800 Subject: [PATCH 58/61] fix(usage): mirror disabled cap semantics --- .../usage/UsageLedgerRetentionControl.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/gui/src/components/usage/UsageLedgerRetentionControl.tsx b/gui/src/components/usage/UsageLedgerRetentionControl.tsx index c854ffa322..5ee88bc56f 100644 --- a/gui/src/components/usage/UsageLedgerRetentionControl.tsx +++ b/gui/src/components/usage/UsageLedgerRetentionControl.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { formatBytes } from "../../format-bytes"; import { useI18n } from "../../i18n/shared"; import { Switch } from "../../ui"; @@ -37,12 +37,14 @@ export default function UsageLedgerRetentionControl({ apiBase }: { apiBase: stri const [status, setStatus] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const loadGeneration = useRef(0); const load = useCallback(async (signal?: AbortSignal) => { + const generation = ++loadGeneration.current; 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) return; + if (signal?.aborted || generation !== loadGeneration.current) return; setStatus(next); }, [apiBase]); @@ -71,7 +73,11 @@ export default function UsageLedgerRetentionControl({ apiBase }: { apiBase: stri body: JSON.stringify({ enabled: nextEnabled, maxBytes }), }); if (!response.ok) throw new Error("save_failed"); - setStatus(parseStatus(await response.json())); + 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); } catch { setError(t("usage.retention.error")); } finally { @@ -101,7 +107,15 @@ export default function UsageLedgerRetentionControl({ apiBase }: { apiBase: stri

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

{error &&

{error}

} From 5bb19c092821a760b491c186ce9905b4663c615d Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:33:41 +0800 Subject: [PATCH 59/61] fix(usage): dim inactive saved ceiling --- gui/src/styles-usage-workspace.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css index 05798359f9..ef0a6c225c 100644 --- a/gui/src/styles-usage-workspace.css +++ b/gui/src/styles-usage-workspace.css @@ -246,6 +246,12 @@ 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; } From 53cbcc165643e30454aa4f5b011adbee01d7cfb8 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:34:03 +0800 Subject: [PATCH 60/61] test(usage): cover disabled ceiling and stale reads --- gui/tests/usage-retention-control.test.ts | 74 +++++++++++++++++++++-- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/gui/tests/usage-retention-control.test.ts b/gui/tests/usage-retention-control.test.ts index 72da3cbd72..6c24b4c3ea 100644 --- a/gui/tests/usage-retention-control.test.ts +++ b/gui/tests/usage-retention-control.test.ts @@ -4,6 +4,7 @@ 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]; @@ -41,6 +42,13 @@ afterEach(async () => { 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); @@ -50,10 +58,17 @@ async function mount(apiBase: string): Promise { createElement(UsageLedgerRetentionControl, { apiBase }), )); }); - await act(async () => { - await new Promise(resolve => testWindow.setTimeout(resolve, 0)); - await Promise.resolve(); - }); + 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 () => { @@ -89,6 +104,8 @@ test("renders one switch and toggles without rewriting the saved byte ceiling", 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(); @@ -96,6 +113,8 @@ test("renders one switch and toggles without rewriting the saved byte ceiling", }); 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(); @@ -103,6 +122,53 @@ test("renders one switch and toggles without rewriting the saved byte ceiling", }); 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("failed toggle keeps the last server state and surfaces an error", async () => { From 471204864c5363a910a450c0341180700e73039e Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:34:23 +0800 Subject: [PATCH 61/61] feat(usage): default saved ceiling to 1 GiB --- src/usage/ledger-retention.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/usage/ledger-retention.ts b/src/usage/ledger-retention.ts index 94dc15b7b0..289b39d1f9 100644 --- a/src/usage/ledger-retention.ts +++ b/src/usage/ledger-retention.ts @@ -10,7 +10,7 @@ import { writeSync, } from "node:fs"; -export const DEFAULT_USAGE_LEDGER_MAX_BYTES = 512 * 1024 * 1024; +export const DEFAULT_USAGE_LEDGER_MAX_BYTES = 1024 * 1024 * 1024; export const MIN_USAGE_LEDGER_MAX_BYTES = 1024 * 1024; const SCAN_CHUNK_BYTES = 1024 * 1024;