diff --git a/apps/web/app/activity/page.tsx b/apps/web/app/activity/page.tsx
index c38bc88..2e8f701 100644
--- a/apps/web/app/activity/page.tsx
+++ b/apps/web/app/activity/page.tsx
@@ -11,6 +11,7 @@ import {
buildActivityFeed,
resolveActivitySortOrder,
} from "../../lib/activityFeed";
+import { attachActivityCompareLinks } from "../../lib/activityCompare";
import { collectArtifactFacets } from "../../lib/artifactFacets";
import { loadBenchmarkArtifacts, loadRunArtifacts } from "../../lib/data";
import { buildQueryString, paginateItems } from "../../lib/listPagination";
@@ -51,16 +52,20 @@ export default async function ActivityPage({
loadBenchmarkArtifacts(),
]);
const { models, presets } = collectArtifactFacets(runs, benchmarks);
- const feed = buildActivityFeed(runs, benchmarks, {
- kind,
- q: params.q,
- model: params.model,
- preset: params.preset,
- fast: params.fast,
- from: params.from,
- to: params.to,
- sort,
- });
+ const feed = attachActivityCompareLinks(
+ buildActivityFeed(runs, benchmarks, {
+ kind,
+ q: params.q,
+ model: params.model,
+ preset: params.preset,
+ fast: params.fast,
+ from: params.from,
+ to: params.to,
+ sort,
+ }),
+ runs,
+ benchmarks,
+ );
const paging = paginateItems(feed, params, {
defaultPageSize: 25,
maxPageSize: 100,
@@ -280,6 +285,21 @@ export default async function ActivityPage({
),
},
+ {
+ key: "compare",
+ label: "Compare",
+ hideOnMobile: true,
+ render: (row) => {
+ const href = (
+ row as { compareHref?: string }
+ ).compareHref;
+ return href ? (
+ Compare
+ ) : (
+ —
+ );
+ },
+ },
]}
data={paging.paged.map((entry) => ({
...entry,
@@ -289,15 +309,29 @@ export default async function ActivityPage({
`${(row as { kind: string }).kind}-${(row as { id: string }).id}`
}
renderCardActions={(row) => (
-
- Open{" "}
- {(row as { kind: string }).kind === "run"
- ? "trace"
- : "benchmark"}
-
+ <>
+
+ Open{" "}
+ {(row as { kind: string }).kind === "run"
+ ? "trace"
+ : "benchmark"}
+
+ {(row as { compareHref?: string })
+ .compareHref ? (
+
+ Compare
+
+ ) : null}
+ >
)}
/>
)}
diff --git a/apps/web/app/api/activity/route.ts b/apps/web/app/api/activity/route.ts
index 29e814a..61a605e 100644
--- a/apps/web/app/api/activity/route.ts
+++ b/apps/web/app/api/activity/route.ts
@@ -4,6 +4,7 @@ import {
type ActivityFeedFilters,
} from "../../../lib/activityFeed";
import { activityEntriesToCsv } from "../../../lib/activityExport";
+import { attachActivityCompareLinks } from "../../../lib/activityCompare";
import { loadBenchmarkArtifacts, loadRunArtifacts } from "../../../lib/data";
import { parseListPagination } from "../_shared/pagination";
@@ -33,7 +34,11 @@ export async function GET(request: Request) {
),
};
- const feed = buildActivityFeed(runs, benchmarks, filters);
+ const feed = attachActivityCompareLinks(
+ buildActivityFeed(runs, benchmarks, filters),
+ runs,
+ benchmarks,
+ );
const { offset, limit } = parseListPagination(url.searchParams);
const items = feed.slice(offset, offset + limit);
diff --git a/apps/web/app/api/benchmarks/[id]/route.ts b/apps/web/app/api/benchmarks/[id]/route.ts
index 9b59fd4..1495aee 100644
--- a/apps/web/app/api/benchmarks/[id]/route.ts
+++ b/apps/web/app/api/benchmarks/[id]/route.ts
@@ -1,15 +1,74 @@
-import { loadBenchmarkById } from "../../../../lib/data";
+import {
+ loadAnalysisIndex,
+ loadBenchmarkById,
+ loadBenchmarkPairsById,
+} from "../../../../lib/data";
+import {
+ buildBenchmarkRunRoster,
+ sortBenchmarkRunRoster,
+} from "../../../../lib/benchmarkRunRoster";
+import { findMostSimilarPeerRunId } from "../../../../lib/benchmarkPeers";
+import { benchmarkRunRosterToCsv } from "../../../../lib/listExport";
+import { buildBenchmarkOutlierLookup } from "../../../../lib/outlierLookup";
export async function GET(
request: Request,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params;
+ const url = new URL(request.url);
+ const format = url.searchParams.get("format") ?? "json";
const benchmark = await loadBenchmarkById(id);
if (!benchmark) {
return Response.json({ error: "benchmark not found" }, { status: 404 });
}
- const download = new URL(request.url).searchParams.get("download") === "1";
+
+ if (format === "roster-csv") {
+ const [pairsData, index] = await Promise.all([
+ loadBenchmarkPairsById(id),
+ loadAnalysisIndex(),
+ ]);
+ const pairs =
+ pairsData.pairs.length > 0
+ ? pairsData.pairs
+ : (benchmark.payload.summary?.stability?.pairs ?? []);
+ const rosterRunIds =
+ pairsData.runIds.length > 0
+ ? pairsData.runIds
+ : (benchmark.payload.runIds ?? []);
+ const outlierLookup = buildBenchmarkOutlierLookup(index, benchmark.id);
+ const roster = sortBenchmarkRunRoster(
+ buildBenchmarkRunRoster({
+ runIds: rosterRunIds,
+ pairs,
+ modes: benchmark.payload.modes,
+ }),
+ ).map((row) => {
+ const peerRunId = findMostSimilarPeerRunId(
+ row.runId,
+ rosterRunIds,
+ pairs,
+ );
+ return {
+ ...row,
+ peerRunId,
+ peerCompareHref: peerRunId
+ ? `/runs/compare?left=${row.runId}&right=${peerRunId}`
+ : null,
+ isOutlier: outlierLookup.has(row.runId),
+ };
+ });
+ const csv = benchmarkRunRosterToCsv(roster);
+ return new Response(csv, {
+ headers: {
+ "Content-Type": "text/csv; charset=utf-8",
+ "Content-Disposition": `attachment; filename="${id}-roster.csv"`,
+ "Cache-Control": "public, max-age=60",
+ },
+ });
+ }
+
+ const download = url.searchParams.get("download") === "1";
const headers = download
? {
"Content-Disposition": `attachment; filename="${id}.json"`,
diff --git a/apps/web/app/api/questions/hub/route.ts b/apps/web/app/api/questions/hub/route.ts
new file mode 100644
index 0000000..b56af3d
--- /dev/null
+++ b/apps/web/app/api/questions/hub/route.ts
@@ -0,0 +1,61 @@
+import {
+ filterBenchmarkArtifacts,
+ filterRunArtifacts,
+ loadBenchmarkArtifacts,
+ loadRunArtifacts,
+} from "../../../../lib/data";
+import { questionHubArtifactsToCsv } from "../../../../lib/listExport";
+
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const question = (url.searchParams.get("question") ?? "").trim();
+ if (!question) {
+ return Response.json(
+ { error: "question query parameter is required" },
+ { status: 400 },
+ );
+ }
+
+ const format = url.searchParams.get("format") ?? "json";
+ const filters = {
+ q: url.searchParams.get("q") ?? undefined,
+ model: url.searchParams.get("model") ?? undefined,
+ preset: url.searchParams.get("preset") ?? undefined,
+ fast: url.searchParams.get("fast") ?? undefined,
+ from: url.searchParams.get("from") ?? undefined,
+ to: url.searchParams.get("to") ?? undefined,
+ };
+
+ const [allRuns, allBenchmarks] = await Promise.all([
+ loadRunArtifacts(),
+ loadBenchmarkArtifacts(),
+ ]);
+ const runs = filterRunArtifacts(allRuns, filters).filter(
+ (run) => run.question === question,
+ );
+ const benchmarks = filterBenchmarkArtifacts(allBenchmarks, filters).filter(
+ (benchmark) => benchmark.question === question,
+ );
+
+ if (format === "csv") {
+ const csv = questionHubArtifactsToCsv(question, runs, benchmarks);
+ const slug = question.slice(0, 48).replace(/[^\w.-]+/g, "_");
+ return new Response(csv, {
+ headers: {
+ "Content-Type": "text/csv; charset=utf-8",
+ "Content-Disposition": `attachment; filename="question-hub-${slug || "export"}.csv"`,
+ "Cache-Control": "public, max-age=60",
+ },
+ });
+ }
+
+ return Response.json({
+ question,
+ totals: {
+ runs: runs.length,
+ benchmarks: benchmarks.length,
+ },
+ runs,
+ benchmarks,
+ });
+}
diff --git a/apps/web/app/api/routes.test.ts b/apps/web/app/api/routes.test.ts
index 5287980..5e959c7 100644
--- a/apps/web/app/api/routes.test.ts
+++ b/apps/web/app/api/routes.test.ts
@@ -32,6 +32,7 @@ import { GET as getOutliers } from "./outliers/route";
import { GET as getCatalog } from "./catalog/route";
import { POST as postAnalysisRebuild } from "./analysis/rebuild/route";
import { GET as getQuestions } from "./questions/route";
+import { GET as getQuestionsHub } from "./questions/hub/route";
import { GET as getPairs } from "./pairs/route";
import { GET as getErrors } from "./errors/route";
@@ -2314,6 +2315,165 @@ describe("web api routes", () => {
expect(await csvResponse.text()).toContain("Shared topic");
});
+ it("exports question hub artifacts as CSV", async () => {
+ const dir = await makeTempDir();
+ process.env.RUNS_DIR = dir;
+ await writeFile(
+ join(dir, "run_a.json"),
+ JSON.stringify({
+ kind: "run",
+ id: "run_a",
+ question: "Hub topic",
+ metadata: {
+ createdAt: "2026-01-02T00:00:00.000Z",
+ model: "gpt-a",
+ pipelinePreset: "standard",
+ fastMode: false,
+ },
+ run: { id: "run_a", finalAnswer: "A", steps: [], metrics: {} },
+ }),
+ "utf-8",
+ );
+ await writeFile(
+ join(dir, "benchmark_a.json"),
+ JSON.stringify({
+ kind: "benchmark",
+ id: "benchmark_a",
+ question: "Hub topic",
+ metadata: {
+ createdAt: "2026-01-01T00:00:00.000Z",
+ model: "gpt-a",
+ pipelinePreset: "standard",
+ fastMode: false,
+ },
+ payload: {
+ runs: 2,
+ modeCount: 1,
+ modeSizes: [2],
+ divergenceEntropy: 0.2,
+ summary: { stability: { pairwiseMean: 0.9, pairs: [] } },
+ },
+ }),
+ "utf-8",
+ );
+
+ const response = await getQuestionsHub(
+ new Request(
+ "http://localhost/api/questions/hub?question=Hub%20topic&format=csv",
+ ),
+ );
+ expect(response.status).toBe(200);
+ const csv = await response.text();
+ expect(csv).toContain("Hub topic");
+ expect(csv).toContain("run_a");
+ expect(csv).toContain("benchmark_a");
+ });
+
+ it("filters runs API by pipeline errors", async () => {
+ const dir = await makeTempDir();
+ process.env.RUNS_DIR = dir;
+ await writeFile(
+ join(dir, "run_ok.json"),
+ JSON.stringify({
+ kind: "run",
+ id: "run_ok",
+ question: "Alpha",
+ metadata: {
+ createdAt: "2026-01-01T00:00:00.000Z",
+ model: "gpt-a",
+ pipelinePreset: "standard",
+ fastMode: false,
+ },
+ run: { id: "run_ok", finalAnswer: "A", steps: [], metrics: {} },
+ }),
+ "utf-8",
+ );
+ await writeFile(
+ join(dir, "run_fail.json"),
+ JSON.stringify({
+ kind: "run",
+ id: "run_fail",
+ question: "Beta",
+ metadata: {
+ createdAt: "2026-01-02T00:00:00.000Z",
+ model: "gpt-a",
+ pipelinePreset: "standard",
+ fastMode: false,
+ },
+ run: {
+ id: "run_fail",
+ finalAnswer: "B",
+ steps: [
+ {
+ agentName: "Solver",
+ role: "solver",
+ createdAt: "2026-01-02T00:00:00.000Z",
+ error: "timeout",
+ },
+ ],
+ metrics: {},
+ },
+ }),
+ "utf-8",
+ );
+
+ const response = await getRuns(
+ new Request("http://localhost/api/runs?errors=true"),
+ );
+ expect(response.status).toBe(200);
+ const json = (await response.json()) as {
+ filtered: number;
+ items: Array<{ id: string }>;
+ };
+ expect(json.filtered).toBe(1);
+ expect(json.items[0].id).toBe("run_fail");
+ });
+
+ it("exports benchmark roster CSV", async () => {
+ const dir = await makeTempDir();
+ process.env.RUNS_DIR = dir;
+ await writeFile(
+ join(dir, "benchmark_roster.json"),
+ JSON.stringify({
+ kind: "benchmark",
+ id: "benchmark_roster",
+ question: "Roster topic",
+ metadata: {
+ createdAt: "2026-01-01T00:00:00.000Z",
+ model: "gpt-a",
+ pipelinePreset: "standard",
+ fastMode: false,
+ },
+ payload: {
+ runs: 2,
+ modeCount: 1,
+ modeSizes: [2],
+ divergenceEntropy: 0.2,
+ runIds: ["run_a", "run_b"],
+ modes: [{ members: [0, 1] }],
+ summary: {
+ stability: {
+ pairwiseMean: 0.5,
+ pairs: [{ i: 0, j: 1, similarity: 0.5 }],
+ },
+ },
+ },
+ }),
+ "utf-8",
+ );
+
+ const response = await getBenchmarkById(
+ new Request(
+ "http://localhost/api/benchmarks/benchmark_roster?format=roster-csv",
+ ),
+ { params: Promise.resolve({ id: "benchmark_roster" }) },
+ );
+ expect(response.status).toBe(200);
+ const csv = await response.text();
+ expect(csv).toContain("run_a");
+ expect(csv).toContain("avgSimilarity");
+ });
+
it("returns filtered activity feed as JSON and CSV", async () => {
const dir = await makeTempDir();
process.env.RUNS_DIR = dir;
diff --git a/apps/web/app/api/runs/route.ts b/apps/web/app/api/runs/route.ts
index c5bc604..b01bf95 100644
--- a/apps/web/app/api/runs/route.ts
+++ b/apps/web/app/api/runs/route.ts
@@ -1,23 +1,43 @@
-import { filterRunArtifacts, loadRunArtifacts } from "../../../lib/data";
+import { filterRunArtifacts, loadAnalysisIndex, loadRunArtifacts } from "../../../lib/data";
import {
resolveRunSortOrder,
sortRunArtifacts,
} from "../../../lib/artifactSort";
import { runArtifactsToCsv } from "../../../lib/listExport";
+import { buildOutlierRunIdSet } from "../../../lib/outlierLookup";
+import {
+ applyRunListExtraFilters,
+ buildPipelineErrorRunIdSet,
+ parseRunListExtraFilters,
+} from "../../../lib/runListFilters";
import { parseListPagination } from "../_shared/pagination";
export async function GET(request: Request) {
const url = new URL(request.url);
const format = url.searchParams.get("format") ?? "json";
- const runs = await loadRunArtifacts();
- const filtered = filterRunArtifacts(runs, {
- q: url.searchParams.get("q") ?? undefined,
- model: url.searchParams.get("model") ?? undefined,
- preset: url.searchParams.get("preset") ?? undefined,
- fast: url.searchParams.get("fast") ?? undefined,
- from: url.searchParams.get("from") ?? undefined,
- to: url.searchParams.get("to") ?? undefined,
+ const [runs, index] = await Promise.all([
+ loadRunArtifacts(),
+ loadAnalysisIndex(),
+ ]);
+ const extraFilters = parseRunListExtraFilters({
+ outlier: url.searchParams.get("outlier") ?? undefined,
+ errors: url.searchParams.get("errors") ?? undefined,
});
+ const filtered = applyRunListExtraFilters(
+ filterRunArtifacts(runs, {
+ q: url.searchParams.get("q") ?? undefined,
+ model: url.searchParams.get("model") ?? undefined,
+ preset: url.searchParams.get("preset") ?? undefined,
+ fast: url.searchParams.get("fast") ?? undefined,
+ from: url.searchParams.get("from") ?? undefined,
+ to: url.searchParams.get("to") ?? undefined,
+ }),
+ extraFilters,
+ {
+ outlierRunIds: buildOutlierRunIdSet(index),
+ errorRunIds: buildPipelineErrorRunIdSet(runs),
+ },
+ );
const sort = resolveRunSortOrder(url.searchParams.get("sort") ?? undefined);
const { offset, limit, page } = parseListPagination(url.searchParams);
const sorted = sortRunArtifacts(filtered, sort);
diff --git a/apps/web/app/benchmarks/[id]/page.tsx b/apps/web/app/benchmarks/[id]/page.tsx
index 93b9c36..dbbbb1e 100644
--- a/apps/web/app/benchmarks/[id]/page.tsx
+++ b/apps/web/app/benchmarks/[id]/page.tsx
@@ -143,6 +143,13 @@ export default async function BenchmarkDetailPage({
href={`/api/benchmarks/${benchmark.id}?download=1`}
filename={`${benchmark.id}.json`}
/>
+
+ Export roster CSV
+