From d834dfbafcae772a1cfc08d7c425de655796a55d Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 7 Apr 2026 11:16:26 -0700 Subject: [PATCH 1/2] feat(web-ui): add Run Gates button with live gate progress to PROOF9 page (#566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: cache proof run results and expose GET /api/v2/proof/runs/{run_id} polling endpoint (RunStatusResponse model); 4 new tests - Types: add RunProofResponse, RunStatusResponse, GateRunStatus, GateRunEntry - API client: add proofApi.startRun() and proofApi.getRun() - Hook: useProofRun manages idle→starting→polling→complete/error lifecycle with 2s interval poll and cleanup on unmount; 7 new tests - Components: GateRunPanel (per-gate status badges) and GateRunBanner (pass/fail result with retry) added to proof component barrel - Page: Run Gates button in header (disabled during active run), GateRunPanel + GateRunBanner + error state wired below filter controls, mutate() called on run completion to refresh requirements table Closes #566 --- codeframe/ui/routers/proof_v2.py | 57 +++++- tests/ui/test_proof_v2.py | 44 +++++ .../src/__tests__/hooks/useProofRun.test.ts | 171 ++++++++++++++++++ web-ui/src/app/proof/page.tsx | 60 +++++- web-ui/src/components/proof/GateRunBanner.tsx | 40 ++++ web-ui/src/components/proof/GateRunPanel.tsx | 46 +++++ web-ui/src/components/proof/index.ts | 2 + web-ui/src/hooks/index.ts | 1 + web-ui/src/hooks/useProofRun.ts | 131 ++++++++++++++ web-ui/src/lib/api.ts | 22 +++ web-ui/src/types/index.ts | 24 +++ 11 files changed, 590 insertions(+), 8 deletions(-) create mode 100644 web-ui/src/__tests__/hooks/useProofRun.test.ts create mode 100644 web-ui/src/components/proof/GateRunBanner.tsx create mode 100644 web-ui/src/components/proof/GateRunPanel.tsx create mode 100644 web-ui/src/hooks/useProofRun.ts diff --git a/codeframe/ui/routers/proof_v2.py b/codeframe/ui/routers/proof_v2.py index b40e410e..08e08c43 100644 --- a/codeframe/ui/routers/proof_v2.py +++ b/codeframe/ui/routers/proof_v2.py @@ -45,6 +45,9 @@ router = APIRouter(prefix="/api/v2/proof", tags=["proof-v2"]) +# Module-level cache: run_id → serialized RunProofResponse dict +_run_cache: dict[str, dict] = {} + # ============================================================================ # Request / Response Models @@ -146,6 +149,16 @@ class RunProofResponse(BaseModel): message: str +class RunStatusResponse(BaseModel): + """Response for GET /runs/{run_id} — poll a completed run.""" + + run_id: str + status: str # "running" | "complete" + results: dict[str, list[dict[str, Any]]] + passed: bool + message: str + + class ProofStatusResponse(BaseModel): """Aggregated proof status response.""" @@ -333,12 +346,23 @@ async def run_proof_endpoint( for req_id, gate_results in results.items() } - return RunProofResponse( + passed = all( + satisfied + for gate_results in results.values() + for _, satisfied in gate_results + ) + response = RunProofResponse( success=True, run_id=run_id, results=serialized, message=f"Proof run complete: {len(results)} requirement(s) evaluated.", ) + _run_cache[run_id] = { + "results": serialized, + "passed": passed, + "message": response.message, + } + return response except Exception as e: logger.error("Proof run failed: %s", e, exc_info=True) raise HTTPException( @@ -347,6 +371,37 @@ async def run_proof_endpoint( ) +@router.get("/runs/{run_id}", response_model=RunStatusResponse) +@rate_limit_standard() +async def get_run_status_endpoint( + request: Request, + run_id: str, + workspace: Workspace = Depends(get_v2_workspace), +) -> RunStatusResponse: + """Get the status of a completed proof run by run_id. + + Since POST /run is synchronous, a run is always complete immediately after + the POST returns. Returns 404 if run_id is unknown. + """ + cached = _run_cache.get(run_id) + if cached is None: + raise HTTPException( + status_code=404, + detail=api_error( + f"Run not found: {run_id}", + ErrorCodes.NOT_FOUND, + f"No proof run with id {run_id}", + ), + ) + return RunStatusResponse( + run_id=run_id, + status="complete", + results=cached["results"], + passed=cached["passed"], + message=cached["message"], + ) + + @router.post("/requirements/{req_id}/waive", response_model=RequirementResponse) @rate_limit_standard() async def waive_requirement_endpoint( diff --git a/tests/ui/test_proof_v2.py b/tests/ui/test_proof_v2.py index 5bcb22f7..e754c4ec 100644 --- a/tests/ui/test_proof_v2.py +++ b/tests/ui/test_proof_v2.py @@ -321,6 +321,50 @@ def test_run_results_shape(self, test_client): assert isinstance(data["results"], dict) +# ============================================================================ +# GET /api/v2/proof/runs/{run_id} — poll run status +# ============================================================================ + + +class TestGetRunStatus: + """Tests for GET /api/v2/proof/runs/{run_id}.""" + + def test_get_run_after_post_returns_200(self, test_client): + """GET /runs/{run_id} returns 200 after a completed POST /run.""" + post_resp = test_client.post("/api/v2/proof/run", json={}) + assert post_resp.status_code == 200 + run_id = post_resp.json()["run_id"] + + response = test_client.get(f"/api/v2/proof/runs/{run_id}") + assert response.status_code == 200 + + def test_get_run_response_shape(self, test_client): + """RunStatusResponse has required fields.""" + post_resp = test_client.post("/api/v2/proof/run", json={}) + run_id = post_resp.json()["run_id"] + + data = test_client.get(f"/api/v2/proof/runs/{run_id}").json() + assert data["run_id"] == run_id + assert data["status"] == "complete" + assert isinstance(data["results"], dict) + assert isinstance(data["passed"], bool) + assert isinstance(data["message"], str) + + def test_get_unknown_run_returns_404(self, test_client): + """Unknown run_id returns 404.""" + response = test_client.get("/api/v2/proof/runs/does-not-exist") + assert response.status_code == 404 + + def test_get_run_results_match_post(self, test_client): + """GET run results match the original POST results.""" + post_resp = test_client.post("/api/v2/proof/run", json={"full": True}) + post_data = post_resp.json() + run_id = post_data["run_id"] + + get_data = test_client.get(f"/api/v2/proof/runs/{run_id}").json() + assert get_data["results"] == post_data["results"] + + # ============================================================================ # POST /api/v2/proof/requirements/{req_id}/waive — waive requirement # ============================================================================ diff --git a/web-ui/src/__tests__/hooks/useProofRun.test.ts b/web-ui/src/__tests__/hooks/useProofRun.test.ts new file mode 100644 index 00000000..08e00fad --- /dev/null +++ b/web-ui/src/__tests__/hooks/useProofRun.test.ts @@ -0,0 +1,171 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useProofRun } from '@/hooks/useProofRun'; +import { proofApi } from '@/lib/api'; + +jest.mock('@/lib/api', () => ({ + proofApi: { + startRun: jest.fn(), + getRun: jest.fn(), + }, +})); + +const mockStartRun = proofApi.startRun as jest.MockedFunction; +const mockGetRun = proofApi.getRun as jest.MockedFunction; + +const WORKSPACE = '/tmp/test-workspace'; + +const makeStartRunResponse = (passed = true) => ({ + success: true, + run_id: 'abc123', + results: { + 'req-1': [ + { gate: 'unit', satisfied: passed }, + { gate: 'sec', satisfied: true }, + ], + }, + message: 'Proof run complete: 1 requirement(s) evaluated.', +}); + +const makeGetRunResponse = (passed = true) => ({ + run_id: 'abc123', + status: 'complete' as const, + results: { + 'req-1': [ + { gate: 'unit', satisfied: passed }, + { gate: 'sec', satisfied: true }, + ], + }, + passed, + message: 'Proof run complete: 1 requirement(s) evaluated.', +}); + +beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('useProofRun', () => { + it('starts in idle state', () => { + const { result } = renderHook(() => useProofRun()); + expect(result.current.runState).toBe('idle'); + expect(result.current.gateEntries).toHaveLength(0); + expect(result.current.passed).toBeNull(); + expect(result.current.errorMessage).toBeNull(); + }); + + it('transitions to starting then polling on successful POST', async () => { + mockStartRun.mockResolvedValue(makeStartRunResponse()); + mockGetRun.mockResolvedValue(makeGetRunResponse()); + + const { result } = renderHook(() => useProofRun()); + + act(() => { + result.current.startRun(WORKSPACE); + }); + + expect(result.current.runState).toBe('starting'); + + await waitFor(() => expect(result.current.runState).toBe('polling')); + expect(mockStartRun).toHaveBeenCalledWith(WORKSPACE, { full: true }); + expect(result.current.gateEntries.length).toBeGreaterThan(0); + expect(result.current.gateEntries.every((e) => e.status === 'running')).toBe(true); + }); + + it('transitions to complete after poll resolves', async () => { + mockStartRun.mockResolvedValue(makeStartRunResponse(true)); + mockGetRun.mockResolvedValue(makeGetRunResponse(true)); + + const { result } = renderHook(() => useProofRun()); + + act(() => { + result.current.startRun(WORKSPACE); + }); + + await waitFor(() => expect(result.current.runState).toBe('polling')); + + // Trigger the 2s poll interval + await act(async () => { + jest.advanceTimersByTime(2000); + }); + + await waitFor(() => expect(result.current.runState).toBe('complete')); + expect(result.current.passed).toBe(true); + expect(result.current.gateEntries.some((e) => e.status === 'passed')).toBe(true); + }); + + it('sets passed=false when gates fail', async () => { + mockStartRun.mockResolvedValue(makeStartRunResponse(false)); + mockGetRun.mockResolvedValue(makeGetRunResponse(false)); + + const { result } = renderHook(() => useProofRun()); + + act(() => { + result.current.startRun(WORKSPACE); + }); + + await waitFor(() => expect(result.current.runState).toBe('polling')); + + await act(async () => { + jest.advanceTimersByTime(2000); + }); + + await waitFor(() => expect(result.current.runState).toBe('complete')); + expect(result.current.passed).toBe(false); + }); + + it('transitions to error state on POST failure', async () => { + mockStartRun.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useProofRun()); + + act(() => { + result.current.startRun(WORKSPACE); + }); + + await waitFor(() => expect(result.current.runState).toBe('error')); + expect(result.current.errorMessage).toContain('Network error'); + }); + + it('transitions to error state on poll failure', async () => { + mockStartRun.mockResolvedValue(makeStartRunResponse()); + mockGetRun.mockRejectedValue(new Error('Poll failed')); + + const { result } = renderHook(() => useProofRun()); + + act(() => { + result.current.startRun(WORKSPACE); + }); + + await waitFor(() => expect(result.current.runState).toBe('polling')); + + await act(async () => { + jest.advanceTimersByTime(2000); + }); + + await waitFor(() => expect(result.current.runState).toBe('error')); + expect(result.current.errorMessage).toBeTruthy(); + }); + + it('retry() resets state to idle', async () => { + mockStartRun.mockRejectedValue(new Error('fail')); + + const { result } = renderHook(() => useProofRun()); + + act(() => { + result.current.startRun(WORKSPACE); + }); + + await waitFor(() => expect(result.current.runState).toBe('error')); + + act(() => { + result.current.retry(); + }); + + expect(result.current.runState).toBe('idle'); + expect(result.current.errorMessage).toBeNull(); + }); +}); diff --git a/web-ui/src/app/proof/page.tsx b/web-ui/src/app/proof/page.tsx index 9cd870a7..3dc39c85 100644 --- a/web-ui/src/app/proof/page.tsx +++ b/web-ui/src/app/proof/page.tsx @@ -12,8 +12,9 @@ import { TooltipProvider, } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; -import { ProofStatusBadge, WaiveDialog } from '@/components/proof'; +import { ProofStatusBadge, WaiveDialog, GateRunPanel, GateRunBanner } from '@/components/proof'; import { proofApi } from '@/lib/api'; +import { useProofRun } from '@/hooks/useProofRun'; import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; import type { ProofRequirement, ProofRequirementListResponse, ProofReqStatus, ProofSeverity } from '@/types'; @@ -103,6 +104,8 @@ function ProofPageContent() { const [workspaceReady, setWorkspaceReady] = useState(false); const [waivedReq, setWaivedReq] = useState(null); + const { runState, gateEntries, passed, errorMessage, startRun, retry } = useProofRun(); + // Sort state (default: status asc → open first, then severity) const [sortCol, setSortCol] = useState('status'); const [sortDir, setSortDir] = useState('asc'); @@ -126,6 +129,13 @@ function ProofPageContent() { () => proofApi.listRequirements(workspacePath!) ); + // Refresh requirements table after a run completes + useEffect(() => { + if (runState === 'complete') { + mutate(); + } + }, [runState, mutate]); + // Collect unique glitch types from data for the dropdown const glitchTypes = useMemo(() => { if (!data) return [] as string[]; @@ -172,12 +182,29 @@ function ProofPageContent() {
-
-

PROOF9 Requirements

-

- PROOF9 tracks quality requirements with evidence. Requirements must be satisfied or waived before shipping.{' '} - Learn more ↓ -

+
+
+

PROOF9 Requirements

+

+ PROOF9 tracks quality requirements with evidence. Requirements must be satisfied or waived before shipping.{' '} + Learn more ↓ +

+
+
{isLoading && ( @@ -228,6 +255,25 @@ function ProofPageContent() { return ( <> + {/* Gate run progress and result */} + {(runState === 'starting' || runState === 'polling' || runState === 'complete' || runState === 'error') && gateEntries.length > 0 && ( + + )} + {runState === 'complete' && passed !== null && ( + + )} + {runState === 'error' && errorMessage && ( +
+

{errorMessage}

+ +
+ )} +
{data.by_status?.open ?? 0} open {data.by_status?.satisfied ?? 0} satisfied diff --git a/web-ui/src/components/proof/GateRunBanner.tsx b/web-ui/src/components/proof/GateRunBanner.tsx new file mode 100644 index 00000000..d05f2edf --- /dev/null +++ b/web-ui/src/components/proof/GateRunBanner.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { Button } from '@/components/ui/button'; + +interface GateRunBannerProps { + passed: boolean; + message: string; + onRetry: () => void; +} + +export function GateRunBanner({ passed, message, onRetry }: GateRunBannerProps) { + if (passed) { + return ( +
+
+ ); + } + + return ( +
+
+ ); +} diff --git a/web-ui/src/components/proof/GateRunPanel.tsx b/web-ui/src/components/proof/GateRunPanel.tsx new file mode 100644 index 00000000..097b0b10 --- /dev/null +++ b/web-ui/src/components/proof/GateRunPanel.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { Badge } from '@/components/ui/badge'; +import type { GateRunEntry, GateRunStatus } from '@/types'; + +interface GateRunPanelProps { + gateEntries: GateRunEntry[]; +} + +const STATUS_LABEL: Record = { + pending: 'pending', + running: 'running', + passed: 'passed', + failed: 'failed', +}; + +const STATUS_CLASSES: Record = { + pending: 'bg-gray-100 text-gray-600', + running: 'bg-blue-100 text-blue-800 animate-pulse', + passed: 'bg-green-100 text-green-900', + failed: 'bg-red-100 text-red-900', +}; + +export function GateRunPanel({ gateEntries }: GateRunPanelProps) { + if (gateEntries.length === 0) return null; + + return ( +
+

Gate progress

+
    + {gateEntries.map(({ gate, status }) => ( +
  • + {gate} + + {STATUS_LABEL[status]} + +
  • + ))} +
+
+ ); +} diff --git a/web-ui/src/components/proof/index.ts b/web-ui/src/components/proof/index.ts index 9ba0ebc3..9c934588 100644 --- a/web-ui/src/components/proof/index.ts +++ b/web-ui/src/components/proof/index.ts @@ -1,3 +1,5 @@ export { ProofStatusBadge } from './ProofStatusBadge'; export { ProofStatusWidget } from './ProofStatusWidget'; export { WaiveDialog } from './WaiveDialog'; +export { GateRunPanel } from './GateRunPanel'; +export { GateRunBanner } from './GateRunBanner'; diff --git a/web-ui/src/hooks/index.ts b/web-ui/src/hooks/index.ts index 7d8eb7d4..7769dc9c 100644 --- a/web-ui/src/hooks/index.ts +++ b/web-ui/src/hooks/index.ts @@ -7,6 +7,7 @@ export { } from './useTerminalSocket'; export { useEventSource, type SSEStatus, type UseEventSourceOptions } from './useEventSource'; export { useRequirementsLookup } from './useRequirementsLookup'; +export { useProofRun, type UseProofRunReturn, type ProofRunState } from './useProofRun'; export { useTaskStream, type UseTaskStreamOptions, diff --git a/web-ui/src/hooks/useProofRun.ts b/web-ui/src/hooks/useProofRun.ts new file mode 100644 index 00000000..30d49580 --- /dev/null +++ b/web-ui/src/hooks/useProofRun.ts @@ -0,0 +1,131 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { proofApi } from '@/lib/api'; +import type { GateRunEntry, GateRunStatus } from '@/types'; + +export type ProofRunState = 'idle' | 'starting' | 'polling' | 'complete' | 'error'; + +export interface UseProofRunReturn { + runState: ProofRunState; + gateEntries: GateRunEntry[]; + passed: boolean | null; + errorMessage: string | null; + startRun: (workspacePath: string) => void; + retry: () => void; +} + +/** + * Manages the full lifecycle of a PROOF9 gate run. + * + * Flow: idle → starting (POST) → polling (GET every 2s) → complete / error + * + * Since POST /run is synchronous on the backend, the first poll immediately + * resolves. The optimistic "pending → running" transition gives visual feedback + * before the final pass/fail state is applied. + */ +export function useProofRun(): UseProofRunReturn { + const [runState, setRunState] = useState('idle'); + const [gateEntries, setGateEntries] = useState([]); + const [passed, setPassed] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + const intervalRef = useRef | null>(null); + const workspaceRef = useRef(''); + const runIdRef = useRef(''); + + const clearPollInterval = useCallback(() => { + if (intervalRef.current !== null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + useEffect(() => { + return clearPollInterval; + }, [clearPollInterval]); + + const startRun = useCallback( + async (workspacePath: string) => { + clearPollInterval(); + workspaceRef.current = workspacePath; + setRunState('starting'); + setGateEntries([]); + setPassed(null); + setErrorMessage(null); + + try { + const response = await proofApi.startRun(workspacePath, { full: true }); + runIdRef.current = response.run_id; + + // Build optimistic "running" entries from returned results + const entries: GateRunEntry[] = Object.values(response.results) + .flat() + .map((item) => ({ gate: item.gate, status: 'running' as GateRunStatus })); + // Deduplicate gate names + const seen = new Set(); + const uniqueEntries = entries.filter(({ gate }) => { + if (seen.has(gate)) return false; + seen.add(gate); + return true; + }); + + setGateEntries(uniqueEntries.length > 0 ? uniqueEntries : []); + setRunState('polling'); + + // Poll every 2s until complete + intervalRef.current = setInterval(async () => { + try { + const status = await proofApi.getRun(workspaceRef.current, runIdRef.current); + if (status.status === 'complete') { + clearPollInterval(); + + // Build final gate entries with pass/fail status + const finalEntries: GateRunEntry[] = Object.values(status.results) + .flat() + .map((item) => ({ + gate: item.gate, + status: item.satisfied ? ('passed' as GateRunStatus) : ('failed' as GateRunStatus), + })); + const seenFinal = new Set(); + const uniqueFinal = finalEntries.filter(({ gate }) => { + if (seenFinal.has(gate)) return false; + seenFinal.add(gate); + return true; + }); + + setGateEntries(uniqueFinal); + setPassed(status.passed); + setRunState('complete'); + } + } catch { + clearPollInterval(); + setErrorMessage('Failed to retrieve run status. Please retry.'); + setRunState('error'); + } + }, 2000); + } catch (err: unknown) { + clearPollInterval(); + const message = + err instanceof Error + ? err.message + : typeof err === 'object' && err !== null && 'detail' in err + ? String((err as { detail: unknown }).detail) + : 'Failed to start proof run.'; + setErrorMessage(message); + setRunState('error'); + } + }, + [clearPollInterval] + ); + + const retry = useCallback(() => { + clearPollInterval(); + setRunState('idle'); + setGateEntries([]); + setPassed(null); + setErrorMessage(null); + }, [clearPollInterval]); + + return { runState, gateEntries, passed, errorMessage, startRun, retry }; +} diff --git a/web-ui/src/lib/api.ts b/web-ui/src/lib/api.ts index b365441b..f1259893 100644 --- a/web-ui/src/lib/api.ts +++ b/web-ui/src/lib/api.ts @@ -43,6 +43,8 @@ import type { ProofStatusResponse, ProofReqStatus, WaiveRequest, + RunProofResponse, + RunStatusResponse, Session, SessionState, SessionListResponse, @@ -629,6 +631,26 @@ export const proofApi = { ); return response.data; }, + + startRun: async ( + workspacePath: string, + body: { full: boolean } + ): Promise => { + const response = await api.post( + '/api/v2/proof/run', + body, + { params: { workspace_path: workspacePath } } + ); + return response.data; + }, + + getRun: async (workspacePath: string, runId: string): Promise => { + const response = await api.get( + `/api/v2/proof/runs/${encodeURIComponent(runId)}`, + { params: { workspace_path: workspacePath } } + ); + return response.data; + }, }; // PR API methods diff --git a/web-ui/src/types/index.ts b/web-ui/src/types/index.ts index cfe45e4e..a85bfd38 100644 --- a/web-ui/src/types/index.ts +++ b/web-ui/src/types/index.ts @@ -343,6 +343,30 @@ export interface WaiveRequest { approved_by: string; } +// Proof run types (mirrors proof_v2.py RunProofResponse + RunStatusResponse) +export interface RunProofResponse { + success: boolean; + run_id: string; + results: Record>; + message: string; +} + +export interface RunStatusResponse { + run_id: string; + status: 'running' | 'complete'; + results: Record>; + passed: boolean; + message: string; +} + +// UI-only types for per-gate display in the Run Gates panel +export type GateRunStatus = 'pending' | 'running' | 'passed' | 'failed'; + +export interface GateRunEntry { + gate: string; + status: GateRunStatus; +} + // Quick Actions props (dashboard) export interface QuickActionsProps { From 631137373715f161ee75842c107e905e08549828 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 7 Apr 2026 11:37:08 -0700 Subject: [PATCH 2/2] fix(proof): address CodeRabbit review feedback - Backend: scope _run_cache by (workspace.repo_path, run_id) to prevent cross-workspace cache collisions; GET /runs/{run_id} verifies workspace before cache lookup - Types: export RunProofRequest interface from @/types (was inline) - GateRunPanel: use Badge design system variants (backlog/in-progress/done/failed) instead of hardcoded Tailwind color classes - GateRunBanner: use semantic design system tokens (bg-muted/30, border-destructive, text-destructive) consistent with app patterns --- codeframe/ui/routers/proof_v2.py | 8 +++---- web-ui/src/components/proof/GateRunBanner.tsx | 18 +++++++-------- web-ui/src/components/proof/GateRunPanel.tsx | 23 ++++++++----------- web-ui/src/hooks/useProofRun.ts | 5 ++-- web-ui/src/lib/api.ts | 3 ++- web-ui/src/types/index.ts | 4 ++++ 6 files changed, 32 insertions(+), 29 deletions(-) diff --git a/codeframe/ui/routers/proof_v2.py b/codeframe/ui/routers/proof_v2.py index 08e08c43..92b904cb 100644 --- a/codeframe/ui/routers/proof_v2.py +++ b/codeframe/ui/routers/proof_v2.py @@ -45,8 +45,8 @@ router = APIRouter(prefix="/api/v2/proof", tags=["proof-v2"]) -# Module-level cache: run_id → serialized RunProofResponse dict -_run_cache: dict[str, dict] = {} +# Module-level cache: (workspace_path, run_id) → serialized RunProofResponse dict +_run_cache: dict[tuple[str, str], dict] = {} # ============================================================================ @@ -357,7 +357,7 @@ async def run_proof_endpoint( results=serialized, message=f"Proof run complete: {len(results)} requirement(s) evaluated.", ) - _run_cache[run_id] = { + _run_cache[(str(workspace.repo_path), run_id)] = { "results": serialized, "passed": passed, "message": response.message, @@ -383,7 +383,7 @@ async def get_run_status_endpoint( Since POST /run is synchronous, a run is always complete immediately after the POST returns. Returns 404 if run_id is unknown. """ - cached = _run_cache.get(run_id) + cached = _run_cache.get((str(workspace.repo_path), run_id)) if cached is None: raise HTTPException( status_code=404, diff --git a/web-ui/src/components/proof/GateRunBanner.tsx b/web-ui/src/components/proof/GateRunBanner.tsx index d05f2edf..b08c1040 100644 --- a/web-ui/src/components/proof/GateRunBanner.tsx +++ b/web-ui/src/components/proof/GateRunBanner.tsx @@ -14,11 +14,11 @@ export function GateRunBanner({ passed, message, onRetry }: GateRunBannerProps)
-
); } @@ -27,12 +27,12 @@ export function GateRunBanner({ passed, message, onRetry }: GateRunBannerProps)
-
diff --git a/web-ui/src/components/proof/GateRunPanel.tsx b/web-ui/src/components/proof/GateRunPanel.tsx index 097b0b10..f4811e93 100644 --- a/web-ui/src/components/proof/GateRunPanel.tsx +++ b/web-ui/src/components/proof/GateRunPanel.tsx @@ -7,20 +7,14 @@ interface GateRunPanelProps { gateEntries: GateRunEntry[]; } -const STATUS_LABEL: Record = { - pending: 'pending', - running: 'running', - passed: 'passed', +// Map GateRunStatus to Badge variant names from the shared design system +const STATUS_VARIANT: Record = { + pending: 'backlog', + running: 'in-progress', + passed: 'done', failed: 'failed', }; -const STATUS_CLASSES: Record = { - pending: 'bg-gray-100 text-gray-600', - running: 'bg-blue-100 text-blue-800 animate-pulse', - passed: 'bg-green-100 text-green-900', - failed: 'bg-red-100 text-red-900', -}; - export function GateRunPanel({ gateEntries }: GateRunPanelProps) { if (gateEntries.length === 0) return null; @@ -35,8 +29,11 @@ export function GateRunPanel({ gateEntries }: GateRunPanelProps) { {gateEntries.map(({ gate, status }) => (
  • {gate} - - {STATUS_LABEL[status]} + + {status}
  • ))} diff --git a/web-ui/src/hooks/useProofRun.ts b/web-ui/src/hooks/useProofRun.ts index 30d49580..0c6e2920 100644 --- a/web-ui/src/hooks/useProofRun.ts +++ b/web-ui/src/hooks/useProofRun.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { proofApi } from '@/lib/api'; -import type { GateRunEntry, GateRunStatus } from '@/types'; +import type { GateRunEntry, GateRunStatus, RunProofRequest } from '@/types'; export type ProofRunState = 'idle' | 'starting' | 'polling' | 'complete' | 'error'; @@ -55,7 +55,8 @@ export function useProofRun(): UseProofRunReturn { setErrorMessage(null); try { - const response = await proofApi.startRun(workspacePath, { full: true }); + const runRequest: RunProofRequest = { full: true }; + const response = await proofApi.startRun(workspacePath, runRequest); runIdRef.current = response.run_id; // Build optimistic "running" entries from returned results diff --git a/web-ui/src/lib/api.ts b/web-ui/src/lib/api.ts index f1259893..874ab49e 100644 --- a/web-ui/src/lib/api.ts +++ b/web-ui/src/lib/api.ts @@ -43,6 +43,7 @@ import type { ProofStatusResponse, ProofReqStatus, WaiveRequest, + RunProofRequest, RunProofResponse, RunStatusResponse, Session, @@ -634,7 +635,7 @@ export const proofApi = { startRun: async ( workspacePath: string, - body: { full: boolean } + body: RunProofRequest ): Promise => { const response = await api.post( '/api/v2/proof/run', diff --git a/web-ui/src/types/index.ts b/web-ui/src/types/index.ts index a85bfd38..8a15dfe0 100644 --- a/web-ui/src/types/index.ts +++ b/web-ui/src/types/index.ts @@ -344,6 +344,10 @@ export interface WaiveRequest { } // Proof run types (mirrors proof_v2.py RunProofResponse + RunStatusResponse) +export interface RunProofRequest { + full: boolean; +} + export interface RunProofResponse { success: boolean; run_id: string;