chore: merge main into cw/small-change-flow
Integrates main branch changes (headquarters dashboard, task retry count, agent prompt persistence, remote sync improvements) with the initiative's errand agent feature. Both features coexist in the merged result. Key resolutions: - Schema: take main's errands table (nullable projectId, no conflictFiles, with errandsRelations); migrate to 0035_faulty_human_fly - Router: keep both errandProcedures and headquartersProcedures - Errand prompt: take main's simpler version (no question-asking flow) - Manager: take main's status check (running|idle only, no waiting_for_input) - Tests: update to match removed conflictFiles field and undefined vs null
This commit is contained in:
@@ -481,36 +481,4 @@ describe('buildErrandPrompt', () => {
|
||||
const result = buildErrandPrompt('some change');
|
||||
expect(result).toContain('"status": "error"');
|
||||
});
|
||||
|
||||
it('includes instructions for asking questions', () => {
|
||||
const result = buildErrandPrompt('some change');
|
||||
expect(result).toMatch(/ask|question/i);
|
||||
expect(result).toMatch(/chat|message|reply/i);
|
||||
});
|
||||
|
||||
it('includes questions signal format for session-ending questions', () => {
|
||||
const result = buildErrandPrompt('some change');
|
||||
expect(result).toContain('"status": "questions"');
|
||||
expect(result).toContain('"questions"');
|
||||
});
|
||||
|
||||
it('explains session ends and resumes with user answers', () => {
|
||||
const result = buildErrandPrompt('some change');
|
||||
expect(result).toMatch(/resume|end.*session|session.*end/i);
|
||||
});
|
||||
|
||||
it('does not present inline asking as an alternative that bypasses signal.json', () => {
|
||||
const result = buildErrandPrompt('some change');
|
||||
// "session stays open" implied agents can skip signal.json — all exits must write it
|
||||
expect(result).not.toMatch(/session stays open/i);
|
||||
expect(result).not.toMatch(/Option A/i);
|
||||
});
|
||||
|
||||
it('requires signal.json for all question-asking paths', () => {
|
||||
const result = buildErrandPrompt('some change');
|
||||
// questions status must be the mechanism for all user-input requests
|
||||
expect(result).toContain('"status": "questions"');
|
||||
// must not describe a path that skips signal.json
|
||||
expect(result).not.toMatch(/session stays open/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface AgentInfo {
|
||||
status: string;
|
||||
initiativeId?: string | null;
|
||||
worktreeId: string;
|
||||
exitCode?: number | null;
|
||||
}
|
||||
|
||||
export interface CleanupStrategy {
|
||||
|
||||
@@ -50,6 +50,7 @@ function makeController(overrides: {
|
||||
cleanupStrategy,
|
||||
overrides.accountRepository as AccountRepository | undefined,
|
||||
false,
|
||||
overrides.eventBus,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { RetryPolicy, AgentError } from './retry-policy.js';
|
||||
import { AgentExhaustedError, AgentFailureError } from './retry-policy.js';
|
||||
import type { AgentErrorAnalyzer } from './error-analyzer.js';
|
||||
import type { CleanupStrategy, AgentInfo } from './cleanup-strategy.js';
|
||||
import type { EventBus, AgentAccountSwitchedEvent } from '../../events/types.js';
|
||||
|
||||
const log = createModuleLogger('lifecycle-controller');
|
||||
|
||||
@@ -48,6 +49,7 @@ export class AgentLifecycleController {
|
||||
private cleanupStrategy: CleanupStrategy,
|
||||
private accountRepository?: AccountRepository,
|
||||
private debug: boolean = false,
|
||||
private eventBus?: EventBus,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -304,7 +306,7 @@ export class AgentLifecycleController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle account exhaustion by marking account as exhausted.
|
||||
* Handle account exhaustion by marking account as exhausted and emitting account_switched event.
|
||||
*/
|
||||
private async handleAccountExhaustion(agentId: string): Promise<void> {
|
||||
if (!this.accountRepository) {
|
||||
@@ -319,15 +321,34 @@ export class AgentLifecycleController {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousAccountId = agent.accountId;
|
||||
|
||||
// Mark account as exhausted for 1 hour
|
||||
const exhaustedUntil = new Date(Date.now() + 60 * 60 * 1000);
|
||||
await this.accountRepository.markExhausted(agent.accountId, exhaustedUntil);
|
||||
await this.accountRepository.markExhausted(previousAccountId, exhaustedUntil);
|
||||
|
||||
log.info({
|
||||
agentId,
|
||||
accountId: agent.accountId,
|
||||
accountId: previousAccountId,
|
||||
exhaustedUntil
|
||||
}, 'marked account as exhausted due to usage limits');
|
||||
|
||||
// Find the next available account and emit account_switched event
|
||||
const newAccount = await this.accountRepository.findNextAvailable(agent.provider ?? 'claude');
|
||||
if (newAccount && this.eventBus) {
|
||||
const event: AgentAccountSwitchedEvent = {
|
||||
type: 'agent:account_switched',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
agentId,
|
||||
name: agent.name,
|
||||
previousAccountId,
|
||||
newAccountId: newAccount.id,
|
||||
reason: 'account_exhausted',
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn({
|
||||
agentId,
|
||||
@@ -353,6 +374,7 @@ export class AgentLifecycleController {
|
||||
status: agent.status,
|
||||
initiativeId: agent.initiativeId,
|
||||
worktreeId: agent.worktreeId,
|
||||
exitCode: agent.exitCode ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type { AgentRepository } from '../../db/repositories/agent-repository.js'
|
||||
import type { AccountRepository } from '../../db/repositories/account-repository.js';
|
||||
import type { ProcessManager } from '../process-manager.js';
|
||||
import type { CleanupManager } from '../cleanup-manager.js';
|
||||
import type { EventBus } from '../../events/types.js';
|
||||
|
||||
export interface LifecycleFactoryOptions {
|
||||
repository: AgentRepository;
|
||||
@@ -21,6 +22,7 @@ export interface LifecycleFactoryOptions {
|
||||
cleanupManager: CleanupManager;
|
||||
accountRepository?: AccountRepository;
|
||||
debug?: boolean;
|
||||
eventBus?: EventBus;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,7 +34,8 @@ export function createLifecycleController(options: LifecycleFactoryOptions): Age
|
||||
processManager,
|
||||
cleanupManager,
|
||||
accountRepository,
|
||||
debug = false
|
||||
debug = false,
|
||||
eventBus,
|
||||
} = options;
|
||||
|
||||
// Create core components
|
||||
@@ -51,7 +54,8 @@ export function createLifecycleController(options: LifecycleFactoryOptions): Age
|
||||
cleanupManager,
|
||||
cleanupStrategy,
|
||||
accountRepository,
|
||||
debug
|
||||
debug,
|
||||
eventBus,
|
||||
);
|
||||
|
||||
return lifecycleController;
|
||||
|
||||
@@ -463,10 +463,10 @@ describe('MultiProviderAgentManager', () => {
|
||||
});
|
||||
|
||||
describe('sendUserMessage', () => {
|
||||
it('resumes errand agent in waiting_for_input status', async () => {
|
||||
it('resumes errand agent in idle status', async () => {
|
||||
mockRepository.findById = vi.fn().mockResolvedValue({
|
||||
...mockAgent,
|
||||
status: 'waiting_for_input',
|
||||
status: 'idle',
|
||||
});
|
||||
|
||||
const mockChild = createMockChildProcess();
|
||||
|
||||
@@ -98,6 +98,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
cleanupManager: this.cleanupManager,
|
||||
accountRepository,
|
||||
debug,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
// Listen for process crashed events to handle agents specially
|
||||
@@ -238,8 +239,18 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
log.debug({ alias, initiativeId, baseBranch, branchName }, 'creating initiative-based worktrees');
|
||||
agentCwd = await this.processManager.createProjectWorktrees(alias, initiativeId, baseBranch, branchName);
|
||||
|
||||
// Log projects linked to the initiative
|
||||
// Verify each project worktree subdirectory actually exists
|
||||
const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId);
|
||||
for (const project of projects) {
|
||||
const projectWorktreePath = join(agentCwd, project.name);
|
||||
if (!existsSync(projectWorktreePath)) {
|
||||
throw new Error(
|
||||
`Worktree subdirectory missing after createProjectWorktrees: ${projectWorktreePath}. ` +
|
||||
`Agent ${alias} cannot run without an isolated worktree.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log.info({
|
||||
alias,
|
||||
initiativeId,
|
||||
@@ -254,11 +265,12 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
}
|
||||
|
||||
// Verify the final agentCwd exists
|
||||
const cwdVerified = existsSync(agentCwd);
|
||||
if (!existsSync(agentCwd)) {
|
||||
throw new Error(`Agent workdir does not exist after creation: ${agentCwd}`);
|
||||
}
|
||||
log.info({
|
||||
alias,
|
||||
agentCwd,
|
||||
cwdVerified,
|
||||
initiativeBasedAgent: !!initiativeId
|
||||
}, 'agent workdir setup completed');
|
||||
|
||||
@@ -282,14 +294,15 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
});
|
||||
const agentId = agent.id;
|
||||
|
||||
// 3a. Append inter-agent communication instructions with actual agent ID
|
||||
prompt = prompt + buildInterAgentCommunication(agentId, mode);
|
||||
// 3a. Append inter-agent communication + preview instructions (skipped for focused agents)
|
||||
if (!options.skipPromptExtras) {
|
||||
prompt = prompt + buildInterAgentCommunication(agentId, mode);
|
||||
|
||||
// 3b. Append preview deployment instructions if applicable
|
||||
if (['execute', 'refine', 'discuss'].includes(mode) && initiativeId) {
|
||||
const shouldInject = await this.shouldInjectPreviewInstructions(initiativeId);
|
||||
if (shouldInject) {
|
||||
prompt = prompt + buildPreviewInstructions(agentId);
|
||||
if (['execute', 'refine', 'discuss'].includes(mode) && initiativeId) {
|
||||
const shouldInject = await this.shouldInjectPreviewInstructions(initiativeId);
|
||||
if (shouldInject) {
|
||||
prompt = prompt + buildPreviewInstructions(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +310,10 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
if (options.inputContext) {
|
||||
await writeInputFiles({ agentWorkdir: agentCwd, ...options.inputContext, agentId, agentName: alias });
|
||||
log.debug({ alias }, 'input files written');
|
||||
} else {
|
||||
// Always create .cw/output/ at the agent workdir root so the agent
|
||||
// writes signal.json here rather than in a project subdirectory.
|
||||
await mkdir(join(agentCwd, '.cw', 'output'), { recursive: true });
|
||||
}
|
||||
|
||||
// 4. Build spawn command
|
||||
@@ -330,7 +347,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
this.createLogChunkCallback(agentId, alias, 1),
|
||||
);
|
||||
|
||||
await this.repository.update(agentId, { pid, outputFilePath });
|
||||
await this.repository.update(agentId, { pid, outputFilePath, prompt });
|
||||
|
||||
// Register agent and start polling BEFORE non-critical I/O so that a
|
||||
// diagnostic-write failure can never orphan a running process.
|
||||
@@ -603,6 +620,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
this.activeAgents.set(agentId, activeEntry);
|
||||
|
||||
if (this.eventBus) {
|
||||
// verified: payload matches AgentResumedEvent shape (agentId, name, taskId, sessionId)
|
||||
const event: AgentResumedEvent = {
|
||||
type: 'agent:resumed',
|
||||
timestamp: new Date(),
|
||||
@@ -634,7 +652,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
const agent = await this.repository.findById(agentId);
|
||||
if (!agent) throw new Error(`Agent not found: ${agentId}`);
|
||||
|
||||
if (agent.status !== 'running' && agent.status !== 'idle' && agent.status !== 'waiting_for_input') {
|
||||
if (agent.status !== 'running' && agent.status !== 'idle') {
|
||||
throw new Error(`Agent is not running (status: ${agent.status})`);
|
||||
}
|
||||
|
||||
@@ -859,6 +877,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
log.info({ agentId, pid }, 'resume detached subprocess started');
|
||||
|
||||
if (this.eventBus) {
|
||||
// verified: payload matches AgentResumedEvent shape (agentId, name, taskId, sessionId)
|
||||
const event: AgentResumedEvent = {
|
||||
type: 'agent:resumed',
|
||||
timestamp: new Date(),
|
||||
@@ -1163,6 +1182,8 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
userDismissedAt?: Date | null;
|
||||
exitCode?: number | null;
|
||||
prompt?: string | null;
|
||||
}): AgentInfo {
|
||||
return {
|
||||
id: agent.id,
|
||||
@@ -1178,6 +1199,8 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
createdAt: agent.createdAt,
|
||||
updatedAt: agent.updatedAt,
|
||||
userDismissedAt: agent.userDismissedAt,
|
||||
exitCode: agent.exitCode ?? null,
|
||||
prompt: agent.prompt ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,8 @@ export class MockAgentManager implements AgentManager {
|
||||
accountId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
exitCode: null,
|
||||
prompt: null,
|
||||
};
|
||||
|
||||
const record: MockAgentRecord = {
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
|
||||
import {
|
||||
SIGNAL_FORMAT,
|
||||
SESSION_STARTUP,
|
||||
GIT_WORKFLOW,
|
||||
CONTEXT_MANAGEMENT,
|
||||
} from './shared.js';
|
||||
|
||||
export function buildConflictResolutionPrompt(
|
||||
@@ -29,7 +27,12 @@ You are a Conflict Resolution agent. Your job is to merge \`${targetBranch}\` in
|
||||
${conflictList}
|
||||
</conflict_details>
|
||||
${SIGNAL_FORMAT}
|
||||
${SESSION_STARTUP}
|
||||
|
||||
<session_startup>
|
||||
1. \`pwd\` — confirm working directory
|
||||
2. \`git status\` — check branch state
|
||||
3. Read \`CLAUDE.md\` at the repo root (if it exists) — it contains project conventions you must follow.
|
||||
</session_startup>
|
||||
|
||||
<resolution_protocol>
|
||||
Follow these steps in order:
|
||||
@@ -57,7 +60,6 @@ Follow these steps in order:
|
||||
8. **Signal done**: Write signal.json with status "done".
|
||||
</resolution_protocol>
|
||||
${GIT_WORKFLOW}
|
||||
${CONTEXT_MANAGEMENT}
|
||||
|
||||
<important>
|
||||
- You are on a temporary branch created from ${sourceBranch}. You are merging ${targetBranch} INTO this branch — bringing it up to date, NOT the other way around.
|
||||
|
||||
@@ -4,21 +4,6 @@ export function buildErrandPrompt(description: string): string {
|
||||
Description: ${description}
|
||||
|
||||
Work interactively with the user. Make only the changes needed to fulfill the description.
|
||||
|
||||
## Asking questions
|
||||
|
||||
If you need clarification before or during the change, write .cw/output/signal.json with the questions format and end your session:
|
||||
|
||||
{ "status": "questions", "questions": [{ "id": "q1", "question": "What is the target file?" }] }
|
||||
|
||||
The session will end. The user will be shown your questions in the UI or via:
|
||||
|
||||
cw errand chat <id> "<their answer>"
|
||||
|
||||
Your session will then resume with their answers. Be explicit about what you need — don't make assumptions when the task is ambiguous.
|
||||
|
||||
## Finishing
|
||||
|
||||
When you are done, write .cw/output/signal.json:
|
||||
|
||||
{ "status": "done", "result": { "message": "<one-sentence summary of what you changed>" } }
|
||||
|
||||
@@ -81,6 +81,15 @@ Each phase must pass: **"Could a detail agent break this into tasks without clar
|
||||
</examples>
|
||||
</specificity>
|
||||
|
||||
<subagent_usage>
|
||||
Use subagents to parallelize your analysis — don't do everything sequentially:
|
||||
- **Domain decomposition**: Spawn separate subagents to investigate different aspects of the initiative (e.g., one for database/schema concerns, one for API surface, one for frontend components) and synthesize their findings into your phase plan.
|
||||
- **Dependency mapping**: Spawn a subagent to map existing code dependencies and file ownership while you analyze initiative requirements, so you can make informed decisions about phase boundaries and parallelism.
|
||||
- **Pattern discovery**: When the initiative touches multiple subsystems, spawn subagents to search for existing patterns in each subsystem simultaneously rather than exploring them one at a time.
|
||||
|
||||
Don't spawn subagents for trivial initiatives with obvious structure — use judgment.
|
||||
</subagent_usage>
|
||||
|
||||
<existing_context>
|
||||
- Account for existing phases/tasks — don't plan work already covered
|
||||
- Always generate new phase IDs — never reuse existing ones
|
||||
|
||||
@@ -33,6 +33,15 @@ Ignore style, grammar, formatting unless they cause genuine ambiguity. Rough but
|
||||
If all pages are already clear, signal done with no output files.
|
||||
</improvement_priorities>
|
||||
|
||||
<subagent_usage>
|
||||
Use subagents to parallelize your work:
|
||||
- **Parallel page analysis**: Spawn one subagent per page (or group of related pages) to analyze clarity issues simultaneously rather than reviewing pages sequentially.
|
||||
- **Codebase verification**: When checking whether a requirement is feasible or matches existing patterns, spawn a subagent to search the codebase while you continue reviewing other pages.
|
||||
- **Cross-reference validation**: Spawn a subagent to verify that all [[page:$id|title]] cross-references are valid and consistent across pages.
|
||||
|
||||
Don't over-split — if there are only 1-2 short pages, just do the work directly.
|
||||
</subagent_usage>
|
||||
|
||||
<rules>
|
||||
- Ask 2-4 questions if you need clarification
|
||||
- Preserve [[page:\$id|title]] cross-references
|
||||
|
||||
@@ -36,5 +36,7 @@ This is an isolated git worktree. Other agents may be working in parallel on sep
|
||||
The following project directories contain the source code (git worktrees):
|
||||
|
||||
${lines.join('\n')}
|
||||
|
||||
**IMPORTANT**: All \`.cw/output/\` paths (signal.json, progress.md, etc.) are relative to this working directory (\`${agentCwd}\`), NOT to any project subdirectory. Always write to \`${join(agentCwd, '.cw/output/')}\` regardless of your current \`cd\` location.
|
||||
</workspace>`;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface SpawnAgentOptions {
|
||||
branchName?: string;
|
||||
/** Context data to write as input files in agent workdir */
|
||||
inputContext?: AgentInputContext;
|
||||
/** Skip inter-agent communication and preview instructions (for focused agents like conflict resolution) */
|
||||
skipPromptExtras?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +95,10 @@ export interface AgentInfo {
|
||||
updatedAt: Date;
|
||||
/** When the user dismissed this agent (null if not dismissed) */
|
||||
userDismissedAt?: Date | null;
|
||||
/** Process exit code — null while running or if not yet exited */
|
||||
exitCode: number | null;
|
||||
/** Full assembled prompt passed to the agent process — null for agents spawned before DB persistence */
|
||||
prompt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -191,10 +191,6 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
|
||||
);
|
||||
log.info('agent manager created');
|
||||
|
||||
// Reconcile agent state from any previous server session
|
||||
await agentManager.reconcileAfterRestart();
|
||||
log.info('agent reconciliation complete');
|
||||
|
||||
// Branch manager
|
||||
const branchManager = new SimpleGitBranchManager();
|
||||
log.info('branch manager created');
|
||||
@@ -254,10 +250,17 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
|
||||
conflictResolutionService,
|
||||
eventBus,
|
||||
workspaceRoot,
|
||||
repos.agentRepository,
|
||||
);
|
||||
executionOrchestrator.start();
|
||||
log.info('execution orchestrator started');
|
||||
|
||||
// Reconcile agent state from any previous server session.
|
||||
// Must run AFTER orchestrator.start() so event listeners are registered
|
||||
// and agent:stopped / agent:crashed events are not lost.
|
||||
await agentManager.reconcileAfterRestart();
|
||||
log.info('agent reconciliation complete');
|
||||
|
||||
// Preview manager
|
||||
const previewManager = new PreviewManager(
|
||||
repos.projectRepository,
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface UpdateAgentData {
|
||||
accountId?: string | null;
|
||||
pid?: number | null;
|
||||
exitCode?: number | null;
|
||||
prompt?: string | null;
|
||||
outputFilePath?: string | null;
|
||||
result?: string | null;
|
||||
pendingQuestions?: string | null;
|
||||
|
||||
@@ -1,161 +1,336 @@
|
||||
/**
|
||||
* DrizzleErrandRepository Tests
|
||||
*
|
||||
* Tests for the Errand repository adapter.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { DrizzleErrandRepository } from './errand.js';
|
||||
import { DrizzleProjectRepository } from './project.js';
|
||||
import { createTestDatabase } from './test-helpers.js';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import type { Project } from '../../schema.js';
|
||||
import { projects, agents, errands } from '../../schema.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
describe('DrizzleErrandRepository', () => {
|
||||
let db: DrizzleDatabase;
|
||||
let repo: DrizzleErrandRepository;
|
||||
let projectRepo: DrizzleProjectRepository;
|
||||
|
||||
const createProject = async (): Promise<Project> => {
|
||||
const suffix = Math.random().toString(36).slice(2, 8);
|
||||
return projectRepo.create({
|
||||
name: `test-project-${suffix}`,
|
||||
url: `https://github.com/test/repo-${suffix}`,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
repo = new DrizzleErrandRepository(db);
|
||||
projectRepo = new DrizzleProjectRepository(db);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates an errand with generated id and timestamps', async () => {
|
||||
// Helper: create a project record
|
||||
async function createProject(name = 'Test Project', suffix = '') {
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
const [project] = await db.insert(projects).values({
|
||||
id,
|
||||
name: name + suffix + id,
|
||||
url: `https://github.com/test/${id}`,
|
||||
defaultBranch: 'main',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
return project;
|
||||
}
|
||||
|
||||
// Helper: create an agent record
|
||||
async function createAgent(name?: string) {
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
const agentName = name ?? `agent-${id}`;
|
||||
const [agent] = await db.insert(agents).values({
|
||||
id,
|
||||
name: agentName,
|
||||
worktreeId: `agent-workdirs/${agentName}`,
|
||||
provider: 'claude',
|
||||
status: 'idle',
|
||||
mode: 'execute',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
return agent;
|
||||
}
|
||||
|
||||
// Helper: create an errand
|
||||
async function createErrand(overrides: Partial<{
|
||||
id: string;
|
||||
description: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
agentId: string | null;
|
||||
projectId: string | null;
|
||||
status: 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned';
|
||||
createdAt: Date;
|
||||
}> = {}) {
|
||||
const project = await createProject();
|
||||
const id = overrides.id ?? nanoid();
|
||||
return repo.create({
|
||||
id,
|
||||
description: overrides.description ?? 'Test errand',
|
||||
branch: overrides.branch ?? 'feature/test',
|
||||
baseBranch: overrides.baseBranch ?? 'main',
|
||||
agentId: overrides.agentId !== undefined ? overrides.agentId : null,
|
||||
projectId: overrides.projectId !== undefined ? overrides.projectId : project.id,
|
||||
status: overrides.status ?? 'active',
|
||||
});
|
||||
}
|
||||
|
||||
describe('create + findById', () => {
|
||||
it('should create errand and find by id with all fields', async () => {
|
||||
const project = await createProject();
|
||||
const errand = await repo.create({
|
||||
description: 'fix typo',
|
||||
branch: 'cw/errand/fix-typo-abc12345',
|
||||
const id = nanoid();
|
||||
|
||||
await repo.create({
|
||||
id,
|
||||
description: 'Fix the bug',
|
||||
branch: 'fix/bug-123',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
expect(errand.id).toBeDefined();
|
||||
expect(errand.id.length).toBeGreaterThan(0);
|
||||
expect(errand.description).toBe('fix typo');
|
||||
expect(errand.branch).toBe('cw/errand/fix-typo-abc12345');
|
||||
expect(errand.baseBranch).toBe('main');
|
||||
expect(errand.agentId).toBeNull();
|
||||
expect(errand.projectId).toBe(project.id);
|
||||
expect(errand.status).toBe('active');
|
||||
expect(errand.conflictFiles).toBeNull();
|
||||
expect(errand.createdAt).toBeInstanceOf(Date);
|
||||
expect(errand.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('returns null for non-existent errand', async () => {
|
||||
const result = await repo.findById('does-not-exist');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns errand with agentAlias null when no agent', async () => {
|
||||
const project = await createProject();
|
||||
const created = await repo.create({
|
||||
description: 'test',
|
||||
branch: 'cw/errand/test-xyz',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
});
|
||||
const found = await repo.findById(created.id);
|
||||
expect(found).not.toBeNull();
|
||||
const found = await repo.findById(id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.id).toBe(id);
|
||||
expect(found!.description).toBe('Fix the bug');
|
||||
expect(found!.branch).toBe('fix/bug-123');
|
||||
expect(found!.baseBranch).toBe('main');
|
||||
expect(found!.status).toBe('active');
|
||||
expect(found!.projectId).toBe(project.id);
|
||||
expect(found!.agentId).toBeNull();
|
||||
expect(found!.agentAlias).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('returns empty array when no errands', async () => {
|
||||
const results = await repo.findAll();
|
||||
expect(results).toEqual([]);
|
||||
it('should return all errands ordered by createdAt desc', async () => {
|
||||
const project = await createProject();
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
const t3 = new Date('2024-01-03T00:00:00Z');
|
||||
|
||||
const id1 = nanoid();
|
||||
const id2 = nanoid();
|
||||
const id3 = nanoid();
|
||||
|
||||
await db.insert(errands).values([
|
||||
{ id: id1, description: 'Errand 1', branch: 'b1', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t1, updatedAt: t1 },
|
||||
{ id: id2, description: 'Errand 2', branch: 'b2', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t2, updatedAt: t2 },
|
||||
{ id: id3, description: 'Errand 3', branch: 'b3', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t3, updatedAt: t3 },
|
||||
]);
|
||||
|
||||
const result = await repo.findAll();
|
||||
expect(result.length).toBeGreaterThanOrEqual(3);
|
||||
// Find our three in the results
|
||||
const ids = result.map((e) => e.id);
|
||||
expect(ids.indexOf(id3)).toBeLessThan(ids.indexOf(id2));
|
||||
expect(ids.indexOf(id2)).toBeLessThan(ids.indexOf(id1));
|
||||
});
|
||||
|
||||
it('filters by projectId', async () => {
|
||||
const projectA = await createProject();
|
||||
const projectB = await createProject();
|
||||
await repo.create({ description: 'a', branch: 'cw/errand/a', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active' });
|
||||
await repo.create({ description: 'b', branch: 'cw/errand/b', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active' });
|
||||
it('should filter by projectId', async () => {
|
||||
const projectA = await createProject('A');
|
||||
const projectB = await createProject('B');
|
||||
const now = new Date();
|
||||
|
||||
const results = await repo.findAll({ projectId: projectA.id });
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].description).toBe('a');
|
||||
const idA1 = nanoid();
|
||||
const idA2 = nanoid();
|
||||
const idB1 = nanoid();
|
||||
|
||||
await db.insert(errands).values([
|
||||
{ id: idA1, description: 'A1', branch: 'b-a1', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now },
|
||||
{ id: idA2, description: 'A2', branch: 'b-a2', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now },
|
||||
{ id: idB1, description: 'B1', branch: 'b-b1', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active', createdAt: now, updatedAt: now },
|
||||
]);
|
||||
|
||||
const result = await repo.findAll({ projectId: projectA.id });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((e) => e.id).sort()).toEqual([idA1, idA2].sort());
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
const project = await createProject();
|
||||
const now = new Date();
|
||||
|
||||
const id1 = nanoid();
|
||||
const id2 = nanoid();
|
||||
const id3 = nanoid();
|
||||
|
||||
await db.insert(errands).values([
|
||||
{ id: id1, description: 'E1', branch: 'b1', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: now, updatedAt: now },
|
||||
{ id: id2, description: 'E2', branch: 'b2', baseBranch: 'main', agentId: null, projectId: project.id, status: 'pending_review', createdAt: now, updatedAt: now },
|
||||
{ id: id3, description: 'E3', branch: 'b3', baseBranch: 'main', agentId: null, projectId: project.id, status: 'merged', createdAt: now, updatedAt: now },
|
||||
]);
|
||||
|
||||
const result = await repo.findAll({ status: 'pending_review' });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(id2);
|
||||
});
|
||||
|
||||
it('should filter by both projectId and status', async () => {
|
||||
const projectA = await createProject('PA');
|
||||
const projectB = await createProject('PB');
|
||||
const now = new Date();
|
||||
|
||||
const idMatch = nanoid();
|
||||
const idOtherStatus = nanoid();
|
||||
const idOtherProject = nanoid();
|
||||
const idNeither = nanoid();
|
||||
|
||||
await db.insert(errands).values([
|
||||
{ id: idMatch, description: 'Match', branch: 'b1', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'pending_review', createdAt: now, updatedAt: now },
|
||||
{ id: idOtherStatus, description: 'Wrong status', branch: 'b2', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now },
|
||||
{ id: idOtherProject, description: 'Wrong project', branch: 'b3', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'pending_review', createdAt: now, updatedAt: now },
|
||||
{ id: idNeither, description: 'Neither', branch: 'b4', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active', createdAt: now, updatedAt: now },
|
||||
]);
|
||||
|
||||
const result = await repo.findAll({ projectId: projectA.id, status: 'pending_review' });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(idMatch);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return agentAlias when agentId is set', async () => {
|
||||
const agent = await createAgent('known-agent');
|
||||
const project = await createProject();
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(errands).values({
|
||||
id,
|
||||
description: 'With agent',
|
||||
branch: 'feature/x',
|
||||
baseBranch: 'main',
|
||||
agentId: agent.id,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const found = await repo.findById(id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.agentAlias).toBe(agent.name);
|
||||
});
|
||||
|
||||
it('should return agentAlias as null when agentId is null', async () => {
|
||||
const project = await createProject();
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(errands).values({
|
||||
id,
|
||||
description: 'No agent',
|
||||
branch: 'feature/y',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const found = await repo.findById(id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.agentAlias).toBeNull();
|
||||
});
|
||||
|
||||
it('should return undefined for unknown id', async () => {
|
||||
const found = await repo.findById('nonexistent');
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates errand status', async () => {
|
||||
it('should update status and advance updatedAt', async () => {
|
||||
const project = await createProject();
|
||||
const created = await repo.create({
|
||||
description: 'upd test',
|
||||
branch: 'cw/errand/upd',
|
||||
const id = nanoid();
|
||||
const past = new Date('2024-01-01T00:00:00Z');
|
||||
|
||||
await db.insert(errands).values({
|
||||
id,
|
||||
description: 'Errand',
|
||||
branch: 'feature/update',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
createdAt: past,
|
||||
updatedAt: past,
|
||||
});
|
||||
const updated = await repo.update(created.id, { status: 'pending_review' });
|
||||
expect(updated!.status).toBe('pending_review');
|
||||
|
||||
const updated = await repo.update(id, { status: 'pending_review' });
|
||||
expect(updated.status).toBe('pending_review');
|
||||
expect(updated.updatedAt.getTime()).toBeGreaterThan(past.getTime());
|
||||
});
|
||||
|
||||
it('should throw on unknown id', async () => {
|
||||
await expect(
|
||||
repo.update('nonexistent', { status: 'merged' })
|
||||
).rejects.toThrow('Errand not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('conflictFiles column', () => {
|
||||
it('stores and retrieves conflictFiles via update + findById', async () => {
|
||||
describe('delete', () => {
|
||||
it('should delete errand and findById returns undefined', async () => {
|
||||
const errand = await createErrand();
|
||||
await repo.delete(errand.id);
|
||||
const found = await repo.findById(errand.id);
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cascade and set null', () => {
|
||||
it('should cascade delete errands when project is deleted', async () => {
|
||||
const project = await createProject();
|
||||
const created = await repo.create({
|
||||
description: 'x',
|
||||
branch: 'cw/errand/x',
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(errands).values({
|
||||
id,
|
||||
description: 'Cascade test',
|
||||
branch: 'feature/cascade',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await repo.update(created.id, { status: 'conflict', conflictFiles: '["src/a.ts","src/b.ts"]' });
|
||||
const found = await repo.findById(created.id);
|
||||
expect(found!.conflictFiles).toBe('["src/a.ts","src/b.ts"]');
|
||||
expect(found!.status).toBe('conflict');
|
||||
|
||||
// Delete project — should cascade delete errands
|
||||
await db.delete(projects).where(eq(projects.id, project.id));
|
||||
|
||||
const found = await repo.findById(id);
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns null conflictFiles for non-conflict errands', async () => {
|
||||
it('should set agentId to null when agent is deleted', async () => {
|
||||
const agent = await createAgent();
|
||||
const project = await createProject();
|
||||
const created = await repo.create({
|
||||
description: 'y',
|
||||
branch: 'cw/errand/y',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
});
|
||||
const found = await repo.findById(created.id);
|
||||
expect(found!.conflictFiles).toBeNull();
|
||||
});
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
it('findAll includes conflictFiles in results', async () => {
|
||||
const project = await createProject();
|
||||
const created = await repo.create({
|
||||
description: 'z',
|
||||
branch: 'cw/errand/z',
|
||||
await db.insert(errands).values({
|
||||
id,
|
||||
description: 'Agent null test',
|
||||
branch: 'feature/agent-null',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
agentId: agent.id,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await repo.update(created.id, { conflictFiles: '["x.ts"]' });
|
||||
const all = await repo.findAll({ projectId: project.id });
|
||||
expect(all[0].conflictFiles).toBe('["x.ts"]');
|
||||
|
||||
// Delete agent — should set null
|
||||
await db.delete(agents).where(eq(agents.id, agent.id));
|
||||
|
||||
const [errand] = await db.select().from(errands).where(eq(errands.id, id));
|
||||
expect(errand).toBeDefined();
|
||||
expect(errand.agentId).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,41 +4,32 @@
|
||||
* Implements ErrandRepository interface using Drizzle ORM.
|
||||
*/
|
||||
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import { errands, agents, type Errand } from '../../schema.js';
|
||||
import { errands, agents } from '../../schema.js';
|
||||
import type {
|
||||
ErrandRepository,
|
||||
ErrandWithAlias,
|
||||
ErrandStatus,
|
||||
CreateErrandData,
|
||||
UpdateErrandData,
|
||||
ErrandWithAlias,
|
||||
FindAllErrandOptions,
|
||||
} from '../errand-repository.js';
|
||||
import type { Errand } from '../../schema.js';
|
||||
|
||||
export class DrizzleErrandRepository implements ErrandRepository {
|
||||
constructor(private db: DrizzleDatabase) {}
|
||||
|
||||
async create(data: CreateErrandData): Promise<Errand> {
|
||||
const now = new Date();
|
||||
const id = nanoid();
|
||||
const [created] = await this.db.insert(errands).values({
|
||||
id,
|
||||
description: data.description,
|
||||
branch: data.branch,
|
||||
baseBranch: data.baseBranch ?? 'main',
|
||||
agentId: data.agentId ?? null,
|
||||
projectId: data.projectId,
|
||||
status: data.status ?? 'active',
|
||||
conflictFiles: data.conflictFiles ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
const [created] = await this.db
|
||||
.insert(errands)
|
||||
.values({ ...data, createdAt: now, updatedAt: now })
|
||||
.returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ErrandWithAlias | null> {
|
||||
const rows = await this.db
|
||||
async findById(id: string): Promise<ErrandWithAlias | undefined> {
|
||||
const result = await this.db
|
||||
.select({
|
||||
id: errands.id,
|
||||
description: errands.description,
|
||||
@@ -47,7 +38,6 @@ export class DrizzleErrandRepository implements ErrandRepository {
|
||||
agentId: errands.agentId,
|
||||
projectId: errands.projectId,
|
||||
status: errands.status,
|
||||
conflictFiles: errands.conflictFiles,
|
||||
createdAt: errands.createdAt,
|
||||
updatedAt: errands.updatedAt,
|
||||
agentAlias: agents.name,
|
||||
@@ -56,16 +46,15 @@ export class DrizzleErrandRepository implements ErrandRepository {
|
||||
.leftJoin(agents, eq(errands.agentId, agents.id))
|
||||
.where(eq(errands.id, id))
|
||||
.limit(1);
|
||||
if (!rows[0]) return null;
|
||||
return rows[0] as ErrandWithAlias;
|
||||
return result[0] ?? undefined;
|
||||
}
|
||||
|
||||
async findAll(options?: FindAllErrandOptions): Promise<ErrandWithAlias[]> {
|
||||
async findAll(opts?: { projectId?: string; status?: ErrandStatus }): Promise<ErrandWithAlias[]> {
|
||||
const conditions = [];
|
||||
if (options?.projectId) conditions.push(eq(errands.projectId, options.projectId));
|
||||
if (options?.status) conditions.push(eq(errands.status, options.status));
|
||||
if (opts?.projectId) conditions.push(eq(errands.projectId, opts.projectId));
|
||||
if (opts?.status) conditions.push(eq(errands.status, opts.status));
|
||||
|
||||
const rows = await this.db
|
||||
return this.db
|
||||
.select({
|
||||
id: errands.id,
|
||||
description: errands.description,
|
||||
@@ -74,7 +63,6 @@ export class DrizzleErrandRepository implements ErrandRepository {
|
||||
agentId: errands.agentId,
|
||||
projectId: errands.projectId,
|
||||
status: errands.status,
|
||||
conflictFiles: errands.conflictFiles,
|
||||
createdAt: errands.createdAt,
|
||||
updatedAt: errands.updatedAt,
|
||||
agentAlias: agents.name,
|
||||
@@ -82,21 +70,17 @@ export class DrizzleErrandRepository implements ErrandRepository {
|
||||
.from(errands)
|
||||
.leftJoin(agents, eq(errands.agentId, agents.id))
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(errands.createdAt), desc(errands.id));
|
||||
return rows as ErrandWithAlias[];
|
||||
.orderBy(desc(errands.createdAt));
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateErrandData): Promise<Errand | null> {
|
||||
await this.db
|
||||
async update(id: string, data: UpdateErrandData): Promise<Errand> {
|
||||
const [updated] = await this.db
|
||||
.update(errands)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(errands.id, id));
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(errands)
|
||||
.where(eq(errands.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
.returning();
|
||||
if (!updated) throw new Error(`Errand not found: ${id}`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
|
||||
@@ -1,45 +1,15 @@
|
||||
/**
|
||||
* Errand Repository Port Interface
|
||||
*
|
||||
* Port for Errand aggregate operations.
|
||||
* Implementations (Drizzle, etc.) are adapters.
|
||||
*/
|
||||
import type { Errand, NewErrand } from '../schema.js';
|
||||
|
||||
import type { Errand, NewErrand, ErrandStatus } from '../schema.js';
|
||||
export type ErrandStatus = 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned';
|
||||
export type ErrandWithAlias = Errand & { agentAlias: string | null };
|
||||
|
||||
/**
|
||||
* Data for creating a new errand.
|
||||
* Omits system-managed fields (id, createdAt, updatedAt).
|
||||
*/
|
||||
export type CreateErrandData = Omit<NewErrand, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
|
||||
/**
|
||||
* Data for updating an errand.
|
||||
*/
|
||||
export type CreateErrandData = Omit<NewErrand, 'createdAt' | 'updatedAt'>;
|
||||
export type UpdateErrandData = Partial<Omit<NewErrand, 'id' | 'createdAt'>>;
|
||||
|
||||
/**
|
||||
* Errand with the agent alias joined in.
|
||||
*/
|
||||
export interface ErrandWithAlias extends Errand {
|
||||
agentAlias: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter options for listing errands.
|
||||
*/
|
||||
export interface FindAllErrandOptions {
|
||||
projectId?: string;
|
||||
status?: ErrandStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Errand Repository Port
|
||||
*/
|
||||
export interface ErrandRepository {
|
||||
create(data: CreateErrandData): Promise<Errand>;
|
||||
findById(id: string): Promise<ErrandWithAlias | null>;
|
||||
findAll(options?: FindAllErrandOptions): Promise<ErrandWithAlias[]>;
|
||||
update(id: string, data: UpdateErrandData): Promise<Errand | null>;
|
||||
findById(id: string): Promise<ErrandWithAlias | undefined>;
|
||||
findAll(opts?: { projectId?: string; status?: ErrandStatus }): Promise<ErrandWithAlias[]>;
|
||||
update(id: string, data: UpdateErrandData): Promise<Errand>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -85,8 +85,8 @@ export type {
|
||||
|
||||
export type {
|
||||
ErrandRepository,
|
||||
ErrandWithAlias,
|
||||
ErrandStatus,
|
||||
CreateErrandData,
|
||||
UpdateErrandData,
|
||||
ErrandWithAlias,
|
||||
FindAllErrandOptions,
|
||||
} from './errand-repository.js';
|
||||
|
||||
@@ -157,6 +157,7 @@ export const tasks = sqliteTable('tasks', {
|
||||
.default('pending'),
|
||||
order: integer('order').notNull().default(0),
|
||||
summary: text('summary'), // Agent result summary — propagated to dependent tasks as context
|
||||
retryCount: integer('retry_count').notNull().default(0),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
@@ -266,6 +267,7 @@ export const agents = sqliteTable('agents', {
|
||||
.default('execute'),
|
||||
pid: integer('pid'),
|
||||
exitCode: integer('exit_code'), // Process exit code for debugging crashes
|
||||
prompt: text('prompt'), // Full assembled prompt passed to the agent process (persisted for durability after log cleanup)
|
||||
outputFilePath: text('output_file_path'),
|
||||
result: text('result'),
|
||||
pendingQuestions: text('pending_questions'),
|
||||
@@ -633,28 +635,30 @@ export type NewReviewComment = InferInsertModel<typeof reviewComments>;
|
||||
// ERRANDS
|
||||
// ============================================================================
|
||||
|
||||
export const ERRAND_STATUS_VALUES = ['active', 'pending_review', 'conflict', 'merged', 'abandoned'] as const;
|
||||
export type ErrandStatus = (typeof ERRAND_STATUS_VALUES)[number];
|
||||
|
||||
export const errands = sqliteTable('errands', {
|
||||
id: text('id').primaryKey(),
|
||||
description: text('description').notNull(),
|
||||
branch: text('branch').notNull(),
|
||||
baseBranch: text('base_branch').notNull().default('main'),
|
||||
agentId: text('agent_id').references(() => agents.id, { onDelete: 'set null' }),
|
||||
projectId: text('project_id')
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||
status: text('status', { enum: ERRAND_STATUS_VALUES })
|
||||
.notNull()
|
||||
.default('active'),
|
||||
projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }),
|
||||
status: text('status', {
|
||||
enum: ['active', 'pending_review', 'conflict', 'merged', 'abandoned'],
|
||||
}).notNull().default('active'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
conflictFiles: text('conflict_files'), // JSON-encoded string[] | null; set on merge conflict
|
||||
}, (table) => [
|
||||
index('errands_project_id_idx').on(table.projectId),
|
||||
index('errands_status_idx').on(table.status),
|
||||
]);
|
||||
});
|
||||
|
||||
export const errandsRelations = relations(errands, ({ one }) => ({
|
||||
agent: one(agents, {
|
||||
fields: [errands.agentId],
|
||||
references: [agents.id],
|
||||
}),
|
||||
project: one(projects, {
|
||||
fields: [errands.projectId],
|
||||
references: [projects.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Errand = InferSelectModel<typeof errands>;
|
||||
export type NewErrand = InferInsertModel<typeof errands>;
|
||||
|
||||
@@ -70,6 +70,8 @@ function createMockAgentManager(
|
||||
accountId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
exitCode: null,
|
||||
prompt: null,
|
||||
};
|
||||
mockAgents.push(newAgent);
|
||||
return newAgent;
|
||||
@@ -102,6 +104,8 @@ function createIdleAgent(id: string, name: string): AgentInfo {
|
||||
accountId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
exitCode: null,
|
||||
prompt: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -247,8 +247,8 @@ export class DefaultDispatchManager implements DispatchManager {
|
||||
// Clear blocked state
|
||||
this.blockedTasks.delete(taskId);
|
||||
|
||||
// Reset DB status to pending
|
||||
await this.taskRepository.update(taskId, { status: 'pending' });
|
||||
// Reset DB status to pending and clear retry count (manual retry = fresh start)
|
||||
await this.taskRepository.update(taskId, { status: 'pending', retryCount: 0 });
|
||||
|
||||
log.info({ taskId }, 'retrying blocked task');
|
||||
|
||||
@@ -327,8 +327,13 @@ export class DefaultDispatchManager implements DispatchManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: fall back to default branching
|
||||
} catch (err) {
|
||||
if (!isPlanningCategory(task.category)) {
|
||||
// Execution tasks MUST have correct branches — fail loudly
|
||||
throw new Error(`Failed to compute branches for execution task ${task.id}: ${err}`);
|
||||
}
|
||||
// Planning tasks: non-fatal, fall back to default branching
|
||||
log.debug({ taskId: task.id, err }, 'branch computation skipped for planning task');
|
||||
}
|
||||
|
||||
// Ensure branches exist in project clones before spawning worktrees
|
||||
@@ -350,7 +355,10 @@ export class DefaultDispatchManager implements DispatchManager {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn({ taskId: task.id, err }, 'failed to ensure branches for task dispatch');
|
||||
if (!isPlanningCategory(task.category)) {
|
||||
throw new Error(`Failed to ensure branches for execution task ${task.id}: ${err}`);
|
||||
}
|
||||
log.warn({ taskId: task.id, err }, 'failed to ensure branches for planning task dispatch');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
apps/server/drizzle/0034_add_task_retry_count.sql
Normal file
1
apps/server/drizzle/0034_add_task_retry_count.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE tasks ADD COLUMN retry_count integer NOT NULL DEFAULT 0;
|
||||
13
apps/server/drizzle/0035_faulty_human_fly.sql
Normal file
13
apps/server/drizzle/0035_faulty_human_fly.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE `errands` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`description` text NOT NULL,
|
||||
`branch` text NOT NULL,
|
||||
`base_branch` text DEFAULT 'main' NOT NULL,
|
||||
`agent_id` text,
|
||||
`project_id` text,
|
||||
`status` text DEFAULT 'active' NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`agent_id`) REFERENCES `agents`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
1
apps/server/drizzle/0036_icy_silvermane.sql
Normal file
1
apps/server/drizzle/0036_icy_silvermane.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `agents` ADD `prompt` text;
|
||||
1974
apps/server/drizzle/meta/0035_snapshot.json
Normal file
1974
apps/server/drizzle/meta/0035_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1159
apps/server/drizzle/meta/0036_snapshot.json
Normal file
1159
apps/server/drizzle/meta/0036_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -243,9 +243,23 @@
|
||||
{
|
||||
"idx": 34,
|
||||
"version": "6",
|
||||
"when": 1772808163349,
|
||||
"tag": "0034_salty_next_avengers",
|
||||
"when": 1772496000000,
|
||||
"tag": "0034_add_task_retry_count",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 35,
|
||||
"version": "6",
|
||||
"when": 1772796561474,
|
||||
"tag": "0035_faulty_human_fly",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 36,
|
||||
"version": "6",
|
||||
"when": 1772798869413,
|
||||
"tag": "0036_icy_silvermane",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ExecutionOrchestrator } from './orchestrator.js';
|
||||
import { ensureProjectClone } from '../git/project-clones.js';
|
||||
import type { BranchManager } from '../git/branch-manager.js';
|
||||
|
||||
vi.mock('../git/project-clones.js', () => ({
|
||||
ensureProjectClone: vi.fn().mockResolvedValue('/tmp/test-workspace/clones/test'),
|
||||
}));
|
||||
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
|
||||
@@ -39,7 +44,7 @@ function createMockEventBus(): EventBus & { handlers: Map<string, Function[]>; e
|
||||
function createMocks() {
|
||||
const branchManager: BranchManager = {
|
||||
ensureBranch: vi.fn(),
|
||||
mergeBranch: vi.fn().mockResolvedValue({ success: true, message: 'merged' }),
|
||||
mergeBranch: vi.fn().mockResolvedValue({ success: true, message: 'merged', previousRef: 'abc000' }),
|
||||
diffBranches: vi.fn().mockResolvedValue(''),
|
||||
deleteBranch: vi.fn(),
|
||||
branchExists: vi.fn().mockResolvedValue(true),
|
||||
@@ -51,6 +56,7 @@ function createMocks() {
|
||||
checkMergeability: vi.fn().mockResolvedValue({ mergeable: true }),
|
||||
fetchRemote: vi.fn(),
|
||||
fastForwardBranch: vi.fn(),
|
||||
updateRef: vi.fn(),
|
||||
};
|
||||
|
||||
const phaseRepository = {
|
||||
@@ -306,4 +312,58 @@ describe('ExecutionOrchestrator', () => {
|
||||
expect(mocks.phaseDispatchManager.completePhase).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('approveInitiative', () => {
|
||||
function setupApproveTest(mocks: ReturnType<typeof createMocks>) {
|
||||
const initiative = { id: 'init-1', branch: 'cw/test', status: 'pending_review' };
|
||||
const project = { id: 'proj-1', name: 'test', url: 'https://example.com', defaultBranch: 'main' };
|
||||
vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any);
|
||||
vi.mocked(mocks.projectRepository.findProjectsByInitiativeId).mockResolvedValue([project] as any);
|
||||
vi.mocked(mocks.branchManager.branchExists).mockResolvedValue(true);
|
||||
vi.mocked(mocks.branchManager.mergeBranch).mockResolvedValue({ success: true, message: 'ok', previousRef: 'abc000' });
|
||||
return { initiative, project };
|
||||
}
|
||||
|
||||
it('should roll back merge when push fails', async () => {
|
||||
setupApproveTest(mocks);
|
||||
vi.mocked(mocks.branchManager.pushBranch).mockRejectedValue(new Error('non-fast-forward'));
|
||||
|
||||
const orchestrator = createOrchestrator(mocks);
|
||||
|
||||
await expect(orchestrator.approveInitiative('init-1', 'merge_and_push')).rejects.toThrow('non-fast-forward');
|
||||
|
||||
// Should have rolled back the merge by restoring the previous ref
|
||||
expect(mocks.branchManager.updateRef).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'main',
|
||||
'abc000',
|
||||
);
|
||||
|
||||
// Should NOT have marked initiative as completed
|
||||
expect(mocks.initiativeRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should complete initiative when push succeeds', async () => {
|
||||
setupApproveTest(mocks);
|
||||
|
||||
const orchestrator = createOrchestrator(mocks);
|
||||
|
||||
await orchestrator.approveInitiative('init-1', 'merge_and_push');
|
||||
|
||||
expect(mocks.branchManager.updateRef).not.toHaveBeenCalled();
|
||||
expect(mocks.initiativeRepository.update).toHaveBeenCalledWith('init-1', { status: 'completed' });
|
||||
});
|
||||
|
||||
it('should not attempt rollback for push_branch strategy', async () => {
|
||||
setupApproveTest(mocks);
|
||||
vi.mocked(mocks.branchManager.pushBranch).mockRejectedValue(new Error('auth failed'));
|
||||
|
||||
const orchestrator = createOrchestrator(mocks);
|
||||
|
||||
await expect(orchestrator.approveInitiative('init-1', 'push_branch')).rejects.toThrow('auth failed');
|
||||
|
||||
// No merge happened, so no rollback needed
|
||||
expect(mocks.branchManager.updateRef).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,13 @@
|
||||
* - Review per-phase: pause after each phase for diff review
|
||||
*/
|
||||
|
||||
import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseChangesRequestedEvent, PhaseMergedEvent, TaskMergedEvent, PhaseQueuedEvent, AgentStoppedEvent, InitiativePendingReviewEvent, InitiativeReviewApprovedEvent, InitiativeChangesRequestedEvent } from '../events/index.js';
|
||||
import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseChangesRequestedEvent, PhaseMergedEvent, TaskMergedEvent, PhaseQueuedEvent, AgentStoppedEvent, AgentCrashedEvent, InitiativePendingReviewEvent, InitiativeReviewApprovedEvent, InitiativeChangesRequestedEvent } from '../events/index.js';
|
||||
import type { BranchManager } from '../git/branch-manager.js';
|
||||
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
|
||||
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
||||
import type { AgentRepository } from '../db/repositories/agent-repository.js';
|
||||
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
|
||||
import type { ConflictResolutionService } from '../coordination/conflict-resolution-service.js';
|
||||
import { phaseBranchName, taskBranchName } from '../git/branch-naming.js';
|
||||
@@ -25,6 +26,9 @@ import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
const log = createModuleLogger('execution-orchestrator');
|
||||
|
||||
/** Maximum number of automatic retries for crashed tasks before blocking */
|
||||
const MAX_TASK_RETRIES = 3;
|
||||
|
||||
export class ExecutionOrchestrator {
|
||||
/** Serialize merges per phase to avoid concurrent merge conflicts */
|
||||
private phaseMergeLocks: Map<string, Promise<void>> = new Map();
|
||||
@@ -44,6 +48,7 @@ export class ExecutionOrchestrator {
|
||||
private conflictResolutionService: ConflictResolutionService,
|
||||
private eventBus: EventBus,
|
||||
private workspaceRoot: string,
|
||||
private agentRepository?: AgentRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -66,6 +71,13 @@ export class ExecutionOrchestrator {
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-retry crashed agent tasks (up to MAX_TASK_RETRIES)
|
||||
this.eventBus.on<AgentCrashedEvent>('agent:crashed', (event) => {
|
||||
this.handleAgentCrashed(event).catch((err) => {
|
||||
log.error({ err: err instanceof Error ? err.message : String(err) }, 'error handling agent:crashed');
|
||||
});
|
||||
});
|
||||
|
||||
// Recover in-memory dispatch queues from DB state (survives server restarts)
|
||||
this.recoverDispatchQueues().catch((err) => {
|
||||
log.error({ err: err instanceof Error ? err.message : String(err) }, 'dispatch queue recovery failed');
|
||||
@@ -111,6 +123,27 @@ export class ExecutionOrchestrator {
|
||||
this.scheduleDispatch();
|
||||
}
|
||||
|
||||
private async handleAgentCrashed(event: AgentCrashedEvent): Promise<void> {
|
||||
const { taskId, agentId, error } = event.payload;
|
||||
if (!taskId) return;
|
||||
|
||||
const task = await this.taskRepository.findById(taskId);
|
||||
if (!task || task.status !== 'in_progress') return;
|
||||
|
||||
const retryCount = (task.retryCount ?? 0) + 1;
|
||||
if (retryCount > MAX_TASK_RETRIES) {
|
||||
log.warn({ taskId, agentId, retryCount, error }, 'task exceeded max retries, leaving in_progress');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset task for re-dispatch with incremented retry count
|
||||
await this.taskRepository.update(taskId, { status: 'pending', retryCount });
|
||||
await this.dispatchManager.queue(taskId);
|
||||
log.info({ taskId, agentId, retryCount, error }, 'crashed task re-queued for retry');
|
||||
|
||||
this.scheduleDispatch();
|
||||
}
|
||||
|
||||
private async runDispatchCycle(): Promise<void> {
|
||||
this.dispatchRunning = true;
|
||||
try {
|
||||
@@ -560,7 +593,7 @@ export class ExecutionOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-queue pending tasks for in_progress phases into the task dispatch queue
|
||||
// Re-queue pending tasks and recover stuck in_progress tasks for in_progress phases
|
||||
if (phase.status === 'in_progress') {
|
||||
const tasks = await this.taskRepository.findByPhaseId(phase.id);
|
||||
for (const task of tasks) {
|
||||
@@ -571,6 +604,17 @@ export class ExecutionOrchestrator {
|
||||
} catch {
|
||||
// Already queued or task issue
|
||||
}
|
||||
} else if (task.status === 'in_progress' && this.agentRepository) {
|
||||
// Check if the assigned agent is still alive
|
||||
const agent = await this.agentRepository.findByTaskId(task.id);
|
||||
const isAlive = agent && (agent.status === 'running' || agent.status === 'waiting_for_input');
|
||||
if (!isAlive) {
|
||||
// Agent is dead — reset task for re-dispatch
|
||||
await this.taskRepository.update(task.id, { status: 'pending' });
|
||||
await this.dispatchManager.queue(task.id);
|
||||
tasksRecovered++;
|
||||
log.info({ taskId: task.id, agentId: agent?.id }, 'recovered stuck in_progress task (dead agent)');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -651,7 +695,18 @@ export class ExecutionOrchestrator {
|
||||
if (!result.success) {
|
||||
throw new Error(`Failed to merge ${initiative.branch} into ${project.defaultBranch} for project ${project.name}: ${result.message}`);
|
||||
}
|
||||
await this.branchManager.pushBranch(clonePath, project.defaultBranch);
|
||||
try {
|
||||
await this.branchManager.pushBranch(clonePath, project.defaultBranch);
|
||||
} catch (pushErr) {
|
||||
// Roll back the merge so the diff doesn't disappear from the review tab.
|
||||
// Without rollback, defaultBranch includes the initiative changes and the
|
||||
// three-dot diff (defaultBranch...initiativeBranch) becomes empty.
|
||||
if (result.previousRef) {
|
||||
log.warn({ project: project.name, previousRef: result.previousRef }, 'push failed — rolling back merge');
|
||||
await this.branchManager.updateRef(clonePath, project.defaultBranch, result.previousRef);
|
||||
}
|
||||
throw pushErr;
|
||||
}
|
||||
log.info({ initiativeId, project: project.name }, 'initiative branch merged into default and pushed');
|
||||
} else {
|
||||
await this.branchManager.pushBranch(clonePath, initiative.branch);
|
||||
|
||||
@@ -88,4 +88,10 @@ export interface BranchManager {
|
||||
* (i.e. the branches have diverged).
|
||||
*/
|
||||
fastForwardBranch(repoPath: string, branch: string, remote?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Force-update a branch ref to point at a specific commit.
|
||||
* Used to roll back a merge when a subsequent push fails.
|
||||
*/
|
||||
updateRef(repoPath: string, branch: string, commitHash: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -453,6 +453,58 @@ describe('SimpleGitWorktreeManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Cross-Agent Isolation
|
||||
// ==========================================================================
|
||||
|
||||
describe('cross-agent isolation', () => {
|
||||
it('get() only matches worktrees in its own worktreesDir', async () => {
|
||||
// Simulate two agents with separate worktree base dirs but same repo
|
||||
const agentADir = path.join(repoPath, 'workdirs', 'agent-a');
|
||||
const agentBDir = path.join(repoPath, 'workdirs', 'agent-b');
|
||||
await mkdir(agentADir, { recursive: true });
|
||||
await mkdir(agentBDir, { recursive: true });
|
||||
|
||||
const managerA = new SimpleGitWorktreeManager(repoPath, undefined, agentADir);
|
||||
const managerB = new SimpleGitWorktreeManager(repoPath, undefined, agentBDir);
|
||||
|
||||
// Both create worktrees with the same id (project name)
|
||||
await managerA.create('my-project', 'agent/agent-a');
|
||||
await managerB.create('my-project', 'agent/agent-b');
|
||||
|
||||
// Each manager should only see its own worktree
|
||||
const wtA = await managerA.get('my-project');
|
||||
const wtB = await managerB.get('my-project');
|
||||
|
||||
expect(wtA).not.toBeNull();
|
||||
expect(wtB).not.toBeNull();
|
||||
expect(wtA!.path).toContain('agent-a');
|
||||
expect(wtB!.path).toContain('agent-b');
|
||||
expect(wtA!.path).not.toBe(wtB!.path);
|
||||
});
|
||||
|
||||
it('remove() only removes worktrees in its own worktreesDir', async () => {
|
||||
const agentADir = path.join(repoPath, 'workdirs', 'agent-a');
|
||||
const agentBDir = path.join(repoPath, 'workdirs', 'agent-b');
|
||||
await mkdir(agentADir, { recursive: true });
|
||||
await mkdir(agentBDir, { recursive: true });
|
||||
|
||||
const managerA = new SimpleGitWorktreeManager(repoPath, undefined, agentADir);
|
||||
const managerB = new SimpleGitWorktreeManager(repoPath, undefined, agentBDir);
|
||||
|
||||
await managerA.create('my-project', 'agent/agent-a');
|
||||
await managerB.create('my-project', 'agent/agent-b');
|
||||
|
||||
// Remove agent A's worktree
|
||||
await managerA.remove('my-project');
|
||||
|
||||
// Agent B's worktree should still exist
|
||||
const wtB = await managerB.get('my-project');
|
||||
expect(wtB).not.toBeNull();
|
||||
expect(wtB!.path).toContain('agent-b');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Edge Cases
|
||||
// ==========================================================================
|
||||
|
||||
@@ -61,11 +61,30 @@ export class SimpleGitWorktreeManager implements WorktreeManager {
|
||||
const worktreePath = path.join(this.worktreesDir, id);
|
||||
log.info({ id, branch, baseBranch }, 'creating worktree');
|
||||
|
||||
// Safety: never force-reset a branch to its own base — this would nuke
|
||||
// shared branches like the initiative branch if passed as both branch and baseBranch.
|
||||
if (branch === baseBranch) {
|
||||
throw new Error(`Worktree branch and baseBranch are the same (${branch}). Use a unique branch name.`);
|
||||
}
|
||||
|
||||
// Create worktree — reuse existing branch or create new one
|
||||
const branchExists = await this.branchExists(branch);
|
||||
if (branchExists) {
|
||||
// Branch exists from a previous run — reset it to baseBranch and check it out
|
||||
await this.git.raw(['branch', '-f', branch, baseBranch]);
|
||||
// Branch exists from a previous run. Check if it has commits beyond baseBranch
|
||||
// before resetting — a previous agent may have done real work on this branch.
|
||||
try {
|
||||
const aheadCount = await this.git.raw(['rev-list', '--count', `${baseBranch}..${branch}`]);
|
||||
if (parseInt(aheadCount.trim(), 10) > 0) {
|
||||
log.warn({ branch, baseBranch, aheadBy: aheadCount.trim() }, 'branch has commits beyond base, preserving');
|
||||
} else {
|
||||
await this.git.raw(['branch', '-f', branch, baseBranch]);
|
||||
}
|
||||
} catch {
|
||||
// If rev-list fails (e.g. baseBranch doesn't exist yet), fall back to reset
|
||||
await this.git.raw(['branch', '-f', branch, baseBranch]);
|
||||
}
|
||||
// Prune stale worktree references before adding new one
|
||||
await this.git.raw(['worktree', 'prune']);
|
||||
await this.git.raw(['worktree', 'add', worktreePath, branch]);
|
||||
} else {
|
||||
// git worktree add -b <branch> <path> <base-branch>
|
||||
@@ -140,8 +159,14 @@ export class SimpleGitWorktreeManager implements WorktreeManager {
|
||||
* Finds worktree by matching path ending with id.
|
||||
*/
|
||||
async get(id: string): Promise<Worktree | null> {
|
||||
const expectedSuffix = path.join(path.basename(this.worktreesDir), id);
|
||||
const worktrees = await this.list();
|
||||
return worktrees.find((wt) => wt.path.endsWith(id)) ?? null;
|
||||
// Match on the worktreesDir + id suffix to avoid cross-agent collisions.
|
||||
// Multiple agents may have worktrees ending with the same project name
|
||||
// (e.g., ".../agent-A/codewalk-district" vs ".../agent-B/codewalk-district").
|
||||
// We match on basename(worktreesDir)/id to handle symlink differences
|
||||
// (e.g., macOS /var → /private/var) while still being unambiguous.
|
||||
return worktrees.find((wt) => wt.path.endsWith(expectedSuffix)) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
172
apps/server/git/remote-sync.test.ts
Normal file
172
apps/server/git/remote-sync.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ProjectSyncManager, type SyncResult } from './remote-sync.js'
|
||||
import type { ProjectRepository } from '../db/repositories/project-repository.js'
|
||||
|
||||
vi.mock('simple-git', () => ({
|
||||
simpleGit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./project-clones.js', () => ({
|
||||
ensureProjectClone: vi.fn().mockResolvedValue('/tmp/fake-clone'),
|
||||
}))
|
||||
|
||||
vi.mock('../logger/index.js', () => ({
|
||||
createModuleLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
function makeRepo(overrides: Partial<ProjectRepository> = {}): ProjectRepository {
|
||||
return {
|
||||
findAll: vi.fn().mockResolvedValue([]),
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
delete: vi.fn(),
|
||||
findProjectsByInitiativeId: vi.fn().mockResolvedValue([]),
|
||||
setInitiativeProjects: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
} as unknown as ProjectRepository
|
||||
}
|
||||
|
||||
const project1 = {
|
||||
id: 'proj-1',
|
||||
name: 'alpha',
|
||||
url: 'https://github.com/org/alpha',
|
||||
defaultBranch: 'main',
|
||||
lastFetchedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
const project2 = {
|
||||
id: 'proj-2',
|
||||
name: 'beta',
|
||||
url: 'https://github.com/org/beta',
|
||||
defaultBranch: 'main',
|
||||
lastFetchedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
describe('ProjectSyncManager', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let simpleGitMock: any
|
||||
|
||||
beforeEach(async () => {
|
||||
const mod = await import('simple-git')
|
||||
simpleGitMock = vi.mocked(mod.simpleGit)
|
||||
simpleGitMock.mockReset()
|
||||
})
|
||||
|
||||
describe('syncAllProjects', () => {
|
||||
it('returns empty array when no projects exist', async () => {
|
||||
const repo = makeRepo({ findAll: vi.fn().mockResolvedValue([]) })
|
||||
const manager = new ProjectSyncManager(repo, '/workspace')
|
||||
const results = await manager.syncAllProjects()
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('returns success result for each project when all succeed', async () => {
|
||||
const mockGit = {
|
||||
fetch: vi.fn().mockResolvedValue({}),
|
||||
raw: vi.fn().mockResolvedValue(''),
|
||||
}
|
||||
simpleGitMock.mockReturnValue(mockGit)
|
||||
|
||||
const repo = makeRepo({
|
||||
findAll: vi.fn().mockResolvedValue([project1, project2]),
|
||||
findById: vi.fn()
|
||||
.mockResolvedValueOnce(project1)
|
||||
.mockResolvedValueOnce(project2),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
})
|
||||
const manager = new ProjectSyncManager(repo, '/workspace')
|
||||
const results = await manager.syncAllProjects()
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0]).toMatchObject({
|
||||
projectId: 'proj-1',
|
||||
projectName: 'alpha',
|
||||
success: true,
|
||||
fetched: true,
|
||||
})
|
||||
expect(results[1]).toMatchObject({
|
||||
projectId: 'proj-2',
|
||||
projectName: 'beta',
|
||||
success: true,
|
||||
fetched: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns partial failure when the second project fetch throws', async () => {
|
||||
const mockGitSuccess = {
|
||||
fetch: vi.fn().mockResolvedValue({}),
|
||||
raw: vi.fn().mockResolvedValue(''),
|
||||
}
|
||||
const mockGitFail = {
|
||||
fetch: vi.fn().mockRejectedValue(new Error('network error')),
|
||||
raw: vi.fn().mockResolvedValue(''),
|
||||
}
|
||||
simpleGitMock
|
||||
.mockReturnValueOnce(mockGitSuccess)
|
||||
.mockReturnValueOnce(mockGitFail)
|
||||
|
||||
const repo = makeRepo({
|
||||
findAll: vi.fn().mockResolvedValue([project1, project2]),
|
||||
findById: vi.fn()
|
||||
.mockResolvedValueOnce(project1)
|
||||
.mockResolvedValueOnce(project2),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
})
|
||||
const manager = new ProjectSyncManager(repo, '/workspace')
|
||||
const results = await manager.syncAllProjects()
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0]).toMatchObject({ projectId: 'proj-1', success: true })
|
||||
expect(results[1]).toMatchObject({
|
||||
projectId: 'proj-2',
|
||||
success: false,
|
||||
error: expect.any(String),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SyncResult shape', () => {
|
||||
it('result always contains projectId and success fields', async () => {
|
||||
const mockGit = {
|
||||
fetch: vi.fn().mockResolvedValue({}),
|
||||
raw: vi.fn().mockResolvedValue(''),
|
||||
}
|
||||
simpleGitMock.mockReturnValue(mockGit)
|
||||
|
||||
const repo = makeRepo({
|
||||
findAll: vi.fn().mockResolvedValue([project1]),
|
||||
findById: vi.fn().mockResolvedValue(project1),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
})
|
||||
const manager = new ProjectSyncManager(repo, '/workspace')
|
||||
const results = await manager.syncAllProjects()
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
projectId: expect.any(String),
|
||||
success: expect.any(Boolean),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure counting logic', () => {
|
||||
it('counts failures from SyncResult array', () => {
|
||||
const results: Pick<SyncResult, 'success'>[] = [
|
||||
{ success: true },
|
||||
{ success: false },
|
||||
{ success: true },
|
||||
{ success: false },
|
||||
]
|
||||
const failed = results.filter(r => !r.success)
|
||||
expect(failed.length).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@
|
||||
* on project clones without requiring a worktree.
|
||||
*/
|
||||
|
||||
import { join } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { simpleGit } from 'simple-git';
|
||||
@@ -39,6 +39,9 @@ export class SimpleGitBranchManager implements BranchManager {
|
||||
const tempBranch = `cw-merge-${Date.now()}`;
|
||||
|
||||
try {
|
||||
// Capture the target branch ref before merge so callers can roll back on push failure
|
||||
const previousRef = (await repoGit.raw(['rev-parse', targetBranch])).trim();
|
||||
|
||||
// Create worktree with a temp branch starting at targetBranch's commit
|
||||
await repoGit.raw(['worktree', 'add', '-b', tempBranch, tmpPath, targetBranch]);
|
||||
|
||||
@@ -53,7 +56,7 @@ export class SimpleGitBranchManager implements BranchManager {
|
||||
await repoGit.raw(['update-ref', `refs/heads/${targetBranch}`, mergeCommit]);
|
||||
|
||||
log.info({ repoPath, sourceBranch, targetBranch }, 'merge completed cleanly');
|
||||
return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}` };
|
||||
return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}`, previousRef };
|
||||
} catch (mergeErr) {
|
||||
// Check for merge conflicts
|
||||
const status = await wtGit.status();
|
||||
@@ -161,7 +164,26 @@ export class SimpleGitBranchManager implements BranchManager {
|
||||
|
||||
async pushBranch(repoPath: string, branch: string, remote = 'origin'): Promise<void> {
|
||||
const git = simpleGit(repoPath);
|
||||
await git.push(remote, branch);
|
||||
try {
|
||||
await git.push(remote, branch);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!msg.includes('branch is currently checked out')) throw err;
|
||||
|
||||
// Local non-bare repo with the branch checked out — temporarily allow it.
|
||||
// receive.denyCurrentBranch=updateInstead updates the remote's working tree
|
||||
// and index to match, or rejects if the working tree is dirty.
|
||||
const remoteUrl = (await git.remote(['get-url', remote]))?.trim();
|
||||
if (!remoteUrl) throw err;
|
||||
const remotePath = resolve(repoPath, remoteUrl);
|
||||
const remoteGit = simpleGit(remotePath);
|
||||
await remoteGit.addConfig('receive.denyCurrentBranch', 'updateInstead');
|
||||
try {
|
||||
await git.push(remote, branch);
|
||||
} finally {
|
||||
await remoteGit.raw(['config', '--unset', 'receive.denyCurrentBranch']);
|
||||
}
|
||||
}
|
||||
log.info({ repoPath, branch, remote }, 'branch pushed to remote');
|
||||
}
|
||||
|
||||
@@ -205,7 +227,24 @@ export class SimpleGitBranchManager implements BranchManager {
|
||||
async fastForwardBranch(repoPath: string, branch: string, remote = 'origin'): Promise<void> {
|
||||
const git = simpleGit(repoPath);
|
||||
const remoteBranch = `${remote}/${branch}`;
|
||||
await git.raw(['merge', '--ff-only', remoteBranch, branch]);
|
||||
|
||||
// Verify it's a genuine fast-forward (branch is ancestor of remote)
|
||||
try {
|
||||
await git.raw(['merge-base', '--is-ancestor', branch, remoteBranch]);
|
||||
} catch {
|
||||
throw new Error(`Cannot fast-forward ${branch}: it has diverged from ${remoteBranch}`);
|
||||
}
|
||||
|
||||
// Use update-ref instead of git merge so dirty working trees don't block it.
|
||||
// The clone may have uncommitted agent work; we only need to advance the ref.
|
||||
const targetCommit = (await git.raw(['rev-parse', remoteBranch])).trim();
|
||||
await git.raw(['update-ref', `refs/heads/${branch}`, targetCommit]);
|
||||
log.info({ repoPath, branch, remoteBranch }, 'fast-forwarded branch');
|
||||
}
|
||||
|
||||
async updateRef(repoPath: string, branch: string, commitHash: string): Promise<void> {
|
||||
const git = simpleGit(repoPath);
|
||||
await git.raw(['update-ref', `refs/heads/${branch}`, commitHash]);
|
||||
log.info({ repoPath, branch, commitHash: commitHash.slice(0, 7) }, 'branch ref updated');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface MergeResult {
|
||||
conflicts?: string[];
|
||||
/** Human-readable message describing the result */
|
||||
message: string;
|
||||
/** The target branch's commit hash before the merge (for rollback on push failure) */
|
||||
previousRef?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -32,6 +32,7 @@ interface TestAgent {
|
||||
initiativeId: string | null;
|
||||
userDismissedAt: Date | null;
|
||||
exitCode: number | null;
|
||||
prompt: string | null;
|
||||
}
|
||||
|
||||
describe('Crash marking race condition', () => {
|
||||
@@ -72,7 +73,8 @@ describe('Crash marking race condition', () => {
|
||||
pendingQuestions: null,
|
||||
initiativeId: 'init-1',
|
||||
userDismissedAt: null,
|
||||
exitCode: null
|
||||
exitCode: null,
|
||||
prompt: null,
|
||||
};
|
||||
|
||||
// Mock repository that tracks all update calls
|
||||
|
||||
320
apps/server/test/unit/headquarters.test.ts
Normal file
320
apps/server/test/unit/headquarters.test.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Unit tests for getHeadquartersDashboard tRPC procedure.
|
||||
*
|
||||
* Uses in-memory Drizzle DB + inline MockAgentManager for isolation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { router, publicProcedure, createCallerFactory } from '../../trpc/trpc.js';
|
||||
import { headquartersProcedures } from '../../trpc/routers/headquarters.js';
|
||||
import type { TRPCContext } from '../../trpc/context.js';
|
||||
import type { AgentManager, AgentInfo, PendingQuestions } from '../../agent/types.js';
|
||||
import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js';
|
||||
import {
|
||||
DrizzleInitiativeRepository,
|
||||
DrizzlePhaseRepository,
|
||||
DrizzleTaskRepository,
|
||||
} from '../../db/repositories/drizzle/index.js';
|
||||
|
||||
// =============================================================================
|
||||
// MockAgentManager
|
||||
// =============================================================================
|
||||
|
||||
class MockAgentManager implements AgentManager {
|
||||
private agents: AgentInfo[] = [];
|
||||
private questions: Map<string, PendingQuestions> = new Map();
|
||||
|
||||
addAgent(info: Partial<AgentInfo> & Pick<AgentInfo, 'id' | 'name' | 'status'>): void {
|
||||
this.agents.push({
|
||||
taskId: null,
|
||||
initiativeId: null,
|
||||
sessionId: null,
|
||||
worktreeId: info.id,
|
||||
mode: 'execute',
|
||||
provider: 'claude',
|
||||
accountId: null,
|
||||
createdAt: new Date('2025-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2025-01-01T00:00:00Z'),
|
||||
userDismissedAt: null,
|
||||
exitCode: null,
|
||||
prompt: null,
|
||||
...info,
|
||||
});
|
||||
}
|
||||
|
||||
setQuestions(agentId: string, questions: PendingQuestions): void {
|
||||
this.questions.set(agentId, questions);
|
||||
}
|
||||
|
||||
async list(): Promise<AgentInfo[]> {
|
||||
return [...this.agents];
|
||||
}
|
||||
|
||||
async getPendingQuestions(agentId: string): Promise<PendingQuestions | null> {
|
||||
return this.questions.get(agentId) ?? null;
|
||||
}
|
||||
|
||||
async spawn(): Promise<AgentInfo> { throw new Error('Not implemented'); }
|
||||
async stop(): Promise<void> { throw new Error('Not implemented'); }
|
||||
async get(): Promise<AgentInfo | null> { return null; }
|
||||
async getByName(): Promise<AgentInfo | null> { return null; }
|
||||
async resume(): Promise<void> { throw new Error('Not implemented'); }
|
||||
async getResult() { return null; }
|
||||
async delete(): Promise<void> { throw new Error('Not implemented'); }
|
||||
async dismiss(): Promise<void> { throw new Error('Not implemented'); }
|
||||
async resumeForConversation(): Promise<boolean> { return false; }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Test router
|
||||
// =============================================================================
|
||||
|
||||
const testRouter = router({
|
||||
...headquartersProcedures(publicProcedure),
|
||||
});
|
||||
|
||||
const createCaller = createCallerFactory(testRouter);
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function makeCtx(agentManager: MockAgentManager, overrides?: Partial<TRPCContext>): TRPCContext {
|
||||
const db = createTestDatabase();
|
||||
return {
|
||||
eventBus: {} as TRPCContext['eventBus'],
|
||||
serverStartedAt: null,
|
||||
processCount: 0,
|
||||
agentManager,
|
||||
initiativeRepository: new DrizzleInitiativeRepository(db),
|
||||
phaseRepository: new DrizzlePhaseRepository(db),
|
||||
taskRepository: new DrizzleTaskRepository(db),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('getHeadquartersDashboard', () => {
|
||||
it('empty state — no initiatives, no agents → all arrays empty', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const caller = createCaller(makeCtx(agents));
|
||||
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.waitingForInput).toEqual([]);
|
||||
expect(result.pendingReviewInitiatives).toEqual([]);
|
||||
expect(result.pendingReviewPhases).toEqual([]);
|
||||
expect(result.planningInitiatives).toEqual([]);
|
||||
expect(result.blockedPhases).toEqual([]);
|
||||
});
|
||||
|
||||
it('waitingForInput — agent with waiting_for_input status appears', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'My Initiative', status: 'active' });
|
||||
|
||||
agents.addAgent({
|
||||
id: 'agent-1',
|
||||
name: 'jolly-agent',
|
||||
status: 'waiting_for_input',
|
||||
initiativeId: initiative.id,
|
||||
userDismissedAt: null,
|
||||
updatedAt: new Date('2025-06-01T12:00:00Z'),
|
||||
});
|
||||
agents.setQuestions('agent-1', {
|
||||
questions: [{ id: 'q1', question: 'Which approach?' }],
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.waitingForInput).toHaveLength(1);
|
||||
const item = result.waitingForInput[0];
|
||||
expect(item.agentId).toBe('agent-1');
|
||||
expect(item.agentName).toBe('jolly-agent');
|
||||
expect(item.initiativeId).toBe(initiative.id);
|
||||
expect(item.initiativeName).toBe('My Initiative');
|
||||
expect(item.questionText).toBe('Which approach?');
|
||||
});
|
||||
|
||||
it('waitingForInput — dismissed agent is excluded', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'My Initiative', status: 'active' });
|
||||
|
||||
agents.addAgent({
|
||||
id: 'agent-1',
|
||||
name: 'dismissed-agent',
|
||||
status: 'waiting_for_input',
|
||||
initiativeId: initiative.id,
|
||||
userDismissedAt: new Date(),
|
||||
});
|
||||
agents.setQuestions('agent-1', {
|
||||
questions: [{ id: 'q1', question: 'Which approach?' }],
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.waitingForInput).toEqual([]);
|
||||
});
|
||||
|
||||
it('pendingReviewInitiatives — initiative with pending_review status appears', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'Review Me', status: 'pending_review' });
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.pendingReviewInitiatives).toHaveLength(1);
|
||||
expect(result.pendingReviewInitiatives[0].initiativeId).toBe(initiative.id);
|
||||
expect(result.pendingReviewInitiatives[0].initiativeName).toBe('Review Me');
|
||||
});
|
||||
|
||||
it('pendingReviewPhases — phase with pending_review status appears', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const phaseRepo = ctx.phaseRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'My Initiative', status: 'active' });
|
||||
const phase = await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
name: 'Phase 1',
|
||||
status: 'pending_review',
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.pendingReviewPhases).toHaveLength(1);
|
||||
const item = result.pendingReviewPhases[0];
|
||||
expect(item.initiativeId).toBe(initiative.id);
|
||||
expect(item.initiativeName).toBe('My Initiative');
|
||||
expect(item.phaseId).toBe(phase.id);
|
||||
expect(item.phaseName).toBe('Phase 1');
|
||||
});
|
||||
|
||||
it('planningInitiatives — all phases pending and no running agents', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const phaseRepo = ctx.phaseRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'Planning Init', status: 'active' });
|
||||
const phase1 = await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
name: 'Phase 1',
|
||||
status: 'pending',
|
||||
});
|
||||
await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
name: 'Phase 2',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.planningInitiatives).toHaveLength(1);
|
||||
const item = result.planningInitiatives[0];
|
||||
expect(item.initiativeId).toBe(initiative.id);
|
||||
expect(item.initiativeName).toBe('Planning Init');
|
||||
expect(item.pendingPhaseCount).toBe(2);
|
||||
// since = oldest phase createdAt
|
||||
expect(item.since).toBe(phase1.createdAt.toISOString());
|
||||
});
|
||||
|
||||
it('planningInitiatives — excluded when a running agent exists for the initiative', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const phaseRepo = ctx.phaseRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'Planning Init', status: 'active' });
|
||||
await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 1', status: 'pending' });
|
||||
|
||||
agents.addAgent({
|
||||
id: 'agent-running',
|
||||
name: 'busy-agent',
|
||||
status: 'running',
|
||||
initiativeId: initiative.id,
|
||||
userDismissedAt: null,
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.planningInitiatives).toEqual([]);
|
||||
});
|
||||
|
||||
it('planningInitiatives — excluded when a phase is not pending', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const phaseRepo = ctx.phaseRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'Mixed Init', status: 'active' });
|
||||
await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 1', status: 'pending' });
|
||||
await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 2', status: 'in_progress' });
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.planningInitiatives).toEqual([]);
|
||||
});
|
||||
|
||||
it('blockedPhases — phase with blocked status appears', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const phaseRepo = ctx.phaseRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'Blocked Init', status: 'active' });
|
||||
const phase = await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
name: 'Stuck Phase',
|
||||
status: 'blocked',
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.blockedPhases).toHaveLength(1);
|
||||
const item = result.blockedPhases[0];
|
||||
expect(item.initiativeId).toBe(initiative.id);
|
||||
expect(item.initiativeName).toBe('Blocked Init');
|
||||
expect(item.phaseId).toBe(phase.id);
|
||||
expect(item.phaseName).toBe('Stuck Phase');
|
||||
expect(item.lastMessage).toBeNull();
|
||||
});
|
||||
|
||||
it('ordering — waitingForInput sorted oldest first', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
|
||||
agents.addAgent({
|
||||
id: 'agent-newer',
|
||||
name: 'newer-agent',
|
||||
status: 'waiting_for_input',
|
||||
userDismissedAt: null,
|
||||
updatedAt: new Date('2025-06-02T00:00:00Z'),
|
||||
});
|
||||
agents.addAgent({
|
||||
id: 'agent-older',
|
||||
name: 'older-agent',
|
||||
status: 'waiting_for_input',
|
||||
userDismissedAt: null,
|
||||
updatedAt: new Date('2025-06-01T00:00:00Z'),
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.waitingForInput).toHaveLength(2);
|
||||
expect(result.waitingForInput[0].agentId).toBe('agent-older');
|
||||
expect(result.waitingForInput[1].agentId).toBe('agent-newer');
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import { previewProcedures } from './routers/preview.js';
|
||||
import { conversationProcedures } from './routers/conversation.js';
|
||||
import { chatSessionProcedures } from './routers/chat-session.js';
|
||||
import { errandProcedures } from './routers/errand.js';
|
||||
import { headquartersProcedures } from './routers/headquarters.js';
|
||||
|
||||
// Re-export tRPC primitives (preserves existing import paths)
|
||||
export { router, publicProcedure, middleware, createCallerFactory } from './trpc.js';
|
||||
@@ -65,6 +66,7 @@ export const appRouter = router({
|
||||
...conversationProcedures(publicProcedure),
|
||||
...chatSessionProcedures(publicProcedure),
|
||||
...errandProcedures(publicProcedure),
|
||||
...headquartersProcedures(publicProcedure),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
327
apps/server/trpc/routers/agent.test.ts
Normal file
327
apps/server/trpc/routers/agent.test.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Agent Router Tests
|
||||
*
|
||||
* Tests for getAgent (exitCode, taskName, initiativeName),
|
||||
* getAgentInputFiles, and getAgentPrompt procedures.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { appRouter, createCallerFactory } from '../index.js';
|
||||
import type { TRPCContext } from '../context.js';
|
||||
import type { EventBus } from '../../events/types.js';
|
||||
|
||||
const createCaller = createCallerFactory(appRouter);
|
||||
|
||||
function createMockEventBus(): EventBus {
|
||||
return {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createTestContext(overrides: Partial<TRPCContext> = {}): TRPCContext {
|
||||
return {
|
||||
eventBus: createMockEventBus(),
|
||||
serverStartedAt: new Date('2026-01-30T12:00:00Z'),
|
||||
processCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal AgentInfo fixture matching the full interface */
|
||||
function makeAgentInfo(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'agent-1',
|
||||
name: 'test-agent',
|
||||
taskId: null,
|
||||
initiativeId: null,
|
||||
sessionId: null,
|
||||
worktreeId: 'test-agent',
|
||||
status: 'stopped' as const,
|
||||
mode: 'execute' as const,
|
||||
provider: 'claude',
|
||||
accountId: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
userDismissedAt: null,
|
||||
exitCode: null,
|
||||
prompt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getAgent', () => {
|
||||
it('returns exitCode: 1 when agent has exitCode 1', async () => {
|
||||
const mockManager = {
|
||||
get: vi.fn().mockResolvedValue(makeAgentInfo({ exitCode: 1 })),
|
||||
};
|
||||
|
||||
const ctx = createTestContext({ agentManager: mockManager as any });
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgent({ id: 'agent-1' });
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('returns exitCode: null when agent has no exitCode', async () => {
|
||||
const mockManager = {
|
||||
get: vi.fn().mockResolvedValue(makeAgentInfo({ exitCode: null })),
|
||||
};
|
||||
|
||||
const ctx = createTestContext({ agentManager: mockManager as any });
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgent({ id: 'agent-1' });
|
||||
|
||||
expect(result.exitCode).toBeNull();
|
||||
});
|
||||
|
||||
it('returns taskName and initiativeName from repositories', async () => {
|
||||
const mockManager = {
|
||||
get: vi.fn().mockResolvedValue(makeAgentInfo({ taskId: 'task-1', initiativeId: 'init-1' })),
|
||||
};
|
||||
const mockTaskRepo = {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'task-1', name: 'My Task' }),
|
||||
};
|
||||
const mockInitiativeRepo = {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'init-1', name: 'My Initiative' }),
|
||||
};
|
||||
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
taskRepository: mockTaskRepo as any,
|
||||
initiativeRepository: mockInitiativeRepo as any,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgent({ id: 'agent-1' });
|
||||
|
||||
expect(result.taskName).toBe('My Task');
|
||||
expect(result.initiativeName).toBe('My Initiative');
|
||||
});
|
||||
|
||||
it('returns taskName: null and initiativeName: null when agent has no taskId or initiativeId', async () => {
|
||||
const mockManager = {
|
||||
get: vi.fn().mockResolvedValue(makeAgentInfo({ taskId: null, initiativeId: null })),
|
||||
};
|
||||
|
||||
const ctx = createTestContext({ agentManager: mockManager as any });
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgent({ id: 'agent-1' });
|
||||
|
||||
expect(result.taskName).toBeNull();
|
||||
expect(result.initiativeName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentInputFiles', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeAgentManagerWithWorktree(worktreeId = 'test-worktree', agentName = 'test-agent') {
|
||||
return {
|
||||
get: vi.fn().mockResolvedValue(makeAgentInfo({ worktreeId, name: agentName })),
|
||||
};
|
||||
}
|
||||
|
||||
it('returns worktree_missing when worktree dir does not exist', async () => {
|
||||
const nonExistentRoot = path.join(tmpDir, 'no-such-dir');
|
||||
const mockManager = makeAgentManagerWithWorktree('test-worktree');
|
||||
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
workspaceRoot: nonExistentRoot,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
|
||||
|
||||
expect(result).toEqual({ files: [], reason: 'worktree_missing' });
|
||||
});
|
||||
|
||||
it('returns input_dir_missing when worktree exists but .cw/input does not', async () => {
|
||||
const worktreeId = 'test-worktree';
|
||||
const worktreeRoot = path.join(tmpDir, 'agent-workdirs', worktreeId);
|
||||
await fs.mkdir(worktreeRoot, { recursive: true });
|
||||
|
||||
const mockManager = makeAgentManagerWithWorktree(worktreeId);
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
workspaceRoot: tmpDir,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
|
||||
|
||||
expect(result).toEqual({ files: [], reason: 'input_dir_missing' });
|
||||
});
|
||||
|
||||
it('returns sorted file list with correct name, content, sizeBytes', async () => {
|
||||
const worktreeId = 'test-worktree';
|
||||
const inputDir = path.join(tmpDir, 'agent-workdirs', worktreeId, '.cw', 'input');
|
||||
await fs.mkdir(inputDir, { recursive: true });
|
||||
await fs.mkdir(path.join(inputDir, 'pages'), { recursive: true });
|
||||
|
||||
const manifestContent = '{"files": ["a"]}';
|
||||
const fooContent = '# Foo\nHello world';
|
||||
await fs.writeFile(path.join(inputDir, 'manifest.json'), manifestContent);
|
||||
await fs.writeFile(path.join(inputDir, 'pages', 'foo.md'), fooContent);
|
||||
|
||||
const mockManager = makeAgentManagerWithWorktree(worktreeId);
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
workspaceRoot: tmpDir,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
|
||||
|
||||
expect(result.reason).toBeUndefined();
|
||||
expect(result.files).toHaveLength(2);
|
||||
// Sorted alphabetically: manifest.json before pages/foo.md
|
||||
expect(result.files[0].name).toBe('manifest.json');
|
||||
expect(result.files[0].content).toBe(manifestContent);
|
||||
expect(result.files[0].sizeBytes).toBe(Buffer.byteLength(manifestContent));
|
||||
expect(result.files[1].name).toBe(path.join('pages', 'foo.md'));
|
||||
expect(result.files[1].content).toBe(fooContent);
|
||||
expect(result.files[1].sizeBytes).toBe(Buffer.byteLength(fooContent));
|
||||
});
|
||||
|
||||
it('skips binary files (containing null byte)', async () => {
|
||||
const worktreeId = 'test-worktree';
|
||||
const inputDir = path.join(tmpDir, 'agent-workdirs', worktreeId, '.cw', 'input');
|
||||
await fs.mkdir(inputDir, { recursive: true });
|
||||
|
||||
// Binary file with null byte
|
||||
const binaryData = Buffer.from([0x89, 0x50, 0x00, 0x4e, 0x47]);
|
||||
await fs.writeFile(path.join(inputDir, 'image.png'), binaryData);
|
||||
// Text file should still be returned
|
||||
await fs.writeFile(path.join(inputDir, 'text.txt'), 'hello');
|
||||
|
||||
const mockManager = makeAgentManagerWithWorktree(worktreeId);
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
workspaceRoot: tmpDir,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
|
||||
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(result.files[0].name).toBe('text.txt');
|
||||
});
|
||||
|
||||
it('truncates files larger than 500 KB and preserves original sizeBytes', async () => {
|
||||
const worktreeId = 'test-worktree';
|
||||
const inputDir = path.join(tmpDir, 'agent-workdirs', worktreeId, '.cw', 'input');
|
||||
await fs.mkdir(inputDir, { recursive: true });
|
||||
|
||||
const MAX_SIZE = 500 * 1024;
|
||||
const largeContent = Buffer.alloc(MAX_SIZE + 100 * 1024, 'a'); // 600 KB
|
||||
await fs.writeFile(path.join(inputDir, 'big.txt'), largeContent);
|
||||
|
||||
const mockManager = makeAgentManagerWithWorktree(worktreeId);
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
workspaceRoot: tmpDir,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
|
||||
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(result.files[0].sizeBytes).toBe(largeContent.length);
|
||||
expect(result.files[0].content).toContain('[truncated — file exceeds 500 KB]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentPrompt', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-prompt-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns prompt_not_written when PROMPT.md does not exist', async () => {
|
||||
const mockManager = {
|
||||
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: 'test-agent' })),
|
||||
};
|
||||
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
workspaceRoot: tmpDir,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgentPrompt({ id: 'agent-1' });
|
||||
|
||||
expect(result).toEqual({ content: null, reason: 'prompt_not_written' });
|
||||
});
|
||||
|
||||
it('returns prompt content when PROMPT.md exists', async () => {
|
||||
const agentName = 'test-agent';
|
||||
const promptDir = path.join(tmpDir, '.cw', 'agent-logs', agentName);
|
||||
await fs.mkdir(promptDir, { recursive: true });
|
||||
const promptContent = '# System\nHello';
|
||||
await fs.writeFile(path.join(promptDir, 'PROMPT.md'), promptContent);
|
||||
|
||||
const mockManager = {
|
||||
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName, prompt: null })),
|
||||
};
|
||||
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
workspaceRoot: tmpDir,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgentPrompt({ id: 'agent-1' });
|
||||
|
||||
expect(result).toEqual({ content: promptContent });
|
||||
});
|
||||
|
||||
it('returns prompt from DB when agent.prompt is set (no file needed)', async () => {
|
||||
const dbPromptContent = '# DB Prompt\nThis is persisted in the database';
|
||||
const mockManager = {
|
||||
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: 'test-agent', prompt: dbPromptContent })),
|
||||
};
|
||||
|
||||
// workspaceRoot has no PROMPT.md — but DB value takes precedence
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
workspaceRoot: tmpDir,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgentPrompt({ id: 'agent-1' });
|
||||
|
||||
expect(result).toEqual({ content: dbPromptContent });
|
||||
});
|
||||
|
||||
it('falls back to PROMPT.md when agent.prompt is null in DB', async () => {
|
||||
const agentName = 'test-agent';
|
||||
const promptDir = path.join(tmpDir, '.cw', 'agent-logs', agentName);
|
||||
await fs.mkdir(promptDir, { recursive: true });
|
||||
const fileContent = '# File Prompt\nThis is from the file (legacy)';
|
||||
await fs.writeFile(path.join(promptDir, 'PROMPT.md'), fileContent);
|
||||
|
||||
const mockManager = {
|
||||
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName, prompt: null })),
|
||||
};
|
||||
|
||||
const ctx = createTestContext({
|
||||
agentManager: mockManager as any,
|
||||
workspaceRoot: tmpDir,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getAgentPrompt({ id: 'agent-1' });
|
||||
|
||||
expect(result).toEqual({ content: fileContent });
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,13 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { tracked, type TrackedEnvelope } from '@trpc/server';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import type { ProcedureBuilder } from '../trpc.js';
|
||||
import type { TRPCContext } from '../context.js';
|
||||
import type { AgentInfo, AgentResult, PendingQuestions } from '../../agent/types.js';
|
||||
import type { AgentOutputEvent } from '../../events/types.js';
|
||||
import { requireAgentManager, requireLogChunkRepository } from './_helpers.js';
|
||||
import { requireAgentManager, requireLogChunkRepository, requireTaskRepository, requireInitiativeRepository } from './_helpers.js';
|
||||
|
||||
export const spawnAgentInputSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
@@ -120,7 +122,23 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
|
||||
getAgent: publicProcedure
|
||||
.input(agentIdentifierSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return resolveAgent(ctx, input);
|
||||
const agent = await resolveAgent(ctx, input);
|
||||
|
||||
let taskName: string | null = null;
|
||||
let initiativeName: string | null = null;
|
||||
|
||||
if (agent.taskId) {
|
||||
const taskRepo = requireTaskRepository(ctx);
|
||||
const task = await taskRepo.findById(agent.taskId);
|
||||
taskName = task?.name ?? null;
|
||||
}
|
||||
if (agent.initiativeId) {
|
||||
const initiativeRepo = requireInitiativeRepository(ctx);
|
||||
const initiative = await initiativeRepo.findById(agent.initiativeId);
|
||||
initiativeName = initiative?.name ?? null;
|
||||
}
|
||||
|
||||
return { ...agent, taskName, initiativeName };
|
||||
}),
|
||||
|
||||
getAgentByName: publicProcedure
|
||||
@@ -184,6 +202,17 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
|
||||
return candidates[0] ?? null;
|
||||
}),
|
||||
|
||||
getTaskAgent: publicProcedure
|
||||
.input(z.object({ taskId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }): Promise<AgentInfo | null> => {
|
||||
const agentManager = requireAgentManager(ctx);
|
||||
const all = await agentManager.list();
|
||||
const matches = all
|
||||
.filter(a => a.taskId === input.taskId)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
return matches[0] ?? null;
|
||||
}),
|
||||
|
||||
getActiveConflictAgent: publicProcedure
|
||||
.input(z.object({ initiativeId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }): Promise<AgentInfo | null> => {
|
||||
@@ -207,12 +236,15 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
|
||||
|
||||
getAgentOutput: publicProcedure
|
||||
.input(agentIdentifierSchema)
|
||||
.query(async ({ ctx, input }): Promise<string> => {
|
||||
.query(async ({ ctx, input }) => {
|
||||
const agent = await resolveAgent(ctx, input);
|
||||
const logChunkRepo = requireLogChunkRepository(ctx);
|
||||
|
||||
const chunks = await logChunkRepo.findByAgentId(agent.id);
|
||||
return chunks.map(c => c.content).join('');
|
||||
return chunks.map(c => ({
|
||||
content: c.content,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
}));
|
||||
}),
|
||||
|
||||
onAgentOutput: publicProcedure
|
||||
@@ -267,5 +299,116 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
|
||||
getAgentInputFiles: publicProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.output(z.object({
|
||||
files: z.array(z.object({
|
||||
name: z.string(),
|
||||
content: z.string(),
|
||||
sizeBytes: z.number(),
|
||||
})),
|
||||
reason: z.enum(['worktree_missing', 'input_dir_missing']).optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const agent = await resolveAgent(ctx, { id: input.id });
|
||||
|
||||
const worktreeRoot = path.join(ctx.workspaceRoot!, 'agent-workdirs', agent.worktreeId);
|
||||
const inputDir = path.join(worktreeRoot, '.cw', 'input');
|
||||
|
||||
// Check worktree root exists
|
||||
try {
|
||||
await fs.stat(worktreeRoot);
|
||||
} catch {
|
||||
return { files: [], reason: 'worktree_missing' as const };
|
||||
}
|
||||
|
||||
// Check input dir exists
|
||||
try {
|
||||
await fs.stat(inputDir);
|
||||
} catch {
|
||||
return { files: [], reason: 'input_dir_missing' as const };
|
||||
}
|
||||
|
||||
// Walk inputDir recursively
|
||||
const entries = await fs.readdir(inputDir, { recursive: true, withFileTypes: true });
|
||||
const MAX_SIZE = 500 * 1024;
|
||||
const results: Array<{ name: string; content: string; sizeBytes: number }> = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
// entry.parentPath is available in Node 20+
|
||||
const dir = (entry as any).parentPath ?? (entry as any).path;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
const relativeName = path.relative(inputDir, fullPath);
|
||||
|
||||
try {
|
||||
// Binary detection: read first 512 bytes
|
||||
const fd = await fs.open(fullPath, 'r');
|
||||
const headerBuf = Buffer.alloc(512);
|
||||
const { bytesRead } = await fd.read(headerBuf, 0, 512, 0);
|
||||
await fd.close();
|
||||
if (headerBuf.slice(0, bytesRead).includes(0)) continue; // skip binary
|
||||
|
||||
const raw = await fs.readFile(fullPath);
|
||||
const sizeBytes = raw.length;
|
||||
let content: string;
|
||||
if (sizeBytes > MAX_SIZE) {
|
||||
content = raw.slice(0, MAX_SIZE).toString('utf-8') + '\n\n[truncated — file exceeds 500 KB]';
|
||||
} else {
|
||||
content = raw.toString('utf-8');
|
||||
}
|
||||
results.push({ name: relativeName, content, sizeBytes });
|
||||
} catch {
|
||||
continue; // skip unreadable files
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return { files: results };
|
||||
}),
|
||||
|
||||
getAgentPrompt: publicProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.output(z.object({
|
||||
content: z.string().nullable(),
|
||||
reason: z.enum(['prompt_not_written']).optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const agent = await resolveAgent(ctx, { id: input.id });
|
||||
|
||||
const MAX_BYTES = 1024 * 1024; // 1 MB
|
||||
|
||||
function truncateIfNeeded(text: string): string {
|
||||
if (Buffer.byteLength(text, 'utf-8') > MAX_BYTES) {
|
||||
const buf = Buffer.from(text, 'utf-8');
|
||||
return buf.slice(0, MAX_BYTES).toString('utf-8') + '\n\n[truncated — prompt exceeds 1 MB]';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// Prefer DB-persisted prompt (durable even after log file cleanup)
|
||||
if (agent.prompt !== null) {
|
||||
return { content: truncateIfNeeded(agent.prompt) };
|
||||
}
|
||||
|
||||
// Fall back to filesystem for agents spawned before DB persistence was added
|
||||
const promptPath = path.join(ctx.workspaceRoot!, '.cw', 'agent-logs', agent.name, 'PROMPT.md');
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(promptPath, 'utf-8');
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'ENOENT') {
|
||||
return { content: null, reason: 'prompt_not_written' as const };
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `Failed to read prompt file: ${String(err)}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { content: truncateIfNeeded(raw) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,7 +139,6 @@ async function createErrandDirect(
|
||||
agentId: string | null;
|
||||
projectId: string;
|
||||
status: 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned';
|
||||
conflictFiles: string | null;
|
||||
}> = {},
|
||||
) {
|
||||
const project = await createProject(repos);
|
||||
@@ -153,13 +152,13 @@ async function createErrandDirect(
|
||||
});
|
||||
|
||||
const errand = await repos.errandRepository.create({
|
||||
id: nanoid(),
|
||||
description: overrides.description ?? 'Fix typo in README',
|
||||
branch: overrides.branch ?? 'cw/errand/fix-typo-abc12345',
|
||||
baseBranch: overrides.baseBranch ?? 'main',
|
||||
agentId: overrides.agentId !== undefined ? overrides.agentId : agent.id,
|
||||
projectId: overrides.projectId ?? project.id,
|
||||
status: overrides.status ?? 'active',
|
||||
conflictFiles: overrides.conflictFiles ?? null,
|
||||
});
|
||||
|
||||
return { errand, project, agent };
|
||||
@@ -356,7 +355,7 @@ describe('errand procedures', () => {
|
||||
const { errand: e1, project } = await createErrandDirect(h.repos, h.agentManager);
|
||||
const project2 = await h.repos.projectRepository.create({ name: 'proj2', url: 'https://github.com/t/p2', defaultBranch: 'main' });
|
||||
const agent2 = await h.agentManager.spawn({ prompt: 'x', mode: 'errand', cwd: '/tmp/x', taskId: null });
|
||||
await h.repos.errandRepository.create({ description: 'Other', branch: 'cw/errand/other-abc12345', baseBranch: 'main', agentId: agent2.id, projectId: project2.id, status: 'active', conflictFiles: null });
|
||||
await h.repos.errandRepository.create({ id: nanoid(), description: 'Other', branch: 'cw/errand/other-abc12345', baseBranch: 'main', agentId: agent2.id, projectId: project2.id, status: 'active' });
|
||||
|
||||
const result = await h.caller.errand.list({ projectId: project.id });
|
||||
expect(result.length).toBe(1);
|
||||
@@ -388,23 +387,13 @@ describe('errand procedures', () => {
|
||||
// errand.get
|
||||
// =========================================================================
|
||||
describe('errand.get', () => {
|
||||
it('returns errand with agentAlias and parsed conflictFiles', async () => {
|
||||
it('returns errand with agentAlias and projectPath', async () => {
|
||||
const { errand } = await createErrandDirect(h.repos, h.agentManager);
|
||||
const result = await h.caller.errand.get({ id: errand.id });
|
||||
|
||||
expect(result.id).toBe(errand.id);
|
||||
expect(result).toHaveProperty('agentAlias');
|
||||
expect(result.conflictFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses conflictFiles JSON when present', async () => {
|
||||
const { errand } = await createErrandDirect(h.repos, h.agentManager, {
|
||||
status: 'conflict',
|
||||
conflictFiles: '["src/a.ts","src/b.ts"]',
|
||||
});
|
||||
|
||||
const result = await h.caller.errand.get({ id: errand.id });
|
||||
expect(result.conflictFiles).toEqual(['src/a.ts', 'src/b.ts']);
|
||||
expect(result).toHaveProperty('projectPath');
|
||||
});
|
||||
|
||||
it('throws NOT_FOUND for unknown id', async () => {
|
||||
@@ -496,7 +485,6 @@ describe('errand procedures', () => {
|
||||
it('merges clean conflict errand (re-merge after resolve)', async () => {
|
||||
const { errand } = await createErrandDirect(h.repos, h.agentManager, {
|
||||
status: 'conflict',
|
||||
conflictFiles: '["src/a.ts"]',
|
||||
});
|
||||
h.branchManager.setMergeResult({ success: true, message: 'Merged' });
|
||||
|
||||
@@ -517,7 +505,7 @@ describe('errand procedures', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('throws BAD_REQUEST and stores conflictFiles on merge conflict', async () => {
|
||||
it('throws BAD_REQUEST and sets status to conflict on merge conflict', async () => {
|
||||
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
|
||||
h.branchManager.setMergeResult({
|
||||
success: false,
|
||||
@@ -532,7 +520,6 @@ describe('errand procedures', () => {
|
||||
|
||||
const updated = await h.repos.errandRepository.findById(errand.id);
|
||||
expect(updated!.status).toBe('conflict');
|
||||
expect(JSON.parse(updated!.conflictFiles!)).toEqual(['src/a.ts', 'src/b.ts']);
|
||||
});
|
||||
|
||||
it('throws BAD_REQUEST when status is active', async () => {
|
||||
@@ -570,7 +557,7 @@ describe('errand procedures', () => {
|
||||
expect(h.branchManager.deletedBranches).toContain(errand.branch);
|
||||
|
||||
const deleted = await h.repos.errandRepository.findById(errand.id);
|
||||
expect(deleted).toBeNull();
|
||||
expect(deleted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deletes non-active errand: skips agent stop', async () => {
|
||||
@@ -583,7 +570,7 @@ describe('errand procedures', () => {
|
||||
expect(stopSpy).not.toHaveBeenCalled();
|
||||
|
||||
const deleted = await h.repos.errandRepository.findById(errand.id);
|
||||
expect(deleted).toBeNull();
|
||||
expect(deleted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('succeeds when worktree already removed (no-op)', async () => {
|
||||
@@ -595,7 +582,7 @@ describe('errand procedures', () => {
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
const deleted = await h.repos.errandRepository.findById(errand.id);
|
||||
expect(deleted).toBeNull();
|
||||
expect(deleted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('succeeds when branch already deleted (no-op)', async () => {
|
||||
@@ -692,7 +679,6 @@ describe('errand procedures', () => {
|
||||
it('abandons conflict errand: skips agent stop, removes worktree, deletes branch', async () => {
|
||||
const { errand } = await createErrandDirect(h.repos, h.agentManager, {
|
||||
status: 'conflict',
|
||||
conflictFiles: '["src/a.ts"]',
|
||||
agentId: null,
|
||||
});
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
|
||||
let errand;
|
||||
try {
|
||||
errand = await repo.create({
|
||||
id: nanoid(),
|
||||
description: input.description,
|
||||
branch: branchName,
|
||||
baseBranch,
|
||||
@@ -202,16 +203,6 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' });
|
||||
}
|
||||
|
||||
// Parse conflictFiles; return [] on null or malformed JSON
|
||||
let conflictFiles: string[] = [];
|
||||
if (errand.conflictFiles) {
|
||||
try {
|
||||
conflictFiles = JSON.parse(errand.conflictFiles) as string[];
|
||||
} catch {
|
||||
conflictFiles = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Compute project clone path for cw errand resolve
|
||||
let projectPath: string | null = null;
|
||||
if (errand.projectId && ctx.workspaceRoot) {
|
||||
@@ -221,7 +212,7 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
|
||||
}
|
||||
}
|
||||
|
||||
return { ...errand, conflictFiles, projectPath };
|
||||
return { ...errand, projectPath };
|
||||
}),
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -235,6 +226,9 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' });
|
||||
}
|
||||
|
||||
if (!errand.projectId) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand has no project' });
|
||||
}
|
||||
const project = await requireProjectRepository(ctx).findById(errand.projectId);
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' });
|
||||
@@ -303,6 +297,9 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
|
||||
|
||||
const targetBranch = input.target ?? errand.baseBranch;
|
||||
|
||||
if (!errand.projectId) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand has no project' });
|
||||
}
|
||||
const project = await requireProjectRepository(ctx).findById(errand.projectId);
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' });
|
||||
@@ -319,15 +316,12 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
|
||||
// Clean merge — remove worktree and mark merged
|
||||
const worktreeManager = new SimpleGitWorktreeManager(clonePath);
|
||||
try { await worktreeManager.remove(errand.id); } catch { /* no-op */ }
|
||||
await repo.update(input.id, { status: 'merged', conflictFiles: null });
|
||||
await repo.update(input.id, { status: 'merged' });
|
||||
return { status: 'merged' };
|
||||
} else {
|
||||
// Conflict — persist conflict files and throw
|
||||
// Conflict — update status and throw
|
||||
const conflictFilesList = result.conflicts ?? [];
|
||||
await repo.update(input.id, {
|
||||
status: 'conflict',
|
||||
conflictFiles: JSON.stringify(conflictFilesList),
|
||||
});
|
||||
await repo.update(input.id, { status: 'conflict' });
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Merge conflict in ${conflictFilesList.length} file(s)`,
|
||||
|
||||
214
apps/server/trpc/routers/headquarters.ts
Normal file
214
apps/server/trpc/routers/headquarters.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Headquarters Router
|
||||
*
|
||||
* Provides the composite dashboard query for the Headquarters page,
|
||||
* aggregating all action items that require user intervention.
|
||||
*/
|
||||
|
||||
import type { ProcedureBuilder } from '../trpc.js';
|
||||
import type { Phase } from '../../db/schema.js';
|
||||
import {
|
||||
requireAgentManager,
|
||||
requireInitiativeRepository,
|
||||
requirePhaseRepository,
|
||||
} from './_helpers.js';
|
||||
|
||||
export function headquartersProcedures(publicProcedure: ProcedureBuilder) {
|
||||
return {
|
||||
getHeadquartersDashboard: publicProcedure.query(async ({ ctx }) => {
|
||||
const initiativeRepo = requireInitiativeRepository(ctx);
|
||||
const phaseRepo = requirePhaseRepository(ctx);
|
||||
const agentManager = requireAgentManager(ctx);
|
||||
|
||||
const [allInitiatives, allAgents] = await Promise.all([
|
||||
initiativeRepo.findAll(),
|
||||
agentManager.list(),
|
||||
]);
|
||||
|
||||
// Relevant initiatives: status in ['active', 'pending_review']
|
||||
const relevantInitiatives = allInitiatives.filter(
|
||||
(i) => i.status === 'active' || i.status === 'pending_review',
|
||||
);
|
||||
|
||||
// Non-dismissed agents only
|
||||
const activeAgents = allAgents.filter((a) => !a.userDismissedAt);
|
||||
|
||||
// Fast lookup map: initiative id → initiative
|
||||
const initiativeMap = new Map(relevantInitiatives.map((i) => [i.id, i]));
|
||||
|
||||
// Batch-fetch all phases for relevant initiatives in parallel
|
||||
const phasesByInitiative = new Map<string, Phase[]>();
|
||||
await Promise.all(
|
||||
relevantInitiatives.map(async (init) => {
|
||||
const phases = await phaseRepo.findByInitiativeId(init.id);
|
||||
phasesByInitiative.set(init.id, phases);
|
||||
}),
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Section 1: waitingForInput
|
||||
// -----------------------------------------------------------------------
|
||||
const waitingAgents = activeAgents.filter((a) => a.status === 'waiting_for_input');
|
||||
const pendingQuestionsResults = await Promise.all(
|
||||
waitingAgents.map((a) => agentManager.getPendingQuestions(a.id)),
|
||||
);
|
||||
|
||||
const waitingForInput = waitingAgents
|
||||
.map((agent, i) => {
|
||||
const initiative = agent.initiativeId ? initiativeMap.get(agent.initiativeId) : undefined;
|
||||
return {
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
initiativeId: agent.initiativeId,
|
||||
initiativeName: initiative?.name ?? null,
|
||||
questionText: pendingQuestionsResults[i]?.questions[0]?.question ?? '',
|
||||
waitingSince: agent.updatedAt.toISOString(),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.waitingSince.localeCompare(b.waitingSince));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Section 2a: pendingReviewInitiatives
|
||||
// -----------------------------------------------------------------------
|
||||
const pendingReviewInitiatives = relevantInitiatives
|
||||
.filter((i) => i.status === 'pending_review')
|
||||
.map((i) => ({
|
||||
initiativeId: i.id,
|
||||
initiativeName: i.name,
|
||||
since: i.updatedAt.toISOString(),
|
||||
}))
|
||||
.sort((a, b) => a.since.localeCompare(b.since));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Section 2b: pendingReviewPhases
|
||||
// -----------------------------------------------------------------------
|
||||
const pendingReviewPhases: Array<{
|
||||
initiativeId: string;
|
||||
initiativeName: string;
|
||||
phaseId: string;
|
||||
phaseName: string;
|
||||
since: string;
|
||||
}> = [];
|
||||
|
||||
for (const [initiativeId, phases] of phasesByInitiative) {
|
||||
const initiative = initiativeMap.get(initiativeId)!;
|
||||
for (const phase of phases) {
|
||||
if (phase.status === 'pending_review') {
|
||||
pendingReviewPhases.push({
|
||||
initiativeId,
|
||||
initiativeName: initiative.name,
|
||||
phaseId: phase.id,
|
||||
phaseName: phase.name,
|
||||
since: phase.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingReviewPhases.sort((a, b) => a.since.localeCompare(b.since));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Section 3: planningInitiatives
|
||||
// -----------------------------------------------------------------------
|
||||
const planningInitiatives: Array<{
|
||||
initiativeId: string;
|
||||
initiativeName: string;
|
||||
pendingPhaseCount: number;
|
||||
since: string;
|
||||
}> = [];
|
||||
|
||||
for (const initiative of relevantInitiatives) {
|
||||
if (initiative.status !== 'active') continue;
|
||||
const phases = phasesByInitiative.get(initiative.id) ?? [];
|
||||
if (phases.length === 0) continue;
|
||||
|
||||
const allPending = phases.every((p) => p.status === 'pending');
|
||||
if (!allPending) continue;
|
||||
|
||||
const hasActiveAgent = activeAgents.some(
|
||||
(a) =>
|
||||
a.initiativeId === initiative.id &&
|
||||
(a.status === 'running' || a.status === 'waiting_for_input'),
|
||||
);
|
||||
if (hasActiveAgent) continue;
|
||||
|
||||
const sortedByCreatedAt = [...phases].sort(
|
||||
(a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
|
||||
);
|
||||
|
||||
planningInitiatives.push({
|
||||
initiativeId: initiative.id,
|
||||
initiativeName: initiative.name,
|
||||
pendingPhaseCount: phases.length,
|
||||
since: sortedByCreatedAt[0].createdAt.toISOString(),
|
||||
});
|
||||
}
|
||||
planningInitiatives.sort((a, b) => a.since.localeCompare(b.since));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Section 4: blockedPhases
|
||||
// -----------------------------------------------------------------------
|
||||
const blockedPhases: Array<{
|
||||
initiativeId: string;
|
||||
initiativeName: string;
|
||||
phaseId: string;
|
||||
phaseName: string;
|
||||
lastMessage: string | null;
|
||||
since: string;
|
||||
}> = [];
|
||||
|
||||
for (const initiative of relevantInitiatives) {
|
||||
if (initiative.status !== 'active') continue;
|
||||
const phases = phasesByInitiative.get(initiative.id) ?? [];
|
||||
|
||||
for (const phase of phases) {
|
||||
if (phase.status !== 'blocked') continue;
|
||||
|
||||
let lastMessage: string | null = null;
|
||||
try {
|
||||
if (ctx.taskRepository && ctx.messageRepository) {
|
||||
const taskRepo = ctx.taskRepository;
|
||||
const messageRepo = ctx.messageRepository;
|
||||
const tasks = await taskRepo.findByPhaseId(phase.id);
|
||||
const phaseAgentIds = allAgents
|
||||
.filter((a) => tasks.some((t) => t.id === a.taskId))
|
||||
.map((a) => a.id);
|
||||
|
||||
if (phaseAgentIds.length > 0) {
|
||||
const messageLists = await Promise.all(
|
||||
phaseAgentIds.map((id) => messageRepo.findBySender('agent', id)),
|
||||
);
|
||||
const allMessages = messageLists
|
||||
.flat()
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
if (allMessages.length > 0) {
|
||||
lastMessage = allMessages[0].content.slice(0, 160);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical: message retrieval failure does not crash the dashboard
|
||||
}
|
||||
|
||||
blockedPhases.push({
|
||||
initiativeId: initiative.id,
|
||||
initiativeName: initiative.name,
|
||||
phaseId: phase.id,
|
||||
phaseName: phase.name,
|
||||
lastMessage,
|
||||
since: phase.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
blockedPhases.sort((a, b) => a.since.localeCompare(b.since));
|
||||
|
||||
return {
|
||||
waitingForInput,
|
||||
pendingReviewInitiatives,
|
||||
pendingReviewPhases,
|
||||
planningInitiatives,
|
||||
blockedPhases,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export interface ActiveArchitectAgent {
|
||||
initiativeId: string;
|
||||
mode: string;
|
||||
status: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const MODE_TO_STATE: Record<string, InitiativeActivityState> = {
|
||||
@@ -30,6 +31,18 @@ export function deriveInitiativeActivity(
|
||||
if (initiative.status === 'archived') {
|
||||
return { ...base, state: 'archived' };
|
||||
}
|
||||
|
||||
// Check for active conflict resolution agent — takes priority over pending_review
|
||||
// because the agent is actively working to resolve merge conflicts
|
||||
const conflictAgent = activeArchitectAgents?.find(
|
||||
a => a.initiativeId === initiative.id
|
||||
&& a.name?.startsWith('conflict-')
|
||||
&& (a.status === 'running' || a.status === 'waiting_for_input'),
|
||||
);
|
||||
if (conflictAgent) {
|
||||
return { ...base, state: 'resolving_conflict' };
|
||||
}
|
||||
|
||||
if (initiative.status === 'pending_review') {
|
||||
return { ...base, state: 'pending_review' };
|
||||
}
|
||||
@@ -41,6 +54,7 @@ export function deriveInitiativeActivity(
|
||||
// so architect agents (discuss/plan/detail/refine) surface activity
|
||||
const activeAgent = activeArchitectAgents?.find(
|
||||
a => a.initiativeId === initiative.id
|
||||
&& !a.name?.startsWith('conflict-')
|
||||
&& (a.status === 'running' || a.status === 'waiting_for_input'),
|
||||
);
|
||||
if (activeAgent) {
|
||||
|
||||
@@ -129,27 +129,42 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||
: await repo.findAll();
|
||||
}
|
||||
|
||||
// Fetch active architect agents once for all initiatives
|
||||
// Fetch active agents once for all initiatives (architect + conflict)
|
||||
const ARCHITECT_MODES = ['discuss', 'plan', 'detail', 'refine'];
|
||||
const allAgents = ctx.agentManager ? await ctx.agentManager.list() : [];
|
||||
const activeArchitectAgents = allAgents
|
||||
.filter(a =>
|
||||
ARCHITECT_MODES.includes(a.mode ?? '')
|
||||
(ARCHITECT_MODES.includes(a.mode ?? '') || a.name?.startsWith('conflict-'))
|
||||
&& (a.status === 'running' || a.status === 'waiting_for_input')
|
||||
&& !a.userDismissedAt,
|
||||
)
|
||||
.map(a => ({ initiativeId: a.initiativeId ?? '', mode: a.mode ?? '', status: a.status }));
|
||||
.map(a => ({ initiativeId: a.initiativeId ?? '', mode: a.mode ?? '', status: a.status, name: a.name }));
|
||||
|
||||
// Batch-fetch projects for all initiatives
|
||||
const projectRepo = ctx.projectRepository;
|
||||
const projectsByInitiativeId = new Map<string, Array<{ id: string; name: string }>>();
|
||||
if (projectRepo) {
|
||||
await Promise.all(initiatives.map(async (init) => {
|
||||
const projects = await projectRepo.findProjectsByInitiativeId(init.id);
|
||||
projectsByInitiativeId.set(init.id, projects.map(p => ({ id: p.id, name: p.name })));
|
||||
}));
|
||||
}
|
||||
|
||||
const addProjects = (init: typeof initiatives[0]) => ({
|
||||
projects: projectsByInitiativeId.get(init.id) ?? [],
|
||||
});
|
||||
|
||||
if (ctx.phaseRepository) {
|
||||
const phaseRepo = ctx.phaseRepository;
|
||||
return Promise.all(initiatives.map(async (init) => {
|
||||
const phases = await phaseRepo.findByInitiativeId(init.id);
|
||||
return { ...init, activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) };
|
||||
return { ...init, ...addProjects(init), activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) };
|
||||
}));
|
||||
}
|
||||
|
||||
return initiatives.map(init => ({
|
||||
...init,
|
||||
...addProjects(init),
|
||||
activity: deriveInitiativeActivity(init, [], activeArchitectAgents),
|
||||
}));
|
||||
}),
|
||||
@@ -473,6 +488,7 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||
initiativeId: input.initiativeId,
|
||||
baseBranch: initiative.branch,
|
||||
branchName: tempBranch,
|
||||
skipPromptExtras: true,
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
92
apps/server/trpc/routers/project.test.ts
Normal file
92
apps/server/trpc/routers/project.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Tests for registerProject CONFLICT error disambiguation.
|
||||
* Verifies that UNIQUE constraint failures on specific columns produce
|
||||
* column-specific error messages.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { router, publicProcedure, createCallerFactory } from '../trpc.js';
|
||||
import { projectProcedures } from './project.js';
|
||||
import type { TRPCContext } from '../context.js';
|
||||
import type { ProjectRepository } from '../../db/repositories/project-repository.js';
|
||||
|
||||
const testRouter = router({
|
||||
...projectProcedures(publicProcedure),
|
||||
});
|
||||
|
||||
const createCaller = createCallerFactory(testRouter);
|
||||
|
||||
function makeCtx(mockCreate: () => Promise<never>): TRPCContext {
|
||||
const projectRepository: ProjectRepository = {
|
||||
create: mockCreate as unknown as ProjectRepository['create'],
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByName: vi.fn().mockResolvedValue(null),
|
||||
findAll: vi.fn().mockResolvedValue([]),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
addProjectToInitiative: vi.fn(),
|
||||
removeProjectFromInitiative: vi.fn(),
|
||||
findProjectsByInitiativeId: vi.fn().mockResolvedValue([]),
|
||||
setInitiativeProjects: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
eventBus: {} as TRPCContext['eventBus'],
|
||||
serverStartedAt: null,
|
||||
processCount: 0,
|
||||
projectRepository,
|
||||
// No workspaceRoot — prevents cloneProject from running
|
||||
};
|
||||
}
|
||||
|
||||
const INPUT = { name: 'my-project', url: 'https://github.com/example/repo' };
|
||||
|
||||
describe('registerProject — CONFLICT error disambiguation', () => {
|
||||
it('throws CONFLICT with name-specific message on projects.name UNIQUE violation', async () => {
|
||||
const caller = createCaller(makeCtx(() => {
|
||||
throw new Error('UNIQUE constraint failed: projects.name');
|
||||
}));
|
||||
|
||||
const err = await caller.registerProject(INPUT).catch(e => e);
|
||||
expect(err).toBeInstanceOf(TRPCError);
|
||||
expect(err.code).toBe('CONFLICT');
|
||||
expect(err.message).toBe('A project with this name already exists');
|
||||
});
|
||||
|
||||
it('throws CONFLICT with url-specific message on projects.url UNIQUE violation', async () => {
|
||||
const caller = createCaller(makeCtx(() => {
|
||||
throw new Error('UNIQUE constraint failed: projects.url');
|
||||
}));
|
||||
|
||||
const err = await caller.registerProject(INPUT).catch(e => e);
|
||||
expect(err).toBeInstanceOf(TRPCError);
|
||||
expect(err.code).toBe('CONFLICT');
|
||||
expect(err.message).toBe('A project with this URL already exists');
|
||||
});
|
||||
|
||||
it('throws CONFLICT with fallback message on unknown UNIQUE constraint violation', async () => {
|
||||
const caller = createCaller(makeCtx(() => {
|
||||
throw new Error('UNIQUE constraint failed: projects.unknown_col');
|
||||
}));
|
||||
|
||||
const err = await caller.registerProject(INPUT).catch(e => e);
|
||||
expect(err).toBeInstanceOf(TRPCError);
|
||||
expect(err.code).toBe('CONFLICT');
|
||||
expect(err.message).toBe('A project with this name or URL already exists');
|
||||
});
|
||||
|
||||
it('rethrows non-UNIQUE errors without wrapping in a CONFLICT', async () => {
|
||||
const originalError = new Error('SQLITE_BUSY');
|
||||
const caller = createCaller(makeCtx(() => {
|
||||
throw originalError;
|
||||
}));
|
||||
|
||||
const err = await caller.registerProject(INPUT).catch(e => e);
|
||||
expect(err).toBeDefined();
|
||||
// Must not be surfaced as a CONFLICT — the catch block should re-throw as-is
|
||||
expect(err).not.toMatchObject({ code: 'CONFLICT' });
|
||||
// The original error message must be preserved somewhere
|
||||
expect(err.message).toContain('SQLITE_BUSY');
|
||||
});
|
||||
});
|
||||
@@ -30,11 +30,24 @@ export function projectProcedures(publicProcedure: ProcedureBuilder) {
|
||||
...(input.defaultBranch && { defaultBranch: input.defaultBranch }),
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
const msg = (error as Error).message ?? '';
|
||||
if (msg.includes('UNIQUE') || msg.includes('unique')) {
|
||||
if (msg.includes('projects.name') || (msg.includes('name') && !msg.includes('url'))) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'A project with this name already exists',
|
||||
});
|
||||
}
|
||||
if (msg.includes('projects.url') || msg.includes('url')) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'A project with this URL already exists',
|
||||
});
|
||||
}
|
||||
// fallback: neither column identifiable
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: `A project with that name or URL already exists`,
|
||||
message: 'A project with this name or URL already exists',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -70,6 +70,7 @@ export const ALL_EVENT_TYPES: DomainEventType[] = [
|
||||
'chat:session_closed',
|
||||
'initiative:pending_review',
|
||||
'initiative:review_approved',
|
||||
'initiative:changes_requested',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -102,6 +103,7 @@ export const TASK_EVENT_TYPES: DomainEventType[] = [
|
||||
'phase:merged',
|
||||
'initiative:pending_review',
|
||||
'initiative:review_approved',
|
||||
'initiative:changes_requested',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user