Initial curated projectbrain source from 8a7b1e0651dbdab97d744cbd2e653ab3970ac6c5
This commit is contained in:
commit
5aa3e8d685
700 files changed
+132801
No files matched your search
@@ -0,0 +1,314 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import { getGiteaClient, listLinkedRepositories, type LinkedRepository } from "../integrations/gitea/service.js";
|
||||
import type { GiteaRepository } from "../integrations/gitea/types.js";
|
||||
import { chooseAnalysisStrategy, type AnalysisStrategy } from "../services/analysisStrategy.js";
|
||||
|
||||
export type PortfolioAnalysisRequest = {
|
||||
projectIds?: string[];
|
||||
repositoryIds?: string[];
|
||||
includeBranches: boolean;
|
||||
includePullRequests: boolean;
|
||||
includeActions: boolean;
|
||||
changedPaths?: string[];
|
||||
strategy?: AnalysisStrategy;
|
||||
strategyReason?: string;
|
||||
};
|
||||
|
||||
const TERMINAL_STATUSES = ["succeeded", "partial", "failed", "cancelled"] as const;
|
||||
const MAX_REPOSITORIES = 200;
|
||||
export const projectAnalysisItemScope = (projectIdOrSlug: string) => ({ OR: [{ projectId: projectIdOrSlug }, { projectSlug: projectIdOrSlug }] });
|
||||
|
||||
const canonicalRequest = (input: Partial<PortfolioAnalysisRequest>): PortfolioAnalysisRequest => ({
|
||||
projectIds: [...new Set((input.projectIds ?? []).map((value) => value.trim()).filter(Boolean))].sort(),
|
||||
repositoryIds: [...new Set((input.repositoryIds ?? []).map((value) => value.trim()).filter(Boolean))].sort(),
|
||||
includeBranches: input.includeBranches ?? true,
|
||||
includePullRequests: input.includePullRequests ?? true,
|
||||
includeActions: input.includeActions ?? true,
|
||||
...(input.changedPaths?.length ? { changedPaths: [...new Set(input.changedPaths.map((value) => value.trim()).filter(Boolean))].slice(0, 200) } : {}),
|
||||
...(input.strategy ? { strategy: input.strategy } : {}),
|
||||
...(input.strategyReason ? { strategyReason: input.strategyReason } : {}),
|
||||
});
|
||||
|
||||
export type AnalysisTarget = Pick<LinkedRepository, "id" | "projectId" | "projectSlug" | "projectName" | "owner" | "repository" | "fullName" | "defaultBranch" | "accessMode"> & {
|
||||
repositoryLinkId?: string;
|
||||
};
|
||||
|
||||
const matchesRepository = (target: AnalysisTarget, request: PortfolioAnalysisRequest) => {
|
||||
return !request.repositoryIds?.length || request.repositoryIds.includes(target.id) || request.repositoryIds.includes(target.fullName);
|
||||
};
|
||||
|
||||
export const resolveAnalysisTargets = (
|
||||
links: LinkedRepository[],
|
||||
repositories: GiteaRepository[],
|
||||
request: PortfolioAnalysisRequest,
|
||||
): AnalysisTarget[] => {
|
||||
let targets: AnalysisTarget[];
|
||||
if (request.projectIds?.length) {
|
||||
targets = links
|
||||
.filter((link) => request.projectIds!.includes(link.projectId) || request.projectIds!.includes(link.projectSlug))
|
||||
.map((link) => ({ ...link, repositoryLinkId: link.id }));
|
||||
} else {
|
||||
const byFullName = new Map(links.map((link) => [link.fullName.toLowerCase(), link]));
|
||||
targets = repositories.map((repository) => {
|
||||
const link = byFullName.get(repository.full_name.toLowerCase());
|
||||
return {
|
||||
id: link?.id ?? `gitea:${repository.full_name}`,
|
||||
repositoryLinkId: link?.id,
|
||||
projectId: link?.projectId ?? "",
|
||||
projectSlug: link?.projectSlug ?? "gitea-all",
|
||||
projectName: link?.projectName ?? "Gitea portfolio",
|
||||
owner: repository.owner.login,
|
||||
repository: repository.name,
|
||||
fullName: repository.full_name,
|
||||
defaultBranch: repository.default_branch || "main",
|
||||
accessMode: link?.accessMode ?? (repository.permissions?.push || repository.permissions?.admin ? "read_write" : "read_only"),
|
||||
};
|
||||
});
|
||||
}
|
||||
return targets.filter((target) => matchesRepository(target, request)).sort((left, right) => left.fullName.localeCompare(right.fullName));
|
||||
};
|
||||
|
||||
const jobSelection = {
|
||||
id: true,
|
||||
kind: true,
|
||||
status: true,
|
||||
actor: true,
|
||||
progressCurrent: true,
|
||||
progressTotal: true,
|
||||
summaryText: true,
|
||||
summaryJson: true,
|
||||
errorCode: true,
|
||||
cancelRequestedAt: true,
|
||||
queuedAt: true,
|
||||
startedAt: true,
|
||||
heartbeatAt: true,
|
||||
completedAt: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} as const;
|
||||
|
||||
export const serializeAnalysisJob = (job: Record<string, unknown>) => {
|
||||
const serialized = Object.fromEntries(
|
||||
Object.entries(job).map(([key, value]) => [key, value instanceof Date ? value.toISOString() : value]),
|
||||
);
|
||||
const status = typeof serialized.status === "string" ? serialized.status : "unknown";
|
||||
const terminal = TERMINAL_STATUSES.includes(status as (typeof TERMINAL_STATUSES)[number]);
|
||||
const stateVersion = createHash("sha256").update(JSON.stringify({
|
||||
id: serialized.id,
|
||||
status,
|
||||
progressCurrent: serialized.progressCurrent,
|
||||
progressTotal: serialized.progressTotal,
|
||||
summaryText: serialized.summaryText,
|
||||
errorCode: serialized.errorCode,
|
||||
updatedAt: serialized.updatedAt,
|
||||
})).digest("hex").slice(0, 24);
|
||||
const nextAction = status === "succeeded"
|
||||
? "read-results"
|
||||
: status === "partial"
|
||||
? "read-results-and-inspect-failures"
|
||||
: status === "failed"
|
||||
? "inspect-summary-or-restart"
|
||||
: status === "cancelled"
|
||||
? "restart-if-needed"
|
||||
: "wait-for-change";
|
||||
return { ...serialized, stateVersion, terminal, pollAfterMs: terminal ? 0 : status === "queued" ? 5_000 : 4_000, nextAction };
|
||||
};
|
||||
|
||||
export const createPortfolioAnalysisJob = async (
|
||||
env: AppEnv,
|
||||
actor: string,
|
||||
input: Partial<PortfolioAnalysisRequest>,
|
||||
) => {
|
||||
const request = canonicalRequest(input);
|
||||
const links = await listLinkedRepositories();
|
||||
const repositories = request.projectIds?.length ? [] : await getGiteaClient(env).listRepositories();
|
||||
const targets = resolveAnalysisTargets(links, repositories, request);
|
||||
if (!targets.length) throw new Error("ANALYSIS_REPOSITORIES_NOT_FOUND");
|
||||
if (targets.length > MAX_REPOSITORIES) throw new Error("ANALYSIS_REPOSITORY_LIMIT_EXCEEDED");
|
||||
|
||||
const lastFullScan = await prisma.analysisJob.findFirst({ where: { status: "succeeded", items: { some: { repositoryLinkId: { in: targets.map((target) => target.repositoryLinkId ?? target.id) } } } }, orderBy: { completedAt: "desc" }, select: { completedAt: true } });
|
||||
const analysisPlan = chooseAnalysisStrategy({ changedPaths: request.changedPaths, lastFullScanAt: lastFullScan?.completedAt?.toISOString(), budgetItems: targets.length });
|
||||
const persistedRequest = { ...request, strategy: request.strategy ?? analysisPlan.strategy, strategyReason: request.strategyReason ?? analysisPlan.reason, repositoryIds: targets.map((target) => target.id) };
|
||||
const requestHash = createHash("sha256").update(JSON.stringify(persistedRequest)).digest("hex");
|
||||
const activeDedupeKey = createHash("sha256").update(`${actor}:portfolio_health:${requestHash}`).digest("hex");
|
||||
const existing = await prisma.analysisJob.findUnique({ where: { activeDedupeKey }, select: jobSelection });
|
||||
if (existing) return { job: serializeAnalysisJob(existing), deduplicated: true };
|
||||
|
||||
try {
|
||||
const job = await prisma.analysisJob.create({
|
||||
data: {
|
||||
actor,
|
||||
requestJson: persistedRequest as never,
|
||||
requestHash,
|
||||
activeDedupeKey,
|
||||
progressTotal: targets.length,
|
||||
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
items: {
|
||||
create: targets.map((target, sequence) => ({
|
||||
repositoryLinkId: target.repositoryLinkId,
|
||||
projectId: target.projectId || undefined,
|
||||
projectSlug: target.projectSlug,
|
||||
projectName: target.projectName,
|
||||
owner: target.owner,
|
||||
repository: target.repository,
|
||||
defaultBranch: target.defaultBranch,
|
||||
accessMode: target.accessMode,
|
||||
sequence,
|
||||
})),
|
||||
},
|
||||
},
|
||||
select: jobSelection,
|
||||
});
|
||||
return { job: serializeAnalysisJob(job), deduplicated: false };
|
||||
} catch (error) {
|
||||
const raced = await prisma.analysisJob.findUnique({ where: { activeDedupeKey }, select: jobSelection });
|
||||
if (raced) return { job: serializeAnalysisJob(raced), deduplicated: true };
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getAnalysisJob = async (actor: string, jobId: string) => {
|
||||
const job = await prisma.analysisJob.findFirst({ where: { id: jobId, actor }, select: jobSelection });
|
||||
if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND");
|
||||
return serializeAnalysisJob(job);
|
||||
};
|
||||
|
||||
export const listAnalysisJobs = async (actor: string, limit = 10) => {
|
||||
const jobs = await prisma.analysisJob.findMany({
|
||||
where: { actor },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: Math.min(Math.max(limit, 1), 25),
|
||||
select: jobSelection,
|
||||
});
|
||||
return jobs.map((job) => serializeAnalysisJob(job));
|
||||
};
|
||||
|
||||
export const listProjectAnalysisJobs = async (projectId: string, limit = 10) => {
|
||||
const jobs = await prisma.analysisJob.findMany({
|
||||
where: { items: { some: projectAnalysisItemScope(projectId) } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: Math.min(Math.max(limit, 1), 25),
|
||||
select: jobSelection,
|
||||
});
|
||||
return jobs.map((job) => serializeAnalysisJob(job));
|
||||
};
|
||||
|
||||
export const getProjectAnalysisJob = async (projectId: string, jobId: string) => {
|
||||
const job = await prisma.analysisJob.findFirst({ where: { id: jobId, items: { some: projectAnalysisItemScope(projectId) } }, select: jobSelection });
|
||||
if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND");
|
||||
return serializeAnalysisJob(job);
|
||||
};
|
||||
|
||||
export const getPortfolioAnalysisJob = async (jobId: string) => {
|
||||
const job = await prisma.analysisJob.findUnique({ where: { id: jobId }, select: jobSelection });
|
||||
if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND");
|
||||
return serializeAnalysisJob(job);
|
||||
};
|
||||
|
||||
export const cancelAnalysisJob = async (actor: string, jobId: string) => {
|
||||
const job = await prisma.analysisJob.findFirst({ where: { id: jobId, actor }, select: { id: true, status: true } });
|
||||
if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND");
|
||||
if (TERMINAL_STATUSES.includes(job.status as (typeof TERMINAL_STATUSES)[number])) return getAnalysisJob(actor, jobId);
|
||||
const now = new Date();
|
||||
await prisma.$transaction([
|
||||
prisma.analysisJob.update({
|
||||
where: { id: job.id },
|
||||
data: job.status === "queued"
|
||||
? { status: "cancelled", cancelRequestedAt: now, completedAt: now, activeDedupeKey: null, summaryText: "Analyse geannuleerd voordat de worker startte." }
|
||||
: { cancelRequestedAt: now },
|
||||
}),
|
||||
...(job.status === "queued" ? [prisma.analysisJobItem.updateMany({ where: { jobId: job.id, status: "pending" }, data: { status: "cancelled", completedAt: now } })] : []),
|
||||
]);
|
||||
return getAnalysisJob(actor, jobId);
|
||||
};
|
||||
|
||||
export const cancelProjectAnalysisJob = async (projectId: string, jobId: string) => {
|
||||
const job = await prisma.analysisJob.findFirst({ where: { id: jobId, items: { some: projectAnalysisItemScope(projectId) } }, select: { id: true, actor: true } });
|
||||
if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND");
|
||||
return cancelAnalysisJob(job.actor, job.id);
|
||||
};
|
||||
|
||||
export const cancelPortfolioAnalysisJob = async (jobId: string) => {
|
||||
const job = await prisma.analysisJob.findUnique({ where: { id: jobId }, select: { actor: true } });
|
||||
if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND");
|
||||
return cancelAnalysisJob(job.actor, jobId);
|
||||
};
|
||||
|
||||
type AnalysisResultScope = { actor?: string; projectId?: string; portfolio?: boolean };
|
||||
|
||||
const getScopedAnalysisJobResults = async (
|
||||
scope: AnalysisResultScope,
|
||||
jobId: string,
|
||||
input: { cursor?: string; pageSize?: number; characterBudget?: number } = {},
|
||||
) => {
|
||||
const where = scope.projectId
|
||||
? { id: jobId, items: { some: projectAnalysisItemScope(scope.projectId) } }
|
||||
: scope.portfolio
|
||||
? { id: jobId }
|
||||
: { id: jobId, actor: scope.actor ?? "" };
|
||||
const job = await prisma.analysisJob.findFirst({ where, select: jobSelection });
|
||||
if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND");
|
||||
const cursor = Number.parseInt(input.cursor ?? "0", 10);
|
||||
if (!Number.isInteger(cursor) || cursor < 0) throw new Error("ANALYSIS_CURSOR_INVALID");
|
||||
const pageSize = Math.min(Math.max(input.pageSize ?? 5, 1), 10);
|
||||
const characterBudget = Math.min(Math.max(input.characterBudget ?? 24_000, 4_000), 32_000);
|
||||
const rows = await prisma.analysisJobItem.findMany({
|
||||
where: { jobId, ...(scope.projectId ? projectAnalysisItemScope(scope.projectId) : {}) },
|
||||
orderBy: { sequence: "asc" },
|
||||
skip: cursor,
|
||||
take: pageSize + 1,
|
||||
});
|
||||
|
||||
let usedCharacters = 0;
|
||||
const items: Array<Record<string, unknown>> = [];
|
||||
for (const row of rows.slice(0, pageSize)) {
|
||||
const detail = row.detailJson && typeof row.detailJson === "object" ? row.detailJson : undefined;
|
||||
const detailCharacters = detail ? JSON.stringify(detail).length : 0;
|
||||
const includeDetail = usedCharacters + detailCharacters <= characterBudget;
|
||||
items.push({
|
||||
repositoryId: row.repositoryLinkId ?? `gitea:${row.owner}/${row.repository}`,
|
||||
projectId: row.projectId,
|
||||
projectSlug: row.projectSlug,
|
||||
projectName: row.projectName,
|
||||
repository: `${row.owner}/${row.repository}`,
|
||||
status: row.status,
|
||||
summary: row.summary,
|
||||
findingCount: row.findingCount,
|
||||
errorCode: row.errorCode,
|
||||
...(includeDetail && detail ? { detail } : { detailTruncated: Boolean(detail) }),
|
||||
});
|
||||
if (includeDetail) usedCharacters += detailCharacters;
|
||||
}
|
||||
const consumed = items.length;
|
||||
const hasMore = rows.length > pageSize;
|
||||
return {
|
||||
job: serializeAnalysisJob(job),
|
||||
items,
|
||||
cursor: String(cursor),
|
||||
pageSize,
|
||||
characterBudget,
|
||||
usedCharacters,
|
||||
nextCursor: hasMore ? String(cursor + consumed) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const getAnalysisJobResults = async (
|
||||
actor: string,
|
||||
jobId: string,
|
||||
input: { cursor?: string; pageSize?: number; characterBudget?: number } = {},
|
||||
projectId?: string,
|
||||
) => getScopedAnalysisJobResults(projectId ? { projectId } : { actor }, jobId, input);
|
||||
|
||||
export const getProjectAnalysisJobResults = async (
|
||||
projectId: string,
|
||||
jobId: string,
|
||||
input: { cursor?: string; pageSize?: number; characterBudget?: number } = {},
|
||||
) => getAnalysisJobResults("", jobId, input, projectId);
|
||||
|
||||
export const getPortfolioAnalysisJobResults = async (
|
||||
jobId: string,
|
||||
input: { cursor?: string; pageSize?: number; characterBudget?: number } = {},
|
||||
) => getScopedAnalysisJobResults({ portfolio: true }, jobId, input);
|
||||
@@ -0,0 +1,18 @@
|
||||
export type AutonomyExecutionTarget = {
|
||||
surface: "gitea";
|
||||
claimMode: "branch";
|
||||
completionMode: "validated_merge";
|
||||
repositoryLinkId: string;
|
||||
owner: string;
|
||||
repository: string;
|
||||
fullName: string;
|
||||
defaultBranch: string;
|
||||
branch: string;
|
||||
};
|
||||
|
||||
export const hasGiteaAutonomyCompletionEvidence = (evidence: string[] | undefined) => {
|
||||
const values = evidence ?? [];
|
||||
return values.some((entry) => entry.startsWith("gitea:auto:merge:"))
|
||||
&& values.some((entry) => entry.startsWith("gitea:auto:head:"))
|
||||
&& values.some((entry) => entry.startsWith("gitea:auto:validation:"));
|
||||
};
|
||||
@@ -0,0 +1,797 @@
|
||||
import { autonomyProfileSettings, type AutonomyProfile } from "@projectbrain/contracts";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import { autonomyRetryDelayMinutes, classifyAutonomyBlocker, recoverStoredBlockerKind } from "../services/autonomyRecovery.js";
|
||||
import { addDiscoveredTasks, hasOpenAutonomyExecutionItems, isAutonomyItemInExecutionScope, orderAutonomyRepairs, workPrompt } from "../services/autonomyWork.js";
|
||||
import { getFeatureDelivery } from "./featureDeliveryRepository.js";
|
||||
import { createBrainstormForProject } from "./brainstormRepository.js";
|
||||
import { parseRoadmapMarkdown } from "../services/autonomyRoadmap.js";
|
||||
import { decideRecovery } from "../services/recoveryPolicy.js";
|
||||
import { hasGiteaAutonomyCompletionEvidence, type AutonomyExecutionTarget } from "./autonomyExecutionEvidence.js";
|
||||
export { parseRoadmapMarkdown };
|
||||
export { autonomyRetryDelayMinutes, classifyAutonomyBlocker };
|
||||
export { hasGiteaAutonomyCompletionEvidence };
|
||||
export type { AutonomyExecutionTarget };
|
||||
export type AutonomyStatus = "off" | "waiting_roadmap" | "running" | "blocked" | "completed";
|
||||
export type AutonomyWorkKind = "brainstorm" | "implement" | "validate_phase";
|
||||
export type AutonomyWorkOutcome = "success" | "failed" | "blocked";
|
||||
export type RoadmapItemStatus = "pending" | "in_progress" | "completed" | "blocked";
|
||||
export type RoadmapPhaseStatus = "pending" | "active" | "brainstorming" | "executing" | "validating" | "completed" | "blocked";
|
||||
|
||||
export type AutonomyRoadmapItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
instruction?: string;
|
||||
source?: "roadmap" | "user_assignment" | "brainstorm" | "recovery" | "finding" | "delivery" | "release" | "remediation" | "feature" | "bug";
|
||||
createdAt?: string;
|
||||
status: RoadmapItemStatus;
|
||||
attempts: number;
|
||||
priority?: number;
|
||||
scope?: "required" | "committed" | "stretch" | "future";
|
||||
risk?: "low" | "normal" | "medium" | "high" | "critical";
|
||||
estimatedScope?: number;
|
||||
releaseBlocking?: boolean;
|
||||
waitingExternal?: boolean;
|
||||
touchedPaths?: string[];
|
||||
domain?: string;
|
||||
deliveryId?: string;
|
||||
dependsOn?: string[];
|
||||
lastResult?: string;
|
||||
lastError?: string;
|
||||
completionEvidence?: string[];
|
||||
supersededBy?: string;
|
||||
};
|
||||
|
||||
export type AutonomyRoadmapPhase = {
|
||||
id: string;
|
||||
title: string;
|
||||
objective: string;
|
||||
status: RoadmapPhaseStatus;
|
||||
brainstormIterations: number;
|
||||
brainstormConverged: boolean;
|
||||
validationFailures: number;
|
||||
items: AutonomyRoadmapItem[];
|
||||
};
|
||||
|
||||
export type AutonomyRoadmap = {
|
||||
id: string;
|
||||
title: string;
|
||||
sourceMarkdown: string;
|
||||
status: "active" | "completed" | "blocked";
|
||||
currentPhaseIndex: number;
|
||||
phases: AutonomyRoadmapPhase[];
|
||||
importedAt: string;
|
||||
completedAt?: string;
|
||||
};
|
||||
|
||||
export type AutonomyLease = {
|
||||
token: string;
|
||||
kind: AutonomyWorkKind;
|
||||
actor: string;
|
||||
runId?: string;
|
||||
roadmapId: string;
|
||||
phaseId: string;
|
||||
itemId?: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type AutonomyCompletedLease = {
|
||||
token: string;
|
||||
outcome: AutonomyWorkOutcome;
|
||||
summary: string;
|
||||
addedTasks: string[];
|
||||
completedAt: string;
|
||||
};
|
||||
|
||||
export type AutonomyRun = {
|
||||
id: string;
|
||||
actor: string;
|
||||
startedAt: string;
|
||||
reportedWorkItems: number;
|
||||
};
|
||||
|
||||
export type AutonomyEvent = {
|
||||
id: string;
|
||||
at: string;
|
||||
type: string;
|
||||
actor: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type AutonomyState = {
|
||||
version: 1;
|
||||
profile: AutonomyProfile;
|
||||
enabled: boolean;
|
||||
status: AutonomyStatus;
|
||||
autoBrainstorm: boolean;
|
||||
maxBrainstormIterations: number;
|
||||
maxConsecutiveFailures: number;
|
||||
leaseMinutes: number;
|
||||
maxWorkItemsPerRun: number;
|
||||
maxRunMinutes: number;
|
||||
pauseAfterCurrent: boolean;
|
||||
schedulingPolicy: "balanced" | "downstream-impact";
|
||||
roadmap?: AutonomyRoadmap;
|
||||
activeLease?: AutonomyLease;
|
||||
blocker?: { at: string; phaseId?: string; itemId?: string; reason: string; kind: "human" | "retryable"; retryAt?: string; recoveryStage?: "retry" | "cooldown" | "alternate_recovery" | "escalated"; recoveryAttempts?: number; recoveryStartedAt?: string };
|
||||
recoveryStartedAt?: string;
|
||||
recoveryAttempts?: number;
|
||||
maxRecoveryMinutes?: number;
|
||||
runs: AutonomyRun[];
|
||||
completedLeases: AutonomyCompletedLease[];
|
||||
events: AutonomyEvent[];
|
||||
updatedAt: string;
|
||||
updatedBy: string;
|
||||
};
|
||||
|
||||
export type AutonomyWork = {
|
||||
token: string;
|
||||
kind: AutonomyWorkKind;
|
||||
projectId: string;
|
||||
roadmapId: string;
|
||||
roadmapTitle: string;
|
||||
phaseId: string;
|
||||
phaseTitle: string;
|
||||
phaseObjective: string;
|
||||
itemId?: string;
|
||||
itemTitle?: string;
|
||||
deliveryId?: string;
|
||||
execution?: AutonomyExecutionTarget;
|
||||
prompt: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type AutonomyReportInput = {
|
||||
token: string;
|
||||
outcome: AutonomyWorkOutcome;
|
||||
summary: string;
|
||||
deliveryId?: string;
|
||||
discoveredTasks?: string[];
|
||||
brainstorm?: {
|
||||
contextSummary?: string;
|
||||
ideas?: Array<{ id: string; title: string; description: string; score?: number }>;
|
||||
outcomes?: string[];
|
||||
selectedIdeaIds?: string[];
|
||||
nextActions?: string[];
|
||||
};
|
||||
blockerType?: "human" | "retryable";
|
||||
retryAfterMinutes?: number;
|
||||
};
|
||||
|
||||
export type EnqueueAutonomyTaskInput = { title?: string; instruction: string };
|
||||
|
||||
const DEFAULTS = {
|
||||
profile: "normal" as const,
|
||||
autoBrainstorm: true,
|
||||
maxBrainstormIterations: 5,
|
||||
maxConsecutiveFailures: 3,
|
||||
leaseMinutes: 30,
|
||||
maxWorkItemsPerRun: 8,
|
||||
maxRunMinutes: 45,
|
||||
maxRecoveryMinutes: 120,
|
||||
} as const;
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
const asString = (value: unknown, fallback = "") => typeof value === "string" ? value : fallback;
|
||||
const asBoolean = (value: unknown, fallback: boolean) => typeof value === "boolean" ? value : fallback;
|
||||
const asInt = (value: unknown, fallback: number, min: number, max: number) => {
|
||||
const parsed = typeof value === "number" && Number.isInteger(value) ? value : fallback;
|
||||
return Math.max(min, Math.min(max, parsed));
|
||||
};
|
||||
const json = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
const event = (type: string, actor: string, summary: string): AutonomyEvent => ({ id: randomUUID(), at: nowIso(), type, actor, summary });
|
||||
const appendEvent = (state: AutonomyState, next: AutonomyEvent): AutonomyState => ({ ...state, events: [...state.events, next].slice(-100) });
|
||||
|
||||
const defaultState = (actor = "system"): AutonomyState => ({
|
||||
version: 1,
|
||||
enabled: false,
|
||||
status: "off",
|
||||
profile: DEFAULTS.profile,
|
||||
autoBrainstorm: DEFAULTS.autoBrainstorm,
|
||||
maxBrainstormIterations: DEFAULTS.maxBrainstormIterations,
|
||||
maxConsecutiveFailures: DEFAULTS.maxConsecutiveFailures,
|
||||
leaseMinutes: DEFAULTS.leaseMinutes,
|
||||
maxWorkItemsPerRun: DEFAULTS.maxWorkItemsPerRun,
|
||||
maxRunMinutes: DEFAULTS.maxRunMinutes,
|
||||
pauseAfterCurrent: false,
|
||||
schedulingPolicy: "balanced",
|
||||
maxRecoveryMinutes: DEFAULTS.maxRecoveryMinutes,
|
||||
recoveryAttempts: 0,
|
||||
runs: [],
|
||||
completedLeases: [],
|
||||
events: [],
|
||||
updatedAt: nowIso(),
|
||||
updatedBy: actor,
|
||||
});
|
||||
|
||||
const normalizeItem = (value: unknown): AutonomyRoadmapItem | undefined => {
|
||||
const row = asRecord(value);
|
||||
if (!asString(row.id) || !asString(row.title)) return undefined;
|
||||
const status = ["pending", "in_progress", "completed", "blocked"].includes(asString(row.status)) ? asString(row.status) as RoadmapItemStatus : "pending";
|
||||
return {
|
||||
id: asString(row.id), title: asString(row.title), status, attempts: asInt(row.attempts, 0, 0, 100),
|
||||
...(asString(row.instruction) ? { instruction: asString(row.instruction) } : {}),
|
||||
...(["roadmap", "user_assignment", "brainstorm", "recovery", "finding", "delivery", "release", "remediation", "feature", "bug"].includes(asString(row.source)) ? { source: asString(row.source) as AutonomyRoadmapItem["source"] } : {}),
|
||||
...(asString(row.createdAt) ? { createdAt: asString(row.createdAt) } : {}),
|
||||
...(asString(row.deliveryId) ? { deliveryId: asString(row.deliveryId) } : {}),
|
||||
...(typeof row.priority === "number" ? { priority: asInt(row.priority, 50, 0, 100) } : {}),
|
||||
...(["required", "committed", "stretch", "future"].includes(asString(row.scope)) ? { scope: asString(row.scope) as AutonomyRoadmapItem["scope"] } : {}),
|
||||
...(["low", "normal", "medium", "high", "critical"].includes(asString(row.risk)) ? { risk: asString(row.risk) as AutonomyRoadmapItem["risk"] } : {}),
|
||||
...(typeof row.estimatedScope === "number" ? { estimatedScope: asInt(row.estimatedScope, 0, 0, 10) } : {}),
|
||||
...(typeof row.releaseBlocking === "boolean" ? { releaseBlocking: row.releaseBlocking } : {}),
|
||||
...(typeof row.waitingExternal === "boolean" ? { waitingExternal: row.waitingExternal } : {}),
|
||||
...(Array.isArray(row.touchedPaths) ? { touchedPaths: row.touchedPaths.filter((path): path is string => typeof path === "string").slice(0, 100) } : {}),
|
||||
...(asString(row.domain) ? { domain: asString(row.domain) } : {}),
|
||||
...(Array.isArray(row.dependsOn) ? { dependsOn: row.dependsOn.filter((id): id is string => typeof id === "string") } : {}),
|
||||
...(asString(row.lastResult) ? { lastResult: asString(row.lastResult) } : {}),
|
||||
...(asString(row.lastError) ? { lastError: asString(row.lastError) } : {}),
|
||||
...(Array.isArray(row.completionEvidence) ? { completionEvidence: row.completionEvidence.filter((value): value is string => typeof value === "string").slice(0, 50) } : {}),
|
||||
...(asString(row.supersededBy) ? { supersededBy: asString(row.supersededBy) } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePhase = (value: unknown): AutonomyRoadmapPhase | undefined => {
|
||||
const row = asRecord(value);
|
||||
if (!asString(row.id) || !asString(row.title)) return undefined;
|
||||
const statuses = ["pending", "active", "brainstorming", "executing", "validating", "completed", "blocked"];
|
||||
return {
|
||||
id: asString(row.id),
|
||||
title: asString(row.title),
|
||||
objective: asString(row.objective, asString(row.title)),
|
||||
status: statuses.includes(asString(row.status)) ? asString(row.status) as RoadmapPhaseStatus : "pending",
|
||||
brainstormIterations: asInt(row.brainstormIterations, 0, 0, 100),
|
||||
brainstormConverged: asBoolean(row.brainstormConverged, false),
|
||||
validationFailures: asInt(row.validationFailures, 0, 0, 100),
|
||||
items: Array.isArray(row.items) ? row.items.map(normalizeItem).filter((item): item is AutonomyRoadmapItem => Boolean(item)) : [],
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeCompletedLease = (value: unknown): AutonomyCompletedLease | undefined => {
|
||||
const row = asRecord(value);
|
||||
const outcome = asString(row.outcome);
|
||||
if (!asString(row.token) || !["success", "failed", "blocked"].includes(outcome)) return undefined;
|
||||
return {
|
||||
token: asString(row.token),
|
||||
outcome: outcome as AutonomyWorkOutcome,
|
||||
summary: asString(row.summary),
|
||||
addedTasks: Array.isArray(row.addedTasks) ? row.addedTasks.filter((item): item is string => typeof item === "string") : [],
|
||||
completedAt: asString(row.completedAt, nowIso()),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeRun = (value: unknown): AutonomyRun | undefined => {
|
||||
const row = asRecord(value);
|
||||
if (!asString(row.id) || !asString(row.actor) || !asString(row.startedAt)) return undefined;
|
||||
return {
|
||||
id: asString(row.id),
|
||||
actor: asString(row.actor),
|
||||
startedAt: asString(row.startedAt),
|
||||
reportedWorkItems: asInt(row.reportedWorkItems, 0, 0, 10_000),
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeAutonomyState = (raw: unknown, actor = "system"): AutonomyState => {
|
||||
const row = asRecord(raw);
|
||||
if (!Object.keys(row).length) return defaultState(actor);
|
||||
const roadmapRow = asRecord(row.roadmap);
|
||||
const phases = Array.isArray(roadmapRow.phases) ? roadmapRow.phases.map(normalizePhase).filter((phase): phase is AutonomyRoadmapPhase => Boolean(phase)) : [];
|
||||
const roadmap: AutonomyRoadmap | undefined = asString(roadmapRow.id) && phases.length ? {
|
||||
id: asString(roadmapRow.id), title: asString(roadmapRow.title, "Roadmap"), sourceMarkdown: asString(roadmapRow.sourceMarkdown),
|
||||
status: ["active", "completed", "blocked"].includes(asString(roadmapRow.status)) ? asString(roadmapRow.status) as AutonomyRoadmap["status"] : "active",
|
||||
currentPhaseIndex: asInt(roadmapRow.currentPhaseIndex, 0, 0, Math.max(0, phases.length - 1)), phases,
|
||||
importedAt: asString(roadmapRow.importedAt, nowIso()), ...(asString(roadmapRow.completedAt) ? { completedAt: asString(roadmapRow.completedAt) } : {}),
|
||||
} : undefined;
|
||||
const leaseRow = asRecord(row.activeLease);
|
||||
const leaseKind = asString(leaseRow.kind);
|
||||
const activeLease: AutonomyLease | undefined = asString(leaseRow.token) && asString(leaseRow.phaseId) && ["brainstorm", "implement", "validate_phase"].includes(leaseKind) ? {
|
||||
token: asString(leaseRow.token), kind: leaseKind as AutonomyWorkKind, actor: asString(leaseRow.actor),
|
||||
...(asString(leaseRow.runId) ? { runId: asString(leaseRow.runId) } : {}),
|
||||
roadmapId: asString(leaseRow.roadmapId), phaseId: asString(leaseRow.phaseId), ...(asString(leaseRow.itemId) ? { itemId: asString(leaseRow.itemId) } : {}),
|
||||
createdAt: asString(leaseRow.createdAt), expiresAt: asString(leaseRow.expiresAt),
|
||||
} : undefined;
|
||||
const blockerRow = asRecord(row.blocker);
|
||||
const enabled = asBoolean(row.enabled, false);
|
||||
const blockerKind = recoverStoredBlockerKind(asString(blockerRow.reason), asString(blockerRow.kind));
|
||||
const blockedAt = Date.parse(asString(blockerRow.at));
|
||||
const recoveryStage = ["retry", "cooldown", "alternate_recovery", "escalated"].includes(asString(blockerRow.recoveryStage)) ? asString(blockerRow.recoveryStage) as NonNullable<AutonomyState["blocker"]>["recoveryStage"] : undefined;
|
||||
const blocker = asString(blockerRow.reason) ? {
|
||||
at: asString(blockerRow.at, nowIso()),
|
||||
reason: asString(blockerRow.reason),
|
||||
kind: blockerKind,
|
||||
...(blockerKind === "retryable" ? { retryAt: asString(blockerRow.retryAt) || new Date((Number.isFinite(blockedAt) ? blockedAt : Date.now()) + 15 * 60_000).toISOString() } : {}),
|
||||
...(asString(blockerRow.phaseId) ? { phaseId: asString(blockerRow.phaseId) } : {}),
|
||||
...(asString(blockerRow.itemId) ? { itemId: asString(blockerRow.itemId) } : {}),
|
||||
...(recoveryStage ? { recoveryStage } : {}),
|
||||
...(typeof blockerRow.recoveryAttempts === "number" ? { recoveryAttempts: asInt(blockerRow.recoveryAttempts, 0, 0, 100) } : {}),
|
||||
...(asString(blockerRow.recoveryStartedAt) ? { recoveryStartedAt: asString(blockerRow.recoveryStartedAt) } : {}),
|
||||
} : undefined;
|
||||
const status: AutonomyStatus = !enabled
|
||||
? "off"
|
||||
: !roadmap
|
||||
? "waiting_roadmap"
|
||||
: blocker || roadmap.status === "blocked"
|
||||
? "blocked"
|
||||
: roadmap.status === "completed"
|
||||
? "completed"
|
||||
: "running";
|
||||
const leaseMatchesRoadmap = Boolean(enabled && activeLease && roadmap && activeLease.roadmapId === roadmap.id && roadmap.phases.some((phase) => phase.id === activeLease.phaseId && (!activeLease.itemId || phase.items.some((item) => item.id === activeLease.itemId))));
|
||||
return {
|
||||
version: 1,
|
||||
enabled, status,
|
||||
profile: ["conservative", "normal", "autonomous", "custom"].includes(asString(row.profile)) ? asString(row.profile) as AutonomyProfile : DEFAULTS.profile,
|
||||
autoBrainstorm: asBoolean(row.autoBrainstorm, DEFAULTS.autoBrainstorm),
|
||||
maxBrainstormIterations: asInt(row.maxBrainstormIterations, DEFAULTS.maxBrainstormIterations, 1, 10),
|
||||
maxConsecutiveFailures: asInt(row.maxConsecutiveFailures, DEFAULTS.maxConsecutiveFailures, 1, 10),
|
||||
leaseMinutes: asInt(row.leaseMinutes, DEFAULTS.leaseMinutes, 5, 120),
|
||||
maxWorkItemsPerRun: asInt(row.maxWorkItemsPerRun, DEFAULTS.maxWorkItemsPerRun, 1, 20),
|
||||
maxRunMinutes: asInt(row.maxRunMinutes, DEFAULTS.maxRunMinutes, 5, 55),
|
||||
pauseAfterCurrent: asBoolean(row.pauseAfterCurrent, false),
|
||||
schedulingPolicy: row.schedulingPolicy === "downstream-impact" ? "downstream-impact" : "balanced",
|
||||
maxRecoveryMinutes: asInt(row.maxRecoveryMinutes, DEFAULTS.maxRecoveryMinutes, 15, 1_440),
|
||||
recoveryAttempts: asInt(row.recoveryAttempts, 0, 0, 100),
|
||||
...(roadmap ? { roadmap } : {}), ...(leaseMatchesRoadmap && activeLease ? { activeLease } : {}),
|
||||
...(blocker ? { blocker } : {}),
|
||||
runs: Array.isArray(row.runs) ? row.runs.map(normalizeRun).filter((entry): entry is AutonomyRun => Boolean(entry)).slice(-50) : [],
|
||||
completedLeases: Array.isArray(row.completedLeases) ? row.completedLeases.map(normalizeCompletedLease).filter((entry): entry is AutonomyCompletedLease => Boolean(entry)).slice(-50) : [],
|
||||
events: Array.isArray(row.events) ? row.events.map((entry) => {
|
||||
const e = asRecord(entry); return { id: asString(e.id, randomUUID()), at: asString(e.at, nowIso()), type: asString(e.type, "event"), actor: asString(e.actor, "system"), summary: asString(e.summary) };
|
||||
}).slice(-100) : [],
|
||||
updatedAt: asString(row.updatedAt, nowIso()), updatedBy: asString(row.updatedBy, actor),
|
||||
};
|
||||
};
|
||||
|
||||
const getProjectRow = async (projectIdOrSlug: string) => {
|
||||
const project = await prisma.project.findFirst({ where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] }, include: { workspaceState: true } });
|
||||
if (!project) throw new Error("PROJECT_NOT_FOUND");
|
||||
return project;
|
||||
};
|
||||
|
||||
export type AutonomyProjectRow = Awaited<ReturnType<typeof getProjectRow>>;
|
||||
export const getAutonomyProject = getProjectRow;
|
||||
|
||||
const readState = (project: Awaited<ReturnType<typeof getProjectRow>>, actor = "system") => {
|
||||
const notes = asRecord(project.workspaceState?.notesJson);
|
||||
return normalizeAutonomyState(notes.autonomy, actor);
|
||||
};
|
||||
export const readAutonomyState = readState;
|
||||
|
||||
export const releaseActiveAutonomyLease = (state: AutonomyState, actor: string) => {
|
||||
if (!state.activeLease || !state.roadmap) return state;
|
||||
const next = structuredClone(state) as AutonomyState;
|
||||
const lease = next.activeLease!;
|
||||
const phase = next.roadmap!.phases.find((entry) => entry.id === lease.phaseId);
|
||||
const item = lease.itemId ? phase?.items.find((entry) => entry.id === lease.itemId) : undefined;
|
||||
if (item?.status === "in_progress") item.status = "pending";
|
||||
if (phase && !["completed", "blocked"].includes(phase.status)) phase.status = "active";
|
||||
delete next.activeLease;
|
||||
return appendEvent(next, event("autonomy.lease.released", actor, `Released ${lease.kind} lease because Auto Mode was disabled.`));
|
||||
};
|
||||
|
||||
// Update only autonomy with a monotonic CAS version; preserve unrelated notes.
|
||||
const saveState = async (project: Awaited<ReturnType<typeof getProjectRow>>, state: AutonomyState, actor: string) => {
|
||||
const storedAutonomy = asRecord(asRecord(project.workspaceState?.notesJson).autonomy);
|
||||
const storedUpdatedAt = asString(storedAutonomy.updatedAt);
|
||||
const priorTimestamp = Date.parse(storedUpdatedAt);
|
||||
const savedAt = new Date(Math.max(Date.now(), Number.isFinite(priorTimestamp) ? priorTimestamp + 1 : 0));
|
||||
const next: AutonomyState = { ...state, updatedAt: savedAt.toISOString(), updatedBy: actor };
|
||||
const serialized = JSON.stringify(next);
|
||||
|
||||
if (project.workspaceState) {
|
||||
const updated = storedUpdatedAt
|
||||
? await prisma.$executeRaw`
|
||||
UPDATE "WorkspaceState"
|
||||
SET
|
||||
"notesJson" = jsonb_set(COALESCE("notesJson"::jsonb, '{}'::jsonb), '{autonomy}', ${serialized}::jsonb, true),
|
||||
"updatedBy" = ${actor},
|
||||
"sourceType" = 'autonomy',
|
||||
"updatedAt" = ${savedAt}
|
||||
WHERE "projectId" = ${project.id}
|
||||
AND "notesJson"->'autonomy'->>'updatedAt' = ${storedUpdatedAt}
|
||||
`
|
||||
: await prisma.$executeRaw`
|
||||
UPDATE "WorkspaceState"
|
||||
SET
|
||||
"notesJson" = jsonb_set(COALESCE("notesJson"::jsonb, '{}'::jsonb), '{autonomy}', ${serialized}::jsonb, true),
|
||||
"updatedBy" = ${actor},
|
||||
"sourceType" = 'autonomy',
|
||||
"updatedAt" = ${savedAt}
|
||||
WHERE "projectId" = ${project.id}
|
||||
AND COALESCE("notesJson"->'autonomy'->>'updatedAt', '') = ''
|
||||
`;
|
||||
if (updated !== 1) throw new Error("AUTO_MODE_CONFLICT");
|
||||
} else {
|
||||
try {
|
||||
await prisma.workspaceState.create({
|
||||
data: { projectId: project.id, primaryObjective: project.description, activeSection: "workspace", notesJson: json({ autonomy: next }), createdBy: actor, updatedBy: actor, sourceType: "autonomy", updatedAt: savedAt },
|
||||
});
|
||||
} catch (error) {
|
||||
if (asString(asRecord(error).code) === "P2002") throw new Error("AUTO_MODE_CONFLICT", { cause: error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
};
|
||||
export const saveAutonomyState = saveState;
|
||||
export const appendAutonomyEvent = appendEvent;
|
||||
export const createAutonomyEvent = event;
|
||||
|
||||
const assignmentTitle = (input: EnqueueAutonomyTaskInput) => {
|
||||
const explicit = input.title?.trim();
|
||||
if (explicit) return explicit.slice(0, 200);
|
||||
const firstLine = input.instruction.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "Nieuwe ProjectBrain-opdracht";
|
||||
return firstLine.slice(0, 160);
|
||||
};
|
||||
|
||||
export const enqueueAutonomyTaskInState = (current: AutonomyState, input: EnqueueAutonomyTaskInput, actor = "owner") => {
|
||||
const next = structuredClone(current) as AutonomyState;
|
||||
const createdAt = nowIso();
|
||||
const task: AutonomyRoadmapItem = {
|
||||
id: `assignment-${randomUUID()}`,
|
||||
title: assignmentTitle(input),
|
||||
instruction: input.instruction.trim(),
|
||||
source: "user_assignment",
|
||||
createdAt,
|
||||
status: "pending",
|
||||
attempts: 0,
|
||||
};
|
||||
const createAssignmentPhase = (): AutonomyRoadmapPhase => ({
|
||||
id: `projectbrain-opdrachten-${randomUUID()}`,
|
||||
title: "ProjectBrain-opdrachten",
|
||||
objective: "Werk de opdrachten uit de interne ProjectBrain-takenlijst veilig, controleerbaar en in volgorde af.",
|
||||
status: "active",
|
||||
brainstormIterations: 0,
|
||||
brainstormConverged: false,
|
||||
validationFailures: 0,
|
||||
items: [task],
|
||||
});
|
||||
|
||||
if (!next.roadmap || next.roadmap.status === "completed") {
|
||||
next.roadmap = {
|
||||
id: `roadmap-${randomUUID()}`,
|
||||
title: "ProjectBrain-opdrachten",
|
||||
sourceMarkdown: `# ProjectBrain-opdrachten\n\n## Opdrachten\n- [ ] ${task.title}`,
|
||||
status: "active",
|
||||
currentPhaseIndex: 0,
|
||||
phases: [createAssignmentPhase()],
|
||||
importedAt: createdAt,
|
||||
};
|
||||
delete next.blocker;
|
||||
delete next.activeLease;
|
||||
} else {
|
||||
const currentPhase = next.roadmap.phases[next.roadmap.currentPhaseIndex];
|
||||
if (currentPhase && currentPhase.status !== "completed") {
|
||||
currentPhase.items.unshift(task);
|
||||
if (!["blocked", "executing", "brainstorming", "validating"].includes(currentPhase.status)) currentPhase.status = "active";
|
||||
} else {
|
||||
const phase = createAssignmentPhase();
|
||||
next.roadmap.phases.splice(Math.min(next.roadmap.currentPhaseIndex + 1, next.roadmap.phases.length), 0, phase);
|
||||
}
|
||||
next.roadmap.sourceMarkdown = `${next.roadmap.sourceMarkdown.trim()}\n- [ ] ${task.title}`.trim();
|
||||
}
|
||||
|
||||
if (next.enabled && next.roadmap.status !== "blocked" && !next.blocker) next.status = "running";
|
||||
else if (!next.enabled) next.status = "off";
|
||||
const state = appendEvent(next, event("autonomy.task.enqueued", actor, `Queued ProjectBrain assignment '${task.title}' with user priority.`));
|
||||
return { state, task };
|
||||
};
|
||||
|
||||
const expireLeaseIfNeeded = (state: AutonomyState) => {
|
||||
if (!state.activeLease || new Date(state.activeLease.expiresAt).getTime() > Date.now()) return state;
|
||||
const next = structuredClone(state) as AutonomyState;
|
||||
const lease = next.activeLease!;
|
||||
const phase = next.roadmap?.phases.find((entry) => entry.id === lease.phaseId);
|
||||
const item = lease.itemId ? phase?.items.find((entry) => entry.id === lease.itemId) : undefined;
|
||||
if (item?.status === "in_progress") item.status = "pending";
|
||||
if (phase && !["completed", "blocked"].includes(phase.status)) phase.status = "active";
|
||||
delete next.activeLease;
|
||||
return appendEvent(next, event("autonomy.lease.expired", "system", `Expired ${lease.kind} lease for ${phase?.title ?? lease.phaseId}.`));
|
||||
};
|
||||
|
||||
export const retryAutonomyBlockerIfDue = (state: AutonomyState, now = Date.now()) => {
|
||||
if (!state.blocker || state.blocker.kind !== "retryable" || state.blocker.recoveryStage === "escalated" || !state.blocker.retryAt || Date.parse(state.blocker.retryAt) > now || !state.roadmap) return state;
|
||||
const next = structuredClone(state) as AutonomyState;
|
||||
const blocker = next.blocker!;
|
||||
const recoveryStartedAt = Date.parse(blocker.recoveryStartedAt ?? next.recoveryStartedAt ?? blocker.at);
|
||||
const recoveryLimit = Math.max(15, next.maxRecoveryMinutes ?? DEFAULTS.maxRecoveryMinutes) * 60_000;
|
||||
if (Number.isFinite(recoveryStartedAt) && now - recoveryStartedAt >= recoveryLimit) {
|
||||
const phase = blocker.phaseId ? next.roadmap!.phases.find((entry) => entry.id === blocker.phaseId) : undefined;
|
||||
if (phase) phase.status = "blocked";
|
||||
next.roadmap!.status = "blocked";
|
||||
next.status = "blocked";
|
||||
next.blocker = {
|
||||
...blocker,
|
||||
recoveryStage: "escalated",
|
||||
recoveryAttempts: Math.max(blocker.recoveryAttempts ?? 0, next.recoveryAttempts ?? 0),
|
||||
recoveryStartedAt: new Date(recoveryStartedAt).toISOString(),
|
||||
reason: `${blocker.reason} Automatic recovery exceeded its wall-clock limit and needs escalation.`,
|
||||
};
|
||||
delete next.blocker.retryAt;
|
||||
return appendEvent(next, event("autonomy.recovery.escalated", "system", "Automatic recovery exceeded its wall-clock limit."));
|
||||
}
|
||||
const phase = blocker.phaseId
|
||||
? next.roadmap!.phases.find((entry) => entry.id === blocker.phaseId)
|
||||
: next.roadmap!.phases[next.roadmap!.currentPhaseIndex];
|
||||
const item = blocker.itemId ? phase?.items.find((entry) => entry.id === blocker.itemId) : undefined;
|
||||
if (item?.status === "blocked") item.status = "pending";
|
||||
if (phase?.status === "blocked") phase.status = "active";
|
||||
next.roadmap!.status = "active";
|
||||
next.status = next.enabled ? "running" : "off";
|
||||
delete next.blocker;
|
||||
delete next.activeLease;
|
||||
return appendEvent(next, event("autonomy.blocker.retry_ready", "system", "Retryable blocker cooldown elapsed; work is claimable again."));
|
||||
};
|
||||
|
||||
export const evaluateAutonomyRunBudget = (state: AutonomyState, actor: string, runId: string, now = Date.now()) => {
|
||||
const run = state.runs.find((entry) => entry.id === runId);
|
||||
if (run && run.actor !== actor) return { allowed: false, reason: "owner_mismatch" as const, run };
|
||||
if (!run) return { allowed: true, reason: undefined, run: { id: runId, actor, startedAt: new Date(now).toISOString(), reportedWorkItems: 0 } satisfies AutonomyRun };
|
||||
if (run.reportedWorkItems >= state.maxWorkItemsPerRun) return { allowed: false, reason: "item_limit" as const, run };
|
||||
if (Date.parse(run.startedAt) + state.maxRunMinutes * 60_000 <= now) return { allowed: false, reason: "time_limit" as const, run };
|
||||
return { allowed: true, reason: undefined, run };
|
||||
};
|
||||
|
||||
export const getAutonomyState = async (projectIdOrSlug: string) => {
|
||||
const project = await getProjectRow(projectIdOrSlug);
|
||||
const original = readState(project);
|
||||
const state = retryAutonomyBlockerIfDue(expireLeaseIfNeeded(original));
|
||||
if (original.activeLease?.token === state.activeLease?.token && original.blocker?.retryAt === state.blocker?.retryAt) return state;
|
||||
return saveState(project, state, "system");
|
||||
};
|
||||
|
||||
export const configureAutonomy = async (projectIdOrSlug: string, patch: Partial<Pick<AutonomyState, "enabled" | "profile" | "autoBrainstorm" | "maxBrainstormIterations" | "maxConsecutiveFailures" | "leaseMinutes" | "maxWorkItemsPerRun" | "maxRunMinutes" | "maxRecoveryMinutes" | "pauseAfterCurrent" | "schedulingPolicy">>, actor = "owner") => {
|
||||
const project = await getProjectRow(projectIdOrSlug);
|
||||
let state = retryAutonomyBlockerIfDue(expireLeaseIfNeeded(readState(project, actor)));
|
||||
const requestedProfile = patch.profile && Object.hasOwn(autonomyProfileSettings, patch.profile) ? patch.profile : undefined;
|
||||
const selectedProfileSettings = requestedProfile ? autonomyProfileSettings[requestedProfile] : undefined;
|
||||
state = {
|
||||
...state,
|
||||
...(requestedProfile ? { profile: requestedProfile } : {}),
|
||||
...(selectedProfileSettings ?? {}),
|
||||
...(typeof patch.autoBrainstorm === "boolean" ? { autoBrainstorm: patch.autoBrainstorm } : {}),
|
||||
...(typeof patch.maxBrainstormIterations === "number" ? { maxBrainstormIterations: Math.max(1, Math.min(10, Math.trunc(patch.maxBrainstormIterations))) } : {}),
|
||||
...(typeof patch.maxConsecutiveFailures === "number" ? { maxConsecutiveFailures: Math.max(1, Math.min(10, Math.trunc(patch.maxConsecutiveFailures))) } : {}),
|
||||
...(typeof patch.leaseMinutes === "number" ? { leaseMinutes: Math.max(5, Math.min(120, Math.trunc(patch.leaseMinutes))) } : {}),
|
||||
...(typeof patch.maxWorkItemsPerRun === "number" ? { maxWorkItemsPerRun: Math.max(1, Math.min(20, Math.trunc(patch.maxWorkItemsPerRun))) } : {}),
|
||||
...(typeof patch.maxRunMinutes === "number" ? { maxRunMinutes: Math.max(5, Math.min(55, Math.trunc(patch.maxRunMinutes))) } : {}),
|
||||
...(typeof patch.maxRecoveryMinutes === "number" ? { maxRecoveryMinutes: Math.max(15, Math.min(1_440, Math.trunc(patch.maxRecoveryMinutes))) } : {}),
|
||||
...(typeof patch.pauseAfterCurrent === "boolean" ? { pauseAfterCurrent: patch.pauseAfterCurrent } : {}),
|
||||
...(patch.schedulingPolicy === "balanced" || patch.schedulingPolicy === "downstream-impact" ? { schedulingPolicy: patch.schedulingPolicy } : {}),
|
||||
};
|
||||
if (typeof patch.enabled === "boolean") {
|
||||
if (!patch.enabled && state.activeLease && state.roadmap) state = releaseActiveAutonomyLease(state, actor);
|
||||
state.enabled = patch.enabled;
|
||||
if (!patch.enabled) state.status = "off";
|
||||
else if (state.roadmap?.status === "completed") state.status = "completed";
|
||||
else if (state.blocker || state.roadmap?.status === "blocked") state.status = "blocked";
|
||||
else state.status = state.roadmap ? "running" : "waiting_roadmap";
|
||||
state = appendEvent(state, event(patch.enabled ? "autonomy.enabled" : "autonomy.disabled", actor, patch.enabled ? "Auto Mode enabled." : "Auto Mode disabled."));
|
||||
}
|
||||
return saveState(project, state, actor);
|
||||
};
|
||||
|
||||
export const importAutonomyRoadmap = async (projectIdOrSlug: string, input: { title?: string; markdown: string }, actor = "owner") => {
|
||||
const project = await getProjectRow(projectIdOrSlug);
|
||||
let state = expireLeaseIfNeeded(readState(project, actor));
|
||||
if (state.roadmap?.sourceMarkdown.trim() === input.markdown.trim()) {
|
||||
if (input.title?.trim() && input.title.trim() !== state.roadmap.title) {
|
||||
state.roadmap.title = input.title.trim();
|
||||
return saveState(project, state, actor);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
if (state.activeLease) throw new Error("AUTO_MODE_WORK_ACTIVE");
|
||||
const roadmap = parseRoadmapMarkdown(input.markdown, input.title);
|
||||
state = { ...state, roadmap, activeLease: undefined, blocker: undefined, completedLeases: [], status: state.enabled ? "running" : "off" };
|
||||
state = appendEvent(state, event("autonomy.roadmap.imported", actor, `Imported '${roadmap.title}' with ${roadmap.phases.length} phase(s) and ${roadmap.phases.reduce((count, phase) => count + phase.items.length, 0)} item(s).`));
|
||||
return saveState(project, state, actor);
|
||||
};
|
||||
|
||||
export const resumeAutonomy = async (projectIdOrSlug: string, actor = "owner") => {
|
||||
const project = await getProjectRow(projectIdOrSlug);
|
||||
let state = expireLeaseIfNeeded(readState(project, actor));
|
||||
if (!state.roadmap || state.roadmap.status === "completed") return state;
|
||||
if (state.status !== "blocked" && state.roadmap.status !== "blocked" && !state.blocker) return state;
|
||||
const blockedPhase = state.blocker?.phaseId ? state.roadmap.phases.find((phase) => phase.id === state.blocker?.phaseId) : state.roadmap.phases[state.roadmap.currentPhaseIndex];
|
||||
if (blockedPhase) {
|
||||
if (blockedPhase.status === "blocked") blockedPhase.status = "active";
|
||||
blockedPhase.validationFailures = 0;
|
||||
if (state.blocker?.itemId) {
|
||||
const blockedItem = blockedPhase.items.find((item) => item.id === state.blocker?.itemId);
|
||||
if (blockedItem?.status === "blocked") { blockedItem.status = "pending"; delete blockedItem.lastError; }
|
||||
}
|
||||
}
|
||||
state.roadmap.status = "active";
|
||||
delete state.blocker;
|
||||
delete state.activeLease;
|
||||
state.recoveryAttempts = 0;
|
||||
delete state.recoveryStartedAt;
|
||||
state.status = state.enabled ? "running" : "off";
|
||||
state = appendEvent(state, event("autonomy.resumed", actor, "Auto Mode blocker cleared; roadmap is resumable."));
|
||||
return saveState(project, state, actor);
|
||||
};
|
||||
|
||||
export const canResumeAutonomyLease = (lease: AutonomyLease, actor: string, runId?: string) => {
|
||||
if (lease.actor !== actor) return false;
|
||||
if (lease.runId || runId) return Boolean(lease.runId && runId && lease.runId === runId);
|
||||
return true;
|
||||
};
|
||||
|
||||
export const isAutonomyLeaseOwner = (lease: AutonomyLease, actor: string) => lease.actor === actor;
|
||||
|
||||
const nextLease = (state: AutonomyState, actor: string, kind: AutonomyWorkKind, phase: AutonomyRoadmapPhase, item?: AutonomyRoadmapItem, runId?: string): AutonomyLease => {
|
||||
const createdAt = new Date();
|
||||
return { token: randomUUID(), kind, actor, ...(runId ? { runId } : {}), roadmapId: state.roadmap!.id, phaseId: phase.id, ...(item ? { itemId: item.id } : {}), createdAt: createdAt.toISOString(), expiresAt: new Date(createdAt.getTime() + state.leaseMinutes * 60_000).toISOString() };
|
||||
};
|
||||
export const createAutonomyLease = nextLease;
|
||||
|
||||
const applyRetryableBlocker = (state: AutonomyState, phase: AutonomyRoadmapPhase, summary: string, failureCount: number, item?: AutonomyRoadmapItem, requestedDelay?: number) => {
|
||||
const blockedAt = Date.now();
|
||||
const recoveryStartedAt = state.recoveryStartedAt ?? new Date(blockedAt).toISOString();
|
||||
const recoveryAttempts = (state.recoveryAttempts ?? 0) + 1;
|
||||
const decision = decideRecovery({
|
||||
failureCount,
|
||||
recoveryAttempts,
|
||||
recoveryStartedAt,
|
||||
maxRecoveryMinutes: state.maxRecoveryMinutes ?? DEFAULTS.maxRecoveryMinutes,
|
||||
now: blockedAt,
|
||||
});
|
||||
state.recoveryStartedAt = recoveryStartedAt;
|
||||
state.recoveryAttempts = recoveryAttempts;
|
||||
if (decision.stage === "alternate_recovery") {
|
||||
if (item) { item.status = "pending"; item.lastError = `${summary} Alternate recovery is being attempted.`; }
|
||||
phase.status = "active";
|
||||
state.roadmap!.status = "active";
|
||||
state.status = state.enabled ? "running" : "off";
|
||||
delete state.blocker;
|
||||
return decision;
|
||||
}
|
||||
if (item) { item.status = "blocked"; item.lastError = summary; }
|
||||
phase.status = "blocked";
|
||||
state.roadmap!.status = "blocked";
|
||||
state.status = "blocked";
|
||||
const retryAfterMinutes = decision.exhausted
|
||||
? undefined
|
||||
: Math.max(5, Math.min(1_440, requestedDelay ?? decision.retryAfterMinutes ?? autonomyRetryDelayMinutes(failureCount)));
|
||||
state.blocker = {
|
||||
at: new Date(blockedAt).toISOString(), phaseId: phase.id, ...(item ? { itemId: item.id } : {}), reason: summary,
|
||||
kind: "retryable", ...(retryAfterMinutes ? { retryAt: new Date(blockedAt + retryAfterMinutes * 60_000).toISOString() } : {}),
|
||||
recoveryStage: decision.stage, recoveryAttempts, recoveryStartedAt,
|
||||
};
|
||||
return decision;
|
||||
};
|
||||
|
||||
const applyHumanBlocker = (state: AutonomyState, phase: AutonomyRoadmapPhase, summary: string, item?: AutonomyRoadmapItem) => {
|
||||
if (item) { item.status = "blocked"; item.lastError = summary; }
|
||||
phase.status = "blocked";
|
||||
state.roadmap!.status = "blocked";
|
||||
state.status = "blocked";
|
||||
state.blocker = { at: nowIso(), phaseId: phase.id, ...(item ? { itemId: item.id } : {}), reason: summary, kind: "human" };
|
||||
};
|
||||
|
||||
export const reportAutonomyWork = async (projectIdOrSlug: string, input: AutonomyReportInput, actor = "chatgpt") => {
|
||||
const project = await getProjectRow(projectIdOrSlug);
|
||||
let state = readState(project, actor);
|
||||
const replay = state.completedLeases.find((entry) => entry.token === input.token);
|
||||
if (replay) return { state, addedTasks: replay.addedTasks, replayed: true, runId: undefined };
|
||||
const lease = state.activeLease;
|
||||
if (!lease || lease.token !== input.token) throw new Error("AUTO_MODE_LEASE_STALE");
|
||||
if (!isAutonomyLeaseOwner(lease, actor)) throw new Error("AUTO_MODE_LEASE_OWNER_MISMATCH");
|
||||
if (!state.roadmap || lease.roadmapId !== state.roadmap.id) throw new Error("AUTO_MODE_ROADMAP_CHANGED");
|
||||
const phase = state.roadmap.phases.find((entry) => entry.id === lease.phaseId);
|
||||
if (!phase) throw new Error("AUTO_MODE_PHASE_NOT_FOUND");
|
||||
const item = lease.itemId ? phase.items.find((entry) => entry.id === lease.itemId) : undefined;
|
||||
const deliveryId = input.deliveryId ?? item?.deliveryId;
|
||||
const reconciledGiteaCompletion = item ? hasGiteaAutonomyCompletionEvidence(item.completionEvidence) : false;
|
||||
if (lease.kind === "implement" && input.outcome === "success" && !deliveryId && !reconciledGiteaCompletion) throw new Error("AUTO_MODE_DELIVERY_REQUIRED");
|
||||
if (deliveryId) {
|
||||
const delivery = await getFeatureDelivery(project.slug, deliveryId);
|
||||
if (item?.deliveryId && item.deliveryId !== delivery.id) throw new Error("AUTO_MODE_DELIVERY_ALREADY_LINKED");
|
||||
if (input.outcome === "success" && lease.kind === "implement" && (!['merged', 'verified', 'released'].includes(delivery.spec.status) || !delivery.spec.pullRequest?.mergeCommitSha || !delivery.spec.validationEvidence)) throw new Error("AUTO_MODE_DELIVERY_NOT_MERGED");
|
||||
if (item) item.deliveryId = delivery.id;
|
||||
}
|
||||
if (lease.kind === "validate_phase" && input.outcome === "success") {
|
||||
for (const completed of phase.items.filter((entry) => entry.deliveryId && isAutonomyItemInExecutionScope(entry))) {
|
||||
const delivery = await getFeatureDelivery(project.slug, completed.deliveryId!);
|
||||
if (!["merged", "verified", "released"].includes(delivery.spec.status) || !delivery.spec.validationEvidence || !delivery.spec.pullRequest?.mergeCommitSha) throw new Error("AUTO_MODE_DELIVERY_NOT_MERGED");
|
||||
}
|
||||
}
|
||||
const repairTasks = input.outcome === "success" ? [] : input.discoveredTasks ?? [];
|
||||
const brainstormTasks = input.brainstorm?.nextActions ?? [];
|
||||
const newTasks = [
|
||||
...addDiscoveredTasks(phase, repairTasks, { scope: "committed", source: "recovery" }),
|
||||
...addDiscoveredTasks(phase, brainstormTasks),
|
||||
];
|
||||
const hasRepairs = item && repairTasks.length > 0 && input.outcome !== "success" && classifyAutonomyBlocker(input.summary, input.blockerType) !== "human" && orderAutonomyRepairs(phase, item, repairTasks);
|
||||
|
||||
if (hasRepairs) {
|
||||
item.status = "pending"; item.lastError = input.summary;
|
||||
phase.status = "active"; state.status = "running"; state.roadmap.status = "active";
|
||||
delete state.blocker;
|
||||
} else if (input.outcome === "blocked") {
|
||||
if (!item) phase.validationFailures += 1;
|
||||
const failureCount = Math.max(1, item ? item.attempts : phase.validationFailures);
|
||||
const blockerKind = classifyAutonomyBlocker(input.summary, input.blockerType);
|
||||
if (blockerKind === "human") applyHumanBlocker(state, phase, input.summary, item);
|
||||
else applyRetryableBlocker(state, phase, input.summary, failureCount, item, input.retryAfterMinutes);
|
||||
} else if (lease.kind === "brainstorm") {
|
||||
phase.brainstormIterations += 1;
|
||||
if (input.outcome === "failed") {
|
||||
phase.validationFailures += 1;
|
||||
if (phase.validationFailures >= state.maxConsecutiveFailures) applyRetryableBlocker(state, phase, input.summary, phase.validationFailures, undefined, input.retryAfterMinutes);
|
||||
else phase.status = "active";
|
||||
} else if (newTasks.length && hasOpenAutonomyExecutionItems(phase)) {
|
||||
phase.validationFailures = 0;
|
||||
phase.brainstormConverged = false;
|
||||
phase.status = "executing";
|
||||
} else {
|
||||
phase.validationFailures = 0;
|
||||
phase.brainstormConverged = true;
|
||||
phase.status = "validating";
|
||||
}
|
||||
await createBrainstormForProject(project.slug, {
|
||||
title: `Auto brainstorm · ${phase.title} · round ${phase.brainstormIterations}`,
|
||||
prompt: workPrompt("brainstorm", phase), scope: "roadmap_phase", mode: "autonomy_loop",
|
||||
status: input.outcome === "failed" ? "failed" : hasOpenAutonomyExecutionItems(phase) ? "selected" : "converged", targetEntityId: phase.id, targetEntityType: "roadmap_phase", generatedBy: actor,
|
||||
contextSummary: input.brainstorm?.contextSummary?.trim() || input.summary,
|
||||
ideas: input.brainstorm?.ideas ?? [], clusters: [],
|
||||
outcomes: (input.brainstorm?.outcomes ?? [input.summary]).map((summary, index) => ({ id: `outcome-${index + 1}`, summary })),
|
||||
selectedIdeaIds: input.brainstorm?.selectedIdeaIds ?? [], nextActions: newTasks,
|
||||
});
|
||||
} else if (lease.kind === "implement") {
|
||||
if (!item) throw new Error("AUTO_MODE_ITEM_NOT_FOUND");
|
||||
if (input.outcome === "success") {
|
||||
phase.validationFailures = 0;
|
||||
item.status = "completed";
|
||||
item.lastResult = input.summary;
|
||||
delete item.lastError;
|
||||
if (input.deliveryId) item.deliveryId = input.deliveryId;
|
||||
phase.status = "active";
|
||||
} else {
|
||||
item.lastError = input.summary;
|
||||
if (item.attempts >= state.maxConsecutiveFailures) applyRetryableBlocker(state, phase, input.summary, item.attempts, item, input.retryAfterMinutes);
|
||||
else { item.status = "pending"; phase.status = "active"; }
|
||||
}
|
||||
} else {
|
||||
if (input.outcome === "success" && !hasOpenAutonomyExecutionItems(phase)) {
|
||||
phase.status = "completed";
|
||||
phase.brainstormConverged = true;
|
||||
phase.validationFailures = 0;
|
||||
if (state.roadmap.currentPhaseIndex < state.roadmap.phases.length - 1) {
|
||||
state.roadmap.currentPhaseIndex += 1;
|
||||
const next = state.roadmap.phases[state.roadmap.currentPhaseIndex];
|
||||
if (next.status !== "completed") next.status = "active";
|
||||
} else {
|
||||
state.roadmap.status = "completed";
|
||||
state.roadmap.completedAt = nowIso();
|
||||
state.status = "completed";
|
||||
}
|
||||
} else {
|
||||
phase.validationFailures += 1;
|
||||
phase.brainstormConverged = !hasOpenAutonomyExecutionItems(phase);
|
||||
if (phase.validationFailures >= state.maxConsecutiveFailures && !hasOpenAutonomyExecutionItems(phase)) applyRetryableBlocker(state, phase, input.summary, phase.validationFailures, undefined, input.retryAfterMinutes);
|
||||
else phase.status = hasOpenAutonomyExecutionItems(phase) ? "executing" : "active";
|
||||
}
|
||||
}
|
||||
|
||||
if (input.outcome === "success") {
|
||||
state.recoveryAttempts = 0;
|
||||
delete state.recoveryStartedAt;
|
||||
}
|
||||
delete state.activeLease;
|
||||
if (lease.runId) {
|
||||
const run = state.runs.find((entry) => entry.id === lease.runId);
|
||||
if (run) run.reportedWorkItems += 1;
|
||||
else state.runs = [...state.runs, { id: lease.runId, actor: lease.actor, startedAt: lease.createdAt, reportedWorkItems: 1 }].slice(-50);
|
||||
}
|
||||
state.completedLeases = [...state.completedLeases, { token: lease.token, outcome: input.outcome, summary: input.summary, addedTasks: newTasks, completedAt: nowIso() }].slice(-50);
|
||||
state = appendEvent(state, event(`autonomy.work.${input.outcome}`, actor, `${lease.kind} · ${phase.title}: ${input.summary}${newTasks.length ? ` Added ${newTasks.length} new task(s).` : ""}`));
|
||||
const completedRoadmap = state.roadmap;
|
||||
if (completedRoadmap?.status === "completed") state = appendEvent(state, event("autonomy.roadmap.completed", actor, `Roadmap '${completedRoadmap.title}' completed.`));
|
||||
state = await saveState(project, state, actor);
|
||||
return { state, addedTasks: newTasks, replayed: false, runId: lease.runId };
|
||||
};
|
||||
@@ -0,0 +1,625 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { ApprovalState, EvidenceStrength, ReviewStatus, VerificationState } from "@prisma/client";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import { listContextPacksForProject } from "./contextPacksRepository.js";
|
||||
import { getProjectBySlugOrId } from "./projectsRepository.js";
|
||||
import {
|
||||
createBlueprintImpactReportDto,
|
||||
createBlueprintNodeRunPreviewDto,
|
||||
createBlueprintPlanDto,
|
||||
createBlueprintRepoExportBundleDto,
|
||||
type BlueprintDepthDto,
|
||||
type BlueprintNodeDto,
|
||||
type BlueprintPlanDto,
|
||||
} from "../services/blueprintEngine.js";
|
||||
|
||||
const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
|
||||
const toStringArray = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
|
||||
|
||||
|
||||
type BlueprintDelegate = {
|
||||
create: (args: unknown) => Promise<unknown>;
|
||||
findFirst: (args: unknown) => Promise<unknown>;
|
||||
update: (args: unknown) => Promise<unknown>;
|
||||
updateMany: (args: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type BlueprintPrisma = {
|
||||
blueprintGraph: BlueprintDelegate;
|
||||
blueprintNode: BlueprintDelegate;
|
||||
blueprintNodeRun: BlueprintDelegate;
|
||||
};
|
||||
|
||||
const blueprintDb = prisma as unknown as BlueprintPrisma;
|
||||
|
||||
const findProjectRecord = (projectIdOrSlug: string) => prisma.project.findFirst({
|
||||
where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] },
|
||||
select: { id: true, slug: true, activeContextPackId: true },
|
||||
});
|
||||
|
||||
type BlueprintGraphRecord = {
|
||||
id: string;
|
||||
slug: string | null;
|
||||
readableId: string | null;
|
||||
projectId: string;
|
||||
project?: { slug: string };
|
||||
archetypeId: string;
|
||||
archetypeLabel: string;
|
||||
depth: string;
|
||||
status: string;
|
||||
documentRoot: string;
|
||||
intakeSummary: string;
|
||||
assumptionsJson: Prisma.JsonValue;
|
||||
exportManifestJson: Prisma.JsonValue;
|
||||
recommendedNextActionsJson: Prisma.JsonValue;
|
||||
metadataJson: Prisma.JsonValue | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
nodes?: BlueprintNodeRecord[];
|
||||
};
|
||||
|
||||
type BlueprintNodeRecord = {
|
||||
id: string;
|
||||
templateId: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
cluster: string;
|
||||
status: string;
|
||||
generationOrder: number;
|
||||
requiredDepth: string;
|
||||
dependsOnJson: Prisma.JsonValue;
|
||||
dependencyTitlesJson: Prisma.JsonValue;
|
||||
blockedByJson: Prisma.JsonValue;
|
||||
outputPath: string;
|
||||
purpose: string;
|
||||
requiredContextJson: Prisma.JsonValue;
|
||||
qualityChecksJson: Prisma.JsonValue;
|
||||
producesJson: Prisma.JsonValue;
|
||||
promptPreview: string;
|
||||
contextSelectionJson: Prisma.JsonValue;
|
||||
documentationItemId: string | null;
|
||||
reviewItemId: string | null;
|
||||
latestRunId: string | null;
|
||||
staleReason: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type BlueprintNodeRunRecord = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
graphId: string;
|
||||
nodeId: string;
|
||||
status: string;
|
||||
generatedBy: string;
|
||||
inputSnapshotJson: Prisma.JsonValue;
|
||||
prompt: string;
|
||||
draftMarkdown: string;
|
||||
qualityReportJson: Prisma.JsonValue;
|
||||
documentationItemId: string | null;
|
||||
reviewItemId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
const asContextSelection = (value: Prisma.JsonValue) => isRecord(value) ? value : { selectedContextPackIds: [], candidates: [], decisionSummary: "No context selection metadata stored." };
|
||||
|
||||
const recordToNodeDto = (record: BlueprintNodeRecord, projectSlug: string): BlueprintPlanDto["nodes"][number] => ({
|
||||
id: record.templateId,
|
||||
title: record.title,
|
||||
cluster: record.cluster as BlueprintPlanDto["nodes"][number]["cluster"],
|
||||
requiredDepth: record.requiredDepth as BlueprintDepthDto,
|
||||
dependsOn: toStringArray(record.dependsOnJson),
|
||||
outputPath: record.outputPath,
|
||||
purpose: record.purpose,
|
||||
requiredContext: toStringArray(record.requiredContextJson),
|
||||
qualityChecks: toStringArray(record.qualityChecksJson),
|
||||
produces: toStringArray(record.producesJson),
|
||||
projectId: projectSlug,
|
||||
dependencyTitles: toStringArray(record.dependencyTitlesJson),
|
||||
blockedBy: toStringArray(record.blockedByJson),
|
||||
generationOrder: record.generationOrder,
|
||||
status: record.status,
|
||||
contextSelection: asContextSelection(record.contextSelectionJson) as BlueprintNodeDto["contextSelection"],
|
||||
promptPreview: record.promptPreview,
|
||||
exportReady: record.status !== "blocked",
|
||||
documentationItemId: record.documentationItemId ?? undefined,
|
||||
reviewItemId: record.reviewItemId ?? undefined,
|
||||
latestRunId: record.latestRunId ?? undefined,
|
||||
staleReason: record.staleReason ?? undefined,
|
||||
} as BlueprintPlanDto["nodes"][number]);
|
||||
|
||||
const recordToPlanDto = (record: BlueprintGraphRecord): BlueprintPlanDto => {
|
||||
const projectSlug = record.project?.slug ?? record.projectId;
|
||||
const metadata = isRecord(record.metadataJson) ? record.metadataJson : {};
|
||||
const nodes = (record.nodes ?? []).sort((left, right) => left.generationOrder - right.generationOrder).map((node) => recordToNodeDto(node, projectSlug));
|
||||
const clusterIds = [...new Set(nodes.map((node) => node.cluster))];
|
||||
const clusters = clusterIds.map((clusterId) => {
|
||||
const clusterNodes = nodes.filter((node) => node.cluster === clusterId);
|
||||
return {
|
||||
id: clusterId,
|
||||
label: String(clusterId).split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" "),
|
||||
planned: clusterNodes.length,
|
||||
ready: clusterNodes.filter((node) => node.status !== "blocked").length,
|
||||
blocked: clusterNodes.filter((node) => node.status === "blocked").length,
|
||||
exportPaths: clusterNodes.map((node) => node.outputPath),
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: record.slug ?? record.readableId ?? record.id,
|
||||
projectId: projectSlug,
|
||||
archetype: {
|
||||
id: record.archetypeId as BlueprintPlanDto["archetype"]["id"],
|
||||
label: record.archetypeLabel,
|
||||
summary: typeof metadata.archetypeSummary === "string" ? metadata.archetypeSummary : "Persisted blueprint archetype.",
|
||||
signals: toStringArray(metadata.archetypeSignals),
|
||||
recommendedDepth: (typeof metadata.recommendedDepth === "string" ? metadata.recommendedDepth : record.depth) as BlueprintDepthDto,
|
||||
clusters: toStringArray(metadata.archetypeClusters) as BlueprintPlanDto["archetype"]["clusters"],
|
||||
},
|
||||
depth: record.depth as BlueprintDepthDto,
|
||||
status: record.status as BlueprintPlanDto["status"],
|
||||
generatedAt: record.createdAt.toISOString(),
|
||||
intakeSummary: record.intakeSummary,
|
||||
assumptions: toStringArray(record.assumptionsJson),
|
||||
documentRoot: record.documentRoot,
|
||||
totalNodes: nodes.length,
|
||||
readyNodes: nodes.filter((node) => node.status !== "blocked").length,
|
||||
blockedNodes: nodes.filter((node) => node.status === "blocked").length,
|
||||
clusters,
|
||||
nodes,
|
||||
exportManifest: toStringArray(record.exportManifestJson),
|
||||
recommendedNextActions: toStringArray(record.recommendedNextActionsJson),
|
||||
};
|
||||
};
|
||||
|
||||
export const createAndPersistBlueprintPlan = async (projectIdOrSlug: string, depth?: BlueprintDepthDto): Promise<BlueprintPlanDto | undefined> => {
|
||||
const projectRecord = await findProjectRecord(projectIdOrSlug);
|
||||
if (!projectRecord) return undefined;
|
||||
const project = await getProjectBySlugOrId(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const packs = await listContextPacksForProject(projectIdOrSlug) ?? [];
|
||||
const plan = createBlueprintPlanDto(project, packs, depth);
|
||||
const now = Date.now().toString(36);
|
||||
const slug = `bp-${projectRecord.slug}-${plan.archetype.id}-${plan.depth}-${now}`;
|
||||
|
||||
const graph = await blueprintDb.blueprintGraph.create({
|
||||
data: {
|
||||
projectId: projectRecord.id,
|
||||
slug,
|
||||
readableId: slug,
|
||||
archetypeId: plan.archetype.id,
|
||||
archetypeLabel: plan.archetype.label,
|
||||
depth: plan.depth,
|
||||
status: plan.status,
|
||||
documentRoot: plan.documentRoot,
|
||||
intakeSummary: plan.intakeSummary,
|
||||
assumptionsJson: toJson(plan.assumptions),
|
||||
exportManifestJson: toJson(plan.exportManifest),
|
||||
recommendedNextActionsJson: toJson(plan.recommendedNextActions),
|
||||
metadataJson: toJson({ archetypeSummary: plan.archetype.summary, archetypeSignals: plan.archetype.signals, archetypeClusters: plan.archetype.clusters, recommendedDepth: plan.archetype.recommendedDepth, sourcePlanId: plan.id }),
|
||||
createdBy: "api",
|
||||
updatedBy: "api",
|
||||
sourceType: "blueprint_engine_v52",
|
||||
nodes: {
|
||||
create: plan.nodes.map((node) => ({
|
||||
projectId: projectRecord.id,
|
||||
templateId: node.id,
|
||||
kind: node.id,
|
||||
title: node.title,
|
||||
cluster: node.cluster,
|
||||
status: node.status,
|
||||
generationOrder: node.generationOrder,
|
||||
requiredDepth: node.requiredDepth,
|
||||
dependsOnJson: toJson(node.dependsOn),
|
||||
dependencyTitlesJson: toJson(node.dependencyTitles),
|
||||
blockedByJson: toJson(node.blockedBy),
|
||||
outputPath: node.outputPath,
|
||||
purpose: node.purpose,
|
||||
requiredContextJson: toJson(node.requiredContext),
|
||||
qualityChecksJson: toJson(node.qualityChecks),
|
||||
producesJson: toJson(node.produces),
|
||||
promptPreview: node.promptPreview,
|
||||
contextSelectionJson: toJson(node.contextSelection),
|
||||
createdBy: "api",
|
||||
updatedBy: "api",
|
||||
sourceType: "blueprint_engine_v52",
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { project: { select: { slug: true } }, nodes: true },
|
||||
}) as BlueprintGraphRecord;
|
||||
|
||||
await prisma.operatingLogEntry.create({
|
||||
data: {
|
||||
projectId: projectRecord.id,
|
||||
slug: `log-${projectRecord.slug}-blueprint-plan-${Date.now().toString(36)}`,
|
||||
readableId: `log-${projectRecord.slug}-blueprint-plan-${Date.now().toString(36)}`,
|
||||
type: "blueprint_plan_created",
|
||||
title: "Blueprint plan persisted",
|
||||
summary: `${plan.totalNodes} documentation nodes planned for ${plan.archetype.label} at ${plan.depth} depth.`,
|
||||
linkedEntityIdsJson: toJson([graph.slug ?? graph.id]),
|
||||
source: "blueprint_engine_v52",
|
||||
importance: "normal",
|
||||
payloadJson: toJson({ graphId: graph.slug ?? graph.id, archetypeId: plan.archetype.id, depth: plan.depth, nodes: plan.totalNodes }),
|
||||
},
|
||||
});
|
||||
|
||||
return recordToPlanDto(graph);
|
||||
};
|
||||
|
||||
export const getLatestBlueprintPlan = async (projectIdOrSlug: string): Promise<BlueprintPlanDto | undefined> => {
|
||||
const projectRecord = await findProjectRecord(projectIdOrSlug);
|
||||
if (!projectRecord) return undefined;
|
||||
const graph = await blueprintDb.blueprintGraph.findFirst({
|
||||
where: { projectId: projectRecord.id, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { project: { select: { slug: true } }, nodes: true },
|
||||
}) as BlueprintGraphRecord | null;
|
||||
return graph ? recordToPlanDto(graph) : undefined;
|
||||
};
|
||||
|
||||
export const getBlueprintPlanById = async (projectIdOrSlug: string, graphId: string): Promise<BlueprintPlanDto | undefined> => {
|
||||
const projectRecord = await findProjectRecord(projectIdOrSlug);
|
||||
if (!projectRecord) return undefined;
|
||||
const graph = await blueprintDb.blueprintGraph.findFirst({
|
||||
where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: graphId }, { slug: graphId }, { readableId: graphId }] },
|
||||
include: { project: { select: { slug: true } }, nodes: true },
|
||||
}) as BlueprintGraphRecord | null;
|
||||
return graph ? recordToPlanDto(graph) : undefined;
|
||||
};
|
||||
|
||||
export const runBlueprintNode = async (projectIdOrSlug: string, graphId: string, nodeId: string, options: { createDocumentation?: boolean; sendToReview?: boolean } = {}) => {
|
||||
const projectRecord = await findProjectRecord(projectIdOrSlug);
|
||||
if (!projectRecord) return undefined;
|
||||
const graphRecord = await blueprintDb.blueprintGraph.findFirst({
|
||||
where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: graphId }, { slug: graphId }, { readableId: graphId }] },
|
||||
include: { project: { select: { slug: true } }, nodes: true },
|
||||
}) as BlueprintGraphRecord | null;
|
||||
if (!graphRecord) return undefined;
|
||||
const plan = recordToPlanDto(graphRecord);
|
||||
const node = plan.nodes.find((candidate) => candidate.id === nodeId || candidate.id === nodeId);
|
||||
if (!node || node.status === "blocked") return undefined;
|
||||
const preview = createBlueprintNodeRunPreviewDto(plan, node);
|
||||
|
||||
let documentationItemId: string | undefined;
|
||||
let reviewItemId: string | undefined;
|
||||
|
||||
if (options.createDocumentation) {
|
||||
const slug = `doc-${projectRecord.slug}-${node.id}-${Date.now().toString(36)}`;
|
||||
const doc = await prisma.documentationItem.create({
|
||||
data: {
|
||||
projectId: projectRecord.id,
|
||||
slug,
|
||||
readableId: slug,
|
||||
title: node.title,
|
||||
kind: "blueprint_document",
|
||||
status: options.sendToReview ? "draft_review" : "draft",
|
||||
approvalState: ApprovalState.pending,
|
||||
content: preview.draftMarkdown,
|
||||
metadataJson: toJson({
|
||||
blueprintGraphId: graphRecord.slug ?? graphRecord.id,
|
||||
blueprintNodeId: node.id,
|
||||
outputPath: node.outputPath,
|
||||
linkedFeatureIds: [],
|
||||
primaryContextPackId: node.contextSelection.primaryContextPackId,
|
||||
selectedContextPackIds: node.contextSelection.selectedContextPackIds,
|
||||
contextSelectionSummary: node.contextSelection.decisionSummary,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}),
|
||||
trustJson: toJson({ source: "blueprint node run", confidence: preview.qualityReport.score, evidence: [], lastChecked: new Date().toISOString(), approvalState: "pending" }),
|
||||
provenanceJson: toJson({ createdFrom: "blueprint_node_run", graphId: graphRecord.slug ?? graphRecord.id, nodeId: node.id }),
|
||||
createdBy: "api",
|
||||
updatedBy: "api",
|
||||
sourceType: "blueprint_engine_v52",
|
||||
},
|
||||
});
|
||||
documentationItemId = doc.slug ?? doc.id;
|
||||
}
|
||||
|
||||
if (options.sendToReview) {
|
||||
const slug = `rq-${projectRecord.slug}-blueprint-${node.id}-${Date.now().toString(36)}`;
|
||||
const review = await prisma.reviewQueueItem.create({
|
||||
data: {
|
||||
projectId: projectRecord.id,
|
||||
slug,
|
||||
readableId: slug,
|
||||
type: "documentation_update",
|
||||
title: `Review blueprint draft: ${node.title}`,
|
||||
description: preview.draftMarkdown.slice(0, 4000),
|
||||
priority: "normal",
|
||||
status: ReviewStatus.pending,
|
||||
source: "blueprint_engine_v52",
|
||||
confidence: preview.qualityReport.score,
|
||||
approvalState: ApprovalState.pending,
|
||||
evidenceStrength: EvidenceStrength.medium,
|
||||
verificationState: VerificationState.needs_evidence,
|
||||
suggestedAction: "Review, edit and accept this generated blueprint document before exporting or treating it as project truth.",
|
||||
targetEntityType: "documentation_item",
|
||||
targetEntityId: documentationItemId,
|
||||
payloadJson: toJson({ graphId: graphRecord.slug ?? graphRecord.id, nodeId: node.id, outputPath: node.outputPath, draftMarkdown: preview.draftMarkdown, qualityReport: preview.qualityReport, contextSelection: node.contextSelection }),
|
||||
provenanceJson: toJson({ createdFrom: "blueprint_node_run", graphId: graphRecord.slug ?? graphRecord.id, nodeId: node.id }),
|
||||
createdBy: "api",
|
||||
updatedBy: "api",
|
||||
sourceType: "blueprint_engine_v52",
|
||||
},
|
||||
});
|
||||
reviewItemId = review.slug ?? review.id;
|
||||
}
|
||||
|
||||
const nodeRecord = (graphRecord.nodes ?? []).find((candidate) => candidate.templateId === node.id);
|
||||
if (!nodeRecord) return undefined;
|
||||
|
||||
const run = await blueprintDb.blueprintNodeRun.create({
|
||||
data: {
|
||||
projectId: projectRecord.id,
|
||||
graphId: graphRecord.id,
|
||||
nodeId: nodeRecord.id,
|
||||
status: "needs_review",
|
||||
generatedBy: "deterministic",
|
||||
inputSnapshotJson: toJson(preview.inputSnapshot),
|
||||
prompt: preview.prompt,
|
||||
draftMarkdown: preview.draftMarkdown,
|
||||
qualityReportJson: toJson(preview.qualityReport),
|
||||
documentationItemId,
|
||||
reviewItemId,
|
||||
metadataJson: toJson({ outputPath: node.outputPath, contextSelection: node.contextSelection }),
|
||||
createdBy: "api",
|
||||
updatedBy: "api",
|
||||
sourceType: "blueprint_engine_v52",
|
||||
},
|
||||
}) as BlueprintNodeRunRecord;
|
||||
|
||||
await blueprintDb.blueprintNode.updateMany({
|
||||
where: { graphId: graphRecord.id, templateId: node.id },
|
||||
data: { status: options.sendToReview ? "needs_review" : "pending", latestRunId: run.id, documentationItemId, reviewItemId, updatedBy: "api" },
|
||||
});
|
||||
|
||||
await prisma.operatingLogEntry.create({
|
||||
data: {
|
||||
projectId: projectRecord.id,
|
||||
slug: `log-${projectRecord.slug}-blueprint-run-${Date.now().toString(36)}`,
|
||||
readableId: `log-${projectRecord.slug}-blueprint-run-${Date.now().toString(36)}`,
|
||||
type: "blueprint_node_run_created",
|
||||
title: `Blueprint draft generated: ${node.title}`,
|
||||
summary: `Generated a review-gated blueprint draft for ${node.outputPath}.`,
|
||||
linkedEntityIdsJson: toJson([graphRecord.slug ?? graphRecord.id, node.id, documentationItemId, reviewItemId].filter(Boolean)),
|
||||
source: "blueprint_engine_v52",
|
||||
importance: "normal",
|
||||
payloadJson: toJson({ graphId: graphRecord.slug ?? graphRecord.id, nodeId: node.id, documentationItemId, reviewItemId, qualityScore: preview.qualityReport.score }),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...preview,
|
||||
id: run.id,
|
||||
graphId: graphRecord.slug ?? graphRecord.id,
|
||||
documentationItemId,
|
||||
reviewItemId,
|
||||
generatedAt: run.createdAt.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
export const getBlueprintRepoExportBundle = async (projectIdOrSlug: string, graphId: string, options: { includeDrafts?: boolean } = {}) => {
|
||||
const projectRecord = await findProjectRecord(projectIdOrSlug);
|
||||
if (!projectRecord) return undefined;
|
||||
const graph = await blueprintDb.blueprintGraph.findFirst({
|
||||
where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: graphId }, { slug: graphId }, { readableId: graphId }] },
|
||||
include: { project: { select: { slug: true } }, nodes: true },
|
||||
}) as BlueprintGraphRecord | null;
|
||||
if (!graph) return undefined;
|
||||
const plan = recordToPlanDto(graph);
|
||||
const docIds = (graph.nodes ?? []).map((node) => node.documentationItemId).filter((item): item is string => Boolean(item));
|
||||
const docs = docIds.length
|
||||
? await prisma.documentationItem.findMany({
|
||||
where: { projectId: projectRecord.id, deletedAt: null, OR: docIds.flatMap((id) => [{ id }, { slug: id }, { readableId: id }]) },
|
||||
})
|
||||
: [];
|
||||
const draftByNodeId: Record<string, string> = {};
|
||||
for (const node of graph.nodes ?? []) {
|
||||
const doc = docs.find((candidate) => candidate.id === node.documentationItemId || candidate.slug === node.documentationItemId || candidate.readableId === node.documentationItemId);
|
||||
if (doc?.content) draftByNodeId[node.templateId] = doc.content;
|
||||
}
|
||||
const bundle = createBlueprintRepoExportBundleDto(plan, { includeDrafts: options.includeDrafts, draftByNodeId });
|
||||
await prisma.operatingLogEntry.create({
|
||||
data: {
|
||||
projectId: projectRecord.id,
|
||||
slug: `log-${projectRecord.slug}-blueprint-export-${Date.now().toString(36)}`,
|
||||
readableId: `log-${projectRecord.slug}-blueprint-export-${Date.now().toString(36)}`,
|
||||
type: "blueprint_export_prepared",
|
||||
title: "Blueprint repo export prepared",
|
||||
summary: `${bundle.files.length} blueprint export file(s) prepared; ${bundle.skippedCount} node(s) skipped.`,
|
||||
linkedEntityIdsJson: toJson([graph.slug ?? graph.id, ...bundle.files.map((file) => file.nodeId)]),
|
||||
source: "blueprint_engine_v53",
|
||||
importance: bundle.skippedCount ? "important" : "normal",
|
||||
payloadJson: toJson({ graphId: graph.slug ?? graph.id, includeDrafts: bundle.includeDrafts, files: bundle.files.map((file) => file.path), warnings: bundle.warnings }),
|
||||
},
|
||||
});
|
||||
return bundle;
|
||||
};
|
||||
|
||||
export const getBlueprintImpactReport = async (projectIdOrSlug: string, graphId: string, nodeId: string) => {
|
||||
const plan = await getBlueprintPlanById(projectIdOrSlug, graphId);
|
||||
if (!plan) return undefined;
|
||||
if (!plan.nodes.some((node) => node.id === nodeId)) return undefined;
|
||||
return createBlueprintImpactReportDto(plan, nodeId);
|
||||
};
|
||||
|
||||
export const syncBlueprintReviewAction = async (projectIdOrSlug: string, reviewItemId: string, action: "accept" | "reject") => {
|
||||
const projectRecord = await findProjectRecord(projectIdOrSlug);
|
||||
if (!projectRecord) return undefined;
|
||||
const review = await prisma.reviewQueueItem.findFirst({
|
||||
where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }] },
|
||||
});
|
||||
if (!review || review.source !== "blueprint_engine_v52") return undefined;
|
||||
const payload = isRecord(review.payloadJson) ? review.payloadJson : {};
|
||||
const graphId = typeof payload.graphId === "string" ? payload.graphId : undefined;
|
||||
const nodeId = typeof payload.nodeId === "string" ? payload.nodeId : undefined;
|
||||
if (!graphId || !nodeId) return undefined;
|
||||
|
||||
const graph = await blueprintDb.blueprintGraph.findFirst({
|
||||
where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: graphId }, { slug: graphId }, { readableId: graphId }] },
|
||||
include: { nodes: true },
|
||||
}) as BlueprintGraphRecord | null;
|
||||
if (!graph) return undefined;
|
||||
const node = (graph.nodes ?? []).find((candidate) => candidate.templateId === nodeId || candidate.id === nodeId);
|
||||
if (!node) return undefined;
|
||||
|
||||
const nextStatus = action === "accept" ? "accepted" : "pending";
|
||||
await blueprintDb.blueprintNode.update({
|
||||
where: { id: node.id },
|
||||
data: { status: nextStatus, staleReason: action === "reject" ? "Latest generated draft was rejected in the review queue." : null, updatedBy: "review_queue_sync_v53" },
|
||||
});
|
||||
await blueprintDb.blueprintNodeRun.updateMany({
|
||||
where: { projectId: projectRecord.id, nodeId: node.id, reviewItemId: review.slug ?? review.id, deletedAt: null },
|
||||
data: { status: action === "accept" ? "accepted" : "failed", updatedBy: "review_queue_sync_v53" },
|
||||
});
|
||||
if (node.documentationItemId) {
|
||||
await prisma.documentationItem.updateMany({
|
||||
where: { projectId: projectRecord.id, OR: [{ id: node.documentationItemId }, { slug: node.documentationItemId }, { readableId: node.documentationItemId }] },
|
||||
data: { status: action === "accept" ? "accepted" : "draft_rejected", approvalState: action === "accept" ? ApprovalState.approved : ApprovalState.rejected, updatedBy: "review_queue_sync_v53" },
|
||||
});
|
||||
}
|
||||
|
||||
let staleNodeIds: string[] = [];
|
||||
if (action === "accept") {
|
||||
const plan = recordToPlanDto(graph);
|
||||
const impact = createBlueprintImpactReportDto(plan, node.templateId);
|
||||
staleNodeIds = impact.downstreamNodeIds;
|
||||
if (staleNodeIds.length) {
|
||||
await blueprintDb.blueprintNode.updateMany({
|
||||
where: { graphId: graph.id, templateId: { in: staleNodeIds }, status: { in: ["accepted", "needs_review", "pending", "ready"] } },
|
||||
data: { status: "stale", staleReason: `${node.title} was accepted and may affect this downstream document.`, updatedBy: "staleness_engine_v53" },
|
||||
});
|
||||
await blueprintDb.blueprintGraph.update({ where: { id: graph.id }, data: { status: "partially_stale", updatedBy: "staleness_engine_v53" } });
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.operatingLogEntry.create({
|
||||
data: {
|
||||
projectId: projectRecord.id,
|
||||
slug: `log-${projectRecord.slug}-blueprint-review-sync-${Date.now().toString(36)}`,
|
||||
readableId: `log-${projectRecord.slug}-blueprint-review-sync-${Date.now().toString(36)}`,
|
||||
type: "blueprint_review_sync",
|
||||
title: action === "accept" ? `Blueprint document accepted: ${node.title}` : `Blueprint draft rejected: ${node.title}`,
|
||||
summary: action === "accept" ? `Accepted review item updated the blueprint node and marked ${staleNodeIds.length} downstream node(s) stale.` : "Rejected review item returned the blueprint node to pending.",
|
||||
linkedEntityIdsJson: toJson([graph.slug ?? graph.id, node.templateId, review.slug ?? review.id, ...staleNodeIds]),
|
||||
source: "blueprint_engine_v53",
|
||||
importance: staleNodeIds.length ? "important" : "normal",
|
||||
payloadJson: toJson({ graphId: graph.slug ?? graph.id, nodeId: node.templateId, reviewItemId: review.slug ?? review.id, action, staleNodeIds }),
|
||||
},
|
||||
});
|
||||
|
||||
return { graphId: graph.slug ?? graph.id, nodeId: node.templateId, action, staleNodeIds };
|
||||
};
|
||||
|
||||
|
||||
export const regenerateBlueprintNode = async (
|
||||
projectIdOrSlug: string,
|
||||
graphId: string,
|
||||
nodeId: string,
|
||||
options: { createDocumentation?: boolean; sendToReview?: boolean } = {},
|
||||
) => {
|
||||
const projectRecord = await findProjectRecord(projectIdOrSlug);
|
||||
if (!projectRecord) return undefined;
|
||||
const graph = await blueprintDb.blueprintGraph.findFirst({
|
||||
where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: graphId }, { slug: graphId }, { readableId: graphId }] },
|
||||
include: { project: { select: { slug: true } }, nodes: true },
|
||||
}) as BlueprintGraphRecord | null;
|
||||
if (!graph) return undefined;
|
||||
|
||||
const node = (graph.nodes ?? []).find((candidate) => candidate.templateId === nodeId || candidate.id === nodeId);
|
||||
if (!node) return undefined;
|
||||
const run = await runBlueprintNode(projectIdOrSlug, graph.slug ?? graph.id, node.templateId, {
|
||||
createDocumentation: options.createDocumentation ?? true,
|
||||
sendToReview: options.sendToReview ?? true,
|
||||
});
|
||||
if (!run) return undefined;
|
||||
|
||||
await prisma.operatingLogEntry.create({
|
||||
data: {
|
||||
projectId: projectRecord.id,
|
||||
slug: `log-${projectRecord.slug}-blueprint-node-regenerate-${Date.now().toString(36)}`,
|
||||
readableId: `log-${projectRecord.slug}-blueprint-node-regenerate-${Date.now().toString(36)}`,
|
||||
type: "blueprint_node_regenerated",
|
||||
title: `Blueprint node regenerated: ${node.title}`,
|
||||
summary: `Regenerated ${node.title} into a review-gated draft for ${node.outputPath}.`,
|
||||
linkedEntityIdsJson: toJson([graph.slug ?? graph.id, node.templateId, run.reviewItemId, run.documentationItemId].filter(Boolean)),
|
||||
source: "blueprint_engine_v55",
|
||||
importance: node.status === "stale" ? "important" : "normal",
|
||||
payloadJson: toJson({ graphId: graph.slug ?? graph.id, nodeId: node.templateId, previousStatus: node.status, runId: run.id, reviewItemId: run.reviewItemId, documentationItemId: run.documentationItemId }),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
graphId: graph.slug ?? graph.id,
|
||||
nodeId: node.templateId,
|
||||
previousStatus: node.status,
|
||||
run,
|
||||
message: `Blueprint node '${node.title}' regenerated and sent through review gates.`,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
export const regenerateStaleBlueprintNodes = async (
|
||||
projectIdOrSlug: string,
|
||||
graphId: string,
|
||||
options: { limit?: number; createDocumentation?: boolean; sendToReview?: boolean } = {},
|
||||
) => {
|
||||
const projectRecord = await findProjectRecord(projectIdOrSlug);
|
||||
if (!projectRecord) return undefined;
|
||||
const graph = await blueprintDb.blueprintGraph.findFirst({
|
||||
where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: graphId }, { slug: graphId }, { readableId: graphId }] },
|
||||
include: { project: { select: { slug: true } }, nodes: true },
|
||||
}) as BlueprintGraphRecord | null;
|
||||
if (!graph) return undefined;
|
||||
|
||||
const staleNodes = (graph.nodes ?? [])
|
||||
.filter((node) => node.status === "stale")
|
||||
.sort((left, right) => left.generationOrder - right.generationOrder)
|
||||
.slice(0, Math.max(1, options.limit ?? 10));
|
||||
|
||||
const runs = [];
|
||||
for (const node of staleNodes) {
|
||||
const run = await runBlueprintNode(projectIdOrSlug, graph.slug ?? graph.id, node.templateId, {
|
||||
createDocumentation: options.createDocumentation ?? true,
|
||||
sendToReview: options.sendToReview ?? true,
|
||||
});
|
||||
if (run) runs.push(run);
|
||||
}
|
||||
|
||||
if (runs.length) {
|
||||
await blueprintDb.blueprintGraph.update({
|
||||
where: { id: graph.id },
|
||||
data: { status: "running", updatedBy: "api" },
|
||||
});
|
||||
await prisma.operatingLogEntry.create({
|
||||
data: {
|
||||
projectId: projectRecord.id,
|
||||
slug: `log-${projectRecord.slug}-blueprint-regenerate-${Date.now().toString(36)}`,
|
||||
readableId: `log-${projectRecord.slug}-blueprint-regenerate-${Date.now().toString(36)}`,
|
||||
type: "blueprint_stale_nodes_regenerated",
|
||||
title: "Blueprint stale nodes regenerated",
|
||||
summary: `${runs.length} stale blueprint node(s) were regenerated into review-gated drafts.`,
|
||||
linkedEntityIdsJson: toJson([graph.slug ?? graph.id, ...runs.map((run) => run.nodeId)]),
|
||||
source: "blueprint_engine_v54",
|
||||
importance: "important",
|
||||
payloadJson: toJson({ graphId: graph.slug ?? graph.id, regeneratedNodeIds: runs.map((run) => run.nodeId), remainingStaleCount: Math.max(0, staleNodes.length - runs.length) }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
graphId: graph.slug ?? graph.id,
|
||||
regeneratedCount: runs.length,
|
||||
regeneratedNodeIds: runs.map((run) => run.nodeId),
|
||||
createdReviewItemIds: runs.map((run) => run.reviewItemId).filter(Boolean),
|
||||
createdDocumentationItemIds: runs.map((run) => run.documentationItemId).filter(Boolean),
|
||||
skippedCount: Math.max(0, staleNodes.length - runs.length),
|
||||
message: runs.length
|
||||
? `${runs.length} stale blueprint node(s) regenerated and sent through review gates.`
|
||||
: "No stale blueprint nodes were available for regeneration.",
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,518 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { ApprovalState, EntityType, EvidenceStrength, FeatureLifecycle, FeatureStatus, ReviewStatus, VerificationState } from "@prisma/client";
|
||||
import { markInitiativePlannedFromReview } from "./initiativesRepository.js";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
|
||||
export type BrainstormSessionDto = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
prompt: string;
|
||||
scope: string;
|
||||
mode: string;
|
||||
status: string;
|
||||
targetEntityId?: string;
|
||||
targetEntityType?: string;
|
||||
generatedBy: string;
|
||||
contextSummary: string;
|
||||
ideas: unknown[];
|
||||
clusters: unknown[];
|
||||
outcomes: unknown[];
|
||||
selectedIdeaIds: string[];
|
||||
nextActions: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type BrainstormConversionTarget = "feature" | "documentation" | "context_pack" | "blueprint_node" | "initiative";
|
||||
|
||||
export type BrainstormConversionResultDto = {
|
||||
success: true;
|
||||
message: string;
|
||||
target: BrainstormConversionTarget;
|
||||
brainstorm: BrainstormSessionDto;
|
||||
createdEntityId?: string;
|
||||
reviewItemId?: string;
|
||||
targetRoute?: string;
|
||||
affectedEntities: string[];
|
||||
};
|
||||
|
||||
export type BrainstormReviewSyncResultDto = {
|
||||
applied: boolean;
|
||||
action: "accept" | "reject";
|
||||
reviewItemId: string;
|
||||
brainstormId?: string;
|
||||
acceptedTruthId?: string;
|
||||
affectedEntities: string[];
|
||||
message: string;
|
||||
};
|
||||
|
||||
const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
|
||||
const toArray = (value: Prisma.JsonValue | null | undefined): unknown[] => Array.isArray(value) ? value : [];
|
||||
const toStringArray = (value: Prisma.JsonValue | null | undefined): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
const slugify = (value: string) => value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 56) || "brainstorm";
|
||||
|
||||
const getProject = async (projectIdOrSlug: string) => {
|
||||
const project = await prisma.project.findFirst({ where: { OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }], deletedAt: null }});
|
||||
if (!project) throw new Error(`Project '${projectIdOrSlug}' not found`);
|
||||
return project;
|
||||
};
|
||||
|
||||
type BrainstormRecord = Prisma.BrainstormSessionGetPayload<{ include: { project: { select: { slug: true } } } }>;
|
||||
|
||||
const toDto = (session: BrainstormRecord): BrainstormSessionDto => ({
|
||||
id: session.id,
|
||||
projectId: session.project.slug,
|
||||
title: session.title,
|
||||
prompt: session.prompt,
|
||||
scope: session.scope,
|
||||
mode: session.mode,
|
||||
status: session.status,
|
||||
targetEntityId: session.targetEntityId ?? undefined,
|
||||
targetEntityType: session.targetEntityType ?? undefined,
|
||||
generatedBy: session.generatedBy,
|
||||
contextSummary: session.contextSummary,
|
||||
ideas: toArray(session.ideasJson),
|
||||
clusters: toArray(session.clustersJson),
|
||||
outcomes: toArray(session.outcomesJson),
|
||||
selectedIdeaIds: toStringArray(session.selectedIdeaIdsJson),
|
||||
nextActions: toStringArray(session.nextActionsJson),
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
updatedAt: session.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
|
||||
const getString = (value: unknown, fallback = ""): string => typeof value === "string" ? value : fallback;
|
||||
const getNumber = (value: unknown, fallback = 72): number => typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
|
||||
const getSelectedIdeas = (session: BrainstormSessionDto) => {
|
||||
const selected = new Set(session.selectedIdeaIds);
|
||||
const ideas = session.ideas.filter(isRecord);
|
||||
const selectedIdeas = ideas.filter((idea) => selected.has(getString(idea.id)));
|
||||
return selectedIdeas.length ? selectedIdeas : ideas.slice(0, 3);
|
||||
};
|
||||
|
||||
const getTopIdea = (session: BrainstormSessionDto): Record<string, unknown> | undefined =>
|
||||
[...getSelectedIdeas(session)].sort((left, right) => getNumber(right.score) - getNumber(left.score))[0];
|
||||
|
||||
const averageScore = (session: BrainstormSessionDto): number => {
|
||||
const ideas = session.ideas.filter(isRecord);
|
||||
if (!ideas.length) return 72;
|
||||
return Math.round(ideas.reduce((total, idea) => total + getNumber(idea.score), 0) / ideas.length);
|
||||
};
|
||||
|
||||
const toSelectedIdeaMarkdown = (session: BrainstormSessionDto): string => {
|
||||
const selected = getSelectedIdeas(session);
|
||||
if (!selected.length) return "- No selected ideas yet.";
|
||||
return selected.map((idea) => `- **${getString(idea.title, "Idea")}** (${getNumber(idea.score)}/100): ${getString(idea.description, "No description.")}`).join("\n");
|
||||
};
|
||||
|
||||
const markOutcomeConverted = (session: BrainstormSessionDto, target: BrainstormConversionTarget, targetRoute?: string) => {
|
||||
const outcomeTypeByTarget: Record<BrainstormConversionTarget, string[]> = {
|
||||
feature: ["feature_candidate", "implementation_slice"],
|
||||
documentation: ["documentation_candidate"],
|
||||
context_pack: ["context_pack_seed"],
|
||||
blueprint_node: ["documentation_candidate", "implementation_slice"],
|
||||
initiative: ["initiative_candidate", "implementation_slice"],
|
||||
};
|
||||
const targetTypes = outcomeTypeByTarget[target];
|
||||
return session.outcomes.map((outcome) => {
|
||||
if (!isRecord(outcome)) return outcome;
|
||||
const type = getString(outcome.type);
|
||||
if (!targetTypes.includes(type)) return outcome;
|
||||
return { ...outcome, conversionStatus: "converted", targetRoute };
|
||||
});
|
||||
};
|
||||
|
||||
const findSession = async (projectId: string, brainstormId: string) =>
|
||||
prisma.brainstormSession.findFirst({
|
||||
where: { projectId, deletedAt: null, OR: [{ id: brainstormId }, { slug: brainstormId }, { readableId: brainstormId }] },
|
||||
include: { project: { select: { slug: true } } },
|
||||
});
|
||||
|
||||
export const listBrainstormsForProject = async (projectIdOrSlug: string): Promise<BrainstormSessionDto[]> => {
|
||||
const project = await getProject(projectIdOrSlug);
|
||||
const sessions = await prisma.brainstormSession.findMany({
|
||||
where: { projectId: project.id, deletedAt: null },
|
||||
include: { project: { select: { slug: true }}},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
return sessions.map(toDto);
|
||||
};
|
||||
|
||||
export const createBrainstormForProject = async (projectIdOrSlug: string, input: {
|
||||
title: string;
|
||||
prompt: string;
|
||||
scope: string;
|
||||
mode: string;
|
||||
status?: string;
|
||||
targetEntityId?: string;
|
||||
targetEntityType?: string;
|
||||
generatedBy?: string;
|
||||
contextSummary?: string;
|
||||
ideas?: unknown[];
|
||||
clusters?: unknown[];
|
||||
outcomes?: unknown[];
|
||||
selectedIdeaIds?: string[];
|
||||
nextActions?: string[];
|
||||
}): Promise<BrainstormSessionDto> => {
|
||||
const project = await getProject(projectIdOrSlug);
|
||||
const slugBase = slugify(input.title);
|
||||
const session = await prisma.brainstormSession.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug: `${slugBase}-${Date.now().toString(36)}`,
|
||||
title: input.title,
|
||||
prompt: input.prompt,
|
||||
scope: input.scope,
|
||||
mode: input.mode,
|
||||
status: input.status ?? "exploring",
|
||||
targetEntityId: input.targetEntityId,
|
||||
targetEntityType: input.targetEntityType,
|
||||
generatedBy: input.generatedBy ?? "api",
|
||||
contextSummary: input.contextSummary ?? `${project.name} • ${input.scope}/${input.mode}`,
|
||||
ideasJson: toJson(input.ideas ?? []),
|
||||
clustersJson: toJson(input.clusters ?? []),
|
||||
outcomesJson: toJson(input.outcomes ?? []),
|
||||
selectedIdeaIdsJson: toJson(input.selectedIdeaIds ?? []),
|
||||
nextActionsJson: toJson(input.nextActions ?? []),
|
||||
metadataJson: toJson({ reviewBeforeTruth: true }),
|
||||
},
|
||||
include: { project: { select: { slug: true }}},
|
||||
});
|
||||
return toDto(session);
|
||||
};
|
||||
|
||||
export const updateBrainstormForProject = async (projectIdOrSlug: string, brainstormId: string, input: Partial<{
|
||||
title: string;
|
||||
prompt: string;
|
||||
scope: string;
|
||||
mode: string;
|
||||
status: string;
|
||||
targetEntityId: string;
|
||||
targetEntityType: string;
|
||||
ideas: unknown[];
|
||||
clusters: unknown[];
|
||||
outcomes: unknown[];
|
||||
selectedIdeaIds: string[];
|
||||
nextActions: string[];
|
||||
}>): Promise<BrainstormSessionDto | undefined> => {
|
||||
const project = await getProject(projectIdOrSlug);
|
||||
const existing = await prisma.brainstormSession.findFirst({ where: { projectId: project.id, OR: [{ id: brainstormId }, { slug: brainstormId }, { readableId: brainstormId }], deletedAt: null }});
|
||||
if (!existing) return undefined;
|
||||
const session = await prisma.brainstormSession.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
title: input.title,
|
||||
prompt: input.prompt,
|
||||
scope: input.scope,
|
||||
mode: input.mode,
|
||||
status: input.status,
|
||||
targetEntityId: input.targetEntityId,
|
||||
targetEntityType: input.targetEntityType,
|
||||
ideasJson: input.ideas ? toJson(input.ideas) : undefined,
|
||||
clustersJson: input.clusters ? toJson(input.clusters) : undefined,
|
||||
outcomesJson: input.outcomes ? toJson(input.outcomes) : undefined,
|
||||
selectedIdeaIdsJson: input.selectedIdeaIds ? toJson(input.selectedIdeaIds) : undefined,
|
||||
nextActionsJson: input.nextActions ? toJson(input.nextActions) : undefined,
|
||||
},
|
||||
include: { project: { select: { slug: true }}},
|
||||
});
|
||||
return toDto(session);
|
||||
};
|
||||
|
||||
const createReviewForConversion = async (projectId: string, projectSlug: string, session: BrainstormSessionDto, target: BrainstormConversionTarget, createdEntityId?: string, targetRoute?: string) => {
|
||||
const now = new Date();
|
||||
const readableTarget = target.replace("_", " ");
|
||||
const slug = `rq-brainstorm-${slugify(session.title)}-${target}-${now.getTime()}`;
|
||||
const record = await prisma.reviewQueueItem.create({
|
||||
data: {
|
||||
projectId,
|
||||
slug,
|
||||
readableId: slug,
|
||||
type: target === "feature" ? "feature" : target === "documentation" ? "documentation_update" : target === "context_pack" ? "knowledge" : target === "initiative" ? "initiative" : "documentation_update",
|
||||
title: `Review brainstorm conversion: ${session.title}`,
|
||||
description: `Brainstorm CPU proposes converting '${session.title}' into a ${readableTarget}.\n\nSelected ideas:\n${toSelectedIdeaMarkdown(session)}`,
|
||||
priority: target === "feature" ? "important" : "normal",
|
||||
status: ReviewStatus.pending,
|
||||
source: "brainstorm_cpu",
|
||||
confidence: averageScore(session),
|
||||
approvalState: ApprovalState.pending,
|
||||
evidenceStrength: EvidenceStrength.medium,
|
||||
verificationState: VerificationState.needs_evidence,
|
||||
suggestedAction: `Review the brainstorm output before accepting the ${readableTarget} as project truth.`,
|
||||
createdBy: "api",
|
||||
updatedBy: "api",
|
||||
sourceType: "brainstorm_cpu_conversion",
|
||||
createdEntityType: target === "feature" ? EntityType.feature : target === "documentation" ? EntityType.documentation_item : target === "context_pack" ? EntityType.context_pack : target === "initiative" ? EntityType.initiative : undefined,
|
||||
createdEntityId,
|
||||
targetEntityType: undefined,
|
||||
targetEntityId: session.id,
|
||||
payloadJson: toJson({ brainstormId: session.id, conversionTarget: target, createdEntityId, targetRoute, selectedIdeaIds: session.selectedIdeaIds }),
|
||||
provenanceJson: toJson({ createdFrom: "Brainstorm CPU conversion", projectSlug }),
|
||||
},
|
||||
});
|
||||
return record.slug ?? record.id;
|
||||
};
|
||||
|
||||
export const convertBrainstormForProject = async (
|
||||
projectIdOrSlug: string,
|
||||
brainstormId: string,
|
||||
target: BrainstormConversionTarget,
|
||||
): Promise<BrainstormConversionResultDto | undefined> => {
|
||||
const project = await getProject(projectIdOrSlug);
|
||||
const record = await findSession(project.id, brainstormId);
|
||||
if (!record) return undefined;
|
||||
const session = toDto(record);
|
||||
const topIdea = getTopIdea(session);
|
||||
const score = averageScore(session);
|
||||
const suffix = `${slugify(session.title)}-${Date.now().toString(36)}`;
|
||||
let createdEntityId: string | undefined;
|
||||
let targetRoute: string | undefined;
|
||||
|
||||
if (target === "feature") {
|
||||
const group = await prisma.featureGroup.upsert({
|
||||
where: { projectId_slug: { projectId: project.id, slug: "brainstorm-lab" } },
|
||||
create: { projectId: project.id, slug: "brainstorm-lab", readableId: "brainstorm-lab", name: "Brainstorm Lab", description: "Feature candidates converted from Brainstorm CPU sessions.", focusArea: "Exploration" },
|
||||
update: {},
|
||||
});
|
||||
const feature = await prisma.feature.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
featureGroupId: group.id,
|
||||
slug: `brainstorm-feature-${suffix}`,
|
||||
readableId: `brainstorm-feature-${suffix}`,
|
||||
name: session.title,
|
||||
description: getString(topIdea?.description, session.prompt),
|
||||
status: FeatureStatus.todo,
|
||||
lifecycle: FeatureLifecycle.feature_candidate,
|
||||
intent: `Converted from Brainstorm CPU session ${session.id}. Review before implementation.`,
|
||||
confidence: score,
|
||||
health: 70,
|
||||
approvalState: ApprovalState.pending,
|
||||
evidenceStrength: EvidenceStrength.medium,
|
||||
verificationState: VerificationState.needs_evidence,
|
||||
trustJson: toJson({ source: "brainstorm_cpu", confidence: score, brainstormId: session.id }),
|
||||
provenanceJson: toJson({ sourceBrainstormId: session.id, selectedIdeaIds: session.selectedIdeaIds }),
|
||||
sourceType: "brainstorm_cpu_conversion",
|
||||
},
|
||||
});
|
||||
createdEntityId = feature.slug ?? feature.id;
|
||||
targetRoute = `/projects/${project.slug}/features/${feature.slug ?? feature.id}`;
|
||||
}
|
||||
|
||||
if (target === "documentation") {
|
||||
const doc = await prisma.documentationItem.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug: `brainstorm-doc-${suffix}`,
|
||||
readableId: `brainstorm-doc-${suffix}`,
|
||||
title: `Brainstorm documentation seed: ${session.title}`,
|
||||
kind: "brainstorm_seed",
|
||||
status: "draft",
|
||||
approvalState: ApprovalState.pending,
|
||||
content: `# ${session.title}\n\n${session.prompt}\n\n## Selected ideas\n${toSelectedIdeaMarkdown(session)}\n`,
|
||||
metadataJson: toJson({ brainstormId: session.id, selectedIdeaIds: session.selectedIdeaIds }),
|
||||
provenanceJson: toJson({ source: "brainstorm_cpu_conversion" }),
|
||||
sourceType: "brainstorm_cpu_conversion",
|
||||
},
|
||||
});
|
||||
createdEntityId = doc.slug ?? doc.id;
|
||||
targetRoute = `/projects/${project.slug}/documentation`;
|
||||
}
|
||||
|
||||
if (target === "context_pack") {
|
||||
const pack = await prisma.contextPack.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug: `brainstorm-context-${suffix}`,
|
||||
readableId: `brainstorm-context-${suffix}`,
|
||||
title: `Brainstorm context: ${session.title}`,
|
||||
category: "brainstorm",
|
||||
summary: `Reusable context generated from Brainstorm CPU session '${session.title}'.`,
|
||||
generatedBy: "brainstorm_cpu",
|
||||
confidence: score,
|
||||
approvalState: ApprovalState.pending,
|
||||
qualityJson: toJson({ coverage: score, reviewBeforeTruth: true }),
|
||||
trustJson: toJson({ source: "brainstorm_cpu", brainstormId: session.id }),
|
||||
provenanceJson: toJson({ selectedIdeaIds: session.selectedIdeaIds }),
|
||||
sourceType: "brainstorm_cpu_conversion",
|
||||
sections: {
|
||||
create: [
|
||||
{ projectId: project.id, slug: "selected-ideas", title: "Selected ideas", sectionType: "signals", orderIndex: 1, contentJson: toJson(getSelectedIdeas(session)) },
|
||||
{ projectId: project.id, slug: "outcomes", title: "Outcome candidates", sectionType: "outcomes", orderIndex: 2, contentJson: toJson(session.outcomes) },
|
||||
{ projectId: project.id, slug: "next-actions", title: "Next actions", sectionType: "actions", orderIndex: 3, contentJson: toJson(session.nextActions) },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
createdEntityId = pack.slug ?? pack.id;
|
||||
targetRoute = `/projects/${project.slug}/context-packs`;
|
||||
}
|
||||
|
||||
if (target === "initiative") {
|
||||
const initiative = await prisma.initiative.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug: `brainstorm-initiative-${suffix}`,
|
||||
readableId: `brainstorm-initiative-${suffix}`,
|
||||
title: `Brainstorm initiative: ${session.title}`,
|
||||
intent: `${session.prompt}\n\nSelected ideas:\n${toSelectedIdeaMarkdown(session)}`,
|
||||
status: "proposed",
|
||||
priority: getSelectedIdeas(session).some((idea) => getString(idea.impact) === "high") ? "important" : "normal",
|
||||
confidence: score,
|
||||
sourceBrainstormId: session.id,
|
||||
featureIdsJson: toJson([]),
|
||||
approvalState: ApprovalState.pending,
|
||||
metadataJson: toJson({ sourceBrainstormId: session.id, selectedIdeaIds: session.selectedIdeaIds, reviewBeforeTruth: true }),
|
||||
provenanceJson: toJson({ source: "brainstorm_cpu_conversion" }),
|
||||
},
|
||||
});
|
||||
createdEntityId = initiative.slug ?? initiative.id;
|
||||
targetRoute = `/projects/${project.slug}/initiatives`;
|
||||
}
|
||||
|
||||
if (target === "blueprint_node") {
|
||||
createdEntityId = `blueprint-seed-${suffix}`;
|
||||
targetRoute = `/projects/${project.slug}/blueprint?brainstormId=${session.id}`;
|
||||
}
|
||||
|
||||
const reviewItemId = await createReviewForConversion(project.id, project.slug, session, target, createdEntityId, targetRoute);
|
||||
const updatedSession = await prisma.brainstormSession.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
status: "converted",
|
||||
outcomesJson: toJson(markOutcomeConverted(session, target, targetRoute)),
|
||||
metadataJson: toJson({ reviewBeforeTruth: true, lastConversionTarget: target, lastConvertedEntityId: createdEntityId, lastReviewItemId: reviewItemId }),
|
||||
},
|
||||
include: { project: { select: { slug: true }}},
|
||||
});
|
||||
await prisma.operatingLogEntry.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug: `log-brainstorm-conversion-${suffix}`,
|
||||
readableId: `log-brainstorm-conversion-${suffix}`,
|
||||
type: "brainstorm conversion",
|
||||
title: `Brainstorm converted: ${session.title}`,
|
||||
summary: `Brainstorm CPU converted selected ideas into ${target.replace("_", " ")} and queued review before truth.`,
|
||||
linkedEntityIdsJson: toJson([session.id, reviewItemId, createdEntityId].filter(Boolean)),
|
||||
source: "brainstorm_cpu",
|
||||
importance: target === "feature" ? "important" : "normal",
|
||||
payloadJson: toJson({ target, targetRoute }),
|
||||
provenanceJson: toJson({ sourceBrainstormId: session.id }),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Brainstorm '${session.title}' converted to ${target.replace("_", " ")} candidate and queued for review.`,
|
||||
target,
|
||||
brainstorm: toDto(updatedSession),
|
||||
createdEntityId,
|
||||
reviewItemId,
|
||||
targetRoute,
|
||||
affectedEntities: [session.id, reviewItemId, createdEntityId].filter((item): item is string => Boolean(item)),
|
||||
};
|
||||
};
|
||||
|
||||
export const syncBrainstormReviewAction = async (
|
||||
projectIdOrSlug: string,
|
||||
reviewItemId: string,
|
||||
action: "accept" | "reject",
|
||||
): Promise<BrainstormReviewSyncResultDto | undefined> => {
|
||||
const project = await getProject(projectIdOrSlug);
|
||||
const review = await prisma.reviewQueueItem.findFirst({
|
||||
where: { projectId: project.id, deletedAt: null, OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }] },
|
||||
});
|
||||
if (!review) return undefined;
|
||||
if (review.source !== "brainstorm_cpu" && review.sourceType !== "brainstorm_cpu_conversion") return undefined;
|
||||
const payload = isRecord(review.payloadJson) ? review.payloadJson : {};
|
||||
const brainstormId = getString(payload.brainstormId, review.targetEntityType === EntityType.brainstorm_session ? review.targetEntityId ?? "" : "");
|
||||
const affectedEntities = [review.slug ?? review.id];
|
||||
|
||||
if (brainstormId) {
|
||||
await prisma.brainstormSession.updateMany({
|
||||
where: { projectId: project.id, OR: [{ id: brainstormId }, { slug: brainstormId }, { readableId: brainstormId }] },
|
||||
data: { status: action === "accept" ? "converted" : "shortlisted" },
|
||||
});
|
||||
affectedEntities.push(brainstormId);
|
||||
}
|
||||
|
||||
if (action === "accept" && getString(payload.conversionTarget) === "initiative" && review.createdEntityId) {
|
||||
const plannedInitiativeId = await markInitiativePlannedFromReview(project.id, review.createdEntityId, review.slug ?? review.id);
|
||||
if (plannedInitiativeId) affectedEntities.push(plannedInitiativeId);
|
||||
}
|
||||
|
||||
let acceptedTruthId: string | undefined;
|
||||
if (action === "accept") {
|
||||
const existing = await prisma.acceptedTruthItem.findFirst({ where: { projectId: project.id, sourceReviewItemId: review.id, deletedAt: null } });
|
||||
if (existing) acceptedTruthId = existing.slug ?? existing.id;
|
||||
else {
|
||||
const now = new Date();
|
||||
const slug = `truth-brainstorm-${slugify(review.title)}-${now.getTime()}`;
|
||||
const truth = await prisma.acceptedTruthItem.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug,
|
||||
readableId: slug,
|
||||
sourceReviewItemId: review.id,
|
||||
kind: review.type === "documentation_update" ? "documentation" : review.type,
|
||||
title: review.title,
|
||||
summary: review.description,
|
||||
status: "accepted",
|
||||
source: "brainstorm_cpu",
|
||||
confidence: review.confidence,
|
||||
approvalState: ApprovalState.approved,
|
||||
evidenceStrength: review.evidenceStrength,
|
||||
verificationState: VerificationState.verified,
|
||||
payloadJson: toJson({
|
||||
reviewItemId: review.id,
|
||||
brainstormId,
|
||||
conversionTarget: payload.conversionTarget,
|
||||
createdEntityId: payload.createdEntityId,
|
||||
targetRoute: payload.targetRoute,
|
||||
candidateSummary: review.description,
|
||||
nextAction: action === "accept" ? "Use this accepted brainstorm truth in context packs, roadmap, blueprint docs, or implementation planning." : undefined,
|
||||
}),
|
||||
provenanceJson: toJson({
|
||||
createdFrom: "Brainstorm CPU review acceptance",
|
||||
reviewSource: review.source,
|
||||
reviewSourceType: review.sourceType,
|
||||
reviewTargetEntityType: review.targetEntityType,
|
||||
reviewTargetEntityId: review.targetEntityId,
|
||||
}),
|
||||
verifiedAt: now,
|
||||
verifiedBy: "api",
|
||||
},
|
||||
});
|
||||
acceptedTruthId = truth.slug ?? truth.id;
|
||||
}
|
||||
if (acceptedTruthId) affectedEntities.push(acceptedTruthId);
|
||||
}
|
||||
|
||||
await prisma.operatingLogEntry.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug: `log-brainstorm-review-${slugify(review.title)}-${Date.now()}`,
|
||||
readableId: `log-brainstorm-review-${slugify(review.title)}-${Date.now()}`,
|
||||
type: "brainstorm review sync",
|
||||
title: `Brainstorm review ${action === "accept" ? "accepted" : "rejected"}`,
|
||||
summary: action === "accept" ? "Accepted brainstorm conversion was materialized as accepted truth." : "Rejected brainstorm conversion was returned to shortlist state.",
|
||||
linkedEntityIdsJson: toJson(affectedEntities),
|
||||
source: "brainstorm_cpu",
|
||||
importance: action === "accept" ? "important" : "normal",
|
||||
payloadJson: toJson({ action, brainstormId, acceptedTruthId }),
|
||||
provenanceJson: toJson({ reviewItemId: review.id }),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
applied: true,
|
||||
action,
|
||||
reviewItemId: review.slug ?? review.id,
|
||||
brainstormId,
|
||||
acceptedTruthId,
|
||||
affectedEntities,
|
||||
message: action === "accept" ? "Brainstorm review accepted and materialized as accepted truth." : "Brainstorm review rejected and brainstorm returned to shortlist state.",
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import type {
|
||||
CaptureClassificationDto,
|
||||
CaptureClassificationType,
|
||||
CaptureItemDto,
|
||||
CaptureItemListFilters,
|
||||
CaptureItemStatus,
|
||||
CaptureItemType,
|
||||
CreateCaptureItemRequestDto,
|
||||
} from "../types/api.js";
|
||||
|
||||
type CaptureRecord = Prisma.CaptureItemGetPayload<Record<string, never>>;
|
||||
|
||||
type ProjectRecord = { id: string; slug: string };
|
||||
|
||||
const toIso = (value: Date): string => value.toISOString();
|
||||
|
||||
const findProjectRecord = (projectIdOrSlug: string): Promise<ProjectRecord | null> =>
|
||||
prisma.project.findFirst({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }],
|
||||
},
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
|
||||
const toCaptureDto = (
|
||||
record: CaptureRecord,
|
||||
projectSlug: string,
|
||||
): CaptureItemDto => ({
|
||||
id: record.slug ?? record.readableId ?? record.id,
|
||||
projectId: projectSlug,
|
||||
type: record.type as CaptureItemType,
|
||||
title: record.title,
|
||||
content: record.content,
|
||||
source: record.source,
|
||||
status: record.status as CaptureItemStatus,
|
||||
...(record.classificationType
|
||||
? { classificationType: record.classificationType as CaptureClassificationType }
|
||||
: {}),
|
||||
...(record.confidence !== null ? { confidence: record.confidence } : {}),
|
||||
...(record.evidenceStrength ? { evidenceStrength: record.evidenceStrength } : {}),
|
||||
...(record.suggestedAction ? { suggestedAction: record.suggestedAction } : {}),
|
||||
createdAt: toIso(record.createdAt),
|
||||
updatedAt: toIso(record.updatedAt),
|
||||
...(record.linkedReviewItemId ? { linkedReviewItemId: record.linkedReviewItemId } : {}),
|
||||
});
|
||||
|
||||
const splitSearch = (value: string | undefined): string | undefined =>
|
||||
value?.trim() || undefined;
|
||||
|
||||
const toWhere = (
|
||||
projectId: string,
|
||||
filters: CaptureItemListFilters = {},
|
||||
): Prisma.CaptureItemWhereInput => ({
|
||||
projectId,
|
||||
deletedAt: null,
|
||||
...(filters.status?.length ? { status: { in: filters.status } } : {}),
|
||||
...(filters.type?.length ? { type: { in: filters.type } } : {}),
|
||||
...(filters.source?.length ? { source: { in: filters.source } } : {}),
|
||||
...(filters.confidence !== undefined ? { confidence: filters.confidence } : {}),
|
||||
...(filters.classificationType?.length
|
||||
? { classificationType: { in: filters.classificationType } }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const classifyContent = (
|
||||
item: CaptureItemDto,
|
||||
): Omit<CaptureClassificationDto, "captureItemId"> => {
|
||||
const text = `${item.type} ${item.content}`.toLowerCase();
|
||||
if (text.includes("bug") || text.includes("error")) {
|
||||
return {
|
||||
classificationType: "bug",
|
||||
confidence: 86,
|
||||
evidenceStrength: "medium",
|
||||
suggestedAction: "Send to review as bug candidate.",
|
||||
};
|
||||
}
|
||||
if (text.includes("task") || text.includes("todo")) {
|
||||
return {
|
||||
classificationType: "task",
|
||||
confidence: 80,
|
||||
evidenceStrength: "medium",
|
||||
suggestedAction: "Send to review as task candidate.",
|
||||
};
|
||||
}
|
||||
if (text.includes("decision")) {
|
||||
return {
|
||||
classificationType: "decision",
|
||||
confidence: 82,
|
||||
evidenceStrength: "medium",
|
||||
suggestedAction: "Send to review as decision candidate.",
|
||||
};
|
||||
}
|
||||
if (text.includes("rule") || text.includes("must") || text.includes("do not")) {
|
||||
return {
|
||||
classificationType: "memory_rule",
|
||||
confidence: 90,
|
||||
evidenceStrength: "strong",
|
||||
suggestedAction: "Send to review as memory rule.",
|
||||
};
|
||||
}
|
||||
if (text.includes("docs") || text.includes("documentation")) {
|
||||
return {
|
||||
classificationType: "documentation_update",
|
||||
confidence: 78,
|
||||
evidenceStrength: "weak",
|
||||
suggestedAction: "Send to review as documentation update.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
classificationType: "knowledge",
|
||||
confidence: 72,
|
||||
evidenceStrength: "weak",
|
||||
suggestedAction: "Send to review as knowledge item.",
|
||||
};
|
||||
};
|
||||
|
||||
export const listCaptureItemsFromDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
filters: CaptureItemListFilters,
|
||||
options: { page: number; pageSize: number; cursor?: string; sort?: string },
|
||||
) => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
|
||||
const where = toWhere(project.id, filters);
|
||||
const total = await prisma.captureItem.count({ where });
|
||||
const records = await prisma.captureItem.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: options.sort?.endsWith(":asc") ? "asc" : "desc" },
|
||||
skip: (options.page - 1) * options.pageSize,
|
||||
take: options.pageSize,
|
||||
});
|
||||
|
||||
const pageCount = Math.ceil(total / options.pageSize);
|
||||
return {
|
||||
items: records.map((record) => toCaptureDto(record, project.slug)),
|
||||
total,
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
hasNextPage: options.page < pageCount,
|
||||
hasPreviousPage: options.page > 1,
|
||||
...(options.sort ? { sort: options.sort } : {}),
|
||||
...(options.cursor ? { nextCursor: undefined, cursor: options.cursor } : {}),
|
||||
filters,
|
||||
};
|
||||
};
|
||||
|
||||
export const getCaptureItemFromDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
captureItemId: string,
|
||||
): Promise<CaptureItemDto | undefined> => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
|
||||
const record = await prisma.captureItem.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
OR: [{ id: captureItemId }, { slug: captureItemId }, { readableId: captureItemId }],
|
||||
},
|
||||
});
|
||||
return record ? toCaptureDto(record, project.slug) : undefined;
|
||||
};
|
||||
|
||||
export const createCaptureItemInDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
body: CreateCaptureItemRequestDto,
|
||||
): Promise<CaptureItemDto | undefined> => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
|
||||
const createdAt = new Date();
|
||||
const title = body.content.trim().slice(0, 64) || "Captured item";
|
||||
const slug = `cap-${project.slug}-${createdAt.getTime()}`;
|
||||
const record = await prisma.captureItem.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug,
|
||||
readableId: slug,
|
||||
type: body.type,
|
||||
title,
|
||||
content: body.content.trim(),
|
||||
source: body.source?.trim() || "manual",
|
||||
status: "captured",
|
||||
sourceType: "runtime_api",
|
||||
createdBy: "api",
|
||||
updatedBy: "api",
|
||||
createdAt,
|
||||
metadataJson: {},
|
||||
provenanceJson: { createdFrom: "P5 capture API" },
|
||||
},
|
||||
});
|
||||
|
||||
return toCaptureDto(record, project.slug);
|
||||
};
|
||||
|
||||
export const classifyCaptureItemInDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
captureItemId: string,
|
||||
): Promise<CaptureClassificationDto | undefined | "CAPTURE_ALREADY_IN_REVIEW"> => {
|
||||
const item = await getCaptureItemFromDatabase(projectIdOrSlug, captureItemId);
|
||||
if (!item) return undefined;
|
||||
if (item.status === "in_review") return "CAPTURE_ALREADY_IN_REVIEW";
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
|
||||
const classification = classifyContent(item);
|
||||
const update = await prisma.captureItem.updateMany({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
status: { in: ["captured", "classified"] },
|
||||
linkedReviewItemId: null,
|
||||
OR: [{ id: captureItemId }, { slug: captureItemId }, { readableId: captureItemId }],
|
||||
},
|
||||
data: {
|
||||
status: "classified",
|
||||
classificationType: classification.classificationType,
|
||||
confidence: classification.confidence,
|
||||
evidenceStrength: classification.evidenceStrength,
|
||||
suggestedAction: classification.suggestedAction,
|
||||
updatedBy: "api",
|
||||
},
|
||||
});
|
||||
if (update.count !== 1) return "CAPTURE_ALREADY_IN_REVIEW";
|
||||
|
||||
return {
|
||||
captureItemId,
|
||||
...classification,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,367 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import type {
|
||||
ContextPackDto,
|
||||
ContextPackSummaryDto,
|
||||
CreateContextPackRequestDto,
|
||||
EvidenceRefDto,
|
||||
GeneratePromptRequestDto,
|
||||
GeneratedPromptDto,
|
||||
ProjectDna,
|
||||
} from "../types/api.js";
|
||||
|
||||
const toPublicId = (value: { slug: string | null; readableId: string | null; id: string }) =>
|
||||
value.slug ?? value.readableId ?? value.id;
|
||||
|
||||
const toJson = (value: unknown): Prisma.InputJsonValue =>
|
||||
JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null;
|
||||
|
||||
const asStringArray = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
|
||||
const asEvidenceRefs = (value: unknown): EvidenceRefDto[] =>
|
||||
Array.isArray(value) ? (value as EvidenceRefDto[]) : [];
|
||||
|
||||
type ContextPackMetadata = {
|
||||
dna?: ProjectDna;
|
||||
assumptions?: string[];
|
||||
constraints?: string[];
|
||||
evidence?: EvidenceRefDto[];
|
||||
signals?: string[];
|
||||
featureIds?: string[];
|
||||
bugIds?: string[];
|
||||
taskIds?: string[];
|
||||
decisionIds?: string[];
|
||||
memoryRuleIds?: string[];
|
||||
architectureNotes?: string[];
|
||||
regressions?: string[];
|
||||
risks?: string[];
|
||||
currentFocus?: ContextPackDto["currentFocus"];
|
||||
};
|
||||
|
||||
const toMetadata = (value: Prisma.JsonValue | null): ContextPackMetadata =>
|
||||
isRecord(value) ? (value as ContextPackMetadata) : {};
|
||||
|
||||
const findProjectRecordBySlugOrId = (projectIdOrSlug: string) =>
|
||||
prisma.project.findFirst({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }],
|
||||
},
|
||||
include: {
|
||||
dna: true,
|
||||
},
|
||||
});
|
||||
|
||||
const toProjectDna = (project: NonNullable<Awaited<ReturnType<typeof findProjectRecordBySlugOrId>>>): ProjectDna => ({
|
||||
vision: project.dna?.vision ?? "",
|
||||
goals: asStringArray(project.dna?.goalsJson),
|
||||
nonGoals: asStringArray(project.dna?.nonGoalsJson),
|
||||
targetAudience: project.dna?.targetAudience ?? "",
|
||||
principles: asStringArray(project.dna?.principlesJson),
|
||||
successMetrics: Array.isArray(project.dna?.metricsJson) ? (project.dna?.metricsJson as ProjectDna["successMetrics"]) : [],
|
||||
maturityStage: (project.dna?.maturityStage as ProjectDna["maturityStage"]) ?? "Discovery",
|
||||
revision: project.dna?.revision ?? 1,
|
||||
updatedAt: project.dna?.updatedAt.toISOString() ?? project.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
type DbContextPackRecord = Awaited<ReturnType<typeof prisma.contextPack.findFirst>> & {
|
||||
sections?: Array<{
|
||||
id: string;
|
||||
slug: string | null;
|
||||
title: string;
|
||||
sectionType: string;
|
||||
orderIndex: number;
|
||||
contentJson: Prisma.JsonValue;
|
||||
metadataJson: Prisma.JsonValue | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
const toContextPackDto = async (
|
||||
contextPack: NonNullable<DbContextPackRecord>,
|
||||
): Promise<ContextPackDto> => {
|
||||
const project = await findProjectRecordBySlugOrId(contextPack.projectId);
|
||||
const metadata = toMetadata(contextPack.metadataJson);
|
||||
const quality = isRecord(contextPack.qualityJson) ? (contextPack.qualityJson as ContextPackDto["quality"]) : undefined;
|
||||
const trust = isRecord(contextPack.trustJson)
|
||||
? (contextPack.trustJson as ContextPackDto["trust"])
|
||||
: {
|
||||
source: "database context pack",
|
||||
confidence: contextPack.confidence,
|
||||
evidence: [],
|
||||
lastChecked: contextPack.updatedAt.toISOString(),
|
||||
approvalState: contextPack.approvalState,
|
||||
};
|
||||
|
||||
return {
|
||||
id: toPublicId(contextPack),
|
||||
projectId: project?.slug ?? contextPack.projectId,
|
||||
title: contextPack.title,
|
||||
category: contextPack.category as ContextPackDto["category"],
|
||||
dna: metadata.dna ?? (project ? toProjectDna(project) : {
|
||||
vision: "",
|
||||
goals: [],
|
||||
nonGoals: [],
|
||||
targetAudience: "",
|
||||
principles: [],
|
||||
successMetrics: [],
|
||||
maturityStage: "Discovery",
|
||||
revision: 0,
|
||||
updatedAt: contextPack.updatedAt.toISOString(),
|
||||
}),
|
||||
summary: contextPack.summary,
|
||||
assumptions: metadata.assumptions ?? [],
|
||||
constraints: metadata.constraints ?? [],
|
||||
evidence: metadata.evidence ?? [],
|
||||
signals: metadata.signals ?? [],
|
||||
featureIds: metadata.featureIds,
|
||||
bugIds: metadata.bugIds,
|
||||
taskIds: metadata.taskIds,
|
||||
decisionIds: metadata.decisionIds,
|
||||
memoryRuleIds: metadata.memoryRuleIds,
|
||||
architectureNotes: metadata.architectureNotes,
|
||||
regressions: metadata.regressions,
|
||||
risks: metadata.risks,
|
||||
quality,
|
||||
currentFocus: metadata.currentFocus,
|
||||
trust,
|
||||
generatedBy: contextPack.generatedBy as ContextPackDto["generatedBy"],
|
||||
createdAt: contextPack.createdAt.toISOString(),
|
||||
updatedAt: contextPack.updatedAt.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const projectExists = async (projectIdOrSlug: string): Promise<boolean> =>
|
||||
Boolean(await findProjectRecordBySlugOrId(projectIdOrSlug));
|
||||
|
||||
export const listContextPacksForProject = async (
|
||||
projectIdOrSlug: string,
|
||||
): Promise<ContextPackDto[] | undefined> => {
|
||||
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
|
||||
const contextPacks = await prisma.contextPack.findMany({
|
||||
where: { projectId: project.id, deletedAt: null },
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
include: { sections: { orderBy: { orderIndex: "asc" } } },
|
||||
});
|
||||
|
||||
return Promise.all(contextPacks.map(toContextPackDto));
|
||||
};
|
||||
|
||||
export const getContextPackBySlugOrId = async (
|
||||
projectIdOrSlug: string,
|
||||
contextPackIdOrSlug: string,
|
||||
): Promise<ContextPackDto | undefined> => {
|
||||
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
|
||||
const contextPack = await prisma.contextPack.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
OR: [
|
||||
{ id: contextPackIdOrSlug },
|
||||
{ slug: contextPackIdOrSlug },
|
||||
{ readableId: contextPackIdOrSlug },
|
||||
],
|
||||
},
|
||||
include: { sections: { orderBy: { orderIndex: "asc" } } },
|
||||
});
|
||||
|
||||
return contextPack ? toContextPackDto(contextPack) : undefined;
|
||||
};
|
||||
|
||||
export const getContextPackSummaryBySlugOrId = async (
|
||||
projectIdOrSlug: string,
|
||||
contextPackIdOrSlug: string,
|
||||
): Promise<ContextPackSummaryDto | undefined> => {
|
||||
const contextPack = await getContextPackBySlugOrId(projectIdOrSlug, contextPackIdOrSlug);
|
||||
if (!contextPack) return undefined;
|
||||
|
||||
return {
|
||||
projectId: contextPack.projectId,
|
||||
contextPackId: contextPack.id,
|
||||
title: contextPack.title,
|
||||
category: contextPack.category,
|
||||
summary: contextPack.summary,
|
||||
counts: {
|
||||
assumptions: contextPack.assumptions.length,
|
||||
constraints: contextPack.constraints.length,
|
||||
evidence: contextPack.evidence.length,
|
||||
features: contextPack.featureIds?.length ?? 0,
|
||||
bugs: contextPack.bugIds?.length ?? 0,
|
||||
tasks: contextPack.taskIds?.length ?? 0,
|
||||
decisions: contextPack.decisionIds?.length ?? 0,
|
||||
memoryRules: contextPack.memoryRuleIds?.length ?? 0,
|
||||
risks: contextPack.risks?.length ?? 0,
|
||||
},
|
||||
qualityScore: contextPack.quality?.score,
|
||||
createdAt: contextPack.createdAt,
|
||||
updatedAt: contextPack.updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
const toGeneratedBy = (sourceType: CreateContextPackRequestDto["sourceType"]): ContextPackDto["generatedBy"] => {
|
||||
if (sourceType === "check") return "check-run";
|
||||
if (sourceType === "manual") return "manual";
|
||||
return "user";
|
||||
};
|
||||
|
||||
export const createContextPackForProject = async (
|
||||
projectIdOrSlug: string,
|
||||
request: CreateContextPackRequestDto,
|
||||
): Promise<ContextPackDto | undefined> => {
|
||||
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
|
||||
const dna = toProjectDna(project);
|
||||
const features = await prisma.feature.findMany({
|
||||
where: { projectId: project.id, deletedAt: null },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
const evidence = await prisma.evidence.findMany({
|
||||
where: { projectId: project.id, deletedAt: null },
|
||||
orderBy: { lastSeen: "desc" },
|
||||
});
|
||||
const featureIds =
|
||||
request.includeFeatures === false
|
||||
? []
|
||||
: request.sourceType === "feature" && request.sourceId
|
||||
? [request.sourceId]
|
||||
: features.map((feature) => feature.slug ?? feature.readableId ?? feature.id);
|
||||
const evidenceRefs: EvidenceRefDto[] =
|
||||
request.includeEvidence === false
|
||||
? []
|
||||
: evidence.slice(0, 10).map((item) => ({
|
||||
id: `er-${item.slug ?? item.readableId ?? item.id}`,
|
||||
type: "analysis",
|
||||
refId: item.slug ?? item.readableId ?? item.id,
|
||||
label: item.reason,
|
||||
}));
|
||||
const createdAt = new Date();
|
||||
const slug = `cp-${project.slug}-${request.sourceType}-${createdAt.getTime().toString(36)}`;
|
||||
const title = request.title?.trim() || `${project.name} ${request.sourceType.replace("_", " ")} context pack`;
|
||||
const metadata: ContextPackMetadata = {
|
||||
dna,
|
||||
assumptions: request.sourceType === "manual" ? ["Manual context source requires human review."] : ["Database-generated context should be verified before use."],
|
||||
constraints: ["Context pack generated from database-backed project data."],
|
||||
evidence: evidenceRefs,
|
||||
signals: ["database-generated", request.sourceType],
|
||||
featureIds,
|
||||
memoryRuleIds: [],
|
||||
architectureNotes: request.includeArchitecture === false ? [] : [`Database-backed architecture context for ${project.name}.`],
|
||||
risks: request.includeRisks === false ? [] : ["Database-generated context still needs review before implementation."],
|
||||
regressions: request.includeRegressions === false ? [] : [],
|
||||
};
|
||||
const quality = {
|
||||
dnaIncluded: true,
|
||||
constitutionIncluded: false,
|
||||
constraintsIncluded: true,
|
||||
featuresIncluded: request.includeFeatures !== false,
|
||||
evidenceIncluded: request.includeEvidence !== false,
|
||||
risksIncluded: request.includeRisks !== false,
|
||||
memoryIncluded: request.includeMemoryRules !== false,
|
||||
doNotBreakIncluded: true,
|
||||
verificationIncluded: false,
|
||||
score: request.includeEvidence === false ? 58 : 74,
|
||||
};
|
||||
const trust = {
|
||||
source: "database context-pack create",
|
||||
confidence: request.includeEvidence === false ? 64 : 82,
|
||||
evidence: evidenceRefs,
|
||||
lastChecked: createdAt.toISOString(),
|
||||
approvalState: "pending" as const,
|
||||
};
|
||||
|
||||
const contextPack = await prisma.contextPack.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug,
|
||||
readableId: slug,
|
||||
title,
|
||||
category: request.sourceType === "feature" ? "feature" : "quality",
|
||||
summary: `Created from database project ${project.slug} via ${request.sourceType} context source. DNA is always included.`,
|
||||
generatedBy: toGeneratedBy(request.sourceType),
|
||||
confidence: trust.confidence,
|
||||
approvalState: "pending",
|
||||
qualityJson: toJson(quality),
|
||||
trustJson: toJson(trust),
|
||||
metadataJson: toJson(metadata),
|
||||
createdBy: "runtime",
|
||||
updatedBy: "runtime",
|
||||
sourceType: "database_runtime",
|
||||
sections: {
|
||||
create: [
|
||||
{
|
||||
projectId: project.id,
|
||||
slug: "dna",
|
||||
title: "Project DNA",
|
||||
sectionType: "dna",
|
||||
orderIndex: 1,
|
||||
contentJson: toJson(dna),
|
||||
},
|
||||
{
|
||||
projectId: project.id,
|
||||
slug: "features",
|
||||
title: "Relevant features",
|
||||
sectionType: "features",
|
||||
orderIndex: 2,
|
||||
contentJson: toJson(featureIds),
|
||||
},
|
||||
{
|
||||
projectId: project.id,
|
||||
slug: "evidence",
|
||||
title: "Evidence references",
|
||||
sectionType: "evidence",
|
||||
orderIndex: 3,
|
||||
contentJson: toJson(evidenceRefs),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: { sections: { orderBy: { orderIndex: "asc" } } },
|
||||
});
|
||||
|
||||
return toContextPackDto(contextPack);
|
||||
};
|
||||
|
||||
export const generatePromptFromContextPack = async (
|
||||
projectIdOrSlug: string,
|
||||
request: GeneratePromptRequestDto,
|
||||
): Promise<GeneratedPromptDto | undefined> => {
|
||||
const contextPack = await getContextPackBySlugOrId(projectIdOrSlug, request.contextPackId);
|
||||
if (!contextPack) return undefined;
|
||||
|
||||
const createdAt = new Date().toISOString();
|
||||
const promptId = `prompt-${contextPack.projectId}-${Date.now().toString(36)}`;
|
||||
const expectedOutput = request.expectedOutput?.trim() || "Clear implementation notes and validation steps.";
|
||||
const generatedText = [
|
||||
`Generated from context pack ${contextPack.id} (${contextPack.title}).`,
|
||||
`Target tool: ${request.targetTool}.`,
|
||||
`Persona: ${request.persona}.`,
|
||||
`Objective: ${request.objective.trim()}.`,
|
||||
`Expected output: ${expectedOutput}`,
|
||||
`Context summary: ${contextPack.summary}`,
|
||||
`Project vision: ${contextPack.dna.vision}`,
|
||||
`Relevant features: ${(contextPack.featureIds ?? []).join(", ") || "none"}.`,
|
||||
`Risks: ${(contextPack.risks ?? []).join(" ") || "none listed"}.`,
|
||||
"Use only the supplied context pack facts and mark uncertainty explicitly.",
|
||||
].join("\n");
|
||||
|
||||
return {
|
||||
id: promptId,
|
||||
projectId: contextPack.projectId,
|
||||
contextPackId: contextPack.id,
|
||||
targetTool: request.targetTool,
|
||||
persona: request.persona,
|
||||
objective: request.objective.trim(),
|
||||
expectedOutput,
|
||||
generatedText,
|
||||
generatedFrom: "context-pack",
|
||||
createdAt,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import type { ApprovalState, EvidenceDto, EvidenceType } from "../types/api.js";
|
||||
|
||||
const toPublicId = (value: { slug: string | null; readableId: string | null; id: string }) =>
|
||||
value.slug ?? value.readableId ?? value.id;
|
||||
|
||||
type EvidenceMetadata = {
|
||||
linkedEntity?: EvidenceDto["linkedEntity"];
|
||||
featureIds?: string[];
|
||||
route?: string;
|
||||
};
|
||||
|
||||
type DbEvidenceRecord = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
slug: string | null;
|
||||
readableId: string | null;
|
||||
type: string;
|
||||
source: string;
|
||||
confidence: number;
|
||||
approvalState: string;
|
||||
reason: string;
|
||||
metadataJson: Prisma.JsonValue | null;
|
||||
lastSeen: Date;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null;
|
||||
|
||||
const toEvidenceMetadata = (value: Prisma.JsonValue | null): EvidenceMetadata => {
|
||||
if (!isRecord(value)) return {};
|
||||
return value as EvidenceMetadata;
|
||||
};
|
||||
|
||||
const findProjectRecordBySlugOrId = (projectIdOrSlug: string) =>
|
||||
prisma.project.findFirst({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }],
|
||||
},
|
||||
});
|
||||
|
||||
const toEvidenceDto = (
|
||||
evidence: DbEvidenceRecord,
|
||||
projectSlug: string,
|
||||
): EvidenceDto => {
|
||||
const metadata = toEvidenceMetadata(evidence.metadataJson);
|
||||
|
||||
return {
|
||||
id: toPublicId(evidence),
|
||||
projectId: projectSlug,
|
||||
type: evidence.type as EvidenceType,
|
||||
linkedEntity: metadata.linkedEntity ?? {
|
||||
type: "feature",
|
||||
id: "unknown",
|
||||
label: "Unknown linked entity",
|
||||
},
|
||||
featureIds: metadata.featureIds ?? [],
|
||||
source: evidence.source,
|
||||
confidence: evidence.confidence,
|
||||
reason: evidence.reason,
|
||||
lastSeen: evidence.lastSeen.toISOString(),
|
||||
approvalState: evidence.approvalState as ApprovalState,
|
||||
route: metadata.route,
|
||||
};
|
||||
};
|
||||
|
||||
export const listEvidenceForProject = async (
|
||||
projectIdOrSlug: string,
|
||||
): Promise<EvidenceDto[] | undefined> => {
|
||||
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
|
||||
const evidence = await prisma.evidence.findMany({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: [{ lastSeen: "desc" }],
|
||||
});
|
||||
|
||||
return evidence.map((item) => toEvidenceDto(item, project.slug));
|
||||
};
|
||||
|
||||
export const getEvidenceBySlugOrId = async (
|
||||
projectIdOrSlug: string,
|
||||
evidenceIdOrSlug: string,
|
||||
): Promise<EvidenceDto | undefined> => {
|
||||
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
|
||||
const evidence = await prisma.evidence.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
OR: [{ id: evidenceIdOrSlug }, { slug: evidenceIdOrSlug }, { readableId: evidenceIdOrSlug }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!evidence) return undefined;
|
||||
|
||||
return toEvidenceDto(evidence, project.slug);
|
||||
};
|
||||
|
||||
export const listEvidenceForFeature = async (
|
||||
projectIdOrSlug: string,
|
||||
featureIdOrSlug: string,
|
||||
): Promise<EvidenceDto[] | undefined> => {
|
||||
const evidence = await listEvidenceForProject(projectIdOrSlug);
|
||||
if (!evidence) return undefined;
|
||||
|
||||
return evidence.filter((item) => item.featureIds.includes(featureIdOrSlug));
|
||||
};
|
||||
|
||||
export const projectExists = async (projectIdOrSlug: string): Promise<boolean> => {
|
||||
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
|
||||
return Boolean(project);
|
||||
};
|
||||
@@ -0,0 +1,360 @@
|
||||
import {
|
||||
ApprovalState,
|
||||
DeliveryChangeOperation,
|
||||
DeliveryStatus,
|
||||
DeliveryValidationStatus,
|
||||
type Prisma,
|
||||
} from "@prisma/client";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import { createInitiativeForProject } from "./initiativesRepository.js";
|
||||
|
||||
export type FeatureDeliveryStatus =
|
||||
| "draft" | "awaiting_approval" | "approved" | "executing" | "validating"
|
||||
| "pull_request_open" | "merged" | "verified" | "released" | "blocked"
|
||||
| "failed" | "cancelled" | "superseded";
|
||||
export type FeatureDeliveryRiskLevel = "low" | "medium" | "high" | "destructive";
|
||||
export type FeatureValidationResult = { command: string; status: "passed" | "failed" | "not_run"; summary?: string };
|
||||
export type FeatureValidationEvidence = {
|
||||
provider: "gitea_actions"; runId: number; runNumber: number; url: string; workflowPath: string;
|
||||
headSha: string; conclusion: "success"; verifiedAt: string;
|
||||
};
|
||||
export type FeatureDeliverySpec = {
|
||||
version: number; problem: string; desiredOutcome: string; users: string[]; goals: string[]; nonGoals: string[];
|
||||
acceptanceCriteria: string[]; implementationPlan: string[]; risks: string[]; validationCommands: string[];
|
||||
status: FeatureDeliveryStatus; branch?: string; repositoryId?: string; changedFiles: string[]; progressNotes: string[];
|
||||
reviewFeedback?: string;
|
||||
validationResults: FeatureValidationResult[]; validationEvidence?: FeatureValidationEvidence;
|
||||
pullRequest?: { number: number; url: string; title: string; state: string; mergedAt?: string; mergeCommitSha?: string };
|
||||
updatedBy: string; updatedAt: string;
|
||||
};
|
||||
export type FeatureDeliveryDto = {
|
||||
id: string; projectId: string; title: string; intent: string; priority: "critical" | "important" | "normal" | "low";
|
||||
approvalState: string; spec: FeatureDeliverySpec; createdAt: string; updatedAt: string;
|
||||
};
|
||||
export type FeatureDeliveryInput = {
|
||||
title: string; intent: string; priority?: FeatureDeliveryDto["priority"]; problem: string; desiredOutcome: string;
|
||||
users?: string[]; goals: string[]; nonGoals?: string[]; acceptanceCriteria: string[]; implementationPlan: string[];
|
||||
risks?: string[]; validationCommands: string[]; riskLevel?: FeatureDeliveryRiskLevel;
|
||||
};
|
||||
export type DeliveryChangeInput = {
|
||||
path: string; operation: "create" | "update" | "delete" | "rename"; previousPath?: string;
|
||||
beforeSha?: string; afterSha?: string; commitSha?: string;
|
||||
};
|
||||
|
||||
const deliveryTransitions: Record<FeatureDeliveryStatus, readonly FeatureDeliveryStatus[]> = {
|
||||
draft: ["awaiting_approval", "approved", "cancelled"],
|
||||
awaiting_approval: ["awaiting_approval", "approved", "cancelled"],
|
||||
approved: ["approved", "executing", "awaiting_approval", "cancelled"],
|
||||
executing: ["executing", "validating", "blocked", "failed", "cancelled"],
|
||||
validating: ["validating", "pull_request_open", "blocked", "failed", "cancelled"],
|
||||
pull_request_open: ["pull_request_open", "merged", "failed", "blocked", "cancelled"],
|
||||
merged: ["merged", "verified", "superseded"],
|
||||
verified: ["verified", "released", "superseded"],
|
||||
released: ["released", "superseded"],
|
||||
blocked: ["blocked", "approved", "cancelled"],
|
||||
failed: ["failed", "approved", "cancelled"],
|
||||
cancelled: ["cancelled"],
|
||||
superseded: ["superseded"],
|
||||
};
|
||||
|
||||
export const canTransitionFeatureDeliveryStatus = (from: FeatureDeliveryStatus, to: FeatureDeliveryStatus) => deliveryTransitions[from]?.includes(to) ?? false;
|
||||
export const assertFeatureDeliveryTransition = (from: FeatureDeliveryStatus, to: FeatureDeliveryStatus) => {
|
||||
if (!canTransitionFeatureDeliveryStatus(from, to)) throw new Error("FEATURE_DELIVERY_INVALID_TRANSITION");
|
||||
};
|
||||
|
||||
const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
|
||||
const strings = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
const publicId = (value: { id: string; slug?: string | null }) => value.slug ?? value.id;
|
||||
const toPriority = (value: string): FeatureDeliveryDto["priority"] =>
|
||||
(["critical", "important", "normal", "low"].includes(value) ? value : "normal") as FeatureDeliveryDto["priority"];
|
||||
const validationStatus = (status: DeliveryValidationStatus): FeatureValidationResult["status"] =>
|
||||
status === DeliveryValidationStatus.passed ? "passed" : status === DeliveryValidationStatus.failed ? "failed" : "not_run";
|
||||
export const actionValidationCommand = (workflowPath: string) => `Gitea Actions: ${workflowPath}`;
|
||||
export const reportedValidationResult = (entry: { command: string; status: DeliveryValidationStatus; provider: string | null; summary: string | null }): FeatureValidationResult => {
|
||||
// Old syncs copied one workflow result onto every planned command, including
|
||||
// native PR and live-provider checks that the workflow had never executed.
|
||||
const inferredLegacyResult = entry.provider === "gitea_actions" && entry.status === DeliveryValidationStatus.passed && entry.command !== actionValidationCommand(entry.summary ?? "");
|
||||
return { command: entry.command, status: inferredLegacyResult ? "not_run" : validationStatus(entry.status),
|
||||
...(inferredLegacyResult ? { summary: `Legacy inferred result; verify this check independently. Observed workflow: ${entry.summary ?? "unknown"}` } : entry.summary ? { summary: entry.summary } : {}) };
|
||||
};
|
||||
const hasStatus = (status: DeliveryStatus, allowed: DeliveryStatus[]) => allowed.includes(status);
|
||||
const normalizedRiskLevel = (value: FeatureDeliveryRiskLevel | undefined): FeatureDeliveryRiskLevel => value ?? "medium";
|
||||
const requiresHumanDecision = (riskLevel: FeatureDeliveryRiskLevel) => riskLevel === "high" || riskLevel === "destructive";
|
||||
const authorizationMode = (riskLevel: FeatureDeliveryRiskLevel) => riskLevel === "low" ? "auto_allowed" : "auto_allowed_with_audit";
|
||||
|
||||
const getProject = async (projectIdOrSlug: string) => {
|
||||
const project = await prisma.project.findFirst({ where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] } });
|
||||
if (!project) throw new Error("PROJECT_NOT_FOUND");
|
||||
return project;
|
||||
};
|
||||
const planInclude = {
|
||||
initiative: true,
|
||||
runs: { orderBy: { createdAt: "desc" as const }, include: {
|
||||
repositoryLink: true,
|
||||
changes: { orderBy: { createdAt: "asc" as const } },
|
||||
validations: { orderBy: { createdAt: "asc" as const } },
|
||||
} },
|
||||
} satisfies Prisma.DeliveryPlanInclude;
|
||||
type PlanWithRuntime = Prisma.DeliveryPlanGetPayload<{ include: typeof planInclude }>;
|
||||
|
||||
const toDto = (projectSlug: string, plan: PlanWithRuntime): FeatureDeliveryDto => {
|
||||
const run = plan.runs[0];
|
||||
const actionValidation = run?.validations.find((entry) => entry.provider === "gitea_actions" && entry.status === DeliveryValidationStatus.passed && entry.headSha);
|
||||
const validationEvidence = actionValidation?.externalRunId && actionValidation.externalUrl && actionValidation.headSha && actionValidation.verifiedAt
|
||||
? { provider: "gitea_actions" as const, runId: Number(actionValidation.externalRunId), runNumber: Number(actionValidation.checkName?.replace(/^run-/, "") ?? actionValidation.externalRunId), url: actionValidation.externalUrl, workflowPath: actionValidation.summary ?? "gitea-actions", headSha: actionValidation.headSha, conclusion: "success" as const, verifiedAt: actionValidation.verifiedAt.toISOString() }
|
||||
: undefined;
|
||||
const pullRequest = run?.pullRequestNumber && run.pullRequestUrl && run.pullRequestTitle
|
||||
? { number: run.pullRequestNumber, url: run.pullRequestUrl, title: run.pullRequestTitle, state: run.pullRequestState ?? "open", ...(run.mergedAt ? { mergedAt: run.mergedAt.toISOString() } : {}), ...(run.mergeCommitSha ? { mergeCommitSha: run.mergeCommitSha } : {}) }
|
||||
: undefined;
|
||||
return {
|
||||
id: publicId(plan), projectId: projectSlug, title: plan.title, intent: plan.intent, priority: toPriority(plan.priority), approvalState: plan.approvalState,
|
||||
spec: {
|
||||
version: plan.version, problem: plan.problem, desiredOutcome: plan.desiredOutcome, users: strings(plan.usersJson), goals: strings(plan.goalsJson),
|
||||
nonGoals: strings(plan.nonGoalsJson), acceptanceCriteria: strings(plan.acceptanceCriteriaJson), implementationPlan: strings(plan.implementationPlanJson),
|
||||
risks: strings(plan.risksJson), validationCommands: strings(plan.validationCommandsJson), status: plan.status, branch: run?.branch,
|
||||
repositoryId: run?.repositoryLinkId, changedFiles: Array.from(new Set(run?.changes.map((change) => change.path) ?? [])),
|
||||
progressNotes: run?.changes.map((change) => `${change.operation}: ${change.previousPath ? `${change.previousPath} -> ` : ""}${change.path}`) ?? [],
|
||||
validationResults: run?.validations.map(reportedValidationResult) ?? [],
|
||||
...(plan.reviewFeedback ? { reviewFeedback: plan.reviewFeedback } : {}),
|
||||
validationEvidence, pullRequest, updatedBy: plan.updatedBy ?? plan.createdBy, updatedAt: plan.updatedAt.toISOString(),
|
||||
},
|
||||
createdAt: plan.createdAt.toISOString(), updatedAt: plan.updatedAt.toISOString(),
|
||||
};
|
||||
};
|
||||
const getDeliveryRow = async (projectIdOrSlug: string, deliveryId: string) => {
|
||||
const project = await getProject(projectIdOrSlug);
|
||||
const plan = await prisma.deliveryPlan.findFirst({ where: { projectId: project.id, OR: [{ id: deliveryId }, { slug: deliveryId }] }, include: planInclude });
|
||||
if (!plan) throw new Error("FEATURE_DELIVERY_NOT_FOUND");
|
||||
return { project, plan };
|
||||
};
|
||||
const emit = async (tx: Prisma.TransactionClient, projectId: string, aggregateId: string, eventType: string, payload: unknown) => {
|
||||
await tx.outboxEvent.create({ data: { eventId: randomUUID(), projectId, aggregateType: "delivery", aggregateId, eventType, payloadJson: toJson(payload) } });
|
||||
};
|
||||
|
||||
export const assertFeatureDeliveryPlanComplete = (spec: Pick<FeatureDeliverySpec, "problem" | "desiredOutcome" | "goals" | "acceptanceCriteria" | "implementationPlan" | "validationCommands">) => {
|
||||
if (!spec.problem.trim() || !spec.desiredOutcome.trim() || !spec.goals.length || !spec.acceptanceCriteria.length || !spec.implementationPlan.length || !spec.validationCommands.length) throw new Error("FEATURE_DELIVERY_PLAN_INCOMPLETE");
|
||||
};
|
||||
export const assertFeatureDeliveryActionEvidence = (evidence: FeatureValidationEvidence | undefined, headSha: string) => {
|
||||
if (!evidence || evidence.provider !== "gitea_actions" || evidence.conclusion !== "success" || evidence.headSha !== headSha) throw new Error("FEATURE_DELIVERY_CI_EVIDENCE_REQUIRED");
|
||||
};
|
||||
export const canSyncFeatureDeliveryValidation = (status: FeatureDeliveryStatus): boolean =>
|
||||
["executing", "validating", "pull_request_open"].includes(status);
|
||||
export const statusAfterFeatureDeliveryValidationSync = (status: FeatureDeliveryStatus): "validating" | "pull_request_open" =>
|
||||
status === "pull_request_open" ? "pull_request_open" : "validating";
|
||||
|
||||
export const createFeatureDelivery = async (projectIdOrSlug: string, input: FeatureDeliveryInput, actor = "chatgpt") => {
|
||||
assertFeatureDeliveryPlanComplete({ problem: input.problem, desiredOutcome: input.desiredOutcome, goals: input.goals, acceptanceCriteria: input.acceptanceCriteria, implementationPlan: input.implementationPlan, validationCommands: input.validationCommands });
|
||||
const riskLevel = normalizedRiskLevel(input.riskLevel);
|
||||
const humanDecisionRequired = requiresHumanDecision(riskLevel);
|
||||
const initiative = await createInitiativeForProject(projectIdOrSlug, { title: input.title, intent: input.intent, priority: input.priority, sourceType: "chatgpt_feature_delivery" });
|
||||
const project = await getProject(projectIdOrSlug);
|
||||
const initiativeRow = await prisma.initiative.findFirstOrThrow({ where: { projectId: project.id, OR: [{ id: initiative.id }, { slug: initiative.id }] } });
|
||||
const now = new Date();
|
||||
const plan = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.deliveryPlan.create({ data: {
|
||||
projectId: project.id, initiativeId: initiativeRow.id, slug: publicId(initiativeRow), title: input.title, intent: input.intent, priority: input.priority ?? "normal",
|
||||
problem: input.problem, desiredOutcome: input.desiredOutcome, usersJson: toJson(input.users ?? []), goalsJson: toJson(input.goals), nonGoalsJson: toJson(input.nonGoals ?? []),
|
||||
acceptanceCriteriaJson: toJson(input.acceptanceCriteria), implementationPlanJson: toJson(input.implementationPlan), risksJson: toJson(input.risks ?? []), validationCommandsJson: toJson(input.validationCommands),
|
||||
status: humanDecisionRequired ? DeliveryStatus.awaiting_approval : DeliveryStatus.approved,
|
||||
approvalState: humanDecisionRequired ? ApprovalState.pending : ApprovalState.approved,
|
||||
approvedAt: humanDecisionRequired ? null : now,
|
||||
approvedBy: humanDecisionRequired ? null : actor,
|
||||
createdBy: actor, updatedBy: actor,
|
||||
}, include: planInclude });
|
||||
await tx.initiative.update({ where: { id: initiativeRow.id }, data: {
|
||||
status: humanDecisionRequired ? "proposed" : "planned",
|
||||
approvalState: humanDecisionRequired ? ApprovalState.pending : ApprovalState.approved,
|
||||
updatedBy: actor,
|
||||
} });
|
||||
await emit(
|
||||
tx,
|
||||
project.id,
|
||||
created.id,
|
||||
humanDecisionRequired ? "delivery.plan.user_decision_required" : "delivery.plan.auto_authorized",
|
||||
humanDecisionRequired
|
||||
? { actor, slug: created.slug, riskLevel, policyDecision: "USER_DECISION_REQUIRED" }
|
||||
: { actor, slug: created.slug, riskLevel, policyDecision: riskLevel === "low" ? "AUTO_ALLOWED" : "AUTO_ALLOWED_WITH_AUDIT", mode: authorizationMode(riskLevel) },
|
||||
);
|
||||
return created;
|
||||
});
|
||||
return toDto(project.slug, plan);
|
||||
};
|
||||
export const listFeatureDeliveries = async (projectIdOrSlug: string) => {
|
||||
const project = await getProject(projectIdOrSlug);
|
||||
const plans = await prisma.deliveryPlan.findMany({ where: { projectId: project.id }, orderBy: { updatedAt: "desc" }, include: planInclude });
|
||||
return plans.map((plan) => toDto(project.slug, plan));
|
||||
};
|
||||
export const getFeatureDelivery = async (projectIdOrSlug: string, deliveryId: string) => {
|
||||
const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId);
|
||||
return toDto(project.slug, plan);
|
||||
};
|
||||
export const updateFeatureDelivery = async (projectIdOrSlug: string, deliveryId: string, patch: Partial<FeatureDeliveryInput>, actor = "chatgpt") => {
|
||||
const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId);
|
||||
if (!hasStatus(plan.status, [DeliveryStatus.draft, DeliveryStatus.awaiting_approval, DeliveryStatus.approved])) throw new Error("FEATURE_DELIVERY_PLAN_LOCKED");
|
||||
if (plan.approvalState === ApprovalState.blocked) throw new Error("FEATURE_DELIVERY_BLOCKED");
|
||||
const riskLevel = normalizedRiskLevel(patch.riskLevel ?? (plan.approvalState === ApprovalState.pending ? "high" : "medium"));
|
||||
const humanDecisionRequired = requiresHumanDecision(riskLevel);
|
||||
assertFeatureDeliveryTransition(plan.status, humanDecisionRequired ? DeliveryStatus.awaiting_approval : DeliveryStatus.approved);
|
||||
const now = new Date();
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: {
|
||||
title: patch.title, intent: patch.intent, priority: patch.priority, problem: patch.problem, desiredOutcome: patch.desiredOutcome,
|
||||
usersJson: patch.users ? toJson(patch.users) : undefined, goalsJson: patch.goals ? toJson(patch.goals) : undefined, nonGoalsJson: patch.nonGoals ? toJson(patch.nonGoals) : undefined,
|
||||
acceptanceCriteriaJson: patch.acceptanceCriteria ? toJson(patch.acceptanceCriteria) : undefined, implementationPlanJson: patch.implementationPlan ? toJson(patch.implementationPlan) : undefined,
|
||||
risksJson: patch.risks ? toJson(patch.risks) : undefined, validationCommandsJson: patch.validationCommands ? toJson(patch.validationCommands) : undefined,
|
||||
status: humanDecisionRequired ? DeliveryStatus.awaiting_approval : DeliveryStatus.approved,
|
||||
approvalState: humanDecisionRequired ? ApprovalState.pending : ApprovalState.approved,
|
||||
approvedAt: humanDecisionRequired ? null : now,
|
||||
approvedBy: humanDecisionRequired ? null : actor,
|
||||
reviewFeedback: null, version: { increment: 1 }, updatedBy: actor,
|
||||
}, include: planInclude });
|
||||
if (plan.initiativeId) await tx.initiative.update({ where: { id: plan.initiativeId }, data: {
|
||||
status: humanDecisionRequired ? "proposed" : "planned",
|
||||
approvalState: humanDecisionRequired ? ApprovalState.pending : ApprovalState.approved,
|
||||
updatedBy: actor,
|
||||
} });
|
||||
await emit(
|
||||
tx,
|
||||
project.id,
|
||||
plan.id,
|
||||
humanDecisionRequired ? "delivery.plan.user_decision_required" : "delivery.plan.auto_authorized",
|
||||
humanDecisionRequired
|
||||
? { actor, version: row.version, riskLevel, policyDecision: "USER_DECISION_REQUIRED" }
|
||||
: { actor, version: row.version, riskLevel, policyDecision: riskLevel === "low" ? "AUTO_ALLOWED" : "AUTO_ALLOWED_WITH_AUDIT", mode: authorizationMode(riskLevel) },
|
||||
);
|
||||
return row;
|
||||
});
|
||||
return toDto(project.slug, updated);
|
||||
};
|
||||
export const approveFeatureDelivery = async (projectIdOrSlug: string, deliveryId: string, actor = "owner") => {
|
||||
const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId);
|
||||
assertFeatureDeliveryPlanComplete({ problem: plan.problem, desiredOutcome: plan.desiredOutcome, goals: strings(plan.goalsJson), acceptanceCriteria: strings(plan.acceptanceCriteriaJson), implementationPlan: strings(plan.implementationPlanJson), validationCommands: strings(plan.validationCommandsJson) });
|
||||
if (!hasStatus(plan.status, [DeliveryStatus.draft, DeliveryStatus.awaiting_approval, DeliveryStatus.approved])) throw new Error("FEATURE_DELIVERY_APPROVAL_LOCKED");
|
||||
assertFeatureDeliveryTransition(plan.status, DeliveryStatus.approved);
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status: DeliveryStatus.approved, approvalState: ApprovalState.approved, approvedAt: new Date(), approvedBy: actor, reviewFeedback: null, updatedBy: actor }, include: planInclude });
|
||||
if (plan.initiativeId) await tx.initiative.update({ where: { id: plan.initiativeId }, data: { status: "planned", approvalState: ApprovalState.approved, updatedBy: actor } });
|
||||
await emit(tx, project.id, plan.id, "delivery.plan.approved", { actor, version: row.version });
|
||||
return row;
|
||||
});
|
||||
return toDto(project.slug, updated);
|
||||
};
|
||||
|
||||
export const rejectFeatureDelivery = async (projectIdOrSlug: string, deliveryId: string, actor = "owner", reason?: string) => {
|
||||
const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId);
|
||||
if (!hasStatus(plan.status, [DeliveryStatus.draft, DeliveryStatus.awaiting_approval])) throw new Error("FEATURE_DELIVERY_REJECTION_LOCKED");
|
||||
assertFeatureDeliveryTransition(plan.status, DeliveryStatus.awaiting_approval);
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const feedback = reason?.trim() || "Herwerk dit plan voor een nieuwe uitvoeringspoging.";
|
||||
const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status: DeliveryStatus.awaiting_approval, approvalState: ApprovalState.rejected, reviewFeedback: feedback, updatedBy: actor }, include: planInclude });
|
||||
if (plan.initiativeId) await tx.initiative.update({ where: { id: plan.initiativeId }, data: { approvalState: ApprovalState.rejected, updatedBy: actor } });
|
||||
await emit(tx, project.id, plan.id, "delivery.plan.revision_requested", { actor, version: row.version, reason: feedback });
|
||||
return toDto(project.slug, row);
|
||||
});
|
||||
};
|
||||
export const startFeatureDelivery = async (projectIdOrSlug: string, deliveryId: string, branch: string, actor = "chatgpt", input: { repositoryLinkId?: string; baseCommitSha?: string; headSha?: string } = {}) => {
|
||||
const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId);
|
||||
assertFeatureDeliveryPlanComplete({ problem: plan.problem, desiredOutcome: plan.desiredOutcome, goals: strings(plan.goalsJson), acceptanceCriteria: strings(plan.acceptanceCriteriaJson), implementationPlan: strings(plan.implementationPlanJson), validationCommands: strings(plan.validationCommandsJson) });
|
||||
if (plan.approvalState !== ApprovalState.approved || plan.status !== DeliveryStatus.approved) throw new Error("FEATURE_DELIVERY_APPROVAL_REQUIRED");
|
||||
assertFeatureDeliveryTransition(plan.status, DeliveryStatus.executing);
|
||||
const repository = input.repositoryLinkId
|
||||
? await prisma.giteaRepositoryLink.findFirst({ where: { id: input.repositoryLinkId, projectId: project.id } })
|
||||
: await prisma.giteaRepositoryLink.findFirst({ where: { projectId: project.id }, orderBy: [{ isPrimary: "desc" }, { createdAt: "asc" }] });
|
||||
if (!repository) throw new Error("GITEA_LINK_NOT_FOUND");
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
const run = await tx.deliveryRun.create({ data: { deliveryPlanId: plan.id, repositoryLinkId: repository.id, branch, baseBranch: repository.defaultBranch, baseCommitSha: input.baseCommitSha, headSha: input.headSha, status: DeliveryStatus.executing, createdBy: actor, updatedBy: actor } });
|
||||
const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status: DeliveryStatus.executing, approvalState: ApprovalState.approved, approvedAt: plan.approvedAt ?? new Date(), approvedBy: plan.approvedBy ?? actor, updatedBy: actor }, include: planInclude });
|
||||
const investigation = await tx.findingInvestigation.findUnique({ where: { deliveryPlanId: plan.id } });
|
||||
if (investigation) {
|
||||
await tx.findingInvestigation.update({ where: { id: investigation.id }, data: { status: "fixing" } });
|
||||
await tx.repositoryFinding.update({ where: { id: investigation.findingId }, data: { status: "fixing" } });
|
||||
}
|
||||
if (plan.initiativeId) await tx.initiative.update({ where: { id: plan.initiativeId }, data: { status: "active", approvalState: ApprovalState.approved, updatedBy: actor } });
|
||||
await emit(tx, project.id, plan.id, "delivery.run.started", { actor, runId: run.id, repositoryLinkId: repository.id, branch, authorization: "projectbrain_policy" });
|
||||
return row;
|
||||
});
|
||||
return toDto(project.slug, updated);
|
||||
};
|
||||
export const recordFeatureDeliveryProgress = async (projectIdOrSlug: string, deliveryId: string, input: { changedFiles?: string[]; changes?: DeliveryChangeInput[]; note?: string; validationResults?: FeatureValidationResult[]; readyForReview?: boolean; headSha?: string }, actor = "chatgpt") => {
|
||||
const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId);
|
||||
const run = plan.runs[0];
|
||||
if (!run || plan.approvalState !== ApprovalState.approved || !hasStatus(plan.status, [DeliveryStatus.executing, DeliveryStatus.validating, DeliveryStatus.pull_request_open])) throw new Error("FEATURE_DELIVERY_PROGRESS_FORBIDDEN");
|
||||
const changes: DeliveryChangeInput[] = input.changes ?? (input.changedFiles ?? []).map((path) => ({ path, operation: "update" }));
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
for (const change of changes) await tx.deliveryFileChange.create({ data: { deliveryRunId: run.id, path: change.path, previousPath: change.previousPath, operation: change.operation as DeliveryChangeOperation, beforeSha: change.beforeSha, afterSha: change.afterSha, commitSha: change.commitSha, actor } });
|
||||
if (changes.length || (input.headSha && input.headSha !== run.headSha)) await tx.deliveryValidation.deleteMany({ where: { deliveryRunId: run.id } });
|
||||
if (input.validationResults) {
|
||||
await tx.deliveryValidation.deleteMany({ where: { deliveryRunId: run.id, provider: null } });
|
||||
for (const result of input.validationResults) await tx.deliveryValidation.create({ data: { deliveryRunId: run.id, command: result.command, status: result.status === "passed" ? DeliveryValidationStatus.passed : result.status === "failed" ? DeliveryValidationStatus.failed : DeliveryValidationStatus.skipped, summary: result.summary } });
|
||||
}
|
||||
const status = plan.status === DeliveryStatus.pull_request_open ? plan.status : input.readyForReview ? DeliveryStatus.validating : plan.status;
|
||||
assertFeatureDeliveryTransition(plan.status, status);
|
||||
await tx.deliveryRun.update({ where: { id: run.id }, data: { status, headSha: input.headSha, updatedBy: actor } });
|
||||
const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status, updatedBy: actor }, include: planInclude });
|
||||
await emit(tx, project.id, plan.id, "delivery.progress.recorded", { actor, runId: run.id, note: input.note, changeCount: changes.length, status });
|
||||
return row;
|
||||
});
|
||||
return toDto(project.slug, updated);
|
||||
};
|
||||
export const recordFeatureDeliveryActionValidation = async (projectIdOrSlug: string, deliveryId: string, evidence: FeatureValidationEvidence, actor = "chatgpt") => {
|
||||
const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId);
|
||||
const run = plan.runs[0];
|
||||
if (!run || plan.approvalState !== ApprovalState.approved || !canSyncFeatureDeliveryValidation(plan.status)) throw new Error("FEATURE_DELIVERY_PROGRESS_FORBIDDEN");
|
||||
const nextStatus = statusAfterFeatureDeliveryValidationSync(plan.status) === "pull_request_open"
|
||||
? DeliveryStatus.pull_request_open
|
||||
: DeliveryStatus.validating;
|
||||
assertFeatureDeliveryTransition(plan.status, nextStatus);
|
||||
const command = actionValidationCommand(evidence.workflowPath);
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
await tx.deliveryValidation.deleteMany({ where: { deliveryRunId: run.id, provider: "gitea_actions" } });
|
||||
await tx.deliveryValidation.create({ data: { deliveryRunId: run.id, command, status: DeliveryValidationStatus.passed, provider: "gitea_actions", externalRunId: String(evidence.runId), externalUrl: evidence.url, checkName: `run-${evidence.runNumber}`, headSha: evidence.headSha, summary: evidence.workflowPath, verifiedAt: new Date(evidence.verifiedAt) } });
|
||||
await tx.deliveryRun.update({ where: { id: run.id }, data: { status: nextStatus, headSha: evidence.headSha, updatedBy: actor } });
|
||||
const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status: nextStatus, updatedBy: actor }, include: planInclude });
|
||||
await emit(tx, project.id, plan.id, "delivery.validation.passed", { actor, runId: run.id, headSha: evidence.headSha, externalRunId: evidence.runId });
|
||||
return row;
|
||||
});
|
||||
return toDto(project.slug, updated);
|
||||
};
|
||||
export const openFeatureDeliveryPullRequest = async (projectIdOrSlug: string, deliveryId: string, pullRequest: { number: number; url: string; title: string }, actor = "chatgpt") => {
|
||||
const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId);
|
||||
const run = plan.runs[0];
|
||||
if (!run || plan.approvalState !== ApprovalState.approved || plan.status !== DeliveryStatus.validating) throw new Error("FEATURE_DELIVERY_COMPLETION_FORBIDDEN");
|
||||
assertFeatureDeliveryTransition(plan.status, DeliveryStatus.pull_request_open);
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
await tx.deliveryRun.update({ where: { id: run.id }, data: { status: DeliveryStatus.pull_request_open, pullRequestNumber: pullRequest.number, pullRequestUrl: pullRequest.url, pullRequestTitle: pullRequest.title, pullRequestState: "open", pullRequestOpenedAt: new Date(), updatedBy: actor } });
|
||||
const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status: DeliveryStatus.pull_request_open, updatedBy: actor }, include: planInclude });
|
||||
await emit(tx, project.id, plan.id, "delivery.pull_request.opened", { actor, runId: run.id, pullRequest });
|
||||
return row;
|
||||
});
|
||||
return toDto(project.slug, updated);
|
||||
};
|
||||
/** Compatibility export: opening a PR no longer marks the delivery completed. */
|
||||
export const completeFeatureDelivery = openFeatureDeliveryPullRequest;
|
||||
export const reconcileFeatureDeliveryPullRequest = async (input: { owner: string; repository: string; number: number; state: "open" | "closed"; merged: boolean; mergeCommitSha?: string; headSha?: string; mergedAt?: string; actor?: string }) => {
|
||||
const run = await prisma.deliveryRun.findFirst({ where: { pullRequestNumber: input.number, repositoryLink: { owner: input.owner, repository: input.repository } }, include: { deliveryPlan: true } });
|
||||
if (!run) return undefined;
|
||||
const actor = input.actor ?? "gitea-webhook";
|
||||
const status = input.merged ? DeliveryStatus.merged : input.state === "closed" ? DeliveryStatus.failed : DeliveryStatus.pull_request_open;
|
||||
assertFeatureDeliveryTransition(run.deliveryPlan.status, status);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.deliveryRun.update({ where: { id: run.id }, data: { status, pullRequestState: input.merged ? "merged" : input.state, mergeCommitSha: input.mergeCommitSha, headSha: input.headSha, mergedAt: input.merged ? new Date(input.mergedAt ?? Date.now()) : undefined, completedAt: input.merged ? new Date(input.mergedAt ?? Date.now()) : input.state === "closed" ? new Date() : undefined, updatedBy: actor } });
|
||||
await tx.deliveryPlan.update({ where: { id: run.deliveryPlanId }, data: { status, updatedBy: actor } });
|
||||
if (run.deliveryPlan.initiativeId) await tx.initiative.update({ where: { id: run.deliveryPlan.initiativeId }, data: { status: input.merged ? "complete" : input.state === "closed" ? "paused" : "active", updatedBy: actor } });
|
||||
await emit(tx, run.deliveryPlan.projectId, run.deliveryPlanId, input.merged ? "delivery.pull_request.merged" : input.state === "closed" ? "delivery.pull_request.closed" : "delivery.pull_request.updated", { actor, runId: run.id, ...input });
|
||||
});
|
||||
return getFeatureDelivery(run.deliveryPlan.projectId, run.deliveryPlanId);
|
||||
};
|
||||
export const markFeatureDeliveryVerified = async (projectIdOrSlug: string, deliveryId: string, actor = "system") => {
|
||||
const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId);
|
||||
const run = plan.runs[0];
|
||||
if (!run || plan.status !== DeliveryStatus.merged) throw new Error("FEATURE_DELIVERY_NOT_MERGED");
|
||||
assertFeatureDeliveryTransition(plan.status, DeliveryStatus.verified);
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
await tx.deliveryRun.update({ where: { id: run.id }, data: { status: DeliveryStatus.verified, verifiedAt: new Date(), completedAt: new Date(), updatedBy: actor } });
|
||||
const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status: DeliveryStatus.verified, updatedBy: actor }, include: planInclude });
|
||||
await emit(tx, project.id, plan.id, "delivery.verified", { actor, runId: run.id, mergeCommitSha: run.mergeCommitSha });
|
||||
return row;
|
||||
});
|
||||
return toDto(project.slug, updated);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import type { FeatureGroupDto, FocusAreaPurpose } from "../types/api.js";
|
||||
|
||||
const toPublicId = (value: { slug: string | null; readableId: string | null; id: string }) =>
|
||||
value.slug ?? value.readableId ?? value.id;
|
||||
|
||||
const findProjectRecordBySlugOrId = (projectIdOrSlug: string) =>
|
||||
prisma.project.findFirst({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }],
|
||||
},
|
||||
});
|
||||
|
||||
export const listFeatureGroupsForProject = async (
|
||||
projectIdOrSlug: string,
|
||||
): Promise<FeatureGroupDto[] | undefined> => {
|
||||
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
|
||||
const groups = await prisma.featureGroup.findMany({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: [{ name: "asc" }],
|
||||
include: {
|
||||
features: {
|
||||
where: { deletedAt: null },
|
||||
orderBy: { name: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return groups.map((group) => ({
|
||||
id: toPublicId(group),
|
||||
projectId: project.slug,
|
||||
name: group.name,
|
||||
description: group.description ?? undefined,
|
||||
focusArea: group.focusArea as FocusAreaPurpose | undefined,
|
||||
featureIds: group.features.map(toPublicId),
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import type {
|
||||
CodeEvidenceDto,
|
||||
EvidenceStrength,
|
||||
FeatureDto,
|
||||
FeatureStatus,
|
||||
} from "../types/api.js";
|
||||
|
||||
const toPublicId = (value: { slug: string | null; readableId: string | null; id: string }) =>
|
||||
value.slug ?? value.readableId ?? value.id;
|
||||
|
||||
type FeatureMetadata = {
|
||||
bugCount?: number;
|
||||
taskCount?: number;
|
||||
decisionCount?: number;
|
||||
codeEvidence?: CodeEvidenceDto[];
|
||||
aiSuggestionCount?: number;
|
||||
evidenceIds?: string[];
|
||||
initiativeId?: string;
|
||||
};
|
||||
|
||||
type DbFeatureRecord = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
featureGroupId: string;
|
||||
slug: string | null;
|
||||
readableId: string | null;
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
lifecycle: FeatureDto["lifecycle"];
|
||||
intent: string | null;
|
||||
confidence: number;
|
||||
health: number;
|
||||
evidenceStrength: string | null;
|
||||
trustJson: Prisma.JsonValue | null;
|
||||
featureGroup?: {
|
||||
id: string;
|
||||
slug: string | null;
|
||||
readableId: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null;
|
||||
|
||||
const toFeatureMetadata = (value: Prisma.JsonValue | null): FeatureMetadata => {
|
||||
if (!isRecord(value)) return {};
|
||||
return value as FeatureMetadata;
|
||||
};
|
||||
|
||||
const findProjectRecordBySlugOrId = (projectIdOrSlug: string) =>
|
||||
prisma.project.findFirst({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }],
|
||||
},
|
||||
});
|
||||
|
||||
const mapFeatureStatus = (status: string): FeatureStatus => {
|
||||
if (status === "done") return "Done";
|
||||
if (status === "in_progress") return "In Progress";
|
||||
return "Todo";
|
||||
};
|
||||
|
||||
const toFeatureDto = (feature: DbFeatureRecord, projectSlug: string): FeatureDto => {
|
||||
const metadata = toFeatureMetadata(feature.trustJson);
|
||||
|
||||
return {
|
||||
id: toPublicId(feature),
|
||||
projectId: projectSlug,
|
||||
groupId: feature.featureGroup ? toPublicId(feature.featureGroup) : feature.featureGroupId,
|
||||
name: feature.name,
|
||||
description: feature.description,
|
||||
status: mapFeatureStatus(feature.status),
|
||||
lifecycle: feature.lifecycle,
|
||||
intent: feature.intent ?? undefined,
|
||||
initiativeId: metadata.initiativeId,
|
||||
evidenceStrength: feature.evidenceStrength as EvidenceStrength | undefined,
|
||||
confidence: feature.confidence,
|
||||
health: feature.health,
|
||||
bugCount: metadata.bugCount ?? 0,
|
||||
taskCount: metadata.taskCount ?? 0,
|
||||
decisionCount: metadata.decisionCount ?? 0,
|
||||
codeEvidence: metadata.codeEvidence ?? [],
|
||||
aiSuggestionCount: metadata.aiSuggestionCount ?? 0,
|
||||
evidenceIds: metadata.evidenceIds ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
export const listFeaturesForProject = async (
|
||||
projectIdOrSlug: string,
|
||||
): Promise<FeatureDto[] | undefined> => {
|
||||
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
|
||||
const features = await prisma.feature.findMany({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: [{ name: "asc" }],
|
||||
include: {
|
||||
featureGroup: true,
|
||||
},
|
||||
});
|
||||
|
||||
return features.map((feature) => toFeatureDto(feature, project.slug));
|
||||
};
|
||||
|
||||
export const getFeatureBySlugOrId = async (
|
||||
projectIdOrSlug: string,
|
||||
featureIdOrSlug: string,
|
||||
): Promise<FeatureDto | undefined> => {
|
||||
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
|
||||
const feature = await prisma.feature.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
OR: [{ id: featureIdOrSlug }, { slug: featureIdOrSlug }, { readableId: featureIdOrSlug }],
|
||||
},
|
||||
include: {
|
||||
featureGroup: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!feature) return undefined;
|
||||
|
||||
return toFeatureDto(feature, project.slug);
|
||||
};
|
||||
|
||||
export const projectExists = async (projectIdOrSlug: string): Promise<boolean> => {
|
||||
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
|
||||
return Boolean(project);
|
||||
};
|
||||
@@ -0,0 +1,455 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { FindingSeverity, FindingStatus, InvestigationStatus, IssueSyncMode, type Prisma } from "@prisma/client";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import { getGiteaIssueClient } from "../integrations/gitea/service.js";
|
||||
import type { GiteaIssue, GiteaLabel } from "../integrations/gitea/types.js";
|
||||
import { createFeatureDelivery, markFeatureDeliveryVerified, type FeatureDeliveryInput } from "./featureDeliveryRepository.js";
|
||||
import { createPortfolioAnalysisJob } from "./analysisJobRepository.js";
|
||||
|
||||
export type FindingEvidence = Record<string, unknown>;
|
||||
export type DetectedFinding = { severity: "info" | "warning" | "critical"; code: string; message: string; subject?: string; evidence?: FindingEvidence };
|
||||
type FindingScope = {
|
||||
jobId: string;
|
||||
analysisJobItemId: string;
|
||||
projectId?: string;
|
||||
repositoryLinkId?: string;
|
||||
owner: string;
|
||||
repository: string;
|
||||
defaultBranch: string;
|
||||
findings: DetectedFinding[];
|
||||
evaluatedRuleCodes?: string[];
|
||||
};
|
||||
|
||||
const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
|
||||
const severityRank: Record<FindingSeverity, number> = { info: 1, warning: 2, critical: 3 };
|
||||
const baseRiskBySeverity: Record<FindingSeverity, number> = { info: 20, warning: 60, critical: 90 };
|
||||
const terminalInvestigationStatuses: InvestigationStatus[] = [InvestigationStatus.resolved, InvestigationStatus.failed, InvestigationStatus.cancelled];
|
||||
|
||||
const projectByIdOrSlug = async (projectIdOrSlug: string) => {
|
||||
const project = await prisma.project.findFirst({ where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] } });
|
||||
if (!project) throw new Error("PROJECT_NOT_FOUND");
|
||||
return project;
|
||||
};
|
||||
|
||||
const subjectFor = (finding: DetectedFinding, defaultBranch: string) => finding.subject ?? (finding.code === "DEFAULT_BRANCH_UNPROTECTED" ? defaultBranch : "repository");
|
||||
export const repositoryFindingFingerprint = (owner: string, repository: string, code: string, subject: string) => createHash("sha256").update(`${owner.toLowerCase()}/${repository.toLowerCase()}:${code}:${subject}`).digest("hex");
|
||||
const titleFor = (finding: DetectedFinding, owner: string, repository: string) => `[ProjectBrain] ${finding.code.replaceAll("_", " ").toLowerCase()} in ${owner}/${repository}`;
|
||||
|
||||
const evidenceNumber = (finding: DetectedFinding, key: string) => {
|
||||
const value = finding.evidence?.[key];
|
||||
return typeof value === "number" ? value : 0;
|
||||
};
|
||||
const evidenceString = (finding: DetectedFinding, key: string) => {
|
||||
const value = finding.evidence?.[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
};
|
||||
export const findingRiskScore = (finding: DetectedFinding) => {
|
||||
const scopeBonus = evidenceString(finding, "scope") === "default_branch" ? 8 : evidenceString(finding, "scope") === "scheduled" ? 4 : 0;
|
||||
const persistenceBonus = Math.min(4, Math.max(0, evidenceNumber(finding, "consecutiveFailures") - 1));
|
||||
const ruleBonus = finding.code === "DEFAULT_BRANCH_ACTION_FAILED" ? 2 : finding.code === "DEFAULT_BRANCH_IS_AGENT_BRANCH" ? 3 : 0;
|
||||
return Math.min(100, baseRiskBySeverity[finding.severity as FindingSeverity] + scopeBonus + persistenceBonus + ruleBonus);
|
||||
};
|
||||
|
||||
const legacyMigrationCandidate = (finding: DetectedFinding) => finding.severity === "critical" && new Set(["DEFAULT_BRANCH_ACTION_FAILED", "SCHEDULED_ACTION_FAILED"]).has(finding.code);
|
||||
|
||||
export const reconcileRepositoryFindings = async (scope: FindingScope) => {
|
||||
const now = new Date();
|
||||
const observedFingerprints = scope.findings.map((finding) => repositoryFindingFingerprint(scope.owner, scope.repository, finding.code, subjectFor(finding, scope.defaultBranch)));
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const reconciled = [];
|
||||
for (let index = 0; index < scope.findings.length; index += 1) {
|
||||
const detected = scope.findings[index];
|
||||
const severity = detected.severity as FindingSeverity;
|
||||
const subject = subjectFor(detected, scope.defaultBranch);
|
||||
const fingerprint = observedFingerprints[index];
|
||||
let existing = await tx.repositoryFinding.findUnique({ where: { fingerprint } });
|
||||
if (!existing && legacyMigrationCandidate(detected)) {
|
||||
const legacy = await tx.repositoryFinding.findFirst({
|
||||
where: {
|
||||
owner: { equals: scope.owner, mode: "insensitive" },
|
||||
repository: { equals: scope.repository, mode: "insensitive" },
|
||||
ruleCode: "LATEST_ACTION_FAILED",
|
||||
status: { notIn: [FindingStatus.resolved, FindingStatus.suppressed] },
|
||||
},
|
||||
orderBy: { lastSeenAt: "desc" },
|
||||
});
|
||||
if (legacy) {
|
||||
existing = await tx.repositoryFinding.update({
|
||||
where: { id: legacy.id },
|
||||
data: {
|
||||
fingerprint,
|
||||
ruleCode: detected.code,
|
||||
subject,
|
||||
status: legacy.status === FindingStatus.issue_open ? FindingStatus.triaged : legacy.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
const suppressionExpired = existing?.status === FindingStatus.suppressed && existing.suppressedUntil && existing.suppressedUntil <= now;
|
||||
const nextStatus = existing?.status === FindingStatus.resolved || suppressionExpired ? FindingStatus.regressed : existing?.status ?? FindingStatus.new;
|
||||
const finding = existing
|
||||
? await tx.repositoryFinding.update({ where: { id: existing.id }, data: {
|
||||
projectId: scope.projectId || existing.projectId, repositoryLinkId: scope.repositoryLinkId || existing.repositoryLinkId,
|
||||
owner: scope.owner, repository: scope.repository,
|
||||
title: titleFor(detected, scope.owner, scope.repository), message: detected.message, severity, riskScore: findingRiskScore(detected),
|
||||
status: nextStatus, occurrenceCount: existing.lastAnalysisJobId === scope.jobId ? existing.occurrenceCount : { increment: 1 }, clearCount: 0, lastSeenAt: now, lastAnalysisJobId: scope.jobId,
|
||||
resolvedAt: null, ...(suppressionExpired ? { suppressedAt: null, suppressedUntil: null, suppressionReason: null } : {}),
|
||||
} })
|
||||
: await tx.repositoryFinding.create({ data: {
|
||||
fingerprint, projectId: scope.projectId || undefined, repositoryLinkId: scope.repositoryLinkId,
|
||||
owner: scope.owner, repository: scope.repository, ruleCode: detected.code, subject,
|
||||
title: titleFor(detected, scope.owner, scope.repository), message: detected.message, severity,
|
||||
confidence: 95, riskScore: findingRiskScore(detected), status: FindingStatus.new, firstSeenAt: now, lastSeenAt: now, lastAnalysisJobId: scope.jobId,
|
||||
} });
|
||||
await tx.findingOccurrence.upsert({
|
||||
where: { findingId_analysisJobItemId: { findingId: finding.id, analysisJobItemId: scope.analysisJobItemId } },
|
||||
create: { findingId: finding.id, analysisJobItemId: scope.analysisJobItemId, observed: true, detailJson: toJson(detected) },
|
||||
update: { observed: true, detailJson: toJson(detected) },
|
||||
});
|
||||
reconciled.push(finding);
|
||||
}
|
||||
|
||||
const missing = await tx.repositoryFinding.findMany({
|
||||
where: {
|
||||
owner: { equals: scope.owner, mode: "insensitive" },
|
||||
repository: { equals: scope.repository, mode: "insensitive" },
|
||||
fingerprint: { notIn: observedFingerprints },
|
||||
status: { notIn: [FindingStatus.resolved, FindingStatus.suppressed] },
|
||||
},
|
||||
});
|
||||
for (const finding of missing) {
|
||||
if (!scope.evaluatedRuleCodes?.includes(finding.ruleCode)) continue;
|
||||
if (finding.lastAnalysisJobId === scope.jobId) continue;
|
||||
const clearCount = finding.clearCount + 1;
|
||||
await tx.repositoryFinding.update({ where: { id: finding.id }, data: {
|
||||
clearCount,
|
||||
...(clearCount >= 2 ? { status: FindingStatus.resolved, resolvedAt: now } : {}),
|
||||
lastAnalysisJobId: scope.jobId,
|
||||
} });
|
||||
await tx.findingOccurrence.upsert({
|
||||
where: { findingId_analysisJobItemId: { findingId: finding.id, analysisJobItemId: scope.analysisJobItemId } },
|
||||
create: { findingId: finding.id, analysisJobItemId: scope.analysisJobItemId, observed: false },
|
||||
update: { observed: false },
|
||||
});
|
||||
}
|
||||
return reconciled;
|
||||
});
|
||||
};
|
||||
|
||||
const findingInclude = {
|
||||
project: { select: { id: true, slug: true, name: true } },
|
||||
repositoryLink: true,
|
||||
issueLink: true,
|
||||
investigations: { orderBy: { createdAt: "desc" as const }, take: 3, include: { deliveryPlan: { select: { id: true, slug: true, title: true, status: true, approvalState: true } } } },
|
||||
occurrences: { where: { observed: true }, orderBy: [{ createdAt: "desc" as const }, { id: "desc" as const }], take: 1, select: { observed: true, detailJson: true, createdAt: true } },
|
||||
} satisfies Prisma.RepositoryFindingInclude;
|
||||
|
||||
export const listRepositoryFindings = async (input: { projectId?: string; statuses?: FindingStatus[]; severities?: FindingSeverity[]; limit?: number; cursor?: string }) => {
|
||||
const project = input.projectId ? await projectByIdOrSlug(input.projectId) : undefined;
|
||||
const cursor = Number.parseInt(input.cursor ?? "0", 10);
|
||||
if (!Number.isInteger(cursor) || cursor < 0) throw new Error("FINDING_CURSOR_INVALID");
|
||||
const limit = Math.min(Math.max(input.limit ?? 20, 1), 50);
|
||||
const rows = await prisma.repositoryFinding.findMany({
|
||||
where: { ...(project ? { projectId: project.id } : {}), ...(input.statuses?.length ? { status: { in: input.statuses } } : {}), ...(input.severities?.length ? { severity: { in: input.severities } } : {}) },
|
||||
orderBy: [{ riskScore: "desc" }, { lastSeenAt: "desc" }, { id: "asc" }], skip: cursor, take: limit + 1, include: findingInclude,
|
||||
});
|
||||
return { items: rows.slice(0, limit), nextCursor: rows.length > limit ? String(cursor + limit) : undefined };
|
||||
};
|
||||
|
||||
export const getRepositoryFinding = async (findingId: string) => {
|
||||
const finding = await prisma.repositoryFinding.findUnique({ where: { id: findingId }, include: {
|
||||
...findingInclude,
|
||||
occurrences: { orderBy: [{ createdAt: "desc" }, { id: "desc" }], take: 20, include: { analysisJobItem: { select: { jobId: true, status: true, summary: true, createdAt: true } } } },
|
||||
} });
|
||||
if (!finding) throw new Error("FINDING_NOT_FOUND");
|
||||
return finding;
|
||||
};
|
||||
|
||||
export const suppressRepositoryFinding = async (findingId: string, reason: string, until?: Date) => {
|
||||
const finding = await prisma.repositoryFinding.update({ where: { id: findingId }, data: { status: FindingStatus.suppressed, suppressionReason: reason, suppressedAt: new Date(), suppressedUntil: until } });
|
||||
return finding;
|
||||
};
|
||||
|
||||
export const getFindingAutomationPolicy = async (projectIdOrSlug: string) => {
|
||||
const project = await projectByIdOrSlug(projectIdOrSlug);
|
||||
return prisma.findingAutomationPolicy.upsert({ where: { projectId: project.id }, create: { projectId: project.id }, update: {} });
|
||||
};
|
||||
|
||||
export const updateFindingAutomationPolicy = async (projectIdOrSlug: string, input: {
|
||||
enabled?: boolean; issueSyncMode?: IssueSyncMode; minimumSeverity?: FindingSeverity; minimumOccurrences?: number; maxIssuesPerRun?: number;
|
||||
autoCloseResolved?: boolean; autoReopenRegressed?: boolean; scheduledAnalysis?: boolean; scheduleMinutes?: number; updatedBy: string;
|
||||
}) => {
|
||||
const project = await projectByIdOrSlug(projectIdOrSlug);
|
||||
return prisma.findingAutomationPolicy.upsert({
|
||||
where: { projectId: project.id },
|
||||
create: { projectId: project.id, ...input },
|
||||
update: input,
|
||||
});
|
||||
};
|
||||
|
||||
export const findingIssueMarker = (finding: { id: string; fingerprint: string }) => `<!-- projectbrain:finding=${finding.id} fingerprint=${finding.fingerprint} -->`;
|
||||
const latestObservedDetection = (finding: Awaited<ReturnType<typeof getRepositoryFinding>>): DetectedFinding | undefined => {
|
||||
const detail = finding.occurrences.find((occurrence) => occurrence.observed)?.detailJson;
|
||||
return detail && typeof detail === "object" && !Array.isArray(detail) ? detail as DetectedFinding : undefined;
|
||||
};
|
||||
|
||||
export const renderFindingEvidence = (evidence?: FindingEvidence) => {
|
||||
if (!evidence) return "";
|
||||
const entries = [
|
||||
["Workflow", evidence.workflow],
|
||||
["Branch", evidence.branch],
|
||||
["Event", evidence.event],
|
||||
["Conclusie", evidence.conclusion],
|
||||
["Opeenvolgende failures", evidence.consecutiveFailures],
|
||||
["Voltooid", evidence.completedAt],
|
||||
].filter((entry): entry is [string, string | number] => typeof entry[1] === "string" || typeof entry[1] === "number");
|
||||
const runUrl = typeof evidence.runUrl === "string" ? evidence.runUrl : undefined;
|
||||
if (runUrl) entries.unshift(["Gitea Actions-run", `[open run](${runUrl})`]);
|
||||
if (!entries.length) return "";
|
||||
return `\n## Bewijs\n\n${entries.map(([label, value]) => `- **${label}:** ${value}`).join("\n")}\n`;
|
||||
};
|
||||
|
||||
const issueBodyFor = (finding: Awaited<ReturnType<typeof getRepositoryFinding>>) => `${findingIssueMarker(finding)}
|
||||
|
||||
## ProjectBrain-bevinding
|
||||
|
||||
${finding.message}
|
||||
|
||||
- **Repository:** \`${finding.owner}/${finding.repository}\`
|
||||
- **Regel:** \`${finding.ruleCode}\`
|
||||
- **Ernst:** ${finding.severity}
|
||||
- **Vertrouwen:** ${finding.confidence}%
|
||||
- **Eerste detectie:** ${finding.firstSeenAt.toISOString()}
|
||||
- **Laatste detectie:** ${finding.lastSeenAt.toISOString()}
|
||||
- **Waarnemingen:** ${finding.occurrenceCount}
|
||||
${renderFindingEvidence(latestObservedDetection(finding)?.evidence)}
|
||||
|
||||
Dit issue wordt idempotent beheerd door ProjectBrain. Onderzoek en codewijzigingen blijven onder de ProjectBrain-deliverygoedkeuring vallen.`;
|
||||
|
||||
const labelDefinitions = (finding: { severity: FindingSeverity; ruleCode: string }) => [
|
||||
{ name: "projectbrain", color: "6d28d9", description: "Automatisch beheerd door ProjectBrain" },
|
||||
{ name: `severity:${finding.severity}`, color: finding.severity === FindingSeverity.critical ? "dc2626" : finding.severity === FindingSeverity.warning ? "f59e0b" : "2563eb", description: "ProjectBrain finding severity" },
|
||||
{ name: `finding:${finding.ruleCode.toLowerCase().replaceAll("_", "-")}`, color: "475569", description: `ProjectBrain rule ${finding.ruleCode}` },
|
||||
];
|
||||
|
||||
const ensureLabels = async (env: AppEnv, owner: string, repository: string, finding: { severity: FindingSeverity; ruleCode: string }): Promise<GiteaLabel[]> => {
|
||||
const client = getGiteaIssueClient(env);
|
||||
const existing = await client.listLabels(owner, repository);
|
||||
const labels: GiteaLabel[] = [];
|
||||
for (const definition of labelDefinitions(finding)) {
|
||||
const label = existing.find((candidate) => candidate.name === definition.name) ?? await client.createLabel(owner, repository, definition);
|
||||
labels.push(label);
|
||||
}
|
||||
return labels;
|
||||
};
|
||||
|
||||
const persistIssueLink = async (finding: Awaited<ReturnType<typeof getRepositoryFinding>>, issue: GiteaIssue, body: string) => prisma.$transaction(async (tx) => {
|
||||
const bodyFingerprint = createHash("sha256").update(body).digest("hex");
|
||||
const link = await tx.giteaIssueLink.upsert({
|
||||
where: { findingId: finding.id },
|
||||
create: { findingId: finding.id, owner: finding.owner, repository: finding.repository, issueNumber: issue.number, issueUrl: issue.html_url, state: issue.state, title: issue.title, bodyFingerprint, lastSyncedAt: new Date(), closedAt: issue.state === "closed" ? new Date(issue.closed_at ?? Date.now()) : undefined },
|
||||
update: { issueNumber: issue.number, issueUrl: issue.html_url, state: issue.state, title: issue.title, bodyFingerprint, lastSyncedAt: new Date(), closedAt: issue.state === "closed" ? new Date(issue.closed_at ?? Date.now()) : null },
|
||||
});
|
||||
if (finding.projectId) await tx.outboxEvent.create({ data: { eventId: randomUUID(), projectId: finding.projectId, aggregateType: "finding", aggregateId: finding.id, eventType: "finding.issue.synced", payloadJson: toJson({ issueNumber: issue.number, issueUrl: issue.html_url, state: issue.state }) } });
|
||||
await tx.repositoryFinding.update({ where: { id: finding.id }, data: { status: issue.state === "open" ? FindingStatus.issue_open : finding.status } });
|
||||
return link;
|
||||
});
|
||||
|
||||
export const syncFindingIssue = async (env: AppEnv, findingId: string, actor = "system") => {
|
||||
const finding = await getRepositoryFinding(findingId);
|
||||
if (!finding.repositoryLink || finding.repositoryLink.accessMode !== "read_write") throw new Error("FINDING_REPOSITORY_WRITE_LINK_REQUIRED");
|
||||
if (finding.status === FindingStatus.resolved || finding.status === FindingStatus.suppressed) throw new Error("FINDING_ISSUE_CREATION_FORBIDDEN");
|
||||
const client = getGiteaIssueClient(env);
|
||||
const body = issueBodyFor(finding);
|
||||
const marker = findingIssueMarker(finding);
|
||||
const labels = await ensureLabels(env, finding.owner, finding.repository, finding);
|
||||
let issue: GiteaIssue | undefined;
|
||||
if (finding.issueLink) {
|
||||
issue = await client.getIssue(finding.owner, finding.repository, finding.issueLink.issueNumber);
|
||||
} else {
|
||||
issue = (await client.listIssues(finding.owner, finding.repository, "all", 1, 100)).find((candidate) => candidate.body?.includes(marker));
|
||||
}
|
||||
if (!issue) issue = await client.createIssue(finding.owner, finding.repository, { title: finding.title, body, labels: labels.map((label) => label.id) });
|
||||
else {
|
||||
issue = await client.editIssue(finding.owner, finding.repository, issue.number, { title: finding.title, body, ...(issue.state === "closed" ? { state: "open" as const } : {}) });
|
||||
await client.replaceIssueLabels(finding.owner, finding.repository, issue.number, labels.map((label) => label.id));
|
||||
}
|
||||
const link = await persistIssueLink(finding, issue, body);
|
||||
return { finding: await getRepositoryFinding(finding.id), issue, link, actor };
|
||||
};
|
||||
|
||||
export const closeResolvedFindingIssue = async (env: AppEnv, findingId: string, summary: string) => {
|
||||
const finding = await getRepositoryFinding(findingId);
|
||||
if (!finding.issueLink || finding.issueLink.state === "closed") return finding;
|
||||
const client = getGiteaIssueClient(env);
|
||||
await client.createIssueComment(finding.owner, finding.repository, finding.issueLink.issueNumber, `ProjectBrain-verificatie: ${summary}`);
|
||||
const issue = await client.editIssue(finding.owner, finding.repository, finding.issueLink.issueNumber, { state: "closed" });
|
||||
await persistIssueLink(finding, issue, issueBodyFor(finding));
|
||||
return getRepositoryFinding(finding.id);
|
||||
};
|
||||
|
||||
export const closeAutomaticallyResolvedFindingIssues = async (env: AppEnv) => {
|
||||
const findings = await prisma.repositoryFinding.findMany({
|
||||
where: { status: FindingStatus.resolved, projectId: { not: null }, issueLink: { state: "open" } },
|
||||
include: { project: { include: { findingAutomationPolicy: true } }, issueLink: true },
|
||||
orderBy: [{ resolvedAt: "asc" }, { id: "asc" }],
|
||||
take: 25,
|
||||
});
|
||||
const closed: string[] = [];
|
||||
for (const finding of findings) {
|
||||
const policy = finding.project?.findingAutomationPolicy ?? (finding.projectId ? await getFindingAutomationPolicy(finding.projectId) : undefined);
|
||||
if (!policy?.autoCloseResolved) continue;
|
||||
try {
|
||||
await closeResolvedFindingIssue(env, finding.id, "Twee opeenvolgende analyses vonden de bevinding niet meer.");
|
||||
closed.push(finding.id);
|
||||
} catch (error) {
|
||||
console.warn(JSON.stringify({ event: "finding_issue_close_failed", findingId: finding.id, code: error instanceof Error ? error.message : "FINDING_ISSUE_CLOSE_FAILED" }));
|
||||
}
|
||||
}
|
||||
return closed;
|
||||
};
|
||||
|
||||
export const reconcileFindingIssueWebhook = async (input: { owner: string; repository: string; issueNumber: number; state: string; title: string; url: string; actor: string }) => {
|
||||
const link = await prisma.giteaIssueLink.findUnique({ where: { owner_repository_issueNumber: { owner: input.owner, repository: input.repository, issueNumber: input.issueNumber } }, include: { finding: true } });
|
||||
if (!link) return undefined;
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await tx.giteaIssueLink.update({ where: { id: link.id }, data: { state: input.state, title: input.title, issueUrl: input.url, lastSyncedAt: new Date(), closedAt: input.state === "closed" ? new Date() : null } });
|
||||
const wasResolved = link.finding.status === FindingStatus.resolved || Boolean(link.finding.resolvedAt);
|
||||
const status = input.state === "open" ? FindingStatus.issue_open : wasResolved ? FindingStatus.resolved : FindingStatus.suppressed;
|
||||
const finding = await tx.repositoryFinding.update({ where: { id: link.findingId }, data: {
|
||||
status,
|
||||
...(status === FindingStatus.resolved ? { suppressedAt: null, suppressedUntil: null, suppressionReason: null } : {}),
|
||||
...(status === FindingStatus.suppressed ? { suppressedAt: new Date(), suppressionReason: `Gitea issue handmatig gesloten door ${input.actor}` } : {}),
|
||||
} });
|
||||
if (finding.projectId) await tx.outboxEvent.create({ data: { eventId: randomUUID(), projectId: finding.projectId, aggregateType: "finding", aggregateId: finding.id, eventType: "finding.issue.webhook", payloadJson: toJson(input) } });
|
||||
return finding;
|
||||
});
|
||||
};
|
||||
|
||||
export const startFindingInvestigation = async (env: AppEnv, findingId: string, actor: string) => {
|
||||
const finding = await getRepositoryFinding(findingId);
|
||||
const active = finding.investigations.find((investigation) => !terminalInvestigationStatuses.includes(investigation.status));
|
||||
if (active) return active;
|
||||
const client = getGiteaIssueClient(env);
|
||||
const [repository, branches, pullRequests, actionRuns, issue, comments, root] = await Promise.all([
|
||||
client.getRepository(finding.owner, finding.repository),
|
||||
client.listBranches(finding.owner, finding.repository),
|
||||
client.listPullRequests(finding.owner, finding.repository, "open"),
|
||||
client.listActionRuns(finding.owner, finding.repository, { limit: 10 }),
|
||||
finding.issueLink ? client.getIssue(finding.owner, finding.repository, finding.issueLink.issueNumber) : Promise.resolve(undefined),
|
||||
finding.issueLink ? client.listIssueComments(finding.owner, finding.repository, finding.issueLink.issueNumber) : Promise.resolve([]),
|
||||
client.getContents(finding.owner, finding.repository, "", finding.repositoryLink?.defaultBranch ?? repositoryDefault(finding)).catch(() => []),
|
||||
]);
|
||||
const rootEntries = (Array.isArray(root) ? root : [root]).slice(0, 50).map(({ content: _content, ...entry }) => entry);
|
||||
const context = {
|
||||
finding: { id: finding.id, title: finding.title, message: finding.message, ruleCode: finding.ruleCode, severity: finding.severity, confidence: finding.confidence, occurrenceCount: finding.occurrenceCount, firstSeenAt: finding.firstSeenAt, lastSeenAt: finding.lastSeenAt },
|
||||
repository: { fullName: `${finding.owner}/${finding.repository}`, defaultBranch: repository.default_branch, archived: repository.archived, private: repository.private },
|
||||
branches: branches.slice(0, 30), pullRequests: pullRequests.slice(0, 10), actionRuns: actionRuns.workflow_runs.slice(0, 10), rootEntries,
|
||||
issue: issue ? { number: issue.number, title: issue.title, body: issue.body.slice(0, 10_000), state: issue.state, url: issue.html_url, comments: comments.slice(-10).map((comment) => ({ body: comment.body.slice(0, 2_000), author: comment.user?.login, createdAt: comment.created_at })) } : undefined,
|
||||
};
|
||||
const investigation = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.findingInvestigation.create({ data: { findingId: finding.id, projectId: finding.projectId, actor, status: InvestigationStatus.ready, contextJson: toJson(context) } });
|
||||
await tx.repositoryFinding.update({ where: { id: finding.id }, data: { status: FindingStatus.investigating } });
|
||||
if (finding.projectId) await tx.outboxEvent.create({ data: { eventId: randomUUID(), projectId: finding.projectId, aggregateType: "investigation", aggregateId: created.id, eventType: "finding.investigation.ready", payloadJson: toJson({ findingId: finding.id, actor }) } });
|
||||
return created;
|
||||
});
|
||||
return investigation;
|
||||
};
|
||||
|
||||
const repositoryDefault = (finding: Awaited<ReturnType<typeof getRepositoryFinding>>) => finding.repositoryLink?.defaultBranch ?? "main";
|
||||
|
||||
export const getFindingInvestigation = async (investigationId: string) => {
|
||||
const investigation = await prisma.findingInvestigation.findUnique({ where: { id: investigationId }, include: { finding: { include: { issueLink: true, repositoryLink: true } }, deliveryPlan: true } });
|
||||
if (!investigation) throw new Error("FINDING_INVESTIGATION_NOT_FOUND");
|
||||
return investigation;
|
||||
};
|
||||
|
||||
export const saveFindingInvestigationConclusion = async (investigationId: string, input: { diagnosis: string; rootCause: string; proposedChanges: string[]; confidence: number; conclusion: string }) => {
|
||||
const investigation = await getFindingInvestigation(investigationId);
|
||||
if (terminalInvestigationStatuses.includes(investigation.status)) throw new Error("FINDING_INVESTIGATION_LOCKED");
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.findingInvestigation.update({ where: { id: investigation.id }, data: { status: InvestigationStatus.concluded, diagnosis: input.diagnosis, rootCause: input.rootCause, proposedChangesJson: toJson(input.proposedChanges), confidence: input.confidence, conclusion: input.conclusion, concludedAt: new Date() } });
|
||||
await tx.repositoryFinding.update({ where: { id: investigation.findingId }, data: { status: FindingStatus.triaged } });
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
export const createDeliveryFromFindingInvestigation = async (investigationId: string, input: FeatureDeliveryInput, actor: string) => {
|
||||
const investigation = await getFindingInvestigation(investigationId);
|
||||
if (!investigation.projectId) throw new Error("FINDING_PROJECT_LINK_REQUIRED");
|
||||
if (investigation.deliveryPlanId) throw new Error("FINDING_DELIVERY_ALREADY_EXISTS");
|
||||
if (investigation.status !== InvestigationStatus.concluded) throw new Error("FINDING_INVESTIGATION_CONCLUSION_REQUIRED");
|
||||
const delivery = await createFeatureDelivery(investigation.projectId, input, actor);
|
||||
const plan = await prisma.deliveryPlan.findFirstOrThrow({ where: { projectId: investigation.projectId, OR: [{ id: delivery.id }, { slug: delivery.id }] } });
|
||||
await prisma.$transaction([
|
||||
prisma.findingInvestigation.update({ where: { id: investigation.id }, data: { deliveryPlanId: plan.id, status: InvestigationStatus.planned } }),
|
||||
prisma.repositoryFinding.update({ where: { id: investigation.findingId }, data: { status: FindingStatus.planned } }),
|
||||
]);
|
||||
return { delivery, investigation: await getFindingInvestigation(investigation.id) };
|
||||
};
|
||||
|
||||
export const queueFindingVerificationForDelivery = async (env: AppEnv, projectIdOrSlug: string, deliveryId: string) => {
|
||||
const project = await projectByIdOrSlug(projectIdOrSlug);
|
||||
const plan = await prisma.deliveryPlan.findFirst({ where: { projectId: project.id, OR: [{ id: deliveryId }, { slug: deliveryId }] }, include: { findingInvestigation: { include: { finding: true } } } });
|
||||
const investigation = plan?.findingInvestigation;
|
||||
if (!plan || !investigation?.finding.repositoryLinkId) return undefined;
|
||||
if (new Set<InvestigationStatus>([InvestigationStatus.verifying, InvestigationStatus.resolved, InvestigationStatus.failed, InvestigationStatus.cancelled]).has(investigation.status)) return undefined;
|
||||
const result = await createPortfolioAnalysisJob(env, `system:verification:${plan.id}`, { repositoryIds: [investigation.finding.repositoryLinkId], includeBranches: true, includePullRequests: true, includeActions: true });
|
||||
await prisma.$transaction([
|
||||
prisma.findingInvestigation.update({ where: { id: investigation.id }, data: { status: InvestigationStatus.verifying } }),
|
||||
prisma.repositoryFinding.update({ where: { id: investigation.findingId }, data: { status: FindingStatus.verifying } }),
|
||||
]);
|
||||
return { job: result.job, investigationId: investigation.id, findingId: investigation.findingId };
|
||||
};
|
||||
|
||||
export const finalizeFindingVerificationForJob = async (env: AppEnv, jobId: string) => {
|
||||
const items = await prisma.analysisJobItem.findMany({ where: { jobId }, include: { findingOccurrences: true } });
|
||||
const completed = [];
|
||||
for (const item of items) {
|
||||
if (item.status !== "succeeded") continue;
|
||||
const investigations = await prisma.findingInvestigation.findMany({ where: { status: InvestigationStatus.verifying, finding: { owner: item.owner, repository: item.repository } }, include: { finding: { include: { issueLink: true } }, deliveryPlan: true } });
|
||||
for (const investigation of investigations) {
|
||||
const observed = item.findingOccurrences.some((occurrence) => occurrence.findingId === investigation.findingId && occurrence.observed);
|
||||
if (observed) {
|
||||
await prisma.$transaction([
|
||||
prisma.findingInvestigation.update({ where: { id: investigation.id }, data: { status: InvestigationStatus.failed, completedAt: new Date(), conclusion: "De bevinding is na de merge opnieuw waargenomen." } }),
|
||||
prisma.repositoryFinding.update({ where: { id: investigation.findingId }, data: { status: FindingStatus.regressed } }),
|
||||
]);
|
||||
if (investigation.finding.issueLink) await getGiteaIssueClient(env).createIssueComment(item.owner, item.repository, investigation.finding.issueLink.issueNumber, "ProjectBrain-verificatie mislukt: de bevinding is na de merge opnieuw waargenomen.").catch(() => undefined);
|
||||
completed.push({ findingId: investigation.findingId, resolved: false });
|
||||
} else if (item.findingOccurrences.some((occurrence) => occurrence.findingId === investigation.findingId && occurrence.observed === false)) {
|
||||
await prisma.$transaction([
|
||||
prisma.findingInvestigation.update({ where: { id: investigation.id }, data: { status: InvestigationStatus.resolved, completedAt: new Date(), conclusion: "Gerichte heranalyse bevestigt dat de bevinding verdwenen is." } }),
|
||||
prisma.repositoryFinding.update({ where: { id: investigation.findingId }, data: { status: FindingStatus.resolved, resolvedAt: new Date(), clearCount: { increment: 1 } } }),
|
||||
]);
|
||||
if (investigation.deliveryPlan) await markFeatureDeliveryVerified(investigation.deliveryPlan.projectId, investigation.deliveryPlan.id, "projectbrain-verifier").catch(() => undefined);
|
||||
const policy = investigation.finding.projectId ? await getFindingAutomationPolicy(investigation.finding.projectId) : undefined;
|
||||
if (policy?.autoCloseResolved) await closeResolvedFindingIssue(env, investigation.findingId, "Gerichte heranalyse na de merge vond de bevinding niet meer.").catch(() => undefined);
|
||||
completed.push({ findingId: investigation.findingId, resolved: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
return completed;
|
||||
};
|
||||
|
||||
export const syncAutomaticFindingIssues = async (env: AppEnv, jobId: string) => {
|
||||
const findings = await prisma.repositoryFinding.findMany({ where: { lastAnalysisJobId: jobId, projectId: { not: null }, repositoryLinkId: { not: null }, status: { in: [FindingStatus.new, FindingStatus.regressed, FindingStatus.triaged] } }, include: { project: { include: { findingAutomationPolicy: true } }, issueLink: true } });
|
||||
const created: string[] = [];
|
||||
const counts = new Map<string, number>();
|
||||
for (const finding of findings) {
|
||||
const policy = finding.project?.findingAutomationPolicy ?? (finding.projectId ? await getFindingAutomationPolicy(finding.projectId) : undefined);
|
||||
if (!policy?.enabled || policy.issueSyncMode !== IssueSyncMode.automatic || severityRank[finding.severity] < severityRank[policy.minimumSeverity] || finding.occurrenceCount < policy.minimumOccurrences) continue;
|
||||
const projectCount = counts.get(finding.projectId!) ?? 0;
|
||||
if (projectCount >= policy.maxIssuesPerRun) continue;
|
||||
if (finding.status === FindingStatus.regressed && finding.issueLink?.state === "closed" && !policy.autoReopenRegressed) continue;
|
||||
try {
|
||||
await syncFindingIssue(env, finding.id, "system:analysis-worker");
|
||||
counts.set(finding.projectId!, projectCount + 1);
|
||||
created.push(finding.id);
|
||||
} catch (error) {
|
||||
console.warn(JSON.stringify({ event: "finding_issue_sync_failed", findingId: finding.id, code: error instanceof Error ? error.message : "FINDING_ISSUE_SYNC_FAILED" }));
|
||||
}
|
||||
}
|
||||
return created;
|
||||
};
|
||||
@@ -0,0 +1,406 @@
|
||||
import { ApprovalState, EntityType, EvidenceStrength, FeatureLifecycle, FeatureStatus, ReviewStatus, VerificationState } from "@prisma/client";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import type { Initiative, Prisma } from "@prisma/client";
|
||||
|
||||
export type InitiativeDto = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
intent: string;
|
||||
status: "proposed" | "planned" | "active" | "complete" | "paused";
|
||||
featureIds: string[];
|
||||
priority: "critical" | "important" | "normal" | "low";
|
||||
confidence?: number;
|
||||
approvalState?: string;
|
||||
sourceBrainstormId?: string;
|
||||
reviewItemId?: string;
|
||||
sourceType?: string;
|
||||
metadata?: unknown;
|
||||
provenance?: unknown;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
|
||||
const slugify = (value: string) =>
|
||||
value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 56) || "initiative";
|
||||
|
||||
const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
|
||||
|
||||
export type CreateInitiativeInput = {
|
||||
title: string;
|
||||
intent: string;
|
||||
priority?: InitiativeDto["priority"];
|
||||
status?: InitiativeDto["status"];
|
||||
confidence?: number;
|
||||
featureIds?: string[];
|
||||
sourceType?: string;
|
||||
};
|
||||
|
||||
export type UpdateInitiativeInput = Partial<CreateInitiativeInput> & {
|
||||
approvalState?: string;
|
||||
};
|
||||
|
||||
const toStringArray = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
const toStatus = (value: string): InitiativeDto["status"] => ["proposed", "planned", "active", "complete", "paused"].includes(value) ? value as InitiativeDto["status"] : "proposed";
|
||||
const toPriority = (value: string): InitiativeDto["priority"] => ["critical", "important", "normal", "low"].includes(value) ? value as InitiativeDto["priority"] : "normal";
|
||||
|
||||
const getProject = async (projectIdOrSlug: string) => {
|
||||
const project = await prisma.project.findFirst({ where: { OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }], deletedAt: null }});
|
||||
if (!project) throw new Error(`Project '${projectIdOrSlug}' not found`);
|
||||
return project;
|
||||
};
|
||||
|
||||
const toDto = (projectSlug: string, initiative: Initiative): InitiativeDto => ({
|
||||
id: initiative.slug ?? initiative.id,
|
||||
projectId: projectSlug,
|
||||
title: initiative.title,
|
||||
intent: initiative.intent,
|
||||
status: toStatus(initiative.status),
|
||||
featureIds: toStringArray(initiative.featureIdsJson),
|
||||
priority: toPriority(initiative.priority),
|
||||
confidence: initiative.confidence,
|
||||
approvalState: initiative.approvalState,
|
||||
sourceBrainstormId: initiative.sourceBrainstormId ?? undefined,
|
||||
reviewItemId: initiative.reviewItemId ?? undefined,
|
||||
sourceType: initiative.sourceType,
|
||||
metadata: initiative.metadataJson ?? undefined,
|
||||
provenance: initiative.provenanceJson ?? undefined,
|
||||
createdAt: initiative.createdAt.toISOString(),
|
||||
updatedAt: initiative.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
export const listInitiativesForProject = async (projectIdOrSlug: string): Promise<InitiativeDto[]> => {
|
||||
const project = await getProject(projectIdOrSlug);
|
||||
const items = await prisma.initiative.findMany({
|
||||
where: { projectId: project.id, deletedAt: null },
|
||||
orderBy: [{ status: "asc" }, { updatedAt: "desc" }],
|
||||
});
|
||||
return items.map((item) => toDto(project.slug, item));
|
||||
};
|
||||
|
||||
export const markInitiativePlannedFromReview = async (projectId: string, initiativeId: string, reviewItemId: string): Promise<string | undefined> => {
|
||||
const existing = await prisma.initiative.findFirst({
|
||||
where: { projectId, deletedAt: null, OR: [{ id: initiativeId }, { slug: initiativeId }, { readableId: initiativeId }] },
|
||||
});
|
||||
if (!existing) return undefined;
|
||||
const updated = await prisma.initiative.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
status: "planned",
|
||||
approvalState: ApprovalState.approved,
|
||||
reviewItemId,
|
||||
updatedBy: "review_queue",
|
||||
},
|
||||
});
|
||||
return updated.slug ?? updated.id;
|
||||
};
|
||||
|
||||
|
||||
export const getInitiativeForProject = async (projectIdOrSlug: string, initiativeId: string): Promise<InitiativeDto | undefined> => {
|
||||
const project = await getProject(projectIdOrSlug);
|
||||
const item = await prisma.initiative.findFirst({
|
||||
where: { projectId: project.id, deletedAt: null, OR: [{ id: initiativeId }, { slug: initiativeId }, { readableId: initiativeId }] },
|
||||
});
|
||||
return item ? toDto(project.slug, item) : undefined;
|
||||
};
|
||||
|
||||
export const createInitiativeForProject = async (projectIdOrSlug: string, input: CreateInitiativeInput): Promise<InitiativeDto> => {
|
||||
const project = await getProject(projectIdOrSlug);
|
||||
const slug = `${slugify(input.title)}-${Date.now().toString(36)}`;
|
||||
const item = await prisma.initiative.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug,
|
||||
readableId: slug,
|
||||
title: input.title,
|
||||
intent: input.intent,
|
||||
priority: input.priority ?? "normal",
|
||||
status: input.status ?? "proposed",
|
||||
confidence: input.confidence ?? 72,
|
||||
featureIdsJson: toJson(input.featureIds ?? []),
|
||||
approvalState: ApprovalState.pending,
|
||||
sourceType: input.sourceType ?? "manual_initiative",
|
||||
metadataJson: toJson({ reviewBeforeTruth: true, createdFrom: "initiative_endpoint" }),
|
||||
provenanceJson: toJson({ source: input.sourceType ?? "manual_initiative" }),
|
||||
},
|
||||
});
|
||||
await prisma.operatingLogEntry.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug: `log-initiative-created-${slug}`,
|
||||
readableId: `log-initiative-created-${slug}`,
|
||||
type: "initiative created",
|
||||
title: `Initiative created: ${input.title}`,
|
||||
summary: "A roadmap initiative was created as a review-gated planning object.",
|
||||
linkedEntityIdsJson: toJson([item.slug ?? item.id]),
|
||||
source: "initiative_center",
|
||||
importance: input.priority === "critical" ? "critical" : input.priority === "important" ? "important" : "normal",
|
||||
payloadJson: toJson({ initiativeId: item.slug ?? item.id, status: item.status }),
|
||||
provenanceJson: toJson({ source: input.sourceType ?? "manual_initiative" }),
|
||||
},
|
||||
});
|
||||
return toDto(project.slug, item);
|
||||
};
|
||||
|
||||
export const updateInitiativeForProject = async (projectIdOrSlug: string, initiativeId: string, input: UpdateInitiativeInput): Promise<InitiativeDto | undefined> => {
|
||||
const project = await getProject(projectIdOrSlug);
|
||||
const existing = await prisma.initiative.findFirst({
|
||||
where: { projectId: project.id, deletedAt: null, OR: [{ id: initiativeId }, { slug: initiativeId }, { readableId: initiativeId }] },
|
||||
});
|
||||
if (!existing) return undefined;
|
||||
const updated = await prisma.initiative.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
title: input.title,
|
||||
intent: input.intent,
|
||||
priority: input.priority,
|
||||
status: input.status,
|
||||
confidence: input.confidence,
|
||||
featureIdsJson: input.featureIds ? toJson(input.featureIds) : undefined,
|
||||
approvalState: input.approvalState === "approved" ? ApprovalState.approved : input.approvalState === "rejected" ? ApprovalState.rejected : undefined,
|
||||
updatedBy: "initiative_endpoint",
|
||||
},
|
||||
});
|
||||
return toDto(project.slug, updated);
|
||||
};
|
||||
|
||||
const suggestedFeatureTitlesFor = (initiative: InitiativeDto): string[] => {
|
||||
const text = `${initiative.title} ${initiative.intent}`.toLowerCase();
|
||||
if (text.includes("brainstorm")) return ["Brainstorm review detail", "Outcome conversion guardrails", "Idea graph comparison panel"];
|
||||
if (text.includes("blueprint") || text.includes("documentation")) return ["Blueprint acceptance workflow", "Documentation export audit", "Stale document regeneration queue"];
|
||||
if (text.includes("database") || text.includes("backend")) return ["Database smoke evidence panel", "API contract regression pack", "Persistence recovery checklist"];
|
||||
if (text.includes("ux") || text.includes("usability")) return ["Guided empty-state actions", "Workflow-based navigation polish", "Mobile action rail refinement"];
|
||||
return ["Initiative scope brief", "Feature candidate breakdown", "Acceptance criteria draft"];
|
||||
};
|
||||
|
||||
export type InitiativeFeatureCandidateDto = {
|
||||
id: string;
|
||||
title: string;
|
||||
confidence: number;
|
||||
reviewItemId: string;
|
||||
targetRoute: string;
|
||||
materializedFeatureId?: string;
|
||||
};
|
||||
|
||||
export type InitiativeReviewSyncResultDto = {
|
||||
applied: boolean;
|
||||
action: "accept" | "reject";
|
||||
reviewItemId: string;
|
||||
initiativeId?: string;
|
||||
featureId?: string;
|
||||
acceptedTruthId?: string;
|
||||
affectedEntities: string[];
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const createFeatureCandidatesForInitiative = async (projectIdOrSlug: string, initiativeId: string): Promise<{ initiative: InitiativeDto; candidates: InitiativeFeatureCandidateDto[] } | undefined> => {
|
||||
const project = await getProject(projectIdOrSlug);
|
||||
const initiative = await getInitiativeForProject(projectIdOrSlug, initiativeId);
|
||||
if (!initiative) return undefined;
|
||||
const titles = suggestedFeatureTitlesFor(initiative);
|
||||
const candidates: InitiativeFeatureCandidateDto[] = [];
|
||||
|
||||
for (const [index, title] of titles.entries()) {
|
||||
const slug = `rq-initiative-feature-${slugify(initiative.title)}-${index}-${Date.now().toString(36)}`;
|
||||
const review = await prisma.reviewQueueItem.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug,
|
||||
readableId: slug,
|
||||
type: "feature",
|
||||
title: `Feature candidate: ${title}`,
|
||||
description: `Initiative '${initiative.title}' proposes this feature candidate.\n\nIntent: ${initiative.intent}`,
|
||||
priority: initiative.priority === "critical" ? "critical" : initiative.priority === "important" ? "important" : "normal",
|
||||
status: ReviewStatus.pending,
|
||||
source: "initiative_center",
|
||||
confidence: Math.max(62, (initiative.confidence ?? 72) - index * 6),
|
||||
approvalState: ApprovalState.pending,
|
||||
evidenceStrength: EvidenceStrength.medium,
|
||||
verificationState: VerificationState.needs_evidence,
|
||||
suggestedAction: "Review whether this feature should be promoted into the Feature Map.",
|
||||
createdBy: "api",
|
||||
updatedBy: "api",
|
||||
sourceType: "initiative_feature_candidate",
|
||||
targetEntityType: undefined,
|
||||
targetEntityId: initiative.id,
|
||||
payloadJson: toJson({ initiativeId: initiative.id, candidateTitle: title, candidateIndex: index }),
|
||||
provenanceJson: toJson({ createdFrom: "Initiative feature generation", initiativeId: initiative.id }),
|
||||
},
|
||||
});
|
||||
candidates.push({ id: `${initiative.id}-candidate-${index}`, title, confidence: Math.max(62, (initiative.confidence ?? 72) - index * 6), reviewItemId: review.slug ?? review.id, targetRoute: `/projects/${project.slug}/review-queue` });
|
||||
}
|
||||
|
||||
await prisma.operatingLogEntry.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug: `log-initiative-feature-candidates-${slugify(initiative.title)}-${Date.now().toString(36)}`,
|
||||
readableId: `log-initiative-feature-candidates-${slugify(initiative.title)}-${Date.now().toString(36)}`,
|
||||
type: "initiative planning",
|
||||
title: `Feature candidates generated for ${initiative.title}`,
|
||||
summary: `${candidates.length} feature candidates were generated and queued for review.`,
|
||||
linkedEntityIdsJson: toJson([initiative.id, ...candidates.map((item) => item.reviewItemId)]),
|
||||
source: "initiative_center",
|
||||
importance: initiative.priority === "critical" ? "critical" : initiative.priority === "important" ? "important" : "normal",
|
||||
payloadJson: toJson({ initiativeId: initiative.id, candidateCount: candidates.length }),
|
||||
provenanceJson: toJson({ source: "initiative_feature_generation" }),
|
||||
},
|
||||
});
|
||||
|
||||
return { initiative, candidates };
|
||||
};
|
||||
|
||||
|
||||
const safeRecord = (value: unknown): Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
|
||||
|
||||
const getString = (value: unknown, fallback = ""): string => typeof value === "string" && value.trim() ? value : fallback;
|
||||
|
||||
const acceptedTruthKindForInitiativeReview = (reviewType: string) =>
|
||||
reviewType === "feature" ? "feature" : reviewType;
|
||||
|
||||
const ensureInitiativeFeatureGroup = async (projectId: string) =>
|
||||
prisma.featureGroup.upsert({
|
||||
where: { projectId_slug: { projectId, slug: "initiative-roadmap" } },
|
||||
create: {
|
||||
projectId,
|
||||
slug: "initiative-roadmap",
|
||||
readableId: "initiative-roadmap",
|
||||
name: "Initiative Roadmap",
|
||||
description: "Features materialized from accepted roadmap initiative candidates.",
|
||||
focusArea: "Roadmap",
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
export const syncInitiativeReviewAction = async (
|
||||
projectIdOrSlug: string,
|
||||
reviewItemId: string,
|
||||
action: "accept" | "reject",
|
||||
): Promise<InitiativeReviewSyncResultDto | undefined> => {
|
||||
const project = await getProject(projectIdOrSlug);
|
||||
const review = await prisma.reviewQueueItem.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }],
|
||||
},
|
||||
});
|
||||
if (!review || review.sourceType !== "initiative_feature_candidate") return undefined;
|
||||
|
||||
const payload = safeRecord(review.payloadJson);
|
||||
const initiativeId = getString(payload.initiativeId, review.targetEntityId ?? "");
|
||||
const candidateTitle = getString(payload.candidateTitle, review.title.replace(/^Feature candidate:\s*/i, ""));
|
||||
const affectedEntities = [review.slug ?? review.id];
|
||||
|
||||
let featureId: string | undefined;
|
||||
let acceptedTruthId: string | undefined;
|
||||
|
||||
if (initiativeId) affectedEntities.push(initiativeId);
|
||||
|
||||
if (action === "accept") {
|
||||
const group = await ensureInitiativeFeatureGroup(project.id);
|
||||
const featureSlug = `initiative-feature-${slugify(candidateTitle)}-${Date.now().toString(36)}`;
|
||||
const feature = await prisma.feature.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
featureGroupId: group.id,
|
||||
slug: featureSlug,
|
||||
readableId: featureSlug,
|
||||
name: candidateTitle,
|
||||
description: review.description,
|
||||
status: FeatureStatus.todo,
|
||||
lifecycle: FeatureLifecycle.planned,
|
||||
intent: `Materialized from accepted roadmap initiative candidate. Initiative: ${initiativeId || "unknown"}.`,
|
||||
confidence: review.confidence,
|
||||
health: 65,
|
||||
approvalState: ApprovalState.approved,
|
||||
evidenceStrength: review.evidenceStrength,
|
||||
verificationState: VerificationState.needs_evidence,
|
||||
trustJson: toJson({ initiativeId, reviewItemId: review.slug ?? review.id, materializedFrom: "initiative_feature_candidate" }),
|
||||
provenanceJson: toJson({ source: "initiative_review_accept", initiativeId, reviewItemId: review.id, candidateTitle }),
|
||||
sourceType: "initiative_review_accept",
|
||||
createdBy: "review_queue",
|
||||
updatedBy: "review_queue",
|
||||
},
|
||||
});
|
||||
featureId = feature.slug ?? feature.id;
|
||||
affectedEntities.push(featureId);
|
||||
|
||||
if (initiativeId) {
|
||||
const initiative = await prisma.initiative.findFirst({
|
||||
where: { projectId: project.id, deletedAt: null, OR: [{ id: initiativeId }, { slug: initiativeId }, { readableId: initiativeId }] },
|
||||
});
|
||||
if (initiative) {
|
||||
const existingFeatureIds = toStringArray(initiative.featureIdsJson);
|
||||
await prisma.initiative.update({
|
||||
where: { id: initiative.id },
|
||||
data: {
|
||||
status: initiative.status === "proposed" ? "planned" : initiative.status,
|
||||
approvalState: ApprovalState.approved,
|
||||
featureIdsJson: toJson(Array.from(new Set([...existingFeatureIds, featureId]))),
|
||||
updatedBy: "review_queue",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const existingTruth = await prisma.acceptedTruthItem.findFirst({ where: { projectId: project.id, sourceReviewItemId: review.id, deletedAt: null } });
|
||||
if (existingTruth) acceptedTruthId = existingTruth.slug ?? existingTruth.id;
|
||||
else {
|
||||
const slug = `truth-initiative-feature-${slugify(candidateTitle)}-${Date.now().toString(36)}`;
|
||||
const truth = await prisma.acceptedTruthItem.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug,
|
||||
readableId: slug,
|
||||
sourceReviewItemId: review.id,
|
||||
kind: acceptedTruthKindForInitiativeReview(review.type),
|
||||
title: `Accepted initiative feature: ${candidateTitle}`,
|
||||
summary: review.description,
|
||||
status: "accepted",
|
||||
source: "initiative_center",
|
||||
confidence: review.confidence,
|
||||
approvalState: ApprovalState.approved,
|
||||
evidenceStrength: review.evidenceStrength,
|
||||
verificationState: VerificationState.verified,
|
||||
payloadJson: toJson({ initiativeId, featureId, reviewItemId: review.slug ?? review.id, candidateTitle, targetRoute: `/projects/${project.slug}/features/${featureId}` }),
|
||||
provenanceJson: toJson({ createdFrom: "Initiative feature candidate review acceptance", sourceType: review.sourceType }),
|
||||
verifiedAt: new Date(),
|
||||
verifiedBy: "api",
|
||||
},
|
||||
});
|
||||
acceptedTruthId = truth.slug ?? truth.id;
|
||||
}
|
||||
if (acceptedTruthId) affectedEntities.push(acceptedTruthId);
|
||||
}
|
||||
|
||||
await prisma.operatingLogEntry.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug: `log-initiative-review-${slugify(candidateTitle)}-${Date.now().toString(36)}`,
|
||||
readableId: `log-initiative-review-${slugify(candidateTitle)}-${Date.now().toString(36)}`,
|
||||
type: "initiative review sync",
|
||||
title: action === "accept" ? `Initiative feature accepted: ${candidateTitle}` : `Initiative feature rejected: ${candidateTitle}`,
|
||||
summary: action === "accept" ? "Accepted initiative feature candidate was materialized into the Feature Map and Accepted Truth." : "Rejected initiative feature candidate was left out of the Feature Map.",
|
||||
linkedEntityIdsJson: toJson(affectedEntities),
|
||||
source: "initiative_center",
|
||||
importance: action === "accept" ? "important" : "normal",
|
||||
payloadJson: toJson({ action, initiativeId, featureId, acceptedTruthId }),
|
||||
provenanceJson: toJson({ reviewItemId: review.id, sourceType: review.sourceType }),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
applied: true,
|
||||
action,
|
||||
reviewItemId: review.slug ?? review.id,
|
||||
initiativeId,
|
||||
featureId,
|
||||
acceptedTruthId,
|
||||
affectedEntities,
|
||||
message: action === "accept"
|
||||
? "Initiative feature candidate accepted and materialized into Feature Map."
|
||||
: "Initiative feature candidate rejected without materializing a feature.",
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,568 @@
|
||||
import { ApprovalState, type Prisma } from "@prisma/client";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import { listContextPacksForProject } from "./contextPacksRepository.js";
|
||||
import { selectContextPacks } from "../services/contextPackSelector.js";
|
||||
import type {
|
||||
CodexRunImportDto,
|
||||
CodexTaskDto,
|
||||
CodexTaskListFilters,
|
||||
CreateCodexTaskFromHandoffRequestDto,
|
||||
CreateHandoffBundleRequestDto,
|
||||
CurrentStateReportDto,
|
||||
CurrentStateReportListFilters,
|
||||
GenerateCurrentStateReportRequestDto,
|
||||
HandoffBundleDto,
|
||||
HandoffBundleListFilters,
|
||||
HandoffTarget,
|
||||
ImportCodexOutputRequestDto,
|
||||
P7MutationResponseDto,
|
||||
} from "../types/api.js";
|
||||
|
||||
type ProjectRecord = { id: string; slug: string; activeContextPackId: string | null };
|
||||
type CurrentStateRecord = Prisma.CurrentStateReportGetPayload<Record<string, never>>;
|
||||
type HandoffRecord = Prisma.HandoffBundleGetPayload<Record<string, never>>;
|
||||
type CodexTaskRecord = Prisma.CodexTaskGetPayload<Record<string, never>>;
|
||||
type CodexImportRecord = Prisma.CodexRunImportGetPayload<Record<string, never>>;
|
||||
|
||||
type PageOptions = { page: number; pageSize: number; sort?: string };
|
||||
|
||||
const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
|
||||
const asStrings = (value: Prisma.JsonValue | null | undefined): string[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
const asRecord = (value: Prisma.JsonValue | null | undefined): Record<string, unknown> | undefined =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
||||
const publicId = (record: { id: string; slug: string | null; readableId: string | null }) => record.slug ?? record.readableId ?? record.id;
|
||||
|
||||
type StoredContextSelection = {
|
||||
primaryContextPackId?: string;
|
||||
selectedContextPackIds?: string[];
|
||||
decisionSummary?: string;
|
||||
candidateCount?: number;
|
||||
};
|
||||
|
||||
const readStoredContextSelection = (value: Prisma.JsonValue | null | undefined): StoredContextSelection | undefined => {
|
||||
const record = asRecord(value);
|
||||
const rawSelection = asRecord(record?.contextSelection as Prisma.JsonValue | undefined);
|
||||
if (!rawSelection) return undefined;
|
||||
const primaryContextPackId = typeof rawSelection.primaryContextPackId === "string" ? rawSelection.primaryContextPackId : undefined;
|
||||
const selectedContextPackIds = asStrings(rawSelection.selectedContextPackIds as Prisma.JsonValue | undefined);
|
||||
const decisionSummary = typeof rawSelection.decisionSummary === "string" ? rawSelection.decisionSummary : undefined;
|
||||
const candidateCount = typeof rawSelection.candidateCount === "number" ? rawSelection.candidateCount : undefined;
|
||||
if (!primaryContextPackId && !selectedContextPackIds.length && !decisionSummary) return undefined;
|
||||
return { primaryContextPackId, selectedContextPackIds, decisionSummary, candidateCount };
|
||||
};
|
||||
|
||||
const findProjectRecord = (projectIdOrSlug: string): Promise<ProjectRecord | null> =>
|
||||
prisma.project.findFirst({
|
||||
where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }] },
|
||||
select: { id: true, slug: true, activeContextPackId: true },
|
||||
});
|
||||
|
||||
const findHandoffRecord = (projectId: string, idOrSlug: string) =>
|
||||
prisma.handoffBundle.findFirst({
|
||||
where: { projectId, deletedAt: null, OR: [{ id: idOrSlug }, { slug: idOrSlug }, { readableId: idOrSlug }] },
|
||||
});
|
||||
|
||||
const findCodexTaskRecord = (projectId: string, idOrSlug: string) =>
|
||||
prisma.codexTask.findFirst({
|
||||
where: { projectId, deletedAt: null, OR: [{ id: idOrSlug }, { slug: idOrSlug }, { readableId: idOrSlug }] },
|
||||
});
|
||||
|
||||
const paginateMeta = <T>(items: T[], total: number, page: number, pageSize: number, sort?: string, filters?: Record<string, unknown>) => ({
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
hasNextPage: page < Math.ceil(total / pageSize),
|
||||
hasPreviousPage: page > 1,
|
||||
...(sort ? { sort } : {}),
|
||||
...(filters ? { filters: filters as Record<string, string | number | boolean | string[]> } : {}),
|
||||
});
|
||||
|
||||
const toCurrentStateDto = (record: CurrentStateRecord, projectSlug: string): CurrentStateReportDto => ({
|
||||
id: publicId(record),
|
||||
projectId: projectSlug,
|
||||
generatedAt: record.generatedAt.toISOString(),
|
||||
exists: asStrings(record.existsJson),
|
||||
works: asStrings(record.worksJson),
|
||||
uncertain: asStrings(record.uncertainJson),
|
||||
broken: asStrings(record.brokenJson),
|
||||
currentRisks: asStrings(record.currentRisksJson),
|
||||
pendingReviewItemIds: asStrings(record.pendingReviewItemIdsJson),
|
||||
...(record.activeContextPackId ? { activeContextPackId: record.activeContextPackId } : {}),
|
||||
recommendedNextActions: asStrings(record.recommendedNextActionsJson),
|
||||
source: record.source,
|
||||
approvalState: record.approvalState,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
const toHandoffDto = (record: HandoffRecord, projectSlug: string): HandoffBundleDto => {
|
||||
const contextSelection = readStoredContextSelection(record.payloadJson);
|
||||
return {
|
||||
id: publicId(record),
|
||||
projectId: projectSlug,
|
||||
target: record.target as HandoffTarget,
|
||||
title: record.title,
|
||||
markdown: record.markdown,
|
||||
qualityScore: record.qualityScore,
|
||||
...(contextSelection?.primaryContextPackId ? { primaryContextPackId: contextSelection.primaryContextPackId } : {}),
|
||||
...(contextSelection?.selectedContextPackIds?.length ? { selectedContextPackIds: contextSelection.selectedContextPackIds } : {}),
|
||||
sourceTruthItemIds: asStrings(record.sourceTruthItemIdsJson),
|
||||
sourceReviewItemIds: asStrings(record.sourceReviewItemIdsJson),
|
||||
sourceCaptureItemIds: asStrings(record.sourceCaptureItemIdsJson),
|
||||
sourceProjectUpdateIds: asStrings(record.sourceProjectUpdateIdsJson),
|
||||
sourceOperatingLogEntryIds: asStrings(record.sourceOperatingLogEntryIdsJson),
|
||||
status: record.status as HandoffBundleDto["status"],
|
||||
approvalState: record.approvalState,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
const toCodexTaskDto = (record: CodexTaskRecord, projectSlug: string): CodexTaskDto => {
|
||||
const contextSelection = readStoredContextSelection(record.payloadJson);
|
||||
return {
|
||||
id: publicId(record),
|
||||
projectId: projectSlug,
|
||||
...(record.handoffBundleId ? { handoffBundleId: record.handoffBundleId } : {}),
|
||||
goal: record.goal,
|
||||
...(record.contextPackId ? { contextPackId: record.contextPackId } : {}),
|
||||
...(contextSelection?.selectedContextPackIds?.length ? { selectedContextPackIds: contextSelection.selectedContextPackIds } : {}),
|
||||
...(contextSelection?.decisionSummary ? { contextSelectionSummary: contextSelection.decisionSummary } : {}),
|
||||
constraints: asStrings(record.constraintsJson),
|
||||
relevantFiles: asStrings(record.relevantFilesJson),
|
||||
doNotBreakRules: asStrings(record.doNotBreakRulesJson),
|
||||
validationCommands: asStrings(record.validationCommandsJson),
|
||||
expectedReportFormat: record.expectedReportFormat,
|
||||
outputImportInstructions: record.outputImportInstructions,
|
||||
approvalState: record.approvalState,
|
||||
status: record.status as CodexTaskDto["status"],
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
const toCodexImportDto = (record: CodexImportRecord, projectSlug: string): CodexRunImportDto => {
|
||||
const contextSelection = readStoredContextSelection(record.payloadJson);
|
||||
return {
|
||||
id: publicId(record),
|
||||
projectId: projectSlug,
|
||||
...(record.codexTaskId ? { codexTaskId: record.codexTaskId } : {}),
|
||||
sourceRun: record.sourceRun,
|
||||
summary: record.summary,
|
||||
...(contextSelection?.primaryContextPackId ? { contextPackId: contextSelection.primaryContextPackId } : {}),
|
||||
...(contextSelection?.selectedContextPackIds?.length ? { selectedContextPackIds: contextSelection.selectedContextPackIds } : {}),
|
||||
...(contextSelection?.decisionSummary ? { contextSelectionSummary: contextSelection.decisionSummary } : {}),
|
||||
extractedReviewItemIds: asStrings(record.extractedReviewItemIdsJson),
|
||||
status: record.status as CodexRunImportDto["status"],
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveRuntimeContextSelection = async (args: {
|
||||
projectSlug: string;
|
||||
activeContextPackId?: string | null;
|
||||
target: HandoffTarget | "codex-task";
|
||||
title: string;
|
||||
intent?: string;
|
||||
explicitContextPackId?: string;
|
||||
existingSelection?: StoredContextSelection;
|
||||
}): Promise<StoredContextSelection | undefined> => {
|
||||
if (args.explicitContextPackId) {
|
||||
return {
|
||||
primaryContextPackId: args.explicitContextPackId,
|
||||
selectedContextPackIds: [args.explicitContextPackId],
|
||||
decisionSummary: `Explicit context pack '${args.explicitContextPackId}' was supplied by the caller.`,
|
||||
candidateCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (args.existingSelection?.primaryContextPackId || args.existingSelection?.selectedContextPackIds?.length) {
|
||||
return args.existingSelection;
|
||||
}
|
||||
|
||||
const contextPacks = await listContextPacksForProject(args.projectSlug);
|
||||
if (!contextPacks?.length) return undefined;
|
||||
|
||||
const mode = args.target === "codex-task" ? "codex" : args.target === "current-state" ? "validation" : "workspace";
|
||||
const selection = selectContextPacks(
|
||||
args.projectSlug,
|
||||
contextPacks,
|
||||
{
|
||||
mode,
|
||||
intent: args.intent?.trim() || `${args.title}. Runtime handoff should preserve accepted truth, review discipline and current project constraints.`,
|
||||
targetType: args.target === "codex-task" ? "build" : "project",
|
||||
maxPacks: 3,
|
||||
},
|
||||
args.activeContextPackId ?? undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
primaryContextPackId: selection.primaryContextPackId,
|
||||
selectedContextPackIds: selection.selectedContextPackIds,
|
||||
decisionSummary: selection.decisionSummary,
|
||||
candidateCount: selection.candidates.length,
|
||||
};
|
||||
};
|
||||
|
||||
const mkSlug = (prefix: string, seed: string) => `${prefix}-${seed.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 42)}-${Date.now().toString(36)}`;
|
||||
|
||||
export const listCurrentStateReportsFromDatabase = async (projectIdOrSlug: string, filters: CurrentStateReportListFilters, options: PageOptions) => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const where: Prisma.CurrentStateReportWhereInput = {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
...(filters.approvalState?.length ? { approvalState: { in: filters.approvalState } } : {}),
|
||||
...(filters.source?.length ? { source: { in: filters.source } } : {}),
|
||||
};
|
||||
const total = await prisma.currentStateReport.count({ where });
|
||||
const records = await prisma.currentStateReport.findMany({
|
||||
where,
|
||||
orderBy: { generatedAt: options.sort?.endsWith(":asc") ? "asc" : "desc" },
|
||||
skip: (options.page - 1) * options.pageSize,
|
||||
take: options.pageSize,
|
||||
});
|
||||
return paginateMeta(records.map((record) => toCurrentStateDto(record, project.slug)), total, options.page, options.pageSize, options.sort, filters);
|
||||
};
|
||||
|
||||
export const generateCurrentStateReportInDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
_body: GenerateCurrentStateReportRequestDto,
|
||||
): Promise<CurrentStateReportDto | undefined> => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const [truth, updates, review, logs, contextPack] = await Promise.all([
|
||||
prisma.acceptedTruthItem.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { createdAt: "desc" }, take: 8 }),
|
||||
prisma.projectUpdate.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { createdAt: "desc" }, take: 8 }),
|
||||
prisma.reviewQueueItem.findMany({ where: { projectId: project.id, deletedAt: null, status: "pending" }, orderBy: { createdAt: "desc" }, take: 12 }),
|
||||
prisma.operatingLogEntry.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { timestamp: "desc" }, take: 8 }),
|
||||
prisma.contextPack.findFirst({ where: { projectId: project.id, deletedAt: null }, orderBy: { updatedAt: "desc" } }),
|
||||
]);
|
||||
const exists = ["Project", "Workspace", "Project DNA", "Feature/Evidence DB parity", "Capture/Review DB parity", "Accepted Truth/Project Updates/Operating Log DB parity"];
|
||||
const works = [
|
||||
...truth.slice(0, 3).map((item) => `Accepted truth: ${item.title}`),
|
||||
...updates.slice(0, 3).map((item) => `Project update ${item.status}: ${item.title}`),
|
||||
...logs.slice(0, 2).map((item) => `Operating log: ${item.title}`),
|
||||
];
|
||||
const uncertain = review.length ? [`${review.length} pending Review Queue item(s) still require approval.`] : [];
|
||||
const broken = updates.filter((item) => item.status !== "applied").length ? ["Some project updates are not applied yet."] : [];
|
||||
const currentRisks = [
|
||||
"Generated reports remain reviewable artifacts; they do not mutate project truth silently.",
|
||||
...(review.length ? ["Pending review items may hide unaccepted build or product facts."] : []),
|
||||
];
|
||||
const recommendedNextActions = [
|
||||
updates.some((item) => item.status !== "applied") ? "Apply ready project updates after review." : "Prepare the next context handoff from accepted truth.",
|
||||
"Create a Codex handoff bundle from the latest accepted truth and current state.",
|
||||
"Import Codex/build output through Capture and Review Queue, not directly into truth.",
|
||||
];
|
||||
const record = await prisma.currentStateReport.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug: mkSlug("current-state", project.slug),
|
||||
readableId: mkSlug("csr", project.slug),
|
||||
existsJson: toJson(exists),
|
||||
worksJson: toJson(works.length ? works : ["Backend persistence gates up to P6 are available."]),
|
||||
uncertainJson: toJson(uncertain),
|
||||
brokenJson: toJson(broken),
|
||||
currentRisksJson: toJson(currentRisks),
|
||||
pendingReviewItemIdsJson: toJson(review.map((item) => item.slug ?? item.readableId ?? item.id)),
|
||||
activeContextPackId: project.activeContextPackId ?? contextPack?.slug ?? contextPack?.readableId ?? contextPack?.id,
|
||||
recommendedNextActionsJson: toJson(recommendedNextActions),
|
||||
source: "database-runtime-generator",
|
||||
approvalState: ApprovalState.pending,
|
||||
provenanceJson: toJson({ createdFrom: "P7 current state generator" }),
|
||||
},
|
||||
});
|
||||
await prisma.operatingLogEntry.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug: mkSlug("log-current-state", project.slug),
|
||||
readableId: mkSlug("log", project.slug),
|
||||
type: "build summary",
|
||||
title: "Current State report generated",
|
||||
summary: `Generated ${publicId(record)} from accepted truth, project updates and operating log.`,
|
||||
linkedEntityIdsJson: toJson([record.id]),
|
||||
source: "p7-current-state",
|
||||
importance: "important",
|
||||
},
|
||||
});
|
||||
return toCurrentStateDto(record, project.slug);
|
||||
};
|
||||
|
||||
const markdownForHandoff = (args: {
|
||||
projectSlug: string;
|
||||
target: HandoffTarget;
|
||||
title: string;
|
||||
truth: Array<{ id: string; slug: string | null; readableId: string | null; title: string; kind: string; summary: string }>;
|
||||
updates: Array<{ id: string; slug: string | null; readableId: string | null; title: string; targetArea: string; status: string; summary: string }>;
|
||||
logs: Array<{ id: string; slug: string | null; readableId: string | null; title: string; type: string; summary: string }>;
|
||||
contextSelection?: StoredContextSelection;
|
||||
}) => [
|
||||
`# ${args.title}`,
|
||||
"",
|
||||
`Project: ${args.projectSlug}`,
|
||||
`Target: ${args.target}`,
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
"",
|
||||
"## Automatic context pack selection",
|
||||
args.contextSelection?.primaryContextPackId ? `Primary pack: ${args.contextSelection.primaryContextPackId}` : "Primary pack: none",
|
||||
args.contextSelection?.selectedContextPackIds?.length ? `Selected packs: ${args.contextSelection.selectedContextPackIds.join(", ")}` : "Selected packs: none",
|
||||
args.contextSelection?.decisionSummary ? `Decision: ${args.contextSelection.decisionSummary}` : "Decision: no selector result attached",
|
||||
typeof args.contextSelection?.candidateCount === "number" ? `Candidates considered: ${args.contextSelection.candidateCount}` : "Candidates considered: 0",
|
||||
"",
|
||||
"## Accepted truth",
|
||||
...(args.truth.length ? args.truth.map((item) => `- [${item.kind}] ${item.title}: ${item.summary}`) : ["- None yet."]),
|
||||
"",
|
||||
"## Project updates",
|
||||
...(args.updates.length ? args.updates.map((item) => `- [${item.status}] [${item.targetArea}] ${item.title}: ${item.summary}`) : ["- None yet."]),
|
||||
"",
|
||||
"## Operating log context",
|
||||
...(args.logs.length ? args.logs.map((item) => `- [${item.type}] ${item.title}: ${item.summary}`) : ["- None yet."]),
|
||||
"",
|
||||
"## Guardrails",
|
||||
"- Do not mutate project truth directly from AI/build output.",
|
||||
"- Route output back through Capture and Review Queue.",
|
||||
"- Preserve existing API contracts and fixture mode unless explicitly changing a persistence gate.",
|
||||
].join("\n");
|
||||
|
||||
export const listHandoffBundlesFromDatabase = async (projectIdOrSlug: string, filters: HandoffBundleListFilters, options: PageOptions) => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const where: Prisma.HandoffBundleWhereInput = {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
...(filters.target?.length ? { target: { in: filters.target } } : {}),
|
||||
...(filters.status?.length ? { status: { in: filters.status } } : {}),
|
||||
...(filters.approvalState?.length ? { approvalState: { in: filters.approvalState } } : {}),
|
||||
};
|
||||
const total = await prisma.handoffBundle.count({ where });
|
||||
const records = await prisma.handoffBundle.findMany({ where, orderBy: { createdAt: "desc" }, skip: (options.page - 1) * options.pageSize, take: options.pageSize });
|
||||
return paginateMeta(records.map((record) => toHandoffDto(record, project.slug)), total, options.page, options.pageSize, options.sort, filters);
|
||||
};
|
||||
|
||||
export const createHandoffBundleInDatabase = async (projectIdOrSlug: string, body: CreateHandoffBundleRequestDto): Promise<HandoffBundleDto | undefined> => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const [truth, updates, review, captures, logs] = await Promise.all([
|
||||
prisma.acceptedTruthItem.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { createdAt: "desc" }, take: 12 }),
|
||||
prisma.projectUpdate.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { createdAt: "desc" }, take: 12 }),
|
||||
prisma.reviewQueueItem.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { createdAt: "desc" }, take: 8 }),
|
||||
prisma.captureItem.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { createdAt: "desc" }, take: 8 }),
|
||||
prisma.operatingLogEntry.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { timestamp: "desc" }, take: 8 }),
|
||||
]);
|
||||
const target = body.target ?? "codex-task";
|
||||
const title = body.title?.trim() || `${target} handoff for ${project.slug}`;
|
||||
const contextSelection = await resolveRuntimeContextSelection({
|
||||
projectSlug: project.slug,
|
||||
activeContextPackId: project.activeContextPackId,
|
||||
target,
|
||||
title,
|
||||
intent: body.selectionIntent,
|
||||
explicitContextPackId: body.contextPackId,
|
||||
});
|
||||
const qualityScore = Math.min(98, 45 + truth.length * 10 + updates.length * 5 + logs.length * 2 + (contextSelection?.primaryContextPackId ? 6 : 0));
|
||||
const markdown = markdownForHandoff({ projectSlug: project.slug, target, title, truth, updates, logs, contextSelection });
|
||||
const record = await prisma.handoffBundle.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug: mkSlug("handoff", title),
|
||||
readableId: mkSlug("hb", project.slug),
|
||||
target,
|
||||
title,
|
||||
markdown,
|
||||
qualityScore,
|
||||
sourceTruthItemIdsJson: toJson(truth.map(publicId)),
|
||||
sourceReviewItemIdsJson: toJson(review.map(publicId)),
|
||||
sourceCaptureItemIdsJson: toJson(captures.map(publicId)),
|
||||
sourceProjectUpdateIdsJson: toJson(updates.map(publicId)),
|
||||
sourceOperatingLogEntryIdsJson: toJson(logs.map(publicId)),
|
||||
status: "ready",
|
||||
approvalState: ApprovalState.pending,
|
||||
payloadJson: toJson({ contextSelection }),
|
||||
provenanceJson: toJson({ createdFrom: "P7 handoff bundle generator", contextSelectionSummary: contextSelection?.decisionSummary }),
|
||||
},
|
||||
});
|
||||
await prisma.operatingLogEntry.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug: mkSlug("log-handoff", project.slug),
|
||||
readableId: mkSlug("log", project.slug),
|
||||
type: "project update",
|
||||
title: "Handoff bundle created",
|
||||
summary: `${title} created for ${target} with ${qualityScore}% quality.`,
|
||||
linkedEntityIdsJson: toJson([record.id]),
|
||||
source: "p7-handoff",
|
||||
importance: qualityScore >= 75 ? "important" : "normal",
|
||||
},
|
||||
});
|
||||
return toHandoffDto(record, project.slug);
|
||||
};
|
||||
|
||||
export const listCodexTasksFromDatabase = async (projectIdOrSlug: string, filters: CodexTaskListFilters, options: PageOptions) => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const handoffFilter = filters.handoffBundleId ? await findHandoffRecord(project.id, filters.handoffBundleId) : undefined;
|
||||
if (filters.handoffBundleId && !handoffFilter) {
|
||||
return paginateMeta([], 0, options.page, options.pageSize, options.sort, filters);
|
||||
}
|
||||
const where: Prisma.CodexTaskWhereInput = {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
...(filters.status?.length ? { status: { in: filters.status } } : {}),
|
||||
...(filters.approvalState?.length ? { approvalState: { in: filters.approvalState } } : {}),
|
||||
...(handoffFilter ? { handoffBundleId: handoffFilter.id } : {}),
|
||||
};
|
||||
const total = await prisma.codexTask.count({ where });
|
||||
const records = await prisma.codexTask.findMany({ where, orderBy: { createdAt: "desc" }, skip: (options.page - 1) * options.pageSize, take: options.pageSize });
|
||||
return paginateMeta(records.map((record) => toCodexTaskDto(record, project.slug)), total, options.page, options.pageSize, options.sort, filters);
|
||||
};
|
||||
|
||||
export const createCodexTaskFromHandoffInDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
handoffBundleId: string,
|
||||
body: CreateCodexTaskFromHandoffRequestDto,
|
||||
): Promise<CodexTaskDto | undefined | "HANDOFF_NOT_FOUND"> => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const handoff = await findHandoffRecord(project.id, handoffBundleId);
|
||||
if (!handoff) return "HANDOFF_NOT_FOUND";
|
||||
const goal = body.goal?.trim() || `Implement ${handoff.title}`;
|
||||
const existingSelection = readStoredContextSelection(handoff.payloadJson);
|
||||
const contextSelection = body.autoSelectContext === false
|
||||
? await resolveRuntimeContextSelection({
|
||||
projectSlug: project.slug,
|
||||
activeContextPackId: project.activeContextPackId,
|
||||
target: "codex-task",
|
||||
title: goal,
|
||||
explicitContextPackId: body.contextPackId ?? existingSelection?.primaryContextPackId ?? project.activeContextPackId ?? undefined,
|
||||
existingSelection,
|
||||
})
|
||||
: await resolveRuntimeContextSelection({
|
||||
projectSlug: project.slug,
|
||||
activeContextPackId: project.activeContextPackId,
|
||||
target: "codex-task",
|
||||
title: goal,
|
||||
intent: body.selectionIntent ?? `${goal}. ${handoff.title}.`,
|
||||
explicitContextPackId: body.contextPackId,
|
||||
existingSelection,
|
||||
});
|
||||
const selectedContextPackId = body.contextPackId ?? contextSelection?.primaryContextPackId ?? project.activeContextPackId ?? undefined;
|
||||
const record = await prisma.codexTask.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug: mkSlug("codex-task", goal),
|
||||
readableId: mkSlug("ct", project.slug),
|
||||
handoffBundleId: handoff.id,
|
||||
goal,
|
||||
contextPackId: selectedContextPackId,
|
||||
constraintsJson: toJson(["Preserve fixture mode.", "Do not write AI output directly into project truth.", "Keep review-before-truth semantics."]),
|
||||
relevantFilesJson: toJson(["backend/src/routes", "backend/src/repositories", "src/features/operations", "docs"]),
|
||||
doNotBreakRulesJson: toJson(["No Vite middleware expansion.", "No frontend default switch to real mode.", "No silent truth mutation."]),
|
||||
validationCommandsJson: toJson(body.validationCommands?.length ? body.validationCommands : ["pnpm run validate:all", "pnpm run audit:runtime", "pnpm run audit:release"]),
|
||||
expectedReportFormat: "Changed files, validation commands, remaining warnings, smoke status and next checklist.",
|
||||
outputImportInstructions: "Import Codex/build output as Capture, extract Review Queue items, and only materialize approved truth after review.",
|
||||
approvalState: ApprovalState.pending,
|
||||
status: "ready",
|
||||
payloadJson: toJson({ contextSelection }),
|
||||
provenanceJson: toJson({ handoffBundleId: publicId(handoff), createdFrom: "P7 handoff", contextSelectionSummary: contextSelection?.decisionSummary }),
|
||||
},
|
||||
});
|
||||
await prisma.handoffBundle.update({ where: { id: handoff.id }, data: { status: "used" } });
|
||||
await prisma.operatingLogEntry.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug: mkSlug("log-codex-task", project.slug),
|
||||
readableId: mkSlug("log", project.slug),
|
||||
type: "codex run",
|
||||
title: "Codex task created from handoff",
|
||||
summary: `${goal} was created from ${handoff.title}.${selectedContextPackId ? ` Context pack: ${selectedContextPackId}.` : ""}`,
|
||||
linkedEntityIdsJson: toJson([handoff.id, record.id]),
|
||||
source: "p7-codex-task",
|
||||
importance: "important",
|
||||
},
|
||||
});
|
||||
return toCodexTaskDto(record, project.slug);
|
||||
};
|
||||
|
||||
export const importCodexOutputInDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
codexTaskId: string,
|
||||
body: ImportCodexOutputRequestDto,
|
||||
): Promise<{ importDto: CodexRunImportDto; mutation: P7MutationResponseDto } | undefined | "CODEX_TASK_NOT_FOUND"> => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const task = await findCodexTaskRecord(project.id, codexTaskId);
|
||||
if (!task) return "CODEX_TASK_NOT_FOUND";
|
||||
const contextSelection = readStoredContextSelection(task.payloadJson) ?? (task.contextPackId
|
||||
? {
|
||||
primaryContextPackId: task.contextPackId,
|
||||
selectedContextPackIds: [task.contextPackId],
|
||||
decisionSummary: `Imported output inherits Codex task context pack '${task.contextPackId}'.`,
|
||||
candidateCount: 1,
|
||||
}
|
||||
: undefined);
|
||||
const review = await prisma.reviewQueueItem.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug: mkSlug("review-codex-output", project.slug),
|
||||
readableId: mkSlug("rq", project.slug),
|
||||
type: "documentation_update",
|
||||
title: `Review Codex output for ${task.goal}`,
|
||||
description: body.summary,
|
||||
priority: "important",
|
||||
status: "pending",
|
||||
source: body.sourceRun ?? task.slug ?? task.id,
|
||||
confidence: 72,
|
||||
approvalState: ApprovalState.pending,
|
||||
evidenceStrength: "medium",
|
||||
verificationState: "unverified",
|
||||
suggestedAction: "Review generated changes, then accept only verified feature, documentation, memory or regression updates.",
|
||||
createdBy: "api",
|
||||
updatedBy: "api",
|
||||
sourceType: "codex_output_import",
|
||||
payloadJson: toJson({ outputText: body.outputText ?? "", codexTaskId: task.id, contextSelection }),
|
||||
provenanceJson: toJson({ createdFrom: "P7 codex import", contextSelectionSummary: contextSelection?.decisionSummary }),
|
||||
},
|
||||
});
|
||||
const record = await prisma.codexRunImport.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug: mkSlug("codex-import", project.slug),
|
||||
readableId: mkSlug("cri", project.slug),
|
||||
codexTaskId: task.id,
|
||||
sourceRun: body.sourceRun ?? `manual-${Date.now().toString(36)}`,
|
||||
summary: body.summary,
|
||||
extractedReviewItemIdsJson: toJson([publicId(review)]),
|
||||
status: "imported",
|
||||
payloadJson: toJson({ outputText: body.outputText ?? "", contextSelection }),
|
||||
provenanceJson: toJson({ createdFrom: "P7 codex output import", contextSelectionSummary: contextSelection?.decisionSummary }),
|
||||
},
|
||||
});
|
||||
await prisma.codexTask.update({ where: { id: task.id }, data: { status: "imported" } });
|
||||
await prisma.operatingLogEntry.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug: mkSlug("log-codex-import", project.slug),
|
||||
readableId: mkSlug("log", project.slug),
|
||||
type: "codex run",
|
||||
title: "Codex output imported to Review Queue",
|
||||
summary: `${body.summary}${contextSelection?.primaryContextPackId ? ` Context pack: ${contextSelection.primaryContextPackId}.` : ""}`,
|
||||
linkedEntityIdsJson: toJson([task.id, record.id, review.id]),
|
||||
source: "p7-codex-import",
|
||||
importance: "important",
|
||||
},
|
||||
});
|
||||
return {
|
||||
importDto: toCodexImportDto(record, project.slug),
|
||||
mutation: {
|
||||
success: true,
|
||||
message: "Codex output imported as a Review Queue item.",
|
||||
createdEntityId: publicId(review),
|
||||
updatedEntityId: publicId(task),
|
||||
affectedEntities: [publicId(task), publicId(record), publicId(review)],
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { ApprovalState, EvidenceStrength, VerificationState } from "@prisma/client";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import type {
|
||||
KnowledgeItemDto,
|
||||
KnowledgeListFilters,
|
||||
KnowledgeMutationRequestDto,
|
||||
MemoryRuleDto,
|
||||
MemoryRuleListFilters,
|
||||
MemoryRuleMutationRequestDto,
|
||||
} from "../types/api.js";
|
||||
|
||||
export type DocumentationItemDto = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
linkedFeatureIds: string[];
|
||||
lastUpdated?: string;
|
||||
primaryContextPackId?: string;
|
||||
selectedContextPackIds?: string[];
|
||||
contextSelectionSummary?: string;
|
||||
approvalState: "pending" | "approved" | "rejected" | "blocked";
|
||||
content?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type ApprovalStateInput = ApprovalState | "pending" | "approved" | "rejected" | "blocked";
|
||||
|
||||
export type DocumentationListFilters = {
|
||||
kind?: string[];
|
||||
status?: string[];
|
||||
approvalState?: string[];
|
||||
search?: string;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
|
||||
const toStringArray = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
|
||||
|
||||
export const projectExists = async (projectIdOrSlug: string): Promise<boolean> => {
|
||||
const project = await prisma.project.findFirst({ where: { OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }], deletedAt: null }, select: { id: true }});
|
||||
return Boolean(project);
|
||||
};
|
||||
|
||||
const getProject = async (projectIdOrSlug: string) => {
|
||||
const project = await prisma.project.findFirst({ where: { OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }], deletedAt: null }});
|
||||
if (!project) throw new Error(`Project '${projectIdOrSlug}' not found`);
|
||||
return project;
|
||||
};
|
||||
|
||||
const metadata = (value: Prisma.JsonValue | null | undefined): Record<string, unknown> => isRecord(value) ? value : {};
|
||||
|
||||
const trust = (source: string, confidence: number, approvalState: ApprovalState, createdAt: Date) => ({
|
||||
source,
|
||||
confidence,
|
||||
evidence: [],
|
||||
lastChecked: createdAt.toISOString(),
|
||||
approvalState,
|
||||
});
|
||||
|
||||
export const toKnowledgeDto = (item: {
|
||||
slug: string | null; readableId: string | null; project: { slug: string }; title: string; category: string; content: string; source: string; confidence: number; metadataJson: Prisma.JsonValue | null; trustJson: Prisma.JsonValue | null; approvalState: ApprovalState; createdAt: Date; updatedAt: Date;
|
||||
}): KnowledgeItemDto => {
|
||||
const meta = metadata(item.metadataJson);
|
||||
const trustJson = isRecord(item.trustJson) ? item.trustJson : trust(item.source, item.confidence, item.approvalState, item.updatedAt);
|
||||
return {
|
||||
id: item.slug ?? item.readableId ?? item.title,
|
||||
projectId: item.project.slug,
|
||||
title: item.title,
|
||||
category: item.category,
|
||||
content: item.content,
|
||||
linkedFeatureIds: toStringArray(meta.linkedFeatureIds),
|
||||
source: item.source,
|
||||
confidence: item.confidence,
|
||||
includeInContextPacks: toStringArray(meta.includeInContextPacks),
|
||||
trust: trustJson as KnowledgeItemDto["trust"],
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const toMemoryRuleDto = (rule: {
|
||||
slug: string | null; readableId: string | null; project: { slug: string }; title: string; rule: string; scope: string; category: string; severity: string; includeInContextPacks: boolean; confidence: number; trustJson: Prisma.JsonValue | null; provenanceJson: Prisma.JsonValue | null; approvalState: ApprovalState; createdAt: Date; updatedAt: Date;
|
||||
}): MemoryRuleDto => {
|
||||
const meta = metadata(rule.provenanceJson);
|
||||
const trustJson = isRecord(rule.trustJson) ? rule.trustJson : trust("database memory rule", rule.confidence, rule.approvalState, rule.updatedAt);
|
||||
return {
|
||||
id: rule.slug ?? rule.readableId ?? rule.title,
|
||||
projectId: rule.project.slug,
|
||||
title: rule.title,
|
||||
rule: rule.rule,
|
||||
scope: rule.scope as MemoryRuleDto["scope"],
|
||||
category: rule.category as MemoryRuleDto["category"],
|
||||
severity: rule.severity as MemoryRuleDto["severity"],
|
||||
linkedFeatureIds: toStringArray(meta.linkedFeatureIds),
|
||||
includeInContextPacks: toStringArray(meta.includeInContextPacks),
|
||||
includeInContextPacksDefault: rule.includeInContextPacks,
|
||||
trust: trustJson as MemoryRuleDto["trust"],
|
||||
createdAt: rule.createdAt.toISOString(),
|
||||
updatedAt: rule.updatedAt.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const toDocumentationDto = (doc: {
|
||||
slug: string | null; readableId: string | null; project: { slug: string }; title: string; kind: string; status: string; approvalState: ApprovalState; content: string | null; metadataJson: Prisma.JsonValue | null; createdAt: Date; updatedAt: Date;
|
||||
}): DocumentationItemDto => {
|
||||
const meta = metadata(doc.metadataJson);
|
||||
return {
|
||||
id: doc.slug ?? doc.readableId ?? doc.title,
|
||||
projectId: doc.project.slug,
|
||||
title: doc.title,
|
||||
kind: doc.kind,
|
||||
status: doc.status,
|
||||
linkedFeatureIds: toStringArray(meta.linkedFeatureIds),
|
||||
lastUpdated: typeof meta.lastUpdated === "string" ? meta.lastUpdated : doc.updatedAt.toISOString(),
|
||||
primaryContextPackId: typeof meta.primaryContextPackId === "string" ? meta.primaryContextPackId : undefined,
|
||||
selectedContextPackIds: toStringArray(meta.selectedContextPackIds),
|
||||
contextSelectionSummary: typeof meta.contextSelectionSummary === "string" ? meta.contextSelectionSummary : undefined,
|
||||
approvalState: doc.approvalState,
|
||||
content: doc.content ?? undefined,
|
||||
createdAt: doc.createdAt.toISOString(),
|
||||
updatedAt: doc.updatedAt.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
const filterSearch = (haystack: string, search?: string) => !search || haystack.toLowerCase().includes(search.toLowerCase());
|
||||
|
||||
export const listKnowledgeForProject = async (projectId: string, filters: KnowledgeListFilters = {}): Promise<KnowledgeItemDto[]> => {
|
||||
const project = await getProject(projectId);
|
||||
const rows = await prisma.knowledgeItem.findMany({ where: { projectId: project.id, deletedAt: null }, include: { project: { select: { slug: true } } }, orderBy: { updatedAt: "desc" }});
|
||||
return rows.map(toKnowledgeDto).filter((item) =>
|
||||
(!filters.category?.length || filters.category.includes(item.category)) &&
|
||||
(!filters.source?.length || filters.source.includes(item.source)) &&
|
||||
(filters.confidence === undefined || item.confidence === filters.confidence) &&
|
||||
(!filters.featureId || item.linkedFeatureIds.includes(filters.featureId)) &&
|
||||
filterSearch(`${item.title} ${item.content} ${item.category}`, filters.search)
|
||||
);
|
||||
};
|
||||
|
||||
export const getKnowledgeBySlugOrId = async (projectId: string, knowledgeId: string): Promise<KnowledgeItemDto | undefined> => {
|
||||
const project = await getProject(projectId);
|
||||
const row = await prisma.knowledgeItem.findFirst({ where: { projectId: project.id, OR: [{ id: knowledgeId }, { slug: knowledgeId }, { readableId: knowledgeId }], deletedAt: null }, include: { project: { select: { slug: true } } }});
|
||||
return row ? toKnowledgeDto(row) : undefined;
|
||||
};
|
||||
|
||||
export const createKnowledgeItem = async (projectId: string, body: KnowledgeMutationRequestDto & { title: string; content: string }): Promise<KnowledgeItemDto> => {
|
||||
const project = await getProject(projectId);
|
||||
const now = new Date();
|
||||
const confidence = body.confidence ?? 75;
|
||||
const slug = `kn-${project.slug}-${Date.now().toString(36)}`;
|
||||
const row = await prisma.knowledgeItem.create({ data: { projectId: project.id, slug, readableId: slug, title: body.title.trim(), category: body.category?.trim() || "Technical", content: body.content.trim(), source: body.source?.trim() || "manual", confidence, includeInContextPacks: false, approvalState: ApprovalState.pending, evidenceStrength: EvidenceStrength.medium, verificationState: VerificationState.unverified, metadataJson: toJson({ linkedFeatureIds: body.linkedFeatureIds ?? [], includeInContextPacks: [] }), trustJson: toJson(trust("database knowledge create", confidence, ApprovalState.pending, now)), createdBy: "api", updatedBy: "api", sourceType: "database_api" }, include: { project: { select: { slug: true } } }});
|
||||
return toKnowledgeDto(row);
|
||||
};
|
||||
|
||||
export const updateKnowledgeItem = async (projectId: string, knowledgeId: string, body: KnowledgeMutationRequestDto): Promise<KnowledgeItemDto | undefined> => {
|
||||
const project = await getProject(projectId);
|
||||
const current = await prisma.knowledgeItem.findFirst({ where: { projectId: project.id, OR: [{ id: knowledgeId }, { slug: knowledgeId }, { readableId: knowledgeId }], deletedAt: null }});
|
||||
if (!current) return undefined;
|
||||
const meta = metadata(current.metadataJson);
|
||||
const confidence = body.confidence ?? current.confidence;
|
||||
const row = await prisma.knowledgeItem.update({ where: { id: current.id }, data: { ...(body.title !== undefined ? { title: body.title.trim() } : {}), ...(body.category !== undefined ? { category: body.category.trim() } : {}), ...(body.content !== undefined ? { content: body.content.trim() } : {}), ...(body.source !== undefined ? { source: body.source.trim() } : {}), ...(body.confidence !== undefined ? { confidence } : {}), ...(body.linkedFeatureIds !== undefined ? { metadataJson: toJson({ ...meta, linkedFeatureIds: body.linkedFeatureIds }) } : {}), updatedBy: "api", trustJson: toJson(trust("database knowledge update", confidence, current.approvalState, new Date())) }, include: { project: { select: { slug: true } } }});
|
||||
return toKnowledgeDto(row);
|
||||
};
|
||||
|
||||
export const includeKnowledgeInContextPack = async (projectId: string, knowledgeId: string, contextPackId: string): Promise<KnowledgeItemDto | undefined> => {
|
||||
const project = await getProject(projectId);
|
||||
const contextPack = await prisma.contextPack.findFirst({ where: { projectId: project.id, OR: [{ id: contextPackId }, { slug: contextPackId }, { readableId: contextPackId }], deletedAt: null }});
|
||||
if (!contextPack) throw new Error("CONTEXT_PACK_NOT_FOUND");
|
||||
const current = await prisma.knowledgeItem.findFirst({ where: { projectId: project.id, OR: [{ id: knowledgeId }, { slug: knowledgeId }, { readableId: knowledgeId }], deletedAt: null }});
|
||||
if (!current) return undefined;
|
||||
const meta = metadata(current.metadataJson);
|
||||
const list = new Set(toStringArray(meta.includeInContextPacks));
|
||||
list.add(contextPack.slug ?? contextPack.readableId ?? contextPack.id);
|
||||
const row = await prisma.knowledgeItem.update({ where: { id: current.id }, data: { includeInContextPacks: true, metadataJson: toJson({ ...meta, includeInContextPacks: [...list] }), updatedBy: "api" }, include: { project: { select: { slug: true } } }});
|
||||
return toKnowledgeDto(row);
|
||||
};
|
||||
|
||||
export const listMemoryRulesForProject = async (projectId: string, filters: MemoryRuleListFilters = {}): Promise<MemoryRuleDto[]> => {
|
||||
const project = await getProject(projectId);
|
||||
const rows = await prisma.memoryRule.findMany({ where: { projectId: project.id, deletedAt: null }, include: { project: { select: { slug: true } } }, orderBy: { updatedAt: "desc" }});
|
||||
return rows.map(toMemoryRuleDto).filter((item) =>
|
||||
(!filters.scope?.length || filters.scope.includes(item.scope)) &&
|
||||
(!filters.category?.length || filters.category.includes(item.category)) &&
|
||||
(!filters.severity?.length || filters.severity.includes(item.severity)) &&
|
||||
(filters.includeInContextPacks === undefined || item.includeInContextPacksDefault === filters.includeInContextPacks) &&
|
||||
(!filters.featureId || item.linkedFeatureIds.includes(filters.featureId)) &&
|
||||
filterSearch(`${item.title} ${item.rule} ${item.category}`, filters.search)
|
||||
);
|
||||
};
|
||||
|
||||
export const getMemoryRuleBySlugOrId = async (projectId: string, ruleId: string): Promise<MemoryRuleDto | undefined> => {
|
||||
const project = await getProject(projectId);
|
||||
const row = await prisma.memoryRule.findFirst({ where: { projectId: project.id, OR: [{ id: ruleId }, { slug: ruleId }, { readableId: ruleId }], deletedAt: null }, include: { project: { select: { slug: true } } }});
|
||||
return row ? toMemoryRuleDto(row) : undefined;
|
||||
};
|
||||
|
||||
export const createMemoryRuleItem = async (projectId: string, body: MemoryRuleMutationRequestDto & { rule: string }): Promise<MemoryRuleDto> => {
|
||||
const project = await getProject(projectId);
|
||||
const slug = `R-${project.slug.toUpperCase()}-${Date.now().toString(36)}`;
|
||||
const confidence = 78;
|
||||
const now = new Date();
|
||||
const row = await prisma.memoryRule.create({ data: { projectId: project.id, slug, readableId: slug, title: body.title?.trim() || body.rule.trim().slice(0, 60), rule: body.rule.trim(), scope: body.scope ?? "global", category: body.category ?? "Technical", severity: body.severity ?? "important", includeInContextPacks: body.includeInContextPacks ?? false, confidence, approvalState: ApprovalState.pending, evidenceStrength: EvidenceStrength.medium, verificationState: VerificationState.unverified, provenanceJson: toJson({ linkedFeatureIds: body.linkedFeatureIds ?? [], includeInContextPacks: [] }), trustJson: toJson(trust("database memory rule create", confidence, ApprovalState.pending, now)), createdBy: "api", updatedBy: "api", sourceType: "database_api" }, include: { project: { select: { slug: true } } }});
|
||||
return toMemoryRuleDto(row);
|
||||
};
|
||||
|
||||
export const updateMemoryRuleItem = async (projectId: string, ruleId: string, body: MemoryRuleMutationRequestDto): Promise<MemoryRuleDto | undefined> => {
|
||||
const project = await getProject(projectId);
|
||||
const current = await prisma.memoryRule.findFirst({ where: { projectId: project.id, OR: [{ id: ruleId }, { slug: ruleId }, { readableId: ruleId }], deletedAt: null }});
|
||||
if (!current) return undefined;
|
||||
const meta = metadata(current.provenanceJson);
|
||||
const row = await prisma.memoryRule.update({ where: { id: current.id }, data: { ...(body.title !== undefined ? { title: body.title.trim() } : {}), ...(body.rule !== undefined ? { rule: body.rule.trim() } : {}), ...(body.scope !== undefined ? { scope: body.scope } : {}), ...(body.category !== undefined ? { category: body.category } : {}), ...(body.severity !== undefined ? { severity: body.severity } : {}), ...(body.includeInContextPacks !== undefined ? { includeInContextPacks: body.includeInContextPacks } : {}), ...(body.linkedFeatureIds !== undefined ? { provenanceJson: toJson({ ...meta, linkedFeatureIds: body.linkedFeatureIds }) } : {}), updatedBy: "api", trustJson: toJson(trust("database memory rule update", current.confidence, current.approvalState, new Date())) }, include: { project: { select: { slug: true } } }});
|
||||
return toMemoryRuleDto(row);
|
||||
};
|
||||
|
||||
export const includeMemoryRuleInContextPack = async (projectId: string, ruleId: string, contextPackId: string): Promise<MemoryRuleDto | undefined> => {
|
||||
const project = await getProject(projectId);
|
||||
const contextPack = await prisma.contextPack.findFirst({ where: { projectId: project.id, OR: [{ id: contextPackId }, { slug: contextPackId }, { readableId: contextPackId }], deletedAt: null }});
|
||||
if (!contextPack) throw new Error("CONTEXT_PACK_NOT_FOUND");
|
||||
const current = await prisma.memoryRule.findFirst({ where: { projectId: project.id, OR: [{ id: ruleId }, { slug: ruleId }, { readableId: ruleId }], deletedAt: null }});
|
||||
if (!current) return undefined;
|
||||
const meta = metadata(current.provenanceJson);
|
||||
const list = new Set(toStringArray(meta.includeInContextPacks));
|
||||
list.add(contextPack.slug ?? contextPack.readableId ?? contextPack.id);
|
||||
const row = await prisma.memoryRule.update({ where: { id: current.id }, data: { includeInContextPacks: true, provenanceJson: toJson({ ...meta, includeInContextPacks: [...list] }), updatedBy: "api" }, include: { project: { select: { slug: true } } }});
|
||||
return toMemoryRuleDto(row);
|
||||
};
|
||||
|
||||
export const listDocumentationForProject = async (projectId: string, filters: DocumentationListFilters = {}): Promise<DocumentationItemDto[]> => {
|
||||
const project = await getProject(projectId);
|
||||
const rows = await prisma.documentationItem.findMany({ where: { projectId: project.id, deletedAt: null }, include: { project: { select: { slug: true } } }, orderBy: { updatedAt: "desc" }});
|
||||
return rows.map(toDocumentationDto).filter((item) =>
|
||||
(!filters.kind?.length || filters.kind.includes(item.kind)) &&
|
||||
(!filters.status?.length || filters.status.includes(item.status)) &&
|
||||
(!filters.approvalState?.length || filters.approvalState.includes(item.approvalState)) &&
|
||||
filterSearch(`${item.title} ${item.kind} ${item.content ?? ""}`, filters.search)
|
||||
);
|
||||
};
|
||||
|
||||
export const getDocumentationBySlugOrId = async (projectId: string, docId: string): Promise<DocumentationItemDto | undefined> => {
|
||||
const project = await getProject(projectId);
|
||||
const row = await prisma.documentationItem.findFirst({ where: { projectId: project.id, OR: [{ id: docId }, { slug: docId }, { readableId: docId }], deletedAt: null }, include: { project: { select: { slug: true } } }});
|
||||
return row ? toDocumentationDto(row) : undefined;
|
||||
};
|
||||
|
||||
export const createDocumentationItem = async (projectId: string, body: { title: string; kind?: string; status?: string; linkedFeatureIds?: string[]; content?: string; approvalState?: ApprovalStateInput; primaryContextPackId?: string; selectedContextPackIds?: string[]; contextSelectionSummary?: string }): Promise<DocumentationItemDto> => {
|
||||
const project = await getProject(projectId);
|
||||
const slug = `doc-${project.slug}-${Date.now().toString(36)}`;
|
||||
const now = new Date();
|
||||
const row = await prisma.documentationItem.create({ data: { projectId: project.id, slug, readableId: slug, title: body.title.trim(), kind: body.kind?.trim() || "current state report", status: body.status?.trim() || "draft", approvalState: body.approvalState ?? ApprovalState.pending, content: body.content?.trim(), metadataJson: toJson({ linkedFeatureIds: body.linkedFeatureIds ?? [], lastUpdated: now.toISOString(), primaryContextPackId: body.primaryContextPackId, selectedContextPackIds: body.selectedContextPackIds ?? [], contextSelectionSummary: body.contextSelectionSummary }), trustJson: toJson(trust("database documentation create", 75, body.approvalState ?? ApprovalState.pending, now)), provenanceJson: toJson({ source: "database_api" }), createdBy: "api", updatedBy: "api", sourceType: "database_api" }, include: { project: { select: { slug: true } } }});
|
||||
return toDocumentationDto(row);
|
||||
};
|
||||
|
||||
export const updateDocumentationItem = async (projectId: string, docId: string, body: { title?: string; kind?: string; status?: string; linkedFeatureIds?: string[]; content?: string; approvalState?: ApprovalStateInput; primaryContextPackId?: string; selectedContextPackIds?: string[]; contextSelectionSummary?: string }): Promise<DocumentationItemDto | undefined> => {
|
||||
const project = await getProject(projectId);
|
||||
const current = await prisma.documentationItem.findFirst({ where: { projectId: project.id, OR: [{ id: docId }, { slug: docId }, { readableId: docId }], deletedAt: null }});
|
||||
if (!current) return undefined;
|
||||
const meta = metadata(current.metadataJson);
|
||||
const row = await prisma.documentationItem.update({ where: { id: current.id }, data: { ...(body.title !== undefined ? { title: body.title.trim() } : {}), ...(body.kind !== undefined ? { kind: body.kind.trim() } : {}), ...(body.status !== undefined ? { status: body.status.trim() } : {}), ...(body.approvalState !== undefined ? { approvalState: body.approvalState } : {}), ...(body.content !== undefined ? { content: body.content.trim() } : {}), metadataJson: toJson({
|
||||
...meta,
|
||||
...(body.linkedFeatureIds !== undefined ? { linkedFeatureIds: body.linkedFeatureIds } : {}),
|
||||
...(body.primaryContextPackId !== undefined ? { primaryContextPackId: body.primaryContextPackId } : {}),
|
||||
...(body.selectedContextPackIds !== undefined ? { selectedContextPackIds: body.selectedContextPackIds } : {}),
|
||||
...(body.contextSelectionSummary !== undefined ? { contextSelectionSummary: body.contextSelectionSummary } : {}),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}), updatedBy: "api" }, include: { project: { select: { slug: true } } }});
|
||||
return toDocumentationDto(row);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
const persistedSetupKeys = [
|
||||
"activeContextPackId",
|
||||
"featureGroups",
|
||||
"memoryRules",
|
||||
"promptTemplates",
|
||||
"constraints",
|
||||
"principles",
|
||||
"firstRun",
|
||||
"starterContextPacks",
|
||||
"starterDocumentationItems",
|
||||
"starterReviewItems",
|
||||
"starterOperatingLog",
|
||||
"brainstorms",
|
||||
"codexSkills",
|
||||
"disabledCodexSkillIds",
|
||||
"workspaceState",
|
||||
] as const;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
export const sanitizeProjectSetupState = (value: unknown): Record<string, unknown> => {
|
||||
if (!isRecord(value)) return {};
|
||||
|
||||
return Object.fromEntries(
|
||||
persistedSetupKeys.flatMap((key) =>
|
||||
Object.prototype.hasOwnProperty.call(value, key) ? [[key, value[key]]] : [],
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const projectSetupWorkspaceState = (value: unknown): Record<string, unknown> | undefined => {
|
||||
const setup = sanitizeProjectSetupState(value);
|
||||
return isRecord(setup.workspaceState) ? setup.workspaceState : undefined;
|
||||
};
|
||||
|
||||
export const projectSetupActiveContextPackId = (value: unknown): string | undefined => {
|
||||
const setup = sanitizeProjectSetupState(value);
|
||||
return typeof setup.activeContextPackId === "string" && setup.activeContextPackId.trim()
|
||||
? setup.activeContextPackId
|
||||
: undefined;
|
||||
};
|
||||
|
||||
export const mergeProjectSetupState = <T extends Record<string, unknown>>(
|
||||
setupState: unknown,
|
||||
canonical: T,
|
||||
): T => ({
|
||||
...sanitizeProjectSetupState(setupState),
|
||||
...canonical,
|
||||
}) as T;
|
||||
@@ -0,0 +1,514 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import type {
|
||||
CurrentStateSummaryDto,
|
||||
FocusAreaDto,
|
||||
HealthSummaryDto,
|
||||
ProjectDetailDto,
|
||||
ProjectDna,
|
||||
ProjectStatus,
|
||||
ProjectSummaryDto,
|
||||
CreateProjectRequestDto,
|
||||
UpdateActiveContextPackRequestDto,
|
||||
UpdateProjectRequestDto,
|
||||
WorkspaceReadDto,
|
||||
WorkspaceStateDto,
|
||||
} from "../types/api.js";
|
||||
import {
|
||||
mergeProjectSetupState,
|
||||
projectSetupActiveContextPackId,
|
||||
projectSetupWorkspaceState,
|
||||
sanitizeProjectSetupState,
|
||||
} from "./projectSetupState.js";
|
||||
import { humanAttentionReviewWhere } from "./reviewAttentionPolicy.js";
|
||||
import { projectHealth } from "../services/projectHealthSemantics.js";
|
||||
|
||||
const projectRelationsInclude = {
|
||||
dna: true,
|
||||
workspaceState: true,
|
||||
giteaRepositories: { select: { id: true, syncStatus: true, lastSyncAt: true } },
|
||||
sourceItems: { orderBy: { updatedAt: "desc" as const } },
|
||||
repositoryFindings: { where: { status: { notIn: ["resolved", "suppressed"] } }, select: { severity: true, status: true, lastSeenAt: true } },
|
||||
_count: {
|
||||
select: {
|
||||
features: { where: { deletedAt: null } },
|
||||
contextPacks: { where: { deletedAt: null } },
|
||||
deliveryPlans: true,
|
||||
documentationItems: { where: { deletedAt: null } },
|
||||
knowledgeItems: { where: { deletedAt: null } },
|
||||
reviewQueueItems: { where: humanAttentionReviewWhere },
|
||||
initiatives: { where: { deletedAt: null, reviewItemId: null, approvalState: { in: ["pending", "blocked"] } } },
|
||||
},
|
||||
},
|
||||
} as const satisfies Prisma.ProjectInclude;
|
||||
|
||||
type ProjectWithP2Relations = Prisma.ProjectGetPayload<{ include: typeof projectRelationsInclude }>;
|
||||
|
||||
type WorkspaceJsonPayload = {
|
||||
workspaceState?: WorkspaceStateDto;
|
||||
focusAreas?: FocusAreaDto[];
|
||||
activeFocusAreaId?: string;
|
||||
activeContextPackId?: string;
|
||||
currentStateSummary?: CurrentStateSummaryDto;
|
||||
healthSummary?: HealthSummaryDto;
|
||||
setupState?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null;
|
||||
|
||||
const slugify = (value: string): string => {
|
||||
const slug = value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
return slug || "project";
|
||||
};
|
||||
|
||||
const toStringArray = (value: Prisma.JsonValue): string[] =>
|
||||
Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
|
||||
const isDisposableProject = (project: Pick<ProjectWithP2Relations, "name" | "description">) =>
|
||||
/\b(smoke|disposable|test project|proof project)\b/i.test(`${project.name} ${project.description}`);
|
||||
|
||||
const toDna = (
|
||||
dna: NonNullable<ProjectWithP2Relations["dna"]>,
|
||||
): ProjectDna => ({
|
||||
vision: dna.vision,
|
||||
goals: toStringArray(dna.goalsJson),
|
||||
nonGoals: toStringArray(dna.nonGoalsJson),
|
||||
targetAudience: dna.targetAudience,
|
||||
principles: toStringArray(dna.principlesJson),
|
||||
successMetrics: Array.isArray(dna.metricsJson)
|
||||
? (dna.metricsJson as ProjectDna["successMetrics"])
|
||||
: [],
|
||||
maturityStage: dna.maturityStage as ProjectDna["maturityStage"],
|
||||
revision: dna.revision,
|
||||
updatedAt: dna.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
const projectLastSync = (project: ProjectWithP2Relations) => {
|
||||
const timestamps = [
|
||||
project.updatedAt.getTime(),
|
||||
...project.giteaRepositories.flatMap((repository) => repository.lastSyncAt ? [repository.lastSyncAt.getTime()] : []),
|
||||
...project.repositoryFindings.map((finding) => finding.lastSeenAt.getTime()),
|
||||
];
|
||||
return new Date(Math.max(...timestamps)).toISOString();
|
||||
};
|
||||
|
||||
const toProjectSummary = (
|
||||
project: ProjectWithP2Relations,
|
||||
): ProjectSummaryDto => {
|
||||
const health = projectHealth(project);
|
||||
const workspacePayload = project.workspaceState ? toWorkspacePayload(project.workspaceState.notesJson) : {};
|
||||
const configuredSkills = Array.isArray(workspacePayload.setupState?.codexSkills)
|
||||
? workspacePayload.setupState.codexSkills
|
||||
: [];
|
||||
return {
|
||||
id: project.slug,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
health: health.score,
|
||||
healthStatus: health.healthStatus,
|
||||
lastSync: projectLastSync(project),
|
||||
domain: project.domain,
|
||||
status: project.status as ProjectStatus,
|
||||
createdAt: project.createdAt.toISOString(),
|
||||
updatedAt: project.updatedAt.toISOString(),
|
||||
activeContextPackId: project.activeContextPackId ?? undefined,
|
||||
resourceCounts: {
|
||||
repositories: project.giteaRepositories.length,
|
||||
readyRepositories: project.giteaRepositories.filter((repository) => repository.syncStatus === "ready").length,
|
||||
sources: project.sourceItems.filter((source) => source.status !== "archived").length,
|
||||
documentation: project._count.documentationItems,
|
||||
pendingReviews: project._count.reviewQueueItems,
|
||||
attentionItems: project._count.reviewQueueItems + project._count.initiatives,
|
||||
activeFindings: health.activeFindingCount,
|
||||
criticalFindings: health.criticalCount,
|
||||
warningFindings: health.warningCount,
|
||||
regressedFindings: health.regressedCount,
|
||||
contextPacks: project._count.contextPacks,
|
||||
features: project._count.features,
|
||||
deliveryPlans: project._count.deliveryPlans,
|
||||
knowledge: project._count.knowledgeItems,
|
||||
enabledSkills: configuredSkills.filter((skill) => isRecord(skill) && skill.enabled === true).length,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const toWorkspacePayload = (
|
||||
value: Prisma.JsonValue,
|
||||
): WorkspaceJsonPayload => {
|
||||
if (!isRecord(value)) return {};
|
||||
|
||||
return value as WorkspaceJsonPayload;
|
||||
};
|
||||
|
||||
const toWorkspaceState = (
|
||||
project: ProjectWithP2Relations,
|
||||
): WorkspaceStateDto | undefined => {
|
||||
if (!project.workspaceState) return undefined;
|
||||
|
||||
const payload = toWorkspacePayload(project.workspaceState.notesJson);
|
||||
if (payload.workspaceState) return payload.workspaceState;
|
||||
|
||||
return {
|
||||
projectId: project.slug,
|
||||
activeContextPackId:
|
||||
project.workspaceState.activeContextPackId ?? undefined,
|
||||
primaryObjective: project.workspaceState.primaryObjective,
|
||||
focusAreas: [],
|
||||
openTasksCount: 0,
|
||||
openBugCount: 0,
|
||||
activeSection: project.workspaceState
|
||||
.activeSection as WorkspaceStateDto["activeSection"],
|
||||
updatedAt: project.workspaceState.updatedAt.toISOString(),
|
||||
notes: [],
|
||||
};
|
||||
};
|
||||
|
||||
const toProjectDetail = (
|
||||
project: ProjectWithP2Relations,
|
||||
): ProjectDetailDto | undefined => {
|
||||
if (!project.dna) return undefined;
|
||||
|
||||
const workspacePayload = project.workspaceState
|
||||
? toWorkspacePayload(project.workspaceState.notesJson)
|
||||
: {};
|
||||
|
||||
return mergeProjectSetupState(workspacePayload.setupState, {
|
||||
...toProjectSummary(project),
|
||||
dna: toDna(project.dna),
|
||||
constraints: Array.isArray(workspacePayload.setupState?.constraints)
|
||||
? workspacePayload.setupState.constraints as ProjectDetailDto["constraints"]
|
||||
: [],
|
||||
principles: Array.isArray(workspacePayload.setupState?.principles)
|
||||
? workspacePayload.setupState.principles as ProjectDetailDto["principles"]
|
||||
: [],
|
||||
initiatives: [],
|
||||
featureGroups: Array.isArray(workspacePayload.setupState?.featureGroups)
|
||||
? workspacePayload.setupState.featureGroups as ProjectDetailDto["featureGroups"]
|
||||
: [],
|
||||
memoryRules: Array.isArray(workspacePayload.setupState?.memoryRules)
|
||||
? workspacePayload.setupState.memoryRules as ProjectDetailDto["memoryRules"]
|
||||
: [],
|
||||
promptTemplates: Array.isArray(workspacePayload.setupState?.promptTemplates)
|
||||
? workspacePayload.setupState.promptTemplates as ProjectDetailDto["promptTemplates"]
|
||||
: [],
|
||||
sourceLibrary: project.sourceItems.map((source) => ({
|
||||
id: source.id,
|
||||
projectId: project.slug,
|
||||
title: source.title,
|
||||
kind: source.kind,
|
||||
inputMode: source.inputMode,
|
||||
originalName: source.path ?? undefined,
|
||||
url: source.url ?? undefined,
|
||||
text: source.content,
|
||||
summary: source.summary,
|
||||
status: source.status,
|
||||
suggestedDocumentationTargets: [],
|
||||
suggestedContextPackIds: [],
|
||||
extractedAssetIds: [],
|
||||
createdAt: source.createdAt.toISOString(),
|
||||
updatedAt: source.updatedAt.toISOString(),
|
||||
repositoryPath: source.path ?? undefined,
|
||||
repositoryBranch: source.branch ?? undefined,
|
||||
commitSha: source.commitSha ?? undefined,
|
||||
lastSyncedAt: source.lastSyncedAt?.toISOString(),
|
||||
attribution: { createdBy: "imported", sourceRun: source.commitSha ?? undefined, approvalState: "approved" },
|
||||
})),
|
||||
workspaceState: toWorkspaceState(project),
|
||||
} as ProjectDetailDto & Record<string, unknown>);
|
||||
};
|
||||
|
||||
const defaultDnaForProject = (
|
||||
input: CreateProjectRequestDto,
|
||||
): Omit<ProjectDna, "updatedAt"> => ({
|
||||
vision: input.dna?.vision || input.primaryGoal || input.description,
|
||||
goals: input.dna?.goals ?? (input.primaryGoal ? [input.primaryGoal] : []),
|
||||
nonGoals: input.dna?.nonGoals ?? [],
|
||||
targetAudience: input.dna?.targetAudience || input.targetAudience || "Delivery teams",
|
||||
principles: input.dna?.principles ?? [],
|
||||
successMetrics: input.dna?.successMetrics ?? [],
|
||||
maturityStage: input.dna?.maturityStage ?? "Discovery",
|
||||
revision: input.dna?.revision ?? 1,
|
||||
});
|
||||
|
||||
const defaultWorkspacePayload = (
|
||||
projectSlug: string,
|
||||
input: CreateProjectRequestDto,
|
||||
): WorkspaceJsonPayload => {
|
||||
const setupWorkspace = projectSetupWorkspaceState(input.setupState);
|
||||
const workspaceState = {
|
||||
...setupWorkspace,
|
||||
projectId: projectSlug,
|
||||
primaryObjective: typeof setupWorkspace?.primaryObjective === "string"
|
||||
? setupWorkspace.primaryObjective
|
||||
: input.primaryGoal || input.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: new Date().toISOString(),
|
||||
notes: Array.isArray(setupWorkspace?.notes) ? setupWorkspace.notes : [],
|
||||
...(projectSetupActiveContextPackId(input.setupState)
|
||||
? { activeContextPackId: projectSetupActiveContextPackId(input.setupState) }
|
||||
: {}),
|
||||
} as WorkspaceStateDto;
|
||||
|
||||
return {
|
||||
workspaceState,
|
||||
focusAreas: workspaceState.focusAreas,
|
||||
healthSummary: {
|
||||
health: 100,
|
||||
healthStatus: "healthy",
|
||||
openTasksCount: workspaceState.openTasksCount,
|
||||
openBugCount: workspaceState.openBugCount,
|
||||
riskCount: 0,
|
||||
pendingReviewCount: 0,
|
||||
},
|
||||
setupState: sanitizeProjectSetupState(input.setupState),
|
||||
};
|
||||
};
|
||||
|
||||
const findProjectRecordBySlugOrId = (projectIdOrSlug: string) =>
|
||||
prisma.project.findFirst({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }],
|
||||
},
|
||||
include: projectRelationsInclude,
|
||||
});
|
||||
|
||||
export const listProjects = async (): Promise<ProjectSummaryDto[]> => {
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
include: projectRelationsInclude,
|
||||
});
|
||||
|
||||
return projects.filter((project) => !isDisposableProject(project)).map(toProjectSummary);
|
||||
};
|
||||
|
||||
export const createProject = async (
|
||||
input: CreateProjectRequestDto,
|
||||
): Promise<ProjectDetailDto> => {
|
||||
const slug = slugify(input.id || input.name);
|
||||
const existing = await prisma.project.findFirst({
|
||||
where: {
|
||||
slug,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error("PROJECT_SLUG_ALREADY_EXISTS");
|
||||
}
|
||||
|
||||
const dna = defaultDnaForProject(input);
|
||||
const workspacePayload = defaultWorkspacePayload(slug, input);
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
slug,
|
||||
readableId: slug,
|
||||
name: input.name.trim(),
|
||||
description: input.description.trim(),
|
||||
domain: input.domain?.trim() || "general",
|
||||
status: "active",
|
||||
health: 100,
|
||||
healthStatus: "healthy",
|
||||
activeContextPackId: projectSetupActiveContextPackId(input.setupState),
|
||||
createdBy: "frontend",
|
||||
updatedBy: "frontend",
|
||||
sourceType: "project_setup",
|
||||
dna: {
|
||||
create: {
|
||||
vision: dna.vision,
|
||||
goalsJson: dna.goals,
|
||||
nonGoalsJson: dna.nonGoals,
|
||||
targetAudience: dna.targetAudience,
|
||||
principlesJson: dna.principles,
|
||||
metricsJson: dna.successMetrics,
|
||||
maturityStage: dna.maturityStage,
|
||||
revision: dna.revision,
|
||||
createdBy: "frontend",
|
||||
updatedBy: "frontend",
|
||||
sourceType: "project_setup",
|
||||
},
|
||||
},
|
||||
workspaceState: {
|
||||
create: {
|
||||
primaryObjective: workspacePayload.workspaceState?.primaryObjective ?? input.primaryGoal ?? input.description.trim(),
|
||||
activeContextPackId: projectSetupActiveContextPackId(input.setupState),
|
||||
activeSection: workspacePayload.workspaceState?.activeSection ?? "workspace",
|
||||
notesJson: workspacePayload as Prisma.InputJsonValue,
|
||||
createdBy: "frontend",
|
||||
updatedBy: "frontend",
|
||||
sourceType: "project_setup",
|
||||
},
|
||||
},
|
||||
},
|
||||
include: projectRelationsInclude,
|
||||
});
|
||||
|
||||
const detail = toProjectDetail(project);
|
||||
if (!detail) {
|
||||
throw new Error("PROJECT_CREATE_FAILED");
|
||||
}
|
||||
|
||||
return detail;
|
||||
};
|
||||
|
||||
export const updateProject = async (
|
||||
projectIdOrSlug: string,
|
||||
input: UpdateProjectRequestDto,
|
||||
): Promise<ProjectDetailDto | undefined> => {
|
||||
const existing = await findProjectRecordBySlugOrId(projectIdOrSlug);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const project = await prisma.project.update({
|
||||
where: {
|
||||
id: existing.id,
|
||||
},
|
||||
data: {
|
||||
...(input.name !== undefined ? { name: input.name.trim() } : {}),
|
||||
...(input.description !== undefined ? { description: input.description.trim() } : {}),
|
||||
...(input.domain !== undefined ? { domain: input.domain.trim() || "general" } : {}),
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
updatedBy: "frontend",
|
||||
},
|
||||
include: projectRelationsInclude,
|
||||
});
|
||||
|
||||
return toProjectDetail(project);
|
||||
};
|
||||
|
||||
export const updateActiveContextPack = async (
|
||||
projectIdOrSlug: string,
|
||||
input: UpdateActiveContextPackRequestDto,
|
||||
): Promise<WorkspaceReadDto | undefined> => {
|
||||
const existing = await findProjectRecordBySlugOrId(projectIdOrSlug);
|
||||
if (!existing?.dna) return undefined;
|
||||
|
||||
const currentPayload = existing.workspaceState
|
||||
? toWorkspacePayload(existing.workspaceState.notesJson)
|
||||
: {};
|
||||
const currentWorkspace = toWorkspaceState(existing);
|
||||
const updatedAt = new Date().toISOString();
|
||||
const nextWorkspaceState: WorkspaceStateDto = {
|
||||
projectId: existing.slug,
|
||||
primaryObjective:
|
||||
currentWorkspace?.primaryObjective ??
|
||||
existing.workspaceState?.primaryObjective ??
|
||||
existing.description,
|
||||
focusAreas: currentWorkspace?.focusAreas ?? currentPayload.focusAreas ?? [],
|
||||
openTasksCount: currentWorkspace?.openTasksCount ?? 0,
|
||||
openBugCount: currentWorkspace?.openBugCount ?? 0,
|
||||
activeSection: currentWorkspace?.activeSection ?? "context-packs",
|
||||
updatedAt,
|
||||
notes: currentWorkspace?.notes ?? [],
|
||||
activeContextPackId: input.contextPackId,
|
||||
};
|
||||
|
||||
const payload: WorkspaceJsonPayload = {
|
||||
...currentPayload,
|
||||
workspaceState: nextWorkspaceState,
|
||||
focusAreas: currentPayload.focusAreas ?? nextWorkspaceState.focusAreas,
|
||||
activeContextPackId: input.contextPackId,
|
||||
};
|
||||
|
||||
await prisma.project.update({
|
||||
where: {
|
||||
id: existing.id,
|
||||
},
|
||||
data: {
|
||||
activeContextPackId: input.contextPackId,
|
||||
updatedBy: "frontend",
|
||||
workspaceState: {
|
||||
upsert: {
|
||||
create: {
|
||||
activeContextPackId: input.contextPackId,
|
||||
primaryObjective: nextWorkspaceState.primaryObjective,
|
||||
activeSection: nextWorkspaceState.activeSection,
|
||||
notesJson: payload as Prisma.InputJsonValue,
|
||||
createdBy: "frontend",
|
||||
updatedBy: "frontend",
|
||||
sourceType: "workspace_update",
|
||||
},
|
||||
update: {
|
||||
activeContextPackId: input.contextPackId,
|
||||
primaryObjective: nextWorkspaceState.primaryObjective,
|
||||
activeSection: nextWorkspaceState.activeSection,
|
||||
notesJson: payload as Prisma.InputJsonValue,
|
||||
updatedBy: "frontend",
|
||||
sourceType: "workspace_update",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return getProjectWorkspace(existing.slug);
|
||||
};
|
||||
|
||||
export const getProjectBySlugOrId = async (
|
||||
projectIdOrSlug: string,
|
||||
): Promise<ProjectDetailDto | undefined> => {
|
||||
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
|
||||
return toProjectDetail(project);
|
||||
};
|
||||
|
||||
export const getProjectDNA = async (
|
||||
projectIdOrSlug: string,
|
||||
): Promise<ProjectDna | undefined> => {
|
||||
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
|
||||
if (!project?.dna) return undefined;
|
||||
|
||||
return toDna(project.dna);
|
||||
};
|
||||
|
||||
export const getProjectWorkspace = async (
|
||||
projectIdOrSlug: string,
|
||||
): Promise<WorkspaceReadDto | undefined> => {
|
||||
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
|
||||
if (!project?.dna) return undefined;
|
||||
|
||||
const workspaceState = toWorkspaceState(project);
|
||||
const payload = project.workspaceState
|
||||
? toWorkspacePayload(project.workspaceState.notesJson)
|
||||
: {};
|
||||
const activeContextPackId =
|
||||
workspaceState?.activeContextPackId ??
|
||||
project.activeContextPackId ??
|
||||
undefined;
|
||||
const operationalHealth = projectHealth(project);
|
||||
|
||||
return {
|
||||
project: toProjectSummary(project),
|
||||
dna: toDna(project.dna),
|
||||
activeFocusAreaId: payload.activeFocusAreaId,
|
||||
activeContextPackId,
|
||||
focusAreas: payload.focusAreas ?? workspaceState?.focusAreas ?? [],
|
||||
workspaceState,
|
||||
currentStateSummary: payload.currentStateSummary,
|
||||
healthSummary: {
|
||||
...(payload.healthSummary ?? {}),
|
||||
health: operationalHealth.score,
|
||||
healthStatus: operationalHealth.healthStatus,
|
||||
openTasksCount: workspaceState?.openTasksCount ?? 0,
|
||||
openBugCount: workspaceState?.openBugCount ?? 0,
|
||||
riskCount: payload.healthSummary?.riskCount ?? 0,
|
||||
pendingReviewCount: payload.healthSummary?.pendingReviewCount ?? 0,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
ApprovalState,
|
||||
EntityType,
|
||||
EvidenceStrength,
|
||||
VerificationState,
|
||||
type Prisma,
|
||||
} from "@prisma/client";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import type {
|
||||
ApprovalState as ApprovalStateDto,
|
||||
CreateReleaseEvidenceRecordRequestDto,
|
||||
EvidenceStrength as EvidenceStrengthDto,
|
||||
ReleaseEvidenceKind,
|
||||
ReleaseEvidenceListFilters,
|
||||
ReleaseEvidenceRecordDto,
|
||||
ReleaseEvidenceStatus,
|
||||
VerificationState as VerificationStateDto,
|
||||
} from "../types/api.js";
|
||||
|
||||
type ProjectRecord = { id: string; slug: string };
|
||||
type ReleaseEvidenceRecord = Prisma.ReleaseEvidenceRecordGetPayload<Record<string, never>>;
|
||||
|
||||
const findProjectRecord = (projectIdOrSlug: string): Promise<ProjectRecord | null> =>
|
||||
prisma.project.findFirst({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }],
|
||||
},
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
|
||||
const toEvidenceStrengthEnum = (
|
||||
value: EvidenceStrengthDto | string | null | undefined,
|
||||
): EvidenceStrength => {
|
||||
if (value === "verified") return EvidenceStrength.verified;
|
||||
if (value === "strong") return EvidenceStrength.strong;
|
||||
if (value === "medium") return EvidenceStrength.medium;
|
||||
return EvidenceStrength.weak;
|
||||
};
|
||||
|
||||
const toVerificationStateEnum = (
|
||||
value: VerificationStateDto | string | null | undefined,
|
||||
): VerificationState => {
|
||||
if (value === "blocked") return VerificationState.blocked;
|
||||
if (value === "needs evidence" || value === "needs_evidence") return VerificationState.needs_evidence;
|
||||
if (value === "unverified") return VerificationState.unverified;
|
||||
return VerificationState.verified;
|
||||
};
|
||||
|
||||
const toVerificationStateDto = (value: VerificationState): VerificationStateDto =>
|
||||
value === VerificationState.needs_evidence ? "needs evidence" : value;
|
||||
|
||||
const toDto = (record: ReleaseEvidenceRecord, projectSlug: string): ReleaseEvidenceRecordDto => ({
|
||||
id: record.slug ?? record.readableId ?? record.id,
|
||||
projectId: projectSlug,
|
||||
capturedAt: record.capturedAt.toISOString(),
|
||||
kind: record.kind as ReleaseEvidenceKind,
|
||||
title: record.title,
|
||||
summary: record.summary,
|
||||
status: record.status as ReleaseEvidenceStatus,
|
||||
...(record.command ? { command: record.command } : {}),
|
||||
...(record.outputText ? { outputText: record.outputText } : {}),
|
||||
...(record.route ? { route: record.route } : {}),
|
||||
source: record.source,
|
||||
confidence: record.confidence,
|
||||
approvalState: record.approvalState as ApprovalStateDto,
|
||||
evidenceStrength: record.evidenceStrength,
|
||||
verificationState: toVerificationStateDto(record.verificationState),
|
||||
createdBy: record.createdBy,
|
||||
...(record.verifiedAt ? { verifiedAt: record.verifiedAt.toISOString() } : {}),
|
||||
...(record.verifiedBy ? { verifiedBy: record.verifiedBy } : {}),
|
||||
...(record.metadataJson && typeof record.metadataJson === "object" && !Array.isArray(record.metadataJson)
|
||||
? { metadata: record.metadataJson as Record<string, unknown> }
|
||||
: {}),
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
const paginateMeta = <T>(
|
||||
items: T[],
|
||||
total: number,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
sort?: string,
|
||||
filters?: Record<string, unknown>,
|
||||
) => ({
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
hasNextPage: page < Math.ceil(total / pageSize),
|
||||
hasPreviousPage: page > 1,
|
||||
...(sort ? { sort } : {}),
|
||||
...(filters ? { filters: filters as Record<string, string | number | boolean | string[]> } : {}),
|
||||
});
|
||||
|
||||
export const listReleaseEvidenceRecordsFromDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
filters: ReleaseEvidenceListFilters,
|
||||
options: { page: number; pageSize: number; sort?: string },
|
||||
) => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const where: Prisma.ReleaseEvidenceRecordWhereInput = {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
...(filters.kind?.length ? { kind: { in: filters.kind } } : {}),
|
||||
...(filters.status?.length ? { status: { in: filters.status } } : {}),
|
||||
...(filters.evidenceStrength?.length
|
||||
? { evidenceStrength: { in: filters.evidenceStrength.map(toEvidenceStrengthEnum) } }
|
||||
: {}),
|
||||
...(filters.verificationState?.length
|
||||
? { verificationState: { in: filters.verificationState.map(toVerificationStateEnum) } }
|
||||
: {}),
|
||||
...(filters.source?.length ? { source: { in: filters.source } } : {}),
|
||||
...(filters.createdBy?.length ? { createdBy: { in: filters.createdBy } } : {}),
|
||||
};
|
||||
const total = await prisma.releaseEvidenceRecord.count({ where });
|
||||
const records = await prisma.releaseEvidenceRecord.findMany({
|
||||
where,
|
||||
orderBy: { capturedAt: options.sort?.endsWith(":asc") ? "asc" : "desc" },
|
||||
skip: (options.page - 1) * options.pageSize,
|
||||
take: options.pageSize,
|
||||
});
|
||||
return paginateMeta(
|
||||
records.map((record) => toDto(record, project.slug)),
|
||||
total,
|
||||
options.page,
|
||||
options.pageSize,
|
||||
options.sort,
|
||||
filters,
|
||||
);
|
||||
};
|
||||
|
||||
export const getReleaseEvidenceRecordFromDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
releaseEvidenceId: string,
|
||||
) => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const record = await prisma.releaseEvidenceRecord.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
OR: [
|
||||
{ id: releaseEvidenceId },
|
||||
{ slug: releaseEvidenceId },
|
||||
{ readableId: releaseEvidenceId },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (!record) return "RELEASE_EVIDENCE_NOT_FOUND" as const;
|
||||
return toDto(record, project.slug);
|
||||
};
|
||||
|
||||
export const createReleaseEvidenceRecordInDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
body: CreateReleaseEvidenceRecordRequestDto,
|
||||
): Promise<ReleaseEvidenceRecordDto | undefined> => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const capturedAt = new Date();
|
||||
const slug = `rel-${project.slug}-${capturedAt.getTime()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const verificationState = toVerificationStateEnum(body.verificationState ?? "verified");
|
||||
const evidenceStrength = toEvidenceStrengthEnum(body.evidenceStrength ?? "verified");
|
||||
const verifiedAt = verificationState === VerificationState.verified ? capturedAt : undefined;
|
||||
const record = await prisma.releaseEvidenceRecord.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug,
|
||||
readableId: slug,
|
||||
capturedAt,
|
||||
kind: body.kind,
|
||||
title: body.title,
|
||||
summary: body.summary,
|
||||
status: body.status ?? "captured",
|
||||
command: body.command,
|
||||
outputText: body.outputText,
|
||||
route: body.route,
|
||||
source: body.source ?? "runtime_readiness",
|
||||
confidence: body.confidence ?? 100,
|
||||
approvalState: ApprovalState.approved,
|
||||
evidenceStrength,
|
||||
verificationState,
|
||||
metadataJson: (body.metadata ?? {}) as Prisma.InputJsonValue,
|
||||
provenanceJson: {
|
||||
createdFrom: "release-evidence-api",
|
||||
sourceType: "runtime_readiness",
|
||||
},
|
||||
createdBy: body.createdBy ?? "api",
|
||||
verifiedAt,
|
||||
verifiedBy: body.verifiedBy ?? (verifiedAt ? "api" : undefined),
|
||||
},
|
||||
});
|
||||
await prisma.activityEvent.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
entityType: EntityType.release_evidence_record,
|
||||
entityId: record.id,
|
||||
eventType: "release_evidence_captured",
|
||||
actor: body.createdBy ?? "api",
|
||||
payloadJson: {
|
||||
kind: record.kind,
|
||||
title: record.title,
|
||||
status: record.status,
|
||||
route: record.route,
|
||||
},
|
||||
},
|
||||
});
|
||||
return toDto(record, project.slug);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ReviewStatus, type Prisma, type ReviewQueueItem } from "@prisma/client";
|
||||
|
||||
type ReviewAttentionRecord = Pick<ReviewQueueItem, "status" | "source" | "sourceType" | "createdBy">;
|
||||
|
||||
const isSyntheticSource = (value: string | null | undefined) => value?.toLowerCase().includes("smoke") ?? false;
|
||||
|
||||
/** Pending test and seed artefacts are evidence, not owner decisions. */
|
||||
export const reviewRequiresHumanAttention = (record: ReviewAttentionRecord) =>
|
||||
record.status === ReviewStatus.pending &&
|
||||
record.createdBy !== "seed" &&
|
||||
!isSyntheticSource(record.source) &&
|
||||
!isSyntheticSource(record.sourceType);
|
||||
|
||||
export const humanAttentionReviewWhere = {
|
||||
deletedAt: null,
|
||||
status: ReviewStatus.pending,
|
||||
NOT: [
|
||||
{ createdBy: "seed" },
|
||||
{ source: { contains: "smoke", mode: "insensitive" } },
|
||||
{ sourceType: { contains: "smoke", mode: "insensitive" } },
|
||||
],
|
||||
} satisfies Prisma.ReviewQueueItemWhereInput;
|
||||
@@ -0,0 +1,448 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { ApprovalState, EntityType, EvidenceStrength, ReviewStatus, VerificationState } from "@prisma/client";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import type {
|
||||
CaptureItemDto,
|
||||
ReviewMutationResponseDto,
|
||||
ReviewQueueConvertTarget,
|
||||
ReviewQueueItemDto,
|
||||
ReviewQueueListFilters,
|
||||
ReviewQueuePriority,
|
||||
ReviewQueueStatus,
|
||||
UpdateReviewQueueItemRequestDto,
|
||||
} from "../types/api.js";
|
||||
import { reviewRequiresHumanAttention } from "./reviewAttentionPolicy.js";
|
||||
|
||||
type ReviewRecord = Prisma.ReviewQueueItemGetPayload<Record<string, never>>;
|
||||
type ProjectRecord = { id: string; slug: string };
|
||||
|
||||
const findProjectRecord = (projectIdOrSlug: string): Promise<ProjectRecord | null> =>
|
||||
prisma.project.findFirst({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }],
|
||||
},
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
|
||||
const mapEvidenceStrength = (value: string): EvidenceStrength => {
|
||||
if (value === "verified") return EvidenceStrength.verified;
|
||||
if (value === "strong") return EvidenceStrength.strong;
|
||||
if (value === "medium") return EvidenceStrength.medium;
|
||||
return EvidenceStrength.weak;
|
||||
};
|
||||
|
||||
const mapVerificationState = (value: string): VerificationState => {
|
||||
if (value === "verified") return VerificationState.verified;
|
||||
if (value === "blocked") return VerificationState.blocked;
|
||||
if (value === "needs evidence") return VerificationState.needs_evidence;
|
||||
return VerificationState.unverified;
|
||||
};
|
||||
|
||||
const mapReviewStatus = (value: ReviewQueueStatus): ReviewStatus => {
|
||||
if (value === "accepted") return ReviewStatus.accepted;
|
||||
if (value === "rejected") return ReviewStatus.rejected;
|
||||
if (value === "merged") return ReviewStatus.merged;
|
||||
if (value === "converted") return ReviewStatus.converted;
|
||||
return ReviewStatus.pending;
|
||||
};
|
||||
|
||||
const toReviewStatus = (value: ReviewStatus): ReviewQueueStatus => {
|
||||
if (value === ReviewStatus.accepted) return "accepted";
|
||||
if (value === ReviewStatus.rejected) return "rejected";
|
||||
if (value === ReviewStatus.merged) return "merged";
|
||||
if (value === ReviewStatus.converted) return "converted";
|
||||
return "pending";
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
|
||||
const toStringArray = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
|
||||
const toBlueprintReviewMetadata = (record: ReviewRecord): ReviewQueueItemDto["blueprint"] | undefined => {
|
||||
const payload = isRecord(record.payloadJson) ? record.payloadJson : {};
|
||||
const provenance = isRecord(record.provenanceJson) ? record.provenanceJson : {};
|
||||
const contextSelection = isRecord(payload.contextSelection) ? payload.contextSelection : {};
|
||||
const graphId = typeof payload.graphId === "string" ? payload.graphId : typeof provenance.graphId === "string" ? provenance.graphId : undefined;
|
||||
const nodeId = typeof payload.nodeId === "string" ? payload.nodeId : typeof provenance.nodeId === "string" ? provenance.nodeId : undefined;
|
||||
if (!graphId && !nodeId && record.source !== "blueprint_engine_v52") return undefined;
|
||||
return {
|
||||
graphId,
|
||||
nodeId,
|
||||
outputPath: typeof payload.outputPath === "string" ? payload.outputPath : undefined,
|
||||
contextPackId: typeof contextSelection.primaryContextPackId === "string" ? contextSelection.primaryContextPackId : undefined,
|
||||
selectedContextPackIds: toStringArray(contextSelection.selectedContextPackIds),
|
||||
};
|
||||
};
|
||||
|
||||
const toReviewDto = (record: ReviewRecord, projectSlug: string): ReviewQueueItemDto & { attentionRequired: boolean; sourceType: string } => ({
|
||||
id: record.slug ?? record.readableId ?? record.id,
|
||||
projectId: projectSlug,
|
||||
type: record.type as ReviewQueueItemDto["type"],
|
||||
title: record.title,
|
||||
description: record.description,
|
||||
priority: record.priority as ReviewQueuePriority,
|
||||
status: toReviewStatus(record.status),
|
||||
source: record.source,
|
||||
confidence: record.confidence,
|
||||
evidenceStrength: record.evidenceStrength,
|
||||
verificationState:
|
||||
record.verificationState === VerificationState.needs_evidence
|
||||
? "needs evidence"
|
||||
: (record.verificationState as ReviewQueueItemDto["verificationState"]),
|
||||
suggestedAction: record.suggestedAction,
|
||||
attentionRequired: reviewRequiresHumanAttention(record),
|
||||
sourceType: record.sourceType,
|
||||
...(record.notes ? { notes: record.notes } : {}),
|
||||
...(record.targetEntityId ? { targetEntityId: record.targetEntityId } : {}),
|
||||
...(isRecord(record.payloadJson) && typeof record.payloadJson.convertedTarget === "string"
|
||||
? { convertedTarget: record.payloadJson.convertedTarget as ReviewQueueConvertTarget }
|
||||
: {}),
|
||||
...(record.createdEntityId ? { createdEntityId: record.createdEntityId } : {}),
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
...(toBlueprintReviewMetadata(record) ? { blueprint: toBlueprintReviewMetadata(record) } : {}),
|
||||
});
|
||||
|
||||
const toWhere = (
|
||||
projectId: string,
|
||||
filters: ReviewQueueListFilters = {},
|
||||
): Prisma.ReviewQueueItemWhereInput => ({
|
||||
projectId,
|
||||
deletedAt: null,
|
||||
...(filters.priority?.length ? { priority: { in: filters.priority } } : {}),
|
||||
...(filters.type?.length ? { type: { in: filters.type } } : {}),
|
||||
...(filters.status?.length
|
||||
? { status: { in: filters.status.map(mapReviewStatus) } }
|
||||
: {}),
|
||||
...(filters.source?.length ? { source: { in: filters.source } } : {}),
|
||||
...(filters.confidence !== undefined ? { confidence: filters.confidence } : {}),
|
||||
...(filters.evidenceStrength?.length
|
||||
? { evidenceStrength: { in: filters.evidenceStrength.map(mapEvidenceStrength) } }
|
||||
: {}),
|
||||
...(filters.verificationState?.length
|
||||
? { verificationState: { in: filters.verificationState.map(mapVerificationState) } }
|
||||
: {}),
|
||||
});
|
||||
|
||||
export const listReviewItemsFromDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
filters: ReviewQueueListFilters,
|
||||
options: { page: number; pageSize: number; cursor?: string; sort?: string },
|
||||
) => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
|
||||
const where = toWhere(project.id, filters);
|
||||
const total = await prisma.reviewQueueItem.count({ where });
|
||||
const records = await prisma.reviewQueueItem.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: options.sort?.endsWith(":asc") ? "asc" : "desc" },
|
||||
skip: (options.page - 1) * options.pageSize,
|
||||
take: options.pageSize,
|
||||
});
|
||||
const pageCount = Math.ceil(total / options.pageSize);
|
||||
|
||||
return {
|
||||
items: records.map((record) => toReviewDto(record, project.slug)),
|
||||
total,
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
hasNextPage: options.page < pageCount,
|
||||
hasPreviousPage: options.page > 1,
|
||||
...(options.sort ? { sort: options.sort } : {}),
|
||||
...(options.cursor ? { nextCursor: undefined, cursor: options.cursor } : {}),
|
||||
filters,
|
||||
};
|
||||
};
|
||||
|
||||
export const getReviewItemFromDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
reviewItemId: string,
|
||||
): Promise<ReviewQueueItemDto | undefined> => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const record = await prisma.reviewQueueItem.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }],
|
||||
},
|
||||
});
|
||||
return record ? toReviewDto(record, project.slug) : undefined;
|
||||
};
|
||||
|
||||
const toReviewType = (
|
||||
classificationType: NonNullable<CaptureItemDto["classificationType"]>,
|
||||
): ReviewQueueItemDto["type"] => {
|
||||
if (classificationType === "documentation_update") return "documentation_update";
|
||||
if (classificationType === "open_question") return "open_question";
|
||||
if (classificationType === "memory_rule") return "memory_rule";
|
||||
return classificationType;
|
||||
};
|
||||
|
||||
export const sendCaptureItemToReviewInDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
captureItemId: string,
|
||||
): Promise<ReviewQueueItemDto | undefined | "CAPTURE_NOT_CLASSIFIED" | "CAPTURE_ALREADY_IN_REVIEW"> => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const captureItem = await prisma.captureItem.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
OR: [{ id: captureItemId }, { slug: captureItemId }, { readableId: captureItemId }],
|
||||
},
|
||||
});
|
||||
if (!captureItem) return undefined;
|
||||
if (captureItem.status === "in_review" || captureItem.linkedReviewItemId) return "CAPTURE_ALREADY_IN_REVIEW";
|
||||
if (captureItem.status !== "classified" || !captureItem.classificationType) return "CAPTURE_NOT_CLASSIFIED";
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.captureItem.updateMany({
|
||||
where: { id: captureItem.id, status: "classified", linkedReviewItemId: null },
|
||||
data: { status: "in_review", updatedBy: "api" },
|
||||
});
|
||||
if (claimed.count !== 1) return "CAPTURE_ALREADY_IN_REVIEW";
|
||||
|
||||
const createdAt = new Date();
|
||||
const slug = `rq-capture-${captureItem.id}`;
|
||||
const record = await tx.reviewQueueItem.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug,
|
||||
readableId: slug,
|
||||
type: toReviewType(captureItem.classificationType as NonNullable<CaptureItemDto["classificationType"]>),
|
||||
title: captureItem.title,
|
||||
description: captureItem.content,
|
||||
priority: captureItem.confidence && captureItem.confidence >= 90 ? "important" : "normal",
|
||||
status: ReviewStatus.pending,
|
||||
source: captureItem.source,
|
||||
confidence: captureItem.confidence ?? 72,
|
||||
approvalState: ApprovalState.pending,
|
||||
evidenceStrength: captureItem.evidenceStrength ?? EvidenceStrength.weak,
|
||||
verificationState: VerificationState.needs_evidence,
|
||||
suggestedAction: captureItem.suggestedAction ?? "Review captured item before project truth changes.",
|
||||
createdBy: "api",
|
||||
updatedBy: "api",
|
||||
sourceType: "capture_item",
|
||||
createdAt,
|
||||
payloadJson: { captureItemId: captureItem.id },
|
||||
provenanceJson: { createdFrom: "capture_send_to_review" },
|
||||
},
|
||||
});
|
||||
await tx.captureItem.update({
|
||||
where: { id: captureItem.id },
|
||||
data: { linkedReviewItemId: record.slug ?? record.id, updatedBy: "api" },
|
||||
});
|
||||
return toReviewDto(record, project.slug);
|
||||
});
|
||||
};
|
||||
|
||||
export const updateReviewItemInDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
reviewItemId: string,
|
||||
body: UpdateReviewQueueItemRequestDto,
|
||||
): Promise<ReviewQueueItemDto | undefined | "REVIEW_ALREADY_DECIDED"> => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const existing = await prisma.reviewQueueItem.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }],
|
||||
},
|
||||
});
|
||||
if (!existing) return undefined;
|
||||
if (existing.status !== ReviewStatus.pending) return "REVIEW_ALREADY_DECIDED";
|
||||
const claimed = await prisma.reviewQueueItem.updateMany({
|
||||
where: { id: existing.id, status: ReviewStatus.pending },
|
||||
data: {
|
||||
...(body.title !== undefined ? { title: body.title.trim() } : {}),
|
||||
...(body.description !== undefined ? { description: body.description.trim() } : {}),
|
||||
...(body.priority !== undefined ? { priority: body.priority } : {}),
|
||||
...(body.notes !== undefined ? { notes: body.notes.trim() } : {}),
|
||||
updatedBy: "api",
|
||||
},
|
||||
});
|
||||
if (claimed.count !== 1) return "REVIEW_ALREADY_DECIDED";
|
||||
const record = await prisma.reviewQueueItem.findUniqueOrThrow({ where: { id: existing.id } });
|
||||
return toReviewDto(record, project.slug);
|
||||
};
|
||||
|
||||
const statusToApproval = (status: ReviewStatus): ApprovalState => {
|
||||
if (status === ReviewStatus.accepted || status === ReviewStatus.converted || status === ReviewStatus.merged) {
|
||||
return ApprovalState.approved;
|
||||
}
|
||||
if (status === ReviewStatus.rejected) return ApprovalState.rejected;
|
||||
return ApprovalState.pending;
|
||||
};
|
||||
|
||||
const mutationResult = (
|
||||
reviewItemId: string,
|
||||
message: string,
|
||||
affectedEntities: string[],
|
||||
createdEntityId?: string,
|
||||
): ReviewMutationResponseDto => ({
|
||||
success: true,
|
||||
message,
|
||||
updatedEntityId: reviewItemId,
|
||||
...(createdEntityId ? { createdEntityId } : {}),
|
||||
affectedEntities,
|
||||
});
|
||||
|
||||
export const applyReviewActionInDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
reviewItemId: string,
|
||||
action: "accept" | "reject",
|
||||
notes?: string,
|
||||
): Promise<ReviewMutationResponseDto | undefined | "REVIEW_ALREADY_DECIDED"> => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const existing = await prisma.reviewQueueItem.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }],
|
||||
},
|
||||
});
|
||||
if (!existing) return undefined;
|
||||
const status = action === "accept" ? ReviewStatus.accepted : ReviewStatus.rejected;
|
||||
const claimed = await prisma.reviewQueueItem.updateMany({
|
||||
where: { id: existing.id, status: ReviewStatus.pending },
|
||||
data: {
|
||||
status,
|
||||
approvalState: statusToApproval(status),
|
||||
notes,
|
||||
updatedBy: "api",
|
||||
verifiedAt: action === "accept" ? new Date() : undefined,
|
||||
verifiedBy: action === "accept" ? "api" : undefined,
|
||||
},
|
||||
});
|
||||
if (claimed.count !== 1) return "REVIEW_ALREADY_DECIDED";
|
||||
return mutationResult(reviewItemId, `Review item '${reviewItemId}' ${action === "accept" ? "accepted" : "rejected"}.`, [reviewItemId]);
|
||||
};
|
||||
|
||||
export const mergeReviewItemInDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
reviewItemId: string,
|
||||
targetEntityId: string,
|
||||
notes?: string,
|
||||
): Promise<ReviewMutationResponseDto | undefined | "REVIEW_ALREADY_DECIDED" | "REVIEW_TARGET_NOT_FOUND"> => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const existing = await prisma.reviewQueueItem.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }],
|
||||
},
|
||||
});
|
||||
if (!existing) return undefined;
|
||||
if (existing.status !== ReviewStatus.pending) return "REVIEW_ALREADY_DECIDED";
|
||||
|
||||
const publicId = {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
OR: [{ id: targetEntityId }, { slug: targetEntityId }, { readableId: targetEntityId }],
|
||||
};
|
||||
const targets = await Promise.all([
|
||||
prisma.feature.findFirst({ where: publicId, select: { id: true } }).then((item) => item && EntityType.feature),
|
||||
prisma.memoryRule.findFirst({ where: publicId, select: { id: true } }).then((item) => item && EntityType.memory_rule),
|
||||
prisma.knowledgeItem.findFirst({ where: publicId, select: { id: true } }).then((item) => item && EntityType.knowledge_item),
|
||||
prisma.documentationItem.findFirst({ where: publicId, select: { id: true } }).then((item) => item && EntityType.documentation_item),
|
||||
prisma.acceptedTruthItem.findFirst({ where: publicId, select: { id: true } }).then((item) => item && EntityType.accepted_truth_item),
|
||||
prisma.projectUpdate.findFirst({ where: publicId, select: { id: true } }).then((item) => item && EntityType.project_update),
|
||||
prisma.contextPack.findFirst({ where: publicId, select: { id: true } }).then((item) => item && EntityType.context_pack),
|
||||
prisma.initiative.findFirst({ where: publicId, select: { id: true } }).then((item) => item && EntityType.initiative),
|
||||
prisma.reviewQueueItem.findFirst({
|
||||
where: { ...publicId, id: { not: existing.id } },
|
||||
select: { id: true },
|
||||
}).then((item) => item && EntityType.review_queue_item),
|
||||
]);
|
||||
const targetEntityType = targets.find((target) => Boolean(target));
|
||||
if (!targetEntityType) return "REVIEW_TARGET_NOT_FOUND";
|
||||
|
||||
const claimed = await prisma.reviewQueueItem.updateMany({
|
||||
where: { id: existing.id, status: ReviewStatus.pending },
|
||||
data: {
|
||||
status: ReviewStatus.merged,
|
||||
approvalState: ApprovalState.approved,
|
||||
targetEntityType,
|
||||
targetEntityId,
|
||||
notes,
|
||||
updatedBy: "api",
|
||||
verifiedAt: new Date(),
|
||||
verifiedBy: "api",
|
||||
},
|
||||
});
|
||||
if (claimed.count !== 1) return "REVIEW_ALREADY_DECIDED";
|
||||
return mutationResult(reviewItemId, `Review item '${reviewItemId}' merged into '${targetEntityId}'.`, [reviewItemId, targetEntityId]);
|
||||
};
|
||||
|
||||
const toAcceptedTruthKind = (target: ReviewQueueConvertTarget) =>
|
||||
target === "documentation_update" ? "documentation" : target;
|
||||
|
||||
export const convertReviewItemInDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
reviewItemId: string,
|
||||
target: ReviewQueueConvertTarget,
|
||||
notes?: string,
|
||||
): Promise<ReviewMutationResponseDto | undefined | "REVIEW_ALREADY_DECIDED"> => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const existing = await prisma.reviewQueueItem.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }],
|
||||
},
|
||||
});
|
||||
if (!existing) return undefined;
|
||||
if (existing.status !== ReviewStatus.pending) return "REVIEW_ALREADY_DECIDED";
|
||||
const createdEntityId = `truth-converted-${target}-${existing.id}`;
|
||||
const converted = await prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.reviewQueueItem.updateMany({
|
||||
where: { id: existing.id, status: ReviewStatus.pending },
|
||||
data: {
|
||||
status: ReviewStatus.converted,
|
||||
approvalState: ApprovalState.approved,
|
||||
createdEntityId,
|
||||
createdEntityType: EntityType.accepted_truth_item,
|
||||
payloadJson: {
|
||||
...(isRecord(existing.payloadJson) ? existing.payloadJson : {}),
|
||||
convertedTarget: target,
|
||||
},
|
||||
notes,
|
||||
updatedBy: "api",
|
||||
verifiedAt: new Date(),
|
||||
verifiedBy: "api",
|
||||
},
|
||||
});
|
||||
if (claimed.count !== 1) return false;
|
||||
await tx.acceptedTruthItem.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug: createdEntityId,
|
||||
readableId: createdEntityId,
|
||||
sourceReviewItemId: existing.id,
|
||||
kind: toAcceptedTruthKind(target),
|
||||
title: existing.title,
|
||||
summary: existing.description,
|
||||
status: "converted",
|
||||
source: existing.source,
|
||||
confidence: existing.confidence,
|
||||
approvalState: ApprovalState.approved,
|
||||
evidenceStrength: existing.evidenceStrength,
|
||||
verificationState: VerificationState.verified,
|
||||
payloadJson: { reviewItemId: existing.id, convertedTarget: target },
|
||||
provenanceJson: { createdFrom: "review_queue_convert" },
|
||||
createdBy: "api",
|
||||
verifiedAt: new Date(),
|
||||
verifiedBy: "api",
|
||||
},
|
||||
});
|
||||
return true;
|
||||
});
|
||||
if (!converted) return "REVIEW_ALREADY_DECIDED";
|
||||
return mutationResult(reviewItemId, `Review item '${reviewItemId}' converted into durable ${target} accepted-truth artifact '${createdEntityId}'.`, [reviewItemId, createdEntityId], createdEntityId);
|
||||
};
|
||||
@@ -0,0 +1,424 @@
|
||||
import { ApprovalState, EvidenceStrength, VerificationState, type Prisma } from "@prisma/client";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import type {
|
||||
AcceptedTruthItemDto,
|
||||
AcceptedTruthKind,
|
||||
AcceptedTruthListFilters,
|
||||
AcceptedTruthStatus,
|
||||
CreateOperatingLogEntryRequestDto,
|
||||
EvidenceStrength as EvidenceStrengthDto,
|
||||
OperatingLogEntryDto,
|
||||
OperatingLogListFilters,
|
||||
ProjectUpdateDto,
|
||||
ProjectUpdateListFilters,
|
||||
ProjectUpdateTargetArea,
|
||||
ReviewMutationResponseDto,
|
||||
} from "../types/api.js";
|
||||
|
||||
type ProjectRecord = { id: string; slug: string };
|
||||
type ReviewRecord = Prisma.ReviewQueueItemGetPayload<Record<string, never>>;
|
||||
type TruthRecord = Prisma.AcceptedTruthItemGetPayload<Record<string, never>>;
|
||||
type UpdateRecord = Prisma.ProjectUpdateGetPayload<Record<string, never>>;
|
||||
type LogRecord = Prisma.OperatingLogEntryGetPayload<Record<string, never>>;
|
||||
|
||||
const findProjectRecord = (projectIdOrSlug: string): Promise<ProjectRecord | null> =>
|
||||
prisma.project.findFirst({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }],
|
||||
},
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
|
||||
const findReviewRecord = async (
|
||||
projectId: string,
|
||||
reviewItemId: string,
|
||||
): Promise<ReviewRecord | null> =>
|
||||
prisma.reviewQueueItem.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
deletedAt: null,
|
||||
OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }],
|
||||
},
|
||||
});
|
||||
|
||||
const toEvidenceStrengthEnum = (value: EvidenceStrengthDto | string | null | undefined): EvidenceStrength => {
|
||||
if (value === "verified") return EvidenceStrength.verified;
|
||||
if (value === "strong") return EvidenceStrength.strong;
|
||||
if (value === "medium") return EvidenceStrength.medium;
|
||||
return EvidenceStrength.weak;
|
||||
};
|
||||
|
||||
const toEvidenceStrengthDto = (value: EvidenceStrength): EvidenceStrengthDto => value;
|
||||
|
||||
const normalizeKind = (type: string): AcceptedTruthKind => {
|
||||
if (type === "memory_rule") return "memory_rule";
|
||||
if (type === "documentation_update") return "documentation";
|
||||
if (type === "open_question") return "open_question";
|
||||
if (type === "feature") return "feature";
|
||||
if (type === "bug") return "bug";
|
||||
if (type === "task") return "task";
|
||||
if (type === "decision") return "decision";
|
||||
if (type === "knowledge") return "knowledge";
|
||||
if (type === "risk") return "risk";
|
||||
if (type === "assumption") return "assumption";
|
||||
return "knowledge";
|
||||
};
|
||||
|
||||
const targetAreaForKind = (kind: AcceptedTruthKind): ProjectUpdateTargetArea => {
|
||||
if (kind === "feature") return "feature-map";
|
||||
if (kind === "bug") return "bugs";
|
||||
if (kind === "task") return "tasks";
|
||||
if (kind === "decision") return "decisions";
|
||||
if (kind === "memory_rule") return "memory";
|
||||
if (kind === "documentation") return "documentation";
|
||||
if (kind === "risk") return "risks";
|
||||
if (kind === "open_question") return "questions";
|
||||
return "knowledge";
|
||||
};
|
||||
|
||||
const toTruthDto = (record: TruthRecord, projectSlug: string): AcceptedTruthItemDto => ({
|
||||
id: record.slug ?? record.readableId ?? record.id,
|
||||
projectId: projectSlug,
|
||||
sourceReviewItemId: record.sourceReviewItemId,
|
||||
kind: record.kind as AcceptedTruthKind,
|
||||
title: record.title,
|
||||
summary: record.summary,
|
||||
status: record.status as AcceptedTruthStatus,
|
||||
source: record.source,
|
||||
confidence: record.confidence,
|
||||
evidenceStrength: toEvidenceStrengthDto(record.evidenceStrength),
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
const toProjectUpdateDto = (record: UpdateRecord, projectSlug: string): ProjectUpdateDto => ({
|
||||
id: record.slug ?? record.readableId ?? record.id,
|
||||
projectId: projectSlug,
|
||||
sourceTruthItemId: record.sourceTruthItemId,
|
||||
kind: record.kind as AcceptedTruthKind,
|
||||
title: record.title,
|
||||
summary: record.summary,
|
||||
targetArea: record.targetArea as ProjectUpdateTargetArea,
|
||||
status: record.status as ProjectUpdateDto["status"],
|
||||
confidence: record.confidence,
|
||||
evidenceStrength: toEvidenceStrengthDto(record.evidenceStrength),
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
...(record.appliedAt ? { appliedAt: record.appliedAt.toISOString() } : {}),
|
||||
});
|
||||
|
||||
const linkedIdsFromJson = (value: Prisma.JsonValue): string[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
|
||||
const toLogDto = (record: LogRecord, projectSlug: string): OperatingLogEntryDto => ({
|
||||
id: record.slug ?? record.readableId ?? record.id,
|
||||
projectId: projectSlug,
|
||||
timestamp: record.timestamp.toISOString(),
|
||||
type: record.type as OperatingLogEntryDto["type"],
|
||||
title: record.title,
|
||||
summary: record.summary,
|
||||
linkedEntityIds: linkedIdsFromJson(record.linkedEntityIdsJson),
|
||||
source: record.source,
|
||||
importance: record.importance as OperatingLogEntryDto["importance"],
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
const paginateMeta = <T>(
|
||||
items: T[],
|
||||
total: number,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
sort?: string,
|
||||
filters?: Record<string, unknown>,
|
||||
) => ({
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
hasNextPage: page < Math.ceil(total / pageSize),
|
||||
hasPreviousPage: page > 1,
|
||||
...(sort ? { sort } : {}),
|
||||
...(filters ? { filters: filters as Record<string, string | number | boolean | string[]> } : {}),
|
||||
});
|
||||
|
||||
export const listAcceptedTruthFromDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
filters: AcceptedTruthListFilters,
|
||||
options: { page: number; pageSize: number; sort?: string },
|
||||
) => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const where: Prisma.AcceptedTruthItemWhereInput = {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
...(filters.kind?.length ? { kind: { in: filters.kind } } : {}),
|
||||
...(filters.status?.length ? { status: { in: filters.status } } : {}),
|
||||
...(filters.confidence !== undefined ? { confidence: filters.confidence } : {}),
|
||||
...(filters.evidenceStrength?.length
|
||||
? { evidenceStrength: { in: filters.evidenceStrength.map(toEvidenceStrengthEnum) } }
|
||||
: {}),
|
||||
...(filters.sourceReviewItemId ? { sourceReviewItemId: filters.sourceReviewItemId } : {}),
|
||||
};
|
||||
const total = await prisma.acceptedTruthItem.count({ where });
|
||||
const records = await prisma.acceptedTruthItem.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: options.sort?.endsWith(":asc") ? "asc" : "desc" },
|
||||
skip: (options.page - 1) * options.pageSize,
|
||||
take: options.pageSize,
|
||||
});
|
||||
return paginateMeta(records.map((record) => toTruthDto(record, project.slug)), total, options.page, options.pageSize, options.sort, filters);
|
||||
};
|
||||
|
||||
export const materializeReviewItemAsTruthInDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
reviewItemId: string,
|
||||
): Promise<ReviewMutationResponseDto | undefined> => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const review = await findReviewRecord(project.id, reviewItemId);
|
||||
if (!review) return undefined;
|
||||
if (!["accepted", "converted", "merged"].includes(review.status)) {
|
||||
return {
|
||||
success: true,
|
||||
message: `Review item '${reviewItemId}' is not accepted, converted or merged yet.`,
|
||||
updatedEntityId: reviewItemId,
|
||||
affectedEntities: [reviewItemId],
|
||||
};
|
||||
}
|
||||
const existing = await prisma.acceptedTruthItem.findFirst({
|
||||
where: { projectId: project.id, sourceReviewItemId: review.id, deletedAt: null },
|
||||
});
|
||||
if (existing) {
|
||||
return {
|
||||
success: true,
|
||||
message: `Accepted truth already exists for review item '${reviewItemId}'.`,
|
||||
updatedEntityId: reviewItemId,
|
||||
createdEntityId: existing.slug ?? existing.readableId ?? existing.id,
|
||||
affectedEntities: [reviewItemId, existing.slug ?? existing.id],
|
||||
};
|
||||
}
|
||||
const createdAt = new Date();
|
||||
const kind = normalizeKind(review.createdEntityType ?? review.type);
|
||||
const slug = `truth-${project.slug}-${createdAt.getTime()}`;
|
||||
const truth = await prisma.acceptedTruthItem.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug,
|
||||
readableId: slug,
|
||||
sourceReviewItemId: review.id,
|
||||
kind,
|
||||
title: review.title,
|
||||
summary: review.description,
|
||||
status: review.status === "converted" ? "converted" : review.status === "merged" ? "merged" : "accepted",
|
||||
source: review.source,
|
||||
confidence: review.confidence,
|
||||
approvalState: ApprovalState.approved,
|
||||
evidenceStrength: review.evidenceStrength,
|
||||
verificationState: VerificationState.verified,
|
||||
payloadJson: { reviewItemId: review.id, reviewSlug: review.slug, suggestedAction: review.suggestedAction },
|
||||
provenanceJson: { createdFrom: "P6 review materialization" },
|
||||
verifiedAt: createdAt,
|
||||
verifiedBy: "api",
|
||||
},
|
||||
});
|
||||
await createOperatingLogEntryInDatabase(project.slug, {
|
||||
type: "accepted truth",
|
||||
title: `Accepted truth created: ${review.title}`,
|
||||
summary: review.description,
|
||||
linkedEntityIds: [review.id, truth.id],
|
||||
source: "review_queue",
|
||||
importance: review.priority === "critical" ? "critical" : review.priority === "important" ? "important" : "normal",
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: `Review item '${reviewItemId}' materialized as accepted truth.`,
|
||||
updatedEntityId: reviewItemId,
|
||||
createdEntityId: truth.slug ?? truth.id,
|
||||
affectedEntities: [reviewItemId, truth.slug ?? truth.id],
|
||||
};
|
||||
};
|
||||
|
||||
export const listProjectUpdatesFromDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
filters: ProjectUpdateListFilters,
|
||||
options: { page: number; pageSize: number; sort?: string },
|
||||
) => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const where: Prisma.ProjectUpdateWhereInput = {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
...(filters.status?.length ? { status: { in: filters.status } } : {}),
|
||||
...(filters.targetArea?.length ? { targetArea: { in: filters.targetArea } } : {}),
|
||||
...(filters.kind?.length ? { kind: { in: filters.kind } } : {}),
|
||||
...(filters.sourceTruthItemId ? { sourceTruthItemId: filters.sourceTruthItemId } : {}),
|
||||
};
|
||||
const total = await prisma.projectUpdate.count({ where });
|
||||
const records = await prisma.projectUpdate.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: options.sort?.endsWith(":asc") ? "asc" : "desc" },
|
||||
skip: (options.page - 1) * options.pageSize,
|
||||
take: options.pageSize,
|
||||
});
|
||||
return paginateMeta(records.map((record) => toProjectUpdateDto(record, project.slug)), total, options.page, options.pageSize, options.sort, filters);
|
||||
};
|
||||
|
||||
export const createProjectUpdateFromTruthInDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
truthItemId: string,
|
||||
): Promise<ReviewMutationResponseDto | undefined> => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const truth = await prisma.acceptedTruthItem.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
OR: [{ id: truthItemId }, { slug: truthItemId }, { readableId: truthItemId }],
|
||||
},
|
||||
});
|
||||
if (!truth) return undefined;
|
||||
const existing = await prisma.projectUpdate.findFirst({
|
||||
where: { projectId: project.id, sourceTruthItemId: truth.id, deletedAt: null },
|
||||
});
|
||||
if (existing) {
|
||||
return {
|
||||
success: true,
|
||||
message: `Project update already exists for accepted truth '${truthItemId}'.`,
|
||||
updatedEntityId: truthItemId,
|
||||
createdEntityId: existing.slug ?? existing.id,
|
||||
affectedEntities: [truthItemId, existing.slug ?? existing.id],
|
||||
};
|
||||
}
|
||||
const createdAt = new Date();
|
||||
const slug = `update-${project.slug}-${createdAt.getTime()}`;
|
||||
const update = await prisma.projectUpdate.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug,
|
||||
readableId: slug,
|
||||
sourceTruthItemId: truth.id,
|
||||
kind: truth.kind,
|
||||
title: truth.title,
|
||||
summary: truth.summary,
|
||||
targetArea: targetAreaForKind(truth.kind as AcceptedTruthKind),
|
||||
status: "ready",
|
||||
confidence: truth.confidence,
|
||||
approvalState: ApprovalState.pending,
|
||||
evidenceStrength: truth.evidenceStrength,
|
||||
payloadJson: { acceptedTruthItemId: truth.id },
|
||||
provenanceJson: { createdFrom: "P6 accepted truth" },
|
||||
},
|
||||
});
|
||||
await createOperatingLogEntryInDatabase(project.slug, {
|
||||
type: "project update",
|
||||
title: `Project update prepared: ${truth.title}`,
|
||||
summary: truth.summary,
|
||||
linkedEntityIds: [truth.id, update.id],
|
||||
source: "accepted_truth",
|
||||
importance: truth.confidence >= 90 ? "important" : "normal",
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: `Project update prepared from accepted truth '${truthItemId}'.`,
|
||||
updatedEntityId: truthItemId,
|
||||
createdEntityId: update.slug ?? update.id,
|
||||
affectedEntities: [truthItemId, update.slug ?? update.id],
|
||||
};
|
||||
};
|
||||
|
||||
export const applyProjectUpdateInDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
projectUpdateId: string,
|
||||
): Promise<ReviewMutationResponseDto | undefined | "PROJECT_UPDATE_ALREADY_APPLIED"> => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const existing = await prisma.projectUpdate.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
OR: [{ id: projectUpdateId }, { slug: projectUpdateId }, { readableId: projectUpdateId }],
|
||||
},
|
||||
});
|
||||
if (!existing) return undefined;
|
||||
const appliedAt = new Date();
|
||||
const claimed = await prisma.projectUpdate.updateMany({
|
||||
where: { id: existing.id, status: "ready" },
|
||||
data: {
|
||||
status: "applied",
|
||||
approvalState: ApprovalState.approved,
|
||||
appliedAt,
|
||||
updatedBy: "api",
|
||||
},
|
||||
});
|
||||
if (claimed.count !== 1) return "PROJECT_UPDATE_ALREADY_APPLIED";
|
||||
const updated = await prisma.projectUpdate.findUniqueOrThrow({ where: { id: existing.id } });
|
||||
await createOperatingLogEntryInDatabase(project.slug, {
|
||||
type: "project update",
|
||||
title: `Project update applied: ${updated.title}`,
|
||||
summary: updated.summary,
|
||||
linkedEntityIds: [updated.id, updated.sourceTruthItemId],
|
||||
source: "project_update",
|
||||
importance: "important",
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: `Project update '${projectUpdateId}' applied.`,
|
||||
updatedEntityId: updated.slug ?? updated.id,
|
||||
affectedEntities: [updated.slug ?? updated.id, updated.sourceTruthItemId],
|
||||
};
|
||||
};
|
||||
|
||||
export const listOperatingLogEntriesFromDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
filters: OperatingLogListFilters,
|
||||
options: { page: number; pageSize: number; sort?: string },
|
||||
) => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const where: Prisma.OperatingLogEntryWhereInput = {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
...(filters.type?.length ? { type: { in: filters.type } } : {}),
|
||||
...(filters.source?.length ? { source: { in: filters.source } } : {}),
|
||||
...(filters.importance?.length ? { importance: { in: filters.importance } } : {}),
|
||||
};
|
||||
const total = await prisma.operatingLogEntry.count({ where });
|
||||
const records = await prisma.operatingLogEntry.findMany({
|
||||
where,
|
||||
orderBy: { timestamp: options.sort?.endsWith(":asc") ? "asc" : "desc" },
|
||||
skip: (options.page - 1) * options.pageSize,
|
||||
take: options.pageSize,
|
||||
});
|
||||
const items = records
|
||||
.map((record) => toLogDto(record, project.slug))
|
||||
.filter((entry) => !filters.linkedEntityId || entry.linkedEntityIds.includes(filters.linkedEntityId));
|
||||
return paginateMeta(items, total, options.page, options.pageSize, options.sort, filters);
|
||||
};
|
||||
|
||||
export const createOperatingLogEntryInDatabase = async (
|
||||
projectIdOrSlug: string,
|
||||
body: CreateOperatingLogEntryRequestDto,
|
||||
): Promise<OperatingLogEntryDto | undefined> => {
|
||||
const project = await findProjectRecord(projectIdOrSlug);
|
||||
if (!project) return undefined;
|
||||
const createdAt = new Date();
|
||||
const slug = `log-${project.slug}-${createdAt.getTime()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const record = await prisma.operatingLogEntry.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
slug,
|
||||
readableId: slug,
|
||||
timestamp: createdAt,
|
||||
type: body.type,
|
||||
title: body.title,
|
||||
summary: body.summary,
|
||||
linkedEntityIdsJson: body.linkedEntityIds ?? [],
|
||||
source: body.source ?? "manual",
|
||||
importance: body.importance ?? "normal",
|
||||
payloadJson: { createdFrom: "P6 operating log API" },
|
||||
},
|
||||
});
|
||||
return toLogDto(record, project.slug);
|
||||
};
|
||||
Reference in new issue
Block a user