#!/usr/bin/env bun /** * Deregister a chat room and purge all managed data, keeping ONLY the chat * channel record (chats) and its message history (messages). * * Usage: * bun scripts/deregister-room.ts [--dry-run] [--force] [--no-backup] [more...] * * What it removes for each target chat: * - room registration + role/skill overrides + channel owner lease * - paired tasks/turns/attempts/outputs/reservations/leases/projects/handoffs * - work items, scheduled tasks, task run logs * - sessions (by group folder) + router cursor entry * - on-disk group/workspace/session/ipc folders (git worktrees removed cleanly) * * What it KEEPS (never touched): the `chats` row and all `messages` rows. * * It does NOT restart the service. Restart ejclaw afterwards so the room is * dropped from the live in-memory bindings (the router loads bindings only at * startup), e.g. `systemctl --user restart ejclaw.service`. * * Safety: * - refuses to deregister a main room unless --force * - a group folder shared by another remaining room is NOT deleted on disk * (only that chat's DB rows are removed) * - --dry-run reports what would change without writing anything * - the DB is auto-backed up to /home/claude/ejclaw-db-backup-.db before * the purge unless --no-backup is passed */ import { execFileSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import { Database } from 'bun:sqlite'; import { DATA_DIR, GROUPS_DIR, STORE_DIR } from '../src/config.js'; const args = process.argv.slice(2); const dryRun = args.includes('--dry-run'); const force = args.includes('--force'); const noBackup = args.includes('--no-backup'); const ids = args.filter((a) => !a.startsWith('--')); if (ids.length === 0) { console.error( 'Usage: bun scripts/deregister-room.ts [--dry-run] [--force] [--no-backup] [more...]', ); process.exit(2); } function normalizeJid(id: string): string { if (/^(dc|tg|wa):/.test(id)) return id; if (/^\d+$/.test(id)) return `dc:${id}`; return id; } const jids = [...new Set(ids.map(normalizeJid))]; const repoRoot = path.resolve(GROUPS_DIR, '..'); const db = new Database(path.join(STORE_DIR, 'messages.db')); interface Target { jid: string; folders: string[]; isMain: boolean; name: string | null; registered: boolean; } // When a room was already unregistered (room_settings gone) the group folder is // no longer stored there, so recover it from the group_folder recorded on the // chat's leftover managed rows — otherwise sessions/disk/worktrees for an // already-unregistered room would be missed. function backtraceFolders(jid: string): string[] { const set = new Set(); for (const t of [ 'paired_tasks', 'work_items', 'scheduled_tasks', 'service_handoffs', 'paired_projects', ]) { const rows = db .query( `SELECT DISTINCT group_folder FROM ${t} WHERE chat_jid=? AND group_folder IS NOT NULL`, ) .all(jid) as Array<{ group_folder: string | null }>; for (const r of rows) if (r.group_folder) set.add(r.group_folder); } return [...set]; } const targets: Target[] = jids.map((jid) => { const r = db .query('SELECT folder, is_main, name FROM room_settings WHERE chat_jid=?') .get(jid) as | { folder: string | null; is_main: number | null; name: string | null } | undefined; const folders = r?.folder ? [r.folder] : backtraceFolders(jid); return { jid, folders, isMain: r?.is_main === 1, name: r?.name ?? null, registered: Boolean(r), }; }); const mainTargets = targets.filter((t) => t.isMain); if (mainTargets.length && !force) { console.error( 'Refusing to deregister main room(s):', mainTargets.map((t) => t.jid).join(', '), '\nPass --force only if you really intend to unregister the main control room.', ); process.exit(3); } const targetJidSet = new Set(jids); function folderShared(folder: string): boolean { const rows = db .query('SELECT chat_jid FROM room_settings WHERE folder=?') .all(folder) as Array<{ chat_jid: string }>; return rows.some((r) => !targetJidSet.has(r.chat_jid)); } const safeFolders = new Set(); const sharedFolders = new Set(); for (const t of targets) { for (const folder of t.folders) { if (folderShared(folder)) sharedFolders.add(folder); else safeFolders.add(folder); } } console.log('=== targets ==='); for (const t of targets) { console.log( ` ${t.jid} | name=${t.name ?? '(unregistered)'} | folder=${t.folders.length ? t.folders.join(',') : '-'} | registered=${t.registered}${t.isMain ? ' | MAIN' : ''}`, ); } if (sharedFolders.size) { console.log( ' NOTE shared folders kept on disk (used by another room):', [...sharedFolders].join(', '), ); } const qJ = jids.map(() => '?').join(','); const taskIds = ( db .query(`SELECT id FROM paired_tasks WHERE chat_jid IN (${qJ})`) .all(...jids) as Array<{ id: string; }> ).map((r) => r.id); const qT = taskIds.map(() => '?').join(','); // task_run_logs.task_id references scheduled_tasks.id (NOT paired_tasks.id), so // its rows must be pruned by the chat's scheduled task ids. const scheduledTaskIds = ( db .query(`SELECT id FROM scheduled_tasks WHERE chat_jid IN (${qJ})`) .all(...jids) as Array<{ id: string }> ).map((r) => r.id); const qS = scheduledTaskIds.map(() => '?').join(','); const safeFolderList = [...safeFolders]; const count = (t: string, where: string, a: unknown[]): number => ( db.query(`SELECT COUNT(*) n FROM ${t} WHERE ${where}`).get(...a) as { n: number; } ).n; // Report / gather counts. const plan: Array<{ table: string; n: number; run: () => void }> = []; const addJid = (t: string) => plan.push({ table: t, n: count(t, `chat_jid IN (${qJ})`, jids), run: () => db.query(`DELETE FROM ${t} WHERE chat_jid IN (${qJ})`).run(...jids), }); const addTask = (t: string) => { if (!taskIds.length) return; plan.push({ table: t, n: count(t, `task_id IN (${qT})`, taskIds), run: () => db.query(`DELETE FROM ${t} WHERE task_id IN (${qT})`).run(...taskIds), }); }; addTask('paired_turn_attempts'); addTask('paired_turn_outputs'); addTask('paired_turns'); // Prune scheduled-task run logs before the scheduled_tasks rows they belong to. if (scheduledTaskIds.length) { plan.push({ table: 'task_run_logs', n: count('task_run_logs', `task_id IN (${qS})`, scheduledTaskIds), run: () => db .query(`DELETE FROM task_run_logs WHERE task_id IN (${qS})`) .run(...scheduledTaskIds), }); } for (const t of [ 'paired_task_execution_leases', 'paired_turn_reservations', 'paired_tasks', 'paired_projects', 'service_handoffs', 'work_items', 'scheduled_tasks', 'channel_owner', 'room_role_overrides', 'room_skill_overrides', 'room_settings', ]) { addJid(t); } // sessions are keyed by group folder — only purge folders not shared with a // surviving room. Reviewer/arbiter sessions are stored under the colon-suffixed // group_folder (e.g. ":reviewer"), so include those variants too. const sessionFolderKeys = safeFolderList.flatMap((f) => [ f, `${f}:reviewer`, `${f}:arbiter`, ]); if (sessionFolderKeys.length) { const qSess = sessionFolderKeys.map(() => '?').join(','); plan.push({ table: 'sessions', n: count('sessions', `group_folder IN (${qSess})`, sessionFolderKeys), run: () => db .query(`DELETE FROM sessions WHERE group_folder IN (${qSess})`) .run(...sessionFolderKeys), }); } // router cursor prune count const ras = db .query('SELECT value FROM router_state WHERE key=?') .get('last_agent_seq') as { value: string } | undefined; let routerPrune = 0; if (ras) { const obj = JSON.parse(ras.value) as Record; routerPrune = jids.filter((j) => j in obj).length; } console.log('\n=== DB rows to delete ==='); for (const p of plan) if (p.n) console.log(` ${p.table}: ${p.n}`); if (routerPrune) console.log(` router_state.last_agent_seq: ${routerPrune}`); // disk targets const diskDirs: string[] = []; for (const folder of safeFolders) { // Tribunal rooms keep separate reviewer/arbiter runtime dirs suffixed with // the role (e.g. data/sessions/-reviewer). Include those variants so // deregistration leaves no leftovers. const folderVariants = [folder, `${folder}-reviewer`, `${folder}-arbiter`]; for (const d of [ path.join(GROUPS_DIR, folder), ...folderVariants.flatMap((f) => [ path.join(DATA_DIR, 'workspaces', f), path.join(DATA_DIR, 'sessions', f), path.join(DATA_DIR, 'ipc', f), ]), ]) { if (fs.existsSync(d)) diskDirs.push(d); } } console.log('\n=== disk dirs to delete ==='); for (const d of diskDirs) console.log(` ${d}`); if (dryRun) { console.log('\n[dry-run] no changes made.'); process.exit(0); } // --- execute --- // Auto-backup the DB before the irreversible purge (skip with --no-backup). if (!noBackup) { const dbFile = path.join(STORE_DIR, 'messages.db'); const stamp = new Date() .toISOString() .replace(/[-:]/g, '') .replace(/\..+$/, '') .replace('T', '-'); const backup = path.join('/home/claude', `ejclaw-db-backup-${stamp}.db`); fs.copyFileSync(dbFile, backup); console.log(`DB backed up to ${backup}`); } db.exec('BEGIN'); try { for (const p of plan) p.run(); if (ras && routerPrune) { const obj = JSON.parse(ras.value) as Record; for (const j of jids) delete obj[j]; db.query('UPDATE router_state SET value=? WHERE key=?').run( JSON.stringify(obj), 'last_agent_seq', ); } db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); console.error('ROLLBACK due to error:', (e as Error).message); process.exit(1); } // remove git worktrees that live under a safe workspace folder, then rm -rf. function listWorktrees(): string[] { try { const out = execFileSync('git', ['worktree', 'list', '--porcelain'], { cwd: repoRoot, encoding: 'utf-8', }); return out .split('\n') .filter((l) => l.startsWith('worktree ')) .map((l) => l.slice('worktree '.length).trim()); } catch { return []; } } const worktrees = listWorktrees(); for (const folder of safeFolders) { const wsDir = path.join(DATA_DIR, 'workspaces', folder); for (const wt of worktrees) { if (wt === wsDir || wt.startsWith(wsDir + path.sep)) { try { execFileSync('git', ['worktree', 'remove', '--force', wt], { cwd: repoRoot, stdio: 'inherit', }); console.log(` git worktree removed: ${wt}`); } catch (e) { console.warn( ` worktree remove failed (${wt}): ${(e as Error).message}`, ); } } } } for (const d of diskDirs) { fs.rmSync(d, { recursive: true, force: true }); console.log(` removed ${d}`); } try { execFileSync('git', ['worktree', 'prune'], { cwd: repoRoot }); } catch { /* ignore */ } console.log('\nDone. Restart ejclaw to drop the room(s) from live bindings:'); console.log(' systemctl --user restart ejclaw.service');