Files
EJClaw/scripts/deregister-room.ts
Codex 77426fc15c feat(scripts): add deregister-room purge script
Reusable script to fully unregister chat room(s) and purge all managed data
while keeping ONLY the chats channel row and messages history. Removes room
registration, paired tasks/turns/attempts/outputs/reservations/leases/
projects/handoffs, work items, scheduled tasks, sessions, router cursor, and
the on-disk group/workspace/session/ipc folders (git worktrees removed
cleanly). Supports --dry-run, refuses main rooms without --force, and skips
disk deletion for group folders still shared by another room.

Backs the "채팅 등록 해제" standing workflow (unregister + purge + restart).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-18 23:30:35 +09:00

289 lines
8.2 KiB
TypeScript

#!/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] <channelId|jid> [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] <channelId|jid> [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<string>();
const sharedFolders = new Set<string>();
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<string, unknown>;
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<string, unknown>;
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');