Add userDismissedAt field to agents schema
This commit is contained in:
67
src/agent/accounts/extractor.ts
Normal file
67
src/agent/accounts/extractor.ts
Normal 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 };
|
||||
}
|
||||
5
src/agent/accounts/index.ts
Normal file
5
src/agent/accounts/index.ts
Normal 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';
|
||||
5
src/agent/accounts/paths.ts
Normal file
5
src/agent/accounts/paths.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
export function getAccountConfigDir(workspaceRoot: string, accountId: string): string {
|
||||
return join(workspaceRoot, '.cw', 'accounts', accountId);
|
||||
}
|
||||
15
src/agent/accounts/setup.ts
Normal file
15
src/agent/accounts/setup.ts
Normal 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
332
src/agent/accounts/usage.ts
Normal 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
34
src/agent/alias.ts
Normal 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`);
|
||||
}
|
||||
323
src/agent/cleanup-manager.ts
Normal file
323
src/agent/cleanup-manager.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
126
src/agent/content-serializer.ts
Normal file
126
src/agent/content-serializer.ts
Normal 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');
|
||||
}
|
||||
152
src/agent/credential-handler.ts
Normal file
152
src/agent/credential-handler.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
318
src/agent/credentials/default-credential-manager.ts
Normal file
318
src/agent/credentials/default-credential-manager.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
src/agent/credentials/index.ts
Normal file
17
src/agent/credentials/index.ts
Normal 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';
|
||||
98
src/agent/credentials/types.ts
Normal file
98
src/agent/credentials/types.ts
Normal 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
340
src/agent/file-io.test.ts
Normal 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
288
src/agent/file-io.ts
Normal 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
267
src/agent/file-tailer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
496
src/agent/output-handler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
269
src/agent/process-manager.ts
Normal file
269
src/agent/process-manager.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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/`;
|
||||
}
|
||||
|
||||
40
src/agent/providers/index.ts
Normal file
40
src/agent/providers/index.ts
Normal 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';
|
||||
165
src/agent/providers/parsers/claude.ts
Normal file
165
src/agent/providers/parsers/claude.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
32
src/agent/providers/parsers/generic.ts
Normal file
32
src/agent/providers/parsers/generic.ts
Normal 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 }];
|
||||
}
|
||||
}
|
||||
31
src/agent/providers/parsers/index.ts
Normal file
31
src/agent/providers/parsers/index.ts
Normal 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';
|
||||
145
src/agent/providers/presets.ts
Normal file
145
src/agent/providers/presets.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
50
src/agent/providers/registry.ts
Normal file
50
src/agent/providers/registry.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
77
src/agent/providers/stream-types.ts
Normal file
77
src/agent/providers/stream-types.ts
Normal 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[];
|
||||
}
|
||||
61
src/agent/providers/types.ts
Normal file
61
src/agent/providers/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
423
src/cli/index.ts
423
src/cli/index.ts
@@ -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
66
src/config/cwrc.ts
Normal 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
13
src/config/index.ts
Normal 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
15
src/config/types.ts
Normal 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
162
src/container.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
372
src/coordination/conflict-resolution-service.test.ts
Normal file
372
src/coordination/conflict-resolution-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
136
src/coordination/conflict-resolution-service.ts
Normal file
136
src/coordination/conflict-resolution-service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
61
src/db/repositories/account-repository.ts
Normal file
61
src/db/repositories/account-repository.ts
Normal 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>;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
203
src/db/repositories/drizzle/account.ts
Normal file
203
src/db/repositories/drizzle/account.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
109
src/db/repositories/drizzle/page.ts
Normal file
109
src/db/repositories/drizzle/page.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
154
src/db/repositories/drizzle/project.ts
Normal file
154
src/db/repositories/drizzle/project.ts
Normal 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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
33
src/db/repositories/page-repository.ts
Normal file
33
src/db/repositories/page-repository.ts
Normal 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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
38
src/db/repositories/project-repository.ts
Normal file
38
src/db/repositories/project-repository.ts
Normal 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>;
|
||||
}
|
||||
@@ -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[]>;
|
||||
}
|
||||
|
||||
227
src/db/schema.ts
227
src/db/schema.ts
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
24
src/git/clone.ts
Normal 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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
48
src/git/project-clones.ts
Normal 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
28
src/logger/index.ts
Normal 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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
export {
|
||||
seedFixture,
|
||||
type TaskFixture,
|
||||
type PlanFixture,
|
||||
type TaskGroupFixture,
|
||||
type PhaseFixture,
|
||||
type InitiativeFixture,
|
||||
type SeededFixture,
|
||||
|
||||
@@ -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
|
||||
|
||||
304
src/test/integration/real-providers/claude-manager.test.ts
Normal file
304
src/test/integration/real-providers/claude-manager.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
176
src/test/integration/real-providers/codex-manager.test.ts
Normal file
176
src/test/integration/real-providers/codex-manager.test.ts
Normal 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>
|
||||
*/
|
||||
265
src/test/integration/real-providers/crash-recovery.test.ts
Normal file
265
src/test/integration/real-providers/crash-recovery.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
378
src/test/integration/real-providers/harness.ts
Normal file
378
src/test/integration/real-providers/harness.ts
Normal 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;
|
||||
56
src/test/integration/real-providers/index.ts
Normal file
56
src/test/integration/real-providers/index.ts
Normal 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';
|
||||
113
src/test/integration/real-providers/prompts.ts
Normal file
113
src/test/integration/real-providers/prompts.ts
Normal 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
Reference in New Issue
Block a user