Skip to content

feat(audit): user-initiated cleanup for old audit data (15/30/90 days) with orphan sweep and typed-confirmation gate#1852

Closed
Dani Akash (DaniAkash) wants to merge 5 commits into
mainfrom
feat/audit-cleanup
Closed

feat(audit): user-initiated cleanup for old audit data (15/30/90 days) with orphan sweep and typed-confirmation gate#1852
Dani Akash (DaniAkash) wants to merge 5 commits into
mainfrom
feat/audit-cleanup

Conversation

@DaniAkash

Copy link
Copy Markdown
Contributor

What's new

An in-cockpit way to reclaim disk and prune the BrowserClaw audit log. Deletes sessions, replay files, and screenshots older than one of three fixed thresholds (15, 30, 90 days). A dedicated Storage button appears in the /audit header when there is data old enough to matter, and hides itself completely otherwise. Behind the button, a two-stage AlertDialog with a typed-confirmation gate protects the destructive call.

Design

Definition of "old"

A session is old when max(dispatch.created_at) < now - N days. Last-activity based, not session-start based: a long-lived session with a fresh straggler dispatch survives even if it started 100 days ago. Prevents the classic "old start ⇒ delete" bug.

Two endpoints

  • GET /audit/cleanup/candidates returns per-threshold session/dispatch/bytes counts. Cheap enough for the client to poll on a 30s interval so options appear/disappear naturally as data ages past a threshold.
  • POST /audit/cleanup accepts { olderThanDays: 15 | 30 | 90 }. Zod union derived from the shared constant array so adding a new threshold is one-line.

Deletion algorithm

  1. Compute sessionIds and dispatchIds BEFORE mutating anything.
  2. Single db.transaction() deletes from tool_dispatches, agent_session_starts, and agent_session_ends atomically.
  3. Best-effort file unlinks (replays + screenshots) AFTER commit. If a file unlink fails the row stays deleted; the worst-case outcome is orphaned files (harmless), not visible-but-broken sessions in the UI.
  4. Orphan sweep (see below) runs at the tail.

Orphan sweep (separate commit)

Every cleanup call finishes with, and server startup runs (deferred 30s), a sweep that unlinks replay + screenshot files whose id has no row in the DB. Catches partial-failure drift and crashed writers.

Safety guards:

  • In-flight guard: skips files younger than 5 minutes. Protects fresh writes whose DB row hasn't flushed yet.
  • Strict filename filter: only *.ndjson (replays) or integer-basename *.jpg (screenshots). Foreign files in the directories are left alone.
  • DB-first snapshot: known ids read before scanning disk to avoid TOCTOU where a row appears mid-scan with a newborn file.
  • MAX_SWEEP_ENTRIES = 100_000 cap per directory. Pathological cases log a warning and truncate.
  • Best-effort throughout: readdir/stat/unlink failures logged, never throw.

Typed-confirmation gate (Stage 2)

The Delete button will not unlock until the user types `delete N sessions older than D days` verbatim. Case-sensitive exact match, whitespace trimmed. The phrase mirrors the read-back line above the input and rebuilds when the range changes so muscle memory from a prior cleanup cannot unlock a new one. Same UX pattern as GitHub repo delete / Stripe account delete / Vercel project delete.

Failure clears the typed input so hitting Enter cannot silently retry.

Commits (four)

  1. feat(claw-server): age-based audit cleanup service + route + tests (`125650f90`) — the base feature: shared constant, service, route, 8 age-based tests + 4 route tests.
  2. feat(claw-server): orphan file sweep bundled into cleanup + startup pass (`b746cd324`) — the sweep, its startup wiring, and 10 orphan-sweep tests. Purely additive on top of commit 1: revertable in isolation.
  3. test(claw-server): concurrent-write survives cleanup transaction (`96966e878`) — documents the accepted concurrency behaviour of a dispatch arriving during cleanup (bun:sqlite WAL serialises it, session resurrects as zombie with only the fresh row).
  4. feat(claw-app): audit cleanup dialog with typed-confirmation gate (`8583ba1e4`) — client hooks, two-stage dialog, phrase-gate helpers, Storage button in /audit header, 15 helper tests + 3 visibility tests.

Test plan

  • bun run typecheck (claw-server + claw-app): clean
  • bunx biome check on all touched files: clean
  • bun test (claw-server): 423 pass, 0 fail (was 399 before; +24 new tests across 3 files)
  • bun test (claw-app): each file individually passes (18/18 across new files); full suite hits the bun parse-race flake per ci(tests): retry each bun test suite once on failure #1851 which passes on rerun
  • Manual: cleanup with the typed phrase works end-to-end via curl against a live dev server
  • Manual: dialog interactions in a running BrowserClaw
  • CI green

Adds a user-initiated cleanup path for old BrowserClaw audit data.
Three fixed thresholds (15/30/90 days) live in a new shared constant
so the server route validation and client dialog options stay in
lockstep.

Service (services/audit-cleanup.ts):
- listCandidates() returns per-threshold session/dispatch/bytes counts;
  cheap enough for the client to poll on a 30s interval.
- cleanupOlderThan(days) deletes every session whose latest dispatch
  is older than the cutoff. 'Old' is last-activity based so a
  long-lived session with a fresh straggler dispatch survives.
- Transaction spans all three audit tables. File unlinks run AFTER
  commit and are best-effort; the worst-case outcome is orphaned
  files (harmless), not visible-but-broken rows.

Route (routes/audit/cleanup.ts):
- GET /audit/cleanup/candidates
- POST /audit/cleanup with olderThanDays in {15, 30, 90}
- Schema derives its literal union from the shared constant.

Also exposes ReplayStorage.pathFor(sessionId) so the cleanup service
can size-check files without reimplementing the sanitise+join logic
that lives inside replay-storage.

Tests: 8 service tests (age-based semantics, last-activity guard,
file unlink, idempotency, empty DB, candidates-match-cleanup, all
three tables deleted together) + 4 route tests (candidates shape,
happy path, threshold rejection, missing body). Full claw-server
suite: 412 pass, 0 fail.
Every cleanupOlderThan() now finishes with an orphan sweep that
unlinks replay + screenshot files whose id no longer exists in the
audit DB. Also runs once on server startup (deferred 30s so it never
delays the socket going live). Catches partial-failure drift, crashed
writers, and prior manual DB edits.

Safety guards:
- In-flight guard: skips files younger than 5 minutes. Protects fresh
  writes whose DB row has not flushed yet.
- Strict filename filter: only *.ndjson (replays) or integer-basename
  *.jpg (screenshots). Foreign files in either directory are left
  alone, not deleted.
- DB-first snapshot: known ids read before scanning disk so rows that
  appear mid-scan (with newborn files) don't get nuked.
- MAX_SWEEP_ENTRIES cap of 100k per directory. Pathological cases log
  a warning and truncate instead of hanging the request.
- Best-effort throughout: readdir/stat/unlink failures logged, never
  throw.

Response shape gains an 'orphans' block so age-based and drift-based
counts stay distinguishable in the log and the UI totals.

Also exports REPLAY_DIR_NAME / REPLAY_FILE_EXTENSION and
SCREENSHOTS_DIR_NAME / SCREENSHOT_FILE_EXTENSION so the sweep and the
writers share one source of truth for directory + extension.

Tests: 10 orphan tests covering all guards plus integration with the
bundled cleanup call. Full claw-server suite: 422 pass, 0 fail.
Documents the accepted concurrency behaviour: a dispatch that lands
after cleanupOlderThan has computed its target set but before the
transaction commits is serialised behind the cleanup by bun:sqlite's
single-writer WAL, ends up in tool_dispatches AFTER the deletes, and
resurrects the doomed session as a zombie with only the fresh row
and no start/end.

Kept as its own commit so the semantic (deliberate) can be reverted
without touching the age-based service tests.
Adds a Storage button to the /audit header that opens a two-stage
shadcn AlertDialog for the destructive /audit/cleanup endpoint.

Stage 1 lets the user pick a threshold (15/30/90 days). Only ranges
with at least one session appear as options, per Dani's spec; if none
of the three has any candidates, the whole button is hidden.

Stage 2 is a typed-confirmation gate: the user has to type
'delete N sessions older than D days' verbatim before the destructive
Delete button unlocks. Case-sensitive exact match. The phrase mirrors
the read-back line above the input and rebuilds when the range
changes so muscle memory from a prior cleanup cannot unlock a new
one. Same UX as GitHub repo delete / Stripe account delete.

On success: the mutation invalidates useTasks + useAuditCleanupCandidates
so the audit list, empty state, and Storage button all refetch on the
same tick. On failure: dialog stays on Stage 2 with the typed input
cleared so hitting Enter cannot silently retry.

Client hooks (useAuditCleanupCandidates + useAuditCleanup) live in
modules/api/audit.hooks.ts alongside the existing useTasks factories,
per the react-query-kit rules in CLAUDE.md.

Tests: 15 helper tests (phrase builder + case-sensitive matcher +
byte formatter) + 3 button-visibility tests. The interactive
two-stage flow is exercised by the pure-logic helpers plus the
server-side cleanup tests.
@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

✅ Tests passed — 1352/1356

Suite Passed Failed Skipped
agent 301/301 0 0
build 40/40 0 0
⚠️ claw-app 0/0 0 0
⚠️ claw-onboard 0/0 0 0
⚠️ claw-server 0/0 0 0
server-agent 301/301 0 0
server-api 133/133 0 0
server-browser 10/10 0 0
server-integration 10/10 0 0
server-lib 265/266 0 1
server-root 38/41 0 3
server-tools 254/254 0 0

View workflow run

@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds user-initiated audit data cleanup with three age-based thresholds (15/30/90 days), an orphan file sweep bundled into every cleanup call and run at startup, and a two-stage client dialog with a typed-confirmation gate that only unlocks after the exact phrase is entered. The implementation is well-layered, thoroughly tested (+24 tests), and handles all documented failure modes gracefully.

  • Server: audit-cleanup.ts provides listCandidates, cleanupOlderThan, and sweepOrphanFiles. Transaction-first / best-effort-file-unlink-second ordering is correct.
  • Client: CleanupButton self-hides when every range has sessionCount === 0; CleanupDialog protects Stage 2 progress from the 30-second candidates poll via a prevOpenRef guard.
  • Shared: AUDIT_CLEANUP_THRESHOLD_DAYS as a single const keeps the server Zod schema and client dialog options in sync.

Confidence Score: 5/5

Safe to merge. The destructive path is transactional, file deletions are best-effort with orphan recovery, and the typed-confirmation gate correctly guards against accidental data loss.

All three audit tables are deleted atomically within a single SQLite transaction before any file I/O, the orphan sweep has layered safety guards (5-minute in-flight window, strict filename filters, DB-first snapshot), and the client-side dialog fix (prevOpenRef) prevents the 30-second poll from wiping Stage 2 progress. The findings are edge-case quality nits that do not affect correctness on the main path.

audit-cleanup.ts: the knownSessionIds source for the orphan sweep and the synchronous stat calls on the candidates polling path.

Important Files Changed

Filename Overview
packages/browseros-agent/apps/claw-server/src/services/audit-cleanup.ts Core cleanup service: age-based session deletion + orphan file sweep. Well-structured with safety guards, but orphan sweep derives known session IDs from tool_dispatches only, and runs synchronous stat calls per-session/dispatch on every candidates poll.
packages/browseros-agent/apps/claw-server/src/routes/audit/cleanup.ts Two-endpoint route. Zod union schema restricts olderThanDays to {15,30,90}, but the hardcoded tuple type-cast and misleading comment need attention when adding a new threshold.
packages/browseros-agent/apps/claw-app/components/audit/CleanupDialog.tsx Two-stage dialog with typed-confirmation gate. The prevOpenRef guard correctly prevents the 30s poll from wiping Stage 2 progress.
packages/browseros-agent/apps/claw-app/modules/api/audit.hooks.ts Adds cleanup candidates query (30s poll) and cleanup mutation with correct query invalidation on success.
packages/browseros-agent/apps/claw-server/src/services/audit-cleanup-startup.ts Startup scheduling shim. timer.unref() correctly prevents blocking shutdown.
packages/browseros-agent/packages/shared/src/constants/audit.ts Shared threshold constant and derived union type. Single source of truth for 15/30/90 thresholds.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant UI as CleanupButton
    participant Hook as useAuditCleanupCandidates
    participant CandidatesAPI as GET /audit/cleanup/candidates
    participant Dialog as CleanupDialog
    participant CleanupAPI as POST /audit/cleanup
    participant Service as audit-cleanup.ts
    participant DB as SQLite
    participant FS as File System

    loop every 30s
        Hook->>CandidatesAPI: poll
        CandidatesAPI->>Service: listCandidates()
        Service->>DB: eligibleSessionIds x3 thresholds
        Service->>FS: statSync per file (bytesOnDisk)
        CandidatesAPI-->>UI: ranges
        UI-->>UI: "hide if all sessionCount===0"
    end

    UI->>Dialog: open Stage 1
    Dialog-->>Dialog: user picks threshold
    Dialog-->>Dialog: Stage 2 typed-confirmation
    Dialog->>CleanupAPI: POST olderThanDays
    CleanupAPI->>Service: cleanupOlderThan(days)
    Service->>DB: eligibleSessionIds(cutoff)
    Service->>DB: transaction delete rows
    Service->>FS: unlink files (best-effort)
    Service->>Service: sweepOrphanFiles()
    Service->>DB: snapshot known IDs
    Service->>FS: "unlink orphans >5min old"
    CleanupAPI-->>Dialog: CleanupResult
    Dialog->>Hook: invalidateQueries
    Dialog-->>UI: close
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant UI as CleanupButton
    participant Hook as useAuditCleanupCandidates
    participant CandidatesAPI as GET /audit/cleanup/candidates
    participant Dialog as CleanupDialog
    participant CleanupAPI as POST /audit/cleanup
    participant Service as audit-cleanup.ts
    participant DB as SQLite
    participant FS as File System

    loop every 30s
        Hook->>CandidatesAPI: poll
        CandidatesAPI->>Service: listCandidates()
        Service->>DB: eligibleSessionIds x3 thresholds
        Service->>FS: statSync per file (bytesOnDisk)
        CandidatesAPI-->>UI: ranges
        UI-->>UI: "hide if all sessionCount===0"
    end

    UI->>Dialog: open Stage 1
    Dialog-->>Dialog: user picks threshold
    Dialog-->>Dialog: Stage 2 typed-confirmation
    Dialog->>CleanupAPI: POST olderThanDays
    CleanupAPI->>Service: cleanupOlderThan(days)
    Service->>DB: eligibleSessionIds(cutoff)
    Service->>DB: transaction delete rows
    Service->>FS: unlink files (best-effort)
    Service->>Service: sweepOrphanFiles()
    Service->>DB: snapshot known IDs
    Service->>FS: "unlink orphans >5min old"
    CleanupAPI-->>Dialog: CleanupResult
    Dialog->>Hook: invalidateQueries
    Dialog-->>UI: close
Loading

Reviews (2): Last reviewed commit: "fix(audit): guard dialog reset + drop de..." | Re-trigger Greptile

Comment thread packages/browseros-agent/apps/claw-server/src/services/audit-cleanup-startup.ts Outdated
Two Greptile catches from PR review:

1. CleanupDialog reset effect had [open, ranges] deps, so the 30s
   candidates poll (fresh array reference on every refetch) would
   collapse Stage 2 back to Stage 1 and clear the typed phrase on a
   user who took longer than 30 seconds to read + type the
   confirmation. Guard the reset on the false->true transition of
   'open' via a useRef so only the actual open event triggers it.

2. audit-cleanup-startup exported wasStartupSweepInvokedForTesting +
   resetStartupSweepForTesting labelled 'test-only', but the actual
   test uses runStartupSweep + a logger spy directly. Remove the dead
   probe helpers and the scheduledForTesting flag they backed.
@DaniAkash

Copy link
Copy Markdown
Contributor Author

Greptile (@greptileai)

@DaniAkash

Copy link
Copy Markdown
Contributor Author

Closing in favor of #1993.

The TS claw-server is being retired, so this feature was reimplemented in the Rust claw-server. It also evolved in the process: instead of a one-shot delete with fixed 15/30/90-day thresholds behind a typed-confirmation gate, #1993 ships a user-controlled retention policy ("automatically delete audit log older than 7 days / 30 days / custom / never") enforced by a background sweep, unified across audit metadata, screenshots, and recordings, with automatic disk reclamation via SQLite incremental vacuum, and a Manage audit files dialog showing current storage usage.

No functionality is lost — see #1993 for the replacement.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant