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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
414 changes: 414 additions & 0 deletions src/__tests__/unit/app/api/recursion-summary-route.test.ts

Large diffs are not rendered by default.

405 changes: 405 additions & 0 deletions src/__tests__/unit/components/legal/RecursionBox.test.tsx

Large diffs are not rendered by default.

402 changes: 402 additions & 0 deletions src/__tests__/unit/services/legal-benchmark-recursion-summary.test.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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<string, { message: string; status: number }> = {
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 });
}
}
12 changes: 11 additions & 1 deletion src/app/w/[slug]/legal/benchmarks/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,17 @@
}

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();

Check failure on line 33 in src/app/w/[slug]/legal/benchmarks/page.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

src/__tests__/unit/components/LegalBenchmarksPage.test.tsx > LegalBenchmarksPage > renders the Recursion tab when its trigger is clicked

TypeError: fetchSummary is not a function ❯ src/app/w/[slug]/legal/benchmarks/page.tsx:33:12 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:25989:20 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom-client.development.js:13249:29 ❯ commitHookPassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13336:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:15484:13 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:15439:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:15718:11 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:15439:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:15476:11
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading]);

return (
<RecursionList
Expand Down
Loading
Loading