import { AppError, appendEvent, audit, conflict, notFound, transition } from "@autoscholar/core"; import { artifacts, corrections, type Db, paperSequences, papers, paperTopics, paperVersions, publicationEvents, researchRuns, retractions, runAttempts, topics, } from "@autoscholar/db"; import { redact } from "@autoscholar/observability"; import { isPromotable } from "@autoscholar/research-contract"; import { ManuscriptMetadata, RunSummary } from "@autoscholar/schemas"; import { type FilesystemStorage, publicArtifactKey } from "@autoscholar/storage"; import { and, desc, eq, sql } from "drizzle-orm"; export function slugify(title: string, publicId: string): string { const base = title .toLowerCase() .normalize("NFKD") .replace(/[̀-ͯ]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 70) .replace(/-+$/, ""); return `${publicId.toLowerCase()}-${base || "paper"}`; } /** Allocates AS-WP--NNNN inside the caller's transaction (row-locked per year). */ async function allocatePublicId( tx: Parameters[0]>[0], year: number, ): Promise { await tx.insert(paperSequences).values({ year, last: 0 }).onConflictDoNothing(); const [row] = await tx .update(paperSequences) .set({ last: sql`${paperSequences.last} + 1` }) .where(eq(paperSequences.year, year)) .returning({ last: paperSequences.last }); return `AS-WP-${year}-${String(row!.last).padStart(4, "0")}`; } export interface PublishOptions { actor: { type: "system" | "admin"; id?: string; ip?: string | null }; /** publish as a new version (correction) of an existing paper */ correctsPaperId?: string; changelog?: string; override?: { rationale: string }; } /** * Stage 10 — transactional, idempotent publication. Promotes public artifacts (copy + re-hash into the public store), * then commits paper + version + events in one transaction. Re-running after a partial failure is safe: * object copies are write-once and the paper_versions.run_id unique index prevents duplicates. */ export async function publishRun( db: Db, storage: FilesystemStorage, runId: string, opts: PublishOptions, ): Promise<{ paperId: string; publicId: string; slug: string; versionNo: number; alreadyPublished: boolean; }> { const [run] = await db.select().from(researchRuns).where(eq(researchRuns.id, runId)).limit(1); if (!run) throw notFound("run"); const [existing] = await db .select({ v: paperVersions, p: papers }) .from(paperVersions) .innerJoin(papers, eq(paperVersions.paperId, papers.id)) .where(eq(paperVersions.runId, runId)) .limit(1); if (existing) { if (run.status !== "published") await db.transaction((tx) => transition(tx, runId, "published", { actor: "publisher", reason: "already published (idempotent)" }), ); return { paperId: existing.p.id, publicId: existing.p.publicId, slug: existing.p.slug, versionNo: existing.v.versionNo, alreadyPublished: true, }; } if (!["publishing", "ready_to_publish", "awaiting_approval"].includes(run.status)) throw conflict(`run status ${run.status} is not publishable`); if (run.status !== "publishing") await db.transaction((tx) => transition(tx, runId, "publishing", { actor: opts.actor.type === "admin" ? `admin:${opts.actor.id}` : "publisher", reason: opts.override ? `admin override: ${opts.override.rationale}` : "admin approval", }), ); // latest version of each artifact of the current attempt chain 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); const metaRow = latest.get("manuscript/metadata.json"); const pdfRow = latest.get("manuscript/main.pdf"); if (!metaRow || !pdfRow) throw new AppError("incomplete", "manuscript metadata or PDF missing", 409); if (pdfRow.synthetic || metaRow.synthetic) throw new AppError("synthetic", "synthetic artifacts can never be published", 409); const meta = ManuscriptMetadata.parse(JSON.parse((await storage.get(metaRow.storageKey)).toString("utf8"))); const summaryRow = latest.get("provenance/run-summary.json"); const summary = summaryRow ? RunSummary.parse(JSON.parse((await storage.get(summaryRow.storageKey)).toString("utf8"))) : null; const qaRow = latest.get("review/qa-report.json"); const qa = qaRow ? (JSON.parse((await storage.get(qaRow.storageKey)).toString("utf8")) as Record) : {}; const [attempt] = await db .select() .from(runAttempts) .where(and(eq(runAttempts.runId, runId), eq(runAttempts.attemptNo, run.currentAttemptNo))) .limit(1); const year = new Date().getUTCFullYear(); const result = await db.transaction(async (tx) => { // re-check idempotency under lock const [again] = await tx .select({ id: paperVersions.id }) .from(paperVersions) .where(eq(paperVersions.runId, runId)) .limit(1); if (again) throw new AppError("race", "published concurrently", 409); let paperId: string; let publicId: string; let slug: string; let versionNo: number; if (opts.correctsPaperId) { const [p] = await tx.select().from(papers).where(eq(papers.id, opts.correctsPaperId)).for("update"); if (!p) throw notFound("paper to correct"); if (p.status === "retracted") throw conflict("retracted papers cannot be corrected; publish a new paper"); const [last] = await tx .select({ n: sql`coalesce(max(${paperVersions.versionNo}),0)::int` }) .from(paperVersions) .where(eq(paperVersions.paperId, p.id)); paperId = p.id; publicId = p.publicId; slug = p.slug; versionNo = (last?.n ?? 0) + 1; } else { const reserved = (run.manifest as { paperNumber?: string }).paperNumber; if (reserved) { const [taken] = await tx .select({ id: papers.id }) .from(papers) .where(eq(papers.publicId, reserved)) .limit(1); publicId = taken ? await allocatePublicId(tx, year) : reserved; } else publicId = await allocatePublicId(tx, year); slug = slugify(meta.title, publicId); versionNo = 1; const [p] = await tx .insert(papers) .values({ publicId, slug, title: meta.title, status: "published", discipline: run.discipline, researchMode: run.researchMode, synthetic: false, }) .returning({ id: papers.id }); paperId = p!.id; } // promote artifacts (write-once copies; idempotent) const artifactSet: Array<{ path: string; kind: string; sha256: string; bytes: number; contentType: string; publicPath: string; }> = []; for (const a of latest.values()) { const rule = isPromotable(a.path); if (!rule.promote || a.synthetic) continue; const pubKey = publicArtifactKey(publicId, versionNo, a.path); const copied = await storage.copy(a.storageKey, pubKey); if (copied.sha256 !== a.sha256) throw new AppError("integrity", `promotion digest mismatch for ${a.path}`, 500); await tx.update(artifacts).set({ visibility: "public" }).where(eq(artifacts.id, a.id)); artifactSet.push({ path: a.path, kind: rule.kind ?? a.kind, sha256: a.sha256, bytes: a.bytes, contentType: rule.contentType ?? a.contentType, publicPath: `/papers/${publicId}/v${versionNo}/${a.path}`, }); } const pdfPublicPath = `/papers/${publicId}/v${versionNo}/manuscript/main.pdf`; const provenance = redact({ runId, attemptNo: run.currentAttemptNo, instructionSha256: run.manifestSha256 ? (run.manifest as { instructionSha256?: string }).instructionSha256 : undefined, instructionVersion: (run.manifest as { instructionVersion?: number }).instructionVersion, manifestSha256: run.manifestSha256, model: run.model, reviewerModel: run.reviewerModel, provider: run.provider, qualityPolicyVersion: run.qualityPolicyVersion, computeProfile: run.computeProfile, costUsd: Number(run.totalCostUsd), workerName: summary?.workerName ?? null, sdkVersion: summary?.sdkVersion ?? null, toolchain: summary?.toolchain ?? {}, stagesCompleted: summary?.stagesCompleted ?? [], startedAt: run.startedAt?.toISOString() ?? null, completedAt: attempt?.endedAt?.toISOString() ?? null, infrastructure: "MacLustr — Apple Silicon research compute infrastructure", }); const [v] = await tx .insert(paperVersions) .values({ paperId, versionNo, runId, title: meta.title, abstract: meta.abstract, keywords: meta.keywords, metadata: { ...meta }, artifactSet, pdfPublicPath, qaSummary: { publishable: qa.publishable, warnings: qa.warnings ?? [], policyVersion: qa.policyVersion, evaluatedAt: qa.evaluatedAt, gates: ((qa.gates as Array>) ?? []).map((g) => ({ gate: g.gate, status: g.status, severity: g.severity, message: g.message, })), }, provenance, changelog: opts.changelog ?? (versionNo === 1 ? "Initial release." : "Corrected version."), }) .returning({ id: paperVersions.id }); await tx .update(papers) .set({ currentVersionId: v!.id, title: meta.title, status: versionNo > 1 ? "corrected" : "published", updatedAt: new Date(), }) .where(eq(papers.id, paperId)); if (versionNo > 1) { await tx.insert(corrections).values({ paperId, fromVersionNo: versionNo - 1, toVersionNo: versionNo, notice: opts.changelog ?? "Corrected version.", }); await tx.insert(publicationEvents).values({ paperId, versionId: v!.id, kind: "corrected", actor: opts.actor.type === "admin" ? `admin:${opts.actor.id}` : "system", detail: { runId, versionNo }, }); } else { await tx.insert(publicationEvents).values({ paperId, versionId: v!.id, kind: "published", actor: opts.actor.type === "admin" ? `admin:${opts.actor.id}` : "system", detail: { runId, publicId }, }); } // topics from discipline (+ keyword matches on existing topics) const topicRows = await tx.select().from(topics); const chosen = new Set(); for (const t of topicRows) { if (t.slug === run.discipline) chosen.add(t.id); if (meta.keywords.some((k) => k.toLowerCase() === t.name.toLowerCase() || k.toLowerCase() === t.slug)) chosen.add(t.id); } for (const id of chosen) await tx.insert(paperTopics).values({ paperId, topicId: id }).onConflictDoNothing(); await tx.update(researchRuns).set({ paperId }).where(eq(researchRuns.id, runId)); await transition(tx, runId, "published", { actor: "publisher", reason: `${publicId} v${versionNo}`, data: { paperId, publicId, slug, versionNo, artifacts: artifactSet.length }, }); await audit( tx, { type: opts.actor.type, id: opts.actor.id ?? null, ip: opts.actor.ip ?? null }, "paper.publish", { type: "paper", id: paperId }, { publicId, versionNo, runId, override: !!opts.override }, ); return { paperId, publicId, slug, versionNo, alreadyPublished: false }; }); return result; } export async function retractPaper( db: Db, paperId: string, reason: string, notice: string, actor: { id: string; ip?: string | null }, ) { return db.transaction(async (tx) => { const [p] = await tx.select().from(papers).where(eq(papers.id, paperId)).for("update"); if (!p) throw notFound("paper"); if (p.status === "retracted") throw conflict("already retracted"); await tx.insert(retractions).values({ paperId, reason, notice, retractedBy: `admin:${actor.id}` }); await tx .update(papers) .set({ status: "retracted", retractedAt: new Date(), updatedAt: new Date() }) .where(eq(papers.id, paperId)); await tx.insert(publicationEvents).values({ paperId, versionId: p.currentVersionId, kind: "retracted", actor: `admin:${actor.id}`, detail: { reason }, }); await audit( tx, { type: "admin", id: actor.id, ip: actor.ip }, "paper.retract", { type: "paper", id: paperId }, { reason }, ); return { ok: true, publicId: p.publicId }; }); } /** Withdraws artifact delivery (files) while keeping the public tombstone and internal audit. */ export async function withdrawArtifacts( db: Db, paperId: string, actor: { id: string; ip?: string | null }, reason: string, ) { return db.transaction(async (tx) => { const [p] = await tx.select().from(papers).where(eq(papers.id, paperId)).for("update"); if (!p) throw notFound("paper"); const versions = await tx.select().from(paperVersions).where(eq(paperVersions.paperId, paperId)); for (const v of versions) await tx .update(paperVersions) .set({ metadata: { ...(v.metadata as object), artifactsWithdrawn: true, withdrawnReason: reason } }) .where(eq(paperVersions.id, v.id)); await tx.insert(publicationEvents).values({ paperId, versionId: p.currentVersionId, kind: "unpublished", actor: `admin:${actor.id}`, detail: { reason }, }); await audit( tx, { type: "admin", id: actor.id, ip: actor.ip }, "paper.withdraw_artifacts", { type: "paper", id: paperId }, { reason }, ); return { ok: true }; }); } export async function updatePaperMetadata( db: Db, paperId: string, patch: { title?: string; keywords?: string[]; abstract?: string }, actor: { id: string; ip?: string | null }, note: string, ) { return db.transaction(async (tx) => { const [p] = await tx.select().from(papers).where(eq(papers.id, paperId)).for("update"); if (!p?.currentVersionId) throw notFound("paper"); const [v] = await tx .select() .from(paperVersions) .where(eq(paperVersions.id, p.currentVersionId)) .limit(1); if (!v) throw notFound("version"); const history = ((v.metadata as { metadataHistory?: unknown[] }).metadataHistory ?? []) as unknown[]; history.push({ at: new Date().toISOString(), by: `admin:${actor.id}`, note, previous: { title: v.title, keywords: v.keywords, abstract: v.abstract }, }); await tx .update(paperVersions) .set({ title: patch.title ?? v.title, keywords: patch.keywords ?? v.keywords, abstract: patch.abstract ?? v.abstract, metadata: { ...(v.metadata as object), metadataHistory: history }, }) .where(eq(paperVersions.id, v.id)); if (patch.title) await tx .update(papers) .set({ title: patch.title, updatedAt: new Date() }) .where(eq(papers.id, paperId)); await tx.insert(publicationEvents).values({ paperId, versionId: v.id, kind: "metadata_updated", actor: `admin:${actor.id}`, detail: { note, fields: Object.keys(patch) }, }); await audit( tx, { type: "admin", id: actor.id, ip: actor.ip }, "paper.metadata_update", { type: "paper", id: paperId }, { note, fields: Object.keys(patch) }, ); return { ok: true }; }); } export async function listPapersAdmin(db: Db) { return db .select({ p: papers, v: paperVersions, runStatus: researchRuns.status }) .from(papers) .leftJoin(paperVersions, eq(papers.currentVersionId, paperVersions.id)) .leftJoin(researchRuns, eq(paperVersions.runId, researchRuns.id)) .orderBy(desc(papers.updatedAt)); } export async function appendRunEvent( db: Db, runId: string, kind: string, message: string, data?: Record, ) { await db.transaction((tx) => appendEvent(tx, runId, { kind, message, data, at: new Date().toISOString() })); }