From 8387d5b22c73bfde2f0bd8ca72f82a6cafd53788 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 17:29:09 +0100 Subject: [PATCH 01/54] chore: Gitignore agent-generated screenshots --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index af99b39..7cea6a2 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ workdir/* # Agent working directories agent-workdirs/ +# Agent-generated screenshots +.screenshots/ + # Logs *.log npm-debug.log* From 2eac5b9908e37e7fb2c7b3bdcabc3cd87a68cf4b Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 17:50:01 +0100 Subject: [PATCH 02/54] fix: Remove agent:output from general SSE subscriptions to prevent listener leak agent:output is high-frequency streaming data that was included in ALL_EVENT_TYPES and AGENT_EVENT_TYPES, causing every onEvent/onAgentUpdate SSE subscription to register a listener. With multiple subscriptions per browser tab plus reconnections, this exceeded the 100 listener limit. The dedicated onAgentOutput subscription handles output streaming already. Bonus: stops useLiveUpdates from refetching listAgents on every output chunk. --- apps/server/trpc/subscriptions.ts | 2 -- docs/server-api.md | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/server/trpc/subscriptions.ts b/apps/server/trpc/subscriptions.ts index 97cf38b..027e055 100644 --- a/apps/server/trpc/subscriptions.ts +++ b/apps/server/trpc/subscriptions.ts @@ -40,7 +40,6 @@ export const ALL_EVENT_TYPES: DomainEventType[] = [ 'agent:account_switched', 'agent:deleted', 'agent:waiting', - 'agent:output', 'task:queued', 'task:dispatched', 'task:completed', @@ -84,7 +83,6 @@ export const AGENT_EVENT_TYPES: DomainEventType[] = [ 'agent:account_switched', 'agent:deleted', 'agent:waiting', - 'agent:output', ]; /** diff --git a/docs/server-api.md b/docs/server-api.md index 2b69321..f1faab4 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -204,13 +204,13 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | Procedure | Type | Events | |-----------|------|--------| | onEvent | subscription | All event types | -| onAgentUpdate | subscription | agent:* events (8 types) | +| onAgentUpdate | subscription | agent:* events (7 types, excludes agent:output) | | onTaskUpdate | subscription | task:* + phase:* events (8 types) | | onPageUpdate | subscription | page:created/updated/deleted | | onPreviewUpdate | subscription | preview:building/ready/stopped/failed | | onConversationUpdate | subscription | conversation:created/answered | -Subscriptions use `eventBusIterable()` — queue-based async generator, max 1000 events, 30s heartbeat. +Subscriptions use `eventBusIterable()` — queue-based async generator, max 1000 events, 30s heartbeat. `agent:output` is excluded from all general subscriptions (it's high-frequency streaming data); use the dedicated `onAgentOutput` subscription instead. ## Coordination Module From d81e0864f7010d65bc893e1b19f3509f65955291 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 20:41:49 +0100 Subject: [PATCH 03/54] feat: Add retry mechanism for blocked tasks Blocked tasks (from spawn failures) were a dead-end with no way to recover. Add retryBlockedTask to DispatchManager that resets status to pending and re-queues, a tRPC mutation that also kicks dispatchNext, and a Retry button in the task slide-over when status is blocked. --- apps/server/dispatch/manager.ts | 21 ++++++++++ apps/server/dispatch/phase-manager.test.ts | 1 + apps/server/dispatch/types.ts | 8 ++++ apps/server/trpc/routers/dispatch.ts | 10 +++++ .../components/execution/TaskSlideOver.tsx | 40 +++++++++++++------ docs/dispatch-events.md | 2 + 6 files changed, 70 insertions(+), 12 deletions(-) diff --git a/apps/server/dispatch/manager.ts b/apps/server/dispatch/manager.ts index b799e57..ad17f7e 100644 --- a/apps/server/dispatch/manager.ts +++ b/apps/server/dispatch/manager.ts @@ -237,6 +237,27 @@ export class DefaultDispatchManager implements DispatchManager { this.eventBus.emit(event); } + /** + * Retry a blocked task. + * Resets status to pending, clears block state, and re-queues for dispatch. + */ + async retryBlockedTask(taskId: string): Promise { + const task = await this.taskRepository.findById(taskId); + if (!task) throw new Error(`Task not found: ${taskId}`); + if (task.status !== 'blocked') throw new Error(`Task ${taskId} is not blocked (status: ${task.status})`); + + // Clear blocked state + this.blockedTasks.delete(taskId); + + // Reset DB status to pending + await this.taskRepository.update(taskId, { status: 'pending' }); + + log.info({ taskId }, 'retrying blocked task'); + + // Re-queue for dispatch + await this.queue(taskId); + } + /** * Dispatch next available task to an agent. */ diff --git a/apps/server/dispatch/phase-manager.test.ts b/apps/server/dispatch/phase-manager.test.ts index 246e141..bd57241 100644 --- a/apps/server/dispatch/phase-manager.test.ts +++ b/apps/server/dispatch/phase-manager.test.ts @@ -50,6 +50,7 @@ function createMockDispatchManager(): DispatchManager { dispatchNext: vi.fn().mockResolvedValue({ success: false, reason: 'mock' }), completeTask: vi.fn(), blockTask: vi.fn(), + retryBlockedTask: vi.fn(), getQueueState: vi.fn().mockResolvedValue({ queued: [], ready: [], blocked: [] }), }; } diff --git a/apps/server/dispatch/types.ts b/apps/server/dispatch/types.ts index 6c86111..6478ce2 100644 --- a/apps/server/dispatch/types.ts +++ b/apps/server/dispatch/types.ts @@ -102,6 +102,14 @@ export interface DispatchManager { */ blockTask(taskId: string, reason: string): Promise; + /** + * Retry a blocked task. + * Resets status to pending, removes from blocked map, and re-queues for dispatch. + * + * @param taskId - ID of the blocked task to retry + */ + retryBlockedTask(taskId: string): Promise; + /** * Get current queue state. * Returns all queued tasks with their dispatch readiness. diff --git a/apps/server/trpc/routers/dispatch.ts b/apps/server/trpc/routers/dispatch.ts index 13f4b0c..1a41dc5 100644 --- a/apps/server/trpc/routers/dispatch.ts +++ b/apps/server/trpc/routers/dispatch.ts @@ -35,5 +35,15 @@ export function dispatchProcedures(publicProcedure: ProcedureBuilder) { await dispatchManager.completeTask(input.taskId); return { success: true }; }), + + retryBlockedTask: publicProcedure + .input(z.object({ taskId: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const dispatchManager = requireDispatchManager(ctx); + await dispatchManager.retryBlockedTask(input.taskId); + // Kick dispatch loop to pick up the re-queued task + await dispatchManager.dispatchNext(); + return { success: true }; + }), }; } diff --git a/apps/web/src/components/execution/TaskSlideOver.tsx b/apps/web/src/components/execution/TaskSlideOver.tsx index e2df6bf..ff9a4d3 100644 --- a/apps/web/src/components/execution/TaskSlideOver.tsx +++ b/apps/web/src/components/execution/TaskSlideOver.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useMemo } from "react"; import { motion, AnimatePresence } from "motion/react"; -import { X, Trash2, MessageCircle } from "lucide-react"; +import { X, Trash2, MessageCircle, RotateCw } from "lucide-react"; import type { ChatTarget } from "@/components/chat/ChatSlideOver"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -20,6 +20,7 @@ interface TaskSlideOverProps { export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) { const { selectedEntry, setSelectedTaskId } = useExecutionContext(); const queueTaskMutation = trpc.queueTask.useMutation(); + const retryBlockedTaskMutation = trpc.retryBlockedTask.useMutation(); const deleteTaskMutation = trpc.deleteTask.useMutation(); const updateTaskMutation = trpc.updateTask.useMutation(); @@ -229,17 +230,32 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) { {/* Footer */}
- + {task.status === "blocked" ? ( + + ) : ( + + )} +
+ ); + } + + if (preview.status === "failed") { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/web/src/components/review/ReviewHeader.tsx b/apps/web/src/components/review/ReviewHeader.tsx index 7aed391..d12a5bd 100644 --- a/apps/web/src/components/review/ReviewHeader.tsx +++ b/apps/web/src/components/review/ReviewHeader.tsx @@ -6,11 +6,7 @@ import { FileCode, Plus, Minus, - ExternalLink, Loader2, - Square, - CircleDot, - RotateCcw, ArrowRight, Eye, AlertCircle, @@ -18,6 +14,8 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { PreviewControls } from "./PreviewControls"; +import type { PreviewState } from "./PreviewControls"; import type { FileDiff, ReviewStatus } from "./types"; interface PhaseOption { @@ -25,15 +23,6 @@ interface PhaseOption { name: string; } -interface PreviewState { - status: "idle" | "building" | "running" | "failed"; - url?: string; - onStart: () => void; - onStop: () => void; - isStarting: boolean; - isStopping: boolean; -} - interface ReviewHeaderProps { phases: PhaseOption[]; activePhaseId: string | null; @@ -285,66 +274,3 @@ export function ReviewHeader({ ); } -function PreviewControls({ preview }: { preview: PreviewState }) { - if (preview.status === "building" || preview.isStarting) { - return ( -
- - Building... -
- ); - } - - if (preview.status === "running") { - return ( - - ); - } - - if (preview.status === "failed") { - return ( - - ); - } - - return ( - - ); -} From 0e61c48c861014db7dfa044eaa2bce9b41e21bac Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 21:49:01 +0100 Subject: [PATCH 17/54] fix: Use lowercase alphanumeric nanoid for Docker compose project names Docker compose requires project names to be lowercase alphanumeric with hyphens/underscores only. The default nanoid alphabet includes uppercase and special characters, causing build failures. --- apps/server/preview/manager.test.ts | 2 +- apps/server/preview/manager.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/preview/manager.test.ts b/apps/server/preview/manager.test.ts index 14dc0cd..d668b6b 100644 --- a/apps/server/preview/manager.test.ts +++ b/apps/server/preview/manager.test.ts @@ -67,7 +67,7 @@ vi.mock('node:fs/promises', () => ({ })); vi.mock('nanoid', () => ({ - nanoid: vi.fn(() => 'abc123test'), + customAlphabet: vi.fn(() => vi.fn(() => 'abc123test')), })); import { PreviewManager } from './manager.js'; diff --git a/apps/server/preview/manager.ts b/apps/server/preview/manager.ts index 418e9ba..6806c4f 100644 --- a/apps/server/preview/manager.ts +++ b/apps/server/preview/manager.ts @@ -8,7 +8,7 @@ import { join } from 'node:path'; import { mkdir, writeFile, rm } from 'node:fs/promises'; -import { nanoid } from 'nanoid'; +import { customAlphabet } from 'nanoid'; import type { ProjectRepository } from '../db/repositories/project-repository.js'; import type { PhaseRepository } from '../db/repositories/phase-repository.js'; import type { InitiativeRepository } from '../db/repositories/initiative-repository.js'; @@ -116,7 +116,8 @@ export class PreviewManager { ); // 4. Generate ID and prepare deploy dir - const id = nanoid(10); + const previewNanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 10); + const id = previewNanoid(); const projectName = `${COMPOSE_PROJECT_PREFIX}${id}`; const deployDir = join(this.workspaceRoot, PREVIEWS_DIR, id); await mkdir(deployDir, { recursive: true }); From 4958b6624d04b0b64c60dc41f2ef85cb53a6505c Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 21:56:05 +0100 Subject: [PATCH 18/54] fix: Refetch previews on start and switch to path-based routing Two fixes: - Call previewsQuery.refetch() in startPreview.onSuccess so the UI transitions from "building" to the preview link without a page refresh. - Switch from subdomain routing (*.localhost) to path-based routing (localhost://) since macOS doesn't resolve wildcard localhost subdomains. --- apps/server/preview/compose-generator.test.ts | 24 ++++++++++--------- apps/server/preview/gateway.ts | 18 +++++++------- apps/server/preview/health-checker.ts | 6 ++--- apps/server/preview/manager.test.ts | 8 +++---- apps/server/preview/manager.ts | 4 ++-- .../components/review/InitiativeReview.tsx | 1 + apps/web/src/components/review/ReviewTab.tsx | 1 + 7 files changed, 33 insertions(+), 29 deletions(-) diff --git a/apps/server/preview/compose-generator.test.ts b/apps/server/preview/compose-generator.test.ts index 62448f0..37aebf2 100644 --- a/apps/server/preview/compose-generator.test.ts +++ b/apps/server/preview/compose-generator.test.ts @@ -156,7 +156,7 @@ describe('generateComposeFile', () => { }); describe('generateGatewayCaddyfile', () => { - it('generates single-preview Caddyfile with subdomain routing', () => { + it('generates single-preview Caddyfile with path-based routing', () => { const previews = new Map(); previews.set('abc123', [ { containerName: 'cw-preview-abc123-app', port: 3000, route: '/' }, @@ -164,7 +164,8 @@ describe('generateGatewayCaddyfile', () => { const caddyfile = generateGatewayCaddyfile(previews, 9100); expect(caddyfile).toContain('auto_https off'); - expect(caddyfile).toContain('abc123.localhost:9100 {'); + expect(caddyfile).toContain('localhost:9100 {'); + expect(caddyfile).toContain('handle_path /abc123/*'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-app:3000'); }); @@ -176,13 +177,13 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - expect(caddyfile).toContain('handle_path /api/*'); + expect(caddyfile).toContain('handle_path /abc123/api/*'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-backend:8080'); - expect(caddyfile).toContain('handle {'); + expect(caddyfile).toContain('handle_path /abc123/*'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-frontend:3000'); }); - it('generates multi-preview Caddyfile with separate subdomain blocks', () => { + it('generates multi-preview Caddyfile under single host block', () => { const previews = new Map(); previews.set('abc', [ { containerName: 'cw-preview-abc-app', port: 3000, route: '/' }, @@ -192,8 +193,9 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - expect(caddyfile).toContain('abc.localhost:9100 {'); - expect(caddyfile).toContain('xyz.localhost:9100 {'); + expect(caddyfile).toContain('localhost:9100 {'); + expect(caddyfile).toContain('handle_path /abc/*'); + expect(caddyfile).toContain('handle_path /xyz/*'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc-app:3000'); expect(caddyfile).toContain('reverse_proxy cw-preview-xyz-app:5000'); }); @@ -207,12 +209,12 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - const apiAuthIdx = caddyfile.indexOf('/api/auth'); - const apiIdx = caddyfile.indexOf('handle_path /api/*'); - const handleIdx = caddyfile.indexOf('handle {'); + const apiAuthIdx = caddyfile.indexOf('/abc/api/auth'); + const apiIdx = caddyfile.indexOf('handle_path /abc/api/*'); + const rootIdx = caddyfile.indexOf('handle_path /abc/*'); expect(apiAuthIdx).toBeLessThan(apiIdx); - expect(apiIdx).toBeLessThan(handleIdx); + expect(apiIdx).toBeLessThan(rootIdx); }); }); diff --git a/apps/server/preview/gateway.ts b/apps/server/preview/gateway.ts index 967921a..c9b67c0 100644 --- a/apps/server/preview/gateway.ts +++ b/apps/server/preview/gateway.ts @@ -2,7 +2,7 @@ * Gateway Manager * * Manages a single shared Caddy reverse proxy (the "gateway") that routes - * subdomain requests to per-preview compose stacks on a shared Docker network. + * path-prefixed requests to per-preview compose stacks on a shared Docker network. * * Architecture: * .cw-previews/gateway/ @@ -195,7 +195,8 @@ export class GatewayManager { /** * Generate a Caddyfile for the gateway from all active preview routes. * - * Each preview gets a subdomain block: `.localhost:` + * Uses path-based routing under a single `localhost:` block. + * Each preview is accessible at `//...` — no subdomain DNS needed. * Routes within a preview are sorted by specificity (longest path first). */ export function generateGatewayCaddyfile( @@ -207,6 +208,7 @@ export function generateGatewayCaddyfile( ' auto_https off', '}', '', + `localhost:${port} {`, ]; for (const [previewId, routes] of previews) { @@ -217,24 +219,22 @@ export function generateGatewayCaddyfile( return b.route.length - a.route.length; }); - lines.push(`${previewId}.localhost:${port} {`); - for (const route of sorted) { if (route.route === '/') { - lines.push(` handle {`); + lines.push(` handle_path /${previewId}/* {`); lines.push(` reverse_proxy ${route.containerName}:${route.port}`); lines.push(` }`); } else { const path = route.route.endsWith('/') ? route.route.slice(0, -1) : route.route; - lines.push(` handle_path ${path}/* {`); + lines.push(` handle_path /${previewId}${path}/* {`); lines.push(` reverse_proxy ${route.containerName}:${route.port}`); lines.push(` }`); } } - - lines.push('}'); - lines.push(''); } + lines.push('}'); + lines.push(''); + return lines.join('\n'); } diff --git a/apps/server/preview/health-checker.ts b/apps/server/preview/health-checker.ts index 529cdf1..0eaf38d 100644 --- a/apps/server/preview/health-checker.ts +++ b/apps/server/preview/health-checker.ts @@ -1,7 +1,7 @@ /** * Health Checker * - * Polls service healthcheck endpoints through the gateway's subdomain routing + * Polls service healthcheck endpoints through the gateway's path-based routing * to verify that preview services are ready. */ @@ -20,7 +20,7 @@ const DEFAULT_INTERVAL_MS = 3_000; * Wait for all non-internal services to become healthy by polling their * healthcheck endpoints through the gateway's subdomain routing. * - * @param previewId - The preview deployment ID (used as subdomain) + * @param previewId - The preview deployment ID (used as path prefix) * @param gatewayPort - The gateway's host port * @param config - Preview config with service definitions * @param timeoutMs - Maximum time to wait (default: 120s) @@ -60,7 +60,7 @@ export async function waitForHealthy( const route = svc.route ?? '/'; const healthPath = svc.healthcheck!.path; const basePath = route === '/' ? '' : route; - const url = `http://${previewId}.localhost:${gatewayPort}${basePath}${healthPath}`; + const url = `http://localhost:${gatewayPort}/${previewId}${basePath}${healthPath}`; try { const response = await fetch(url, { diff --git a/apps/server/preview/manager.test.ts b/apps/server/preview/manager.test.ts index d668b6b..39cacb7 100644 --- a/apps/server/preview/manager.test.ts +++ b/apps/server/preview/manager.test.ts @@ -220,7 +220,7 @@ describe('PreviewManager', () => { expect(result.projectId).toBe('proj-1'); expect(result.branch).toBe('feature-x'); expect(result.gatewayPort).toBe(9100); - expect(result.url).toBe('http://abc123test.localhost:9100'); + expect(result.url).toBe('http://localhost:9100/abc123test/'); expect(result.mode).toBe('preview'); expect(result.status).toBe('running'); @@ -233,7 +233,7 @@ describe('PreviewManager', () => { expect(buildingEvent).toBeDefined(); expect(readyEvent).toBeDefined(); expect((readyEvent!.payload as Record).url).toBe( - 'http://abc123test.localhost:9100', + 'http://localhost:9100/abc123test/', ); }); @@ -472,7 +472,7 @@ describe('PreviewManager', () => { expect(previews).toHaveLength(2); expect(previews[0].id).toBe('aaa'); expect(previews[0].gatewayPort).toBe(9100); - expect(previews[0].url).toBe('http://aaa.localhost:9100'); + expect(previews[0].url).toBe('http://localhost:9100/aaa/'); expect(previews[0].mode).toBe('preview'); expect(previews[0].services).toHaveLength(1); expect(previews[1].id).toBe('bbb'); @@ -573,7 +573,7 @@ describe('PreviewManager', () => { expect(status!.status).toBe('running'); expect(status!.id).toBe('abc'); expect(status!.gatewayPort).toBe(9100); - expect(status!.url).toBe('http://abc.localhost:9100'); + expect(status!.url).toBe('http://localhost:9100/abc/'); expect(status!.mode).toBe('preview'); }); diff --git a/apps/server/preview/manager.ts b/apps/server/preview/manager.ts index 6806c4f..7e50bed 100644 --- a/apps/server/preview/manager.ts +++ b/apps/server/preview/manager.ts @@ -239,7 +239,7 @@ export class PreviewManager { await this.runSeeds(projectName, config); // 11. Success - const url = `http://${id}.localhost:${gatewayPort}`; + const url = `http://localhost:${gatewayPort}/${id}/`; log.info({ id, url }, 'preview deployment ready'); this.eventBus.emit({ @@ -605,7 +605,7 @@ export class PreviewManager { projectId, branch, gatewayPort, - url: `http://${previewId}.localhost:${gatewayPort}`, + url: `http://localhost:${gatewayPort}/${previewId}/`, mode, status: 'running', services: [], diff --git a/apps/web/src/components/review/InitiativeReview.tsx b/apps/web/src/components/review/InitiativeReview.tsx index 14b4864..50b4750 100644 --- a/apps/web/src/components/review/InitiativeReview.tsx +++ b/apps/web/src/components/review/InitiativeReview.tsx @@ -73,6 +73,7 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview const startPreview = trpc.startPreview.useMutation({ onSuccess: (data) => { setActivePreviewId(data.id); + previewsQuery.refetch(); toast.success(`Preview running at ${data.url}`); }, onError: (err) => toast.error(`Preview failed: ${err.message}`), diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index def288f..7d05afa 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -99,6 +99,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { const startPreview = trpc.startPreview.useMutation({ onSuccess: (data) => { setActivePreviewId(data.id); + previewsQuery.refetch(); toast.success(`Preview running at ${data.url}`); }, onError: (err) => toast.error(`Preview failed: ${err.message}`), From df84a877f2b77c67c88dde3b4885e8ff409d672d Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 21:58:02 +0100 Subject: [PATCH 19/54] fix: Prioritize polled preview status over mutation pending state The startPreview mutation blocks server-side through the entire Docker build + health check + seed cycle. isPending was checked first, so even after polling detected containers running, the UI stayed on "Building..." until the full mutation resolved. Now polled status (running/failed) takes priority, falling back to isPending only when no status exists. --- .../web/src/components/review/InitiativeReview.tsx | 14 ++++++-------- apps/web/src/components/review/ReviewTab.tsx | 14 ++++++-------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/review/InitiativeReview.tsx b/apps/web/src/components/review/InitiativeReview.tsx index 50b4750..2b7709c 100644 --- a/apps/web/src/components/review/InitiativeReview.tsx +++ b/apps/web/src/components/review/InitiativeReview.tsx @@ -129,15 +129,13 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview const previewState = firstProjectId && sourceBranch ? { - status: startPreview.isPending - ? ("building" as const) - : preview?.status === "running" - ? ("running" as const) - : preview?.status === "building" + status: preview?.status === "running" + ? ("running" as const) + : preview?.status === "failed" + ? ("failed" as const) + : (startPreview.isPending || preview?.status === "building") ? ("building" as const) - : preview?.status === "failed" - ? ("failed" as const) - : ("idle" as const), + : ("idle" as const), url: preview?.url ?? undefined, onStart: () => startPreview.mutate({ diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 7d05afa..2b8b660 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -116,15 +116,13 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { const previewState = firstProjectId && sourceBranch ? { - status: startPreview.isPending - ? ("building" as const) - : preview?.status === "running" - ? ("running" as const) - : preview?.status === "building" + status: preview?.status === "running" + ? ("running" as const) + : preview?.status === "failed" + ? ("failed" as const) + : (startPreview.isPending || preview?.status === "building") ? ("building" as const) - : preview?.status === "failed" - ? ("failed" as const) - : ("idle" as const), + : ("idle" as const), url: preview?.url ?? undefined, onStart: () => startPreview.mutate({ From cdb3de353d2dd496e735c1d5f64a360cacd65a07 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 22:01:16 +0100 Subject: [PATCH 20/54] fix: Use SSE events instead of polling for preview status updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit httpBatchLink batches polling queries behind the long-running startPreview mutation, so refetchInterval never fires independently. Replace polling with preview: event invalidation via the existing useLiveUpdates SSE subscription — preview:building/ready/stopped/failed events now trigger listPreviews and getPreviewStatus invalidation. --- .../components/review/InitiativeReview.tsx | 10 ++----- apps/web/src/components/review/ReviewTab.tsx | 28 +++++++++---------- apps/web/src/routes/initiatives/$id.tsx | 9 +++--- 3 files changed, 20 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/review/InitiativeReview.tsx b/apps/web/src/components/review/InitiativeReview.tsx index 2b7709c..fc0cd7a 100644 --- a/apps/web/src/components/review/InitiativeReview.tsx +++ b/apps/web/src/components/review/InitiativeReview.tsx @@ -53,20 +53,14 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId }); const firstProjectId = projectsQuery.data?.[0]?.id ?? null; - const previewsQuery = trpc.listPreviews.useQuery( - { initiativeId }, - { refetchInterval: 3000 }, - ); + const previewsQuery = trpc.listPreviews.useQuery({ initiativeId }); const existingPreview = previewsQuery.data?.find( (p) => p.initiativeId === initiativeId, ); const [activePreviewId, setActivePreviewId] = useState(null); const previewStatusQuery = trpc.getPreviewStatus.useQuery( { previewId: activePreviewId ?? existingPreview?.id ?? "" }, - { - enabled: !!(activePreviewId ?? existingPreview?.id), - refetchInterval: 3000, - }, + { enabled: !!(activePreviewId ?? existingPreview?.id) }, ); const preview = previewStatusQuery.data ?? existingPreview; diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 2b8b660..7a28e3a 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -45,14 +45,17 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { // Fetch phases for this initiative const phasesQuery = trpc.listPhases.useQuery({ initiativeId }); - const pendingReviewPhases = useMemo( - () => (phasesQuery.data ?? []).filter((p) => p.status === "pending_review"), + const reviewablePhases = useMemo( + () => (phasesQuery.data ?? []).filter((p) => p.status === "pending_review" || p.status === "completed"), [phasesQuery.data], ); - // Select first pending review phase + // Select first pending review phase, falling back to completed phases const [selectedPhaseId, setSelectedPhaseId] = useState(null); - const activePhaseId = selectedPhaseId ?? pendingReviewPhases[0]?.id ?? null; + const defaultPhaseId = reviewablePhases.find((p) => p.status === "pending_review")?.id ?? reviewablePhases[0]?.id ?? null; + const activePhaseId = selectedPhaseId ?? defaultPhaseId; + const activePhase = reviewablePhases.find((p) => p.id === activePhaseId); + const isActivePhaseCompleted = activePhase?.status === "completed"; // Fetch projects for this initiative (needed for preview) const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId }); @@ -78,20 +81,14 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { ); // Preview state - const previewsQuery = trpc.listPreviews.useQuery( - { initiativeId }, - { refetchInterval: 3000 }, - ); + const previewsQuery = trpc.listPreviews.useQuery({ initiativeId }); const existingPreview = previewsQuery.data?.find( (p) => p.phaseId === activePhaseId || p.initiativeId === initiativeId, ); const [activePreviewId, setActivePreviewId] = useState(null); const previewStatusQuery = trpc.getPreviewStatus.useQuery( { previewId: activePreviewId ?? existingPreview?.id ?? "" }, - { - enabled: !!(activePreviewId ?? existingPreview?.id), - refetchInterval: 3000, - }, + { enabled: !!(activePreviewId ?? existingPreview?.id) }, ); const preview = previewStatusQuery.data ?? existingPreview; const sourceBranch = diffQuery.data?.sourceBranch ?? commitsQuery.data?.sourceBranch ?? ""; @@ -263,7 +260,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { const activePhaseName = diffQuery.data?.phaseName ?? - pendingReviewPhases.find((p) => p.id === activePhaseId)?.name ?? + reviewablePhases.find((p) => p.id === activePhaseId)?.name ?? "Phase"; // All files from the full branch diff (for sidebar file list) @@ -285,7 +282,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { ); } - if (pendingReviewPhases.length === 0) { + if (reviewablePhases.length === 0) { return (

No phases pending review

@@ -297,8 +294,9 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
{/* Header: phase selector + toolbar */} ({ id: p.id, name: p.name }))} + phases={reviewablePhases.map((p) => ({ id: p.id, name: p.name, status: p.status }))} activePhaseId={activePhaseId} + isReadOnly={isActivePhaseCompleted} onPhaseSelect={handlePhaseSelect} phaseName={activePhaseName} sourceBranch={sourceBranch} diff --git a/apps/web/src/routes/initiatives/$id.tsx b/apps/web/src/routes/initiatives/$id.tsx index 7ff848e..6662f18 100644 --- a/apps/web/src/routes/initiatives/$id.tsx +++ b/apps/web/src/routes/initiatives/$id.tsx @@ -29,10 +29,11 @@ function InitiativeDetailPage() { // Single SSE stream for all live updates useLiveUpdates([ - { prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks'] }, - { prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies'] }, - { prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] }, - { prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] }, + { prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks'] }, + { prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies'] }, + { prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] }, + { prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] }, + { prefix: 'preview:', invalidate: ['listPreviews', 'getPreviewStatus'] }, ]); // tRPC queries From 84250955d180b731a66db4b60378056f204f8ce4 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 22:05:28 +0100 Subject: [PATCH 21/54] fix: Show completed phase diffs in review tab Completed phases showed "No phases pending review" because: 1. Frontend filtered only pending_review phases 2. Server rejected non-pending_review phases 3. After merge, three-dot diff returned empty (merge base moved) Fix: store pre-merge merge base hash on phase, use it to reconstruct diffs for completed phases. Frontend now shows both pending_review and completed phases with read-only mode (Merged badge) for completed ones. --- apps/server/agent/file-io.test.ts | 1 + apps/server/db/schema.ts | 1 + .../drizzle/0031_add_phase_merge_base.sql | 1 + apps/server/drizzle/meta/_journal.json | 7 + apps/server/execution/orchestrator.ts | 12 + apps/server/git/branch-manager.ts | 6 + apps/server/git/simple-git-branch-manager.ts | 6 + apps/server/trpc/routers/phase.ts | 15 +- .../src/components/review/ReviewHeader.tsx | 208 ++++++++++-------- docs/database.md | 3 +- docs/git-process-logging.md | 1 + 11 files changed, 158 insertions(+), 103 deletions(-) create mode 100644 apps/server/drizzle/0031_add_phase_merge_base.sql diff --git a/apps/server/agent/file-io.test.ts b/apps/server/agent/file-io.test.ts index 20aec4d..396453f 100644 --- a/apps/server/agent/file-io.test.ts +++ b/apps/server/agent/file-io.test.ts @@ -68,6 +68,7 @@ describe('writeInputFiles', () => { name: 'Phase One', content: 'First phase', status: 'pending', + mergeBase: null, createdAt: new Date(), updatedAt: new Date(), } as Phase; diff --git a/apps/server/db/schema.ts b/apps/server/db/schema.ts index 61ae1e2..1248f7f 100644 --- a/apps/server/db/schema.ts +++ b/apps/server/db/schema.ts @@ -55,6 +55,7 @@ export const phases = sqliteTable('phases', { status: text('status', { enum: ['pending', 'approved', 'in_progress', 'completed', 'blocked', 'pending_review'] }) .notNull() .default('pending'), + mergeBase: text('merge_base'), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }); diff --git a/apps/server/drizzle/0031_add_phase_merge_base.sql b/apps/server/drizzle/0031_add_phase_merge_base.sql new file mode 100644 index 0000000..7771d38 --- /dev/null +++ b/apps/server/drizzle/0031_add_phase_merge_base.sql @@ -0,0 +1 @@ +ALTER TABLE phases ADD COLUMN merge_base TEXT; diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index ac6687d..1c5870a 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1772150400000, "tag": "0030_remove_task_approval", "breakpoints": true + }, + { + "idx": 31, + "version": "6", + "when": 1772236800000, + "tag": "0031_add_phase_merge_base", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/execution/orchestrator.ts b/apps/server/execution/orchestrator.ts index a08e2f1..55de53a 100644 --- a/apps/server/execution/orchestrator.ts +++ b/apps/server/execution/orchestrator.ts @@ -278,6 +278,18 @@ export class ExecutionOrchestrator { const projects = await this.projectRepository.findProjectsByInitiativeId(phase.initiativeId); + // Store merge base before merging so we can reconstruct diffs for completed phases + for (const project of projects) { + const clonePath = await ensureProjectClone(project, this.workspaceRoot); + try { + const mergeBase = await this.branchManager.getMergeBase(clonePath, initBranch, phBranch); + await this.phaseRepository.update(phaseId, { mergeBase }); + break; // Only need one merge base (first project) + } catch { + // Phase branch may not exist in this project clone + } + } + for (const project of projects) { const clonePath = await ensureProjectClone(project, this.workspaceRoot); const result = await this.branchManager.mergeBranch(clonePath, phBranch, initBranch); diff --git a/apps/server/git/branch-manager.ts b/apps/server/git/branch-manager.ts index f5d9b54..113ac7b 100644 --- a/apps/server/git/branch-manager.ts +++ b/apps/server/git/branch-manager.ts @@ -57,6 +57,12 @@ export interface BranchManager { */ diffCommit(repoPath: string, commitHash: string): Promise; + /** + * Get the merge base (common ancestor) of two branches. + * Returns the commit hash of the merge base. + */ + getMergeBase(repoPath: string, branch1: string, branch2: string): Promise; + /** * Push a branch to a remote. * Defaults to 'origin' if no remote specified. diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index bee747a..0c73ce7 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -141,6 +141,12 @@ export class SimpleGitBranchManager implements BranchManager { return git.diff([`${commitHash}~1`, commitHash]); } + async getMergeBase(repoPath: string, branch1: string, branch2: string): Promise { + const git = simpleGit(repoPath); + const result = await git.raw(['merge-base', branch1, branch2]); + return result.trim(); + } + async pushBranch(repoPath: string, branch: string, remote = 'origin'): Promise { const git = simpleGit(repoPath); await git.push(remote, branch); diff --git a/apps/server/trpc/routers/phase.ts b/apps/server/trpc/routers/phase.ts index 604b45a..4762232 100644 --- a/apps/server/trpc/routers/phase.ts +++ b/apps/server/trpc/routers/phase.ts @@ -219,8 +219,8 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) { if (!phase) { throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` }); } - if (phase.status !== 'pending_review') { - throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not pending review (status: ${phase.status})` }); + if (phase.status !== 'pending_review' && phase.status !== 'completed') { + throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not reviewable (status: ${phase.status})` }); } const initiative = await initiativeRepo.findById(phase.initiativeId); @@ -230,13 +230,15 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) { const initBranch = initiative.branch; const phBranch = phaseBranchName(initBranch, phase.name); + // For completed phases, use stored merge base; for pending_review, use initiative branch + const diffBase = (phase.status === 'completed' && phase.mergeBase) ? phase.mergeBase : initBranch; const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId); let rawDiff = ''; for (const project of projects) { const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); - const diff = await branchManager.diffBranches(clonePath, initBranch, phBranch); + const diff = await branchManager.diffBranches(clonePath, diffBase, phBranch); if (diff) { rawDiff += diff + '\n'; } @@ -270,8 +272,8 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) { if (!phase) { throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` }); } - if (phase.status !== 'pending_review') { - throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not pending review (status: ${phase.status})` }); + if (phase.status !== 'pending_review' && phase.status !== 'completed') { + throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not reviewable (status: ${phase.status})` }); } const initiative = await initiativeRepo.findById(phase.initiativeId); @@ -281,13 +283,14 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) { const initBranch = initiative.branch; const phBranch = phaseBranchName(initBranch, phase.name); + const diffBase = (phase.status === 'completed' && phase.mergeBase) ? phase.mergeBase : initBranch; const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId); const allCommits: Array<{ hash: string; shortHash: string; message: string; author: string; date: string; filesChanged: number; insertions: number; deletions: number }> = []; for (const project of projects) { const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); - const commits = await branchManager.listCommits(clonePath, initBranch, phBranch); + const commits = await branchManager.listCommits(clonePath, diffBase, phBranch); allCommits.push(...commits); } diff --git a/apps/web/src/components/review/ReviewHeader.tsx b/apps/web/src/components/review/ReviewHeader.tsx index d12a5bd..0b10a96 100644 --- a/apps/web/src/components/review/ReviewHeader.tsx +++ b/apps/web/src/components/review/ReviewHeader.tsx @@ -21,11 +21,13 @@ import type { FileDiff, ReviewStatus } from "./types"; interface PhaseOption { id: string; name: string; + status: string; } interface ReviewHeaderProps { phases: PhaseOption[]; activePhaseId: string | null; + isReadOnly?: boolean; onPhaseSelect: (id: string) => void; phaseName: string; sourceBranch: string; @@ -44,6 +46,7 @@ interface ReviewHeaderProps { export function ReviewHeader({ phases, activePhaseId, + isReadOnly, onPhaseSelect, phaseName, sourceBranch, @@ -92,6 +95,12 @@ export function ReviewHeader({
{phases.map((phase) => { const isActive = phase.id === activePhaseId; + const isCompleted = phase.status === "completed"; + const dotColor = isActive + ? "bg-primary" + : isCompleted + ? "bg-status-success-dot" + : "bg-status-warning-dot"; return ( @@ -171,102 +178,111 @@ export function ReviewHeader({ {preview && } {/* Review status / actions */} - {status === "pending" && ( - <> - -
- - - {/* Merge confirmation dropdown */} - {showConfirmation && ( -
-

- Ready to merge? -

-
-
- - - 0 unresolved comments - -
-
- - - {viewed}/{total} files viewed - -
-
-
- - -
-
- )} -
- - )} - {status === "approved" && ( + {isReadOnly ? ( - Approved - - )} - {status === "changes_requested" && ( - - - Changes Requested + Merged + ) : ( + <> + {status === "pending" && ( + <> + +
+ + + {/* Merge confirmation dropdown */} + {showConfirmation && ( +
+

+ Ready to merge? +

+
+
+ + + 0 unresolved comments + +
+
+ + + {viewed}/{total} files viewed + +
+
+
+ + +
+
+ )} +
+ + )} + {status === "approved" && ( + + + Approved + + )} + {status === "changes_requested" && ( + + + Changes Requested + + )} + )}
diff --git a/docs/database.md b/docs/database.md index c4bfc59..6afb841 100644 --- a/docs/database.md +++ b/docs/database.md @@ -29,7 +29,8 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r | initiativeId | text FK → initiatives (cascade) | | | name | text NOT NULL | | | content | text nullable | Tiptap JSON | -| status | text enum | 'pending' \| 'approved' \| 'in_progress' \| 'completed' \| 'blocked' | +| status | text enum | 'pending' \| 'approved' \| 'in_progress' \| 'completed' \| 'blocked' \| 'pending_review' | +| mergeBase | text nullable | git merge-base hash stored before phase merge, enables diff reconstruction for completed phases | | createdAt, updatedAt | integer/timestamp | | ### phase_dependencies diff --git a/docs/git-process-logging.md b/docs/git-process-logging.md index 817dbfc..b35efa2 100644 --- a/docs/git-process-logging.md +++ b/docs/git-process-logging.md @@ -45,6 +45,7 @@ Worktrees stored in `.cw-worktrees/` subdirectory of the repo. Each agent gets a | `remoteBranchExists(repoPath, branch)` | Check remote tracking branches (`origin/`) | | `listCommits(repoPath, base, head)` | List commits head has that base doesn't (with stats) | | `diffCommit(repoPath, commitHash)` | Get unified diff for a single commit | +| `getMergeBase(repoPath, branch1, branch2)` | Get common ancestor commit hash | `remoteBranchExists` is used by `registerProject` and `updateProject` to validate that a project's default branch actually exists in the cloned repository before saving. From aedf149471235ac6a6a157c6f66cda32c2f2045b Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 22:16:19 +0100 Subject: [PATCH 22/54] fix: Use container-internal port 80 in gateway Caddyfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Caddyfile was using the host port (e.g., 9100) as the Caddy listen address, but Docker maps host:9100 → container:80. Caddy inside the container was listening on 9100 while Docker only forwarded to port 80, causing all health checks to fail with "connection reset by peer". --- apps/server/preview/compose-generator.test.ts | 4 ++-- apps/server/preview/gateway.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/server/preview/compose-generator.test.ts b/apps/server/preview/compose-generator.test.ts index 37aebf2..1e1f650 100644 --- a/apps/server/preview/compose-generator.test.ts +++ b/apps/server/preview/compose-generator.test.ts @@ -164,7 +164,7 @@ describe('generateGatewayCaddyfile', () => { const caddyfile = generateGatewayCaddyfile(previews, 9100); expect(caddyfile).toContain('auto_https off'); - expect(caddyfile).toContain('localhost:9100 {'); + expect(caddyfile).toContain(':80 {'); expect(caddyfile).toContain('handle_path /abc123/*'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-app:3000'); }); @@ -193,7 +193,7 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - expect(caddyfile).toContain('localhost:9100 {'); + expect(caddyfile).toContain(':80 {'); expect(caddyfile).toContain('handle_path /abc/*'); expect(caddyfile).toContain('handle_path /xyz/*'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc-app:3000'); diff --git a/apps/server/preview/gateway.ts b/apps/server/preview/gateway.ts index c9b67c0..57536c5 100644 --- a/apps/server/preview/gateway.ts +++ b/apps/server/preview/gateway.ts @@ -201,14 +201,16 @@ export class GatewayManager { */ export function generateGatewayCaddyfile( previews: Map, - port: number, + _port: number, ): string { + // Caddy runs inside a container where Docker maps host:${port} → container:80. + // The Caddyfile must listen on the container-internal port (80), not the host port. const lines: string[] = [ '{', ' auto_https off', '}', '', - `localhost:${port} {`, + `:80 {`, ]; for (const [previewId, routes] of previews) { From 1b8e496d395ebf7a632016ecad69e87ca73bc8b0 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 22:38:00 +0100 Subject: [PATCH 23/54] fix: Switch preview gateway from path-prefix to subdomain routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path-prefix routing (`localhost:9100//`) broke SPAs because absolute asset paths (`/assets/index.js`) didn't match the `handle_path //*` route. Subdomain routing (`.localhost:9100/`) resolves this since all paths are relative to the root. Chrome/Firefox resolve *.localhost to 127.0.0.1 natively — no DNS setup needed. --- apps/server/preview/compose-generator.test.ts | 24 +++++++++---------- apps/server/preview/gateway.ts | 18 +++++++------- apps/server/preview/health-checker.ts | 6 ++--- apps/server/preview/manager.test.ts | 8 +++---- apps/server/preview/manager.ts | 4 ++-- 5 files changed, 31 insertions(+), 29 deletions(-) diff --git a/apps/server/preview/compose-generator.test.ts b/apps/server/preview/compose-generator.test.ts index 1e1f650..234d62b 100644 --- a/apps/server/preview/compose-generator.test.ts +++ b/apps/server/preview/compose-generator.test.ts @@ -156,7 +156,7 @@ describe('generateComposeFile', () => { }); describe('generateGatewayCaddyfile', () => { - it('generates single-preview Caddyfile with path-based routing', () => { + it('generates single-preview Caddyfile with subdomain routing', () => { const previews = new Map(); previews.set('abc123', [ { containerName: 'cw-preview-abc123-app', port: 3000, route: '/' }, @@ -164,8 +164,8 @@ describe('generateGatewayCaddyfile', () => { const caddyfile = generateGatewayCaddyfile(previews, 9100); expect(caddyfile).toContain('auto_https off'); - expect(caddyfile).toContain(':80 {'); - expect(caddyfile).toContain('handle_path /abc123/*'); + expect(caddyfile).toContain('abc123.localhost:80 {'); + expect(caddyfile).toContain('handle /* {'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-app:3000'); }); @@ -177,13 +177,14 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - expect(caddyfile).toContain('handle_path /abc123/api/*'); + expect(caddyfile).toContain('abc123.localhost:80 {'); + expect(caddyfile).toContain('handle_path /api/*'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-backend:8080'); - expect(caddyfile).toContain('handle_path /abc123/*'); + expect(caddyfile).toContain('handle /* {'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-frontend:3000'); }); - it('generates multi-preview Caddyfile under single host block', () => { + it('generates separate subdomain blocks for each preview', () => { const previews = new Map(); previews.set('abc', [ { containerName: 'cw-preview-abc-app', port: 3000, route: '/' }, @@ -193,9 +194,8 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - expect(caddyfile).toContain(':80 {'); - expect(caddyfile).toContain('handle_path /abc/*'); - expect(caddyfile).toContain('handle_path /xyz/*'); + expect(caddyfile).toContain('abc.localhost:80 {'); + expect(caddyfile).toContain('xyz.localhost:80 {'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc-app:3000'); expect(caddyfile).toContain('reverse_proxy cw-preview-xyz-app:5000'); }); @@ -209,9 +209,9 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - const apiAuthIdx = caddyfile.indexOf('/abc/api/auth'); - const apiIdx = caddyfile.indexOf('handle_path /abc/api/*'); - const rootIdx = caddyfile.indexOf('handle_path /abc/*'); + const apiAuthIdx = caddyfile.indexOf('/api/auth'); + const apiIdx = caddyfile.indexOf('handle_path /api/*'); + const rootIdx = caddyfile.indexOf('handle /* {'); expect(apiAuthIdx).toBeLessThan(apiIdx); expect(apiIdx).toBeLessThan(rootIdx); diff --git a/apps/server/preview/gateway.ts b/apps/server/preview/gateway.ts index 57536c5..ed4ec96 100644 --- a/apps/server/preview/gateway.ts +++ b/apps/server/preview/gateway.ts @@ -2,7 +2,7 @@ * Gateway Manager * * Manages a single shared Caddy reverse proxy (the "gateway") that routes - * path-prefixed requests to per-preview compose stacks on a shared Docker network. + * subdomain-based requests to per-preview compose stacks on a shared Docker network. * * Architecture: * .cw-previews/gateway/ @@ -195,8 +195,8 @@ export class GatewayManager { /** * Generate a Caddyfile for the gateway from all active preview routes. * - * Uses path-based routing under a single `localhost:` block. - * Each preview is accessible at `//...` — no subdomain DNS needed. + * Uses subdomain-based routing: each preview gets its own `.localhost:80` block. + * Chrome/Firefox resolve `*.localhost` to 127.0.0.1 natively — no DNS setup needed. * Routes within a preview are sorted by specificity (longest path first). */ export function generateGatewayCaddyfile( @@ -209,8 +209,6 @@ export function generateGatewayCaddyfile( '{', ' auto_https off', '}', - '', - `:80 {`, ]; for (const [previewId, routes] of previews) { @@ -221,21 +219,25 @@ export function generateGatewayCaddyfile( return b.route.length - a.route.length; }); + lines.push(''); + lines.push(`${previewId}.localhost:80 {`); + for (const route of sorted) { if (route.route === '/') { - lines.push(` handle_path /${previewId}/* {`); + lines.push(` handle /* {`); lines.push(` reverse_proxy ${route.containerName}:${route.port}`); lines.push(` }`); } else { const path = route.route.endsWith('/') ? route.route.slice(0, -1) : route.route; - lines.push(` handle_path /${previewId}${path}/* {`); + lines.push(` handle_path ${path}/* {`); lines.push(` reverse_proxy ${route.containerName}:${route.port}`); lines.push(` }`); } } + + lines.push('}'); } - lines.push('}'); lines.push(''); return lines.join('\n'); diff --git a/apps/server/preview/health-checker.ts b/apps/server/preview/health-checker.ts index 0eaf38d..febe87b 100644 --- a/apps/server/preview/health-checker.ts +++ b/apps/server/preview/health-checker.ts @@ -1,7 +1,7 @@ /** * Health Checker * - * Polls service healthcheck endpoints through the gateway's path-based routing + * Polls service healthcheck endpoints through the gateway's subdomain-based routing * to verify that preview services are ready. */ @@ -20,7 +20,7 @@ const DEFAULT_INTERVAL_MS = 3_000; * Wait for all non-internal services to become healthy by polling their * healthcheck endpoints through the gateway's subdomain routing. * - * @param previewId - The preview deployment ID (used as path prefix) + * @param previewId - The preview deployment ID (used as subdomain) * @param gatewayPort - The gateway's host port * @param config - Preview config with service definitions * @param timeoutMs - Maximum time to wait (default: 120s) @@ -60,7 +60,7 @@ export async function waitForHealthy( const route = svc.route ?? '/'; const healthPath = svc.healthcheck!.path; const basePath = route === '/' ? '' : route; - const url = `http://localhost:${gatewayPort}/${previewId}${basePath}${healthPath}`; + const url = `http://${previewId}.localhost:${gatewayPort}${basePath}${healthPath}`; try { const response = await fetch(url, { diff --git a/apps/server/preview/manager.test.ts b/apps/server/preview/manager.test.ts index 39cacb7..4f60cfc 100644 --- a/apps/server/preview/manager.test.ts +++ b/apps/server/preview/manager.test.ts @@ -220,7 +220,7 @@ describe('PreviewManager', () => { expect(result.projectId).toBe('proj-1'); expect(result.branch).toBe('feature-x'); expect(result.gatewayPort).toBe(9100); - expect(result.url).toBe('http://localhost:9100/abc123test/'); + expect(result.url).toBe('http://abc123test.localhost:9100/'); expect(result.mode).toBe('preview'); expect(result.status).toBe('running'); @@ -233,7 +233,7 @@ describe('PreviewManager', () => { expect(buildingEvent).toBeDefined(); expect(readyEvent).toBeDefined(); expect((readyEvent!.payload as Record).url).toBe( - 'http://localhost:9100/abc123test/', + 'http://abc123test.localhost:9100/', ); }); @@ -472,7 +472,7 @@ describe('PreviewManager', () => { expect(previews).toHaveLength(2); expect(previews[0].id).toBe('aaa'); expect(previews[0].gatewayPort).toBe(9100); - expect(previews[0].url).toBe('http://localhost:9100/aaa/'); + expect(previews[0].url).toBe('http://aaa.localhost:9100/'); expect(previews[0].mode).toBe('preview'); expect(previews[0].services).toHaveLength(1); expect(previews[1].id).toBe('bbb'); @@ -573,7 +573,7 @@ describe('PreviewManager', () => { expect(status!.status).toBe('running'); expect(status!.id).toBe('abc'); expect(status!.gatewayPort).toBe(9100); - expect(status!.url).toBe('http://localhost:9100/abc/'); + expect(status!.url).toBe('http://abc.localhost:9100/'); expect(status!.mode).toBe('preview'); }); diff --git a/apps/server/preview/manager.ts b/apps/server/preview/manager.ts index 7e50bed..2b04cc3 100644 --- a/apps/server/preview/manager.ts +++ b/apps/server/preview/manager.ts @@ -239,7 +239,7 @@ export class PreviewManager { await this.runSeeds(projectName, config); // 11. Success - const url = `http://localhost:${gatewayPort}/${id}/`; + const url = `http://${id}.localhost:${gatewayPort}/`; log.info({ id, url }, 'preview deployment ready'); this.eventBus.emit({ @@ -605,7 +605,7 @@ export class PreviewManager { projectId, branch, gatewayPort, - url: `http://localhost:${gatewayPort}/${previewId}/`, + url: `http://${previewId}.localhost:${gatewayPort}/`, mode, status: 'running', services: [], From 65bcbf1a35f41ade57f4573e224867000577fa4b Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 09:41:28 +0100 Subject: [PATCH 24/54] fix: Fix review task completion bug + add initiative-level Request Changes Critical: review/merge tasks hit an early return in handleTaskCompleted() that skipped the phase completion check, leaving phases stuck in in_progress forever. Changed to an if-block wrapping only the merge step. Also adds requestChangesOnInitiative() which creates/reuses a "Finalization" phase for initiative-level review feedback, with dedup guards for both phase and initiative request-changes flows. --- apps/server/events/index.ts | 1 + apps/server/events/types.ts | 12 +- apps/server/execution/orchestrator.ts | 112 +++++++++++++++--- apps/server/trpc/routers/initiative.ts | 14 +++ apps/server/trpc/routers/phase.ts | 2 +- .../components/review/InitiativeReview.tsx | 28 ++++- 6 files changed, 152 insertions(+), 17 deletions(-) diff --git a/apps/server/events/index.ts b/apps/server/events/index.ts index e53920b..64a5e0e 100644 --- a/apps/server/events/index.ts +++ b/apps/server/events/index.ts @@ -52,6 +52,7 @@ export type { AccountCredentialsValidatedEvent, InitiativePendingReviewEvent, InitiativeReviewApprovedEvent, + InitiativeChangesRequestedEvent, DomainEventMap, DomainEventType, } from './types.js'; diff --git a/apps/server/events/types.ts b/apps/server/events/types.ts index fb6a8a6..2f4b84c 100644 --- a/apps/server/events/types.ts +++ b/apps/server/events/types.ts @@ -591,6 +591,15 @@ export interface InitiativeReviewApprovedEvent extends DomainEvent { }; } +export interface InitiativeChangesRequestedEvent extends DomainEvent { + type: 'initiative:changes_requested'; + payload: { + initiativeId: string; + phaseId: string; + taskId: string; + }; +} + /** * Chat Session Events */ @@ -668,7 +677,8 @@ export type DomainEventMap = | ChatMessageCreatedEvent | ChatSessionClosedEvent | InitiativePendingReviewEvent - | InitiativeReviewApprovedEvent; + | InitiativeReviewApprovedEvent + | InitiativeChangesRequestedEvent; /** * Event type literal union for type checking diff --git a/apps/server/execution/orchestrator.ts b/apps/server/execution/orchestrator.ts index 55de53a..b48b821 100644 --- a/apps/server/execution/orchestrator.ts +++ b/apps/server/execution/orchestrator.ts @@ -11,7 +11,7 @@ * - Review per-phase: pause after each phase for diff review */ -import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseChangesRequestedEvent, PhaseMergedEvent, TaskMergedEvent, PhaseQueuedEvent, AgentStoppedEvent, InitiativePendingReviewEvent, InitiativeReviewApprovedEvent } from '../events/index.js'; +import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseChangesRequestedEvent, PhaseMergedEvent, TaskMergedEvent, PhaseQueuedEvent, AgentStoppedEvent, InitiativePendingReviewEvent, InitiativeReviewApprovedEvent, InitiativeChangesRequestedEvent } from '../events/index.js'; import type { BranchManager } from '../git/branch-manager.js'; import type { PhaseRepository } from '../db/repositories/phase-repository.js'; import type { TaskRepository } from '../db/repositories/task-repository.js'; @@ -150,20 +150,20 @@ export class ExecutionOrchestrator { const phase = await this.phaseRepository.findById(task.phaseId); if (!phase) return; - // Skip merge/review tasks — they already work on the phase branch directly - if (task.category === 'merge' || task.category === 'review') return; + // Skip merge for review/merge tasks — they already work on the phase branch directly + if (task.category !== 'merge' && task.category !== 'review') { + const initBranch = initiative.branch; + const phBranch = phaseBranchName(initBranch, phase.name); + const tBranch = taskBranchName(initBranch, task.id); - const initBranch = initiative.branch; - const phBranch = phaseBranchName(initBranch, phase.name); - const tBranch = taskBranchName(initBranch, task.id); - - // Serialize merges per phase - const lock = this.phaseMergeLocks.get(task.phaseId) ?? Promise.resolve(); - const mergeOp = lock.then(async () => { - await this.mergeTaskIntoPhase(taskId, task.phaseId!, tBranch, phBranch); - }); - this.phaseMergeLocks.set(task.phaseId, mergeOp.catch(() => {})); - await mergeOp; + // Serialize merges per phase + const lock = this.phaseMergeLocks.get(task.phaseId) ?? Promise.resolve(); + const mergeOp = lock.then(async () => { + await this.mergeTaskIntoPhase(taskId, task.phaseId!, tBranch, phBranch); + }); + this.phaseMergeLocks.set(task.phaseId, mergeOp.catch(() => {})); + await mergeOp; + } // Check if all phase tasks are done const phaseTasks = await this.taskRepository.findByPhaseId(task.phaseId); @@ -356,6 +356,15 @@ export class ExecutionOrchestrator { const initiative = await this.initiativeRepository.findById(phase.initiativeId); if (!initiative) throw new Error(`Initiative not found: ${phase.initiativeId}`); + // Guard: don't create duplicate review tasks + const existingTasks = await this.taskRepository.findByPhaseId(phaseId); + const activeReview = existingTasks.find( + (t) => t.category === 'review' && (t.status === 'pending' || t.status === 'in_progress'), + ); + if (activeReview) { + return { taskId: activeReview.id }; + } + // Build revision task description from comments + summary const lines: string[] = []; if (summary) { @@ -418,6 +427,81 @@ export class ExecutionOrchestrator { return { taskId: task.id }; } + /** + * Request changes on an initiative that's pending review. + * Creates/reuses a "Finalization" phase and adds a review task to it. + */ + async requestChangesOnInitiative( + initiativeId: string, + summary: string, + ): Promise<{ taskId: string }> { + const initiative = await this.initiativeRepository.findById(initiativeId); + if (!initiative) throw new Error(`Initiative not found: ${initiativeId}`); + if (initiative.status !== 'pending_review') { + throw new Error(`Initiative ${initiativeId} is not pending review (status: ${initiative.status})`); + } + + // Find or create a "Finalization" phase + const phases = await this.phaseRepository.findByInitiativeId(initiativeId); + let finalizationPhase = phases.find((p) => p.name === 'Finalization'); + + if (!finalizationPhase) { + finalizationPhase = await this.phaseRepository.create({ + initiativeId, + name: 'Finalization', + status: 'in_progress', + }); + } else if (finalizationPhase.status === 'completed' || finalizationPhase.status === 'pending_review') { + await this.phaseRepository.update(finalizationPhase.id, { status: 'in_progress' as any }); + } + + // Guard: don't create duplicate review tasks + const existingTasks = await this.taskRepository.findByPhaseId(finalizationPhase.id); + const activeReview = existingTasks.find( + (t) => t.category === 'review' && (t.status === 'pending' || t.status === 'in_progress'), + ); + if (activeReview) { + // Still reset initiative to active + await this.initiativeRepository.update(initiativeId, { status: 'active' as any }); + this.scheduleDispatch(); + return { taskId: activeReview.id }; + } + + // Create review task + const task = await this.taskRepository.create({ + phaseId: finalizationPhase.id, + initiativeId, + name: `Address initiative review feedback`, + description: `## Summary\n\n${summary}`, + category: 'review', + priority: 'high', + }); + + // Reset initiative status to active + await this.initiativeRepository.update(initiativeId, { status: 'active' as any }); + + // Queue task for dispatch + await this.dispatchManager.queue(task.id); + + // Emit event + const event: InitiativeChangesRequestedEvent = { + type: 'initiative:changes_requested', + timestamp: new Date(), + payload: { + initiativeId, + phaseId: finalizationPhase.id, + taskId: task.id, + }, + }; + this.eventBus.emit(event); + + log.info({ initiativeId, phaseId: finalizationPhase.id, taskId: task.id }, 'changes requested on initiative'); + + this.scheduleDispatch(); + + return { taskId: task.id }; + } + /** * Re-queue approved phases for an initiative into the in-memory dispatch queue. * Self-healing: ensures phases aren't lost if the server restarted since the diff --git a/apps/server/trpc/routers/initiative.ts b/apps/server/trpc/routers/initiative.ts index 4b8fb66..37b77b3 100644 --- a/apps/server/trpc/routers/initiative.ts +++ b/apps/server/trpc/routers/initiative.ts @@ -335,5 +335,19 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) { await orchestrator.approveInitiative(input.initiativeId, input.strategy); return { success: true }; }), + + requestInitiativeChanges: publicProcedure + .input(z.object({ + initiativeId: z.string().min(1), + summary: z.string().trim().min(1), + })) + .mutation(async ({ ctx, input }) => { + const orchestrator = requireExecutionOrchestrator(ctx); + const result = await orchestrator.requestChangesOnInitiative( + input.initiativeId, + input.summary, + ); + return { success: true, taskId: result.taskId }; + }), }; } diff --git a/apps/server/trpc/routers/phase.ts b/apps/server/trpc/routers/phase.ts index 4762232..d2ec9e8 100644 --- a/apps/server/trpc/routers/phase.ts +++ b/apps/server/trpc/routers/phase.ts @@ -371,7 +371,7 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) { requestPhaseChanges: publicProcedure .input(z.object({ phaseId: z.string().min(1), - summary: z.string().optional(), + summary: z.string().trim().min(1).optional(), })) .mutation(async ({ ctx, input }) => { const orchestrator = requireExecutionOrchestrator(ctx); diff --git a/apps/web/src/components/review/InitiativeReview.tsx b/apps/web/src/components/review/InitiativeReview.tsx index fc0cd7a..5f7a36b 100644 --- a/apps/web/src/components/review/InitiativeReview.tsx +++ b/apps/web/src/components/review/InitiativeReview.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge } from "lucide-react"; +import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge, RotateCcw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { trpc } from "@/lib/trpc"; import { parseUnifiedDiff } from "./parse-diff"; @@ -82,6 +82,14 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview onError: (err) => toast.error(`Failed to stop: ${err.message}`), }); + const requestChangesMutation = trpc.requestInitiativeChanges.useMutation({ + onSuccess: () => { + toast.success("Changes requested — review task created"); + onCompleted(); + }, + onError: (err) => toast.error(err.message), + }); + const approveMutation = trpc.approveInitiativeReview.useMutation({ onSuccess: (_data, variables) => { const msg = variables.strategy === "merge_and_push" @@ -189,6 +197,24 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview {/* Right: preview + action buttons */}
{previewState && } + - ) : ( - - )} -
-
-

- {comment.body} -

- + comment={comment} + replies={repliesByParent.get(comment.id) ?? []} + onResolve={onResolve} + onUnresolve={onUnresolve} + onReply={onReply} + /> ))} ); } +function RootComment({ + comment, + replies, + onResolve, + onUnresolve, + onReply, +}: { + comment: ReviewComment; + replies: ReviewComment[]; + onResolve: (id: string) => void; + onUnresolve: (id: string) => void; + onReply?: (parentCommentId: string, body: string) => void; +}) { + const [isReplying, setIsReplying] = useState(false); + const replyRef = useRef(null); + + useEffect(() => { + if (isReplying) replyRef.current?.focus(); + }, [isReplying]); + + return ( +
+ {/* Root comment */} +
+
+
+ {comment.author} + {formatTime(comment.createdAt)} + {comment.resolved && ( + + + Resolved + + )} +
+
+ {onReply && !comment.resolved && ( + + )} + {comment.resolved ? ( + + ) : ( + + )} +
+
+

{comment.body}

+
+ + {/* Replies */} + {replies.length > 0 && ( +
+ {replies.map((reply) => ( +
+
+ + {reply.author} + + {formatTime(reply.createdAt)} +
+

{reply.body}

+
+ ))} +
+ )} + + {/* Reply form */} + {isReplying && onReply && ( +
+ { + onReply(comment.id, body); + setIsReplying(false); + }} + onCancel={() => setIsReplying(false)} + placeholder="Write a reply..." + submitLabel="Reply" + /> +
+ )} +
+ ); +} + function formatTime(iso: string): string { const d = new Date(iso); return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); diff --git a/apps/web/src/components/review/DiffViewer.tsx b/apps/web/src/components/review/DiffViewer.tsx index 5cec6c5..6fac668 100644 --- a/apps/web/src/components/review/DiffViewer.tsx +++ b/apps/web/src/components/review/DiffViewer.tsx @@ -12,6 +12,7 @@ interface DiffViewerProps { ) => void; onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; + onReplyComment?: (parentCommentId: string, body: string) => void; viewedFiles?: Set; onToggleViewed?: (filePath: string) => void; onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void; @@ -23,6 +24,7 @@ export function DiffViewer({ onAddComment, onResolveComment, onUnresolveComment, + onReplyComment, viewedFiles, onToggleViewed, onRegisterRef, @@ -37,6 +39,7 @@ export function DiffViewer({ onAddComment={onAddComment} onResolveComment={onResolveComment} onUnresolveComment={onUnresolveComment} + onReplyComment={onReplyComment} isViewed={viewedFiles?.has(file.newPath) ?? false} onToggleViewed={() => onToggleViewed?.(file.newPath)} /> diff --git a/apps/web/src/components/review/FileCard.tsx b/apps/web/src/components/review/FileCard.tsx index a1180e3..5606de4 100644 --- a/apps/web/src/components/review/FileCard.tsx +++ b/apps/web/src/components/review/FileCard.tsx @@ -52,6 +52,7 @@ interface FileCardProps { ) => void; onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; + onReplyComment?: (parentCommentId: string, body: string) => void; isViewed?: boolean; onToggleViewed?: () => void; } @@ -62,6 +63,7 @@ export function FileCard({ onAddComment, onResolveComment, onUnresolveComment, + onReplyComment, isViewed = false, onToggleViewed = () => {}, }: FileCardProps) { @@ -157,6 +159,7 @@ export function FileCard({ onAddComment={onAddComment} onResolveComment={onResolveComment} onUnresolveComment={onUnresolveComment} + onReplyComment={onReplyComment} tokenMap={tokenMap} /> ))} diff --git a/apps/web/src/components/review/HunkRows.tsx b/apps/web/src/components/review/HunkRows.tsx index e8f9038..eaeeb5a 100644 --- a/apps/web/src/components/review/HunkRows.tsx +++ b/apps/web/src/components/review/HunkRows.tsx @@ -15,6 +15,7 @@ interface HunkRowsProps { ) => void; onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; + onReplyComment?: (parentCommentId: string, body: string) => void; tokenMap?: LineTokenMap | null; } @@ -25,6 +26,7 @@ export function HunkRows({ onAddComment, onResolveComment, onUnresolveComment, + onReplyComment, tokenMap, }: HunkRowsProps) { const [commentingLine, setCommentingLine] = useState<{ @@ -98,6 +100,7 @@ export function HunkRows({ onSubmitComment={handleSubmitComment} onResolveComment={onResolveComment} onUnresolveComment={onUnresolveComment} + onReplyComment={onReplyComment} tokens={ line.newLineNumber !== null ? tokenMap?.get(line.newLineNumber) ?? undefined diff --git a/apps/web/src/components/review/LineWithComments.tsx b/apps/web/src/components/review/LineWithComments.tsx index ac4288f..57d58cf 100644 --- a/apps/web/src/components/review/LineWithComments.tsx +++ b/apps/web/src/components/review/LineWithComments.tsx @@ -15,6 +15,7 @@ interface LineWithCommentsProps { onSubmitComment: (body: string) => void; onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; + onReplyComment?: (parentCommentId: string, body: string) => void; /** Syntax-highlighted tokens for this line (if available) */ tokens?: TokenizedLine; } @@ -29,6 +30,7 @@ export function LineWithComments({ onSubmitComment, onResolveComment, onUnresolveComment, + onReplyComment, tokens, }: LineWithCommentsProps) { const formRef = useRef(null); @@ -141,6 +143,7 @@ export function LineWithComments({ comments={lineComments} onResolve={onResolveComment} onUnresolve={onUnresolveComment} + onReply={onReplyComment} /> diff --git a/apps/web/src/components/review/ReviewSidebar.tsx b/apps/web/src/components/review/ReviewSidebar.tsx index 3a5eb54..fe8d383 100644 --- a/apps/web/src/components/review/ReviewSidebar.tsx +++ b/apps/web/src/components/review/ReviewSidebar.tsx @@ -183,8 +183,8 @@ function FilesView({ activeFiles: FileDiff[]; viewedFiles: Set; }) { - const unresolvedCount = comments.filter((c) => !c.resolved).length; - const resolvedCount = comments.filter((c) => c.resolved).length; + const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length; + const resolvedCount = comments.filter((c) => c.resolved && !c.parentCommentId).length; const activeFilePaths = new Set(activeFiles.map((f) => f.newPath)); const directoryGroups = useMemo(() => groupFilesByDirectory(files), [files]); @@ -263,7 +263,7 @@ function FilesView({
{group.files.map((file) => { const fileCommentCount = comments.filter( - (c) => c.filePath === file.newPath, + (c) => c.filePath === file.newPath && !c.parentCommentId, ).length; const isInView = activeFilePaths.has(file.newPath); const dimmed = selectedCommit && !isInView; diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 7a28e3a..cd5d987 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -153,6 +153,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { author: c.author, createdAt: typeof c.createdAt === 'string' ? c.createdAt : String(c.createdAt), resolved: c.resolved, + parentCommentId: c.parentCommentId ?? null, })); }, [commentsQuery.data]); @@ -175,6 +176,13 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { }, }); + const replyToCommentMutation = trpc.replyToReviewComment.useMutation({ + onSuccess: () => { + utils.listReviewComments.invalidate({ phaseId: activePhaseId! }); + }, + onError: (err) => toast.error(`Failed to post reply: ${err.message}`), + }); + const approveMutation = trpc.approvePhaseReview.useMutation({ onSuccess: () => { setStatus("approved"); @@ -221,6 +229,10 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { unresolveCommentMutation.mutate({ id: commentId }); }, [unresolveCommentMutation]); + const handleReplyComment = useCallback((parentCommentId: string, body: string) => { + replyToCommentMutation.mutate({ parentCommentId, body }); + }, [replyToCommentMutation]); + const handleApprove = useCallback(() => { if (!activePhaseId) return; approveMutation.mutate({ phaseId: activePhaseId }); @@ -256,7 +268,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { setViewedFiles(new Set()); }, []); - const unresolvedCount = comments.filter((c) => !c.resolved).length; + const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length; const activePhaseName = diffQuery.data?.phaseName ?? @@ -350,6 +362,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { onAddComment={handleAddComment} onResolveComment={handleResolveComment} onUnresolveComment={handleUnresolveComment} + onReplyComment={handleReplyComment} viewedFiles={viewedFiles} onToggleViewed={toggleViewed} onRegisterRef={registerFileRef} diff --git a/apps/web/src/components/review/types.ts b/apps/web/src/components/review/types.ts index 488a832..2b99452 100644 --- a/apps/web/src/components/review/types.ts +++ b/apps/web/src/components/review/types.ts @@ -34,6 +34,7 @@ export interface ReviewComment { author: string; createdAt: string; resolved: boolean; + parentCommentId?: string | null; } export type ReviewStatus = "pending" | "approved" | "changes_requested"; diff --git a/docs/dispatch-events.md b/docs/dispatch-events.md index 558a325..0c261b7 100644 --- a/docs/dispatch-events.md +++ b/docs/dispatch-events.md @@ -123,5 +123,5 @@ Multiple rapid events (e.g. several `phase:queued` from `queueAllPhases`) are co - **YOLO**: phase completes → auto-merge → auto-dispatch next phase → auto-dispatch tasks - **review_per_phase**: phase completes → set `pending_review` → STOP. User approves → `approveAndMergePhase()` → merge → dispatch next phase → dispatch tasks -- **request-changes (phase)**: phase `pending_review` → user requests changes → creates revision task (category=`'review'`, dedup guard skips if active review exists) → phase reset to `in_progress` → agent fixes → phase returns to `pending_review` +- **request-changes (phase)**: phase `pending_review` → user requests changes → creates revision task (category=`'review'`, dedup guard skips if active review exists) with threaded comments (`[comment:ID]` tags + reply threads) → phase reset to `in_progress` → agent reads comments, fixes code, writes `.cw/output/comment-responses.json` → OutputHandler creates reply comments and optionally resolves threads → phase returns to `pending_review` - **request-changes (initiative)**: initiative `pending_review` → user requests changes → creates/reuses "Finalization" phase → creates review task → initiative reset to `active` → agent fixes → initiative returns to `pending_review` diff --git a/docs/frontend.md b/docs/frontend.md index 4c8ebfa..cd95313 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -111,10 +111,11 @@ The initiative detail page has three tabs managed via local state (not URL param ### Review Components (`src/components/review/`) | Component | Purpose | |-----------|---------| -| `ReviewTab` | Review tab container — orchestrates header, diff, sidebar, and preview. Phase-level review has inline comments + Request Changes; initiative-level review has Request Changes (summary prompt) + Push Branch / Merge & Push | +| `ReviewTab` | Review tab container — orchestrates header, diff, sidebar, and preview. Phase-level review has threaded inline comments (with reply support) + Request Changes; initiative-level review has Request Changes (summary prompt) + Push Branch / Merge & Push | | `ReviewHeader` | Consolidated toolbar: phase selector pills, branch info, stats, preview controls, approve/reject actions | -| `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, comment counts, and commit navigation | -| `DiffViewer` | Unified diff renderer with inline comments | +| `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, root-only comment counts, and commit navigation | +| `DiffViewer` | Unified diff renderer with threaded inline comments (root + reply threads) | +| `CommentThread` | Renders root comment with resolve/reopen + nested reply threads (agent replies styled with primary border). Inline reply form | | `PreviewPanel` | Docker preview status: building/running/failed with start/stop (legacy, now integrated into ReviewHeader) | | `ProposalCard` | Individual proposal display | diff --git a/docs/server-api.md b/docs/server-api.md index 337ec2a..48c62bd 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -116,11 +116,12 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | getPhaseReviewCommits | query | List commits between initiative and phase branch | | getCommitDiff | query | Diff for a single commit (by hash) in a phase | | approvePhaseReview | mutation | Approve and merge phase branch | -| requestPhaseChanges | mutation | Request changes: creates revision task from unresolved comments (dedup guard skips if active review exists), resets phase to in_progress | -| listReviewComments | query | List review comments by phaseId | +| requestPhaseChanges | mutation | Request changes: creates revision task from unresolved threaded comments (with `[comment:ID]` tags and reply threads), resets phase to in_progress | +| listReviewComments | query | List review comments by phaseId (flat list including replies, frontend groups by parentCommentId) | | createReviewComment | mutation | Create inline review comment on diff | | resolveReviewComment | mutation | Mark review comment as resolved | | unresolveReviewComment | mutation | Mark review comment as unresolved | +| replyToReviewComment | mutation | Create a threaded reply to an existing review comment (copies parent's phaseId/filePath/lineNumber) | ### Phase Dispatch | Procedure | Type | Description | From eb667dd3d7e0b9abc3e9e41e412974e887719b4b Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 10:27:44 +0100 Subject: [PATCH 27/54] fix: Register migration 0032 in drizzle journal The migration file existed but wasn't in _journal.json, so drizzle-kit's migrator never applied it. Adds the journal entry for 0032_add_comment_threading. --- apps/server/drizzle/meta/_journal.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 1c5870a..96636a8 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -225,6 +225,13 @@ "when": 1772236800000, "tag": "0031_add_phase_merge_base", "breakpoints": true + }, + { + "idx": 32, + "version": "6", + "when": 1772323200000, + "tag": "0032_add_comment_threading", + "breakpoints": true } ] } \ No newline at end of file From 03c2abbd8f5b3e4f161f6003461a860f83f9e1ea Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 10:29:49 +0100 Subject: [PATCH 28/54] =?UTF-8?q?docs:=20Fix=20migration=20workflow=20?= =?UTF-8?q?=E2=80=94=20document=20hand-written=20SQL=20+=20journal=20regis?= =?UTF-8?q?tration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drizzle-kit generate has been broken since migration 0008 (stale snapshots). The actual workflow is hand-written SQL + manual _journal.json registration. Updated CLAUDE.md and database-migrations.md to reflect reality and prevent future migrations from silently failing to apply. --- CLAUDE.md | 2 +- docs/database-migrations.md | 72 ++++++++++++++++++++++++++----------- 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5f9f778..c4e8e07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ Pre-implementation design docs are archived in `docs/archive/`. ## Key Rules -- **Database**: Never use raw SQL for schema initialization. Use `drizzle-kit generate` and the migration system. See [docs/database-migrations.md](docs/database-migrations.md). +- **Database migrations**: Hand-write SQL in `apps/server/drizzle/NNNN_name.sql` AND register it in `apps/server/drizzle/meta/_journal.json` (increment `idx`, set `tag` to filename without `.sql`). Both files must be committed together. Do NOT use `drizzle-kit generate` — snapshots are stale since migration 0008. See [docs/database-migrations.md](docs/database-migrations.md). - **Logging**: Use `createModuleLogger()` from `apps/server/logger/index.ts`. Keep `console.log` for CLI user-facing output only. - **Hexagonal architecture**: Repository ports in `apps/server/db/repositories/*.ts`, Drizzle adapters in `apps/server/db/repositories/drizzle/*.ts`. All re-exported from `apps/server/db/index.ts`. - **tRPC context**: Optional repos accessed via `require*Repository()` helpers in `apps/server/trpc/routers/_helpers.ts`. diff --git a/docs/database-migrations.md b/docs/database-migrations.md index 5d149fe..93b0975 100644 --- a/docs/database-migrations.md +++ b/docs/database-migrations.md @@ -1,11 +1,11 @@ # Database Migrations -This project uses [drizzle-kit](https://orm.drizzle.team/kit-docs/overview) for database schema management and migrations. +This project uses [drizzle-orm](https://orm.drizzle.team/) for database schema management with **hand-written SQL migrations**. ## Overview - **Schema definition:** `apps/server/db/schema.ts` (drizzle-orm table definitions) -- **Migration output:** `apps/server/drizzle/` directory (SQL files + meta journal) +- **Migration output:** `apps/server/drizzle/` directory (SQL files + `meta/_journal.json`) - **Config:** `drizzle.config.ts` - **Runtime migrator:** `apps/server/db/ensure-schema.ts` (calls `drizzle-orm/better-sqlite3/migrator`) @@ -13,17 +13,55 @@ This project uses [drizzle-kit](https://orm.drizzle.team/kit-docs/overview) for On every server startup, `ensureSchema(db)` runs all pending migrations from the `apps/server/drizzle/` folder. Drizzle tracks applied migrations in a `__drizzle_migrations` table so only new migrations are applied. This is safe to call repeatedly. +The migrator discovers migrations via `apps/server/drizzle/meta/_journal.json` — **not** by scanning the filesystem. A migration SQL file that isn't registered in the journal will never be applied. + ## Workflow ### Making schema changes +**Do NOT use `drizzle-kit generate`** — the snapshots in `meta/` have been stale since migration 0008 and `drizzle-kit generate` will produce incorrect interactive prompts. All migrations since 0008 are hand-written. + 1. Edit `apps/server/db/schema.ts` with your table/column changes -2. Generate a migration: - ```bash - npx drizzle-kit generate - ``` -3. Review the generated SQL in `apps/server/drizzle/NNNN_*.sql` -4. Commit the migration file along with your schema change +2. Create a new SQL migration file: `apps/server/drizzle/NNNN_descriptive_name.sql` + - Number it sequentially (check the last migration number) + - Write the SQL (ALTER TABLE, CREATE INDEX, etc.) +3. **Register it in the journal**: edit `apps/server/drizzle/meta/_journal.json` + - Add a new entry at the end of the `entries` array: + ```json + { + "idx": , + "version": "6", + "when": , + "tag": "NNNN_descriptive_name", + "breakpoints": true + } + ``` + - `idx`: sequential (previous entry's idx + 1) + - `tag`: migration filename **without `.sql` extension** + - `when`: any timestamp in milliseconds (e.g., previous + 86400000) +4. Commit **both** the SQL file and the updated `_journal.json` together +5. Run `npm run build && npm link` to pick up the changes + +### Example + +Adding a column to an existing table: + +```sql +-- apps/server/drizzle/0032_add_comment_threading.sql +ALTER TABLE review_comments ADD COLUMN parent_comment_id TEXT REFERENCES review_comments(id) ON DELETE CASCADE; +CREATE INDEX review_comments_parent_id_idx ON review_comments(parent_comment_id); +``` + +```json +// In meta/_journal.json entries array: +{ + "idx": 32, + "version": "6", + "when": 1772323200000, + "tag": "0032_add_comment_threading", + "breakpoints": true +} +``` ### Applying migrations @@ -31,20 +69,12 @@ Migrations are applied automatically on server startup. No manual step needed. For tests, the same `ensureSchema()` function is called on in-memory SQLite databases in `apps/server/db/repositories/drizzle/test-helpers.ts`. -### Checking migration status - -```bash -# See what drizzle-kit would generate (dry run) -npx drizzle-kit generate --dry-run - -# Open drizzle studio to inspect the database -npx drizzle-kit studio -``` - ## Rules -- **Never hand-write migration SQL.** Always use `drizzle-kit generate` from the schema. +- **Always hand-write migration SQL.** Do not use `drizzle-kit generate` (stale snapshots). +- **Always register in `_journal.json`.** The migrator reads the journal, not the filesystem. +- **Commit SQL + journal together.** A migration file without a journal entry is invisible to the migrator. - **Never use raw CREATE TABLE statements** for schema initialization. The migration system handles this. -- **Always commit migration files.** They are the source of truth for database evolution. - **Migration files are immutable.** Once committed, never edit them. Make a new migration instead. -- **Test with `npx vitest run`** after generating migrations to verify they work with in-memory databases. +- **Test with `npm test`** after creating migrations to verify they work with in-memory databases. +- **Keep schema.ts in sync.** The schema file is the source of truth for TypeScript types; migrations are the source of truth for database DDL. Both must reflect the same structure. From 4656627a592f3ebc38ac6e81e704f44afb0f4715 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 10:36:07 +0100 Subject: [PATCH 29/54] fix: Restore drizzle-kit generate by syncing snapshot baseline Snapshots were stale since migration 0008. Generated a schema-derived snapshot at 0032 so drizzle-kit generate works again (zero diff on current schema.ts). Also fixed migration 0032 to use statement-breakpoint separator required by better-sqlite3. - Added 0032_snapshot.json derived from current schema.ts - Fixed 0032 SQL to use --> statement-breakpoint between statements - Updated CLAUDE.md and database-migrations.md with correct workflow --- CLAUDE.md | 2 +- .../drizzle/0032_add_comment_threading.sql | 2 +- apps/server/drizzle/meta/0032_snapshot.json | 1864 +++++++++++++++++ docs/database-migrations.md | 65 +- 4 files changed, 1887 insertions(+), 46 deletions(-) create mode 100644 apps/server/drizzle/meta/0032_snapshot.json diff --git a/CLAUDE.md b/CLAUDE.md index c4e8e07..4bf881c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ Pre-implementation design docs are archived in `docs/archive/`. ## Key Rules -- **Database migrations**: Hand-write SQL in `apps/server/drizzle/NNNN_name.sql` AND register it in `apps/server/drizzle/meta/_journal.json` (increment `idx`, set `tag` to filename without `.sql`). Both files must be committed together. Do NOT use `drizzle-kit generate` — snapshots are stale since migration 0008. See [docs/database-migrations.md](docs/database-migrations.md). +- **Database migrations**: Edit `apps/server/db/schema.ts`, then run `npx drizzle-kit generate`. Multi-statement migrations need `--> statement-breakpoint` between statements. See [docs/database-migrations.md](docs/database-migrations.md). - **Logging**: Use `createModuleLogger()` from `apps/server/logger/index.ts`. Keep `console.log` for CLI user-facing output only. - **Hexagonal architecture**: Repository ports in `apps/server/db/repositories/*.ts`, Drizzle adapters in `apps/server/db/repositories/drizzle/*.ts`. All re-exported from `apps/server/db/index.ts`. - **tRPC context**: Optional repos accessed via `require*Repository()` helpers in `apps/server/trpc/routers/_helpers.ts`. diff --git a/apps/server/drizzle/0032_add_comment_threading.sql b/apps/server/drizzle/0032_add_comment_threading.sql index fd17e7a..11afcd0 100644 --- a/apps/server/drizzle/0032_add_comment_threading.sql +++ b/apps/server/drizzle/0032_add_comment_threading.sql @@ -1,2 +1,2 @@ -ALTER TABLE review_comments ADD COLUMN parent_comment_id TEXT REFERENCES review_comments(id) ON DELETE CASCADE; +ALTER TABLE review_comments ADD COLUMN parent_comment_id TEXT REFERENCES review_comments(id) ON DELETE CASCADE;--> statement-breakpoint CREATE INDEX review_comments_parent_id_idx ON review_comments(parent_comment_id); diff --git a/apps/server/drizzle/meta/0032_snapshot.json b/apps/server/drizzle/meta/0032_snapshot.json new file mode 100644 index 0000000..28a0410 --- /dev/null +++ b/apps/server/drizzle/meta/0032_snapshot.json @@ -0,0 +1,1864 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5fbe1151-1dfb-4b0c-a7fa-2177369543fd", + "prevId": "c0b6d7d3-c9da-440a-9fb8-9dd88df5672a", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude'" + }, + "config_json": { + "name": "config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_exhausted": { + "name": "is_exhausted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "exhausted_until": { + "name": "exhausted_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_log_chunks": { + "name": "agent_log_chunks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_number": { + "name": "session_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_log_chunks_agent_id_idx": { + "name": "agent_log_chunks_agent_id_idx", + "columns": [ + "agent_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agents": { + "name": "agents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude'" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'execute'" + }, + "pid": { + "name": "pid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_file_path": { + "name": "output_file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_questions": { + "name": "pending_questions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_dismissed_at": { + "name": "user_dismissed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "agents_name_unique": { + "name": "agents_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "agents_task_id_tasks_id_fk": { + "name": "agents_task_id_tasks_id_fk", + "tableFrom": "agents", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_initiative_id_initiatives_id_fk": { + "name": "agents_initiative_id_initiatives_id_fk", + "tableFrom": "agents", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_account_id_accounts_id_fk": { + "name": "agents_account_id_accounts_id_fk", + "tableFrom": "agents", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "change_set_entries": { + "name": "change_set_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_state": { + "name": "previous_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_state": { + "name": "new_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "change_set_entries_change_set_id_idx": { + "name": "change_set_entries_change_set_id_idx", + "columns": [ + "change_set_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "change_set_entries_change_set_id_change_sets_id_fk": { + "name": "change_set_entries_change_set_id_change_sets_id_fk", + "tableFrom": "change_set_entries", + "tableTo": "change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "change_sets": { + "name": "change_sets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'applied'" + }, + "reverted_at": { + "name": "reverted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "change_sets_initiative_id_idx": { + "name": "change_sets_initiative_id_idx", + "columns": [ + "initiative_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "change_sets_agent_id_agents_id_fk": { + "name": "change_sets_agent_id_agents_id_fk", + "tableFrom": "change_sets", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "change_sets_initiative_id_initiatives_id_fk": { + "name": "change_sets_initiative_id_initiatives_id_fk", + "tableFrom": "change_sets", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "chat_session_id": { + "name": "chat_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_messages_session_id_idx": { + "name": "chat_messages_session_id_idx", + "columns": [ + "chat_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_messages_chat_session_id_chat_sessions_id_fk": { + "name": "chat_messages_chat_session_id_chat_sessions_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_messages_change_set_id_change_sets_id_fk": { + "name": "chat_messages_change_set_id_change_sets_id_fk", + "tableFrom": "chat_messages", + "tableTo": "change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_sessions": { + "name": "chat_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_sessions_target_idx": { + "name": "chat_sessions_target_idx", + "columns": [ + "target_type", + "target_id" + ], + "isUnique": false + }, + "chat_sessions_initiative_id_idx": { + "name": "chat_sessions_initiative_id_idx", + "columns": [ + "initiative_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_sessions_initiative_id_initiatives_id_fk": { + "name": "chat_sessions_initiative_id_initiatives_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_agent_id_agents_id_fk": { + "name": "chat_sessions_agent_id_agents_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "from_agent_id": { + "name": "from_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_agent_id": { + "name": "to_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_to_agent_status_idx": { + "name": "conversations_to_agent_status_idx", + "columns": [ + "to_agent_id", + "status" + ], + "isUnique": false + }, + "conversations_from_agent_idx": { + "name": "conversations_from_agent_idx", + "columns": [ + "from_agent_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "conversations_from_agent_id_agents_id_fk": { + "name": "conversations_from_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "from_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_to_agent_id_agents_id_fk": { + "name": "conversations_to_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "to_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_initiative_id_initiatives_id_fk": { + "name": "conversations_initiative_id_initiatives_id_fk", + "tableFrom": "conversations", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_phase_id_phases_id_fk": { + "name": "conversations_phase_id_phases_id_fk", + "tableFrom": "conversations", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_task_id_tasks_id_fk": { + "name": "conversations_task_id_tasks_id_fk", + "tableFrom": "conversations", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "initiative_projects": { + "name": "initiative_projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "initiative_project_unique": { + "name": "initiative_project_unique", + "columns": [ + "initiative_id", + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "initiative_projects_initiative_id_initiatives_id_fk": { + "name": "initiative_projects_initiative_id_initiatives_id_fk", + "tableFrom": "initiative_projects", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "initiative_projects_project_id_projects_id_fk": { + "name": "initiative_projects_project_id_projects_id_fk", + "tableFrom": "initiative_projects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "initiatives": { + "name": "initiatives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_mode": { + "name": "execution_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'review_per_phase'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sender_type": { + "name": "sender_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_type": { + "name": "recipient_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requires_response": { + "name": "requires_response", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "messages_sender_id_agents_id_fk": { + "name": "messages_sender_id_agents_id_fk", + "tableFrom": "messages", + "tableTo": "agents", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_recipient_id_agents_id_fk": { + "name": "messages_recipient_id_agents_id_fk", + "tableFrom": "messages", + "tableTo": "agents", + "columnsFrom": [ + "recipient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_parent_message_id_messages_id_fk": { + "name": "messages_parent_message_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": [ + "parent_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pages": { + "name": "pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_page_id": { + "name": "parent_page_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "pages_initiative_id_initiatives_id_fk": { + "name": "pages_initiative_id_initiatives_id_fk", + "tableFrom": "pages", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pages_parent_page_id_pages_id_fk": { + "name": "pages_parent_page_id_pages_id_fk", + "tableFrom": "pages", + "tableTo": "pages", + "columnsFrom": [ + "parent_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "phase_dependencies": { + "name": "phase_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "depends_on_phase_id": { + "name": "depends_on_phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "phase_dependencies_phase_id_phases_id_fk": { + "name": "phase_dependencies_phase_id_phases_id_fk", + "tableFrom": "phase_dependencies", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "phase_dependencies_depends_on_phase_id_phases_id_fk": { + "name": "phase_dependencies_depends_on_phase_id_phases_id_fk", + "tableFrom": "phase_dependencies", + "tableTo": "phases", + "columnsFrom": [ + "depends_on_phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "phases": { + "name": "phases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "merge_base": { + "name": "merge_base", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "phases_initiative_id_initiatives_id_fk": { + "name": "phases_initiative_id_initiatives_id_fk", + "tableFrom": "phases", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'main'" + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_name_unique": { + "name": "projects_name_unique", + "columns": [ + "name" + ], + "isUnique": true + }, + "projects_url_unique": { + "name": "projects_url_unique", + "columns": [ + "url" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "review_comments": { + "name": "review_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line_type": { + "name": "line_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'you'" + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved": { + "name": "resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "review_comments_phase_id_idx": { + "name": "review_comments_phase_id_idx", + "columns": [ + "phase_id" + ], + "isUnique": false + }, + "review_comments_parent_id_idx": { + "name": "review_comments_parent_id_idx", + "columns": [ + "parent_comment_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "review_comments_phase_id_phases_id_fk": { + "name": "review_comments_phase_id_phases_id_fk", + "tableFrom": "review_comments", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_parent_comment_id_review_comments_id_fk": { + "name": "review_comments_parent_comment_id_review_comments_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_comments", + "columnsFrom": [ + "parent_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_dependencies": { + "name": "task_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "depends_on_task_id": { + "name": "depends_on_task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "task_dependencies_task_id_tasks_id_fk": { + "name": "task_dependencies_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_dependencies_depends_on_task_id_tasks_id_fk": { + "name": "task_dependencies_depends_on_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "tableTo": "tasks", + "columnsFrom": [ + "depends_on_task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_task_id": { + "name": "parent_task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'auto'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'execute'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_phase_id_phases_id_fk": { + "name": "tasks_phase_id_phases_id_fk", + "tableFrom": "tasks", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_initiative_id_initiatives_id_fk": { + "name": "tasks_initiative_id_initiatives_id_fk", + "tableFrom": "tasks", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_parent_task_id_tasks_id_fk": { + "name": "tasks_parent_task_id_tasks_id_fk", + "tableFrom": "tasks", + "tableTo": "tasks", + "columnsFrom": [ + "parent_task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/docs/database-migrations.md b/docs/database-migrations.md index 93b0975..603d49e 100644 --- a/docs/database-migrations.md +++ b/docs/database-migrations.md @@ -1,11 +1,11 @@ # Database Migrations -This project uses [drizzle-orm](https://orm.drizzle.team/) for database schema management with **hand-written SQL migrations**. +This project uses [drizzle-kit](https://orm.drizzle.team/kit-docs/overview) for database schema management and migrations. ## Overview - **Schema definition:** `apps/server/db/schema.ts` (drizzle-orm table definitions) -- **Migration output:** `apps/server/drizzle/` directory (SQL files + `meta/_journal.json`) +- **Migration output:** `apps/server/drizzle/` directory (SQL files + `meta/_journal.json` + `meta/NNNN_snapshot.json`) - **Config:** `drizzle.config.ts` - **Runtime migrator:** `apps/server/db/ensure-schema.ts` (calls `drizzle-orm/better-sqlite3/migrator`) @@ -13,54 +13,28 @@ This project uses [drizzle-orm](https://orm.drizzle.team/) for database schema m On every server startup, `ensureSchema(db)` runs all pending migrations from the `apps/server/drizzle/` folder. Drizzle tracks applied migrations in a `__drizzle_migrations` table so only new migrations are applied. This is safe to call repeatedly. -The migrator discovers migrations via `apps/server/drizzle/meta/_journal.json` — **not** by scanning the filesystem. A migration SQL file that isn't registered in the journal will never be applied. +The migrator discovers migrations via `apps/server/drizzle/meta/_journal.json` — **not** by scanning the filesystem. ## Workflow ### Making schema changes -**Do NOT use `drizzle-kit generate`** — the snapshots in `meta/` have been stale since migration 0008 and `drizzle-kit generate` will produce incorrect interactive prompts. All migrations since 0008 are hand-written. - 1. Edit `apps/server/db/schema.ts` with your table/column changes -2. Create a new SQL migration file: `apps/server/drizzle/NNNN_descriptive_name.sql` - - Number it sequentially (check the last migration number) - - Write the SQL (ALTER TABLE, CREATE INDEX, etc.) -3. **Register it in the journal**: edit `apps/server/drizzle/meta/_journal.json` - - Add a new entry at the end of the `entries` array: - ```json - { - "idx": , - "version": "6", - "when": , - "tag": "NNNN_descriptive_name", - "breakpoints": true - } - ``` - - `idx`: sequential (previous entry's idx + 1) - - `tag`: migration filename **without `.sql` extension** - - `when`: any timestamp in milliseconds (e.g., previous + 86400000) -4. Commit **both** the SQL file and the updated `_journal.json` together -5. Run `npm run build && npm link` to pick up the changes +2. Generate a migration: + ```bash + npx drizzle-kit generate + ``` +3. Review the generated SQL in `apps/server/drizzle/NNNN_*.sql` +4. Verify multi-statement migrations use `--> statement-breakpoint` between statements (required by better-sqlite3 which only allows one statement per `prepare()` call) +5. Commit the migration file, snapshot, and journal update together -### Example +### Important: statement breakpoints -Adding a column to an existing table: +better-sqlite3 rejects SQL with multiple statements in a single `prepare()` call. Drizzle-kit splits on `--> statement-breakpoint`. If you hand-write or edit a migration with multiple statements, append `--> statement-breakpoint` to the end of each statement line (before the next statement): ```sql --- apps/server/drizzle/0032_add_comment_threading.sql -ALTER TABLE review_comments ADD COLUMN parent_comment_id TEXT REFERENCES review_comments(id) ON DELETE CASCADE; -CREATE INDEX review_comments_parent_id_idx ON review_comments(parent_comment_id); -``` - -```json -// In meta/_journal.json entries array: -{ - "idx": 32, - "version": "6", - "when": 1772323200000, - "tag": "0032_add_comment_threading", - "breakpoints": true -} +ALTER TABLE foo ADD COLUMN bar TEXT;--> statement-breakpoint +CREATE INDEX foo_bar_idx ON foo(bar); ``` ### Applying migrations @@ -69,12 +43,15 @@ Migrations are applied automatically on server startup. No manual step needed. For tests, the same `ensureSchema()` function is called on in-memory SQLite databases in `apps/server/db/repositories/drizzle/test-helpers.ts`. +## History + +Migrations 0000–0007 were generated by `drizzle-kit generate`. Migrations 0008–0032 were hand-written (the snapshots fell out of sync). A schema-derived snapshot was restored at 0032, so `drizzle-kit generate` works normally from that point forward. + ## Rules -- **Always hand-write migration SQL.** Do not use `drizzle-kit generate` (stale snapshots). -- **Always register in `_journal.json`.** The migrator reads the journal, not the filesystem. -- **Commit SQL + journal together.** A migration file without a journal entry is invisible to the migrator. +- **Use `drizzle-kit generate`** for new migrations. It reads schema.ts, diffs against the last snapshot, and generates both SQL + snapshot automatically. - **Never use raw CREATE TABLE statements** for schema initialization. The migration system handles this. +- **Always commit migration files.** They are the source of truth for database evolution. - **Migration files are immutable.** Once committed, never edit them. Make a new migration instead. -- **Test with `npm test`** after creating migrations to verify they work with in-memory databases. - **Keep schema.ts in sync.** The schema file is the source of truth for TypeScript types; migrations are the source of truth for database DDL. Both must reflect the same structure. +- **Test with `npm test`** after generating migrations to verify they work with in-memory databases. From 1e2819eeff8c8f326540d9022f13218572b83000 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 10:43:36 +0100 Subject: [PATCH 30/54] fix: Request changes uses in-app confirmation, requires review comments - Replace window.prompt with in-app dropdown confirmation (matches merge dialog pattern) - Disable button when no unresolved comments exist (comments-only, no summary) - Remove initiative-level request changes button (no comment system there) --- .../components/review/InitiativeReview.tsx | 28 +------ .../src/components/review/ReviewHeader.tsx | 82 +++++++++++++++---- apps/web/src/components/review/ReviewTab.tsx | 4 +- 3 files changed, 68 insertions(+), 46 deletions(-) diff --git a/apps/web/src/components/review/InitiativeReview.tsx b/apps/web/src/components/review/InitiativeReview.tsx index 5f7a36b..fc0cd7a 100644 --- a/apps/web/src/components/review/InitiativeReview.tsx +++ b/apps/web/src/components/review/InitiativeReview.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge, RotateCcw } from "lucide-react"; +import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge } from "lucide-react"; import { Button } from "@/components/ui/button"; import { trpc } from "@/lib/trpc"; import { parseUnifiedDiff } from "./parse-diff"; @@ -82,14 +82,6 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview onError: (err) => toast.error(`Failed to stop: ${err.message}`), }); - const requestChangesMutation = trpc.requestInitiativeChanges.useMutation({ - onSuccess: () => { - toast.success("Changes requested — review task created"); - onCompleted(); - }, - onError: (err) => toast.error(err.message), - }); - const approveMutation = trpc.approveInitiativeReview.useMutation({ onSuccess: (_data, variables) => { const msg = variables.strategy === "merge_and_push" @@ -197,24 +189,6 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview {/* Right: preview + action buttons */}
{previewState && } - + + {showRequestConfirm && ( +
+

+ Request changes? +

+
+
+ + + {unresolvedCount} unresolved {unresolvedCount === 1 ? "comment" : "comments"} will be sent + +
+
+
+ + +
+
)} - Request Changes - +
From 157fa445c53ed40d0b75423810050ac6a7dd506c Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:03:27 +0100 Subject: [PATCH 33/54] fix: Show individual discussion threads in review sidebar with navigation Discussions section was showing only aggregate counts (total/resolved/unresolved) with no way to see or navigate to individual threads. Now lists each root comment with file:line location, body preview, resolved status, and reply count. Clicking a discussion scrolls to its file in the diff viewer. --- .../src/components/review/ReviewSidebar.tsx | 77 ++++++++++++++----- 1 file changed, 57 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/review/ReviewSidebar.tsx b/apps/web/src/components/review/ReviewSidebar.tsx index fe8d383..cc7042e 100644 --- a/apps/web/src/components/review/ReviewSidebar.tsx +++ b/apps/web/src/components/review/ReviewSidebar.tsx @@ -213,29 +213,66 @@ function FilesView({ )} - {/* Comment summary */} + {/* Discussions — individual threads */} {comments.length > 0 && (
-

- Discussions -

-
- - - {comments.length} +

+ Discussions + + {unresolvedCount > 0 && ( + + + {unresolvedCount} + + )} + {resolvedCount > 0 && ( + + + {resolvedCount} + + )} - {resolvedCount > 0 && ( - - - {resolvedCount} - - )} - {unresolvedCount > 0 && ( - - - {unresolvedCount} - - )} +

+
+ {comments + .filter((c) => !c.parentCommentId) + .map((thread) => { + const replyCount = comments.filter( + (c) => c.parentCommentId === thread.id, + ).length; + return ( + + ); + })}
)} From 14d09b16dfd988c90ac73de2c582664861095a39 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:07:01 +0100 Subject: [PATCH 34/54] fix: Phase completion check runs regardless of branch/merge status handleTaskCompleted and handlePhaseAllTasksDone both bailed early when initiative had no branch, silently skipping phase status transitions. Also, merge failures would skip the phase completion check entirely. - Decouple phase completion check from branch existence - Wrap merge in try/catch so phase check runs even if merge fails - Route updateTaskStatus through dispatchManager.completeTask when completing, so the task:completed event fires for orchestration --- apps/server/execution/orchestrator.test.ts | 306 +++++++++++++++++++++ apps/server/execution/orchestrator.ts | 39 +-- apps/server/trpc/routers/task.ts | 8 + 3 files changed, 336 insertions(+), 17 deletions(-) create mode 100644 apps/server/execution/orchestrator.test.ts diff --git a/apps/server/execution/orchestrator.test.ts b/apps/server/execution/orchestrator.test.ts new file mode 100644 index 0000000..57efcd5 --- /dev/null +++ b/apps/server/execution/orchestrator.test.ts @@ -0,0 +1,306 @@ +/** + * ExecutionOrchestrator Tests + * + * Tests phase completion transitions, especially when initiative has no branch. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ExecutionOrchestrator } from './orchestrator.js'; +import type { BranchManager } from '../git/branch-manager.js'; +import type { PhaseRepository } from '../db/repositories/phase-repository.js'; +import type { TaskRepository } from '../db/repositories/task-repository.js'; +import type { InitiativeRepository } from '../db/repositories/initiative-repository.js'; +import type { ProjectRepository } from '../db/repositories/project-repository.js'; +import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js'; +import type { ConflictResolutionService } from '../coordination/conflict-resolution-service.js'; +import type { EventBus, TaskCompletedEvent, DomainEvent } from '../events/types.js'; + +function createMockEventBus(): EventBus & { handlers: Map; emitted: DomainEvent[] } { + const handlers = new Map(); + const emitted: DomainEvent[] = []; + return { + handlers, + emitted, + emit: vi.fn((event: DomainEvent) => { + emitted.push(event); + const fns = handlers.get(event.type) ?? []; + for (const fn of fns) fn(event); + }), + on: vi.fn((type: string, handler: Function) => { + const fns = handlers.get(type) ?? []; + fns.push(handler); + handlers.set(type, fns); + }), + off: vi.fn(), + once: vi.fn(), + }; +} + +function createMocks() { + const branchManager: BranchManager = { + ensureBranch: vi.fn(), + mergeBranch: vi.fn().mockResolvedValue({ success: true, message: 'merged' }), + diffBranches: vi.fn().mockResolvedValue(''), + deleteBranch: vi.fn(), + branchExists: vi.fn().mockResolvedValue(true), + remoteBranchExists: vi.fn().mockResolvedValue(false), + listCommits: vi.fn().mockResolvedValue([]), + diffCommit: vi.fn().mockResolvedValue(''), + getMergeBase: vi.fn().mockResolvedValue('abc123'), + pushBranch: vi.fn(), + }; + + const phaseRepository = { + findById: vi.fn(), + findByInitiativeId: vi.fn().mockResolvedValue([]), + update: vi.fn().mockImplementation(async (id: string, data: any) => ({ id, ...data })), + create: vi.fn(), + } as unknown as PhaseRepository; + + const taskRepository = { + findById: vi.fn(), + findByPhaseId: vi.fn().mockResolvedValue([]), + findByInitiativeId: vi.fn().mockResolvedValue([]), + } as unknown as TaskRepository; + + const initiativeRepository = { + findById: vi.fn(), + findByStatus: vi.fn().mockResolvedValue([]), + update: vi.fn(), + } as unknown as InitiativeRepository; + + const projectRepository = { + findProjectsByInitiativeId: vi.fn().mockResolvedValue([]), + } as unknown as ProjectRepository; + + const phaseDispatchManager: PhaseDispatchManager = { + queuePhase: vi.fn(), + getNextDispatchablePhase: vi.fn().mockResolvedValue(null), + dispatchNextPhase: vi.fn().mockResolvedValue({ success: false, phaseId: '', reason: 'none' }), + completePhase: vi.fn(), + blockPhase: vi.fn(), + getPhaseQueueState: vi.fn().mockResolvedValue({ queued: [], ready: [], blocked: [] }), + }; + + const dispatchManager = { + queue: vi.fn(), + getNextDispatchable: vi.fn().mockResolvedValue(null), + dispatchNext: vi.fn().mockResolvedValue({ success: false, taskId: '' }), + completeTask: vi.fn(), + blockTask: vi.fn(), + retryBlockedTask: vi.fn(), + getQueueState: vi.fn().mockResolvedValue({ queued: [], ready: [], blocked: [] }), + } as unknown as DispatchManager; + + const conflictResolutionService: ConflictResolutionService = { + handleConflict: vi.fn(), + }; + + const eventBus = createMockEventBus(); + + return { + branchManager, + phaseRepository, + taskRepository, + initiativeRepository, + projectRepository, + phaseDispatchManager, + dispatchManager, + conflictResolutionService, + eventBus, + }; +} + +function createOrchestrator(mocks: ReturnType) { + const orchestrator = new ExecutionOrchestrator( + mocks.branchManager, + mocks.phaseRepository, + mocks.taskRepository, + mocks.initiativeRepository, + mocks.projectRepository, + mocks.phaseDispatchManager, + mocks.dispatchManager, + mocks.conflictResolutionService, + mocks.eventBus, + '/tmp/test-workspace', + ); + orchestrator.start(); + return orchestrator; +} + +function emitTaskCompleted(eventBus: ReturnType, taskId: string) { + const event: TaskCompletedEvent = { + type: 'task:completed', + timestamp: new Date(), + payload: { taskId, agentId: 'agent-1', success: true, message: 'done' }, + }; + eventBus.emit(event); +} + +describe('ExecutionOrchestrator', () => { + let mocks: ReturnType; + + beforeEach(() => { + mocks = createMocks(); + }); + + describe('phase completion when initiative has no branch', () => { + it('should transition phase to pending_review in review mode even without a branch', async () => { + const task = { + id: 'task-1', + phaseId: 'phase-1', + initiativeId: 'init-1', + category: 'execute', + status: 'completed', + }; + const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' }; + const initiative = { id: 'init-1', branch: null, executionMode: 'review_per_phase' }; + + vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task as any); + vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any); + vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any); + vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task] as any); + + createOrchestrator(mocks); + + emitTaskCompleted(mocks.eventBus, 'task-1'); + + // Allow async handler to complete + await vi.waitFor(() => { + expect(mocks.phaseRepository.update).toHaveBeenCalledWith('phase-1', { status: 'pending_review' }); + }); + }); + + it('should complete phase in yolo mode even without a branch', async () => { + const task = { + id: 'task-1', + phaseId: 'phase-1', + initiativeId: 'init-1', + category: 'execute', + status: 'completed', + }; + const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' }; + const initiative = { id: 'init-1', branch: null, executionMode: 'yolo' }; + + vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task as any); + vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any); + vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any); + vi.mocked(mocks.initiativeRepository.findByStatus).mockResolvedValue([]); + vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task] as any); + vi.mocked(mocks.phaseRepository.findByInitiativeId).mockResolvedValue([phase] as any); + + createOrchestrator(mocks); + + emitTaskCompleted(mocks.eventBus, 'task-1'); + + await vi.waitFor(() => { + expect(mocks.phaseDispatchManager.completePhase).toHaveBeenCalledWith('phase-1'); + }); + + // Should NOT have attempted any branch merges + expect(mocks.branchManager.mergeBranch).not.toHaveBeenCalled(); + }); + }); + + describe('phase completion when merge fails', () => { + it('should still check phase completion even if task merge throws', async () => { + const task = { + id: 'task-1', + phaseId: 'phase-1', + initiativeId: 'init-1', + category: 'execute', + status: 'completed', + }; + const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' }; + const initiative = { id: 'init-1', branch: 'cw/test', executionMode: 'review_per_phase' }; + const project = { id: 'proj-1', name: 'test', url: 'https://example.com', defaultBranch: 'main' }; + + vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task as any); + vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any); + vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any); + vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task] as any); + vi.mocked(mocks.projectRepository.findProjectsByInitiativeId).mockResolvedValue([project] as any); + // Merge fails + vi.mocked(mocks.branchManager.mergeBranch).mockResolvedValue({ + success: false, + message: 'conflict', + conflicts: ['file.ts'], + }); + + createOrchestrator(mocks); + + emitTaskCompleted(mocks.eventBus, 'task-1'); + + // Phase should still transition despite merge failure + await vi.waitFor(() => { + expect(mocks.phaseRepository.update).toHaveBeenCalledWith('phase-1', { status: 'pending_review' }); + }); + }); + }); + + describe('phase completion with branch (normal flow)', () => { + it('should merge task branch and transition phase when all tasks done', async () => { + const task = { + id: 'task-1', + phaseId: 'phase-1', + initiativeId: 'init-1', + category: 'execute', + status: 'completed', + }; + const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' }; + const initiative = { id: 'init-1', branch: 'cw/test', executionMode: 'review_per_phase' }; + const project = { id: 'proj-1', name: 'test', url: 'https://example.com', defaultBranch: 'main' }; + + vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task as any); + vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any); + vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any); + vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task] as any); + vi.mocked(mocks.projectRepository.findProjectsByInitiativeId).mockResolvedValue([project] as any); + vi.mocked(mocks.branchManager.branchExists).mockResolvedValue(true); + vi.mocked(mocks.branchManager.mergeBranch).mockResolvedValue({ success: true, message: 'ok' }); + + createOrchestrator(mocks); + + emitTaskCompleted(mocks.eventBus, 'task-1'); + + await vi.waitFor(() => { + expect(mocks.phaseRepository.update).toHaveBeenCalledWith('phase-1', { status: 'pending_review' }); + }); + }); + + it('should not transition phase when some tasks are still pending', async () => { + const task1 = { + id: 'task-1', + phaseId: 'phase-1', + initiativeId: 'init-1', + category: 'execute', + status: 'completed', + }; + const task2 = { + id: 'task-2', + phaseId: 'phase-1', + initiativeId: 'init-1', + category: 'execute', + status: 'pending', + }; + const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' }; + const initiative = { id: 'init-1', branch: 'cw/test', executionMode: 'review_per_phase' }; + + vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task1 as any); + vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any); + vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any); + vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task1, task2] as any); + vi.mocked(mocks.projectRepository.findProjectsByInitiativeId).mockResolvedValue([]); + + createOrchestrator(mocks); + + emitTaskCompleted(mocks.eventBus, 'task-1'); + + // Give the async handler time to run + await new Promise((r) => setTimeout(r, 50)); + + expect(mocks.phaseRepository.update).not.toHaveBeenCalled(); + expect(mocks.phaseDispatchManager.completePhase).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/server/execution/orchestrator.ts b/apps/server/execution/orchestrator.ts index 973cc59..2883f18 100644 --- a/apps/server/execution/orchestrator.ts +++ b/apps/server/execution/orchestrator.ts @@ -145,27 +145,29 @@ export class ExecutionOrchestrator { if (!task?.phaseId || !task.initiativeId) return; const initiative = await this.initiativeRepository.findById(task.initiativeId); - if (!initiative?.branch) return; - const phase = await this.phaseRepository.findById(task.phaseId); if (!phase) return; - // Skip merge for review/merge tasks — they already work on the phase branch directly - if (task.category !== 'merge' && task.category !== 'review') { - const initBranch = initiative.branch; - const phBranch = phaseBranchName(initBranch, phase.name); - const tBranch = taskBranchName(initBranch, task.id); + // Merge task branch into phase branch (only when branches exist) + if (initiative?.branch && task.category !== 'merge' && task.category !== 'review') { + try { + const initBranch = initiative.branch; + const phBranch = phaseBranchName(initBranch, phase.name); + const tBranch = taskBranchName(initBranch, task.id); - // Serialize merges per phase - const lock = this.phaseMergeLocks.get(task.phaseId) ?? Promise.resolve(); - const mergeOp = lock.then(async () => { - await this.mergeTaskIntoPhase(taskId, task.phaseId!, tBranch, phBranch); - }); - this.phaseMergeLocks.set(task.phaseId, mergeOp.catch(() => {})); - await mergeOp; + // Serialize merges per phase + const lock = this.phaseMergeLocks.get(task.phaseId) ?? Promise.resolve(); + const mergeOp = lock.then(async () => { + await this.mergeTaskIntoPhase(taskId, task.phaseId!, tBranch, phBranch); + }); + this.phaseMergeLocks.set(task.phaseId, mergeOp.catch(() => {})); + await mergeOp; + } catch (err) { + log.error({ taskId, err: err instanceof Error ? err.message : String(err) }, 'task merge failed, still checking phase completion'); + } } - // Check if all phase tasks are done + // Check if all phase tasks are done — always, regardless of branch/merge status const phaseTasks = await this.taskRepository.findByPhaseId(task.phaseId); const allDone = phaseTasks.every((t) => t.status === 'completed'); if (allDone) { @@ -233,10 +235,13 @@ export class ExecutionOrchestrator { if (!phase) return; const initiative = await this.initiativeRepository.findById(phase.initiativeId); - if (!initiative?.branch) return; + if (!initiative) return; if (initiative.executionMode === 'yolo') { - await this.mergePhaseIntoInitiative(phaseId); + // Merge phase branch into initiative branch (only when branches exist) + if (initiative.branch) { + await this.mergePhaseIntoInitiative(phaseId); + } await this.phaseDispatchManager.completePhase(phaseId); // Re-queue approved phases (self-healing: survives server restarts that wipe in-memory queue) diff --git a/apps/server/trpc/routers/task.ts b/apps/server/trpc/routers/task.ts index 1f21074..44f0e95 100644 --- a/apps/server/trpc/routers/task.ts +++ b/apps/server/trpc/routers/task.ts @@ -50,6 +50,14 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) { message: `Task '${input.id}' not found`, }); } + + // Route through dispatchManager when completing — emits task:completed + // event so the orchestrator can check phase completion and merge branches + if (input.status === 'completed' && ctx.dispatchManager) { + await ctx.dispatchManager.completeTask(input.id); + return (await taskRepository.findById(input.id))!; + } + return taskRepository.update(input.id, { status: input.status }); }), From b1233dd013110ed634088cd698d2ee010cba036f Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:07:28 +0100 Subject: [PATCH 35/54] docs: Update dispatch-events for branchless phase completion --- docs/dispatch-events.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/dispatch-events.md b/docs/dispatch-events.md index 0c261b7..5356b0e 100644 --- a/docs/dispatch-events.md +++ b/docs/dispatch-events.md @@ -113,7 +113,7 @@ InitiativeChangesRequestedEvent { initiativeId, phaseId, taskId } |-------|--------| | `phase:queued` | Dispatch ready phases → dispatch their tasks to idle agents | | `agent:stopped` | Re-dispatch queued tasks (freed agent slot) | -| `task:completed` | Merge task branch, then dispatch next queued task | +| `task:completed` | Merge task branch (if branch exists), check phase completion, dispatch next queued task | ### Coalesced Scheduling @@ -121,7 +121,8 @@ Multiple rapid events (e.g. several `phase:queued` from `queueAllPhases`) are co ### Execution Mode Behavior -- **YOLO**: phase completes → auto-merge → auto-dispatch next phase → auto-dispatch tasks +- **YOLO**: phase completes → auto-merge (if branch exists, skipped otherwise) → auto-dispatch next phase → auto-dispatch tasks - **review_per_phase**: phase completes → set `pending_review` → STOP. User approves → `approveAndMergePhase()` → merge → dispatch next phase → dispatch tasks +- **No branch**: Phase completion check runs regardless of branch existence. Merge steps are skipped; status transitions still fire. `updateTaskStatus` tRPC routes completions through `dispatchManager.completeTask()` to emit `task:completed`. - **request-changes (phase)**: phase `pending_review` → user requests changes → creates revision task (category=`'review'`, dedup guard skips if active review exists) with threaded comments (`[comment:ID]` tags + reply threads) → phase reset to `in_progress` → agent reads comments, fixes code, writes `.cw/output/comment-responses.json` → OutputHandler creates reply comments and optionally resolves threads → phase returns to `pending_review` - **request-changes (initiative)**: initiative `pending_review` → user requests changes → creates/reuses "Finalization" phase → creates review task → initiative reset to `active` → agent fixes → initiative returns to `pending_review` From 3fcfa619149786d4f9903ee5eb28cb43f60850ab Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:09:07 +0100 Subject: [PATCH 36/54] fix: Scroll to exact comment location when clicking sidebar discussions Adds data-comment-id attributes to comment thread rows so clicking a discussion in the sidebar scrolls directly to the comment, not just the file card. Includes a brief ring highlight on the target row. --- apps/web/src/components/review/LineWithComments.tsx | 2 +- apps/web/src/components/review/ReviewSidebar.tsx | 7 ++++++- apps/web/src/components/review/ReviewTab.tsx | 11 +++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/review/LineWithComments.tsx b/apps/web/src/components/review/LineWithComments.tsx index 57d58cf..579db1b 100644 --- a/apps/web/src/components/review/LineWithComments.tsx +++ b/apps/web/src/components/review/LineWithComments.tsx @@ -134,7 +134,7 @@ export function LineWithComments({ {/* Existing comments on this line */} {lineComments.length > 0 && ( - + !c.parentCommentId)?.id}> void; + onCommentClick?: (commentId: string) => void; selectedCommit: string | null; activeFiles: FileDiff[]; commits: CommitInfo[]; @@ -29,6 +30,7 @@ export function ReviewSidebar({ files, comments, onFileClick, + onCommentClick, selectedCommit, activeFiles, commits, @@ -63,6 +65,7 @@ export function ReviewSidebar({ files={files} comments={comments} onFileClick={onFileClick} + onCommentClick={onCommentClick} selectedCommit={selectedCommit} activeFiles={activeFiles} viewedFiles={viewedFiles} @@ -172,6 +175,7 @@ function FilesView({ files, comments, onFileClick, + onCommentClick, selectedCommit, activeFiles, viewedFiles, @@ -179,6 +183,7 @@ function FilesView({ files: FileDiff[]; comments: ReviewComment[]; onFileClick: (filePath: string) => void; + onCommentClick?: (commentId: string) => void; selectedCommit: string | null; activeFiles: FileDiff[]; viewedFiles: Set; @@ -248,7 +253,7 @@ function FilesView({ transition-colors hover:bg-accent/50 ${thread.resolved ? "opacity-50" : ""} `} - onClick={() => onFileClick(thread.filePath)} + onClick={() => onCommentClick ? onCommentClick(thread.id) : onFileClick(thread.filePath)} >
{thread.resolved ? ( diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index fbeb072..164ec52 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -259,6 +259,16 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { } }, []); + const handleCommentClick = useCallback((commentId: string) => { + const el = document.querySelector(`[data-comment-id="${commentId}"]`); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + // Brief highlight flash + el.classList.add("ring-2", "ring-primary/50"); + setTimeout(() => el.classList.remove("ring-2", "ring-primary/50"), 1500); + } + }, []); + const handlePhaseSelect = useCallback((id: string) => { setSelectedPhaseId(id); setSelectedCommit(null); @@ -331,6 +341,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { files={allFiles} comments={comments} onFileClick={handleFileClick} + onCommentClick={handleCommentClick} selectedCommit={selectedCommit} activeFiles={files} commits={commits} From 50043f4c6138923036d03dd0c9568ac6545ab282 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:10:50 +0100 Subject: [PATCH 37/54] fix: Use instant scroll for discussion navigation --- apps/web/src/components/review/ReviewTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 164ec52..494a7f9 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -262,7 +262,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { const handleCommentClick = useCallback((commentId: string) => { const el = document.querySelector(`[data-comment-id="${commentId}"]`); if (el) { - el.scrollIntoView({ behavior: "smooth", block: "center" }); + el.scrollIntoView({ behavior: "instant", block: "center" }); // Brief highlight flash el.classList.add("ring-2", "ring-primary/50"); setTimeout(() => el.classList.remove("ring-2", "ring-primary/50"), 1500); From d4b466ce6dbd7855171c29668621470ae8be8a60 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:11:46 +0100 Subject: [PATCH 38/54] fix: Make review sidebar sticky to viewport with internal scrolling Removed overflow-hidden from grid container and changed sidebar to sticky positioning with viewport-relative max-height. Sidebar content scrolls independently while staying visible during diff navigation. --- apps/web/src/components/review/ReviewTab.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 494a7f9..ec78480 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -333,10 +333,10 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { /> {/* Main content area — sidebar always rendered to preserve state */} -
- {/* Left: Sidebar — sticky so icon strip stays visible */} +
+ {/* Left: Sidebar — sticky to viewport, scrolls independently */}
-
+
Date: Fri, 6 Mar 2026 11:15:02 +0100 Subject: [PATCH 39/54] fix: Sidebar accounts for sticky header height via ResizeObserver Measures the review header dynamically and offsets the sidebar's sticky top and max-height accordingly, eliminating the gap when scrolled. --- apps/web/src/components/review/ReviewTab.tsx | 24 ++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index ec78480..91c7c4e 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Loader2 } from "lucide-react"; import { trpc } from "@/lib/trpc"; @@ -18,6 +18,18 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { const [selectedCommit, setSelectedCommit] = useState(null); const [viewedFiles, setViewedFiles] = useState>(new Set()); const fileRefs = useRef>(new Map()); + const headerRef = useRef(null); + const [headerHeight, setHeaderHeight] = useState(0); + + useEffect(() => { + const el = headerRef.current; + if (!el) return; + const ro = new ResizeObserver(([entry]) => { + setHeaderHeight(entry.contentRect.height); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); const toggleViewed = useCallback((filePath: string) => { setViewedFiles(prev => { @@ -313,6 +325,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { return (
{/* Header: phase selector + toolbar */} +
({ id: p.id, name: p.name, status: p.status }))} activePhaseId={activePhaseId} @@ -331,12 +344,19 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { viewedCount={viewedFiles.size} totalCount={allFiles.length} /> +
{/* Main content area — sidebar always rendered to preserve state */}
{/* Left: Sidebar — sticky to viewport, scrolls independently */}
-
+
Date: Fri, 6 Mar 2026 11:15:25 +0100 Subject: [PATCH 40/54] fix: Restore sticky positioning on header wrapper div --- apps/web/src/components/review/ReviewTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 91c7c4e..a532253 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -325,7 +325,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { return (
{/* Header: phase selector + toolbar */} -
+
({ id: p.id, name: p.name, status: p.status }))} activePhaseId={activePhaseId} From 6cf6bd076f25482971ea6ec1829bccd48027e14f Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:17:25 +0100 Subject: [PATCH 41/54] feat: Add merge conflict detection and agent resolution in initiative review Pre-merge mergeability check via `git merge-tree --write-tree` (dry-run, no side effects). When conflicts exist the "Merge & Push" button is disabled and a ConflictResolutionPanel shows conflict files with options to resolve manually or spawn a conflict-resolution agent. Agent questions appear inline via QuestionForm; on completion the mergeability re-checks automatically. New server-side: MergeabilityResult type, BranchManager.checkMergeability, conflict-resolution prompt, checkInitiativeMergeability query, spawnConflictResolutionAgent mutation, getActiveConflictAgent query. New frontend: useConflictAgent hook, ConflictResolutionPanel component, mergeability badge + panel integration in InitiativeReview. --- .../agent/prompts/conflict-resolution.ts | 74 ++++++ apps/server/agent/prompts/index.ts | 1 + apps/server/execution/orchestrator.test.ts | 1 + apps/server/git/branch-manager.ts | 9 +- apps/server/git/index.ts | 2 +- apps/server/git/simple-git-branch-manager.ts | 39 +++- apps/server/git/types.ts | 13 ++ apps/server/trpc/routers/agent.ts | 21 ++ apps/server/trpc/routers/initiative.ts | 125 +++++++++- .../review/ConflictResolutionPanel.tsx | 182 +++++++++++++++ .../components/review/InitiativeReview.tsx | 56 ++++- apps/web/src/hooks/index.ts | 8 +- apps/web/src/hooks/useConflictAgent.ts | 214 ++++++++++++++++++ docs/agent.md | 2 +- docs/frontend.md | 2 + docs/git-process-logging.md | 2 + docs/server-api.md | 3 + 17 files changed, 745 insertions(+), 9 deletions(-) create mode 100644 apps/server/agent/prompts/conflict-resolution.ts create mode 100644 apps/web/src/components/review/ConflictResolutionPanel.tsx create mode 100644 apps/web/src/hooks/useConflictAgent.ts diff --git a/apps/server/agent/prompts/conflict-resolution.ts b/apps/server/agent/prompts/conflict-resolution.ts new file mode 100644 index 0000000..876df7b --- /dev/null +++ b/apps/server/agent/prompts/conflict-resolution.ts @@ -0,0 +1,74 @@ +/** + * Conflict resolution prompt — spawned when initiative branch has merge conflicts + * with the target branch. + */ + +import { + SIGNAL_FORMAT, + SESSION_STARTUP, + GIT_WORKFLOW, + CONTEXT_MANAGEMENT, +} from './shared.js'; + +export function buildConflictResolutionPrompt( + sourceBranch: string, + targetBranch: string, + conflicts: string[], +): string { + const conflictList = conflicts.map(f => `- \`${f}\``).join('\n'); + + return ` +You are a Conflict Resolution agent. Your job is to merge \`${targetBranch}\` into \`${sourceBranch}\` and resolve all merge conflicts so the initiative branch is up to date with the target branch. + + + +**Source branch (initiative):** \`${sourceBranch}\` +**Target branch (default):** \`${targetBranch}\` + +**Conflicting files:** +${conflictList} + +${SIGNAL_FORMAT} +${SESSION_STARTUP} + + +Follow these steps in order: + +1. **Inspect divergence**: Run \`git log --oneline ${targetBranch}..${sourceBranch}\` and \`git log --oneline ${sourceBranch}..${targetBranch}\` to understand what each side changed. + +2. **Review conflicting files**: For each conflicting file, read both versions: + - \`git show ${sourceBranch}:\` + - \`git show ${targetBranch}:\` + +3. **Merge**: Run \`git merge ${targetBranch} --no-edit\`. This will produce conflict markers. + +4. **Resolve each file**: For each conflicting file: + - Read the file to see conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`) + - Understand both sides' intent from step 1-2 + - Choose the correct resolution — keep both changes when they don't overlap, prefer the more complete version when they do + - If you genuinely cannot determine the correct resolution, signal "questions" explaining the ambiguity + +5. **Verify**: Run \`git diff --check\` to confirm no conflict markers remain. Run the test suite to confirm nothing is broken. + +6. **Commit**: Stage resolved files with \`git add \` (never \`git add .\`), then \`git commit --no-edit\` to complete the merge commit. + +7. **Signal done**: Write signal.json with status "done". + +${GIT_WORKFLOW} +${CONTEXT_MANAGEMENT} + + +- You are merging ${targetBranch} INTO ${sourceBranch} — bringing the initiative branch up to date, NOT the other way around. +- Do NOT force-push or rebase. A merge commit is the correct approach. +- If tests fail after resolution, fix the code — don't skip tests. +- If a conflict is genuinely ambiguous (e.g., both sides rewrote the same function differently), signal "questions" with the specific ambiguity and your proposed resolution. +`; +} + +export function buildConflictResolutionDescription( + sourceBranch: string, + targetBranch: string, + conflicts: string[], +): string { + return `Resolve ${conflicts.length} merge conflict(s) between ${sourceBranch} and ${targetBranch}: ${conflicts.join(', ')}`; +} diff --git a/apps/server/agent/prompts/index.ts b/apps/server/agent/prompts/index.ts index 7722872..2186994 100644 --- a/apps/server/agent/prompts/index.ts +++ b/apps/server/agent/prompts/index.ts @@ -15,3 +15,4 @@ export { buildChatPrompt } from './chat.js'; export type { ChatHistoryEntry } from './chat.js'; export { buildWorkspaceLayout } from './workspace.js'; export { buildPreviewInstructions } from './preview.js'; +export { buildConflictResolutionPrompt, buildConflictResolutionDescription } from './conflict-resolution.js'; diff --git a/apps/server/execution/orchestrator.test.ts b/apps/server/execution/orchestrator.test.ts index 57efcd5..698b9d2 100644 --- a/apps/server/execution/orchestrator.test.ts +++ b/apps/server/execution/orchestrator.test.ts @@ -48,6 +48,7 @@ function createMocks() { diffCommit: vi.fn().mockResolvedValue(''), getMergeBase: vi.fn().mockResolvedValue('abc123'), pushBranch: vi.fn(), + checkMergeability: vi.fn().mockResolvedValue({ mergeable: true }), }; const phaseRepository = { diff --git a/apps/server/git/branch-manager.ts b/apps/server/git/branch-manager.ts index 113ac7b..901413d 100644 --- a/apps/server/git/branch-manager.ts +++ b/apps/server/git/branch-manager.ts @@ -6,7 +6,7 @@ * a worktree to be checked out. */ -import type { MergeResult, BranchCommit } from './types.js'; +import type { MergeResult, MergeabilityResult, BranchCommit } from './types.js'; export interface BranchManager { /** @@ -68,4 +68,11 @@ export interface BranchManager { * Defaults to 'origin' if no remote specified. */ pushBranch(repoPath: string, branch: string, remote?: string): Promise; + + /** + * Dry-run merge check — determines if sourceBranch can be cleanly merged + * into targetBranch without actually performing the merge. + * Uses `git merge-tree --write-tree` (git 2.38+). + */ + checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise; } diff --git a/apps/server/git/index.ts b/apps/server/git/index.ts index 41bd3b0..f7b9351 100644 --- a/apps/server/git/index.ts +++ b/apps/server/git/index.ts @@ -13,7 +13,7 @@ export type { WorktreeManager } from './types.js'; // Domain types -export type { Worktree, WorktreeDiff, MergeResult } from './types.js'; +export type { Worktree, WorktreeDiff, MergeResult, MergeabilityResult } from './types.js'; // Adapters export { SimpleGitWorktreeManager } from './manager.js'; diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index a7678e7..66c5754 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -11,7 +11,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { simpleGit } from 'simple-git'; import type { BranchManager } from './branch-manager.js'; -import type { MergeResult, BranchCommit } from './types.js'; +import type { MergeResult, MergeabilityResult, BranchCommit } from './types.js'; import { createModuleLogger } from '../logger/index.js'; const log = createModuleLogger('branch-manager'); @@ -164,4 +164,41 @@ export class SimpleGitBranchManager implements BranchManager { await git.push(remote, branch); log.info({ repoPath, branch, remote }, 'branch pushed to remote'); } + + async checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise { + const git = simpleGit(repoPath); + + try { + // git merge-tree --write-tree merges source INTO target virtually. + // Exit 0 = clean merge, non-zero = conflicts. + await git.raw(['merge-tree', '--write-tree', targetBranch, sourceBranch]); + log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: clean'); + return { mergeable: true }; + } catch (err) { + const stderr = err instanceof Error ? err.message : String(err); + + // Parse conflict file names from "CONFLICT (content): Merge conflict in " + const conflictPattern = /CONFLICT \([^)]+\): (?:Merge conflict in|.* -> )(.+)/g; + const conflicts: string[] = []; + let match: RegExpExecArray | null; + while ((match = conflictPattern.exec(stderr)) !== null) { + conflicts.push(match[1].trim()); + } + + if (conflicts.length > 0) { + log.debug({ repoPath, sourceBranch, targetBranch, conflicts }, 'merge-tree check: conflicts'); + return { mergeable: false, conflicts }; + } + + // If we couldn't parse conflicts but the command failed, it's still a conflict + // (could be add/add, rename conflicts, etc.) + if (stderr.includes('CONFLICT')) { + log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: unparsed conflicts'); + return { mergeable: false, conflicts: ['(unable to parse conflict details)'] }; + } + + // Genuine error (not a conflict) + throw err; + } + } } diff --git a/apps/server/git/types.ts b/apps/server/git/types.ts index 17d56ae..8471b75 100644 --- a/apps/server/git/types.ts +++ b/apps/server/git/types.ts @@ -58,6 +58,19 @@ export interface MergeResult { message: string; } +// ============================================================================= +// Mergeability Check +// ============================================================================= + +/** + * Result of a dry-run merge check. + * No side effects — only tells you whether the merge would succeed. + */ +export interface MergeabilityResult { + mergeable: boolean; + conflicts?: string[]; +} + // ============================================================================= // Branch Commit Info // ============================================================================= diff --git a/apps/server/trpc/routers/agent.ts b/apps/server/trpc/routers/agent.ts index 74fdc50..a0c3660 100644 --- a/apps/server/trpc/routers/agent.ts +++ b/apps/server/trpc/routers/agent.ts @@ -184,6 +184,27 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { return candidates[0] ?? null; }), + getActiveConflictAgent: publicProcedure + .input(z.object({ initiativeId: z.string().min(1) })) + .query(async ({ ctx, input }): Promise => { + const agentManager = requireAgentManager(ctx); + const allAgents = await agentManager.list(); + const candidates = allAgents + .filter( + (a) => + a.mode === 'execute' && + a.initiativeId === input.initiativeId && + a.name?.startsWith('conflict-') && + ['running', 'waiting_for_input', 'idle', 'crashed'].includes(a.status) && + !a.userDismissedAt, + ) + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + return candidates[0] ?? null; + }), + getAgentOutput: publicProcedure .input(agentIdentifierSchema) .query(async ({ ctx, input }): Promise => { diff --git a/apps/server/trpc/routers/initiative.ts b/apps/server/trpc/routers/initiative.ts index 37b77b3..373639c 100644 --- a/apps/server/trpc/routers/initiative.ts +++ b/apps/server/trpc/routers/initiative.ts @@ -7,7 +7,7 @@ import { z } from 'zod'; import type { ProcedureBuilder } from '../trpc.js'; import { requireAgentManager, requireInitiativeRepository, requireProjectRepository, requireTaskRepository, requireBranchManager, requireExecutionOrchestrator } from './_helpers.js'; import { deriveInitiativeActivity } from './initiative-activity.js'; -import { buildRefinePrompt } from '../../agent/prompts/index.js'; +import { buildRefinePrompt, buildConflictResolutionPrompt, buildConflictResolutionDescription } from '../../agent/prompts/index.js'; import type { PageForSerialization } from '../../agent/content-serializer.js'; import { ensureProjectClone } from '../../git/project-clones.js'; @@ -349,5 +349,128 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) { ); return { success: true, taskId: result.taskId }; }), + + checkInitiativeMergeability: publicProcedure + .input(z.object({ initiativeId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const initiativeRepo = requireInitiativeRepository(ctx); + const projectRepo = requireProjectRepository(ctx); + const branchManager = requireBranchManager(ctx); + + const initiative = await initiativeRepo.findById(input.initiativeId); + if (!initiative) { + throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` }); + } + if (!initiative.branch) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' }); + } + + const projects = await projectRepo.findProjectsByInitiativeId(input.initiativeId); + const allConflicts: string[] = []; + let mergeable = true; + + for (const project of projects) { + const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); + const result = await branchManager.checkMergeability(clonePath, initiative.branch, project.defaultBranch); + if (!result.mergeable) { + mergeable = false; + if (result.conflicts) allConflicts.push(...result.conflicts); + } + } + + return { + mergeable, + conflictFiles: allConflicts, + targetBranch: projects[0]?.defaultBranch ?? 'main', + }; + }), + + spawnConflictResolutionAgent: publicProcedure + .input(z.object({ + initiativeId: z.string().min(1), + provider: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + const agentManager = requireAgentManager(ctx); + const initiativeRepo = requireInitiativeRepository(ctx); + const projectRepo = requireProjectRepository(ctx); + const taskRepo = requireTaskRepository(ctx); + const branchManager = requireBranchManager(ctx); + + const initiative = await initiativeRepo.findById(input.initiativeId); + if (!initiative) { + throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` }); + } + if (!initiative.branch) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' }); + } + + const projects = await projectRepo.findProjectsByInitiativeId(input.initiativeId); + if (projects.length === 0) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no linked projects' }); + } + + // Auto-dismiss stale conflict agents + const allAgents = await agentManager.list(); + const staleAgents = allAgents.filter( + (a) => + a.mode === 'execute' && + a.initiativeId === input.initiativeId && + a.name?.startsWith('conflict-') && + ['crashed', 'idle'].includes(a.status) && + !a.userDismissedAt, + ); + for (const stale of staleAgents) { + await agentManager.dismiss(stale.id); + } + + // Reject if active conflict agent already running + const activeConflictAgents = allAgents.filter( + (a) => + a.mode === 'execute' && + a.initiativeId === input.initiativeId && + a.name?.startsWith('conflict-') && + ['running', 'waiting_for_input'].includes(a.status), + ); + if (activeConflictAgents.length > 0) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'A conflict resolution agent is already running for this initiative', + }); + } + + // Re-check mergeability to get current conflict list + const project = projects[0]; + const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); + const mergeCheck = await branchManager.checkMergeability(clonePath, initiative.branch, project.defaultBranch); + if (mergeCheck.mergeable) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'No merge conflicts detected — merge is clean' }); + } + + const conflicts = mergeCheck.conflicts ?? []; + const targetBranch = project.defaultBranch; + + // Create task + const task = await taskRepo.create({ + initiativeId: input.initiativeId, + name: `Resolve conflicts: ${initiative.name}`, + description: buildConflictResolutionDescription(initiative.branch, targetBranch, conflicts), + category: 'merge', + status: 'in_progress', + }); + + // Spawn agent + const prompt = buildConflictResolutionPrompt(initiative.branch, targetBranch, conflicts); + return agentManager.spawn({ + name: `conflict-${Date.now()}`, + taskId: task.id, + prompt, + mode: 'execute', + provider: input.provider, + initiativeId: input.initiativeId, + baseBranch: targetBranch, + branchName: initiative.branch, + }); + }), }; } diff --git a/apps/web/src/components/review/ConflictResolutionPanel.tsx b/apps/web/src/components/review/ConflictResolutionPanel.tsx new file mode 100644 index 0000000..f4d99fd --- /dev/null +++ b/apps/web/src/components/review/ConflictResolutionPanel.tsx @@ -0,0 +1,182 @@ +import { Loader2, AlertCircle, GitMerge, CheckCircle2, ChevronDown, ChevronRight, Terminal } from 'lucide-react'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { QuestionForm } from '@/components/QuestionForm'; +import { useConflictAgent } from '@/hooks/useConflictAgent'; + +interface ConflictResolutionPanelProps { + initiativeId: string; + conflicts: string[]; + onResolved: () => void; +} + +export function ConflictResolutionPanel({ initiativeId, conflicts, onResolved }: ConflictResolutionPanelProps) { + const { state, agent, questions, spawn, resume, stop, dismiss } = useConflictAgent(initiativeId); + const [showManual, setShowManual] = useState(false); + + if (state === 'none') { + return ( +
+
+ +
+

+ {conflicts.length} merge conflict{conflicts.length !== 1 ? 's' : ''} detected +

+
    + {conflicts.map((file) => ( +
  • {file}
  • + ))} +
+
+ + +
+ {spawn.error && ( +

{spawn.error.message}

+ )} + {showManual && ( +
+

+ In your project clone, run: +

+
+{`git checkout 
+git merge 
+# Resolve conflicts in each file
+git add 
+git commit --no-edit`}
+                
+
+ )} +
+
+
+ ); + } + + if (state === 'running') { + return ( +
+
+
+ + Resolving merge conflicts... +
+ +
+
+ ); + } + + if (state === 'waiting' && questions) { + return ( +
+
+ +

Agent needs input

+
+ resume.mutate(answers)} + onCancel={() => {}} + onDismiss={() => stop.mutate()} + isSubmitting={resume.isPending} + isDismissing={stop.isPending} + /> +
+ ); + } + + if (state === 'completed') { + return ( +
+
+
+ + Conflicts resolved +
+
+ +
+
+
+ ); + } + + if (state === 'crashed') { + return ( +
+
+
+ + Conflict resolution agent crashed +
+
+ + +
+
+
+ ); + } + + return null; +} diff --git a/apps/web/src/components/review/InitiativeReview.tsx b/apps/web/src/components/review/InitiativeReview.tsx index a23a83a..1f28963 100644 --- a/apps/web/src/components/review/InitiativeReview.tsx +++ b/apps/web/src/components/review/InitiativeReview.tsx @@ -1,12 +1,14 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge } from "lucide-react"; +import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge, AlertTriangle, CheckCircle2 } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { trpc } from "@/lib/trpc"; import { parseUnifiedDiff } from "./parse-diff"; import { DiffViewer } from "./DiffViewer"; import { ReviewSidebar } from "./ReviewSidebar"; import { PreviewControls } from "./PreviewControls"; +import { ConflictResolutionPanel } from "./ConflictResolutionPanel"; interface InitiativeReviewProps { initiativeId: string; @@ -49,6 +51,26 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview { enabled: !!selectedCommit }, ); + // Mergeability check + const mergeabilityQuery = trpc.checkInitiativeMergeability.useQuery( + { initiativeId }, + { refetchInterval: 30_000 }, + ); + const mergeability = mergeabilityQuery.data ?? null; + + // Auto-refresh mergeability when a conflict agent completes + const conflictAgentQuery = trpc.getActiveConflictAgent.useQuery({ initiativeId }); + const conflictAgentStatus = conflictAgentQuery.data?.status; + const prevConflictStatusRef = useRef(conflictAgentStatus); + useEffect(() => { + const prev = prevConflictStatusRef.current; + prevConflictStatusRef.current = conflictAgentStatus; + // When agent transitions from running/waiting to idle (completed) + if (prev && ['running', 'waiting_for_input'].includes(prev) && conflictAgentStatus === 'idle') { + void mergeabilityQuery.refetch(); + } + }, [conflictAgentStatus, mergeabilityQuery]); + // Preview state const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId }); const firstProjectId = projectsQuery.data?.[0]?.id ?? null; @@ -184,6 +206,24 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview {totalDeletions}
+ + {/* Mergeability badge */} + {mergeabilityQuery.isLoading ? ( + + + Checking... + + ) : mergeability?.mergeable ? ( + + + Clean merge + + ) : mergeability && !mergeability.mergeable ? ( + + + {mergeability.conflictFiles.length} conflict{mergeability.conflictFiles.length !== 1 ? 's' : ''} + + ) : null}
{/* Right: preview + action buttons */} @@ -206,7 +246,8 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
+ {/* Conflict resolution panel */} + {mergeability && !mergeability.mergeable && ( + mergeabilityQuery.refetch()} + /> + )} + {/* Main content */}
diff --git a/apps/web/src/hooks/index.ts b/apps/web/src/hooks/index.ts index 8d0cbdd..0211b7a 100644 --- a/apps/web/src/hooks/index.ts +++ b/apps/web/src/hooks/index.ts @@ -9,10 +9,16 @@ export { useAutoSave } from './useAutoSave.js'; export { useDebounce, useDebounceWithImmediate } from './useDebounce.js'; export { useLiveUpdates } from './useLiveUpdates.js'; export { useRefineAgent } from './useRefineAgent.js'; +export { useConflictAgent } from './useConflictAgent.js'; export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js'; export type { RefineAgentState, SpawnRefineAgentOptions, UseRefineAgentResult, -} from './useRefineAgent.js'; \ No newline at end of file +} from './useRefineAgent.js'; + +export type { + ConflictAgentState, + UseConflictAgentResult, +} from './useConflictAgent.js'; \ No newline at end of file diff --git a/apps/web/src/hooks/useConflictAgent.ts b/apps/web/src/hooks/useConflictAgent.ts new file mode 100644 index 0000000..3594fdf --- /dev/null +++ b/apps/web/src/hooks/useConflictAgent.ts @@ -0,0 +1,214 @@ +import { useCallback, useMemo, useRef } from 'react'; +import { trpc } from '@/lib/trpc'; +import type { PendingQuestions } from '@codewalk-district/shared'; + +export type ConflictAgentState = 'none' | 'running' | 'waiting' | 'completed' | 'crashed'; + +type ConflictAgent = NonNullable['data']>; + +export interface UseConflictAgentResult { + agent: ConflictAgent | null; + state: ConflictAgentState; + questions: PendingQuestions | null; + spawn: { + mutate: (options: { initiativeId: string; provider?: string }) => void; + isPending: boolean; + error: Error | null; + }; + resume: { + mutate: (answers: Record) => void; + isPending: boolean; + error: Error | null; + }; + stop: { + mutate: () => void; + isPending: boolean; + }; + dismiss: () => void; + isLoading: boolean; + refresh: () => void; +} + +export function useConflictAgent(initiativeId: string): UseConflictAgentResult { + const utils = trpc.useUtils(); + + const agentQuery = trpc.getActiveConflictAgent.useQuery({ initiativeId }); + const agent = agentQuery.data ?? null; + + const state: ConflictAgentState = useMemo(() => { + if (!agent) return 'none'; + switch (agent.status) { + case 'running': + return 'running'; + case 'waiting_for_input': + return 'waiting'; + case 'idle': + return 'completed'; + case 'crashed': + return 'crashed'; + default: + return 'none'; + } + }, [agent]); + + const questionsQuery = trpc.getAgentQuestions.useQuery( + { id: agent?.id ?? '' }, + { enabled: state === 'waiting' && !!agent }, + ); + + const spawnMutation = trpc.spawnConflictResolutionAgent.useMutation({ + onMutate: async () => { + await utils.listAgents.cancel(); + await utils.getActiveConflictAgent.cancel({ initiativeId }); + + const previousAgents = utils.listAgents.getData(); + const previousConflictAgent = utils.getActiveConflictAgent.getData({ initiativeId }); + + const tempAgent = { + id: `temp-${Date.now()}`, + name: `conflict-${Date.now()}`, + mode: 'execute' as const, + status: 'running' as const, + initiativeId, + taskId: null, + phaseId: null, + provider: 'claude', + accountId: null, + instruction: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + userDismissedAt: null, + completedAt: null, + }; + + utils.listAgents.setData(undefined, (old = []) => [tempAgent, ...old]); + utils.getActiveConflictAgent.setData({ initiativeId }, tempAgent as any); + + return { previousAgents, previousConflictAgent }; + }, + onError: (_err, _variables, context) => { + if (context?.previousAgents) { + utils.listAgents.setData(undefined, context.previousAgents); + } + if (context?.previousConflictAgent !== undefined) { + utils.getActiveConflictAgent.setData({ initiativeId }, context.previousConflictAgent); + } + }, + onSettled: () => { + void utils.listAgents.invalidate(); + void utils.getActiveConflictAgent.invalidate({ initiativeId }); + }, + }); + + const resumeMutation = trpc.resumeAgent.useMutation({ + onSuccess: () => { + void utils.listAgents.invalidate(); + }, + }); + + const stopMutation = trpc.stopAgent.useMutation({ + onSuccess: () => { + void utils.listAgents.invalidate(); + void utils.listWaitingAgents.invalidate(); + }, + }); + + const dismissMutation = trpc.dismissAgent.useMutation({ + onMutate: async ({ id }) => { + await utils.listAgents.cancel(); + await utils.getActiveConflictAgent.cancel({ initiativeId }); + + const previousAgents = utils.listAgents.getData(); + const previousConflictAgent = utils.getActiveConflictAgent.getData({ initiativeId }); + + utils.listAgents.setData(undefined, (old = []) => old.filter(a => a.id !== id)); + utils.getActiveConflictAgent.setData({ initiativeId }, null); + + return { previousAgents, previousConflictAgent }; + }, + onError: (_err, _variables, context) => { + if (context?.previousAgents) { + utils.listAgents.setData(undefined, context.previousAgents); + } + if (context?.previousConflictAgent !== undefined) { + utils.getActiveConflictAgent.setData({ initiativeId }, context.previousConflictAgent); + } + }, + onSettled: () => { + void utils.listAgents.invalidate(); + void utils.getActiveConflictAgent.invalidate({ initiativeId }); + }, + }); + + const spawnMutateRef = useRef(spawnMutation.mutate); + spawnMutateRef.current = spawnMutation.mutate; + const agentRef = useRef(agent); + agentRef.current = agent; + const resumeMutateRef = useRef(resumeMutation.mutate); + resumeMutateRef.current = resumeMutation.mutate; + const stopMutateRef = useRef(stopMutation.mutate); + stopMutateRef.current = stopMutation.mutate; + const dismissMutateRef = useRef(dismissMutation.mutate); + dismissMutateRef.current = dismissMutation.mutate; + + const spawnFn = useCallback(({ initiativeId, provider }: { initiativeId: string; provider?: string }) => { + spawnMutateRef.current({ initiativeId, provider }); + }, []); + + const spawn = useMemo(() => ({ + mutate: spawnFn, + isPending: spawnMutation.isPending, + error: spawnMutation.error, + }), [spawnFn, spawnMutation.isPending, spawnMutation.error]); + + const resumeFn = useCallback((answers: Record) => { + const a = agentRef.current; + if (a) { + resumeMutateRef.current({ id: a.id, answers }); + } + }, []); + + const resume = useMemo(() => ({ + mutate: resumeFn, + isPending: resumeMutation.isPending, + error: resumeMutation.error, + }), [resumeFn, resumeMutation.isPending, resumeMutation.error]); + + const stopFn = useCallback(() => { + const a = agentRef.current; + if (a) { + stopMutateRef.current({ id: a.id }); + } + }, []); + + const stop = useMemo(() => ({ + mutate: stopFn, + isPending: stopMutation.isPending, + }), [stopFn, stopMutation.isPending]); + + const dismiss = useCallback(() => { + const a = agentRef.current; + if (a) { + dismissMutateRef.current({ id: a.id }); + } + }, []); + + const refresh = useCallback(() => { + void utils.getActiveConflictAgent.invalidate({ initiativeId }); + }, [utils, initiativeId]); + + const isLoading = agentQuery.isLoading || + (state === 'waiting' && questionsQuery.isLoading); + + return { + agent, + state, + questions: questionsQuery.data ?? null, + spawn, + resume, + stop, + dismiss, + isLoading, + refresh, + }; +} diff --git a/docs/agent.md b/docs/agent.md index 5529f63..7083585 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -24,7 +24,7 @@ | `accounts/` | Account discovery, config dir setup, credential management, usage API | | `credentials/` | `AccountCredentialManager` — credential injection per account | | `lifecycle/` | `LifecycleController` — retry policy, signal recovery, missing signal instructions | -| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions | +| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat, conflict-resolution) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions | ## Key Flows diff --git a/docs/frontend.md b/docs/frontend.md index cd95313..6488640 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -116,6 +116,7 @@ The initiative detail page has three tabs managed via local state (not URL param | `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, root-only comment counts, and commit navigation | | `DiffViewer` | Unified diff renderer with threaded inline comments (root + reply threads) | | `CommentThread` | Renders root comment with resolve/reopen + nested reply threads (agent replies styled with primary border). Inline reply form | +| `ConflictResolutionPanel` | Merge conflict detection + agent resolution in initiative review. Shows conflict files, spawns conflict agent, inline questions, re-check on completion | | `PreviewPanel` | Docker preview status: building/running/failed with start/stop (legacy, now integrated into ReviewHeader) | | `ProposalCard` | Individual proposal display | @@ -127,6 +128,7 @@ shadcn/ui components: badge (6 status variants + xs size), button, card, dialog, | Hook | Purpose | |------|---------| | `useRefineAgent` | Manages refine agent lifecycle for initiative | +| `useConflictAgent` | Manages conflict resolution agent lifecycle for initiative review | | `useDetailAgent` | Manages detail agent for phase planning | | `useAgentOutput` | Subscribes to live agent output stream | | `useChatSession` | Manages chat session for phase/task refinement | diff --git a/docs/git-process-logging.md b/docs/git-process-logging.md index b35efa2..2e5d8c4 100644 --- a/docs/git-process-logging.md +++ b/docs/git-process-logging.md @@ -46,6 +46,8 @@ Worktrees stored in `.cw-worktrees/` subdirectory of the repo. Each agent gets a | `listCommits(repoPath, base, head)` | List commits head has that base doesn't (with stats) | | `diffCommit(repoPath, commitHash)` | Get unified diff for a single commit | | `getMergeBase(repoPath, branch1, branch2)` | Get common ancestor commit hash | +| `pushBranch(repoPath, branch, remote?)` | Push branch to remote (default: 'origin') | +| `checkMergeability(repoPath, source, target)` | Dry-run merge check via `git merge-tree --write-tree` (git 2.38+). Returns `{ mergeable, conflicts? }` with no side effects | `remoteBranchExists` is used by `registerProject` and `updateProject` to validate that a project's default branch actually exists in the cloned repository before saving. diff --git a/docs/server-api.md b/docs/server-api.md index 48c62bd..ec11000 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -64,6 +64,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | getAgentQuestions | query | Pending questions | | getAgentOutput | query | Full output from DB log chunks | | getActiveRefineAgent | query | Active refine agent for initiative | +| getActiveConflictAgent | query | Active conflict resolution agent for initiative (name starts with `conflict-`) | | listWaitingAgents | query | Agents waiting for input | | onAgentOutput | subscription | Live raw JSONL output stream via EventBus | @@ -96,6 +97,8 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | getInitiativeCommitDiff | query | Single commit diff for initiative review | | approveInitiativeReview | mutation | Approve initiative review: `{initiativeId, strategy: 'push_branch' \| 'merge_and_push'}` | | requestInitiativeChanges | mutation | Request changes on initiative: `{initiativeId, summary}` → creates review task in Finalization phase, resets initiative to active | +| checkInitiativeMergeability | query | Dry-run merge check: `{initiativeId}` → `{mergeable, conflictFiles[], targetBranch}` | +| spawnConflictResolutionAgent | mutation | Spawn agent to resolve merge conflicts: `{initiativeId, provider?}` → auto-dismisses stale conflict agents, creates merge task | ### Phases | Procedure | Type | Description | From 01f22797359142cec6837a0988fc3a55036153c4 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:17:29 +0100 Subject: [PATCH 42/54] fix: Eliminate whitespace above sticky review header Moved card border/rounding onto the sticky header wrapper itself so it scrolls flush to top-0 with no gap. The body grid gets its own border-x and border-b to preserve the card appearance. ResizeObserver now measures border-box size for accurate sidebar offset. --- apps/web/src/components/review/ReviewTab.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index a532253..09bdd7e 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -25,9 +25,9 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { const el = headerRef.current; if (!el) return; const ro = new ResizeObserver(([entry]) => { - setHeaderHeight(entry.contentRect.height); + setHeaderHeight(entry.borderBoxSize?.[0]?.blockSize ?? entry.target.getBoundingClientRect().height); }); - ro.observe(el); + ro.observe(el, { box: 'border-box' }); return () => ro.disconnect(); }, []); @@ -323,9 +323,9 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { } return ( -
+
{/* Header: phase selector + toolbar */} -
+
({ id: p.id, name: p.name, status: p.status }))} activePhaseId={activePhaseId} @@ -347,7 +347,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
{/* Main content area — sidebar always rendered to preserve state */} -
+
{/* Left: Sidebar — sticky to viewport, scrolls independently */}
Date: Fri, 6 Mar 2026 11:20:05 +0100 Subject: [PATCH 43/54] migration: Drop orphaned approval columns from initiatives and tasks merge_requires_approval (initiatives) and requires_approval (tasks) were removed from schema.ts in the task-approval removal but left in the DB because 0030 assumed SQLite couldn't DROP COLUMN. SQLite 3.35+ supports it. These orphaned columns caused the old stale-build approval code path to silently set detail tasks to pending_approval, stranding them and blocking phase completion. --- apps/server/drizzle/0033_drop_approval_columns.sql | 5 +++++ apps/server/drizzle/meta/_journal.json | 7 +++++++ 2 files changed, 12 insertions(+) create mode 100644 apps/server/drizzle/0033_drop_approval_columns.sql diff --git a/apps/server/drizzle/0033_drop_approval_columns.sql b/apps/server/drizzle/0033_drop_approval_columns.sql new file mode 100644 index 0000000..ea73e34 --- /dev/null +++ b/apps/server/drizzle/0033_drop_approval_columns.sql @@ -0,0 +1,5 @@ +-- Drop orphaned approval columns left behind by 0030_remove_task_approval. +-- These columns were removed from schema.ts but left in the DB because +-- 0030 assumed SQLite couldn't DROP COLUMN. SQLite 3.35+ supports it. +ALTER TABLE initiatives DROP COLUMN merge_requires_approval;--> statement-breakpoint +ALTER TABLE tasks DROP COLUMN requires_approval; diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 96636a8..2c92726 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -232,6 +232,13 @@ "when": 1772323200000, "tag": "0032_add_comment_threading", "breakpoints": true + }, + { + "idx": 33, + "version": "6", + "when": 1772409600000, + "tag": "0033_drop_approval_columns", + "breakpoints": true } ] } \ No newline at end of file From 19cd0a2cb04edb4ae0c8381d6b6b1e88f5edc0df Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:21:08 +0100 Subject: [PATCH 44/54] fix: Cover transparent gap above sticky review header Use negative margin to pull sticky header into the parent space-y-3 gap, with matching padding and bg-background to paint over it when stuck. --- apps/web/src/components/review/ReviewTab.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 09bdd7e..361623a 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -325,7 +325,8 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { return (
{/* Header: phase selector + toolbar */} -
+
+
({ id: p.id, name: p.name, status: p.status }))} activePhaseId={activePhaseId} @@ -345,6 +346,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { totalCount={allFiles.length} />
+
{/* Main content area — sidebar always rendered to preserve state */}
From 4664644cdac121af2dbadcd28351e2beb77f4cf3 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:22:30 +0100 Subject: [PATCH 45/54] fix: Use pseudo-element to cover all transparent space above sticky header Replaces negative margin hack with a ::before that extends upward from the sticky header to paint bg-background over the main padding gap. --- apps/web/src/components/review/ReviewTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 361623a..b44a1ef 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -325,7 +325,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { return (
{/* Header: phase selector + toolbar */} -
+
({ id: p.id, name: p.name, status: p.status }))} From 5b497b84a04ece7d4e9386f739157a8feecc8940 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:26:43 +0100 Subject: [PATCH 46/54] fix: Restore sticky header and sidebar by simplifying layout Removed wrapper divs that broke sticky positioning. ReviewHeader now accepts a ref prop directly, with sticky top-0 on its own root element. Card wrapper restored as the tall containing block so both header and sidebar have room to stick within it. --- apps/web/src/components/review/ReviewHeader.tsx | 4 +++- apps/web/src/components/review/ReviewTab.tsx | 9 +++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/review/ReviewHeader.tsx b/apps/web/src/components/review/ReviewHeader.tsx index d0b19d3..44e55fc 100644 --- a/apps/web/src/components/review/ReviewHeader.tsx +++ b/apps/web/src/components/review/ReviewHeader.tsx @@ -25,6 +25,7 @@ interface PhaseOption { } interface ReviewHeaderProps { + ref?: React.Ref; phases: PhaseOption[]; activePhaseId: string | null; isReadOnly?: boolean; @@ -44,6 +45,7 @@ interface ReviewHeaderProps { } export function ReviewHeader({ + ref, phases, activePhaseId, isReadOnly, @@ -95,7 +97,7 @@ export function ReviewHeader({ const total = totalCount ?? 0; return ( -
+
{/* Phase selector row */} {phases.length > 1 && (
diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index b44a1ef..4c4fa13 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -323,11 +323,10 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { } return ( -
+
{/* Header: phase selector + toolbar */} -
-
({ id: p.id, name: p.name, status: p.status }))} activePhaseId={activePhaseId} isReadOnly={isActivePhaseCompleted} @@ -345,11 +344,9 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { viewedCount={viewedFiles.size} totalCount={allFiles.length} /> -
-
{/* Main content area — sidebar always rendered to preserve state */} -
+
{/* Left: Sidebar — sticky to viewport, scrolls independently */}
Date: Fri, 6 Mar 2026 11:27:32 +0100 Subject: [PATCH 47/54] fix: Parse merge-tree output from stdout instead of catch block simple-git's .raw() resolves successfully even on exit code 1, returning stdout content. git merge-tree --write-tree outputs CONFLICT markers to stdout (not stderr), so the catch block never fired and conflicts were reported as clean merges. --- apps/server/git/simple-git-branch-manager.ts | 54 +++++++++----------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index 66c5754..fdca4b9 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -168,37 +168,31 @@ export class SimpleGitBranchManager implements BranchManager { async checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise { const git = simpleGit(repoPath); - try { - // git merge-tree --write-tree merges source INTO target virtually. - // Exit 0 = clean merge, non-zero = conflicts. - await git.raw(['merge-tree', '--write-tree', targetBranch, sourceBranch]); - log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: clean'); - return { mergeable: true }; - } catch (err) { - const stderr = err instanceof Error ? err.message : String(err); + // git merge-tree --write-tree outputs everything to stdout. + // simple-git's .raw() resolves with stdout even on exit code 1 (conflicts), + // so we parse the output text instead of relying on catch. + const output = await git.raw(['merge-tree', '--write-tree', targetBranch, sourceBranch]); - // Parse conflict file names from "CONFLICT (content): Merge conflict in " - const conflictPattern = /CONFLICT \([^)]+\): (?:Merge conflict in|.* -> )(.+)/g; - const conflicts: string[] = []; - let match: RegExpExecArray | null; - while ((match = conflictPattern.exec(stderr)) !== null) { - conflicts.push(match[1].trim()); - } - - if (conflicts.length > 0) { - log.debug({ repoPath, sourceBranch, targetBranch, conflicts }, 'merge-tree check: conflicts'); - return { mergeable: false, conflicts }; - } - - // If we couldn't parse conflicts but the command failed, it's still a conflict - // (could be add/add, rename conflicts, etc.) - if (stderr.includes('CONFLICT')) { - log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: unparsed conflicts'); - return { mergeable: false, conflicts: ['(unable to parse conflict details)'] }; - } - - // Genuine error (not a conflict) - throw err; + // Parse conflict file names from "CONFLICT (content): Merge conflict in " + const conflictPattern = /CONFLICT \([^)]+\): (?:Merge conflict in|.* -> )(.+)/g; + const conflicts: string[] = []; + let match: RegExpExecArray | null; + while ((match = conflictPattern.exec(output)) !== null) { + conflicts.push(match[1].trim()); } + + if (conflicts.length > 0) { + log.debug({ repoPath, sourceBranch, targetBranch, conflicts }, 'merge-tree check: conflicts'); + return { mergeable: false, conflicts }; + } + + // Fallback: check for any CONFLICT text we couldn't parse specifically + if (output.includes('CONFLICT')) { + log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: unparsed conflicts'); + return { mergeable: false, conflicts: ['(unable to parse conflict details)'] }; + } + + log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: clean'); + return { mergeable: true }; } } From c87aac44cce89cd772d766226c9fe26e3c34727f Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:33:31 +0100 Subject: [PATCH 48/54] fix: Cover transparent gap above sticky header with upward box-shadow Uses a 50px upward box-shadow in bg-background color to paint over the main padding gap that shows above the stuck review header. --- apps/web/src/components/review/ReviewHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/review/ReviewHeader.tsx b/apps/web/src/components/review/ReviewHeader.tsx index 44e55fc..a8225eb 100644 --- a/apps/web/src/components/review/ReviewHeader.tsx +++ b/apps/web/src/components/review/ReviewHeader.tsx @@ -97,7 +97,7 @@ export function ReviewHeader({ const total = totalCount ?? 0; return ( -
+
{/* Phase selector row */} {phases.length > 1 && (
From f428ec027e57c1800427e41538f539669e118cda Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:36:28 +0100 Subject: [PATCH 49/54] fix: Sticky file headers sit below review header using CSS variable Sets --review-header-h on the card wrapper from measured header height. FileCard reads it for sticky top offset so file headers dock just below the review header instead of overlapping it. --- apps/web/src/components/review/FileCard.tsx | 3 ++- apps/web/src/components/review/ReviewTab.tsx | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/review/FileCard.tsx b/apps/web/src/components/review/FileCard.tsx index 5606de4..16100db 100644 --- a/apps/web/src/components/review/FileCard.tsx +++ b/apps/web/src/components/review/FileCard.tsx @@ -82,7 +82,8 @@ export function FileCard({
{/* File header — sticky so it stays visible when scrolling */}
@@ -45,20 +47,30 @@ function RootComment({ onResolve, onUnresolve, onReply, + onEdit, }: { comment: ReviewComment; replies: ReviewComment[]; onResolve: (id: string) => void; onUnresolve: (id: string) => void; onReply?: (parentCommentId: string, body: string) => void; + onEdit?: (commentId: string, body: string) => void; }) { const [isReplying, setIsReplying] = useState(false); + const [editingId, setEditingId] = useState(null); const replyRef = useRef(null); + const editRef = useRef(null); useEffect(() => { if (isReplying) replyRef.current?.focus(); }, [isReplying]); + useEffect(() => { + if (editingId) editRef.current?.focus(); + }, [editingId]); + + const isEditingRoot = editingId === comment.id; + return (
{/* Root comment */} @@ -75,6 +87,17 @@ function RootComment({ )}
+ {onEdit && comment.author !== "agent" && !comment.resolved && ( + + )} {onReply && !comment.resolved && (
-

{comment.body}

+ {isEditingRoot ? ( + { + onEdit!(comment.id, body); + setEditingId(null); + }} + onCancel={() => setEditingId(null)} + placeholder="Edit comment..." + submitLabel="Save" + /> + ) : ( +

{comment.body}

+ )}
{/* Replies */} @@ -114,13 +151,40 @@ function RootComment({ : "border-l-muted-foreground/30" }`} > -
- - {reply.author} - - {formatTime(reply.createdAt)} +
+
+ + {reply.author} + + {formatTime(reply.createdAt)} +
+ {onEdit && reply.author !== "agent" && !comment.resolved && editingId !== reply.id && ( + + )}
-

{reply.body}

+ {editingId === reply.id ? ( + { + onEdit!(reply.id, body); + setEditingId(null); + }} + onCancel={() => setEditingId(null)} + placeholder="Edit reply..." + submitLabel="Save" + /> + ) : ( +

{reply.body}

+ )}
))}
diff --git a/apps/web/src/components/review/DiffViewer.tsx b/apps/web/src/components/review/DiffViewer.tsx index 6fac668..5b9c1e2 100644 --- a/apps/web/src/components/review/DiffViewer.tsx +++ b/apps/web/src/components/review/DiffViewer.tsx @@ -13,6 +13,7 @@ interface DiffViewerProps { onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; onReplyComment?: (parentCommentId: string, body: string) => void; + onEditComment?: (commentId: string, body: string) => void; viewedFiles?: Set; onToggleViewed?: (filePath: string) => void; onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void; @@ -25,6 +26,7 @@ export function DiffViewer({ onResolveComment, onUnresolveComment, onReplyComment, + onEditComment, viewedFiles, onToggleViewed, onRegisterRef, @@ -40,6 +42,7 @@ export function DiffViewer({ onResolveComment={onResolveComment} onUnresolveComment={onUnresolveComment} onReplyComment={onReplyComment} + onEditComment={onEditComment} isViewed={viewedFiles?.has(file.newPath) ?? false} onToggleViewed={() => onToggleViewed?.(file.newPath)} /> diff --git a/apps/web/src/components/review/FileCard.tsx b/apps/web/src/components/review/FileCard.tsx index 87b2b5c..d7056c8 100644 --- a/apps/web/src/components/review/FileCard.tsx +++ b/apps/web/src/components/review/FileCard.tsx @@ -53,6 +53,7 @@ interface FileCardProps { onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; onReplyComment?: (parentCommentId: string, body: string) => void; + onEditComment?: (commentId: string, body: string) => void; isViewed?: boolean; onToggleViewed?: () => void; } @@ -64,6 +65,7 @@ export function FileCard({ onResolveComment, onUnresolveComment, onReplyComment, + onEditComment, isViewed = false, onToggleViewed = () => {}, }: FileCardProps) { @@ -161,6 +163,7 @@ export function FileCard({ onResolveComment={onResolveComment} onUnresolveComment={onUnresolveComment} onReplyComment={onReplyComment} + onEditComment={onEditComment} tokenMap={tokenMap} /> ))} diff --git a/apps/web/src/components/review/HunkRows.tsx b/apps/web/src/components/review/HunkRows.tsx index eaeeb5a..86cf6dd 100644 --- a/apps/web/src/components/review/HunkRows.tsx +++ b/apps/web/src/components/review/HunkRows.tsx @@ -16,6 +16,7 @@ interface HunkRowsProps { onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; onReplyComment?: (parentCommentId: string, body: string) => void; + onEditComment?: (commentId: string, body: string) => void; tokenMap?: LineTokenMap | null; } @@ -27,6 +28,7 @@ export function HunkRows({ onResolveComment, onUnresolveComment, onReplyComment, + onEditComment, tokenMap, }: HunkRowsProps) { const [commentingLine, setCommentingLine] = useState<{ @@ -101,6 +103,7 @@ export function HunkRows({ onResolveComment={onResolveComment} onUnresolveComment={onUnresolveComment} onReplyComment={onReplyComment} + onEditComment={onEditComment} tokens={ line.newLineNumber !== null ? tokenMap?.get(line.newLineNumber) ?? undefined diff --git a/apps/web/src/components/review/LineWithComments.tsx b/apps/web/src/components/review/LineWithComments.tsx index 579db1b..c5b8d12 100644 --- a/apps/web/src/components/review/LineWithComments.tsx +++ b/apps/web/src/components/review/LineWithComments.tsx @@ -16,6 +16,7 @@ interface LineWithCommentsProps { onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; onReplyComment?: (parentCommentId: string, body: string) => void; + onEditComment?: (commentId: string, body: string) => void; /** Syntax-highlighted tokens for this line (if available) */ tokens?: TokenizedLine; } @@ -31,6 +32,7 @@ export function LineWithComments({ onResolveComment, onUnresolveComment, onReplyComment, + onEditComment, tokens, }: LineWithCommentsProps) { const formRef = useRef(null); @@ -144,6 +146,7 @@ export function LineWithComments({ onResolve={onResolveComment} onUnresolve={onUnresolveComment} onReply={onReplyComment} + onEdit={onEditComment} /> diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 0ce2079..099f380 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -195,6 +195,13 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { onError: (err) => toast.error(`Failed to post reply: ${err.message}`), }); + const editCommentMutation = trpc.updateReviewComment.useMutation({ + onSuccess: () => { + utils.listReviewComments.invalidate({ phaseId: activePhaseId! }); + }, + onError: (err) => toast.error(`Failed to update comment: ${err.message}`), + }); + const approveMutation = trpc.approvePhaseReview.useMutation({ onSuccess: () => { setStatus("approved"); @@ -245,6 +252,10 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { replyToCommentMutation.mutate({ parentCommentId, body }); }, [replyToCommentMutation]); + const handleEditComment = useCallback((commentId: string, body: string) => { + editCommentMutation.mutate({ id: commentId, body }); + }, [editCommentMutation]); + const handleApprove = useCallback(() => { if (!activePhaseId) return; approveMutation.mutate({ phaseId: activePhaseId }); @@ -394,6 +405,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { onResolveComment={handleResolveComment} onUnresolveComment={handleUnresolveComment} onReplyComment={handleReplyComment} + onEditComment={handleEditComment} viewedFiles={viewedFiles} onToggleViewed={toggleViewed} onRegisterRef={registerFileRef} From 2814c2d3b2ec6bd8fced7dcdb041223884a458fb Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:59:16 +0100 Subject: [PATCH 53/54] fix: Fetch remote before merge/push in initiative approval approveInitiative was merging and pushing with a stale local defaultBranch, causing "rejected (fetch first)" when origin/main had advanced since the last project sync. Now fetches remote and fast-forwards the target branch before merging. --- apps/server/execution/orchestrator.test.ts | 2 ++ apps/server/execution/orchestrator.ts | 9 +++++++++ apps/server/git/branch-manager.ts | 13 +++++++++++++ apps/server/git/simple-git-branch-manager.ts | 13 +++++++++++++ 4 files changed, 37 insertions(+) diff --git a/apps/server/execution/orchestrator.test.ts b/apps/server/execution/orchestrator.test.ts index 698b9d2..fb52e13 100644 --- a/apps/server/execution/orchestrator.test.ts +++ b/apps/server/execution/orchestrator.test.ts @@ -49,6 +49,8 @@ function createMocks() { getMergeBase: vi.fn().mockResolvedValue('abc123'), pushBranch: vi.fn(), checkMergeability: vi.fn().mockResolvedValue({ mergeable: true }), + fetchRemote: vi.fn(), + fastForwardBranch: vi.fn(), }; const phaseRepository = { diff --git a/apps/server/execution/orchestrator.ts b/apps/server/execution/orchestrator.ts index 2883f18..e29e5a4 100644 --- a/apps/server/execution/orchestrator.ts +++ b/apps/server/execution/orchestrator.ts @@ -637,7 +637,16 @@ export class ExecutionOrchestrator { continue; } + // Fetch remote so local branches are up-to-date before merge/push + await this.branchManager.fetchRemote(clonePath); + if (strategy === 'merge_and_push') { + // Fast-forward local defaultBranch to match origin before merging + try { + await this.branchManager.fastForwardBranch(clonePath, project.defaultBranch); + } catch (ffErr) { + log.warn({ project: project.name, err: (ffErr as Error).message }, 'fast-forward of default branch failed — attempting merge anyway'); + } const result = await this.branchManager.mergeBranch(clonePath, initiative.branch, project.defaultBranch); if (!result.success) { throw new Error(`Failed to merge ${initiative.branch} into ${project.defaultBranch} for project ${project.name}: ${result.message}`); diff --git a/apps/server/git/branch-manager.ts b/apps/server/git/branch-manager.ts index 901413d..ceb399c 100644 --- a/apps/server/git/branch-manager.ts +++ b/apps/server/git/branch-manager.ts @@ -75,4 +75,17 @@ export interface BranchManager { * Uses `git merge-tree --write-tree` (git 2.38+). */ checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise; + + /** + * Fetch all refs from a remote. + * Defaults to 'origin' if no remote specified. + */ + fetchRemote(repoPath: string, remote?: string): Promise; + + /** + * Fast-forward a local branch to match its remote-tracking counterpart. + * No-op if already up to date. Throws if fast-forward is not possible + * (i.e. the branches have diverged). + */ + fastForwardBranch(repoPath: string, branch: string, remote?: string): Promise; } diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index fdca4b9..e686a6f 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -195,4 +195,17 @@ export class SimpleGitBranchManager implements BranchManager { log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: clean'); return { mergeable: true }; } + + async fetchRemote(repoPath: string, remote = 'origin'): Promise { + const git = simpleGit(repoPath); + await git.fetch(remote); + log.info({ repoPath, remote }, 'fetched remote'); + } + + async fastForwardBranch(repoPath: string, branch: string, remote = 'origin'): Promise { + const git = simpleGit(repoPath); + const remoteBranch = `${remote}/${branch}`; + await git.raw(['merge', '--ff-only', remoteBranch, branch]); + log.info({ repoPath, branch, remoteBranch }, 'fast-forwarded branch'); + } } From b853b287513b07776b15486f2c9d7be21bf701d3 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:03:20 +0100 Subject: [PATCH 54/54] fix: Resolve agent workdir probing for initiative project subdirectories Conflict-resolution agents (and any initiative-based agent) can write .cw/output/signal.json inside a project subdirectory (e.g. agent-workdirs//codewalk-district/.cw/output/) rather than the parent agent workdir. This caused two failures: 1. spawnInternal wrote spawn-diagnostic.json before registering the agent in activeAgents and starting pollForCompletion. If the .cw/ directory didn't exist (no inputContext provided), the write threw ENOENT, orphaning the running process with no completion monitoring. 2. resolveAgentCwd in cleanup-manager and output-handler only probed for a workspace/ subdirectory (standalone agents) but not project subdirectories, so reconciliation and completion handling couldn't find signal.json and marked the agent as crashed. Fixes: - Move activeAgents registration and pollForCompletion setup before the diagnostic write; make the write non-fatal with mkdir -p - Add project subdirectory probing to resolveAgentCwd in both cleanup-manager.ts and output-handler.ts --- apps/server/agent/cleanup-manager.ts | 29 +++++++++++++- apps/server/agent/manager.ts | 59 ++++++++++++++++------------ apps/server/agent/output-handler.ts | 35 ++++++++++++++--- 3 files changed, 91 insertions(+), 32 deletions(-) diff --git a/apps/server/agent/cleanup-manager.ts b/apps/server/agent/cleanup-manager.ts index e35d406..17586ac 100644 --- a/apps/server/agent/cleanup-manager.ts +++ b/apps/server/agent/cleanup-manager.ts @@ -8,7 +8,7 @@ import { promisify } from 'node:util'; import { execFile } from 'node:child_process'; import { readFile, readdir, rm, cp, mkdir } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { ProjectRepository } from '../db/repositories/project-repository.js'; @@ -49,10 +49,35 @@ export class CleanupManager { */ private resolveAgentCwd(worktreeId: string): string { const base = this.getAgentWorkdir(worktreeId); + + // Fast path: .cw/output exists at the base level + if (existsSync(join(base, '.cw', 'output'))) { + return base; + } + + // Standalone agents use a workspace/ subdirectory const workspaceSub = join(base, 'workspace'); - if (!existsSync(join(base, '.cw', 'output')) && existsSync(join(workspaceSub, '.cw'))) { + if (existsSync(join(workspaceSub, '.cw'))) { return workspaceSub; } + + // Initiative-based agents may have written .cw/ inside a project + // subdirectory (e.g. agent-workdirs//codewalk-district/.cw/). + // Probe immediate children for a .cw/output directory. + try { + const entries = readdirSync(base, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name !== '.cw') { + const projectSub = join(base, entry.name); + if (existsSync(join(projectSub, '.cw', 'output'))) { + return projectSub; + } + } + } + } catch { + // base dir may not exist + } + return base; } diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index 0572edb..d567fcc 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -43,7 +43,7 @@ import { getProvider } from './providers/registry.js'; import { createModuleLogger } from '../logger/index.js'; import { getProjectCloneDir } from '../git/project-clones.js'; import { join } from 'node:path'; -import { unlink, readFile, writeFile as writeFileAsync } from 'node:fs/promises'; +import { unlink, readFile, writeFile as writeFileAsync, mkdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import type { AccountCredentialManager } from './credentials/types.js'; import { ProcessManager } from './process-manager.js'; @@ -332,32 +332,10 @@ export class MultiProviderAgentManager implements AgentManager { await this.repository.update(agentId, { pid, outputFilePath }); - // Write spawn diagnostic file for post-execution verification - const diagnostic = { - timestamp: new Date().toISOString(), - agentId, - alias, - intendedCwd: finalCwd, - worktreeId: agent.worktreeId, - provider: providerName, - command, - args, - env: processEnv, - cwdExistsAtSpawn: existsSync(finalCwd), - initiativeId: initiativeId || null, - customCwdProvided: !!cwd, - accountId: accountId || null, - }; - - await writeFileAsync( - join(finalCwd, '.cw', 'spawn-diagnostic.json'), - JSON.stringify(diagnostic, null, 2), - 'utf-8' - ); - + // Register agent and start polling BEFORE non-critical I/O so that a + // diagnostic-write failure can never orphan a running process. const activeEntry: ActiveAgent = { agentId, pid, tailer, outputFilePath, agentCwd: finalCwd }; this.activeAgents.set(agentId, activeEntry); - log.info({ agentId, alias, pid, diagnosticWritten: true }, 'detached subprocess started with diagnostic'); // Emit spawned event if (this.eventBus) { @@ -377,6 +355,37 @@ export class MultiProviderAgentManager implements AgentManager { ); activeEntry.cancelPoll = cancel; + // Write spawn diagnostic file (non-fatal — .cw/ may not exist yet for + // agents spawned without inputContext, e.g. conflict-resolution agents) + try { + const diagnosticDir = join(finalCwd, '.cw'); + await mkdir(diagnosticDir, { recursive: true }); + const diagnostic = { + timestamp: new Date().toISOString(), + agentId, + alias, + intendedCwd: finalCwd, + worktreeId: agent.worktreeId, + provider: providerName, + command, + args, + env: processEnv, + cwdExistsAtSpawn: existsSync(finalCwd), + initiativeId: initiativeId || null, + customCwdProvided: !!cwd, + accountId: accountId || null, + }; + await writeFileAsync( + join(diagnosticDir, 'spawn-diagnostic.json'), + JSON.stringify(diagnostic, null, 2), + 'utf-8' + ); + } catch (err) { + log.warn({ agentId, alias, err: err instanceof Error ? err.message : String(err) }, 'failed to write spawn diagnostic'); + } + + log.info({ agentId, alias, pid }, 'detached subprocess started'); + return this.toAgentInfo(agent); } diff --git a/apps/server/agent/output-handler.ts b/apps/server/agent/output-handler.ts index a43f09e..28fdaf6 100644 --- a/apps/server/agent/output-handler.ts +++ b/apps/server/agent/output-handler.ts @@ -7,7 +7,7 @@ */ import { readFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { ChangeSetRepository, CreateChangeSetEntryData } from '../db/repositories/change-set-repository.js'; @@ -233,10 +233,10 @@ export class OutputHandler { log.debug({ agentId }, 'detached agent completed'); - // Resolve actual agent working directory — standalone agents run in a - // "workspace/" subdirectory inside getAgentWorkdir, so prefer agentCwd - // recorded at spawn time when available. - const agentWorkdir = active?.agentCwd ?? getAgentWorkdir(agent.worktreeId); + // Resolve actual agent working directory. + // The recorded agentCwd may be the parent dir (agent-workdirs//) while + // the agent actually writes .cw/output/ inside a project subdirectory. + const agentWorkdir = this.resolveAgentWorkdir(active?.agentCwd ?? getAgentWorkdir(agent.worktreeId)); const outputDir = join(agentWorkdir, '.cw', 'output'); const expectedPwdFile = join(agentWorkdir, '.cw', 'expected-pwd.txt'); const diagnosticFile = join(agentWorkdir, '.cw', 'spawn-diagnostic.json'); @@ -1158,6 +1158,31 @@ export class OutputHandler { } } + /** + * Resolve the actual agent working directory. The recorded agentCwd may be + * the parent (agent-workdirs//) but .cw/output/ could be inside a + * project subdirectory (e.g. codewalk-district/.cw/output/). + */ + private resolveAgentWorkdir(base: string): string { + if (existsSync(join(base, '.cw', 'output'))) return base; + + // Standalone agents: workspace/ subdirectory + const workspaceSub = join(base, 'workspace'); + if (existsSync(join(workspaceSub, '.cw'))) return workspaceSub; + + // Initiative-based agents: probe project subdirectories + try { + for (const entry of readdirSync(base, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name !== '.cw') { + const sub = join(base, entry.name); + if (existsSync(join(sub, '.cw', 'output'))) return sub; + } + } + } catch { /* base may not exist */ } + + return base; + } + private emitCrashed(agent: { id: string; name: string; taskId: string | null }, error: string): void { if (this.eventBus) { const event: AgentCrashedEvent = {