diff --git a/docs/server-api.md b/docs/server-api.md index f761549..b574303 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -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 diff --git a/drizzle/0025_fix_agents_fk_constraints.sql b/drizzle/0025_fix_agents_fk_constraints.sql new file mode 100644 index 0000000..c040fbc --- /dev/null +++ b/drizzle/0025_fix_agents_fk_constraints.sql @@ -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`); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index aa5c7a8..c6aa940 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/packages/web/src/components/InitiativeCard.tsx b/packages/web/src/components/InitiativeCard.tsx index 6bbeddf..6736f74 100644 --- a/packages/web/src/components/InitiativeCard.tsx +++ b/packages/web/src/components/InitiativeCard.tsx @@ -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({ Delete diff --git a/packages/web/src/components/InitiativeList.tsx b/packages/web/src/components/InitiativeList.tsx index 31ecd55..0b688da 100644 --- a/packages/web/src/components/InitiativeList.tsx +++ b/packages/web/src/components/InitiativeList.tsx @@ -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)} /> ))} diff --git a/packages/web/src/routes/initiatives/index.tsx b/packages/web/src/routes/initiatives/index.tsx index 1d04e49..9f567ee 100644 --- a/packages/web/src/routes/initiatives/index.tsx +++ b/packages/web/src/routes/initiatives/index.tsx @@ -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 */} diff --git a/src/db/repositories/drizzle/cascade.test.ts b/src/db/repositories/drizzle/cascade.test.ts index 90318ce..3441736 100644 --- a/src/db/repositories/drizzle/cascade.test.ts +++ b/src/db/repositories/drizzle/cascade.test.ts @@ -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(); }); }); diff --git a/src/trpc/routers/initiative.ts b/src/trpc/routers/initiative.ts index 11181d4..bfc6f7c 100644 --- a/src/trpc/routers/initiative.ts +++ b/src/trpc/routers/initiative.ts @@ -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),