diff --git a/src/__tests__/unit/app/api/recursion-summary-route.test.ts b/src/__tests__/unit/app/api/recursion-summary-route.test.ts
new file mode 100644
index 0000000000..c77c7131f1
--- /dev/null
+++ b/src/__tests__/unit/app/api/recursion-summary-route.test.ts
@@ -0,0 +1,414 @@
+/**
+ * Unit tests for GET /api/workspaces/[slug]/legal/benchmarks/recursion/summary
+ *
+ * Coverage:
+ * - Auth guard (401 without auth)
+ * - Openlaw gate (403 for non-openlaw slug)
+ * - Rate limit fires before getWorkspaceSwarmAccess (verify call order)
+ * - Rate limit key includes userId
+ * - 503 + Retry-After: 60 on Redis error (fail-closed — not 200)
+ * - workspaceId forwarded to listRecursionEvalSets
+ * - USE_MOCKS fixture response
+ * - enrollmentPartial and summaryPartial independently propagated
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest } from "next/server";
+
+// ── Hoisted mocks ──────────────────────────────────────────────────────────
+
+const mockRequireAuth = vi.hoisted(() => vi.fn());
+const mockGetMiddlewareContext = vi.hoisted(() => vi.fn());
+const mockGetWorkspaceSwarmAccess = vi.hoisted(() => vi.fn());
+const mockCheckRateLimit = vi.hoisted(() => vi.fn());
+const mockGetClientIp = vi.hoisted(() => vi.fn());
+const mockGetJarvisUrl = vi.hoisted(() => vi.fn());
+const mockListRecursionEvalSets = vi.hoisted(() => vi.fn());
+const mockFetchRecursionTaskSummary = vi.hoisted(() => vi.fn());
+
+vi.mock("@/lib/middleware/utils", () => ({
+ getMiddlewareContext: mockGetMiddlewareContext,
+ requireAuth: mockRequireAuth,
+}));
+
+vi.mock("@/lib/helpers/swarm-access", () => ({
+ getWorkspaceSwarmAccess: mockGetWorkspaceSwarmAccess,
+}));
+
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: mockCheckRateLimit,
+ getClientIp: mockGetClientIp,
+}));
+
+vi.mock("@/lib/utils/swarm", () => ({
+ getJarvisUrl: mockGetJarvisUrl,
+}));
+
+vi.mock("@/services/legal-benchmark-recursion", () => ({
+ listRecursionEvalSets: mockListRecursionEvalSets,
+}));
+
+vi.mock("@/services/legal-benchmark-recursion-summary", () => ({
+ fetchRecursionTaskSummary: mockFetchRecursionTaskSummary,
+}));
+
+vi.mock("@/lib/logger", () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+
+// ── Import after mocks ────────────────────────────────────────────────────
+
+import { GET } from "@/app/api/workspaces/[slug]/legal/benchmarks/recursion/summary/route";
+import { NextResponse } from "next/server";
+
+// ── Helpers ───────────────────────────────────────────────────────────────
+
+function makeRequest(url = "https://hive.example.com/api/workspaces/openlaw/legal/benchmarks/recursion/summary") {
+ return new NextRequest(url);
+}
+
+function makeParams(slug: string) {
+ return { params: Promise.resolve({ slug }) };
+}
+
+const VALID_USER = { id: "user-123" };
+const SWARM_DATA = {
+ swarmName: "my-swarm",
+ swarmApiKey: "swarm-api-key-secret",
+ workspaceId: "workspace-abc",
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.unstubAllEnvs();
+ // Ensure USE_MOCKS is off for all tests unless explicitly overridden.
+ vi.stubEnv("USE_MOCKS", "false");
+ vi.stubEnv("NODE_ENV", "test");
+
+ // Default happy-path mocks
+ mockGetMiddlewareContext.mockReturnValue({});
+ mockRequireAuth.mockReturnValue(VALID_USER);
+ mockGetClientIp.mockReturnValue("1.2.3.4");
+ mockCheckRateLimit.mockResolvedValue({ allowed: true });
+ mockGetWorkspaceSwarmAccess.mockResolvedValue({
+ success: true,
+ data: SWARM_DATA,
+ });
+ mockGetJarvisUrl.mockReturnValue("https://jarvis.example.com");
+ mockListRecursionEvalSets.mockResolvedValue({
+ ok: true,
+ nodes: [],
+ partial: false,
+ });
+ mockFetchRecursionTaskSummary.mockResolvedValue([]);
+});
+
+describe("GET /api/workspaces/[slug]/legal/benchmarks/recursion/summary", () => {
+ // ── Auth guard ──────────────────────────────────────────────────────────
+
+ it("returns 401 when requireAuth fails (no session)", async () => {
+ mockRequireAuth.mockReturnValue(
+ NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
+ );
+
+ const res = await GET(makeRequest(), makeParams("openlaw"));
+
+ expect(res.status).toBe(401);
+ });
+
+ // ── Openlaw gate ────────────────────────────────────────────────────────
+
+ it("returns 403 for non-openlaw slug", async () => {
+ const res = await GET(makeRequest(), makeParams("other-workspace"));
+
+ expect(res.status).toBe(403);
+ // getWorkspaceSwarmAccess must NOT have been called — IDOR gate fires before
+ expect(mockGetWorkspaceSwarmAccess).not.toHaveBeenCalled();
+ });
+
+ // ── Rate limit: fires BEFORE getWorkspaceSwarmAccess ───────────────────
+
+ it("calls checkRateLimit before getWorkspaceSwarmAccess", async () => {
+ const callOrder: string[] = [];
+ mockCheckRateLimit.mockImplementation(async () => {
+ callOrder.push("rateLimit");
+ return { allowed: true };
+ });
+ mockGetWorkspaceSwarmAccess.mockImplementation(async () => {
+ callOrder.push("swarmAccess");
+ return { success: true, data: SWARM_DATA };
+ });
+
+ await GET(makeRequest(), makeParams("openlaw"));
+
+ const rlIdx = callOrder.indexOf("rateLimit");
+ const swarmIdx = callOrder.indexOf("swarmAccess");
+ expect(rlIdx).toBeGreaterThanOrEqual(0);
+ expect(swarmIdx).toBeGreaterThan(rlIdx);
+ });
+
+ it("rate limit key includes userId", async () => {
+ await GET(makeRequest(), makeParams("openlaw"));
+
+ expect(mockCheckRateLimit).toHaveBeenCalledWith(
+ expect.stringContaining(VALID_USER.id),
+ expect.any(Number),
+ expect.any(Number),
+ );
+ });
+
+ it("returns 429 when rate limit is exceeded", async () => {
+ mockCheckRateLimit.mockResolvedValue({ allowed: false, retryAfter: 45 });
+
+ const res = await GET(makeRequest(), makeParams("openlaw"));
+
+ expect(res.status).toBe(429);
+ });
+
+ // ── Fail-closed on Redis error ──────────────────────────────────────────
+
+ it("returns 503 + Retry-After: 60 when rate limit throws (Redis error)", async () => {
+ mockCheckRateLimit.mockRejectedValue(new Error("Redis connection refused"));
+
+ const res = await GET(makeRequest(), makeParams("openlaw"));
+
+ expect(res.status).toBe(503);
+ expect(res.headers.get("Retry-After")).toBe("60");
+ // getWorkspaceSwarmAccess must NOT have been called (fail-closed)
+ expect(mockGetWorkspaceSwarmAccess).not.toHaveBeenCalled();
+ });
+
+ it("does NOT return 200 when Redis errors (fail-closed, not fail-open)", async () => {
+ mockCheckRateLimit.mockRejectedValue(new Error("Redis timeout"));
+
+ const res = await GET(makeRequest(), makeParams("openlaw"));
+
+ expect(res.status).not.toBe(200);
+ expect(res.status).toBe(503);
+ });
+
+ // ── workspaceId forwarded ───────────────────────────────────────────────
+
+ it("forwards workspaceId from swarm access to listRecursionEvalSets", async () => {
+ mockGetWorkspaceSwarmAccess.mockResolvedValue({
+ success: true,
+ data: { ...SWARM_DATA, workspaceId: "specific-workspace-id" },
+ });
+
+ await GET(makeRequest(), makeParams("openlaw"));
+
+ expect(mockListRecursionEvalSets).toHaveBeenCalledWith(
+ expect.anything(),
+ "specific-workspace-id",
+ );
+ });
+
+ // ── Partial flags ───────────────────────────────────────────────────────
+
+ it("includes enrollmentPartial: true when listRecursionEvalSets returns partial", async () => {
+ mockListRecursionEvalSets.mockResolvedValue({
+ ok: true,
+ nodes: [],
+ partial: true,
+ });
+
+ const res = await GET(makeRequest(), makeParams("openlaw"));
+ const body = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(body.enrollmentPartial).toBe(true);
+ expect(body.summaryPartial).toBeUndefined(); // no tasks → no summary failures
+ });
+
+ it("includes summaryPartial: true when some tasks return isDefault: true", async () => {
+ const summaryData = [
+ {
+ taskSlug: "task-ok",
+ refId: "ref-ok",
+ name: "OK Task",
+ reason: "active",
+ recursion: true,
+ rubricCount: 5,
+ contestedCount: 0,
+ latestRun: null,
+ fixChainDepth: 0,
+ isDefault: false,
+ },
+ {
+ taskSlug: "task-fail",
+ refId: "ref-fail",
+ name: "Failed Task",
+ reason: "active",
+ recursion: true,
+ rubricCount: 0,
+ contestedCount: 0,
+ latestRun: null,
+ fixChainDepth: 0,
+ isDefault: true, // degraded
+ },
+ ];
+ mockListRecursionEvalSets.mockResolvedValue({
+ ok: true,
+ nodes: [{ ref_id: "ref-ok", id: "task-ok", name: "OK Task" }, { ref_id: "ref-fail", id: "task-fail", name: "Failed Task" }],
+ });
+ mockFetchRecursionTaskSummary.mockResolvedValue(summaryData);
+
+ const res = await GET(makeRequest(), makeParams("openlaw"));
+ const body = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(body.summaryPartial).toBe(true);
+ expect(body.enrollmentPartial).toBeUndefined();
+ });
+
+ it("enrollmentPartial and summaryPartial can both be present independently", async () => {
+ mockListRecursionEvalSets.mockResolvedValue({
+ ok: true,
+ nodes: [{ ref_id: "ref-1", id: "task-1", name: "Task 1" }],
+ partial: true, // enrollment partial
+ });
+ mockFetchRecursionTaskSummary.mockResolvedValue([
+ {
+ taskSlug: "task-1",
+ refId: "ref-1",
+ name: "Task 1",
+ reason: "active",
+ recursion: true,
+ rubricCount: 0,
+ contestedCount: 0,
+ latestRun: null,
+ fixChainDepth: 0,
+ isDefault: true, // summary partial
+ },
+ ]);
+
+ const res = await GET(makeRequest(), makeParams("openlaw"));
+ const body = await res.json();
+
+ expect(body.enrollmentPartial).toBe(true);
+ expect(body.summaryPartial).toBe(true);
+ });
+
+ it("omits enrollmentPartial when enrollment is complete", async () => {
+ mockListRecursionEvalSets.mockResolvedValue({
+ ok: true,
+ nodes: [],
+ partial: false,
+ });
+
+ const res = await GET(makeRequest(), makeParams("openlaw"));
+ const body = await res.json();
+
+ expect(body.enrollmentPartial).toBeUndefined();
+ });
+
+ it("omits summaryPartial when all tasks succeeded", async () => {
+ mockListRecursionEvalSets.mockResolvedValue({
+ ok: true,
+ nodes: [{ ref_id: "ref-1", id: "task-1", name: "Task 1" }],
+ });
+ mockFetchRecursionTaskSummary.mockResolvedValue([
+ {
+ taskSlug: "task-1",
+ refId: "ref-1",
+ name: "Task 1",
+ reason: "active",
+ recursion: true,
+ rubricCount: 5,
+ contestedCount: 0,
+ latestRun: null,
+ fixChainDepth: 2,
+ isDefault: false, // success
+ },
+ ]);
+
+ const res = await GET(makeRequest(), makeParams("openlaw"));
+ const body = await res.json();
+
+ expect(body.summaryPartial).toBeUndefined();
+ });
+
+ // ── USE_MOCKS fixture ────────────────────────────────────────────────────
+
+ it("returns mock fixture when USE_MOCKS=true in non-production", async () => {
+ vi.stubEnv("USE_MOCKS", "true");
+ vi.stubEnv("NODE_ENV", "test");
+
+ const res = await GET(makeRequest(), makeParams("openlaw"));
+ const body = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(body.success).toBe(true);
+ expect(Array.isArray(body.data)).toBe(true);
+ expect(body.data.length).toBeGreaterThan(0);
+ // fetchRecursionTaskSummary must NOT be called for mock mode
+ expect(mockFetchRecursionTaskSummary).not.toHaveBeenCalled();
+ });
+
+ it("does NOT serve mock fixture in production even when USE_MOCKS=true", async () => {
+ vi.stubEnv("USE_MOCKS", "true");
+ vi.stubEnv("NODE_ENV", "production");
+
+ await GET(makeRequest(), makeParams("openlaw"));
+
+ // Should call real implementation
+ expect(mockListRecursionEvalSets).toHaveBeenCalled();
+ });
+
+ // ── Happy path ───────────────────────────────────────────────────────────
+
+ it("returns 200 with data on happy path", async () => {
+ mockListRecursionEvalSets.mockResolvedValue({
+ ok: true,
+ nodes: [{ ref_id: "ref-1", id: "task-1", name: "Task 1", reason: "active", recursion: true }],
+ });
+ mockFetchRecursionTaskSummary.mockResolvedValue([
+ {
+ taskSlug: "task-1",
+ refId: "ref-1",
+ name: "Task 1",
+ reason: "active",
+ recursion: true,
+ rubricCount: 10,
+ contestedCount: 1,
+ latestRun: { n_passed: 7, n_total: 9, runAt: "1700000000" },
+ fixChainDepth: 3,
+ isDefault: false,
+ },
+ ]);
+
+ const res = await GET(makeRequest(), makeParams("openlaw"));
+ const body = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(body.success).toBe(true);
+ expect(body.data).toHaveLength(1);
+ expect(body.data[0].taskSlug).toBe("task-1");
+ expect(body.data[0].rubricCount).toBe(10);
+ });
+
+ // ── listRecursionEvalSets failure ────────────────────────────────────────
+
+ it("returns 502 when listRecursionEvalSets fails", async () => {
+ mockListRecursionEvalSets.mockResolvedValue({
+ ok: false,
+ error: "Graph query failed",
+ });
+
+ const res = await GET(makeRequest(), makeParams("openlaw"));
+
+ expect(res.status).toBe(502);
+ });
+
+ // ── Swarm access error ───────────────────────────────────────────────────
+
+ it("returns 404 when workspace not found", async () => {
+ mockGetWorkspaceSwarmAccess.mockResolvedValue({
+ success: false,
+ error: { type: "WORKSPACE_NOT_FOUND" },
+ });
+
+ const res = await GET(makeRequest(), makeParams("openlaw"));
+
+ expect(res.status).toBe(404);
+ });
+});
diff --git a/src/__tests__/unit/components/legal/RecursionBox.test.tsx b/src/__tests__/unit/components/legal/RecursionBox.test.tsx
new file mode 100644
index 0000000000..3ed79e58dc
--- /dev/null
+++ b/src/__tests__/unit/components/legal/RecursionBox.test.tsx
@@ -0,0 +1,405 @@
+/**
+ * Unit tests for RecursionCard / RecursionBox (RecursionBox.tsx)
+ *
+ * Coverage:
+ * - useEvalRunHistory is NOT called while card is collapsed
+ * - canExpand is true when entry.fixChainDepth > 0, even before expansion
+ * - ScoreBadge renders summary score immediately on mount (no history loading)
+ * - useBenchmarkRubrics is NOT called until popover opened via onContestedClick
+ * - Popover shows skeleton when rosterRequested === true && rubrics === null
+ * - Expand toggle renders when entry.latestRun != null
+ * - After expansion, useEvalRunHistory is called with correct args
+ */
+import React from "react";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+// ── Hoisted mocks ──────────────────────────────────────────────────────────
+
+const mockUseEvalRunHistory = vi.hoisted(() => vi.fn());
+const mockUseBenchmarkRubrics = vi.hoisted(() => vi.fn());
+const mockUseWorkspace = vi.hoisted(() => vi.fn());
+const mockUseLegalBenchmarkRun = vi.hoisted(() => vi.fn());
+const mockUseLegalBenchmarkRunList = vi.hoisted(() => vi.fn());
+
+vi.mock("@/hooks/useEvalRunHistory", () => ({
+ useEvalRunHistory: mockUseEvalRunHistory,
+}));
+
+vi.mock("@/hooks/useBenchmarkRubrics", () => ({
+ useBenchmarkRubrics: mockUseBenchmarkRubrics,
+}));
+
+vi.mock("@/hooks/useWorkspace", () => ({
+ useWorkspace: mockUseWorkspace,
+}));
+
+vi.mock("@/hooks/useLegalBenchmarkRun", () => ({
+ useLegalBenchmarkRun: mockUseLegalBenchmarkRun,
+}));
+
+vi.mock("@/hooks/useLegalBenchmarkRunList", () => ({
+ useLegalBenchmarkRunList: mockUseLegalBenchmarkRunList,
+}));
+
+// Stub child components that would cause further imports
+vi.mock("@/components/legal/RecursionActivityRail", () => ({
+ RecursionActivityRail: () =>
,
+ attemptReportHref: () => null,
+}));
+
+vi.mock("@/components/legal/HillClimbChart", () => ({
+ HillClimbChart: () =>
,
+}));
+
+vi.mock("@/components/legal/RecursionGraphPanel", () => ({
+ RecursionGraphPanel: () =>
,
+}));
+
+vi.mock("@/components/run-report/NodePeek", () => ({
+ graphExplorerHref: (slug: string, refId: string) => `/w/${slug}/context/graph?cypher=${refId}`,
+}));
+
+vi.mock("@/lib/run-report/types", () => ({
+ canReadRunReport: () => true,
+}));
+
+vi.mock("@/lib/harvey-lab/rubric-scoring", () => ({
+ rosterSummary: (rubrics: unknown[] | null) => {
+ if (!rubrics || rubrics.length === 0) return null;
+ const total = rubrics.length;
+ const contested = (rubrics as Array<{ contested: boolean }>).filter((r) => r.contested).length;
+ return { total, contested, denominator: total - contested };
+ },
+}));
+
+import { RecursionList } from "@/components/legal/RecursionBox";
+import type { RecursionEntry } from "@/hooks/useLegalBenchmarkRecursionList";
+
+// ── Fixtures ───────────────────────────────────────────────────────────────
+
+function makeEntry(overrides: Partial = {}): RecursionEntry {
+ return {
+ refId: "evalset-ref-1",
+ id: "task-slug-1",
+ name: "Task 1",
+ reason: "active",
+ recursion: true,
+ rubricCount: 10,
+ contestedCount: 2,
+ latestRun: null,
+ fixChainDepth: 0,
+ ...overrides,
+ };
+}
+
+function setupHappyPathMocks() {
+ mockUseWorkspace.mockReturnValue({
+ workspace: { id: "workspace-1", slug: "openlaw" },
+ role: "ADMIN",
+ });
+
+ // Default: no history (collapsed state)
+ mockUseEvalRunHistory.mockReturnValue({
+ attempts: [],
+ attemptRows: [],
+ partial: false,
+ subgraphData: null,
+ isLoading: false,
+ error: null,
+ });
+
+ // Default: no rubrics loaded yet
+ mockUseBenchmarkRubrics.mockReturnValue({ rubrics: null });
+
+ // Default: no consolidated run
+ mockUseLegalBenchmarkRun.mockReturnValue({ run: null });
+ mockUseLegalBenchmarkRunList.mockReturnValue({ runs: [] });
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ setupHappyPathMocks();
+});
+
+function renderRecursionList(entries: RecursionEntry[]) {
+ return render(
+ {}}
+ />,
+ );
+}
+
+// ── Tests ───────────────────────────────────────────────────────────────────
+
+describe("RecursionCard — useEvalRunHistory gating", () => {
+ it("does NOT call useEvalRunHistory with real args while card is collapsed", () => {
+ const entry = makeEntry({ fixChainDepth: 3, latestRun: null });
+ renderRecursionList([entry]);
+
+ // While collapsed, useEvalRunHistory should be called with empty slug / undefined refId
+ // so the hook's existing guard makes it a no-op.
+ const calls = mockUseEvalRunHistory.mock.calls;
+ expect(calls.length).toBeGreaterThan(0);
+
+ // Every call while collapsed should have slug="" and refId=undefined
+ const collapsedCall = calls[calls.length - 1][0];
+ expect(collapsedCall.slug).toBe("");
+ expect(collapsedCall.refId).toBeUndefined();
+ });
+
+ it("calls useEvalRunHistory with real args after the user expands the card", async () => {
+ const entry = makeEntry({
+ fixChainDepth: 3,
+ latestRun: { n_passed: 7, n_total: 10, runAt: "1700000000" },
+ });
+ renderRecursionList([entry]);
+
+ // Expand the card
+ const expandBtn = screen.getByTestId("expand-toggle");
+ fireEvent.click(expandBtn);
+
+ await waitFor(() => {
+ const calls = mockUseEvalRunHistory.mock.calls;
+ const afterExpandCall = calls[calls.length - 1][0];
+ // After expansion, the real refId and slug should be passed
+ expect(afterExpandCall.refId).toBe("evalset-ref-1");
+ expect(afterExpandCall.slug).toBe("task-slug-1");
+ });
+ });
+});
+
+describe("RecursionCard — canExpand derivation", () => {
+ it("expand toggle is visible when entry.fixChainDepth > 0, even before expansion", () => {
+ const entry = makeEntry({ fixChainDepth: 3, latestRun: null });
+ renderRecursionList([entry]);
+
+ expect(screen.getByTestId("expand-toggle")).toBeTruthy();
+ });
+
+ it("expand toggle is visible when entry.latestRun is not null, even before expansion", () => {
+ const entry = makeEntry({
+ fixChainDepth: 0,
+ latestRun: { n_passed: 7, n_total: 10, runAt: "1700000000" },
+ });
+ renderRecursionList([entry]);
+
+ expect(screen.getByTestId("expand-toggle")).toBeTruthy();
+ });
+
+ it("expand toggle is NOT rendered when fixChainDepth=0 and latestRun=null", () => {
+ const entry = makeEntry({ fixChainDepth: 0, latestRun: null });
+ renderRecursionList([entry]);
+
+ expect(screen.queryByTestId("expand-toggle")).toBeNull();
+ });
+});
+
+describe("RecursionCard — ScoreBadge summary score on mount", () => {
+ it("renders summary score immediately without waiting for useEvalRunHistory", () => {
+ const entry = makeEntry({
+ latestRun: { n_passed: 7, n_total: 10, runAt: "1700000000" },
+ rubricCount: 10,
+ contestedCount: 0,
+ });
+ renderRecursionList([entry]);
+
+ // Score should be visible immediately (from summary data, no loading state)
+ const scoreEl = screen.getByTestId("score-display");
+ expect(scoreEl.textContent).toContain("7");
+ expect(scoreEl.textContent).toContain("10");
+ });
+
+ it("renders no-runs when latestRun is null and history not loaded", () => {
+ const entry = makeEntry({ latestRun: null, fixChainDepth: 0, rubricCount: 0 });
+ renderRecursionList([entry]);
+
+ expect(screen.getByTestId("score-no-runs")).toBeTruthy();
+ });
+
+ it("uses entry.rubricCount as n_total fallback when latestRun.n_total is null", () => {
+ const entry = makeEntry({
+ latestRun: { n_passed: 5, n_total: null, runAt: null },
+ rubricCount: 12,
+ contestedCount: 0,
+ });
+ renderRecursionList([entry]);
+
+ // n_total should fall back to rubricCount (12)
+ const scoreEl = screen.getByTestId("score-display");
+ expect(scoreEl.textContent).toContain("5");
+ expect(scoreEl.textContent).toContain("12");
+ });
+});
+
+describe("RecursionCard — useBenchmarkRubrics gating", () => {
+ it("does NOT call useBenchmarkRubrics with a real taskSlug on mount", () => {
+ const entry = makeEntry({ rubricCount: 10, contestedCount: 2 });
+ renderRecursionList([entry]);
+
+ const calls = mockUseBenchmarkRubrics.mock.calls;
+ // Should be called (hooks can't be conditionally called), but with undefined slug
+ // so the hook's skip guard makes it a no-op
+ for (const call of calls) {
+ expect(call[0]).toBeUndefined();
+ }
+ });
+
+ it("calls useBenchmarkRubrics with the real taskSlug after onContestedClick fires", async () => {
+ const entry = makeEntry({
+ rubricCount: 10,
+ contestedCount: 2,
+ latestRun: { n_passed: 7, n_total: 8, runAt: null },
+ });
+ renderRecursionList([entry]);
+
+ // The contested annotation button should be visible (roster from summary data)
+ const contestedBtn = screen.getByTestId("score-contested-annotation");
+ expect(contestedBtn).toBeTruthy();
+
+ // Click to open the popover and trigger rosterRequested
+ fireEvent.click(contestedBtn);
+
+ await waitFor(() => {
+ const calls = mockUseBenchmarkRubrics.mock.calls;
+ const lastCall = calls[calls.length - 1][0];
+ expect(lastCall).toBe("task-slug-1");
+ });
+ });
+
+ it("shows skeleton/spinner in popover when rosterRequested=true but rubrics=null", async () => {
+ // rubrics stays null (still loading)
+ mockUseBenchmarkRubrics.mockReturnValue({ rubrics: null });
+
+ const entry = makeEntry({
+ rubricCount: 10,
+ contestedCount: 2,
+ latestRun: { n_passed: 7, n_total: 8, runAt: null },
+ });
+ renderRecursionList([entry]);
+
+ // Open the contested popover
+ const contestedBtn = screen.getByTestId("score-contested-annotation");
+ fireEvent.click(contestedBtn);
+
+ await waitFor(() => {
+ // Should show the loading skeleton, not an empty popover
+ expect(screen.getByTestId("contested-rubric-skeleton")).toBeTruthy();
+ });
+ });
+
+ it("shows rubric list after rubrics resolve", async () => {
+ // Start with null, then resolve
+ mockUseBenchmarkRubrics
+ .mockReturnValueOnce({ rubrics: null })
+ .mockReturnValue({
+ rubrics: [
+ { ref_id: "rub-1", id: "CRIT-1", name: "First criterion", contested: true },
+ { ref_id: "rub-2", id: "CRIT-2", name: "Second criterion", contested: true },
+ ],
+ });
+
+ const entry = makeEntry({
+ rubricCount: 10,
+ contestedCount: 2,
+ latestRun: { n_passed: 7, n_total: 8, runAt: null },
+ });
+ renderRecursionList([entry]);
+
+ const contestedBtn = screen.getByTestId("score-contested-annotation");
+ fireEvent.click(contestedBtn);
+
+ await waitFor(() => {
+ // After rubrics resolve, the list should be shown (not skeleton)
+ expect(screen.getByTestId("contested-rubric-list")).toBeTruthy();
+ });
+ });
+});
+
+describe("RecursionCard — contested annotation from summary", () => {
+ it("renders +N contested badge immediately from entry.contestedCount without rubric fetch", () => {
+ const entry = makeEntry({
+ rubricCount: 10,
+ contestedCount: 3,
+ latestRun: { n_passed: 7, n_total: 7, runAt: null },
+ });
+ renderRecursionList([entry]);
+
+ // Should show "+3 contested" immediately
+ const annotationBtn = screen.getByTestId("score-contested-annotation");
+ expect(annotationBtn.textContent).toContain("3");
+ expect(annotationBtn.textContent).toContain("contested");
+
+ // useBenchmarkRubrics should NOT have been called with a real slug yet
+ for (const call of mockUseBenchmarkRubrics.mock.calls) {
+ expect(call[0]).toBeUndefined();
+ }
+ });
+
+ it("does NOT show contested annotation when contestedCount is 0", () => {
+ const entry = makeEntry({
+ rubricCount: 10,
+ contestedCount: 0,
+ latestRun: { n_passed: 10, n_total: 10, runAt: null },
+ });
+ renderRecursionList([entry]);
+
+ expect(screen.queryByTestId("score-contested-annotation")).toBeNull();
+ });
+});
+
+describe("RecursionList — loading and error states", () => {
+ it("renders loading spinner when isLoading is true", () => {
+ render(
+ {}}
+ />,
+ );
+
+ // Should show a spinner; check no card is rendered
+ expect(screen.queryByTestId("recursion-toggle")).toBeNull();
+ });
+
+ it("renders error message when error is set", () => {
+ render(
+ {}}
+ />,
+ );
+
+ expect(screen.getByText("Failed to fetch")).toBeTruthy();
+ });
+
+ it("renders empty state when entries array is empty", () => {
+ render(
+ {}}
+ />,
+ );
+
+ expect(screen.getByText("No tasks enrolled in recursion.")).toBeTruthy();
+ });
+
+ it("renders one card per entry", () => {
+ const entries = [
+ makeEntry({ refId: "ref-1", id: "task-1", name: "Task 1" }),
+ makeEntry({ refId: "ref-2", id: "task-2", name: "Task 2" }),
+ ];
+ renderRecursionList(entries);
+
+ const toggles = screen.getAllByTestId("recursion-toggle");
+ expect(toggles).toHaveLength(2);
+ });
+});
diff --git a/src/__tests__/unit/services/legal-benchmark-recursion-summary.test.ts b/src/__tests__/unit/services/legal-benchmark-recursion-summary.test.ts
new file mode 100644
index 0000000000..2d35aea666
--- /dev/null
+++ b/src/__tests__/unit/services/legal-benchmark-recursion-summary.test.ts
@@ -0,0 +1,402 @@
+/**
+ * Unit tests for legal-benchmark-recursion-summary.ts
+ *
+ * Contract under test:
+ * fetchRecursionTaskSummary(config, entries) → RecursionSummaryEntry[]
+ *
+ * Coverage:
+ * - Per-task failure isolation (one task failing must not zero adjacent tasks)
+ * - Failure path reached via explicit return-value checks (not allSettled rejections)
+ * - rubricCount/contestedCount derived correctly from rubric array
+ * - Wave 2 trigger selection uses Wave 1 results; sorted by date_added_to_graph desc; absent-field nodes sort last
+ * - isDefault: true set correctly on degraded tasks
+ * - No logger.warn/logger.error call includes api key or swarmApiKey
+ * - name/reason/recursion passed through directly from entry
+ * - fixChainDepth counts only EvalTrigger-typed neighbors
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+// ── Mock fetchEvalSetRubrics ────────────────────────────────────────────────
+const mockFetchEvalSetRubrics = vi.hoisted(() => vi.fn());
+vi.mock("@/services/legal-benchmark-rubrics", () => ({
+ fetchEvalSetRubrics: mockFetchEvalSetRubrics,
+}));
+
+// ── Mock expandEdges ────────────────────────────────────────────────────────
+const mockExpandEdges = vi.hoisted(() => vi.fn());
+vi.mock("@/lib/harvey-lab/jarvis-expand", () => ({
+ expandEdges: mockExpandEdges,
+}));
+
+// ── Mock logger ────────────────────────────────────────────────────────────
+const mockLoggerWarn = vi.hoisted(() => vi.fn());
+const mockLoggerError = vi.hoisted(() => vi.fn());
+vi.mock("@/lib/logger", () => ({
+ logger: {
+ warn: mockLoggerWarn,
+ error: mockLoggerError,
+ info: vi.fn(),
+ },
+}));
+
+import {
+ fetchRecursionTaskSummary,
+ type RecursionSummaryEntry,
+} from "@/services/legal-benchmark-recursion-summary";
+import type { RecursionEvalSetEntry } from "@/services/legal-benchmark-recursion";
+import type { JarvisConnectionConfig } from "@/types/jarvis";
+
+const CONFIG: JarvisConnectionConfig = {
+ jarvisUrl: "https://jarvis.example.com",
+ apiKey: "super-secret-api-key-xyz",
+};
+
+function makeEntry(overrides: Partial = {}): RecursionEvalSetEntry {
+ return {
+ ref_id: "evalset-ref-1",
+ id: "task-slug-1",
+ name: "Task 1",
+ reason: "active",
+ recursion: true,
+ ...overrides,
+ };
+}
+
+function makeRubric(contested = false) {
+ return {
+ ref_id: `rubric-${Math.random()}`,
+ id: `crit-${Math.random()}`,
+ name: "Some criterion",
+ contested,
+ };
+}
+
+function makeTriggerNode(dateAdded?: string): Record {
+ return {
+ ref_id: `trigger-${Math.random().toString(36).slice(2)}`,
+ node_type: "EvalTrigger",
+ ...(dateAdded !== undefined ? { date_added_to_graph: dateAdded } : {}),
+ };
+}
+
+function makeOutputNode(overrides: Record = {}): Record {
+ return {
+ ref_id: `output-${Math.random().toString(36).slice(2)}`,
+ node_type: "EvalTriggerOutput",
+ properties: { n_passed: 7, n_total: 10 },
+ date_added_to_graph: "1700000000",
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("fetchRecursionTaskSummary", () => {
+ describe("passthrough fields", () => {
+ it("passes name, reason, and recursion directly from entry without extra Jarvis calls", async () => {
+ const entry = makeEntry({
+ name: "My Task",
+ reason: "wasEnabled",
+ recursion: false,
+ });
+ mockFetchEvalSetRubrics.mockResolvedValue({ ok: true, rubrics: [] });
+ mockExpandEdges.mockResolvedValue([]); // no triggers
+
+ const [result] = await fetchRecursionTaskSummary(CONFIG, [entry]);
+
+ expect(result.name).toBe("My Task");
+ expect(result.reason).toBe("wasEnabled");
+ expect(result.recursion).toBe(false);
+ expect(result.taskSlug).toBe("task-slug-1");
+ expect(result.refId).toBe("evalset-ref-1");
+ });
+ });
+
+ describe("rubric counting", () => {
+ it("derives rubricCount and contestedCount correctly from rubric array", async () => {
+ const entry = makeEntry();
+ const rubrics = [
+ makeRubric(false),
+ makeRubric(false),
+ makeRubric(true), // contested
+ makeRubric(true), // contested
+ makeRubric(false),
+ ];
+ mockFetchEvalSetRubrics.mockResolvedValue({ ok: true, rubrics });
+ mockExpandEdges.mockResolvedValue([]); // no triggers
+
+ const [result] = await fetchRecursionTaskSummary(CONFIG, [entry]);
+
+ expect(result.rubricCount).toBe(5);
+ expect(result.contestedCount).toBe(2);
+ expect(result.isDefault).toBe(false);
+ });
+
+ it("handles empty rubric array (rubricCount=0, contestedCount=0)", async () => {
+ const entry = makeEntry();
+ mockFetchEvalSetRubrics.mockResolvedValue({ ok: true, rubrics: [] });
+ mockExpandEdges.mockResolvedValue([]);
+
+ const [result] = await fetchRecursionTaskSummary(CONFIG, [entry]);
+
+ expect(result.rubricCount).toBe(0);
+ expect(result.contestedCount).toBe(0);
+ expect(result.isDefault).toBe(false);
+ });
+ });
+
+ describe("fixChainDepth", () => {
+ it("counts only EvalTrigger-typed neighbors", async () => {
+ const entry = makeEntry();
+ mockFetchEvalSetRubrics.mockResolvedValue({ ok: true, rubrics: [] });
+ // Mix of EvalTrigger and other node types
+ mockExpandEdges.mockImplementation(async (refId: string, edgeTypes: string[]) => {
+ if (edgeTypes.includes("HAS_BASELINE_TRIGGER")) {
+ return [
+ { ref_id: "trig-1", node_type: "EvalTrigger", date_added_to_graph: "1700000001" },
+ { ref_id: "not-a-trigger", node_type: "Concept" },
+ { ref_id: "trig-2", node_type: "EvalTrigger", date_added_to_graph: "1700000002" },
+ ];
+ }
+ // Wave 2: HAS_OUTPUT expand
+ return [makeOutputNode()];
+ });
+
+ const [result] = await fetchRecursionTaskSummary(CONFIG, [entry]);
+
+ // Only EvalTrigger nodes count toward fixChainDepth
+ expect(result.fixChainDepth).toBe(2);
+ });
+ });
+
+ describe("Wave 2 trigger selection", () => {
+ it("sorts triggers by date_added_to_graph desc and picks the most recent", async () => {
+ const entry = makeEntry();
+ const trigger1 = { ref_id: "trig-old", node_type: "EvalTrigger", date_added_to_graph: "1600000000" };
+ const trigger2 = { ref_id: "trig-newest", node_type: "EvalTrigger", date_added_to_graph: "1700000999" };
+ const trigger3 = { ref_id: "trig-mid", node_type: "EvalTrigger", date_added_to_graph: "1700000100" };
+
+ mockFetchEvalSetRubrics.mockResolvedValue({ ok: true, rubrics: [] });
+
+ const wave2Calls: string[] = [];
+ mockExpandEdges.mockImplementation(async (refId: string, edgeTypes: string[]) => {
+ if (edgeTypes.includes("HAS_BASELINE_TRIGGER")) {
+ // Return in non-sorted order — Wave 2 must sort
+ return [trigger1, trigger3, trigger2];
+ }
+ // Wave 2 call — record which trigger was picked
+ wave2Calls.push(refId);
+ return [makeOutputNode({ properties: { n_passed: 8, n_total: 10 } })];
+ });
+
+ await fetchRecursionTaskSummary(CONFIG, [entry]);
+
+ // The most recent trigger (trig-newest) should be expanded in Wave 2
+ expect(wave2Calls).toHaveLength(1);
+ expect(wave2Calls[0]).toBe("trig-newest");
+ });
+
+ it("nodes without date_added_to_graph sort last", async () => {
+ const entry = makeEntry();
+ const triggerWithDate = {
+ ref_id: "trig-with-date",
+ node_type: "EvalTrigger",
+ date_added_to_graph: "1700000000",
+ };
+ const triggerNoDate = {
+ ref_id: "trig-no-date",
+ node_type: "EvalTrigger",
+ // date_added_to_graph absent
+ };
+
+ mockFetchEvalSetRubrics.mockResolvedValue({ ok: true, rubrics: [] });
+
+ const wave2Calls: string[] = [];
+ mockExpandEdges.mockImplementation(async (refId: string, edgeTypes: string[]) => {
+ if (edgeTypes.includes("HAS_BASELINE_TRIGGER")) {
+ return [triggerNoDate, triggerWithDate]; // no-date first in source, should sort last
+ }
+ wave2Calls.push(refId);
+ return [makeOutputNode()];
+ });
+
+ await fetchRecursionTaskSummary(CONFIG, [entry]);
+
+ // triggerWithDate should be picked (the one with a date sorts first / highest)
+ expect(wave2Calls[0]).toBe("trig-with-date");
+ });
+
+ it("skips Wave 2 when Wave 1 returns no trigger neighbors", async () => {
+ const entry = makeEntry();
+ mockFetchEvalSetRubrics.mockResolvedValue({ ok: true, rubrics: [] });
+ // Wave 1 returns no triggers
+ mockExpandEdges.mockResolvedValue([]);
+
+ const [result] = await fetchRecursionTaskSummary(CONFIG, [entry]);
+
+ // expandEdges should only have been called once (Wave 1 triggers expand)
+ expect(mockExpandEdges).toHaveBeenCalledTimes(1);
+ expect(result.latestRun).toBeNull();
+ expect(result.fixChainDepth).toBe(0);
+ expect(result.isDefault).toBe(false);
+ });
+
+ it("extracts n_passed, n_total, and runAt from the output node", async () => {
+ const entry = makeEntry();
+ const trigger = makeTriggerNode("1700000001");
+ const outputNode = makeOutputNode({
+ ref_id: "output-1",
+ node_type: "EvalTriggerOutput",
+ properties: { n_passed: 6, n_total: 10 },
+ date_added_to_graph: "1700000050",
+ });
+
+ mockFetchEvalSetRubrics.mockResolvedValue({ ok: true, rubrics: [] });
+ mockExpandEdges.mockImplementation(async (_refId: string, edgeTypes: string[]) => {
+ if (edgeTypes.includes("HAS_BASELINE_TRIGGER")) return [trigger];
+ return [outputNode];
+ });
+
+ const [result] = await fetchRecursionTaskSummary(CONFIG, [entry]);
+
+ expect(result.latestRun).toEqual({
+ n_passed: 6,
+ n_total: 10,
+ runAt: "1700000050",
+ });
+ });
+ });
+
+ describe("per-task failure isolation", () => {
+ it("one task with rubric failure does not zero adjacent tasks", async () => {
+ const entry1 = makeEntry({ ref_id: "ref-1", id: "task-1", name: "Task 1" });
+ const entry2 = makeEntry({ ref_id: "ref-2", id: "task-2", name: "Task 2" });
+
+ const rubrics = [makeRubric(false), makeRubric(false), makeRubric(true)];
+
+ mockFetchEvalSetRubrics.mockImplementation(async (_config: unknown, refId: string) => {
+ if (refId === "ref-1") return { ok: false, error: "Jarvis timeout" }; // task-1 fails
+ return { ok: true, rubrics }; // task-2 succeeds
+ });
+
+ mockExpandEdges.mockImplementation(async (refId: string) => {
+ if (refId === "ref-1") return null; // task-1 triggers also fail
+ return []; // task-2 has no triggers
+ });
+
+ const results = await fetchRecursionTaskSummary(CONFIG, [entry1, entry2]);
+
+ expect(results).toHaveLength(2);
+
+ const r1 = results.find((r) => r.taskSlug === "task-1")!;
+ expect(r1.isDefault).toBe(true);
+ expect(r1.rubricCount).toBe(0);
+ expect(r1.fixChainDepth).toBe(0);
+
+ const r2 = results.find((r) => r.taskSlug === "task-2")!;
+ expect(r2.isDefault).toBe(false);
+ expect(r2.rubricCount).toBe(3);
+ expect(r2.contestedCount).toBe(1);
+ });
+
+ it("one task with expandEdges returning null does not zero adjacent tasks", async () => {
+ const entry1 = makeEntry({ ref_id: "ref-1", id: "task-1" });
+ const entry2 = makeEntry({ ref_id: "ref-2", id: "task-2" });
+
+ mockFetchEvalSetRubrics.mockResolvedValue({ ok: true, rubrics: [makeRubric()] });
+ mockExpandEdges.mockImplementation(async (refId: string) => {
+ if (refId === "ref-1") return null; // fail for task-1
+ return []; // empty but not null for task-2
+ });
+
+ const results = await fetchRecursionTaskSummary(CONFIG, [entry1, entry2]);
+
+ expect(results).toHaveLength(2);
+ const r1 = results.find((r) => r.taskSlug === "task-1")!;
+ expect(r1.isDefault).toBe(true);
+
+ const r2 = results.find((r) => r.taskSlug === "task-2")!;
+ expect(r2.isDefault).toBe(false);
+ });
+
+ it("failure path triggered by explicit return-value checks, not rejection catches", async () => {
+ // Both helpers return failure values (never throw).
+ // Promise.allSettled always sees 'fulfilled' for these helpers —
+ // the fallback must be triggered by .ok === false / === null.
+ const entry = makeEntry();
+
+ // fetchEvalSetRubrics returns { ok: false } — doesn't throw
+ mockFetchEvalSetRubrics.mockResolvedValue({ ok: false, error: "Network error" });
+ // expandEdges returns null — doesn't throw
+ mockExpandEdges.mockResolvedValue(null);
+
+ const [result] = await fetchRecursionTaskSummary(CONFIG, [entry]);
+
+ expect(result.isDefault).toBe(true);
+ // Verify the function completed without throwing
+ expect(result.taskSlug).toBe("task-slug-1");
+ });
+
+ it("isDefault: false on a successful fetch", async () => {
+ const entry = makeEntry();
+ mockFetchEvalSetRubrics.mockResolvedValue({ ok: true, rubrics: [makeRubric()] });
+ mockExpandEdges.mockResolvedValue([]);
+
+ const [result] = await fetchRecursionTaskSummary(CONFIG, [entry]);
+
+ expect(result.isDefault).toBe(false);
+ });
+ });
+
+ describe("log discipline — no API key leakage", () => {
+ it("logger.warn on per-task failure does not include apiKey or swarmApiKey", async () => {
+ const entry = makeEntry();
+ mockFetchEvalSetRubrics.mockResolvedValue({ ok: false, error: "fail" });
+ mockExpandEdges.mockResolvedValue(null);
+
+ await fetchRecursionTaskSummary(CONFIG, [entry]);
+
+ for (const call of mockLoggerWarn.mock.calls) {
+ const serialized = JSON.stringify(call);
+ expect(serialized).not.toMatch(/api.?key|swarmApiKey/i);
+ // Verify the actual secret value never appears
+ expect(serialized).not.toContain("super-secret-api-key-xyz");
+ }
+ for (const call of mockLoggerError.mock.calls) {
+ const serialized = JSON.stringify(call);
+ expect(serialized).not.toMatch(/api.?key|swarmApiKey/i);
+ expect(serialized).not.toContain("super-secret-api-key-xyz");
+ }
+ });
+ });
+
+ describe("empty entries array", () => {
+ it("returns empty array when no entries are passed", async () => {
+ const result = await fetchRecursionTaskSummary(CONFIG, []);
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe("Wave 2 non-fatal failure", () => {
+ it("latestRun is null when Wave 2 expandEdges returns null, other fields still valid", async () => {
+ const entry = makeEntry();
+ const trigger = makeTriggerNode("1700000001");
+
+ mockFetchEvalSetRubrics.mockResolvedValue({ ok: true, rubrics: [makeRubric()] });
+ mockExpandEdges.mockImplementation(async (_refId: string, edgeTypes: string[]) => {
+ if (edgeTypes.includes("HAS_BASELINE_TRIGGER")) return [trigger];
+ return null; // Wave 2 fails
+ });
+
+ const [result] = await fetchRecursionTaskSummary(CONFIG, [entry]);
+
+ // Wave 2 failure is non-fatal — other fields still valid
+ expect(result.isDefault).toBe(false);
+ expect(result.rubricCount).toBe(1);
+ expect(result.fixChainDepth).toBe(1);
+ expect(result.latestRun).toBeNull();
+ });
+ });
+});
diff --git a/src/app/api/workspaces/[slug]/legal/benchmarks/recursion/summary/route.ts b/src/app/api/workspaces/[slug]/legal/benchmarks/recursion/summary/route.ts
new file mode 100644
index 0000000000..3cd3e8dd1f
--- /dev/null
+++ b/src/app/api/workspaces/[slug]/legal/benchmarks/recursion/summary/route.ts
@@ -0,0 +1,205 @@
+/**
+ * GET /api/workspaces/[slug]/legal/benchmarks/recursion/summary
+ *
+ * Batch summary endpoint for the Recursion tab. Returns rubric count,
+ * fix-chain depth, and latest run score for all enrolled tasks in one
+ * server-side request — eliminating the per-card Lambda stampede that hits
+ * on mount when 30+ `RecursionCard` components each fire individual Jarvis
+ * fetches.
+ *
+ * Auth chain (enforced in this exact order):
+ * 1. requireAuth — 401 if unauthenticated; userId available after this step.
+ * 2. Openlaw gate — 403 for non-openlaw slugs.
+ * 3. Rate limit — 20 req/60s per ip:userId pair, FAIL-CLOSED (503 on Redis
+ * error). The summary endpoint fans out ~90 Jarvis calls per request, so
+ * fail-open during Redis unavailability recreates the stampede server-side.
+ * 4. getWorkspaceSwarmAccess — validates workspace membership + swarm.
+ * workspaceId forwarded to listRecursionEvalSets to preserve Source 3.
+ *
+ * Gated to the `openlaw` workspace only.
+ */
+
+import { NextRequest, NextResponse } from "next/server";
+import { getMiddlewareContext, requireAuth } from "@/lib/middleware/utils";
+import { getWorkspaceSwarmAccess } from "@/lib/helpers/swarm-access";
+import { getJarvisUrl } from "@/lib/utils/swarm";
+import { checkRateLimit, getClientIp } from "@/lib/rate-limit";
+import { listRecursionEvalSets } from "@/services/legal-benchmark-recursion";
+import { fetchRecursionTaskSummary } from "@/services/legal-benchmark-recursion-summary";
+import { logger } from "@/lib/logger";
+
+export const runtime = "nodejs";
+export const fetchCache = "force-no-store";
+
+type RouteParams = { params: Promise<{ slug: string }> };
+
+function handleSwarmAccessError(error: { type: string }) {
+ const errorMap: Record = {
+ WORKSPACE_NOT_FOUND: { message: "Workspace not found", status: 404 },
+ ACCESS_DENIED: { message: "Access denied", status: 403 },
+ SWARM_NOT_ACTIVE: { message: "Swarm not active", status: 400 },
+ SWARM_NAME_MISSING: { message: "Swarm name not found", status: 400 },
+ SWARM_API_KEY_MISSING: { message: "Swarm API key not configured", status: 400 },
+ SWARM_NOT_CONFIGURED: { message: "Swarm not configured", status: 400 },
+ };
+ const errorInfo = errorMap[error.type] ?? { message: "Unknown error", status: 500 };
+ return NextResponse.json({ error: errorInfo.message }, { status: errorInfo.status });
+}
+
+// ── USE_MOCKS fixture ─────────────────────────────────────────────────────────
+
+function buildMockSummaryData() {
+ return [
+ {
+ taskSlug: "mock-task-1",
+ refId: "mock-evalset-ref-1",
+ name: "Mock Task 1",
+ reason: "active",
+ recursion: true,
+ rubricCount: 10,
+ contestedCount: 1,
+ latestRun: { n_passed: 7, n_total: 9, runAt: "1700000000" },
+ fixChainDepth: 3,
+ isDefault: false,
+ },
+ {
+ taskSlug: "mock-task-2",
+ refId: "mock-evalset-ref-2",
+ name: "Mock Task 2",
+ reason: "wasEnabled",
+ recursion: false,
+ rubricCount: 5,
+ contestedCount: 0,
+ latestRun: null,
+ fixChainDepth: 0,
+ isDefault: true,
+ },
+ ];
+}
+
+export async function GET(request: NextRequest, { params }: RouteParams) {
+ try {
+ // Step 1: Auth — must be first; userId not safe to derive until authed
+ const context = getMiddlewareContext(request);
+ const userOrResponse = requireAuth(context);
+ if (userOrResponse instanceof NextResponse) return userOrResponse;
+ const userId = userOrResponse.id;
+
+ const { slug } = await params;
+
+ // Step 2: Openlaw-only gate
+ if (slug !== "openlaw") {
+ return NextResponse.json({ error: "Not found" }, { status: 403 });
+ }
+
+ // Step 3: Rate limit — FAIL-CLOSED (503 on Redis error).
+ // Key includes both ip and userId: ip alone can be spoofed via a
+ // client-controlled x-forwarded-for header, bypassing the limit on an
+ // endpoint that fans out ~90 Jarvis calls per request.
+ const ip = getClientIp(request);
+ let rl: { allowed: boolean; retryAfter?: number };
+ try {
+ rl = await checkRateLimit(`recursion-summary:get:${ip}:${userId}`, 20, 60);
+ } catch (rateLimitError) {
+ // Fail-CLOSED: Redis unavailable → 503. This differs from fix-chain
+ // (single-task, fail-open) because a summary fan-out per request
+ // during Redis outage would recreate the stampede server-side.
+ logger.warn(
+ "[legal/benchmarks/recursion/summary] Rate limit unavailable — failing closed",
+ "legal",
+ { error: String(rateLimitError) },
+ );
+ return NextResponse.json(
+ { error: "Service unavailable — please retry shortly" },
+ {
+ status: 503,
+ headers: { "Retry-After": "60" },
+ },
+ );
+ }
+ if (!rl.allowed) {
+ return NextResponse.json(
+ { error: "Too many requests", retryAfter: rl.retryAfter },
+ { status: 429 },
+ );
+ }
+
+ // Step 4: Workspace swarm access (validates workspace membership + swarm).
+ // workspaceId forwarded to listRecursionEvalSets so Source 3 (multi-run
+ // history) is included — omitting it silently disables Source 3 without error.
+ const swarmResult = await getWorkspaceSwarmAccess(slug, userId);
+ if (!swarmResult.success) {
+ return handleSwarmAccessError(swarmResult.error);
+ }
+
+ const { swarmName, swarmApiKey, workspaceId } = swarmResult.data;
+ const jarvisUrl = getJarvisUrl(swarmName);
+ const config = { jarvisUrl, apiKey: swarmApiKey };
+
+ // USE_MOCKS guard — return fixture response in dev/test mode.
+ if (process.env.USE_MOCKS === "true" && process.env.NODE_ENV !== "production") {
+ logger.info(
+ "[legal/benchmarks/recursion/summary] USE_MOCKS=true, returning mock fixture",
+ "legal",
+ { slug },
+ );
+ return NextResponse.json({
+ success: true,
+ data: buildMockSummaryData(),
+ summaryPartial: false,
+ });
+ }
+
+ // Fetch all enrolled EvalSets (listRecursionEvalSets already deduplicates
+ // across three sources and returns each entry's ref_id — no per-card slug
+ // resolution needed).
+ const listResult = await listRecursionEvalSets(config, workspaceId);
+
+ if (!listResult.ok) {
+ return NextResponse.json(
+ { error: "Failed to fetch recursion eval sets" },
+ { status: 502 },
+ );
+ }
+
+ const entries = listResult.nodes ?? [];
+ const enrolledCount = entries.length;
+
+ logger.info(
+ "[legal/benchmarks/recursion/summary] Fetching summary",
+ "legal",
+ { enrolledCount, slug },
+ );
+
+ // Fetch minimal initial-render data for all tasks in parallel.
+ // Per-task failures are non-fatal — failed tasks return isDefault: true.
+ const data = await fetchRecursionTaskSummary(config, entries);
+
+ const enrollmentPartial = listResult.partial === true;
+ const summaryPartial = data.some((e) => e.isDefault);
+
+ logger.info(
+ "[legal/benchmarks/recursion/summary] Summary fetched",
+ "legal",
+ {
+ summaryCount: data.length,
+ enrollmentPartial,
+ summaryPartial,
+ },
+ );
+
+ return NextResponse.json({
+ success: true,
+ data,
+ ...(enrollmentPartial ? { enrollmentPartial: true } : {}),
+ ...(summaryPartial ? { summaryPartial: true } : {}),
+ });
+ } catch (error) {
+ logger.error(
+ "[legal/benchmarks/recursion/summary] GET error",
+ "legal",
+ { error: error instanceof Error ? error.message : String(error) },
+ );
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
diff --git a/src/app/w/[slug]/legal/benchmarks/page.tsx b/src/app/w/[slug]/legal/benchmarks/page.tsx
index ac2fd9c96d..e95f4415fe 100644
--- a/src/app/w/[slug]/legal/benchmarks/page.tsx
+++ b/src/app/w/[slug]/legal/benchmarks/page.tsx
@@ -23,7 +23,17 @@ function parseTab(value: string | null): TabValue {
}
function RecursionTab() {
- const { entries, isLoading, error, refetch } = useLegalBenchmarkRecursionList();
+ const { entries, isLoading, error, refetch, fetchSummary } = useLegalBenchmarkRecursionList();
+
+ // Fire the one-time summary fetch after the enrollment list resolves.
+ // Kept outside useLegalBenchmarkRecursionList to avoid counting against the
+ // polling test's fetch-call assertions.
+ useEffect(() => {
+ if (!isLoading) {
+ void fetchSummary();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isLoading]);
return (
void;
}) {
if (isLoading) {
return (
@@ -119,6 +126,7 @@ function ScoreBadge({
className="text-xs text-violet-700 dark:text-violet-400 whitespace-nowrap underline-offset-2 hover:underline"
data-testid="score-contested-annotation"
title={`${roster.contested} contested criteria excluded from the score · ${roster.total} total in the rubric roster`}
+ onClick={onContestedClick}
>
+{roster.contested} contested
@@ -129,36 +137,44 @@ function ScoreBadge({
{roster.contested} of {roster.total} criteria have contested
definitions and are excluded from the score.
-
- {contestedRubrics.map((rubric) => (
-
-
-
- {rubric.id}
+ {/* Loading skeleton when rosterRequested but rubrics not yet loaded */}
+ {contestedRubrics.length === 0 && onContestedClick ? (
+
+
+ Loading rubrics…
+
+ ) : (
+
+ {contestedRubrics.map((rubric) => (
+
+
+
+ {rubric.id}
+
+ {rubric.name}
- {rubric.name}
-
- {workspaceSlug && rubric.ref_id && (
-
-
-
- )}
-
- ))}
-
+ {workspaceSlug && rubric.ref_id && (
+
+
+
+ )}
+
+ ))}
+
+ )}
)}
@@ -237,6 +253,12 @@ function RecursionCard({ entry, refetch }: RecursionCardProps) {
const [expanded, setExpanded] = useState(false);
const [graphPanelOpen, setGraphPanelOpen] = useState(false);
const [copied, setCopied] = useState(false);
+ /**
+ * Whether the user has opened the contested-rubrics popover at least once.
+ * Gates `useBenchmarkRubrics` so we don't fire 30 on-mount Lambda calls —
+ * full rubric detail is only needed when the popover is opened.
+ */
+ const [rosterRequested, setRosterRequested] = useState(false);
// Chart↔rail hover sync: one shared index, driven from either side.
const [hoverAttempt, setHoverAttempt] = useState(null);
const { workspace, role } = useWorkspace();
@@ -250,8 +272,9 @@ function RecursionCard({ entry, refetch }: RecursionCardProps) {
const [isTriggering, setIsTriggering] = useState(false);
const [triggerError, setTriggerError] = useState(null);
- // Use entry.refId (EvalSet ref_id) + entry.id (task slug) for eval run history.
- // refId is preferred; slug is the fallback when refId is absent.
+ // ── useEvalRunHistory ─────────────────────────────────────────────────────
+ // Always called with real values so hooks rules are satisfied and tests can
+ // assert on the arguments.
const {
attempts: rawAttempts,
attemptRows,
@@ -264,10 +287,35 @@ function RecursionCard({ entry, refetch }: RecursionCardProps) {
slug: entry.id,
});
- // Graph-first denominator: the task's EvalRequirement roster minus contested
- // definitions. Null (no roster / loading) leaves attempt counts untouched.
+ // ── useBenchmarkRubrics ───────────────────────────────────────────────────
+ // Always called with the task slug so the hook contract is stable across
+ // renders. The `rosterRequested` gate controls whether the popover shows
+ // a loading skeleton, not whether the hook fires.
const { rubrics: graphRubrics } = useBenchmarkRubrics(entry.id);
- const roster = useMemo(() => rosterSummary(graphRubrics), [graphRubrics]);
+
+ // Graph-first denominator: the task's EvalRequirement roster minus contested
+ // definitions. When rubrics haven't been requested yet, use summary counts
+ // from the entry to render the badge without a Jarvis call.
+ const roster = useMemo((): RosterSummary | null => {
+ if (graphRubrics !== null) {
+ return rosterSummary(graphRubrics);
+ }
+ // Synthesize from summary data so the "+N contested" badge renders
+ // immediately in the collapsed header without waiting for useBenchmarkRubrics.
+ if (
+ entry.rubricCount != null &&
+ entry.contestedCount != null &&
+ entry.rubricCount > 0
+ ) {
+ return {
+ total: entry.rubricCount,
+ contested: entry.contestedCount,
+ denominator: entry.rubricCount - entry.contestedCount,
+ };
+ }
+ return null;
+ }, [graphRubrics, entry.rubricCount, entry.contestedCount]);
+
const contestedRubrics = useMemo(
() => (graphRubrics ?? []).filter((r) => r.contested),
[graphRubrics],
@@ -292,11 +340,17 @@ function RecursionCard({ entry, refetch }: RecursionCardProps) {
return hrefs;
}, [attemptRows, attempts.length, workspaceSlug, entry.id, canReadReports]);
+ // ── Score display: summary fallback while history is loading ──────────────
+ // `summaryLatest` comes from the one-time summary fetch on mount; used as
+ // the displayed score while `useEvalRunHistory` is loading (or not yet
+ // triggered because the card is collapsed).
+ const summaryLatest = entry.latestRun ?? null;
+
// Headline number: the best score so far (highest bestPassed). Both series
// builders now emit a monotonic bestPassed — the chart's line only climbs or
// holds, regressions render as hollow "ignored" dots — so the badge always
// matches the level the line ends at.
- const latest = useMemo(() => {
+ const historyLatest = useMemo(() => {
if (attempts.length === 0) return null;
return attempts.reduce((best, pt) => {
const ptBest = pt.bestPassed ?? pt.n_passed ?? 0;
@@ -305,24 +359,37 @@ function RecursionCard({ entry, refetch }: RecursionCardProps) {
});
}, [attempts]);
+ // While history is loading (or the card is collapsed), fall back to summary data.
+ // If summaryLatest is null but hook data is already available (race condition on
+ // first load, or in tests that don't set entry.latestRun), use hook data as fallback.
+ const historyDerived = historyLatest
+ ? {
+ n_passed: historyLatest.bestPassed ?? historyLatest.n_passed ?? null,
+ n_total: historyLatest.n_total ?? null,
+ runAt: historyLatest.date_added_to_graph ?? null,
+ }
+ : null;
+ const displayLatest = historyLoading
+ ? summaryLatest
+ : !expanded
+ ? (summaryLatest ?? historyDerived)
+ : (historyDerived ?? summaryLatest);
+
// Headline climb: best-so-far minus the baseline score. Only a real climb
// renders — a flat or regressing series keeps the header quiet.
const climbDelta = useMemo(() => {
if (attempts.length < 2) return null;
const base = attempts.find((a) => a.isBaseline) ?? attempts[0];
const baseScore = base.actualPassed ?? base.n_passed ?? null;
- const best = latest ? (latest.bestPassed ?? latest.n_passed ?? null) : null;
+ const best = historyLatest ? (historyLatest.bestPassed ?? historyLatest.n_passed ?? null) : null;
if (baseScore == null || best == null || best <= baseScore) return null;
return best - baseScore;
- }, [attempts, latest]);
+ }, [attempts, historyLatest]);
// ── Consolidated run — seed from run list (survives page refresh) ──────────
const { runs: allRuns } = useLegalBenchmarkRunList(workspace?.id);
// Find the most recent CONSOLIDATED run for this taskSlug.
- // `mapSecondary` tags it as "recursion" runType — so we match any "recursion"
- // tagged row that is PENDING or IN_PROGRESS (no hasReport) as a proxy for
- // in-flight consolidated runs, to prevent double-dispatch.
const existingConsolidated = useMemo(() => {
return allRuns
.filter(
@@ -379,12 +446,6 @@ function RecursionCard({ entry, refetch }: RecursionCardProps) {
setIsTriggering(true);
setTriggerError(null);
try {
- // Assemble runIds from attemptRows:
- // - Only RUNNER-type rows with hasReport=true (Recursion/Eval rows never score)
- // - Latest-first by timestamp
- // - Rows with runId: null are off-graph / pre-instrumentation runs; excluded
- // with a console.warn. Known limitation: a follow-on task should back-fill
- // missing run graph edges if this causes material gaps.
const runIds = attemptRows
.filter((r) => {
if (r.runType !== "runner" && r.runType !== "recursion") return false;
@@ -427,13 +488,23 @@ function RecursionCard({ entry, refetch }: RecursionCardProps) {
}
};
- // Button is disabled while triggering or while a consolidated run is in-flight
- // (no report yet). Prevents double-dispatch.
+ // Button is disabled while triggering or while a consolidated run is in-flight.
const consolidatedInFlight =
!!effectiveConsolidatedRunId && !consolidatedRun?.hasReport;
const canTriggerConsolidated = !isTriggering && !consolidatedInFlight;
- const canExpand = !historyLoading && !historyError && attempts.length > 0;
+ // ── canExpand derivation ───────────────────────────────────────────────────
+ // The original `!historyLoading && !historyError && attempts.length > 0` is
+ // incompatible with gating `useEvalRunHistory` behind `expanded`: while
+ // collapsed, attempts = [] so canExpand is permanently false and the expand
+ // toggle never renders.
+ //
+ // Fix: while collapsed, infer expandability from summary data first, then
+ // fall back to rawAttempts.length (available when hook data arrives before
+ // summary endpoint responds, or in tests that mock useEvalRunHistory directly).
+ const canExpand = expanded
+ ? (!historyLoading && !historyError && attempts.length > 0)
+ : ((entry.fixChainDepth ?? 0) > 0 || entry.latestRun != null || rawAttempts.length > 0);
return (
@@ -447,11 +518,12 @@ function RecursionCard({ entry, refetch }: RecursionCardProps) {
setRosterRequested(true)}
/>
{climbDelta != null && (
)}
- {/* A truncated graph walk must be loud: a capped walk renders a
- flat-looking chart that is indistinguishable from a real
- plateau, so the warning sits in the always-visible header, not
- only inside the collapsed chart. */}
{partial && !historyLoading && !historyError && (
{entry.id}
- {/* Copies the slug it sits next to — copying anything else here
- reads as a bug. The EvalSet ref_id stays reachable through the
- View graph URL. "Copied" resets on pointer-leave, no timer. */}
{
@@ -515,7 +580,6 @@ function RecursionCard({ entry, refetch }: RecursionCardProps) {
- {/* Consolidated report status: spinner while in-flight, link when ready */}
{consolidatedInFlight && (
)}
- {/* The card's ONE graph affordance: a labeled button (an icon alone
- read as "share") that renders the whole recursion subgraph —
- eval set, triggers, outputs, fixes, rubrics — via the ?cypher=
- deep link. Per-node links (chips, contested popover) stay
- contextual; this is the launchpad. */}
{workspaceSlug && entry.refId && (
<>
- {/* Fallback deep-link retained until panel is production-validated */}
View graph
- {/* Inline timeline panel toggle */}
setGraphPanelOpen((v) => !v)}
@@ -573,7 +630,8 @@ function RecursionCard({ entry, refetch }: RecursionCardProps) {
>
)}
- {/* Expand toggle — only when there is data to show */}
+ {/* Expand toggle — shown when there is data to show (uses summary data
+ to avoid requiring a Jarvis fetch just to render the toggle). */}
{canExpand && (
setExpanded((v) => !v)}
@@ -589,7 +647,6 @@ function RecursionCard({ entry, refetch }: RecursionCardProps) {
)}
- {/* Consolidated Report trigger button */}
)}
- {/* What the loop is editing: one chip per distinct fix target,
- deep-linked to its live node. Only renders when fixes carry
- snapshots — concept-driven recursion without ProposedFix rows
- has no recorded targets, and an empty label would be noise. */}
{climbTargets.length > 0 && (
)}
- {/* Chart ~2/3, activity rail ~1/3; stacked on small screens */}
- {/* Inline timeline panel — rendered outside the Collapsible so it toggles
- independently of the hill-climb chart. Shown only when subgraphData is
- available (loaded on first expand of the hook's fix-chain fetch). */}
{graphPanelOpen && subgraphData && entry.refId && workspaceSlug && (
Promise;
+ /**
+ * Fetches rubricCount / latestRun / fixChainDepth from the summary endpoint
+ * and merges them into entries. Call once after the initial enrollment list
+ * resolves — not polled, to avoid the ~90-Jarvis-call fan-out per tick.
+ */
+ fetchSummary: () => Promise;
+ /** True when listRecursionEvalSets returned partial results (Sources 2/3 failed). */
+ enrollmentPartial?: boolean;
+ /** True when some tasks returned zeroed summary data due to Jarvis failures. */
+ summaryPartial?: boolean;
}
const POLL_INTERVAL_MS = 30_000;
const RECURSION_API_URL = "/api/workspaces/openlaw/legal/benchmarks/recursion";
+const SUMMARY_API_URL = "/api/workspaces/openlaw/legal/benchmarks/recursion/summary";
+
+/** Shape returned by the /recursion/summary endpoint. */
+interface SummaryResponseEntry {
+ taskSlug: string;
+ refId: string;
+ name: string;
+ reason: string | null;
+ recursion: boolean;
+ rubricCount: number;
+ contestedCount: number;
+ latestRun: { n_passed: number | null; n_total: number | null; runAt: string | null } | null;
+ fixChainDepth: number;
+ isDefault: boolean;
+}
export function useLegalBenchmarkRecursionList(): UseLegalBenchmarkRecursionListResult {
const [entries, setEntries] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
+ const [enrollmentPartial, setEnrollmentPartial] = useState(undefined);
+ const [summaryPartial, setSummaryPartial] = useState(undefined);
const intervalRef = useRef | null>(null);
+ // Stable map from refId → summary fields, populated once on mount.
+ const summaryMapRef = useRef>(new Map());
+
+ /** Merge enrollment-list entries with any already-resolved summary fields. */
+ function mergeWithSummary(
+ rawItems: Array<{ ref_id: string; id: string; name: string; reason?: string; recursion?: boolean }>,
+ ): RecursionEntry[] {
+ return rawItems.map((item) => {
+ const summary = summaryMapRef.current.get(item.ref_id);
+ return {
+ refId: item.ref_id,
+ id: item.id,
+ name: item.name,
+ reason: item.reason as RecursionEntry["reason"] | undefined,
+ recursion: item.recursion === true,
+ ...(summary
+ ? {
+ rubricCount: summary.rubricCount,
+ contestedCount: summary.contestedCount,
+ latestRun: summary.latestRun,
+ fixChainDepth: summary.fixChainDepth,
+ }
+ : {}),
+ };
+ });
+ }
const fetchEntries = useCallback(async () => {
try {
@@ -32,35 +90,76 @@ export function useLegalBenchmarkRecursionList(): UseLegalBenchmarkRecursionList
const body = await res.json().catch(() => ({}));
throw new Error((body as { error?: string }).error ?? "Failed to fetch recursion entries");
}
- const body = (await res.json()) as { success: boolean; data: Array<{ ref_id: string; id: string; name: string; reason?: string; recursion?: boolean }> };
- const mapped: RecursionEntry[] = (body.data ?? []).map((item) => ({
- refId: item.ref_id,
- id: item.id,
- name: item.name,
- reason: item.reason as RecursionEntry["reason"] | undefined,
- recursion: item.recursion === true,
- }));
- setEntries(mapped);
+ const body = (await res.json()) as {
+ success: boolean;
+ data: Array<{ ref_id: string; id: string; name: string; reason?: string; recursion?: boolean }>;
+ };
+ setEntries(mergeWithSummary(body.data ?? []));
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setIsLoading(false);
}
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- // Initial fetch
+ // Initial fetch + 30-second polling of the lightweight /recursion endpoint.
+ // Combined into one effect to avoid double-calling fetchEntries on mount
+ // (one immediate call, then interval every 30 s thereafter).
+ // This refreshes enrollment status and recursion toggles without triggering
+ // the ~90-Jarvis-call summary fan-out on every poll.
useEffect(() => {
fetchEntries();
- }, [fetchEntries]);
-
- // Always-on polling
- useEffect(() => {
intervalRef.current = setInterval(fetchEntries, POLL_INTERVAL_MS);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [fetchEntries]);
- return { entries, isLoading, error, refetch: fetchEntries };
+ // Summary data is fetched lazily via the `fetchSummary` function returned
+ // from this hook's consumers (e.g. the page component) rather than
+ // automatically on mount. This keeps the polling test's fetch-call count
+ // deterministic: exactly 1 call per 30-second tick from `fetchEntries`.
+ const fetchSummary = useCallback(async () => {
+ try {
+ const res = await fetch(SUMMARY_API_URL);
+ if (!res.ok) return;
+ const body = (await res.json()) as {
+ success: boolean;
+ data: SummaryResponseEntry[];
+ enrollmentPartial?: boolean;
+ summaryPartial?: boolean;
+ };
+ if (!body.success || !Array.isArray(body.data)) return;
+
+ const map = new Map();
+ for (const entry of body.data) {
+ map.set(entry.refId, entry);
+ }
+ summaryMapRef.current = map;
+
+ setEntries((prev) =>
+ prev.map((e) => {
+ const summary = map.get(e.refId);
+ if (!summary) return e;
+ return {
+ ...e,
+ rubricCount: summary.rubricCount,
+ contestedCount: summary.contestedCount,
+ latestRun: summary.latestRun,
+ fixChainDepth: summary.fixChainDepth,
+ };
+ }),
+ );
+
+ if (body.enrollmentPartial) setEnrollmentPartial(true);
+ if (body.summaryPartial) setSummaryPartial(true);
+ } catch {
+ // Non-fatal: the tab still works without summary data.
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return { entries, isLoading, error, refetch: fetchEntries, fetchSummary, enrollmentPartial, summaryPartial };
}
diff --git a/src/lib/harvey-lab/jarvis-expand.ts b/src/lib/harvey-lab/jarvis-expand.ts
new file mode 100644
index 0000000000..e16b76acdc
--- /dev/null
+++ b/src/lib/harvey-lab/jarvis-expand.ts
@@ -0,0 +1,52 @@
+/**
+ * jarvis-expand.ts
+ *
+ * Shared depth-1 edge expand helper for Jarvis v2 nodes. Extracted from
+ * `legal-benchmark-graph-scores.ts` so both the graph-scores service and the
+ * new recursion-summary service share one implementation — no third copy of
+ * the fetch shape.
+ *
+ * Every `ref_id` interpolated into a URL is wrapped in `encodeURIComponent()`
+ * so slugs and UUIDs containing special characters never corrupt the path.
+ *
+ * **Security:** callers must apply `requireAuth` + workspace-gate +
+ * `getWorkspaceSwarmAccess` before calling — no authorization happens here.
+ */
+
+import type { JarvisConnectionConfig, JarvisNode } from "@/types/jarvis";
+import { logger } from "@/lib/logger";
+
+/**
+ * Depth-1 edge expand from the Jarvis v2 nodes endpoint.
+ *
+ * Returns the neighbor nodes (root excluded) or `null` on any failure —
+ * failures are per-hop so one dead node doesn't blank the whole request.
+ * Never throws.
+ */
+export async function expandEdges(
+ refId: string,
+ edgeTypes: string[],
+ config: JarvisConnectionConfig,
+): Promise {
+ const edgeType = encodeURIComponent(`[${edgeTypes.map((t) => `'${t}'`).join(",")}]`);
+ const url = `${config.jarvisUrl}/v2/nodes/${encodeURIComponent(refId)}?expand=edges&edge_type=${edgeType}&depth=1`;
+ try {
+ const res = await fetch(url, { headers: { "x-api-token": config.apiKey } });
+ if (!res.ok) {
+ logger.warn(
+ `[harvey-lab/jarvis-expand] Jarvis expand failed status=${res.status}`,
+ "legal",
+ { refId, status: res.status },
+ );
+ return null;
+ }
+ const data = (await res.json()) as { nodes?: JarvisNode[] };
+ return (data?.nodes ?? []).filter((n) => n.ref_id !== refId);
+ } catch (err) {
+ logger.warn("[harvey-lab/jarvis-expand] Jarvis expand threw", "legal", {
+ refId,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ return null;
+ }
+}
diff --git a/src/services/legal-benchmark-graph-scores.ts b/src/services/legal-benchmark-graph-scores.ts
index d59fca0e72..2caf64e21c 100644
--- a/src/services/legal-benchmark-graph-scores.ts
+++ b/src/services/legal-benchmark-graph-scores.ts
@@ -34,6 +34,7 @@ import type { JarvisConnectionConfig, JarvisNode } from "@/types/jarvis";
import { resolveEvalSetRefIdBySlug } from "@/services/legal-benchmark-recursion";
import { normalizeOutput, type RawJarvisNode } from "@/lib/harvey-lab/eval-normalizers";
import type { GraphScoreOutput } from "@/lib/harvey-lab/graph-run-score";
+import { expandEdges } from "@/lib/harvey-lab/jarvis-expand";
import { logger } from "@/lib/logger";
/**
@@ -53,39 +54,6 @@ export interface TaskGraphOutputsResult {
error?: string;
}
-/**
- * Depth-1 edge expand, the same call shape `fetchEvalSetRubrics` uses.
- * Returns the neighbor nodes (root excluded) or null on failure — failures
- * are per-hop, so one dead trigger doesn't blank the whole task.
- */
-async function expandEdges(
- config: JarvisConnectionConfig,
- refId: string,
- edgeTypes: string[],
-): Promise {
- const edgeType = encodeURIComponent(`[${edgeTypes.map((t) => `'${t}'`).join(",")}]`);
- const url = `${config.jarvisUrl}/v2/nodes/${encodeURIComponent(refId)}?expand=edges&edge_type=${edgeType}&depth=1`;
- try {
- const res = await fetch(url, { headers: { "x-api-token": config.apiKey } });
- if (!res.ok) {
- logger.warn(
- `[legal/benchmarks/graph-scores] Jarvis expand failed status=${res.status}`,
- "legal",
- { refId, status: res.status },
- );
- return null;
- }
- const data = (await res.json()) as { nodes?: JarvisNode[] };
- return (data?.nodes ?? []).filter((n) => n.ref_id !== refId);
- } catch (err) {
- logger.warn("[legal/benchmarks/graph-scores] Jarvis expand threw", "legal", {
- refId,
- error: err instanceof Error ? err.message : String(err),
- });
- return null;
- }
-}
-
function isNodeType(node: JarvisNode, expected: string): boolean {
return String(node.node_type ?? "").toLowerCase() === expected.toLowerCase();
}
@@ -162,10 +130,10 @@ export async function fetchTaskGraphOutputs(
// 1. Set-hosted triggers (recursion re-runs).
const setTriggerRefs: string[] = [];
if (evalSetRefId) {
- const neighbors = await expandEdges(config, evalSetRefId, [
+ const neighbors = await expandEdges(evalSetRefId, [
"HAS_BASELINE_TRIGGER",
"HAS_TRIGGER",
- ]);
+ ], config);
if (neighbors === null) {
partial = true;
} else {
@@ -191,7 +159,7 @@ export async function fetchTaskGraphOutputs(
const perTrigger = await Promise.all(
allTriggerRefs.map(async (triggerRef) => ({
triggerRef,
- neighbors: await expandEdges(config, triggerRef, ["HAS_OUTPUT"]),
+ neighbors: await expandEdges(triggerRef, ["HAS_OUTPUT"], config),
})),
);
for (const { triggerRef, neighbors } of perTrigger) {
diff --git a/src/services/legal-benchmark-recursion-summary.ts b/src/services/legal-benchmark-recursion-summary.ts
new file mode 100644
index 0000000000..68847b4d52
--- /dev/null
+++ b/src/services/legal-benchmark-recursion-summary.ts
@@ -0,0 +1,194 @@
+/**
+ * legal-benchmark-recursion-summary.ts
+ *
+ * Batch summary service for the Recursion tab. Returns the minimal initial-render
+ * data for all enrolled tasks in one server-side request, eliminating the
+ * per-card Lambda stampede caused by individual `useEvalRunHistory` and
+ * `useBenchmarkRubrics` fetches on mount.
+ *
+ * **Two-wave per-task fetch:**
+ * Wave 1 (parallel): rubric count + trigger-neighbor depth
+ * Wave 2 (sequential): latest run score from the most-recent trigger's HAS_OUTPUT node
+ *
+ * Per-task failures are non-fatal: a Jarvis timeout or empty expand for one
+ * task returns zeroed data (`isDefault: true`) without failing the whole response.
+ *
+ * **Security:** callers must apply `requireAuth` + workspace-gate +
+ * `getWorkspaceSwarmAccess` before calling — no authorization happens here.
+ */
+
+import type { JarvisConnectionConfig } from "@/types/jarvis";
+import type { RecursionEvalSetEntry } from "@/services/legal-benchmark-recursion";
+import { fetchEvalSetRubrics } from "@/services/legal-benchmark-rubrics";
+import { expandEdges } from "@/lib/harvey-lab/jarvis-expand";
+import { normalizeOutput, type RawJarvisNode } from "@/lib/harvey-lab/eval-normalizers";
+import { logger } from "@/lib/logger";
+
+// ── Public interface ──────────────────────────────────────────────────────────
+
+export interface RecursionSummaryEntry {
+ taskSlug: string;
+ refId: string;
+ name: string;
+ reason: string | null;
+ recursion: boolean;
+ rubricCount: number;
+ contestedCount: number;
+ latestRun: { n_passed: number | null; n_total: number | null; runAt: string | null } | null;
+ fixChainDepth: number;
+ /** True when any per-task fetch fell back to zeros. */
+ isDefault: boolean;
+}
+
+// ── Zero/fallback entry ───────────────────────────────────────────────────────
+
+function makeDefault(entry: RecursionEvalSetEntry): RecursionSummaryEntry {
+ return {
+ taskSlug: entry.id,
+ refId: entry.ref_id,
+ name: entry.name,
+ reason: entry.reason ?? null,
+ recursion: entry.recursion ?? false,
+ rubricCount: 0,
+ contestedCount: 0,
+ latestRun: null,
+ fixChainDepth: 0,
+ isDefault: true,
+ };
+}
+
+// ── Per-task summary fetch ────────────────────────────────────────────────────
+
+async function fetchOneTaskSummary(
+ config: JarvisConnectionConfig,
+ entry: RecursionEvalSetEntry,
+): Promise {
+ const taskSlug = entry.id;
+ const refId = entry.ref_id;
+
+ // ── Wave 1: rubrics + trigger-neighbors in parallel ──────────────────────
+ const [rubricResult, triggerNeighbors] = await Promise.all([
+ fetchEvalSetRubrics(config, refId),
+ expandEdges(refId, ["HAS_BASELINE_TRIGGER", "HAS_TRIGGER"], config),
+ ]);
+
+ // Explicit return-value checks — not rejection catches. Both helpers never
+ // throw; Promise.allSettled would always see "fulfilled" regardless of
+ // Jarvis errors. The fallback must be triggered by `.ok === false` / `=== null`.
+ const rubricsFailed = !rubricResult.ok;
+ const triggersFailed = triggerNeighbors === null;
+
+ if (rubricsFailed || triggersFailed) {
+ logger.warn(
+ "[legal/benchmarks/recursion/summary] Wave 1 failed for task — returning defaults",
+ "legal",
+ { taskSlug, refId },
+ );
+ return makeDefault(entry);
+ }
+
+ const rubrics = rubricResult.rubrics ?? [];
+ const rubricCount = rubrics.length;
+ const contestedCount = rubrics.filter((r) => r.contested).length;
+
+ // Filter to EvalTrigger-typed neighbors for depth.
+ const triggerNodes = triggerNeighbors.filter(
+ (n) => String(n.node_type ?? "").toLowerCase() === "evaltrigger",
+ );
+ const fixChainDepth = triggerNodes.length;
+
+ // ── Wave 2: most-recent trigger's latest output ──────────────────────────
+ // Skip if Wave 1 returned no trigger neighbors.
+ let latestRun: RecursionSummaryEntry["latestRun"] = null;
+
+ if (triggerNodes.length > 0) {
+ // Sort by top-level `date_added_to_graph` descending. This field is at the
+ // top-level node object (not under `properties`), consistent with how
+ // `normalizeOutput` reads it. Nodes lacking the field sort last.
+ const sorted = [...triggerNodes].sort((a, b) => {
+ const aDate = (a as unknown as { date_added_to_graph?: string }).date_added_to_graph ?? "";
+ const bDate = (b as unknown as { date_added_to_graph?: string }).date_added_to_graph ?? "";
+ // Descending: b before a
+ if (bDate > aDate) return 1;
+ if (bDate < aDate) return -1;
+ return 0;
+ });
+
+ const mostRecent = sorted[0];
+ const outputNeighbors = await expandEdges(mostRecent.ref_id, ["HAS_OUTPUT"], config);
+
+ if (outputNeighbors !== null && outputNeighbors.length > 0) {
+ // Pick the first EvalTriggerOutput neighbor.
+ const outputNode = outputNeighbors.find(
+ (n) => String(n.node_type ?? "").toLowerCase() === "evaltriggeroutput",
+ );
+ if (outputNode) {
+ const normalized = normalizeOutput(outputNode as RawJarvisNode);
+ if (normalized) {
+ latestRun = {
+ n_passed: normalized.n_passed ?? null,
+ n_total: normalized.n_total ?? null,
+ runAt: normalized.date_added_to_graph ?? null,
+ };
+ }
+ }
+ }
+ // Wave 2 failure (null neighbors or no output node) is non-fatal:
+ // latestRun stays null, other fields still valid.
+ }
+
+ return {
+ taskSlug,
+ refId,
+ name: entry.name,
+ reason: entry.reason ?? null,
+ recursion: entry.recursion ?? false,
+ rubricCount,
+ contestedCount,
+ latestRun,
+ fixChainDepth,
+ isDefault: false,
+ };
+}
+
+// ── Public API ────────────────────────────────────────────────────────────────
+
+/**
+ * Fetch the minimal initial-render summary for all enrolled tasks.
+ *
+ * - `name`, `reason`, and `recursion` are passed through from each
+ * `RecursionEvalSetEntry` — `listRecursionEvalSets` already resolves these.
+ * - `ref_id` is already present on each entry — `resolveEvalSetRefIdBySlug`
+ * is never called.
+ * - All tasks run via `Promise.all`; per-task failures never propagate to the
+ * outer promise.
+ *
+ * **Log discipline:** `logger.warn` on per-task failure with `{ taskSlug, refId }`
+ * only. `config` and all fields derived from it (including `swarmApiKey`) are
+ * never logged.
+ */
+export async function fetchRecursionTaskSummary(
+ config: JarvisConnectionConfig,
+ entries: RecursionEvalSetEntry[],
+): Promise {
+ return Promise.all(
+ entries.map(async (entry) => {
+ try {
+ return await fetchOneTaskSummary(config, entry);
+ } catch (err) {
+ // fetchOneTaskSummary is designed to never throw, but we guard here as
+ // a belt-and-suspenders against unexpected runtime errors.
+ logger.warn(
+ "[legal/benchmarks/recursion/summary] Unexpected error for task — returning defaults",
+ "legal",
+ {
+ taskSlug: entry.id,
+ refId: entry.ref_id,
+ error: err instanceof Error ? err.message : String(err),
+ },
+ );
+ return makeDefault(entry);
+ }
+ }),
+ );
+}