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
98 changes: 98 additions & 0 deletions docs-site/src/content/docs/guides/codex-log-guard-reclaim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
---
title: Codex Log Guard Reclaim
description: Manually reclaim free pages from Codex diagnostic-log SQLite storage with bounded incremental vacuuming.
---

Reclaim is the manual space-recovery stage of Codex Log Guard. It compacts the canonical Codex `logs_2.sqlite` database only when the database and runtime pass the same safety checks used by Log Guard protection.

Reclaim is **never scheduled automatically** and never runs merely because the Storage page is opened. The dashboard requires an explicit Compact action and a second confirmation before it sends the mutation request.

## What Reclaim does

OpenCodex performs a bounded offline maintenance sequence:

1. resolve the canonical `logs_2.sqlite` through Codex's effective `sqlite_home`;
2. verify the file identity and known Codex log schema;
3. verify process enumeration succeeded and no supported Codex writer process is running;
4. acquire the dedicated cross-process Log Guard lock;
5. repeat the Codex-process check while that lock is held;
6. open the existing database read/write without create semantics and prove an immediate SQLite writer can be acquired;
7. require `PRAGMA auto_vacuum` to already be `INCREMENTAL`;
8. run `PRAGMA quick_check` before maintenance;
9. run a full WAL checkpoint and refuse a busy/incomplete checkpoint;
10. execute bounded `PRAGMA incremental_vacuum(N)` batches, checkpointing after each batch;
11. run `PRAGMA quick_check` again after maintenance; and
12. report before/after database, WAL, page-count, freelist, and reclaimable-byte metrics.

The default batch target is approximately **8 MiB of SQLite pages**. A single invocation reclaims at most approximately **256 MiB of pages**, with an additional finite iteration cap. If more free pages remain, the result is reported as partial and you can invoke Compact again later.

The byte limits are converted to page counts using the database's actual SQLite page size. They are bounds on logical SQLite pages processed, not claims about SSD/NAND write volume.

## Safety guarantees

Reclaim deliberately does **not**:

- run full `VACUUM`;
- change `auto_vacuum` mode on an existing Codex database;
- delete, truncate, rename, or otherwise manipulate Codex `-wal` / `-shm` files directly;
- delete diagnostic rows;
- modify Log Guard protection triggers or unrelated user triggers;
- run while Codex is detected as active;
- proceed when process enumeration is uncertain;
- proceed on an unknown future log schema; or
- continue after a failed SQLite integrity check.

A busy Log Guard lock, busy SQLite writer, or busy initial checkpoint is returned as an explicit refusal rather than being retried in the background. If checkpoint contention appears only after an incremental-vacuum batch has already committed, OpenCodex reports the work already completed as a successful partial result with `stopReason: "busy"` instead of claiming that nothing changed.

## CLI

Inspect reclaimable space first:

```bash
ocx storage codex-logs status
```

Run one bounded maintenance pass:

```bash
ocx storage codex-logs compact
```

For machine-readable before/after metrics:

```bash
ocx storage codex-logs compact --json
```

If the result reports that more reclaimable space remains, stop there unless you explicitly want another bounded pass. OpenCodex does not loop indefinitely or schedule a follow-up pass for you.

## Management API

Compaction is exposed only as a mutation endpoint:

```text
POST /api/storage/codex-logs/compact
```

There is no GET alias for compaction. A successful response contains a `report` object with before/after measurements, reclaimed page counts, physical main-database size change, iteration count, completeness, stop reason, and integrity status.

Typical refusal states include:

- `codex_running`
- `process_enumeration_failed`
- `busy`
- `unsupported_schema`
- `auto_vacuum_not_incremental`
- `unsafe_path`
- `integrity_check_failed`
- `database_error`

Integrity failures include whether they occurred before or after the maintenance pass. A `busy` refusal means contention was detected before any vacuum batch committed; `stopReason: "busy"` inside a successful report means at least one batch committed before later checkpoint contention stopped the pass.

## Understanding the result

`pagesReclaimed` and `logicalBytesReclaimed` describe SQLite freelist pages removed during the pass. `physicalDatabaseBytesReclaimed` reports the observed reduction in the main database file after the maintenance checkpoints.

Those numbers can differ. SQLite/WAL/filesystem behaviour means reclaiming logical pages does not guarantee an identical immediate physical-file reduction, and none of these metrics should be interpreted as NAND writes, SSD wear, or TBW consumed/saved.

`complete: true` means the observed freelist reached zero. A partial result uses `stopReason: "page_budget"` when either the per-run page budget or the finite iteration cap ends the pass, `stopReason: "no_progress"` when SQLite stops reducing the freelist, and `stopReason: "busy"` when checkpoint contention appears after committed reclamation. All three are bounded outcomes; none causes an automatic retry.
162 changes: 152 additions & 10 deletions gui/src/components/storage-workspace/StorageWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useMemo, useState } from "react";
import { IconChevron, IconHardDrive } from "../../icons";
import { useT, type TFn, type TKey, type Locale } from "../../i18n/shared";
import { logGuardLabel } from "../../i18n/log-guard-labels";
import { logGuardOperationLabel } from "../../i18n/log-guard-operation-labels";
import {
logGuardProtectionModeLabel,
logGuardProtectionStateLabel,
Expand Down Expand Up @@ -87,7 +88,8 @@ export interface StorageReport {
export type CodexLogGuardAction =
| { action: "protect"; mode: "compat" | "quiet" }
| { action: "unprotect" }
| { action: "repair" };
| { action: "repair" }
| { action: "compact" };

// Known scanner bucket keys -> localized labels; unknown future keys fall back to the API label.
const BUCKET_TKEYS: Record<string, TKey> = {
Expand Down Expand Up @@ -117,15 +119,22 @@ function rowsDisplay(bucket: StorageBucket, locale: Locale, t: TFn): string {

function mutationErrorLabel(locale: Locale, code: unknown): string {
switch (code) {
case "codex_running": return logGuardLabel(locale, "error.codex_running");
case "process_enumeration_failed": return logGuardLabel(locale, "error.process_enumeration_failed");
case "busy": return logGuardLabel(locale, "error.busy");
case "unsupported_schema": return logGuardLabel(locale, "error.unsupported_schema");
case "codex_running": return logGuardOperationLabel(locale, "error.codex_running");
case "process_enumeration_failed": return logGuardOperationLabel(locale, "error.process_enumeration_failed");
case "busy": return logGuardOperationLabel(locale, "error.busy");
case "unsupported_schema": return logGuardOperationLabel(locale, "error.unsupported_schema");
case "trigger_collision": return logGuardLabel(locale, "error.trigger_collision");
case "unsafe_path": return logGuardLabel(locale, "error.unsafe_path");
case "database_error": return logGuardLabel(locale, "error.database_error");
case "unsafe_path": return logGuardOperationLabel(locale, "error.unsafe_path");
case "database_error": return logGuardOperationLabel(locale, "error.database_error");
// Two distinct situations that previously collapsed into the generic
// message: an unsupported auto-vacuum configuration (nothing can be
// reclaimed without a full rebuild) versus a failed integrity check
// (the database itself is suspect). A user cannot act on "something
// went wrong".
case "auto_vacuum_not_incremental": return logGuardOperationLabel(locale, "error.auto_vacuum_not_incremental");
case "integrity_check_failed": return logGuardOperationLabel(locale, "error.integrity_check_failed");
case "config_write_failed": return logGuardLabel(locale, "error.config_write_failed");
default: return logGuardLabel(locale, "error.generic");
default: return logGuardOperationLabel(locale, "error.generic");
}
}

Expand All @@ -135,20 +144,27 @@ function CodexLogGuardPanel({
t,
busy,
error,
compaction,
onAction,
}: {
report: CodexLogGuardReport;
locale: Locale;
t: TFn;
busy: boolean;
error: string | null;
/** Outcome of the last compaction POST, retained independently of the status refresh. */
compaction: string | null;
onAction: (action: CodexLogGuardAction) => void;
}) {
const metrics = report.metrics;
const inspectOnly = report.capabilities.protection.state === "unsupported"
|| report.capabilities.reclaim.state === "unsupported";
const protection = report.protection;
const mutationDisabled = busy || report.capabilities.protection.state !== "supported";
const reclaimAvailable = protection !== undefined
&& report.capabilities.reclaim.state === "supported"
&& (metrics?.reclaimableBytes ?? 0) > 0;
const [confirmCompact, setConfirmCompact] = useState(false);

return (
<div className="stw-section" data-testid="codex-log-guard">
Expand Down Expand Up @@ -248,12 +264,64 @@ function CodexLogGuardPanel({
{logGuardLabel(locale, "repair")}
</button>
)}
{busy && <span className="muted" role="status">{logGuardLabel(locale, "applying")}</span>}
{busy && <span className="muted" role="status">{logGuardOperationLabel(locale, "applying")}</span>}
</div>
{error && <p className="err" role="alert">{error}</p>}
</div>
)}

{reclaimAvailable && (
<div className="stw-section" data-testid="log-guard-reclaim">
<div className="storage-policy-actions">
{!confirmCompact ? (
<button
type="button"
className="btn btn-ghost btn-sm"
data-testid="log-guard-compact"
disabled={busy}
onClick={() => setConfirmCompact(true)}
>
{logGuardLabel(locale, "compact")}
</button>
) : (
<>
<button
type="button"
className="btn btn-sm"
data-testid="log-guard-compact-confirm"
disabled={busy}
onClick={() => {
setConfirmCompact(false);
onAction({ action: "compact" });
}}
>
{logGuardLabel(locale, "confirmCompact")}
</button>
<button
type="button"
className="btn btn-ghost btn-sm"
disabled={busy}
onClick={() => setConfirmCompact(false)}
>
{logGuardLabel(locale, "cancel")}
</button>
</>
)}
</div>
</div>
)}

{compaction && (
// Rendered OUTSIDE `reclaimAvailable` on purpose. A fully successful
// compaction drives reclaimableBytes to 0, which retires the reclaim
// section — so nesting the result inside it hid the outcome in exactly
// the best case. The POST's own result is also shown when the follow-up
// status refresh fails, since the mutation had already succeeded.
<div className="stw-section">
<p className="stw-kv-mono" role="status" data-testid="log-guard-compact-result">{compaction}</p>
</div>
)}

{metrics && metrics.topTargets.length > 0 && (
<div className="stw-section">
<h4 className="stw-section-title"><code>target</code></h4>
Expand Down Expand Up @@ -296,6 +364,17 @@ type GenerationScopedError = {
message: string;
};

/**
* The compaction POST's own report. Kept separately from the periodic status
* refresh: the mutation already succeeded, so its outcome must survive even if
* the follow-up GET fails. Without this, a successful compact whose refresh
* failed showed the user nothing and invited them to run it again.
*/
type GenerationScopedCompaction = {
generation: number;
summary: string;
};

export default function StorageWorkspace({
report,
locale,
Expand All @@ -307,6 +386,7 @@ export default function StorageWorkspace({
const [logGuardOverride, setLogGuardOverride] = useState<GenerationScopedLogGuardReport | null>(null);
const [internalLogGuardBusy, setInternalLogGuardBusy] = useState(false);
const [logGuardError, setLogGuardError] = useState<GenerationScopedError | null>(null);
const [logGuardCompaction, setLogGuardCompaction] = useState<GenerationScopedCompaction | null>(null);

const sortedBuckets = useMemo(
() => report.buckets.toSorted((a, b) => b.bytes - a.bytes),
Expand All @@ -319,6 +399,9 @@ export default function StorageWorkspace({
const displayedLogGuardError = logGuardError?.generation === report.generatedAt
? logGuardError.message
: null;
const displayedCompaction = logGuardCompaction?.generation === report.generatedAt
? logGuardCompaction.summary
: null;
const effectiveLogGuardBusy = logGuardBusy || internalLogGuardBusy;

const largestAcross = useMemo(() => {
Expand All @@ -344,6 +427,10 @@ export default function StorageWorkspace({
void (async () => {
setInternalLogGuardBusy(true);
setLogGuardError(null);
// A new attempt invalidates the previous receipt. Leaving it up meant a
// failed retry could render a stale success alongside the current error,
// so the section misreported the latest attempt.
if (action.action === "compact") setLogGuardCompaction(null);
try {
const suffix = action.action === "protect" ? "protect" : action.action;
const init: RequestInit = {
Expand All @@ -359,10 +446,64 @@ export default function StorageWorkspace({
setLogGuardError({ generation, message: mutationErrorLabel(locale, errorPayload.error) });
return;
}
if (action.action === "compact") {
// Read the POST's own report FIRST. It is the authoritative record of
// what this mutation reclaimed; discarding it meant a successful
// compaction whose refresh then failed showed the user nothing at all,
// hiding pagesReclaimed/complete/stopReason and inviting them to
// repeat a mutation that already worked.
const posted = await response.json().catch(() => null) as {
report?: {
pagesReclaimed?: number;
logicalBytesReclaimed?: number;
physicalDatabaseBytesReclaimed?: number;
complete?: boolean;
stopReason?: string;
};
} | null;
const postedReport = posted?.report;
if (postedReport) {
// Both figures are shown because they answer different questions: an
// incremental vacuum can return pages to the free list without the
// file shrinking, so "logical > 0, physical 0" is normal progress
// rather than a failed run.
const logical = formatBytes(postedReport.logicalBytesReclaimed ?? 0, locale);
const physical = formatBytes(postedReport.physicalDatabaseBytesReclaimed ?? 0, locale);
const state = postedReport.complete
? logGuardLabel(locale, "compactComplete")
: logGuardLabel(locale, "compactPartial");
// stopReason is what distinguishes page_budget from no_progress from
// busy, and pagesReclaimed is the unit the budget is actually spent
// in. Reporting only bytes left a partial run indistinguishable from
// any other partial run.
const pages = postedReport.pagesReclaimed ?? 0;
const reason = !postedReport.complete && postedReport.stopReason
? ` (${postedReport.stopReason})`
: "";
setLogGuardCompaction({
generation,
summary: [
`${state}${reason}`,
`${pages.toLocaleString(locale)} ${logGuardLabel(locale, "pagesUnit")}`,
`${logical} / ${physical}`,
].join(" — "),
});
}
// The mutation has already succeeded. Refresh is deliberately best effort so
// a transient GET/JSON failure cannot be presented as a failed compaction.
try {
const refreshed = await fetch(`${API_BASE}/api/storage/codex-logs`);
if (refreshed.ok) {
const payload = await refreshed.json() as CodexLogGuardReport;
setLogGuardOverride({ generation, report: payload });
}
} catch { /* keep the existing report after successful compaction */ }
return;
Comment thread
Wibias marked this conversation as resolved.
}
const payload = await response.json() as CodexLogGuardReport;
setLogGuardOverride({ generation, report: payload });
} catch {
setLogGuardError({ generation, message: logGuardLabel(locale, "error.generic") });
setLogGuardError({ generation, message: logGuardOperationLabel(locale, "error.generic") });
} finally {
setInternalLogGuardBusy(false);
}
Expand Down Expand Up @@ -472,6 +613,7 @@ export default function StorageWorkspace({
t={t}
busy={effectiveLogGuardBusy}
error={displayedLogGuardError}
compaction={displayedCompaction}
onAction={runLogGuardAction}
/>
) : report.codexLogsError === "inspect_failed" ? (
Expand Down
Loading
Loading