diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 5bea652194..f849c9be0a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1685,6 +1685,7 @@ export const de: Record = { "storage.policy.skippedEmpty": "Keine Archivkandidaten passend zum Ziel.", "storage.policy.doneQuarantine": "Richtlinie hat {count} Datei(en) in Quarantäne ({size}).", "storage.policy.donePermanent": "Richtlinie hat {count} Datei(en) endgültig gelöscht ({size}).", + "storage.policy.metadataSaveWarning": "Der Richtlinienlauf wurde beendet, aber seine Planungsmetadaten konnten nicht gespeichert werden.", "modal.back": "Zurück", "modal.badge.oauth": "OAuth", "modal.customProvider": "Benutzerdefinierter Anbieter", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 0e27ca29e2..0e809578bd 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1007,6 +1007,7 @@ export const en = { "storage.policy.skippedEmpty": "No archived candidates matched the target.", "storage.policy.doneQuarantine": "Policy quarantined {count} file(s) ({size}).", "storage.policy.donePermanent": "Policy permanently deleted {count} file(s) ({size}).", + "storage.policy.metadataSaveWarning": "The policy run finished, but its scheduling metadata could not be saved.", // add-provider modal "modal.addNamed": "Add: {label}", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4c5292014b..b552df536e 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -982,6 +982,7 @@ export const fr: Record = { "storage.policy.skippedEmpty": "Aucune archive candidate ne correspond à l’objectif.", "storage.policy.doneQuarantine": "La politique a mis {count} fichier(s) en quarantaine ({size}).", "storage.policy.donePermanent": "La politique a supprimé définitivement {count} fichier(s) ({size}).", + "storage.policy.metadataSaveWarning": "L’exécution de la politique est terminée, mais ses métadonnées de planification n’ont pas pu être enregistrées.", "modal.addNamed": "Ajouter : {label}", "modal.add": "Ajouter un fournisseur", "modal.search": "Rechercher des fournisseurs…", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 5be35ce9e4..82e0281f8b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -950,6 +950,7 @@ export const ja: Record = { "storage.policy.skippedEmpty": "目標に合うアーカイブ候補がありません。", "storage.policy.doneQuarantine": "方針が {count} 件を隔離しました({size})。", "storage.policy.donePermanent": "方針が {count} 件を完全削除しました({size})。", + "storage.policy.metadataSaveWarning": "方針の実行は完了しましたが、スケジュールのメタデータを保存できませんでした。", // add-provider modal "modal.addNamed": "追加: {label}", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index d7def6f1c7..0a0c58fdd0 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1712,6 +1712,7 @@ export const ko: Record = { "storage.policy.skippedEmpty": "목표에 맞는 보관 후보가 없습니다.", "storage.policy.doneQuarantine": "정책이 파일 {count}개를 격리했습니다({size}).", "storage.policy.donePermanent": "정책이 파일 {count}개를 영구 삭제했습니다({size}).", + "storage.policy.metadataSaveWarning": "정책 실행은 완료됐지만 일정 메타데이터를 저장하지 못했습니다.", "modal.back": "뒤로", "modal.badge.oauth": "OAuth", "modal.customProvider": "사용자 지정 프로바이더", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 97f77fcdca..94e2dbc333 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -991,6 +991,7 @@ export const ru: Record = { "storage.policy.skippedEmpty": "Нет архивных кандидатов под цель.", "storage.policy.doneQuarantine": "Политика отправила в карантин {count} файл(ов) ({size}).", "storage.policy.donePermanent": "Политика навсегда удалила {count} файл(ов) ({size}).", + "storage.policy.metadataSaveWarning": "Выполнение политики завершено, но не удалось сохранить метаданные расписания.", // add-provider modal "modal.addNamed": "Добавить: {label}", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 57e40312d0..1391401055 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -998,6 +998,7 @@ export const tr: Record = { "storage.policy.skippedEmpty": "Hedefle eşleşen aday yok.", "storage.policy.doneQuarantine": "Politika {count} dosyayı karantinaya aldı ({size}).", "storage.policy.donePermanent": "Politika {count} dosyayı kalıcı olarak sildi ({size}).", + "storage.policy.metadataSaveWarning": "Politika çalışması tamamlandı ancak zamanlama meta verileri kaydedilemedi.", // add-provider modal "modal.addNamed": "Ekle: {label}", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 36d4e6b6d2..d0f337d05e 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -795,6 +795,7 @@ export const zhTW: Record = { "storage.policy.skippedEmpty": "沒有匹配目標的歸檔候選項。", "storage.policy.doneQuarantine": "策略已隔離 {count} 個檔案({size})。", "storage.policy.donePermanent": "策略已永久刪除 {count} 個檔案({size})。", + "storage.policy.metadataSaveWarning": "策略執行已完成,但無法儲存其排程中繼資料。", "modal.addNamed": "新增:{label}", "modal.add": "新增供應商", "modal.search": "搜尋供應商…", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 894c9a07ca..aaa31df4e2 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1705,6 +1705,7 @@ export const zh: Record = { "storage.policy.skippedEmpty": "没有匹配目标的归档候选项。", "storage.policy.doneQuarantine": "策略已隔离 {count} 个文件({size})。", "storage.policy.donePermanent": "策略已永久删除 {count} 个文件({size})。", + "storage.policy.metadataSaveWarning": "策略运行已完成,但无法保存其调度元数据。", "modal.back": "返回", "modal.badge.oauth": "OAuth", "modal.customProvider": "自定义提供方", diff --git a/gui/src/pages/Storage.tsx b/gui/src/pages/Storage.tsx index 3c99c5a2cd..019b93e6b0 100644 --- a/gui/src/pages/Storage.tsx +++ b/gui/src/pages/Storage.tsx @@ -74,6 +74,7 @@ interface CleanupPolicy { skipped?: string; deferred?: string; error?: string; + metadataPersistenceError?: "missing" | "invalid" | "conflict" | "write_failed"; mode?: string; freedBytes?: number; removed?: number; @@ -888,6 +889,9 @@ function AutoCleanupPolicyPanel({ if (outcome.skipped === "disabled") { setStatus(t("storage.policy.skippedDisabled")); + } else if (outcome.ok && outcome.metadataPersistenceError) { + setError(t("storage.policy.metadataSaveWarning")); + if (outcome.removed !== undefined) onDone(); } else if (outcome.skipped === "under_threshold") { setStatus(t("storage.policy.skippedUnder")); } else if (outcome.skipped === "nothing_selected") { diff --git a/gui/tests/storage-policy-metadata-warning.test.tsx b/gui/tests/storage-policy-metadata-warning.test.tsx new file mode 100644 index 0000000000..cc46172359 --- /dev/null +++ b/gui/tests/storage-policy-metadata-warning.test.tsx @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import Storage from "../src/pages/Storage"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +const originalFetch = globalThis.fetch; + +const REPORT = { + codexHome: "/tmp/codex", + generatedAt: 1, + total: { bytes: 100, fileCount: 1 }, + buckets: [{ key: "archived_sessions", label: "Archived", bytes: 100, fileCount: 1 }], +}; + +const POLICY = { + enabled: true, + trigger: { archivedBytesOver: 0 }, + target: { removeOldestPercent: 25 }, + schedule: "manual", + mode: "quarantine", +} as const; + +beforeEach(() => { + clearClientResourceStoresForTests(); + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + 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 }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearClientResourceStoresForTests(); + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function waitFor(predicate: () => boolean, timeoutMs = 1500): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 10)); + }); + } +} + +test("storage policy run warns when cleanup succeeds but metadata persistence fails", async () => { + const startedAt = 10; + let started = false; + let storageFetches = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? "GET"; + if (url.endsWith("/api/storage/cleanup-policy/run") && method === "POST") { + started = true; + return Response.json({ + ok: true, + started: true, + job: { status: "running", startedAt }, + policy: { ...POLICY, job: { status: "running", startedAt } }, + }); + } + if (url.endsWith("/api/storage/cleanup-policy") && method === "PUT") { + return Response.json({ ok: true, policy: POLICY }); + } + if (url.endsWith("/api/storage/cleanup-policy")) { + if (!started) return Response.json(POLICY); + return Response.json({ + ...POLICY, + job: { + status: "idle", + startedAt, + finishedAt: startedAt + 1, + lastOutcome: { + ok: true, + mode: "quarantine", + removed: 1, + freedBytes: 100, + metadataPersistenceError: "missing", + }, + }, + }); + } + if (url.endsWith("/api/storage/trash")) return Response.json({ entries: [] }); + if (url.endsWith("/api/storage")) { + storageFetches += 1; + return Response.json(REPORT); + } + return new Response(null, { status: 404 }); + }) as typeof fetch; + + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + let root!: Root; + try { + await act(async () => { + root = createRoot(container); + root.render(); + }); + await waitFor(() => Array.from(container.querySelectorAll("button")).some(button => button.textContent?.includes("Run now"))); + const runButton = Array.from(container.querySelectorAll("button")) + .find(button => button.textContent?.includes("Run now")); + expect(runButton).toBeDefined(); + + await act(async () => { + runButton!.click(); + }); + await waitFor(() => (container.textContent ?? "").includes("scheduling metadata could not be saved")); + await waitFor(() => storageFetches >= 2); + + expect(container.textContent).not.toContain("Policy quarantined"); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("scheduling metadata could not be saved"); + expect(storageFetches).toBeGreaterThanOrEqual(2); + } finally { + await act(async () => { + root.unmount(); + }); + container.remove(); + } +}); diff --git a/src/storage/policy-job.ts b/src/storage/policy-job.ts index 5c553345b1..50dc5cbb7b 100644 --- a/src/storage/policy-job.ts +++ b/src/storage/policy-job.ts @@ -38,6 +38,7 @@ export interface PolicyJobOutcome { freedBytes?: number; removed?: number; trashDir?: string; + metadataPersistenceError?: PolicyRunResult["metadataPersistenceError"]; } export interface PolicyJobState { @@ -238,6 +239,7 @@ export async function abortStorageCleanupPolicyJobAsync(): Promise { } } +/** Project a run result into the bounded management-API job outcome. */ function outcomeFromResult(result: PolicyRunResult): PolicyJobOutcome { return { ok: result.ok, @@ -248,18 +250,26 @@ function outcomeFromResult(result: PolicyRunResult): PolicyJobOutcome { ...(result.freedBytes !== undefined ? { freedBytes: result.freedBytes } : {}), ...(result.removed !== undefined ? { removed: result.removed } : {}), ...(result.trashDir ? { trashDir: result.trashDir } : {}), + ...(result.metadataPersistenceError + ? { metadataPersistenceError: result.metadataPersistenceError } + : {}), }; } +/** Publish one completed evaluation without losing successful cleanup effects. */ function applyFinished(result: PolicyRunResult): void { // Prefer the latest persisted policy over `result.policy`. The worker (or // in-process run) already merged run metadata into disk; a concurrent PUT // may also have landed after that write. Re-reading avoids applying a stale // start-of-job snapshot when the run skipped without saving. - try { - livePolicyApply?.(readStorageCleanupPolicyFromConfig()); - } catch { - livePolicyApply?.(result.policy); + // A best-effort fallback policy may predate concurrent edits; keep the current + // live config untouched when the durable metadata write did not land. + if (!result.metadataPersistenceError) { + try { + livePolicyApply?.(readStorageCleanupPolicyFromConfig()); + } catch { + livePolicyApply?.(result.policy); + } } state = { status: "idle", diff --git a/src/storage/policy.ts b/src/storage/policy.ts index 5da1fdb2ca..d90a2473a5 100644 --- a/src/storage/policy.ts +++ b/src/storage/policy.ts @@ -8,7 +8,7 @@ * Privacy: logs never include host paths, digests of file contents, or secrets. */ import { resolveCodexHomeDir } from "../codex/home"; -import { loadConfig, saveConfigPreservingClaudeCode } from "../config"; +import { loadConfig, mutatePersistedConfig, saveConfigPreservingClaudeCode } from "../config"; import type { StorageCleanupPolicy } from "../types"; import { computePreviewDigest, @@ -38,6 +38,7 @@ export function setStorageCleanupPolicyLiveSink( export type PolicySchedule = StorageCleanupPolicy["schedule"]; export type PolicyRunReason = "startup" | "schedule" | "manual"; +export type PolicyMetadataPersistenceError = "missing" | "invalid" | "conflict" | "write_failed"; export type PolicySkipReason = | "disabled" @@ -54,6 +55,7 @@ export interface PolicyRunResult { freedBytes?: number; removed?: number; trashDir?: string; + metadataPersistenceError?: PolicyMetadataPersistenceError; policy: StorageCleanupPolicy; } @@ -399,6 +401,21 @@ export type PolicyRunMetadataPatch = { lastRun?: StorageCleanupPolicy["lastRun"]; }; +/** Apply only run-owned fields while preserving the supplied policy settings. */ +function applyPolicyRunMetadata( + policy: StorageCleanupPolicy, + patch: PolicyRunMetadataPatch, +): StorageCleanupPolicy { + let next = + patch.nextRun === "defer_busy" + ? deferBusy(policy, patch.now) + : advanceNextRun(policy, patch.now); + if (patch.lastRun) { + next = { ...next, lastRun: patch.lastRun }; + } + return next; +} + /** * Reload the latest persisted policy and write only run-owned metadata * (`lastRun` / `nextRun`). Preserves concurrent edits to enabled, trigger, @@ -410,17 +427,57 @@ export function commitPolicyRunMetadata( patch: PolicyRunMetadataPatch, ): StorageCleanupPolicy { const latest = normalizeStorageCleanupPolicy(load()); - let next = - patch.nextRun === "defer_busy" - ? deferBusy(latest, patch.now) - : advanceNextRun(latest, patch.now); - if (patch.lastRun) { - next = { ...next, lastRun: patch.lastRun }; - } + const next = applyPolicyRunMetadata(latest, patch); save(next); return next; } +type PolicyRunMetadataCommit = { + policy: StorageCleanupPolicy; + persistenceError?: PolicyMetadataPersistenceError; +}; + +/** Attach the durable metadata outcome without replacing cleanup status or metrics. */ +function withMetadataCommit( + result: Omit, + committed: PolicyRunMetadataCommit, +): PolicyRunResult { + return { + ...result, + policy: committed.policy, + ...(committed.persistenceError ? { metadataPersistenceError: committed.persistenceError } : {}), + }; +} + +/** Recompute run-owned metadata from the latest config inside the mutation lock. */ +function commitPolicyRunMetadataToConfig( + patch: PolicyRunMetadataPatch, + fallbackPolicy: StorageCleanupPolicy, +): PolicyRunMetadataCommit { + const unavailable = (reason: PolicyMetadataPersistenceError): PolicyRunMetadataCommit => { + console.warn(`[storage-policy] metadata_persist_failed reason=${reason}`); + return { + policy: applyPolicyRunMetadata(fallbackPolicy, patch), + persistenceError: reason, + }; + }; + try { + const outcome = mutatePersistedConfig(config => { + const next = applyPolicyRunMetadata( + normalizeStorageCleanupPolicy(config.storageCleanupPolicy), + patch, + ); + config.storageCleanupPolicy = next; + return { changed: true, value: next }; + }); + if (outcome.status === "unavailable") return unavailable(outcome.reason); + livePolicySink?.(outcome.value); + return { policy: outcome.value }; + } catch { + return unavailable("write_failed"); + } +} + function logPolicyEvent(message: string): void { console.log(`[storage-policy] ${message}`); } @@ -434,8 +491,14 @@ export function runStorageCleanupPolicy(deps: PolicyRunDeps): PolicyRunResult { const load = deps.loadPolicy ?? readStorageCleanupPolicyFromConfig; const save = deps.savePolicy ?? writeStorageCleanupPolicyToConfig; const execute = deps.execute ?? executeArchivedCleanup; - const policy = normalizeStorageCleanupPolicy(load()); + // Injected stores retain the existing load/save contract. The production path + // recomputes metadata from the latest persisted policy inside the config lock. + const commitMetadata = deps.loadPolicy !== undefined || deps.savePolicy !== undefined + ? (patch: PolicyRunMetadataPatch): PolicyRunMetadataCommit => ({ + policy: commitPolicyRunMetadata(load, save, patch), + }) + : (patch: PolicyRunMetadataPatch) => commitPolicyRunMetadataToConfig(patch, policy); if (typeof deps.holdAfterLoadMs === "number" && Number.isFinite(deps.holdAfterLoadMs) && deps.holdAfterLoadMs > 0) { Bun.sleepSync(Math.floor(deps.holdAfterLoadMs)); @@ -451,15 +514,15 @@ export function runStorageCleanupPolicy(deps: PolicyRunDeps): PolicyRunResult { const selection = selectPolicyPreview(policy, deps.codexHome); if (selection.archivedBytes <= policy.trigger.archivedBytesOver) { - const saved = commitPolicyRunMetadata(load, save, { now, nextRun: "advance" }); + const committed = commitMetadata({ now, nextRun: "advance" }); logPolicyEvent("skip under_threshold"); - return { ok: true, skipped: "under_threshold", policy: saved }; + return withMetadataCommit({ ok: true, skipped: "under_threshold" }, committed); } if (selection.count === 0) { - const saved = commitPolicyRunMetadata(load, save, { now, nextRun: "advance" }); + const committed = commitMetadata({ now, nextRun: "advance" }); logPolicyEvent("skip nothing_selected"); - return { ok: true, skipped: "nothing_selected", policy: saved }; + return withMetadataCommit({ ok: true, skipped: "nothing_selected" }, committed); } const result = execute({ @@ -473,25 +536,28 @@ export function runStorageCleanupPolicy(deps: PolicyRunDeps): PolicyRunResult { }); if (!result.ok && result.error === "codex_busy") { - const saved = commitPolicyRunMetadata(load, save, { now, nextRun: "defer_busy" }); + const committed = commitMetadata({ now, nextRun: "defer_busy" }); logPolicyEvent("defer codex_busy"); - return { ok: false, deferred: "codex_busy", error: "codex_busy", policy: saved }; + return withMetadataCommit({ + ok: false, + deferred: "codex_busy", + error: "codex_busy", + }, committed); } if (!result.ok) { // Non-busy failure: still advance schedule so we do not tight-loop. - const saved = commitPolicyRunMetadata(load, save, { now, nextRun: "advance" }); + const committed = commitMetadata({ now, nextRun: "advance" }); logPolicyEvent(`fail ${result.error ?? "cleanup_failed"}`); - return { + return withMetadataCommit({ ok: false, error: result.error, mode: result.mode, ...(result.trashDir ? { trashDir: result.trashDir } : {}), - policy: saved, - }; + }, committed); } - const saved = commitPolicyRunMetadata(load, save, { + const committed = commitMetadata({ now, nextRun: "advance", lastRun: { @@ -503,14 +569,13 @@ export function runStorageCleanupPolicy(deps: PolicyRunDeps): PolicyRunResult { logPolicyEvent( `ok mode=${result.mode} removed=${result.count} freedBytes=${result.bytes}`, ); - return { + return withMetadataCommit({ ok: true, mode: result.mode, freedBytes: result.bytes, removed: result.count, ...(result.trashDir ? { trashDir: result.trashDir } : {}), - policy: saved, - }; + }, committed); } /** Startup / schedule tick entry — swallows unexpected errors. */ diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 1c99e2c2f2..3685fb5bb4 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -122,6 +122,10 @@ the recorded service ownership. `atomicWriteFile` uses a temp file named `{path}.ocx.{pid}.{seq}.tmp` (process ID + incrementing sequence number) to avoid collisions when concurrent writers (e.g. `ocx stop` and the proxy's own shutdown handler) both restore Codex config simultaneously. The temp is renamed atomically into place. +Storage cleanup run metadata uses the field-scoped persisted-config mutation path, so a background +Worker cannot restore unrelated API keys or provider settings from a snapshot read before the lock. +If that metadata write is unavailable after cleanup has already completed, the job retains the +cleanup outcome and exposes a bounded persistence error instead of relabeling the run as a Worker failure. Windows secret-file hardening resolves the effective token SID through an absolute, trusted PowerShell path before granting the owner and removing inherited broad ACL entries. The normal diff --git a/tests/storage-policy-config-race.test.ts b/tests/storage-policy-config-race.test.ts new file mode 100644 index 0000000000..d908386db0 --- /dev/null +++ b/tests/storage-policy-config-race.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + getConfigPath, + loadConfig, + saveConfig, + setPersistedConfigMutationBeforeCommitForTests, +} from "../src/config"; +import { computeNextRun, runStorageCleanupPolicy } from "../src/storage/policy"; +import { + getStorageCleanupPolicyJobState, + requestStorageCleanupPolicyRun, + resetStorageCleanupPolicyJobForTestsAsync, + setStorageCleanupPolicyJobTestHooks, +} from "../src/storage/policy-job"; +import type { OcxConfig, StorageCleanupPolicy } from "../src/types"; + +let configHome = ""; +let previousHome: string | undefined; + +function baseConfig(): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authMode: "forward", + }, + }, + apiKeys: [{ id: "revoked", name: "Revoked", key: "ocx_revoked", createdAt: "2026-01-01" }], + } as OcxConfig; +} + +beforeEach(async () => { + await resetStorageCleanupPolicyJobForTestsAsync(); + setStorageCleanupPolicyJobTestHooks(null); + previousHome = process.env.OPENCODEX_HOME; + configHome = mkdtempSync(join(tmpdir(), "ocx-storage-policy-config-race-")); + process.env.OPENCODEX_HOME = configHome; + setPersistedConfigMutationBeforeCommitForTests(null); + saveConfig(baseConfig()); +}); + +afterEach(async () => { + await resetStorageCleanupPolicyJobForTestsAsync(); + setStorageCleanupPolicyJobTestHooks(null); + setPersistedConfigMutationBeforeCommitForTests(null); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (configHome) rmSync(configHome, { recursive: true, force: true }); + configHome = ""; +}); + +test("run metadata commit rebases concurrent policy and unrelated config writes", () => { + const now = 1_800_000_000_000; + const initialPolicy: StorageCleanupPolicy = { + enabled: true, + trigger: { archivedBytesOver: 1234 }, + target: { removeOldestPercent: 40 }, + schedule: "manual", + mode: "quarantine", + }; + const initial = loadConfig(); + initial.storageCleanupPolicy = initialPolicy; + saveConfig(initial); + + let injected = false; + setPersistedConfigMutationBeforeCommitForTests(() => { + injected = true; + const concurrent = loadConfig(); + concurrent.apiKeys = []; + concurrent.storageCleanupPolicy = { + enabled: false, + trigger: { archivedBytesOver: 9999 }, + target: { reduceToBytes: 42 }, + schedule: "daily", + mode: "permanent", + }; + saveConfig(concurrent); + }); + + const result = runStorageCleanupPolicy({ + reason: "manual", + force: true, + now, + codexHome: configHome, + }); + expect(result.skipped).toBe("under_threshold"); + + const persisted = loadConfig(); + expect(injected).toBe(true); + expect(persisted.apiKeys).toEqual([]); + expect(persisted.storageCleanupPolicy).toEqual({ + enabled: false, + trigger: { archivedBytesOver: 9999 }, + target: { reduceToBytes: 42 }, + schedule: "daily", + mode: "permanent", + nextRun: computeNextRun("daily", now), + }); + expect(result.policy).toEqual(persisted.storageCleanupPolicy); +}); + +test("job outcome keeps successful cleanup when metadata cannot persist", async () => { + const initial = loadConfig(); + initial.storageCleanupPolicy = { + enabled: true, + trigger: { archivedBytesOver: 0 }, + target: { removeOldestPercent: 100 }, + schedule: "daily", + mode: "quarantine", + }; + saveConfig(initial); + const archived = join(configHome, "archived_sessions", "rollout-old.jsonl"); + mkdirSync(join(configHome, "archived_sessions")); + writeFileSync(archived, "x".repeat(100)); + setStorageCleanupPolicyJobTestHooks({ runInProcess: true }); + setPersistedConfigMutationBeforeCommitForTests(() => { + rmSync(getConfigPath(), { force: true }); + }); + + const started = requestStorageCleanupPolicyRun({ + reason: "manual", + force: true, + codexHome: configHome, + }); + expect(started.accepted).toBe(true); + + const deadline = Date.now() + 5_000; + while (getStorageCleanupPolicyJobState().status !== "idle" && Date.now() < deadline) { + await Bun.sleep(10); + } + const state = getStorageCleanupPolicyJobState(); + expect(state.status).toBe("idle"); + expect(state.lastOutcome).toMatchObject({ + ok: true, + mode: "quarantine", + removed: 1, + freedBytes: 100, + metadataPersistenceError: "missing", + }); + expect(state.lastError).toBeUndefined(); + expect(existsSync(archived)).toBe(false); + expect(existsSync(getConfigPath())).toBe(false); +}, { timeout: 10_000 });