190 lines
19 KiB
JavaScript
190 lines
19 KiB
JavaScript
import { z } from "zod";
|
|
|
|
export const requestIdSchema = z.string().min(1);
|
|
export const timestampSchema = z.string().datetime({ offset: true });
|
|
export const validationIssueSchema = z.object({ field: z.string().optional(), message: z.string().min(1), code: z.string().optional() });
|
|
export const apiMetaSchema = z.object({ requestId: requestIdSchema, timestamp: timestampSchema });
|
|
export const apiErrorEnvelopeSchema = z.object({
|
|
error: z.object({ code: z.string().min(1), message: z.string().min(1), validation: z.array(validationIssueSchema).optional() }),
|
|
meta: apiMetaSchema,
|
|
});
|
|
export const successEnvelopeSchema = (dataSchema = z.unknown()) => z.object({
|
|
data: dataSchema.refine((value) => value !== undefined, { message: "A success envelope must contain data" }),
|
|
meta: apiMetaSchema,
|
|
});
|
|
export const apiEnvelopeSchema = z.union([apiErrorEnvelopeSchema, successEnvelopeSchema()]);
|
|
|
|
// Canonical lifecycle values are intentionally language-neutral. Frontends and
|
|
// operational surfaces may translate them for people, but transport and
|
|
// backend decisions must use the same finite vocabulary.
|
|
export const canonicalLifecycleValues = Object.freeze([
|
|
"ready",
|
|
"running",
|
|
"waiting",
|
|
"needs_user",
|
|
"recovering",
|
|
"failed",
|
|
"done",
|
|
]);
|
|
export const canonicalLifecycleSchema = z.enum(canonicalLifecycleValues);
|
|
export const autonomyProfileSchema = z.enum(["conservative", "normal", "autonomous", "custom"]);
|
|
export const autonomyProfileSettings = Object.freeze({
|
|
conservative: Object.freeze({ autoBrainstorm: false, maxBrainstormIterations: 2, maxConsecutiveFailures: 2, leaseMinutes: 20, maxWorkItemsPerRun: 3, maxRunMinutes: 20 }),
|
|
normal: Object.freeze({ autoBrainstorm: true, maxBrainstormIterations: 5, maxConsecutiveFailures: 3, leaseMinutes: 30, maxWorkItemsPerRun: 8, maxRunMinutes: 45 }),
|
|
autonomous: Object.freeze({ autoBrainstorm: true, maxBrainstormIterations: 5, maxConsecutiveFailures: 5, leaseMinutes: 45, maxWorkItemsPerRun: 12, maxRunMinutes: 55 }),
|
|
custom: undefined,
|
|
});
|
|
|
|
// Standard views must explain a blocker without making raw execution payloads
|
|
// (SHAs, workflow names, leases, etc.) the primary product language. Both the
|
|
// API portfolio read model and the web client use this same mapping.
|
|
const normalizeOperationalCopy = (value) => value.replace(/\s+/g, " ").trim();
|
|
const containsTechnicalOperationalDetail = (value) => /\b(?:[a-f0-9]{7,64}|https?:\/\/|\.gitea\/|workflow|default branch|branch protection|pull request|\bpr\s*#?\d+|executor|lease|run \d+|plan \w{12,})\b/i.test(value);
|
|
const firstOperationalSentence = (value) => {
|
|
const sentence = normalizeOperationalCopy(value).split(/(?<=[.!?])\s+/)[0] ?? "";
|
|
return sentence.length > 220 ? `${sentence.slice(0, 217).trimEnd()}…` : sentence;
|
|
};
|
|
export const summarizeHumanBlocker = (reason) => {
|
|
const normalized = reason ? normalizeOperationalCopy(reason) : "";
|
|
if (!normalized) return "ProjectBrain heeft jouw beslissing of toegang nodig om veilig verder te gaan.";
|
|
const lower = normalized.toLowerCase();
|
|
if (/governance|default branch|branch protection|workflow|pull request|\bpr\s*#?\d+/.test(lower)) return "ProjectBrain heeft jouw expliciete keuze nodig over een repositorywijziging voordat het veilig kan doorgaan.";
|
|
if (/authori[sz]|permission|access|credential|token|owner/.test(lower)) return "ProjectBrain heeft jouw toestemming of toegang nodig om veilig verder te gaan.";
|
|
if (/conflict|ambiguous|disagree|inconsistent/.test(lower)) return "ProjectBrain heeft jouw keuze nodig omdat betrouwbare projectinformatie elkaar tegenspreekt.";
|
|
return containsTechnicalOperationalDetail(normalized) ? "ProjectBrain heeft jouw expliciete beslissing nodig voordat het deze stap verantwoord kan voortzetten." : firstOperationalSentence(normalized);
|
|
};
|
|
export const summarizeTechnicalBlocker = (reason) => {
|
|
const normalized = reason ? normalizeOperationalCopy(reason) : "";
|
|
if (!normalized) return "ProjectBrain onderzoekt de technische oorzaak en herstelt automatisch waar dat veilig kan.";
|
|
const lower = normalized.toLowerCase();
|
|
if (/auth|credential|token|permission|access/.test(lower)) return "ProjectBrain herstelt een verbindings- of toegangsprobleem automatisch waar dat veilig kan.";
|
|
if (/branch|pull request|workflow|repository/.test(lower)) return "ProjectBrain controleert de repositorytoestand opnieuw voordat het verdergaat.";
|
|
return containsTechnicalOperationalDetail(normalized) ? "ProjectBrain verwerkt een technische blokkade. Details blijven beschikbaar onder technische diagnose." : firstOperationalSentence(normalized);
|
|
};
|
|
|
|
export const userRoleSchema = z.enum(["owner", "member"]);
|
|
export const authUserSchema = z.object({ id: z.string().min(1), username: z.string().min(3).max(80), role: userRoleSchema });
|
|
export const loginCredentialsSchema = z.object({ username: z.string().trim().min(3).max(80), password: z.string().min(12).max(256) });
|
|
export const rotatePasswordSchema = z.object({ currentPassword: z.string().min(12).max(256), newPassword: z.string().min(12).max(256) }).refine((value) => value.currentPassword !== value.newPassword, { message: "New password must differ from current password", path: ["newPassword"] });
|
|
export const authSessionDataSchema = z.object({ user: authUserSchema, csrfToken: z.string().min(20), expiresAt: timestampSchema.optional() });
|
|
export const entraPublicConfigSchema = z.object({ enabled: z.boolean(), provider: z.literal("microsoft-entra") });
|
|
export const entraLinkStatusSchema = z.object({ enabled: z.boolean(), linked: z.boolean(), displayName: z.string().optional(), preferredUsername: z.string().optional() });
|
|
|
|
export const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-_]+$/);
|
|
export const mutationResponseSchema = z.object({ success: z.boolean(), message: z.string().min(1), updatedEntityId: idSchema.optional(), createdEntityId: idSchema.optional(), affectedEntities: z.array(idSchema) });
|
|
export const captureCreateSchema = z.object({ type: z.string().min(1), content: z.string().trim().min(1), source: z.string().optional() });
|
|
export const operatingLogCreateSchema = z.object({ type: z.string().min(1), title: z.string().trim().min(1), summary: z.string().trim().min(1), linkedEntityIds: z.array(idSchema).optional(), source: z.string().optional(), importance: z.string().optional() });
|
|
|
|
export const apiPaths = Object.freeze({
|
|
auth: {
|
|
login: "/api/auth/login", session: "/api/auth/session", logout: "/api/auth/logout", rotatePassword: "/api/auth/rotate-password",
|
|
entraConfig: "/api/auth/entra/config", entraStart: "/api/auth/entra/start", entraCallback: "/api/auth/entra/callback",
|
|
entraStatus: "/api/auth/entra/status", entraLinkStart: "/api/auth/entra/link/start", entraIdentity: "/api/auth/entra/identity",
|
|
},
|
|
captureItems: (projectId = ":projectId") => `/api/projects/${projectId}/capture-items`,
|
|
classifyCapture: (projectId = ":projectId", captureItemId = ":captureItemId") => `/api/projects/${projectId}/capture-items/${captureItemId}/classify`,
|
|
sendCaptureToReview: (projectId = ":projectId", captureItemId = ":captureItemId") => `/api/projects/${projectId}/capture-items/${captureItemId}/send-to-review`,
|
|
acceptReview: (projectId = ":projectId", reviewItemId = ":reviewItemId") => `/api/projects/${projectId}/review-queue/${reviewItemId}/accept`,
|
|
materializeTruth: (projectId = ":projectId", reviewItemId = ":reviewItemId") => `/api/projects/${projectId}/review-queue/${reviewItemId}/materialize-truth`,
|
|
createProjectUpdate: (projectId = ":projectId", truthItemId = ":truthItemId") => `/api/projects/${projectId}/accepted-truth/${truthItemId}/project-updates`,
|
|
applyProjectUpdate: (projectId = ":projectId", projectUpdateId = ":projectUpdateId") => `/api/projects/${projectId}/project-updates/${projectUpdateId}/apply`,
|
|
handoffBundles: (projectId = ":projectId") => `/api/projects/${projectId}/handoff-bundles`,
|
|
codexTaskFromHandoff: (projectId = ":projectId", handoffBundleId = ":handoffBundleId") => `/api/projects/${projectId}/handoff-bundles/${handoffBundleId}/codex-task`,
|
|
importCodexOutput: (projectId = ":projectId", codexTaskId = ":codexTaskId") => `/api/projects/${projectId}/codex-tasks/${codexTaskId}/import-output`,
|
|
projectActivity: (projectId = ":projectId") => `/api/projects/${projectId}/activity`,
|
|
projectActivityRuntime: (projectId = ":projectId") => `/api/projects/${projectId}/activity/runtime`,
|
|
repositoryFindings: (projectId = ":projectId") => `/api/projects/${projectId}/findings`,
|
|
repositoryFinding: (projectId = ":projectId", findingId = ":findingId") => `/api/projects/${projectId}/findings/${findingId}`,
|
|
findingIssue: (projectId = ":projectId", findingId = ":findingId") => `/api/projects/${projectId}/findings/${findingId}/issue`,
|
|
findingInvestigation: (projectId = ":projectId", findingId = ":findingId") => `/api/projects/${projectId}/findings/${findingId}/investigations`,
|
|
findingSuppression: (projectId = ":projectId", findingId = ":findingId") => `/api/projects/${projectId}/findings/${findingId}/suppress`,
|
|
findingAutomationPolicy: (projectId = ":projectId") => `/api/projects/${projectId}/finding-automation-policy`,
|
|
portfolioControlPlane: "/api/portfolio/control-plane",
|
|
portfolioRuntime: "/api/portfolio/runtime",
|
|
portfolioSourceSync: "/api/portfolio/sources/sync-git",
|
|
projectSourceSync: (projectId = ":projectId") => `/api/projects/${projectId}/sources/sync-git`,
|
|
portfolioRepositories: "/api/portfolio/repositories",
|
|
portfolioReconcile: "/api/portfolio/reconcile",
|
|
portfolioAnalysisJobs: "/api/portfolio/analysis-jobs",
|
|
portfolioAnalysisJob: (jobId = ":jobId") => `/api/portfolio/analysis-jobs/${jobId}`,
|
|
portfolioAnalysisJobResults: (jobId = ":jobId") => `/api/portfolio/analysis-jobs/${jobId}/results`,
|
|
portfolioAnalysisJobCancel: (jobId = ":jobId") => `/api/portfolio/analysis-jobs/${jobId}/cancel`,
|
|
portfolioRepositoryExclude: (repositoryId = ":repositoryId") => `/api/portfolio/repositories/${repositoryId}/exclude`,
|
|
portfolioRepositoryRestore: (repositoryId = ":repositoryId") => `/api/portfolio/repositories/${repositoryId}/restore`,
|
|
workGraph: (projectId = ":projectId") => `/api/projects/${projectId}/work-graph`,
|
|
workGraphReady: (projectId = ":projectId") => `/api/projects/${projectId}/work-graph/ready`,
|
|
workGraphBlockers: (projectId = ":projectId") => `/api/projects/${projectId}/work-graph/blockers`,
|
|
workGraphHistory: (projectId = ":projectId") => `/api/projects/${projectId}/work-graph/history`,
|
|
workGraphClaim: (projectId = ":projectId") => `/api/projects/${projectId}/work-graph/claim`,
|
|
workGraphReclaim: (projectId = ":projectId") => `/api/projects/${projectId}/work-graph/reclaim`,
|
|
workGraphRelease: (projectId = ":projectId", nodeId = ":nodeId") => `/api/projects/${projectId}/work-graph/${nodeId}/release`,
|
|
workGraphComplete: (projectId = ":projectId", nodeId = ":nodeId") => `/api/projects/${projectId}/work-graph/${nodeId}/complete`,
|
|
workGraphDependencies: (projectId = ":projectId", nodeId = ":nodeId") => `/api/projects/${projectId}/work-graph/${nodeId}/dependencies`,
|
|
definitionOfDone: (projectId = ":projectId") => `/api/projects/${projectId}/definition-of-done`,
|
|
autonomySimulation: (projectId = ":projectId") => `/api/projects/${projectId}/autonomy/simulation`,
|
|
projectAttention: (projectId = ":projectId") => `/api/projects/${projectId}/attention`,
|
|
projectKnowledge: (projectId = ":projectId") => `/api/projects/${projectId}/project-knowledge`,
|
|
projectKnowledgeConflicts: (projectId = ":projectId") => `/api/projects/${projectId}/project-knowledge/conflicts`,
|
|
projectEvidenceGraph: (projectId = ":projectId") => `/api/projects/${projectId}/evidence-graph`,
|
|
diagnostics: "/api/diagnostics",
|
|
diagnosticsHistory: "/api/diagnostics/history",
|
|
projectDiagnostics: (projectId = ":projectId") => `/api/projects/${projectId}/diagnostics`,
|
|
projectPulse: (projectId = ":projectId") => `/api/projects/${projectId}/pulse`,
|
|
portfolioPulse: "/api/portfolio/pulse",
|
|
commandInterpret: "/api/commands/interpret",
|
|
projectCommandInterpret: (projectId = ":projectId") => `/api/projects/${projectId}/commands/interpret`,
|
|
portfolioViews: "/api/portfolio/views",
|
|
portfolioView: (viewId = ":viewId") => `/api/portfolio/views/${viewId}`,
|
|
portfolioPins: "/api/portfolio/pins",
|
|
portfolioPin: (projectId = ":projectId") => `/api/portfolio/pins/${projectId}`,
|
|
});
|
|
|
|
const openApiPath = (path) => path.replace(/:([A-Za-z][A-Za-z0-9]*)/g, "{$1}");
|
|
const operations = [
|
|
["post", apiPaths.auth.login, "Authenticate owner or member"], ["get", apiPaths.auth.session, "Read the active session"], ["post", apiPaths.auth.logout, "Revoke the active session"], ["post", apiPaths.auth.rotatePassword, "Rotate owner credentials"],
|
|
["get", apiPaths.auth.entraConfig, "Read public Microsoft Entra availability"], ["get", apiPaths.auth.entraStart, "Start Microsoft Entra login"], ["get", apiPaths.auth.entraCallback, "Complete Microsoft Entra login"], ["get", apiPaths.auth.entraStatus, "Read linked Microsoft Entra identity status"], ["post", apiPaths.auth.entraLinkStart, "Start authenticated Microsoft Entra identity linking"], ["delete", apiPaths.auth.entraIdentity, "Unlink Microsoft Entra identity after password confirmation"],
|
|
["post", apiPaths.captureItems(), "Capture source material"], ["patch", apiPaths.classifyCapture(), "Classify captured source"], ["post", apiPaths.sendCaptureToReview(), "Queue captured source for review"], ["post", apiPaths.acceptReview(), "Accept a review decision"], ["post", apiPaths.materializeTruth(), "Materialize accepted project truth"], ["post", apiPaths.createProjectUpdate(), "Prepare a project update"], ["patch", apiPaths.applyProjectUpdate(), "Apply a project update"], ["post", apiPaths.handoffBundles(), "Create a controlled handoff"], ["post", apiPaths.codexTaskFromHandoff(), "Create a Codex task"], ["post", apiPaths.importCodexOutput(), "Import execution feedback"],
|
|
["get", apiPaths.projectActivity(), "Read durable project, delivery and repository activity"],
|
|
["get", apiPaths.projectActivityRuntime(), "Read compact live project, Gitea runner and analysis-worker activity"],
|
|
["get", apiPaths.portfolioRuntime, "Read platform-wide runner and worker activity"],
|
|
["post", apiPaths.portfolioSourceSync, "Synchronize safe Git sources for the portfolio"],
|
|
["post", apiPaths.projectSourceSync(), "Synchronize safe Git sources for one project"],
|
|
["get", apiPaths.repositoryFindings(), "List durable repository findings"], ["get", apiPaths.repositoryFinding(), "Read one repository finding"],
|
|
["post", apiPaths.findingIssue(), "Create or synchronize a finding issue"], ["post", apiPaths.findingInvestigation(), "Start a bounded finding investigation"],
|
|
["post", apiPaths.findingSuppression(), "Suppress a finding with a reason"], ["get", apiPaths.findingAutomationPolicy(), "Read closed-loop automation policy"],
|
|
["put", apiPaths.findingAutomationPolicy(), "Update closed-loop automation policy"],
|
|
["get", apiPaths.portfolioControlPlane, "Read the central portfolio control plane"], ["get", apiPaths.portfolioRepositories, "List durable repository onboarding state"],
|
|
["post", apiPaths.portfolioReconcile, "Preview or apply idempotent repository onboarding"], ["post", apiPaths.portfolioAnalysisJobs, "Queue a bounded portfolio analysis"],
|
|
["get", apiPaths.portfolioAnalysisJob(), "Read one portfolio analysis job"], ["get", apiPaths.portfolioAnalysisJobResults(), "Read a bounded portfolio analysis result page"],
|
|
["post", apiPaths.portfolioAnalysisJobCancel(), "Cancel a portfolio analysis job"],
|
|
["post", apiPaths.portfolioRepositoryExclude(), "Exclude a repository from automatic onboarding"], ["post", apiPaths.portfolioRepositoryRestore(), "Restore a repository for onboarding"],
|
|
["get", apiPaths.workGraph(), "Read the durable cross-domain project work graph"],
|
|
["get", apiPaths.workGraphReady(), "List ready cross-domain work"], ["get", apiPaths.workGraphBlockers(), "Inspect work graph blockers"], ["get", apiPaths.workGraphHistory(), "Read work graph audit history"],
|
|
["post", apiPaths.workGraphClaim(), "Claim the highest-value ready work atomically"], ["post", apiPaths.workGraphReclaim(), "Reclaim expired work leases"], ["post", apiPaths.workGraphRelease(), "Release a work lease"], ["post", apiPaths.workGraphComplete(), "Record work completion and evidence"], ["put", apiPaths.workGraphDependencies(), "Update durable work dependencies"],
|
|
["get", apiPaths.definitionOfDone(), "Evaluate project definition of done"], ["put", apiPaths.definitionOfDone(), "Save project completion requirements"], ["get", apiPaths.autonomySimulation(), "Simulate the current autonomous plan"], ["get", apiPaths.projectAttention(), "Read the deduplicated project attention inbox"],
|
|
["get", apiPaths.projectKnowledge(), "Read the unified project knowledge view"], ["get", apiPaths.projectKnowledgeConflicts(), "Read project truth conflicts and reconciliation provenance"],
|
|
["get", apiPaths.projectEvidenceGraph(), "Read requirement-to-runtime evidence relationships"],
|
|
["post", apiPaths.diagnostics, "Run safe platform diagnostics"], ["get", apiPaths.diagnosticsHistory, "Read recent diagnostic history"], ["post", apiPaths.projectDiagnostics(), "Run safe project diagnostics"], ["get", apiPaths.projectPulse(), "Read the concise project pulse"], ["get", apiPaths.portfolioPulse, "Read the concise portfolio pulse"],
|
|
["post", apiPaths.commandInterpret, "Interpret a natural-language control command"], ["post", apiPaths.projectCommandInterpret(), "Interpret a project control command"], ["get", apiPaths.portfolioViews, "List saved portfolio views"], ["post", apiPaths.portfolioViews, "Create a saved portfolio view"], ["patch", apiPaths.portfolioView(), "Update a saved portfolio view"], ["delete", apiPaths.portfolioView(), "Delete a saved portfolio view"], ["get", apiPaths.portfolioPins, "List per-user pinned projects"], ["put", apiPaths.portfolioPin(), "Pin or unpin a project for the current user"],
|
|
];
|
|
|
|
const openApiPaths = () => operations.reduce((paths, [method, path, summary]) => {
|
|
const normalizedPath = openApiPath(path);
|
|
paths[normalizedPath] = {
|
|
...(paths[normalizedPath] ?? {}),
|
|
[method]: { summary, responses: { 200: { description: "Successful canonical envelope" }, 201: { description: "Created canonical envelope" }, 400: { description: "Validation error envelope" }, 401: { description: "Authentication required" }, 403: { description: "Authorization or CSRF denied" }, 409: { description: "State transition conflict" } } },
|
|
};
|
|
return paths;
|
|
}, {});
|
|
|
|
export const buildOpenApiDocument = () => ({
|
|
openapi: "3.1.0",
|
|
info: { title: "ProjectBrain API", version: "1.0.0", description: "Authenticated private-network API; generated from @projectbrain/contracts." },
|
|
servers: [{ url: "/" }],
|
|
paths: openApiPaths(),
|
|
components: { schemas: {
|
|
ApiMeta: { type: "object", required: ["requestId", "timestamp"], properties: { requestId: { type: "string", minLength: 1 }, timestamp: { type: "string", format: "date-time" } } },
|
|
ApiError: { type: "object", required: ["error", "meta"], properties: { error: { type: "object", required: ["code", "message"], properties: { code: { type: "string" }, message: { type: "string" } } }, meta: { $ref: "#/components/schemas/ApiMeta" } } },
|
|
} },
|
|
});
|