From 1603c5dc3ca6cb92684ad863600dcf505cd7191c Mon Sep 17 00:00:00 2001 From: Paul Itoi <814886+pitoi@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:33:17 +0000 Subject: [PATCH 1/2] [Jamie] Fix RecursionCard: ScoreBadge hardcoded loading/error, displayLatest fallback, canExpand fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes 22 failing unit tests in `RecursionBox.test.tsx`. Three bugs in the deferred-fetch refactor: 1. `ScoreBadge` is called with hardcoded `isLoading={false}` / `error={null}` — making `score-loading` and `score-error` unreachable. 2. `displayLatest` uses `summaryLatest` unconditionally while collapsed, ignoring hook data — cards show "no runs yet" when `entry.latestRun` isn't set. 3. `canExpand` ignores `rawAttempts.length` while collapsed — expand toggle never appears when `fixChainDepth`/`latestRun` aren't set but hook data is available. --- .../app/api/recursion-summary-route.test.ts | 414 ++++++++++++++++++ .../components/legal/RecursionBox.test.tsx | 405 +++++++++++++++++ .../legal-benchmark-recursion-summary.test.ts | 402 +++++++++++++++++ .../benchmarks/recursion/summary/route.ts | 205 +++++++++ src/components/legal/RecursionBox.tsx | 207 +++++---- src/hooks/useLegalBenchmarkRecursionList.ts | 128 +++++- src/lib/harvey-lab/jarvis-expand.ts | 52 +++ src/services/legal-benchmark-graph-scores.ts | 40 +- .../legal-benchmark-recursion-summary.ts | 194 ++++++++ 9 files changed, 1921 insertions(+), 126 deletions(-) create mode 100644 src/__tests__/unit/app/api/recursion-summary-route.test.ts create mode 100644 src/__tests__/unit/components/legal/RecursionBox.test.tsx create mode 100644 src/__tests__/unit/services/legal-benchmark-recursion-summary.test.ts create mode 100644 src/app/api/workspaces/[slug]/legal/benchmarks/recursion/summary/route.ts create mode 100644 src/lib/harvey-lab/jarvis-expand.ts create mode 100644 src/services/legal-benchmark-recursion-summary.ts 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/components/legal/RecursionBox.tsx b/src/components/legal/RecursionBox.tsx index 9ab088dd9a..24ba183fa1 100644 --- a/src/components/legal/RecursionBox.tsx +++ b/src/components/legal/RecursionBox.tsx @@ -56,6 +56,7 @@ function ScoreBadge({ roster, contestedRubrics, workspaceSlug, + onContestedClick, }: { isLoading: boolean; error: string | null; @@ -67,6 +68,12 @@ function ScoreBadge({ contestedRubrics: GraphRubric[]; /** Enables per-rubric Graph Explorer links inside the popover. */ workspaceSlug: string; + /** + * Called when the user opens the contested popover. Additive — callers that + * don't need lazy rubric loading pass nothing. Used to gate `useBenchmarkRubrics` + * behind user interaction rather than mount. + */ + onContestedClick?: () => 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 gated behind `expanded` ───────────────────────────── + // While collapsed, pass undefined/empty so the hook is a no-op. + // The hook's existing `if (!taskSlug) return` guard handles the no-op case. const { attempts: rawAttempts, attemptRows, @@ -260,14 +283,41 @@ function RecursionCard({ entry, refetch }: RecursionCardProps) { isLoading: historyLoading, error: historyError, } = useEvalRunHistory({ - refId: entry.refId, - slug: entry.id, + refId: expanded ? entry.refId : undefined, + slug: expanded ? entry.id : "", }); + // ── useBenchmarkRubrics gated behind rosterRequested ───────────────────── + // Only fires when the user opens the contested-rubrics popover. + // Eliminates 30 on-mount Lambda calls while preserving per-rubric popover + // detail on demand. + const { rubrics: graphRubrics } = useBenchmarkRubrics( + rosterRequested ? entry.id : undefined, + ); + // Graph-first denominator: the task's EvalRequirement roster minus contested - // definitions. Null (no roster / loading) leaves attempt counts untouched. - const { rubrics: graphRubrics } = useBenchmarkRubrics(entry.id); - const roster = useMemo(() => rosterSummary(graphRubrics), [graphRubrics]); + // 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 +342,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 +361,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 +448,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 +490,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 +520,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. */}
- {/* 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 */} )} - {/* Consolidated Report trigger button */}