refactor: Restructure monorepo to apps/server/ and apps/web/ layout

Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt
standard monorepo conventions (apps/ for runnable apps, packages/
for reusable libraries). Update all config files, shared package
imports, test fixtures, and documentation to reflect new paths.

Key fixes:
- Update workspace config to ["apps/*", "packages/*"]
- Update tsconfig.json rootDir/include for apps/server/
- Add apps/web/** to vitest exclude list
- Update drizzle.config.ts schema path
- Fix ensure-schema.ts migration path detection (3 levels up in dev,
  2 levels up in dist)
- Fix tests/integration/cli-server.test.ts import paths
- Update packages/shared imports to apps/server/ paths
- Update all docs/ files with new paths
This commit is contained in:
Lukas May
2026-03-03 11:22:53 +01:00
parent 8c38d958ce
commit 34578d39c6
535 changed files with 75452 additions and 687 deletions

139
apps/server/trpc/context.ts Normal file
View File

@@ -0,0 +1,139 @@
/**
* tRPC Context
*
* Defines the context available to all tRPC procedures.
* Context is injected into each procedure call.
*/
import type { EventBus, DomainEvent } from '../events/types.js';
import type { AgentManager } from '../agent/types.js';
import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { MessageRepository } from '../db/repositories/message-repository.js';
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { PageRepository } from '../db/repositories/page-repository.js';
import type { ProjectRepository } from '../db/repositories/project-repository.js';
import type { AccountRepository } from '../db/repositories/account-repository.js';
import type { ChangeSetRepository } from '../db/repositories/change-set-repository.js';
import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js';
import type { ConversationRepository } from '../db/repositories/conversation-repository.js';
import type { AccountCredentialManager } from '../agent/credentials/types.js';
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
import type { CoordinationManager } from '../coordination/types.js';
import type { BranchManager } from '../git/branch-manager.js';
import type { ExecutionOrchestrator } from '../execution/orchestrator.js';
import type { PreviewManager } from '../preview/index.js';
// Re-export for convenience
export type { EventBus, DomainEvent };
/**
* Context available to all tRPC procedures.
*/
export interface TRPCContext {
/** Event bus for inter-module communication */
eventBus: EventBus;
/** When the server started (null if not yet started) */
serverStartedAt: Date | null;
/** Number of managed processes */
processCount: number;
/** Agent manager for agent lifecycle operations (optional until server wiring complete) */
agentManager?: AgentManager;
/** Task repository for task CRUD operations (optional until server wiring complete) */
taskRepository?: TaskRepository;
/** Message repository for agent-user communication (optional until server wiring complete) */
messageRepository?: MessageRepository;
/** Dispatch manager for task queue operations (optional until server wiring complete) */
dispatchManager?: DispatchManager;
/** Coordination manager for merge queue operations (optional until server wiring complete) */
coordinationManager?: CoordinationManager;
/** Initiative repository for initiative CRUD operations (optional until server wiring complete) */
initiativeRepository?: InitiativeRepository;
/** Phase repository for phase CRUD operations (optional until server wiring complete) */
phaseRepository?: PhaseRepository;
/** Phase dispatch manager for phase queue operations (optional until server wiring complete) */
phaseDispatchManager?: PhaseDispatchManager;
/** Page repository for page CRUD operations (optional until server wiring complete) */
pageRepository?: PageRepository;
/** Project repository for project CRUD and initiative-project junction operations */
projectRepository?: ProjectRepository;
/** Account repository for account CRUD and load balancing */
accountRepository?: AccountRepository;
/** Change set repository for agent change set operations */
changeSetRepository?: ChangeSetRepository;
/** Log chunk repository for agent output persistence */
logChunkRepository?: LogChunkRepository;
/** Credential manager for account OAuth token management */
credentialManager?: AccountCredentialManager;
/** Branch manager for git branch operations */
branchManager?: BranchManager;
/** Execution orchestrator for phase merge/review workflow */
executionOrchestrator?: ExecutionOrchestrator;
/** Preview manager for Docker-based preview deployments */
previewManager?: PreviewManager;
/** Conversation repository for inter-agent communication */
conversationRepository?: ConversationRepository;
/** Absolute path to the workspace root (.cwrc directory) */
workspaceRoot?: string;
}
/**
* Options for creating the tRPC context.
*/
export interface CreateContextOptions {
eventBus: EventBus;
serverStartedAt: Date | null;
processCount: number;
agentManager?: AgentManager;
taskRepository?: TaskRepository;
messageRepository?: MessageRepository;
dispatchManager?: DispatchManager;
coordinationManager?: CoordinationManager;
initiativeRepository?: InitiativeRepository;
phaseRepository?: PhaseRepository;
phaseDispatchManager?: PhaseDispatchManager;
pageRepository?: PageRepository;
projectRepository?: ProjectRepository;
accountRepository?: AccountRepository;
changeSetRepository?: ChangeSetRepository;
logChunkRepository?: LogChunkRepository;
credentialManager?: AccountCredentialManager;
branchManager?: BranchManager;
executionOrchestrator?: ExecutionOrchestrator;
previewManager?: PreviewManager;
conversationRepository?: ConversationRepository;
workspaceRoot?: string;
}
/**
* Creates the tRPC context for procedure calls.
*
* @param options - Context creation options
* @returns The tRPC context
*/
export function createContext(options: CreateContextOptions): TRPCContext {
return {
eventBus: options.eventBus,
serverStartedAt: options.serverStartedAt,
processCount: options.processCount,
agentManager: options.agentManager,
taskRepository: options.taskRepository,
messageRepository: options.messageRepository,
dispatchManager: options.dispatchManager,
coordinationManager: options.coordinationManager,
initiativeRepository: options.initiativeRepository,
phaseRepository: options.phaseRepository,
phaseDispatchManager: options.phaseDispatchManager,
pageRepository: options.pageRepository,
projectRepository: options.projectRepository,
accountRepository: options.accountRepository,
changeSetRepository: options.changeSetRepository,
logChunkRepository: options.logChunkRepository,
credentialManager: options.credentialManager,
branchManager: options.branchManager,
executionOrchestrator: options.executionOrchestrator,
previewManager: options.previewManager,
conversationRepository: options.conversationRepository,
workspaceRoot: options.workspaceRoot,
};
}

31
apps/server/trpc/index.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* tRPC Module
*
* Type-safe RPC layer for CLI-server communication.
* Same interface for CLI and future WebUI clients.
*/
// Re-export router components
export {
router,
publicProcedure,
middleware,
createCallerFactory,
appRouter,
healthResponseSchema,
statusResponseSchema,
processInfoSchema,
} from './router.js';
// Export types
export type {
AppRouter,
HealthResponse,
StatusResponse,
ProcessInfo,
} from './router.js';
// Re-export context
export { createContext } from './context.js';
export type { TRPCContext, CreateContextOptions } from './context.js';
export type { EventBus, DomainEvent } from './context.js';

View File

@@ -0,0 +1,222 @@
/**
* tRPC Router Tests
*
* Tests for the tRPC procedures using createCallerFactory.
* Tests verify correct response shapes and Zod validation.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
appRouter,
createCallerFactory,
healthResponseSchema,
statusResponseSchema,
} from './index.js';
import type { TRPCContext } from './context.js';
import type { EventBus } from '../events/types.js';
// Create caller factory for the app router
const createCaller = createCallerFactory(appRouter);
/**
* Create a mock EventBus for testing.
*/
function createMockEventBus(): EventBus {
return {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
};
}
/**
* Create a test context with configurable options.
*/
function createTestContext(overrides: Partial<TRPCContext> = {}): TRPCContext {
return {
eventBus: createMockEventBus(),
serverStartedAt: new Date('2026-01-30T12:00:00Z'),
processCount: 0,
...overrides,
};
}
describe('tRPC Router', () => {
let caller: ReturnType<typeof createCaller>;
let ctx: TRPCContext;
beforeEach(() => {
ctx = createTestContext();
caller = createCaller(ctx);
});
describe('health procedure', () => {
it('should return correct shape', async () => {
const result = await caller.health();
expect(result).toEqual({
status: 'ok',
uptime: expect.any(Number),
processCount: 0,
});
});
it('should validate against Zod schema', async () => {
const result = await caller.health();
const parsed = healthResponseSchema.safeParse(result);
expect(parsed.success).toBe(true);
});
it('should calculate uptime from serverStartedAt', async () => {
// Set serverStartedAt to 60 seconds ago
const sixtySecondsAgo = new Date(Date.now() - 60000);
ctx = createTestContext({ serverStartedAt: sixtySecondsAgo });
caller = createCaller(ctx);
const result = await caller.health();
// Uptime should be approximately 60 seconds (allow 1 second tolerance)
expect(result.uptime).toBeGreaterThanOrEqual(59);
expect(result.uptime).toBeLessThanOrEqual(61);
});
it('should return uptime 0 when serverStartedAt is null', async () => {
ctx = createTestContext({ serverStartedAt: null });
caller = createCaller(ctx);
const result = await caller.health();
expect(result.uptime).toBe(0);
});
it('should reflect processCount from context', async () => {
ctx = createTestContext({ processCount: 5 });
caller = createCaller(ctx);
const result = await caller.health();
expect(result.processCount).toBe(5);
});
});
describe('status procedure', () => {
it('should return correct shape', async () => {
const result = await caller.status();
expect(result).toEqual({
server: {
startedAt: expect.any(String),
uptime: expect.any(Number),
pid: expect.any(Number),
},
processes: [],
});
});
it('should validate against Zod schema', async () => {
const result = await caller.status();
const parsed = statusResponseSchema.safeParse(result);
expect(parsed.success).toBe(true);
});
it('should include server startedAt as ISO string', async () => {
const result = await caller.status();
expect(result.server.startedAt).toBe('2026-01-30T12:00:00.000Z');
});
it('should return empty startedAt when serverStartedAt is null', async () => {
ctx = createTestContext({ serverStartedAt: null });
caller = createCaller(ctx);
const result = await caller.status();
expect(result.server.startedAt).toBe('');
});
it('should include actual process.pid', async () => {
const result = await caller.status();
expect(result.server.pid).toBe(process.pid);
});
it('should calculate uptime correctly', async () => {
const thirtySecondsAgo = new Date(Date.now() - 30000);
ctx = createTestContext({ serverStartedAt: thirtySecondsAgo });
caller = createCaller(ctx);
const result = await caller.status();
expect(result.server.uptime).toBeGreaterThanOrEqual(29);
expect(result.server.uptime).toBeLessThanOrEqual(31);
});
it('should return empty processes array', async () => {
const result = await caller.status();
expect(result.processes).toEqual([]);
});
});
describe('Zod schema validation', () => {
it('healthResponseSchema should reject invalid status', () => {
const invalid = {
status: 'not-ok',
uptime: 100,
processCount: 0,
};
const parsed = healthResponseSchema.safeParse(invalid);
expect(parsed.success).toBe(false);
});
it('healthResponseSchema should reject negative uptime', () => {
const invalid = {
status: 'ok',
uptime: -1,
processCount: 0,
};
const parsed = healthResponseSchema.safeParse(invalid);
expect(parsed.success).toBe(false);
});
it('statusResponseSchema should reject missing server fields', () => {
const invalid = {
server: {
startedAt: '2026-01-30T12:00:00Z',
// missing uptime and pid
},
processes: [],
};
const parsed = statusResponseSchema.safeParse(invalid);
expect(parsed.success).toBe(false);
});
it('statusResponseSchema should accept valid process info', () => {
const valid = {
server: {
startedAt: '2026-01-30T12:00:00Z',
uptime: 100,
pid: 12345,
},
processes: [
{
id: 'proc-1',
pid: 54321,
command: 'node server.js',
status: 'running',
startedAt: '2026-01-30T12:00:00Z',
},
],
};
const parsed = statusResponseSchema.safeParse(valid);
expect(parsed.success).toBe(true);
});
});
});

View File

@@ -0,0 +1,66 @@
/**
* tRPC Router — Merge Point
*
* Combines all domain routers into a single application router.
* Each domain file exports a builder function that returns procedure records.
*/
import { router, publicProcedure } from './trpc.js';
import { systemProcedures } from './routers/system.js';
import { agentProcedures } from './routers/agent.js';
import { taskProcedures } from './routers/task.js';
import { messageProcedures } from './routers/message.js';
import { dispatchProcedures } from './routers/dispatch.js';
import { coordinationProcedures } from './routers/coordination.js';
import { initiativeProcedures } from './routers/initiative.js';
import { phaseProcedures } from './routers/phase.js';
import { phaseDispatchProcedures } from './routers/phase-dispatch.js';
import { architectProcedures } from './routers/architect.js';
import { projectProcedures } from './routers/project.js';
import { pageProcedures } from './routers/page.js';
import { accountProcedures } from './routers/account.js';
import { changeSetProcedures } from './routers/change-set.js';
import { subscriptionProcedures } from './routers/subscription.js';
import { previewProcedures } from './routers/preview.js';
import { conversationProcedures } from './routers/conversation.js';
// Re-export tRPC primitives (preserves existing import paths)
export { router, publicProcedure, middleware, createCallerFactory } from './trpc.js';
// Re-export schemas and types from domain routers
export {
healthResponseSchema,
processInfoSchema,
statusResponseSchema,
} from './routers/system.js';
export type { HealthResponse, StatusResponse, ProcessInfo } from './routers/system.js';
export {
spawnAgentInputSchema,
agentIdentifierSchema,
resumeAgentInputSchema,
} from './routers/agent.js';
export type { SpawnAgentInput, AgentIdentifier, ResumeAgentInput } from './routers/agent.js';
// Application router
export const appRouter = router({
...systemProcedures(publicProcedure),
...agentProcedures(publicProcedure),
...taskProcedures(publicProcedure),
...messageProcedures(publicProcedure),
...dispatchProcedures(publicProcedure),
...coordinationProcedures(publicProcedure),
...initiativeProcedures(publicProcedure),
...phaseProcedures(publicProcedure),
...phaseDispatchProcedures(publicProcedure),
...architectProcedures(publicProcedure),
...projectProcedures(publicProcedure),
...pageProcedures(publicProcedure),
...accountProcedures(publicProcedure),
...changeSetProcedures(publicProcedure),
...subscriptionProcedures(publicProcedure),
...previewProcedures(publicProcedure),
...conversationProcedures(publicProcedure),
});
export type AppRouter = typeof appRouter;

View File

@@ -0,0 +1,194 @@
/**
* Router Helpers
*
* Shared require*() helpers that validate context dependencies
* and throw TRPCError when a required dependency is missing.
*/
import { TRPCError } from '@trpc/server';
import type { TRPCContext } from '../context.js';
import type { TaskRepository } from '../../db/repositories/task-repository.js';
import type { MessageRepository } from '../../db/repositories/message-repository.js';
import type { InitiativeRepository } from '../../db/repositories/initiative-repository.js';
import type { PhaseRepository } from '../../db/repositories/phase-repository.js';
import type { PageRepository } from '../../db/repositories/page-repository.js';
import type { ProjectRepository } from '../../db/repositories/project-repository.js';
import type { AccountRepository } from '../../db/repositories/account-repository.js';
import type { ChangeSetRepository } from '../../db/repositories/change-set-repository.js';
import type { LogChunkRepository } from '../../db/repositories/log-chunk-repository.js';
import type { ConversationRepository } from '../../db/repositories/conversation-repository.js';
import type { DispatchManager, PhaseDispatchManager } from '../../dispatch/types.js';
import type { CoordinationManager } from '../../coordination/types.js';
import type { BranchManager } from '../../git/branch-manager.js';
import type { ExecutionOrchestrator } from '../../execution/orchestrator.js';
import type { PreviewManager } from '../../preview/index.js';
export function requireAgentManager(ctx: TRPCContext) {
if (!ctx.agentManager) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Agent manager not available',
});
}
return ctx.agentManager;
}
export function requireTaskRepository(ctx: TRPCContext): TaskRepository {
if (!ctx.taskRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Task repository not available',
});
}
return ctx.taskRepository;
}
export function requireMessageRepository(ctx: TRPCContext): MessageRepository {
if (!ctx.messageRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Message repository not available',
});
}
return ctx.messageRepository;
}
export function requireDispatchManager(ctx: TRPCContext): DispatchManager {
if (!ctx.dispatchManager) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Dispatch manager not available',
});
}
return ctx.dispatchManager;
}
export function requireCoordinationManager(ctx: TRPCContext): CoordinationManager {
if (!ctx.coordinationManager) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Coordination manager not available',
});
}
return ctx.coordinationManager;
}
export function requireInitiativeRepository(ctx: TRPCContext): InitiativeRepository {
if (!ctx.initiativeRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Initiative repository not available',
});
}
return ctx.initiativeRepository;
}
export function requirePhaseRepository(ctx: TRPCContext): PhaseRepository {
if (!ctx.phaseRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Phase repository not available',
});
}
return ctx.phaseRepository;
}
export function requirePhaseDispatchManager(ctx: TRPCContext): PhaseDispatchManager {
if (!ctx.phaseDispatchManager) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Phase dispatch manager not available',
});
}
return ctx.phaseDispatchManager;
}
export function requirePageRepository(ctx: TRPCContext): PageRepository {
if (!ctx.pageRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Page repository not available',
});
}
return ctx.pageRepository;
}
export function requireProjectRepository(ctx: TRPCContext): ProjectRepository {
if (!ctx.projectRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Project repository not available',
});
}
return ctx.projectRepository;
}
export function requireAccountRepository(ctx: TRPCContext): AccountRepository {
if (!ctx.accountRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Account repository not available',
});
}
return ctx.accountRepository;
}
export function requireChangeSetRepository(ctx: TRPCContext): ChangeSetRepository {
if (!ctx.changeSetRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Change set repository not available',
});
}
return ctx.changeSetRepository;
}
export function requireLogChunkRepository(ctx: TRPCContext): LogChunkRepository {
if (!ctx.logChunkRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Log chunk repository not available',
});
}
return ctx.logChunkRepository;
}
export function requireBranchManager(ctx: TRPCContext): BranchManager {
if (!ctx.branchManager) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Branch manager not available',
});
}
return ctx.branchManager;
}
export function requireExecutionOrchestrator(ctx: TRPCContext): ExecutionOrchestrator {
if (!ctx.executionOrchestrator) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Execution orchestrator not available',
});
}
return ctx.executionOrchestrator;
}
export function requirePreviewManager(ctx: TRPCContext): PreviewManager {
if (!ctx.previewManager) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Preview manager not available',
});
}
return ctx.previewManager;
}
export function requireConversationRepository(ctx: TRPCContext): ConversationRepository {
if (!ctx.conversationRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Conversation repository not available',
});
}
return ctx.conversationRepository;
}

View File

@@ -0,0 +1,76 @@
/**
* Account Router — list, add, remove, refresh, update auth, mark exhausted, providers
*/
import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js';
import { requireAccountRepository } from './_helpers.js';
import { listProviders as listProviderNames } from '../../agent/providers/registry.js';
export function accountProcedures(publicProcedure: ProcedureBuilder) {
return {
listAccounts: publicProcedure
.query(async ({ ctx }) => {
const repo = requireAccountRepository(ctx);
return repo.findAll();
}),
addAccount: publicProcedure
.input(z.object({
email: z.string().min(1),
provider: z.string().default('claude'),
configJson: z.string().optional(),
credentials: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requireAccountRepository(ctx);
return repo.create({
email: input.email,
provider: input.provider,
configJson: input.configJson,
credentials: input.credentials,
});
}),
removeAccount: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const repo = requireAccountRepository(ctx);
await repo.delete(input.id);
return { success: true };
}),
refreshAccounts: publicProcedure
.mutation(async ({ ctx }) => {
const repo = requireAccountRepository(ctx);
const cleared = await repo.clearExpiredExhaustion();
return { cleared };
}),
updateAccountAuth: publicProcedure
.input(z.object({
id: z.string().min(1),
configJson: z.string(),
credentials: z.string(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requireAccountRepository(ctx);
return repo.updateAccountAuth(input.id, input.configJson, input.credentials);
}),
markAccountExhausted: publicProcedure
.input(z.object({
id: z.string().min(1),
until: z.string().datetime(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requireAccountRepository(ctx);
return repo.markExhausted(input.id, new Date(input.until));
}),
listProviderNames: publicProcedure
.query(() => {
return listProviderNames();
}),
};
}

View File

@@ -0,0 +1,248 @@
/**
* Agent Router — spawn, stop, delete, list, get, resume, result, questions, output
*/
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { tracked, type TrackedEnvelope } from '@trpc/server';
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';
export const spawnAgentInputSchema = z.object({
name: z.string().min(1).optional(),
taskId: z.string().min(1),
prompt: z.string().min(1),
cwd: z.string().optional(),
mode: z.enum(['execute', 'discuss', 'plan', 'detail', 'refine']).optional(),
provider: z.string().optional(),
initiativeId: z.string().min(1).optional(),
});
export type SpawnAgentInput = z.infer<typeof spawnAgentInputSchema>;
export const agentIdentifierSchema = z.object({
name: z.string().optional(),
id: z.string().optional(),
}).refine(data => data.name || data.id, {
message: 'Either name or id must be provided',
});
export type AgentIdentifier = z.infer<typeof agentIdentifierSchema>;
export const resumeAgentInputSchema = z.object({
name: z.string().optional(),
id: z.string().optional(),
answers: z.record(z.string(), z.string()),
}).refine(data => data.name || data.id, {
message: 'Either name or id must be provided',
});
export type ResumeAgentInput = z.infer<typeof resumeAgentInputSchema>;
async function resolveAgent(
ctx: TRPCContext,
input: { name?: string; id?: string }
): Promise<AgentInfo> {
if (!ctx.agentManager) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Agent manager not available',
});
}
const agent = input.name
? await ctx.agentManager.getByName(input.name)
: await ctx.agentManager.get(input.id!);
if (!agent) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Agent '${input.name ?? input.id}' not found`,
});
}
return agent;
}
export function agentProcedures(publicProcedure: ProcedureBuilder) {
return {
spawnAgent: publicProcedure
.input(spawnAgentInputSchema)
.mutation(async ({ ctx, input }) => {
const agentManager = requireAgentManager(ctx);
return agentManager.spawn({
name: input.name,
taskId: input.taskId,
prompt: input.prompt,
cwd: input.cwd,
mode: input.mode,
provider: input.provider,
initiativeId: input.initiativeId,
});
}),
stopAgent: publicProcedure
.input(agentIdentifierSchema)
.mutation(async ({ ctx, input }) => {
const agentManager = requireAgentManager(ctx);
const agent = await resolveAgent(ctx, input);
await agentManager.stop(agent.id);
return { success: true, name: agent.name };
}),
deleteAgent: publicProcedure
.input(agentIdentifierSchema)
.mutation(async ({ ctx, input }) => {
const agentManager = requireAgentManager(ctx);
const agent = await resolveAgent(ctx, input);
await agentManager.delete(agent.id);
return { success: true, name: agent.name };
}),
dismissAgent: publicProcedure
.input(agentIdentifierSchema)
.mutation(async ({ ctx, input }) => {
const agentManager = requireAgentManager(ctx);
const agent = await resolveAgent(ctx, input);
await agentManager.dismiss(agent.id);
return { success: true, name: agent.name };
}),
listAgents: publicProcedure
.query(async ({ ctx }) => {
const agentManager = requireAgentManager(ctx);
return agentManager.list();
}),
getAgent: publicProcedure
.input(agentIdentifierSchema)
.query(async ({ ctx, input }) => {
return resolveAgent(ctx, input);
}),
getAgentByName: publicProcedure
.input(z.object({ name: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const agentManager = requireAgentManager(ctx);
return agentManager.getByName(input.name);
}),
resumeAgent: publicProcedure
.input(resumeAgentInputSchema)
.mutation(async ({ ctx, input }) => {
const agentManager = requireAgentManager(ctx);
const agent = await resolveAgent(ctx, input);
await agentManager.resume(agent.id, input.answers);
return { success: true, name: agent.name };
}),
getAgentResult: publicProcedure
.input(agentIdentifierSchema)
.query(async ({ ctx, input }): Promise<AgentResult | null> => {
const agentManager = requireAgentManager(ctx);
const agent = await resolveAgent(ctx, input);
return agentManager.getResult(agent.id);
}),
getAgentQuestions: publicProcedure
.input(agentIdentifierSchema)
.query(async ({ ctx, input }): Promise<PendingQuestions | null> => {
const agentManager = requireAgentManager(ctx);
const agent = await resolveAgent(ctx, input);
return agentManager.getPendingQuestions(agent.id);
}),
listWaitingAgents: publicProcedure
.query(async ({ ctx }) => {
const agentManager = requireAgentManager(ctx);
const allAgents = await agentManager.list();
return allAgents.filter(agent => agent.status === 'waiting_for_input');
}),
getActiveRefineAgent: publicProcedure
.input(z.object({ initiativeId: z.string().min(1) }))
.query(async ({ ctx, input }): Promise<AgentInfo | null> => {
const agentManager = requireAgentManager(ctx);
const allAgents = await agentManager.list();
const candidates = allAgents
.filter(
(a) =>
a.mode === 'refine' &&
a.initiativeId === input.initiativeId &&
['running', 'waiting_for_input', 'idle', 'crashed'].includes(a.status) &&
!a.userDismissedAt,
)
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
return candidates[0] ?? null;
}),
getAgentOutput: publicProcedure
.input(agentIdentifierSchema)
.query(async ({ ctx, input }): Promise<string> => {
const agent = await resolveAgent(ctx, input);
const logChunkRepo = requireLogChunkRepository(ctx);
const chunks = await logChunkRepo.findByAgentId(agent.id);
return chunks.map(c => c.content).join('');
}),
onAgentOutput: publicProcedure
.input(z.object({ agentId: z.string().min(1) }))
.subscription(async function* (opts): AsyncGenerator<TrackedEnvelope<{ agentId: string; data: string }>> {
const { agentId } = opts.input;
const signal = opts.signal ?? new AbortController().signal;
const eventBus = opts.ctx.eventBus;
let eventCounter = 0;
const queue: string[] = [];
let resolve: (() => void) | null = null;
const handler = (event: AgentOutputEvent) => {
if (event.payload.agentId !== agentId) return;
queue.push(event.payload.data);
if (resolve) {
const r = resolve;
resolve = null;
r();
}
};
eventBus.on('agent:output', handler);
const cleanup = () => {
eventBus.off('agent:output', handler);
if (resolve) {
const r = resolve;
resolve = null;
r();
}
};
signal.addEventListener('abort', cleanup, { once: true });
try {
while (!signal.aborted) {
while (queue.length > 0) {
const data = queue.shift()!;
const id = `${agentId}-live-${eventCounter++}`;
yield tracked(id, { agentId, data });
}
if (!signal.aborted) {
await new Promise<void>((r) => {
resolve = r;
});
}
}
} finally {
cleanup();
}
}),
};
}

View File

@@ -0,0 +1,365 @@
/**
* Architect Router — discuss, plan, refine, detail spawn procedures
*/
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js';
import {
requireAgentManager,
requireInitiativeRepository,
requirePhaseRepository,
requirePageRepository,
requireTaskRepository,
} from './_helpers.js';
import {
buildDiscussPrompt,
buildPlanPrompt,
buildRefinePrompt,
buildDetailPrompt,
} from '../../agent/prompts/index.js';
import { isPlanningCategory } from '../../git/branch-naming.js';
import type { PhaseRepository } from '../../db/repositories/phase-repository.js';
import type { TaskRepository } from '../../db/repositories/task-repository.js';
import type { PageRepository } from '../../db/repositories/page-repository.js';
import type { Phase, Task } from '../../db/schema.js';
import type { PageForSerialization } from '../../agent/content-serializer.js';
async function gatherInitiativeContext(
phaseRepo: PhaseRepository | undefined,
taskRepo: TaskRepository | undefined,
pageRepo: PageRepository | undefined,
initiativeId: string,
): Promise<{
phases: Array<Phase & { dependsOn?: string[] }>;
tasks: Task[];
pages: PageForSerialization[];
}> {
const [rawPhases, deps, initiativeTasks, pages] = await Promise.all([
phaseRepo?.findByInitiativeId(initiativeId) ?? [],
phaseRepo?.findDependenciesByInitiativeId(initiativeId) ?? [],
taskRepo?.findByInitiativeId(initiativeId) ?? [],
pageRepo?.findByInitiativeId(initiativeId) ?? [],
]);
// Merge dependencies into each phase as a dependsOn array
const depsByPhase = new Map<string, string[]>();
for (const dep of deps) {
const arr = depsByPhase.get(dep.phaseId) ?? [];
arr.push(dep.dependsOnPhaseId);
depsByPhase.set(dep.phaseId, arr);
}
const phases = rawPhases.map((ph) => ({
...ph,
dependsOn: depsByPhase.get(ph.id) ?? [],
}));
// Collect tasks from all phases (some tasks only have phaseId, not initiativeId)
const taskIds = new Set(initiativeTasks.map((t) => t.id));
const allTasks = [...initiativeTasks];
if (taskRepo) {
for (const ph of rawPhases) {
const phaseTasks = await taskRepo.findByPhaseId(ph.id);
for (const t of phaseTasks) {
if (!taskIds.has(t.id)) {
taskIds.add(t.id);
allTasks.push(t);
}
}
}
}
// Only include implementation tasks in agent context — planning tasks are irrelevant noise
const implementationTasks = allTasks.filter(t => !isPlanningCategory(t.category));
return { phases, tasks: implementationTasks, pages };
}
export function architectProcedures(publicProcedure: ProcedureBuilder) {
return {
spawnArchitectDiscuss: publicProcedure
.input(z.object({
name: z.string().min(1).optional(),
initiativeId: z.string().min(1),
context: z.string().optional(),
provider: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const agentManager = requireAgentManager(ctx);
const initiativeRepo = requireInitiativeRepository(ctx);
const taskRepo = requireTaskRepository(ctx);
const initiative = await initiativeRepo.findById(input.initiativeId);
if (!initiative) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Initiative '${input.initiativeId}' not found`,
});
}
const task = await taskRepo.create({
initiativeId: input.initiativeId,
name: `Discuss: ${initiative.name}`,
description: input.context ?? 'Gather context and requirements for initiative',
category: 'discuss',
status: 'in_progress',
});
const prompt = buildDiscussPrompt();
return agentManager.spawn({
name: input.name,
taskId: task.id,
prompt,
mode: 'discuss',
provider: input.provider,
initiativeId: input.initiativeId,
inputContext: { initiative },
});
}),
spawnArchitectPlan: publicProcedure
.input(z.object({
name: z.string().min(1).optional(),
initiativeId: z.string().min(1),
contextSummary: z.string().optional(),
provider: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const agentManager = requireAgentManager(ctx);
const initiativeRepo = requireInitiativeRepository(ctx);
const taskRepo = requireTaskRepository(ctx);
const initiative = await initiativeRepo.findById(input.initiativeId);
if (!initiative) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Initiative '${input.initiativeId}' not found`,
});
}
// Auto-dismiss stale plan agents
const allAgents = await agentManager.list();
const staleAgents = allAgents.filter(
(a) =>
a.mode === 'plan' &&
a.initiativeId === input.initiativeId &&
['crashed', 'idle'].includes(a.status) &&
!a.userDismissedAt,
);
for (const stale of staleAgents) {
await agentManager.dismiss(stale.id);
}
// Reject if a plan agent is already active for this initiative
const activePlanAgents = allAgents.filter(
(a) =>
a.mode === 'plan' &&
a.initiativeId === input.initiativeId &&
['running', 'waiting_for_input'].includes(a.status),
);
if (activePlanAgents.length > 0) {
throw new TRPCError({
code: 'CONFLICT',
message: 'A plan agent is already running for this initiative',
});
}
const task = await taskRepo.create({
initiativeId: input.initiativeId,
name: `Plan: ${initiative.name}`,
description: 'Plan initiative into phases',
category: 'plan',
status: 'in_progress',
});
const context = await gatherInitiativeContext(ctx.phaseRepository, ctx.taskRepository, ctx.pageRepository, input.initiativeId);
const prompt = buildPlanPrompt();
return agentManager.spawn({
name: input.name,
taskId: task.id,
prompt,
mode: 'plan',
provider: input.provider,
initiativeId: input.initiativeId,
inputContext: {
initiative,
pages: context.pages.length > 0 ? context.pages : undefined,
phases: context.phases.length > 0 ? context.phases : undefined,
tasks: context.tasks.length > 0 ? context.tasks : undefined,
},
});
}),
spawnArchitectRefine: publicProcedure
.input(z.object({
name: z.string().min(1).optional(),
initiativeId: z.string().min(1),
instruction: z.string().optional(),
provider: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const agentManager = requireAgentManager(ctx);
const initiativeRepo = requireInitiativeRepository(ctx);
const pageRepo = requirePageRepository(ctx);
const taskRepo = requireTaskRepository(ctx);
const initiative = await initiativeRepo.findById(input.initiativeId);
if (!initiative) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Initiative '${input.initiativeId}' not found`,
});
}
// Bug #10: Auto-dismiss stale (crashed/idle) refine agents before checking for active ones
const allAgents = await agentManager.list();
const staleAgents = allAgents.filter(
(a) =>
a.mode === 'refine' &&
a.initiativeId === input.initiativeId &&
['crashed', 'idle'].includes(a.status) &&
!a.userDismissedAt,
);
for (const stale of staleAgents) {
await agentManager.dismiss(stale.id);
}
// Bug #9: Prevent concurrent refine agents on the same initiative
const activeRefineAgents = allAgents.filter(
(a) =>
a.mode === 'refine' &&
a.initiativeId === input.initiativeId &&
['running', 'waiting_for_input'].includes(a.status),
);
if (activeRefineAgents.length > 0) {
throw new TRPCError({
code: 'CONFLICT',
message: `A refine agent is already running for this initiative`,
});
}
const pages = await pageRepo.findByInitiativeId(input.initiativeId);
if (pages.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Initiative has no page content to refine',
});
}
const task = await taskRepo.create({
initiativeId: input.initiativeId,
name: `Refine: ${initiative.name}`,
description: input.instruction ?? 'Review and propose edits to initiative content',
category: 'refine',
status: 'in_progress',
});
const prompt = buildRefinePrompt();
return agentManager.spawn({
name: input.name,
taskId: task.id,
prompt,
mode: 'refine',
provider: input.provider,
initiativeId: input.initiativeId,
inputContext: { initiative, pages },
});
}),
spawnArchitectDetail: publicProcedure
.input(z.object({
name: z.string().min(1).optional(),
phaseId: z.string().min(1),
taskName: z.string().min(1).optional(),
context: z.string().optional(),
provider: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const agentManager = requireAgentManager(ctx);
const phaseRepo = requirePhaseRepository(ctx);
const taskRepo = requireTaskRepository(ctx);
const initiativeRepo = requireInitiativeRepository(ctx);
const phase = await phaseRepo.findById(input.phaseId);
if (!phase) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Phase '${input.phaseId}' not found`,
});
}
const initiative = await initiativeRepo.findById(phase.initiativeId);
if (!initiative) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Initiative '${phase.initiativeId}' not found`,
});
}
// Auto-dismiss stale detail agents for this phase
const allAgents = await agentManager.list();
const detailAgents = allAgents.filter(
(a) => a.mode === 'detail' && !a.userDismissedAt,
);
// Look up tasks to find which phase each detail agent targets
const activeForPhase: typeof detailAgents = [];
const staleForPhase: typeof detailAgents = [];
for (const agent of detailAgents) {
if (!agent.taskId) continue;
const agentTask = await taskRepo.findById(agent.taskId);
if (agentTask?.phaseId !== input.phaseId) continue;
if (['crashed', 'idle'].includes(agent.status)) {
staleForPhase.push(agent);
} else if (['running', 'waiting_for_input'].includes(agent.status)) {
activeForPhase.push(agent);
}
}
for (const stale of staleForPhase) {
await agentManager.dismiss(stale.id);
}
if (activeForPhase.length > 0) {
throw new TRPCError({
code: 'CONFLICT',
message: `A detail agent is already running for phase "${phase.name}"`,
});
}
const detailTaskName = input.taskName ?? `Detail: ${phase.name}`;
const task = await taskRepo.create({
phaseId: phase.id,
initiativeId: phase.initiativeId,
name: detailTaskName,
description: input.context ?? `Detail phase "${phase.name}" into executable tasks`,
category: 'detail',
status: 'in_progress',
});
const context = await gatherInitiativeContext(ctx.phaseRepository, ctx.taskRepository, ctx.pageRepository, phase.initiativeId);
const prompt = buildDetailPrompt();
return agentManager.spawn({
name: input.name,
taskId: task.id,
prompt,
mode: 'detail',
provider: input.provider,
initiativeId: phase.initiativeId,
inputContext: {
initiative,
phase,
task,
pages: context.pages.length > 0 ? context.pages : undefined,
phases: context.phases.length > 0 ? context.phases : undefined,
tasks: context.tasks.length > 0 ? context.tasks : undefined,
},
});
}),
};
}

View File

@@ -0,0 +1,146 @@
/**
* Change Set Router — list, get, revert workflows
*/
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js';
import {
requireChangeSetRepository,
requirePhaseRepository,
requireTaskRepository,
requirePageRepository,
} from './_helpers.js';
export function changeSetProcedures(publicProcedure: ProcedureBuilder) {
return {
listChangeSets: publicProcedure
.input(z.object({
initiativeId: z.string().min(1).optional(),
agentId: z.string().min(1).optional(),
}))
.query(async ({ ctx, input }) => {
const repo = requireChangeSetRepository(ctx);
if (input.agentId) {
return repo.findByAgentId(input.agentId);
}
if (input.initiativeId) {
return repo.findByInitiativeId(input.initiativeId);
}
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Either agentId or initiativeId is required',
});
}),
getChangeSet: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requireChangeSetRepository(ctx);
const cs = await repo.findByIdWithEntries(input.id);
if (!cs) {
throw new TRPCError({ code: 'NOT_FOUND', message: `ChangeSet '${input.id}' not found` });
}
return cs;
}),
revertChangeSet: publicProcedure
.input(z.object({ id: z.string().min(1), force: z.boolean().optional() }))
.mutation(async ({ ctx, input }) => {
const repo = requireChangeSetRepository(ctx);
const cs = await repo.findByIdWithEntries(input.id);
if (!cs) {
throw new TRPCError({ code: 'NOT_FOUND', message: `ChangeSet '${input.id}' not found` });
}
if (cs.status === 'reverted') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'ChangeSet is already reverted' });
}
const phaseRepo = requirePhaseRepository(ctx);
const taskRepo = requireTaskRepository(ctx);
const pageRepo = requirePageRepository(ctx);
// Conflict detection (unless force)
if (!input.force) {
const conflicts: string[] = [];
for (const entry of cs.entries) {
if (entry.action === 'create') {
if (entry.entityType === 'phase') {
const phase = await phaseRepo.findById(entry.entityId);
if (phase && phase.status === 'in_progress') {
conflicts.push(`Phase "${phase.name}" is in progress`);
}
} else if (entry.entityType === 'task') {
const task = await taskRepo.findById(entry.entityId);
if (task && task.status === 'in_progress') {
conflicts.push(`Task "${task.name}" is in progress`);
}
}
} else if (entry.action === 'update' && entry.entityType === 'page' && entry.newState) {
const page = await pageRepo.findById(entry.entityId);
if (page) {
const expectedContent = JSON.parse(entry.newState).content;
if (page.content !== expectedContent) {
conflicts.push(`Page "${page.title}" was modified since change set was applied`);
}
}
}
}
if (conflicts.length > 0) {
return { success: false as const, conflicts };
}
}
// Apply reverts in reverse entry order
const reversedEntries = [...cs.entries].reverse();
for (const entry of reversedEntries) {
try {
if (entry.action === 'create') {
switch (entry.entityType) {
case 'phase':
try { await phaseRepo.delete(entry.entityId); } catch { /* already deleted */ }
break;
case 'task':
try { await taskRepo.delete(entry.entityId); } catch { /* already deleted */ }
break;
case 'phase_dependency': {
const depData = JSON.parse(entry.newState || '{}');
if (depData.phaseId && depData.dependsOnPhaseId) {
try { await phaseRepo.removeDependency(depData.phaseId, depData.dependsOnPhaseId); } catch { /* already removed */ }
}
break;
}
}
} else if (entry.action === 'update' && entry.previousState) {
const prev = JSON.parse(entry.previousState);
switch (entry.entityType) {
case 'page':
await pageRepo.update(entry.entityId, {
content: prev.content,
title: prev.title,
});
ctx.eventBus.emit({
type: 'page:updated',
timestamp: new Date(),
payload: { pageId: entry.entityId, initiativeId: cs.initiativeId, title: prev.title },
});
break;
}
}
} catch (err) {
// Log but continue reverting other entries
}
}
await repo.markReverted(input.id);
ctx.eventBus.emit({
type: 'changeset:reverted' as const,
timestamp: new Date(),
payload: { changeSetId: cs.id, initiativeId: cs.initiativeId },
});
return { success: true as const };
}),
};
}

View File

@@ -0,0 +1,281 @@
/**
* Conversation Router — inter-agent communication procedures
*/
import { TRPCError } from '@trpc/server';
import { tracked, type TrackedEnvelope } from '@trpc/server';
import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js';
import { requireConversationRepository, requireAgentManager, requireTaskRepository } from './_helpers.js';
import type { ConversationCreatedEvent, ConversationAnsweredEvent } from '../../events/types.js';
export function conversationProcedures(publicProcedure: ProcedureBuilder) {
return {
createConversation: publicProcedure
.input(z.object({
fromAgentId: z.string().min(1),
toAgentId: z.string().min(1).optional(),
phaseId: z.string().min(1).optional(),
taskId: z.string().min(1).optional(),
question: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
const repo = requireConversationRepository(ctx);
const agentManager = requireAgentManager(ctx);
let toAgentId = input.toAgentId;
// Resolve target agent from taskId
if (!toAgentId && input.taskId) {
const agents = await agentManager.list();
const match = agents.find(a => a.taskId === input.taskId && a.status === 'running');
if (!match) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `No running agent found for task '${input.taskId}'`,
});
}
toAgentId = match.id;
}
// Resolve target agent from phaseId
if (!toAgentId && input.phaseId) {
const taskRepo = requireTaskRepository(ctx);
const tasks = await taskRepo.findByPhaseId(input.phaseId);
const taskIds = new Set(tasks.map(t => t.id));
const agents = await agentManager.list();
const match = agents.find(a => a.taskId && taskIds.has(a.taskId) && a.status === 'running');
if (!match) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `No running agent found for phase '${input.phaseId}'`,
});
}
toAgentId = match.id;
}
if (!toAgentId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Must provide toAgentId, taskId, or phaseId to identify target agent',
});
}
const conversation = await repo.create({
fromAgentId: input.fromAgentId,
toAgentId,
initiativeId: null,
phaseId: input.phaseId ?? null,
taskId: input.taskId ?? null,
question: input.question,
});
ctx.eventBus.emit({
type: 'conversation:created' as const,
timestamp: new Date(),
payload: {
conversationId: conversation.id,
fromAgentId: input.fromAgentId,
toAgentId,
},
});
return conversation;
}),
getPendingConversations: publicProcedure
.input(z.object({
agentId: z.string().min(1),
}))
.query(async ({ ctx, input }) => {
const repo = requireConversationRepository(ctx);
return repo.findPendingForAgent(input.agentId);
}),
getConversation: publicProcedure
.input(z.object({
id: z.string().min(1),
}))
.query(async ({ ctx, input }) => {
const repo = requireConversationRepository(ctx);
return repo.findById(input.id);
}),
answerConversation: publicProcedure
.input(z.object({
id: z.string().min(1),
answer: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
const repo = requireConversationRepository(ctx);
const existing = await repo.findById(input.id);
if (!existing) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Conversation '${input.id}' not found`,
});
}
if (existing.status === 'answered') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Conversation '${input.id}' is already answered`,
});
}
const updated = await repo.answer(input.id, input.answer);
ctx.eventBus.emit({
type: 'conversation:answered' as const,
timestamp: new Date(),
payload: {
conversationId: input.id,
fromAgentId: existing.fromAgentId,
toAgentId: existing.toAgentId,
},
});
return updated;
}),
onPendingConversation: publicProcedure
.input(z.object({ agentId: z.string().min(1) }))
.subscription(async function* (opts): AsyncGenerator<TrackedEnvelope<{
conversationId: string;
fromAgentId: string;
question: string;
phaseId: string | null;
taskId: string | null;
}>> {
const { agentId } = opts.input;
const signal = opts.signal ?? new AbortController().signal;
const eventBus = opts.ctx.eventBus;
const repo = requireConversationRepository(opts.ctx);
// First yield any already-pending conversations
const existing = await repo.findPendingForAgent(agentId);
let eventCounter = 0;
for (const conv of existing) {
yield tracked(`conv-${eventCounter++}`, {
conversationId: conv.id,
fromAgentId: conv.fromAgentId,
question: conv.question,
phaseId: conv.phaseId,
taskId: conv.taskId,
});
}
// Then listen for new conversation:created events
const queue: string[] = []; // conversation IDs
let resolve: (() => void) | null = null;
const handler = (event: ConversationCreatedEvent) => {
if (event.payload.toAgentId !== agentId) return;
queue.push(event.payload.conversationId);
if (resolve) {
const r = resolve;
resolve = null;
r();
}
};
eventBus.on('conversation:created', handler);
const cleanup = () => {
eventBus.off('conversation:created', handler);
if (resolve) {
const r = resolve;
resolve = null;
r();
}
};
signal.addEventListener('abort', cleanup, { once: true });
try {
while (!signal.aborted) {
while (queue.length > 0) {
const convId = queue.shift()!;
const conv = await repo.findById(convId);
if (conv && conv.status === 'pending') {
yield tracked(`conv-${eventCounter++}`, {
conversationId: conv.id,
fromAgentId: conv.fromAgentId,
question: conv.question,
phaseId: conv.phaseId,
taskId: conv.taskId,
});
}
}
if (!signal.aborted) {
await new Promise<void>((r) => {
resolve = r;
});
}
}
} finally {
cleanup();
}
}),
onConversationAnswer: publicProcedure
.input(z.object({ conversationId: z.string().min(1) }))
.subscription(async function* (opts): AsyncGenerator<TrackedEnvelope<{ answer: string }>> {
const { conversationId } = opts.input;
const signal = opts.signal ?? new AbortController().signal;
const eventBus = opts.ctx.eventBus;
const repo = requireConversationRepository(opts.ctx);
// Check if already answered
const existing = await repo.findById(conversationId);
if (existing && existing.status === 'answered' && existing.answer) {
yield tracked('answer-0', { answer: existing.answer });
return;
}
// Listen for conversation:answered events matching this ID
let answered = false;
let resolve: (() => void) | null = null;
const handler = (event: ConversationAnsweredEvent) => {
if (event.payload.conversationId !== conversationId) return;
answered = true;
if (resolve) {
const r = resolve;
resolve = null;
r();
}
};
eventBus.on('conversation:answered', handler);
const cleanup = () => {
eventBus.off('conversation:answered', handler);
if (resolve) {
const r = resolve;
resolve = null;
r();
}
};
signal.addEventListener('abort', cleanup, { once: true });
try {
while (!signal.aborted && !answered) {
await new Promise<void>((r) => {
resolve = r;
});
}
if (answered) {
const conv = await repo.findById(conversationId);
if (conv && conv.answer) {
yield tracked('answer-0', { answer: conv.answer });
}
}
} finally {
cleanup();
}
}),
};
}

View File

@@ -0,0 +1,41 @@
/**
* Coordination Router — merge queue operations
*/
import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js';
import { requireCoordinationManager } from './_helpers.js';
export function coordinationProcedures(publicProcedure: ProcedureBuilder) {
return {
queueMerge: publicProcedure
.input(z.object({ taskId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const coordinationManager = requireCoordinationManager(ctx);
await coordinationManager.queueMerge(input.taskId);
return { success: true };
}),
processMerges: publicProcedure
.input(z.object({
targetBranch: z.string().default('main'),
}))
.mutation(async ({ ctx, input }) => {
const coordinationManager = requireCoordinationManager(ctx);
const results = await coordinationManager.processMerges(input.targetBranch);
return { results };
}),
getMergeQueueStatus: publicProcedure
.query(async ({ ctx }) => {
const coordinationManager = requireCoordinationManager(ctx);
return coordinationManager.getQueueState();
}),
getNextMergeable: publicProcedure
.query(async ({ ctx }) => {
const coordinationManager = requireCoordinationManager(ctx);
return coordinationManager.getNextMergeable();
}),
};
}

View File

@@ -0,0 +1,39 @@
/**
* Dispatch Router — queue, dispatchNext, getQueueState, completeTask
*/
import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js';
import { requireDispatchManager } from './_helpers.js';
export function dispatchProcedures(publicProcedure: ProcedureBuilder) {
return {
queueTask: publicProcedure
.input(z.object({ taskId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const dispatchManager = requireDispatchManager(ctx);
await dispatchManager.queue(input.taskId);
return { success: true };
}),
dispatchNext: publicProcedure
.mutation(async ({ ctx }) => {
const dispatchManager = requireDispatchManager(ctx);
return dispatchManager.dispatchNext();
}),
getQueueState: publicProcedure
.query(async ({ ctx }) => {
const dispatchManager = requireDispatchManager(ctx);
return dispatchManager.getQueueState();
}),
completeTask: publicProcedure
.input(z.object({ taskId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const dispatchManager = requireDispatchManager(ctx);
await dispatchManager.completeTask(input.taskId);
return { success: true };
}),
};
}

View File

@@ -0,0 +1,153 @@
/**
* Initiative Router — create, list, get, update, merge config
*/
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js';
import { requireInitiativeRepository, requireProjectRepository, requireTaskRepository } from './_helpers.js';
export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
return {
createInitiative: publicProcedure
.input(z.object({
name: z.string().min(1),
branch: z.string().nullable().optional(),
projectIds: z.array(z.string().min(1)).min(1).optional(),
executionMode: z.enum(['yolo', 'review_per_phase']).optional(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requireInitiativeRepository(ctx);
if (input.projectIds && input.projectIds.length > 0) {
const projectRepo = requireProjectRepository(ctx);
for (const pid of input.projectIds) {
const project = await projectRepo.findById(pid);
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Project '${pid}' not found`,
});
}
}
}
const initiative = await repo.create({
name: input.name,
status: 'active',
...(input.executionMode && { executionMode: input.executionMode }),
...(input.branch && { branch: input.branch }),
});
if (input.projectIds && input.projectIds.length > 0) {
const projectRepo = requireProjectRepository(ctx);
await projectRepo.setInitiativeProjects(initiative.id, input.projectIds);
}
if (ctx.pageRepository) {
await ctx.pageRepository.create({
initiativeId: initiative.id,
parentPageId: null,
title: input.name,
content: null,
sortOrder: 0,
});
}
return initiative;
}),
listInitiatives: publicProcedure
.input(z.object({
status: z.enum(['active', 'completed', 'archived']).optional(),
}).optional())
.query(async ({ ctx, input }) => {
const repo = requireInitiativeRepository(ctx);
if (input?.status) {
return repo.findByStatus(input.status);
}
return repo.findAll();
}),
getInitiative: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requireInitiativeRepository(ctx);
const initiative = await repo.findById(input.id);
if (!initiative) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Initiative '${input.id}' not found`,
});
}
let projects: Array<{ id: string; name: string; url: string }> = [];
if (ctx.projectRepository) {
const fullProjects = await ctx.projectRepository.findProjectsByInitiativeId(input.id);
projects = fullProjects.map((p) => ({ id: p.id, name: p.name, url: p.url }));
}
let branchLocked = false;
if (ctx.taskRepository) {
const tasks = await ctx.taskRepository.findByInitiativeId(input.id);
branchLocked = tasks.some((t) => t.status !== 'pending');
}
return { ...initiative, projects, branchLocked };
}),
updateInitiative: publicProcedure
.input(z.object({
id: z.string().min(1),
name: z.string().min(1).optional(),
status: z.enum(['active', 'completed', 'archived']).optional(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requireInitiativeRepository(ctx);
const { id, ...data } = input;
return repo.update(id, data);
}),
deleteInitiative: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const repo = requireInitiativeRepository(ctx);
await repo.delete(input.id);
return { success: true };
}),
updateInitiativeConfig: publicProcedure
.input(z.object({
initiativeId: z.string().min(1),
mergeRequiresApproval: z.boolean().optional(),
executionMode: z.enum(['yolo', 'review_per_phase']).optional(),
branch: z.string().nullable().optional(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requireInitiativeRepository(ctx);
const { initiativeId, ...data } = input;
const existing = await repo.findById(initiativeId);
if (!existing) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Initiative '${initiativeId}' not found`,
});
}
// Prevent branch changes once work has started
if (data.branch !== undefined && ctx.taskRepository) {
const tasks = await ctx.taskRepository.findByInitiativeId(initiativeId);
const hasStarted = tasks.some((t) => t.status !== 'pending');
if (hasStarted) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Cannot change branch after work has started',
});
}
}
return repo.update(initiativeId, data);
}),
};
}

View File

@@ -0,0 +1,77 @@
/**
* Message Router — list, get, respond
*/
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js';
import { requireMessageRepository } from './_helpers.js';
export function messageProcedures(publicProcedure: ProcedureBuilder) {
return {
listMessages: publicProcedure
.input(z.object({
agentId: z.string().optional(),
status: z.enum(['pending', 'read', 'responded']).optional(),
}))
.query(async ({ ctx, input }) => {
const messageRepository = requireMessageRepository(ctx);
let messages = await messageRepository.findByRecipient('user');
if (input.agentId) {
messages = messages.filter(m => m.senderId === input.agentId);
}
if (input.status) {
messages = messages.filter(m => m.status === input.status);
}
return messages;
}),
getMessage: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const messageRepository = requireMessageRepository(ctx);
const message = await messageRepository.findById(input.id);
if (!message) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Message '${input.id}' not found`,
});
}
return message;
}),
respondToMessage: publicProcedure
.input(z.object({
id: z.string().min(1),
response: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
const messageRepository = requireMessageRepository(ctx);
const existing = await messageRepository.findById(input.id);
if (!existing) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Message '${input.id}' not found`,
});
}
const responseMessage = await messageRepository.create({
senderType: 'user',
recipientType: 'agent',
recipientId: existing.senderId,
type: 'response',
content: input.response,
parentMessageId: input.id,
});
await messageRepository.update(input.id, { status: 'responded' });
return responseMessage;
}),
};
}

View File

@@ -0,0 +1,117 @@
/**
* Page Router — CRUD, tree operations
*/
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js';
import { requirePageRepository } from './_helpers.js';
export function pageProcedures(publicProcedure: ProcedureBuilder) {
return {
getRootPage: publicProcedure
.input(z.object({ initiativeId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requirePageRepository(ctx);
return repo.getOrCreateRootPage(input.initiativeId);
}),
getPage: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requirePageRepository(ctx);
const page = await repo.findById(input.id);
if (!page) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Page '${input.id}' not found`,
});
}
return page;
}),
getPageUpdatedAtMap: publicProcedure
.input(z.object({ ids: z.array(z.string().min(1)) }))
.query(async ({ ctx, input }) => {
const repo = requirePageRepository(ctx);
const foundPages = await repo.findByIds(input.ids);
const map: Record<string, string> = {};
for (const p of foundPages) {
map[p.id] = p.updatedAt instanceof Date ? p.updatedAt.toISOString() : String(p.updatedAt);
}
return map;
}),
listPages: publicProcedure
.input(z.object({ initiativeId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requirePageRepository(ctx);
return repo.findByInitiativeId(input.initiativeId);
}),
listChildPages: publicProcedure
.input(z.object({ parentPageId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requirePageRepository(ctx);
return repo.findByParentPageId(input.parentPageId);
}),
createPage: publicProcedure
.input(z.object({
initiativeId: z.string().min(1),
parentPageId: z.string().min(1).nullable(),
title: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
const repo = requirePageRepository(ctx);
const page = await repo.create({
initiativeId: input.initiativeId,
parentPageId: input.parentPageId,
title: input.title,
content: null,
sortOrder: 0,
});
ctx.eventBus.emit({
type: 'page:created',
timestamp: new Date(),
payload: { pageId: page.id, initiativeId: input.initiativeId, title: input.title },
});
return page;
}),
updatePage: publicProcedure
.input(z.object({
id: z.string().min(1),
title: z.string().min(1).optional(),
content: z.string().nullable().optional(),
sortOrder: z.number().int().optional(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requirePageRepository(ctx);
const { id, ...data } = input;
const page = await repo.update(id, data);
ctx.eventBus.emit({
type: 'page:updated',
timestamp: new Date(),
payload: { pageId: id, initiativeId: page.initiativeId, title: input.title },
});
return page;
}),
deletePage: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const repo = requirePageRepository(ctx);
const page = await repo.findById(input.id);
await repo.delete(input.id);
if (page) {
ctx.eventBus.emit({
type: 'page:deleted',
timestamp: new Date(),
payload: { pageId: input.id, initiativeId: page.initiativeId },
});
}
return { success: true };
}),
};
}

View File

@@ -0,0 +1,94 @@
/**
* Phase Dispatch Router — queue, dispatch, state, child tasks
*/
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { Task } from '../../db/schema.js';
import type { ProcedureBuilder } from '../trpc.js';
import { requirePhaseDispatchManager, requireTaskRepository } from './_helpers.js';
export function phaseDispatchProcedures(publicProcedure: ProcedureBuilder) {
return {
queuePhase: publicProcedure
.input(z.object({ phaseId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const phaseDispatchManager = requirePhaseDispatchManager(ctx);
await phaseDispatchManager.queuePhase(input.phaseId);
return { success: true };
}),
dispatchNextPhase: publicProcedure
.mutation(async ({ ctx }) => {
const phaseDispatchManager = requirePhaseDispatchManager(ctx);
return phaseDispatchManager.dispatchNextPhase();
}),
getPhaseQueueState: publicProcedure
.query(async ({ ctx }) => {
const phaseDispatchManager = requirePhaseDispatchManager(ctx);
return phaseDispatchManager.getPhaseQueueState();
}),
createChildTasks: publicProcedure
.input(z.object({
parentTaskId: z.string().min(1),
tasks: z.array(z.object({
number: z.number().int().positive(),
name: z.string().min(1),
description: z.string(),
type: z.enum(['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action']).default('auto'),
dependencies: z.array(z.number().int().positive()).optional(),
})),
}))
.mutation(async ({ ctx, input }) => {
const taskRepo = requireTaskRepository(ctx);
const parentTask = await taskRepo.findById(input.parentTaskId);
if (!parentTask) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Parent task '${input.parentTaskId}' not found`,
});
}
if (parentTask.category !== 'detail') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Parent task must have category 'detail', got '${parentTask.category}'`,
});
}
const numberToId = new Map<number, string>();
const created: Task[] = [];
for (const taskInput of input.tasks) {
const task = await taskRepo.create({
parentTaskId: input.parentTaskId,
phaseId: parentTask.phaseId,
initiativeId: parentTask.initiativeId,
name: taskInput.name,
description: taskInput.description,
type: taskInput.type,
order: taskInput.number,
status: 'pending',
});
numberToId.set(taskInput.number, task.id);
created.push(task);
}
for (const taskInput of input.tasks) {
if (taskInput.dependencies && taskInput.dependencies.length > 0) {
const taskId = numberToId.get(taskInput.number)!;
for (const depNumber of taskInput.dependencies) {
const dependsOnTaskId = numberToId.get(depNumber);
if (dependsOnTaskId) {
await taskRepo.createDependency(taskId, dependsOnTaskId);
}
}
}
}
return created;
}),
};
}

View File

@@ -0,0 +1,238 @@
/**
* Phase Router — create, list, get, update, dependencies, bulk create
*/
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { Phase } from '../../db/schema.js';
import type { ProcedureBuilder } from '../trpc.js';
import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator } from './_helpers.js';
import { phaseBranchName } from '../../git/branch-naming.js';
import { ensureProjectClone } from '../../git/project-clones.js';
export function phaseProcedures(publicProcedure: ProcedureBuilder) {
return {
createPhase: publicProcedure
.input(z.object({
initiativeId: z.string().min(1),
name: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
return repo.create({
initiativeId: input.initiativeId,
name: input.name,
status: 'pending',
});
}),
listPhases: publicProcedure
.input(z.object({ initiativeId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
return repo.findByInitiativeId(input.initiativeId);
}),
getPhase: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
const phase = await repo.findById(input.id);
if (!phase) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Phase '${input.id}' not found`,
});
}
return phase;
}),
updatePhase: publicProcedure
.input(z.object({
id: z.string().min(1),
name: z.string().min(1).optional(),
content: z.string().nullable().optional(),
status: z.enum(['pending', 'approved', 'in_progress', 'completed', 'blocked', 'pending_review']).optional(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
const { id, ...data } = input;
return repo.update(id, data);
}),
approvePhase: publicProcedure
.input(z.object({ phaseId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
const taskRepo = requireTaskRepository(ctx);
const phase = await repo.findById(input.phaseId);
if (!phase) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Phase '${input.phaseId}' not found`,
});
}
if (phase.status !== 'pending') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Phase must be pending to approve (current status: ${phase.status})`,
});
}
// Validate phase has work tasks (filter out detail tasks)
const phaseTasks = await taskRepo.findByPhaseId(input.phaseId);
const workTasks = phaseTasks.filter((t) => t.category !== 'detail');
if (workTasks.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Phase must have tasks before it can be approved',
});
}
return repo.update(input.phaseId, { status: 'approved' });
}),
deletePhase: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
await repo.delete(input.id);
return { success: true };
}),
createPhasesFromPlan: publicProcedure
.input(z.object({
initiativeId: z.string().min(1),
phases: z.array(z.object({
name: z.string().min(1),
})),
}))
.mutation(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
const created: Phase[] = [];
for (const p of input.phases) {
const phase = await repo.create({
initiativeId: input.initiativeId,
name: p.name,
status: 'pending',
});
created.push(phase);
}
return created;
}),
listInitiativePhaseDependencies: publicProcedure
.input(z.object({ initiativeId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
return repo.findDependenciesByInitiativeId(input.initiativeId);
}),
createPhaseDependency: publicProcedure
.input(z.object({
phaseId: z.string().min(1),
dependsOnPhaseId: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
const phase = await repo.findById(input.phaseId);
if (!phase) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Phase '${input.phaseId}' not found`,
});
}
const dependsOnPhase = await repo.findById(input.dependsOnPhaseId);
if (!dependsOnPhase) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Phase '${input.dependsOnPhaseId}' not found`,
});
}
await repo.createDependency(input.phaseId, input.dependsOnPhaseId);
return { success: true };
}),
getPhaseDependencies: publicProcedure
.input(z.object({ phaseId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
const dependencies = await repo.getDependencies(input.phaseId);
return { dependencies };
}),
getPhaseDependents: publicProcedure
.input(z.object({ phaseId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
const dependents = await repo.getDependents(input.phaseId);
return { dependents };
}),
removePhaseDependency: publicProcedure
.input(z.object({
phaseId: z.string().min(1),
dependsOnPhaseId: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
await repo.removeDependency(input.phaseId, input.dependsOnPhaseId);
return { success: true };
}),
getPhaseReviewDiff: publicProcedure
.input(z.object({ phaseId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const phaseRepo = requirePhaseRepository(ctx);
const initiativeRepo = requireInitiativeRepository(ctx);
const projectRepo = requireProjectRepository(ctx);
const branchManager = requireBranchManager(ctx);
const phase = await phaseRepo.findById(input.phaseId);
if (!phase) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` });
}
if (phase.status !== 'pending_review') {
throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not pending review (status: ${phase.status})` });
}
const initiative = await initiativeRepo.findById(phase.initiativeId);
if (!initiative?.branch) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' });
}
const initBranch = initiative.branch;
const phBranch = phaseBranchName(initBranch, phase.name);
const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId);
let rawDiff = '';
for (const project of projects) {
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
const diff = await branchManager.diffBranches(clonePath, initBranch, phBranch);
if (diff) {
rawDiff += diff + '\n';
}
}
return {
phaseName: phase.name,
sourceBranch: phBranch,
targetBranch: initBranch,
rawDiff,
};
}),
approvePhaseReview: publicProcedure
.input(z.object({ phaseId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const orchestrator = requireExecutionOrchestrator(ctx);
await orchestrator.approveAndMergePhase(input.phaseId);
return { success: true };
}),
};
}

View File

@@ -0,0 +1,51 @@
/**
* Preview Router — start, stop, list, status for Docker-based preview deployments
*/
import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js';
import { requirePreviewManager } from './_helpers.js';
export function previewProcedures(publicProcedure: ProcedureBuilder) {
return {
startPreview: publicProcedure
.input(z.object({
initiativeId: z.string().min(1),
phaseId: z.string().min(1).optional(),
projectId: z.string().min(1),
branch: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
const previewManager = requirePreviewManager(ctx);
return previewManager.start(input);
}),
stopPreview: publicProcedure
.input(z.object({
previewId: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
const previewManager = requirePreviewManager(ctx);
await previewManager.stop(input.previewId);
return { success: true };
}),
listPreviews: publicProcedure
.input(z.object({
initiativeId: z.string().min(1).optional(),
}).optional())
.query(async ({ ctx, input }) => {
const previewManager = requirePreviewManager(ctx);
return previewManager.list(input?.initiativeId);
}),
getPreviewStatus: publicProcedure
.input(z.object({
previewId: z.string().min(1),
}))
.query(async ({ ctx, input }) => {
const previewManager = requirePreviewManager(ctx);
return previewManager.getStatus(input.previewId);
}),
};
}

View File

@@ -0,0 +1,157 @@
/**
* Project Router — register, list, get, delete, initiative associations
*/
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { join } from 'node:path';
import { rm } from 'node:fs/promises';
import type { ProcedureBuilder } from '../trpc.js';
import { requireProjectRepository } from './_helpers.js';
import { cloneProject } from '../../git/clone.js';
import { getProjectCloneDir } from '../../git/project-clones.js';
export function projectProcedures(publicProcedure: ProcedureBuilder) {
return {
registerProject: publicProcedure
.input(z.object({
name: z.string().min(1),
url: z.string().min(1),
defaultBranch: z.string().min(1).optional(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requireProjectRepository(ctx);
let project;
try {
project = await repo.create({
name: input.name,
url: input.url,
...(input.defaultBranch && { defaultBranch: input.defaultBranch }),
});
} catch (error) {
const msg = (error as Error).message;
if (msg.includes('UNIQUE') || msg.includes('unique')) {
throw new TRPCError({
code: 'CONFLICT',
message: `A project with that name or URL already exists`,
});
}
throw error;
}
if (ctx.workspaceRoot) {
const clonePath = join(ctx.workspaceRoot, getProjectCloneDir(input.name, project.id));
try {
await cloneProject(input.url, clonePath);
} catch (cloneError) {
await repo.delete(project.id);
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `Failed to clone repository: ${(cloneError as Error).message}`,
});
}
// Validate that the specified default branch exists in the cloned repo
const branchToValidate = input.defaultBranch ?? 'main';
if (ctx.branchManager) {
const exists = await ctx.branchManager.remoteBranchExists(clonePath, branchToValidate);
if (!exists) {
// Clean up: remove project and clone
await rm(clonePath, { recursive: true, force: true }).catch(() => {});
await repo.delete(project.id);
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Branch '${branchToValidate}' does not exist in the repository`,
});
}
}
}
return project;
}),
listProjects: publicProcedure
.query(async ({ ctx }) => {
const repo = requireProjectRepository(ctx);
return repo.findAll();
}),
getProject: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requireProjectRepository(ctx);
const project = await repo.findById(input.id);
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Project '${input.id}' not found`,
});
}
return project;
}),
deleteProject: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const repo = requireProjectRepository(ctx);
const project = await repo.findById(input.id);
if (project && ctx.workspaceRoot) {
const clonePath = join(ctx.workspaceRoot, getProjectCloneDir(project.name, project.id));
await rm(clonePath, { recursive: true, force: true }).catch(() => {});
}
await repo.delete(input.id);
return { success: true };
}),
updateProject: publicProcedure
.input(z.object({
id: z.string().min(1),
defaultBranch: z.string().min(1).optional(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requireProjectRepository(ctx);
const { id, ...data } = input;
const existing = await repo.findById(id);
if (!existing) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Project '${id}' not found`,
});
}
// Validate that the new default branch exists in the repo
if (data.defaultBranch && ctx.workspaceRoot && ctx.branchManager) {
const clonePath = join(ctx.workspaceRoot, getProjectCloneDir(existing.name, existing.id));
const exists = await ctx.branchManager.remoteBranchExists(clonePath, data.defaultBranch);
if (!exists) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Branch '${data.defaultBranch}' does not exist in the repository`,
});
}
}
return repo.update(id, data);
}),
getInitiativeProjects: publicProcedure
.input(z.object({ initiativeId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requireProjectRepository(ctx);
return repo.findProjectsByInitiativeId(input.initiativeId);
}),
updateInitiativeProjects: publicProcedure
.input(z.object({
initiativeId: z.string().min(1),
projectIds: z.array(z.string().min(1)).min(1),
}))
.mutation(async ({ ctx, input }) => {
const repo = requireProjectRepository(ctx);
await repo.setInitiativeProjects(input.initiativeId, input.projectIds);
return { success: true };
}),
};
}

View File

@@ -0,0 +1,45 @@
/**
* Subscription Router — SSE event streams
*/
import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js';
import {
eventBusIterable,
ALL_EVENT_TYPES,
AGENT_EVENT_TYPES,
TASK_EVENT_TYPES,
PAGE_EVENT_TYPES,
} from '../subscriptions.js';
export function subscriptionProcedures(publicProcedure: ProcedureBuilder) {
return {
onEvent: publicProcedure
.input(z.object({ lastEventId: z.string().nullish() }).optional())
.subscription(async function* (opts) {
const signal = opts.signal ?? new AbortController().signal;
yield* eventBusIterable(opts.ctx.eventBus, ALL_EVENT_TYPES, signal);
}),
onAgentUpdate: publicProcedure
.input(z.object({ lastEventId: z.string().nullish() }).optional())
.subscription(async function* (opts) {
const signal = opts.signal ?? new AbortController().signal;
yield* eventBusIterable(opts.ctx.eventBus, AGENT_EVENT_TYPES, signal);
}),
onTaskUpdate: publicProcedure
.input(z.object({ lastEventId: z.string().nullish() }).optional())
.subscription(async function* (opts) {
const signal = opts.signal ?? new AbortController().signal;
yield* eventBusIterable(opts.ctx.eventBus, TASK_EVENT_TYPES, signal);
}),
onPageUpdate: publicProcedure
.input(z.object({ lastEventId: z.string().nullish() }).optional())
.subscription(async function* (opts) {
const signal = opts.signal ?? new AbortController().signal;
yield* eventBusIterable(opts.ctx.eventBus, PAGE_EVENT_TYPES, signal);
}),
};
}

View File

@@ -0,0 +1,110 @@
/**
* System Router — health, status, systemHealthCheck
*/
import { z } from 'zod';
import { join } from 'node:path';
import { access } from 'node:fs/promises';
import type { ProcedureBuilder } from '../trpc.js';
import { requireAccountRepository, requireProjectRepository } from './_helpers.js';
import { checkAccountHealth } from '../../agent/accounts/usage.js';
import { getProjectCloneDir } from '../../git/project-clones.js';
export const healthResponseSchema = z.object({
status: z.literal('ok'),
uptime: z.number().int().nonnegative(),
processCount: z.number().int().nonnegative(),
});
export type HealthResponse = z.infer<typeof healthResponseSchema>;
export const processInfoSchema = z.object({
id: z.string(),
pid: z.number().int().positive(),
command: z.string(),
status: z.string(),
startedAt: z.string(),
});
export type ProcessInfo = z.infer<typeof processInfoSchema>;
export const statusResponseSchema = z.object({
server: z.object({
startedAt: z.string(),
uptime: z.number().int().nonnegative(),
pid: z.number().int().positive(),
}),
processes: z.array(processInfoSchema),
});
export type StatusResponse = z.infer<typeof statusResponseSchema>;
export function systemProcedures(publicProcedure: ProcedureBuilder) {
return {
health: publicProcedure
.output(healthResponseSchema)
.query(({ ctx }): HealthResponse => {
const uptime = ctx.serverStartedAt
? Math.floor((Date.now() - ctx.serverStartedAt.getTime()) / 1000)
: 0;
return {
status: 'ok',
uptime,
processCount: ctx.processCount,
};
}),
status: publicProcedure
.output(statusResponseSchema)
.query(({ ctx }): StatusResponse => {
const uptime = ctx.serverStartedAt
? Math.floor((Date.now() - ctx.serverStartedAt.getTime()) / 1000)
: 0;
return {
server: {
startedAt: ctx.serverStartedAt?.toISOString() ?? '',
uptime,
pid: process.pid,
},
processes: [],
};
}),
systemHealthCheck: publicProcedure
.query(async ({ ctx }) => {
const uptime = ctx.serverStartedAt
? Math.floor((Date.now() - ctx.serverStartedAt.getTime()) / 1000)
: 0;
const accountRepo = requireAccountRepository(ctx);
const allAccounts = await accountRepo.findAll();
const allAgents = ctx.agentManager ? await ctx.agentManager.list() : [];
const accounts = await Promise.all(
allAccounts.map(account => checkAccountHealth(account, allAgents, ctx.credentialManager, ctx.workspaceRoot ?? undefined))
);
const projectRepo = requireProjectRepository(ctx);
const allProjects = await projectRepo.findAll();
const projects = await Promise.all(
allProjects.map(async project => {
let repoExists = false;
if (ctx.workspaceRoot) {
const clonePath = join(ctx.workspaceRoot, getProjectCloneDir(project.name, project.id));
try { await access(clonePath); repoExists = true; } catch { repoExists = false; }
}
return { id: project.id, name: project.name, url: project.url, repoExists };
})
);
return {
server: { status: 'ok' as const, uptime, startedAt: ctx.serverStartedAt?.toISOString() ?? null },
accounts,
projects,
};
}),
};
}

View File

@@ -0,0 +1,162 @@
/**
* Task Router — CRUD, approval, listing by parent/initiative/phase
*/
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js';
import {
requireTaskRepository,
requireInitiativeRepository,
requirePhaseRepository,
requireDispatchManager,
} from './_helpers.js';
export function taskProcedures(publicProcedure: ProcedureBuilder) {
return {
listTasks: publicProcedure
.input(z.object({ parentTaskId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx);
return taskRepository.findByParentTaskId(input.parentTaskId);
}),
getTask: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx);
const task = await taskRepository.findById(input.id);
if (!task) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Task '${input.id}' not found`,
});
}
return task;
}),
updateTaskStatus: publicProcedure
.input(z.object({
id: z.string().min(1),
status: z.enum(['pending_approval', 'pending', 'in_progress', 'completed', 'blocked']),
}))
.mutation(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx);
const existing = await taskRepository.findById(input.id);
if (!existing) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Task '${input.id}' not found`,
});
}
return taskRepository.update(input.id, { status: input.status });
}),
createInitiativeTask: publicProcedure
.input(z.object({
initiativeId: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
category: z.enum(['execute', 'research', 'discuss', 'plan', 'detail', 'refine', 'verify', 'merge', 'review']).optional(),
type: z.enum(['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action']).optional(),
requiresApproval: z.boolean().nullable().optional(),
}))
.mutation(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx);
const initiativeRepo = requireInitiativeRepository(ctx);
const initiative = await initiativeRepo.findById(input.initiativeId);
if (!initiative) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Initiative '${input.initiativeId}' not found`,
});
}
return taskRepository.create({
initiativeId: input.initiativeId,
name: input.name,
description: input.description ?? null,
category: input.category ?? 'execute',
type: input.type ?? 'auto',
requiresApproval: input.requiresApproval ?? null,
status: 'pending',
});
}),
createPhaseTask: publicProcedure
.input(z.object({
phaseId: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
category: z.enum(['execute', 'research', 'discuss', 'plan', 'detail', 'refine', 'verify', 'merge', 'review']).optional(),
type: z.enum(['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action']).optional(),
requiresApproval: z.boolean().nullable().optional(),
}))
.mutation(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx);
const phaseRepo = requirePhaseRepository(ctx);
const phase = await phaseRepo.findById(input.phaseId);
if (!phase) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Phase '${input.phaseId}' not found`,
});
}
return taskRepository.create({
phaseId: input.phaseId,
name: input.name,
description: input.description ?? null,
category: input.category ?? 'execute',
type: input.type ?? 'auto',
requiresApproval: input.requiresApproval ?? null,
status: 'pending',
});
}),
listPendingApprovals: publicProcedure
.input(z.object({
initiativeId: z.string().optional(),
phaseId: z.string().optional(),
category: z.enum(['execute', 'research', 'discuss', 'plan', 'detail', 'refine', 'verify', 'merge', 'review']).optional(),
}).optional())
.query(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx);
return taskRepository.findPendingApproval(input);
}),
listInitiativeTasks: publicProcedure
.input(z.object({ initiativeId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx);
const tasks = await taskRepository.findByInitiativeId(input.initiativeId);
return tasks.filter((t) => t.category !== 'detail');
}),
listPhaseTasks: publicProcedure
.input(z.object({ phaseId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx);
const tasks = await taskRepository.findByPhaseId(input.phaseId);
return tasks.filter((t) => t.category !== 'detail');
}),
deleteTask: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx);
await taskRepository.delete(input.id);
return { success: true };
}),
approveTask: publicProcedure
.input(z.object({ taskId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const dispatchManager = requireDispatchManager(ctx);
await dispatchManager.approveTask(input.taskId);
return { success: true };
}),
};
}

View File

@@ -0,0 +1,218 @@
/**
* tRPC Subscription Helpers
*
* Bridges EventBus domain events into tRPC async generator subscriptions.
* Uses a queue-based approach: EventBus handlers push events into a queue,
* the async generator yields from the queue, and waits on a promise when empty.
*/
import { tracked, type TrackedEnvelope } from '@trpc/server';
import type { EventBus, DomainEvent, DomainEventType } from '../events/types.js';
/**
* Shape of events yielded by subscription procedures.
*/
export interface SubscriptionEventData {
id: string;
type: string;
payload: unknown;
timestamp: string;
}
/**
* All domain event types in the system.
*/
export const ALL_EVENT_TYPES: DomainEventType[] = [
'process:spawned',
'process:stopped',
'process:crashed',
'server:started',
'server:stopped',
'log:entry',
'worktree:created',
'worktree:removed',
'worktree:merged',
'worktree:conflict',
'agent:spawned',
'agent:stopped',
'agent:crashed',
'agent:resumed',
'agent:account_switched',
'agent:deleted',
'agent:waiting',
'agent:output',
'task:queued',
'task:dispatched',
'task:completed',
'task:blocked',
'phase:queued',
'phase:started',
'phase:completed',
'phase:blocked',
'phase:pending_review',
'phase:merged',
'task:merged',
'merge:queued',
'merge:started',
'merge:completed',
'merge:conflicted',
'page:created',
'page:updated',
'page:deleted',
'changeset:created',
'changeset:reverted',
'conversation:created',
'conversation:answered',
];
/**
* Agent-specific event types.
*/
export const AGENT_EVENT_TYPES: DomainEventType[] = [
'agent:spawned',
'agent:stopped',
'agent:crashed',
'agent:resumed',
'agent:account_switched',
'agent:deleted',
'agent:waiting',
'agent:output',
];
/**
* Task and phase event types.
*/
export const TASK_EVENT_TYPES: DomainEventType[] = [
'task:queued',
'task:dispatched',
'task:completed',
'task:blocked',
'task:merged',
'phase:queued',
'phase:started',
'phase:completed',
'phase:blocked',
'phase:pending_review',
'phase:merged',
];
/**
* Page event types.
*/
export const PAGE_EVENT_TYPES: DomainEventType[] = [
'page:created',
'page:updated',
'page:deleted',
];
/** Counter for generating unique event IDs */
let eventCounter = 0;
/** Drop oldest events when the queue exceeds this size */
const MAX_QUEUE_SIZE = 1000;
/** Yield a synthetic heartbeat after this many ms of silence */
const HEARTBEAT_INTERVAL_MS = 30_000;
/**
* Creates an async generator that bridges EventBus events into a pull-based stream.
*
* Uses a queue + deferred promise pattern:
* - EventBus handlers push events into a queue array
* - The async generator yields from the queue
* - When the queue is empty, it waits on a promise that resolves when the next event arrives
* - Cleans up handlers on AbortSignal abort
*
* Bounded queue: events beyond MAX_QUEUE_SIZE are dropped (oldest first).
* Heartbeat: a synthetic `__heartbeat__` event is yielded when no real events
* arrive within HEARTBEAT_INTERVAL_MS, allowing clients to detect silent disconnects.
*
* Each yielded event is wrapped with `tracked(id, data)` to enable client-side reconnection.
*
* @param eventBus - The EventBus to subscribe to
* @param eventTypes - Array of event type strings to listen for
* @param signal - AbortSignal to cancel the subscription
*/
export async function* eventBusIterable(
eventBus: EventBus,
eventTypes: DomainEventType[],
signal: AbortSignal,
): AsyncGenerator<TrackedEnvelope<SubscriptionEventData>> {
const queue: DomainEvent[] = [];
let resolve: (() => void) | null = null;
// Handler that pushes events into the queue and resolves the waiter
const handler = (event: DomainEvent) => {
queue.push(event);
// Drop oldest when queue overflows
while (queue.length > MAX_QUEUE_SIZE) {
queue.shift();
}
if (resolve) {
const r = resolve;
resolve = null;
r();
}
};
// Subscribe to all requested event types
for (const eventType of eventTypes) {
eventBus.on(eventType as DomainEvent['type'], handler);
}
// Cleanup function
const cleanup = () => {
for (const eventType of eventTypes) {
eventBus.off(eventType as DomainEvent['type'], handler);
}
// Resolve any pending waiter so the generator can exit
if (resolve) {
const r = resolve;
resolve = null;
r();
}
};
// Clean up when the client disconnects
signal.addEventListener('abort', cleanup, { once: true });
try {
while (!signal.aborted) {
// Drain the queue
while (queue.length > 0) {
const event = queue.shift()!;
const id = `${Date.now()}-${eventCounter++}`;
const data: SubscriptionEventData = {
id,
type: event.type,
payload: event.payload,
timestamp: event.timestamp.toISOString(),
};
yield tracked(id, data);
}
// Wait for the next event, abort, or heartbeat timeout
if (!signal.aborted) {
const gotEvent = await Promise.race([
new Promise<true>((r) => {
resolve = () => r(true);
}),
new Promise<false>((r) => setTimeout(() => r(false), HEARTBEAT_INTERVAL_MS)),
]);
if (!gotEvent && !signal.aborted) {
// No real event arrived — yield heartbeat
const id = `${Date.now()}-${eventCounter++}`;
yield tracked(id, {
id,
type: '__heartbeat__',
payload: null,
timestamp: new Date().toISOString(),
});
}
}
}
} finally {
cleanup();
}
}

18
apps/server/trpc/trpc.ts Normal file
View File

@@ -0,0 +1,18 @@
/**
* tRPC Initialization
*
* Extracted from router.ts to break circular dependencies.
* All domain routers import from here instead of router.ts.
*/
import { initTRPC } from '@trpc/server';
import type { TRPCContext } from './context.js';
const t = initTRPC.context<TRPCContext>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;
export const createCallerFactory = t.createCallerFactory;
export type ProcedureBuilder = typeof t.procedure;