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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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; From e89012332df2bafe116b61890ec1b56b0c1b1f7b Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:49:31 +0800 Subject: [PATCH 62/96] fix(gui): ignore stale retention read failures --- .../usage/UsageLedgerRetentionControl.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/gui/src/components/usage/UsageLedgerRetentionControl.tsx b/gui/src/components/usage/UsageLedgerRetentionControl.tsx index 5ee88bc56f..a508478584 100644 --- a/gui/src/components/usage/UsageLedgerRetentionControl.tsx +++ b/gui/src/components/usage/UsageLedgerRetentionControl.tsx @@ -41,11 +41,20 @@ export default function UsageLedgerRetentionControl({ apiBase }: { apiBase: stri 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 || generation !== loadGeneration.current) return; - setStatus(next); + try { + const response = await fetch(`${apiBase}/api/storage/usage-ledger-retention`, { signal }); + if (!response.ok) throw new Error("load_failed"); + const next = parseStatus(await response.json()); + if (signal?.aborted || generation !== loadGeneration.current) return; + setError(null); + setStatus(next); + } catch (errorValue) { + // A successful PUT invalidates reads that started under the old policy. Stale reads + // must be silent whether they eventually succeed, fail HTTP, reject, or parse badly. + if (signal?.aborted || generation !== loadGeneration.current + || (errorValue as { name?: string })?.name === "AbortError") return; + throw errorValue; + } }, [apiBase]); useEffect(() => { From 859e07376e8396136bd930f6ddef85d673f7f50f Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:50:33 +0800 Subject: [PATCH 63/96] test(gui): cover stale retention read failures --- gui/tests/usage-retention-control.test.ts | 46 +++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/gui/tests/usage-retention-control.test.ts b/gui/tests/usage-retention-control.test.ts index 6c24b4c3ea..e5137984a5 100644 --- a/gui/tests/usage-retention-control.test.ts +++ b/gui/tests/usage-retention-control.test.ts @@ -171,6 +171,52 @@ test("a stale GET cannot repaint policy after a successful toggle", async () => expect(toggle.getAttribute("aria-pressed")).toBe("true"); }); +test("a stale failed GET is silent after a successful toggle", async () => { + const apiBase = "http://usage-retention-stale-failure"; + const maxBytes = 1024 * 1024 * 1024; + let getCount = 0; + let resolveStaleGet: ((response: Response) => void) | undefined; + + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url !== `${apiBase}/api/storage/usage-ledger-retention`) throw new Error(`unexpected fetch: ${url}`); + if ((init?.method ?? "GET") === "PUT") { + return Promise.resolve(Response.json({ enabled: true, maxBytes, currentBytes: 1234 })); + } + getCount += 1; + if (getCount === 1) return Promise.resolve(Response.json({ enabled: false, maxBytes, currentBytes: 1234 })); + return new Promise(resolve => { resolveStaleGet = resolve; }); + }) as typeof fetch; + + await act(async () => { + root = createRoot(host); + root.render(createElement(LanguageProvider, null, createElement(LocaleHarness, { apiBase }))); + }); + await settleTimers(); + + const localeSwitch = host.querySelector("#locale-switch"); + if (!localeSwitch) throw new Error("locale switch missing"); + await act(async () => { localeSwitch.click(); }); + await settleTimers(); + expect(getCount).toBe(2); + + const toggle = host.querySelector("button.switch"); + if (!toggle) throw new Error("retention switch missing"); + await act(async () => { + toggle.click(); + await Promise.resolve(); + }); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + + if (!resolveStaleGet) throw new Error("stale GET was not started"); + await act(async () => { + resolveStaleGet(new Response("", { status: 500 })); + await Promise.resolve(); + }); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + expect(host.querySelector('[role="alert"]')).toBeNull(); +}); + test("failed toggle keeps the last server state and surfaces an error", async () => { const apiBase = "http://usage-retention-failure"; const maxBytes = 256 * 1024 * 1024; From 4c3727ad65d6c34b35d1d56251b4ed8284cb794a Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:20:42 +0800 Subject: [PATCH 64/96] feat(usage): add retention limit editor --- .../usage/UsageLedgerRetentionControl.tsx | 115 +++++++++++++++++- gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/fr.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/pages/Usage.tsx | 3 +- gui/src/styles-usage-workspace.css | 23 ++++ gui/tests/usage-retention-control.test.ts | 45 +++++++ 13 files changed, 198 insertions(+), 6 deletions(-) diff --git a/gui/src/components/usage/UsageLedgerRetentionControl.tsx b/gui/src/components/usage/UsageLedgerRetentionControl.tsx index a508478584..9884138a64 100644 --- a/gui/src/components/usage/UsageLedgerRetentionControl.tsx +++ b/gui/src/components/usage/UsageLedgerRetentionControl.tsx @@ -1,7 +1,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { clampNumberDraft } from "../../clamp-draft"; import { formatBytes } from "../../format-bytes"; import { useI18n } from "../../i18n/shared"; import { Switch } from "../../ui"; +import { NumberStepper } from "../NumberStepper"; + +const MIB = 1024 ** 2; +const MAX_MIB = Math.floor(Number.MAX_SAFE_INTEGER / MIB); interface RetentionStatus { enabled: boolean; @@ -25,16 +30,31 @@ function parseStatus(value: unknown): RetentionStatus { }; } +function formatMaxMiBDraft(maxBytes: number): string { + return Number.isFinite(maxBytes) && maxBytes > 0 ? String(maxBytes / MIB) : ""; +} + +function parseMaxMiBDraft(raw: string): number | null { + const mib = Number(raw.trim()); + if (!Number.isSafeInteger(mib) || mib < 1 || mib > MAX_MIB) return null; + const bytes = mib * MIB; + return Number.isSafeInteger(bytes) ? bytes : null; +} + /** - * Minimal Usage-page toggle for the opt-in usage-ledger byte ceiling. + * Usage-page control for the opt-in usage-ledger byte ceiling. * - * 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. + * The dashboard keeps the switch as the primary action and exposes a MiB-aligned + * custom editor only while the policy is enabled. Toggling the switch always + * sends the exact server-reported byte value, so existing non-MiB-aligned values + * can never be rounded or silently rewritten. */ export default function UsageLedgerRetentionControl({ apiBase }: { apiBase: string }) { const { locale, t } = useI18n(); + const mibLabel = formatBytes(MIB, locale).replace(/^[\d.,]+\s*/, ""); const [status, setStatus] = useState(null); + const [customDraft, setCustomDraft] = useState(""); + const [editing, setEditing] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const loadGeneration = useRef(0); @@ -48,6 +68,7 @@ export default function UsageLedgerRetentionControl({ apiBase }: { apiBase: stri if (signal?.aborted || generation !== loadGeneration.current) return; setError(null); setStatus(next); + setCustomDraft(formatMaxMiBDraft(next.maxBytes)); } catch (errorValue) { // A successful PUT invalidates reads that started under the old policy. Stale reads // must be silent whether they eventually succeed, fail HTTP, reject, or parse badly. @@ -87,6 +108,8 @@ export default function UsageLedgerRetentionControl({ apiBase }: { apiBase: stri // after a locale change). Do not let that older snapshot repaint the saved state. loadGeneration.current += 1; setStatus(next); + setCustomDraft(formatMaxMiBDraft(next.maxBytes)); + setEditing(false); } catch { setError(t("usage.retention.error")); } finally { @@ -99,6 +122,29 @@ export default function UsageLedgerRetentionControl({ apiBase }: { apiBase: stri void persist(!status.enabled, status.maxBytes); }; + const saveCustom = () => { + if (!status || busy) return; + // If the saved value is not MiB-aligned, leaving the field untouched must + // not force an unrelated rounding write; the switch path remains exact. + if (customDraft === formatMaxMiBDraft(status.maxBytes)) { + setEditing(false); + return; + } + const nextMaxBytes = parseMaxMiBDraft(customDraft); + if (nextMaxBytes === null) { + setError(t("usage.retention.error")); + return; + } + void persist(true, nextMaxBytes); + }; + + const resetCustom = () => { + if (!status) return; + setCustomDraft(formatMaxMiBDraft(status.maxBytes)); + setEditing(false); + setError(null); + }; + return (
@@ -114,6 +160,67 @@ export default function UsageLedgerRetentionControl({ apiBase }: { apiBase: stri />
+ {status?.enabled && ( +
+ +
+ )} +

{t("usage.retention.current")}: {status?.currentBytes === undefined ? "—" : formatBytes(status.currentBytes, locale)} {status && ( diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 6e05204c40..4d0755b7ad 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -866,6 +866,8 @@ export const de: Record = { "usage.retention.enabled": "Größe des Nutzungsverlaufs begrenzen", "usage.retention.current": "Aktuelle Größe", "usage.retention.limit": "Maximale Größe", + "usage.retention.increase": "Maximale Größe erhöhen", + "usage.retention.decrease": "Maximale Größe verringern", "usage.retention.unlimited": "Unbegrenzt", "usage.retention.error": "Das Größenlimit für den Nutzungsverlauf konnte nicht aktualisiert werden.", "usage.retention.disabled": "Unbegrenzt — automatische Verlaufskomprimierung ist deaktiviert.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 65274de1fd..5e78aff50d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -917,6 +917,8 @@ export const en = { "usage.retention.enabled": "Limit usage history size", "usage.retention.current": "Current size", "usage.retention.limit": "Maximum size", + "usage.retention.increase": "Increase maximum size", + "usage.retention.decrease": "Decrease maximum size", "usage.retention.unlimited": "Unlimited", "usage.retention.error": "Could not update the usage history limit.", "usage.retention.disabled": "Unlimited — automatic history compaction is off.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 77913fd399..e8793ddb47 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -894,6 +894,8 @@ export const fr: Record = { "usage.retention.enabled": "Limiter la taille de l’historique d’utilisation", "usage.retention.current": "Taille actuelle", "usage.retention.limit": "Taille maximale", + "usage.retention.increase": "Augmenter la taille maximale", + "usage.retention.decrease": "Réduire la taille maximale", "usage.retention.unlimited": "Illimitée", "usage.retention.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.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 60797a105f..855c165113 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -832,6 +832,8 @@ export const ja: Record = { "usage.retention.enabled": "使用履歴のサイズを制限", "usage.retention.current": "現在のサイズ", "usage.retention.limit": "最大サイズ", + "usage.retention.increase": "最大サイズを増やす", + "usage.retention.decrease": "最大サイズを減らす", "usage.retention.unlimited": "無制限", "usage.retention.error": "使用履歴のサイズ上限を更新できませんでした。", "usage.retention.disabled": "無制限 — 使用履歴の自動圧縮はオフです。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index fd6962ec23..c319099360 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -900,6 +900,8 @@ export const ko: Record = { "usage.retention.enabled": "사용 기록 크기 제한", "usage.retention.current": "현재 크기", "usage.retention.limit": "최대 크기", + "usage.retention.increase": "최대 크기 늘리기", + "usage.retention.decrease": "최대 크기 줄이기", "usage.retention.unlimited": "제한 없음", "usage.retention.error": "사용 기록 크기 제한을 업데이트할 수 없습니다.", "usage.retention.disabled": "제한 없음 — 자동 사용 기록 압축이 꺼져 있습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index b40c42451d..c86edcea2a 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -887,6 +887,8 @@ export const ru: Record = { "usage.retention.enabled": "Ограничить размер истории использования", "usage.retention.current": "Текущий размер", "usage.retention.limit": "Максимальный размер", + "usage.retention.increase": "Увеличить максимальный размер", + "usage.retention.decrease": "Уменьшить максимальный размер", "usage.retention.unlimited": "Без ограничений", "usage.retention.error": "Не удалось обновить ограничение размера истории использования.", "usage.retention.disabled": "Без ограничений — автоматическое сжатие истории выключено.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 1a954c5959..938d9d366c 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -906,6 +906,8 @@ export const tr: Record = { "usage.retention.enabled": "Kullanım geçmişi boyutunu sınırla", "usage.retention.current": "Geçerli boyut", "usage.retention.limit": "Maksimum boyut", + "usage.retention.increase": "Maksimum boyutu artır", + "usage.retention.decrease": "Maksimum boyutu azalt", "usage.retention.unlimited": "Sınırsız", "usage.retention.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ı.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index f20d970ae1..5ad13864ad 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -712,6 +712,8 @@ export const zhTW: Record = { "usage.retention.enabled": "限制用量歷史大小", "usage.retention.current": "目前大小", "usage.retention.limit": "最大大小", + "usage.retention.increase": "增大最大大小", + "usage.retention.decrease": "減小最大大小", "usage.retention.unlimited": "無限制", "usage.retention.error": "無法更新用量歷史大小限制。", "usage.retention.disabled": "無限制 — 自動壓縮用量歷史已關閉。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 5d2f8b1251..444d7da7e8 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -881,6 +881,8 @@ export const zh: Record = { "usage.retention.enabled": "限制用量历史大小", "usage.retention.current": "当前大小", "usage.retention.limit": "最大大小", + "usage.retention.increase": "增大最大大小", + "usage.retention.decrease": "减小最大大小", "usage.retention.unlimited": "无限制", "usage.retention.error": "无法更新用量历史大小限制。", "usage.retention.disabled": "无限制 — 自动压缩用量历史已关闭。", diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index a8235ca139..93f2bcfb36 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -943,8 +943,6 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas )} - - {state.showSkeleton && !data ? ( ) : state.kind === "failed-cold" ? ( @@ -992,6 +990,7 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas /> )} + ); } diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css index ef0a6c225c..51a52c9f95 100644 --- a/gui/src/styles-usage-workspace.css +++ b/gui/src/styles-usage-workspace.css @@ -241,6 +241,29 @@ max-width: 68ch; } +.usage-retention-editor { + display: flex; + align-items: flex-end; + flex-wrap: wrap; + gap: var(--space-3); +} + +.usage-retention-limit { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-1); + margin: 0; +} + +.usage-retention-limit .field-label { + white-space: nowrap; +} + +.usage-retention-editor .codex-auto-switch-input { + width: 104px; +} + .usage-retention-current { margin: 0; white-space: nowrap; diff --git a/gui/tests/usage-retention-control.test.ts b/gui/tests/usage-retention-control.test.ts index e5137984a5..f122e07453 100644 --- a/gui/tests/usage-retention-control.test.ts +++ b/gui/tests/usage-retention-control.test.ts @@ -217,6 +217,51 @@ test("a stale failed GET is silent after a successful toggle", async () => { expect(host.querySelector('[role="alert"]')).toBeNull(); }); +test("shows a custom MiB editor only when enabled and saves the edited ceiling", async () => { + const apiBase = "http://usage-retention-custom"; + const initialMaxBytes = 768 * 1024 * 1024; + const writes: Array<{ enabled: boolean; maxBytes: number }> = []; + let maxBytes = initialMaxBytes; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url !== `${apiBase}/api/storage/usage-ledger-retention`) throw new Error(`unexpected fetch: ${url}`); + if ((init?.method ?? "GET") === "PUT") { + const body = JSON.parse(String(init?.body)) as { enabled: boolean; maxBytes: number }; + writes.push(body); + maxBytes = body.maxBytes; + return Response.json({ enabled: body.enabled, maxBytes, currentBytes: 1234 }); + } + return Response.json({ enabled: true, maxBytes, currentBytes: 1234 }); + }) as typeof fetch; + + await mount(apiBase); + + const input = host.querySelector('input[type="number"]'); + if (!input) throw new Error("custom retention input missing"); + expect(input.value).toBe("768"); + expect(input.min).toBe("1"); + expect(host.querySelector('[aria-haspopup="listbox"]')).toBeNull(); + + const increment = input.parentElement?.querySelector(".ocx-stepper__btn"); + if (!increment) throw new Error("retention stepper missing"); + await act(async () => { increment.click(); }); + await act(async () => { input.focus(); }); + await act(async () => { + input.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await Promise.resolve(); + }); + expect(writes).toEqual([{ enabled: true, maxBytes: 769 * 1024 * 1024 }]); + + const toggle = host.querySelector("button.switch"); + if (!toggle) throw new Error("retention switch missing"); + await act(async () => { + toggle.click(); + await Promise.resolve(); + }); + expect(host.querySelector('input[type="number"]')).toBeNull(); +}); + test("failed toggle keeps the last server state and surfaces an error", async () => { const apiBase = "http://usage-retention-failure"; const maxBytes = 256 * 1024 * 1024; From f33810e21e319c2b5d6ba654b020549c10d7279b Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:40:49 +0800 Subject: [PATCH 65/96] fix(usage): keep retention stepper edits committable --- gui/src/components/usage/UsageLedgerRetentionControl.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/gui/src/components/usage/UsageLedgerRetentionControl.tsx b/gui/src/components/usage/UsageLedgerRetentionControl.tsx index 9884138a64..911474bcca 100644 --- a/gui/src/components/usage/UsageLedgerRetentionControl.tsx +++ b/gui/src/components/usage/UsageLedgerRetentionControl.tsx @@ -58,6 +58,7 @@ export default function UsageLedgerRetentionControl({ apiBase }: { apiBase: stri const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const loadGeneration = useRef(0); + const inputRef = useRef(null); const load = useCallback(async (signal?: AbortSignal) => { const generation = ++loadGeneration.current; @@ -176,6 +177,7 @@ export default function UsageLedgerRetentionControl({ apiBase }: { apiBase: stri }} > { + inputRef.current?.focus(); setEditing(true); setCustomDraft(clampNumberDraft(customDraft, 1, 1, MAX_MIB)); }} onDecrement={() => { + inputRef.current?.focus(); setEditing(true); setCustomDraft(clampNumberDraft(customDraft, -1, 1, MAX_MIB)); }} From 199aa92545671cad9c850520a7f638ad79ae7070 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:41:11 +0800 Subject: [PATCH 66/96] fix(usage): scope retention editor field styles --- gui/src/styles-usage-workspace.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css index 51a52c9f95..047279fe4c 100644 --- a/gui/src/styles-usage-workspace.css +++ b/gui/src/styles-usage-workspace.css @@ -248,7 +248,7 @@ gap: var(--space-3); } -.usage-retention-limit { +.usage-retention-editor .usage-retention-limit { display: flex; flex-direction: column; align-items: flex-start; @@ -256,7 +256,7 @@ margin: 0; } -.usage-retention-limit .field-label { +.usage-retention-editor .usage-retention-limit .field-label { white-space: nowrap; } From 3767626c0358e8018d47cc5fd062e4b6bf96d407 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:46:34 +0800 Subject: [PATCH 67/96] docs(usage): align retained ceiling example --- skills/ocx/references/02_json_shapes.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/skills/ocx/references/02_json_shapes.md b/skills/ocx/references/02_json_shapes.md index c722638567..1bd17db565 100644 --- a/skills/ocx/references/02_json_shapes.md +++ b/skills/ocx/references/02_json_shapes.md @@ -37,7 +37,7 @@ One row per line. The fields worth branching on: | Field | Meaning | |---|---| | `requestId` | pass to `ocx logs explain` | -| `conversationId` | groups a conversation; also printed as `conv=` in human output | +| `conversationId` | groups a conversation; also printed as `conv=` | | `provider` / `model` | what actually served it | | `requestedModel` / `requestedAlias` | what the client asked for | | `status` / `durationMs` | outcome | @@ -119,11 +119,12 @@ one with 409. The CLI handles that for you — it always previews first. The status response is the management payload: ```json -{"enabled":false,"maxBytes":134217728,"currentBytes":67108864,"overLimit":false,"job":{"status":"idle"}} +{"enabled":false,"maxBytes":1073741824,"currentBytes":67108864,"overLimit":false,"job":{"status":"idle"}} ``` -The default policy is Unlimited (`enabled:false`); `maxBytes` is the saved ceiling that becomes -effective only after enabling retention. +The default policy is Unlimited (`enabled:false`). On an unconfigured installation, `maxBytes` +remembers 1 GiB (`1073741824`) as the ceiling used on first enable; later API/CLI changes preserve +the saved ceiling while retention is disabled. `set` returns the same fields with `ok: true`; it merges only the fields supplied by `--enabled` and `--mib`. Oversized ledgers are compacted by the automatic scheduler after the From b430aa030813169ea0b300a6b7d5411f4c14222d Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:47:33 +0800 Subject: [PATCH 68/96] test(usage): cover stepper blur persistence --- gui/tests/usage-retention-control.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/gui/tests/usage-retention-control.test.ts b/gui/tests/usage-retention-control.test.ts index f122e07453..cf95a54bde 100644 --- a/gui/tests/usage-retention-control.test.ts +++ b/gui/tests/usage-retention-control.test.ts @@ -246,9 +246,15 @@ test("shows a custom MiB editor only when enabled and saves the edited ceiling", const increment = input.parentElement?.querySelector(".ocx-stepper__btn"); if (!increment) throw new Error("retention stepper missing"); await act(async () => { increment.click(); }); - await act(async () => { input.focus(); }); + expect(testWindow.document.activeElement).toBe(input); + expect(input.value).toBe("769"); + expect(writes).toEqual([]); + + const outside = testWindow.document.createElement("button") as never as HTMLButtonElement; + outside.type = "button"; + host.appendChild(outside as never); await act(async () => { - input.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + outside.focus(); await Promise.resolve(); }); expect(writes).toEqual([{ enabled: true, maxBytes: 769 * 1024 * 1024 }]); From 1870fca6ff13de219a3cf4dfb95af4d6fa774300 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:48:46 +0800 Subject: [PATCH 69/96] docs(usage): keep unrelated JSON-shape wording --- skills/ocx/references/02_json_shapes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/ocx/references/02_json_shapes.md b/skills/ocx/references/02_json_shapes.md index 1bd17db565..2c5a92a7d8 100644 --- a/skills/ocx/references/02_json_shapes.md +++ b/skills/ocx/references/02_json_shapes.md @@ -37,7 +37,7 @@ One row per line. The fields worth branching on: | Field | Meaning | |---|---| | `requestId` | pass to `ocx logs explain` | -| `conversationId` | groups a conversation; also printed as `conv=` | +| `conversationId` | groups a conversation; also printed as `conv=` in human output | | `provider` / `model` | what actually served it | | `requestedModel` / `requestedAlias` | what the client asked for | | `status` / `durationMs` | outcome | From 1f147e29fe930698979b5a580ccb81fe2ed9dc86 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:50:17 +0800 Subject: [PATCH 70/96] docs(usage): document remembered 1 GiB ceiling --- .../src/content/docs/reference/configuration/server.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index d09f349154..957cebc25a 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -237,10 +237,12 @@ the file exceeds the configured limit; normal request handling is not blocked by } ``` -The default is **Unlimited** (`enabled: false`). To enable a ceiling, set `enabled: true` together -with `maxBytes`; it must be a safe integer of at least 1 MiB (`1048576`). A 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. +The effective default is **Unlimited** (`enabled: false`). An unconfigured installation remembers +**1 GiB** (`1073741824`) as `maxBytes`; that saved value becomes effective on first enable. Set +`maxBytes` explicitly only when selecting a different ceiling. Any explicit value must be a safe +integer of at least 1 MiB (`1048576`). A 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 From 41c19fb87962975498ddcd6e25bbdda6c08a2153 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:35:35 +0800 Subject: [PATCH 71/96] docs(pr): add real usage retention screenshot --- docs/screenshots/usage-retention-real.png | Bin 0 -> 41172 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/screenshots/usage-retention-real.png diff --git a/docs/screenshots/usage-retention-real.png b/docs/screenshots/usage-retention-real.png new file mode 100644 index 0000000000000000000000000000000000000000..c79f1eab004e14b73f31bbb9cc7aaa2e9c56c944 GIT binary patch literal 41172 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

@kTc!ZI2c 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

b4S+P&SCqv)x;-@Z>NTa1I9jhDubyvJZ3 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 A7XSbN literal 0 HcmV?d00001 From 7ddb3d63c810f75e00ea7980392df1505dd54361 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:28:04 +0800 Subject: [PATCH 72/96] chore(pr): keep runtime screenshot out of tree --- docs/screenshots/usage-retention-real.png | Bin 41172 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 docs/screenshots/usage-retention-real.png diff --git a/docs/screenshots/usage-retention-real.png b/docs/screenshots/usage-retention-real.png deleted file mode 100644 index c79f1eab004e14b73f31bbb9cc7aaa2e9c56c944..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41172 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

@kTc!ZI2c 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

b4S+P&SCqv)x;-@Z>NTa1I9jhDubyvJZ3 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 A7XSbN From 9dd95cdf4db66fcbc325b80192160e6836875e06 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:39:46 +0800 Subject: [PATCH 73/96] test(usage): fold retention coverage into usage domain --- tests/usage/usage-ledger-scanner.test.ts | 334 ++++++++++++++++++++++- 1 file changed, 331 insertions(+), 3 deletions(-) diff --git a/tests/usage/usage-ledger-scanner.test.ts b/tests/usage/usage-ledger-scanner.test.ts index b5bf34adc8..2b5bae7e88 100644 --- a/tests/usage/usage-ledger-scanner.test.ts +++ b/tests/usage/usage-ledger-scanner.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { appendFileSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, existsSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -10,6 +10,24 @@ import { UsageLedgerRebuildRequiredError, } from "../../src/usage/ledger-scanner"; import { usageLogIdentityKey, usageLogPath, type PersistedUsageEntry } from "../../src/usage/log"; +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, + normalizeUsageLedgerRetention, + prepareUsageLedgerCompaction, + usageLedgerRevisionMatches, +} from "../../src/usage/ledger-retention"; +import { parseUsageLedgerRetentionInput } from "../../src/usage/ledger-retention-config"; +import { + commitPreparedUsageLedgerCompaction, + getUsageLedgerRetentionJobState, + invalidateUsageLedgerRetentionRun, + requestUsageLedgerRetentionRun, + resetUsageLedgerRetentionJobForTests, +} from "../../src/usage/ledger-retention-job"; +import { getConfigPath, getDefaultConfig, saveConfig } from "../../src/config"; let testDir = ""; let previousHome: string | undefined; @@ -256,7 +274,7 @@ describe("usage ledger cooperative scanner", () => { test("a torn EOF keeps the checkpoint behind it and is counted once after completion", async () => { const committed = line("committed"); - const completedRow = Buffer.from(JSON.stringify(entry("완성-🙂"))); + const completedRow = Buffer.from(JSON.stringify(entry("완成-🙂"))); const splitAt = completedRow.indexOf(Buffer.from("🙂")) + 2; writeFileSync(usageLogPath(), Buffer.concat([ Buffer.from(committed), @@ -286,7 +304,7 @@ describe("usage ledger cooperative scanner", () => { expectedProcessedThroughDigest: first.processedThroughDigest, onEntry: value => completedIds.push(value.requestId), }); - expect(completedIds).toEqual(["완성-🙂"]); + expect(completedIds).toEqual(["완成-🙂"]); expect(second.invalidRows).toBe(0); const afterIds: string[] = []; @@ -496,3 +514,313 @@ describe("usage ledger cooperative scanner", () => { })).rejects.toBe(sentinel); }); }); + +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(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("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, + }); + }); + + 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, MIN_USAGE_LEDGER_MAX_BYTES + 0.5]) { + 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 while retaining a complete row at the ceiling", () => { + const dir = home(); + const path = join(dir, "usage.jsonl"); + const complete = jsonlRowOfSize("complete", MIN_USAGE_LEDGER_MAX_BYTES); + 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).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"); + 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("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("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); + 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 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`; + 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; + let replaced = false; + let discarded = false; + + const result = commitPreparedUsageLedgerCompaction(prepared, { + activeTurnCount: () => 0, + closeHistoryIndex: () => { closed = true; }, + rename: (from, to) => { + expect(closed).toBe(true); + 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); + }); + + test("invalidating a policy generation prevents a prepared Worker candidate from publishing", async () => { + const dir = home(); + const previousRetentionHome = 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); + 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 (previousRetentionHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousRetentionHome; + expect(getConfigPath()).not.toBe(join(dir, "config.json")); + } + }); +}); From 00e2233cfafb42af5ad3f97546d1c7fbb4be3efb Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:40:36 +0800 Subject: [PATCH 74/96] test(cli): fold usage-limit coverage into storage suite --- tests/cli/cli-storage-inspect.test.ts | 97 ++++++++++++++++++++------- 1 file changed, 74 insertions(+), 23 deletions(-) diff --git a/tests/cli/cli-storage-inspect.test.ts b/tests/cli/cli-storage-inspect.test.ts index 336cc7fc90..9d7d936514 100644 --- a/tests/cli/cli-storage-inspect.test.ts +++ b/tests/cli/cli-storage-inspect.test.ts @@ -47,10 +47,8 @@ describe("ocx storage cleanup", () => { try { code = await handleStorageCommand(["cleanup", "--percent", "25"], deps); } finally { cap.restore(); } expect(code).toBe(0); - // Exactly one call, and it is the preview. expect(calls).toHaveLength(1); expect(calls[0]?.path).toBe("/api/storage/cleanup/preview"); - // The assertion that matters: the deleting route was never touched. expect(calls.some(c => c.path === "/api/storage/cleanup")).toBe(false); expect(cap.out.join("\n")).toContain("Nothing was deleted"); expect(cap.out.join("\n")).toContain("archived_sessions/a.jsonl"); @@ -66,8 +64,6 @@ describe("ocx storage cleanup", () => { expect(code).toBe(0); expect(calls.map(c => c.path)).toEqual(["/api/storage/cleanup/preview", "/api/storage/cleanup"]); - // The digest binds the run to the preview it was authorized against; the server rejects a - // stale one with 409, so forwarding it is not optional politeness. expect(calls[1]?.body).toEqual({ percent: 25, mode: "quarantine", digest: "digest-abc" }); }); @@ -140,18 +136,10 @@ describe("ocx storage trash and policy", () => { const cap = capture(); try { await handleStorageCommand(["policy", "set", "--percent", "40"], deps); } finally { cap.restore(); } expect(calls[0]?.method).toBe("PUT"); - // `enabled` is absent, which the server reads as "keep the stored value". Sending - // `enabled: false` here would silently disable a policy the operator never mentioned. - // The percent travels inside `target`: the PUT contract has no top-level `percent`, so - // that shape was accepted, dropped, and left the stored target in place. expect(calls[0]?.body).toEqual({ target: { removeOldestPercent: 40 } }); }); test("--percent reaches the server in the shape the policy target actually reads", async () => { - // A top-level `percent` round-trips as HTTP 200 while changing nothing: - // `normalizeStorageCleanupPolicy` reads only `target`, so a policy still holding the - // default 25% stayed at 25% after `--percent 10` reported success — cleanup remained - // authorized to delete more than the operator asked for. const { calls, deps } = harness(() => ({ json: { ok: true, policy: {} } })); const cap = capture(); try { await handleStorageCommand(["policy", "set", "--percent", "10"], deps); } finally { cap.restore(); } @@ -161,8 +149,6 @@ describe("ocx storage trash and policy", () => { }); test("an out-of-range percent is still sent so the server can name the rejection", async () => { - // Rejecting locally would duplicate the server's 1-100 vocabulary. A named 400 is a - // refused write; the defect being fixed here was a silent accepted one. const { calls, deps } = harness(() => ({ json: { ok: true, policy: {} } })); const cap = capture(); try { await handleStorageCommand(["policy", "set", "--percent", "0"], deps); } finally { cap.restore(); } @@ -181,8 +167,6 @@ describe("ocx storage trash and policy", () => { describe("ocx storage keeps its old meaning", () => { test("a bare invocation and a leading flag both read the report", async () => { - // `ocx storage` and `ocx storage --json` were an alias of `observe storage` before this - // module existed. A leading flag must not be parsed as a subcommand name. for (const argv of [[], ["--json"]]) { const { calls, deps } = harness(() => ({ json: { codexHome: "/tmp", total: { bytes: 1 } } })); const cap = capture(); @@ -194,9 +178,6 @@ describe("ocx storage keeps its old meaning", () => { }); test("codex-logs still reaches the log-guard route", async () => { - // Doctor and the published Log Guard guides still tell the operator to run - // `ocx storage codex-logs repair`. Treating that as an unknown subcommand - // would make the documented recovery path exit 2. const { calls, deps } = harness(() => ({ json: { ok: true } })); const cap = capture(); let code: number; @@ -213,7 +194,6 @@ describe("ocx inspect", () => { try { await handleInspectCommand(["star"], deps); } finally { cap.restore(); } expect(calls).toHaveLength(1); expect(calls[0]?.method).toBe("GET"); - // No POST, ever: it spends the operator GitHub identity and requires a dashboard session. expect(calls.every(c => c.method === "GET")).toBe(true); expect(cap.out.join("\n")).toContain("only you can do it"); }); @@ -255,8 +235,6 @@ describe("ocx integration native", () => { ] }; test("the list renders per-client state instead of an item count", async () => { - // The shared flattener rendered this array as `clients: 2 item(s)`, discarding every - // column the operator asked for. const { deps } = harness(() => ({ json: CLIENTS })); const cap = capture(); try { await handleIntegrationCommand(["native", "list"], deps); } finally { cap.restore(); } @@ -265,7 +243,6 @@ describe("ocx integration native", () => { expect(out).toContain("claude"); expect(out).toContain("grok"); expect(out).toContain("stale"); - // A blocked disable explains why a toggle did not take effect, so it is never dropped. expect(out).toContain("disable blocked: in use"); }); @@ -291,3 +268,77 @@ describe("ocx integration native", () => { } }); }); + +const RETENTION_STATUS = { + enabled: false, + maxBytes: 128 * 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: RETENTION_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, ...RETENTION_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, ...RETENTION_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: RETENTION_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 is no longer exposed", 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); + }); +}); From 018a26e8f034351ca5f72c6bc42981410849c1f5 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:41:39 +0800 Subject: [PATCH 75/96] test(cli): preserve storage coverage while adding usage-limit cases --- tests/cli/cli-storage-inspect.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/cli/cli-storage-inspect.test.ts b/tests/cli/cli-storage-inspect.test.ts index 9d7d936514..9a32ca10ff 100644 --- a/tests/cli/cli-storage-inspect.test.ts +++ b/tests/cli/cli-storage-inspect.test.ts @@ -47,8 +47,10 @@ describe("ocx storage cleanup", () => { try { code = await handleStorageCommand(["cleanup", "--percent", "25"], deps); } finally { cap.restore(); } expect(code).toBe(0); + // Exactly one call, and it is the preview. expect(calls).toHaveLength(1); expect(calls[0]?.path).toBe("/api/storage/cleanup/preview"); + // The assertion that matters: the deleting route was never touched. expect(calls.some(c => c.path === "/api/storage/cleanup")).toBe(false); expect(cap.out.join("\n")).toContain("Nothing was deleted"); expect(cap.out.join("\n")).toContain("archived_sessions/a.jsonl"); @@ -64,6 +66,8 @@ describe("ocx storage cleanup", () => { expect(code).toBe(0); expect(calls.map(c => c.path)).toEqual(["/api/storage/cleanup/preview", "/api/storage/cleanup"]); + // The digest binds the run to the preview it was authorized against; the server rejects a + // stale one with 409, so forwarding it is not optional politeness. expect(calls[1]?.body).toEqual({ percent: 25, mode: "quarantine", digest: "digest-abc" }); }); @@ -136,10 +140,18 @@ describe("ocx storage trash and policy", () => { const cap = capture(); try { await handleStorageCommand(["policy", "set", "--percent", "40"], deps); } finally { cap.restore(); } expect(calls[0]?.method).toBe("PUT"); + // `enabled` is absent, which the server reads as "keep the stored value". Sending + // `enabled: false` here would silently disable a policy the operator never mentioned. + // The percent travels inside `target`: the PUT contract has no top-level `percent`, so + // that shape was accepted, dropped, and left the stored target in place. expect(calls[0]?.body).toEqual({ target: { removeOldestPercent: 40 } }); }); test("--percent reaches the server in the shape the policy target actually reads", async () => { + // A top-level `percent` round-trips as HTTP 200 while changing nothing: + // `normalizeStorageCleanupPolicy` reads only `target`, so a policy still holding the + // default 25% stayed at 25% after `--percent 10` reported success — cleanup remained + // authorized to delete more than the operator asked for. const { calls, deps } = harness(() => ({ json: { ok: true, policy: {} } })); const cap = capture(); try { await handleStorageCommand(["policy", "set", "--percent", "10"], deps); } finally { cap.restore(); } @@ -149,6 +161,8 @@ describe("ocx storage trash and policy", () => { }); test("an out-of-range percent is still sent so the server can name the rejection", async () => { + // Rejecting locally would duplicate the server's 1-100 vocabulary. A named 400 is a + // refused write; the defect being fixed here was a silent accepted one. const { calls, deps } = harness(() => ({ json: { ok: true, policy: {} } })); const cap = capture(); try { await handleStorageCommand(["policy", "set", "--percent", "0"], deps); } finally { cap.restore(); } @@ -167,6 +181,8 @@ describe("ocx storage trash and policy", () => { describe("ocx storage keeps its old meaning", () => { test("a bare invocation and a leading flag both read the report", async () => { + // `ocx storage` and `ocx storage --json` were an alias of `observe storage` before this + // module existed. A leading flag must not be parsed as a subcommand name. for (const argv of [[], ["--json"]]) { const { calls, deps } = harness(() => ({ json: { codexHome: "/tmp", total: { bytes: 1 } } })); const cap = capture(); @@ -178,6 +194,9 @@ describe("ocx storage keeps its old meaning", () => { }); test("codex-logs still reaches the log-guard route", async () => { + // Doctor and the published Log Guard guides still tell the operator to run + // `ocx storage codex-logs repair`. Treating that as an unknown subcommand + // would make the documented recovery path exit 2. const { calls, deps } = harness(() => ({ json: { ok: true } })); const cap = capture(); let code: number; @@ -194,6 +213,7 @@ describe("ocx inspect", () => { try { await handleInspectCommand(["star"], deps); } finally { cap.restore(); } expect(calls).toHaveLength(1); expect(calls[0]?.method).toBe("GET"); + // No POST, ever: it spends the operator GitHub identity and requires a dashboard session. expect(calls.every(c => c.method === "GET")).toBe(true); expect(cap.out.join("\n")).toContain("only you can do it"); }); @@ -235,6 +255,8 @@ describe("ocx integration native", () => { ] }; test("the list renders per-client state instead of an item count", async () => { + // The shared flattener rendered this array as `clients: 2 item(s)`, discarding every + // column the operator asked for. const { deps } = harness(() => ({ json: CLIENTS })); const cap = capture(); try { await handleIntegrationCommand(["native", "list"], deps); } finally { cap.restore(); } @@ -243,6 +265,7 @@ describe("ocx integration native", () => { expect(out).toContain("claude"); expect(out).toContain("grok"); expect(out).toContain("stale"); + // A blocked disable explains why a toggle did not take effect, so it is never dropped. expect(out).toContain("disable blocked: in use"); }); From 7f2b28dd89a33c68446c7abdfbec3577719ae4a1 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:41:58 +0800 Subject: [PATCH 76/96] test: remove unregistered retention test basename --- tests/usage-ledger-retention-v2.test.ts | 345 ------------------------ 1 file changed, 345 deletions(-) delete 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 deleted file mode 100644 index 38ed87e4c2..0000000000 --- a/tests/usage-ledger-retention-v2.test.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { - appendFileSync, - existsSync, - mkdtempSync, - readdirSync, - 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, - normalizeUsageLedgerRetention, - prepareUsageLedgerCompaction, - usageLedgerRevisionMatches, -} from "../src/usage/ledger-retention"; -import { parseUsageLedgerRetentionInput } from "../src/usage/ledger-retention-config"; -import { - commitPreparedUsageLedgerCompaction, - getUsageLedgerRetentionJobState, - invalidateUsageLedgerRetentionRun, - requestUsageLedgerRetentionRun, - resetUsageLedgerRetentionJobForTests, -} from "../src/usage/ledger-retention-job"; -import { getConfigPath, getDefaultConfig, saveConfig } from "../src/config"; - -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(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("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, - }); - }); - - 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, MIN_USAGE_LEDGER_MAX_BYTES + 0.5]) { - 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 while retaining a complete row at the ceiling", () => { - const dir = home(); - const path = join(dir, "usage.jsonl"); - const complete = jsonlRowOfSize("complete", MIN_USAGE_LEDGER_MAX_BYTES); - 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).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"); - 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("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("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); - 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 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`; - 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; - let replaced = false; - let discarded = false; - - const result = commitPreparedUsageLedgerCompaction(prepared, { - activeTurnCount: () => 0, - closeHistoryIndex: () => { closed = true; }, - rename: (from, to) => { - expect(closed).toBe(true); - 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); - }); - - 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 693eefb329b8f39946e71309f9e943dd0d0ff76b Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:42:08 +0800 Subject: [PATCH 77/96] test: remove unregistered usage-limit test basename --- tests/cli/cli-storage-usage-limit.test.ts | 102 ---------------------- 1 file changed, 102 deletions(-) delete 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 deleted file mode 100644 index 27bf1120b4..0000000000 --- a/tests/cli/cli-storage-usage-limit.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -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: 128 * 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 is no longer exposed", 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); - }); -}); From 097a8aed29a394d9707942d3a594fd09ec46240d Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:47:57 +0800 Subject: [PATCH 78/96] test(usage): preserve scanner unicode fixture --- tests/usage/usage-ledger-scanner.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/usage/usage-ledger-scanner.test.ts b/tests/usage/usage-ledger-scanner.test.ts index 2b5bae7e88..a2999f342a 100644 --- a/tests/usage/usage-ledger-scanner.test.ts +++ b/tests/usage/usage-ledger-scanner.test.ts @@ -274,7 +274,7 @@ describe("usage ledger cooperative scanner", () => { test("a torn EOF keeps the checkpoint behind it and is counted once after completion", async () => { const committed = line("committed"); - const completedRow = Buffer.from(JSON.stringify(entry("완成-🙂"))); + const completedRow = Buffer.from(JSON.stringify(entry("완성-🙂"))); const splitAt = completedRow.indexOf(Buffer.from("🙂")) + 2; writeFileSync(usageLogPath(), Buffer.concat([ Buffer.from(committed), @@ -304,7 +304,7 @@ describe("usage ledger cooperative scanner", () => { expectedProcessedThroughDigest: first.processedThroughDigest, onEntry: value => completedIds.push(value.requestId), }); - expect(completedIds).toEqual(["완成-🙂"]); + expect(completedIds).toEqual(["완성-🙂"]); expect(second.invalidRows).toBe(0); const afterIds: string[] = []; From cbea1de666afe9b1f4f7297cc66d93d1e0a7c44a Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:56:37 +0800 Subject: [PATCH 79/96] test(gui): include usage retention stepper locale keys --- gui/tests/i18n-locales.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gui/tests/i18n-locales.test.ts b/gui/tests/i18n-locales.test.ts index 28e18d2c46..0eb4d589b5 100644 --- a/gui/tests/i18n-locales.test.ts +++ b/gui/tests/i18n-locales.test.ts @@ -76,6 +76,8 @@ describe("i18n locale contracts", () => { "usage.retention.enabled", "usage.retention.current", "usage.retention.limit", + "usage.retention.increase", + "usage.retention.decrease", "usage.retention.unlimited", "usage.retention.error", "usage.retention.disabled", From 2f4d8ff035f49a265571b1911d35c8464c2297ca Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Fri, 11 Sep 2026 04:56:56 +0800 Subject: [PATCH 80/96] chore(skills): regenerate ocx management surface --- skills/ocx/references/01_management_surface.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 464662af1f..bc4482fdc7 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -177,7 +177,6 @@ Disk usage under CODEX_HOME, with the log-guard protection report. | Method | Route | |---|---| | GET | `/api/storage` | - | Flag | Value | Meaning | |---|---|---| | `--json` | boolean | Emit the storage report as JSON. | @@ -537,7 +536,6 @@ Show or set how many consecutive requests stay on one account. JSON mode: `envelope`. - Only meaningful under the sticky-capable strategies; the pool strategy is the other half of this setting. - ### `ocx storage cleanup` Preview or delete the oldest archived sessions by percentage. @@ -727,6 +725,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 38 -- of those, state-changing: 17 -- head-resolved invocations: 2 +- declared capabilities: 39 +- of those, state-changing: 18 +- head-resolved invocations: 2 \ No newline at end of file From 175df716bc6da57a53208dcd9f525b690b8b89b7 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Fri, 11 Sep 2026 04:59:07 +0800 Subject: [PATCH 81/96] fix(skills): preserve generated surface formatting --- skills/ocx/references/01_management_surface.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index bc4482fdc7..571f592f92 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -177,6 +177,7 @@ Disk usage under CODEX_HOME, with the log-guard protection report. | Method | Route | |---|---| | GET | `/api/storage` | + | Flag | Value | Meaning | |---|---|---| | `--json` | boolean | Emit the storage report as JSON. | @@ -536,6 +537,7 @@ Show or set how many consecutive requests stay on one account. JSON mode: `envelope`. - Only meaningful under the sticky-capable strategies; the pool strategy is the other half of this setting. + ### `ocx storage cleanup` Preview or delete the oldest archived sessions by percentage. @@ -727,4 +729,4 @@ JSON mode: `payload`. - declared capabilities: 39 - of those, state-changing: 18 -- head-resolved invocations: 2 \ No newline at end of file +- head-resolved invocations: 2 From 60cec195ee2495dcb9fbdbad31cc6b71548cc806 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 13:26:48 +0900 Subject: [PATCH 82/96] fix(codex): emit max/ultra efforts unconditionally and expire stale clamp diagnostics The observed-runtime clamp matched a persisted diagnostic to the current runtime by path alone, so an in-place Codex upgrade (the normal Windows case) kept max/ultra hidden forever. The diagnostic is now version-aware, rungs nothing clamps any more no longer keep the warning alive, and the sync clamp exempts max/ultra from the observed-runtime intersection while hub admission stays fail-closed. ocx status, ocx doctor, and /api/settings now read one shared predicate. Refs #4204. --- .../content/docs/guides/codex-app-models.md | 8 ++ src/cli/doctor.ts | 11 +- src/cli/status.ts | 8 +- src/codex/catalog/effort.ts | 13 ++- src/codex/runtime.ts | 40 ++++++- src/server/management/config-routes.ts | 4 +- tests/cli/cli-status-json.test.ts | 4 +- tests/codex-integration/codex-catalog.test.ts | 43 +++++-- ...odex-convergence-account-selectors.test.ts | 16 ++- tests/codex-integration/codex-runtime.test.ts | 109 +++++++++++++++++- .../codex-integration/reserve-catalog.test.ts | 26 +++++ tests/config/settings-stream-mode.test.ts | 4 +- 12 files changed, 254 insertions(+), 32 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 6f9b756789..ccd98f0453 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -254,6 +254,14 @@ On the wire, routed adapters map or clamp unsupported tiers. For older native mo ladder stops at `xhigh`, `nativeEffortClamp` maps a direct `max` or an `ultra` selection to `xhigh` (for example, GPT-5.5). Sol, Terra, and Luna have a real `max` rung. +Catalog advertisement of the two top tiers is unconditional: `ocx sync` no longer removes `max` or +`ultra` when the installed Codex binary is too old to offer them — Codex versions without those +rungs are out of support, and hiding them from current clients costs more than it buys. Other +rungs are still intersected with the observed runtime ladder, and a clamp diagnostic recorded by a +previous binary stops applying once the binary at that path reports a different version (the +in-place upgrade case), so `ocx status` and `ocx doctor` stop warning about a clamp the upgraded +runtime no longer needs. + ## Fast tier rules Codex stores fast mode as: diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index a769252620..0f8fffedf6 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -56,6 +56,8 @@ import { import { collectStartupHealth, formatStartupRoutingDetail, startupHealthSummary } from "../codex/autostart-health"; import { displayCodexRuntimePath, + effortClampAppliesToRuntime, + liveRemovedEfforts, loadLastEffortClamp, persistCodexRuntime, resolveAndPersistCodexRuntime, @@ -1177,9 +1179,14 @@ export async function runDoctor(args: string[] = []): Promise { console.log(" Suggested: set CODEX_CLI_PATH to the desired binary and run ocx sync."); console.log(" Optional: ocx doctor --fix-codex-runtime"); } + // Doctor used to warn on any non-empty `removedEfforts`, while `ocx status` asked + // `effortClampAppliesToRuntime` — so the two could disagree about the same file, and doctor + // would tell an operator to install a newer Codex while the resolved runtime was already + // newer than the one the diagnostic described. Both surfaces now read the same predicate. const lastClamp = loadLastEffortClamp(); - if (lastClamp && lastClamp.removedEfforts.length > 0) { - console.log(` !! ${lastClamp.removedEfforts.join(" and ")} were removed during catalog sync.`); + if (effortClampAppliesToRuntime(lastClamp, resolved.runtime)) { + const live = liveRemovedEfforts(lastClamp); + console.log(` !! ${live.join(" and ")} were removed during catalog sync.`); console.log(" Suggested: set CODEX_CLI_PATH to a newer Codex binary and run ocx sync."); } } diff --git a/src/cli/status.ts b/src/cli/status.ts index 1fcbe33466..1b5d9bd508 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -8,7 +8,7 @@ import { diagnoseService, serviceLogPath } from "../service"; import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health"; import { getCodexRoutingKind } from "../codex/inject"; import { diagnoseCodexShim } from "../codex/shim"; -import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, resolveCodexRuntime } from "../codex/runtime"; +import { displayCodexRuntimePath, effortClampAppliesToRuntime, liveRemovedEfforts, loadLastEffortClamp, resolveCodexRuntime } from "../codex/runtime"; import { packageVersion } from "./help"; import { computeVersionSkew, type VersionSkew } from "./version-skew"; import { redactSecretString, redactUserPath } from "../lib/redact"; @@ -573,7 +573,7 @@ export async function collectStatus(): Promise { } if (clampActive) { warningParts.push( - `Catalog clamp removed: ${lastClamp!.removedEfforts.join(", ")}. Run ocx doctor for diagnosis and recovery.`, + `Catalog clamp removed: ${liveRemovedEfforts(lastClamp).join(", ")}. Run ocx doctor for diagnosis and recovery.`, ); } // A Grok fence naming a port we are not listening on is invisible everywhere else: @@ -611,7 +611,9 @@ export async function collectStatus(): Promise { warning: warningParts.length > 0 ? warningParts.join(" ") : null, catalogClamp: { active: clampActive, - removedEfforts: clampActive ? (lastClamp?.removedEfforts ?? []) : [], + // Report what is still clamped, not what the file happens to name: a leftover written + // before the max/ultra exemption lists rungs nothing removes any more. + removedEfforts: clampActive ? [...liveRemovedEfforts(lastClamp)] : [], runtimeVersion: clampActive ? (lastClamp?.runtimeVersion ?? null) : null, }, }; diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index ba324f12a3..47dc0ab572 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -46,6 +46,7 @@ import { displayCodexRuntimePath, persistEffortClamp, resolveAndPersistCodexRuntime, + UNCLAMPABLE_REASONING_EFFORTS, type EffortClampDiagnostic, } from "../runtime"; @@ -354,7 +355,13 @@ export function clampEntryToCodexSupportedEfforts( ? entry.supported_reasoning_levels as Array<{ effort?: string }> : null; if (levels && levels.length > 0) { - const kept = levels.filter(level => typeof level?.effort === "string" && supported.has(level.effort)); + // A rung survives when the observed runtime offers it OR when it is one of the rungs the + // clamp no longer removes (max/ultra, per the unconditional-emission ruling): CLI versions + // that genuinely lack them are out of support, and hiding them from current clients costs + // more than it buys. Hub admission is a different question and stays fail-closed in + // `catalogEffortCompatibility` below. + const kept = levels.filter(level => typeof level?.effort === "string" + && (supported.has(level.effort) || UNCLAMPABLE_REASONING_EFFORTS.has(level.effort))); if (requiresExactReserveEfforts(entry)) { entry.supported_reasoning_levels = kept; if (kept.length === 0) { @@ -375,7 +382,9 @@ export function clampEntryToCodexSupportedEfforts( .map(level => ({ ...level })); } const currentDefault = entry.default_reasoning_level; - if (typeof currentDefault === "string" && !supported.has(currentDefault)) { + if (typeof currentDefault === "string" + && !supported.has(currentDefault) + && !UNCLAMPABLE_REASONING_EFFORTS.has(currentDefault)) { const surviving = (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : []) .flatMap(level => typeof (level as { effort?: string })?.effort === "string" ? [(level as { effort: string }).effort] diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index 9a12395808..576f454c9c 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -427,13 +427,47 @@ function sameRuntimeCommand(a: string, b: string): boolean { return a.trim().toLowerCase() === b.trim().toLowerCase(); } -/** True when a persisted clamp diagnostic still applies to the currently selected runtime. */ +/** + * Rungs OpenCodex no longer lets the observed-runtime intersection remove, so a persisted + * diagnostic naming only these describes a policy that is gone rather than a live restriction. + * This is the single copy of the exemption: `catalog/effort.ts` imports it for the clamp + * predicate, and a leftover file written before the exemption must not keep warning about + * rungs the next sync will stop removing. + */ +export const UNCLAMPABLE_REASONING_EFFORTS: ReadonlySet = new Set(["max", "ultra"]); + +/** Removals that still describe a real restriction, ignoring rungs nothing clamps any more. */ +export function liveRemovedEfforts( + diagnostic: EffortClampDiagnostic | null | undefined, +): readonly string[] { + if (!diagnostic) return []; + return diagnostic.removedEfforts.filter(effort => !UNCLAMPABLE_REASONING_EFFORTS.has(effort)); +} + +/** + * True when a persisted clamp diagnostic still applies to the currently selected runtime. + * + * Two ways a stored diagnostic stops describing reality: + * + * 1. Every rung it names is one nothing clamps any more, so the file is inert until the next + * sync unlinks it. + * 2. The binary at that path was upgraded in place. Windows updates Codex without moving the + * executable, so path equality alone kept a 0.135.0 observation "current" for a 0.154.0 + * runtime whose own bundled catalog carried the rungs the diagnostic claimed were missing. + * A known version mismatch therefore wins over a path match; an unknown version on either + * side stays conservative, because absence of a version is not evidence of an upgrade. + */ export function effortClampAppliesToRuntime( diagnostic: EffortClampDiagnostic | null | undefined, runtime: Pick, ): boolean { - if (!diagnostic || diagnostic.removedEfforts.length === 0) return false; - if (sameRuntimeCommand(diagnostic.runtimePath, runtime.command)) return true; + if (!diagnostic || liveRemovedEfforts(diagnostic).length === 0) return false; + if (sameRuntimeCommand(diagnostic.runtimePath, runtime.command)) { + if (diagnostic.runtimeVersion && runtime.version) { + return diagnostic.runtimeVersion === runtime.version; + } + return true; + } return Boolean( diagnostic.runtimeVersion && runtime.version diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 527178ba0f..1faa056c8d 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -110,7 +110,7 @@ import { applySystemEnvToggle } from "../system-env"; import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache"; import { runWindowsTrayAction } from "../windows-tray-control"; import { runStartupInstallAction, type StartupInstallAction } from "../startup-action-control"; -import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, resolveCodexRuntime } from "../../codex/runtime"; +import { displayCodexRuntimePath, effortClampAppliesToRuntime, liveRemovedEfforts, loadLastEffortClamp, resolveCodexRuntime } from "../../codex/runtime"; import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; @@ -344,7 +344,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise 0 ? warningParts.join(" ") : null, diff --git a/tests/cli/cli-status-json.test.ts b/tests/cli/cli-status-json.test.ts index 2aae43a671..c4fe01752a 100644 --- a/tests/cli/cli-status-json.test.ts +++ b/tests/cli/cli-status-json.test.ts @@ -396,7 +396,7 @@ describe("CLI status JSON", () => { persistEffortClamp({ runtimePath: fakeCodex, runtimeVersion: "0.133.0", - removedEfforts: ["max", "ultra"], + removedEfforts: ["xhigh"], affectedModels: ["gpt-5.6-sol"], }, { configDir: opencodexHome }); resetCodexRuntimeResolveCacheForTests(); @@ -421,7 +421,7 @@ describe("CLI status JSON", () => { expect(parsed.codexRuntime?.version).toBe("0.133.0"); expect(parsed.codexRuntime?.catalogClamp).toEqual({ active: true, - removedEfforts: ["max", "ultra"], + removedEfforts: ["xhigh"], runtimeVersion: "0.133.0", }); } finally { diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 38f362c81b..b3d263a488 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -7064,23 +7064,25 @@ describe("Codex reasoning-effort capability clamp", () => { const supported = supportedCodexReasoningEffortsFromObservedCatalog(observed); const clamp = clampCatalogModelsToObservedCodexSupport(models, supported); + // max and ultra are exempt from the observed-runtime intersection: nothing is removed, + // the ladder is untouched, and an ultra default survives a runtime that stops at xhigh. expect(clamp).toEqual({ - removedEfforts: ["max", "ultra"], - affectedModels: ["openrouter/example"], + removedEfforts: [], + affectedModels: [], }); expect(models[0]!.supported_reasoning_levels.map(level => level.effort)) - .toEqual(["low", "medium", "high", "xhigh"]); - expect(models[0]!.default_reasoning_level).toBe("xhigh"); + .toEqual(["low", "medium", "high", "xhigh", "max", "ultra"]); + expect(models[0]!.default_reasoning_level).toBe("ultra"); expect(JSON.stringify(observed)).toBe(before); }); - test("strips max and ultra when the installed Codex ladder stops at xhigh", () => { + test("keeps max and ultra when the installed Codex ladder stops at xhigh", () => { const models = [routedEntry()]; clampCatalogModelsToCodexSupport(models, bundledCatalogDeps(["low", "medium", "high", "xhigh"])); expect(models[0]!.supported_reasoning_levels.map(level => level.effort)) - .toEqual(["low", "medium", "high", "xhigh"]); + .toEqual(["low", "medium", "high", "xhigh", "max", "ultra"]); }); test("preserves max and ultra when the installed Codex ladder includes them", () => { @@ -7092,7 +7094,7 @@ describe("Codex reasoning-effort capability clamp", () => { .toEqual(["low", "medium", "high", "xhigh", "max", "ultra"]); }); - test("falls back to the conservative universal ladder when every advertised effort is unsupported", () => { + test("a max/ultra-only ladder survives instead of collapsing to the universal fallback", () => { const entry = { supported_reasoning_levels: [{ effort: "max" }, { effort: "ultra" }], default_reasoning_level: "ultra", @@ -7100,16 +7102,39 @@ describe("Codex reasoning-effort capability clamp", () => { clampEntryToCodexSupportedEfforts(entry, new Set(["low", "medium", "high", "xhigh"])); + expect(entry.supported_reasoning_levels.map(level => level.effort)).toEqual(["max", "ultra"]); + expect(entry.default_reasoning_level).toBe("ultra"); + }); + + test("still falls back to the conservative universal ladder when every advertised effort is genuinely unsupported", () => { + const entry = { + supported_reasoning_levels: [{ effort: "xhigh" }], + default_reasoning_level: "xhigh", + }; + + clampEntryToCodexSupportedEfforts(entry, new Set(["low", "medium"])); + expect(entry.supported_reasoning_levels.map(level => level.effort)).toEqual(["low", "medium", "high"]); expect(clampedDefaultEffort("max", [])).toBe("medium"); }); - test("repairs an unsupported max default to the highest surviving xhigh rung", () => { + test("keeps an unclampable max default instead of repairing it down to xhigh", () => { const entry = routedEntry(); clampEntryToCodexSupportedEfforts(entry, new Set(["low", "medium", "high", "xhigh"])); - expect(entry.default_reasoning_level).toBe("xhigh"); + expect(entry.default_reasoning_level).toBe("max"); + }); + + test("still repairs a genuinely unsupported default to the highest surviving rung", () => { + const entry = routedEntry(); + entry.default_reasoning_level = "xhigh"; + + clampEntryToCodexSupportedEfforts(entry, new Set(["low", "medium", "high"])); + + expect(entry.supported_reasoning_levels.map(level => level.effort)) + .toEqual(["low", "medium", "high", "max", "ultra"]); + expect(entry.default_reasoning_level).toBe("high"); }); test("is a no-op when the installed Codex binary cannot be probed", () => { diff --git a/tests/codex-integration/codex-convergence-account-selectors.test.ts b/tests/codex-integration/codex-convergence-account-selectors.test.ts index 9602e91309..ceb34ef6a2 100644 --- a/tests/codex-integration/codex-convergence-account-selectors.test.ts +++ b/tests/codex-integration/codex-convergence-account-selectors.test.ts @@ -913,7 +913,7 @@ test("disabled-provider selections cannot delete a foreign row in either writer" expect(readFileSync(catalogPath, "utf8")).toBe(convergenceBytes); }); -test("convergence clamps native, routed, and account rows to observed runtime support", async () => { +test("convergence clamps clampable rungs but keeps exempt max/ultra on native, routed, and account rows", async () => { grantGpt56NativeModels("main-chatgpt-account", "side-chatgpt-account"); seedObservedRuntimeSupport(); writeCatalog([nativeEntry()]); @@ -931,6 +931,7 @@ test("convergence clamps native, routed, and account rows to observed runtime su const catalog = await convergeCatalog(nextConfig); const models = catalog.models ?? []; + const observed = ["low", "medium", "high", "xhigh"]; for (const slug of [ "gpt-5.6-sol", "static/reasoning-model", @@ -940,8 +941,11 @@ test("convergence clamps native, routed, and account rows to observed runtime su const entry = models.find(model => model.slug === slug); const efforts = (entry?.supported_reasoning_levels ?? []) as Array<{ effort?: string }>; expect(entry).toBeDefined(); - expect(efforts.map(level => level.effort)).not.toContain("max"); - expect(efforts.map(level => level.effort)).not.toContain("ultra"); + // Every surviving rung is either observed or one of the exempt top tiers; the exemption + // preserves the max/ultra a row already advertises but never adds new rungs. + for (const level of efforts) { + expect(observed.includes(level.effort!) || level.effort === "max" || level.effort === "ultra").toBe(true); + } if (typeof entry?.default_reasoning_level === "string") { expect(efforts.some(level => level.effort === entry.default_reasoning_level)).toBe(true); } @@ -949,6 +953,12 @@ test("convergence clamps native, routed, and account rows to observed runtime su const cache = JSON.parse(readFileSync(join(codexHome, "models_cache.json"), "utf8")) as { models?: RawEntry[]; }; + // The routed row advertises the full ladder with an ultra default: both exempt rungs and + // the default survive verbatim, which is the observable proof the exemption ran. + const routed = models.find(model => model.slug === "static/reasoning-model"); + expect((routed?.supported_reasoning_levels ?? []).map(level => (level as { effort?: string }).effort)) + .toEqual(["low", "medium", "high", "xhigh", "max", "ultra"]); + expect(routed?.default_reasoning_level).toBe("ultra"); expect(cache.models).toEqual(models); }); diff --git a/tests/codex-integration/codex-runtime.test.ts b/tests/codex-integration/codex-runtime.test.ts index bf95f304a6..5b82cf893c 100644 --- a/tests/codex-integration/codex-runtime.test.ts +++ b/tests/codex-integration/codex-runtime.test.ts @@ -23,6 +23,7 @@ import { compareCodexVersions, displayCodexRuntimePath, effortClampAppliesToRuntime, + liveRemovedEfforts, loadLastEffortClamp, loadPersistedCodexRuntime, parseCodexVersionOutput, @@ -603,11 +604,11 @@ describe("resolveCodexRuntime", () => { persistEffortClamp({ runtimePath: "C:\\Users\\Bob\\codex.exe", runtimeVersion: "0.133.0", - removedEfforts: ["max", "ultra"], + removedEfforts: ["xhigh"], affectedModels: ["gpt-5.6-sol"], }, { configDir }); const loaded = loadLastEffortClamp({ configDir }); - expect(loaded?.removedEfforts).toEqual(["max", "ultra"]); + expect(loaded?.removedEfforts).toEqual(["xhigh"]); expect(loaded?.affectedModels).toEqual(["gpt-5.6-sol"]); expect(effortClampAppliesToRuntime(loaded, { command: "C:\\Users\\Bob\\codex.exe", @@ -621,6 +622,70 @@ describe("resolveCodexRuntime", () => { expect(loadLastEffortClamp({ configDir })).toBeNull(); }); + // The binary that produced the diagnostic is upgraded in place. Windows does exactly this, so + // path equality alone kept a 0.135.0 observation alive for a 0.154.0 runtime whose own bundled + // catalog carried the rungs the file claimed were missing. + test("a same-path runtime at a different version no longer inherits the diagnostic", () => { + const configDir = tempConfigDir(); + persistEffortClamp({ + runtimePath: "C:\\Users\\Bob\\codex.exe", + runtimeVersion: "0.135.0", + removedEfforts: ["xhigh"], + affectedModels: ["gpt-6-astra"], + }, { configDir }); + const loaded = loadLastEffortClamp({ configDir }); + expect(effortClampAppliesToRuntime(loaded, { + command: "C:\\Users\\Bob\\codex.exe", + version: "0.154.0", + })).toBe(false); + // Same path, same version is still the runtime that produced it. + expect(effortClampAppliesToRuntime(loaded, { + command: "C:\\Users\\Bob\\codex.exe", + version: "0.135.0", + })).toBe(true); + // An unknown version on either side is not evidence of an upgrade: stay conservative. + expect(effortClampAppliesToRuntime(loaded, { + command: "C:\\Users\\Bob\\codex.exe", + version: null, + })).toBe(true); + }); + + // max and ultra are exempt from the observed-runtime intersection, so a file naming only those + // describes a policy that no longer exists and must not keep the warning alive until the next + // sync unlinks it. + test("a diagnostic naming only max and ultra is inert", () => { + const configDir = tempConfigDir(); + persistEffortClamp({ + runtimePath: "C:\\Users\\Bob\\codex.exe", + runtimeVersion: "0.135.0", + removedEfforts: ["max", "ultra"], + affectedModels: ["gpt-6-astra"], + }, { configDir }); + const loaded = loadLastEffortClamp({ configDir }); + expect(loaded?.removedEfforts).toEqual(["max", "ultra"]); + expect(liveRemovedEfforts(loaded)).toEqual([]); + expect(effortClampAppliesToRuntime(loaded, { + command: "C:\\Users\\Bob\\codex.exe", + version: "0.135.0", + })).toBe(false); + }); + + test("a mixed diagnostic still reports the rungs that are genuinely clamped", () => { + const configDir = tempConfigDir(); + persistEffortClamp({ + runtimePath: "C:\\Users\\Bob\\codex.exe", + runtimeVersion: "0.135.0", + removedEfforts: ["max", "ultra", "xhigh"], + affectedModels: ["gpt-6-astra"], + }, { configDir }); + const loaded = loadLastEffortClamp({ configDir }); + expect(liveRemovedEfforts(loaded)).toEqual(["xhigh"]); + expect(effortClampAppliesToRuntime(loaded, { + command: "C:\\Users\\Bob\\codex.exe", + version: "0.135.0", + })).toBe(true); + }); + test("creates missing config directory on first runtime/clamp persist", () => { const parent = tempConfigDir(); const configDir = join(parent, "nested", "opencodex-home"); @@ -940,6 +1005,8 @@ describe("resolveCodexRuntime", () => { test("clamp diagnostics include unsupported default_reasoning_level changes", async () => { const { clampCatalogModelsToCodexSupport } = await import("../../src/codex/catalog/effort"); const diagnostics: Array<{ removedEfforts: string[]; affectedModels: string[] }> = []; + // A genuinely unsupported (and clampable) default rung: xhigh. The exempt rungs + // (max/ultra) are covered by the no-diagnostic case below. const models = [{ slug: "openrouter/example", supported_reasoning_levels: [ @@ -947,7 +1014,7 @@ describe("resolveCodexRuntime", () => { { effort: "medium", description: "medium" }, { effort: "high", description: "high" }, ], - default_reasoning_level: "ultra", + default_reasoning_level: "xhigh", }]; clampCatalogModelsToCodexSupport(models, { commandCandidates: () => ["stub"], @@ -966,9 +1033,43 @@ describe("resolveCodexRuntime", () => { onEffortClamp: (diagnostic) => diagnostics.push(diagnostic), }); expect(models[0]!.default_reasoning_level).toBe("high"); - expect(diagnostics[0]?.removedEfforts).toContain("ultra"); + expect(diagnostics[0]?.removedEfforts).toContain("xhigh"); expect(diagnostics[0]?.affectedModels).toEqual(["openrouter/example"]); }); + + test("an ultra default against a runtime that stops at high produces no clamp diagnostic", async () => { + const { clampCatalogModelsToCodexSupport } = await import("../../src/codex/catalog/effort"); + const diagnostics: Array<{ removedEfforts: string[]; affectedModels: string[] }> = []; + const models = [{ + slug: "openrouter/example", + supported_reasoning_levels: [ + { effort: "low", description: "low" }, + { effort: "medium", description: "medium" }, + { effort: "high", description: "high" }, + ], + default_reasoning_level: "ultra", + }]; + clampCatalogModelsToCodexSupport(models, { + commandCandidates: () => ["stub"], + execFileSync: () => JSON.stringify({ + models: [{ + slug: "gpt-5.5", + base_instructions: "x", + supported_reasoning_levels: [ + { effort: "low", description: "low" }, + { effort: "medium", description: "medium" }, + { effort: "high", description: "high" }, + ], + default_reasoning_level: "medium", + }], + }), + onEffortClamp: (diagnostic) => diagnostics.push(diagnostic), + }); + // ultra is exempt from the observed-runtime intersection: the default survives and + // nothing is reported, so the persisted diagnostic stays absent. + expect(models[0]!.default_reasoning_level).toBe("ultra"); + expect(diagnostics).toEqual([]); + }); }); describe("dead configured pin recovery (#4035)", () => { diff --git a/tests/codex-integration/reserve-catalog.test.ts b/tests/codex-integration/reserve-catalog.test.ts index 5abd28744d..7e7b46df84 100644 --- a/tests/codex-integration/reserve-catalog.test.ts +++ b/tests/codex-integration/reserve-catalog.test.ts @@ -249,6 +249,32 @@ describe("Reserve catalog metadata is not permission", () => { expect(diagnostic.removedEfforts).toContain("xhigh"); }); + test("a Reserve row whose sole survivors would be max/ultra is kept, not spliced out", () => { + const rows = merge(build(config(), [actualReserve({ + supported_reasoning_levels: [ + { effort: "max", description: "Source max" }, + { effort: "ultra", description: "Source ultra" }, + ], + default_reasoning_level: "ultra", + })])); + const diagnostic = clampCatalogModelsToObservedCodexSupport(rows, new Set(["medium"])); + // max/ultra are exempt from the observed-runtime intersection, so the ladder never + // empties and the omission branch never fires. + expect(rows.map(row => row.slug)).toContain("personal/gpt-reserve"); + expect(rows.find(isReserveCatalogProjection)).toMatchObject({ + supported_reasoning_levels: [ + { effort: "max", description: "Source max" }, + { effort: "ultra", description: "Source ultra" }, + ], + default_reasoning_level: "ultra", + }); + // Other rows in the merged catalog legitimately lose rungs against {medium}; the point + // is that nothing clampable was taken from the Reserve row. + expect(diagnostic.removedEfforts).not.toContain("max"); + expect(diagnostic.removedEfforts).not.toContain("ultra"); + expect(diagnostic.affectedModels).not.toContain("personal/gpt-reserve"); + }); + test("partial effort intersection keeps only source efforts and a surviving default", () => { const rows = merge(build(config(), [actualReserve({ supported_reasoning_levels: [ diff --git a/tests/config/settings-stream-mode.test.ts b/tests/config/settings-stream-mode.test.ts index 514ba6110a..fb2f01579f 100644 --- a/tests/config/settings-stream-mode.test.ts +++ b/tests/config/settings-stream-mode.test.ts @@ -163,7 +163,7 @@ describe("GET /api/settings", () => { persistEffortClamp({ runtimePath: fakeCodex, runtimeVersion: "0.133.0", - removedEfforts: ["max", "ultra"], + removedEfforts: ["xhigh"], affectedModels: ["gpt-5.6-sol"], }, { configDir: TEST_DIR }); @@ -190,7 +190,7 @@ describe("GET /api/settings", () => { expect(body.codexRuntime?.source).toBe("environment"); expect(body.codexRuntime?.catalogClamp).toEqual({ active: true, - removedEfforts: ["max", "ultra"], + removedEfforts: ["xhigh"], runtimeVersion: "0.133.0", }); expect( From 9d0f4932c980fa47662ac423771441323e922f1f Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 13:27:03 +0900 Subject: [PATCH 83/96] docs(devlog): record the catalog presentation and effort projection unit Plan, evidence, architecture dispositions, test impact, open gaps, and the resumed-cycle revalidation with the three-round audit synthesis (reviewer: xai/grok-4.6). Phase 3 stays withdrawn pending a live account-roster probe. --- .../000_plan.md | 221 ++++++++++++++++++ .../010_evidence.md | 134 +++++++++++ .../020_architecture_dispositions.md | 49 ++++ .../030_test_impact.md | 57 +++++ .../040_open_gaps.md | 68 ++++++ .../050_revalidation.md | 96 ++++++++ 6 files changed, 625 insertions(+) create mode 100644 devlog/_plan/260911_catalog_presentation_and_effort_projection/000_plan.md create mode 100644 devlog/_plan/260911_catalog_presentation_and_effort_projection/010_evidence.md create mode 100644 devlog/_plan/260911_catalog_presentation_and_effort_projection/020_architecture_dispositions.md create mode 100644 devlog/_plan/260911_catalog_presentation_and_effort_projection/030_test_impact.md create mode 100644 devlog/_plan/260911_catalog_presentation_and_effort_projection/040_open_gaps.md create mode 100644 devlog/_plan/260911_catalog_presentation_and_effort_projection/050_revalidation.md diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/000_plan.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/000_plan.md new file mode 100644 index 0000000000..86a954e719 --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/000_plan.md @@ -0,0 +1,221 @@ +# Unconditional `max`/`ultra`, and the stale clamp that hides them + +## Reader summary + +Two catalog defects share one shape: OpenCodex decides what the Codex client may +render, and in both cases it decides "nothing" for the wrong reason. First, a +persisted effort-clamp diagnostic keeps `max` and `ultra` hidden after the Codex +binary is upgraded in place, because the diagnostic is matched to the current +runtime by **path alone** — the recorded version is never compared when the path +is unchanged, which is the normal Windows auto-update case. Second, the two +presentation fields the Codex client renders as cards (`availability_nux`, +`upgrade`) are deleted at four sites and carried at none, so no announcement or +upgrade card can ever appear through the proxy. This unit makes the clamp +diagnostic version-aware, exempts `max` and `ultra` from the observed-runtime +intersection entirely, and — pending a probe — lets the account roster contribute +the presentation copy it currently discards. The per-consumer ladder projection in +the first draft was withdrawn by maintainer ruling; the delete-site registry was +withdrawn because the explorer pass showed native rows already keep the fields. + +Evidence: `010_evidence.md`. Upstream issues: #4204 (efforts), #4213 (cards). + +## Loop spec + +| Field | Content | +| --- | --- | +| Loop archetype | Satisfy-spec. Both defects have a decidable correct behaviour; this is not an open-ended optimization. | +| Trigger | User request: make `max`/`ultra` reachable regardless of the CLI version recorded on disk, and make the card fields usable as boilerplate for other presentation surfaces. | +| Goal | A Codex client that supports an effort sees it offered; a presentation field that upstream populates survives to the client; adding the next such field is a registry entry, not a new code path. | +| Non-goals | Proxying `wham/workspace-messages`. Changing image generation. Authoring card copy that upstream does not ship. Pushing to any remote. Clamping rungs other than `max`/`ultra` — the rest of the ladder keeps its current observed-runtime behaviour. | +| Verifier | `bun test tests/codex-integration/codex-runtime.test.ts tests/codex-integration/catalog-go-exact-efforts.test.ts` — RUN, exit 0, 42 pass, and it reads `src/codex/runtime.ts` (`codex-runtime.test.ts:37` imports it; `:612` calls `effortClampAppliesToRuntime`). **That pair is sufficient for Phase 1 and NOT sufficient for Phase 2** — `catalog-go-exact-efforts.test.ts` pins construction, never the clamp. The gate that observes Phase 2 is `bun test tests/codex-integration/codex-catalog.test.ts tests/codex-integration/reserve-catalog.test.ts tests/clients/client-catalog-compatibility.test.ts` (see `030_test_impact.md` for the exact test names that must invert). NOT RUN at plan time — inverting them is B's work, so a pass today would prove nothing. Phase 3 has no gate until its test exists; that acceptance row is human review. | +| Stop condition | ~~Plan-only~~ Superseded 2026-09-11 (second session): the user authorised the full PABCD cycle. This cycle ends at D with Phases 1, 2 and 4 built and verified. Phase 3 stays withdrawn (GAP-2). | +| Memory artifact | This unit directory: `devlog/_plan/260911_catalog_presentation_and_effort_projection/`. | +| Expected terminal outcomes | Success: Phases 1, 2 and 4 land on `codex/260911-clamp-expiry` with their gates green. Resolved: GAP-1 (CLAMP-04 ships — see `050_revalidation.md`). Unresolved: GAP-2/GAP-3 in `040_open_gaps.md` are not closed by this cycle. Blocked: Phase 3 stays blocked on a live account-roster probe that only the user can authorise. | +| Escalation condition | The account-roster probe needs a real ChatGPT token; main does not take it. CLAMP-04 changes the meaning of an active clamp and inverts diagnostic tests the plan currently promises to keep green — that contradiction goes to the user, not to B. Reserve deletion is no longer an escalation: CLAMP-05 decided it. | + +## Phase map + +Ordered by build dependency. Each phase closes with something independently +verifiable. + +### Phase 1 — Foundation: the clamp diagnostic must expire + +`effortClampAppliesToRuntime` (`src/codex/runtime.ts:431`) returns `true` as soon as +the recorded path equals the resolved runtime path, before it ever looks at the +version. An in-place upgrade therefore keeps a clamp alive forever. This machine +is in exactly that state: the diagnostic records `0.135.0` for a path that now +reports `0.154.0`, and that binary's own bundled catalog contains `max` and +`ultra`. + +Change: when the diagnostic and the runtime both carry a version and those +versions differ, the diagnostic does not apply — regardless of path equality. A +missing version on either side stays conservative and keeps the current +path-match behaviour, because an unknown version is not evidence of an upgrade. + +This narrows nothing and weakens no clamp: it invalidates an observation that is +provably about a different binary. It is also the phase the other two depend on, +because a stale diagnostic would mask whatever Phase 2 computes. + +Accept criteria: + +- Given a diagnostic at version A and a runtime at the same path at version B (A ≠ B), `effortClampAppliesToRuntime` returns `false`. +- Given the same path and the same version, it still returns `true`. +- Given a diagnostic with a null version, current behaviour is unchanged. +- Activation scenario for the guard: construct the two-version case in the test and assert `ocx status` composes without the clamp warning — the observable effect is the absent `Catalog clamp removed` line at `src/cli/status.ts:277`. + +Files: `src/codex/runtime.ts` (`effortClampAppliesToRuntime`), `tests/codex-integration/codex-runtime.test.ts` (extend near line 612). + +### Phase 2 — Core: `max` and `ultra` stop being clampable + +**Maintainer ruling, 2026-09-11.** Emit `max` and `ultra` unconditionally. The +consumer-projection design in the first draft of this plan is withdrawn, and the +#4204 review constraint it was written against is superseded by the person who +wrote it. Rationale on the record: enough time has passed that the CLI versions +which genuinely lack the two rungs are effectively unsupported, so a clamp that +exists to protect them costs more than it buys. + +What the clamp does today: the ladder comes from `codex debug models --bundled` +of the resolved runtime (`src/codex/catalog/effort.ts:331` → +`src/codex/catalog/bundled.ts:239`), alternative-runtime discovery is off for that +call (`bundled.ts:261` defaults `discoverAlternatives` to `false`; +`runtime.ts:603` breaks out of the candidate loop when it is `false`), so the +persisted binary decides the ladder for the whole machine. + +Mechanism, per CLAMP-01/03 in `020_architecture_dispositions.md` and the A-audit +correction (reviewer blocker 1, folded): the single predicate site is the +keep-filter inside `clampEntryToCodexSupportedEfforts` — `effort.ts:357`, where +`kept` is built with `supported.has(...)`. A rung survives when it is in +`supported` **or** it is `max`/`ultra`. The same predicate gates BOTH default-repair +blocks — the Reserve branch's own repair at `effort.ts:363-367` (which returns +before the shared block) and the shared block at `effort.ts:378`. One predicate, +three places. No new module, no signature change at `sync.ts:1945` or +`convergence.ts:382`, no `bundled.ts` discovery change, no consumer binding. + +Explicitly rejected: re-adding the rungs after the clamp via +`ensureUltraReasoningLevel` (`effort.ts:300`). It no-ops on an empty ladder, and it +would leave `removedEfforts` naming rungs that were put back — a diagnostic that +lies. Also rejected: a floor allowlist, which would strip `none`/`minimal` that +current CLIs do parse. + +**Emission and admission stay separate (CLAMP-02).** +`supportedCodexReasoningEffortsFromObservedCatalog` (`effort.ts:313`) keeps +reporting what it observes, and `catalogEffortCompatibility` (`effort.ts:409`, +`src/client/catalog-compatibility.ts:47`) keeps refusing a hub catalog an old +runtime cannot parse. Making observation lie would reintroduce #4207: a hub client +on a leftover 0.135 CLI would write the file and then crash reading it. + +**Reserve (CLAMP-05).** `requiresExactReserveEfforts` (`effort.ts:344`) deletes a +row whose ladder empties (the `omitted`/`splice` at `effort.ts:466,472` are pure +effects of the emptied ladder — they get NO special case). The keep falls out of +the `:357` filter: when `max`/`ultra` are the sole survivors `kept` is non-empty, +so the row is kept with exactly those rungs. `{xhigh}` vs `{medium}` still +deletes; `{low,high}` vs `{medium,high}` still yields `{high}`. + +Phase 1 is not made redundant by this: the diagnostic still exists for other rungs, +and a same-version leftover listing only `max`/`ultra` would keep warning without +the CLAMP-04 filter. + +**Landing constraint (A-audit blocker 2, folded).** Phases 1 and 4 must not land +without Phase 2 in the same diff: `liveRemovedEfforts` already hides rungs that +`clampEntryToCodexSupportedEfforts` still removes, so landing 1+4 alone makes +`ocx status`/`ocx doctor` report "no clamp" while the next sync still strips the +rungs. One branch, one landing. + +Accept criteria: + +- With a fixture runtime whose bundled catalog stops at `xhigh`, a native row ends the sync carrying `max` and `ultra`. +- A genuinely absent rung that is NOT `max`/`ultra` is still removed — asserted explicitly, so this is provably an exemption and not a disabled clamp. +- `default_reasoning_level: "ultra"` is no longer rewritten to `xhigh` when the ladder kept `ultra` (`effort.ts:378`). +- `catalogEffortCompatibility` still reports `unsupportedEfforts: ["max"]` against an old-CLI ladder — the #4207 gate is unchanged. +- Activation scenario for the reserve branch: a reserve fixture whose source ladder is `max`/`ultra`-only against an observed `{medium}`; the observable effect is that the row appears in the written catalog instead of being spliced out, while the existing `{xhigh}` vs `{medium}` fixture still produces an omitted row. + +Files: `src/codex/catalog/effort.ts` (only). Unchanged by design: `sync.ts:1945`, `convergence.ts:382`, `bundled.ts`, `src/client/catalog-compatibility.ts`. + +### Phase 3 — Integration: keep the account roster's presentation fields + +**The first draft had this backwards.** The four `delete` sites are not why no card +appears — a pin-backed native row already carries both fields end to end, and +`tests/codex-integration/codex-catalog.test.ts:7398` pins exactly that. The carrier +exists. Full derivation in `020_architecture_dispositions.md`. + +The real loss is upstream. `src/codex/model-entitlements.ts` fetches +`https://chatgpt.com/backend-api/codex/models`, and `parseAccountModels` (`:536-546`) +keeps **only the slug** — `supported_in_api` and `visibility` are read as filters and +every other field, presentation included, is dropped on the floor. The set is then +used as an allowlist for account-gated natives (currently just Daybreak). So Astra's +row is always the pin or the bundled catalog, and both carry +`availability_nux: null`. + +Change: let the account roster contribute presentation fields for a native slug it +already authorises, through one small descriptor that names which fields may cross +that boundary and in which direction the account roster wins over the pin. That +descriptor is the reusable piece the user asked for — the next presentation field +becomes an entry rather than a new merge path. + +**Blocked on evidence, by design.** Whether the account roster carries copy the pin +lacks is unverified and needs a live ChatGPT token. If it does not, Phase 3 is +withdrawn rather than built — there would be nothing to carry, and a descriptor +with no producer is the ghost state `PLAN-FIELD-CHAIN-01` exists to prevent. + +PLAN-FIELD-CHAIN-01 for the descriptor: + +| Stage | Path | +| --- | --- | +| Creation | account roster response parsed at `src/codex/model-entitlements.ts:536`; today the only producer, and it currently produces nothing | +| Serialization | none — both fields already exist in the catalog JSON written by `sync.ts`; no new wire shape | +| Deserialization | `src/codex/catalog/parsing.ts` entry normalization; `ensureStrictCatalogFields` already routes by `isRouted` | +| Consumers | the merge in `sync.ts` (`finishUpstreamNativeEntry`, `:257`), plus the four existing sanitizers which stay as they are — `metadata.ts:567` (Daybreak capability alias), `sync.ts:354` (template clone), `parsing.ts:613` (routed), `reserve.ts:36` (Reserve). **N/A by design:** none of them gains a registry lookup, because each is already correct. | + +Accept criteria: + +- A native slug whose account-roster row carries `availability_nux` ends the sync carrying it, overriding a `null` pin. +- A routed row, the Daybreak capability alias, and a Reserve projection still lose the field — asserted per row kind, not once, so the fix is proved not to have widened. +- `parseAccountModels`' existing filtering (`supported_in_api !== true`, `visibility === "hide"`) is unchanged; a hidden row contributes no copy. +- Activation scenario: a fixture roster carrying copy for one native slug and nothing for another; the observable effect is one row with a message and one still `null` in the written catalog. + +Files: `src/codex/model-entitlements.ts`, `src/codex/catalog/sync.ts`, new test under `tests/codex-integration/` (needs an entry in both `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`, or a name matching the `codex-integration` regex seed). + +### Phase 4 — Hardening: say what happened + +`ocx doctor` currently suggests "set CODEX_CLI_PATH to a newer Codex binary" while +the selected binary is already newer — the advice is generated from the stale +diagnostic. Once Phase 1 lands, the message must distinguish "this runtime really +lacks the rung" from "a previous runtime lacked it". `doctor.ts:1180` does not call +`effortClampAppliesToRuntime` at all — it warns on any non-empty `removedEfforts` — +so status and doctor can disagree about the same file. Accept criterion: given one +leftover diagnostic, `ocx status` and `ocx doctor` reach the same verdict. Docs-site +follows only if Phase 2 changes user-visible behaviour, which it does: `max` and +`ultra` now appear on runtimes that previously hid them. + +Files: `src/cli/doctor.ts:1181`, `src/cli/status.ts:250`, `src/server/management/config-routes.ts:283`, `docs-site/`. + +## Scope boundary + +IN: `src/codex/catalog/effort.ts` (Phase 2), `src/codex/runtime.ts` (Phases 1 and 4), `src/cli/{status,doctor}.ts` and `src/server/management/config-routes.ts` (Phase 4), and — only if the Phase 3 probe succeeds — `src/codex/model-entitlements.ts` and `src/codex/catalog/sync.ts`. Matching tests, docs-site. + +OUT, and explicitly unchanged by design: `src/codex/catalog/{bundled,parsing,metadata,reserve}.ts`, `src/client/catalog-compatibility.ts` and `catalogEffortCompatibility` (CLAMP-02), `supportedCodexReasoningEffortsFromObservedCatalog`, `nativeEffortClamp` and the wire-clamp layer, `src/server/index.ts` route allowlist, `src/server/images.ts`, `src/lab/`, the `wham` client surface, and hand-authored `upstream-models.json` copy. There is no new presentation-field module; that idea was withdrawn. + +## PLAN-BYPASS-NAMED-01 + +The thing being enforced is the CLAMP-02 boundary: emission may exempt the two +rungs, hub admission may not. + +- Tier: E2 — repository tests. +- Executing surface: `tests/clients/client-catalog-compatibility.test.ts` plus `bun run test` in CI. +- Known bypass path: a contributor who adds the exemption to `supportedCodexReasoningEffortsFromObservedCatalog` instead of to `clampEntryToCodexSupportedEfforts` gets the same visible outcome locally and silently reopens #4207. The compatibility tests would catch that specific move; a new code path that recomputes the supported set elsewhere would not be caught at all. +- Residual risk: a local `ocx sync` on a leftover pre-0.14x CLI can now write a catalog that CLI cannot parse. Accepted by the ruling; hub clients still fail closed. +- Wording: **early warning**, not enforcement. Final layer: none. + +## Consultation record + +Architect consultation completed on `xai/grok-4.6` through the connected hub: +proposal (CLAMP-01..06) in `020_architecture_dispositions.md`, main dispositions in +the same file, reflection check returned **MISALIGNED** with five findings. +Findings 1 and 4 — the document contradicting its own loop-spec and scope — are +resolved in this revision. Findings 2, 3 and 5 are recorded unresolved in +`040_open_gaps.md`. Two explorers ran alongside on disjoint questions; their output +is `030_test_impact.md` and the Phase 3 correction in `020`. + +This plan has **not** passed an independent A audit. The reflection is the +architect checking its own proposal against main's rewrite; it does not substitute +for A. diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/010_evidence.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/010_evidence.md new file mode 100644 index 0000000000..b25bf214d4 --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/010_evidence.md @@ -0,0 +1,134 @@ +# Evidence + +Collected 2026-09-11 on the reporting Windows machine. Read-only except for +`bun install`, which populated `node_modules` so the verifier could run. + +## 1. The clamp diagnostic outlives the binary it describes + +`~/.opencodex/codex-runtime-clamp.json`: + +```json +{ + "version": 1, + "updatedAt": "2026-09-10T12:01:09.525Z", + "runtimePath": "C:\\Users\\\\AppData\\Local\\Programs\\OpenAI\\Codex\\bin\\codex.exe", + "runtimeVersion": "0.135.0", + "removedEfforts": ["max", "ultra"], + "affectedModels": ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark", + "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra", + "anthropic/claude-fable-5-1", "anthropic/claude-opus-4-6", "anthropic/claude-opus-5"] +} +``` + +The binary at that exact path now reports `codex-cli 0.154.0`, and its own +bundled catalog carries both rungs: + +``` +$ codex debug models --bundled +... "slug":"gpt-6-astra" ... "supported_reasoning_levels":[ + {"effort":"low"...},{"effort":"medium"...},{"effort":"high"...}, + {"effort":"xhigh"...},{"effort":"max"...},{"effort":"ultra"...}] +``` + +`ocx status` nevertheless reports: + +``` +Codex version: 0.154.0 +Catalog clamp: active +Removed efforts: max, ultra +``` + +and `ocx doctor`: + +``` +ok Selected runtime: ...\codex.exe (0.154.0, source=configured) +!! max and ultra were removed during catalog sync. + Suggested: set CODEX_CLI_PATH to a newer Codex binary and run ocx sync. +``` + +The advice is impossible to follow — the selected binary is already the newer one. + +## 2. Why: path equality short-circuits the version check + +`src/codex/runtime.ts:431` + +```ts +export function effortClampAppliesToRuntime(diagnostic, runtime): boolean { + if (!diagnostic || diagnostic.removedEfforts.length === 0) return false; + if (sameRuntimeCommand(diagnostic.runtimePath, runtime.command)) return true; // <-- returns before version is read + return Boolean(diagnostic.runtimeVersion && runtime.version + && diagnostic.runtimeVersion === runtime.version); +} +``` + +The version comparison on the last line is only reachable when the paths +**differ**. An in-place upgrade — which is how the Windows Codex install updates — +keeps the path identical, so the stale diagnostic is treated as current forever. +Consumers: `src/cli/status.ts:250`, `src/server/management/config-routes.ts:283`. + +## 3. Where the effort ladder is derived + +- `src/codex/catalog/effort.ts:331` `codexSupportedReasoningEfforts` → `loadBundledCodexCatalog` +- `src/codex/catalog/bundled.ts:225` runs `debug models --bundled` +- `src/codex/catalog/bundled.ts:261` passes `discoverAlternatives: deps.discoverAlternatives ?? false` +- `src/codex/runtime.ts:603` `if (deps.discoverAlternatives === false) break;` — candidate search stops after the persisted entry +- `src/codex/runtime.ts:582` the persisted command is pushed first with source `configured` +- Applied at `src/codex/catalog/sync.ts:1945` and `src/codex/convergence.ts:382` + +So the machine-wide ladder is whatever the persisted binary reports, with no +notion of which client will render it. + +## 4. Card fields: deleted everywhere, carried nowhere — OPEN QUESTION + +Delete sites: + +| Path | Row kind | Fields | +| --- | --- | --- | +| `src/codex/catalog/metadata.ts:567` | alias | `availability_nux` | +| `src/codex/catalog/sync.ts:354` | routed | `upgrade = null`, `availability_nux` | +| `src/codex/catalog/parsing.ts:613` | routed | `availability_nux`, `upgrade` | +| `src/codex/catalog/reserve.ts:36` | reserve projection | `availability_nux` | + +Client side, for reference (openai/codex at submodule HEAD): + +- `codex-rs/protocol/src/openai_models.rs:409,410` — `ModelInfo.availability_nux`, `ModelInfo.upgrade` +- `codex-rs/tui/src/app/startup_prompts.rs:203,211` — NUX selection and a four-show cap +- `codex-rs/app-server/src/models.rs:31` — `upgrade` / `upgrade_info` forwarded to the desktop app + +**Unresolved.** `src/codex/data/upstream-models.json:928` has `availability_nux: null` +for `gpt-6-astra`, and so does the live `debug models --bundled` output above. Only +`gpt-5.6-sol` and `gpt-5.5` carry copy in the bundled catalog. That means the +bundled catalog may simply not be where Astra's announcement lives — the account +endpoint `backend-api/codex/models?client_version=...` is the other candidate, and +it has not been probed. **Phase 3 builds a carrier; whether there is anything to +carry for Astra is not yet established.** Probing it needs a live ChatGPT account +token and is a user decision, not an agent one. + +Separately, the `workspace-messages` channel (`headline` / `announcement`, +`codex-rs/backend-client/src/client.rs:651`) has zero references in this +repository. It is out of scope here and is gated client-side on +`auth.uses_codex_backend()` (`account_processor.rs:1307`), which is false for +`AuthMode::ApiKey` (`codex-rs/protocol/src/auth.rs:61`) — the mode produced by +`env_key` injection at `src/codex/inject.ts:327`. + +## 5. Verifier, actually run + +``` +$ bun install +103 packages installed + +$ bun test tests/codex-integration/codex-runtime.test.ts tests/codex-integration/catalog-go-exact-efforts.test.ts +42 pass, 0 fail, 160 expect() calls # exit 0 +``` + +Reads the change target: `tests/codex-integration/codex-runtime.test.ts:37` imports +`../../src/codex/runtime`; line 612 calls `effortClampAppliesToRuntime` directly. + +A first attempt failed with `Cannot find module 'zod/v4'` before `bun install` — +recorded because "the verifier passed" would otherwise be unverifiable. + +## 6. Not established + +- Whether Astra carries announcement copy on the account catalog endpoint (§4). +- Which consumer should own the shared catalog when Desktop and CLI disagree. +- Whether any non-Windows install reproduces §1; the in-place-upgrade shape was only observed here. diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/020_architecture_dispositions.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/020_architecture_dispositions.md new file mode 100644 index 0000000000..3c82aacaa0 --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/020_architecture_dispositions.md @@ -0,0 +1,49 @@ +# Architecture consultation and main dispositions + +Architect: `xai/grok-4.6` via the connected hub, read-only, dispatched 2026-09-11. +Two explorers ran alongside it on disjoint questions (test impact; presentation-field +flow). Verdict on the first draft of `000_plan.md`: **MISALIGNED**. Main accepts most +of it; the dispositions below are what actually governs B. + +## Decisions + +| ID | Proposal | Main disposition | +| --- | --- | --- | +| CLAMP-01 | Keep the clamp; a rung survives if it is in `supported` **or** it is `max`/`ultra`. Apply the same predicate to the default-repair block (`effort.ts:378`), which today would still rewrite `default_reasoning_level: "ultra"` even on a ladder that kept `ultra`. Do not post-patch with `ensureUltraReasoningLevel` — it is a construction helper, no-ops on empty ladders, and would make `removedEfforts` report rungs that were put back. | **Accepted.** This is the mechanism. The default-repair catch is a real defect the plan missed. | +| CLAMP-02 | Do **not** union `max`/`ultra` into `supportedCodexReasoningEffortsFromObservedCatalog` (`effort.ts:313`), and do not touch `catalogEffortCompatibility` (`effort.ts:409`, `src/client/catalog-compatibility.ts:47`). Emission and hub admission are different questions; hub download stays fail-closed per #4207. | **Accepted, and promoted to a scope boundary.** A hub client on a leftover 0.135 CLI must keep refusing rather than writing a catalog it cannot parse. | +| CLAMP-03 | One predicate change in `clampEntryToCodexSupportedEfforts` (`effort.ts:348`). No new module, no signature change at `sync.ts:1945` or `convergence.ts:382`, no consumer binding in `runtime.ts`, no `bundled.ts` discovery change. | **Accepted.** Strictly smaller than the plan's Phase 2. | +| CLAMP-04 | Keep `codex-runtime-clamp.json`. After CLAMP-01 a sync whose only removals were `max`/`ultra` persists `null` and unlinks. Until that sync, filter the two rungs out of "active clamp" in a single helper that **both** `effortClampAppliesToRuntime` and `doctor.ts` call — today `ocx doctor` (`doctor.ts:1180`) never calls the helper `ocx status` uses (`status.ts:249`), so the two can disagree about the same file. | **Accepted.** This explains the observed contradiction in `010_evidence.md` §1 and is a second, independent defect. Phase 1 stays: it is necessary for non-`max`/`ultra` rungs and insufficient alone, because a same-version leftover listing only those two would still warn. | +| CLAMP-05 | Keep reserve exactness. `{xhigh}` vs `{medium}` still deletes the row; `{low,high}` vs `{medium,high}` still yields `{high}`. Only the case where `max`/`ultra` are the sole survivors changes: the row is **kept** instead of spliced out (`effort.ts:466,472`). | **Accepted.** This closes the escalation the plan left open. Add the focused reserve test; do not weaken the existing xhigh-vs-medium omission test. | +| CLAMP-06 | Drop the per-consumer projection. One shared `$CODEX_HOME/opencodex-catalog.json`, no consumer key in the diagnostic, no second catalog. Desktop/CLI disagreement is resolved by always offering the two rungs. | **Accepted.** The "who owns the shared file" blocker in the first draft is obsolete. | + +Residual risk accepted with CLAMP-06: a leftover pre-0.14x CLI reading the shared +file locally can fail to parse it. Hub clients still fail closed. Local `ocx sync` +does not, and that is the stated cost of the ruling. + +## Correction to Phase 3 — the premise was wrong + +The first draft assumed the four `delete` sites were why no card appears. The +explorer pass disproves it. **Pin-backed native rows already keep both fields end +to end** — `upstreamNativeEntry` deletes only `minimal_client_version`, +`finishUpstreamNativeEntry` (`sync.ts:257`) does not touch them, and +`ensureStrictCatalogFields` strips them only when `isRouted === true` +(`parsing.ts:609`). There is a test pinning exactly this: +`tests/codex-integration/codex-catalog.test.ts:7398` — "a native row keeps its own +eligibility metadata" — and `:3529` asserts Sol's `availability_nux` is defined. + +So the carrier exists. Three native-looking kinds still lose the field, each for a +defensible reason: the Daybreak capability alias (`metadata.ts:567`), older natives +not in `UPSTREAM_NATIVE_ENTRIES` when OpenCodex has to synthesize the row +(`deriveEntry`), and Reserve. + +**The actual gap is upstream of all four sites.** `src/codex/model-entitlements.ts` +does fetch `https://chatgpt.com/backend-api/codex/models`, but `parseAccountModels` +(`:536-546`) keeps nothing except the slug — every presentation field in that +response is discarded, and the set is used only as an allowlist for account-gated +natives (currently just Daybreak). Astra's row therefore comes from the pin or the +bundled catalog, both of which carry `availability_nux: null`. + +That reframes the open question in `010_evidence.md` §4. It is no longer "does the +carrier exist" but "does the account roster carry copy the pin does not, and should +it override the pin". The probe still needs a live account token and is still a user +decision. diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/030_test_impact.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/030_test_impact.md new file mode 100644 index 0000000000..c476f80df3 --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/030_test_impact.md @@ -0,0 +1,57 @@ +# Test impact of the `max`/`ultra` exemption + +From the explorer pass (`xai/grok-4.6`, read-only). This is the list B inverts, and +it is the reason the loop-spec verifier row now names three files the first draft +did not. + +## Must invert — these encode the behaviour being removed + +`tests/codex-integration/codex-catalog.test.ts`, `describe("Codex reasoning-effort capability clamp")` at `:7027`: + +| Test | Line | Why it inverts | +| --- | --- | --- | +| the observed-state clamp is pure with respect to frozen runtime evidence | 7051 | expects `removedEfforts: ["max","ultra"]` and a default rewritten to `xhigh` | +| strips max and ultra when the installed Codex ladder stops at xhigh | 7077 | the exemption is precisely this case | +| falls back to the conservative universal ladder when every advertised effort is unsupported | 7095 | a `max`/`ultra`-only row must no longer collapse to `low/medium/high` | +| repairs an unsupported max default to the highest surviving xhigh rung | 7107 | `effort.ts:378`, the default-repair block CLAMP-01 also changes | + +`tests/codex-integration/codex-runtime.test.ts`: + +| Test | Line | Why it inverts | +| --- | --- | --- | +| clamp diagnostics include unsupported default_reasoning_level changes | 1005 (listed as 940 before the new Phase 1 tests shifted it) | runs the live clamp and expects `ultra` → `high` with `"ultra"` in `removedEfforts` | + +## Must keep passing — assert these explicitly, they are the proof it is an exemption + +- `preserves max and ultra when the installed Codex ladder includes them` — `codex-catalog.test.ts:7086` +- `is a no-op when the installed Codex binary cannot be probed` — `codex-catalog.test.ts:7115` +- `final clamp omits incompatible Reserve in-place without inventing efforts` — `reserve-catalog.test.ts:239`; `{xhigh}` vs `{medium}` still deletes the row. Fails only if the whole clamp is disabled, which is the mistake this plan is trying not to make. +- `partial effort intersection keeps only source efforts and a surviving default` — `reserve-catalog.test.ts:252` +- The four `#4207` cases in `tests/clients/client-catalog-compatibility.test.ts:37,51,76,97` — they do not mutate the catalog, and they fail **only** if CLAMP-02 is violated by also treating the two rungs as always compatible. They are the regression gate for the hub boundary. +- Runtime tests that seed `persistEffortClamp` themselves and therefore do not depend on live stripping: `codex-runtime.test.ts:601, 624, 815, 842` — line numbers verified stale by the A reviewer (the Phase 1 test insertions shifted them; the tests are found by name, not line). + +## Out of scope — a different layer, do not touch + +The wire clamp (`nativeEffortClamp`, `effort.ts:52`, consumed at +`src/server/responses/core.ts:2582`) still maps `max`/`ultra` down for natives that +only mock those rungs. Catalog advertisement and wire honesty are deliberately +split, as `structure/03_catalog-and-subagents.md:339` already records. Affected +suites that must stay green unchanged: `codex-v2-gate.test.ts:1821`, +`effort-policy.test.ts:434`, `reasoning-effort.test.ts:887`, +`openai-responses-passthrough.test.ts:559`, `claude-model-info.test.ts:63`, +`vision-reasoning-contract.test.ts:193`. + +Likewise the construction-side exactness suites — `catalog-go-exact-efforts.test.ts`, +`codex-v2-gate.test.ts:111-126`, the none-only and combo ladder pins in +`codex-catalog.test.ts` — fail only if "unconditional" is misread as "always **add** +`max`/`ultra`". It is not: Go rows, Luna, combo rows, and none-only custom ladders +keep their exact ladders. Anything that grows Muse to include `max` or Luna to +include `ultra` is a defect, not the feature. + +## New test file placement + +`tests/test-layout.test.ts:20` forbids a root-level file that resolves to a migrated +domain. A new file needs matching entries in `scripts/test-layout/layout.json` +`explicit` and `tests/fixtures/test-layout-expected.json`; the `codex-integration` +regex seed already matches an `effort-*.test.ts` name until those exist +(`layout.json:34`). diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/040_open_gaps.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/040_open_gaps.md new file mode 100644 index 0000000000..005b1d0f6d --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/040_open_gaps.md @@ -0,0 +1,68 @@ +# Unresolved after the reflection check + +The architect reflection returned MISALIGNED with five findings. Two were document +coherence and are fixed in `000_plan.md`. These three are real and are **not** +closed. B does not start Phase 3, and does not implement CLAMP-04, until they are. + +## GAP-1 — CLAMP-04 contradicts tests that `030_test_impact.md` promises to keep green + +CLAMP-04 says a leftover diagnostic listing only `max`/`ultra` should stop counting +as an active clamp. But `tests/codex-integration/codex-runtime.test.ts:601` and +`:624` seed `persistEffortClamp` with exactly `removedEfforts: ["max","ultra"]` and +`["max"]` and then assert the diagnostic **is** active. `030_test_impact.md` lists +both as must-keep-passing. Both cannot be true. + +Two more files assert the same leftover shape live and are missing from `030` +entirely: `tests/cli/cli-status-json.test.ts:376` and +`tests/config/settings-stream-mode.test.ts:145`. + +Phase 1's accept criteria also require same-path-same-version to still return +`true` — which is precisely the leftover file on the reporting machine. + +**Disposition: RESOLVED 2026-09-11 — CLAMP-04 ships.** The user authorised the full +cycle with the working tree already carrying the CLAMP-04 implementation +(`liveRemovedEfforts` in `src/codex/runtime.ts`, doctor/status/config-routes aligned +to it, and the four seed-test rows inverted to `["xhigh"]`). That is the recorded +decision; see `050_revalidation.md`. + +Independent of that choice: `src/cli/doctor.ts:1180` does not call +`effortClampAppliesToRuntime`, so doctor and status can disagree about one file. +That is a defect either way and belongs in Phase 4. + +## GAP-2 — Phase 3 names a consumer that cannot consume + +The reflection is right that the field chain skips a stage. The roster result is a +`ReadonlySet` on `CodexModelEntitlementSnapshot.modelsByAccount`; there is no +presentation payload anywhere until that cache shape changes. +`finishUpstreamNativeEntry` (`sync.ts:257`) clones the pin and takes no roster data, +so naming it as the consumer describes a path that does not exist. The missing +stages are fetch → snapshot shape → sync plumbing, and the plan names none of them. + +Worse for the stated goal: **Astra is not account-gated.** +`ACCOUNT_GATED_NATIVE_OPENAI_MODELS` (`src/codex/catalog/native-models.ts:50`) is +Daybreak alone, and `availableAccountGatedNativeModels` +(`model-entitlements.ts:1074`) filters only that set. So "a native slug the roster +already authorises" excludes the one model this whole thread is about. Overlaying +roster copy onto Astra is a new use of `/models`, not a descriptor on an existing +allowlist. + +And Daybreak — the one slug the roster does authorise — is a capability alias whose +`availability_nux` is deleted at `metadata.ts:567`. Phase 3 asserts the alias still +loses the field while also asserting the roster wins over the pin. Merge order is +unspecified, so those two accept rows can contradict each other. + +**Disposition: Phase 3 is withdrawn from the executable plan** and reduced to a +question: does `backend-api/codex/models?client_version=0.154.0` return +`availability_nux` for `gpt-6-astra` under a real account? If no, the whole phase +dies and the answer to "why is there no Astra card" is simply that upstream has not +shipped copy for it. If yes, Phase 3 is re-planned from the snapshot shape up, not +patched into `finishUpstreamNativeEntry`. + +## GAP-3 — citation nits + +- `020` cites `codex-catalog.test.ts:7398` for the native-keeps-eligibility pin; `:7398` is the comment, the test is `:7400`. +- `020` cites Sol's `availability_nux` assertion at `:3529`; it is `:3530`. +- `030` lists `client-catalog-compatibility.test.ts:97` as a fourth `#4207` case; it is an assertion inside the test at `:83`. + +Left uncorrected in place deliberately — the reflection is the record, and rewriting +the numbers without re-reading the files would be the same class of error. diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/050_revalidation.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/050_revalidation.md new file mode 100644 index 0000000000..b034e7de09 --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/050_revalidation.md @@ -0,0 +1,96 @@ +# P revalidation — second session, 2026-09-11 + +Session `01a08e7d-be48-72f0-9063-fb3f26ea2eb8` (hook-bound, CODEX_THREAD_ID verified +against the SessionStart line) resumed this unit after the first session stopped at +plan-only. This document is the P-phase revalidation record for the resumed cycle. + +## What changed since the plan was written + +1. **The user authorised the full cycle.** The first session's stop condition was + plan-only; the user then instructed this session to proceed (`진행해줘`) and to + use `xai/grok-4.6` subagents without a cap. `000_plan.md` loop spec updated. +2. **Phases 1 and 4 are already implemented, uncommitted**, on branch + `codex/260911-clamp-expiry` (base: `dev` @ `babb76449`). `git diff` shows: + - `src/codex/runtime.ts` — `liveRemovedEfforts` + `UNCLAMPABLE_REASONING_EFFORTS`; + `effortClampAppliesToRuntime` is now version-aware on the same-path branch + (Phase 1) and inert when only `max`/`ultra` are named (CLAMP-04). + - `src/cli/doctor.ts`, `src/cli/status.ts`, `src/server/management/config-routes.ts` + — all three surfaces now read the same predicate (Phase 4 core). + - Tests inverted per GAP-1: `codex-runtime.test.ts` (601-region seeds changed to + `["xhigh"]`, three new tests), `cli-status-json.test.ts:396,421`, + `settings-stream-mode.test.ts:163,190`. + The authorship of this diff is not recorded anywhere this session can see — no + other cxc session file exists and the ledger has no B entry. Treated as user- + authorised work in progress and adopted as this cycle's B baseline. +3. **GAP-1 resolved: CLAMP-04 ships.** The tree is the decision. See `040_open_gaps.md`. +4. **Phase 3 stays withdrawn** (GAP-2). The account-roster probe needs a live ChatGPT + token; not attempted. + +## Remaining B scope (revalidated against `030_test_impact.md`) + +- `src/codex/catalog/effort.ts` — the CLAMP-01 predicate at the keep-filter + (`clampEntryToCodexSupportedEfforts`, :357), gated default-repair at BOTH + `:363-367` (Reserve branch) and `:378` (shared block). The CLAMP-05 reserve keep + falls out of the filter — `:466,472` are effects, NO splice special-case + (A-audit round 2 correction). **Not yet implemented** — the tree diff does + not touch `effort.ts`, so until B lands it, `liveRemovedEfforts` is a forward + reference and status/doctor would under-report a clamp that sync still applies. +- Test inversions still pending: `codex-catalog.test.ts` :7051, :7077, :7095, :7107; + `codex-runtime.test.ts:1005`; reserve keep-case added near `reserve-catalog.test.ts:239`. +- Must-stay-green: `reserve-catalog.test.ts:239,252`, + `client-catalog-compatibility.test.ts:37,51,76,97` (CLAMP-02 / #4207 gate), + `codex-catalog.test.ts:7086,7115`. +- Docs-site: Phase 2 changes user-visible behaviour, so a docs note is owed (Phase 4). + +## Verifier re-run + +`bun test tests/codex-integration/codex-runtime.test.ts tests/codex-integration/catalog-go-exact-efforts.test.ts` +— attempted at P; queued behind a concurrent `bun run test:changed` (pid 11292, +started 12:20:56 by a process outside this session). Result recorded in C with the +fresh run. The verifier command exists and reads the target (unchanged from `010_evidence.md` §5). + +## Collision note + +A `bun run test:changed` run owned by another process is active in this working +tree. This session re-checks `git status`/`git diff` before every B edit and does +not revert hunks it did not write. +## A-audit round 1 synthesis (2026-09-11, reviewer `xai/grok-4.6` "Tesla") + +VERDICT: FAIL, two blockers. Both accepted, none rebutted. + +1. **Reserve keep must fall out of the `:357` keep-filter, not a `:466/:472` splice + exception.** Correct — a splice special-case would leave an empty-ladder Reserve + row. `000_plan.md` Phase 2 mechanism and CLAMP-05 paragraphs rewritten: the + predicate site is the filter at `effort.ts:357`; the Reserve default-repair is + `:363-367` (not `:378`, which is unreachable for Reserve because of the early + return); `:466/:472` are pure effects. +2. **Phases 1+4 must not land without Phase 2.** Correct — `liveRemovedEfforts` + already hides rungs the clamp still removes. Recorded as a landing constraint in + `000_plan.md`: one branch, one landing. + +Reviewer-verified facts folded into `030_test_impact.md`: the default-repair test +lives at `codex-runtime.test.ts:1005` (not 940); the must-keep persist-seed line +numbers are stale and those tests are located by name. + +Verifier baselines the reviewer ran fresh: Phase 1 pair 45 pass / 0 fail; Phase 2 +gate trio 346 pass / 0 fail (pre-CLAMP-01 baseline — inverting them is B's work). + +## B-phase discoveries (2026-09-11) + +- **030's invert list missed one test.** `bun run test:changed` caught + `codex-convergence-account-selectors.test.ts:916` ("convergence clamps native, routed, + and account rows to observed runtime support") still expecting `max`/`ultra` stripped. + Inverted: the four rows now assert the surviving-rungs invariant (observed ∪ + {max,ultra}), and the full-ladder routed row proves the exemption ran (ladder and + `ultra` default verbatim). The generic invariant replaced a blanket `toContain` because + account-projection rows legitimately ship narrow ladders (`["medium","max"]`) — the + exemption preserves, never adds. +- **Pre-existing environmental failures, proved on base.** `test:changed` also failed + cursor-integration-status (gateway `apiKeyMode`), update-pnpm ×3 (EFAULT / POSIX shims + on Windows), and a 5s bearer-admission timeout. A pristine worktree at the merge base + (`babb76449`) fails the same five, so they are not this diff's. Worktree removed after + the check. +- **codexclaw tooling issue filed.** `cxc session current`/`session bind` cannot resolve + the native session cwd on this desktop install (CODEX_THREAD_ID is set and matches the + SessionStart binding): https://github.com/lidge-jun/codexclaw/issues/134 +*** End Patch From dac34a6a1c25272f68108c6d047ae02e17f62e0e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 13:40:41 +0900 Subject: [PATCH 84/96] docs(devlog): close the catalog presentation and effort projection cycle --- .../060_done.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 devlog/_plan/260911_catalog_presentation_and_effort_projection/060_done.md diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/060_done.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/060_done.md new file mode 100644 index 0000000000..0691a81241 --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/060_done.md @@ -0,0 +1,54 @@ +# Done — catalog presentation and effort projection (cycle 1) + +2026-09-11, session `01a08e7d-be48-72f0-9063-fb3f26ea2eb8`. Reader: someone who was +not in the loop. + +## Conclusion + +Phases 1, 2 and 4 landed on `codex/260911-clamp-expiry` and every gate that observes +them is green. Phase 3 (account-roster presentation fields) stays withdrawn: whether +`backend-api/codex/models` carries card copy the pin lacks is still unverified, and +the probe needs a live ChatGPT token, which is the user's call. + +## What changed + +- `fddbb7fad` — the code. `src/codex/runtime.ts` exports the single + `UNCLAMPABLE_REASONING_EFFORTS` set (`max`/`ultra`); `effortClampAppliesToRuntime` is + version-aware on the same-path branch (in-place Windows upgrades no longer keep a + stale diagnostic alive) and inert when only exempt rungs are named; + `src/codex/catalog/effort.ts:357` keeps those rungs in the observed-runtime + intersection and `:378` stops repairing an exempt default; the Reserve keep falls + out of the filter with no splice special-case; `ocx status`, `ocx doctor` and + `/api/settings` read one shared predicate. +- `docs(devlog)` commit — this unit (000-060). + +## Evidence + +- Focused gate: `bun test` on the 8 affected files — 503 pass / 0 fail, exit 0. +- `bun run typecheck` — exit 0. +- `bun run test:changed` — 14080 pass; the 4 unique failures (cursor `apiKeyMode`, + pnpm ×3, one 5s bearer timeout) were reproduced on a pristine worktree at merge + base `babb76449`, so they pre-date this diff. +- Activation observed live on the reporting machine: repo build prints `Catalog + clamp: inactive` and no doctor warning against the real leftover 0.135.0 + diagnostic at the unchanged binary path (now 0.154.0). +- Audit: three rounds with the same independent reviewer (`xai/grok-4.6`), + FAIL → FAIL → PASS; synthesis in `050_revalidation.md`. + +## What did not improve (LOOP-PESSIMIST-01) + +- The installed proxy (2.50.0) still ships the old behaviour; this machine's warning + clears only for a build that includes this branch. +- The four environmental test failures are untouched — they are not this unit's, + but they are also nobody's right now. +- The Astra card question is narrowed, not answered: bundled and pin both carry + `availability_nux: null` for `gpt-6-astra`, so if upstream ships copy at all it + lives on the account roster endpoint. If the probe shows nothing there either, + the honest answer to #4213 is that upstream has not shipped the copy. + +## Next + +- Push + PR to `dev` — needs explicit user approval (DEV-GIT-PUSH-01). PR text must + fill the template; no GUI surface changed, so no screenshot obligation. +- The account-roster probe (Phase 3 re-plan trigger) — user-authorized token only. +- A summary comment on #4213 with the catalog-field evidence — offered, not requested. From 5f7eb2cdc920f45a0d3c833b3c35a5f2191fbb65 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:08:02 +0800 Subject: [PATCH 85/96] chore(skill): regenerate management surface after dev sync --- skills/ocx/references/01_management_surface.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index f60b502027..cbeb94757c 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -208,7 +208,7 @@ The generated model catalog served to clients. | Flag | Value | Meaning | |---|---|---| -| `--json` | boolean | Emit the catalog as JSON. | +| `--json` | boolean | Emit the catalog payload as JSON. | JSON mode: `payload`. @@ -749,6 +749,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 39 -- of those, state-changing: 18 +- declared capabilities: 40 +- of those, state-changing: 19 - head-resolved invocations: 2 From b1c7cba922d19cb9da5fb7acaf8fd8fdf7d7c5de Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:11:41 +0800 Subject: [PATCH 86/96] fix(skill): restore generated catalog wording --- skills/ocx/references/01_management_surface.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index cbeb94757c..fed84aaabc 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -208,7 +208,7 @@ The generated model catalog served to clients. | Flag | Value | Meaning | |---|---|---| -| `--json` | boolean | Emit the catalog payload as JSON. | +| `--json` | boolean | Emit the catalog as JSON. | JSON mode: `payload`. From b4a3a8055afbcbaf76b19ba1723f31b41d84a05b Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 14:20:23 +0900 Subject: [PATCH 87/96] fix(codex): repair an orphaned exempt default instead of advertising an unselectable one CodeRabbit review on #4257: the exempt default must survive only when the surviving ladder advertises it; an orphaned ultra default is repaired down without naming the rung in the clamp diagnostic. Also folds the three doc-coherence findings into the devlog unit and docs-site. --- .../000_plan.md | 8 ++++-- .../040_open_gaps.md | 7 +++--- .../content/docs/guides/codex-app-models.md | 3 +++ src/codex/catalog/effort.ts | 25 +++++++++++++------ tests/codex-integration/codex-runtime.test.ts | 9 ++++--- 5 files changed, 35 insertions(+), 17 deletions(-) diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/000_plan.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/000_plan.md index 86a954e719..179bdb55d4 100644 --- a/devlog/_plan/260911_catalog_presentation_and_effort_projection/000_plan.md +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/000_plan.md @@ -9,8 +9,12 @@ binary is upgraded in place, because the diagnostic is matched to the current runtime by **path alone** — the recorded version is never compared when the path is unchanged, which is the normal Windows auto-update case. Second, the two presentation fields the Codex client renders as cards (`availability_nux`, -`upgrade`) are deleted at four sites and carried at none, so no announcement or -upgrade card can ever appear through the proxy. This unit makes the clamp +`upgrade`) survive end to end on pin-backed native rows but are discarded where +the account roster is parsed (`model-entitlements.ts` keeps only the slug), so a +card whose copy lives only on the account endpoint can never appear through the +proxy. (An earlier draft of this paragraph said "deleted at four sites and +carried at none"; the explorer pass disproved it — see +`020_architecture_dispositions.md`.) This unit makes the clamp diagnostic version-aware, exempts `max` and `ultra` from the observed-runtime intersection entirely, and — pending a probe — lets the account roster contribute the presentation copy it currently discards. The per-consumer ladder projection in diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/040_open_gaps.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/040_open_gaps.md index 005b1d0f6d..783687138d 100644 --- a/devlog/_plan/260911_catalog_presentation_and_effort_projection/040_open_gaps.md +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/040_open_gaps.md @@ -25,9 +25,10 @@ cycle with the working tree already carrying the CLAMP-04 implementation to it, and the four seed-test rows inverted to `["xhigh"]`). That is the recorded decision; see `050_revalidation.md`. -Independent of that choice: `src/cli/doctor.ts:1180` does not call -`effortClampAppliesToRuntime`, so doctor and status can disagree about one file. -That is a defect either way and belongs in Phase 4. +Independent of that choice: `src/cli/doctor.ts:1180` did not call +`effortClampAppliesToRuntime`, so doctor and status could disagree about one file. +**Historical as of 2026-09-11** — `doctor.ts:1182-1189` now calls the shared +predicate; recorded in `050_revalidation.md`. ## GAP-2 — Phase 3 names a consumer that cannot consume diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index ccd98f0453..b30240bf0e 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -261,6 +261,9 @@ rungs are still intersected with the observed runtime ladder, and a clamp diagno previous binary stops applying once the binary at that path reports a different version (the in-place upgrade case), so `ocx status` and `ocx doctor` stop warning about a clamp the upgraded runtime no longer needs. +Catalog visibility is not entitlement: advertising `max`/`ultra` does not guarantee the upstream +account or provider accepts the tier, and for older native models whose real ladder stops at +`xhigh` the wire clamp above still maps the selection down at request time. ## Fast tier rules diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 47dc0ab572..f54792b2ec 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -382,14 +382,19 @@ export function clampEntryToCodexSupportedEfforts( .map(level => ({ ...level })); } const currentDefault = entry.default_reasoning_level; + const surviving = (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : []) + .flatMap(level => typeof (level as { effort?: string })?.effort === "string" + ? [(level as { effort: string }).effort] + : []); + // An exempt default survives only when the surviving ladder actually advertises it; + // otherwise the row would name a default the client cannot select (review: PR #4257). if (typeof currentDefault === "string" - && !supported.has(currentDefault) - && !UNCLAMPABLE_REASONING_EFFORTS.has(currentDefault)) { - const surviving = (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : []) - .flatMap(level => typeof (level as { effort?: string })?.effort === "string" - ? [(level as { effort: string }).effort] - : []); - entry.default_reasoning_level = clampedDefaultEffort(currentDefault, surviving); + && !supported.has(currentDefault)) { + const exemptAndAdvertised = UNCLAMPABLE_REASONING_EFFORTS.has(currentDefault) + && surviving.includes(currentDefault); + if (!exemptAndAdvertised) { + entry.default_reasoning_level = clampedDefaultEffort(currentDefault, surviving); + } } } @@ -475,7 +480,11 @@ export function clampCatalogModelsToObservedCodexSupport( const omitted = requiresExactReserveEfforts(entry) && hadLadder && after.size === 0; if (lost.length > 0 || defaultClamped || omitted) { for (const effort of lost) removed.add(effort); - if (defaultClamped && beforeDefault) removed.add(beforeDefault); + // An orphaned exempt default (ultra with no ultra rung in the ladder) is repaired for + // coherence, but nothing was removed from the offering — do not name it in the diagnostic. + if (defaultClamped && beforeDefault && !UNCLAMPABLE_REASONING_EFFORTS.has(beforeDefault)) { + removed.add(beforeDefault); + } if (typeof entry.slug === "string") affected.push(entry.slug); } if (omitted) models.splice(index, 1); diff --git a/tests/codex-integration/codex-runtime.test.ts b/tests/codex-integration/codex-runtime.test.ts index 5b82cf893c..e413994ce6 100644 --- a/tests/codex-integration/codex-runtime.test.ts +++ b/tests/codex-integration/codex-runtime.test.ts @@ -1037,7 +1037,10 @@ describe("resolveCodexRuntime", () => { expect(diagnostics[0]?.affectedModels).toEqual(["openrouter/example"]); }); - test("an ultra default against a runtime that stops at high produces no clamp diagnostic", async () => { + // An exempt default only survives when the surviving ladder advertises it; an orphaned ultra + // default (no ultra rung in the ladder) is repaired down for catalog coherence, and because + // nothing was removed from the offering the repair produces no clamp diagnostic. + test("an orphaned ultra default is repaired without a clamp diagnostic", async () => { const { clampCatalogModelsToCodexSupport } = await import("../../src/codex/catalog/effort"); const diagnostics: Array<{ removedEfforts: string[]; affectedModels: string[] }> = []; const models = [{ @@ -1065,9 +1068,7 @@ describe("resolveCodexRuntime", () => { }), onEffortClamp: (diagnostic) => diagnostics.push(diagnostic), }); - // ultra is exempt from the observed-runtime intersection: the default survives and - // nothing is reported, so the persisted diagnostic stays absent. - expect(models[0]!.default_reasoning_level).toBe("ultra"); + expect(models[0]!.default_reasoning_level).toBe("high"); expect(diagnostics).toEqual([]); }); }); From 1b503b089ac0ed949d6f2a1e0828ee6efa150714 Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:29:09 +0800 Subject: [PATCH 88/96] docs(pr): add privacy-safe usage retention preview --- .../usage-ledger-retention-usage-ui.jpg | Bin 0 -> 13485 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .github/pr-assets/usage-ledger-retention-usage-ui.jpg diff --git a/.github/pr-assets/usage-ledger-retention-usage-ui.jpg b/.github/pr-assets/usage-ledger-retention-usage-ui.jpg new file mode 100644 index 0000000000000000000000000000000000000000..935bb1c6c93cc18ccd917dfefb9d3f12728b072b GIT binary patch literal 13485 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 Date: Fri, 11 Sep 2026 10:06:51 +0900 Subject: [PATCH 89/96] provider: seed GLM-5.3-Flash on the BigModel Responses preset The Responses preset shipped a two-model roster read off the models.json sample on BigModel's Codex page. That sample is a starter catalog, not the endpoint's roster, and taking it for the latter left Flash off a subscription that sells it. Three upstream pages disagree with the old reading, all checked 2026-09-11: - coding-plan/latest-model.md pins Codex to https://open.bigmodel.cn/api/v1 -- this preset's exact baseUrl -- and states GLM Coding Plan supports GLM-5.3 and GLM-5.3-Flash for every tier, then treats glm-5.3-flash as an already-callable id in that tool. - coding-plan/overview.md states GLM-5-Turbo calls are auto-switched to GLM-5.3-Flash. The preset already lists glm-5-turbo, so it was already reaching Flash on this endpoint under another name. - guide/models/vlm/glm-5.3-flash.md gives native multimodal input, a 1M window, and text parameters "consistent with GLM-5.3". Flash is seeded into the roster and all five sibling per-model maps. Its context tracks the 5.3 sibling on this row (1_048_576) rather than the Chat row's 1_000_000: both are documented as "1M", and this preset expresses that family's 1M the way BigModel's own Codex declaration does, so one preset does not claim two sizes for one documented window. Flash declares ["text", "image"]. It is the only row here that can actually see an image; the other two are text-only upstream and get image back from the vision sidecar at catalog-build time. Declaring Flash text-only would push a native VLM's pictures through a describe-it-first detour and hand the model prose about an image it could have read -- the defect ZAI_GLM_5X_SIDECAR_VISION_MODELS already exists to prevent on the Chat rows. The oracle moves in this same commit, because a test asserting the old roster is not evidence for the new one, it is the thing being changed. It previously locked models to two entries and asserted glm-5.3-flash was absent from the export. It now pins the three-model contract, Flash's exported window, ladder (low/high/max plus the compatibility ultra tier), default effort and native image modality, and keeps asserting the part no document supports: there is still no HTTP /models contract here, so liveModels and apiKeyValidation must not drift. Closes #4201 --- src/providers/registry.ts | 39 ++++++++++++++--- .../provider-registry-parity.test.ts | 43 +++++++++++++++---- 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/src/providers/registry.ts b/src/providers/registry.ts index d4c6c542d0..c4ea553d2d 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2640,6 +2640,23 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // Narrowed carry of #3641: the official Codex example declares a local static catalog, // not an HTTP /models contract. Keep Responses separate from the Chat endpoint above. // Source: https://docs.bigmodel.cn/cn/coding-plan/tool/codex.md (checked 2026-09-07). + // + // #4201 completes the roster. The `models.json` example on that Codex page is a *starter + // catalog*, not the set of models the endpoint serves, and reading it as the latter is what + // left Flash off a subscription that sells it. Three upstream pages say so directly, all + // checked 2026-09-11: + // - coding-plan/latest-model.md pins Codex to THIS baseUrl + // (`Codex:https://open.bigmodel.cn/api/v1`) and opens with GLM Coding Plan supporting + // GLM-5.3 and GLM-5.3-Flash for every tier (Max & Pro & Lite), then treats + // `glm-5.3-flash` as an already-callable id in that same tool. + // - coding-plan/overview.md: every plan supports GLM-5.3 and GLM-5.3-Flash, and calls to + // GLM-5-Turbo are auto-switched to GLM-5.3-Flash. Turbo below is therefore an alias of + // the very model this row omitted, which is the clearest statement that the endpoint + // serves Flash: it was already serving it under another name. + // - guide/models/vlm/glm-5.3-flash.md: native multimodal input, 1M context, and text + // parameters explicitly "consistent with GLM-5.3". + // No authenticated /models probe is implied by any of this, so `liveModels` and + // `apiKeyValidation` below are deliberately unchanged. { id: "zhipu-bigmodel-responses", label: "Zhipu AI — BigModel Coding Plan (Responses)", @@ -2648,22 +2665,34 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ authKind: "key", dashboardUrl: "https://bigmodel.cn/console/usercenter/apikeys", defaultModel: "glm-5.3", - models: ["glm-5.3", "glm-5-turbo"], + models: ["glm-5.3", "glm-5.3-flash", "glm-5-turbo"], liveModels: false, // The local Codex catalog does not establish an authenticated HTTP /models contract. apiKeyValidation: "unknown", jawcodeBundle: "zai", // A pre-existing same-named custom provider must retain its destination and key boundary. preserveCustomDestination: true, - modelContextWindows: { "glm-5.3": 1_048_576, "glm-5-turbo": 204_800 }, - modelInputModalities: { "glm-5.3": ["text"], "glm-5-turbo": ["text"] }, + // Flash tracks its 5.3 sibling on this row rather than the Chat row's 1_000_000. Both + // models are documented as "1M", and this preset expresses that family's 1M the way + // BigModel's own Codex declaration does. Splitting the two would leave one preset + // claiming two different sizes for one documented window. + modelContextWindows: { "glm-5.3": 1_048_576, "glm-5.3-flash": 1_048_576, "glm-5-turbo": 204_800 }, + // Flash is the only row here that can actually see an image. Its siblings are declared + // text-only and get `image` back from the vision sidecar at catalog-build time; declaring + // Flash text-only would route a native VLM's pictures through a describe-it-first detour + // and hand the model prose about an image it could have read (same defect + // ZAI_GLM_5X_SIDECAR_VISION_MODELS exists to prevent on the Chat rows). + modelInputModalities: { "glm-5.3": ["text"], "glm-5.3-flash": ["text", "image"], "glm-5-turbo": ["text"] }, modelReasoningEfforts: { "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS, + // Same three effective tiers: upstream documents Flash's text parameters as identical + // to GLM-5.3, and the Codex effort table folds every inbound value into low/high/max. + "glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS, // Explicitly empty: Turbo must not inherit the generic selectable effort ladder. "glm-5-turbo": [], }, - modelDefaultReasoningEfforts: { "glm-5.3": "max", "glm-5-turbo": "max" }, - modelSupportsReasoningSummaries: { "glm-5.3": true, "glm-5-turbo": true }, + modelDefaultReasoningEfforts: { "glm-5.3": "max", "glm-5.3-flash": "max", "glm-5-turbo": "max" }, + modelSupportsReasoningSummaries: { "glm-5.3": true, "glm-5.3-flash": true, "glm-5-turbo": true }, // Responses replay uses this provider-level flag, not the Chat-path model list. preserveResponsesReasoningContent: true, note: "Domestic BigModel Coding Plan Responses endpoint; static model roster", diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index ab9a5c64a3..1dcc89ca1f 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -451,9 +451,20 @@ describe("provider registry parity", () => { expect(glm53Entry?.default_reasoning_level).toBe("max"); }); - test("BigModel Responses exports only the officially documented static Codex models", () => { - // Independent oracle: https://docs.bigmodel.cn/cn/coding-plan/tool/codex.md, - // local models.json example checked 2026-09-07; not an authenticated /models response. + test("BigModel Responses exports the documented Coding Plan roster for the Codex endpoint", () => { + // Independent oracle, all checked 2026-09-11 and none of them an authenticated /models + // response. The earlier version of this test read the models.json sample on + // https://docs.bigmodel.cn/cn/coding-plan/tool/codex.md as the endpoint's whole roster and + // hard-locked two models. It is a starter catalog, and three other upstream pages contradict + // that reading (#4201): + // - coding-plan/latest-model.md binds Codex to https://open.bigmodel.cn/api/v1 and states + // GLM-5.3 and GLM-5.3-Flash are available to every plan tier. + // - coding-plan/overview.md states GLM-5-Turbo calls are auto-switched to GLM-5.3-Flash, + // so this preset was already reaching Flash through the Turbo id it does list. + // - guide/models/vlm/glm-5.3-flash.md gives native multimodal input, a 1M window, and text + // parameters "consistent with GLM-5.3". + // What stays locked is the part no document supports: there is still no HTTP /models + // contract here, so liveModels and apiKeyValidation must not drift. const id = "zhipu-bigmodel-responses"; const registry = PROVIDER_REGISTRY.find(entry => entry.id === id)!; expect(registry).toMatchObject({ @@ -461,17 +472,24 @@ describe("provider registry parity", () => { baseUrl: "https://open.bigmodel.cn/api/v1", authKind: "key", defaultModel: "glm-5.3", - models: ["glm-5.3", "glm-5-turbo"], + models: ["glm-5.3", "glm-5.3-flash", "glm-5-turbo"], liveModels: false, preserveCustomDestination: true, preserveResponsesReasoningContent: true, }); expect(registry.modelDiscovery).toBeUndefined(); expect(registry.preserveReasoningContentModels).toBeUndefined(); - const upstreamModalities = { "glm-5.3": ["text"], "glm-5-turbo": ["text"] }; + // Flash is the one row upstream documents as natively multimodal; the other two are text. + // Pinned separately from the global VLM rule above so that copying glm-5.3's ["text"] onto + // Flash fails here, naming this preset, rather than only in a loop over every provider. + const upstreamModalities = { + "glm-5.3": ["text"], "glm-5.3-flash": ["text", "image"], "glm-5-turbo": ["text"], + }; expect(registry.modelInputModalities).toEqual(upstreamModalities); + expect(registry.modelInputModalities?.["glm-5.3-flash"]).toContain("image"); + expect(registry.noVisionModels ?? []).not.toContain("glm-5.3-flash"); expect(KEY_LOGIN_PROVIDERS[id]).toMatchObject({ - models: ["glm-5.3", "glm-5-turbo"], liveModels: false, apiKeyValidation: "unknown", + models: ["glm-5.3", "glm-5.3-flash", "glm-5-turbo"], liveModels: false, apiKeyValidation: "unknown", }); const provider = providerConfigSeed(registry); enrichProviderFromRegistry(id, provider); @@ -480,18 +498,23 @@ describe("provider registry parity", () => { const models = provider.models!.map(modelId => applyProviderConfigHints(id, provider, { provider: id, id: modelId, })); - // The official upstream declaration stays text-only. Catalog hints add image for the - // existing vision sidecar (vision/eligibility.ts), not native BigModel image support. + // glm-5.3 and glm-5-turbo stay text-only upstream and get image back from the existing + // vision sidecar (vision/eligibility.ts). Flash already declares image, so its catalog + // modality is the model's own capability rather than a sidecar detour — the rows look + // alike below, and this assertion is what keeps the reason for them different. expect(provider.modelInputModalities).toEqual(upstreamModalities); expect(models).toMatchObject([ { id: "glm-5.3", contextWindow: 1_048_576, reasoningEfforts: ["low", "high", "max"], defaultReasoningEffort: "max", supportsReasoningSummaries: true, inputModalities: ["text", "image"] }, + { id: "glm-5.3-flash", contextWindow: 1_048_576, reasoningEfforts: ["low", "high", "max"], + defaultReasoningEffort: "max", supportsReasoningSummaries: true, inputModalities: ["text", "image"] }, { id: "glm-5-turbo", contextWindow: 204_800, reasoningEfforts: [], defaultReasoningEffort: "max", supportsReasoningSummaries: true, inputModalities: ["text", "image"] }, ]); const entries = buildCatalogEntries(nativeTemplate(), [], models); for (const [modelId, window, efforts] of [ ["glm-5.3", 1_048_576, ["low", "high", "max", "ultra"]], + ["glm-5.3-flash", 1_048_576, ["low", "high", "max", "ultra"]], ["glm-5-turbo", 204_800, []], ] as const) { const entry = entries.find(row => row.slug === `${id}/${modelId}`); @@ -505,7 +528,9 @@ describe("provider registry parity", () => { expect((entry?.supported_reasoning_levels as Array<{ effort: string }>).map(row => row.effort)) .toEqual([...efforts]); } - expect(entries.some(entry => String(entry.slug).includes("glm-5.3-flash"))).toBe(false); + // The reported gap: Flash reaches the exported catalog for this preset, once, under its + // own slug rather than only as the Turbo alias upstream silently redirects. + expect(entries.filter(entry => String(entry.slug) === `${id}/glm-5.3-flash`)).toHaveLength(1); }); test("BigModel Responses key login does not probe an undocumented models endpoint", async () => { From c838ff5f5110cafbf9d2e473cc61be64faef4bef Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 16:54:47 +0900 Subject: [PATCH 90/96] docs(providers): seed Flash in the BigModel Responses roster the page describes The preset now seeds glm-5.3-flash, and both pages still said it did not. The guide went further and told the reader why it was excluded, which stops being staleness and becomes a false statement the moment this lands. The modality note is split rather than rewritten: 5.3 and Turbo still reach images through the vision sidecar, while Flash declares native text and image input, so the page should not describe one mechanism for all three rows. --- .../src/content/docs/guides/providers.md | 25 ++++++++++++------- .../docs/reference/configuration/providers.md | 2 +- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index d4a0f1c724..d93dbe0af5 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -817,18 +817,23 @@ Select **Zhipu AI — BigModel Coding Plan (Responses)** (`zhipu-bigmodel-respon for the `openai-responses` endpoint `https://open.bigmodel.cn/api/v1`. This is separate from `zhipu-bigmodel-coding`, which uses Chat Completions at `/api/coding/paas/v4`. -The preset uses a **static roster** (`liveModels: false`) taken from the -[official BigModel Codex example](https://docs.bigmodel.cn/cn/coding-plan/tool/codex.md): +The preset uses a **static roster** (`liveModels: false`) taken from the published +[GLM Coding Plan documentation](https://docs.bigmodel.cn/cn/coding-plan/tool/codex.md): | Model | Context tokens | Upstream selectable effort | Default effort | Reasoning summaries | | --- | ---: | --- | --- | --- | | `glm-5.3` | 1,048,576 | `low`, `high`, `max` | `max` | Supported | +| `glm-5.3-flash` | 1,048,576 | `low`, `high`, `max` | `max` | Supported | | `glm-5-turbo` | 204,800 | None (empty list) | `max` | Supported | -Both entries declare upstream text-only input. The Codex catalog advertises text and -image because opencodex's existing vision sidecar can describe images for text-only -models. Image handling requires an available, enabled vision sidecar; this does not -declare native BigModel image support. +`glm-5.3` and `glm-5-turbo` declare upstream text-only input. The Codex catalog +advertises text and image for them because opencodex's existing vision sidecar can +describe images for text-only models; that path requires an available, enabled vision +sidecar and does not claim native BigModel image support. + +`glm-5.3-flash` is the exception: it declares native `text` and `image` input, because +upstream documents it as a natively multimodal model. It therefore reads pictures +directly instead of being routed through the describe-it-first sidecar detour. The default model is `glm-5.3`; Responses reasoning content is preserved on replay. The existing Codex export adds its compatibility @@ -839,9 +844,11 @@ For Turbo, outgoing Responses requests omit `reasoning.effort`, including a call selection to the upstream default; opencodex does not inject a selectable or wire `max`. The example's `models.json` is a local catalog file, not a documented HTTP model-list -response. This preset does not perform live model discovery. `glm-5.3-flash` is not -seeded here because its exact Responses metadata is not verified. An existing custom -provider with the same name keeps its configured destination and metadata. +response, and not the set of models the endpoint serves — the Coding Plan pages state +that every plan tier reaches GLM-5.3 and GLM-5.3-Flash, and that GLM-5-Turbo calls are +auto-switched to Flash, so this endpoint was already serving Flash under the Turbo id. +This preset still does not perform live model discovery. An existing custom provider +with the same name keeps its configured destination and metadata. CLI key login also skips the undocumented `/models` probe and reports validation as unknown; successful key authentication is established by a subsequent inference request. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d40de409d2..e2769ab807 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -836,7 +836,7 @@ container usually has no unlocked keychain session, so requests would fail close `${ENV_VAR}` reference in the service environment there instead. Env references are left untouched by `store`. -The `zhipu-bigmodel-responses` preset seeds `glm-5.3` and `glm-5-turbo` with +The `zhipu-bigmodel-responses` preset seeds `glm-5.3`, `glm-5.3-flash` and `glm-5-turbo` with `liveModels: false` for `https://open.bigmodel.cn/api/v1`. Its static roster and per-model context, effort, and summary metadata come from the [BigModel Responses guide](/guides/providers/#bigmodel-coding-plan-over-responses). From d2779262d5d37ff72e6c77aa58dcd6d46293a8db Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 10:23:11 +0900 Subject: [PATCH 91/96] pool: name the account when a refresh fails or its models vanish Two surfaces changed because one pooled account stopped being usable, and neither said so. The reporter in #4212 lost astra and sol through the proxy, found the proxy worked with ocx turned off, and concluded OpenCodex had broken. The real cause was a single account stuck on a failed credential refresh, which they eventually found themselves and then asked to be told about. The request-time refusal now names the account. refreshPoolForwardAuth and refreshPoolCompactContext both caught a non-terminal refresh failure and returned "Codex credential refresh did not complete; retry this request", which describes a transient server problem. It stays a retryable 503 and stays non-quarantining, because the refresh genuinely may succeed and a token-endpoint 5xx must not retire a healthy account (#2887). What it gains is the account and the exit: when retrying stops helping, that account has to be signed in again. The two call sites now share one helper, so the regular and compact contracts on this endpoint cannot drift the way they already had -- compact takes no RouteResult and so could not reach the public selector at all until its caller started passing it. The refusal says "sign in to that account again" rather than the more natural "needs reauthentication", and that is load-bearing. classifyError runs isAuthenticationMessage before it reaches the status === 503 arm, and that check is status-blind on the bare substring "authentication", which "reauthentication" contains. The friendlier wording reclassifies the body to authentication_error / invalid_api_key while the HTTP status stays 503, and Codex applies retry-after backoff only for server_is_overloaded -- so it would have quietly disabled the retry this refusal exists to ask for. A test pins the wording, not just the resulting code, because the next person to improve this sentence will not know. The name is a public account selector when the request carried one, otherwise the durable p-prefixed log label. Never the raw pool id and never the email: those are the identifiers responses-compaction-routing.test.ts and codex-auth-context.test.ts already assert must not reach an operator-facing surface, and an error body travels further than a log line. When neither resolves, the sentence degrades to "the selected Codex pool account" rather than naming something opaque. The catalog drop now explains itself. A gated native model that no usable account backs is omitted from the catalog -- there is no row, so nothing downstream could attach a reason to it, and no later surface can tell "never entitled" apart from "the account broke this morning". The suppression site now says which accounts are stuck while the entitlement snapshot that produced the omission is still in scope. That explanation is deliberately narrow, in two ways. It is produced only when an account needs reauthentication, because being unentitled is the default state of most installations and explaining that on every sync would bury the case an operator can act on. And it considers only accounts that could have served the model in question: an account upstream positively denied is not the reason the model is missing, so naming it would send the operator to repair a credential that was never going to help. An unconfirmed roster stays a candidate, because that is exactly what a credential stuck on a failed refresh looks like. Catalog bytes are unchanged. The suppressed slugs are still suppressed, so the existing oracles that assert gated slugs stay absent from the written catalog keep asserting exactly that. Closes #4212 --- scripts/test-layout/layout.json | 2 + src/codex/catalog/sync.ts | 85 ++++++++++ src/server/responses/compact.ts | 26 ++- src/server/responses/core.ts | 61 ++++++- ...og-gated-native-suppression-reason.test.ts | 149 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 + ...responses-pool-refresh-attribution.test.ts | 124 +++++++++++++++ 7 files changed, 433 insertions(+), 16 deletions(-) create mode 100644 tests/codex-integration/catalog-gated-native-suppression-reason.test.ts create mode 100644 tests/responses/responses-pool-refresh-attribution.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index fad27db650..0c07fc9a7c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -267,6 +267,7 @@ "catalog-cursor-search.test.ts": "codex-integration", "catalog-free-pricing-status.test.ts": "codex-integration", "catalog-full-picker-order.test.ts": "codex-integration", + "catalog-gated-native-suppression-reason.test.ts": "codex-integration", "catalog-go-exact-efforts.test.ts": "codex-integration", "catalog-hub-context-window.test.ts": "codex-integration", "catalog-input-modality-enum.test.ts": "codex-integration", @@ -1084,6 +1085,7 @@ "responses-parser-malformed-content.test.ts": "responses", "responses-parser.test.ts": "responses", "responses-pool-401-refresh.test.ts": "responses", + "responses-pool-refresh-attribution.test.ts": "responses", "responses-reasoning-summary-passthrough.test.ts": "responses", "responses-reasoning-summary-rewrite.test.ts": "responses", "responses-routed-web-search-fields.test.ts": "responses", diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 087b659140..074d9ddef0 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -42,6 +42,8 @@ import { resolveCodexModelEntitlements, type CodexModelEntitlementSnapshot, } from "../model-entitlements"; +import { isAccountNeedsReauth } from "../account-runtime-state"; +import { codexAccountLogLabel, fallbackCodexAccountLogLabel } from "../account-label"; import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, findSupportedNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readConfiguredAutoReviewModel, readNativeBaseline } from "./parsing"; @@ -1708,6 +1710,75 @@ export function finalizeAutoReviewModelOverride( return applyAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), sourceModels); } +/** + * Why an account-gated native model stopped being offered, but only when the answer is one the + * operator can act on. + * + * Suppression is an omission: the row is never built, so there is no catalog entry for a reason + * to ride on and no downstream consumer that could explain it later. #4212's reporter watched + * their models disappear and reasonably concluded the proxy was broken, because every surface + * that changed said nothing about the account that caused it. + * + * Returns `undefined` for the ordinary case — an account that is simply not entitled to a gated + * model. That is the default state for most installations, it is not news, and warning about it + * on every sync would bury the one case that matters. A credential the operator must repair is + * the case that matters, so that is the only one this speaks up about. + * + * Accounts are named with the durable `p`-prefixed log label, the same identifier the dashboard + * shows, never the raw pool id or the email. + */ +export function gatedNativeReauthSuppressionReason(args: { + snapshot: CodexModelEntitlementSnapshot; + slug: string; + eligibleAccountIds?: ReadonlySet; + needsReauth: (accountId: string) => boolean; + label: (accountId: string) => string; +}): string | undefined { + const observed = [...args.snapshot.modelsByAccount.keys()] + .filter(accountId => !args.eligibleAccountIds || args.eligibleAccountIds.has(accountId)) + // Only accounts that could actually have served THIS model. An account upstream positively + // denied is not why the model is missing, and blaming it would send the operator to repair a + // credential that was never going to help. `unknown` has to stay in: an account whose roster + // could not be confirmed reports `unknown` rather than `granted`, and a credential stuck on + // a failed refresh is exactly that account. + .filter(accountId => ( + codexModelEntitlementStateForAccount(args.snapshot, accountId, args.slug) !== "denied" + )); + const stuck = observed.filter(accountId => args.needsReauth(accountId)); + if (stuck.length === 0) return undefined; + const names = stuck.map(accountId => args.label(accountId)).sort().join(", "); + return stuck.length === observed.length + ? `every Codex account that could serve it needs reauthentication (${names})` + : `${stuck.length} of ${observed.length} Codex accounts that could serve it need reauthentication (${names})`; +} + +/** Durable, operator-facing label for a pool account id; never the raw id or the email. */ +function gatedNativeAccountLabel(config: OcxConfig, accountId: string): string { + // Direct mode narrows eligibility to the native main credential, so this is the account most + // likely to be named here. `codexAuthContextLogLabel` calls it "main" everywhere else; hashing + // it into a `p`-prefixed digest would name the one account the operator cannot look up. + if (accountId === MAIN_CODEX_ACCOUNT_ID) return "main"; + const account = (config.codexAccounts ?? []).find(candidate => candidate.id === accountId); + return account ? codexAccountLogLabel(account) : fallbackCodexAccountLogLabel(accountId); +} + +const warnedGatedNativeSuppression = new Set(); + +/** Test seam: the warn-once memory is process-global, so a case needs to be able to clear it. */ +export function resetGatedNativeSuppressionWarningsForTests(): void { + warnedGatedNativeSuppression.clear(); +} + +function warnGatedNativeSuppressedOnce(slug: string, reason: string): void { + const signature = `${slug}\u0000${reason}`; + if (warnedGatedNativeSuppression.has(signature)) return; + warnedGatedNativeSuppression.add(signature); + console.warn( + `[opencodex] catalog sync: ${slug} is not being offered because ${reason}. ` + + "Sign in again to restore it.", + ); +} + /** * Mescla o catálogo retido com os modelos visíveis e as configurações atuais, * incluindo os nomes nativos. Tenta preservar o backup original e usa a permissão @@ -1777,6 +1848,20 @@ function writeRetainedCatalogSync({ const unavailableGatedNativeSlugs = new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => ( !availableBareGatedNativeSlugs.has(slug) ))); + // #4212: this set is the whole record of a model vanishing, and it is a set of strings that + // nothing downstream ever asks a question of. Explain it here, while the entitlement snapshot + // that produced it is still in scope, because after this point the model is simply absent and + // no later surface can tell "never entitled" apart from "the account broke this morning". + for (const slug of unavailableGatedNativeSlugs) { + const reason = gatedNativeReauthSuppressionReason({ + snapshot: modelEntitlements, + slug, + eligibleAccountIds: bareEligibleAccountIds, + needsReauth: isAccountNeedsReauth, + label: accountId => gatedNativeAccountLabel(config, accountId), + }); + if (reason) warnGatedNativeSuppressedOnce(slug, reason); + } const suppressedBareNativeSlugs = new Set([ ...desktopAllowlistSuppressedNativeSlugs(config), ...unavailableGatedNativeSlugs, diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 8add02babf..45bdb3fa3d 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -152,6 +152,7 @@ import { decodeRequestErrorResponse, handleResponses, preAuthUpstreamHostCircuitKey, + poolCredentialRefreshIncompleteResponse, upstreamHostCircuitOpenResponse, usesCodexForwardPoolAuth, } from "./core"; @@ -317,6 +318,13 @@ async function refreshPoolCompactContext(args: { authCtx: CodexAuthContext & { kind: "pool" }; provider: OcxProviderConfig; codexAccountMode?: CodexAccountMode; + /** + * Public selector for the account this refresh is for, when the request carried one. The + * caller has it and this function does not, because compact takes no `RouteResult` — which + * is the whole reason the refusal here used to be less specific than the one core returns + * for the identical failure. + */ + codexAccountNamespace?: string; substituteMainCredential: boolean; options: HandleResponsesCompactOptions; }): Promise< @@ -377,14 +385,15 @@ async function refreshPoolCompactContext(args: { if (isTerminalCompactPoolRefreshFailure(error)) { return { ok: false, quarantine: true, response: reauthResponse() }; } - const response = formatErrorResponse( - 503, - "server_busy", - "Codex credential refresh did not complete; retry this request", - ); - const headers = new Headers(response.headers); - headers.set("Retry-After", "1"); - return { ok: false, quarantine: false, response: new Response(response.body, { status: response.status, headers }) }; + return { + ok: false, + quarantine: false, + response: poolCredentialRefreshIncompleteResponse({ + authCtx, + config, + accountSelector: args.codexAccountNamespace, + }), + }; } } @@ -917,6 +926,7 @@ export async function handleResponsesCompact( authCtx: poolAuthCtx, provider: compactProvider, codexAccountMode: route.codexAccountMode, + codexAccountNamespace: route.codexAccountNamespace, substituteMainCredential, options, }) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 425621814c..3f4cfe4345 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2262,6 +2262,50 @@ function isTerminalPoolRefreshFailure(error: unknown): boolean { return error instanceof TokenRefreshError && (error.reason === "revoked" || error.reason === "expired"); } +/** + * The refusal an operator meets when a stored pool credential's forced refresh does not complete. + * + * A bare "retry this request" reads as a transient fault in the proxy, which is how #4212's + * reporter spent an afternoon concluding OpenCodex had broken while one of their own accounts was + * the thing that needed them. It stays a retryable 503 and stays non-quarantining, because the + * refresh genuinely may succeed and a token-endpoint 5xx must not retire a healthy account + * (#2887). What it adds is the account and the exit: when retrying stops helping, that account + * has to be signed in again. + * + * The label is a public account selector when the request carried one, otherwise the durable + * `p`-prefixed log label — never the raw pool id and never the email. Those are the identifiers + * `responses-compaction-routing.test.ts` and `codex-auth-context.test.ts` already assert must not + * reach an operator-facing surface, and an error body travels further than a log line, not less. + * When neither is resolvable the sentence degrades to "the selected Codex pool account" rather + * than naming something opaque, because a wrong name is worse than no name. + * + * The wording says "sign in to that account again" and deliberately does NOT say + * "reauthentication". `classifyError` runs `isAuthenticationMessage` before it reaches the + * `status === 503` arm, and that check is status-blind on the bare substring "authentication", + * which "reauthentication" contains. A body carrying that word is reclassified to + * `authentication_error` / `invalid_api_key` even though the HTTP status stays 503 — and Codex + * applies retry-after backoff only for `server_is_overloaded`, so the friendlier sentence would + * have quietly disabled the retry this refusal exists to ask for. `options.code` cannot buy the + * classification back; only the wording can. + */ +export function poolCredentialRefreshIncompleteResponse(args: { + authCtx: CodexAuthContext; + config: Pick; + accountSelector?: string; +}): Response { + const label = args.accountSelector ?? codexAuthContextLogLabel(args.authCtx, args.config); + const account = label ? `Codex pool account ${label}` : "the selected Codex pool account"; + const response = formatErrorResponse( + 503, + "server_busy", + `Codex credential refresh did not complete for ${account}; retry this request. ` + + "If it keeps failing, sign in to that account again.", + ); + const headers = new Headers(response.headers); + headers.set("Retry-After", "1"); + return new Response(response.body, { status: response.status, headers }); +} + /** * One forced refresh and one same-account rebuild for a stored pool credential that * upstream rejected with a pre-stream 401. `quarantine` distinguishes a dead grant, @@ -2332,14 +2376,15 @@ async function refreshPoolForwardAuth(args: { response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"), }; } - const response = formatErrorResponse( - 503, - "server_busy", - "Codex credential refresh did not complete; retry this request", - ); - const headers = new Headers(response.headers); - headers.set("Retry-After", "1"); - return { ok: false, quarantine: false, response: new Response(response.body, { status: response.status, headers }) }; + return { + ok: false, + quarantine: false, + response: poolCredentialRefreshIncompleteResponse({ + authCtx, + config, + accountSelector: route.codexAccountNamespace, + }), + }; } } diff --git a/tests/codex-integration/catalog-gated-native-suppression-reason.test.ts b/tests/codex-integration/catalog-gated-native-suppression-reason.test.ts new file mode 100644 index 0000000000..3b24f70501 --- /dev/null +++ b/tests/codex-integration/catalog-gated-native-suppression-reason.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from "bun:test"; +import { gatedNativeReauthSuppressionReason } from "../../src/codex/catalog/sync"; +import type { CodexModelEntitlementSnapshot } from "../../src/codex/model-entitlements"; + +/** + * #4212: when the accounts backing an account-gated native model stop being usable, the model is + * omitted from the catalog. An omission has no row, so nothing downstream could later explain the + * disappearance — the model was simply gone, and the reporter concluded the proxy had broken. + * + * Two properties have to hold together, and they pull against each other. The explanation has to + * appear for the operator whose credential is the cause, and it has to stay silent for everyone + * else, because being unentitled to a gated model is the normal state of most installations and + * a line printed on every sync would bury the one that matters. + */ + +const SLUG = "gpt-daybreak-blue-latest"; + +interface AccountFixture { + id: string; + /** Observed roster for this account. Defaults to one that includes the gated model. */ + models?: string[]; + /** False leaves the roster unconfirmed, which is what a stuck credential looks like. */ + confirmed?: boolean; +} + +function snapshot(accounts: AccountFixture[]): CodexModelEntitlementSnapshot { + return { + modelsByAccount: new Map(accounts.map(account => [account.id, new Set(account.models ?? [SLUG])])), + clientVersionByAccount: new Map(), + confirmedAccountIds: new Set( + accounts.filter(account => account.confirmed !== false).map(account => account.id), + ), + credentialIdentities: new Map(), + }; +} + +const label = (accountId: string): string => `label-${accountId}`; +const nobodyNeedsReauth = (): boolean => false; +const everybodyNeedsReauth = (): boolean => true; + +describe("gated native suppression reason", () => { + test("stays silent when every account is healthy", () => { + expect(gatedNativeReauthSuppressionReason({ + snapshot: snapshot([{ id: "pool-a" }, { id: "pool-b" }]), + slug: SLUG, + needsReauth: nobodyNeedsReauth, + label, + })).toBeUndefined(); + }); + + test("stays silent when no account was observed at all", () => { + expect(gatedNativeReauthSuppressionReason({ + snapshot: snapshot([]), + slug: SLUG, + needsReauth: everybodyNeedsReauth, + label, + })).toBeUndefined(); + }); + + test("stays silent when the stuck account was never entitled to this model", () => { + // The ordinary install: a confirmed roster that simply does not list the gated model is a + // denial, so this account is not why the model is missing. Naming it would send the operator + // to repair a credential that was never going to produce the model. + expect(gatedNativeReauthSuppressionReason({ + snapshot: snapshot([{ id: "pool-a", models: [] }]), + slug: SLUG, + needsReauth: everybodyNeedsReauth, + label, + })).toBeUndefined(); + }); + + test("names an entitled account that is stuck", () => { + const reason = gatedNativeReauthSuppressionReason({ + snapshot: snapshot([{ id: "pool-a" }, { id: "pool-b" }]), + slug: SLUG, + needsReauth: everybodyNeedsReauth, + label, + }); + expect(reason).toContain("every Codex account that could serve it needs reauthentication"); + expect(reason).toContain("label-pool-a"); + expect(reason).toContain("label-pool-b"); + }); + + test("names an account whose roster could not be confirmed", () => { + // This is the reported shape. A credential stuck on a failed refresh cannot confirm its + // roster, so entitlement reads `unknown` rather than `granted` — the model disappears + // precisely because the evidence went missing, and that account must stay a candidate. + const reason = gatedNativeReauthSuppressionReason({ + snapshot: snapshot([{ id: "pool-a", models: [], confirmed: false }]), + slug: SLUG, + needsReauth: everybodyNeedsReauth, + label, + }); + expect(reason).toContain("every Codex account that could serve it needs reauthentication"); + expect(reason).toContain("label-pool-a"); + }); + + test("reports how many accounts are stuck when only some are", () => { + const reason = gatedNativeReauthSuppressionReason({ + snapshot: snapshot([{ id: "pool-a" }, { id: "pool-b" }]), + slug: SLUG, + needsReauth: accountId => accountId === "pool-a", + label, + }); + expect(reason).toContain("1 of 2 Codex accounts that could serve it need reauthentication"); + expect(reason).toContain("label-pool-a"); + expect(reason).not.toContain("label-pool-b"); + }); + + test("counts only accounts the caller considers eligible", () => { + // Direct mode narrows the eligible set to main. A broken pool account outside that set did + // not cause this omission. + expect(gatedNativeReauthSuppressionReason({ + snapshot: snapshot([{ id: "pool-a" }, { id: "main" }]), + slug: SLUG, + eligibleAccountIds: new Set(["main"]), + needsReauth: accountId => accountId === "pool-a", + label, + })).toBeUndefined(); + + const reason = gatedNativeReauthSuppressionReason({ + snapshot: snapshot([{ id: "pool-a" }, { id: "main" }]), + slug: SLUG, + eligibleAccountIds: new Set(["main"]), + needsReauth: accountId => accountId === "main", + label, + }); + expect(reason).toContain("every Codex account that could serve it needs reauthentication"); + expect(reason).toContain("label-main"); + }); + + test("orders names so the same failure produces the same sentence", () => { + // The warning is emitted once per distinct sentence, so an unstable order would re-warn + // about a situation that had not changed. + const forwards = gatedNativeReauthSuppressionReason({ + snapshot: snapshot([{ id: "pool-b" }, { id: "pool-a" }]), + slug: SLUG, + needsReauth: everybodyNeedsReauth, + label, + }); + const backwards = gatedNativeReauthSuppressionReason({ + snapshot: snapshot([{ id: "pool-a" }, { id: "pool-b" }]), + slug: SLUG, + needsReauth: everybodyNeedsReauth, + label, + }); + expect(forwards).toBe(backwards!); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e8e712c839..ed625c193b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -102,6 +102,7 @@ "catalog-cursor-search.test.ts": "codex-integration", "catalog-free-pricing-status.test.ts": "codex-integration", "catalog-full-picker-order.test.ts": "codex-integration", + "catalog-gated-native-suppression-reason.test.ts": "codex-integration", "catalog-go-exact-efforts.test.ts": "codex-integration", "catalog-hub-context-window.test.ts": "codex-integration", "catalog-input-modality-enum.test.ts": "codex-integration", @@ -919,6 +920,7 @@ "responses-parser-malformed-content.test.ts": "responses", "responses-parser.test.ts": "responses", "responses-pool-401-refresh.test.ts": "responses", + "responses-pool-refresh-attribution.test.ts": "responses", "responses-reasoning-summary-passthrough.test.ts": "responses", "responses-reasoning-summary-rewrite.test.ts": "responses", "responses-routed-web-search-fields.test.ts": "responses", diff --git a/tests/responses/responses-pool-refresh-attribution.test.ts b/tests/responses/responses-pool-refresh-attribution.test.ts new file mode 100644 index 0000000000..e7cae0ae3e --- /dev/null +++ b/tests/responses/responses-pool-refresh-attribution.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; +import { poolCredentialRefreshIncompleteResponse } from "../../src/server/responses/core"; +import type { CodexAuthContext } from "../../src/codex/auth-context"; +import type { OcxConfig } from "../../src/types"; + +/** + * #4212: a pool credential whose forced refresh does not complete used to refuse with + * "Codex credential refresh did not complete; retry this request" and nothing else. That reads + * as a fault in the proxy, so the reporter went looking for a bug in OpenCodex while one of + * their own accounts was the thing that needed them. + * + * These cases pin the two halves of the fix that can regress independently: the refusal names + * an account and states that reauthentication is the exit, and the name it uses is never an + * identifier this codebase treats as private. + */ + +const ACCOUNT_ID = "sensitive-pool-account-id"; +const ACCOUNT_EMAIL = "operator@example.test"; +const LOG_LABEL = "pa1b2c3"; + +function poolAuthCtx(): CodexAuthContext { + return { + kind: "pool", + accountId: ACCOUNT_ID, + writerGeneration: 1, + generation: 1, + accessToken: "access-token", + chatgptAccountId: "chatgpt-account-id", + }; +} + +function configWithAccount(): Pick { + return { + codexAccounts: [{ id: ACCOUNT_ID, email: ACCOUNT_EMAIL, logLabel: LOG_LABEL, isMain: false }], + }; +} + +async function errorPayload(response: Response): Promise<{ message: string; type: string; code: string }> { + const body = await response.json() as { error: { message: string; type: string; code: string } }; + return body.error; +} + +describe("pool credential refresh refusal attribution", () => { + test("names the selector the request actually used", async () => { + const response = poolCredentialRefreshIncompleteResponse({ + authCtx: poolAuthCtx(), + config: configWithAccount(), + accountSelector: "team", + }); + const error = await errorPayload(response); + expect(error.message).toContain("Codex pool account team"); + expect(error.message).toContain("sign in to that account again"); + // The selector the operator typed wins over the derived label: it is the name they can act on. + expect(error.message).not.toContain(LOG_LABEL); + }); + + test("falls back to the durable log label when the request carried no selector", async () => { + const response = poolCredentialRefreshIncompleteResponse({ + authCtx: poolAuthCtx(), + config: configWithAccount(), + }); + const error = await errorPayload(response); + expect(error.message).toContain(`Codex pool account ${LOG_LABEL}`); + expect(error.message).toContain("sign in to that account again"); + }); + + test("never puts the raw pool id or the account email in the refusal", async () => { + for (const accountSelector of [undefined, "team"]) { + const response = poolCredentialRefreshIncompleteResponse({ + authCtx: poolAuthCtx(), + config: configWithAccount(), + accountSelector, + }); + const error = await errorPayload(response); + expect(error.message).not.toContain(ACCOUNT_ID); + expect(error.message).not.toContain(ACCOUNT_EMAIL); + } + }); + + test("says nothing specific rather than naming something opaque", async () => { + const response = poolCredentialRefreshIncompleteResponse({ + authCtx: poolAuthCtx(), + config: { codexAccounts: [] }, + }); + const error = await errorPayload(response); + // An unresolvable account still gets the actionable half of the sentence. + expect(error.message).toContain("the selected Codex pool account"); + expect(error.message).toContain("sign in to that account again"); + expect(error.message).not.toContain(ACCOUNT_ID); + }); + + test("stays the retryable 503 contract it replaced", async () => { + const response = poolCredentialRefreshIncompleteResponse({ + authCtx: poolAuthCtx(), + config: configWithAccount(), + accountSelector: "team", + }); + expect(response.status).toBe(503); + expect(response.headers.get("Retry-After")).toBe("1"); + // Naming the account must not reclassify the refusal. Codex applies retry-after backoff only + // for server_is_overloaded, so a message-driven remap here would silently drop the retry. + const error = await errorPayload(response); + expect(error.type).toBe("server_error"); + expect(error.code).toBe("server_is_overloaded"); + expect(error.message).toContain("retry this request"); + }); + + test("keeps the word that would reclassify it out of the body", async () => { + // classifyError runs isAuthenticationMessage before it reaches the status === 503 arm, and + // that check is status-blind on the bare substring "authentication" — which "reauthentication" + // contains. Saying the friendlier word here turns a retryable overload into + // authentication_error / invalid_api_key and drops Codex's retry-after backoff, so this + // guards the wording rather than only the resulting code. + const response = poolCredentialRefreshIncompleteResponse({ + authCtx: poolAuthCtx(), + config: configWithAccount(), + accountSelector: "team", + }); + expect(response.status).toBe(503); + const error = await errorPayload(response); + expect(error.message.toLowerCase()).not.toContain("authentication"); + expect(error.message.toLowerCase()).not.toContain("unauthorized"); + }); +}); From 635a3d1238b2beb43704f834dc0249f365513643 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 10:14:16 +0900 Subject: [PATCH 92/96] client: report local Codex readiness instead of bare connected state A connected client reported healthy while the installed Codex CLI exited before its first request, because the catalog on disk used a reasoning level that CLI does not know. Connection state proved the hub and the credential; it never proved the selected local runtime could consume what was written. The write-time gate cannot answer this. It runs once, on bytes about to be written, so it says nothing about a catalog that predates it, one written while the runtime ladder was unverified, or a runtime swapped afterwards. inspectClientCatalogReadiness assesses the installed file, and ocx connect status, ocx status --json and ocx connect now report the verdict. Only "ready" means ready; an unobservable runtime stays "unverified" rather than becoming an incompatibility, which is the line the write-time gate already refuses to cross. The probe runs only for a connected client, so no other install pays a Codex process for it. Closes #4207 --- scripts/test-layout/layout.json | 1 + src/cli/connect.ts | 119 +++++++++-- src/cli/status.ts | 10 + src/client/catalog-compatibility.ts | 78 +++++++ tests/cli/cli-connect-readiness.test.ts | 190 ++++++++++++++++++ tests/cli/cli-status-json.test.ts | 5 + .../client-catalog-compatibility.test.ts | 61 ++++++ tests/fixtures/test-layout-expected.json | 1 + 8 files changed, 453 insertions(+), 12 deletions(-) create mode 100644 tests/cli/cli-connect-readiness.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0c07fc9a7c..f83a0a8f49 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -335,6 +335,7 @@ "cli-codex-log-guard.test.ts": "cli", "cli-config-command.test.ts": "cli", "cli-config-show-client.test.ts": "cli", + "cli-connect-readiness.test.ts": "cli", "cli-dispatch.test.ts": "cli", "cli-dto-fidelity.test.ts": "cli", "cli-export-command.test.ts": "cli", diff --git a/src/cli/connect.ts b/src/cli/connect.ts index 3506a2fa95..c533ff358f 100644 --- a/src/cli/connect.ts +++ b/src/cli/connect.ts @@ -1,5 +1,11 @@ -import { existsSync, lstatSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; import { DEFAULT_CATALOG_PATH } from "../codex/paths"; +import { + inspectClientCatalogReadiness, + type CatalogCompatibilityDeps, + type ClientCatalogFileState, + type ClientCatalogReadiness, +} from "../client/catalog-compatibility"; import { disconnectClient, revokeConnectedClientKey, @@ -26,6 +32,12 @@ import { export interface ClientCommandDeps extends RuntimeApiDeps { lifecycleLockDeps?: ClientLifecycleLockDeps; + catalogProbeDeps?: ClientCatalogProbeDeps; +} + +export interface ClientCatalogProbeDeps extends CatalogCompatibilityDeps { + /** Injected in tests; defaults to reading the materialized client catalog off disk. */ + readCatalogBody?: () => string | null; } export const CONNECT_USAGE = `Usage: @@ -56,21 +68,71 @@ export type ClientConnectionStatus = { catalog: "present" | "missing" | "unsafe"; token: "owned" | "missing" | "changed" | "unsafe"; rotation: "clean" | "orphan-cleaned" | "recovery-required" | "unsafe"; + /** + * Whether the selected local Codex CLI can actually launch against this connection (#4207). + * + * `state: "connected"` proves the hub answered and the credential works. It never proved the + * local runtime could consume what was downloaded, which is how a connection kept reporting + * itself healthy while `codex exec` exited on `unknown variant` before its first request. + * + * Reported only while connected, and reported as its own field rather than as a fourth + * `catalog` value: the status JSON is documented additive-only, so widening an existing + * field's value domain would change what `catalog: "present"` means for every consumer that + * already reads it. + */ + readiness?: ClientCatalogReadiness["kind"]; + /** Present whenever readiness is not `ready`; names the fault and the way out. */ + readinessReason?: string; }; -export function collectClientConnectionStatus(now = Date.now(), lifecycleLockDeps?: ClientLifecycleLockDeps): ClientConnectionStatus { +function readInstalledCatalogBody(): string | null { + try { + return readFileSync(DEFAULT_CATALOG_PATH, "utf8"); + } catch { + return null; + } +} + +/** The stat half of the catalog verdict, shared by the status collector and `ocx connect`. */ +function installedCatalogFileState(): ClientCatalogFileState { + if (!existsSync(DEFAULT_CATALOG_PATH)) return "missing"; + try { + const stat = lstatSync(DEFAULT_CATALOG_PATH); + return !stat.isSymbolicLink() && stat.isFile() ? "present" : "unsafe"; + } catch { + return "unsafe"; + } +} + +/** + * Observing the runtime spawns `codex debug models`, so this runs only for a connected client — + * the one configuration that installs hub bytes the local clamp never touched. A standalone or + * hub install pays nothing for it. + * + * Never throws. A status command that dies because a Codex probe failed would replace one wrong + * answer with a worse one. + */ +function inspectInstalledCatalogReadiness( + file: ClientCatalogFileState, + deps: ClientCatalogProbeDeps, +): ClientCatalogReadiness { + try { + const read = deps.readCatalogBody ?? readInstalledCatalogBody; + return inspectClientCatalogReadiness(file, file === "present" ? read() : null, deps); + } catch { + return { kind: "unverified", reason: "the selected local Codex runtime could not be inspected" }; + } +} + +export function collectClientConnectionStatus( + now = Date.now(), + lifecycleLockDeps?: ClientLifecycleLockDeps, + catalogProbeDeps: ClientCatalogProbeDeps = {}, +): ClientConnectionStatus { const state = readClientConnectionState(); const tokenState = readServiceApiTokenState(); const rotation = inspectClientRotationRecoveryGate(state, lifecycleLockDeps).kind; - let catalog: ClientConnectionStatus["catalog"] = "missing"; - if (existsSync(DEFAULT_CATALOG_PATH)) { - try { - const stat = lstatSync(DEFAULT_CATALOG_PATH); - catalog = !stat.isSymbolicLink() && stat.isFile() ? "present" : "unsafe"; - } catch { - catalog = "unsafe"; - } - } + const catalog = installedCatalogFileState(); if (state.kind !== "connected") { return { state: state.kind, @@ -88,6 +150,7 @@ export function collectClientConnectionStatus(now = Date.now(), lifecycleLockDep : tokenState.kind === "unsafe" ? "unsafe" : tokenState.fingerprint === state.value.tokenFingerprint ? "owned" : "changed"; + const readiness = inspectInstalledCatalogReadiness(catalog, catalogProbeDeps); return { state: "connected", serverUrl: state.value.serverUrl, @@ -102,6 +165,8 @@ export function collectClientConnectionStatus(now = Date.now(), lifecycleLockDep catalog, token, rotation, + readiness: readiness.kind, + ...(readiness.kind === "ready" ? {} : { readinessReason: readiness.reason }), }; } @@ -113,12 +178,25 @@ function parseClients(raw: string | undefined): OcxConnectedClientId[] { return values as OcxConnectedClientId[]; } +/** Reads as a verdict, not a field dump: "ready" is the only word that means the client works. */ +function readinessLine(status: ClientConnectionStatus): string { + const label = status.readiness === "ready" + ? "ready" + : status.readiness === "incompatible" + ? "not ready" + : "unverified"; + return `Local Codex CLI: ${label}${status.readinessReason ? ` (${status.readinessReason})` : ""}`; +} + function statusLines(status: ClientConnectionStatus): string[] { if (status.state !== "connected") { return [`Connection: ${status.state}${status.reason ? ` (${status.reason})` : ""}`]; } return [ "Connection: connected", + // Second line on purpose. The whole of #4207 is that a reader stopped at "connected" and + // believed the client was usable, so the local verdict has to arrive before the hub detail. + readinessLine(status), `Hub: ${status.serverUrl}`, `Management: ${status.managementUrl} (${status.managementTransport})`, `Protocol: ${status.protocolVersion}`, @@ -185,6 +263,23 @@ async function runConnect(argv: string[], deps: ClientCommandDeps): Promise { @@ -204,7 +299,7 @@ export async function handleConnectCommand(argv: string[], deps: ClientCommandDe const args = argv.slice(1); const wantsJson = takeFlag(args, "--json"); rejectArgs(args, CONNECT_USAGE, { redactValues: true }); - const status = collectClientConnectionStatus(Date.now(), deps.lifecycleLockDeps); + const status = collectClientConnectionStatus(Date.now(), deps.lifecycleLockDeps, deps.catalogProbeDeps ?? {}); printData(status, wantsJson, statusLines(status)); return; } diff --git a/src/cli/status.ts b/src/cli/status.ts index 1b5d9bd508..cc1f4506bf 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -149,6 +149,14 @@ export type CliStatusJson = { catalog?: "present" | "missing" | "unsafe"; catalogAgeSeconds?: number; credentialFile: "owned" | "missing" | "changed" | "unsafe"; + /** + * #4207: whether the selected local Codex CLI can consume the catalog this client + * installed. Absent unless a client connection exists. `connected` alone proved only the + * hub and the credential, and a reader who stopped there saw a healthy connection while + * `codex exec` was exiting before its first request. + */ + readiness?: "ready" | "unverified" | "incompatible"; + readinessReason?: string; }; service: { summary: string }; codexShim: { summary: string }; @@ -678,6 +686,8 @@ export async function collectStatus(): Promise { catalog: clientConnection.catalog, ...(clientConnection.catalogAgeSeconds !== undefined ? { catalogAgeSeconds: clientConnection.catalogAgeSeconds } : {}), credentialFile: clientConnection.token, + ...(clientConnection.readiness ? { readiness: clientConnection.readiness } : {}), + ...(clientConnection.readinessReason ? { readinessReason: clientConnection.readinessReason } : {}), }, service: { summary: serviceSummary }, codexShim: { summary: codexShimSummary }, diff --git a/src/client/catalog-compatibility.ts b/src/client/catalog-compatibility.ts index 8b4b9011cf..d101a13ac3 100644 --- a/src/client/catalog-compatibility.ts +++ b/src/client/catalog-compatibility.ts @@ -34,6 +34,32 @@ export interface CatalogCompatibilityDeps { supportedEfforts?: () => ReadonlySet | null; } +/** State of the materialized client catalog file, as `ocx connect status` already reports it. */ +export type ClientCatalogFileState = "present" | "missing" | "unsafe"; + +/** + * Whether the selected local Codex runtime can consume the catalog that is *already on disk* — + * a different question from the write-time gate, and the one #4207 was actually asking. + * + * The gate runs once, on bytes about to be written. It cannot speak for a file that predates it, + * for a file written while the ladder was {@link ClientCatalogCompatibility} `unverified`, or for + * a runtime that was swapped after the write. Those are exactly the states that kept reporting + * `connected` while `codex exec` died on `unknown variant \`max\``. + * + * Only `ready` means ready. `unverified` is not `incompatible`: a client machine may legitimately + * have no observable Codex CLI, and calling that an incompatibility would condemn a working + * install on absent evidence — the same mistake the write-time gate refuses to make. + */ +export type ClientCatalogReadiness = + | { kind: "ready" } + | { kind: "unverified"; reason: string } + | { + kind: "incompatible"; + reason: string; + unsupportedEfforts: readonly string[]; + affectedModels: readonly string[]; + }; + function parseModels(body: string): RawEntry[] | null { try { const parsed = JSON.parse(body) as { models?: unknown }; @@ -105,3 +131,55 @@ export function assertClientCatalogCompatible(body: string, deps: CatalogCompati if (assessment.kind !== "incompatible") return; throw new ClientCatalogIncompatibleError(assessment.unsupportedEfforts, assessment.affectedModels); } + +/** + * Why an already-installed incompatible catalog does not reuse the refusal message above: + * nothing was kept back. The unusable bytes are the ones Codex will read on its next launch, + * so "the previous catalog was kept" would be false. The two remedies are the same, because + * the operator's options do not depend on when the file arrived. + */ +function installedCatalogRejectionReason( + unsupportedEfforts: readonly string[], + affectedModels: readonly string[], +): string { + const models = affectedModels.length > 3 + ? `${affectedModels.slice(0, 3).join(", ")} and ${affectedModels.length - 3} more` + : affectedModels.join(", "); + return `the installed catalog uses reasoning ${unsupportedEfforts.length === 1 ? "level" : "levels"} ` + + `${unsupportedEfforts.join(", ")}, which the selected local Codex CLI rejects` + + `${models ? ` (${models})` : ""}. Codex exits before its first request until the CLI is ` + + "upgraded to a version that supports those levels, or CODEX_CLI_PATH points at one that " + + "does and `ocx sync` is run. `ocx doctor` reports which runtime is selected."; +} + +/** + * Assess the catalog this machine has already installed, so a surface can stop calling a + * connection ready when the local runtime cannot launch against it. + * + * `body` is the file's bytes, or `null` when they could not be read; `file` is the state the + * caller already established by stat. Neither non-present file state is an incompatibility: an + * absent or non-regular catalog is a different fault, and this function only ever claims an + * incompatibility it has proven. + */ +export function inspectClientCatalogReadiness( + file: ClientCatalogFileState, + body: string | null, + deps: CatalogCompatibilityDeps = {}, +): ClientCatalogReadiness { + if (file === "missing") { + return { kind: "unverified", reason: "no catalog is installed for the local Codex CLI to read" }; + } + if (file === "unsafe") { + return { kind: "unverified", reason: "the catalog path is not a regular file, so its bytes were not read" }; + } + if (body === null) return { kind: "unverified", reason: "the installed catalog could not be read" }; + const assessment = assessClientCatalogCompatibility(body, deps); + if (assessment.kind === "compatible") return { kind: "ready" }; + if (assessment.kind === "unverified") return assessment; + return { + kind: "incompatible", + reason: installedCatalogRejectionReason(assessment.unsupportedEfforts, assessment.affectedModels), + unsupportedEfforts: assessment.unsupportedEfforts, + affectedModels: assessment.affectedModels, + }; +} diff --git a/tests/cli/cli-connect-readiness.test.ts b/tests/cli/cli-connect-readiness.test.ts new file mode 100644 index 0000000000..8ed26b006f --- /dev/null +++ b/tests/cli/cli-connect-readiness.test.ts @@ -0,0 +1,190 @@ +/** + * #4207: "ocx connect status" answered a different question from the one the operator asked. + * It proved the hub answered and the credential worked, then printed "connected" over a catalog + * the installed Codex CLI could not parse, so "codex exec" died on an unknown-variant error for + * the reasoning level "max" before its first request. + * + * The write-time gate added in the first round cannot close this. It runs once, on bytes about + * to be written, so it says nothing about a catalog that predates it, one written while the + * runtime ladder was unverified, or a runtime swapped after the write. These tests drive the + * status surface itself, in a real client home, with the ladder injected so no Codex process is + * spawned to observe it. + */ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoRoot } from "../helpers/repo-root"; + +/** Codex CLI 0.135.0's ladder, verbatim from the parse error in the issue. */ +const OLD_CLI = ["none", "minimal", "low", "medium", "high", "xhigh"]; +const NEW_CLI = [...OLD_CLI, "max", "ultra"]; + +/** A hub catalog whose top rung the reporter's CLI rejects. */ +const CATALOG_WITH_MAX = JSON.stringify({ + models: [{ slug: "gpt-5.6-sol", supported_reasoning_levels: [{ effort: "high" }, { effort: "max" }] }], +}); + +type ProbeResult = { + lines: string[]; + status: { + state: string; + catalog: string; + readiness?: string; + readinessReason?: string; + }; +}; + +/** + * Runs the real "ocx connect status" surface against a throwaway client home. The ladder is + * injected rather than observed: a spawned "codex debug models" would make the assertion depend + * on whichever Codex CLI the test machine happens to have. + */ +function runStatusProbe(options: { + connected: boolean; + ladder: string[] | null | "forbidden"; + catalog?: string; +}): ProbeResult { + const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-readiness-home-")); + const codexHome = mkdtempSync(join(tmpdir(), "ocx-readiness-codex-")); + try { + const token = `ocx_data_${"f".repeat(40)}`; + const fingerprint = createHash("sha256").update(token).digest("hex"); + const catalog = options.catalog ?? CATALOG_WITH_MAX; + writeFileSync(join(opencodexHome, "config.json"), JSON.stringify(options.connected + ? { + port: 10100, + providers: {}, + defaultProvider: "openai", + runtimeRole: "client", + client: { + serverUrl: "https://hub.example.test", + managementUrl: "https://hub.example.test", + managementTransport: "direct", + selectedClients: ["codex"], + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + apiKeyId: "client-key-1", + tokenFingerprint: fingerprint, + protocolVersion: 1, + connectedAt: "2026-08-28T00:00:00.000Z", + catalogFingerprint: createHash("sha256").update(catalog).digest("base64url"), + catalogSyncedAt: "2026-08-28T00:00:00.000Z", + }, + } + : { port: 10100, providers: {}, defaultProvider: "openai" }), "utf8"); + writeFileSync(join(opencodexHome, "service-api-token"), `${token}\n`, { mode: 0o600 }); + writeFileSync(join(codexHome, "opencodex-catalog.json"), catalog, "utf8"); + + const script = ` + const { collectClientConnectionStatus, handleConnectCommand } = require("./src/cli/connect"); + const ladder = JSON.parse(process.env.FIXTURE_LADDER); + const supportedEfforts = ladder === "forbidden" + ? () => { throw new Error("the runtime was probed on a path that must not probe it"); } + : ladder === null ? () => null : () => new Set(ladder); + const lifecycleLockDeps = { lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" }; + const captured = []; + const real = console.log; + (async () => { + console.log = (...parts) => captured.push(parts.join(" ")); + try { + await handleConnectCommand(["status"], { lifecycleLockDeps, catalogProbeDeps: { supportedEfforts } }); + } finally { + console.log = real; + } + const status = collectClientConnectionStatus( + Date.parse("2026-08-28T00:00:10.000Z"), + lifecycleLockDeps, + { supportedEfforts }, + ); + console.log(JSON.stringify({ lines: captured, status })); + })(); + `; + + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot(), + encoding: "utf8", + env: { + ...process.env, + OPENCODEX_HOME: opencodexHome, + CODEX_HOME: codexHome, + // Matches the existing client fixtures: no probe may reach the operator's real Claude + // Desktop configuration, even transitively. + OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop"), + FIXTURE_LADDER: JSON.stringify(options.ladder), + }, + }); + expect(result.status).toBe(0); + return JSON.parse(result.stdout.trim().split("\n").at(-1)!) as ProbeResult; + } finally { + removeTreeWithRetry(opencodexHome); + removeTreeWithRetry(codexHome); + } +} + +describe("#4207 connected-client readiness", () => { + test("an installed catalog the local CLI rejects is not reported as ready", () => { + const probe = runStatusProbe({ connected: true, ladder: OLD_CLI }); + + expect(probe.status.state).toBe("connected"); + // The connection is real and the file is there. Both were true in the report, and both are + // why "connected" plus "present" read as success. + expect(probe.status.catalog).toBe("present"); + expect(probe.status.readiness).toBe("incompatible"); + expect(probe.status.readinessReason).toContain("max"); + // "Incompatible" alone is not actionable; the operator needs the way out. + expect(probe.status.readinessReason).toContain("CODEX_CLI_PATH"); + // The refusal message belongs to the write-time gate, which kept a previous file. Nothing + // was kept here: the unusable bytes are the ones Codex will read next. + expect(probe.status.readinessReason).not.toContain("The previous catalog was kept"); + }); + + test("the human status states the local verdict before the hub detail", () => { + const probe = runStatusProbe({ connected: true, ladder: OLD_CLI }); + + expect(probe.lines[0]).toBe("Connection: connected"); + // Second line, not buried under Hub/Protocol/Catalog: a reader who stops at "connected" is + // exactly the failure this issue describes. + expect(probe.lines[1]).toContain("Local Codex CLI: not ready"); + expect(probe.lines.find(line => line.startsWith("Hub:"))).toBeDefined(); + }); + + test("a catalog the local CLI accepts is ready, with nothing to explain", () => { + const probe = runStatusProbe({ connected: true, ladder: NEW_CLI }); + + expect(probe.status.readiness).toBe("ready"); + expect(probe.status.readinessReason).toBeUndefined(); + expect(probe.lines[1]).toBe("Local Codex CLI: ready"); + }); + + test("an unobservable runtime is unverified, never incompatible", () => { + // A client machine may legitimately have no Codex CLI to observe. Calling that an + // incompatibility would condemn a working install on absent evidence, which is the same + // line the write-time gate refuses to cross. + const probe = runStatusProbe({ connected: true, ladder: null }); + + expect(probe.status.readiness).toBe("unverified"); + expect(probe.status.readinessReason).toContain("did not report the reasoning levels"); + }); + + test("an unreadable catalog is unverified rather than blamed on the runtime", () => { + const probe = runStatusProbe({ connected: true, ladder: OLD_CLI, catalog: "not json" }); + + expect(probe.status.readiness).toBe("unverified"); + expect(probe.status.readinessReason).toContain("could not be read"); + }); + + test("a machine with no client connection never probes the runtime", () => { + // Observing the ladder spawns a Codex process. A standalone or hub install has no client + // catalog question to answer and must not pay for one on every status call, so the injected + // probe throws if it is reached. + const probe = runStatusProbe({ connected: false, ladder: "forbidden" }); + + expect(probe.status.state).toBe("disconnected"); + expect(probe.status.readiness).toBeUndefined(); + expect(probe.status.readinessReason).toBeUndefined(); + expect(probe.lines[0]).toBe("Connection: disconnected"); + }); +}); diff --git a/tests/cli/cli-status-json.test.ts b/tests/cli/cli-status-json.test.ts index c4fe01752a..aeef546421 100644 --- a/tests/cli/cli-status-json.test.ts +++ b/tests/cli/cli-status-json.test.ts @@ -363,6 +363,11 @@ describe("CLI status JSON", () => { state: "disconnected", credentialFile: "missing", }); + // #4207 gave a connected client a local-runtime readiness verdict. Observing that runtime + // spawns a Codex process, so a machine with no client connection must not carry the field + // at all; its absence is what keeps every ordinary `ocx status` off that probe. + expect(parsed.connection).not.toHaveProperty("readiness"); + expect(parsed.connection).not.toHaveProperty("readinessReason"); const serialized = JSON.stringify(parsed).toLowerCase(); for (const forbidden of ["apikey", "sk-test-secret", "token", "refreshtoken", "authorization", "email"]) { diff --git a/tests/clients/client-catalog-compatibility.test.ts b/tests/clients/client-catalog-compatibility.test.ts index f01f43d071..454ff14c67 100644 --- a/tests/clients/client-catalog-compatibility.test.ts +++ b/tests/clients/client-catalog-compatibility.test.ts @@ -15,6 +15,7 @@ import { assertClientCatalogCompatible, assessClientCatalogCompatibility, ClientCatalogIncompatibleError, + inspectClientCatalogReadiness, } from "../../src/client/catalog-compatibility"; import { repoPath } from "../helpers/repo-root"; @@ -137,3 +138,63 @@ describe("#4207 client catalog gate", () => { expect(source.match(/assertClientCatalogCompatible\(/g)).toHaveLength(2); }); }); + +describe("#4207 installed catalog readiness", () => { + test("a catalog the local runtime accepts is ready", () => { + expect(inspectClientCatalogReadiness("present", catalogBody(["low", "max"]), { supportedEfforts: () => NEW_CLI })) + .toEqual({ kind: "ready" }); + }); + + test("a catalog already on disk that the runtime rejects is an established incompatibility", () => { + // The write-time gate never saw this file: it may predate the gate, or have been written + // while the ladder was unverified. Readiness is a question about the bytes that are there. + const readiness = inspectClientCatalogReadiness( + "present", + catalogBody(["low", "medium", "high", "xhigh", "max"]), + { supportedEfforts: () => OLD_CLI }, + ); + + expect(readiness.kind).toBe("incompatible"); + if (readiness.kind !== "incompatible") throw new Error("unreachable"); + expect(readiness.unsupportedEfforts).toEqual(["max"]); + expect(readiness.affectedModels).toEqual(["gpt-5.6-sol"]); + expect(readiness.reason).toContain("max"); + expect(readiness.reason).toContain("CODEX_CLI_PATH"); + // The gate's wording promises the previous catalog survived. Nothing survived here, so + // reusing that message would tell the operator the opposite of what happened. + expect(readiness.reason).not.toContain("The previous catalog was kept"); + }); + + test("an unobservable runtime ladder is unverified, not incompatible", () => { + const readiness = inspectClientCatalogReadiness("present", catalogBody(["max"]), { supportedEfforts: () => null }); + + expect(readiness.kind).toBe("unverified"); + }); + + test("an unreadable body is unverified", () => { + expect(inspectClientCatalogReadiness("present", "not json", { supportedEfforts: () => OLD_CLI }).kind) + .toBe("unverified"); + }); + + test("bytes that could not be read at all are unverified", () => { + expect(inspectClientCatalogReadiness("present", null, { supportedEfforts: () => OLD_CLI }).kind) + .toBe("unverified"); + }); + + test("an absent or non-regular catalog is a different fault, never an incompatibility", () => { + // Claiming an incompatibility here would name a cause nothing established -- the same + // mistake #4169 was filed for. + for (const file of ["missing", "unsafe"] as const) { + const readiness = inspectClientCatalogReadiness(file, null, { supportedEfforts: () => OLD_CLI }); + expect(readiness.kind).toBe("unverified"); + } + }); + + test("the runtime is not observed for a file state that was never read", () => { + // Only 'present' has bytes worth an opinion. Probing the local Codex CLI for a missing file + // would spend a process on a question its answer cannot change. + const probe = () => { throw new Error("the runtime was observed for a catalog that was not read"); }; + expect(inspectClientCatalogReadiness("missing", null, { supportedEfforts: probe }).kind).toBe("unverified"); + expect(inspectClientCatalogReadiness("unsafe", null, { supportedEfforts: probe }).kind).toBe("unverified"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ed625c193b..a6fef10432 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -170,6 +170,7 @@ "cli-codex-log-guard.test.ts": "cli", "cli-config-command.test.ts": "cli", "cli-config-show-client.test.ts": "cli", + "cli-connect-readiness.test.ts": "cli", "cli-dispatch.test.ts": "cli", "cli-dto-fidelity.test.ts": "cli", "cli-export-command.test.ts": "cli", From efefde42c24c4a58c305a1ed489d36d94efccc97 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 10:34:10 +0900 Subject: [PATCH 93/96] client: fold adversarial review into the readiness report Four things an independent read of the diff found. A diagnostics command should not start writing runtime selection state: the default observer now resolves the runtime without persisting and hands that command to the catalog read, which also avoids a second probe on a path that had already resolved it. The ocx connect decision moves into a pure connectCompletionReport, so the fail-closed exit is exercised without a hub. It prints the verdict first and withholds "Connected to" when it fails, because a caller grepping that phrase would otherwise read a broken catalog as success. A Claude-only connection is told about an old Codex CLI but not failed by it, since nothing in that connection launches Codex. connectClient now receives the same observer, so the write-time gate and the readiness check cannot disagree about the ladder inside one command. An installed catalog that is not JSON gets its own sentence instead of the gate's "downloaded" wording, and the subprocess fixture takes the same spawn budget the neighbouring client fixtures use. --- src/cli/connect.ts | 95 ++++++++++++++++++++----- src/client/catalog-compatibility.ts | 9 ++- tests/cli/cli-connect-readiness.test.ts | 63 +++++++++++++++- 3 files changed, 149 insertions(+), 18 deletions(-) diff --git a/src/cli/connect.ts b/src/cli/connect.ts index c533ff358f..22930011fc 100644 --- a/src/cli/connect.ts +++ b/src/cli/connect.ts @@ -1,5 +1,7 @@ import { existsSync, lstatSync, readFileSync } from "node:fs"; import { DEFAULT_CATALOG_PATH } from "../codex/paths"; +import { codexSupportedReasoningEfforts } from "../codex/catalog/effort"; +import { resolveCodexRuntime } from "../codex/runtime"; import { inspectClientCatalogReadiness, type CatalogCompatibilityDeps, @@ -93,6 +95,20 @@ function readInstalledCatalogBody(): string | null { } } +/** + * The ladder the selected Codex CLI accepts, observed without persisting anything. + * + * `codexSupportedReasoningEfforts()` with no deps reaches `resolveAndPersistCodexRuntime`, which + * writes codex-runtime.json. `ocx status` deliberately resolves without persisting, and a + * read-only diagnostics command should not start writing runtime selection state because a + * readiness check was added to it. Handing the already-resolved command in as the only candidate + * skips that path and reuses the resolve cache `ocx status` has usually already filled. + */ +function observeLocalCodexEffortLadder(): ReadonlySet | null { + const command = resolveCodexRuntime().runtime.command; + return codexSupportedReasoningEfforts({ commandCandidates: () => [command] }); +} + /** The stat half of the catalog verdict, shared by the status collector and `ocx connect`. */ function installedCatalogFileState(): ClientCatalogFileState { if (!existsSync(DEFAULT_CATALOG_PATH)) return "missing"; @@ -118,7 +134,9 @@ function inspectInstalledCatalogReadiness( ): ClientCatalogReadiness { try { const read = deps.readCatalogBody ?? readInstalledCatalogBody; - return inspectClientCatalogReadiness(file, file === "present" ? read() : null, deps); + return inspectClientCatalogReadiness(file, file === "present" ? read() : null, { + supportedEfforts: deps.supportedEfforts ?? observeLocalCodexEffortLadder, + }); } catch { return { kind: "unverified", reason: "the selected local Codex runtime could not be inspected" }; } @@ -188,6 +206,55 @@ function readinessLine(status: ClientConnectionStatus): string { return `Local Codex CLI: ${label}${status.readinessReason ? ` (${status.readinessReason})` : ""}`; } +export type ConnectCompletionReport = { + readonly lines: readonly string[]; + /** Non-null when `ocx connect` must exit non-zero rather than report success. */ + readonly failure: string | null; +}; + +/** + * What `ocx connect` says once the hub and the credential are settled, and whether the command + * still fails (#4207). + * + * Pure so the fail-closed decision can be exercised without a hub. Two rules it encodes: + * + * On a proven incompatibility the verdict is printed FIRST and the `Connected to …` line is + * withheld, because a caller grepping for that phrase would otherwise read a catalog the local + * CLI cannot parse as a success. The connection really was saved, so the replacement line says + * where to see it. + * + * And that failure applies only when this connection selected Codex. A Claude-only connection + * never launches the Codex CLI, so an old binary somewhere on PATH is not a reason to fail the + * operator's Claude Desktop setup — it is still worth saying, which is why the line survives + * without the exit code. + */ +export function connectCompletionReport( + connection: { serverUrl: string; apiKeyId: string }, + selectedClients: readonly OcxConnectedClientId[], + readiness: ClientCatalogReadiness, +): ConnectCompletionReport { + const connected = `Connected to ${connection.serverUrl} as key ${connection.apiKeyId}.`; + if (readiness.kind === "ready") { + return { lines: [connected, "Local Codex CLI: ready (it accepts every reasoning level in the installed catalog)."], failure: null }; + } + if (readiness.kind === "unverified") { + // Not a failure. A client with no observable Codex CLI is a working configuration, and the + // write-time gate deliberately lets it through; saying so is the honest middle report. + return { lines: [connected, `Local Codex CLI: unverified (${readiness.reason}).`], failure: null }; + } + const verdict = `Local Codex CLI: not ready (${readiness.reason})`; + if (!selectedClients.includes("codex")) { + return { + lines: [connected, `${verdict} This connection selected ${selectedClients.join(", ")}, so nothing here launches Codex.`], + failure: null, + }; + } + return { + lines: [verdict, `The connection to ${connection.serverUrl} as key ${connection.apiKeyId} was saved; run 'ocx connect status' to see it.`], + failure: `client_not_ready: ${readiness.reason}`, + }; +} + function statusLines(status: ClientConnectionStatus): string[] { if (status.state !== "connected") { return [`Connection: ${status.state}${status.reason ? ` (${status.reason})` : ""}`]; @@ -261,25 +328,21 @@ async function runConnect(argv: string[], deps: ClientCommandDeps): Promise { diff --git a/src/client/catalog-compatibility.ts b/src/client/catalog-compatibility.ts index d101a13ac3..9114f935a9 100644 --- a/src/client/catalog-compatibility.ts +++ b/src/client/catalog-compatibility.ts @@ -175,7 +175,14 @@ export function inspectClientCatalogReadiness( if (body === null) return { kind: "unverified", reason: "the installed catalog could not be read" }; const assessment = assessClientCatalogCompatibility(body, deps); if (assessment.kind === "compatible") return { kind: "ready" }; - if (assessment.kind === "unverified") return assessment; + if (assessment.kind === "unverified") { + // assessClientCatalogCompatibility words its parse failure for bytes that have just been + // downloaded. These bytes are already installed, so blaming a download would send the + // operator to the wrong place; name the file that is actually unusable. + return parseModels(body) === null + ? { kind: "unverified", reason: "the installed catalog is not readable JSON, so the local Codex CLI cannot parse it either" } + : assessment; + } return { kind: "incompatible", reason: installedCatalogRejectionReason(assessment.unsupportedEfforts, assessment.affectedModels), diff --git a/tests/cli/cli-connect-readiness.test.ts b/tests/cli/cli-connect-readiness.test.ts index 8ed26b006f..548cdb716d 100644 --- a/tests/cli/cli-connect-readiness.test.ts +++ b/tests/cli/cli-connect-readiness.test.ts @@ -18,6 +18,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoRoot } from "../helpers/repo-root"; +import { INTERNAL_DEADLINE_MS } from "../helpers/test-budget"; +import { connectCompletionReport } from "../../src/cli/connect"; +import type { ClientCatalogReadiness } from "../../src/client/catalog-compatibility"; /** Codex CLI 0.135.0's ladder, verbatim from the parse error in the issue. */ const OLD_CLI = ["none", "minimal", "low", "medium", "high", "xhigh"]; @@ -106,6 +109,11 @@ function runStatusProbe(options: { const result = spawnSync(process.execPath, ["--eval", script], { cwd: repoRoot(), encoding: "utf8", + // Bun's test timeout cannot interrupt spawnSync, so a child that wedged on a lock or an + // unexpected probe would hang the worker rather than fail. Same budget the existing + // client fixtures use. + timeout: INTERNAL_DEADLINE_MS, + killSignal: "SIGKILL", env: { ...process.env, OPENCODEX_HOME: opencodexHome, @@ -173,7 +181,10 @@ describe("#4207 connected-client readiness", () => { const probe = runStatusProbe({ connected: true, ladder: OLD_CLI, catalog: "not json" }); expect(probe.status.readiness).toBe("unverified"); - expect(probe.status.readinessReason).toContain("could not be read"); + // The write-time gate says "the downloaded catalog could not be read", which points the + // operator at a download that is not the problem. These bytes are already installed. + expect(probe.status.readinessReason) + .toBe("the installed catalog is not readable JSON, so the local Codex CLI cannot parse it either"); }); test("a machine with no client connection never probes the runtime", () => { @@ -188,3 +199,53 @@ describe("#4207 connected-client readiness", () => { expect(probe.lines[0]).toBe("Connection: disconnected"); }); }); + +describe("#4207 what ocx connect reports when the local CLI cannot use the catalog", () => { + const incompatible: ClientCatalogReadiness = { + kind: "incompatible", + reason: "the installed catalog uses reasoning level max, which the selected local Codex CLI rejects", + unsupportedEfforts: ["max"], + affectedModels: ["gpt-5.6-sol"], + }; + const connection = { serverUrl: "https://hub.example.test", apiKeyId: "client-key-1" }; + + test("a ready client reports the connection and the local verdict", () => { + const report = connectCompletionReport(connection, ["codex"], { kind: "ready" }); + + expect(report.failure).toBeNull(); + expect(report.lines[0]).toContain("Connected to https://hub.example.test"); + expect(report.lines[1]).toContain("ready"); + }); + + test("an unverifiable runtime is reported but does not fail the command", () => { + // Refusing here would block a working configuration on absent evidence, which is the line + // the write-time gate already refuses to cross. + const report = connectCompletionReport(connection, ["codex"], { kind: "unverified", reason: "no Codex CLI was observed" }); + + expect(report.failure).toBeNull(); + expect(report.lines.join(" ")).toContain("unverified"); + }); + + test("a proven incompatibility fails the command and withholds the success line", () => { + const report = connectCompletionReport(connection, ["codex"], incompatible); + + expect(report.failure).toBe(`client_not_ready: ${incompatible.reason}`); + // A caller grepping for "Connected to" must not read a catalog the local CLI cannot parse + // as success, so the verdict leads and that phrase is withheld. + expect(report.lines[0]).toContain("not ready"); + expect(report.lines.join(" ")).not.toContain("Connected to"); + // The connection really was saved. Saying so is what keeps the failure from reading as a + // rollback that never happened. + expect(report.lines.join(" ")).toContain("was saved"); + }); + + test("a Claude-only connection is told, but not failed, by an old Codex CLI", () => { + // Nothing in this connection launches Codex, so a stale binary elsewhere on PATH is not a + // reason to fail an operator's Claude Desktop setup. + const report = connectCompletionReport(connection, ["claude"], incompatible); + + expect(report.failure).toBeNull(); + expect(report.lines.join(" ")).toContain("nothing here launches Codex"); + expect(report.lines[0]).toContain("Connected to"); + }); +}); From 87f5b52033e9acb5a77938b10f5ec3f7a708b3f8 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 10:36:32 +0900 Subject: [PATCH 94/96] client: give the write-time gate the same observer in production The previous commit only forwarded catalogCompatibility when a test had injected it, so an ordinary ocx connect still let assertClientCatalogCompatible fall back to its own default -- which persists runtime selection state and runs a second probe. One command could then act on two separately observed ladders, and the comment claiming otherwise was false. Both checks now build the observer through one helper. --- src/cli/connect.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/cli/connect.ts b/src/cli/connect.ts index 22930011fc..8059cf1d74 100644 --- a/src/cli/connect.ts +++ b/src/cli/connect.ts @@ -109,6 +109,17 @@ function observeLocalCodexEffortLadder(): ReadonlySet | null { return codexSupportedReasoningEfforts({ commandCandidates: () => [command] }); } +/** + * One observer per command, for the write-time gate and the readiness check alike. Built even + * when nothing was injected, so production does not silently fall back to the default inside + * {@link assertClientCatalogCompatible} — that default persists runtime selection state and + * would run its own probe, which is how the two checks could disagree about the ladder within + * a single `ocx connect`. + */ +function catalogObserver(deps: ClientCatalogProbeDeps | undefined): CatalogCompatibilityDeps { + return { supportedEfforts: deps?.supportedEfforts ?? observeLocalCodexEffortLadder }; +} + /** The stat half of the catalog verdict, shared by the status collector and `ocx connect`. */ function installedCatalogFileState(): ClientCatalogFileState { if (!existsSync(DEFAULT_CATALOG_PATH)) return "missing"; @@ -134,9 +145,7 @@ function inspectInstalledCatalogReadiness( ): ClientCatalogReadiness { try { const read = deps.readCatalogBody ?? readInstalledCatalogBody; - return inspectClientCatalogReadiness(file, file === "present" ? read() : null, { - supportedEfforts: deps.supportedEfforts ?? observeLocalCodexEffortLadder, - }); + return inspectClientCatalogReadiness(file, file === "present" ? read() : null, catalogObserver(deps)); } catch { return { kind: "unverified", reason: "the selected local Codex runtime could not be inspected" }; } @@ -331,10 +340,10 @@ async function runConnect(argv: string[], deps: ClientCommandDeps): Promise Date: Fri, 11 Sep 2026 17:46:24 +0900 Subject: [PATCH 95/96] client: keep ocx config show out of the local Codex probe collectClientConnectionStatus observes the local ladder for a connected client, and observing it spawns codex debug models under a 45s budget. That is the point on ocx status and ocx connect status. config show is a different caller: it reads state, reason and token to answer whether the hub link is real, and it arrived on dev after this branch forked, so nothing here had declined the probe on its behalf. Declining it explicitly keeps a read-only config dump from turning into a runtime probe - the same reasoning the readiness check already applies when it refuses to persist runtime selection state. --- src/cli/config-command.ts | 11 ++++++++++- tests/cli/cli-status-hub-state.test.ts | 5 ++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index f0d3c1cbae..b06ef38d15 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -171,7 +171,16 @@ export async function handleConfigCommand(argv: string[]): Promise { // Imported here rather than at module scope: `./connect` pulls the whole client lifecycle // in, and `ocx config get/set` has no use for it. const { collectClientConnectionStatus } = await import("./connect"); - const note = remoteHubConfigNote(diagnostics.config, () => collectClientConnectionStatus()); + // The readiness probe is declined explicitly. `collectClientConnectionStatus` observes the + // local Codex ladder for a connected client, and observing it spawns `codex debug models` + // under a 45s budget. `ocx config show` reads only `state`, `reason` and `token` from the + // result, so paying for a subprocess here would buy nothing and would quietly turn a + // read-only config dump into a runtime probe. Returning no ladder resolves readiness to + // `unverified`, which is the honest answer for a caller that never asked. + const note = remoteHubConfigNote( + diagnostics.config, + () => collectClientConnectionStatus(undefined, undefined, { supportedEfforts: () => null }), + ); // First key, not last: it has to be read before the empty `providers` map that misled a // reader into concluding nothing was configured anywhere. const config = note && redacted && typeof redacted === "object" && !Array.isArray(redacted) diff --git a/tests/cli/cli-status-hub-state.test.ts b/tests/cli/cli-status-hub-state.test.ts index 9875223061..e396f023e1 100644 --- a/tests/cli/cli-status-hub-state.test.ts +++ b/tests/cli/cli-status-hub-state.test.ts @@ -241,7 +241,10 @@ describe("ocx status end to end on a connected client", () => { expect(parsed.remoteHub.origin).toBe("https://hub.example.test:8443"); expect(parsed.remoteHub.subagentModels).toEqual(["xai/grok-4.6", "gpt-5.6-sol"]); expect(parsed.remoteHub.oauth).toEqual([{ provider: "xai", loggedIn: true }, { provider: "anthropic", loggedIn: false }]); - // The connection block is untouched; remoteHub describes the other end of the link. + // `remoteHub` describes the other end of the link, so it does not disturb the link's own + // state. `connection` itself is no longer untouched — a connected client also reports a + // local `readiness` verdict — so this asserts the one field `remoteHub` must not perturb + // rather than claiming the whole block is unchanged. expect(parsed.connection.state).toBe("connected"); const human = await runStatus(home, codexHome, false); From 9cc825ff011c43b9661c9f54f5a01b5b8d379c8f Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 10:14:50 +0900 Subject: [PATCH 96/96] docs(i18n): make the remote hub guide runnable in every locale Round one fixed the English remote hub guide and left the seven translated copies telling their readers to run the line that fails. Each locale still ran a nested `ocx config set hub.` straight after `ocx config set runtimeRole hub`, but `runtimeRole` does not create the object and the CLI refuses to create a missing parent, so the guide's own next line died with `config parent path not found: hub`. Each locale also still offered `--allow-insecure-http`, which `ocx connect` rejects as an unknown argument, and none of them documented the data plane at all. Every locale now creates `hub` and `remoteGui` first, offers the whole-object alternative with its replace-not-merge warning, and carries the section that gives the data listener TLS: the macOS constraint that Serve proxies only to 127.0.0.1, the loopback forwarder, the split data and `--management-url` origins, and the quiet trap where a loopback-bound listener behind a TLS frontend answers 403 `origin_rejected` on `/v1/catalog` while `/readyz` still returns 200. The locales say a mistyped key is rejected at write time with a `schema_invalid` error, without pinning the literal error shape. That is deliberate: the English guide at docs-site/src/content/docs/guides/remote-hub.md:110 says the shape is `schema_invalid: hub.`, but `remoteGuiConfigError` in src/config.ts only produces the dotted form when the Zod issue carries a path. An unrecognized key has an empty path, so a typo actually reports `schema_invalid: hub`. Correcting the English source is outside this change's scope, and a translation should not quietly assert a different error shape than the source it translates, so the locales state only what is true of both. tests/ci-workflows/docs-remote-hub-claims.test.ts only read the English guide, which is why this drift went unenforced. It now runs the language-independent assertions over all eight files, English included, pinning commands and literal error codes rather than prose a translator is meant to rewrite. The replace-not-merge check bounds its window on the next heading of any level and requires the warning to name `hub.managementIngress`; bounding on `##` alone and accepting any bold let the following subsection satisfy it, which made the assertion decorative in five of the eight files. Closes #4200 --- .../src/content/docs/fr/guides/remote-hub.md | 56 +++++++++- .../src/content/docs/ja/guides/remote-hub.md | 56 +++++++++- .../src/content/docs/ru/guides/remote-hub.md | 56 +++++++++- .../src/content/docs/tr/guides/remote-hub.md | 56 +++++++++- .../content/docs/zh-cn/guides/remote-hub.md | 55 +++++++++- .../content/docs/zh-tw/guides/remote-hub.md | 55 +++++++++- .../docs-remote-hub-claims.test.ts | 102 ++++++++++++++++++ 7 files changed, 424 insertions(+), 12 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/remote-hub.md b/docs-site/src/content/docs/fr/guides/remote-hub.md index 15d5392c18..7c24706562 100644 --- a/docs-site/src/content/docs/fr/guides/remote-hub.md +++ b/docs-site/src/content/docs/fr/guides/remote-hub.md @@ -24,14 +24,32 @@ Le jeton admin permet la gestion ordinaire mais ne peut jamais créer une sessio ```bash ocx config set runtimeRole hub ocx config set hostname 100.64.0.10 -ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' ocx config set corsAllowOrigins '["http://localhost:10100"]' + +# Une configuration standalone neuve n'a ni objet `hub` ni objet `remoteGui`, et +# `ocx config set` ne crée pas un parent manquant : un chemin imbriqué échoue avec +# `config parent path not found: hub`. Définir `runtimeRole` ne le crée pas non plus. +# Créez d'abord chaque objet, puis définissez ses champs. +ocx config set hub '{}' +ocx config set remoteGui '{}' +ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' ocx config set hub.managementIngress '{"enabled":true,"port":10101}' ocx config set remoteGui.allowedTailscaleUsers '["operator@example.com"]' export OPENCODEX_API_AUTH_TOKEN="$(openssl rand -hex 32)" ocx service install ``` +Sur une configuration réellement vide, vous pouvez écrire chaque objet en un seul appel : + +```bash +ocx config set hub '{"managementPublicOrigin":"https://hub-name.tailnet-name.ts.net","managementIngress":{"enabled":true,"port":10101}}' +ocx config set remoteGui '{"allowedTailscaleUsers":["operator@example.com"]}' +``` + +N'utilisez cette forme que si l'objet n'existe pas encore. Affecter l'objet entier le **remplace** au lieu de fusionner : exécutée sur une configuration qui contenait déjà `hub.managementIngress`, la ligne ci-dessus supprime silencieusement cette entrée. Pour adapter une configuration existante, le parent est déjà là : définissez un champ à la fois, la forme imbriquée fonctionne et ne touche à rien d'autre. + +Deux détails décident qu'une ligne passe. La valeur est d'abord interprétée comme du JSON et retombe sur la chaîne brute, d'où l'écriture d'une URL en `'"https://…"'` : objets, tableaux, booléens et nombres doivent être du JSON valide. Ensuite, `hub` et `remoteGui` sont stricts : une clé mal orthographiée comme une valeur non conforme sont rejetées à l'écriture par une erreur `schema_invalid`, au lieu de devenir un réglage sans effet. `managementPublicOrigin` doit être une origine nue, sans chemin, requête ni fragment. + Le service lit le secret depuis `service-api-token`; le plist ou l’unité systemd ne contient pas sa valeur. ```bash @@ -43,6 +61,39 @@ tailscale serve status `/healthz` ne prouve que la vie du processus. Validez aussi `/readyz`, `GET /v1/catalog` authentifié et une vraie réponse routée. Le port de gestion doit écouter uniquement sur `127.0.0.1`. Pour un proxy TLS privé, utilisez `tailscale cert hub-name.tailnet-name.ts.net` et ne fabriquez jamais d’en-têtes `Tailscale-User-*`; utilisez l’association à usage unique. +### Donner du TLS à l'écoute de données + +Le mappage Serve ci-dessus ne publie que l'entrée de **gestion**. Celle-ci ne sert jamais `/v1/*`, `/healthz` ni `/readyz` : à elle seule, elle ne donne donc à un client distant aucun plan de données utilisable. opencodex ne termine par ailleurs aucun TLS : l'écoute est en HTTP clair et le HTTPS vient toujours d'un frontal détenu par l'opérateur. + +Serve peut aussi être ce frontal pour le plan de données, sur un second port HTTPS. Sur macOS, il faut un saut supplémentaire : Tailscale Serve ne relaie que vers `127.0.0.1` et ne peut donc pas viser l'écoute liée à l'adresse tailnet du nœud, tandis que la version App Store du client macOS refuse purement et simplement une destination distante. Lancez un relais local sur le hub et pointez Serve dessus : + +```bash +# N'importe quel relais TCP local convient; socat en est un. Choisissez un port que le hub +# n'utilise pas déjà : avec le companion de loopback activé, 127.0.0.1:10100 appartient à opencodex. +socat TCP-LISTEN:10110,bind=127.0.0.1,fork,reuseaddr TCP:100.64.0.10:10100 & + +tailscale serve --bg --https=8443 http://127.0.0.1:10110 +tailscale serve status # les deux mappages attendus : 443 -> 10101 et 8443 -> 10110 +``` + +Serve n'accepte qu'un jeu limité de ports HTTPS; confirmez avec `tailscale serve status` que le mappage a bien été créé plutôt que de supposer le port autorisé. Donnez au relais la même durée de vie qu'au hub : une tâche shell en arrière-plan meurt au redémarrage alors que le service revient, ce qui laisse un hub actif et injoignable en TLS. Lancez-le depuis launchd ou systemd, aux côtés de `ocx service install`. + +Connectez-vous ensuite en énonçant les deux origines séparément. L'URL positionnelle est l'origine **de données** — c'est là que sont récupérés `/readyz` et `/v1/catalog` — et `--management-url` est l'origine du tableau de bord, utilisée pour l'association et l'émission de clé. Elles ne partagent pas nécessairement le même port : + +```bash +ocx connect https://hub-name.tailnet-name.ts.net:8443 \ + --management-url https://hub-name.tailnet-name.ts.net \ + --admin-token-stdin +``` + +Quand `--management-url` est omis, il est repris de la réponse `/readyz`, qui rapporte `hub.managementPublicOrigin`. L'indiquer explicitement est plus clair lorsque les deux origines diffèrent. + +**Ne contournez pas cela en liant l'écoute de données à `127.0.0.1`.** Une liaison loopback est précisément ce à quoi opencodex reconnaît un déploiement purement local : il cesse d'exiger un identifiant de données et se met à exiger que l'en-tête `Host` de la requête soit lui aussi loopback. Un frontal TLS transmet `Host: hub-name.tailnet-name.ts.net`, donc `/v1/catalog` répond `403 origin_rejected` tandis que `/readyz`, qui n'applique pas ce contrôle, renvoie toujours `200`. Le déploiement paraît sain et ne peut servir aucun modèle. Rien dans le chemin de requête ne lit `X-Forwarded-Host`, le frontal ne peut donc pas corriger cela. Gardez l'écoute sur l'adresse tailnet : l'admission par identifiant y reste active et le contrôle `Host` ne s'applique pas. + +Lier `0.0.0.0` fonctionne aussi et supprime le besoin de relais, puisque l'écoute devient alors joignable en loopback. Cela publie le port de données sur toutes les interfaces : réservez-le aux hôtes dont les autres réseaux vous importent peu. + +Une fois Serve en place, rejouez les contrôles d'acceptation sur l'origine de données HTTPS : `/readyz`, `GET /v1/catalog` authentifié et une vraie réponse routée. + ## OAuth, rotation et déconnexion ```bash @@ -103,6 +154,7 @@ Le conteneur s’exécute avec l’utilisateur non-root `bun`, un système de fi - Récupération `.prev` : conservez les deux fichiers et relancez la rotation avec une autorité transitoire. - `hub-too-new`/`hub-too-old` : mettez à niveau le côté indiqué avant toute écriture locale. - Code d’association perdu ou épuisé : créez-en un nouveau; les essais sont limités avec 429. -- HTTP non local exige `--allow-insecure-http`; un jeton admin n’est jamais envoyé en HTTP. +- HTTP non local : l'association est refusée d'emblée et aucun indicateur ne permet d'y déroger. Placez l'origine de gestion derrière HTTPS, ou associez en loopback. Un jeton admin n’est jamais envoyé en HTTP. +- `403 origin_rejected` sur `/v1/catalog` alors que `/readyz` renvoie `200` : l'écoute de données est liée au loopback derrière un frontal TLS. Voir « Donner du TLS à l'écoute de données » ci-dessus. - Déconnexion/expiration de session navigateur n’affecte pas la clé de données. - Avant `tailscale serve reset`, inspectez `tailscale serve status`, car reset supprime tous les mappages. diff --git a/docs-site/src/content/docs/ja/guides/remote-hub.md b/docs-site/src/content/docs/ja/guides/remote-hub.md index cffc233143..daa836ebec 100644 --- a/docs-site/src/content/docs/ja/guides/remote-hub.md +++ b/docs-site/src/content/docs/ja/guides/remote-hub.md @@ -24,14 +24,32 @@ ocx sync ```bash ocx config set runtimeRole hub ocx config set hostname 100.64.0.10 -ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' ocx config set corsAllowOrigins '["http://localhost:10100"]' + +# 新規の standalone 設定には `hub` も `remoteGui` も存在せず、`ocx config set` は +# 親オブジェクトを自動生成しません。いきなりネストしたパスを書くと +# `config parent path not found: hub` で失敗します。`runtimeRole` を変えても +# 生成されません。先にオブジェクトを作ってからフィールドを設定してください。 +ocx config set hub '{}' +ocx config set remoteGui '{}' +ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' ocx config set hub.managementIngress '{"enabled":true,"port":10101}' ocx config set remoteGui.allowedTailscaleUsers '["operator@example.com"]' export OPENCODEX_API_AUTH_TOKEN="$(openssl rand -hex 32)" ocx service install ``` +設定がまだ完全に空であれば、オブジェクトごと一度に書いても構いません。 + +```bash +ocx config set hub '{"managementPublicOrigin":"https://hub-name.tailnet-name.ts.net","managementIngress":{"enabled":true,"port":10101}}' +ocx config set remoteGui '{"allowedTailscaleUsers":["operator@example.com"]}' +``` + +この形はオブジェクトがまだ無いときだけに使ってください。オブジェクト全体の設定はマージではなく**置換**です。すでに `hub.managementIngress` がある設定に上の行を流すと、その値は黙って消えます。既存の設定を直すときは親が揃っているので、ネストしたパスで一つずつ設定すれば他には触れません。 + +行が通るかどうかを決めるのは二点です。値はまず JSON として解釈され、失敗すると生の文字列として扱われます。URL を `'"https://…"'` と書く理由がこれで、オブジェクト・配列・真偽値・数値は正しい JSON である必要があります。また `hub` と `remoteGui` は厳格なスキーマで、キーの打ち間違いも規格外の値も書き込み時点で `schema_invalid` エラーとして拒否されます。効かない設定が静かに残ることはありません。`managementPublicOrigin` はパス・クエリ・フラグメントを含まない裸の origin である必要があります。 + launchd/systemd は保護された `service-api-token` を読み、設定ファイルへ秘密値を埋め込みません。 ```bash @@ -43,6 +61,39 @@ tailscale serve status `/healthz` の `200` はプロセスの生存確認にすぎません。`/readyz`、認証済み `GET /v1/catalog`、実際のモデル応答も確認してください。独自 TLS プロキシでは `tailscale cert hub-name.tailnet-name.ts.net` を使い、`127.0.0.1:10101` のみに転送します。`Tailscale-User-*` を偽造せず、信頼できる ID がない場合は一度限りのペアリングを使います。 +### データリスナーに TLS を付ける + +上の Serve マッピングが公開するのは**管理**入口だけです。管理入口は `/v1/*`、`/healthz`、`/readyz` を提供しないため、それだけではリモートクライアントが使えるデータプレーンになりません。opencodex 自身は TLS を終端しません。リスナーは平文 HTTP で、HTTPS は常に運用者が用意するフロントエンドの役目です。 + +データプレーンも Serve で公開できます。HTTPS ポートをもう一つ使うだけです。macOS では一段だけ余分に必要になります。Tailscale Serve は `127.0.0.1` にしかプロキシできず、ノード自身の tailnet アドレスにバインドしたリスナーを指せません。App Store 版の macOS クライアントはリモート宛先自体を拒否します。hub 上にループバックのフォワーダーを立て、Serve をそちらへ向けてください。 + +```bash +# ループバック TCP フォワーダーなら何でも構いません。socat はその一つです。 +# ハブがまだ使っていないポートを選んでください。ループバック companion を有効にすると 127.0.0.1:10100 は opencodex 自身のものです。 +socat TCP-LISTEN:10110,bind=127.0.0.1,fork,reuseaddr TCP:100.64.0.10:10100 & + +tailscale serve --bg --https=8443 http://127.0.0.1:10110 +tailscale serve status # 443 -> 10101 と 8443 -> 10110 の両方が出ること +``` + +Serve が受け付ける HTTPS ポートは限られています。通ったと決めつけず、`tailscale serve status` でマッピングが実際に作られたか確認してください。フォワーダーは hub と同じ寿命にします。バックグラウンドのシェルジョブは再起動で消える一方サービスは戻ってくるため、hub は動いているのに TLS では届かない状態が残ります。`ocx service install` と並べて launchd か systemd から起動してください。 + +接続時は二つの origin を別々に指定します。位置引数の URL が**データ** origin で、`/readyz` と `/v1/catalog` はここから取得されます。`--management-url` はペアリングとキー発行に使うダッシュボードの origin です。ポートが同じである必要はありません。 + +```bash +ocx connect https://hub-name.tailnet-name.ts.net:8443 \ + --management-url https://hub-name.tailnet-name.ts.net \ + --admin-token-stdin +``` + +`--management-url` を省略すると `/readyz` の応答が返す `hub.managementPublicOrigin` が使われます。二つの origin が異なる場合は明示したほうが明快です。 + +**データリスナーを `127.0.0.1` にバインドして近道しないでください。** ループバックへのバインドは、opencodex が純粋にローカルな配置だと判断する仕組みです。データ用の資格情報を要求しなくなる代わりに、リクエストの `Host` ヘッダーまでループバックであることを要求します。TLS フロントエンドは `Host: hub-name.tailnet-name.ts.net` をそのまま転送するので、`/v1/catalog` は `403 origin_rejected` を返し、その検査を行わない `/readyz` は `200` のままです。健全に見えるのにモデルを返せない配置ができあがります。リクエスト経路のどこも `X-Forwarded-Host` を読まないため、フロントエンド側では直せません。リスナーは tailnet アドレスに置いてください。資格情報の検査は有効なままで、`Host` 検査は適用されません。 + +`0.0.0.0` へのバインドでも動き、ループバックからも届くのでフォワーダーは不要になります。ただしデータポートが全インターフェースに公開されるため、他のネットワークを気にしなくてよいホストに限ってください。 + +Serve が立ち上がったら、HTTPS のデータ origin に対して `/readyz`、認証済み `GET /v1/catalog`、実際のモデル応答を改めて確認します。 + ## OAuth、キー更新、切断 ```bash @@ -104,6 +155,7 @@ docker compose up -d - `.prev` 復旧では二つのファイルを保持して一時権限付きで再実行します。 - `hub-too-new`/`hub-too-old` が示す古い側を更新してください。書き込み前に拒否されます。 - ペアリングコードは一度限りで、失敗は 429 制限されます。失った場合は再発行します。 -- 非ループバック HTTP は `--allow-insecure-http` が必要で、管理トークンは HTTP 送信されません。 +- 非ループバック HTTP のペアリングは拒否され、それを外すフラグはありません。管理 origin を HTTPS の背後に置くか、ループバックでペアリングしてください。管理トークンは HTTP 送信されません。 +- `/readyz` が `200` なのに `/v1/catalog` が `403 origin_rejected` を返す場合、データリスナーが TLS フロントエンドの背後でループバックにバインドされています。上の「データリスナーに TLS を付ける」を参照してください。 - ブラウザーのログアウト/期限切れはデータキーを失効させません。 - `tailscale serve reset` の前に全マッピングを確認してください。 diff --git a/docs-site/src/content/docs/ru/guides/remote-hub.md b/docs-site/src/content/docs/ru/guides/remote-hub.md index 0887baf9a8..964fe4e423 100644 --- a/docs-site/src/content/docs/ru/guides/remote-hub.md +++ b/docs-site/src/content/docs/ru/guides/remote-hub.md @@ -24,14 +24,32 @@ Admin token разрешает обычное управление, но ник ```bash ocx config set runtimeRole hub ocx config set hostname 100.64.0.10 -ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' ocx config set corsAllowOrigins '["http://localhost:10100"]' + +# В новой standalone-конфигурации нет объектов `hub` и `remoteGui`, а `ocx config set` +# не создаёт отсутствующего родителя: вложенный путь завершится ошибкой +# `config parent path not found: hub`. Установка `runtimeRole` его тоже не создаёт. +# Сначала создайте каждый объект, затем задавайте его поля. +ocx config set hub '{}' +ocx config set remoteGui '{}' +ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' ocx config set hub.managementIngress '{"enabled":true,"port":10101}' ocx config set remoteGui.allowedTailscaleUsers '["operator@example.com"]' export OPENCODEX_API_AUTH_TOKEN="$(openssl rand -hex 32)" ocx service install ``` +Если конфигурация действительно пуста, каждый объект можно задать одним вызовом: + +```bash +ocx config set hub '{"managementPublicOrigin":"https://hub-name.tailnet-name.ts.net","managementIngress":{"enabled":true,"port":10101}}' +ocx config set remoteGui '{"allowedTailscaleUsers":["operator@example.com"]}' +``` + +Эта форма годится, только пока объекта нет. Присваивание объекта целиком **заменяет** его, а не сливает с прежним: выполнив строку выше над конфигурацией, где уже был `hub.managementIngress`, вы молча потеряете этот ingress. Когда вы правите существующую конфигурацию, родитель уже на месте — задавайте по одному полю вложенным путём, и остальное останется нетронутым. + +Принятие строки решают две детали. Значение сначала разбирается как JSON и лишь затем трактуется как обычная строка — поэтому URL пишется как `'"https://…"'`, а объекты, массивы, булевы значения и числа обязаны быть корректным JSON. Кроме того, `hub` и `remoteGui` строгие: и опечатка в ключе, и несоответствующее значение отклоняются прямо при записи ошибкой `schema_invalid`, а не превращаются в настройку, которая никогда не сработает. `managementPublicOrigin` должен быть чистым origin без пути, запроса и фрагмента. + systemd/launchd читает секрет из `service-api-token`; plist и unit не содержат его значения. ```bash @@ -43,6 +61,39 @@ tailscale serve status `/healthz` подтверждает только работу процесса. Проверьте также `/readyz`, авторизованный `GET /v1/catalog` и реальный ответ модели. Собственный TLS-прокси должен использовать `tailscale cert hub-name.tailnet-name.ts.net` и проксировать только на `127.0.0.1:10101`. Не подделывайте `Tailscale-User-*`; без доверенной идентификации используйте одноразовое pairing. +### TLS для слушателя данных + +Показанное выше сопоставление Serve публикует только **управляющий** вход. Он никогда не отдаёт `/v1/*`, `/healthz` и `/readyz`, поэтому сам по себе не даёт удалённому клиенту работоспособного плана данных. opencodex к тому же не терминирует TLS: слушатель работает по открытому HTTP, а HTTPS всегда обеспечивает фронтенд на стороне оператора. + +Serve может стать таким фронтендом и для плана данных — на втором HTTPS-порту. В macOS нужен ещё один переход: Tailscale Serve проксирует только на `127.0.0.1` и не может указывать на слушателя, привязанного к собственному tailnet-адресу узла, а сборка macOS-клиента из App Store прямо отказывает удалённому назначению. Запустите на hub локальный форвардер и направьте Serve на него: + +```bash +# Подойдёт любой loopback-форвардер TCP; socat — один из них. Выберите порт, который хаб +# ещё не занял: при включённом loopback-companion 127.0.0.1:10100 принадлежит самому opencodex. +socat TCP-LISTEN:10110,bind=127.0.0.1,fork,reuseaddr TCP:100.64.0.10:10100 & + +tailscale serve --bg --https=8443 http://127.0.0.1:10110 +tailscale serve status # ожидаются оба сопоставления: 443 -> 10101 и 8443 -> 10110 +``` + +Serve принимает ограниченный набор HTTPS-портов; убедитесь через `tailscale serve status`, что сопоставление действительно создано, вместо того чтобы считать порт разрешённым. Дайте форвардеру тот же срок жизни, что и hub: фоновая задача оболочки умирает при перезагрузке, а служба возвращается, и остаётся работающий hub, недоступный по TLS. Запускайте форвардер из launchd или systemd рядом с `ocx service install`. + +Затем подключайтесь, указывая оба origin по отдельности. Позиционный URL — это origin **данных**, именно оттуда берутся `/readyz` и `/v1/catalog`; `--management-url` — origin панели, который используется для pairing и выдачи ключа. Совпадение портов не требуется: + +```bash +ocx connect https://hub-name.tailnet-name.ts.net:8443 \ + --management-url https://hub-name.tailnet-name.ts.net \ + --admin-token-stdin +``` + +Если `--management-url` опущен, он берётся из ответа `/readyz`, который сообщает `hub.managementPublicOrigin`. Когда origin различаются, указать его явно понятнее. + +**Не сокращайте путь, привязывая слушатель данных к `127.0.0.1`.** Именно по loopback-привязке opencodex распознаёт сугубо локальное развёртывание: он перестаёт требовать ключ данных и начинает требовать, чтобы заголовок `Host` тоже был loopback. TLS-фронтенд передаёт `Host: hub-name.tailnet-name.ts.net`, поэтому `/v1/catalog` отвечает `403 origin_rejected`, а `/readyz`, где этой проверки нет, по-прежнему возвращает `200`. Развёртывание выглядит здоровым и не может отдать модель. Ничто в тракте запроса не читает `X-Forwarded-Host`, так что фронтенд это не исправит. Оставьте слушатель на tailnet-адресе: проверка ключа останется включённой, а проверка `Host` не применяется. + +Привязка к `0.0.0.0` тоже работает и снимает нужду в форвардере, так как слушатель становится доступен и по loopback. Она публикует порт данных на всех интерфейсах, поэтому выбирайте её только там, где другие сети вас не волнуют. + +Когда Serve поднят, повторите приёмочные проверки для HTTPS-origin данных: `/readyz`, авторизованный `GET /v1/catalog` и один реальный маршрутизированный ответ. + ## OAuth, ротация и отключение ```bash @@ -106,6 +157,7 @@ docker compose up -d - Для `.prev` сохраните оба файла и повторите ротацию с временными полномочиями. - `hub-too-new`/`hub-too-old` указывает, какую сторону обновить; локальные записи ещё не сделаны. - Pairing одноразовый, попытки ограничены 429; потерянный код создайте заново. -- Для не-loopback HTTP нужен `--allow-insecure-http`; admin token по HTTP не отправляется. +- Pairing по не-loopback HTTP отклоняется сразу, и флага-исключения нет. Поставьте управляющий origin за HTTPS или выполняйте pairing по loopback; admin token по HTTP не отправляется. +- `/readyz` отвечает `200`, а `/v1/catalog` — `403 origin_rejected`: слушатель данных привязан к loopback за TLS-фронтендом, см. «TLS для слушателя данных» выше. - Logout/expiry браузерной сессии не отзывает ключ данных. - Перед `tailscale serve reset` просмотрите все mappings через `tailscale serve status`. diff --git a/docs-site/src/content/docs/tr/guides/remote-hub.md b/docs-site/src/content/docs/tr/guides/remote-hub.md index 6499325908..ae89276984 100644 --- a/docs-site/src/content/docs/tr/guides/remote-hub.md +++ b/docs-site/src/content/docs/tr/guides/remote-hub.md @@ -24,14 +24,32 @@ Admin token sıradan yönetim yapabilir ancak hiçbir zaman onay oturumu oluştu ```bash ocx config set runtimeRole hub ocx config set hostname 100.64.0.10 -ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' ocx config set corsAllowOrigins '["http://localhost:10100"]' + +# Yeni bir standalone yapılandırmada `hub` veya `remoteGui` nesnesi yoktur ve +# `ocx config set` eksik bir üst nesneyi oluşturmaz: iç içe bir yol +# `config parent path not found: hub` hatasıyla başarısız olur. `runtimeRole` +# ayarlamak da onu oluşturmaz. Önce her nesneyi oluşturun, sonra alanlarını ayarlayın. +ocx config set hub '{}' +ocx config set remoteGui '{}' +ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' ocx config set hub.managementIngress '{"enabled":true,"port":10101}' ocx config set remoteGui.allowedTailscaleUsers '["operator@example.com"]' export OPENCODEX_API_AUTH_TOKEN="$(openssl rand -hex 32)" ocx service install ``` +Yapılandırma gerçekten boşsa her nesneyi tek çağrıda da yazabilirsiniz: + +```bash +ocx config set hub '{"managementPublicOrigin":"https://hub-name.tailnet-name.ts.net","managementIngress":{"enabled":true,"port":10101}}' +ocx config set remoteGui '{"allowedTailscaleUsers":["operator@example.com"]}' +``` + +Bu biçimi yalnızca nesne henüz yokken kullanın. Nesnenin tamamını atamak birleştirmez, **değiştirir**: zaten `hub.managementIngress` içeren bir yapılandırmada yukarıdaki satırı çalıştırmak o girişi sessizce düşürür. Mevcut bir yapılandırmayı uyarlarken üst nesne zaten yerindedir; tek tek alan ayarlayın, iç içe biçim çalışır ve başka hiçbir şeye dokunmaz. + +Bir satırın kabul edilip edilmeyeceğini iki ayrıntı belirler. Değer önce JSON olarak ayrıştırılır, olmazsa ham dizgeye düşer; bir URL'nin `'"https://…"'` biçiminde yazılmasının nedeni budur ve nesneler, diziler, mantıksal değerler ve sayılar geçerli JSON olmak zorundadır. Ayrıca `hub` ve `remoteGui` katıdır: yanlış yazılmış bir anahtar da kurala uymayan bir değer de yazma anında `schema_invalid` hatasıyla reddedilir; hiçbiri hiçbir zaman etkili olmayan bir ayara dönüşmez. `managementPublicOrigin` yol, sorgu veya parça içermeyen çıplak bir origin olmalıdır. + systemd/launchd korumalı `service-api-token` dosyasını okur; plist veya unit içine gerçek sır yazılmaz. ```bash @@ -43,6 +61,39 @@ tailscale serve status `/healthz` yalnızca işlemin yaşadığını gösterir. `/readyz`, kimlik doğrulamalı `GET /v1/catalog` ve gerçek bir model yanıtını da doğrulayın. Kendi TLS proxy'niz için `tailscale cert hub-name.tailnet-name.ts.net` kullanın ve yalnızca `127.0.0.1:10101` hedefine yönlendirin. `Tailscale-User-*` başlıkları uydurmayın; güvenilir kimlik yoksa tek kullanımlık eşleştirme kullanın. +### Veri dinleyicisine TLS vermek + +Yukarıdaki Serve eşlemesi yalnızca **yönetim** girişini yayımlar. Bu giriş `/v1/*`, `/healthz` veya `/readyz` sunmaz; dolayısıyla tek başına uzak bir istemciye kullanılabilir bir veri düzlemi vermez. opencodex kendi TLS'ini de sonlandırmaz: dinleyici düz HTTP'dir ve HTTPS her zaman operatörün kendi ön ucudur. + +Serve, ikinci bir HTTPS portunda veri düzlemi için de bu ön uç olabilir. macOS'ta bir adım daha gerekir: Tailscale Serve yalnızca `127.0.0.1` adresine proxy yapar, yani düğümün kendi tailnet adresine bağladığınız dinleyiciyi hedefleyemez; macOS istemcisinin App Store sürümü ise uzak bir hedefi doğrudan reddeder. Hub üzerinde bir loopback yönlendirici çalıştırın ve Serve'ü ona yöneltin: + +```bash +# Herhangi bir loopback TCP yönlendirici iş görür; socat bunlardan biridir. Hub'ın halihazırda +# kullanmadığı bir port seçin: loopback companion açıkken 127.0.0.1:10100 opencodex'in kendisine aittir. +socat TCP-LISTEN:10110,bind=127.0.0.1,fork,reuseaddr TCP:100.64.0.10:10100 & + +tailscale serve --bg --https=8443 http://127.0.0.1:10110 +tailscale serve status # iki eşleme de beklenir: 443 -> 10101 ve 8443 -> 10110 +``` + +Serve sınırlı bir HTTPS portu kümesini kabul eder; portun izinli olduğunu varsaymak yerine `tailscale serve status` ile eşlemenin gerçekten oluştuğunu doğrulayın. Yönlendiriciye hub ile aynı ömrü verin: arka plandaki bir kabuk işi yeniden başlatmada ölürken servis geri gelir ve ortada çalışan ama TLS üzerinden erişilemeyen bir hub kalır. `ocx service install` ile birlikte launchd veya systemd üzerinden çalıştırın. + +Ardından iki origin'i ayrı ayrı belirterek bağlanın. Konumsal URL **veri** origin'idir; `/readyz` ve `/v1/catalog` oradan alınır. `--management-url` ise eşleştirme ve anahtar verme için kullanılan pano origin'idir. Aynı portu paylaşmaları gerekmez: + +```bash +ocx connect https://hub-name.tailnet-name.ts.net:8443 \ + --management-url https://hub-name.tailnet-name.ts.net \ + --admin-token-stdin +``` + +`--management-url` atlandığında, `hub.managementPublicOrigin` değerini bildiren `/readyz` yanıtından alınır. İki origin farklıysa açıkça yazmak daha nettir. + +**Veri dinleyicisini `127.0.0.1` adresine bağlayarak kestirmeden gitmeyin.** Loopback bağlaması, opencodex'in dağıtımı tümüyle yerel saymasının yoludur: veri kimlik bilgisi istemeyi bırakır ve bunun yerine isteğin `Host` başlığının da loopback olmasını şart koşar. Bir TLS ön ucu `Host: hub-name.tailnet-name.ts.net` başlığını olduğu gibi iletir, bu yüzden `/v1/catalog` `403 origin_rejected` yanıtı verirken bu denetimi yapmayan `/readyz` hâlâ `200` döndürür. Dağıtım sağlıklı görünür ama model sunamaz. İstek yolundaki hiçbir kod `X-Forwarded-Host` okumaz, dolayısıyla ön uç bunu onaramaz. Dinleyiciyi tailnet adresinde tutun: kimlik bilgisi kabulü açık kalır ve `Host` denetimi uygulanmaz. + +`0.0.0.0` bağlaması da çalışır ve dinleyici loopback üzerinden de erişilebilir olduğundan yönlendirici ihtiyacını ortadan kaldırır. Veri portunu tüm arayüzlerde yayımladığı için yalnızca başka ağını önemsemediğiniz makinelerde tercih edin. + +Serve ayağa kalktıktan sonra kabul denetimlerini HTTPS veri origin'ine karşı yineleyin: `/readyz`, kimlik doğrulamalı `GET /v1/catalog` ve bir gerçek yönlendirilmiş yanıt. + ## OAuth, döndürme ve bağlantı kesme ```bash @@ -106,6 +157,7 @@ Konteyner root olmayan `bun` kullanıcısıyla, salt okunur kök dosya sistemiyl - `.prev` kurtarmasında iki dosyayı koruyup geçici yetkiyle yeniden çalıştırın. - `hub-too-new`/`hub-too-old` eski tarafı gösterir; yerel yazımdan önce reddedilir. - Eşleştirme tek kullanımlıktır ve hatalar 429 ile sınırlanır; kayıp kodu yeniden üretin. -- Loopback dışı HTTP için `--allow-insecure-http` gerekir; admin token HTTP ile gönderilmez. +- Loopback dışı HTTP eşleştirmesi doğrudan reddedilir ve bunu devre dışı bırakan bir bayrak yoktur. Yönetim origin'ini HTTPS arkasına alın ya da loopback üzerinden eşleştirin; admin token HTTP ile gönderilmez. +- `/readyz` `200` dönerken `/v1/catalog` `403 origin_rejected` veriyorsa, veri dinleyicisi bir TLS ön ucunun arkasında loopback'e bağlıdır. Yukarıdaki "Veri dinleyicisine TLS vermek" bölümüne bakın. - Tarayıcı logout/expiry veri anahtarını iptal etmez. - `tailscale serve reset` tüm eşlemeleri kaldırır; önce durumu inceleyin. diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-hub.md b/docs-site/src/content/docs/zh-cn/guides/remote-hub.md index 6caa44fc23..47516f1dbd 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-hub.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-hub.md @@ -24,14 +24,31 @@ Admin token 只能执行普通管理,永远不能创建用户同意会话。 ```bash ocx config set runtimeRole hub ocx config set hostname 100.64.0.10 -ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' ocx config set corsAllowOrigins '["http://localhost:10100"]' + +# 全新的 standalone 配置没有 `hub` 或 `remoteGui` 对象,而 `ocx config set` 不会 +# 自动创建缺失的父对象:直接写嵌套路径会以 `config parent path not found: hub` 失败。 +# 设置 `runtimeRole` 同样不会创建它。请先建对象,再设置字段。 +ocx config set hub '{}' +ocx config set remoteGui '{}' +ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' ocx config set hub.managementIngress '{"enabled":true,"port":10101}' ocx config set remoteGui.allowedTailscaleUsers '["operator@example.com"]' export OPENCODEX_API_AUTH_TOKEN="$(openssl rand -hex 32)" ocx service install ``` +如果配置确实还是空的,也可以一次性写入整个对象: + +```bash +ocx config set hub '{"managementPublicOrigin":"https://hub-name.tailnet-name.ts.net","managementIngress":{"enabled":true,"port":10101}}' +ocx config set remoteGui '{"allowedTailscaleUsers":["operator@example.com"]}' +``` + +只有在对象尚不存在时才用这种写法。整对象赋值是**替换**而不是合并:对已经含有 `hub.managementIngress` 的配置执行上面这行,该入口会被悄悄丢掉。调整既有配置时父对象已经存在,用嵌套路径逐个字段设置即可,不会动到其他值。 + +有两点决定一行命令能否被接受。值先按 JSON 解析,失败才回退为原始字符串——这就是 URL 要写成 `'"https://…"'` 的原因,对象、数组、布尔值和数字都必须是合法 JSON。另外 `hub` 和 `remoteGui` 采用严格模式:键名写错或取值不合规,都会在写入时以 `schema_invalid` 错误被拒绝,而不会变成一个永远不生效的设置。`managementPublicOrigin` 必须是不带路径、查询和片段的纯 origin。 + systemd/launchd 从受保护的 `service-api-token` 读取密钥,plist 和 unit 不包含明文密钥。 ```bash @@ -43,6 +60,39 @@ tailscale serve status `/healthz` 只证明进程存活。还必须验证 `/readyz`、经过身份验证的 `GET /v1/catalog` 和一次真实模型响应。管理端口只能监听 `127.0.0.1`。自建 TLS 代理应使用 `tailscale cert hub-name.tailnet-name.ts.net`,并仅代理到 `127.0.0.1:10101`。不要伪造 `Tailscale-User-*`;没有可信身份时请使用一次性配对。 +### 为数据监听器提供 TLS + +上面的 Serve 映射只发布**管理**入口。该入口从不提供 `/v1/*`、`/healthz` 或 `/readyz`,因此仅凭它并不能让远程客户端获得可用的数据平面。opencodex 自身也不终结 TLS:监听器是明文 HTTP,HTTPS 始终由运维方自建的前端负责。 + +数据平面同样可以交给 Serve,只需再用一个 HTTPS 端口。在 macOS 上还要多一跳,因为 Tailscale Serve 只能代理到 `127.0.0.1`,无法指向你绑定在节点自身 tailnet 地址上的监听器,而 App Store 版 macOS 客户端会直接拒绝远程目标。请在 hub 上运行一个回环转发器,再让 Serve 指向它: + +```bash +# 任何回环 TCP 转发器都可以,socat 只是其中之一。请选一个 hub 尚未占用的端口: +# 启用回环 companion 后,127.0.0.1:10100 属于 opencodex 自己。 +socat TCP-LISTEN:10110,bind=127.0.0.1,fork,reuseaddr TCP:100.64.0.10:10100 & + +tailscale serve --bg --https=8443 http://127.0.0.1:10110 +tailscale serve status # 应同时出现 443 -> 10101 和 8443 -> 10110 +``` + +Serve 只接受有限的几个 HTTPS 端口。请用 `tailscale serve status` 确认映射确实建立,而不要假定端口被允许。转发器应与 hub 拥有相同的生命周期:后台 shell 作业会在重启时消失而服务会自行恢复,于是 hub 在运行却无法经 TLS 访问。请随 `ocx service install` 一起,用 launchd 或 systemd 托管它。 + +连接时把两个 origin 分开写。位置参数 URL 是**数据** origin,`/readyz` 和 `/v1/catalog` 都从这里获取;`--management-url` 是用于配对和密钥签发的控制台 origin。两者不必共用端口: + +```bash +ocx connect https://hub-name.tailnet-name.ts.net:8443 \ + --management-url https://hub-name.tailnet-name.ts.net \ + --admin-token-stdin +``` + +省略 `--management-url` 时,它取自 `/readyz` 响应,而该响应报告的正是 `hub.managementPublicOrigin`。两个 origin 不同时,显式写出更清楚。 + +**不要为图省事把数据监听器绑到 `127.0.0.1`。** 回环绑定正是 opencodex 判定“纯本地部署”的依据:它会不再要求数据凭据,转而要求请求的 `Host` 头也是回环地址。TLS 前端会原样转发 `Host: hub-name.tailnet-name.ts.net`,于是 `/v1/catalog` 返回 `403 origin_rejected`,而不做这项检查的 `/readyz` 仍然返回 `200`。部署看起来健康,却无法提供模型。请求路径中没有任何代码读取 `X-Forwarded-Host`,所以前端也无法修正。请把监听器留在 tailnet 地址上:凭据准入保持开启,而 `Host` 检查不会生效。 + +绑定 `0.0.0.0` 同样可行,而且因为回环也能访问,就不再需要转发器。但它会把数据端口发布到所有接口,所以只在你不在意其他网络的主机上这么做。 + +Serve 就绪后,请针对 HTTPS 数据 origin 重新执行验收检查:`/readyz`、经过身份验证的 `GET /v1/catalog` 和一次真实模型响应。 + ## OAuth、密钥轮换与断开 ```bash @@ -101,6 +151,7 @@ docker compose up -d - `.prev` 恢复:保留两个文件,使用临时权限重新运行轮换。 - `hub-too-new`/`hub-too-old` 会指出需要升级的一端,并在本地写入前失败。 - 配对码一次性使用,失败次数会触发 429;丢失后请重新创建。 -- 非回环 HTTP 配对必须显式使用 `--allow-insecure-http`;Admin token 绝不通过 HTTP 发送。 +- 非回环 HTTP 配对会被直接拒绝,且没有任何开关可以豁免。请把管理 origin 放到 HTTPS 之后,或改在回环上配对;Admin token 绝不通过 HTTP 发送。 +- `/readyz` 返回 `200` 而 `/v1/catalog` 返回 `403 origin_rejected`:说明数据监听器绑在回环地址上却位于 TLS 前端之后,参见上文“为数据监听器提供 TLS”。 - 浏览器 logout/expiry 只影响会话,不会吊销数据密钥。 - `tailscale serve reset` 会删除节点上的所有映射,请先查看 `tailscale serve status`。 diff --git a/docs-site/src/content/docs/zh-tw/guides/remote-hub.md b/docs-site/src/content/docs/zh-tw/guides/remote-hub.md index 073bb6a6f5..cade45c84c 100644 --- a/docs-site/src/content/docs/zh-tw/guides/remote-hub.md +++ b/docs-site/src/content/docs/zh-tw/guides/remote-hub.md @@ -24,14 +24,31 @@ Admin token 只能執行一般管理,永遠不能建立使用者同意工作 ```bash ocx config set runtimeRole hub ocx config set hostname 100.64.0.10 -ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' ocx config set corsAllowOrigins '["http://localhost:10100"]' + +# 全新的 standalone 設定沒有 `hub` 或 `remoteGui` 物件,而 `ocx config set` 不會 +# 自動建立缺少的父物件:直接寫巢狀路徑會以 `config parent path not found: hub` 失敗。 +# 設定 `runtimeRole` 同樣不會建立它。請先建立物件,再設定欄位。 +ocx config set hub '{}' +ocx config set remoteGui '{}' +ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' ocx config set hub.managementIngress '{"enabled":true,"port":10101}' ocx config set remoteGui.allowedTailscaleUsers '["operator@example.com"]' export OPENCODEX_API_AUTH_TOKEN="$(openssl rand -hex 32)" ocx service install ``` +若設定確實還是空的,也可以一次寫入整個物件: + +```bash +ocx config set hub '{"managementPublicOrigin":"https://hub-name.tailnet-name.ts.net","managementIngress":{"enabled":true,"port":10101}}' +ocx config set remoteGui '{"allowedTailscaleUsers":["operator@example.com"]}' +``` + +只有在物件尚未存在時才使用這種寫法。整個物件的賦值是**取代**而非合併:對已經含有 `hub.managementIngress` 的設定執行上面那行,該入口會被悄悄丟掉。調整既有設定時父物件已經存在,用巢狀路徑逐一設定欄位即可,不會動到其他值。 + +有兩點決定一行命令能否被接受。值會先以 JSON 解析,失敗才退回原始字串——這就是 URL 要寫成 `'"https://…"'` 的原因,物件、陣列、布林值與數字都必須是合法 JSON。另外 `hub` 與 `remoteGui` 採用嚴格結構:鍵名打錯或取值不合規,都會在寫入當下以 `schema_invalid` 錯誤遭拒,而不會變成永遠不生效的設定。`managementPublicOrigin` 必須是不含路徑、查詢與片段的純 origin。 + systemd/launchd 從受保護的 `service-api-token` 讀取金鑰,plist 與 unit 不包含明文金鑰。 ```bash @@ -43,6 +60,39 @@ tailscale serve status `/healthz` 只證明程序仍在執行。還必須驗證 `/readyz`、已驗證的 `GET /v1/catalog` 與一次真實模型回應。管理連接埠只能監聽 `127.0.0.1`。自管 TLS proxy 應使用 `tailscale cert hub-name.tailnet-name.ts.net`,並只代理到 `127.0.0.1:10101`。不要偽造 `Tailscale-User-*`;沒有可信身分時請使用一次性配對。 +### 為資料監聽器提供 TLS + +上面的 Serve 對應只發布**管理**入口。該入口從不提供 `/v1/*`、`/healthz` 或 `/readyz`,因此光靠它並不能讓遠端用戶端取得可用的資料平面。opencodex 本身也不終結 TLS:監聽器是明文 HTTP,HTTPS 一律由維運方自建的前端負責。 + +資料平面同樣可以交給 Serve,只要再用一個 HTTPS 連接埠。在 macOS 上還要多一跳,因為 Tailscale Serve 只能代理到 `127.0.0.1`,無法指向你綁在節點自身 tailnet 位址上的監聽器,而 App Store 版 macOS 用戶端會直接拒絕遠端目的地。請在 hub 上執行一個迴路轉送器,再讓 Serve 指向它: + +```bash +# 任何迴路 TCP 轉送器都可以,socat 只是其中之一。請選一個 hub 尚未佔用的連接埠: +# 啟用迴路 companion 後,127.0.0.1:10100 屬於 opencodex 自己。 +socat TCP-LISTEN:10110,bind=127.0.0.1,fork,reuseaddr TCP:100.64.0.10:10100 & + +tailscale serve --bg --https=8443 http://127.0.0.1:10110 +tailscale serve status # 應同時出現 443 -> 10101 與 8443 -> 10110 +``` + +Serve 只接受有限的幾個 HTTPS 連接埠。請用 `tailscale serve status` 確認對應確實建立,不要假設連接埠已被允許。轉送器應與 hub 有相同的生命週期:背景 shell 工作會在重開機時消失而服務會自行復原,於是 hub 在執行卻無法經 TLS 連到。請隨 `ocx service install` 一起,用 launchd 或 systemd 託管它。 + +連線時把兩個 origin 分開寫。位置參數 URL 是**資料** origin,`/readyz` 與 `/v1/catalog` 都從這裡取得;`--management-url` 則是用於配對與金鑰簽發的儀表板 origin。兩者不必共用連接埠: + +```bash +ocx connect https://hub-name.tailnet-name.ts.net:8443 \ + --management-url https://hub-name.tailnet-name.ts.net \ + --admin-token-stdin +``` + +省略 `--management-url` 時,它取自 `/readyz` 回應,而該回應回報的正是 `hub.managementPublicOrigin`。兩個 origin 不同時,明確寫出更清楚。 + +**不要為了省事把資料監聽器綁到 `127.0.0.1`。** 迴路繫結正是 opencodex 判定「純本機部署」的依據:它會不再要求資料憑證,改為要求請求的 `Host` 標頭也是迴路位址。TLS 前端會原樣轉送 `Host: hub-name.tailnet-name.ts.net`,於是 `/v1/catalog` 回應 `403 origin_rejected`,而不做這項檢查的 `/readyz` 仍然回應 `200`。部署看起來健康,卻無法提供模型。請求路徑中沒有任何程式碼會讀取 `X-Forwarded-Host`,所以前端也修不了。請把監聽器留在 tailnet 位址上:憑證准入維持開啟,而 `Host` 檢查不會生效。 + +繫結 `0.0.0.0` 同樣可行,而且因為迴路也連得到,就不再需要轉送器。但它會把資料連接埠發布到所有介面,所以只在你不在意其他網路的主機上這麼做。 + +Serve 就緒後,請對 HTTPS 資料 origin 重新執行驗收檢查:`/readyz`、已驗證的 `GET /v1/catalog` 與一次真實模型回應。 + ## OAuth、金鑰輪替與中斷連線 ```bash @@ -82,6 +132,7 @@ docker compose up -d - `.prev` 復原:保留兩個檔案,使用暫時權限重新執行輪替。 - `hub-too-new`/`hub-too-old` 會指出需要升級的一端,並在本機寫入前失敗。 - 配對碼只能使用一次,失敗次數會觸發 429;遺失後請重新建立。 -- 非迴路 HTTP 配對必須明確使用 `--allow-insecure-http`;Admin token 絕不透過 HTTP 傳送。 +- 非迴路 HTTP 配對會被直接拒絕,而且沒有任何開關可以豁免。請把管理 origin 放到 HTTPS 之後,或改在迴路上配對;Admin token 絕不透過 HTTP 傳送。 +- `/readyz` 回應 `200` 但 `/v1/catalog` 回應 `403 origin_rejected`:表示資料監聽器綁在迴路位址卻位於 TLS 前端之後,請參見上文「為資料監聽器提供 TLS」。 - 瀏覽器 logout/expiry 只影響工作階段,不會撤銷資料金鑰。 - `tailscale serve reset` 會刪除節點上的所有映射,請先查看 `tailscale serve status`。 diff --git a/tests/ci-workflows/docs-remote-hub-claims.test.ts b/tests/ci-workflows/docs-remote-hub-claims.test.ts index 23ef5bd0c8..e6812f31fb 100644 --- a/tests/ci-workflows/docs-remote-hub-claims.test.ts +++ b/tests/ci-workflows/docs-remote-hub-claims.test.ts @@ -19,12 +19,23 @@ * `export OPENCODEX_API_AUTH_TOKEN=…` step is the one that has to stay gone: it is how the * maintainer's hub ended up with a management admin token in the data-plane variable, and the * service now provisions its own token, so re-adding the line would re-teach the incident. + * + * Round one fixed the English source only, and the seven translated copies kept telling their + * readers to run the line that fails (#4200). That drift was unenforced because this oracle read + * one file. The locale-wide block below is the part that keeps the next English edit from + * silently leaving the translations behind; the markers it pins are commands and literal error + * codes, which survive translation, rather than prose a translator is supposed to rewrite. */ import { describe, expect, test } from "bun:test"; import { repoPath } from "../helpers/repo-root"; const GUIDE = repoPath("docs-site/src/content/docs/guides/remote-hub.md"); const KO_GUIDE = repoPath("docs-site/src/content/docs/ko/guides/remote-hub.md"); +const TRANSLATED = ["ko", "ja", "zh-cn", "zh-tw", "fr", "ru", "tr"] as const; +const LOCALE_GUIDES: ReadonlyArray = [ + ["en", GUIDE], + ...TRANSLATED.map(locale => [locale, repoPath(`docs-site/src/content/docs/${locale}/guides/remote-hub.md`)] as const), +]; describe("remote hub guide", () => { test("no nested config set runs before its parent object exists", async () => { @@ -160,3 +171,94 @@ describe("the one-port hub recipe", () => { expect(source).toContain("Do not point Serve at the loopback companion listener"); }); }); + +describe("remote hub guide translations", () => { + // Every locale is checked against the SAME expectations as the source, including "en" itself. + // Putting English in the list is deliberate: it means a future English edit that drops one of + // these markers fails here too, instead of quietly redefining what the locales owe. + for (const [locale, path] of LOCALE_GUIDES) { + describe(locale, () => { + test("no nested config set runs before its parent object exists", async () => { + const source = await Bun.file(path).text(); + + // Ordering is the whole fix. A guide that sets the field first and shows `{}` further + // down still fails verbatim on the fresh standalone config it told the reader to build. + for (const parent of ["hub", "remoteGui"] as const) { + const initializer = source.indexOf(`ocx config set ${parent} '{}'`); + const nested = source.indexOf(`ocx config set ${parent}.`); + expect(initializer, `${locale} no longer initializes an empty ${parent} object`).toBeGreaterThanOrEqual(0); + expect(nested, `${locale} no longer sets any ${parent} field`).toBeGreaterThanOrEqual(0); + expect( + initializer, + `${locale} sets a ${parent}. before creating ${parent}, which fails on a fresh config`, + ).toBeLessThan(nested); + } + + // The error text is terminal output, so it stays literal in every language: it is how a + // reader who already hit the failure recognizes their own screen. + expect(source, `${locale} no longer names the error a reader actually sees`) + .toContain("config parent path not found: hub"); + }); + + test("the whole-object alternative carries its replace-not-merge warning", async () => { + // `setPath` assigns the leaf, so the one-call form drops a pre-existing managementIngress. + // Each locale words the warning natively, so this pins shape: the alternative exists, and + // an emphasized caveat follows it before the section ends. Without the second half a + // locale could keep the convenient line and lose the reason it is dangerous. + const source = await Bun.file(path).text(); + const wholeObject = source.indexOf(`ocx config set hub '{"managementPublicOrigin"`); + expect(wholeObject, `${locale} lost the whole-object alternative`).toBeGreaterThanOrEqual(0); + + // Stop at the next heading of ANY level, not just `##`. Bounding on `##` alone let the + // bold text inside the following `###` data-plane subsection satisfy this check, so + // deleting the warning itself still passed -- the assertion was decorative in five of the + // eight files. Two or more hashes also keeps a `# comment` line inside a bash fence from + // closing the window early. + const nextHeading = source.slice(wholeObject).search(/\n#{2,6} /); + const section = source.slice(wholeObject, nextHeading < 0 ? undefined : wholeObject + nextHeading); + expect(section, `${locale} offers the whole-object form with no emphasized warning`).toContain("**"); + + // Emphasis alone is content-free -- any unrelated bold in the window would satisfy it. + // The warning's actual subject is the setting that silently disappears, and its name is + // a config path, so it survives translation. A locale that keeps the convenient one-call + // line and drops the reason it is dangerous fails here. + expect( + section, + `${locale} does not name hub.managementIngress as what a whole-object set drops`, + ).toContain("hub.managementIngress"); + }); + + test("the data plane is given TLS on its own origin", async () => { + // The management ingress serves no /v1/*, /healthz or /readyz, so a guide that publishes + // only that ingress leaves a hub that pairs and then cannot answer a request. These are + // commands, so a translation that dropped the section fails rather than reading fine. + const source = await Bun.file(path).text(); + expect(source, `${locale} lost the loopback forwarder macOS Serve requires`) + .toContain("socat TCP-LISTEN:10110,bind=127.0.0.1"); + expect(source, `${locale} lost the second HTTPS mapping for the data listener`) + .toContain("tailscale serve --bg --https=8443 http://127.0.0.1:10110"); + expect(source, `${locale} lost the data origin on ocx connect`) + .toContain("ocx connect https://hub-name.tailnet-name.ts.net:8443"); + expect(source, `${locale} lost the separate management origin`) + .toContain("--management-url https://hub-name.tailnet-name.ts.net"); + }); + + test("the quiet loopback-bind trap is documented", async () => { + // This is the failure the section exists for: a loopback-bound data listener behind a TLS + // frontend answers 403 on /v1/catalog while /readyz still returns 200, so the deployment + // looks healthy and serves no model. Both tokens are literal wire values in every locale. + const source = await Bun.file(path).text(); + expect(source, `${locale} lost the error code the operator actually sees`).toContain("403 origin_rejected"); + expect(source, `${locale} no longer says the frontend cannot repair this`).toContain("X-Forwarded-Host"); + }); + + test("the retired --allow-insecure-http flag is not offered", async () => { + // `rejectArgs` throws "Unexpected argument(s)" on it, pairing refuses non-loopback HTTP + // with no opt-out, and remoteGui.allowInsecureHttp is a retired no-op kept only so old + // configs still load. Offering it in any language sends that reader to an error. + const source = await Bun.file(path).text(); + expect(source, `${locale} still offers the retired flag`).not.toContain("--allow-insecure-http"); + }); + }); + } +});