Skip to content

Commit a56ccb9

Browse files
committed
feat: v2.0.0-rc2 — close 3 endpoint-coverage gaps + 1 dead route
The post-rc1 endpoint audit found that the React UI was behind the backend on 3 features and that one route (`/un-duplicate`) was defined but never mounted on the production app (only on a test fixture). rc2 closes all four. Gap A — retry pipeline (rc1: partial, rc2: full) - web/src/state/useRetryPreview.ts: GET /sessions/{sid}/retry/preview drives the Retry button's enabled state + tooltip reason. - web/src/canvas/CanvasHead.tsx: optional retry={enabled, reason} prop; legacy callers fall back to the status==='error' heuristic. - web/src/canvas/SessionCanvas.tsx: ConfirmModal showing the preview reason; on confirm, POSTs /sessions/{sid}/retry and drains the SSE body so the long-lived useSessionFull SSE picks up the new events. Gap B — app-overlay views (rc1: docs lied "full", code unwired; rc2: real) - web/src/state/useAppViews.ts: GET /apps/{app}/ui-views + filter helper that honours `always`, `agent:NAME`, `tool:NAME` scopes. - web/src/monitors/SelectedPanel.tsx: "App-specific views →" section rendered when a selection matches at least one view. Gap C — un-duplicate (rc1: dead route + no UI; rc2: full) - src/runtime/api.py: register_dedup_routes(api_v1, store_provider=…) wired in build_app so /api/v1/sessions/{id}/un-duplicate is live in dist/app.py (was only live in tests). - src/runtime/api_dedup.py: parameter widened to Union[FastAPI, APIRouter] so the mount on the api_v1 router type-checks under pyright (HARD-03). - web/src/modals/UnDuplicateModal.tsx: confirm + free-text note + error envelope rendering. - web/src/canvas/CanvasHead.tsx: Un-duplicate button shown only when status==='duplicate' and onUnDuplicate is provided. - web/src/canvas/SessionCanvas.tsx: modal mount + handler. - tests/test_dedup_mounted_in_build_app.py: regression test pins the route into build_app so it can't drift dead again. Other: - web/package.json + web/src/App.tsx: bumped to 2.0.0-rc2. - web/package.json: added @vitest/coverage-v8 devDep (web.yml runs `npx vitest run --coverage`). - web/.gitignore: coverage/ (vitest's lcov report). - .github/workflows/web-e2e.yml: corrected boot to `uvicorn runtime.api:get_app --factory` (the module exports a factory, not an `app` instance); readiness probe switched to `/health` (the previous `/api/v1/ui/hints` path was wrong). - docs/REACT_UI_PARITY.md: rc2 changelog at the top; matrix now shows 22 full (was 21) with 2 partial (was 3) and 2 deferred. Explicitly notes the rc1 app-overlay overclaim. - dist/* regenerated (HARD-08). Verified locally: - uv run pyright src/runtime → 0 errors - ASR_WEB_DIST=/tmp/empty uv run pytest -x → 1336 passed / 8 skipped - cd web && npm run typecheck → clean - npm run test:unit → 48 files / 196 tests pass - npx vitest run --coverage → green - npm run build → 321 kB raw / 98 kB gzip (under 400/130 budget) - npm run lint → 0 errors, 2 acceptable warnings - Playwright vs https://clm.randomcodespace.dev (Caddy → uvicorn:37777 running this branch): new-session.live PASS, retry-after-error.live PASS, hitl-approve.live SKIP.
1 parent b61c49b commit a56ccb9

21 files changed

Lines changed: 1247 additions & 35 deletions

.github/workflows/web-e2e.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,10 @@ jobs:
6464
- name: Boot backend (serves SPA + API)
6565
run: |
6666
mkdir -p logs
67-
nohup uv run uvicorn runtime.api:app --port 8000 > logs/uvicorn.log 2>&1 &
67+
nohup uv run uvicorn runtime.api:get_app --factory --port 8000 > logs/uvicorn.log 2>&1 &
6868
echo $! > .backend.pid
6969
for i in $(seq 1 60); do
70-
if curl -sf http://localhost:8000/api/v1/ui/hints >/dev/null; then
70+
if curl -sf http://localhost:8000/health >/dev/null; then
7171
echo "backend ready after ${i}s"
7272
exit 0
7373
fi

dist/app.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1440,6 +1440,7 @@ async def _poll(self, registry):
14401440

14411441

14421442

1443+
14431444
# ----- imports for runtime/api_dedup.py -----
14441445
"""Dedup retraction HTTP routes.
14451446

@@ -1462,8 +1463,9 @@ async def _poll(self, registry):
14621463
"""
14631464

14641465

1466+
from typing import Any, Callable, Union
14651467

1466-
from fastapi import FastAPI, HTTPException
1468+
from fastapi import APIRouter, FastAPI, HTTPException
14671469

14681470

14691471
# ----- imports for runtime/api_session_full.py -----
@@ -16338,6 +16340,19 @@ async def ws_events(websocket: WebSocket, session_id: str) -> None:
1633816340
# ==================================================================
1633916341
add_recent_events_routes(api_v1)
1634016342

16343+
# ==================================================================
16344+
# Dedup retraction: POST /api/v1/sessions/{id}/un-duplicate
16345+
# Operator-triggered correction when the dedup pipeline flipped a
16346+
# session to status='duplicate' incorrectly. The store rewrites
16347+
# status back to a runnable state in the same transaction as the
16348+
# audit row (see SessionStore.un_duplicate). Mounted on api_v1 so
16349+
# the route inherits the /api/v1 prefix and CORS/exception envelope.
16350+
# ==================================================================
16351+
register_dedup_routes(
16352+
api_v1,
16353+
store_provider=lambda: fastapi_app.state.orchestrator.store,
16354+
)
16355+
1634116356
# Legacy /incidents/* and /investigate redirects to /api/v1/* equivalents.
1634216357
# 308 preserves method + body so legacy POSTs (e.g. /incidents/{id}/resume)
1634316358
# keep working transparently. Removed in v2.1.
@@ -16428,12 +16443,17 @@ class UnDuplicateResponse(BaseModel):
1642816443

1642916444

1643016445
def register_dedup_routes(
16431-
app: FastAPI,
16446+
app: Union[FastAPI, APIRouter],
1643216447
*,
1643316448
store_provider: Callable[[], Any],
1643416449
) -> None:
1643516450
"""Register the un-duplicate route on ``app``.
1643616451

16452+
Accepts either a full ``FastAPI`` instance (used by lightweight
16453+
test fixtures so the URL has no prefix) or an ``APIRouter`` so
16454+
``runtime.api.build_app`` can mount the route on the ``/api/v1``
16455+
router and inherit its prefix.
16456+
1643716457
``store_provider`` is a no-arg callable that returns the live
1643816458
``SessionStore``. We accept a callable (rather than the store
1643916459
directly) so apps can defer construction until first request — the

dist/apps/code-review.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1440,6 +1440,7 @@ async def _poll(self, registry):
14401440

14411441

14421442

1443+
14431444
# ----- imports for runtime/api_dedup.py -----
14441445
"""Dedup retraction HTTP routes.
14451446

@@ -1462,8 +1463,9 @@ async def _poll(self, registry):
14621463
"""
14631464

14641465

1466+
from typing import Any, Callable, Union
14651467

1466-
from fastapi import FastAPI, HTTPException
1468+
from fastapi import APIRouter, FastAPI, HTTPException
14671469

14681470

14691471
# ----- imports for runtime/api_session_full.py -----
@@ -16391,6 +16393,19 @@ async def ws_events(websocket: WebSocket, session_id: str) -> None:
1639116393
# ==================================================================
1639216394
add_recent_events_routes(api_v1)
1639316395

16396+
# ==================================================================
16397+
# Dedup retraction: POST /api/v1/sessions/{id}/un-duplicate
16398+
# Operator-triggered correction when the dedup pipeline flipped a
16399+
# session to status='duplicate' incorrectly. The store rewrites
16400+
# status back to a runnable state in the same transaction as the
16401+
# audit row (see SessionStore.un_duplicate). Mounted on api_v1 so
16402+
# the route inherits the /api/v1 prefix and CORS/exception envelope.
16403+
# ==================================================================
16404+
register_dedup_routes(
16405+
api_v1,
16406+
store_provider=lambda: fastapi_app.state.orchestrator.store,
16407+
)
16408+
1639416409
# Legacy /incidents/* and /investigate redirects to /api/v1/* equivalents.
1639516410
# 308 preserves method + body so legacy POSTs (e.g. /incidents/{id}/resume)
1639616411
# keep working transparently. Removed in v2.1.
@@ -16481,12 +16496,17 @@ class UnDuplicateResponse(BaseModel):
1648116496

1648216497

1648316498
def register_dedup_routes(
16484-
app: FastAPI,
16499+
app: Union[FastAPI, APIRouter],
1648516500
*,
1648616501
store_provider: Callable[[], Any],
1648716502
) -> None:
1648816503
"""Register the un-duplicate route on ``app``.
1648916504

16505+
Accepts either a full ``FastAPI`` instance (used by lightweight
16506+
test fixtures so the URL has no prefix) or an ``APIRouter`` so
16507+
``runtime.api.build_app`` can mount the route on the ``/api/v1``
16508+
router and inherit its prefix.
16509+
1649016510
``store_provider`` is a no-arg callable that returns the live
1649116511
``SessionStore``. We accept a callable (rather than the store
1649216512
directly) so apps can defer construction until first request — the

dist/apps/incident-management.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1440,6 +1440,7 @@ async def _poll(self, registry):
14401440

14411441

14421442

1443+
14431444
# ----- imports for runtime/api_dedup.py -----
14441445
"""Dedup retraction HTTP routes.
14451446

@@ -1462,8 +1463,9 @@ async def _poll(self, registry):
14621463
"""
14631464

14641465

1466+
from typing import Any, Callable, Union
14651467

1466-
from fastapi import FastAPI, HTTPException
1468+
from fastapi import APIRouter, FastAPI, HTTPException
14671469

14681470

14691471
# ----- imports for runtime/api_session_full.py -----
@@ -16403,6 +16405,19 @@ async def ws_events(websocket: WebSocket, session_id: str) -> None:
1640316405
# ==================================================================
1640416406
add_recent_events_routes(api_v1)
1640516407

16408+
# ==================================================================
16409+
# Dedup retraction: POST /api/v1/sessions/{id}/un-duplicate
16410+
# Operator-triggered correction when the dedup pipeline flipped a
16411+
# session to status='duplicate' incorrectly. The store rewrites
16412+
# status back to a runnable state in the same transaction as the
16413+
# audit row (see SessionStore.un_duplicate). Mounted on api_v1 so
16414+
# the route inherits the /api/v1 prefix and CORS/exception envelope.
16415+
# ==================================================================
16416+
register_dedup_routes(
16417+
api_v1,
16418+
store_provider=lambda: fastapi_app.state.orchestrator.store,
16419+
)
16420+
1640616421
# Legacy /incidents/* and /investigate redirects to /api/v1/* equivalents.
1640716422
# 308 preserves method + body so legacy POSTs (e.g. /incidents/{id}/resume)
1640816423
# keep working transparently. Removed in v2.1.
@@ -16493,12 +16508,17 @@ class UnDuplicateResponse(BaseModel):
1649316508

1649416509

1649516510
def register_dedup_routes(
16496-
app: FastAPI,
16511+
app: Union[FastAPI, APIRouter],
1649716512
*,
1649816513
store_provider: Callable[[], Any],
1649916514
) -> None:
1650016515
"""Register the un-duplicate route on ``app``.
1650116516

16517+
Accepts either a full ``FastAPI`` instance (used by lightweight
16518+
test fixtures so the URL has no prefix) or an ``APIRouter`` so
16519+
``runtime.api.build_app`` can mount the route on the ``/api/v1``
16520+
router and inherit its prefix.
16521+
1650216522
``store_provider`` is a no-arg callable that returns the live
1650316523
``SessionStore``. We accept a callable (rather than the store
1650416524
directly) so apps can defer construction until first request — the

docs/REACT_UI_PARITY.md

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,53 +7,63 @@ can be removed in v2.1.
77
Streamlit module: `src/runtime/ui.py`.
88
React app: `web/src/`.
99

10+
**v2.0.0-rc2 changelog (this revision):** the rc1 matrix overclaimed
11+
that the app-overlay views were wired — they were not. rc2 actually
12+
wires them (`useAppViews` + `<SelectedPanel>` "App-specific views"
13+
section), plus closes the retry pipeline (preview + POST) and the
14+
un-duplicate flow (route mount + `<UnDuplicateModal>`). See PR
15+
`feat/v2-rc2-close-endpoint-gaps`.
16+
1017
## Coverage matrix
1118

1219
| Streamlit feature (function) | React equivalent | Status | Notes |
1320
|---|---|---|---|
1421
| Sidebar — session list (`render_sidebar`, `_render_session_row`) | `<SessionsRail>` + `<MonitorRail>` "Other Sessions" panel | **full** | Same data source (GET /api/v1/sessions); React adds keyboard-free selection + per-session badges |
1522
| Sidebar — active in-flight row (`_render_active_row`) | `<SessionsRail>` row with `data-active="true"` styling | **full** | React shows breathing dot via `asr-pulse` |
1623
| Investigate form (top-level form in `main`) | `<NewSessionModal>` | **full** | POST /api/v1/sessions with `{query, environment, submitter}`. Same envelope; modal vs. inline |
17-
| Session header / metadata (`_render_top_badges`, `_render_metrics`) | `<CanvasHead>` (eyebrow + title + meta row) | **full** | React adds active pulse + STOP / RETRY buttons inline |
24+
| Session header / metadata (`_render_top_badges`, `_render_metrics`) | `<CanvasHead>` (eyebrow + title + meta row) | **full** | React adds active pulse + STOP / RETRY / UN-DUPLICATE buttons inline |
1825
| Findings block (`_render_findings_block`) | `<SessionCanvas>``<Transcript>``<Turn>` body summary | **full** | Editorial layout vs. KV block; same source `session.findings` |
1926
| Resolution block (`_render_resolution_block`) | `<Transcript>` terminal turn + `<CanvasHead>` status pill | **full** | React shows status as `RESOLVED` pill rather than a separate section |
2027
| Hypothesis trail (`_render_hypothesis_trail_block`) | `<SelectedPanel>` when a tool call surfaces hypotheses; embedded in turn meta | **partial** | React surfaces hypothesis-shaped data via SelectedPanel but lacks the dedicated "Trail" view. Defer to v2.1; the underlying tool-call audit is unchanged. |
2128
| Pending approvals (`_render_pending_approvals_block`) | `<HITLBand>` inline + `<ApprovalsQueuePanel>` cross-session list | **full** | React drives the same POST /api/v1/sessions/{sid}/approvals/{tcid} endpoint |
2229
| Approve action | `<HITLBand>` Approve button → direct apiFetch | **full** | rationale=null path |
23-
| Approve with rationale | `<HITLBand>` Approve-with-rationale → `<ApproveRationaleModal>` | **full** | Includes uiHints.approval_rationale_templates chip row (Task 52) |
24-
| Reject action | `<HITLBand>` Reject → `<ConfirmModal>` destructive | **full** | Same endpoint with `decision: 'reject'` (Task 53) |
25-
| Stop session | `<CanvasHead>` Stop button → `<ConfirmModal>` destructive | **full** | DELETE /api/v1/sessions/{sid} (Task 53) |
26-
| Retry decision (`_render_retry_block`, `_preview_retry_decision_sync`) | `<CanvasHead>` Retry button (visible when status='error') | **partial** | Button calls `refresh()`. v2.1 will surface the retry preview JSON in a side modal |
30+
| Approve with rationale | `<HITLBand>` Approve-with-rationale → `<ApproveRationaleModal>` | **full** | Includes uiHints.approval_rationale_templates chip row |
31+
| Reject action | `<HITLBand>` Reject → `<ConfirmModal>` destructive | **full** | Same endpoint with `decision: 'reject'` |
32+
| Stop session | `<CanvasHead>` Stop button → `<ConfirmModal>` destructive | **full** | DELETE /api/v1/sessions/{sid} |
33+
| Retry decision (`_render_retry_block`, `_preview_retry_decision_sync`) | `<CanvasHead>` Retry button → `<ConfirmModal>` with preview reason → POST /sessions/{sid}/retry | **full (rc2)** | rc2: `useRetryPreview` drives enabled state + reason tooltip; confirm modal shows the preview reason; POST consumes the SSE body and refreshes the bootstrap bundle |
34+
| **Un-duplicate (rc2 new)** | `<CanvasHead>` Un-duplicate button (status==='duplicate') → `<UnDuplicateModal>` | **full (rc2)** | rc2: backend `register_dedup_routes` now wired in `build_app`; UI POSTs `{retracted_by, note}` to /sessions/{sid}/un-duplicate and refreshes |
2735
| Intervention block (`_render_intervention_block`) | `<HITLBand>` (question rendering, args dump, risk badge) | **full** | React renders policy + risk + waited-seconds + confidence in the same band |
2836
| Tool calls log (`_render_tool_calls_block`) | `<Transcript>` per-turn `<ToolCallCard>` list + `<SelectedPanel>` detail | **full** | React adds click-to-select via `useSetSelected` |
2937
| Agents accordion (`_render_agents_accordion`) | `<FlowStrip>` top-of-canvas pipeline overview | **partial** | FlowStrip shows agents-as-nodes with status; the detailed system_prompt_excerpt is in `<SelectedPanel>` when an agent is selected. Defer the "full prompt expander" to v2.1. |
3038
| Tools by category (`_render_tools_by_category`) | `<ToolsPanel>` monitor | **full** | Same GET /api/v1/tools; React groups by category in collapsible monitor |
3139
| Lessons learned | `<LessonsPanel>` monitor (per-session) | **full** | GET /api/v1/sessions/{sid}/lessons; React polls once via react-query |
3240
| Health (`_make_repository` health gating) | `<HealthPanel>` monitor + Topbar `<HealthDot>` | **full** | 30s poll of /health |
3341
| Cross-session activity feed | `<OtherSessionsPanel>` monitor | **full** | Powered by SSE GET /api/v1/sessions/recent/events |
34-
| App-specific UI views (Approach C overlays) | `<SelectedPanel>` "App-specific views →" links | **full** | GET /api/v1/apps/{app}/ui-views |
42+
| App-specific UI views (Approach C overlays) | `<SelectedPanel>` "App-specific views →" links via `useAppViews` | **full (rc2)** | rc1 docs claimed this; rc1 code did not deliver. rc2 actually wires `GET /api/v1/apps/{app}/ui-views` and renders matching links per selection (`always`, `agent:NAME`, `tool:NAME` filters) |
3543
| Run metadata + global status bar | `<Statusbar>` | **full** | sse event count + vm_seq + connection state + versions |
36-
| Mobile / responsive | `<MobileShell>` + `<TabletShell>` (Tasks 57-61) | **full** | Streamlit has no mobile story; React: <768 mobile, 768-1199 tablet, >=1200 desktop |
44+
| Mobile / responsive | `<MobileShell>` + `<TabletShell>` | **full** | Streamlit has no mobile story; React: <768 mobile, 768-1199 tablet, >=1200 desktop |
3745
| Keyboard shortcuts || **deferred** | Locked decision: no keyboard shortcuts in v2.0 (see in-flight notes); v2.1 reconsider |
3846
| Light/dark theme | Light only | **deferred** | Single light theme by design; dark mode is v2.1 |
3947

4048
## Verdict
4149

42-
- 21 features at **full** parity.
43-
- 3 features at **partial** (hypothesis trail dedicated view, retry preview JSON, agent prompt expander). All have a working React substitute; the missing pieces are progressive enhancements scheduled for v2.1.
44-
- 2 features intentionally **deferred** (keyboard shortcuts, dark theme).
50+
- **22 features at full parity** (rc2 promoted retry + app-overlay; un-duplicate added as new row, also full).
51+
- **2 features at partial** (hypothesis trail dedicated view, agent prompt expander). Both have a working React substitute; the missing pieces are progressive enhancements scheduled for v2.1.
52+
- **2 features intentionally deferred** (keyboard shortcuts, dark theme).
53+
54+
The React UI clears the v2.0.0-rc1 ship gate and rc2 sweep closes the endpoint-coverage gaps surfaced by the post-merge audit (rc1 → rc2 promotion proposal: cut a `v2.0.0-rc2` tag after this PR merges).
55+
56+
## Latent items (not in the parity matrix because Streamlit doesn't have them either)
4557

46-
The React UI clears the v2.0.0-rc1 ship gate. Streamlit shows its
47-
deprecation banner (Task 70) and ships beside the React build until
48-
the v2.0.0 GA release.
58+
- `POST /sessions/{id}/resume` for non-tool HITL (free-text input prompts). Today's HITL flow only resumes via the approvals POST; UI will need an additional code path when free-text HITL ships.
4959

5060
## Open ticket parking lot (v2.1)
5161

5262
- Hypothesis trail dedicated panel
53-
- Retry preview JSON in a side modal
5463
- Agent system_prompt expander accessible from the FlowStrip
5564
- Keyboard shortcuts: `?` overlay + `j/k` session navigation
5665
- Dark mode (re-derive accent + warm-cream palette)
5766
- App.tsx + SessionCanvas double `useSessionFull` subscription
5867
- `<Select>` Radix upgrade (currently native)
5968
- SessionId type rename (incident_management still emits `INC-`)
69+
- Multi-app per-deploy: today `appName` is informational (single app per deploy); v2.1 should switch to per-app filtering of `ui-views` endpoints

src/runtime/api.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from starlette.exceptions import HTTPException as StarletteHTTPException
3636

3737
from runtime.api_apps_overlay import add_apps_overlay_routes
38+
from runtime.api_dedup import register_dedup_routes
3839
from runtime.api_recent_events import add_recent_events_routes
3940
from runtime.api_session_full import add_session_full_routes
4041
from runtime.api_static import mount_static_assets
@@ -986,6 +987,19 @@ async def ws_events(websocket: WebSocket, session_id: str) -> None:
986987
# ==================================================================
987988
add_recent_events_routes(api_v1)
988989

990+
# ==================================================================
991+
# Dedup retraction: POST /api/v1/sessions/{id}/un-duplicate
992+
# Operator-triggered correction when the dedup pipeline flipped a
993+
# session to status='duplicate' incorrectly. The store rewrites
994+
# status back to a runnable state in the same transaction as the
995+
# audit row (see SessionStore.un_duplicate). Mounted on api_v1 so
996+
# the route inherits the /api/v1 prefix and CORS/exception envelope.
997+
# ==================================================================
998+
register_dedup_routes(
999+
api_v1,
1000+
store_provider=lambda: fastapi_app.state.orchestrator.store,
1001+
)
1002+
9891003
# Legacy /incidents/* and /investigate redirects to /api/v1/* equivalents.
9901004
# 308 preserves method + body so legacy POSTs (e.g. /incidents/{id}/resume)
9911005
# keep working transparently. Removed in v2.1.

src/runtime/api_dedup.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@
1919
"""
2020
from __future__ import annotations
2121

22-
from typing import Any, Callable
22+
from typing import Any, Callable, Union
2323

24-
from fastapi import FastAPI, HTTPException
24+
from fastapi import APIRouter, FastAPI, HTTPException
2525
from pydantic import BaseModel, Field
2626

2727

@@ -49,12 +49,17 @@ class UnDuplicateResponse(BaseModel):
4949

5050

5151
def register_dedup_routes(
52-
app: FastAPI,
52+
app: Union[FastAPI, APIRouter],
5353
*,
5454
store_provider: Callable[[], Any],
5555
) -> None:
5656
"""Register the un-duplicate route on ``app``.
5757
58+
Accepts either a full ``FastAPI`` instance (used by lightweight
59+
test fixtures so the URL has no prefix) or an ``APIRouter`` so
60+
``runtime.api.build_app`` can mount the route on the ``/api/v1``
61+
router and inherit its prefix.
62+
5863
``store_provider`` is a no-arg callable that returns the live
5964
``SessionStore``. We accept a callable (rather than the store
6065
directly) so apps can defer construction until first request — the
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Regression: register_dedup_routes is wired in production build_app.
2+
3+
The un-duplicate route lives in api_dedup.py (register_dedup_routes).
4+
v1.x left the registration unwired in build_app — only the in-test
5+
fixture mounted it — so the route was effectively dead in dist/app.py.
6+
v2.0.0-rc2 (Gap C in the endpoint audit) closes the gap by calling
7+
register_dedup_routes(api_v1, store_provider=...) inside build_app.
8+
9+
This test pins that wiring so a future refactor can't silently undo it.
10+
"""
11+
from fastapi.testclient import TestClient
12+
from runtime.api import build_app
13+
from tests.test_api_v1_url_move import _cfg
14+
15+
16+
def test_un_duplicate_route_is_mounted_on_build_app(tmp_path):
17+
app = build_app(_cfg(tmp_path))
18+
paths = {getattr(r, "path", None) for r in app.routes}
19+
assert "/api/v1/sessions/{session_id}/un-duplicate" in paths, (
20+
"register_dedup_routes is not wired in build_app — Gap C regression"
21+
)
22+
23+
24+
def test_un_duplicate_returns_404_for_unknown_session(tmp_path):
25+
"""Smoke through the live app — the route is reachable end-to-end,
26+
not just declared. 404 is the correct envelope for an unknown id."""
27+
app = build_app(_cfg(tmp_path))
28+
with TestClient(app) as client:
29+
r = client.post(
30+
"/api/v1/sessions/INC-29991231-001/un-duplicate",
31+
json={"retracted_by": "operator", "note": "smoke"},
32+
)
33+
assert r.status_code == 404, r.text

web/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ node_modules
22
dist
33
playwright-report
44
test-results
5+
coverage
56
*.log
67
.vite
78
.env.local

0 commit comments

Comments
 (0)