diff --git a/apps/api/test/archive-api.test.ts b/apps/api/test/archive-api.test.ts index 5084f10..cb56754 100644 --- a/apps/api/test/archive-api.test.ts +++ b/apps/api/test/archive-api.test.ts @@ -43,12 +43,66 @@ const archiveRun = { status: "completed", evaluatorSlug: "exact-text", evaluatorVersion: "1.0.0", + evaluatorKind: "deterministic", + definitionSha256: + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + rawResultSha256: + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", exactMatch: true, }, runnerBuild: "build-test", createdAt: "2026-09-24T08:00:00.000Z", completedAt: "2026-09-24T08:00:01.000Z", sealedAt: "2026-09-24T08:00:01.100Z", + config: { + maxOutputTokens: 64, + }, + requestBlob: { + sha256: + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + sizeBytes: 123, + mimeType: "application/json", + visibility: "private", + }, + responseBlob: { + sha256: + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + sizeBytes: 456, + mimeType: "application/json", + visibility: "private", + }, + responseHeadersSha256: + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + usage: { + inputTokens: 4, + outputTokens: 1, + totalTokens: 5, + }, + timing: { + durationMs: 1000, + }, + evidence: [ + { + id: "00000000-0000-4000-8000-000000000026", + level: "E4", + executionPath: "first_party_direct", + collector: "modelapse-smoke", + sourceId: null, + notes: null, + createdAt: "2026-09-24T08:00:01.100Z", + attestation: { + id: "00000000-0000-4000-8000-000000000027", + keyId: "prod-key", + algorithm: "Ed25519", + payloadSha256: + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + signature: "base64-signature", + keyValidFrom: "2026-09-24T00:00:00.000Z", + keyValidTo: null, + createdAt: "2026-09-24T08:00:01.100Z", + }, + }, + ], } as const; describe("Archive read API", () => { diff --git a/apps/web/src/modelapse.ts b/apps/web/src/modelapse.ts index 17bcf3d..d1793a8 100644 --- a/apps/web/src/modelapse.ts +++ b/apps/web/src/modelapse.ts @@ -102,6 +102,9 @@ export interface ArchiveRun { readonly status: string; readonly evaluatorSlug: string; readonly evaluatorVersion: string; + readonly evaluatorKind: string; + readonly definitionSha256: string; + readonly rawResultSha256: string | null; readonly exactMatch: boolean | null; } | null; readonly runnerBuild: string; @@ -110,6 +113,53 @@ export interface ArchiveRun { readonly sealedAt: string | null; } +export interface ArchiveBlob { + readonly sha256: string; + readonly sizeBytes: number; + readonly mimeType: string; + readonly visibility: string; +} + +export interface ArchiveRunEvidence { + readonly id: string; + readonly level: string; + readonly executionPath: string; + readonly collector: string; + readonly sourceId: string | null; + readonly notes: string | null; + readonly createdAt: string; + readonly attestation: { + readonly id: string; + readonly keyId: string; + readonly algorithm: string; + readonly payloadSha256: string; + readonly signature: string; + readonly keyValidFrom: string; + readonly keyValidTo: string | null; + readonly createdAt: string; + } | null; +} + +export interface ArchiveRunDetail extends ArchiveRun { + readonly configJson: string | null; + readonly requestBlob: ArchiveBlob | null; + readonly responseBlob: ArchiveBlob | null; + readonly responseHeadersSha256: string | null; + readonly usageJson: string | null; + readonly timingJson: string | null; + readonly evidence: readonly ArchiveRunEvidence[]; +} + +interface ArchiveRunDetailWire extends ArchiveRun { + readonly config: unknown; + readonly requestBlob: ArchiveBlob | null; + readonly responseBlob: ArchiveBlob | null; + readonly responseHeadersSha256: string | null; + readonly usage: unknown; + readonly timing: unknown; + readonly evidence: readonly ArchiveRunEvidence[]; +} + export interface ControlJob { readonly id: string; readonly kind: string; @@ -148,6 +198,10 @@ interface ReadJobInput extends OperatorInput { readonly jobId: string; } +interface ReadArchiveRunInput { + readonly runId: string; +} + interface ApiOptions { readonly method?: "GET" | "POST"; readonly control?: boolean; @@ -195,6 +249,11 @@ function apiErrorMessage(payload: unknown, status: number): string { return `Modelapse API request failed with HTTP ${status}`; } +function serializeArchiveMetadata(value: unknown): string | null { + if (value === null || value === undefined) return null; + return JSON.stringify(value, null, 2) ?? null; +} + function controlAuthDiagnostic(payload: unknown): string | null { if (!isRecord(payload)) return null; @@ -320,6 +379,17 @@ function parseReadJobInput(value: unknown): ReadJobInput { return { operatorToken, jobId }; } +function parseArchiveRunInput(value: unknown): ReadArchiveRunInput { + if (!isRecord(value)) throw new Error("Archive Run request must be an object"); + + const runId = value.runId; + if (typeof runId !== "string" || !UUID_RE.test(runId)) { + throw new Error("runId must be a UUID"); + } + + return { runId }; +} + function requireOperator(candidate: string): void { const expected = process.env.MODELAPSE_WEB_OPERATOR_TOKEN; if (!expected) { @@ -361,6 +431,28 @@ export const getWorkbenchSnapshot = createServerFn({ method: "GET" }).handler( }, ); +export const getArchiveRun = createServerFn({ method: "POST" }) + .validator(parseArchiveRunInput) + .handler(async ({ data }): Promise => { + try { + const result = await requestJson<{ run: ArchiveRunDetailWire }>( + `/v1/archive/runs/${data.runId}`, + ); + const { config, usage, timing, ...run } = result.run; + return { + ...run, + configJson: serializeArchiveMetadata(config), + usageJson: serializeArchiveMetadata(usage), + timingJson: serializeArchiveMetadata(timing), + }; + } catch (error) { + if (error instanceof ApiRequestError && error.status === 404) { + return null; + } + throw error; + } + }); + export const getControlCatalog = createServerFn({ method: "POST" }) .validator(parseOperatorInput) .handler(async ({ data }): Promise => { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 2e0b271..c679283 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -4,8 +4,15 @@ // This file is regenerated by the TanStack Start Vite plugin. import { Route as rootRouteImport } from "./routes/__root"; +import { Route as RunsRunIdRouteImport } from "./routes/runs.$runId"; import { Route as IndexRouteImport } from "./routes/index"; +const RunsRunIdRoute = RunsRunIdRouteImport.update({ + id: "/runs/$runId", + path: "/runs/$runId", + getParentRoute: () => rootRouteImport, +} as any); + const IndexRoute = IndexRouteImport.update({ id: "/", path: "/", @@ -14,23 +21,26 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { "/": typeof IndexRoute; + "/runs/$runId": typeof RunsRunIdRoute; } export interface FileRoutesByTo { "/": typeof IndexRoute; + "/runs/$runId": typeof RunsRunIdRoute; } export interface FileRoutesById { __root__: typeof rootRouteImport; "/": typeof IndexRoute; + "/runs/$runId": typeof RunsRunIdRoute; } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath; - fullPaths: "/"; + fullPaths: "/" | "/runs/$runId"; fileRoutesByTo: FileRoutesByTo; - to: "/"; - id: "__root__" | "/"; + to: "/" | "/runs/$runId"; + id: "__root__" | "/" | "/runs/$runId"; fileRoutesById: FileRoutesById; } @@ -43,11 +53,19 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof IndexRouteImport; parentRoute: typeof rootRouteImport; }; + "/runs/$runId": { + id: "/runs/$runId"; + path: "/runs/$runId"; + fullPath: "/runs/$runId"; + preLoaderRoute: typeof RunsRunIdRouteImport; + parentRoute: typeof rootRouteImport; + }; } } const rootRouteChildren = { IndexRoute, + RunsRunIdRoute, }; export const routeTree = rootRouteImport diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 616dfde..3994257 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -377,6 +377,9 @@ function ModelapseHome() { {activeRun.model.marketingName ?? activeRun.requestedModel} {activeRun.test.caseSlug} + + View Run → + ) : null} @@ -430,7 +433,9 @@ function ModelapseHome() { {filteredRuns.map((run) => (
- {run.id.slice(0, 8)} + + {run.id.slice(0, 8)} → + {run.provider.slug} diff --git a/apps/web/src/routes/runs.$runId.tsx b/apps/web/src/routes/runs.$runId.tsx new file mode 100644 index 0000000..2f5df2f --- /dev/null +++ b/apps/web/src/routes/runs.$runId.tsx @@ -0,0 +1,413 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { + getArchiveRun, + type ArchiveBlob, + type ArchiveRunDetail, +} from "../modelapse"; + +export const Route = createFileRoute("/runs/$runId")({ + loader: ({ params }) => + getArchiveRun({ + data: { + runId: params.runId, + }, + }), + component: ArchiveRunPage, +}); + +function formatTimestamp(value: string | null): string { + if (!value) return "—"; + return new Date(value).toISOString().replace("T", " ").replace(".000Z", "Z"); +} + +function shortHash(value: string | null): string { + if (!value) return "—"; + if (value.length <= 28) return value; + return `${value.slice(0, 16)}…${value.slice(-10)}`; +} + +function evaluationLabel(run: ArchiveRunDetail): string { + if (!run.evaluation) return "not evaluated"; + if (run.evaluation.exactMatch === true) return "exact match"; + if (run.evaluation.exactMatch === false) return "mismatch"; + return run.evaluation.status; +} + +function evaluationClass(run: ArchiveRunDetail): string { + if (run.evaluation?.exactMatch === true) return "badge badge-pass"; + if (run.evaluation?.exactMatch === false) return "badge badge-fail"; + return "badge"; +} + +function jsonBlock(value: string | null): string { + return value ?? "—"; +} + +function BlobProof({ + label, + blob, +}: Readonly<{ + label: string; + blob: ArchiveBlob | null; +}>) { + return ( +
+ {label} + {shortHash(blob?.sha256 ?? null)} + + {blob + ? `${blob.mimeType} · ${blob.sizeBytes} bytes · ${blob.visibility} bytes` + : "not captured"} + +
+ ); +} + +function ArchiveRunPage() { + const run = Route.useLoaderData(); + + if (!run) { + return ( +
+
+ + M + + Modelapse + AI Model Test & Evolution Archive + + +
+
+

PUBLIC ARCHIVE

+

Run not found

+

+ This Run is not a sealed public Archive record, or the identifier is + no longer available. +

+ + ← Back to Archive + +
+
+ ); + } + + const primaryEvidence = + run.evidence.find((evidence) => evidence.level === run.evidenceLevel) ?? + run.evidence[0] ?? + null; + const attestation = primaryEvidence?.attestation ?? null; + + return ( +
+
+ + M + + Modelapse + AI Model Test & Evolution Archive + + + + Archive + +
+ +
+
+ Archive + / + Run {run.id.slice(0, 8)} +
+ +
+
+

SEALED RUN

+

{run.model.marketingName ?? run.requestedModel}

+

+ {run.test.familyName} · {run.test.caseSlug} · v{run.test.version} +

+
+
+ {evaluationLabel(run)} + evidence {run.evidenceLevel ?? "—"} + {run.executionPath} +
+
+ +
+
+
Run ID
+
{run.id}
+
+
+
Status
+
{run.status}
+
+
+
Sealed
+
{formatTimestamp(run.sealedAt)}
+
+
+
+ +
+
+
+

PROOF CHAIN

+

Hashes before claims

+
+

+ Payload bytes remain private. Public hashes make the archived + evidence addressable without publishing provider traffic. +

+
+ +
+ + +
+ Attestation payload + + {shortHash(attestation?.payloadSha256 ?? null)} + + + {attestation + ? `${attestation.algorithm} · key ${attestation.keyId}` + : "no attestation"} + +
+
+ Evaluation result + + {shortHash(run.evaluation?.rawResultSha256 ?? null)} + + + {run.evaluation + ? `${run.evaluation.evaluatorSlug}@${run.evaluation.evaluatorVersion}` + : "not evaluated"} + +
+
+
+ +
+
+
+

EXECUTION

+

What actually ran

+
+
+ +
+
+
+
Provider
+
{run.provider.name}
+
+
+
Requested model
+
{run.requestedModel}
+
+
+
Returned model
+
{run.returnedModel ?? "—"}
+
+
+
Canonical model
+
{run.model.canonicalSlug ?? "unbound"}
+
+
+
Execution path
+
{run.executionPath}
+
+
+
Runner build
+
{run.runnerBuild}
+
+
+ +
+
+
Created
+
{formatTimestamp(run.createdAt)}
+
+
+
Completed
+
{formatTimestamp(run.completedAt)}
+
+
+
Sealed
+
{formatTimestamp(run.sealedAt)}
+
+
+
Response headers SHA-256
+
+ {shortHash(run.responseHeadersSha256)} +
+
+
+
Test Case ID
+
{run.test.testCaseId}
+
+
+
Variant
+
{run.test.variantName}
+
+
+
+ +
+
+ Run configuration +
{jsonBlock(run.configJson)}
+
+
+ Provider usage +
{jsonBlock(run.usageJson)}
+
+
+ Timing +
{jsonBlock(run.timingJson)}
+
+
+
+ +
+
+
+

EVIDENCE

+

Attested provenance

+
+ {run.evidence.length} record(s) +
+ +
+ {run.evidence.map((evidence) => ( +
+
+
+ {evidence.level} + {evidence.collector} +
+ {formatTimestamp(evidence.createdAt)} +
+ +
+
+
Execution path
+
{evidence.executionPath}
+
+
+
Evidence ID
+
{evidence.id}
+
+
+
Source record
+
{evidence.sourceId ?? "—"}
+
+
+
Notes
+
{evidence.notes ?? "—"}
+
+
+ + {evidence.attestation ? ( +
+ Attestation details +
+
+
Attestation ID
+
{evidence.attestation.id}
+
+
+
Key
+
+ {evidence.attestation.keyId} ·{" "} + {evidence.attestation.algorithm} +
+
+
+
Key valid from
+
{formatTimestamp(evidence.attestation.keyValidFrom)}
+
+
+
Payload SHA-256
+
{evidence.attestation.payloadSha256}
+
+
+
Signature
+
{evidence.attestation.signature}
+
+
+
+ ) : null} +
+ ))} + + {run.evidence.length === 0 ? ( +
No evidence record is attached.
+ ) : null} +
+
+ +
+
+
+

EVALUATION

+

Derived view

+
+ {evaluationLabel(run)} +
+ + {run.evaluation ? ( +
+
+
Evaluation ID
+
{run.evaluation.id}
+
+
+
Evaluator
+
+ {run.evaluation.evaluatorSlug}@ + {run.evaluation.evaluatorVersion} +
+
+
+
Kind
+
{run.evaluation.evaluatorKind}
+
+
+
Definition SHA-256
+
{run.evaluation.definitionSha256}
+
+
+
Raw result SHA-256
+
{run.evaluation.rawResultSha256 ?? "—"}
+
+
+
Exact match
+
+ {run.evaluation.exactMatch === null + ? "—" + : run.evaluation.exactMatch + ? "true" + : "false"} +
+
+
+ ) : ( +
No Evaluation has been recorded.
+ )} +
+ + +
+ ); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 4fe6296..c943ec4 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -458,6 +458,300 @@ button:disabled { text-align: center; } + +.text-link, +.header-link, +.archive-run-link, +.run-breadcrumb a { + color: var(--accent); + text-decoration: none; +} + +.text-link:hover, +.header-link:hover, +.archive-run-link:hover, +.run-breadcrumb a:hover { + text-decoration: underline; + text-underline-offset: 4px; +} + +.header-link { + font: 700 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.archive-run-link { + font: 800 13px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.run-detail-hero { + padding: 46px clamp(20px, 4vw, 64px) 54px; + border-bottom: 1px solid var(--line); + background: + radial-gradient(circle at 84% 18%, rgba(200, 255, 99, 0.08), transparent 24rem), + rgba(255, 255, 255, 0.008); +} + +.run-breadcrumb { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 46px; + color: var(--muted); + font: 11px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.run-title-row { + display: flex; + align-items: end; + justify-content: space-between; + gap: 32px; +} + +.run-title-row h1 { + margin: 0; + font-size: clamp(44px, 6vw, 86px); + line-height: 0.95; + letter-spacing: -0.05em; + font-weight: 650; +} + +.run-subtitle { + margin: 20px 0 0; + color: var(--soft); + font-size: clamp(15px, 1.5vw, 19px); +} + +.run-verdict { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.run-identity { + margin: 48px 0 0; + display: grid; + grid-template-columns: 1.5fr 0.7fr 1fr; + border-top: 1px solid var(--line); + border-left: 1px solid var(--line); +} + +.run-identity > div { + min-width: 0; + padding: 16px; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.run-identity dt, +.detail-panel dt, +.evaluation-panel dt, +.evidence-card dt { + color: var(--muted); + font: 700 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.run-identity dd, +.detail-panel dd, +.evaluation-panel dd, +.evidence-card dd { + margin: 7px 0 0; + overflow-wrap: anywhere; + font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.run-detail-section { + background: rgba(255, 255, 255, 0.004); +} + +.section-note { + max-width: 560px; + margin: 0; + color: var(--muted); + font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; + text-align: right; +} + +.proof-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border-top: 1px solid var(--line); + border-left: 1px solid var(--line); +} + +.proof-card { + min-width: 0; + min-height: 145px; + padding: 20px; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 14px; +} + +.proof-card > span, +.json-panel > span { + color: var(--muted); + font: 700 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.proof-card strong { + color: var(--ink); + overflow-wrap: anywhere; + font: 600 13px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.proof-card small { + color: var(--muted); + font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + border-top: 1px solid var(--line); + border-left: 1px solid var(--line); +} + +.detail-panel { + margin: 0; + display: grid; + grid-template-columns: 1fr 1fr; +} + +.detail-panel > div { + min-width: 0; + min-height: 92px; + padding: 17px; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.json-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 20px; + border-top: 1px solid var(--line); + border-left: 1px solid var(--line); +} + +.json-panel { + min-width: 0; + padding: 18px; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.json-panel pre { + margin: 14px 0 0; + min-height: 92px; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--soft); + font: 11px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.evidence-stack { + display: grid; + gap: 16px; +} + +.evidence-card { + border: 1px solid var(--line); + background: var(--panel); +} + +.evidence-card-head { + padding: 18px 20px; + border-bottom: 1px solid var(--line); + display: flex; + justify-content: space-between; + gap: 20px; + align-items: center; +} + +.evidence-card-head > div { + display: flex; + align-items: center; + gap: 12px; +} + +.evidence-card-head small { + color: var(--muted); + font: 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.evidence-card > dl, +.attestation-details dl, +.evaluation-panel { + margin: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.evidence-card > dl > div, +.attestation-details dl > div, +.evaluation-panel > div { + min-width: 0; + padding: 16px 20px; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.attestation-details { + border-top: 0; +} + +.attestation-details summary { + padding: 14px 20px; + cursor: pointer; + color: var(--accent); + font: 700 11px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.attestation-details .detail-wide { + grid-column: 1 / -1; +} + +.evaluation-panel { + border-top: 1px solid var(--line); + border-left: 1px solid var(--line); +} + +.run-not-found { + min-height: 70vh; + padding: clamp(80px, 12vw, 160px) clamp(20px, 4vw, 64px); + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; +} + +.run-not-found h1 { + margin: 0; + font-size: clamp(48px, 8vw, 96px); + letter-spacing: -0.05em; +} + +.run-not-found p:not(.eyebrow) { + max-width: 620px; + margin: 24px 0; + color: var(--muted); + line-height: 1.6; +} + footer { height: 110px; padding: 0 clamp(20px, 4vw, 64px); @@ -498,6 +792,29 @@ footer { grid-template-columns: 1fr 1fr; } + .run-title-row { + align-items: flex-start; + flex-direction: column; + } + + .run-verdict { + justify-content: flex-start; + } + + .run-identity, + .proof-grid, + .json-grid { + grid-template-columns: 1fr 1fr; + } + + .detail-grid { + grid-template-columns: 1fr; + } + + .section-note { + text-align: left; + } + .archive-row > span { min-height: 90px; } @@ -533,6 +850,20 @@ footer { grid-template-columns: 1fr; } + .run-identity, + .proof-grid, + .detail-panel, + .json-grid, + .evidence-card > dl, + .attestation-details dl, + .evaluation-panel { + grid-template-columns: 1fr; + } + + .attestation-details .detail-wide { + grid-column: auto; + } + .execute-bar { align-items: stretch; flex-direction: column; diff --git a/docs/web-operator-ui.md b/docs/web-operator-ui.md index f2b0500..cbc00d7 100644 --- a/docs/web-operator-ui.md +++ b/docs/web-operator-ui.md @@ -1,4 +1,4 @@ -# TanStack Start operator Web v0.1 +# TanStack Start Web and Archive The first Modelapse Web layer closes the normal product loop without moving provider execution or privileged catalog access into the browser. @@ -109,6 +109,42 @@ ghcr.io/imwarn/modelapse-web:sha- Coolify deployment is optional until `COOLIFY_WEB_UUID` is configured. This lets Web rollout be staged without disturbing API/runner deployment. +## Archive / Run Detail v0.2 + +Every sealed public Run now has a stable route: + +```text +/runs/ +``` + +The page is public and does not require the operator credential. Its loader reads only `/v1/archive/runs/:runId`, so private Test Cases and unsealed Runs remain outside the Archive projection. + +The detail page exposes public verification metadata without publishing captured provider traffic: + +- canonical/provider/Test identity; +- requested and returned model identifiers; +- Run status and sealed timestamps; +- runner build and execution path; +- reproducibility-oriented scalar Run configuration; +- request and response SHA-256, size and MIME metadata; +- response-header capture SHA-256; +- normalized timing and usage metadata; +- Evidence records and collector identity; +- attestation key ID, algorithm, payload hash and signature; +- Evaluation identity, evaluator definition hash, raw result hash and deterministic result. + +Request and response payload bytes remain private CAS objects. Their `objectKey` values are not included in the public Archive contract. + +This preserves the project rule: + +```text +Run = historical fact +Evidence = provenance +Evaluation = derived view +``` + +The Archive list and successful operator Run result both link directly to the immutable Run detail URL. + ## Deliberately deferred - general user login/session management; diff --git a/packages/persistence/src/archive-repository.ts b/packages/persistence/src/archive-repository.ts index 6e95a4a..0a64a64 100644 --- a/packages/persistence/src/archive-repository.ts +++ b/packages/persistence/src/archive-repository.ts @@ -63,6 +63,9 @@ export interface ArchiveRunView { readonly status: string; readonly evaluatorSlug: string; readonly evaluatorVersion: string; + readonly evaluatorKind: string; + readonly definitionSha256: string; + readonly rawResultSha256: string | null; readonly exactMatch: boolean | null; } | null; readonly runnerBuild: string; @@ -71,6 +74,43 @@ export interface ArchiveRunView { readonly sealedAt: string | null; } +export interface ArchiveBlobView { + readonly sha256: string; + readonly sizeBytes: number; + readonly mimeType: string; + readonly visibility: string; +} + +export interface ArchiveRunEvidenceView { + readonly id: string; + readonly level: string; + readonly executionPath: string; + readonly collector: string; + readonly sourceId: string | null; + readonly notes: string | null; + readonly createdAt: string; + readonly attestation: { + readonly id: string; + readonly keyId: string; + readonly algorithm: string; + readonly payloadSha256: string; + readonly signature: string; + readonly keyValidFrom: string; + readonly keyValidTo: string | null; + readonly createdAt: string; + } | null; +} + +export interface ArchiveRunDetailView extends ArchiveRunView { + readonly config: Readonly> | null; + readonly requestBlob: ArchiveBlobView | null; + readonly responseBlob: ArchiveBlobView | null; + readonly responseHeadersSha256: string | null; + readonly usage: Readonly> | null; + readonly timing: Readonly> | null; + readonly evidence: readonly ArchiveRunEvidenceView[]; +} + interface ArchiveRunRow { id: string; status: string; @@ -95,6 +135,9 @@ interface ArchiveRunRow { evaluation_status: string | null; evaluator_slug: string | null; evaluator_version: string | null; + evaluator_kind: string | null; + evaluator_definition_sha256: string | null; + evaluation_raw_result_sha256: string | null; exact_match: number | null; runner_build: string; created_at: Date; @@ -133,12 +176,17 @@ function runView(row: ArchiveRunRow): ArchiveRunView { row.evaluation_id && row.evaluation_status && row.evaluator_slug && - row.evaluator_version + row.evaluator_version && + row.evaluator_kind && + row.evaluator_definition_sha256 ? { id: row.evaluation_id, status: row.evaluation_status, evaluatorSlug: row.evaluator_slug, evaluatorVersion: row.evaluator_version, + evaluatorKind: row.evaluator_kind, + definitionSha256: row.evaluator_definition_sha256, + rawResultSha256: row.evaluation_raw_result_sha256, exactMatch: row.exact_match === null ? null : Number(row.exact_match) === 1, } @@ -175,6 +223,9 @@ const RUN_SELECT = ` ev.status AS evaluation_status, e.slug AS evaluator_slug, e.version AS evaluator_version, + e.kind AS evaluator_kind, + e.definition_sha256 AS evaluator_definition_sha256, + ev.raw_result_blob_sha256 AS evaluation_raw_result_sha256, mv.numeric_value AS exact_match, r.runner_build, r.created_at, @@ -366,8 +417,8 @@ export class PgArchiveRepository { return result.rows.map(runView); } - async getRun(runId: string): Promise { - const result = await this.pool.query( + async getRun(runId: string): Promise { + const summaryResult = await this.pool.query( RUN_SELECT + ` WHERE r.id = $1 @@ -377,6 +428,132 @@ export class PgArchiveRepository { [runId], ); - return result.rows[0] ? runView(result.rows[0]) : null; + const summaryRow = summaryResult.rows[0]; + if (!summaryRow) return null; + + const detailResult = await this.pool.query<{ + config: Readonly> | null; + request_sha256: string | null; + request_size_bytes: string | null; + request_mime_type: string | null; + request_visibility: string | null; + response_sha256: string | null; + response_size_bytes: string | null; + response_mime_type: string | null; + response_visibility: string | null; + response_headers_sha256: string | null; + usage: Readonly> | null; + timing: Readonly> | null; + evidence: ArchiveRunEvidenceView[]; + }>( + `SELECT + CASE WHEN rc.run_id IS NULL THEN NULL ELSE jsonb_strip_nulls( + jsonb_build_object( + 'temperature', rc.temperature, + 'topP', rc.top_p, + 'maxOutputTokens', rc.max_output_tokens, + 'reasoningMode', rc.reasoning_mode, + 'reasoningEffort', rc.reasoning_effort, + 'seed', rc.seed::text, + 'serviceTier', rc.service_tier + ) + ) END AS config, + request_blob.sha256 AS request_sha256, + request_blob.size_bytes AS request_size_bytes, + request_blob.mime_type AS request_mime_type, + request_blob.visibility AS request_visibility, + response_blob.sha256 AS response_sha256, + response_blob.size_bytes AS response_size_bytes, + response_blob.mime_type AS response_mime_type, + response_blob.visibility AS response_visibility, + prm.response_headers_blob_sha256 AS response_headers_sha256, + prm.usage, + prm.timing, + COALESCE(( + SELECT jsonb_agg( + jsonb_strip_nulls(jsonb_build_object( + 'id', er.id, + 'level', er.level, + 'executionPath', er.execution_path, + 'collector', er.collector, + 'sourceId', er.source_id, + 'notes', er.notes, + 'createdAt', er.created_at, + 'attestation', CASE + WHEN ra.id IS NULL THEN NULL + ELSE jsonb_build_object( + 'id', ra.id, + 'keyId', ra.key_id, + 'algorithm', ak.algorithm, + 'payloadSha256', ra.payload_blob_sha256, + 'signature', ra.signature, + 'keyValidFrom', ak.valid_from, + 'keyValidTo', ak.valid_to, + 'createdAt', ra.created_at + ) + END + )) + ORDER BY er.created_at ASC + ) + FROM modelapse.evidence_records er + LEFT JOIN modelapse.run_attestations ra + ON ra.id = er.attestation_id + LEFT JOIN modelapse.attestation_keys ak + ON ak.id = ra.key_id + WHERE er.run_id = r.id + ), '[]'::jsonb) AS evidence + FROM modelapse.runs r + JOIN modelapse.test_cases tc ON tc.id = r.test_case_id + LEFT JOIN modelapse.run_configs rc ON rc.run_id = r.id + LEFT JOIN modelapse.provider_run_metadata prm ON prm.run_id = r.id + LEFT JOIN modelapse.blobs request_blob + ON request_blob.sha256 = r.request_blob_sha256 + LEFT JOIN modelapse.blobs response_blob + ON response_blob.sha256 = r.response_blob_sha256 + WHERE r.id = $1 + AND tc.visibility = 'public' + AND r.sealed_at IS NOT NULL + LIMIT 1`, + [runId], + ); + + const detail = detailResult.rows[0]; + if (!detail) return null; + + const blob = ( + sha256: string | null, + sizeBytes: string | null, + mimeType: string | null, + visibility: string | null, + ): ArchiveBlobView | null => + sha256 && sizeBytes && mimeType && visibility + ? { + sha256, + sizeBytes: Number(sizeBytes), + mimeType, + visibility, + } + : null; + + return { + ...runView(summaryRow), + config: detail.config, + requestBlob: blob( + detail.request_sha256, + detail.request_size_bytes, + detail.request_mime_type, + detail.request_visibility, + ), + responseBlob: blob( + detail.response_sha256, + detail.response_size_bytes, + detail.response_mime_type, + detail.response_visibility, + ), + responseHeadersSha256: detail.response_headers_sha256, + usage: detail.usage, + timing: detail.timing, + evidence: detail.evidence, + }; } } diff --git a/packages/persistence/test/integration.test.ts b/packages/persistence/test/integration.test.ts index e0a44b5..6d494b7 100644 --- a/packages/persistence/test/integration.test.ts +++ b/packages/persistence/test/integration.test.ts @@ -11,6 +11,7 @@ import type { } from "@modelapse/provider-adapter"; import { executePersistedProviderRun, + PgArchiveRepository, PgRunRepository, } from "../src/index.js"; @@ -21,6 +22,7 @@ const DATABASE_URL = describe("PostgreSQL Run persistence", () => { const seedPool = new Pool({ connectionString: DATABASE_URL }); const repository = PgRunRepository.connect(DATABASE_URL, { max: 2 }); + const archive = PgArchiveRepository.connect(DATABASE_URL, { max: 2 }); let root = ""; let providerId = ""; let testCaseId = ""; @@ -87,6 +89,7 @@ describe("PostgreSQL Run persistence", () => { }); afterAll(async () => { + await archive.close(); await repository.close(); await seedPool.end(); if (root) await rm(root, { recursive: true, force: true }); @@ -190,6 +193,32 @@ describe("PostgreSQL Run persistence", () => { routedProviderName: "Fake Upstream", }); + const archived = await archive.getRun(result.run.id); + expect(archived).not.toBeNull(); + expect(archived?.requestBlob).toMatchObject({ + sha256: result.sealed.requestSha256, + visibility: "private", + }); + expect(archived?.responseBlob).toMatchObject({ + sha256: result.sealed.responseSha256, + visibility: "private", + }); + expect(archived?.timing).toMatchObject({ durationMs: 250 }); + expect(archived?.usage).toMatchObject({ + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + }); + expect(archived?.evidence[0]).toMatchObject({ + level: "E3", + executionPath: "routed_provider", + collector: "modelapse-integration-test", + attestation: { + keyId: "integration-key", + algorithm: "Ed25519", + }, + }); + await expect( repository.markStatus(result.run.id, "executing"), ).rejects.toThrow(/sealed run/);