From a5f72533775b790c5d10808c20ccd368e91e76b3 Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Sat, 13 Jun 2026 00:12:39 +0200 Subject: [PATCH 1/4] feat(db): extend showcase_workspace with metadata and provenance columns (#407) --- ..._showcase_workspace_metadata_provenance.py | 137 ++++++++++++++++++ app/features/demo/models.py | 88 ++++++++++- 2 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/d45cf40dfe47_add_showcase_workspace_metadata_provenance.py diff --git a/alembic/versions/d45cf40dfe47_add_showcase_workspace_metadata_provenance.py b/alembic/versions/d45cf40dfe47_add_showcase_workspace_metadata_provenance.py new file mode 100644 index 00000000..0ba9d043 --- /dev/null +++ b/alembic/versions/d45cf40dfe47_add_showcase_workspace_metadata_provenance.py @@ -0,0 +1,137 @@ +"""add showcase_workspace metadata and provenance columns + +Revision ID: d45cf40dfe47 +Revises: 324a2fa37fcc +Create Date: 2026-06-12 12:00:00.000000 + +E1 of the showcase-completion initiative (umbrella #406, epic #407). Extends +``showcase_workspace`` with the metadata + provenance backbone every parallel +epic consumes: lifecycle columns (``archived`` / ``pinned`` / ``notes`` / +``tags`` / ``config_schema_version``), the replay-provenance soft reference +``replayed_from_workspace_id`` (deliberately NO ForeignKey -- not even +self-referential; ancestor rows stay independently deletable), and six +documented JSONB story slots (``seed_overrides`` / ``user_scope`` / +``approval_events`` / ``rag_events`` / ``job_ids`` / ``phase_summaries``) +that stay NULL until their writer epic lands. NOT NULL columns carry server +defaults so the migration applies on tables with existing rows. Forward-only. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "d45cf40dfe47" +down_revision: str | None = "324a2fa37fcc" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add the lifecycle, provenance, and story-slot columns plus indexes.""" + op.add_column( + "showcase_workspace", + sa.Column( + "archived", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + ) + op.add_column( + "showcase_workspace", + sa.Column( + "pinned", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + ) + op.add_column( + "showcase_workspace", + sa.Column("notes", sa.Text(), nullable=True), + ) + op.add_column( + "showcase_workspace", + sa.Column( + "tags", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'[]'::jsonb"), + ), + ) + op.add_column( + "showcase_workspace", + sa.Column( + "config_schema_version", + sa.Integer(), + nullable=False, + server_default=sa.text("1"), + ), + ) + op.add_column( + "showcase_workspace", + sa.Column("replayed_from_workspace_id", sa.String(length=32), nullable=True), + ) + op.add_column( + "showcase_workspace", + sa.Column("seed_overrides", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + ) + op.add_column( + "showcase_workspace", + sa.Column("user_scope", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + ) + op.add_column( + "showcase_workspace", + sa.Column("approval_events", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + ) + op.add_column( + "showcase_workspace", + sa.Column("rag_events", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + ) + op.add_column( + "showcase_workspace", + sa.Column("job_ids", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + ) + op.add_column( + "showcase_workspace", + sa.Column("phase_summaries", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + ) + op.create_index( + "ix_showcase_workspace_tags_gin", + "showcase_workspace", + ["tags"], + unique=False, + postgresql_using="gin", + ) + op.create_index( + "ix_showcase_workspace_replayed_from", + "showcase_workspace", + ["replayed_from_workspace_id"], + unique=False, + ) + + +def downgrade() -> None: + """Drop the two indexes, then the twelve columns (reverse order).""" + op.drop_index("ix_showcase_workspace_replayed_from", table_name="showcase_workspace") + op.drop_index( + "ix_showcase_workspace_tags_gin", + table_name="showcase_workspace", + postgresql_using="gin", + ) + op.drop_column("showcase_workspace", "phase_summaries") + op.drop_column("showcase_workspace", "job_ids") + op.drop_column("showcase_workspace", "rag_events") + op.drop_column("showcase_workspace", "approval_events") + op.drop_column("showcase_workspace", "user_scope") + op.drop_column("showcase_workspace", "seed_overrides") + op.drop_column("showcase_workspace", "replayed_from_workspace_id") + op.drop_column("showcase_workspace", "config_schema_version") + op.drop_column("showcase_workspace", "tags") + op.drop_column("showcase_workspace", "notes") + op.drop_column("showcase_workspace", "pinned") + op.drop_column("showcase_workspace", "archived") diff --git a/app/features/demo/models.py b/app/features/demo/models.py index 30ad586e..4a50eb4a 100644 --- a/app/features/demo/models.py +++ b/app/features/demo/models.py @@ -10,6 +10,17 @@ references the run). E1 of the showcase-workspace initiative (umbrella #389, epic #390). +E1 of the showcase-completion initiative (umbrella #406, epic #407) adds the +metadata + provenance backbone: lifecycle columns (``archived`` / ``pinned`` / +``notes`` / ``tags`` / ``config_schema_version``), the replay-provenance +column ``replayed_from_workspace_id`` -- ALSO a soft reference, deliberately +no ForeignKey, not even self-referential: ancestor rows must stay +independently deletable (metadata-only delete) without cascading to or +blocking descendants, so dangling lineage pointers are expected -- and six +documented JSONB story slots (``seed_overrides`` / ``user_scope`` / +``approval_events`` / ``rag_events`` / ``job_ids`` / ``phase_summaries``) +that stay NULL until their writer epic lands (#408-#412). + GOTCHA: SQLAlchemy reserves the declarative attribute name ``metadata``; the JSONB columns are therefore named ``created_objects`` and ``result_summary``. """ @@ -19,7 +30,7 @@ import datetime as _dt from typing import Any -from sqlalchemy import CheckConstraint, Date, Index, Integer, String, text +from sqlalchemy import CheckConstraint, Date, Index, Integer, String, Text, text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column @@ -52,6 +63,18 @@ class ShowcaseWorkspace(TimestampMixin, Base): date_end: Seeded data window end; NULL when unknown. created_objects: Soft-reference ids of everything the run created (JSONB). result_summary: Winner / WAPE / wall-clock display payload (JSONB). + archived: Operator curation flag -- archived rows still list in E1. + pinned: Operator curation flag -- no behavioral semantics in E1. + notes: Free-text operator annotation (capped at the Pydantic boundary). + tags: Queryable JSONB string array, GIN-indexed (scenario_plan pattern). + config_schema_version: Version of the config + story-slot schema (starts at 1). + replayed_from_workspace_id: Soft reference to the replayed source row. + seed_overrides: Story slot (E3 #409 writes) -- NULL until written. + user_scope: Story slot (E3 #409 writes) -- NULL until written. + approval_events: Story slot (E5 #411 writes) -- NULL until written. + rag_events: Story slot (E5 #411 writes) -- NULL until written. + job_ids: Story slot (later parallel epic writes) -- NULL until written. + phase_summaries: Story slot (later parallel epic writes) -- NULL until written. """ __tablename__ = "showcase_workspace" @@ -80,10 +103,73 @@ class ShowcaseWorkspace(TimestampMixin, Base): # winner_model_type / winner_wape / wall_clock_s -- display payload. result_summary: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) + # ── E1 (#407) — lifecycle metadata ──────────────────────────────────── + # Orthogonal to ``status`` (which the pipeline owns): archive/pin are + # operator curation flags, PATCH-mutable, default false. + archived: Mapped[bool] = mapped_column( + nullable=False, default=False, server_default=text("false") + ) + pinned: Mapped[bool] = mapped_column( + nullable=False, default=False, server_default=text("false") + ) + # Free-text operator annotation; length capped at the Pydantic boundary (2000). + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + # Queryable JSONB string array -- EXACT scenario_plan.tags pattern + # (app/features/scenarios/models.py); GIN-indexed below. + tags: Mapped[list[str]] = mapped_column( + JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb") + ) + # Version of the workspace config + story-slot schema (umbrella #406 + # junk-drawer mitigation). Bump the ORM default when a slot shape changes. + config_schema_version: Mapped[int] = mapped_column( + Integer, nullable=False, default=1, server_default=text("1") + ) + + # ── E1 (#407) — replay provenance ───────────────────────────────────── + # SOFT reference to the workspace this run replayed (uuid4().hex of the + # source row). Deliberately NO ForeignKey -- not even self-referential: + # ancestor rows must stay independently deletable (metadata-only delete), + # and dangling lineage pointers are expected, like every created_objects id. + replayed_from_workspace_id: Mapped[str | None] = mapped_column(String(32), nullable=True) + + # ── E1 (#407) — documented JSONB story slots ────────────────────────── + # Six dedicated nullable JSONB columns (precedent: created_objects / + # result_summary). NULL = "slot never written" (distinct from empty). + # E1 writes NONE of them; documented schema per slot (authoritative copy + # in docs/_base/DOMAIN_MODEL.md): + # seed_overrides (E3 #409 writes) — dict: the curated seeder-override + # payload from the start frame, stored verbatim + # (model_dump(mode="json")); replay echoes it. + # user_scope (E3 #409 writes) — dict: operator-selected focus, + # {"store_id": int, "product_id": int} (additive keys + # allowed later). + # approval_events (E5 #411 writes) — list[dict], append-only: + # {"action_id": str, "tool_name": str, + # "decision": "approved"|"rejected", + # "decided_at": iso8601-str, "session_id": str}. + # rag_events (E5 #411 writes) — list[dict], append-only: + # {"event": "index"|"retrieve"|"skip", "detail": str, + # "count": int, "occurred_at": iso8601-str}. + # job_ids (later parallel epic) — list[str]: job / batch + # sub-job ids the run submitted (soft references). + # phase_summaries (later parallel epic) — list[dict], one per phase: + # {"phase_name": str, "status": "pass"|"fail"|"warn"|"skip", + # "steps": int, "duration_ms": float}. + seed_overrides: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) + user_scope: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) + approval_events: Mapped[list[dict[str, Any]] | None] = mapped_column(JSONB, nullable=True) + rag_events: Mapped[list[dict[str, Any]] | None] = mapped_column(JSONB, nullable=True) + job_ids: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True) + phase_summaries: Mapped[list[dict[str, Any]] | None] = mapped_column(JSONB, nullable=True) + __table_args__ = ( CheckConstraint( "status IN ('running', 'completed', 'failed')", name="ck_showcase_workspace_status", ), Index("ix_showcase_workspace_status_created", "status", "created_at"), + # E1 (#407) — tag containment queries (scenario_plan GIN precedent). + Index("ix_showcase_workspace_tags_gin", "tags", postgresql_using="gin"), + # E1 (#407) — lineage lookups ("which runs replayed this workspace?"). + Index("ix_showcase_workspace_replayed_from", "replayed_from_workspace_id"), ) From 9e12aadc1246f07691d461a7db0308e8b2990f43 Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Sat, 13 Jun 2026 00:12:39 +0200 Subject: [PATCH 2/4] feat(api): add workspace patch lifecycle endpoint and replay provenance (#407) --- app/features/demo/routes.py | 37 ++++++ app/features/demo/schemas.py | 105 ++++++++++++++++- app/features/demo/tests/test_models.py | 82 +++++++++++++ app/features/demo/tests/test_routes.py | 121 ++++++++++++++++++++ app/features/demo/tests/test_schemas.py | 127 +++++++++++++++++++++ app/features/demo/tests/test_workspace.py | 133 +++++++++++++++++++++- app/features/demo/workspace.py | 45 +++++++- 7 files changed, 646 insertions(+), 4 deletions(-) diff --git a/app/features/demo/routes.py b/app/features/demo/routes.py index d2881acb..87584247 100644 --- a/app/features/demo/routes.py +++ b/app/features/demo/routes.py @@ -5,6 +5,8 @@ - ``WS /demo/stream`` -- streams one StepEvent per step for the live UI. - ``GET /demo/workspaces`` -- E4 (#393): list saved workspaces. - ``GET /demo/workspaces/{workspace_id}`` -- E4 (#393): one workspace's detail. +- ``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 row only; the run's created objects are soft references and stay untouched. @@ -41,6 +43,7 @@ WorkspaceDetailResponse, WorkspaceListItem, WorkspaceListResponse, + WorkspaceUpdateRequest, ) logger = get_logger(__name__) @@ -135,6 +138,40 @@ async def get_showcase_workspace( return WorkspaceDetailResponse.model_validate(row) +@router.patch( + "/workspaces/{workspace_id}", + response_model=WorkspaceDetailResponse, + summary="Update a saved showcase workspace's lifecycle metadata", + description=( + "Partial update: rename / notes / tags / archive / pin. Only fields " + "present in the body change; explicit null clears name/notes. The run " + "lifecycle status is not patchable." + ), +) +async def update_showcase_workspace( + workspace_id: str, + update: WorkspaceUpdateRequest, + db: AsyncSession = Depends(get_db), +) -> WorkspaceDetailResponse: + """Update a saved showcase workspace's lifecycle metadata (E1, #407). + + Args: + workspace_id: External identifier of the workspace. + update: Partial-update body; only provided fields are applied. + db: Async database session from dependency. + + Returns: + The full updated workspace row. + + Raises: + NotFoundError: When no workspace matches ``workspace_id``. + """ + row = await workspace.update_workspace(db, workspace_id, update) + if row is None: + raise NotFoundError(message=f"Workspace not found: {workspace_id}") + return WorkspaceDetailResponse.model_validate(row) + + @router.delete( "/workspaces/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT, diff --git a/app/features/demo/schemas.py b/app/features/demo/schemas.py index cad7d32e..66bf202b 100644 --- a/app/features/demo/schemas.py +++ b/app/features/demo/schemas.py @@ -11,7 +11,7 @@ from datetime import UTC, date, datetime from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from app.shared.seeder.config import ScenarioPreset @@ -76,6 +76,15 @@ class DemoRunRequest(BaseModel): pattern=r"^[a-z0-9][a-z0-9\-_]*$", description="Optional workspace label; requires preservation='keep'.", ) + # E1 (#407): replay provenance. The frontend Replay handler sends the + # SOURCE row's workspace_id; create_workspace records it verbatim on the + # NEW row (soft reference -- no existence check). JSON-native str -> no + # Field(strict=False) needed. + replayed_from_workspace_id: str | None = Field( + default=None, + pattern=r"^[0-9a-f]{32}$", # uuid4().hex shape of workspace_id + description="workspace_id this run replays; requires preservation='keep'.", + ) @model_validator(mode="after") def _workspace_name_requires_keep(self) -> DemoRunRequest: @@ -84,6 +93,67 @@ def _workspace_name_requires_keep(self) -> DemoRunRequest: raise ValueError("workspace_name requires preservation='keep'") return self + @model_validator(mode="after") + def _replayed_from_requires_keep(self) -> DemoRunRequest: + """Reject a lineage pointer on a run that writes no workspace row.""" + if self.replayed_from_workspace_id is not None and self.preservation != "keep": + raise ValueError("replayed_from_workspace_id requires preservation='keep'") + return self + + +class WorkspaceUpdateRequest(BaseModel): + """Partial lifecycle update for ``PATCH /demo/workspaces/{workspace_id}``. + + exclude_unset semantics: only fields present in the body are applied; + explicit ``null`` clears ``name`` / ``notes``. Explicit ``null`` on + ``archived`` / ``pinned`` / ``tags`` is rejected (422) -- they back NOT + NULL columns; send ``[]`` to clear tags. ``extra="forbid"`` so a typo'd + field 422s instead of silently no-opping (RunUpdate precedent, + ``app/features/registry/schemas.py``). All fields JSON-native -> the + model-level ``strict=True`` needs no per-field override. ``status`` is + deliberately absent -- the pipeline owns the run lifecycle. + """ + + model_config = ConfigDict(strict=True, extra="forbid") + + name: str | None = Field( + default=None, + max_length=100, + pattern=r"^[a-z0-9][a-z0-9\-_]*$", # same as workspace_name + description="Rename the workspace; explicit null clears the label.", + ) + notes: str | None = Field( + default=None, + max_length=2000, + description="Free-text annotation; explicit null clears it.", + ) + tags: list[str] | None = Field( + default=None, + max_length=20, + description="Replace the full tag list (not a merge).", + ) + archived: bool | None = Field(default=None, description="Archive flag.") + pinned: bool | None = Field(default=None, description="Pin flag.") + + @field_validator("archived", "pinned", "tags") + @classmethod + def _reject_explicit_null(cls, v: bool | list[str] | None) -> bool | list[str]: + """Reject an explicit ``null`` on the NOT NULL-backed optional fields. + + Fires only on explicitly provided values (pydantic skips validators + for defaults unless ``validate_default=True``), so an absent field + stays unset while an explicit ``{"archived": null}`` / ``{"tags": + null}`` 422s instead of reaching the NOT NULL column via + ``exclude_unset`` -> ``setattr`` -> IntegrityError 500. tags: send + ``[]`` to clear, never ``null``. + """ + if v is None: + raise ValueError( + "archived/pinned accept only true/false and tags accepts a list " + "(send [] to clear) — explicit null is not allowed" + ) + return v + class StepEvent(BaseModel): """One streamed pipeline event. @@ -187,6 +257,15 @@ class WorkspaceListItem(BaseModel): default=None, description="Winner / WAPE / wall-clock display payload." ) created_at: datetime = Field(..., description="When the run was recorded (UTC).") + # E1 (#407) -- additive lifecycle + provenance fields (defaults so + # pre-E1 ORM-shaped stand-ins keep validating). + archived: bool = Field(default=False, description="Operator archive flag.") + pinned: bool = Field(default=False, description="Operator pin flag.") + tags: list[str] = Field(default_factory=list, description="Operator tags.") + replayed_from_workspace_id: str | None = Field( + default=None, + description="workspace_id this run replayed (soft reference; may dangle).", + ) class WorkspaceDetailResponse(WorkspaceListItem): @@ -200,6 +279,30 @@ class WorkspaceDetailResponse(WorkspaceListItem): default_factory=dict, description="Soft-reference ids of everything the run created.", ) + # E1 (#407) -- additive lifecycle metadata + the six story slots + # (NULL until their writer epic lands; defaults keep pre-E1 stand-ins valid). + notes: str | None = Field(default=None, description="Free-text operator annotation.") + config_schema_version: int = Field( + default=1, description="Version of the config + story-slot schema." + ) + seed_overrides: dict[str, Any] | None = Field( + default=None, description="Story slot (E3 #409 writes): seeder-override payload." + ) + user_scope: dict[str, Any] | None = Field( + default=None, description="Story slot (E3 #409 writes): operator-selected focus." + ) + approval_events: list[dict[str, Any]] | None = Field( + default=None, description="Story slot (E5 #411 writes): HITL approval audit." + ) + rag_events: list[dict[str, Any]] | None = Field( + default=None, description="Story slot (E5 #411 writes): RAG event audit." + ) + job_ids: list[str] | None = Field( + default=None, description="Story slot (later epic): submitted job/batch ids." + ) + phase_summaries: list[dict[str, Any]] | None = Field( + default=None, description="Story slot (later epic): per-phase outcome summary." + ) class WorkspaceListResponse(BaseModel): diff --git a/app/features/demo/tests/test_models.py b/app/features/demo/tests/test_models.py index 91c9d0e5..28791caa 100644 --- a/app/features/demo/tests/test_models.py +++ b/app/features/demo/tests/test_models.py @@ -11,6 +11,7 @@ from datetime import date import pytest +from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -109,3 +110,84 @@ async def test_showcase_workspace_status_check_violation(db_session: AsyncSessio with pytest.raises(IntegrityError): await db_session.commit() await db_session.rollback() + + +# ============================================================================= +# E1 (#407) -- metadata + provenance backbone +# ============================================================================= + + +async def test_showcase_workspace_e1_defaults_applied(db_session: AsyncSession) -> None: + """A minimal insert gets the E1 defaults (ORM + server defaults agree).""" + row = _make_row() + db_session.add(row) + await db_session.commit() + + loaded = await get_workspace(db_session, row.workspace_id) + assert loaded is not None + assert loaded.archived is False + assert loaded.pinned is False + assert loaded.notes is None + assert loaded.tags == [] + assert loaded.config_schema_version == 1 + assert loaded.replayed_from_workspace_id is None + # All six story slots stay NULL until their writer epic lands. + assert loaded.seed_overrides is None + assert loaded.user_scope is None + assert loaded.approval_events is None + assert loaded.rag_events is None + assert loaded.job_ids is None + assert loaded.phase_summaries is None + + +async def test_showcase_workspace_tags_containment_query(db_session: AsyncSession) -> None: + """tags round-trips as a JSONB string array and answers .contains().""" + tagged = _make_row(tags=["workspace:x", "demo"]) + untagged = _make_row(tags=["other"]) + db_session.add_all([tagged, untagged]) + await db_session.commit() + + result = await db_session.execute( + select(ShowcaseWorkspace).where(ShowcaseWorkspace.tags.contains(["demo"])) + ) + matches = [r.workspace_id for r in result.scalars().all()] + assert tagged.workspace_id in matches + assert untagged.workspace_id not in matches + + loaded = await get_workspace(db_session, tagged.workspace_id) + assert loaded is not None + assert loaded.tags == ["workspace:x", "demo"] + + +async def test_showcase_workspace_story_slot_roundtrip(db_session: AsyncSession) -> None: + """A dict slot and a list[dict] slot round-trip through JSONB intact.""" + seed_overrides = {"noise_sigma": 0.2, "promo_intensity": "high"} + approval_events = [ + { + "action_id": "act-1", + "tool_name": "save_scenario", + "decision": "approved", + "decided_at": "2026-06-12T12:00:00+00:00", + "session_id": "sess-1", + } + ] + row = _make_row(seed_overrides=seed_overrides, approval_events=approval_events) + db_session.add(row) + await db_session.commit() + + loaded = await get_workspace(db_session, row.workspace_id) + assert loaded is not None + assert loaded.seed_overrides == seed_overrides + assert loaded.approval_events == approval_events + + +async def test_showcase_workspace_replayed_from_recorded(db_session: AsyncSession) -> None: + """replayed_from_workspace_id stores a verbatim soft reference (may dangle).""" + dangling_source = uuid.uuid4().hex # no such row -- dangles by design + row = _make_row(replayed_from_workspace_id=dangling_source) + db_session.add(row) + await db_session.commit() + + loaded = await get_workspace(db_session, row.workspace_id) + assert loaded is not None + assert loaded.replayed_from_workspace_id == dangling_source diff --git a/app/features/demo/tests/test_routes.py b/app/features/demo/tests/test_routes.py index 6fd5b84a..1934c018 100644 --- a/app/features/demo/tests/test_routes.py +++ b/app/features/demo/tests/test_routes.py @@ -351,6 +351,92 @@ async def fake_delete(_db, _workspace_id: str) -> bool: assert "Workspace not found" in resp.json()["detail"] +# ============================================================================= +# E1 (#407) -- PATCH /demo/workspaces/{workspace_id} (unit) +# ============================================================================= + + +async def test_patch_workspace_happy_path(client, monkeypatch): + """E1 (#407) -- provided fields update; response echoes the full detail.""" + seen: dict[str, object] = {} + + async def fake_update(_db, workspace_id: str, update) -> SimpleNamespace: + seen["workspace_id"] = workspace_id + seen["changes"] = update.model_dump(exclude_unset=True) + return _orm_like_row( + workspace_id=workspace_id, + name="renamed", + pinned=True, + tags=["t1"], + ) + + monkeypatch.setattr(workspace, "update_workspace", fake_update) + + resp = await client.patch( + "/demo/workspaces/" + "a" * 32, + json={"name": "renamed", "pinned": True, "tags": ["t1"]}, + ) + assert resp.status_code == 200 + assert seen["workspace_id"] == "a" * 32 + assert seen["changes"] == {"name": "renamed", "pinned": True, "tags": ["t1"]} + body = resp.json() + assert body["name"] == "renamed" + assert body["pinned"] is True + assert body["tags"] == ["t1"] + # Untouched fields ride through from the row. + assert body["status"] == "completed" + assert body["seed"] == 42 + + +async def test_patch_workspace_missing_404_problem_json(client, monkeypatch): + """E1 (#407) -- an unknown workspace_id is a 404 problem+json.""" + + async def fake_update(_db, _workspace_id: str, _update) -> None: + return None + + monkeypatch.setattr(workspace, "update_workspace", fake_update) + + resp = await client.patch("/demo/workspaces/" + "0" * 32, json={"pinned": True}) + 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_patch_workspace_unknown_field_422(client): + """E1 (#407) -- extra='forbid': a typo'd field is a 422 problem+json.""" + resp = await client.patch("/demo/workspaces/" + "a" * 32, json={"bogus": 1}) + assert resp.status_code == 422 + assert resp.headers["content-type"].startswith("application/problem+json") + + +async def test_patch_workspace_explicit_null_archived_422(client): + """E1 (#407) -- explicit null on a NOT NULL-backed field is a 422.""" + resp = await client.patch("/demo/workspaces/" + "a" * 32, json={"archived": None}) + assert resp.status_code == 422 + assert resp.headers["content-type"].startswith("application/problem+json") + + +async def test_patch_workspace_empty_body_noop_200(client, monkeypatch): + """E1 (#407) -- an empty body is a 200 no-op returning the current row.""" + + async def fake_update(_db, workspace_id: str, update) -> SimpleNamespace: + assert update.model_dump(exclude_unset=True) == {} + return _orm_like_row(workspace_id=workspace_id) + + monkeypatch.setattr(workspace, "update_workspace", fake_update) + + resp = await client.patch("/demo/workspaces/" + "a" * 32, json={}) + assert resp.status_code == 200 + assert resp.json()["workspace_id"] == "a" * 32 + + +async def test_run_demo_rejects_replayed_from_without_keep_422(client): + """E1 (#407) -- a lineage pointer without preservation='keep' is a 422.""" + resp = await client.post("/demo/run", json={"replayed_from_workspace_id": "a" * 32}) + assert resp.status_code == 422 + assert resp.headers["content-type"].startswith("application/problem+json") + + # ============================================================================= # E4 (#393) -- workspace GET routes against real Postgres (integration) # ============================================================================= @@ -405,6 +491,41 @@ async def test_get_workspace_integration_round_trip(client, db_session: AsyncSes assert missing.headers["content-type"].startswith("application/problem+json") +@pytest.mark.integration +async def test_patch_workspace_integration_round_trip(client, db_session: AsyncSession): + """E1 (#407) -- PATCH round-trips rename/notes/tags/archive/pin on a real row.""" + workspace_id = await workspace.create_workspace( + DemoRunRequest.model_validate({"preservation": "keep", "workspace_name": "e1-patch"}) + ) + assert workspace_id is not None + + resp = await client.patch( + f"/demo/workspaces/{workspace_id}", + json={ + "name": "e1-renamed", + "notes": "kept for review", + "tags": ["smoke", "workspace:e1"], + "archived": True, + "pinned": True, + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "e1-renamed" + assert body["notes"] == "kept for review" + assert body["tags"] == ["smoke", "workspace:e1"] + assert body["archived"] is True + assert body["pinned"] is True + # The pipeline-owned lifecycle status is untouched. + assert body["status"] == "running" + + # The change persisted -- the detail endpoint reads it back. + detail = await client.get(f"/demo/workspaces/{workspace_id}") + assert detail.status_code == 200 + assert detail.json()["name"] == "e1-renamed" + assert detail.json()["archived"] is True + + @pytest.mark.integration async def test_delete_workspace_integration_round_trip(client, db_session: AsyncSession): """DELETE removes exactly the target metadata row; a re-delete is 404.""" diff --git a/app/features/demo/tests/test_schemas.py b/app/features/demo/tests/test_schemas.py index c4e120f2..866f708c 100644 --- a/app/features/demo/tests/test_schemas.py +++ b/app/features/demo/tests/test_schemas.py @@ -13,6 +13,7 @@ WorkspaceDetailResponse, WorkspaceListItem, WorkspaceListResponse, + WorkspaceUpdateRequest, ) from app.shared.seeder.config import ScenarioPreset @@ -102,6 +103,89 @@ def test_demo_run_request_rejects_unknown_preservation(): DemoRunRequest.model_validate({"preservation": "archive"}) +# ============================================================================= +# E1 (#407) -- replayed_from_workspace_id (replay provenance) +# ============================================================================= + + +def test_demo_run_request_replayed_from_default_none(): + """E1 (#407) -- default None; a legacy frame without the key validates.""" + assert DemoRunRequest().replayed_from_workspace_id is None + legacy = DemoRunRequest.model_validate({"seed": 7}) + assert legacy.replayed_from_workspace_id is None + + +def test_demo_run_request_replayed_from_json_path(): + """E1 (#407) -- the JSON wire form (validate_python on a parsed dict, the + path FastAPI uses) accepts keep + a 32-hex lineage pointer.""" + req = DemoRunRequest.model_validate( + {"preservation": "keep", "replayed_from_workspace_id": "a" * 32} + ) + assert req.replayed_from_workspace_id == "a" * 32 + + +def test_demo_run_request_replayed_from_requires_keep(): + """E1 (#407) -- a lineage pointer without preservation='keep' is rejected.""" + with pytest.raises(ValidationError): + DemoRunRequest.model_validate({"replayed_from_workspace_id": "a" * 32}) + with pytest.raises(ValidationError): + DemoRunRequest.model_validate( + {"preservation": "ephemeral", "replayed_from_workspace_id": "a" * 32} + ) + + +def test_demo_run_request_replayed_from_pattern_rejected(): + """E1 (#407) -- values off the uuid4().hex shape are rejected.""" + for bad in ("not-hex!" + "0" * 24, "A" * 32, "a" * 31, "a" * 33): + with pytest.raises(ValidationError): + DemoRunRequest.model_validate( + {"preservation": "keep", "replayed_from_workspace_id": bad} + ) + + +# ============================================================================= +# E1 (#407) -- WorkspaceUpdateRequest (PATCH body) +# ============================================================================= + + +def test_workspace_update_request_partial_fields_set(): + """E1 (#407) -- exclude_unset distinguishes absent from explicit null.""" + cleared = WorkspaceUpdateRequest.model_validate({"notes": None}) + assert cleared.model_dump(exclude_unset=True) == {"notes": None} + empty = WorkspaceUpdateRequest.model_validate({}) + assert empty.model_dump(exclude_unset=True) == {} + + +def test_workspace_update_request_rejects_unknown_key(): + """E1 (#407) -- extra='forbid': status (and any typo) is not patchable.""" + with pytest.raises(ValidationError): + WorkspaceUpdateRequest.model_validate({"status": "archived"}) + with pytest.raises(ValidationError): + WorkspaceUpdateRequest.model_validate({"archvied": True}) + + +def test_workspace_update_request_name_pattern_and_tags_cap(): + """E1 (#407) -- name pattern + the 20-item tag cap are enforced.""" + with pytest.raises(ValidationError): + WorkspaceUpdateRequest.model_validate({"name": "Bad Name!"}) + with pytest.raises(ValidationError): + WorkspaceUpdateRequest.model_validate({"tags": [f"t{i}" for i in range(21)]}) + ok = WorkspaceUpdateRequest.model_validate({"tags": ["workspace:x", "demo"]}) + assert ok.tags == ["workspace:x", "demo"] + + +def test_workspace_update_request_rejects_explicit_null_flags(): + """E1 (#407) -- explicit null on the NOT NULL-backed fields is a 422.""" + with pytest.raises(ValidationError): + WorkspaceUpdateRequest.model_validate({"archived": None}) + with pytest.raises(ValidationError): + WorkspaceUpdateRequest.model_validate({"pinned": None}) + with pytest.raises(ValidationError): + WorkspaceUpdateRequest.model_validate({"tags": None}) + # The sanctioned clear path: an empty list, never null. + assert WorkspaceUpdateRequest.model_validate({"tags": []}).tags == [] + + def test_step_event_json_round_trip(): event = StepEvent( event_type="step_complete", @@ -266,6 +350,49 @@ def test_workspace_detail_tolerates_running_row_nulls(): assert detail.result_summary is None +def test_workspace_responses_default_e1_fields_for_pre_e1_rows(): + """E1 (#407) -- pre-E1 ORM-shaped rows (no new attrs) still validate; + the additive fields fall back to their defaults.""" + item = WorkspaceListItem.model_validate(_orm_like_workspace_row()) + assert item.archived is False + assert item.pinned is False + assert item.tags == [] + assert item.replayed_from_workspace_id is None + + detail = WorkspaceDetailResponse.model_validate(_orm_like_workspace_row()) + assert detail.notes is None + assert detail.config_schema_version == 1 + assert detail.seed_overrides is None + assert detail.user_scope is None + assert detail.approval_events is None + assert detail.rag_events is None + assert detail.job_ids is None + assert detail.phase_summaries is None + + +def test_workspace_detail_passes_e1_fields_through(): + """E1 (#407) -- populated lifecycle + slot values ride through verbatim.""" + detail = WorkspaceDetailResponse.model_validate( + _orm_like_workspace_row( + archived=True, + pinned=True, + tags=["demo", "workspace:x"], + replayed_from_workspace_id="b" * 32, + notes="kept for the quarterly review", + config_schema_version=1, + seed_overrides={"noise_sigma": 0.2}, + job_ids=["job-1", "job-2"], + ) + ) + assert detail.archived is True + assert detail.pinned is True + assert detail.tags == ["demo", "workspace:x"] + assert detail.replayed_from_workspace_id == "b" * 32 + assert detail.notes == "kept for the quarterly review" + assert detail.seed_overrides == {"noise_sigma": 0.2} + assert detail.job_ids == ["job-1", "job-2"] + + def test_workspace_list_response_shape(): """E4 (#393) -- page shape mirrors the scenarios list (items + total).""" item = WorkspaceListItem.model_validate(_orm_like_workspace_row()) diff --git a/app/features/demo/tests/test_workspace.py b/app/features/demo/tests/test_workspace.py index 0b002be3..cb28dea2 100644 --- a/app/features/demo/tests/test_workspace.py +++ b/app/features/demo/tests/test_workspace.py @@ -20,7 +20,7 @@ WORKSPACE_STATUS_RUNNING, ) from app.features.demo.pipeline import DemoContext -from app.features.demo.schemas import DemoRunRequest +from app.features.demo.schemas import DemoRunRequest, WorkspaceUpdateRequest from app.shared.seeder.config import ScenarioPreset pytestmark = pytest.mark.integration @@ -182,3 +182,134 @@ async def test_delete_workspace_removes_only_target_row(db_session: AsyncSession async def test_delete_workspace_missing_returns_false(db_session: AsyncSession) -> None: """delete_workspace returns False (no raise) for an unknown id.""" assert await workspace.delete_workspace(db_session, "0" * 32) is False + + +# ============================================================================= +# E1 (#407) -- replay provenance recording +# ============================================================================= + + +async def test_create_workspace_records_replayed_from(db_session: AsyncSession) -> None: + """create_workspace records the lineage pointer verbatim on the NEW row.""" + source_id = "a" * 32 # soft reference -- no row needs to exist + workspace_id = await workspace.create_workspace( + _keep_request(replayed_from_workspace_id=source_id) + ) + assert workspace_id is not None + + row = await workspace.get_workspace(db_session, workspace_id) + assert row is not None + assert row.replayed_from_workspace_id == source_id + + +async def test_create_workspace_without_replayed_from_is_none(db_session: AsyncSession) -> None: + """A fresh keep-run (no lineage pointer) records NULL -- legacy identical.""" + workspace_id = await workspace.create_workspace(_keep_request()) + assert workspace_id is not None + + row = await workspace.get_workspace(db_session, workspace_id) + assert row is not None + assert row.replayed_from_workspace_id is None + assert row.archived is False + assert row.pinned is False + assert row.tags == [] + assert row.config_schema_version == 1 + + +# ============================================================================= +# E1 (#407) -- update_workspace (PATCH helper) +# ============================================================================= + + +async def test_update_workspace_partial_leaves_other_fields(db_session: AsyncSession) -> None: + """Only provided fields change; everything else is untouched.""" + workspace_id = await workspace.create_workspace(_keep_request(workspace_name="it-upd")) + assert workspace_id is not None + + row = await workspace.update_workspace( + db_session, + workspace_id, + WorkspaceUpdateRequest.model_validate({"name": "it-upd-renamed", "pinned": True}), + ) + assert row is not None + assert row.name == "it-upd-renamed" + assert row.pinned is True + # Untouched fields keep their values. + assert row.archived is False + assert row.notes is None + assert row.tags == [] + assert row.seed == 7 + assert row.status == WORKSPACE_STATUS_RUNNING + + +async def test_update_workspace_explicit_null_clears_name(db_session: AsyncSession) -> None: + """An explicit null clears name/notes (exclude_unset keeps it in changes).""" + workspace_id = await workspace.create_workspace(_keep_request(workspace_name="it-clear")) + assert workspace_id is not None + await workspace.update_workspace( + db_session, + workspace_id, + WorkspaceUpdateRequest.model_validate({"notes": "temporary"}), + ) + + row = await workspace.update_workspace( + db_session, + workspace_id, + WorkspaceUpdateRequest.model_validate({"name": None, "notes": None}), + ) + assert row is not None + assert row.name is None + assert row.notes is None + + +async def test_update_workspace_tags_replaced_whole(db_session: AsyncSession) -> None: + """tags is replaced as a whole list (never merged); [] clears it.""" + workspace_id = await workspace.create_workspace(_keep_request(workspace_name="it-tags")) + assert workspace_id is not None + + row = await workspace.update_workspace( + db_session, + workspace_id, + WorkspaceUpdateRequest.model_validate({"tags": ["a", "b"]}), + ) + assert row is not None + assert row.tags == ["a", "b"] + + row = await workspace.update_workspace( + db_session, + workspace_id, + WorkspaceUpdateRequest.model_validate({"tags": ["c"]}), + ) + assert row is not None + assert row.tags == ["c"] # replaced, not merged + + row = await workspace.update_workspace( + db_session, + workspace_id, + WorkspaceUpdateRequest.model_validate({"tags": []}), + ) + assert row is not None + assert row.tags == [] + + +async def test_update_workspace_missing_returns_none(db_session: AsyncSession) -> None: + """update_workspace returns None for an unknown id (route maps to 404).""" + result = await workspace.update_workspace( + db_session, + "0" * 32, + WorkspaceUpdateRequest.model_validate({"pinned": True}), + ) + assert result is None + + +async def test_update_workspace_empty_request_noop(db_session: AsyncSession) -> None: + """An empty request is a no-op that still returns the row.""" + workspace_id = await workspace.create_workspace(_keep_request(workspace_name="it-noop")) + assert workspace_id is not None + + row = await workspace.update_workspace( + db_session, workspace_id, WorkspaceUpdateRequest.model_validate({}) + ) + assert row is not None + assert row.name == "it-noop" + assert row.status == WORKSPACE_STATUS_RUNNING diff --git a/app/features/demo/workspace.py b/app/features/demo/workspace.py index b0e65dad..0af35a50 100644 --- a/app/features/demo/workspace.py +++ b/app/features/demo/workspace.py @@ -15,7 +15,10 @@ :func:`get_workspace` / :func:`list_workspaces` / :func:`count_workspaces` are routed since E4 (epic #393) by ``GET /demo/workspaces`` and ``GET /demo/workspaces/{workspace_id}`` in ``app/features/demo/routes.py``; -:func:`delete_workspace` backs ``DELETE /demo/workspaces/{workspace_id}``. +: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. """ from __future__ import annotations @@ -33,7 +36,7 @@ WORKSPACE_STATUS_FAILED, ShowcaseWorkspace, ) -from app.features.demo.schemas import DemoRunRequest +from app.features.demo.schemas import DemoRunRequest, WorkspaceUpdateRequest if TYPE_CHECKING: # NOTE: pipeline imports this module at runtime; importing DemoContext @@ -65,6 +68,9 @@ async def create_workspace(req: DemoRunRequest) -> str | None: scenario=req.scenario.value, reset=req.reset, skip_seed=req.skip_seed, + # E1 (#407): replay provenance, recorded verbatim (soft + # reference -- no existence check; dangles are designed). + replayed_from_workspace_id=req.replayed_from_workspace_id, ) ) await db.commit() @@ -171,6 +177,41 @@ async def get_workspace(db: AsyncSession, workspace_id: str) -> ShowcaseWorkspac return result.scalar_one_or_none() +async def update_workspace( + db: AsyncSession, + workspace_id: str, + update: WorkspaceUpdateRequest, +) -> ShowcaseWorkspace | None: + """Apply a partial lifecycle update; return the row or ``None`` when missing. + + ``exclude_unset`` distinguishes absent fields from explicit ``null`` -- + only fields present in the request body are applied (explicit ``null`` + clears ``name`` / ``notes``; the schema rejects ``null`` on the NOT NULL + columns). JSONB values are assigned WHOLE (never mutated in place) so + SQLAlchemy change detection fires. An empty request is a no-op that still + returns the row. + + Args: + db: An open async session (caller-owned; this backs an HTTP route, + NOT the pipeline -- it raises normally, no warn-and-continue). + workspace_id: The external id of the row to update. + update: The validated partial-update request. + + Returns: + The updated row, or ``None`` when no row matched (route maps to 404). + """ + row = await get_workspace(db, workspace_id) + if row is None: + return None + changes = update.model_dump(exclude_unset=True) # absent != explicit null + for field, value in changes.items(): + setattr(row, field, value) # whole-value assignment (JSONB gotcha) + await db.commit() + await db.refresh(row) + logger.info("demo.workspace_updated", workspace_id=workspace_id, fields=sorted(changes)) + return row + + async def list_workspaces( db: AsyncSession, *, From e26de84e5aec87898f0b0b5b5c9d2955dc6dd072 Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Sat, 13 Jun 2026 00:12:52 +0200 Subject: [PATCH 3/4] feat(ui): send replayed_from_workspace_id on showcase replay (#407) --- frontend/src/pages/showcase.tsx | 2 ++ frontend/src/types/api.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/frontend/src/pages/showcase.tsx b/frontend/src/pages/showcase.tsx index b7eb4444..9643de1a 100644 --- a/frontend/src/pages/showcase.tsx +++ b/frontend/src/pages/showcase.tsx @@ -181,6 +181,8 @@ export default function ShowcasePage() { 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/types/api.ts b/frontend/src/types/api.ts index 93de98cc..1232e991 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -785,6 +785,8 @@ export interface DemoRunRequest { // Omit both to keep the legacy ephemeral behavior byte-identical. preservation?: 'ephemeral' | 'keep' workspace_name?: string + // E1 (#407) — replay provenance: the source workspace_id a Replay re-runs. + replayed_from_workspace_id?: string } // Aggregate result returned by the synchronous POST /demo/run. From 493a9a436cce5810e68d0053f7cb23d494edf95b Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Sat, 13 Jun 2026 00:12:52 +0200 Subject: [PATCH 4/4] docs(docs): document workspace story slots and patch contract (#407) --- docs/_base/API_CONTRACTS.md | 11 ++++++----- docs/_base/DOMAIN_MODEL.md | 15 ++++++++++++--- docs/_base/RUNBOOKS.md | 2 +- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/docs/_base/API_CONTRACTS.md b/docs/_base/API_CONTRACTS.md index d307d2a9..47bc7b6e 100644 --- a/docs/_base/API_CONTRACTS.md +++ b/docs/_base/API_CONTRACTS.md @@ -58,10 +58,11 @@ All endpoints serve JSON; error responses use `application/problem+json` (RFC 78 | agents | WS | `/agents/stream` | Token-by-token streaming + tool-call events | | seeder | (see `app/features/seeder/routes.py`) | `/seeder/*` | Trigger scenarios, status, customization | | seeder | POST | `/seeder/phase2-enrichment` | PRP-38 — run Phase 2 generators (lifecycle, replenishment, exogenous, returns) against the existing seeded data. `422 application/problem+json` on an empty database. | -| demo | POST | `/demo/run` | Run the end-to-end demo pipeline in-process; returns a `DemoRunResult`. `409 application/problem+json` if a run is already active. **PRP-38** — body accepts an Optional `scenario: 'demo_minimal' \| 'showcase_rich' \| 'sparse'` field; default `'demo_minimal'` (back-compat). **E1 (#390)** — body accepts additive Optional `preservation: 'ephemeral' \| 'keep'` (default `'ephemeral'`, today's no-row behavior) and `workspace_name: str \| null` (pattern `^[a-z0-9][a-z0-9\-_]*$`, ≤100 chars); `workspace_name` without `preservation='keep'` → `422 application/problem+json`. `preservation='keep'` records the run as a `showcase_workspace` row; `DemoRunResult` gains an additive Optional `workspace_id: str \| null`. **E2 (#391)** — `scenario` accepts all 8 `ScenarioPreset` values (`retail_standard` / `holiday_rush` / `high_variance` / `stockout_heavy` / `new_launches` / `sparse` / `demo_minimal` / `showcase_rich`); only `showcase_rich` changes the step table (24 rows), every other preset runs the legacy 11-row flow. | +| demo | 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 | -| 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 | +| 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/{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 | 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 | | config | PATCH | `/config/ai` | Persist + apply AI-model changes live (no restart). `409` if an embedding-dimension change would orphan indexed RAG chunks (resend with `force=true`) | @@ -86,7 +87,7 @@ Verified against `app/features/agents/websocket.py` and `app/features/agents/sch Drives the end-to-end demo pipeline for the dashboard Showcase page. Verified against `app/features/demo/routes.py` and `app/features/demo/schemas.py` (`StepEvent`). -- **Client → server (one start frame):** `{"seed": int, "reset": bool, "skip_seed": bool, "scenario"?: "demo_minimal" | "showcase_rich" | "sparse", "preservation"?: "ephemeral" | "keep", "workspace_name"?: str}` — all fields optional (`DemoRunRequest` supplies defaults `seed=42`, `reset=false`, `skip_seed=true`, `scenario="demo_minimal"`, `preservation="ephemeral"`, `workspace_name=null`). E1 (#390) — `workspace_name` requires `preservation="keep"` (else one `error` event from validation); unknown start-frame keys remain ignored (forward/backward compat). 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. The pipeline runs once, then the server closes. +- **Client → server (one start frame):** `{"seed": int, "reset": bool, "skip_seed": bool, "scenario"?: "demo_minimal" | "showcase_rich" | "sparse", "preservation"?: "ephemeral" | "keep", "workspace_name"?: str}` — all fields optional (`DemoRunRequest` supplies defaults `seed=42`, `reset=false`, `skip_seed=true`, `scenario="demo_minimal"`, `preservation="ephemeral"`, `workspace_name=null`). E1 (#390) — `workspace_name` requires `preservation="keep"` (else one `error` event from validation); unknown start-frame keys remain ignored (forward/backward compat). 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) — the start frame additively accepts `replayed_from_workspace_id?: str` (`^[0-9a-f]{32}$`, requires `preservation="keep"` else one `error` event from validation); the Showcase Replay button sends the source row's `workspace_id`, recorded verbatim on the NEW row as a soft reference. The pipeline runs once, then the server closes. - **Server → client (every frame):** Pydantic-serialized `StepEvent` — `{"event_type", "step_name", "step_index", "total_steps", "status", "detail", "duration_ms", "data", "timestamp", "phase_name"?, "phase_index"?, "phase_total"?}`. PRP-38 — the three `phase_*` fields are Optional + Nullable so legacy clients that don't render phases keep working. - **`event_type` values (Literal in `StepEvent`):** - `step_start` — a step began; `status` is `null`. @@ -97,7 +98,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: