Skip to content
Merged
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
57 changes: 56 additions & 1 deletion codeframe/ui/routers/proof_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@

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

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


# ============================================================================
# Request / Response Models
Expand Down Expand Up @@ -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
Comment on lines +152 to +159
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This backend cannot produce live gate progress yet.

run_proof() finishes before the client gets run_id, and the poll endpoint hardcodes status="complete" with final results only. That means the UI cannot receive real pending → running → passed/failed transitions from the backend, so the live-progress acceptance criteria is not actually met. Seed a run record before execution and update it during the run, or move the work to an async job.

Also applies to: 323-365, 374-402

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/ui/routers/proof_v2.py` around lines 152 - 159, The
RunStatusResponse/polling flow currently always returns a completed result
because run_proof() finishes before the client receives run_id and the poll
handler hardcodes status="complete"; fix by persisting a Run record (with
run_id, status, partial results, message, passed flag) before executing the
proof, return that run_id immediately from run_proof(), then execute the proof
work asynchronously (background thread/task or job queue) and update the
persisted Run record status to "pending" → "running" → "complete"/"failed" as
gates progress; update the poll endpoint that uses RunStatusResponse to read the
current record and return live status and partial results, and ensure
functions/methods named run_proof(), the poll handler that constructs
RunStatusResponse, and any Run model/state storage are modified accordingly.



class ProofStatusResponse(BaseModel):
"""Aggregated proof status response."""

Expand Down Expand Up @@ -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[(str(workspace.repo_path), 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(
Expand All @@ -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((str(workspace.repo_path), 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(
Expand Down
44 changes: 44 additions & 0 deletions tests/ui/test_proof_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ============================================================================
Expand Down
171 changes: 171 additions & 0 deletions web-ui/src/__tests__/hooks/useProofRun.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof proofApi.startRun>;
const mockGetRun = proofApi.getRun as jest.MockedFunction<typeof proofApi.getRun>;

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