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:
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;
|
||||
|
||||
Reference in New Issue
Block a user