Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions src/cli/crud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ export interface ResourceDef {
};
/** Non-standard verbs (grant, revoke, add, remove, restart, etc.). */
customOperations?: Record<string, CustomOperation>;
/**
* 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;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -201,8 +220,10 @@ function genericDelete(def: ResourceDef) {
return async (args: Record<string, unknown>) => {
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 };
};
}
Expand Down
3 changes: 3 additions & 0 deletions src/cli/resources/groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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',
Expand Down
24 changes: 22 additions & 2 deletions src/db/agent-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ export function updateAgentGroup(id: string, updates: Partial<Pick<AgentGroup, '
.run(values);
}

export function deleteAgentGroup(id: string): void {
getDb().prepare('DELETE FROM agent_groups WHERE id = ?').run(id);
/**
* Deletes the agent group and all rows that reference it. The original FK
* declarations were authored without ON DELETE CASCADE, so this function
* walks every dependent table in one transaction. An agent group with a
* dangling session/wiring/member/role row is unusable anyway — the container,
* inbound/outbound DBs, and routing config are all gone.
*/
export function deleteAgentGroup(id: string): number {
const db = getDb();
const tx = db.transaction(() => {
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;
}
42 changes: 42 additions & 0 deletions src/db/db-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──
Expand Down
Loading