feat: Wire up initiative deletion end-to-end
Add deleteInitiative tRPC procedure, wire Delete button in InitiativeCard with confirm dialog (Shift+click bypass), remove unused onDelete prop chain. Fix agents table FK constraints (initiative_id, account_id missing ON DELETE SET NULL) via table recreation migration. Register conversations migration in journal. Expand cascade delete tests to cover pages, projects, change sets, agents (set null), and conversations (set null).
This commit is contained in:
@@ -88,6 +88,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
||||
| listInitiatives | query | Filter by status |
|
||||
| getInitiative | query | With projects array |
|
||||
| updateInitiative | mutation | Name, status |
|
||||
| deleteInitiative | mutation | Cascade delete initiative and all children |
|
||||
| updateInitiativeConfig | mutation | mergeRequiresApproval, executionMode, branch |
|
||||
|
||||
### Phases
|
||||
|
||||
32
drizzle/0025_fix_agents_fk_constraints.sql
Normal file
32
drizzle/0025_fix_agents_fk_constraints.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- Fix agents table FK constraints: initiative_id and account_id need ON DELETE SET NULL
|
||||
-- SQLite cannot ALTER column constraints, so we must recreate the table
|
||||
|
||||
CREATE TABLE `agents_new` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`task_id` text REFERENCES `tasks`(`id`) ON DELETE SET NULL,
|
||||
`initiative_id` text REFERENCES `initiatives`(`id`) ON DELETE SET NULL,
|
||||
`session_id` text,
|
||||
`worktree_id` text NOT NULL,
|
||||
`provider` text DEFAULT 'claude' NOT NULL,
|
||||
`account_id` text REFERENCES `accounts`(`id`) ON DELETE SET NULL,
|
||||
`status` text DEFAULT 'idle' NOT NULL,
|
||||
`mode` text DEFAULT 'execute' NOT NULL,
|
||||
`pid` integer,
|
||||
`exit_code` integer,
|
||||
`output_file_path` text,
|
||||
`result` text,
|
||||
`pending_questions` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
`user_dismissed_at` integer
|
||||
);--> statement-breakpoint
|
||||
INSERT INTO `agents_new` SELECT
|
||||
`id`, `name`, `task_id`, `initiative_id`, `session_id`, `worktree_id`,
|
||||
`provider`, `account_id`, `status`, `mode`, `pid`, `exit_code`,
|
||||
`output_file_path`, `result`, `pending_questions`,
|
||||
`created_at`, `updated_at`, `user_dismissed_at`
|
||||
FROM `agents`;--> statement-breakpoint
|
||||
DROP TABLE `agents`;--> statement-breakpoint
|
||||
ALTER TABLE `agents_new` RENAME TO `agents`;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `agents_name_unique` ON `agents` (`name`);
|
||||
@@ -169,6 +169,20 @@
|
||||
"when": 1771545600000,
|
||||
"tag": "0023_rename_breakdown_decompose",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "6",
|
||||
"when": 1771632000000,
|
||||
"tag": "0024_add_conversations",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "6",
|
||||
"when": 1771718400000,
|
||||
"tag": "0025_fix_agents_fk_constraints",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -27,19 +27,20 @@ interface InitiativeCardProps {
|
||||
initiative: SerializedInitiative;
|
||||
onView: () => void;
|
||||
onSpawnArchitect: (mode: "discuss" | "plan") => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function InitiativeCard({
|
||||
initiative,
|
||||
onView,
|
||||
onSpawnArchitect,
|
||||
onDelete,
|
||||
}: InitiativeCardProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const archiveMutation = trpc.updateInitiative.useMutation({
|
||||
onSuccess: () => utils.listInitiatives.invalidate(),
|
||||
});
|
||||
const deleteMutation = trpc.deleteInitiative.useMutation({
|
||||
onSuccess: () => utils.listInitiatives.invalidate(),
|
||||
});
|
||||
|
||||
function handleArchive(e: React.MouseEvent) {
|
||||
if (
|
||||
@@ -51,6 +52,16 @@ export function InitiativeCard({
|
||||
archiveMutation.mutate({ id: initiative.id, status: "archived" });
|
||||
}
|
||||
|
||||
function handleDelete(e: React.MouseEvent) {
|
||||
if (
|
||||
!e.shiftKey &&
|
||||
!window.confirm(`Delete "${initiative.name}"? This cannot be undone.`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate({ id: initiative.id });
|
||||
}
|
||||
|
||||
// Each card fetches its own phase stats (N+1 acceptable for v1 small counts)
|
||||
const phasesQuery = trpc.listPhases.useQuery({
|
||||
initiativeId: initiative.id,
|
||||
@@ -128,7 +139,7 @@ export function InitiativeCard({
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={onDelete}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -13,7 +13,6 @@ interface InitiativeListProps {
|
||||
initiativeId: string,
|
||||
mode: "discuss" | "plan",
|
||||
) => void;
|
||||
onDeleteInitiative: (id: string) => void;
|
||||
}
|
||||
|
||||
export function InitiativeList({
|
||||
@@ -21,7 +20,6 @@ export function InitiativeList({
|
||||
onCreateNew,
|
||||
onViewInitiative,
|
||||
onSpawnArchitect,
|
||||
onDeleteInitiative,
|
||||
}: InitiativeListProps) {
|
||||
const initiativesQuery = trpc.listInitiatives.useQuery(
|
||||
statusFilter === "all" ? undefined : { status: statusFilter },
|
||||
@@ -95,7 +93,6 @@ export function InitiativeList({
|
||||
initiative={initiative}
|
||||
onView={() => onViewInitiative(initiative.id)}
|
||||
onSpawnArchitect={(mode) => onSpawnArchitect(initiative.id, mode)}
|
||||
onDelete={() => onDeleteInitiative(initiative.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -67,10 +67,6 @@ function DashboardPage() {
|
||||
// Architect spawning is self-contained within SpawnArchitectDropdown
|
||||
// This callback is available for future toast notifications
|
||||
}}
|
||||
onDeleteInitiative={(_id) => {
|
||||
// Delete is a placeholder (no deleteInitiative tRPC procedure yet)
|
||||
// ActionMenu handles this internally when implemented
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Create initiative dialog */}
|
||||
|
||||
@@ -6,10 +6,17 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { DrizzleInitiativeRepository } from './initiative.js';
|
||||
import { DrizzlePhaseRepository } from './phase.js';
|
||||
import { DrizzleTaskRepository } from './task.js';
|
||||
import { DrizzlePageRepository } from './page.js';
|
||||
import { DrizzleProjectRepository } from './project.js';
|
||||
import { DrizzleChangeSetRepository } from './change-set.js';
|
||||
import { DrizzleAgentRepository } from './agent.js';
|
||||
import { DrizzleConversationRepository } from './conversation.js';
|
||||
import { createTestDatabase } from './test-helpers.js';
|
||||
import { changeSets } from '../../schema.js';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
|
||||
describe('Cascade Deletes', () => {
|
||||
@@ -17,12 +24,22 @@ describe('Cascade Deletes', () => {
|
||||
let initiativeRepo: DrizzleInitiativeRepository;
|
||||
let phaseRepo: DrizzlePhaseRepository;
|
||||
let taskRepo: DrizzleTaskRepository;
|
||||
let pageRepo: DrizzlePageRepository;
|
||||
let projectRepo: DrizzleProjectRepository;
|
||||
let changeSetRepo: DrizzleChangeSetRepository;
|
||||
let agentRepo: DrizzleAgentRepository;
|
||||
let conversationRepo: DrizzleConversationRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
initiativeRepo = new DrizzleInitiativeRepository(db);
|
||||
phaseRepo = new DrizzlePhaseRepository(db);
|
||||
taskRepo = new DrizzleTaskRepository(db);
|
||||
pageRepo = new DrizzlePageRepository(db);
|
||||
projectRepo = new DrizzleProjectRepository(db);
|
||||
changeSetRepo = new DrizzleChangeSetRepository(db);
|
||||
agentRepo = new DrizzleAgentRepository(db);
|
||||
conversationRepo = new DrizzleConversationRepository(db);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -102,17 +119,72 @@ describe('Cascade Deletes', () => {
|
||||
order: 1,
|
||||
});
|
||||
|
||||
// Create a page for the initiative
|
||||
const page = await pageRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
parentPageId: null,
|
||||
title: 'Root Page',
|
||||
content: null,
|
||||
sortOrder: 0,
|
||||
});
|
||||
|
||||
// Create a project and link it via junction table
|
||||
const project = await projectRepo.create({
|
||||
name: 'test-project',
|
||||
url: 'https://github.com/test/test-project.git',
|
||||
});
|
||||
await projectRepo.setInitiativeProjects(initiative.id, [project.id]);
|
||||
|
||||
// Create two agents (need two for conversations, and one for changeSet FK)
|
||||
const agent1 = await agentRepo.create({
|
||||
name: 'agent-1',
|
||||
worktreeId: 'wt-1',
|
||||
initiativeId: initiative.id,
|
||||
});
|
||||
const agent2 = await agentRepo.create({
|
||||
name: 'agent-2',
|
||||
worktreeId: 'wt-2',
|
||||
initiativeId: initiative.id,
|
||||
});
|
||||
|
||||
// Insert change set directly (createWithEntries uses async tx, incompatible with better-sqlite3 sync driver)
|
||||
const changeSetId = nanoid();
|
||||
await db.insert(changeSets).values({
|
||||
id: changeSetId,
|
||||
agentId: agent1.id,
|
||||
agentName: agent1.name,
|
||||
initiativeId: initiative.id,
|
||||
mode: 'plan',
|
||||
status: 'applied',
|
||||
createdAt: new Date(),
|
||||
});
|
||||
const changeSet = (await changeSetRepo.findById(changeSetId))!;
|
||||
|
||||
// Create a conversation between agents with initiative context
|
||||
const conversation = await conversationRepo.create({
|
||||
fromAgentId: agent1.id,
|
||||
toAgentId: agent2.id,
|
||||
initiativeId: initiative.id,
|
||||
question: 'Test question',
|
||||
});
|
||||
|
||||
return {
|
||||
initiative,
|
||||
phases: { phase1, phase2 },
|
||||
parentTasks: { parentTask1, parentTask2, parentTask3 },
|
||||
tasks: { task1, task2, task3, task4 },
|
||||
page,
|
||||
project,
|
||||
changeSet,
|
||||
agents: { agent1, agent2 },
|
||||
conversation,
|
||||
};
|
||||
}
|
||||
|
||||
describe('delete initiative', () => {
|
||||
it('should cascade delete all phases and tasks', async () => {
|
||||
const { initiative, phases, parentTasks, tasks } = await createFullHierarchy();
|
||||
it('should cascade delete all phases, tasks, pages, junction rows, and change sets', async () => {
|
||||
const { initiative, phases, parentTasks, tasks, page, project, changeSet } =
|
||||
await createFullHierarchy();
|
||||
|
||||
// Verify everything exists
|
||||
expect(await initiativeRepo.findById(initiative.id)).not.toBeNull();
|
||||
@@ -125,11 +197,15 @@ describe('Cascade Deletes', () => {
|
||||
expect(await taskRepo.findById(tasks.task2.id)).not.toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task3.id)).not.toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task4.id)).not.toBeNull();
|
||||
expect(await pageRepo.findById(page.id)).not.toBeNull();
|
||||
expect(await changeSetRepo.findById(changeSet.id)).not.toBeNull();
|
||||
const linkedProjects = await projectRepo.findProjectsByInitiativeId(initiative.id);
|
||||
expect(linkedProjects).toHaveLength(1);
|
||||
|
||||
// Delete initiative
|
||||
await initiativeRepo.delete(initiative.id);
|
||||
|
||||
// Verify everything is gone
|
||||
// Verify cascade deletes — all gone
|
||||
expect(await initiativeRepo.findById(initiative.id)).toBeNull();
|
||||
expect(await phaseRepo.findById(phases.phase1.id)).toBeNull();
|
||||
expect(await phaseRepo.findById(phases.phase2.id)).toBeNull();
|
||||
@@ -140,6 +216,38 @@ describe('Cascade Deletes', () => {
|
||||
expect(await taskRepo.findById(tasks.task2.id)).toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task3.id)).toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task4.id)).toBeNull();
|
||||
expect(await pageRepo.findById(page.id)).toBeNull();
|
||||
expect(await changeSetRepo.findById(changeSet.id)).toBeNull();
|
||||
|
||||
// Junction row gone but project itself survives
|
||||
const remainingLinks = await projectRepo.findProjectsByInitiativeId(initiative.id);
|
||||
expect(remainingLinks).toHaveLength(0);
|
||||
expect(await projectRepo.findById(project.id)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should set null on agents and conversations (not cascade)', async () => {
|
||||
const { initiative, agents, conversation } = await createFullHierarchy();
|
||||
|
||||
// Verify agents are linked
|
||||
const a1Before = await agentRepo.findById(agents.agent1.id);
|
||||
expect(a1Before!.initiativeId).toBe(initiative.id);
|
||||
|
||||
// Delete initiative
|
||||
await initiativeRepo.delete(initiative.id);
|
||||
|
||||
// Agents survive with initiativeId set to null
|
||||
const a1After = await agentRepo.findById(agents.agent1.id);
|
||||
expect(a1After).not.toBeNull();
|
||||
expect(a1After!.initiativeId).toBeNull();
|
||||
|
||||
const a2After = await agentRepo.findById(agents.agent2.id);
|
||||
expect(a2After).not.toBeNull();
|
||||
expect(a2After!.initiativeId).toBeNull();
|
||||
|
||||
// Conversation survives with initiativeId set to null
|
||||
const convAfter = await conversationRepo.findById(conversation.id);
|
||||
expect(convAfter).not.toBeNull();
|
||||
expect(convAfter!.initiativeId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -108,6 +108,14 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||
return repo.update(id, data);
|
||||
}),
|
||||
|
||||
deleteInitiative: publicProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const repo = requireInitiativeRepository(ctx);
|
||||
await repo.delete(input.id);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
updateInitiativeConfig: publicProcedure
|
||||
.input(z.object({
|
||||
initiativeId: z.string().min(1),
|
||||
|
||||
Reference in New Issue
Block a user