From 45854b50b99e6ca446b771badaf84e0709b518d5 Mon Sep 17 00:00:00 2001 From: pitoi Date: Mon, 10 Aug 2026 23:11:39 +0000 Subject: [PATCH] Generated with Hive: Show C&H ingest runs in Legal Benchmarks tab and fix CNH webhook persistence --- .../components/BenchmarkRunsHistory.test.tsx | 117 ++++++++++++++++- .../hooks/useLegalBenchmarkRunList.test.ts | 119 ++++++++++++++++-- .../unit/services/stakwork-run.test.ts | 37 +++++- src/app/api/stakwork/runs/route.ts | 16 ++- .../api/webhook/stakwork/response/route.ts | 1 + src/components/legal/BenchmarkRunsHistory.tsx | 81 ++++++++---- src/hooks/useLegalBenchmarkRunList.ts | 80 ++++++++---- src/services/stakwork-run.ts | 4 +- src/types/stakwork.ts | 2 +- 9 files changed, 388 insertions(+), 69 deletions(-) diff --git a/src/__tests__/unit/components/BenchmarkRunsHistory.test.tsx b/src/__tests__/unit/components/BenchmarkRunsHistory.test.tsx index 44700bc18c..104c779840 100644 --- a/src/__tests__/unit/components/BenchmarkRunsHistory.test.tsx +++ b/src/__tests__/unit/components/BenchmarkRunsHistory.test.tsx @@ -29,6 +29,7 @@ const makeRun = (overrides: Partial<{ generateReport: boolean; reportStatus: string; reportChatPath: string; + runType: string; }> = {}) => ({ id: "runner-1", workspaceId: WORKSPACE_ID, @@ -46,9 +47,22 @@ const makeRun = (overrides: Partial<{ generateReport: undefined as boolean | undefined, reportStatus: undefined as string | undefined, reportChatPath: undefined as string | undefined, + runType: "LEGAL_BENCHMARK_RUNNER", ...overrides, }); +const makeCnhRun = (overrides: Partial<{ id: string; status: string; createdAt: string }> = {}) => + makeRun({ + id: "cnh-1", + taskSlug: "", + taskTitle: "C&H Ingest", + runType: "LEGAL_BENCHMARK_CNH_INGEST", + n_passed: undefined, + n_total: undefined, + all_pass: undefined, + ...overrides, + }); + // ─── Module mocks ───────────────────────────────────────────────────────────── const mockSetExpandedId = vi.fn(); @@ -57,6 +71,7 @@ const mockRefetch = vi.fn(); const mockUseList = vi.fn((_workspaceId: string | undefined) => ({ runs: [makeRun()], total: 1, + runnerTotal: 1, isLoading: false, error: null, refetch: mockRefetch, @@ -171,8 +186,8 @@ vi.mock("@/components/legal/HillClimbChart", () => ({ })); vi.mock("@/components/ui/badge", () => ({ - Badge: ({ children, className }: { children?: React.ReactNode; className?: string }) => - React.createElement("span", { "data-testid": "badge", className }, children), + Badge: ({ children, className, ...rest }: { children?: React.ReactNode; className?: string; [key: string]: unknown }) => + React.createElement("span", { "data-testid": "badge", className, ...rest }, children), })); vi.mock("date-fns", () => ({ @@ -193,6 +208,7 @@ describe("BenchmarkRunsHistory", () => { mockUseList.mockReturnValue({ runs: [makeRun()], total: 1, + runnerTotal: 1, isLoading: false, error: null, refetch: mockRefetch, @@ -979,4 +995,101 @@ describe("BenchmarkRunsHistory", () => { expect(screen.queryByTestId("results-b1")).toBeNull(); expect(mockSetExpandedId).toHaveBeenLastCalledWith(null); }); + + // ─── CNH Ingest row tests ────────────────────────────────────────────────── + + it("CNH row renders 'C&H' badge and 'C&H Ingest' label", () => { + mockUseList.mockReturnValue({ + runs: [makeCnhRun()], + total: 0, + runnerTotal: 0, + isLoading: false, + error: null, + refetch: mockRefetch, + setExpandedId: mockSetExpandedId, + }); + render(React.createElement(BenchmarkRunsHistory)); + // jsdom decodes HTML entities, so getByText matches decoded text + expect(screen.getByTestId("cnh-badge")).toBeInTheDocument(); + // "C&H Ingest" appears as text content (entities decoded by jsdom) + expect(screen.getAllByText(/C&H Ingest/i).length).toBeGreaterThan(0); + }); + + it("clicking a CNH row does NOT expand a detail panel", async () => { + mockUseList.mockReturnValue({ + runs: [makeCnhRun()], + total: 0, + runnerTotal: 0, + isLoading: false, + error: null, + refetch: mockRefetch, + setExpandedId: mockSetExpandedId, + }); + const user = userEvent.setup(); + render(React.createElement(BenchmarkRunsHistory)); + + const row = screen.getByTestId("run-row-cnh-1"); + await user.click(row); + + // No LegalBenchmarkResults should be mounted + expect(screen.queryByTestId("results-cnh-1")).toBeNull(); + // setExpandedId must NOT have been called with the CNH run id + expect(mockSetExpandedId).not.toHaveBeenCalledWith("cnh-1"); + }); + + it("CNH row Score and Report columns show '—'", () => { + mockUseList.mockReturnValue({ + runs: [makeCnhRun()], + total: 0, + runnerTotal: 0, + isLoading: false, + error: null, + refetch: mockRefetch, + setExpandedId: mockSetExpandedId, + }); + render(React.createElement(BenchmarkRunsHistory)); + // Both Score and Report cells render '—' for CNH rows (no all_pass, no report) + expect(screen.getAllByText("—").length).toBeGreaterThanOrEqual(2); + expect(screen.queryByText("PASS")).toBeNull(); + expect(screen.queryByText("FAIL")).toBeNull(); + }); + + it("CNH row does NOT appear in the task filter dropdown", () => { + mockUseList.mockReturnValue({ + runs: [makeRun(), makeCnhRun()], + total: 1, + runnerTotal: 1, + isLoading: false, + error: null, + refetch: mockRefetch, + setExpandedId: mockSetExpandedId, + }); + render(React.createElement(BenchmarkRunsHistory)); + // The runner task should appear as an option + expect(screen.getByTestId("task-filter-option-antitrust/task-1")).toBeInTheDocument(); + // CNH rows have no taskSlug — they must NOT appear in the dropdown + expect(screen.queryByTestId("task-filter-option-")).toBeNull(); + }); + + it("window note uses runnerTotal — CNH rows don't inflate the 'loaded N of total' count", () => { + // 12 runner rows + 1 CNH row — runnerTotal=150 means we cap message on runner total only + const runs = [ + ...Array.from({ length: 12 }, (_, i) => + makeRun({ id: `s-${i}`, status: "COMPLETED", all_pass: true }), + ), + makeCnhRun({ id: "cnh-latest" }), + ]; + mockUseList.mockReturnValue({ + runs, + total: 150, + runnerTotal: 150, + isLoading: false, + error: null, + refetch: mockRefetch, + setExpandedId: mockSetExpandedId, + }); + render(React.createElement(BenchmarkRunsHistory)); + const note = screen.getByTestId("window-note"); + expect(note.textContent).toContain("Only the latest 100 of 150 runs are loaded"); + }); }); diff --git a/src/__tests__/unit/hooks/useLegalBenchmarkRunList.test.ts b/src/__tests__/unit/hooks/useLegalBenchmarkRunList.test.ts index 95c07cec4b..87d53b73db 100644 --- a/src/__tests__/unit/hooks/useLegalBenchmarkRunList.test.ts +++ b/src/__tests__/unit/hooks/useLegalBenchmarkRunList.test.ts @@ -26,12 +26,14 @@ global.fetch = vi.fn(); const makeRow = (overrides: Partial<{ id: string; + type: string; status: string; projectId: number | null; result: string | null; createdAt: string; }> = {}) => ({ id: "runner-abc", + type: "LEGAL_BENCHMARK_RUNNER", workspaceId: "ws-cuid-123", status: "COMPLETED", projectId: 42, @@ -49,11 +51,36 @@ const makeRow = (overrides: Partial<{ ...overrides, }); -function mockFetchOk(runs: ReturnType[], total?: number) { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ runs, total: total ?? runs.length }), - } as Response); +const makeCnhRow = (overrides: Partial<{ id: string; status: string; createdAt: string }> = {}) => ({ + id: "cnh-xyz", + type: "LEGAL_BENCHMARK_CNH_INGEST", + workspaceId: "ws-cuid-123", + status: "COMPLETED", + projectId: 55, + result: null, + createdAt: new Date("2025-01-02T10:00:00Z").toISOString(), + updatedAt: new Date("2025-01-02T10:05:00Z").toISOString(), + ...overrides, +}); + +/** + * Mock fetch for both parallel calls (runner + CNH). + * First call receives runner rows, second call receives CNH rows (empty by default). + */ +function mockFetchOk( + runnerRuns: ReturnType[], + total?: number, + cnhRuns: ReturnType[] = [], +) { + vi.mocked(global.fetch) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ runs: runnerRuns, total: total ?? runnerRuns.length }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ runs: cnhRuns, total: cnhRuns.length }), + } as Response); } function mockFetchFail() { @@ -87,7 +114,26 @@ describe("useLegalBenchmarkRunList", () => { expect(url).not.toContain("workspaceId=openlaw"); }); - it("includes type=LEGAL_BENCHMARK_RUNNER and limit=100 in query params", async () => { + it("issues two parallel fetch calls — one for RUNNER, one for CNH_INGEST", async () => { + mockFetchOk([makeRow()]); + + const { result } = renderHook(() => useLegalBenchmarkRunList("ws-cuid-123")); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const urls = vi.mocked(global.fetch).mock.calls.map((c) => String(c[0])); + // First call — runner + expect(urls[0]).toContain("type=LEGAL_BENCHMARK_RUNNER"); + expect(urls[0]).toContain("limit=100"); + expect(urls[0]).toContain("includeResult=true"); + // Second call — CNH ingest + expect(urls[1]).toContain("type=LEGAL_BENCHMARK_CNH_INGEST"); + expect(urls[1]).toContain("includeResult=true"); + // Both share the same workspaceId + expect(urls[0]).toContain("workspaceId=ws-cuid-123"); + expect(urls[1]).toContain("workspaceId=ws-cuid-123"); + }); + + it("includes type=LEGAL_BENCHMARK_RUNNER and limit=100 in query params (runner fetch)", async () => { mockFetchOk([makeRow()]); const { result } = renderHook(() => useLegalBenchmarkRunList("ws-cuid-123")); @@ -99,6 +145,63 @@ describe("useLegalBenchmarkRunList", () => { expect(url).toContain("includeResult=true"); }); + it("merges runner and CNH rows sorted by createdAt descending", async () => { + const runnerRow = makeRow({ + id: "runner-old", + createdAt: new Date("2025-01-01T10:00:00Z").toISOString(), + }); + const cnhRow = makeCnhRow({ + id: "cnh-new", + createdAt: new Date("2025-01-03T10:00:00Z").toISOString(), + }); + mockFetchOk([runnerRow], 1, [cnhRow]); + + const { result } = renderHook(() => useLegalBenchmarkRunList("ws-cuid-123")); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.runs).toHaveLength(2); + // CNH row is newer — must come first + expect(result.current.runs[0].id).toBe("cnh-new"); + expect(result.current.runs[1].id).toBe("runner-old"); + }); + + it("CNH row has runType=LEGAL_BENCHMARK_CNH_INGEST and taskTitle='C&H Ingest'", async () => { + mockFetchOk([], 0, [makeCnhRow()]); + + const { result } = renderHook(() => useLegalBenchmarkRunList("ws-cuid-123")); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.runs).toHaveLength(1); + const row = result.current.runs[0]; + expect(row.runType).toBe("LEGAL_BENCHMARK_CNH_INGEST"); + expect(row.taskTitle).toBe("C&H Ingest"); + expect(row.taskSlug).toBe(""); + expect(row.n_passed).toBeUndefined(); + expect(row.all_pass).toBeUndefined(); + }); + + it("runner total is exposed as runnerTotal — CNH count does not inflate it", async () => { + mockFetchOk([makeRow()], 150, [makeCnhRow()]); + + const { result } = renderHook(() => useLegalBenchmarkRunList("ws-cuid-123")); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + // total/runnerTotal = 150 (runner only) — CNH total (1) must not add to it + expect(result.current.total).toBe(150); + expect(result.current.runnerTotal).toBe(150); + // But merged runs array contains both + expect(result.current.runs).toHaveLength(2); + }); + + it("runner rows have runType=LEGAL_BENCHMARK_RUNNER", async () => { + mockFetchOk([makeRow()]); + + const { result } = renderHook(() => useLegalBenchmarkRunList("ws-cuid-123")); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.runs[0].runType).toBe("LEGAL_BENCHMARK_RUNNER"); + }); + it("maps run rows to BenchmarkRunListRow with parsed taskTitle and taskSlug", async () => { mockFetchOk([makeRow()]); @@ -470,8 +573,8 @@ describe("useLegalBenchmarkRunList", () => { await Promise.resolve(); }); - // Only one additional fetch call despite three rapid events - expect(vi.mocked(global.fetch).mock.calls.length).toBe(fetchCallsBefore + 1); + // Only the first event triggers a fetch (2 parallel calls); 2nd and 3rd are dropped by isFetchingRef + expect(vi.mocked(global.fetch).mock.calls.length).toBe(fetchCallsBefore + 2); }); it("unbinds STAKWORK_RUN_UPDATE event handler on unmount", async () => { diff --git a/src/__tests__/unit/services/stakwork-run.test.ts b/src/__tests__/unit/services/stakwork-run.test.ts index 02648b78bc..3e0292db91 100644 --- a/src/__tests__/unit/services/stakwork-run.test.ts +++ b/src/__tests__/unit/services/stakwork-run.test.ts @@ -3072,7 +3072,7 @@ describe("Stakwork Run Service", () => { expect(result.offset).toBe(0); }); - test("should filter runs by type and status", async () => { + test("should filter runs by type and status — single type collapses to scalar", async () => { const mockWorkspace = { id: "ws-1", members: [{ userId: "user-1" }], @@ -3085,7 +3085,7 @@ describe("Stakwork Run Service", () => { await getStakworkRuns( { workspaceId: "ws-1", - type: StakworkRunType.ARCHITECTURE, + type: [StakworkRunType.ARCHITECTURE], status: WorkflowStatus.COMPLETED, limit: 10, offset: 0, @@ -3093,6 +3093,7 @@ describe("Stakwork Run Service", () => { "user-1" ); + // Single-element array collapses to a plain enum value in the where clause. expect(db.stakworkRun.findMany).toHaveBeenCalledWith({ where: { workspaceId: "ws-1", @@ -3106,6 +3107,38 @@ describe("Stakwork Run Service", () => { }); }); + test("should filter runs by multiple types using Prisma { in: [...] }", async () => { + const mockWorkspace = { + id: "ws-1", + members: [{ userId: "user-1" }], + }; + + mockedDb.workspace.findUnique = vi.fn().mockResolvedValue(mockWorkspace); + mockedDb.stakworkRun.count = vi.fn().mockResolvedValue(2); + mockedDb.stakworkRun.findMany = vi.fn().mockResolvedValue([]); + + await getStakworkRuns( + { + workspaceId: "ws-1", + type: [StakworkRunType.LEGAL_BENCHMARK_RUNNER, StakworkRunType.LEGAL_BENCHMARK_CNH_INGEST], + limit: 10, + offset: 0, + }, + "user-1" + ); + + expect(db.stakworkRun.findMany).toHaveBeenCalledWith({ + where: { + workspaceId: "ws-1", + type: { in: [StakworkRunType.LEGAL_BENCHMARK_RUNNER, StakworkRunType.LEGAL_BENCHMARK_CNH_INGEST] }, + }, + orderBy: { createdAt: "desc" }, + skip: 0, + take: 10, + select: expect.any(Object), + }); + }); + test("should throw error when workspace not found", async () => { mockedDb.workspace.findUnique = vi.fn().mockResolvedValue(null); diff --git a/src/app/api/stakwork/runs/route.ts b/src/app/api/stakwork/runs/route.ts index 6b6800a977..321ccf652f 100644 --- a/src/app/api/stakwork/runs/route.ts +++ b/src/app/api/stakwork/runs/route.ts @@ -39,13 +39,17 @@ export async function GET(request: NextRequest) { }; if (type) { - if (!Object.values(StakworkRunType).includes(type as StakworkRunType)) { - return NextResponse.json( - { error: `Invalid type: ${type}` }, - { status: 400 } - ); + const tokens = type.split(",").map((t) => t.trim()); + const validValues = Object.values(StakworkRunType) as string[]; + for (const token of tokens) { + if (!validValues.includes(token)) { + return NextResponse.json( + { error: `Invalid type: ${token}` }, + { status: 400 } + ); + } } - queryData.type = type; + queryData.type = tokens as StakworkRunType[]; } if (featureId) { diff --git a/src/app/api/webhook/stakwork/response/route.ts b/src/app/api/webhook/stakwork/response/route.ts index 93c8b1e69d..b10c5dca54 100644 --- a/src/app/api/webhook/stakwork/response/route.ts +++ b/src/app/api/webhook/stakwork/response/route.ts @@ -10,6 +10,7 @@ const LEGAL_BENCHMARK_TYPES = new Set([ StakworkRunType.LEGAL_BENCHMARK_RUNNER, StakworkRunType.LEGAL_BENCHMARK_SCORER, StakworkRunType.LEGAL_BENCHMARK_EVAL, + StakworkRunType.LEGAL_BENCHMARK_CNH_INGEST, ]); /** diff --git a/src/components/legal/BenchmarkRunsHistory.tsx b/src/components/legal/BenchmarkRunsHistory.tsx index 6d9215cb54..766c6f42ce 100644 --- a/src/components/legal/BenchmarkRunsHistory.tsx +++ b/src/components/legal/BenchmarkRunsHistory.tsx @@ -30,7 +30,7 @@ import { import { LegalBenchmarkResults } from "@/components/legal/LegalBenchmarkResults"; import { StakworkRunLink } from "@/components/legal/StakworkRunLink"; import { HillClimbChart } from "@/components/legal/HillClimbChart"; -import { WorkflowStatus } from "@prisma/client"; +import { WorkflowStatus, StakworkRunType } from "@prisma/client"; import type { EvalTriggerOutput } from "@/lib/harvey-lab/eval-normalizers"; export const ALL_TASKS = "all"; @@ -41,7 +41,8 @@ interface TaskOption { count: number; } -/** Unique tasks across the loaded runs, preserving most-recent-first order */ +/** Unique tasks across the loaded runs, preserving most-recent-first order. + * CNH rows (no taskSlug) are intentionally excluded. */ function buildTaskOptions(runs: BenchmarkRunListRow[]): TaskOption[] { const map = new Map(); for (const run of runs) { @@ -100,6 +101,9 @@ function resolveModelDisplay(run: BenchmarkRunListRow) { return { exec, judge, hasAny }; } +const isCnhRun = (run: BenchmarkRunListRow) => + run.runType === StakworkRunType.LEGAL_BENCHMARK_CNH_INGEST; + export type RunListHookResult = ReturnType; interface BenchmarkRunsHistoryProps { @@ -128,7 +132,7 @@ export function BenchmarkRunsHistory({ const internalList = useLegalBenchmarkRunList( runListProp ? undefined : workspaceId, ); - const { runs, total, isLoading, error, setExpandedId } = + const { runs, total: runnerTotal, isLoading, error, setExpandedId } = runListProp ?? internalList; const [expandedRunId, setExpandedRunId] = useState(null); @@ -152,6 +156,12 @@ export function BenchmarkRunsHistory({ return; } + // CNH rows cannot be expanded — skip expansion but still scroll/highlight + if (isCnhRun(runs.find((r) => r.id === runId)!)) { + onFocusHandled?.(); + return; + } + // (a) Reset filter directly — do NOT call handleFilterChange/handleReset // because handleReset calls setExpandedId(null), which triggers an // unwanted refetch and would immediately collapse the row we're opening. @@ -230,8 +240,12 @@ export function BenchmarkRunsHistory({ [selectedTask, visibleRuns], ); - const handleToggleExpand = (runId: string) => { - const next = expandedRunId === runId ? null : runId; + const handleToggleExpand = (run: BenchmarkRunListRow) => { + // CNH ingest rows are non-expandable: useLegalBenchmarkRun hardcodes + // type=LEGAL_BENCHMARK_RUNNER so a CNH run ID would never be found, + // producing a permanent spinner or empty detail panel. + if (isCnhRun(run)) return; + const next = expandedRunId === run.id ? null : run.id; setExpandedRunId(next); setExpandedId(next); }; @@ -315,8 +329,8 @@ export function BenchmarkRunsHistory({ > Showing {visibleRuns.length} of {filteredRuns.length} loaded runs — back to the {windowSize} most recent scored runs. - {total > RUN_LIST_LIMIT && - ` Only the latest ${RUN_LIST_LIMIT} of ${total} runs are loaded.`} + {runnerTotal > RUN_LIST_LIMIT && + ` Only the latest ${RUN_LIST_LIMIT} of ${runnerTotal} runs are loaded.`} )} @@ -342,26 +356,45 @@ export function BenchmarkRunsHistory({ if (el) rowRefs.current.set(run.id, el); else rowRefs.current.delete(run.id); }} - className="border-b last:border-0 cursor-pointer hover:bg-muted/30 transition-colors" - onClick={() => handleToggleExpand(run.id)} + className={`border-b last:border-0 transition-colors ${ + isCnhRun(run) + ? "cursor-default" + : "cursor-pointer hover:bg-muted/30" + }`} + onClick={() => handleToggleExpand(run)} data-testid={`run-row-${run.id}`} > -
- {run.taskTitle || "(Unknown task)"} -
- {run.taskSlug && ( -
{run.taskSlug}
- )} - {(() => { - const { exec, judge, hasAny } = resolveModelDisplay(run); - if (!hasAny) return null; - return ( -
- {exec} · Judge: {judge} + {isCnhRun(run) ? ( +
+ + C&H + + C&H Ingest +
+ ) : ( +
+
+ {run.taskTitle || "(Unknown task)"}
- ); - })()} + {run.taskSlug && ( +
{run.taskSlug}
+ )} + {(() => { + const { exec, judge, hasAny } = resolveModelDisplay(run); + if (!hasAny) return null; + return ( +
+ {exec} · Judge: {judge} +
+ ); + })()} +
+ )} )} - {expandedRunId === run.id && ( + {expandedRunId === run.id && !isCnhRun(run) && ( ?chat=" */ reportChatPath?: string; + /** Run type — present for mixed lists so components can distinguish CNH rows */ + runType?: StakworkRunType; } interface UseLegalBenchmarkRunListResult { runs: BenchmarkRunListRow[]; + /** Total runner (LEGAL_BENCHMARK_RUNNER) runs available on the server. + * Does NOT include CNH ingest runs so the summary strip / "loaded N of total" + * message is not inflated. */ total: number; + /** Alias for total — benchmark-runner total only */ + runnerTotal: number; isLoading: boolean; error: string | null; refetch: () => Promise; @@ -42,6 +49,19 @@ interface UseLegalBenchmarkRunListResult { } const POLL_INTERVAL_MS = 15_000; +/** Separate limit for CNH ingest runs so they cannot displace runner rows. */ +const CNH_RUN_LIMIT = 50; + +type RawRow = { + id: string; + workspaceId: string; + type: string; + status: string; + projectId: number | null; + result: string | null; + createdAt: string; + updatedAt: string; +}; export function useLegalBenchmarkRunList( workspaceId: string | undefined, @@ -62,33 +82,38 @@ export function useLegalBenchmarkRunList( const fetchRuns = useCallback(async () => { if (!workspaceId) return; try { - const res = await fetch( - `/api/stakwork/runs?type=${StakworkRunType.LEGAL_BENCHMARK_RUNNER}&workspaceId=${workspaceId}&limit=${RUN_LIST_LIMIT}&includeResult=true`, - ); - if (!res.ok) throw new Error("Failed to fetch runs"); - const data = await res.json(); - - const rawRows: Array<{ - id: string; - workspaceId: string; - status: string; - projectId: number | null; - result: string | null; - createdAt: string; - updatedAt: string; - }> = data.runs ?? []; - - const mapped: BenchmarkRunListRow[] = rawRows.map((r) => { - const parsed = parseBenchmarkRunResult(r.result); + // Two parallel fetches to prevent CNH runs from displacing runner rows + // within the RUN_LIST_LIMIT window. + const [runnerRes, cnhRes] = await Promise.all([ + fetch( + `/api/stakwork/runs?type=${StakworkRunType.LEGAL_BENCHMARK_RUNNER}&workspaceId=${workspaceId}&limit=${RUN_LIST_LIMIT}&includeResult=true`, + ), + fetch( + `/api/stakwork/runs?type=${StakworkRunType.LEGAL_BENCHMARK_CNH_INGEST}&workspaceId=${workspaceId}&limit=${CNH_RUN_LIMIT}&includeResult=true`, + ), + ]); + + if (!runnerRes.ok) throw new Error("Failed to fetch runs"); + // CNH fetch failure is non-fatal — degrade gracefully + const runnerData = await runnerRes.json(); + const cnhData = cnhRes.ok ? await cnhRes.json() : { runs: [], total: 0 }; + + const runnerRows: RawRow[] = runnerData.runs ?? []; + const cnhRows: RawRow[] = cnhData.runs ?? []; + + const mapRow = (r: RawRow): BenchmarkRunListRow => { + const isCnh = r.type === StakworkRunType.LEGAL_BENCHMARK_CNH_INGEST; + const parsed = isCnh ? null : parseBenchmarkRunResult(r.result); return { id: r.id, workspaceId: r.workspaceId, status: r.status as WorkflowStatus, projectId: r.projectId, taskSlug: parsed?.taskSlug ?? "", - taskTitle: parsed?.taskTitle ?? "", + taskTitle: isCnh ? "C&H Ingest" : (parsed?.taskTitle ?? ""), createdAt: r.createdAt, updatedAt: r.updatedAt, + runType: r.type as StakworkRunType, n_passed: parsed?.n_passed, n_total: parsed?.n_total, all_pass: parsed?.all_pass, @@ -98,7 +123,6 @@ export function useLegalBenchmarkRunList( reportStatus: parsed?.reportStatus, reportChatPath: parsed?.reportChatPath, // Unified judge precedence: operator choice takes priority over runner-echoed value. - // Format mirrors stakwork-run.ts — if the server-side format string changes, update this line to match. judgeNotes: parsed?.n_passed != null && parsed?.n_total != null ? `${parsed.n_passed}/${parsed.n_total} criteria passed${ @@ -108,11 +132,17 @@ export function useLegalBenchmarkRunList( }` : undefined, }; - }); + }; + + // Merge and sort combined list by createdAt descending + const merged = [...runnerRows.map(mapRow), ...cnhRows.map(mapRow)].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); - runsRef.current = mapped; - setRuns(mapped); - setTotal(data.total ?? mapped.length); + runsRef.current = merged; + setRuns(merged); + // Expose only the runner total — CNH total must not inflate this count + setTotal(runnerData.total ?? runnerRows.length); setError(null); } catch (err) { setError(err instanceof Error ? err.message : "Unknown error"); @@ -205,5 +235,5 @@ export function useLegalBenchmarkRunList( [fetchRuns, startPolling, hasActiveRuns], ); - return { runs, total, isLoading, error, refetch: fetchRuns, setExpandedId }; + return { runs, total, runnerTotal: total, isLoading, error, refetch: fetchRuns, setExpandedId }; } diff --git a/src/services/stakwork-run.ts b/src/services/stakwork-run.ts index 7c4d7481cd..f1dd2ccc88 100644 --- a/src/services/stakwork-run.ts +++ b/src/services/stakwork-run.ts @@ -1957,7 +1957,9 @@ export async function getStakworkRuns( // Build where clause const where: Prisma.StakworkRunWhereInput = { workspaceId: query.workspaceId, - ...(query.type && { type: query.type }), + ...(query.type?.length && { + type: query.type.length === 1 ? query.type[0] : { in: query.type }, + }), ...(query.featureId && { featureId: query.featureId }), ...(query.status && { status: query.status }), }; diff --git a/src/types/stakwork.ts b/src/types/stakwork.ts index c973d86c19..50e25f2e4b 100644 --- a/src/types/stakwork.ts +++ b/src/types/stakwork.ts @@ -94,7 +94,7 @@ export const UpdateStakworkRunDecisionSchema = z.object({ export const StakworkRunQuerySchema = z.object({ workspaceId: z.string().cuid(), - type: z.nativeEnum(StakworkRunType).optional(), + type: z.array(z.nativeEnum(StakworkRunType)).optional(), featureId: z.string().cuid().optional(), status: z.nativeEnum(WorkflowStatus).optional(), limit: z.number().int().positive().max(100).optional().default(20),