diff --git a/src/cli/crud.ts b/src/cli/crud.ts index 4889bf0f721..117a2b59a5a 100644 --- a/src/cli/crud.ts +++ b/src/cli/crud.ts @@ -69,6 +69,21 @@ export interface ResourceDef { }; /** Non-standard verbs (grant, revoke, add, remove, restart, etc.). */ customOperations?: Record; + /** + * Override the default DELETE for this resource. Use when the row has + * dependents that aren't covered by ON DELETE CASCADE — e.g. agent_groups + * has FK references from sessions/wirings/members/roles that need to be + * walked in one transaction. Called with the validated `id`; returns the + * number of rows removed from the primary table (0 means "not found"). + */ + customDelete?: (id: string) => number; + /** + * Prefix prepended to auto-generated IDs. Use when downstream systems + * constrain the ID format — e.g. OneCLI requires agent identifiers to + * start with a letter, so groups uses `ag-` to keep `randomUUID()` output + * valid. Resources without a prefix still get a raw UUID. + */ + idPrefix?: string; } // --------------------------------------------------------------------------- @@ -133,7 +148,11 @@ function genericCreate(def: ResourceDef) { for (const col of def.columns) { if (col.generated) { if (col.name === def.idColumn) { - values[col.name] = randomUUID(); + // Honor an explicit --id if the caller provides one; otherwise + // auto-generate. The prefix lets resources (e.g. groups) keep + // auto-IDs compatible with downstream identifier constraints. + const provided = args[col.name]; + values[col.name] = provided !== undefined ? String(provided) : `${def.idPrefix ?? ''}${randomUUID()}`; } else if (col.name.endsWith('_at')) { values[col.name] = new Date().toISOString(); } @@ -201,8 +220,10 @@ function genericDelete(def: ResourceDef) { return async (args: Record) => { const id = args.id as string; if (!id) throw new Error(`${def.name} id is required`); - const result = getDb().prepare(`DELETE FROM ${def.table} WHERE ${def.idColumn} = ?`).run(id); - if (result.changes === 0) throw new Error(`${def.name} not found: ${id}`); + const changes = def.customDelete + ? def.customDelete(id) + : getDb().prepare(`DELETE FROM ${def.table} WHERE ${def.idColumn} = ?`).run(id).changes; + if (changes === 0) throw new Error(`${def.name} not found: ${id}`); return { deleted: id }; }; } diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts index 031ccb5cad0..8d7e57c9277 100644 --- a/src/cli/resources/groups.ts +++ b/src/cli/resources/groups.ts @@ -9,6 +9,7 @@ import { updateContainerConfigJson, } from '../../db/container-configs.js'; import type { ContainerConfigRow } from '../../types.js'; +import { deleteAgentGroup } from '../../db/agent-groups.js'; import { registerResource } from '../crud.js'; /** Deserialize JSON columns for display. */ @@ -58,6 +59,8 @@ registerResource({ { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, ], operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, + customDelete: deleteAgentGroup, + idPrefix: 'ag-', customOperations: { restart: { access: 'approval', diff --git a/src/db/agent-groups.ts b/src/db/agent-groups.ts index db7e4029641..876b3eb17f8 100644 --- a/src/db/agent-groups.ts +++ b/src/db/agent-groups.ts @@ -39,6 +39,26 @@ export function updateAgentGroup(id: string, updates: Partial { + db.prepare('DELETE FROM sessions WHERE agent_group_id = ?').run(id); + db.prepare('DELETE FROM messaging_group_agents WHERE agent_group_id = ?').run(id); + db.prepare('DELETE FROM agent_group_members WHERE agent_group_id = ?').run(id); + db.prepare('DELETE FROM user_roles WHERE agent_group_id = ?').run(id); + db.prepare('DELETE FROM agent_destinations WHERE agent_group_id = ?').run(id); + db.prepare('UPDATE pending_approvals SET agent_group_id = NULL WHERE agent_group_id = ?').run(id); + db.prepare('DELETE FROM unregistered_senders WHERE agent_group_id = ?').run(id); + db.prepare('DELETE FROM pending_sender_approvals WHERE agent_group_id = ?').run(id); + db.prepare('DELETE FROM pending_channel_approvals WHERE agent_group_id = ?').run(id); + return db.prepare('DELETE FROM agent_groups WHERE id = ?').run(id).changes as number; + }); + return tx() as number; } diff --git a/src/db/db-v2.test.ts b/src/db/db-v2.test.ts index e0cebdf9340..fbedb219029 100644 --- a/src/db/db-v2.test.ts +++ b/src/db/db-v2.test.ts @@ -105,6 +105,48 @@ describe('agent groups', () => { createAgentGroup(ag()); expect(() => createAgentGroup({ ...ag(), id: 'ag-dup' })).toThrow(); }); + + it('cascades through sessions and wirings on delete', () => { + createAgentGroup(ag()); + createMessagingGroup({ + id: 'mg-1', + channel_type: 'discord', + platform_id: 'chan-1', + name: 'Gen', + is_group: 1, + unknown_sender_policy: 'strict', + created_at: now(), + }); + createMessagingGroupAgent({ + id: 'mga-1', + messaging_group_id: 'mg-1', + agent_group_id: 'ag-1', + engage_mode: 'pattern', + engage_pattern: '.', + sender_scope: 'all', + ignored_message_policy: 'drop', + session_mode: 'shared', + priority: 0, + created_at: now(), + }); + createSession({ + id: 'sess-1', + agent_group_id: 'ag-1', + messaging_group_id: 'mg-1', + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: now(), + created_at: now(), + }); + + // Without cascade, the session FK alone would block this. + expect(() => deleteAgentGroup('ag-1')).not.toThrow(); + expect(getAgentGroup('ag-1')).toBeUndefined(); + expect(getSession('sess-1')).toBeUndefined(); + expect(getMessagingGroupAgents('mg-1')).toHaveLength(0); + }); }); // ── Messaging Groups ──