From f2809365082bc937cad790c676e9f6b29c1b818f Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Fri, 12 Jun 2026 16:59:40 +0200 Subject: [PATCH 1/5] feat(api): expose showcase workspace list and detail endpoints (#393) --- app/features/demo/routes.py | 83 ++++++++++- app/features/demo/schemas.py | 49 ++++++- app/features/demo/tests/test_routes.py | 175 +++++++++++++++++++++++- app/features/demo/tests/test_schemas.py | 96 ++++++++++++- app/features/demo/workspace.py | 21 ++- 5 files changed, 409 insertions(+), 15 deletions(-) diff --git a/app/features/demo/routes.py b/app/features/demo/routes.py index 660df652..6d3284c4 100644 --- a/app/features/demo/routes.py +++ b/app/features/demo/routes.py @@ -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__) @@ -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. diff --git a/app/features/demo/schemas.py b/app/features/demo/schemas.py index e02738af..cad7d32e 100644 --- a/app/features/demo/schemas.py +++ b/app/features/demo/schemas.py @@ -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 @@ -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.") diff --git a/app/features/demo/tests/test_routes.py b/app/features/demo/tests/test_routes.py index 5158d1ca..016049db 100644 --- a/app/features/demo/tests/test_routes.py +++ b/app/features/demo/tests/test_routes.py @@ -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 @@ -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") diff --git a/app/features/demo/tests/test_schemas.py b/app/features/demo/tests/test_schemas.py index bdbfaac3..c4e120f2 100644 --- a/app/features/demo/tests/test_schemas.py +++ b/app/features/demo/tests/test_schemas.py @@ -1,9 +1,19 @@ """Unit tests for demo slice schemas.""" +import datetime as _dt +from types import SimpleNamespace + import pytest from pydantic import ValidationError -from app.features.demo.schemas import DemoRunRequest, DemoRunResult, StepEvent +from app.features.demo.schemas import ( + DemoRunRequest, + DemoRunResult, + StepEvent, + WorkspaceDetailResponse, + WorkspaceListItem, + WorkspaceListResponse, +) from app.shared.seeder.config import ScenarioPreset @@ -181,3 +191,87 @@ def test_demo_run_result_defaults(): assert result.wall_clock_s == 0.0 # E1 (#390) -- additive Optional field defaults to None (ephemeral runs). assert result.workspace_id is None + + +# ============================================================================= +# E4 (#393) -- workspace response models +# ============================================================================= + + +def _orm_like_workspace_row(**overrides: object) -> SimpleNamespace: + """An ORM-shaped stand-in for a ShowcaseWorkspace row (from_attributes).""" + base: dict[str, object] = { + "workspace_id": "a" * 32, + "name": "e4-demo", + "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", "scenario_plan_ids": ["sp-1"]}, + "result_summary": {"winner_model_type": "naive", "winner_wape": 0.2}, + "created_at": _dt.datetime(2026, 6, 1, 12, 0, tzinfo=_dt.UTC), + } + base.update(overrides) + return SimpleNamespace(**base) + + +def test_workspace_list_item_from_attributes_round_trip(): + """E4 (#393) -- list item builds from an ORM-shaped row.""" + item = WorkspaceListItem.model_validate(_orm_like_workspace_row()) + assert item.workspace_id == "a" * 32 + assert item.name == "e4-demo" + assert item.status == "completed" + assert item.seed == 42 + assert item.scenario == "demo_minimal" + assert item.reset is False + assert item.skip_seed is True + assert item.result_summary == {"winner_model_type": "naive", "winner_wape": 0.2} + + +def test_workspace_detail_carries_created_objects_verbatim(): + """E4 (#393) -- detail model passes created_objects + grain through untouched.""" + detail = WorkspaceDetailResponse.model_validate(_orm_like_workspace_row()) + assert detail.created_objects == { + "winning_run_id": "run-abc", + "scenario_plan_ids": ["sp-1"], + } + assert detail.store_id == 3 + assert detail.product_id == 7 + assert detail.date_start == _dt.date(2026, 1, 1) + assert detail.date_end == _dt.date(2026, 3, 31) + + +def test_workspace_detail_tolerates_running_row_nulls(): + """E4 (#393) -- a still-running row (NULL grain/summary) validates.""" + detail = WorkspaceDetailResponse.model_validate( + _orm_like_workspace_row( + status="running", + name=None, + store_id=None, + product_id=None, + date_start=None, + date_end=None, + created_objects={}, + result_summary=None, + ) + ) + assert detail.status == "running" + assert detail.name is None + assert detail.created_objects == {} + assert detail.result_summary is None + + +def test_workspace_list_response_shape(): + """E4 (#393) -- page shape mirrors the scenarios list (items + total).""" + item = WorkspaceListItem.model_validate(_orm_like_workspace_row()) + page = WorkspaceListResponse(workspaces=[item], total=1) + dumped = page.model_dump(mode="json") + assert dumped["total"] == 1 + assert dumped["workspaces"][0]["workspace_id"] == "a" * 32 + # ISO serialization on the wire. + assert isinstance(dumped["workspaces"][0]["created_at"], str) diff --git a/app/features/demo/workspace.py b/app/features/demo/workspace.py index 44e8b475..40b20807 100644 --- a/app/features/demo/workspace.py +++ b/app/features/demo/workspace.py @@ -12,9 +12,9 @@ :func:`finalize_workspace` swallows any error. Both log a structured warning (pattern: the ``app/main.py`` lifespan config-override load). -:func:`get_workspace` / :func:`list_workspaces` are unrouted in E1 -- consumed -by the integration tests now and by the E4 restore/replay routes later -(epic #393). +:func:`get_workspace` / :func:`list_workspaces` / :func:`count_workspaces` are +routed since E4 (epic #393) by ``GET /demo/workspaces`` and +``GET /demo/workspaces/{workspace_id}`` in ``app/features/demo/routes.py``. """ from __future__ import annotations @@ -22,7 +22,7 @@ import uuid from typing import TYPE_CHECKING, Any -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_session_maker @@ -193,3 +193,16 @@ async def list_workspaces( .offset(offset) ) return list(result.scalars().all()) + + +async def count_workspaces(db: AsyncSession) -> int: + """Count all workspace rows (E4, issue #393). + + Args: + db: An open async session (caller-owned). + + Returns: + The total number of saved workspaces. + """ + count_stmt = select(func.count()).select_from(ShowcaseWorkspace) + return int(await db.scalar(count_stmt) or 0) From 67dac81e3b97ba18271a909185b00bab2061991f Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Fri, 12 Jun 2026 16:59:40 +0200 Subject: [PATCH 2/5] feat(ui): add workspace restore and replay to showcase page (#393) --- .../demo/InspectArtifactsPanel.test.tsx | 1 + .../components/demo/RunHistoryStrip.test.tsx | 19 +++ .../src/components/demo/RunHistoryStrip.tsx | 4 +- .../demo/WorkspaceArtifactsPanel.test.tsx | 92 ++++++++++ .../demo/WorkspaceArtifactsPanel.tsx | 157 ++++++++++++++++++ .../components/demo/WorkspacePanel.test.tsx | 105 ++++++++++++ .../src/components/demo/WorkspacePanel.tsx | 129 ++++++++++++++ frontend/src/components/demo/index.ts | 3 + frontend/src/hooks/index.ts | 1 + frontend/src/hooks/use-demo-pipeline.test.ts | 22 +++ frontend/src/hooks/use-demo-pipeline.ts | 3 + frontend/src/hooks/use-workspaces.ts | 25 +++ frontend/src/pages/showcase.tsx | 141 +++++++++++++++- frontend/src/types/api.ts | 36 ++++ 14 files changed, 734 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/demo/WorkspaceArtifactsPanel.test.tsx create mode 100644 frontend/src/components/demo/WorkspaceArtifactsPanel.tsx create mode 100644 frontend/src/components/demo/WorkspacePanel.test.tsx create mode 100644 frontend/src/components/demo/WorkspacePanel.tsx create mode 100644 frontend/src/hooks/use-workspaces.ts diff --git a/frontend/src/components/demo/InspectArtifactsPanel.test.tsx b/frontend/src/components/demo/InspectArtifactsPanel.test.tsx index 4a692dfb..4c0e5d57 100644 --- a/frontend/src/components/demo/InspectArtifactsPanel.test.tsx +++ b/frontend/src/components/demo/InspectArtifactsPanel.test.tsx @@ -25,6 +25,7 @@ const baseSummary: DemoSummary = { alias: 'demo-production', wallClockS: 180, v2RunId: 'v2-456', + workspaceId: null, } describe('InspectArtifactsPanel', () => { diff --git a/frontend/src/components/demo/RunHistoryStrip.test.tsx b/frontend/src/components/demo/RunHistoryStrip.test.tsx index 5f74e422..92593ef8 100644 --- a/frontend/src/components/demo/RunHistoryStrip.test.tsx +++ b/frontend/src/components/demo/RunHistoryStrip.test.tsx @@ -23,6 +23,7 @@ const summary: DemoSummary = { alias: 'demo-production', wallClockS: 174.5, v2RunId: 'v2-456', + workspaceId: null, } describe('RunHistoryStrip', () => { @@ -108,6 +109,24 @@ describe('RunHistoryStrip', () => { ) }) + it('E4 (#393) — does NOT append a kept run (workspaceId set)', () => { + const keptSummary: DemoSummary = { ...summary, workspaceId: 'ws-e4-abc' } + const { container } = render( + {}} summary={keptSummary} scenario="showcase_rich" />, + ) + // Server-backed WorkspacePanel owns kept runs; localStorage stays empty + // (the persist effect writes the initial '[]') and the strip renders nothing. + expect(JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '[]')).toHaveLength(0) + expect(container.firstChild).toBeNull() + }) + + it('E4 (#393) — still appends an ephemeral run (workspaceId null)', () => { + render( {}} summary={summary} scenario="demo_minimal" />) + const stored = window.localStorage.getItem(STORAGE_KEY) + expect(stored).not.toBeNull() + expect(JSON.parse(stored!)).toHaveLength(1) + }) + it('Clear button empties history + localStorage', () => { const { container } = render( {}} summary={summary} scenario="demo_minimal" />, diff --git a/frontend/src/components/demo/RunHistoryStrip.tsx b/frontend/src/components/demo/RunHistoryStrip.tsx index 39addd31..98629380 100644 --- a/frontend/src/components/demo/RunHistoryStrip.tsx +++ b/frontend/src/components/demo/RunHistoryStrip.tsx @@ -68,7 +68,9 @@ export function RunHistoryStrip({ onReplay, summary, scenario }: RunHistoryStrip // (the React "storing information from previous renders" pattern) rather than // in an effect — calling setState synchronously inside an effect body causes // cascading renders and is flagged by react-hooks/set-state-in-effect. - if (summary && summary !== lastSummary) { + // E4 (#393) — kept runs (workspaceId != null) are owned by the server-backed + // WorkspacePanel; localStorage records ephemeral runs only. + if (summary && summary !== lastSummary && summary.workspaceId === null) { setLastSummary(summary) setItems((prev) => [ diff --git a/frontend/src/components/demo/WorkspaceArtifactsPanel.test.tsx b/frontend/src/components/demo/WorkspaceArtifactsPanel.test.tsx new file mode 100644 index 00000000..8d1e60ce --- /dev/null +++ b/frontend/src/components/demo/WorkspaceArtifactsPanel.test.tsx @@ -0,0 +1,92 @@ +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import { WorkspaceArtifactsPanel } from './WorkspaceArtifactsPanel' +import type { WorkspaceDetail } from '@/types/api' + +afterEach(() => cleanup()) + +const fullWorkspace: WorkspaceDetail = { + workspace_id: 'a'.repeat(32), + name: 'e4-artifacts', + status: 'completed', + seed: 42, + scenario: 'showcase_rich', + reset: false, + skip_seed: true, + result_summary: { winner_model_type: 'prophet_like' }, + created_at: '2026-06-01T12:00:00Z', + store_id: 3, + product_id: 7, + date_start: '2026-01-01', + date_end: '2026-03-31', + created_objects: { + winning_run_id: 'run-win', + v2_run_id: 'run-v2', + batch_id: 'batch-1', + alias: 'demo-production', + agent_session_id: 'sess-1', + scenario_plan_ids: ['sp-1', 'sp-2'], + }, +} + +function renderPanel(workspace: WorkspaceDetail) { + return render( + + + , + ) +} + +describe('WorkspaceArtifactsPanel', () => { + it('renders deep links for every recorded object', () => { + const { container } = renderPanel(fullWorkspace) + const hrefs = Array.from(container.querySelectorAll('a')).map((a) => + a.getAttribute('href'), + ) + expect(hrefs).toContain('/explorer/runs/run-win') + expect(hrefs).toContain('/explorer/runs/run-v2') + expect(hrefs).toContain('/visualize/planner?scenario_id=sp-1') + expect(hrefs).toContain('/visualize/planner?scenario_id=sp-2') + expect(hrefs).toContain('/visualize/batch/batch-1') + expect(hrefs).toContain('/ops') + expect(hrefs).toContain('/visualize/forecast?store_id=3&product_id=7') + expect(hrefs).toContain('/visualize/backtest?store_id=3&product_id=7') + expect(hrefs).toContain('/chat') + expect(container.textContent).toContain('e4-artifacts') + }) + + it('renders disabled cards (no links) when objects are missing', () => { + const empty: WorkspaceDetail = { + ...fullWorkspace, + name: null, + store_id: null, + product_id: null, + created_objects: {}, + } + const { container } = renderPanel(empty) + // Nothing recorded -> no active links at all. + expect(container.querySelectorAll('a').length).toBe(0) + // Disabled cards still render their labels (with the id-slice header). + expect(container.textContent).toContain('Winning run') + expect(container.textContent).toContain('Scenario plans') + expect(container.textContent).toContain('aaaaaaaa') + }) + + it('tolerates malformed created_objects values', () => { + const malformed: WorkspaceDetail = { + ...fullWorkspace, + created_objects: { + winning_run_id: 123, // wrong type -> treated as missing + scenario_plan_ids: 'not-a-list', + }, + } + const { container } = renderPanel(malformed) + const hrefs = Array.from(container.querySelectorAll('a')).map((a) => + a.getAttribute('href'), + ) + expect(hrefs).not.toContain('/explorer/runs/123') + // Grain links still resolve from the columns. + expect(hrefs).toContain('/visualize/forecast?store_id=3&product_id=7') + }) +}) diff --git a/frontend/src/components/demo/WorkspaceArtifactsPanel.tsx b/frontend/src/components/demo/WorkspaceArtifactsPanel.tsx new file mode 100644 index 00000000..255d62fa --- /dev/null +++ b/frontend/src/components/demo/WorkspaceArtifactsPanel.tsx @@ -0,0 +1,157 @@ +/** + * E4 (#393) — re-attach deep-link card grid for a LOADED workspace. + * + * Mirrors InspectArtifactsPanel's card shape but reads the persisted + * `created_objects` soft references + grain columns from the workspace row + * instead of live step.data — the run is long gone; the row is the memory. + */ + +import { Link } from 'react-router-dom' +import { ArrowUpRight } from 'lucide-react' +import { Card, CardContent } from '@/components/ui/card' +import { ROUTES } from '@/lib/constants' +import type { WorkspaceDetail } from '@/types/api' + +interface ArtifactCard { + label: string + blurb: string + href: string | null + disabledReason?: string +} + +interface WorkspaceArtifactsPanelProps { + workspace: WorkspaceDetail +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +function buildCards(ws: WorkspaceDetail): ArtifactCard[] { + const objects = ws.created_objects + const winningRunId = asString(objects.winning_run_id) + const v2RunId = asString(objects.v2_run_id) + const batchId = asString(objects.batch_id) + const alias = asString(objects.alias) + const sessionId = asString(objects.agent_session_id) + const planIds = Array.isArray(objects.scenario_plan_ids) + ? objects.scenario_plan_ids.filter((id): id is string => typeof id === 'string') + : [] + const hasGrain = ws.store_id !== null && ws.product_id !== null + + const cards: ArtifactCard[] = [] + + cards.push({ + label: 'Winning run', + blurb: 'Registry detail for the run this workspace promoted.', + href: winningRunId ? `${ROUTES.EXPLORER.RUNS}/${winningRunId}` : null, + disabledReason: 'The run never registered a winner.', + }) + cards.push({ + label: 'V2 feature-frame run', + blurb: 'The prophet_like V2 run with feature groups + safety classes.', + href: v2RunId ? `${ROUTES.EXPLORER.RUNS}/${v2RunId}` : null, + disabledReason: 'No V2 run recorded (demo_minimal flow or v2_train skipped).', + }) + planIds.forEach((planId, index) => { + cards.push({ + label: `Scenario plan ${index + 1}`, + blurb: 'Saved what-if plan from the planning phase.', + href: `${ROUTES.VISUALIZE.PLANNER}?scenario_id=${planId}`, + }) + }) + if (planIds.length === 0) { + cards.push({ + label: 'Scenario plans', + blurb: 'Saved what-if plans from the planning phase.', + href: null, + disabledReason: 'No plans recorded (planning phase skipped or failed).', + }) + } + cards.push({ + label: 'Portfolio batch', + blurb: 'Run-by-run results for the batch preset sweep.', + href: batchId ? `${ROUTES.VISUALIZE.BATCH}/${batchId}` : null, + disabledReason: 'No batch recorded (demo_minimal flow or batch skipped).', + }) + cards.push({ + label: 'Deployment alias', + blurb: alias ? `Ops view of the ${alias} alias.` : 'Ops view of aliases.', + href: alias ? ROUTES.OPS : null, + disabledReason: 'No alias recorded.', + }) + cards.push({ + label: 'Forecast on grain', + blurb: 'Visualize the trained model on the recorded showcase grain.', + href: hasGrain + ? `${ROUTES.VISUALIZE.FORECAST}?store_id=${ws.store_id}&product_id=${ws.product_id}` + : null, + disabledReason: 'The run failed before a grain was discovered.', + }) + cards.push({ + label: 'Backtest on grain', + blurb: 'Horizon-bucket metrics on the recorded showcase grain.', + href: hasGrain + ? `${ROUTES.VISUALIZE.BACKTEST}?store_id=${ws.store_id}&product_id=${ws.product_id}` + : null, + disabledReason: 'The run failed before a grain was discovered.', + }) + cards.push({ + label: 'Agent session', + blurb: 'The chat surface — the recorded session has likely expired.', + href: sessionId ? ROUTES.CHAT : null, + disabledReason: 'No agent session recorded (no LLM key or step skipped).', + }) + + return cards +} + +export function WorkspaceArtifactsPanel({ workspace }: WorkspaceArtifactsPanelProps) { + const cards = buildCards(workspace) + return ( + + +

+ Workspace artifacts + + {workspace.name ?? workspace.workspace_id.slice(0, 8)} + +

+

+ Everything this kept run created, re-attached from its workspace row. + Cards greyed out when the run did not record the matching object. +

+
+ {cards.map((card) => { + const isActive = typeof card.href === 'string' && card.href.length > 0 + return ( +
+ {isActive ? ( + +
+ {card.label} + +
+

{card.blurb}

+ + ) : ( +
+
{card.label}
+

{card.blurb}

+
+ )} +
+ ) + })} +
+
+
+ ) +} diff --git a/frontend/src/components/demo/WorkspacePanel.test.tsx b/frontend/src/components/demo/WorkspacePanel.test.tsx new file mode 100644 index 00000000..2d08aa40 --- /dev/null +++ b/frontend/src/components/demo/WorkspacePanel.test.tsx @@ -0,0 +1,105 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WorkspacePanel } from './WorkspacePanel' +import type { WorkspaceListItem, WorkspaceListResponse } from '@/types/api' + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +const baseItem: WorkspaceListItem = { + workspace_id: 'a'.repeat(32), + name: 'e4-panel', + status: 'completed', + seed: 7, + scenario: 'demo_minimal', + reset: false, + skip_seed: true, + result_summary: { winner_model_type: 'seasonal_naive' }, + created_at: '2026-06-01T12:00:00Z', +} + +let mockResponse: { data: WorkspaceListResponse | undefined; isLoading: boolean } = { + data: undefined, + isLoading: false, +} + +vi.mock('@/hooks/use-workspaces', () => ({ + useWorkspaces: () => mockResponse, +})) + +function renderPanel(props: Partial[0]> = {}) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + {}} + onReplay={() => {}} + isRunning={false} + lastWorkspaceId={null} + {...props} + /> + , + ) +} + +describe('WorkspacePanel', () => { + it('renders the discoverable empty state (panel never hidden)', () => { + mockResponse = { data: { workspaces: [], total: 0 }, isLoading: false } + const { container } = renderPanel() + expect(container.textContent).toContain('Saved workspaces') + expect(container.textContent).toContain('No saved workspaces yet') + }) + + it('renders a workspace row with name, scenario, seed, status, and winner', () => { + mockResponse = { data: { workspaces: [baseItem], total: 1 }, isLoading: false } + const { container } = renderPanel() + expect(container.textContent).toContain('e4-panel') + expect(container.textContent).toContain('demo_minimal') + expect(container.textContent).toContain('seed 7') + expect(container.textContent).toContain('COMPLETED') + expect(container.textContent).toContain('winner seasonal_naive') + // No destructive badge on a reset=false row. + expect(container.textContent).not.toContain('DESTRUCTIVE') + }) + + it('shows the destructive badge on reset=true rows', () => { + mockResponse = { + data: { workspaces: [{ ...baseItem, reset: true }], total: 1 }, + isLoading: false, + } + const { container } = renderPanel() + expect(container.textContent).toContain('DESTRUCTIVE') + }) + + it('falls back to the workspace_id slice when the row is unnamed', () => { + mockResponse = { + data: { workspaces: [{ ...baseItem, name: null }], total: 1 }, + isLoading: false, + } + const { container } = renderPanel() + expect(container.textContent).toContain('aaaaaaaa') + }) + + it('invokes onLoad / onReplay with the list item', () => { + mockResponse = { data: { workspaces: [baseItem], total: 1 }, isLoading: false } + const onLoad = vi.fn() + const onReplay = vi.fn() + const { container } = renderPanel({ onLoad, onReplay }) + const buttons = Array.from(container.querySelectorAll('button')) + fireEvent.click(buttons.find((b) => (b.textContent ?? '').includes('Load'))!) + expect(onLoad).toHaveBeenCalledWith(baseItem) + fireEvent.click(buttons.find((b) => (b.textContent ?? '').includes('Replay'))!) + expect(onReplay).toHaveBeenCalledWith(baseItem) + }) + + it('disables both actions while a run is in flight', () => { + mockResponse = { data: { workspaces: [baseItem], total: 1 }, isLoading: false } + const { container } = renderPanel({ isRunning: true }) + const buttons = Array.from(container.querySelectorAll('button')) + expect(buttons.length).toBeGreaterThanOrEqual(2) + expect(buttons.every((b) => b.disabled)).toBe(true) + }) +}) diff --git a/frontend/src/components/demo/WorkspacePanel.tsx b/frontend/src/components/demo/WorkspacePanel.tsx new file mode 100644 index 00000000..6638b597 --- /dev/null +++ b/frontend/src/components/demo/WorkspacePanel.tsx @@ -0,0 +1,129 @@ +/** + * E4 (#393) — server-backed saved-workspaces panel for the Showcase page. + * + * Lists `showcase_workspace` rows (newest first) with two actions per row: + * - Load — re-attach: the page repopulates the run controls + renders the + * artifact deep-link cards. Read-only; no run starts. + * - Replay — re-run: the page re-submits the recorded config verbatim through + * the existing WS run path with preservation="keep". + * + * The panel stays dumb: it hands the LIST item to the page callbacks; detail + * fetching (created_objects) lives in the page via useWorkspace. + */ + +import { useEffect } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { FolderOpen, Play } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { useWorkspaces } from '@/hooks/use-workspaces' +import type { WorkspaceListItem } from '@/types/api' + +interface WorkspacePanelProps { + /** Called when the operator clicks Load — restore config + artifacts, no run. */ + onLoad: (ws: WorkspaceListItem) => void + /** Called when the operator clicks Replay — re-run the recorded config. */ + onReplay: (ws: WorkspaceListItem) => void + /** Disables both actions while a pipeline run is in flight. */ + isRunning: boolean + /** summary.workspaceId of the latest kept run — triggers a list refetch. */ + lastWorkspaceId: string | null +} + +function statusClass(status: WorkspaceListItem['status']): string { + switch (status) { + case 'completed': + return 'text-success font-semibold' + case 'failed': + return 'text-destructive font-semibold' + default: + return 'text-muted-foreground font-semibold' + } +} + +function winnerOf(ws: WorkspaceListItem): string | null { + const winner = ws.result_summary?.winner_model_type + return typeof winner === 'string' ? winner : null +} + +export function WorkspacePanel({ onLoad, onReplay, isRunning, lastWorkspaceId }: WorkspacePanelProps) { + const { data, isLoading } = useWorkspaces() + const queryClient = useQueryClient() + + // Refetch the list once the latest kept run settles — syncing React state to + // an external system (the server-backed list) is the sanctioned effect use. + useEffect(() => { + if (lastWorkspaceId) { + void queryClient.invalidateQueries({ queryKey: ['workspaces'] }) + } + }, [lastWorkspaceId, queryClient]) + + const items = data?.workspaces ?? [] + + return ( + + +
+

Saved workspaces

+ {data && data.total > items.length && ( + + showing {items.length} of {data.total} + + )} +
+ {items.length === 0 ? ( +

+ {isLoading + ? 'Loading workspaces…' + : 'No saved workspaces yet — tick "Save as workspace" before a run to keep it.'} +

+ ) : ( +
    + {items.map((ws) => ( +
  • +
    + {ws.name ?? ws.workspace_id.slice(0, 8)} + {ws.scenario} + seed {ws.seed} + {ws.status.toUpperCase()} + {winnerOf(ws) && winner {winnerOf(ws)}} + {ws.reset && ( + + DESTRUCTIVE (replay wipes all data) + + )} + + {new Date(ws.created_at).toLocaleString()} + +
    +
    + + +
    +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/demo/index.ts b/frontend/src/components/demo/index.ts index 48f34346..ccfe7b71 100644 --- a/frontend/src/components/demo/index.ts +++ b/frontend/src/components/demo/index.ts @@ -1 +1,4 @@ export * from './demo-step-card' +// E4 (#393) — showcase workspace restore/replay panels. +export * from './WorkspacePanel' +export * from './WorkspaceArtifactsPanel' diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index eebde40d..fb3e6aa7 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -14,3 +14,4 @@ export * from './use-rag-sources' export * from './use-websocket' export * from './use-seeder' export * from './use-demo-pipeline' +export * from './use-workspaces' diff --git a/frontend/src/hooks/use-demo-pipeline.test.ts b/frontend/src/hooks/use-demo-pipeline.test.ts index 135e463e..f06dd5d5 100644 --- a/frontend/src/hooks/use-demo-pipeline.test.ts +++ b/frontend/src/hooks/use-demo-pipeline.test.ts @@ -107,9 +107,31 @@ describe('applyEvent', () => { alias: 'demo-production', wallClockS: 42, v2RunId: null, + workspaceId: null, }) }) + it('E4 (#393) — captures workspace_id from pipeline_complete data', () => { + const next = applyEvent( + initialState(), + makeEvent({ + event_type: 'pipeline_complete', + step_name: 'summary', + status: 'pass', + data: { workspace_id: 'ws-e4-abc' }, + }) + ) + expect(next.summary?.workspaceId).toBe('ws-e4-abc') + }) + + it('E4 (#393) — legacy pipeline_complete without workspace_id yields null', () => { + const next = applyEvent( + initialState(), + makeEvent({ event_type: 'pipeline_complete', step_name: 'summary', status: 'pass', data: {} }) + ) + expect(next.summary?.workspaceId).toBeNull() + }) + it('reports a failed pipeline_complete as fail', () => { const next = applyEvent( initialState(), diff --git a/frontend/src/hooks/use-demo-pipeline.ts b/frontend/src/hooks/use-demo-pipeline.ts index 328bcd53..f29db921 100644 --- a/frontend/src/hooks/use-demo-pipeline.ts +++ b/frontend/src/hooks/use-demo-pipeline.ts @@ -30,6 +30,8 @@ export interface DemoSummary { wallClockS: number /** PRP-41 — populated when the SHOWCASE_RICH v2_train step registered a run. */ v2RunId: string | null + /** E4 (#393) — populated when the run was kept as a showcase workspace. */ + workspaceId: string | null } export interface DemoPipelineState { @@ -123,6 +125,7 @@ export function applyEvent(state: DemoPipelineState, event: StepEvent): DemoPipe alias: toStringOrNull(event.data.alias), wallClockS: toNumber(event.data.wall_clock_s) ?? 0, v2RunId: toStringOrNull(event.data.v2_run_id), + workspaceId: toStringOrNull(event.data.workspace_id), } return { ...state, phase: 'done', summary } } diff --git a/frontend/src/hooks/use-workspaces.ts b/frontend/src/hooks/use-workspaces.ts new file mode 100644 index 00000000..8fc02054 --- /dev/null +++ b/frontend/src/hooks/use-workspaces.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query' +import { api } from '@/lib/api' +import type { WorkspaceDetail, WorkspaceListResponse } from '@/types/api' + +/** + * E4 (#393) — list saved showcase workspaces, newest first. Server-backed + * source of truth for `preservation="keep"` runs (the localStorage + * RunHistoryStrip stays ephemeral-only). + */ +export function useWorkspaces(limit = 20, enabled = true) { + return useQuery({ + queryKey: ['workspaces', { limit }], + queryFn: () => api('/demo/workspaces', { params: { limit } }), + enabled, + }) +} + +/** E4 (#393) — fetch one workspace, including its created-object soft references. */ +export function useWorkspace(workspaceId: string, enabled = true) { + return useQuery({ + queryKey: ['workspaces', workspaceId], + queryFn: () => api(`/demo/workspaces/${workspaceId}`), + enabled: enabled && !!workspaceId, + }) +} diff --git a/frontend/src/pages/showcase.tsx b/frontend/src/pages/showcase.tsx index 6f6e38ed..61d5b947 100644 --- a/frontend/src/pages/showcase.tsx +++ b/frontend/src/pages/showcase.tsx @@ -3,19 +3,28 @@ import { Play, Loader2, Trophy, AlertTriangle, ArrowRight, Square } from 'lucide import { useState } from 'react' import { useDemoPipeline } from '@/hooks/use-demo-pipeline' import type { DemoStep } from '@/hooks/use-demo-pipeline' +import { useWorkspace } from '@/hooks/use-workspaces' import { DemoPhasePanel } from '@/components/demo/DemoPhasePanel' import { ScenarioPicker } from '@/components/demo/ScenarioPicker' import { ShowcaseKpiStrip } from '@/components/demo/ShowcaseKpiStrip' import { InspectArtifactsPanel } from '@/components/demo/InspectArtifactsPanel' import { RunHistoryStrip } from '@/components/demo/RunHistoryStrip' +import { WorkspacePanel } from '@/components/demo/WorkspacePanel' +import { WorkspaceArtifactsPanel } from '@/components/demo/WorkspaceArtifactsPanel' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' import { ROUTES } from '@/lib/constants' import { cn } from '@/lib/utils' +import type { WorkspaceListItem } from '@/types/api' const TERMINAL_STATUSES = new Set(['pass', 'fail', 'skip', 'warn']) +// E4 (#393) — mirrors the backend DemoRunRequest.workspace_name pattern +// (schemas.py): lowercase letters/digits, then -/_ allowed; ≤100 chars. +const WORKSPACE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_]*$/ + /** * PRP-38 / PRP-39 / PRP-40 — resolve the per-step Inspect deep link. * @@ -108,11 +117,72 @@ export default function ShowcasePage() { } = useDemoPipeline() const [reseed, setReseed] = useState(false) const [resetDb, setResetDb] = useState(false) + // E4 (#393) — workspace controls + restore state. + const [seed, setSeed] = useState(42) + const [keepWorkspace, setKeepWorkspace] = useState(false) + const [workspaceName, setWorkspaceName] = useState('') + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(null) + + // The page (not the panel) resolves the loaded workspace's detail — the + // artifacts panel needs detail-only created_objects. + const { data: loadedWorkspace } = useWorkspace( + selectedWorkspaceId ?? '', + !!selectedWorkspaceId + ) const completed = steps.filter((s) => TERMINAL_STATUSES.has(s.status)).length + const trimmedName = workspaceName.trim() + const nameInvalid = + keepWorkspace && trimmedName !== '' && !WORKSPACE_NAME_PATTERN.test(trimmedName) + const handleRun = () => { - start({ seed: 42, skip_seed: !reseed, reset: resetDb, scenario }) + // Starting a run detaches any loaded workspace — live cards take over. + setSelectedWorkspaceId(null) + start({ + seed, + skip_seed: !reseed, + reset: resetDb, + scenario, + // Omit the preservation fields entirely on ephemeral runs (legacy + // byte-compat); omit workspace_name when the input is empty. + ...(keepWorkspace + ? { + preservation: 'keep' as const, + ...(trimmedName ? { workspace_name: trimmedName } : {}), + } + : {}), + }) + } + + // E4 (#393) — Load: recorded config repopulates the controls; the detail + // query then renders the artifacts panel. Read-only — no run starts. + const handleLoadWorkspace = (ws: WorkspaceListItem) => { + setScenario(ws.scenario) + setSeed(ws.seed) + setReseed(!ws.skip_seed) + setResetDb(ws.reset) + setKeepWorkspace(true) + setWorkspaceName(ws.name ?? '') + setSelectedWorkspaceId(ws.workspace_id) + } + + // E4 (#393) — Replay: Load, then re-submit the recorded config VERBATIM + // through the existing WS run path with preservation='keep' (a replay is + // itself a workspace run). setScenario runs first (picker-desync gotcha: + // start() does not sync the picker state). + const handleReplayWorkspace = (ws: WorkspaceListItem) => { + handleLoadWorkspace(ws) + // The re-run's live cards take over; the original row stays untouched. + setSelectedWorkspaceId(null) + start({ + seed: ws.seed, + scenario: ws.scenario, + reset: ws.reset, + skip_seed: ws.skip_seed, + preservation: 'keep', + ...(ws.name ? { workspace_name: ws.name } : {}), + }) } // For the Inspect link to surface store_id/product_id on the train/backtest @@ -164,13 +234,21 @@ export default function ShowcasePage() { {/* PRP-41 — KPI strip at the top, hidden until at least one step completes. */} - {/* PRP-41 — Replayable run history (localStorage FIFO 5). */} + {/* PRP-41 — Replayable run history (localStorage FIFO 5; ephemeral runs only). */} start(req)} summary={phase === 'done' ? summary : null} scenario={scenario} /> + {/* E4 (#393) — server-backed saved workspaces (Load + Replay). */} + + {/* Controls */} @@ -186,7 +264,7 @@ export default function ShowcasePage() {
-
{phase === 'running' && ( @@ -308,6 +437,12 @@ export default function ShowcasePage() { {phase === 'done' && summary && ( )} + + {/* E4 (#393) — re-attached artifacts of a LOADED workspace. Any started + run detaches it (selectedWorkspaceId cleared) so live cards take over. */} + {phase !== 'running' && loadedWorkspace && ( + + )} ) } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 54ff956a..93de98cc 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -781,6 +781,10 @@ export interface DemoRunRequest { skip_seed?: boolean // PRP-38 — optional scenario picker; default is 'demo_minimal' (back-compat). scenario?: ScenarioPreset + // E4 (#393) — preservation policy (E1 backend fields, first UI exposure). + // Omit both to keep the legacy ephemeral behavior byte-identical. + preservation?: 'ephemeral' | 'keep' + workspace_name?: string } // Aggregate result returned by the synchronous POST /demo/run. @@ -792,6 +796,38 @@ export interface DemoRunResult { winning_run_id: string | null alias: string | null wall_clock_s: number + // E4 (#393) — non-null on preservation='keep' runs. + workspace_id: string | null +} + +// === Showcase Workspaces (E4, #393) === + +// A compact row from GET /demo/workspaces. +export interface WorkspaceListItem { + workspace_id: string + name: string | null + status: 'running' | 'completed' | 'failed' + seed: number + scenario: ScenarioPreset + reset: boolean + skip_seed: boolean + result_summary: Record | null + created_at: string +} + +// Full row from GET /demo/workspaces/{workspace_id}. +export interface WorkspaceDetail extends WorkspaceListItem { + store_id: number | null + product_id: number | null + date_start: string | null + date_end: string | null + created_objects: Record +} + +// Page shape of GET /demo/workspaces. +export interface WorkspaceListResponse { + workspaces: WorkspaceListItem[] + total: number } // === AI Model Configuration (/config) === From 41a3cd1a3891d7b1b1ed8e5c027291ce71e55f5c Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Fri, 12 Jun 2026 16:59:40 +0200 Subject: [PATCH 3/5] test(api): add demo replay same-config regression test (#393) --- tests/test_e2e_demo.py | 83 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/test_e2e_demo.py b/tests/test_e2e_demo.py index ac3a5278..5ef406ff 100644 --- a/tests/test_e2e_demo.py +++ b/tests/test_e2e_demo.py @@ -531,6 +531,89 @@ def test_run_demo_showcase_rich_full_epic( ) +# E4 (#393) — wall-clock budget per demo_minimal replay run (11 steps, the +# fastest preset; reset+seed dominates). Two sequential runs share one test. +REPLAY_RUN_TIMEOUT_S: float = 240.0 + + +def _post_demo_run(body_dict: dict[str, object], timeout_s: float) -> dict[str, object]: + """POST /demo/run with a JSON body; return the parsed DemoRunResult.""" + import json + + body = json.dumps(body_dict).encode("utf-8") + req = urllib.request.Request( # noqa: S310 — http://127.0.0.1 internal URL + f"{DEMO_API_URL}/demo/run", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=timeout_s) as resp: # noqa: S310 + payload = resp.read() + assert resp.status == 200, f"POST /demo/run -> {resp.status}" + except urllib.error.HTTPError as exc: + raise AssertionError(f"POST /demo/run failed: HTTP {exc.code} body={exc.read()!r}") from exc + result: dict[str, object] = json.loads(payload) + return result + + +@pytest.mark.integration +def test_demo_replay_same_config_twice( + uvicorn_subprocess: subprocess.Popen[bytes], +) -> None: + """E4 (#393) — replaying the IDENTICAL config stays green (no 409/500). + + The umbrella #389 replay-regression guard: the same ``preservation="keep"`` + body runs twice sequentially — the harshest path (re-seed + re-register + over the first run's accumulated model_run rows) must survive the #146 + (`_find_duplicate` multi-match 500) and #324 (safer-promote alias + corruption) fixes. Asserts both runs pass with DISTINCT workspace ids and + that ``GET /demo/workspaces`` lists both rows as completed. + """ + import json + + body_dict: dict[str, object] = { + "seed": 42, + "reset": True, + "skip_seed": False, + "scenario": "demo_minimal", + "preservation": "keep", + "workspace_name": "replay-regression", + } + + first = _post_demo_run(body_dict, REPLAY_RUN_TIMEOUT_S) + assert first["overall_status"] == "pass", ( + f"first run did not pass: " + f"steps={[(s['step_name'], s['status'], s['detail']) for s in first['steps']]}" # type: ignore[index] + ) + assert first["workspace_id"], "first keep-run surfaced no workspace_id" + + # Replay: the IDENTICAL body (verbatim semantics — incl. reset=true). + second = _post_demo_run(body_dict, REPLAY_RUN_TIMEOUT_S) + assert second["overall_status"] == "pass", ( + f"replay did not pass (replay regression — #146/#324 guard): " + f"steps={[(s['step_name'], s['status'], s['detail']) for s in second['steps']]}" # type: ignore[index] + ) + assert second["workspace_id"], "replay keep-run surfaced no workspace_id" + assert first["workspace_id"] != second["workspace_id"], ( + "replay must create a NEW workspace row, not reuse the original" + ) + + # Both rows are listed (newest first) and settled to completed. + with urllib.request.urlopen( # noqa: S310 — http://127.0.0.1 internal URL + f"{DEMO_API_URL}/demo/workspaces?limit=100", timeout=10.0 + ) as resp: + assert resp.status == 200 + page = json.loads(resp.read()) + replay_rows = [w for w in page["workspaces"] if w["name"] == "replay-regression"] + assert len(replay_rows) >= 2, f"expected >=2 replay-regression rows, got {len(replay_rows)}" + listed_ids = {w["workspace_id"] for w in replay_rows} + assert {first["workspace_id"], second["workspace_id"]} <= listed_ids + for row in replay_rows: + if row["workspace_id"] in {first["workspace_id"], second["workspace_id"]}: + assert row["status"] == "completed" + + @pytest.mark.integration def test_run_demo_precondition_failure_exits_2() -> None: """A bogus API URL surfaces as a precondition failure with exit 2. From ee844f120ff8f060d944453a0febb1d837ed2c27 Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Fri, 12 Jun 2026 16:59:40 +0200 Subject: [PATCH 4/5] docs(api): document workspace restore endpoints (#393) --- docs/_base/API_CONTRACTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/_base/API_CONTRACTS.md b/docs/_base/API_CONTRACTS.md index 2ce5dc8e..7947f13a 100644 --- a/docs/_base/API_CONTRACTS.md +++ b/docs/_base/API_CONTRACTS.md @@ -60,6 +60,8 @@ All endpoints serve JSON; error responses use `application/problem+json` (RFC 78 | seeder | POST | `/seeder/phase2-enrichment` | PRP-38 — run Phase 2 generators (lifecycle, replenishment, exogenous, returns) against the existing seeded data. `422 application/problem+json` on an empty database. | | demo | POST | `/demo/run` | Run the end-to-end demo pipeline in-process; returns a `DemoRunResult`. `409 application/problem+json` if a run is already active. **PRP-38** — body accepts an Optional `scenario: 'demo_minimal' \| 'showcase_rich' \| 'sparse'` field; default `'demo_minimal'` (back-compat). **E1 (#390)** — body accepts additive Optional `preservation: 'ephemeral' \| 'keep'` (default `'ephemeral'`, today's no-row behavior) and `workspace_name: str \| null` (pattern `^[a-z0-9][a-z0-9\-_]*$`, ≤100 chars); `workspace_name` without `preservation='keep'` → `422 application/problem+json`. `preservation='keep'` records the run as a `showcase_workspace` row; `DemoRunResult` gains an additive Optional `workspace_id: str \| null`. **E2 (#391)** — `scenario` accepts all 8 `ScenarioPreset` values (`retail_standard` / `holiday_rush` / `high_variance` / `stockout_heavy` / `new_launches` / `sparse` / `demo_minimal` / `showcase_rich`); only `showcase_rich` changes the step table (24 rows), every other preset runs the legacy 11-row flow. | | demo | WS | `/demo/stream` | Stream one `StepEvent` per pipeline step for the live Showcase page | +| demo | GET | `/demo/workspaces` | **E4 (#393)** — list saved showcase workspaces, newest first (`limit` 1-100 default 20 / `offset`); `200` + empty list on an empty table | +| demo | GET | `/demo/workspaces/{workspace_id}` | **E4 (#393)** — full workspace row incl. `created_objects` soft references + grain/window columns; `404 application/problem+json` when missing | | config | GET | `/config/ai` | Effective AI-model config (agent LLM + RAG embeddings); API keys masked, never raw | | config | PATCH | `/config/ai` | Persist + apply AI-model changes live (no restart). `409` if an embedding-dimension change would orphan indexed RAG chunks (resend with `force=true`) | | config | GET | `/config/providers/health` | Per-provider connectivity — Ollama probed live, cloud providers by API-key presence | @@ -94,6 +96,7 @@ Drives the end-to-end demo pipeline for the dashboard Showcase page. Verified ag - PRP-38 — `scenario="showcase_rich"` extends the data phase with `phase2_enrichment` + `historical_backfill` steps and the modeling phase with `v2_train` (one V2 `prophet_like` run). Phase ids are `data` / `modeling` / `decision` / `verify` / `agent` / `cleanup` (6 phases). - PRP-40 — `scenario="showcase_rich"` ALSO adds two phases inserted BEFORE `verify`: `planning` (2 steps — `scenario_simulate_and_save`, `multi_plan_compare`) and `knowledge` (3 steps — `embedding_provider_probe`, `rag_index_subset`, `rag_retrieve_probe`). Total step count: 19 for `showcase_rich`, 11 for `demo_minimal` and `sparse`. Phase ids on `showcase_rich` are `data` / `modeling` / `decision` / `planning` / `knowledge` / `verify` / `agent` / `cleanup` (8 phases). The knowledge steps SKIP gracefully when the embedding provider is unreachable; the pipeline still goes green. - E3 (#392) — the planning-phase steps tag the plans they save: pipeline-saved plans now carry `source:showcase` (alongside the legacy `showcase` + `price`/`holiday` tags), and on `preservation="keep"` runs additionally `workspace:` — retrievable via `GET /scenarios?tags=workspace: