(response)
+ },
+ onSuccess: (_result, _vars, _ctx, { client }) => {
+ // Sessions and their totals just changed. Invalidate every audit
+ // surface so the list, empty state, and the Storage button all
+ // refetch on the same tick.
+ client.invalidateQueries({ queryKey: useTasks.getKey() })
+ client.invalidateQueries({ queryKey: useAuditCleanupCandidates.getKey() })
+ },
+})
diff --git a/packages/browseros-agent/apps/claw-app/screens/audit/Audit.tsx b/packages/browseros-agent/apps/claw-app/screens/audit/Audit.tsx
index 3978080ae5..f933f6a285 100644
--- a/packages/browseros-agent/apps/claw-app/screens/audit/Audit.tsx
+++ b/packages/browseros-agent/apps/claw-app/screens/audit/Audit.tsx
@@ -10,6 +10,7 @@ import { Fragment, useMemo, useState } from 'react'
import { useLocation, useNavigate } from 'react-router'
import { AuditEmpty } from '@/components/audit/AuditEmpty'
import { AuditHoverPreview } from '@/components/audit/AuditHoverPreview'
+import { CleanupButton } from '@/components/audit/CleanupButton'
import { FilterBar } from '@/components/audit/FilterBar'
import {
Table,
@@ -107,10 +108,11 @@ export function Audit() {
return (
-
+
{!isError && (tasks.length > 0 || hasActiveFilters) && (
diff --git a/packages/browseros-agent/apps/claw-server/src/main.ts b/packages/browseros-agent/apps/claw-server/src/main.ts
index 8e32d47528..0b295dab76 100644
--- a/packages/browseros-agent/apps/claw-server/src/main.ts
+++ b/packages/browseros-agent/apps/claw-server/src/main.ts
@@ -30,6 +30,7 @@ import { writeRuntimeFile } from './lib/runtime-file'
import { setLocalServerUrl } from './local-server-url'
import { createServer } from './server'
import { captureEvent, shutdownAnalytics } from './services/analytics'
+import { scheduleStartupOrphanSweep } from './services/audit-cleanup-startup'
import { runIntegrityScan } from './services/integrity-scan'
import { startScreencastPoller } from './services/screencast-poller'
import { publicMcpUrl } from './shared/mcp-url'
@@ -85,6 +86,12 @@ async function start(): Promise {
})
}
+ // Self-heal loop 2: sweep orphan replay + screenshot files whose DB
+ // row disappeared (partial cleanup failure, crashed writer). Deferred
+ // 30s so it never delays the socket going live; the sweep is
+ // best-effort and its own errors are swallowed.
+ scheduleStartupOrphanSweep()
+
// Attach to the BrowserOS Chromium so MCP `tools/call` dispatches
// hit a real browser. The bootstrap soft-fails when BrowserOS is
// not reachable: the cockpit keeps serving the UI, connection
diff --git a/packages/browseros-agent/apps/claw-server/src/routes/audit/cleanup.ts b/packages/browseros-agent/apps/claw-server/src/routes/audit/cleanup.ts
new file mode 100644
index 0000000000..bf83ee7591
--- /dev/null
+++ b/packages/browseros-agent/apps/claw-server/src/routes/audit/cleanup.ts
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * Audit cleanup routes. Two endpoints:
+ * GET /audit/cleanup/candidates cheap counts per threshold; UI polls
+ * this to know which options to offer
+ * POST /audit/cleanup destructive delete for one threshold
+ *
+ * The `olderThanDays` payload is restricted to the three shared
+ * thresholds (15/30/90) at the schema layer. Server never trusts a
+ * client-supplied cutoff; the UI's typed-confirmation gate is the
+ * final human-facing safety, not the API.
+ */
+
+import { AUDIT_CLEANUP_THRESHOLD_DAYS } from '@browseros/shared/constants/audit'
+import { zValidator } from '@hono/zod-validator'
+import { Hono } from 'hono'
+import { z } from 'zod'
+import { cleanupOlderThan, listCandidates } from '../../services/audit-cleanup'
+
+// z.union of literals from the shared constant. Adding a new threshold
+// to the shared array picks up here without touching this file, but the
+// tuple annotation is required so TS keeps the union tight instead of
+// widening to `z.ZodLiteral`.
+const thresholdSchema = z.union(
+ AUDIT_CLEANUP_THRESHOLD_DAYS.map((d: number) => z.literal(d)) as [
+ z.ZodLiteral<15>,
+ z.ZodLiteral<30>,
+ z.ZodLiteral<90>,
+ ],
+)
+
+const cleanupBodySchema = z.object({
+ olderThanDays: thresholdSchema,
+})
+
+export const auditCleanupRoute = new Hono()
+ .get('/audit/cleanup/candidates', (c) => c.json({ ranges: listCandidates() }))
+ .post('/audit/cleanup', zValidator('json', cleanupBodySchema), async (c) => {
+ const { olderThanDays } = c.req.valid('json')
+ const result = await cleanupOlderThan(olderThanDays)
+ return c.json(result)
+ })
diff --git a/packages/browseros-agent/apps/claw-server/src/server.ts b/packages/browseros-agent/apps/claw-server/src/server.ts
index c39dd75a30..8f7de80caf 100644
--- a/packages/browseros-agent/apps/claw-server/src/server.ts
+++ b/packages/browseros-agent/apps/claw-server/src/server.ts
@@ -20,6 +20,7 @@ import { HttpError } from './lib/errors'
import { logger } from './lib/logger'
import { agentsControlRoute } from './routes/agents-control'
import { auditRoute } from './routes/audit'
+import { auditCleanupRoute } from './routes/audit/cleanup'
import { auditScreenshotsRoute } from './routes/audit/screenshots'
import { auditTasksRoute } from './routes/audit/tasks'
import { auditReplayRoute } from './routes/audit-replay'
@@ -111,6 +112,7 @@ export function createServer(options: CreateServerOptions = {}) {
.route('/', connectionsRoute)
.route('/', auditRoute)
.route('/', auditTasksRoute)
+ .route('/', auditCleanupRoute)
.route('/', auditScreenshotsRoute)
.route('/', auditReplayRoute)
.route('/', replayTabsRoute)
diff --git a/packages/browseros-agent/apps/claw-server/src/services/audit-cleanup-startup.ts b/packages/browseros-agent/apps/claw-server/src/services/audit-cleanup-startup.ts
new file mode 100644
index 0000000000..259d213476
--- /dev/null
+++ b/packages/browseros-agent/apps/claw-server/src/services/audit-cleanup-startup.ts
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * Startup-side entry to the orphan sweep. Deferred so the sweep never
+ * delays the HTTP socket going live: main.ts calls this AFTER the
+ * server is listening, and the actual sweep runs after a short timer.
+ * Split out from audit-cleanup.ts so the startup scheduling can be
+ * mocked in tests without touching the sweep implementation.
+ */
+
+import { logger } from '../lib/logger'
+import { sweepOrphanFiles } from './audit-cleanup'
+
+/** Ms to wait after boot before running the first orphan sweep. */
+const STARTUP_SWEEP_DELAY_MS = 30_000
+
+export function scheduleStartupOrphanSweep(): void {
+ const timer = setTimeout(() => {
+ void runStartupSweep()
+ }, STARTUP_SWEEP_DELAY_MS)
+ // Do not keep the process alive just for this timer; if the server
+ // is shutting down before the sweep fires, we skip this cycle.
+ timer.unref?.()
+}
+
+/** Exported for testing. Runs the sweep immediately, no delay. */
+export async function runStartupSweep(): Promise {
+ try {
+ const result = await sweepOrphanFiles()
+ logger.info('orphan sweep', {
+ replayFilesDeleted: result.replayFilesDeleted,
+ screenshotFilesDeleted: result.screenshotFilesDeleted,
+ bytesFreed: result.bytesFreed,
+ source: 'startup',
+ })
+ } catch (err) {
+ // Best-effort maintenance pass; never bubble up.
+ logger.warn('orphan sweep failed', {
+ error: err instanceof Error ? err.message : String(err),
+ })
+ }
+}
diff --git a/packages/browseros-agent/apps/claw-server/src/services/audit-cleanup.ts b/packages/browseros-agent/apps/claw-server/src/services/audit-cleanup.ts
new file mode 100644
index 0000000000..47128f9da1
--- /dev/null
+++ b/packages/browseros-agent/apps/claw-server/src/services/audit-cleanup.ts
@@ -0,0 +1,433 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * User-initiated cleanup of old audit data (SQLite rows + replay files +
+ * screenshot files). Three fixed thresholds (15/30/90 days) so the UI
+ * copy is stable and the SQL is trivial. "Old" is last-activity based:
+ * a session with any dispatch newer than the cutoff survives, even if
+ * it started long before. That way a resumed / long-running session is
+ * never nuked mid-life.
+ *
+ * SQL is transactional across all three audit tables. File deletions
+ * run AFTER commit and are best-effort. Ordering choice: SQL first
+ * means the worst-case failure is orphaned files (harmless, cleaned by
+ * the orphan sweep next time), not visible-but-broken sessions in the
+ * audit UI.
+ */
+
+import { readdirSync, statSync } from 'node:fs'
+import { unlink } from 'node:fs/promises'
+import {
+ AUDIT_CLEANUP_THRESHOLD_DAYS,
+ type AuditCleanupThresholdDays,
+} from '@browseros/shared/constants/audit'
+import { inArray, lt, sql } from 'drizzle-orm'
+import { resolveClawServerPath } from '../lib/browserclaw-dir'
+import { logger } from '../lib/logger'
+import { getAuditDb } from '../modules/db/db'
+import {
+ agentSessionEnds,
+ agentSessionStarts,
+ toolDispatches,
+} from '../modules/db/schema/schema'
+import {
+ REPLAY_DIR_NAME,
+ REPLAY_FILE_EXTENSION,
+ replayStorage,
+} from './replay-storage'
+import {
+ SCREENSHOT_FILE_EXTENSION,
+ SCREENSHOTS_DIR_NAME,
+ screenshotPath,
+} from './screenshots'
+
+const MS_PER_DAY = 86_400_000
+
+/**
+ * Files younger than this are skipped by the orphan sweep. Protects
+ * fresh writes whose DB row has not flushed yet. 5 minutes is far
+ * longer than any realistic write-to-commit delay and short enough
+ * that drift stays bounded.
+ */
+const ORPHAN_MIN_AGE_MS = 5 * 60 * 1000
+
+/**
+ * Safety cap on the sweep. Pathological cases (corrupt writer,
+ * accidental `cp` of a huge dir) would otherwise scan a million-plus
+ * entries and hang the request. When the cap trips we log a warning
+ * and return what we processed so the operator sees partial progress
+ * instead of a hang.
+ */
+const MAX_SWEEP_ENTRIES = 100_000
+
+export interface CleanupCandidateStats {
+ olderThanDays: AuditCleanupThresholdDays
+ sessionCount: number
+ dispatchCount: number
+ bytesOnDisk: number
+}
+
+export interface OrphanSweepResult {
+ replayFilesDeleted: number
+ screenshotFilesDeleted: number
+ bytesFreed: number
+}
+
+export interface CleanupResult {
+ olderThanDays: AuditCleanupThresholdDays
+ sessionsDeleted: number
+ dispatchesDeleted: number
+ replayFilesDeleted: number
+ screenshotFilesDeleted: number
+ bytesFreed: number
+ orphans: OrphanSweepResult
+}
+
+/**
+ * Returns one row per configured threshold with the counts the UI needs
+ * to decide which options to offer. Ranges with `sessionCount === 0`
+ * are still returned so the UI just filters; the endpoint's job is to
+ * be predictable, not to hide fields.
+ */
+export function listCandidates(): CleanupCandidateStats[] {
+ return AUDIT_CLEANUP_THRESHOLD_DAYS.map((days) => candidatesFor(days))
+}
+
+/** Session/dispatch/byte counts for a single threshold. */
+export function candidatesFor(
+ days: AuditCleanupThresholdDays,
+): CleanupCandidateStats {
+ const cutoff = Date.now() - days * MS_PER_DAY
+ const sessionIds = eligibleSessionIds(cutoff)
+ if (sessionIds.length === 0) {
+ return {
+ olderThanDays: days,
+ sessionCount: 0,
+ dispatchCount: 0,
+ bytesOnDisk: 0,
+ }
+ }
+ const dispatchIds = dispatchIdsForSessions(sessionIds)
+ return {
+ olderThanDays: days,
+ sessionCount: sessionIds.length,
+ dispatchCount: dispatchIds.length,
+ bytesOnDisk:
+ sumReplayFileBytes(sessionIds) + sumScreenshotFileBytes(dispatchIds),
+ }
+}
+
+/**
+ * Deletes every session whose latest dispatch is older than `days` days.
+ * Transactional across the three audit tables. Files unlinked
+ * best-effort after commit; a partial file failure leaves orphans that
+ * the sweep picks up next time (or on server restart).
+ */
+export async function cleanupOlderThan(
+ days: AuditCleanupThresholdDays,
+): Promise {
+ const cutoff = Date.now() - days * MS_PER_DAY
+
+ // Phase 1: compute the target set BEFORE mutating anything.
+ const sessionIds = eligibleSessionIds(cutoff)
+ if (sessionIds.length === 0) {
+ // Even with nothing age-eligible, sweep orphans. Any file that
+ // drifted from the DB by other paths (crashed writer, prior
+ // partial failure) still gets picked up here.
+ const orphans = await sweepOrphanFiles()
+ return {
+ olderThanDays: days,
+ sessionsDeleted: 0,
+ dispatchesDeleted: 0,
+ replayFilesDeleted: 0,
+ screenshotFilesDeleted: 0,
+ bytesFreed: 0,
+ orphans,
+ }
+ }
+ const dispatchIds = dispatchIdsForSessions(sessionIds)
+
+ // Phase 2: single SQL transaction. All three tables together, or none.
+ const db = getAuditDb()
+ db.transaction((tx) => {
+ tx.delete(toolDispatches)
+ .where(inArray(toolDispatches.sessionId, sessionIds))
+ .run()
+ tx.delete(agentSessionStarts)
+ .where(inArray(agentSessionStarts.sessionId, sessionIds))
+ .run()
+ tx.delete(agentSessionEnds)
+ .where(inArray(agentSessionEnds.sessionId, sessionIds))
+ .run()
+ })
+
+ // Phase 3: best-effort file cleanup AFTER commit. If a file unlink
+ // fails, we count it and move on. An orphaned file is harmless
+ // (nothing references it) and safer than surfacing a broken row in
+ // the audit UI.
+ const replayResult = await unlinkReplayFiles(sessionIds)
+ const screenshotResult = await unlinkScreenshotFiles(dispatchIds)
+
+ // Phase 4: sweep any orphans left over from prior partial failures
+ // or crashed writers. Snapshot known ids AFTER our own deletes so
+ // rows we just removed are not treated as still-referenced.
+ const orphans = await sweepOrphanFiles()
+
+ return {
+ olderThanDays: days,
+ sessionsDeleted: sessionIds.length,
+ dispatchesDeleted: dispatchIds.length,
+ replayFilesDeleted: replayResult.deleted,
+ screenshotFilesDeleted: screenshotResult.deleted,
+ bytesFreed: replayResult.bytes + screenshotResult.bytes,
+ orphans,
+ }
+}
+
+/**
+ * Removes replay + screenshot files whose id no longer exists in the
+ * DB. Bundled into every cleanupOlderThan call and also invoked at
+ * server startup so partial failures don't drift over time.
+ *
+ * Safety guards:
+ * - Skips files younger than ORPHAN_MIN_AGE_MS (protects in-flight writes)
+ * - Strict filename filter: only *.ndjson (replays) or integer *.jpg (screenshots)
+ * - DB-first snapshot: known ids read before scanning disk to avoid TOCTOU
+ * - Best-effort throughout: readdir/stat/unlink failures logged, never throw
+ * - MAX_SWEEP_ENTRIES cap per directory
+ */
+export async function sweepOrphanFiles(): Promise {
+ const now = Date.now()
+ const db = getAuditDb()
+
+ const knownSessionIds = new Set(
+ db
+ .selectDistinct({ id: toolDispatches.sessionId })
+ .from(toolDispatches)
+ .all()
+ .map((r) => r.id),
+ )
+ const knownDispatchIds = new Set(
+ db
+ .select({ id: toolDispatches.id })
+ .from(toolDispatches)
+ .all()
+ .map((r) => r.id),
+ )
+
+ const replayRes = await sweepDir({
+ dir: resolveClawServerPath(REPLAY_DIR_NAME),
+ ext: REPLAY_FILE_EXTENSION,
+ now,
+ isReferenced: (basename) => knownSessionIds.has(basename),
+ label: 'replays',
+ })
+ const screenshotRes = await sweepDir({
+ dir: resolveClawServerPath(SCREENSHOTS_DIR_NAME),
+ ext: SCREENSHOT_FILE_EXTENSION,
+ now,
+ isReferenced: (basename) => {
+ const asInt = Number.parseInt(basename, 10)
+ if (!Number.isFinite(asInt) || `${asInt}` !== basename) {
+ // Non-integer basename is not something the writer produces;
+ // treat as "referenced" so we don't touch it. Foreign files
+ // in the screenshots dir are left alone rather than deleted.
+ return true
+ }
+ return knownDispatchIds.has(asInt)
+ },
+ label: 'screenshots',
+ })
+
+ return {
+ replayFilesDeleted: replayRes.deleted,
+ screenshotFilesDeleted: screenshotRes.deleted,
+ bytesFreed: replayRes.bytes + screenshotRes.bytes,
+ }
+}
+
+interface SweepDirArgs {
+ dir: string
+ ext: string
+ now: number
+ isReferenced: (basename: string) => boolean
+ label: string
+}
+
+async function sweepDir(
+ args: SweepDirArgs,
+): Promise<{ deleted: number; bytes: number }> {
+ let entries: string[]
+ try {
+ entries = readdirSync(args.dir)
+ } catch (err) {
+ if (!isMissingFile(err)) {
+ logger.warn('orphan sweep readdir failed', {
+ dir: args.dir,
+ error: err instanceof Error ? err.message : String(err),
+ })
+ }
+ return { deleted: 0, bytes: 0 }
+ }
+
+ let deleted = 0
+ let bytes = 0
+ let scanned = 0
+ let truncated = false
+ for (const entry of entries) {
+ scanned += 1
+ if (scanned > MAX_SWEEP_ENTRIES) {
+ truncated = true
+ break
+ }
+ if (!entry.endsWith(args.ext)) continue
+ const basename = entry.slice(0, -args.ext.length)
+ if (args.isReferenced(basename)) continue
+
+ const full = `${args.dir}/${entry}`
+ let size = 0
+ let mtimeMs = 0
+ try {
+ const st = statSync(full)
+ size = st.size
+ mtimeMs = st.mtimeMs
+ } catch {
+ // File vanished between readdir and stat, or unreadable. Skip.
+ continue
+ }
+ if (args.now - mtimeMs < ORPHAN_MIN_AGE_MS) continue // in-flight guard
+
+ try {
+ await unlink(full)
+ deleted += 1
+ bytes += size
+ } catch (err) {
+ if (isMissingFile(err)) continue
+ logger.warn('orphan sweep unlink failed', {
+ dir: args.dir,
+ entry,
+ error: err instanceof Error ? err.message : String(err),
+ })
+ }
+ }
+
+ if (truncated) {
+ logger.warn('orphan sweep truncated', {
+ dir: args.dir,
+ label: args.label,
+ cap: MAX_SWEEP_ENTRIES,
+ })
+ }
+
+ return { deleted, bytes }
+}
+
+function eligibleSessionIds(cutoff: number): string[] {
+ const db = getAuditDb()
+ const rows = db
+ .select({ sessionId: toolDispatches.sessionId })
+ .from(toolDispatches)
+ .groupBy(toolDispatches.sessionId)
+ .having(lt(sql`max(${toolDispatches.createdAt})`, cutoff))
+ .all()
+ return rows.map((r) => r.sessionId)
+}
+
+function dispatchIdsForSessions(sessionIds: string[]): number[] {
+ if (sessionIds.length === 0) return []
+ const db = getAuditDb()
+ const rows = db
+ .select({ id: toolDispatches.id })
+ .from(toolDispatches)
+ .where(inArray(toolDispatches.sessionId, sessionIds))
+ .all()
+ return rows.map((r) => r.id)
+}
+
+function sumReplayFileBytes(sessionIds: string[]): number {
+ let total = 0
+ for (const sessionId of sessionIds) {
+ total += safeFileSize(replayStorage.pathFor(sessionId))
+ }
+ return total
+}
+
+function sumScreenshotFileBytes(dispatchIds: number[]): number {
+ let total = 0
+ for (const id of dispatchIds) {
+ total += safeFileSize(screenshotPath(id))
+ }
+ return total
+}
+
+function safeFileSize(path: string): number {
+ try {
+ return statSync(path).size
+ } catch {
+ return 0
+ }
+}
+
+async function unlinkReplayFiles(
+ sessionIds: string[],
+): Promise<{ deleted: number; bytes: number }> {
+ let deleted = 0
+ let bytes = 0
+ for (const sessionId of sessionIds) {
+ const path = replayStorage.pathFor(sessionId)
+ const size = safeFileSize(path)
+ try {
+ await unlink(path)
+ deleted += 1
+ bytes += size
+ } catch (err) {
+ // Missing files count as already-deleted; any other IO error is
+ // logged and skipped so a single stubborn file does not fail the
+ // whole cleanup.
+ if (isMissingFile(err)) {
+ continue
+ }
+ logger.warn('replay unlink failed during audit cleanup', {
+ sessionId,
+ error: err instanceof Error ? err.message : String(err),
+ })
+ }
+ }
+ return { deleted, bytes }
+}
+
+async function unlinkScreenshotFiles(
+ dispatchIds: number[],
+): Promise<{ deleted: number; bytes: number }> {
+ let deleted = 0
+ let bytes = 0
+ for (const id of dispatchIds) {
+ const path = screenshotPath(id)
+ const size = safeFileSize(path)
+ try {
+ await unlink(path)
+ deleted += 1
+ bytes += size
+ } catch (err) {
+ if (isMissingFile(err)) {
+ continue
+ }
+ logger.warn('screenshot unlink failed during audit cleanup', {
+ dispatchId: id,
+ error: err instanceof Error ? err.message : String(err),
+ })
+ }
+ }
+ return { deleted, bytes }
+}
+
+function isMissingFile(err: unknown): boolean {
+ return (
+ typeof err === 'object' &&
+ err !== null &&
+ 'code' in err &&
+ (err as { code: string }).code === 'ENOENT'
+ )
+}
diff --git a/packages/browseros-agent/apps/claw-server/src/services/replay-storage.ts b/packages/browseros-agent/apps/claw-server/src/services/replay-storage.ts
index b2cfebec8b..a295f6323d 100644
--- a/packages/browseros-agent/apps/claw-server/src/services/replay-storage.ts
+++ b/packages/browseros-agent/apps/claw-server/src/services/replay-storage.ts
@@ -26,7 +26,8 @@ import { dirname } from 'node:path'
import { resolveClawServerPath } from '../lib/browserclaw-dir'
import { logger } from '../lib/logger'
-const REPLAY_DIR_NAME = 'replays'
+export const REPLAY_DIR_NAME = 'replays'
+export const REPLAY_FILE_EXTENSION = '.ndjson'
const MAX_OPEN_HANDLES = 50
const IDLE_HANDLE_MS = 30_000
@@ -43,6 +44,13 @@ export interface ReplayStorage {
readEvents(sessionId: string): Promise>
statSession(sessionId: string): Promise
deleteSession(sessionId: string): Promise
+ /**
+ * Resolves the on-disk NDJSON path for a session id (does not touch
+ * the file). Exposed so callers that need to size-check or sweep
+ * files without opening the append pipeline can do so without
+ * reimplementing the sanitise+join logic.
+ */
+ pathFor(sessionId: string): string
/** Test-only: forcibly close all open handles and drop the chain map. */
resetForTesting(): Promise
}
@@ -73,7 +81,7 @@ export function createReplayStorage(
function resolvePath(sessionId: string): string {
const sid = sanitiseSessionId(sessionId)
const root = opts.rootDir ?? resolveClawServerPath(REPLAY_DIR_NAME)
- return `${root.replace(/\/$/, '')}/${sid}.ndjson`
+ return `${root.replace(/\/$/, '')}/${sid}${REPLAY_FILE_EXTENSION}`
}
async function evictOldestIfNeeded(): Promise {
@@ -225,6 +233,9 @@ export function createReplayStorage(
await fh.close()
}
},
+ pathFor(sessionId) {
+ return resolvePath(sessionId)
+ },
async deleteSession(sessionId) {
await closeEntry(sessionId)
const path = resolvePath(sessionId)
diff --git a/packages/browseros-agent/apps/claw-server/src/services/screenshots.ts b/packages/browseros-agent/apps/claw-server/src/services/screenshots.ts
index 3beeedc6a5..acd62c2de7 100644
--- a/packages/browseros-agent/apps/claw-server/src/services/screenshots.ts
+++ b/packages/browseros-agent/apps/claw-server/src/services/screenshots.ts
@@ -46,8 +46,14 @@ import { logger } from '../lib/logger'
import { screencastCache } from './screencast-cache'
import { extractToolResultImageData } from './tool-result-image'
+export const SCREENSHOTS_DIR_NAME = 'screenshots'
+export const SCREENSHOT_FILE_EXTENSION = '.jpg'
+
export function screenshotPath(dispatchId: number): string {
- return resolveClawServerPath('screenshots', `${dispatchId}.jpg`)
+ return resolveClawServerPath(
+ SCREENSHOTS_DIR_NAME,
+ `${dispatchId}${SCREENSHOT_FILE_EXTENSION}`,
+ )
}
export interface PersistScreenshotInput {
diff --git a/packages/browseros-agent/apps/claw-server/tests/routes/audit/cleanup.routes.test.ts b/packages/browseros-agent/apps/claw-server/tests/routes/audit/cleanup.routes.test.ts
new file mode 100644
index 0000000000..0268f80290
--- /dev/null
+++ b/packages/browseros-agent/apps/claw-server/tests/routes/audit/cleanup.routes.test.ts
@@ -0,0 +1,120 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * HTTP surface tests for /audit/cleanup*. Verifies the zod schema
+ * rejects out-of-set thresholds and that valid requests round-trip
+ * through the service layer with the expected response shape.
+ */
+
+import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
+import { eq } from 'drizzle-orm'
+import {
+ getAuditDb,
+ resetAuditDbForTesting,
+ setAuditDbForTesting,
+} from '../../../src/modules/db/db'
+import { toolDispatches } from '../../../src/modules/db/schema/schema'
+import app from '../../../src/server'
+import { recordToolDispatch } from '../../../src/services/audit-log'
+import { recordSessionStart } from '../../../src/services/session-events'
+import { withTempBrowserClawDir } from '../../_helpers/temp-browserclaw-dir'
+
+const MS_PER_DAY = 86_400_000
+
+function seed(sessionId: string): void {
+ recordSessionStart({
+ sessionId,
+ agentId: 'claude-code',
+ slug: 'claude-code',
+ agentLabel: 'Claude Code',
+ clientName: 'claude-code',
+ clientVersion: '0.0.0',
+ })
+ recordToolDispatch({
+ agentId: 'claude-code',
+ slug: 'claude-code',
+ agentLabel: 'Claude Code',
+ sessionId,
+ toolName: 'act',
+ pageId: 1,
+ targetId: null,
+ url: null,
+ title: null,
+ rawArgs: {},
+ durationMs: 5,
+ result: {
+ isError: false,
+ structuredContent: {},
+ content: [{ type: 'text', text: 'ok' }],
+ },
+ })
+}
+
+function backdate(sessionId: string, daysOld: number): void {
+ const target = Date.now() - daysOld * MS_PER_DAY
+ getAuditDb()
+ .update(toolDispatches)
+ .set({ createdAt: target })
+ .where(eq(toolDispatches.sessionId, sessionId))
+ .run()
+}
+
+describe('/audit/cleanup routes', () => {
+ beforeEach(() => setAuditDbForTesting())
+ afterEach(() => resetAuditDbForTesting())
+
+ it('GET /audit/cleanup/candidates returns three ranges', async () => {
+ await withTempBrowserClawDir(async () => {
+ const res = await app.request('/audit/cleanup/candidates')
+ expect(res.status).toBe(200)
+ const body = (await res.json()) as {
+ ranges: { olderThanDays: number; sessionCount: number }[]
+ }
+ expect(body.ranges.map((r) => r.olderThanDays)).toEqual([15, 30, 90])
+ })
+ })
+
+ it('POST /audit/cleanup deletes eligible sessions and returns counts', async () => {
+ await withTempBrowserClawDir(async () => {
+ seed('s-old')
+ backdate('s-old', 100)
+ seed('s-fresh')
+ backdate('s-fresh', 5)
+
+ const res = await app.request('/audit/cleanup', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ olderThanDays: 15 }),
+ })
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.olderThanDays).toBe(15)
+ expect(body.sessionsDeleted).toBe(1)
+ expect(body.dispatchesDeleted).toBe(1)
+ })
+ })
+
+ it('POST /audit/cleanup rejects thresholds outside {15,30,90}', async () => {
+ await withTempBrowserClawDir(async () => {
+ const res = await app.request('/audit/cleanup', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ olderThanDays: 7 }),
+ })
+ expect(res.status).toBe(400)
+ })
+ })
+
+ it('POST /audit/cleanup rejects a missing body', async () => {
+ await withTempBrowserClawDir(async () => {
+ const res = await app.request('/audit/cleanup', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: '{}',
+ })
+ expect(res.status).toBe(400)
+ })
+ })
+})
diff --git a/packages/browseros-agent/apps/claw-server/tests/services/audit-cleanup.orphans.test.ts b/packages/browseros-agent/apps/claw-server/tests/services/audit-cleanup.orphans.test.ts
new file mode 100644
index 0000000000..bde87454e8
--- /dev/null
+++ b/packages/browseros-agent/apps/claw-server/tests/services/audit-cleanup.orphans.test.ts
@@ -0,0 +1,261 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * Orphan sweep tests. The sweep is bundled into cleanupOlderThan()
+ * and also runs on a delayed startup pass. Tests seed files with
+ * controlled mtimes so the 5-minute in-flight guard fires
+ * deterministically.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'
+import { existsSync, mkdirSync, utimesSync, writeFileSync } from 'node:fs'
+import { eq } from 'drizzle-orm'
+import { resolveClawServerPath } from '../../src/lib/browserclaw-dir'
+import { logger } from '../../src/lib/logger'
+import {
+ getAuditDb,
+ resetAuditDbForTesting,
+ setAuditDbForTesting,
+} from '../../src/modules/db/db'
+import { toolDispatches } from '../../src/modules/db/schema/schema'
+import {
+ cleanupOlderThan,
+ sweepOrphanFiles,
+} from '../../src/services/audit-cleanup'
+import { runStartupSweep } from '../../src/services/audit-cleanup-startup'
+import { recordToolDispatch } from '../../src/services/audit-log'
+import { screenshotPath } from '../../src/services/screenshots'
+import { recordSessionStart } from '../../src/services/session-events'
+import { withTempBrowserClawDir } from '../_helpers/temp-browserclaw-dir'
+
+const MS_PER_DAY = 86_400_000
+const TEN_MIN_AGO_SEC = Math.floor((Date.now() - 10 * 60 * 1000) / 1000)
+const RECENT_SEC = Math.floor(Date.now() / 1000)
+
+function writeReplayFile(sessionId: string, mtimeSec: number): string {
+ const dir = resolveClawServerPath('replays')
+ mkdirSync(dir, { recursive: true })
+ const path = `${dir}/${sessionId}.ndjson`
+ writeFileSync(path, `{"ts":${Date.now()},"type":4}\n`)
+ utimesSync(path, mtimeSec, mtimeSec)
+ return path
+}
+
+function writeScreenshotFile(dispatchId: number, mtimeSec: number): string {
+ const path = screenshotPath(dispatchId)
+ mkdirSync(path.split('/').slice(0, -1).join('/'), { recursive: true })
+ writeFileSync(path, Buffer.from([0xff, 0xd8, 0xff, 0xd9]))
+ utimesSync(path, mtimeSec, mtimeSec)
+ return path
+}
+
+function writeArbitraryFile(
+ dir: string,
+ name: string,
+ mtimeSec: number,
+): string {
+ const full = resolveClawServerPath(dir, name)
+ mkdirSync(resolveClawServerPath(dir), { recursive: true })
+ writeFileSync(full, 'garbage')
+ utimesSync(full, mtimeSec, mtimeSec)
+ return full
+}
+
+function seedSession(sessionId: string, dispatchTool = 'act'): number | null {
+ recordSessionStart({
+ sessionId,
+ agentId: 'claude-code',
+ slug: 'claude-code',
+ agentLabel: 'Claude Code',
+ clientName: 'claude-code',
+ clientVersion: '0.0.0',
+ })
+ return recordToolDispatch({
+ agentId: 'claude-code',
+ slug: 'claude-code',
+ agentLabel: 'Claude Code',
+ sessionId,
+ toolName: dispatchTool,
+ pageId: 1,
+ targetId: null,
+ url: null,
+ title: null,
+ rawArgs: {},
+ durationMs: 5,
+ result: {
+ isError: false,
+ structuredContent: {},
+ content: [{ type: 'text', text: 'ok' }],
+ },
+ })
+}
+
+describe('audit-cleanup orphan sweep', () => {
+ beforeEach(() => setAuditDbForTesting())
+ afterEach(() => resetAuditDbForTesting())
+
+ it('unlinks a replay file whose session id has no DB row', async () => {
+ await withTempBrowserClawDir(async () => {
+ const orphan = writeReplayFile('phantom-session', TEN_MIN_AGO_SEC)
+ expect(existsSync(orphan)).toBe(true)
+
+ const r = await sweepOrphanFiles()
+ expect(r.replayFilesDeleted).toBe(1)
+ expect(r.bytesFreed).toBeGreaterThan(0)
+ expect(existsSync(orphan)).toBe(false)
+ })
+ })
+
+ it('unlinks a screenshot whose dispatch id has no DB row', async () => {
+ await withTempBrowserClawDir(async () => {
+ const orphan = writeScreenshotFile(999_999, TEN_MIN_AGO_SEC)
+ expect(existsSync(orphan)).toBe(true)
+
+ const r = await sweepOrphanFiles()
+ expect(r.screenshotFilesDeleted).toBe(1)
+ expect(existsSync(orphan)).toBe(false)
+ })
+ })
+
+ it('leaves a fresh orphan alone (in-flight guard)', async () => {
+ await withTempBrowserClawDir(async () => {
+ const orphan = writeReplayFile('fresh-session', RECENT_SEC)
+ const r = await sweepOrphanFiles()
+ expect(r.replayFilesDeleted).toBe(0)
+ expect(existsSync(orphan)).toBe(true)
+ })
+ })
+
+ it('keeps files whose id still has a DB row', async () => {
+ await withTempBrowserClawDir(async () => {
+ const dispatchId = seedSession('kept-session') as number
+ writeReplayFile('kept-session', TEN_MIN_AGO_SEC)
+ writeScreenshotFile(dispatchId, TEN_MIN_AGO_SEC)
+
+ const r = await sweepOrphanFiles()
+ expect(r.replayFilesDeleted).toBe(0)
+ expect(r.screenshotFilesDeleted).toBe(0)
+ expect(
+ existsSync(`${resolveClawServerPath('replays')}/kept-session.ndjson`),
+ ).toBe(true)
+ expect(existsSync(screenshotPath(dispatchId))).toBe(true)
+ })
+ })
+
+ it('ignores files with unexpected extensions', async () => {
+ await withTempBrowserClawDir(async () => {
+ const foreign = writeArbitraryFile('replays', 'note.txt', TEN_MIN_AGO_SEC)
+ const r = await sweepOrphanFiles()
+ expect(r.replayFilesDeleted).toBe(0)
+ expect(existsSync(foreign)).toBe(true)
+ })
+ })
+
+ it('ignores screenshot files whose basename is not an integer', async () => {
+ await withTempBrowserClawDir(async () => {
+ const foreign = writeArbitraryFile(
+ 'screenshots',
+ 'junk.jpg',
+ TEN_MIN_AGO_SEC,
+ )
+ const r = await sweepOrphanFiles()
+ expect(r.screenshotFilesDeleted).toBe(0)
+ expect(existsSync(foreign)).toBe(true)
+ })
+ })
+
+ it('reports orphan counts as part of cleanupOlderThan result', async () => {
+ await withTempBrowserClawDir(async () => {
+ // Seed a real (fresh) session so cleanupOlderThan finds nothing to
+ // age-delete, and drop an orphan file into the replays dir.
+ seedSession('s-fresh')
+ writeReplayFile('drifted', TEN_MIN_AGO_SEC)
+
+ const r = await cleanupOlderThan(15)
+ expect(r.sessionsDeleted).toBe(0)
+ expect(r.orphans.replayFilesDeleted).toBe(1)
+ expect(r.orphans.bytesFreed).toBeGreaterThan(0)
+ })
+ })
+
+ it('handles a missing directory gracefully', async () => {
+ await withTempBrowserClawDir(async () => {
+ // No replays/ or screenshots/ directory exists yet.
+ const r = await sweepOrphanFiles()
+ expect(r.replayFilesDeleted).toBe(0)
+ expect(r.screenshotFilesDeleted).toBe(0)
+ expect(r.bytesFreed).toBe(0)
+ })
+ })
+
+ it('runStartupSweep logs a result even when nothing to sweep', async () => {
+ await withTempBrowserClawDir(async () => {
+ const infoSpy = spyOn(logger, 'info')
+ try {
+ await runStartupSweep()
+ const call = infoSpy.mock.calls.find((c) => c[0] === 'orphan sweep')
+ expect(call).toBeDefined()
+ expect(call?.[1]).toMatchObject({
+ replayFilesDeleted: 0,
+ screenshotFilesDeleted: 0,
+ bytesFreed: 0,
+ source: 'startup',
+ })
+ } finally {
+ infoSpy.mockRestore()
+ }
+ })
+ })
+
+ it('after an age-based cleanup deletes rows, orphan sweep skips their files (already unlinked)', async () => {
+ await withTempBrowserClawDir(async () => {
+ recordSessionStart({
+ sessionId: 's-old',
+ agentId: 'claude-code',
+ slug: 'claude-code',
+ agentLabel: 'Claude Code',
+ clientName: 'claude-code',
+ clientVersion: '0.0.0',
+ })
+ const dOld = recordToolDispatch({
+ agentId: 'claude-code',
+ slug: 'claude-code',
+ agentLabel: 'Claude Code',
+ sessionId: 's-old',
+ toolName: 'act',
+ pageId: 1,
+ targetId: null,
+ url: null,
+ title: null,
+ rawArgs: {},
+ durationMs: 5,
+ result: {
+ isError: false,
+ structuredContent: {},
+ content: [{ type: 'text', text: 'ok' }],
+ },
+ }) as number
+ // Backdate the session so cleanupOlderThan finds it.
+ getAuditDb()
+ .update(toolDispatches)
+ .set({ createdAt: Date.now() - 100 * MS_PER_DAY })
+ .where(eq(toolDispatches.sessionId, 's-old'))
+ .run()
+
+ // Seed the on-disk artefacts.
+ writeReplayFile('s-old', TEN_MIN_AGO_SEC)
+ writeScreenshotFile(dOld, TEN_MIN_AGO_SEC)
+
+ const r = await cleanupOlderThan(15)
+ // Age-based delete removed 1 session + 1 dispatch + 2 files.
+ expect(r.sessionsDeleted).toBe(1)
+ expect(r.replayFilesDeleted).toBe(1)
+ expect(r.screenshotFilesDeleted).toBe(1)
+ // Orphan sweep in the same call finds nothing left to do.
+ expect(r.orphans.replayFilesDeleted).toBe(0)
+ expect(r.orphans.screenshotFilesDeleted).toBe(0)
+ })
+ })
+})
diff --git a/packages/browseros-agent/apps/claw-server/tests/services/audit-cleanup.test.ts b/packages/browseros-agent/apps/claw-server/tests/services/audit-cleanup.test.ts
new file mode 100644
index 0000000000..bce0f19fe5
--- /dev/null
+++ b/packages/browseros-agent/apps/claw-server/tests/services/audit-cleanup.test.ts
@@ -0,0 +1,360 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * Age-based cleanup tests. Seeds sessions with controlled createdAt
+ * values by patching the row after the dispatch writer runs (the
+ * writer sets createdAt to Date.now() via $defaultFn per PR #1846, so
+ * we UPDATE after insert to backdate).
+ *
+ * Orphan-sweep tests live in the sibling file
+ * `audit-cleanup.orphans.test.ts` so this file stays focused on the
+ * age-based path.
+ */
+
+import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
+import { mkdirSync, statSync, writeFileSync } from 'node:fs'
+import { dirname } from 'node:path'
+import { eq, sql } from 'drizzle-orm'
+import {
+ getAuditDb,
+ resetAuditDbForTesting,
+ setAuditDbForTesting,
+} from '../../src/modules/db/db'
+import {
+ agentSessionEnds,
+ agentSessionStarts,
+ toolDispatches,
+} from '../../src/modules/db/schema/schema'
+import {
+ candidatesFor,
+ cleanupOlderThan,
+ listCandidates,
+} from '../../src/services/audit-cleanup'
+import { recordToolDispatch } from '../../src/services/audit-log'
+import { replayStorage } from '../../src/services/replay-storage'
+import { screenshotPath } from '../../src/services/screenshots'
+import {
+ recordSessionEnd,
+ recordSessionStart,
+} from '../../src/services/session-events'
+import { withTempBrowserClawDir } from '../_helpers/temp-browserclaw-dir'
+
+const MS_PER_DAY = 86_400_000
+
+function dispatch(sessionId: string, toolName = 'act'): number | null {
+ return recordToolDispatch({
+ agentId: 'claude-code',
+ slug: 'claude-code',
+ agentLabel: 'Claude Code',
+ sessionId,
+ toolName,
+ pageId: 1,
+ targetId: null,
+ url: null,
+ title: null,
+ rawArgs: {},
+ durationMs: 5,
+ result: {
+ isError: false,
+ structuredContent: {},
+ content: [{ type: 'text', text: 'ok' }],
+ },
+ })
+}
+
+/**
+ * Backdates every dispatch AND session-start/end row for a session so
+ * `max(created_at)` for the session becomes exactly `daysOld` days
+ * ago. Cleanup groups sessions by `max(created_at)`, so we backdate to
+ * a single moment instead of scattering.
+ */
+function backdateSession(sessionId: string, daysOld: number): void {
+ const targetMs = Date.now() - daysOld * MS_PER_DAY
+ const db = getAuditDb()
+ db.update(toolDispatches)
+ .set({ createdAt: targetMs })
+ .where(eq(toolDispatches.sessionId, sessionId))
+ .run()
+ db.update(agentSessionStarts)
+ .set({ createdAt: targetMs })
+ .where(eq(agentSessionStarts.sessionId, sessionId))
+ .run()
+ db.update(agentSessionEnds)
+ .set({ createdAt: targetMs })
+ .where(eq(agentSessionEnds.sessionId, sessionId))
+ .run()
+}
+
+function startSession(sessionId: string): void {
+ recordSessionStart({
+ sessionId,
+ agentId: 'claude-code',
+ slug: 'claude-code',
+ agentLabel: 'Claude Code',
+ clientName: 'claude-code',
+ clientVersion: '0.0.0',
+ })
+}
+
+function seedScreenshotFile(dispatchId: number | null): void {
+ if (typeof dispatchId !== 'number') return
+ const path = screenshotPath(dispatchId)
+ mkdirSync(dirname(path), { recursive: true })
+ writeFileSync(path, Buffer.from([0xff, 0xd8, 0xff, 0xd9]))
+}
+
+async function seedReplayFile(sessionId: string): Promise {
+ // Uses the storage's own writer so paths/sanitisation stay in sync.
+ await replayStorage.appendEvents(sessionId, [
+ JSON.stringify({ ts: Date.now(), type: 4 }),
+ ])
+}
+
+describe('audit-cleanup age-based', () => {
+ beforeEach(() => setAuditDbForTesting())
+ afterEach(async () => {
+ resetAuditDbForTesting()
+ await replayStorage.resetForTesting()
+ })
+
+ it('deletes sessions strictly older than the threshold', async () => {
+ await withTempBrowserClawDir(async () => {
+ // Three sessions aged 5, 20, 100 days.
+ startSession('s-5')
+ const d5 = dispatch('s-5', 'act')
+ backdateSession('s-5', 5)
+ startSession('s-20')
+ const d20 = dispatch('s-20', 'act')
+ backdateSession('s-20', 20)
+ startSession('s-100')
+ const d100 = dispatch('s-100', 'act')
+ backdateSession('s-100', 100)
+
+ seedScreenshotFile(d5)
+ seedScreenshotFile(d20)
+ seedScreenshotFile(d100)
+
+ // cleanupOlderThan(15) removes 20d and 100d, leaves 5d.
+ const r15 = await cleanupOlderThan(15)
+ expect(r15.sessionsDeleted).toBe(2)
+ expect(r15.dispatchesDeleted).toBe(2)
+
+ const survivors = getAuditDb()
+ .select({ id: toolDispatches.sessionId })
+ .from(toolDispatches)
+ .groupBy(toolDispatches.sessionId)
+ .all()
+ .map((r) => r.id)
+ expect(survivors).toEqual(['s-5'])
+ })
+ })
+
+ it('90-day threshold only nukes truly ancient sessions', async () => {
+ await withTempBrowserClawDir(async () => {
+ startSession('s-30')
+ dispatch('s-30', 'act')
+ backdateSession('s-30', 30)
+ startSession('s-100')
+ dispatch('s-100', 'act')
+ backdateSession('s-100', 100)
+
+ const r90 = await cleanupOlderThan(90)
+ expect(r90.sessionsDeleted).toBe(1)
+
+ const survivors = getAuditDb()
+ .select({ id: toolDispatches.sessionId })
+ .from(toolDispatches)
+ .groupBy(toolDispatches.sessionId)
+ .all()
+ .map((r) => r.id)
+ expect(survivors).toEqual(['s-30'])
+ })
+ })
+
+ it('leaves a long-lived session with a fresh straggler dispatch alone', async () => {
+ // The bug we're guarding against: computing "old" on session start
+ // rather than session last-activity would nuke this one.
+ await withTempBrowserClawDir(async () => {
+ startSession('s-long')
+ dispatch('s-long', 'act')
+ backdateSession('s-long', 100)
+
+ // Now add a fresh dispatch (undated, i.e. Date.now()) to the
+ // same session. `max(created_at)` should shift to ~now.
+ dispatch('s-long', 'act')
+
+ const r15 = await cleanupOlderThan(15)
+ expect(r15.sessionsDeleted).toBe(0)
+
+ const remaining = getAuditDb()
+ .select({ count: sql`count(*)` })
+ .from(toolDispatches)
+ .all()[0]?.count
+ expect(remaining).toBe(2)
+ })
+ })
+
+ it('unlinks replay + screenshot files for deleted sessions and leaves survivors on disk', async () => {
+ await withTempBrowserClawDir(async () => {
+ startSession('s-fresh')
+ const dFresh = dispatch('s-fresh', 'act')
+ backdateSession('s-fresh', 5)
+ startSession('s-old')
+ const dOld = dispatch('s-old', 'act')
+ backdateSession('s-old', 100)
+
+ await seedReplayFile('s-fresh')
+ await seedReplayFile('s-old')
+ seedScreenshotFile(dFresh)
+ seedScreenshotFile(dOld)
+
+ const oldReplay = replayStorage.pathFor('s-old')
+ const freshReplay = replayStorage.pathFor('s-fresh')
+ const oldShot = screenshotPath(dOld as number)
+ const freshShot = screenshotPath(dFresh as number)
+
+ // All four files exist pre-cleanup.
+ expect(statSync(oldReplay).size).toBeGreaterThan(0)
+ expect(statSync(freshReplay).size).toBeGreaterThan(0)
+ expect(statSync(oldShot).size).toBeGreaterThan(0)
+ expect(statSync(freshShot).size).toBeGreaterThan(0)
+
+ const r = await cleanupOlderThan(15)
+ expect(r.replayFilesDeleted).toBe(1)
+ expect(r.screenshotFilesDeleted).toBe(1)
+ expect(r.bytesFreed).toBeGreaterThan(0)
+
+ // Old files gone.
+ expect(() => statSync(oldReplay)).toThrow()
+ expect(() => statSync(oldShot)).toThrow()
+ // Fresh files still there.
+ expect(statSync(freshReplay).size).toBeGreaterThan(0)
+ expect(statSync(freshShot).size).toBeGreaterThan(0)
+ })
+ })
+
+ it('is idempotent: a second cleanup with the same threshold is a no-op', async () => {
+ await withTempBrowserClawDir(async () => {
+ startSession('s-old')
+ dispatch('s-old', 'act')
+ backdateSession('s-old', 100)
+
+ const first = await cleanupOlderThan(15)
+ expect(first.sessionsDeleted).toBe(1)
+
+ const second = await cleanupOlderThan(15)
+ expect(second.sessionsDeleted).toBe(0)
+ expect(second.dispatchesDeleted).toBe(0)
+ expect(second.replayFilesDeleted).toBe(0)
+ expect(second.screenshotFilesDeleted).toBe(0)
+ })
+ })
+
+ it('listCandidates returns three zero rows on an empty DB', async () => {
+ await withTempBrowserClawDir(async () => {
+ const ranges = listCandidates()
+ expect(ranges.map((r) => r.olderThanDays)).toEqual([15, 30, 90])
+ for (const r of ranges) {
+ expect(r.sessionCount).toBe(0)
+ expect(r.dispatchCount).toBe(0)
+ expect(r.bytesOnDisk).toBe(0)
+ }
+ })
+ })
+
+ it('candidatesFor reports counts that match cleanup outcome', async () => {
+ await withTempBrowserClawDir(async () => {
+ startSession('s-old-1')
+ dispatch('s-old-1', 'act')
+ dispatch('s-old-1', 'read')
+ backdateSession('s-old-1', 100)
+
+ startSession('s-old-2')
+ dispatch('s-old-2', 'act')
+ backdateSession('s-old-2', 40)
+
+ startSession('s-fresh')
+ dispatch('s-fresh', 'act')
+ backdateSession('s-fresh', 5)
+
+ // 15-day threshold: covers both old-1 and old-2.
+ const c15 = candidatesFor(15)
+ expect(c15.sessionCount).toBe(2)
+ expect(c15.dispatchCount).toBe(3)
+
+ // 90-day threshold: only old-1.
+ const c90 = candidatesFor(90)
+ expect(c90.sessionCount).toBe(1)
+ expect(c90.dispatchCount).toBe(2)
+
+ // And now delete: the reported count matches candidates.
+ const r = await cleanupOlderThan(15)
+ expect(r.sessionsDeleted).toBe(c15.sessionCount)
+ expect(r.dispatchesDeleted).toBe(c15.dispatchCount)
+ })
+ })
+
+ it('deletes agent_session_starts and agent_session_ends rows together with dispatches', async () => {
+ await withTempBrowserClawDir(async () => {
+ startSession('s-old')
+ dispatch('s-old', 'act')
+ recordSessionEnd({ sessionId: 's-old', kind: 'closed' })
+ backdateSession('s-old', 100)
+
+ await cleanupOlderThan(15)
+
+ const db = getAuditDb()
+ expect(db.select().from(toolDispatches).all().length).toBe(0)
+ expect(db.select().from(agentSessionStarts).all().length).toBe(0)
+ expect(db.select().from(agentSessionEnds).all().length).toBe(0)
+ })
+ })
+
+ it('lets a concurrent dispatch that lands during cleanup survive as a resurrected session', async () => {
+ // Regression coverage for the accepted concurrency behaviour: a
+ // dispatch that arrives after cleanupOlderThan has computed its
+ // target set but before it commits will end up committed AFTER the
+ // deletes finish (WAL serialises writers). The row belongs to a
+ // session that we just "deleted", so it resurrects that session in
+ // the audit UI, minus its old start/end rows. That's fine: the
+ // deriver already handles missing start rows. This test documents
+ // the behaviour so a future concurrency guard would surface here.
+ await withTempBrowserClawDir(async () => {
+ startSession('s-doomed')
+ dispatch('s-doomed', 'act')
+ backdateSession('s-doomed', 100)
+
+ const cleanupPromise = cleanupOlderThan(15)
+ // Kick off a fresh dispatch immediately; bun:sqlite serialises
+ // through a single writer so this queues behind the cleanup's
+ // transaction and lands after commit.
+ const resurrectedId = dispatch('s-doomed', 'act')
+ const cleanupResult = await cleanupPromise
+
+ expect(cleanupResult.sessionsDeleted).toBe(1)
+
+ const remaining = getAuditDb()
+ .select({ sessionId: toolDispatches.sessionId })
+ .from(toolDispatches)
+ .all()
+ .map((r) => r.sessionId)
+ // The doomed session comes back as a "zombie" with only the
+ // resurrected dispatch.
+ expect(remaining).toEqual(['s-doomed'])
+ // And it's the fresh row, not one from before cleanup.
+ const rows = getAuditDb()
+ .select({ id: toolDispatches.id })
+ .from(toolDispatches)
+ .all()
+ expect(rows.map((r) => r.id)).toEqual([resurrectedId])
+
+ // Session start/end rows are gone (cleanup deleted them and the
+ // fresh dispatch does not re-emit a start event).
+ expect(getAuditDb().select().from(agentSessionStarts).all().length).toBe(
+ 0,
+ )
+ })
+ })
+})
diff --git a/packages/browseros-agent/packages/shared/package.json b/packages/browseros-agent/packages/shared/package.json
index e7db968c7a..2f529397f4 100644
--- a/packages/browseros-agent/packages/shared/package.json
+++ b/packages/browseros-agent/packages/shared/package.json
@@ -34,6 +34,10 @@
"types": "./src/constants/paths.ts",
"default": "./src/constants/paths.ts"
},
+ "./constants/audit": {
+ "types": "./src/constants/audit.ts",
+ "default": "./src/constants/audit.ts"
+ },
"./constants/hermes": {
"types": "./src/constants/hermes.ts",
"default": "./src/constants/hermes.ts"
diff --git a/packages/browseros-agent/packages/shared/src/constants/audit.ts b/packages/browseros-agent/packages/shared/src/constants/audit.ts
new file mode 100644
index 0000000000..d779e82b00
--- /dev/null
+++ b/packages/browseros-agent/packages/shared/src/constants/audit.ts
@@ -0,0 +1,14 @@
+/**
+ * @license
+ * Copyright 2025 BrowserOS
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * BrowserClaw audit-log cleanup thresholds. Shared across claw-server
+ * (route validation, service predicate) and claw-app (dialog options)
+ * so the three offered thresholds never drift.
+ */
+
+export const AUDIT_CLEANUP_THRESHOLD_DAYS = [15, 30, 90] as const
+
+export type AuditCleanupThresholdDays =
+ (typeof AUDIT_CLEANUP_THRESHOLD_DAYS)[number]