194 lines
20 KiB
TypeScript
194 lines
20 KiB
TypeScript
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") || code === "WORK_GRAPH_ACTIVE_LEASE" ? 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); }
|
|
});
|
|
};
|