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:
365
apps/server/trpc/routers/architect.ts
Normal file
365
apps/server/trpc/routers/architect.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user