Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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")
88 changes: 87 additions & 1 deletion app/features/demo/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
"""
Expand All @@ -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

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"),
)
37 changes: 37 additions & 0 deletions app/features/demo/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -41,6 +43,7 @@
WorkspaceDetailResponse,
WorkspaceListItem,
WorkspaceListResponse,
WorkspaceUpdateRequest,
)

logger = get_logger(__name__)
Expand Down Expand Up @@ -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,
Expand Down
Loading