@@ -676,6 +678,59 @@ def analysis_run_detail(
return page(f"{repository.name} Run {analysis_run_id}", body)
+@router.get("/ui/repos/{repository_id}/analysis-runs/{base_analysis_run_id}/diff/{target_analysis_run_id}")
+def analysis_run_diff_detail(
+ repository_id: int,
+ base_analysis_run_id: int,
+ target_analysis_run_id: int,
+ service: RegistryService = Depends(get_service),
+) -> HTMLResponse:
+ try:
+ diff = service.diff_analysis_runs(
+ repository_id,
+ base_analysis_run_id,
+ target_analysis_run_id,
+ )
+ except NotFoundError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+ body = f"""
+
+
Comparing run #{base_analysis_run_id} to run #{target_analysis_run_id}.
+
+
+
Approve Target Changes
+
+
+
+
+
+ Approved Registry Impact
+ {render_diff_section(asdict(diff.approved_entries))}
+
+
+ Candidate Claims
+ {render_diff_section(asdict(diff.candidates))}
+
+
+ Observed Facts
+ {render_diff_section(asdict(diff.facts))}
+
+
+ Content Chunks
+ {render_diff_section(asdict(diff.chunks))}
+
+
+ """
+ return page(f"{diff.repository.name} Change Review", body)
+
+
@router.post("/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-graph/approve")
def approve_candidate_graph_from_form(
repository_id: int,
@@ -690,6 +745,21 @@ def approve_candidate_graph_from_form(
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
+@router.post("/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}/changes/approve")
+def approve_analysis_run_changes_from_form(
+ repository_id: int,
+ analysis_run_id: int,
+ notes: str = Form("Approved target run changes from web UI"),
+ service: RegistryService = Depends(get_service),
+) -> RedirectResponse:
+ service.approve_analysis_run_changes(
+ repository_id,
+ analysis_run_id,
+ notes=notes,
+ )
+ return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
+
+
@router.post(
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
"/candidate-abilities/{candidate_ability_id}/reject"
@@ -1002,6 +1072,90 @@ def merge_candidate_evidence_from_form(
)
+def render_run_compare_link(repository_id: int, run_id: int, runs: list) -> str:
+ base_run = next((run for run in runs if run.id < run_id), None)
+ if base_run is None:
+ return '
Baseline'
+ href = f"/ui/repos/{repository_id}/analysis-runs/{base_run.id}/diff/{run_id}"
+ return f'
Compare to #{base_run.id}'
+
+
+def render_run_detail_compare_link(
+ repository_id: int,
+ analysis_run_id: int,
+ runs: list,
+) -> str:
+ base_run = next((run for run in runs if run.id < analysis_run_id), None)
+ if base_run is None:
+ return ""
+ href = f"/ui/repos/{repository_id}/analysis-runs/{base_run.id}/diff/{analysis_run_id}"
+ return f'
Compare to #{base_run.id}'
+
+
+def render_diff_section(section: dict) -> str:
+ groups = [
+ ("added", "Added"),
+ ("removed", "Removed"),
+ ("changed", "Changed"),
+ ("weakened", "Weakened"),
+ ]
+ rendered = "".join(
+ render_diff_group(section.get(key, []), title)
+ for key, title in groups
+ )
+ return rendered or '
No differences.
'
+
+
+def render_diff_group(items: list[dict], title: str) -> str:
+ if not items:
+ return ""
+ rows = "\n".join(
+ f"""
+
+ | {escape(item['item_type'])} |
+ {escape(item['key'])} |
+ {render_diff_payload(item.get('base'))} |
+ {render_diff_payload(item.get('target'))} |
+
+ """
+ for item in items
+ )
+ return f"""
+
{escape(title)}
+
+ | Type | Key | Base | Target |
+ {rows}
+
+ """
+
+
+def render_diff_payload(payload: dict | None) -> str:
+ if payload is None:
+ return '
None'
+ preferred = [
+ "name",
+ "description",
+ "confidence",
+ "strength",
+ "path",
+ "location",
+ "reference",
+ "value",
+ "text",
+ ]
+ parts = []
+ for key in preferred:
+ if key not in payload or payload[key] in (None, "", []):
+ continue
+ value = str(payload[key])
+ if key == "text" and len(value) > 180:
+ value = value[:180] + "..."
+ parts.append(
+ f'
{escape(key)}: {escape(value)}
'
+ )
+ return "".join(parts) or '
No display fields.'
+
+
def render_candidate_graph(graph: dict, repository_id: int, analysis_run_id: int) -> str:
abilities = graph.get("abilities", [])
if not abilities:
diff --git a/tests/test_web_api.py b/tests/test_web_api.py
index 3f0cbde..bab3e0e 100644
--- a/tests/test_web_api.py
+++ b/tests/test_web_api.py
@@ -841,6 +841,7 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
)
assert run_response.status_code == 303
run_path = run_response.headers["location"]
+ first_run_id = int(run_path.rsplit("/", 1)[-1])
run_detail = client.get(run_path)
assert run_detail.status_code == 200
@@ -865,6 +866,49 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
assert "interface:app.py:3" in approved_detail.text
assert "approve_candidate_graph" in approved_detail.text
+ (source / "app.py").write_text(
+ "from fastapi import FastAPI\n"
+ "app = FastAPI()\n"
+ '@app.get("/status")\n'
+ "def status():\n"
+ " return {}\n\n"
+ '@app.get("/ready")\n'
+ "def ready():\n"
+ " return {}\n",
+ encoding="utf-8",
+ )
+ second_run_response = client.post(
+ f"{repository_path}/analysis-runs",
+ data={"source_path": ""},
+ follow_redirects=False,
+ )
+ assert second_run_response.status_code == 303
+ second_run_path = second_run_response.headers["location"]
+
+ second_run_detail = client.get(second_run_path)
+ assert second_run_detail.status_code == 200
+ assert "Compare to #" in second_run_detail.text
+
+ second_run_id = int(second_run_path.rsplit("/", 1)[-1])
+ diff_response = client.get(
+ f"{repository_path}/analysis-runs/{first_run_id}/diff/{second_run_id}"
+ )
+ assert diff_response.status_code == 200
+ assert "Change Review" in diff_response.text
+ assert "Approved Registry Impact" in diff_response.text
+ assert "Candidate Claims" in diff_response.text
+ assert "GET /ready" in diff_response.text
+
+ change_approval = client.post(
+ f"{second_run_path}/changes/approve",
+ data={"notes": "Accept UI change review."},
+ follow_redirects=False,
+ )
+ assert change_approval.status_code == 303
+ changed_detail = client.get(change_approval.headers["location"])
+ assert "GET /ready" in changed_detail.text
+ assert "approve_analysis_run_changes" in changed_detail.text
+
search_response = client.get("/ui/search", params={"q": "repository"})
assert search_response.status_code == 200
assert "UI Repo" in search_response.text
diff --git a/workplans/RREG-WP-0002-production-hardening.md b/workplans/RREG-WP-0002-production-hardening.md
index 68b9c6b..99bd953 100644
--- a/workplans/RREG-WP-0002-production-hardening.md
+++ b/workplans/RREG-WP-0002-production-hardening.md
@@ -24,7 +24,7 @@ are reviewable; approved registry truth is explicit.
```task
id: RREG-WP-0002-T01
-status: in_progress
+status: done
priority: high
state_hub_task_id: "a27142a6-c160-4453-ab59-50a7db92f9c4"
```