mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
Merge pull request #2526 from glifocat/fix/2525-groups-delete-cascade
fix(cli): cascade dependent rows on groups delete (#2525)
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Regression test for #2525 — `ncl groups delete` must cascade dependent
|
||||
* rows in FK order so the final `DELETE FROM agent_groups` succeeds even
|
||||
* when the group has sessions, destinations, approvals, role grants, etc.
|
||||
*
|
||||
* The bug pre-fix: the generic single-table DELETE handler ran a bare
|
||||
* `DELETE FROM agent_groups WHERE id = ?` which always failed with a
|
||||
* `SQLITE_CONSTRAINT_FOREIGNKEY` when anything pointed at the group.
|
||||
*
|
||||
* The approval handler in `dispatch.ts` re-enters `dispatch()` with
|
||||
* `caller: 'host'` after admin approval, so the test invokes dispatch
|
||||
* with the host caller — same code path a real approval would take.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
isContainerRunning: vi.fn().mockReturnValue(false),
|
||||
getActiveContainerCount: vi.fn().mockReturnValue(0),
|
||||
killContainer: vi.fn(),
|
||||
buildAgentGroupImage: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-cli-groups' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-cli-groups';
|
||||
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup, getDb } from '../../db/index.js';
|
||||
import { createSession } from '../../db/sessions.js';
|
||||
import { dispatch } from '../dispatch.js';
|
||||
// Side-effect import: registers the `groups-*` commands (including delete).
|
||||
import './groups.js';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function count(sql: string, ...params: unknown[]): number {
|
||||
return (
|
||||
getDb()
|
||||
.prepare(sql)
|
||||
.get(...params) as { c: number }
|
||||
).c;
|
||||
}
|
||||
|
||||
describe('groups CLI delete cascades dependent rows (#2525)', () => {
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it('deletes a group with sessions, destinations, approvals, members, roles, and wirings', async () => {
|
||||
const GID = 'ag-victim';
|
||||
const SID = 'sess-victim-1';
|
||||
const MGID = 'mg-1';
|
||||
const UID = 'tg:42';
|
||||
|
||||
createAgentGroup({ id: GID, name: 'victim', folder: 'victim', agent_provider: null, created_at: now() });
|
||||
createSession({
|
||||
id: SID,
|
||||
agent_group_id: GID,
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Direct inserts for the dependent tables. Keeps the fixture minimal —
|
||||
// we only need rows that establish FK relationships, not full domain
|
||||
// entities.
|
||||
db.prepare(`INSERT INTO users (id, kind, display_name, created_at) VALUES (?, 'telegram', 'someone', ?)`).run(
|
||||
UID,
|
||||
now(),
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES (?, 'telegram', 'tg-1', 'chat', 1, 'strict', ?)`,
|
||||
).run(MGID, now());
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
|
||||
VALUES (?, 'chan', 'channel', ?, ?)`,
|
||||
).run(GID, MGID, now());
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO pending_questions (question_id, session_id, message_out_id, title, options_json, created_at)
|
||||
VALUES (?, ?, 'mout-1', 'q', '[]', ?)`,
|
||||
).run('q-1', SID, now());
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, agent_group_id, status, title, options_json)
|
||||
VALUES (?, ?, 'req-1', 'cli_command', '{}', ?, ?, 'pending', '', '[]')`,
|
||||
).run('pa-1', SID, now(), GID);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO pending_sender_approvals (id, messaging_group_id, agent_group_id, sender_identity, sender_name, original_message, approver_user_id, created_at)
|
||||
VALUES ('psa-1', ?, ?, 'tg:99', 'them', '{}', ?, ?)`,
|
||||
).run(MGID, GID, UID, now());
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO pending_channel_approvals (messaging_group_id, agent_group_id, original_message, approver_user_id, created_at)
|
||||
VALUES (?, ?, '{}', ?, ?)`,
|
||||
).run(MGID, GID, UID, now());
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, engage_mode, sender_scope, ignored_message_policy, session_mode, priority, created_at)
|
||||
VALUES ('mga-1', ?, ?, 'mention', 'all', 'drop', 'shared', 0, ?)`,
|
||||
).run(MGID, GID, now());
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO agent_group_members (user_id, agent_group_id, added_by, added_at) VALUES (?, ?, NULL, ?)`,
|
||||
).run(UID, GID, now());
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at) VALUES (?, 'admin', ?, NULL, ?)`,
|
||||
).run(UID, GID, now());
|
||||
|
||||
// Container config row exercises the ON DELETE CASCADE on container_configs.
|
||||
db.prepare(
|
||||
`INSERT INTO container_configs
|
||||
(agent_group_id, provider, model, effort, image_tag, assistant_name, max_messages_per_prompt,
|
||||
skills, mcp_servers, packages_apt, packages_npm, additional_mounts, cli_scope, updated_at)
|
||||
VALUES (?, NULL, NULL, NULL, NULL, NULL, NULL, '"all"', '{}', '[]', '[]', '[]', 'group', ?)`,
|
||||
).run(GID, now());
|
||||
|
||||
const resp = await dispatch({ id: 'req-del', command: 'groups-delete', args: { id: GID } }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
const data = (resp as { ok: true; data: { deleted: string; removed: Record<string, number> } }).data;
|
||||
expect(data.deleted).toBe(GID);
|
||||
expect(data.removed).toMatchObject({
|
||||
sessions: 1,
|
||||
pending_questions: 1,
|
||||
pending_approvals: 1,
|
||||
agent_destinations_owned: 1,
|
||||
agent_destinations_pointing: 0,
|
||||
pending_sender_approvals: 1,
|
||||
pending_channel_approvals: 1,
|
||||
messaging_group_agents: 1,
|
||||
agent_group_members: 1,
|
||||
user_roles: 1,
|
||||
container_configs: 1,
|
||||
});
|
||||
|
||||
// The group and every dependent row must be gone.
|
||||
expect(count('SELECT COUNT(*) AS c FROM agent_groups WHERE id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM sessions WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM pending_questions WHERE session_id = ?', SID)).toBe(0);
|
||||
expect(
|
||||
count('SELECT COUNT(*) AS c FROM pending_approvals WHERE agent_group_id = ? OR session_id = ?', GID, SID),
|
||||
).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM agent_destinations WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM pending_sender_approvals WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM pending_channel_approvals WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM agent_group_members WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM user_roles WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM container_configs WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
|
||||
// Unrelated tables untouched.
|
||||
expect(count('SELECT COUNT(*) AS c FROM users WHERE id = ?', UID)).toBe(1);
|
||||
expect(count('SELECT COUNT(*) AS c FROM messaging_groups WHERE id = ?', MGID)).toBe(1);
|
||||
});
|
||||
|
||||
it('removes polymorphic agent_destinations that point at the deleted group', async () => {
|
||||
const A = 'ag-a';
|
||||
const B = 'ag-b';
|
||||
createAgentGroup({ id: A, name: 'a', folder: 'a', agent_provider: null, created_at: now() });
|
||||
createAgentGroup({ id: B, name: 'b', folder: 'b', agent_provider: null, created_at: now() });
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// B has a destination pointing at A. target_id is polymorphic — no FK
|
||||
// constraint enforces it, so without explicit cleanup the row would
|
||||
// dangle after A is deleted.
|
||||
db.prepare(
|
||||
`INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
|
||||
VALUES (?, 'sibling', 'agent', ?, ?)`,
|
||||
).run(B, A, now());
|
||||
|
||||
const resp = await dispatch({ id: 'req-del-a', command: 'groups-delete', args: { id: A } }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
const data = (resp as { ok: true; data: { removed: Record<string, number> } }).data;
|
||||
expect(data.removed.agent_destinations_pointing).toBe(1);
|
||||
|
||||
// A is gone, B remains, and B's stale destination is cleaned up.
|
||||
expect(count('SELECT COUNT(*) AS c FROM agent_groups WHERE id = ?', A)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM agent_groups WHERE id = ?', B)).toBe(1);
|
||||
expect(count('SELECT COUNT(*) AS c FROM agent_destinations WHERE agent_group_id = ?', B)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns a handler error for an unknown group id', async () => {
|
||||
const resp = await dispatch(
|
||||
{ id: 'req-missing', command: 'groups-delete', args: { id: 'ag-does-not-exist' } },
|
||||
{ caller: 'host' },
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
expect((resp as { ok: false; error: { code: string; message: string } }).error.code).toBe('handler-error');
|
||||
expect((resp as { ok: false; error: { code: string; message: string } }).error.message).toMatch(/not found/i);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { McpServerConfig } from '../../container-config.js';
|
||||
import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js';
|
||||
import { restartAgentGroupContainers } from '../../container-restart.js';
|
||||
import { getDb, hasTable } from '../../db/connection.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import {
|
||||
@@ -57,8 +58,97 @@ registerResource({
|
||||
},
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||
],
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
|
||||
// `delete` is intentionally not in `operations` — the generic single-table
|
||||
// DELETE violates FK constraints (see #2525). The cascading handler is
|
||||
// provided as `customOperations.delete` below.
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' },
|
||||
customOperations: {
|
||||
delete: {
|
||||
access: 'approval',
|
||||
description:
|
||||
'Delete an agent group and its dependent rows (sessions, destinations, approvals, role grants, ' +
|
||||
'memberships, channel wirings). FK-ordered cascade in a single transaction. ' +
|
||||
'Use --id <group-id>. Out of scope: killing running containers, on-disk cleanup of groups/<folder>/ and data/v2-sessions/<group-id>/.',
|
||||
handler: async (args) => {
|
||||
const id = args.id as string;
|
||||
if (!id) throw new Error('--id is required');
|
||||
const db = getDb();
|
||||
|
||||
// Verify the group exists before doing anything — preserves the
|
||||
// genericDelete behaviour of throwing "not found" for unknown IDs.
|
||||
const exists = db.prepare('SELECT 1 FROM agent_groups WHERE id = ? LIMIT 1').get(id);
|
||||
if (!exists) throw new Error(`group not found: ${id}`);
|
||||
|
||||
const hasAgentDestinations = hasTable(db, 'agent_destinations');
|
||||
const hasPendingApprovals = hasTable(db, 'pending_approvals');
|
||||
|
||||
// FK-ordered cascade. Single sync transaction — better-sqlite3 rolls
|
||||
// back the whole thing if any statement throws (e.g. an FK constraint
|
||||
// we missed), so the central DB stays consistent. The `removed` counts
|
||||
// are sourced from each DELETE's `changes` so they describe exactly
|
||||
// what the transaction did, not a separate pre-flight snapshot.
|
||||
const cascade = db.transaction((groupId: string) => {
|
||||
const counts = {
|
||||
sessions: 0,
|
||||
pending_questions: 0,
|
||||
pending_approvals: 0,
|
||||
agent_destinations_owned: 0,
|
||||
agent_destinations_pointing: 0,
|
||||
pending_sender_approvals: 0,
|
||||
pending_channel_approvals: 0,
|
||||
messaging_group_agents: 0,
|
||||
agent_group_members: 0,
|
||||
user_roles: 0,
|
||||
container_configs: 0,
|
||||
};
|
||||
|
||||
if (hasAgentDestinations) {
|
||||
counts.agent_destinations_owned = db
|
||||
.prepare('DELETE FROM agent_destinations WHERE agent_group_id = ?')
|
||||
.run(groupId).changes;
|
||||
counts.agent_destinations_pointing = db
|
||||
.prepare('DELETE FROM agent_destinations WHERE target_type = ? AND target_id = ?')
|
||||
.run('agent', groupId).changes;
|
||||
}
|
||||
counts.pending_questions = db
|
||||
.prepare(
|
||||
'DELETE FROM pending_questions WHERE session_id IN (SELECT id FROM sessions WHERE agent_group_id = ?)',
|
||||
)
|
||||
.run(groupId).changes;
|
||||
if (hasPendingApprovals) {
|
||||
counts.pending_approvals = db
|
||||
.prepare(
|
||||
'DELETE FROM pending_approvals WHERE agent_group_id = ? OR session_id IN (SELECT id FROM sessions WHERE agent_group_id = ?)',
|
||||
)
|
||||
.run(groupId, groupId).changes;
|
||||
}
|
||||
counts.sessions = db.prepare('DELETE FROM sessions WHERE agent_group_id = ?').run(groupId).changes;
|
||||
counts.pending_sender_approvals = db
|
||||
.prepare('DELETE FROM pending_sender_approvals WHERE agent_group_id = ?')
|
||||
.run(groupId).changes;
|
||||
counts.pending_channel_approvals = db
|
||||
.prepare('DELETE FROM pending_channel_approvals WHERE agent_group_id = ?')
|
||||
.run(groupId).changes;
|
||||
counts.messaging_group_agents = db
|
||||
.prepare('DELETE FROM messaging_group_agents WHERE agent_group_id = ?')
|
||||
.run(groupId).changes;
|
||||
counts.agent_group_members = db
|
||||
.prepare('DELETE FROM agent_group_members WHERE agent_group_id = ?')
|
||||
.run(groupId).changes;
|
||||
counts.user_roles = db.prepare('DELETE FROM user_roles WHERE agent_group_id = ?').run(groupId).changes;
|
||||
// migration-014 has ON DELETE CASCADE on container_configs.agent_group_id;
|
||||
// the explicit delete here mirrors the other tables and surfaces the count.
|
||||
counts.container_configs = db
|
||||
.prepare('DELETE FROM container_configs WHERE agent_group_id = ?')
|
||||
.run(groupId).changes;
|
||||
db.prepare('DELETE FROM agent_groups WHERE id = ?').run(groupId);
|
||||
return counts;
|
||||
});
|
||||
const removed = cascade(id);
|
||||
|
||||
return { deleted: id, removed };
|
||||
},
|
||||
},
|
||||
restart: {
|
||||
access: 'approval',
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user