From ab1871534dd6fdb310c6aab3b71f63d2ce22d5f4 Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Sat, 13 Jun 2026 01:24:02 +0200 Subject: [PATCH 1/6] feat(api): add workspace list filters and link-health endpoint (#408) --- app/features/demo/link_health.py | 178 +++++++++++++++++++++++++++++++ app/features/demo/routes.py | 101 ++++++++++++++++-- app/features/demo/schemas.py | 46 ++++++++ app/features/demo/workspace.py | 106 +++++++++++++++--- 4 files changed, 412 insertions(+), 19 deletions(-) create mode 100644 app/features/demo/link_health.py diff --git a/app/features/demo/link_health.py b/app/features/demo/link_health.py new file mode 100644 index 00000000..d3748fd4 --- /dev/null +++ b/app/features/demo/link_health.py @@ -0,0 +1,178 @@ +"""Soft-reference liveness probes for showcase workspaces (E2, issue #408). + +A workspace row records everything its run created as OPAQUE SOFT REFERENCES +(no ForeignKeys -- see ``app/features/demo/models.py``), so referenced objects +can be deleted out from under it by design. This module turns that silent +staleness into a per-workspace health signal. + +The demo slice may NOT import another feature slice (vertical-slice rule), so +liveness is checked through the public HTTP API **in-process** via +``httpx.ASGITransport`` -- the exact mechanism ``pipeline._Client`` already +uses (``app/features/demo/pipeline.py``). ``raise_app_exceptions=False`` is +load-bearing: an unhandled error inside a probed endpoint must surface as a +500 *response* (classified ``unknown``), never as a re-raised exception. + +Classification table: + + 2xx -> "alive" (the referenced object still exists) + 404 -> "dead" (deleted after the run -- expected, designed) + anything else -> "unknown" (5xx, timeout, transport error -- no false alarms) + +A probe NEVER raises -- a flaky slice must not 500 the health route. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import httpx + +from app.features.demo.models import WORKSPACE_STATUS_COMPLETED, ShowcaseWorkspace +from app.features.demo.schemas import ( + RefHealthStatus, + RefType, + WorkspaceHealthResponse, + WorkspaceRefHealth, +) + +if TYPE_CHECKING: + from fastapi import FastAPI + +# Probe budget -- generous for an in-process call; a hung dependency inside a +# probed endpoint classifies as "unknown" instead of hanging the health route. +_PROBE_TIMEOUT = httpx.Timeout(10.0, connect=5.0) + + +@dataclass(frozen=True) +class _ProbeTarget: + """One probeable soft reference resolved from a workspace row.""" + + key: str # created_objects key (list keys carry an index, e.g. "scenario_plan_ids[0]") + ref_type: RefType + ref_id: str + probe_path: str # public API path whose status code decides liveness + + +def build_probe_targets(ws: ShowcaseWorkspace) -> list[_ProbeTarget]: + """Map a workspace's soft references to probeable public API paths. + + Non-probeable ``created_objects`` keys (``v2_model_path``, + ``scenario_artifact_key``, ``train_model_types``) are skipped -- they have + no HTTP identity to check. The E1 ``job_ids`` story slot (CONTRACT(E1)-6) + probes through ``GET /jobs/{job_id}`` when present; pre-backfill rows + where the slot is NULL are silently skipped. + """ + targets: list[_ProbeTarget] = [] + objects = ws.created_objects or {} + + def _str_value(key: str) -> str | None: + value = objects.get(key) + return value if isinstance(value, str) and value else None + + for key in ("winning_run_id", "v2_run_id", "stale_alias_run_id"): + run_id = _str_value(key) + if run_id: + targets.append(_ProbeTarget(key, "model_run", run_id, f"/registry/runs/{run_id}")) + + plan_ids = objects.get("scenario_plan_ids") + if isinstance(plan_ids, list): + for index, plan_id in enumerate(plan_ids): + if isinstance(plan_id, str) and plan_id: + targets.append( + _ProbeTarget( + f"scenario_plan_ids[{index}]", + "scenario_plan", + plan_id, + f"/scenarios/{plan_id}", + ) + ) + + alias = _str_value("alias") + if alias: + targets.append(_ProbeTarget("alias", "alias", alias, f"/registry/aliases/{alias}")) + + batch_id = _str_value("batch_id") + if batch_id: + targets.append(_ProbeTarget("batch_id", "batch", batch_id, f"/batch/{batch_id}")) + + session_id = _str_value("agent_session_id") + if session_id: + targets.append( + _ProbeTarget( + "agent_session_id", + "agent_session", + session_id, + f"/agents/sessions/{session_id}", + ) + ) + + # The ORM types job_ids as list[str], but JSONB enforces nothing at + # runtime -- treat entries as untrusted (mirrors the created_objects guards). + job_ids: list[Any] = list(ws.job_ids or []) + for index, job_id in enumerate(job_ids): + if isinstance(job_id, str) and job_id: + targets.append(_ProbeTarget(f"job_ids[{index}]", "job", job_id, f"/jobs/{job_id}")) + + return targets + + +async def _probe_one(client: httpx.AsyncClient, target: _ProbeTarget) -> WorkspaceRefHealth: + """Probe one reference; classify the status code. NEVER raises.""" + status: RefHealthStatus + try: + response = await client.get(target.probe_path) + except (httpx.HTTPError, OSError): + status = "unknown" + else: + if 200 <= response.status_code < 300: + status = "alive" + elif response.status_code == 404: + status = "dead" + else: + status = "unknown" + return WorkspaceRefHealth( + key=target.key, + ref_type=target.ref_type, + ref_id=target.ref_id, + status=status, + probe_path=target.probe_path, + ) + + +async def probe_workspace_links(app: FastAPI, ws: ShowcaseWorkspace) -> WorkspaceHealthResponse: + """Probe every soft reference a workspace recorded; aggregate the counts. + + Probes run concurrently. ``partial_run`` flags a row whose pipeline never + settled to ``completed`` -- its artifacts may be missing regardless of + what the probes find. + + Args: + app: The live FastAPI app (``request.app`` -- the slice never imports + ``app.main``). + ws: The workspace row whose references are probed. + + Returns: + The per-reference results plus alive/dead/unknown counts. + """ + targets = build_probe_targets(ws) + references: list[WorkspaceRefHealth] = [] + if targets: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app, raise_app_exceptions=False), + base_url="http://demo.internal", + timeout=_PROBE_TIMEOUT, + ) as client: + references = list( + await asyncio.gather(*(_probe_one(client, target) for target in targets)) + ) + return WorkspaceHealthResponse( + workspace_id=ws.workspace_id, + workspace_status=ws.status, + partial_run=ws.status != WORKSPACE_STATUS_COMPLETED, + references=references, + alive=sum(1 for ref in references if ref.status == "alive"), + dead=sum(1 for ref in references if ref.status == "dead"), + unknown=sum(1 for ref in references if ref.status == "unknown"), + ) diff --git a/app/features/demo/routes.py b/app/features/demo/routes.py index 87584247..deaa8ac0 100644 --- a/app/features/demo/routes.py +++ b/app/features/demo/routes.py @@ -4,7 +4,12 @@ - ``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. + E2 (#408): ``q`` name search, repeated ``tags`` containment, + ``include_archived`` (default false), allow-listed ``sort_by``/``sort_order``; + pinned rows always order first. - ``GET /demo/workspaces/{workspace_id}`` -- E4 (#393): one workspace's detail. +- ``GET /demo/workspaces/{workspace_id}/health`` -- E2 (#408): probe the + workspace's soft references in-process; per-ref alive/dead/unknown + counts. - ``PATCH /demo/workspaces/{workspace_id}`` -- E1 (#407): partial lifecycle update (rename / notes / tags / archive / pin); ``status`` is not patchable. - ``DELETE /demo/workspaces/{workspace_id}`` -- delete the workspace METADATA @@ -35,12 +40,13 @@ 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, workspace +from app.features.demo import link_health, service, workspace from app.features.demo.schemas import ( DemoRunRequest, DemoRunResult, StepEvent, WorkspaceDetailResponse, + WorkspaceHealthResponse, WorkspaceListItem, WorkspaceListResponse, WorkspaceUpdateRequest, @@ -84,26 +90,70 @@ async def run_demo_pipeline(request: Request, params: DemoRunRequest) -> DemoRun "/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.", + description=( + "List saved showcase workspaces, newest first (pinned rows always " + "order first). E2 (#408): `q` searches names case-insensitively, " + "repeated `tags` params filter by containment, archived rows are " + "hidden unless `include_archived=true`, and `sort_by`/`sort_order` " + "are allow-listed (unknown values use the default order). Returns " + "200 + an empty list when nothing matches." + ), ) 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."), + q: str | None = Query( + default=None, + min_length=2, + description="Search in workspace name (case-insensitive).", + ), + tags: list[str] | None = Query( + default=None, + description="Repeatable tag filter -- a workspace matches when it " + "carries every listed tag.", + ), + include_archived: bool = Query( + default=False, + description="Include archived workspaces (hidden by default).", + ), + sort_by: str | None = Query( + default=None, + description="Sort column: created_at, name, seed, or status. " + "Unknown values fall back to the default order (created_at desc).", + ), + sort_order: str = Query( + default="desc", + pattern="^(asc|desc)$", + description="Sort direction: asc or desc.", + ), ) -> WorkspaceListResponse: - """List saved showcase workspaces (E4, issue #393). + """List saved showcase workspaces (E4 #393; filters/sort E2 #408). Args: db: Async database session from dependency. limit: Maximum workspaces to return (1-100). offset: Number of workspaces to skip. + q: Case-insensitive name search. + tags: Repeatable tag containment filter. + include_archived: Include archived workspaces. + sort_by: Allow-listed sort column (unknown values use default order). + sort_order: Sort direction (asc or desc). Returns: - A page of saved workspaces plus the total count. + A page of saved workspaces plus the filtered total count. """ - rows = await workspace.list_workspaces(db, limit=limit, offset=offset) - total = await workspace.count_workspaces(db) + rows = await workspace.list_workspaces( + db, + limit=limit, + offset=offset, + q=q, + tags=tags, + include_archived=include_archived, + sort_by=sort_by, + sort_order=sort_order, + ) + total = await workspace.count_workspaces(db, q=q, tags=tags, include_archived=include_archived) return WorkspaceListResponse( workspaces=[WorkspaceListItem.model_validate(row) for row in rows], total=total, @@ -138,6 +188,43 @@ async def get_showcase_workspace( return WorkspaceDetailResponse.model_validate(row) +@router.get( + "/workspaces/{workspace_id}/health", + response_model=WorkspaceHealthResponse, + summary="Probe a workspace's soft-reference link health", + description=( + "Probe every soft reference the workspace recorded (model runs, " + "scenario plans, alias, batch, agent session, job ids) through the " + "public API in-process. Each reference classifies as alive (2xx), " + "dead (404 -- deleted after the run), or unknown (anything else). " + "`partial_run` flags a row whose pipeline never completed." + ), +) +async def get_workspace_health( + workspace_id: str, + request: Request, + db: AsyncSession = Depends(get_db), +) -> WorkspaceHealthResponse: + """Probe a saved workspace's soft references (E2, issue #408). + + Args: + workspace_id: External identifier of the workspace. + request: The incoming request (used to obtain the live FastAPI app + for the in-process probes). + db: Async database session from dependency. + + Returns: + Per-reference liveness plus aggregate counts. + + 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 await link_health.probe_workspace_links(request.app, row) + + @router.patch( "/workspaces/{workspace_id}", response_model=WorkspaceDetailResponse, diff --git a/app/features/demo/schemas.py b/app/features/demo/schemas.py index 66bf202b..58daf891 100644 --- a/app/features/demo/schemas.py +++ b/app/features/demo/schemas.py @@ -314,3 +314,49 @@ class WorkspaceListResponse(BaseModel): ..., description="Saved workspaces for the current page; empty when none." ) total: int = Field(..., ge=0, description="Total saved workspaces.") + + +# E2 (#408) -- link-health classification of one probed soft reference. +RefHealthStatus = Literal["alive", "dead", "unknown"] +# E2 (#408) -- kind of soft-referenced object a workspace can record. +RefType = Literal["model_run", "scenario_plan", "alias", "batch", "agent_session", "job"] + + +class WorkspaceRefHealth(BaseModel): + """Liveness of one soft reference recorded on a workspace (E2, #408). + + Response model -- plain ``BaseModel``, NOT strict (``StepEvent`` + precedent above; strict mode is request-body-only policy). + """ + + key: str = Field( + ..., + description="created_objects key, e.g. 'winning_run_id' or 'scenario_plan_ids[0]'.", + ) + ref_type: RefType = Field(..., description="Kind of referenced object.") + ref_id: str = Field(..., description="The recorded soft-reference id.") + status: RefHealthStatus = Field( + ..., description="alive (2xx) / dead (404) / unknown (anything else)." + ) + probe_path: str = Field(..., description="The public API path probed.") + + +class WorkspaceHealthResponse(BaseModel): + """Per-workspace link-health summary (E2, #408). + + Response model -- plain ``BaseModel``, NOT strict. + """ + + workspace_id: str = Field(..., description="The probed workspace's external id.") + workspace_status: str = Field(..., description="running / completed / failed.") + partial_run: bool = Field( + ..., description="True when workspace_status != 'completed' (the run never settled)." + ) + references: list[WorkspaceRefHealth] = Field( + default_factory=list, + description="Per-reference probe results; empty when nothing was recorded.", + ) + alive: int = Field(..., ge=0, description="Count of references that probed alive.") + dead: int = Field(..., ge=0, description="Count of references that probed dead (404).") + unknown: int = Field(..., ge=0, description="Count of references whose probe was inconclusive.") + checked_at: datetime = Field(default_factory=_utc_now, description="When the probes ran (UTC).") diff --git a/app/features/demo/workspace.py b/app/features/demo/workspace.py index 0af35a50..364b64fd 100644 --- a/app/features/demo/workspace.py +++ b/app/features/demo/workspace.py @@ -17,8 +17,11 @@ ``GET /demo/workspaces/{workspace_id}`` in ``app/features/demo/routes.py``; :func:`delete_workspace` backs ``DELETE /demo/workspaces/{workspace_id}``; :func:`update_workspace` backs ``PATCH /demo/workspaces/{workspace_id}`` -(E1, #407). The request-scoped helpers take a caller-owned session and raise -normally -- the warn-and-continue contract is pipeline-only. +(E1, #407). E2 (#408) adds server-side list filters (``q`` name search, +``tags`` containment, ``include_archived``) and an allow-listed sort with +unconditional pinned-first ordering. The request-scoped helpers take a +caller-owned session and raise normally -- the warn-and-continue contract is +pipeline-only. """ from __future__ import annotations @@ -26,8 +29,9 @@ import uuid from typing import TYPE_CHECKING, Any -from sqlalchemy import func, select +from sqlalchemy import Select, func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import InstrumentedAttribute from app.core.database import get_session_maker from app.core.logging import get_logger @@ -45,6 +49,43 @@ logger = get_logger(__name__) +# E2 (#408) -- allow-listed sort columns for GET /demo/workspaces. sort_by is +# user input; unknown values fall back to the default order (created_at desc) +# rather than erroring (dimensions precedent, app/features/dimensions/service.py). +_SORT_COLUMNS: dict[str, InstrumentedAttribute[Any]] = { + "created_at": ShowcaseWorkspace.created_at, + "name": ShowcaseWorkspace.name, + "seed": ShowcaseWorkspace.seed, + "status": ShowcaseWorkspace.status, +} + + +def _apply_filters[SelectT: Select[Any]]( + stmt: SelectT, + *, + q: str | None = None, + tags: list[str] | None = None, + include_archived: bool = False, +) -> SelectT: + """Apply the E2 list filters to a select statement. + + Shared by :func:`list_workspaces` and :func:`count_workspaces` so the + page's ``total`` always respects the active filters (scenarios precedent: + ``app/features/scenarios/service.py`` applies the same ``.where`` chain to + both the count and rows statements). + """ + if not include_archived: + stmt = stmt.where(ShowcaseWorkspace.archived.is_(False)) + if q: + # Case-insensitive name search (dimensions ILIKE precedent). NAME only + # -- workspace_id prefixes are copy-paste handles, not search terms. + stmt = stmt.where(ShowcaseWorkspace.name.ilike(f"%{q}%")) + if tags: + # JSONB @> containment -- a workspace matches when it carries every + # listed tag (scenario_plan.tags precedent; GIN-indexed since E1 #407). + stmt = stmt.where(ShowcaseWorkspace.tags.contains(tags)) + return stmt + async def create_workspace(req: DemoRunRequest) -> str | None: """Insert a ``running`` workspace row for a ``preservation="keep"`` run. @@ -217,20 +258,44 @@ async def list_workspaces( *, limit: int = 50, offset: int = 0, + q: str | None = None, + tags: list[str] | None = None, + include_archived: bool = False, + sort_by: str | None = None, + sort_order: str = "desc", ) -> list[ShowcaseWorkspace]: - """List workspace rows, newest first (tie-broken by id, descending). + """List workspace rows with E2 (#408) filters; pinned rows always first. + + Default order is newest first (tie-broken by id, descending). ``sort_by`` + is allow-listed (created_at / name / seed / status); unknown values fall + back to the default order. ``name`` sorts NULLS LAST so unnamed rows sink. + Pinned rows order first regardless of the active sort. Args: db: An open async session (caller-owned). limit: Maximum rows to return. - offset: Rows to skip from the newest end. + offset: Rows to skip from the sorted front. + q: Case-insensitive name search (ILIKE substring). + tags: Tag containment filter -- a row must carry every listed tag. + include_archived: Include archived rows (hidden by default). + sort_by: Allow-listed sort column; unknown values use the default order. + sort_order: Sort direction ("asc" or "desc"). Returns: - The matching rows, newest first. + The matching rows in the requested order. """ + sort_column = _SORT_COLUMNS.get(sort_by) if sort_by else None + if sort_column is not None: + order_expr = sort_column.desc() if sort_order == "desc" else sort_column.asc() + if sort_by == "name": + order_expr = order_expr.nulls_last() + else: + order_expr = ShowcaseWorkspace.created_at.desc() + stmt = _apply_filters( + select(ShowcaseWorkspace), q=q, tags=tags, include_archived=include_archived + ) result = await db.execute( - select(ShowcaseWorkspace) - .order_by(ShowcaseWorkspace.created_at.desc(), ShowcaseWorkspace.id.desc()) + stmt.order_by(ShowcaseWorkspace.pinned.desc(), order_expr, ShowcaseWorkspace.id.desc()) .limit(limit) .offset(offset) ) @@ -262,14 +327,31 @@ async def delete_workspace(db: AsyncSession, workspace_id: str) -> bool: return True -async def count_workspaces(db: AsyncSession) -> int: - """Count all workspace rows (E4, issue #393). +async def count_workspaces( + db: AsyncSession, + *, + q: str | None = None, + tags: list[str] | None = None, + include_archived: bool = False, +) -> int: + """Count workspace rows matching the active filters (E4 #393, E2 #408). + + Applies the SAME filter chain as :func:`list_workspaces` (via + :func:`_apply_filters`) so a filtered page's ``total`` stays honest. Args: db: An open async session (caller-owned). + q: Case-insensitive name search (ILIKE substring). + tags: Tag containment filter -- a row must carry every listed tag. + include_archived: Include archived rows (hidden by default). Returns: - The total number of saved workspaces. + The number of saved workspaces matching the filters. """ - count_stmt = select(func.count()).select_from(ShowcaseWorkspace) + count_stmt = _apply_filters( + select(func.count()).select_from(ShowcaseWorkspace), + q=q, + tags=tags, + include_archived=include_archived, + ) return int(await db.scalar(count_stmt) or 0) From 9f9993a8f6d576d236b075533196921516ae724a Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Sat, 13 Jun 2026 01:24:02 +0200 Subject: [PATCH 2/6] test(api): cover workspace filters and link-health probes (#408) --- app/features/demo/tests/test_link_health.py | 204 ++++++++++++++++ app/features/demo/tests/test_routes.py | 253 +++++++++++++++++++- 2 files changed, 449 insertions(+), 8 deletions(-) create mode 100644 app/features/demo/tests/test_link_health.py diff --git a/app/features/demo/tests/test_link_health.py b/app/features/demo/tests/test_link_health.py new file mode 100644 index 00000000..dd27f828 --- /dev/null +++ b/app/features/demo/tests/test_link_health.py @@ -0,0 +1,204 @@ +"""Unit tests for the link-health probe module (E2, issue #408). + +Probes run against a THROWAWAY FastAPI stub app -- no database, no real +slices. The stub returns 200 / 404 / 500 (and one raising endpoint) at the +probed paths so every classification branch is exercised. Workspace rows are +constructed in memory (never persisted) -- Python-side column defaults apply +at INSERT time, so every consumed field is passed explicitly. +""" + +from typing import Any + +from fastapi import FastAPI, Response + +from app.features.demo import link_health +from app.features.demo.link_health import _ProbeTarget, build_probe_targets +from app.features.demo.models import ShowcaseWorkspace + + +def _make_workspace(**overrides: Any) -> ShowcaseWorkspace: + """An in-memory (unpersisted) ShowcaseWorkspace with explicit fields.""" + base: dict[str, Any] = { + "workspace_id": "a" * 32, + "name": "e2-health", + "status": "completed", + "seed": 42, + "scenario": "demo_minimal", + "reset": False, + "skip_seed": True, + "created_objects": {}, + "job_ids": None, + } + base.update(overrides) + return ShowcaseWorkspace(**base) + + +def _stub_app() -> FastAPI: + """A throwaway ASGI app standing in for the probed public surface.""" + app = FastAPI() + + @app.get("/registry/runs/{run_id}") + def get_run(run_id: str) -> Response: + if run_id == "run-alive": + return Response(status_code=200, content="{}", media_type="application/json") + return Response(status_code=404) + + @app.get("/scenarios/{scenario_id}") + def get_scenario(scenario_id: str) -> Response: + return Response(status_code=404) + + @app.get("/registry/aliases/{alias_name}") + def get_alias(alias_name: str) -> Response: + return Response(status_code=200, content="{}", media_type="application/json") + + @app.get("/batch/{batch_id}") + def get_batch(batch_id: str) -> Response: + return Response(status_code=500) + + @app.get("/agents/sessions/{session_id}") + def get_session(session_id: str) -> Response: + raise RuntimeError("probed endpoint blew up") # -> 500 response, never re-raised + + @app.get("/jobs/{job_id}") + def get_job(job_id: str) -> Response: + if job_id == "job-alive": + return Response(status_code=200, content="{}", media_type="application/json") + return Response(status_code=404) + + return app + + +# ============================================================================= +# build_probe_targets +# ============================================================================= + + +def test_build_probe_targets_covers_every_probeable_key() -> None: + """Every probeable created_objects key + the job_ids slot map to a path.""" + ws = _make_workspace( + created_objects={ + "winning_run_id": "run-1", + "v2_run_id": "run-2", + "stale_alias_run_id": "run-3", + "scenario_plan_ids": ["sp-1", "sp-2"], + "alias": "demo-production", + "batch_id": "batch-1", + "agent_session_id": "sess-1", + # Non-probeable keys -- no HTTP identity; must be skipped. + "v2_model_path": "artifacts/models/model_x.joblib", + "scenario_artifact_key": "abc123", + "train_model_types": ["naive", "seasonal_naive"], + }, + job_ids=["job-1", "job-2"], + ) + targets = build_probe_targets(ws) + by_key = {t.key: t for t in targets} + + assert by_key["winning_run_id"].probe_path == "/registry/runs/run-1" + assert by_key["winning_run_id"].ref_type == "model_run" + assert by_key["v2_run_id"].probe_path == "/registry/runs/run-2" + assert by_key["stale_alias_run_id"].probe_path == "/registry/runs/run-3" + assert by_key["scenario_plan_ids[0]"].probe_path == "/scenarios/sp-1" + assert by_key["scenario_plan_ids[1]"].probe_path == "/scenarios/sp-2" + assert by_key["scenario_plan_ids[0]"].ref_type == "scenario_plan" + assert by_key["alias"].probe_path == "/registry/aliases/demo-production" + assert by_key["batch_id"].probe_path == "/batch/batch-1" + assert by_key["agent_session_id"].probe_path == "/agents/sessions/sess-1" + assert by_key["job_ids[0]"].probe_path == "/jobs/job-1" + assert by_key["job_ids[1]"].probe_path == "/jobs/job-2" + assert by_key["job_ids[0]"].ref_type == "job" + # 3 run ids + 2 plans + alias + batch + session + 2 jobs -- and nothing + # for the non-probeable keys. + assert len(targets) == 10 + assert not any("model_path" in t.key or "artifact" in t.key for t in targets) + + +def test_build_probe_targets_empty_objects() -> None: + """No recorded references (and a NULL job_ids slot) -> no targets.""" + assert build_probe_targets(_make_workspace()) == [] + + +def test_build_probe_targets_skips_non_string_values() -> None: + """Malformed JSONB values (non-strings, empties) are skipped, not raised.""" + ws = _make_workspace( + created_objects={ + "winning_run_id": 123, # not a str + "alias": "", # empty + "scenario_plan_ids": ["sp-1", 7, None, ""], + "batch_id": None, + }, + job_ids=["job-1", 42], + ) + targets = build_probe_targets(ws) + assert [t.key for t in targets] == ["scenario_plan_ids[0]", "job_ids[0]"] + + +# ============================================================================= +# probe_workspace_links (against the stub app) +# ============================================================================= + + +async def test_probe_classification_alive_dead_unknown() -> None: + """2xx -> alive, 404 -> dead, 5xx/exception -> unknown; counts add up.""" + ws = _make_workspace( + created_objects={ + "winning_run_id": "run-alive", # 200 -> alive + "v2_run_id": "run-gone", # 404 -> dead + "scenario_plan_ids": ["sp-gone"], # 404 -> dead + "alias": "demo-production", # 200 -> alive + "batch_id": "batch-1", # 500 -> unknown + "agent_session_id": "sess-1", # raises -> 500 response -> unknown + }, + job_ids=["job-alive", "job-gone"], # 200 + 404 + ) + health = await link_health.probe_workspace_links(_stub_app(), ws) + + by_key = {r.key: r.status for r in health.references} + assert by_key["winning_run_id"] == "alive" + assert by_key["v2_run_id"] == "dead" + assert by_key["scenario_plan_ids[0]"] == "dead" + assert by_key["alias"] == "alive" + assert by_key["batch_id"] == "unknown" + assert by_key["agent_session_id"] == "unknown" + assert by_key["job_ids[0]"] == "alive" + assert by_key["job_ids[1]"] == "dead" + + assert health.alive == 3 + assert health.dead == 3 + assert health.unknown == 2 + assert health.workspace_id == ws.workspace_id + assert health.partial_run is False + + +async def test_probe_empty_workspace_short_circuits() -> None: + """No references -> empty result, zero counts, no client construction.""" + health = await link_health.probe_workspace_links(_stub_app(), _make_workspace()) + assert health.references == [] + assert (health.alive, health.dead, health.unknown) == (0, 0, 0) + + +async def test_partial_run_flag_tracks_status() -> None: + """partial_run is True exactly when the row never reached 'completed'.""" + app = _stub_app() + for status, expected in (("completed", False), ("failed", True), ("running", True)): + health = await link_health.probe_workspace_links(app, _make_workspace(status=status)) + assert health.partial_run is expected + assert health.workspace_status == status + + +async def test_probe_transport_error_classifies_unknown() -> None: + """A transport-level failure classifies as unknown -- never raises.""" + + class _ExplodingClient: + async def get(self, _path: str) -> Response: + raise OSError("transport down") + + target = _ProbeTarget( + key="winning_run_id", + ref_type="model_run", + ref_id="run-1", + probe_path="/registry/runs/run-1", + ) + result = await link_health._probe_one(_ExplodingClient(), target) # type: ignore[arg-type] + assert result.status == "unknown" + assert result.ref_id == "run-1" diff --git a/app/features/demo/tests/test_routes.py b/app/features/demo/tests/test_routes.py index 1934c018..f5813d79 100644 --- a/app/features/demo/tests/test_routes.py +++ b/app/features/demo/tests/test_routes.py @@ -236,10 +236,10 @@ def _orm_like_row(workspace_id: str = "a" * 32, **overrides: object) -> SimpleNa 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]: + async def fake_list(_db, **_kwargs: object) -> list[SimpleNamespace]: return [] - async def fake_count(_db) -> int: + async def fake_count(_db, **_kwargs: object) -> int: return 0 monkeypatch.setattr(workspace, "list_workspaces", fake_list) @@ -252,14 +252,13 @@ async def fake_count(_db) -> int: async def test_list_workspaces_passes_pagination(client, monkeypatch): """E4 (#393) -- limit/offset query params reach the helper.""" - seen: dict[str, int] = {} + seen: dict[str, object] = {} - async def fake_list(_db, *, limit: int, offset: int) -> list[SimpleNamespace]: - seen["limit"] = limit - seen["offset"] = offset + async def fake_list(_db, **kwargs: object) -> list[SimpleNamespace]: + seen.update(kwargs) return [_orm_like_row()] - async def fake_count(_db) -> int: + async def fake_count(_db, **_kwargs: object) -> int: return 5 monkeypatch.setattr(workspace, "list_workspaces", fake_list) @@ -267,7 +266,8 @@ async def fake_count(_db) -> int: resp = await client.get("/demo/workspaces", params={"limit": 2, "offset": 3}) assert resp.status_code == 200 - assert seen == {"limit": 2, "offset": 3} + assert seen["limit"] == 2 + assert seen["offset"] == 3 body = resp.json() assert body["total"] == 5 assert body["workspaces"][0]["workspace_id"] == "a" * 32 @@ -283,6 +283,136 @@ async def test_list_workspaces_rejects_bad_pagination(client): assert resp.status_code == 422 +# ============================================================================= +# E2 (#408) -- list filters / sort + GET /demo/workspaces/{id}/health (unit) +# ============================================================================= + + +async def test_list_workspaces_passes_filters_and_sort(client, monkeypatch): + """E2 (#408) -- q/tags/include_archived/sort params reach BOTH helpers.""" + seen_list: dict[str, object] = {} + seen_count: dict[str, object] = {} + + async def fake_list(_db, **kwargs: object) -> list[SimpleNamespace]: + seen_list.update(kwargs) + return [] + + async def fake_count(_db, **kwargs: object) -> int: + seen_count.update(kwargs) + return 0 + + monkeypatch.setattr(workspace, "list_workspaces", fake_list) + monkeypatch.setattr(workspace, "count_workspaces", fake_count) + + resp = await client.get( + "/demo/workspaces", + params=[ + ("q", "demo"), + ("tags", "smoke"), + ("tags", "e2"), + ("include_archived", "true"), + ("sort_by", "name"), + ("sort_order", "asc"), + ], + ) + assert resp.status_code == 200 + assert seen_list["q"] == "demo" + assert seen_list["tags"] == ["smoke", "e2"] + assert seen_list["include_archived"] is True + assert seen_list["sort_by"] == "name" + assert seen_list["sort_order"] == "asc" + # The count helper gets the SAME filters -- total respects them. + assert seen_count["q"] == "demo" + assert seen_count["tags"] == ["smoke", "e2"] + assert seen_count["include_archived"] is True + + +async def test_list_workspaces_defaults_hide_archived(client, monkeypatch): + """E2 (#408) -- a legacy no-param call defaults to include_archived=False.""" + seen: dict[str, object] = {} + + async def fake_list(_db, **kwargs: object) -> list[SimpleNamespace]: + seen.update(kwargs) + return [] + + async def fake_count(_db, **_kwargs: object) -> 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 seen["include_archived"] is False + assert seen["q"] is None + assert seen["tags"] is None + assert seen["sort_by"] is None + + +async def test_list_workspaces_rejects_bad_sort_order(client): + """E2 (#408) -- sort_order is pattern-constrained (asc|desc only).""" + resp = await client.get("/demo/workspaces", params={"sort_order": "sideways"}) + assert resp.status_code == 422 + assert resp.headers["content-type"].startswith("application/problem+json") + + +async def test_workspace_health_404(client, monkeypatch): + """E2 (#408) -- health on a missing workspace 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 + "/health") + 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_workspace_health_happy_path(client, monkeypatch): + """E2 (#408) -- the route resolves the row and returns the probe result.""" + from app.features.demo import link_health + from app.features.demo.schemas import WorkspaceHealthResponse, WorkspaceRefHealth + + row = _orm_like_row(status="failed") + + async def fake_get(_db, workspace_id: str) -> SimpleNamespace: + return row + + async def fake_probe(_app, ws) -> WorkspaceHealthResponse: + assert ws is row # the route passes the resolved ORM row through + return WorkspaceHealthResponse( + workspace_id="a" * 32, + workspace_status="failed", + partial_run=True, + references=[ + WorkspaceRefHealth( + key="winning_run_id", + ref_type="model_run", + ref_id="run-abc", + status="dead", + probe_path="/registry/runs/run-abc", + ) + ], + alive=0, + dead=1, + unknown=0, + ) + + monkeypatch.setattr(workspace, "get_workspace", fake_get) + monkeypatch.setattr(link_health, "probe_workspace_links", fake_probe) + + resp = await client.get("/demo/workspaces/" + "a" * 32 + "/health") + assert resp.status_code == 200 + body = resp.json() + assert body["workspace_id"] == "a" * 32 + assert body["partial_run"] is True + assert body["dead"] == 1 + assert body["references"][0]["status"] == "dead" + assert body["references"][0]["probe_path"] == "/registry/runs/run-abc" + + async def test_get_workspace_404(client, monkeypatch): """E4 (#393) -- unknown workspace_id is a 404 problem+json.""" @@ -585,3 +715,110 @@ async def test_delete_workspace_integration_keeps_created_objects(client, db_ses assert still_there.status_code == 200 finally: await client.delete(f"/agents/sessions/{agent_session_id}") + + +# ============================================================================= +# E2 (#408) -- list filters / sort + health against real Postgres (integration) +# ============================================================================= + + +@pytest.mark.integration +async def test_list_workspaces_integration_filters_and_sort(client, db_session: AsyncSession): + """Filters, sort, pinned-first ordering, and filtered totals on real rows.""" + ids: dict[str, str] = {} + # Creation order matters for the default created_at sort assertions. + for name in ("alpha-match", "beta", "zeta-pinned"): + workspace_id = await workspace.create_workspace( + DemoRunRequest.model_validate({"preservation": "keep", "workspace_name": name}) + ) + assert workspace_id is not None + ids[name] = workspace_id + unnamed = await workspace.create_workspace( + DemoRunRequest.model_validate({"preservation": "keep"}) + ) + assert unnamed is not None + + # Curate via the PATCH surface (E1): pin zeta, archive beta, tag alpha. + assert ( + await client.patch(f"/demo/workspaces/{ids['zeta-pinned']}", json={"pinned": True}) + ).status_code == 200 + assert ( + await client.patch(f"/demo/workspaces/{ids['beta']}", json={"archived": True}) + ).status_code == 200 + assert ( + await client.patch(f"/demo/workspaces/{ids['alpha-match']}", json={"tags": ["smoke", "e2"]}) + ).status_code == 200 + + # Default list: archived hidden, pinned first, then newest-first. + resp = await client.get("/demo/workspaces") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 3 # beta (archived) excluded from the total too + listed = [w["workspace_id"] for w in body["workspaces"]] + assert ids["beta"] not in listed + assert listed == [ids["zeta-pinned"], unnamed, ids["alpha-match"]] + + # include_archived=true surfaces the archived row again. + resp = await client.get("/demo/workspaces", params={"include_archived": "true"}) + assert resp.json()["total"] == 4 + assert ids["beta"] in [w["workspace_id"] for w in resp.json()["workspaces"]] + + # q: case-insensitive name substring; total respects the filter. + resp = await client.get("/demo/workspaces", params={"q": "ALPHA"}) + body = resp.json() + assert body["total"] == 1 + assert [w["workspace_id"] for w in body["workspaces"]] == [ids["alpha-match"]] + + # tags: containment -- ALL listed tags must match. + resp = await client.get("/demo/workspaces", params=[("tags", "smoke"), ("tags", "e2")]) + assert [w["workspace_id"] for w in resp.json()["workspaces"]] == [ids["alpha-match"]] + resp = await client.get("/demo/workspaces", params=[("tags", "smoke"), ("tags", "nope")]) + assert resp.json()["total"] == 0 + + # sort_by=name asc: pinned row STILL first, unnamed row sinks (NULLS LAST). + resp = await client.get("/demo/workspaces", params={"sort_by": "name", "sort_order": "asc"}) + names = [w["name"] for w in resp.json()["workspaces"]] + assert names == ["zeta-pinned", "alpha-match", None] + + # Unknown sort_by silently falls back to the default order (no 422). + resp = await client.get("/demo/workspaces", params={"sort_by": "bogus"}) + assert resp.status_code == 200 + assert [w["workspace_id"] for w in resp.json()["workspaces"]] == [ + ids["zeta-pinned"], + unnamed, + ids["alpha-match"], + ] + + +@pytest.mark.integration +async def test_workspace_health_integration_alive_and_dead(client, db_session: AsyncSession): + """A real reference probes alive; a bogus one probes dead (E2, #408).""" + session_resp = await client.post("/agents/sessions", json={"agent_type": "experiment"}) + assert session_resp.status_code == 201 + agent_session_id = session_resp.json()["session_id"] + try: + workspace_id = await workspace.create_workspace( + DemoRunRequest.model_validate({"preservation": "keep", "workspace_name": "e2-health"}) + ) + assert workspace_id is not None + row = await workspace.get_workspace(db_session, workspace_id) + assert row is not None + row.created_objects = { + "agent_session_id": agent_session_id, + "winning_run_id": "run-dangling-never-created", + } + await db_session.commit() + + resp = await client.get(f"/demo/workspaces/{workspace_id}/health") + assert resp.status_code == 200 + body = resp.json() + by_key = {r["key"]: r["status"] for r in body["references"]} + assert by_key["agent_session_id"] == "alive" + assert by_key["winning_run_id"] == "dead" + assert body["alive"] == 1 + assert body["dead"] == 1 + assert body["unknown"] == 0 + # The row was inserted as 'running' (never finalized) -> partial run. + assert body["partial_run"] is True + finally: + await client.delete(f"/agents/sessions/{agent_session_id}") From f26507f7f4e0d6a456f862d5e11125fe0b9b3a2e Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Sat, 13 Jun 2026 01:24:10 +0200 Subject: [PATCH 3/6] feat(ui): add workspace lifecycle types and hooks (#408) --- frontend/src/hooks/use-workspaces.test.ts | 210 +++++++++++++++++++++- frontend/src/hooks/use-workspaces.ts | 124 ++++++++++++- frontend/src/types/api.ts | 53 ++++++ 3 files changed, 377 insertions(+), 10 deletions(-) diff --git a/frontend/src/hooks/use-workspaces.test.ts b/frontend/src/hooks/use-workspaces.test.ts index d804f96b..66825d61 100644 --- a/frontend/src/hooks/use-workspaces.test.ts +++ b/frontend/src/hooks/use-workspaces.test.ts @@ -1,5 +1,5 @@ /** - * Unit tests for the use-workspaces hooks (``useDeleteWorkspace``). + * Unit tests for the use-workspaces hooks. * * Stubs ``fetch`` to assert the hook issues a DELETE to the workspace * endpoint and invalidates the workspaces list on success; no real backend @@ -10,7 +10,13 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { createElement, type ReactNode } from 'react' -import { useDeleteWorkspace } from './use-workspaces' +import { + useDeleteWorkspace, + usePatchWorkspace, + useWorkspaceHealth, + useWorkspaceLineage, + useWorkspaces, +} from './use-workspaces' import { ApiError } from '@/lib/api' function makeWrapper(client: QueryClient) { @@ -88,3 +94,203 @@ describe('useDeleteWorkspace', () => { expect((error as ApiError).message).toContain('Workspace not found') }) }) + +// ============================================================================= +// E2 (#408) — params-aware list + PATCH + health + lineage +// ============================================================================= + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function problemResponse(detail: string, status: number): Response { + return new Response( + JSON.stringify({ type: '/errors/not-found', title: 'Not Found', status, detail }), + { status, headers: { 'content-type': 'application/problem+json' } }, + ) +} + +describe('useWorkspaces (E2 params)', () => { + it('serializes the list params onto the query string', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ workspaces: [], total: 0 })) + vi.stubGlobal('fetch', fetchMock) + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const { result } = renderHook( + () => + useWorkspaces({ + q: 'demo', + tags: 'smoke', + include_archived: true, + sort_by: 'name', + sort_order: 'asc', + }), + { wrapper: makeWrapper(client) }, + ) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + const url = String(fetchMock.mock.calls[0]![0]) + expect(url).toContain('/demo/workspaces') + expect(url).toContain('q=demo') + expect(url).toContain('tags=smoke') + expect(url).toContain('include_archived=true') + expect(url).toContain('sort_by=name') + expect(url).toContain('sort_order=asc') + }) + + it('omits unset params (legacy URL shape preserved)', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ workspaces: [], total: 0 })) + vi.stubGlobal('fetch', fetchMock) + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const { result } = renderHook(() => useWorkspaces(), { wrapper: makeWrapper(client) }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + const url = String(fetchMock.mock.calls[0]![0]) + expect(url).toContain('limit=20') + expect(url).not.toContain('q=') + expect(url).not.toContain('include_archived') + expect(url).not.toContain('sort_by') + }) +}) + +describe('usePatchWorkspace', () => { + it('issues a PATCH with the partial body and invalidates the list', async () => { + const workspaceId = 'a'.repeat(32) + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ workspace_id: workspaceId, pinned: true })) + vi.stubGlobal('fetch', fetchMock) + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const invalidateSpy = vi.spyOn(client, 'invalidateQueries') + const { result } = renderHook(() => usePatchWorkspace(), { + wrapper: makeWrapper(client), + }) + + await act(async () => { + result.current.mutate({ workspaceId, update: { pinned: true } }) + }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + const call = fetchMock.mock.calls[0]! + expect(String(call[0])).toContain(`/demo/workspaces/${workspaceId}`) + const init = call[1] as RequestInit + expect(init.method).toBe('PATCH') + expect(JSON.parse(String(init.body))).toEqual({ pinned: true }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['workspaces'] }) + }) +}) + +describe('useWorkspaceHealth', () => { + it('fetches the health endpoint for the loaded workspace', async () => { + const workspaceId = 'a'.repeat(32) + const health = { + workspace_id: workspaceId, + workspace_status: 'completed', + partial_run: false, + references: [], + alive: 0, + dead: 0, + unknown: 0, + checked_at: '2026-06-13T00:00:00Z', + } + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(health)) + vi.stubGlobal('fetch', fetchMock) + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const { result } = renderHook(() => useWorkspaceHealth(workspaceId), { + wrapper: makeWrapper(client), + }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(String(fetchMock.mock.calls[0]![0])).toContain( + `/demo/workspaces/${workspaceId}/health`, + ) + expect(result.current.data).toEqual(health) + }) + + it('stays disabled without a workspace id', () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + renderHook(() => useWorkspaceHealth(''), { wrapper: makeWrapper(client) }) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +describe('useWorkspaceLineage', () => { + const idA = 'a'.repeat(32) + const idB = 'b'.repeat(32) + const idC = 'c'.repeat(32) + + function detailBody(id: string, name: string | null, parent: string | null) { + return { + workspace_id: id, + name, + replayed_from_workspace_id: parent, + tags: [], + archived: false, + pinned: false, + } + } + + it('walks the chain newest → original and stops at the root', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(detailBody(idA, 'child', idB))) + .mockResolvedValueOnce(jsonResponse(detailBody(idB, 'origin', null))) + vi.stubGlobal('fetch', fetchMock) + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const { result } = renderHook(() => useWorkspaceLineage(idA), { + wrapper: makeWrapper(client), + }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + const lineage = result.current.data! + expect(lineage.entries.map((e) => e.workspace_id)).toEqual([idA, idB]) + expect(lineage.entries.map((e) => e.deleted)).toEqual([false, false]) + expect(lineage.truncated).toBe(false) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('terminates the walk with a deleted sentinel on a 404 ancestor', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(detailBody(idA, 'child', idC))) + .mockResolvedValueOnce(problemResponse(`Workspace not found: ${idC}`, 404)) + vi.stubGlobal('fetch', fetchMock) + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const { result } = renderHook(() => useWorkspaceLineage(idA), { + wrapper: makeWrapper(client), + }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + const lineage = result.current.data! + expect(lineage.entries).toHaveLength(2) + expect(lineage.entries[1]).toMatchObject({ workspace_id: idC, deleted: true, detail: null }) + expect(lineage.truncated).toBe(false) + }) + + it('caps the walk depth and flags truncation', async () => { + // Every row points at another parent — an unbounded chain. + const fetchMock = vi.fn().mockImplementation((url: unknown) => { + const id = String(url).split('/').pop()! + return Promise.resolve(jsonResponse(detailBody(id, null, 'f'.repeat(32)))) + }) + vi.stubGlobal('fetch', fetchMock) + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const { result } = renderHook(() => useWorkspaceLineage(idA), { + wrapper: makeWrapper(client), + }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data!.entries).toHaveLength(5) + expect(result.current.data!.truncated).toBe(true) + }) +}) diff --git a/frontend/src/hooks/use-workspaces.ts b/frontend/src/hooks/use-workspaces.ts index 76fd01bd..610cefb8 100644 --- a/frontend/src/hooks/use-workspaces.ts +++ b/frontend/src/hooks/use-workspaces.ts @@ -1,16 +1,35 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { api } from '@/lib/api' -import type { WorkspaceDetail, WorkspaceListResponse } from '@/types/api' +import { api, ApiError } from '@/lib/api' +import type { + WorkspaceDetail, + WorkspaceHealth, + WorkspaceListParams, + WorkspaceListResponse, + WorkspaceUpdate, +} 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). + * E4 (#393) — list saved showcase workspaces. Server-backed source of truth + * for `preservation="keep"` runs (the localStorage RunHistoryStrip stays + * ephemeral-only). E2 (#408) — params-aware: q name search, single-tag + * filter, include_archived (server default hides archived), allow-listed + * sort_by/sort_order. Pinned rows always order first server-side. */ -export function useWorkspaces(limit = 20, enabled = true) { +export function useWorkspaces(params: WorkspaceListParams = {}, enabled = true) { return useQuery({ - queryKey: ['workspaces', { limit }], - queryFn: () => api('/demo/workspaces', { params: { limit } }), + queryKey: ['workspaces', params], + queryFn: () => + api('/demo/workspaces', { + params: { + limit: params.limit ?? 20, + offset: params.offset, + q: params.q, + tags: params.tags, + include_archived: params.include_archived, + sort_by: params.sort_by, + sort_order: params.sort_order, + }, + }), enabled, }) } @@ -40,3 +59,92 @@ export function useDeleteWorkspace() { }, }) } + +/** + * E2 (#408) — partial lifecycle update (rename / notes / tags / pin / + * archive) through the E1 PATCH endpoint. Only provided fields change. + * Invalidates the blanket ['workspaces'] key so list + detail + lineage + * queries all refetch. + */ +export function usePatchWorkspace() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ workspaceId, update }: { workspaceId: string; update: WorkspaceUpdate }) => + api(`/demo/workspaces/${workspaceId}`, { + method: 'PATCH', + body: update, + }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['workspaces'] }) + }, + }) +} + +/** + * E2 (#408) — soft-reference link health for the LOADED workspace only + * (never probed per list row — the backend fans out one in-process probe + * per reference). staleTime keeps reloads from hammering the probe fan-out. + */ +export function useWorkspaceHealth(workspaceId: string, enabled = true) { + return useQuery({ + queryKey: ['workspaces', workspaceId, 'health'], + queryFn: () => api(`/demo/workspaces/${workspaceId}/health`), + enabled: enabled && !!workspaceId, + staleTime: 30_000, + }) +} + +/** One ancestor entry in a workspace's replay lineage chain (newest first). */ +export interface LineageEntry { + workspace_id: string + name: string | null + /** True when the ancestor row was deleted — dangling pointers are designed. */ + deleted: boolean + detail: WorkspaceDetail | null +} + +export interface WorkspaceLineage { + entries: LineageEntry[] + /** True when the chain continues past the depth cap. */ + truncated: boolean +} + +// A replay-of-a-replay chain deeper than this is pathological; the strip +// renders a trailing ellipsis instead of walking forever. +const LINEAGE_DEPTH_CAP = 5 + +/** + * E2 (#408) — walk the replayed_from_workspace_id chain (newest → original) + * as ONE query of serial fetches. A 404 ancestor terminates the walk with a + * deleted sentinel — dangling lineage is expected, never an error. + */ +export function useWorkspaceLineage(workspaceId: string | null) { + return useQuery({ + queryKey: ['workspaces', workspaceId, 'lineage'], + enabled: !!workspaceId, + queryFn: async (): Promise => { + const entries: LineageEntry[] = [] + let current: string | null = workspaceId + for (let depth = 0; depth < LINEAGE_DEPTH_CAP && current; depth += 1) { + try { + const detail = await api(`/demo/workspaces/${current}`) + entries.push({ + workspace_id: current, + name: detail.name, + deleted: false, + detail, + }) + current = detail.replayed_from_workspace_id + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + entries.push({ workspace_id: current, name: null, deleted: true, detail: null }) + current = null + } else { + throw error + } + } + } + return { entries, truncated: current !== null } + }, + }) +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 1232e991..f64ae24a 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -815,6 +815,11 @@ export interface WorkspaceListItem { skip_seed: boolean result_summary: Record | null created_at: string + // E1 (#407) — lifecycle + provenance fields (consumed by E2 #408). + archived: boolean + pinned: boolean + tags: string[] + replayed_from_workspace_id: string | null } // Full row from GET /demo/workspaces/{workspace_id}. @@ -824,6 +829,9 @@ export interface WorkspaceDetail extends WorkspaceListItem { date_start: string | null date_end: string | null created_objects: Record + // E1 (#407) — operator annotation + schema version. + notes: string | null + config_schema_version: number } // Page shape of GET /demo/workspaces. @@ -832,6 +840,51 @@ export interface WorkspaceListResponse { total: number } +// E2 (#408) — partial-update body for PATCH /demo/workspaces/{workspace_id} +// (E1 endpoint). Absent field = unchanged; explicit null clears name/notes. +export interface WorkspaceUpdate { + name?: string | null + notes?: string | null + tags?: string[] + archived?: boolean + pinned?: boolean +} + +// E2 (#408) — query params for GET /demo/workspaces. Archived rows are +// hidden unless include_archived; unknown sort_by falls back server-side. +export interface WorkspaceListParams { + limit?: number + offset?: number + q?: string + tags?: string + include_archived?: boolean + sort_by?: 'created_at' | 'name' | 'seed' | 'status' + sort_order?: 'asc' | 'desc' +} + +// E2 (#408) — link-health classification of one probed soft reference. +export type RefHealthStatus = 'alive' | 'dead' | 'unknown' + +export interface WorkspaceRefHealth { + key: string + ref_type: 'model_run' | 'scenario_plan' | 'alias' | 'batch' | 'agent_session' | 'job' + ref_id: string + status: RefHealthStatus + probe_path: string +} + +// E2 (#408) — GET /demo/workspaces/{workspace_id}/health response. +export interface WorkspaceHealth { + workspace_id: string + workspace_status: 'running' | 'completed' | 'failed' + partial_run: boolean + references: WorkspaceRefHealth[] + alive: number + dead: number + unknown: number + checked_at: string +} + // === AI Model Configuration (/config) === // Presence + masked preview of one provider API key (never the raw value). From 7012fd08b2eb314aea372d5afbf931c95e3b640d Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Sat, 13 Jun 2026 01:24:10 +0200 Subject: [PATCH 4/6] feat(ui): add safe replay and workspace lifecycle to showcase (#408) --- .../demo/ReplayConfirmDialog.test.tsx | 110 ++++ .../components/demo/ReplayConfirmDialog.tsx | 158 ++++++ .../demo/WorkspaceArtifactsPanel.test.tsx | 86 +++- .../demo/WorkspaceArtifactsPanel.tsx | 88 +++- .../demo/WorkspaceEditDialog.test.tsx | 140 ++++++ .../components/demo/WorkspaceEditDialog.tsx | Bin 0 -> 6206 bytes .../demo/WorkspaceLineageStrip.test.tsx | 89 ++++ .../components/demo/WorkspaceLineageStrip.tsx | 64 +++ .../components/demo/WorkspacePanel.test.tsx | 277 +++++++++-- .../src/components/demo/WorkspacePanel.tsx | 470 +++++++++++++++--- frontend/src/components/demo/index.ts | 5 + .../components/demo/replay-request.test.ts | 39 ++ .../src/components/demo/replay-request.ts | 19 + .../src/components/demo/workspace-name.ts | 8 + frontend/src/pages/showcase.tsx | 72 ++- 15 files changed, 1460 insertions(+), 165 deletions(-) create mode 100644 frontend/src/components/demo/ReplayConfirmDialog.test.tsx create mode 100644 frontend/src/components/demo/ReplayConfirmDialog.tsx create mode 100644 frontend/src/components/demo/WorkspaceEditDialog.test.tsx create mode 100644 frontend/src/components/demo/WorkspaceEditDialog.tsx create mode 100644 frontend/src/components/demo/WorkspaceLineageStrip.test.tsx create mode 100644 frontend/src/components/demo/WorkspaceLineageStrip.tsx create mode 100644 frontend/src/components/demo/replay-request.test.ts create mode 100644 frontend/src/components/demo/replay-request.ts create mode 100644 frontend/src/components/demo/workspace-name.ts diff --git a/frontend/src/components/demo/ReplayConfirmDialog.test.tsx b/frontend/src/components/demo/ReplayConfirmDialog.test.tsx new file mode 100644 index 00000000..8c3d705d --- /dev/null +++ b/frontend/src/components/demo/ReplayConfirmDialog.test.tsx @@ -0,0 +1,110 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { ReplayConfirmDialog } from './ReplayConfirmDialog' +import { buildReplayRequest } from './replay-request' +import type { WorkspaceListItem } from '@/types/api' + +beforeAll(() => { + class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} + } + vi.stubGlobal('ResizeObserver', ResizeObserverStub) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +const baseItem: WorkspaceListItem = { + workspace_id: 'a'.repeat(32), + name: 'replay-me', + status: 'completed', + seed: 7, + scenario: 'demo_minimal', + reset: false, + skip_seed: true, + result_summary: null, + created_at: '2026-06-01T12:00:00Z', + archived: false, + pinned: false, + tags: [], + replayed_from_workspace_id: null, +} + +function renderDialog(workspace: WorkspaceListItem | null, handlers = {}) { + const onConfirm = vi.fn() + const onCancel = vi.fn() + render( + , + ) + return { onConfirm, onCancel } +} + +describe('ReplayConfirmDialog', () => { + it('renders nothing while no replay is pending', () => { + renderDialog(null) + expect(document.body.textContent).not.toContain('Replay workspace') + }) + + it('renders the recorded-vs-sent preview values', () => { + renderDialog(baseItem) + const copy = document.body.textContent ?? '' + expect(copy).toContain('Replay workspace “replay-me”?') + expect(copy).toContain('seed') + expect(copy).toContain('7') + expect(copy).toContain('demo_minimal') + expect(copy).toContain('keep') + // replayed_from points at the source row on both columns. + expect(copy).toContain(baseItem.workspace_id) + // The verbatim-replay hint for operators who want a different config. + expect(copy).toContain('Use Load instead') + }) + + it('uses a plain confirm label on a non-destructive replay', () => { + renderDialog(baseItem) + const action = screen.getByTestId('replay-confirm') + expect(action.textContent).toBe('Replay') + expect(document.body.textContent).not.toContain('WIPES the database') + }) + + it('escalates to destructive copy + label when reset=true', () => { + renderDialog({ ...baseItem, reset: true }) + expect(document.body.textContent).toContain('WIPES the database') + const action = screen.getByTestId('replay-confirm') + expect(action.textContent).toBe('Replay & wipe database') + expect(action.className).toContain('bg-destructive') + }) + + it('confirm fires onConfirm once; cancel fires onCancel and never confirms', () => { + const { onConfirm } = renderDialog(baseItem) + fireEvent.click(screen.getByTestId('replay-confirm')) + expect(onConfirm).toHaveBeenCalledTimes(1) + cleanup() + const second = renderDialog(baseItem) + fireEvent.click(screen.getByText('Cancel')) + expect(second.onCancel).toHaveBeenCalledTimes(1) + expect(second.onConfirm).not.toHaveBeenCalled() + }) + + it('highlights a mismatching row (defensive — verbatim replays match)', () => { + render( + , + ) + const mismatched = document.querySelector('td.font-semibold.text-destructive') + expect(mismatched?.textContent).toBe('99') + }) +}) diff --git a/frontend/src/components/demo/ReplayConfirmDialog.tsx b/frontend/src/components/demo/ReplayConfirmDialog.tsx new file mode 100644 index 00000000..8395f446 --- /dev/null +++ b/frontend/src/components/demo/ReplayConfirmDialog.tsx @@ -0,0 +1,158 @@ +/** + * E2 (#408) — replay confirmation dialog with a recorded-vs-sent preview. + * + * Every panel Replay goes through this dialog (no code path starts a replay + * without it). The body renders a Field / Recorded / Will-send table; rows + * where the two values differ are highlighted (defensive — a verbatim replay + * normally matches). A reset=true workspace escalates: destructive warning + * copy + a destructive-styled confirm button ("Replay & wipe database"). + * + * Replay policy stays verbatim by design — operators who want a different + * config use Load (which repopulates every control) and Run instead. + */ + +import { AlertTriangle } from 'lucide-react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { cn } from '@/lib/utils' +import type { DemoRunRequest, WorkspaceListItem } from '@/types/api' + +interface ReplayConfirmDialogProps { + /** The workspace pending replay — null keeps the dialog closed. */ + workspace: WorkspaceListItem | null + /** The exact request the confirmed replay will send (single source). */ + requestPreview: DemoRunRequest | null + onConfirm: () => void + onCancel: () => void +} + +function fmt(value: unknown): string { + if (value === undefined || value === null || value === '') return '—' + return String(value) +} + +interface PreviewRow { + field: string + recorded: unknown + willSend: unknown +} + +function buildRows(ws: WorkspaceListItem, req: DemoRunRequest): PreviewRow[] { + return [ + { field: 'seed', recorded: ws.seed, willSend: req.seed }, + { field: 'scenario', recorded: ws.scenario, willSend: req.scenario }, + { field: 'reset', recorded: ws.reset, willSend: req.reset }, + { field: 'skip_seed', recorded: ws.skip_seed, willSend: req.skip_seed }, + { field: 'name', recorded: ws.name, willSend: req.workspace_name ?? null }, + { field: 'preservation', recorded: 'keep', willSend: req.preservation }, + { + field: 'replayed_from', + recorded: ws.workspace_id, + willSend: req.replayed_from_workspace_id, + }, + ] +} + +export function ReplayConfirmDialog({ + workspace, + requestPreview, + onConfirm, + onCancel, +}: ReplayConfirmDialogProps) { + const rows = + workspace && requestPreview ? buildRows(workspace, requestPreview) : [] + const destructive = workspace?.reset === true + const label = workspace?.name ?? workspace?.workspace_id.slice(0, 8) ?? '' + + return ( + { + if (!open) onCancel() + }} + > + + + Replay workspace “{label}”? + + The recorded config is re-submitted verbatim as a new kept run — + the original workspace row is never changed. + + + + {destructive && ( +
+ + + Replaying this workspace WIPES the database and + reseeds it from scratch. + +
+ )} + + + + + Field + Recorded + Will send + + + + {rows.map((row) => { + const mismatch = fmt(row.recorded) !== fmt(row.willSend) + return ( + + {row.field} + {fmt(row.recorded)} + + {fmt(row.willSend)} + + + ) + })} + +
+ +

+ Want to change the config first? Use Load instead. +

+ + + Cancel + + {destructive ? 'Replay & wipe database' : 'Replay'} + + +
+
+ ) +} diff --git a/frontend/src/components/demo/WorkspaceArtifactsPanel.test.tsx b/frontend/src/components/demo/WorkspaceArtifactsPanel.test.tsx index 8d1e60ce..8a6d0549 100644 --- a/frontend/src/components/demo/WorkspaceArtifactsPanel.test.tsx +++ b/frontend/src/components/demo/WorkspaceArtifactsPanel.test.tsx @@ -1,8 +1,8 @@ -import { cleanup, render } from '@testing-library/react' +import { cleanup, render, screen } 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' +import type { WorkspaceDetail, WorkspaceHealth } from '@/types/api' afterEach(() => cleanup()) @@ -28,12 +28,18 @@ const fullWorkspace: WorkspaceDetail = { agent_session_id: 'sess-1', scenario_plan_ids: ['sp-1', 'sp-2'], }, + archived: false, + pinned: false, + tags: [], + replayed_from_workspace_id: null, + notes: null, + config_schema_version: 1, } -function renderPanel(workspace: WorkspaceDetail) { +function renderPanel(workspace: WorkspaceDetail, health: WorkspaceHealth | null = null) { return render( - + , ) } @@ -90,3 +96,75 @@ describe('WorkspaceArtifactsPanel', () => { expect(hrefs).toContain('/visualize/forecast?store_id=3&product_id=7') }) }) + +// ============================================================================= +// E2 (#408) — link-health markers + summary chip +// ============================================================================= + +const baseHealth: WorkspaceHealth = { + workspace_id: fullWorkspace.workspace_id, + workspace_status: 'completed', + partial_run: false, + references: [], + alive: 0, + dead: 0, + unknown: 0, + checked_at: '2026-06-13T00:00:00Z', +} + +describe('WorkspaceArtifactsPanel — health', () => { + it('renders the summary chip with alive/dead counts', () => { + const health: WorkspaceHealth = { ...baseHealth, alive: 5, dead: 2 } + renderPanel(fullWorkspace, health) + const chip = screen.getByTestId('workspace-health-summary') + expect(chip.textContent).toContain('5 live') + expect(chip.textContent).toContain('2 dead') + }) + + it('hides the dead count at zero and the chip without health data', () => { + const { container, unmount } = renderPanel(fullWorkspace, { ...baseHealth, alive: 3 }) + expect(container.textContent).toContain('3 live') + expect(container.textContent).not.toContain('dead') + unmount() + renderPanel(fullWorkspace, null) + expect(screen.queryByTestId('workspace-health-summary')).toBeNull() + }) + + it('marks a card whose reference probed dead — unknown gets no marker', () => { + const health: WorkspaceHealth = { + ...baseHealth, + alive: 4, + dead: 1, + unknown: 1, + references: [ + { + key: 'scenario_plan_ids[0]', + ref_type: 'scenario_plan', + ref_id: 'sp-1', + status: 'dead', + probe_path: '/scenarios/sp-1', + }, + { + key: 'batch_id', + ref_type: 'batch', + ref_id: 'batch-1', + status: 'unknown', + probe_path: '/batch/batch-1', + }, + ], + } + renderPanel(fullWorkspace, health) + expect(screen.getByTestId('dead-link-sp-1')).toBeTruthy() + expect(screen.queryByTestId('dead-link-batch-1')).toBeNull() + }) + + it('renders the partial-run badge for a never-completed row', () => { + const health: WorkspaceHealth = { + ...baseHealth, + workspace_status: 'failed', + partial_run: true, + } + const { container } = renderPanel({ ...fullWorkspace, status: 'failed' }, health) + expect(container.textContent).toContain('partial run') + }) +}) diff --git a/frontend/src/components/demo/WorkspaceArtifactsPanel.tsx b/frontend/src/components/demo/WorkspaceArtifactsPanel.tsx index 255d62fa..0246f7da 100644 --- a/frontend/src/components/demo/WorkspaceArtifactsPanel.tsx +++ b/frontend/src/components/demo/WorkspaceArtifactsPanel.tsx @@ -4,23 +4,33 @@ * 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. + * + * E2 (#408) — health-aware: cards whose soft reference probed `dead` carry a + * warning marker, and a summary chip row shows alive/dead counts plus a + * partial-run warning for rows whose pipeline never completed. `unknown` + * references render without a marker (no false alarms on transient 5xx). */ import { Link } from 'react-router-dom' -import { ArrowUpRight } from 'lucide-react' +import { AlertTriangle, ArrowUpRight } from 'lucide-react' +import { Badge } from '@/components/ui/badge' import { Card, CardContent } from '@/components/ui/card' import { ROUTES } from '@/lib/constants' -import type { WorkspaceDetail } from '@/types/api' +import type { WorkspaceDetail, WorkspaceHealth } from '@/types/api' interface ArtifactCard { label: string blurb: string href: string | null disabledReason?: string + /** E2 (#408) — the soft-reference id backing this card, when probeable. */ + refId?: string } interface WorkspaceArtifactsPanelProps { workspace: WorkspaceDetail + /** E2 (#408) — link-health result; undefined while loading / not probed. */ + health?: WorkspaceHealth | null } function asString(value: unknown): string | null { @@ -46,18 +56,21 @@ function buildCards(ws: WorkspaceDetail): ArtifactCard[] { blurb: 'Registry detail for the run this workspace promoted.', href: winningRunId ? `${ROUTES.EXPLORER.RUNS}/${winningRunId}` : null, disabledReason: 'The run never registered a winner.', + refId: winningRunId ?? undefined, }) 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).', + refId: v2RunId ?? undefined, }) 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}`, + refId: planId, }) }) if (planIds.length === 0) { @@ -73,12 +86,14 @@ function buildCards(ws: WorkspaceDetail): ArtifactCard[] { 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).', + refId: batchId ?? undefined, }) 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.', + refId: alias ?? undefined, }) cards.push({ label: 'Forecast on grain', @@ -101,22 +116,53 @@ function buildCards(ws: WorkspaceDetail): ArtifactCard[] { 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).', + refId: sessionId ?? undefined, }) return cards } -export function WorkspaceArtifactsPanel({ workspace }: WorkspaceArtifactsPanelProps) { +const DEAD_LINK_TOOLTIP = 'This object no longer exists — it was deleted after the run.' + +export function WorkspaceArtifactsPanel({ workspace, health }: WorkspaceArtifactsPanelProps) { const cards = buildCards(workspace) + // E2 (#408) — ref_id -> status lookup; only `dead` produces a marker. + const deadRefIds = new Set( + (health?.references ?? []) + .filter((ref) => ref.status === 'dead') + .map((ref) => ref.ref_id) + ) return ( -

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

+
+

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

+ {health && ( +
+ ✓ {health.alive} live + {health.dead > 0 && ( + ✕ {health.dead} dead + )} + {health.partial_run && ( + + partial run + + )} +
+ )} +

Everything this kept run created, re-attached from its workspace row. Cards greyed out when the run did not record the matching object. @@ -124,26 +170,38 @@ export function WorkspaceArtifactsPanel({ workspace }: WorkspaceArtifactsPanelPr

{cards.map((card) => { const isActive = typeof card.href === 'string' && card.href.length > 0 + const isDead = card.refId !== undefined && deadRefIds.has(card.refId) + const cardTitle = ( +
+ + {card.label} + {isDead && ( + + )} + + {isActive && } +
+ ) return (
{isActive ? ( -
- {card.label} - -
+ {cardTitle}

{card.blurb}

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

{card.blurb}

)} diff --git a/frontend/src/components/demo/WorkspaceEditDialog.test.tsx b/frontend/src/components/demo/WorkspaceEditDialog.test.tsx new file mode 100644 index 00000000..ca73e36b --- /dev/null +++ b/frontend/src/components/demo/WorkspaceEditDialog.test.tsx @@ -0,0 +1,140 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { WorkspaceEditDialog } from './WorkspaceEditDialog' +import type { WorkspaceDetail, WorkspaceListItem } from '@/types/api' + +beforeAll(() => { + class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} + } + vi.stubGlobal('ResizeObserver', ResizeObserverStub) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +const baseItem: WorkspaceListItem = { + workspace_id: 'a'.repeat(32), + name: 'edit-me', + status: 'completed', + seed: 7, + scenario: 'demo_minimal', + reset: false, + skip_seed: true, + result_summary: null, + created_at: '2026-06-01T12:00:00Z', + archived: false, + pinned: false, + tags: ['smoke'], + replayed_from_workspace_id: null, +} + +let mockDetail: { + data: Partial | undefined + isSuccess: boolean + isError: boolean +} = { data: undefined, isSuccess: false, isError: false } + +let mockPatchResult: { mutate: ReturnType; isPending: boolean } = { + mutate: vi.fn(), + isPending: false, +} + +vi.mock('@/hooks/use-workspaces', () => ({ + useWorkspace: () => mockDetail, + usePatchWorkspace: () => mockPatchResult, +})) + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +beforeEach(() => { + mockDetail = { + data: { ...baseItem, notes: 'old notes' }, + isSuccess: true, + isError: false, + } + mockPatchResult = { mutate: vi.fn(), isPending: false } +}) + +function renderDialog(workspace: WorkspaceListItem | null = baseItem) { + const onClose = vi.fn() + render() + return { onClose } +} + +describe('WorkspaceEditDialog', () => { + it('renders nothing when closed', () => { + renderDialog(null) + expect(document.body.textContent).not.toContain('Edit workspace details') + }) + + it('primes the form from the row + detail notes', () => { + renderDialog() + expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe('edit-me') + expect((screen.getByLabelText('Notes') as HTMLTextAreaElement).value).toBe('old notes') + expect( + (screen.getByLabelText(/Tags/) as HTMLInputElement).value, + ).toBe('smoke') + }) + + it('disables Save with an inline hint on a pattern violation', () => { + renderDialog() + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Bad Name!' } }) + expect(document.body.textContent).toContain('Lowercase letters/digits only') + expect((screen.getByTestId('workspace-edit-save') as HTMLButtonElement).disabled).toBe(true) + expect(mockPatchResult.mutate).not.toHaveBeenCalled() + }) + + it('sends ONLY dirty fields (partial-update semantics)', () => { + renderDialog() + fireEvent.change(screen.getByLabelText(/Tags/), { target: { value: 'smoke, e2' } }) + fireEvent.click(screen.getByTestId('workspace-edit-save')) + expect(mockPatchResult.mutate).toHaveBeenCalledTimes(1) + const [payload] = mockPatchResult.mutate.mock.calls[0] as [ + { workspaceId: string; update: Record }, + unknown, + ] + expect(payload.workspaceId).toBe(baseItem.workspace_id) + expect(payload.update).toEqual({ tags: ['smoke', 'e2'] }) + }) + + it('clearing the name sends an explicit null', () => { + renderDialog() + fireEvent.change(screen.getByLabelText('Name'), { target: { value: '' } }) + fireEvent.click(screen.getByTestId('workspace-edit-save')) + const [payload] = mockPatchResult.mutate.mock.calls[0] as [ + { update: Record }, + unknown, + ] + expect(payload.update).toEqual({ name: null }) + }) + + it('a clean save (no changes) just closes without a mutation', () => { + const { onClose } = renderDialog() + fireEvent.click(screen.getByTestId('workspace-edit-save')) + expect(mockPatchResult.mutate).not.toHaveBeenCalled() + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('success toasts and closes; failure toasts an error', () => { + const { onClose } = renderDialog() + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'renamed' } }) + fireEvent.click(screen.getByTestId('workspace-edit-save')) + const [, options] = mockPatchResult.mutate.mock.calls[0] as [ + unknown, + { onSuccess: () => void; onError: (error: unknown) => void }, + ] + options.onSuccess() + expect(toast.success).toHaveBeenCalledWith('Workspace updated.') + expect(onClose).toHaveBeenCalled() + options.onError(new Error('boom')) + expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Update failed')) + }) +}) diff --git a/frontend/src/components/demo/WorkspaceEditDialog.tsx b/frontend/src/components/demo/WorkspaceEditDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..82367182f07cbf1ee74e0bb5814877cb73a58109 GIT binary patch literal 6206 zcmc&&?`|Wv5$|U|#n>&Pl_9NsX+9J_$?nDH;EZ1GjI-0A2!acxrR0rwmy0Fkv#T?Z zhv+l(!SW>i4awzlWw}NAp*4`4mYjc_8UAKClf%P3I;8V2Df#_ZM}Hd9ufP0*8d-@_ z(u68yq{hEStTf3iH>#l}{u89doy_QyYHoEc7BYnpmh+8jO3HFks1+saeByqNSP;D` zWMf|2y&r5D4EJbDVg|?=Pxh1kpG-FZgYH=f~mCS}OX;DbgIhCLbr3prM`#4?;?lq#$(l_Ie=3T$;D zjU2X4ayoNJrc4@<7h}c8wKk7PD{Gy1Vk5OG?hpj_Rii!)wC7=ia-(Ijd};R(mXb=f zu8gKz`2h1^PZuCk)j~SKRwdkKOd&PvvtH8j%CI~0DrfKJf0vD>gySZcOSv$5NOhAV zE7%Yh6F$#mZ!a%v)fn2)x|Q)r%SM$nXrx$}K}TSe(1zMwt*S~k(e~FOTgkBf=LtJb zRfxHsAeH948&j{1QPuP0cDtkBBXYSn3ct=&Wgxv~_r&uKhC#Dh3o3bVNRzP+6z4o&!q zMBONL3+HM%4mVjxf>zR;H;rn(m0F8lXo`G35p^DD%|k7b0YS&xT$>9c%Tc%Vox85x z!I?+*q(H#?4}ZD3e06sI@!hL$&p*Docy|pE{YAjV_4$W);TY*87(eDaJwQq#u+0*< z)TuuQdbtF+Y*ejjgEW{N9?~_^Iq=h5GCDBQ;Q}!fG7jKANpNuD`&+pvl$Ke_=}#T^ zai^_4KYg|mK&7fPW;&*1NV5~VQ+c-cxVN_iJaN7eKt)T&o1_t+j!7HFsbTx$haauM zjWp|~B2YjgQL2GfCK-$dL-$>ZI!OT5Rt5e|A*D=)es`G{a7&+{kk!iE&?u!<{f_g=%_8QCBeLuY3THn2G(sYBbU0riel~A!)H&3gYIo!csgR`AX-5^&Q3j3 zr$3|AA#gN6aDw$5tx{%eIxJ^1eEAUG5!8*h6B+TU(o*U`;y{(CW%-g z#q)JuWd4HAkfBio+tm{rlGe|z_U<5~%_EE@%9j-DA~tV#2wbe0#|d@e=^t+94tMIV z<8gd&tF6;%17yALRK@{&s>!xD@N@_^ZENbbB1JA`Dj+Y%8iD98Z%74q=hzJ`osaqN z219Gw&V<0`6DwIHr-N4)+2P^9`3>v9lQuwTd9p%Yp-Tg+gB-re?Y3jVRup-Aja=m3 z&wobAPtix{#E}kDj}+{nqs~ST0-l%9Ix<2?exU^YMSMD=zE3M3@ft(+pdov#$O$@6hzO_yohK(RfxGPVp-gKjCF}8L1o4O zu6$h{(2XezRF2s}h~b#)sV&Vtc|>fHHU0AZ+QbdFN%yx_J5dk6Ba<*Z( zbRK2-{D*wCuYuVnkv59HR3v~}(I8N%WgYgp>H@vQjVjRi%?^}p5JfQ}nXXcrWAApn zZ1PMz9Q2GR#r<1bUT2#ZM@M~$c!r2^?vUKs>0v!4o5ZfLhiS1NFh8xIgyJ4e3Wp>E z(l=}L6?gLBWV2NcxTkuYPU@3R(-iNtyAuWv_xHca2L|2kgy6Mc#_o)0JK~6JcT!mS zJ*qY<0^D~l;DhhXjX?VwGGy6EIp#>K4u7bN4?A87fu;;cX>F&&};xTMKtzy=5WqiT^kL)QFl^q8`7X+Kl_q z%)C7ny@x5lQJG61)=De?-i(Q_#CKm(ILS)Wd((XrtTgRzo-&AI6qy3}}l(Y+#0du^h7 z(T!+-KQtcQyU#idk6=)*zK81Zd&b$pN z1IcM>*Em% { + cleanup() + vi.clearAllMocks() +}) + +let mockLineage: { data: WorkspaceLineage | undefined } = { data: undefined } + +vi.mock('@/hooks/use-workspaces', () => ({ + useWorkspaceLineage: () => mockLineage, +})) + +const detailOf = (id: string, name: string | null): WorkspaceDetail => + ({ workspace_id: id, name }) as WorkspaceDetail + +function renderStrip(onLoadAncestor = vi.fn()) { + render() + return onLoadAncestor +} + +describe('WorkspaceLineageStrip', () => { + it('renders nothing when the workspace has no lineage', () => { + mockLineage = { + data: { + entries: [ + { workspace_id: 'a'.repeat(32), name: 'solo', deleted: false, detail: detailOf('a'.repeat(32), 'solo') }, + ], + truncated: false, + }, + } + renderStrip() + expect(screen.queryByTestId('workspace-lineage')).toBeNull() + }) + + it('renders the chain newest → original with clickable ancestors', () => { + const parentDetail = detailOf('b'.repeat(32), 'parent') + mockLineage = { + data: { + entries: [ + { workspace_id: 'a'.repeat(32), name: 'child', deleted: false, detail: detailOf('a'.repeat(32), 'child') }, + { workspace_id: 'b'.repeat(32), name: 'parent', deleted: false, detail: parentDetail }, + { workspace_id: 'c'.repeat(32), name: 'origin', deleted: false, detail: detailOf('c'.repeat(32), 'origin') }, + ], + truncated: false, + }, + } + const onLoadAncestor = renderStrip() + const strip = screen.getByTestId('workspace-lineage') + const text = strip.textContent ?? '' + // Order: current first, then parents. + expect(text.indexOf('child')).toBeLessThan(text.indexOf('parent')) + expect(text.indexOf('parent')).toBeLessThan(text.indexOf('origin')) + fireEvent.click(screen.getByText('parent')) + expect(onLoadAncestor).toHaveBeenCalledWith(parentDetail) + }) + + it('renders the deleted-ancestor sentinel without erroring', () => { + mockLineage = { + data: { + entries: [ + { workspace_id: 'a'.repeat(32), name: 'child', deleted: false, detail: detailOf('a'.repeat(32), 'child') }, + { workspace_id: 'b'.repeat(32), name: null, deleted: true, detail: null }, + ], + truncated: false, + }, + } + renderStrip() + expect(screen.getByTestId('workspace-lineage').textContent).toContain('(original deleted)') + }) + + it('renders a trailing ellipsis when the chain is depth-capped', () => { + mockLineage = { + data: { + entries: [ + { workspace_id: 'a'.repeat(32), name: 'child', deleted: false, detail: detailOf('a'.repeat(32), 'child') }, + { workspace_id: 'b'.repeat(32), name: 'parent', deleted: false, detail: detailOf('b'.repeat(32), 'parent') }, + ], + truncated: true, + }, + } + renderStrip() + expect(screen.getByTestId('workspace-lineage').textContent).toContain('…') + }) +}) diff --git a/frontend/src/components/demo/WorkspaceLineageStrip.tsx b/frontend/src/components/demo/WorkspaceLineageStrip.tsx new file mode 100644 index 00000000..c405fdcc --- /dev/null +++ b/frontend/src/components/demo/WorkspaceLineageStrip.tsx @@ -0,0 +1,64 @@ +/** + * E2 (#408) — replay lineage breadcrumb for the loaded workspace. + * + * Renders the replayed_from_workspace_id chain newest → original: + * `this ← parent ← grandparent …` (depth-capped). Ancestors are clickable + * (loads them); a deleted ancestor renders as "(original deleted)" — dangling + * soft references are designed, never an error. Renders nothing when the + * loaded workspace is not a replay. + */ + +import { Fragment } from 'react' +import { Button } from '@/components/ui/button' +import { useWorkspaceLineage } from '@/hooks/use-workspaces' +import type { WorkspaceDetail } from '@/types/api' + +interface WorkspaceLineageStripProps { + workspaceId: string + /** Load an ancestor into the page (full detail — the walk already has it). */ + onLoadAncestor: (ws: WorkspaceDetail) => void +} + +function labelOf(workspaceId: string, name: string | null): string { + return name ?? workspaceId.slice(0, 8) +} + +export function WorkspaceLineageStrip({ workspaceId, onLoadAncestor }: WorkspaceLineageStripProps) { + const { data } = useWorkspaceLineage(workspaceId) + const entries = data?.entries ?? [] + + // No lineage to show: still walking, or the loaded row is not a replay. + if (entries.length < 2) return null + + return ( +
+ Replay lineage: + {entries.map((entry, index) => ( + + {index > 0 && } + {entry.deleted ? ( + (original deleted) + ) : index === 0 ? ( + // The loaded workspace itself — not a link. + + {labelOf(entry.workspace_id, entry.name)} + + ) : ( + + )} + + ))} + {data?.truncated && } +
+ ) +} diff --git a/frontend/src/components/demo/WorkspacePanel.test.tsx b/frontend/src/components/demo/WorkspacePanel.test.tsx index 843415f0..75bf0f56 100644 --- a/frontend/src/components/demo/WorkspacePanel.test.tsx +++ b/frontend/src/components/demo/WorkspacePanel.test.tsx @@ -1,13 +1,14 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { toast } from 'sonner' import { WorkspacePanel } from './WorkspacePanel' import { ApiError } from '@/lib/api' -import type { WorkspaceListItem, WorkspaceListResponse } from '@/types/api' +import type { WorkspaceListItem, WorkspaceListParams, WorkspaceListResponse } from '@/types/api' beforeAll(() => { - // Radix AlertDialog needs these in jsdom (pattern: cancel-run-dialog.test.tsx). + // Radix AlertDialog/DropdownMenu need these in jsdom. class ResizeObserverStub { observe() {} unobserve() {} @@ -17,6 +18,9 @@ beforeAll(() => { if (!Element.prototype.hasPointerCapture) { Element.prototype.hasPointerCapture = () => false } + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {} + } }) afterEach(() => { @@ -34,6 +38,16 @@ const baseItem: WorkspaceListItem = { skip_seed: true, result_summary: { winner_model_type: 'seasonal_naive' }, created_at: '2026-06-01T12:00:00Z', + archived: false, + pinned: false, + tags: [], + replayed_from_workspace_id: null, +} + +const secondItem: WorkspaceListItem = { + ...baseItem, + workspace_id: 'b'.repeat(32), + name: 'second', } let mockResponse: { data: WorkspaceListResponse | undefined; isLoading: boolean } = { @@ -41,35 +55,71 @@ let mockResponse: { data: WorkspaceListResponse | undefined; isLoading: boolean isLoading: false, } -let mockDeleteResult: { mutate: ReturnType; isPending: boolean } = { +let lastListParams: WorkspaceListParams | undefined + +let mockDeleteResult: { + mutate: ReturnType + mutateAsync: ReturnType + isPending: boolean +} = { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false } + +let mockPatchResult: { mutate: ReturnType; isPending: boolean } = { mutate: vi.fn(), isPending: false, } +const mockNavigate = vi.fn() + vi.mock('@/hooks/use-workspaces', () => ({ - useWorkspaces: () => mockResponse, + useWorkspaces: (params: WorkspaceListParams) => { + lastListParams = params + return mockResponse + }, + // WorkspaceEditDialog dependencies (mounted closed by the panel). + useWorkspace: () => ({ data: undefined, isSuccess: false, isError: false }), useDeleteWorkspace: () => mockDeleteResult, + usePatchWorkspace: () => mockPatchResult, })) +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() }, })) +beforeEach(() => { + lastListParams = undefined + mockDeleteResult = { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false } + mockPatchResult = { mutate: vi.fn(), isPending: false } +}) + function renderPanel(props: Partial[0]> = {}) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) return render( - {}} - onReplay={() => {}} - isRunning={false} - lastWorkspaceId={null} - {...props} - /> + + {}} + onRequestReplay={() => {}} + isRunning={false} + lastWorkspaceId={null} + {...props} + /> + , ) } +/** Open a Radix dropdown/select (pattern: model-family-tabs.test.tsx). */ +function radixOpen(target: HTMLElement) { + fireEvent.pointerDown(target, { button: 0, ctrlKey: false }) + fireEvent.mouseDown(target, { button: 0 }) + fireEvent.click(target) +} + describe('WorkspacePanel', () => { it('renders the discoverable empty state (panel never hidden)', () => { mockResponse = { data: { workspaces: [], total: 0 }, isLoading: false } @@ -86,7 +136,6 @@ describe('WorkspacePanel', () => { 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') }) @@ -99,57 +148,192 @@ describe('WorkspacePanel', () => { 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', () => { + it('invokes onLoad / onRequestReplay with the list item — replay never starts here', () => { mockResponse = { data: { workspaces: [baseItem], total: 1 }, isLoading: false } const onLoad = vi.fn() - const onReplay = vi.fn() - const { container } = renderPanel({ onLoad, onReplay }) + const onRequestReplay = vi.fn() + const { container } = renderPanel({ onLoad, onRequestReplay }) 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) + expect(onRequestReplay).toHaveBeenCalledWith(baseItem) }) - it('disables both actions while a run is in flight', () => { + it('disables row 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) + renderPanel({ isRunning: true }) + const labels = ['Load', 'Replay'] + for (const label of labels) { + const button = screen + .getAllByRole('button') + .find((b) => (b.textContent ?? '').includes(label))! as HTMLButtonElement + expect(button.disabled).toBe(true) + } }) }) -describe('WorkspacePanel — delete', () => { - function openDeleteDialog() { +describe('WorkspacePanel — E2 lifecycle badges + toolbar params', () => { + it('renders pinned / archived / replay badges', () => { + mockResponse = { + data: { + workspaces: [ + { + ...baseItem, + pinned: true, + archived: true, + replayed_from_workspace_id: 'c'.repeat(32), + }, + ], + total: 1, + }, + isLoading: false, + } + const { container } = renderPanel() + expect(container.textContent).toContain('archived') + expect(container.textContent).toContain('replay') + expect(screen.getByLabelText('Unpin e4-panel')).toBeTruthy() + }) + + it('flows the debounced search into the q list param (min 2 chars)', async () => { + mockResponse = { data: { workspaces: [baseItem], total: 1 }, isLoading: false } + renderPanel() + fireEvent.change(screen.getByLabelText('Search workspaces by name'), { + target: { value: 'demo' }, + }) + await waitFor(() => expect(lastListParams?.q).toBe('demo')) + }) + + it('flows the show-archived toggle into include_archived', () => { mockResponse = { data: { workspaces: [baseItem], total: 1 }, isLoading: false } - mockDeleteResult = { mutate: vi.fn(), isPending: false } + const { container } = renderPanel() + expect(lastListParams?.include_archived).toBeUndefined() + const checkbox = Array.from(container.querySelectorAll('button[role="checkbox"]')).find( + (el) => el.parentElement?.textContent?.includes('Show archived'), + )! + fireEvent.click(checkbox) + expect(lastListParams?.include_archived).toBe(true) + }) + + it('flows the sort select into sort_by/sort_order', () => { + mockResponse = { data: { workspaces: [baseItem], total: 1 }, isLoading: false } + renderPanel() + radixOpen(screen.getByLabelText('Sort workspaces')) + fireEvent.click(screen.getByText('Name')) + expect(lastListParams?.sort_by).toBe('name') + expect(lastListParams?.sort_order).toBe('asc') + }) + + it('clicking a tag chip filters by that tag; the toolbar chip clears it', () => { + mockResponse = { + data: { workspaces: [{ ...baseItem, tags: ['smoke'] }], total: 1 }, + isLoading: false, + } + renderPanel() + fireEvent.click(screen.getByLabelText('Filter by tag smoke')) + expect(lastListParams?.tags).toBe('smoke') + fireEvent.click(screen.getByLabelText('Clear tag filter smoke')) + expect(lastListParams?.tags).toBeUndefined() + }) + + it('pin toggle fires the PATCH mutation', () => { + mockResponse = { data: { workspaces: [baseItem], total: 1 }, isLoading: false } + renderPanel() + fireEvent.click(screen.getByLabelText('Pin e4-panel')) + expect(mockPatchResult.mutate).toHaveBeenCalledWith( + { workspaceId: baseItem.workspace_id, update: { pinned: true } }, + expect.anything(), + ) + }) + + it('archive action in the dropdown fires the PATCH mutation', () => { + mockResponse = { data: { workspaces: [baseItem], total: 1 }, isLoading: false } + renderPanel() + radixOpen(screen.getByLabelText('More actions for e4-panel')) + fireEvent.click(screen.getByText('Archive')) + expect(mockPatchResult.mutate).toHaveBeenCalledWith( + { workspaceId: baseItem.workspace_id, update: { archived: true } }, + expect.anything(), + ) + }) +}) + +describe('WorkspacePanel — multi-select', () => { + function selectBoth() { + mockResponse = { data: { workspaces: [baseItem, secondItem], total: 2 }, isLoading: false } const result = renderPanel({ onDeleted: vi.fn() }) - fireEvent.click(screen.getByLabelText('Delete workspace e4-panel')) + fireEvent.click(screen.getByLabelText('Select workspace e4-panel')) + fireEvent.click(screen.getByLabelText('Select workspace second')) return result } - it('renders a Delete action for each saved workspace row', () => { - mockResponse = { data: { workspaces: [baseItem], total: 1 }, isLoading: false } - const { container } = renderPanel() - const buttons = Array.from(container.querySelectorAll('button')) - expect(buttons.some((b) => (b.textContent ?? '').includes('Delete'))).toBe(true) + it('shows the selection footer with the count', () => { + const { container } = selectBoth() + expect(container.textContent).toContain('2 selected') + }) + + it('Compare is enabled only at exactly two selections', () => { + mockResponse = { data: { workspaces: [baseItem, secondItem], total: 2 }, isLoading: false } + renderPanel() + fireEvent.click(screen.getByLabelText('Select workspace e4-panel')) + const compare = () => + screen + .getAllByRole('button') + .find((b) => (b.textContent ?? '') === 'Compare')! as HTMLButtonElement + expect(compare().disabled).toBe(true) + fireEvent.click(screen.getByLabelText('Select workspace second')) + expect(compare().disabled).toBe(false) + fireEvent.click(compare()) + expect(mockNavigate).toHaveBeenCalledWith( + `/showcase/compare?a=${baseItem.workspace_id}&b=${secondItem.workspace_id}`, + ) + }) + + it('delete-selected confirms once then issues N sequential single deletes', async () => { + mockDeleteResult.mutateAsync.mockResolvedValue(undefined) + selectBoth() + fireEvent.click( + screen.getAllByRole('button').find((b) => (b.textContent ?? '').includes('Delete selected'))!, + ) + // Nothing deleted before the confirmation. + expect(mockDeleteResult.mutateAsync).not.toHaveBeenCalled() + expect(document.body.textContent).toContain('Delete 2 workspace records?') + fireEvent.click(screen.getByTestId('workspace-multi-delete-confirm')) + await waitFor(() => expect(mockDeleteResult.mutateAsync).toHaveBeenCalledTimes(2)) + expect(mockDeleteResult.mutateAsync).toHaveBeenNthCalledWith(1, baseItem.workspace_id) + expect(mockDeleteResult.mutateAsync).toHaveBeenNthCalledWith(2, secondItem.workspace_id) + await waitFor(() => + expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('2 workspace records')), + ) + }) + + it('collects multi-delete failures into one error toast', async () => { + mockDeleteResult.mutateAsync + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new ApiError('Workspace not found', 404)) + selectBoth() + fireEvent.click( + screen.getAllByRole('button').find((b) => (b.textContent ?? '').includes('Delete selected'))!, + ) + fireEvent.click(screen.getByTestId('workspace-multi-delete-confirm')) + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Some deletes failed')), + ) }) +}) + +describe('WorkspacePanel — single delete', () => { + function openDeleteDialog() { + mockResponse = { data: { workspaces: [baseItem], total: 1 }, isLoading: false } + const result = renderPanel({ onDeleted: vi.fn() }) + radixOpen(screen.getByLabelText('More actions for e4-panel')) + fireEvent.click(screen.getByText('Delete…')) + return result + } it('shows a confirmation whose copy makes metadata-only deletion clear', () => { openDeleteDialog() - // The mutation must not fire before confirmation. expect(mockDeleteResult.mutate).not.toHaveBeenCalled() - // Radix renders the dialog in a portal — read the whole document. const copy = document.body.textContent ?? '' expect(copy).toContain('Delete workspace "e4-panel"?') expect(copy).toContain('only the saved workspace record') @@ -159,9 +343,9 @@ describe('WorkspacePanel — delete', () => { it('confirming deletes the row and notifies the page on success', () => { const onDeleted = vi.fn() mockResponse = { data: { workspaces: [baseItem], total: 1 }, isLoading: false } - mockDeleteResult = { mutate: vi.fn(), isPending: false } renderPanel({ onDeleted }) - fireEvent.click(screen.getByLabelText('Delete workspace e4-panel')) + radixOpen(screen.getByLabelText('More actions for e4-panel')) + fireEvent.click(screen.getByText('Delete…')) fireEvent.click(screen.getByTestId('workspace-delete-confirm')) expect(mockDeleteResult.mutate).toHaveBeenCalledTimes(1) @@ -170,9 +354,6 @@ describe('WorkspacePanel — delete', () => { { onSuccess: () => void; onError: (error: unknown) => void }, ] expect(workspaceId).toBe(baseItem.workspace_id) - - // Success path: the page hook is told so it can drop a loaded workspace; - // the list refetch itself lives in useDeleteWorkspace (hook test). options.onSuccess() expect(onDeleted).toHaveBeenCalledWith(baseItem.workspace_id) expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('were kept')) diff --git a/frontend/src/components/demo/WorkspacePanel.tsx b/frontend/src/components/demo/WorkspacePanel.tsx index 3231cf14..1fa62fe6 100644 --- a/frontend/src/components/demo/WorkspacePanel.tsx +++ b/frontend/src/components/demo/WorkspacePanel.tsx @@ -1,22 +1,39 @@ /** - * E4 (#393) — server-backed saved-workspaces panel for the Showcase page. + * E4 (#393) / E2 (#408) — server-backed saved-workspaces panel for the + * Showcase page. * - * Lists `showcase_workspace` rows (newest first) with three 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". - * - Delete — remove the saved workspace METADATA row only (confirmed via - * dialog). The run's created objects — model runs, scenario plans, - * aliases, jobs, artifacts — are soft references and stay intact. + * Lists `showcase_workspace` rows with lifecycle management (E2 #408): + * - Toolbar: name search, show-archived toggle, allow-listed sort, active + * tag-filter chip. The panel owns the list params; filtering/sorting is + * server-side (pinned rows always order first). + * - Per-row: Load (restore config, read-only), Replay (routes through the + * page's confirm dialog via onRequestReplay — NO replay starts here), + * pin toggle, actions dropdown (pin / archive / edit details / delete), + * pinned/archived/replay badges, clickable tag chips. + * - Multi-select: per-row checkboxes; Delete selected (N sequential single + * DELETEs behind one confirmation — deliberately NO bulk endpoint) and + * Compare (exactly 2 → /showcase/compare?a=&b=). * - * The panel stays dumb: it hands the LIST item to the page callbacks; detail - * fetching (created_objects) lives in the page via useWorkspace. + * Deletes remove the workspace METADATA row only — created objects are soft + * references and stay intact. */ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' import { useQueryClient } from '@tanstack/react-query' -import { FolderOpen, Play, Trash2 } from 'lucide-react' +import { + Archive, + ArchiveRestore, + FolderOpen, + MoreHorizontal, + Pencil, + Pin, + PinOff, + Play, + Search, + Trash2, + X, +} from 'lucide-react' import { toast } from 'sonner' import { AlertDialog, @@ -28,17 +45,39 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' +import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' -import { useDeleteWorkspace, useWorkspaces } from '@/hooks/use-workspaces' +import { Checkbox } from '@/components/ui/checkbox' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { useDeleteWorkspace, usePatchWorkspace, useWorkspaces } from '@/hooks/use-workspaces' import { ApiError, getErrorMessage } from '@/lib/api' -import type { WorkspaceListItem } from '@/types/api' +import { ROUTES } from '@/lib/constants' +import { cn } from '@/lib/utils' +import type { WorkspaceListItem, WorkspaceListParams } from '@/types/api' +import { WorkspaceEditDialog } from './WorkspaceEditDialog' 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 + /** + * E2 (#408) — called when the operator clicks Replay. The PAGE owns the + * confirmation dialog; the panel never starts a replay itself. + */ + onRequestReplay: (ws: WorkspaceListItem) => void /** Called after a workspace row was deleted — lets the page drop a loaded one. */ onDeleted?: (workspaceId: string) => void /** Disables all actions while a pipeline run is in flight. */ @@ -47,6 +86,15 @@ interface WorkspacePanelProps { lastWorkspaceId: string | null } +type SortKey = 'newest' | 'oldest' | 'name' | 'status' + +const SORT_PARAMS: Record> = { + newest: {}, + oldest: { sort_by: 'created_at', sort_order: 'asc' }, + name: { sort_by: 'name', sort_order: 'asc' }, + status: { sort_by: 'status', sort_order: 'asc' }, +} + function statusClass(status: WorkspaceListItem['status']): string { switch (status) { case 'completed': @@ -69,16 +117,45 @@ function labelOf(ws: WorkspaceListItem): string { export function WorkspacePanel({ onLoad, - onReplay, + onRequestReplay, onDeleted, isRunning, lastWorkspaceId, }: WorkspacePanelProps) { - const { data, isLoading } = useWorkspaces() + // ── E2 (#408) — server-side list params ───────────────────────────────── + const [search, setSearch] = useState('') + const [appliedQ, setAppliedQ] = useState('') + const [showArchived, setShowArchived] = useState(false) + const [sortKey, setSortKey] = useState('newest') + const [tagFilter, setTagFilter] = useState(null) + + // Debounced search — the q param needs >= 2 chars (server min_length). + useEffect(() => { + const handle = window.setTimeout(() => setAppliedQ(search.trim()), 300) + return () => window.clearTimeout(handle) + }, [search]) + + const params = useMemo( + () => ({ + ...(appliedQ.length >= 2 ? { q: appliedQ } : {}), + ...(tagFilter ? { tags: tagFilter } : {}), + ...(showArchived ? { include_archived: true } : {}), + ...SORT_PARAMS[sortKey], + }), + [appliedQ, tagFilter, showArchived, sortKey] + ) + + const { data, isLoading } = useWorkspaces(params) const queryClient = useQueryClient() const deleteWorkspace = useDeleteWorkspace() - // The row awaiting confirmation — one shared dialog instead of one per row. + const patchWorkspace = usePatchWorkspace() + + // ── dialogs + selection state ──────────────────────────────────────────── const [pendingDelete, setPendingDelete] = useState(null) + const [pendingEdit, setPendingEdit] = useState(null) + const [confirmMultiDelete, setConfirmMultiDelete] = useState(false) + const [selected, setSelected] = useState>(new Set()) + const navigate = useNavigate() const handleConfirmDelete = () => { const ws = pendingDelete @@ -101,6 +178,58 @@ export function WorkspacePanel({ }) } + // E2 (#408) — multi-select delete: N sequential SINGLE deletes (no bulk + // endpoint by design); failures collect into one summary toast. + const handleConfirmDeleteSelected = async () => { + const ids = Array.from(selected) + setConfirmMultiDelete(false) + const failures: string[] = [] + for (const id of ids) { + try { + await deleteWorkspace.mutateAsync(id) + onDeleted?.(id) + } catch (error) { + failures.push(`${id.slice(0, 8)}: ${getErrorMessage(error)}`) + } + } + setSelected(new Set()) + if (failures.length === 0) { + toast.success( + `Deleted ${ids.length} workspace record${ids.length === 1 ? '' : 's'} — created objects were kept.` + ) + } else { + toast.error(`Some deletes failed: ${failures.join('; ')}`) + } + } + + const handleTogglePin = (ws: WorkspaceListItem) => { + patchWorkspace.mutate( + { workspaceId: ws.workspace_id, update: { pinned: !ws.pinned } }, + { onError: (error) => toast.error(`Update failed: ${getErrorMessage(error)}`) } + ) + } + + const handleToggleArchive = (ws: WorkspaceListItem) => { + patchWorkspace.mutate( + { workspaceId: ws.workspace_id, update: { archived: !ws.archived } }, + { + onSuccess: () => { + toast.success(ws.archived ? 'Workspace unarchived.' : 'Workspace archived.') + }, + onError: (error) => toast.error(`Update failed: ${getErrorMessage(error)}`), + } + ) + } + + const toggleSelected = (workspaceId: string) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(workspaceId)) next.delete(workspaceId) + else next.add(workspaceId) + return next + }) + } + // 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(() => { @@ -110,6 +239,9 @@ export function WorkspacePanel({ }, [lastWorkspaceId, queryClient]) const items = data?.workspaces ?? [] + const allSelected = items.length > 0 && items.every((ws) => selected.has(ws.workspace_id)) + const selectedIds = Array.from(selected) + const hasActiveFilter = appliedQ.length >= 2 || tagFilter !== null || showArchived return ( @@ -122,68 +254,225 @@ export function WorkspacePanel({ )}
+ + {/* E2 (#408) — toolbar: search / show-archived / sort / tag chip. */} +
+
+ + setSearch(e.target.value)} + aria-label="Search workspaces by name" + /> +
+ + + {tagFilter && ( + + tag: {tagFilter} + + + )} +
+ {items.length === 0 ? (

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

) : ( -
    - {items.map((ws) => ( -
  • -
    - {labelOf(ws)} - {ws.scenario} - seed {ws.seed} - {ws.status.toUpperCase()} - {winnerOf(ws) && winner {winnerOf(ws)}} - {ws.reset && ( - - DESTRUCTIVE (replay wipes all data) - + <> + +
      + {items.map((ws) => ( +
    • - {new Date(ws.created_at).toLocaleString()} - -
    -
    - - - -
    -
  • - ))} -
+ > +
+ toggleSelected(ws.workspace_id)} + aria-label={`Select workspace ${labelOf(ws)}`} + /> + + {labelOf(ws)} + {ws.archived && archived} + {ws.replayed_from_workspace_id && replay} + {ws.scenario} + seed {ws.seed} + {ws.status.toUpperCase()} + {winnerOf(ws) && winner {winnerOf(ws)}} + {ws.reset && ( + + DESTRUCTIVE (replay wipes all data) + + )} + {ws.tags.map((tag) => ( + + ))} + + {new Date(ws.created_at).toLocaleString()} + +
+
+ + + + + + + + handleTogglePin(ws)}> + {ws.pinned ? ( + + ) : ( + + )} + {ws.pinned ? 'Unpin' : 'Pin'} + + handleToggleArchive(ws)}> + {ws.archived ? ( + + ) : ( + + )} + {ws.archived ? 'Unarchive' : 'Archive'} + + setPendingEdit(ws)}> + + Edit details… + + setPendingDelete(ws)} + > + + Delete… + + + +
+ + ))} + + + {/* E2 (#408) — selection footer. */} + {selectedIds.length > 0 && ( +
+ {selectedIds.length} selected + + +
+ )} + )} @@ -214,6 +503,37 @@ export function WorkspacePanel({ + + {/* E2 (#408) — one confirmation for the whole selection. */} + { + if (!open) setConfirmMultiDelete(false) + }} + > + + + Delete {selectedIds.length} workspace records? + + Their created objects are NOT deleted — model runs, scenario + plans, aliases, jobs, and artifacts stay available elsewhere in + the app. This cannot be undone. + + + + Keep workspaces + void handleConfirmDeleteSelected()} + data-testid="workspace-multi-delete-confirm" + > + Delete selected + + + + + + {/* E2 (#408) — rename / notes / tags editor. */} + setPendingEdit(null)} /> ) } diff --git a/frontend/src/components/demo/index.ts b/frontend/src/components/demo/index.ts index ccfe7b71..88731868 100644 --- a/frontend/src/components/demo/index.ts +++ b/frontend/src/components/demo/index.ts @@ -2,3 +2,8 @@ export * from './demo-step-card' // E4 (#393) — showcase workspace restore/replay panels. export * from './WorkspacePanel' export * from './WorkspaceArtifactsPanel' +// E2 (#408) — safe replay + lifecycle + lineage. +export * from './ReplayConfirmDialog' +export * from './WorkspaceEditDialog' +export * from './WorkspaceLineageStrip' +export * from './workspace-name' diff --git a/frontend/src/components/demo/replay-request.test.ts b/frontend/src/components/demo/replay-request.test.ts new file mode 100644 index 00000000..1e50759d --- /dev/null +++ b/frontend/src/components/demo/replay-request.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { buildReplayRequest } from './replay-request' +import type { WorkspaceListItem } from '@/types/api' + +const baseItem: WorkspaceListItem = { + workspace_id: 'a'.repeat(32), + name: 'replayable', + status: 'completed', + seed: 7, + scenario: 'showcase_rich', + reset: true, + skip_seed: false, + result_summary: null, + created_at: '2026-06-01T12:00:00Z', + archived: false, + pinned: false, + tags: [], + replayed_from_workspace_id: null, +} + +describe('buildReplayRequest', () => { + it('re-submits the recorded config verbatim with keep + provenance', () => { + expect(buildReplayRequest(baseItem)).toEqual({ + seed: 7, + scenario: 'showcase_rich', + reset: true, + skip_seed: false, + preservation: 'keep', + replayed_from_workspace_id: baseItem.workspace_id, + workspace_name: 'replayable', + }) + }) + + it('omits workspace_name on an unnamed row (names stay optional)', () => { + const request = buildReplayRequest({ ...baseItem, name: null }) + expect('workspace_name' in request).toBe(false) + expect(request.preservation).toBe('keep') + }) +}) diff --git a/frontend/src/components/demo/replay-request.ts b/frontend/src/components/demo/replay-request.ts new file mode 100644 index 00000000..e2ecee3d --- /dev/null +++ b/frontend/src/components/demo/replay-request.ts @@ -0,0 +1,19 @@ +import type { DemoRunRequest, WorkspaceListItem } from '@/types/api' + +/** + * E2 (#408) — the EXACT request a confirmed replay sends. Single source for + * the confirm dialog's "Will send" column AND the page's executeReplay, so + * the preview can never lie about what goes on the wire. + */ +export function buildReplayRequest(ws: WorkspaceListItem): DemoRunRequest { + return { + seed: ws.seed, + scenario: ws.scenario, + reset: ws.reset, + skip_seed: ws.skip_seed, + preservation: 'keep', + // E1 (#407) — record replay lineage on the NEW row (soft reference). + replayed_from_workspace_id: ws.workspace_id, + ...(ws.name ? { workspace_name: ws.name } : {}), + } +} diff --git a/frontend/src/components/demo/workspace-name.ts b/frontend/src/components/demo/workspace-name.ts new file mode 100644 index 00000000..cd14aa34 --- /dev/null +++ b/frontend/src/components/demo/workspace-name.ts @@ -0,0 +1,8 @@ +// E2 (#408) — single source for the workspace-name client validation, +// shared by the showcase run controls and the WorkspaceEditDialog. Mirrors +// the backend DemoRunRequest.workspace_name pattern (app/features/demo/ +// schemas.py): lowercase letters/digits, then -/_ allowed; ≤100 chars. +export const WORKSPACE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_]*$/ + +export const WORKSPACE_NAME_HINT = + 'Lowercase letters/digits only, then “-” or “_” (must not start with either).' diff --git a/frontend/src/pages/showcase.tsx b/frontend/src/pages/showcase.tsx index 9643de1a..6a3497ce 100644 --- a/frontend/src/pages/showcase.tsx +++ b/frontend/src/pages/showcase.tsx @@ -3,14 +3,18 @@ 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 { useWorkspace, useWorkspaceHealth } 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 { ReplayConfirmDialog } from '@/components/demo/ReplayConfirmDialog' +import { WorkspaceLineageStrip } from '@/components/demo/WorkspaceLineageStrip' import { WorkspacePanel } from '@/components/demo/WorkspacePanel' import { WorkspaceArtifactsPanel } from '@/components/demo/WorkspaceArtifactsPanel' +import { buildReplayRequest } from '@/components/demo/replay-request' +import { WORKSPACE_NAME_PATTERN } from '@/components/demo/workspace-name' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Checkbox } from '@/components/ui/checkbox' @@ -21,10 +25,6 @@ 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. * @@ -122,6 +122,8 @@ export default function ShowcasePage() { const [keepWorkspace, setKeepWorkspace] = useState(false) const [workspaceName, setWorkspaceName] = useState('') const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(null) + // E2 (#408) — the workspace awaiting replay confirmation (null = no dialog). + const [pendingReplay, setPendingReplay] = useState(null) // The page (not the panel) resolves the loaded workspace's detail — the // artifacts panel needs detail-only created_objects. @@ -129,6 +131,11 @@ export default function ShowcasePage() { selectedWorkspaceId ?? '', !!selectedWorkspaceId ) + // E2 (#408) — probe the LOADED workspace's soft references (never per row). + const { data: workspaceHealth } = useWorkspaceHealth( + selectedWorkspaceId ?? '', + !!selectedWorkspaceId + ) const completed = steps.filter((s) => TERMINAL_STATUSES.has(s.status)).length @@ -167,24 +174,24 @@ export default function ShowcasePage() { 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). + // E2 (#408) — Replay request: every replay first opens the confirmation + // dialog (recorded-vs-sent preview; destructive variant on reset=true). + // NO code path starts a replay without it. const handleReplayWorkspace = (ws: WorkspaceListItem) => { + setPendingReplay(ws) + } + + // E4 (#393) / E2 (#408) — the CONFIRMED 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 via handleLoadWorkspace (picker-desync gotcha: start() does + // not sync the picker state). + const executeReplay = (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', - // E1 (#407) — record replay lineage on the NEW row (soft reference). - replayed_from_workspace_id: ws.workspace_id, - ...(ws.name ? { workspace_name: ws.name } : {}), - }) + start(buildReplayRequest(ws)) + setPendingReplay(null) } // For the Inspect link to surface store_id/product_id on the train/backtest @@ -243,10 +250,11 @@ export default function ShowcasePage() { scenario={scenario} /> - {/* E4 (#393) — server-backed saved workspaces (Load + Replay + Delete). */} + {/* E4 (#393) / E2 (#408) — server-backed saved workspaces (lifecycle + panel; Replay routes through the confirm dialog below). */} { // Deleting the currently loaded workspace detaches its artifacts // panel — the metadata row backing it is gone (created objects stay). @@ -446,10 +454,28 @@ export default function ShowcasePage() { )} {/* E4 (#393) — re-attached artifacts of a LOADED workspace. Any started - run detaches it (selectedWorkspaceId cleared) so live cards take over. */} + run detaches it (selectedWorkspaceId cleared) so live cards take over. + E2 (#408) — lineage strip + link-health markers ride along. */} {phase !== 'running' && loadedWorkspace && ( - +
+ handleLoadWorkspace(ancestor)} + /> + +
)} + + {/* E2 (#408) — replay confirmation with the recorded-vs-sent preview. */} + pendingReplay && executeReplay(pendingReplay)} + onCancel={() => setPendingReplay(null)} + />
) } From c957de80bcf1c5d826adb716ed3ab9c4bf32b3e8 Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Sat, 13 Jun 2026 01:24:10 +0200 Subject: [PATCH 5/6] feat(ui): add two-workspace compare page (#408) --- frontend/src/App.tsx | 9 + frontend/src/lib/constants.ts | 2 + frontend/src/pages/workspace-compare.test.tsx | 157 +++++++ frontend/src/pages/workspace-compare.tsx | 394 ++++++++++++++++++ 4 files changed, 562 insertions(+) create mode 100644 frontend/src/pages/workspace-compare.test.tsx create mode 100644 frontend/src/pages/workspace-compare.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2dc4042f..56df44e9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { ROUTES } from '@/lib/constants' // Lazy-loaded page components const DashboardPage = lazy(() => import('@/pages/dashboard')) const ShowcasePage = lazy(() => import('@/pages/showcase')) +const WorkspaceComparePage = lazy(() => import('@/pages/workspace-compare')) const OpsPage = lazy(() => import('@/pages/ops')) const SalesExplorerPage = lazy(() => import('@/pages/explorer/sales')) const StoresExplorerPage = lazy(() => import('@/pages/explorer/stores')) @@ -59,6 +60,14 @@ function App() { } /> + }> + + + } + /> { + class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} + } + vi.stubGlobal('ResizeObserver', ResizeObserverStub) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +const idA = 'a'.repeat(32) +const idB = 'b'.repeat(32) + +function makeDetail(overrides: Partial): WorkspaceDetail { + return { + workspace_id: idA, + name: 'ws-a', + status: 'completed', + seed: 42, + scenario: 'demo_minimal', + reset: false, + skip_seed: true, + result_summary: { + winner_model_type: 'seasonal_naive', + winner_wape: 0.15, + wall_clock_s: 12, + }, + created_at: '2026-06-01T12:00:00Z', + archived: false, + pinned: false, + tags: [], + replayed_from_workspace_id: null, + store_id: 3, + product_id: 7, + date_start: '2026-01-01', + date_end: '2026-03-31', + created_objects: { winning_run_id: 'run-1', alias: 'demo-production' }, + notes: null, + config_schema_version: 1, + ...overrides, + } +} + +let details: Record = {} + +vi.mock('@/hooks/use-workspaces', () => ({ + useWorkspaces: () => ({ + data: { + workspaces: Object.values(details).filter(Boolean), + total: Object.keys(details).length, + }, + isLoading: false, + }), + useWorkspace: (workspaceId: string, enabled = true) => { + if (!enabled || !workspaceId) return { data: undefined, isLoading: false, error: null } + const detail = details[workspaceId] + return detail + ? { data: detail, isLoading: false, error: null } + : { data: undefined, isLoading: false, error: new Error('not found') } + }, +})) + +beforeEach(() => { + details = { + [idA]: makeDetail({}), + [idB]: makeDetail({ + workspace_id: idB, + name: 'ws-b', + seed: 99, + status: 'failed', + replayed_from_workspace_id: idA, + result_summary: { + winner_model_type: 'naive', + winner_wape: 0.25, + wall_clock_s: 20, + }, + created_objects: { winning_run_id: 'run-2', batch_id: 'batch-1' }, + }), + } +}) + +function renderPage(query = `?a=${idA}&b=${idB}`) { + return render( + + + } /> + + , + ) +} + +describe('WorkspaceComparePage', () => { + it('renders the config diff for two deep-linked workspaces', () => { + const { container } = renderPage() + const copy = container.textContent ?? '' + expect(copy).toContain('ws-a') + expect(copy).toContain('ws-b') + expect(copy).toContain('42') + expect(copy).toContain('99') + // Mismatching seed rows are emphasized. + const bolded = Array.from(container.querySelectorAll('td.font-semibold')).map( + (el) => el.textContent, + ) + expect(bolded).toContain('42') + expect(bolded).toContain('99') + }) + + it('renders the result diff with the sign-only WAPE delta', () => { + const { container } = renderPage() + const copy = container.textContent ?? '' + expect(copy).toContain('seasonal_naive') + expect(copy).toContain('0.1500') + expect(copy).toContain('0.2500') + expect(copy).toContain('0.1000') // 0.25 - 0.15 + }) + + it('renders the created-objects presence matrix over the key union', () => { + const { container } = renderPage() + const copy = container.textContent ?? '' + expect(copy).toContain('winning_run_id') + expect(copy).toContain('alias') + expect(copy).toContain('batch_id') + }) + + it('renders the lineage note when one side replays the other', () => { + const { container } = renderPage() + expect(container.textContent).toContain('Workspace B is a replay of workspace A.') + }) + + it('renders the partial-run badge on a failed side', () => { + const { container } = renderPage() + expect(container.textContent).toContain('partial run') + }) + + it('degrades to the picker when an id no longer resolves (no crash)', () => { + details[idB] = undefined + const { container } = renderPage() + expect(container.textContent).toContain('no longer exists') + // The diff sections never render half-ready. + expect(container.textContent).not.toContain('Created objects') + }) + + it('prompts for selection when ids are missing', () => { + const { container } = renderPage('') + expect(container.textContent).toContain('Select two workspaces') + }) +}) diff --git a/frontend/src/pages/workspace-compare.tsx b/frontend/src/pages/workspace-compare.tsx new file mode 100644 index 00000000..f6e8b785 --- /dev/null +++ b/frontend/src/pages/workspace-compare.tsx @@ -0,0 +1,394 @@ +/** + * E2 (#408) — two-workspace compare page (/showcase/compare?a=&b=). + * + * Mirrors the run-compare two-picker pattern (pages/explorer/run-compare.tsx) + * but the diff is FRONTEND-ONLY: a workspace compare is a plain field diff + * over two already-served WorkspaceDetail payloads — no backend endpoint. + * Renders: config table (mismatches highlighted), result-summary diff + * (WAPE delta is sign-only), created-objects presence matrix, lineage note + * when one side replays the other, and partial-run badges. Invalid/missing + * ids degrade to the picker — never a crash. + */ + +import { Link, useSearchParams } from 'react-router-dom' +import { ArrowDown, ArrowLeft, ArrowUp } from 'lucide-react' +import { useWorkspace, useWorkspaces } from '@/hooks/use-workspaces' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { formatNumber } from '@/lib/api' +import { ROUTES } from '@/lib/constants' +import { cn } from '@/lib/utils' +import type { WorkspaceDetail, WorkspaceListItem } from '@/types/api' + +/** Neutral delta indicator — sign only, no better/worse colour-coding. */ +function DeltaCell({ diff }: { diff: number | null }) { + if (diff == null) { + return + } + if (diff > 0) { + return ( + + + {formatNumber(diff, 4)} + + ) + } + if (diff < 0) { + return ( + + + {formatNumber(diff, 4)} + + ) + } + return {formatNumber(diff, 4)} +} + +function labelOf(ws: WorkspaceListItem): string { + return ws.name ?? ws.workspace_id.slice(0, 8) +} + +function WorkspacePicker({ + label, + value, + workspaces, + onSelect, +}: { + label: string + value: string + workspaces: WorkspaceListItem[] + onSelect: (workspaceId: string) => void +}) { + return ( +
+ {label} + +
+ ) +} + +function summaryNumber(ws: WorkspaceDetail, key: string): number | null { + const value = ws.result_summary?.[key] + return typeof value === 'number' ? value : null +} + +function summaryString(ws: WorkspaceDetail, key: string): string | null { + const value = ws.result_summary?.[key] + return typeof value === 'string' ? value : null +} + +interface ConfigRow { + field: string + a: string + b: string +} + +function buildConfigRows(a: WorkspaceDetail, b: WorkspaceDetail): ConfigRow[] { + const fmt = (value: unknown): string => + value === null || value === undefined || value === '' ? '—' : String(value) + return [ + { field: 'seed', a: fmt(a.seed), b: fmt(b.seed) }, + { field: 'scenario', a: fmt(a.scenario), b: fmt(b.scenario) }, + { field: 'reset', a: fmt(a.reset), b: fmt(b.reset) }, + { field: 'skip_seed', a: fmt(a.skip_seed), b: fmt(b.skip_seed) }, + { field: 'name', a: fmt(a.name), b: fmt(b.name) }, + { field: 'tags', a: fmt(a.tags.join(', ')), b: fmt(b.tags.join(', ')) }, + ] +} + +/** Union of soft-reference keys recorded on either side. */ +function objectKeys(a: WorkspaceDetail, b: WorkspaceDetail): string[] { + return Array.from( + new Set([...Object.keys(a.created_objects), ...Object.keys(b.created_objects)]) + ).sort() +} + +function lineageNote(a: WorkspaceDetail, b: WorkspaceDetail): string | null { + if (b.replayed_from_workspace_id === a.workspace_id) { + return 'Workspace B is a replay of workspace A.' + } + if (a.replayed_from_workspace_id === b.workspace_id) { + return 'Workspace A is a replay of workspace B.' + } + return null +} + +function SideStatus({ ws }: { ws: WorkspaceDetail }) { + return ( + + {ws.status} + {ws.status !== 'completed' && ( + + partial run + + )} + + ) +} + +export default function WorkspaceComparePage() { + const [params, setParams] = useSearchParams() + const a = params.get('a') ?? '' + const b = params.get('b') ?? '' + + // Pickers include archived rows — comparing an archived run is legitimate. + const listQuery = useWorkspaces({ limit: 100, include_archived: true }) + const detailA = useWorkspace(a, !!a) + const detailB = useWorkspace(b, !!b) + + function selectWorkspace(slot: 'a' | 'b', workspaceId: string) { + setParams((prev) => { + const next = new URLSearchParams(prev) + next.set(slot, workspaceId) + return next + }) + } + + const workspaces = listQuery.data?.workspaces ?? [] + const wsA = detailA.data + const wsB = detailB.data + // A 404 (deleted id in the URL) degrades to the picker — never a crash. + const bothReady = !!wsA && !!wsB + + const wapeA = wsA ? summaryNumber(wsA, 'winner_wape') : null + const wapeB = wsB ? summaryNumber(wsB, 'winner_wape') : null + const note = bothReady ? lineageNote(wsA, wsB) : null + + return ( +
+
+ +

Compare workspaces

+

+ Pick two saved showcase workspaces to compare their replay config, + results, and recorded objects side by side. +

+
+ + + + Select workspaces + + The comparison is deep-linkable — the URL carries the two workspace ids. + + + + selectWorkspace('a', id)} + /> + selectWorkspace('b', id)} + /> + + + + {(!a || !b || detailA.error || detailB.error || !bothReady) && ( + + + {detailA.error || detailB.error + ? 'One of the selected workspaces no longer exists — select another above.' + : detailA.isLoading || detailB.isLoading + ? 'Loading workspaces…' + : 'Select two workspaces above to see the comparison.'} + + + )} + + {bothReady && ( + <> + {note && ( + + + {note} + + + )} + + + + Config + + Recorded replay config — mismatching rows are highlighted. + + + + + + + Field + Workspace A + Workspace B + + + + + Workspace ID + + {wsA.workspace_id} + + + {wsB.workspace_id} + + + + Status + + + + + + + + {buildConfigRows(wsA, wsB).map((row) => { + const mismatch = row.a !== row.b + return ( + + {row.field} + + {row.a} + + + {row.b} + + + ) + })} + +
+
+
+ + + + Results + + Δ is Workspace B minus Workspace A — sign only, not a quality judgement. + + + + + + + Metric + Workspace A + Workspace B + Δ + + + + + Winner + {summaryString(wsA, 'winner_model_type') ?? '—'} + {summaryString(wsB, 'winner_model_type') ?? '—'} + + + + + + Winner WAPE + {wapeA != null ? formatNumber(wapeA, 4) : '—'} + {wapeB != null ? formatNumber(wapeB, 4) : '—'} + + + + + + Wall-clock (s) + + {summaryNumber(wsA, 'wall_clock_s') != null + ? formatNumber(summaryNumber(wsA, 'wall_clock_s')!, 1) + : '—'} + + + {summaryNumber(wsB, 'wall_clock_s') != null + ? formatNumber(summaryNumber(wsB, 'wall_clock_s')!, 1) + : '—'} + + + + + + +
+
+
+ + + + Created objects + + Which soft references each run recorded (✓ recorded / — absent). + + + + {objectKeys(wsA, wsB).length === 0 ? ( +

+ Neither workspace recorded any created objects. +

+ ) : ( + + + + Object + Workspace A + Workspace B + + + + {objectKeys(wsA, wsB).map((key) => ( + + {key} + {key in wsA.created_objects ? '✓' : '—'} + {key in wsB.created_objects ? '✓' : '—'} + + ))} + +
+ )} +
+
+ + )} +
+ ) +} From 0560e0eba6e56958f8605087d4dd099a088a3da2 Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Sat, 13 Jun 2026 01:24:10 +0200 Subject: [PATCH 6/6] docs(api): document workspace lifecycle and health contracts (#408) --- docs/_base/API_CONTRACTS.md | 5 +++-- docs/_base/RUNBOOKS.md | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/_base/API_CONTRACTS.md b/docs/_base/API_CONTRACTS.md index 47bc7b6e..70e6f5ab 100644 --- a/docs/_base/API_CONTRACTS.md +++ b/docs/_base/API_CONTRACTS.md @@ -60,8 +60,9 @@ 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. **E1 (#407)** — body accepts additive Optional `replayed_from_workspace_id: str \| null` (`^[0-9a-f]{32}$`); requires `preservation='keep'` (else `422 application/problem+json`); recorded verbatim on the new `showcase_workspace` row as a SOFT reference (no existence check — dangles are designed). | | 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. **E1 (#407)** — list items additively carry `archived`, `pinned`, `tags`, `replayed_from_workspace_id`; archived rows still list (default-filtering is E2 #408) | +| 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. **E1 (#407)** — list items additively carry `archived`, `pinned`, `tags`, `replayed_from_workspace_id`. **E2 (#408)** — additive query params: `q` (name ILIKE search, min 2 chars), repeated `tags` (JSONB containment — all listed tags must match), `include_archived` (default `false` — archived rows are now HIDDEN by default), allow-listed `sort_by` (`created_at`/`name`/`seed`/`status`; unknown → default `created_at desc`, no 422) + `sort_order` (`asc`/`desc`); pinned rows always order first; `total` respects the active filters | | 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. **E1 (#407)** — response additively carries the list-item lifecycle fields plus `notes`, `config_schema_version`, and the six story slots (`seed_overrides` / `user_scope` / `approval_events` / `rag_events` / `job_ids` / `phase_summaries` — all `null` until their writer epic lands; schemas in `docs/_base/DOMAIN_MODEL.md`) | +| demo | GET | `/demo/workspaces/{workspace_id}/health` | **E2 (#408)** — probe the workspace's soft references in-process (model runs, scenario plans, alias, batch, agent session, `job_ids` slot) via `httpx.ASGITransport`; per-reference `status` ∈ `alive` (2xx) / `dead` (404 — deleted after the run) / `unknown` (anything else — never a 500), plus `alive`/`dead`/`unknown` counts and `partial_run` (true when the row's status ≠ `completed`); non-probeable keys (`v2_model_path`, `scenario_artifact_key`, `train_model_types`) are skipped; `404 application/problem+json` when the workspace is missing | | demo | PATCH | `/demo/workspaces/{workspace_id}` | **E1 (#407)** — partial lifecycle update (`name` / `notes` / `tags` / `archived` / `pinned`; `exclude_unset` semantics — only provided fields change; explicit `null` clears `name`/`notes`; explicit `null` on `archived`/`pinned`/`tags` → `422` (send `[]` to clear tags); `status` NOT patchable — the pipeline owns it); returns the updated `WorkspaceDetailResponse`; empty body = `200` no-op; `404 application/problem+json` when missing; `422` on unknown keys / bad name pattern / >20 tags | | demo | DELETE | `/demo/workspaces/{workspace_id}` | Delete one saved workspace METADATA row; `204` on success, `404 application/problem+json` when missing. The run's created objects (model runs, scenario plans, aliases, jobs, artifacts) are soft references and are NOT deleted | | config | GET | `/config/ai` | Effective AI-model config (agent LLM + RAG embeddings); API keys masked, never raw | @@ -98,7 +99,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: