diff --git a/scripts/deregister-room.ts b/scripts/deregister-room.ts new file mode 100644 index 0000000..ab2c3b2 --- /dev/null +++ b/scripts/deregister-room.ts @@ -0,0 +1,288 @@ +#!/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] [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 + */ +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 ids = args.filter((a) => !a.startsWith('--')); + +if (ids.length === 0) { + console.error( + 'Usage: bun scripts/deregister-room.ts [--dry-run] [--force] [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; + folder: string | null; + isMain: boolean; + name: string | null; + registered: boolean; +} + +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; + return { + jid, + folder: r?.folder ?? null, + 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) { + if (!t.folder) continue; + if (folderShared(t.folder)) sharedFolders.add(t.folder); + else safeFolders.add(t.folder); +} + +console.log('=== targets ==='); +for (const t of targets) { + console.log( + ` ${t.jid} | name=${t.name ?? '(unregistered)'} | folder=${t.folder ?? '-'} | 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(','); +const safeFolderList = [...safeFolders]; +const qF = safeFolderList.map(() => '?').join(','); + +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'); +addTask('task_run_logs'); +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. +if (safeFolderList.length) { + plan.push({ + table: 'sessions', + n: count('sessions', `group_folder IN (${qF})`, safeFolderList), + run: () => + db + .query(`DELETE FROM sessions WHERE group_folder IN (${qF})`) + .run(...safeFolderList), + }); +} + +// 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) { + for (const d of [ + path.join(GROUPS_DIR, folder), + path.join(DATA_DIR, 'workspaces', folder), + path.join(DATA_DIR, 'sessions', folder), + path.join(DATA_DIR, 'ipc', folder), + ]) { + 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 --- +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');