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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -30,6 +30,9 @@ workdir/*
|
||||
# Agent working directories
|
||||
agent-workdirs/
|
||||
|
||||
# Agent-generated screenshots
|
||||
.screenshots/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface AgentInfo {
|
||||
status: string;
|
||||
initiativeId?: string | null;
|
||||
worktreeId: string;
|
||||
exitCode?: number | null;
|
||||
}
|
||||
|
||||
export interface CleanupStrategy {
|
||||
|
||||
155
apps/server/agent/lifecycle/controller.test.ts
Normal file
155
apps/server/agent/lifecycle/controller.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,8 @@ export class MockAgentManager implements AgentManager {
|
||||
accountId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
exitCode: null,
|
||||
prompt: null,
|
||||
};
|
||||
|
||||
const record: MockAgentRecord = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
79
apps/server/agent/prompts/conflict-resolution.ts
Normal file
79
apps/server/agent/prompts/conflict-resolution.ts
Normal 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(', ')}`;
|
||||
}
|
||||
@@ -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\`)
|
||||
|
||||
16
apps/server/agent/prompts/errand.ts
Normal file
16
apps/server/agent/prompts/errand.ts
Normal 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.`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
90
apps/server/cli/extract.test.ts
Normal file
90
apps/server/cli/extract.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
336
apps/server/db/repositories/drizzle/errand.test.ts
Normal file
336
apps/server/db/repositories/drizzle/errand.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
89
apps/server/db/repositories/drizzle/errand.ts
Normal file
89
apps/server/db/repositories/drizzle/errand.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
15
apps/server/db/repositories/errand-repository.ts
Normal file
15
apps/server/db/repositories/errand-repository.ts
Normal 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>;
|
||||
}
|
||||
@@ -82,3 +82,11 @@ export type {
|
||||
ReviewCommentRepository,
|
||||
CreateReviewCommentData,
|
||||
} from './review-comment-repository.js';
|
||||
|
||||
export type {
|
||||
ErrandRepository,
|
||||
ErrandWithAlias,
|
||||
ErrandStatus,
|
||||
CreateErrandData,
|
||||
UpdateErrandData,
|
||||
} from './errand-repository.js';
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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: [] }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
1
apps/server/drizzle/0031_add_phase_merge_base.sql
Normal file
1
apps/server/drizzle/0031_add_phase_merge_base.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE phases ADD COLUMN merge_base TEXT;
|
||||
2
apps/server/drizzle/0032_add_comment_threading.sql
Normal file
2
apps/server/drizzle/0032_add_comment_threading.sql
Normal 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);
|
||||
5
apps/server/drizzle/0033_drop_approval_columns.sql
Normal file
5
apps/server/drizzle/0033_drop_approval_columns.sql
Normal 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;
|
||||
1
apps/server/drizzle/0034_add_task_retry_count.sql
Normal file
1
apps/server/drizzle/0034_add_task_retry_count.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE tasks ADD COLUMN retry_count integer NOT NULL DEFAULT 0;
|
||||
13
apps/server/drizzle/0035_faulty_human_fly.sql
Normal file
13
apps/server/drizzle/0035_faulty_human_fly.sql
Normal 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
|
||||
);
|
||||
1
apps/server/drizzle/0036_icy_silvermane.sql
Normal file
1
apps/server/drizzle/0036_icy_silvermane.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `agents` ADD `prompt` text;
|
||||
1864
apps/server/drizzle/meta/0032_snapshot.json
Normal file
1864
apps/server/drizzle/meta/0032_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1974
apps/server/drizzle/meta/0035_snapshot.json
Normal file
1974
apps/server/drizzle/meta/0035_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1159
apps/server/drizzle/meta/0036_snapshot.json
Normal file
1159
apps/server/drizzle/meta/0036_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export type {
|
||||
AccountCredentialsValidatedEvent,
|
||||
InitiativePendingReviewEvent,
|
||||
InitiativeReviewApprovedEvent,
|
||||
InitiativeChangesRequestedEvent,
|
||||
DomainEventMap,
|
||||
DomainEventType,
|
||||
} from './types.js';
|
||||
|
||||
@@ -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 {
|
||||
/**
|
||||
|
||||
369
apps/server/execution/orchestrator.test.ts
Normal file
369
apps/server/execution/orchestrator.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
327
apps/server/trpc/routers/agent.test.ts
Normal file
327
apps/server/trpc/routers/agent.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})),
|
||||
}))
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
230
apps/web/src/components/AgentDetailsPanel.tsx
Normal file
230
apps/web/src/components/AgentDetailsPanel.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" });
|
||||
|
||||
180
apps/web/src/components/review/ConflictResolutionPanel.tsx
Normal file
180
apps/web/src/components/review/ConflictResolutionPanel.tsx
Normal 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;
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
81
apps/web/src/components/review/PreviewControls.tsx
Normal file
81
apps/web/src/components/review/PreviewControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user