Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type ArchiveRepository = Pick<
| "getModel"
| "getTest"
| "compareLatest"
| "getRunHistory"
>;

export interface AppDependencies {
Expand Down Expand Up @@ -199,6 +200,42 @@ export function createApp(deps: AppDependencies) {
return c.json({ test });
});

app.get("/v1/archive/history", async (c) => {
if (!deps.archive) {
return c.json({ error: "archive_unavailable" }, 503);
}

const modelId = c.req.query("modelId");
const testCaseId = c.req.query("testCaseId");
const rawLimit = c.req.query("limit");

if (!modelId || !UUID_RE.test(modelId)) {
return c.json({ error: "invalid_model_id" }, 400);
}
if (!testCaseId || !UUID_RE.test(testCaseId)) {
return c.json({ error: "invalid_test_case_id" }, 400);
}

const limit = rawLimit === undefined ? undefined : Number(rawLimit);
if (
limit !== undefined &&
(!Number.isInteger(limit) || limit < 1 || limit > 100)
) {
return c.json({ error: "invalid_limit" }, 400);
}

const history = await deps.archive.getRunHistory({
modelId,
testCaseId,
...(limit !== undefined ? { limit } : {}),
});
if (!history) {
return c.json({ error: "archive_history_not_found" }, 404);
}

return c.json({ history });
});

app.get("/v1/archive/compare", async (c) => {
if (!deps.archive) {
return c.json({ error: "archive_unavailable" }, 503);
Expand Down
81 changes: 81 additions & 0 deletions apps/api/test/archive-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createApp } from "../src/app.js";
const RUN_ID = "00000000-0000-4000-8000-000000000021";
const MODEL_ID = "00000000-0000-4000-8000-000000000022";
const TEST_CASE_ID = "00000000-0000-4000-8000-000000000023";
const PREVIOUS_RUN_ID = "00000000-0000-4000-8000-000000000037";

function baseRuns() {
return {
Expand Down Expand Up @@ -103,6 +104,62 @@ const archiveRun = {
},
},
],
relations: [
{
direction: "outgoing",
relationType: "repeat_of",
relatedRunId: PREVIOUS_RUN_ID,
createdAt: "2026-09-24T08:00:01.200Z",
},
],
} as const;

const archiveRunHistory = {
model: {
id: MODEL_ID,
provider: archiveRun.provider,
canonicalSlug: "deepseek-flash",
marketingName: "DeepSeek Flash",
status: "active",
runCount: 2,
latestRunAt: archiveRun.completedAt,
},
test: {
testCaseId: TEST_CASE_ID,
familySlug: archiveRun.test.familySlug,
familyName: archiveRun.test.familyName,
variantSlug: archiveRun.test.variantSlug,
variantName: archiveRun.test.variantName,
category: "smoke",
artifactType: "text",
version: archiveRun.test.version,
caseSlug: archiveRun.test.caseSlug,
evaluator: {
slug: "exact-text",
version: "1.0.0",
kind: "deterministic",
},
runCount: 2,
},
runs: [
{
...archiveRun,
id: PREVIOUS_RUN_ID,
createdAt: "2026-09-23T08:00:00.000Z",
completedAt: "2026-09-23T08:00:01.000Z",
sealedAt: "2026-09-23T08:00:01.100Z",
relations: [],
},
archiveRun,
],
relations: [
{
fromRunId: RUN_ID,
toRunId: PREVIOUS_RUN_ID,
relationType: "repeat_of",
createdAt: "2026-09-24T08:00:01.200Z",
},
],
} as const;

const archiveModelDetail = {
Expand Down Expand Up @@ -306,6 +363,7 @@ describe("Archive read API", () => {
getTest: async (testCaseId) =>
testCaseId === TEST_CASE_ID ? archiveTestDetail : null,
compareLatest: async () => archiveComparison,
getRunHistory: async () => archiveRunHistory,
},
});

Expand Down Expand Up @@ -353,6 +411,14 @@ describe("Archive read API", () => {
comparison: archiveComparison,
});

const history = await app.request(
`/v1/archive/history?modelId=${MODEL_ID}&testCaseId=${TEST_CASE_ID}&limit=20`,
);
expect(history.status).toBe(200);
await expect(history.json()).resolves.toEqual({
history: archiveRunHistory,
});

const runs = await app.request(
`/v1/archive/runs?modelId=${MODEL_ID}&testCaseId=${TEST_CASE_ID}&limit=10`,
);
Expand All @@ -376,6 +442,7 @@ describe("Archive read API", () => {
getModel: async () => null,
getTest: async () => null,
compareLatest: async () => null,
getRunHistory: async () => null,
},
});

Expand All @@ -398,6 +465,20 @@ describe("Archive read API", () => {
)
).status,
).toBe(400);
expect(
(
await app.request(
`/v1/archive/history?modelId=nope&testCaseId=${TEST_CASE_ID}`,
)
).status,
).toBe(400);
expect(
(
await app.request(
`/v1/archive/history?modelId=${MODEL_ID}&testCaseId=${TEST_CASE_ID}&limit=101`,
)
).status,
).toBe(400);

});
});
134 changes: 134 additions & 0 deletions apps/web/src/modelapse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,20 @@ export interface ArchiveRunEvidence {
} | null;
}

export interface ArchiveRunRelationEdge {
readonly fromRunId: string;
readonly toRunId: string;
readonly relationType: string;
readonly createdAt: string;
}

export interface ArchiveRunRelation {
readonly direction: "outgoing" | "incoming";
readonly relationType: string;
readonly relatedRunId: string;
readonly createdAt: string;
}

export interface ArchiveRunDetail extends ArchiveRun {
readonly configJson: string | null;
readonly requestBlob: ArchiveBlob | null;
Expand All @@ -259,6 +273,7 @@ export interface ArchiveRunDetail extends ArchiveRun {
readonly usageJson: string | null;
readonly timingJson: string | null;
readonly evidence: readonly ArchiveRunEvidence[];
readonly relations: readonly ArchiveRunRelation[];
}

interface ArchiveRunDetailWire extends ArchiveRun {
Expand All @@ -269,6 +284,23 @@ interface ArchiveRunDetailWire extends ArchiveRun {
readonly usage: unknown;
readonly timing: unknown;
readonly evidence: readonly ArchiveRunEvidence[];
readonly relations: readonly ArchiveRunRelation[];
}

export interface ArchiveRunHistory {
readonly model: ArchiveModel;
readonly test: ArchiveTest;
readonly runs: readonly ArchiveRun[];
readonly relations: readonly ArchiveRunRelationEdge[];
}

export interface ArchiveTemporalComparison {
readonly test: ArchiveTest;
readonly rows: readonly {
readonly model: ArchiveModel;
readonly runs: readonly ArchiveRun[];
readonly relations: readonly ArchiveRunRelationEdge[];
}[];
}

export interface ControlJob {
Expand Down Expand Up @@ -326,6 +358,13 @@ interface CompareArchiveInput {
readonly testCaseId: string;
}

interface ReadArchiveHistoryInput {
readonly modelId: string;
readonly testCaseId: string;
readonly limit?: number;
}


interface ApiOptions {
readonly method?: "GET" | "POST";
readonly control?: boolean;
Expand Down Expand Up @@ -536,6 +575,36 @@ function parseArchiveTestInput(value: unknown): ReadArchiveTestInput {
return { testCaseId };
}

function parseArchiveHistoryInput(value: unknown): ReadArchiveHistoryInput {
if (!isRecord(value)) throw new Error("Archive history request must be an object");

const modelId = value.modelId;
const testCaseId = value.testCaseId;
const limit = value.limit;

if (typeof modelId !== "string" || !UUID_RE.test(modelId)) {
throw new Error("modelId must be a UUID");
}
if (typeof testCaseId !== "string" || !UUID_RE.test(testCaseId)) {
throw new Error("testCaseId must be a UUID");
}
if (
limit !== undefined &&
(typeof limit !== "number" ||
!Number.isInteger(limit) ||
limit < 1 ||
limit > 100)
) {
throw new Error("limit must be an integer between 1 and 100");
}

return {
modelId,
testCaseId,
...(limit === undefined ? {} : { limit }),
};
}

function parseArchiveComparisonInput(value: unknown): CompareArchiveInput {
if (!isRecord(value)) throw new Error("Archive comparison request must be an object");

Expand Down Expand Up @@ -669,6 +738,71 @@ export const getArchiveTest = createServerFn({ method: "POST" })
}
});

export const getArchiveRunHistory = createServerFn({ method: "POST" })
.validator(parseArchiveHistoryInput)
.handler(async ({ data }): Promise<ArchiveRunHistory | null> => {
const params = new URLSearchParams({
modelId: data.modelId,
testCaseId: data.testCaseId,
limit: String(data.limit ?? 50),
});
try {
const result = await requestJson<{ history: ArchiveRunHistory }>(
`/v1/archive/history?${params.toString()}`,
);
return result.history;
} catch (error) {
if (error instanceof ApiRequestError && error.status === 404) {
return null;
}
throw error;
}
});

export const compareArchiveHistory = createServerFn({ method: "POST" })
.validator(parseArchiveComparisonInput)
.handler(async ({ data }): Promise<ArchiveTemporalComparison | null> => {
const histories = await Promise.all(
data.modelIds.map(async (modelId) => {
const params = new URLSearchParams({
modelId,
testCaseId: data.testCaseId,
limit: "20",
});
try {
const result = await requestJson<{ history: ArchiveRunHistory }>(
`/v1/archive/history?${params.toString()}`,
);
return result.history;
} catch (error) {
if (error instanceof ApiRequestError && error.status === 404) {
return null;
}
throw error;
}
}),
);

if (histories.some((history) => history === null)) {
return null;
}

const complete = histories.filter(
(history): history is ArchiveRunHistory => history !== null,
);
const first = complete[0];
if (!first) return null;

return {
test: first.test,
rows: complete.map((history) => ({
model: history.model,
runs: history.runs,
relations: history.relations,
})),
};
});

export const compareArchive = createServerFn({ method: "POST" })
.validator(parseArchiveComparisonInput)
.handler(async ({ data }): Promise<ArchiveComparison | null> => {
Expand Down
Loading
Loading