import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js"; import { findProject } from "../fixtures/projectFixtures.js"; import type { AppEnv } from "../config/env.js"; import { getRequestId } from "../plugins/requestId.js"; import type { ApprovalState, CodexRunImportDto, CodexTaskDto, CreateCodexTaskFromHandoffRequestDto, CreateHandoffBundleRequestDto, CurrentStateReportDto, GenerateCurrentStateReportRequestDto, HandoffBundleDto, HandoffTarget, ImportCodexOutputRequestDto, } from "../types/api.js"; import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; import { getDurableIdempotentResponse, getIdempotencyKey, setDurableIdempotentResponse } from "../utils/idempotency.js"; import { createCodexTaskFromHandoffInDatabase, createHandoffBundleInDatabase, generateCurrentStateReportInDatabase, importCodexOutputInDatabase, listCodexTasksFromDatabase, listCurrentStateReportsFromDatabase, listHandoffBundlesFromDatabase, } from "../repositories/p7RuntimeRepository.js"; const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-_]+$/); const projectParamsSchema = z.object({ projectId: idSchema }); const handoffParamsSchema = z.object({ projectId: idSchema, handoffBundleId: idSchema }); const codexParamsSchema = z.object({ projectId: idSchema, codexTaskId: idSchema }); const paginationQuerySchema = z.object({ page: z.coerce.number().int().positive().default(1), pageSize: z.coerce.number().int().positive().max(100).default(25), cursor: z.string().optional(), sort: z.string().optional(), }); const currentStateQuerySchema = paginationQuerySchema.extend({ approvalState: z.string().optional(), source: z.string().optional(), }); const handoffQuerySchema = paginationQuerySchema.extend({ target: z.string().optional(), status: z.string().optional(), approvalState: z.string().optional(), }); const codexQuerySchema = paginationQuerySchema.extend({ status: z.string().optional(), approvalState: z.string().optional(), handoffBundleId: z.string().optional(), }); const generateCurrentStateBodySchema = z.object({ source: z.string().optional(), includeOperatingLog: z.boolean().optional(), includeProjectUpdates: z.boolean().optional(), includeReviewQueue: z.boolean().optional(), }).default({}); const createHandoffBodySchema = z.object({ target: z.enum(["context-pack", "codex-task", "current-state"]), title: z.string().optional(), includeAcceptedTruth: z.boolean().optional(), includeProjectUpdates: z.boolean().optional(), includeOperatingLog: z.boolean().optional(), autoSelectContext: z.boolean().optional(), selectionIntent: z.string().optional(), contextPackId: z.string().optional(), }); const createCodexTaskBodySchema = z.object({ goal: z.string().optional(), contextPackId: z.string().optional(), validationCommands: z.array(z.string()).optional(), autoSelectContext: z.boolean().optional(), selectionIntent: z.string().optional(), }).default({}); const importCodexBodySchema = z.object({ summary: z.string().min(1), outputText: z.string().optional(), sourceRun: z.string().optional(), }); const nowIso = () => new Date().toISOString(); const fixtureCurrentStates: CurrentStateReportDto[] = []; const fixtureHandoffs: HandoffBundleDto[] = []; const fixtureCodexTasks: CodexTaskDto[] = []; const splitCsv = (value: string | undefined): string[] => value?.split(",").map((item) => item.trim()).filter(Boolean) ?? []; const sendValidationError = (request: FastifyRequest, reply: FastifyReply, validation: z.ZodIssue[]) => reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "Request validation failed", getRequestId(request), validation.map((issue) => ({ field: issue.path.join("."), message: issue.message, code: issue.code })))); const parseParams = (schema: TSchema, request: FastifyRequest, reply: FastifyReply): z.output | undefined => { const parsed = schema.safeParse(request.params); if (!parsed.success) { sendValidationError(request, reply, parsed.error.issues); return undefined; } return parsed.data; }; const parseQuery = (schema: TSchema, request: FastifyRequest, reply: FastifyReply): z.output | undefined => { const parsed = schema.safeParse(request.query); if (!parsed.success) { sendValidationError(request, reply, parsed.error.issues); return undefined; } return parsed.data; }; const parseBody = (schema: TSchema, request: FastifyRequest, reply: FastifyReply): z.output | undefined => { const parsed = schema.safeParse(request.body ?? {}); if (!parsed.success) { sendValidationError(request, reply, parsed.error.issues); return undefined; } return parsed.data; }; const ensureProject = (request: FastifyRequest, reply: FastifyReply, projectId: string) => { const project = findProject(projectId); if (!project) { reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${projectId}' was not found.`, getRequestId(request))); return undefined; } return project; }; const getRequiredIdempotencyKey = (request: FastifyRequest, reply: FastifyReply): string | undefined => { const rawKey = request.headers["idempotency-key"]; const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; if (!idempotencyKey?.trim()) { reply.status(400).send(errorEnvelope("IDEMPOTENCY_KEY_REQUIRED", "Idempotency-Key header is required.", getRequestId(request))); return undefined; } return idempotencyKey; }; const withMutation = async (request: FastifyRequest, reply: FastifyReply, useDatabase: boolean, callback: (cacheKey: string) => Promise | void) => { const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return; const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = await getDurableIdempotentResponse(cacheKey, useDatabase); if (replay) { reply.status(replay.statusCode).send(replay.body); return; } await callback(cacheKey); }; const toPaginationInput = (query: z.output, filters: PaginationInput["filters"]): PaginationInput => ({ page: query.page, pageSize: query.pageSize, cursor: query.cursor, sort: query.sort, filters, }); const sortByField = >(items: T[], sort: string | undefined): T[] => { if (!sort) return items; const [field, direction = "asc"] = sort.split(":"); if (!field) return items; return [...items].sort((left, right) => { const comparison = String(left[field] ?? "").localeCompare(String(right[field] ?? ""), undefined, { numeric: true }); return direction === "desc" ? -comparison : comparison; }); }; const fixturePaginate = >(items: T[], query: z.output, filters: PaginationInput["filters"]) => paginate(sortByField(items, query.sort), toPaginationInput(query, filters)); type P7RuntimeRoutesOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"] }; export const registerP7RuntimeRoutes = async (app: FastifyInstance, options: P7RuntimeRoutesOptions = {}) => { const isDatabaseMode = options.dataSource === "database"; app.get("/api/projects/:projectId/current-state", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); const query = parseQuery(currentStateQuerySchema, request, reply); if (!params || !query) return; const filters = { ...(query.approvalState ? { approvalState: splitCsv(query.approvalState) as ApprovalState[] } : {}), ...(query.source ? { source: splitCsv(query.source) } : {}), ...(query.cursor ? { cursor: query.cursor } : {}) }; if (isDatabaseMode) { const result = await listCurrentStateReportsFromDatabase(params.projectId, filters, { page: query.page, pageSize: query.pageSize, sort: query.sort }); if (!result) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); return reply.send(successEnvelope(result, getRequestId(request))); } const project = ensureProject(request, reply, params.projectId); if (!project) return; return reply.send(successEnvelope(fixturePaginate(fixtureCurrentStates.filter((item) => item.projectId === project.id), query, filters), getRequestId(request))); }); app.post("/api/projects/:projectId/current-state/generate", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); const body = parseBody(generateCurrentStateBodySchema, request, reply) as GenerateCurrentStateReportRequestDto | undefined; if (!params || !body) return; await withMutation(request, reply, isDatabaseMode, async (cacheKey) => { if (isDatabaseMode) { const report = await generateCurrentStateReportInDatabase(params.projectId, body); if (!report) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); const envelope = successEnvelope(report, getRequestId(request)); await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body: envelope }, isDatabaseMode); return reply.status(201).send(envelope); } const project = ensureProject(request, reply, params.projectId); if (!project) return; const report: CurrentStateReportDto = { id: `csr-${Date.now()}`, projectId: project.id, generatedAt: nowIso(), exists: ["Fixture runtime"], works: ["Fixture Current State generated"], uncertain: [], broken: [], currentRisks: ["Fixture report is not persistent."], pendingReviewItemIds: [], activeContextPackId: project.activeContextPackId, recommendedNextActions: ["Switch to database mode for persistence."], source: body.source ?? "fixture", approvalState: "pending", createdAt: nowIso(), updatedAt: nowIso() }; fixtureCurrentStates.unshift(report); const envelope = successEnvelope(report, getRequestId(request)); await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body: envelope }, isDatabaseMode); return reply.status(201).send(envelope); }); }); app.get("/api/projects/:projectId/handoff-bundles", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); const query = parseQuery(handoffQuerySchema, request, reply); if (!params || !query) return; const filters = { ...(query.target ? { target: splitCsv(query.target) as HandoffTarget[] } : {}), ...(query.status ? { status: splitCsv(query.status) as HandoffBundleDto["status"][] } : {}), ...(query.approvalState ? { approvalState: splitCsv(query.approvalState) as ApprovalState[] } : {}), ...(query.cursor ? { cursor: query.cursor } : {}) }; if (isDatabaseMode) { const result = await listHandoffBundlesFromDatabase(params.projectId, filters, { page: query.page, pageSize: query.pageSize, sort: query.sort }); if (!result) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); return reply.send(successEnvelope(result, getRequestId(request))); } const project = ensureProject(request, reply, params.projectId); if (!project) return; return reply.send(successEnvelope(fixturePaginate(fixtureHandoffs.filter((item) => item.projectId === project.id), query, filters), getRequestId(request))); }); app.post("/api/projects/:projectId/handoff-bundles", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); const body = parseBody(createHandoffBodySchema, request, reply) as CreateHandoffBundleRequestDto | undefined; if (!params || !body) return; await withMutation(request, reply, isDatabaseMode, async (cacheKey) => { if (isDatabaseMode) { const bundle = await createHandoffBundleInDatabase(params.projectId, body); if (!bundle) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); const envelope = successEnvelope(bundle, getRequestId(request)); await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body: envelope }, isDatabaseMode); return reply.status(201).send(envelope); } const project = ensureProject(request, reply, params.projectId); if (!project) return; const selectedContextPackIds = [body.contextPackId ?? project.activeContextPackId].filter(Boolean) as string[]; const bundle: HandoffBundleDto = { id: `hb-${Date.now()}`, projectId: project.id, target: body.target, title: body.title ?? "Fixture handoff", markdown: `# Fixture handoff\n\n## Automatic context pack selection\nPrimary pack: ${selectedContextPackIds[0] ?? "none"}\nSelected packs: ${selectedContextPackIds.join(", ") || "none"}\n\n## Accepted truth\n- Fixture mode preview only.\n\nSwitch to database mode for persistent handoffs.`, qualityScore: 55, primaryContextPackId: selectedContextPackIds[0], selectedContextPackIds, sourceTruthItemIds: [], sourceReviewItemIds: [], sourceCaptureItemIds: [], sourceProjectUpdateIds: [], sourceOperatingLogEntryIds: [], status: "ready", approvalState: "pending", createdAt: nowIso(), updatedAt: nowIso() }; fixtureHandoffs.unshift(bundle); const envelope = successEnvelope(bundle, getRequestId(request)); await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body: envelope }, isDatabaseMode); return reply.status(201).send(envelope); }); }); app.get("/api/projects/:projectId/codex-tasks", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); const query = parseQuery(codexQuerySchema, request, reply); if (!params || !query) return; const filters = { ...(query.status ? { status: splitCsv(query.status) as CodexTaskDto["status"][] } : {}), ...(query.approvalState ? { approvalState: splitCsv(query.approvalState) as ApprovalState[] } : {}), ...(query.handoffBundleId ? { handoffBundleId: query.handoffBundleId } : {}), ...(query.cursor ? { cursor: query.cursor } : {}) }; if (isDatabaseMode) { const result = await listCodexTasksFromDatabase(params.projectId, filters, { page: query.page, pageSize: query.pageSize, sort: query.sort }); if (!result) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); return reply.send(successEnvelope(result, getRequestId(request))); } const project = ensureProject(request, reply, params.projectId); if (!project) return; return reply.send(successEnvelope(fixturePaginate(fixtureCodexTasks.filter((item) => item.projectId === project.id), query, filters), getRequestId(request))); }); app.post("/api/projects/:projectId/handoff-bundles/:handoffBundleId/codex-task", async (request, reply) => { const params = parseParams(handoffParamsSchema, request, reply); const body = parseBody(createCodexTaskBodySchema, request, reply) as CreateCodexTaskFromHandoffRequestDto | undefined; if (!params || !body) return; await withMutation(request, reply, isDatabaseMode, async (cacheKey) => { if (isDatabaseMode) { const task = await createCodexTaskFromHandoffInDatabase(params.projectId, params.handoffBundleId, body); if (!task) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); if (task === "HANDOFF_NOT_FOUND") return reply.status(404).send(errorEnvelope("HANDOFF_BUNDLE_NOT_FOUND", `Handoff bundle '${params.handoffBundleId}' was not found.`, getRequestId(request))); const envelope = successEnvelope(task, getRequestId(request)); await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body: envelope }, isDatabaseMode); return reply.status(201).send(envelope); } const project = ensureProject(request, reply, params.projectId); const handoff = fixtureHandoffs.find((item) => item.id === params.handoffBundleId); if (!project) return; if (!handoff) return reply.status(404).send(errorEnvelope("HANDOFF_BUNDLE_NOT_FOUND", `Handoff bundle '${params.handoffBundleId}' was not found.`, getRequestId(request))); const contextPackId = body.contextPackId ?? handoff.primaryContextPackId ?? project.activeContextPackId; const task: CodexTaskDto = { id: `ct-${Date.now()}`, projectId: project.id, handoffBundleId: handoff.id, goal: body.goal ?? `Implement ${handoff.title}`, contextPackId, selectedContextPackIds: [contextPackId].filter(Boolean) as string[], contextSelectionSummary: contextPackId ? `Fixture task uses context pack ${contextPackId}.` : "No context pack selected in fixture mode.", constraints: ["Fixture mode only"], relevantFiles: ["src", "backend", "docs"], doNotBreakRules: ["No silent truth mutation"], validationCommands: body.validationCommands ?? ["pnpm run validate:all"], expectedReportFormat: "Changed files and checks", outputImportInstructions: "Import as Capture and Review Queue item.", approvalState: "pending", status: "ready", createdAt: nowIso(), updatedAt: nowIso() }; fixtureCodexTasks.unshift(task); const envelope = successEnvelope(task, getRequestId(request)); await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body: envelope }, isDatabaseMode); return reply.status(201).send(envelope); }); }); app.post("/api/projects/:projectId/codex-tasks/:codexTaskId/import-output", async (request, reply) => { const params = parseParams(codexParamsSchema, request, reply); const body = parseBody(importCodexBodySchema, request, reply) as ImportCodexOutputRequestDto | undefined; if (!params || !body) return; await withMutation(request, reply, isDatabaseMode, async (cacheKey) => { if (isDatabaseMode) { const result = await importCodexOutputInDatabase(params.projectId, params.codexTaskId, body); if (!result) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); if (result === "CODEX_TASK_NOT_FOUND") return reply.status(404).send(errorEnvelope("CODEX_TASK_NOT_FOUND", `Codex task '${params.codexTaskId}' was not found.`, getRequestId(request))); const envelope = successEnvelope(result, getRequestId(request)); await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body: envelope }, isDatabaseMode); return reply.status(201).send(envelope); } const project = ensureProject(request, reply, params.projectId); if (!project) return; const task = fixtureCodexTasks.find((item) => item.id === params.codexTaskId); if (!task) return reply.status(404).send(errorEnvelope("CODEX_TASK_NOT_FOUND", `Codex task '${params.codexTaskId}' was not found.`, getRequestId(request))); task.status = "imported"; const importDto: CodexRunImportDto = { id: `cri-${Date.now()}`, projectId: project.id, codexTaskId: task.id, sourceRun: body.sourceRun ?? "fixture-run", summary: body.summary, contextPackId: task.contextPackId, selectedContextPackIds: task.selectedContextPackIds ?? [task.contextPackId].filter(Boolean) as string[], contextSelectionSummary: task.contextSelectionSummary, extractedReviewItemIds: [`rq-${Date.now()}`], status: "imported", createdAt: nowIso(), updatedAt: nowIso(), }; const envelope = successEnvelope({ importDto, mutation: { success: true, message: "Fixture Codex output imported.", createdEntityId: importDto.extractedReviewItemIds[0], updatedEntityId: task.id, affectedEntities: [task.id, importDto.id, importDto.extractedReviewItemIds[0]] } }, getRequestId(request)); await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body: envelope }, isDatabaseMode); return reply.status(201).send(envelope); }); }); };