Skip to content

Commit ea8f4d2

Browse files
frankbriaTest User
andauthored
feat(web-ui): Run Gates button + live gate progress for PROOF9 (#566) (#574)
* feat(web-ui): add Run Gates button with live gate progress to PROOF9 page (#566) - 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 * 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 --------- Co-authored-by: Test User <test@example.com>
1 parent c29d021 commit ea8f4d2

11 files changed

Lines changed: 593 additions & 8 deletions

File tree

codeframe/ui/routers/proof_v2.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@
4545

4646
router = APIRouter(prefix="/api/v2/proof", tags=["proof-v2"])
4747

48+
# Module-level cache: (workspace_path, run_id) → serialized RunProofResponse dict
49+
_run_cache: dict[tuple[str, str], dict] = {}
50+
4851

4952
# ============================================================================
5053
# Request / Response Models
@@ -146,6 +149,16 @@ class RunProofResponse(BaseModel):
146149
message: str
147150

148151

152+
class RunStatusResponse(BaseModel):
153+
"""Response for GET /runs/{run_id} — poll a completed run."""
154+
155+
run_id: str
156+
status: str # "running" | "complete"
157+
results: dict[str, list[dict[str, Any]]]
158+
passed: bool
159+
message: str
160+
161+
149162
class ProofStatusResponse(BaseModel):
150163
"""Aggregated proof status response."""
151164

@@ -333,12 +346,23 @@ async def run_proof_endpoint(
333346
for req_id, gate_results in results.items()
334347
}
335348

336-
return RunProofResponse(
349+
passed = all(
350+
satisfied
351+
for gate_results in results.values()
352+
for _, satisfied in gate_results
353+
)
354+
response = RunProofResponse(
337355
success=True,
338356
run_id=run_id,
339357
results=serialized,
340358
message=f"Proof run complete: {len(results)} requirement(s) evaluated.",
341359
)
360+
_run_cache[(str(workspace.repo_path), run_id)] = {
361+
"results": serialized,
362+
"passed": passed,
363+
"message": response.message,
364+
}
365+
return response
342366
except Exception as e:
343367
logger.error("Proof run failed: %s", e, exc_info=True)
344368
raise HTTPException(
@@ -347,6 +371,37 @@ async def run_proof_endpoint(
347371
)
348372

349373

374+
@router.get("/runs/{run_id}", response_model=RunStatusResponse)
375+
@rate_limit_standard()
376+
async def get_run_status_endpoint(
377+
request: Request,
378+
run_id: str,
379+
workspace: Workspace = Depends(get_v2_workspace),
380+
) -> RunStatusResponse:
381+
"""Get the status of a completed proof run by run_id.
382+
383+
Since POST /run is synchronous, a run is always complete immediately after
384+
the POST returns. Returns 404 if run_id is unknown.
385+
"""
386+
cached = _run_cache.get((str(workspace.repo_path), run_id))
387+
if cached is None:
388+
raise HTTPException(
389+
status_code=404,
390+
detail=api_error(
391+
f"Run not found: {run_id}",
392+
ErrorCodes.NOT_FOUND,
393+
f"No proof run with id {run_id}",
394+
),
395+
)
396+
return RunStatusResponse(
397+
run_id=run_id,
398+
status="complete",
399+
results=cached["results"],
400+
passed=cached["passed"],
401+
message=cached["message"],
402+
)
403+
404+
350405
@router.post("/requirements/{req_id}/waive", response_model=RequirementResponse)
351406
@rate_limit_standard()
352407
async def waive_requirement_endpoint(

tests/ui/test_proof_v2.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,50 @@ def test_run_results_shape(self, test_client):
321321
assert isinstance(data["results"], dict)
322322

323323

324+
# ============================================================================
325+
# GET /api/v2/proof/runs/{run_id} — poll run status
326+
# ============================================================================
327+
328+
329+
class TestGetRunStatus:
330+
"""Tests for GET /api/v2/proof/runs/{run_id}."""
331+
332+
def test_get_run_after_post_returns_200(self, test_client):
333+
"""GET /runs/{run_id} returns 200 after a completed POST /run."""
334+
post_resp = test_client.post("/api/v2/proof/run", json={})
335+
assert post_resp.status_code == 200
336+
run_id = post_resp.json()["run_id"]
337+
338+
response = test_client.get(f"/api/v2/proof/runs/{run_id}")
339+
assert response.status_code == 200
340+
341+
def test_get_run_response_shape(self, test_client):
342+
"""RunStatusResponse has required fields."""
343+
post_resp = test_client.post("/api/v2/proof/run", json={})
344+
run_id = post_resp.json()["run_id"]
345+
346+
data = test_client.get(f"/api/v2/proof/runs/{run_id}").json()
347+
assert data["run_id"] == run_id
348+
assert data["status"] == "complete"
349+
assert isinstance(data["results"], dict)
350+
assert isinstance(data["passed"], bool)
351+
assert isinstance(data["message"], str)
352+
353+
def test_get_unknown_run_returns_404(self, test_client):
354+
"""Unknown run_id returns 404."""
355+
response = test_client.get("/api/v2/proof/runs/does-not-exist")
356+
assert response.status_code == 404
357+
358+
def test_get_run_results_match_post(self, test_client):
359+
"""GET run results match the original POST results."""
360+
post_resp = test_client.post("/api/v2/proof/run", json={"full": True})
361+
post_data = post_resp.json()
362+
run_id = post_data["run_id"]
363+
364+
get_data = test_client.get(f"/api/v2/proof/runs/{run_id}").json()
365+
assert get_data["results"] == post_data["results"]
366+
367+
324368
# ============================================================================
325369
# POST /api/v2/proof/requirements/{req_id}/waive — waive requirement
326370
# ============================================================================
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { renderHook, act, waitFor } from '@testing-library/react';
2+
import { useProofRun } from '@/hooks/useProofRun';
3+
import { proofApi } from '@/lib/api';
4+
5+
jest.mock('@/lib/api', () => ({
6+
proofApi: {
7+
startRun: jest.fn(),
8+
getRun: jest.fn(),
9+
},
10+
}));
11+
12+
const mockStartRun = proofApi.startRun as jest.MockedFunction<typeof proofApi.startRun>;
13+
const mockGetRun = proofApi.getRun as jest.MockedFunction<typeof proofApi.getRun>;
14+
15+
const WORKSPACE = '/tmp/test-workspace';
16+
17+
const makeStartRunResponse = (passed = true) => ({
18+
success: true,
19+
run_id: 'abc123',
20+
results: {
21+
'req-1': [
22+
{ gate: 'unit', satisfied: passed },
23+
{ gate: 'sec', satisfied: true },
24+
],
25+
},
26+
message: 'Proof run complete: 1 requirement(s) evaluated.',
27+
});
28+
29+
const makeGetRunResponse = (passed = true) => ({
30+
run_id: 'abc123',
31+
status: 'complete' as const,
32+
results: {
33+
'req-1': [
34+
{ gate: 'unit', satisfied: passed },
35+
{ gate: 'sec', satisfied: true },
36+
],
37+
},
38+
passed,
39+
message: 'Proof run complete: 1 requirement(s) evaluated.',
40+
});
41+
42+
beforeEach(() => {
43+
jest.useFakeTimers();
44+
jest.clearAllMocks();
45+
});
46+
47+
afterEach(() => {
48+
jest.useRealTimers();
49+
});
50+
51+
describe('useProofRun', () => {
52+
it('starts in idle state', () => {
53+
const { result } = renderHook(() => useProofRun());
54+
expect(result.current.runState).toBe('idle');
55+
expect(result.current.gateEntries).toHaveLength(0);
56+
expect(result.current.passed).toBeNull();
57+
expect(result.current.errorMessage).toBeNull();
58+
});
59+
60+
it('transitions to starting then polling on successful POST', async () => {
61+
mockStartRun.mockResolvedValue(makeStartRunResponse());
62+
mockGetRun.mockResolvedValue(makeGetRunResponse());
63+
64+
const { result } = renderHook(() => useProofRun());
65+
66+
act(() => {
67+
result.current.startRun(WORKSPACE);
68+
});
69+
70+
expect(result.current.runState).toBe('starting');
71+
72+
await waitFor(() => expect(result.current.runState).toBe('polling'));
73+
expect(mockStartRun).toHaveBeenCalledWith(WORKSPACE, { full: true });
74+
expect(result.current.gateEntries.length).toBeGreaterThan(0);
75+
expect(result.current.gateEntries.every((e) => e.status === 'running')).toBe(true);
76+
});
77+
78+
it('transitions to complete after poll resolves', async () => {
79+
mockStartRun.mockResolvedValue(makeStartRunResponse(true));
80+
mockGetRun.mockResolvedValue(makeGetRunResponse(true));
81+
82+
const { result } = renderHook(() => useProofRun());
83+
84+
act(() => {
85+
result.current.startRun(WORKSPACE);
86+
});
87+
88+
await waitFor(() => expect(result.current.runState).toBe('polling'));
89+
90+
// Trigger the 2s poll interval
91+
await act(async () => {
92+
jest.advanceTimersByTime(2000);
93+
});
94+
95+
await waitFor(() => expect(result.current.runState).toBe('complete'));
96+
expect(result.current.passed).toBe(true);
97+
expect(result.current.gateEntries.some((e) => e.status === 'passed')).toBe(true);
98+
});
99+
100+
it('sets passed=false when gates fail', async () => {
101+
mockStartRun.mockResolvedValue(makeStartRunResponse(false));
102+
mockGetRun.mockResolvedValue(makeGetRunResponse(false));
103+
104+
const { result } = renderHook(() => useProofRun());
105+
106+
act(() => {
107+
result.current.startRun(WORKSPACE);
108+
});
109+
110+
await waitFor(() => expect(result.current.runState).toBe('polling'));
111+
112+
await act(async () => {
113+
jest.advanceTimersByTime(2000);
114+
});
115+
116+
await waitFor(() => expect(result.current.runState).toBe('complete'));
117+
expect(result.current.passed).toBe(false);
118+
});
119+
120+
it('transitions to error state on POST failure', async () => {
121+
mockStartRun.mockRejectedValue(new Error('Network error'));
122+
123+
const { result } = renderHook(() => useProofRun());
124+
125+
act(() => {
126+
result.current.startRun(WORKSPACE);
127+
});
128+
129+
await waitFor(() => expect(result.current.runState).toBe('error'));
130+
expect(result.current.errorMessage).toContain('Network error');
131+
});
132+
133+
it('transitions to error state on poll failure', async () => {
134+
mockStartRun.mockResolvedValue(makeStartRunResponse());
135+
mockGetRun.mockRejectedValue(new Error('Poll failed'));
136+
137+
const { result } = renderHook(() => useProofRun());
138+
139+
act(() => {
140+
result.current.startRun(WORKSPACE);
141+
});
142+
143+
await waitFor(() => expect(result.current.runState).toBe('polling'));
144+
145+
await act(async () => {
146+
jest.advanceTimersByTime(2000);
147+
});
148+
149+
await waitFor(() => expect(result.current.runState).toBe('error'));
150+
expect(result.current.errorMessage).toBeTruthy();
151+
});
152+
153+
it('retry() resets state to idle', async () => {
154+
mockStartRun.mockRejectedValue(new Error('fail'));
155+
156+
const { result } = renderHook(() => useProofRun());
157+
158+
act(() => {
159+
result.current.startRun(WORKSPACE);
160+
});
161+
162+
await waitFor(() => expect(result.current.runState).toBe('error'));
163+
164+
act(() => {
165+
result.current.retry();
166+
});
167+
168+
expect(result.current.runState).toBe('idle');
169+
expect(result.current.errorMessage).toBeNull();
170+
});
171+
});

0 commit comments

Comments
 (0)