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:
139
apps/server/trpc/context.ts
Normal file
139
apps/server/trpc/context.ts
Normal 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
31
apps/server/trpc/index.ts
Normal 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';
|
||||
222
apps/server/trpc/router.test.ts
Normal file
222
apps/server/trpc/router.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
66
apps/server/trpc/router.ts
Normal file
66
apps/server/trpc/router.ts
Normal 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;
|
||||
194
apps/server/trpc/routers/_helpers.ts
Normal file
194
apps/server/trpc/routers/_helpers.ts
Normal 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;
|
||||
}
|
||||
76
apps/server/trpc/routers/account.ts
Normal file
76
apps/server/trpc/routers/account.ts
Normal 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();
|
||||
}),
|
||||
};
|
||||
}
|
||||
248
apps/server/trpc/routers/agent.ts
Normal file
248
apps/server/trpc/routers/agent.ts
Normal 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();
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
146
apps/server/trpc/routers/change-set.ts
Normal file
146
apps/server/trpc/routers/change-set.ts
Normal 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 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
281
apps/server/trpc/routers/conversation.ts
Normal file
281
apps/server/trpc/routers/conversation.ts
Normal 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();
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
41
apps/server/trpc/routers/coordination.ts
Normal file
41
apps/server/trpc/routers/coordination.ts
Normal 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();
|
||||
}),
|
||||
};
|
||||
}
|
||||
39
apps/server/trpc/routers/dispatch.ts
Normal file
39
apps/server/trpc/routers/dispatch.ts
Normal 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 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
153
apps/server/trpc/routers/initiative.ts
Normal file
153
apps/server/trpc/routers/initiative.ts
Normal 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);
|
||||
}),
|
||||
};
|
||||
}
|
||||
77
apps/server/trpc/routers/message.ts
Normal file
77
apps/server/trpc/routers/message.ts
Normal 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;
|
||||
}),
|
||||
};
|
||||
}
|
||||
117
apps/server/trpc/routers/page.ts
Normal file
117
apps/server/trpc/routers/page.ts
Normal 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 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
94
apps/server/trpc/routers/phase-dispatch.ts
Normal file
94
apps/server/trpc/routers/phase-dispatch.ts
Normal 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;
|
||||
}),
|
||||
};
|
||||
}
|
||||
238
apps/server/trpc/routers/phase.ts
Normal file
238
apps/server/trpc/routers/phase.ts
Normal 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 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
51
apps/server/trpc/routers/preview.ts
Normal file
51
apps/server/trpc/routers/preview.ts
Normal 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);
|
||||
}),
|
||||
};
|
||||
}
|
||||
157
apps/server/trpc/routers/project.ts
Normal file
157
apps/server/trpc/routers/project.ts
Normal 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 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
45
apps/server/trpc/routers/subscription.ts
Normal file
45
apps/server/trpc/routers/subscription.ts
Normal 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);
|
||||
}),
|
||||
};
|
||||
}
|
||||
110
apps/server/trpc/routers/system.ts
Normal file
110
apps/server/trpc/routers/system.ts
Normal 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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
162
apps/server/trpc/routers/task.ts
Normal file
162
apps/server/trpc/routers/task.ts
Normal 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 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
218
apps/server/trpc/subscriptions.ts
Normal file
218
apps/server/trpc/subscriptions.ts
Normal 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
18
apps/server/trpc/trpc.ts
Normal 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;
|
||||
Reference in New Issue
Block a user