Initial curated projectbrain source from 8a7b1e0651dbdab97d744cbd2e653ab3970ac6c5
This commit is contained in:
commit
5aa3e8d685
700 files changed
+132801
No files matched your search
@@ -0,0 +1,205 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import { createGiteaActivityRuntimeLoader, isActiveRunnerJob, prioritizeRuntimeJobs, type CompactRunnerJob } from "../services/activityRuntime.js";
|
||||
import { loadAutonomyRuntime } from "../services/autonomyRuntime.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import { apiPaths } from "@projectbrain/contracts";
|
||||
|
||||
type ActivityItem = {
|
||||
id: string;
|
||||
type: "delivery" | "repository" | "project" | "operating-log" | "analysis" | "finding" | "runner-job" | "autonomy";
|
||||
title: string;
|
||||
description: string;
|
||||
status?: string;
|
||||
actor?: string;
|
||||
repository?: string;
|
||||
branch?: string;
|
||||
url?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const humanize = (value: string) => value.replace(/[._-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
const clip = (value: string, maximum = 500) => value.length <= maximum ? value : `${value.slice(0, maximum - 1)}…`;
|
||||
const activityTime = (job: CompactRunnerJob) => job.completedAt ?? job.startedAt ?? job.createdAt;
|
||||
const newestFirst = (items: ActivityItem[]) => items.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt));
|
||||
const asRecord = (value: unknown): Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
|
||||
const loadDurableActivity = async (projectId: string): Promise<ActivityItem[]> => {
|
||||
const [deliveryEvents, repositoryEvents, projectEvents, operatingLogs, analysisItems, findings, workspace] = await Promise.all([
|
||||
prisma.outboxEvent.findMany({ where: { projectId }, orderBy: { createdAt: "desc" }, take: 50 }),
|
||||
prisma.mcpAuditEvent.findMany({ where: { projectId }, orderBy: { createdAt: "desc" }, take: 50 }),
|
||||
prisma.activityEvent.findMany({ where: { projectId }, orderBy: { createdAt: "desc" }, take: 50 }),
|
||||
prisma.operatingLogEntry.findMany({ where: { projectId, deletedAt: null }, orderBy: { timestamp: "desc" }, take: 50 }),
|
||||
prisma.analysisJobItem.findMany({
|
||||
where: { projectId }, orderBy: { createdAt: "desc" }, take: 50,
|
||||
include: { job: { select: { actor: true, status: true, progressCurrent: true, progressTotal: true } } },
|
||||
}),
|
||||
prisma.repositoryFinding.findMany({
|
||||
where: { projectId }, orderBy: { updatedAt: "desc" }, take: 50,
|
||||
select: { id: true, owner: true, repository: true, title: true, message: true, status: true, updatedAt: true, issueLink: { select: { issueUrl: true } } },
|
||||
}),
|
||||
prisma.workspaceState.findUnique({ where: { projectId }, select: { notesJson: true } }),
|
||||
]);
|
||||
|
||||
const autonomyEvents = (() => {
|
||||
const events = asRecord(asRecord(workspace?.notesJson).autonomy).events;
|
||||
return Array.isArray(events) ? events.flatMap((value) => {
|
||||
const event = asRecord(value);
|
||||
if (typeof event.id !== "string" || typeof event.at !== "string" || typeof event.type !== "string" || typeof event.summary !== "string") return [];
|
||||
return [{
|
||||
id: `autonomy:${event.id}`,
|
||||
type: "autonomy" as const,
|
||||
title: clip(humanize(event.type), 180),
|
||||
description: clip(event.summary),
|
||||
status: event.type.split(".").at(-1),
|
||||
actor: typeof event.actor === "string" ? event.actor : undefined,
|
||||
createdAt: event.at,
|
||||
}];
|
||||
}) : [];
|
||||
})();
|
||||
|
||||
return newestFirst([
|
||||
...deliveryEvents.map((event) => ({
|
||||
id: `delivery:${event.id}`, type: "delivery" as const, title: clip(humanize(event.eventType), 180),
|
||||
description: `${humanize(event.aggregateType)} ${event.aggregateId}`, status: event.publishedAt ? "published" : "recorded", createdAt: event.createdAt.toISOString(),
|
||||
})),
|
||||
...repositoryEvents.map((event) => ({
|
||||
id: `repository:${event.id}`, type: "repository" as const, title: clip(humanize(event.toolName), 180),
|
||||
description: clip([event.repository, event.branch].filter(Boolean).join(" · ") || "ProjectBrain repositoryactie"),
|
||||
status: event.outcome, actor: event.actor, repository: event.repository ?? undefined, branch: event.branch ?? undefined, createdAt: event.createdAt.toISOString(),
|
||||
})),
|
||||
...projectEvents.map((event) => ({
|
||||
id: `project:${event.id}`, type: "project" as const, title: clip(humanize(event.eventType), 180),
|
||||
description: `${humanize(event.entityType)} ${event.entityId}`, actor: event.actor, createdAt: event.createdAt.toISOString(),
|
||||
})),
|
||||
...operatingLogs.map((event) => ({
|
||||
id: `log:${event.id}`, type: "operating-log" as const, title: clip(event.title, 180), description: clip(event.summary),
|
||||
status: event.importance, actor: event.createdBy, createdAt: event.timestamp.toISOString(),
|
||||
})),
|
||||
...analysisItems.map((item) => ({
|
||||
id: `analysis:${item.id}`, type: "analysis" as const, title: `Repositoryanalyse ${humanize(item.status)}`,
|
||||
description: clip(item.summary ?? `${item.owner}/${item.repository} · job ${item.job.progressCurrent}/${item.job.progressTotal}`),
|
||||
status: item.status, actor: item.job.actor, repository: `${item.owner}/${item.repository}`, branch: item.defaultBranch,
|
||||
createdAt: (item.completedAt ?? item.startedAt ?? item.createdAt).toISOString(),
|
||||
})),
|
||||
...findings.map((finding) => ({
|
||||
id: `finding:${finding.id}`, type: "finding" as const, title: clip(finding.title, 180),
|
||||
description: clip(finding.message), status: finding.status, repository: `${finding.owner}/${finding.repository}`,
|
||||
url: finding.issueLink?.issueUrl, createdAt: finding.updatedAt.toISOString(),
|
||||
})),
|
||||
...autonomyEvents,
|
||||
]).slice(0, 150);
|
||||
};
|
||||
|
||||
export const registerActivityRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => {
|
||||
const loadGiteaRuntime = createGiteaActivityRuntimeLoader(options.env);
|
||||
|
||||
app.get("/api/portfolio/runtime", async (request, reply) => {
|
||||
reply.header("Cache-Control", "private, no-store");
|
||||
const [snapshot, worker, autonomy] = await Promise.all([
|
||||
loadGiteaRuntime(),
|
||||
prisma.analysisWorkerHeartbeat.findFirst({ orderBy: { lastSeenAt: "desc" } }),
|
||||
loadAutonomyRuntime(options.env.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS),
|
||||
]);
|
||||
return successEnvelope({
|
||||
...snapshot,
|
||||
worker: worker ? { status: Date.now() - worker.lastSeenAt.getTime() < 30_000 ? (worker.activeJobId ? "working" : worker.status) : "stale", activeJobId: worker.activeJobId ?? undefined, detail: worker.detail ?? undefined, lastSeenAt: worker.lastSeenAt.toISOString() } : undefined,
|
||||
autonomy,
|
||||
pollAfterMs: snapshot.counts.running || snapshot.counts.runnable || snapshot.counts.dependencyBlocked || autonomy.counts.activeProjects || autonomy.counts.claimableProjects ? 8_000 : 30_000,
|
||||
}, getRequestId(request));
|
||||
});
|
||||
|
||||
app.get(apiPaths.projectActivity(), async (request, reply) => {
|
||||
reply.header("Cache-Control", "private, no-store");
|
||||
const { projectId } = request.params as { projectId: string };
|
||||
const project = await prisma.project.findFirst({ where: { deletedAt: null, OR: [{ id: projectId }, { slug: projectId }] }, select: { id: true } });
|
||||
if (!project) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", "Project not found", getRequestId(request)));
|
||||
return successEnvelope(await loadDurableActivity(project.id), getRequestId(request));
|
||||
});
|
||||
|
||||
app.get(apiPaths.projectActivityRuntime(), async (request, reply) => {
|
||||
reply.header("Cache-Control", "private, no-store");
|
||||
const { projectId } = request.params as { projectId: string };
|
||||
const { stateVersion: requestedStateVersion } = request.query as { stateVersion?: string };
|
||||
const knownStateVersion = requestedStateVersion && /^[a-f0-9]{24}$/.test(requestedStateVersion) ? requestedStateVersion : undefined;
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { deletedAt: null, OR: [{ id: projectId }, { slug: projectId }] },
|
||||
select: { id: true, giteaRepositories: { select: { owner: true, repository: true } } },
|
||||
});
|
||||
if (!project) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", "Project not found", getRequestId(request)));
|
||||
|
||||
const [durableItems, giteaRuntime, worker, autonomy] = await Promise.all([
|
||||
loadDurableActivity(project.id),
|
||||
loadGiteaRuntime(),
|
||||
prisma.analysisWorkerHeartbeat.findFirst({ orderBy: { lastSeenAt: "desc" } }),
|
||||
loadAutonomyRuntime(options.env.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS),
|
||||
]);
|
||||
const projectAutonomy = autonomy.projects.find((item) => item.projectId === project.id);
|
||||
const repositoryNames = new Set(project.giteaRepositories.map((link) => `${link.owner}/${link.repository}`.toLowerCase()));
|
||||
const projectJobs = giteaRuntime.jobs.filter((job) => job.repository && repositoryNames.has(job.repository.toLowerCase())).slice(0, 20);
|
||||
const activePortfolioJobs = giteaRuntime.jobs.filter((job) => isActiveRunnerJob(job.status)).slice(0, 12);
|
||||
const monitorJobs = prioritizeRuntimeJobs([...new Map([...projectJobs, ...activePortfolioJobs].map((job) => [job.id, {
|
||||
...job,
|
||||
scope: job.repository && repositoryNames.has(job.repository.toLowerCase()) ? "project" as const : "portfolio" as const,
|
||||
}])).values()]).slice(0, 24);
|
||||
const runnerItems: ActivityItem[] = projectJobs.map((job) => ({
|
||||
id: `runner-job:${job.id}`,
|
||||
type: "runner-job",
|
||||
title: job.name,
|
||||
description: [job.repository, job.currentStep, job.runnerName].filter(Boolean).join(" · "),
|
||||
status: job.conclusion ?? job.status,
|
||||
repository: job.repository,
|
||||
branch: job.branch,
|
||||
url: job.url,
|
||||
createdAt: activityTime(job),
|
||||
}));
|
||||
const items = newestFirst([...durableItems, ...runnerItems]).slice(0, 150);
|
||||
const workerFresh = worker ? Date.now() - worker.lastSeenAt.getTime() < 30_000 : false;
|
||||
const workerSnapshot = worker ? {
|
||||
status: workerFresh ? (worker.activeJobId ? "working" : worker.status) : "stale",
|
||||
instanceId: worker.instanceId,
|
||||
activeJobId: worker.activeJobId ?? undefined,
|
||||
detail: worker.detail ?? undefined,
|
||||
lastSeenAt: worker.lastSeenAt.toISOString(),
|
||||
} : undefined;
|
||||
const hasActiveWork = giteaRuntime.counts.running > 0
|
||||
|| giteaRuntime.counts.runnable > 0
|
||||
|| giteaRuntime.counts.dependencyBlocked > 0
|
||||
|| giteaRuntime.runners.some((runner) => runner.busy)
|
||||
|| Boolean(workerSnapshot?.activeJobId)
|
||||
|| Boolean(projectAutonomy?.activeWorkKind)
|
||||
|| Boolean(projectAutonomy?.enabled && projectAutonomy.status === "running");
|
||||
const stateVersion = createHash("sha256").update(JSON.stringify({
|
||||
items: items.map((item) => [item.id, item.status, item.createdAt]),
|
||||
gitea: giteaRuntime.stateVersion,
|
||||
worker: workerSnapshot ? [workerSnapshot.status, workerSnapshot.activeJobId, workerSnapshot.detail] : undefined,
|
||||
autonomy: projectAutonomy ? [projectAutonomy.status, projectAutonomy.pendingWorkCount, projectAutonomy.activeWorkKind, projectAutonomy.blockerKind, projectAutonomy.blockerReason, autonomy.executor?.status] : undefined,
|
||||
})).digest("hex").slice(0, 24);
|
||||
|
||||
const pollAfterMs = hasActiveWork ? 8_000 : 30_000;
|
||||
if (knownStateVersion === stateVersion) {
|
||||
return successEnvelope({ unchanged: true, stateVersion, pollAfterMs }, getRequestId(request));
|
||||
}
|
||||
return successEnvelope({
|
||||
items,
|
||||
runtime: {
|
||||
availability: giteaRuntime.availability,
|
||||
warning: giteaRuntime.warning,
|
||||
runners: giteaRuntime.runners,
|
||||
jobs: monitorJobs,
|
||||
activeJobCount: giteaRuntime.counts.running,
|
||||
queuedJobCount: giteaRuntime.counts.runnable,
|
||||
dependencyBlockedJobCount: giteaRuntime.counts.dependencyBlocked,
|
||||
worker: workerSnapshot,
|
||||
autonomy: { executor: autonomy.executor, executors: autonomy.executors, project: projectAutonomy },
|
||||
generatedAt: giteaRuntime.generatedAt,
|
||||
},
|
||||
stateVersion,
|
||||
pollAfterMs,
|
||||
}, getRequestId(request));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,331 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
applyAIActionFilters,
|
||||
applyAIActionRunFilters,
|
||||
findAIAction,
|
||||
findAIActionRun,
|
||||
listAIActionRunsForProject,
|
||||
listAIActionsForProject,
|
||||
runAIAction,
|
||||
} from "../fixtures/aiActionFixtures.js";
|
||||
import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js";
|
||||
import { findProject } from "../fixtures/projectFixtures.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import type {
|
||||
AIActionCategory,
|
||||
AIActionId,
|
||||
AIActionInputSource,
|
||||
AIActionListFilters,
|
||||
AIActionOutputTarget,
|
||||
AIActionRunListFilters,
|
||||
AIActionRunStatus,
|
||||
} from "../types/api.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import {
|
||||
getIdempotencyKey,
|
||||
getIdempotentResponse,
|
||||
setIdempotentResponse,
|
||||
} from "../utils/idempotency.js";
|
||||
|
||||
const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-_]+$/);
|
||||
const projectParamsSchema = z.object({ projectId: idSchema });
|
||||
const actionParamsSchema = z.object({ projectId: idSchema, actionId: idSchema });
|
||||
const runParamsSchema = z.object({ projectId: idSchema, runId: 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 actionQuerySchema = paginationQuerySchema.extend({
|
||||
category: z.string().optional(),
|
||||
outputTarget: z.string().optional(),
|
||||
sourceType: z.string().optional(),
|
||||
});
|
||||
const runQuerySchema = paginationQuerySchema.extend({
|
||||
actionId: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
outputTarget: z.string().optional(),
|
||||
createdFrom: z.string().optional(),
|
||||
});
|
||||
const inputSources = [
|
||||
"constitution",
|
||||
"dna",
|
||||
"feature_map",
|
||||
"architecture",
|
||||
"capture_items",
|
||||
"context_pack",
|
||||
"checks",
|
||||
"manual",
|
||||
] as const;
|
||||
const outputTargets = [
|
||||
"review_queue",
|
||||
"context_pack",
|
||||
"documentation",
|
||||
"roadmap",
|
||||
"current_state",
|
||||
"codex_task",
|
||||
] as const;
|
||||
const runBodySchema = z.object({
|
||||
inputSource: z.enum(inputSources).optional(),
|
||||
inputId: z.string().optional(),
|
||||
outputTarget: z.enum(outputTargets).optional(),
|
||||
objective: z.string().optional(),
|
||||
});
|
||||
|
||||
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 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 splitCsv = (value: string | undefined): string[] =>
|
||||
value?.split(",").map((item) => item.trim()).filter(Boolean) ?? [];
|
||||
|
||||
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 toPaginationInput = (
|
||||
query: z.output<typeof paginationQuerySchema>,
|
||||
filters: PaginationInput["filters"],
|
||||
): PaginationInput => ({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
cursor: query.cursor,
|
||||
sort: query.sort,
|
||||
filters,
|
||||
});
|
||||
|
||||
const toActionFilters = (
|
||||
query: z.output<typeof actionQuerySchema>,
|
||||
): AIActionListFilters => ({
|
||||
...(query.category ? { category: splitCsv(query.category) as AIActionCategory[] } : {}),
|
||||
...(query.outputTarget
|
||||
? { outputTarget: splitCsv(query.outputTarget) as AIActionOutputTarget[] }
|
||||
: {}),
|
||||
...(query.sourceType
|
||||
? { sourceType: splitCsv(query.sourceType) as AIActionInputSource[] }
|
||||
: {}),
|
||||
...(query.cursor ? { cursor: query.cursor } : {}),
|
||||
});
|
||||
|
||||
const toRunFilters = (
|
||||
query: z.output<typeof runQuerySchema>,
|
||||
): AIActionRunListFilters => ({
|
||||
...(query.actionId ? { actionId: splitCsv(query.actionId) as AIActionId[] } : {}),
|
||||
...(query.status ? { status: splitCsv(query.status) as AIActionRunStatus[] } : {}),
|
||||
...(query.outputTarget
|
||||
? { outputTarget: splitCsv(query.outputTarget) as AIActionOutputTarget[] }
|
||||
: {}),
|
||||
...(query.createdFrom ? { createdFrom: splitCsv(query.createdFrom) } : {}),
|
||||
...(query.cursor ? { cursor: query.cursor } : {}),
|
||||
});
|
||||
|
||||
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 sendActionNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
actionId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"AI_ACTION_NOT_FOUND",
|
||||
`AI action '${actionId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
const sendRunNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
runId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"AI_ACTION_RUN_NOT_FOUND",
|
||||
`AI action run '${runId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
export const registerAIActionRoutes = async (app: FastifyInstance) => {
|
||||
app.get("/api/projects/:projectId/ai-actions", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
const query = parseQuery(actionQuerySchema, request, reply);
|
||||
if (!query) return reply;
|
||||
|
||||
const filters = toActionFilters(query);
|
||||
const actions = sortByField(
|
||||
applyAIActionFilters(listAIActionsForProject(params.projectId), filters),
|
||||
query.sort,
|
||||
);
|
||||
|
||||
return successEnvelope(
|
||||
paginate(actions, toPaginationInput(query, filters)),
|
||||
getRequestId(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/ai-actions/runs", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
const query = parseQuery(runQuerySchema, request, reply);
|
||||
if (!query) return reply;
|
||||
|
||||
const filters = toRunFilters(query);
|
||||
const runs = sortByField(
|
||||
applyAIActionRunFilters(listAIActionRunsForProject(params.projectId), filters),
|
||||
query.sort,
|
||||
);
|
||||
|
||||
return successEnvelope(
|
||||
paginate(runs, toPaginationInput(query, filters)),
|
||||
getRequestId(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/api/projects/:projectId/ai-actions/runs/:runId",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(runParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
const run = findAIActionRun(params.projectId, params.runId);
|
||||
if (!run) return sendRunNotFound(request, reply, params.runId);
|
||||
return successEnvelope(run, getRequestId(request));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/projects/:projectId/ai-actions/:actionId/run",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(actionParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
||||
if (!idempotencyKey) return reply;
|
||||
const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey);
|
||||
const replay = getIdempotentResponse<unknown>(cacheKey);
|
||||
if (replay) return reply.status(replay.statusCode).send(replay.body);
|
||||
|
||||
const parsedBody = runBodySchema.safeParse(request.body);
|
||||
if (
|
||||
!parsedBody.success ||
|
||||
(parsedBody.data.objective !== undefined &&
|
||||
!parsedBody.data.objective.trim())
|
||||
) {
|
||||
return reply.status(400).send(
|
||||
errorEnvelope(
|
||||
"AI_ACTION_VALIDATION_FAILED",
|
||||
"AI action run body is invalid.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const action = findAIAction(params.projectId, params.actionId);
|
||||
if (!action) return sendActionNotFound(request, reply, params.actionId);
|
||||
|
||||
const body = successEnvelope(
|
||||
runAIAction(params.projectId, action, parsedBody.data),
|
||||
getRequestId(request),
|
||||
);
|
||||
setIdempotentResponse(cacheKey, { statusCode: 201, body });
|
||||
return reply.status(201).send(body);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { cancelProjectAnalysisJob, createPortfolioAnalysisJob, getProjectAnalysisJob, getProjectAnalysisJobResults, listProjectAnalysisJobs } from "../repositories/analysisJobRepository.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
|
||||
const projectParams = z.object({ projectId: z.string().min(1) });
|
||||
const jobParams = projectParams.extend({ jobId: z.string().min(1) });
|
||||
const startBody = z.object({
|
||||
repositoryIds: z.array(z.string().trim().min(1)).max(200).optional(),
|
||||
includeBranches: z.boolean().default(true),
|
||||
includePullRequests: z.boolean().default(true),
|
||||
includeActions: z.boolean().default(true),
|
||||
changedPaths: z.array(z.string().trim().min(1)).max(200).optional(),
|
||||
});
|
||||
const pageQuery = z.object({
|
||||
cursor: z.string().regex(/^\d+$/).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(10).default(5),
|
||||
characterBudget: z.coerce.number().int().min(4000).max(32000).default(24000),
|
||||
});
|
||||
|
||||
const requireOwner = (request: FastifyRequest) => request.authUser?.role === "owner";
|
||||
const analysisError = (error: unknown, request: FastifyRequest, reply: FastifyReply) => {
|
||||
const code = error instanceof Error ? error.message : "ANALYSIS_JOB_FAILED";
|
||||
const status = code.endsWith("NOT_FOUND") ? 404 : code.endsWith("LIMIT_EXCEEDED") ? 422 : 409;
|
||||
return reply.status(status).send(errorEnvelope(code, code === "ANALYSIS_REPOSITORIES_NOT_FOUND" ? "No linked repositories match this analysis scope." : "The repository analysis request could not be completed.", getRequestId(request)));
|
||||
};
|
||||
|
||||
export const registerAnalysisJobRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => {
|
||||
app.get("/api/projects/:projectId/analysis-jobs", async (request) => {
|
||||
const { projectId } = projectParams.parse(request.params);
|
||||
const limit = z.coerce.number().int().min(1).max(25).default(10).parse((request.query as { limit?: unknown }).limit);
|
||||
return successEnvelope({ items: await listProjectAnalysisJobs(projectId, limit) }, getRequestId(request));
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/analysis-jobs", async (request, reply) => {
|
||||
if (!requireOwner(request)) return reply.status(403).send(errorEnvelope("ANALYSIS_JOB_OWNER_REQUIRED", "Only the project owner can start server-side analyses.", getRequestId(request)));
|
||||
const { projectId } = projectParams.parse(request.params);
|
||||
const body = startBody.parse(request.body ?? {});
|
||||
try {
|
||||
const result = await createPortfolioAnalysisJob(options.env, `web:${request.authUser!.username}`, {
|
||||
projectIds: [projectId],
|
||||
repositoryIds: body.repositoryIds,
|
||||
includeBranches: body.includeBranches,
|
||||
includePullRequests: body.includePullRequests,
|
||||
includeActions: body.includeActions,
|
||||
changedPaths: body.changedPaths,
|
||||
});
|
||||
return reply.status(result.deduplicated ? 200 : 202).send(successEnvelope(result, getRequestId(request)));
|
||||
} catch (error) {
|
||||
return analysisError(error, request, reply);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/analysis-jobs/:jobId", async (request, reply) => {
|
||||
const { projectId, jobId } = jobParams.parse(request.params);
|
||||
try {
|
||||
return successEnvelope({ job: await getProjectAnalysisJob(projectId, jobId) }, getRequestId(request));
|
||||
} catch (error) {
|
||||
return analysisError(error, request, reply);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/analysis-jobs/:jobId/results", async (request, reply) => {
|
||||
const { projectId, jobId } = jobParams.parse(request.params);
|
||||
const query = pageQuery.parse(request.query ?? {});
|
||||
try {
|
||||
return successEnvelope(await getProjectAnalysisJobResults(projectId, jobId, { cursor: query.cursor, pageSize: query.pageSize, characterBudget: query.characterBudget }), getRequestId(request));
|
||||
} catch (error) {
|
||||
return analysisError(error, request, reply);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/analysis-jobs/:jobId/cancel", async (request, reply) => {
|
||||
if (!requireOwner(request)) return reply.status(403).send(errorEnvelope("ANALYSIS_JOB_OWNER_REQUIRED", "Only the project owner can cancel server-side analyses.", getRequestId(request)));
|
||||
const { projectId, jobId } = jobParams.parse(request.params);
|
||||
try {
|
||||
return successEnvelope({ job: await cancelProjectAnalysisJob(projectId, jobId) }, getRequestId(request));
|
||||
} catch (error) {
|
||||
return analysisError(error, request, reply);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { apiPaths, loginCredentialsSchema, rotatePasswordSchema } from "@projectbrain/contracts";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { authenticate, createSession, revokeSession, rotatePassword } from "../security/authStore.js";
|
||||
import { SESSION_COOKIE, sessionCookieOptions } from "../plugins/authentication.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
|
||||
export const registerAuthRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => {
|
||||
const { env } = options;
|
||||
app.post(apiPaths.auth.login, { config: { rateLimit: { max: 5, timeWindow: "1 minute" } } }, async (request, reply) => {
|
||||
const parsed = loginCredentialsSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
request.log.warn({ requestId: getRequestId(request), invalidFields: parsed.error.issues.map((issue) => issue.path.join(".")) }, "Authentication request validation failed");
|
||||
return reply.status(400).send(errorEnvelope("AUTH_VALIDATION_FAILED", "Valid credentials are required", getRequestId(request)));
|
||||
}
|
||||
const user = await authenticate(env, parsed.data.username, parsed.data.password);
|
||||
if (!user) {
|
||||
request.log.warn({ requestId: getRequestId(request), code: "INVALID_CREDENTIALS" }, "Authentication failed");
|
||||
return reply.status(401).send(errorEnvelope("INVALID_CREDENTIALS", "Invalid username or password", getRequestId(request)));
|
||||
}
|
||||
const session = await createSession(env, user);
|
||||
request.log.info({ requestId: getRequestId(request), userId: user.id, role: user.role }, "Authentication succeeded");
|
||||
reply.setCookie(SESSION_COOKIE, session.token, sessionCookieOptions(env));
|
||||
return successEnvelope({ user: { id: user.id, username: user.username, role: user.role }, csrfToken: session.csrfToken, expiresAt: session.expiresAt.toISOString() }, getRequestId(request));
|
||||
});
|
||||
|
||||
app.get(apiPaths.auth.session, async (request) => successEnvelope({ user: request.authUser, csrfToken: request.authCsrfToken }, getRequestId(request)));
|
||||
|
||||
app.post(apiPaths.auth.logout, async (request, reply) => {
|
||||
const signedToken = request.cookies[SESSION_COOKIE];
|
||||
const unsigned = signedToken ? request.unsignCookie(signedToken) : undefined;
|
||||
const token = unsigned?.valid ? unsigned.value : undefined;
|
||||
if (token) await revokeSession(env, token);
|
||||
reply.clearCookie(SESSION_COOKIE, { path: "/api" });
|
||||
return successEnvelope({ loggedOut: true }, getRequestId(request));
|
||||
});
|
||||
|
||||
app.post(apiPaths.auth.rotatePassword, async (request, reply) => {
|
||||
const parsed = rotatePasswordSchema.safeParse(request.body);
|
||||
if (!parsed.success || !request.authUser) return reply.status(400).send(errorEnvelope("PASSWORD_ROTATION_INVALID", "Valid current and new passwords are required", getRequestId(request)));
|
||||
const rotated = await rotatePassword(env, request.authUser, parsed.data.currentPassword, parsed.data.newPassword);
|
||||
if (!rotated) return reply.status(403).send(errorEnvelope("CURRENT_PASSWORD_INVALID", "Current password is invalid", getRequestId(request)));
|
||||
reply.clearCookie(SESSION_COOKIE, { path: "/api" });
|
||||
return successEnvelope({ rotated: true, loginRequired: true }, getRequestId(request));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { autonomyProfileSchema } from "@projectbrain/contracts";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { configureAutonomy, getAutonomyState, importAutonomyRoadmap, reportAutonomyWork, resumeAutonomy } from "../repositories/autonomyRepository.js";
|
||||
import { enqueueAutonomyTask } from "../services/autonomyAssignments.js";
|
||||
import { claimAutonomyWork } from "../services/autonomyRuntimeControl.js";
|
||||
import { reconcileRepositoryAutonomyRoadmap } from "../services/repositoryAutonomy.js";
|
||||
import { syncGitSources } from "../services/gitSourceSync.js";
|
||||
import { reconcileGiteaAutonomyExecution } from "../services/giteaAutonomyExecution.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
|
||||
const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-]+$/);
|
||||
const paramsSchema = z.object({ projectId: idSchema });
|
||||
const configSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
profile: autonomyProfileSchema.optional(),
|
||||
autoBrainstorm: z.boolean().optional(),
|
||||
maxBrainstormIterations: z.number().int().min(1).max(10).optional(),
|
||||
maxConsecutiveFailures: z.number().int().min(1).max(10).optional(),
|
||||
leaseMinutes: z.number().int().min(5).max(120).optional(),
|
||||
maxWorkItemsPerRun: z.number().int().min(1).max(20).optional(),
|
||||
maxRunMinutes: z.number().int().min(5).max(55).optional(),
|
||||
maxRecoveryMinutes: z.number().int().min(15).max(1_440).optional(),
|
||||
pauseAfterCurrent: z.boolean().optional(),
|
||||
schedulingPolicy: z.enum(["balanced", "downstream-impact"]).optional(),
|
||||
}).refine((value) => Object.keys(value).length > 0, "At least one Auto Mode setting is required.");
|
||||
const roadmapSchema = z.object({ title: z.string().trim().min(2).max(200).optional(), markdown: z.string().trim().min(3).max(100_000) });
|
||||
const taskSchema = z.object({ title: z.string().trim().min(2).max(200).optional(), instruction: z.string().trim().min(3).max(1_200) });
|
||||
const claimSchema = z.object({
|
||||
runId: z.string().trim().min(1).max(160).optional(),
|
||||
}).default({});
|
||||
// A browser caller may provide a scheduled-run identifier, but not the
|
||||
// identity that owns a durable Auto Mode lease or its audit events.
|
||||
export const parseAutonomyClaimInput = (input: unknown) => claimSchema.parse(input);
|
||||
const reportSchema = z.object({
|
||||
token: z.string().uuid(),
|
||||
outcome: z.enum(["success", "failed", "blocked"]),
|
||||
summary: z.string().trim().min(3).max(12_000),
|
||||
blockerType: z.enum(["human", "retryable"]).optional(),
|
||||
retryAfterMinutes: z.number().int().min(5).max(1_440).optional(),
|
||||
deliveryId: z.string().trim().min(1).max(240).optional(),
|
||||
discoveredTasks: z.array(z.string().trim().min(2).max(1_000)).max(100).optional(),
|
||||
brainstorm: z.object({
|
||||
contextSummary: z.string().trim().min(3).max(8_000).optional(),
|
||||
ideas: z.array(z.object({ id: z.string().min(1).max(160), title: z.string().min(1).max(300), description: z.string().min(1).max(4_000), score: z.number().min(0).max(100).optional() })).max(50).optional(),
|
||||
outcomes: z.array(z.string().min(1).max(2_000)).max(50).optional(),
|
||||
selectedIdeaIds: z.array(z.string().min(1).max(160)).max(50).optional(),
|
||||
nextActions: z.array(z.string().min(2).max(1_000)).max(100).optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
const sendValidationError = (request: FastifyRequest, reply: FastifyReply, issues: z.ZodIssue[]) =>
|
||||
reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "Request validation failed", getRequestId(request), issues.map((issue) => ({ field: issue.path.join("."), message: issue.message, code: issue.code }))));
|
||||
|
||||
const params = (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const parsed = paramsSchema.safeParse(request.params);
|
||||
if (!parsed.success) { sendValidationError(request, reply, parsed.error.issues); return undefined; }
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
const requireDatabase = (dataSource: AppEnv["PROJECTBRAIN_DATA_SOURCE"], request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (dataSource === "database") return false;
|
||||
reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Auto Mode requires database mode because its roadmap and loop state must be durable.", getRequestId(request)));
|
||||
return true;
|
||||
};
|
||||
|
||||
const actorFor = (request: FastifyRequest, fallback = "owner") => request.authUser?.username ?? fallback;
|
||||
|
||||
const sendAutonomyError = (request: FastifyRequest, reply: FastifyReply, error: unknown) => {
|
||||
const code = error instanceof Error ? error.message : "AUTO_MODE_OPERATION_FAILED";
|
||||
const conflictCodes = ["AUTO_MODE_DISABLED", "AUTO_MODE_ROADMAP_REQUIRED", "AUTO_MODE_LEASE_STALE", "AUTO_MODE_LEASE_OWNER_MISMATCH", "AUTO_MODE_ROADMAP_CHANGED", "AUTO_MODE_CONFLICT", "AUTO_MODE_DELIVERY_REQUIRED", "AUTO_MODE_DELIVERY_NOT_MERGED", "AUTO_MODE_DELIVERY_ALREADY_LINKED"];
|
||||
const status = code === "PROJECT_NOT_FOUND" ? 404 : code === "AUTO_MODE_WORK_ACTIVE" || conflictCodes.includes(code) ? 409 : 400;
|
||||
const messages: Record<string, string> = {
|
||||
PROJECT_NOT_FOUND: "Project not found.",
|
||||
AUTO_MODE_DISABLED: "Auto Mode is disabled for this project.",
|
||||
AUTO_MODE_WORK_ACTIVE: "Er wordt nog aan deze roadmap gewerkt. Wacht tot de huidige uitvoering klaar is of pauzeer Auto voordat je de roadmap vervangt.",
|
||||
AUTO_MODE_ROADMAP_REQUIRED: "Import a roadmap before claiming autonomous work.",
|
||||
AUTO_MODE_LEASE_STALE: "This autonomous work lease is no longer active. Claim the current work item again.",
|
||||
AUTO_MODE_LEASE_OWNER_MISMATCH: "This autonomous work lease belongs to a different authenticated executor.",
|
||||
AUTO_MODE_ROADMAP_CHANGED: "The roadmap changed while this work item was active. Claim a fresh work item.",
|
||||
AUTO_MODE_CONFLICT: "Auto Mode state changed concurrently. Reload the state and retry the operation.",
|
||||
AUTO_MODE_DELIVERY_NOT_MERGED: "Implementation is unfinished. Complete exact-head validation and merge on the canonical Gitea execution branch before reporting success.",
|
||||
AUTO_MODE_DELIVERY_REQUIRED: "Implementation success requires reconciled Gitea validated-merge evidence or a backward-compatible ProjectBrain delivery record.",
|
||||
AUTO_MODE_DELIVERY_ALREADY_LINKED: "This work item already owns a delivery. Resume it instead of replacing it.",
|
||||
};
|
||||
return reply.status(status).send(errorEnvelope(code, messages[code] ?? "Auto Mode operation failed.", getRequestId(request)));
|
||||
};
|
||||
|
||||
export const registerAutonomyRoutes = async (app: FastifyInstance, options: { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"]; env?: AppEnv } = {}) => {
|
||||
const dataSource = options.dataSource ?? "fixture";
|
||||
const env = options.env;
|
||||
|
||||
app.get("/api/projects/:projectId/autonomy", async (request, reply) => {
|
||||
const parsed = params(request, reply); if (!parsed) return reply;
|
||||
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||
try {
|
||||
if (env) {
|
||||
try {
|
||||
return successEnvelope((await reconcileGiteaAutonomyExecution(env, parsed.projectId, "system:autonomy-read")).state, getRequestId(request));
|
||||
} catch (error) {
|
||||
request.log.warn({ error }, "Gitea Auto execution reconciliation failed during state read; returning durable controller state");
|
||||
}
|
||||
}
|
||||
return successEnvelope(await getAutonomyState(parsed.projectId), getRequestId(request));
|
||||
}
|
||||
catch (error) { return sendAutonomyError(request, reply, error); }
|
||||
});
|
||||
|
||||
app.patch("/api/projects/:projectId/autonomy", async (request, reply) => {
|
||||
const parsed = params(request, reply); if (!parsed) return reply;
|
||||
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||
const body = configSchema.safeParse(request.body ?? {}); if (!body.success) return sendValidationError(request, reply, body.error.issues);
|
||||
try {
|
||||
let state = await configureAutonomy(parsed.projectId, body.data, actorFor(request));
|
||||
if (state.enabled) {
|
||||
if (env) {
|
||||
try {
|
||||
const sourceSync = await syncGitSources(env, { projectId: parsed.projectId });
|
||||
if (sourceSync.errors.length) request.log.warn({ errors: sourceSync.errors }, "Auto Mode repository source refresh completed with warnings");
|
||||
} catch (error) {
|
||||
request.log.warn({ error }, "Auto Mode repository source refresh failed; continuing with durable repository state");
|
||||
}
|
||||
}
|
||||
state = (await reconcileRepositoryAutonomyRoadmap(parsed.projectId, actorFor(request))).state;
|
||||
}
|
||||
return successEnvelope(state, getRequestId(request));
|
||||
}
|
||||
catch (error) { return sendAutonomyError(request, reply, error); }
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/autonomy/roadmap", async (request, reply) => {
|
||||
const parsed = params(request, reply); if (!parsed) return reply;
|
||||
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||
const body = roadmapSchema.safeParse(request.body ?? {}); if (!body.success) return sendValidationError(request, reply, body.error.issues);
|
||||
try { return reply.status(201).send(successEnvelope(await importAutonomyRoadmap(parsed.projectId, body.data, actorFor(request)), getRequestId(request))); }
|
||||
catch (error) { return sendAutonomyError(request, reply, error); }
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/autonomy/tasks", async (request, reply) => {
|
||||
const parsed = params(request, reply); if (!parsed) return reply;
|
||||
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||
const body = taskSchema.safeParse(request.body ?? {}); if (!body.success) return sendValidationError(request, reply, body.error.issues);
|
||||
try { return reply.status(201).send(successEnvelope(await enqueueAutonomyTask(parsed.projectId, body.data, actorFor(request)), getRequestId(request))); }
|
||||
catch (error) { return sendAutonomyError(request, reply, error); }
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/autonomy/resume", async (request, reply) => {
|
||||
const parsed = params(request, reply); if (!parsed) return reply;
|
||||
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||
try { return successEnvelope(await resumeAutonomy(parsed.projectId, actorFor(request)), getRequestId(request)); }
|
||||
catch (error) { return sendAutonomyError(request, reply, error); }
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/autonomy/claim", async (request, reply) => {
|
||||
const parsed = params(request, reply); if (!parsed) return reply;
|
||||
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||
const body = claimSchema.safeParse(request.body ?? {}); if (!body.success) return sendValidationError(request, reply, body.error.issues);
|
||||
try {
|
||||
if (env) {
|
||||
const beforeClaim = await getAutonomyState(parsed.projectId);
|
||||
const shouldRefresh = !body.data.runId || !beforeClaim.runs.some((run) => run.id === body.data.runId);
|
||||
if (shouldRefresh) {
|
||||
try {
|
||||
const sourceSync = await syncGitSources(env, { projectId: parsed.projectId });
|
||||
if (sourceSync.errors.length) request.log.warn({ errors: sourceSync.errors }, "Auto Mode repository source refresh completed with warnings");
|
||||
} catch (error) {
|
||||
request.log.warn({ error }, "Auto Mode repository source refresh failed; continuing with durable repository state");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (env) {
|
||||
try {
|
||||
await reconcileGiteaAutonomyExecution(env, parsed.projectId, "system:autonomy-claim");
|
||||
} catch (error) {
|
||||
request.log.warn({ error }, "Gitea Auto execution reconciliation failed before claim; continuing with durable controller state");
|
||||
}
|
||||
}
|
||||
return successEnvelope(await claimAutonomyWork(parsed.projectId, actorFor(request, "projectbrain-agent"), body.data.runId), getRequestId(request));
|
||||
}
|
||||
catch (error) { return sendAutonomyError(request, reply, error); }
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/autonomy/report", async (request, reply) => {
|
||||
const parsed = params(request, reply); if (!parsed) return reply;
|
||||
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||
const body = reportSchema.safeParse(request.body ?? {}); if (!body.success) return sendValidationError(request, reply, body.error.issues);
|
||||
try {
|
||||
if (env) {
|
||||
try {
|
||||
await reconcileGiteaAutonomyExecution(env, parsed.projectId, "system:autonomy-report");
|
||||
} catch (error) {
|
||||
request.log.warn({ error }, "Gitea Auto execution reconciliation failed before compatibility report");
|
||||
}
|
||||
}
|
||||
return successEnvelope(await reportAutonomyWork(parsed.projectId, body.data, actorFor(request, "projectbrain-agent")), getRequestId(request));
|
||||
}
|
||||
catch (error) { return sendAutonomyError(request, reply, error); }
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { contextPackFixtures } from "../fixtures/contextPackFixtures.js";
|
||||
import { findProject } from "../fixtures/projectFixtures.js";
|
||||
import {
|
||||
createBlueprintNodeRunPreviewDto,
|
||||
createBlueprintPlanDto,
|
||||
type BlueprintDepthDto,
|
||||
} from "../services/blueprintEngine.js";
|
||||
import {
|
||||
createAndPersistBlueprintPlan,
|
||||
getBlueprintImpactReport,
|
||||
getBlueprintPlanById,
|
||||
getBlueprintRepoExportBundle,
|
||||
getLatestBlueprintPlan,
|
||||
regenerateBlueprintNode,
|
||||
regenerateStaleBlueprintNodes,
|
||||
runBlueprintNode,
|
||||
} from "../repositories/blueprintsRepository.js";
|
||||
import { successEnvelope, errorEnvelope } from "../utils/envelope.js";
|
||||
import { createBlueprintRepoExportBundleDto, createBlueprintRepoExportZipBufferDto } from "../services/blueprintEngine.js";
|
||||
|
||||
const depthSchema = z.enum(["auto", "light", "standard", "deep", "full"]).default("auto");
|
||||
const planBodySchema = z.object({ depth: depthSchema.optional(), persist: z.boolean().optional() }).optional();
|
||||
const nodeRunBodySchema = z.object({
|
||||
createDocumentation: z.boolean().default(true),
|
||||
sendToReview: z.boolean().default(true),
|
||||
}).optional();
|
||||
const exportQuerySchema = z.object({ includeDrafts: z.coerce.boolean().default(false) });
|
||||
const regenerateBodySchema = z.object({ limit: z.number().int().positive().max(50).optional(), createDocumentation: z.boolean().default(true), sendToReview: z.boolean().default(true) }).optional();
|
||||
|
||||
type BlueprintRouteOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"] };
|
||||
|
||||
const requestId = (request: FastifyRequest) => request.id;
|
||||
const toDepth = (depth?: "auto" | BlueprintDepthDto): BlueprintDepthDto | undefined => depth === "auto" || !depth ? undefined : depth;
|
||||
|
||||
const notFound = (reply: FastifyReply, request: FastifyRequest, message: string) =>
|
||||
reply.status(404).send(errorEnvelope("BLUEPRINT_NOT_FOUND", message, requestId(request)));
|
||||
|
||||
export const registerBlueprintRoutes = async (app: FastifyInstance, options: BlueprintRouteOptions = {}) => {
|
||||
const dataSource = options.dataSource ?? "fixture";
|
||||
|
||||
app.get<{ Params: { projectId: string } }>("/api/projects/:projectId/blueprints/latest", async (request, reply) => {
|
||||
if (dataSource !== "database") {
|
||||
const project = findProject(request.params.projectId);
|
||||
if (!project) return notFound(reply, request, `Project '${request.params.projectId}' was not found.`);
|
||||
const plan = createBlueprintPlanDto(project, contextPackFixtures, undefined);
|
||||
return successEnvelope(plan, requestId(request));
|
||||
}
|
||||
|
||||
const latest = await getLatestBlueprintPlan(request.params.projectId);
|
||||
if (!latest) return notFound(reply, request, `No persisted blueprint plan was found for project '${request.params.projectId}'.`);
|
||||
return successEnvelope(latest, requestId(request));
|
||||
});
|
||||
|
||||
app.get<{ Params: { projectId: string; graphId: string } }>("/api/projects/:projectId/blueprints/:graphId", async (request, reply) => {
|
||||
if (dataSource !== "database") {
|
||||
const project = findProject(request.params.projectId);
|
||||
if (!project) return notFound(reply, request, `Project '${request.params.projectId}' was not found.`);
|
||||
const plan = createBlueprintPlanDto(project, contextPackFixtures, undefined);
|
||||
if (plan.id !== request.params.graphId) return notFound(reply, request, `Blueprint '${request.params.graphId}' was not found in fixture mode.`);
|
||||
return successEnvelope(plan, requestId(request));
|
||||
}
|
||||
|
||||
const plan = await getBlueprintPlanById(request.params.projectId, request.params.graphId);
|
||||
if (!plan) return notFound(reply, request, `Blueprint '${request.params.graphId}' was not found.`);
|
||||
return successEnvelope(plan, requestId(request));
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Params: { projectId: string };
|
||||
Body: { depth?: BlueprintDepthDto | "auto"; persist?: boolean };
|
||||
}>("/api/projects/:projectId/blueprints/plan", async (request, reply) => {
|
||||
const parsed = planBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) return reply.status(400).send(errorEnvelope("BLUEPRINT_VALIDATION_FAILED", "Invalid blueprint plan request.", requestId(request)));
|
||||
const requestedDepth = toDepth(parsed.data?.depth);
|
||||
|
||||
if (dataSource === "database" && parsed.data?.persist !== false) {
|
||||
const plan = await createAndPersistBlueprintPlan(request.params.projectId, requestedDepth);
|
||||
if (!plan) return notFound(reply, request, `Project '${request.params.projectId}' was not found.`);
|
||||
return reply.status(201).send(successEnvelope(plan, requestId(request)));
|
||||
}
|
||||
|
||||
const project = findProject(request.params.projectId);
|
||||
if (!project) {
|
||||
return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${request.params.projectId}' was not found.`, requestId(request)));
|
||||
}
|
||||
|
||||
const plan = createBlueprintPlanDto(project, contextPackFixtures, requestedDepth);
|
||||
return successEnvelope(plan, requestId(request));
|
||||
});
|
||||
|
||||
|
||||
app.get<{
|
||||
Params: { projectId: string; graphId: string };
|
||||
Querystring: { includeDrafts?: boolean };
|
||||
}>("/api/projects/:projectId/blueprints/:graphId/export", async (request, reply) => {
|
||||
const parsed = exportQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) return reply.status(400).send(errorEnvelope("BLUEPRINT_EXPORT_VALIDATION_FAILED", "Invalid blueprint export request.", requestId(request)));
|
||||
|
||||
if (dataSource !== "database") {
|
||||
const project = findProject(request.params.projectId);
|
||||
if (!project) return notFound(reply, request, `Project '${request.params.projectId}' was not found.`);
|
||||
const plan = createBlueprintPlanDto(project, contextPackFixtures, undefined);
|
||||
if (plan.id !== request.params.graphId) return notFound(reply, request, `Blueprint '${request.params.graphId}' was not found in fixture mode.`);
|
||||
const { createBlueprintRepoExportBundleDto } = await import("../services/blueprintEngine.js");
|
||||
return successEnvelope(createBlueprintRepoExportBundleDto(plan, { includeDrafts: parsed.data.includeDrafts }), requestId(request));
|
||||
}
|
||||
|
||||
const bundle = await getBlueprintRepoExportBundle(request.params.projectId, request.params.graphId, { includeDrafts: parsed.data.includeDrafts });
|
||||
if (!bundle) return notFound(reply, request, `Blueprint export '${request.params.graphId}' could not be prepared.`);
|
||||
return successEnvelope(bundle, requestId(request));
|
||||
});
|
||||
|
||||
|
||||
|
||||
app.get<{
|
||||
Params: { projectId: string; graphId: string };
|
||||
Querystring: { includeDrafts?: boolean };
|
||||
}>("/api/projects/:projectId/blueprints/:graphId/export.zip", async (request, reply) => {
|
||||
const parsed = exportQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) return reply.status(400).send(errorEnvelope("BLUEPRINT_EXPORT_VALIDATION_FAILED", "Invalid blueprint zip export request.", requestId(request)));
|
||||
|
||||
let bundle: ReturnType<typeof createBlueprintRepoExportBundleDto> | undefined;
|
||||
if (dataSource !== "database") {
|
||||
const project = findProject(request.params.projectId);
|
||||
if (!project) return notFound(reply, request, `Project '${request.params.projectId}' was not found.`);
|
||||
const plan = createBlueprintPlanDto(project, contextPackFixtures, undefined);
|
||||
if (plan.id !== request.params.graphId) return notFound(reply, request, `Blueprint '${request.params.graphId}' was not found in fixture mode.`);
|
||||
bundle = createBlueprintRepoExportBundleDto(plan, { includeDrafts: parsed.data.includeDrafts });
|
||||
} else {
|
||||
bundle = await getBlueprintRepoExportBundle(request.params.projectId, request.params.graphId, { includeDrafts: parsed.data.includeDrafts });
|
||||
}
|
||||
if (!bundle) return notFound(reply, request, `Blueprint export '${request.params.graphId}' could not be prepared.`);
|
||||
|
||||
const zipBuffer = createBlueprintRepoExportZipBufferDto(bundle);
|
||||
reply
|
||||
.header("Content-Type", "application/zip")
|
||||
.header("Content-Disposition", `attachment; filename="projectbrain-blueprint-${request.params.graphId}.zip"`)
|
||||
.header("Content-Length", zipBuffer.length)
|
||||
.send(zipBuffer);
|
||||
});
|
||||
|
||||
|
||||
app.post<{
|
||||
Params: { projectId: string; graphId: string; nodeId: string };
|
||||
Body: { createDocumentation?: boolean; sendToReview?: boolean };
|
||||
}>("/api/projects/:projectId/blueprints/:graphId/nodes/:nodeId/regenerate", async (request, reply) => {
|
||||
const parsed = nodeRunBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) return reply.status(400).send(errorEnvelope("BLUEPRINT_NODE_REGENERATION_VALIDATION_FAILED", "Invalid blueprint node regeneration request.", requestId(request)));
|
||||
|
||||
if (dataSource !== "database") {
|
||||
const project = findProject(request.params.projectId);
|
||||
if (!project) return notFound(reply, request, `Project '${request.params.projectId}' was not found.`);
|
||||
const plan = createBlueprintPlanDto(project, contextPackFixtures, undefined);
|
||||
const node = plan.nodes.find((candidate) => candidate.id === request.params.nodeId);
|
||||
if (!node) return notFound(reply, request, `Blueprint node '${request.params.nodeId}' was not found.`);
|
||||
return successEnvelope({ graphId: plan.id, nodeId: node.id, previousStatus: node.status, run: createBlueprintNodeRunPreviewDto(plan, node), message: "Local preview generated; database mode is required to persist regeneration." }, requestId(request));
|
||||
}
|
||||
|
||||
const result = await regenerateBlueprintNode(request.params.projectId, request.params.graphId, request.params.nodeId, parsed.data ?? {});
|
||||
if (!result) return notFound(reply, request, `Blueprint node '${request.params.nodeId}' could not be regenerated.`);
|
||||
return reply.status(201).send(successEnvelope(result, requestId(request)));
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Params: { projectId: string; graphId: string };
|
||||
Body: { limit?: number; createDocumentation?: boolean; sendToReview?: boolean };
|
||||
}>("/api/projects/:projectId/blueprints/:graphId/regenerate-stale", async (request, reply) => {
|
||||
const parsed = regenerateBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) return reply.status(400).send(errorEnvelope("BLUEPRINT_REGENERATION_VALIDATION_FAILED", "Invalid blueprint stale regeneration request.", requestId(request)));
|
||||
|
||||
if (dataSource !== "database") {
|
||||
return reply.status(409).send(errorEnvelope("BLUEPRINT_REGENERATION_REQUIRES_DATABASE", "Selective stale-node regeneration requires database mode so generated drafts can be review-gated.", requestId(request)));
|
||||
}
|
||||
|
||||
const result = await regenerateStaleBlueprintNodes(request.params.projectId, request.params.graphId, parsed.data ?? {});
|
||||
if (!result) return notFound(reply, request, `Blueprint '${request.params.graphId}' was not found.`);
|
||||
return successEnvelope(result, requestId(request));
|
||||
});
|
||||
|
||||
app.get<{ Params: { projectId: string; graphId: string; nodeId: string } }>("/api/projects/:projectId/blueprints/:graphId/nodes/:nodeId/impact", async (request, reply) => {
|
||||
if (dataSource !== "database") {
|
||||
const project = findProject(request.params.projectId);
|
||||
if (!project) return notFound(reply, request, `Project '${request.params.projectId}' was not found.`);
|
||||
const plan = createBlueprintPlanDto(project, contextPackFixtures, undefined);
|
||||
const { createBlueprintImpactReportDto } = await import("../services/blueprintEngine.js");
|
||||
const report = createBlueprintImpactReportDto(plan, request.params.nodeId);
|
||||
return successEnvelope(report, requestId(request));
|
||||
}
|
||||
const report = await getBlueprintImpactReport(request.params.projectId, request.params.graphId, request.params.nodeId);
|
||||
if (!report) return notFound(reply, request, `Blueprint impact report for '${request.params.nodeId}' could not be prepared.`);
|
||||
return successEnvelope(report, requestId(request));
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Params: { projectId: string; graphId: string; nodeId: string };
|
||||
Body: { createDocumentation?: boolean; sendToReview?: boolean };
|
||||
}>("/api/projects/:projectId/blueprints/:graphId/nodes/:nodeId/runs", async (request, reply) => {
|
||||
const parsed = nodeRunBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) return reply.status(400).send(errorEnvelope("BLUEPRINT_RUN_VALIDATION_FAILED", "Invalid blueprint node run request.", requestId(request)));
|
||||
|
||||
if (dataSource !== "database") {
|
||||
const project = findProject(request.params.projectId);
|
||||
if (!project) return notFound(reply, request, `Project '${request.params.projectId}' was not found.`);
|
||||
const plan = createBlueprintPlanDto(project, contextPackFixtures, undefined);
|
||||
const node = plan.nodes.find((candidate) => candidate.id === request.params.nodeId);
|
||||
if (!node) return notFound(reply, request, `Blueprint node '${request.params.nodeId}' was not found.`);
|
||||
return successEnvelope(createBlueprintNodeRunPreviewDto(plan, node), requestId(request));
|
||||
}
|
||||
|
||||
const run = await runBlueprintNode(request.params.projectId, request.params.graphId, request.params.nodeId, parsed.data ?? {});
|
||||
if (!run) return notFound(reply, request, `Blueprint node '${request.params.nodeId}' could not be run.`);
|
||||
return reply.status(201).send(successEnvelope(run, requestId(request)));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { convertBrainstormForProject, createBrainstormForProject, listBrainstormsForProject, updateBrainstormForProject } from "../repositories/brainstormRepository.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import { getIdempotencyKey, getIdempotentResponse, setIdempotentResponse } from "../utils/idempotency.js";
|
||||
|
||||
type BrainstormRouteOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"] };
|
||||
const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-]+$/);
|
||||
const projectParamsSchema = z.object({ projectId: idSchema });
|
||||
const brainstormParamsSchema = z.object({ projectId: idSchema, brainstormId: idSchema });
|
||||
const bodySchema = z.object({
|
||||
title: z.string().min(2).max(200),
|
||||
prompt: z.string().min(1).max(8000),
|
||||
scope: z.string().min(1).max(80),
|
||||
mode: z.string().min(1).max(80),
|
||||
status: z.string().max(80).optional(),
|
||||
targetEntityId: z.string().max(200).optional(),
|
||||
targetEntityType: z.string().max(80).optional(),
|
||||
generatedBy: z.string().max(80).optional(),
|
||||
contextSummary: z.string().max(1000).optional(),
|
||||
ideas: z.array(z.unknown()).optional(),
|
||||
clusters: z.array(z.unknown()).optional(),
|
||||
outcomes: z.array(z.unknown()).optional(),
|
||||
selectedIdeaIds: z.array(z.string()).optional(),
|
||||
nextActions: z.array(z.string()).optional(),
|
||||
});
|
||||
const patchSchema = bodySchema.partial();
|
||||
const conversionTargets = ["feature", "documentation", "context_pack", "blueprint_node", "initiative"] as const;
|
||||
const convertParamsSchema = z.object({ projectId: idSchema, brainstormId: idSchema });
|
||||
const convertBodySchema = z.object({ target: z.enum(conversionTargets) });
|
||||
|
||||
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 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; };
|
||||
|
||||
export const registerBrainstormRoutes = async (app: FastifyInstance, options: BrainstormRouteOptions = {}) => {
|
||||
const dataSource = options.dataSource ?? "fixture";
|
||||
|
||||
app.get("/api/projects/:projectId/brainstorms", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply;
|
||||
if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Brainstorm persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request)));
|
||||
return successEnvelope({ items: await listBrainstormsForProject(params.projectId) }, getRequestId(request));
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/brainstorms", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply;
|
||||
if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Brainstorm persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request)));
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply;
|
||||
const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey);
|
||||
const replay = getIdempotentResponse<unknown>(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body);
|
||||
const parsed = bodySchema.safeParse(request.body); if (!parsed.success) return sendValidationError(request, reply, parsed.error.issues);
|
||||
const body = successEnvelope(await createBrainstormForProject(params.projectId, parsed.data), getRequestId(request));
|
||||
setIdempotentResponse(cacheKey, { statusCode: 201, body });
|
||||
return reply.status(201).send(body);
|
||||
});
|
||||
|
||||
app.patch("/api/projects/:projectId/brainstorms/:brainstormId", async (request, reply) => {
|
||||
const params = parseParams(brainstormParamsSchema, request, reply); if (!params) return reply;
|
||||
if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Brainstorm persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request)));
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply;
|
||||
const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey);
|
||||
const replay = getIdempotentResponse<unknown>(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body);
|
||||
const parsed = patchSchema.safeParse(request.body); if (!parsed.success) return sendValidationError(request, reply, parsed.error.issues);
|
||||
const item = await updateBrainstormForProject(params.projectId, params.brainstormId, parsed.data);
|
||||
if (!item) return reply.status(404).send(errorEnvelope("BRAINSTORM_NOT_FOUND", `Brainstorm '${params.brainstormId}' was not found.`, getRequestId(request)));
|
||||
const body = successEnvelope(item, getRequestId(request));
|
||||
setIdempotentResponse(cacheKey, { statusCode: 200, body });
|
||||
return reply.status(200).send(body);
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/brainstorms/:brainstormId/convert", async (request, reply) => {
|
||||
const params = parseParams(convertParamsSchema, request, reply); if (!params) return reply;
|
||||
if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Brainstorm conversion persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request)));
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply;
|
||||
const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey);
|
||||
const replay = getIdempotentResponse<unknown>(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body);
|
||||
const parsed = convertBodySchema.safeParse(request.body); if (!parsed.success) return sendValidationError(request, reply, parsed.error.issues);
|
||||
const result = await convertBrainstormForProject(params.projectId, params.brainstormId, parsed.data.target);
|
||||
if (!result) return reply.status(404).send(errorEnvelope("BRAINSTORM_NOT_FOUND", `Brainstorm '${params.brainstormId}' was not found.`, getRequestId(request)));
|
||||
const body = successEnvelope(result, getRequestId(request));
|
||||
setIdempotentResponse(cacheKey, { statusCode: 200, body });
|
||||
return reply.status(200).send(body);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,286 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
applyBuildFilters,
|
||||
createBuild,
|
||||
findBuild,
|
||||
listBuildsForProject,
|
||||
} from "../fixtures/buildFixtures.js";
|
||||
import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js";
|
||||
import { listContextPacksForProject } from "../fixtures/contextPackFixtures.js";
|
||||
import { findProject } from "../fixtures/projectFixtures.js";
|
||||
import { selectContextPacks } from "../services/contextPackSelector.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import type {
|
||||
BuildListFilters,
|
||||
BuildStatus,
|
||||
BuildValidationResult,
|
||||
} from "../types/api.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import {
|
||||
getIdempotencyKey,
|
||||
getIdempotentResponse,
|
||||
setIdempotentResponse,
|
||||
} from "../utils/idempotency.js";
|
||||
|
||||
const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/);
|
||||
const projectParamsSchema = z.object({ projectId: idSchema });
|
||||
const buildParamsSchema = z.object({ projectId: idSchema, buildId: 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 buildQuerySchema = paginationQuerySchema.extend({
|
||||
agent: z.string().optional(),
|
||||
result: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
featureId: z.string().optional(),
|
||||
contextPackId: z.string().optional(),
|
||||
});
|
||||
const createBuildBodySchema = z.object({
|
||||
goal: z.string().optional(),
|
||||
agent: z.enum(["codex", "claude", "gpt", "cursor", "manual"]).optional(),
|
||||
contextPackId: z.string().optional(),
|
||||
promptId: z.string().optional(),
|
||||
filesChanged: z.array(z.string()).optional(),
|
||||
validationResult: z.string().optional(),
|
||||
lessonsLearned: z.array(z.string()).optional(),
|
||||
linkedFeatureIds: z.array(z.string()).optional(),
|
||||
autoSelectContext: z.boolean().optional().default(true),
|
||||
selectionIntent: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
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 sendProjectNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"PROJECT_NOT_FOUND",
|
||||
`Project '${projectId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
const ensureProject = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
) => {
|
||||
const project = findProject(projectId);
|
||||
if (!project) {
|
||||
sendProjectNotFound(request, reply, projectId);
|
||||
return undefined;
|
||||
}
|
||||
return project;
|
||||
};
|
||||
|
||||
const splitCsv = (value: string | undefined): string[] =>
|
||||
value?.split(",").map((item) => item.trim()).filter(Boolean) ?? [];
|
||||
|
||||
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 leftValue = left[field];
|
||||
const rightValue = right[field];
|
||||
if (leftValue === rightValue) return 0;
|
||||
const comparison = String(leftValue ?? "").localeCompare(
|
||||
String(rightValue ?? ""),
|
||||
undefined,
|
||||
{ numeric: true },
|
||||
);
|
||||
return direction === "desc" ? -comparison : comparison;
|
||||
});
|
||||
};
|
||||
|
||||
type BuildQuery = z.output<typeof buildQuerySchema>;
|
||||
type PaginationQuery = z.output<typeof paginationQuerySchema>;
|
||||
|
||||
const toFilters = (query: BuildQuery): BuildListFilters => ({
|
||||
...(query.agent ? { agent: splitCsv(query.agent) } : {}),
|
||||
...(query.result
|
||||
? { result: splitCsv(query.result) as BuildValidationResult[] }
|
||||
: {}),
|
||||
...(query.status ? { status: splitCsv(query.status) as BuildStatus[] } : {}),
|
||||
...(query.featureId ? { featureId: query.featureId } : {}),
|
||||
...(query.contextPackId ? { contextPackId: query.contextPackId } : {}),
|
||||
...(query.cursor ? { cursor: query.cursor } : {}),
|
||||
});
|
||||
|
||||
const toPaginationInput = (
|
||||
query: PaginationQuery,
|
||||
filters: PaginationInput["filters"],
|
||||
): PaginationInput => ({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
cursor: query.cursor,
|
||||
sort: query.sort,
|
||||
filters,
|
||||
});
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export const registerBuildRoutes = async (app: FastifyInstance) => {
|
||||
app.get("/api/projects/:projectId/builds", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
|
||||
const query = parseQuery(buildQuerySchema, request, reply);
|
||||
if (!query) return reply;
|
||||
|
||||
const filters = toFilters(query);
|
||||
const builds = sortByField(
|
||||
applyBuildFilters(listBuildsForProject(params.projectId), filters),
|
||||
query.sort,
|
||||
);
|
||||
|
||||
return successEnvelope(
|
||||
paginate(builds, toPaginationInput(query, filters)),
|
||||
getRequestId(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/builds/:buildId", async (request, reply) => {
|
||||
const params = parseParams(buildParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
|
||||
const build = findBuild(params.projectId, params.buildId);
|
||||
if (!build) {
|
||||
return reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"BUILD_NOT_FOUND",
|
||||
`Build '${params.buildId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return successEnvelope(build, getRequestId(request));
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/builds", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
||||
if (!idempotencyKey) return reply;
|
||||
const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey);
|
||||
const replay = getIdempotentResponse<unknown>(cacheKey);
|
||||
if (replay) return reply.status(replay.statusCode).send(replay.body);
|
||||
|
||||
const parsedBody = createBuildBodySchema.safeParse(request.body);
|
||||
if (!parsedBody.success || !parsedBody.data.goal?.trim()) {
|
||||
return reply.status(400).send(
|
||||
errorEnvelope(
|
||||
"BUILD_VALIDATION_FAILED",
|
||||
"goal is required to create a build.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const project = findProject(params.projectId);
|
||||
const contextSelection = parsedBody.data.autoSelectContext === false && parsedBody.data.contextPackId
|
||||
? {
|
||||
primaryContextPackId: parsedBody.data.contextPackId,
|
||||
selectedContextPackIds: [parsedBody.data.contextPackId],
|
||||
decisionSummary: `Explicit context pack '${parsedBody.data.contextPackId}' was supplied for this build.`,
|
||||
}
|
||||
: selectContextPacks(
|
||||
params.projectId,
|
||||
listContextPacksForProject(params.projectId),
|
||||
{
|
||||
mode: parsedBody.data.agent === "codex" ? "codex" : "validation",
|
||||
intent: parsedBody.data.selectionIntent?.trim() || parsedBody.data.goal,
|
||||
targetType: "build",
|
||||
maxPacks: 3,
|
||||
},
|
||||
project?.activeContextPackId,
|
||||
);
|
||||
|
||||
const body = successEnvelope(
|
||||
createBuild(params.projectId, {
|
||||
...parsedBody.data,
|
||||
goal: parsedBody.data.goal,
|
||||
contextPackId: parsedBody.data.contextPackId ?? contextSelection.primaryContextPackId,
|
||||
selectedContextPackIds: contextSelection.selectedContextPackIds,
|
||||
contextSelectionSummary: contextSelection.decisionSummary,
|
||||
}),
|
||||
getRequestId(request),
|
||||
);
|
||||
setIdempotentResponse(cacheKey, { statusCode: 201, body });
|
||||
return reply.status(201).send(body);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,442 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
applyCaptureFilters,
|
||||
classifyCaptureItem,
|
||||
createCaptureItem,
|
||||
findCaptureItem,
|
||||
listCaptureItemsForProject,
|
||||
markCaptureItemInReview,
|
||||
} from "../fixtures/captureFixtures.js";
|
||||
import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js";
|
||||
import { findProject } from "../fixtures/projectFixtures.js";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { createReviewItemFromCapture } from "../fixtures/reviewQueueFixtures.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import type {
|
||||
CaptureClassificationType,
|
||||
CaptureItemListFilters,
|
||||
CaptureItemStatus,
|
||||
CaptureItemType,
|
||||
ReviewMutationResponseDto,
|
||||
} from "../types/api.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import {
|
||||
classifyCaptureItemInDatabase,
|
||||
createCaptureItemInDatabase,
|
||||
getCaptureItemFromDatabase,
|
||||
listCaptureItemsFromDatabase,
|
||||
} from "../repositories/captureRepository.js";
|
||||
import { sendCaptureItemToReviewInDatabase } from "../repositories/reviewQueueRepository.js";
|
||||
import {
|
||||
getIdempotencyKey,
|
||||
getDurableIdempotentResponse,
|
||||
setDurableIdempotentResponse,
|
||||
} from "../utils/idempotency.js";
|
||||
|
||||
const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-_]+$/);
|
||||
const projectParamsSchema = z.object({ projectId: idSchema });
|
||||
const captureParamsSchema = z.object({
|
||||
projectId: idSchema,
|
||||
captureItemId: 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 captureQuerySchema = paginationQuerySchema.extend({
|
||||
status: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
confidence: z.coerce.number().optional(),
|
||||
classificationType: z.string().optional(),
|
||||
});
|
||||
const captureTypes = [
|
||||
"note",
|
||||
"idea",
|
||||
"bug",
|
||||
"build_summary",
|
||||
"codex_output",
|
||||
"chatgpt_output",
|
||||
"error_log",
|
||||
"docs_snippet",
|
||||
] as const;
|
||||
const captureBodySchema = z.object({
|
||||
type: z.enum(captureTypes).optional(),
|
||||
content: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
});
|
||||
|
||||
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 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 splitCsv = (value: string | undefined): string[] =>
|
||||
value?.split(",").map((item) => item.trim()).filter(Boolean) ?? [];
|
||||
|
||||
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 toPaginationInput = (
|
||||
query: z.output<typeof paginationQuerySchema>,
|
||||
filters: PaginationInput["filters"],
|
||||
): PaginationInput => ({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
cursor: query.cursor,
|
||||
sort: query.sort,
|
||||
filters,
|
||||
});
|
||||
|
||||
const toFilters = (
|
||||
query: z.output<typeof captureQuerySchema>,
|
||||
): CaptureItemListFilters => ({
|
||||
...(query.status ? { status: splitCsv(query.status) as CaptureItemStatus[] } : {}),
|
||||
...(query.type ? { type: splitCsv(query.type) as CaptureItemType[] } : {}),
|
||||
...(query.source ? { source: splitCsv(query.source) } : {}),
|
||||
...(query.confidence !== undefined ? { confidence: query.confidence } : {}),
|
||||
...(query.classificationType
|
||||
? {
|
||||
classificationType: splitCsv(
|
||||
query.classificationType,
|
||||
) as CaptureClassificationType[],
|
||||
}
|
||||
: {}),
|
||||
...(query.cursor ? { cursor: query.cursor } : {}),
|
||||
});
|
||||
|
||||
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 sendCaptureNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
captureItemId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"CAPTURE_ITEM_NOT_FOUND",
|
||||
`Capture item '${captureItemId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
type CaptureRoutesOptions = {
|
||||
dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"];
|
||||
};
|
||||
|
||||
export const registerCaptureRoutes = async (
|
||||
app: FastifyInstance,
|
||||
options: CaptureRoutesOptions = {},
|
||||
) => {
|
||||
const useDatabase = options.dataSource === "database";
|
||||
app.get("/api/projects/:projectId/capture-items", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply;
|
||||
const query = parseQuery(captureQuerySchema, request, reply);
|
||||
if (!query) return reply;
|
||||
|
||||
const filters = toFilters(query);
|
||||
|
||||
if (useDatabase) {
|
||||
const result = await listCaptureItemsFromDatabase(
|
||||
params.projectId,
|
||||
filters,
|
||||
{
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
cursor: query.cursor,
|
||||
sort: query.sort,
|
||||
},
|
||||
);
|
||||
if (!result) {
|
||||
return reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"PROJECT_NOT_FOUND",
|
||||
`Project '${params.projectId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
return successEnvelope(result, getRequestId(request));
|
||||
}
|
||||
|
||||
const items = sortByField(
|
||||
applyCaptureFilters(listCaptureItemsForProject(params.projectId), filters),
|
||||
query.sort,
|
||||
);
|
||||
|
||||
return successEnvelope(
|
||||
paginate(items, toPaginationInput(query, filters)),
|
||||
getRequestId(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/api/projects/:projectId/capture-items/:captureItemId",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(captureParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply;
|
||||
const item = useDatabase
|
||||
? await getCaptureItemFromDatabase(params.projectId, params.captureItemId)
|
||||
: findCaptureItem(params.projectId, params.captureItemId);
|
||||
if (!item) return sendCaptureNotFound(request, reply, params.captureItemId);
|
||||
return successEnvelope(item, getRequestId(request));
|
||||
},
|
||||
);
|
||||
|
||||
app.post("/api/projects/:projectId/capture-items", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply;
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
||||
if (!idempotencyKey) return reply;
|
||||
const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey);
|
||||
const replay = await getDurableIdempotentResponse<unknown>(cacheKey, useDatabase);
|
||||
if (replay) return reply.status(replay.statusCode).send(replay.body);
|
||||
|
||||
const parsedBody = captureBodySchema.safeParse(request.body);
|
||||
if (
|
||||
!parsedBody.success ||
|
||||
!parsedBody.data.type ||
|
||||
!parsedBody.data.content?.trim()
|
||||
) {
|
||||
return reply.status(400).send(
|
||||
errorEnvelope(
|
||||
"CAPTURE_VALIDATION_FAILED",
|
||||
"type and content are required to create a capture item.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const created = useDatabase
|
||||
? await createCaptureItemInDatabase(params.projectId, {
|
||||
type: parsedBody.data.type,
|
||||
content: parsedBody.data.content,
|
||||
source: parsedBody.data.source,
|
||||
})
|
||||
: createCaptureItem(params.projectId, {
|
||||
type: parsedBody.data.type,
|
||||
content: parsedBody.data.content,
|
||||
source: parsedBody.data.source,
|
||||
});
|
||||
if (!created) {
|
||||
return reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"PROJECT_NOT_FOUND",
|
||||
`Project '${params.projectId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
const body = successEnvelope(created, getRequestId(request));
|
||||
await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body }, useDatabase);
|
||||
return reply.status(201).send(body);
|
||||
});
|
||||
|
||||
app.patch(
|
||||
"/api/projects/:projectId/capture-items/:captureItemId/classify",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(captureParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply;
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
||||
if (!idempotencyKey) return reply;
|
||||
const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey);
|
||||
const replay = await getDurableIdempotentResponse<unknown>(cacheKey, useDatabase);
|
||||
if (replay) return reply.status(replay.statusCode).send(replay.body);
|
||||
|
||||
const classification = useDatabase
|
||||
? await classifyCaptureItemInDatabase(params.projectId, params.captureItemId)
|
||||
: classifyCaptureItem(
|
||||
params.projectId,
|
||||
params.captureItemId,
|
||||
);
|
||||
if (!classification) {
|
||||
return sendCaptureNotFound(request, reply, params.captureItemId);
|
||||
}
|
||||
if (classification === "CAPTURE_ALREADY_IN_REVIEW") {
|
||||
return reply.status(409).send(errorEnvelope(
|
||||
"CAPTURE_ALREADY_IN_REVIEW",
|
||||
"A capture that is already in review can no longer be reclassified.",
|
||||
getRequestId(request),
|
||||
));
|
||||
}
|
||||
|
||||
const body = successEnvelope(classification, getRequestId(request));
|
||||
await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase);
|
||||
return reply.status(200).send(body);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/projects/:projectId/capture-items/:captureItemId/send-to-review",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(captureParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply;
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
||||
if (!idempotencyKey) return reply;
|
||||
const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey);
|
||||
const replay = await getDurableIdempotentResponse<unknown>(cacheKey, useDatabase);
|
||||
if (replay) return reply.status(replay.statusCode).send(replay.body);
|
||||
|
||||
const item = useDatabase
|
||||
? await getCaptureItemFromDatabase(params.projectId, params.captureItemId)
|
||||
: findCaptureItem(params.projectId, params.captureItemId);
|
||||
if (!item) return sendCaptureNotFound(request, reply, params.captureItemId);
|
||||
|
||||
if (!useDatabase && (item.status === "in_review" || item.linkedReviewItemId)) {
|
||||
return reply.status(409).send(errorEnvelope(
|
||||
"CAPTURE_ALREADY_IN_REVIEW",
|
||||
"This capture is already linked to a review item.",
|
||||
getRequestId(request),
|
||||
));
|
||||
}
|
||||
if (!useDatabase && (item.status !== "classified" || !item.classificationType)) {
|
||||
return reply.status(409).send(errorEnvelope(
|
||||
"CAPTURE_NOT_CLASSIFIED",
|
||||
"Classify the capture before sending it to review.",
|
||||
getRequestId(request),
|
||||
));
|
||||
}
|
||||
|
||||
const reviewItem = useDatabase
|
||||
? await sendCaptureItemToReviewInDatabase(params.projectId, params.captureItemId)
|
||||
: createReviewItemFromCapture(item);
|
||||
if (!reviewItem) {
|
||||
return reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"PROJECT_NOT_FOUND",
|
||||
`Project '${params.projectId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (reviewItem === "CAPTURE_ALREADY_IN_REVIEW") {
|
||||
return reply.status(409).send(errorEnvelope(
|
||||
"CAPTURE_ALREADY_IN_REVIEW",
|
||||
"This capture is already linked to a review item.",
|
||||
getRequestId(request),
|
||||
));
|
||||
}
|
||||
if (reviewItem === "CAPTURE_NOT_CLASSIFIED") {
|
||||
return reply.status(409).send(errorEnvelope(
|
||||
"CAPTURE_NOT_CLASSIFIED",
|
||||
"Classify the capture before sending it to review.",
|
||||
getRequestId(request),
|
||||
));
|
||||
}
|
||||
if (!useDatabase) {
|
||||
markCaptureItemInReview(params.projectId, params.captureItemId, reviewItem.id);
|
||||
}
|
||||
|
||||
const body = successEnvelope<ReviewMutationResponseDto>(
|
||||
{
|
||||
success: true,
|
||||
message: `Capture item '${params.captureItemId}' sent to review.`,
|
||||
updatedEntityId: params.captureItemId,
|
||||
createdEntityId: reviewItem.id,
|
||||
affectedEntities: [params.captureItemId, reviewItem.id],
|
||||
},
|
||||
getRequestId(request),
|
||||
);
|
||||
await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body }, useDatabase);
|
||||
return reply.status(201).send(body);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,465 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
applyCheckFilters,
|
||||
applyReviewAction,
|
||||
checkFilterValues,
|
||||
convertCheck,
|
||||
findCheck,
|
||||
listChecksForProject,
|
||||
toReviewBoard,
|
||||
} from "../fixtures/checkFixtures.js";
|
||||
import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js";
|
||||
import { findProject } from "../fixtures/projectFixtures.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import type {
|
||||
ApprovalState,
|
||||
CheckCategory,
|
||||
CheckItemDto,
|
||||
CheckListFilters,
|
||||
CheckPipelineStage,
|
||||
CheckSeverity,
|
||||
CheckStatus,
|
||||
ConvertTarget,
|
||||
ReviewAction,
|
||||
ReviewMutationResponseDto,
|
||||
} from "../types/api.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import {
|
||||
getIdempotencyKey,
|
||||
getIdempotentResponse,
|
||||
setIdempotentResponse,
|
||||
} from "../utils/idempotency.js";
|
||||
|
||||
const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/);
|
||||
|
||||
const projectParamsSchema = z.object({
|
||||
projectId: idSchema,
|
||||
});
|
||||
|
||||
const checkParamsSchema = z.object({
|
||||
projectId: idSchema,
|
||||
checkId: idSchema,
|
||||
});
|
||||
|
||||
const sourceParamsSchema = z.object({
|
||||
projectId: idSchema,
|
||||
sourceId: 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 checksQuerySchema = paginationQuerySchema.extend({
|
||||
status: z.string().optional(),
|
||||
severity: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
confidence: z.coerce.number().int().min(0).max(100).optional(),
|
||||
approvalState: z.enum(["pending", "approved", "rejected", "blocked"]).optional(),
|
||||
category: z.string().optional(),
|
||||
});
|
||||
|
||||
const reviewBodySchema = z.object({
|
||||
action: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
targetEntityId: z.string().optional(),
|
||||
});
|
||||
|
||||
const convertBodySchema = z.object({
|
||||
target: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
const reviewActions = ["accept", "reject", "ignore", "merge"] as const;
|
||||
const convertTargets = ["bug", "task", "memory_rule", "decision"] as const;
|
||||
|
||||
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 sendProjectNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"PROJECT_NOT_FOUND",
|
||||
`Project '${projectId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
const ensureProject = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
) => {
|
||||
const project = findProject(projectId);
|
||||
if (!project) {
|
||||
sendProjectNotFound(request, reply, projectId);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return project;
|
||||
};
|
||||
|
||||
const splitCsv = (value: string | undefined): string[] =>
|
||||
value
|
||||
?.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean) ?? [];
|
||||
|
||||
const filterKnownValues = <T extends string>(
|
||||
values: string[],
|
||||
allowed: readonly T[],
|
||||
): T[] => values.filter((value): value is T => allowed.includes(value as T));
|
||||
|
||||
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 leftValue =
|
||||
field === "confidence"
|
||||
? (left.trust as { confidence?: number })?.confidence
|
||||
: left[field];
|
||||
const rightValue =
|
||||
field === "confidence"
|
||||
? (right.trust as { confidence?: number })?.confidence
|
||||
: right[field];
|
||||
if (leftValue === rightValue) return 0;
|
||||
const comparison = String(leftValue ?? "").localeCompare(
|
||||
String(rightValue ?? ""),
|
||||
undefined,
|
||||
{ numeric: true },
|
||||
);
|
||||
return direction === "desc" ? -comparison : comparison;
|
||||
});
|
||||
};
|
||||
|
||||
type ChecksQuery = z.output<typeof checksQuerySchema>;
|
||||
type PaginationQuery = z.output<typeof paginationQuerySchema>;
|
||||
|
||||
const toFilters = (query: ChecksQuery): CheckListFilters => ({
|
||||
...(query.status
|
||||
? {
|
||||
status: filterKnownValues(
|
||||
splitCsv(query.status),
|
||||
checkFilterValues.status,
|
||||
) as CheckStatus[],
|
||||
}
|
||||
: {}),
|
||||
...(query.severity
|
||||
? {
|
||||
severity: filterKnownValues(
|
||||
splitCsv(query.severity),
|
||||
checkFilterValues.severity,
|
||||
) as CheckSeverity[],
|
||||
}
|
||||
: {}),
|
||||
...(query.source
|
||||
? {
|
||||
source: filterKnownValues(
|
||||
splitCsv(query.source),
|
||||
checkFilterValues.source,
|
||||
) as CheckPipelineStage[],
|
||||
}
|
||||
: {}),
|
||||
...(query.category
|
||||
? {
|
||||
category: filterKnownValues(
|
||||
splitCsv(query.category),
|
||||
checkFilterValues.category,
|
||||
) as CheckCategory[],
|
||||
}
|
||||
: {}),
|
||||
...(query.confidence !== undefined ? { confidence: query.confidence } : {}),
|
||||
...(query.approvalState
|
||||
? { approvalState: query.approvalState as ApprovalState }
|
||||
: {}),
|
||||
...(query.cursor ? { cursor: query.cursor } : {}),
|
||||
});
|
||||
|
||||
const toPaginationInput = (
|
||||
query: PaginationQuery,
|
||||
filters: PaginationInput["filters"],
|
||||
): PaginationInput => ({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
cursor: query.cursor,
|
||||
sort: query.sort,
|
||||
filters,
|
||||
});
|
||||
|
||||
const sendCheckNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
checkId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"CHECK_NOT_FOUND",
|
||||
`Check '${checkId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
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 sendReplayOrUndefined = (
|
||||
reply: FastifyReply,
|
||||
key: string,
|
||||
): boolean => {
|
||||
const replay = getIdempotentResponse<unknown>(key);
|
||||
if (!replay) return false;
|
||||
|
||||
reply.status(replay.statusCode).send(replay.body);
|
||||
return true;
|
||||
};
|
||||
|
||||
export const registerCheckRoutes = async (app: FastifyInstance) => {
|
||||
app.get("/api/projects/:projectId/checks", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
|
||||
const query = parseQuery(checksQuerySchema, request, reply);
|
||||
if (!query) return reply;
|
||||
|
||||
const filters = toFilters(query);
|
||||
const checks = sortByField(
|
||||
applyCheckFilters(listChecksForProject(params.projectId), filters),
|
||||
query.sort,
|
||||
);
|
||||
|
||||
return successEnvelope(
|
||||
paginate(checks, toPaginationInput(query, filters)),
|
||||
getRequestId(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/review-board", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
|
||||
return successEnvelope(
|
||||
toReviewBoard(listChecksForProject(params.projectId)),
|
||||
getRequestId(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/checks/:checkId", async (request, reply) => {
|
||||
const params = parseParams(checkParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
|
||||
const check = findCheck(params.projectId, params.checkId);
|
||||
if (!check) return sendCheckNotFound(request, reply, params.checkId);
|
||||
|
||||
return successEnvelope(check, getRequestId(request));
|
||||
});
|
||||
|
||||
app.patch(
|
||||
"/api/projects/:projectId/checks/:sourceId/review",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(sourceParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
||||
if (!idempotencyKey) return reply;
|
||||
|
||||
const cacheKey = getIdempotencyKey(
|
||||
request.method,
|
||||
request.url,
|
||||
idempotencyKey,
|
||||
);
|
||||
if (sendReplayOrUndefined(reply, cacheKey)) return reply;
|
||||
|
||||
const parsedBody = reviewBodySchema.safeParse(request.body);
|
||||
if (!parsedBody.success) {
|
||||
sendValidationError(request, reply, parsedBody.error.issues);
|
||||
return reply;
|
||||
}
|
||||
|
||||
const action = parsedBody.data.action as ReviewAction | undefined;
|
||||
if (!action || !reviewActions.includes(action)) {
|
||||
return reply.status(400).send(
|
||||
errorEnvelope(
|
||||
"REVIEW_ACTION_INVALID",
|
||||
"Review action must be accept, reject, ignore, or merge.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "merge" && !parsedBody.data.targetEntityId?.trim()) {
|
||||
return reply.status(409).send(
|
||||
errorEnvelope(
|
||||
"REVIEW_ACTION_CONFLICT",
|
||||
"targetEntityId is required when action is merge.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!findCheck(params.projectId, params.sourceId)) {
|
||||
return sendCheckNotFound(request, reply, params.sourceId);
|
||||
}
|
||||
|
||||
const response = applyReviewAction(
|
||||
params.projectId,
|
||||
params.sourceId,
|
||||
action,
|
||||
parsedBody.data.reason,
|
||||
parsedBody.data.targetEntityId,
|
||||
);
|
||||
if (!response) return sendCheckNotFound(request, reply, params.sourceId);
|
||||
|
||||
const body = successEnvelope<ReviewMutationResponseDto>(
|
||||
response,
|
||||
getRequestId(request),
|
||||
);
|
||||
setIdempotentResponse(cacheKey, { statusCode: 200, body });
|
||||
return reply.status(200).send(body);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/projects/:projectId/checks/:sourceId/convert",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(sourceParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
||||
if (!idempotencyKey) return reply;
|
||||
|
||||
const cacheKey = getIdempotencyKey(
|
||||
request.method,
|
||||
request.url,
|
||||
idempotencyKey,
|
||||
);
|
||||
if (sendReplayOrUndefined(reply, cacheKey)) return reply;
|
||||
|
||||
const parsedBody = convertBodySchema.safeParse(request.body);
|
||||
if (!parsedBody.success) {
|
||||
sendValidationError(request, reply, parsedBody.error.issues);
|
||||
return reply;
|
||||
}
|
||||
|
||||
const target = parsedBody.data.target as ConvertTarget | undefined;
|
||||
if (!target || !convertTargets.includes(target)) {
|
||||
return reply.status(400).send(
|
||||
errorEnvelope(
|
||||
"CONVERT_TARGET_INVALID",
|
||||
"Convert target must be bug, task, memory_rule, or decision.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!findCheck(params.projectId, params.sourceId)) {
|
||||
return sendCheckNotFound(request, reply, params.sourceId);
|
||||
}
|
||||
|
||||
const response = convertCheck(
|
||||
params.projectId,
|
||||
params.sourceId,
|
||||
target,
|
||||
parsedBody.data.title,
|
||||
parsedBody.data.reason,
|
||||
);
|
||||
if (!response) {
|
||||
return reply.status(409).send(
|
||||
errorEnvelope(
|
||||
"CONVERSION_FAILED",
|
||||
`Conversion failed for check '${params.sourceId}'.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const body = successEnvelope<ReviewMutationResponseDto>(
|
||||
response,
|
||||
getRequestId(request),
|
||||
);
|
||||
setIdempotentResponse(cacheKey, { statusCode: 200, body });
|
||||
return reply.status(200).send(body);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,415 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import {
|
||||
applyContextPackFilters,
|
||||
createContextPack,
|
||||
findContextPack,
|
||||
findContextPackSummary,
|
||||
listContextPacksForProject,
|
||||
} from "../fixtures/contextPackFixtures.js";
|
||||
import {
|
||||
createContextPackForProject as createDatabaseContextPack,
|
||||
getContextPackBySlugOrId as getDatabaseContextPackBySlugOrId,
|
||||
getContextPackSummaryBySlugOrId as getDatabaseContextPackSummaryBySlugOrId,
|
||||
listContextPacksForProject as listDatabaseContextPacksForProject,
|
||||
projectExists as databaseProjectExists,
|
||||
} from "../repositories/contextPacksRepository.js";
|
||||
import { getProjectBySlugOrId as getDatabaseProjectBySlugOrId } from "../repositories/projectsRepository.js";
|
||||
import { selectContextPacks } from "../services/contextPackSelector.js";
|
||||
import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js";
|
||||
import { findFeature } from "../fixtures/featureFixtures.js";
|
||||
import { getFeatureBySlugOrId as getDatabaseFeatureBySlugOrId } from "../repositories/featuresRepository.js";
|
||||
import { findProject } from "../fixtures/projectFixtures.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import type {
|
||||
ContextPackCategory,
|
||||
ContextPackGeneratedBy,
|
||||
ContextPackListFilters,
|
||||
} from "../types/api.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
|
||||
const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/);
|
||||
|
||||
const projectParamsSchema = z.object({
|
||||
projectId: idSchema,
|
||||
});
|
||||
|
||||
const contextPackParamsSchema = z.object({
|
||||
projectId: idSchema,
|
||||
contextPackId: 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 contextPackQuerySchema = paginationQuerySchema.extend({
|
||||
category: z.string().optional(),
|
||||
generatedBy: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
confidence: z.coerce.number().int().min(0).max(100).optional(),
|
||||
});
|
||||
|
||||
const sourceTypeSchema = z.enum([
|
||||
"workspace",
|
||||
"focus_area",
|
||||
"feature",
|
||||
"check",
|
||||
"manual",
|
||||
]);
|
||||
|
||||
const createContextPackBodySchema = z.object({
|
||||
sourceType: z.string().optional(),
|
||||
sourceId: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
includeDNA: z.boolean().optional(),
|
||||
includeFeatures: z.boolean().optional(),
|
||||
includeEvidence: z.boolean().optional(),
|
||||
includeMemoryRules: z.boolean().optional(),
|
||||
includeRisks: z.boolean().optional(),
|
||||
includeRegressions: z.boolean().optional(),
|
||||
includeArchitecture: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const selectionModeSchema = z.enum(["workspace", "ai", "codex", "documentation", "validation"]);
|
||||
const selectionTargetTypeSchema = z.enum([
|
||||
"project",
|
||||
"feature",
|
||||
"bug",
|
||||
"task",
|
||||
"decision",
|
||||
"memory-rule",
|
||||
"knowledge",
|
||||
"documentation",
|
||||
"build",
|
||||
]);
|
||||
|
||||
const contextPackSelectionBodySchema = z.object({
|
||||
mode: selectionModeSchema.optional().default("workspace"),
|
||||
intent: z.string().max(2000).optional(),
|
||||
targetType: selectionTargetTypeSchema.optional(),
|
||||
targetId: z.string().min(1).max(160).optional(),
|
||||
maxPacks: z.coerce.number().int().min(1).max(8).optional().default(3),
|
||||
includeUnapproved: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
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 sendContextPackValidationFailed = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
message: string,
|
||||
) =>
|
||||
reply.status(400).send(
|
||||
errorEnvelope(
|
||||
"CONTEXT_PACK_VALIDATION_FAILED",
|
||||
message,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
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 sendProjectNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"PROJECT_NOT_FOUND",
|
||||
`Project '${projectId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
const ensureProject = async (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
useDatabase: boolean,
|
||||
) => {
|
||||
const exists = useDatabase
|
||||
? await databaseProjectExists(projectId)
|
||||
: Boolean(findProject(projectId));
|
||||
if (!exists) {
|
||||
sendProjectNotFound(request, reply, projectId);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const splitCsv = (value: string | undefined): string[] =>
|
||||
value
|
||||
?.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean) ?? [];
|
||||
|
||||
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 leftValue =
|
||||
field === "confidence" ? (left.trust as { confidence?: number })?.confidence : left[field];
|
||||
const rightValue =
|
||||
field === "confidence" ? (right.trust as { confidence?: number })?.confidence : right[field];
|
||||
if (leftValue === rightValue) return 0;
|
||||
const comparison = String(leftValue ?? "").localeCompare(
|
||||
String(rightValue ?? ""),
|
||||
undefined,
|
||||
{ numeric: true },
|
||||
);
|
||||
return direction === "desc" ? -comparison : comparison;
|
||||
});
|
||||
};
|
||||
|
||||
type ContextPackQuery = z.output<typeof contextPackQuerySchema>;
|
||||
type PaginationQuery = z.output<typeof paginationQuerySchema>;
|
||||
|
||||
const toFilters = (query: ContextPackQuery): ContextPackListFilters => ({
|
||||
...(query.category
|
||||
? { category: splitCsv(query.category) as ContextPackCategory[] }
|
||||
: {}),
|
||||
...(query.generatedBy
|
||||
? { generatedBy: splitCsv(query.generatedBy) as ContextPackGeneratedBy[] }
|
||||
: {}),
|
||||
...(query.source
|
||||
? { source: splitCsv(query.source) as ContextPackGeneratedBy[] }
|
||||
: {}),
|
||||
...(query.confidence !== undefined ? { confidence: query.confidence } : {}),
|
||||
...(query.cursor ? { cursor: query.cursor } : {}),
|
||||
});
|
||||
|
||||
const toPaginationInput = (
|
||||
query: PaginationQuery,
|
||||
filters: PaginationInput["filters"],
|
||||
): PaginationInput => ({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
cursor: query.cursor,
|
||||
sort: query.sort,
|
||||
filters,
|
||||
});
|
||||
|
||||
type ContextPackRoutesOptions = {
|
||||
dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"];
|
||||
};
|
||||
|
||||
export const registerContextPackRoutes = async (
|
||||
app: FastifyInstance,
|
||||
options: ContextPackRoutesOptions = {},
|
||||
) => {
|
||||
const useDatabase = options.dataSource === "database";
|
||||
app.get("/api/projects/:projectId/context-packs", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!(await ensureProject(request, reply, params.projectId, useDatabase))) return reply;
|
||||
|
||||
const query = parseQuery(contextPackQuerySchema, request, reply);
|
||||
if (!query) return reply;
|
||||
|
||||
const filters = toFilters(query);
|
||||
const contextPacks = useDatabase
|
||||
? await listDatabaseContextPacksForProject(params.projectId)
|
||||
: listContextPacksForProject(params.projectId);
|
||||
if (!contextPacks) return sendProjectNotFound(request, reply, params.projectId);
|
||||
|
||||
const filteredContextPacks = sortByField(
|
||||
applyContextPackFilters(contextPacks, filters),
|
||||
query.sort,
|
||||
);
|
||||
|
||||
return successEnvelope(
|
||||
paginate(filteredContextPacks, toPaginationInput(query, filters)),
|
||||
getRequestId(request),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
app.post("/api/projects/:projectId/context-packs/select", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!(await ensureProject(request, reply, params.projectId, useDatabase))) return reply;
|
||||
|
||||
const parsedBody = contextPackSelectionBodySchema.safeParse(request.body ?? {});
|
||||
if (!parsedBody.success) {
|
||||
return sendValidationError(request, reply, parsedBody.error.issues);
|
||||
}
|
||||
|
||||
const [contextPacks, project] = await Promise.all([
|
||||
useDatabase
|
||||
? listDatabaseContextPacksForProject(params.projectId)
|
||||
: Promise.resolve(listContextPacksForProject(params.projectId)),
|
||||
useDatabase
|
||||
? getDatabaseProjectBySlugOrId(params.projectId)
|
||||
: Promise.resolve(findProject(params.projectId)),
|
||||
]);
|
||||
|
||||
const activeContextPackId = project?.activeContextPackId ?? project?.workspaceState?.activeContextPackId;
|
||||
const selection = selectContextPacks(
|
||||
params.projectId,
|
||||
contextPacks ?? [],
|
||||
parsedBody.data,
|
||||
activeContextPackId,
|
||||
);
|
||||
|
||||
return successEnvelope(selection, getRequestId(request));
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/api/projects/:projectId/context-packs/:contextPackId/summary",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(contextPackParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!(await ensureProject(request, reply, params.projectId, useDatabase))) return reply;
|
||||
|
||||
const contextPack = useDatabase
|
||||
? await getDatabaseContextPackSummaryBySlugOrId(params.projectId, params.contextPackId)
|
||||
: findContextPackSummary(
|
||||
params.projectId,
|
||||
params.contextPackId,
|
||||
);
|
||||
if (!contextPack) {
|
||||
return reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"CONTEXT_PACK_NOT_FOUND",
|
||||
`Context pack '${params.contextPackId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return successEnvelope(contextPack, getRequestId(request));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/projects/:projectId/context-packs/:contextPackId",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(contextPackParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!(await ensureProject(request, reply, params.projectId, useDatabase))) return reply;
|
||||
|
||||
const contextPack = useDatabase
|
||||
? await getDatabaseContextPackBySlugOrId(params.projectId, params.contextPackId)
|
||||
: findContextPack(params.projectId, params.contextPackId);
|
||||
if (!contextPack) {
|
||||
return reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"CONTEXT_PACK_NOT_FOUND",
|
||||
`Context pack '${params.contextPackId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return successEnvelope(contextPack, getRequestId(request));
|
||||
},
|
||||
);
|
||||
|
||||
app.post("/api/projects/:projectId/context-packs", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!(await ensureProject(request, reply, params.projectId, useDatabase))) return reply;
|
||||
|
||||
const parsedBody = createContextPackBodySchema.safeParse(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return sendContextPackValidationFailed(
|
||||
request,
|
||||
reply,
|
||||
"Context pack request body is invalid.",
|
||||
);
|
||||
}
|
||||
|
||||
const parsedSourceType = sourceTypeSchema.safeParse(parsedBody.data.sourceType);
|
||||
if (!parsedSourceType.success) {
|
||||
return sendContextPackValidationFailed(
|
||||
request,
|
||||
reply,
|
||||
"sourceType must be one of workspace, focus_area, feature, check, or manual.",
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
parsedSourceType.data === "feature" &&
|
||||
(!parsedBody.data.sourceId ||
|
||||
!(useDatabase
|
||||
? await getDatabaseFeatureBySlugOrId(params.projectId, parsedBody.data.sourceId)
|
||||
: findFeature(params.projectId, parsedBody.data.sourceId)))
|
||||
) {
|
||||
return sendContextPackValidationFailed(
|
||||
request,
|
||||
reply,
|
||||
"sourceId must reference an existing feature when sourceType is feature.",
|
||||
);
|
||||
}
|
||||
|
||||
const contextPack = useDatabase
|
||||
? await createDatabaseContextPack(params.projectId, {
|
||||
...parsedBody.data,
|
||||
sourceType: parsedSourceType.data,
|
||||
})
|
||||
: createContextPack(params.projectId, {
|
||||
...parsedBody.data,
|
||||
sourceType: parsedSourceType.data,
|
||||
});
|
||||
|
||||
return reply
|
||||
.status(201)
|
||||
.send(successEnvelope(contextPack, getRequestId(request)));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { createDocumentationItem, getDocumentationBySlugOrId, listDocumentationForProject, projectExists, updateDocumentationItem } from "../repositories/p8KnowledgeMemoryRepository.js";
|
||||
import { listContextPacksForProject } from "../repositories/contextPacksRepository.js";
|
||||
import { getProjectBySlugOrId } from "../repositories/projectsRepository.js";
|
||||
import { selectContextPacks } from "../services/contextPackSelector.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import { getIdempotencyKey, getIdempotentResponse, setIdempotentResponse } from "../utils/idempotency.js";
|
||||
|
||||
type DocumentationRouteOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"] };
|
||||
const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-]+$/);
|
||||
const projectParamsSchema = z.object({ projectId: idSchema });
|
||||
const docParamsSchema = z.object({ projectId: idSchema, documentationId: idSchema });
|
||||
const querySchema = z.object({ page: z.coerce.number().int().positive().default(1), pageSize: z.coerce.number().int().positive().max(100).default(25), kind: z.string().optional(), status: z.string().optional(), approvalState: z.string().optional(), search: z.string().optional() });
|
||||
const bodySchema = z.object({ title: z.string().optional(), kind: z.string().optional(), status: z.string().optional(), linkedFeatureIds: z.array(z.string()).optional(), content: z.string().optional(), approvalState: z.enum(["pending", "approved", "rejected", "blocked"]).optional(), autoSelectContext: z.boolean().optional().default(true), selectionIntent: z.string().max(2000).optional(), contextPackId: z.string().optional() });
|
||||
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 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 sendDocumentationNotFound = (request: FastifyRequest, reply: FastifyReply, documentationId: string) => reply.status(404).send(errorEnvelope("DOCUMENTATION_NOT_FOUND", `Documentation item '${documentationId}' was not found.`, getRequestId(request)));
|
||||
export const registerDocumentationRoutes = async (app: FastifyInstance, options: DocumentationRouteOptions = {}) => {
|
||||
const dataSource = options.dataSource ?? "fixture";
|
||||
app.get("/api/projects/:projectId/documentation-items", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Documentation write persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request))); if (!(await projectExists(params.projectId))) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); const query = parseQuery(querySchema, request, reply); if (!query) return reply; const docs = await listDocumentationForProject(params.projectId, { kind: splitCsv(query.kind), status: splitCsv(query.status), approvalState: splitCsv(query.approvalState), search: query.search }); const start = (query.page - 1) * query.pageSize; return successEnvelope({ items: docs.slice(start, start + query.pageSize), total: docs.length, page: query.page, pageSize: query.pageSize }, getRequestId(request)); });
|
||||
app.get("/api/projects/:projectId/documentation-items/:documentationId", async (request, reply) => { const params = parseParams(docParamsSchema, request, reply); if (!params) return reply; if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Documentation write persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request))); const item = await getDocumentationBySlugOrId(params.projectId, params.documentationId); if (!item) return sendDocumentationNotFound(request, reply, params.documentationId); return successEnvelope(item, getRequestId(request)); });
|
||||
app.post("/api/projects/:projectId/documentation-items", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Documentation write persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request))); const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = getIdempotentResponse<unknown>(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); const parsed = bodySchema.safeParse(request.body); if (!parsed.success || !parsed.data.title?.trim()) return reply.status(400).send(errorEnvelope("DOCUMENTATION_VALIDATION_FAILED", "title is required to create documentation.", getRequestId(request))); const project = await getProjectBySlugOrId(params.projectId); const contextPacks = await listContextPacksForProject(params.projectId) ?? []; const contextSelection = parsed.data.autoSelectContext === false && parsed.data.contextPackId ? { primaryContextPackId: parsed.data.contextPackId, selectedContextPackIds: [parsed.data.contextPackId], decisionSummary: `Explicit context pack '${parsed.data.contextPackId}' was supplied for documentation generation.` } : selectContextPacks(params.projectId, contextPacks, { mode: "documentation", intent: parsed.data.selectionIntent?.trim() || `${parsed.data.title}. ${parsed.data.content ?? ""}`, targetType: "documentation", maxPacks: 3 }, project?.activeContextPackId); const body = successEnvelope(await createDocumentationItem(params.projectId, { ...parsed.data, title: parsed.data.title, primaryContextPackId: parsed.data.contextPackId ?? contextSelection.primaryContextPackId, selectedContextPackIds: contextSelection.selectedContextPackIds, contextSelectionSummary: contextSelection.decisionSummary }), getRequestId(request)); setIdempotentResponse(cacheKey, { statusCode: 201, body }); return reply.status(201).send(body); });
|
||||
app.patch("/api/projects/:projectId/documentation-items/:documentationId", async (request, reply) => { const params = parseParams(docParamsSchema, request, reply); if (!params) return reply; if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Documentation write persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request))); const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = getIdempotentResponse<unknown>(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); const parsed = bodySchema.safeParse(request.body); if (!parsed.success) return sendValidationError(request, reply, parsed.error.issues); const project = await getProjectBySlugOrId(params.projectId); const contextPacks = await listContextPacksForProject(params.projectId) ?? []; const contextSelection = parsed.data.autoSelectContext === false && parsed.data.contextPackId ? { primaryContextPackId: parsed.data.contextPackId, selectedContextPackIds: [parsed.data.contextPackId], decisionSummary: `Explicit context pack '${parsed.data.contextPackId}' was supplied for documentation update.` } : selectContextPacks(params.projectId, contextPacks, { mode: "documentation", intent: parsed.data.selectionIntent?.trim() || `${parsed.data.title ?? params.documentationId}. ${parsed.data.content ?? ""}`, targetType: "documentation", targetId: params.documentationId, maxPacks: 3 }, project?.activeContextPackId); const item = await updateDocumentationItem(params.projectId, params.documentationId, { ...parsed.data, primaryContextPackId: parsed.data.contextPackId ?? contextSelection.primaryContextPackId, selectedContextPackIds: contextSelection.selectedContextPackIds, contextSelectionSummary: contextSelection.decisionSummary }); if (!item) return sendDocumentationNotFound(request, reply, params.documentationId); const body = successEnvelope(item, getRequestId(request)); setIdempotentResponse(cacheKey, { statusCode: 200, body }); return reply.status(200).send(body); });
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { apiPaths } from "@projectbrain/contracts";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { SESSION_COOKIE, sessionCookieOptions } from "../plugins/authentication.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import { authenticate, createSession } from "../security/authStore.js";
|
||||
import { consumeEntraAttempt, createEntraAuthorization, exchangeEntraCode, getEntraLinkStatus, isEntraConfigured, linkEntraIdentity, resolveEntraUser, unlinkEntraIdentity } from "../security/entraOidc.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
|
||||
const callbackQuery = (query: unknown) => {
|
||||
const value = query as Record<string, unknown> | undefined;
|
||||
return { code: typeof value?.code === "string" ? value.code : undefined, state: typeof value?.state === "string" ? value.state : undefined, error: typeof value?.error === "string" ? value.error : undefined };
|
||||
};
|
||||
const requestedReturnTo = (query: unknown) => {
|
||||
const value = (query as Record<string, unknown> | undefined)?.returnTo;
|
||||
return typeof value === "string" ? value : undefined;
|
||||
};
|
||||
const errorRedirect = (env: AppEnv, code: string) => new URL(`/login?entraError=${encodeURIComponent(code)}`, env.PROJECTBRAIN_PUBLIC_URL).toString();
|
||||
|
||||
export const registerEntraAuthRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => {
|
||||
const { env } = options;
|
||||
app.get(apiPaths.auth.entraConfig, async (request) => successEnvelope({ enabled: isEntraConfigured(env), provider: "microsoft-entra" }, getRequestId(request)));
|
||||
|
||||
app.get(apiPaths.auth.entraStart, { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (request, reply) => {
|
||||
if (!isEntraConfigured(env)) return reply.status(503).send(errorEnvelope("ENTRA_NOT_CONFIGURED", "Microsoft Entra login is not configured", getRequestId(request)));
|
||||
const authorizationUrl = await createEntraAuthorization(env, { purpose: "login", returnTo: requestedReturnTo(request.query) });
|
||||
return reply.redirect(authorizationUrl);
|
||||
});
|
||||
|
||||
app.get(apiPaths.auth.entraCallback, { config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, async (request, reply) => {
|
||||
if (!isEntraConfigured(env)) return reply.status(503).send(errorEnvelope("ENTRA_NOT_CONFIGURED", "Microsoft Entra login is not configured", getRequestId(request)));
|
||||
const query = callbackQuery(request.query);
|
||||
if (query.error || !query.code || !query.state) return reply.redirect(errorRedirect(env, query.error === "access_denied" ? "cancelled" : "invalid_callback"));
|
||||
try {
|
||||
const attempt = await consumeEntraAttempt(env, query.state);
|
||||
if (!attempt) return reply.redirect(errorRedirect(env, "expired_attempt"));
|
||||
const profile = await exchangeEntraCode(env, query.code, attempt);
|
||||
const user = attempt.purpose === "link" && attempt.userId
|
||||
? await linkEntraIdentity(attempt.userId, profile)
|
||||
: await resolveEntraUser(profile);
|
||||
if (!user) return reply.redirect(errorRedirect(env, "not_linked"));
|
||||
const session = await createSession(env, user);
|
||||
reply.setCookie(SESSION_COOKIE, session.token, sessionCookieOptions(env));
|
||||
request.log.info({ requestId: getRequestId(request), userId: user.id, provider: "microsoft-entra", purpose: attempt.purpose }, "Entra authentication succeeded");
|
||||
return reply.redirect(new URL(attempt.returnTo, env.PROJECTBRAIN_PUBLIC_URL).toString());
|
||||
} catch (error) {
|
||||
request.log.warn({ requestId: getRequestId(request), provider: "microsoft-entra", errorCode: error instanceof Error ? error.message : "ENTRA_UNKNOWN_ERROR" }, "Entra authentication failed");
|
||||
return reply.redirect(errorRedirect(env, "authentication_failed"));
|
||||
}
|
||||
});
|
||||
|
||||
app.get(apiPaths.auth.entraStatus, async (request) => {
|
||||
const status = request.authUser && env.PROJECTBRAIN_DATA_SOURCE === "database" ? await getEntraLinkStatus(request.authUser.id) : { linked: false };
|
||||
return successEnvelope({ enabled: isEntraConfigured(env), ...status }, getRequestId(request));
|
||||
});
|
||||
|
||||
app.post(apiPaths.auth.entraLinkStart, async (request, reply) => {
|
||||
if (!request.authUser) return reply.status(401).send(errorEnvelope("AUTHENTICATION_REQUIRED", "Authentication required", getRequestId(request)));
|
||||
if (!isEntraConfigured(env)) return reply.status(503).send(errorEnvelope("ENTRA_NOT_CONFIGURED", "Microsoft Entra login is not configured", getRequestId(request)));
|
||||
const authorizationUrl = await createEntraAuthorization(env, { purpose: "link", userId: request.authUser.id, returnTo: "/settings" });
|
||||
return successEnvelope({ authorizationUrl }, getRequestId(request));
|
||||
});
|
||||
|
||||
app.delete(apiPaths.auth.entraIdentity, async (request, reply) => {
|
||||
if (!request.authUser) return reply.status(401).send(errorEnvelope("AUTHENTICATION_REQUIRED", "Authentication required", getRequestId(request)));
|
||||
if (!isEntraConfigured(env)) return reply.status(503).send(errorEnvelope("ENTRA_NOT_CONFIGURED", "Microsoft Entra login is not configured", getRequestId(request)));
|
||||
const currentPassword = (request.body as { currentPassword?: unknown } | undefined)?.currentPassword;
|
||||
if (typeof currentPassword !== "string" || currentPassword.length < 12) return reply.status(400).send(errorEnvelope("PASSWORD_REQUIRED", "The local owner password is required to unlink Microsoft Entra", getRequestId(request)));
|
||||
const verified = await authenticate(env, request.authUser.username, currentPassword);
|
||||
if (!verified) return reply.status(403).send(errorEnvelope("CURRENT_PASSWORD_INVALID", "Current password is invalid", getRequestId(request)));
|
||||
await unlinkEntraIdentity(request.authUser.id);
|
||||
return successEnvelope({ unlinked: true }, getRequestId(request));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import {
|
||||
findEvidence,
|
||||
findFeature,
|
||||
listEvidenceForFeature as listFixtureEvidenceForFeature,
|
||||
listEvidenceForProject as listFixtureEvidenceForProject,
|
||||
paginate,
|
||||
type PaginationInput,
|
||||
} from "../fixtures/featureFixtures.js";
|
||||
import { findProject } from "../fixtures/projectFixtures.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import {
|
||||
getFeatureBySlugOrId,
|
||||
projectExists as databaseFeatureProjectExists,
|
||||
} from "../repositories/featuresRepository.js";
|
||||
import {
|
||||
getEvidenceBySlugOrId,
|
||||
listEvidenceForFeature as listDatabaseEvidenceForFeature,
|
||||
listEvidenceForProject as listDatabaseEvidenceForProject,
|
||||
projectExists as databaseEvidenceProjectExists,
|
||||
} from "../repositories/evidenceRepository.js";
|
||||
import type { EvidenceDto, EvidenceType } from "../types/api.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
|
||||
const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/);
|
||||
|
||||
const projectParamsSchema = z.object({
|
||||
projectId: idSchema,
|
||||
});
|
||||
|
||||
const featureParamsSchema = z.object({
|
||||
projectId: idSchema,
|
||||
featureId: idSchema,
|
||||
});
|
||||
|
||||
const evidenceParamsSchema = z.object({
|
||||
projectId: idSchema,
|
||||
evidenceId: 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 evidenceQuerySchema = paginationQuerySchema.extend({
|
||||
type: z.string().optional(),
|
||||
featureId: idSchema.optional(),
|
||||
confidence: z.coerce.number().int().min(0).max(100).optional(),
|
||||
search: z.string().optional(),
|
||||
});
|
||||
|
||||
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 sendProjectNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"PROJECT_NOT_FOUND",
|
||||
`Project '${projectId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
const splitCsv = (value: string | undefined): string[] =>
|
||||
value
|
||||
?.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean) ?? [];
|
||||
|
||||
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 leftValue = left[field];
|
||||
const rightValue = right[field];
|
||||
if (leftValue === rightValue) return 0;
|
||||
const comparison = String(leftValue ?? "").localeCompare(
|
||||
String(rightValue ?? ""),
|
||||
undefined,
|
||||
{ numeric: true },
|
||||
);
|
||||
return direction === "desc" ? -comparison : comparison;
|
||||
});
|
||||
};
|
||||
|
||||
type EvidenceQuery = z.output<typeof evidenceQuerySchema>;
|
||||
type PaginationQuery = z.output<typeof paginationQuerySchema>;
|
||||
|
||||
const applyEvidenceFilters = (
|
||||
evidenceItems: EvidenceDto[],
|
||||
query: EvidenceQuery,
|
||||
): EvidenceDto[] => {
|
||||
const types = splitCsv(query.type) as EvidenceType[];
|
||||
const normalizedSearch = query.search?.trim().toLowerCase();
|
||||
|
||||
return evidenceItems.filter((evidence) => {
|
||||
if (types.length > 0 && !types.includes(evidence.type)) return false;
|
||||
if (
|
||||
query.featureId !== undefined &&
|
||||
!evidence.featureIds.includes(query.featureId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
query.confidence !== undefined &&
|
||||
evidence.confidence !== query.confidence
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
normalizedSearch &&
|
||||
!`${evidence.source} ${evidence.reason} ${evidence.linkedEntity.label}`
|
||||
.toLowerCase()
|
||||
.includes(normalizedSearch)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const toPaginationInput = (
|
||||
query: PaginationQuery,
|
||||
filters: PaginationInput["filters"],
|
||||
): PaginationInput => ({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
cursor: query.cursor,
|
||||
sort: query.sort,
|
||||
filters,
|
||||
});
|
||||
|
||||
const ensureProject = async (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
useDatabase: boolean,
|
||||
) => {
|
||||
const exists = useDatabase
|
||||
? await databaseEvidenceProjectExists(projectId)
|
||||
: Boolean(findProject(projectId));
|
||||
|
||||
if (!exists) {
|
||||
sendProjectNotFound(request, reply, projectId);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
type EvidenceRoutesOptions = {
|
||||
dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"];
|
||||
};
|
||||
|
||||
export const registerEvidenceRoutes = async (
|
||||
app: FastifyInstance,
|
||||
options: EvidenceRoutesOptions = {},
|
||||
) => {
|
||||
const useDatabase = options.dataSource === "database";
|
||||
|
||||
app.get(
|
||||
"/api/projects/:projectId/features/:featureId/evidence",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(featureParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!(await ensureProject(request, reply, params.projectId, useDatabase))) {
|
||||
return reply;
|
||||
}
|
||||
|
||||
const feature = useDatabase
|
||||
? await getFeatureBySlugOrId(params.projectId, params.featureId)
|
||||
: findFeature(params.projectId, params.featureId);
|
||||
if (!feature) {
|
||||
return reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"FEATURE_NOT_FOUND",
|
||||
`Feature '${params.featureId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const evidence = useDatabase
|
||||
? await listDatabaseEvidenceForFeature(params.projectId, params.featureId)
|
||||
: listFixtureEvidenceForFeature(params.projectId, params.featureId);
|
||||
|
||||
return successEnvelope(evidence ?? [], getRequestId(request));
|
||||
},
|
||||
);
|
||||
|
||||
app.get("/api/projects/:projectId/evidence", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!(await ensureProject(request, reply, params.projectId, useDatabase))) {
|
||||
return reply;
|
||||
}
|
||||
|
||||
const query = parseQuery(evidenceQuerySchema, request, reply);
|
||||
if (!query) return reply;
|
||||
|
||||
const filters = {
|
||||
...(query.type ? { type: splitCsv(query.type) } : {}),
|
||||
...(query.featureId ? { featureId: query.featureId } : {}),
|
||||
...(query.confidence !== undefined ? { confidence: query.confidence } : {}),
|
||||
...(query.search ? { search: query.search } : {}),
|
||||
...(query.cursor ? { cursor: query.cursor } : {}),
|
||||
};
|
||||
const sourceEvidence = useDatabase
|
||||
? await listDatabaseEvidenceForProject(params.projectId)
|
||||
: listFixtureEvidenceForProject(params.projectId);
|
||||
const filteredEvidence = sortByField(
|
||||
applyEvidenceFilters(sourceEvidence ?? [], query),
|
||||
query.sort,
|
||||
);
|
||||
|
||||
return successEnvelope(
|
||||
paginate(filteredEvidence, toPaginationInput(query, filters)),
|
||||
getRequestId(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/api/projects/:projectId/evidence/:evidenceId",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(evidenceParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!(await ensureProject(request, reply, params.projectId, useDatabase))) {
|
||||
return reply;
|
||||
}
|
||||
|
||||
const evidence = useDatabase
|
||||
? await getEvidenceBySlugOrId(params.projectId, params.evidenceId)
|
||||
: findEvidence(params.projectId, params.evidenceId);
|
||||
if (!evidence) {
|
||||
return reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"EVIDENCE_NOT_FOUND",
|
||||
`Evidence '${params.evidenceId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return successEnvelope(evidence, getRequestId(request));
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import {
|
||||
findFeature,
|
||||
listFeatureGroupsForProject as listFixtureFeatureGroupsForProject,
|
||||
listFeaturesForProject as listFixtureFeaturesForProject,
|
||||
paginate,
|
||||
type PaginationInput,
|
||||
} from "../fixtures/featureFixtures.js";
|
||||
import { findProject } from "../fixtures/projectFixtures.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import {
|
||||
listFeatureGroupsForProject as listDatabaseFeatureGroupsForProject,
|
||||
} from "../repositories/featureGroupsRepository.js";
|
||||
import {
|
||||
getFeatureBySlugOrId,
|
||||
listFeaturesForProject as listDatabaseFeaturesForProject,
|
||||
projectExists as databaseProjectExists,
|
||||
} from "../repositories/featuresRepository.js";
|
||||
import type { FeatureDto, FeatureStatus } from "../types/api.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
|
||||
const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/);
|
||||
|
||||
const projectParamsSchema = z.object({
|
||||
projectId: idSchema,
|
||||
});
|
||||
|
||||
const featureParamsSchema = z.object({
|
||||
projectId: idSchema,
|
||||
featureId: idSchema,
|
||||
});
|
||||
|
||||
const booleanQuerySchema = z.preprocess(
|
||||
(value) => {
|
||||
if (value === undefined || typeof value === "boolean") return value;
|
||||
if (typeof value !== "string") return undefined;
|
||||
const normalized = value.toLowerCase();
|
||||
if (["true", "1", "yes", "on"].includes(normalized)) return true;
|
||||
if (["false", "0", "no", "off"].includes(normalized)) return false;
|
||||
return undefined;
|
||||
},
|
||||
z.boolean().optional(),
|
||||
);
|
||||
|
||||
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 featuresQuerySchema = paginationQuerySchema.extend({
|
||||
status: z.string().optional(),
|
||||
confidence: z.coerce.number().int().min(0).max(100).optional(),
|
||||
hasEvidence: booleanQuerySchema,
|
||||
groupId: idSchema.optional(),
|
||||
search: z.string().optional(),
|
||||
});
|
||||
|
||||
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 sendProjectNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"PROJECT_NOT_FOUND",
|
||||
`Project '${projectId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
const splitCsv = (value: string | undefined): string[] =>
|
||||
value
|
||||
?.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean) ?? [];
|
||||
|
||||
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 leftValue = left[field];
|
||||
const rightValue = right[field];
|
||||
if (leftValue === rightValue) return 0;
|
||||
const comparison = String(leftValue ?? "").localeCompare(
|
||||
String(rightValue ?? ""),
|
||||
undefined,
|
||||
{ numeric: true },
|
||||
);
|
||||
return direction === "desc" ? -comparison : comparison;
|
||||
});
|
||||
};
|
||||
|
||||
type FeatureQuery = z.output<typeof featuresQuerySchema>;
|
||||
type PaginationQuery = z.output<typeof paginationQuerySchema>;
|
||||
|
||||
const applyFeatureFilters = (
|
||||
features: FeatureDto[],
|
||||
query: FeatureQuery,
|
||||
): FeatureDto[] => {
|
||||
const statuses = splitCsv(query.status) as FeatureStatus[];
|
||||
const normalizedSearch = query.search?.trim().toLowerCase();
|
||||
|
||||
return features.filter((feature) => {
|
||||
if (statuses.length > 0 && !statuses.includes(feature.status)) return false;
|
||||
if (query.confidence !== undefined && feature.confidence !== query.confidence) {
|
||||
return false;
|
||||
}
|
||||
if (query.groupId !== undefined && feature.groupId !== query.groupId) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
query.hasEvidence !== undefined &&
|
||||
(feature.evidenceIds.length > 0) !== query.hasEvidence
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
normalizedSearch &&
|
||||
!`${feature.name} ${feature.description}`
|
||||
.toLowerCase()
|
||||
.includes(normalizedSearch)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const toPaginationInput = (
|
||||
query: PaginationQuery,
|
||||
filters: PaginationInput["filters"],
|
||||
): PaginationInput => ({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
cursor: query.cursor,
|
||||
sort: query.sort,
|
||||
filters,
|
||||
});
|
||||
|
||||
const ensureProject = async (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
useDatabase: boolean,
|
||||
) => {
|
||||
const exists = useDatabase
|
||||
? await databaseProjectExists(projectId)
|
||||
: Boolean(findProject(projectId));
|
||||
|
||||
if (!exists) {
|
||||
sendProjectNotFound(request, reply, projectId);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
type FeatureRoutesOptions = {
|
||||
dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"];
|
||||
};
|
||||
|
||||
export const registerFeatureRoutes = async (
|
||||
app: FastifyInstance,
|
||||
options: FeatureRoutesOptions = {},
|
||||
) => {
|
||||
const useDatabase = options.dataSource === "database";
|
||||
|
||||
app.get("/api/projects/:projectId/feature-groups", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!(await ensureProject(request, reply, params.projectId, useDatabase))) {
|
||||
return reply;
|
||||
}
|
||||
|
||||
const groups = useDatabase
|
||||
? await listDatabaseFeatureGroupsForProject(params.projectId)
|
||||
: listFixtureFeatureGroupsForProject(params.projectId);
|
||||
|
||||
return successEnvelope(groups ?? [], getRequestId(request));
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/features", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!(await ensureProject(request, reply, params.projectId, useDatabase))) {
|
||||
return reply;
|
||||
}
|
||||
|
||||
const query = parseQuery(featuresQuerySchema, request, reply);
|
||||
if (!query) return reply;
|
||||
|
||||
const filters = {
|
||||
...(query.status ? { status: splitCsv(query.status) } : {}),
|
||||
...(query.confidence !== undefined ? { confidence: query.confidence } : {}),
|
||||
...(query.hasEvidence !== undefined ? { hasEvidence: query.hasEvidence } : {}),
|
||||
...(query.groupId ? { groupId: query.groupId } : {}),
|
||||
...(query.search ? { search: query.search } : {}),
|
||||
...(query.cursor ? { cursor: query.cursor } : {}),
|
||||
};
|
||||
const sourceFeatures = useDatabase
|
||||
? await listDatabaseFeaturesForProject(params.projectId)
|
||||
: listFixtureFeaturesForProject(params.projectId);
|
||||
const filteredFeatures = sortByField(
|
||||
applyFeatureFilters(sourceFeatures ?? [], query),
|
||||
query.sort,
|
||||
);
|
||||
|
||||
return successEnvelope(
|
||||
paginate(filteredFeatures, toPaginationInput(query, filters)),
|
||||
getRequestId(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/features/:featureId", async (request, reply) => {
|
||||
const params = parseParams(featureParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!(await ensureProject(request, reply, params.projectId, useDatabase))) {
|
||||
return reply;
|
||||
}
|
||||
|
||||
const feature = useDatabase
|
||||
? await getFeatureBySlugOrId(params.projectId, params.featureId)
|
||||
: findFeature(params.projectId, params.featureId);
|
||||
if (!feature) {
|
||||
return reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"FEATURE_NOT_FOUND",
|
||||
`Feature '${params.featureId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return successEnvelope(feature, getRequestId(request));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { FindingSeverity, FindingStatus, IssueSyncMode } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import {
|
||||
getFindingAutomationPolicy,
|
||||
getRepositoryFinding,
|
||||
listRepositoryFindings,
|
||||
startFindingInvestigation,
|
||||
suppressRepositoryFinding,
|
||||
syncFindingIssue,
|
||||
updateFindingAutomationPolicy,
|
||||
} from "../repositories/findingRepository.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
|
||||
const projectParams = z.object({ projectId: z.string().min(1) });
|
||||
const findingParams = projectParams.extend({ findingId: z.string().min(1) });
|
||||
const listQuery = z.object({
|
||||
status: z.string().optional(), severity: z.string().optional(), cursor: z.string().regex(/^\d+$/).optional(), limit: z.coerce.number().int().min(1).max(50).default(20),
|
||||
});
|
||||
const suppressBody = z.object({ reason: z.string().trim().min(3).max(2_000), until: z.string().datetime().optional() });
|
||||
const policyBody = z.object({
|
||||
enabled: z.boolean(), issueSyncMode: z.nativeEnum(IssueSyncMode), minimumSeverity: z.nativeEnum(FindingSeverity),
|
||||
minimumOccurrences: z.number().int().min(1).max(20), maxIssuesPerRun: z.number().int().min(1).max(25),
|
||||
autoCloseResolved: z.boolean(), autoReopenRegressed: z.boolean(), scheduledAnalysis: z.boolean(), scheduleMinutes: z.number().int().min(60).max(43_200),
|
||||
});
|
||||
|
||||
const ownerOnly = (request: FastifyRequest, reply: FastifyReply) => request.authUser?.role === "owner" ? false : Boolean(reply.status(403).send(errorEnvelope("OWNER_ACCESS_REQUIRED", "Owner access is required for finding mutations.", getRequestId(request))));
|
||||
const parseCsvEnum = <T extends string>(value: string | undefined, allowed: readonly T[]) => value?.split(",").map((part) => part.trim()).filter((part): part is T => allowed.includes(part as T));
|
||||
const findingError = (error: unknown, request: FastifyRequest, reply: FastifyReply) => {
|
||||
const code = error instanceof Error ? error.message : "FINDING_OPERATION_FAILED";
|
||||
const status = code.endsWith("NOT_FOUND") ? 404 : code.includes("WRITE_LINK_REQUIRED") || code.includes("FORBIDDEN") ? 403 : 409;
|
||||
return reply.status(status).send(errorEnvelope(code, "The finding operation could not be completed.", getRequestId(request)));
|
||||
};
|
||||
|
||||
const requireProjectFinding = async (projectId: string, findingId: string) => {
|
||||
const finding = await getRepositoryFinding(findingId);
|
||||
if (!finding.project || ![finding.project.id, finding.project.slug].includes(projectId)) throw new Error("FINDING_NOT_FOUND");
|
||||
return finding;
|
||||
};
|
||||
|
||||
export const registerFindingRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => {
|
||||
app.get("/api/projects/:projectId/findings", async (request, reply) => {
|
||||
const { projectId } = projectParams.parse(request.params);
|
||||
const query = listQuery.parse(request.query ?? {});
|
||||
try {
|
||||
const result = await listRepositoryFindings({
|
||||
projectId,
|
||||
statuses: parseCsvEnum(query.status, Object.values(FindingStatus)),
|
||||
severities: parseCsvEnum(query.severity, Object.values(FindingSeverity)),
|
||||
cursor: query.cursor,
|
||||
limit: query.limit,
|
||||
});
|
||||
return successEnvelope(result, getRequestId(request));
|
||||
} catch (error) { return findingError(error, request, reply); }
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/findings/:findingId", async (request, reply) => {
|
||||
const { projectId, findingId } = findingParams.parse(request.params);
|
||||
try { return successEnvelope(await requireProjectFinding(projectId, findingId), getRequestId(request)); }
|
||||
catch (error) { return findingError(error, request, reply); }
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/findings/:findingId/issue", async (request, reply) => {
|
||||
if (ownerOnly(request, reply)) return reply;
|
||||
const { projectId, findingId } = findingParams.parse(request.params);
|
||||
try {
|
||||
await requireProjectFinding(projectId, findingId);
|
||||
return reply.status(201).send(successEnvelope(await syncFindingIssue(options.env, findingId, request.authUser?.username ?? "owner"), getRequestId(request)));
|
||||
} catch (error) { return findingError(error, request, reply); }
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/findings/:findingId/investigations", async (request, reply) => {
|
||||
if (ownerOnly(request, reply)) return reply;
|
||||
const { projectId, findingId } = findingParams.parse(request.params);
|
||||
try {
|
||||
await requireProjectFinding(projectId, findingId);
|
||||
return reply.status(201).send(successEnvelope(await startFindingInvestigation(options.env, findingId, request.authUser?.username ?? "owner"), getRequestId(request)));
|
||||
} catch (error) { return findingError(error, request, reply); }
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/findings/:findingId/suppress", async (request, reply) => {
|
||||
if (ownerOnly(request, reply)) return reply;
|
||||
const { projectId, findingId } = findingParams.parse(request.params);
|
||||
const body = suppressBody.parse(request.body ?? {});
|
||||
try {
|
||||
await requireProjectFinding(projectId, findingId);
|
||||
return successEnvelope(await suppressRepositoryFinding(findingId, body.reason, body.until ? new Date(body.until) : undefined), getRequestId(request));
|
||||
} catch (error) { return findingError(error, request, reply); }
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/finding-automation-policy", async (request, reply) => {
|
||||
const { projectId } = projectParams.parse(request.params);
|
||||
try { return successEnvelope(await getFindingAutomationPolicy(projectId), getRequestId(request)); }
|
||||
catch (error) { return findingError(error, request, reply); }
|
||||
});
|
||||
|
||||
app.put("/api/projects/:projectId/finding-automation-policy", async (request, reply) => {
|
||||
if (ownerOnly(request, reply)) return reply;
|
||||
const { projectId } = projectParams.parse(request.params);
|
||||
const body = policyBody.parse(request.body ?? {});
|
||||
try { return successEnvelope(await updateFindingAutomationPolicy(projectId, { ...body, updatedBy: request.authUser?.username ?? "owner" }), getRequestId(request)); }
|
||||
catch (error) { return findingError(error, request, reply); }
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,492 @@
|
||||
import { assertDeliveryWriteAllowed } from "../services/featureDeliveryFlow.js";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import { GiteaApiError } from "../integrations/gitea/client.js";
|
||||
import { assertExpectedFileSha, assertFileSize, assertProjectBrainBranch, assertReadablePath, assertSuccessfulActionRuns, assertWritablePath, findReusableDeliveryPullRequest } from "../integrations/gitea/policy.js";
|
||||
import { finishExternalOperation, getGiteaClient, getLinkedRepository, GiteaRepositoryOwnershipConflictError, GiteaRepositoryTransferBlockedError, linkRepository, listLinkedRepositories, listProjectRepositories, markExternalOperationExecuting, prepareExternalOperation, recordMcpAudit, unlinkRepository, unlinkRepositoryById, type LinkedRepository } from "../integrations/gitea/service.js";
|
||||
import type { GiteaContent } from "../integrations/gitea/types.js";
|
||||
import { assertFeatureDeliveryActionEvidence, getFeatureDelivery, openFeatureDeliveryPullRequest, reconcileFeatureDeliveryPullRequest, recordFeatureDeliveryActionValidation, recordFeatureDeliveryProgress, startFeatureDelivery } from "../repositories/featureDeliveryRepository.js";
|
||||
import { queueFindingVerificationForDelivery, reconcileFindingIssueWebhook } from "../repositories/findingRepository.js";
|
||||
import { createPortfolioAnalysisJob } from "../repositories/analysisJobRepository.js";
|
||||
import { syncGitSources } from "../services/gitSourceSync.js";
|
||||
import { reconcileGiteaAutonomyExecution } from "../services/giteaAutonomyExecution.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
|
||||
declare module "fastify" { interface FastifyRequest { rawBody?: Buffer } }
|
||||
|
||||
export const hasGiteaAdminAccess = (role?: string) => role === "owner";
|
||||
const ownerOnly = (request: { authUser?: { role: string } }, reply: { status: (code: number) => { send: (body: unknown) => unknown } }, requestId: string) =>
|
||||
hasGiteaAdminAccess(request.authUser?.role) ? undefined : reply.status(403).send(errorEnvelope("OWNER_ACCESS_REQUIRED", "Owner access is required for Gitea integration administration", requestId));
|
||||
|
||||
const linkSchema = z.object({
|
||||
repository: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/),
|
||||
accessMode: z.enum(["read_only", "read_write"]).default("read_write"),
|
||||
role: z.enum(["primary", "frontend", "backend", "infra", "docs", "shared", "other"]).optional(),
|
||||
isPrimary: z.boolean().optional(),
|
||||
transfer: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
const treeQuerySchema = z.object({ path: z.string().max(1000).optional().default(""), ref: z.string().max(200).optional(), repositoryId: z.string().min(1).optional() });
|
||||
const fileQuerySchema = z.object({ path: z.string().min(1).max(1000), ref: z.string().max(200).optional(), repositoryId: z.string().min(1).optional() });
|
||||
const branchSchema = z.object({ deliveryId: z.string().min(1), repositoryId: z.string().min(1).optional(), branch: z.string().min(1).max(300), sourceBranch: z.string().min(1).max(300).optional() });
|
||||
const fileWriteSchema = z.object({
|
||||
deliveryId: z.string().min(1),
|
||||
branch: z.string().min(1).max(300), path: z.string().min(1).max(1000), content: z.string(),
|
||||
message: z.string().trim().min(3).max(200), expectedSha: z.string().min(1).max(200).optional(),
|
||||
});
|
||||
const pullRequestSchema = z.object({
|
||||
deliveryId: z.string().min(1),
|
||||
branch: z.string().min(1).max(300), title: z.string().trim().min(3).max(200), body: z.string().max(20_000).default(""),
|
||||
});
|
||||
const deleteFileSchema = z.object({ deliveryId: z.string().min(1), branch: z.string().min(1).max(300), path: z.string().min(1).max(1000), message: z.string().trim().min(3).max(200), expectedSha: z.string().min(1).max(200) });
|
||||
const mergePullRequestSchema = z.object({
|
||||
deliveryId: z.string().min(1), expectedHeadSha: z.string().regex(/^[0-9a-f]{40,64}$/i),
|
||||
method: z.enum(["merge", "rebase", "rebase-merge", "squash", "fast-forward-only"]).default("squash"), deleteBranch: z.boolean().default(true),
|
||||
});
|
||||
const pullRequestWebhookSchema = z.object({
|
||||
action: z.string(), number: z.number().int().positive(),
|
||||
repository: z.object({ full_name: z.string().regex(/^[^/]+\/[^/]+$/) }),
|
||||
pull_request: z.object({ number: z.number().int().positive(), state: z.string(), merged: z.boolean(), merged_at: z.string().optional().nullable(), merge_commit_sha: z.string().optional().nullable(), head: z.object({ sha: z.string() }) }),
|
||||
sender: z.object({ login: z.string() }).optional(),
|
||||
});
|
||||
const issueWebhookSchema = z.object({
|
||||
action: z.string(),
|
||||
repository: z.object({ full_name: z.string().regex(/^[^/]+\/[^/]+$/) }),
|
||||
issue: z.object({ number: z.number().int().positive(), state: z.string(), title: z.string(), html_url: z.string().url() }),
|
||||
sender: z.object({ login: z.string() }).optional(),
|
||||
});
|
||||
const pushWebhookSchema = z.object({
|
||||
ref: z.string().min(1),
|
||||
after: z.string().optional(),
|
||||
repository: z.object({ full_name: z.string().regex(/^[^/]+\/[^/]+$/) }),
|
||||
sender: z.object({ login: z.string() }).optional(),
|
||||
});
|
||||
|
||||
export const verifyGiteaWebhookSignature = (body: Buffer, signature: string | undefined, secret: string) => {
|
||||
if (!signature || !/^[a-f0-9]{64}$/i.test(signature)) return false;
|
||||
const expected = Buffer.from(createHmac("sha256", secret).update(body).digest("hex"), "utf8");
|
||||
const supplied = Buffer.from(signature.toLowerCase(), "utf8");
|
||||
return expected.length === supplied.length && timingSafeEqual(expected, supplied);
|
||||
};
|
||||
|
||||
export const integrationError = (error: unknown): { status: number; code: string; message: string } => {
|
||||
if (error instanceof GiteaRepositoryOwnershipConflictError) {
|
||||
return {
|
||||
status: 409,
|
||||
code: error.code,
|
||||
message: `${error.repositoryFullName} is al gekoppeld aan project '${error.sourceProjectName}'. Bevestig expliciet dat je de repository naar dit project wilt verplaatsen.`,
|
||||
};
|
||||
}
|
||||
if (error instanceof GiteaRepositoryTransferBlockedError) {
|
||||
return {
|
||||
status: 409,
|
||||
code: error.code,
|
||||
message: `${error.repositoryFullName} heeft ${error.deliveryRunCount} gekoppelde deliveryrun(s). Verplaatsen is geblokkeerd om projectgeschiedenis niet te beschadigen.`,
|
||||
};
|
||||
}
|
||||
if (error instanceof GiteaApiError) return { status: error.status === 404 ? 404 : 502, code: error.code, message: error.message };
|
||||
const code = error instanceof Error ? error.message : "GITEA_INTEGRATION_FAILED";
|
||||
if (code === "PROJECT_NOT_FOUND") return { status: 404, code, message: "Project not found" };
|
||||
if (code === "GITEA_LINK_NOT_FOUND") return { status: 404, code, message: "No Gitea repository is linked to this project" };
|
||||
if (code === "GITEA_CONTENT_NOT_FILE") return { status: 400, code, message: "The selected repository entry is not a readable text file" };
|
||||
if (code === "GITEA_NOT_CONFIGURED") return { status: 503, code, message: "Gitea is not configured on this server" };
|
||||
if (code === "GITEA_WRITE_ACCESS_REQUIRED") return { status: 403, code, message: "The configured Gitea identity has no write access" };
|
||||
if (["PROJECTBRAIN_BRANCH_FORBIDDEN", "DEFAULT_BRANCH_WRITE_FORBIDDEN", "SENSITIVE_PATH_FORBIDDEN", "REPOSITORY_PATH_FORBIDDEN"].includes(code)) return { status: 403, code, message: "The requested repository operation is outside the ProjectBrain safety policy" };
|
||||
if (code === "MCP_FILE_TOO_LARGE") return { status: 413, code, message: "The file is larger than the ProjectBrain editing limit" };
|
||||
if (code === "GITEA_FILE_CONFLICT") return { status: 409, code, message: "This file changed after you opened it. Reload it before committing your changes" };
|
||||
if (code === "GITEA_REPOSITORY_READ_ONLY") return { status: 403, code, message: "This ProjectBrain project has read-only repository access" };
|
||||
if (code.startsWith("FEATURE_DELIVERY_")) return { status: code.endsWith("NOT_FOUND") ? 404 : 409, code, message: "This repository change is not allowed by the selected delivery plan" };
|
||||
return { status: 400, code, message: "The Gitea repository link could not be saved" };
|
||||
};
|
||||
|
||||
const decodeFile = (content: GiteaContent) => {
|
||||
if (content.type !== "file" || content.encoding !== "base64" || typeof content.content !== "string") throw new Error("GITEA_CONTENT_NOT_FILE");
|
||||
return Buffer.from(content.content.replace(/\s/g, ""), "base64").toString("utf8");
|
||||
};
|
||||
|
||||
const requireWorkbenchLink = async (projectId: string, write = false, repositoryId?: string): Promise<LinkedRepository> => {
|
||||
const link = repositoryId
|
||||
? (await listProjectRepositories(projectId)).find((candidate) => candidate.id === repositoryId)
|
||||
: await getLinkedRepository(projectId);
|
||||
if (!link) throw new Error("GITEA_LINK_NOT_FOUND");
|
||||
if (write && link.accessMode !== "read_write") throw new Error("GITEA_REPOSITORY_READ_ONLY");
|
||||
return link;
|
||||
};
|
||||
|
||||
type WorkbenchAuditInput = { link?: LinkedRepository; actor: string; toolName: string; branch?: string; outcome: "succeeded" | "failed" | "denied"; errorCode?: string; started: number };
|
||||
|
||||
const recordWorkbenchAudit = (input: WorkbenchAuditInput) =>
|
||||
recordMcpAudit({
|
||||
projectId: input.link?.projectId, actor: input.actor, toolName: `workbench.${input.toolName}`,
|
||||
repository: input.link?.fullName, branch: input.branch, outcome: input.outcome, errorCode: input.errorCode,
|
||||
durationMs: Date.now() - input.started,
|
||||
});
|
||||
|
||||
const beginWorkbenchMutation = async (input: Omit<WorkbenchAuditInput, "outcome" | "errorCode">) => {
|
||||
if (!input.link) throw new Error("GITEA_LINK_NOT_FOUND");
|
||||
const operation = await prepareExternalOperation({ projectId: input.link.projectId, actor: input.actor, action: `workbench.${input.toolName}`, target: { repository: input.link.fullName, branch: input.branch } });
|
||||
await markExternalOperationExecuting(operation.operationId);
|
||||
return operation;
|
||||
};
|
||||
|
||||
const finishWorkbenchMutation = async (operationId: string, outcome: "succeeded" | "failed" | "denied", started: number, errorCode?: string) => {
|
||||
const operation = await finishExternalOperation(operationId, { status: outcome === "succeeded" ? "succeeded" : "failed", errorCode });
|
||||
const target = operation.targetJson && typeof operation.targetJson === "object" && !Array.isArray(operation.targetJson) ? operation.targetJson as Record<string, unknown> : {};
|
||||
await recordMcpAudit({ operationId, projectId: operation.projectId, actor: operation.actor, toolName: operation.action, repository: typeof target.repository === "string" ? target.repository : undefined, branch: typeof target.branch === "string" ? target.branch : undefined, outcome, errorCode, durationMs: Date.now() - started });
|
||||
};
|
||||
|
||||
export const registerGiteaIntegrationRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => {
|
||||
const { env } = options;
|
||||
|
||||
app.post("/api/integrations/gitea/webhook", async (request, reply) => {
|
||||
if (!env.PROJECTBRAIN_GITEA_WEBHOOK_SECRET) return reply.status(503).send(errorEnvelope("GITEA_WEBHOOK_NOT_CONFIGURED", "Gitea webhook processing is not configured", getRequestId(request)));
|
||||
const signature = Array.isArray(request.headers["x-gitea-signature"]) ? request.headers["x-gitea-signature"][0] : request.headers["x-gitea-signature"];
|
||||
if (!request.rawBody || !verifyGiteaWebhookSignature(request.rawBody, signature, env.PROJECTBRAIN_GITEA_WEBHOOK_SECRET)) return reply.status(401).send(errorEnvelope("GITEA_WEBHOOK_SIGNATURE_INVALID", "Webhook signature is invalid", getRequestId(request)));
|
||||
const event = Array.isArray(request.headers["x-gitea-event"]) ? request.headers["x-gitea-event"][0] : request.headers["x-gitea-event"];
|
||||
const deliveryHeader = Array.isArray(request.headers["x-gitea-delivery"]) ? request.headers["x-gitea-delivery"][0] : request.headers["x-gitea-delivery"];
|
||||
const operationId = deliveryHeader ? `gitea-webhook:${deliveryHeader}` : undefined;
|
||||
if (operationId && await prisma.mcpAuditEvent.findUnique({ where: { operationId } })) return successEnvelope({ accepted: true, duplicate: true }, getRequestId(request));
|
||||
if (event === "push") {
|
||||
const parsed = pushWebhookSchema.safeParse(request.body);
|
||||
if (!parsed.success) return reply.status(400).send(errorEnvelope("GITEA_WEBHOOK_PAYLOAD_INVALID", "Webhook payload is invalid", getRequestId(request)));
|
||||
const [owner, repository] = parsed.data.repository.full_name.split("/");
|
||||
const actor = parsed.data.sender?.login ?? "gitea-webhook";
|
||||
const link = await prisma.giteaRepositoryLink.findUnique({ where: { owner_repository: { owner, repository } }, select: { id: true, projectId: true } });
|
||||
const recent = link ? await prisma.analysisJob.findFirst({
|
||||
where: { actor: "system:webhook:push", createdAt: { gte: new Date(Date.now() - 5 * 60_000) }, items: { some: { repositoryLinkId: link.id } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, status: true },
|
||||
}) : undefined;
|
||||
const analysis = link && !recent
|
||||
? await createPortfolioAnalysisJob(env, "system:webhook:push", { projectIds: [link.projectId], repositoryIds: [link.id], includeBranches: true, includePullRequests: true, includeActions: true })
|
||||
: recent ? { job: recent, deduplicated: true } : undefined;
|
||||
const sourceSync = link ? await syncGitSources(env, { repositoryLinkId: link.id }).catch(() => undefined) : undefined;
|
||||
const autonomy = link ? await reconcileGiteaAutonomyExecution(env, link.projectId, actor).catch(() => undefined) : undefined;
|
||||
await recordMcpAudit({ operationId, projectId: link?.projectId, actor, toolName: "webhook.push", repository: parsed.data.repository.full_name, branch: parsed.data.ref.replace(/^refs\/heads\//, ""), outcome: "succeeded", durationMs: 0, metadata: { matched: Boolean(link), analysisQueued: Boolean(analysis), sourceSync, autonomyReconciled: Boolean(autonomy?.changed), deduplicated: analysis?.deduplicated ?? false } });
|
||||
return successEnvelope({ accepted: true, matched: Boolean(link), analysis, autonomy }, getRequestId(request));
|
||||
}
|
||||
if (event === "pull_request") {
|
||||
const parsed = pullRequestWebhookSchema.safeParse(request.body);
|
||||
if (!parsed.success) return reply.status(400).send(errorEnvelope("GITEA_WEBHOOK_PAYLOAD_INVALID", "Webhook payload is invalid", getRequestId(request)));
|
||||
const [owner, repository] = parsed.data.repository.full_name.split("/");
|
||||
const actor = parsed.data.sender?.login ?? "gitea-webhook";
|
||||
const repositoryLink = await prisma.giteaRepositoryLink.findUnique({
|
||||
where: { owner_repository: { owner, repository } },
|
||||
select: { id: true, projectId: true },
|
||||
});
|
||||
const reconciled = await reconcileFeatureDeliveryPullRequest({ owner, repository, number: parsed.data.pull_request.number, state: parsed.data.pull_request.state === "open" ? "open" : "closed", merged: parsed.data.pull_request.merged, mergedAt: parsed.data.pull_request.merged_at ?? undefined, mergeCommitSha: parsed.data.pull_request.merge_commit_sha ?? undefined, headSha: parsed.data.pull_request.head.sha, actor });
|
||||
const verification = reconciled && parsed.data.pull_request.merged ? await queueFindingVerificationForDelivery(env, reconciled.projectId, reconciled.id).catch(() => undefined) : undefined;
|
||||
const autonomy = repositoryLink ? await reconcileGiteaAutonomyExecution(env, repositoryLink.projectId, actor).catch(() => undefined) : undefined;
|
||||
await recordMcpAudit({ operationId, projectId: repositoryLink?.projectId ?? reconciled?.projectId, actor, toolName: "webhook.pull_request", repository: parsed.data.repository.full_name, outcome: "succeeded", durationMs: 0, metadata: { action: parsed.data.action, pullRequestNumber: parsed.data.pull_request.number, matchedRepository: Boolean(repositoryLink), matchedDelivery: Boolean(reconciled), autonomyReconciled: Boolean(autonomy?.changed), verificationQueued: Boolean(verification) } });
|
||||
return successEnvelope({ accepted: true, matched: Boolean(repositoryLink ?? reconciled), delivery: reconciled, autonomy, verification }, getRequestId(request));
|
||||
}
|
||||
if (event === "issues" || event === "issue_comment") {
|
||||
const parsed = issueWebhookSchema.safeParse(request.body);
|
||||
if (!parsed.success) return reply.status(400).send(errorEnvelope("GITEA_WEBHOOK_PAYLOAD_INVALID", "Webhook payload is invalid", getRequestId(request)));
|
||||
const [owner, repository] = parsed.data.repository.full_name.split("/");
|
||||
const actor = parsed.data.sender?.login ?? "gitea-webhook";
|
||||
const finding = await reconcileFindingIssueWebhook({ owner, repository, issueNumber: parsed.data.issue.number, state: parsed.data.issue.state, title: parsed.data.issue.title, url: parsed.data.issue.html_url, actor });
|
||||
await recordMcpAudit({ operationId, projectId: finding?.projectId ?? undefined, actor, toolName: `webhook.${event}`, repository: parsed.data.repository.full_name, outcome: "succeeded", durationMs: 0, metadata: { action: parsed.data.action, issueNumber: parsed.data.issue.number, matched: Boolean(finding) } });
|
||||
return successEnvelope({ accepted: true, matched: Boolean(finding), finding }, getRequestId(request));
|
||||
}
|
||||
return successEnvelope({ accepted: true, ignored: true, event }, getRequestId(request));
|
||||
});
|
||||
|
||||
app.get("/api/integrations/gitea/status", async (request) => {
|
||||
const configured = Boolean(env.PROJECTBRAIN_GITEA_URL && env.PROJECTBRAIN_GITEA_TOKEN);
|
||||
if (!configured) return successEnvelope({ configured: false, reachable: false, mcpEnabled: env.PROJECTBRAIN_MCP_ENABLED, publicUrl: env.PROJECTBRAIN_PUBLIC_URL, linkedRepositories: 0 }, getRequestId(request));
|
||||
try {
|
||||
const [user, linkedRepositories] = await Promise.all([getGiteaClient(env).getCurrentUser(), prisma.giteaRepositoryLink.count()]);
|
||||
return successEnvelope({ configured: true, reachable: true, baseUrl: env.PROJECTBRAIN_GITEA_URL, identity: user.login, mcpEnabled: env.PROJECTBRAIN_MCP_ENABLED, mcpUrl: env.PROJECTBRAIN_PUBLIC_URL ? `${env.PROJECTBRAIN_PUBLIC_URL.replace(/\/$/, "")}/mcp` : undefined, linkedRepositories }, getRequestId(request));
|
||||
} catch (error) {
|
||||
return successEnvelope({ configured: true, reachable: false, baseUrl: env.PROJECTBRAIN_GITEA_URL, mcpEnabled: env.PROJECTBRAIN_MCP_ENABLED, error: integrationError(error).message, linkedRepositories: await prisma.giteaRepositoryLink.count() }, getRequestId(request));
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/integrations/gitea/repositories", async (request, reply) => {
|
||||
const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied;
|
||||
try {
|
||||
const repositories = await getGiteaClient(env).listRepositories();
|
||||
return successEnvelope(repositories.map((repo) => ({ id: repo.id, name: repo.name, fullName: repo.full_name, description: repo.description, private: repo.private, archived: repo.archived, defaultBranch: repo.default_branch, htmlUrl: repo.html_url, updatedAt: repo.updated_at, canPush: Boolean(repo.permissions?.push || repo.permissions?.admin) })), getRequestId(request));
|
||||
} catch (error) {
|
||||
const failure = integrationError(error);
|
||||
return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request)));
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/integrations/gitea/links", async (request, reply) => {
|
||||
const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied;
|
||||
return successEnvelope(await listLinkedRepositories(), getRequestId(request));
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/gitea", async (request, reply) => {
|
||||
const projectId = (request.params as { projectId: string }).projectId;
|
||||
const link = await getLinkedRepository(projectId);
|
||||
if (!link) return reply.status(404).send(errorEnvelope("GITEA_LINK_NOT_FOUND", "No Gitea repository is linked to this project", getRequestId(request)));
|
||||
return successEnvelope(link, getRequestId(request));
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/gitea/repositories", async (request) =>
|
||||
successEnvelope(await listProjectRepositories((request.params as { projectId: string }).projectId), getRequestId(request)));
|
||||
|
||||
app.get("/api/projects/:projectId/gitea/workspace", async (request, reply) => {
|
||||
const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied;
|
||||
const parsed = treeQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) return reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "A valid repository path and ref are required", getRequestId(request)));
|
||||
try {
|
||||
const link = await requireWorkbenchLink((request.params as { projectId: string }).projectId, false, parsed.data.repositoryId);
|
||||
const client = getGiteaClient(env);
|
||||
const ref = parsed.data.ref ?? link.defaultBranch;
|
||||
const path = parsed.data.path ? assertReadablePath(parsed.data.path) : "";
|
||||
const [contents, branches, pullRequests] = await Promise.all([
|
||||
client.getContents(link.owner, link.repository, path, ref),
|
||||
client.listBranches(link.owner, link.repository),
|
||||
client.listPullRequests(link.owner, link.repository, "open"),
|
||||
]);
|
||||
const entries = (Array.isArray(contents) ? contents : [contents]).map(({ content: _content, ...entry }) => entry);
|
||||
return successEnvelope({ link, path, ref, entries, branches, pullRequests }, getRequestId(request));
|
||||
} catch (error) {
|
||||
const failure = integrationError(error);
|
||||
return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request)));
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/gitea/file", async (request, reply) => {
|
||||
const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied;
|
||||
const parsed = fileQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) return reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "A valid file path and ref are required", getRequestId(request)));
|
||||
try {
|
||||
const link = await requireWorkbenchLink((request.params as { projectId: string }).projectId, false, parsed.data.repositoryId);
|
||||
const path = assertReadablePath(parsed.data.path);
|
||||
const ref = parsed.data.ref ?? link.defaultBranch;
|
||||
const file = await getGiteaClient(env).getContents(link.owner, link.repository, path, ref);
|
||||
if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE");
|
||||
return successEnvelope({ path, ref, sha: file.sha, size: file.size, content: decodeFile(file), htmlUrl: file.html_url }, getRequestId(request));
|
||||
} catch (error) {
|
||||
const failure = integrationError(error);
|
||||
return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request)));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/gitea/branches", async (request, reply) => {
|
||||
const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied;
|
||||
const parsed = branchSchema.safeParse(request.body);
|
||||
if (!parsed.success) return reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "A valid ChatGPT or ProjectBrain delivery branch is required", getRequestId(request)));
|
||||
const started = Date.now(); let link: LinkedRepository | undefined; let auditId: string | undefined;
|
||||
try {
|
||||
link = await requireWorkbenchLink((request.params as { projectId: string }).projectId, true, parsed.data.repositoryId);
|
||||
const branch = assertProjectBrainBranch(parsed.data.branch, link.defaultBranch);
|
||||
const delivery = await getFeatureDelivery(link.projectId, parsed.data.deliveryId);
|
||||
if (delivery.approvalState !== "approved" || delivery.spec.status !== "approved") throw new Error("FEATURE_DELIVERY_APPROVAL_REQUIRED");
|
||||
auditId = (await beginWorkbenchMutation({ link, actor: request.authUser?.username ?? "owner", toolName: "create_branch", branch, started })).operationId;
|
||||
const client = getGiteaClient(env);
|
||||
const base = await client.getBranch(link.owner, link.repository, parsed.data.sourceBranch ?? link.defaultBranch);
|
||||
const created = await client.createBranch(link.owner, link.repository, branch, parsed.data.sourceBranch ?? link.defaultBranch);
|
||||
const updatedDelivery = await startFeatureDelivery(link.projectId, parsed.data.deliveryId, branch, request.authUser?.username ?? "owner", { repositoryLinkId: link.id, baseCommitSha: base.commit.id, headSha: created.commit.id });
|
||||
await finishWorkbenchMutation(auditId, "succeeded", started);
|
||||
return reply.status(201).send(successEnvelope({ branch: created, repository: link.fullName, delivery: updatedDelivery }, getRequestId(request)));
|
||||
} catch (error) {
|
||||
const failure = integrationError(error); const deniedOutcome = failure.status === 403 ? "denied" : "failed";
|
||||
if (auditId) await finishWorkbenchMutation(auditId, deniedOutcome, started, failure.code).catch(() => undefined);
|
||||
else await recordWorkbenchAudit({ link, actor: request.authUser?.username ?? "owner", toolName: "create_branch", branch: parsed.data.branch, outcome: deniedOutcome, errorCode: failure.code, started }).catch(() => undefined);
|
||||
return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request)));
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/projects/:projectId/gitea/file", async (request, reply) => {
|
||||
const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied;
|
||||
const parsed = fileWriteSchema.safeParse(request.body);
|
||||
if (!parsed.success) return reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "A branch, safe path, content and commit message are required", getRequestId(request)));
|
||||
const started = Date.now(); let link: LinkedRepository | undefined; let auditId: string | undefined;
|
||||
try {
|
||||
const delivery = await getFeatureDelivery((request.params as { projectId: string }).projectId, parsed.data.deliveryId);
|
||||
link = await requireWorkbenchLink((request.params as { projectId: string }).projectId, true, delivery.spec.repositoryId);
|
||||
const branch = assertProjectBrainBranch(parsed.data.branch, link.defaultBranch);
|
||||
const path = assertWritablePath(parsed.data.path); assertFileSize(parsed.data.content);
|
||||
const client = getGiteaClient(env); await client.getBranch(link.owner, link.repository, branch);
|
||||
await assertDeliveryWriteAllowed(delivery, branch, client, link);
|
||||
let existing: GiteaContent | undefined;
|
||||
try { const current = await client.getContents(link.owner, link.repository, path, branch); if (!Array.isArray(current)) existing = current; }
|
||||
catch (error) { if (!(error instanceof GiteaApiError && error.status === 404)) throw error; }
|
||||
assertExpectedFileSha(parsed.data.expectedSha, existing?.sha);
|
||||
auditId = (await beginWorkbenchMutation({ link, actor: request.authUser?.username ?? "owner", toolName: "upsert_file", branch, started })).operationId;
|
||||
const result = existing
|
||||
? await client.updateFile(link.owner, link.repository, path, branch, parsed.data.expectedSha!, parsed.data.content, parsed.data.message)
|
||||
: await client.createFile(link.owner, link.repository, path, branch, parsed.data.content, parsed.data.message);
|
||||
const [head, current] = await Promise.all([client.getBranch(link.owner, link.repository, branch), client.getContents(link.owner, link.repository, path, branch)]);
|
||||
const written = Array.isArray(current) ? undefined : current;
|
||||
const updatedDelivery = await recordFeatureDeliveryProgress(link.projectId, parsed.data.deliveryId, { changes: [{ path, operation: existing ? "update" : "create", beforeSha: existing?.sha, afterSha: written?.sha, commitSha: head.commit.id }], headSha: head.commit.id }, request.authUser?.username ?? "owner");
|
||||
await finishWorkbenchMutation(auditId, "succeeded", started);
|
||||
return successEnvelope({ repository: link.fullName, branch, path, action: existing ? "updated" : "created", result, delivery: updatedDelivery }, getRequestId(request));
|
||||
} catch (error) {
|
||||
const failure = integrationError(error); const deniedOutcome = failure.status === 403 ? "denied" : "failed";
|
||||
if (auditId) await finishWorkbenchMutation(auditId, deniedOutcome, started, failure.code).catch(() => undefined);
|
||||
else await recordWorkbenchAudit({ link, actor: request.authUser?.username ?? "owner", toolName: "upsert_file", branch: parsed.data.branch, outcome: deniedOutcome, errorCode: failure.code, started }).catch(() => undefined);
|
||||
return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request)));
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/projects/:projectId/gitea/file", async (request, reply) => {
|
||||
const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied;
|
||||
const parsed = deleteFileSchema.safeParse(request.body);
|
||||
if (!parsed.success) return reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "A delivery, branch, path, SHA and commit message are required", getRequestId(request)));
|
||||
const started = Date.now(); let link: LinkedRepository | undefined; let auditId: string | undefined;
|
||||
try {
|
||||
const delivery = await getFeatureDelivery((request.params as { projectId: string }).projectId, parsed.data.deliveryId);
|
||||
link = await requireWorkbenchLink((request.params as { projectId: string }).projectId, true, delivery.spec.repositoryId);
|
||||
const branch = assertProjectBrainBranch(parsed.data.branch, link.defaultBranch);
|
||||
const path = assertWritablePath(parsed.data.path); const client = getGiteaClient(env);
|
||||
await assertDeliveryWriteAllowed(delivery, branch, client, link);
|
||||
const file = await client.getContents(link.owner, link.repository, path, branch); if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE");
|
||||
assertExpectedFileSha(parsed.data.expectedSha, file.sha);
|
||||
auditId = (await beginWorkbenchMutation({ link, actor: request.authUser?.username ?? "owner", toolName: "delete_file", branch, started })).operationId;
|
||||
const result = await client.deleteFile(link.owner, link.repository, path, branch, file.sha, parsed.data.message);
|
||||
const head = await client.getBranch(link.owner, link.repository, branch);
|
||||
const updatedDelivery = await recordFeatureDeliveryProgress(link.projectId, parsed.data.deliveryId, { changes: [{ path, operation: "delete", beforeSha: file.sha, commitSha: head.commit.id }], headSha: head.commit.id }, request.authUser?.username ?? "owner");
|
||||
await finishWorkbenchMutation(auditId, "succeeded", started);
|
||||
return successEnvelope({ repository: link.fullName, branch, path, result, delivery: updatedDelivery }, getRequestId(request));
|
||||
} catch (error) {
|
||||
const failure = integrationError(error); const deniedOutcome = failure.status === 403 ? "denied" : "failed";
|
||||
if (auditId) await finishWorkbenchMutation(auditId, deniedOutcome, started, failure.code).catch(() => undefined);
|
||||
else await recordWorkbenchAudit({ link, actor: request.authUser?.username ?? "owner", toolName: "delete_file", branch: parsed.data.branch, outcome: deniedOutcome, errorCode: failure.code, started }).catch(() => undefined);
|
||||
return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request)));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/gitea/pull-requests", async (request, reply) => {
|
||||
const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied;
|
||||
const parsed = pullRequestSchema.safeParse(request.body);
|
||||
if (!parsed.success) return reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "A branch, title and optional description are required", getRequestId(request)));
|
||||
const started = Date.now(); let link: LinkedRepository | undefined; let auditId: string | undefined;
|
||||
try {
|
||||
const delivery = await getFeatureDelivery((request.params as { projectId: string }).projectId, parsed.data.deliveryId);
|
||||
link = await requireWorkbenchLink((request.params as { projectId: string }).projectId, true, delivery.spec.repositoryId);
|
||||
const branch = assertProjectBrainBranch(parsed.data.branch, link.defaultBranch);
|
||||
if (delivery.approvalState !== "approved" || delivery.spec.branch !== branch || delivery.spec.status !== "validating") throw new Error("FEATURE_DELIVERY_COMPLETION_FORBIDDEN");
|
||||
if (delivery.spec.validationResults.some((result) => result.status === "failed")) throw new Error("FEATURE_DELIVERY_VALIDATION_FAILED");
|
||||
const client = getGiteaClient(env); const head = await client.getBranch(link.owner, link.repository, branch);
|
||||
assertFeatureDeliveryActionEvidence(delivery.spec.validationEvidence, head.commit.id);
|
||||
const actionRuns = await client.listActionRuns(link.owner, link.repository, { branch, headSha: head.commit.id, limit: 30 });
|
||||
assertSuccessfulActionRuns(actionRuns.workflow_runs, branch, head.commit.id);
|
||||
auditId = (await beginWorkbenchMutation({ link, actor: request.authUser?.username ?? "owner", toolName: "create_pull_request", branch, started })).operationId;
|
||||
const existing = findReusableDeliveryPullRequest(await client.listPullRequests(link.owner, link.repository, "all"), branch, link.defaultBranch, head.commit.id);
|
||||
const pullRequest = existing ?? await client.createPullRequest(link.owner, link.repository, { title: parsed.data.title, body: parsed.data.body, head: branch, base: link.defaultBranch });
|
||||
let updatedDelivery = await openFeatureDeliveryPullRequest(link.projectId, parsed.data.deliveryId, { number: pullRequest.number, url: pullRequest.html_url, title: pullRequest.title }, request.authUser?.username ?? "owner");
|
||||
if (pullRequest.merged) updatedDelivery = await reconcileFeatureDeliveryPullRequest({ owner: link.owner, repository: link.repository, number: pullRequest.number, state: "closed", merged: true, mergeCommitSha: pullRequest.merge_commit_sha, mergedAt: pullRequest.merged_at, headSha: pullRequest.head.sha, actor: request.authUser?.username ?? "owner" }) ?? updatedDelivery;
|
||||
await finishWorkbenchMutation(auditId, "succeeded", started);
|
||||
return reply.status(201).send(successEnvelope({ repository: link.fullName, pullRequest, delivery: updatedDelivery }, getRequestId(request)));
|
||||
} catch (error) {
|
||||
const failure = integrationError(error); const deniedOutcome = failure.status === 403 ? "denied" : "failed";
|
||||
if (auditId) await finishWorkbenchMutation(auditId, deniedOutcome, started, failure.code).catch(() => undefined);
|
||||
else await recordWorkbenchAudit({ link, actor: request.authUser?.username ?? "owner", toolName: "create_pull_request", branch: parsed.data.branch, outcome: deniedOutcome, errorCode: failure.code, started }).catch(() => undefined);
|
||||
return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request)));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/gitea/pull-requests/:number/merge", async (request, reply) => {
|
||||
const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied;
|
||||
const parsed = mergePullRequestSchema.safeParse(request.body);
|
||||
const number = Number((request.params as { number: string }).number);
|
||||
if (!parsed.success || !Number.isInteger(number) || number < 1) return reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "A delivery, pull request, exact head SHA and supported merge method are required", getRequestId(request)));
|
||||
const projectId = (request.params as { projectId: string }).projectId;
|
||||
const started = Date.now(); let link: LinkedRepository | undefined; let auditId: string | undefined;
|
||||
try {
|
||||
const delivery = await getFeatureDelivery(projectId, parsed.data.deliveryId);
|
||||
link = await requireWorkbenchLink(projectId, true, delivery.spec.repositoryId);
|
||||
if (!delivery.spec.branch || delivery.approvalState !== "approved" || delivery.spec.status !== "pull_request_open" || delivery.spec.pullRequest?.number !== number) throw new Error("FEATURE_DELIVERY_MERGE_FORBIDDEN");
|
||||
const branch = assertProjectBrainBranch(delivery.spec.branch, link.defaultBranch);
|
||||
const client = getGiteaClient(env); const pullRequest = await client.getPullRequest(link.owner, link.repository, number);
|
||||
if (pullRequest.head.ref !== branch || pullRequest.base.ref !== link.defaultBranch || pullRequest.head.sha !== parsed.data.expectedHeadSha) throw new Error("FEATURE_DELIVERY_MERGE_HEAD_CONFLICT");
|
||||
if (pullRequest.merged || pullRequest.state !== "open" || pullRequest.draft === true || pullRequest.mergeable === false) throw new Error("FEATURE_DELIVERY_PULL_REQUEST_NOT_READY");
|
||||
const head = await client.getBranch(link.owner, link.repository, branch);
|
||||
if (head.commit.id !== parsed.data.expectedHeadSha) throw new Error("FEATURE_DELIVERY_MERGE_HEAD_CONFLICT");
|
||||
assertFeatureDeliveryActionEvidence(delivery.spec.validationEvidence, parsed.data.expectedHeadSha);
|
||||
const actionRuns = await client.listActionRuns(link.owner, link.repository, { branch, headSha: parsed.data.expectedHeadSha, limit: 30 });
|
||||
assertSuccessfulActionRuns(actionRuns.workflow_runs, branch, parsed.data.expectedHeadSha);
|
||||
auditId = (await beginWorkbenchMutation({ link, actor: request.authUser?.username ?? "owner", toolName: "merge_pull_request", branch, started })).operationId;
|
||||
await client.mergePullRequest(link.owner, link.repository, number, { method: parsed.data.method, headCommitId: parsed.data.expectedHeadSha });
|
||||
const merged = await client.getPullRequest(link.owner, link.repository, number);
|
||||
if (!merged.merged) throw new Error("FEATURE_DELIVERY_MERGE_UNCONFIRMED");
|
||||
const reconciled = await reconcileFeatureDeliveryPullRequest({ owner: link.owner, repository: link.repository, number, state: "closed", merged: true, mergedAt: merged.merged_at, mergeCommitSha: merged.merge_commit_sha, headSha: merged.head.sha, actor: request.authUser?.username ?? "owner" });
|
||||
const verification = await queueFindingVerificationForDelivery(env, projectId, parsed.data.deliveryId).catch(() => undefined);
|
||||
let branchDeleted = false; let branchCleanupError: string | undefined;
|
||||
if (parsed.data.deleteBranch) {
|
||||
try { await client.deleteBranch(link.owner, link.repository, branch); branchDeleted = true; }
|
||||
catch (error) { if (error instanceof GiteaApiError && error.status === 404) branchDeleted = true; else branchCleanupError = error instanceof Error ? error.message : "GITEA_BRANCH_DELETE_FAILED"; }
|
||||
}
|
||||
await finishWorkbenchMutation(auditId, "succeeded", started);
|
||||
return successEnvelope({ repository: link.fullName, pullRequest: merged, delivery: reconciled, branchDeleted, branchCleanupError, verificationQueued: Boolean(verification) }, getRequestId(request));
|
||||
} catch (error) {
|
||||
const failure = integrationError(error); const deniedOutcome = failure.status === 403 ? "denied" : "failed";
|
||||
if (auditId) await finishWorkbenchMutation(auditId, deniedOutcome, started, failure.code).catch(() => undefined);
|
||||
else await recordWorkbenchAudit({ link, actor: request.authUser?.username ?? "owner", toolName: "merge_pull_request", outcome: deniedOutcome, errorCode: failure.code, started }).catch(() => undefined);
|
||||
return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request)));
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/projects/:projectId/gitea", async (request, reply) => {
|
||||
const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied;
|
||||
const parsed = linkSchema.safeParse(request.body);
|
||||
if (!parsed.success) return reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "A valid owner/repository and access mode are required", getRequestId(request)));
|
||||
try {
|
||||
const projectId = (request.params as { projectId: string }).projectId;
|
||||
const link = await linkRepository(env, projectId, parsed.data.repository, parsed.data.accessMode, request.authUser?.username ?? "owner", { role: parsed.data.role, isPrimary: parsed.data.isPrimary, transfer: parsed.data.transfer });
|
||||
return successEnvelope(link, getRequestId(request));
|
||||
} catch (error) {
|
||||
const failure = integrationError(error);
|
||||
return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request)));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/feature-deliveries/:deliveryId/reconcile", async (request, reply) => {
|
||||
try {
|
||||
const projectId = (request.params as { projectId: string; deliveryId: string }).projectId;
|
||||
const deliveryId = (request.params as { projectId: string; deliveryId: string }).deliveryId;
|
||||
const delivery = await getFeatureDelivery(projectId, deliveryId); const pullRequestNumber = delivery.spec.pullRequest?.number;
|
||||
if (!pullRequestNumber) throw new Error("FEATURE_DELIVERY_PULL_REQUEST_NOT_FOUND");
|
||||
const link = await requireWorkbenchLink(projectId, false, delivery.spec.repositoryId); const pullRequest = (await getGiteaClient(env).listPullRequests(link.owner, link.repository, "all")).find((item) => item.number === pullRequestNumber);
|
||||
if (!pullRequest) throw new Error("FEATURE_DELIVERY_PULL_REQUEST_NOT_FOUND");
|
||||
const reconciled = await reconcileFeatureDeliveryPullRequest({ owner: link.owner, repository: link.repository, number: pullRequest.number, state: pullRequest.state === "open" ? "open" : "closed", merged: pullRequest.merged, mergeCommitSha: pullRequest.merge_commit_sha, mergedAt: pullRequest.merged_at, headSha: pullRequest.head.sha, actor: request.authUser?.username ?? "owner" });
|
||||
return successEnvelope({ delivery: reconciled, pullRequest }, getRequestId(request));
|
||||
} catch (error) { const failure = integrationError(error); return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request))); }
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/feature-deliveries/:deliveryId/validation/sync", async (request, reply) => {
|
||||
try {
|
||||
const { projectId, deliveryId } = request.params as { projectId: string; deliveryId: string };
|
||||
const delivery = await getFeatureDelivery(projectId, deliveryId);
|
||||
if (!delivery.spec.branch || delivery.approvalState !== "approved" || !["executing", "validating", "pull_request_open"].includes(delivery.spec.status)) throw new Error("FEATURE_DELIVERY_PROGRESS_FORBIDDEN");
|
||||
const link = await requireWorkbenchLink(projectId, false, delivery.spec.repositoryId); const client = getGiteaClient(env);
|
||||
const branch = assertProjectBrainBranch(delivery.spec.branch, link.defaultBranch); const head = await client.getBranch(link.owner, link.repository, branch);
|
||||
const response = await client.listActionRuns(link.owner, link.repository, { branch, headSha: head.commit.id, limit: 30 });
|
||||
const runs = assertSuccessfulActionRuns(response.workflow_runs, branch, head.commit.id);
|
||||
const run = runs.reduce((latest, candidate) => candidate.run_number > latest.run_number ? candidate : latest);
|
||||
const evidence = { provider: "gitea_actions" as const, runId: run.id, runNumber: run.run_number, url: run.html_url, workflowPath: run.path, headSha: run.head_sha, conclusion: "success" as const, verifiedAt: new Date().toISOString() };
|
||||
return successEnvelope({ run, delivery: await recordFeatureDeliveryActionValidation(projectId, deliveryId, evidence, request.authUser?.username ?? "owner") }, getRequestId(request));
|
||||
} catch (error) { const failure = integrationError(error); return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request))); }
|
||||
});
|
||||
|
||||
app.delete("/api/projects/:projectId/gitea", async (request, reply) => {
|
||||
const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied;
|
||||
const removed = await unlinkRepository((request.params as { projectId: string }).projectId);
|
||||
if (!removed) return reply.status(404).send(errorEnvelope("GITEA_LINK_NOT_FOUND", "No Gitea repository is linked to this project", getRequestId(request)));
|
||||
return successEnvelope({ removed: true }, getRequestId(request));
|
||||
});
|
||||
|
||||
app.delete("/api/projects/:projectId/gitea/repositories/:repositoryLinkId", async (request, reply) => {
|
||||
const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied;
|
||||
const { projectId, repositoryLinkId } = request.params as { projectId: string; repositoryLinkId: string };
|
||||
const removed = await unlinkRepositoryById(projectId, repositoryLinkId);
|
||||
if (!removed) return reply.status(404).send(errorEnvelope("GITEA_LINK_NOT_FOUND", "Repository link not found", getRequestId(request)));
|
||||
return successEnvelope({ removed: true }, getRequestId(request));
|
||||
});
|
||||
|
||||
app.get("/api/integrations/gitea/audit", async (request, reply) => {
|
||||
const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied;
|
||||
return successEnvelope(await prisma.mcpAuditEvent.findMany({ orderBy: { createdAt: "desc" }, take: 100 }), getRequestId(request));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import type { HealthResponse } from "../types/api.js";
|
||||
import { successEnvelope } from "../utils/envelope.js";
|
||||
|
||||
type HealthRouteOptions = {
|
||||
env: AppEnv;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export const registerHealthRoutes = async (
|
||||
app: FastifyInstance,
|
||||
options: HealthRouteOptions,
|
||||
) => {
|
||||
app.get("/api/health", async (request) => {
|
||||
const data: HealthResponse = {
|
||||
status: "ok",
|
||||
service: "projectbrain-api",
|
||||
version: options.version,
|
||||
environment: options.env.NODE_ENV,
|
||||
};
|
||||
|
||||
return successEnvelope(data, getRequestId(request));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import {
|
||||
createFeatureCandidatesForInitiative,
|
||||
createInitiativeForProject,
|
||||
getInitiativeForProject,
|
||||
listInitiativesForProject,
|
||||
updateInitiativeForProject,
|
||||
} from "../repositories/initiativesRepository.js";
|
||||
import { approveFeatureDelivery, createFeatureDelivery, getFeatureDelivery, listFeatureDeliveries, rejectFeatureDelivery, updateFeatureDelivery } from "../repositories/featureDeliveryRepository.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
|
||||
type InitiativeRouteOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"] };
|
||||
const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-]+$/);
|
||||
const projectParamsSchema = z.object({ projectId: idSchema });
|
||||
const initiativeParamsSchema = z.object({ projectId: idSchema, initiativeId: idSchema });
|
||||
const initiativeStatusSchema = z.enum(["proposed", "planned", "active", "complete", "paused"]);
|
||||
const initiativePrioritySchema = z.enum(["critical", "important", "normal", "low"]);
|
||||
const createInitiativeBodySchema = z.object({
|
||||
title: z.string().min(3).max(160),
|
||||
intent: z.string().min(8).max(4000),
|
||||
priority: initiativePrioritySchema.optional(),
|
||||
status: initiativeStatusSchema.optional(),
|
||||
confidence: z.number().int().min(0).max(100).optional(),
|
||||
featureIds: z.array(z.string()).optional(),
|
||||
sourceType: z.string().max(80).optional(),
|
||||
});
|
||||
const updateInitiativeBodySchema = createInitiativeBodySchema.partial().extend({
|
||||
approvalState: z.enum(["pending", "approved", "rejected"]).optional(),
|
||||
});
|
||||
const featureDeliveryBodySchema = z.object({
|
||||
title: z.string().min(3).max(160), intent: z.string().min(8).max(4000), priority: initiativePrioritySchema.optional(),
|
||||
problem: z.string().min(8).max(8000), desiredOutcome: z.string().min(8).max(8000), users: z.array(z.string().min(1).max(300)).max(30).optional(),
|
||||
goals: z.array(z.string().min(1).max(1000)).min(1).max(50), nonGoals: z.array(z.string().min(1).max(1000)).max(50).optional(),
|
||||
acceptanceCriteria: z.array(z.string().min(1).max(1000)).min(1).max(100), implementationPlan: z.array(z.string().min(1).max(2000)).min(1).max(100),
|
||||
risks: z.array(z.string().min(1).max(1000)).max(50).optional(), validationCommands: z.array(z.string().min(1).max(500)).min(1).max(30),
|
||||
});
|
||||
const rejectDeliveryBodySchema = z.object({ reason: z.string().trim().max(2000).optional() });
|
||||
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 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 requireDatabase = (dataSource: InitiativeRouteOptions["dataSource"], request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (dataSource === "database") return false;
|
||||
reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Initiative persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request)));
|
||||
return true;
|
||||
};
|
||||
|
||||
export const registerInitiativeRoutes = async (app: FastifyInstance, options: InitiativeRouteOptions = {}) => {
|
||||
const dataSource = options.dataSource ?? "fixture";
|
||||
|
||||
app.get("/api/projects/:projectId/initiatives", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply;
|
||||
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||
return successEnvelope({ items: await listInitiativesForProject(params.projectId) }, getRequestId(request));
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/initiatives", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply;
|
||||
const body = parseBody(createInitiativeBodySchema, request, reply); if (!body) return reply;
|
||||
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||
return reply.status(201).send(successEnvelope(await createInitiativeForProject(params.projectId, body), getRequestId(request)));
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/initiatives/:initiativeId", async (request, reply) => {
|
||||
const params = parseParams(initiativeParamsSchema, request, reply); if (!params) return reply;
|
||||
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||
const initiative = await getInitiativeForProject(params.projectId, params.initiativeId);
|
||||
if (!initiative) return reply.status(404).send(errorEnvelope("INITIATIVE_NOT_FOUND", "Initiative not found.", getRequestId(request)));
|
||||
if (initiative.sourceType === "chatgpt_feature_delivery") {
|
||||
try {
|
||||
const delivery = await getFeatureDelivery(params.projectId, params.initiativeId);
|
||||
return successEnvelope({ ...initiative, approvalState: delivery.approvalState, metadata: delivery.spec }, getRequestId(request));
|
||||
} catch { /* Preserve the initiative even if legacy data has no delivery plan. */ }
|
||||
}
|
||||
return successEnvelope(initiative, getRequestId(request));
|
||||
});
|
||||
|
||||
app.patch("/api/projects/:projectId/initiatives/:initiativeId", async (request, reply) => {
|
||||
const params = parseParams(initiativeParamsSchema, request, reply); if (!params) return reply;
|
||||
const body = parseBody(updateInitiativeBodySchema, request, reply); if (!body) return reply;
|
||||
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||
const initiative = await updateInitiativeForProject(params.projectId, params.initiativeId, body);
|
||||
if (!initiative) return reply.status(404).send(errorEnvelope("INITIATIVE_NOT_FOUND", "Initiative not found.", getRequestId(request)));
|
||||
return successEnvelope(initiative, getRequestId(request));
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/initiatives/:initiativeId/feature-candidates", async (request, reply) => {
|
||||
const params = parseParams(initiativeParamsSchema, request, reply); if (!params) return reply;
|
||||
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||
const result = await createFeatureCandidatesForInitiative(params.projectId, params.initiativeId);
|
||||
if (!result) return reply.status(404).send(errorEnvelope("INITIATIVE_NOT_FOUND", "Initiative not found.", getRequestId(request)));
|
||||
return reply.status(201).send(successEnvelope(result, getRequestId(request)));
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/feature-deliveries", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply;
|
||||
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||
return successEnvelope({ items: await listFeatureDeliveries(params.projectId) }, getRequestId(request));
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/feature-deliveries", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply;
|
||||
const body = parseBody(featureDeliveryBodySchema, request, reply); if (!body) return reply;
|
||||
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||
return reply.status(201).send(successEnvelope(await createFeatureDelivery(params.projectId, body, request.authUser?.username ?? "owner"), getRequestId(request)));
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/feature-deliveries/:initiativeId", async (request, reply) => {
|
||||
const params = parseParams(initiativeParamsSchema, request, reply); if (!params) return reply;
|
||||
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||
try { return successEnvelope(await getFeatureDelivery(params.projectId, params.initiativeId), getRequestId(request)); }
|
||||
catch { return reply.status(404).send(errorEnvelope("FEATURE_DELIVERY_NOT_FOUND", "Feature delivery not found.", getRequestId(request))); }
|
||||
});
|
||||
|
||||
app.patch("/api/projects/:projectId/feature-deliveries/:initiativeId", async (request, reply) => {
|
||||
const params = parseParams(initiativeParamsSchema, request, reply); if (!params) return reply;
|
||||
const body = parseBody(featureDeliveryBodySchema.partial(), request, reply); if (!body) return reply;
|
||||
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||
try { return successEnvelope(await updateFeatureDelivery(params.projectId, params.initiativeId, body, request.authUser?.username ?? "owner"), getRequestId(request)); }
|
||||
catch (error) {
|
||||
const code = error instanceof Error ? error.message : "FEATURE_DELIVERY_UPDATE_FAILED";
|
||||
return reply.status(code === "FEATURE_DELIVERY_PLAN_LOCKED" ? 409 : 404).send(errorEnvelope(code, code === "FEATURE_DELIVERY_PLAN_LOCKED" ? "An active delivery plan can no longer be edited." : "Feature delivery not found.", getRequestId(request)));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/feature-deliveries/:initiativeId/approve", async (request, reply) => {
|
||||
const params = parseParams(initiativeParamsSchema, request, reply); if (!params) return reply;
|
||||
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||
try { return successEnvelope(await approveFeatureDelivery(params.projectId, params.initiativeId, request.authUser?.username ?? "owner"), getRequestId(request)); }
|
||||
catch (error) {
|
||||
const code = error instanceof Error ? error.message : "FEATURE_DELIVERY_APPROVAL_FAILED";
|
||||
return reply.status(code === "FEATURE_DELIVERY_PLAN_INCOMPLETE" ? 400 : 404).send(errorEnvelope(code, code === "FEATURE_DELIVERY_PLAN_INCOMPLETE" ? "The plan needs goals, acceptance criteria, implementation steps and validation commands." : "Feature delivery not found.", getRequestId(request)));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/feature-deliveries/:initiativeId/reject", async (request, reply) => {
|
||||
const params = parseParams(initiativeParamsSchema, request, reply); if (!params) return reply;
|
||||
const body = parseBody(rejectDeliveryBodySchema, request, reply); if (!body) return reply;
|
||||
if (requireDatabase(dataSource, request, reply)) return reply;
|
||||
try { return successEnvelope(await rejectFeatureDelivery(params.projectId, params.initiativeId, request.authUser?.username ?? "owner", body.reason), getRequestId(request)); }
|
||||
catch (error) {
|
||||
const code = error instanceof Error ? error.message : "FEATURE_DELIVERY_REJECTION_FAILED";
|
||||
return reply.status(code === "FEATURE_DELIVERY_REJECTION_LOCKED" ? 409 : 404).send(errorEnvelope(code, code === "FEATURE_DELIVERY_REJECTION_LOCKED" ? "An active delivery can no longer be rejected." : "Feature delivery not found.", getRequestId(request)));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js";
|
||||
import {
|
||||
applyInsightFilters,
|
||||
findInsight,
|
||||
listInsightsForProject,
|
||||
reviewInsight,
|
||||
} from "../fixtures/insightFixtures.js";
|
||||
import { findProject } from "../fixtures/projectFixtures.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import type {
|
||||
InsightListFilters,
|
||||
InsightReviewAction,
|
||||
InsightSeverity,
|
||||
InsightStatus,
|
||||
} from "../types/api.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import {
|
||||
getIdempotencyKey,
|
||||
getIdempotentResponse,
|
||||
setIdempotentResponse,
|
||||
} from "../utils/idempotency.js";
|
||||
|
||||
const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-]+$/);
|
||||
const projectParamsSchema = z.object({ projectId: idSchema });
|
||||
const insightParamsSchema = z.object({ projectId: idSchema, insightId: 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 insightQuerySchema = paginationQuerySchema.extend({
|
||||
category: z.string().optional(),
|
||||
severity: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
confidence: z.coerce.number().optional(),
|
||||
reviewed: z.coerce.boolean().optional(),
|
||||
});
|
||||
const reviewBodySchema = z.object({
|
||||
action: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
const reviewActions = ["accept", "reject", "ignore"] as const;
|
||||
|
||||
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 sendProjectNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"PROJECT_NOT_FOUND",
|
||||
`Project '${projectId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
const ensureProject = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
) => {
|
||||
const project = findProject(projectId);
|
||||
if (!project) {
|
||||
sendProjectNotFound(request, reply, projectId);
|
||||
return undefined;
|
||||
}
|
||||
return project;
|
||||
};
|
||||
|
||||
const splitCsv = (value: string | undefined): string[] =>
|
||||
value?.split(",").map((item) => item.trim()).filter(Boolean) ?? [];
|
||||
|
||||
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 toPaginationInput = (
|
||||
query: z.output<typeof paginationQuerySchema>,
|
||||
filters: PaginationInput["filters"],
|
||||
): PaginationInput => ({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
cursor: query.cursor,
|
||||
sort: query.sort,
|
||||
filters,
|
||||
});
|
||||
|
||||
const toFilters = (
|
||||
query: z.output<typeof insightQuerySchema>,
|
||||
): InsightListFilters => ({
|
||||
...(query.category ? { category: splitCsv(query.category) } : {}),
|
||||
...(query.severity
|
||||
? { severity: splitCsv(query.severity) as InsightSeverity[] }
|
||||
: {}),
|
||||
...(query.status ? { status: splitCsv(query.status) as InsightStatus[] } : {}),
|
||||
...(query.confidence !== undefined ? { confidence: query.confidence } : {}),
|
||||
...(query.reviewed !== undefined ? { reviewed: query.reviewed } : {}),
|
||||
...(query.cursor ? { cursor: query.cursor } : {}),
|
||||
});
|
||||
|
||||
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 sendInsightNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
insightId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"INSIGHT_NOT_FOUND",
|
||||
`Insight '${insightId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
export const registerInsightRoutes = async (app: FastifyInstance) => {
|
||||
app.get("/api/projects/:projectId/insights", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
const query = parseQuery(insightQuerySchema, request, reply);
|
||||
if (!query) return reply;
|
||||
|
||||
const filters = toFilters(query);
|
||||
const insights = sortByField(
|
||||
applyInsightFilters(listInsightsForProject(params.projectId), filters),
|
||||
query.sort,
|
||||
);
|
||||
|
||||
return successEnvelope(
|
||||
paginate(insights, toPaginationInput(query, filters)),
|
||||
getRequestId(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/api/projects/:projectId/insights/:insightId",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(insightParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
const insight = findInsight(params.projectId, params.insightId);
|
||||
if (!insight) return sendInsightNotFound(request, reply, params.insightId);
|
||||
return successEnvelope(insight, getRequestId(request));
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/api/projects/:projectId/insights/:insightId/review",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(insightParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
||||
if (!idempotencyKey) return reply;
|
||||
const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey);
|
||||
const replay = getIdempotentResponse<unknown>(cacheKey);
|
||||
if (replay) return reply.status(replay.statusCode).send(replay.body);
|
||||
|
||||
const parsedBody = reviewBodySchema.safeParse(request.body);
|
||||
if (!parsedBody.success) {
|
||||
sendValidationError(request, reply, parsedBody.error.issues);
|
||||
return reply;
|
||||
}
|
||||
|
||||
const action = parsedBody.data.action as InsightReviewAction | undefined;
|
||||
if (!action || !reviewActions.includes(action)) {
|
||||
return reply.status(400).send(
|
||||
errorEnvelope(
|
||||
"REVIEW_ACTION_INVALID",
|
||||
"Review action must be accept, reject, or ignore.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const insight = reviewInsight(
|
||||
params.projectId,
|
||||
params.insightId,
|
||||
action,
|
||||
parsedBody.data.reason,
|
||||
);
|
||||
if (!insight) return sendInsightNotFound(request, reply, params.insightId);
|
||||
|
||||
const body = successEnvelope(insight, getRequestId(request));
|
||||
setIdempotentResponse(cacheKey, { statusCode: 200, body });
|
||||
return reply.status(200).send(body);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { findContextPack } from "../fixtures/contextPackFixtures.js";
|
||||
import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js";
|
||||
import {
|
||||
applyKnowledgeFilters,
|
||||
createKnowledge,
|
||||
findKnowledge,
|
||||
includeKnowledgeInContextPack,
|
||||
listKnowledgeForProject,
|
||||
updateKnowledge,
|
||||
} from "../fixtures/knowledgeFixtures.js";
|
||||
import { findProject } from "../fixtures/projectFixtures.js";
|
||||
import {
|
||||
createKnowledgeItem as createDatabaseKnowledge,
|
||||
getKnowledgeBySlugOrId as getDatabaseKnowledgeBySlugOrId,
|
||||
includeKnowledgeInContextPack as includeDatabaseKnowledgeInContextPack,
|
||||
listKnowledgeForProject as listDatabaseKnowledgeForProject,
|
||||
projectExists as databaseProjectExists,
|
||||
updateKnowledgeItem as updateDatabaseKnowledge,
|
||||
} from "../repositories/p8KnowledgeMemoryRepository.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import type { KnowledgeListFilters } from "../types/api.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import { getIdempotencyKey, getIdempotentResponse, setIdempotentResponse } from "../utils/idempotency.js";
|
||||
|
||||
type KnowledgeRouteOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"] };
|
||||
const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-]+$/);
|
||||
const projectParamsSchema = z.object({ projectId: idSchema });
|
||||
const knowledgeParamsSchema = z.object({ projectId: idSchema, knowledgeId: 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 knowledgeQuerySchema = paginationQuerySchema.extend({ category: z.string().optional(), featureId: z.string().optional(), source: z.string().optional(), confidence: z.coerce.number().optional(), search: z.string().optional() });
|
||||
const knowledgeBodySchema = z.object({ title: z.string().optional(), category: z.string().optional(), content: z.string().optional(), linkedFeatureIds: z.array(z.string()).optional(), source: z.string().optional(), confidence: z.number().min(0).max(100).optional() });
|
||||
const includeBodySchema = z.object({ contextPackId: z.string().optional() });
|
||||
|
||||
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 ensureProject = async (request: FastifyRequest, reply: FastifyReply, projectId: string, dataSource: AppEnv["PROJECTBRAIN_DATA_SOURCE"]) => { const exists = dataSource === "database" ? await databaseProjectExists(projectId) : Boolean(findProject(projectId)); if (!exists) { reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${projectId}' was not found.`, getRequestId(request))); return false; } return true; };
|
||||
const splitCsv = (value: string | undefined): string[] => value?.split(",").map((item) => item.trim()).filter(Boolean) ?? [];
|
||||
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 toPaginationInput = (query: z.output<typeof paginationQuerySchema>, filters: PaginationInput["filters"]): PaginationInput => ({ page: query.page, pageSize: query.pageSize, cursor: query.cursor, sort: query.sort, filters });
|
||||
const toFilters = (query: z.output<typeof knowledgeQuerySchema>): KnowledgeListFilters => ({ ...(query.category ? { category: splitCsv(query.category) } : {}), ...(query.featureId ? { featureId: query.featureId } : {}), ...(query.source ? { source: splitCsv(query.source) } : {}), ...(query.confidence !== undefined ? { confidence: query.confidence } : {}), ...(query.search ? { search: query.search } : {}), ...(query.cursor ? { cursor: query.cursor } : {}) });
|
||||
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 sendKnowledgeNotFound = (request: FastifyRequest, reply: FastifyReply, knowledgeId: string) => reply.status(404).send(errorEnvelope("KNOWLEDGE_NOT_FOUND", `Knowledge item '${knowledgeId}' was not found.`, getRequestId(request)));
|
||||
const sendContextPackNotFound = (request: FastifyRequest, reply: FastifyReply, contextPackId: string) => reply.status(404).send(errorEnvelope("CONTEXT_PACK_NOT_FOUND", `Context pack '${contextPackId}' was not found.`, getRequestId(request)));
|
||||
|
||||
export const registerKnowledgeRoutes = async (app: FastifyInstance, options: KnowledgeRouteOptions = {}) => {
|
||||
const dataSource = options.dataSource ?? "fixture";
|
||||
app.get("/api/projects/:projectId/knowledge", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply;
|
||||
if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply;
|
||||
const query = parseQuery(knowledgeQuerySchema, request, reply); if (!query) return reply;
|
||||
const filters = toFilters(query);
|
||||
const knowledge = dataSource === "database" ? await listDatabaseKnowledgeForProject(params.projectId, filters) : applyKnowledgeFilters(listKnowledgeForProject(params.projectId), filters);
|
||||
return successEnvelope(paginate(sortByField(knowledge, query.sort), toPaginationInput(query, filters)), getRequestId(request));
|
||||
});
|
||||
app.get("/api/projects/:projectId/knowledge/:knowledgeId", async (request, reply) => {
|
||||
const params = parseParams(knowledgeParamsSchema, request, reply); if (!params) return reply;
|
||||
if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply;
|
||||
const item = dataSource === "database" ? await getDatabaseKnowledgeBySlugOrId(params.projectId, params.knowledgeId) : findKnowledge(params.projectId, params.knowledgeId);
|
||||
if (!item) return sendKnowledgeNotFound(request, reply, params.knowledgeId);
|
||||
return successEnvelope(item, getRequestId(request));
|
||||
});
|
||||
app.post("/api/projects/:projectId/knowledge", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply;
|
||||
if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply;
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply;
|
||||
const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = getIdempotentResponse<unknown>(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body);
|
||||
const parsedBody = knowledgeBodySchema.safeParse(request.body);
|
||||
if (!parsedBody.success || !parsedBody.data.title?.trim() || !parsedBody.data.content?.trim()) return reply.status(400).send(errorEnvelope("KNOWLEDGE_VALIDATION_FAILED", "title and content are required to create knowledge.", getRequestId(request)));
|
||||
const item = dataSource === "database" ? await createDatabaseKnowledge(params.projectId, { ...parsedBody.data, title: parsedBody.data.title, content: parsedBody.data.content }) : createKnowledge(params.projectId, { ...parsedBody.data, title: parsedBody.data.title, content: parsedBody.data.content });
|
||||
const body = successEnvelope(item, getRequestId(request)); setIdempotentResponse(cacheKey, { statusCode: 201, body }); return reply.status(201).send(body);
|
||||
});
|
||||
app.patch("/api/projects/:projectId/knowledge/:knowledgeId", async (request, reply) => {
|
||||
const params = parseParams(knowledgeParamsSchema, request, reply); if (!params) return reply;
|
||||
if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply;
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply;
|
||||
const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = getIdempotentResponse<unknown>(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body);
|
||||
const parsedBody = knowledgeBodySchema.safeParse(request.body);
|
||||
if (!parsedBody.success || (parsedBody.data.title !== undefined && !parsedBody.data.title.trim()) || (parsedBody.data.content !== undefined && !parsedBody.data.content.trim())) return reply.status(400).send(errorEnvelope("KNOWLEDGE_VALIDATION_FAILED", "Knowledge update body is invalid.", getRequestId(request)));
|
||||
const item = dataSource === "database" ? await updateDatabaseKnowledge(params.projectId, params.knowledgeId, parsedBody.data) : updateKnowledge(params.projectId, params.knowledgeId, parsedBody.data);
|
||||
if (!item) return sendKnowledgeNotFound(request, reply, params.knowledgeId);
|
||||
const body = successEnvelope(item, getRequestId(request)); setIdempotentResponse(cacheKey, { statusCode: 200, body }); return reply.status(200).send(body);
|
||||
});
|
||||
app.post("/api/projects/:projectId/knowledge/:knowledgeId/include-in-context-pack", async (request, reply) => {
|
||||
const params = parseParams(knowledgeParamsSchema, request, reply); if (!params) return reply;
|
||||
if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply;
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply;
|
||||
const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = getIdempotentResponse<unknown>(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body);
|
||||
const parsedBody = includeBodySchema.safeParse(request.body); if (!parsedBody.success || !parsedBody.data.contextPackId?.trim()) return reply.status(400).send(errorEnvelope("INCLUDE_IN_CONTEXT_PACK_FAILED", "contextPackId is required.", getRequestId(request)));
|
||||
let item;
|
||||
if (dataSource === "database") { try { item = await includeDatabaseKnowledgeInContextPack(params.projectId, params.knowledgeId, parsedBody.data.contextPackId); } catch (error) { if ((error as Error).message === "CONTEXT_PACK_NOT_FOUND") return sendContextPackNotFound(request, reply, parsedBody.data.contextPackId); throw error; } }
|
||||
else { if (!findKnowledge(params.projectId, params.knowledgeId)) return sendKnowledgeNotFound(request, reply, params.knowledgeId); if (!findContextPack(params.projectId, parsedBody.data.contextPackId)) return sendContextPackNotFound(request, reply, parsedBody.data.contextPackId); item = includeKnowledgeInContextPack(params.projectId, params.knowledgeId, parsedBody.data.contextPackId); }
|
||||
if (!item) return sendKnowledgeNotFound(request, reply, params.knowledgeId);
|
||||
const body = successEnvelope(item, getRequestId(request)); setIdempotentResponse(cacheKey, { statusCode: 200, body }); return reply.status(200).send(body);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { createProjectBrainMcpServer } from "../mcp/autonomyServer.js";
|
||||
import { registerPortfolioMcpTools } from "../mcp/portfolioTools.js";
|
||||
import { verifyMcpAccessToken } from "../security/mcpOAuth.js";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import { isMcpHubServiceToken, mcpHubActor } from "../security/mcpServiceAuth.js";
|
||||
import { augmentProjectBrainInitialize, handleProjectBrainSkillMethod } from "../mcp/skills.js";
|
||||
|
||||
const bearer = (value: string | undefined) => value?.match(/^Bearer\s+(.+)$/i)?.[1];
|
||||
|
||||
export const registerMcpRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => {
|
||||
const { env } = options;
|
||||
app.route({ method: ["GET", "POST", "DELETE"], url: "/mcp", config: { rateLimit: { max: env.PROJECTBRAIN_MCP_RATE_LIMIT_PER_MINUTE, timeWindow: "1 minute" } }, handler: async (request, reply) => {
|
||||
if (!env.PROJECTBRAIN_MCP_ENABLED || !env.PROJECTBRAIN_PUBLIC_URL) return reply.status(404).send({ error: "MCP_DISABLED" });
|
||||
const token = bearer(request.headers.authorization);
|
||||
const serviceAuthenticated = isMcpHubServiceToken(env.PROJECTBRAIN_HUB_SERVICE_TOKEN, token);
|
||||
let auth;
|
||||
if (!serviceAuthenticated) {
|
||||
try { auth = token ? verifyMcpAccessToken(env, token) : undefined; } catch { auth = undefined; }
|
||||
}
|
||||
if (!serviceAuthenticated && !auth) {
|
||||
const metadata = `${env.PROJECTBRAIN_PUBLIC_URL.replace(/\/$/, "")}/.well-known/oauth-protected-resource/mcp`;
|
||||
return reply.header("WWW-Authenticate", `Bearer resource_metadata="${metadata}"`).status(401).send({ error: "invalid_token", error_description: "A valid ProjectBrain MCP OAuth token is required" });
|
||||
}
|
||||
const user = auth ? await prisma.user.findUnique({ where: { username: auth.subject }, select: { credentialVersion: true, disabledAt: true, role: true } }) : undefined;
|
||||
if (auth && (!user || user.disabledAt || user.role !== "owner" || user.credentialVersion !== auth.credentialVersion)) {
|
||||
const metadata = `${env.PROJECTBRAIN_PUBLIC_URL.replace(/\/$/, "")}/.well-known/oauth-protected-resource/mcp`;
|
||||
return reply.header("WWW-Authenticate", `Bearer error="invalid_token", resource_metadata="${metadata}"`).status(401).send({ error: "invalid_token", error_description: "The ProjectBrain identity or credentials changed; authorize the connector again" });
|
||||
}
|
||||
|
||||
const actor = serviceAuthenticated ? mcpHubActor(request.headers["x-itworx-client-id"]) : auth!.subject;
|
||||
const rpc = request.method === "POST" && request.body && typeof request.body === "object" && !Array.isArray(request.body)
|
||||
? request.body as { jsonrpc?: string; id?: string | number | null; method?: string; params?: Record<string, unknown> }
|
||||
: undefined;
|
||||
if (rpc?.method) {
|
||||
try {
|
||||
const extensionResult = handleProjectBrainSkillMethod(rpc.method, rpc.params ?? {});
|
||||
if (extensionResult !== undefined) return reply.send({ jsonrpc: "2.0", id: rpc.id ?? null, result: extensionResult });
|
||||
} catch (error) {
|
||||
return reply.status(400).send({ jsonrpc: "2.0", id: rpc.id ?? null, error: { code: -32602, message: error instanceof Error ? error.message : "Invalid MCP Skills request" } });
|
||||
}
|
||||
}
|
||||
|
||||
const target = new URL(request.url, env.PROJECTBRAIN_PUBLIC_URL);
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(request.headers)) if (value !== undefined) headers.set(key, Array.isArray(value) ? value.join(", ") : String(value));
|
||||
const webRequest = new Request(target, { method: request.method, headers, ...(request.method === "POST" ? { body: JSON.stringify(request.body) } : {}) });
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
|
||||
const server = createProjectBrainMcpServer(env, actor);
|
||||
registerPortfolioMcpTools(server, env, actor);
|
||||
try {
|
||||
await server.connect(transport);
|
||||
const response = await transport.handleRequest(webRequest, { parsedBody: request.body, authInfo: {
|
||||
token: token!, clientId: serviceAuthenticated ? "itworx-mcp-hub" : auth!.clientId,
|
||||
scopes: serviceAuthenticated ? ["mcp:tools", "mcp:skills", "mcp:resources"] : auth!.scope.split(" "),
|
||||
expiresAt: serviceAuthenticated ? Math.floor(Date.now() / 1000) + 300 : auth!.exp,
|
||||
} });
|
||||
reply.status(response.status);
|
||||
response.headers.forEach((value, key) => { if (!["content-length", "transfer-encoding", "content-encoding"].includes(key.toLowerCase())) reply.header(key, value); });
|
||||
let bytes = Buffer.from(await response.arrayBuffer());
|
||||
if (rpc?.method === "initialize" && response.headers.get("content-type")?.toLowerCase().includes("application/json")) {
|
||||
try { bytes = Buffer.from(JSON.stringify(augmentProjectBrainInitialize(JSON.parse(bytes.toString("utf8"))))); } catch { /* Preserve the valid upstream response. */ }
|
||||
}
|
||||
return reply.send(bytes);
|
||||
} finally {
|
||||
await transport.close().catch(() => undefined);
|
||||
await server.close().catch(() => undefined);
|
||||
}
|
||||
} });
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { authenticate } from "../security/authStore.js";
|
||||
import { exchangeAuthorizationCode, exchangeRefreshToken, issueAuthorizationCode, registerOAuthClient, verifyOAuthClient } from "../security/mcpOAuth.js";
|
||||
|
||||
const escapeHtml = (value: string) => value.replace(/[&<>'"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[char] ?? char));
|
||||
const oauthError = (reply: FastifyReply, error: string, description: string, status = 400) => reply.status(status).send({ error, error_description: description });
|
||||
|
||||
const authorizeSchema = z.object({
|
||||
response_type: z.literal("code"), client_id: z.string().min(1), redirect_uri: z.string().url(),
|
||||
state: z.string().optional().default(""), scope: z.string().optional().default("mcp:tools"),
|
||||
code_challenge: z.string().min(43).max(128), code_challenge_method: z.literal("S256"),
|
||||
resource: z.string().url().optional(), username: z.string().optional(), password: z.string().optional(),
|
||||
});
|
||||
|
||||
export const registerMcpOAuthRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => {
|
||||
const { env } = options;
|
||||
const base = () => env.PROJECTBRAIN_PUBLIC_URL?.replace(/\/$/, "") ?? "";
|
||||
|
||||
app.get("/.well-known/oauth-authorization-server", async () => ({
|
||||
issuer: base(), authorization_endpoint: `${base()}/oauth/authorize`, token_endpoint: `${base()}/oauth/token`, registration_endpoint: `${base()}/oauth/register`,
|
||||
response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
token_endpoint_auth_methods_supported: ["none"], code_challenge_methods_supported: ["S256"], scopes_supported: ["mcp:tools"],
|
||||
}));
|
||||
app.get("/.well-known/oauth-protected-resource", async () => ({ resource: `${base()}/mcp`, authorization_servers: [base()], scopes_supported: ["mcp:tools"], bearer_methods_supported: ["header"] }));
|
||||
app.get("/.well-known/oauth-protected-resource/mcp", async () => ({ resource: `${base()}/mcp`, authorization_servers: [base()], scopes_supported: ["mcp:tools"], bearer_methods_supported: ["header"] }));
|
||||
|
||||
app.post("/oauth/register", async (request, reply) => {
|
||||
const parsed = z.object({ redirect_uris: z.array(z.string().url()).min(1).max(10), client_name: z.string().optional(), token_endpoint_auth_method: z.literal("none").optional() }).safeParse(request.body);
|
||||
if (!parsed.success) return oauthError(reply, "invalid_client_metadata", "Valid redirect_uris are required");
|
||||
try {
|
||||
const client = registerOAuthClient(env, { redirectUris: parsed.data.redirect_uris, clientName: parsed.data.client_name });
|
||||
return reply.status(201).send({ client_id: client.clientId, client_id_issued_at: Math.floor(Date.now() / 1000), client_name: parsed.data.client_name ?? "ChatGPT", redirect_uris: client.redirectUris, token_endpoint_auth_method: "none", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"] });
|
||||
} catch { return oauthError(reply, "invalid_redirect_uri", "Only HTTPS and local development redirect URIs are allowed"); }
|
||||
});
|
||||
|
||||
const renderAuthorization = (query: Record<string, unknown>, message?: string) => {
|
||||
const hidden = Object.entries(query).filter(([key]) => !["username", "password"].includes(key)).map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(String(value ?? ""))}">`).join("");
|
||||
return `<!doctype html><html lang="nl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>ProjectBrain koppelen</title></head><body><main><h1>ProjectBrain koppelen met ChatGPT</h1><p>Geef ChatGPT toegang tot de repositories die je expliciet aan ProjectBrain-projecten hebt gekoppeld. Wijzigingen gebeuren op geldige niet-standaardbranches en gaan via pull requests.</p>${message ? `<p role="alert">${escapeHtml(message)}</p>` : ""}<form method="post" action="/oauth/authorize">${hidden}<label>ProjectBrain-gebruiker <input name="username" autocomplete="username" required></label><br><label>Wachtwoord <input type="password" name="password" autocomplete="current-password" required></label><br><button type="submit">Toegang verlenen</button></form></main></body></html>`;
|
||||
};
|
||||
|
||||
app.get("/oauth/authorize", async (request, reply) => {
|
||||
const parsed = authorizeSchema.safeParse(request.query);
|
||||
if (!parsed.success || parsed.data.scope.split(" ").some((scope) => scope !== "mcp:tools")) return oauthError(reply, "invalid_request", "OAuth authorization request is invalid");
|
||||
try { verifyOAuthClient(env, parsed.data.client_id, parsed.data.redirect_uri); } catch { return oauthError(reply, "invalid_client", "OAuth client or redirect URI is invalid", 401); }
|
||||
return reply.type("text/html; charset=utf-8").send(renderAuthorization(parsed.data));
|
||||
});
|
||||
|
||||
app.post("/oauth/authorize", { config: { rateLimit: { max: 5, timeWindow: "1 minute" } } }, async (request, reply) => {
|
||||
const parsed = authorizeSchema.safeParse(request.body);
|
||||
if (!parsed.success || !parsed.data.username || !parsed.data.password) return oauthError(reply, "invalid_request", "OAuth authorization request is invalid");
|
||||
try { verifyOAuthClient(env, parsed.data.client_id, parsed.data.redirect_uri); } catch { return oauthError(reply, "invalid_client", "OAuth client or redirect URI is invalid", 401); }
|
||||
const user = await authenticate(env, parsed.data.username, parsed.data.password);
|
||||
if (!user || user.role !== "owner") return reply.status(401).type("text/html; charset=utf-8").send(renderAuthorization(parsed.data, "Ongeldige ProjectBrain-inloggegevens."));
|
||||
const audience = parsed.data.resource ?? `${base()}/mcp`;
|
||||
if (audience !== `${base()}/mcp`) return oauthError(reply, "invalid_target", "Requested MCP resource is not available");
|
||||
const code = issueAuthorizationCode(env, { clientId: parsed.data.client_id, redirectUri: parsed.data.redirect_uri, codeChallenge: parsed.data.code_challenge, scope: "mcp:tools", subject: user.username, credentialVersion: user.credentialVersion, audience });
|
||||
const redirect = new URL(parsed.data.redirect_uri); redirect.searchParams.set("code", code); if (parsed.data.state) redirect.searchParams.set("state", parsed.data.state);
|
||||
return reply.redirect(redirect.toString());
|
||||
});
|
||||
|
||||
app.post("/oauth/token", async (request, reply) => {
|
||||
const body = request.body as Record<string, string> | undefined;
|
||||
if (!body?.grant_type || !body.client_id) return oauthError(reply, "invalid_request", "grant_type and client_id are required");
|
||||
try {
|
||||
const pair = body.grant_type === "authorization_code"
|
||||
? exchangeAuthorizationCode(env, { code: body.code, clientId: body.client_id, redirectUri: body.redirect_uri, codeVerifier: body.code_verifier })
|
||||
: body.grant_type === "refresh_token"
|
||||
? exchangeRefreshToken(env, body.refresh_token, body.client_id)
|
||||
: undefined;
|
||||
if (!pair) return oauthError(reply, "unsupported_grant_type", "Only authorization_code and refresh_token are supported");
|
||||
return { access_token: pair.accessToken, token_type: "Bearer", expires_in: pair.expiresIn, refresh_token: pair.refreshToken, scope: "mcp:tools" };
|
||||
} catch { return oauthError(reply, "invalid_grant", "The authorization grant is invalid or expired", 401); }
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { findContextPack } from "../fixtures/contextPackFixtures.js";
|
||||
import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js";
|
||||
import { applyMemoryRuleFilters, createMemoryRule, findMemoryRule, includeMemoryRuleInContextPack, listMemoryRulesForProject, updateMemoryRule } from "../fixtures/memoryRuleFixtures.js";
|
||||
import { findProject } from "../fixtures/projectFixtures.js";
|
||||
import { createMemoryRuleItem as createDatabaseMemoryRule, getMemoryRuleBySlugOrId as getDatabaseMemoryRuleBySlugOrId, includeMemoryRuleInContextPack as includeDatabaseMemoryRuleInContextPack, listMemoryRulesForProject as listDatabaseMemoryRulesForProject, projectExists as databaseProjectExists, updateMemoryRuleItem as updateDatabaseMemoryRule } from "../repositories/p8KnowledgeMemoryRepository.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import type { MemoryRuleCategory, MemoryRuleListFilters, MemoryRuleScope, MemoryRuleSeverity } from "../types/api.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import { getIdempotencyKey, getIdempotentResponse, setIdempotentResponse } from "../utils/idempotency.js";
|
||||
|
||||
type MemoryRouteOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"] };
|
||||
const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-]+$/);
|
||||
const projectParamsSchema = z.object({ projectId: idSchema });
|
||||
const ruleParamsSchema = z.object({ projectId: idSchema, ruleId: 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 scopes = ["global", "module", "feature", "prompt_only"] as const;
|
||||
const categories = ["UX", "Technical", "Architecture", "Do Not Break", "Naming", "Workflow", "Security"] as const;
|
||||
const severities = ["info", "important", "critical", "do_not_break"] as const;
|
||||
const memoryRuleQuerySchema = paginationQuerySchema.extend({ scope: z.string().optional(), category: z.string().optional(), severity: z.string().optional(), includeInContextPacks: z.coerce.boolean().optional(), featureId: z.string().optional(), search: z.string().optional() });
|
||||
const memoryRuleBodySchema = z.object({ title: z.string().optional(), rule: z.string().optional(), scope: z.enum(scopes).optional(), category: z.enum(categories).optional(), severity: z.enum(severities).optional(), linkedFeatureIds: z.array(z.string()).optional(), includeInContextPacks: z.boolean().optional() });
|
||||
const includeBodySchema = z.object({ contextPackId: z.string().optional() });
|
||||
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 ensureProject = async (request: FastifyRequest, reply: FastifyReply, projectId: string, dataSource: AppEnv["PROJECTBRAIN_DATA_SOURCE"]) => { const exists = dataSource === "database" ? await databaseProjectExists(projectId) : Boolean(findProject(projectId)); if (!exists) { reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${projectId}' was not found.`, getRequestId(request))); return false; } return true; };
|
||||
const splitCsv = (value: string | undefined): string[] => value?.split(",").map((item) => item.trim()).filter(Boolean) ?? [];
|
||||
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 toPaginationInput = (query: z.output<typeof paginationQuerySchema>, filters: PaginationInput["filters"]): PaginationInput => ({ page: query.page, pageSize: query.pageSize, cursor: query.cursor, sort: query.sort, filters });
|
||||
const toFilters = (query: z.output<typeof memoryRuleQuerySchema>): MemoryRuleListFilters => ({ ...(query.scope ? { scope: splitCsv(query.scope) as MemoryRuleScope[] } : {}), ...(query.category ? { category: splitCsv(query.category) as MemoryRuleCategory[] } : {}), ...(query.severity ? { severity: splitCsv(query.severity) as MemoryRuleSeverity[] } : {}), ...(query.includeInContextPacks !== undefined ? { includeInContextPacks: query.includeInContextPacks } : {}), ...(query.featureId ? { featureId: query.featureId } : {}), ...(query.search ? { search: query.search } : {}), ...(query.cursor ? { cursor: query.cursor } : {}) });
|
||||
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 sendMemoryRuleNotFound = (request: FastifyRequest, reply: FastifyReply, ruleId: string) => reply.status(404).send(errorEnvelope("MEMORY_RULE_NOT_FOUND", `Memory rule '${ruleId}' was not found.`, getRequestId(request)));
|
||||
const sendContextPackNotFound = (request: FastifyRequest, reply: FastifyReply, contextPackId: string) => reply.status(404).send(errorEnvelope("CONTEXT_PACK_NOT_FOUND", `Context pack '${contextPackId}' was not found.`, getRequestId(request)));
|
||||
export const registerMemoryRuleRoutes = async (app: FastifyInstance, options: MemoryRouteOptions = {}) => {
|
||||
const dataSource = options.dataSource ?? "fixture";
|
||||
app.get("/api/projects/:projectId/memory-rules", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply; const query = parseQuery(memoryRuleQuerySchema, request, reply); if (!query) return reply; const filters = toFilters(query); const rules = dataSource === "database" ? await listDatabaseMemoryRulesForProject(params.projectId, filters) : applyMemoryRuleFilters(listMemoryRulesForProject(params.projectId), filters); return successEnvelope(paginate(sortByField(rules, query.sort), toPaginationInput(query, filters)), getRequestId(request)); });
|
||||
app.get("/api/projects/:projectId/memory-rules/:ruleId", async (request, reply) => { const params = parseParams(ruleParamsSchema, request, reply); if (!params) return reply; if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply; const rule = dataSource === "database" ? await getDatabaseMemoryRuleBySlugOrId(params.projectId, params.ruleId) : findMemoryRule(params.projectId, params.ruleId); if (!rule) return sendMemoryRuleNotFound(request, reply, params.ruleId); return successEnvelope(rule, getRequestId(request)); });
|
||||
app.post("/api/projects/:projectId/memory-rules", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply; const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = getIdempotentResponse<unknown>(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); const parsedBody = memoryRuleBodySchema.safeParse(request.body); if (!parsedBody.success || !parsedBody.data.rule?.trim()) return reply.status(400).send(errorEnvelope("MEMORY_RULE_VALIDATION_FAILED", "rule is required to create a memory rule.", getRequestId(request))); const item = dataSource === "database" ? await createDatabaseMemoryRule(params.projectId, { ...parsedBody.data, rule: parsedBody.data.rule }) : createMemoryRule(params.projectId, { ...parsedBody.data, rule: parsedBody.data.rule }); const body = successEnvelope(item, getRequestId(request)); setIdempotentResponse(cacheKey, { statusCode: 201, body }); return reply.status(201).send(body); });
|
||||
app.patch("/api/projects/:projectId/memory-rules/:ruleId", async (request, reply) => { const params = parseParams(ruleParamsSchema, request, reply); if (!params) return reply; if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply; const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = getIdempotentResponse<unknown>(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); const parsedBody = memoryRuleBodySchema.safeParse(request.body); if (!parsedBody.success || (parsedBody.data.rule !== undefined && !parsedBody.data.rule.trim())) return reply.status(400).send(errorEnvelope("MEMORY_RULE_VALIDATION_FAILED", "Memory rule update body is invalid.", getRequestId(request))); const item = dataSource === "database" ? await updateDatabaseMemoryRule(params.projectId, params.ruleId, parsedBody.data) : updateMemoryRule(params.projectId, params.ruleId, parsedBody.data); if (!item) return sendMemoryRuleNotFound(request, reply, params.ruleId); const body = successEnvelope(item, getRequestId(request)); setIdempotentResponse(cacheKey, { statusCode: 200, body }); return reply.status(200).send(body); });
|
||||
app.post("/api/projects/:projectId/memory-rules/:ruleId/include-in-context-pack", async (request, reply) => { const params = parseParams(ruleParamsSchema, request, reply); if (!params) return reply; if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply; const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = getIdempotentResponse<unknown>(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); const parsedBody = includeBodySchema.safeParse(request.body); if (!parsedBody.success || !parsedBody.data.contextPackId?.trim()) return reply.status(400).send(errorEnvelope("INCLUDE_IN_CONTEXT_PACK_FAILED", "contextPackId is required.", getRequestId(request))); let item; if (dataSource === "database") { try { item = await includeDatabaseMemoryRuleInContextPack(params.projectId, params.ruleId, parsedBody.data.contextPackId); } catch (error) { if ((error as Error).message === "CONTEXT_PACK_NOT_FOUND") return sendContextPackNotFound(request, reply, parsedBody.data.contextPackId); throw error; } } else { if (!findMemoryRule(params.projectId, params.ruleId)) return sendMemoryRuleNotFound(request, reply, params.ruleId); if (!findContextPack(params.projectId, parsedBody.data.contextPackId)) return sendContextPackNotFound(request, reply, parsedBody.data.contextPackId); item = includeMemoryRuleInContextPack(params.projectId, params.ruleId, parsedBody.data.contextPackId); } if (!item) return sendMemoryRuleNotFound(request, reply, params.ruleId); const body = successEnvelope(item, getRequestId(request)); setIdempotentResponse(cacheKey, { statusCode: 200, body }); return reply.status(200).send(body); });
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import { renderHttpMetrics } from "../plugins/observability.js";
|
||||
|
||||
export const registerMetricsRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => {
|
||||
app.get("/api/metrics", async (_request, reply) => {
|
||||
let databaseReady = options.env.PROJECTBRAIN_DATA_SOURCE === "fixture" ? 1 : 0;
|
||||
let active = 0;
|
||||
let idle = 0;
|
||||
let maximum = 0;
|
||||
let probeDurationMs = 0;
|
||||
if (options.env.PROJECTBRAIN_DATA_SOURCE === "database") {
|
||||
const started = performance.now();
|
||||
try {
|
||||
const [connections, settings] = await Promise.all([
|
||||
prisma.$queryRaw<Array<{ state: string | null; count: bigint }>>`SELECT state, COUNT(*)::bigint AS count FROM pg_stat_activity WHERE datname = current_database() GROUP BY state`,
|
||||
prisma.$queryRaw<Array<{ setting: string }>>`SELECT setting FROM pg_settings WHERE name = 'max_connections'`,
|
||||
]);
|
||||
active = Number(connections.find((item) => item.state === "active")?.count ?? 0);
|
||||
idle = Number(connections.find((item) => item.state === "idle")?.count ?? 0);
|
||||
maximum = Number(settings[0]?.setting ?? 0);
|
||||
databaseReady = 1;
|
||||
} catch {
|
||||
databaseReady = 0;
|
||||
} finally {
|
||||
probeDurationMs = performance.now() - started;
|
||||
}
|
||||
}
|
||||
const databaseMetrics = [
|
||||
"# HELP projectbrain_database_ready Whether the configured persistence dependency is reachable.",
|
||||
"# TYPE projectbrain_database_ready gauge",
|
||||
`projectbrain_database_ready ${databaseReady}`,
|
||||
"# HELP projectbrain_database_connections PostgreSQL connections for the current database.",
|
||||
"# TYPE projectbrain_database_connections gauge",
|
||||
`projectbrain_database_connections{state="active"} ${active}`,
|
||||
`projectbrain_database_connections{state="idle"} ${idle}`,
|
||||
`projectbrain_database_connections{state="maximum"} ${maximum}`,
|
||||
"# HELP projectbrain_database_probe_duration_ms Latest database observation duration.",
|
||||
"# TYPE projectbrain_database_probe_duration_ms gauge",
|
||||
`projectbrain_database_probe_duration_ms ${probeDurationMs.toFixed(3)}`,
|
||||
].join("\n");
|
||||
return reply.type("text/plain; version=0.0.4; charset=utf-8").send(`${renderHttpMetrics()}${databaseMetrics}\n`);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import { claimReadyWork, getProjectWorkGraph, listWorkGraphHistory, recordWorkCompletion, reclaimExpiredWork, releaseWork, updateWorkDependencies } from "../services/workGraph.js";
|
||||
import { evaluateProjectCompletion, saveProjectDefinitionOfDone, simulateAutonomy, type CompletionRequirementKey } from "../services/projectCompletion.js";
|
||||
import { listDiagnosticHistory, runDiagnostics } from "../services/diagnostics.js";
|
||||
import { getProjectAttention } from "../services/attention.js";
|
||||
import { applyCommandIntent, parseCommandIntent } from "../services/commandIntent.js";
|
||||
import { buildPulse, type PulseEvent } from "../services/pulse.js";
|
||||
import { getProjectKnowledge } from "../services/projectKnowledge.js";
|
||||
import { getProjectEvidenceGraph } from "../services/evidenceGraph.js";
|
||||
import { listPortfolioProjectPins, setPortfolioProjectPin } from "../services/portfolioControlPlane.js";
|
||||
|
||||
const id = z.string().trim().min(1).max(200);
|
||||
const projectParams = z.object({ projectId: id });
|
||||
const nodeParams = z.object({ projectId: id, nodeId: id });
|
||||
const viewParams = z.object({ viewId: id });
|
||||
const pinParams = z.object({ projectId: id });
|
||||
// The authenticated identity, never a caller-controlled request field, owns
|
||||
// leases and audit events. Otherwise a project member could forge a trusted
|
||||
// executor or owner in durable work-history evidence.
|
||||
const leaseBody = z.object({ leaseMinutes: z.number().int().min(5).max(120).optional(), leaseToken: id.optional() });
|
||||
const completionBody = z.object({ leaseToken: id, summary: z.string().trim().min(3).max(12_000), evidence: z.array(z.object({ kind: id, referenceType: id, referenceId: id, status: id.optional() })).max(30).optional() });
|
||||
const dependencyBody = z.object({ dependencyNodeIds: z.array(id).max(100) });
|
||||
export const parseWorkGraphLeaseInput = (input: unknown) => leaseBody.parse(input);
|
||||
const doneBody = z.object({ requirements: z.array(z.object({ key: z.enum(["committed_scope", "validations_green", "ux_verification", "documentation_current", "release_candidate", "deployment_completed", "runtime_verified", "backup_rollback_evidence"] as [CompletionRequirementKey, ...CompletionRequirementKey[]]), label: z.string().trim().min(2).max(160).optional(), description: z.string().trim().min(2).max(500).optional(), required: z.boolean().optional() })).min(1).max(20) });
|
||||
const viewBody = z.object({ name: z.string().trim().min(2).max(80), scope: z.enum(["portfolio", "project"]).default("portfolio"), projectId: id.optional(), filters: z.record(z.unknown()).default({}), isPinned: z.boolean().default(false) });
|
||||
const viewPatch = viewBody.partial();
|
||||
const pinBody = z.object({ pinned: z.boolean() });
|
||||
const commandBody = z.object({ command: z.string().trim().min(2).max(2_000), apply: z.boolean().default(false) });
|
||||
const pulseQuery = z.object({ window: z.enum(["24h", "7d", "30d"]).default("7d") });
|
||||
const diagnosticsQuery = z.object({ projectId: id.optional(), limit: z.coerce.number().int().min(1).max(50).default(10) });
|
||||
const pulseWindowMs = (window: "24h" | "7d" | "30d") => window === "24h" ? 24 * 60 * 60_000 : window === "30d" ? 30 * 24 * 60 * 60_000 : 7 * 24 * 60 * 60_000;
|
||||
const actorFor = (request: FastifyRequest, fallback = "owner") => request.authUser?.username ?? fallback;
|
||||
const json = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
|
||||
export const isOwnerRequest = (request: Pick<FastifyRequest, "authUser">) => request.authUser?.role === "owner";
|
||||
|
||||
const requireDatabase = (env: AppEnv, request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (env.PROJECTBRAIN_DATA_SOURCE === "database") return false;
|
||||
reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "This operating-system capability requires durable database mode.", getRequestId(request)));
|
||||
return true;
|
||||
};
|
||||
|
||||
// Portfolio-wide snapshots and diagnostics include every project and can
|
||||
// trigger safe repair. They must not inherit the looser project-member access
|
||||
// that applies to URLs scoped by /api/projects/:projectId.
|
||||
const requireOwner = (request: FastifyRequest, reply: FastifyReply) => isOwnerRequest(request)
|
||||
? false
|
||||
: Boolean(reply.status(403).send(errorEnvelope("OWNER_ACCESS_REQUIRED", "Owner access is required for portfolio-wide operating-system data.", getRequestId(request))));
|
||||
|
||||
const operationError = (request: FastifyRequest, reply: FastifyReply, error: unknown) => {
|
||||
const code = error instanceof Error ? error.message : "OPERATING_SYSTEM_OPERATION_FAILED";
|
||||
const status = code === "PROJECT_NOT_FOUND" || code.endsWith("NOT_FOUND") ? 404 : code.includes("STALE") || code.includes("CYCLE") ? 409 : 400;
|
||||
return reply.status(status).send(errorEnvelope(code, "ProjectBrain could not complete this operating-system action.", getRequestId(request)));
|
||||
};
|
||||
|
||||
const pulseEventsForProject = async (projectId: string): Promise<PulseEvent[]> => {
|
||||
const [activity, logs, graphEvents] = await Promise.all([
|
||||
prisma.activityEvent.findMany({ where: { projectId }, orderBy: { createdAt: "desc" }, take: 200 }),
|
||||
prisma.operatingLogEntry.findMany({ where: { projectId, deletedAt: null }, orderBy: { timestamp: "desc" }, take: 200 }),
|
||||
prisma.workGraphAuditEvent.findMany({ where: { projectId }, orderBy: { createdAt: "desc" }, take: 200 }),
|
||||
]);
|
||||
return [
|
||||
...activity.map((event) => ({ at: event.createdAt.toISOString(), kind: event.eventType, title: event.eventType, summary: JSON.stringify(event.payloadJson ?? {}), meaningful: true })),
|
||||
...logs.map((event) => ({ at: event.timestamp.toISOString(), kind: event.type, title: event.title, summary: event.summary, meaningful: event.importance !== "low" })),
|
||||
...graphEvents.map((event) => ({ at: event.createdAt.toISOString(), kind: event.eventType, title: event.eventType, summary: event.summary, meaningful: !["work.claimed"].includes(event.eventType) })),
|
||||
];
|
||||
};
|
||||
|
||||
export const registerOperatingSystemRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => {
|
||||
app.get("/api/projects/:projectId/work-graph", async (request, reply) => {
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { return successEnvelope(await getProjectWorkGraph(projectParams.parse(request.params).projectId), getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.get("/api/projects/:projectId/work-graph/ready", async (request, reply) => {
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { const graph = await getProjectWorkGraph(projectParams.parse(request.params).projectId); return successEnvelope({ generatedAt: graph.generatedAt, items: graph.ready, parallelGroups: graph.parallelGroups }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.get("/api/projects/:projectId/work-graph/blockers", async (request, reply) => {
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { const graph = await getProjectWorkGraph(projectParams.parse(request.params).projectId); return successEnvelope({ generatedAt: graph.generatedAt, blockers: graph.blockers }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.get("/api/projects/:projectId/work-graph/history", async (request, reply) => {
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { const projectId = projectParams.parse(request.params).projectId; return successEnvelope({ items: await listWorkGraphHistory(projectId) }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.post("/api/projects/:projectId/work-graph/claim", async (request, reply) => {
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { const projectId = projectParams.parse(request.params).projectId; const body = parseWorkGraphLeaseInput(request.body ?? {}); return successEnvelope({ work: await claimReadyWork(projectId, actorFor(request, "projectbrain-agent"), body.leaseMinutes) }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.post("/api/projects/:projectId/work-graph/reclaim", async (request, reply) => {
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { return successEnvelope(await reclaimExpiredWork(projectParams.parse(request.params).projectId, actorFor(request, "system")), getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.post("/api/projects/:projectId/work-graph/:nodeId/release", async (request, reply) => {
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { const params = nodeParams.parse(request.params); const body = leaseBody.parse(request.body ?? {}); if (!body.leaseToken) throw new Error("WORK_GRAPH_LEASE_TOKEN_REQUIRED"); return successEnvelope(await releaseWork(params.projectId, params.nodeId, actorFor(request), body.leaseToken), getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.post("/api/projects/:projectId/work-graph/:nodeId/complete", async (request, reply) => {
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { const params = nodeParams.parse(request.params); const body = completionBody.parse(request.body ?? {}); return successEnvelope(await recordWorkCompletion(params.projectId, params.nodeId, actorFor(request), body.leaseToken, body), getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.put("/api/projects/:projectId/work-graph/:nodeId/dependencies", async (request, reply) => {
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { const params = nodeParams.parse(request.params); const body = dependencyBody.parse(request.body ?? {}); return successEnvelope(await updateWorkDependencies(params.projectId, params.nodeId, body.dependencyNodeIds, actorFor(request)), getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/definition-of-done", async (request, reply) => {
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { return successEnvelope(await evaluateProjectCompletion(projectParams.parse(request.params).projectId), getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.put("/api/projects/:projectId/definition-of-done", async (request, reply) => {
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { const body = doneBody.parse(request.body ?? {}); return successEnvelope(await saveProjectDefinitionOfDone(projectParams.parse(request.params).projectId, body, actorFor(request)), getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.get("/api/projects/:projectId/autonomy/simulation", async (request, reply) => {
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { return successEnvelope(await simulateAutonomy(projectParams.parse(request.params).projectId), getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.get("/api/projects/:projectId/attention", async (request, reply) => {
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { return successEnvelope(await getProjectAttention(projectParams.parse(request.params).projectId), getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.get("/api/projects/:projectId/project-knowledge", async (request, reply) => {
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { return successEnvelope(await getProjectKnowledge(projectParams.parse(request.params).projectId), getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.get("/api/projects/:projectId/project-knowledge/conflicts", async (request, reply) => {
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { const knowledge = await getProjectKnowledge(projectParams.parse(request.params).projectId); return successEnvelope({ generatedAt: knowledge.generatedAt, conflicts: knowledge.conflicts, unresolvedConflictCount: knowledge.unresolvedConflictCount }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.get("/api/projects/:projectId/evidence-graph", async (request, reply) => {
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { return successEnvelope(await getProjectEvidenceGraph(projectParams.parse(request.params).projectId), getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.post("/api/diagnostics", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
try { const body = z.object({ projectId: id.optional(), repairSafe: z.boolean().default(true) }).parse(request.body ?? {}); return successEnvelope(await runDiagnostics(options.env, { ...body, actor: actorFor(request, "diagnostics") }), getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.get("/api/diagnostics/history", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
try { const query = diagnosticsQuery.parse(request.query ?? {}); return successEnvelope({ items: await listDiagnosticHistory(query.projectId, query.limit) }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.post("/api/projects/:projectId/diagnostics", async (request, reply) => {
|
||||
try { const body = z.object({ repairSafe: z.boolean().default(true) }).parse(request.body ?? {}); return successEnvelope(await runDiagnostics(options.env, { projectId: projectParams.parse(request.params).projectId, ...body, actor: actorFor(request, "diagnostics") }), getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.get("/api/projects/:projectId/pulse", async (request, reply) => {
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { const projectId = projectParams.parse(request.params).projectId; const { window } = pulseQuery.parse(request.query ?? {}); const events = await pulseEventsForProject(projectId); return successEnvelope(buildPulse(events, Date.now(), pulseWindowMs(window)), getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.get("/api/portfolio/pulse", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { const projects = await prisma.project.findMany({ where: { deletedAt: null }, select: { id: true, name: true } }); const { window } = pulseQuery.parse(request.query ?? {}); const pulses = await Promise.all(projects.map(async (project) => ({ projectId: project.id, projectName: project.name, pulse: buildPulse(await pulseEventsForProject(project.id), Date.now(), pulseWindowMs(window)) }))); return successEnvelope({ generatedAt: new Date().toISOString(), window, projects: pulses, significantCompletions: pulses.flatMap((item) => item.pulse.completed.slice(0, 2).map((event) => ({ ...event, projectId: item.projectId, projectName: item.projectName }))) }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.post("/api/commands/interpret", async (request, reply) => { try { const body = commandBody.parse(request.body ?? {}); return successEnvelope(parseCommandIntent(body.command), getRequestId(request)); } catch (error) { return operationError(request, reply, error); } });
|
||||
app.post("/api/projects/:projectId/commands/interpret", async (request, reply) => { try { const projectId = projectParams.parse(request.params).projectId; const body = commandBody.parse(request.body ?? {}); const intent = parseCommandIntent(body.command); if (!body.apply) return successEnvelope({ projectId, intent, applied: false }, getRequestId(request)); if (requireDatabase(options.env, request, reply)) return reply; return successEnvelope(await applyCommandIntent(projectId, intent, actorFor(request)), getRequestId(request)); } catch (error) { return operationError(request, reply, error); } });
|
||||
|
||||
app.get("/api/portfolio/views", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { const ownerKey = actorFor(request); return successEnvelope({ items: await prisma.projectSavedView.findMany({ where: { ownerKey }, orderBy: [{ isPinned: "desc" }, { updatedAt: "desc" }] }) }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.post("/api/portfolio/views", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { const body = viewBody.parse(request.body ?? {}); const ownerKey = actorFor(request); const view = await prisma.projectSavedView.create({ data: { ownerKey, name: body.name, scope: body.scope, filtersJson: json(body.filters), isPinned: body.isPinned, ...(body.projectId ? { project: { connect: { id: body.projectId } } } : {}) } }); return reply.status(201).send(successEnvelope(view, getRequestId(request))); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.patch("/api/portfolio/views/:viewId", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { const { viewId } = viewParams.parse(request.params); const body = viewPatch.parse(request.body ?? {}); const existing = await prisma.projectSavedView.findFirst({ where: { id: viewId, ownerKey: actorFor(request) } }); if (!existing) throw new Error("SAVED_VIEW_NOT_FOUND"); const view = await prisma.projectSavedView.update({ where: { id: viewId }, data: { ...(body.name ? { name: body.name } : {}), ...(body.scope ? { scope: body.scope } : {}), ...(body.projectId ? { project: { connect: { id: body.projectId } } } : {}), ...(body.filters ? { filtersJson: json(body.filters) } : {}), ...(body.isPinned !== undefined ? { isPinned: body.isPinned } : {}) } }); return successEnvelope(view, getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.delete("/api/portfolio/views/:viewId", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { const { viewId } = viewParams.parse(request.params); const existing = await prisma.projectSavedView.findFirst({ where: { id: viewId, ownerKey: actorFor(request) } }); if (!existing) throw new Error("SAVED_VIEW_NOT_FOUND"); await prisma.projectSavedView.delete({ where: { id: viewId } }); return successEnvelope({ deleted: true, id: viewId }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.get("/api/portfolio/pins", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { return successEnvelope({ projectIds: await listPortfolioProjectPins(actorFor(request)) }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
app.put("/api/portfolio/pins/:projectId", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
if (requireDatabase(options.env, request, reply)) return reply;
|
||||
try { const { projectId } = pinParams.parse(request.params); const { pinned } = pinBody.parse(request.body ?? {}); return successEnvelope(await setPortfolioProjectPin(actorFor(request), projectId, pinned), getRequestId(request)); } catch (error) { return operationError(request, reply, error); }
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,331 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { excludePortfolioRepository, getPortfolioControlPlane, listPortfolioRepositories, reconcilePortfolioRepositories, restorePortfolioRepository } from "../services/portfolioControlPlane.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import { cancelPortfolioAnalysisJob, createPortfolioAnalysisJob, getPortfolioAnalysisJob, getPortfolioAnalysisJobResults } from "../repositories/analysisJobRepository.js";
|
||||
import { syncGitSources } from "../services/gitSourceSync.js";
|
||||
|
||||
const reconcileBody = z.object({
|
||||
dryRun: z.boolean().default(true),
|
||||
repositoryFullNames: z.array(z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/)).max(50).optional(),
|
||||
});
|
||||
const repositoryParams = z.object({ repositoryId: z.string().min(1) });
|
||||
const jobParams = z.object({ jobId: z.string().min(1) });
|
||||
const resultPageQuery = z.object({
|
||||
cursor: z.string().regex(/^\d+$/).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(10).default(8),
|
||||
characterBudget: z.coerce.number().int().min(4_000).max(32_000).default(16_000),
|
||||
});
|
||||
const exclusionBody = z.object({ reason: z.string().trim().min(3).max(2_000) });
|
||||
|
||||
const requireOwner = (request: FastifyRequest, reply: FastifyReply) => request.authUser?.role === "owner"
|
||||
? false
|
||||
: Boolean(reply.status(403).send(errorEnvelope("OWNER_ACCESS_REQUIRED", "Owner access is required for portfolio administration.", getRequestId(request))));
|
||||
|
||||
const portfolioError = (error: unknown, request: FastifyRequest, reply: FastifyReply) => {
|
||||
const code = error instanceof Error ? error.message : "PORTFOLIO_OPERATION_FAILED";
|
||||
const status = code.endsWith("NOT_FOUND") ? 404 : code.includes("ALREADY_ONBOARDED") ? 409 : 502;
|
||||
return reply.status(status).send(errorEnvelope(code, "The portfolio operation could not be completed.", getRequestId(request)));
|
||||
};
|
||||
|
||||
export const registerPortfolioRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => {
|
||||
app.post("/api/portfolio/sources/sync-git", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
try { return reply.status(202).send(successEnvelope(await syncGitSources(options.env), getRequestId(request))); }
|
||||
catch (error) { return portfolioError(error, request, reply); }
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/sources/sync-git", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
const { projectId } = request.params as { projectId: string };
|
||||
try { return reply.status(202).send(successEnvelope(await syncGitSources(options.env, { projectId }), getRequestId(request))); }
|
||||
catch (error) { return portfolioError(error, request, reply); }
|
||||
});
|
||||
app.get("/api/portfolio/control-plane", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
try { return successEnvelope(await getPortfolioControlPlane(options.env, request.authUser?.username ?? "owner"), getRequestId(request)); }
|
||||
catch (error) { return portfolioError(error, request, reply); }
|
||||
});
|
||||
|
||||
app.get("/api/portfolio/repositories", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
try { return successEnvelope(await listPortfolioRepositories(options.env), getRequestId(request)); }
|
||||
catch (error) { return portfolioError(error, request, reply); }
|
||||
});
|
||||
|
||||
app.post("/api/portfolio/reconcile", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
const body = reconcileBody.parse(request.body ?? {});
|
||||
try {
|
||||
const result = await reconcilePortfolioRepositories(options.env, `web:${request.authUser!.username}`, body);
|
||||
const sourceSync = body.dryRun ? undefined : await syncGitSources(options.env);
|
||||
return reply.status(body.dryRun ? 200 : 202).send(successEnvelope({ ...result, sourceSync }, getRequestId(request)));
|
||||
} catch (error) { return portfolioError(error, request, reply); }
|
||||
});
|
||||
|
||||
app.post("/api/portfolio/analysis-jobs", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
try {
|
||||
const result = await createPortfolioAnalysisJob(options.env, `web:${request.authUser!.username}:portfolio`, {
|
||||
includeBranches: true,
|
||||
includePullRequests: true,
|
||||
includeActions: true,
|
||||
});
|
||||
return reply.status(result.deduplicated ? 200 : 202).send(successEnvelope(result, getRequestId(request)));
|
||||
} catch (error) { return portfolioError(error, request, reply); }
|
||||
});
|
||||
|
||||
app.get("/api/portfolio/analysis-jobs/:jobId", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
const { jobId } = jobParams.parse(request.params);
|
||||
try { return successEnvelope({ job: await getPortfolioAnalysisJob(jobId) }, getRequestId(request)); }
|
||||
catch (error) { return portfolioError(error, request, reply); }
|
||||
});
|
||||
|
||||
app.get("/api/portfolio/analysis-jobs/:jobId/results", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
const { jobId } = jobParams.parse(request.params);
|
||||
const query = resultPageQuery.parse(request.query ?? {});
|
||||
try { return successEnvelope(await getPortfolioAnalysisJobResults(jobId, query), getRequestId(request)); }
|
||||
catch (error) { return portfolioError(error, request, reply); }
|
||||
});
|
||||
|
||||
app.post("/api/portfolio/analysis-jobs/:jobId/cancel", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
const { jobId } = jobParams.parse(request.params);
|
||||
try { return successEnvelope({ job: await cancelPortfolioAnalysisJob(jobId) }, getRequestId(request)); }
|
||||
catch (error) { return portfolioError(error, request, reply); }
|
||||
});
|
||||
|
||||
app.post("/api/portfolio/repositories/:repositoryId/exclude", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
const { repositoryId } = repositoryParams.parse(request.params);
|
||||
const { reason } = exclusionBody.parse(request.body ?? {});
|
||||
try { return successEnvelope(await excludePortfolioRepository(repositoryId, reason), getRequestId(request)); }
|
||||
catch (error) { return portfolioError(error, request, reply); }
|
||||
});
|
||||
|
||||
app.post("/api/portfolio/repositories/:repositoryId/restore", async (request, reply) => {
|
||||
if (requireOwner(request, reply)) return reply;
|
||||
const { repositoryId } = repositoryParams.parse(request.params);
|
||||
try { return successEnvelope(await restorePortfolioRepository(repositoryId), getRequestId(request)); }
|
||||
catch (error) { return portfolioError(error, request, reply); }
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,414 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
findProject,
|
||||
getWorkspaceRead,
|
||||
listProjects as listFixtureProjects,
|
||||
} from "../fixtures/projectFixtures.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import {
|
||||
createProject,
|
||||
getProjectBySlugOrId,
|
||||
getProjectDNA,
|
||||
getProjectWorkspace,
|
||||
listProjects as listDatabaseProjects,
|
||||
updateActiveContextPack,
|
||||
updateProject,
|
||||
} from "../repositories/projectsRepository.js";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import { mergeProjectSetupState, projectSetupActiveContextPackId, projectSetupWorkspaceState, sanitizeProjectSetupState } from "../repositories/projectSetupState.js";
|
||||
import {
|
||||
existingRepositoryFromProjectSetup,
|
||||
hasProjectRepositoryProvisioning,
|
||||
projectSlugFromInput,
|
||||
provisionProjectRepository,
|
||||
} from "../services/projectRepositoryProvisioning.js";
|
||||
|
||||
const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/);
|
||||
|
||||
const projectParamsSchema = z.object({
|
||||
projectId: idSchema,
|
||||
});
|
||||
|
||||
const projectDnaSchema = z.object({
|
||||
vision: z.string().trim().min(1).max(4000),
|
||||
goals: z.array(z.string().trim().min(1).max(1000)).max(50),
|
||||
nonGoals: z.array(z.string().trim().min(1).max(1000)).max(50),
|
||||
targetAudience: z.string().trim().min(1).max(1000),
|
||||
principles: z.array(z.string().trim().min(1).max(1000)).max(50),
|
||||
successMetrics: z.array(z.object({
|
||||
metric: z.string().trim().min(1).max(200),
|
||||
target: z.string().trim().min(1).max(500),
|
||||
targetValue: z.number().optional(),
|
||||
operator: z.enum([">", "<", ">=", "<=", "=", "between"]).optional(),
|
||||
})).max(50),
|
||||
maturityStage: z.enum(["Discovery", "Definition", "Execution", "Validation", "Operational"]),
|
||||
revision: z.number().int().positive(),
|
||||
updatedAt: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
const projectSetupStateSchema = z.record(z.string(), z.unknown()).refine(
|
||||
(value) => JSON.stringify(value).length <= 750_000,
|
||||
"Project setup state may not exceed 750 KB.",
|
||||
);
|
||||
|
||||
const createProjectBodySchema = z.object({
|
||||
id: idSchema.optional(),
|
||||
name: z.string().trim().min(2).max(120),
|
||||
description: z.string().trim().min(8).max(2000),
|
||||
domain: z.string().trim().min(1).max(120).optional(),
|
||||
primaryGoal: z.string().trim().min(1).max(1000).optional(),
|
||||
targetAudience: z.string().trim().min(1).max(500).optional(),
|
||||
dna: projectDnaSchema.optional(),
|
||||
setupState: projectSetupStateSchema.optional(),
|
||||
});
|
||||
|
||||
const updateProjectBodySchema = z.object({
|
||||
name: z.string().trim().min(2).max(120).optional(),
|
||||
description: z.string().trim().min(8).max(2000).optional(),
|
||||
domain: z.string().trim().min(1).max(120).optional(),
|
||||
status: z.enum(["active", "archived", "paused"]).optional(),
|
||||
}).refine((value) => Object.keys(value).length > 0, {
|
||||
message: "At least one project field must be provided.",
|
||||
});
|
||||
|
||||
const updateActiveContextPackBodySchema = z.object({
|
||||
contextPackId: idSchema,
|
||||
});
|
||||
|
||||
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 parseProjectParams = (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const parsed = projectParamsSchema.safeParse(request.params);
|
||||
|
||||
if (!parsed.success) {
|
||||
sendValidationError(request, reply, parsed.error.issues);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
const sendProjectNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"PROJECT_NOT_FOUND",
|
||||
`Project '${projectId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
type ProjectRoutesOptions = {
|
||||
dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"];
|
||||
env?: AppEnv;
|
||||
};
|
||||
|
||||
type RepositoryEvidence = { repositoryLinkCount: number; readyRepositoryCount: number };
|
||||
|
||||
const repositoryEvidenceForProjects = async (projectSlugs: string[]) => {
|
||||
const evidence = new Map<string, RepositoryEvidence>();
|
||||
for (const slug of projectSlugs) evidence.set(slug, { repositoryLinkCount: 0, readyRepositoryCount: 0 });
|
||||
if (!projectSlugs.length) return evidence;
|
||||
|
||||
const links = await prisma.giteaRepositoryLink.findMany({
|
||||
where: { project: { deletedAt: null, slug: { in: projectSlugs } } },
|
||||
select: { syncStatus: true, project: { select: { slug: true } } },
|
||||
});
|
||||
for (const link of links) {
|
||||
const current = evidence.get(link.project.slug) ?? { repositoryLinkCount: 0, readyRepositoryCount: 0 };
|
||||
current.repositoryLinkCount += 1;
|
||||
if (link.syncStatus === "ready") current.readyRepositoryCount += 1;
|
||||
evidence.set(link.project.slug, current);
|
||||
}
|
||||
return evidence;
|
||||
};
|
||||
|
||||
const withRepositoryEvidence = <T extends { id: string }>(project: T, evidence: Map<string, RepositoryEvidence>) => ({
|
||||
...project,
|
||||
...(evidence.get(project.id) ?? { repositoryLinkCount: 0, readyRepositoryCount: 0 }),
|
||||
});
|
||||
|
||||
export const registerProjectRoutes = async (
|
||||
app: FastifyInstance,
|
||||
options: ProjectRoutesOptions = {},
|
||||
) => {
|
||||
const useDatabase = options.dataSource === "database";
|
||||
|
||||
app.get("/api/projects", async (request) => {
|
||||
if (!useDatabase) return successEnvelope(listFixtureProjects(), getRequestId(request));
|
||||
const projects = await listDatabaseProjects();
|
||||
const evidence = await repositoryEvidenceForProjects(projects.map((project) => project.id));
|
||||
return successEnvelope(projects.map((project) => withRepositoryEvidence(project, evidence)), getRequestId(request));
|
||||
});
|
||||
|
||||
app.post("/api/projects", async (request, reply) => {
|
||||
if (useDatabase && request.authUser?.role !== "owner") {
|
||||
return reply.status(403).send(errorEnvelope(
|
||||
"PROJECT_CREATION_FORBIDDEN",
|
||||
"Only the ProjectBrain owner can create a project and provision its Gitea repository.",
|
||||
getRequestId(request),
|
||||
));
|
||||
}
|
||||
if (useDatabase && options.env?.NODE_ENV === "production" && (!options.env.PROJECTBRAIN_GITEA_URL || !options.env.PROJECTBRAIN_GITEA_TOKEN)) {
|
||||
return reply.status(503).send(errorEnvelope(
|
||||
"GITEA_NOT_CONFIGURED",
|
||||
"ProjectBrain kan geen project starten zolang de Gitea-verbinding niet geconfigureerd is.",
|
||||
getRequestId(request),
|
||||
));
|
||||
}
|
||||
const parsed = createProjectBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return sendValidationError(request, reply, parsed.error.issues);
|
||||
}
|
||||
|
||||
if (!useDatabase) {
|
||||
const now = new Date().toISOString();
|
||||
const slug = parsed.data.id ?? parsed.data.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
const setupState = sanitizeProjectSetupState(parsed.data.setupState);
|
||||
const setupWorkspace = projectSetupWorkspaceState(parsed.data.setupState);
|
||||
return reply.status(201).send(
|
||||
successEnvelope(
|
||||
mergeProjectSetupState(parsed.data.setupState, {
|
||||
id: slug,
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
health: 100,
|
||||
healthStatus: "healthy",
|
||||
lastSync: now,
|
||||
domain: parsed.data.domain ?? "general",
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
activeContextPackId: projectSetupActiveContextPackId(parsed.data.setupState),
|
||||
dna: {
|
||||
vision: parsed.data.dna?.vision ?? parsed.data.primaryGoal ?? parsed.data.description,
|
||||
goals: parsed.data.dna?.goals ?? (parsed.data.primaryGoal ? [parsed.data.primaryGoal] : []),
|
||||
nonGoals: parsed.data.dna?.nonGoals ?? [],
|
||||
targetAudience: parsed.data.dna?.targetAudience ?? parsed.data.targetAudience ?? "Delivery teams",
|
||||
principles: parsed.data.dna?.principles ?? [],
|
||||
successMetrics: parsed.data.dna?.successMetrics ?? [],
|
||||
maturityStage: parsed.data.dna?.maturityStage ?? "Discovery",
|
||||
revision: parsed.data.dna?.revision ?? 1,
|
||||
updatedAt: now,
|
||||
},
|
||||
constraints: Array.isArray(setupState.constraints) ? setupState.constraints : [],
|
||||
principles: Array.isArray(setupState.principles) ? setupState.principles : [],
|
||||
initiatives: [],
|
||||
featureGroups: Array.isArray(setupState.featureGroups) ? setupState.featureGroups : [],
|
||||
memoryRules: Array.isArray(setupState.memoryRules) ? setupState.memoryRules : [],
|
||||
promptTemplates: Array.isArray(setupState.promptTemplates) ? setupState.promptTemplates : [],
|
||||
workspaceState: {
|
||||
...setupWorkspace,
|
||||
projectId: slug,
|
||||
primaryObjective: typeof setupWorkspace?.primaryObjective === "string" ? setupWorkspace.primaryObjective : parsed.data.primaryGoal ?? parsed.data.description,
|
||||
focusAreas: Array.isArray(setupWorkspace?.focusAreas) ? setupWorkspace.focusAreas : [],
|
||||
openTasksCount: typeof setupWorkspace?.openTasksCount === "number" ? setupWorkspace.openTasksCount : 0,
|
||||
openBugCount: typeof setupWorkspace?.openBugCount === "number" ? setupWorkspace.openBugCount : 0,
|
||||
activeSection: typeof setupWorkspace?.activeSection === "string" ? setupWorkspace.activeSection : "workspace",
|
||||
updatedAt: now,
|
||||
notes: Array.isArray(setupWorkspace?.notes) ? setupWorkspace.notes : [],
|
||||
},
|
||||
}),
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const existingRepository = existingRepositoryFromProjectSetup(parsed.data.setupState);
|
||||
const desiredSlug = projectSlugFromInput(parsed.data.id, parsed.data.name);
|
||||
let project;
|
||||
try {
|
||||
project = await createProject(parsed.data);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "PROJECT_SLUG_ALREADY_EXISTS") {
|
||||
const existingProject = await getProjectBySlugOrId(desiredSlug);
|
||||
const sameCreateRequest = existingProject?.name === parsed.data.name.trim()
|
||||
&& existingProject.description === parsed.data.description.trim();
|
||||
const hasManagedProvisioning = !existingRepository
|
||||
&& Boolean(options.env?.PROJECTBRAIN_GITEA_URL && options.env.PROJECTBRAIN_GITEA_TOKEN)
|
||||
&& sameCreateRequest
|
||||
&& await hasProjectRepositoryProvisioning(desiredSlug);
|
||||
if (hasManagedProvisioning) {
|
||||
project = existingProject;
|
||||
if (!project) throw error;
|
||||
} else {
|
||||
return reply.status(409).send(
|
||||
errorEnvelope(
|
||||
"VALIDATION_ERROR",
|
||||
"A project with this id or slug already exists.",
|
||||
getRequestId(request),
|
||||
[
|
||||
{
|
||||
field: "id",
|
||||
message: "Choose a different project name or id.",
|
||||
code: "custom",
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!project) throw new Error("PROJECT_CREATE_FAILED");
|
||||
const shouldProvisionRepository = !existingRepository
|
||||
&& Boolean(options.env?.PROJECTBRAIN_GITEA_URL && options.env.PROJECTBRAIN_GITEA_TOKEN);
|
||||
if (shouldProvisionRepository) {
|
||||
try {
|
||||
await provisionProjectRepository(options.env!, project.id, `web:${request.authUser?.username ?? "owner"}`);
|
||||
} catch (error) {
|
||||
const code = error instanceof Error ? error.message : "GITEA_REPOSITORY_PROVISIONING_FAILED";
|
||||
return reply.status(502).send(errorEnvelope(
|
||||
"GITEA_REPOSITORY_PROVISIONING_FAILED",
|
||||
"Het ProjectBrain-project is veilig opgeslagen, maar de Gitea-repository kon nog niet worden aangemaakt. Probeer opnieuw; de bestaande provisioning wordt idempotent hervat.",
|
||||
getRequestId(request),
|
||||
[{ field: "repository", message: code, code: "external_service" }],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const evidence = await repositoryEvidenceForProjects([project.id]);
|
||||
return reply.status(201).send(successEnvelope(withRepositoryEvidence(project, evidence), getRequestId(request)));
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId", async (request, reply) => {
|
||||
const params = parseProjectParams(request, reply);
|
||||
if (!params) return reply;
|
||||
|
||||
if (useDatabase) {
|
||||
const project = await getProjectBySlugOrId(params.projectId);
|
||||
if (!project) return sendProjectNotFound(request, reply, params.projectId);
|
||||
const evidence = await repositoryEvidenceForProjects([project.id]);
|
||||
return successEnvelope(withRepositoryEvidence(project, evidence), getRequestId(request));
|
||||
}
|
||||
|
||||
const project = findProject(params.projectId);
|
||||
if (!project) return sendProjectNotFound(request, reply, params.projectId);
|
||||
|
||||
return successEnvelope(project, getRequestId(request));
|
||||
});
|
||||
|
||||
app.patch("/api/projects/:projectId", async (request, reply) => {
|
||||
const params = parseProjectParams(request, reply);
|
||||
if (!params) return reply;
|
||||
|
||||
const parsed = updateProjectBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return sendValidationError(request, reply, parsed.error.issues);
|
||||
}
|
||||
|
||||
if (useDatabase) {
|
||||
const project = await updateProject(params.projectId, parsed.data);
|
||||
if (!project) return sendProjectNotFound(request, reply, params.projectId);
|
||||
const evidence = await repositoryEvidenceForProjects([project.id]);
|
||||
return successEnvelope(withRepositoryEvidence(project, evidence), getRequestId(request));
|
||||
}
|
||||
|
||||
const project = findProject(params.projectId);
|
||||
if (!project) return sendProjectNotFound(request, reply, params.projectId);
|
||||
|
||||
return successEnvelope(
|
||||
{
|
||||
...project,
|
||||
...parsed.data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastSync: new Date().toISOString(),
|
||||
},
|
||||
getRequestId(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/workspace", async (request, reply) => {
|
||||
const params = parseProjectParams(request, reply);
|
||||
if (!params) return reply;
|
||||
|
||||
if (useDatabase) {
|
||||
const workspace = await getProjectWorkspace(params.projectId);
|
||||
if (!workspace) return sendProjectNotFound(request, reply, params.projectId);
|
||||
const evidence = await repositoryEvidenceForProjects([workspace.project.id]);
|
||||
return successEnvelope({ ...workspace, project: withRepositoryEvidence(workspace.project, evidence) }, getRequestId(request));
|
||||
}
|
||||
|
||||
const project = findProject(params.projectId);
|
||||
if (!project) return sendProjectNotFound(request, reply, params.projectId);
|
||||
|
||||
return successEnvelope(getWorkspaceRead(project), getRequestId(request));
|
||||
});
|
||||
|
||||
app.patch("/api/projects/:projectId/workspace/active-context-pack", async (request, reply) => {
|
||||
const params = parseProjectParams(request, reply);
|
||||
if (!params) return reply;
|
||||
|
||||
const parsed = updateActiveContextPackBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return sendValidationError(request, reply, parsed.error.issues);
|
||||
}
|
||||
|
||||
if (useDatabase) {
|
||||
const workspace = await updateActiveContextPack(params.projectId, parsed.data);
|
||||
if (!workspace) return sendProjectNotFound(request, reply, params.projectId);
|
||||
const evidence = await repositoryEvidenceForProjects([workspace.project.id]);
|
||||
return successEnvelope({ ...workspace, project: withRepositoryEvidence(workspace.project, evidence) }, getRequestId(request));
|
||||
}
|
||||
|
||||
const project = findProject(params.projectId);
|
||||
if (!project) return sendProjectNotFound(request, reply, params.projectId);
|
||||
|
||||
return successEnvelope(
|
||||
{
|
||||
...getWorkspaceRead(project),
|
||||
activeContextPackId: parsed.data.contextPackId,
|
||||
workspaceState: project.workspaceState
|
||||
? {
|
||||
...project.workspaceState,
|
||||
activeContextPackId: parsed.data.contextPackId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
getRequestId(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/dna", async (request, reply) => {
|
||||
const params = parseProjectParams(request, reply);
|
||||
if (!params) return reply;
|
||||
|
||||
if (useDatabase) {
|
||||
const dna = await getProjectDNA(params.projectId);
|
||||
if (!dna) return sendProjectNotFound(request, reply, params.projectId);
|
||||
|
||||
return successEnvelope(dna, getRequestId(request));
|
||||
}
|
||||
|
||||
const project = findProject(params.projectId);
|
||||
if (!project) return sendProjectNotFound(request, reply, params.projectId);
|
||||
|
||||
return successEnvelope(project.dna, getRequestId(request));
|
||||
});
|
||||
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import {
|
||||
findContextPack,
|
||||
generatePrompt,
|
||||
} from "../fixtures/contextPackFixtures.js";
|
||||
import {
|
||||
generatePromptFromContextPack as generateDatabasePrompt,
|
||||
getContextPackBySlugOrId as getDatabaseContextPackBySlugOrId,
|
||||
projectExists as databaseProjectExists,
|
||||
} from "../repositories/contextPacksRepository.js";
|
||||
import { findProject } from "../fixtures/projectFixtures.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
|
||||
const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/);
|
||||
|
||||
const projectParamsSchema = z.object({
|
||||
projectId: idSchema,
|
||||
});
|
||||
|
||||
const targetToolSchema = z.enum([
|
||||
"codex",
|
||||
"claude",
|
||||
"gpt",
|
||||
"cursor",
|
||||
"gemini",
|
||||
"local_llm",
|
||||
"markdown",
|
||||
"json",
|
||||
"mcp",
|
||||
]);
|
||||
|
||||
const personaSchema = z.enum([
|
||||
"developer",
|
||||
"architect",
|
||||
"qa",
|
||||
"ux",
|
||||
"security",
|
||||
"devops",
|
||||
"technical_writer",
|
||||
"product_owner",
|
||||
]);
|
||||
|
||||
const generatePromptBodySchema = z.object({
|
||||
contextPackId: z.string().optional(),
|
||||
targetTool: targetToolSchema.default("codex"),
|
||||
persona: personaSchema.default("developer"),
|
||||
objective: z.string().optional(),
|
||||
expectedOutput: z.string().optional(),
|
||||
});
|
||||
|
||||
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 sendProjectNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"PROJECT_NOT_FOUND",
|
||||
`Project '${projectId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
const sendPromptGenerationFailed = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
message: string,
|
||||
) =>
|
||||
reply.status(400).send(
|
||||
errorEnvelope("PROMPT_GENERATION_FAILED", message, getRequestId(request)),
|
||||
);
|
||||
|
||||
type PromptRoutesOptions = {
|
||||
dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"];
|
||||
};
|
||||
|
||||
export const registerPromptRoutes = async (
|
||||
app: FastifyInstance,
|
||||
options: PromptRoutesOptions = {},
|
||||
) => {
|
||||
const useDatabase = options.dataSource === "database";
|
||||
|
||||
app.post("/api/projects/:projectId/prompts/generate", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
|
||||
const projectExists = useDatabase
|
||||
? await databaseProjectExists(params.projectId)
|
||||
: Boolean(findProject(params.projectId));
|
||||
if (!projectExists) return sendProjectNotFound(request, reply, params.projectId);
|
||||
|
||||
const parsedBody = generatePromptBodySchema.safeParse(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return sendPromptGenerationFailed(
|
||||
request,
|
||||
reply,
|
||||
"Prompt generation request body is invalid.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsedBody.data.contextPackId?.trim()) {
|
||||
return reply.status(400).send(
|
||||
errorEnvelope(
|
||||
"ACTIVE_CONTEXT_PACK_REQUIRED",
|
||||
"contextPackId is required to generate a prompt.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const contextPack = useDatabase
|
||||
? await getDatabaseContextPackBySlugOrId(params.projectId, parsedBody.data.contextPackId)
|
||||
: findContextPack(params.projectId, parsedBody.data.contextPackId);
|
||||
|
||||
if (!contextPack) {
|
||||
return reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"CONTEXT_PACK_NOT_FOUND",
|
||||
`Context pack '${parsedBody.data.contextPackId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsedBody.data.objective?.trim()) {
|
||||
return sendPromptGenerationFailed(
|
||||
request,
|
||||
reply,
|
||||
"objective is required to generate a prompt.",
|
||||
);
|
||||
}
|
||||
|
||||
const prompt = useDatabase
|
||||
? await generateDatabasePrompt(params.projectId, {
|
||||
contextPackId: parsedBody.data.contextPackId,
|
||||
targetTool: parsedBody.data.targetTool,
|
||||
persona: parsedBody.data.persona,
|
||||
objective: parsedBody.data.objective,
|
||||
expectedOutput: parsedBody.data.expectedOutput,
|
||||
})
|
||||
: generatePrompt(params.projectId, {
|
||||
contextPackId: parsedBody.data.contextPackId,
|
||||
targetTool: parsedBody.data.targetTool,
|
||||
persona: parsedBody.data.persona,
|
||||
objective: parsedBody.data.objective,
|
||||
expectedOutput: parsedBody.data.expectedOutput,
|
||||
});
|
||||
|
||||
if (!prompt) {
|
||||
return sendPromptGenerationFailed(
|
||||
request,
|
||||
reply,
|
||||
"Prompt generation failed for the selected context pack.",
|
||||
);
|
||||
}
|
||||
|
||||
return reply.status(201).send(successEnvelope(prompt, getRequestId(request)));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,330 @@
|
||||
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 {
|
||||
applyRegressionFilters,
|
||||
convertRegressionToRule,
|
||||
createRegression,
|
||||
findRegression,
|
||||
listRegressionsForProject,
|
||||
} from "../fixtures/regressionFixtures.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import type {
|
||||
ConvertRegressionRuleSeverity,
|
||||
RegressionListFilters,
|
||||
RegressionSeverity,
|
||||
RegressionStatus,
|
||||
} from "../types/api.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import {
|
||||
getIdempotencyKey,
|
||||
getIdempotentResponse,
|
||||
setIdempotentResponse,
|
||||
} from "../utils/idempotency.js";
|
||||
|
||||
const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/);
|
||||
const projectParamsSchema = z.object({ projectId: idSchema });
|
||||
const regressionParamsSchema = z.object({
|
||||
projectId: idSchema,
|
||||
regressionId: 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 regressionQuerySchema = paginationQuerySchema.extend({
|
||||
status: z.string().optional(),
|
||||
featureId: z.string().optional(),
|
||||
severity: z.string().optional(),
|
||||
timesOccurredMin: z.coerce.number().int().min(0).optional(),
|
||||
timesOccurredMax: z.coerce.number().int().min(0).optional(),
|
||||
});
|
||||
const createRegressionBodySchema = z.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
affectedFeatureIds: z.array(z.string()).optional(),
|
||||
typicalCause: z.string().optional(),
|
||||
preventionRule: z.string().optional(),
|
||||
severity: z.enum(["low", "medium", "high", "critical"]).optional(),
|
||||
});
|
||||
const convertRegressionBodySchema = z.object({
|
||||
ruleTitle: z.string().optional(),
|
||||
severity: z.enum(["important", "critical", "do_not_break"]).optional(),
|
||||
});
|
||||
|
||||
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 sendProjectNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"PROJECT_NOT_FOUND",
|
||||
`Project '${projectId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
const ensureProject = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
) => {
|
||||
const project = findProject(projectId);
|
||||
if (!project) {
|
||||
sendProjectNotFound(request, reply, projectId);
|
||||
return undefined;
|
||||
}
|
||||
return project;
|
||||
};
|
||||
|
||||
const splitCsv = (value: string | undefined): string[] =>
|
||||
value?.split(",").map((item) => item.trim()).filter(Boolean) ?? [];
|
||||
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
type RegressionQuery = z.output<typeof regressionQuerySchema>;
|
||||
type PaginationQuery = z.output<typeof paginationQuerySchema>;
|
||||
|
||||
const toFilters = (query: RegressionQuery): RegressionListFilters => ({
|
||||
...(query.status ? { status: splitCsv(query.status) as RegressionStatus[] } : {}),
|
||||
...(query.featureId ? { featureId: query.featureId } : {}),
|
||||
...(query.severity
|
||||
? { severity: splitCsv(query.severity) as RegressionSeverity[] }
|
||||
: {}),
|
||||
...(query.timesOccurredMin !== undefined
|
||||
? { timesOccurredMin: query.timesOccurredMin }
|
||||
: {}),
|
||||
...(query.timesOccurredMax !== undefined
|
||||
? { timesOccurredMax: query.timesOccurredMax }
|
||||
: {}),
|
||||
...(query.cursor ? { cursor: query.cursor } : {}),
|
||||
});
|
||||
|
||||
const toPaginationInput = (
|
||||
query: PaginationQuery,
|
||||
filters: PaginationInput["filters"],
|
||||
): PaginationInput => ({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
cursor: query.cursor,
|
||||
sort: query.sort,
|
||||
filters,
|
||||
});
|
||||
|
||||
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 sendRegressionNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
regressionId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"REGRESSION_NOT_FOUND",
|
||||
`Regression '${regressionId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
export const registerRegressionRoutes = async (app: FastifyInstance) => {
|
||||
app.get("/api/projects/:projectId/regressions", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
const query = parseQuery(regressionQuerySchema, request, reply);
|
||||
if (!query) return reply;
|
||||
|
||||
const filters = toFilters(query);
|
||||
const regressions = sortByField(
|
||||
applyRegressionFilters(
|
||||
listRegressionsForProject(params.projectId),
|
||||
filters,
|
||||
),
|
||||
query.sort,
|
||||
);
|
||||
|
||||
return successEnvelope(
|
||||
paginate(regressions, toPaginationInput(query, filters)),
|
||||
getRequestId(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/api/projects/:projectId/regressions/:regressionId",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(regressionParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
const regression = findRegression(params.projectId, params.regressionId);
|
||||
if (!regression) {
|
||||
return sendRegressionNotFound(request, reply, params.regressionId);
|
||||
}
|
||||
return successEnvelope(regression, getRequestId(request));
|
||||
},
|
||||
);
|
||||
|
||||
app.post("/api/projects/:projectId/regressions", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
||||
if (!idempotencyKey) return reply;
|
||||
const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey);
|
||||
const replay = getIdempotentResponse<unknown>(cacheKey);
|
||||
if (replay) return reply.status(replay.statusCode).send(replay.body);
|
||||
|
||||
const parsedBody = createRegressionBodySchema.safeParse(request.body);
|
||||
if (!parsedBody.success || !parsedBody.data.title?.trim()) {
|
||||
return reply.status(400).send(
|
||||
errorEnvelope(
|
||||
"REGRESSION_VALIDATION_FAILED",
|
||||
"title is required to create a regression.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const body = successEnvelope(
|
||||
createRegression(params.projectId, {
|
||||
...parsedBody.data,
|
||||
title: parsedBody.data.title,
|
||||
}),
|
||||
getRequestId(request),
|
||||
);
|
||||
setIdempotentResponse(cacheKey, { statusCode: 201, body });
|
||||
return reply.status(201).send(body);
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/api/projects/:projectId/regressions/:regressionId/convert-to-rule",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(regressionParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
||||
if (!idempotencyKey) return reply;
|
||||
const cacheKey = getIdempotencyKey(
|
||||
request.method,
|
||||
request.url,
|
||||
idempotencyKey,
|
||||
);
|
||||
const replay = getIdempotentResponse<unknown>(cacheKey);
|
||||
if (replay) return reply.status(replay.statusCode).send(replay.body);
|
||||
|
||||
const parsedBody = convertRegressionBodySchema.safeParse(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.status(409).send(
|
||||
errorEnvelope(
|
||||
"CONVERSION_FAILED",
|
||||
"Regression conversion request body is invalid.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!findRegression(params.projectId, params.regressionId)) {
|
||||
return sendRegressionNotFound(request, reply, params.regressionId);
|
||||
}
|
||||
|
||||
const result = convertRegressionToRule(
|
||||
params.projectId,
|
||||
params.regressionId,
|
||||
parsedBody.data.ruleTitle,
|
||||
parsedBody.data.severity as ConvertRegressionRuleSeverity | undefined,
|
||||
);
|
||||
if (!result) {
|
||||
return reply.status(409).send(
|
||||
errorEnvelope(
|
||||
"CONVERSION_FAILED",
|
||||
`Conversion failed for regression '${params.regressionId}'.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const body = successEnvelope(result, getRequestId(request));
|
||||
setIdempotentResponse(cacheKey, { statusCode: 200, body });
|
||||
return reply.status(200).send(body);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,314 @@
|
||||
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 {
|
||||
applyRelationshipFilters,
|
||||
createRelationship,
|
||||
deleteRelationship,
|
||||
findRelationship,
|
||||
listRelationshipsForProject,
|
||||
} from "../fixtures/relationshipFixtures.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import type {
|
||||
RelationshipListFilters,
|
||||
RelationshipType,
|
||||
ReviewMutationResponseDto,
|
||||
} from "../types/api.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import {
|
||||
getIdempotencyKey,
|
||||
getIdempotentResponse,
|
||||
setIdempotentResponse,
|
||||
} from "../utils/idempotency.js";
|
||||
|
||||
const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-]+$/);
|
||||
const projectParamsSchema = z.object({ projectId: idSchema });
|
||||
const relationshipParamsSchema = z.object({
|
||||
projectId: idSchema,
|
||||
relationshipId: 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 relationshipTypes = [
|
||||
"depends_on",
|
||||
"conflicts_with",
|
||||
"duplicates",
|
||||
"supersedes",
|
||||
"belongs_to",
|
||||
"implements",
|
||||
"references",
|
||||
] as const;
|
||||
const relationshipQuerySchema = paginationQuerySchema.extend({
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
confidence: z.coerce.number().optional(),
|
||||
strength: z.coerce.number().int().min(1).max(5).optional(),
|
||||
});
|
||||
const createRelationshipBodySchema = z.object({
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
type: z.enum(relationshipTypes).optional(),
|
||||
rationale: z.string().optional(),
|
||||
strength: z.number().int().min(1).max(5).optional(),
|
||||
confidence: z.number().min(0).max(5).optional(),
|
||||
});
|
||||
|
||||
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 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 splitCsv = (value: string | undefined): string[] =>
|
||||
value?.split(",").map((item) => item.trim()).filter(Boolean) ?? [];
|
||||
|
||||
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 toPaginationInput = (
|
||||
query: z.output<typeof paginationQuerySchema>,
|
||||
filters: PaginationInput["filters"],
|
||||
): PaginationInput => ({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
cursor: query.cursor,
|
||||
sort: query.sort,
|
||||
filters,
|
||||
});
|
||||
|
||||
const toFilters = (
|
||||
query: z.output<typeof relationshipQuerySchema>,
|
||||
): RelationshipListFilters => ({
|
||||
...(query.from ? { from: query.from } : {}),
|
||||
...(query.to ? { to: query.to } : {}),
|
||||
...(query.type ? { type: splitCsv(query.type) as RelationshipType[] } : {}),
|
||||
...(query.confidence !== undefined ? { confidence: query.confidence } : {}),
|
||||
...(query.strength !== undefined ? { strength: query.strength } : {}),
|
||||
...(query.cursor ? { cursor: query.cursor } : {}),
|
||||
});
|
||||
|
||||
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 sendRelationshipNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
relationshipId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"RELATIONSHIP_NOT_FOUND",
|
||||
`Relationship '${relationshipId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
export const registerRelationshipRoutes = async (app: FastifyInstance) => {
|
||||
app.get("/api/projects/:projectId/relationships", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
const query = parseQuery(relationshipQuerySchema, request, reply);
|
||||
if (!query) return reply;
|
||||
|
||||
const filters = toFilters(query);
|
||||
const relationships = sortByField(
|
||||
applyRelationshipFilters(
|
||||
listRelationshipsForProject(params.projectId),
|
||||
filters,
|
||||
),
|
||||
query.sort,
|
||||
);
|
||||
|
||||
return successEnvelope(
|
||||
paginate(relationships, toPaginationInput(query, filters)),
|
||||
getRequestId(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/api/projects/:projectId/relationships/:relationshipId",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(relationshipParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
const relationship = findRelationship(params.projectId, params.relationshipId);
|
||||
if (!relationship) {
|
||||
return sendRelationshipNotFound(request, reply, params.relationshipId);
|
||||
}
|
||||
return successEnvelope(relationship, getRequestId(request));
|
||||
},
|
||||
);
|
||||
|
||||
app.post("/api/projects/:projectId/relationships", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
||||
if (!idempotencyKey) return reply;
|
||||
const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey);
|
||||
const replay = getIdempotentResponse<unknown>(cacheKey);
|
||||
if (replay) return reply.status(replay.statusCode).send(replay.body);
|
||||
|
||||
const parsedBody = createRelationshipBodySchema.safeParse(request.body);
|
||||
if (
|
||||
!parsedBody.success ||
|
||||
!parsedBody.data.from?.trim() ||
|
||||
!parsedBody.data.to?.trim() ||
|
||||
!parsedBody.data.type ||
|
||||
!parsedBody.data.rationale?.trim()
|
||||
) {
|
||||
return reply.status(400).send(
|
||||
errorEnvelope(
|
||||
"RELATIONSHIP_VALIDATION_FAILED",
|
||||
"from, to, type and rationale are required to create a relationship.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const body = successEnvelope(
|
||||
createRelationship(params.projectId, {
|
||||
...parsedBody.data,
|
||||
from: parsedBody.data.from,
|
||||
to: parsedBody.data.to,
|
||||
type: parsedBody.data.type,
|
||||
rationale: parsedBody.data.rationale,
|
||||
}),
|
||||
getRequestId(request),
|
||||
);
|
||||
setIdempotentResponse(cacheKey, { statusCode: 201, body });
|
||||
return reply.status(201).send(body);
|
||||
});
|
||||
|
||||
app.delete(
|
||||
"/api/projects/:projectId/relationships/:relationshipId",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(relationshipParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
||||
if (!idempotencyKey) return reply;
|
||||
const cacheKey = getIdempotencyKey(
|
||||
request.method,
|
||||
request.url,
|
||||
idempotencyKey,
|
||||
);
|
||||
const replay = getIdempotentResponse<unknown>(cacheKey);
|
||||
if (replay) return reply.status(replay.statusCode).send(replay.body);
|
||||
|
||||
const relationship = deleteRelationship(
|
||||
params.projectId,
|
||||
params.relationshipId,
|
||||
);
|
||||
if (!relationship) {
|
||||
return sendRelationshipNotFound(request, reply, params.relationshipId);
|
||||
}
|
||||
|
||||
const body = successEnvelope<ReviewMutationResponseDto>(
|
||||
{
|
||||
success: true,
|
||||
message: `Relationship '${params.relationshipId}' deleted.`,
|
||||
updatedEntityId: params.relationshipId,
|
||||
affectedEntities: [params.relationshipId],
|
||||
},
|
||||
getRequestId(request),
|
||||
);
|
||||
setIdempotentResponse(cacheKey, { statusCode: 200, body });
|
||||
return reply.status(200).send(body);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,315 @@
|
||||
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 {
|
||||
CreateReleaseEvidenceRecordRequestDto,
|
||||
EvidenceStrength,
|
||||
ReleaseEvidenceKind,
|
||||
ReleaseEvidenceListFilters,
|
||||
ReleaseEvidenceRecordDto,
|
||||
ReleaseEvidenceStatus,
|
||||
VerificationState,
|
||||
} from "../types/api.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import {
|
||||
getIdempotencyKey,
|
||||
getIdempotentResponse,
|
||||
setIdempotentResponse,
|
||||
} from "../utils/idempotency.js";
|
||||
import {
|
||||
createReleaseEvidenceRecordInDatabase,
|
||||
getReleaseEvidenceRecordFromDatabase,
|
||||
listReleaseEvidenceRecordsFromDatabase,
|
||||
} from "../repositories/releaseEvidenceRepository.js";
|
||||
|
||||
const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-_]+$/);
|
||||
const projectParamsSchema = z.object({ projectId: idSchema });
|
||||
const evidenceParamsSchema = z.object({ projectId: idSchema, releaseEvidenceId: idSchema });
|
||||
const releaseEvidenceKindSchema = z.enum([
|
||||
"validation",
|
||||
"tower_smoke",
|
||||
"browser_audit",
|
||||
"backup",
|
||||
"restore_dry_run",
|
||||
"deployment",
|
||||
"release_gate",
|
||||
"operator_note",
|
||||
]);
|
||||
const releaseEvidenceStatusSchema = z.enum(["captured", "verified", "rejected", "superseded"]);
|
||||
const evidenceStrengthSchema = z.enum(["weak", "medium", "strong", "verified"]);
|
||||
const verificationStateSchema = z.enum(["unverified", "needs evidence", "verified", "blocked"]);
|
||||
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 releaseEvidenceQuerySchema = paginationQuerySchema.extend({
|
||||
kind: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
evidenceStrength: z.string().optional(),
|
||||
verificationState: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
createdBy: z.string().optional(),
|
||||
});
|
||||
const releaseEvidenceBodySchema = z.object({
|
||||
kind: releaseEvidenceKindSchema,
|
||||
title: z.string().min(1),
|
||||
summary: z.string().min(1),
|
||||
status: releaseEvidenceStatusSchema.optional(),
|
||||
command: z.string().optional(),
|
||||
outputText: z.string().optional(),
|
||||
route: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
confidence: z.coerce.number().int().min(0).max(100).optional(),
|
||||
evidenceStrength: evidenceStrengthSchema.optional(),
|
||||
verificationState: verificationStateSchema.optional(),
|
||||
createdBy: z.string().optional(),
|
||||
verifiedBy: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
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 splitCsv = (value: string | undefined): string[] =>
|
||||
value?.split(",").map((item) => item.trim()).filter(Boolean) ?? [];
|
||||
|
||||
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,
|
||||
callback: (cacheKey: string) => Promise<void> | void,
|
||||
) => {
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
||||
if (!idempotencyKey) return;
|
||||
const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey);
|
||||
const replay = getIdempotentResponse<unknown>(cacheKey);
|
||||
if (replay) {
|
||||
reply.status(replay.statusCode).send(replay.body);
|
||||
return;
|
||||
}
|
||||
await callback(cacheKey);
|
||||
};
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
const fixtureReleaseEvidence: ReleaseEvidenceRecordDto[] = [
|
||||
{
|
||||
id: "rel-widefrog-runtime-readiness",
|
||||
projectId: "widefrog",
|
||||
capturedAt: nowIso(),
|
||||
kind: "release_gate",
|
||||
title: "Fixture release evidence checklist",
|
||||
summary: "Runtime Readiness has fixture-mode release evidence while database mode stores durable records.",
|
||||
status: "verified",
|
||||
route: "/projects/widefrog/runtime",
|
||||
source: "fixture",
|
||||
confidence: 80,
|
||||
approvalState: "approved",
|
||||
evidenceStrength: "medium",
|
||||
verificationState: "verified",
|
||||
createdBy: "fixture",
|
||||
verifiedAt: nowIso(),
|
||||
verifiedBy: "fixture",
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
},
|
||||
];
|
||||
|
||||
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 toPaginationInput = (
|
||||
query: z.output<typeof paginationQuerySchema>,
|
||||
filters: PaginationInput["filters"],
|
||||
): PaginationInput => ({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
cursor: query.cursor,
|
||||
sort: query.sort,
|
||||
filters,
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
const releaseEvidenceFilters = (
|
||||
query: z.output<typeof releaseEvidenceQuerySchema>,
|
||||
): ReleaseEvidenceListFilters => ({
|
||||
...(query.kind ? { kind: splitCsv(query.kind) as ReleaseEvidenceKind[] } : {}),
|
||||
...(query.status ? { status: splitCsv(query.status) as ReleaseEvidenceStatus[] } : {}),
|
||||
...(query.evidenceStrength ? { evidenceStrength: splitCsv(query.evidenceStrength) as EvidenceStrength[] } : {}),
|
||||
...(query.verificationState ? { verificationState: splitCsv(query.verificationState) as VerificationState[] } : {}),
|
||||
...(query.source ? { source: splitCsv(query.source) } : {}),
|
||||
...(query.createdBy ? { createdBy: splitCsv(query.createdBy) } : {}),
|
||||
...(query.cursor ? { cursor: query.cursor } : {}),
|
||||
});
|
||||
|
||||
const matchesFixtureFilters = (item: ReleaseEvidenceRecordDto, filters: ReleaseEvidenceListFilters) =>
|
||||
(!filters.kind?.length || filters.kind.includes(item.kind)) &&
|
||||
(!filters.status?.length || filters.status.includes(item.status)) &&
|
||||
(!filters.evidenceStrength?.length || filters.evidenceStrength.includes(item.evidenceStrength)) &&
|
||||
(!filters.verificationState?.length || filters.verificationState.includes(item.verificationState)) &&
|
||||
(!filters.source?.length || filters.source.includes(item.source)) &&
|
||||
(!filters.createdBy?.length || filters.createdBy.includes(item.createdBy));
|
||||
|
||||
const ensureFixtureProject = (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;
|
||||
};
|
||||
|
||||
type ReleaseEvidenceRoutesOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"] };
|
||||
|
||||
export const registerReleaseEvidenceRoutes = async (
|
||||
app: FastifyInstance,
|
||||
options: ReleaseEvidenceRoutesOptions = {},
|
||||
) => {
|
||||
const useDatabase = options.dataSource === "database";
|
||||
|
||||
app.get("/api/projects/:projectId/release-evidence", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
const query = parseQuery(releaseEvidenceQuerySchema, request, reply);
|
||||
if (!params || !query) return reply;
|
||||
const filters = releaseEvidenceFilters(query);
|
||||
if (useDatabase) {
|
||||
const result = await listReleaseEvidenceRecordsFromDatabase(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 successEnvelope(result, getRequestId(request));
|
||||
}
|
||||
const project = ensureFixtureProject(request, reply, params.projectId);
|
||||
if (!project) return reply;
|
||||
const items = fixtureReleaseEvidence
|
||||
.filter((item) => item.projectId === project.id)
|
||||
.filter((item) => matchesFixtureFilters(item, filters));
|
||||
return successEnvelope(fixturePaginate(items, query, filters), getRequestId(request));
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/release-evidence/:releaseEvidenceId", async (request, reply) => {
|
||||
const params = parseParams(evidenceParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (useDatabase) {
|
||||
const result = await getReleaseEvidenceRecordFromDatabase(params.projectId, params.releaseEvidenceId);
|
||||
if (!result) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request)));
|
||||
if (result === "RELEASE_EVIDENCE_NOT_FOUND") return reply.status(404).send(errorEnvelope("RELEASE_EVIDENCE_NOT_FOUND", `Release evidence '${params.releaseEvidenceId}' was not found.`, getRequestId(request)));
|
||||
return successEnvelope(result, getRequestId(request));
|
||||
}
|
||||
const project = ensureFixtureProject(request, reply, params.projectId);
|
||||
if (!project) return reply;
|
||||
const item = fixtureReleaseEvidence.find((entry) => entry.projectId === project.id && entry.id === params.releaseEvidenceId);
|
||||
if (!item) return reply.status(404).send(errorEnvelope("RELEASE_EVIDENCE_NOT_FOUND", `Release evidence '${params.releaseEvidenceId}' was not found.`, getRequestId(request)));
|
||||
return successEnvelope(item, getRequestId(request));
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/release-evidence", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
const parsedBody = releaseEvidenceBodySchema.safeParse(request.body);
|
||||
if (!parsedBody.success) return sendValidationError(request, reply, parsedBody.error.issues);
|
||||
await withMutation(request, reply, async (cacheKey) => {
|
||||
if (useDatabase) {
|
||||
const record = await createReleaseEvidenceRecordInDatabase(params.projectId, parsedBody.data as CreateReleaseEvidenceRecordRequestDto);
|
||||
if (!record) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request)));
|
||||
const body = successEnvelope(record, getRequestId(request));
|
||||
setIdempotentResponse(cacheKey, { statusCode: 201, body });
|
||||
return reply.status(201).send(body);
|
||||
}
|
||||
const project = ensureFixtureProject(request, reply, params.projectId);
|
||||
if (!project) return;
|
||||
const timestamp = nowIso();
|
||||
const record: ReleaseEvidenceRecordDto = {
|
||||
id: `rel-${project.id}-${Date.now()}`,
|
||||
projectId: project.id,
|
||||
capturedAt: timestamp,
|
||||
kind: parsedBody.data.kind,
|
||||
title: parsedBody.data.title,
|
||||
summary: parsedBody.data.summary,
|
||||
status: parsedBody.data.status ?? "captured",
|
||||
...(parsedBody.data.command ? { command: parsedBody.data.command } : {}),
|
||||
...(parsedBody.data.outputText ? { outputText: parsedBody.data.outputText } : {}),
|
||||
...(parsedBody.data.route ? { route: parsedBody.data.route } : {}),
|
||||
source: parsedBody.data.source ?? "fixture",
|
||||
confidence: parsedBody.data.confidence ?? 100,
|
||||
approvalState: "approved",
|
||||
evidenceStrength: parsedBody.data.evidenceStrength ?? "verified",
|
||||
verificationState: parsedBody.data.verificationState ?? "verified",
|
||||
createdBy: parsedBody.data.createdBy ?? "api",
|
||||
verifiedAt: timestamp,
|
||||
verifiedBy: parsedBody.data.verifiedBy ?? "api",
|
||||
...(parsedBody.data.metadata ? { metadata: parsedBody.data.metadata } : {}),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
fixtureReleaseEvidence.unshift(record);
|
||||
const body = successEnvelope(record, getRequestId(request));
|
||||
setIdempotentResponse(cacheKey, { statusCode: 201, body });
|
||||
return reply.status(201).send(body);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,605 @@
|
||||
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 {
|
||||
applyReviewAction,
|
||||
applyReviewQueueFilters,
|
||||
convertReviewItem,
|
||||
findReviewItem,
|
||||
listReviewItemsForProject,
|
||||
mergeReviewItem,
|
||||
updateReviewItem,
|
||||
} from "../fixtures/reviewQueueFixtures.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import type {
|
||||
EvidenceStrength,
|
||||
ReviewQueueConvertTarget,
|
||||
ReviewQueueItemType,
|
||||
ReviewQueueListFilters,
|
||||
ReviewQueuePriority,
|
||||
ReviewQueueStatus,
|
||||
VerificationState,
|
||||
} from "../types/api.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import {
|
||||
applyReviewActionInDatabase,
|
||||
convertReviewItemInDatabase,
|
||||
getReviewItemFromDatabase,
|
||||
listReviewItemsFromDatabase,
|
||||
mergeReviewItemInDatabase,
|
||||
updateReviewItemInDatabase,
|
||||
} from "../repositories/reviewQueueRepository.js";
|
||||
import { syncBlueprintReviewAction } from "../repositories/blueprintsRepository.js";
|
||||
import { syncBrainstormReviewAction } from "../repositories/brainstormRepository.js";
|
||||
import { syncInitiativeReviewAction } from "../repositories/initiativesRepository.js";
|
||||
import { findFeature } from "../fixtures/featureFixtures.js";
|
||||
import { findMemoryRule } from "../fixtures/memoryRuleFixtures.js";
|
||||
import { findKnowledge } from "../fixtures/knowledgeFixtures.js";
|
||||
import { findContextPack } from "../fixtures/contextPackFixtures.js";
|
||||
import {
|
||||
getIdempotencyKey,
|
||||
getDurableIdempotentResponse,
|
||||
setDurableIdempotentResponse,
|
||||
} from "../utils/idempotency.js";
|
||||
|
||||
const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-_]+$/);
|
||||
const projectParamsSchema = z.object({ projectId: idSchema });
|
||||
const reviewParamsSchema = z.object({ projectId: idSchema, reviewItemId: 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 reviewQuerySchema = paginationQuerySchema.extend({
|
||||
priority: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
confidence: z.coerce.number().optional(),
|
||||
evidenceStrength: z.string().optional(),
|
||||
verificationState: z.string().optional(),
|
||||
});
|
||||
const priorities = ["critical", "important", "normal", "low"] as const;
|
||||
const convertTargets = [
|
||||
"feature",
|
||||
"bug",
|
||||
"task",
|
||||
"memory_rule",
|
||||
"decision",
|
||||
"knowledge",
|
||||
"assumption",
|
||||
"open_question",
|
||||
"risk",
|
||||
"documentation_update",
|
||||
] as const;
|
||||
const updateBodySchema = z.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
priority: z.enum(priorities).optional(),
|
||||
notes: z.string().optional(),
|
||||
}).strict();
|
||||
const actionBodySchema = z.object({
|
||||
notes: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
targetEntityId: z.string().optional(),
|
||||
target: z.enum(convertTargets).optional(),
|
||||
});
|
||||
|
||||
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 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 splitCsv = (value: string | undefined): string[] =>
|
||||
value?.split(",").map((item) => item.trim()).filter(Boolean) ?? [];
|
||||
|
||||
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 toPaginationInput = (
|
||||
query: z.output<typeof paginationQuerySchema>,
|
||||
filters: PaginationInput["filters"],
|
||||
): PaginationInput => ({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
cursor: query.cursor,
|
||||
sort: query.sort,
|
||||
filters,
|
||||
});
|
||||
|
||||
const toFilters = (
|
||||
query: z.output<typeof reviewQuerySchema>,
|
||||
): ReviewQueueListFilters => ({
|
||||
...(query.priority
|
||||
? { priority: splitCsv(query.priority) as ReviewQueuePriority[] }
|
||||
: {}),
|
||||
...(query.type ? { type: splitCsv(query.type) as ReviewQueueItemType[] } : {}),
|
||||
...(query.status
|
||||
? { status: splitCsv(query.status) as ReviewQueueStatus[] }
|
||||
: {}),
|
||||
...(query.source ? { source: splitCsv(query.source) } : {}),
|
||||
...(query.confidence !== undefined ? { confidence: query.confidence } : {}),
|
||||
...(query.evidenceStrength
|
||||
? { evidenceStrength: splitCsv(query.evidenceStrength) as EvidenceStrength[] }
|
||||
: {}),
|
||||
...(query.verificationState
|
||||
? { verificationState: splitCsv(query.verificationState) as VerificationState[] }
|
||||
: {}),
|
||||
...(query.cursor ? { cursor: query.cursor } : {}),
|
||||
});
|
||||
|
||||
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 sendReviewNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
reviewItemId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"REVIEW_ITEM_NOT_FOUND",
|
||||
`Review item '${reviewItemId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
const sendAlreadyDecided = (request: FastifyRequest, reply: FastifyReply) =>
|
||||
reply.status(409).send(
|
||||
errorEnvelope(
|
||||
"REVIEW_ALREADY_DECIDED",
|
||||
"This review item already has a final decision and can no longer be changed.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
type ReviewQueueRoutesOptions = {
|
||||
dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"];
|
||||
};
|
||||
|
||||
export const registerReviewQueueRoutes = async (
|
||||
app: FastifyInstance,
|
||||
options: ReviewQueueRoutesOptions = {},
|
||||
) => {
|
||||
const useDatabase = options.dataSource === "database";
|
||||
app.get("/api/projects/:projectId/review-queue", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply;
|
||||
const query = parseQuery(reviewQuerySchema, request, reply);
|
||||
if (!query) return reply;
|
||||
|
||||
const filters = toFilters(query);
|
||||
|
||||
if (useDatabase) {
|
||||
const result = await listReviewItemsFromDatabase(
|
||||
params.projectId,
|
||||
filters,
|
||||
{
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
cursor: query.cursor,
|
||||
sort: query.sort,
|
||||
},
|
||||
);
|
||||
if (!result) {
|
||||
return reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"PROJECT_NOT_FOUND",
|
||||
`Project '${params.projectId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
return successEnvelope(result, getRequestId(request));
|
||||
}
|
||||
|
||||
const items = sortByField(
|
||||
applyReviewQueueFilters(listReviewItemsForProject(params.projectId), filters),
|
||||
query.sort,
|
||||
);
|
||||
|
||||
return successEnvelope(
|
||||
paginate(items, toPaginationInput(query, filters)),
|
||||
getRequestId(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/api/projects/:projectId/review-queue/:reviewItemId",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(reviewParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply;
|
||||
const item = useDatabase
|
||||
? await getReviewItemFromDatabase(params.projectId, params.reviewItemId)
|
||||
: findReviewItem(params.projectId, params.reviewItemId);
|
||||
if (!item) return sendReviewNotFound(request, reply, params.reviewItemId);
|
||||
return successEnvelope(item, getRequestId(request));
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/api/projects/:projectId/review-queue/:reviewItemId",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(reviewParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply;
|
||||
await withMutation(request, reply, useDatabase, async (cacheKey) => {
|
||||
const parsedBody = updateBodySchema.safeParse(request.body);
|
||||
if (
|
||||
!parsedBody.success ||
|
||||
(parsedBody.data.title !== undefined && !parsedBody.data.title.trim()) ||
|
||||
(parsedBody.data.description !== undefined && !parsedBody.data.description.trim())
|
||||
) {
|
||||
reply.status(400).send(
|
||||
errorEnvelope(
|
||||
"VALIDATION_ERROR",
|
||||
"Review queue update body is invalid.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const item = useDatabase
|
||||
? await updateReviewItemInDatabase(
|
||||
params.projectId,
|
||||
params.reviewItemId,
|
||||
parsedBody.data,
|
||||
)
|
||||
: updateReviewItem(
|
||||
params.projectId,
|
||||
params.reviewItemId,
|
||||
parsedBody.data,
|
||||
);
|
||||
if (!item) {
|
||||
sendReviewNotFound(request, reply, params.reviewItemId);
|
||||
return;
|
||||
}
|
||||
if (item === "REVIEW_ALREADY_DECIDED") {
|
||||
sendAlreadyDecided(request, reply);
|
||||
return;
|
||||
}
|
||||
const body = successEnvelope(item, getRequestId(request));
|
||||
await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase);
|
||||
reply.status(200).send(body);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/projects/:projectId/review-queue/:reviewItemId/accept",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(reviewParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply;
|
||||
await withMutation(request, reply, useDatabase, async (cacheKey) => {
|
||||
const parsedBody = actionBodySchema.safeParse(request.body);
|
||||
if (!parsedBody.success) {
|
||||
sendValidationError(request, reply, parsedBody.error.issues);
|
||||
return;
|
||||
}
|
||||
const result = useDatabase
|
||||
? await applyReviewActionInDatabase(
|
||||
params.projectId,
|
||||
params.reviewItemId,
|
||||
"accept",
|
||||
parsedBody.data.notes ?? parsedBody.data.reason,
|
||||
)
|
||||
: applyReviewAction(
|
||||
params.projectId,
|
||||
params.reviewItemId,
|
||||
"accept",
|
||||
parsedBody.data.notes ?? parsedBody.data.reason,
|
||||
);
|
||||
if (!result) {
|
||||
sendReviewNotFound(request, reply, params.reviewItemId);
|
||||
return;
|
||||
}
|
||||
if (result === "REVIEW_ALREADY_DECIDED") {
|
||||
sendAlreadyDecided(request, reply);
|
||||
return;
|
||||
}
|
||||
const blueprintSync = useDatabase
|
||||
? await syncBlueprintReviewAction(params.projectId, params.reviewItemId, "accept")
|
||||
: undefined;
|
||||
const brainstormSync = useDatabase
|
||||
? await syncBrainstormReviewAction(params.projectId, params.reviewItemId, "accept")
|
||||
: undefined;
|
||||
const initiativeSync = useDatabase
|
||||
? await syncInitiativeReviewAction(params.projectId, params.reviewItemId, "accept")
|
||||
: undefined;
|
||||
const body = successEnvelope({ ...result, ...(blueprintSync ? { blueprintSync } : {}), ...(brainstormSync ? { brainstormSync } : {}), ...(initiativeSync ? { initiativeSync } : {}) }, getRequestId(request));
|
||||
await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase);
|
||||
reply.status(200).send(body);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/projects/:projectId/review-queue/:reviewItemId/reject",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(reviewParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply;
|
||||
await withMutation(request, reply, useDatabase, async (cacheKey) => {
|
||||
const parsedBody = actionBodySchema.safeParse(request.body);
|
||||
if (!parsedBody.success) {
|
||||
sendValidationError(request, reply, parsedBody.error.issues);
|
||||
return;
|
||||
}
|
||||
const result = useDatabase
|
||||
? await applyReviewActionInDatabase(
|
||||
params.projectId,
|
||||
params.reviewItemId,
|
||||
"reject",
|
||||
parsedBody.data.notes ?? parsedBody.data.reason,
|
||||
)
|
||||
: applyReviewAction(
|
||||
params.projectId,
|
||||
params.reviewItemId,
|
||||
"reject",
|
||||
parsedBody.data.notes ?? parsedBody.data.reason,
|
||||
);
|
||||
if (!result) {
|
||||
sendReviewNotFound(request, reply, params.reviewItemId);
|
||||
return;
|
||||
}
|
||||
if (result === "REVIEW_ALREADY_DECIDED") {
|
||||
sendAlreadyDecided(request, reply);
|
||||
return;
|
||||
}
|
||||
const blueprintSync = useDatabase
|
||||
? await syncBlueprintReviewAction(params.projectId, params.reviewItemId, "reject")
|
||||
: undefined;
|
||||
const brainstormSync = useDatabase
|
||||
? await syncBrainstormReviewAction(params.projectId, params.reviewItemId, "reject")
|
||||
: undefined;
|
||||
const initiativeSync = useDatabase
|
||||
? await syncInitiativeReviewAction(params.projectId, params.reviewItemId, "reject")
|
||||
: undefined;
|
||||
const body = successEnvelope({ ...result, ...(blueprintSync ? { blueprintSync } : {}), ...(brainstormSync ? { brainstormSync } : {}), ...(initiativeSync ? { initiativeSync } : {}) }, getRequestId(request));
|
||||
await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase);
|
||||
reply.status(200).send(body);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/projects/:projectId/review-queue/:reviewItemId/merge",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(reviewParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply;
|
||||
await withMutation(request, reply, useDatabase, async (cacheKey) => {
|
||||
const parsedBody = actionBodySchema.safeParse(request.body);
|
||||
if (!parsedBody.success) {
|
||||
sendValidationError(request, reply, parsedBody.error.issues);
|
||||
return;
|
||||
}
|
||||
if (!parsedBody.data.targetEntityId?.trim()) {
|
||||
reply.status(409).send(
|
||||
errorEnvelope(
|
||||
"REVIEW_ACTION_CONFLICT",
|
||||
"targetEntityId is required when merging a review item.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!useDatabase) {
|
||||
const targetId = parsedBody.data.targetEntityId;
|
||||
const targetExists = Boolean(
|
||||
findFeature(params.projectId, targetId) ||
|
||||
findMemoryRule(params.projectId, targetId) ||
|
||||
findKnowledge(params.projectId, targetId) ||
|
||||
findContextPack(params.projectId, targetId) ||
|
||||
findReviewItem(params.projectId, targetId),
|
||||
);
|
||||
if (!targetExists || targetId === params.reviewItemId) {
|
||||
reply.status(409).send(errorEnvelope(
|
||||
"REVIEW_TARGET_NOT_FOUND",
|
||||
"The merge target must identify an existing entity in this project.",
|
||||
getRequestId(request),
|
||||
));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const result = useDatabase
|
||||
? await mergeReviewItemInDatabase(
|
||||
params.projectId,
|
||||
params.reviewItemId,
|
||||
parsedBody.data.targetEntityId,
|
||||
parsedBody.data.notes ?? parsedBody.data.reason,
|
||||
)
|
||||
: mergeReviewItem(
|
||||
params.projectId,
|
||||
params.reviewItemId,
|
||||
parsedBody.data.targetEntityId,
|
||||
parsedBody.data.notes ?? parsedBody.data.reason,
|
||||
);
|
||||
if (!result) {
|
||||
sendReviewNotFound(request, reply, params.reviewItemId);
|
||||
return;
|
||||
}
|
||||
if (result === "REVIEW_ALREADY_DECIDED") {
|
||||
sendAlreadyDecided(request, reply);
|
||||
return;
|
||||
}
|
||||
if (result === "REVIEW_TARGET_NOT_FOUND") {
|
||||
reply.status(409).send(errorEnvelope(
|
||||
"REVIEW_TARGET_NOT_FOUND",
|
||||
"The merge target must identify an existing entity in this project.",
|
||||
getRequestId(request),
|
||||
));
|
||||
return;
|
||||
}
|
||||
const body = successEnvelope(result, getRequestId(request));
|
||||
await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase);
|
||||
reply.status(200).send(body);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/projects/:projectId/review-queue/:reviewItemId/convert",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(reviewParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply;
|
||||
await withMutation(request, reply, useDatabase, async (cacheKey) => {
|
||||
const parsedBody = actionBodySchema.safeParse(request.body);
|
||||
if (!parsedBody.success || !parsedBody.data.target) {
|
||||
reply.status(400).send(
|
||||
errorEnvelope(
|
||||
"REVIEW_ACTION_INVALID",
|
||||
"target is required and must be a supported conversion target.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!useDatabase) {
|
||||
reply.status(501).send(errorEnvelope(
|
||||
"DATABASE_MODE_REQUIRED",
|
||||
"Review conversion is unavailable in fixture mode because no durable artifact can be created.",
|
||||
getRequestId(request),
|
||||
));
|
||||
return;
|
||||
}
|
||||
const result = useDatabase
|
||||
? await convertReviewItemInDatabase(
|
||||
params.projectId,
|
||||
params.reviewItemId,
|
||||
parsedBody.data.target as ReviewQueueConvertTarget,
|
||||
parsedBody.data.notes ?? parsedBody.data.reason,
|
||||
)
|
||||
: convertReviewItem(
|
||||
params.projectId,
|
||||
params.reviewItemId,
|
||||
parsedBody.data.target as ReviewQueueConvertTarget,
|
||||
parsedBody.data.notes ?? parsedBody.data.reason,
|
||||
);
|
||||
if (!result) {
|
||||
sendReviewNotFound(request, reply, params.reviewItemId);
|
||||
return;
|
||||
}
|
||||
if (result === "REVIEW_ALREADY_DECIDED") {
|
||||
sendAlreadyDecided(request, reply);
|
||||
return;
|
||||
}
|
||||
const body = successEnvelope(result, getRequestId(request));
|
||||
await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase);
|
||||
reply.status(200).send(body);
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,400 @@
|
||||
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 {
|
||||
AcceptedTruthItemDto,
|
||||
AcceptedTruthKind,
|
||||
AcceptedTruthListFilters,
|
||||
AcceptedTruthStatus,
|
||||
EvidenceStrength,
|
||||
CreateOperatingLogEntryRequestDto,
|
||||
OperatingLogEntryDto,
|
||||
OperatingLogEntryType,
|
||||
OperatingLogListFilters,
|
||||
ProjectUpdateDto,
|
||||
ProjectUpdateListFilters,
|
||||
ProjectUpdateStatus,
|
||||
ProjectUpdateTargetArea,
|
||||
} from "../types/api.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import {
|
||||
createOperatingLogEntryInDatabase,
|
||||
createProjectUpdateFromTruthInDatabase,
|
||||
applyProjectUpdateInDatabase,
|
||||
listAcceptedTruthFromDatabase,
|
||||
listOperatingLogEntriesFromDatabase,
|
||||
listProjectUpdatesFromDatabase,
|
||||
materializeReviewItemAsTruthInDatabase,
|
||||
} from "../repositories/runtimeRepository.js";
|
||||
import {
|
||||
getIdempotencyKey,
|
||||
getDurableIdempotentResponse,
|
||||
setDurableIdempotentResponse,
|
||||
} from "../utils/idempotency.js";
|
||||
|
||||
const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-_]+$/);
|
||||
const projectParamsSchema = z.object({ projectId: idSchema });
|
||||
const reviewParamsSchema = z.object({ projectId: idSchema, reviewItemId: idSchema });
|
||||
const truthParamsSchema = z.object({ projectId: idSchema, truthItemId: idSchema });
|
||||
const updateParamsSchema = z.object({ projectId: idSchema, projectUpdateId: 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 truthQuerySchema = paginationQuerySchema.extend({
|
||||
kind: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
confidence: z.coerce.number().optional(),
|
||||
evidenceStrength: z.string().optional(),
|
||||
sourceReviewItemId: z.string().optional(),
|
||||
});
|
||||
const updateQuerySchema = paginationQuerySchema.extend({
|
||||
status: z.string().optional(),
|
||||
targetArea: z.string().optional(),
|
||||
kind: z.string().optional(),
|
||||
sourceTruthItemId: z.string().optional(),
|
||||
});
|
||||
const logQuerySchema = paginationQuerySchema.extend({
|
||||
type: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
importance: z.string().optional(),
|
||||
linkedEntityId: z.string().optional(),
|
||||
});
|
||||
const operatingLogBodySchema = z.object({
|
||||
type: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
summary: z.string().min(1),
|
||||
linkedEntityIds: z.array(z.string()).optional(),
|
||||
source: z.string().optional(),
|
||||
importance: z.enum(["critical", "important", "normal", "low"]).optional(),
|
||||
});
|
||||
|
||||
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 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 splitCsv = (value: string | undefined): string[] =>
|
||||
value?.split(",").map((item) => item.trim()).filter(Boolean) ?? [];
|
||||
|
||||
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 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 nowIso = () => new Date().toISOString();
|
||||
const fixtureTruth: AcceptedTruthItemDto[] = [];
|
||||
const fixtureUpdates: ProjectUpdateDto[] = [];
|
||||
const fixtureLogs: OperatingLogEntryDto[] = [];
|
||||
|
||||
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));
|
||||
|
||||
const truthFilters = (query: z.output<typeof truthQuerySchema>): AcceptedTruthListFilters => ({
|
||||
...(query.kind ? { kind: splitCsv(query.kind) as AcceptedTruthKind[] } : {}),
|
||||
...(query.status ? { status: splitCsv(query.status) as AcceptedTruthStatus[] } : {}),
|
||||
...(query.confidence !== undefined ? { confidence: query.confidence } : {}),
|
||||
...(query.evidenceStrength ? { evidenceStrength: splitCsv(query.evidenceStrength) as EvidenceStrength[] } : {}),
|
||||
...(query.sourceReviewItemId ? { sourceReviewItemId: query.sourceReviewItemId } : {}),
|
||||
...(query.cursor ? { cursor: query.cursor } : {}),
|
||||
});
|
||||
|
||||
const updateFilters = (query: z.output<typeof updateQuerySchema>): ProjectUpdateListFilters => ({
|
||||
...(query.status ? { status: splitCsv(query.status) as ProjectUpdateStatus[] } : {}),
|
||||
...(query.targetArea ? { targetArea: splitCsv(query.targetArea) as ProjectUpdateTargetArea[] } : {}),
|
||||
...(query.kind ? { kind: splitCsv(query.kind) as AcceptedTruthKind[] } : {}),
|
||||
...(query.sourceTruthItemId ? { sourceTruthItemId: query.sourceTruthItemId } : {}),
|
||||
...(query.cursor ? { cursor: query.cursor } : {}),
|
||||
});
|
||||
|
||||
const logFilters = (query: z.output<typeof logQuerySchema>): OperatingLogListFilters => ({
|
||||
...(query.type ? { type: splitCsv(query.type) as OperatingLogEntryType[] } : {}),
|
||||
...(query.source ? { source: splitCsv(query.source) } : {}),
|
||||
...(query.importance ? { importance: splitCsv(query.importance) as OperatingLogListFilters["importance"] } : {}),
|
||||
...(query.linkedEntityId ? { linkedEntityId: query.linkedEntityId } : {}),
|
||||
...(query.cursor ? { cursor: query.cursor } : {}),
|
||||
});
|
||||
|
||||
type RuntimePersistenceRoutesOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"] };
|
||||
|
||||
export const registerRuntimePersistenceRoutes = async (
|
||||
app: FastifyInstance,
|
||||
options: RuntimePersistenceRoutesOptions = {},
|
||||
) => {
|
||||
const useDatabase = options.dataSource === "database";
|
||||
|
||||
app.get("/api/projects/:projectId/accepted-truth", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply;
|
||||
const query = parseQuery(truthQuerySchema, request, reply);
|
||||
if (!query) return reply;
|
||||
const filters = truthFilters(query);
|
||||
if (useDatabase) {
|
||||
const result = await listAcceptedTruthFromDatabase(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 successEnvelope(result, getRequestId(request));
|
||||
}
|
||||
const items = fixtureTruth.filter((item) => item.projectId === params.projectId);
|
||||
return successEnvelope(fixturePaginate(items, query, filters), getRequestId(request));
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/review-queue/:reviewItemId/materialize-truth", async (request, reply) => {
|
||||
const params = parseParams(reviewParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply;
|
||||
await withMutation(request, reply, useDatabase, async (cacheKey) => {
|
||||
if (useDatabase) {
|
||||
const result = await materializeReviewItemAsTruthInDatabase(params.projectId, params.reviewItemId);
|
||||
if (!result) return reply.status(404).send(errorEnvelope("REVIEW_ITEM_NOT_FOUND", `Review item '${params.reviewItemId}' was not found.`, getRequestId(request)));
|
||||
const body = successEnvelope(result, getRequestId(request));
|
||||
await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase);
|
||||
return reply.status(200).send(body);
|
||||
}
|
||||
const id = `truth-${params.projectId}-${Date.now()}`;
|
||||
const truth: AcceptedTruthItemDto = {
|
||||
id,
|
||||
projectId: params.projectId,
|
||||
sourceReviewItemId: params.reviewItemId,
|
||||
kind: "knowledge",
|
||||
title: `Accepted truth from ${params.reviewItemId}`,
|
||||
summary: "Fixture-mode accepted truth materialized from review queue.",
|
||||
status: "accepted",
|
||||
source: "review_queue",
|
||||
confidence: 72,
|
||||
evidenceStrength: "medium",
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
fixtureTruth.unshift(truth);
|
||||
const body = successEnvelope({ success: true, message: "Accepted truth materialized.", updatedEntityId: params.reviewItemId, createdEntityId: id, affectedEntities: [params.reviewItemId, id] }, getRequestId(request));
|
||||
await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase);
|
||||
return reply.status(200).send(body);
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/project-updates", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply;
|
||||
const query = parseQuery(updateQuerySchema, request, reply);
|
||||
if (!query) return reply;
|
||||
const filters = updateFilters(query);
|
||||
if (useDatabase) {
|
||||
const result = await listProjectUpdatesFromDatabase(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 successEnvelope(result, getRequestId(request));
|
||||
}
|
||||
const items = fixtureUpdates.filter((item) => item.projectId === params.projectId);
|
||||
return successEnvelope(fixturePaginate(items, query, filters), getRequestId(request));
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/accepted-truth/:truthItemId/project-updates", async (request, reply) => {
|
||||
const params = parseParams(truthParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply;
|
||||
await withMutation(request, reply, useDatabase, async (cacheKey) => {
|
||||
if (useDatabase) {
|
||||
const result = await createProjectUpdateFromTruthInDatabase(params.projectId, params.truthItemId);
|
||||
if (!result) return reply.status(404).send(errorEnvelope("ACCEPTED_TRUTH_NOT_FOUND", `Accepted truth '${params.truthItemId}' was not found.`, getRequestId(request)));
|
||||
const body = successEnvelope(result, getRequestId(request));
|
||||
await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase);
|
||||
return reply.status(200).send(body);
|
||||
}
|
||||
const truth = fixtureTruth.find((item) => item.id === params.truthItemId && item.projectId === params.projectId);
|
||||
if (!truth) return reply.status(404).send(errorEnvelope("ACCEPTED_TRUTH_NOT_FOUND", `Accepted truth '${params.truthItemId}' was not found.`, getRequestId(request)));
|
||||
const id = `update-${params.projectId}-${Date.now()}`;
|
||||
const update: ProjectUpdateDto = {
|
||||
id,
|
||||
projectId: params.projectId,
|
||||
sourceTruthItemId: truth.id,
|
||||
kind: truth.kind,
|
||||
title: truth.title,
|
||||
summary: truth.summary,
|
||||
targetArea: truth.kind === "feature" ? "feature-map" : "knowledge",
|
||||
status: "ready",
|
||||
confidence: truth.confidence,
|
||||
evidenceStrength: truth.evidenceStrength,
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
fixtureUpdates.unshift(update);
|
||||
const body = successEnvelope({ success: true, message: "Project update prepared.", updatedEntityId: truth.id, createdEntityId: id, affectedEntities: [truth.id, id] }, getRequestId(request));
|
||||
await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase);
|
||||
return reply.status(200).send(body);
|
||||
});
|
||||
});
|
||||
|
||||
app.patch("/api/projects/:projectId/project-updates/:projectUpdateId/apply", async (request, reply) => {
|
||||
const params = parseParams(updateParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply;
|
||||
await withMutation(request, reply, useDatabase, async (cacheKey) => {
|
||||
if (useDatabase) {
|
||||
const result = await applyProjectUpdateInDatabase(params.projectId, params.projectUpdateId);
|
||||
if (!result) return reply.status(404).send(errorEnvelope("PROJECT_UPDATE_NOT_FOUND", `Project update '${params.projectUpdateId}' was not found.`, getRequestId(request)));
|
||||
if (result === "PROJECT_UPDATE_ALREADY_APPLIED") return reply.status(409).send(errorEnvelope("PROJECT_UPDATE_ALREADY_APPLIED", "This project update was already applied.", getRequestId(request)));
|
||||
const body = successEnvelope(result, getRequestId(request));
|
||||
await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase);
|
||||
return reply.status(200).send(body);
|
||||
}
|
||||
const update = fixtureUpdates.find((item) => item.id === params.projectUpdateId && item.projectId === params.projectId);
|
||||
if (!update) return reply.status(404).send(errorEnvelope("PROJECT_UPDATE_NOT_FOUND", `Project update '${params.projectUpdateId}' was not found.`, getRequestId(request)));
|
||||
update.status = "applied";
|
||||
update.appliedAt = nowIso();
|
||||
update.updatedAt = nowIso();
|
||||
const body = successEnvelope({ success: true, message: "Project update applied.", updatedEntityId: update.id, affectedEntities: [update.id, update.sourceTruthItemId] }, getRequestId(request));
|
||||
await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase);
|
||||
return reply.status(200).send(body);
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/operating-log", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply;
|
||||
const query = parseQuery(logQuerySchema, request, reply);
|
||||
if (!query) return reply;
|
||||
const filters = logFilters(query);
|
||||
if (useDatabase) {
|
||||
const result = await listOperatingLogEntriesFromDatabase(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 successEnvelope(result, getRequestId(request));
|
||||
}
|
||||
const items = fixtureLogs.filter((item) => item.projectId === params.projectId);
|
||||
return successEnvelope(fixturePaginate(items, query, filters), getRequestId(request));
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/operating-log", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply;
|
||||
const parsedBody = operatingLogBodySchema.safeParse(request.body);
|
||||
if (!parsedBody.success) return sendValidationError(request, reply, parsedBody.error.issues);
|
||||
await withMutation(request, reply, useDatabase, async (cacheKey) => {
|
||||
if (useDatabase) {
|
||||
const entry = await createOperatingLogEntryInDatabase(params.projectId, parsedBody.data as CreateOperatingLogEntryRequestDto);
|
||||
if (!entry) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request)));
|
||||
const body = successEnvelope(entry, getRequestId(request));
|
||||
await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body }, useDatabase);
|
||||
return reply.status(201).send(body);
|
||||
}
|
||||
const entry: OperatingLogEntryDto = {
|
||||
id: `log-${params.projectId}-${Date.now()}`,
|
||||
projectId: params.projectId,
|
||||
timestamp: nowIso(),
|
||||
type: parsedBody.data.type as OperatingLogEntryType,
|
||||
title: parsedBody.data.title,
|
||||
summary: parsedBody.data.summary,
|
||||
linkedEntityIds: parsedBody.data.linkedEntityIds ?? [],
|
||||
source: parsedBody.data.source ?? "manual",
|
||||
importance: parsedBody.data.importance ?? "normal",
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
fixtureLogs.unshift(entry);
|
||||
const body = successEnvelope(entry, getRequestId(request));
|
||||
await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body }, useDatabase);
|
||||
return reply.status(201).send(body);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,301 @@
|
||||
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 {
|
||||
applySnapshotFilters,
|
||||
compareSnapshots,
|
||||
createSnapshot,
|
||||
findSnapshot,
|
||||
listSnapshotsForProject,
|
||||
} from "../fixtures/snapshotFixtures.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import type { SnapshotListFilters, SnapshotSourceRun } from "../types/api.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import {
|
||||
getIdempotencyKey,
|
||||
getIdempotentResponse,
|
||||
setIdempotentResponse,
|
||||
} from "../utils/idempotency.js";
|
||||
|
||||
const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/);
|
||||
const projectParamsSchema = z.object({ projectId: idSchema });
|
||||
const snapshotParamsSchema = z.object({
|
||||
projectId: idSchema,
|
||||
snapshotId: 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 snapshotQuerySchema = paginationQuerySchema.extend({
|
||||
sourceRun: z.string().optional(),
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
containsRisk: z.string().optional(),
|
||||
});
|
||||
const createSnapshotBodySchema = z.object({
|
||||
sourceRun: z
|
||||
.enum(["manual", "import-analysis", "check-run", "build"])
|
||||
.optional(),
|
||||
summary: z.string().optional(),
|
||||
keyRisks: z.array(z.string()).optional(),
|
||||
});
|
||||
const compareBodySchema = z.object({
|
||||
baseSnapshotId: z.string().optional(),
|
||||
targetSnapshotId: z.string().optional(),
|
||||
});
|
||||
|
||||
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 sendProjectNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"PROJECT_NOT_FOUND",
|
||||
`Project '${projectId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
const ensureProject = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
) => {
|
||||
const project = findProject(projectId);
|
||||
if (!project) {
|
||||
sendProjectNotFound(request, reply, projectId);
|
||||
return undefined;
|
||||
}
|
||||
return project;
|
||||
};
|
||||
|
||||
const splitCsv = (value: string | undefined): string[] =>
|
||||
value?.split(",").map((item) => item.trim()).filter(Boolean) ?? [];
|
||||
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
type SnapshotQuery = z.output<typeof snapshotQuerySchema>;
|
||||
type PaginationQuery = z.output<typeof paginationQuerySchema>;
|
||||
|
||||
const toFilters = (query: SnapshotQuery): SnapshotListFilters => ({
|
||||
...(query.sourceRun
|
||||
? { sourceRun: splitCsv(query.sourceRun) as SnapshotSourceRun[] }
|
||||
: {}),
|
||||
...(query.from ? { from: query.from } : {}),
|
||||
...(query.to ? { to: query.to } : {}),
|
||||
...(query.containsRisk ? { containsRisk: query.containsRisk } : {}),
|
||||
...(query.cursor ? { cursor: query.cursor } : {}),
|
||||
});
|
||||
|
||||
const toPaginationInput = (
|
||||
query: PaginationQuery,
|
||||
filters: PaginationInput["filters"],
|
||||
): PaginationInput => ({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
cursor: query.cursor,
|
||||
sort: query.sort,
|
||||
filters,
|
||||
});
|
||||
|
||||
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 sendSnapshotNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
snapshotId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"SNAPSHOT_NOT_FOUND",
|
||||
`Snapshot '${snapshotId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
export const registerSnapshotRoutes = async (app: FastifyInstance) => {
|
||||
app.get("/api/projects/:projectId/snapshots", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
const query = parseQuery(snapshotQuerySchema, request, reply);
|
||||
if (!query) return reply;
|
||||
|
||||
const filters = toFilters(query);
|
||||
const snapshots = sortByField(
|
||||
applySnapshotFilters(listSnapshotsForProject(params.projectId), filters),
|
||||
query.sort,
|
||||
);
|
||||
return successEnvelope(
|
||||
paginate(snapshots, toPaginationInput(query, filters)),
|
||||
getRequestId(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/snapshots", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
||||
if (!idempotencyKey) return reply;
|
||||
const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey);
|
||||
const replay = getIdempotentResponse<unknown>(cacheKey);
|
||||
if (replay) return reply.status(replay.statusCode).send(replay.body);
|
||||
|
||||
const parsedBody = createSnapshotBodySchema.safeParse(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.status(400).send(
|
||||
errorEnvelope(
|
||||
"SNAPSHOT_VALIDATION_FAILED",
|
||||
"Snapshot request body is invalid.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const body = successEnvelope(
|
||||
createSnapshot(params.projectId, parsedBody.data),
|
||||
getRequestId(request),
|
||||
);
|
||||
setIdempotentResponse(cacheKey, { statusCode: 201, body });
|
||||
return reply.status(201).send(body);
|
||||
});
|
||||
|
||||
app.post("/api/projects/:projectId/snapshots/compare", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
||||
if (!idempotencyKey) return reply;
|
||||
const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey);
|
||||
const replay = getIdempotentResponse<unknown>(cacheKey);
|
||||
if (replay) return reply.status(replay.statusCode).send(replay.body);
|
||||
|
||||
const parsedBody = compareBodySchema.safeParse(request.body);
|
||||
if (
|
||||
!parsedBody.success ||
|
||||
!parsedBody.data.baseSnapshotId?.trim() ||
|
||||
!parsedBody.data.targetSnapshotId?.trim()
|
||||
) {
|
||||
return reply.status(400).send(
|
||||
errorEnvelope(
|
||||
"SNAPSHOT_COMPARE_FAILED",
|
||||
"baseSnapshotId and targetSnapshotId are required.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const comparison = compareSnapshots(
|
||||
params.projectId,
|
||||
parsedBody.data.baseSnapshotId,
|
||||
parsedBody.data.targetSnapshotId,
|
||||
);
|
||||
if (!comparison) {
|
||||
return reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"SNAPSHOT_NOT_FOUND",
|
||||
"One or both snapshots were not found.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const body = successEnvelope(comparison, getRequestId(request));
|
||||
setIdempotentResponse(cacheKey, { statusCode: 200, body });
|
||||
return reply.status(200).send(body);
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/api/projects/:projectId/snapshots/:snapshotId",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(snapshotParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
const snapshot = findSnapshot(params.projectId, params.snapshotId);
|
||||
if (!snapshot) return sendSnapshotNotFound(request, reply, params.snapshotId);
|
||||
return successEnvelope(snapshot, getRequestId(request));
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import { successEnvelope } from "../utils/envelope.js";
|
||||
import { loadAutonomyRuntime, type AutonomyRuntimeSnapshot } from "../services/autonomyRuntime.js";
|
||||
|
||||
type ReadinessCheck = {
|
||||
id: string;
|
||||
label: string;
|
||||
status: "pass" | "warn" | "fail" | "skip";
|
||||
detail: string;
|
||||
value?: string | number | boolean;
|
||||
};
|
||||
|
||||
type SystemReadinessResponse = {
|
||||
status: "ready" | "degraded" | "blocked";
|
||||
service: "projectbrain-api";
|
||||
dataSource: AppEnv["PROJECTBRAIN_DATA_SOURCE"];
|
||||
environment: AppEnv["NODE_ENV"];
|
||||
database: {
|
||||
required: boolean;
|
||||
reachable: boolean;
|
||||
provider: "postgresql";
|
||||
projectCount?: number;
|
||||
blueprintGraphCount?: number;
|
||||
migrationCount?: number;
|
||||
portfolioRepositoryCount?: number;
|
||||
linkedRepositoryCount?: number;
|
||||
error?: string;
|
||||
};
|
||||
checks: ReadinessCheck[];
|
||||
analysisWorker: {
|
||||
status: "ready" | "degraded" | "unavailable";
|
||||
activeJobId?: string;
|
||||
queuedJobs?: number;
|
||||
lastSeenAt?: string;
|
||||
};
|
||||
autonomyExecutor: {
|
||||
status: "ready" | "degraded" | "unavailable";
|
||||
connectedExecutors?: number;
|
||||
enabledProjects?: number;
|
||||
claimableProjects?: number;
|
||||
activeProjects?: number;
|
||||
pendingWorkItems?: number;
|
||||
lastSeenAt?: string;
|
||||
};
|
||||
generatedAt: string;
|
||||
};
|
||||
|
||||
const toSafeError = (error: unknown): string => {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
};
|
||||
|
||||
export const registerSystemReadinessRoutes = async (
|
||||
app: FastifyInstance,
|
||||
options: { env: AppEnv },
|
||||
) => {
|
||||
app.get("/api/system/readiness", async (request) => {
|
||||
const checks: ReadinessCheck[] = [];
|
||||
const databaseRequired = options.env.PROJECTBRAIN_DATA_SOURCE === "database";
|
||||
const database = {
|
||||
required: databaseRequired,
|
||||
reachable: false,
|
||||
provider: "postgresql" as const,
|
||||
projectCount: undefined as number | undefined,
|
||||
blueprintGraphCount: undefined as number | undefined,
|
||||
migrationCount: undefined as number | undefined,
|
||||
portfolioRepositoryCount: undefined as number | undefined,
|
||||
linkedRepositoryCount: undefined as number | undefined,
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
const analysisWorker: SystemReadinessResponse["analysisWorker"] = { status: "unavailable" };
|
||||
const autonomyExecutor: SystemReadinessResponse["autonomyExecutor"] = { status: "unavailable" };
|
||||
|
||||
checks.push({
|
||||
id: "data-source-mode",
|
||||
label: "Data source mode",
|
||||
status: databaseRequired ? "pass" : "warn",
|
||||
detail: databaseRequired
|
||||
? "Backend is configured for database-backed persistence."
|
||||
: "Backend is running in fixture mode; persistence checks are informational only.",
|
||||
value: options.env.PROJECTBRAIN_DATA_SOURCE,
|
||||
});
|
||||
|
||||
if (!databaseRequired) {
|
||||
checks.push({
|
||||
id: "database-connection",
|
||||
label: "Database connection",
|
||||
status: "skip",
|
||||
detail: "Skipped because PROJECTBRAIN_DATA_SOURCE is not database.",
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
database.reachable = true;
|
||||
const [projectCount, blueprintGraphCount, migrations] = await Promise.all([
|
||||
prisma.project.count(),
|
||||
prisma.blueprintGraph.count(),
|
||||
prisma.$queryRaw<Array<{ count: bigint }>>`SELECT COUNT(*)::bigint AS count FROM "_prisma_migrations" WHERE finished_at IS NOT NULL`,
|
||||
]);
|
||||
database.projectCount = projectCount;
|
||||
database.blueprintGraphCount = blueprintGraphCount;
|
||||
database.migrationCount = Number(migrations[0]?.count ?? 0);
|
||||
try {
|
||||
const [portfolioRepositoryCount, linkedRepositoryCount] = await Promise.all([
|
||||
prisma.portfolioRepository.count(),
|
||||
prisma.giteaRepositoryLink.count(),
|
||||
]);
|
||||
database.portfolioRepositoryCount = portfolioRepositoryCount;
|
||||
database.linkedRepositoryCount = linkedRepositoryCount;
|
||||
checks.push({
|
||||
id: "portfolio-control-plane",
|
||||
label: "Portfolio control plane",
|
||||
status: portfolioRepositoryCount === linkedRepositoryCount && portfolioRepositoryCount > 0 ? "pass" : "warn",
|
||||
detail: `${linkedRepositoryCount} of ${portfolioRepositoryCount} discovered repositories are linked to ProjectBrain.`,
|
||||
value: linkedRepositoryCount,
|
||||
});
|
||||
} catch {
|
||||
checks.push({ id: "portfolio-control-plane", label: "Portfolio control plane", status: "skip", detail: "Portfolio tables are not available yet; migrations may still be applying." });
|
||||
}
|
||||
try {
|
||||
const autonomy: AutonomyRuntimeSnapshot = await loadAutonomyRuntime(options.env.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS);
|
||||
const executorFresh = autonomy.counts.connectedExecutors > 0;
|
||||
// Paused Auto Mode has no executor dependency. Keep the actual heartbeat
|
||||
// and connection count untouched; readiness is not proof of connection.
|
||||
const executorRequired = autonomy.counts.enabledProjects > 0
|
||||
|| autonomy.counts.claimableProjects > 0
|
||||
|| autonomy.counts.activeProjects > 0
|
||||
|| autonomy.counts.pendingWorkItems > 0;
|
||||
autonomyExecutor.status = !executorRequired || executorFresh ? "ready" : autonomy.executor ? "degraded" : "unavailable";
|
||||
autonomyExecutor.connectedExecutors = autonomy.counts.connectedExecutors;
|
||||
autonomyExecutor.enabledProjects = autonomy.counts.enabledProjects;
|
||||
autonomyExecutor.claimableProjects = autonomy.counts.claimableProjects;
|
||||
autonomyExecutor.activeProjects = autonomy.counts.activeProjects;
|
||||
autonomyExecutor.pendingWorkItems = autonomy.counts.pendingWorkItems;
|
||||
if (autonomy.executor) autonomyExecutor.lastSeenAt = autonomy.executor.lastSeenAt;
|
||||
checks.push({
|
||||
id: "autonomy-executor",
|
||||
label: "Autonomous execution loop",
|
||||
status: !executorRequired ? "skip" : executorFresh ? "pass" : "warn",
|
||||
detail: !executorRequired
|
||||
? "Auto Mode has no enabled projects or pending/active work; an autonomous executor is not required. Historical heartbeat data is unchanged."
|
||||
: executorFresh
|
||||
? `${autonomy.counts.connectedExecutors} scheduled autonomous executor(s) connected; ${autonomy.counts.activeProjects} project(s) active and ${autonomy.counts.claimableProjects} claimable.`
|
||||
: `The autonomous executor heartbeat is missing or stale; ${autonomy.counts.pendingWorkItems} pending work item(s) will remain durable but will not start automatically.`,
|
||||
value: autonomy.counts.pendingWorkItems,
|
||||
});
|
||||
} catch {
|
||||
checks.push({
|
||||
id: "autonomy-executor",
|
||||
label: "Autonomous execution loop",
|
||||
status: "skip",
|
||||
detail: "Autonomous-executor tables are not available yet; migrations may still be applying.",
|
||||
});
|
||||
}
|
||||
|
||||
checks.push({
|
||||
id: "database-connection",
|
||||
label: "Database connection",
|
||||
status: "pass",
|
||||
detail: "Postgres accepted a live query through Prisma.",
|
||||
});
|
||||
checks.push({
|
||||
id: "database-seed",
|
||||
label: "Seeded project data",
|
||||
status: projectCount > 0 ? "pass" : "warn",
|
||||
detail: projectCount > 0
|
||||
? "At least one project is available in database mode."
|
||||
: "Database is reachable but has no projects yet; run the seed command or create a project.",
|
||||
value: projectCount,
|
||||
});
|
||||
checks.push({
|
||||
id: "database-migrations",
|
||||
label: "Applied migrations",
|
||||
status: database.migrationCount > 0 ? "pass" : "warn",
|
||||
detail: database.migrationCount > 0
|
||||
? "Prisma migrations table contains finished migrations."
|
||||
: "No finished Prisma migrations were found.",
|
||||
value: database.migrationCount,
|
||||
});
|
||||
try {
|
||||
const [worker, queuedJobs] = await Promise.all([
|
||||
prisma.analysisWorkerHeartbeat.findUnique({ where: { id: "primary" } }),
|
||||
prisma.analysisJob.count({ where: { status: "queued" } }),
|
||||
]);
|
||||
const workerFresh = Boolean(worker && worker.lastSeenAt.getTime() >= Date.now() - 30_000);
|
||||
analysisWorker.status = workerFresh && worker?.status === "ready" ? "ready" : worker ? "degraded" : "unavailable";
|
||||
analysisWorker.queuedJobs = queuedJobs;
|
||||
if (worker?.activeJobId) analysisWorker.activeJobId = worker.activeJobId;
|
||||
if (worker) analysisWorker.lastSeenAt = worker.lastSeenAt.toISOString();
|
||||
checks.push({
|
||||
id: "analysis-worker",
|
||||
label: "Repository analysis worker",
|
||||
status: workerFresh ? "pass" : "warn",
|
||||
detail: workerFresh ? "The bounded repository-analysis worker is reporting a fresh heartbeat." : "The analysis worker heartbeat is missing or stale; synchronous API and MCP reads remain available.",
|
||||
value: queuedJobs,
|
||||
});
|
||||
} catch {
|
||||
checks.push({
|
||||
id: "analysis-worker",
|
||||
label: "Repository analysis worker",
|
||||
status: "skip",
|
||||
detail: "Analysis-worker tables are not available yet; migrations may still be applying.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
database.error = toSafeError(error);
|
||||
checks.push({
|
||||
id: "database-connection",
|
||||
label: "Database connection",
|
||||
status: "fail",
|
||||
detail: database.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hasFailure = checks.some((check) => check.status === "fail");
|
||||
const hasWarning = checks.some((check) => check.status === "warn");
|
||||
const status: SystemReadinessResponse["status"] = hasFailure ? "blocked" : hasWarning ? "degraded" : "ready";
|
||||
|
||||
const data: SystemReadinessResponse = {
|
||||
status,
|
||||
service: "projectbrain-api",
|
||||
dataSource: options.env.PROJECTBRAIN_DATA_SOURCE,
|
||||
environment: options.env.NODE_ENV,
|
||||
database,
|
||||
checks,
|
||||
analysisWorker,
|
||||
autonomyExecutor,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return successEnvelope(data, getRequestId(request));
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user