From 890675bc3c9a3d48e234b7e44ae1cd6e73aaa213 Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Sat, 13 Jun 2026 01:47:34 +0200 Subject: [PATCH 1/5] feat(data): add allow-listed nested seed overrides to seeder contract (#409) --- app/features/seeder/schemas.py | 12 +++ app/features/seeder/service.py | 47 +++++++++ app/features/seeder/tests/test_routes.py | 46 +++++++++ app/features/seeder/tests/test_service.py | 116 ++++++++++++++++++++++ app/shared/seeder/overrides.py | 90 +++++++++++++++++ app/shared/seeder/tests/test_overrides.py | 95 ++++++++++++++++++ 6 files changed, 406 insertions(+) create mode 100644 app/shared/seeder/overrides.py create mode 100644 app/shared/seeder/tests/test_overrides.py diff --git a/app/features/seeder/schemas.py b/app/features/seeder/schemas.py index 20a22dc3..42b697c1 100644 --- a/app/features/seeder/schemas.py +++ b/app/features/seeder/schemas.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from app.shared.seeder.config import default_seed_end_date, default_seed_start_date +from app.shared.seeder.overrides import SeederOverrides VALID_CHANNELS: frozenset[str] = frozenset({"in_store", "online", "click_collect", "wholesale"}) """Allow-list for ``sales_daily.channel`` — mirrors the SQL CHECK.""" @@ -257,6 +258,17 @@ class GenerateParams(BaseModel): ), ) + # E3 (#409) — curated nested overrides. Absent field = byte-identical + # legacy behavior (the same promise as the Phase 1/2 blocks above). + overrides: SeederOverrides | None = Field( + default=None, + description=( + "Curated nested overrides (E3 #409); applied LAST — wins over the " + "scalar stores/products/sparsity. Unknown knobs are rejected " + "(extra=forbid). Absent = byte-identical legacy behavior." + ), + ) + @model_validator(mode="after") def _validate_date_range(self) -> "GenerateParams": """Reject inverted date ranges with a clear message.""" diff --git a/app/features/seeder/service.py b/app/features/seeder/service.py index 87b20709..341afdef 100644 --- a/app/features/seeder/service.py +++ b/app/features/seeder/service.py @@ -52,6 +52,7 @@ from app.shared.seeder.generators.lifecycle import LifecycleGenerator from app.shared.seeder.generators.replenishment import ReplenishmentGenerator from app.shared.seeder.generators.returns import ReturnsGenerator +from app.shared.seeder.overrides import SeederOverrides logger = get_logger(__name__) @@ -199,6 +200,49 @@ def _apply_phase2_overrides(config: SeederConfig, params: schemas.GenerateParams ) +def _apply_seed_overrides(config: SeederConfig, overrides: SeederOverrides | None) -> None: + """Apply the curated nested overrides LAST -- wins over scalar params (E3, #409). + + Mutates ``config`` in place (the ``_apply_phaseN_overrides`` pattern). + ``dataclasses.replace`` is field-precise: preset-customized sibling fields + (region/category lists, ``random_gaps_*``) survive every knob. ``None`` + (or an all-``None`` object) is a no-op so legacy bodies stay + byte-identical. + """ + if overrides is None: + return + if overrides.stores is not None or overrides.products is not None: + config.dimensions = replace( + config.dimensions, + stores=overrides.stores if overrides.stores is not None else config.dimensions.stores, + products=( + overrides.products if overrides.products is not None else config.dimensions.products + ), + ) + if overrides.window_days is not None: + # Recompute the window length from the (scalar-or-default) end_date; + # end_date itself is untouched. + config.start_date = config.end_date - timedelta(days=overrides.window_days) + if overrides.sparsity is not None: + config.sparsity = replace(config.sparsity, missing_combinations_pct=overrides.sparsity) + if overrides.promotion_intensity is not None or overrides.stockout_intensity is not None: + config.retail = replace( + config.retail, + promotion_probability=( + overrides.promotion_intensity + if overrides.promotion_intensity is not None + else config.retail.promotion_probability + ), + stockout_probability=( + overrides.stockout_intensity + if overrides.stockout_intensity is not None + else config.retail.stockout_probability + ), + ) + if overrides.noise_sigma is not None: + config.time_series = replace(config.time_series, noise_sigma=overrides.noise_sigma) + + def _build_config_from_params(params: schemas.GenerateParams) -> SeederConfig: """Build SeederConfig from API parameters. @@ -239,6 +283,9 @@ def _build_config_from_params(params: schemas.GenerateParams) -> SeederConfig: _apply_phase1_overrides(config, params) _apply_phase2_overrides(config, params) + # E3 (#409) — the curated nested overrides apply LAST so they win over + # the scalar stores/products/sparsity params above. + _apply_seed_overrides(config, params.overrides) settings = get_settings() config.batch_size = settings.seeder_batch_size diff --git a/app/features/seeder/tests/test_routes.py b/app/features/seeder/tests/test_routes.py index f1733142..7da2e947 100644 --- a/app/features/seeder/tests/test_routes.py +++ b/app/features/seeder/tests/test_routes.py @@ -163,6 +163,52 @@ def test_generate_validation_error(self, client, mock_settings): assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + def test_generate_with_overrides(self, client, mock_settings, mock_db): + """E3 (#409) — the nested overrides object is accepted (201).""" + mock_result = schemas.GenerateResult( + success=True, + records_created={"stores": 8, "products": 20, "sales": 5000}, + duration_seconds=12.0, + message="Success", + seed=42, + ) + + with patch( + "app.features.seeder.routes.service.generate_data", return_value=mock_result + ) as mock_generate: + response = client.post( + "/seeder/generate", + json={ + "scenario": "demo_minimal", + "overrides": {"stores": 8, "promotion_intensity": 0.3}, + }, + ) + + assert response.status_code == status.HTTP_201_CREATED + # The validated params object carries the parsed nested model. + params = mock_generate.call_args.args[1] + assert params.overrides is not None + assert params.overrides.stores == 8 + assert params.overrides.promotion_intensity == 0.3 + + def test_generate_overrides_out_of_bounds_rejected(self, client, mock_settings): + """E3 (#409) — an out-of-bounds knob is a 422.""" + response = client.post( + "/seeder/generate", + json={"overrides": {"stores": 0}}, + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + def test_generate_overrides_unknown_knob_rejected(self, client, mock_settings): + """E3 (#409) — extra='forbid' rejects knobs outside the allow-list.""" + response = client.post( + "/seeder/generate", + json={"overrides": {"bogus_knob": 1}}, + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + def test_generate_blocked_in_production(self, client, mock_db): """Test generate is blocked in production.""" with patch("app.features.seeder.routes.get_settings") as mock_settings: diff --git a/app/features/seeder/tests/test_service.py b/app/features/seeder/tests/test_service.py index f21aa28b..7a29058d 100644 --- a/app/features/seeder/tests/test_service.py +++ b/app/features/seeder/tests/test_service.py @@ -7,6 +7,7 @@ from app.features.seeder import schemas, service from app.shared.seeder.config import DEMO_MINIMAL_SPAN_DAYS, default_seed_end_date +from app.shared.seeder.overrides import SeederOverrides class TestListScenarios: @@ -198,6 +199,121 @@ def test_custom_scenario_preserves_holiday_list(self): assert config.time_series.monthly_seasonality == {10: 1.0, 11: 1.3, 12: 1.8} +class TestApplySeedOverrides: + """Tests for the E3 (#409) curated nested overrides layer.""" + + def test_each_knob_maps_to_its_config_field(self): + """Every knob lands on the documented SeederConfig target.""" + params = schemas.GenerateParams( + scenario="demo_minimal", + overrides=SeederOverrides( + stores=8, + products=20, + sparsity=0.3, + promotion_intensity=0.3, + stockout_intensity=0.1, + noise_sigma=0.25, + ), + ) + config = service._build_config_from_params(params) + + assert config.dimensions.stores == 8 + assert config.dimensions.products == 20 + assert config.sparsity.missing_combinations_pct == 0.3 + assert config.retail.promotion_probability == 0.3 + assert config.retail.stockout_probability == 0.1 + assert config.time_series.noise_sigma == 0.25 + + def test_overrides_win_over_scalar_params(self): + """Nested overrides apply LAST and beat the legacy scalar params.""" + params = schemas.GenerateParams( + scenario="demo_minimal", + stores=3, + products=10, + sparsity=0.5, + overrides=SeederOverrides(stores=8, products=20, sparsity=0.2), + ) + config = service._build_config_from_params(params) + + assert config.dimensions.stores == 8 + assert config.dimensions.products == 20 + assert config.sparsity.missing_combinations_pct == 0.2 + + def test_window_days_recomputes_start_from_end(self): + """window_days derives start_date from the request's end_date.""" + params = schemas.GenerateParams( + scenario="demo_minimal", + start_date=date(2025, 1, 1), + end_date=date(2025, 6, 30), + overrides=SeederOverrides(window_days=120), + ) + config = service._build_config_from_params(params) + + assert config.end_date == date(2025, 6, 30) + assert config.start_date == date(2025, 6, 30) - timedelta(days=120) + + def test_sparse_preset_gap_character_survives_sparsity_override(self): + """dataclasses.replace preserves the preset's random_gaps_* siblings.""" + baseline = service._build_config_from_params(schemas.GenerateParams(scenario="sparse")) + assert baseline.sparsity.random_gaps_per_series > 0 # preset character + + params = schemas.GenerateParams( + scenario="sparse", + overrides=SeederOverrides(sparsity=0.2), + ) + config = service._build_config_from_params(params) + + assert config.sparsity.missing_combinations_pct == 0.2 + assert config.sparsity.random_gaps_per_series == baseline.sparsity.random_gaps_per_series + assert config.sparsity.gap_min_days == baseline.sparsity.gap_min_days + assert config.sparsity.gap_max_days == baseline.sparsity.gap_max_days + + def test_partial_overrides_leave_other_fields_untouched(self): + """Setting one retail knob preserves the preset's other retail fields.""" + baseline = service._build_config_from_params( + schemas.GenerateParams(scenario="stockout_heavy") + ) + params = schemas.GenerateParams( + scenario="stockout_heavy", + overrides=SeederOverrides(promotion_intensity=0.4), + ) + config = service._build_config_from_params(params) + + assert config.retail.promotion_probability == 0.4 + assert config.retail.stockout_probability == baseline.retail.stockout_probability + assert config.retail.promotion_lift == baseline.retail.promotion_lift + + def test_no_overrides_is_byte_identical_regression(self): + """A body without overrides produces the exact config it does today.""" + + def _params(**extra: object) -> schemas.GenerateParams: + body: dict[str, object] = { + "scenario": "demo_minimal", + "seed": 42, + "stores": 3, + "products": 10, + "start_date": "2025-01-01", + "end_date": "2025-03-31", + "sparsity": 0.0, + } + body.update(extra) + return schemas.GenerateParams.model_validate(body) + + legacy = service._build_config_from_params(_params()) + with_none = service._build_config_from_params(_params(overrides=None)) + + assert legacy == with_none + + def test_empty_overrides_object_is_noop(self): + """An all-None overrides object changes nothing.""" + base = service._build_config_from_params(schemas.GenerateParams(scenario="demo_minimal")) + with_empty = service._build_config_from_params( + schemas.GenerateParams(scenario="demo_minimal", overrides=SeederOverrides()) + ) + + assert base == with_empty + + class TestGetStatus: """Tests for get_status function.""" diff --git a/app/shared/seeder/overrides.py b/app/shared/seeder/overrides.py new file mode 100644 index 00000000..11d8ed9f --- /dev/null +++ b/app/shared/seeder/overrides.py @@ -0,0 +1,90 @@ +"""Curated, allow-listed seed-override schema (E3, issue #409). + +Shared between the seeder slice (``GenerateParams.overrides``) and the demo +slice (``DemoRunRequest.seed_overrides``) -- ``app/shared`` is the sanctioned +cross-slice home (vertical-slice rule; precedent: ``ScenarioPreset`` is +imported by both slices from ``app.shared.seeder.config``). + +``extra="forbid"`` IS the allow-list: any knob not listed here is a 422 at +the HTTP boundary (umbrella #406 risk mitigation -- the seeder's full 25+ +knob surface stays preset-driven; only these 7 curated knobs are exposed). +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class SeederOverrides(BaseModel): + """The 7 curated seed knobs, applied LAST in ``_build_config_from_params``. + + Precedence: preset -> scalar ``stores``/``products``/``sparsity`` params -> + phase 1/2 overrides -> THIS object (wins). Each knob maps onto one + ``SeederConfig`` sub-dataclass field via ``dataclasses.replace`` so + preset-customized sibling fields survive. + """ + + # strict=True catches JSON-native coercion bugs ("5" -> 5); every field is + # int/float so no Field(strict=False) override is needed (see + # docs/_base/SECURITY.md -> "Pydantic v2 strict mode"). + model_config = ConfigDict(strict=True, extra="forbid") + + stores: int | None = Field( + default=None, + ge=1, + le=100, + description=("Store count -> DimensionConfig.stores; wins over the scalar `stores` param."), + ) + products: int | None = Field( + default=None, + ge=1, + le=500, + description=( + "Product count -> DimensionConfig.products; wins over the scalar `products` param." + ), + ) + window_days: int | None = Field( + default=None, + ge=75, + le=365, + description=( + "Seeded window length; start_date = end_date - window_days. >=75 keeps " + "the showcase historical_backfill gate clear. Rejected on the " + "calendar-pinned holiday_rush preset (demo surface)." + ), + ) + sparsity: float | None = Field( + default=None, + ge=0.0, + le=0.9, + description=( + "Missing (store,product) grain fraction -> " + "SparsityConfig.missing_combinations_pct; preserves the preset's gap " + "config. 1.0 disallowed (would seed zero series)." + ), + ) + promotion_intensity: float | None = Field( + default=None, + ge=0.0, + le=0.5, + description="-> RetailPatternConfig.promotion_probability (preset max 0.25).", + ) + stockout_intensity: float | None = Field( + default=None, + ge=0.0, + le=0.5, + description=( + "-> RetailPatternConfig.stockout_probability. High values can " + "legitimately NaN-WAPE-fail the backtest (documented expected outcome)." + ), + ) + noise_sigma: float | None = Field( + default=None, + ge=0.0, + le=0.5, + description="-> TimeSeriesConfig.noise_sigma (preset max 0.4).", + ) + + def is_empty(self) -> bool: + """True when no knob is set (``{}`` on the wire) -- treated as None everywhere.""" + return not self.model_dump(exclude_none=True) diff --git a/app/shared/seeder/tests/test_overrides.py b/app/shared/seeder/tests/test_overrides.py new file mode 100644 index 00000000..06ad6034 --- /dev/null +++ b/app/shared/seeder/tests/test_overrides.py @@ -0,0 +1,95 @@ +"""Unit tests for the curated SeederOverrides allow-list model (E3, #409).""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from app.shared.seeder.overrides import SeederOverrides + + +class TestBounds: + """Each knob rejects out-of-bounds values at both edges.""" + + @pytest.mark.parametrize( + ("knob", "low", "high"), + [ + ("stores", 0, 101), + ("products", 0, 501), + ("window_days", 74, 366), + ], + ) + def test_int_knob_bounds(self, knob: str, low: int, high: int) -> None: + with pytest.raises(ValidationError): + SeederOverrides.model_validate({knob: low}) + with pytest.raises(ValidationError): + SeederOverrides.model_validate({knob: high}) + + @pytest.mark.parametrize( + ("knob", "low", "high"), + [ + ("sparsity", -0.1, 0.91), + ("promotion_intensity", -0.1, 0.51), + ("stockout_intensity", -0.1, 0.51), + ("noise_sigma", -0.1, 0.51), + ], + ) + def test_float_knob_bounds(self, knob: str, low: float, high: float) -> None: + with pytest.raises(ValidationError): + SeederOverrides.model_validate({knob: low}) + with pytest.raises(ValidationError): + SeederOverrides.model_validate({knob: high}) + + def test_boundary_values_accepted(self) -> None: + ov = SeederOverrides.model_validate( + { + "stores": 100, + "products": 500, + "window_days": 75, + "sparsity": 0.9, + "promotion_intensity": 0.5, + "stockout_intensity": 0.0, + "noise_sigma": 0.5, + } + ) + assert ov.stores == 100 + assert ov.window_days == 75 + + +class TestAllowList: + """extra='forbid' is the machine-enforced allow-list.""" + + def test_unknown_knob_rejected(self) -> None: + with pytest.raises(ValidationError): + SeederOverrides.model_validate({"stores": 5, "bogus_knob": 1}) + + def test_strict_rejects_string_int(self) -> None: + # strict=True: a JSON string is not coerced (validate_python path). + with pytest.raises(ValidationError): + SeederOverrides.model_validate({"stores": "5"}) + + +class TestJsonPath: + """JSON-dict validation (FastAPI's validate_python path) happy paths.""" + + def test_partial_object_validates(self) -> None: + ov = SeederOverrides.model_validate({"stores": 8, "promotion_intensity": 0.3}) + assert ov.stores == 8 + assert ov.promotion_intensity == 0.3 + assert ov.products is None + + def test_model_dump_exclude_none_is_sparse(self) -> None: + ov = SeederOverrides.model_validate({"stores": 8, "noise_sigma": 0.25}) + assert ov.model_dump(exclude_none=True) == {"stores": 8, "noise_sigma": 0.25} + + +class TestIsEmpty: + """is_empty() truth table -- {} on the wire collapses to None everywhere.""" + + def test_empty_object_is_empty(self) -> None: + assert SeederOverrides().is_empty() is True + assert SeederOverrides.model_validate({}).is_empty() is True + + def test_any_knob_makes_non_empty(self) -> None: + assert SeederOverrides(stores=1).is_empty() is False + assert SeederOverrides(noise_sigma=0.0).is_empty() is False From 859b24b63909444262ea727cd5bb5a5acdf20dab Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Sat, 13 Jun 2026 01:47:34 +0200 Subject: [PATCH 2/5] feat(api): thread seed overrides and user scope through demo pipeline (#409) --- app/features/demo/pipeline.py | 106 +++++++++++- app/features/demo/schemas.py | 86 +++++++++- app/features/demo/tests/test_pipeline.py | 190 +++++++++++++++++++++- app/features/demo/tests/test_schemas.py | 112 +++++++++++++ app/features/demo/tests/test_workspace.py | 31 ++++ app/features/demo/workspace.py | 15 ++ 6 files changed, 524 insertions(+), 16 deletions(-) diff --git a/app/features/demo/pipeline.py b/app/features/demo/pipeline.py index a8ae7c3c..6052a4be 100644 --- a/app/features/demo/pipeline.py +++ b/app/features/demo/pipeline.py @@ -41,8 +41,9 @@ from app.core.logging import get_logger from app.core.problem_details import EMBEDDING_AUTH_CODE, ERROR_TYPES from app.features.demo import workspace -from app.features.demo.schemas import DemoRunRequest, StepEvent, StepStatus +from app.features.demo.schemas import DemoRunRequest, StepEvent, StepStatus, UserScope from app.shared.seeder.config import ScenarioPreset +from app.shared.seeder.overrides import SeederOverrides logger = get_logger(__name__) @@ -261,6 +262,12 @@ class DemoContext: # E3 (#392) -- workspace label for plan tagging. Set alongside # workspace_id in run_pipeline's keep-branch; None on ephemeral runs. workspace_name: str | None = None + # E3 (#409) -- additive Optional start-frame config. seed_overrides is + # forwarded verbatim to /seeder/generate by step_seed (None on legacy + # frames); user_scope is the operator-selected focus pair step_status + # validates and adopts (warn + fallback to discovery when dangling). + seed_overrides: SeederOverrides | None = None + user_scope: UserScope | None = None # ============================================================================= @@ -546,12 +553,33 @@ async def step_seed(ctx: DemoContext, client: _Client) -> StepResult: ctx.scenario, _SeedProfile(DEMO_SEED_STORES, DEMO_SEED_PRODUCTS, DEMO_SEED_SPAN_DAYS), ) - stores, products = profile.stores, profile.products - if profile.window is not None: + # E3 (#409) -- effective dims = override-or-profile, used for BOTH the POST + # scalars and the detail line so the step card tells the truth. The nested + # object is ALSO forwarded verbatim; the seeder applies it last (wins). + overrides = ctx.seed_overrides + stores = ( + overrides.stores + if overrides is not None and overrides.stores is not None + else profile.stores + ) + products = ( + overrides.products + if overrides is not None and overrides.products is not None + else profile.products + ) + if overrides is not None and overrides.window_days is not None: + # The DemoRunRequest validator guarantees window_days is never set on + # the calendar-pinned holiday_rush preset, so today-anchored is safe. + seed_end = datetime.now(UTC).date() + seed_start = seed_end - timedelta(days=overrides.window_days) + elif profile.window is not None: seed_start, seed_end = profile.window else: seed_end = datetime.now(UTC).date() seed_start = seed_end - timedelta(days=profile.span_days) + # Scalar sparsity stays 0.0 (preserves preset character per the + # `if params.sparsity > 0` guard); overrides.sparsity is the only way the + # demo overrides sparsity. body = await client.request( "seed", "POST", @@ -565,6 +593,11 @@ async def step_seed(ctx: DemoContext, client: _Client) -> StepResult: "end_date": seed_end.isoformat(), "sparsity": 0.0, "dry_run": False, + **( + {"overrides": overrides.model_dump(exclude_none=True)} + if overrides is not None + else {} + ), }, ) raw_records: dict[str, Any] = body.get("records_created", {}) @@ -572,10 +605,21 @@ async def step_seed(ctx: DemoContext, client: _Client) -> StepResult: ctx.seed_records = records # GenerateResult.records_created uses "sales" (singular), not "sales_daily". sales = records.get("sales", records.get("sales_daily", 0)) + overrides_applied = ( + sorted(overrides.model_dump(exclude_none=True)) if overrides is not None else [] + ) + detail = f"{ctx.scenario.value}: {stores} stores x {products} products, {sales} sales rows" + if overrides_applied: + detail += f" (overrides: {', '.join(overrides_applied)})" return ( "pass", - f"{ctx.scenario.value}: {stores} stores x {products} products, {sales} sales rows", - {"records_created": records, "scenario": ctx.scenario.value}, + detail, + { + "records_created": records, + "scenario": ctx.scenario.value, + # E3 (#409) -- additive echo of the applied override knobs. + "overrides_applied": overrides_applied, + }, ) @@ -593,6 +637,46 @@ async def step_status(ctx: DemoContext, client: _Client) -> StepResult: return ("fail", "no date_range in /seeder/status -- seed the database first", {}) ctx.date_start = date.fromisoformat(raw_start) ctx.date_end = date.fromisoformat(raw_end) + sales = body.get("sales", 0) + + # E3 (#409) -- operator-selected focus pair: validate both ids against the + # dimensions endpoints and adopt them. A dangling pair (e.g. after a + # reset+reseed re-issued ids -- sequences never reset) WARNS and falls back + # to discovery so a replayed reset=true workspace can never hard-fail here. + scope_warning = "" + if ctx.user_scope is not None: + try: + await client.request( + "status[scope-store]", + "GET", + f"/dimensions/stores/{ctx.user_scope.store_id}", + ) + await client.request( + "status[scope-product]", + "GET", + f"/dimensions/products/{ctx.user_scope.product_id}", + ) + except _StepError: + scope_warning = ( + f"user_scope (store={ctx.user_scope.store_id}, " + f"product={ctx.user_scope.product_id}) not found -- " + "fell back to discovered pair; " + ) + else: + ctx.store_id = ctx.user_scope.store_id + ctx.product_id = ctx.user_scope.product_id + return ( + "pass", + f"date_range={raw_start}..{raw_end} sales={sales} " + f"store_id={ctx.store_id} product_id={ctx.product_id} (user-selected)", + { + "store_id": ctx.store_id, + "product_id": ctx.product_id, + "date_range_start": raw_start, + "date_range_end": raw_end, + "user_scope_applied": True, + }, + ) stores_body = await client.request( "status[stores]", "GET", "/dimensions/stores?page=1&page_size=1" @@ -617,16 +701,19 @@ async def step_status(ctx: DemoContext, client: _Client) -> StepResult: ctx.store_id = store_id_raw ctx.product_id = product_id_raw - sales = body.get("sales", 0) return ( - "pass", - f"date_range={raw_start}..{raw_end} sales={sales} " + # E3 (#409) -- "warn" (never "fail") when a requested scope dangled: + # only "fail" stops the run, so the pipeline proceeds on the + # discovered pair with the divergence visible on the step card. + "warn" if scope_warning else "pass", + f"{scope_warning}date_range={raw_start}..{raw_end} sales={sales} " f"store_id={ctx.store_id} product_id={ctx.product_id}", { "store_id": ctx.store_id, "product_id": ctx.product_id, "date_range_start": raw_start, "date_range_end": raw_end, + "user_scope_applied": False, }, ) @@ -2648,6 +2735,9 @@ async def run_pipeline(app: FastAPI, req: DemoRunRequest) -> AsyncIterator[StepE skip_seed=req.skip_seed, reset=req.reset, scenario=req.scenario, + # E3 (#409) -- thread the validated start-frame config verbatim. + seed_overrides=req.seed_overrides, + user_scope=req.user_scope, ) # E1 (#390) -- create the workspace row BEFORE the first step executes so # even an early failure records the run config. create_workspace is diff --git a/app/features/demo/schemas.py b/app/features/demo/schemas.py index 58daf891..d5aa78ea 100644 --- a/app/features/demo/schemas.py +++ b/app/features/demo/schemas.py @@ -14,6 +14,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from app.shared.seeder.config import ScenarioPreset +from app.shared.seeder.overrides import SeederOverrides # One pipeline step's outcome. StepStatus = Literal["running", "pass", "fail", "skip", "warn"] @@ -26,6 +27,22 @@ def _utc_now() -> datetime: return datetime.now(UTC) +class UserScope(BaseModel): + """Operator-selected (store, product) focus pair (E3, issue #409). + + Ids are REAL discovered ids (Postgres sequences never reset -- ids are not + 1-based); ``step_status`` validates them against ``/dimensions/*/{id}`` + and warn-falls-back to discovery when the pair dangles (e.g. after a + reset+reseed re-issued ids). ``extra="forbid"`` keeps the slot schema + closed; additive keys need a documented schema change. + """ + + model_config = ConfigDict(strict=True, extra="forbid") + + store_id: int = Field(..., ge=1, description="Real store id from /dimensions/stores.") + product_id: int = Field(..., ge=1, description="Real product id from /dimensions/products.") + + class DemoRunRequest(BaseModel): """Request body for ``POST /demo/run`` and the ``WS /demo/stream`` start frame. @@ -34,7 +51,10 @@ class DemoRunRequest(BaseModel): override -- there is no ``date`` / ``datetime`` / ``UUID`` / ``Decimal`` field (see ``.claude/rules/security-patterns.md`` and ``test_strict_mode_policy.py``). The sole exception is ``scenario``, whose - enum-on-the-wire form carries its own override (PRP-38). + enum-on-the-wire form carries its own override (PRP-38). The nested + ``seed_overrides`` / ``user_scope`` models are themselves all-JSON-native + and validate from the JSON-parsed dict under the parent's strict mode + (runtime-verified on pydantic 2.12.5 -- E3 #409). """ model_config = ConfigDict(strict=True) @@ -85,6 +105,25 @@ class DemoRunRequest(BaseModel): pattern=r"^[0-9a-f]{32}$", # uuid4().hex shape of workspace_id description="workspace_id this run replays; requires preservation='keep'.", ) + # E3 (#409): curated seed overrides + operator-selected focus pair. Both + # additive Optional with None defaults so legacy frames stay byte-identical. + # The nested models carry their own ConfigDict(strict=True, extra="forbid"). + seed_overrides: SeederOverrides | None = Field( + default=None, + description=( + "Curated seeder overrides (allow-listed knobs); requires " + "skip_seed=false (Re-seed first). Forwarded verbatim to " + "POST /seeder/generate and recorded on a kept workspace row." + ), + ) + user_scope: UserScope | None = Field( + default=None, + description=( + "Operator-selected (store, product) focus pair the pipeline models " + "instead of the auto-discovered first pair; validated by the status " + "step (warn + fallback to discovery on a dangling pair)." + ), + ) @model_validator(mode="after") def _workspace_name_requires_keep(self) -> DemoRunRequest: @@ -100,6 +139,34 @@ def _replayed_from_requires_keep(self) -> DemoRunRequest: raise ValueError("replayed_from_workspace_id requires preservation='keep'") return self + @model_validator(mode="after") + def _seed_overrides_require_reseed(self) -> DemoRunRequest: + """Reject overrides on a run that skips the seed step (silent no-op trap). + + An empty overrides object (``{}`` on the wire) normalizes to ``None`` + so downstream code has a single "no overrides" representation. + """ + if self.seed_overrides is not None and self.seed_overrides.is_empty(): + self.seed_overrides = None + if self.seed_overrides is not None and self.skip_seed: + raise ValueError("seed_overrides requires skip_seed=false (Re-seed first)") + return self + + @model_validator(mode="after") + def _window_days_forbidden_on_holiday_rush(self) -> DemoRunRequest: + """Reject window_days on the calendar-pinned holiday_rush preset. + + The preset's HolidayConfig spikes are fixed 2024 dates -- a shifted + window would silently drop every holiday spike, so this fails loudly. + """ + if ( + self.seed_overrides is not None + and self.seed_overrides.window_days is not None + and self.scenario is ScenarioPreset.HOLIDAY_RUSH + ): + raise ValueError("window_days cannot override the calendar-pinned holiday_rush window") + return self + class WorkspaceUpdateRequest(BaseModel): """Partial lifecycle update for ``PATCH /demo/workspaces/{workspace_id}``. @@ -266,6 +333,15 @@ class WorkspaceListItem(BaseModel): default=None, description="workspace_id this run replayed (soft reference; may dangle).", ) + # E3 (#409) -- the two replay-relevant story slots live on the LIST item + # (not detail-only): the frontend Replay reads list rows, and the + # replay-verbatim contract includes both slots. + seed_overrides: dict[str, Any] | None = Field( + default=None, description="Story slot (E3 #409): seeder-override payload." + ) + user_scope: dict[str, Any] | None = Field( + default=None, description="Story slot (E3 #409): operator-selected focus." + ) class WorkspaceDetailResponse(WorkspaceListItem): @@ -285,12 +361,8 @@ class WorkspaceDetailResponse(WorkspaceListItem): 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." - ) + # E3 (#409) -- seed_overrides / user_scope moved UP to WorkspaceListItem + # (replay reads list rows); the four remaining story slots stay detail-only. approval_events: list[dict[str, Any]] | None = Field( default=None, description="Story slot (E5 #411 writes): HITL approval audit." ) diff --git a/app/features/demo/tests/test_pipeline.py b/app/features/demo/tests/test_pipeline.py index 197c2842..1fc4c1b4 100644 --- a/app/features/demo/tests/test_pipeline.py +++ b/app/features/demo/tests/test_pipeline.py @@ -16,8 +16,9 @@ from fastapi import FastAPI from app.features.demo import pipeline -from app.features.demo.schemas import DemoRunRequest +from app.features.demo.schemas import DemoRunRequest, UserScope from app.shared.seeder.config import ScenarioPreset +from app.shared.seeder.overrides import SeederOverrides # A bare app instance -- the fake clients ignore it; it only satisfies the # run_pipeline(app: FastAPI, ...) signature. @@ -2454,3 +2455,190 @@ async def test_step_seed_retail_standard_posts_demo_scaled_profile(): # sparsity stays 0.0 — the seeder override fires only when > 0, which is # what preserves the sparse preset's 50%-missing character. assert body["sparsity"] == 0.0 + + +# ============================================================================= +# E3 (#409) — seed overrides + user scope +# ============================================================================= + + +async def test_step_seed_forwards_seed_overrides(): + """E3 (#409) — the nested overrides ride the /seeder/generate body verbatim, + the POST scalars echo the effective dims, and scalar sparsity stays 0.0.""" + ctx = pipeline.DemoContext( + seed=42, + skip_seed=False, + reset=False, + scenario=ScenarioPreset.DEMO_MINIMAL, + seed_overrides=SeederOverrides(stores=8, products=20, promotion_intensity=0.3), + ) + client = _RecordingClient( + None, + responses={("POST", "/seeder/generate"): {"records_created": {"sales": 1}}}, + ) + status, detail, data = await pipeline.step_seed(ctx, _as_client(client)) + assert status == "pass" + body = client.calls[0][2] + assert body is not None + assert body["overrides"] == {"stores": 8, "products": 20, "promotion_intensity": 0.3} + # Effective dims on the scalars + the detail line (the card tells the truth). + assert body["stores"] == 8 + assert body["products"] == 20 + assert body["sparsity"] == 0.0 # preset-character guard; nested wins anyway + assert "8 stores x 20 products" in detail + assert "overrides: products, promotion_intensity, stores" in detail + assert data["overrides_applied"] == ["products", "promotion_intensity", "stores"] + + +async def test_step_seed_without_overrides_is_legacy_identical(): + """E3 (#409) — a legacy ctx posts NO overrides key (byte-identical body).""" + ctx = pipeline.DemoContext( + seed=42, skip_seed=False, reset=False, scenario=ScenarioPreset.DEMO_MINIMAL + ) + client = _RecordingClient( + None, + responses={("POST", "/seeder/generate"): {"records_created": {"sales": 1}}}, + ) + status, _detail, data = await pipeline.step_seed(ctx, _as_client(client)) + assert status == "pass" + body = client.calls[0][2] + assert body is not None + assert "overrides" not in body + assert body["stores"] == 3 # demo_minimal profile + assert data["overrides_applied"] == [] + + +async def test_step_seed_window_days_overrides_profile_window(): + """E3 (#409) — window_days drives a today-anchored window of that length.""" + ctx = pipeline.DemoContext( + seed=42, + skip_seed=False, + reset=False, + scenario=ScenarioPreset.DEMO_MINIMAL, + seed_overrides=SeederOverrides(window_days=120), + ) + client = _RecordingClient( + None, + responses={("POST", "/seeder/generate"): {"records_created": {"sales": 1}}}, + ) + status, _detail, _data = await pipeline.step_seed(ctx, _as_client(client)) + assert status == "pass" + body = client.calls[0][2] + assert body is not None + start = date.fromisoformat(body["start_date"]) + end = date.fromisoformat(body["end_date"]) + assert end - start == timedelta(days=120) + assert body["overrides"] == {"window_days": 120} + + +def _status_discovery_responses() -> dict[tuple[str, str], Any]: + """Canned responses for the legacy first-pair discovery path.""" + return { + ("GET", "/seeder/status"): { + "date_range_start": "2026-01-01", + "date_range_end": "2026-03-31", + "sales": 900, + }, + ("GET", "/dimensions/stores?page=1&page_size=1"): {"stores": [{"id": 4}]}, + ("GET", "/dimensions/products?page=1&page_size=1"): {"products": [{"id": 9}]}, + } + + +async def test_step_status_honors_user_scope(): + """E3 (#409) — a valid pair is validated via GET-by-id and adopted.""" + ctx = pipeline.DemoContext( + seed=42, + skip_seed=True, + reset=False, + scenario=ScenarioPreset.DEMO_MINIMAL, + user_scope=UserScope(store_id=12, product_id=47), + ) + client = _RecordingClient( + None, + responses={ + ("GET", "/seeder/status"): { + "date_range_start": "2026-01-01", + "date_range_end": "2026-03-31", + "sales": 900, + }, + ("GET", "/dimensions/stores/12"): {"id": 12, "code": "S012"}, + ("GET", "/dimensions/products/47"): {"id": 47, "sku": "P047"}, + }, + ) + status, detail, data = await pipeline.step_status(ctx, _as_client(client)) + assert status == "pass" + assert ctx.store_id == 12 + assert ctx.product_id == 47 + assert "(user-selected)" in detail + assert data["user_scope_applied"] is True + # Both GET-by-id validations were issued; no discovery call happened. + paths = [path for _method, path, _body in client.calls] + assert "/dimensions/stores/12" in paths + assert "/dimensions/products/47" in paths + assert "/dimensions/stores?page=1&page_size=1" not in paths + + +async def test_step_status_dangling_scope_warns_and_falls_back(): + """E3 (#409) — a 404 pair WARNS (never fails) and discovery takes over.""" + responses = _status_discovery_responses() + responses[("GET", "/dimensions/products/47")] = {"id": 47} + ctx = pipeline.DemoContext( + seed=42, + skip_seed=True, + reset=False, + scenario=ScenarioPreset.DEMO_MINIMAL, + user_scope=UserScope(store_id=12, product_id=47), + ) + client = _RecordingClient( + None, + responses=responses, + errors={ + ("GET", "/dimensions/stores/12"): pipeline._StepError( + "status[scope-store]", 404, {"title": "Not Found"} + ), + }, + ) + status, detail, data = await pipeline.step_status(ctx, _as_client(client)) + assert status == "warn" + assert ctx.store_id == 4 # discovered pair + assert ctx.product_id == 9 + assert "user_scope (store=12, product=47) not found" in detail + assert data["user_scope_applied"] is False + + +async def test_step_status_without_scope_unchanged(): + """E3 (#409) — the legacy discovery path is byte-identical (pass, no warn).""" + ctx = pipeline.DemoContext( + seed=42, skip_seed=True, reset=False, scenario=ScenarioPreset.DEMO_MINIMAL + ) + client = _RecordingClient(None, responses=_status_discovery_responses()) + status, detail, data = await pipeline.step_status(ctx, _as_client(client)) + assert status == "pass" + assert ctx.store_id == 4 + assert ctx.product_id == 9 + assert "user_scope" not in detail + assert data["user_scope_applied"] is False + + +async def test_run_pipeline_threads_e3_fields(monkeypatch): + """E3 (#409) — run_pipeline threads seed_overrides/user_scope into ctx.""" + captured: dict[str, Any] = {} + + async def _capturing_precheck(ctx: Any, _client: Any) -> Any: + captured["seed_overrides"] = ctx.seed_overrides + captured["user_scope"] = ctx.user_scope + return ("fail", "stop after capture", {}) + + monkeypatch.setattr(pipeline, "step_precheck", _capturing_precheck) + monkeypatch.setattr(pipeline, "_Client", _build_fake_client("unused", {})) + + req = DemoRunRequest.model_validate( + { + "skip_seed": False, + "seed_overrides": {"stores": 8, "noise_sigma": 0.25}, + "user_scope": {"store_id": 12, "product_id": 47}, + } + ) + _events = [e async for e in pipeline.run_pipeline(app=_FAKE_APP, req=req)] + assert captured["seed_overrides"] == SeederOverrides(stores=8, noise_sigma=0.25) + assert captured["user_scope"] == UserScope(store_id=12, product_id=47) diff --git a/app/features/demo/tests/test_schemas.py b/app/features/demo/tests/test_schemas.py index 866f708c..8019d219 100644 --- a/app/features/demo/tests/test_schemas.py +++ b/app/features/demo/tests/test_schemas.py @@ -10,6 +10,7 @@ DemoRunRequest, DemoRunResult, StepEvent, + UserScope, WorkspaceDetailResponse, WorkspaceListItem, WorkspaceListResponse, @@ -143,6 +144,95 @@ def test_demo_run_request_replayed_from_pattern_rejected(): ) +# ============================================================================= +# E3 (#409) -- seed_overrides + user_scope (advanced seed config + focus pair) +# ============================================================================= + + +def test_demo_run_request_e3_field_defaults(): + """E3 (#409) -- defaults None; a legacy 4-field frame stays byte-identical.""" + req = DemoRunRequest.model_validate( + {"seed": 7, "reset": False, "skip_seed": True, "scenario": "demo_minimal"} + ) + assert req.seed_overrides is None + assert req.user_scope is None + + +def test_demo_run_request_seed_overrides_json_path(): + """E3 (#409) -- the JSON wire form (validate_python on a parsed dict, the + path FastAPI uses) accepts a nested overrides object on a re-seed run.""" + req = DemoRunRequest.model_validate( + {"skip_seed": False, "seed_overrides": {"stores": 8, "promotion_intensity": 0.3}} + ) + assert req.seed_overrides is not None + assert req.seed_overrides.stores == 8 + assert req.seed_overrides.promotion_intensity == 0.3 + + +def test_demo_run_request_seed_overrides_require_reseed(): + """E3 (#409) -- overrides on a skip_seed run would be a silent no-op.""" + with pytest.raises(ValidationError): + DemoRunRequest.model_validate({"skip_seed": True, "seed_overrides": {"stores": 8}}) + # skip_seed defaults to True -- omitting it must also reject. + with pytest.raises(ValidationError): + DemoRunRequest.model_validate({"seed_overrides": {"stores": 8}}) + + +def test_demo_run_request_empty_seed_overrides_normalizes_to_none(): + """E3 (#409) -- {} on the wire collapses to None (single no-overrides form), + and is therefore legal even on a skip_seed run.""" + req = DemoRunRequest.model_validate({"skip_seed": True, "seed_overrides": {}}) + assert req.seed_overrides is None + + +def test_demo_run_request_window_days_rejected_on_holiday_rush(): + """E3 (#409) -- holiday_rush is calendar-pinned; window_days fails loudly.""" + with pytest.raises(ValidationError): + DemoRunRequest.model_validate( + { + "skip_seed": False, + "scenario": "holiday_rush", + "seed_overrides": {"window_days": 120}, + } + ) + # The same knob is fine on a today-anchored preset. + req = DemoRunRequest.model_validate( + { + "skip_seed": False, + "scenario": "retail_standard", + "seed_overrides": {"window_days": 120}, + } + ) + assert req.seed_overrides is not None + assert req.seed_overrides.window_days == 120 + + +def test_demo_run_request_seed_overrides_unknown_knob_rejected(): + """E3 (#409) -- the nested extra='forbid' allow-list holds on the demo path.""" + with pytest.raises(ValidationError): + DemoRunRequest.model_validate({"skip_seed": False, "seed_overrides": {"bogus_knob": 1}}) + + +def test_demo_run_request_user_scope_json_path(): + """E3 (#409) -- user_scope accepts a real id pair; works with skip_seed.""" + req = DemoRunRequest.model_validate({"user_scope": {"store_id": 12, "product_id": 47}}) + assert req.user_scope is not None + assert req.user_scope.store_id == 12 + assert req.user_scope.product_id == 47 + + +def test_user_scope_rejects_extra_keys_and_bad_ids(): + """E3 (#409) -- closed schema; ids are ge=1; strict rejects string ints.""" + with pytest.raises(ValidationError): + UserScope.model_validate({"store_id": 1, "product_id": 1, "extra": True}) + with pytest.raises(ValidationError): + UserScope.model_validate({"store_id": 0, "product_id": 1}) + with pytest.raises(ValidationError): + UserScope.model_validate({"store_id": 1}) # product_id required + with pytest.raises(ValidationError): + UserScope.model_validate({"store_id": "1", "product_id": 1}) + + # ============================================================================= # E1 (#407) -- WorkspaceUpdateRequest (PATCH body) # ============================================================================= @@ -393,6 +483,28 @@ def test_workspace_detail_passes_e1_fields_through(): assert detail.job_ids == ["job-1", "job-2"] +def test_workspace_list_item_exposes_e3_slots(): + """E3 (#409) -- seed_overrides/user_scope live on the LIST item (replay + reads list rows), defaulting to None on rows without them.""" + bare = WorkspaceListItem.model_validate(_orm_like_workspace_row()) + assert bare.seed_overrides is None + assert bare.user_scope is None + + slotted = WorkspaceListItem.model_validate( + _orm_like_workspace_row( + seed_overrides={"stores": 8, "noise_sigma": 0.25}, + user_scope={"store_id": 12, "product_id": 47}, + ) + ) + assert slotted.seed_overrides == {"stores": 8, "noise_sigma": 0.25} + assert slotted.user_scope == {"store_id": 12, "product_id": 47} + # Detail inherits the same exposure. + detail = WorkspaceDetailResponse.model_validate( + _orm_like_workspace_row(seed_overrides={"sparsity": 0.3}) + ) + assert detail.seed_overrides == {"sparsity": 0.3} + + 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 cb28dea2..fcef7115 100644 --- a/app/features/demo/tests/test_workspace.py +++ b/app/features/demo/tests/test_workspace.py @@ -76,6 +76,37 @@ async def test_create_workspace_persists_config(db_session: AsyncSession) -> Non assert row.result_summary is None +async def test_create_workspace_persists_e3_slots(db_session: AsyncSession) -> None: + """E3 (#409) -- seed_overrides/user_scope land in the story slots, sparse.""" + workspace_id = await workspace.create_workspace( + _keep_request( + skip_seed=False, + seed_overrides={"stores": 8, "promotion_intensity": 0.3}, + user_scope={"store_id": 12, "product_id": 47}, + ) + ) + assert workspace_id is not None + + row = await workspace.get_workspace(db_session, workspace_id) + assert row is not None + # Sparse JSON: only the operator-set knobs appear. + assert row.seed_overrides == {"stores": 8, "promotion_intensity": 0.3} + assert row.user_scope == {"store_id": 12, "product_id": 47} + + +async def test_create_workspace_without_e3_fields_persists_nulls( + db_session: AsyncSession, +) -> None: + """E3 (#409) -- a legacy keep-run stores NULL slots (never {}).""" + 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.seed_overrides is None + assert row.user_scope is None + + async def test_finalize_workspace_completed(db_session: AsyncSession) -> None: """finalize(failed=False) settles to completed with collected ids.""" workspace_id = await workspace.create_workspace(_keep_request()) diff --git a/app/features/demo/workspace.py b/app/features/demo/workspace.py index 364b64fd..ca3002df 100644 --- a/app/features/demo/workspace.py +++ b/app/features/demo/workspace.py @@ -112,6 +112,21 @@ async def create_workspace(req: DemoRunRequest) -> str | None: # E1 (#407): replay provenance, recorded verbatim (soft # reference -- no existence check; dangles are designed). replayed_from_workspace_id=req.replayed_from_workspace_id, + # E3 (#409): the two replay-relevant story slots, recorded + # at create time (the REQUESTED config -- the effective + # grain lands separately on store_id/product_id at + # finalize, so a fallen-back scope stays visible). Sparse + # JSON: only operator-set knobs appear; never {}. + seed_overrides=( + req.seed_overrides.model_dump(mode="json", exclude_none=True) + if req.seed_overrides is not None + else None + ), + user_scope=( + req.user_scope.model_dump(mode="json") + if req.user_scope is not None + else None + ), ) ) await db.commit() From e0dc2d87b884c232d63a11d3208b4c11d5ee9688 Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Sat, 13 Jun 2026 01:47:34 +0200 Subject: [PATCH 3/5] test(api): cover replay-verbatim seed overrides and scope slots (#409) --- tests/test_e2e_demo.py | 56 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/test_e2e_demo.py b/tests/test_e2e_demo.py index 5ef406ff..3323c524 100644 --- a/tests/test_e2e_demo.py +++ b/tests/test_e2e_demo.py @@ -614,6 +614,62 @@ def test_demo_replay_same_config_twice( assert row["status"] == "completed" +@pytest.mark.integration +def test_demo_replay_preserves_seed_overrides_and_scope( + uvicorn_subprocess: subprocess.Popen[bytes], +) -> None: + """E3 (#409) — replayed runs carry identical seed_overrides/user_scope slots. + + The replay-verbatim contract: the slots record the REQUESTED config, so two + runs of the same body must produce two workspace rows with identical slot + JSON. ``user_scope`` is deliberately a (1, 1) pair that almost certainly + dangles (sequences never reset) — the status step must WARN + fall back, + the run must still pass, and the slot must still record the request + verbatim (requested-vs-effective divergence stays visible: the row's + store_id/product_id columns carry the discovered grain). + """ + import json + + body_dict: dict[str, object] = { + "seed": 42, + "reset": True, + "skip_seed": False, + "scenario": "demo_minimal", + "preservation": "keep", + "workspace_name": "e3-replay-slots", + # Smallest overrides to keep wall-clock sane (matches demo_minimal dims). + "seed_overrides": {"stores": 3, "products": 10}, + "user_scope": {"store_id": 1, "product_id": 1}, + } + + first = _post_demo_run(body_dict, REPLAY_RUN_TIMEOUT_S) + assert first["overall_status"] == "pass", ( + f"first run did not pass: " + f"steps={[(s['step_name'], s['status'], s['detail']) for s in first['steps']]}" # type: ignore[index] + ) + second = _post_demo_run(body_dict, REPLAY_RUN_TIMEOUT_S) + assert second["overall_status"] == "pass", ( + f"replay did not pass: " + f"steps={[(s['step_name'], s['status'], s['detail']) for s in second['steps']]}" # type: ignore[index] + ) + + with urllib.request.urlopen( # noqa: S310 — http://127.0.0.1 internal URL + f"{DEMO_API_URL}/demo/workspaces?limit=100", timeout=10.0 + ) as resp: + assert resp.status == 200 + page = json.loads(resp.read()) + rows_by_id = {w["workspace_id"]: w for w in page["workspaces"]} + first_row = rows_by_id[first["workspace_id"]] + second_row = rows_by_id[second["workspace_id"]] + + # The slots are exposed on the LIST item (replay reads list rows) and are + # identical across the original and the replay. + assert first_row["seed_overrides"] == {"stores": 3, "products": 10} + assert first_row["user_scope"] == {"store_id": 1, "product_id": 1} + assert second_row["seed_overrides"] == first_row["seed_overrides"] + assert second_row["user_scope"] == first_row["user_scope"] + + @pytest.mark.integration def test_run_demo_precondition_failure_exits_2() -> None: """A bogus API URL surfaces as a precondition failure with exit 2. From ee59cbd392ff15affd7142633159fbcb50aab37d Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Sat, 13 Jun 2026 01:56:54 +0200 Subject: [PATCH 4/5] feat(ui): add advanced seed config panel and scope selector to showcase (#409) --- .../demo/ReplayConfirmDialog.test.tsx | 2 + .../components/demo/ScopeSelector.test.tsx | 109 +++++++++ .../src/components/demo/ScopeSelector.tsx | 143 ++++++++++++ .../components/demo/SeedConfigPanel.test.tsx | 95 ++++++++ .../src/components/demo/SeedConfigPanel.tsx | 213 ++++++++++++++++++ .../demo/WorkspaceArtifactsPanel.test.tsx | 2 + .../demo/WorkspaceEditDialog.test.tsx | 2 + .../components/demo/WorkspacePanel.test.tsx | 2 + frontend/src/components/demo/index.ts | 3 + .../components/demo/replay-request.test.ts | 26 +++ .../src/components/demo/replay-request.ts | 4 + frontend/src/pages/showcase.tsx | 52 ++++- frontend/src/pages/workspace-compare.test.tsx | 2 + frontend/src/types/api.ts | 26 +++ 14 files changed, 678 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/demo/ScopeSelector.test.tsx create mode 100644 frontend/src/components/demo/ScopeSelector.tsx create mode 100644 frontend/src/components/demo/SeedConfigPanel.test.tsx create mode 100644 frontend/src/components/demo/SeedConfigPanel.tsx diff --git a/frontend/src/components/demo/ReplayConfirmDialog.test.tsx b/frontend/src/components/demo/ReplayConfirmDialog.test.tsx index 8c3d705d..cee3981b 100644 --- a/frontend/src/components/demo/ReplayConfirmDialog.test.tsx +++ b/frontend/src/components/demo/ReplayConfirmDialog.test.tsx @@ -32,6 +32,8 @@ const baseItem: WorkspaceListItem = { pinned: false, tags: [], replayed_from_workspace_id: null, + seed_overrides: null, + user_scope: null, } function renderDialog(workspace: WorkspaceListItem | null, handlers = {}) { diff --git a/frontend/src/components/demo/ScopeSelector.test.tsx b/frontend/src/components/demo/ScopeSelector.test.tsx new file mode 100644 index 00000000..643dc057 --- /dev/null +++ b/frontend/src/components/demo/ScopeSelector.test.tsx @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { ScopeSelector } from './ScopeSelector' + +afterEach(cleanup) + +vi.mock('@/hooks/use-stores', () => ({ + useStores: () => ({ + data: { + stores: [ + { + id: 12, + code: 'S012', + name: 'Riverside', + region: 'North', + city: null, + store_type: 'supermarket', + created_at: '', + updated_at: '', + }, + { + id: 13, + code: 'S013', + name: 'Hilltop', + region: 'South', + city: null, + store_type: 'express', + created_at: '', + updated_at: '', + }, + ], + }, + isLoading: false, + }), +})) + +vi.mock('@/hooks/use-products', () => ({ + useProducts: () => ({ + data: { + products: [ + { + id: 47, + sku: 'SKU-047', + name: 'Oat Milk', + category: 'Dairy', + brand: 'BrandA', + base_price: null, + base_cost: null, + created_at: '', + updated_at: '', + }, + ], + }, + isLoading: false, + }), +})) + +vi.mock('@/hooks/use-seeder', () => ({ + useSeederStatus: () => ({ + data: { + date_range_start: '2026-01-01', + date_range_end: '2026-03-31', + }, + }), +})) + +describe('ScopeSelector', () => { + it('renders the two dropdowns with auto-discover placeholders', () => { + render( undefined} />) + expect(screen.getByText('Auto-discover first store')).toBeTruthy() + expect(screen.getByText('Auto-discover first product')).toBeTruthy() + // No preview card while nothing is selected. + expect(screen.queryByText('Focus pair')).toBeNull() + }) + + it('previews the selected pair with names, traits, and the seeded window', () => { + render( + undefined} /> + ) + expect(screen.getByText('Focus pair')).toBeTruthy() + expect(screen.getByText('S012 · Riverside (North, supermarket)')).toBeTruthy() + expect(screen.getByText('SKU-047 · Oat Milk (Dairy, BrandA)')).toBeTruthy() + expect(screen.getByText(/2026-01-01 → 2026-03-31/)).toBeTruthy() + }) + + it('falls back to raw ids when the pair is not in the loaded page', () => { + render( + undefined} /> + ) + expect(screen.getByText('store #999')).toBeTruthy() + expect(screen.getByText('product #888')).toBeTruthy() + }) + + it('clears the selection via the Clear focus button', () => { + const onChange = vi.fn() + render() + fireEvent.click(screen.getByText('Clear focus')) + expect(onChange).toHaveBeenCalledWith(null) + }) + + it('hides the Clear button and disables triggers when disabled', () => { + render( + undefined} disabled /> + ) + const storeTrigger = screen.getByLabelText('Focus store') as HTMLButtonElement + expect(storeTrigger.disabled).toBe(true) + expect((screen.getByText('Clear focus') as HTMLButtonElement).disabled).toBe(true) + }) +}) diff --git a/frontend/src/components/demo/ScopeSelector.tsx b/frontend/src/components/demo/ScopeSelector.tsx new file mode 100644 index 00000000..1ed1b1e8 --- /dev/null +++ b/frontend/src/components/demo/ScopeSelector.tsx @@ -0,0 +1,143 @@ +import { Crosshair } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { useProducts } from '@/hooks/use-products' +import { useSeederStatus } from '@/hooks/use-seeder' +import { useStores } from '@/hooks/use-stores' +import type { UserScope } from '@/types/api' + +/** + * E3 (#409) — store/product focus-pair selector with a pre-run preview. + * + * Fed from live /dimensions data (NEVER synthesized ids — Postgres sequences + * don't reset, so ids are not 1-based). Works without re-seeding: scope + * selection on the existing dataset is the primary use. The status step + * validates the pair server-side and warn-falls-back to discovery when it + * dangles (e.g. after a reset re-issued ids). + */ +interface ScopeSelectorProps { + value: UserScope | null + onChange: (value: UserScope | null) => void + disabled?: boolean +} + +// page_size hard cap on /dimensions endpoints is 100. +const PAGE_SIZE = 100 + +/** "S001 · Main St (North, supermarket)" — label + non-null traits. */ +function describeEntity(label: string, traits: Array): string { + const present = traits.filter((t): t is string => t !== null && t !== '') + return present.length > 0 ? `${label} (${present.join(', ')})` : label +} + +export function ScopeSelector({ value, onChange, disabled = false }: ScopeSelectorProps) { + const storesQuery = useStores({ page: 1, pageSize: PAGE_SIZE }) + const productsQuery = useProducts({ page: 1, pageSize: PAGE_SIZE }) + const { data: seederStatus } = useSeederStatus() + + const stores = storesQuery.data?.stores ?? [] + const products = productsQuery.data?.products ?? [] + const selectedStore = stores.find((s) => s.id === value?.store_id) ?? null + const selectedProduct = products.find((p) => p.id === value?.product_id) ?? null + + return ( +
+
+ + + + + {value !== null && ( + + )} +
+ + {value !== null && ( + + + + + Focus pair + + + {selectedStore + ? describeEntity(`${selectedStore.code} · ${selectedStore.name}`, [ + selectedStore.region, + selectedStore.store_type, + ]) + : `store #${value.store_id}`} + + + {selectedProduct + ? describeEntity(`${selectedProduct.sku} · ${selectedProduct.name}`, [ + selectedProduct.category, + selectedProduct.brand, + ]) + : `product #${value.product_id}`} + + {seederStatus?.date_range_start && seederStatus.date_range_end && ( + + seeded window {seederStatus.date_range_start} → {seederStatus.date_range_end} + + )} + + + )} +
+ ) +} diff --git a/frontend/src/components/demo/SeedConfigPanel.test.tsx b/frontend/src/components/demo/SeedConfigPanel.test.tsx new file mode 100644 index 00000000..c81d8bed --- /dev/null +++ b/frontend/src/components/demo/SeedConfigPanel.test.tsx @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { SeedConfigPanel } from './SeedConfigPanel' +import type { SeedOverrides } from '@/types/api' + +// jsdom lacks ResizeObserver; the radix Slider requires it (no vitest setup +// file exists in this project — the stub stays local to this suite). +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} +globalThis.ResizeObserver = globalThis.ResizeObserver ?? (ResizeObserverStub as never) + +afterEach(cleanup) + +function openPanel(value: SeedOverrides | null = null, props = {}) { + const onChange = vi.fn() + render() + fireEvent.click(screen.getByText('Advanced seed config')) + return onChange +} + +describe('SeedConfigPanel', () => { + it('renders all 7 knob controls when expanded', () => { + openPanel() + // 3 int inputs + for (const label of ['Stores', 'Products', 'Window (days)']) { + expect(screen.getByLabelText(label)).toBeTruthy() + } + // 4 float sliders + for (const label of [ + 'Sparsity', + 'Promotion intensity', + 'Stockout intensity', + 'Noise sigma', + ]) { + expect(screen.getAllByLabelText(label).length).toBeGreaterThan(0) + } + }) + + it('emits a sparse object containing only the touched knob', () => { + const onChange = openPanel() + fireEvent.change(screen.getByLabelText('Stores'), { target: { value: '8' } }) + expect(onChange).toHaveBeenCalledWith({ stores: 8 }) + }) + + it('merges a new knob into the existing sparse object', () => { + const onChange = openPanel({ stores: 8 }) + fireEvent.change(screen.getByLabelText('Products'), { target: { value: '20' } }) + expect(onChange).toHaveBeenCalledWith({ stores: 8, products: 20 }) + }) + + it('emits null when the last knob is cleared', () => { + const onChange = openPanel({ stores: 8 }) + fireEvent.change(screen.getByLabelText('Stores'), { target: { value: '' } }) + expect(onChange).toHaveBeenCalledWith(null) + }) + + it('emits null via the Clear overrides button', () => { + const onChange = openPanel({ stores: 8, noise_sigma: 0.25 }) + fireEvent.click(screen.getByText('Clear overrides')) + expect(onChange).toHaveBeenCalledWith(null) + }) + + it('disables every control when disabled', () => { + openPanel({ stores: 8 }, { disabled: true }) + expect((screen.getByLabelText('Stores') as HTMLInputElement).disabled).toBe(true) + expect((screen.getByLabelText('Products') as HTMLInputElement).disabled).toBe(true) + }) + + it('locks only the window control when windowLocked (holiday_rush)', () => { + openPanel(null, { windowLocked: true }) + expect((screen.getByLabelText('Window (days)') as HTMLInputElement).disabled).toBe(true) + expect((screen.getByLabelText('Stores') as HTMLInputElement).disabled).toBe(false) + expect(screen.getByText('pinned window (holiday_rush)')).toBeTruthy() + }) + + it('shows the NaN-WAPE caveat at high stockout intensity', () => { + openPanel({ stockout_intensity: 0.4 }) + expect( + screen.getByText(/can legitimately fail the backtest/i) + ).toBeTruthy() + }) + + it('hides the caveat at tame values', () => { + openPanel({ stockout_intensity: 0.1, sparsity: 0.2 }) + expect(screen.queryByText(/can legitimately fail the backtest/i)).toBeNull() + }) + + it('echoes the live summary of set knobs', () => { + openPanel({ stores: 8, products: 20, promotion_intensity: 0.3 }) + expect(screen.getByText('8 stores · 20 products · promo 0.30')).toBeTruthy() + }) +}) diff --git a/frontend/src/components/demo/SeedConfigPanel.tsx b/frontend/src/components/demo/SeedConfigPanel.tsx new file mode 100644 index 00000000..6eed23e2 --- /dev/null +++ b/frontend/src/components/demo/SeedConfigPanel.tsx @@ -0,0 +1,213 @@ +import { useState } from 'react' +import { ChevronsUpDown, AlertTriangle } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible' +import { Input } from '@/components/ui/input' +import { Slider } from '@/components/ui/slider' +import type { SeedOverrides } from '@/types/api' + +/** + * E3 (#409) — advanced seed config panel: the 7 curated, allow-listed knobs. + * + * Emits a SPARSE object (only operator-touched knobs) and null when nothing + * is set, so legacy start frames stay byte-identical. The UI int ranges are + * deliberately TIGHTER than the API bounds (laptop-scale demo data); the API + * bounds are the law and the backend rejects anything outside them. + */ +interface SeedConfigPanelProps { + value: SeedOverrides | null + onChange: (value: SeedOverrides | null) => void + /** Disable every control (run in flight / Re-seed unticked). */ + disabled?: boolean + /** holiday_rush is calendar-pinned — the window control locks. */ + windowLocked?: boolean +} + +// UI input ranges (int knobs). API bounds: stores 1..100, products 1..500, +// window_days 75..365 — the inputs clamp to demo-scale subsets. +const INT_KNOBS = [ + { key: 'stores', label: 'Stores', min: 1, max: 20, placeholder: 'preset' }, + { key: 'products', label: 'Products', min: 1, max: 50, placeholder: 'preset' }, + { key: 'window_days', label: 'Window (days)', min: 75, max: 365, placeholder: 'preset' }, +] as const + +// Float knobs rendered as sliders. API bounds are the slider ranges. +const FLOAT_KNOBS = [ + { key: 'sparsity', label: 'Sparsity', max: 0.9 }, + { key: 'promotion_intensity', label: 'Promotion intensity', max: 0.5 }, + { key: 'stockout_intensity', label: 'Stockout intensity', max: 0.5 }, + { key: 'noise_sigma', label: 'Noise sigma', max: 0.5 }, +] as const + +/** Thresholds above which the NaN-WAPE caveat shows (mirrors the sparse + * preset's documented expected-fail semantics, RUNBOOKS incident 28). */ +const RISKY_SPARSITY = 0.4 +const RISKY_STOCKOUT = 0.25 + +function setKnob( + value: SeedOverrides | null, + key: keyof SeedOverrides, + knobValue: number | undefined +): SeedOverrides | null { + const next: SeedOverrides = { ...(value ?? {}) } + if (knobValue === undefined) { + delete next[key] + } else { + next[key] = knobValue + } + return Object.keys(next).length > 0 ? next : null +} + +export function SeedConfigPanel({ + value, + onChange, + disabled = false, + windowLocked = false, +}: SeedConfigPanelProps) { + const [open, setOpen] = useState(false) + + const touched = value !== null && Object.keys(value).length > 0 + const risky = + (value?.sparsity ?? 0) > RISKY_SPARSITY || (value?.stockout_intensity ?? 0) > RISKY_STOCKOUT + + const summaryParts: string[] = [] + if (value?.stores !== undefined) summaryParts.push(`${value.stores} stores`) + if (value?.products !== undefined) summaryParts.push(`${value.products} products`) + if (value?.window_days !== undefined) summaryParts.push(`${value.window_days} days`) + if (value?.sparsity !== undefined) summaryParts.push(`sparsity ${value.sparsity.toFixed(2)}`) + if (value?.promotion_intensity !== undefined) + summaryParts.push(`promo ${value.promotion_intensity.toFixed(2)}`) + if (value?.stockout_intensity !== undefined) + summaryParts.push(`stockout ${value.stockout_intensity.toFixed(2)}`) + if (value?.noise_sigma !== undefined) + summaryParts.push(`noise ${value.noise_sigma.toFixed(2)}`) + + return ( + + + {/* The trigger stays clickable while disabled so the operator can + still INSPECT the config mid-run; only the controls lock. */} + + + +
+
+ {INT_KNOBS.map((knob) => { + const locked = knob.key === 'window_days' && windowLocked + return ( + + ) + })} +
+ +
+ {FLOAT_KNOBS.map((knob) => { + const knobValue = value?.[knob.key] + return ( +
+ + {knob.label}:{' '} + + {knobValue !== undefined ? knobValue.toFixed(2) : 'preset'} + + + { + const v = vals[0] + // 0 from an untouched slider means "preset" — only an + // explicit non-zero (or a previously set knob) registers. + if (v === 0 && knobValue === undefined) return + onChange(setKnob(value, knob.key, v === 0 ? undefined : v)) + }} + /> +
+ ) + })} +
+ +
+ {touched ? ( + <> +

{summaryParts.join(' · ')}

+ + + ) : ( +

+ No overrides — the scenario preset drives every knob. +

+ )} + {risky && ( + + + high sparsity/stockout can legitimately fail the backtest (NaN WAPE) + + )} +
+
+
+
+ ) +} diff --git a/frontend/src/components/demo/WorkspaceArtifactsPanel.test.tsx b/frontend/src/components/demo/WorkspaceArtifactsPanel.test.tsx index 8a6d0549..6fe96923 100644 --- a/frontend/src/components/demo/WorkspaceArtifactsPanel.test.tsx +++ b/frontend/src/components/demo/WorkspaceArtifactsPanel.test.tsx @@ -32,6 +32,8 @@ const fullWorkspace: WorkspaceDetail = { pinned: false, tags: [], replayed_from_workspace_id: null, + seed_overrides: null, + user_scope: null, notes: null, config_schema_version: 1, } diff --git a/frontend/src/components/demo/WorkspaceEditDialog.test.tsx b/frontend/src/components/demo/WorkspaceEditDialog.test.tsx index ca73e36b..476d120a 100644 --- a/frontend/src/components/demo/WorkspaceEditDialog.test.tsx +++ b/frontend/src/components/demo/WorkspaceEditDialog.test.tsx @@ -32,6 +32,8 @@ const baseItem: WorkspaceListItem = { pinned: false, tags: ['smoke'], replayed_from_workspace_id: null, + seed_overrides: null, + user_scope: null, } let mockDetail: { diff --git a/frontend/src/components/demo/WorkspacePanel.test.tsx b/frontend/src/components/demo/WorkspacePanel.test.tsx index 75bf0f56..9f0ae9be 100644 --- a/frontend/src/components/demo/WorkspacePanel.test.tsx +++ b/frontend/src/components/demo/WorkspacePanel.test.tsx @@ -42,6 +42,8 @@ const baseItem: WorkspaceListItem = { pinned: false, tags: [], replayed_from_workspace_id: null, + seed_overrides: null, + user_scope: null, } const secondItem: WorkspaceListItem = { diff --git a/frontend/src/components/demo/index.ts b/frontend/src/components/demo/index.ts index 88731868..39245c1b 100644 --- a/frontend/src/components/demo/index.ts +++ b/frontend/src/components/demo/index.ts @@ -7,3 +7,6 @@ export * from './ReplayConfirmDialog' export * from './WorkspaceEditDialog' export * from './WorkspaceLineageStrip' export * from './workspace-name' +// E3 (#409) — advanced seed config + focus-pair selection. +export * from './SeedConfigPanel' +export * from './ScopeSelector' diff --git a/frontend/src/components/demo/replay-request.test.ts b/frontend/src/components/demo/replay-request.test.ts index 1e50759d..5b65a677 100644 --- a/frontend/src/components/demo/replay-request.test.ts +++ b/frontend/src/components/demo/replay-request.test.ts @@ -16,6 +16,8 @@ const baseItem: WorkspaceListItem = { pinned: false, tags: [], replayed_from_workspace_id: null, + seed_overrides: null, + user_scope: null, } describe('buildReplayRequest', () => { @@ -36,4 +38,28 @@ describe('buildReplayRequest', () => { expect('workspace_name' in request).toBe(false) expect(request.preservation).toBe('keep') }) + + // E3 (#409) — replay-verbatim covers the recorded story slots. + it('omits the E3 keys on a legacy row (null slots) — byte-identical frame', () => { + const request = buildReplayRequest(baseItem) + expect('seed_overrides' in request).toBe(false) + expect('user_scope' in request).toBe(false) + }) + + it('re-submits recorded seed_overrides and user_scope verbatim', () => { + const slotted: WorkspaceListItem = { + ...baseItem, + seed_overrides: { stores: 8, products: 20, promotion_intensity: 0.3 }, + user_scope: { store_id: 12, product_id: 47 }, + } + const request = buildReplayRequest(slotted) + expect(request.seed_overrides).toEqual({ + stores: 8, + products: 20, + promotion_intensity: 0.3, + }) + expect(request.user_scope).toEqual({ store_id: 12, product_id: 47 }) + // Lineage stays intact when the slots ride along (E1 frozen criterion). + expect(request.replayed_from_workspace_id).toBe(baseItem.workspace_id) + }) }) diff --git a/frontend/src/components/demo/replay-request.ts b/frontend/src/components/demo/replay-request.ts index e2ecee3d..51590be5 100644 --- a/frontend/src/components/demo/replay-request.ts +++ b/frontend/src/components/demo/replay-request.ts @@ -15,5 +15,9 @@ export function buildReplayRequest(ws: WorkspaceListItem): DemoRunRequest { // E1 (#407) — record replay lineage on the NEW row (soft reference). replayed_from_workspace_id: ws.workspace_id, ...(ws.name ? { workspace_name: ws.name } : {}), + // E3 (#409) — replay-verbatim covers the recorded slots; omitted on + // legacy rows (null) so their replay frame stays byte-identical. + ...(ws.seed_overrides ? { seed_overrides: ws.seed_overrides } : {}), + ...(ws.user_scope ? { user_scope: ws.user_scope } : {}), } } diff --git a/frontend/src/pages/showcase.tsx b/frontend/src/pages/showcase.tsx index 6a3497ce..8ff2f8eb 100644 --- a/frontend/src/pages/showcase.tsx +++ b/frontend/src/pages/showcase.tsx @@ -13,6 +13,8 @@ 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 { SeedConfigPanel } from '@/components/demo/SeedConfigPanel' +import { ScopeSelector } from '@/components/demo/ScopeSelector' import { buildReplayRequest } from '@/components/demo/replay-request' import { WORKSPACE_NAME_PATTERN } from '@/components/demo/workspace-name' import { Button } from '@/components/ui/button' @@ -21,7 +23,7 @@ import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { ROUTES } from '@/lib/constants' import { cn } from '@/lib/utils' -import type { WorkspaceListItem } from '@/types/api' +import type { SeedOverrides, UserScope, WorkspaceListItem } from '@/types/api' const TERMINAL_STATUSES = new Set(['pass', 'fail', 'skip', 'warn']) @@ -124,6 +126,10 @@ export default function ShowcasePage() { const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(null) // E2 (#408) — the workspace awaiting replay confirmation (null = no dialog). const [pendingReplay, setPendingReplay] = useState(null) + // E3 (#409) — advanced seed config (sparse; null = preset-driven) and the + // operator-selected focus pair (null = auto-discover first pair). + const [seedOverrides, setSeedOverrides] = useState(null) + const [userScope, setUserScope] = useState(null) // The page (not the panel) resolves the loaded workspace's detail — the // artifacts panel needs detail-only created_objects. @@ -159,6 +165,10 @@ export default function ShowcasePage() { ...(trimmedName ? { workspace_name: trimmedName } : {}), } : {}), + // E3 (#409) — overrides only ride a re-seed run (the backend rejects + // them on skip_seed=true); omit both keys for legacy byte-compat. + ...(reseed && seedOverrides ? { seed_overrides: seedOverrides } : {}), + ...(userScope ? { user_scope: userScope } : {}), }) } @@ -171,6 +181,9 @@ export default function ShowcasePage() { setResetDb(ws.reset) setKeepWorkspace(true) setWorkspaceName(ws.name ?? '') + // E3 (#409) — repopulate the seed-config panel + scope selector. + setSeedOverrides(ws.seed_overrides ?? null) + setUserScope(ws.user_scope ?? null) setSelectedWorkspaceId(ws.workspace_id) } @@ -299,7 +312,13 @@ export default function ShowcasePage() {