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:
Lukas May
2026-03-06 16:48:12 +01:00
parent da3218b530
commit 28521e1c20
100 changed files with 9054 additions and 973 deletions

View File

@@ -0,0 +1,327 @@
/**
* Agent Router Tests
*
* Tests for getAgent (exitCode, taskName, initiativeName),
* getAgentInputFiles, and getAgentPrompt procedures.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { appRouter, createCallerFactory } from '../index.js';
import type { TRPCContext } from '../context.js';
import type { EventBus } from '../../events/types.js';
const createCaller = createCallerFactory(appRouter);
function createMockEventBus(): EventBus {
return {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
};
}
function createTestContext(overrides: Partial<TRPCContext> = {}): TRPCContext {
return {
eventBus: createMockEventBus(),
serverStartedAt: new Date('2026-01-30T12:00:00Z'),
processCount: 0,
...overrides,
};
}
/** Minimal AgentInfo fixture matching the full interface */
function makeAgentInfo(overrides: Record<string, unknown> = {}) {
return {
id: 'agent-1',
name: 'test-agent',
taskId: null,
initiativeId: null,
sessionId: null,
worktreeId: 'test-agent',
status: 'stopped' as const,
mode: 'execute' as const,
provider: 'claude',
accountId: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
userDismissedAt: null,
exitCode: null,
prompt: null,
...overrides,
};
}
describe('getAgent', () => {
it('returns exitCode: 1 when agent has exitCode 1', async () => {
const mockManager = {
get: vi.fn().mockResolvedValue(makeAgentInfo({ exitCode: 1 })),
};
const ctx = createTestContext({ agentManager: mockManager as any });
const caller = createCaller(ctx);
const result = await caller.getAgent({ id: 'agent-1' });
expect(result.exitCode).toBe(1);
});
it('returns exitCode: null when agent has no exitCode', async () => {
const mockManager = {
get: vi.fn().mockResolvedValue(makeAgentInfo({ exitCode: null })),
};
const ctx = createTestContext({ agentManager: mockManager as any });
const caller = createCaller(ctx);
const result = await caller.getAgent({ id: 'agent-1' });
expect(result.exitCode).toBeNull();
});
it('returns taskName and initiativeName from repositories', async () => {
const mockManager = {
get: vi.fn().mockResolvedValue(makeAgentInfo({ taskId: 'task-1', initiativeId: 'init-1' })),
};
const mockTaskRepo = {
findById: vi.fn().mockResolvedValue({ id: 'task-1', name: 'My Task' }),
};
const mockInitiativeRepo = {
findById: vi.fn().mockResolvedValue({ id: 'init-1', name: 'My Initiative' }),
};
const ctx = createTestContext({
agentManager: mockManager as any,
taskRepository: mockTaskRepo as any,
initiativeRepository: mockInitiativeRepo as any,
});
const caller = createCaller(ctx);
const result = await caller.getAgent({ id: 'agent-1' });
expect(result.taskName).toBe('My Task');
expect(result.initiativeName).toBe('My Initiative');
});
it('returns taskName: null and initiativeName: null when agent has no taskId or initiativeId', async () => {
const mockManager = {
get: vi.fn().mockResolvedValue(makeAgentInfo({ taskId: null, initiativeId: null })),
};
const ctx = createTestContext({ agentManager: mockManager as any });
const caller = createCaller(ctx);
const result = await caller.getAgent({ id: 'agent-1' });
expect(result.taskName).toBeNull();
expect(result.initiativeName).toBeNull();
});
});
describe('getAgentInputFiles', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-test-'));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
function makeAgentManagerWithWorktree(worktreeId = 'test-worktree', agentName = 'test-agent') {
return {
get: vi.fn().mockResolvedValue(makeAgentInfo({ worktreeId, name: agentName })),
};
}
it('returns worktree_missing when worktree dir does not exist', async () => {
const nonExistentRoot = path.join(tmpDir, 'no-such-dir');
const mockManager = makeAgentManagerWithWorktree('test-worktree');
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: nonExistentRoot,
});
const caller = createCaller(ctx);
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
expect(result).toEqual({ files: [], reason: 'worktree_missing' });
});
it('returns input_dir_missing when worktree exists but .cw/input does not', async () => {
const worktreeId = 'test-worktree';
const worktreeRoot = path.join(tmpDir, 'agent-workdirs', worktreeId);
await fs.mkdir(worktreeRoot, { recursive: true });
const mockManager = makeAgentManagerWithWorktree(worktreeId);
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: tmpDir,
});
const caller = createCaller(ctx);
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
expect(result).toEqual({ files: [], reason: 'input_dir_missing' });
});
it('returns sorted file list with correct name, content, sizeBytes', async () => {
const worktreeId = 'test-worktree';
const inputDir = path.join(tmpDir, 'agent-workdirs', worktreeId, '.cw', 'input');
await fs.mkdir(inputDir, { recursive: true });
await fs.mkdir(path.join(inputDir, 'pages'), { recursive: true });
const manifestContent = '{"files": ["a"]}';
const fooContent = '# Foo\nHello world';
await fs.writeFile(path.join(inputDir, 'manifest.json'), manifestContent);
await fs.writeFile(path.join(inputDir, 'pages', 'foo.md'), fooContent);
const mockManager = makeAgentManagerWithWorktree(worktreeId);
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: tmpDir,
});
const caller = createCaller(ctx);
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
expect(result.reason).toBeUndefined();
expect(result.files).toHaveLength(2);
// Sorted alphabetically: manifest.json before pages/foo.md
expect(result.files[0].name).toBe('manifest.json');
expect(result.files[0].content).toBe(manifestContent);
expect(result.files[0].sizeBytes).toBe(Buffer.byteLength(manifestContent));
expect(result.files[1].name).toBe(path.join('pages', 'foo.md'));
expect(result.files[1].content).toBe(fooContent);
expect(result.files[1].sizeBytes).toBe(Buffer.byteLength(fooContent));
});
it('skips binary files (containing null byte)', async () => {
const worktreeId = 'test-worktree';
const inputDir = path.join(tmpDir, 'agent-workdirs', worktreeId, '.cw', 'input');
await fs.mkdir(inputDir, { recursive: true });
// Binary file with null byte
const binaryData = Buffer.from([0x89, 0x50, 0x00, 0x4e, 0x47]);
await fs.writeFile(path.join(inputDir, 'image.png'), binaryData);
// Text file should still be returned
await fs.writeFile(path.join(inputDir, 'text.txt'), 'hello');
const mockManager = makeAgentManagerWithWorktree(worktreeId);
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: tmpDir,
});
const caller = createCaller(ctx);
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
expect(result.files).toHaveLength(1);
expect(result.files[0].name).toBe('text.txt');
});
it('truncates files larger than 500 KB and preserves original sizeBytes', async () => {
const worktreeId = 'test-worktree';
const inputDir = path.join(tmpDir, 'agent-workdirs', worktreeId, '.cw', 'input');
await fs.mkdir(inputDir, { recursive: true });
const MAX_SIZE = 500 * 1024;
const largeContent = Buffer.alloc(MAX_SIZE + 100 * 1024, 'a'); // 600 KB
await fs.writeFile(path.join(inputDir, 'big.txt'), largeContent);
const mockManager = makeAgentManagerWithWorktree(worktreeId);
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: tmpDir,
});
const caller = createCaller(ctx);
const result = await caller.getAgentInputFiles({ id: 'agent-1' });
expect(result.files).toHaveLength(1);
expect(result.files[0].sizeBytes).toBe(largeContent.length);
expect(result.files[0].content).toContain('[truncated — file exceeds 500 KB]');
});
});
describe('getAgentPrompt', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-prompt-test-'));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it('returns prompt_not_written when PROMPT.md does not exist', async () => {
const mockManager = {
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: 'test-agent' })),
};
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: tmpDir,
});
const caller = createCaller(ctx);
const result = await caller.getAgentPrompt({ id: 'agent-1' });
expect(result).toEqual({ content: null, reason: 'prompt_not_written' });
});
it('returns prompt content when PROMPT.md exists', async () => {
const agentName = 'test-agent';
const promptDir = path.join(tmpDir, '.cw', 'agent-logs', agentName);
await fs.mkdir(promptDir, { recursive: true });
const promptContent = '# System\nHello';
await fs.writeFile(path.join(promptDir, 'PROMPT.md'), promptContent);
const mockManager = {
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName, prompt: null })),
};
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: tmpDir,
});
const caller = createCaller(ctx);
const result = await caller.getAgentPrompt({ id: 'agent-1' });
expect(result).toEqual({ content: promptContent });
});
it('returns prompt from DB when agent.prompt is set (no file needed)', async () => {
const dbPromptContent = '# DB Prompt\nThis is persisted in the database';
const mockManager = {
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: 'test-agent', prompt: dbPromptContent })),
};
// workspaceRoot has no PROMPT.md — but DB value takes precedence
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: tmpDir,
});
const caller = createCaller(ctx);
const result = await caller.getAgentPrompt({ id: 'agent-1' });
expect(result).toEqual({ content: dbPromptContent });
});
it('falls back to PROMPT.md when agent.prompt is null in DB', async () => {
const agentName = 'test-agent';
const promptDir = path.join(tmpDir, '.cw', 'agent-logs', agentName);
await fs.mkdir(promptDir, { recursive: true });
const fileContent = '# File Prompt\nThis is from the file (legacy)';
await fs.writeFile(path.join(promptDir, 'PROMPT.md'), fileContent);
const mockManager = {
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName, prompt: null })),
};
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: tmpDir,
});
const caller = createCaller(ctx);
const result = await caller.getAgentPrompt({ id: 'agent-1' });
expect(result).toEqual({ content: fileContent });
});
});

View File

@@ -5,11 +5,13 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { tracked, type TrackedEnvelope } from '@trpc/server';
import path from 'path';
import fs from 'fs/promises';
import type { ProcedureBuilder } from '../trpc.js';
import type { TRPCContext } from '../context.js';
import type { AgentInfo, AgentResult, PendingQuestions } from '../../agent/types.js';
import type { AgentOutputEvent } from '../../events/types.js';
import { requireAgentManager, requireLogChunkRepository } from './_helpers.js';
import { requireAgentManager, requireLogChunkRepository, requireTaskRepository, requireInitiativeRepository } from './_helpers.js';
export const spawnAgentInputSchema = z.object({
name: z.string().min(1).optional(),
@@ -120,7 +122,23 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
getAgent: publicProcedure
.input(agentIdentifierSchema)
.query(async ({ ctx, input }) => {
return resolveAgent(ctx, input);
const agent = await resolveAgent(ctx, input);
let taskName: string | null = null;
let initiativeName: string | null = null;
if (agent.taskId) {
const taskRepo = requireTaskRepository(ctx);
const task = await taskRepo.findById(agent.taskId);
taskName = task?.name ?? null;
}
if (agent.initiativeId) {
const initiativeRepo = requireInitiativeRepository(ctx);
const initiative = await initiativeRepo.findById(agent.initiativeId);
initiativeName = initiative?.name ?? null;
}
return { ...agent, taskName, initiativeName };
}),
getAgentByName: publicProcedure
@@ -184,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) };
}),
};
}

View File

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

View File

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

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

View File

@@ -9,6 +9,7 @@ export interface ActiveArchitectAgent {
initiativeId: string;
mode: string;
status: string;
name?: string;
}
const MODE_TO_STATE: Record<string, InitiativeActivityState> = {
@@ -30,6 +31,18 @@ export function deriveInitiativeActivity(
if (initiative.status === 'archived') {
return { ...base, state: 'archived' };
}
// Check for active conflict resolution agent — takes priority over pending_review
// because the agent is actively working to resolve merge conflicts
const conflictAgent = activeArchitectAgents?.find(
a => a.initiativeId === initiative.id
&& a.name?.startsWith('conflict-')
&& (a.status === 'running' || a.status === 'waiting_for_input'),
);
if (conflictAgent) {
return { ...base, state: 'resolving_conflict' };
}
if (initiative.status === 'pending_review') {
return { ...base, state: 'pending_review' };
}
@@ -41,6 +54,7 @@ export function deriveInitiativeActivity(
// so architect agents (discuss/plan/detail/refine) surface activity
const activeAgent = activeArchitectAgents?.find(
a => a.initiativeId === initiative.id
&& !a.name?.startsWith('conflict-')
&& (a.status === 'running' || a.status === 'waiting_for_input'),
);
if (activeAgent) {

View File

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

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

View File

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