332 lines
20 KiB
TypeScript
332 lines
20 KiB
TypeScript
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 = <TSchema extends z.ZodTypeAny>(schema: TSchema, request: FastifyRequest, reply: FastifyReply): z.output<TSchema> | undefined => {
|
|
const parsed = schema.safeParse(request.params);
|
|
if (!parsed.success) {
|
|
sendValidationError(request, reply, parsed.error.issues);
|
|
return undefined;
|
|
}
|
|
return parsed.data;
|
|
};
|
|
|
|
const parseQuery = <TSchema extends z.ZodTypeAny>(schema: TSchema, request: FastifyRequest, reply: FastifyReply): z.output<TSchema> | undefined => {
|
|
const parsed = schema.safeParse(request.query);
|
|
if (!parsed.success) {
|
|
sendValidationError(request, reply, parsed.error.issues);
|
|
return undefined;
|
|
}
|
|
return parsed.data;
|
|
};
|
|
|
|
const parseBody = <TSchema extends z.ZodTypeAny>(schema: TSchema, request: FastifyRequest, reply: FastifyReply): z.output<TSchema> | 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> | void) => {
|
|
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
|
if (!idempotencyKey) return;
|
|
const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey);
|
|
const replay = await getDurableIdempotentResponse<unknown>(cacheKey, useDatabase);
|
|
if (replay) {
|
|
reply.status(replay.statusCode).send(replay.body);
|
|
return;
|
|
}
|
|
await callback(cacheKey);
|
|
};
|
|
|
|
const toPaginationInput = (query: z.output<typeof paginationQuerySchema>, filters: PaginationInput["filters"]): PaginationInput => ({
|
|
page: query.page,
|
|
pageSize: query.pageSize,
|
|
cursor: query.cursor,
|
|
sort: query.sort,
|
|
filters,
|
|
});
|
|
|
|
const sortByField = <T extends Record<string, unknown>>(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 = <T extends Record<string, unknown>>(items: T[], query: z.output<typeof paginationQuerySchema>, 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);
|
|
});
|
|
});
|
|
};
|