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* diff --git a/CLAUDE.md b/CLAUDE.md index 5f9f778..4bf881c 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**: 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/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/file-io.test.ts b/apps/server/agent/file-io.test.ts index 20aec4d..ae0fb9a 100644 --- a/apps/server/agent/file-io.test.ts +++ b/apps/server/agent/file-io.test.ts @@ -2,7 +2,7 @@ * File-Based Agent I/O Tests */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'; import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -15,7 +15,9 @@ import { readDecisionFiles, readPageFiles, generateId, + writeErrandManifest, } from './file-io.js'; +import { buildErrandPrompt } from './prompts/index.js'; import type { Initiative, Phase, Task } from '../db/schema.js'; let testDir: string; @@ -68,6 +70,7 @@ describe('writeInputFiles', () => { name: 'Phase One', content: 'First phase', status: 'pending', + mergeBase: null, createdAt: new Date(), updatedAt: new Date(), } as Phase; @@ -366,3 +369,116 @@ New content for the page. expect(pages).toHaveLength(1); }); }); + +describe('writeErrandManifest', () => { + let errandTestDir: string; + + beforeEach(() => { + errandTestDir = join(tmpdir(), `cw-errand-test-${randomUUID()}`); + mkdirSync(errandTestDir, { recursive: true }); + }); + + afterAll(() => { + // no-op: beforeEach creates dirs, afterEach in outer scope cleans up + }); + + it('writes manifest.json with correct shape', async () => { + await writeErrandManifest({ + agentWorkdir: errandTestDir, + errandId: 'errand-abc', + description: 'fix typo', + branch: 'cw/errand/fix-typo-errandabc', + projectName: 'my-project', + agentId: 'agent-1', + agentName: 'swift-owl', + }); + + const manifestPath = join(errandTestDir, '.cw', 'input', 'manifest.json'); + expect(existsSync(manifestPath)).toBe(true); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); + expect(manifest).toEqual({ + errandId: 'errand-abc', + agentId: 'agent-1', + agentName: 'swift-owl', + mode: 'errand', + }); + expect('files' in manifest).toBe(false); + expect('contextFiles' in manifest).toBe(false); + }); + + it('writes errand.md with correct YAML frontmatter', async () => { + await writeErrandManifest({ + agentWorkdir: errandTestDir, + errandId: 'errand-abc', + description: 'fix typo', + branch: 'cw/errand/fix-typo-errandabc', + projectName: 'my-project', + agentId: 'agent-1', + agentName: 'swift-owl', + }); + + const errandMdPath = join(errandTestDir, '.cw', 'input', 'errand.md'); + expect(existsSync(errandMdPath)).toBe(true); + const content = readFileSync(errandMdPath, 'utf-8'); + expect(content).toContain('id: errand-abc'); + expect(content).toContain('description: fix typo'); + expect(content).toContain('branch: cw/errand/fix-typo-errandabc'); + expect(content).toContain('project: my-project'); + }); + + it('writes expected-pwd.txt with agentWorkdir path', async () => { + await writeErrandManifest({ + agentWorkdir: errandTestDir, + errandId: 'errand-abc', + description: 'fix typo', + branch: 'cw/errand/fix-typo-errandabc', + projectName: 'my-project', + agentId: 'agent-1', + agentName: 'swift-owl', + }); + + const pwdPath = join(errandTestDir, '.cw', 'expected-pwd.txt'); + expect(existsSync(pwdPath)).toBe(true); + const content = readFileSync(pwdPath, 'utf-8').trim(); + expect(content).toBe(errandTestDir); + }); + + it('creates input directory if it does not exist', async () => { + const freshDir = join(tmpdir(), `cw-errand-fresh-${randomUUID()}`); + mkdirSync(freshDir, { recursive: true }); + + await writeErrandManifest({ + agentWorkdir: freshDir, + errandId: 'errand-xyz', + description: 'add feature', + branch: 'cw/errand/add-feature-errandxyz', + projectName: 'other-project', + agentId: 'agent-2', + agentName: 'brave-eagle', + }); + + expect(existsSync(join(freshDir, '.cw', 'input', 'manifest.json'))).toBe(true); + expect(existsSync(join(freshDir, '.cw', 'input', 'errand.md'))).toBe(true); + expect(existsSync(join(freshDir, '.cw', 'expected-pwd.txt'))).toBe(true); + + rmSync(freshDir, { recursive: true, force: true }); + }); +}); + +describe('buildErrandPrompt', () => { + it('includes the description in the output', () => { + const result = buildErrandPrompt('fix typo in README'); + expect(result).toContain('fix typo in README'); + }); + + it('includes signal.json instruction', () => { + const result = buildErrandPrompt('some change'); + expect(result).toContain('signal.json'); + expect(result).toContain('"status": "done"'); + }); + + it('includes error signal format', () => { + const result = buildErrandPrompt('some change'); + expect(result).toContain('"status": "error"'); + }); +}); diff --git a/apps/server/agent/file-io.ts b/apps/server/agent/file-io.ts index e6da8e1..4bbc296 100644 --- a/apps/server/agent/file-io.ts +++ b/apps/server/agent/file-io.ts @@ -298,6 +298,50 @@ export async function writeInputFiles(options: WriteInputFilesOptions): Promise< ); } +// ============================================================================= +// ERRAND INPUT FILE WRITING +// ============================================================================= + +export async function writeErrandManifest(options: { + agentWorkdir: string; + errandId: string; + description: string; + branch: string; + projectName: string; + agentId: string; + agentName: string; +}): Promise { + await mkdir(join(options.agentWorkdir, '.cw', 'input'), { recursive: true }); + + // Write errand.md first (before manifest.json) + const errandMdContent = formatFrontmatter({ + id: options.errandId, + description: options.description, + branch: options.branch, + project: options.projectName, + }); + await writeFile(join(options.agentWorkdir, '.cw', 'input', 'errand.md'), errandMdContent, 'utf-8'); + + // Write manifest.json last (after all other files exist) + await writeFile( + join(options.agentWorkdir, '.cw', 'input', 'manifest.json'), + JSON.stringify({ + errandId: options.errandId, + agentId: options.agentId, + agentName: options.agentName, + mode: 'errand', + }) + '\n', + 'utf-8', + ); + + // Write expected-pwd.txt + await writeFile( + join(options.agentWorkdir, '.cw', 'expected-pwd.txt'), + options.agentWorkdir, + 'utf-8', + ); +} + // ============================================================================= // OUTPUT FILE READING // ============================================================================= @@ -397,6 +441,34 @@ export async function readDecisionFiles(agentWorkdir: string): Promise { + const filePath = join(agentWorkdir, '.cw', 'output', 'comment-responses.json'); + try { + const raw = await readFile(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed + .filter((entry: unknown) => { + if (typeof entry !== 'object' || entry === null) return false; + const e = entry as Record; + return typeof e.commentId === 'string' && typeof e.body === 'string'; + }) + .map((entry: Record) => ({ + commentId: String(entry.commentId), + body: String(entry.body), + resolved: typeof entry.resolved === 'boolean' ? entry.resolved : undefined, + })); + } catch { + return []; + } +} + export async function readPageFiles(agentWorkdir: string): Promise { const dirPath = join(agentWorkdir, '.cw', 'output', 'pages'); return readFrontmatterDir(dirPath, (data, body, filename) => { diff --git a/apps/server/agent/lifecycle/cleanup-strategy.ts b/apps/server/agent/lifecycle/cleanup-strategy.ts index 4391124..a2ca671 100644 --- a/apps/server/agent/lifecycle/cleanup-strategy.ts +++ b/apps/server/agent/lifecycle/cleanup-strategy.ts @@ -18,6 +18,7 @@ export interface AgentInfo { status: string; initiativeId?: string | null; worktreeId: string; + exitCode?: number | null; } export interface CleanupStrategy { diff --git a/apps/server/agent/lifecycle/controller.test.ts b/apps/server/agent/lifecycle/controller.test.ts new file mode 100644 index 0000000..1ce41b9 --- /dev/null +++ b/apps/server/agent/lifecycle/controller.test.ts @@ -0,0 +1,155 @@ +/** + * AgentLifecycleController Tests — Regression coverage for event emissions. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { AgentLifecycleController } from './controller.js'; +import type { AgentRepository } from '../../db/repositories/agent-repository.js'; +import type { AccountRepository } from '../../db/repositories/account-repository.js'; +import type { SignalManager } from './signal-manager.js'; +import type { RetryPolicy } from './retry-policy.js'; +import type { AgentErrorAnalyzer } from './error-analyzer.js'; +import type { ProcessManager } from '../process-manager.js'; +import type { CleanupManager } from '../cleanup-manager.js'; +import type { CleanupStrategy } from './cleanup-strategy.js'; +import type { EventBus, AgentAccountSwitchedEvent } from '../../events/types.js'; + +function makeController(overrides: { + repository?: Partial; + accountRepository?: Partial; + eventBus?: EventBus; +}): AgentLifecycleController { + const signalManager: SignalManager = { + clearSignal: vi.fn(), + checkSignalExists: vi.fn(), + readSignal: vi.fn(), + waitForSignal: vi.fn(), + validateSignalFile: vi.fn(), + }; + const retryPolicy: RetryPolicy = { + maxAttempts: 3, + backoffMs: [1000, 2000, 4000], + shouldRetry: vi.fn().mockReturnValue(false), + getRetryDelay: vi.fn().mockReturnValue(0), + }; + const errorAnalyzer = { analyzeError: vi.fn() } as unknown as AgentErrorAnalyzer; + const processManager = { getAgentWorkdir: vi.fn() } as unknown as ProcessManager; + const cleanupManager = {} as unknown as CleanupManager; + const cleanupStrategy = { + shouldCleanup: vi.fn(), + executeCleanup: vi.fn(), + } as unknown as CleanupStrategy; + + return new AgentLifecycleController( + signalManager, + retryPolicy, + errorAnalyzer, + processManager, + overrides.repository as AgentRepository, + cleanupManager, + cleanupStrategy, + overrides.accountRepository as AccountRepository | undefined, + false, + overrides.eventBus, + ); +} + +describe('AgentLifecycleController', () => { + describe('handleAccountExhaustion', () => { + it('emits agent:account_switched with correct payload when new account is available', async () => { + const emittedEvents: AgentAccountSwitchedEvent[] = []; + const eventBus: EventBus = { + emit: vi.fn((event) => { emittedEvents.push(event as AgentAccountSwitchedEvent); }), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + }; + + const agentRecord = { + id: 'agent-1', + name: 'test-agent', + accountId: 'old-account-id', + provider: 'claude', + }; + const newAccount = { id: 'new-account-id' }; + + const repository: Partial = { + findById: vi.fn().mockResolvedValue(agentRecord), + }; + const accountRepository: Partial = { + markExhausted: vi.fn().mockResolvedValue(agentRecord), + findNextAvailable: vi.fn().mockResolvedValue(newAccount), + }; + + const controller = makeController({ repository, accountRepository, eventBus }); + + // Call private method via any-cast + await (controller as any).handleAccountExhaustion('agent-1'); + + const accountSwitchedEvents = emittedEvents.filter( + (e) => e.type === 'agent:account_switched' + ); + expect(accountSwitchedEvents).toHaveLength(1); + const event = accountSwitchedEvents[0]; + expect(event.type).toBe('agent:account_switched'); + expect(event.payload.agentId).toBe('agent-1'); + expect(event.payload.name).toBe('test-agent'); + expect(event.payload.previousAccountId).toBe('old-account-id'); + expect(event.payload.newAccountId).toBe('new-account-id'); + expect(event.payload.reason).toBe('account_exhausted'); + }); + + it('does not emit agent:account_switched when no new account is available', async () => { + const eventBus: EventBus = { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + }; + + const agentRecord = { + id: 'agent-2', + name: 'test-agent-2', + accountId: 'old-account-id', + provider: 'claude', + }; + + const repository: Partial = { + findById: vi.fn().mockResolvedValue(agentRecord), + }; + const accountRepository: Partial = { + markExhausted: vi.fn().mockResolvedValue(agentRecord), + findNextAvailable: vi.fn().mockResolvedValue(null), + }; + + const controller = makeController({ repository, accountRepository, eventBus }); + + await (controller as any).handleAccountExhaustion('agent-2'); + + expect(eventBus.emit).not.toHaveBeenCalled(); + }); + + it('does not emit when agent has no accountId', async () => { + const eventBus: EventBus = { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + }; + + const repository: Partial = { + findById: vi.fn().mockResolvedValue({ id: 'agent-3', name: 'x', accountId: null }), + }; + const accountRepository: Partial = { + markExhausted: vi.fn(), + findNextAvailable: vi.fn(), + }; + + const controller = makeController({ repository, accountRepository, eventBus }); + + await (controller as any).handleAccountExhaustion('agent-3'); + + expect(eventBus.emit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/server/agent/lifecycle/controller.ts b/apps/server/agent/lifecycle/controller.ts index 833634d..6b6810f 100644 --- a/apps/server/agent/lifecycle/controller.ts +++ b/apps/server/agent/lifecycle/controller.ts @@ -21,6 +21,7 @@ import type { RetryPolicy, AgentError } from './retry-policy.js'; import { AgentExhaustedError, AgentFailureError } from './retry-policy.js'; import type { AgentErrorAnalyzer } from './error-analyzer.js'; import type { CleanupStrategy, AgentInfo } from './cleanup-strategy.js'; +import type { EventBus, AgentAccountSwitchedEvent } from '../../events/types.js'; const log = createModuleLogger('lifecycle-controller'); @@ -48,6 +49,7 @@ export class AgentLifecycleController { private cleanupStrategy: CleanupStrategy, private accountRepository?: AccountRepository, private debug: boolean = false, + private eventBus?: EventBus, ) {} /** @@ -304,7 +306,7 @@ export class AgentLifecycleController { } /** - * Handle account exhaustion by marking account as exhausted. + * Handle account exhaustion by marking account as exhausted and emitting account_switched event. */ private async handleAccountExhaustion(agentId: string): Promise { if (!this.accountRepository) { @@ -319,15 +321,34 @@ export class AgentLifecycleController { return; } + const previousAccountId = agent.accountId; + // Mark account as exhausted for 1 hour const exhaustedUntil = new Date(Date.now() + 60 * 60 * 1000); - await this.accountRepository.markExhausted(agent.accountId, exhaustedUntil); + await this.accountRepository.markExhausted(previousAccountId, exhaustedUntil); log.info({ agentId, - accountId: agent.accountId, + accountId: previousAccountId, exhaustedUntil }, 'marked account as exhausted due to usage limits'); + + // Find the next available account and emit account_switched event + const newAccount = await this.accountRepository.findNextAvailable(agent.provider ?? 'claude'); + if (newAccount && this.eventBus) { + const event: AgentAccountSwitchedEvent = { + type: 'agent:account_switched', + timestamp: new Date(), + payload: { + agentId, + name: agent.name, + previousAccountId, + newAccountId: newAccount.id, + reason: 'account_exhausted', + }, + }; + this.eventBus.emit(event); + } } catch (error) { log.warn({ agentId, @@ -353,6 +374,7 @@ export class AgentLifecycleController { status: agent.status, initiativeId: agent.initiativeId, worktreeId: agent.worktreeId, + exitCode: agent.exitCode ?? null, }; } } \ No newline at end of file diff --git a/apps/server/agent/lifecycle/factory.ts b/apps/server/agent/lifecycle/factory.ts index 4bff87b..51c502a 100644 --- a/apps/server/agent/lifecycle/factory.ts +++ b/apps/server/agent/lifecycle/factory.ts @@ -14,6 +14,7 @@ import type { AgentRepository } from '../../db/repositories/agent-repository.js' import type { AccountRepository } from '../../db/repositories/account-repository.js'; import type { ProcessManager } from '../process-manager.js'; import type { CleanupManager } from '../cleanup-manager.js'; +import type { EventBus } from '../../events/types.js'; export interface LifecycleFactoryOptions { repository: AgentRepository; @@ -21,6 +22,7 @@ export interface LifecycleFactoryOptions { cleanupManager: CleanupManager; accountRepository?: AccountRepository; debug?: boolean; + eventBus?: EventBus; } /** @@ -32,7 +34,8 @@ export function createLifecycleController(options: LifecycleFactoryOptions): Age processManager, cleanupManager, accountRepository, - debug = false + debug = false, + eventBus, } = options; // Create core components @@ -51,7 +54,8 @@ export function createLifecycleController(options: LifecycleFactoryOptions): Age cleanupManager, cleanupStrategy, accountRepository, - debug + debug, + eventBus, ); return lifecycleController; diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index 3bde16a..5c4fc11 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -27,6 +27,7 @@ import type { TaskRepository } from '../db/repositories/task-repository.js'; import type { PageRepository } from '../db/repositories/page-repository.js'; import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js'; import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js'; +import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js'; import { generateUniqueAlias } from './alias.js'; import type { EventBus, @@ -42,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'; @@ -84,11 +85,12 @@ export class MultiProviderAgentManager implements AgentManager { private debug: boolean = false, processManagerOverride?: ProcessManager, private chatSessionRepository?: ChatSessionRepository, + private reviewCommentRepository?: ReviewCommentRepository, ) { this.signalManager = new FileSystemSignalManager(); this.processManager = processManagerOverride ?? new ProcessManager(workspaceRoot, projectRepository); this.credentialHandler = new CredentialHandler(workspaceRoot, accountRepository, credentialManager); - this.outputHandler = new OutputHandler(repository, eventBus, changeSetRepository, phaseRepository, taskRepository, pageRepository, this.signalManager, chatSessionRepository); + this.outputHandler = new OutputHandler(repository, eventBus, changeSetRepository, phaseRepository, taskRepository, pageRepository, this.signalManager, chatSessionRepository, reviewCommentRepository); this.cleanupManager = new CleanupManager(workspaceRoot, repository, projectRepository, eventBus, debug, this.signalManager); this.lifecycleController = createLifecycleController({ repository, @@ -96,6 +98,7 @@ export class MultiProviderAgentManager implements AgentManager { cleanupManager: this.cleanupManager, accountRepository, debug, + eventBus, }); // Listen for process crashed events to handle agents specially @@ -236,8 +239,18 @@ export class MultiProviderAgentManager implements AgentManager { log.debug({ alias, initiativeId, baseBranch, branchName }, 'creating initiative-based worktrees'); agentCwd = await this.processManager.createProjectWorktrees(alias, initiativeId, baseBranch, branchName); - // Log projects linked to the initiative + // Verify each project worktree subdirectory actually exists const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId); + for (const project of projects) { + const projectWorktreePath = join(agentCwd, project.name); + if (!existsSync(projectWorktreePath)) { + throw new Error( + `Worktree subdirectory missing after createProjectWorktrees: ${projectWorktreePath}. ` + + `Agent ${alias} cannot run without an isolated worktree.` + ); + } + } + log.info({ alias, initiativeId, @@ -252,11 +265,12 @@ export class MultiProviderAgentManager implements AgentManager { } // Verify the final agentCwd exists - const cwdVerified = existsSync(agentCwd); + if (!existsSync(agentCwd)) { + throw new Error(`Agent workdir does not exist after creation: ${agentCwd}`); + } log.info({ alias, agentCwd, - cwdVerified, initiativeBasedAgent: !!initiativeId }, 'agent workdir setup completed'); @@ -280,14 +294,15 @@ export class MultiProviderAgentManager implements AgentManager { }); const agentId = agent.id; - // 3a. Append inter-agent communication instructions with actual agent ID - prompt = prompt + buildInterAgentCommunication(agentId, mode); + // 3a. Append inter-agent communication + preview instructions (skipped for focused agents) + if (!options.skipPromptExtras) { + prompt = prompt + buildInterAgentCommunication(agentId, mode); - // 3b. Append preview deployment instructions if applicable - if (['execute', 'refine', 'discuss'].includes(mode) && initiativeId) { - const shouldInject = await this.shouldInjectPreviewInstructions(initiativeId); - if (shouldInject) { - prompt = prompt + buildPreviewInstructions(agentId); + if (['execute', 'refine', 'discuss'].includes(mode) && initiativeId) { + const shouldInject = await this.shouldInjectPreviewInstructions(initiativeId); + if (shouldInject) { + prompt = prompt + buildPreviewInstructions(agentId); + } } } @@ -295,6 +310,10 @@ export class MultiProviderAgentManager implements AgentManager { if (options.inputContext) { await writeInputFiles({ agentWorkdir: agentCwd, ...options.inputContext, agentId, agentName: alias }); log.debug({ alias }, 'input files written'); + } else { + // Always create .cw/output/ at the agent workdir root so the agent + // writes signal.json here rather than in a project subdirectory. + await mkdir(join(agentCwd, '.cw', 'output'), { recursive: true }); } // 4. Build spawn command @@ -328,34 +347,12 @@ export class MultiProviderAgentManager implements AgentManager { this.createLogChunkCallback(agentId, alias, 1), ); - 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' - ); + await this.repository.update(agentId, { pid, outputFilePath, prompt }); + // 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) { @@ -375,6 +372,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); } @@ -592,6 +620,7 @@ export class MultiProviderAgentManager implements AgentManager { this.activeAgents.set(agentId, activeEntry); if (this.eventBus) { + // verified: payload matches AgentResumedEvent shape (agentId, name, taskId, sessionId) const event: AgentResumedEvent = { type: 'agent:resumed', timestamp: new Date(), @@ -614,6 +643,73 @@ export class MultiProviderAgentManager implements AgentManager { } } + /** + * Deliver a user message to a running or idle errand agent. + * Does not use the conversations table — the message is injected directly + * as the next resume prompt for the agent's Claude Code session. + */ + async sendUserMessage(agentId: string, message: string): Promise { + const agent = await this.repository.findById(agentId); + if (!agent) throw new Error(`Agent not found: ${agentId}`); + + if (agent.status !== 'running' && agent.status !== 'idle') { + throw new Error(`Agent is not running (status: ${agent.status})`); + } + + if (!agent.sessionId) { + throw new Error('Agent has no session ID'); + } + + const provider = getProvider(agent.provider); + if (!provider) throw new Error(`Unknown provider: ${agent.provider}`); + + const agentCwd = this.processManager.getAgentWorkdir(agent.worktreeId); + + // Clear previous signal.json + const signalPath = join(agentCwd, '.cw/output/signal.json'); + try { + await unlink(signalPath); + } catch { + // File might not exist + } + + await this.repository.update(agentId, { status: 'running', result: null }); + + const { command, args, env: providerEnv } = this.processManager.buildResumeCommand(provider, agent.sessionId, message); + const { processEnv } = await this.credentialHandler.prepareProcessEnv(providerEnv, provider, agent.accountId); + + // Stop previous tailer/poll + const prevActive = this.activeAgents.get(agentId); + prevActive?.cancelPoll?.(); + if (prevActive?.tailer) { + await prevActive.tailer.stop(); + } + + let sessionNumber = 1; + if (this.logChunkRepository) { + sessionNumber = (await this.logChunkRepository.getSessionCount(agentId)) + 1; + } + + const { pid, outputFilePath, tailer } = await this.processManager.spawnDetached( + agentId, agent.name, command, args, agentCwd, processEnv, provider.name, message, + (event) => this.outputHandler.handleStreamEvent(agentId, event, this.activeAgents.get(agentId)), + this.createLogChunkCallback(agentId, agent.name, sessionNumber), + ); + + await this.repository.update(agentId, { pid, outputFilePath }); + const activeEntry: ActiveAgent = { agentId, pid, tailer, outputFilePath }; + this.activeAgents.set(agentId, activeEntry); + + const { cancel } = this.processManager.pollForCompletion( + agentId, pid, + () => this.handleDetachedAgentCompletion(agentId), + () => this.activeAgents.get(agentId)?.tailer, + ); + activeEntry.cancelPoll = cancel; + + log.info({ agentId, pid }, 'resumed errand agent for user message'); + } + /** * Sync credentials from agent's config dir back to DB after completion. * The subprocess may have refreshed tokens mid-session; this ensures @@ -781,6 +877,7 @@ export class MultiProviderAgentManager implements AgentManager { log.info({ agentId, pid }, 'resume detached subprocess started'); if (this.eventBus) { + // verified: payload matches AgentResumedEvent shape (agentId, name, taskId, sessionId) const event: AgentResumedEvent = { type: 'agent:resumed', timestamp: new Date(), @@ -1085,6 +1182,8 @@ export class MultiProviderAgentManager implements AgentManager { createdAt: Date; updatedAt: Date; userDismissedAt?: Date | null; + exitCode?: number | null; + prompt?: string | null; }): AgentInfo { return { id: agent.id, @@ -1100,6 +1199,8 @@ export class MultiProviderAgentManager implements AgentManager { createdAt: agent.createdAt, updatedAt: agent.updatedAt, userDismissedAt: agent.userDismissedAt, + exitCode: agent.exitCode ?? null, + prompt: agent.prompt ?? null, }; } } diff --git a/apps/server/agent/mock-manager.ts b/apps/server/agent/mock-manager.ts index 63eac8d..68d49be 100644 --- a/apps/server/agent/mock-manager.ts +++ b/apps/server/agent/mock-manager.ts @@ -142,6 +142,8 @@ export class MockAgentManager implements AgentManager { accountId: null, createdAt: now, updatedAt: now, + exitCode: null, + prompt: null, }; const record: MockAgentRecord = { diff --git a/apps/server/agent/output-handler.ts b/apps/server/agent/output-handler.ts index 9c85b2c..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'; @@ -15,6 +15,7 @@ import type { PhaseRepository } from '../db/repositories/phase-repository.js'; import type { TaskRepository } from '../db/repositories/task-repository.js'; import type { PageRepository } from '../db/repositories/page-repository.js'; import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js'; +import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js'; import type { EventBus, AgentStoppedEvent, @@ -37,6 +38,7 @@ import { readDecisionFiles, readPageFiles, readFrontmatterFile, + readCommentResponses, } from './file-io.js'; import { getProvider } from './providers/registry.js'; import { markdownToTiptapJson } from './markdown-to-tiptap.js'; @@ -92,6 +94,7 @@ export class OutputHandler { private pageRepository?: PageRepository, private signalManager?: SignalManager, private chatSessionRepository?: ChatSessionRepository, + private reviewCommentRepository?: ReviewCommentRepository, ) {} /** @@ -230,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'); @@ -851,6 +854,28 @@ export class OutputHandler { } } + // Process comment responses from agent (for review/execute tasks) + if (this.reviewCommentRepository) { + try { + const commentResponses = await readCommentResponses(agentWorkdir); + for (const resp of commentResponses) { + try { + await this.reviewCommentRepository.createReply(resp.commentId, resp.body, 'agent'); + if (resp.resolved) { + await this.reviewCommentRepository.resolve(resp.commentId); + } + } catch (err) { + log.warn({ agentId, commentId: resp.commentId, err: err instanceof Error ? err.message : String(err) }, 'failed to process comment response'); + } + } + if (commentResponses.length > 0) { + log.info({ agentId, count: commentResponses.length }, 'processed agent comment responses'); + } + } catch (err) { + log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to read comment responses'); + } + } + const resultPayload: AgentResult = { success: true, message: resultMessage, @@ -1133,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 = { diff --git a/apps/server/agent/prompts/conflict-resolution.ts b/apps/server/agent/prompts/conflict-resolution.ts new file mode 100644 index 0000000..e295b29 --- /dev/null +++ b/apps/server/agent/prompts/conflict-resolution.ts @@ -0,0 +1,79 @@ +/** + * Conflict resolution prompt — spawned when initiative branch has merge conflicts + * with the target branch. + */ + +import { + SIGNAL_FORMAT, + GIT_WORKFLOW, +} 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 the initiative branch \`${sourceBranch}\` and resolve all merge conflicts. You are working on a temporary branch created from \`${sourceBranch}\`. After resolving conflicts and committing, you must advance the initiative branch pointer using \`git update-ref\`. + + + +**Source branch (initiative):** \`${sourceBranch}\` +**Target branch (default):** \`${targetBranch}\` + +**Conflicting files:** +${conflictList} + +${SIGNAL_FORMAT} + + +1. \`pwd\` — confirm working directory +2. \`git status\` — check branch state +3. Read \`CLAUDE.md\` at the repo root (if it exists) — it contains project conventions you must follow. + + + +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. **Update initiative branch**: Run \`git update-ref refs/heads/${sourceBranch} HEAD\` to advance the initiative branch to include the merge result. This is necessary because you are working on a temporary branch — this command propagates the merge commit to the actual initiative branch. + +8. **Signal done**: Write signal.json with status "done". + +${GIT_WORKFLOW} + + +- You are on a temporary branch created from ${sourceBranch}. You are merging ${targetBranch} INTO this branch — bringing it up to date, NOT the other way around. +- After committing the merge, you MUST run \`git update-ref refs/heads/${sourceBranch} HEAD\` to advance the initiative branch pointer. Without this step, the initiative branch will not reflect the merge. +- 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/detail.ts b/apps/server/agent/prompts/detail.ts index bb16a27..2b20e39 100644 --- a/apps/server/agent/prompts/detail.ts +++ b/apps/server/agent/prompts/detail.ts @@ -13,7 +13,7 @@ ${CODEBASE_EXPLORATION} Write one file per task to \`.cw/output/tasks/{id}.md\`: -- Frontmatter: \`title\`, \`category\` (execute|research|discuss|plan|detail|refine|verify|merge|review), \`type\` (auto|checkpoint:human-verify|checkpoint:decision|checkpoint:human-action), \`dependencies\` (list of task IDs that must complete before this task can start) +- Frontmatter: \`title\`, \`category\` (execute|research|discuss|plan|detail|refine|verify|merge|review), \`dependencies\` (list of task IDs that must complete before this task can start) - Body: Detailed task description @@ -92,14 +92,6 @@ Each task is handled by a separate agent that must load the full codebase contex Bundle related changes into one task. "Add user validation" + "Add user API route" + "Add user route tests" is ONE task ("Add user creation endpoint with validation and tests"), not three. - -- \`checkpoint:human-verify\`: Visual changes, migrations, API contracts -- \`checkpoint:decision\`: Architecture choices affecting multiple phases -- \`checkpoint:human-action\`: External setup (DNS, credentials, third-party config) - -~90% of tasks should be \`auto\`. - - - Read ALL \`context/tasks/\` files before generating output - Only create tasks for THIS phase (\`phase.md\`) diff --git a/apps/server/agent/prompts/errand.ts b/apps/server/agent/prompts/errand.ts new file mode 100644 index 0000000..e94b950 --- /dev/null +++ b/apps/server/agent/prompts/errand.ts @@ -0,0 +1,16 @@ +export function buildErrandPrompt(description: string): string { + return `You are working on a small, focused change in an isolated worktree. + +Description: ${description} + +Work interactively with the user. Make only the changes needed to fulfill the description. +When you are done, write .cw/output/signal.json: + +{ "status": "done", "result": { "message": "" } } + +If you cannot complete the change: + +{ "status": "error", "error": "" } + +Do not create any other output files.`; +} diff --git a/apps/server/agent/prompts/execute.ts b/apps/server/agent/prompts/execute.ts index 03299af..92878bf 100644 --- a/apps/server/agent/prompts/execute.ts +++ b/apps/server/agent/prompts/execute.ts @@ -14,13 +14,26 @@ import { } from './shared.js'; export function buildExecutePrompt(taskDescription?: string): string { + const hasReviewComments = taskDescription?.includes('[comment:'); + const reviewCommentsSection = hasReviewComments + ? ` + +You are addressing review feedback. Each comment is tagged with [comment:ID]. +For EACH comment you address: +1. Fix the issue in code, OR explain why no change is needed. +2. Write \`.cw/output/comment-responses.json\`: + [{"commentId": "abc123", "body": "Fixed: added try-catch around token validation", "resolved": true}] +Set resolved:true when you fixed it, false when you're explaining why you didn't. +` + : ''; + const taskSection = taskDescription ? ` ${taskDescription} Read \`.cw/input/task.md\` for the full structured task with metadata, priority, and dependencies. -` +${reviewCommentsSection}` : ''; return ` diff --git a/apps/server/agent/prompts/index.ts b/apps/server/agent/prompts/index.ts index 7722872..c7167db 100644 --- a/apps/server/agent/prompts/index.ts +++ b/apps/server/agent/prompts/index.ts @@ -13,5 +13,7 @@ export { buildDetailPrompt } from './detail.js'; export { buildRefinePrompt } from './refine.js'; export { buildChatPrompt } from './chat.js'; export type { ChatHistoryEntry } from './chat.js'; +export { buildErrandPrompt } from './errand.js'; export { buildWorkspaceLayout } from './workspace.js'; export { buildPreviewInstructions } from './preview.js'; +export { buildConflictResolutionPrompt, buildConflictResolutionDescription } from './conflict-resolution.js'; diff --git a/apps/server/agent/prompts/plan.ts b/apps/server/agent/prompts/plan.ts index f11d9b5..acb1604 100644 --- a/apps/server/agent/prompts/plan.ts +++ b/apps/server/agent/prompts/plan.ts @@ -81,6 +81,15 @@ Each phase must pass: **"Could a detail agent break this into tasks without clar + +Use subagents to parallelize your analysis — don't do everything sequentially: +- **Domain decomposition**: Spawn separate subagents to investigate different aspects of the initiative (e.g., one for database/schema concerns, one for API surface, one for frontend components) and synthesize their findings into your phase plan. +- **Dependency mapping**: Spawn a subagent to map existing code dependencies and file ownership while you analyze initiative requirements, so you can make informed decisions about phase boundaries and parallelism. +- **Pattern discovery**: When the initiative touches multiple subsystems, spawn subagents to search for existing patterns in each subsystem simultaneously rather than exploring them one at a time. + +Don't spawn subagents for trivial initiatives with obvious structure — use judgment. + + - Account for existing phases/tasks — don't plan work already covered - Always generate new phase IDs — never reuse existing ones diff --git a/apps/server/agent/prompts/refine.ts b/apps/server/agent/prompts/refine.ts index 843a66c..8d831bb 100644 --- a/apps/server/agent/prompts/refine.ts +++ b/apps/server/agent/prompts/refine.ts @@ -33,6 +33,15 @@ Ignore style, grammar, formatting unless they cause genuine ambiguity. Rough but If all pages are already clear, signal done with no output files. + +Use subagents to parallelize your work: +- **Parallel page analysis**: Spawn one subagent per page (or group of related pages) to analyze clarity issues simultaneously rather than reviewing pages sequentially. +- **Codebase verification**: When checking whether a requirement is feasible or matches existing patterns, spawn a subagent to search the codebase while you continue reviewing other pages. +- **Cross-reference validation**: Spawn a subagent to verify that all [[page:$id|title]] cross-references are valid and consistent across pages. + +Don't over-split — if there are only 1-2 short pages, just do the work directly. + + - Ask 2-4 questions if you need clarification - Preserve [[page:\$id|title]] cross-references diff --git a/apps/server/agent/prompts/workspace.ts b/apps/server/agent/prompts/workspace.ts index 846850a..f01c6d3 100644 --- a/apps/server/agent/prompts/workspace.ts +++ b/apps/server/agent/prompts/workspace.ts @@ -36,5 +36,7 @@ This is an isolated git worktree. Other agents may be working in parallel on sep The following project directories contain the source code (git worktrees): ${lines.join('\n')} + +**IMPORTANT**: All \`.cw/output/\` paths (signal.json, progress.md, etc.) are relative to this working directory (\`${agentCwd}\`), NOT to any project subdirectory. Always write to \`${join(agentCwd, '.cw/output/')}\` regardless of your current \`cd\` location. `; } diff --git a/apps/server/agent/types.ts b/apps/server/agent/types.ts index 94737d9..93ad9bc 100644 --- a/apps/server/agent/types.ts +++ b/apps/server/agent/types.ts @@ -61,6 +61,8 @@ export interface SpawnAgentOptions { branchName?: string; /** Context data to write as input files in agent workdir */ inputContext?: AgentInputContext; + /** Skip inter-agent communication and preview instructions (for focused agents like conflict resolution) */ + skipPromptExtras?: boolean; } /** @@ -93,6 +95,10 @@ export interface AgentInfo { updatedAt: Date; /** When the user dismissed this agent (null if not dismissed) */ userDismissedAt?: Date | null; + /** Process exit code — null while running or if not yet exited */ + exitCode: number | null; + /** Full assembled prompt passed to the agent process — null for agents spawned before DB persistence */ + prompt: string | null; } /** diff --git a/apps/server/cli/extract.test.ts b/apps/server/cli/extract.test.ts new file mode 100644 index 0000000..64718ba --- /dev/null +++ b/apps/server/cli/extract.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock the accounts module so the dynamic import is intercepted +vi.mock('../agent/accounts/index.js', () => ({ + extractCurrentClaudeAccount: vi.fn(), +})); + +import { extractCurrentClaudeAccount } from '../agent/accounts/index.js'; +const mockExtract = vi.mocked(extractCurrentClaudeAccount); + +import { createCli } from './index.js'; + +describe('cw account extract', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation((_code?: string | number | null) => undefined as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('happy path — no email flag prints JSON to stdout', async () => { + mockExtract.mockResolvedValueOnce({ + email: 'user@example.com', + accountUuid: 'uuid-1', + configJson: { hasCompletedOnboarding: true }, + credentials: '{"claudeAiOauth":{"accessToken":"tok"}}', + }); + + const program = createCli(); + await program.parseAsync(['node', 'cw', 'account', 'extract']); + + expect(console.log).toHaveBeenCalledTimes(1); + const output = JSON.parse((console.log as ReturnType).mock.calls[0][0]); + expect(output.email).toBe('user@example.com'); + expect(output.configJson).toBe('{"hasCompletedOnboarding":true}'); + expect(output.credentials).toBe('{"claudeAiOauth":{"accessToken":"tok"}}'); + expect(process.exit).not.toHaveBeenCalled(); + }); + + it('happy path — matching email flag succeeds', async () => { + mockExtract.mockResolvedValueOnce({ + email: 'user@example.com', + accountUuid: 'uuid-1', + configJson: { hasCompletedOnboarding: true }, + credentials: '{"claudeAiOauth":{"accessToken":"tok"}}', + }); + + const program = createCli(); + await program.parseAsync(['node', 'cw', 'account', 'extract', '--email', 'user@example.com']); + + expect(console.log).toHaveBeenCalledTimes(1); + const output = JSON.parse((console.log as ReturnType).mock.calls[0][0]); + expect(output.email).toBe('user@example.com'); + expect(output.configJson).toBe('{"hasCompletedOnboarding":true}'); + expect(output.credentials).toBe('{"claudeAiOauth":{"accessToken":"tok"}}'); + expect(process.exit).not.toHaveBeenCalled(); + }); + + it('email mismatch prints error and exits with code 1', async () => { + mockExtract.mockResolvedValueOnce({ + email: 'other@example.com', + accountUuid: 'uuid-2', + configJson: { hasCompletedOnboarding: true }, + credentials: '{"claudeAiOauth":{"accessToken":"tok"}}', + }); + + const program = createCli(); + await program.parseAsync(['node', 'cw', 'account', 'extract', '--email', 'user@example.com']); + + expect(console.error).toHaveBeenCalledWith( + "Account 'user@example.com' not found (active account is 'other@example.com')" + ); + expect(process.exit).toHaveBeenCalledWith(1); + expect(console.log).not.toHaveBeenCalled(); + }); + + it('extractCurrentClaudeAccount throws prints error and exits with code 1', async () => { + mockExtract.mockRejectedValueOnce(new Error('No Claude account found')); + + const program = createCli(); + await program.parseAsync(['node', 'cw', 'account', 'extract']); + + expect(console.error).toHaveBeenCalledWith('Failed to extract account:', 'No Claude account found'); + expect(process.exit).toHaveBeenCalledWith(1); + expect(console.log).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/cli/index.ts b/apps/server/cli/index.ts index 79213ab..007035c 100644 --- a/apps/server/cli/index.ts +++ b/apps/server/cli/index.ts @@ -1334,6 +1334,32 @@ export function createCli(serverHandler?: (port?: number) => Promise): Com } }); + // cw account extract + accountCommand + .command('extract') + .description('Extract current Claude credentials for use with the UI (does not require server)') + .option('--email ', 'Verify extracted account matches this email') + .action(async (options: { email?: string }) => { + try { + const { extractCurrentClaudeAccount } = await import('../agent/accounts/index.js'); + const extracted = await extractCurrentClaudeAccount(); + if (options.email && extracted.email !== options.email) { + console.error(`Account '${options.email}' not found (active account is '${extracted.email}')`); + process.exit(1); + return; + } + const output = { + email: extracted.email, + configJson: JSON.stringify(extracted.configJson), + credentials: extracted.credentials, + }; + console.log(JSON.stringify(output, null, 2)); + } catch (error) { + console.error('Failed to extract account:', (error as Error).message); + process.exit(1); + } + }); + // Preview command group const previewCommand = program .command('preview') diff --git a/apps/server/container.ts b/apps/server/container.ts index e72a7a3..4468184 100644 --- a/apps/server/container.ts +++ b/apps/server/container.ts @@ -183,13 +183,10 @@ export async function createContainer(options?: ContainerOptions): Promise; findByAgentId(agentId: string): Promise; markReverted(id: string): Promise; + + /** + * Find applied changesets that have a 'create' entry for the given entity. + * Used to reconcile changeset status when entities are manually deleted. + */ + findAppliedByCreatedEntity(entityType: string, entityId: string): Promise; } diff --git a/apps/server/db/repositories/drizzle/change-set.ts b/apps/server/db/repositories/drizzle/change-set.ts index 0fc871a..19b8714 100644 --- a/apps/server/db/repositories/drizzle/change-set.ts +++ b/apps/server/db/repositories/drizzle/change-set.ts @@ -4,7 +4,7 @@ * Implements ChangeSetRepository interface using Drizzle ORM. */ -import { eq, desc, asc } from 'drizzle-orm'; +import { eq, desc, asc, and } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import type { DrizzleDatabase } from '../../index.js'; import { changeSets, changeSetEntries, type ChangeSet } from '../../schema.js'; @@ -94,6 +94,32 @@ export class DrizzleChangeSetRepository implements ChangeSetRepository { .orderBy(desc(changeSets.createdAt)); } + async findAppliedByCreatedEntity(entityType: string, entityId: string): Promise { + // Find changeset entries matching the entity + const matchingEntries = await this.db + .select({ changeSetId: changeSetEntries.changeSetId }) + .from(changeSetEntries) + .where( + and( + eq(changeSetEntries.entityType, entityType as any), + eq(changeSetEntries.entityId, entityId), + eq(changeSetEntries.action, 'create'), + ), + ); + + const results: ChangeSetWithEntries[] = []; + const seen = new Set(); + for (const { changeSetId } of matchingEntries) { + if (seen.has(changeSetId)) continue; + seen.add(changeSetId); + const cs = await this.findByIdWithEntries(changeSetId); + if (cs && cs.status === 'applied') { + results.push(cs); + } + } + return results; + } + async markReverted(id: string): Promise { const [updated] = await this.db .update(changeSets) diff --git a/apps/server/db/repositories/drizzle/errand.test.ts b/apps/server/db/repositories/drizzle/errand.test.ts new file mode 100644 index 0000000..749b2ad --- /dev/null +++ b/apps/server/db/repositories/drizzle/errand.test.ts @@ -0,0 +1,336 @@ +/** + * DrizzleErrandRepository Tests + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { DrizzleErrandRepository } from './errand.js'; +import { createTestDatabase } from './test-helpers.js'; +import type { DrizzleDatabase } from '../../index.js'; +import { projects, agents, errands } from '../../schema.js'; +import { nanoid } from 'nanoid'; +import { eq } from 'drizzle-orm'; + +describe('DrizzleErrandRepository', () => { + let db: DrizzleDatabase; + let repo: DrizzleErrandRepository; + + beforeEach(() => { + db = createTestDatabase(); + repo = new DrizzleErrandRepository(db); + }); + + // Helper: create a project record + async function createProject(name = 'Test Project', suffix = '') { + const id = nanoid(); + const now = new Date(); + const [project] = await db.insert(projects).values({ + id, + name: name + suffix + id, + url: `https://github.com/test/${id}`, + defaultBranch: 'main', + createdAt: now, + updatedAt: now, + }).returning(); + return project; + } + + // Helper: create an agent record + async function createAgent(name?: string) { + const id = nanoid(); + const now = new Date(); + const agentName = name ?? `agent-${id}`; + const [agent] = await db.insert(agents).values({ + id, + name: agentName, + worktreeId: `agent-workdirs/${agentName}`, + provider: 'claude', + status: 'idle', + mode: 'execute', + createdAt: now, + updatedAt: now, + }).returning(); + return agent; + } + + // Helper: create an errand + async function createErrand(overrides: Partial<{ + id: string; + description: string; + branch: string; + baseBranch: string; + agentId: string | null; + projectId: string | null; + status: 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned'; + createdAt: Date; + }> = {}) { + const project = await createProject(); + const id = overrides.id ?? nanoid(); + return repo.create({ + id, + description: overrides.description ?? 'Test errand', + branch: overrides.branch ?? 'feature/test', + baseBranch: overrides.baseBranch ?? 'main', + agentId: overrides.agentId !== undefined ? overrides.agentId : null, + projectId: overrides.projectId !== undefined ? overrides.projectId : project.id, + status: overrides.status ?? 'active', + }); + } + + describe('create + findById', () => { + it('should create errand and find by id with all fields', async () => { + const project = await createProject(); + const id = nanoid(); + + await repo.create({ + id, + description: 'Fix the bug', + branch: 'fix/bug-123', + baseBranch: 'main', + agentId: null, + projectId: project.id, + status: 'active', + }); + + const found = await repo.findById(id); + expect(found).toBeDefined(); + expect(found!.id).toBe(id); + expect(found!.description).toBe('Fix the bug'); + expect(found!.branch).toBe('fix/bug-123'); + expect(found!.baseBranch).toBe('main'); + expect(found!.status).toBe('active'); + expect(found!.projectId).toBe(project.id); + expect(found!.agentId).toBeNull(); + expect(found!.agentAlias).toBeNull(); + }); + }); + + describe('findAll', () => { + it('should return all errands ordered by createdAt desc', async () => { + const project = await createProject(); + const t1 = new Date('2024-01-01T00:00:00Z'); + const t2 = new Date('2024-01-02T00:00:00Z'); + const t3 = new Date('2024-01-03T00:00:00Z'); + + const id1 = nanoid(); + const id2 = nanoid(); + const id3 = nanoid(); + + await db.insert(errands).values([ + { id: id1, description: 'Errand 1', branch: 'b1', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t1, updatedAt: t1 }, + { id: id2, description: 'Errand 2', branch: 'b2', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t2, updatedAt: t2 }, + { id: id3, description: 'Errand 3', branch: 'b3', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t3, updatedAt: t3 }, + ]); + + const result = await repo.findAll(); + expect(result.length).toBeGreaterThanOrEqual(3); + // Find our three in the results + const ids = result.map((e) => e.id); + expect(ids.indexOf(id3)).toBeLessThan(ids.indexOf(id2)); + expect(ids.indexOf(id2)).toBeLessThan(ids.indexOf(id1)); + }); + + it('should filter by projectId', async () => { + const projectA = await createProject('A'); + const projectB = await createProject('B'); + const now = new Date(); + + const idA1 = nanoid(); + const idA2 = nanoid(); + const idB1 = nanoid(); + + await db.insert(errands).values([ + { id: idA1, description: 'A1', branch: 'b-a1', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now }, + { id: idA2, description: 'A2', branch: 'b-a2', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now }, + { id: idB1, description: 'B1', branch: 'b-b1', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active', createdAt: now, updatedAt: now }, + ]); + + const result = await repo.findAll({ projectId: projectA.id }); + expect(result).toHaveLength(2); + expect(result.map((e) => e.id).sort()).toEqual([idA1, idA2].sort()); + }); + + it('should filter by status', async () => { + const project = await createProject(); + const now = new Date(); + + const id1 = nanoid(); + const id2 = nanoid(); + const id3 = nanoid(); + + await db.insert(errands).values([ + { id: id1, description: 'E1', branch: 'b1', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: now, updatedAt: now }, + { id: id2, description: 'E2', branch: 'b2', baseBranch: 'main', agentId: null, projectId: project.id, status: 'pending_review', createdAt: now, updatedAt: now }, + { id: id3, description: 'E3', branch: 'b3', baseBranch: 'main', agentId: null, projectId: project.id, status: 'merged', createdAt: now, updatedAt: now }, + ]); + + const result = await repo.findAll({ status: 'pending_review' }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(id2); + }); + + it('should filter by both projectId and status', async () => { + const projectA = await createProject('PA'); + const projectB = await createProject('PB'); + const now = new Date(); + + const idMatch = nanoid(); + const idOtherStatus = nanoid(); + const idOtherProject = nanoid(); + const idNeither = nanoid(); + + await db.insert(errands).values([ + { id: idMatch, description: 'Match', branch: 'b1', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'pending_review', createdAt: now, updatedAt: now }, + { id: idOtherStatus, description: 'Wrong status', branch: 'b2', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now }, + { id: idOtherProject, description: 'Wrong project', branch: 'b3', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'pending_review', createdAt: now, updatedAt: now }, + { id: idNeither, description: 'Neither', branch: 'b4', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active', createdAt: now, updatedAt: now }, + ]); + + const result = await repo.findAll({ projectId: projectA.id, status: 'pending_review' }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(idMatch); + }); + }); + + describe('findById', () => { + it('should return agentAlias when agentId is set', async () => { + const agent = await createAgent('known-agent'); + const project = await createProject(); + const id = nanoid(); + const now = new Date(); + + await db.insert(errands).values({ + id, + description: 'With agent', + branch: 'feature/x', + baseBranch: 'main', + agentId: agent.id, + projectId: project.id, + status: 'active', + createdAt: now, + updatedAt: now, + }); + + const found = await repo.findById(id); + expect(found).toBeDefined(); + expect(found!.agentAlias).toBe(agent.name); + }); + + it('should return agentAlias as null when agentId is null', async () => { + const project = await createProject(); + const id = nanoid(); + const now = new Date(); + + await db.insert(errands).values({ + id, + description: 'No agent', + branch: 'feature/y', + baseBranch: 'main', + agentId: null, + projectId: project.id, + status: 'active', + createdAt: now, + updatedAt: now, + }); + + const found = await repo.findById(id); + expect(found).toBeDefined(); + expect(found!.agentAlias).toBeNull(); + }); + + it('should return undefined for unknown id', async () => { + const found = await repo.findById('nonexistent'); + expect(found).toBeUndefined(); + }); + }); + + describe('update', () => { + it('should update status and advance updatedAt', async () => { + const project = await createProject(); + const id = nanoid(); + const past = new Date('2024-01-01T00:00:00Z'); + + await db.insert(errands).values({ + id, + description: 'Errand', + branch: 'feature/update', + baseBranch: 'main', + agentId: null, + projectId: project.id, + status: 'active', + createdAt: past, + updatedAt: past, + }); + + const updated = await repo.update(id, { status: 'pending_review' }); + expect(updated.status).toBe('pending_review'); + expect(updated.updatedAt.getTime()).toBeGreaterThan(past.getTime()); + }); + + it('should throw on unknown id', async () => { + await expect( + repo.update('nonexistent', { status: 'merged' }) + ).rejects.toThrow('Errand not found'); + }); + }); + + describe('delete', () => { + it('should delete errand and findById returns undefined', async () => { + const errand = await createErrand(); + await repo.delete(errand.id); + const found = await repo.findById(errand.id); + expect(found).toBeUndefined(); + }); + }); + + describe('cascade and set null', () => { + it('should cascade delete errands when project is deleted', async () => { + const project = await createProject(); + const id = nanoid(); + const now = new Date(); + + await db.insert(errands).values({ + id, + description: 'Cascade test', + branch: 'feature/cascade', + baseBranch: 'main', + agentId: null, + projectId: project.id, + status: 'active', + createdAt: now, + updatedAt: now, + }); + + // Delete project — should cascade delete errands + await db.delete(projects).where(eq(projects.id, project.id)); + + const found = await repo.findById(id); + expect(found).toBeUndefined(); + }); + + it('should set agentId to null when agent is deleted', async () => { + const agent = await createAgent(); + const project = await createProject(); + const id = nanoid(); + const now = new Date(); + + await db.insert(errands).values({ + id, + description: 'Agent null test', + branch: 'feature/agent-null', + baseBranch: 'main', + agentId: agent.id, + projectId: project.id, + status: 'active', + createdAt: now, + updatedAt: now, + }); + + // Delete agent — should set null + await db.delete(agents).where(eq(agents.id, agent.id)); + + const [errand] = await db.select().from(errands).where(eq(errands.id, id)); + expect(errand).toBeDefined(); + expect(errand.agentId).toBeNull(); + }); + }); +}); diff --git a/apps/server/db/repositories/drizzle/errand.ts b/apps/server/db/repositories/drizzle/errand.ts new file mode 100644 index 0000000..0774e4b --- /dev/null +++ b/apps/server/db/repositories/drizzle/errand.ts @@ -0,0 +1,89 @@ +/** + * Drizzle Errand Repository Adapter + * + * Implements ErrandRepository interface using Drizzle ORM. + */ + +import { eq, desc, and } from 'drizzle-orm'; +import type { DrizzleDatabase } from '../../index.js'; +import { errands, agents } from '../../schema.js'; +import type { + ErrandRepository, + ErrandWithAlias, + ErrandStatus, + CreateErrandData, + UpdateErrandData, +} from '../errand-repository.js'; +import type { Errand } from '../../schema.js'; + +export class DrizzleErrandRepository implements ErrandRepository { + constructor(private db: DrizzleDatabase) {} + + async create(data: CreateErrandData): Promise { + const now = new Date(); + const [created] = await this.db + .insert(errands) + .values({ ...data, createdAt: now, updatedAt: now }) + .returning(); + return created; + } + + async findById(id: string): Promise { + const result = await this.db + .select({ + id: errands.id, + description: errands.description, + branch: errands.branch, + baseBranch: errands.baseBranch, + agentId: errands.agentId, + projectId: errands.projectId, + status: errands.status, + createdAt: errands.createdAt, + updatedAt: errands.updatedAt, + agentAlias: agents.name, + }) + .from(errands) + .leftJoin(agents, eq(errands.agentId, agents.id)) + .where(eq(errands.id, id)) + .limit(1); + return result[0] ?? undefined; + } + + async findAll(opts?: { projectId?: string; status?: ErrandStatus }): Promise { + const conditions = []; + if (opts?.projectId) conditions.push(eq(errands.projectId, opts.projectId)); + if (opts?.status) conditions.push(eq(errands.status, opts.status)); + + return this.db + .select({ + id: errands.id, + description: errands.description, + branch: errands.branch, + baseBranch: errands.baseBranch, + agentId: errands.agentId, + projectId: errands.projectId, + status: errands.status, + createdAt: errands.createdAt, + updatedAt: errands.updatedAt, + agentAlias: agents.name, + }) + .from(errands) + .leftJoin(agents, eq(errands.agentId, agents.id)) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(errands.createdAt)); + } + + async update(id: string, data: UpdateErrandData): Promise { + const [updated] = await this.db + .update(errands) + .set({ ...data, updatedAt: new Date() }) + .where(eq(errands.id, id)) + .returning(); + if (!updated) throw new Error(`Errand not found: ${id}`); + return updated; + } + + async delete(id: string): Promise { + await this.db.delete(errands).where(eq(errands.id, id)); + } +} diff --git a/apps/server/db/repositories/drizzle/index.ts b/apps/server/db/repositories/drizzle/index.ts index c29daba..78afdea 100644 --- a/apps/server/db/repositories/drizzle/index.ts +++ b/apps/server/db/repositories/drizzle/index.ts @@ -18,3 +18,4 @@ export { DrizzleLogChunkRepository } from './log-chunk.js'; export { DrizzleConversationRepository } from './conversation.js'; export { DrizzleChatSessionRepository } from './chat-session.js'; export { DrizzleReviewCommentRepository } from './review-comment.js'; +export { DrizzleErrandRepository } from './errand.js'; diff --git a/apps/server/db/repositories/drizzle/review-comment.ts b/apps/server/db/repositories/drizzle/review-comment.ts index 836c9d1..2ae3314 100644 --- a/apps/server/db/repositories/drizzle/review-comment.ts +++ b/apps/server/db/repositories/drizzle/review-comment.ts @@ -23,7 +23,43 @@ export class DrizzleReviewCommentRepository implements ReviewCommentRepository { lineNumber: data.lineNumber, lineType: data.lineType, body: data.body, - author: data.author ?? 'you', + author: data.author ?? 'user', + parentCommentId: data.parentCommentId ?? null, + resolved: false, + createdAt: now, + updatedAt: now, + }); + const rows = await this.db + .select() + .from(reviewComments) + .where(eq(reviewComments.id, id)) + .limit(1); + return rows[0]!; + } + + async createReply(parentCommentId: string, body: string, author?: string): Promise { + // Fetch parent comment to copy context fields + const parentRows = await this.db + .select() + .from(reviewComments) + .where(eq(reviewComments.id, parentCommentId)) + .limit(1); + const parent = parentRows[0]; + if (!parent) { + throw new Error(`Parent comment not found: ${parentCommentId}`); + } + + const now = new Date(); + const id = nanoid(); + await this.db.insert(reviewComments).values({ + id, + phaseId: parent.phaseId, + filePath: parent.filePath, + lineNumber: parent.lineNumber, + lineType: parent.lineType, + body, + author: author ?? 'user', + parentCommentId, resolved: false, createdAt: now, updatedAt: now, @@ -44,6 +80,19 @@ export class DrizzleReviewCommentRepository implements ReviewCommentRepository { .orderBy(asc(reviewComments.createdAt)); } + async update(id: string, body: string): Promise { + await this.db + .update(reviewComments) + .set({ body, updatedAt: new Date() }) + .where(eq(reviewComments.id, id)); + const rows = await this.db + .select() + .from(reviewComments) + .where(eq(reviewComments.id, id)) + .limit(1); + return rows[0] ?? null; + } + async resolve(id: string): Promise { await this.db .update(reviewComments) diff --git a/apps/server/db/repositories/drizzle/task.test.ts b/apps/server/db/repositories/drizzle/task.test.ts index 316027c..5f19065 100644 --- a/apps/server/db/repositories/drizzle/task.test.ts +++ b/apps/server/db/repositories/drizzle/task.test.ts @@ -71,13 +71,13 @@ describe('DrizzleTaskRepository', () => { it('should accept custom type and priority', async () => { const task = await taskRepo.create({ phaseId: testPhaseId, - name: 'Checkpoint Task', - type: 'checkpoint:human-verify', + name: 'High Priority Task', + type: 'auto', priority: 'high', order: 1, }); - expect(task.type).toBe('checkpoint:human-verify'); + expect(task.type).toBe('auto'); expect(task.priority).toBe('high'); }); }); diff --git a/apps/server/db/repositories/errand-repository.ts b/apps/server/db/repositories/errand-repository.ts new file mode 100644 index 0000000..9502e34 --- /dev/null +++ b/apps/server/db/repositories/errand-repository.ts @@ -0,0 +1,15 @@ +import type { Errand, NewErrand } from '../schema.js'; + +export type ErrandStatus = 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned'; +export type ErrandWithAlias = Errand & { agentAlias: string | null }; + +export type CreateErrandData = Omit; +export type UpdateErrandData = Partial>; + +export interface ErrandRepository { + create(data: CreateErrandData): Promise; + findById(id: string): Promise; + findAll(opts?: { projectId?: string; status?: ErrandStatus }): Promise; + update(id: string, data: UpdateErrandData): Promise; + delete(id: string): Promise; +} diff --git a/apps/server/db/repositories/index.ts b/apps/server/db/repositories/index.ts index 809214c..c1407df 100644 --- a/apps/server/db/repositories/index.ts +++ b/apps/server/db/repositories/index.ts @@ -82,3 +82,11 @@ export type { ReviewCommentRepository, CreateReviewCommentData, } from './review-comment-repository.js'; + +export type { + ErrandRepository, + ErrandWithAlias, + ErrandStatus, + CreateErrandData, + UpdateErrandData, +} from './errand-repository.js'; diff --git a/apps/server/db/repositories/review-comment-repository.ts b/apps/server/db/repositories/review-comment-repository.ts index 50831bb..9ed7530 100644 --- a/apps/server/db/repositories/review-comment-repository.ts +++ b/apps/server/db/repositories/review-comment-repository.ts @@ -13,11 +13,14 @@ export interface CreateReviewCommentData { lineType: 'added' | 'removed' | 'context'; body: string; author?: string; + parentCommentId?: string; // for replies } export interface ReviewCommentRepository { create(data: CreateReviewCommentData): Promise; + createReply(parentCommentId: string, body: string, author?: string): Promise; findByPhaseId(phaseId: string): Promise; + update(id: string, body: string): Promise; resolve(id: string): Promise; unresolve(id: string): Promise; delete(id: string): Promise; diff --git a/apps/server/db/schema.ts b/apps/server/db/schema.ts index 1e371db..ce35cec 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(), }); @@ -137,7 +138,7 @@ export const tasks = sqliteTable('tasks', { name: text('name').notNull(), description: text('description'), type: text('type', { - enum: ['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action'], + enum: ['auto'], }) .notNull() .default('auto'), @@ -156,6 +157,7 @@ export const tasks = sqliteTable('tasks', { .default('pending'), order: integer('order').notNull().default(0), summary: text('summary'), // Agent result summary — propagated to dependent tasks as context + retryCount: integer('retry_count').notNull().default(0), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }); @@ -260,11 +262,12 @@ export const agents = sqliteTable('agents', { }) .notNull() .default('idle'), - mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine', 'chat'] }) + mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine', 'chat', 'errand'] }) .notNull() .default('execute'), pid: integer('pid'), exitCode: integer('exit_code'), // Process exit code for debugging crashes + prompt: text('prompt'), // Full assembled prompt passed to the agent process (persisted for durability after log cleanup) outputFilePath: text('output_file_path'), result: text('result'), pendingQuestions: text('pending_questions'), @@ -616,12 +619,46 @@ export const reviewComments = sqliteTable('review_comments', { lineType: text('line_type', { enum: ['added', 'removed', 'context'] }).notNull(), body: text('body').notNull(), author: text('author').notNull().default('you'), + parentCommentId: text('parent_comment_id').references((): ReturnType => reviewComments.id, { onDelete: 'cascade' }), resolved: integer('resolved', { mode: 'boolean' }).notNull().default(false), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }, (table) => [ index('review_comments_phase_id_idx').on(table.phaseId), + index('review_comments_parent_id_idx').on(table.parentCommentId), ]); export type ReviewComment = InferSelectModel; export type NewReviewComment = InferInsertModel; + +// ============================================================================ +// ERRANDS +// ============================================================================ + +export const errands = sqliteTable('errands', { + id: text('id').primaryKey(), + description: text('description').notNull(), + branch: text('branch').notNull(), + baseBranch: text('base_branch').notNull().default('main'), + agentId: text('agent_id').references(() => agents.id, { onDelete: 'set null' }), + projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }), + status: text('status', { + enum: ['active', 'pending_review', 'conflict', 'merged', 'abandoned'], + }).notNull().default('active'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}); + +export const errandsRelations = relations(errands, ({ one }) => ({ + agent: one(agents, { + fields: [errands.agentId], + references: [agents.id], + }), + project: one(projects, { + fields: [errands.projectId], + references: [projects.id], + }), +})); + +export type Errand = InferSelectModel; +export type NewErrand = InferInsertModel; diff --git a/apps/server/dispatch/manager.test.ts b/apps/server/dispatch/manager.test.ts index 10a412e..cb0f4e6 100644 --- a/apps/server/dispatch/manager.test.ts +++ b/apps/server/dispatch/manager.test.ts @@ -70,6 +70,8 @@ function createMockAgentManager( accountId: null, createdAt: new Date(), updatedAt: new Date(), + exitCode: null, + prompt: null, }; mockAgents.push(newAgent); return newAgent; @@ -101,6 +103,8 @@ function createIdleAgent(id: string, name: string): AgentInfo { accountId: null, createdAt: new Date(), updatedAt: new Date(), + exitCode: null, + prompt: null, }; } diff --git a/apps/server/dispatch/manager.ts b/apps/server/dispatch/manager.ts index b799e57..554dff3 100644 --- a/apps/server/dispatch/manager.ts +++ b/apps/server/dispatch/manager.ts @@ -21,10 +21,13 @@ import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { InitiativeRepository } from '../db/repositories/initiative-repository.js'; import type { PhaseRepository } from '../db/repositories/phase-repository.js'; import type { PageRepository } from '../db/repositories/page-repository.js'; +import type { ProjectRepository } from '../db/repositories/project-repository.js'; import type { Task, Phase } from '../db/schema.js'; import type { PageForSerialization } from '../agent/content-serializer.js'; +import type { BranchManager } from '../git/branch-manager.js'; import type { DispatchManager, QueuedTask, DispatchResult } from './types.js'; import { phaseBranchName, taskBranchName, isPlanningCategory, generateInitiativeBranch } from '../git/branch-naming.js'; +import { ensureProjectClone } from '../git/project-clones.js'; import { buildExecutePrompt } from '../agent/prompts/index.js'; import { createModuleLogger } from '../logger/index.js'; @@ -68,12 +71,14 @@ export class DefaultDispatchManager implements DispatchManager { private phaseRepository?: PhaseRepository, private agentRepository?: AgentRepository, private pageRepository?: PageRepository, + private projectRepository?: ProjectRepository, + private branchManager?: BranchManager, + private workspaceRoot?: string, ) {} /** * Queue a task for dispatch. * Fetches task dependencies and adds to internal queue. - * Checkpoint tasks are queued but won't auto-dispatch. */ async queue(taskId: string): Promise { // Fetch task to verify it exists and get priority @@ -94,7 +99,7 @@ export class DefaultDispatchManager implements DispatchManager { this.taskQueue.set(taskId, queuedTask); - log.info({ taskId, priority: task.priority, isCheckpoint: this.isCheckpointTask(task) }, 'task queued'); + log.info({ taskId, priority: task.priority }, 'task queued'); // Emit TaskQueuedEvent const event: TaskQueuedEvent = { @@ -112,7 +117,6 @@ export class DefaultDispatchManager implements DispatchManager { /** * Get next dispatchable task. * Returns task with all dependencies complete, highest priority first. - * Checkpoint tasks are excluded (require human action). */ async getNextDispatchable(): Promise { const queuedTasks = Array.from(this.taskQueue.values()); @@ -121,7 +125,7 @@ export class DefaultDispatchManager implements DispatchManager { return null; } - // Filter to only tasks with all dependencies complete and not checkpoint tasks + // Filter to only tasks with all dependencies complete const readyTasks: QueuedTask[] = []; log.debug({ queueSize: queuedTasks.length }, 'evaluating dispatchable tasks'); @@ -133,14 +137,8 @@ export class DefaultDispatchManager implements DispatchManager { continue; } - // Check if this is a checkpoint task (requires human action) - const task = await this.taskRepository.findById(qt.taskId); - if (task && this.isCheckpointTask(task)) { - log.debug({ taskId: qt.taskId, type: task.type }, 'skipping checkpoint task'); - continue; - } - // Skip planning-category tasks (handled by architect flow) + const task = await this.taskRepository.findById(qt.taskId); if (task && isPlanningCategory(task.category)) { log.debug({ taskId: qt.taskId, category: task.category }, 'skipping planning-category task'); continue; @@ -237,6 +235,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 and clear retry count (manual retry = fresh start) + await this.taskRepository.update(taskId, { status: 'pending', retryCount: 0 }); + + log.info({ taskId }, 'retrying blocked task'); + + // Re-queue for dispatch + await this.queue(taskId); + } + /** * Dispatch next available task to an agent. */ @@ -308,8 +327,39 @@ export class DefaultDispatchManager implements DispatchManager { } } } - } catch { - // Non-fatal: fall back to default branching + } catch (err) { + if (!isPlanningCategory(task.category)) { + // Execution tasks MUST have correct branches — fail loudly + throw new Error(`Failed to compute branches for execution task ${task.id}: ${err}`); + } + // Planning tasks: non-fatal, fall back to default branching + log.debug({ taskId: task.id, err }, 'branch computation skipped for planning task'); + } + + // Ensure branches exist in project clones before spawning worktrees + if (baseBranch && this.projectRepository && this.branchManager && this.workspaceRoot) { + try { + const initiative = await this.initiativeRepository.findById(task.initiativeId); + const initBranch = initiative?.branch; + if (initBranch) { + const projects = await this.projectRepository.findProjectsByInitiativeId(task.initiativeId); + for (const project of projects) { + const clonePath = await ensureProjectClone(project, this.workspaceRoot); + // Ensure initiative branch exists (from project defaultBranch) + await this.branchManager.ensureBranch(clonePath, initBranch, project.defaultBranch); + // Ensure phase branch exists (from initiative branch) + const phBranch = phaseBranchName(initBranch, (await this.phaseRepository?.findById(task.phaseId!))?.name ?? ''); + if (phBranch) { + await this.branchManager.ensureBranch(clonePath, phBranch, initBranch); + } + } + } + } catch (err) { + if (!isPlanningCategory(task.category)) { + throw new Error(`Failed to ensure branches for execution task ${task.id}: ${err}`); + } + log.warn({ taskId: task.id, err }, 'failed to ensure branches for planning task dispatch'); + } } } @@ -428,14 +478,6 @@ export class DefaultDispatchManager implements DispatchManager { return true; } - /** - * Check if a task is a checkpoint task. - * Checkpoint tasks require human action and don't auto-dispatch. - */ - private isCheckpointTask(task: Task): boolean { - return task.type.startsWith('checkpoint:'); - } - /** * Store the completing agent's result summary on the task record. */ 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/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/0032_add_comment_threading.sql b/apps/server/drizzle/0032_add_comment_threading.sql new file mode 100644 index 0000000..11afcd0 --- /dev/null +++ b/apps/server/drizzle/0032_add_comment_threading.sql @@ -0,0 +1,2 @@ +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/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/0034_add_task_retry_count.sql b/apps/server/drizzle/0034_add_task_retry_count.sql new file mode 100644 index 0000000..2d483a6 --- /dev/null +++ b/apps/server/drizzle/0034_add_task_retry_count.sql @@ -0,0 +1 @@ +ALTER TABLE tasks ADD COLUMN retry_count integer NOT NULL DEFAULT 0; diff --git a/apps/server/drizzle/0035_faulty_human_fly.sql b/apps/server/drizzle/0035_faulty_human_fly.sql new file mode 100644 index 0000000..5afe9b5 --- /dev/null +++ b/apps/server/drizzle/0035_faulty_human_fly.sql @@ -0,0 +1,13 @@ +CREATE TABLE `errands` ( + `id` text PRIMARY KEY NOT NULL, + `description` text NOT NULL, + `branch` text NOT NULL, + `base_branch` text DEFAULT 'main' NOT NULL, + `agent_id` text, + `project_id` text, + `status` text DEFAULT 'active' NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`agent_id`) REFERENCES `agents`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); \ No newline at end of file diff --git a/apps/server/drizzle/0036_icy_silvermane.sql b/apps/server/drizzle/0036_icy_silvermane.sql new file mode 100644 index 0000000..43dbeed --- /dev/null +++ b/apps/server/drizzle/0036_icy_silvermane.sql @@ -0,0 +1 @@ +ALTER TABLE `agents` ADD `prompt` text; 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/apps/server/drizzle/meta/0035_snapshot.json b/apps/server/drizzle/meta/0035_snapshot.json new file mode 100644 index 0000000..d735a97 --- /dev/null +++ b/apps/server/drizzle/meta/0035_snapshot.json @@ -0,0 +1,1974 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c84e499f-7df8-4091-b2a5-6b12847898bd", + "prevId": "5fbe1151-1dfb-4b0c-a7fa-2177369543fd", + "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": {} + }, + "errands": { + "name": "errands", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'main'" + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_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": {}, + "foreignKeys": { + "errands_agent_id_agents_id_fk": { + "name": "errands_agent_id_agents_id_fk", + "tableFrom": "errands", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "errands_project_id_projects_id_fk": { + "name": "errands_project_id_projects_id_fk", + "tableFrom": "errands", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "retry_count": { + "name": "retry_count", + "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": { + "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": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/0036_snapshot.json b/apps/server/drizzle/meta/0036_snapshot.json new file mode 100644 index 0000000..f60484b --- /dev/null +++ b/apps/server/drizzle/meta/0036_snapshot.json @@ -0,0 +1,1159 @@ +{ + "id": "f85b9df3-dead-4c46-90ac-cf36bcaa6eb4", + "prevId": "c0b6d7d3-c9da-440a-9fb8-9dd88df5672a", + "version": "6", + "dialect": "sqlite", + "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_dir": { + "name": "config_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "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": {} + }, + "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 + }, + "prompt": { + "name": "prompt", + "type": "text", + "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 + } + }, + "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", + "columnsFrom": [ + "task_id" + ], + "tableTo": "tasks", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "agents_initiative_id_initiatives_id_fk": { + "name": "agents_initiative_id_initiatives_id_fk", + "tableFrom": "agents", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "agents_account_id_accounts_id_fk": { + "name": "agents_account_id_accounts_id_fk", + "tableFrom": "agents", + "columnsFrom": [ + "account_id" + ], + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "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", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "initiative_projects_project_id_projects_id_fk": { + "name": "initiative_projects_project_id_projects_id_fk", + "tableFrom": "initiative_projects", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "merge_requires_approval": { + "name": "merge_requires_approval", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "merge_target": { + "name": "merge_target", + "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": {}, + "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", + "columnsFrom": [ + "sender_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "messages_recipient_id_agents_id_fk": { + "name": "messages_recipient_id_agents_id_fk", + "tableFrom": "messages", + "columnsFrom": [ + "recipient_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "messages_parent_message_id_messages_id_fk": { + "name": "messages_parent_message_id_messages_id_fk", + "tableFrom": "messages", + "columnsFrom": [ + "parent_message_id" + ], + "tableTo": "messages", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "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", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "pages_parent_page_id_pages_id_fk": { + "name": "pages_parent_page_id_pages_id_fk", + "tableFrom": "pages", + "columnsFrom": [ + "parent_page_id" + ], + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "phase_id" + ], + "tableTo": "phases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "phase_dependencies_depends_on_phase_id_phases_id_fk": { + "name": "phase_dependencies_depends_on_phase_id_phases_id_fk", + "tableFrom": "phase_dependencies", + "columnsFrom": [ + "depends_on_phase_id" + ], + "tableTo": "phases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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 + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "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": {}, + "foreignKeys": { + "phases_initiative_id_initiatives_id_fk": { + "name": "phases_initiative_id_initiatives_id_fk", + "tableFrom": "phases", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plans": { + "name": "plans", + "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 + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "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": {}, + "foreignKeys": { + "plans_phase_id_phases_id_fk": { + "name": "plans_phase_id_phases_id_fk", + "tableFrom": "plans", + "columnsFrom": [ + "phase_id" + ], + "tableTo": "phases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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 + }, + "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": {} + }, + "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", + "columnsFrom": [ + "task_id" + ], + "tableTo": "tasks", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "task_dependencies_depends_on_task_id_tasks_id_fk": { + "name": "task_dependencies_depends_on_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "columnsFrom": [ + "depends_on_task_id" + ], + "tableTo": "tasks", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "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 + }, + "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'" + }, + "requires_approval": { + "name": "requires_approval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "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": { + "tasks_plan_id_plans_id_fk": { + "name": "tasks_plan_id_plans_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "plan_id" + ], + "tableTo": "plans", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "tasks_phase_id_phases_id_fk": { + "name": "tasks_phase_id_phases_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "phase_id" + ], + "tableTo": "phases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "tasks_initiative_id_initiatives_id_fk": { + "name": "tasks_initiative_id_initiatives_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index ac6687d..a58f2cf 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -218,6 +218,48 @@ "when": 1772150400000, "tag": "0030_remove_task_approval", "breakpoints": true + }, + { + "idx": 31, + "version": "6", + "when": 1772236800000, + "tag": "0031_add_phase_merge_base", + "breakpoints": true + }, + { + "idx": 32, + "version": "6", + "when": 1772323200000, + "tag": "0032_add_comment_threading", + "breakpoints": true + }, + { + "idx": 33, + "version": "6", + "when": 1772409600000, + "tag": "0033_drop_approval_columns", + "breakpoints": true + }, + { + "idx": 34, + "version": "6", + "when": 1772496000000, + "tag": "0034_add_task_retry_count", + "breakpoints": true + }, + { + "idx": 35, + "version": "6", + "when": 1772796561474, + "tag": "0035_faulty_human_fly", + "breakpoints": true + }, + { + "idx": 36, + "version": "6", + "when": 1772798869413, + "tag": "0036_icy_silvermane", + "breakpoints": true } ] -} \ No newline at end of file +} 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..04162cf 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 @@ -684,6 +694,14 @@ export type DomainEventType = DomainEventMap['type']; * * All modules communicate through this interface. * Can be swapped for external systems (RabbitMQ, WebSocket forwarding) later. + * + * **Delivery guarantee: at-most-once.** + * + * Events emitted while a client is disconnected are permanently lost. + * Reconnecting clients receive only events emitted after reconnection. + * React Query's `refetchOnWindowFocus` and `refetchOnReconnect` compensate + * for missed mutations since the system uses query invalidation rather + * than incremental state. */ export interface EventBus { /** diff --git a/apps/server/execution/orchestrator.test.ts b/apps/server/execution/orchestrator.test.ts new file mode 100644 index 0000000..6cf293d --- /dev/null +++ b/apps/server/execution/orchestrator.test.ts @@ -0,0 +1,369 @@ +/** + * 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 { ensureProjectClone } from '../git/project-clones.js'; +import type { BranchManager } from '../git/branch-manager.js'; + +vi.mock('../git/project-clones.js', () => ({ + ensureProjectClone: vi.fn().mockResolvedValue('/tmp/test-workspace/clones/test'), +})); +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', previousRef: 'abc000' }), + 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(), + checkMergeability: vi.fn().mockResolvedValue({ mergeable: true }), + fetchRemote: vi.fn(), + fastForwardBranch: vi.fn(), + updateRef: 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(); + }); + }); + + describe('approveInitiative', () => { + function setupApproveTest(mocks: ReturnType) { + const initiative = { id: 'init-1', branch: 'cw/test', status: 'pending_review' }; + const project = { id: 'proj-1', name: 'test', url: 'https://example.com', defaultBranch: 'main' }; + vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative 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', previousRef: 'abc000' }); + return { initiative, project }; + } + + it('should roll back merge when push fails', async () => { + setupApproveTest(mocks); + vi.mocked(mocks.branchManager.pushBranch).mockRejectedValue(new Error('non-fast-forward')); + + const orchestrator = createOrchestrator(mocks); + + await expect(orchestrator.approveInitiative('init-1', 'merge_and_push')).rejects.toThrow('non-fast-forward'); + + // Should have rolled back the merge by restoring the previous ref + expect(mocks.branchManager.updateRef).toHaveBeenCalledWith( + expect.any(String), + 'main', + 'abc000', + ); + + // Should NOT have marked initiative as completed + expect(mocks.initiativeRepository.update).not.toHaveBeenCalled(); + }); + + it('should complete initiative when push succeeds', async () => { + setupApproveTest(mocks); + + const orchestrator = createOrchestrator(mocks); + + await orchestrator.approveInitiative('init-1', 'merge_and_push'); + + expect(mocks.branchManager.updateRef).not.toHaveBeenCalled(); + expect(mocks.initiativeRepository.update).toHaveBeenCalledWith('init-1', { status: 'completed' }); + }); + + it('should not attempt rollback for push_branch strategy', async () => { + setupApproveTest(mocks); + vi.mocked(mocks.branchManager.pushBranch).mockRejectedValue(new Error('auth failed')); + + const orchestrator = createOrchestrator(mocks); + + await expect(orchestrator.approveInitiative('init-1', 'push_branch')).rejects.toThrow('auth failed'); + + // No merge happened, so no rollback needed + expect(mocks.branchManager.updateRef).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/server/execution/orchestrator.ts b/apps/server/execution/orchestrator.ts index dfa0483..5b8c521 100644 --- a/apps/server/execution/orchestrator.ts +++ b/apps/server/execution/orchestrator.ts @@ -11,12 +11,13 @@ * - 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, AgentCrashedEvent, 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'; import type { InitiativeRepository } from '../db/repositories/initiative-repository.js'; import type { ProjectRepository } from '../db/repositories/project-repository.js'; +import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js'; import type { ConflictResolutionService } from '../coordination/conflict-resolution-service.js'; import { phaseBranchName, taskBranchName } from '../git/branch-naming.js'; @@ -25,6 +26,9 @@ import { createModuleLogger } from '../logger/index.js'; const log = createModuleLogger('execution-orchestrator'); +/** Maximum number of automatic retries for crashed tasks before blocking */ +const MAX_TASK_RETRIES = 3; + export class ExecutionOrchestrator { /** Serialize merges per phase to avoid concurrent merge conflicts */ private phaseMergeLocks: Map> = new Map(); @@ -44,6 +48,7 @@ export class ExecutionOrchestrator { private conflictResolutionService: ConflictResolutionService, private eventBus: EventBus, private workspaceRoot: string, + private agentRepository?: AgentRepository, ) {} /** @@ -66,6 +71,18 @@ export class ExecutionOrchestrator { }); }); + // Auto-retry crashed agent tasks (up to MAX_TASK_RETRIES) + this.eventBus.on('agent:crashed', (event) => { + this.handleAgentCrashed(event).catch((err) => { + log.error({ err: err instanceof Error ? err.message : String(err) }, 'error handling agent:crashed'); + }); + }); + + // Recover in-memory dispatch queues from DB state (survives server restarts) + this.recoverDispatchQueues().catch((err) => { + log.error({ err: err instanceof Error ? err.message : String(err) }, 'dispatch queue recovery failed'); + }); + log.info('execution orchestrator started'); } @@ -106,6 +123,27 @@ export class ExecutionOrchestrator { this.scheduleDispatch(); } + private async handleAgentCrashed(event: AgentCrashedEvent): Promise { + const { taskId, agentId, error } = event.payload; + if (!taskId) return; + + const task = await this.taskRepository.findById(taskId); + if (!task || task.status !== 'in_progress') return; + + const retryCount = (task.retryCount ?? 0) + 1; + if (retryCount > MAX_TASK_RETRIES) { + log.warn({ taskId, agentId, retryCount, error }, 'task exceeded max retries, leaving in_progress'); + return; + } + + // Reset task for re-dispatch with incremented retry count + await this.taskRepository.update(taskId, { status: 'pending', retryCount }); + await this.dispatchManager.queue(taskId); + log.info({ taskId, agentId, retryCount, error }, 'crashed task re-queued for retry'); + + this.scheduleDispatch(); + } + private async runDispatchCycle(): Promise { this.dispatchRunning = true; try { @@ -140,27 +178,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/review tasks — they already work on the phase branch directly - if (task.category === 'merge' || task.category === 'review') return; + // 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); - 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; + } catch (err) { + log.error({ taskId, err: err instanceof Error ? err.message : String(err) }, 'task merge failed, still checking phase completion'); + } + } - // 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 + // 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) { @@ -228,12 +268,18 @@ 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) + await this.requeueApprovedPhases(phase.initiativeId); + // Check if this was the last phase — if so, trigger initiative review const dispatched = await this.phaseDispatchManager.dispatchNextPhase(); if (!dispatched.success) { @@ -270,6 +316,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); @@ -305,6 +363,9 @@ export class ExecutionOrchestrator { await this.mergePhaseIntoInitiative(phaseId); await this.phaseDispatchManager.completePhase(phaseId); + // Re-queue approved phases (self-healing: survives server restarts that wipe in-memory queue) + await this.requeueApprovedPhases(phase.initiativeId); + // Check if this was the last phase — if so, trigger initiative review const dispatched = await this.phaseDispatchManager.dispatchNextPhase(); if (!dispatched.success) { @@ -321,7 +382,14 @@ export class ExecutionOrchestrator { */ async requestChangesOnPhase( phaseId: string, - unresolvedComments: Array<{ filePath: string; lineNumber: number; body: string }>, + unresolvedThreads: Array<{ + id: string; + filePath: string; + lineNumber: number; + body: string; + author: string; + replies: Array<{ id: string; body: string; author: string }>; + }>, summary?: string, ): Promise<{ taskId: string }> { const phase = await this.phaseRepository.findById(phaseId); @@ -333,16 +401,25 @@ export class ExecutionOrchestrator { const initiative = await this.initiativeRepository.findById(phase.initiativeId); if (!initiative) throw new Error(`Initiative not found: ${phase.initiativeId}`); - // Build revision task description from comments + summary + // 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 threaded comments + summary const lines: string[] = []; if (summary) { lines.push(`## Summary\n\n${summary}\n`); } - if (unresolvedComments.length > 0) { + if (unresolvedThreads.length > 0) { lines.push('## Review Comments\n'); // Group comments by file - const byFile = new Map(); - for (const c of unresolvedComments) { + const byFile = new Map(); + for (const c of unresolvedThreads) { const arr = byFile.get(c.filePath) ?? []; arr.push(c); byFile.set(c.filePath, arr); @@ -350,9 +427,13 @@ export class ExecutionOrchestrator { for (const [filePath, fileComments] of byFile) { lines.push(`### ${filePath}\n`); for (const c of fileComments) { - lines.push(`- **Line ${c.lineNumber}**: ${c.body}`); + lines.push(`#### Line ${c.lineNumber} [comment:${c.id}]`); + lines.push(`**${c.author}**: ${c.body}`); + for (const r of c.replies) { + lines.push(`> **${r.author}**: ${r.body}`); + } + lines.push(''); } - lines.push(''); } } @@ -382,12 +463,12 @@ export class ExecutionOrchestrator { phaseId, initiativeId: phase.initiativeId, taskId: task.id, - commentCount: unresolvedComments.length, + commentCount: unresolvedThreads.length, }, }; this.eventBus.emit(event); - log.info({ phaseId, taskId: task.id, commentCount: unresolvedComments.length }, 'changes requested on phase'); + log.info({ phaseId, taskId: task.id, commentCount: unresolvedThreads.length }, 'changes requested on phase'); // Kick off dispatch this.scheduleDispatch(); @@ -395,6 +476,157 @@ 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 + * initial queueAllPhases() call. + */ + private async requeueApprovedPhases(initiativeId: string): Promise { + const phases = await this.phaseRepository.findByInitiativeId(initiativeId); + for (const p of phases) { + if (p.status === 'approved') { + try { + await this.phaseDispatchManager.queuePhase(p.id); + log.info({ phaseId: p.id }, 're-queued approved phase'); + } catch { + // Already queued or status changed — safe to ignore + } + } + } + } + + /** + * Recover in-memory dispatch queues from DB state on server startup. + * Re-queues approved phases and pending tasks for in_progress phases. + */ + private async recoverDispatchQueues(): Promise { + const initiatives = await this.initiativeRepository.findByStatus('active'); + let phasesRecovered = 0; + let tasksRecovered = 0; + + for (const initiative of initiatives) { + const phases = await this.phaseRepository.findByInitiativeId(initiative.id); + + for (const phase of phases) { + // Re-queue approved phases into the phase dispatch queue + if (phase.status === 'approved') { + try { + await this.phaseDispatchManager.queuePhase(phase.id); + phasesRecovered++; + } catch { + // Already queued or status changed + } + } + + // Re-queue pending tasks and recover stuck in_progress tasks for in_progress phases + if (phase.status === 'in_progress') { + const tasks = await this.taskRepository.findByPhaseId(phase.id); + for (const task of tasks) { + if (task.status === 'pending') { + try { + await this.dispatchManager.queue(task.id); + tasksRecovered++; + } catch { + // Already queued or task issue + } + } else if (task.status === 'in_progress' && this.agentRepository) { + // Check if the assigned agent is still alive + const agent = await this.agentRepository.findByTaskId(task.id); + const isAlive = agent && (agent.status === 'running' || agent.status === 'waiting_for_input'); + if (!isAlive) { + // Agent is dead — reset task for re-dispatch + await this.taskRepository.update(task.id, { status: 'pending' }); + await this.dispatchManager.queue(task.id); + tasksRecovered++; + log.info({ taskId: task.id, agentId: agent?.id }, 'recovered stuck in_progress task (dead agent)'); + } + } + } + } + } + } + + if (phasesRecovered > 0 || tasksRecovered > 0) { + log.info({ phasesRecovered, tasksRecovered }, 'recovered dispatch queues from DB state'); + this.scheduleDispatch(); + } + } + /** * Check if all phases for an initiative are completed. * If so, set initiative to pending_review and emit event. @@ -449,12 +681,32 @@ 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}`); } - await this.branchManager.pushBranch(clonePath, project.defaultBranch); + try { + await this.branchManager.pushBranch(clonePath, project.defaultBranch); + } catch (pushErr) { + // Roll back the merge so the diff doesn't disappear from the review tab. + // Without rollback, defaultBranch includes the initiative changes and the + // three-dot diff (defaultBranch...initiativeBranch) becomes empty. + if (result.previousRef) { + log.warn({ project: project.name, previousRef: result.previousRef }, 'push failed — rolling back merge'); + await this.branchManager.updateRef(clonePath, project.defaultBranch, result.previousRef); + } + throw pushErr; + } log.info({ initiativeId, project: project.name }, 'initiative branch merged into default and pushed'); } else { await this.branchManager.pushBranch(clonePath, initiative.branch); diff --git a/apps/server/git/branch-manager.ts b/apps/server/git/branch-manager.ts index f5d9b54..9ba6d85 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 { /** @@ -57,9 +57,41 @@ 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. */ 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; + + /** + * 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; + + /** + * Force-update a branch ref to point at a specific commit. + * Used to roll back a merge when a subsequent push fails. + */ + updateRef(repoPath: string, branch: string, commitHash: 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/manager.ts b/apps/server/git/manager.ts index 258bc46..95539af 100644 --- a/apps/server/git/manager.ts +++ b/apps/server/git/manager.ts @@ -61,16 +61,35 @@ export class SimpleGitWorktreeManager implements WorktreeManager { const worktreePath = path.join(this.worktreesDir, id); log.info({ id, branch, baseBranch }, 'creating worktree'); - // Create worktree with new branch - // git worktree add -b - await this.git.raw([ - 'worktree', - 'add', - '-b', - branch, - worktreePath, - baseBranch, - ]); + // Safety: never force-reset a branch to its own base — this would nuke + // shared branches like the initiative branch if passed as both branch and baseBranch. + if (branch === baseBranch) { + throw new Error(`Worktree branch and baseBranch are the same (${branch}). Use a unique branch name.`); + } + + // Create worktree — reuse existing branch or create new one + const branchExists = await this.branchExists(branch); + if (branchExists) { + // Branch exists from a previous run. Check if it has commits beyond baseBranch + // before resetting — a previous agent may have done real work on this branch. + try { + const aheadCount = await this.git.raw(['rev-list', '--count', `${baseBranch}..${branch}`]); + if (parseInt(aheadCount.trim(), 10) > 0) { + log.warn({ branch, baseBranch, aheadBy: aheadCount.trim() }, 'branch has commits beyond base, preserving'); + } else { + await this.git.raw(['branch', '-f', branch, baseBranch]); + } + } catch { + // If rev-list fails (e.g. baseBranch doesn't exist yet), fall back to reset + await this.git.raw(['branch', '-f', branch, baseBranch]); + } + // Prune stale worktree references before adding new one + await this.git.raw(['worktree', 'prune']); + await this.git.raw(['worktree', 'add', worktreePath, branch]); + } else { + // git worktree add -b + await this.git.raw(['worktree', 'add', '-b', branch, worktreePath, baseBranch]); + } const worktree: Worktree = { id, @@ -327,6 +346,18 @@ export class SimpleGitWorktreeManager implements WorktreeManager { return worktrees; } + /** + * Check if a local branch exists in the repository. + */ + private async branchExists(branch: string): Promise { + try { + await this.git.raw(['rev-parse', '--verify', `refs/heads/${branch}`]); + return true; + } catch { + return false; + } + } + /** * Parse the output of git diff --name-status. */ diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index bee747a..47b690e 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -6,12 +6,12 @@ * on project clones without requiring a worktree. */ -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; 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'); @@ -31,21 +31,32 @@ export class SimpleGitBranchManager implements BranchManager { } async mergeBranch(repoPath: string, sourceBranch: string, targetBranch: string): Promise { - // Use an ephemeral worktree for merge safety + // Use an ephemeral worktree with a temp branch for merge safety. + // We can't check out targetBranch directly — it may already be checked out + // in the clone's main working tree or an agent worktree. const tmpPath = mkdtempSync(join(tmpdir(), 'cw-merge-')); const repoGit = simpleGit(repoPath); + const tempBranch = `cw-merge-${Date.now()}`; try { - // Create ephemeral worktree on target branch - await repoGit.raw(['worktree', 'add', tmpPath, targetBranch]); + // Capture the target branch ref before merge so callers can roll back on push failure + const previousRef = (await repoGit.raw(['rev-parse', targetBranch])).trim(); + + // Create worktree with a temp branch starting at targetBranch's commit + await repoGit.raw(['worktree', 'add', '-b', tempBranch, tmpPath, targetBranch]); const wtGit = simpleGit(tmpPath); try { await wtGit.merge([sourceBranch, '--no-edit']); + // Update the real target branch ref to the merge result. + // update-ref bypasses the "branch is checked out" guard. + const mergeCommit = (await wtGit.revparse(['HEAD'])).trim(); + await repoGit.raw(['update-ref', `refs/heads/${targetBranch}`, mergeCommit]); + log.info({ repoPath, sourceBranch, targetBranch }, 'merge completed cleanly'); - return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}` }; + return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}`, previousRef }; } catch (mergeErr) { // Check for merge conflicts const status = await wtGit.status(); @@ -73,6 +84,10 @@ export class SimpleGitBranchManager implements BranchManager { try { rmSync(tmpPath, { recursive: true, force: true }); } catch { /* ignore */ } try { await repoGit.raw(['worktree', 'prune']); } catch { /* ignore */ } } + // Delete the temp branch + try { + await repoGit.raw(['branch', '-D', tempBranch]); + } catch { /* ignore — may already be cleaned up */ } } } @@ -141,9 +156,95 @@ 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); + try { + await git.push(remote, branch); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes('branch is currently checked out')) throw err; + + // Local non-bare repo with the branch checked out — temporarily allow it. + // receive.denyCurrentBranch=updateInstead updates the remote's working tree + // and index to match, or rejects if the working tree is dirty. + const remoteUrl = (await git.remote(['get-url', remote]))?.trim(); + if (!remoteUrl) throw err; + const remotePath = resolve(repoPath, remoteUrl); + const remoteGit = simpleGit(remotePath); + await remoteGit.addConfig('receive.denyCurrentBranch', 'updateInstead'); + try { + await git.push(remote, branch); + } finally { + await remoteGit.raw(['config', '--unset', 'receive.denyCurrentBranch']); + } + } log.info({ repoPath, branch, remote }, 'branch pushed to remote'); } + + async checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise { + const git = simpleGit(repoPath); + + // 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(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 }; + } + + 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}`; + + // Verify it's a genuine fast-forward (branch is ancestor of remote) + try { + await git.raw(['merge-base', '--is-ancestor', branch, remoteBranch]); + } catch { + throw new Error(`Cannot fast-forward ${branch}: it has diverged from ${remoteBranch}`); + } + + // Use update-ref instead of git merge so dirty working trees don't block it. + // The clone may have uncommitted agent work; we only need to advance the ref. + const targetCommit = (await git.raw(['rev-parse', remoteBranch])).trim(); + await git.raw(['update-ref', `refs/heads/${branch}`, targetCommit]); + log.info({ repoPath, branch, remoteBranch }, 'fast-forwarded branch'); + } + + async updateRef(repoPath: string, branch: string, commitHash: string): Promise { + const git = simpleGit(repoPath); + await git.raw(['update-ref', `refs/heads/${branch}`, commitHash]); + log.info({ repoPath, branch, commitHash: commitHash.slice(0, 7) }, 'branch ref updated'); + } } diff --git a/apps/server/git/types.ts b/apps/server/git/types.ts index 17d56ae..51a35b7 100644 --- a/apps/server/git/types.ts +++ b/apps/server/git/types.ts @@ -56,6 +56,21 @@ export interface MergeResult { conflicts?: string[]; /** Human-readable message describing the result */ message: string; + /** The target branch's commit hash before the merge (for rollback on push failure) */ + previousRef?: 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[]; } // ============================================================================= diff --git a/apps/server/preview/compose-generator.test.ts b/apps/server/preview/compose-generator.test.ts index 62448f0..234d62b 100644 --- a/apps/server/preview/compose-generator.test.ts +++ b/apps/server/preview/compose-generator.test.ts @@ -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('abc123.localhost:80 {'); + expect(caddyfile).toContain('handle /* {'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-app:3000'); }); @@ -176,13 +177,14 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); + 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 {'); + expect(caddyfile).toContain('handle /* {'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-frontend:3000'); }); - it('generates multi-preview Caddyfile with separate subdomain blocks', () => { + it('generates separate subdomain blocks for each preview', () => { const previews = new Map(); previews.set('abc', [ { containerName: 'cw-preview-abc-app', port: 3000, route: '/' }, @@ -192,8 +194,8 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - expect(caddyfile).toContain('abc.localhost:9100 {'); - expect(caddyfile).toContain('xyz.localhost:9100 {'); + 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,10 +211,10 @@ 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 rootIdx = caddyfile.indexOf('handle /* {'); 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..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 - * subdomain 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,18 +195,20 @@ export class GatewayManager { /** * Generate a Caddyfile for the gateway from all active preview routes. * - * Each preview gets a subdomain block: `.localhost:` + * 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( 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', '}', - '', ]; for (const [previewId, routes] of previews) { @@ -217,11 +219,12 @@ export function generateGatewayCaddyfile( return b.route.length - a.route.length; }); - lines.push(`${previewId}.localhost:${port} {`); + lines.push(''); + lines.push(`${previewId}.localhost:80 {`); for (const route of sorted) { if (route.route === '/') { - lines.push(` handle {`); + lines.push(` handle /* {`); lines.push(` reverse_proxy ${route.containerName}:${route.port}`); lines.push(` }`); } else { @@ -233,8 +236,9 @@ export function generateGatewayCaddyfile( } 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..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 subdomain routing + * Polls service healthcheck endpoints through the gateway's subdomain-based routing * to verify that preview services are ready. */ diff --git a/apps/server/preview/manager.test.ts b/apps/server/preview/manager.test.ts index 14dc0cd..4f60cfc 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'; @@ -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://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://abc123test.localhost:9100', + '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://aaa.localhost:9100'); + 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://abc.localhost:9100'); + 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 418e9ba..2b04cc3 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 }); @@ -238,7 +239,7 @@ export class PreviewManager { await this.runSeeds(projectName, config); // 11. Success - const url = `http://${id}.localhost:${gatewayPort}`; + const url = `http://${id}.localhost:${gatewayPort}/`; log.info({ id, url }, 'preview deployment ready'); this.eventBus.emit({ @@ -604,7 +605,7 @@ export class PreviewManager { projectId, branch, gatewayPort, - url: `http://${previewId}.localhost:${gatewayPort}`, + url: `http://${previewId}.localhost:${gatewayPort}/`, mode, status: 'running', services: [], diff --git a/apps/server/test/e2e/decompose-workflow.test.ts b/apps/server/test/e2e/decompose-workflow.test.ts index 8598945..6ff5fe6 100644 --- a/apps/server/test/e2e/decompose-workflow.test.ts +++ b/apps/server/test/e2e/decompose-workflow.test.ts @@ -143,7 +143,7 @@ describe('Detail Workflow E2E', () => { harness.setArchitectDetailComplete('detailer', [ { number: 1, name: 'Task 1', content: 'First task', type: 'auto', dependencies: [] }, { number: 2, name: 'Task 2', content: 'Second task', type: 'auto', dependencies: [1] }, - { number: 3, name: 'Verify', content: 'Verify all', type: 'checkpoint:human-verify', dependencies: [2] }, + { number: 3, name: 'Verify', content: 'Verify all', type: 'auto', dependencies: [2] }, ]); // Resume with all answers @@ -261,7 +261,7 @@ describe('Detail Workflow E2E', () => { tasks: [ { number: 1, name: 'Schema', description: 'Create tables', type: 'auto', dependencies: [] }, { number: 2, name: 'API', description: 'Create endpoints', type: 'auto', dependencies: [1] }, - { number: 3, name: 'Verify', description: 'Test flow', type: 'checkpoint:human-verify', dependencies: [2] }, + { number: 3, name: 'Verify', description: 'Test flow', type: 'auto', dependencies: [2] }, ], }); @@ -271,33 +271,31 @@ describe('Detail Workflow E2E', () => { expect(tasks[0].name).toBe('Schema'); expect(tasks[1].name).toBe('API'); expect(tasks[2].name).toBe('Verify'); - expect(tasks[2].type).toBe('checkpoint:human-verify'); + expect(tasks[2].type).toBe('auto'); }); - it('should handle all task types', async () => { + it('should create tasks with auto type', async () => { const initiative = await harness.createInitiative('Task Types Test'); const phases = await harness.createPhasesFromPlan(initiative.id, [ { name: 'Phase 1' }, ]); const detailTask = await harness.createDetailTask(phases[0].id, 'Mixed Tasks'); - // Create tasks with all types await harness.caller.createChildTasks({ parentTaskId: detailTask.id, tasks: [ { number: 1, name: 'Auto Task', description: 'Automated work', type: 'auto' }, - { number: 2, name: 'Human Verify', description: 'Visual check', type: 'checkpoint:human-verify', dependencies: [1] }, - { number: 3, name: 'Decision', description: 'Choose approach', type: 'checkpoint:decision', dependencies: [2] }, - { number: 4, name: 'Human Action', description: 'Manual step', type: 'checkpoint:human-action', dependencies: [3] }, + { number: 2, name: 'Second Task', description: 'More work', type: 'auto', dependencies: [1] }, + { number: 3, name: 'Third Task', description: 'Even more', type: 'auto', dependencies: [2] }, + { number: 4, name: 'Final Task', description: 'Last step', type: 'auto', dependencies: [3] }, ], }); const tasks = await harness.getChildTasks(detailTask.id); expect(tasks).toHaveLength(4); - expect(tasks[0].type).toBe('auto'); - expect(tasks[1].type).toBe('checkpoint:human-verify'); - expect(tasks[2].type).toBe('checkpoint:decision'); - expect(tasks[3].type).toBe('checkpoint:human-action'); + for (const task of tasks) { + expect(task.type).toBe('auto'); + } }); it('should create task dependencies', async () => { @@ -346,7 +344,7 @@ describe('Detail Workflow E2E', () => { { number: 1, name: 'Create user schema', content: 'Define User model', type: 'auto', dependencies: [] }, { number: 2, name: 'Implement JWT', content: 'Token generation', type: 'auto', dependencies: [1] }, { number: 3, name: 'Protected routes', content: 'Middleware', type: 'auto', dependencies: [2] }, - { number: 4, name: 'Verify auth', content: 'Test login flow', type: 'checkpoint:human-verify', dependencies: [3] }, + { number: 4, name: 'Verify auth', content: 'Test login flow', type: 'auto', dependencies: [3] }, ]); await harness.caller.spawnArchitectDetail({ @@ -367,7 +365,7 @@ describe('Detail Workflow E2E', () => { { number: 1, name: 'Create user schema', description: 'Define User model', type: 'auto', dependencies: [] }, { number: 2, name: 'Implement JWT', description: 'Token generation', type: 'auto', dependencies: [1] }, { number: 3, name: 'Protected routes', description: 'Middleware', type: 'auto', dependencies: [2] }, - { number: 4, name: 'Verify auth', description: 'Test login flow', type: 'checkpoint:human-verify', dependencies: [3] }, + { number: 4, name: 'Verify auth', description: 'Test login flow', type: 'auto', dependencies: [3] }, ], }); @@ -375,7 +373,7 @@ describe('Detail Workflow E2E', () => { const tasks = await harness.getChildTasks(detailTask.id); expect(tasks).toHaveLength(4); expect(tasks[0].name).toBe('Create user schema'); - expect(tasks[3].type).toBe('checkpoint:human-verify'); + expect(tasks[3].type).toBe('auto'); // Agent should be idle const finalAgent = await harness.caller.getAgent({ name: 'detailer' }); diff --git a/apps/server/test/integration/crash-race-condition.test.ts b/apps/server/test/integration/crash-race-condition.test.ts index f7ce25f..4af02a1 100644 --- a/apps/server/test/integration/crash-race-condition.test.ts +++ b/apps/server/test/integration/crash-race-condition.test.ts @@ -32,6 +32,7 @@ interface TestAgent { initiativeId: string | null; userDismissedAt: Date | null; exitCode: number | null; + prompt: string | null; } describe('Crash marking race condition', () => { @@ -72,7 +73,8 @@ describe('Crash marking race condition', () => { pendingQuestions: null, initiativeId: 'init-1', userDismissedAt: null, - exitCode: null + exitCode: null, + prompt: null, }; // Mock repository that tracks all update calls diff --git a/apps/server/trpc/router.test.ts b/apps/server/trpc/router.test.ts index eb64e50..2dfa4d1 100644 --- a/apps/server/trpc/router.test.ts +++ b/apps/server/trpc/router.test.ts @@ -164,66 +164,71 @@ describe('tRPC Router', () => { describe('addAccountByToken procedure', () => { let mockRepo: AccountRepository; - let caller: ReturnType; beforeEach(() => { - vi.resetAllMocks(); mockRepo = { findByEmail: vi.fn(), updateAccountAuth: vi.fn(), create: vi.fn(), } as unknown as AccountRepository; - const ctx = createTestContext({ accountRepository: mockRepo }); - caller = createCaller(ctx); + vi.clearAllMocks(); }); it('creates a new account and returns { upserted: false, account }', async () => { - const stubAccount = { id: 'acc-1', email: 'new@example.com', provider: 'claude' }; - vi.mocked(mockRepo.findByEmail).mockResolvedValue(null); - vi.mocked(mockRepo.create).mockResolvedValue(stubAccount as any); + const stubAccount = { id: 'new-id', email: 'user@example.com', provider: 'claude' }; + (mockRepo.findByEmail as ReturnType).mockResolvedValue(null); + (mockRepo.create as ReturnType).mockResolvedValue(stubAccount); - const result = await caller.addAccountByToken({ email: 'new@example.com', token: 'tok-abc' }); + const testCtx = createTestContext({ accountRepository: mockRepo }); + const testCaller = createCaller(testCtx); + const result = await testCaller.addAccountByToken({ email: 'user@example.com', token: 'my-token' }); expect(result).toEqual({ upserted: false, account: stubAccount }); expect(mockRepo.create).toHaveBeenCalledWith({ - email: 'new@example.com', + email: 'user@example.com', provider: 'claude', configJson: '{"hasCompletedOnboarding":true}', - credentials: '{"claudeAiOauth":{"accessToken":"tok-abc"}}', + credentials: '{"claudeAiOauth":{"accessToken":"my-token"}}', }); expect(mockRepo.updateAccountAuth).not.toHaveBeenCalled(); }); - it('updates an existing account and returns { upserted: true, account }', async () => { - const existingAccount = { id: 'acc-existing', email: 'existing@example.com', provider: 'claude' }; - const updatedAccount = { ...existingAccount, configJson: '{"hasCompletedOnboarding":true}' }; - vi.mocked(mockRepo.findByEmail).mockResolvedValue(existingAccount as any); - vi.mocked(mockRepo.updateAccountAuth).mockResolvedValue(updatedAccount as any); + it('updates existing account and returns { upserted: true, account }', async () => { + const existingAccount = { id: 'existing-id', email: 'user@example.com', provider: 'claude' }; + const updatedAccount = { id: 'existing-id', email: 'user@example.com', provider: 'claude', updated: true }; + (mockRepo.findByEmail as ReturnType).mockResolvedValue(existingAccount); + (mockRepo.updateAccountAuth as ReturnType).mockResolvedValue(updatedAccount); - const result = await caller.addAccountByToken({ email: 'existing@example.com', token: 'tok-xyz' }); + const testCtx = createTestContext({ accountRepository: mockRepo }); + const testCaller = createCaller(testCtx); + const result = await testCaller.addAccountByToken({ email: 'user@example.com', token: 'my-token' }); expect(result).toEqual({ upserted: true, account: updatedAccount }); expect(mockRepo.updateAccountAuth).toHaveBeenCalledWith( - 'acc-existing', + 'existing-id', '{"hasCompletedOnboarding":true}', - '{"claudeAiOauth":{"accessToken":"tok-xyz"}}', + '{"claudeAiOauth":{"accessToken":"my-token"}}', ); expect(mockRepo.create).not.toHaveBeenCalled(); }); it('throws BAD_REQUEST when email is empty', async () => { - await expect( - caller.addAccountByToken({ email: '', provider: 'claude', token: 'tok' }), - ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + const testCtx = createTestContext({ accountRepository: mockRepo }); + const testCaller = createCaller(testCtx); + + await expect(testCaller.addAccountByToken({ email: '', provider: 'claude', token: 'tok' })) + .rejects.toMatchObject({ code: 'BAD_REQUEST' }); expect(mockRepo.findByEmail).not.toHaveBeenCalled(); expect(mockRepo.create).not.toHaveBeenCalled(); expect(mockRepo.updateAccountAuth).not.toHaveBeenCalled(); }); it('throws BAD_REQUEST when token is empty', async () => { - await expect( - caller.addAccountByToken({ email: 'user@example.com', provider: 'claude', token: '' }), - ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + const testCtx = createTestContext({ accountRepository: mockRepo }); + const testCaller = createCaller(testCtx); + + await expect(testCaller.addAccountByToken({ email: 'user@example.com', provider: 'claude', token: '' })) + .rejects.toMatchObject({ code: 'BAD_REQUEST' }); expect(mockRepo.findByEmail).not.toHaveBeenCalled(); expect(mockRepo.create).not.toHaveBeenCalled(); expect(mockRepo.updateAccountAuth).not.toHaveBeenCalled(); diff --git a/apps/server/trpc/routers/agent.test.ts b/apps/server/trpc/routers/agent.test.ts new file mode 100644 index 0000000..21bcc6d --- /dev/null +++ b/apps/server/trpc/routers/agent.test.ts @@ -0,0 +1,327 @@ +/** + * Agent Router Tests + * + * Tests for getAgent (exitCode, taskName, initiativeName), + * getAgentInputFiles, and getAgentPrompt procedures. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { appRouter, createCallerFactory } from '../index.js'; +import type { TRPCContext } from '../context.js'; +import type { EventBus } from '../../events/types.js'; + +const createCaller = createCallerFactory(appRouter); + +function createMockEventBus(): EventBus { + return { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + }; +} + +function createTestContext(overrides: Partial = {}): TRPCContext { + return { + eventBus: createMockEventBus(), + serverStartedAt: new Date('2026-01-30T12:00:00Z'), + processCount: 0, + ...overrides, + }; +} + +/** Minimal AgentInfo fixture matching the full interface */ +function makeAgentInfo(overrides: Record = {}) { + return { + id: 'agent-1', + name: 'test-agent', + taskId: null, + initiativeId: null, + sessionId: null, + worktreeId: 'test-agent', + status: 'stopped' as const, + mode: 'execute' as const, + provider: 'claude', + accountId: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + userDismissedAt: null, + exitCode: null, + prompt: null, + ...overrides, + }; +} + +describe('getAgent', () => { + it('returns exitCode: 1 when agent has exitCode 1', async () => { + const mockManager = { + get: vi.fn().mockResolvedValue(makeAgentInfo({ exitCode: 1 })), + }; + + const ctx = createTestContext({ agentManager: mockManager as any }); + const caller = createCaller(ctx); + const result = await caller.getAgent({ id: 'agent-1' }); + + expect(result.exitCode).toBe(1); + }); + + it('returns exitCode: null when agent has no exitCode', async () => { + const mockManager = { + get: vi.fn().mockResolvedValue(makeAgentInfo({ exitCode: null })), + }; + + const ctx = createTestContext({ agentManager: mockManager as any }); + const caller = createCaller(ctx); + const result = await caller.getAgent({ id: 'agent-1' }); + + expect(result.exitCode).toBeNull(); + }); + + it('returns taskName and initiativeName from repositories', async () => { + const mockManager = { + get: vi.fn().mockResolvedValue(makeAgentInfo({ taskId: 'task-1', initiativeId: 'init-1' })), + }; + const mockTaskRepo = { + findById: vi.fn().mockResolvedValue({ id: 'task-1', name: 'My Task' }), + }; + const mockInitiativeRepo = { + findById: vi.fn().mockResolvedValue({ id: 'init-1', name: 'My Initiative' }), + }; + + const ctx = createTestContext({ + agentManager: mockManager as any, + taskRepository: mockTaskRepo as any, + initiativeRepository: mockInitiativeRepo as any, + }); + const caller = createCaller(ctx); + const result = await caller.getAgent({ id: 'agent-1' }); + + expect(result.taskName).toBe('My Task'); + expect(result.initiativeName).toBe('My Initiative'); + }); + + it('returns taskName: null and initiativeName: null when agent has no taskId or initiativeId', async () => { + const mockManager = { + get: vi.fn().mockResolvedValue(makeAgentInfo({ taskId: null, initiativeId: null })), + }; + + const ctx = createTestContext({ agentManager: mockManager as any }); + const caller = createCaller(ctx); + const result = await caller.getAgent({ id: 'agent-1' }); + + expect(result.taskName).toBeNull(); + expect(result.initiativeName).toBeNull(); + }); +}); + +describe('getAgentInputFiles', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + function makeAgentManagerWithWorktree(worktreeId = 'test-worktree', agentName = 'test-agent') { + return { + get: vi.fn().mockResolvedValue(makeAgentInfo({ worktreeId, name: agentName })), + }; + } + + it('returns worktree_missing when worktree dir does not exist', async () => { + const nonExistentRoot = path.join(tmpDir, 'no-such-dir'); + const mockManager = makeAgentManagerWithWorktree('test-worktree'); + + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: nonExistentRoot, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentInputFiles({ id: 'agent-1' }); + + expect(result).toEqual({ files: [], reason: 'worktree_missing' }); + }); + + it('returns input_dir_missing when worktree exists but .cw/input does not', async () => { + const worktreeId = 'test-worktree'; + const worktreeRoot = path.join(tmpDir, 'agent-workdirs', worktreeId); + await fs.mkdir(worktreeRoot, { recursive: true }); + + const mockManager = makeAgentManagerWithWorktree(worktreeId); + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: tmpDir, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentInputFiles({ id: 'agent-1' }); + + expect(result).toEqual({ files: [], reason: 'input_dir_missing' }); + }); + + it('returns sorted file list with correct name, content, sizeBytes', async () => { + const worktreeId = 'test-worktree'; + const inputDir = path.join(tmpDir, 'agent-workdirs', worktreeId, '.cw', 'input'); + await fs.mkdir(inputDir, { recursive: true }); + await fs.mkdir(path.join(inputDir, 'pages'), { recursive: true }); + + const manifestContent = '{"files": ["a"]}'; + const fooContent = '# Foo\nHello world'; + await fs.writeFile(path.join(inputDir, 'manifest.json'), manifestContent); + await fs.writeFile(path.join(inputDir, 'pages', 'foo.md'), fooContent); + + const mockManager = makeAgentManagerWithWorktree(worktreeId); + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: tmpDir, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentInputFiles({ id: 'agent-1' }); + + expect(result.reason).toBeUndefined(); + expect(result.files).toHaveLength(2); + // Sorted alphabetically: manifest.json before pages/foo.md + expect(result.files[0].name).toBe('manifest.json'); + expect(result.files[0].content).toBe(manifestContent); + expect(result.files[0].sizeBytes).toBe(Buffer.byteLength(manifestContent)); + expect(result.files[1].name).toBe(path.join('pages', 'foo.md')); + expect(result.files[1].content).toBe(fooContent); + expect(result.files[1].sizeBytes).toBe(Buffer.byteLength(fooContent)); + }); + + it('skips binary files (containing null byte)', async () => { + const worktreeId = 'test-worktree'; + const inputDir = path.join(tmpDir, 'agent-workdirs', worktreeId, '.cw', 'input'); + await fs.mkdir(inputDir, { recursive: true }); + + // Binary file with null byte + const binaryData = Buffer.from([0x89, 0x50, 0x00, 0x4e, 0x47]); + await fs.writeFile(path.join(inputDir, 'image.png'), binaryData); + // Text file should still be returned + await fs.writeFile(path.join(inputDir, 'text.txt'), 'hello'); + + const mockManager = makeAgentManagerWithWorktree(worktreeId); + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: tmpDir, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentInputFiles({ id: 'agent-1' }); + + expect(result.files).toHaveLength(1); + expect(result.files[0].name).toBe('text.txt'); + }); + + it('truncates files larger than 500 KB and preserves original sizeBytes', async () => { + const worktreeId = 'test-worktree'; + const inputDir = path.join(tmpDir, 'agent-workdirs', worktreeId, '.cw', 'input'); + await fs.mkdir(inputDir, { recursive: true }); + + const MAX_SIZE = 500 * 1024; + const largeContent = Buffer.alloc(MAX_SIZE + 100 * 1024, 'a'); // 600 KB + await fs.writeFile(path.join(inputDir, 'big.txt'), largeContent); + + const mockManager = makeAgentManagerWithWorktree(worktreeId); + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: tmpDir, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentInputFiles({ id: 'agent-1' }); + + expect(result.files).toHaveLength(1); + expect(result.files[0].sizeBytes).toBe(largeContent.length); + expect(result.files[0].content).toContain('[truncated — file exceeds 500 KB]'); + }); +}); + +describe('getAgentPrompt', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-prompt-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns prompt_not_written when PROMPT.md does not exist', async () => { + const mockManager = { + get: vi.fn().mockResolvedValue(makeAgentInfo({ name: 'test-agent' })), + }; + + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: tmpDir, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentPrompt({ id: 'agent-1' }); + + expect(result).toEqual({ content: null, reason: 'prompt_not_written' }); + }); + + it('returns prompt content when PROMPT.md exists', async () => { + const agentName = 'test-agent'; + const promptDir = path.join(tmpDir, '.cw', 'agent-logs', agentName); + await fs.mkdir(promptDir, { recursive: true }); + const promptContent = '# System\nHello'; + await fs.writeFile(path.join(promptDir, 'PROMPT.md'), promptContent); + + const mockManager = { + get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName, prompt: null })), + }; + + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: tmpDir, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentPrompt({ id: 'agent-1' }); + + expect(result).toEqual({ content: promptContent }); + }); + + it('returns prompt from DB when agent.prompt is set (no file needed)', async () => { + const dbPromptContent = '# DB Prompt\nThis is persisted in the database'; + const mockManager = { + get: vi.fn().mockResolvedValue(makeAgentInfo({ name: 'test-agent', prompt: dbPromptContent })), + }; + + // workspaceRoot has no PROMPT.md — but DB value takes precedence + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: tmpDir, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentPrompt({ id: 'agent-1' }); + + expect(result).toEqual({ content: dbPromptContent }); + }); + + it('falls back to PROMPT.md when agent.prompt is null in DB', async () => { + const agentName = 'test-agent'; + const promptDir = path.join(tmpDir, '.cw', 'agent-logs', agentName); + await fs.mkdir(promptDir, { recursive: true }); + const fileContent = '# File Prompt\nThis is from the file (legacy)'; + await fs.writeFile(path.join(promptDir, 'PROMPT.md'), fileContent); + + const mockManager = { + get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName, prompt: null })), + }; + + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: tmpDir, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentPrompt({ id: 'agent-1' }); + + expect(result).toEqual({ content: fileContent }); + }); +}); diff --git a/apps/server/trpc/routers/agent.ts b/apps/server/trpc/routers/agent.ts index 74fdc50..bdf6395 100644 --- a/apps/server/trpc/routers/agent.ts +++ b/apps/server/trpc/routers/agent.ts @@ -5,11 +5,13 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { tracked, type TrackedEnvelope } from '@trpc/server'; +import path from 'path'; +import fs from 'fs/promises'; import type { ProcedureBuilder } from '../trpc.js'; import type { TRPCContext } from '../context.js'; import type { AgentInfo, AgentResult, PendingQuestions } from '../../agent/types.js'; import type { AgentOutputEvent } from '../../events/types.js'; -import { requireAgentManager, requireLogChunkRepository } from './_helpers.js'; +import { requireAgentManager, requireLogChunkRepository, requireTaskRepository, requireInitiativeRepository } from './_helpers.js'; export const spawnAgentInputSchema = z.object({ name: z.string().min(1).optional(), @@ -120,7 +122,23 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { getAgent: publicProcedure .input(agentIdentifierSchema) .query(async ({ ctx, input }) => { - return resolveAgent(ctx, input); + const agent = await resolveAgent(ctx, input); + + let taskName: string | null = null; + let initiativeName: string | null = null; + + if (agent.taskId) { + const taskRepo = requireTaskRepository(ctx); + const task = await taskRepo.findById(agent.taskId); + taskName = task?.name ?? null; + } + if (agent.initiativeId) { + const initiativeRepo = requireInitiativeRepository(ctx); + const initiative = await initiativeRepo.findById(agent.initiativeId); + initiativeName = initiative?.name ?? null; + } + + return { ...agent, taskName, initiativeName }; }), getAgentByName: publicProcedure @@ -184,14 +202,49 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { return candidates[0] ?? null; }), + getTaskAgent: publicProcedure + .input(z.object({ taskId: z.string().min(1) })) + .query(async ({ ctx, input }): Promise => { + const agentManager = requireAgentManager(ctx); + const all = await agentManager.list(); + const matches = all + .filter(a => a.taskId === input.taskId) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + return matches[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 => { + .query(async ({ ctx, input }) => { const agent = await resolveAgent(ctx, input); const logChunkRepo = requireLogChunkRepository(ctx); const chunks = await logChunkRepo.findByAgentId(agent.id); - return chunks.map(c => c.content).join(''); + return chunks.map(c => ({ + content: c.content, + createdAt: c.createdAt.toISOString(), + })); }), onAgentOutput: publicProcedure @@ -246,5 +299,116 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { cleanup(); } }), + + getAgentInputFiles: publicProcedure + .input(z.object({ id: z.string().min(1) })) + .output(z.object({ + files: z.array(z.object({ + name: z.string(), + content: z.string(), + sizeBytes: z.number(), + })), + reason: z.enum(['worktree_missing', 'input_dir_missing']).optional(), + })) + .query(async ({ ctx, input }) => { + const agent = await resolveAgent(ctx, { id: input.id }); + + const worktreeRoot = path.join(ctx.workspaceRoot!, 'agent-workdirs', agent.worktreeId); + const inputDir = path.join(worktreeRoot, '.cw', 'input'); + + // Check worktree root exists + try { + await fs.stat(worktreeRoot); + } catch { + return { files: [], reason: 'worktree_missing' as const }; + } + + // Check input dir exists + try { + await fs.stat(inputDir); + } catch { + return { files: [], reason: 'input_dir_missing' as const }; + } + + // Walk inputDir recursively + const entries = await fs.readdir(inputDir, { recursive: true, withFileTypes: true }); + const MAX_SIZE = 500 * 1024; + const results: Array<{ name: string; content: string; sizeBytes: number }> = []; + + for (const entry of entries) { + if (!entry.isFile()) continue; + // entry.parentPath is available in Node 20+ + const dir = (entry as any).parentPath ?? (entry as any).path; + const fullPath = path.join(dir, entry.name); + const relativeName = path.relative(inputDir, fullPath); + + try { + // Binary detection: read first 512 bytes + const fd = await fs.open(fullPath, 'r'); + const headerBuf = Buffer.alloc(512); + const { bytesRead } = await fd.read(headerBuf, 0, 512, 0); + await fd.close(); + if (headerBuf.slice(0, bytesRead).includes(0)) continue; // skip binary + + const raw = await fs.readFile(fullPath); + const sizeBytes = raw.length; + let content: string; + if (sizeBytes > MAX_SIZE) { + content = raw.slice(0, MAX_SIZE).toString('utf-8') + '\n\n[truncated — file exceeds 500 KB]'; + } else { + content = raw.toString('utf-8'); + } + results.push({ name: relativeName, content, sizeBytes }); + } catch { + continue; // skip unreadable files + } + } + + results.sort((a, b) => a.name.localeCompare(b.name)); + return { files: results }; + }), + + getAgentPrompt: publicProcedure + .input(z.object({ id: z.string().min(1) })) + .output(z.object({ + content: z.string().nullable(), + reason: z.enum(['prompt_not_written']).optional(), + })) + .query(async ({ ctx, input }) => { + const agent = await resolveAgent(ctx, { id: input.id }); + + const MAX_BYTES = 1024 * 1024; // 1 MB + + function truncateIfNeeded(text: string): string { + if (Buffer.byteLength(text, 'utf-8') > MAX_BYTES) { + const buf = Buffer.from(text, 'utf-8'); + return buf.slice(0, MAX_BYTES).toString('utf-8') + '\n\n[truncated — prompt exceeds 1 MB]'; + } + return text; + } + + // Prefer DB-persisted prompt (durable even after log file cleanup) + if (agent.prompt !== null) { + return { content: truncateIfNeeded(agent.prompt) }; + } + + // Fall back to filesystem for agents spawned before DB persistence was added + const promptPath = path.join(ctx.workspaceRoot!, '.cw', 'agent-logs', agent.name, 'PROMPT.md'); + + let raw: string; + try { + raw = await fs.readFile(promptPath, 'utf-8'); + } catch (err: any) { + if (err?.code === 'ENOENT') { + return { content: null, reason: 'prompt_not_written' as const }; + } + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to read prompt file: ${String(err)}`, + }); + } + + return { content: truncateIfNeeded(raw) }; + }), }; } diff --git a/apps/server/trpc/routers/change-set.ts b/apps/server/trpc/routers/change-set.ts index 344c7f3..111bf54 100644 --- a/apps/server/trpc/routers/change-set.ts +++ b/apps/server/trpc/routers/change-set.ts @@ -91,6 +91,9 @@ export function changeSetProcedures(publicProcedure: ProcedureBuilder) { } } + // Mark reverted FIRST to avoid ghost state if entity deletion fails partway + await repo.markReverted(input.id); + // Apply reverts in reverse entry order const reversedEntries = [...cs.entries].reverse(); for (const entry of reversedEntries) { @@ -159,8 +162,6 @@ export function changeSetProcedures(publicProcedure: ProcedureBuilder) { } } - await repo.markReverted(input.id); - ctx.eventBus.emit({ type: 'changeset:reverted' as const, timestamp: new Date(), 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/server/trpc/routers/initiative-activity.ts b/apps/server/trpc/routers/initiative-activity.ts index fc16b35..8bdbea8 100644 --- a/apps/server/trpc/routers/initiative-activity.ts +++ b/apps/server/trpc/routers/initiative-activity.ts @@ -9,6 +9,7 @@ export interface ActiveArchitectAgent { initiativeId: string; mode: string; status: string; + name?: string; } const MODE_TO_STATE: Record = { @@ -30,6 +31,18 @@ export function deriveInitiativeActivity( if (initiative.status === 'archived') { return { ...base, state: 'archived' }; } + + // Check for active conflict resolution agent — takes priority over pending_review + // because the agent is actively working to resolve merge conflicts + const conflictAgent = activeArchitectAgents?.find( + a => a.initiativeId === initiative.id + && a.name?.startsWith('conflict-') + && (a.status === 'running' || a.status === 'waiting_for_input'), + ); + if (conflictAgent) { + return { ...base, state: 'resolving_conflict' }; + } + if (initiative.status === 'pending_review') { return { ...base, state: 'pending_review' }; } @@ -41,6 +54,7 @@ export function deriveInitiativeActivity( // so architect agents (discuss/plan/detail/refine) surface activity const activeAgent = activeArchitectAgents?.find( a => a.initiativeId === initiative.id + && !a.name?.startsWith('conflict-') && (a.status === 'running' || a.status === 'waiting_for_input'), ); if (activeAgent) { diff --git a/apps/server/trpc/routers/initiative.ts b/apps/server/trpc/routers/initiative.ts index 4b8fb66..0077ad9 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'; @@ -129,27 +129,42 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) { : await repo.findAll(); } - // Fetch active architect agents once for all initiatives + // Fetch active agents once for all initiatives (architect + conflict) const ARCHITECT_MODES = ['discuss', 'plan', 'detail', 'refine']; const allAgents = ctx.agentManager ? await ctx.agentManager.list() : []; const activeArchitectAgents = allAgents .filter(a => - ARCHITECT_MODES.includes(a.mode ?? '') + (ARCHITECT_MODES.includes(a.mode ?? '') || a.name?.startsWith('conflict-')) && (a.status === 'running' || a.status === 'waiting_for_input') && !a.userDismissedAt, ) - .map(a => ({ initiativeId: a.initiativeId ?? '', mode: a.mode ?? '', status: a.status })); + .map(a => ({ initiativeId: a.initiativeId ?? '', mode: a.mode ?? '', status: a.status, name: a.name })); + + // Batch-fetch projects for all initiatives + const projectRepo = ctx.projectRepository; + const projectsByInitiativeId = new Map>(); + if (projectRepo) { + await Promise.all(initiatives.map(async (init) => { + const projects = await projectRepo.findProjectsByInitiativeId(init.id); + projectsByInitiativeId.set(init.id, projects.map(p => ({ id: p.id, name: p.name }))); + })); + } + + const addProjects = (init: typeof initiatives[0]) => ({ + projects: projectsByInitiativeId.get(init.id) ?? [], + }); if (ctx.phaseRepository) { const phaseRepo = ctx.phaseRepository; return Promise.all(initiatives.map(async (init) => { const phases = await phaseRepo.findByInitiativeId(init.id); - return { ...init, activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) }; + return { ...init, ...addProjects(init), activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) }; })); } return initiatives.map(init => ({ ...init, + ...addProjects(init), activity: deriveInitiativeActivity(init, [], activeArchitectAgents), })); }), @@ -335,5 +350,146 @@ 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 }; + }), + + 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 on a unique temp branch based off the initiative branch. + // Using initiative.branch directly as branchName would cause SimpleGitWorktreeManager.create() + // to run `git branch -f `, force-resetting the initiative branch. + const tempBranch = `${initiative.branch}-conflict-${Date.now()}`; + 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: initiative.branch, + branchName: tempBranch, + skipPromptExtras: true, + }); + }), }; } diff --git a/apps/server/trpc/routers/phase-dispatch.ts b/apps/server/trpc/routers/phase-dispatch.ts index 3ccb0c8..4524390 100644 --- a/apps/server/trpc/routers/phase-dispatch.ts +++ b/apps/server/trpc/routers/phase-dispatch.ts @@ -53,7 +53,7 @@ export function phaseDispatchProcedures(publicProcedure: ProcedureBuilder) { number: z.number().int().positive(), name: z.string().min(1), description: z.string(), - type: z.enum(['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action']).default('auto'), + type: z.enum(['auto']).default('auto'), dependencies: z.array(z.number().int().positive()).optional(), })), })) diff --git a/apps/server/trpc/routers/phase.ts b/apps/server/trpc/routers/phase.ts index ba1e138..be59ef2 100644 --- a/apps/server/trpc/routers/phase.ts +++ b/apps/server/trpc/routers/phase.ts @@ -6,7 +6,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import type { Phase } from '../../db/schema.js'; import type { ProcedureBuilder } from '../trpc.js'; -import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator, requireReviewCommentRepository } from './_helpers.js'; +import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator, requireReviewCommentRepository, requireChangeSetRepository } from './_helpers.js'; import { phaseBranchName } from '../../git/branch-naming.js'; import { ensureProjectClone } from '../../git/project-clones.js'; @@ -98,6 +98,29 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) { .mutation(async ({ ctx, input }) => { const repo = requirePhaseRepository(ctx); await repo.delete(input.id); + + // Reconcile any applied changesets that created this phase. + // If all created phases in a changeset are now deleted, mark it reverted. + if (ctx.changeSetRepository) { + try { + const csRepo = requireChangeSetRepository(ctx); + const affectedChangeSets = await csRepo.findAppliedByCreatedEntity('phase', input.id); + for (const cs of affectedChangeSets) { + const createdPhaseIds = cs.entries + .filter(e => e.entityType === 'phase' && e.action === 'create') + .map(e => e.entityId); + const survivingPhases = await Promise.all( + createdPhaseIds.map(id => repo.findById(id)), + ); + if (survivingPhases.every(p => p === null)) { + await csRepo.markReverted(cs.id); + } + } + } catch { + // Best-effort reconciliation — don't fail the delete + } + } + return { success: true }; }), @@ -196,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); @@ -207,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'; } @@ -247,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); @@ -258,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); } @@ -320,6 +346,20 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) { return repo.create(input); }), + updateReviewComment: publicProcedure + .input(z.object({ + id: z.string().min(1), + body: z.string().trim().min(1), + })) + .mutation(async ({ ctx, input }) => { + const repo = requireReviewCommentRepository(ctx); + const comment = await repo.update(input.id, input.body); + if (!comment) { + throw new TRPCError({ code: 'NOT_FOUND', message: `Review comment '${input.id}' not found` }); + } + return comment; + }), + resolveReviewComment: publicProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { @@ -342,25 +382,54 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) { return comment; }), + replyToReviewComment: publicProcedure + .input(z.object({ + parentCommentId: z.string().min(1), + body: z.string().trim().min(1), + author: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + const repo = requireReviewCommentRepository(ctx); + return repo.createReply(input.parentCommentId, input.body, input.author); + }), + 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); const reviewCommentRepo = requireReviewCommentRepository(ctx); const allComments = await reviewCommentRepo.findByPhaseId(input.phaseId); - const unresolved = allComments - .filter((c: { resolved: boolean }) => !c.resolved) - .map((c: { filePath: string; lineNumber: number; body: string }) => ({ + // Build threaded structure: unresolved root comments with their replies + const rootComments = allComments.filter((c) => !c.parentCommentId); + const repliesByParent = new Map(); + for (const c of allComments) { + if (c.parentCommentId) { + const arr = repliesByParent.get(c.parentCommentId) ?? []; + arr.push(c); + repliesByParent.set(c.parentCommentId, arr); + } + } + + const unresolvedThreads = rootComments + .filter((c) => !c.resolved) + .map((c) => ({ + id: c.id, filePath: c.filePath, lineNumber: c.lineNumber, body: c.body, + author: c.author, + replies: (repliesByParent.get(c.id) ?? []).map((r) => ({ + id: r.id, + body: r.body, + author: r.author, + })), })); - if (unresolved.length === 0 && !input.summary) { + if (unresolvedThreads.length === 0 && !input.summary) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Add comments or a summary before requesting changes', @@ -369,7 +438,7 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) { const result = await orchestrator.requestChangesOnPhase( input.phaseId, - unresolved, + unresolvedThreads, input.summary, ); return { success: true, taskId: result.taskId }; diff --git a/apps/server/trpc/routers/subscription.ts b/apps/server/trpc/routers/subscription.ts index 949a011..43dfd79 100644 --- a/apps/server/trpc/routers/subscription.ts +++ b/apps/server/trpc/routers/subscription.ts @@ -2,7 +2,6 @@ * Subscription Router — SSE event streams */ -import { z } from 'zod'; import type { ProcedureBuilder } from '../trpc.js'; import { eventBusIterable, @@ -17,42 +16,40 @@ import { export function subscriptionProcedures(publicProcedure: ProcedureBuilder) { return { onEvent: publicProcedure - .input(z.object({ lastEventId: z.string().nullish() }).optional()) .subscription(async function* (opts) { const signal = opts.signal ?? new AbortController().signal; yield* eventBusIterable(opts.ctx.eventBus, ALL_EVENT_TYPES, signal); }), onAgentUpdate: publicProcedure - .input(z.object({ lastEventId: z.string().nullish() }).optional()) .subscription(async function* (opts) { const signal = opts.signal ?? new AbortController().signal; yield* eventBusIterable(opts.ctx.eventBus, AGENT_EVENT_TYPES, signal); }), onTaskUpdate: publicProcedure - .input(z.object({ lastEventId: z.string().nullish() }).optional()) .subscription(async function* (opts) { const signal = opts.signal ?? new AbortController().signal; yield* eventBusIterable(opts.ctx.eventBus, TASK_EVENT_TYPES, signal); }), onPageUpdate: publicProcedure - .input(z.object({ lastEventId: z.string().nullish() }).optional()) .subscription(async function* (opts) { const signal = opts.signal ?? new AbortController().signal; yield* eventBusIterable(opts.ctx.eventBus, PAGE_EVENT_TYPES, signal); }), onPreviewUpdate: publicProcedure - .input(z.object({ lastEventId: z.string().nullish() }).optional()) .subscription(async function* (opts) { const signal = opts.signal ?? new AbortController().signal; yield* eventBusIterable(opts.ctx.eventBus, PREVIEW_EVENT_TYPES, signal); }), + // NOTE: No frontend view currently displays inter-agent conversation data. + // When a conversation view is added, add to its useLiveUpdates call: + // { prefix: 'conversation:', invalidate: [''] } + // and add the relevant mutation(s) to INVALIDATION_MAP in apps/web/src/lib/invalidation.ts. onConversationUpdate: publicProcedure - .input(z.object({ lastEventId: z.string().nullish() }).optional()) .subscription(async function* (opts) { const signal = opts.signal ?? new AbortController().signal; yield* eventBusIterable(opts.ctx.eventBus, CONVERSATION_EVENT_TYPES, signal); diff --git a/apps/server/trpc/routers/task.ts b/apps/server/trpc/routers/task.ts index 48eedcc..44f0e95 100644 --- a/apps/server/trpc/routers/task.ts +++ b/apps/server/trpc/routers/task.ts @@ -10,6 +10,7 @@ import { requireInitiativeRepository, requirePhaseRepository, requireDispatchManager, + requireChangeSetRepository, } from './_helpers.js'; export function taskProcedures(publicProcedure: ProcedureBuilder) { @@ -49,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 }); }), @@ -58,7 +67,7 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) { name: z.string().min(1), description: z.string().optional(), category: z.enum(['execute', 'research', 'discuss', 'plan', 'detail', 'refine', 'verify', 'merge', 'review']).optional(), - type: z.enum(['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action']).optional(), + type: z.enum(['auto']).optional(), })) .mutation(async ({ ctx, input }) => { const taskRepository = requireTaskRepository(ctx); @@ -88,7 +97,7 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) { name: z.string().min(1), description: z.string().optional(), category: z.enum(['execute', 'research', 'discuss', 'plan', 'detail', 'refine', 'verify', 'merge', 'review']).optional(), - type: z.enum(['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action']).optional(), + type: z.enum(['auto']).optional(), })) .mutation(async ({ ctx, input }) => { const taskRepository = requireTaskRepository(ctx); @@ -152,6 +161,29 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) { .mutation(async ({ ctx, input }) => { const taskRepository = requireTaskRepository(ctx); await taskRepository.delete(input.id); + + // Reconcile any applied changesets that created this task. + // If all created tasks in a changeset are now deleted, mark it reverted. + if (ctx.changeSetRepository) { + try { + const csRepo = requireChangeSetRepository(ctx); + const affectedChangeSets = await csRepo.findAppliedByCreatedEntity('task', input.id); + for (const cs of affectedChangeSets) { + const createdTaskIds = cs.entries + .filter(e => e.entityType === 'task' && e.action === 'create') + .map(e => e.entityId); + const survivingTasks = await Promise.all( + createdTaskIds.map(id => taskRepository.findById(id)), + ); + if (survivingTasks.every(t => t === null)) { + await csRepo.markReverted(cs.id); + } + } + } catch { + // Best-effort reconciliation — don't fail the delete + } + } + return { success: true }; }), diff --git a/apps/server/trpc/subscriptions.ts b/apps/server/trpc/subscriptions.ts index 97cf38b..b4102bd 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', @@ -71,6 +70,7 @@ export const ALL_EVENT_TYPES: DomainEventType[] = [ 'chat:session_closed', 'initiative:pending_review', 'initiative:review_approved', + 'initiative:changes_requested', ]; /** @@ -84,7 +84,6 @@ export const AGENT_EVENT_TYPES: DomainEventType[] = [ 'agent:account_switched', 'agent:deleted', 'agent:waiting', - 'agent:output', ]; /** @@ -104,6 +103,7 @@ export const TASK_EVENT_TYPES: DomainEventType[] = [ 'phase:merged', 'initiative:pending_review', 'initiative:review_approved', + 'initiative:changes_requested', ]; /** diff --git a/apps/web/src/components/AgentDetailsPanel.tsx b/apps/web/src/components/AgentDetailsPanel.tsx new file mode 100644 index 0000000..6086f3d --- /dev/null +++ b/apps/web/src/components/AgentDetailsPanel.tsx @@ -0,0 +1,230 @@ +import { useState, useEffect } from "react"; +import { Link } from "@tanstack/react-router"; +import { trpc } from "@/lib/trpc"; +import { cn } from "@/lib/utils"; +import { Skeleton } from "@/components/Skeleton"; +import { Button } from "@/components/ui/button"; +import { StatusDot } from "@/components/StatusDot"; +import { formatRelativeTime } from "@/lib/utils"; +import { modeLabel } from "@/lib/labels"; + +export function AgentDetailsPanel({ agentId }: { agentId: string }) { + return ( +
+
+

Metadata

+ +
+
+

Input Files

+ +
+
+

Effective Prompt

+ +
+
+ ); +} + +function MetadataSection({ agentId }: { agentId: string }) { + const query = trpc.getAgent.useQuery({ id: agentId }); + + if (query.isLoading) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ); + } + + if (query.isError) { + return ( +
+

{query.error.message}

+ +
+ ); + } + + const agent = query.data; + if (!agent) return null; + + const showExitCode = !['idle', 'running', 'waiting_for_input'].includes(agent.status); + + const rows: Array<{ label: string; value: React.ReactNode }> = [ + { + label: 'Status', + value: ( + + + {agent.status} + + ), + }, + { + label: 'Mode', + value: modeLabel(agent.mode), + }, + { + label: 'Provider', + value: agent.provider, + }, + { + label: 'Initiative', + value: agent.initiativeId ? ( + + {(agent as { initiativeName?: string | null }).initiativeName ?? agent.initiativeId} + + ) : '—', + }, + { + label: 'Task', + value: (agent as { taskName?: string | null }).taskName ?? (agent.taskId ? agent.taskId : '—'), + }, + { + label: 'Created', + value: formatRelativeTime(String(agent.createdAt)), + }, + ]; + + if (showExitCode) { + rows.push({ + label: 'Exit Code', + value: ( + + {agent.exitCode ?? '—'} + + ), + }); + } + + return ( +
+ {rows.map(({ label, value }) => ( +
+ {label} + {value} +
+ ))} +
+ ); +} + +function InputFilesSection({ agentId }: { agentId: string }) { + const query = trpc.getAgentInputFiles.useQuery({ id: agentId }); + const [selectedFile, setSelectedFile] = useState(null); + + useEffect(() => { + setSelectedFile(null); + }, [agentId]); + + useEffect(() => { + if (!query.data?.files) return; + if (selectedFile !== null) return; + const manifest = query.data.files.find(f => f.name === 'manifest.json'); + setSelectedFile(manifest?.name ?? query.data.files[0]?.name ?? null); + }, [query.data?.files]); + + if (query.isLoading) { + return ( +
+ + + +
+ ); + } + + if (query.isError) { + return ( +
+

{query.error.message}

+ +
+ ); + } + + const data = query.data; + if (!data) return null; + + if (data.reason === 'worktree_missing') { + return

Worktree no longer exists — input files unavailable

; + } + + if (data.reason === 'input_dir_missing') { + return

Input directory not found — this agent may not have received input files

; + } + + const { files } = data; + + if (files.length === 0) { + return

No input files found

; + } + + return ( +
+ {/* File list */} +
+ {files.map(file => ( + + ))} +
+ {/* Content pane */} +
+        {files.find(f => f.name === selectedFile)?.content ?? ''}
+      
+
+ ); +} + +function EffectivePromptSection({ agentId }: { agentId: string }) { + const query = trpc.getAgentPrompt.useQuery({ id: agentId }); + + if (query.isLoading) { + return ; + } + + if (query.isError) { + return ( +
+

{query.error.message}

+ +
+ ); + } + + const data = query.data; + if (!data) return null; + + if (data.reason === 'prompt_not_written') { + return

Prompt file not available — agent may have been spawned before this feature was added

; + } + + if (data.content) { + return ( +
+        {data.content}
+      
+ ); + } + + return null; +} diff --git a/apps/web/src/components/AgentOutputViewer.tsx b/apps/web/src/components/AgentOutputViewer.tsx index 3eaaeb3..48663ed 100644 --- a/apps/web/src/components/AgentOutputViewer.tsx +++ b/apps/web/src/components/AgentOutputViewer.tsx @@ -6,6 +6,7 @@ import { trpc } from "@/lib/trpc"; import { useSubscriptionWithErrorHandling } from "@/hooks"; import { type ParsedMessage, + type TimestampedChunk, getMessageStyling, parseAgentOutput, } from "@/lib/parse-agent-output"; @@ -21,8 +22,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO const [messages, setMessages] = useState([]); const [follow, setFollow] = useState(true); const containerRef = useRef(null); - // Accumulate raw JSONL: initial query data + live subscription chunks - const rawBufferRef = useRef(''); + // Accumulate timestamped chunks: initial query data + live subscription chunks + const chunksRef = useRef([]); // Load initial/historical output const outputQuery = trpc.getAgentOutput.useQuery( @@ -40,8 +41,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO // TrackedEnvelope shape: { id, data: { agentId, data: string } } const raw = event?.data?.data ?? event?.data; const chunk = typeof raw === 'string' ? raw : JSON.stringify(raw); - rawBufferRef.current += chunk; - setMessages(parseAgentOutput(rawBufferRef.current)); + chunksRef.current = [...chunksRef.current, { content: chunk, createdAt: new Date().toISOString() }]; + setMessages(parseAgentOutput(chunksRef.current)); }, onError: (error) => { console.error('Agent output subscription error:', error); @@ -54,14 +55,14 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO // Set initial output when query loads useEffect(() => { if (outputQuery.data) { - rawBufferRef.current = outputQuery.data; + chunksRef.current = outputQuery.data; setMessages(parseAgentOutput(outputQuery.data)); } }, [outputQuery.data]); // Reset output when agent changes useEffect(() => { - rawBufferRef.current = ''; + chunksRef.current = []; setMessages([]); setFollow(true); }, [agentId]); @@ -160,57 +161,64 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
{isLoading ? (
Loading output...
) : !hasOutput ? (
No output yet...
) : ( -
+
{messages.map((message, index) => (
{message.type === 'system' && (
System {message.content} +
)} {message.type === 'text' && ( -
- {message.content} -
+ <> + +
+ {message.content} +
+ )} {message.type === 'tool_call' && ( -
- - {message.meta?.toolName} - -
+
+
+ + {message.meta?.toolName} + + +
+
{message.content}
)} {message.type === 'tool_result' && ( -
+
Result -
+
{message.content}
)} {message.type === 'error' && ( -
+
Error -
+
{message.content}
@@ -228,6 +236,7 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO {message.meta?.duration && ( {(message.meta.duration / 1000).toFixed(1)}s )} +
)} @@ -239,3 +248,16 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
); } + +function formatTime(date: Date): string { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); +} + +function Timestamp({ date }: { date?: Date }) { + if (!date) return null; + return ( + + {formatTime(date)} + + ); +} diff --git a/apps/web/src/components/InitiativeCard.tsx b/apps/web/src/components/InitiativeCard.tsx index a4f7e18..5cf86ce 100644 --- a/apps/web/src/components/InitiativeCard.tsx +++ b/apps/web/src/components/InitiativeCard.tsx @@ -1,6 +1,7 @@ import { MoreHorizontal } from "lucide-react"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { DropdownMenu, DropdownMenuContent, @@ -20,6 +21,7 @@ export interface SerializedInitiative { branch: string | null; createdAt: string; updatedAt: string; + projects?: Array<{ id: string; name: string }>; activity: { state: string; activePhase?: { id: string; name: string }; @@ -30,11 +32,12 @@ export interface SerializedInitiative { function activityVisual(state: string): { label: string; variant: StatusVariant; pulse: boolean } { switch (state) { - case "executing": return { label: "Executing", variant: "active", pulse: true }; - case "pending_review": return { label: "Pending Review", variant: "warning", pulse: true }; - case "discussing": return { label: "Discussing", variant: "active", pulse: true }; - case "detailing": return { label: "Detailing", variant: "active", pulse: true }; - case "refining": return { label: "Refining", variant: "active", pulse: true }; + case "executing": return { label: "Executing", variant: "active", pulse: true }; + case "pending_review": return { label: "Pending Review", variant: "warning", pulse: true }; + case "discussing": return { label: "Discussing", variant: "active", pulse: true }; + case "detailing": return { label: "Detailing", variant: "active", pulse: true }; + case "refining": return { label: "Refining", variant: "active", pulse: true }; + case "resolving_conflict": return { label: "Resolving Conflict", variant: "urgent", pulse: true }; case "ready": return { label: "Ready", variant: "active", pulse: false }; case "blocked": return { label: "Blocked", variant: "error", pulse: false }; case "complete": return { label: "Complete", variant: "success", pulse: false }; @@ -87,11 +90,19 @@ export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) { className="p-4" onClick={onClick} > - {/* Row 1: Name + overflow menu */} -
- - {initiative.name} - + {/* Row 1: Name + project pills + overflow menu */} +
+
+ + {initiative.name} + + {initiative.projects && initiative.projects.length > 0 && + initiative.projects.map((p) => ( + + {p.name} + + ))} +
e.stopPropagation()}> diff --git a/apps/web/src/components/StatusDot.tsx b/apps/web/src/components/StatusDot.tsx index f57b454..30e538b 100644 --- a/apps/web/src/components/StatusDot.tsx +++ b/apps/web/src/components/StatusDot.tsx @@ -45,6 +45,10 @@ export function mapEntityStatus(rawStatus: string): StatusVariant { case "medium": return "warning"; + // Urgent / conflict resolution + case "resolving_conflict": + return "urgent"; + // Error / failed case "crashed": case "blocked": diff --git a/apps/web/src/components/TaskRow.tsx b/apps/web/src/components/TaskRow.tsx index 2fdc909..bea31ff 100644 --- a/apps/web/src/components/TaskRow.tsx +++ b/apps/web/src/components/TaskRow.tsx @@ -12,7 +12,7 @@ export interface SerializedTask { parentTaskId: string | null; name: string; description: string | null; - type: "auto" | "checkpoint:human-verify" | "checkpoint:decision" | "checkpoint:human-action"; + type: "auto"; category: string; priority: "low" | "medium" | "high"; status: "pending" | "in_progress" | "completed" | "blocked"; diff --git a/apps/web/src/components/editor/ContentTab.tsx b/apps/web/src/components/editor/ContentTab.tsx index dcb8ff5..71de63b 100644 --- a/apps/web/src/components/editor/ContentTab.tsx +++ b/apps/web/src/components/editor/ContentTab.tsx @@ -253,13 +253,13 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) { {resolvedActivePageId && ( <> - {(isSaving || updateInitiativeMutation.isPending) && ( -
+
+ {(isSaving || updateInitiativeMutation.isPending) && ( Saving... -
- )} + )} +
{activePageQuery.isSuccess && ( { - if (node.type.name === 'heading') { - return `Heading ${node.attrs.level}`; - } - return "Type '/' for commands..."; - }, - }), - Link.configure({ - openOnClick: false, - }), - SlashCommands, - BlockSelectionExtension, - ]; - - const extensions = enablePageLinks - ? [...baseExtensions, PageLinkExtension, pageLinkDeletionDetector] - : baseExtensions; + const extensions = useMemo(() => { + const detector = createPageLinkDeletionDetector(onPageLinkDeletedRef); + const base = [ + StarterKit, + Table.configure({ resizable: true, cellMinWidth: 50 }), + TableRow, + TableCell, + TableHeader, + Placeholder.configure({ + includeChildren: true, + placeholder: ({ node }) => { + if (node.type.name === 'heading') { + return `Heading ${node.attrs.level}`; + } + return "Type '/' for commands..."; + }, + }), + Link.configure({ + openOnClick: false, + }), + SlashCommands, + BlockSelectionExtension, + ]; + return enablePageLinks + ? [...base, PageLinkExtension, detector] + : base; + }, [enablePageLinks]); const editor = useEditor( { diff --git a/apps/web/src/components/execution/PlanSection.tsx b/apps/web/src/components/execution/PlanSection.tsx index a79e3dc..fc24af4 100644 --- a/apps/web/src/components/execution/PlanSection.tsx +++ b/apps/web/src/components/execution/PlanSection.tsx @@ -27,6 +27,7 @@ export function PlanSection({ (a) => a.mode === "plan" && a.initiativeId === initiativeId && + !a.userDismissedAt && ["running", "waiting_for_input", "idle"].includes(a.status), ) .sort( diff --git a/apps/web/src/components/execution/TaskSlideOver.tsx b/apps/web/src/components/execution/TaskSlideOver.tsx index e2df6bf..5b1b261 100644 --- a/apps/web/src/components/execution/TaskSlideOver.tsx +++ b/apps/web/src/components/execution/TaskSlideOver.tsx @@ -1,18 +1,21 @@ -import { useCallback, useEffect, useRef, useMemo } from "react"; +import { useCallback, useEffect, useRef, useMemo, useState } 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"; import { StatusBadge } from "@/components/StatusBadge"; import { StatusDot } from "@/components/StatusDot"; import { TiptapEditor } from "@/components/editor/TiptapEditor"; +import { AgentOutputViewer } from "@/components/AgentOutputViewer"; import { getCategoryConfig } from "@/lib/category"; import { markdownToTiptapJson } from "@/lib/markdown-to-tiptap"; import { useExecutionContext } from "./ExecutionContext"; import { trpc } from "@/lib/trpc"; import { cn } from "@/lib/utils"; +type SlideOverTab = "details" | "logs"; + interface TaskSlideOverProps { onOpenChat?: (target: ChatTarget) => void; } @@ -20,11 +23,19 @@ 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(); + const [tab, setTab] = useState("details"); + const close = useCallback(() => setSelectedTaskId(null), [setSelectedTaskId]); + // Reset tab when task changes + useEffect(() => { + setTab("details"); + }, [selectedEntry?.task?.id]); + // Escape key closes useEffect(() => { if (!selectedEntry) return; @@ -151,95 +162,137 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
+ {/* Tab bar */} +
+ {(["details", "logs"] as const).map((t) => ( + + ))} +
+ {/* Content */} -
- {/* Metadata grid */} -
- - - - - - - - - - - {task.type} - - - - {selectedEntry.agentName ?? "Unassigned"} - - -
+
+ {tab === "details" ? ( +
+ {/* Metadata grid */} +
+ + + + + + + + + + + {task.type} + + + + {selectedEntry.agentName ?? "Unassigned"} + + +
- {/* Description — editable tiptap */} -
- -
+ {/* Description — editable tiptap */} +
+ +
- {/* Dependencies */} -
- {dependencies.length === 0 ? ( -

None

- ) : ( -
    - {dependencies.map((dep) => ( -
  • - - - {dep.name} - -
  • - ))} -
- )} -
+ {/* Dependencies */} +
+ {dependencies.length === 0 ? ( +

None

+ ) : ( +
    + {dependencies.map((dep) => ( +
  • + + + {dep.name} + +
  • + ))} +
+ )} +
- {/* Blocks */} -
- {dependents.length === 0 ? ( -

None

- ) : ( -
    - {dependents.map((dep) => ( -
  • - - - {dep.name} - -
  • - ))} -
- )} -
+ {/* Blocks */} +
+ {dependents.length === 0 ? ( +

None

+ ) : ( +
    + {dependents.map((dep) => ( +
  • + + + {dep.name} + +
  • + ))} +
+ )} +
+
+ ) : ( + + )}
{/* Footer */}
- + {task.status === "blocked" ? ( + + ) : ( + + )}
+ ); + } + + if (!agent) { + return ( +
+ No agent has been assigned to this task yet. +
+ ); + } + + return ( +
+ +
+ ); +} + // --------------------------------------------------------------------------- // Small helpers // --------------------------------------------------------------------------- diff --git a/apps/web/src/components/review/CommentForm.tsx b/apps/web/src/components/review/CommentForm.tsx index 79e942a..0d1515d 100644 --- a/apps/web/src/components/review/CommentForm.tsx +++ b/apps/web/src/components/review/CommentForm.tsx @@ -7,14 +7,15 @@ interface CommentFormProps { onCancel: () => void; placeholder?: string; submitLabel?: string; + initialValue?: string; } export const CommentForm = forwardRef( function CommentForm( - { onSubmit, onCancel, placeholder = "Write a comment...", submitLabel = "Comment" }, + { onSubmit, onCancel, placeholder = "Write a comment...", submitLabel = "Comment", initialValue = "" }, ref ) { - const [body, setBody] = useState(""); + const [body, setBody] = useState(initialValue); const handleSubmit = useCallback(() => { const trimmed = body.trim(); diff --git a/apps/web/src/components/review/CommentThread.tsx b/apps/web/src/components/review/CommentThread.tsx index 6599e34..8a22dde 100644 --- a/apps/web/src/components/review/CommentThread.tsx +++ b/apps/web/src/components/review/CommentThread.tsx @@ -1,71 +1,214 @@ -import { Check, RotateCcw } from "lucide-react"; +import { useState, useRef, useEffect } from "react"; +import { Check, RotateCcw, Reply, Pencil } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { CommentForm } from "./CommentForm"; import type { ReviewComment } from "./types"; interface CommentThreadProps { comments: ReviewComment[]; onResolve: (commentId: string) => void; onUnresolve: (commentId: string) => void; + onReply?: (parentCommentId: string, body: string) => void; + onEdit?: (commentId: string, body: string) => void; } -export function CommentThread({ comments, onResolve, onUnresolve }: CommentThreadProps) { +export function CommentThread({ comments, onResolve, onUnresolve, onReply, onEdit }: CommentThreadProps) { + // Group: root comments (no parentCommentId) and their replies + const rootComments = comments.filter((c) => !c.parentCommentId); + const repliesByParent = new Map(); + for (const c of comments) { + if (c.parentCommentId) { + const arr = repliesByParent.get(c.parentCommentId) ?? []; + arr.push(c); + repliesByParent.set(c.parentCommentId, arr); + } + } + return (
- {comments.map((comment) => ( -
( + -
-
- {comment.author} - - {formatTime(comment.createdAt)} - - {comment.resolved && ( - - - Resolved - - )} -
-
- {comment.resolved ? ( - - ) : ( - - )} -
-
-

- {comment.body} -

-
+ comment={comment} + replies={repliesByParent.get(comment.id) ?? []} + onResolve={onResolve} + onUnresolve={onUnresolve} + onReply={onReply} + onEdit={onEdit} + /> ))}
); } +function RootComment({ + comment, + replies, + 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 */} +
+
+
+ {comment.author} + {formatTime(comment.createdAt)} + {comment.resolved && ( + + + Resolved + + )} +
+
+ {onEdit && comment.author !== "agent" && !comment.resolved && ( + + )} + {onReply && !comment.resolved && ( + + )} + {comment.resolved ? ( + + ) : ( + + )} +
+
+ {isEditingRoot ? ( + { + onEdit!(comment.id, body); + setEditingId(null); + }} + onCancel={() => setEditingId(null)} + placeholder="Edit comment..." + submitLabel="Save" + /> + ) : ( +

{comment.body}

+ )} +
+ + {/* Replies */} + {replies.length > 0 && ( +
+ {replies.map((reply) => ( +
+
+
+ + {reply.author} + + {formatTime(reply.createdAt)} +
+ {onEdit && reply.author !== "agent" && !comment.resolved && editingId !== reply.id && ( + + )} +
+ {editingId === reply.id ? ( + { + onEdit!(reply.id, body); + setEditingId(null); + }} + onCancel={() => setEditingId(null)} + placeholder="Edit reply..." + submitLabel="Save" + /> + ) : ( +

{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/ConflictResolutionPanel.tsx b/apps/web/src/components/review/ConflictResolutionPanel.tsx new file mode 100644 index 0000000..ea2ff36 --- /dev/null +++ b/apps/web/src/components/review/ConflictResolutionPanel.tsx @@ -0,0 +1,180 @@ +import { Loader2, AlertCircle, GitMerge, CheckCircle2, ChevronDown, ChevronRight, Terminal } from 'lucide-react'; +import { useEffect, useRef, 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); + const prevStateRef = useRef(null); + + // Auto-dismiss and re-check mergeability when conflict agent completes + useEffect(() => { + const prev = prevStateRef.current; + prevStateRef.current = state; + if (prev !== 'completed' && state === 'completed') { + dismiss(); + onResolved(); + } + }, [state, dismiss, onResolved]); + + 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') { + // Auto-dismiss effect above handles this — show brief success message during transition + return ( +
+
+ + Conflicts resolved — re-checking mergeability... + +
+
+ ); + } + + if (state === 'crashed') { + return ( +
+
+
+ + Conflict resolution agent crashed +
+
+ + +
+
+
+ ); + } + + return null; +} diff --git a/apps/web/src/components/review/DiffViewer.tsx b/apps/web/src/components/review/DiffViewer.tsx index 5cec6c5..5b9c1e2 100644 --- a/apps/web/src/components/review/DiffViewer.tsx +++ b/apps/web/src/components/review/DiffViewer.tsx @@ -12,6 +12,8 @@ interface DiffViewerProps { ) => void; 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; @@ -23,6 +25,8 @@ export function DiffViewer({ onAddComment, onResolveComment, onUnresolveComment, + onReplyComment, + onEditComment, viewedFiles, onToggleViewed, onRegisterRef, @@ -37,6 +41,8 @@ export function DiffViewer({ onAddComment={onAddComment} 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 a1180e3..d7056c8 100644 --- a/apps/web/src/components/review/FileCard.tsx +++ b/apps/web/src/components/review/FileCard.tsx @@ -52,6 +52,8 @@ interface FileCardProps { ) => void; onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; + onReplyComment?: (parentCommentId: string, body: string) => void; + onEditComment?: (commentId: string, body: string) => void; isViewed?: boolean; onToggleViewed?: () => void; } @@ -62,6 +64,8 @@ export function FileCard({ onAddComment, onResolveComment, onUnresolveComment, + onReplyComment, + onEditComment, isViewed = false, onToggleViewed = () => {}, }: FileCardProps) { @@ -77,10 +81,11 @@ export function FileCard({ const tokenMap = useHighlightedFile(file.newPath, allLines); return ( -
+
{/* File header — sticky so it stays visible when scrolling */}
- {/* Right: action buttons */} + {/* Right: preview + action buttons */}
+ {previewState && }
+ {/* Conflict resolution panel */} + {mergeability && !mergeability.mergeable && ( + { + void mergeabilityQuery.refetch(); + void diffQuery.refetch(); + void commitsQuery.refetch(); + }} + /> + )} + {/* Main content */}
diff --git a/apps/web/src/components/review/LineWithComments.tsx b/apps/web/src/components/review/LineWithComments.tsx index ac4288f..c5b8d12 100644 --- a/apps/web/src/components/review/LineWithComments.tsx +++ b/apps/web/src/components/review/LineWithComments.tsx @@ -15,6 +15,8 @@ interface LineWithCommentsProps { onSubmitComment: (body: string) => void; 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; } @@ -29,6 +31,8 @@ export function LineWithComments({ onSubmitComment, onResolveComment, onUnresolveComment, + onReplyComment, + onEditComment, tokens, }: LineWithCommentsProps) { const formRef = useRef(null); @@ -132,7 +136,7 @@ export function LineWithComments({ {/* Existing comments on this line */} {lineComments.length > 0 && ( - + !c.parentCommentId)?.id}> diff --git a/apps/web/src/components/review/PreviewControls.tsx b/apps/web/src/components/review/PreviewControls.tsx new file mode 100644 index 0000000..98a356a --- /dev/null +++ b/apps/web/src/components/review/PreviewControls.tsx @@ -0,0 +1,81 @@ +import { + ExternalLink, + Loader2, + Square, + CircleDot, + RotateCcw, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export interface PreviewState { + status: "idle" | "building" | "running" | "failed"; + url?: string; + onStart: () => void; + onStop: () => void; + isStarting: boolean; + isStopping: boolean; +} + +export function PreviewControls({ preview }: { preview: PreviewState }) { + if (preview.status === "building" || preview.isStarting) { + return ( +
+ + Building... +
+ ); + } + + if (preview.status === "running") { + return ( + + ); + } + + 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 0e069ff..44e55fc 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,25 +14,21 @@ 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 { id: string; name: string; -} - -interface PreviewState { - status: "idle" | "building" | "running" | "failed"; - url?: string; - onStart: () => void; - onStop: () => void; - isStarting: boolean; - isStopping: boolean; + status: string; } interface ReviewHeaderProps { + ref?: React.Ref; phases: PhaseOption[]; activePhaseId: string | null; + isReadOnly?: boolean; onPhaseSelect: (id: string) => void; phaseName: string; sourceBranch: string; @@ -53,8 +45,10 @@ interface ReviewHeaderProps { } export function ReviewHeader({ + ref, phases, activePhaseId, + isReadOnly, onPhaseSelect, phaseName, sourceBranch, @@ -72,28 +66,38 @@ export function ReviewHeader({ const totalAdditions = files.reduce((s, f) => s + f.additions, 0); const totalDeletions = files.reduce((s, f) => s + f.deletions, 0); const [showConfirmation, setShowConfirmation] = useState(false); + const [showRequestConfirm, setShowRequestConfirm] = useState(false); const confirmRef = useRef(null); + const requestConfirmRef = useRef(null); - // Click-outside handler to dismiss confirmation + // Click-outside handler to dismiss confirmation dropdowns useEffect(() => { - if (!showConfirmation) return; + if (!showConfirmation && !showRequestConfirm) return; function handleClickOutside(e: MouseEvent) { if ( + showConfirmation && confirmRef.current && !confirmRef.current.contains(e.target as Node) ) { setShowConfirmation(false); } + if ( + showRequestConfirm && + requestConfirmRef.current && + !requestConfirmRef.current.contains(e.target as Node) + ) { + setShowRequestConfirm(false); + } } document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); - }, [showConfirmation]); + }, [showConfirmation, showRequestConfirm]); const viewed = viewedCount ?? 0; const total = totalCount ?? 0; return ( -
+
{/* Phase selector row */} {phases.length > 1 && (
@@ -103,6 +107,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 ( @@ -182,102 +190,151 @@ 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" && ( + <> +
+ + + {showRequestConfirm && ( +
+

+ Request changes? +

+
+
+ + + {unresolvedCount} unresolved {unresolvedCount === 1 ? "comment" : "comments"} will be sent + +
+
+
+ + +
+
+ )} +
+
+ + + {/* Merge confirmation dropdown */} + {showConfirmation && ( +
+

+ Ready to merge? +

+
+
+ + + 0 unresolved comments + +
+
+ + + {viewed}/{total} files viewed + +
+
+
+ + +
+
+ )} +
+ + )} + {status === "approved" && ( + + + Approved + + )} + {status === "changes_requested" && ( + + + Changes Requested + + )} + )}
@@ -285,66 +342,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 ( - - ); -} diff --git a/apps/web/src/components/review/ReviewSidebar.tsx b/apps/web/src/components/review/ReviewSidebar.tsx index 3a5eb54..4e344d1 100644 --- a/apps/web/src/components/review/ReviewSidebar.tsx +++ b/apps/web/src/components/review/ReviewSidebar.tsx @@ -18,6 +18,7 @@ interface ReviewSidebarProps { files: FileDiff[]; comments: ReviewComment[]; onFileClick: (filePath: string) => 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,12 +183,13 @@ function FilesView({ files: FileDiff[]; comments: ReviewComment[]; onFileClick: (filePath: string) => void; + onCommentClick?: (commentId: string) => void; selectedCommit: string | null; 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]); @@ -213,29 +218,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 ( + + ); + })}
)} @@ -263,7 +305,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 9945224..099f380 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.borderBoxSize?.[0]?.blockSize ?? entry.target.getBoundingClientRect().height); + }); + ro.observe(el, { box: 'border-box' }); + return () => ro.disconnect(); + }, []); const toggleViewed = useCallback((filePath: string) => { setViewedFiles(prev => { @@ -45,14 +57,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 +93,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 ?? ""; @@ -99,6 +108,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}`), @@ -115,15 +125,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({ @@ -157,6 +165,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]); @@ -179,6 +188,20 @@ 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 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"); @@ -225,6 +248,14 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { unresolveCommentMutation.mutate({ id: commentId }); }, [unresolveCommentMutation]); + const handleReplyComment = useCallback((parentCommentId: string, body: string) => { + 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 }); @@ -241,9 +272,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { const handleRequestChanges = useCallback(() => { if (!activePhaseId) return; - const summary = window.prompt("Optional: describe what needs to change (leave blank for comments only)"); - if (summary === null) return; // cancelled - requestChangesMutation.mutate({ phaseId: activePhaseId, summary: summary || undefined }); + requestChangesMutation.mutate({ phaseId: activePhaseId }); }, [activePhaseId, requestChangesMutation]); const handleFileClick = useCallback((filePath: string) => { @@ -253,6 +282,16 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { } }, []); + const handleCommentClick = useCallback((commentId: string) => { + const el = document.querySelector(`[data-comment-id="${commentId}"]`); + if (el) { + 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); + } + }, []); + const handlePhaseSelect = useCallback((id: string) => { setSelectedPhaseId(id); setSelectedCommit(null); @@ -260,7 +299,18 @@ 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 ?? + reviewablePhases.find((p) => p.id === activePhaseId)?.name ?? + "Phase"; + + // All files from the full branch diff (for sidebar file list) + const allFiles = useMemo(() => { + if (!diffQuery.data?.rawDiff) return []; + return parseUnifiedDiff(diffQuery.data.rawDiff); + }, [diffQuery.data?.rawDiff]); // Initiative-level review takes priority if (isInitiativePendingReview) { @@ -275,7 +325,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { ); } - if (pendingReviewPhases.length === 0) { + if (reviewablePhases.length === 0) { return (

No phases pending review

@@ -283,23 +333,17 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { ); } - const activePhaseName = - diffQuery.data?.phaseName ?? - pendingReviewPhases.find((p) => p.id === activePhaseId)?.name ?? - "Phase"; - - // All files from the full branch diff (for sidebar file list) - const allFiles = useMemo(() => { - if (!diffQuery.data?.rawDiff) return []; - return parseUnifiedDiff(diffQuery.data.rawDiff); - }, [diffQuery.data?.rawDiff]); - return ( -
+
{/* Header: phase selector + toolbar */} ({ id: p.id, name: p.name }))} + ref={headerRef} + phases={reviewablePhases.map((p) => ({ id: p.id, name: p.name, status: p.status }))} activePhaseId={activePhaseId} + isReadOnly={isActivePhaseCompleted} onPhaseSelect={handlePhaseSelect} phaseName={activePhaseName} sourceBranch={sourceBranch} @@ -316,14 +360,21 @@ 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 */}
-
+
['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/apps/web/src/hooks/useLiveUpdates.ts b/apps/web/src/hooks/useLiveUpdates.ts index 6179619..50908c7 100644 --- a/apps/web/src/hooks/useLiveUpdates.ts +++ b/apps/web/src/hooks/useLiveUpdates.ts @@ -15,6 +15,18 @@ export interface LiveUpdateRule { * * Encapsulates error toast + reconnect config so pages don't duplicate boilerplate. */ +/** + * Reusable rules for any page displaying initiative cards. + * Covers all event prefixes that can change derived initiative activity state. + */ +export const INITIATIVE_LIST_RULES: LiveUpdateRule[] = [ + { prefix: 'initiative:', invalidate: ['listInitiatives'] }, + { prefix: 'task:', invalidate: ['listInitiatives'] }, + { prefix: 'phase:', invalidate: ['listInitiatives'] }, + { prefix: 'agent:', invalidate: ['listInitiatives'] }, + { prefix: 'merge:', invalidate: ['listInitiatives'] }, +]; + export function useLiveUpdates(rules: LiveUpdateRule[]) { const utils = trpc.useUtils(); diff --git a/apps/web/src/lib/invalidation.ts b/apps/web/src/lib/invalidation.ts index f08105d..5c4538a 100644 --- a/apps/web/src/lib/invalidation.ts +++ b/apps/web/src/lib/invalidation.ts @@ -44,20 +44,24 @@ const INVALIDATION_MAP: Partial> = { spawnArchitectDiscuss: ["listAgents"], spawnArchitectPlan: ["listAgents"], spawnArchitectDetail: ["listAgents", "listInitiativeTasks"], + spawnConflictResolutionAgent: ["listAgents", "listInitiatives", "getInitiative"], // --- Initiatives --- createInitiative: ["listInitiatives"], updateInitiative: ["listInitiatives", "getInitiative"], updateInitiativeProjects: ["getInitiative"], + approveInitiativeReview: ["listInitiatives", "getInitiative"], + requestInitiativeChanges: ["listInitiatives", "getInitiative"], // --- Phases --- createPhase: ["listPhases", "listInitiativePhaseDependencies"], - deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies"], + deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies", "listChangeSets"], updatePhase: ["listPhases", "getPhase"], - approvePhase: ["listPhases", "listInitiativeTasks"], + approvePhase: ["listPhases", "listInitiativeTasks", "listInitiatives"], + requestPhaseChanges: ["listPhases", "listInitiativeTasks", "listPhaseTasks", "getInitiative"], queuePhase: ["listPhases"], - createPhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies"], - removePhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies"], + createPhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies", "listPhaseTaskDependencies"], + removePhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies", "listPhaseTaskDependencies"], // --- Tasks --- createPhaseTask: ["listPhaseTasks", "listInitiativeTasks", "listTasks"], @@ -65,11 +69,17 @@ const INVALIDATION_MAP: Partial> = { createChildTasks: ["listTasks", "listInitiativeTasks", "listPhaseTasks"], queueTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks"], + deleteTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks", "listChangeSets"], + // --- Change Sets --- - revertChangeSet: ["listPhases", "listPhaseTasks", "listInitiativeTasks", "listPages", "getPage", "listChangeSets", "getRootPage"], + revertChangeSet: ["listPhases", "listPhaseTasks", "listInitiativeTasks", "listPages", "getPage", "listChangeSets", "getRootPage", "getChangeSet"], // --- Pages --- - updatePage: ["listPages", "getPage", "getRootPage"], + // NOTE: getPage omitted — useAutoSave handles optimistic updates for the + // active page, and SSE `page:updated` events cover external changes. + // Including getPage here caused double-invalidation (mutation + SSE) and + // refetch storms that flickered the editor. + updatePage: ["listPages", "getRootPage"], createPage: ["listPages", "getRootPage"], deletePage: ["listPages", "getRootPage"], diff --git a/apps/web/src/lib/parse-agent-output.ts b/apps/web/src/lib/parse-agent-output.ts index 9d4e5e2..ca6065c 100644 --- a/apps/web/src/lib/parse-agent-output.ts +++ b/apps/web/src/lib/parse-agent-output.ts @@ -7,6 +7,7 @@ export interface ParsedMessage { | "session_end" | "error"; content: string; + timestamp?: Date; meta?: { toolName?: string; isError?: boolean; @@ -60,108 +61,135 @@ export function getMessageStyling(type: ParsedMessage["type"]): string { } } -export function parseAgentOutput(raw: string): ParsedMessage[] { - const lines = raw.split("\n").filter(Boolean); +/** + * A chunk of raw JSONL content with an optional timestamp from the DB. + */ +export interface TimestampedChunk { + content: string; + createdAt: string; +} + +/** + * Parse agent output. Accepts either a flat string (legacy) or timestamped chunks. + * When chunks have timestamps, each parsed message inherits the chunk's timestamp. + */ +export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessage[] { + const chunks: { content: string; timestamp?: Date }[] = + typeof raw === "string" + ? [{ content: raw }] + : raw.map((c) => ({ content: c.content, timestamp: new Date(c.createdAt) })); + const parsedMessages: ParsedMessage[] = []; - for (const line of lines) { - try { - const event = JSON.parse(line); + for (const chunk of chunks) { + const lines = chunk.content.split("\n").filter(Boolean); + for (const line of lines) { + try { + const event = JSON.parse(line); - // System initialization - if (event.type === "system" && event.session_id) { - parsedMessages.push({ - type: "system", - content: `Session started: ${event.session_id}`, - }); - } - - // Assistant messages with text and tool calls - else if ( - event.type === "assistant" && - Array.isArray(event.message?.content) - ) { - for (const block of event.message.content) { - if (block.type === "text" && block.text) { - parsedMessages.push({ - type: "text", - content: block.text, - }); - } else if (block.type === "tool_use") { - parsedMessages.push({ - type: "tool_call", - content: formatToolCall(block), - meta: { toolName: block.name }, - }); - } + // System initialization + if (event.type === "system" && event.session_id) { + parsedMessages.push({ + type: "system", + content: `Session started: ${event.session_id}`, + timestamp: chunk.timestamp, + }); } - } - // User messages with tool results - else if ( - event.type === "user" && - Array.isArray(event.message?.content) - ) { - for (const block of event.message.content) { - if (block.type === "tool_result") { - const rawContent = block.content; - const output = - typeof rawContent === "string" - ? rawContent - : Array.isArray(rawContent) - ? rawContent - .map((c: any) => c.text ?? JSON.stringify(c)) - .join("\n") - : (event.tool_use_result?.stdout || ""); - const stderr = event.tool_use_result?.stderr; - - if (stderr) { + // Assistant messages with text and tool calls + else if ( + event.type === "assistant" && + Array.isArray(event.message?.content) + ) { + for (const block of event.message.content) { + if (block.type === "text" && block.text) { parsedMessages.push({ - type: "error", - content: stderr, - meta: { isError: true }, + type: "text", + content: block.text, + timestamp: chunk.timestamp, }); - } else if (output) { - const displayOutput = - output.length > 1000 - ? output.substring(0, 1000) + "\n... (truncated)" - : output; + } else if (block.type === "tool_use") { parsedMessages.push({ - type: "tool_result", - content: displayOutput, + type: "tool_call", + content: formatToolCall(block), + timestamp: chunk.timestamp, + meta: { toolName: block.name }, }); } } } - } - // Legacy streaming format - else if (event.type === "stream_event" && event.event?.delta?.text) { + // User messages with tool results + else if ( + event.type === "user" && + Array.isArray(event.message?.content) + ) { + for (const block of event.message.content) { + if (block.type === "tool_result") { + const rawContent = block.content; + const output = + typeof rawContent === "string" + ? rawContent + : Array.isArray(rawContent) + ? rawContent + .map((c: any) => c.text ?? JSON.stringify(c)) + .join("\n") + : (event.tool_use_result?.stdout || ""); + const stderr = event.tool_use_result?.stderr; + + if (stderr) { + parsedMessages.push({ + type: "error", + content: stderr, + timestamp: chunk.timestamp, + meta: { isError: true }, + }); + } else if (output) { + const displayOutput = + output.length > 1000 + ? output.substring(0, 1000) + "\n... (truncated)" + : output; + parsedMessages.push({ + type: "tool_result", + content: displayOutput, + timestamp: chunk.timestamp, + }); + } + } + } + } + + // Legacy streaming format + else if (event.type === "stream_event" && event.event?.delta?.text) { + parsedMessages.push({ + type: "text", + content: event.event.delta.text, + timestamp: chunk.timestamp, + }); + } + + // Session completion + else if (event.type === "result") { + parsedMessages.push({ + type: "session_end", + content: event.is_error ? "Session failed" : "Session completed", + timestamp: chunk.timestamp, + meta: { + isError: event.is_error, + cost: event.total_cost_usd, + duration: event.duration_ms, + }, + }); + } + } catch { + // Not JSON, display as-is parsedMessages.push({ - type: "text", - content: event.event.delta.text, + type: "error", + content: line, + timestamp: chunk.timestamp, + meta: { isError: true }, }); } - - // Session completion - else if (event.type === "result") { - parsedMessages.push({ - type: "session_end", - content: event.is_error ? "Session failed" : "Session completed", - meta: { - isError: event.is_error, - cost: event.total_cost_usd, - duration: event.duration_ms, - }, - }); - } - } catch { - // Not JSON, display as-is - parsedMessages.push({ - type: "error", - content: line, - meta: { isError: true }, - }); } } return parsedMessages; diff --git a/apps/web/src/routes/agents.tsx b/apps/web/src/routes/agents.tsx index 95ff6ce..d03f7a5 100644 --- a/apps/web/src/routes/agents.tsx +++ b/apps/web/src/routes/agents.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router"; import { motion } from "motion/react"; import { AlertCircle, RefreshCw, Terminal, Users } from "lucide-react"; @@ -9,8 +9,9 @@ import { Skeleton } from "@/components/Skeleton"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc"; import { AgentOutputViewer } from "@/components/AgentOutputViewer"; +import { AgentDetailsPanel } from "@/components/AgentDetailsPanel"; import { AgentActions } from "@/components/AgentActions"; -import { formatRelativeTime } from "@/lib/utils"; +import { formatRelativeTime, cn } from "@/lib/utils"; import { modeLabel } from "@/lib/labels"; import { StatusDot } from "@/components/StatusDot"; import { useLiveUpdates } from "@/hooks"; @@ -29,7 +30,12 @@ export const Route = createFileRoute("/agents")({ function AgentsPage() { const [selectedAgentId, setSelectedAgentId] = useState(null); + const [activeTab, setActiveTab] = useState<'output' | 'details'>('output'); const { filter } = useSearch({ from: "/agents" }); + + useEffect(() => { + setActiveTab('output'); + }, [selectedAgentId]); const navigate = useNavigate(); // Live updates @@ -308,15 +314,49 @@ function AgentsPage() { )}
- {/* Right: Output Viewer */} + {/* Right: Output/Details Viewer */}
{selectedAgent ? ( - +
+ {/* Tab bar */} +
+ + +
+ {/* Panel content */} +
+ {activeTab === 'output' ? ( + + ) : ( + + )} +
+
) : (
diff --git a/apps/web/src/routes/initiatives/$id.tsx b/apps/web/src/routes/initiatives/$id.tsx index 7ff848e..993c1bb 100644 --- a/apps/web/src/routes/initiatives/$id.tsx +++ b/apps/web/src/routes/initiatives/$id.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { motion } from "motion/react"; import { AlertCircle } from "lucide-react"; @@ -11,6 +12,7 @@ import { ExecutionTab } from "@/components/ExecutionTab"; import { ReviewTab } from "@/components/review"; import { PipelineTab } from "@/components/pipeline"; import { useLiveUpdates } from "@/hooks"; +import type { LiveUpdateRule } from "@/hooks"; type Tab = "content" | "plan" | "execution" | "review"; const TABS: Tab[] = ["content", "plan", "execution", "review"]; @@ -27,13 +29,17 @@ function InitiativeDetailPage() { const { tab: activeTab } = Route.useSearch(); const navigate = useNavigate(); - // 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'] }, - ]); + // Single SSE stream for all live updates — memoized to avoid re-subscribe on render + const liveUpdateRules = useMemo(() => [ + { prefix: 'initiative:', invalidate: ['getInitiative'] }, + { prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks', 'getPhaseDependencies', 'listPhaseTaskDependencies'] }, + { prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies', 'getPhaseDependencies'] }, + { prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent', 'getTaskAgent', 'getActiveConflictAgent'] }, + { prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] }, + { prefix: 'changeset:', invalidate: ['getChangeSet', 'listChangeSets'] }, + { prefix: 'preview:', invalidate: ['listPreviews', 'getPreviewStatus'] }, + ], []); + useLiveUpdates(liveUpdateRules); // tRPC queries const initiativeQuery = trpc.getInitiative.useQuery({ id }); diff --git a/apps/web/src/routes/initiatives/index.tsx b/apps/web/src/routes/initiatives/index.tsx index 5407dd1..140b7c3 100644 --- a/apps/web/src/routes/initiatives/index.tsx +++ b/apps/web/src/routes/initiatives/index.tsx @@ -5,7 +5,7 @@ import { Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { InitiativeList } from "@/components/InitiativeList"; import { CreateInitiativeDialog } from "@/components/CreateInitiativeDialog"; -import { useLiveUpdates } from "@/hooks"; +import { useLiveUpdates, INITIATIVE_LIST_RULES } from "@/hooks"; import { trpc } from "@/lib/trpc"; export const Route = createFileRoute("/initiatives/")({ @@ -29,10 +29,7 @@ function DashboardPage() { const projectsQuery = trpc.listProjects.useQuery(); // Single SSE stream for live updates - useLiveUpdates([ - { prefix: 'task:', invalidate: ['listInitiatives'] }, - { prefix: 'phase:', invalidate: ['listInitiatives'] }, - ]); + useLiveUpdates(INITIATIVE_LIST_RULES); return ( /` +2. Manager generates alias (adjective-animal), creates DB record. Appends inter-agent communication and preview instructions unless `skipPromptExtras: true` (used by conflict-resolution agents to keep prompts lean). +3. `AgentProcessManager.createProjectWorktrees()` — creates git worktrees at `agent-workdirs///`. After creation, each project subdirectory is verified to exist; missing worktrees throw immediately to prevent agents running in the wrong directory. 4. `file-io.writeInputFiles()` — writes `.cw/input/` with assignment files (initiative, pages, phase, task) and read-only context dirs (`context/phases/`, `context/tasks/`) 5. Provider config builds spawn command via `buildSpawnCommand()` 6. `spawnDetached()` — launches detached child process with file output redirection @@ -115,6 +115,30 @@ cw account add --token --email user@example.com Stored as `credentials: {"claudeAiOauth":{"accessToken":""}}` and `configJson: {"hasCompletedOnboarding":true}`. +## Errand Agent Support + +### `sendUserMessage(agentId, message)` + +Delivers a user message directly to a running or idle errand agent without going through the conversations table. Used by the `errand.sendMessage` tRPC procedure. + +**Steps**: look up agent → validate status (`running`|`idle`) → validate `sessionId` → clear signal.json → update status to `running` → build resume command → stop active tailer/poll → spawn detached → start polling. + +**Key difference from `resumeForConversation`**: no `conversationResumeLocks`, no conversations table entry, raw message passed as resume prompt. + +### `writeErrandManifest(options)` + +Writes errand input files to `/.cw/input/`: + +- `errand.md` — YAML frontmatter with `id`, `description`, `branch`, `project` +- `manifest.json` — `{ errandId, agentId, agentName, mode: "errand" }` (no `files`/`contextFiles` arrays) +- `expected-pwd.txt` — the agent workdir path + +Written in order: `errand.md` first, `manifest.json` last (same discipline as `writeInputFiles`). + +### `buildErrandPrompt(description)` + +Builds the initial prompt for errand agents. Exported from `prompts/errand.ts` and re-exported from `prompts/index.ts`. The prompt instructs the agent to make only the changes needed for the description and write `signal.json` when done. + ## Auto-Resume for Conversations When Agent A asks Agent B a question via `cw ask` and Agent B is idle, the conversation router automatically resumes Agent B's session. This mirrors the `resumeForCommit()` pattern. @@ -153,9 +177,10 @@ Agent output is persisted to `agent_log_chunks` table and drives all live stream - DB insert → `agent:output` event emission (single source of truth for UI) - No FK to agents — survives agent deletion - Session tracking: spawn=1, resume=previousMax+1 -- Read path (`getAgentOutput` tRPC): concatenates all DB chunks (no file fallback) -- Live path (`onAgentOutput` subscription): listens for `agent:output` events -- Frontend: initial query loads from DB, subscription accumulates raw JSONL, both parsed via `parseAgentOutput()` +- Read path (`getAgentOutput` tRPC): returns timestamped chunks `{ content, createdAt }[]` from DB +- Live path (`onAgentOutput` subscription): listens for `agent:output` events (client stamps with `Date.now()`) +- Frontend: initial query loads timestamped chunks, subscription accumulates live chunks, both parsed via `parseAgentOutput()` which accepts `TimestampedChunk[]` +- Timestamps displayed inline (HH:MM:SS) on text, tool_call, system, and session_end messages ## Inter-Agent Communication @@ -236,7 +261,7 @@ All prompts follow a consistent tag ordering: |------|------|--------------------| | **execute** | `execute.ts` | ``, ``, ``, `` | | **plan** | `plan.ts` | ``, ``, ``, ``, `` | -| **detail** | `detail.ts` | ``, ``, ``, ``, `` | +| **detail** | `detail.ts` | ``, ``, ``, `` | | **discuss** | `discuss.ts` | ``, ``, ``, ``, `` | | **refine** | `refine.ts` | ``, `` | | **chat** | `chat.ts` | ``, `` — iterative refinement loop, uses action field (create/update/delete) in output files, signals "questions" after each change to stay alive | diff --git a/docs/cli-config.md b/docs/cli-config.md index 1de3be7..7def20e 100644 --- a/docs/cli-config.md +++ b/docs/cli-config.md @@ -123,6 +123,7 @@ All three commands output JSON for programmatic agent consumption. | `list` | Show accounts with exhaustion status | | `remove ` | Remove account | | `refresh` | Clear expired exhaustion markers | +| `extract [--email ]` | Extract current Claude credentials as JSON (no server required) | ## Server Wiring diff --git a/docs/database-migrations.md b/docs/database-migrations.md index 5d149fe..603d49e 100644 --- a/docs/database-migrations.md +++ b/docs/database-migrations.md @@ -5,7 +5,7 @@ This project uses [drizzle-kit](https://orm.drizzle.team/kit-docs/overview) for ## 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` + `meta/NNNN_snapshot.json`) - **Config:** `drizzle.config.ts` - **Runtime migrator:** `apps/server/db/ensure-schema.ts` (calls `drizzle-orm/better-sqlite3/migrator`) @@ -13,6 +13,8 @@ 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. + ## Workflow ### Making schema changes @@ -23,7 +25,17 @@ On every server startup, `ensureSchema(db)` runs all pending migrations from the 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 +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 + +### Important: statement breakpoints + +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 +ALTER TABLE foo ADD COLUMN bar TEXT;--> statement-breakpoint +CREATE INDEX foo_bar_idx ON foo(bar); +``` ### Applying migrations @@ -31,20 +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`. -### Checking migration status +## History -```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 -``` +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 -- **Never hand-write migration SQL.** Always use `drizzle-kit generate` from the schema. +- **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 `npx vitest run`** after generating 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. diff --git a/docs/database.md b/docs/database.md index a32cac5..2a6e994 100644 --- a/docs/database.md +++ b/docs/database.md @@ -5,8 +5,8 @@ ## Architecture - **Schema**: `apps/server/db/schema.ts` — all tables, columns, relations -- **Ports** (interfaces): `apps/server/db/repositories/*.ts` — 13 repository interfaces -- **Adapters** (implementations): `apps/server/db/repositories/drizzle/*.ts` — 13 Drizzle adapters +- **Ports** (interfaces): `apps/server/db/repositories/*.ts` — 14 repository interfaces +- **Adapters** (implementations): `apps/server/db/repositories/drizzle/*.ts` — 14 Drizzle adapters - **Barrel exports**: `apps/server/db/index.ts` re-exports everything All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.returning()` for atomic reads after writes. @@ -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 @@ -44,12 +45,13 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r | parentTaskId | text nullable self-ref FK (cascade) | decomposition hierarchy | | name | text NOT NULL | | | description | text nullable | | -| type | text enum | 'auto' \| 'checkpoint:human-verify' \| 'checkpoint:decision' \| 'checkpoint:human-action' | +| type | text enum | 'auto' | | category | text enum | 'execute' \| 'research' \| 'discuss' \| 'plan' \| 'detail' \| 'refine' \| 'verify' \| 'merge' \| 'review' | | priority | text enum | 'low' \| 'medium' \| 'high' | | status | text enum | 'pending' \| 'in_progress' \| 'completed' \| 'blocked' | | order | integer | default 0 | | summary | text nullable | Agent result summary — propagated to dependent tasks as context | +| retryCount | integer NOT NULL | default 0, incremented on agent crash auto-retry, reset on manual retry | | createdAt, updatedAt | integer/timestamp | | ### task_dependencies @@ -70,6 +72,7 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r | mode | text enum | 'execute' \| 'discuss' \| 'plan' \| 'detail' \| 'refine' | | pid | integer nullable | OS process ID | | exitCode | integer nullable | | +| prompt | text nullable | Full assembled prompt passed to agent at spawn; persisted for durability after log cleanup | | outputFilePath | text nullable | | | result | text nullable | JSON | | pendingQuestions | text nullable | JSON | @@ -194,6 +197,21 @@ Messages within a chat session. Index: `(chatSessionId)`. +### errands + +Tracks errand work items linked to a project branch, optionally assigned to an agent. + +| Column | Type | Notes | +|--------|------|-------| +| id | text PK | caller-supplied | +| description | text NOT NULL | human-readable description | +| branch | text NOT NULL | working branch name | +| baseBranch | text NOT NULL | default 'main' | +| agentId | text FK → agents (set null) | assigned agent; null if unassigned | +| projectId | text FK → projects (cascade) | owning project | +| status | text enum | active, pending_review, conflict, merged, abandoned; default 'active' | +| createdAt, updatedAt | integer/timestamp | | + ### review_comments Inline review comments on phase diffs, persisted across page reloads. @@ -214,7 +232,7 @@ Index: `(phaseId)`. ## Repository Interfaces -13 repositories, each with standard CRUD plus domain-specific methods: +14 repositories, each with standard CRUD plus domain-specific methods: | Repository | Key Methods | |-----------|-------------| @@ -231,6 +249,7 @@ Index: `(phaseId)`. | ConversationRepository | create, findById, findPendingForAgent, answer | | ChatSessionRepository | createSession, findActiveSession, findActiveSessionByAgentId, updateSession, createMessage, findMessagesBySessionId | | ReviewCommentRepository | create, findByPhaseId, resolve, unresolve, delete | +| ErrandRepository | create, findById, findAll (filter by projectId/status), update, delete | ## Migrations @@ -242,4 +261,4 @@ Key rules: - See [database-migrations.md](database-migrations.md) for full workflow - Snapshots stale after 0008; migrations 0008+ are hand-written -Current migrations: 0000 through 0030 (31 total). +Current migrations: 0000 through 0035 (36 total). diff --git a/docs/dispatch-events.md b/docs/dispatch-events.md index 82456a8..7f0ffd6 100644 --- a/docs/dispatch-events.md +++ b/docs/dispatch-events.md @@ -11,7 +11,7 @@ - **Adapter**: `TypedEventBus` using Node.js `EventEmitter` - All events implement `BaseEvent { type, timestamp, payload }` -### Event Types (57) +### Event Types (58) | Category | Events | Count | |----------|--------|-------| @@ -27,7 +27,7 @@ | **Preview** | `preview:building`, `preview:ready`, `preview:stopped`, `preview:failed` | 4 | | **Conversation** | `conversation:created`, `conversation:answered` | 2 | `conversation:created` triggers auto-resume of idle target agents via `resumeForConversation()` | | **Chat** | `chat:message_created`, `chat:session_closed` | 2 | Chat session lifecycle events | -| **Initiative** | `initiative:pending_review`, `initiative:review_approved` | 2 | Initiative-level review gate events | +| **Initiative** | `initiative:pending_review`, `initiative:review_approved`, `initiative:changes_requested` | 3 | Initiative-level review gate events | | **Project** | `project:synced`, `project:sync_failed` | 2 | Remote sync results from `ProjectSyncManager` | | **Log** | `log:entry` | 1 | @@ -48,6 +48,7 @@ PhaseChangesRequestedEvent { phaseId, initiativeId, taskId, commentCount } AccountCredentialsRefreshedEvent { accountId, expiresAt, previousExpiresAt? } InitiativePendingReviewEvent { initiativeId, branch } InitiativeReviewApprovedEvent { initiativeId, branch, strategy: 'push_branch' | 'merge_and_push' } +InitiativeChangesRequestedEvent { initiativeId, phaseId, taskId } ``` ## Task Dispatch @@ -64,10 +65,11 @@ InitiativeReviewApprovedEvent { initiativeId, branch, strategy: 'push_branch' | 2. **Dispatch** — `dispatchNext()` finds highest-priority task with all deps complete 3. **Context gathering** — Before spawn, `dispatchNext()` gathers initiative context (initiative, phase, tasks, pages) and passes as `inputContext` to the agent. Agents receive `.cw/input/task.md`, `initiative.md`, `phase.md`, `context/tasks/`, `context/phases/`, and `pages/`. 4. **Priority order**: high > medium > low, then oldest first (FIFO within priority) -5. **Checkpoint skip** — Tasks with type starting with `checkpoint:` skip auto-dispatch -6. **Planning skip** — Planning-category tasks (research, discuss, plan, detail, refine) skip auto-dispatch — they use the architect flow +5. **Planning skip** — Planning-category tasks (research, discuss, plan, detail, refine) skip auto-dispatch — they use the architect flow 7. **Summary propagation** — `completeTask()` reads the completing agent's `result.message` and stores it on the task's `summary` column. Dependent tasks see this summary in `context/tasks/.md` frontmatter. 8. **Spawn failure** — If `agentManager.spawn()` throws, the task is blocked via `blockTask()` with the error message. The dispatch cycle continues instead of crashing. +9. **Retry blocked** — `retryBlockedTask(taskId)` resets a blocked task to pending and re-queues it. Exposed via tRPC `retryBlockedTask` mutation. The UI shows a Retry button in the task slide-over when status is `blocked`. +10. **Branch validation** — Branch computation and `ensureBranch` errors are fatal for execution tasks (execute, verify, merge, review) but non-fatal for planning tasks. This prevents agents from spawning without proper branch isolation. ### DispatchManager Methods @@ -78,6 +80,7 @@ InitiativeReviewApprovedEvent { initiativeId, branch, strategy: 'push_branch' | | `getNextDispatchable()` | Get next task without dispatching | | `completeTask(taskId, agentId?)` | Complete task | | `blockTask(taskId, reason)` | Block task with reason | +| `retryBlockedTask(taskId)` | Reset blocked task to pending and re-queue | | `getQueueState()` | Return queued, ready, blocked tasks | ## Phase Dispatch @@ -110,8 +113,21 @@ InitiativeReviewApprovedEvent { initiativeId, branch, strategy: 'push_branch' | | Event | Action | |-------|--------| | `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 | +| `agent:stopped` | Auto-complete task (unless user_requested), re-dispatch queued tasks (freed agent slot) | +| `agent:crashed` | Auto-retry crashed task up to `MAX_TASK_RETRIES` (3). Increments `retryCount`, resets status to `pending`, re-queues. Exceeding retries leaves task `in_progress` for manual intervention. | +| `task:completed` | Merge task branch (if branch exists), check phase completion, dispatch next queued task | + +### Crash Recovery + +When an agent crashes (`agent:crashed` event), the orchestrator automatically retries the task: +1. Finds the task associated with the crashed agent +2. Checks `task.retryCount` against `MAX_TASK_RETRIES` (3) +3. If under limit: increments `retryCount`, resets task to `pending`, re-queues for dispatch +4. If over limit: logs warning, task stays `in_progress` for manual intervention + +On server restart, `recoverDispatchQueues()` also recovers stuck `in_progress` tasks whose agents are dead (status is not `running` or `waiting_for_input`). These are reset to `pending` and re-queued. + +Manual retry via `retryBlockedTask()` resets `retryCount` to 0, giving the task a fresh set of automatic retries. ### Coalesced Scheduling @@ -119,6 +135,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 -- **request-changes**: phase `pending_review` → user requests changes → creates revision task (category=`'review'`) → phase reset to `in_progress` → agent fixes → phase returns to `pending_review` +- **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` diff --git a/docs/frontend.md b/docs/frontend.md index 523bdbf..0797687 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -44,6 +44,7 @@ Use `mapEntityStatus(rawStatus)` from `StatusDot.tsx` to convert raw entity stat |-------|-----------|---------| | `/` | `routes/index.tsx` | Dashboard / initiative list | | `/initiatives/$id` | `routes/initiatives/$initiativeId.tsx` | Initiative detail (tabbed) | +| `/agents` | `routes/agents.tsx` | Agent list with Output / Details tab panel | | `/settings` | `routes/settings/index.tsx` | Settings page | ## Initiative Detail Tabs @@ -54,7 +55,7 @@ The initiative detail page has three tabs managed via local state (not URL param 2. **Execution Tab** — Pipeline visualization, phase management, task dispatch 3. **Review Tab** — Pending proposals from agents -## Component Inventory (73 components) +## Component Inventory (74 components) ### Core Components (`src/components/`) | Component | Purpose | @@ -66,6 +67,7 @@ The initiative detail page has three tabs managed via local state (not URL param | `StatusBadge` | Colored badge using status tokens | | `TaskRow` | Task list item with status, priority, category | | `QuestionForm` | Agent question form with options | +| `AgentDetailsPanel` | Details tab for agent right-panel: metadata, input files, effective prompt | | `InboxDetailPanel` | Agent message detail + response form | | `ProjectPicker` | Checkbox list for project selection | | `RegisterProjectDialog` | Dialog to register new git project | @@ -111,10 +113,12 @@ 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 | +| `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 | +| `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 | @@ -126,6 +130,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 | @@ -195,4 +200,4 @@ Components: `ChatSlideOver`, `ChatBubble`, `ChatInput`, `ChangeSetInline` in `sr `listInitiatives` returns an `activity` field on each initiative, computed server-side from phase statuses via `deriveInitiativeActivity()` in `apps/server/trpc/routers/initiative-activity.ts`. This eliminates per-card N+1 `listPhases` queries. -Activity states (priority order): active architect agents > `pending_review` > `executing` > `blocked` > `complete` > `ready` > `planning` > `idle` > `archived`. Each state maps to a `StatusVariant` + pulse animation in `InitiativeCard`'s `activityVisual()` function. Active architect agents (modes: discuss, plan, detail, refine) are checked first — mapping to `discussing`, `detailing`, `detailing`, `refining` states respectively — so auto-spawned agents surface activity even when no phases exist yet. `PhaseSidebarItem` also shows a spinner when a detail agent is active for its phase. +Activity states (priority order): conflict agent > `archived` > active architect agents > `pending_review` > `executing` > `blocked` > `complete` > `ready` > `planning` > `idle`. Each state maps to a `StatusVariant` + pulse animation in `InitiativeCard`'s `activityVisual()` function. Active conflict agents (name starts with `conflict-`) are checked first — returning `resolving_conflict` (urgent variant, pulsing). Active architect agents (modes: discuss, plan, detail, refine) are checked next — mapping to `discussing`, `detailing`, `detailing`, `refining` states respectively — so auto-spawned agents surface activity even when no phases exist yet. `PhaseSidebarItem` also shows a spinner when a detail agent is active for its phase. diff --git a/docs/git-process-logging.md b/docs/git-process-logging.md index 817dbfc..2e5d8c4 100644 --- a/docs/git-process-logging.md +++ b/docs/git-process-logging.md @@ -45,6 +45,9 @@ 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 | +| `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 0f7341d..0dd0aca 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -59,11 +59,15 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | dismissAgent | mutation | Dismiss agent (set userDismissedAt) | | resumeAgent | mutation | Resume with answers | | listAgents | query | All agents | -| getAgent | query | Single agent by name or ID | +| getAgent | query | Single agent by name or ID; also returns `taskName`, `initiativeName`, `exitCode` | | getAgentResult | query | Execution result | | getAgentQuestions | query | Pending questions | -| getAgentOutput | query | Full output from DB log chunks | +| getAgentOutput | query | Timestamped log chunks from DB (`{ content, createdAt }[]`) | +| getTaskAgent | query | Most recent agent assigned to a task (by taskId) | +| getAgentInputFiles | query | Files written to agent's `.cw/input/` dir (text only, sorted, 500 KB cap) | +| getAgentPrompt | query | Assembled prompt — reads from DB (`agents.prompt`) first; falls back to `.cw/agent-logs//PROMPT.md` for pre-persistence agents (1 MB cap) | | 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 | @@ -95,6 +99,9 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | getInitiativeReviewCommits | query | Commits on initiative branch not on default branch | | 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 | @@ -115,11 +122,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, 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 | @@ -205,13 +213,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 diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index db8ef20..06859b0 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -4,17 +4,18 @@ export type { PendingQuestions, QuestionItem } from '../../../apps/server/agent/ export type ExecutionMode = 'yolo' | 'review_per_phase'; export type InitiativeActivityState = - | 'idle' // Active but no phases and no agents - | 'discussing' // Discuss agent actively scoping the initiative - | 'planning' // All phases pending (no work started) - | 'detailing' // Detail/plan agent actively decomposing phases into tasks - | 'refining' // Refine agent actively working on content - | 'ready' // Phases approved, waiting to execute - | 'executing' // At least one phase in_progress - | 'pending_review' // At least one phase pending_review - | 'blocked' // At least one phase blocked (none in_progress/pending_review) - | 'complete' // All phases completed - | 'archived'; // Initiative archived + | 'idle' // Active but no phases and no agents + | 'discussing' // Discuss agent actively scoping the initiative + | 'planning' // All phases pending (no work started) + | 'detailing' // Detail/plan agent actively decomposing phases into tasks + | 'refining' // Refine agent actively working on content + | 'resolving_conflict' // Conflict resolution agent actively fixing merge conflicts + | 'ready' // Phases approved, waiting to execute + | 'executing' // At least one phase in_progress + | 'pending_review' // At least one phase pending_review + | 'blocked' // At least one phase blocked (none in_progress/pending_review) + | 'complete' // All phases completed + | 'archived'; // Initiative archived export interface InitiativeActivity { state: InitiativeActivityState;