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
44 changes: 41 additions & 3 deletions app/features/demo/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
Exposes:
- ``POST /demo/run`` -- synchronous; runs the whole pipeline, returns a result.
- ``WS /demo/stream`` -- streams one StepEvent per step for the live UI.
- ``GET /demo/workspaces`` -- E4 (#393): list saved workspaces.
- ``GET /demo/workspaces/{workspace_id}`` -- E4 (#393): one workspace's detail.
- ``GET /demo/workspaces`` -- E4 (#393): list saved workspaces.
- ``GET /demo/workspaces/{workspace_id}`` -- E4 (#393): one workspace's detail.
- ``DELETE /demo/workspaces/{workspace_id}`` -- delete the workspace METADATA
row only; the run's created objects are soft references and stay untouched.

The run/stream handlers obtain the live FastAPI app from ``request.app`` /
``websocket.app`` and pass it into the pipeline -- the slice never imports
Expand All @@ -16,7 +18,15 @@

import json

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

Expand Down Expand Up @@ -125,6 +135,34 @@ async def get_showcase_workspace(
return WorkspaceDetailResponse.model_validate(row)


@router.delete(
"/workspaces/{workspace_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a saved showcase workspace",
description=(
"Delete one saved workspace METADATA row. Everything the run created "
"(model runs, scenario plans, aliases, jobs, artifacts) is a soft "
"reference and is NOT deleted."
),
)
async def delete_showcase_workspace(
workspace_id: str,
db: AsyncSession = Depends(get_db),
) -> None:
"""Delete a saved showcase workspace metadata row.

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

Raises:
NotFoundError: When no workspace matches ``workspace_id``.
"""
deleted = await workspace.delete_workspace(db, workspace_id)
if not deleted:
raise NotFoundError(message=f"Workspace not found: {workspace_id}")


@router.websocket("/stream")
async def stream_demo_pipeline(websocket: WebSocket) -> None:
"""Stream one StepEvent per pipeline step over a WebSocket.
Expand Down
96 changes: 96 additions & 0 deletions app/features/demo/tests/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,41 @@ async def fake_get(_db, workspace_id: str) -> SimpleNamespace:
assert body["date_end"] == "2026-03-31"


# =============================================================================
# DELETE /demo/workspaces/{workspace_id} (unit)
# =============================================================================


async def test_delete_workspace_204(client, monkeypatch):
"""A deleted workspace row yields 204 with an empty body."""
seen: dict[str, str] = {}

async def fake_delete(_db, workspace_id: str) -> bool:
seen["workspace_id"] = workspace_id
return True

monkeypatch.setattr(workspace, "delete_workspace", fake_delete)

resp = await client.delete("/demo/workspaces/" + "c" * 32)
assert resp.status_code == 204
assert resp.content == b""
assert seen["workspace_id"] == "c" * 32


async def test_delete_workspace_404(client, monkeypatch):
"""An unknown workspace_id is a 404 problem+json."""

async def fake_delete(_db, _workspace_id: str) -> bool:
return False

monkeypatch.setattr(workspace, "delete_workspace", fake_delete)

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


# =============================================================================
# E4 (#393) -- workspace GET routes against real Postgres (integration)
# =============================================================================
Expand Down Expand Up @@ -368,3 +403,64 @@ async def test_get_workspace_integration_round_trip(client, db_session: AsyncSes
missing = await client.get("/demo/workspaces/" + "f" * 32)
assert missing.status_code == 404
assert missing.headers["content-type"].startswith("application/problem+json")


@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."""
kept = await workspace.create_workspace(
DemoRunRequest.model_validate({"preservation": "keep", "workspace_name": "del-kept"})
)
target = await workspace.create_workspace(
DemoRunRequest.model_validate({"preservation": "keep", "workspace_name": "del-target"})
)
assert kept is not None and target is not None

resp = await client.delete(f"/demo/workspaces/{target}")
assert resp.status_code == 204

# The deleted row is gone; the sibling row is untouched.
assert (await client.get(f"/demo/workspaces/{target}")).status_code == 404
survivors = await client.get("/demo/workspaces")
assert [w["workspace_id"] for w in survivors.json()["workspaces"]] == [kept]

# Deleting again is a 404 problem+json (no idempotent 204).
again = await client.delete(f"/demo/workspaces/{target}")
assert again.status_code == 404
assert again.headers["content-type"].startswith("application/problem+json")


@pytest.mark.integration
async def test_delete_workspace_integration_keeps_created_objects(client, db_session: AsyncSession):
"""Deleting a workspace never deletes (or resolves) its soft references.

The workspace references one REAL cross-slice object (an agent session)
plus one dangling run id -- the delete must succeed without touching the
former or resolving the latter (no-FK soft-reference contract).
"""
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": "del-softref"})
)
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.delete(f"/demo/workspaces/{workspace_id}")
assert resp.status_code == 204

# The metadata row is gone...
assert (await client.get(f"/demo/workspaces/{workspace_id}")).status_code == 404
# ...but the soft-referenced agent session still exists.
still_there = await client.get(f"/agents/sessions/{agent_session_id}")
assert still_there.status_code == 200
finally:
await client.delete(f"/agents/sessions/{agent_session_id}")
18 changes: 18 additions & 0 deletions app/features/demo/tests/test_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,21 @@ async def test_list_workspaces_newest_first_limit_offset(db_session: AsyncSessio
async def test_get_workspace_missing_returns_none(db_session: AsyncSession) -> None:
"""get_workspace returns None for an unknown id."""
assert await workspace.get_workspace(db_session, "0" * 32) is None


async def test_delete_workspace_removes_only_target_row(db_session: AsyncSession) -> None:
"""delete_workspace removes exactly the matching metadata row."""
id_a = await workspace.create_workspace(_keep_request(workspace_name="it-del-a"))
id_b = await workspace.create_workspace(_keep_request(workspace_name="it-del-b"))
assert id_a is not None and id_b is not None

assert await workspace.delete_workspace(db_session, id_a) is True

assert await workspace.get_workspace(db_session, id_a) is None
remaining = await workspace.list_workspaces(db_session)
assert [r.workspace_id for r in remaining] == [id_b]


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
28 changes: 27 additions & 1 deletion app/features/demo/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@

: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``.
``GET /demo/workspaces/{workspace_id}`` in ``app/features/demo/routes.py``;
:func:`delete_workspace` backs ``DELETE /demo/workspaces/{workspace_id}``.
"""

from __future__ import annotations
Expand Down Expand Up @@ -195,6 +196,31 @@ async def list_workspaces(
return list(result.scalars().all())


async def delete_workspace(db: AsyncSession, workspace_id: str) -> bool:
"""Delete a workspace METADATA row; return ``True`` when a row was removed.

Deletes ONLY the ``showcase_workspace`` row. Everything the run created --
model runs, scenario plans, aliases, jobs, agent sessions, artifacts -- is
carried as OPAQUE SOFT REFERENCES in ``created_objects`` (no ForeignKeys
by design, see ``app/features/demo/models.py``) and is deliberately left
untouched: the workspace is an audit record, never an ownership root.

Args:
db: An open async session (caller-owned).
workspace_id: The external id of the row to delete.

Returns:
``True`` when a row was deleted, ``False`` when none matched.
"""
row = await get_workspace(db, workspace_id)
if row is None:
return False
await db.delete(row)
await db.commit()
logger.info("demo.workspace_deleted", workspace_id=workspace_id)
return True


async def count_workspaces(db: AsyncSession) -> int:
"""Count all workspace rows (E4, issue #393).

Expand Down
1 change: 1 addition & 0 deletions docs/_base/API_CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ All endpoints serve JSON; error responses use `application/problem+json` (RFC 78
| 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 | 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`) |
| config | GET | `/config/providers/health` | Per-provider connectivity — Ollama probed live, cloud providers by API-key presence |
Expand Down
6 changes: 5 additions & 1 deletion docs/_base/DOMAIN_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,18 @@
- An agent-saved plan (`source='agent'`) is persisted ONLY after the human approves it through the HITL gate — it always carries the approval audit trail.

### `showcase_workspace` (Demo)
- **Root:** `ShowcaseWorkspace(workspace_id: str, status: str)` — one row = one preserved (`preservation="keep"`) showcase run.
- **Root:** `ShowcaseWorkspace(workspace_id: str, status: str)` — one row = one preserved (`preservation="keep"`) showcase run. Ephemeral runs (the default) write no row; a `workspace_name` merely labels a keep-run row (names are non-unique).
- **Status state machine:** `running` → `completed` | `failed` (CHECK-constrained; the finalize hook settles the row even on mid-run failure).
- **Stored metadata:** replay config (`seed`, `scenario`, `reset`, `skip_seed`), showcase grain + window (`store_id`, `product_id`, `date_start`, `date_end` — NULL on early failure), lifecycle (`status`, `created_at`/`updated_at`), and the two JSONB payloads below.
- **JSONB fields:** `created_objects` (sparse soft-reference keys — `winning_run_id`, `v2_run_id`, `v2_model_path`, `alias`, `agent_session_id`, `batch_id`, `scenario_plan_ids`, `scenario_artifact_key`, `train_model_types`, `stale_alias_run_id`) and `result_summary` (winner / WAPE / wall-clock display payload).
- **Relationship to demo pipeline runs:** one workspace row per kept pipeline run — `create_workspace` inserts it as `running` before the first step; `finalize_workspace` settles it with the run's collected ids. NOT a seeder `scenario`: a preset is a reusable data-generation recipe; a workspace is the record of ONE concrete run (which preset it used, with what seed, and what it produced).
- **Invariants:**
- The config columns (`seed`, `scenario`, `reset`, `skip_seed`) are sufficient for a verbatim Replay through the normal run path — replay never mutates the original row; it creates a NEW row.
- `name` is deliberately NON-unique; `workspace_id` (UUID hex) is the unique handle.
- `created_objects` carries SOFT references only — **no ForeignKeys by design**. The workspace row is an audit record, not an ownership root: the referenced runs/plans/aliases are independently operator-deletable, and a workspace must never block (or cascade) their deletion.
- Deletion is METADATA-ONLY, symmetric with the no-FK design: `DELETE /demo/workspaces/{id}` removes the `showcase_workspace` row and nothing else — the soft-referenced model runs, scenario plans, aliases, jobs, agent sessions, and artifacts survive, and a workspace whose references already dangle still deletes cleanly.
- Persistence is warn-and-continue: a workspace write failure must never break the demo pipeline (the run completes with `workspace_id: null`).
- **Out of scope (deliberately not modeled yet):** a `replayed_from` provenance column, export bundles under `artifacts/showcase/<workspace>/`, RAG-event / approval-decision capture, advanced seed config, and per-phase interactive configuration — see `docs/_base/RUNBOOKS.md` § Showcase workspace.

## Key Invariants — NEVER violate

Expand Down
Loading