When a room was unregistered first (room_settings row already gone), the folder was unknown, so sessions rows and the on-disk group/workspace/session/ ipc folders (and any git worktree) were silently skipped — leaving orphans. Back-trace the folder from the group_folder recorded on the chat's leftover paired_tasks/work_items/scheduled_tasks/service_handoffs/paired_projects rows so folder-scoped cleanup still runs. Targets now carry a folder list. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
348 lines
10 KiB
TypeScript
348 lines
10 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] [--no-backup] <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
|
|
* - the DB is auto-backed up to /home/claude/ejclaw-db-backup-<ts>.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] <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;
|
|
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<string>();
|
|
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<string>();
|
|
const sharedFolders = new Set<string>();
|
|
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 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');
|
|
// 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.
|
|
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 ---
|
|
// 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<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');
|