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
778 changes: 778 additions & 0 deletions PRPs/PRP-showcase-workspace-E4-restore-replay.md

Large diffs are not rendered by default.

83 changes: 77 additions & 6 deletions app/features/demo/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,35 @@
Exposes:
- ``POST /demo/run`` -- synchronous; runs the whole pipeline, returns a result.
- ``WS /demo/stream`` -- streams one StepEvent per step for the live UI.
- ``GET /demo/workspaces`` -- E4 (#393): list saved workspaces.
- ``GET /demo/workspaces/{workspace_id}`` -- E4 (#393): one workspace's detail.

Both obtain the live FastAPI app from ``request.app`` / ``websocket.app`` and
pass it into the pipeline -- the slice never imports ``app.main`` (circular).
The run/stream handlers obtain the live FastAPI app from ``request.app`` /
``websocket.app`` and pass it into the pipeline -- the slice never imports
``app.main`` (circular). The workspace GETs are the slice's first DB-dependent
routes (``Depends(get_db)``).
"""

from __future__ import annotations

import json

from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, Depends, Query, Request, WebSocket, WebSocketDisconnect
from pydantic import ValidationError
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.exceptions import ConflictError
from app.core.database import get_db
from app.core.exceptions import ConflictError, NotFoundError
from app.core.logging import get_logger
from app.features.demo import service
from app.features.demo.schemas import DemoRunRequest, DemoRunResult, StepEvent
from app.features.demo import service, workspace
from app.features.demo.schemas import (
DemoRunRequest,
DemoRunResult,
StepEvent,
WorkspaceDetailResponse,
WorkspaceListItem,
WorkspaceListResponse,
)

logger = get_logger(__name__)

Expand Down Expand Up @@ -54,6 +67,64 @@ async def run_demo_pipeline(request: Request, params: DemoRunRequest) -> DemoRun
raise ConflictError(str(exc)) from exc


@router.get(
"/workspaces",
response_model=WorkspaceListResponse,
summary="List saved showcase workspaces",
description="List saved showcase workspaces, newest first. Returns 200 + "
"an empty list when no workspaces exist.",
)
async def list_showcase_workspaces(
db: AsyncSession = Depends(get_db),
limit: int = Query(default=20, ge=1, le=100, description="Maximum workspaces to return."),
offset: int = Query(default=0, ge=0, description="Number of workspaces to skip."),
) -> WorkspaceListResponse:
"""List saved showcase workspaces (E4, issue #393).

Args:
db: Async database session from dependency.
limit: Maximum workspaces to return (1-100).
offset: Number of workspaces to skip.

Returns:
A page of saved workspaces plus the total count.
"""
rows = await workspace.list_workspaces(db, limit=limit, offset=offset)
total = await workspace.count_workspaces(db)
return WorkspaceListResponse(
workspaces=[WorkspaceListItem.model_validate(row) for row in rows],
total=total,
)


@router.get(
"/workspaces/{workspace_id}",
response_model=WorkspaceDetailResponse,
summary="Get a saved showcase workspace",
description="Fetch one saved workspace, including its created-object soft references.",
)
async def get_showcase_workspace(
workspace_id: str,
db: AsyncSession = Depends(get_db),
) -> WorkspaceDetailResponse:
"""Get a saved showcase workspace by id (E4, issue #393).

Args:
workspace_id: External identifier of the workspace.
db: Async database session from dependency.

Returns:
The full workspace row including ``created_objects``.

Raises:
NotFoundError: When no workspace matches ``workspace_id``.
"""
row = await workspace.get_workspace(db, workspace_id)
if row is None:
raise NotFoundError(message=f"Workspace not found: {workspace_id}")
return WorkspaceDetailResponse.model_validate(row)


@router.websocket("/stream")
async def stream_demo_pipeline(websocket: WebSocket) -> None:
"""Stream one StepEvent per pipeline step over a WebSocket.
Expand Down
49 changes: 48 additions & 1 deletion app/features/demo/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from __future__ import annotations

from datetime import UTC, datetime
from datetime import UTC, date, datetime
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, Field, model_validator
Expand Down Expand Up @@ -164,3 +164,50 @@ class DemoRunResult(BaseModel):
default=None,
description="showcase_workspace id recorded for this run, if kept.",
)


class WorkspaceListItem(BaseModel):
"""A compact row in the saved-workspaces list (E4, issue #393).

Response model -- plain ``BaseModel`` with ``from_attributes`` (built from
``ShowcaseWorkspace`` ORM rows), NOT ``ConfigDict(strict=True)``: strict
mode is a request-body policy (see the ``StepEvent`` precedent above).
"""

model_config = ConfigDict(from_attributes=True)

workspace_id: str = Field(..., description="Unique external identifier (UUID hex).")
name: str | None = Field(default=None, description="Optional human label.")
status: str = Field(..., description="Lifecycle state -- running / completed / failed.")
seed: int = Field(..., description="Seeder seed the run was started with.")
scenario: str = Field(..., description="Seeder scenario preset value.")
reset: bool = Field(..., description="Whether the run wiped the database first.")
skip_seed: bool = Field(..., description="Whether the run skipped the seed step.")
result_summary: dict[str, Any] | None = Field(
default=None, description="Winner / WAPE / wall-clock display payload."
)
created_at: datetime = Field(..., description="When the run was recorded (UTC).")


class WorkspaceDetailResponse(WorkspaceListItem):
"""Full workspace row incl. created objects (E4, issue #393)."""

store_id: int | None = Field(default=None, description="Showcase grain store id.")
product_id: int | None = Field(default=None, description="Showcase grain product id.")
date_start: date | None = Field(default=None, description="Seeded window start.")
date_end: date | None = Field(default=None, description="Seeded window end.")
created_objects: dict[str, Any] = Field(
default_factory=dict,
description="Soft-reference ids of everything the run created.",
)


class WorkspaceListResponse(BaseModel):
"""A page of saved workspaces, newest first (E4, issue #393)."""

model_config = ConfigDict(from_attributes=True)

workspaces: list[WorkspaceListItem] = Field(
..., description="Saved workspaces for the current page; empty when none."
)
total: int = Field(..., ge=0, description="Total saved workspaces.")
175 changes: 172 additions & 3 deletions app/features/demo/tests/test_routes.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
"""Route tests for the demo slice (POST /demo/run + WS /demo/stream).
"""Route tests for the demo slice (POST /demo/run + WS /demo/stream + GETs).

The demo service is monkeypatched so these tests exercise the route wiring
without a database or a real pipeline run.
without a database or a real pipeline run. The E4 (#393) workspace GET unit
tests monkeypatch the workspace helpers the same way; their integration
counterparts run against the real Postgres via the ``db_session`` fixture.
"""

import datetime as _dt
from collections.abc import AsyncIterator
from types import SimpleNamespace

import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession

from app.features.demo import service
from app.features.demo import service, workspace
from app.features.demo.schemas import DemoRunRequest, DemoRunResult, StepEvent
from app.main import app

Expand Down Expand Up @@ -199,3 +204,167 @@ async def fake_stream(_app, params: DemoRunRequest) -> AsyncIterator[StepEvent]:
assert event["event_type"] == "pipeline_complete"
assert seen["params"].seed == 7
assert seen["params"].preservation == "ephemeral"


# =============================================================================
# E4 (#393) -- GET /demo/workspaces + GET /demo/workspaces/{id} (unit)
# =============================================================================


def _orm_like_row(workspace_id: str = "a" * 32, **overrides: object) -> SimpleNamespace:
"""An ORM-shaped stand-in for a ShowcaseWorkspace row."""
base: dict[str, object] = {
"workspace_id": workspace_id,
"name": "e4-route",
"status": "completed",
"seed": 42,
"scenario": "demo_minimal",
"reset": False,
"skip_seed": True,
"store_id": 3,
"product_id": 7,
"date_start": _dt.date(2026, 1, 1),
"date_end": _dt.date(2026, 3, 31),
"created_objects": {"winning_run_id": "run-abc"},
"result_summary": {"winner_model_type": "naive"},
"created_at": _dt.datetime(2026, 6, 1, 12, 0, tzinfo=_dt.UTC),
}
base.update(overrides)
return SimpleNamespace(**base)


async def test_list_workspaces_empty(client, monkeypatch):
"""E4 (#393) -- empty table yields 200 + an empty page (no 404)."""

async def fake_list(_db, *, limit: int, offset: int) -> list[SimpleNamespace]:
return []

async def fake_count(_db) -> int:
return 0

monkeypatch.setattr(workspace, "list_workspaces", fake_list)
monkeypatch.setattr(workspace, "count_workspaces", fake_count)

resp = await client.get("/demo/workspaces")
assert resp.status_code == 200
assert resp.json() == {"workspaces": [], "total": 0}


async def test_list_workspaces_passes_pagination(client, monkeypatch):
"""E4 (#393) -- limit/offset query params reach the helper."""
seen: dict[str, int] = {}

async def fake_list(_db, *, limit: int, offset: int) -> list[SimpleNamespace]:
seen["limit"] = limit
seen["offset"] = offset
return [_orm_like_row()]

async def fake_count(_db) -> int:
return 5

monkeypatch.setattr(workspace, "list_workspaces", fake_list)
monkeypatch.setattr(workspace, "count_workspaces", fake_count)

resp = await client.get("/demo/workspaces", params={"limit": 2, "offset": 3})
assert resp.status_code == 200
assert seen == {"limit": 2, "offset": 3}
body = resp.json()
assert body["total"] == 5
assert body["workspaces"][0]["workspace_id"] == "a" * 32
# List items are compact -- no created_objects on the page payload.
assert "created_objects" not in body["workspaces"][0]


async def test_list_workspaces_rejects_bad_pagination(client):
"""E4 (#393) -- out-of-range limit/offset are 422 problem+json."""
resp = await client.get("/demo/workspaces", params={"limit": 0})
assert resp.status_code == 422
resp = await client.get("/demo/workspaces", params={"offset": -1})
assert resp.status_code == 422


async def test_get_workspace_404(client, monkeypatch):
"""E4 (#393) -- unknown workspace_id is a 404 problem+json."""

async def fake_get(_db, _workspace_id: str) -> None:
return None

monkeypatch.setattr(workspace, "get_workspace", fake_get)

resp = await client.get("/demo/workspaces/" + "0" * 32)
assert resp.status_code == 404
assert resp.headers["content-type"].startswith("application/problem+json")
assert "Workspace not found" in resp.json()["detail"]


async def test_get_workspace_success(client, monkeypatch):
"""E4 (#393) -- detail fields round-trip incl. created_objects + grain."""

async def fake_get(_db, workspace_id: str) -> SimpleNamespace:
return _orm_like_row(workspace_id=workspace_id)

monkeypatch.setattr(workspace, "get_workspace", fake_get)

resp = await client.get("/demo/workspaces/" + "b" * 32)
assert resp.status_code == 200
body = resp.json()
assert body["workspace_id"] == "b" * 32
assert body["created_objects"] == {"winning_run_id": "run-abc"}
assert body["store_id"] == 3
assert body["product_id"] == 7
assert body["date_start"] == "2026-01-01"
assert body["date_end"] == "2026-03-31"


# =============================================================================
# E4 (#393) -- workspace GET routes against real Postgres (integration)
# =============================================================================


@pytest.mark.integration
async def test_list_workspaces_integration_newest_first(client, db_session: AsyncSession):
"""Seeded rows list newest-first with the right total."""
ids: list[str] = []
for index in range(3):
workspace_id = await workspace.create_workspace(
DemoRunRequest.model_validate(
{"preservation": "keep", "workspace_name": f"e4-it-{index}"}
)
)
assert workspace_id is not None
ids.append(workspace_id)

resp = await client.get("/demo/workspaces")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 3
assert [w["workspace_id"] for w in body["workspaces"]] == list(reversed(ids))
assert body["workspaces"][0]["name"] == "e4-it-2"

paged = await client.get("/demo/workspaces", params={"limit": 1, "offset": 1})
assert paged.status_code == 200
paged_body = paged.json()
assert paged_body["total"] == 3
assert [w["workspace_id"] for w in paged_body["workspaces"]] == [ids[1]]


@pytest.mark.integration
async def test_get_workspace_integration_round_trip(client, db_session: AsyncSession):
"""created_objects JSONB round-trips through the detail endpoint."""
workspace_id = await workspace.create_workspace(
DemoRunRequest.model_validate({"preservation": "keep", "workspace_name": "e4-it-detail"})
)
assert workspace_id is not None

resp = await client.get(f"/demo/workspaces/{workspace_id}")
assert resp.status_code == 200
body = resp.json()
assert body["workspace_id"] == workspace_id
assert body["name"] == "e4-it-detail"
assert body["status"] == "running"
assert body["created_objects"] == {}
assert body["result_summary"] is None

missing = await client.get("/demo/workspaces/" + "f" * 32)
assert missing.status_code == 404
assert missing.headers["content-type"].startswith("application/problem+json")
Loading