Spaces:
Sleeping
Sleeping
| import { NextRequest, NextResponse } from 'next/server' | |
| import { requireRole } from '@/lib/auth' | |
| import { getDatabase, logAuditEvent } from '@/lib/db' | |
| import { config } from '@/lib/config' | |
| import { heavyLimiter } from '@/lib/rate-limit' | |
| import { countStaleGatewaySessions, pruneGatewaySessionsOlderThan } from '@/lib/sessions' | |
| interface CleanupResult { | |
| table: string | |
| deleted: number | |
| cutoff_date: string | |
| retention_days: number | |
| } | |
| /** | |
| * GET /api/cleanup - Show retention policy and what would be cleaned | |
| */ | |
| export async function GET(request: NextRequest) { | |
| const auth = requireRole(request, 'admin') | |
| if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) | |
| const db = getDatabase() | |
| const workspaceId = auth.user.workspace_id ?? 1 | |
| const now = Math.floor(Date.now() / 1000) | |
| const ret = config.retention | |
| const preview = [] | |
| for (const { table, column, days, label, scoped } of getRetentionTargets()) { | |
| if (days <= 0) { | |
| preview.push({ table: label, retention_days: 0, stale_count: 0, note: 'Retention disabled (keep forever)' }) | |
| continue | |
| } | |
| const cutoff = now - days * 86400 | |
| try { | |
| const wsClause = scoped ? ' AND workspace_id = ?' : '' | |
| const params: any[] = scoped ? [cutoff, workspaceId] : [cutoff] | |
| const row = db.prepare(`SELECT COUNT(*) as c FROM ${table} WHERE ${column} < ?${wsClause}`).get(...params) as any | |
| preview.push({ | |
| table: label, | |
| retention_days: days, | |
| cutoff_date: new Date(cutoff * 1000).toISOString().split('T')[0], | |
| stale_count: row.c, | |
| }) | |
| } catch { | |
| preview.push({ table: label, retention_days: days, stale_count: 0, note: 'Table not found' }) | |
| } | |
| } | |
| // Token usage file stats | |
| try { | |
| const { readFile } = require('fs/promises') | |
| const data = JSON.parse(await readFile(config.tokensPath, 'utf-8')) | |
| const cutoffMs = Date.now() - ret.tokenUsage * 86400000 | |
| const stale = data.filter((r: any) => r.timestamp < cutoffMs).length | |
| preview.push({ | |
| table: 'Token Usage (file)', | |
| retention_days: ret.tokenUsage, | |
| cutoff_date: new Date(cutoffMs).toISOString().split('T')[0], | |
| stale_count: stale, | |
| }) | |
| } catch { | |
| preview.push({ table: 'Token Usage (file)', retention_days: ret.tokenUsage, stale_count: 0, note: 'No token data file' }) | |
| } | |
| if (ret.gatewaySessions > 0) { | |
| preview.push({ | |
| table: 'Gateway Session Store', | |
| retention_days: ret.gatewaySessions, | |
| stale_count: countStaleGatewaySessions(ret.gatewaySessions), | |
| note: 'Stored under ~/.openclaw/agents/*/sessions/sessions.json', | |
| }) | |
| } else { | |
| preview.push({ table: 'Gateway Session Store', retention_days: 0, stale_count: 0, note: 'Retention disabled (keep forever)' }) | |
| } | |
| return NextResponse.json({ retention: config.retention, preview }) | |
| } | |
| /** | |
| * POST /api/cleanup - Run cleanup (admin only) | |
| * Body: { dry_run?: boolean } | |
| */ | |
| export async function POST(request: NextRequest) { | |
| const auth = requireRole(request, 'admin') | |
| if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) | |
| const rateCheck = heavyLimiter(request) | |
| if (rateCheck) return rateCheck | |
| const body = await request.json().catch(() => ({})) | |
| const dryRun = body.dry_run === true | |
| const db = getDatabase() | |
| const workspaceId = auth.user.workspace_id ?? 1 | |
| const now = Math.floor(Date.now() / 1000) | |
| const results: CleanupResult[] = [] | |
| let totalDeleted = 0 | |
| for (const { table, column, days, label, scoped } of getRetentionTargets()) { | |
| if (days <= 0) continue | |
| const cutoff = now - days * 86400 | |
| const wsClause = scoped ? ' AND workspace_id = ?' : '' | |
| const params: any[] = scoped ? [cutoff, workspaceId] : [cutoff] | |
| try { | |
| if (dryRun) { | |
| const row = db.prepare(`SELECT COUNT(*) as c FROM ${table} WHERE ${column} < ?${wsClause}`).get(...params) as any | |
| results.push({ | |
| table: label, | |
| deleted: row.c, | |
| cutoff_date: new Date(cutoff * 1000).toISOString().split('T')[0], | |
| retention_days: days, | |
| }) | |
| totalDeleted += row.c | |
| } else { | |
| const res = db.prepare(`DELETE FROM ${table} WHERE ${column} < ?${wsClause}`).run(...params) | |
| results.push({ | |
| table: label, | |
| deleted: res.changes, | |
| cutoff_date: new Date(cutoff * 1000).toISOString().split('T')[0], | |
| retention_days: days, | |
| }) | |
| totalDeleted += res.changes | |
| } | |
| } catch { | |
| results.push({ table: label, deleted: 0, cutoff_date: '', retention_days: days }) | |
| } | |
| } | |
| // Clean token usage file | |
| const ret = config.retention | |
| if (ret.tokenUsage > 0) { | |
| try { | |
| const { readFile, writeFile } = require('fs/promises') | |
| const raw = await readFile(config.tokensPath, 'utf-8') | |
| const data = JSON.parse(raw) | |
| const cutoffMs = Date.now() - ret.tokenUsage * 86400000 | |
| const kept = data.filter((r: any) => r.timestamp >= cutoffMs) | |
| const removed = data.length - kept.length | |
| if (!dryRun && removed > 0) { | |
| await writeFile(config.tokensPath, JSON.stringify(kept, null, 2)) | |
| } | |
| results.push({ | |
| table: 'Token Usage (file)', | |
| deleted: removed, | |
| cutoff_date: new Date(cutoffMs).toISOString().split('T')[0], | |
| retention_days: ret.tokenUsage, | |
| }) | |
| totalDeleted += removed | |
| } catch { | |
| // No token file or parse error | |
| } | |
| } | |
| if (ret.gatewaySessions > 0) { | |
| const sessionPrune = dryRun | |
| ? { deleted: countStaleGatewaySessions(ret.gatewaySessions), filesTouched: 0 } | |
| : pruneGatewaySessionsOlderThan(ret.gatewaySessions) | |
| results.push({ | |
| table: 'Gateway Session Store', | |
| deleted: sessionPrune.deleted, | |
| cutoff_date: new Date(Date.now() - ret.gatewaySessions * 86400000).toISOString().split('T')[0], | |
| retention_days: ret.gatewaySessions, | |
| }) | |
| totalDeleted += sessionPrune.deleted | |
| } | |
| if (!dryRun && totalDeleted > 0) { | |
| const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown' | |
| logAuditEvent({ | |
| action: 'data_cleanup', | |
| actor: auth.user.username, | |
| actor_id: auth.user.id, | |
| detail: { total_deleted: totalDeleted, results }, | |
| ip_address: ipAddress, | |
| }) | |
| } | |
| return NextResponse.json({ | |
| dry_run: dryRun, | |
| total_deleted: totalDeleted, | |
| results, | |
| }) | |
| } | |
| function getRetentionTargets() { | |
| const ret = config.retention | |
| return [ | |
| { table: 'activities', column: 'created_at', days: ret.activities, label: 'Activities', scoped: true }, | |
| { table: 'audit_log', column: 'created_at', days: ret.auditLog, label: 'Audit Log', scoped: false }, // instance-global, admin-only | |
| { table: 'notifications', column: 'created_at', days: ret.notifications, label: 'Notifications', scoped: true }, | |
| { table: 'pipeline_runs', column: 'created_at', days: ret.pipelineRuns, label: 'Pipeline Runs', scoped: true }, | |
| ] | |
| } | |