Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1685,6 +1685,7 @@ export const de: Record<TKey, string> = {
"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",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -982,6 +982,7 @@ export const fr: Record<TKey, string> = {
"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…",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,7 @@ export const ja: Record<TKey, string> = {
"storage.policy.skippedEmpty": "目標に合うアーカイブ候補がありません。",
"storage.policy.doneQuarantine": "方針が {count} 件を隔離しました({size})。",
"storage.policy.donePermanent": "方針が {count} 件を完全削除しました({size})。",
"storage.policy.metadataSaveWarning": "方針の実行は完了しましたが、スケジュールのメタデータを保存できませんでした。",

// add-provider modal
"modal.addNamed": "追加: {label}",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1712,6 +1712,7 @@ export const ko: Record<TKey, string> = {
"storage.policy.skippedEmpty": "목표에 맞는 보관 후보가 없습니다.",
"storage.policy.doneQuarantine": "정책이 파일 {count}개를 격리했습니다({size}).",
"storage.policy.donePermanent": "정책이 파일 {count}개를 영구 삭제했습니다({size}).",
"storage.policy.metadataSaveWarning": "정책 실행은 완료됐지만 일정 메타데이터를 저장하지 못했습니다.",
"modal.back": "뒤로",
"modal.badge.oauth": "OAuth",
"modal.customProvider": "사용자 지정 프로바이더",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,7 @@ export const ru: Record<TKey, string> = {
"storage.policy.skippedEmpty": "Нет архивных кандидатов под цель.",
"storage.policy.doneQuarantine": "Политика отправила в карантин {count} файл(ов) ({size}).",
"storage.policy.donePermanent": "Политика навсегда удалила {count} файл(ов) ({size}).",
"storage.policy.metadataSaveWarning": "Выполнение политики завершено, но не удалось сохранить метаданные расписания.",

// add-provider modal
"modal.addNamed": "Добавить: {label}",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,7 @@ export const tr: Record<TKey, string> = {
"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}",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,7 @@ export const zhTW: Record<TKey, string> = {
"storage.policy.skippedEmpty": "沒有匹配目標的歸檔候選項。",
"storage.policy.doneQuarantine": "策略已隔離 {count} 個檔案({size})。",
"storage.policy.donePermanent": "策略已永久刪除 {count} 個檔案({size})。",
"storage.policy.metadataSaveWarning": "策略執行已完成,但無法儲存其排程中繼資料。",
"modal.addNamed": "新增:{label}",
"modal.add": "新增供應商",
"modal.search": "搜尋供應商…",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1705,6 +1705,7 @@ export const zh: Record<TKey, string> = {
"storage.policy.skippedEmpty": "没有匹配目标的归档候选项。",
"storage.policy.doneQuarantine": "策略已隔离 {count} 个文件({size})。",
"storage.policy.donePermanent": "策略已永久删除 {count} 个文件({size})。",
"storage.policy.metadataSaveWarning": "策略运行已完成,但无法保存其调度元数据。",
"modal.back": "返回",
"modal.badge.oauth": "OAuth",
"modal.customProvider": "自定义提供方",
Expand Down
4 changes: 4 additions & 0 deletions gui/src/pages/Storage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ interface CleanupPolicy {
skipped?: string;
deferred?: string;
error?: string;
metadataPersistenceError?: "missing" | "invalid" | "conflict" | "write_failed";
mode?: string;
freedBytes?: number;
removed?: number;
Expand Down Expand Up @@ -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") {
Expand Down
136 changes: 136 additions & 0 deletions gui/tests/storage-policy-metadata-warning.test.tsx
Original file line number Diff line number Diff line change
@@ -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<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
await act(async () => {
await new Promise<void>(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(<LanguageProvider><Storage apiBase="http://localhost" /></LanguageProvider>);
});
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();
}
});
18 changes: 14 additions & 4 deletions src/storage/policy-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface PolicyJobOutcome {
freedBytes?: number;
removed?: number;
trashDir?: string;
metadataPersistenceError?: PolicyRunResult["metadataPersistenceError"];
}

export interface PolicyJobState {
Expand Down Expand Up @@ -238,6 +239,7 @@ export async function abortStorageCleanupPolicyJobAsync(): Promise<void> {
}
}

/** Project a run result into the bounded management-API job outcome. */
function outcomeFromResult(result: PolicyRunResult): PolicyJobOutcome {
return {
ok: result.ok,
Expand All @@ -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 }
: {}),
Comment thread
luvs01 marked this conversation as resolved.
};
}

/** 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",
Expand Down
Loading
Loading