Files
Codewalkers/src/trpc/router.ts
Lukas May 004140ea83 feat(19-01): add getAgentQuestions and listWaitingAgents tRPC procedures
- getAgentQuestions returns structured PendingQuestions from AgentManager
- listWaitingAgents filters agents to waiting_for_input status
- Updated JSDoc procedure list with both new procedures
2026-02-04 21:51:25 +01:00

1228 lines
36 KiB
TypeScript

/**
* tRPC Router
*
* Type-safe RPC router for CLI-server communication.
* Uses Zod for runtime validation of procedure inputs/outputs.
*/
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { TRPCContext } from './context.js';
import type { AgentInfo, AgentResult, PendingQuestions } 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 { PlanRepository } from '../db/repositories/plan-repository.js';
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
import type { CoordinationManager } from '../coordination/types.js';
import type { Phase, Task } from '../db/schema.js';
import { buildDiscussPrompt, buildBreakdownPrompt, buildDecomposePrompt } from '../agent/prompts.js';
/**
* Initialize tRPC with our context type.
* This creates the tRPC instance that all procedures will use.
*/
const t = initTRPC.context<TRPCContext>().create();
/**
* Base router - used to create the app router.
*/
export const router = t.router;
/**
* Public procedure - no authentication required.
* All current procedures are public since this is a local-only server.
*/
export const publicProcedure = t.procedure;
/**
* Middleware builder for custom middleware.
*/
export const middleware = t.middleware;
/**
* Create caller factory for testing.
* Allows calling procedures directly without HTTP transport.
*/
export const createCallerFactory = t.createCallerFactory;
// =============================================================================
// Zod Schemas for procedure outputs
// =============================================================================
/**
* Schema for health check response.
*/
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>;
/**
* Schema for process info in status response.
*/
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>;
/**
* Schema for status response.
*/
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>;
// =============================================================================
// Agent Input Schemas
// =============================================================================
/**
* Schema for spawning a new agent.
*/
export const spawnAgentInputSchema = z.object({
/** Human-readable name for the agent (required, must be unique) */
name: z.string().min(1),
/** Task ID to assign to agent */
taskId: z.string().min(1),
/** Initial prompt/instruction for the agent */
prompt: z.string().min(1),
/** Optional working directory (defaults to worktree path) */
cwd: z.string().optional(),
/** Agent operation mode (defaults to 'execute') */
mode: z.enum(['execute', 'discuss', 'breakdown']).optional(),
});
export type SpawnAgentInput = z.infer<typeof spawnAgentInputSchema>;
/**
* Schema for identifying an agent by name or ID.
*/
export const agentIdentifierSchema = z.object({
/** Lookup by human-readable name (preferred) */
name: z.string().optional(),
/** Or lookup by ID */
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>;
/**
* Schema for resuming an agent with batched answers.
*/
export const resumeAgentInputSchema = z.object({
/** Lookup by human-readable name (preferred) */
name: z.string().optional(),
/** Or lookup by ID */
id: z.string().optional(),
/** Map of question ID to user's answer */
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>;
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Helper to resolve agent by name or id.
* Throws TRPCError if agent not found.
*/
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;
}
/**
* Helper to ensure agentManager is available in context.
*/
function requireAgentManager(ctx: TRPCContext) {
if (!ctx.agentManager) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Agent manager not available',
});
}
return ctx.agentManager;
}
/**
* Helper to ensure taskRepository is available in context.
*/
function requireTaskRepository(ctx: TRPCContext): TaskRepository {
if (!ctx.taskRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Task repository not available',
});
}
return ctx.taskRepository;
}
/**
* Helper to ensure messageRepository is available in context.
*/
function requireMessageRepository(ctx: TRPCContext): MessageRepository {
if (!ctx.messageRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Message repository not available',
});
}
return ctx.messageRepository;
}
/**
* Helper to ensure dispatchManager is available in context.
*/
function requireDispatchManager(ctx: TRPCContext): DispatchManager {
if (!ctx.dispatchManager) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Dispatch manager not available',
});
}
return ctx.dispatchManager;
}
/**
* Helper to ensure coordinationManager is available in context.
*/
function requireCoordinationManager(ctx: TRPCContext): CoordinationManager {
if (!ctx.coordinationManager) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Coordination manager not available',
});
}
return ctx.coordinationManager;
}
/**
* Helper to ensure initiativeRepository is available in context.
*/
function requireInitiativeRepository(ctx: TRPCContext): InitiativeRepository {
if (!ctx.initiativeRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Initiative repository not available',
});
}
return ctx.initiativeRepository;
}
/**
* Helper to ensure phaseRepository is available in context.
*/
function requirePhaseRepository(ctx: TRPCContext): PhaseRepository {
if (!ctx.phaseRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Phase repository not available',
});
}
return ctx.phaseRepository;
}
/**
* Helper to ensure planRepository is available in context.
*/
function requirePlanRepository(ctx: TRPCContext): PlanRepository {
if (!ctx.planRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Plan repository not available',
});
}
return ctx.planRepository;
}
/**
* Helper to ensure phaseDispatchManager is available in context.
*/
function requirePhaseDispatchManager(ctx: TRPCContext): PhaseDispatchManager {
if (!ctx.phaseDispatchManager) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Phase dispatch manager not available',
});
}
return ctx.phaseDispatchManager;
}
// =============================================================================
// Application Router with Procedures
// =============================================================================
/**
* Application router with all procedures.
*
* Procedures:
* - health: Quick health check with uptime and process count
* - status: Full server status with process list
* - spawnAgent: Spawn a new agent to work on a task
* - stopAgent: Stop a running agent
* - listAgents: List all agents with status
* - getAgent: Get agent details by name or ID
* - getAgentByName: Get agent by name
* - resumeAgent: Resume an agent waiting for input
* - getAgentResult: Get result of agent's work
* - getAgentQuestions: Get pending questions for an agent waiting for input
* - listWaitingAgents: List agents currently waiting for user input
* - listTasks: List tasks for a plan
* - getTask: Get task by ID
* - updateTaskStatus: Update task status
* - listMessages: List messages with optional filtering
* - getMessage: Get message by ID
* - respondToMessage: Respond to a message
* - queueTask: Queue a task for dispatch
* - dispatchNext: Dispatch next available task
* - getQueueState: Get dispatch queue state
* - completeTask: Mark a task as complete
* - queueMerge: Queue a completed task for merge
* - processMerges: Process all ready merges in dependency order
* - getMergeQueueStatus: Get merge queue status
* - getNextMergeable: Get next task ready to merge
*/
export const appRouter = router({
/**
* Health check procedure.
*
* Returns a lightweight response suitable for health monitoring.
* Calculates uptime from serverStartedAt in context.
*/
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,
};
}),
/**
* Full status procedure.
*
* Returns detailed server state including process list.
* More comprehensive than health for admin/debugging purposes.
*/
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,
},
// Process list will be populated when ProcessManager integration is complete
processes: [],
};
}),
// ===========================================================================
// Agent Procedures
// ===========================================================================
/**
* Spawn a new agent to work on a task.
* Creates isolated worktree, starts Claude CLI, persists state.
*/
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,
});
}),
/**
* Stop a running agent by name or ID.
*/
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 };
}),
/**
* List all agents with their current status.
*/
listAgents: publicProcedure
.query(async ({ ctx }) => {
const agentManager = requireAgentManager(ctx);
return agentManager.list();
}),
/**
* Get agent details by name or ID.
*/
getAgent: publicProcedure
.input(agentIdentifierSchema)
.query(async ({ ctx, input }) => {
return resolveAgent(ctx, input);
}),
/**
* Get agent by name (convenience method).
*/
getAgentByName: publicProcedure
.input(z.object({ name: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const agentManager = requireAgentManager(ctx);
return agentManager.getByName(input.name);
}),
/**
* Resume an agent that is waiting for input.
* Uses stored session ID to continue with full context.
*/
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 };
}),
/**
* Get the result of an agent's work.
* Only available after agent completes or stops.
*/
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);
}),
/**
* Get pending questions for an agent waiting for input.
* Returns structured question data (options, multiSelect) from AgentManager.
*/
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);
}),
/**
* List agents currently waiting for user input.
* Filters to only agents with status 'waiting_for_input'.
*/
listWaitingAgents: publicProcedure
.query(async ({ ctx }) => {
const agentManager = requireAgentManager(ctx);
const allAgents = await agentManager.list();
return allAgents.filter(agent => agent.status === 'waiting_for_input');
}),
// ===========================================================================
// Task Procedures
// ===========================================================================
/**
* List tasks for a plan.
* Returns tasks ordered by order field.
*/
listTasks: publicProcedure
.input(z.object({ planId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx);
return taskRepository.findByPlanId(input.planId);
}),
/**
* Get a task by ID.
* Throws NOT_FOUND if task doesn't exist.
*/
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;
}),
/**
* Update a task's status.
* Returns the updated task.
*/
updateTaskStatus: publicProcedure
.input(z.object({
id: z.string().min(1),
status: z.enum(['pending', 'in_progress', 'completed', 'blocked']),
}))
.mutation(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx);
// Check task exists first
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 });
}),
// ===========================================================================
// Message Procedures
// ===========================================================================
/**
* List messages with optional filtering.
* Filter by agent ID or status.
*/
listMessages: publicProcedure
.input(z.object({
agentId: z.string().optional(),
status: z.enum(['pending', 'read', 'responded']).optional(),
}))
.query(async ({ ctx, input }) => {
const messageRepository = requireMessageRepository(ctx);
// Get all messages for user (recipientType='user')
let messages = await messageRepository.findByRecipient('user');
// Filter by agentId if provided (sender is the agent)
if (input.agentId) {
messages = messages.filter(m => m.senderId === input.agentId);
}
// Filter by status if provided
if (input.status) {
messages = messages.filter(m => m.status === input.status);
}
return messages;
}),
/**
* Get a single message by ID.
* Throws NOT_FOUND if message doesn't exist.
*/
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;
}),
/**
* Respond to a message.
* Updates message with response and sets status to 'responded'.
*/
respondToMessage: publicProcedure
.input(z.object({
id: z.string().min(1),
response: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
const messageRepository = requireMessageRepository(ctx);
// Check message exists
const existing = await messageRepository.findById(input.id);
if (!existing) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Message '${input.id}' not found`,
});
}
// Create a response message linked to the original
const responseMessage = await messageRepository.create({
senderType: 'user',
recipientType: 'agent',
recipientId: existing.senderId,
type: 'response',
content: input.response,
parentMessageId: input.id,
});
// Update original message status to 'responded'
await messageRepository.update(input.id, { status: 'responded' });
return responseMessage;
}),
// ===========================================================================
// Dispatch Procedures
// ===========================================================================
/**
* Queue a task for dispatch.
* Task will be dispatched when all dependencies complete.
*/
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 };
}),
/**
* Dispatch next available task to an agent.
* Returns dispatch result with task and agent info.
*/
dispatchNext: publicProcedure
.mutation(async ({ ctx }) => {
const dispatchManager = requireDispatchManager(ctx);
return dispatchManager.dispatchNext();
}),
/**
* Get current queue state.
* Returns queued, ready, and blocked task counts.
*/
getQueueState: publicProcedure
.query(async ({ ctx }) => {
const dispatchManager = requireDispatchManager(ctx);
return dispatchManager.getQueueState();
}),
/**
* Mark a task as complete.
* Removes from queue and triggers dependent task re-evaluation.
*/
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 };
}),
// ===========================================================================
// Coordination Procedures
// ===========================================================================
/**
* Queue a completed task for merge.
* Task will be merged when all dependencies complete.
*/
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 };
}),
/**
* Process all ready merges in dependency order.
* Returns results of all merge operations.
*/
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 };
}),
/**
* Get merge queue status.
* Shows queued, in-progress, merged, and conflicted tasks.
*/
getMergeQueueStatus: publicProcedure
.query(async ({ ctx }) => {
const coordinationManager = requireCoordinationManager(ctx);
return coordinationManager.getQueueState();
}),
/**
* Get next task ready to merge.
* Returns task with all dependencies resolved.
*/
getNextMergeable: publicProcedure
.query(async ({ ctx }) => {
const coordinationManager = requireCoordinationManager(ctx);
return coordinationManager.getNextMergeable();
}),
// ===========================================================================
// Initiative Procedures
// ===========================================================================
/**
* Create a new initiative.
* Returns the created initiative with generated ID.
*/
createInitiative: publicProcedure
.input(z.object({
name: z.string().min(1),
description: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requireInitiativeRepository(ctx);
return repo.create({
name: input.name,
description: input.description ?? null,
status: 'active',
});
}),
/**
* List all initiatives with optional status filter.
* Returns initiatives ordered by creation time.
*/
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();
}),
/**
* Get an initiative by ID.
* Throws NOT_FOUND if initiative doesn't exist.
*/
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`,
});
}
return initiative;
}),
/**
* Update an initiative.
* Returns the updated initiative.
*/
updateInitiative: publicProcedure
.input(z.object({
id: z.string().min(1),
name: z.string().min(1).optional(),
description: z.string().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);
}),
// ===========================================================================
// Phase Procedures
// ===========================================================================
/**
* Create a new phase for an initiative.
* Auto-assigns the next phase number.
*/
createPhase: publicProcedure
.input(z.object({
initiativeId: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
const nextNumber = await repo.getNextNumber(input.initiativeId);
return repo.create({
initiativeId: input.initiativeId,
number: nextNumber,
name: input.name,
description: input.description ?? null,
status: 'pending',
});
}),
/**
* List phases for an initiative.
* Returns phases ordered by number.
*/
listPhases: publicProcedure
.input(z.object({ initiativeId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
return repo.findByInitiativeId(input.initiativeId);
}),
/**
* Get a phase by ID.
* Throws NOT_FOUND if phase doesn't exist.
*/
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;
}),
/**
* Update a phase.
* Returns the updated phase.
*/
updatePhase: publicProcedure
.input(z.object({
id: z.string().min(1),
name: z.string().min(1).optional(),
description: z.string().optional(),
status: z.enum(['pending', 'in_progress', 'completed', 'blocked']).optional(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
const { id, ...data } = input;
return repo.update(id, data);
}),
/**
* Create multiple phases from Architect breakdown output.
* Accepts pre-numbered phases and creates them in bulk.
*/
createPhasesFromBreakdown: publicProcedure
.input(z.object({
initiativeId: z.string().min(1),
phases: z.array(z.object({
number: z.number(),
name: z.string().min(1),
description: z.string(),
})),
}))
.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,
number: p.number,
name: p.name,
description: p.description,
status: 'pending',
});
created.push(phase);
}
return created;
}),
/**
* Create a dependency between two phases.
* The phase with phaseId depends on the phase with dependsOnPhaseId.
*/
createPhaseDependency: publicProcedure
.input(z.object({
phaseId: z.string().min(1),
dependsOnPhaseId: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
// Validate both phases exist
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 };
}),
/**
* Get dependencies for a phase.
* Returns IDs of phases that this phase depends on.
*/
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 };
}),
// ===========================================================================
// Phase Dispatch Procedures
// ===========================================================================
/**
* Queue a phase for dispatch.
* Phase will be dispatched when all dependencies complete.
*/
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 };
}),
/**
* Dispatch next available phase.
* Returns dispatch result with phase info.
*/
dispatchNextPhase: publicProcedure
.mutation(async ({ ctx }) => {
const phaseDispatchManager = requirePhaseDispatchManager(ctx);
return phaseDispatchManager.dispatchNextPhase();
}),
/**
* Get current phase queue state.
* Returns queued, ready, and blocked phase counts.
*/
getPhaseQueueState: publicProcedure
.query(async ({ ctx }) => {
const phaseDispatchManager = requirePhaseDispatchManager(ctx);
return phaseDispatchManager.getPhaseQueueState();
}),
// ===========================================================================
// Plan Procedures
// ===========================================================================
/**
* Create a new plan for a phase.
* Auto-assigns the next plan number if not provided.
*/
createPlan: publicProcedure
.input(z.object({
phaseId: z.string().min(1),
number: z.number().int().positive().optional(),
name: z.string().min(1),
description: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requirePlanRepository(ctx);
const number = input.number ?? await repo.getNextNumber(input.phaseId);
return repo.create({
phaseId: input.phaseId,
number,
name: input.name,
description: input.description ?? null,
status: 'pending',
});
}),
/**
* List plans for a phase.
* Returns plans ordered by number.
*/
listPlans: publicProcedure
.input(z.object({ phaseId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requirePlanRepository(ctx);
return repo.findByPhaseId(input.phaseId);
}),
/**
* Get a plan by ID.
* Throws NOT_FOUND if plan doesn't exist.
*/
getPlan: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requirePlanRepository(ctx);
const plan = await repo.findById(input.id);
if (!plan) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Plan '${input.id}' not found`,
});
}
return plan;
}),
/**
* Update a plan.
* Returns the updated plan.
*/
updatePlan: publicProcedure
.input(z.object({
id: z.string().min(1),
name: z.string().min(1).optional(),
description: z.string().optional(),
status: z.enum(['pending', 'in_progress', 'completed']).optional(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requirePlanRepository(ctx);
const { id, ...data } = input;
return repo.update(id, data);
}),
/**
* Create tasks from decomposition agent output.
* Creates all tasks in order, then creates dependencies from number mappings.
*/
createTasksFromDecomposition: publicProcedure
.input(z.object({
planId: 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 planRepo = requirePlanRepository(ctx);
// Verify plan exists
const plan = await planRepo.findById(input.planId);
if (!plan) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Plan '${input.planId}' not found`,
});
}
// Create tasks in order, building number-to-ID map
const numberToId = new Map<number, string>();
const created: Task[] = [];
for (const taskInput of input.tasks) {
const task = await taskRepo.create({
planId: input.planId,
name: taskInput.name,
description: taskInput.description,
type: taskInput.type,
order: taskInput.number,
status: 'pending',
});
numberToId.set(taskInput.number, task.id);
created.push(task);
}
// Create dependencies after all tasks exist
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;
}),
// ===========================================================================
// Architect Spawn Procedures
// ===========================================================================
/**
* Spawn architect in discuss mode.
* Uses comprehensive discuss prompt to gather context from user.
*/
spawnArchitectDiscuss: publicProcedure
.input(z.object({
name: z.string().min(1),
initiativeId: z.string().min(1),
context: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const agentManager = requireAgentManager(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`,
});
}
const prompt = buildDiscussPrompt(initiative, input.context);
return agentManager.spawn({
name: input.name,
taskId: input.initiativeId,
prompt,
mode: 'discuss',
});
}),
/**
* Spawn architect in breakdown mode.
* Uses comprehensive breakdown prompt to decompose initiative into phases.
*/
spawnArchitectBreakdown: publicProcedure
.input(z.object({
name: z.string().min(1),
initiativeId: z.string().min(1),
contextSummary: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const agentManager = requireAgentManager(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`,
});
}
const prompt = buildBreakdownPrompt(initiative, input.contextSummary);
return agentManager.spawn({
name: input.name,
taskId: input.initiativeId,
prompt,
mode: 'breakdown',
});
}),
/**
* Spawn architect in decompose mode.
* Uses comprehensive decompose prompt to break a plan into executable tasks.
*/
spawnArchitectDecompose: publicProcedure
.input(z.object({
name: z.string().min(1),
planId: z.string().min(1),
context: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const agentManager = requireAgentManager(ctx);
const planRepo = requirePlanRepository(ctx);
const phaseRepo = requirePhaseRepository(ctx);
// 1. Get plan and its phase
const plan = await planRepo.findById(input.planId);
if (!plan) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Plan '${input.planId}' not found`,
});
}
const phase = await phaseRepo.findById(plan.phaseId);
if (!phase) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Phase '${plan.phaseId}' not found`,
});
}
// 2. Build decompose prompt
const prompt = buildDecomposePrompt(plan, phase, input.context);
// 3. Spawn agent in decompose mode
return agentManager.spawn({
name: input.name,
taskId: input.planId, // Associate with plan
prompt,
mode: 'decompose',
});
}),
});
/**
* Type of the application router.
* Used by clients for type-safe procedure calls.
*/
export type AppRouter = typeof appRouter;