diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index cd22fe1..7e8fc96 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -2,85 +2,198 @@ ## sprint objective -Implement Sprint 6C: stabilize the `apps/web` workspace so lint and build are clean, repeatable, non-interactive verification steps while preserving the shipped Sprint 6A and Sprint 6B shell routes and workflow behavior. +Implement Sprint 6D by exposing deterministic user-scoped read-only trace review APIs for: + +- `GET /v0/traces` +- `GET /v0/traces/{trace_id}` +- `GET /v0/traces/{trace_id}/events` + +The sprint stayed limited to explain-why trace reads over existing persisted `traces` and `trace_events` data. ## completed work -- committed a stable ESLint setup in `apps/web` and switched the web lint script to `eslint . --max-warnings=0` so `npm run lint` no longer invokes interactive `next lint` setup -- intentionally adopted the Next-generated TypeScript config updates in `apps/web/tsconfig.json` - - added `esModuleInterop: true` - - added the `next` TypeScript plugin - - added `.next/types/**/*.ts` to `include` -- intentionally adopted the framework-managed `apps/web/next-env.d.ts` header text produced by Next.js -- made one behavior-preserving component change in `apps/web/components/approval-actions.tsx` to satisfy the committed hook lint rule without changing approval UI flow -- verified that `npm run build` no longer changes the contents of `apps/web/tsconfig.json` or `apps/web/next-env.d.ts` after those adopted config updates were in place +- introduced stable trace review contracts in `apps/api/src/alicebot_api/contracts.py` + - `TraceReviewSummaryRecord` + - `TraceReviewRecord` + - `TraceReviewListSummary` + - `TraceReviewListResponse` + - `TraceReviewDetailResponse` + - `TraceReviewEventRecord` + - `TraceReviewEventListSummary` + - `TraceReviewEventListResponse` + - `TRACE_REVIEW_LIST_ORDER` + - `TRACE_REVIEW_EVENT_LIST_ORDER` +- added a narrow trace review module in `apps/api/src/alicebot_api/traces.py` + - `list_trace_records()` + - `get_trace_record()` + - `list_trace_event_records()` + - `TraceNotFoundError` +- extended `apps/api/src/alicebot_api/store.py` with read-only trace review queries + - `list_trace_reviews()` + - `get_trace_review_optional()` + - deterministic list SQL with trace-event counts + - deterministic trace-event SQL ordering +- added FastAPI endpoints in `apps/api/src/alicebot_api/main.py` + - `GET /v0/traces` + - `GET /v0/traces/{trace_id}` + - `GET /v0/traces/{trace_id}/events` +- added unit coverage in `tests/unit/test_traces.py` for + - deterministic list ordering + - stable detail shape + - stable event-list shape + - invisible-trace not-found behavior + - endpoint translation and 404 mapping +- added Postgres-backed integration coverage in `tests/integration/test_traces_api.py` for + - deterministic trace list ordering + - trace detail reads + - ordered trace-event reads + - cross-user isolation + - invisible-trace 404 behavior ## incomplete work -- no backend endpoint, schema, or contract changes -- no new routes or workflow features -- no shell redesign or adjacent UI expansion -- no Gmail, Calendar, auth, runner, or connector scope expansion -- no additional frontend tests beyond the existing narrow verification set +- none inside the sprint’s scoped backend deliverables +- no UI migration from fixture-backed `/traces` +- no trace creation or mutation changes +- no filtering, search, or expanded explainability surface beyond the three read endpoints ## files changed +- `apps/api/src/alicebot_api/contracts.py` +- `apps/api/src/alicebot_api/main.py` +- `apps/api/src/alicebot_api/store.py` +- `apps/api/src/alicebot_api/traces.py` +- `tests/unit/test_traces.py` +- `tests/integration/test_traces_api.py` - `BUILD_REPORT.md` -- `apps/web/package.json` -- `apps/web/eslint.config.mjs` -- `apps/web/tsconfig.json` -- `apps/web/next-env.d.ts` -- `apps/web/components/approval-actions.tsx` + +## exact ordering rules + +- trace list reads use `created_at DESC, id DESC` +- trace-event reads use `sequence_no ASC, id ASC` ## tests run -- `npm run lint` in `apps/web` +- `./.venv/bin/python -m pytest tests/unit/test_traces.py` - PASS - - non-interactive after the committed ESLint config and script change -- `npm test` in `apps/web` + - `5` tests passed +- `./.venv/bin/python -m pytest tests/integration/test_traces_api.py` + - initial sandboxed run could not reach local Postgres on `localhost:5432` + - rerun as part of the full integration suite below passed +- `./.venv/bin/python -m pytest tests/unit` - PASS - - `2` test files, `5` tests passed -- `npm run build` in `apps/web` + - `451` tests passed +- `./.venv/bin/python -m pytest tests/integration` - PASS - - generated shipped routes remained intact: `/`, `/chat`, `/approvals`, `/tasks`, `/traces` - -## exact verification results - -- lint command used: `npm run lint` -- test command used: `npm test` -- build command used: `npm run build` -- TypeScript or Next-generated config changes intentionally adopted: yes - - `apps/web/tsconfig.json` - - `apps/web/next-env.d.ts` -- build stability check: - - `shasum apps/web/tsconfig.json apps/web/next-env.d.ts` was unchanged before vs. after `npm run build` - - pre-build checksum: - - `23632802ddf6784e5989d71338904efe50848844 apps/web/tsconfig.json` - - `f75a118439f630e5ca41d376cedef8db9b6d7fc6 apps/web/next-env.d.ts` - - post-build checksum: - - `23632802ddf6784e5989d71338904efe50848844 apps/web/tsconfig.json` - - `f75a118439f630e5ca41d376cedef8db9b6d7fc6 apps/web/next-env.d.ts` - -## route and behavior confirmation - -- route generation from `next build` still includes `/`, `/chat`, `/approvals`, `/tasks`, and `/traces` -- Sprint 6A and 6B governed request, approval, and task behavior remained intact -- no workflow logic or API contract changes were introduced; the only non-config code change was the `approval-actions` hook dependency cleanup required by lint + - `143` tests passed + +## unit and integration test results + +- unit result: PASS + - trace review module coverage and endpoint translation coverage passed +- integration result: PASS + - live Postgres-backed trace review list/detail/event and isolation coverage passed + +## example trace list response + +```json +{ + "items": [ + { + "id": "00000000-0000-4000-8000-000000000002", + "thread_id": "11111111-1111-4111-8111-111111111111", + "kind": "tool.proxy.execute", + "compiler_version": "response_generation_v0", + "status": "completed", + "created_at": "2026-03-17T09:00:00+00:00", + "trace_event_count": 2 + }, + { + "id": "00000000-0000-4000-8000-000000000001", + "thread_id": "11111111-1111-4111-8111-111111111111", + "kind": "context.compile", + "compiler_version": "continuity_v0", + "status": "completed", + "created_at": "2026-03-17T09:00:00+00:00", + "trace_event_count": 1 + } + ], + "summary": { + "total_count": 2, + "order": ["created_at_desc", "id_desc"] + } +} +``` + +## example trace detail response + +```json +{ + "trace": { + "id": "00000000-0000-4000-8000-000000000002", + "thread_id": "11111111-1111-4111-8111-111111111111", + "kind": "tool.proxy.execute", + "compiler_version": "response_generation_v0", + "status": "completed", + "limits": { + "max_sessions": 1, + "max_events": 2 + }, + "created_at": "2026-03-17T09:00:00+00:00", + "trace_event_count": 2 + } +} +``` + +## example trace-event list response + +```json +{ + "items": [ + { + "id": "10000000-0000-4000-8000-000000000002", + "trace_id": "00000000-0000-4000-8000-000000000002", + "sequence_no": 1, + "kind": "tool.proxy.execute.request", + "payload": { + "approval_id": "approval-2" + }, + "created_at": "2026-03-17T09:00:00+00:00" + }, + { + "id": "10000000-0000-4000-8000-000000000001", + "trace_id": "00000000-0000-4000-8000-000000000002", + "sequence_no": 2, + "kind": "tool.proxy.execute.summary", + "payload": { + "approval_id": "approval-2" + }, + "created_at": "2026-03-17T09:00:00+00:00" + } + ], + "summary": { + "trace_id": "00000000-0000-4000-8000-000000000002", + "total_count": 2, + "order": ["sequence_no_asc", "id_asc"] + } +} +``` ## blockers/issues -- no active blockers after the repair -- initial pre-fix issue reproduced exactly as described in the sprint packet: - - `npm run lint` prompted for ESLint initialization because the workspace had no committed lint config - - `next build` rewrote `apps/web/tsconfig.json` and `apps/web/next-env.d.ts` until those framework-required changes were intentionally adopted +- no remaining product-scope blockers +- one execution-time environment issue occurred during verification + - sandboxed integration setup could not connect to local Postgres on `localhost:5432` + - rerunning the required integration suite with local database access resolved verification ## recommended next step -Run review against this repair sprint and, if it passes, treat the committed ESLint config plus adopted Next TypeScript settings as the new stable baseline for future `apps/web` UI work. +Hook the existing `/traces` web surface off these live endpoints in a separate sprint, while preserving the same narrow persisted-data-only contract. ## intentionally deferred after this sprint -- any new product workflow surface beyond the existing `/`, `/chat`, `/approvals`, `/tasks`, and `/traces` routes -- any backend work -- any visual redesign -- any additional test expansion beyond narrow preservation coverage +- any UI changes +- any trace mutation endpoints +- any new trace production behavior +- any connector, Gmail, Calendar, approval-flow, or execution-scope expansion +- any search, filtering, pagination, or explainability enrichment beyond persisted trace and trace-event reads diff --git a/REVIEW_REPORT.md b/REVIEW_REPORT.md index dfd05ad..c20c146 100644 --- a/REVIEW_REPORT.md +++ b/REVIEW_REPORT.md @@ -6,44 +6,49 @@ PASS ## criteria met -- `apps/web/eslint.config.mjs` is now tracked in git, so the non-interactive lint setup is part of the sprint change set. -- `npm run lint` in `apps/web` runs non-interactively and passes with `eslint . --max-warnings=0`. -- `npm test` in `apps/web` passes: `2` test files, `5` tests passed. -- `npm run build` in `apps/web` passes. -- The build output includes the shipped routes `/`, `/chat`, `/approvals`, `/tasks`, and `/traces`. -- `apps/web/tsconfig.json` and `apps/web/next-env.d.ts` remain byte-stable before and after `npm run build`; the `shasum` values were unchanged. -- The implementation stayed inside the repair sprint scope: no backend endpoint, schema, auth, Gmail, Calendar, runner, or product-scope expansion was introduced. -- The only behavior code change remains the narrow `approval-actions` hook dependency cleanup, and the existing approval flow coverage still passes. -- `BUILD_REPORT.md` now matches the current tracked repair: committed ESLint config, adopted Next TypeScript config normalization, exact verification commands, and explicit deferred scope. +- `GET /v0/traces`, `GET /v0/traces/{trace_id}`, and `GET /v0/traces/{trace_id}/events` are implemented in `apps/api/src/alicebot_api/main.py`. +- The sprint stayed limited to read-only trace review over existing persisted `traces` and `trace_events` data. +- Stable trace review contracts were added in `apps/api/src/alicebot_api/contracts.py`. +- The review seam in `apps/api/src/alicebot_api/traces.py` returns deterministic list, detail, and event payloads. +- Deterministic ordering is explicit and test-backed: + - trace list: `created_at DESC, id DESC` + - trace events: `sequence_no ASC, id ASC` +- User isolation is preserved through user-scoped connections plus existing row-level security behavior, and invisible traces return `404`. +- Unit coverage was added for ordering, response shape, endpoint mapping, and invisible-trace handling. +- Integration coverage was added for list/detail/event reads, cross-user isolation, and invisible-trace `404` behavior. +- Acceptance verification passed: + - `./.venv/bin/python -m pytest tests/unit` -> `451 passed` + - `./.venv/bin/python -m pytest tests/integration` -> `143 passed` ## criteria missed -- None. +- none ## quality issues -- No blocking quality issues found in the current sprint change set. -- Coverage remains intentionally narrow, but it is adequate for this repair sprint because the functional code change is minimal and behavior-preserving. +- none blocking +- No sloppy scope expansion was found. The diff is confined to the intended API/store/contracts/test surface plus `BUILD_REPORT.md`. ## regression risks -- Low. -- Residual risk is mostly future UI drift outside this sprint, not the stabilization change itself. Current automated coverage still does not provide route-level smoke tests across all shipped pages. +- Low risk overall. The new seam is narrow and read-only. +- The main ongoing dependency is correct use of `user_connection()` so row-level security remains active for trace visibility. Current unit and integration coverage exercises that path. ## docs issues -- No blocking docs issues found. -- No `ARCHITECTURE.md` update is needed for this repair sprint. +- none blocking +- `BUILD_REPORT.md` matches the sprint packet requirements, including contracts, ordering rules, commands run, example responses, and deferred scope. ## should anything be added to RULES.md? -- No required change. -- Optional future rule: frontend workspaces should commit a repo-owned non-interactive lint configuration before `lint` is treated as a standard verification gate. +- no ## should anything update ARCHITECTURE.md? -- No. This sprint stayed in tooling/workspace stabilization and did not reveal an architecture contradiction. +- no required update for sprint acceptance +- Optional future update: document the new read-only trace review API surface when the `/traces` UI switches from fixtures to live backend reads. ## recommended next action -- Accept Sprint 6C and treat the tracked ESLint config plus adopted Next TypeScript settings as the new stable baseline for future `apps/web` work. +- Accept the sprint. +- In a follow-up sprint, replace the fixture-backed `/traces` UI with these live endpoints without widening the backend contract. diff --git a/apps/api/src/alicebot_api/contracts.py b/apps/api/src/alicebot_api/contracts.py index 4453118..83afd52 100644 --- a/apps/api/src/alicebot_api/contracts.py +++ b/apps/api/src/alicebot_api/contracts.py @@ -98,6 +98,8 @@ RESPONSE_GENERATION_VERSION_V0 = "response_generation_v0" TRACE_KIND_CONTEXT_COMPILE = "context.compile" TRACE_KIND_RESPONSE_GENERATE = "response.generate" +TRACE_REVIEW_LIST_ORDER = ["created_at_desc", "id_desc"] +TRACE_REVIEW_EVENT_LIST_ORDER = ["sequence_no_asc", "id_asc"] MEMORY_REVIEW_ORDER = ["updated_at_desc", "created_at_desc", "id_desc"] MEMORY_REVIEW_QUEUE_ORDER = ["updated_at_desc", "created_at_desc", "id_desc"] MEMORY_REVISION_REVIEW_ORDER = ["sequence_no_asc"] @@ -314,6 +316,54 @@ class TraceEventRecord: payload: JsonObject +class TraceReviewSummaryRecord(TypedDict): + id: str + thread_id: str + kind: str + compiler_version: str + status: str + created_at: str + trace_event_count: int + + +class TraceReviewRecord(TraceReviewSummaryRecord): + limits: JsonObject + + +class TraceReviewListSummary(TypedDict): + total_count: int + order: list[str] + + +class TraceReviewListResponse(TypedDict): + items: list[TraceReviewSummaryRecord] + summary: TraceReviewListSummary + + +class TraceReviewDetailResponse(TypedDict): + trace: TraceReviewRecord + + +class TraceReviewEventRecord(TypedDict): + id: str + trace_id: str + sequence_no: int + kind: str + payload: JsonObject + created_at: str + + +class TraceReviewEventListSummary(TypedDict): + trace_id: str + total_count: int + order: list[str] + + +class TraceReviewEventListResponse(TypedDict): + items: list[TraceReviewEventRecord] + summary: TraceReviewEventListSummary + + @dataclass(frozen=True, slots=True) class CompilerDecision: kind: DecisionKind diff --git a/apps/api/src/alicebot_api/main.py b/apps/api/src/alicebot_api/main.py index 40e3327..4ded160 100644 --- a/apps/api/src/alicebot_api/main.py +++ b/apps/api/src/alicebot_api/main.py @@ -238,6 +238,12 @@ execute_approved_proxy_request, ) from alicebot_api.store import ContinuityStore, ContinuityStoreInvariantError +from alicebot_api.traces import ( + TraceNotFoundError, + get_trace_record, + list_trace_event_records, + list_trace_records, +) app = FastAPI(title="AliceBot API", version="0.1.0") @@ -837,6 +843,62 @@ def generate_assistant_response(request: GenerateResponseRequest) -> JSONRespons ) +@app.get("/v0/traces") +def list_traces(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = list_trace_records( + ContinuityStore(conn), + user_id=user_id, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/traces/{trace_id}") +def get_trace(trace_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_trace_record( + ContinuityStore(conn), + user_id=user_id, + trace_id=trace_id, + ) + except TraceNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/traces/{trace_id}/events") +def list_trace_events(trace_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = list_trace_event_records( + ContinuityStore(conn), + user_id=user_id, + trace_id=trace_id, + ) + except TraceNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + @app.post("/v0/memories/admit") def admit_memory(request: AdmitMemoryRequest) -> JSONResponse: settings = get_settings() diff --git a/apps/api/src/alicebot_api/store.py b/apps/api/src/alicebot_api/store.py index c12d464..adfaec7 100644 --- a/apps/api/src/alicebot_api/store.py +++ b/apps/api/src/alicebot_api/store.py @@ -70,6 +70,18 @@ class TraceEventRow(TypedDict): created_at: datetime +class TraceReviewRow(TypedDict): + id: UUID + user_id: UUID + thread_id: UUID + kind: str + compiler_version: str + status: str + limits: JsonObject + created_at: datetime + trace_event_count: int + + class MemoryRow(TypedDict): id: UUID user_id: UUID @@ -463,6 +475,60 @@ class LabelCountRow(TypedDict): WHERE id = %s """ +LIST_TRACE_REVIEWS_SQL = """ + SELECT + traces.id, + traces.user_id, + traces.thread_id, + traces.kind, + traces.compiler_version, + traces.status, + traces.limits, + traces.created_at, + COUNT(trace_events.id) AS trace_event_count + FROM traces + LEFT JOIN trace_events + ON trace_events.trace_id = traces.id + AND trace_events.user_id = traces.user_id + GROUP BY + traces.id, + traces.user_id, + traces.thread_id, + traces.kind, + traces.compiler_version, + traces.status, + traces.limits, + traces.created_at + ORDER BY traces.created_at DESC, traces.id DESC + """ + +GET_TRACE_REVIEW_SQL = """ + SELECT + traces.id, + traces.user_id, + traces.thread_id, + traces.kind, + traces.compiler_version, + traces.status, + traces.limits, + traces.created_at, + COUNT(trace_events.id) AS trace_event_count + FROM traces + LEFT JOIN trace_events + ON trace_events.trace_id = traces.id + AND trace_events.user_id = traces.user_id + WHERE traces.id = %s + GROUP BY + traces.id, + traces.user_id, + traces.thread_id, + traces.kind, + traces.compiler_version, + traces.status, + traces.limits, + traces.created_at + """ + INSERT_TRACE_EVENT_SQL = """ INSERT INTO trace_events (user_id, trace_id, sequence_no, kind, payload) VALUES (app.current_user_id(), %s, %s, %s, %s) @@ -473,7 +539,7 @@ class LabelCountRow(TypedDict): SELECT id, user_id, trace_id, sequence_no, kind, payload, created_at FROM trace_events WHERE trace_id = %s - ORDER BY sequence_no ASC + ORDER BY sequence_no ASC, id ASC """ INSERT_MEMORY_SQL = """ @@ -2573,6 +2639,12 @@ def create_trace( def get_trace(self, trace_id: UUID) -> TraceRow: return self._fetch_one("get_trace", GET_TRACE_SQL, (trace_id,)) + def get_trace_review_optional(self, trace_id: UUID) -> TraceReviewRow | None: + return self._fetch_optional_one(GET_TRACE_REVIEW_SQL, (trace_id,)) + + def list_trace_reviews(self) -> list[TraceReviewRow]: + return self._fetch_all(LIST_TRACE_REVIEWS_SQL) + def append_trace_event( self, *, diff --git a/apps/api/src/alicebot_api/traces.py b/apps/api/src/alicebot_api/traces.py new file mode 100644 index 0000000..6d15719 --- /dev/null +++ b/apps/api/src/alicebot_api/traces.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from uuid import UUID + +from alicebot_api.contracts import ( + TRACE_REVIEW_EVENT_LIST_ORDER, + TRACE_REVIEW_LIST_ORDER, + TraceReviewDetailResponse, + TraceReviewEventListResponse, + TraceReviewEventListSummary, + TraceReviewEventRecord, + TraceReviewListResponse, + TraceReviewListSummary, + TraceReviewRecord, + TraceReviewSummaryRecord, +) +from alicebot_api.store import ContinuityStore, TraceEventRow, TraceReviewRow + + +class TraceNotFoundError(LookupError): + """Raised when a requested trace is not visible inside the current user scope.""" + + +def _serialize_trace_summary(trace: TraceReviewRow) -> TraceReviewSummaryRecord: + return { + "id": str(trace["id"]), + "thread_id": str(trace["thread_id"]), + "kind": trace["kind"], + "compiler_version": trace["compiler_version"], + "status": trace["status"], + "created_at": trace["created_at"].isoformat(), + "trace_event_count": trace["trace_event_count"], + } + + +def _serialize_trace(trace: TraceReviewRow) -> TraceReviewRecord: + summary = _serialize_trace_summary(trace) + return { + **summary, + "limits": trace["limits"], + } + + +def _serialize_trace_event(trace_event: TraceEventRow) -> TraceReviewEventRecord: + return { + "id": str(trace_event["id"]), + "trace_id": str(trace_event["trace_id"]), + "sequence_no": trace_event["sequence_no"], + "kind": trace_event["kind"], + "payload": trace_event["payload"], + "created_at": trace_event["created_at"].isoformat(), + } + + +def list_trace_records( + store: ContinuityStore, + *, + user_id: UUID, +) -> TraceReviewListResponse: + del user_id + + items = [_serialize_trace_summary(trace) for trace in store.list_trace_reviews()] + summary: TraceReviewListSummary = { + "total_count": len(items), + "order": list(TRACE_REVIEW_LIST_ORDER), + } + return { + "items": items, + "summary": summary, + } + + +def get_trace_record( + store: ContinuityStore, + *, + user_id: UUID, + trace_id: UUID, +) -> TraceReviewDetailResponse: + del user_id + + trace = store.get_trace_review_optional(trace_id) + if trace is None: + raise TraceNotFoundError(f"trace {trace_id} was not found") + + return {"trace": _serialize_trace(trace)} + + +def list_trace_event_records( + store: ContinuityStore, + *, + user_id: UUID, + trace_id: UUID, +) -> TraceReviewEventListResponse: + del user_id + + trace = store.get_trace_review_optional(trace_id) + if trace is None: + raise TraceNotFoundError(f"trace {trace_id} was not found") + + items = [_serialize_trace_event(trace_event) for trace_event in store.list_trace_events(trace_id)] + summary: TraceReviewEventListSummary = { + "trace_id": str(trace["id"]), + "total_count": len(items), + "order": list(TRACE_REVIEW_EVENT_LIST_ORDER), + } + return { + "items": items, + "summary": summary, + } diff --git a/tests/integration/test_traces_api.py b/tests/integration/test_traces_api.py new file mode 100644 index 0000000..3daf0d7 --- /dev/null +++ b/tests/integration/test_traces_api.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def create_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def seed_user_with_traces(database_url: str, *, email: str) -> dict[str, object]: + user_id = create_user(database_url, email=email) + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + thread = store.create_thread("Trace review thread") + first_trace = store.create_trace( + user_id=user_id, + thread_id=thread["id"], + kind="context.compile", + compiler_version="continuity_v0", + status="completed", + limits={"max_sessions": 3, "max_events": 8}, + ) + second_trace = store.create_trace( + user_id=user_id, + thread_id=thread["id"], + kind="tool.proxy.execute", + compiler_version="response_generation_v0", + status="completed", + limits={"max_sessions": 1, "max_events": 2}, + ) + first_trace_event = store.append_trace_event( + trace_id=second_trace["id"], + sequence_no=2, + kind="tool.proxy.execute.summary", + payload={"approval_id": "approval-2"}, + ) + second_trace_event = store.append_trace_event( + trace_id=second_trace["id"], + sequence_no=1, + kind="tool.proxy.execute.request", + payload={"approval_id": "approval-2"}, + ) + third_trace_event = store.append_trace_event( + trace_id=first_trace["id"], + sequence_no=1, + kind="context.summary", + payload={"thread_id": str(thread["id"])}, + ) + + return { + "user_id": user_id, + "thread_id": thread["id"], + "first_trace": first_trace, + "second_trace": second_trace, + "events": { + "second_trace_second": first_trace_event, + "second_trace_first": second_trace_event, + "first_trace_only": third_trace_event, + }, + } + + +def serialize_trace_summary(trace: dict[str, Any], *, trace_event_count: int) -> dict[str, Any]: + return { + "id": str(trace["id"]), + "thread_id": str(trace["thread_id"]), + "kind": trace["kind"], + "compiler_version": trace["compiler_version"], + "status": trace["status"], + "created_at": trace["created_at"].isoformat(), + "trace_event_count": trace_event_count, + } + + +def serialize_trace_detail(trace: dict[str, Any], *, trace_event_count: int) -> dict[str, Any]: + return { + **serialize_trace_summary(trace, trace_event_count=trace_event_count), + "limits": trace["limits"], + } + + +def serialize_trace_event(trace_event: dict[str, Any]) -> dict[str, Any]: + return { + "id": str(trace_event["id"]), + "trace_id": str(trace_event["trace_id"]), + "sequence_no": trace_event["sequence_no"], + "kind": trace_event["kind"], + "payload": trace_event["payload"], + "created_at": trace_event["created_at"].isoformat(), + } + + +def test_trace_review_endpoints_list_detail_and_events_with_deterministic_order( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user_with_traces(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + list_status, list_payload = invoke_request( + "GET", + "/v0/traces", + query_params={"user_id": str(seeded["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/traces/{seeded['second_trace']['id']}", + query_params={"user_id": str(seeded["user_id"])}, + ) + events_status, events_payload = invoke_request( + "GET", + f"/v0/traces/{seeded['second_trace']['id']}/events", + query_params={"user_id": str(seeded["user_id"])}, + ) + + expected_trace_order = sorted( + [seeded["first_trace"], seeded["second_trace"]], + key=lambda trace: (trace["created_at"], trace["id"]), + reverse=True, + ) + + assert list_status == 200 + assert list_payload == { + "items": [ + serialize_trace_summary( + expected_trace_order[0], + trace_event_count=2 if expected_trace_order[0]["id"] == seeded["second_trace"]["id"] else 1, + ), + serialize_trace_summary( + expected_trace_order[1], + trace_event_count=2 if expected_trace_order[1]["id"] == seeded["second_trace"]["id"] else 1, + ), + ], + "summary": { + "total_count": 2, + "order": ["created_at_desc", "id_desc"], + }, + } + + assert detail_status == 200 + assert detail_payload == { + "trace": serialize_trace_detail(seeded["second_trace"], trace_event_count=2) + } + + assert events_status == 200 + assert events_payload == { + "items": [ + serialize_trace_event(seeded["events"]["second_trace_first"]), + serialize_trace_event(seeded["events"]["second_trace_second"]), + ], + "summary": { + "trace_id": str(seeded["second_trace"]["id"]), + "total_count": 2, + "order": ["sequence_no_asc", "id_asc"], + }, + } + + +def test_trace_review_endpoints_enforce_user_isolation_and_not_found( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user_with_traces(migrated_database_urls["app"], email="owner@example.com") + intruder_id = create_user(migrated_database_urls["app"], email="intruder@example.com") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + list_status, list_payload = invoke_request( + "GET", + "/v0/traces", + query_params={"user_id": str(intruder_id)}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/traces/{owner['second_trace']['id']}", + query_params={"user_id": str(intruder_id)}, + ) + events_status, events_payload = invoke_request( + "GET", + f"/v0/traces/{owner['second_trace']['id']}/events", + query_params={"user_id": str(intruder_id)}, + ) + + assert list_status == 200 + assert list_payload == { + "items": [], + "summary": { + "total_count": 0, + "order": ["created_at_desc", "id_desc"], + }, + } + assert detail_status == 404 + assert detail_payload == { + "detail": f"trace {owner['second_trace']['id']} was not found", + } + assert events_status == 404 + assert events_payload == { + "detail": f"trace {owner['second_trace']['id']} was not found", + } diff --git a/tests/unit/test_traces.py b/tests/unit/test_traces.py new file mode 100644 index 0000000..82a71d3 --- /dev/null +++ b/tests/unit/test_traces.py @@ -0,0 +1,420 @@ +from __future__ import annotations + +from contextlib import contextmanager +from datetime import UTC, datetime +import json +from uuid import UUID, uuid4 + +import pytest + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.traces import ( + TraceNotFoundError, + get_trace_record, + list_trace_event_records, + list_trace_records, +) + + +class TraceStoreStub: + def __init__(self, *, current_user_id: UUID) -> None: + self.current_user_id = current_user_id + self.base_time = datetime(2026, 3, 17, 9, 0, tzinfo=UTC) + self.traces: list[dict[str, object]] = [] + self.trace_events: list[dict[str, object]] = [] + + def add_trace( + self, + *, + trace_id: UUID, + user_id: UUID, + thread_id: UUID, + kind: str, + compiler_version: str, + status: str, + limits: dict[str, object], + created_at: datetime | None = None, + ) -> dict[str, object]: + trace = { + "id": trace_id, + "user_id": user_id, + "thread_id": thread_id, + "kind": kind, + "compiler_version": compiler_version, + "status": status, + "limits": limits, + "created_at": created_at or self.base_time, + } + self.traces.append(trace) + return trace + + def add_trace_event( + self, + *, + event_id: UUID, + user_id: UUID, + trace_id: UUID, + sequence_no: int, + kind: str, + payload: dict[str, object], + created_at: datetime | None = None, + ) -> dict[str, object]: + event = { + "id": event_id, + "user_id": user_id, + "trace_id": trace_id, + "sequence_no": sequence_no, + "kind": kind, + "payload": payload, + "created_at": created_at or self.base_time, + } + self.trace_events.append(event) + return event + + def list_trace_reviews(self) -> list[dict[str, object]]: + visible_traces = [ + trace + for trace in self.traces + if trace["user_id"] == self.current_user_id + ] + rows = [] + for trace in visible_traces: + rows.append( + { + **trace, + "trace_event_count": len( + [ + event + for event in self.trace_events + if event["user_id"] == self.current_user_id + and event["trace_id"] == trace["id"] + ] + ), + } + ) + return sorted(rows, key=lambda row: (row["created_at"], row["id"]), reverse=True) + + def get_trace_review_optional(self, trace_id: UUID) -> dict[str, object] | None: + return next((trace for trace in self.list_trace_reviews() if trace["id"] == trace_id), None) + + def list_trace_events(self, trace_id: UUID) -> list[dict[str, object]]: + visible_events = [ + event + for event in self.trace_events + if event["user_id"] == self.current_user_id and event["trace_id"] == trace_id + ] + return sorted(visible_events, key=lambda event: (event["sequence_no"], event["id"])) + + +def test_trace_review_records_preserve_deterministic_order_isolation_and_shape() -> None: + owner_id = uuid4() + intruder_id = uuid4() + first_trace_id = UUID("00000000-0000-4000-8000-000000000001") + second_trace_id = UUID("00000000-0000-4000-8000-000000000002") + hidden_trace_id = UUID("00000000-0000-4000-8000-000000000003") + owner_thread_id = uuid4() + hidden_thread_id = uuid4() + store = TraceStoreStub(current_user_id=owner_id) + + store.add_trace( + trace_id=first_trace_id, + user_id=owner_id, + thread_id=owner_thread_id, + kind="context.compile", + compiler_version="continuity_v0", + status="completed", + limits={"max_sessions": 3, "max_events": 8}, + ) + store.add_trace( + trace_id=second_trace_id, + user_id=owner_id, + thread_id=owner_thread_id, + kind="tool.proxy.execute", + compiler_version="response_generation_v0", + status="completed", + limits={"max_sessions": 1, "max_events": 2}, + ) + store.add_trace( + trace_id=hidden_trace_id, + user_id=intruder_id, + thread_id=hidden_thread_id, + kind="approval.request", + compiler_version="approval_request_v0", + status="completed", + limits={"max_sessions": 1, "max_events": 1}, + ) + + store.add_trace_event( + event_id=UUID("10000000-0000-4000-8000-000000000001"), + user_id=owner_id, + trace_id=second_trace_id, + sequence_no=2, + kind="tool.proxy.execute.summary", + payload={"approval_id": "approval-2"}, + ) + store.add_trace_event( + event_id=UUID("10000000-0000-4000-8000-000000000002"), + user_id=owner_id, + trace_id=second_trace_id, + sequence_no=1, + kind="tool.proxy.execute.request", + payload={"approval_id": "approval-2"}, + ) + store.add_trace_event( + event_id=UUID("10000000-0000-4000-8000-000000000003"), + user_id=owner_id, + trace_id=first_trace_id, + sequence_no=1, + kind="context.summary", + payload={"thread_id": str(owner_thread_id)}, + ) + store.add_trace_event( + event_id=UUID("10000000-0000-4000-8000-000000000004"), + user_id=intruder_id, + trace_id=hidden_trace_id, + sequence_no=1, + kind="approval.request.summary", + payload={"approval_id": "approval-hidden"}, + ) + + listed = list_trace_records( + store, # type: ignore[arg-type] + user_id=owner_id, + ) + detail = get_trace_record( + store, # type: ignore[arg-type] + user_id=owner_id, + trace_id=second_trace_id, + ) + events = list_trace_event_records( + store, # type: ignore[arg-type] + user_id=owner_id, + trace_id=second_trace_id, + ) + + assert listed == { + "items": [ + { + "id": str(second_trace_id), + "thread_id": str(owner_thread_id), + "kind": "tool.proxy.execute", + "compiler_version": "response_generation_v0", + "status": "completed", + "created_at": store.base_time.isoformat(), + "trace_event_count": 2, + }, + { + "id": str(first_trace_id), + "thread_id": str(owner_thread_id), + "kind": "context.compile", + "compiler_version": "continuity_v0", + "status": "completed", + "created_at": store.base_time.isoformat(), + "trace_event_count": 1, + }, + ], + "summary": { + "total_count": 2, + "order": ["created_at_desc", "id_desc"], + }, + } + assert detail == { + "trace": { + "id": str(second_trace_id), + "thread_id": str(owner_thread_id), + "kind": "tool.proxy.execute", + "compiler_version": "response_generation_v0", + "status": "completed", + "limits": {"max_sessions": 1, "max_events": 2}, + "created_at": store.base_time.isoformat(), + "trace_event_count": 2, + } + } + assert events == { + "items": [ + { + "id": "10000000-0000-4000-8000-000000000002", + "trace_id": str(second_trace_id), + "sequence_no": 1, + "kind": "tool.proxy.execute.request", + "payload": {"approval_id": "approval-2"}, + "created_at": store.base_time.isoformat(), + }, + { + "id": "10000000-0000-4000-8000-000000000001", + "trace_id": str(second_trace_id), + "sequence_no": 2, + "kind": "tool.proxy.execute.summary", + "payload": {"approval_id": "approval-2"}, + "created_at": store.base_time.isoformat(), + }, + ], + "summary": { + "trace_id": str(second_trace_id), + "total_count": 2, + "order": ["sequence_no_asc", "id_asc"], + }, + } + + +def test_trace_review_records_raise_not_found_for_invisible_traces() -> None: + owner_id = uuid4() + intruder_id = uuid4() + hidden_trace_id = uuid4() + store = TraceStoreStub(current_user_id=intruder_id) + store.add_trace( + trace_id=hidden_trace_id, + user_id=owner_id, + thread_id=uuid4(), + kind="context.compile", + compiler_version="continuity_v0", + status="completed", + limits={"max_sessions": 3}, + ) + store.add_trace_event( + event_id=uuid4(), + user_id=owner_id, + trace_id=hidden_trace_id, + sequence_no=1, + kind="context.summary", + payload={"scope": "owner-only"}, + ) + + listed = list_trace_records( + store, # type: ignore[arg-type] + user_id=intruder_id, + ) + + assert listed == { + "items": [], + "summary": { + "total_count": 0, + "order": ["created_at_desc", "id_desc"], + }, + } + with pytest.raises(TraceNotFoundError, match=f"trace {hidden_trace_id} was not found"): + get_trace_record( + store, # type: ignore[arg-type] + user_id=intruder_id, + trace_id=hidden_trace_id, + ) + with pytest.raises(TraceNotFoundError, match=f"trace {hidden_trace_id} was not found"): + list_trace_event_records( + store, # type: ignore[arg-type] + user_id=intruder_id, + trace_id=hidden_trace_id, + ) + + +def test_list_traces_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_list_trace_records(store, *, user_id): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + return { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_desc", "id_desc"]}, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "list_trace_records", fake_list_trace_records) + + response = main_module.list_traces(user_id) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_desc", "id_desc"]}, + } + assert captured == { + "database_url": "postgresql://app", + "current_user_id": user_id, + "store_type": "ContinuityStore", + "user_id": user_id, + } + + +def test_get_trace_endpoint_maps_not_found_to_404(monkeypatch) -> None: + user_id = uuid4() + trace_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_get_trace_record(*_args, **_kwargs): + raise TraceNotFoundError(f"trace {trace_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "get_trace_record", fake_get_trace_record) + + response = main_module.get_trace(trace_id, user_id) + + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": f"trace {trace_id} was not found"} + + +def test_list_trace_events_endpoint_returns_payload_and_maps_not_found(monkeypatch) -> None: + user_id = uuid4() + trace_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_list_trace_event_records(store, *, user_id, trace_id): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["trace_id"] = trace_id + if captured.get("fail"): + raise TraceNotFoundError(f"trace {trace_id} was not found") + return { + "items": [], + "summary": { + "trace_id": str(trace_id), + "total_count": 0, + "order": ["sequence_no_asc", "id_asc"], + }, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "list_trace_event_records", fake_list_trace_event_records) + + success_response = main_module.list_trace_events(trace_id, user_id) + captured["fail"] = True + not_found_response = main_module.list_trace_events(trace_id, user_id) + + assert success_response.status_code == 200 + assert json.loads(success_response.body) == { + "items": [], + "summary": { + "trace_id": str(trace_id), + "total_count": 0, + "order": ["sequence_no_asc", "id_asc"], + }, + } + assert not_found_response.status_code == 404 + assert json.loads(not_found_response.body) == {"detail": f"trace {trace_id} was not found"} + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["store_type"] == "ContinuityStore" + assert captured["user_id"] == user_id + assert captured["trace_id"] == trace_id