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),