Initial curated projectbrain source from 8a7b1e0651dbdab97d744cbd2e653ab3970ac6c5

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:43:26 +02:00
commit 5aa3e8d685
700 files changed
+132801

No files matched your search

+205
View File
@@ -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));
});
};
+331
View File
@@ -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);
},
);
};
+84
View File
@@ -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);
}
});
};
+47
View File
@@ -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));
});
};
+201
View File
@@ -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); }
});
};
+217
View File
@@ -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)));
});
};
+86
View File
@@ -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);
});
};
+286
View File
@@ -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);
});
};
+442
View File
@@ -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);
},
);
};
+465
View File
@@ -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);
},
);
};
+415
View File
@@ -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)));
});
};
+30
View File
@@ -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); });
};
+74
View File
@@ -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));
});
};
+306
View File
@@ -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));
},
);
};
+293
View File
@@ -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));
});
};
+106
View File
@@ -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); }
});
};
+492
View File
@@ -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));
});
};
+26
View File
@@ -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));
});
};
+148
View File
@@ -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)));
}
});
};
+268
View File
@@ -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);
},
);
};
+99
View File
@@ -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);
});
};
+72
View File
@@ -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);
}
} });
};
+76
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" }[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); }
});
};
+43
View File
@@ -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); });
};
+45
View File
@@ -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`);
});
};
+193
View File
@@ -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); }
});
};
+331
View File
@@ -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);
});
});
};
+116
View File
@@ -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); }
});
};
+414
View File
@@ -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));
});
};
+195
View File
@@ -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)));
});
};
+330
View File
@@ -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);
},
);
};
+314
View File
@@ -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);
},
);
};
+315
View File
@@ -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);
});
});
};
+605
View File
@@ -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);
});
},
);
};
+400
View File
@@ -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);
});
});
};
+301
View File
@@ -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));
},
);
};
+237
View File
@@ -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));
});
};