import { randomUUID } from "node:crypto"; import { appendEvent, getEnv, getSetting, SETTING_KEYS, transition } from "@autoscholar/core"; import { artifacts, computeWorkers, type Db, publicationDecisions, qualityGateResults, researchRuns, reviewFindings, runAttempts, } from "@autoscholar/db"; import { effectiveAutoPublish, MODE_PROFILES } from "@autoscholar/research-contract"; import { type GateResult, type QaReport, type ResearchMode, ReviewReport } from "@autoscholar/schemas"; import type { FilesystemStorage } from "@autoscholar/storage"; import { and, desc, eq } from "drizzle-orm"; import { type ArtifactView, runGates } from "./gates.js"; export async function loadArtifactViews( db: Db, storage: FilesystemStorage, runId: string, ): Promise { const rows = await db .select() .from(artifacts) .where(eq(artifacts.runId, runId)) .orderBy(desc(artifacts.version)); const latest = new Map(); for (const r of rows) if (!latest.has(r.path)) latest.set(r.path, r); return [...latest.values()].map((r) => ({ path: r.path, kind: r.kind, sha256: r.sha256, bytes: r.bytes, synthetic: r.synthetic, read: () => storage.get(r.storageKey), storedSha256: async () => (await storage.head(r.storageKey))?.sha256 ?? null, })); } /** Stage 9 — deterministic quality decision computed on the control plane. Never from model text. */ export async function evaluateRun( db: Db, storage: FilesystemStorage, runId: string, ): Promise<{ report: QaReport; nextStatus: string }> { const env = getEnv(); const [run] = await db.select().from(researchRuns).where(eq(researchRuns.id, runId)).limit(1); if (!run) throw new Error("run not found"); const [attempt] = await db .select() .from(runAttempts) .where(and(eq(runAttempts.runId, runId), eq(runAttempts.attemptNo, run.currentAttemptNo))) .limit(1); const [worker] = attempt?.workerId ? await db.select().from(computeWorkers).where(eq(computeWorkers.id, attempt.workerId)).limit(1) : []; const nodeMeta = (worker?.nodeMeta ?? {}) as Record; const views = await loadArtifactViews(db, storage, runId); const mode = run.researchMode as ResearchMode; // persist reviewer findings for the admin quality page const rrView = views.find((v) => v.path === "review/review-1.json"); if (rrView) { try { const rr = ReviewReport.parse(JSON.parse((await rrView.read()).toString("utf8"))); for (const f of rr.findings) { await db .insert(reviewFindings) .values({ runId, round: rr.round, findingKey: f.id, severity: f.severity, category: f.category, title: f.title, detail: f.detail, proposedTest: f.proposedTest ?? null, resolution: f.resolution, resolutionNote: f.resolutionNote ?? null, }) .onConflictDoUpdate({ target: [reviewFindings.runId, reviewFindings.round, reviewFindings.findingKey], set: { resolution: f.resolution, resolutionNote: f.resolutionNote ?? null, detail: f.detail }, }); } } catch { /* gate will report */ } } const gates = await runGates({ mode, secondaryModes: run.secondaryModes, artifacts: views, attemptOutcome: attempt?.status ?? null, cancelRequested: run.cancelRequested, workerSimulated: nodeMeta.simulated === true || (worker?.name ?? "").startsWith("sim-"), reviewFindingsOpenBlockers: 0, }); const hardFailures = gates .filter((g) => g.severity === "hard" && g.status === "fail") .map((g) => `${g.gate}: ${g.message}`); const warnings = gates.filter((g) => g.status === "warn").map((g) => `${g.gate}: ${g.message}`); const publishable = hardFailures.length === 0; const evaluationId = randomUUID(); const report: QaReport = { policyVersion: run.qualityPolicyVersion, mode, evaluatedAt: new Date().toISOString(), gates, publishable, hardFailures, warnings, synthetic: views.some((v) => v.synthetic), }; await db.transaction(async (tx) => { for (const g of gates) await tx.insert(qualityGateResults).values({ runId, evaluationId, policyVersion: run.qualityPolicyVersion, mode, gate: g.gate, severity: g.severity, status: g.status, message: g.message, evidence: g.evidence, }); }); // store the report as a run artifact (review/qa-report.json) produced by the control plane const key = `runs/${runId}/a${run.currentAttemptNo}/review/qa-report.json`; const buf = Buffer.from(JSON.stringify(report, null, 2)); const stored = await storage.put(key, buf); const [prevQa] = await db .select({ version: artifacts.version, sha256: artifacts.sha256 }) .from(artifacts) .where(and(eq(artifacts.runId, runId), eq(artifacts.path, "review/qa-report.json"))) .orderBy(desc(artifacts.version)) .limit(1); if (!prevQa || prevQa.sha256 !== stored.sha256) { const version = (prevQa?.version ?? 0) + 1; const vkey = version === 1 ? key : `${key}.v${version}`; if (version > 1) await storage.put(vkey, buf); await db.insert(artifacts).values({ runId, attemptId: attempt?.id ?? null, stage: "quality", path: "review/qa-report.json", version, kind: "review", sha256: stored.sha256, bytes: stored.bytes, contentType: "application/json", storageKey: vkey, synthetic: false, }); } let nextStatus: string; await db.transaction(async (tx) => { if (!publishable) { await tx.insert(publicationDecisions).values({ runId, evaluationId, decision: "reject", decidedBy: "system", rationale: `hard gates failed: ${hardFailures.join(" | ")}`.slice(0, 4000), gateSummary: { hard: hardFailures.length, warnings: warnings.length }, }); await transition(tx, runId, "quality_failed", { actor: "qa", reason: `${hardFailures.length} hard gate(s) failed`, data: { evaluationId, hardFailures: hardFailures.slice(0, 10) }, }); nextStatus = "quality_failed"; return; } await transition(tx, runId, "ready_to_publish", { actor: "qa", reason: "all hard gates passed", data: { evaluationId, warnings: warnings.length }, }); const halted = env.PUBLISHING_ENABLED !== 1 || (await getSetting(tx as unknown as Db, SETTING_KEYS.publishingHalted, false)); const haltedModes = await getSetting( tx as unknown as Db, SETTING_KEYS.publishingHaltedModes, [], ); const auto = effectiveAutoPublish(mode, run.discipline, run.autoPublish); if (auto.allowed && !halted && !haltedModes.includes(mode) && MODE_PROFILES[mode].autoPublishAllowed) { await tx.insert(publicationDecisions).values({ runId, evaluationId, decision: "publish", decidedBy: "system", rationale: "all hard gates passed; autoPublish=true", gateSummary: { warnings }, }); await transition(tx, runId, "publishing", { actor: "qa", reason: "auto-publish policy" }); nextStatus = "publishing"; } else { await tx.insert(publicationDecisions).values({ runId, evaluationId, decision: "hold", decidedBy: "system", rationale: halted ? "publication halted by founder control" : (auto.reason ?? "autoPublish=false; awaiting admin approval"), gateSummary: { warnings }, }); await transition(tx, runId, "awaiting_approval", { actor: "qa", reason: halted ? "publishing halted" : (auto.reason ?? "manual approval required"), }); nextStatus = "awaiting_approval"; } }); await db.transaction((tx) => appendEvent(tx, runId, { kind: "qa.report", message: publishable ? `QA passed (${warnings.length} warnings)` : `QA failed: ${hardFailures.length} hard gate(s)`, data: { evaluationId }, at: new Date().toISOString(), }), ); return { report, nextStatus: nextStatus! }; } export type { GateResult };