From eafd4d12a955b3f6f901659a269fc9328a39872c Mon Sep 17 00:00:00 2001 From: weishu Date: Tue, 23 Dec 2025 17:45:15 +0800 Subject: [PATCH] feat: add session cleanup script with flexible filtering options Add cleanup-sessions.ts script that enables deletion of sessions from the database with support for multiple filtering strategies: - Message count filtering (delete sessions with fewer than N messages) - Path pattern matching with glob support - Orphaned session detection (sessions whose path no longer exists) - Optional confirmation prompt with --force flag to skip Includes 'clean-session' npm script for convenient invocation. --- package.json | 3 +- server/scripts/cleanup-sessions.ts | 310 +++++++++++++++++++++++++++++ 2 files changed, 312 insertions(+), 1 deletion(-) create mode 100755 server/scripts/cleanup-sessions.ts diff --git a/package.json b/package.json index cef8ad85..7c20b5a9 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "typecheck:cli": "cd cli && bun run typecheck", "typecheck:server": "cd server && bun run typecheck", "typecheck:web": "cd web && bun run typecheck", - "test": "cd cli && bun run test" + "test": "cd cli && bun run test", + "clean-session": "bun run server/scripts/cleanup-sessions.ts" }, "devDependencies": { "concurrently": "^9.2.1", diff --git a/server/scripts/cleanup-sessions.ts b/server/scripts/cleanup-sessions.ts new file mode 100755 index 00000000..7c5c4223 --- /dev/null +++ b/server/scripts/cleanup-sessions.ts @@ -0,0 +1,310 @@ +#!/usr/bin/env bun +/** + * Cleanup script to delete sessions from the database. + * + * Supports filtering by: + * - Message count: Delete sessions with fewer than N messages + * - Path pattern: Delete sessions matching a glob pattern + * - Orphaned: Delete sessions whose path no longer exists + * + * Usage: + * bun run server/scripts/cleanup-sessions.ts [options] + * + * Options: + * --min-messages=N Delete sessions with fewer than N messages (default: 5) + * --path=PATTERN Delete sessions matching path pattern (glob supported) + * --orphaned Delete sessions whose path no longer exists + * --force Skip confirmation prompt + * --help Show this help message + * + * Examples: + * bun run server/scripts/cleanup-sessions.ts + * bun run server/scripts/cleanup-sessions.ts --min-messages=3 + * bun run server/scripts/cleanup-sessions.ts --path="/tmp/*" + * bun run server/scripts/cleanup-sessions.ts --orphaned + * bun run server/scripts/cleanup-sessions.ts --orphaned --min-messages=5 --force + */ + +import { Database } from 'bun:sqlite' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { existsSync } from 'node:fs' + +// Parse command line arguments +function parseArgs(): { minMessages: number | null; pathPattern: string | null; orphaned: boolean; force: boolean; help: boolean } { + const args = process.argv.slice(2) + let minMessages: number | null = null + let pathPattern: string | null = null + let orphaned = false + let force = false + let help = false + + for (const arg of args) { + if (arg === '--help' || arg === '-h') { + help = true + } else if (arg === '--force' || arg === '-f') { + force = true + } else if (arg === '--orphaned') { + orphaned = true + } else if (arg.startsWith('--min-messages=')) { + const value = parseInt(arg.split('=')[1], 10) + if (isNaN(value) || value < 0) { + console.error('Error: --min-messages must be a non-negative integer') + process.exit(1) + } + minMessages = value + } else if (arg.startsWith('--path=')) { + pathPattern = arg.split('=').slice(1).join('=') // Handle paths with '=' + } else { + console.error(`Unknown argument: ${arg}`) + console.error('Use --help for usage information') + process.exit(1) + } + } + + // Default behavior: if no filters specified, use min-messages=5 + if (minMessages === null && pathPattern === null && !orphaned) { + minMessages = 5 + } + + return { minMessages, pathPattern, orphaned, force, help } +} + +// Get database path (same logic as configuration.ts) +function getDbPath(): string { + if (process.env.DB_PATH) { + return process.env.DB_PATH.replace(/^~/, homedir()) + } + const dataDir = process.env.HAPI_HOME + ? process.env.HAPI_HOME.replace(/^~/, homedir()) + : join(homedir(), '.hapi') + return join(dataDir, 'hapi.db') +} + +// Session info for display +interface SessionInfo { + id: string + tag: string | null + path: string | null + messageCount: number +} + +// Query sessions with message counts +function querySessions(db: Database): SessionInfo[] { + const rows = db.query< + { id: string; tag: string | null; metadata: string | null; message_count: number }, + [] + >(` + SELECT + s.id, + s.tag, + s.metadata, + COUNT(m.id) as message_count + FROM sessions s + LEFT JOIN messages m ON m.session_id = s.id + GROUP BY s.id + `).all() + + return rows.map(row => { + let path: string | null = null + if (row.metadata) { + try { + const metadata = JSON.parse(row.metadata) + path = metadata.path ?? null + } catch { + // Ignore parse errors + } + } + return { + id: row.id, + tag: row.tag, + path, + messageCount: row.message_count, + } + }) +} + +// Filter sessions based on criteria +function filterSessions( + sessions: SessionInfo[], + minMessages: number | null, + pathPattern: string | null, + orphaned: boolean +): SessionInfo[] { + let filtered = sessions + + // Filter by message count if specified + if (minMessages !== null) { + filtered = filtered.filter(s => s.messageCount < minMessages) + } + + // Filter by path pattern if specified + if (pathPattern !== null) { + const glob = new Bun.Glob(pathPattern) + filtered = filtered.filter(s => { + if (!s.path) return false + return glob.match(s.path) + }) + } + + // Filter by orphaned (path does not exist) if specified + if (orphaned) { + filtered = filtered.filter(s => { + if (!s.path) return true // No path = orphaned + return !existsSync(s.path) + }) + } + + return filtered +} + +// Display sessions in a table format +function displaySessions(sessions: SessionInfo[]): void { + if (sessions.length === 0) { + console.log('No sessions match the criteria.') + return + } + + // Calculate column widths + const idWidth = Math.max(8, ...sessions.map(s => s.id.length)) + const tagWidth = Math.max(3, ...sessions.map(s => (s.tag ?? '').length)) + const pathWidth = Math.max(4, ...sessions.map(s => (s.path ?? '').length)) + const countWidth = 5 + + // Header + const header = [ + 'ID'.padEnd(idWidth), + 'Tag'.padEnd(tagWidth), + 'Path'.padEnd(pathWidth), + 'Msgs'.padStart(countWidth), + ].join(' | ') + console.log(header) + console.log('-'.repeat(header.length)) + + // Rows + for (const s of sessions) { + console.log([ + s.id.padEnd(idWidth), + (s.tag ?? '').padEnd(tagWidth), + (s.path ?? '').padEnd(pathWidth), + s.messageCount.toString().padStart(countWidth), + ].join(' | ')) + } +} + +// Prompt for confirmation +async function confirm(message: string): Promise { + process.stdout.write(`${message} [y/N]: `) + for await (const line of console) { + const answer = line.trim().toLowerCase() + return answer === 'y' || answer === 'yes' + } + return false +} + +// Delete sessions by IDs +function deleteSessions(db: Database, ids: string[]): number { + if (ids.length === 0) return 0 + + const placeholders = ids.map(() => '?').join(', ') + const result = db.run(`DELETE FROM sessions WHERE id IN (${placeholders})`, ids) + return result.changes +} + +// Main function +async function main(): Promise { + const { minMessages, pathPattern, orphaned, force, help } = parseArgs() + + if (help) { + console.log(` +Usage: bun run server/scripts/cleanup-sessions.ts [options] + +Options: + --min-messages=N Delete sessions with fewer than N messages (default: 5) + --path=PATTERN Delete sessions matching path pattern (glob supported) + --orphaned Delete sessions whose path no longer exists + --force Skip confirmation prompt + --help Show this help message + +Filtering logic: + - Only --min-messages: Delete sessions with message count < N + - Only --path: Delete ALL sessions matching the path pattern + - Only --orphaned: Delete sessions whose path does not exist on filesystem + - Multiple filters: Delete sessions matching ALL conditions (AND) + +Examples: + bun run server/scripts/cleanup-sessions.ts + bun run server/scripts/cleanup-sessions.ts --min-messages=3 + bun run server/scripts/cleanup-sessions.ts --path="/tmp/*" + bun run server/scripts/cleanup-sessions.ts --orphaned + bun run server/scripts/cleanup-sessions.ts --orphaned --min-messages=5 --force +`) + process.exit(0) + } + + // Check database exists + const dbPath = getDbPath() + if (!existsSync(dbPath)) { + console.error(`Database not found: ${dbPath}`) + process.exit(1) + } + + console.log(`Database: ${dbPath}`) + + // Open database + const db = new Database(dbPath) + db.run('PRAGMA foreign_keys = ON') + + try { + // Query all sessions + const allSessions = querySessions(db) + console.log(`Total sessions: ${allSessions.length}`) + + // Apply filters + const toDelete = filterSessions(allSessions, minMessages, pathPattern, orphaned) + + // Display filter criteria + const criteria: string[] = [] + if (minMessages !== null) { + criteria.push(`message count < ${minMessages}`) + } + if (pathPattern !== null) { + criteria.push(`path matches "${pathPattern}"`) + } + if (orphaned) { + criteria.push('path does not exist') + } + console.log(`Filter: ${criteria.join(' AND ')}`) + console.log(`Sessions to delete: ${toDelete.length}`) + console.log() + + if (toDelete.length === 0) { + console.log('Nothing to delete.') + return + } + + // Display sessions + displaySessions(toDelete) + console.log() + + // Confirm deletion + if (!force) { + const confirmed = await confirm(`Delete ${toDelete.length} session(s)?`) + if (!confirmed) { + console.log('Aborted.') + return + } + } + + // Delete sessions + const deleted = deleteSessions(db, toDelete.map(s => s.id)) + console.log(`Deleted ${deleted} session(s) and their messages.`) + } finally { + db.close() + } +} + +main().catch(err => { + console.error('Error:', err.message) + process.exit(1) +})