Merge branch 'main' into cw/account-ui-conflict-1772803526476

# Conflicts:
#	apps/server/trpc/router.test.ts
#	docs/server-api.md
This commit is contained in:
Lukas May
2026-03-06 14:27:38 +01:00
117 changed files with 10348 additions and 862 deletions

3
.gitignore vendored
View File

@@ -30,6 +30,9 @@ workdir/*
# Agent working directories
agent-workdirs/
# Agent-generated screenshots
.screenshots/
# Logs
*.log
npm-debug.log*

View File

@@ -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`.

View File

@@ -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/<name>/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;
}

View File

@@ -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"');
});
});

View File

@@ -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<void> {
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<ParsedDec
});
}
export interface ParsedCommentResponse {
commentId: string;
body: string;
resolved?: boolean;
}
export async function readCommentResponses(agentWorkdir: string): Promise<ParsedCommentResponse[]> {
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<string, unknown>;
return typeof e.commentId === 'string' && typeof e.body === 'string';
})
.map((entry: Record<string, unknown>) => ({
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<ParsedPageFile[]> {
const dirPath = join(agentWorkdir, '.cw', 'output', 'pages');
return readFrontmatterDir(dirPath, (data, body, filename) => {

View File

@@ -18,6 +18,7 @@ export interface AgentInfo {
status: string;
initiativeId?: string | null;
worktreeId: string;
exitCode?: number | null;
}
export interface CleanupStrategy {

View File

@@ -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<AgentRepository>;
accountRepository?: Partial<AccountRepository>;
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<AgentRepository> = {
findById: vi.fn().mockResolvedValue(agentRecord),
};
const accountRepository: Partial<AccountRepository> = {
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<AgentRepository> = {
findById: vi.fn().mockResolvedValue(agentRecord),
};
const accountRepository: Partial<AccountRepository> = {
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<AgentRepository> = {
findById: vi.fn().mockResolvedValue({ id: 'agent-3', name: 'x', accountId: null }),
};
const accountRepository: Partial<AccountRepository> = {
markExhausted: vi.fn(),
findNextAvailable: vi.fn(),
};
const controller = makeController({ repository, accountRepository, eventBus });
await (controller as any).handleAccountExhaustion('agent-3');
expect(eventBus.emit).not.toHaveBeenCalled();
});
});
});

View File

@@ -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<void> {
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,
};
}
}

View File

@@ -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;

View File

@@ -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<void> {
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,
};
}
}

View File

@@ -142,6 +142,8 @@ export class MockAgentManager implements AgentManager {
accountId: null,
createdAt: now,
updatedAt: now,
exitCode: null,
prompt: null,
};
const record: MockAgentRecord = {

View File

@@ -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/<name>/) 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/<name>/) 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 = {

View File

@@ -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 `<role>
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\`.
</role>
<conflict_details>
**Source branch (initiative):** \`${sourceBranch}\`
**Target branch (default):** \`${targetBranch}\`
**Conflicting files:**
${conflictList}
</conflict_details>
${SIGNAL_FORMAT}
<session_startup>
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.
</session_startup>
<resolution_protocol>
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}:<file>\`
- \`git show ${targetBranch}:<file>\`
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 <file>\` (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".
</resolution_protocol>
${GIT_WORKFLOW}
<important>
- 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.
</important>`;
}
export function buildConflictResolutionDescription(
sourceBranch: string,
targetBranch: string,
conflicts: string[],
): string {
return `Resolve ${conflicts.length} merge conflict(s) between ${sourceBranch} and ${targetBranch}: ${conflicts.join(', ')}`;
}

View File

@@ -13,7 +13,7 @@ ${CODEBASE_EXPLORATION}
<output_format>
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
</output_format>
@@ -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.
</task_sizing>
<checkpoint_tasks>
- \`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\`.
</checkpoint_tasks>
<existing_context>
- Read ALL \`context/tasks/\` files before generating output
- Only create tasks for THIS phase (\`phase.md\`)

View File

@@ -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": "<one-sentence summary of what you changed>" } }
If you cannot complete the change:
{ "status": "error", "error": "<explanation>" }
Do not create any other output files.`;
}

View File

@@ -14,13 +14,26 @@ import {
} from './shared.js';
export function buildExecutePrompt(taskDescription?: string): string {
const hasReviewComments = taskDescription?.includes('[comment:');
const reviewCommentsSection = hasReviewComments
? `
<review_comments>
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.
</review_comments>`
: '';
const taskSection = taskDescription
? `
<task>
${taskDescription}
Read \`.cw/input/task.md\` for the full structured task with metadata, priority, and dependencies.
</task>`
</task>${reviewCommentsSection}`
: '';
return `<role>

View File

@@ -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';

View File

@@ -81,6 +81,15 @@ Each phase must pass: **"Could a detail agent break this into tasks without clar
</examples>
</specificity>
<subagent_usage>
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.
</subagent_usage>
<existing_context>
- Account for existing phases/tasks — don't plan work already covered
- Always generate new phase IDs — never reuse existing ones

View File

@@ -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.
</improvement_priorities>
<subagent_usage>
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.
</subagent_usage>
<rules>
- Ask 2-4 questions if you need clarification
- Preserve [[page:\$id|title]] cross-references

View File

@@ -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.
</workspace>`;
}

View File

@@ -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;
}
/**

View File

@@ -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<typeof vi.fn>).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<typeof vi.fn>).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();
});
});

View File

@@ -1334,6 +1334,32 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
}
});
// cw account extract
accountCommand
.command('extract')
.description('Extract current Claude credentials for use with the UI (does not require server)')
.option('--email <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')

View File

@@ -183,13 +183,10 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
options?.debug ?? false,
undefined, // processManagerOverride
repos.chatSessionRepository,
repos.reviewCommentRepository,
);
log.info('agent manager created');
// Reconcile agent state from any previous server session
await agentManager.reconcileAfterRestart();
log.info('agent reconciliation complete');
// Branch manager
const branchManager = new SimpleGitBranchManager();
log.info('branch manager created');
@@ -212,6 +209,9 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
repos.phaseRepository,
repos.agentRepository,
repos.pageRepository,
repos.projectRepository,
branchManager,
workspaceRoot,
);
const phaseDispatchManager = new DefaultPhaseDispatchManager(
repos.phaseRepository,
@@ -246,10 +246,17 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
conflictResolutionService,
eventBus,
workspaceRoot,
repos.agentRepository,
);
executionOrchestrator.start();
log.info('execution orchestrator started');
// Reconcile agent state from any previous server session.
// Must run AFTER orchestrator.start() so event listeners are registered
// and agent:stopped / agent:crashed events are not lost.
await agentManager.reconcileAfterRestart();
log.info('agent reconciliation complete');
// Preview manager
const previewManager = new PreviewManager(
repos.projectRepository,

View File

@@ -45,6 +45,7 @@ export interface UpdateAgentData {
accountId?: string | null;
pid?: number | null;
exitCode?: number | null;
prompt?: string | null;
outputFilePath?: string | null;
result?: string | null;
pendingQuestions?: string | null;

View File

@@ -33,4 +33,10 @@ export interface ChangeSetRepository {
findByInitiativeId(initiativeId: string): Promise<ChangeSet[]>;
findByAgentId(agentId: string): Promise<ChangeSet[]>;
markReverted(id: string): Promise<ChangeSet>;
/**
* 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<ChangeSetWithEntries[]>;
}

View File

@@ -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<ChangeSetWithEntries[]> {
// 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<string>();
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<ChangeSet> {
const [updated] = await this.db
.update(changeSets)

View File

@@ -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();
});
});
});

View File

@@ -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<Errand> {
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<ErrandWithAlias | undefined> {
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<ErrandWithAlias[]> {
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<Errand> {
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<void> {
await this.db.delete(errands).where(eq(errands.id, id));
}
}

View File

@@ -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';

View File

@@ -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<ReviewComment> {
// 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<ReviewComment | null> {
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<ReviewComment | null> {
await this.db
.update(reviewComments)

View File

@@ -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');
});
});

View File

@@ -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<NewErrand, 'createdAt' | 'updatedAt'>;
export type UpdateErrandData = Partial<Omit<NewErrand, 'id' | 'createdAt'>>;
export interface ErrandRepository {
create(data: CreateErrandData): Promise<Errand>;
findById(id: string): Promise<ErrandWithAlias | undefined>;
findAll(opts?: { projectId?: string; status?: ErrandStatus }): Promise<ErrandWithAlias[]>;
update(id: string, data: UpdateErrandData): Promise<Errand>;
delete(id: string): Promise<void>;
}

View File

@@ -82,3 +82,11 @@ export type {
ReviewCommentRepository,
CreateReviewCommentData,
} from './review-comment-repository.js';
export type {
ErrandRepository,
ErrandWithAlias,
ErrandStatus,
CreateErrandData,
UpdateErrandData,
} from './errand-repository.js';

View File

@@ -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<ReviewComment>;
createReply(parentCommentId: string, body: string, author?: string): Promise<ReviewComment>;
findByPhaseId(phaseId: string): Promise<ReviewComment[]>;
update(id: string, body: string): Promise<ReviewComment | null>;
resolve(id: string): Promise<ReviewComment | null>;
unresolve(id: string): Promise<ReviewComment | null>;
delete(id: string): Promise<void>;

View File

@@ -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<typeof text> => 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<typeof reviewComments>;
export type NewReviewComment = InferInsertModel<typeof reviewComments>;
// ============================================================================
// 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<typeof errands>;
export type NewErrand = InferInsertModel<typeof errands>;

View File

@@ -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,
};
}

View File

@@ -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<void> {
// 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<QueuedTask | null> {
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<void> {
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.
*/

View File

@@ -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: [] }),
};
}

View File

@@ -102,6 +102,14 @@ export interface DispatchManager {
*/
blockTask(taskId: string, reason: string): Promise<void>;
/**
* 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<void>;
/**
* Get current queue state.
* Returns all queued tasks with their dispatch readiness.

View File

@@ -0,0 +1 @@
ALTER TABLE phases ADD COLUMN merge_base TEXT;

View File

@@ -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);

View File

@@ -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;

View File

@@ -0,0 +1 @@
ALTER TABLE tasks ADD COLUMN retry_count integer NOT NULL DEFAULT 0;

View File

@@ -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
);

View File

@@ -0,0 +1 @@
ALTER TABLE `agents` ADD `prompt` text;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}
}

View File

@@ -52,6 +52,7 @@ export type {
AccountCredentialsValidatedEvent,
InitiativePendingReviewEvent,
InitiativeReviewApprovedEvent,
InitiativeChangesRequestedEvent,
DomainEventMap,
DomainEventType,
} from './types.js';

View File

@@ -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 {
/**

View File

@@ -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<string, Function[]>; emitted: DomainEvent[] } {
const handlers = new Map<string, Function[]>();
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<typeof createMocks>) {
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<typeof createMockEventBus>, 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<typeof createMocks>;
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<typeof createMocks>) {
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();
});
});
});

View File

@@ -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<string, Promise<void>> = 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<AgentCrashedEvent>('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<void> {
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<void> {
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<string, typeof unresolvedComments>();
for (const c of unresolvedComments) {
const byFile = new Map<string, typeof unresolvedThreads>();
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<void> {
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<void> {
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);

View File

@@ -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<string>;
/**
* 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<string>;
/**
* Push a branch to a remote.
* Defaults to 'origin' if no remote specified.
*/
pushBranch(repoPath: string, branch: string, remote?: string): Promise<void>;
/**
* 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<MergeabilityResult>;
/**
* Fetch all refs from a remote.
* Defaults to 'origin' if no remote specified.
*/
fetchRemote(repoPath: string, remote?: string): Promise<void>;
/**
* 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<void>;
/**
* 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<void>;
}

View File

@@ -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';

View File

@@ -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 <branch> <path> <base-branch>
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 <branch> <path> <base-branch>
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<boolean> {
try {
await this.git.raw(['rev-parse', '--verify', `refs/heads/${branch}`]);
return true;
} catch {
return false;
}
}
/**
* Parse the output of git diff --name-status.
*/

View File

@@ -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<MergeResult> {
// 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<string> {
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<void> {
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<MergeabilityResult> {
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 <path>"
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<void> {
const git = simpleGit(repoPath);
await git.fetch(remote);
log.info({ repoPath, remote }, 'fetched remote');
}
async fastForwardBranch(repoPath: string, branch: string, remote = 'origin'): Promise<void> {
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<void> {
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');
}
}

View File

@@ -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[];
}
// =============================================================================

View File

@@ -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<string, GatewayRoute[]>();
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);
});
});

View File

@@ -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: `<previewId>.localhost:<port>`
* Uses subdomain-based routing: each preview gets its own `<previewId>.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<string, GatewayRoute[]>,
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');
}

View File

@@ -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.
*/

View File

@@ -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<string, unknown>).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');
});

View File

@@ -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<PreviewReadyEvent>({
@@ -604,7 +605,7 @@ export class PreviewManager {
projectId,
branch,
gatewayPort,
url: `http://${previewId}.localhost:${gatewayPort}`,
url: `http://${previewId}.localhost:${gatewayPort}/`,
mode,
status: 'running',
services: [],

View File

@@ -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' });

View File

@@ -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

View File

@@ -164,66 +164,71 @@ describe('tRPC Router', () => {
describe('addAccountByToken procedure', () => {
let mockRepo: AccountRepository;
let caller: ReturnType<typeof createCaller>;
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<typeof vi.fn>).mockResolvedValue(null);
(mockRepo.create as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(existingAccount);
(mockRepo.updateAccountAuth as ReturnType<typeof vi.fn>).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();

View File

@@ -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> = {}): 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<string, unknown> = {}) {
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 });
});
});

View File

@@ -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<AgentInfo | null> => {
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<AgentInfo | null> => {
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<string> => {
.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) };
}),
};
}

View File

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

View File

@@ -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 };
}),
};
}

View File

@@ -9,6 +9,7 @@ export interface ActiveArchitectAgent {
initiativeId: string;
mode: string;
status: string;
name?: string;
}
const MODE_TO_STATE: Record<string, InitiativeActivityState> = {
@@ -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) {

View File

@@ -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<string, Array<{ id: string; name: string }>>();
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 <branch> <base>`, 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,
});
}),
};
}

View File

@@ -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(),
})),
}))

View File

@@ -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<string, typeof allComments>();
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 };

View File

@@ -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: ['<query-key>'] }
// 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);

View File

@@ -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 };
}),

View File

@@ -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',
];
/**

View File

@@ -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 (
<div className="h-full overflow-y-auto p-4 space-y-6">
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Metadata</h3>
<MetadataSection agentId={agentId} />
</section>
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Input Files</h3>
<InputFilesSection agentId={agentId} />
</section>
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Effective Prompt</h3>
<EffectivePromptSection agentId={agentId} />
</section>
</div>
);
}
function MetadataSection({ agentId }: { agentId: string }) {
const query = trpc.getAgent.useQuery({ id: agentId });
if (query.isLoading) {
return (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="line" />
))}
</div>
);
}
if (query.isError) {
return (
<div className="space-y-2">
<p className="text-sm text-destructive">{query.error.message}</p>
<Button variant="outline" size="sm" onClick={() => void query.refetch()}>Retry</Button>
</div>
);
}
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: (
<span className="flex items-center gap-1.5">
<StatusDot status={agent.status} size="sm" />
{agent.status}
</span>
),
},
{
label: 'Mode',
value: modeLabel(agent.mode),
},
{
label: 'Provider',
value: agent.provider,
},
{
label: 'Initiative',
value: agent.initiativeId ? (
<Link
to="/initiatives/$initiativeId"
params={{ initiativeId: agent.initiativeId }}
className="underline underline-offset-2"
>
{(agent as { initiativeName?: string | null }).initiativeName ?? agent.initiativeId}
</Link>
) : '—',
},
{
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: (
<span className={agent.exitCode === 1 ? 'text-destructive' : ''}>
{agent.exitCode ?? '—'}
</span>
),
});
}
return (
<div>
{rows.map(({ label, value }) => (
<div key={label} className="flex items-center gap-4 py-1.5 border-b border-border/30 last:border-0">
<span className="w-28 shrink-0 text-xs text-muted-foreground">{label}</span>
<span className="text-sm">{value}</span>
</div>
))}
</div>
);
}
function InputFilesSection({ agentId }: { agentId: string }) {
const query = trpc.getAgentInputFiles.useQuery({ id: agentId });
const [selectedFile, setSelectedFile] = useState<string | null>(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 (
<div className="space-y-2">
<Skeleton variant="line" />
<Skeleton variant="line" />
<Skeleton variant="line" />
</div>
);
}
if (query.isError) {
return (
<div className="space-y-2">
<p className="text-sm text-destructive">{query.error.message}</p>
<Button variant="outline" size="sm" onClick={() => void query.refetch()}>Retry</Button>
</div>
);
}
const data = query.data;
if (!data) return null;
if (data.reason === 'worktree_missing') {
return <p className="text-sm text-muted-foreground">Worktree no longer exists input files unavailable</p>;
}
if (data.reason === 'input_dir_missing') {
return <p className="text-sm text-muted-foreground">Input directory not found this agent may not have received input files</p>;
}
const { files } = data;
if (files.length === 0) {
return <p className="text-sm text-muted-foreground">No input files found</p>;
}
return (
<div className="flex flex-col md:flex-row gap-2 min-h-0">
{/* File list */}
<div className="md:w-48 shrink-0 overflow-y-auto space-y-0.5">
{files.map(file => (
<button
key={file.name}
onClick={() => setSelectedFile(file.name)}
className={cn(
"w-full text-left px-2 py-1 text-xs rounded truncate",
selectedFile === file.name
? "bg-muted font-medium"
: "hover:bg-muted/50 text-muted-foreground"
)}
>
{file.name}
</button>
))}
</div>
{/* Content pane */}
<pre className="flex-1 text-xs font-mono overflow-auto bg-terminal rounded p-3 min-h-0">
{files.find(f => f.name === selectedFile)?.content ?? ''}
</pre>
</div>
);
}
function EffectivePromptSection({ agentId }: { agentId: string }) {
const query = trpc.getAgentPrompt.useQuery({ id: agentId });
if (query.isLoading) {
return <Skeleton variant="rect" className="h-32 w-full" />;
}
if (query.isError) {
return (
<div className="space-y-2">
<p className="text-sm text-destructive">{query.error.message}</p>
<Button variant="outline" size="sm" onClick={() => void query.refetch()}>Retry</Button>
</div>
);
}
const data = query.data;
if (!data) return null;
if (data.reason === 'prompt_not_written') {
return <p className="text-sm text-muted-foreground">Prompt file not available agent may have been spawned before this feature was added</p>;
}
if (data.content) {
return (
<pre className="text-xs font-mono overflow-y-auto max-h-[400px] bg-terminal rounded p-3 whitespace-pre-wrap">
{data.content}
</pre>
);
}
return null;
}

View File

@@ -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<ParsedMessage[]>([]);
const [follow, setFollow] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
// Accumulate raw JSONL: initial query data + live subscription chunks
const rawBufferRef = useRef<string>('');
// Accumulate timestamped chunks: initial query data + live subscription chunks
const chunksRef = useRef<TimestampedChunk[]>([]);
// 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
<div
ref={containerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-terminal p-4"
className="flex-1 overflow-y-auto overflow-x-hidden bg-terminal p-4"
>
{isLoading ? (
<div className="text-terminal-muted text-sm">Loading output...</div>
) : !hasOutput ? (
<div className="text-terminal-muted text-sm">No output yet...</div>
) : (
<div className="space-y-2">
<div className="space-y-2 min-w-0">
{messages.map((message, index) => (
<div key={index} className={getMessageStyling(message.type)}>
{message.type === 'system' && (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs bg-terminal-border text-terminal-system">System</Badge>
<span className="text-xs text-terminal-muted">{message.content}</span>
<Timestamp date={message.timestamp} />
</div>
)}
{message.type === 'text' && (
<div className="font-mono text-sm whitespace-pre-wrap text-terminal-fg">
{message.content}
</div>
<>
<Timestamp date={message.timestamp} />
<div className="font-mono text-sm whitespace-pre-wrap break-words text-terminal-fg">
{message.content}
</div>
</>
)}
{message.type === 'tool_call' && (
<div className="border-l-2 border-terminal-tool pl-3 py-1">
<Badge variant="default" className="mb-1 text-xs">
{message.meta?.toolName}
</Badge>
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap">
<div className="border-l-2 border-terminal-tool pl-3 py-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant="default" className="text-xs">
{message.meta?.toolName}
</Badge>
<Timestamp date={message.timestamp} />
</div>
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap break-words">
{message.content}
</div>
</div>
)}
{message.type === 'tool_result' && (
<div className="border-l-2 border-terminal-result pl-3 py-1 bg-white/[0.02]">
<div className="border-l-2 border-terminal-result pl-3 py-1 bg-white/[0.02] min-w-0">
<Badge variant="outline" className="mb-1 text-xs text-terminal-result border-terminal-result">
Result
</Badge>
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap">
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap break-words">
{message.content}
</div>
</div>
)}
{message.type === 'error' && (
<div className="border-l-2 border-terminal-error pl-3 py-1 bg-terminal-error/10">
<div className="border-l-2 border-terminal-error pl-3 py-1 bg-terminal-error/10 min-w-0">
<Badge variant="destructive" className="mb-1 text-xs">
Error
</Badge>
<div className="font-mono text-xs text-terminal-error whitespace-pre-wrap">
<div className="font-mono text-xs text-terminal-error whitespace-pre-wrap break-words">
{message.content}
</div>
</div>
@@ -228,6 +236,7 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
{message.meta?.duration && (
<span className="text-xs text-terminal-muted">{(message.meta.duration / 1000).toFixed(1)}s</span>
)}
<Timestamp date={message.timestamp} />
</div>
</div>
)}
@@ -239,3 +248,16 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
</div>
);
}
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 (
<span className="shrink-0 text-[10px] text-terminal-muted/60 font-mono tabular-nums">
{formatTime(date)}
</span>
);
}

View File

@@ -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 */}
<div className="flex items-center justify-between">
<span className="min-w-0 truncate text-base font-bold">
{initiative.name}
</span>
{/* Row 1: Name + project pills + overflow menu */}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-base font-bold">
{initiative.name}
</span>
{initiative.projects && initiative.projects.length > 0 &&
initiative.projects.map((p) => (
<Badge key={p.id} variant="outline" size="xs" className="shrink-0 font-normal">
{p.name}
</Badge>
))}
</div>
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@@ -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":

View File

@@ -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";

View File

@@ -253,13 +253,13 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
{resolvedActivePageId && (
<>
{(isSaving || updateInitiativeMutation.isPending) && (
<div className="flex justify-end mb-2">
<div className="flex justify-end mb-2 h-4">
{(isSaving || updateInitiativeMutation.isPending) && (
<span className="text-xs text-muted-foreground">
Saving...
</span>
</div>
)}
)}
</div>
{activePageQuery.isSuccess && (
<input
value={pageTitle}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback } from "react";
import { useEffect, useRef, useCallback, useMemo } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
@@ -36,33 +36,33 @@ export function TiptapEditor({
const onPageLinkDeletedRef = useRef(onPageLinkDeleted);
onPageLinkDeletedRef.current = onPageLinkDeleted;
const pageLinkDeletionDetector = createPageLinkDeletionDetector(onPageLinkDeletedRef);
const baseExtensions = [
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,
];
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(
{

View File

@@ -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(

View File

@@ -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<SlideOverTab>("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) {
</button>
</div>
{/* Tab bar */}
<div className="flex gap-4 border-b border-border px-5">
{(["details", "logs"] as const).map((t) => (
<button
key={t}
className={cn(
"relative pb-2 pt-3 text-sm font-medium transition-colors",
tab === t
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setTab(t)}
>
{t === "details" ? "Details" : "Agent Logs"}
{tab === t && (
<span className="absolute inset-x-0 bottom-0 h-0.5 bg-primary" />
)}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
{/* Metadata grid */}
<div className="grid grid-cols-2 gap-3 text-sm">
<MetaField label="Status">
<StatusBadge status={task.status} />
</MetaField>
<MetaField label="Category">
<CategoryBadge category={task.category} />
</MetaField>
<MetaField label="Priority">
<PriorityText priority={task.priority} />
</MetaField>
<MetaField label="Type">
<span className="font-medium">{task.type}</span>
</MetaField>
<MetaField label="Agent" span={2}>
<span className="font-medium">
{selectedEntry.agentName ?? "Unassigned"}
</span>
</MetaField>
</div>
<div className={cn("flex-1 min-h-0", tab === "details" ? "overflow-y-auto" : "flex flex-col")}>
{tab === "details" ? (
<div className="px-5 py-4 space-y-5">
{/* Metadata grid */}
<div className="grid grid-cols-2 gap-3 text-sm">
<MetaField label="Status">
<StatusBadge status={task.status} />
</MetaField>
<MetaField label="Category">
<CategoryBadge category={task.category} />
</MetaField>
<MetaField label="Priority">
<PriorityText priority={task.priority} />
</MetaField>
<MetaField label="Type">
<span className="font-medium">{task.type}</span>
</MetaField>
<MetaField label="Agent" span={2}>
<span className="font-medium">
{selectedEntry.agentName ?? "Unassigned"}
</span>
</MetaField>
</div>
{/* Description — editable tiptap */}
<Section title="Description">
<TiptapEditor
entityId={task.id}
content={editorContent}
onUpdate={handleDescriptionUpdate}
enablePageLinks={false}
/>
</Section>
{/* Description — editable tiptap */}
<Section title="Description">
<TiptapEditor
entityId={task.id}
content={editorContent}
onUpdate={handleDescriptionUpdate}
enablePageLinks={false}
/>
</Section>
{/* Dependencies */}
<Section title="Blocked By">
{dependencies.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
) : (
<ul className="space-y-1.5">
{dependencies.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<StatusDot status={dep.status} size="sm" />
<span className="min-w-0 flex-1 truncate">
{dep.name}
</span>
</li>
))}
</ul>
)}
</Section>
{/* Dependencies */}
<Section title="Blocked By">
{dependencies.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
) : (
<ul className="space-y-1.5">
{dependencies.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<StatusDot status={dep.status} size="sm" />
<span className="min-w-0 flex-1 truncate">
{dep.name}
</span>
</li>
))}
</ul>
)}
</Section>
{/* Blocks */}
<Section title="Blocks">
{dependents.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
) : (
<ul className="space-y-1.5">
{dependents.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<StatusDot status={dep.status} size="sm" />
<span className="min-w-0 flex-1 truncate">
{dep.name}
</span>
</li>
))}
</ul>
)}
</Section>
{/* Blocks */}
<Section title="Blocks">
{dependents.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
) : (
<ul className="space-y-1.5">
{dependents.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<StatusDot status={dep.status} size="sm" />
<span className="min-w-0 flex-1 truncate">
{dep.name}
</span>
</li>
))}
</ul>
)}
</Section>
</div>
) : (
<AgentLogsTab taskId={task.id} />
)}
</div>
{/* Footer */}
<div className="flex items-center gap-2 border-t border-border px-5 py-3">
<Button
variant="outline"
size="sm"
disabled={!canQueue}
onClick={() => {
queueTaskMutation.mutate({ taskId: task.id });
close();
}}
>
Queue Task
</Button>
{task.status === "blocked" ? (
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => {
retryBlockedTaskMutation.mutate({ taskId: task.id });
close();
}}
>
<RotateCw className="h-3.5 w-3.5" />
Retry
</Button>
) : (
<Button
variant="outline"
size="sm"
disabled={!canQueue}
onClick={() => {
queueTaskMutation.mutate({ taskId: task.id });
close();
}}
>
Queue Task
</Button>
)}
<Button
variant="outline"
size="sm"
@@ -277,6 +330,43 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
);
}
// ---------------------------------------------------------------------------
// Agent Logs Tab
// ---------------------------------------------------------------------------
function AgentLogsTab({ taskId }: { taskId: string }) {
const { data: agent, isLoading } = trpc.getTaskAgent.useQuery(
{ taskId },
{ refetchOnWindowFocus: false },
);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
Loading...
</div>
);
}
if (!agent) {
return (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
No agent has been assigned to this task yet.
</div>
);
}
return (
<div className="flex-1 min-h-0">
<AgentOutputViewer
agentId={agent.id}
agentName={agent.name ?? undefined}
status={agent.status}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------

View File

@@ -7,14 +7,15 @@ interface CommentFormProps {
onCancel: () => void;
placeholder?: string;
submitLabel?: string;
initialValue?: string;
}
export const CommentForm = forwardRef<HTMLTextAreaElement, CommentFormProps>(
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();

View File

@@ -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<string, ReviewComment[]>();
for (const c of comments) {
if (c.parentCommentId) {
const arr = repliesByParent.get(c.parentCommentId) ?? [];
arr.push(c);
repliesByParent.set(c.parentCommentId, arr);
}
}
return (
<div className="space-y-2">
{comments.map((comment) => (
<div
{rootComments.map((comment) => (
<RootComment
key={comment.id}
className={`rounded border p-2.5 text-xs space-y-1.5 ${
comment.resolved
? "border-status-success-border bg-status-success-bg/50"
: "border-border bg-card"
}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<span className="font-semibold text-foreground">{comment.author}</span>
<span className="text-muted-foreground">
{formatTime(comment.createdAt)}
</span>
{comment.resolved && (
<span className="flex items-center gap-0.5 text-status-success-fg text-[10px] font-medium">
<Check className="h-3 w-3" />
Resolved
</span>
)}
</div>
<div>
{comment.resolved ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px]"
onClick={() => onUnresolve(comment.id)}
>
<RotateCcw className="h-3 w-3 mr-0.5" />
Reopen
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px]"
onClick={() => onResolve(comment.id)}
>
<Check className="h-3 w-3 mr-0.5" />
Resolve
</Button>
)}
</div>
</div>
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
{comment.body}
</p>
</div>
comment={comment}
replies={repliesByParent.get(comment.id) ?? []}
onResolve={onResolve}
onUnresolve={onUnresolve}
onReply={onReply}
onEdit={onEdit}
/>
))}
</div>
);
}
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<string | null>(null);
const replyRef = useRef<HTMLTextAreaElement>(null);
const editRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (isReplying) replyRef.current?.focus();
}, [isReplying]);
useEffect(() => {
if (editingId) editRef.current?.focus();
}, [editingId]);
const isEditingRoot = editingId === comment.id;
return (
<div className={`rounded border ${comment.resolved ? "border-status-success-border bg-status-success-bg/50" : "border-border bg-card"}`}>
{/* Root comment */}
<div className="p-2.5 text-xs space-y-1.5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<span className="font-semibold text-foreground">{comment.author}</span>
<span className="text-muted-foreground">{formatTime(comment.createdAt)}</span>
{comment.resolved && (
<span className="flex items-center gap-0.5 text-status-success-fg text-[10px] font-medium">
<Check className="h-3 w-3" />
Resolved
</span>
)}
</div>
<div className="flex items-center gap-0.5">
{onEdit && comment.author !== "agent" && !comment.resolved && (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px]"
onClick={() => setEditingId(isEditingRoot ? null : comment.id)}
>
<Pencil className="h-3 w-3 mr-0.5" />
Edit
</Button>
)}
{onReply && !comment.resolved && (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px]"
onClick={() => setIsReplying(!isReplying)}
>
<Reply className="h-3 w-3 mr-0.5" />
Reply
</Button>
)}
{comment.resolved ? (
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={() => onUnresolve(comment.id)}>
<RotateCcw className="h-3 w-3 mr-0.5" />
Reopen
</Button>
) : (
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={() => onResolve(comment.id)}>
<Check className="h-3 w-3 mr-0.5" />
Resolve
</Button>
)}
</div>
</div>
{isEditingRoot ? (
<CommentForm
ref={editRef}
initialValue={comment.body}
onSubmit={(body) => {
onEdit!(comment.id, body);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
placeholder="Edit comment..."
submitLabel="Save"
/>
) : (
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">{comment.body}</p>
)}
</div>
{/* Replies */}
{replies.length > 0 && (
<div className="border-t border-border/50">
{replies.map((reply) => (
<div
key={reply.id}
className={`px-2.5 py-2 text-xs border-l-2 ml-3 space-y-1 ${
reply.author === "agent"
? "border-l-primary bg-primary/5"
: "border-l-muted-foreground/30"
}`}
>
<div className="flex items-center justify-between gap-1.5">
<div className="flex items-center gap-1.5">
<span className={`font-semibold ${reply.author === "agent" ? "text-primary" : "text-foreground"}`}>
{reply.author}
</span>
<span className="text-muted-foreground">{formatTime(reply.createdAt)}</span>
</div>
{onEdit && reply.author !== "agent" && !comment.resolved && editingId !== reply.id && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1 text-[10px]"
onClick={() => setEditingId(reply.id)}
>
<Pencil className="h-2.5 w-2.5 mr-0.5" />
Edit
</Button>
)}
</div>
{editingId === reply.id ? (
<CommentForm
ref={editRef}
initialValue={reply.body}
onSubmit={(body) => {
onEdit!(reply.id, body);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
placeholder="Edit reply..."
submitLabel="Save"
/>
) : (
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">{reply.body}</p>
)}
</div>
))}
</div>
)}
{/* Reply form */}
{isReplying && onReply && (
<div className="border-t border-border/50 p-2.5">
<CommentForm
ref={replyRef}
onSubmit={(body) => {
onReply(comment.id, body);
setIsReplying(false);
}}
onCancel={() => setIsReplying(false)}
placeholder="Write a reply..."
submitLabel="Reply"
/>
</div>
)}
</div>
);
}
function formatTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });

View File

@@ -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<string | null>(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 (
<div className="mx-4 mt-3 rounded-lg border border-status-error-border bg-status-error-bg/50 p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-4 w-4 text-status-error-fg mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-foreground mb-1">
{conflicts.length} merge conflict{conflicts.length !== 1 ? 's' : ''} detected
</h3>
<ul className="text-xs text-muted-foreground font-mono space-y-0.5 mb-3">
{conflicts.map((file) => (
<li key={file}>{file}</li>
))}
</ul>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => spawn.mutate({ initiativeId })}
disabled={spawn.isPending}
className="h-7 text-xs"
>
{spawn.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<GitMerge className="h-3 w-3" />
)}
Resolve with Agent
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowManual(!showManual)}
className="h-7 text-xs text-muted-foreground"
>
{showManual ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Manual Resolution
</Button>
</div>
{spawn.error && (
<p className="mt-2 text-xs text-status-error-fg">{spawn.error.message}</p>
)}
{showManual && (
<div className="mt-3 rounded border border-border bg-card p-3">
<p className="text-xs text-muted-foreground mb-2">
In your project clone, run:
</p>
<pre className="text-xs font-mono bg-terminal text-terminal-fg rounded p-2 overflow-x-auto">
{`git checkout <initiative-branch>
git merge <target-branch>
# Resolve conflicts in each file
git add <resolved-files>
git commit --no-edit`}
</pre>
</div>
)}
</div>
</div>
</div>
);
}
if (state === 'running') {
return (
<div className="mx-4 mt-3 rounded-lg border border-border bg-card px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">Resolving merge conflicts...</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => stop.mutate()}
disabled={stop.isPending}
className="h-7 text-xs"
>
Stop
</Button>
</div>
</div>
);
}
if (state === 'waiting' && questions) {
return (
<div className="mx-4 mt-3 rounded-lg border border-border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
<Terminal className="h-3.5 w-3.5 text-primary" />
<h3 className="text-sm font-semibold">Agent needs input</h3>
</div>
<QuestionForm
questions={questions.questions}
onSubmit={(answers) => resume.mutate(answers)}
onCancel={() => {}}
onDismiss={() => stop.mutate()}
isSubmitting={resume.isPending}
isDismissing={stop.isPending}
/>
</div>
);
}
if (state === 'completed') {
// Auto-dismiss effect above handles this — show brief success message during transition
return (
<div className="mx-4 mt-3 rounded-lg border border-status-success-border bg-status-success-bg/50 px-4 py-3">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-3.5 w-3.5 text-status-success-fg" />
<span className="text-sm text-status-success-fg">Conflicts resolved re-checking mergeability...</span>
<Loader2 className="h-3 w-3 animate-spin text-status-success-fg" />
</div>
</div>
);
}
if (state === 'crashed') {
return (
<div className="mx-4 mt-3 rounded-lg border border-status-error-border bg-status-error-bg/50 px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertCircle className="h-3.5 w-3.5 text-status-error-fg" />
<span className="text-sm text-status-error-fg">Conflict resolution agent crashed</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
dismiss();
}}
className="h-7 text-xs"
>
Dismiss
</Button>
<Button
size="sm"
onClick={() => {
dismiss();
spawn.mutate({ initiativeId });
}}
disabled={spawn.isPending}
className="h-7 text-xs"
>
Retry
</Button>
</div>
</div>
</div>
);
}
return null;
}

View File

@@ -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<string>;
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)}
/>

View File

@@ -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 (
<div className="rounded-lg border border-border overflow-hidden">
<div className="rounded-lg border border-border overflow-clip">
{/* File header — sticky so it stays visible when scrolling */}
<button
className={`sticky top-0 z-10 flex w-full items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/90 text-left text-sm font-mono transition-colors ${leftBorderClass[file.changeType]}`}
className={`sticky z-10 flex w-full items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/90 text-left text-sm font-mono transition-colors ${leftBorderClass[file.changeType]}`}
style={{ top: 'var(--review-header-h, 0px)' }}
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
@@ -157,6 +162,8 @@ export function FileCard({
onAddComment={onAddComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
onReplyComment={onReplyComment}
onEditComment={onEditComment}
tokenMap={tokenMap}
/>
))}

View File

@@ -15,6 +15,8 @@ interface HunkRowsProps {
) => void;
onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void;
onReplyComment?: (parentCommentId: string, body: string) => void;
onEditComment?: (commentId: string, body: string) => void;
tokenMap?: LineTokenMap | null;
}
@@ -25,6 +27,8 @@ export function HunkRows({
onAddComment,
onResolveComment,
onUnresolveComment,
onReplyComment,
onEditComment,
tokenMap,
}: HunkRowsProps) {
const [commentingLine, setCommentingLine] = useState<{
@@ -98,6 +102,8 @@ export function HunkRows({
onSubmitComment={handleSubmitComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
onReplyComment={onReplyComment}
onEditComment={onEditComment}
tokens={
line.newLineNumber !== null
? tokenMap?.get(line.newLineNumber) ?? undefined

View File

@@ -1,11 +1,14 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge } from "lucide-react";
import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge, AlertTriangle, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc";
import { parseUnifiedDiff } from "./parse-diff";
import { DiffViewer } from "./DiffViewer";
import { ReviewSidebar } from "./ReviewSidebar";
import { PreviewControls } from "./PreviewControls";
import { ConflictResolutionPanel } from "./ConflictResolutionPanel";
interface InitiativeReviewProps {
initiativeId: string;
@@ -48,6 +51,61 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
{ enabled: !!selectedCommit },
);
// Mergeability check
const mergeabilityQuery = trpc.checkInitiativeMergeability.useQuery(
{ initiativeId },
{ refetchInterval: 30_000 },
);
const mergeability = mergeabilityQuery.data ?? null;
// Auto-refresh mergeability when a conflict agent completes
const conflictAgentQuery = trpc.getActiveConflictAgent.useQuery({ initiativeId });
const conflictAgentStatus = conflictAgentQuery.data?.status;
const prevConflictStatusRef = useRef(conflictAgentStatus);
useEffect(() => {
const prev = prevConflictStatusRef.current;
prevConflictStatusRef.current = conflictAgentStatus;
// When agent transitions from running/waiting to idle (completed)
if (prev && ['running', 'waiting_for_input'].includes(prev) && conflictAgentStatus === 'idle') {
void mergeabilityQuery.refetch();
void diffQuery.refetch();
void commitsQuery.refetch();
}
}, [conflictAgentStatus, mergeabilityQuery, diffQuery, commitsQuery]);
// Preview state
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
const firstProjectId = projectsQuery.data?.[0]?.id ?? null;
const previewsQuery = trpc.listPreviews.useQuery({ initiativeId });
const existingPreview = previewsQuery.data?.find(
(p) => p.initiativeId === initiativeId,
);
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
const previewStatusQuery = trpc.getPreviewStatus.useQuery(
{ previewId: activePreviewId ?? existingPreview?.id ?? "" },
{ enabled: !!(activePreviewId ?? existingPreview?.id) },
);
const preview = previewStatusQuery.data ?? existingPreview;
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}`),
});
const stopPreview = trpc.stopPreview.useMutation({
onSuccess: () => {
setActivePreviewId(null);
toast.success("Preview stopped");
previewsQuery.refetch();
},
onError: (err) => toast.error(`Failed to stop: ${err.message}`),
});
const approveMutation = trpc.approveInitiativeReview.useMutation({
onSuccess: (_data, variables) => {
const msg = variables.strategy === "merge_and_push"
@@ -87,6 +145,31 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
const sourceBranch = diffQuery.data?.sourceBranch ?? "";
const targetBranch = diffQuery.data?.targetBranch ?? "";
const previewState = firstProjectId && sourceBranch
? {
status: preview?.status === "running"
? ("running" as const)
: preview?.status === "failed"
? ("failed" as const)
: (startPreview.isPending || preview?.status === "building")
? ("building" as const)
: ("idle" as const),
url: preview?.url ?? undefined,
onStart: () =>
startPreview.mutate({
initiativeId,
projectId: firstProjectId,
branch: sourceBranch,
}),
onStop: () => {
const id = activePreviewId ?? existingPreview?.id;
if (id) stopPreview.mutate({ previewId: id });
},
isStarting: startPreview.isPending,
isStopping: stopPreview.isPending,
}
: null;
return (
<div className="rounded-lg border border-border overflow-hidden bg-card">
{/* Header */}
@@ -125,10 +208,29 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
{totalDeletions}
</span>
</div>
{/* Mergeability badge */}
{mergeabilityQuery.isLoading ? (
<Badge variant="secondary" className="text-[10px] h-5">
<Loader2 className="h-2.5 w-2.5 animate-spin mr-1" />
Checking...
</Badge>
) : mergeability?.mergeable ? (
<Badge variant="success" className="text-[10px] h-5">
<CheckCircle2 className="h-2.5 w-2.5 mr-1" />
Clean merge
</Badge>
) : mergeability && !mergeability.mergeable ? (
<Badge variant="error" className="text-[10px] h-5">
<AlertTriangle className="h-2.5 w-2.5 mr-1" />
{mergeability.conflictFiles.length} conflict{mergeability.conflictFiles.length !== 1 ? 's' : ''}
</Badge>
) : null}
</div>
{/* Right: action buttons */}
{/* Right: preview + action buttons */}
<div className="flex items-center gap-2 shrink-0">
{previewState && <PreviewControls preview={previewState} />}
<Button
variant="outline"
size="sm"
@@ -146,7 +248,8 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
<Button
size="sm"
onClick={() => approveMutation.mutate({ initiativeId, strategy: "merge_and_push" })}
disabled={approveMutation.isPending}
disabled={approveMutation.isPending || mergeability?.mergeable === false}
title={mergeability?.mergeable === false ? 'Resolve merge conflicts before merging' : undefined}
className="h-9 px-5 text-sm font-semibold shadow-sm"
>
{approveMutation.isPending ? (
@@ -154,12 +257,25 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
) : (
<GitMerge className="h-3.5 w-3.5" />
)}
Merge & Push to Default
Merge & Push to {targetBranch || "default"}
</Button>
</div>
</div>
</div>
{/* Conflict resolution panel */}
{mergeability && !mergeability.mergeable && (
<ConflictResolutionPanel
initiativeId={initiativeId}
conflicts={mergeability.conflictFiles}
onResolved={() => {
void mergeabilityQuery.refetch();
void diffQuery.refetch();
void commitsQuery.refetch();
}}
/>
)}
{/* Main content */}
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr]">
<div className="border-r border-border">

View File

@@ -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<HTMLTextAreaElement>(null);
@@ -132,7 +136,7 @@ export function LineWithComments({
{/* Existing comments on this line */}
{lineComments.length > 0 && (
<tr>
<tr data-comment-id={lineComments.find((c) => !c.parentCommentId)?.id}>
<td
colSpan={3}
className="px-3 py-2 bg-muted/20 border-y border-border/50"
@@ -141,6 +145,8 @@ export function LineWithComments({
comments={lineComments}
onResolve={onResolveComment}
onUnresolve={onUnresolveComment}
onReply={onReplyComment}
onEdit={onEditComment}
/>
</td>
</tr>

View File

@@ -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 (
<div className="flex items-center gap-1.5 text-xs text-status-active-fg">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Building...</span>
</div>
);
}
if (preview.status === "running") {
return (
<div className="flex items-center gap-1.5">
<a
href={preview.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-status-success-fg hover:underline"
>
<CircleDot className="h-3 w-3" />
Preview
<ExternalLink className="h-2.5 w-2.5" />
</a>
<Button
variant="ghost"
size="sm"
onClick={preview.onStop}
disabled={preview.isStopping}
className="h-6 w-6 p-0"
>
<Square className="h-2.5 w-2.5" />
</Button>
</div>
);
}
if (preview.status === "failed") {
return (
<Button
variant="ghost"
size="sm"
onClick={preview.onStart}
className="h-7 text-xs text-status-error-fg"
>
<RotateCcw className="h-3 w-3" />
Retry Preview
</Button>
);
}
return (
<Button
variant="ghost"
size="sm"
onClick={preview.onStart}
disabled={preview.isStarting}
className="h-7 text-xs"
>
<ExternalLink className="h-3 w-3" />
Preview
</Button>
);
}

View File

@@ -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<HTMLDivElement>;
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<HTMLDivElement>(null);
const requestConfirmRef = useRef<HTMLDivElement>(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 (
<div className="border-b border-border bg-card/80 backdrop-blur-sm">
<div ref={ref} className="border-b border-border bg-card backdrop-blur-sm sticky top-0 z-20 rounded-t-lg">
{/* Phase selector row */}
{phases.length > 1 && (
<div className="flex items-center gap-1 px-4 pt-3 pb-2 border-b border-border/50">
@@ -103,6 +107,12 @@ export function ReviewHeader({
<div className="flex gap-1 overflow-x-auto">
{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 (
<button
key={phase.id}
@@ -117,9 +127,7 @@ export function ReviewHeader({
`}
>
<span
className={`h-1.5 w-1.5 rounded-full shrink-0 ${
isActive ? "bg-primary" : "bg-status-warning-dot"
}`}
className={`h-1.5 w-1.5 rounded-full shrink-0 ${dotColor}`}
/>
{phase.name}
</button>
@@ -182,102 +190,151 @@ export function ReviewHeader({
{preview && <PreviewControls preview={preview} />}
{/* Review status / actions */}
{status === "pending" && (
<>
<Button
variant="outline"
size="sm"
onClick={onRequestChanges}
disabled={isRequestingChanges}
className="h-8 text-xs px-3 border-status-error-border/50 text-status-error-fg hover:bg-status-error-bg/50 hover:border-status-error-border"
>
{isRequestingChanges ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<X className="h-3 w-3" />
)}
Request Changes
</Button>
<div className="relative" ref={confirmRef}>
<Button
size="sm"
onClick={() => {
if (unresolvedCount > 0) return;
setShowConfirmation(true);
}}
disabled={unresolvedCount > 0}
className="h-9 px-5 text-sm font-semibold shadow-sm"
>
{unresolvedCount > 0 ? (
<>
<AlertCircle className="h-3.5 w-3.5" />
{unresolvedCount} unresolved
</>
) : (
<>
<GitMerge className="h-3.5 w-3.5" />
Approve & Merge
</>
)}
</Button>
{/* Merge confirmation dropdown */}
{showConfirmation && (
<div className="absolute right-0 top-full mt-1 z-20 w-64 rounded-lg border border-border bg-card shadow-lg p-4">
<p className="text-sm font-semibold mb-3">
Ready to merge?
</p>
<div className="space-y-1.5 mb-4">
<div className="flex items-center gap-2 text-xs">
<Check className="h-3.5 w-3.5 text-status-success-fg" />
<span className="text-muted-foreground">
0 unresolved comments
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
{viewed}/{total} files viewed
</span>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowConfirmation(false)}
className="h-8 text-xs"
>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
setShowConfirmation(false);
onApprove();
}}
className="h-8 px-4 text-xs font-semibold shadow-sm"
>
<GitMerge className="h-3.5 w-3.5" />
Merge Now
</Button>
</div>
</div>
)}
</div>
</>
)}
{status === "approved" && (
{isReadOnly ? (
<Badge variant="success" size="xs">
<Check className="h-3 w-3" />
Approved
</Badge>
)}
{status === "changes_requested" && (
<Badge variant="warning" size="xs">
<X className="h-3 w-3" />
Changes Requested
Merged
</Badge>
) : (
<>
{status === "pending" && (
<>
<div className="relative" ref={requestConfirmRef}>
<Button
variant="outline"
size="sm"
onClick={() => setShowRequestConfirm(true)}
disabled={isRequestingChanges || unresolvedCount === 0}
className="h-8 text-xs px-3 border-status-error-border/50 text-status-error-fg hover:bg-status-error-bg/50 hover:border-status-error-border"
>
{isRequestingChanges ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<X className="h-3 w-3" />
)}
Request Changes
</Button>
{showRequestConfirm && (
<div className="absolute right-0 top-full mt-1 z-30 w-64 rounded-lg border border-border bg-card shadow-lg p-4">
<p className="text-sm font-semibold mb-3">
Request changes?
</p>
<div className="space-y-1.5 mb-4">
<div className="flex items-center gap-2 text-xs">
<AlertCircle className="h-3.5 w-3.5 text-status-error-fg" />
<span className="text-muted-foreground">
{unresolvedCount} unresolved {unresolvedCount === 1 ? "comment" : "comments"} will be sent
</span>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowRequestConfirm(false)}
className="h-8 text-xs"
>
Cancel
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setShowRequestConfirm(false);
onRequestChanges();
}}
className="h-8 px-4 text-xs font-semibold shadow-sm border-status-error-border text-status-error-fg hover:bg-status-error-bg"
>
<X className="h-3.5 w-3.5" />
Request Changes
</Button>
</div>
</div>
)}
</div>
<div className="relative" ref={confirmRef}>
<Button
size="sm"
onClick={() => {
if (unresolvedCount > 0) return;
setShowConfirmation(true);
}}
disabled={unresolvedCount > 0}
className="h-9 px-5 text-sm font-semibold shadow-sm"
>
{unresolvedCount > 0 ? (
<>
<AlertCircle className="h-3.5 w-3.5" />
{unresolvedCount} unresolved
</>
) : (
<>
<GitMerge className="h-3.5 w-3.5" />
Approve & Merge
</>
)}
</Button>
{/* Merge confirmation dropdown */}
{showConfirmation && (
<div className="absolute right-0 top-full mt-1 z-30 w-64 rounded-lg border border-border bg-card shadow-lg p-4">
<p className="text-sm font-semibold mb-3">
Ready to merge?
</p>
<div className="space-y-1.5 mb-4">
<div className="flex items-center gap-2 text-xs">
<Check className="h-3.5 w-3.5 text-status-success-fg" />
<span className="text-muted-foreground">
0 unresolved comments
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
{viewed}/{total} files viewed
</span>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowConfirmation(false)}
className="h-8 text-xs"
>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
setShowConfirmation(false);
onApprove();
}}
className="h-8 px-4 text-xs font-semibold shadow-sm"
>
<GitMerge className="h-3.5 w-3.5" />
Merge Now
</Button>
</div>
</div>
)}
</div>
</>
)}
{status === "approved" && (
<Badge variant="success" size="xs">
<Check className="h-3 w-3" />
Approved
</Badge>
)}
{status === "changes_requested" && (
<Badge variant="warning" size="xs">
<X className="h-3 w-3" />
Changes Requested
</Badge>
)}
</>
)}
</div>
</div>
@@ -285,66 +342,3 @@ export function ReviewHeader({
);
}
function PreviewControls({ preview }: { preview: PreviewState }) {
if (preview.status === "building" || preview.isStarting) {
return (
<div className="flex items-center gap-1.5 text-xs text-status-active-fg">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Building...</span>
</div>
);
}
if (preview.status === "running") {
return (
<div className="flex items-center gap-1.5">
<a
href={preview.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-status-success-fg hover:underline"
>
<CircleDot className="h-3 w-3" />
Preview
<ExternalLink className="h-2.5 w-2.5" />
</a>
<Button
variant="ghost"
size="sm"
onClick={preview.onStop}
disabled={preview.isStopping}
className="h-6 w-6 p-0"
>
<Square className="h-2.5 w-2.5" />
</Button>
</div>
);
}
if (preview.status === "failed") {
return (
<Button
variant="ghost"
size="sm"
onClick={preview.onStart}
className="h-7 text-xs text-status-error-fg"
>
<RotateCcw className="h-3 w-3" />
Retry Preview
</Button>
);
}
return (
<Button
variant="ghost"
size="sm"
onClick={preview.onStart}
disabled={preview.isStarting}
className="h-7 text-xs"
>
<ExternalLink className="h-3 w-3" />
Preview
</Button>
);
}

View File

@@ -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<string>;
}) {
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({
</div>
)}
{/* Comment summary */}
{/* Discussions — individual threads */}
{comments.length > 0 && (
<div className="space-y-1.5">
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
Discussions
</h4>
<div className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1 text-muted-foreground">
<MessageSquare className="h-3 w-3" />
{comments.length}
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider flex items-center justify-between">
<span>Discussions</span>
<span className="flex items-center gap-2 font-normal normal-case">
{unresolvedCount > 0 && (
<span className="flex items-center gap-0.5 text-status-warning-fg">
<Circle className="h-2.5 w-2.5" />
{unresolvedCount}
</span>
)}
{resolvedCount > 0 && (
<span className="flex items-center gap-0.5 text-status-success-fg">
<CheckCircle2 className="h-2.5 w-2.5" />
{resolvedCount}
</span>
)}
</span>
{resolvedCount > 0 && (
<span className="flex items-center gap-1 text-status-success-fg">
<CheckCircle2 className="h-3 w-3" />
{resolvedCount}
</span>
)}
{unresolvedCount > 0 && (
<span className="flex items-center gap-1 text-status-warning-fg">
<Circle className="h-3 w-3" />
{unresolvedCount}
</span>
)}
</h4>
<div className="space-y-0.5">
{comments
.filter((c) => !c.parentCommentId)
.map((thread) => {
const replyCount = comments.filter(
(c) => c.parentCommentId === thread.id,
).length;
return (
<button
key={thread.id}
className={`
flex w-full flex-col gap-0.5 rounded px-2 py-1.5 text-left
transition-colors hover:bg-accent/50
${thread.resolved ? "opacity-50" : ""}
`}
onClick={() => onCommentClick ? onCommentClick(thread.id) : onFileClick(thread.filePath)}
>
<div className="flex items-center gap-1.5 w-full min-w-0">
{thread.resolved ? (
<CheckCircle2 className="h-3 w-3 text-status-success-fg shrink-0" />
) : (
<MessageSquare className="h-3 w-3 text-status-warning-fg shrink-0" />
)}
<span className="text-[10px] font-mono text-muted-foreground truncate">
{getFileName(thread.filePath)}:{thread.lineNumber}
</span>
{replyCount > 0 && (
<span className="text-[9px] text-muted-foreground/70 shrink-0 ml-auto">
{replyCount}
</span>
)}
</div>
<span className="text-[11px] text-foreground/80 truncate pl-[18px]">
{thread.body.length > 60
? thread.body.slice(0, 57) + "..."
: thread.body}
</span>
</button>
);
})}
</div>
</div>
)}
@@ -263,7 +305,7 @@ function FilesView({
<div className="space-y-0.5">
{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;

View File

@@ -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<string | null>(null);
const [viewedFiles, setViewedFiles] = useState<Set<string>>(new Set());
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const headerRef = useRef<HTMLDivElement>(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<string | null>(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<string | null>(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 (
<div className="flex h-64 items-center justify-center text-muted-foreground">
<p>No phases pending review</p>
@@ -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 (
<div className="rounded-lg border border-border overflow-hidden bg-card">
<div
className="rounded-lg border border-border bg-card"
style={{ '--review-header-h': `${headerHeight}px` } as React.CSSProperties}
>
{/* Header: phase selector + toolbar */}
<ReviewHeader
phases={pendingReviewPhases.map((p) => ({ 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 */}
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr]">
{/* Left: Sidebar — sticky so icon strip stays visible */}
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] rounded-b-lg">
{/* Left: Sidebar — sticky to viewport, scrolls independently */}
<div className="border-r border-border">
<div className="sticky top-0 h-[calc(100vh-12rem)]">
<div
className="sticky overflow-hidden"
style={{
top: `${headerHeight}px`,
maxHeight: `calc(100vh - ${headerHeight}px)`,
}}
>
<ReviewSidebar
files={allFiles}
comments={comments}
onFileClick={handleFileClick}
onCommentClick={handleCommentClick}
selectedCommit={selectedCommit}
activeFiles={files}
commits={commits}
@@ -353,6 +404,8 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
onAddComment={handleAddComment}
onResolveComment={handleResolveComment}
onUnresolveComment={handleUnresolveComment}
onReplyComment={handleReplyComment}
onEditComment={handleEditComment}
viewedFiles={viewedFiles}
onToggleViewed={toggleViewed}
onRegisterRef={registerFileRef}

View File

@@ -34,6 +34,7 @@ export interface ReviewComment {
author: string;
createdAt: string;
resolved: boolean;
parentCommentId?: string | null;
}
export type ReviewStatus = "pending" | "approved" | "changes_requested";

Some files were not shown because too many files have changed in this diff Show More