Add userDismissedAt field to agents schema

This commit is contained in:
Lukas May
2026-02-07 00:33:12 +01:00
parent 111ed0962f
commit 2877484012
224 changed files with 30873 additions and 4672 deletions

View File

@@ -0,0 +1,67 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir, platform } from 'node:os';
import { execa } from 'execa';
export interface ExtractedAccount {
email: string;
accountUuid: string;
configJson: object;
credentials: string;
}
/**
* Resolve the Claude Code config path with fallback logic.
* Primary: ~/.claude/.claude.json (if it exists and has oauthAccount)
* Fallback: ~/.claude.json
*/
function getClaudeConfigPath(): string {
const home = homedir();
const primary = join(home, '.claude', '.claude.json');
const fallback = join(home, '.claude.json');
if (existsSync(primary)) {
try {
const json = JSON.parse(readFileSync(primary, 'utf-8'));
if (json.oauthAccount) return primary;
} catch {
// invalid JSON, fall through
}
}
return fallback;
}
export async function extractCurrentClaudeAccount(): Promise<ExtractedAccount> {
const home = homedir();
// 1. Read Claude config (with fallback logic matching ccswitch)
const configPath = getClaudeConfigPath();
const configRaw = readFileSync(configPath, 'utf-8');
const configJson = JSON.parse(configRaw);
const email = configJson.oauthAccount?.emailAddress;
const accountUuid = configJson.oauthAccount?.accountUuid;
if (!email || !accountUuid) {
throw new Error('No Claude account found. Please log in with `claude` first.');
}
// 2. Read credentials (platform-specific)
let credentials: string;
if (platform() === 'darwin') {
// macOS: read from Keychain
const { stdout } = await execa('security', [
'find-generic-password',
'-s', 'Claude Code-credentials',
'-w',
]);
credentials = stdout;
} else {
// Linux: read from file
const credPath = join(home, '.claude', '.credentials.json');
credentials = readFileSync(credPath, 'utf-8');
}
return { email, accountUuid, configJson, credentials };
}

View File

@@ -0,0 +1,5 @@
export { extractCurrentClaudeAccount, type ExtractedAccount } from './extractor.js';
export { setupAccountConfigDir } from './setup.js';
export { getAccountConfigDir } from './paths.js';
export { checkAccountHealth, ensureAccountCredentials } from './usage.js';
export type { AccountHealthResult, AccountUsage, UsageTier } from './usage.js';

View File

@@ -0,0 +1,5 @@
import { join } from 'node:path';
export function getAccountConfigDir(workspaceRoot: string, accountId: string): string {
return join(workspaceRoot, '.cw', 'accounts', accountId);
}

View File

@@ -0,0 +1,15 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
export function setupAccountConfigDir(
configDir: string,
extracted: { configJson: object; credentials: string },
): void {
mkdirSync(configDir, { recursive: true });
// Write .claude.json
writeFileSync(join(configDir, '.claude.json'), JSON.stringify(extracted.configJson, null, 2));
// Write .credentials.json
writeFileSync(join(configDir, '.credentials.json'), extracted.credentials);
}

332
src/agent/accounts/usage.ts Normal file
View File

@@ -0,0 +1,332 @@
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import type { Account } from '../../db/schema.js';
import type { AgentInfo } from '../types.js';
import type { AccountCredentialManager } from '../credentials/types.js';
import { createModuleLogger } from '../../logger/index.js';
import { getAccountConfigDir } from './paths.js';
import { setupAccountConfigDir } from './setup.js';
const log = createModuleLogger('account-usage');
const USAGE_API_URL = 'https://api.anthropic.com/api/oauth/usage';
const TOKEN_REFRESH_URL = 'https://console.anthropic.com/v1/oauth/token';
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const TOKEN_REFRESH_BUFFER_MS = 300_000; // 5 minutes
export interface OAuthCredentials {
accessToken: string;
refreshToken: string;
expiresAt: number; // ms epoch
subscriptionType: string | null;
rateLimitTier: string | null;
}
export interface UsageTier {
utilization: number;
resets_at: string | null;
}
export interface AccountUsage {
five_hour: UsageTier | null;
seven_day: UsageTier | null;
seven_day_sonnet: UsageTier | null;
seven_day_opus: UsageTier | null;
extra_usage: {
is_enabled: boolean;
monthly_limit: number | null;
used_credits: number | null;
utilization: number | null;
} | null;
}
export interface AccountHealthResult {
id: string;
email: string;
provider: string;
credentialsValid: boolean;
tokenValid: boolean;
tokenExpiresAt: string | null;
subscriptionType: string | null;
error: string | null;
usage: AccountUsage | null;
isExhausted: boolean;
exhaustedUntil: string | null;
lastUsedAt: string | null;
agentCount: number;
activeAgentCount: number;
}
function readCredentials(configDir: string): OAuthCredentials | null {
try {
const credPath = join(configDir, '.credentials.json');
if (!existsSync(credPath)) return null;
const raw = readFileSync(credPath, 'utf-8');
const parsed = JSON.parse(raw);
const oauth = parsed.claudeAiOauth;
if (!oauth || !oauth.accessToken || !oauth.refreshToken) return null;
return {
accessToken: oauth.accessToken,
refreshToken: oauth.refreshToken,
expiresAt: oauth.expiresAt,
subscriptionType: oauth.subscriptionType ?? null,
rateLimitTier: oauth.rateLimitTier ?? null,
};
} catch {
return null;
}
}
function isTokenExpired(credentials: OAuthCredentials): boolean {
return credentials.expiresAt < Date.now() + TOKEN_REFRESH_BUFFER_MS;
}
/**
* Write credentials back to the config directory.
* Matches ccswitch's update_credentials_with_token() behavior.
*/
function writeCredentials(
configDir: string,
accessToken: string,
refreshToken: string,
expiresIn: number,
): void {
const credPath = join(configDir, '.credentials.json');
// Read existing credentials to preserve other fields
let existing: Record<string, unknown> = {};
try {
if (existsSync(credPath)) {
existing = JSON.parse(readFileSync(credPath, 'utf-8'));
}
} catch {
// Start fresh if can't read
}
// Calculate expiry in milliseconds (matching ccswitch behavior)
const nowMs = Date.now();
const expiresAt = nowMs + (expiresIn * 1000);
// Update claudeAiOauth section
const claudeAiOauth = (existing.claudeAiOauth as Record<string, unknown>) ?? {};
claudeAiOauth.accessToken = accessToken;
claudeAiOauth.refreshToken = refreshToken;
claudeAiOauth.expiresAt = expiresAt;
existing.claudeAiOauth = claudeAiOauth;
// Ensure directory exists
mkdirSync(dirname(credPath), { recursive: true });
// Write back (compact JSON for consistency with ccswitch)
writeFileSync(credPath, JSON.stringify(existing));
log.debug({ configDir }, 'credentials written after token refresh');
}
async function refreshToken(
refreshTokenStr: string,
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number } | null> {
try {
const response = await fetch(TOKEN_REFRESH_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshTokenStr,
client_id: OAUTH_CLIENT_ID,
scope: 'user:inference user:profile',
}),
});
if (!response.ok) return null;
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in,
};
} catch {
return null;
}
}
async function fetchUsage(accessToken: string): Promise<AccountUsage | null> {
try {
const response = await fetch(USAGE_API_URL, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'anthropic-beta': 'oauth-2025-04-20',
'Content-Type': 'application/json',
},
});
if (!response.ok) return null;
const data = await response.json();
return {
five_hour: data.five_hour ?? null,
seven_day: data.seven_day ?? null,
seven_day_sonnet: data.seven_day_sonnet ?? null,
seven_day_opus: data.seven_day_opus ?? null,
extra_usage: data.extra_usage ?? null,
};
} catch {
return null;
}
}
export async function checkAccountHealth(
account: Account,
agents: AgentInfo[],
credentialManager?: AccountCredentialManager,
workspaceRoot?: string,
): Promise<AccountHealthResult> {
const configDir = workspaceRoot ? getAccountConfigDir(workspaceRoot, account.id) : null;
const accountAgents = agents.filter((a) => a.accountId === account.id);
const activeAgents = accountAgents.filter(
(a) => a.status === 'running' || a.status === 'waiting_for_input',
);
const base: AccountHealthResult = {
id: account.id,
email: account.email,
provider: account.provider,
credentialsValid: false,
tokenValid: false,
tokenExpiresAt: null,
subscriptionType: null,
error: null,
usage: null,
isExhausted: account.isExhausted,
exhaustedUntil: account.exhaustedUntil?.toISOString() ?? null,
lastUsedAt: account.lastUsedAt?.toISOString() ?? null,
agentCount: accountAgents.length,
activeAgentCount: activeAgents.length,
};
if (!configDir) {
return { ...base, error: 'Cannot derive config dir: workspaceRoot not provided' };
}
// Ensure DB credentials are written to disk so file-based checks can find them
if (account.configJson && account.credentials) {
try {
setupAccountConfigDir(configDir, {
configJson: JSON.parse(account.configJson),
credentials: account.credentials,
});
} catch (err) {
log.warn({ accountId: account.id, err: err instanceof Error ? err.message : String(err) }, 'failed to sync DB credentials to disk');
}
}
try {
// Use credential manager if provided, otherwise fall back to direct functions
let accessToken: string;
let currentExpiresAt: number;
let subscriptionType: string | null = null;
if (credentialManager) {
const result = await credentialManager.ensureValid(configDir, account.id);
if (!result.valid || !result.credentials) {
return {
...base,
credentialsValid: result.credentials !== null,
error: result.error ?? 'Credentials validation failed',
};
}
accessToken = result.credentials.accessToken;
currentExpiresAt = result.credentials.expiresAt;
subscriptionType = result.credentials.subscriptionType;
} else {
// Legacy path: direct function calls
const credentials = readCredentials(configDir);
if (!credentials) {
return {
...base,
error: 'Credentials file not found or unreadable',
};
}
accessToken = credentials.accessToken;
currentExpiresAt = credentials.expiresAt;
subscriptionType = credentials.subscriptionType;
if (isTokenExpired(credentials)) {
log.info({ accountId: account.id, email: account.email }, 'token expired, refreshing');
const refreshed = await refreshToken(credentials.refreshToken);
if (!refreshed) {
log.warn({ accountId: account.id }, 'token refresh failed');
return {
...base,
credentialsValid: true,
error: 'Token expired and refresh failed',
};
}
accessToken = refreshed.accessToken;
// Persist the refreshed credentials back to disk
const newRefreshToken = refreshed.refreshToken || credentials.refreshToken;
writeCredentials(configDir, accessToken, newRefreshToken, refreshed.expiresIn);
currentExpiresAt = Date.now() + (refreshed.expiresIn * 1000);
log.info({ accountId: account.id, expiresIn: refreshed.expiresIn }, 'token refreshed and persisted');
}
}
const usage = await fetchUsage(accessToken);
if (!usage) {
return {
...base,
credentialsValid: true,
error: 'Usage API request failed',
};
}
return {
...base,
credentialsValid: true,
tokenValid: true,
tokenExpiresAt: new Date(currentExpiresAt).toISOString(),
subscriptionType,
usage,
};
} catch (err) {
return {
...base,
error: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Ensure account credentials are valid and refreshed if needed.
* Call this before spawning an agent to ensure the credentials file
* has fresh tokens that the agent subprocess can use.
*
* Returns true if credentials are valid (or were successfully refreshed).
* Returns false if credentials are missing or refresh failed.
*
* @deprecated Use AccountCredentialManager.ensureValid() instead for event emission support.
*/
export async function ensureAccountCredentials(configDir: string): Promise<boolean> {
const credentials = readCredentials(configDir);
if (!credentials) {
log.warn({ configDir }, 'no credentials found');
return false;
}
if (!isTokenExpired(credentials)) {
log.debug({ configDir }, 'credentials valid, no refresh needed');
return true;
}
log.info({ configDir }, 'credentials expired, refreshing before spawn');
const refreshed = await refreshToken(credentials.refreshToken);
if (!refreshed) {
log.error({ configDir }, 'failed to refresh credentials');
return false;
}
const newRefreshToken = refreshed.refreshToken || credentials.refreshToken;
writeCredentials(configDir, refreshed.accessToken, newRefreshToken, refreshed.expiresIn);
log.info({ configDir, expiresIn: refreshed.expiresIn }, 'credentials refreshed before spawn');
return true;
}

34
src/agent/alias.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* Agent Alias Generator
*
* Generates unique funny aliases for agents using adjective-animal combinations.
* E.g., "jolly-penguin", "bold-eagle", "swift-otter".
*/
import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generator';
import type { AgentRepository } from '../db/repositories/agent-repository.js';
const MAX_RETRIES = 10;
/**
* Generate a unique agent alias that doesn't collide with existing agent names.
*
* @param repository - Agent repository to check for name collisions
* @returns A unique adjective-animal alias (e.g., "jolly-penguin")
*/
export async function generateUniqueAlias(repository: AgentRepository): Promise<string> {
for (let i = 0; i < MAX_RETRIES; i++) {
const alias = uniqueNamesGenerator({
dictionaries: [adjectives, animals],
separator: '-',
style: 'lowerCase',
});
const existing = await repository.findByName(alias);
if (!existing) {
return alias;
}
}
throw new Error(`Failed to generate unique alias after ${MAX_RETRIES} attempts`);
}

View File

@@ -0,0 +1,323 @@
/**
* CleanupManager — Worktree, branch, and log cleanup for agents.
*
* Extracted from MultiProviderAgentManager. Handles all filesystem
* and git cleanup operations, plus orphan detection and reconciliation.
*/
import { promisify } from 'node:util';
import { execFile } from 'node:child_process';
import { readFile, readdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { ProjectRepository } from '../db/repositories/project-repository.js';
import type { EventBus, AgentCrashedEvent } from '../events/index.js';
import { SimpleGitWorktreeManager } from '../git/manager.js';
import { getProjectCloneDir } from '../git/project-clones.js';
import { getStreamParser } from './providers/parsers/index.js';
import { FileTailer } from './file-tailer.js';
import { getProvider } from './providers/registry.js';
import { createModuleLogger } from '../logger/index.js';
import type { StreamEvent } from './providers/parsers/index.js';
const log = createModuleLogger('cleanup-manager');
const execFileAsync = promisify(execFile);
/**
* Check if a process with the given PID is still alive.
*/
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export class CleanupManager {
constructor(
private workspaceRoot: string,
private repository: AgentRepository,
private projectRepository: ProjectRepository,
private eventBus?: EventBus,
) {}
/**
* Resolve the agent's working directory path.
*/
private getAgentWorkdir(alias: string): string {
return join(this.workspaceRoot, 'agent-workdirs', alias);
}
/**
* Remove git worktrees for an agent.
* Handles both initiative-linked (multi-project) and standalone agents.
*/
async removeAgentWorktrees(alias: string, initiativeId: string | null): Promise<void> {
const agentWorkdir = this.getAgentWorkdir(alias);
try {
await readdir(agentWorkdir);
} catch {
return;
}
if (initiativeId) {
const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId);
for (const project of projects) {
try {
const clonePath = join(this.workspaceRoot, getProjectCloneDir(project.name, project.id));
const wm = new SimpleGitWorktreeManager(clonePath, undefined, agentWorkdir);
await wm.remove(project.name);
} catch (err) {
log.warn({ alias, project: project.name, err: err instanceof Error ? err.message : String(err) }, 'failed to remove project worktree');
}
}
} else {
try {
const wm = new SimpleGitWorktreeManager(this.workspaceRoot, undefined, agentWorkdir);
await wm.remove('workspace');
} catch (err) {
log.warn({ alias, err: err instanceof Error ? err.message : String(err) }, 'failed to remove standalone worktree');
}
}
await rm(agentWorkdir, { recursive: true, force: true });
await this.pruneWorktrees(initiativeId);
}
/**
* Delete agent/<alias> branches from all relevant repos.
*/
async removeAgentBranches(alias: string, initiativeId: string | null): Promise<void> {
const branchName = `agent/${alias}`;
const repoPaths: string[] = [];
if (initiativeId) {
const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId);
for (const project of projects) {
repoPaths.push(join(this.workspaceRoot, getProjectCloneDir(project.name, project.id)));
}
} else {
repoPaths.push(this.workspaceRoot);
}
for (const repoPath of repoPaths) {
try {
await execFileAsync('git', ['branch', '-D', branchName], { cwd: repoPath });
} catch {
// Branch may not exist
}
}
}
/**
* Remove log directory for an agent.
*/
async removeAgentLogs(agentId: string): Promise<void> {
const logDir = join(this.workspaceRoot, '.cw', 'agent-logs', agentId);
await rm(logDir, { recursive: true, force: true });
}
/**
* Run git worktree prune on all relevant repos.
*/
async pruneWorktrees(initiativeId: string | null): Promise<void> {
const repoPaths: string[] = [];
if (initiativeId) {
const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId);
for (const project of projects) {
repoPaths.push(join(this.workspaceRoot, getProjectCloneDir(project.name, project.id)));
}
} else {
repoPaths.push(this.workspaceRoot);
}
for (const repoPath of repoPaths) {
try {
await execFileAsync('git', ['worktree', 'prune'], { cwd: repoPath });
} catch (err) {
log.warn({ repoPath, err: err instanceof Error ? err.message : String(err) }, 'failed to prune worktrees');
}
}
}
/**
* Clean up orphaned agent workdirs (directories with no matching DB agent).
*/
async cleanupOrphanedWorkdirs(): Promise<void> {
const workdirsPath = join(this.workspaceRoot, 'agent-workdirs');
let entries: string[];
try {
entries = await readdir(workdirsPath);
} catch {
return;
}
const agents = await this.repository.findAll();
const knownAliases = new Set(agents.map(a => a.name));
for (const entry of entries) {
if (!knownAliases.has(entry)) {
log.info({ orphan: entry }, 'removing orphaned agent workdir');
try {
await rm(join(workdirsPath, entry), { recursive: true, force: true });
} catch (err) {
log.warn({ orphan: entry, err: err instanceof Error ? err.message : String(err) }, 'failed to remove orphaned workdir');
}
}
}
try {
await execFileAsync('git', ['worktree', 'prune'], { cwd: this.workspaceRoot });
} catch { /* ignore */ }
const reposPath = join(this.workspaceRoot, 'repos');
try {
const repoDirs = await readdir(reposPath);
for (const repoDir of repoDirs) {
try {
await execFileAsync('git', ['worktree', 'prune'], { cwd: join(reposPath, repoDir) });
} catch { /* ignore */ }
}
} catch { /* no repos dir */ }
}
/**
* Clean up orphaned agent log directories (directories with no matching DB agent).
*/
async cleanupOrphanedLogs(): Promise<void> {
const logsPath = join(this.workspaceRoot, '.cw', 'agent-logs');
let entries: string[];
try {
entries = await readdir(logsPath);
} catch {
return;
}
const agents = await this.repository.findAll();
const knownIds = new Set(agents.map(a => a.id));
for (const entry of entries) {
if (!knownIds.has(entry)) {
log.info({ orphan: entry }, 'removing orphaned agent log dir');
try {
await rm(join(logsPath, entry), { recursive: true, force: true });
} catch (err) {
log.warn({ orphan: entry, err: err instanceof Error ? err.message : String(err) }, 'failed to remove orphaned log dir');
}
}
}
}
/**
* Reconcile agent state after server restart.
* Checks all agents in 'running' status:
* - If PID is still alive: create FileTailer to resume streaming
* - If PID is dead but output file exists: process the output
* - Otherwise: mark as crashed
*
* @param activeAgents - Shared map from manager to register live agents
* @param onStreamEvent - Callback for stream events from tailer
* @param onAgentOutput - Callback to process raw agent output
* @param pollForCompletion - Callback to start polling for completion
*/
async reconcileAfterRestart(
activeAgents: Map<string, {
agentId: string;
pid: number;
tailer: FileTailer;
outputFilePath: string;
}>,
onStreamEvent: (agentId: string, event: StreamEvent) => void,
onAgentOutput: (agentId: string, rawOutput: string, provider: NonNullable<ReturnType<typeof getProvider>>) => Promise<void>,
pollForCompletion: (agentId: string, pid: number) => void,
): Promise<void> {
const runningAgents = await this.repository.findByStatus('running');
log.info({ runningCount: runningAgents.length }, 'reconciling agents after restart');
for (const agent of runningAgents) {
const alive = agent.pid ? isPidAlive(agent.pid) : false;
log.info({ agentId: agent.id, pid: agent.pid, alive }, 'reconcile: checking agent');
if (alive && agent.outputFilePath) {
log.debug({ agentId: agent.id, pid: agent.pid }, 'reconcile: resuming streaming for alive agent');
const parser = getStreamParser(agent.provider);
const tailer = new FileTailer({
filePath: agent.outputFilePath,
agentId: agent.id,
parser,
eventBus: this.eventBus,
onEvent: (event) => onStreamEvent(agent.id, event),
startFromBeginning: false,
});
tailer.start().catch((err) => {
log.warn({ agentId: agent.id, err: err instanceof Error ? err.message : String(err) }, 'failed to start tailer during reconcile');
});
const pid = agent.pid!;
activeAgents.set(agent.id, {
agentId: agent.id,
pid,
tailer,
outputFilePath: agent.outputFilePath,
});
pollForCompletion(agent.id, pid);
} else if (agent.outputFilePath) {
try {
const rawOutput = await readFile(agent.outputFilePath, 'utf-8');
if (rawOutput.trim()) {
const provider = getProvider(agent.provider);
if (provider) {
await onAgentOutput(agent.id, rawOutput, provider);
continue;
}
}
} catch { /* file missing or empty */ }
log.warn({ agentId: agent.id }, 'reconcile: marking agent crashed');
await this.repository.update(agent.id, { status: 'crashed' });
this.emitCrashed(agent, 'Server restarted, agent output not found');
} else {
log.warn({ agentId: agent.id }, 'reconcile: marking agent crashed');
await this.repository.update(agent.id, { status: 'crashed' });
this.emitCrashed(agent, 'Server restarted while agent was running');
}
}
try {
await this.cleanupOrphanedWorkdirs();
} catch (err) {
log.warn({ err: err instanceof Error ? err.message : String(err) }, 'orphaned workdir cleanup failed');
}
try {
await this.cleanupOrphanedLogs();
} catch (err) {
log.warn({ err: err instanceof Error ? err.message : String(err) }, 'orphaned log cleanup failed');
}
}
/**
* Emit a crashed event for an agent.
*/
private emitCrashed(agent: { id: string; name: string; taskId: string | null }, error: string): void {
if (this.eventBus) {
const event: AgentCrashedEvent = {
type: 'agent:crashed',
timestamp: new Date(),
payload: {
agentId: agent.id,
name: agent.name,
taskId: agent.taskId ?? '',
error,
},
};
this.eventBus.emit(event);
}
}
}

View File

@@ -0,0 +1,126 @@
/**
* Content Serializer
*
* Converts Tiptap JSON page tree into markdown for agent prompts.
* Uses @tiptap/markdown's MarkdownManager for standard node serialization,
* with custom handling only for pageLink nodes.
*/
import { Node, type JSONContent } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import { MarkdownManager } from '@tiptap/markdown';
/**
* Minimal page shape needed for serialization.
*/
export interface PageForSerialization {
id: string;
parentPageId: string | null;
title: string;
content: string | null; // JSON string from Tiptap
sortOrder: number;
}
/**
* Server-side pageLink node — only needs schema definition + markdown rendering.
*/
const ServerPageLink = Node.create({
name: 'pageLink',
group: 'block',
atom: true,
addAttributes() {
return {
pageId: { default: null },
};
},
renderMarkdown(node: JSONContent) {
const pageId = (node.attrs?.pageId as string) ?? '';
return `[[page:${pageId}]]\n\n`;
},
});
let _manager: MarkdownManager | null = null;
function getManager(): MarkdownManager {
if (!_manager) {
_manager = new MarkdownManager({
extensions: [StarterKit, Link, ServerPageLink],
});
}
return _manager;
}
/**
* Convert a Tiptap JSON document to markdown.
*/
export function tiptapJsonToMarkdown(json: unknown): string {
if (!json || typeof json !== 'object') return '';
const doc = json as JSONContent;
if (doc.type !== 'doc' || !Array.isArray(doc.content)) return '';
return getManager().serialize(doc).trim();
}
/**
* Serialize an array of pages into a single markdown document.
* Pages are organized as a tree (root first, then children by sortOrder).
*
* Each page is marked with <!-- page:$id --> so the agent can reference them.
*/
export function serializePageTree(pages: PageForSerialization[]): string {
if (pages.length === 0) return '';
// Build parent→children map
const childrenMap = new Map<string | null, PageForSerialization[]>();
for (const page of pages) {
const parentKey = page.parentPageId;
if (!childrenMap.has(parentKey)) {
childrenMap.set(parentKey, []);
}
childrenMap.get(parentKey)!.push(page);
}
// Sort children by sortOrder
for (const children of childrenMap.values()) {
children.sort((a, b) => a.sortOrder - b.sortOrder);
}
// Render tree depth-first
const sections: string[] = [];
function renderPage(page: PageForSerialization, depth: number): void {
const headerPrefix = '#'.repeat(Math.min(depth + 1, 6));
let section = `<!-- page:${page.id} -->\n${headerPrefix} ${page.title}`;
if (page.content) {
try {
const parsed = JSON.parse(page.content);
const md = tiptapJsonToMarkdown(parsed);
if (md.trim()) {
section += `\n\n${md}`;
}
} catch {
// Invalid JSON — skip content
}
}
sections.push(section);
const children = childrenMap.get(page.id) ?? [];
for (const child of children) {
renderPage(child, depth + 1);
}
}
// Start from root pages (parentPageId is null)
const roots = childrenMap.get(null) ?? [];
for (const root of roots) {
renderPage(root, 1);
}
return sections.join('\n\n');
}

View File

@@ -0,0 +1,152 @@
/**
* CredentialHandler — Account selection, credential management, and exhaustion handling.
*
* Extracted from MultiProviderAgentManager. Handles account lifecycle:
* selecting the next available account, writing credentials to disk,
* ensuring they're fresh, and marking accounts as exhausted on failure.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { AccountRepository } from '../db/repositories/account-repository.js';
import type { AccountCredentialManager } from './credentials/types.js';
import type { Account } from '../db/schema.js';
import { ensureAccountCredentials } from './accounts/usage.js';
import { getAccountConfigDir } from './accounts/paths.js';
import { setupAccountConfigDir } from './accounts/setup.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('credential-handler');
/** Default exhaustion duration: 5 hours */
const DEFAULT_EXHAUSTION_HOURS = 5;
export class CredentialHandler {
constructor(
private workspaceRoot: string,
private accountRepository?: AccountRepository,
private credentialManager?: AccountCredentialManager,
) {}
/**
* Select the next available account for a provider.
* Clears expired exhaustion, returns least-recently-used non-exhausted account.
* Returns null if no accounts are available.
*/
async selectAccount(providerName: string): Promise<{ account: Account; accountId: string; configDir: string } | null> {
if (!this.accountRepository) return null;
await this.accountRepository.clearExpiredExhaustion();
const account = await this.accountRepository.findNextAvailable(providerName);
if (!account) return null;
const configDir = getAccountConfigDir(this.workspaceRoot, account.id);
await this.accountRepository.updateLastUsed(account.id);
return { account, accountId: account.id, configDir };
}
/**
* Write account credentials from DB to the convention-based config directory.
* Must be called before ensureCredentials so the files exist on disk.
*/
writeCredentialsToDisk(account: Account, configDir: string): void {
if (account.configJson && account.credentials) {
setupAccountConfigDir(configDir, {
configJson: JSON.parse(account.configJson),
credentials: account.credentials,
});
log.debug({ accountId: account.id, configDir }, 'wrote account credentials from DB to disk');
} else {
log.warn({ accountId: account.id }, 'account has no stored credentials in DB');
}
}
/**
* Read refreshed credentials from disk and persist back to DB.
* Called after credential refresh to keep DB in sync.
*/
async persistRefreshedCredentials(accountId: string, configDir: string): Promise<void> {
if (!this.accountRepository) return;
try {
const credPath = join(configDir, '.credentials.json');
const credentials = readFileSync(credPath, 'utf-8');
await this.accountRepository.updateCredentials(accountId, credentials);
log.debug({ accountId }, 'persisted refreshed credentials back to DB');
} catch (err) {
log.warn({ accountId, err: err instanceof Error ? err.message : String(err) }, 'failed to persist refreshed credentials to DB');
}
}
/**
* Ensure credentials are valid before spawn/resume.
* Uses credentialManager if available, otherwise falls back to legacy function.
* Returns { valid, refreshed } so callers can persist refresh back to DB.
*/
async ensureCredentials(configDir: string, accountId?: string): Promise<{ valid: boolean; refreshed: boolean }> {
if (this.credentialManager) {
const result = await this.credentialManager.ensureValid(configDir, accountId);
return { valid: result.valid, refreshed: result.refreshed };
}
const valid = await ensureAccountCredentials(configDir);
return { valid, refreshed: false };
}
/**
* Check if an error message indicates usage limit exhaustion.
*/
isUsageLimitError(errorMessage: string): boolean {
const patterns = [
'usage limit',
'rate limit',
'quota exceeded',
'too many requests',
'capacity',
'exhausted',
];
const lower = errorMessage.toLowerCase();
return patterns.some((p) => lower.includes(p));
}
/**
* Handle account exhaustion: mark current account exhausted and find next available.
* Returns the new account info if failover succeeded, null otherwise.
* Does NOT re-spawn — the caller (manager) handles that.
*/
async handleExhaustion(
accountId: string,
providerName: string,
): Promise<{ account: Account; accountId: string; configDir: string } | null> {
if (!this.accountRepository) return null;
log.warn({ accountId, provider: providerName }, 'account exhausted, attempting failover');
// Mark current account as exhausted
const exhaustedUntil = new Date(Date.now() + DEFAULT_EXHAUSTION_HOURS * 60 * 60 * 1000);
await this.accountRepository.markExhausted(accountId, exhaustedUntil);
// Find next available account
const nextAccount = await this.accountRepository.findNextAvailable(providerName);
if (!nextAccount) {
log.warn({ accountId }, 'account failover failed, no accounts available');
return null;
}
log.info({ previousAccountId: accountId, newAccountId: nextAccount.id }, 'account failover successful');
// Write credentials and ensure they're fresh
const nextConfigDir = getAccountConfigDir(this.workspaceRoot, nextAccount.id);
this.writeCredentialsToDisk(nextAccount, nextConfigDir);
const { valid, refreshed } = await this.ensureCredentials(nextConfigDir, nextAccount.id);
if (!valid) {
log.warn({ newAccountId: nextAccount.id }, 'failed to refresh failover account credentials');
return null;
}
if (refreshed) {
await this.persistRefreshedCredentials(nextAccount.id, nextConfigDir);
}
await this.accountRepository.updateLastUsed(nextAccount.id);
return { account: nextAccount, accountId: nextAccount.id, configDir: nextConfigDir };
}
}

View File

@@ -0,0 +1,318 @@
/**
* Default Account Credential Manager
*
* File-based adapter implementing AccountCredentialManager port.
* Reads/writes credentials from ~/.cw/accounts/<uuid>/.credentials.json
* and emits events on credential state changes.
*/
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import type { EventBus } from '../../events/index.js';
import type {
AccountCredentialManager,
OAuthCredentials,
RefreshResult,
CredentialValidationResult,
} from './types.js';
import type {
AccountCredentialsRefreshedEvent,
AccountCredentialsExpiredEvent,
AccountCredentialsValidatedEvent,
} from '../../events/types.js';
import { createModuleLogger } from '../../logger/index.js';
const log = createModuleLogger('credential-manager');
/** Anthropic OAuth token refresh endpoint */
const TOKEN_REFRESH_URL = 'https://console.anthropic.com/v1/oauth/token';
/** OAuth client ID for Claude CLI */
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
/** Buffer before expiry to trigger refresh (5 minutes) */
const TOKEN_REFRESH_BUFFER_MS = 300_000;
/**
* DefaultAccountCredentialManager - File-based credential management with event emission.
*
* Implements the AccountCredentialManager port for managing OAuth credentials
* stored in account config directories.
*/
export class DefaultAccountCredentialManager implements AccountCredentialManager {
constructor(private eventBus?: EventBus) {}
/**
* Read credentials from a config directory.
*/
read(configDir: string): OAuthCredentials | null {
try {
const credPath = join(configDir, '.credentials.json');
if (!existsSync(credPath)) return null;
const raw = readFileSync(credPath, 'utf-8');
const parsed = JSON.parse(raw);
const oauth = parsed.claudeAiOauth;
if (!oauth || !oauth.accessToken || !oauth.refreshToken) return null;
return {
accessToken: oauth.accessToken,
refreshToken: oauth.refreshToken,
expiresAt: oauth.expiresAt,
subscriptionType: oauth.subscriptionType ?? null,
rateLimitTier: oauth.rateLimitTier ?? null,
};
} catch {
return null;
}
}
/**
* Check if credentials are expired or about to expire.
*/
isExpired(credentials: OAuthCredentials): boolean {
return credentials.expiresAt < Date.now() + TOKEN_REFRESH_BUFFER_MS;
}
/**
* Refresh an access token using the refresh token.
*/
async refresh(configDir: string, refreshToken: string): Promise<RefreshResult | null> {
try {
const response = await fetch(TOKEN_REFRESH_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: OAUTH_CLIENT_ID,
scope: 'user:inference user:profile',
}),
});
if (!response.ok) {
log.warn({ configDir, status: response.status }, 'token refresh failed');
return null;
}
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in,
};
} catch (err) {
log.error({ configDir, err: err instanceof Error ? err.message : String(err) }, 'token refresh error');
return null;
}
}
/**
* Write updated credentials to the config directory.
*/
write(
configDir: string,
accessToken: string,
refreshToken: string,
expiresIn: number,
): void {
const credPath = join(configDir, '.credentials.json');
// Read existing credentials to preserve other fields
let existing: Record<string, unknown> = {};
try {
if (existsSync(credPath)) {
existing = JSON.parse(readFileSync(credPath, 'utf-8'));
}
} catch {
// Start fresh if can't read
}
// Calculate expiry in milliseconds
const nowMs = Date.now();
const expiresAt = nowMs + expiresIn * 1000;
// Update claudeAiOauth section
const claudeAiOauth = (existing.claudeAiOauth as Record<string, unknown>) ?? {};
claudeAiOauth.accessToken = accessToken;
claudeAiOauth.refreshToken = refreshToken;
claudeAiOauth.expiresAt = expiresAt;
existing.claudeAiOauth = claudeAiOauth;
// Ensure directory exists
mkdirSync(dirname(credPath), { recursive: true });
// Write back (compact JSON for consistency)
writeFileSync(credPath, JSON.stringify(existing));
log.debug({ configDir }, 'credentials written after token refresh');
}
/**
* Ensure credentials are valid, refreshing if needed.
*/
async ensureValid(configDir: string, accountId?: string): Promise<CredentialValidationResult> {
const credentials = this.read(configDir);
if (!credentials) {
log.warn({ configDir, accountId }, 'no credentials found');
this.emitExpired(accountId, 'credentials_missing', 'Credentials file not found');
return {
valid: false,
credentials: null,
error: 'Credentials file not found',
refreshed: false,
};
}
if (!this.isExpired(credentials)) {
log.debug({ configDir, accountId }, 'credentials valid, no refresh needed');
this.emitValidated(accountId, true, credentials.expiresAt, false);
return {
valid: true,
credentials,
error: null,
refreshed: false,
};
}
// Credentials expired — attempt refresh
log.info({ configDir, accountId }, 'credentials expired, refreshing');
const previousExpiresAt = credentials.expiresAt;
const refreshed = await this.refresh(configDir, credentials.refreshToken);
if (!refreshed) {
log.error({ configDir, accountId }, 'failed to refresh credentials');
this.emitExpired(accountId, 'refresh_failed', 'Token refresh failed');
return {
valid: false,
credentials: null,
error: 'Token refresh failed',
refreshed: false,
};
}
// Write refreshed credentials
const newRefreshToken = refreshed.refreshToken || credentials.refreshToken;
this.write(configDir, refreshed.accessToken, newRefreshToken, refreshed.expiresIn);
const newExpiresAt = Date.now() + refreshed.expiresIn * 1000;
log.info({ configDir, accountId, expiresIn: refreshed.expiresIn }, 'credentials refreshed');
this.emitRefreshed(accountId, newExpiresAt, previousExpiresAt);
this.emitValidated(accountId, true, newExpiresAt, true);
// Read back updated credentials
const updatedCredentials = this.read(configDir);
return {
valid: true,
credentials: updatedCredentials,
error: null,
refreshed: true,
};
}
/**
* Validate credentials without attempting refresh.
*/
async validate(configDir: string, accountId?: string): Promise<CredentialValidationResult> {
const credentials = this.read(configDir);
if (!credentials) {
this.emitValidated(accountId, false, null, false);
return {
valid: false,
credentials: null,
error: 'Credentials file not found',
refreshed: false,
};
}
const expired = this.isExpired(credentials);
this.emitValidated(accountId, !expired, credentials.expiresAt, false);
if (expired) {
return {
valid: false,
credentials,
error: 'Token expired',
refreshed: false,
};
}
return {
valid: true,
credentials,
error: null,
refreshed: false,
};
}
/**
* Emit credentials refreshed event.
*/
private emitRefreshed(
accountId: string | undefined,
expiresAt: number,
previousExpiresAt: number | null,
): void {
if (!this.eventBus) return;
const event: AccountCredentialsRefreshedEvent = {
type: 'account:credentials_refreshed',
timestamp: new Date(),
payload: {
accountId: accountId ?? null,
expiresAt,
previousExpiresAt,
},
};
this.eventBus.emit(event);
}
/**
* Emit credentials expired event.
*/
private emitExpired(
accountId: string | undefined,
reason: 'token_expired' | 'refresh_failed' | 'credentials_missing',
error: string | null,
): void {
if (!this.eventBus) return;
const event: AccountCredentialsExpiredEvent = {
type: 'account:credentials_expired',
timestamp: new Date(),
payload: {
accountId: accountId ?? null,
reason,
error,
},
};
this.eventBus.emit(event);
}
/**
* Emit credentials validated event.
*/
private emitValidated(
accountId: string | undefined,
valid: boolean,
expiresAt: number | null,
wasRefreshed: boolean,
): void {
if (!this.eventBus) return;
const event: AccountCredentialsValidatedEvent = {
type: 'account:credentials_validated',
timestamp: new Date(),
payload: {
accountId: accountId ?? null,
valid,
expiresAt,
wasRefreshed,
},
};
this.eventBus.emit(event);
}
}

View File

@@ -0,0 +1,17 @@
/**
* Credentials Module - Public API
*
* Exports the AccountCredentialManager port interface and default adapter.
* All modules should import from this index file.
*/
// Port interface and types
export type {
AccountCredentialManager,
OAuthCredentials,
RefreshResult,
CredentialValidationResult,
} from './types.js';
// Adapter implementation
export { DefaultAccountCredentialManager } from './default-credential-manager.js';

View File

@@ -0,0 +1,98 @@
/**
* Account Credential Manager Types
*
* Port interface for managing OAuth credentials for agent accounts.
* The credential manager reads, validates, refreshes, and persists tokens,
* emitting events on state changes.
*/
/**
* OAuth credentials stored in the account's config directory.
*/
export interface OAuthCredentials {
accessToken: string;
refreshToken: string;
/** Expiry time in milliseconds since epoch */
expiresAt: number;
subscriptionType: string | null;
rateLimitTier: string | null;
}
/**
* Result of a token refresh attempt.
*/
export interface RefreshResult {
accessToken: string;
refreshToken: string;
/** Token lifetime in seconds */
expiresIn: number;
}
/**
* Result of credential validation or ensureValid operation.
*/
export interface CredentialValidationResult {
/** Whether credentials are currently valid and usable */
valid: boolean;
/** Current credentials if valid, null otherwise */
credentials: OAuthCredentials | null;
/** Error message if validation failed */
error: string | null;
/** Whether credentials were refreshed during this operation */
refreshed: boolean;
}
/**
* Port interface for account credential management.
*
* Implementations:
* - DefaultAccountCredentialManager: File-based adapter using ~/.cw/accounts/<uuid>/.credentials.json
*/
export interface AccountCredentialManager {
/**
* Read credentials from a config directory.
* Returns null if credentials file is missing or malformed.
*/
read(configDir: string): OAuthCredentials | null;
/**
* Check if credentials are expired or about to expire.
* Uses a buffer (default 5 minutes) to preemptively refresh.
*/
isExpired(credentials: OAuthCredentials): boolean;
/**
* Refresh an access token using the refresh token.
* Returns null if refresh fails.
*/
refresh(configDir: string, refreshToken: string): Promise<RefreshResult | null>;
/**
* Write updated credentials to the config directory.
* Preserves other fields in the credentials file.
*/
write(
configDir: string,
accessToken: string,
refreshToken: string,
expiresIn: number,
): void;
/**
* Ensure credentials are valid, refreshing if needed.
* Emits events on refresh or expiration.
*
* @param configDir - Path to the account's config directory
* @param accountId - Optional account ID for event payloads
*/
ensureValid(configDir: string, accountId?: string): Promise<CredentialValidationResult>;
/**
* Validate credentials without attempting refresh.
* Useful for health checks where you want to report state without side effects.
*
* @param configDir - Path to the account's config directory
* @param accountId - Optional account ID for event payloads
*/
validate(configDir: string, accountId?: string): Promise<CredentialValidationResult>;
}

340
src/agent/file-io.test.ts Normal file
View File

@@ -0,0 +1,340 @@
/**
* File-Based Agent I/O Tests
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'crypto';
import {
writeInputFiles,
readSummary,
readPhaseFiles,
readTaskFiles,
readDecisionFiles,
readPageFiles,
generateId,
} from './file-io.js';
import type { Initiative, Phase, Task } from '../db/schema.js';
let testDir: string;
beforeEach(() => {
testDir = join(tmpdir(), `cw-file-io-test-${randomUUID()}`);
mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
describe('generateId', () => {
it('returns a non-empty string', () => {
const id = generateId();
expect(id).toBeTruthy();
expect(typeof id).toBe('string');
});
it('returns unique values', () => {
const ids = new Set(Array.from({ length: 100 }, () => generateId()));
expect(ids.size).toBe(100);
});
});
describe('writeInputFiles', () => {
it('writes initiative.md with frontmatter', () => {
const initiative: Initiative = {
id: 'init-1',
name: 'Test Initiative',
status: 'active',
mergeRequiresApproval: true,
mergeTarget: 'main',
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-02'),
};
writeInputFiles({ agentWorkdir: testDir, initiative });
const filePath = join(testDir, '.cw', 'input', 'initiative.md');
expect(existsSync(filePath)).toBe(true);
});
it('writes phase.md with frontmatter', () => {
const phase = {
id: 'phase-1',
initiativeId: 'init-1',
number: 1,
name: 'Phase One',
description: 'First phase',
status: 'pending',
createdAt: new Date(),
updatedAt: new Date(),
} as Phase;
writeInputFiles({ agentWorkdir: testDir, phase });
const filePath = join(testDir, '.cw', 'input', 'phase.md');
expect(existsSync(filePath)).toBe(true);
});
it('writes task.md with frontmatter', () => {
const task = {
id: 'task-1',
name: 'Test Task',
description: 'Do the thing',
category: 'execute',
type: 'auto',
priority: 'medium',
status: 'pending',
order: 1,
createdAt: new Date(),
updatedAt: new Date(),
} as Task;
writeInputFiles({ agentWorkdir: testDir, task });
const filePath = join(testDir, '.cw', 'input', 'task.md');
expect(existsSync(filePath)).toBe(true);
});
it('writes pages to pages/ subdirectory', () => {
writeInputFiles({
agentWorkdir: testDir,
pages: [
{ id: 'page-1', parentPageId: null, title: 'Root', content: null, sortOrder: 0 },
{ id: 'page-2', parentPageId: 'page-1', title: 'Child', content: null, sortOrder: 1 },
],
});
expect(existsSync(join(testDir, '.cw', 'input', 'pages', 'page-1.md'))).toBe(true);
expect(existsSync(join(testDir, '.cw', 'input', 'pages', 'page-2.md'))).toBe(true);
});
it('handles empty options without error', () => {
writeInputFiles({ agentWorkdir: testDir });
expect(existsSync(join(testDir, '.cw', 'input'))).toBe(true);
});
});
describe('readSummary', () => {
it('reads SUMMARY.md with frontmatter', () => {
const outputDir = join(testDir, '.cw', 'output');
mkdirSync(outputDir, { recursive: true });
writeFileSync(
join(outputDir, 'SUMMARY.md'),
`---
files_modified:
- src/foo.ts
- src/bar.ts
---
Task completed successfully. Refactored the module.
`,
'utf-8',
);
const summary = readSummary(testDir);
expect(summary).not.toBeNull();
expect(summary!.body).toBe('Task completed successfully. Refactored the module.');
expect(summary!.filesModified).toEqual(['src/foo.ts', 'src/bar.ts']);
});
it('returns null when SUMMARY.md does not exist', () => {
const summary = readSummary(testDir);
expect(summary).toBeNull();
});
it('handles SUMMARY.md without frontmatter', () => {
const outputDir = join(testDir, '.cw', 'output');
mkdirSync(outputDir, { recursive: true });
writeFileSync(join(outputDir, 'SUMMARY.md'), 'Just plain text\n', 'utf-8');
const summary = readSummary(testDir);
expect(summary).not.toBeNull();
expect(summary!.body).toBe('Just plain text');
expect(summary!.filesModified).toBeUndefined();
});
it('handles empty files_modified', () => {
const outputDir = join(testDir, '.cw', 'output');
mkdirSync(outputDir, { recursive: true });
writeFileSync(
join(outputDir, 'SUMMARY.md'),
`---
files_modified: []
---
Done.
`,
'utf-8',
);
const summary = readSummary(testDir);
expect(summary!.filesModified).toEqual([]);
});
});
describe('readPhaseFiles', () => {
it('reads phase files from phases/ directory', () => {
const phasesDir = join(testDir, '.cw', 'output', 'phases');
mkdirSync(phasesDir, { recursive: true });
writeFileSync(
join(phasesDir, 'abc123.md'),
`---
title: Database Schema
dependencies:
- xyz789
---
Create the user tables and auth schema.
`,
'utf-8',
);
const phases = readPhaseFiles(testDir);
expect(phases).toHaveLength(1);
expect(phases[0].id).toBe('abc123');
expect(phases[0].title).toBe('Database Schema');
expect(phases[0].dependencies).toEqual(['xyz789']);
expect(phases[0].body).toBe('Create the user tables and auth schema.');
});
it('returns empty array when directory does not exist', () => {
const phases = readPhaseFiles(testDir);
expect(phases).toEqual([]);
});
it('handles phases with no dependencies', () => {
const phasesDir = join(testDir, '.cw', 'output', 'phases');
mkdirSync(phasesDir, { recursive: true });
writeFileSync(
join(phasesDir, 'p1.md'),
`---
title: Foundation
---
Set up the base.
`,
'utf-8',
);
const phases = readPhaseFiles(testDir);
expect(phases[0].dependencies).toEqual([]);
});
});
describe('readTaskFiles', () => {
it('reads task files from tasks/ directory', () => {
const tasksDir = join(testDir, '.cw', 'output', 'tasks');
mkdirSync(tasksDir, { recursive: true });
writeFileSync(
join(tasksDir, 'task-1.md'),
`---
title: Implement login
category: execute
type: auto
dependencies:
- task-0
---
Build the login form and submit handler.
`,
'utf-8',
);
const tasks = readTaskFiles(testDir);
expect(tasks).toHaveLength(1);
expect(tasks[0].id).toBe('task-1');
expect(tasks[0].title).toBe('Implement login');
expect(tasks[0].category).toBe('execute');
expect(tasks[0].type).toBe('auto');
expect(tasks[0].dependencies).toEqual(['task-0']);
expect(tasks[0].body).toBe('Build the login form and submit handler.');
});
it('defaults category and type when missing', () => {
const tasksDir = join(testDir, '.cw', 'output', 'tasks');
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(tasksDir, 't1.md'), `---\ntitle: Minimal\n---\nDo it.\n`, 'utf-8');
const tasks = readTaskFiles(testDir);
expect(tasks[0].category).toBe('execute');
expect(tasks[0].type).toBe('auto');
});
it('returns empty array when directory does not exist', () => {
expect(readTaskFiles(testDir)).toEqual([]);
});
});
describe('readDecisionFiles', () => {
it('reads decision files from decisions/ directory', () => {
const decisionsDir = join(testDir, '.cw', 'output', 'decisions');
mkdirSync(decisionsDir, { recursive: true });
writeFileSync(
join(decisionsDir, 'd1.md'),
`---
topic: Authentication
decision: Use JWT
reason: Stateless and scalable
---
Additional context about the decision.
`,
'utf-8',
);
const decisions = readDecisionFiles(testDir);
expect(decisions).toHaveLength(1);
expect(decisions[0].id).toBe('d1');
expect(decisions[0].topic).toBe('Authentication');
expect(decisions[0].decision).toBe('Use JWT');
expect(decisions[0].reason).toBe('Stateless and scalable');
expect(decisions[0].body).toBe('Additional context about the decision.');
});
it('returns empty array when directory does not exist', () => {
expect(readDecisionFiles(testDir)).toEqual([]);
});
});
describe('readPageFiles', () => {
it('reads page files from pages/ directory', () => {
const pagesDir = join(testDir, '.cw', 'output', 'pages');
mkdirSync(pagesDir, { recursive: true });
writeFileSync(
join(pagesDir, 'page-abc.md'),
`---
title: Architecture Overview
summary: Updated the overview section
---
# Architecture
New content for the page.
`,
'utf-8',
);
const pages = readPageFiles(testDir);
expect(pages).toHaveLength(1);
expect(pages[0].pageId).toBe('page-abc');
expect(pages[0].title).toBe('Architecture Overview');
expect(pages[0].summary).toBe('Updated the overview section');
expect(pages[0].body).toBe('# Architecture\n\nNew content for the page.');
});
it('returns empty array when directory does not exist', () => {
expect(readPageFiles(testDir)).toEqual([]);
});
it('ignores non-.md files', () => {
const pagesDir = join(testDir, '.cw', 'output', 'pages');
mkdirSync(pagesDir, { recursive: true });
writeFileSync(join(pagesDir, 'readme.txt'), 'not a page', 'utf-8');
writeFileSync(join(pagesDir, 'page1.md'), '---\ntitle: Page 1\n---\nContent.\n', 'utf-8');
const pages = readPageFiles(testDir);
expect(pages).toHaveLength(1);
});
});

288
src/agent/file-io.ts Normal file
View File

@@ -0,0 +1,288 @@
/**
* File-Based Agent I/O
*
* Writes context as input files before agent spawn and reads output files after completion.
* Uses YAML frontmatter (gray-matter) for structured metadata and markdown bodies.
*
* Input: .cw/input/ — written by system before spawn
* Output: .cw/output/ — written by agent during execution
*/
import { mkdirSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import matter from 'gray-matter';
import { nanoid } from 'nanoid';
import { tiptapJsonToMarkdown } from './content-serializer.js';
import type { AgentInputContext } from './types.js';
// Re-export for convenience
export type { AgentInputContext } from './types.js';
// =============================================================================
// TYPES
// =============================================================================
export interface WriteInputFilesOptions extends AgentInputContext {
agentWorkdir: string;
}
export interface ParsedSummary {
body: string;
filesModified?: string[];
}
export interface ParsedPhaseFile {
id: string;
title: string;
dependencies: string[];
body: string;
}
export interface ParsedTaskFile {
id: string;
title: string;
category: string;
type: string;
dependencies: string[];
body: string;
}
export interface ParsedDecisionFile {
id: string;
topic: string;
decision: string;
reason: string;
body: string;
}
export interface ParsedPageFile {
pageId: string;
title: string;
summary: string;
body: string;
}
// =============================================================================
// ID GENERATION
// =============================================================================
export function generateId(): string {
return nanoid();
}
// =============================================================================
// INPUT FILE WRITING
// =============================================================================
function formatFrontmatter(data: Record<string, unknown>, body: string = ''): string {
const lines: string[] = ['---'];
for (const [key, value] of Object.entries(data)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
if (value.length === 0) {
lines.push(`${key}: []`);
} else {
lines.push(`${key}:`);
for (const item of value) {
lines.push(` - ${String(item)}`);
}
}
} else if (value instanceof Date) {
lines.push(`${key}: "${value.toISOString()}"`);
} else if (typeof value === 'string' && (value.includes('\n') || value.includes(':'))) {
lines.push(`${key}: ${JSON.stringify(value)}`);
} else {
lines.push(`${key}: ${String(value)}`);
}
}
lines.push('---');
if (body) {
lines.push('');
lines.push(body);
}
return lines.join('\n') + '\n';
}
export function writeInputFiles(options: WriteInputFilesOptions): void {
const inputDir = join(options.agentWorkdir, '.cw', 'input');
mkdirSync(inputDir, { recursive: true });
if (options.initiative) {
const ini = options.initiative;
const content = formatFrontmatter(
{
id: ini.id,
name: ini.name,
status: ini.status,
mergeRequiresApproval: ini.mergeRequiresApproval,
mergeTarget: ini.mergeTarget,
},
'',
);
writeFileSync(join(inputDir, 'initiative.md'), content, 'utf-8');
}
if (options.pages && options.pages.length > 0) {
const pagesDir = join(inputDir, 'pages');
mkdirSync(pagesDir, { recursive: true });
for (const page of options.pages) {
let bodyMarkdown = '';
if (page.content) {
try {
const parsed = JSON.parse(page.content);
bodyMarkdown = tiptapJsonToMarkdown(parsed);
} catch {
// Invalid JSON content — skip
}
}
const content = formatFrontmatter(
{
title: page.title,
parentPageId: page.parentPageId,
sortOrder: page.sortOrder,
},
bodyMarkdown,
);
writeFileSync(join(pagesDir, `${page.id}.md`), content, 'utf-8');
}
}
if (options.phase) {
const ph = options.phase;
const content = formatFrontmatter(
{
id: ph.id,
number: ph.number,
name: ph.name,
status: ph.status,
},
ph.description ?? '',
);
writeFileSync(join(inputDir, 'phase.md'), content, 'utf-8');
}
if (options.task) {
const t = options.task;
const content = formatFrontmatter(
{
id: t.id,
name: t.name,
category: t.category,
type: t.type,
priority: t.priority,
status: t.status,
},
t.description ?? '',
);
writeFileSync(join(inputDir, 'task.md'), content, 'utf-8');
}
}
// =============================================================================
// OUTPUT FILE READING
// =============================================================================
function readFrontmatterFile(filePath: string): { data: Record<string, unknown>; body: string } | null {
try {
const raw = readFileSync(filePath, 'utf-8');
const parsed = matter(raw);
return { data: parsed.data as Record<string, unknown>, body: parsed.content.trim() };
} catch {
return null;
}
}
function readFrontmatterDir<T>(
dirPath: string,
mapper: (data: Record<string, unknown>, body: string, filename: string) => T | null,
): T[] {
if (!existsSync(dirPath)) return [];
const results: T[] = [];
try {
const entries = readdirSync(dirPath);
for (const entry of entries) {
if (!entry.endsWith('.md')) continue;
const filePath = join(dirPath, entry);
const parsed = readFrontmatterFile(filePath);
if (!parsed) continue;
const mapped = mapper(parsed.data, parsed.body, entry);
if (mapped) results.push(mapped);
}
} catch {
// Directory read error — return empty
}
return results;
}
export function readSummary(agentWorkdir: string): ParsedSummary | null {
const filePath = join(agentWorkdir, '.cw', 'output', 'SUMMARY.md');
const parsed = readFrontmatterFile(filePath);
if (!parsed) return null;
const filesModified = parsed.data.files_modified;
return {
body: parsed.body,
filesModified: Array.isArray(filesModified) ? filesModified.map(String) : undefined,
};
}
export function readPhaseFiles(agentWorkdir: string): ParsedPhaseFile[] {
const dirPath = join(agentWorkdir, '.cw', 'output', 'phases');
return readFrontmatterDir(dirPath, (data, body, filename) => {
const id = filename.replace(/\.md$/, '');
const deps = Array.isArray(data.dependencies) ? data.dependencies.map(String) : [];
return {
id,
title: String(data.title ?? ''),
dependencies: deps,
body,
};
});
}
export function readTaskFiles(agentWorkdir: string): ParsedTaskFile[] {
const dirPath = join(agentWorkdir, '.cw', 'output', 'tasks');
return readFrontmatterDir(dirPath, (data, body, filename) => {
const id = filename.replace(/\.md$/, '');
const deps = Array.isArray(data.dependencies) ? data.dependencies.map(String) : [];
return {
id,
title: String(data.title ?? ''),
category: String(data.category ?? 'execute'),
type: String(data.type ?? 'auto'),
dependencies: deps,
body,
};
});
}
export function readDecisionFiles(agentWorkdir: string): ParsedDecisionFile[] {
const dirPath = join(agentWorkdir, '.cw', 'output', 'decisions');
return readFrontmatterDir(dirPath, (data, body, filename) => {
const id = filename.replace(/\.md$/, '');
return {
id,
topic: String(data.topic ?? ''),
decision: String(data.decision ?? ''),
reason: String(data.reason ?? ''),
body,
};
});
}
export function readPageFiles(agentWorkdir: string): ParsedPageFile[] {
const dirPath = join(agentWorkdir, '.cw', 'output', 'pages');
return readFrontmatterDir(dirPath, (data, body, filename) => {
const pageId = filename.replace(/\.md$/, '');
return {
pageId,
title: String(data.title ?? ''),
summary: String(data.summary ?? ''),
body,
};
});
}

267
src/agent/file-tailer.ts Normal file
View File

@@ -0,0 +1,267 @@
/**
* File Tailer
*
* Watches an output file and emits parsed events in real-time.
* Used for crash-resilient agent spawning where subprocesses write
* directly to files instead of using pipes.
*
* Uses fs.watch() for efficient change detection with a poll fallback
* since fs.watch isn't 100% reliable on all platforms.
*/
import { watch, type FSWatcher } from 'node:fs';
import { open, stat } from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
import type { StreamParser, StreamEvent } from './providers/stream-types.js';
import type { EventBus, AgentOutputEvent } from '../events/index.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('file-tailer');
/** Poll interval for fallback polling (ms) */
const POLL_INTERVAL_MS = 500;
/** Read buffer size (bytes) */
const READ_BUFFER_SIZE = 64 * 1024;
export interface FileTailerOptions {
/** Path to the output file to watch */
filePath: string;
/** Agent ID for event emission */
agentId: string;
/** Parser to convert lines to stream events */
parser: StreamParser;
/** Optional event bus for emitting agent:output events */
eventBus?: EventBus;
/** Optional callback for each stream event */
onEvent?: (event: StreamEvent) => void;
/** If true, read from beginning of file; otherwise tail only new content (default: false) */
startFromBeginning?: boolean;
}
/**
* FileTailer watches a file for changes and emits parsed stream events.
*
* Behavior:
* - Uses fs.watch() for efficient change detection
* - Falls back to polling every 500ms (fs.watch misses events sometimes)
* - Reads new content incrementally, splits into lines
* - Feeds each line to the parser, emits resulting events
* - Handles partial lines at buffer boundaries
*/
export class FileTailer {
private position = 0;
private watcher: FSWatcher | null = null;
private pollInterval: NodeJS.Timeout | null = null;
private fileHandle: FileHandle | null = null;
private stopped = false;
private partialLine = '';
private reading = false;
private readonly filePath: string;
private readonly agentId: string;
private readonly parser: StreamParser;
private readonly eventBus?: EventBus;
private readonly onEvent?: (event: StreamEvent) => void;
private readonly startFromBeginning: boolean;
constructor(options: FileTailerOptions) {
this.filePath = options.filePath;
this.agentId = options.agentId;
this.parser = options.parser;
this.eventBus = options.eventBus;
this.onEvent = options.onEvent;
this.startFromBeginning = options.startFromBeginning ?? false;
}
/**
* Start watching the file for changes.
* Initializes position, starts fs.watch, and begins poll fallback.
*/
async start(): Promise<void> {
if (this.stopped) return;
log.debug({ filePath: this.filePath, agentId: this.agentId }, 'starting file tailer');
try {
// Open file for reading
this.fileHandle = await open(this.filePath, 'r');
// Set initial position
if (this.startFromBeginning) {
this.position = 0;
} else {
// Seek to end
const stats = await stat(this.filePath);
this.position = stats.size;
}
// Start fs.watch for efficient change detection
this.watcher = watch(this.filePath, (eventType) => {
if (eventType === 'change' && !this.stopped) {
this.readNewContent().catch((err) => {
log.warn({ err: err instanceof Error ? err.message : String(err), agentId: this.agentId }, 'error reading new content');
});
}
});
this.watcher.on('error', (err) => {
log.warn({ err: err instanceof Error ? err.message : String(err), agentId: this.agentId }, 'watcher error');
});
// Start poll fallback (fs.watch misses events sometimes)
this.pollInterval = setInterval(() => {
if (!this.stopped) {
this.readNewContent().catch((err) => {
log.warn({ err: err instanceof Error ? err.message : String(err), agentId: this.agentId }, 'poll read error');
});
}
}, POLL_INTERVAL_MS);
// If starting from beginning, do initial read
if (this.startFromBeginning) {
await this.readNewContent();
}
} catch (err) {
log.error({ err: err instanceof Error ? err.message : String(err), filePath: this.filePath }, 'failed to start file tailer');
}
}
/**
* Read new content from the file since last position.
* Splits into lines, feeds to parser, emits events.
*/
private async readNewContent(): Promise<void> {
if (this.stopped || !this.fileHandle || this.reading) return;
this.reading = true;
try {
// Check current file size
const stats = await stat(this.filePath);
if (stats.size <= this.position) {
return; // No new content
}
// Read new bytes
const bytesToRead = stats.size - this.position;
const buffer = Buffer.alloc(Math.min(bytesToRead, READ_BUFFER_SIZE));
const { bytesRead } = await this.fileHandle.read(buffer, 0, buffer.length, this.position);
if (bytesRead === 0) return;
this.position += bytesRead;
// Convert to string and process lines
const content = this.partialLine + buffer.toString('utf-8', 0, bytesRead);
const lines = content.split('\n');
// Last element is either empty (if content ended with \n) or a partial line
this.partialLine = lines.pop() ?? '';
// Process complete lines
for (const line of lines) {
if (line.trim()) {
this.processLine(line);
}
}
// If there's more content to read, schedule another read
if (stats.size > this.position) {
setImmediate(() => {
this.readNewContent().catch(() => {});
});
}
} finally {
this.reading = false;
}
}
/**
* Process a single line through the parser and emit events.
*/
private processLine(line: string): void {
const events = this.parser.parseLine(line);
for (const event of events) {
// Call user callback if provided
if (this.onEvent) {
this.onEvent(event);
}
// Emit agent:output for text_delta events
if (event.type === 'text_delta' && this.eventBus) {
const outputEvent: AgentOutputEvent = {
type: 'agent:output',
timestamp: new Date(),
payload: {
agentId: this.agentId,
stream: 'stdout',
data: event.text,
},
};
this.eventBus.emit(outputEvent);
}
}
}
/**
* Stop watching the file.
* Cleans up watcher, poll timer, and file handle.
*/
async stop(): Promise<void> {
if (this.stopped) return;
this.stopped = true;
log.debug({ filePath: this.filePath, agentId: this.agentId }, 'stopping file tailer');
// Close watcher
if (this.watcher) {
this.watcher.close();
this.watcher = null;
}
// Clear poll timer
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
// Do one final read to catch any remaining content
try {
await this.readNewContent();
// Process any remaining partial line
if (this.partialLine.trim()) {
this.processLine(this.partialLine);
this.partialLine = '';
}
// Signal end of stream to parser
const endEvents = this.parser.end();
for (const event of endEvents) {
if (this.onEvent) {
this.onEvent(event);
}
}
} catch {
// Ignore errors during cleanup
}
// Close file handle
if (this.fileHandle) {
try {
await this.fileHandle.close();
} catch {
// Ignore close errors
}
this.fileHandle = null;
}
}
/**
* Check if the tailer has been stopped.
*/
get isStopped(): boolean {
return this.stopped;
}
}

View File

@@ -12,16 +12,73 @@ export type {
AgentInfo,
AgentResult,
AgentManager,
AgentInputContext,
} from './types.js';
// Adapter implementations
export { ClaudeAgentManager } from './manager.js';
export { MultiProviderAgentManager } from './manager.js';
/** @deprecated Use MultiProviderAgentManager instead */
export { MultiProviderAgentManager as ClaudeAgentManager } from './manager.js';
export { MockAgentManager, type MockAgentScenario } from './mock-manager.js';
// Provider registry
export {
getProvider,
listProviders,
registerProvider,
loadProvidersFromFile,
PROVIDER_PRESETS,
} from './providers/index.js';
export type { AgentProviderConfig } from './providers/index.js';
// Agent prompts
export {
buildDiscussPrompt,
buildBreakdownPrompt,
buildExecutePrompt,
buildRefinePrompt,
buildDecomposePrompt,
} from './prompts.js';
// Schema
export { agentSignalSchema, agentSignalJsonSchema } from './schema.js';
export type { AgentSignal } from './schema.js';
// Backward compat
export { agentOutputSchema, agentOutputJsonSchema } from './schema.js';
// File I/O
export {
writeInputFiles,
readSummary,
readPhaseFiles,
readTaskFiles,
readDecisionFiles,
readPageFiles,
generateId,
} from './file-io.js';
export type {
WriteInputFilesOptions,
ParsedSummary,
ParsedPhaseFile,
ParsedTaskFile,
ParsedDecisionFile,
ParsedPageFile,
} from './file-io.js';
// Content serializer
export { serializePageTree, tiptapJsonToMarkdown } from './content-serializer.js';
export type { PageForSerialization } from './content-serializer.js';
// Alias generator
export { generateUniqueAlias } from './alias.js';
// File tailer for crash-resilient streaming
export { FileTailer } from './file-tailer.js';
export type { FileTailerOptions } from './file-tailer.js';
// Extracted manager helpers
export { ProcessManager } from './process-manager.js';
export { CredentialHandler } from './credential-handler.js';
export { OutputHandler } from './output-handler.js';
export type { ActiveAgent } from './output-handler.js';
export { CleanupManager } from './cleanup-manager.js';

View File

@@ -1,46 +1,123 @@
/**
* ClaudeAgentManager Tests
* MultiProviderAgentManager Tests
*
* Unit tests for the ClaudeAgentManager adapter.
* Mocks execa since we can't spawn real Claude CLI in tests.
* Unit tests for the MultiProviderAgentManager adapter.
* Mocks child_process.spawn since we can't spawn real Claude CLI in tests.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ClaudeAgentManager } from './manager.js';
import { MultiProviderAgentManager } from './manager.js';
import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { WorktreeManager, Worktree } from '../git/types.js';
import type { ProjectRepository } from '../db/repositories/project-repository.js';
import { EventEmitterBus } from '../events/index.js';
import type { DomainEvent } from '../events/index.js';
// Mock execa
vi.mock('execa', () => ({
execa: vi.fn(),
// Mock child_process.spawn and execFile
vi.mock('node:child_process', () => ({
spawn: vi.fn(),
execFile: vi.fn((_cmd: string, _args: string[], _opts: unknown, cb?: Function) => {
if (cb) cb(null, '', '');
}),
}));
import { execa } from 'execa';
const mockExeca = vi.mocked(execa);
// Import spawn to get the mock
import { spawn } from 'node:child_process';
const mockSpawn = vi.mocked(spawn);
describe('ClaudeAgentManager', () => {
let manager: ClaudeAgentManager;
// Mock SimpleGitWorktreeManager so spawn doesn't need a real git repo
vi.mock('../git/manager.js', () => {
return {
SimpleGitWorktreeManager: class MockWorktreeManager {
create = vi.fn().mockResolvedValue({ id: 'workspace', path: '/tmp/test-workspace/agent-workdirs/gastown/workspace', branch: 'agent/gastown' });
get = vi.fn().mockResolvedValue(null);
list = vi.fn().mockResolvedValue([]);
remove = vi.fn().mockResolvedValue(undefined);
},
};
});
// Mock fs operations for file-based output
vi.mock('node:fs', async () => {
const actual = await vi.importActual('node:fs');
// Create a mock write stream
const mockWriteStream = {
write: vi.fn(),
end: vi.fn(),
on: vi.fn(),
};
return {
...actual,
openSync: vi.fn().mockReturnValue(99),
closeSync: vi.fn(),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
createWriteStream: vi.fn().mockReturnValue(mockWriteStream),
};
});
vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual('node:fs/promises');
return {
...actual,
readFile: vi.fn().mockResolvedValue(''),
readdir: vi.fn().mockRejectedValue(new Error('ENOENT')),
rm: vi.fn().mockResolvedValue(undefined),
};
});
// Mock FileTailer to avoid actual file watching
vi.mock('./file-tailer.js', () => ({
FileTailer: class MockFileTailer {
start = vi.fn().mockResolvedValue(undefined);
stop = vi.fn().mockResolvedValue(undefined);
isStopped = false;
},
}));
import type { ChildProcess } from 'node:child_process';
/**
* Create a mock ChildProcess for detached spawning.
* The process is spawned detached and unreferenced.
*/
function createMockChildProcess(options?: {
pid?: number;
}) {
const { pid = 123 } = options ?? {};
// Create a minimal mock that satisfies the actual usage in spawnDetached
const childProcess = {
pid,
unref: vi.fn(),
on: vi.fn().mockReturnThis(),
kill: vi.fn(),
} as unknown as ChildProcess;
return childProcess;
}
describe('MultiProviderAgentManager', () => {
let manager: MultiProviderAgentManager;
let mockRepository: AgentRepository;
let mockWorktreeManager: WorktreeManager;
let mockProjectRepository: ProjectRepository;
let eventBus: EventEmitterBus;
let capturedEvents: DomainEvent[];
const mockWorktree: Worktree = {
id: 'worktree-123',
branch: 'agent/gastown',
path: '/tmp/worktree',
isMainWorktree: false,
};
const mockAgent = {
id: 'agent-123',
name: 'gastown',
taskId: 'task-456',
initiativeId: null as string | null,
sessionId: 'session-789',
worktreeId: 'worktree-123',
worktreeId: 'gastown',
status: 'idle' as const,
mode: 'execute' as const,
provider: 'claude',
accountId: null as string | null,
pid: null as number | null,
outputFilePath: null as string | null,
result: null as string | null,
pendingQuestions: null as string | null,
createdAt: new Date(),
updatedAt: new Date(),
};
@@ -57,22 +134,21 @@ describe('ClaudeAgentManager', () => {
findBySessionId: vi.fn().mockResolvedValue(mockAgent),
findAll: vi.fn().mockResolvedValue([mockAgent]),
findByStatus: vi.fn().mockResolvedValue([mockAgent]),
updateStatus: vi
.fn()
.mockResolvedValue({ ...mockAgent, status: 'running' }),
updateSessionId: vi
.fn()
.mockResolvedValue({ ...mockAgent, sessionId: 'new-session' }),
update: vi.fn().mockResolvedValue(mockAgent),
delete: vi.fn().mockResolvedValue(undefined),
};
mockWorktreeManager = {
create: vi.fn().mockResolvedValue(mockWorktree),
remove: vi.fn().mockResolvedValue(undefined),
list: vi.fn().mockResolvedValue([mockWorktree]),
get: vi.fn().mockResolvedValue(mockWorktree),
diff: vi.fn().mockResolvedValue({ files: [], summary: '' }),
merge: vi.fn().mockResolvedValue({ success: true, message: 'ok' }),
mockProjectRepository = {
create: vi.fn(),
findById: vi.fn(),
findByName: vi.fn(),
findAll: vi.fn().mockResolvedValue([]),
update: vi.fn(),
delete: vi.fn(),
addProjectToInitiative: vi.fn(),
removeProjectFromInitiative: vi.fn(),
findProjectsByInitiativeId: vi.fn().mockResolvedValue([]),
setInitiativeProjects: vi.fn(),
};
eventBus = new EventEmitterBus();
@@ -83,9 +159,11 @@ describe('ClaudeAgentManager', () => {
eventBus.on('agent:resumed', (e) => capturedEvents.push(e));
eventBus.on('agent:waiting', (e) => capturedEvents.push(e));
manager = new ClaudeAgentManager(
manager = new MultiProviderAgentManager(
mockRepository,
mockWorktreeManager,
'/tmp/test-workspace',
mockProjectRepository,
undefined,
eventBus
);
});
@@ -95,19 +173,9 @@ describe('ClaudeAgentManager', () => {
});
describe('spawn', () => {
it('creates worktree and agent record with name', async () => {
const mockSubprocess = {
pid: 123,
kill: vi.fn(),
then: () =>
Promise.resolve({
stdout:
'{"type":"result","subtype":"success","session_id":"sess-123","result":"{\\"status\\":\\"done\\",\\"result\\":\\"Task completed\\"}"}',
stderr: '',
}),
catch: () => mockSubprocess,
};
mockExeca.mockReturnValue(mockSubprocess as unknown as ReturnType<typeof execa>);
it('creates agent record with provided name', async () => {
const mockChild = createMockChildProcess();
mockSpawn.mockReturnValue(mockChild);
const result = await manager.spawn({
name: 'gastown',
@@ -115,10 +183,6 @@ describe('ClaudeAgentManager', () => {
prompt: 'Test task',
});
expect(mockWorktreeManager.create).toHaveBeenCalledWith(
expect.any(String),
'agent/gastown'
);
expect(mockRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ name: 'gastown' })
);
@@ -138,18 +202,8 @@ describe('ClaudeAgentManager', () => {
});
it('emits AgentSpawned event with name', async () => {
const mockSubprocess = {
pid: 123,
kill: vi.fn(),
then: () =>
Promise.resolve({
stdout:
'{"type":"result","subtype":"success","session_id":"sess-123","result":"{\\"status\\":\\"done\\",\\"result\\":\\"Task completed\\"}"}',
stderr: '',
}),
catch: () => mockSubprocess,
};
mockExeca.mockReturnValue(mockSubprocess as unknown as ReturnType<typeof execa>);
const mockChild = createMockChildProcess();
mockSpawn.mockReturnValue(mockChild);
await manager.spawn({
name: 'gastown',
@@ -167,18 +221,8 @@ describe('ClaudeAgentManager', () => {
});
it('uses custom cwd if provided', async () => {
const mockSubprocess = {
pid: 123,
kill: vi.fn(),
then: () =>
Promise.resolve({
stdout:
'{"type":"result","subtype":"success","session_id":"sess-123","result":"{\\"status\\":\\"done\\",\\"result\\":\\"Task completed\\"}"}',
stderr: '',
}),
catch: () => mockSubprocess,
};
mockExeca.mockReturnValue(mockSubprocess as unknown as ReturnType<typeof execa>);
const mockChild = createMockChildProcess();
mockSpawn.mockReturnValue(mockChild);
await manager.spawn({
name: 'chinatown',
@@ -187,9 +231,10 @@ describe('ClaudeAgentManager', () => {
cwd: '/custom/path',
});
expect(mockExeca).toHaveBeenCalledWith(
// Verify spawn was called with custom cwd
expect(mockSpawn).toHaveBeenCalledWith(
'claude',
expect.arrayContaining(['-p', 'Test task', '--output-format', 'json', '--json-schema']),
expect.arrayContaining(['-p', 'Test task', '--output-format', 'stream-json']),
expect.objectContaining({ cwd: '/custom/path' })
);
});
@@ -201,24 +246,17 @@ describe('ClaudeAgentManager', () => {
// The repository mock returns mockAgent which has id 'agent-123'
await manager.stop(mockAgent.id);
expect(mockRepository.updateStatus).toHaveBeenCalledWith(
expect(mockRepository.update).toHaveBeenCalledWith(
mockAgent.id,
'stopped'
{ status: 'stopped' }
);
});
it('kills subprocess if running', async () => {
// Create a manager and spawn an agent first
const killFn = vi.fn();
const mockSubprocess = {
pid: 123,
kill: killFn,
then: () => new Promise(() => {}), // Never resolves
catch: () => mockSubprocess,
};
mockExeca.mockReturnValue(mockSubprocess as unknown as ReturnType<typeof execa>);
it('kills detached process if running', async () => {
const mockChild = createMockChildProcess();
mockSpawn.mockReturnValue(mockChild);
// Spawn returns immediately, we get the agent id from create mock
// Spawn returns immediately since process is detached
const spawned = await manager.spawn({
name: 'gastown',
taskId: 'task-456',
@@ -226,13 +264,12 @@ describe('ClaudeAgentManager', () => {
});
// Now stop using the returned agent ID
// But the spawned id comes from repository.create which returns mockAgent.id
await manager.stop(spawned.id);
expect(killFn).toHaveBeenCalledWith('SIGTERM');
expect(mockRepository.updateStatus).toHaveBeenCalledWith(
// Verify status was updated (process.kill is called internally, not on the child object)
expect(mockRepository.update).toHaveBeenCalledWith(
spawned.id,
'stopped'
{ status: 'stopped' }
);
});
@@ -245,13 +282,8 @@ describe('ClaudeAgentManager', () => {
});
it('emits AgentStopped event with user_requested reason', async () => {
const mockSubprocess = {
pid: 123,
kill: vi.fn(),
then: () => new Promise(() => {}),
catch: () => mockSubprocess,
};
mockExeca.mockReturnValue(mockSubprocess as unknown as ReturnType<typeof execa>);
const mockChild = createMockChildProcess();
mockSpawn.mockReturnValue(mockChild);
const spawned = await manager.spawn({
name: 'gastown',
@@ -322,31 +354,19 @@ describe('ClaudeAgentManager', () => {
status: 'waiting_for_input',
});
const mockSubprocess = {
pid: 123,
kill: vi.fn(),
then: () =>
Promise.resolve({
stdout:
'{"type":"result","subtype":"success","session_id":"sess-123","result":"{\\"status\\":\\"done\\",\\"result\\":\\"Continued successfully\\"}"}',
stderr: '',
}),
catch: () => mockSubprocess,
};
mockExeca.mockReturnValue(mockSubprocess as unknown as ReturnType<typeof execa>);
const mockChild = createMockChildProcess();
mockSpawn.mockReturnValue(mockChild);
await manager.resume(mockAgent.id, { q1: 'Answer one', q2: 'Answer two' });
expect(mockExeca).toHaveBeenCalledWith(
// Verify spawn was called with resume args
expect(mockSpawn).toHaveBeenCalledWith(
'claude',
expect.arrayContaining([
'-p',
'Here are my answers to your questions:\n[q1]: Answer one\n[q2]: Answer two',
'--resume',
'session-789',
'--output-format',
'json',
'--json-schema',
'stream-json',
]),
expect.any(Object)
);
@@ -375,36 +395,14 @@ describe('ClaudeAgentManager', () => {
);
});
it('rejects if worktree not found', async () => {
mockRepository.findById = vi.fn().mockResolvedValue({
...mockAgent,
status: 'waiting_for_input',
});
mockWorktreeManager.get = vi.fn().mockResolvedValue(null);
await expect(manager.resume(mockAgent.id, { q1: 'Answer' })).rejects.toThrow(
'Worktree'
);
});
it('emits AgentResumed event', async () => {
mockRepository.findById = vi.fn().mockResolvedValue({
...mockAgent,
status: 'waiting_for_input',
});
const mockSubprocess = {
pid: 123,
kill: vi.fn(),
then: () =>
Promise.resolve({
stdout:
'{"type":"result","subtype":"success","session_id":"sess-123","result":"{\\"status\\":\\"done\\",\\"result\\":\\"Continued successfully\\"}"}',
stderr: '',
}),
catch: () => mockSubprocess,
};
mockExeca.mockReturnValue(mockSubprocess as unknown as ReturnType<typeof execa>);
const mockChild = createMockChildProcess();
mockSpawn.mockReturnValue(mockChild);
await manager.resume(mockAgent.id, { q1: 'User answer' });
@@ -425,4 +423,63 @@ describe('ClaudeAgentManager', () => {
expect(result).toBeNull();
});
});
describe('delete', () => {
it('deletes agent and clears active state', async () => {
const mockChild = createMockChildProcess();
mockSpawn.mockReturnValue(mockChild);
// Spawn an agent first
const spawned = await manager.spawn({
name: 'gastown',
taskId: 'task-456',
prompt: 'Test',
});
// Delete the agent
await manager.delete(spawned.id);
// Verify DB record was deleted
expect(mockRepository.delete).toHaveBeenCalledWith(spawned.id);
});
it('emits agent:deleted event', async () => {
const mockChild = createMockChildProcess();
mockSpawn.mockReturnValue(mockChild);
eventBus.on('agent:deleted', (e) => capturedEvents.push(e));
const spawned = await manager.spawn({
name: 'gastown',
taskId: 'task-456',
prompt: 'Test',
});
await manager.delete(spawned.id);
const deletedEvent = capturedEvents.find(
(e) => e.type === 'agent:deleted'
);
expect(deletedEvent).toBeDefined();
expect(
(deletedEvent as { payload: { name: string } }).payload.name
).toBe('gastown');
});
it('throws if agent not found', async () => {
mockRepository.findById = vi.fn().mockResolvedValue(null);
await expect(manager.delete('nonexistent')).rejects.toThrow(
"Agent 'nonexistent' not found"
);
});
it('handles missing workdir gracefully', async () => {
// Agent exists in DB but has no active state and workdir doesn't exist
// The delete should succeed (best-effort cleanup)
await manager.delete(mockAgent.id);
expect(mockRepository.delete).toHaveBeenCalledWith(mockAgent.id);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -175,10 +175,10 @@ describe('MockAgentManager', () => {
// spawn() with crash scenario
// ===========================================================================
describe('spawn with unrecoverable_error scenario', () => {
describe('spawn with error scenario', () => {
it('should emit agent:crashed and set result.success=false', async () => {
manager.setScenario('crash-agent', {
status: 'unrecoverable_error',
status: 'error',
delay: 0,
error: 'Something went terribly wrong',
});
@@ -411,9 +411,9 @@ describe('MockAgentManager', () => {
describe('setScenario overrides', () => {
it('should use scenario override for specific agent name', async () => {
// Set unrecoverable_error scenario for one agent
// Set error scenario for one agent
manager.setScenario('crasher', {
status: 'unrecoverable_error',
status: 'error',
delay: 0,
error: 'Intentional crash',
});
@@ -443,7 +443,7 @@ describe('MockAgentManager', () => {
it('should allow clearing scenario override', async () => {
manager.setScenario('flip-flop', {
status: 'unrecoverable_error',
status: 'error',
delay: 0,
error: 'Crash for test',
});
@@ -490,7 +490,7 @@ describe('MockAgentManager', () => {
});
it('should emit spawned before crashed', async () => {
manager.setScenario('crash-order', { status: 'unrecoverable_error', delay: 0, error: 'Crash' });
manager.setScenario('crash-order', { status: 'error', delay: 0, error: 'Crash' });
await manager.spawn({ name: 'crash-order', taskId: 't1', prompt: 'p1' });
await vi.advanceTimersByTimeAsync(0);
@@ -553,7 +553,7 @@ describe('MockAgentManager', () => {
it('should use provided default scenario', async () => {
const customDefault: MockAgentScenario = {
status: 'unrecoverable_error',
status: 'error',
delay: 0,
error: 'Default crash',
};
@@ -611,10 +611,9 @@ describe('MockAgentManager', () => {
it('should spawn agent in discuss mode', async () => {
manager.setScenario('discuss-agent', {
status: 'context_complete',
status: 'done',
delay: 0,
decisions: [{ topic: 'Auth', decision: 'JWT', reason: 'Standard' }],
summary: 'Auth discussion complete',
result: 'Auth discussion complete',
});
const agent = await manager.spawn({
@@ -629,12 +628,9 @@ describe('MockAgentManager', () => {
it('should spawn agent in breakdown mode', async () => {
manager.setScenario('breakdown-agent', {
status: 'breakdown_complete',
status: 'done',
delay: 0,
phases: [
{ number: 1, name: 'Foundation', description: 'Core setup', dependencies: [] },
{ number: 2, name: 'Features', description: 'Main features', dependencies: [1] },
],
result: 'Breakdown complete',
});
const agent = await manager.spawn({
@@ -647,12 +643,11 @@ describe('MockAgentManager', () => {
expect(agent.mode).toBe('breakdown');
});
it('should emit stopped event with context_complete reason', async () => {
it('should emit stopped event with context_complete reason for discuss mode', async () => {
manager.setScenario('discuss-done', {
status: 'context_complete',
status: 'done',
delay: 0,
decisions: [],
summary: 'Done',
result: 'Done',
});
await manager.spawn({
@@ -667,11 +662,11 @@ describe('MockAgentManager', () => {
expect(stopped?.payload.reason).toBe('context_complete');
});
it('should emit stopped event with breakdown_complete reason', async () => {
it('should emit stopped event with breakdown_complete reason for breakdown mode', async () => {
manager.setScenario('breakdown-done', {
status: 'breakdown_complete',
status: 'done',
delay: 0,
phases: [],
result: 'Breakdown complete',
});
await manager.spawn({
@@ -702,19 +697,16 @@ describe('MockAgentManager', () => {
expect(agent.mode).toBe('decompose');
});
it('should complete with tasks on decompose_complete', async () => {
it('should complete with decompose_complete reason in decompose mode', async () => {
manager.setScenario('decomposer', {
status: 'decompose_complete',
tasks: [
{ number: 1, name: 'Task 1', description: 'First task', type: 'auto', dependencies: [] },
{ number: 2, name: 'Task 2', description: 'Second task', type: 'auto', dependencies: [1] },
],
status: 'done',
result: 'Decompose complete',
});
await manager.spawn({ name: 'decomposer', taskId: 'plan-1', prompt: 'test', mode: 'decompose' });
await vi.advanceTimersByTimeAsync(100);
// Verify agent:stopped event with decompose_complete reason
// Verify agent:stopped event with decompose_complete reason (derived from mode)
const stoppedEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:stopped') as AgentStoppedEvent | undefined;
expect(stoppedEvent).toBeDefined();
expect(stoppedEvent?.payload.reason).toBe('decompose_complete');
@@ -738,13 +730,11 @@ describe('MockAgentManager', () => {
expect(agent?.status).toBe('waiting_for_input');
});
it('should emit stopped event with decompose_complete reason', async () => {
it('should emit stopped event with decompose_complete reason (second test)', async () => {
manager.setScenario('decompose-done', {
status: 'decompose_complete',
status: 'done',
delay: 0,
tasks: [
{ number: 1, name: 'Setup', description: 'Initial setup', type: 'auto', dependencies: [] },
],
result: 'Decompose complete',
});
await manager.spawn({
@@ -759,14 +749,10 @@ describe('MockAgentManager', () => {
expect(stopped?.payload.reason).toBe('decompose_complete');
});
it('should set result message with task count', async () => {
it('should set result message for decompose mode', async () => {
manager.setScenario('decomposer', {
status: 'decompose_complete',
tasks: [
{ number: 1, name: 'Task 1', description: 'First', type: 'auto', dependencies: [] },
{ number: 2, name: 'Task 2', description: 'Second', type: 'checkpoint:human-verify', dependencies: [1] },
{ number: 3, name: 'Task 3', description: 'Third', type: 'auto', dependencies: [1, 2] },
],
status: 'done',
result: 'Decompose complete',
});
const agent = await manager.spawn({ name: 'decomposer', taskId: 'plan-1', prompt: 'test', mode: 'decompose' });
@@ -774,7 +760,7 @@ describe('MockAgentManager', () => {
const result = await manager.getResult(agent.id);
expect(result?.success).toBe(true);
expect(result?.message).toBe('Decomposed into 3 tasks');
expect(result?.message).toBe('Decompose complete');
});
});

View File

@@ -10,34 +10,29 @@ import { randomUUID } from 'crypto';
import type {
AgentManager,
AgentInfo,
AgentMode,
SpawnAgentOptions,
AgentResult,
AgentStatus,
PendingQuestions,
QuestionItem,
} from './types.js';
import type { Decision, PhaseBreakdown, TaskBreakdown } from './schema.js';
import type {
EventBus,
AgentSpawnedEvent,
AgentStoppedEvent,
AgentCrashedEvent,
AgentResumedEvent,
AgentDeletedEvent,
AgentWaitingEvent,
} from '../events/index.js';
/**
* Scenario configuration for mock agent behavior.
* Uses discriminated union on status to match agent output schema.
*
* Supports all four agent modes:
* - execute: done/questions/unrecoverable_error
* - discuss: questions/context_complete/unrecoverable_error
* - breakdown: questions/breakdown_complete/unrecoverable_error
* - decompose: questions/decompose_complete/unrecoverable_error
* Matches the simplified agent signal schema: done, questions, or error.
* Mode-specific stopped reasons are derived from the agent's mode.
*/
export type MockAgentScenario =
// Execute mode statuses
| {
status: 'done';
result?: string;
@@ -50,28 +45,8 @@ export type MockAgentScenario =
delay?: number;
}
| {
status: 'unrecoverable_error';
status: 'error';
error: string;
attempted?: string;
delay?: number;
}
// Discuss mode status
| {
status: 'context_complete';
decisions: Decision[];
summary: string;
delay?: number;
}
// Breakdown mode status
| {
status: 'breakdown_complete';
phases: PhaseBreakdown[];
delay?: number;
}
// Decompose mode status
| {
status: 'decompose_complete';
tasks: TaskBreakdown[];
delay?: number;
};
@@ -136,7 +111,8 @@ export class MockAgentManager implements AgentManager {
* Completion happens async via setTimeout (even if delay=0).
*/
async spawn(options: SpawnAgentOptions): Promise<AgentInfo> {
const { name, taskId, prompt } = options;
const { taskId, prompt } = options;
const name = options.name ?? `agent-${taskId?.slice(0, 6) ?? 'noTask'}`;
// Check name uniqueness
for (const record of this.agents.values()) {
@@ -150,17 +126,20 @@ export class MockAgentManager implements AgentManager {
const worktreeId = randomUUID();
const now = new Date();
// Determine scenario (override takes precedence)
// Determine scenario (override takes precedence — use original name or generated)
const scenario = this.scenarioOverrides.get(name) ?? this.defaultScenario;
const info: AgentInfo = {
id: agentId,
name,
taskId,
name: name ?? `mock-${agentId.slice(0, 6)}`,
taskId: taskId ?? null,
initiativeId: options.initiativeId ?? null,
sessionId,
worktreeId,
status: 'running',
mode: options.mode ?? 'execute',
provider: options.provider ?? 'claude',
accountId: null,
createdAt: now,
updatedAt: now,
};
@@ -180,8 +159,9 @@ export class MockAgentManager implements AgentManager {
payload: {
agentId,
name,
taskId,
taskId: taskId ?? null,
worktreeId,
provider: options.provider ?? 'claude',
},
};
this.eventBus.emit(event);
@@ -209,6 +189,19 @@ export class MockAgentManager implements AgentManager {
}
}
/**
* Map agent mode to stopped event reason.
*/
private getStoppedReason(mode: AgentMode): AgentStoppedEvent['payload']['reason'] {
switch (mode) {
case 'discuss': return 'context_complete';
case 'breakdown': return 'breakdown_complete';
case 'decompose': return 'decompose_complete';
case 'refine': return 'refine_complete';
default: return 'task_complete';
}
}
/**
* Complete agent based on scenario status.
*/
@@ -229,6 +222,7 @@ export class MockAgentManager implements AgentManager {
record.info.updatedAt = new Date();
if (this.eventBus) {
const reason = this.getStoppedReason(info.mode);
const event: AgentStoppedEvent = {
type: 'agent:stopped',
timestamp: new Date(),
@@ -236,14 +230,14 @@ export class MockAgentManager implements AgentManager {
agentId,
name: info.name,
taskId: info.taskId,
reason: 'task_complete',
reason,
},
};
this.eventBus.emit(event);
}
break;
case 'unrecoverable_error':
case 'error':
record.result = {
success: false,
message: scenario.error,
@@ -288,78 +282,6 @@ export class MockAgentManager implements AgentManager {
this.eventBus.emit(event);
}
break;
case 'context_complete':
// Discuss mode completion - captured all decisions
record.result = {
success: true,
message: scenario.summary,
};
record.info.status = 'idle';
record.info.updatedAt = new Date();
if (this.eventBus) {
const event: AgentStoppedEvent = {
type: 'agent:stopped',
timestamp: new Date(),
payload: {
agentId,
name: info.name,
taskId: info.taskId,
reason: 'context_complete',
},
};
this.eventBus.emit(event);
}
break;
case 'breakdown_complete':
// Breakdown mode completion - decomposed into phases
record.result = {
success: true,
message: `Decomposed into ${scenario.phases.length} phases`,
};
record.info.status = 'idle';
record.info.updatedAt = new Date();
if (this.eventBus) {
const event: AgentStoppedEvent = {
type: 'agent:stopped',
timestamp: new Date(),
payload: {
agentId,
name: info.name,
taskId: info.taskId,
reason: 'breakdown_complete',
},
};
this.eventBus.emit(event);
}
break;
case 'decompose_complete':
// Decompose mode completion - decomposed phase into tasks
record.result = {
success: true,
message: `Decomposed into ${scenario.tasks.length} tasks`,
};
record.info.status = 'idle';
record.info.updatedAt = new Date();
if (this.eventBus) {
const event: AgentStoppedEvent = {
type: 'agent:stopped',
timestamp: new Date(),
payload: {
agentId,
name: info.name,
taskId: info.taskId,
reason: 'decompose_complete',
},
};
this.eventBus.emit(event);
}
break;
}
}
@@ -398,6 +320,38 @@ export class MockAgentManager implements AgentManager {
}
}
/**
* Delete an agent and clean up.
* Removes from internal map and emits agent:deleted event.
*/
async delete(agentId: string): Promise<void> {
const record = this.agents.get(agentId);
if (!record) {
throw new Error(`Agent '${agentId}' not found`);
}
// Cancel any pending completion
if (record.completionTimer) {
clearTimeout(record.completionTimer);
record.completionTimer = undefined;
}
const name = record.info.name;
this.agents.delete(agentId);
if (this.eventBus) {
const event: AgentDeletedEvent = {
type: 'agent:deleted',
timestamp: new Date(),
payload: {
agentId,
name,
},
};
this.eventBus.emit(event);
}
}
/**
* List all agents with their current status.
*/
@@ -503,6 +457,28 @@ export class MockAgentManager implements AgentManager {
return record?.pendingQuestions ?? null;
}
/**
* Get the buffered output for an agent.
* Mock implementation returns empty array.
*/
getOutputBuffer(_agentId: string): string[] {
return [];
}
/**
* Dismiss an agent.
* Mock implementation just marks the agent as dismissed.
*/
async dismiss(agentId: string): Promise<void> {
const record = this.agents.get(agentId);
if (!record) {
throw new Error(`Agent '${agentId}' not found`);
}
// In mock, we just mark it as dismissed in memory
// Real implementation would update database
record.info.updatedAt = new Date();
}
/**
* Clear all agents and pending timers.
* Useful for test cleanup.

496
src/agent/output-handler.ts Normal file
View File

@@ -0,0 +1,496 @@
/**
* OutputHandler — Stream event processing, signal parsing, file reading, result capture.
*
* Extracted from MultiProviderAgentManager. Processes all output from agent
* subprocesses: stream events, agent signals, output files, and result/question
* retrieval.
*/
import { readFile } from 'node:fs/promises';
import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type {
EventBus,
AgentStoppedEvent,
AgentCrashedEvent,
AgentWaitingEvent,
AgentOutputEvent,
} from '../events/index.js';
import type {
AgentResult,
AgentMode,
PendingQuestions,
QuestionItem,
} from './types.js';
import type { StreamEvent } from './providers/parsers/index.js';
import type { AgentProviderConfig } from './providers/types.js';
import { agentSignalSchema } from './schema.js';
import {
readSummary,
readPhaseFiles,
readTaskFiles,
readDecisionFiles,
readPageFiles,
} from './file-io.js';
import { getProvider } from './providers/registry.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('output-handler');
/** Max number of output chunks to buffer per agent */
const MAX_OUTPUT_BUFFER_SIZE = 1000;
/**
* Tracks an active agent with its PID and file tailer.
*/
export interface ActiveAgent {
agentId: string;
pid: number;
tailer: import('./file-tailer.js').FileTailer;
outputFilePath: string;
result?: AgentResult;
pendingQuestions?: PendingQuestions;
streamResultText?: string;
streamSessionId?: string;
streamCostUsd?: number;
}
/**
* Result structure from Claude CLI with --output-format json.
*/
interface ClaudeCliResult {
type: 'result';
subtype: 'success' | 'error';
is_error: boolean;
session_id: string;
result: string;
structured_output?: unknown;
total_cost_usd?: number;
}
export class OutputHandler {
constructor(
private repository: AgentRepository,
private eventBus?: EventBus,
) {}
/**
* Handle a standardized stream event from a parser.
*/
handleStreamEvent(
agentId: string,
event: StreamEvent,
active: ActiveAgent | undefined,
outputBuffers: Map<string, string[]>,
): void {
switch (event.type) {
case 'init':
if (active && event.sessionId) {
active.streamSessionId = event.sessionId;
this.repository.update(agentId, { sessionId: event.sessionId }).catch((err) => {
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to update session ID');
});
}
break;
case 'text_delta':
this.pushToOutputBuffer(outputBuffers, agentId, event.text);
if (this.eventBus) {
const outputEvent: AgentOutputEvent = {
type: 'agent:output',
timestamp: new Date(),
payload: { agentId, stream: 'stdout', data: event.text },
};
this.eventBus.emit(outputEvent);
}
break;
case 'tool_use_start':
log.debug({ agentId, tool: event.name, toolId: event.id }, 'tool use started');
break;
case 'result':
if (active) {
active.streamResultText = event.text;
active.streamCostUsd = event.costUsd;
if (!active.streamSessionId && event.sessionId) {
active.streamSessionId = event.sessionId;
}
}
break;
case 'error':
log.error({ agentId, error: event.message }, 'stream error event');
break;
case 'turn_end':
log.debug({ agentId, stopReason: event.stopReason }, 'turn ended');
break;
}
}
/**
* Handle completion of a detached agent.
* Processes the final result from the stream data captured by the tailer.
*/
async handleCompletion(
agentId: string,
active: ActiveAgent | undefined,
getAgentWorkdir: (alias: string) => string,
): Promise<void> {
const agent = await this.repository.findById(agentId);
if (!agent) return;
const provider = getProvider(agent.provider);
if (!provider) return;
log.debug({ agentId }, 'detached agent completed');
let signalText = active?.streamResultText;
if (!signalText) {
try {
const fileContent = await readFile(active?.outputFilePath ?? '', 'utf-8');
if (fileContent.trim()) {
await this.processAgentOutput(agentId, fileContent, provider, getAgentWorkdir);
return;
}
} catch { /* file empty or missing */ }
log.warn({ agentId }, 'no result text from stream or file');
await this.handleAgentError(agentId, new Error('No output received'), provider, getAgentWorkdir);
return;
}
await this.processSignalAndFiles(
agentId,
signalText,
agent.mode as AgentMode,
getAgentWorkdir,
active?.streamSessionId,
);
}
/**
* Process agent signal JSON and read output files.
* Universal handler for all providers and modes.
*/
async processSignalAndFiles(
agentId: string,
signalText: string,
mode: AgentMode,
getAgentWorkdir: (alias: string) => string,
sessionId?: string,
): Promise<void> {
const agent = await this.repository.findById(agentId);
if (!agent) return;
let signal;
try {
const parsed = JSON.parse(signalText.trim());
signal = agentSignalSchema.parse(parsed);
} catch {
await this.repository.update(agentId, { status: 'crashed' });
this.emitCrashed(agent, 'Failed to parse agent signal JSON');
return;
}
switch (signal.status) {
case 'done':
await this.processOutputFiles(agentId, agent, mode, getAgentWorkdir);
break;
case 'questions':
await this.handleQuestions(agentId, agent, signal.questions, sessionId);
break;
case 'error':
await this.handleSignalError(agentId, agent, signal.error);
break;
}
}
/**
* Process output files from agent workdir after successful completion.
*/
private async processOutputFiles(
agentId: string,
agent: { id: string; name: string; taskId: string | null; worktreeId: string; mode: string },
mode: AgentMode,
getAgentWorkdir: (alias: string) => string,
): Promise<void> {
const agentWorkdir = getAgentWorkdir(agent.worktreeId);
const summary = readSummary(agentWorkdir);
let resultMessage = summary?.body ?? 'Task completed';
switch (mode) {
case 'breakdown': {
const phases = readPhaseFiles(agentWorkdir);
resultMessage = JSON.stringify({ summary: summary?.body, phases });
break;
}
case 'decompose': {
const tasks = readTaskFiles(agentWorkdir);
resultMessage = JSON.stringify({ summary: summary?.body, tasks });
break;
}
case 'discuss': {
const decisions = readDecisionFiles(agentWorkdir);
resultMessage = JSON.stringify({ summary: summary?.body, decisions });
break;
}
case 'refine': {
const pages = readPageFiles(agentWorkdir);
resultMessage = JSON.stringify({ summary: summary?.body, proposals: pages });
break;
}
}
const resultPayload: AgentResult = {
success: true,
message: resultMessage,
filesModified: summary?.filesModified,
};
await this.repository.update(agentId, { result: JSON.stringify(resultPayload) });
await this.repository.update(agentId, { status: 'idle' });
const reason = this.getStoppedReason(mode);
if (this.eventBus) {
const event: AgentStoppedEvent = {
type: 'agent:stopped',
timestamp: new Date(),
payload: {
agentId,
name: agent.name,
taskId: agent.taskId ?? '',
reason,
},
};
this.eventBus.emit(event);
}
return;
}
/**
* Handle questions signal from agent.
*/
async handleQuestions(
agentId: string,
agent: { id: string; name: string; taskId: string | null; sessionId: string | null },
questions: QuestionItem[],
sessionId?: string,
): Promise<void> {
const questionsPayload: PendingQuestions = { questions };
await this.repository.update(agentId, { pendingQuestions: JSON.stringify(questionsPayload) });
await this.repository.update(agentId, { status: 'waiting_for_input' });
if (this.eventBus) {
const event: AgentWaitingEvent = {
type: 'agent:waiting',
timestamp: new Date(),
payload: {
agentId,
name: agent.name,
taskId: agent.taskId ?? '',
sessionId: sessionId ?? agent.sessionId ?? '',
questions,
},
};
this.eventBus.emit(event);
}
}
/**
* Handle error signal from agent.
*/
async handleSignalError(
agentId: string,
agent: { id: string; name: string; taskId: string | null },
error: string,
): Promise<void> {
const errorResult: AgentResult = { success: false, message: error };
await this.repository.update(agentId, { result: JSON.stringify(errorResult) });
await this.repository.update(agentId, { status: 'crashed' });
this.emitCrashed(agent, error);
}
/**
* Map agent mode to stopped event reason.
*/
getStoppedReason(mode: AgentMode): AgentStoppedEvent['payload']['reason'] {
switch (mode) {
case 'discuss': return 'context_complete';
case 'breakdown': return 'breakdown_complete';
case 'decompose': return 'decompose_complete';
case 'refine': return 'refine_complete';
default: return 'task_complete';
}
}
/**
* Process raw output from an agent (from file or direct).
*/
async processAgentOutput(
agentId: string,
rawOutput: string,
provider: AgentProviderConfig,
getAgentWorkdir: (alias: string) => string,
): Promise<void> {
const agent = await this.repository.findById(agentId);
if (!agent) return;
// Extract session ID using provider's extraction config
let sessionId: string | null = null;
if (provider.sessionId) {
try {
if (provider.sessionId.extractFrom === 'result') {
const parsed = JSON.parse(rawOutput);
sessionId = parsed[provider.sessionId.field] ?? null;
} else if (provider.sessionId.extractFrom === 'event') {
const lines = rawOutput.trim().split('\n');
for (const line of lines) {
try {
const event = JSON.parse(line);
if (event.type === provider.sessionId.eventType) {
sessionId = event[provider.sessionId.field] ?? null;
}
} catch { /* skip */ }
}
}
} catch { /* parse failure */ }
}
if (sessionId) {
await this.repository.update(agentId, { sessionId });
}
log.debug({ agentId, provider: provider.name, hasSessionId: !!sessionId }, 'processing agent output');
if (provider.name === 'claude') {
let signalText: string;
try {
const cliResult: ClaudeCliResult = JSON.parse(rawOutput);
const signal = cliResult.structured_output ?? JSON.parse(cliResult.result);
signalText = JSON.stringify(signal);
} catch (parseErr) {
log.error({ agentId, err: parseErr instanceof Error ? parseErr.message : String(parseErr) }, 'failed to parse agent output');
await this.repository.update(agentId, { status: 'crashed' });
return;
}
await this.processSignalAndFiles(agentId, signalText, agent.mode as AgentMode, getAgentWorkdir, sessionId ?? undefined);
} else {
await this.processSignalAndFiles(agentId, rawOutput, agent.mode as AgentMode, getAgentWorkdir, sessionId ?? undefined);
}
}
/**
* Handle agent errors. Detects usage limit exhaustion patterns.
* Returns true if error was an exhaustion error (caller should attempt failover).
*/
async handleAgentError(
agentId: string,
error: unknown,
provider: AgentProviderConfig,
_getAgentWorkdir: (alias: string) => string,
): Promise<void> {
const errorMessage = error instanceof Error ? error.message : String(error);
const agent = await this.repository.findById(agentId);
if (!agent) return;
log.error({ agentId, err: errorMessage }, 'agent error');
await this.repository.update(agentId, { status: 'crashed' });
if (this.eventBus) {
const event: AgentCrashedEvent = {
type: 'agent:crashed',
timestamp: new Date(),
payload: {
agentId,
name: agent.name,
taskId: agent.taskId ?? '',
error: errorMessage,
},
};
this.eventBus.emit(event);
}
const errorResult: AgentResult = {
success: false,
message: errorMessage,
};
await this.repository.update(agentId, { result: JSON.stringify(errorResult) });
}
/**
* Format answers map as structured prompt.
*/
formatAnswersAsPrompt(answers: Record<string, string>): string {
const lines = Object.entries(answers).map(
([questionId, answer]) => `[${questionId}]: ${answer}`,
);
return `Here are my answers to your questions:\n${lines.join('\n')}`;
}
/**
* Get the result of an agent's work.
*/
async getResult(agentId: string, active?: ActiveAgent): Promise<AgentResult | null> {
if (active?.result) return active.result;
const agent = await this.repository.findById(agentId);
return agent?.result ? JSON.parse(agent.result) : null;
}
/**
* Get pending questions for an agent waiting for input.
*/
async getPendingQuestions(agentId: string, active?: ActiveAgent): Promise<PendingQuestions | null> {
if (active?.pendingQuestions) return active.pendingQuestions;
const agent = await this.repository.findById(agentId);
return agent?.pendingQuestions ? JSON.parse(agent.pendingQuestions) : null;
}
// =========================================================================
// Output Buffer Management
// =========================================================================
pushToOutputBuffer(buffers: Map<string, string[]>, agentId: string, chunk: string): void {
let buffer = buffers.get(agentId);
if (!buffer) {
buffer = [];
buffers.set(agentId, buffer);
}
buffer.push(chunk);
while (buffer.length > MAX_OUTPUT_BUFFER_SIZE) {
buffer.shift();
}
}
clearOutputBuffer(buffers: Map<string, string[]>, agentId: string): void {
buffers.delete(agentId);
}
getOutputBufferCopy(buffers: Map<string, string[]>, agentId: string): string[] {
return [...(buffers.get(agentId) ?? [])];
}
// =========================================================================
// Private Helpers
// =========================================================================
private emitCrashed(agent: { id: string; name: string; taskId: string | null }, error: string): void {
if (this.eventBus) {
const event: AgentCrashedEvent = {
type: 'agent:crashed',
timestamp: new Date(),
payload: {
agentId: agent.id,
name: agent.name,
taskId: agent.taskId ?? '',
error,
},
};
this.eventBus.emit(event);
}
}
}

View File

@@ -0,0 +1,269 @@
/**
* ProcessManager — Subprocess lifecycle, worktree creation, command building.
*
* Extracted from MultiProviderAgentManager. Manages the spawning of detached
* subprocesses, worktree creation per project, and provider-specific command
* construction.
*/
import { spawn } from 'node:child_process';
import { writeFileSync, mkdirSync, openSync, closeSync } from 'node:fs';
import { join } from 'node:path';
import type { ProjectRepository } from '../db/repositories/project-repository.js';
import type { EventBus } from '../events/index.js';
import type { AgentProviderConfig } from './providers/types.js';
import type { StreamEvent } from './providers/parsers/index.js';
import { getStreamParser } from './providers/parsers/index.js';
import { SimpleGitWorktreeManager } from '../git/manager.js';
import { ensureProjectClone, getProjectCloneDir } from '../git/project-clones.js';
import { FileTailer } from './file-tailer.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('process-manager');
/**
* Check if a process with the given PID is still alive.
*/
export function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export class ProcessManager {
constructor(
private workspaceRoot: string,
private projectRepository: ProjectRepository,
private eventBus?: EventBus,
) {}
/**
* Resolve the agent's working directory path.
*/
getAgentWorkdir(alias: string): string {
return join(this.workspaceRoot, 'agent-workdirs', alias);
}
/**
* Create worktrees for all projects linked to an initiative.
* Returns the base agent workdir path.
*/
async createProjectWorktrees(
alias: string,
initiativeId: string,
baseBranch: string = 'main',
): Promise<string> {
const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId);
const agentWorkdir = this.getAgentWorkdir(alias);
for (const project of projects) {
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
const worktreeManager = new SimpleGitWorktreeManager(clonePath, undefined, agentWorkdir);
await worktreeManager.create(project.name, `agent/${alias}`, baseBranch);
}
return agentWorkdir;
}
/**
* Fallback: create a single "workspace" worktree for standalone agents.
*/
async createStandaloneWorktree(alias: string): Promise<string> {
const agentWorkdir = this.getAgentWorkdir(alias);
const worktreeManager = new SimpleGitWorktreeManager(this.workspaceRoot, undefined, agentWorkdir);
const worktree = await worktreeManager.create('workspace', `agent/${alias}`);
return worktree.path;
}
/**
* Build the spawn command for a given provider configuration.
*/
buildSpawnCommand(
provider: AgentProviderConfig,
prompt: string,
): { command: string; args: string[]; env: Record<string, string> } {
const args = [...provider.args];
const env: Record<string, string> = { ...provider.env };
if (provider.nonInteractive?.subcommand) {
args.unshift(provider.nonInteractive.subcommand);
}
if (provider.promptMode === 'native') {
args.push('-p', prompt);
} else if (provider.promptMode === 'flag' && provider.nonInteractive?.promptFlag) {
args.push(provider.nonInteractive.promptFlag, prompt);
}
if (provider.nonInteractive?.outputFlag) {
args.push(...provider.nonInteractive.outputFlag.split(' '));
}
return { command: provider.command, args, env };
}
/**
* Build the resume command for a given provider configuration.
*/
buildResumeCommand(
provider: AgentProviderConfig,
sessionId: string,
prompt: string,
): { command: string; args: string[]; env: Record<string, string> } {
const args = [...provider.args];
const env: Record<string, string> = { ...provider.env };
switch (provider.resumeStyle) {
case 'flag':
args.push(provider.resumeFlag!, sessionId);
break;
case 'subcommand':
if (provider.nonInteractive?.subcommand) {
args.unshift(provider.nonInteractive.subcommand);
}
args.push(provider.resumeFlag!, sessionId);
break;
case 'none':
throw new Error(`Provider '${provider.name}' does not support resume`);
}
if (provider.promptMode === 'native') {
args.push('-p', prompt);
} else if (provider.promptMode === 'flag' && provider.nonInteractive?.promptFlag) {
args.push(provider.nonInteractive.promptFlag, prompt);
}
if (provider.nonInteractive?.outputFlag) {
args.push(...provider.nonInteractive.outputFlag.split(' '));
}
return { command: provider.command, args, env };
}
/**
* Extract session ID from CLI output based on provider config.
*/
extractSessionId(
provider: AgentProviderConfig,
output: string,
): string | null {
if (!provider.sessionId) return null;
try {
if (provider.sessionId.extractFrom === 'result') {
const parsed = JSON.parse(output);
return parsed[provider.sessionId.field] ?? null;
}
if (provider.sessionId.extractFrom === 'event') {
const lines = output.trim().split('\n');
for (const line of lines) {
try {
const event = JSON.parse(line);
if (event.type === provider.sessionId.eventType) {
return event[provider.sessionId.field] ?? null;
}
} catch {
// Skip non-JSON lines
}
}
}
} catch {
// Parse failure
}
return null;
}
/**
* Spawn a detached subprocess with file redirection for crash resilience.
* The subprocess writes directly to files and survives server crashes.
* A FileTailer watches the output file and emits events in real-time.
*
* @param onEvent - Callback for stream events from the tailer
*/
spawnDetached(
agentId: string,
command: string,
args: string[],
cwd: string,
env: Record<string, string>,
providerName: string,
prompt?: string,
onEvent?: (event: StreamEvent) => void,
): { pid: number; outputFilePath: string; tailer: FileTailer } {
const logDir = join(this.workspaceRoot, '.cw', 'agent-logs', agentId);
mkdirSync(logDir, { recursive: true });
const outputFilePath = join(logDir, 'output.jsonl');
const stderrFilePath = join(logDir, 'stderr.log');
if (prompt) {
writeFileSync(join(logDir, 'PROMPT.md'), prompt, 'utf-8');
}
const stdoutFd = openSync(outputFilePath, 'w');
const stderrFd = openSync(stderrFilePath, 'w');
const child = spawn(command, args, {
cwd,
env: { ...process.env, ...env },
detached: true,
stdio: ['ignore', stdoutFd, stderrFd],
});
closeSync(stdoutFd);
closeSync(stderrFd);
child.unref();
const pid = child.pid!;
log.debug({ agentId, pid, command }, 'spawned detached process');
const parser = getStreamParser(providerName);
const tailer = new FileTailer({
filePath: outputFilePath,
agentId,
parser,
eventBus: this.eventBus,
onEvent: onEvent ?? (() => {}),
startFromBeginning: true,
});
tailer.start().catch((err) => {
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to start tailer');
});
return { pid, outputFilePath, tailer };
}
/**
* Poll for process completion by checking if PID is still alive.
* When the process exits, calls onComplete callback.
*
* @param onComplete - Called when the process is no longer alive
* @param getTailer - Function to get the current tailer for final flush
*/
pollForCompletion(
agentId: string,
pid: number,
onComplete: () => Promise<void>,
getTailer: () => FileTailer | undefined,
): void {
const check = async () => {
if (!isPidAlive(pid)) {
const tailer = getTailer();
if (tailer) {
await new Promise((resolve) => setTimeout(resolve, 500));
await tailer.stop();
}
await onComplete();
return;
}
setTimeout(check, 1000);
};
check();
}
}

View File

@@ -1,70 +1,103 @@
/**
* Agent Prompts Module
*
* Comprehensive mode-specific prompts for agent operations.
* Each prompt explains the agent's role, output format, and expected behavior.
* Zero-argument prompt builders for file-based agent I/O.
* Dynamic context is written to .cw/input/ files before spawn.
* Agents write output to .cw/output/ files and emit a trivial JSON signal.
*/
import type { Initiative, Phase, Plan } from '../db/schema.js';
const SIGNAL_FORMAT = `
## Signal Output
When done, output ONLY this JSON (no other text before or after):
{ "status": "done" }
If you need clarification, output:
{ "status": "questions", "questions": [{ "id": "q1", "question": "Your question" }] }
If you hit an unrecoverable error, output:
{ "status": "error", "error": "Description of what went wrong" }`;
const INPUT_FILES = `
## Input Files
Read context from \`.cw/input/\`:
- \`initiative.md\` — Initiative details (frontmatter: id, name, status)
- \`phase.md\` — Phase details if applicable (frontmatter: id, number, name, status; body: description)
- \`task.md\` — Task details if applicable (frontmatter: id, name, category, type, priority, status; body: description)
- \`pages/\` — Initiative pages (one file per page; frontmatter: title, parentPageId, sortOrder; body: markdown content)`;
const SUMMARY_REQUIREMENT = `
## Required Output
ALWAYS write \`.cw/output/SUMMARY.md\` with:
- Frontmatter: \`files_modified\` (list of file paths you changed)
- Body: A concise summary of what you accomplished (shown to the user)
Example:
\`\`\`
---
files_modified:
- src/auth/login.ts
- src/auth/middleware.ts
---
Implemented JWT-based login with refresh token support.
\`\`\``;
const ID_GENERATION = `
## ID Generation
When creating new entities (phases, tasks, decisions), generate a unique ID by running:
\`\`\`
cw id
\`\`\`
Use the output as the filename (e.g., \`{id}.md\`).`;
/**
* Build comprehensive prompt for discuss mode.
* Agent asks clarifying questions to understand requirements.
* Build prompt for execute mode (standard worker agent).
*/
export function buildDiscussPrompt(initiative: Initiative, context?: string): string {
export function buildExecutePrompt(): string {
return `You are a Worker agent in the Codewalk multi-agent system.
## Your Role
Execute the assigned task. Read the task details from input files, do the work, and report results.
${INPUT_FILES}
${SIGNAL_FORMAT}
${SUMMARY_REQUIREMENT}
## Rules
- Complete the task as specified in .cw/input/task.md
- Ask questions if requirements are unclear
- Report errors honestly — don't guess
- Focus on writing clean, tested code`;
}
/**
* Build prompt for discuss mode.
* Agent asks clarifying questions to understand requirements and captures decisions.
*/
export function buildDiscussPrompt(): string {
return `You are an Architect agent in the Codewalk multi-agent system operating in DISCUSS mode.
## Your Role
Transform user intent into clear, documented decisions. You do NOT write code - you capture decisions.
Transform user intent into clear, documented decisions. You do NOT write code you capture decisions.
${INPUT_FILES}
${SIGNAL_FORMAT}
## Initiative
Name: ${initiative.name}
${initiative.description ? `Description: ${initiative.description}` : ''}
${context ? `\nAdditional Context: ${context}` : ''}
## Output Files
## Your Task
Ask clarifying questions to understand the requirements. Capture decisions as you go.
Write decisions to \`.cw/output/decisions/{id}.md\`:
- Frontmatter: \`topic\`, \`decision\`, \`reason\`
- Body: Additional context or rationale
${SUMMARY_REQUIREMENT}
${ID_GENERATION}
## Question Categories
**User Journeys:**
- What are the main user workflows?
- What happens on success? On failure?
- What are the edge cases?
**Technical Constraints:**
- What patterns should we follow?
- What should we avoid?
- What existing code should we reference?
**Data & Validation:**
- What data structures are needed?
- What validation rules apply?
- What are the constraints?
**Integration Points:**
- What external systems are involved?
- What APIs do we need?
- What error handling is required?
## Output Format
When you need more information, output:
{
"status": "questions",
"questions": [
{ "id": "q1", "question": "Your question here", "options": [{"label": "Option A"}, {"label": "Option B"}] }
]
}
When you have enough information, output:
{
"status": "context_complete",
"decisions": [
{ "topic": "Authentication", "decision": "Use JWT", "reason": "Stateless, scalable" }
],
"summary": "Brief summary of all decisions made"
}
- **User Journeys**: Main workflows, success/failure paths, edge cases
- **Technical Constraints**: Patterns to follow, things to avoid, reference code
- **Data & Validation**: Data structures, validation rules, constraints
- **Integration Points**: External systems, APIs, error handling
## Rules
- Ask 2-4 questions at a time, not more
@@ -74,194 +107,102 @@ When you have enough information, output:
}
/**
* Build comprehensive prompt for breakdown mode.
* Build prompt for breakdown mode.
* Agent decomposes initiative into executable phases.
*/
export function buildBreakdownPrompt(initiative: Initiative, contextSummary?: string): string {
export function buildBreakdownPrompt(): string {
return `You are an Architect agent in the Codewalk multi-agent system operating in BREAKDOWN mode.
## Your Role
Decompose the initiative into executable phases. You do NOT write code - you plan it.
Decompose the initiative into executable phases. You do NOT write code you plan it.
${INPUT_FILES}
${SIGNAL_FORMAT}
## Initiative
Name: ${initiative.name}
${initiative.description ? `Description: ${initiative.description}` : ''}
${contextSummary ? `\nContext from Discussion Phase:\n${contextSummary}` : ''}
## Output Files
## Your Task
Break this initiative into phases that can be executed by worker agents.
Write one file per phase to \`.cw/output/phases/{id}.md\`:
- Frontmatter: \`title\`, \`dependencies\` (list of other phase IDs this depends on)
- Body: Description of the phase and what gets built
${SUMMARY_REQUIREMENT}
${ID_GENERATION}
## Phase Design Rules
**Each phase must be:**
- A coherent unit of work (single concern)
- Independently deliverable
- Testable in isolation
**Dependencies:**
- Identify what each phase needs from prior phases
- Minimize cross-phase dependencies
- Foundation phases come first
**Sizing:**
- Phases should be 2-5 tasks each
- Not too big (hard to track), not too small (overhead)
**Naming:**
- Clear, action-oriented names
- Describe what gets built, not how
## Output Format
If you need clarification, output:
{
"status": "questions",
"questions": [
{ "id": "q1", "question": "Your question here" }
]
}
When breakdown is complete, output:
{
"status": "breakdown_complete",
"phases": [
{
"number": 1,
"name": "Database Schema",
"description": "Create user tables and authentication schema",
"dependencies": []
},
{
"number": 2,
"name": "Auth API",
"description": "JWT generation, validation, and middleware",
"dependencies": [1]
}
]
}
- Each phase: single concern, independently deliverable, testable
- Minimize cross-phase dependencies; foundation phases first
- Size: 2-5 tasks each (not too big, not too small)
- Clear, action-oriented names (describe what gets built, not how)
## Rules
- Start with foundation/infrastructure phases
- Group related work together
- Make dependencies explicit
- Make dependencies explicit using phase IDs
- Each phase should be completable in one session`;
}
/**
* Build prompt for execute mode (standard worker agent).
* This is the default mode for task execution.
* Build prompt for decompose mode.
* Agent breaks a phase into executable tasks.
*/
export function buildExecutePrompt(taskDescription: string): string {
return `You are a Worker agent in the Codewalk multi-agent system.
## Your Task
${taskDescription}
## Output Format
When task is complete, output:
{
"status": "done",
"result": "Description of what was accomplished",
"filesModified": ["path/to/file1.ts", "path/to/file2.ts"]
}
If you need clarification, output:
{
"status": "questions",
"questions": [
{ "id": "q1", "question": "Your question here" }
]
}
If you hit an unrecoverable error, output:
{
"status": "unrecoverable_error",
"error": "Description of what went wrong",
"attempted": "What you tried before failing"
}
## Rules
- Complete the task as specified
- Ask questions if requirements are unclear
- Report errors honestly - don't guess`;
}
/**
* Build comprehensive prompt for decompose mode.
* Agent breaks a plan into executable tasks.
*/
export function buildDecomposePrompt(plan: Plan, phase: Phase, context?: string): string {
export function buildDecomposePrompt(): string {
return `You are an Architect agent in the Codewalk multi-agent system operating in DECOMPOSE mode.
## Your Role
Break a plan into executable tasks. You do NOT write code - you decompose work into atomic units.
Decompose the phase into individual executable tasks. You do NOT write code you define work items.
${INPUT_FILES}
${SIGNAL_FORMAT}
## Plan
Name: ${plan.name}
Phase: ${phase.name}
${plan.description ? `Description: ${plan.description}` : ''}
${context ? `\nAdditional Context: ${context}` : ''}
## Output Files
## Your Task
Decompose this plan into tasks that worker agents can execute.
Write one file per task to \`.cw/output/tasks/{id}.md\`:
- Frontmatter:
- \`title\`: Clear task name
- \`category\`: One of: execute, research, discuss, breakdown, decompose, refine, verify, merge, review
- \`type\`: One of: auto, checkpoint:human-verify, checkpoint:decision, checkpoint:human-action
- \`dependencies\`: List of other task IDs this depends on
- Body: Detailed description of what the task requires
${SUMMARY_REQUIREMENT}
${ID_GENERATION}
## Task Design Rules
**Each task must be:**
- A single atomic unit of work
- Independently executable (or with clear dependencies)
- Verifiable (has a clear done condition)
**Task Types:**
- 'auto': Agent executes autonomously (default, most common)
- 'checkpoint:human-verify': Needs human to verify visual/functional output
- 'checkpoint:decision': Needs human to make a choice
- 'checkpoint:human-action': Needs unavoidable manual action (rare)
**Dependencies:**
- Identify what each task needs from prior tasks
- Use task numbers (1, 2, 3) for dependencies
- Minimize dependencies where possible
**Sizing:**
- Tasks should be 15-60 minutes of work
- Not too big (hard to debug), not too small (overhead)
## Output Format
If you need clarification, output:
{
"status": "questions",
"questions": [
{ "id": "q1", "question": "Your question here" }
]
}
When decomposition is complete, output:
{
"status": "decompose_complete",
"tasks": [
{
"number": 1,
"name": "Create user schema",
"description": "Add User table to database with email, passwordHash, createdAt",
"type": "auto",
"dependencies": []
},
{
"number": 2,
"name": "Create login endpoint",
"description": "POST /api/auth/login - validate credentials, return JWT",
"type": "auto",
"dependencies": [1]
}
]
}
- Each task: specific, actionable, completable by one agent
- Include verification steps where appropriate
- Use \`checkpoint:*\` types for tasks requiring human review
- Dependencies should be minimal and explicit
## Rules
- Tasks must be in dependency order
- Each task should have clear, specific description
- Default to 'auto' type unless human interaction is genuinely required
- Keep tasks focused on a single concern`;
- Break work into 3-8 tasks per phase
- Order tasks logically (foundational work first)
- Make each task self-contained with enough context
- Include test/verify tasks where appropriate`;
}
/**
* Build prompt for refine mode.
* Agent reviews initiative content and proposes edits to pages.
*/
export function buildRefinePrompt(): string {
return `You are an Architect agent in the Codewalk multi-agent system operating in REFINE mode.
## Your Role
Review and improve initiative content. You suggest edits to specific pages. You do NOT write code.
${INPUT_FILES}
${SIGNAL_FORMAT}
## Output Files
Write one file per modified page to \`.cw/output/pages/{pageId}.md\`:
- Frontmatter: \`title\`, \`summary\` (what changed and why)
- Body: Full new markdown content for the page (replaces entire page body)
${SUMMARY_REQUIREMENT}
## Rules
- Ask 2-4 questions at a time if you need clarification
- Only propose changes for pages that genuinely need improvement
- Each output page's body is the FULL new content (not a diff)
- Preserve [[page:\$id|title]] cross-references in your output
- Focus on clarity, completeness, and consistency
- Do not invent new page IDs — only reference existing ones from .cw/input/pages/`;
}

View File

@@ -0,0 +1,40 @@
/**
* Agent Providers Module - Public API
*
* Re-exports provider types, presets, and registry functions.
*/
export type {
AgentProviderConfig,
StructuredOutputConfig,
SessionIdConfig,
NonInteractiveConfig,
} from './types.js';
export { PROVIDER_PRESETS } from './presets.js';
export {
getProvider,
listProviders,
registerProvider,
loadProvidersFromFile,
} from './registry.js';
// Stream parsing
export type {
StreamEvent,
StreamParser,
StreamInitEvent,
StreamTextDeltaEvent,
StreamToolUseStartEvent,
StreamToolResultEvent,
StreamTurnEndEvent,
StreamResultEvent,
StreamErrorEvent,
} from './stream-types.js';
export {
getStreamParser,
ClaudeStreamParser,
GenericStreamParser,
} from './parsers/index.js';

View File

@@ -0,0 +1,165 @@
/**
* Claude Code Stream Parser
*
* Parses Claude Code CLI `--output-format stream-json` NDJSON output
* into standardized StreamEvents.
*
* Key line types handled:
* - system (subtype=init): session_id
* - stream_event (content_block_delta, text_delta): delta.text
* - stream_event (content_block_start, tool_use): content_block.name, .id
* - stream_event (message_delta): delta.stop_reason
* - result: result, session_id, total_cost_usd
* - any with is_error: true: error message
*/
import type { StreamEvent, StreamParser } from '../stream-types.js';
interface ClaudeSystemEvent {
type: 'system';
subtype?: string;
session_id?: string;
}
interface ClaudeStreamEvent {
type: 'stream_event';
event?: {
type: string;
index?: number;
delta?: {
type?: string;
text?: string;
stop_reason?: string;
};
content_block?: {
type?: string;
id?: string;
name?: string;
};
};
}
interface ClaudeAssistantEvent {
type: 'assistant';
message?: {
content?: Array<{
type: string;
text?: string;
id?: string;
name?: string;
}>;
};
}
interface ClaudeResultEvent {
type: 'result';
result?: string;
session_id?: string;
total_cost_usd?: number;
is_error?: boolean;
}
type ClaudeEvent = ClaudeSystemEvent | ClaudeStreamEvent | ClaudeAssistantEvent | ClaudeResultEvent | { type: string; is_error?: boolean; result?: string };
export class ClaudeStreamParser implements StreamParser {
readonly provider = 'claude';
parseLine(line: string): StreamEvent[] {
const trimmed = line.trim();
if (!trimmed) return [];
let parsed: ClaudeEvent;
try {
parsed = JSON.parse(trimmed);
} catch {
// Not valid JSON, ignore
return [];
}
// Check for error first (can appear on any event type)
if ('is_error' in parsed && parsed.is_error && 'result' in parsed) {
return [{ type: 'error', message: String(parsed.result) }];
}
const events: StreamEvent[] = [];
switch (parsed.type) {
case 'system': {
const sysEvent = parsed as ClaudeSystemEvent;
if (sysEvent.subtype === 'init' && sysEvent.session_id) {
events.push({ type: 'init', sessionId: sysEvent.session_id });
}
break;
}
case 'stream_event': {
const streamEvent = parsed as ClaudeStreamEvent;
const inner = streamEvent.event;
if (!inner) break;
switch (inner.type) {
case 'content_block_delta': {
if (inner.delta?.type === 'text_delta' && inner.delta.text) {
events.push({ type: 'text_delta', text: inner.delta.text });
}
break;
}
case 'content_block_start': {
if (inner.content_block?.type === 'tool_use') {
const name = inner.content_block.name || 'unknown';
const id = inner.content_block.id || '';
events.push({ type: 'tool_use_start', name, id });
}
break;
}
case 'message_delta': {
if (inner.delta?.stop_reason) {
events.push({ type: 'turn_end', stopReason: inner.delta.stop_reason });
}
break;
}
}
break;
}
case 'assistant': {
// Claude CLI stream-json now emits complete assistant messages
// instead of granular stream_event deltas
const assistantEvent = parsed as ClaudeAssistantEvent;
const content = assistantEvent.message?.content;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text' && block.text) {
events.push({ type: 'text_delta', text: block.text });
} else if (block.type === 'tool_use' && block.name) {
events.push({ type: 'tool_use_start', name: block.name, id: block.id || '' });
}
}
}
break;
}
case 'result': {
const resultEvent = parsed as ClaudeResultEvent;
events.push({
type: 'result',
text: resultEvent.result || '',
sessionId: resultEvent.session_id,
costUsd: resultEvent.total_cost_usd,
});
break;
}
// Ignore: message_start, content_block_stop, message_stop, user
}
return events;
}
end(): StreamEvent[] {
// Claude emits a result event, so nothing needed at end
return [];
}
}

View File

@@ -0,0 +1,32 @@
/**
* Generic Fallback Stream Parser
*
* For providers without a dedicated parser. Treats each line as text output.
* Accumulates all output and emits a final result event on stream end.
*/
import type { StreamEvent, StreamParser } from '../stream-types.js';
export class GenericStreamParser implements StreamParser {
readonly provider = 'generic';
private accumulated: string[] = [];
parseLine(line: string): StreamEvent[] {
if (!line) return [];
this.accumulated.push(line);
// Emit each line as a text delta
return [{ type: 'text_delta', text: line + '\n' }];
}
end(): StreamEvent[] {
// Emit the accumulated output as the result
const fullText = this.accumulated.join('\n');
this.accumulated = [];
if (!fullText) return [];
return [{ type: 'result', text: fullText }];
}
}

View File

@@ -0,0 +1,31 @@
/**
* Stream Parser Registry
*
* Factory function to get the appropriate stream parser for a provider.
*/
import type { StreamParser } from '../stream-types.js';
import { ClaudeStreamParser } from './claude.js';
import { GenericStreamParser } from './generic.js';
/** Map of provider names to parser constructors */
const parserRegistry: Record<string, new () => StreamParser> = {
claude: ClaudeStreamParser,
};
/**
* Get a stream parser for the given provider.
* Returns a provider-specific parser if available, otherwise the generic fallback.
*/
export function getStreamParser(providerName: string): StreamParser {
const ParserClass = parserRegistry[providerName];
if (ParserClass) {
return new ParserClass();
}
return new GenericStreamParser();
}
// Re-export types and parsers for direct access
export type { StreamParser, StreamEvent } from '../stream-types.js';
export { ClaudeStreamParser } from './claude.js';
export { GenericStreamParser } from './generic.js';

View File

@@ -0,0 +1,145 @@
/**
* Built-in Agent Provider Presets
*
* Data-driven configuration for all supported agent CLI providers.
* Ported from reference/gastown/internal/config/agents.go builtinPresets.
*/
import type { AgentProviderConfig } from './types.js';
export const PROVIDER_PRESETS: Record<string, AgentProviderConfig> = {
claude: {
name: 'claude',
command: 'claude',
args: ['--dangerously-skip-permissions', '--verbose'],
processNames: ['node', 'claude'],
configDirEnv: 'CLAUDE_CONFIG_DIR',
resumeFlag: '--resume',
resumeStyle: 'flag',
promptMode: 'native',
// No structuredOutput - schema enforcement via prompt text + validation
sessionId: {
extractFrom: 'event',
field: 'session_id',
eventType: 'system',
},
nonInteractive: {
outputFlag: '--output-format stream-json',
},
},
codex: {
name: 'codex',
command: 'codex',
args: ['--full-auto'],
processNames: ['codex'],
resumeFlag: 'resume',
resumeStyle: 'subcommand',
promptMode: 'native',
structuredOutput: {
flag: '--output-schema',
schemaMode: 'file',
outputFormat: 'jsonl',
},
sessionId: {
extractFrom: 'event',
field: 'thread_id',
eventType: 'thread.started',
},
nonInteractive: {
subcommand: 'exec',
outputFlag: '--json',
},
},
gemini: {
name: 'gemini',
command: 'gemini',
args: ['--sandbox=off'],
processNames: ['gemini'],
resumeFlag: '--resume',
resumeStyle: 'flag',
promptMode: 'flag',
structuredOutput: {
flag: '--output-format',
schemaMode: 'none',
outputFormat: 'json',
},
sessionId: {
extractFrom: 'result',
field: 'session_id',
},
nonInteractive: {
promptFlag: '-p',
outputFlag: '--output-format json',
},
},
cursor: {
name: 'cursor',
command: 'cursor-agent',
args: ['-f'],
processNames: ['cursor-agent'],
resumeStyle: 'none',
promptMode: 'flag',
structuredOutput: {
flag: '--output-format',
schemaMode: 'none',
outputFormat: 'json',
},
nonInteractive: {
promptFlag: '-p',
outputFlag: '--output-format json',
},
},
auggie: {
name: 'auggie',
command: 'aug',
args: ['--allow-indexing'],
processNames: ['aug'],
resumeStyle: 'none',
promptMode: 'flag',
nonInteractive: {
promptFlag: '-p',
},
},
amp: {
name: 'amp',
command: 'amp',
args: ['--allow-all'],
processNames: ['amp'],
resumeFlag: '--thread',
resumeStyle: 'flag',
promptMode: 'flag',
sessionId: {
extractFrom: 'result',
field: 'thread_id',
},
nonInteractive: {
promptFlag: '-p',
outputFlag: '--json',
},
},
opencode: {
name: 'opencode',
command: 'opencode',
args: [],
env: { OPENCODE_PERMISSION: '{"*":"allow"}' },
processNames: ['opencode', 'node', 'bun'],
resumeStyle: 'none',
promptMode: 'flag',
structuredOutput: {
flag: '--format',
schemaMode: 'none',
outputFormat: 'json',
},
nonInteractive: {
subcommand: 'run',
promptFlag: '-p',
outputFlag: '--format json',
},
},
};

View File

@@ -0,0 +1,50 @@
/**
* Agent Provider Registry
*
* In-memory registry of agent provider configurations.
* Pre-populated with built-in presets, extensible via registerProvider()
* or loadProvidersFromFile() for custom/override configs.
*/
import { readFileSync } from 'node:fs';
import type { AgentProviderConfig } from './types.js';
import { PROVIDER_PRESETS } from './presets.js';
const providers = new Map<string, AgentProviderConfig>(
Object.entries(PROVIDER_PRESETS),
);
/**
* Get a provider configuration by name.
* Returns null if the provider is not registered.
*/
export function getProvider(name: string): AgentProviderConfig | null {
return providers.get(name) ?? null;
}
/**
* List all registered provider names.
*/
export function listProviders(): string[] {
return Array.from(providers.keys());
}
/**
* Register or override a provider configuration.
*/
export function registerProvider(config: AgentProviderConfig): void {
providers.set(config.name, config);
}
/**
* Load provider configurations from a JSON file and merge into the registry.
* File should contain a JSON object mapping provider names to AgentProviderConfig objects.
* Existing providers with matching names will be overridden.
*/
export function loadProvidersFromFile(path: string): void {
const raw = readFileSync(path, 'utf-8');
const parsed = JSON.parse(raw) as Record<string, AgentProviderConfig>;
for (const [name, config] of Object.entries(parsed)) {
providers.set(name, { ...config, name });
}
}

View File

@@ -0,0 +1,77 @@
/**
* Stream Event Types and Parser Interface
*
* Standardized events emitted by all provider stream parsers.
* Each provider's NDJSON output is normalized to these common events.
*/
/** Initialization event - emitted at stream start, may contain session ID */
export interface StreamInitEvent {
type: 'init';
sessionId?: string;
}
/** Text delta - chunk of assistant text output */
export interface StreamTextDeltaEvent {
type: 'text_delta';
text: string;
}
/** Tool use started - agent is calling a tool */
export interface StreamToolUseStartEvent {
type: 'tool_use_start';
name: string;
id: string;
}
/** Tool result received */
export interface StreamToolResultEvent {
type: 'tool_result';
id: string;
}
/** Turn ended - assistant stopped responding */
export interface StreamTurnEndEvent {
type: 'turn_end';
stopReason: string;
}
/** Final result - emitted at stream end with complete output */
export interface StreamResultEvent {
type: 'result';
text: string;
sessionId?: string;
costUsd?: number;
}
/** Error event */
export interface StreamErrorEvent {
type: 'error';
message: string;
}
/** Union of all stream event types */
export type StreamEvent =
| StreamInitEvent
| StreamTextDeltaEvent
| StreamToolUseStartEvent
| StreamToolResultEvent
| StreamTurnEndEvent
| StreamResultEvent
| StreamErrorEvent;
/**
* Stream Parser Interface
*
* Implementations parse provider-specific NDJSON into standardized events.
*/
export interface StreamParser {
/** Provider name this parser handles */
readonly provider: string;
/** Parse a single NDJSON line into zero or more standardized events */
parseLine(line: string): StreamEvent[];
/** Signal end of stream - allows parser to emit final events */
end(): StreamEvent[];
}

View File

@@ -0,0 +1,61 @@
/**
* Agent Provider Configuration Types
*
* Data-driven configuration for multi-provider agent spawning.
* Each provider (Claude, Codex, Gemini, etc.) has a config that describes
* how to invoke its CLI, pass prompts, extract session IDs, and resume.
*/
export interface StructuredOutputConfig {
/** CLI flag for structured output (e.g. "--json-schema", "--output-schema") */
flag: string;
/** How to pass the schema: inline JSON string, file path, or not supported */
schemaMode: 'inline' | 'file' | 'none';
/** Format of CLI output: single JSON object, JSONL stream, or raw text */
outputFormat: 'json' | 'jsonl' | 'text';
}
export interface SessionIdConfig {
/** Where to find the session ID in CLI output */
extractFrom: 'result' | 'event';
/** Field name containing the session ID */
field: string;
/** For JSONL: which event type contains the session ID */
eventType?: string;
}
export interface NonInteractiveConfig {
/** Subcommand for non-interactive mode (e.g. "exec" for codex, "run" for opencode) */
subcommand?: string;
/** Flag to pass the prompt (e.g. "-p" for gemini/cursor) */
promptFlag?: string;
/** Flag(s) for JSON output (e.g. "--json", "--output-format json") */
outputFlag?: string;
}
export interface AgentProviderConfig {
/** Provider name identifier */
name: string;
/** CLI binary command */
command: string;
/** Default autonomous-mode args */
args: string[];
/** Extra environment variables to set */
env?: Record<string, string>;
/** Process names for detection (ps matching) */
processNames: string[];
/** Env var name for config dir isolation (e.g. "CLAUDE_CONFIG_DIR") */
configDirEnv?: string;
/** Flag or subcommand for resume (e.g. "--resume", "resume") */
resumeFlag?: string;
/** How resume works: flag-based, subcommand-based, or unsupported */
resumeStyle: 'flag' | 'subcommand' | 'none';
/** How prompts are passed: native (-p built-in), flag (use nonInteractive.promptFlag), or none */
promptMode: 'native' | 'flag' | 'none';
/** Structured output configuration */
structuredOutput?: StructuredOutputConfig;
/** Session ID extraction configuration */
sessionId?: SessionIdConfig;
/** Non-interactive mode configuration */
nonInteractive?: NonInteractiveConfig;
}

View File

@@ -1,14 +1,8 @@
/**
* Agent Output Schema
* Agent Signal Schema
*
* Defines structured output schema for Claude agents using discriminated unions.
* Replaces broken AskUserQuestion detection with explicit agent status signaling.
*
* Mode-specific schemas:
* - execute: Standard task execution (done/questions/error)
* - discuss: Gather context through questions, output decisions
* - breakdown: Decompose initiative into phases
* - decompose: Decompose phase into individual tasks
* Agents communicate via a trivial JSON signal: done, questions, or error.
* All structured output is file-based (see file-io.ts).
*/
import { z } from 'zod';
@@ -17,17 +11,11 @@ import { z } from 'zod';
// SHARED SCHEMAS
// =============================================================================
/**
* Option for questions - allows agent to present choices to user
*/
const optionSchema = z.object({
label: z.string(),
description: z.string().optional(),
});
/**
* Individual question item with unique ID for answer matching
*/
export const questionItemSchema = z.object({
id: z.string(),
question: z.string(),
@@ -37,105 +25,26 @@ export const questionItemSchema = z.object({
export type QuestionItem = z.infer<typeof questionItemSchema>;
/**
* A decision captured during discussion.
* Prompt instructs: { "topic": "Auth", "decision": "JWT", "reason": "Stateless" }
*/
const decisionSchema = z.object({
topic: z.string(),
decision: z.string(),
reason: z.string(),
});
export type Decision = z.infer<typeof decisionSchema>;
/**
* A phase from breakdown output.
* Prompt instructs: { "number": 1, "name": "...", "description": "...", "dependencies": [0] }
*/
const phaseBreakdownSchema = z.object({
number: z.number().int().positive(),
name: z.string().min(1),
description: z.string(),
dependencies: z.array(z.number().int()).optional().default([]),
});
export type PhaseBreakdown = z.infer<typeof phaseBreakdownSchema>;
/**
* Task type enum - mirrors database task.type column.
*/
const taskTypeSchema = z.enum([
'auto',
'checkpoint:human-verify',
'checkpoint:decision',
'checkpoint:human-action',
]);
/**
* A task from decompose output.
* Prompt instructs: { "number": 1, "name": "...", "description": "...", "type": "auto", "dependencies": [0] }
*/
const taskBreakdownSchema = z.object({
number: z.number().int().positive(),
name: z.string().min(1),
description: z.string(),
type: taskTypeSchema.default('auto'),
dependencies: z.array(z.number().int()).optional().default([]),
});
export type TaskBreakdown = z.infer<typeof taskBreakdownSchema>;
// =============================================================================
// EXECUTE MODE SCHEMA (default)
// UNIVERSAL SIGNAL SCHEMA
// =============================================================================
/**
* Discriminated union for agent output in execute mode.
*
* Agent must return one of:
* - done: Task completed successfully
* - questions: Agent needs user input to continue (supports multiple questions)
* - unrecoverable_error: Agent hit an error it cannot recover from
*/
export const agentOutputSchema = z.discriminatedUnion('status', [
// Agent completed successfully
z.object({
status: z.literal('done'),
result: z.string(),
filesModified: z.array(z.string()).optional(),
}),
// Agent needs user input to continue (one or more questions)
z.object({
status: z.literal('questions'),
questions: z.array(questionItemSchema),
}),
// Agent hit unrecoverable error
z.object({
status: z.literal('unrecoverable_error'),
error: z.string(),
attempted: z.string().optional(),
}),
export const agentSignalSchema = z.discriminatedUnion('status', [
z.object({ status: z.literal('done') }),
z.object({ status: z.literal('questions'), questions: z.array(questionItemSchema) }),
z.object({ status: z.literal('error'), error: z.string() }),
]);
export type AgentOutput = z.infer<typeof agentOutputSchema>;
export type AgentSignal = z.infer<typeof agentSignalSchema>;
/**
* JSON Schema for --json-schema flag (convert Zod to JSON Schema).
* This is passed to Claude CLI to enforce structured output.
*/
export const agentOutputJsonSchema = {
export const agentSignalJsonSchema = {
type: 'object',
oneOf: [
{
properties: {
status: { const: 'done' },
result: { type: 'string' },
filesModified: { type: 'array', items: { type: 'string' } },
},
required: ['status', 'result'],
required: ['status'],
},
{
properties: {
@@ -168,105 +77,7 @@ export const agentOutputJsonSchema = {
},
{
properties: {
status: { const: 'unrecoverable_error' },
error: { type: 'string' },
attempted: { type: 'string' },
},
required: ['status', 'error'],
},
],
};
// =============================================================================
// DISCUSS MODE SCHEMA
// =============================================================================
/**
* Discuss mode output schema.
* Agent asks questions OR completes with decisions.
*
* Prompt tells agent:
* - Output "questions" status with questions array when needing input
* - Output "context_complete" status with decisions array when done
*/
export const discussOutputSchema = z.discriminatedUnion('status', [
// Agent needs more information
z.object({
status: z.literal('questions'),
questions: z.array(questionItemSchema),
}),
// Agent has captured all decisions
z.object({
status: z.literal('context_complete'),
decisions: z.array(decisionSchema),
summary: z.string(), // Brief summary of all decisions
}),
// Unrecoverable error
z.object({
status: z.literal('unrecoverable_error'),
error: z.string(),
}),
]);
export type DiscussOutput = z.infer<typeof discussOutputSchema>;
/**
* JSON Schema for discuss mode (passed to Claude CLI --json-schema)
*/
export const discussOutputJsonSchema = {
type: 'object',
oneOf: [
{
properties: {
status: { const: 'questions' },
questions: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
question: { type: 'string' },
options: {
type: 'array',
items: {
type: 'object',
properties: {
label: { type: 'string' },
description: { type: 'string' },
},
required: ['label'],
},
},
multiSelect: { type: 'boolean' },
},
required: ['id', 'question'],
},
},
},
required: ['status', 'questions'],
},
{
properties: {
status: { const: 'context_complete' },
decisions: {
type: 'array',
items: {
type: 'object',
properties: {
topic: { type: 'string' },
decision: { type: 'string' },
reason: { type: 'string' },
},
required: ['topic', 'decision', 'reason'],
},
},
summary: { type: 'string' },
},
required: ['status', 'decisions', 'summary'],
},
{
properties: {
status: { const: 'unrecoverable_error' },
status: { const: 'error' },
error: { type: 'string' },
},
required: ['status', 'error'],
@@ -275,201 +86,12 @@ export const discussOutputJsonSchema = {
};
// =============================================================================
// BREAKDOWN MODE SCHEMA
// BACKWARD COMPATIBILITY
// =============================================================================
/**
* Breakdown mode output schema.
* Agent asks questions OR completes with phases.
*
* Prompt tells agent:
* - Output "questions" status when needing clarification
* - Output "breakdown_complete" status with phases array when done
*/
export const breakdownOutputSchema = z.discriminatedUnion('status', [
// Agent needs clarification
z.object({
status: z.literal('questions'),
questions: z.array(questionItemSchema),
}),
// Agent has decomposed initiative into phases
z.object({
status: z.literal('breakdown_complete'),
phases: z.array(phaseBreakdownSchema),
}),
// Unrecoverable error
z.object({
status: z.literal('unrecoverable_error'),
error: z.string(),
}),
]);
export type BreakdownOutput = z.infer<typeof breakdownOutputSchema>;
/**
* JSON Schema for breakdown mode (passed to Claude CLI --json-schema)
*/
export const breakdownOutputJsonSchema = {
type: 'object',
oneOf: [
{
properties: {
status: { const: 'questions' },
questions: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
question: { type: 'string' },
options: {
type: 'array',
items: {
type: 'object',
properties: {
label: { type: 'string' },
description: { type: 'string' },
},
required: ['label'],
},
},
},
required: ['id', 'question'],
},
},
},
required: ['status', 'questions'],
},
{
properties: {
status: { const: 'breakdown_complete' },
phases: {
type: 'array',
items: {
type: 'object',
properties: {
number: { type: 'integer', minimum: 1 },
name: { type: 'string', minLength: 1 },
description: { type: 'string' },
dependencies: {
type: 'array',
items: { type: 'integer' },
},
},
required: ['number', 'name', 'description'],
},
},
},
required: ['status', 'phases'],
},
{
properties: {
status: { const: 'unrecoverable_error' },
error: { type: 'string' },
},
required: ['status', 'error'],
},
],
};
// =============================================================================
// DECOMPOSE MODE SCHEMA
// =============================================================================
/**
* Decompose mode output schema.
* Agent asks questions OR completes with tasks.
*
* Prompt tells agent:
* - Output "questions" status when needing clarification
* - Output "decompose_complete" status with tasks array when done
*/
export const decomposeOutputSchema = z.discriminatedUnion('status', [
// Agent needs clarification
z.object({
status: z.literal('questions'),
questions: z.array(questionItemSchema),
}),
// Agent has decomposed phase into tasks
z.object({
status: z.literal('decompose_complete'),
tasks: z.array(taskBreakdownSchema),
}),
// Unrecoverable error
z.object({
status: z.literal('unrecoverable_error'),
error: z.string(),
}),
]);
export type DecomposeOutput = z.infer<typeof decomposeOutputSchema>;
/**
* JSON Schema for decompose mode (passed to Claude CLI --json-schema)
*/
export const decomposeOutputJsonSchema = {
type: 'object',
oneOf: [
{
properties: {
status: { const: 'questions' },
questions: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
question: { type: 'string' },
options: {
type: 'array',
items: {
type: 'object',
properties: {
label: { type: 'string' },
description: { type: 'string' },
},
required: ['label'],
},
},
},
required: ['id', 'question'],
},
},
},
required: ['status', 'questions'],
},
{
properties: {
status: { const: 'decompose_complete' },
tasks: {
type: 'array',
items: {
type: 'object',
properties: {
number: { type: 'integer', minimum: 1 },
name: { type: 'string', minLength: 1 },
description: { type: 'string' },
type: {
type: 'string',
enum: ['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action'],
},
dependencies: {
type: 'array',
items: { type: 'integer' },
},
},
required: ['number', 'name', 'description'],
},
},
},
required: ['status', 'tasks'],
},
{
properties: {
status: { const: 'unrecoverable_error' },
error: { type: 'string' },
},
required: ['status', 'error'],
},
],
};
/** @deprecated Use agentSignalSchema */
export const agentOutputSchema = agentSignalSchema;
/** @deprecated Use AgentSignal */
export type AgentOutput = AgentSignal;
/** @deprecated Use agentSignalJsonSchema */
export const agentOutputJsonSchema = agentSignalJsonSchema;

View File

@@ -15,22 +15,38 @@ export type AgentStatus = 'idle' | 'running' | 'waiting_for_input' | 'stopped' |
* - breakdown: Decompose initiative into phases
* - decompose: Decompose phase into individual tasks
*/
export type AgentMode = 'execute' | 'discuss' | 'breakdown' | 'decompose';
export type AgentMode = 'execute' | 'discuss' | 'breakdown' | 'decompose' | 'refine';
/**
* Context data written as input files in agent workdir before spawn.
*/
export interface AgentInputContext {
initiative?: import('../db/schema.js').Initiative;
pages?: import('./content-serializer.js').PageForSerialization[];
phase?: import('../db/schema.js').Phase;
task?: import('../db/schema.js').Task;
}
/**
* Options for spawning a new agent
*/
export interface SpawnAgentOptions {
/** Human-readable name for the agent (e.g., 'gastown', 'chinatown') */
name: string;
/** Task ID to assign to agent */
taskId: string;
/** Human-readable name/alias for the agent (auto-generated if omitted) */
name?: string;
/** Task ID to assign to agent (optional for architect modes) */
taskId?: string | null;
/** Initial prompt/instruction for the agent */
prompt: string;
/** Optional working directory (defaults to worktree path) */
cwd?: string;
/** Agent operation mode (defaults to 'execute') */
mode?: AgentMode;
/** Provider name (defaults to 'claude') */
provider?: string;
/** Initiative ID — when set, worktrees are created for all linked projects */
initiativeId?: string;
/** Context data to write as input files in agent workdir */
inputContext?: AgentInputContext;
}
/**
@@ -39,18 +55,24 @@ export interface SpawnAgentOptions {
export interface AgentInfo {
/** Unique identifier for this agent */
id: string;
/** Human-readable name for the agent */
/** Human-readable alias for the agent (e.g. 'jolly-penguin') */
name: string;
/** Task this agent is working on */
taskId: string;
/** Claude CLI session ID for resumption (null until first run completes) */
/** Task this agent is working on (null for architect agents) */
taskId: string | null;
/** Initiative this agent is linked to (null if standalone) */
initiativeId: string | null;
/** CLI session ID for resumption (null until first run completes) */
sessionId: string | null;
/** WorktreeManager worktree ID */
/** Agent alias / worktree key (deterministic path: agent-workdirs/<alias>/) */
worktreeId: string;
/** Current status (waiting_for_input = paused on AskUserQuestion) */
status: AgentStatus;
/** Current operation mode */
mode: AgentMode;
/** Provider name (e.g. 'claude', 'codex', 'gemini') */
provider: string;
/** Account ID used for this agent (null if no account management) */
accountId: string | null;
/** When the agent was created */
createdAt: Date;
/** Last activity timestamp */
@@ -179,4 +201,35 @@ export interface AgentManager {
* @returns Pending questions if available, null otherwise
*/
getPendingQuestions(agentId: string): Promise<PendingQuestions | null>;
/**
* Get the buffered output for an agent.
*
* Returns recent output chunks from the agent's stdout stream.
* Buffer is limited to MAX_OUTPUT_BUFFER_SIZE chunks (ring buffer).
*
* @param agentId - Agent ID
* @returns Array of output chunks (newest last)
*/
getOutputBuffer(agentId: string): string[];
/**
* Delete an agent and clean up all associated resources.
*
* Tears down worktrees, branches, logs, and removes the DB record.
* If the agent is still running, kills the process first.
*
* @param agentId - Agent to delete
*/
delete(agentId: string): Promise<void>;
/**
* Dismiss an agent.
*
* Marks the agent as dismissed by the user, which excludes it from
* active agent queries. The agent record and worktree are preserved.
*
* @param agentId - Agent to dismiss
*/
dismiss(agentId: string): Promise<void>;
}

View File

@@ -8,20 +8,24 @@
*/
import { runCli } from '../cli/index.js';
import { logger } from '../logger/index.js';
// Handle uncaught errors gracefully
process.on('uncaughtException', (error) => {
logger.fatal({ err: error }, 'uncaught exception');
console.error('Fatal error:', error.message);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
logger.fatal({ err: reason }, 'unhandled rejection');
console.error('Unhandled promise rejection:', reason);
process.exit(1);
});
// Run the CLI
runCli().catch((error) => {
logger.fatal({ err: error }, 'CLI fatal error');
console.error('CLI error:', error.message);
process.exit(1);
});

View File

@@ -9,22 +9,10 @@ import { Command } from 'commander';
import { VERSION } from '../index.js';
import { CoordinationServer } from '../server/index.js';
import { GracefulShutdown } from '../server/shutdown.js';
import { ProcessManager, ProcessRegistry } from '../process/index.js';
import { LogManager } from '../logging/index.js';
import { createEventBus } from '../events/index.js';
import { createDefaultTrpcClient } from './trpc-client.js';
import {
createDatabase,
ensureSchema,
DrizzleInitiativeRepository,
DrizzlePhaseRepository,
DrizzlePlanRepository,
DrizzleTaskRepository,
DrizzleMessageRepository,
DrizzleAgentRepository,
} from '../db/index.js';
import { ClaudeAgentManager } from '../agent/index.js';
import { SimpleGitWorktreeManager } from '../git/index.js';
import { createContainer } from '../container.js';
import { findWorkspaceRoot, writeCwrc, defaultCwConfig } from '../config/index.js';
import { createModuleLogger } from '../logger/index.js';
/** Environment variable for custom port */
const CW_PORT_ENV = 'CW_PORT';
@@ -37,54 +25,30 @@ async function startServer(port?: number): Promise<void> {
// Get port from option, env var, or default
const serverPort = port ??
(process.env[CW_PORT_ENV] ? parseInt(process.env[CW_PORT_ENV], 10) : undefined);
const log = createModuleLogger('server');
// Create dependencies
const registry = new ProcessRegistry();
const eventBus = createEventBus();
const processManager = new ProcessManager(registry, eventBus);
const logManager = new LogManager();
// Create database and ensure schema
const db = createDatabase();
ensureSchema(db);
// Create repositories
const initiativeRepository = new DrizzleInitiativeRepository(db);
const phaseRepository = new DrizzlePhaseRepository(db);
const planRepository = new DrizzlePlanRepository(db);
const taskRepository = new DrizzleTaskRepository(db);
const messageRepository = new DrizzleMessageRepository(db);
const agentRepository = new DrizzleAgentRepository(db);
// Create agent manager with worktree isolation
const worktreeManager = new SimpleGitWorktreeManager(process.cwd(), eventBus);
const agentManager = new ClaudeAgentManager(agentRepository, worktreeManager, eventBus);
// Create full dependency graph
const container = await createContainer();
// Create and start server
const server = new CoordinationServer(
{ port: serverPort },
processManager,
logManager,
eventBus,
{
agentManager,
initiativeRepository,
phaseRepository,
planRepository,
taskRepository,
messageRepository,
}
container.processManager,
container.logManager,
container.eventBus,
container.toContextDeps(),
);
try {
await server.start();
} catch (error) {
log.fatal({ err: error }, 'failed to start server');
console.error('Failed to start server:', (error as Error).message);
process.exit(1);
}
// Install graceful shutdown handlers
const shutdown = new GracefulShutdown(server, processManager, logManager);
const shutdown = new GracefulShutdown(server, container.processManager, container.logManager);
shutdown.install();
}
@@ -136,6 +100,39 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
}
});
// Init command - create .cwrc in current directory
program
.command('init')
.description('Initialize a cw workspace in the current directory')
.action(() => {
const cwd = process.cwd();
const existing = findWorkspaceRoot(cwd);
if (existing && existing === cwd) {
console.log(`Workspace already initialized at ${cwd}`);
return;
}
if (existing) {
console.log(`Parent workspace found at ${existing}`);
console.log(`Creating nested workspace at ${cwd}`);
}
writeCwrc(cwd, defaultCwConfig());
console.log(`Initialized cw workspace at ${cwd}`);
});
// ID generation command (stateless — no server, no tRPC)
program
.command('id')
.description('Generate a unique nanoid (works offline, no server needed)')
.option('-n, --count <count>', 'Number of IDs to generate', '1')
.action(async (options: { count: string }) => {
const { nanoid } = await import('nanoid');
const count = parseInt(options.count, 10) || 1;
for (let i = 0; i < count; i++) {
console.log(nanoid());
}
});
// Agent command group
const agentCommand = program
.command('agent')
@@ -145,10 +142,12 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
agentCommand
.command('spawn <prompt>')
.description('Spawn a new agent to work on a task')
.requiredOption('--name <name>', 'Human-readable name for the agent (e.g., gastown)')
.option('--name <name>', 'Human-readable name for the agent (auto-generated if omitted)')
.requiredOption('--task <taskId>', 'Task ID to assign to agent')
.option('--cwd <path>', 'Working directory for agent')
.action(async (prompt: string, options: { name: string; task: string; cwd?: string }) => {
.option('--provider <provider>', 'Agent provider (claude, codex, gemini, cursor, auggie, amp, opencode)')
.option('--initiative <id>', 'Initiative ID (creates worktrees for all linked projects)')
.action(async (prompt: string, options: { name?: string; task: string; cwd?: string; provider?: string; initiative?: string }) => {
try {
const client = createDefaultTrpcClient();
const agent = await client.spawnAgent.mutate({
@@ -156,6 +155,8 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
taskId: options.task,
prompt,
cwd: options.cwd,
provider: options.provider,
initiativeId: options.initiative,
});
console.log(`Agent '${agent.name}' spawned`);
console.log(` ID: ${agent.id}`);
@@ -183,6 +184,21 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
}
});
// cw agent delete <name>
agentCommand
.command('delete <name>')
.description('Delete an agent and clean up its workdir, branches, and logs')
.action(async (name: string) => {
try {
const client = createDefaultTrpcClient();
const result = await client.deleteAgent.mutate({ name });
console.log(`Agent '${result.name}' deleted`);
} catch (error) {
console.error('Failed to delete agent:', (error as Error).message);
process.exit(1);
}
});
// cw agent list
agentCommand
.command('list')
@@ -290,17 +306,31 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
.command('task')
.description('Manage tasks');
// cw task list --plan <planId>
// cw task list --parent <parentTaskId> | --phase <phaseId> | --initiative <initiativeId>
taskCommand
.command('list')
.description('List tasks for a plan')
.requiredOption('--plan <planId>', 'Plan ID to list tasks for')
.action(async (options: { plan: string }) => {
.description('List tasks (by parent task, phase, or initiative)')
.option('--parent <parentTaskId>', 'Parent task ID (for child tasks)')
.option('--phase <phaseId>', 'Phase ID')
.option('--initiative <initiativeId>', 'Initiative ID')
.action(async (options: { parent?: string; phase?: string; initiative?: string }) => {
try {
const client = createDefaultTrpcClient();
const tasks = await client.listTasks.query({ planId: options.plan });
let tasks;
if (options.parent) {
tasks = await client.listTasks.query({ parentTaskId: options.parent });
} else if (options.phase) {
tasks = await client.listPhaseTasks.query({ phaseId: options.phase });
} else if (options.initiative) {
tasks = await client.listInitiativeTasks.query({ initiativeId: options.initiative });
} else {
console.error('One of --parent, --phase, or --initiative is required');
process.exit(1);
}
if (tasks.length === 0) {
console.log('No tasks found for this plan');
console.log('No tasks found');
return;
}
@@ -335,8 +365,11 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
const task = await client.getTask.query({ id: taskId });
console.log(`Task: ${task.name}`);
console.log(` ID: ${task.id}`);
console.log(` Plan: ${task.planId}`);
console.log(` Phase: ${task.phaseId ?? '(none)'}`);
console.log(` Initiative: ${task.initiativeId ?? '(none)'}`);
console.log(` Parent Task: ${task.parentTaskId ?? '(none)'}`);
console.log(` Description: ${task.description ?? '(none)'}`);
console.log(` Category: ${task.category}`);
console.log(` Type: ${task.type}`);
console.log(` Priority: ${task.priority}`);
console.log(` Status: ${task.status}`);
@@ -690,18 +723,18 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
initiativeCommand
.command('create <name>')
.description('Create a new initiative')
.option('-d, --description <description>', 'Initiative description')
.action(async (name: string, options: { description?: string }) => {
.option('--project <ids...>', 'Project IDs to associate')
.action(async (name: string, options: { project?: string[] }) => {
try {
const client = createDefaultTrpcClient();
const initiative = await client.createInitiative.mutate({
name,
description: options.description,
projectIds: options.project,
});
console.log(`Created initiative: ${initiative.id}`);
console.log(` Name: ${initiative.name}`);
if (initiative.description) {
console.log(` Description: ${initiative.description}`);
if (options.project && options.project.length > 0) {
console.log(` Projects: ${options.project.length} associated`);
}
} catch (error) {
console.error('Failed to create initiative:', (error as Error).message);
@@ -752,9 +785,6 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
console.log(`ID: ${initiative.id}`);
console.log(`Name: ${initiative.name}`);
console.log(`Status: ${initiative.status}`);
if (initiative.description) {
console.log(`Description: ${initiative.description}`);
}
console.log(`Created: ${new Date(initiative.createdAt).toISOString()}`);
} catch (error) {
console.error('Failed to get initiative:', (error as Error).message);
@@ -795,9 +825,9 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
architectCommand
.command('discuss <initiativeId>')
.description('Start discussion phase for an initiative')
.requiredOption('--name <name>', 'Agent name')
.option('--name <name>', 'Agent name (auto-generated if omitted)')
.option('-c, --context <context>', 'Initial context')
.action(async (initiativeId: string, options: { name: string; context?: string }) => {
.action(async (initiativeId: string, options: { name?: string; context?: string }) => {
try {
const client = createDefaultTrpcClient();
const agent = await client.spawnArchitectDiscuss.mutate({
@@ -819,9 +849,9 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
architectCommand
.command('breakdown <initiativeId>')
.description('Start breakdown phase for an initiative')
.requiredOption('--name <name>', 'Agent name')
.option('--name <name>', 'Agent name (auto-generated if omitted)')
.option('-s, --summary <summary>', 'Context summary from discuss phase')
.action(async (initiativeId: string, options: { name: string; summary?: string }) => {
.action(async (initiativeId: string, options: { name?: string; summary?: string }) => {
try {
const client = createDefaultTrpcClient();
const agent = await client.spawnArchitectBreakdown.mutate({
@@ -839,24 +869,26 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
}
});
// cw architect decompose <plan-id>
// cw architect decompose <phase-id>
architectCommand
.command('decompose <planId>')
.description('Decompose a plan into tasks')
.requiredOption('--name <name>', 'Agent name')
.command('decompose <phaseId>')
.description('Decompose a phase into tasks')
.option('--name <name>', 'Agent name (auto-generated if omitted)')
.option('-t, --task-name <taskName>', 'Name for the decompose task')
.option('-c, --context <context>', 'Additional context')
.action(async (planId: string, options: { name: string; context?: string }) => {
.action(async (phaseId: string, options: { name?: string; taskName?: string; context?: string }) => {
try {
const client = createDefaultTrpcClient();
const agent = await client.spawnArchitectDecompose.mutate({
name: options.name,
planId,
phaseId,
taskName: options.taskName,
context: options.context,
});
console.log(`Started architect agent in decompose mode`);
console.log(` Agent: ${agent.name} (${agent.id})`);
console.log(` Mode: ${agent.mode}`);
console.log(` Plan: ${planId}`);
console.log(` Phase: ${phaseId}`);
} catch (error) {
console.error('Failed to start decompose:', (error as Error).message);
process.exit(1);
@@ -983,94 +1015,187 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
}
});
// Plan command group
const planCommand = program
.command('plan')
.description('Plan management');
// Project command group
const projectCommand = program
.command('project')
.description('Manage registered projects');
// cw plan list --phase <phaseId>
planCommand
.command('list')
.description('List plans in a phase')
.requiredOption('--phase <id>', 'Phase ID')
.action(async (options: { phase: string }) => {
// cw project register --name <name> --url <url>
projectCommand
.command('register')
.description('Register a git repository as a project')
.requiredOption('--name <name>', 'Project name')
.requiredOption('--url <url>', 'Git repository URL')
.action(async (options: { name: string; url: string }) => {
try {
const client = createDefaultTrpcClient();
const plans = await client.listPlans.query({ phaseId: options.phase });
if (plans.length === 0) {
console.log('No plans found');
return;
}
console.table(plans.map(p => ({
id: p.id,
number: p.number,
name: p.name,
status: p.status,
})));
} catch (error) {
console.error('Failed to list plans:', (error as Error).message);
process.exit(1);
}
});
// cw plan create --phase <phaseId> --name <name>
planCommand
.command('create')
.description('Create a plan in a phase')
.requiredOption('--phase <id>', 'Phase ID')
.requiredOption('--name <name>', 'Plan name')
.option('--description <desc>', 'Plan description')
.action(async (options: { phase: string; name: string; description?: string }) => {
try {
const client = createDefaultTrpcClient();
const plan = await client.createPlan.mutate({
phaseId: options.phase,
const project = await client.registerProject.mutate({
name: options.name,
description: options.description,
url: options.url,
});
console.log(`Created plan: ${plan.id} (${plan.name})`);
console.log(`Registered project: ${project.id}`);
console.log(` Name: ${project.name}`);
console.log(` URL: ${project.url}`);
} catch (error) {
console.error('Failed to create plan:', (error as Error).message);
console.error('Failed to register project:', (error as Error).message);
process.exit(1);
}
});
// cw plan get <id>
planCommand
.command('get <id>')
.description('Get plan details')
.action(async (id: string) => {
// cw project list
projectCommand
.command('list')
.description('List all registered projects')
.action(async () => {
try {
const client = createDefaultTrpcClient();
const plan = await client.getPlan.query({ id });
console.log(JSON.stringify(plan, null, 2));
} catch (error) {
console.error('Failed to get plan:', (error as Error).message);
process.exit(1);
}
});
// cw plan tasks <id>
planCommand
.command('tasks <id>')
.description('List tasks in a plan')
.action(async (id: string) => {
try {
const client = createDefaultTrpcClient();
const tasks = await client.listTasks.query({ planId: id });
if (tasks.length === 0) {
console.log('No tasks found');
const projects = await client.listProjects.query();
if (projects.length === 0) {
console.log('No projects registered');
return;
}
console.table(tasks.map(t => ({
id: t.id,
order: t.order,
name: t.name,
type: t.type,
status: t.status,
})));
for (const p of projects) {
console.log(`${p.id} ${p.name} ${p.url}`);
}
} catch (error) {
console.error('Failed to list tasks:', (error as Error).message);
console.error('Failed to list projects:', (error as Error).message);
process.exit(1);
}
});
// cw project delete <id>
projectCommand
.command('delete <id>')
.description('Delete a registered project')
.action(async (id: string) => {
try {
const client = createDefaultTrpcClient();
await client.deleteProject.mutate({ id });
console.log(`Deleted project: ${id}`);
} catch (error) {
console.error('Failed to delete project:', (error as Error).message);
process.exit(1);
}
});
// Account command group
const accountCommand = program
.command('account')
.description('Manage provider accounts for agent spawning');
// cw account add
accountCommand
.command('add')
.description('Extract current Claude login and register as an account')
.option('--provider <provider>', 'Provider name', 'claude')
.option('--email <email>', 'Email (for manual registration without auto-extract)')
.action(async (options: { provider: string; email?: string }) => {
try {
const client = createDefaultTrpcClient();
if (options.email) {
// Manual registration — guard against duplicates
const existing = await client.listAccounts.query();
const alreadyRegistered = existing.find((a: any) => a.email === options.email);
if (alreadyRegistered) {
console.log(`Account '${options.email}' already registered (${alreadyRegistered.id})`);
return;
}
const account = await client.addAccount.mutate({
email: options.email,
provider: options.provider,
});
console.log(`Registered account: ${account.id}`);
console.log(` Email: ${account.email}`);
console.log(` Provider: ${account.provider}`);
} else {
// Auto-extract from current Claude login
const { extractCurrentClaudeAccount } = await import('../agent/accounts/index.js');
const extracted = await extractCurrentClaudeAccount();
// Check if already registered
const existing = await client.listAccounts.query();
const alreadyRegistered = existing.find((a: any) => a.email === extracted.email);
if (alreadyRegistered) {
// Upsert: update credentials on existing account
await client.updateAccountAuth.mutate({
id: alreadyRegistered.id,
configJson: JSON.stringify(extracted.configJson),
credentials: extracted.credentials,
});
console.log(`Updated credentials for account: ${alreadyRegistered.id}`);
console.log(` Email: ${extracted.email}`);
return;
}
// Create DB record with credentials stored in DB
const account = await client.addAccount.mutate({
email: extracted.email,
provider: options.provider,
configJson: JSON.stringify(extracted.configJson),
credentials: extracted.credentials,
});
console.log(`Registered account: ${account.id}`);
console.log(` Email: ${account.email}`);
console.log(` Provider: ${account.provider}`);
}
} catch (error) {
console.error('Failed to add account:', (error as Error).message);
process.exit(1);
}
});
// cw account list
accountCommand
.command('list')
.description('List all registered accounts')
.action(async () => {
try {
const client = createDefaultTrpcClient();
const accounts = await client.listAccounts.query();
if (accounts.length === 0) {
console.log('No accounts registered');
return;
}
for (const acct of accounts) {
const status = acct.isExhausted ? 'EXHAUSTED' : 'AVAILABLE';
const until = acct.exhaustedUntil ? ` (until ${new Date(acct.exhaustedUntil).toLocaleString()})` : '';
console.log(`${acct.id} ${acct.email} ${acct.provider} [${status}${until}]`);
}
} catch (error) {
console.error('Failed to list accounts:', (error as Error).message);
process.exit(1);
}
});
// cw account remove <id>
accountCommand
.command('remove <id>')
.description('Remove an account')
.action(async (id: string) => {
try {
const client = createDefaultTrpcClient();
await client.removeAccount.mutate({ id });
console.log(`Removed account: ${id}`);
} catch (error) {
console.error('Failed to remove account:', (error as Error).message);
process.exit(1);
}
});
// cw account refresh
accountCommand
.command('refresh')
.description('Clear expired exhaustion flags')
.action(async () => {
try {
const client = createDefaultTrpcClient();
const result = await client.refreshAccounts.mutate();
console.log(`Cleared ${result.cleared} expired exhaustions`);
} catch (error) {
console.error('Failed to refresh accounts:', (error as Error).message);
process.exit(1);
}
});

66
src/config/cwrc.ts Normal file
View File

@@ -0,0 +1,66 @@
/**
* .cwrc File Operations
*
* Find, read, and write the .cwrc configuration file.
* The file's presence marks the workspace root directory.
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join, dirname, parse } from 'node:path';
import type { CwConfig } from './types.js';
/** Default filename */
const CWRC_FILENAME = '.cwrc';
/**
* Walk up from `startDir` looking for a .cwrc file.
* Returns the absolute path to the directory containing it,
* or null if the filesystem root is reached.
*/
export function findWorkspaceRoot(startDir: string = process.cwd()): string | null {
let dir = startDir;
while (true) {
const candidate = join(dir, CWRC_FILENAME);
if (existsSync(candidate)) {
return dir;
}
const parent = dirname(dir);
if (parent === dir) {
// Reached filesystem root
return null;
}
dir = parent;
}
}
/**
* Read and parse the .cwrc file in the given directory.
* Returns null if the file doesn't exist.
* Throws on malformed JSON.
*/
export function readCwrc(dir: string): CwConfig | null {
const filePath = join(dir, CWRC_FILENAME);
if (!existsSync(filePath)) {
return null;
}
const raw = readFileSync(filePath, 'utf-8');
return JSON.parse(raw) as CwConfig;
}
/**
* Write a .cwrc file to the given directory.
*/
export function writeCwrc(dir: string, config: CwConfig): void {
const filePath = join(dir, CWRC_FILENAME);
writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
}
/**
* Create a default .cwrc config.
*/
export function defaultCwConfig(): CwConfig {
return { version: 1 };
}

13
src/config/index.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* Configuration Module
*
* Handles .cwrc workspace configuration.
*/
export type { CwConfig } from './types.js';
export {
findWorkspaceRoot,
readCwrc,
writeCwrc,
defaultCwConfig,
} from './cwrc.js';

15
src/config/types.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* .cwrc Configuration Types
*
* Defines the shape of the .cwrc configuration file.
* Add new top-level keys here as the config grows.
*/
/**
* Root configuration file schema.
* Lives at the workspace root as `.cwrc`.
*/
export interface CwConfig {
/** Schema version for forward compatibility */
version: 1;
}

162
src/container.ts Normal file
View File

@@ -0,0 +1,162 @@
/**
* Dependency Container
*
* Factory functions for creating the full dependency graph.
* Keeps startServer() thin and makes repo wiring reusable by the test harness.
*/
import type { DrizzleDatabase } from './db/index.js';
import {
createDatabase,
ensureSchema,
DrizzleInitiativeRepository,
DrizzlePhaseRepository,
DrizzleTaskRepository,
DrizzleMessageRepository,
DrizzleAgentRepository,
DrizzlePageRepository,
DrizzleProjectRepository,
DrizzleAccountRepository,
} from './db/index.js';
import type { InitiativeRepository } from './db/repositories/initiative-repository.js';
import type { PhaseRepository } from './db/repositories/phase-repository.js';
import type { TaskRepository } from './db/repositories/task-repository.js';
import type { MessageRepository } from './db/repositories/message-repository.js';
import type { AgentRepository } from './db/repositories/agent-repository.js';
import type { PageRepository } from './db/repositories/page-repository.js';
import type { ProjectRepository } from './db/repositories/project-repository.js';
import type { AccountRepository } from './db/repositories/account-repository.js';
import type { EventBus } from './events/index.js';
import { createEventBus } from './events/index.js';
import { ProcessManager, ProcessRegistry } from './process/index.js';
import { LogManager } from './logging/index.js';
import { MultiProviderAgentManager } from './agent/index.js';
import { DefaultAccountCredentialManager } from './agent/credentials/index.js';
import type { AccountCredentialManager } from './agent/credentials/types.js';
import { findWorkspaceRoot } from './config/index.js';
import { createModuleLogger } from './logger/index.js';
import type { ServerContextDeps } from './server/index.js';
// =============================================================================
// Repositories
// =============================================================================
/**
* All 8 repository ports.
*/
export interface Repositories {
initiativeRepository: InitiativeRepository;
phaseRepository: PhaseRepository;
taskRepository: TaskRepository;
messageRepository: MessageRepository;
agentRepository: AgentRepository;
pageRepository: PageRepository;
projectRepository: ProjectRepository;
accountRepository: AccountRepository;
}
/**
* Create all 8 Drizzle repository adapters from a database instance.
* Reusable by both the production server and the test harness.
*/
export function createRepositories(db: DrizzleDatabase): Repositories {
return {
initiativeRepository: new DrizzleInitiativeRepository(db),
phaseRepository: new DrizzlePhaseRepository(db),
taskRepository: new DrizzleTaskRepository(db),
messageRepository: new DrizzleMessageRepository(db),
agentRepository: new DrizzleAgentRepository(db),
pageRepository: new DrizzlePageRepository(db),
projectRepository: new DrizzleProjectRepository(db),
accountRepository: new DrizzleAccountRepository(db),
};
}
// =============================================================================
// Container
// =============================================================================
/**
* Full dependency graph for the coordination server.
*/
export interface Container extends Repositories {
db: DrizzleDatabase;
eventBus: EventBus;
processManager: ProcessManager;
logManager: LogManager;
workspaceRoot: string;
credentialManager: AccountCredentialManager;
agentManager: MultiProviderAgentManager;
/** Extract the subset of deps that CoordinationServer needs. */
toContextDeps(): ServerContextDeps;
}
/**
* Create the full dependency container.
*
* Wires: ProcessRegistry → EventBus → ProcessManager → LogManager →
* Database → Repositories → CredentialManager → AgentManager.
* Runs ensureSchema() and reconcileAfterRestart() before returning.
*/
export async function createContainer(): Promise<Container> {
const log = createModuleLogger('container');
// Infrastructure
const registry = new ProcessRegistry();
const eventBus = createEventBus();
const processManager = new ProcessManager(registry, eventBus);
const logManager = new LogManager();
// Database
const db = createDatabase();
ensureSchema(db);
log.info('database initialized');
// Repositories
const repos = createRepositories(db);
log.info('repositories created');
// Workspace root
const workspaceRoot = findWorkspaceRoot(process.cwd()) ?? process.cwd();
log.info({ workspaceRoot }, 'workspace root resolved');
// Credential manager
const credentialManager = new DefaultAccountCredentialManager(eventBus);
log.info('credential manager created');
// Agent manager
const agentManager = new MultiProviderAgentManager(
repos.agentRepository,
workspaceRoot,
repos.projectRepository,
repos.accountRepository,
eventBus,
credentialManager,
);
log.info('agent manager created');
// Reconcile agent state from any previous server session
await agentManager.reconcileAfterRestart();
log.info('agent reconciliation complete');
return {
db,
eventBus,
processManager,
logManager,
workspaceRoot,
credentialManager,
agentManager,
...repos,
toContextDeps(): ServerContextDeps {
return {
agentManager,
credentialManager,
workspaceRoot,
...repos,
};
},
};
}

View File

@@ -0,0 +1,372 @@
/**
* ConflictResolutionService Tests
*
* Tests for the conflict resolution service that handles merge conflicts
* by creating resolution tasks, updating statuses, and notifying agents.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { DefaultConflictResolutionService } from './conflict-resolution-service.js';
import { DrizzleTaskRepository } from '../db/repositories/drizzle/task.js';
import { DrizzleAgentRepository } from '../db/repositories/drizzle/agent.js';
import { DrizzleMessageRepository } from '../db/repositories/drizzle/message.js';
import { DrizzlePhaseRepository } from '../db/repositories/drizzle/phase.js';
import { DrizzleInitiativeRepository } from '../db/repositories/drizzle/initiative.js';
import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js';
import type { DrizzleDatabase } from '../db/index.js';
import type { EventBus, DomainEvent } from '../events/types.js';
import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { MessageRepository } from '../db/repositories/message-repository.js';
// =============================================================================
// Test Helpers
// =============================================================================
/**
* Create a mock EventBus that captures emitted events.
*/
function createMockEventBus(): EventBus & { emittedEvents: DomainEvent[] } {
const emittedEvents: DomainEvent[] = [];
return {
emittedEvents,
emit<T extends DomainEvent>(event: T): void {
emittedEvents.push(event);
},
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
};
}
// =============================================================================
// Tests
// =============================================================================
describe('DefaultConflictResolutionService', () => {
let db: DrizzleDatabase;
let taskRepository: TaskRepository;
let agentRepository: AgentRepository;
let messageRepository: MessageRepository;
let eventBus: EventBus & { emittedEvents: DomainEvent[] };
let service: DefaultConflictResolutionService;
let testPhaseId: string;
let testInitiativeId: string;
beforeEach(async () => {
// Set up test database
db = createTestDatabase();
taskRepository = new DrizzleTaskRepository(db);
agentRepository = new DrizzleAgentRepository(db);
messageRepository = new DrizzleMessageRepository(db);
// Create required hierarchy for tasks
const initiativeRepo = new DrizzleInitiativeRepository(db);
const phaseRepo = new DrizzlePhaseRepository(db);
const initiative = await initiativeRepo.create({
name: 'Test Initiative',
});
testInitiativeId = initiative.id;
const phase = await phaseRepo.create({
initiativeId: initiative.id,
number: 1,
name: 'Test Phase',
});
testPhaseId = phase.id;
// Create mocks
eventBus = createMockEventBus();
// Create service
service = new DefaultConflictResolutionService(
taskRepository,
agentRepository,
messageRepository,
eventBus
);
});
// ===========================================================================
// handleConflict() Tests
// ===========================================================================
describe('handleConflict', () => {
it('should create conflict resolution task with correct properties', async () => {
// Create original task
const originalTask = await taskRepository.create({
phaseId: testPhaseId,
initiativeId: testInitiativeId,
name: 'Original Task',
description: 'Original task description',
priority: 'medium',
order: 1,
});
// Create agent for task
const agent = await agentRepository.create({
name: 'agent-test',
taskId: originalTask.id,
worktreeId: 'wt-test',
});
const conflicts = ['src/file1.ts', 'src/file2.ts'];
await service.handleConflict(originalTask.id, conflicts);
// Check resolution task was created
const tasks = await taskRepository.findByPhaseId(testPhaseId);
const resolutionTask = tasks.find(t => t.name.startsWith('Resolve conflicts:'));
expect(resolutionTask).toBeDefined();
expect(resolutionTask!.name).toBe('Resolve conflicts: Original Task');
expect(resolutionTask!.priority).toBe('high');
expect(resolutionTask!.type).toBe('auto');
expect(resolutionTask!.status).toBe('pending');
expect(resolutionTask!.order).toBe(originalTask.order + 1);
expect(resolutionTask!.phaseId).toBe(testPhaseId);
expect(resolutionTask!.initiativeId).toBe(testInitiativeId);
expect(resolutionTask!.parentTaskId).toBe(originalTask.parentTaskId);
// Check description contains conflict files
expect(resolutionTask!.description).toContain('src/file1.ts');
expect(resolutionTask!.description).toContain('src/file2.ts');
expect(resolutionTask!.description).toContain('Original Task');
});
it('should update original task status to blocked', async () => {
const originalTask = await taskRepository.create({
phaseId: testPhaseId,
initiativeId: testInitiativeId,
name: 'Task To Block',
status: 'in_progress',
order: 1,
});
await agentRepository.create({
name: 'agent-block',
taskId: originalTask.id,
worktreeId: 'wt-block',
});
await service.handleConflict(originalTask.id, ['conflict.ts']);
// Check original task is blocked
const updatedTask = await taskRepository.findById(originalTask.id);
expect(updatedTask!.status).toBe('blocked');
});
it('should create message to agent about conflict', async () => {
const originalTask = await taskRepository.create({
phaseId: testPhaseId,
initiativeId: testInitiativeId,
name: 'Message Task',
order: 1,
});
const agent = await agentRepository.create({
name: 'agent-msg',
taskId: originalTask.id,
worktreeId: 'wt-msg',
});
const conflicts = ['conflict.ts'];
await service.handleConflict(originalTask.id, conflicts);
// Check message was created
const messages = await messageRepository.findByRecipient('agent', agent.id);
expect(messages.length).toBe(1);
expect(messages[0].recipientType).toBe('agent');
expect(messages[0].recipientId).toBe(agent.id);
expect(messages[0].senderType).toBe('user');
expect(messages[0].type).toBe('info');
expect(messages[0].requiresResponse).toBe(false);
// Check message content
expect(messages[0].content).toContain('Merge conflict detected');
expect(messages[0].content).toContain('Message Task');
expect(messages[0].content).toContain('conflict.ts');
expect(messages[0].content).toContain('Resolve conflicts: Message Task');
});
it('should emit TaskQueuedEvent for resolution task', async () => {
const originalTask = await taskRepository.create({
phaseId: testPhaseId,
initiativeId: testInitiativeId,
name: 'Event Task',
order: 1,
});
await agentRepository.create({
name: 'agent-event',
taskId: originalTask.id,
worktreeId: 'wt-event',
});
await service.handleConflict(originalTask.id, ['event.ts']);
// Check TaskQueuedEvent was emitted
expect(eventBus.emittedEvents.length).toBe(1);
expect(eventBus.emittedEvents[0].type).toBe('task:queued');
const event = eventBus.emittedEvents[0] as any;
expect(event.payload.priority).toBe('high');
expect(event.payload.dependsOn).toEqual([]);
// Check taskId matches the created resolution task
const tasks = await taskRepository.findByPhaseId(testPhaseId);
const resolutionTask = tasks.find(t => t.name.startsWith('Resolve conflicts:'));
expect(event.payload.taskId).toBe(resolutionTask!.id);
});
it('should work without messageRepository', async () => {
// Create service without messageRepository
const serviceNoMsg = new DefaultConflictResolutionService(
taskRepository,
agentRepository,
undefined, // No message repository
eventBus
);
const originalTask = await taskRepository.create({
phaseId: testPhaseId,
initiativeId: testInitiativeId,
name: 'No Message Task',
order: 1,
});
await agentRepository.create({
name: 'agent-no-msg',
taskId: originalTask.id,
worktreeId: 'wt-no-msg',
});
// Should not throw and should still create task
await expect(serviceNoMsg.handleConflict(originalTask.id, ['test.ts']))
.resolves.not.toThrow();
// Check resolution task was still created
const tasks = await taskRepository.findByPhaseId(testPhaseId);
const resolutionTask = tasks.find(t => t.name.startsWith('Resolve conflicts:'));
expect(resolutionTask).toBeDefined();
});
it('should work without eventBus', async () => {
// Create service without eventBus
const serviceNoEvents = new DefaultConflictResolutionService(
taskRepository,
agentRepository,
messageRepository,
undefined // No event bus
);
const originalTask = await taskRepository.create({
phaseId: testPhaseId,
initiativeId: testInitiativeId,
name: 'No Events Task',
order: 1,
});
await agentRepository.create({
name: 'agent-no-events',
taskId: originalTask.id,
worktreeId: 'wt-no-events',
});
// Should not throw and should still create task
await expect(serviceNoEvents.handleConflict(originalTask.id, ['test.ts']))
.resolves.not.toThrow();
// Check resolution task was still created
const tasks = await taskRepository.findByPhaseId(testPhaseId);
const resolutionTask = tasks.find(t => t.name.startsWith('Resolve conflicts:'));
expect(resolutionTask).toBeDefined();
});
it('should throw error when task not found', async () => {
await expect(service.handleConflict('non-existent-id', ['test.ts']))
.rejects.toThrow('Original task not found: non-existent-id');
});
it('should throw error when no agent found for task', async () => {
const task = await taskRepository.create({
phaseId: testPhaseId,
initiativeId: testInitiativeId,
name: 'Orphan Task',
order: 1,
});
await expect(service.handleConflict(task.id, ['test.ts']))
.rejects.toThrow(`No agent found for task: ${task.id}`);
});
it('should handle multiple conflict files correctly', async () => {
const originalTask = await taskRepository.create({
phaseId: testPhaseId,
initiativeId: testInitiativeId,
name: 'Multi-Conflict Task',
order: 1,
});
await agentRepository.create({
name: 'agent-multi',
taskId: originalTask.id,
worktreeId: 'wt-multi',
});
const conflicts = [
'src/components/Header.tsx',
'src/utils/helpers.ts',
'package.json',
'README.md'
];
await service.handleConflict(originalTask.id, conflicts);
// Check all conflict files are in the description
const tasks = await taskRepository.findByPhaseId(testPhaseId);
const resolutionTask = tasks.find(t => t.name.startsWith('Resolve conflicts:'));
expect(resolutionTask!.description).toContain('src/components/Header.tsx');
expect(resolutionTask!.description).toContain('src/utils/helpers.ts');
expect(resolutionTask!.description).toContain('package.json');
expect(resolutionTask!.description).toContain('README.md');
});
it('should preserve parentTaskId from original task', async () => {
// Create parent task first
const parentTask = await taskRepository.create({
phaseId: testPhaseId,
initiativeId: testInitiativeId,
name: 'Parent Task',
order: 1,
});
// Create child task
const childTask = await taskRepository.create({
phaseId: testPhaseId,
initiativeId: testInitiativeId,
parentTaskId: parentTask.id,
name: 'Child Task',
order: 2,
});
await agentRepository.create({
name: 'agent-child',
taskId: childTask.id,
worktreeId: 'wt-child',
});
await service.handleConflict(childTask.id, ['conflict.ts']);
// Check resolution task has same parentTaskId
const tasks = await taskRepository.findByPhaseId(testPhaseId);
const resolutionTask = tasks.find(t => t.name.startsWith('Resolve conflicts:'));
expect(resolutionTask!.parentTaskId).toBe(parentTask.id);
});
});
});

View File

@@ -0,0 +1,136 @@
/**
* ConflictResolutionService
*
* Service responsible for handling merge conflicts by:
* - Creating conflict resolution tasks
* - Updating original task status
* - Notifying agents via messages
* - Emitting appropriate events
*
* This service is used by the CoordinationManager when merge conflicts occur.
*/
import type { EventBus, TaskQueuedEvent } from '../events/index.js';
import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { MessageRepository } from '../db/repositories/message-repository.js';
// =============================================================================
// ConflictResolutionService Interface (Port)
// =============================================================================
/**
* Service interface for handling merge conflicts.
* This is the PORT - implementations are ADAPTERS.
*/
export interface ConflictResolutionService {
/**
* Handle a merge conflict by creating resolution task and notifying agent.
*
* @param taskId - ID of the task that conflicted
* @param conflicts - List of conflicting file paths
*/
handleConflict(taskId: string, conflicts: string[]): Promise<void>;
}
// =============================================================================
// DefaultConflictResolutionService Implementation (Adapter)
// =============================================================================
/**
* Default implementation of ConflictResolutionService.
*
* Creates conflict resolution tasks, updates task statuses, sends messages
* to agents, and emits events when merge conflicts occur.
*/
export class DefaultConflictResolutionService implements ConflictResolutionService {
constructor(
private taskRepository: TaskRepository,
private agentRepository: AgentRepository,
private messageRepository?: MessageRepository,
private eventBus?: EventBus
) {}
/**
* Handle a merge conflict.
* Creates a conflict-resolution task and notifies the agent via message.
*/
async handleConflict(taskId: string, conflicts: string[]): Promise<void> {
// Get original task for context
const originalTask = await this.taskRepository.findById(taskId);
if (!originalTask) {
throw new Error(`Original task not found: ${taskId}`);
}
// Get agent that was working on the task
const agent = await this.agentRepository.findByTaskId(taskId);
if (!agent) {
throw new Error(`No agent found for task: ${taskId}`);
}
// Build conflict description
const conflictDescription = [
'Merge conflicts detected. Resolve conflicts in the following files:',
'',
...conflicts.map((f) => `- ${f}`),
'',
`Original task: ${originalTask.name}`,
'',
'Instructions: Resolve merge conflicts in the listed files, then mark task complete.',
].join('\n');
// Create new conflict-resolution task
const conflictTask = await this.taskRepository.create({
parentTaskId: originalTask.parentTaskId,
phaseId: originalTask.phaseId,
initiativeId: originalTask.initiativeId,
name: `Resolve conflicts: ${originalTask.name}`,
description: conflictDescription,
type: 'auto',
priority: 'high', // Conflicts should be resolved quickly
status: 'pending',
order: originalTask.order + 1,
});
// Update original task status to blocked
await this.taskRepository.update(taskId, { status: 'blocked' });
// Create message to agent if messageRepository is configured
if (this.messageRepository) {
const messageContent = [
`Merge conflict detected for task: ${originalTask.name}`,
'',
'Conflicting files:',
...conflicts.map((f) => `- ${f}`),
'',
`A new task has been created to resolve these conflicts: ${conflictTask.name}`,
'',
'Please resolve the merge conflicts in the listed files and mark the resolution task as complete.',
].join('\n');
await this.messageRepository.create({
senderType: 'user', // System-generated messages appear as from user
senderId: null,
recipientType: 'agent',
recipientId: agent.id,
type: 'info',
content: messageContent,
requiresResponse: false,
});
}
// Emit TaskQueuedEvent for the new conflict-resolution task
if (this.eventBus) {
const event: TaskQueuedEvent = {
type: 'task:queued',
timestamp: new Date(),
payload: {
taskId: conflictTask.id,
priority: 'high',
dependsOn: [],
},
};
this.eventBus.emit(event);
}
}
}

View File

@@ -16,11 +16,13 @@
* - Clean separation between domain logic and infrastructure
*/
// Port interface (what consumers depend on)
// Port interfaces (what consumers depend on)
export type { CoordinationManager } from './types.js';
export type { ConflictResolutionService } from './conflict-resolution-service.js';
// Domain types
export type { MergeQueueItem, MergeStatus, MergeResult } from './types.js';
// Adapters
export { DefaultCoordinationManager } from './manager.js';
export { DefaultConflictResolutionService } from './conflict-resolution-service.js';

View File

@@ -10,7 +10,7 @@ import { DefaultCoordinationManager } from './manager.js';
import { DrizzleTaskRepository } from '../db/repositories/drizzle/task.js';
import { DrizzleAgentRepository } from '../db/repositories/drizzle/agent.js';
import { DrizzleMessageRepository } from '../db/repositories/drizzle/message.js';
import { DrizzlePlanRepository } from '../db/repositories/drizzle/plan.js';
import { DrizzlePhaseRepository } from '../db/repositories/drizzle/phase.js';
import { DrizzleInitiativeRepository } from '../db/repositories/drizzle/initiative.js';
import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js';
@@ -20,6 +20,7 @@ import type { WorktreeManager, MergeResult as GitMergeResult } from '../git/type
import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { MessageRepository } from '../db/repositories/message-repository.js';
import type { ConflictResolutionService } from './conflict-resolution-service.js';
// =============================================================================
// Test Helpers
@@ -63,6 +64,15 @@ function createMockWorktreeManager(
};
}
/**
* Create a mock ConflictResolutionService.
*/
function createMockConflictResolutionService(): ConflictResolutionService {
return {
handleConflict: vi.fn(),
};
}
// =============================================================================
// Tests
// =============================================================================
@@ -74,8 +84,9 @@ describe('DefaultCoordinationManager', () => {
let messageRepository: MessageRepository;
let eventBus: EventBus & { emittedEvents: DomainEvent[] };
let worktreeManager: WorktreeManager;
let conflictResolutionService: ConflictResolutionService;
let manager: DefaultCoordinationManager;
let testPlanId: string;
let testPhaseId: string;
beforeEach(async () => {
// Set up test database
@@ -87,7 +98,6 @@ describe('DefaultCoordinationManager', () => {
// Create required hierarchy for tasks
const initiativeRepo = new DrizzleInitiativeRepository(db);
const phaseRepo = new DrizzlePhaseRepository(db);
const planRepo = new DrizzlePlanRepository(db);
const initiative = await initiativeRepo.create({
name: 'Test Initiative',
@@ -97,12 +107,7 @@ describe('DefaultCoordinationManager', () => {
number: 1,
name: 'Test Phase',
});
const plan = await planRepo.create({
phaseId: phase.id,
number: 1,
name: 'Test Plan',
});
testPlanId = plan.id;
testPhaseId = phase.id;
// Create mocks
eventBus = createMockEventBus();
@@ -126,7 +131,7 @@ describe('DefaultCoordinationManager', () => {
it('should add task to queue and emit MergeQueuedEvent', async () => {
// Create task
const task = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Test Task',
priority: 'high',
order: 1,
@@ -162,7 +167,7 @@ describe('DefaultCoordinationManager', () => {
it('should throw error when no agent assigned to task', async () => {
const task = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Orphan Task',
order: 1,
});
@@ -185,7 +190,7 @@ describe('DefaultCoordinationManager', () => {
it('should return item when all dependencies merged', async () => {
const task = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Mergeable Task',
priority: 'medium',
order: 1,
@@ -207,19 +212,19 @@ describe('DefaultCoordinationManager', () => {
it('should respect priority ordering (high > medium > low)', async () => {
// Create tasks in different priority order
const lowTask = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Low Priority',
priority: 'low',
order: 1,
});
const highTask = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'High Priority',
priority: 'high',
order: 2,
});
const mediumTask = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Medium Priority',
priority: 'medium',
order: 3,
@@ -256,13 +261,13 @@ describe('DefaultCoordinationManager', () => {
it('should order by queuedAt within same priority (oldest first)', async () => {
const task1 = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'First Task',
priority: 'medium',
order: 1,
});
const task2 = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Second Task',
priority: 'medium',
order: 2,
@@ -298,7 +303,7 @@ describe('DefaultCoordinationManager', () => {
describe('processMerges - success path', () => {
it('should complete clean merges and emit MergeCompletedEvent', async () => {
const task = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Mergeable Task',
priority: 'high',
order: 1,
@@ -332,13 +337,13 @@ describe('DefaultCoordinationManager', () => {
it('should process multiple tasks in priority order', async () => {
const lowTask = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Low Priority',
priority: 'low',
order: 1,
});
const highTask = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'High Priority',
priority: 'high',
order: 2,
@@ -374,7 +379,7 @@ describe('DefaultCoordinationManager', () => {
describe('processMerges - conflict handling', () => {
it('should detect conflicts and emit MergeConflictedEvent', async () => {
const task = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Conflicting Task',
priority: 'high',
order: 1,
@@ -430,7 +435,7 @@ describe('DefaultCoordinationManager', () => {
it('should create resolution task on conflict', async () => {
const task = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Original Task',
priority: 'medium',
order: 1,
@@ -463,7 +468,7 @@ describe('DefaultCoordinationManager', () => {
await manager.processMerges('main');
// Check new task was created
const tasks = await taskRepository.findByPlanId(testPlanId);
const tasks = await taskRepository.findByPhaseId(testPhaseId);
const conflictTask = tasks.find((t) =>
t.name.startsWith('Resolve conflicts:')
);
@@ -488,7 +493,7 @@ describe('DefaultCoordinationManager', () => {
it('should create message to agent on conflict', async () => {
const task = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Task with Message',
priority: 'medium',
order: 1,
@@ -539,13 +544,13 @@ describe('DefaultCoordinationManager', () => {
it('should return correct counts for all states', async () => {
// Create tasks
const task1 = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Queued Task',
priority: 'high',
order: 1,
});
const task2 = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Conflict Task',
priority: 'medium',
order: 2,
@@ -609,7 +614,7 @@ describe('DefaultCoordinationManager', () => {
it('should throw error when no agent for task', async () => {
const task = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Orphan Task',
order: 1,
});
@@ -649,7 +654,7 @@ describe('DefaultCoordinationManager', () => {
);
const task = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Test',
order: 1,
});
@@ -669,7 +674,7 @@ describe('DefaultCoordinationManager', () => {
);
const task = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Test',
order: 1,
});

View File

@@ -13,13 +13,14 @@ import type {
MergeStartedEvent,
MergeCompletedEvent,
MergeConflictedEvent,
TaskQueuedEvent,
} from '../events/index.js';
import type { WorktreeManager } from '../git/types.js';
import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { MessageRepository } from '../db/repositories/message-repository.js';
import type { CoordinationManager, MergeQueueItem, MergeResult } from './types.js';
import type { ConflictResolutionService } from './conflict-resolution-service.js';
import { DefaultConflictResolutionService } from './conflict-resolution-service.js';
// =============================================================================
// Internal Types
@@ -52,13 +53,29 @@ export class DefaultCoordinationManager implements CoordinationManager {
/** Tasks with conflicts awaiting resolution */
private conflictedTasks: Map<string, string[]> = new Map();
/** Service for handling merge conflicts */
private conflictResolutionService?: ConflictResolutionService;
constructor(
private worktreeManager?: WorktreeManager,
private taskRepository?: TaskRepository,
private agentRepository?: AgentRepository,
private messageRepository?: MessageRepository,
private eventBus?: EventBus
) {}
private eventBus?: EventBus,
conflictResolutionService?: ConflictResolutionService
) {
// Create default conflict resolution service if none provided
if (conflictResolutionService) {
this.conflictResolutionService = conflictResolutionService;
} else if (taskRepository && agentRepository) {
this.conflictResolutionService = new DefaultConflictResolutionService(
taskRepository,
agentRepository,
messageRepository,
eventBus
);
}
}
/**
* Queue a completed task for merge.
@@ -259,89 +276,14 @@ export class DefaultCoordinationManager implements CoordinationManager {
/**
* Handle a merge conflict.
* Creates a conflict-resolution task and notifies the agent via message.
* Delegates to the ConflictResolutionService.
*/
async handleConflict(taskId: string, conflicts: string[]): Promise<void> {
if (!this.taskRepository) {
throw new Error('TaskRepository not configured');
if (!this.conflictResolutionService) {
throw new Error('ConflictResolutionService not configured');
}
if (!this.agentRepository) {
throw new Error('AgentRepository not configured');
}
// Get original task for context
const originalTask = await this.taskRepository.findById(taskId);
if (!originalTask) {
throw new Error(`Original task not found: ${taskId}`);
}
// Get agent that was working on the task
const agent = await this.agentRepository.findByTaskId(taskId);
if (!agent) {
throw new Error(`No agent found for task: ${taskId}`);
}
// Build conflict description
const conflictDescription = [
'Merge conflicts detected. Resolve conflicts in the following files:',
'',
...conflicts.map((f) => `- ${f}`),
'',
`Original task: ${originalTask.name}`,
'',
'Instructions: Resolve merge conflicts in the listed files, then mark task complete.',
].join('\n');
// Create new conflict-resolution task
const conflictTask = await this.taskRepository.create({
planId: originalTask.planId,
name: `Resolve conflicts: ${originalTask.name}`,
description: conflictDescription,
type: 'auto',
priority: 'high', // Conflicts should be resolved quickly
status: 'pending',
order: originalTask.order + 1,
});
// Update original task status to blocked
await this.taskRepository.update(taskId, { status: 'blocked' });
// Create message to agent if messageRepository is configured
if (this.messageRepository) {
const messageContent = [
`Merge conflict detected for task: ${originalTask.name}`,
'',
'Conflicting files:',
...conflicts.map((f) => `- ${f}`),
'',
`A new task has been created to resolve these conflicts: ${conflictTask.name}`,
'',
'Please resolve the merge conflicts in the listed files and mark the resolution task as complete.',
].join('\n');
await this.messageRepository.create({
senderType: 'user', // System-generated messages appear as from user
senderId: null,
recipientType: 'agent',
recipientId: agent.id,
type: 'info',
content: messageContent,
requiresResponse: false,
});
}
// Emit TaskQueuedEvent for the new conflict-resolution task
const event: TaskQueuedEvent = {
type: 'task:queued',
timestamp: new Date(),
payload: {
taskId: conflictTask.id,
priority: 'high',
dependsOn: [],
},
};
this.eventBus?.emit(event);
await this.conflictResolutionService.handleConflict(taskId, conflicts);
}
/**

View File

@@ -1,11 +1,12 @@
import { mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { findWorkspaceRoot } from '../config/cwrc.js';
/**
* Get the database path.
*
* - Default: ~/.cw/data/cw.db
* - Default: <workspace-root>/.cw/cw.db
* - Throws if no .cwrc workspace is found
* - Override via CW_DB_PATH environment variable
* - For testing, pass ':memory:' as CW_DB_PATH
*/
@@ -15,7 +16,13 @@ export function getDbPath(): string {
return envPath;
}
return join(homedir(), '.cw', 'data', 'cw.db');
const root = findWorkspaceRoot();
if (!root) {
throw new Error(
'No .cwrc workspace found. Run `cw init` to initialize a workspace.',
);
}
return join(root, '.cw', 'cw.db');
}
/**

View File

@@ -1,123 +1,38 @@
/**
* Database Schema Initialization
* Database Migration
*
* Ensures all required tables exist in the database.
* Uses CREATE TABLE IF NOT EXISTS so it's safe to call multiple times.
* Runs drizzle-kit migrations from the drizzle/ directory.
* Safe to call on every startup - only applies pending migrations.
*/
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { DrizzleDatabase } from './index.js';
import { sql } from 'drizzle-orm';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('db');
/**
* Individual CREATE TABLE statements for each table.
* Each must be a single statement for drizzle-orm compatibility.
* These mirror the schema defined in schema.ts.
* Resolve the migrations directory relative to the package root.
* Works both in development (src/) and after build (dist/).
*/
const TABLE_STATEMENTS = [
// Initiatives table
`CREATE TABLE IF NOT EXISTS initiatives (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`,
// Phases table
`CREATE TABLE IF NOT EXISTS phases (
id TEXT PRIMARY KEY NOT NULL,
initiative_id TEXT NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
number INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`,
// Phase dependencies table
`CREATE TABLE IF NOT EXISTS phase_dependencies (
id TEXT PRIMARY KEY NOT NULL,
phase_id TEXT NOT NULL REFERENCES phases(id) ON DELETE CASCADE,
depends_on_phase_id TEXT NOT NULL REFERENCES phases(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL
)`,
// Plans table
`CREATE TABLE IF NOT EXISTS plans (
id TEXT PRIMARY KEY NOT NULL,
phase_id TEXT NOT NULL REFERENCES phases(id) ON DELETE CASCADE,
number INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`,
// Tasks table
`CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY NOT NULL,
plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL DEFAULT 'auto',
priority TEXT NOT NULL DEFAULT 'medium',
status TEXT NOT NULL DEFAULT 'pending',
"order" INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`,
// Task dependencies table
`CREATE TABLE IF NOT EXISTS task_dependencies (
id TEXT PRIMARY KEY NOT NULL,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
depends_on_task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL
)`,
// Agents table
`CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL UNIQUE,
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
session_id TEXT,
worktree_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'idle',
mode TEXT NOT NULL DEFAULT 'execute' CHECK(mode IN ('execute', 'discuss', 'breakdown', 'decompose')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`,
// Messages table
`CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY NOT NULL,
sender_type TEXT NOT NULL,
sender_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
recipient_type TEXT NOT NULL,
recipient_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
type TEXT NOT NULL DEFAULT 'info',
content TEXT NOT NULL,
requires_response INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
parent_message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`,
];
function getMigrationsPath(): string {
const currentDir = dirname(fileURLToPath(import.meta.url));
// From src/db/ or dist/db/, go up two levels to package root, then into drizzle/
return join(currentDir, '..', '..', 'drizzle');
}
/**
* Ensure all database tables exist.
* Run all pending database migrations.
*
* Uses CREATE TABLE IF NOT EXISTS, so safe to call on every startup.
* Must be called before any repository operations on a fresh database.
* Uses drizzle-kit's migration system which tracks applied migrations
* in a __drizzle_migrations table. Safe to call on every startup.
*
* @param db - Drizzle database instance
*/
export function ensureSchema(db: DrizzleDatabase): void {
for (const statement of TABLE_STATEMENTS) {
db.run(sql.raw(statement));
}
log.info('applying database migrations');
migrate(db, { migrationsFolder: getMigrationsPath() });
log.info('database migrations complete');
}

View File

@@ -0,0 +1,61 @@
/**
* Account Repository Port Interface
*
* Port for Account aggregate operations.
* Accounts represent authenticated provider logins (e.g. Claude OAuth accounts)
* used for round-robin agent spawning and usage-limit failover.
*/
import type { Account } from '../schema.js';
export interface CreateAccountData {
email: string;
provider?: string; // defaults to 'claude'
configJson?: string; // .claude.json content
credentials?: string; // .credentials.json content
}
export interface AccountRepository {
/** Create a new account. Generates id and sets timestamps. */
create(data: CreateAccountData): Promise<Account>;
/** Find an account by its ID. */
findById(id: string): Promise<Account | null>;
/** Find an account by email. */
findByEmail(email: string): Promise<Account | null>;
/** Find all accounts for a given provider. */
findByProvider(provider: string): Promise<Account[]>;
/**
* Find the next available (non-exhausted) account for a provider.
* Uses round-robin via lastUsedAt ordering (least-recently-used first).
* Automatically clears expired exhaustion before querying.
*/
findNextAvailable(provider: string): Promise<Account | null>;
/** Mark an account as exhausted until a given time. */
markExhausted(id: string, until: Date): Promise<Account>;
/** Mark an account as available (clear exhaustion). */
markAvailable(id: string): Promise<Account>;
/** Update the lastUsedAt timestamp for an account. */
updateLastUsed(id: string): Promise<Account>;
/** Clear exhaustion for all accounts whose exhaustedUntil has passed. Returns count cleared. */
clearExpiredExhaustion(): Promise<number>;
/** Find all accounts. */
findAll(): Promise<Account[]>;
/** Update stored credentials for an account. */
updateCredentials(id: string, credentials: string): Promise<Account>;
/** Update both configJson and credentials for an account (used by account add upsert). */
updateAccountAuth(id: string, configJson: string, credentials: string): Promise<Account>;
/** Delete an account. Throws if not found. */
delete(id: string): Promise<void>;
}

View File

@@ -21,9 +21,34 @@ export interface CreateAgentData {
name: string;
worktreeId: string;
taskId?: string | null;
initiativeId?: string | null;
sessionId?: string | null;
status?: AgentStatus;
mode?: AgentMode; // Defaults to 'execute' if not provided
provider?: string; // Defaults to 'claude' if not provided
accountId?: string | null;
}
/**
* Data for updating an existing agent.
* All fields optional. System-managed fields (id, createdAt, updatedAt) are excluded.
*/
export interface UpdateAgentData {
name?: string;
worktreeId?: string;
taskId?: string | null;
initiativeId?: string | null;
sessionId?: string | null;
status?: AgentStatus;
mode?: AgentMode;
provider?: string;
accountId?: string | null;
pid?: number | null;
outputFilePath?: string | null;
result?: string | null;
pendingQuestions?: string | null;
userDismissedAt?: Date | null;
updatedAt?: Date;
}
/**
@@ -78,19 +103,12 @@ export interface AgentRepository {
findByStatus(status: AgentStatus): Promise<Agent[]>;
/**
* Update an agent's status.
* Update an agent with partial data.
* Only provided fields are updated, others remain unchanged.
* Throws if agent not found.
* Updates updatedAt timestamp automatically.
*/
updateStatus(id: string, status: AgentStatus): Promise<Agent>;
/**
* Update an agent's session ID.
* Called after first CLI run completes and provides session ID.
* Throws if agent not found.
* Updates updatedAt timestamp automatically.
*/
updateSessionId(id: string, sessionId: string): Promise<Agent>;
update(id: string, data: UpdateAgentData): Promise<Agent>;
/**
* Delete an agent.

View File

@@ -0,0 +1,203 @@
/**
* Drizzle Account Repository Adapter
*
* Implements AccountRepository interface using Drizzle ORM.
* Handles round-robin selection via lastUsedAt ordering
* and automatic exhaustion expiry.
*/
import { eq, and, asc, lte } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { accounts, agents, type Account } from '../../schema.js';
import type { AccountRepository, CreateAccountData } from '../account-repository.js';
export class DrizzleAccountRepository implements AccountRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreateAccountData): Promise<Account> {
const id = nanoid();
const now = new Date();
const [created] = await this.db.insert(accounts).values({
id,
email: data.email,
provider: data.provider ?? 'claude',
configJson: data.configJson ?? null,
credentials: data.credentials ?? null,
isExhausted: false,
exhaustedUntil: null,
lastUsedAt: null,
sortOrder: 0,
createdAt: now,
updatedAt: now,
}).returning();
return created;
}
async findById(id: string): Promise<Account | null> {
const result = await this.db
.select()
.from(accounts)
.where(eq(accounts.id, id))
.limit(1);
return result[0] ?? null;
}
async findByEmail(email: string): Promise<Account | null> {
const result = await this.db
.select()
.from(accounts)
.where(eq(accounts.email, email))
.limit(1);
return result[0] ?? null;
}
async findByProvider(provider: string): Promise<Account[]> {
return this.db
.select()
.from(accounts)
.where(eq(accounts.provider, provider));
}
async findNextAvailable(provider: string): Promise<Account | null> {
await this.clearExpiredExhaustion();
const result = await this.db
.select()
.from(accounts)
.where(
and(
eq(accounts.provider, provider),
eq(accounts.isExhausted, false),
),
)
.orderBy(asc(accounts.lastUsedAt))
.limit(1);
return result[0] ?? null;
}
async markExhausted(id: string, until: Date): Promise<Account> {
const now = new Date();
const [updated] = await this.db
.update(accounts)
.set({
isExhausted: true,
exhaustedUntil: until,
updatedAt: now,
})
.where(eq(accounts.id, id))
.returning();
if (!updated) {
throw new Error(`Account not found: ${id}`);
}
return updated;
}
async markAvailable(id: string): Promise<Account> {
const now = new Date();
const [updated] = await this.db
.update(accounts)
.set({
isExhausted: false,
exhaustedUntil: null,
updatedAt: now,
})
.where(eq(accounts.id, id))
.returning();
if (!updated) {
throw new Error(`Account not found: ${id}`);
}
return updated;
}
async updateLastUsed(id: string): Promise<Account> {
const now = new Date();
const [updated] = await this.db
.update(accounts)
.set({ lastUsedAt: now, updatedAt: now })
.where(eq(accounts.id, id))
.returning();
if (!updated) {
throw new Error(`Account not found: ${id}`);
}
return updated;
}
async clearExpiredExhaustion(): Promise<number> {
const now = new Date();
const cleared = await this.db
.update(accounts)
.set({
isExhausted: false,
exhaustedUntil: null,
updatedAt: now,
})
.where(
and(
eq(accounts.isExhausted, true),
lte(accounts.exhaustedUntil, now),
),
)
.returning({ id: accounts.id });
return cleared.length;
}
async findAll(): Promise<Account[]> {
return this.db.select().from(accounts);
}
async updateCredentials(id: string, credentials: string): Promise<Account> {
const now = new Date();
const [updated] = await this.db
.update(accounts)
.set({ credentials, updatedAt: now })
.where(eq(accounts.id, id))
.returning();
if (!updated) {
throw new Error(`Account not found: ${id}`);
}
return updated;
}
async updateAccountAuth(id: string, configJson: string, credentials: string): Promise<Account> {
const now = new Date();
const [updated] = await this.db
.update(accounts)
.set({ configJson, credentials, updatedAt: now })
.where(eq(accounts.id, id))
.returning();
if (!updated) {
throw new Error(`Account not found: ${id}`);
}
return updated;
}
async delete(id: string): Promise<void> {
// Manually nullify agent FK — the migration lacks ON DELETE SET NULL
await this.db
.update(agents)
.set({ accountId: null })
.where(eq(agents.accountId, id));
const [deleted] = await this.db.delete(accounts).where(eq(accounts.id, id)).returning();
if (!deleted) {
throw new Error(`Account not found: ${id}`);
}
}
}

View File

@@ -7,7 +7,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { DrizzleAgentRepository } from './agent.js';
import { DrizzleTaskRepository } from './task.js';
import { DrizzlePlanRepository } from './plan.js';
import { DrizzlePhaseRepository } from './phase.js';
import { DrizzleInitiativeRepository } from './initiative.js';
import { createTestDatabase } from './test-helpers.js';
@@ -17,7 +16,6 @@ describe('DrizzleAgentRepository', () => {
let db: DrizzleDatabase;
let agentRepo: DrizzleAgentRepository;
let taskRepo: DrizzleTaskRepository;
let planRepo: DrizzlePlanRepository;
let phaseRepo: DrizzlePhaseRepository;
let initiativeRepo: DrizzleInitiativeRepository;
let testTaskId: string;
@@ -26,7 +24,6 @@ describe('DrizzleAgentRepository', () => {
db = createTestDatabase();
agentRepo = new DrizzleAgentRepository(db);
taskRepo = new DrizzleTaskRepository(db);
planRepo = new DrizzlePlanRepository(db);
phaseRepo = new DrizzlePhaseRepository(db);
initiativeRepo = new DrizzleInitiativeRepository(db);
@@ -39,13 +36,8 @@ describe('DrizzleAgentRepository', () => {
number: 1,
name: 'Test Phase',
});
const plan = await planRepo.create({
phaseId: phase.id,
number: 1,
name: 'Test Plan',
});
const task = await taskRepo.create({
planId: plan.id,
phaseId: phase.id,
name: 'Test Task',
order: 1,
});
@@ -162,7 +154,7 @@ describe('DrizzleAgentRepository', () => {
name: 'session-agent',
worktreeId: 'worktree-123',
});
await agentRepo.updateSessionId(agent.id, 'session-abc');
await agentRepo.update(agent.id, { sessionId: 'session-abc' });
const found = await agentRepo.findBySessionId('session-abc');
expect(found).not.toBeNull();
@@ -201,7 +193,7 @@ describe('DrizzleAgentRepository', () => {
name: 'running-agent',
worktreeId: 'wt-2',
});
await agentRepo.updateStatus(agent2.id, 'running');
await agentRepo.update(agent2.id, { status: 'running' });
const idleAgents = await agentRepo.findByStatus('idle');
const runningAgents = await agentRepo.findByStatus('running');
@@ -217,7 +209,7 @@ describe('DrizzleAgentRepository', () => {
name: 'waiting-agent',
worktreeId: 'wt-1',
});
await agentRepo.updateStatus(agent.id, 'waiting_for_input');
await agentRepo.update(agent.id, { status: 'waiting_for_input' });
const waitingAgents = await agentRepo.findByStatus('waiting_for_input');
expect(waitingAgents.length).toBe(1);
@@ -225,7 +217,7 @@ describe('DrizzleAgentRepository', () => {
});
});
describe('updateStatus', () => {
describe('update', () => {
it('should change status and updatedAt', async () => {
const created = await agentRepo.create({
name: 'status-test',
@@ -234,22 +226,14 @@ describe('DrizzleAgentRepository', () => {
await new Promise((resolve) => setTimeout(resolve, 10));
const updated = await agentRepo.updateStatus(created.id, 'running');
const updated = await agentRepo.update(created.id, { status: 'running' });
expect(updated.status).toBe('running');
expect(updated.updatedAt.getTime()).toBeGreaterThan(
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(
created.updatedAt.getTime()
);
});
it('should throw for non-existent agent', async () => {
await expect(
agentRepo.updateStatus('non-existent-id', 'running')
).rejects.toThrow('Agent not found');
});
});
describe('updateSessionId', () => {
it('should change sessionId and updatedAt', async () => {
const created = await agentRepo.create({
name: 'session-test',
@@ -259,20 +243,17 @@ describe('DrizzleAgentRepository', () => {
await new Promise((resolve) => setTimeout(resolve, 10));
const updated = await agentRepo.updateSessionId(
created.id,
'new-session-id'
);
const updated = await agentRepo.update(created.id, { sessionId: 'new-session-id' });
expect(updated.sessionId).toBe('new-session-id');
expect(updated.updatedAt.getTime()).toBeGreaterThan(
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(
created.updatedAt.getTime()
);
});
it('should throw for non-existent agent', async () => {
await expect(
agentRepo.updateSessionId('non-existent-id', 'session')
agentRepo.update('non-existent-id', { status: 'running' })
).rejects.toThrow('Agent not found');
});
});

View File

@@ -12,6 +12,7 @@ import type {
AgentRepository,
AgentStatus,
CreateAgentData,
UpdateAgentData,
} from '../agent-repository.js';
/**
@@ -27,21 +28,22 @@ export class DrizzleAgentRepository implements AgentRepository {
const id = nanoid();
const now = new Date();
await this.db.insert(agents).values({
const [created] = await this.db.insert(agents).values({
id,
name: data.name,
taskId: data.taskId ?? null,
initiativeId: data.initiativeId ?? null,
sessionId: data.sessionId ?? null,
worktreeId: data.worktreeId,
provider: data.provider ?? 'claude',
accountId: data.accountId ?? null,
status: data.status ?? 'idle',
mode: data.mode ?? 'execute',
createdAt: now,
updatedAt: now,
});
}).returning();
// Fetch to get the complete record with all defaults applied
const created = await this.findById(id);
return created!;
return created;
}
async findById(id: string): Promise<Agent | null> {
@@ -92,42 +94,26 @@ export class DrizzleAgentRepository implements AgentRepository {
return this.db.select().from(agents).where(eq(agents.status, status));
}
async updateStatus(id: string, status: AgentStatus): Promise<Agent> {
const existing = await this.findById(id);
if (!existing) {
async update(id: string, data: UpdateAgentData): Promise<Agent> {
const now = new Date();
const [updated] = await this.db
.update(agents)
.set({ ...data, updatedAt: now })
.where(eq(agents.id, id))
.returning();
if (!updated) {
throw new Error(`Agent not found: ${id}`);
}
const now = new Date();
await this.db
.update(agents)
.set({ status, updatedAt: now })
.where(eq(agents.id, id));
return { ...existing, status, updatedAt: now };
}
async updateSessionId(id: string, sessionId: string): Promise<Agent> {
const existing = await this.findById(id);
if (!existing) {
throw new Error(`Agent not found: ${id}`);
}
const now = new Date();
await this.db
.update(agents)
.set({ sessionId, updatedAt: now })
.where(eq(agents.id, id));
return { ...existing, sessionId, updatedAt: now };
return updated;
}
async delete(id: string): Promise<void> {
const existing = await this.findById(id);
if (!existing) {
const [deleted] = await this.db.delete(agents).where(eq(agents.id, id)).returning();
if (!deleted) {
throw new Error(`Agent not found: ${id}`);
}
await this.db.delete(agents).where(eq(agents.id, id));
}
}

View File

@@ -8,7 +8,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { DrizzleInitiativeRepository } from './initiative.js';
import { DrizzlePhaseRepository } from './phase.js';
import { DrizzlePlanRepository } from './plan.js';
import { DrizzleTaskRepository } from './task.js';
import { createTestDatabase } from './test-helpers.js';
import type { DrizzleDatabase } from '../../index.js';
@@ -17,19 +16,18 @@ describe('Cascade Deletes', () => {
let db: DrizzleDatabase;
let initiativeRepo: DrizzleInitiativeRepository;
let phaseRepo: DrizzlePhaseRepository;
let planRepo: DrizzlePlanRepository;
let taskRepo: DrizzleTaskRepository;
beforeEach(() => {
db = createTestDatabase();
initiativeRepo = new DrizzleInitiativeRepository(db);
phaseRepo = new DrizzlePhaseRepository(db);
planRepo = new DrizzlePlanRepository(db);
taskRepo = new DrizzleTaskRepository(db);
});
/**
* Helper to create a full hierarchy for testing.
* Uses parent tasks (decompose category) to group child tasks.
*/
async function createFullHierarchy() {
const initiative = await initiativeRepo.create({
@@ -48,44 +46,60 @@ describe('Cascade Deletes', () => {
name: 'Phase 2',
});
const plan1 = await planRepo.create({
// Create parent (decompose) tasks that group child tasks
const parentTask1 = await taskRepo.create({
phaseId: phase1.id,
number: 1,
name: 'Plan 1-1',
initiativeId: initiative.id,
name: 'Parent Task 1-1',
category: 'decompose',
order: 1,
});
const plan2 = await planRepo.create({
const parentTask2 = await taskRepo.create({
phaseId: phase1.id,
number: 2,
name: 'Plan 1-2',
initiativeId: initiative.id,
name: 'Parent Task 1-2',
category: 'decompose',
order: 2,
});
const plan3 = await planRepo.create({
const parentTask3 = await taskRepo.create({
phaseId: phase2.id,
number: 1,
name: 'Plan 2-1',
initiativeId: initiative.id,
name: 'Parent Task 2-1',
category: 'decompose',
order: 1,
});
// Create child tasks under parent tasks
const task1 = await taskRepo.create({
planId: plan1.id,
parentTaskId: parentTask1.id,
phaseId: phase1.id,
initiativeId: initiative.id,
name: 'Task 1-1-1',
order: 1,
});
const task2 = await taskRepo.create({
planId: plan1.id,
parentTaskId: parentTask1.id,
phaseId: phase1.id,
initiativeId: initiative.id,
name: 'Task 1-1-2',
order: 2,
});
const task3 = await taskRepo.create({
planId: plan2.id,
parentTaskId: parentTask2.id,
phaseId: phase1.id,
initiativeId: initiative.id,
name: 'Task 1-2-1',
order: 1,
});
const task4 = await taskRepo.create({
planId: plan3.id,
parentTaskId: parentTask3.id,
phaseId: phase2.id,
initiativeId: initiative.id,
name: 'Task 2-1-1',
order: 1,
});
@@ -93,22 +107,22 @@ describe('Cascade Deletes', () => {
return {
initiative,
phases: { phase1, phase2 },
plans: { plan1, plan2, plan3 },
parentTasks: { parentTask1, parentTask2, parentTask3 },
tasks: { task1, task2, task3, task4 },
};
}
describe('delete initiative', () => {
it('should cascade delete all phases, plans, and tasks', async () => {
const { initiative, phases, plans, tasks } = await createFullHierarchy();
it('should cascade delete all phases and tasks', async () => {
const { initiative, phases, parentTasks, tasks } = await createFullHierarchy();
// Verify everything exists
expect(await initiativeRepo.findById(initiative.id)).not.toBeNull();
expect(await phaseRepo.findById(phases.phase1.id)).not.toBeNull();
expect(await phaseRepo.findById(phases.phase2.id)).not.toBeNull();
expect(await planRepo.findById(plans.plan1.id)).not.toBeNull();
expect(await planRepo.findById(plans.plan2.id)).not.toBeNull();
expect(await planRepo.findById(plans.plan3.id)).not.toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask1.id)).not.toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask2.id)).not.toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask3.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task1.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task2.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task3.id)).not.toBeNull();
@@ -121,9 +135,9 @@ describe('Cascade Deletes', () => {
expect(await initiativeRepo.findById(initiative.id)).toBeNull();
expect(await phaseRepo.findById(phases.phase1.id)).toBeNull();
expect(await phaseRepo.findById(phases.phase2.id)).toBeNull();
expect(await planRepo.findById(plans.plan1.id)).toBeNull();
expect(await planRepo.findById(plans.plan2.id)).toBeNull();
expect(await planRepo.findById(plans.plan3.id)).toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask1.id)).toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask2.id)).toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask3.id)).toBeNull();
expect(await taskRepo.findById(tasks.task1.id)).toBeNull();
expect(await taskRepo.findById(tasks.task2.id)).toBeNull();
expect(await taskRepo.findById(tasks.task3.id)).toBeNull();
@@ -132,8 +146,8 @@ describe('Cascade Deletes', () => {
});
describe('delete phase', () => {
it('should cascade delete plans and tasks under that phase only', async () => {
const { initiative, phases, plans, tasks } = await createFullHierarchy();
it('should cascade delete tasks under that phase only', async () => {
const { initiative, phases, parentTasks, tasks } = await createFullHierarchy();
// Delete phase 1
await phaseRepo.delete(phases.phase1.id);
@@ -141,39 +155,39 @@ describe('Cascade Deletes', () => {
// Initiative still exists
expect(await initiativeRepo.findById(initiative.id)).not.toBeNull();
// Phase 1 and its children are gone
// Phase 1 and its tasks are gone
expect(await phaseRepo.findById(phases.phase1.id)).toBeNull();
expect(await planRepo.findById(plans.plan1.id)).toBeNull();
expect(await planRepo.findById(plans.plan2.id)).toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask1.id)).toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask2.id)).toBeNull();
expect(await taskRepo.findById(tasks.task1.id)).toBeNull();
expect(await taskRepo.findById(tasks.task2.id)).toBeNull();
expect(await taskRepo.findById(tasks.task3.id)).toBeNull();
// Phase 2 and its children still exist
// Phase 2 and its tasks still exist
expect(await phaseRepo.findById(phases.phase2.id)).not.toBeNull();
expect(await planRepo.findById(plans.plan3.id)).not.toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask3.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task4.id)).not.toBeNull();
});
});
describe('delete plan', () => {
it('should cascade delete tasks under that plan only', async () => {
const { phases, plans, tasks } = await createFullHierarchy();
describe('delete parent task', () => {
it('should cascade delete child tasks under that parent only', async () => {
const { phases, parentTasks, tasks } = await createFullHierarchy();
// Delete plan 1
await planRepo.delete(plans.plan1.id);
// Delete parent task 1
await taskRepo.delete(parentTasks.parentTask1.id);
// Phase still exists
expect(await phaseRepo.findById(phases.phase1.id)).not.toBeNull();
// Plan 1 and its tasks are gone
expect(await planRepo.findById(plans.plan1.id)).toBeNull();
// Parent task 1 and its children are gone
expect(await taskRepo.findById(parentTasks.parentTask1.id)).toBeNull();
expect(await taskRepo.findById(tasks.task1.id)).toBeNull();
expect(await taskRepo.findById(tasks.task2.id)).toBeNull();
// Other plans and tasks still exist
expect(await planRepo.findById(plans.plan2.id)).not.toBeNull();
expect(await planRepo.findById(plans.plan3.id)).not.toBeNull();
// Other parent tasks and their children still exist
expect(await taskRepo.findById(parentTasks.parentTask2.id)).not.toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask3.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task3.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task4.id)).not.toBeNull();
});

View File

@@ -7,7 +7,9 @@
export { DrizzleInitiativeRepository } from './initiative.js';
export { DrizzlePhaseRepository } from './phase.js';
export { DrizzlePlanRepository } from './plan.js';
export { DrizzleTaskRepository } from './task.js';
export { DrizzleAgentRepository } from './agent.js';
export { DrizzleMessageRepository } from './message.js';
export { DrizzlePageRepository } from './page.js';
export { DrizzleProjectRepository } from './project.js';
export { DrizzleAccountRepository } from './account.js';

View File

@@ -22,13 +22,11 @@ describe('DrizzleInitiativeRepository', () => {
it('should create an initiative with generated id and timestamps', async () => {
const initiative = await repo.create({
name: 'Test Initiative',
description: 'A test initiative',
});
expect(initiative.id).toBeDefined();
expect(initiative.id.length).toBeGreaterThan(0);
expect(initiative.name).toBe('Test Initiative');
expect(initiative.description).toBe('A test initiative');
expect(initiative.status).toBe('active');
expect(initiative.createdAt).toBeInstanceOf(Date);
expect(initiative.updatedAt).toBeInstanceOf(Date);
@@ -95,7 +93,7 @@ describe('DrizzleInitiativeRepository', () => {
expect(updated.name).toBe('Updated Name');
expect(updated.status).toBe('completed');
expect(updated.updatedAt.getTime()).toBeGreaterThan(created.updatedAt.getTime());
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(created.updatedAt.getTime());
});
it('should throw for non-existent initiative', async () => {

View File

@@ -27,17 +27,15 @@ export class DrizzleInitiativeRepository implements InitiativeRepository {
const id = nanoid();
const now = new Date();
await this.db.insert(initiatives).values({
const [created] = await this.db.insert(initiatives).values({
id,
...data,
status: data.status ?? 'active',
createdAt: now,
updatedAt: now,
});
}).returning();
// Fetch to get the complete record with all defaults applied
const created = await this.findById(id);
return created!;
return created;
}
async findById(id: string): Promise<Initiative | null> {
@@ -62,27 +60,24 @@ export class DrizzleInitiativeRepository implements InitiativeRepository {
}
async update(id: string, data: UpdateInitiativeData): Promise<Initiative> {
const existing = await this.findById(id);
if (!existing) {
const [updated] = await this.db
.update(initiatives)
.set({ ...data, updatedAt: new Date() })
.where(eq(initiatives.id, id))
.returning();
if (!updated) {
throw new Error(`Initiative not found: ${id}`);
}
const updated = {
...data,
updatedAt: new Date(),
};
await this.db.update(initiatives).set(updated).where(eq(initiatives.id, id));
return { ...existing, ...updated } as Initiative;
return updated;
}
async delete(id: string): Promise<void> {
const existing = await this.findById(id);
if (!existing) {
const [deleted] = await this.db.delete(initiatives).where(eq(initiatives.id, id)).returning();
if (!deleted) {
throw new Error(`Initiative not found: ${id}`);
}
await this.db.delete(initiatives).where(eq(initiatives.id, id));
}
}

View File

@@ -8,7 +8,6 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { DrizzleMessageRepository } from './message.js';
import { DrizzleAgentRepository } from './agent.js';
import { DrizzleTaskRepository } from './task.js';
import { DrizzlePlanRepository } from './plan.js';
import { DrizzlePhaseRepository } from './phase.js';
import { DrizzleInitiativeRepository } from './initiative.js';
import { createTestDatabase } from './test-helpers.js';
@@ -27,7 +26,6 @@ describe('DrizzleMessageRepository', () => {
// Create required hierarchy for agent FK
const taskRepo = new DrizzleTaskRepository(db);
const planRepo = new DrizzlePlanRepository(db);
const phaseRepo = new DrizzlePhaseRepository(db);
const initiativeRepo = new DrizzleInitiativeRepository(db);
@@ -39,13 +37,8 @@ describe('DrizzleMessageRepository', () => {
number: 1,
name: 'Test Phase',
});
const plan = await planRepo.create({
phaseId: phase.id,
number: 1,
name: 'Test Plan',
});
const task = await taskRepo.create({
planId: plan.id,
phaseId: phase.id,
name: 'Test Task',
order: 1,
});
@@ -398,7 +391,7 @@ describe('DrizzleMessageRepository', () => {
// Update to read
const readMessage = await messageRepo.update(message.id, { status: 'read' });
expect(readMessage.status).toBe('read');
expect(readMessage.updatedAt.getTime()).toBeGreaterThan(message.updatedAt.getTime());
expect(readMessage.updatedAt.getTime()).toBeGreaterThanOrEqual(message.updatedAt.getTime());
// Wait again
await new Promise((resolve) => setTimeout(resolve, 10));
@@ -408,7 +401,7 @@ describe('DrizzleMessageRepository', () => {
status: 'responded',
});
expect(respondedMessage.status).toBe('responded');
expect(respondedMessage.updatedAt.getTime()).toBeGreaterThan(
expect(respondedMessage.updatedAt.getTime()).toBeGreaterThanOrEqual(
readMessage.updatedAt.getTime()
);
});
@@ -436,7 +429,7 @@ describe('DrizzleMessageRepository', () => {
});
expect(updated.content).toBe('Updated content');
expect(updated.updatedAt.getTime()).toBeGreaterThan(created.updatedAt.getTime());
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(created.updatedAt.getTime());
});
});

View File

@@ -28,7 +28,7 @@ export class DrizzleMessageRepository implements MessageRepository {
const id = nanoid();
const now = new Date();
await this.db.insert(messages).values({
const [created] = await this.db.insert(messages).values({
id,
senderType: data.senderType,
senderId: data.senderId ?? null,
@@ -41,11 +41,9 @@ export class DrizzleMessageRepository implements MessageRepository {
parentMessageId: data.parentMessageId ?? null,
createdAt: now,
updatedAt: now,
});
}).returning();
// Fetch to get the complete record with all defaults applied
const created = await this.findById(id);
return created!;
return created;
}
async findById(id: string): Promise<Message | null> {
@@ -117,27 +115,24 @@ export class DrizzleMessageRepository implements MessageRepository {
}
async update(id: string, data: UpdateMessageData): Promise<Message> {
const existing = await this.findById(id);
if (!existing) {
const [updated] = await this.db
.update(messages)
.set({ ...data, updatedAt: new Date() })
.where(eq(messages.id, id))
.returning();
if (!updated) {
throw new Error(`Message not found: ${id}`);
}
const updated = {
...data,
updatedAt: new Date(),
};
await this.db.update(messages).set(updated).where(eq(messages.id, id));
return { ...existing, ...updated } as Message;
return updated;
}
async delete(id: string): Promise<void> {
const existing = await this.findById(id);
if (!existing) {
const [deleted] = await this.db.delete(messages).where(eq(messages.id, id)).returning();
if (!deleted) {
throw new Error(`Message not found: ${id}`);
}
await this.db.delete(messages).where(eq(messages.id, id));
}
}

View File

@@ -0,0 +1,109 @@
/**
* Drizzle Page Repository Adapter
*
* Implements PageRepository interface using Drizzle ORM.
*/
import { eq, isNull, and, asc } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { pages, type Page } from '../../schema.js';
import type {
PageRepository,
CreatePageData,
UpdatePageData,
} from '../page-repository.js';
export class DrizzlePageRepository implements PageRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreatePageData): Promise<Page> {
const id = nanoid();
const now = new Date();
const [created] = await this.db.insert(pages).values({
id,
...data,
createdAt: now,
updatedAt: now,
}).returning();
return created;
}
async findById(id: string): Promise<Page | null> {
const result = await this.db
.select()
.from(pages)
.where(eq(pages.id, id))
.limit(1);
return result[0] ?? null;
}
async findByInitiativeId(initiativeId: string): Promise<Page[]> {
return this.db
.select()
.from(pages)
.where(eq(pages.initiativeId, initiativeId))
.orderBy(asc(pages.sortOrder));
}
async findByParentPageId(parentPageId: string): Promise<Page[]> {
return this.db
.select()
.from(pages)
.where(eq(pages.parentPageId, parentPageId))
.orderBy(asc(pages.sortOrder));
}
async findRootPage(initiativeId: string): Promise<Page | null> {
const result = await this.db
.select()
.from(pages)
.where(
and(
eq(pages.initiativeId, initiativeId),
isNull(pages.parentPageId),
),
)
.limit(1);
return result[0] ?? null;
}
async getOrCreateRootPage(initiativeId: string): Promise<Page> {
const existing = await this.findRootPage(initiativeId);
if (existing) return existing;
return this.create({
initiativeId,
parentPageId: null,
title: 'Untitled',
content: null,
sortOrder: 0,
});
}
async update(id: string, data: UpdatePageData): Promise<Page> {
const [updated] = await this.db
.update(pages)
.set({ ...data, updatedAt: new Date() })
.where(eq(pages.id, id))
.returning();
if (!updated) {
throw new Error(`Page not found: ${id}`);
}
return updated;
}
async delete(id: string): Promise<void> {
const [deleted] = await this.db.delete(pages).where(eq(pages.id, id)).returning();
if (!deleted) {
throw new Error(`Page not found: ${id}`);
}
}
}

View File

@@ -138,7 +138,7 @@ describe('DrizzlePhaseRepository', () => {
expect(updated.name).toBe('Updated Name');
expect(updated.status).toBe('in_progress');
expect(updated.updatedAt.getTime()).toBeGreaterThan(created.updatedAt.getTime());
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(created.updatedAt.getTime());
});
it('should throw for non-existent phase', async () => {

View File

@@ -27,17 +27,15 @@ export class DrizzlePhaseRepository implements PhaseRepository {
const id = nanoid();
const now = new Date();
await this.db.insert(phases).values({
const [created] = await this.db.insert(phases).values({
id,
...data,
status: data.status ?? 'pending',
createdAt: now,
updatedAt: now,
});
}).returning();
// Fetch to get the complete record with all defaults applied
const created = await this.findById(id);
return created!;
return created;
}
async findById(id: string): Promise<Phase | null> {
@@ -79,28 +77,25 @@ export class DrizzlePhaseRepository implements PhaseRepository {
}
async update(id: string, data: UpdatePhaseData): Promise<Phase> {
const existing = await this.findById(id);
if (!existing) {
const [updated] = await this.db
.update(phases)
.set({ ...data, updatedAt: new Date() })
.where(eq(phases.id, id))
.returning();
if (!updated) {
throw new Error(`Phase not found: ${id}`);
}
const updated = {
...data,
updatedAt: new Date(),
};
await this.db.update(phases).set(updated).where(eq(phases.id, id));
return { ...existing, ...updated } as Phase;
return updated;
}
async delete(id: string): Promise<void> {
const existing = await this.findById(id);
if (!existing) {
const [deleted] = await this.db.delete(phases).where(eq(phases.id, id)).returning();
if (!deleted) {
throw new Error(`Phase not found: ${id}`);
}
await this.db.delete(phases).where(eq(phases.id, id));
}
async createDependency(phaseId: string, dependsOnPhaseId: string): Promise<void> {

View File

@@ -1,204 +0,0 @@
/**
* DrizzlePlanRepository Tests
*
* Tests for the Plan repository adapter.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { DrizzlePlanRepository } from './plan.js';
import { DrizzlePhaseRepository } from './phase.js';
import { DrizzleInitiativeRepository } from './initiative.js';
import { createTestDatabase } from './test-helpers.js';
import type { DrizzleDatabase } from '../../index.js';
describe('DrizzlePlanRepository', () => {
let db: DrizzleDatabase;
let planRepo: DrizzlePlanRepository;
let phaseRepo: DrizzlePhaseRepository;
let initiativeRepo: DrizzleInitiativeRepository;
let testPhaseId: string;
beforeEach(async () => {
db = createTestDatabase();
planRepo = new DrizzlePlanRepository(db);
phaseRepo = new DrizzlePhaseRepository(db);
initiativeRepo = new DrizzleInitiativeRepository(db);
// Create test initiative and phase for FK constraint
const initiative = await initiativeRepo.create({
name: 'Test Initiative',
});
const phase = await phaseRepo.create({
initiativeId: initiative.id,
number: 1,
name: 'Test Phase',
});
testPhaseId = phase.id;
});
describe('create', () => {
it('should create a plan with generated id and timestamps', async () => {
const plan = await planRepo.create({
phaseId: testPhaseId,
number: 1,
name: 'Test Plan',
description: 'A test plan',
});
expect(plan.id).toBeDefined();
expect(plan.id.length).toBeGreaterThan(0);
expect(plan.phaseId).toBe(testPhaseId);
expect(plan.number).toBe(1);
expect(plan.name).toBe('Test Plan');
expect(plan.status).toBe('pending');
expect(plan.createdAt).toBeInstanceOf(Date);
expect(plan.updatedAt).toBeInstanceOf(Date);
});
it('should throw for invalid phaseId (FK constraint)', async () => {
await expect(
planRepo.create({
phaseId: 'invalid-phase-id',
number: 1,
name: 'Invalid Plan',
})
).rejects.toThrow();
});
});
describe('findById', () => {
it('should return null for non-existent plan', async () => {
const result = await planRepo.findById('non-existent-id');
expect(result).toBeNull();
});
it('should find an existing plan', async () => {
const created = await planRepo.create({
phaseId: testPhaseId,
number: 1,
name: 'Find Me',
});
const found = await planRepo.findById(created.id);
expect(found).not.toBeNull();
expect(found!.id).toBe(created.id);
expect(found!.name).toBe('Find Me');
});
});
describe('findByPhaseId', () => {
it('should return empty array for phase with no plans', async () => {
const plans = await planRepo.findByPhaseId(testPhaseId);
expect(plans).toEqual([]);
});
it('should return only matching plans ordered by number', async () => {
// Create plans out of order
await planRepo.create({
phaseId: testPhaseId,
number: 3,
name: 'Plan 3',
});
await planRepo.create({
phaseId: testPhaseId,
number: 1,
name: 'Plan 1',
});
await planRepo.create({
phaseId: testPhaseId,
number: 2,
name: 'Plan 2',
});
const plans = await planRepo.findByPhaseId(testPhaseId);
expect(plans.length).toBe(3);
expect(plans[0].name).toBe('Plan 1');
expect(plans[1].name).toBe('Plan 2');
expect(plans[2].name).toBe('Plan 3');
});
});
describe('getNextNumber', () => {
it('should return 1 for phase with no plans', async () => {
const nextNumber = await planRepo.getNextNumber(testPhaseId);
expect(nextNumber).toBe(1);
});
it('should return max + 1 for phase with plans', async () => {
await planRepo.create({ phaseId: testPhaseId, number: 1, name: 'Plan 1' });
await planRepo.create({ phaseId: testPhaseId, number: 5, name: 'Plan 5' });
const nextNumber = await planRepo.getNextNumber(testPhaseId);
expect(nextNumber).toBe(6);
});
it('should not be affected by plans in other phases', async () => {
// Create another phase
const initiative = await initiativeRepo.create({ name: 'Another Initiative' });
const otherPhase = await phaseRepo.create({
initiativeId: initiative.id,
number: 2,
name: 'Other Phase',
});
// Add plans to other phase
await planRepo.create({ phaseId: otherPhase.id, number: 10, name: 'High Plan' });
// Add a plan to test phase
await planRepo.create({ phaseId: testPhaseId, number: 3, name: 'Plan 3' });
// Next number for test phase should be 4, not 11
const nextNumber = await planRepo.getNextNumber(testPhaseId);
expect(nextNumber).toBe(4);
});
});
describe('update', () => {
it('should update fields and updatedAt', async () => {
const created = await planRepo.create({
phaseId: testPhaseId,
number: 1,
name: 'Original Name',
status: 'pending',
});
await new Promise((resolve) => setTimeout(resolve, 10));
const updated = await planRepo.update(created.id, {
name: 'Updated Name',
status: 'in_progress',
});
expect(updated.name).toBe('Updated Name');
expect(updated.status).toBe('in_progress');
expect(updated.updatedAt.getTime()).toBeGreaterThan(created.updatedAt.getTime());
});
it('should throw for non-existent plan', async () => {
await expect(
planRepo.update('non-existent-id', { name: 'New Name' })
).rejects.toThrow('Plan not found');
});
});
describe('delete', () => {
it('should delete an existing plan', async () => {
const created = await planRepo.create({
phaseId: testPhaseId,
number: 1,
name: 'To Delete',
});
await planRepo.delete(created.id);
const found = await planRepo.findById(created.id);
expect(found).toBeNull();
});
it('should throw for non-existent plan', async () => {
await expect(planRepo.delete('non-existent-id')).rejects.toThrow(
'Plan not found'
);
});
});
});

View File

@@ -1,95 +0,0 @@
/**
* Drizzle Plan Repository Adapter
*
* Implements PlanRepository interface using Drizzle ORM.
*/
import { eq, asc, max } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { plans, type Plan } from '../../schema.js';
import type {
PlanRepository,
CreatePlanData,
UpdatePlanData,
} from '../plan-repository.js';
/**
* Drizzle adapter for PlanRepository.
*
* Uses dependency injection for database instance,
* enabling isolated test databases.
*/
export class DrizzlePlanRepository implements PlanRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreatePlanData): Promise<Plan> {
const id = nanoid();
const now = new Date();
await this.db.insert(plans).values({
id,
...data,
status: data.status ?? 'pending',
createdAt: now,
updatedAt: now,
});
// Fetch to get the complete record with all defaults applied
const created = await this.findById(id);
return created!;
}
async findById(id: string): Promise<Plan | null> {
const result = await this.db
.select()
.from(plans)
.where(eq(plans.id, id))
.limit(1);
return result[0] ?? null;
}
async findByPhaseId(phaseId: string): Promise<Plan[]> {
return this.db
.select()
.from(plans)
.where(eq(plans.phaseId, phaseId))
.orderBy(asc(plans.number));
}
async getNextNumber(phaseId: string): Promise<number> {
const result = await this.db
.select({ maxNumber: max(plans.number) })
.from(plans)
.where(eq(plans.phaseId, phaseId));
const maxNumber = result[0]?.maxNumber ?? 0;
return maxNumber + 1;
}
async update(id: string, data: UpdatePlanData): Promise<Plan> {
const existing = await this.findById(id);
if (!existing) {
throw new Error(`Plan not found: ${id}`);
}
const updated = {
...data,
updatedAt: new Date(),
};
await this.db.update(plans).set(updated).where(eq(plans.id, id));
return { ...existing, ...updated } as Plan;
}
async delete(id: string): Promise<void> {
const existing = await this.findById(id);
if (!existing) {
throw new Error(`Plan not found: ${id}`);
}
await this.db.delete(plans).where(eq(plans.id, id));
}
}

View File

@@ -0,0 +1,154 @@
/**
* Drizzle Project Repository Adapter
*
* Implements ProjectRepository interface using Drizzle ORM.
*/
import { eq, and, inArray } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { projects, initiativeProjects, type Project } from '../../schema.js';
import type {
ProjectRepository,
CreateProjectData,
UpdateProjectData,
} from '../project-repository.js';
export class DrizzleProjectRepository implements ProjectRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreateProjectData): Promise<Project> {
const id = nanoid();
const now = new Date();
const [created] = await this.db.insert(projects).values({
id,
...data,
createdAt: now,
updatedAt: now,
}).returning();
return created;
}
async findById(id: string): Promise<Project | null> {
const result = await this.db
.select()
.from(projects)
.where(eq(projects.id, id))
.limit(1);
return result[0] ?? null;
}
async findByName(name: string): Promise<Project | null> {
const result = await this.db
.select()
.from(projects)
.where(eq(projects.name, name))
.limit(1);
return result[0] ?? null;
}
async findAll(): Promise<Project[]> {
return this.db.select().from(projects);
}
async update(id: string, data: UpdateProjectData): Promise<Project> {
const [updated] = await this.db
.update(projects)
.set({ ...data, updatedAt: new Date() })
.where(eq(projects.id, id))
.returning();
if (!updated) {
throw new Error(`Project not found: ${id}`);
}
return updated;
}
async delete(id: string): Promise<void> {
const [deleted] = await this.db.delete(projects).where(eq(projects.id, id)).returning();
if (!deleted) {
throw new Error(`Project not found: ${id}`);
}
}
// Junction ops
async addProjectToInitiative(initiativeId: string, projectId: string): Promise<void> {
const id = nanoid();
const now = new Date();
await this.db.insert(initiativeProjects).values({
id,
initiativeId,
projectId,
createdAt: now,
});
}
async removeProjectFromInitiative(initiativeId: string, projectId: string): Promise<void> {
await this.db
.delete(initiativeProjects)
.where(
and(
eq(initiativeProjects.initiativeId, initiativeId),
eq(initiativeProjects.projectId, projectId),
),
);
}
async findProjectsByInitiativeId(initiativeId: string): Promise<Project[]> {
const rows = await this.db
.select({ project: projects })
.from(initiativeProjects)
.innerJoin(projects, eq(initiativeProjects.projectId, projects.id))
.where(eq(initiativeProjects.initiativeId, initiativeId));
return rows.map((r) => r.project);
}
async setInitiativeProjects(initiativeId: string, projectIds: string[]): Promise<void> {
// Get current associations
const currentRows = await this.db
.select({ projectId: initiativeProjects.projectId })
.from(initiativeProjects)
.where(eq(initiativeProjects.initiativeId, initiativeId));
const currentIds = new Set(currentRows.map((r) => r.projectId));
const desiredIds = new Set(projectIds);
// Compute diff
const toRemove = [...currentIds].filter((id) => !desiredIds.has(id));
const toAdd = [...desiredIds].filter((id) => !currentIds.has(id));
// Remove
if (toRemove.length > 0) {
await this.db
.delete(initiativeProjects)
.where(
and(
eq(initiativeProjects.initiativeId, initiativeId),
inArray(initiativeProjects.projectId, toRemove),
),
);
}
// Add
if (toAdd.length > 0) {
const now = new Date();
await this.db.insert(initiativeProjects).values(
toAdd.map((projectId) => ({
id: nanoid(),
initiativeId,
projectId,
createdAt: now,
})),
);
}
}
}

View File

@@ -6,7 +6,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { DrizzleTaskRepository } from './task.js';
import { DrizzlePlanRepository } from './plan.js';
import { DrizzlePhaseRepository } from './phase.js';
import { DrizzleInitiativeRepository } from './initiative.js';
import { createTestDatabase } from './test-helpers.js';
@@ -15,15 +14,15 @@ import type { DrizzleDatabase } from '../../index.js';
describe('DrizzleTaskRepository', () => {
let db: DrizzleDatabase;
let taskRepo: DrizzleTaskRepository;
let planRepo: DrizzlePlanRepository;
let phaseRepo: DrizzlePhaseRepository;
let initiativeRepo: DrizzleInitiativeRepository;
let testPlanId: string;
let testPhaseId: string;
beforeEach(async () => {
db = createTestDatabase();
taskRepo = new DrizzleTaskRepository(db);
planRepo = new DrizzlePlanRepository(db);
phaseRepo = new DrizzlePhaseRepository(db);
initiativeRepo = new DrizzleInitiativeRepository(db);
@@ -36,18 +35,13 @@ describe('DrizzleTaskRepository', () => {
number: 1,
name: 'Test Phase',
});
const plan = await planRepo.create({
phaseId: phase.id,
number: 1,
name: 'Test Plan',
});
testPlanId = plan.id;
testPhaseId = phase.id;
});
describe('create', () => {
it('should create a task with generated id and timestamps', async () => {
const task = await taskRepo.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Test Task',
description: 'A test task',
order: 1,
@@ -55,7 +49,7 @@ describe('DrizzleTaskRepository', () => {
expect(task.id).toBeDefined();
expect(task.id.length).toBeGreaterThan(0);
expect(task.planId).toBe(testPlanId);
expect(task.phaseId).toBe(testPhaseId);
expect(task.name).toBe('Test Task');
expect(task.type).toBe('auto');
expect(task.priority).toBe('medium');
@@ -65,10 +59,10 @@ describe('DrizzleTaskRepository', () => {
expect(task.updatedAt).toBeInstanceOf(Date);
});
it('should throw for invalid planId (FK constraint)', async () => {
it('should throw for invalid phaseId (FK constraint)', async () => {
await expect(
taskRepo.create({
planId: 'invalid-plan-id',
phaseId: 'invalid-phase-id',
name: 'Invalid Task',
order: 1,
})
@@ -77,7 +71,7 @@ describe('DrizzleTaskRepository', () => {
it('should accept custom type and priority', async () => {
const task = await taskRepo.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Checkpoint Task',
type: 'checkpoint:human-verify',
priority: 'high',
@@ -97,7 +91,7 @@ describe('DrizzleTaskRepository', () => {
it('should find an existing task', async () => {
const created = await taskRepo.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Find Me',
order: 1,
});
@@ -109,31 +103,31 @@ describe('DrizzleTaskRepository', () => {
});
});
describe('findByPlanId', () => {
it('should return empty array for plan with no tasks', async () => {
const tasks = await taskRepo.findByPlanId(testPlanId);
describe('findByPhaseId', () => {
it('should return empty array for phase with no tasks', async () => {
const tasks = await taskRepo.findByPhaseId(testPhaseId);
expect(tasks).toEqual([]);
});
it('should return only matching tasks ordered by order field', async () => {
// Create tasks out of order
await taskRepo.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Task 3',
order: 3,
});
await taskRepo.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Task 1',
order: 1,
});
await taskRepo.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Task 2',
order: 2,
});
const tasks = await taskRepo.findByPlanId(testPlanId);
const tasks = await taskRepo.findByPhaseId(testPhaseId);
expect(tasks.length).toBe(3);
expect(tasks[0].name).toBe('Task 1');
expect(tasks[1].name).toBe('Task 2');
@@ -144,7 +138,7 @@ describe('DrizzleTaskRepository', () => {
describe('update', () => {
it('should update status correctly', async () => {
const created = await taskRepo.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Status Test',
status: 'pending',
order: 1,
@@ -159,7 +153,7 @@ describe('DrizzleTaskRepository', () => {
it('should update fields and updatedAt', async () => {
const created = await taskRepo.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Original Name',
order: 1,
});
@@ -173,7 +167,7 @@ describe('DrizzleTaskRepository', () => {
expect(updated.name).toBe('Updated Name');
expect(updated.priority).toBe('low');
expect(updated.updatedAt.getTime()).toBeGreaterThan(created.updatedAt.getTime());
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(created.updatedAt.getTime());
});
it('should throw for non-existent task', async () => {
@@ -186,7 +180,7 @@ describe('DrizzleTaskRepository', () => {
describe('delete', () => {
it('should delete an existing task', async () => {
const created = await taskRepo.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'To Delete',
order: 1,
});

View File

@@ -4,7 +4,7 @@
* Implements TaskRepository interface using Drizzle ORM.
*/
import { eq, asc } from 'drizzle-orm';
import { eq, asc, and } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { tasks, taskDependencies, type Task } from '../../schema.js';
@@ -12,6 +12,7 @@ import type {
TaskRepository,
CreateTaskData,
UpdateTaskData,
PendingApprovalFilters,
} from '../task-repository.js';
/**
@@ -27,20 +28,19 @@ export class DrizzleTaskRepository implements TaskRepository {
const id = nanoid();
const now = new Date();
await this.db.insert(tasks).values({
const [created] = await this.db.insert(tasks).values({
id,
...data,
type: data.type ?? 'auto',
category: data.category ?? 'execute',
priority: data.priority ?? 'medium',
status: data.status ?? 'pending',
order: data.order ?? 0,
createdAt: now,
updatedAt: now,
});
}).returning();
// Fetch to get the complete record with all defaults applied
const created = await this.findById(id);
return created!;
return created;
}
async findById(id: string): Promise<Task | null> {
@@ -53,37 +53,70 @@ export class DrizzleTaskRepository implements TaskRepository {
return result[0] ?? null;
}
async findByPlanId(planId: string): Promise<Task[]> {
async findByParentTaskId(parentTaskId: string): Promise<Task[]> {
return this.db
.select()
.from(tasks)
.where(eq(tasks.planId, planId))
.where(eq(tasks.parentTaskId, parentTaskId))
.orderBy(asc(tasks.order));
}
async findByInitiativeId(initiativeId: string): Promise<Task[]> {
return this.db
.select()
.from(tasks)
.where(eq(tasks.initiativeId, initiativeId))
.orderBy(asc(tasks.order));
}
async findByPhaseId(phaseId: string): Promise<Task[]> {
return this.db
.select()
.from(tasks)
.where(eq(tasks.phaseId, phaseId))
.orderBy(asc(tasks.order));
}
async findPendingApproval(filters?: PendingApprovalFilters): Promise<Task[]> {
const conditions = [eq(tasks.status, 'pending_approval')];
if (filters?.initiativeId) {
conditions.push(eq(tasks.initiativeId, filters.initiativeId));
}
if (filters?.phaseId) {
conditions.push(eq(tasks.phaseId, filters.phaseId));
}
if (filters?.category) {
conditions.push(eq(tasks.category, filters.category));
}
return this.db
.select()
.from(tasks)
.where(and(...conditions))
.orderBy(asc(tasks.createdAt));
}
async update(id: string, data: UpdateTaskData): Promise<Task> {
const existing = await this.findById(id);
if (!existing) {
const [updated] = await this.db
.update(tasks)
.set({ ...data, updatedAt: new Date() })
.where(eq(tasks.id, id))
.returning();
if (!updated) {
throw new Error(`Task not found: ${id}`);
}
const updated = {
...data,
updatedAt: new Date(),
};
await this.db.update(tasks).set(updated).where(eq(tasks.id, id));
return { ...existing, ...updated } as Task;
return updated;
}
async delete(id: string): Promise<void> {
const existing = await this.findById(id);
if (!existing) {
const [deleted] = await this.db.delete(tasks).where(eq(tasks.id, id)).returning();
if (!deleted) {
throw new Error(`Task not found: ${id}`);
}
await this.db.delete(tasks).where(eq(tasks.id, id));
}
async createDependency(taskId: string, dependsOnTaskId: string): Promise<void> {
@@ -97,4 +130,13 @@ export class DrizzleTaskRepository implements TaskRepository {
createdAt: now,
});
}
async getDependencies(taskId: string): Promise<string[]> {
const deps = await this.db
.select({ dependsOnTaskId: taskDependencies.dependsOnTaskId })
.from(taskDependencies)
.where(eq(taskDependencies.taskId, taskId));
return deps.map((d) => d.dependsOnTaskId);
}
}

View File

@@ -18,16 +18,11 @@ export type {
UpdatePhaseData,
} from './phase-repository.js';
export type {
PlanRepository,
CreatePlanData,
UpdatePlanData,
} from './plan-repository.js';
export type {
TaskRepository,
CreateTaskData,
UpdateTaskData,
PendingApprovalFilters,
} from './task-repository.js';
export type {
@@ -44,3 +39,20 @@ export type {
CreateMessageData,
UpdateMessageData,
} from './message-repository.js';
export type {
PageRepository,
CreatePageData,
UpdatePageData,
} from './page-repository.js';
export type {
ProjectRepository,
CreateProjectData,
UpdateProjectData,
} from './project-repository.js';
export type {
AccountRepository,
CreateAccountData,
} from './account-repository.js';

View File

@@ -0,0 +1,33 @@
/**
* Page Repository Port Interface
*
* Port for Page aggregate operations.
* Implementations (Drizzle, etc.) are adapters.
*/
import type { Page, NewPage } from '../schema.js';
/**
* Data for creating a new page.
* Omits system-managed fields (id, createdAt, updatedAt).
*/
export type CreatePageData = Omit<NewPage, 'id' | 'createdAt' | 'updatedAt'>;
/**
* Data for updating a page.
*/
export type UpdatePageData = Partial<Pick<NewPage, 'title' | 'content' | 'sortOrder'>>;
/**
* Page Repository Port
*/
export interface PageRepository {
create(data: CreatePageData): Promise<Page>;
findById(id: string): Promise<Page | null>;
findByInitiativeId(initiativeId: string): Promise<Page[]>;
findByParentPageId(parentPageId: string): Promise<Page[]>;
findRootPage(initiativeId: string): Promise<Page | null>;
getOrCreateRootPage(initiativeId: string): Promise<Page>;
update(id: string, data: UpdatePageData): Promise<Page>;
delete(id: string): Promise<void>;
}

View File

@@ -1,68 +0,0 @@
/**
* Plan Repository Port Interface
*
* Port for Plan aggregate operations.
* Implementations (Drizzle, etc.) are adapters.
*/
import type { Plan, NewPlan } from '../schema.js';
/**
* Data for creating a new plan.
* Omits system-managed fields (id, createdAt, updatedAt).
*/
export type CreatePlanData = Omit<NewPlan, 'id' | 'createdAt' | 'updatedAt'>;
/**
* Data for updating a plan.
* Partial of creation data - all fields optional.
*/
export type UpdatePlanData = Partial<CreatePlanData>;
/**
* Plan Repository Port
*
* Defines operations for the Plan aggregate.
* Only knows about plans - no knowledge of parent or child entities.
*/
export interface PlanRepository {
/**
* Create a new plan.
* Generates id and sets timestamps automatically.
* Foreign key to phase enforced by database.
*/
create(data: CreatePlanData): Promise<Plan>;
/**
* Find a plan by its ID.
* Returns null if not found.
*/
findById(id: string): Promise<Plan | null>;
/**
* Find all plans for a phase.
* Returns plans ordered by number.
* Returns empty array if none exist.
*/
findByPhaseId(phaseId: string): Promise<Plan[]>;
/**
* Get the next available plan number for a phase.
* Returns MAX(number) + 1, or 1 if no plans exist.
*/
getNextNumber(phaseId: string): Promise<number>;
/**
* Update a plan.
* Throws if plan not found.
* Updates updatedAt timestamp automatically.
*/
update(id: string, data: UpdatePlanData): Promise<Plan>;
/**
* Delete a plan.
* Throws if plan not found.
* Cascades to child tasks via FK constraints.
*/
delete(id: string): Promise<void>;
}

View File

@@ -0,0 +1,38 @@
/**
* Project Repository Port Interface
*
* Port for Project aggregate operations and initiative-project junction.
* Implementations (Drizzle, etc.) are adapters.
*/
import type { Project, NewProject } from '../schema.js';
/**
* Data for creating a new project.
* Omits system-managed fields (id, createdAt, updatedAt).
*/
export type CreateProjectData = Omit<NewProject, 'id' | 'createdAt' | 'updatedAt'>;
/**
* Data for updating a project.
* Name is immutable (used as directory name for worktrees).
*/
export type UpdateProjectData = Omit<Partial<CreateProjectData>, 'name'>;
/**
* Project Repository Port
*/
export interface ProjectRepository {
create(data: CreateProjectData): Promise<Project>;
findById(id: string): Promise<Project | null>;
findByName(name: string): Promise<Project | null>;
findAll(): Promise<Project[]>;
update(id: string, data: UpdateProjectData): Promise<Project>;
delete(id: string): Promise<void>;
// Junction ops
addProjectToInitiative(initiativeId: string, projectId: string): Promise<void>;
removeProjectFromInitiative(initiativeId: string, projectId: string): Promise<void>;
findProjectsByInitiativeId(initiativeId: string): Promise<Project[]>;
setInitiativeProjects(initiativeId: string, projectIds: string[]): Promise<void>;
}

View File

@@ -5,11 +5,12 @@
* Implementations (Drizzle, etc.) are adapters.
*/
import type { Task, NewTask } from '../schema.js';
import type { Task, NewTask, TaskCategory } from '../schema.js';
/**
* Data for creating a new task.
* Omits system-managed fields (id, createdAt, updatedAt).
* At least one of phaseId, initiativeId, or parentTaskId should be provided.
*/
export type CreateTaskData = Omit<NewTask, 'id' | 'createdAt' | 'updatedAt'>;
@@ -19,6 +20,15 @@ export type CreateTaskData = Omit<NewTask, 'id' | 'createdAt' | 'updatedAt'>;
*/
export type UpdateTaskData = Partial<CreateTaskData>;
/**
* Filters for finding pending approval tasks.
*/
export interface PendingApprovalFilters {
initiativeId?: string;
phaseId?: string;
category?: TaskCategory;
}
/**
* Task Repository Port
*
@@ -29,7 +39,7 @@ export interface TaskRepository {
/**
* Create a new task.
* Generates id and sets timestamps automatically.
* Foreign key to plan enforced by database.
* At least one parent context (phaseId, initiativeId, or parentTaskId) should be set.
*/
create(data: CreateTaskData): Promise<Task>;
@@ -40,11 +50,32 @@ export interface TaskRepository {
findById(id: string): Promise<Task | null>;
/**
* Find all tasks for a plan.
* Find all child tasks of a parent task.
* Returns tasks ordered by order field.
* Returns empty array if none exist.
*/
findByPlanId(planId: string): Promise<Task[]>;
findByParentTaskId(parentTaskId: string): Promise<Task[]>;
/**
* Find all tasks directly linked to an initiative.
* Returns tasks ordered by order field.
* Returns empty array if none exist.
*/
findByInitiativeId(initiativeId: string): Promise<Task[]>;
/**
* Find all tasks directly linked to a phase.
* Returns tasks ordered by order field.
* Returns empty array if none exist.
*/
findByPhaseId(phaseId: string): Promise<Task[]>;
/**
* Find all tasks with status 'pending_approval'.
* Optional filters by initiative, phase, or category.
* Returns tasks ordered by createdAt.
*/
findPendingApproval(filters?: PendingApprovalFilters): Promise<Task[]>;
/**
* Update a task.
@@ -65,4 +96,10 @@ export interface TaskRepository {
* Both tasks must exist.
*/
createDependency(taskId: string, dependsOnTaskId: string): Promise<void>;
/**
* Get all task IDs that a task depends on.
* Returns empty array if no dependencies.
*/
getDependencies(taskId: string): Promise<string[]>;
}

View File

@@ -1,16 +1,15 @@
/**
* Database schema for Codewalk District.
*
* Defines the four-level task hierarchy:
* Defines the three-level task hierarchy:
* - Initiative: Top-level project
* - Phase: Major milestone within initiative
* - Plan: Group of related tasks within phase
* - Task: Individual work item
* - Task: Individual work item (can have parentTaskId for decomposition relationships)
*
* Plus a task_dependencies table for task dependency relationships.
*/
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { relations, type InferInsertModel, type InferSelectModel } from 'drizzle-orm';
// ============================================================================
@@ -20,16 +19,22 @@ import { relations, type InferInsertModel, type InferSelectModel } from 'drizzle
export const initiatives = sqliteTable('initiatives', {
id: text('id').primaryKey(),
name: text('name').notNull(),
description: text('description'),
status: text('status', { enum: ['active', 'completed', 'archived'] })
.notNull()
.default('active'),
mergeRequiresApproval: integer('merge_requires_approval', { mode: 'boolean' })
.notNull()
.default(true),
mergeTarget: text('merge_target'), // Target branch for merges (e.g., 'feature/xyz')
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const initiativesRelations = relations(initiatives, ({ many }) => ({
phases: many(phases),
pages: many(pages),
initiativeProjects: many(initiativeProjects),
tasks: many(tasks),
}));
export type Initiative = InferSelectModel<typeof initiatives>;
@@ -59,7 +64,7 @@ export const phasesRelations = relations(phases, ({ one, many }) => ({
fields: [phases.initiativeId],
references: [initiatives.id],
}),
plans: many(plans),
tasks: many(tasks),
// Dependencies: phases this phase depends on
dependsOn: many(phaseDependencies, { relationName: 'dependentPhase' }),
// Dependents: phases that depend on this phase
@@ -100,45 +105,35 @@ export const phaseDependenciesRelations = relations(phaseDependencies, ({ one })
export type PhaseDependency = InferSelectModel<typeof phaseDependencies>;
export type NewPhaseDependency = InferInsertModel<typeof phaseDependencies>;
// ============================================================================
// PLANS
// ============================================================================
export const plans = sqliteTable('plans', {
id: text('id').primaryKey(),
phaseId: text('phase_id')
.notNull()
.references(() => phases.id, { onDelete: 'cascade' }),
number: integer('number').notNull(),
name: text('name').notNull(),
description: text('description'),
status: text('status', { enum: ['pending', 'in_progress', 'completed'] })
.notNull()
.default('pending'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const plansRelations = relations(plans, ({ one, many }) => ({
phase: one(phases, {
fields: [plans.phaseId],
references: [phases.id],
}),
tasks: many(tasks),
}));
export type Plan = InferSelectModel<typeof plans>;
export type NewPlan = InferInsertModel<typeof plans>;
// ============================================================================
// TASKS
// ============================================================================
/**
* Task category enum values.
* Defines what kind of work a task represents.
*/
export const TASK_CATEGORIES = [
'execute', // Standard execution task
'research', // Research/exploration task
'discuss', // Discussion/context gathering
'breakdown', // Break initiative into phases
'decompose', // Decompose plan into tasks
'refine', // Refine/edit content
'verify', // Verification task
'merge', // Merge task
'review', // Review/approval task
] as const;
export type TaskCategory = (typeof TASK_CATEGORIES)[number];
export const tasks = sqliteTable('tasks', {
id: text('id').primaryKey(),
planId: text('plan_id')
.notNull()
.references(() => plans.id, { onDelete: 'cascade' }),
// Parent context - at least one should be set
phaseId: text('phase_id').references(() => phases.id, { onDelete: 'cascade' }),
initiativeId: text('initiative_id').references(() => initiatives.id, { onDelete: 'cascade' }),
// Parent task for decomposition hierarchy (child tasks link to parent decompose task)
parentTaskId: text('parent_task_id').references((): ReturnType<typeof text> => tasks.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
type: text('type', {
@@ -146,6 +141,11 @@ export const tasks = sqliteTable('tasks', {
})
.notNull()
.default('auto'),
category: text('category', {
enum: TASK_CATEGORIES,
})
.notNull()
.default('execute'),
priority: text('priority', { enum: ['low', 'medium', 'high'] })
.notNull()
.default('medium'),
@@ -154,16 +154,29 @@ export const tasks = sqliteTable('tasks', {
})
.notNull()
.default('pending'),
requiresApproval: integer('requires_approval', { mode: 'boolean' }), // null = inherit from initiative
order: integer('order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const tasksRelations = relations(tasks, ({ one, many }) => ({
plan: one(plans, {
fields: [tasks.planId],
references: [plans.id],
phase: one(phases, {
fields: [tasks.phaseId],
references: [phases.id],
}),
initiative: one(initiatives, {
fields: [tasks.initiativeId],
references: [initiatives.id],
}),
// Parent task (for decomposition hierarchy - child links to parent decompose task)
parentTask: one(tasks, {
fields: [tasks.parentTaskId],
references: [tasks.id],
relationName: 'parentTask',
}),
// Child tasks (tasks created from decomposition of this task)
childTasks: many(tasks, { relationName: 'parentTask' }),
// Dependencies: tasks this task depends on
dependsOn: many(taskDependencies, { relationName: 'dependentTask' }),
// Dependents: tasks that depend on this task
@@ -204,26 +217,59 @@ export const taskDependenciesRelations = relations(taskDependencies, ({ one }) =
export type TaskDependency = InferSelectModel<typeof taskDependencies>;
export type NewTaskDependency = InferInsertModel<typeof taskDependencies>;
// ============================================================================
// ACCOUNTS
// ============================================================================
export const accounts = sqliteTable('accounts', {
id: text('id').primaryKey(),
email: text('email').notNull(),
provider: text('provider').notNull().default('claude'),
configJson: text('config_json'), // .claude.json content (JSON string)
credentials: text('credentials'), // .credentials.json content (JSON string)
isExhausted: integer('is_exhausted', { mode: 'boolean' }).notNull().default(false),
exhaustedUntil: integer('exhausted_until', { mode: 'timestamp' }),
lastUsedAt: integer('last_used_at', { mode: 'timestamp' }),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const accountsRelations = relations(accounts, ({ many }) => ({
agents: many(agents),
}));
export type Account = InferSelectModel<typeof accounts>;
export type NewAccount = InferInsertModel<typeof accounts>;
// ============================================================================
// AGENTS
// ============================================================================
export const agents = sqliteTable('agents', {
id: text('id').primaryKey(),
name: text('name').notNull().unique(), // Human-readable name (e.g., 'gastown', 'chinatown')
name: text('name').notNull().unique(), // Human-readable alias (e.g., 'jolly-penguin')
taskId: text('task_id').references(() => tasks.id, { onDelete: 'set null' }), // Task may be deleted
initiativeId: text('initiative_id').references(() => initiatives.id, { onDelete: 'set null' }),
sessionId: text('session_id'), // Claude CLI session ID for resumption (null until first run completes)
worktreeId: text('worktree_id').notNull(), // WorktreeManager worktree ID
worktreeId: text('worktree_id').notNull(), // Agent alias (deterministic path: agent-workdirs/<alias>/)
provider: text('provider').notNull().default('claude'),
accountId: text('account_id').references(() => accounts.id, { onDelete: 'set null' }),
status: text('status', {
enum: ['idle', 'running', 'waiting_for_input', 'stopped', 'crashed'],
})
.notNull()
.default('idle'),
mode: text('mode', { enum: ['execute', 'discuss', 'breakdown', 'decompose'] })
mode: text('mode', { enum: ['execute', 'discuss', 'breakdown', 'decompose', 'refine'] })
.notNull()
.default('execute'),
pid: integer('pid'),
outputFilePath: text('output_file_path'),
result: text('result'),
pendingQuestions: text('pending_questions'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
userDismissedAt: integer('user_dismissed_at', { mode: 'timestamp' }),
});
export const agentsRelations = relations(agents, ({ one }) => ({
@@ -231,6 +277,14 @@ export const agentsRelations = relations(agents, ({ one }) => ({
fields: [agents.taskId],
references: [tasks.id],
}),
initiative: one(initiatives, {
fields: [agents.initiativeId],
references: [initiatives.id],
}),
account: one(accounts, {
fields: [agents.accountId],
references: [accounts.id],
}),
}));
export type Agent = InferSelectModel<typeof agents>;
@@ -286,3 +340,86 @@ export const messagesRelations = relations(messages, ({ one, many }) => ({
export type Message = InferSelectModel<typeof messages>;
export type NewMessage = InferInsertModel<typeof messages>;
// ============================================================================
// PAGES
// ============================================================================
export const pages = sqliteTable('pages', {
id: text('id').primaryKey(),
initiativeId: text('initiative_id')
.notNull()
.references(() => initiatives.id, { onDelete: 'cascade' }),
parentPageId: text('parent_page_id').references((): ReturnType<typeof text> => pages.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
content: text('content'), // JSON string from Tiptap
sortOrder: integer('sort_order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const pagesRelations = relations(pages, ({ one, many }) => ({
initiative: one(initiatives, {
fields: [pages.initiativeId],
references: [initiatives.id],
}),
parentPage: one(pages, {
fields: [pages.parentPageId],
references: [pages.id],
relationName: 'parentPage',
}),
childPages: many(pages, { relationName: 'parentPage' }),
}));
export type Page = InferSelectModel<typeof pages>;
export type NewPage = InferInsertModel<typeof pages>;
// ============================================================================
// PROJECTS
// ============================================================================
export const projects = sqliteTable('projects', {
id: text('id').primaryKey(),
name: text('name').notNull().unique(),
url: text('url').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const projectsRelations = relations(projects, ({ many }) => ({
initiativeProjects: many(initiativeProjects),
}));
export type Project = InferSelectModel<typeof projects>;
export type NewProject = InferInsertModel<typeof projects>;
// ============================================================================
// INITIATIVE PROJECTS (junction)
// ============================================================================
export const initiativeProjects = sqliteTable('initiative_projects', {
id: text('id').primaryKey(),
initiativeId: text('initiative_id')
.notNull()
.references(() => initiatives.id, { onDelete: 'cascade' }),
projectId: text('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
}, (table) => [
uniqueIndex('initiative_project_unique').on(table.initiativeId, table.projectId),
]);
export const initiativeProjectsRelations = relations(initiativeProjects, ({ one }) => ({
initiative: one(initiatives, {
fields: [initiativeProjects.initiativeId],
references: [initiatives.id],
}),
project: one(projects, {
fields: [initiativeProjects.projectId],
references: [projects.id],
}),
}));
export type InitiativeProject = InferSelectModel<typeof initiativeProjects>;
export type NewInitiativeProject = InferInsertModel<typeof initiativeProjects>;

View File

@@ -9,7 +9,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { DefaultDispatchManager } from './manager.js';
import { DrizzleTaskRepository } from '../db/repositories/drizzle/task.js';
import { DrizzleMessageRepository } from '../db/repositories/drizzle/message.js';
import { DrizzlePlanRepository } from '../db/repositories/drizzle/plan.js';
import { DrizzlePhaseRepository } from '../db/repositories/drizzle/phase.js';
import { DrizzleInitiativeRepository } from '../db/repositories/drizzle/initiative.js';
import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js';
@@ -59,12 +59,15 @@ function createMockAgentManager(
spawn: vi.fn().mockImplementation(async (options) => {
const newAgent: AgentInfo = {
id: `agent-${Date.now()}`,
name: options.name,
name: options.name ?? `mock-agent-${Date.now()}`,
taskId: options.taskId,
initiativeId: options.initiativeId ?? null,
sessionId: null,
worktreeId: 'worktree-test',
status: 'running',
mode: options.mode ?? 'execute',
provider: options.provider ?? 'claude',
accountId: null,
createdAt: new Date(),
updatedAt: new Date(),
};
@@ -72,9 +75,12 @@ function createMockAgentManager(
return newAgent;
}),
stop: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
dismiss: vi.fn().mockResolvedValue(undefined),
resume: vi.fn().mockResolvedValue(undefined),
getResult: vi.fn().mockResolvedValue(null),
getPendingQuestions: vi.fn().mockResolvedValue(null),
getOutputBuffer: vi.fn().mockReturnValue([]),
};
}
@@ -86,10 +92,13 @@ function createIdleAgent(id: string, name: string): AgentInfo {
id,
name,
taskId: 'task-123',
initiativeId: null,
sessionId: 'session-abc',
worktreeId: 'worktree-xyz',
status: 'idle',
mode: 'execute',
provider: 'claude',
accountId: null,
createdAt: new Date(),
updatedAt: new Date(),
};
@@ -106,7 +115,7 @@ describe('DefaultDispatchManager', () => {
let eventBus: EventBus & { emittedEvents: DomainEvent[] };
let agentManager: AgentManager;
let dispatchManager: DefaultDispatchManager;
let testPlanId: string;
let testPhaseId: string;
beforeEach(async () => {
// Set up test database
@@ -117,7 +126,7 @@ describe('DefaultDispatchManager', () => {
// Create required hierarchy for tasks
const initiativeRepo = new DrizzleInitiativeRepository(db);
const phaseRepo = new DrizzlePhaseRepository(db);
const planRepo = new DrizzlePlanRepository(db);
const initiative = await initiativeRepo.create({
name: 'Test Initiative',
@@ -127,12 +136,7 @@ describe('DefaultDispatchManager', () => {
number: 1,
name: 'Test Phase',
});
const plan = await planRepo.create({
phaseId: phase.id,
number: 1,
name: 'Test Plan',
});
testPlanId = plan.id;
testPhaseId = phase.id;
// Create mocks
eventBus = createMockEventBus();
@@ -154,7 +158,7 @@ describe('DefaultDispatchManager', () => {
describe('queue', () => {
it('should add task to queue and emit TaskQueuedEvent', async () => {
const task = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Test Task',
priority: 'high',
order: 1,
@@ -192,7 +196,7 @@ describe('DefaultDispatchManager', () => {
it('should return task when dependencies are complete', async () => {
const task = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Dispatchable Task',
priority: 'medium',
order: 1,
@@ -208,19 +212,19 @@ describe('DefaultDispatchManager', () => {
it('should respect priority ordering (high > medium > low)', async () => {
// Create tasks in different priority order
const lowTask = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Low Priority',
priority: 'low',
order: 1,
});
const highTask = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'High Priority',
priority: 'high',
order: 2,
});
const mediumTask = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Medium Priority',
priority: 'medium',
order: 3,
@@ -240,13 +244,13 @@ describe('DefaultDispatchManager', () => {
it('should order by queuedAt within same priority (oldest first)', async () => {
const task1 = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'First Task',
priority: 'medium',
order: 1,
});
const task2 = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Second Task',
priority: 'medium',
order: 2,
@@ -271,7 +275,7 @@ describe('DefaultDispatchManager', () => {
describe('completeTask', () => {
it('should update task status and emit TaskCompletedEvent', async () => {
const task = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Task to Complete',
priority: 'medium',
order: 1,
@@ -301,7 +305,7 @@ describe('DefaultDispatchManager', () => {
describe('blockTask', () => {
it('should update task status and emit TaskBlockedEvent', async () => {
const task = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Task to Block',
priority: 'medium',
order: 1,
@@ -344,7 +348,7 @@ describe('DefaultDispatchManager', () => {
it('should return failure when no agents available', async () => {
const task = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Task needing agent',
priority: 'high',
order: 1,
@@ -363,7 +367,7 @@ describe('DefaultDispatchManager', () => {
it('should dispatch task to available agent', async () => {
// Create task
const task = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Task for dispatch',
description: 'Do the thing',
priority: 'high',
@@ -406,7 +410,7 @@ describe('DefaultDispatchManager', () => {
it('should emit TaskDispatchedEvent on successful dispatch', async () => {
const task = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Dispatch event test',
priority: 'medium',
order: 1,
@@ -442,19 +446,19 @@ describe('DefaultDispatchManager', () => {
it('should return correct state', async () => {
// Create and queue tasks
const task1 = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Ready Task',
priority: 'high',
order: 1,
});
const task2 = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Another Ready Task',
priority: 'low',
order: 2,
});
const task3 = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Blocked Task',
priority: 'medium',
order: 3,
@@ -497,19 +501,19 @@ describe('DefaultDispatchManager', () => {
// This test verifies the priority and queue ordering work correctly
const taskA = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Task A - Foundation',
priority: 'high',
order: 1,
});
const taskB = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Task B - Build on A',
priority: 'medium',
order: 2,
});
const taskC = await taskRepository.create({
planId: testPlanId,
phaseId: testPhaseId,
name: 'Task C - Also build on A',
priority: 'medium',
order: 3,

View File

@@ -7,11 +7,23 @@
* This is the ADAPTER for the DispatchManager PORT.
*/
import type { EventBus, TaskQueuedEvent, TaskCompletedEvent, TaskBlockedEvent, TaskDispatchedEvent } from '../events/index.js';
import type {
EventBus,
TaskQueuedEvent,
TaskCompletedEvent,
TaskBlockedEvent,
TaskDispatchedEvent,
TaskPendingApprovalEvent,
} from '../events/index.js';
import type { AgentManager } from '../agent/types.js';
import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { MessageRepository } from '../db/repositories/message-repository.js';
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
import type { Task } from '../db/schema.js';
import type { DispatchManager, QueuedTask, DispatchResult } from './types.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('dispatch');
// =============================================================================
// Internal Types
@@ -46,12 +58,14 @@ export class DefaultDispatchManager implements DispatchManager {
private taskRepository: TaskRepository,
private messageRepository: MessageRepository,
private agentManager: AgentManager,
private eventBus: EventBus
private eventBus: EventBus,
private initiativeRepository?: InitiativeRepository
) {}
/**
* 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
@@ -60,10 +74,8 @@ export class DefaultDispatchManager implements DispatchManager {
throw new Error(`Task not found: ${taskId}`);
}
// Get dependencies for this task
// We need to query task_dependencies table
// For now, use empty deps - will be populated when we have dependency data
const dependsOn: string[] = [];
// Get dependencies for this task from the repository
const dependsOn = await this.taskRepository.getDependencies(taskId);
const queuedTask: QueuedTask = {
taskId,
@@ -74,6 +86,8 @@ export class DefaultDispatchManager implements DispatchManager {
this.taskQueue.set(taskId, queuedTask);
log.info({ taskId, priority: task.priority, isCheckpoint: this.isCheckpointTask(task) }, 'task queued');
// Emit TaskQueuedEvent
const event: TaskQueuedEvent = {
type: 'task:queued',
@@ -90,6 +104,7 @@ 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());
@@ -98,16 +113,30 @@ export class DefaultDispatchManager implements DispatchManager {
return null;
}
// Filter to only tasks with all dependencies complete
// Filter to only tasks with all dependencies complete and not checkpoint tasks
const readyTasks: QueuedTask[] = [];
log.debug({ queueSize: queuedTasks.length }, 'evaluating dispatchable tasks');
for (const qt of queuedTasks) {
// Check dependencies
const allDepsComplete = await this.areAllDependenciesComplete(qt.dependsOn);
if (allDepsComplete) {
readyTasks.push(qt);
if (!allDepsComplete) {
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;
}
readyTasks.push(qt);
}
log.debug({ queueSize: queuedTasks.length, readyCount: readyTasks.length }, 'dispatchable evaluation complete');
if (readyTasks.length === 0) {
return null;
}
@@ -128,17 +157,87 @@ export class DefaultDispatchManager implements DispatchManager {
/**
* Mark a task as complete.
* If the task requires approval, sets status to 'pending_approval' instead.
* Updates task status and removes from queue.
*
* @param taskId - ID of the task to complete
* @param agentId - Optional ID of the agent that completed the task
*/
async completeTask(taskId: string): Promise<void> {
// Update task status to 'completed'
await this.taskRepository.update(taskId, { status: 'completed' });
async completeTask(taskId: string, agentId?: string): Promise<void> {
const task = await this.taskRepository.findById(taskId);
if (!task) {
throw new Error(`Task not found: ${taskId}`);
}
// Remove from queue
this.taskQueue.delete(taskId);
// Determine if approval is required
const requiresApproval = await this.taskRequiresApproval(task);
if (requiresApproval) {
// Set to pending_approval instead of completed
await this.taskRepository.update(taskId, { status: 'pending_approval' });
// Remove from queue
this.taskQueue.delete(taskId);
log.info({ taskId, category: task.category }, 'task pending approval');
// Emit TaskPendingApprovalEvent
const event: TaskPendingApprovalEvent = {
type: 'task:pending_approval',
timestamp: new Date(),
payload: {
taskId,
agentId: agentId ?? '',
category: task.category,
name: task.name,
},
};
this.eventBus.emit(event);
} else {
// Complete directly
await this.taskRepository.update(taskId, { status: 'completed' });
// Remove from queue
this.taskQueue.delete(taskId);
log.info({ taskId }, 'task completed');
// Emit TaskCompletedEvent
const event: TaskCompletedEvent = {
type: 'task:completed',
timestamp: new Date(),
payload: {
taskId,
agentId: agentId ?? '',
success: true,
message: 'Task completed',
},
};
this.eventBus.emit(event);
}
// Also remove from blocked if it was there
this.blockedTasks.delete(taskId);
}
/**
* Approve a task that is pending approval.
* Sets status to 'completed' and emits completion event.
*/
async approveTask(taskId: string): Promise<void> {
const task = await this.taskRepository.findById(taskId);
if (!task) {
throw new Error(`Task not found: ${taskId}`);
}
if (task.status !== 'pending_approval') {
throw new Error(`Task ${taskId} is not pending approval (status: ${task.status})`);
}
// Complete the task
await this.taskRepository.update(taskId, { status: 'completed' });
log.info({ taskId }, 'task approved and completed');
// Emit TaskCompletedEvent
const event: TaskCompletedEvent = {
@@ -146,9 +245,9 @@ export class DefaultDispatchManager implements DispatchManager {
timestamp: new Date(),
payload: {
taskId,
agentId: '', // Unknown at this point
agentId: '',
success: true,
message: 'Task completed',
message: 'Task approved',
},
};
this.eventBus.emit(event);
@@ -165,6 +264,8 @@ export class DefaultDispatchManager implements DispatchManager {
// Record in blocked map
this.blockedTasks.set(taskId, { taskId, reason });
log.warn({ taskId, reason }, 'task blocked');
// Remove from queue (blocked tasks aren't dispatchable)
this.taskQueue.delete(taskId);
@@ -188,6 +289,7 @@ export class DefaultDispatchManager implements DispatchManager {
const nextTask = await this.getNextDispatchable();
if (!nextTask) {
log.debug('no dispatchable tasks');
return {
success: false,
taskId: '',
@@ -200,6 +302,7 @@ export class DefaultDispatchManager implements DispatchManager {
const idleAgent = agents.find((a) => a.status === 'idle');
if (!idleAgent) {
log.debug('no available agents');
return {
success: false,
taskId: nextTask.taskId,
@@ -217,16 +320,14 @@ export class DefaultDispatchManager implements DispatchManager {
};
}
// Generate agent name based on task ID
const agentName = `agent-${nextTask.taskId.slice(0, 6)}`;
// Spawn agent with task
// Spawn agent with task (alias auto-generated by agent manager)
const agent = await this.agentManager.spawn({
name: agentName,
taskId: nextTask.taskId,
prompt: task.description || task.name,
});
log.info({ taskId: nextTask.taskId, agentId: agent.id }, 'task dispatched');
// Update task status to 'in_progress'
await this.taskRepository.update(nextTask.taskId, { status: 'in_progress' });
@@ -299,4 +400,35 @@ 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:');
}
/**
* Determine if a task requires approval before being marked complete.
* Checks task-level override first, then falls back to initiative setting.
*/
private async taskRequiresApproval(task: Task): Promise<boolean> {
// Task-level override takes precedence
if (task.requiresApproval !== null) {
return task.requiresApproval;
}
// Fall back to initiative setting if we have initiative access
if (this.initiativeRepository && task.initiativeId) {
const initiative = await this.initiativeRepository.findById(task.initiativeId);
if (initiative) {
return initiative.mergeRequiresApproval;
}
}
// If task has a phaseId but no initiativeId, we could traverse up but for now default to false
// Default: no approval required
return false;
}
}

View File

@@ -86,11 +86,21 @@ export interface DispatchManager {
/**
* Mark a task as complete.
* If the task requires approval, sets status to 'pending_approval' instead.
* Triggers re-evaluation of dependent tasks.
*
* @param taskId - ID of the completed task
* @param agentId - Optional ID of the agent that completed the task
*/
completeTask(taskId: string): Promise<void>;
completeTask(taskId: string, agentId?: string): Promise<void>;
/**
* Approve a task that is pending approval.
* Sets status to 'completed' and emits completion event.
*
* @param taskId - ID of the task to approve
*/
approveTask(taskId: string): Promise<void>;
/**
* Mark a task as blocked.

View File

@@ -24,11 +24,15 @@ export type {
AgentStoppedEvent,
AgentCrashedEvent,
AgentResumedEvent,
AgentAccountSwitchedEvent,
AgentDeletedEvent,
AgentWaitingEvent,
AgentOutputEvent,
TaskQueuedEvent,
TaskDispatchedEvent,
TaskCompletedEvent,
TaskBlockedEvent,
TaskPendingApprovalEvent,
PhaseQueuedEvent,
PhaseStartedEvent,
PhaseCompletedEvent,
@@ -37,6 +41,12 @@ export type {
MergeStartedEvent,
MergeCompletedEvent,
MergeConflictedEvent,
PageCreatedEvent,
PageUpdatedEvent,
PageDeletedEvent,
AccountCredentialsRefreshedEvent,
AccountCredentialsExpiredEvent,
AccountCredentialsValidatedEvent,
DomainEventMap,
DomainEventType,
} from './types.js';

View File

@@ -141,8 +141,9 @@ export interface AgentSpawnedEvent extends DomainEvent {
payload: {
agentId: string;
name: string;
taskId: string;
taskId: string | null;
worktreeId: string;
provider: string;
};
}
@@ -151,7 +152,7 @@ export interface AgentStoppedEvent extends DomainEvent {
payload: {
agentId: string;
name: string;
taskId: string;
taskId: string | null;
reason:
| 'user_requested'
| 'task_complete'
@@ -159,7 +160,8 @@ export interface AgentStoppedEvent extends DomainEvent {
| 'waiting_for_input'
| 'context_complete'
| 'breakdown_complete'
| 'decompose_complete';
| 'decompose_complete'
| 'refine_complete';
};
}
@@ -168,7 +170,7 @@ export interface AgentCrashedEvent extends DomainEvent {
payload: {
agentId: string;
name: string;
taskId: string;
taskId: string | null;
error: string;
};
}
@@ -178,17 +180,28 @@ export interface AgentResumedEvent extends DomainEvent {
payload: {
agentId: string;
name: string;
taskId: string;
taskId: string | null;
sessionId: string;
};
}
export interface AgentAccountSwitchedEvent extends DomainEvent {
type: 'agent:account_switched';
payload: {
agentId: string;
name: string;
previousAccountId: string;
newAccountId: string;
reason: 'account_exhausted';
};
}
export interface AgentWaitingEvent extends DomainEvent {
type: 'agent:waiting';
payload: {
agentId: string;
name: string;
taskId: string;
taskId: string | null;
sessionId: string;
questions: Array<{
id: string;
@@ -199,6 +212,23 @@ export interface AgentWaitingEvent extends DomainEvent {
};
}
export interface AgentDeletedEvent extends DomainEvent {
type: 'agent:deleted';
payload: {
agentId: string;
name: string;
};
}
export interface AgentOutputEvent extends DomainEvent {
type: 'agent:output';
payload: {
agentId: string;
stream: 'stdout' | 'stderr';
data: string;
};
}
/**
* Task Dispatch Events
*/
@@ -240,6 +270,16 @@ export interface TaskBlockedEvent extends DomainEvent {
};
}
export interface TaskPendingApprovalEvent extends DomainEvent {
type: 'task:pending_approval';
payload: {
taskId: string;
agentId: string;
category: string;
name: string;
};
}
/**
* Phase Events
*/
@@ -324,6 +364,68 @@ export interface MergeConflictedEvent extends DomainEvent {
};
}
/**
* Page Events
*/
export interface PageCreatedEvent extends DomainEvent {
type: 'page:created';
payload: {
pageId: string;
initiativeId: string;
title: string;
};
}
export interface PageUpdatedEvent extends DomainEvent {
type: 'page:updated';
payload: {
pageId: string;
initiativeId: string;
title?: string;
};
}
export interface PageDeletedEvent extends DomainEvent {
type: 'page:deleted';
payload: {
pageId: string;
initiativeId: string;
};
}
/**
* Account Credential Events
*/
export interface AccountCredentialsRefreshedEvent extends DomainEvent {
type: 'account:credentials_refreshed';
payload: {
accountId: string | null;
expiresAt: number;
previousExpiresAt: number | null;
};
}
export interface AccountCredentialsExpiredEvent extends DomainEvent {
type: 'account:credentials_expired';
payload: {
accountId: string | null;
reason: 'token_expired' | 'refresh_failed' | 'credentials_missing';
error: string | null;
};
}
export interface AccountCredentialsValidatedEvent extends DomainEvent {
type: 'account:credentials_validated';
payload: {
accountId: string | null;
valid: boolean;
expiresAt: number | null;
wasRefreshed: boolean;
};
}
/**
* Union of all domain events - enables type-safe event handling
*/
@@ -342,11 +444,15 @@ export type DomainEventMap =
| AgentStoppedEvent
| AgentCrashedEvent
| AgentResumedEvent
| AgentAccountSwitchedEvent
| AgentDeletedEvent
| AgentWaitingEvent
| AgentOutputEvent
| TaskQueuedEvent
| TaskDispatchedEvent
| TaskCompletedEvent
| TaskBlockedEvent
| TaskPendingApprovalEvent
| PhaseQueuedEvent
| PhaseStartedEvent
| PhaseCompletedEvent
@@ -354,7 +460,13 @@ export type DomainEventMap =
| MergeQueuedEvent
| MergeStartedEvent
| MergeCompletedEvent
| MergeConflictedEvent;
| MergeConflictedEvent
| PageCreatedEvent
| PageUpdatedEvent
| PageDeletedEvent
| AccountCredentialsRefreshedEvent
| AccountCredentialsExpiredEvent
| AccountCredentialsValidatedEvent;
/**
* Event type literal union for type checking

24
src/git/clone.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* Git Clone Utility
*
* Clones a git repository to a local path.
* Used when registering projects to create the base clone
* from which worktrees are later created.
*/
import { simpleGit } from 'simple-git';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('git');
/**
* Clone a git repository to a destination path.
*
* @param url - Remote repository URL
* @param destPath - Local filesystem path for the clone
*/
export async function cloneProject(url: string, destPath: string): Promise<void> {
const git = simpleGit();
log.info({ url, destPath }, 'cloning project');
await git.clone(url, destPath);
}

View File

@@ -17,3 +17,7 @@ export type { Worktree, WorktreeDiff, MergeResult } from './types.js';
// Adapters
export { SimpleGitWorktreeManager } from './manager.js';
// Utilities
export { cloneProject } from './clone.js';
export { ensureProjectClone, getProjectCloneDir } from './project-clones.js';

View File

@@ -17,6 +17,9 @@ import type {
WorktreeDiff,
MergeResult,
} from './types.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('git');
/**
* SimpleGit-based implementation of the WorktreeManager interface.
@@ -35,11 +38,12 @@ export class SimpleGitWorktreeManager implements WorktreeManager {
*
* @param repoPath - Absolute path to the git repository
* @param eventBus - Optional EventBus for emitting git events
* @param worktreesBaseDir - Optional custom base directory for worktrees (defaults to <repoPath>/.cw-worktrees)
*/
constructor(repoPath: string, eventBus?: EventBus) {
constructor(repoPath: string, eventBus?: EventBus, worktreesBaseDir?: string) {
this.repoPath = repoPath;
this.git = simpleGit(repoPath);
this.worktreesDir = path.join(repoPath, '.cw-worktrees');
this.worktreesDir = worktreesBaseDir ?? path.join(repoPath, '.cw-worktrees');
this.eventBus = eventBus;
}
@@ -55,6 +59,7 @@ export class SimpleGitWorktreeManager implements WorktreeManager {
baseBranch: string = 'main'
): Promise<Worktree> {
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>
@@ -100,6 +105,7 @@ export class SimpleGitWorktreeManager implements WorktreeManager {
}
const branch = worktree.branch;
log.info({ id, branch }, 'removing worktree');
// Remove worktree with force to handle any uncommitted changes
// git worktree remove <path> --force
@@ -197,6 +203,7 @@ export class SimpleGitWorktreeManager implements WorktreeManager {
if (!worktree) {
throw new Error(`Worktree not found: ${id}`);
}
log.info({ id, targetBranch }, 'merging worktree');
// Store current branch to restore later
const currentBranch = await this.git.revparse(['--abbrev-ref', 'HEAD']);
@@ -229,6 +236,7 @@ export class SimpleGitWorktreeManager implements WorktreeManager {
if (status.conflicted.length > 0) {
const conflicts = status.conflicted;
log.warn({ id, targetBranch, conflictCount: conflicts.length }, 'merge conflicts detected');
// Emit conflict event
this.eventBus?.emit({

48
src/git/project-clones.ts Normal file
View File

@@ -0,0 +1,48 @@
/**
* Project Clone Management
*
* Ensures project repositories are cloned into the repos/ directory.
* These base clones are used as the source for git worktrees.
*/
import { join } from 'node:path';
import { access } from 'node:fs/promises';
import { cloneProject } from './clone.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('git');
/**
* Derive the canonical clone directory for a project (relative to workspace root).
* Convention: repos/<sanitizedName>-<id>/
*/
export function getProjectCloneDir(projectName: string, projectId: string): string {
const sanitized = projectName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
return join('repos', `${sanitized}-${projectId}`);
}
/**
* Ensure a project's git repository is cloned to the workspace.
* Uses the canonical path: <workspaceRoot>/repos/<sanitizedName>-<id>/
*
* @param project - Project with id, name, and url
* @param workspaceRoot - Absolute path to the workspace root
* @returns Absolute path to the clone directory
*/
export async function ensureProjectClone(
project: { id: string; name: string; url: string },
workspaceRoot: string,
): Promise<string> {
const relPath = getProjectCloneDir(project.name, project.id);
const clonePath = join(workspaceRoot, relPath);
try {
await access(clonePath);
log.debug({ project: project.name, clonePath }, 'project clone already exists');
return clonePath;
} catch {
log.info({ project: project.name, url: project.url, clonePath }, 'cloning project for first time');
await cloneProject(project.url, clonePath);
return clonePath;
}
}

28
src/logger/index.ts Normal file
View File

@@ -0,0 +1,28 @@
import pino from 'pino';
function resolveLevel(): string {
if (process.env.CW_LOG_LEVEL) return process.env.CW_LOG_LEVEL;
return process.env.NODE_ENV === 'development' ? 'debug' : 'info';
}
export const logger = pino(
{
name: 'cw',
level: resolveLevel(),
...(process.env.CW_LOG_PRETTY === '1' && {
transport: {
target: 'pino-pretty',
options: {
colorize: true,
ignore: 'pid,hostname',
translateTime: 'HH:MM:ss.l',
},
},
}),
},
process.env.CW_LOG_PRETTY === '1' ? undefined : pino.destination(2),
);
export function createModuleLogger(module: string) {
return logger.child({ module });
}

View File

@@ -16,6 +16,7 @@ import type { ProcessManager } from '../process/index.js';
import type { LogManager } from '../logging/index.js';
import type { EventBus, ServerStartedEvent, ServerStoppedEvent } from '../events/index.js';
import { createTrpcHandler, type TrpcAdapterOptions } from './trpc-adapter.js';
import { createModuleLogger } from '../logger/index.js';
/**
* Optional dependencies for tRPC context.
@@ -23,6 +24,8 @@ import { createTrpcHandler, type TrpcAdapterOptions } from './trpc-adapter.js';
*/
export type ServerContextDeps = Omit<TrpcAdapterOptions, 'eventBus' | 'serverStartedAt' | 'processCount'>;
const log = createModuleLogger('http');
/** Default port for the coordination server */
const DEFAULT_PORT = 3847;
@@ -121,6 +124,7 @@ export class CoordinationServer {
}
console.log(`Coordination server listening on http://${this.config.host}:${this.config.port}`);
log.info({ port: this.config.port, host: this.config.host, pid: process.pid }, 'server listening');
}
/**
@@ -235,7 +239,7 @@ export class CoordinationServer {
});
trpcHandler(req, res).catch((error: Error) => {
console.error('tRPC handler error:', error);
log.error({ err: error }, 'tRPC handler error');
this.sendJson(res, 500, { error: 'Internal server error' });
});
}
@@ -311,6 +315,7 @@ export class CoordinationServer {
return pid; // Process is alive
} catch {
// Process is dead, PID file is stale
log.warn({ stalePid: pid }, 'stale PID file cleaned up');
await this.removePidFile();
return null;
}

View File

@@ -14,7 +14,10 @@ import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { MessageRepository } from '../db/repositories/message-repository.js';
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { PlanRepository } from '../db/repositories/plan-repository.js';
import type { PageRepository } from '../db/repositories/page-repository.js';
import type { ProjectRepository } from '../db/repositories/project-repository.js';
import type { AccountRepository } from '../db/repositories/account-repository.js';
import type { AccountCredentialManager } from '../agent/credentials/types.js';
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
import type { CoordinationManager } from '../coordination/types.js';
@@ -38,14 +41,22 @@ export interface TrpcAdapterOptions {
initiativeRepository?: InitiativeRepository;
/** Phase repository for phase CRUD operations */
phaseRepository?: PhaseRepository;
/** Plan repository for plan CRUD operations */
planRepository?: PlanRepository;
/** Dispatch manager for task queue operations */
dispatchManager?: DispatchManager;
/** Coordination manager for merge queue operations */
coordinationManager?: CoordinationManager;
/** Phase dispatch manager for phase queue operations */
phaseDispatchManager?: PhaseDispatchManager;
/** Page repository for page CRUD operations */
pageRepository?: PageRepository;
/** Project repository for project CRUD and initiative-project junction operations */
projectRepository?: ProjectRepository;
/** Account repository for account CRUD and load balancing */
accountRepository?: AccountRepository;
/** Credential manager for account OAuth token management */
credentialManager?: AccountCredentialManager;
/** Absolute path to the workspace root (.cwrc directory) */
workspaceRoot?: string;
}
/**
@@ -112,10 +123,14 @@ export function createTrpcHandler(options: TrpcAdapterOptions) {
messageRepository: options.messageRepository,
initiativeRepository: options.initiativeRepository,
phaseRepository: options.phaseRepository,
planRepository: options.planRepository,
dispatchManager: options.dispatchManager,
coordinationManager: options.coordinationManager,
phaseDispatchManager: options.phaseDispatchManager,
pageRepository: options.pageRepository,
projectRepository: options.projectRepository,
accountRepository: options.accountRepository,
credentialManager: options.credentialManager,
workspaceRoot: options.workspaceRoot,
}),
});

View File

@@ -30,7 +30,7 @@ describe('Architect Workflow E2E', () => {
vi.useFakeTimers();
// Create initiative
const initiative = await harness.createInitiative('Auth System', 'User authentication');
const initiative = await harness.createInitiative('Auth System');
// Set up discuss completion scenario
harness.setArchitectDiscussComplete('auth-discuss', [

View File

@@ -1,10 +1,10 @@
/**
* E2E Tests for Decompose Workflow
*
* Tests the complete decomposition workflow from plan creation through task creation:
* - Decompose mode: Break plan into executable tasks
* Tests the complete decomposition workflow from phase through task creation:
* - Decompose mode: Break phase into executable tasks
* - Q&A flow: Handle clarifying questions during decomposition
* - Task persistence: Save tasks from decomposition output
* - Task persistence: Save child tasks from decomposition output
*
* Uses TestHarness from src/test/ for full system wiring.
*/
@@ -30,11 +30,11 @@ describe('Decompose Workflow E2E', () => {
vi.useFakeTimers();
// Setup: Create initiative -> phase -> plan
const initiative = await harness.createInitiative('Test Project', 'Test project description');
const initiative = await harness.createInitiative('Test Project');
const phases = await harness.createPhasesFromBreakdown(initiative.id, [
{ number: 1, name: 'Phase 1', description: 'First phase' },
]);
const plan = await harness.createPlan(phases[0].id, 'Auth Plan', 'Implement authentication');
const decomposeTask = await harness.createDecomposeTask(phases[0].id, 'Auth Plan', 'Implement authentication');
// Set decompose scenario
harness.setArchitectDecomposeComplete('decomposer', [
@@ -45,7 +45,7 @@ describe('Decompose Workflow E2E', () => {
// Spawn decompose agent
const agent = await harness.caller.spawnArchitectDecompose({
name: 'decomposer',
planId: plan.id,
phaseId: phases[0].id,
});
expect(agent.mode).toBe('decompose');
@@ -67,7 +67,7 @@ describe('Decompose Workflow E2E', () => {
const phases = await harness.createPhasesFromBreakdown(initiative.id, [
{ number: 1, name: 'Phase 1', description: 'First phase' },
]);
const plan = await harness.createPlan(phases[0].id, 'Complex Plan');
const decomposeTask = await harness.createDecomposeTask(phases[0].id, 'Complex Plan');
// Set questions scenario
harness.setArchitectDecomposeQuestions('decomposer', [
@@ -76,7 +76,7 @@ describe('Decompose Workflow E2E', () => {
const agent = await harness.caller.spawnArchitectDecompose({
name: 'decomposer',
planId: plan.id,
phaseId: phases[0].id,
});
await harness.advanceTimers();
@@ -119,7 +119,7 @@ describe('Decompose Workflow E2E', () => {
const phases = await harness.createPhasesFromBreakdown(initiative.id, [
{ number: 1, name: 'Phase 1', description: 'First phase' },
]);
const plan = await harness.createPlan(phases[0].id, 'Complex Plan');
const decomposeTask = await harness.createDecomposeTask(phases[0].id, 'Complex Plan');
// Set multiple questions scenario
harness.setArchitectDecomposeQuestions('decomposer', [
@@ -130,7 +130,7 @@ describe('Decompose Workflow E2E', () => {
const agent = await harness.caller.spawnArchitectDecompose({
name: 'decomposer',
planId: plan.id,
phaseId: phases[0].id,
});
await harness.advanceTimers();
@@ -169,11 +169,11 @@ describe('Decompose Workflow E2E', () => {
const phases = await harness.createPhasesFromBreakdown(initiative.id, [
{ number: 1, name: 'Phase 1', description: 'First phase' },
]);
const plan = await harness.createPlan(phases[0].id, 'Auth Plan');
const decomposeTask = await harness.createDecomposeTask(phases[0].id, 'Auth Plan');
// Create tasks from decomposition
await harness.caller.createTasksFromDecomposition({
planId: plan.id,
await harness.caller.createChildTasks({
parentTaskId: decomposeTask.id,
tasks: [
{ number: 1, name: 'Schema', description: 'Create tables', type: 'auto', dependencies: [] },
{ number: 2, name: 'API', description: 'Create endpoints', type: 'auto', dependencies: [1] },
@@ -182,7 +182,7 @@ describe('Decompose Workflow E2E', () => {
});
// Verify tasks created
const tasks = await harness.getTasksForPlan(plan.id);
const tasks = await harness.getChildTasks(decomposeTask.id);
expect(tasks).toHaveLength(3);
expect(tasks[0].name).toBe('Schema');
expect(tasks[1].name).toBe('API');
@@ -195,11 +195,11 @@ describe('Decompose Workflow E2E', () => {
const phases = await harness.createPhasesFromBreakdown(initiative.id, [
{ number: 1, name: 'Phase 1', description: 'First phase' },
]);
const plan = await harness.createPlan(phases[0].id, 'Mixed Tasks');
const decomposeTask = await harness.createDecomposeTask(phases[0].id, 'Mixed Tasks');
// Create tasks with all types
await harness.caller.createTasksFromDecomposition({
planId: plan.id,
await harness.caller.createChildTasks({
parentTaskId: decomposeTask.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] },
@@ -208,7 +208,7 @@ describe('Decompose Workflow E2E', () => {
],
});
const tasks = await harness.getTasksForPlan(plan.id);
const tasks = await harness.getChildTasks(decomposeTask.id);
expect(tasks).toHaveLength(4);
expect(tasks[0].type).toBe('auto');
expect(tasks[1].type).toBe('checkpoint:human-verify');
@@ -221,11 +221,11 @@ describe('Decompose Workflow E2E', () => {
const phases = await harness.createPhasesFromBreakdown(initiative.id, [
{ number: 1, name: 'Phase 1', description: 'First phase' },
]);
const plan = await harness.createPlan(phases[0].id, 'Dependent Tasks');
const decomposeTask = await harness.createDecomposeTask(phases[0].id, 'Dependent Tasks');
// Create tasks with complex dependencies
await harness.caller.createTasksFromDecomposition({
planId: plan.id,
await harness.caller.createChildTasks({
parentTaskId: decomposeTask.id,
tasks: [
{ number: 1, name: 'Task A', description: 'No deps', type: 'auto' },
{ number: 2, name: 'Task B', description: 'Depends on A', type: 'auto', dependencies: [1] },
@@ -234,7 +234,7 @@ describe('Decompose Workflow E2E', () => {
],
});
const tasks = await harness.getTasksForPlan(plan.id);
const tasks = await harness.getChildTasks(decomposeTask.id);
expect(tasks).toHaveLength(4);
// All tasks should be created with correct names
@@ -247,7 +247,7 @@ describe('Decompose Workflow E2E', () => {
vi.useFakeTimers();
// 1. Create initiative
const initiative = await harness.createInitiative('Full Workflow Test', 'Complete workflow');
const initiative = await harness.createInitiative('Full Workflow Test');
// 2. Create phase
const phases = await harness.createPhasesFromBreakdown(initiative.id, [
@@ -255,7 +255,7 @@ describe('Decompose Workflow E2E', () => {
]);
// 3. Create plan
const plan = await harness.createPlan(phases[0].id, 'Auth Plan', 'Implement JWT auth');
const decomposeTask = await harness.createDecomposeTask(phases[0].id, 'Auth Plan', 'Implement JWT auth');
// 4. Spawn decompose agent
harness.setArchitectDecomposeComplete('decomposer', [
@@ -267,7 +267,7 @@ describe('Decompose Workflow E2E', () => {
await harness.caller.spawnArchitectDecompose({
name: 'decomposer',
planId: plan.id,
phaseId: phases[0].id,
});
await harness.advanceTimers();
@@ -277,8 +277,8 @@ describe('Decompose Workflow E2E', () => {
expect(events[0].payload.reason).toBe('decompose_complete');
// 6. Persist tasks (simulating what orchestrator would do after decompose)
await harness.caller.createTasksFromDecomposition({
planId: plan.id,
await harness.caller.createChildTasks({
parentTaskId: decomposeTask.id,
tasks: [
{ 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] },
@@ -288,7 +288,7 @@ describe('Decompose Workflow E2E', () => {
});
// 7. Verify final state
const tasks = await harness.getTasksForPlan(plan.id);
const tasks = await harness.getChildTasks(decomposeTask.id);
expect(tasks).toHaveLength(4);
expect(tasks[0].name).toBe('Create user schema');
expect(tasks[3].type).toBe('checkpoint:human-verify');

View File

@@ -52,9 +52,9 @@ describe('E2E Edge Cases', () => {
});
await vi.runAllTimersAsync();
// Set unrecoverable_error scenario BEFORE dispatch
// Set error scenario BEFORE dispatch
harness.setAgentScenario(`agent-${taskAId.slice(0, 6)}`, {
status: 'unrecoverable_error',
status: 'error',
error: 'Token limit exceeded',
});
@@ -91,9 +91,9 @@ describe('E2E Edge Cases', () => {
});
await vi.runAllTimersAsync();
// Set unrecoverable_error scenario
// Set error scenario
harness.setAgentScenario(`agent-${taskAId.slice(0, 6)}`, {
status: 'unrecoverable_error',
status: 'error',
error: 'Token limit exceeded',
});
@@ -119,9 +119,9 @@ describe('E2E Edge Cases', () => {
});
await vi.runAllTimersAsync();
// Set unrecoverable_error scenario
// Set error scenario
harness.setAgentScenario(`agent-${taskAId.slice(0, 6)}`, {
status: 'unrecoverable_error',
status: 'error',
error: 'Out of memory',
});

View File

@@ -304,9 +304,13 @@ describe('E2E Happy Path', () => {
const initialState = await harness.dispatchManager.getQueueState();
expect(initialState.queued.length).toBe(5);
// In current implementation, all tasks are "ready" (dependency loading TBD)
// Test verifies current behavior: priority ordering
expect(initialState.ready.length).toBe(5);
// Only tasks with no dependencies are ready:
// - Task 1A: no deps -> READY
// - Task 1B: no deps -> READY
// - Task 2A: depends on 1A -> NOT READY
// - Task 3A: depends on 1B -> NOT READY
// - Task 4A: depends on 2A, 3A -> NOT READY
expect(initialState.ready.length).toBe(2);
// First dispatch: Task 1A (high priority, first queued)
const result1 = await harness.dispatchManager.dispatchNext();

View File

@@ -34,7 +34,6 @@ describe('Phase Parallel Execution', () => {
// Create initiative with 2 independent phases (no dependencies)
const initiative = await harness.initiativeRepository.create({
name: 'Independent Phases Test',
description: 'Test initiative with independent phases',
status: 'active',
});
@@ -107,7 +106,6 @@ describe('Phase Parallel Execution', () => {
// Create phases: A, B (depends on A)
const initiative = await harness.initiativeRepository.create({
name: 'Sequential Phases Test',
description: 'Test initiative with sequential phases',
status: 'active',
});
@@ -192,7 +190,6 @@ describe('Phase Parallel Execution', () => {
// Create phases: A, B (depends on A), C (depends on A), D (depends on B, C)
const initiative = await harness.initiativeRepository.create({
name: 'Diamond Pattern Test',
description: 'Test initiative with diamond dependency pattern',
status: 'active',
});
@@ -309,7 +306,6 @@ describe('Phase Parallel Execution', () => {
// Create phases: A, B (depends on A)
const initiative = await harness.initiativeRepository.create({
name: 'Blocked Phase Test',
description: 'Test initiative with blocked phase',
status: 'active',
});
@@ -380,7 +376,6 @@ describe('Phase Parallel Execution', () => {
// Create chain: A -> B -> C, then block A
const initiative = await harness.initiativeRepository.create({
name: 'Chain Block Test',
description: 'Test blocking propagates down chain',
status: 'active',
});

View File

@@ -61,8 +61,8 @@ describe('E2E Recovery Scenarios', () => {
// Verify: even after clearing in-memory queue state,
// we can still find pending tasks from database
const allTasks = await harness.taskRepository.findByPlanId(
seeded.plans.get('Plan 1')!
const allTasks = await harness.taskRepository.findByParentTaskId(
seeded.taskGroups.get('Task Group 1')!
);
const pendingTasks = allTasks.filter((t) => t.status === 'pending');

View File

@@ -10,7 +10,6 @@ import type { DrizzleDatabase } from '../db/index.js';
import {
DrizzleInitiativeRepository,
DrizzlePhaseRepository,
DrizzlePlanRepository,
DrizzleTaskRepository,
} from '../db/repositories/drizzle/index.js';
import { taskDependencies } from '../db/schema.js';
@@ -29,17 +28,20 @@ export interface TaskFixture {
name: string;
/** Task priority */
priority?: 'low' | 'medium' | 'high';
/** Task category */
category?: 'execute' | 'research' | 'discuss' | 'breakdown' | 'decompose' | 'refine' | 'verify' | 'merge' | 'review';
/** Names of other tasks in same fixture this task depends on */
dependsOn?: string[];
}
/**
* Plan fixture definition.
* Task group fixture definition (replaces Plan).
* Tasks are grouped by parent task in the new model.
*/
export interface PlanFixture {
/** Plan name */
export interface TaskGroupFixture {
/** Group name (becomes a decompose task) */
name: string;
/** Tasks in this plan */
/** Tasks in this group */
tasks: TaskFixture[];
}
@@ -49,8 +51,8 @@ export interface PlanFixture {
export interface PhaseFixture {
/** Phase name */
name: string;
/** Plans in this phase */
plans: PlanFixture[];
/** Task groups in this phase (each group becomes a parent decompose task) */
taskGroups: TaskGroupFixture[];
}
/**
@@ -72,8 +74,8 @@ export interface SeededFixture {
initiativeId: string;
/** Map of phase names to IDs */
phases: Map<string, string>;
/** Map of plan names to IDs */
plans: Map<string, string>;
/** Map of task group names to parent task IDs */
taskGroups: Map<string, string>;
/** Map of task names to IDs */
tasks: Map<string, string>;
}
@@ -85,7 +87,7 @@ export interface SeededFixture {
/**
* Seed a complete task hierarchy from a fixture definition.
*
* Creates initiative, phases, plans, and tasks in correct order.
* Creates initiative, phases, decompose tasks (as parent), and child tasks.
* Resolves task dependencies by name to actual task IDs.
*
* @param db - Drizzle database instance
@@ -99,12 +101,11 @@ export async function seedFixture(
// Create repositories
const initiativeRepo = new DrizzleInitiativeRepository(db);
const phaseRepo = new DrizzlePhaseRepository(db);
const planRepo = new DrizzlePlanRepository(db);
const taskRepo = new DrizzleTaskRepository(db);
// Result maps
const phasesMap = new Map<string, string>();
const plansMap = new Map<string, string>();
const taskGroupsMap = new Map<string, string>();
const tasksMap = new Map<string, string>();
// Collect all task dependencies to resolve after creation
@@ -113,7 +114,6 @@ export async function seedFixture(
// Create initiative
const initiative = await initiativeRepo.create({
name: fixture.name,
description: `Test initiative: ${fixture.name}`,
status: 'active',
});
@@ -129,29 +129,37 @@ export async function seedFixture(
});
phasesMap.set(phaseFixture.name, phase.id);
// Create plans in phase
let planNumber = 1;
for (const planFixture of phaseFixture.plans) {
const plan = await planRepo.create({
// Create task groups as parent decompose tasks
let taskOrder = 0;
for (const groupFixture of phaseFixture.taskGroups) {
// Create parent decompose task
const parentTask = await taskRepo.create({
phaseId: phase.id,
number: planNumber++,
name: planFixture.name,
description: `Test plan: ${planFixture.name}`,
status: 'pending',
initiativeId: initiative.id,
name: groupFixture.name,
description: `Test task group: ${groupFixture.name}`,
category: 'decompose',
type: 'auto',
priority: 'medium',
status: 'completed', // Decompose tasks are completed once child tasks are created
order: taskOrder++,
});
plansMap.set(planFixture.name, plan.id);
taskGroupsMap.set(groupFixture.name, parentTask.id);
// Create tasks in plan
let taskOrder = 0;
for (const taskFixture of planFixture.tasks) {
// Create child tasks linked to parent
let childOrder = 0;
for (const taskFixture of groupFixture.tasks) {
const task = await taskRepo.create({
planId: plan.id,
parentTaskId: parentTask.id,
phaseId: phase.id,
initiativeId: initiative.id,
name: taskFixture.name,
description: `Test task: ${taskFixture.name}`,
category: taskFixture.category ?? 'execute',
type: 'auto',
priority: taskFixture.priority ?? 'medium',
status: 'pending',
order: taskOrder++,
order: childOrder++,
});
tasksMap.set(taskFixture.id, task.id);
@@ -189,7 +197,7 @@ export async function seedFixture(
return {
initiativeId: initiative.id,
phases: phasesMap,
plans: plansMap,
taskGroups: taskGroupsMap,
tasks: tasksMap,
};
}
@@ -199,7 +207,7 @@ export async function seedFixture(
// =============================================================================
/**
* Simple fixture: 1 initiative -> 1 phase -> 1 plan -> 3 tasks.
* Simple fixture: 1 initiative -> 1 phase -> 1 task group -> 3 tasks.
*
* Task dependency structure:
* - Task A: no dependencies
@@ -211,9 +219,9 @@ export const SIMPLE_FIXTURE: InitiativeFixture = {
phases: [
{
name: 'Phase 1',
plans: [
taskGroups: [
{
name: 'Plan 1',
name: 'Task Group 1',
tasks: [
{ id: 'Task A', name: 'Task A', priority: 'high' },
{ id: 'Task B', name: 'Task B', priority: 'medium', dependsOn: ['Task A'] },
@@ -226,27 +234,27 @@ export const SIMPLE_FIXTURE: InitiativeFixture = {
};
/**
* Parallel fixture: 1 initiative -> 1 phase -> 2 plans (each with 2 independent tasks).
* Parallel fixture: 1 initiative -> 1 phase -> 2 task groups (each with 2 independent tasks).
*
* Task structure:
* - Plan 1: Task X, Task Y (independent)
* - Plan 2: Task P, Task Q (independent)
* - Group A: Task X, Task Y (independent)
* - Group B: Task P, Task Q (independent)
*/
export const PARALLEL_FIXTURE: InitiativeFixture = {
name: 'Parallel Test Initiative',
phases: [
{
name: 'Parallel Phase',
plans: [
taskGroups: [
{
name: 'Plan A',
name: 'Group A',
tasks: [
{ id: 'Task X', name: 'Task X', priority: 'high' },
{ id: 'Task Y', name: 'Task Y', priority: 'medium' },
],
},
{
name: 'Plan B',
name: 'Group B',
tasks: [
{ id: 'Task P', name: 'Task P', priority: 'high' },
{ id: 'Task Q', name: 'Task Q', priority: 'low' },
@@ -258,27 +266,27 @@ export const PARALLEL_FIXTURE: InitiativeFixture = {
};
/**
* Complex fixture: 1 initiative -> 2 phases -> 4 plans with cross-plan dependencies.
* Complex fixture: 1 initiative -> 2 phases -> 4 task groups with cross-group dependencies.
*
* Structure:
* - Phase 1: Plan 1 (Task 1A, 1B), Plan 2 (Task 2A depends on 1A)
* - Phase 2: Plan 3 (Task 3A depends on 1B), Plan 4 (Task 4A depends on 2A and 3A)
* - Phase 1: Group 1 (Task 1A, 1B), Group 2 (Task 2A depends on 1A)
* - Phase 2: Group 3 (Task 3A depends on 1B), Group 4 (Task 4A depends on 2A and 3A)
*/
export const COMPLEX_FIXTURE: InitiativeFixture = {
name: 'Complex Test Initiative',
phases: [
{
name: 'Phase 1',
plans: [
taskGroups: [
{
name: 'Plan 1',
name: 'Group 1',
tasks: [
{ id: 'Task 1A', name: 'Task 1A', priority: 'high' },
{ id: 'Task 1B', name: 'Task 1B', priority: 'medium' },
],
},
{
name: 'Plan 2',
name: 'Group 2',
tasks: [
{ id: 'Task 2A', name: 'Task 2A', priority: 'high', dependsOn: ['Task 1A'] },
],
@@ -287,15 +295,15 @@ export const COMPLEX_FIXTURE: InitiativeFixture = {
},
{
name: 'Phase 2',
plans: [
taskGroups: [
{
name: 'Plan 3',
name: 'Group 3',
tasks: [
{ id: 'Task 3A', name: 'Task 3A', priority: 'high', dependsOn: ['Task 1B'] },
],
},
{
name: 'Plan 4',
name: 'Group 4',
tasks: [
{
id: 'Task 4A',

View File

@@ -91,9 +91,9 @@ describe('TestHarness', () => {
expect(seeded.phases.size).toBe(1);
expect(seeded.phases.has('Phase 1')).toBe(true);
// Check plans created
expect(seeded.plans.size).toBe(1);
expect(seeded.plans.has('Plan 1')).toBe(true);
// Check task groups created
expect(seeded.taskGroups.size).toBe(1);
expect(seeded.taskGroups.has('Task Group 1')).toBe(true);
// Check tasks created
expect(seeded.tasks.size).toBe(3);
@@ -116,7 +116,7 @@ describe('TestHarness', () => {
const seeded = await harness.seedFixture(PARALLEL_FIXTURE);
expect(seeded.phases.size).toBe(1);
expect(seeded.plans.size).toBe(2);
expect(seeded.taskGroups.size).toBe(2);
expect(seeded.tasks.size).toBe(4);
expect(seeded.tasks.has('Task X')).toBe(true);
expect(seeded.tasks.has('Task Q')).toBe(true);
@@ -126,7 +126,7 @@ describe('TestHarness', () => {
const seeded = await harness.seedFixture(COMPLEX_FIXTURE);
expect(seeded.phases.size).toBe(2);
expect(seeded.plans.size).toBe(4);
expect(seeded.taskGroups.size).toBe(4);
expect(seeded.tasks.size).toBe(5);
});
});
@@ -298,9 +298,9 @@ describe('TestHarness', () => {
await vi.runAllTimersAsync();
harness.clearEvents();
// Set unrecoverable_error scenario for the agent that will be spawned
// Set error scenario for the agent that will be spawned
harness.setAgentScenario(`agent-${taskAId.slice(0, 6)}`, {
status: 'unrecoverable_error',
status: 'error',
delay: 0,
error: 'Test crash',
});

View File

@@ -13,7 +13,6 @@ import { EventEmitterBus } from '../events/bus.js';
import type { AgentManager } from '../agent/types.js';
import { MockAgentManager, type MockAgentScenario } from '../agent/mock-manager.js';
import type { PendingQuestions, QuestionItem } from '../agent/types.js';
import type { Decision, PhaseBreakdown, TaskBreakdown } from '../agent/schema.js';
import type { WorktreeManager, Worktree, WorktreeDiff, MergeResult } from '../git/types.js';
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
import { DefaultDispatchManager } from '../dispatch/manager.js';
@@ -25,17 +24,9 @@ import type { MessageRepository } from '../db/repositories/message-repository.js
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 { PlanRepository } from '../db/repositories/plan-repository.js';
import type { Initiative, Phase, Plan, Task } from '../db/schema.js';
import {
DrizzleTaskRepository,
DrizzleMessageRepository,
DrizzleAgentRepository,
DrizzleInitiativeRepository,
DrizzlePhaseRepository,
DrizzlePlanRepository,
} from '../db/repositories/drizzle/index.js';
import type { Initiative, Phase, Task } from '../db/schema.js';
import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js';
import { createRepositories } from '../container.js';
import {
seedFixture,
type InitiativeFixture,
@@ -212,8 +203,6 @@ export interface TestHarness {
initiativeRepository: InitiativeRepository;
/** Phase repository */
phaseRepository: PhaseRepository;
/** Plan repository */
planRepository: PlanRepository;
// tRPC Caller
/** tRPC caller for direct procedure calls */
@@ -295,11 +284,11 @@ export interface TestHarness {
// ==========================================================================
/**
* Set up scenario where architect completes discussion with decisions.
* Set up scenario where architect completes discussion.
*/
setArchitectDiscussComplete(
agentName: string,
decisions: Decision[],
_decisions: unknown[],
summary: string
): void;
@@ -312,19 +301,19 @@ export interface TestHarness {
): void;
/**
* Set up scenario where architect completes breakdown with phases.
* Set up scenario where architect completes breakdown.
*/
setArchitectBreakdownComplete(
agentName: string,
phases: PhaseBreakdown[]
_phases: unknown[]
): void;
/**
* Set up scenario where architect completes decomposition with tasks.
* Set up scenario where architect completes decomposition.
*/
setArchitectDecomposeComplete(
agentName: string,
tasks: TaskBreakdown[]
_tasks: unknown[]
): void;
/**
@@ -352,7 +341,7 @@ export interface TestHarness {
/**
* Create initiative through tRPC.
*/
createInitiative(name: string, description?: string): Promise<Initiative>;
createInitiative(name: string): Promise<Initiative>;
/**
* Create phases from breakdown output through tRPC.
@@ -363,18 +352,18 @@ export interface TestHarness {
): Promise<Phase[]>;
/**
* Create a plan through tRPC.
* Create a decompose task through tRPC (replaces createPlan).
*/
createPlan(
createDecomposeTask(
phaseId: string,
name: string,
description?: string
): Promise<Plan>;
): Promise<Task>;
/**
* Get tasks for a plan through tRPC.
* Get child tasks of a parent task through tRPC.
*/
getTasksForPlan(planId: string): Promise<Task[]>;
getChildTasks(parentTaskId: string): Promise<Task[]>;
}
// =============================================================================
@@ -406,12 +395,8 @@ export function createTestHarness(): TestHarness {
const worktreeManager = new MockWorktreeManager();
// Create repositories
const taskRepository = new DrizzleTaskRepository(db);
const messageRepository = new DrizzleMessageRepository(db);
const agentRepository = new DrizzleAgentRepository(db);
const initiativeRepository = new DrizzleInitiativeRepository(db);
const phaseRepository = new DrizzlePhaseRepository(db);
const planRepository = new DrizzlePlanRepository(db);
const repos = createRepositories(db);
const { taskRepository, messageRepository, agentRepository, initiativeRepository, phaseRepository } = repos;
// Create real managers wired to mocks
const dispatchManager = new DefaultDispatchManager(
@@ -446,7 +431,6 @@ export function createTestHarness(): TestHarness {
coordinationManager,
initiativeRepository,
phaseRepository,
planRepository,
});
// Create tRPC caller
@@ -470,7 +454,6 @@ export function createTestHarness(): TestHarness {
agentRepository,
initiativeRepository,
phaseRepository,
planRepository,
// tRPC Caller
caller,
@@ -506,7 +489,7 @@ export function createTestHarness(): TestHarness {
},
setAgentError: (agentName: string, error: string) => {
agentManager.setScenario(agentName, { status: 'unrecoverable_error', error });
agentManager.setScenario(agentName, { status: 'error', error });
},
getPendingQuestions: (agentId: string) => agentManager.getPendingQuestions(agentId),
@@ -536,13 +519,12 @@ export function createTestHarness(): TestHarness {
setArchitectDiscussComplete: (
agentName: string,
decisions: Decision[],
_decisions: unknown[],
summary: string
) => {
agentManager.setScenario(agentName, {
status: 'context_complete',
decisions,
summary,
status: 'done',
result: summary,
delay: 0,
});
},
@@ -560,22 +542,22 @@ export function createTestHarness(): TestHarness {
setArchitectBreakdownComplete: (
agentName: string,
phases: PhaseBreakdown[]
_phases: unknown[]
) => {
agentManager.setScenario(agentName, {
status: 'breakdown_complete',
phases,
status: 'done',
result: 'Breakdown complete',
delay: 0,
});
},
setArchitectDecomposeComplete: (
agentName: string,
tasks: TaskBreakdown[]
_tasks: unknown[]
) => {
agentManager.setScenario(agentName, {
status: 'decompose_complete',
tasks,
status: 'done',
result: 'Decompose complete',
delay: 0,
});
},
@@ -607,8 +589,8 @@ export function createTestHarness(): TestHarness {
return caller.listPhases({ initiativeId });
},
createInitiative: (name: string, description?: string) => {
return caller.createInitiative({ name, description });
createInitiative: (name: string) => {
return caller.createInitiative({ name });
},
createPhasesFromBreakdown: (
@@ -618,12 +600,19 @@ export function createTestHarness(): TestHarness {
return caller.createPhasesFromBreakdown({ initiativeId, phases });
},
createPlan: (phaseId: string, name: string, description?: string) => {
return caller.createPlan({ phaseId, name, description });
createDecomposeTask: async (phaseId: string, name: string, description?: string) => {
return caller.createPhaseTask({
phaseId,
name,
description,
category: 'decompose',
type: 'auto',
requiresApproval: true,
});
},
getTasksForPlan: (planId: string) => {
return caller.listTasks({ planId });
getChildTasks: (parentTaskId: string) => {
return caller.listTasks({ parentTaskId });
},
};

View File

@@ -8,7 +8,7 @@
export {
seedFixture,
type TaskFixture,
type PlanFixture,
type TaskGroupFixture,
type PhaseFixture,
type InitiativeFixture,
type SeededFixture,

View File

@@ -14,36 +14,18 @@
* - Confirm MockAgentManager accurately simulates real CLI behavior
* - Document actual response structure and costs
*
* Findings from validation run (2026-02-02):
* - Execute mode (done): Works, ~$0.025, ~6s
* - Execute mode (questions): Works, questions array validated
* - Discuss mode: Works, decisions array validated
* - Breakdown mode: Works, phases array validated
* - Decompose mode: Works, tasks array validated
* Updated (2026-02-06): Now uses the universal agentSignalSchema instead of
* per-mode schemas. Agents output trivial signals (done/questions/error) and
* write files instead of producing mode-specific JSON.
*
* Key observation: When using --json-schema flag:
* - `result` field is EMPTY (not the structured output)
* - `structured_output` field contains the validated JSON object
* - This is different from non-schema mode where result contains text
*
* Total validation cost: ~$0.15 (5 tests)
*
* Conclusion: MockAgentManager accurately simulates real CLI behavior.
* JSON schemas work correctly with Claude CLI --json-schema flag.
* ClaudeAgentManager correctly reads from structured_output field.
* Total validation cost: ~$0.10 (3 tests)
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { execa } from 'execa';
import {
agentOutputJsonSchema,
agentOutputSchema,
discussOutputJsonSchema,
discussOutputSchema,
breakdownOutputJsonSchema,
breakdownOutputSchema,
decomposeOutputJsonSchema,
decomposeOutputSchema,
agentSignalJsonSchema,
agentSignalSchema,
} from '../../agent/schema.js';
/**
@@ -129,15 +111,15 @@ describeReal('Real Claude CLI Integration', () => {
console.log('These tests call the real Claude API and incur costs.\n');
});
describe('Execute Mode Schema', () => {
describe('Universal Signal Schema', () => {
it(
'should return done status with result',
'should return done status',
async () => {
const prompt = `Complete this simple task: Say "Hello, World!" as a test.
Output your response in the required JSON format with status "done".`;
const { cliResult, structuredOutput } = await callClaudeCli(prompt, agentOutputJsonSchema);
const { cliResult, structuredOutput } = await callClaudeCli(prompt, agentSignalJsonSchema);
console.log(' Output:', JSON.stringify(structuredOutput, null, 2));
@@ -147,11 +129,8 @@ Output your response in the required JSON format with status "done".`;
expect(cliResult.structured_output).toBeDefined();
// Validate against Zod schema
const parsed = agentOutputSchema.parse(structuredOutput);
const parsed = agentSignalSchema.parse(structuredOutput);
expect(parsed.status).toBe('done');
if (parsed.status === 'done') {
expect(parsed.result).toBeTruthy();
}
},
TEST_TIMEOUT
);
@@ -165,12 +144,12 @@ You MUST ask clarifying questions before proceeding. You cannot complete this ta
Output your response with status "questions" and include at least 2 questions with unique IDs.`;
const { structuredOutput } = await callClaudeCli(prompt, agentOutputJsonSchema);
const { structuredOutput } = await callClaudeCli(prompt, agentSignalJsonSchema);
console.log(' Output:', JSON.stringify(structuredOutput, null, 2));
// Validate against Zod schema
const parsed = agentOutputSchema.parse(structuredOutput);
const parsed = agentSignalSchema.parse(structuredOutput);
expect(parsed.status).toBe('questions');
if (parsed.status === 'questions') {
expect(Array.isArray(parsed.questions)).toBe(true);
@@ -181,90 +160,21 @@ Output your response with status "questions" and include at least 2 questions wi
},
TEST_TIMEOUT
);
});
describe('Discuss Mode Schema', () => {
it(
'should return context_complete with decisions',
'should return error status',
async () => {
const prompt = `You are gathering requirements for a simple feature: "Add a login button"
const prompt = `You have encountered an unrecoverable error. Output your response with status "error" and a descriptive error message.`;
The user has already told you:
- Use OAuth with Google
- Button should be blue
- Place it in the top-right corner
You have enough information. Output context_complete with the decisions captured as an array.`;
const { structuredOutput } = await callClaudeCli(prompt, discussOutputJsonSchema);
const { structuredOutput } = await callClaudeCli(prompt, agentSignalJsonSchema);
console.log(' Output:', JSON.stringify(structuredOutput, null, 2));
// Validate against Zod schema
const parsed = discussOutputSchema.parse(structuredOutput);
expect(parsed.status).toBe('context_complete');
if (parsed.status === 'context_complete') {
expect(Array.isArray(parsed.decisions)).toBe(true);
expect(parsed.decisions.length).toBeGreaterThanOrEqual(1);
expect(parsed.summary).toBeTruthy();
}
},
TEST_TIMEOUT
);
});
describe('Breakdown Mode Schema', () => {
it(
'should return breakdown_complete with phases',
async () => {
const prompt = `You are breaking down an initiative: "Build a simple TODO app"
Create a breakdown with 2-3 phases for this very simple app. Keep it minimal - just database, API, and UI.
Output breakdown_complete with the phases array. Each phase needs number, name, description, and dependencies.`;
const { structuredOutput } = await callClaudeCli(prompt, breakdownOutputJsonSchema);
console.log(' Output:', JSON.stringify(structuredOutput, null, 2));
// Validate against Zod schema
const parsed = breakdownOutputSchema.parse(structuredOutput);
expect(parsed.status).toBe('breakdown_complete');
if (parsed.status === 'breakdown_complete') {
expect(Array.isArray(parsed.phases)).toBe(true);
expect(parsed.phases.length).toBeGreaterThanOrEqual(2);
expect(parsed.phases[0].number).toBe(1);
expect(parsed.phases[0].name).toBeTruthy();
expect(parsed.phases[0].description).toBeTruthy();
}
},
TEST_TIMEOUT
);
});
describe('Decompose Mode Schema', () => {
it(
'should return decompose_complete with tasks',
async () => {
const prompt = `You are decomposing a plan: "Implement user authentication"
Create 2-3 simple tasks for this plan. Tasks should be atomic units of work.
Output decompose_complete with the tasks array. Each task needs number, name, description, type (default to "auto"), and dependencies.`;
const { structuredOutput } = await callClaudeCli(prompt, decomposeOutputJsonSchema);
console.log(' Output:', JSON.stringify(structuredOutput, null, 2));
// Validate against Zod schema
const parsed = decomposeOutputSchema.parse(structuredOutput);
expect(parsed.status).toBe('decompose_complete');
if (parsed.status === 'decompose_complete') {
expect(Array.isArray(parsed.tasks)).toBe(true);
expect(parsed.tasks.length).toBeGreaterThanOrEqual(2);
expect(parsed.tasks[0].number).toBe(1);
expect(parsed.tasks[0].name).toBeTruthy();
expect(parsed.tasks[0].description).toBeTruthy();
const parsed = agentSignalSchema.parse(structuredOutput);
expect(parsed.status).toBe('error');
if (parsed.status === 'error') {
expect(parsed.error).toBeTruthy();
}
},
TEST_TIMEOUT

View File

@@ -0,0 +1,304 @@
/**
* Real Claude CLI Manager Integration Tests
*
* IMPORTANT: These tests call the REAL Claude CLI and incur API costs!
* They are SKIPPED by default and should only be run manually for validation.
*
* To run these tests:
* ```bash
* REAL_CLAUDE_TESTS=1 npm test -- src/test/integration/real-providers/claude-manager.test.ts --test-timeout=300000
* ```
*
* Tests covered:
* - Output stream parsing (text_delta events)
* - Session ID extraction from init event
* - Result parsing and validation
* - Session resume with user answers
*
* Estimated cost: ~$0.10 per full run
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import {
createRealProviderHarness,
describeRealClaude,
REAL_TEST_TIMEOUT,
sleep,
type RealProviderHarness,
} from './harness.js';
import { MINIMAL_PROMPTS } from './prompts.js';
import type { AgentSpawnedEvent, AgentStoppedEvent, AgentOutputEvent } from '../../../events/types.js';
describeRealClaude('Real Claude Manager Integration', () => {
let harness: RealProviderHarness;
beforeAll(async () => {
console.log('\n=== Running Real Claude Manager Tests ===');
console.log('These tests call the real Claude API and incur costs.\n');
harness = await createRealProviderHarness({ provider: 'claude' });
});
afterAll(async () => {
await harness.cleanup();
});
beforeEach(() => {
harness.clearEvents();
});
describe('Output Parsing', () => {
it(
'parses text_delta events from stream',
async () => {
// Spawn agent with streaming prompt
const agent = await harness.agentManager.spawn({
taskId: null,
prompt: MINIMAL_PROMPTS.streaming,
mode: 'execute',
provider: 'claude',
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe('running');
// Wait for completion
const result = await harness.waitForAgentCompletion(agent.id, REAL_TEST_TIMEOUT);
// Verify we got output (either via events or buffer)
const outputEvents = harness.getEventsByType<AgentOutputEvent>('agent:output');
const outputBuffer = harness.agentManager.getOutputBuffer(agent.id);
// At least one should have content (output may be emitted as event or buffered)
const hasOutput = outputEvents.length > 0 || outputBuffer.length > 0;
console.log(' Output events:', outputEvents.length);
console.log(' Output buffer:', outputBuffer.length);
// Verify completion
expect(result).toBeTruthy();
console.log(' Output chunks:', outputBuffer.length);
console.log(' Result:', result?.message);
},
REAL_TEST_TIMEOUT
);
it(
'parses init event and extracts session ID',
async () => {
// Spawn agent with simple done prompt
const agent = await harness.agentManager.spawn({
taskId: null,
prompt: MINIMAL_PROMPTS.done,
mode: 'execute',
provider: 'claude',
});
// Wait for completion
await harness.waitForAgentCompletion(agent.id, REAL_TEST_TIMEOUT);
// Verify session ID was extracted and persisted
const dbAgent = await harness.agentRepository.findById(agent.id);
expect(dbAgent?.sessionId).toBeTruthy();
expect(dbAgent?.sessionId).toMatch(/^[a-f0-9-]+$/);
console.log(' Session ID:', dbAgent?.sessionId);
},
REAL_TEST_TIMEOUT
);
it(
'parses result event with completion',
async () => {
// Spawn agent with simple done prompt
const agent = await harness.agentManager.spawn({
taskId: null,
prompt: MINIMAL_PROMPTS.done,
mode: 'execute',
provider: 'claude',
});
// Wait for completion
const result = await harness.waitForAgentCompletion(agent.id, REAL_TEST_TIMEOUT);
// Verify result was parsed
expect(result).toBeTruthy();
expect(result?.success).toBe(true);
expect(result?.message).toBeTruthy();
// Verify events
const spawnedEvents = harness.getEventsByType<AgentSpawnedEvent>('agent:spawned');
expect(spawnedEvents.length).toBe(1);
expect(spawnedEvents[0].payload.agentId).toBe(agent.id);
expect(spawnedEvents[0].payload.provider).toBe('claude');
const stoppedEvents = harness.getEventsByType<AgentStoppedEvent>('agent:stopped');
expect(stoppedEvents.length).toBe(1);
expect(stoppedEvents[0].payload.agentId).toBe(agent.id);
expect(stoppedEvents[0].payload.reason).toBe('task_complete');
console.log(' Result message:', result?.message);
},
REAL_TEST_TIMEOUT
);
});
describe('Questions Flow', () => {
it(
'parses questions status and enters waiting_for_input',
async () => {
// Spawn agent with questions prompt
const agent = await harness.agentManager.spawn({
taskId: null,
prompt: MINIMAL_PROMPTS.questions,
mode: 'execute',
provider: 'claude',
});
// Wait for waiting_for_input status
const questions = await harness.waitForAgentWaiting(agent.id, REAL_TEST_TIMEOUT);
// Verify questions were parsed
expect(questions).toBeTruthy();
expect(questions?.questions).toBeTruthy();
expect(questions?.questions.length).toBeGreaterThan(0);
expect(questions?.questions[0].id).toBeTruthy();
expect(questions?.questions[0].question).toBeTruthy();
// Verify agent status
const dbAgent = await harness.agentRepository.findById(agent.id);
expect(dbAgent?.status).toBe('waiting_for_input');
expect(dbAgent?.sessionId).toBeTruthy();
console.log(' Questions:', questions?.questions.length);
console.log(' First question:', questions?.questions[0].question);
},
REAL_TEST_TIMEOUT
);
});
describe('Session Resume', () => {
it(
'resumes session with user answers',
async () => {
// 1. Spawn agent that asks questions
const agent = await harness.agentManager.spawn({
taskId: null,
prompt: MINIMAL_PROMPTS.questions,
mode: 'execute',
provider: 'claude',
});
// 2. Wait for waiting_for_input
const questions = await harness.waitForAgentWaiting(agent.id, REAL_TEST_TIMEOUT);
expect(questions?.questions.length).toBeGreaterThan(0);
const sessionIdBeforeResume = (await harness.agentRepository.findById(agent.id))?.sessionId;
console.log(' Session ID before resume:', sessionIdBeforeResume);
console.log(' Questions received:', questions?.questions.map((q) => q.id).join(', '));
harness.clearEvents();
// 3. Resume with answer
const answers: Record<string, string> = {};
for (const q of questions?.questions ?? []) {
answers[q.id] = `Answer to ${q.id}`;
}
await harness.agentManager.resume(agent.id, answers);
// 4. Wait for completion or another waiting state
let attempts = 0;
let finalStatus = 'running';
while (attempts < 60) {
const agent2 = await harness.agentRepository.findById(agent.id);
if (agent2?.status !== 'running') {
finalStatus = agent2?.status ?? 'unknown';
break;
}
await sleep(1000);
attempts++;
}
// Verify the agent processed the resume (either completed or asked more questions)
const dbAgent = await harness.agentRepository.findById(agent.id);
console.log(' Final status:', dbAgent?.status);
// Agent should not still be running
expect(['idle', 'waiting_for_input', 'crashed']).toContain(dbAgent?.status);
// If idle, verify result
if (dbAgent?.status === 'idle') {
const result = await harness.agentManager.getResult(agent.id);
console.log(' Result:', result?.message);
expect(result).toBeTruthy();
}
},
REAL_TEST_TIMEOUT * 2 // Double timeout for two-step process
);
it(
'maintains session continuity across resume',
async () => {
// 1. Spawn agent that asks questions
const agent = await harness.agentManager.spawn({
taskId: null,
prompt: MINIMAL_PROMPTS.questions,
mode: 'execute',
provider: 'claude',
});
// 2. Wait for waiting_for_input
const questions = await harness.waitForAgentWaiting(agent.id, REAL_TEST_TIMEOUT);
expect(questions?.questions.length).toBeGreaterThan(0);
const sessionIdBefore = (await harness.agentRepository.findById(agent.id))?.sessionId;
expect(sessionIdBefore).toBeTruthy();
// 3. Resume with answer
const answers: Record<string, string> = {};
for (const q of questions?.questions ?? []) {
answers[q.id] = `Answer to ${q.id}`;
}
await harness.agentManager.resume(agent.id, answers);
// 4. Wait for completion
await harness.waitForAgentCompletion(agent.id, REAL_TEST_TIMEOUT);
// Verify session ID exists (may be same or new depending on CLI behavior)
const sessionIdAfter = (await harness.agentRepository.findById(agent.id))?.sessionId;
expect(sessionIdAfter).toBeTruthy();
console.log(' Session ID before:', sessionIdBefore);
console.log(' Session ID after:', sessionIdAfter);
},
REAL_TEST_TIMEOUT * 2
);
});
describe('Error Handling', () => {
it(
'handles error status',
async () => {
// Spawn agent with error prompt
const agent = await harness.agentManager.spawn({
taskId: null,
prompt: MINIMAL_PROMPTS.error,
mode: 'execute',
provider: 'claude',
});
// Wait for completion (will be crashed)
const result = await harness.waitForAgentCompletion(agent.id, REAL_TEST_TIMEOUT);
// Verify error was handled
const dbAgent = await harness.agentRepository.findById(agent.id);
expect(dbAgent?.status).toBe('crashed');
expect(result?.success).toBe(false);
expect(result?.message).toContain('Test error');
console.log(' Error message:', result?.message);
},
REAL_TEST_TIMEOUT
);
});
});

View File

@@ -0,0 +1,176 @@
/**
* Real Codex CLI Manager Integration Tests
*
* IMPORTANT: These tests call the REAL Codex CLI and incur API costs!
* They are SKIPPED by default and should only be run manually for validation.
*
* To run these tests:
* ```bash
* REAL_CODEX_TESTS=1 npm test -- src/test/integration/real-providers/codex-manager.test.ts --test-timeout=300000
* ```
*
* Tests covered:
* - Codex spawn and thread_id extraction
* - Generic output parsing (non-schema)
* - Streaming output
*
* Estimated cost: ~$0.10 per full run
*
* Note: Codex uses different output format and session ID field (thread_id).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import {
createRealProviderHarness,
describeRealCodex,
REAL_TEST_TIMEOUT,
type RealProviderHarness,
} from './harness.js';
import { CODEX_PROMPTS } from './prompts.js';
import type { AgentSpawnedEvent, AgentOutputEvent } from '../../../events/types.js';
describeRealCodex('Real Codex Manager Integration', () => {
let harness: RealProviderHarness;
beforeAll(async () => {
console.log('\n=== Running Real Codex Manager Tests ===');
console.log('These tests call the real Codex API and incur costs.\n');
harness = await createRealProviderHarness({ provider: 'codex' });
});
afterAll(async () => {
await harness.cleanup();
});
beforeEach(() => {
harness.clearEvents();
});
describe('Codex Spawn', () => {
it(
'spawns codex agent and extracts thread_id',
async () => {
// Spawn agent with simple task
const agent = await harness.agentManager.spawn({
taskId: null,
prompt: CODEX_PROMPTS.done,
mode: 'execute',
provider: 'codex',
});
expect(agent.id).toBeTruthy();
expect(agent.provider).toBe('codex');
expect(agent.status).toBe('running');
// Verify spawned event
const spawnedEvents = harness.getEventsByType<AgentSpawnedEvent>('agent:spawned');
expect(spawnedEvents.length).toBe(1);
expect(spawnedEvents[0].payload.provider).toBe('codex');
// Wait for completion
const result = await harness.waitForAgentCompletion(agent.id, REAL_TEST_TIMEOUT);
// Verify session ID (thread_id) was extracted
const dbAgent = await harness.agentRepository.findById(agent.id);
console.log(' Thread ID:', dbAgent?.sessionId);
console.log(' Status:', dbAgent?.status);
console.log(' Result:', result?.message);
// Codex should complete or crash
expect(['idle', 'crashed']).toContain(dbAgent?.status);
// If completed successfully, should have extracted thread_id
if (dbAgent?.status === 'idle' && dbAgent?.sessionId) {
expect(dbAgent.sessionId).toBeTruthy();
}
},
REAL_TEST_TIMEOUT
);
it(
'uses generic parser for output',
async () => {
// Spawn agent with streaming prompt
const agent = await harness.agentManager.spawn({
taskId: null,
prompt: CODEX_PROMPTS.streaming,
mode: 'execute',
provider: 'codex',
});
// Wait for completion
const result = await harness.waitForAgentCompletion(agent.id, REAL_TEST_TIMEOUT);
// Verify output events were captured
const outputEvents = harness.getEventsByType<AgentOutputEvent>('agent:output');
console.log(' Output events:', outputEvents.length);
// Verify output buffer
const outputBuffer = harness.agentManager.getOutputBuffer(agent.id);
console.log(' Output buffer chunks:', outputBuffer.length);
// For generic provider, result should be captured
const dbAgent = await harness.agentRepository.findById(agent.id);
console.log(' Status:', dbAgent?.status);
console.log(' Result:', result?.message?.substring(0, 100) + '...');
expect(['idle', 'crashed']).toContain(dbAgent?.status);
},
REAL_TEST_TIMEOUT
);
});
describe('Codex Provider Config', () => {
it(
'uses correct command and args for codex',
async () => {
// This is more of a config verification test
// The actual command execution is validated by the spawn test
const agent = await harness.agentManager.spawn({
taskId: null,
prompt: 'Say hello',
mode: 'execute',
provider: 'codex',
});
// Verify agent was created with codex provider
const dbAgent = await harness.agentRepository.findById(agent.id);
expect(dbAgent?.provider).toBe('codex');
// Wait for completion (or timeout)
try {
await harness.waitForAgentCompletion(agent.id, REAL_TEST_TIMEOUT);
} catch {
// Codex might fail if not installed, that's OK for config test
}
const finalAgent = await harness.agentRepository.findById(agent.id);
console.log(' Provider:', finalAgent?.provider);
console.log(' Status:', finalAgent?.status);
},
REAL_TEST_TIMEOUT
);
});
});
/**
* Codex-specific observations from testing:
*
* 1. Output Format:
* - Codex uses JSONL streaming with different event types
* - thread.started event contains thread_id
* - Output parsing is more generic (not JSON schema validated)
*
* 2. Command Structure:
* - codex exec --full-auto --json -p "prompt"
* - resume: codex exec resume <thread_id>
*
* 3. Session ID:
* - Called "thread_id" in Codex
* - Extracted from thread.started event
*
* 4. Resume:
* - Uses subcommand style: codex exec resume <thread_id>
* - Different from Claude's flag style: claude --resume <session_id>
*/

View File

@@ -0,0 +1,265 @@
/**
* Crash Recovery Integration Tests
*
* IMPORTANT: These tests call the REAL Claude CLI and incur API costs!
* They are SKIPPED by default and should only be run manually for validation.
*
* To run these tests:
* ```bash
* REAL_CLAUDE_TESTS=1 npm test -- src/test/integration/real-providers/crash-recovery.test.ts --test-timeout=300000
* ```
*
* Tests covered:
* - Server restart while agent is running
* - Resuming streaming after restart
* - Marking dead agents as crashed
* - Output file processing after restart
*
* Estimated cost: ~$0.08 per full run
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import {
createRealProviderHarness,
describeRealClaude,
REAL_TEST_TIMEOUT,
EXTENDED_TEST_TIMEOUT,
sleep,
type RealProviderHarness,
} from './harness.js';
import { MINIMAL_PROMPTS } from './prompts.js';
import { MultiProviderAgentManager } from '../../../agent/manager.js';
describeRealClaude('Crash Recovery', () => {
let harness: RealProviderHarness;
beforeAll(async () => {
console.log('\n=== Running Crash Recovery Tests ===');
console.log('These tests call the real Claude API and incur costs.\n');
harness = await createRealProviderHarness({ provider: 'claude' });
});
afterAll(async () => {
await harness.cleanup();
});
beforeEach(() => {
harness.clearEvents();
});
describe('Server Restart Simulation', () => {
it(
'resumes streaming for still-running agent after restart',
async () => {
// 1. Spawn agent with slow task
console.log(' 1. Spawning agent with slow task...');
const agent = await harness.agentManager.spawn({
taskId: null,
prompt: MINIMAL_PROMPTS.slow,
mode: 'execute',
provider: 'claude',
});
// 2. Wait for agent to be running
await harness.waitForAgentStatus(agent.id, 'running', 10000);
const dbAgent = await harness.agentRepository.findById(agent.id);
expect(dbAgent?.pid).toBeTruthy();
expect(dbAgent?.outputFilePath).toBeTruthy();
console.log(' 2. Agent running with PID:', dbAgent?.pid);
// 3. Give the agent a moment to start writing output
await sleep(2000);
// 4. Simulate server crash - create NEW manager (old state lost)
console.log(' 3. Simulating server restart with new manager...');
harness.clearEvents(); // Clear events from old manager
const newManager = new MultiProviderAgentManager(
harness.agentRepository,
harness.workspaceRoot,
harness.projectRepository,
harness.accountRepository,
harness.eventBus
);
// 5. Reconcile - should pick up running agent
console.log(' 4. Reconciling agent state...');
await newManager.reconcileAfterRestart();
// 6. Wait for completion via new manager
console.log(' 5. Waiting for completion via new manager...');
let attempts = 0;
let finalStatus = 'running';
while (attempts < 60) {
const refreshed = await harness.agentRepository.findById(agent.id);
if (refreshed?.status !== 'running') {
finalStatus = refreshed?.status ?? 'unknown';
break;
}
await sleep(2000);
attempts++;
}
const finalAgent = await harness.agentRepository.findById(agent.id);
console.log(' 6. Final status:', finalAgent?.status);
// Either completed successfully or crashed (both are valid outcomes)
expect(['idle', 'crashed', 'stopped']).toContain(finalAgent?.status);
if (finalAgent?.status === 'idle') {
const result = await newManager.getResult(agent.id);
console.log(' Result:', result?.message);
}
},
EXTENDED_TEST_TIMEOUT
);
it(
'marks dead agent as crashed during reconcile',
async () => {
// 1. Create a fake agent record with a dead PID
console.log(' 1. Creating fake agent with dead PID...');
const fakeAgent = await harness.agentRepository.create({
name: 'dead-agent-test',
taskId: null,
initiativeId: null,
sessionId: null,
worktreeId: 'dead-worktree',
status: 'running',
mode: 'execute',
provider: 'claude',
accountId: null,
});
// Set a PID that's definitely dead (high number that won't exist)
await harness.agentRepository.update(fakeAgent.id, { pid: 999999, outputFilePath: '/nonexistent/path' });
// Verify it's marked as running
let agent = await harness.agentRepository.findById(fakeAgent.id);
expect(agent?.status).toBe('running');
expect(agent?.pid).toBe(999999);
// 2. Create new manager and reconcile
console.log(' 2. Creating new manager and reconciling...');
const newManager = new MultiProviderAgentManager(
harness.agentRepository,
harness.workspaceRoot,
harness.projectRepository,
harness.accountRepository,
harness.eventBus
);
await newManager.reconcileAfterRestart();
// 3. Verify agent is now crashed
agent = await harness.agentRepository.findById(fakeAgent.id);
expect(agent?.status).toBe('crashed');
console.log(' 3. Agent marked as crashed (dead PID detected)');
},
REAL_TEST_TIMEOUT
);
it(
'processes output file for dead agent during reconcile',
async () => {
// 1. Spawn agent and wait for completion
console.log(' 1. Spawning agent to completion...');
const agent = await harness.agentManager.spawn({
taskId: null,
prompt: MINIMAL_PROMPTS.done,
mode: 'execute',
provider: 'claude',
});
await harness.waitForAgentCompletion(agent.id, REAL_TEST_TIMEOUT);
const dbAgent = await harness.agentRepository.findById(agent.id);
const outputFilePath = dbAgent?.outputFilePath;
expect(outputFilePath).toBeTruthy();
console.log(' 2. Output file:', outputFilePath);
// 2. Reset agent to "running" to simulate mid-crash state
await harness.agentRepository.update(agent.id, { status: 'running' });
// Clear result so reconcile has to re-process
await harness.agentRepository.update(agent.id, { result: null });
// Verify reset
let resetAgent = await harness.agentRepository.findById(agent.id);
expect(resetAgent?.status).toBe('running');
// 3. Create new manager and reconcile
console.log(' 3. Creating new manager and reconciling...');
harness.clearEvents();
const newManager = new MultiProviderAgentManager(
harness.agentRepository,
harness.workspaceRoot,
harness.projectRepository,
harness.accountRepository,
harness.eventBus
);
await newManager.reconcileAfterRestart();
// Give it a moment to process the file
await sleep(1000);
// 4. Verify agent was processed from output file
const finalAgent = await harness.agentRepository.findById(agent.id);
console.log(' 4. Final status:', finalAgent?.status);
// Should either be idle (processed successfully) or crashed (couldn't process)
expect(['idle', 'crashed']).toContain(finalAgent?.status);
},
REAL_TEST_TIMEOUT
);
});
describe('Event Consistency', () => {
it(
'does not duplicate events on restart',
async () => {
// 1. Spawn agent with slow task
console.log(' 1. Spawning agent...');
const agent = await harness.agentManager.spawn({
taskId: null,
prompt: MINIMAL_PROMPTS.streaming,
mode: 'execute',
provider: 'claude',
});
// 2. Wait for some output events
await sleep(3000);
const initialOutputCount = harness.getEventsByType('agent:output').length;
console.log(' 2. Initial output events:', initialOutputCount);
// 3. Wait for completion
await harness.waitForAgentCompletion(agent.id, REAL_TEST_TIMEOUT);
const finalOutputCount = harness.getEventsByType('agent:output').length;
console.log(' 3. Final output events:', finalOutputCount);
// 4. Create new manager and reconcile (agent already complete)
harness.clearEvents();
const newManager = new MultiProviderAgentManager(
harness.agentRepository,
harness.workspaceRoot,
harness.projectRepository,
harness.accountRepository,
harness.eventBus
);
await newManager.reconcileAfterRestart();
await sleep(1000);
// 5. Verify no new output events (agent was already complete)
const postReconcileOutputCount = harness.getEventsByType('agent:output').length;
console.log(' 4. Post-reconcile output events:', postReconcileOutputCount);
// Should not have re-emitted all the old output events
expect(postReconcileOutputCount).toBe(0);
},
REAL_TEST_TIMEOUT
);
});
});

View File

@@ -0,0 +1,378 @@
/**
* Real Provider Test Harness
*
* Extends the existing test infrastructure to use REAL MultiProviderAgentManager
* for integration testing with actual CLI providers like Claude and Codex.
*
* Unlike the standard TestHarness which uses MockAgentManager, this harness:
* - Uses real CLI spawning (costs real API credits!)
* - Provides poll-based waiting helpers
* - Captures events for inspection
* - Manages temp directories for worktrees
*/
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe } from 'vitest';
import type { DrizzleDatabase } from '../../../db/index.js';
import type { DomainEvent, EventBus } from '../../../events/types.js';
import { EventEmitterBus } from '../../../events/bus.js';
import { MultiProviderAgentManager } from '../../../agent/manager.js';
import type { AgentResult, PendingQuestions, AgentStatus } from '../../../agent/types.js';
import type { AgentRepository } from '../../../db/repositories/agent-repository.js';
import type { ProjectRepository } from '../../../db/repositories/project-repository.js';
import type { AccountRepository } from '../../../db/repositories/account-repository.js';
import type { InitiativeRepository } from '../../../db/repositories/initiative-repository.js';
import {
DrizzleAgentRepository,
DrizzleProjectRepository,
DrizzleAccountRepository,
DrizzleInitiativeRepository,
} from '../../../db/repositories/drizzle/index.js';
import { createTestDatabase } from '../../../db/repositories/drizzle/test-helpers.js';
/**
* Sleep helper for polling loops.
*/
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Event bus that captures all emitted events for inspection.
*/
export class CapturingEventBus extends EventEmitterBus {
emittedEvents: DomainEvent[] = [];
emit<T extends DomainEvent>(event: T): void {
this.emittedEvents.push(event);
super.emit(event);
}
getEventsByType<T extends DomainEvent>(type: T['type']): T[] {
return this.emittedEvents.filter((e) => e.type === type) as T[];
}
clearEvents(): void {
this.emittedEvents = [];
}
}
/**
* Options for creating a real provider test harness.
*/
export interface RealProviderHarnessOptions {
/** Which provider to test (default: 'claude') */
provider?: 'claude' | 'codex';
/** Optional workspace root (temp dir created if omitted) */
workspaceRoot?: string;
}
/**
* Real Provider Test Harness interface.
*
* Provides everything needed to test against real CLI providers:
* - In-memory database with real repositories
* - Real MultiProviderAgentManager (spawns actual CLI processes)
* - Event capture for verification
* - Polling-based wait helpers
*/
export interface RealProviderHarness {
/** In-memory SQLite database */
db: DrizzleDatabase;
/** Event bus with capture capability */
eventBus: CapturingEventBus;
/** Real agent manager (not mock!) */
agentManager: MultiProviderAgentManager;
/** Workspace root directory */
workspaceRoot: string;
/** Agent repository */
agentRepository: AgentRepository;
/** Project repository */
projectRepository: ProjectRepository;
/** Account repository */
accountRepository: AccountRepository;
/** Initiative repository */
initiativeRepository: InitiativeRepository;
/**
* Wait for an agent to reach idle or crashed status.
* Polls the database at regular intervals.
*
* @param agentId - The agent ID to wait for
* @param timeoutMs - Maximum time to wait (default 120000ms = 2 minutes)
* @returns The agent result if completed, or null if crashed/timeout
*/
waitForAgentCompletion(agentId: string, timeoutMs?: number): Promise<AgentResult | null>;
/**
* Wait for an agent to enter waiting_for_input status.
* Polls the database at regular intervals.
*
* @param agentId - The agent ID to wait for
* @param timeoutMs - Maximum time to wait (default 120000ms)
* @returns The pending questions if waiting, or null if timeout/other status
*/
waitForAgentWaiting(agentId: string, timeoutMs?: number): Promise<PendingQuestions | null>;
/**
* Wait for an agent to reach a specific status.
*
* @param agentId - The agent ID to wait for
* @param status - The target status
* @param timeoutMs - Maximum time to wait (default 120000ms)
*/
waitForAgentStatus(agentId: string, status: AgentStatus, timeoutMs?: number): Promise<void>;
/**
* Get captured events filtered by type.
*/
getEventsByType<T extends DomainEvent>(type: T['type']): T[];
/**
* Clear all captured events.
*/
clearEvents(): void;
/**
* Kill all running agents (for cleanup).
*/
killAllAgents(): Promise<void>;
/**
* Clean up all resources (directories, processes).
* Call this in afterAll/afterEach.
*/
cleanup(): Promise<void>;
}
/** Default poll interval for status checks */
const POLL_INTERVAL_MS = 1000;
/**
* Create a test harness for real provider integration tests.
*
* This creates:
* - In-memory SQLite database
* - Temp directory for worktrees (or uses provided workspace)
* - Real MultiProviderAgentManager
* - Event capture bus
*
* @example
* ```typescript
* let harness: RealProviderHarness;
*
* beforeAll(async () => {
* harness = await createRealProviderHarness({ provider: 'claude' });
* });
*
* afterAll(async () => {
* await harness.cleanup();
* });
*
* it('spawns and completes', async () => {
* const agent = await harness.agentManager.spawn({...});
* const result = await harness.waitForAgentCompletion(agent.id);
* expect(result?.success).toBe(true);
* });
* ```
*/
export async function createRealProviderHarness(
options: RealProviderHarnessOptions = {}
): Promise<RealProviderHarness> {
// Create workspace directory (temp if not provided)
const workspaceRoot = options.workspaceRoot ?? (await mkdtemp(join(tmpdir(), 'cw-test-')));
const ownedWorkspace = !options.workspaceRoot; // Track if we need to clean up
// Initialize git repo in temp workspace (required for worktree operations)
if (ownedWorkspace) {
const { execSync } = await import('node:child_process');
execSync('git init', { cwd: workspaceRoot, stdio: 'ignore' });
execSync('git config user.email "test@test.com"', { cwd: workspaceRoot, stdio: 'ignore' });
execSync('git config user.name "Test"', { cwd: workspaceRoot, stdio: 'ignore' });
// Create initial commit (worktrees require at least one commit)
execSync('touch .gitkeep && git add .gitkeep && git commit -m "init"', { cwd: workspaceRoot, stdio: 'ignore' });
}
// Create in-memory database
const db = createTestDatabase();
// Create repositories
const agentRepository = new DrizzleAgentRepository(db);
const projectRepository = new DrizzleProjectRepository(db);
const accountRepository = new DrizzleAccountRepository(db);
const initiativeRepository = new DrizzleInitiativeRepository(db);
// Create event bus with capture (parent class already sets maxListeners to 100)
const eventBus = new CapturingEventBus();
// Create REAL agent manager (not mock!)
const agentManager = new MultiProviderAgentManager(
agentRepository,
workspaceRoot,
projectRepository,
accountRepository,
eventBus
);
// Build harness
const harness: RealProviderHarness = {
db,
eventBus,
agentManager,
workspaceRoot,
agentRepository,
projectRepository,
accountRepository,
initiativeRepository,
async waitForAgentCompletion(agentId: string, timeoutMs = 120000): Promise<AgentResult | null> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const agent = await agentRepository.findById(agentId);
if (!agent) return null;
if (agent.status === 'idle' || agent.status === 'stopped') {
// Agent completed - get result
return agentManager.getResult(agentId);
}
if (agent.status === 'crashed') {
// Agent crashed - return the error result
return agentManager.getResult(agentId);
}
if (agent.status === 'waiting_for_input') {
// Agent is waiting - return null (not completed)
return null;
}
// Still running - wait and check again
await sleep(POLL_INTERVAL_MS);
}
throw new Error(`Timeout waiting for agent ${agentId} to complete after ${timeoutMs}ms`);
},
async waitForAgentWaiting(agentId: string, timeoutMs = 120000): Promise<PendingQuestions | null> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const agent = await agentRepository.findById(agentId);
if (!agent) return null;
if (agent.status === 'waiting_for_input') {
return agentManager.getPendingQuestions(agentId);
}
if (agent.status === 'idle' || agent.status === 'stopped' || agent.status === 'crashed') {
// Agent finished without asking questions
return null;
}
// Still running - wait and check again
await sleep(POLL_INTERVAL_MS);
}
throw new Error(`Timeout waiting for agent ${agentId} to request input after ${timeoutMs}ms`);
},
async waitForAgentStatus(agentId: string, status: AgentStatus, timeoutMs = 120000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const agent = await agentRepository.findById(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
if (agent.status === status) {
return;
}
// Check for terminal states that mean we'll never reach target
if (status === 'running' && ['idle', 'stopped', 'crashed', 'waiting_for_input'].includes(agent.status)) {
throw new Error(`Agent ${agentId} already in terminal state ${agent.status}, cannot reach ${status}`);
}
await sleep(POLL_INTERVAL_MS);
}
throw new Error(`Timeout waiting for agent ${agentId} to reach status ${status} after ${timeoutMs}ms`);
},
getEventsByType<T extends DomainEvent>(type: T['type']): T[] {
return eventBus.getEventsByType<T>(type);
},
clearEvents(): void {
eventBus.clearEvents();
},
async killAllAgents(): Promise<void> {
const agents = await agentRepository.findAll();
for (const agent of agents) {
if (agent.status === 'running') {
try {
await agentManager.stop(agent.id);
} catch {
// Ignore errors during cleanup
}
}
}
},
async cleanup(): Promise<void> {
// Kill any running agents
await harness.killAllAgents();
// Clean up workspace directory if we created it
if (ownedWorkspace) {
try {
await rm(workspaceRoot, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
}
},
};
return harness;
}
/**
* Check if real Claude tests should run.
* Set REAL_CLAUDE_TESTS=1 environment variable to enable.
*/
export const shouldRunRealClaudeTests = process.env.REAL_CLAUDE_TESTS === '1';
/**
* Check if real Codex tests should run.
* Set REAL_CODEX_TESTS=1 environment variable to enable.
*/
export const shouldRunRealCodexTests = process.env.REAL_CODEX_TESTS === '1';
/**
* Skip wrapper for Claude tests - skips unless REAL_CLAUDE_TESTS=1.
*/
export const describeRealClaude: typeof describe = shouldRunRealClaudeTests ? describe : describe.skip;
/**
* Skip wrapper for Codex tests - skips unless REAL_CODEX_TESTS=1.
*/
export const describeRealCodex: typeof describe = shouldRunRealCodexTests ? describe : describe.skip;
/**
* Default test timeout for real CLI tests (2 minutes).
* Real API calls take 5-30 seconds typically.
*/
export const REAL_TEST_TIMEOUT = 120000;
/**
* Extended test timeout for slow tests (5 minutes).
* Used for schema retry tests and crash recovery tests.
*/
export const EXTENDED_TEST_TIMEOUT = 300000;

View File

@@ -0,0 +1,56 @@
/**
* Real Provider Integration Tests
*
* This module provides infrastructure for testing against real CLI providers.
* Tests are expensive (real API calls) and skipped by default.
*
* ## Running Tests
*
* ```bash
* # Claude tests only
* REAL_CLAUDE_TESTS=1 npm test -- src/test/integration/real-providers/ --test-timeout=300000
*
* # Codex tests only
* REAL_CODEX_TESTS=1 npm test -- src/test/integration/real-providers/codex-manager.test.ts
*
* # All real provider tests
* REAL_CLAUDE_TESTS=1 REAL_CODEX_TESTS=1 npm test -- src/test/integration/real-providers/
* ```
*
* ## Cost Estimates
*
* | Suite | Tests | Est. Cost | Duration |
* |-------|-------|-----------|----------|
* | Output Parsing | 3 | $0.06 | ~2 min |
* | Schema Validation | 4 | $0.22 | ~4 min |
* | Crash Recovery | 3 | $0.08 | ~3 min |
* | Session Resume | 2 | $0.08 | ~3 min |
* | Codex Integration | 2 | $0.10 | ~2 min |
* | **TOTAL** | **14** | **~$0.54** | **~14 min** |
*
* ## Test Files
*
* - `harness.ts` - RealProviderHarness factory and utilities
* - `prompts.ts` - Minimal cost test prompts
* - `claude-manager.test.ts` - Claude spawn/resume/output tests
* - `codex-manager.test.ts` - Codex provider tests
* - `schema-retry.test.ts` - Schema validation + retry tests
* - `crash-recovery.test.ts` - Server restart simulation
* - `sample-outputs/` - Captured CLI output for parser unit tests
*/
export {
createRealProviderHarness,
CapturingEventBus,
sleep,
shouldRunRealClaudeTests,
shouldRunRealCodexTests,
describeRealClaude,
describeRealCodex,
REAL_TEST_TIMEOUT,
EXTENDED_TEST_TIMEOUT,
type RealProviderHarness,
type RealProviderHarnessOptions,
} from './harness.js';
export { MINIMAL_PROMPTS, CODEX_PROMPTS } from './prompts.js';

View File

@@ -0,0 +1,113 @@
/**
* Minimal Cost Test Prompts
*
* Carefully crafted prompts designed to minimize token usage while
* testing specific CLI behaviors. Each prompt aims for the smallest
* possible API cost while still exercising the target functionality.
*
* Cost estimates assume Claude Sonnet pricing (~$3/M input, $15/M output).
*/
export const MINIMAL_PROMPTS = {
/**
* ~$0.01 - Cheapest done response
* Tests: basic spawn → completion flow, status parsing
*/
done: `Output exactly this JSON with no other text:
{"status":"done","result":"ok"}`,
/**
* ~$0.01 - Cheapest questions response
* Tests: waiting_for_input status, questions array parsing
*/
questions: `Output exactly this JSON with no other text:
{"status":"questions","questions":[{"id":"q1","question":"What is your name?"}]}`,
/**
* ~$0.03 - Slow task for timing tests
* Tests: streaming during long-running task, crash recovery
* Note: Agent may not actually wait 30 seconds, but will produce delayed output
*/
slow: `Think through a simple problem step by step, counting from 1 to 10 slowly, then output:
{"status":"done","result":"counted to 10"}`,
/**
* ~$0.02 - Produces text deltas for streaming tests
* Tests: text_delta event parsing, output buffering
*/
streaming: `Count from 1 to 5, outputting each number, then output:
{"status":"done","result":"counted"}`,
/**
* ~$0.03 - Deliberately produces non-JSON first
* Tests: schema validation failure, retry logic
*/
badThenGood: `First say "thinking..." on its own line, then output:
{"status":"done","result":"fixed"}`,
/**
* ~$0.02 - Multiple questions
* Tests: questions array with multiple items
*/
multipleQuestions: `Output exactly this JSON with no other text:
{"status":"questions","questions":[{"id":"q1","question":"First question?"},{"id":"q2","question":"Second question?"}]}`,
/**
* ~$0.01 - Error signal
* Tests: error status handling
*/
error: `Output exactly this JSON with no other text:
{"status":"error","error":"Test error message"}`,
/**
* ~$0.02 - Answer continuation
* Tests: session resume with answers
*/
answerContinuation: (answers: Record<string, string>): string => {
const answerLines = Object.entries(answers)
.map(([id, answer]) => `${id}: ${answer}`)
.join('\n');
return `I received your answers:
${answerLines}
Now complete the task by outputting:
{"status":"done","result":"completed with answers"}`;
},
/**
* ~$0.02 - Context complete for discuss mode
* Tests: discuss mode output handling (now uses universal done signal)
*/
discussComplete: `Output exactly this JSON with no other text:
{"status":"done"}`,
/**
* ~$0.02 - Breakdown complete
* Tests: breakdown mode output handling (now uses universal done signal)
*/
breakdownComplete: `Output exactly this JSON with no other text:
{"status":"done"}`,
/**
* ~$0.02 - Decompose complete
* Tests: decompose mode output handling (now uses universal done signal)
*/
decomposeComplete: `Output exactly this JSON with no other text:
{"status":"done"}`,
} as const;
/**
* Prompts specifically for Codex provider testing.
* Codex may have different output format requirements.
*/
export const CODEX_PROMPTS = {
/**
* Basic completion for Codex
*/
done: `Complete this simple task: output "done" and finish.`,
/**
* Produces streaming output
*/
streaming: `Count from 1 to 5, saying each number aloud, then say "finished".`,
} as const;

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