diff --git a/AGENTS.md b/AGENTS.md index 07f0a8c5a..642589b80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,156 @@ - Рабочий виртуальный окружение (`.venv`) находится внутри `backend/`: `backend/.venv`. +## Тестовый стенд и запуск + +### Прямой локальный стенд: `run.sh` + +Основной способ запуска тестового стенда для разработки — из корня репозитория: + +```bash +./run.sh --skip-install +``` + +`run.sh` запускает три процесса и завершает их по `Ctrl+C`: + +| Сервис | Порт по умолчанию | URL | +|---|---:|---| +| Backend / FastAPI | `8000` | `http://127.0.0.1:8000` | +| Frontend / Vite | `5173` | `http://127.0.0.1:5173` | +| Gradio agent | `7860` | `http://127.0.0.1:7860` | + +После запуска backend готовность можно проверить через `http://127.0.0.1:8000/api/ready`, +а API-документация доступна на `http://127.0.0.1:8000/docs`. `run.sh` сам не ждет frontend +или agent healthcheck после запуска, поэтому первое открытие UI может потребовать несколько секунд. + +При старте скрипт: + +1. Проверяет `python3 >= 3.9` и `npm`. +2. Загружает `backend/.env` до database preflight. +3. Берет URL БД в порядке `DATABASE_URL`, `POSTGRES_URL`, затем локальный PostgreSQL: + `postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools`. +4. Для недоступного локального PostgreSQL пытается выполнить `docker compose up -d db` и + ждет доступность порта до 20 секунд. +5. Генерирует и сохраняет отсутствующие или некорректные `ENCRYPTION_KEY` и + `AUTH_SECRET_KEY` в `backend/.env`. +6. Перед запуском Uvicorn выполняет `alembic upgrade head`; существующую схему без + `alembic_version` скрипт пытается `alembic stamp head`. +7. Загружает `backend/.env` также в backend и agent. `SERVICE_JWT`, если не задан, получает + случайное значение на текущий запуск; при запуске сервисов в отдельных терминалах его + нужно задать одинаковым явно. + +Опции и переменные `run.sh`: + +```bash +./run.sh --help +./run.sh --skip-install +DEV_MODE=true ./run.sh --skip-install +BACKEND_PORT=8001 FRONTEND_PORT=5174 AGENT_PORT=7861 ./run.sh --skip-install +``` + +- `DEV_MODE=true` включает `uvicorn --reload --reload-dir src` и watchfiles для agent. +- Без `--skip-install` скрипт создает `backend/.venv`, устанавливает `backend/requirements.txt`, + `shared` и frontend dependencies. +- `AGENT_CONFIRM_TOOLS` по умолчанию `true`. +- Backend и agent читают LLM-конфигурацию через `/api/agent/llm-config`; провайдеры обычно + настраиваются в Admin -> LLM Settings. + +### Docker Compose стенд + +Для полного контейнерного стенда использовать `build.sh`, а не смешивать его с прямым +`run.sh`: + +```bash +./build.sh up current +./build.sh status +./build.sh logs current +./build.sh down current +``` + +Профили `build.sh`: + +- `current` (по умолчанию): `docker-compose.yml`, project `superset-tools-current`; +- `master`: `docker-compose.yml`, project `superset-tools-master`; +- `enterprise-clean`: `docker-compose.enterprise-clean.yml`, внешний PostgreSQL и + корпоративные сертификаты. + +Для профилей `current`/`master` переменные берутся из `.env.current`/`.env.master`. +Ключевые host ports профиля `current`: PostgreSQL `5433`, backend `8101`, frontend `8100`, +agent `7860`; для `master`: PostgreSQL `5432`, backend `8001`, frontend `8000`, agent `7860`. +Секреты `AUTH_SECRET_KEY`, `ENCRYPTION_KEY` и `SERVICE_JWT` должны быть заданы явно в +Docker-профиле. Не использовать публичные значения из example-файлов. + +### Изолированный Playwright E2E стенд + +E2E запускается отдельным compose-файлом и не должен использовать тот же проект/порты, что +и текущий локальный стенд: + +```bash +docker compose --env-file .env.e2e -f docker-compose.e2e.yml up -d --build +cd frontend && npm run test:e2e +docker compose --env-file .env.e2e -f docker-compose.e2e.yml down -v +``` + +Профиль по умолчанию использует PostgreSQL `5435`, backend `8103`, frontend `8102` и +Playwright runner на образе `mcr.microsoft.com/playwright:v1.52.0-noble`. Для E2E нужны +`AUTH_SECRET_KEY`, `E2E_USERNAME`, `E2E_PASSWORD`; `GITEA_TOKEN` нужен только тестам, +которые обращаются к Gitea. + +## Тестовые команды + +Перед backend-командами: + +```bash +cd backend +source .venv/bin/activate +``` + +Основные проверки: + +```bash +python -m pytest -v # unit/service tests; integration skipped +python -m pytest -v --run-integration # включая Docker/Testcontainers integration +python -m ruff check . +python -m compileall -q src +alembic heads +alembic upgrade head +``` + +Для спеки 044: + +```bash +python -m pytest -q \ + tests/services/dashboard_testing/registry/test_scenario_*.py \ + tests/api/test_scenario_runs_api.py \ + tests/api/test_scenario_automation_api.py \ + tests/api/test_scenario_analytics_api.py +python -m ruff check \ + src/services/dashboard_testing/execution \ + src/api/routes/dashboard_testing/scenario_runs.py +cd .. +source backend/.venv/bin/activate +python specs/044-dashboard-scenario-execution/prototype/validate_static.py +``` + +Frontend: + +```bash +cd frontend +npm run test -- --run +npm run lint +npm run build +``` + +Важные ограничения: + +- Integration tests пропускаются без `--run-integration`. +- Реальные `alembic check/upgrade` требуют доступный PostgreSQL и корректный + `DATABASE_URL`; SQLite не заменяет проверку production migration chain. +- `run.sh` может автоматически создать локальный `backend/.env` и секреты; не коммитить + этот файл и не переносить его секреты в Docker/E2E конфигурацию. +- Если используется `docker compose`, переменная `SERVICE_JWT` обязательна для backend и + agent; Compose намеренно завершается без нее. + ## AXIOM doc-gen Генерация документации (Doxygen/JSDoc) из семантических контрактов выполняется diff --git a/backend/alembic/versions/b9c0d1e2f3a4_add_investigation_evidence_snapshot.py b/backend/alembic/versions/b9c0d1e2f3a4_add_investigation_evidence_snapshot.py new file mode 100644 index 000000000..b4284af7e --- /dev/null +++ b/backend/alembic/versions/b9c0d1e2f3a4_add_investigation_evidence_snapshot.py @@ -0,0 +1,28 @@ +# #region Alembic.ScenarioInvestigationEvidence [C:2] [TYPE Module] [SEMANTICS alembic,scenario,investigation,evidence] +"""add immutable evidence snapshots to scenario investigation projections""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "b9c0d1e2f3a4" +down_revision: str | Sequence[str] | None = "a8b9c0d1e2f3" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("scenario_investigation_queue", sa.Column("evidence_snapshot", sa.JSON(), nullable=False, server_default=sa.text("'{}'"))) + op.add_column("scenario_investigation_cases", sa.Column("evidence_snapshot", sa.JSON(), nullable=False, server_default=sa.text("'{}'"))) + op.add_column("scenario_investigation_cases", sa.Column("linked_run_ids", sa.JSON(), nullable=False, server_default=sa.text("'[]'"))) + op.add_column("scenario_investigation_cases", sa.Column("owner_id", sa.String(length=128), nullable=True)) + op.create_index("ix_scenario_investigation_cases_owner_id", "scenario_investigation_cases", ["owner_id"]) + + +def downgrade() -> None: + op.drop_index("ix_scenario_investigation_cases_owner_id", table_name="scenario_investigation_cases") + op.drop_column("scenario_investigation_cases", "owner_id") + op.drop_column("scenario_investigation_cases", "linked_run_ids") + op.drop_column("scenario_investigation_cases", "evidence_snapshot") + op.drop_column("scenario_investigation_queue", "evidence_snapshot") +# #endregion Alembic.ScenarioInvestigationEvidence diff --git a/backend/alembic/versions/c1d2e3f4a5b6_add_scenario_live_execution_binding.py b/backend/alembic/versions/c1d2e3f4a5b6_add_scenario_live_execution_binding.py new file mode 100644 index 000000000..552813a75 --- /dev/null +++ b/backend/alembic/versions/c1d2e3f4a5b6_add_scenario_live_execution_binding.py @@ -0,0 +1,41 @@ +# #region Alembic.ScenarioLiveExecutionBinding [C:3] [TYPE Module] [SEMANTICS alembic,migration,scenario,execution,live-binding] +# @ingroup Alembic +# @BRIEF Add nullable immutable live-execution binding identity fields to durable scenario runs. +# @RELATION DEPENDS_ON -> [Models.ScenarioExecution.Run] +# @INVARIANT Existing runs remain valid with no binding and therefore fail closed as unavailable live I/O. +# @RATIONALE The new fields are nullable and additive so historical runs preserve their original lifecycle evidence. +# @REJECTED Backfilling binding authority from environment_id was rejected — an environment label is not a pinned RLS/principal/model capability. +"""add nullable ScenarioRun live execution binding identity snapshot (044)""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "c1d2e3f4a5b6" +down_revision: str | Sequence[str] | None = "b9c0d1e2f3a4" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +# #region Alembic.ScenarioLiveExecutionBinding.Upgrade [C:4] [TYPE Function] [SEMANTICS alembic,migration,scenario,live-binding,additive] +# @BRIEF Add nullable persisted live-binding identity fields without deriving legacy authority. +# @POST Existing scenario runs retain valid null binding fields; a lookup index exists for binding_ref. +# @SIDE_EFFECT Schema mutation: two nullable columns and one index on scenario_runs. +def upgrade() -> None: + op.add_column("scenario_runs", sa.Column("live_execution_binding_ref", sa.String(128), nullable=True)) + op.add_column("scenario_runs", sa.Column("live_execution_binding_snapshot", sa.JSON(), nullable=True)) + op.create_index("ix_scenario_runs_live_execution_binding_ref", "scenario_runs", ["live_execution_binding_ref"]) +# #endregion Alembic.ScenarioLiveExecutionBinding.Upgrade + + +# #region Alembic.ScenarioLiveExecutionBinding.Downgrade [C:3] [TYPE Function] [SEMANTICS alembic,migration,scenario,live-binding,rollback] +# @BRIEF Remove only the additive live-binding fields in reverse dependency order. +# @SIDE_EFFECT Schema mutation: binding-ref index and nullable columns are removed. +def downgrade() -> None: + op.drop_index("ix_scenario_runs_live_execution_binding_ref", table_name="scenario_runs") + op.drop_column("scenario_runs", "live_execution_binding_snapshot") + op.drop_column("scenario_runs", "live_execution_binding_ref") +# #endregion Alembic.ScenarioLiveExecutionBinding.Downgrade +# #endregion Alembic.ScenarioLiveExecutionBinding diff --git a/backend/alembic/versions/d2e3f4a5b6c7_add_scenario_artifact_attempt_projection.py b/backend/alembic/versions/d2e3f4a5b6c7_add_scenario_artifact_attempt_projection.py new file mode 100644 index 000000000..686657d72 --- /dev/null +++ b/backend/alembic/versions/d2e3f4a5b6c7_add_scenario_artifact_attempt_projection.py @@ -0,0 +1,50 @@ +# #region Alembic.ScenarioArtifactAttemptProjection [C:3] [TYPE Module] [SEMANTICS alembic,scenario,artifact,retry,provenance] +# @ingroup Alembic +# @BRIEF Add additive retry-attempt projection fields without rewriting historical artifact rows. +# @RELATION DEPENDS_ON -> [Models.ScenarioExecution.Artifact] +# @INVARIANT Existing evidence defaults to active and remains inspectable; retries retire only +# future step-bound projections, never content/digest audit data. +# @REJECTED Deleting or backfilling artifact content for retry provenance was rejected — legacy +# rows remain valid historical evidence with nullable step/attempt linkage. +"""add active ScenarioArtifact attempt projection for retry closure (044)""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "d2e3f4a5b6c7" +down_revision: str | Sequence[str] | None = "c1d2e3f4a5b6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +# #region Alembic.ScenarioArtifactAttemptProjection.Upgrade [C:4] [TYPE Function] [SEMANTICS alembic,scenario,artifact,retry,additive] +# @BRIEF Add nullable producer linkage and an active projection flag for durable retry evidence. +# @POST Legacy rows are active=true; no historical SHA/ref is changed or removed. +# @SIDE_EFFECT Schema mutation: two nullable provenance columns, active projection flag, invalidation timestamp, and lookup indexes. +def upgrade() -> None: + op.add_column("scenario_artifacts", sa.Column("logical_step_id", sa.String(128), nullable=True)) + op.add_column("scenario_artifacts", sa.Column("attempt", sa.Integer(), nullable=True)) + op.add_column( + "scenario_artifacts", + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + ) + op.add_column("scenario_artifacts", sa.Column("invalidated_at", sa.DateTime(), nullable=True)) + op.create_index("ix_scenario_artifacts_logical_step_id", "scenario_artifacts", ["logical_step_id"]) + op.create_index("ix_scenario_artifacts_is_active", "scenario_artifacts", ["is_active"]) +# #endregion Alembic.ScenarioArtifactAttemptProjection.Upgrade + + +# #region Alembic.ScenarioArtifactAttemptProjection.Downgrade [C:3] [TYPE Function] [SEMANTICS alembic,scenario,artifact,retry,rollback] +# @BRIEF Remove only additive projection metadata; retained evidence rows and digests are untouched. +def downgrade() -> None: + op.drop_index("ix_scenario_artifacts_is_active", table_name="scenario_artifacts") + op.drop_index("ix_scenario_artifacts_logical_step_id", table_name="scenario_artifacts") + op.drop_column("scenario_artifacts", "invalidated_at") + op.drop_column("scenario_artifacts", "is_active") + op.drop_column("scenario_artifacts", "attempt") + op.drop_column("scenario_artifacts", "logical_step_id") +# #endregion Alembic.ScenarioArtifactAttemptProjection.Downgrade +# #endregion Alembic.ScenarioArtifactAttemptProjection diff --git a/backend/alembic/versions/e3f4a5b6c7d8_add_scenario_cancel_drain_deadline.py b/backend/alembic/versions/e3f4a5b6c7d8_add_scenario_cancel_drain_deadline.py new file mode 100644 index 000000000..a9ff05406 --- /dev/null +++ b/backend/alembic/versions/e3f4a5b6c7d8_add_scenario_cancel_drain_deadline.py @@ -0,0 +1,43 @@ +# #region Alembic.ScenarioCancelDrainDeadline [C:3] [TYPE Module] [SEMANTICS alembic,scenario,execution,cancel,drain] +# @ingroup Alembic +# @BRIEF Add nullable persisted cancellation timing so a worker/scheduler can finish a bounded drain after restart. +# @RELATION DEPENDS_ON -> [Models.ScenarioExecution.Run] +# @INVARIANT Existing runs retain null timing fields; only a cancel request pins a deadline. +# @REJECTED Deriving a deadline from mutable target snapshot or an in-memory process timer was rejected. +"""add durable ScenarioRun cancellation drain timing (044)""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "e3f4a5b6c7d8" +down_revision: str | Sequence[str] | None = "d2e3f4a5b6c7" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +# #region Alembic.ScenarioCancelDrainDeadline.Upgrade [C:4] [TYPE Function] [SEMANTICS alembic,scenario,execution,cancel,additive] +# @BRIEF Add nullable request/deadline timestamps and a deadline lookup index. +# @POST Historical ScenarioRuns preserve null timing until explicitly cancelled. +# @SIDE_EFFECT Schema mutation: two nullable lifecycle timestamps and one index. +def upgrade() -> None: + op.add_column("scenario_runs", sa.Column("cancel_requested_at", sa.DateTime(), nullable=True)) + op.add_column("scenario_runs", sa.Column("cancel_drain_deadline_at", sa.DateTime(), nullable=True)) + op.create_index( + "ix_scenario_runs_cancel_drain_deadline_at", + "scenario_runs", + ["cancel_drain_deadline_at"], + ) +# #endregion Alembic.ScenarioCancelDrainDeadline.Upgrade + + +# #region Alembic.ScenarioCancelDrainDeadline.Downgrade [C:3] [TYPE Function] [SEMANTICS alembic,scenario,execution,cancel,rollback] +# @BRIEF Remove only additive timing metadata; no run/step/evidence history is altered. +def downgrade() -> None: + op.drop_index("ix_scenario_runs_cancel_drain_deadline_at", table_name="scenario_runs") + op.drop_column("scenario_runs", "cancel_drain_deadline_at") + op.drop_column("scenario_runs", "cancel_requested_at") +# #endregion Alembic.ScenarioCancelDrainDeadline.Downgrade +# #endregion Alembic.ScenarioCancelDrainDeadline diff --git a/backend/src/api/routes/dashboard_testing/scenario_analytics.py b/backend/src/api/routes/dashboard_testing/scenario_analytics.py index 41d9fa97c..ddfdd004b 100644 --- a/backend/src/api/routes/dashboard_testing/scenario_analytics.py +++ b/backend/src/api/routes/dashboard_testing/scenario_analytics.py @@ -13,7 +13,8 @@ from pydantic import BaseModel from src.dependencies import get_current_user, get_db, has_permission from src.models.scenario_investigation import InvestigationCase, InvestigationQueueItem from src.services.dashboard_testing.analytics.aggregates import case_detail, recurring_failures, scenario_trends -from src.services.dashboard_testing.analytics.investigation import open_case, set_disposition +from src.services.dashboard_testing.analytics.flakiness import ScenarioAnalyticsClient +from src.services.dashboard_testing.analytics.investigation import can_access_case, open_case, set_disposition from src.services.dashboard_testing.registry.health import get_health_badge router = APIRouter(prefix="/api/scenario-analytics", tags=["scenario-analytics"]) @@ -25,6 +26,8 @@ _TRIAGE = Depends(has_permission("scenario:result", "TRIAGE")) class DispositionRequest(BaseModel): disposition: str expected_version: int + verification_evidence: dict[str, Any] | None = None + rationale: str = "" # #region Api.ScenarioAnalytics.Routes.Serialize [C:2] [TYPE Function] [SEMANTICS scenario,analytics,serialize,projection] @@ -50,6 +53,7 @@ def _case_dict(case: InvestigationCase) -> dict[str, Any]: "status": case.status, "disposition": case.disposition, "decision_version": case.decision_version, + "owner_id": case.owner_id, "created_at": case.created_at.isoformat() if case.created_at else None, } # #endregion Api.ScenarioAnalytics.Routes.Serialize @@ -74,11 +78,13 @@ def open_queue_case(fingerprint: str, scenario_id: str, db=_DB, current_user=_US raise HTTPException(status_code=409, detail={"code": "OPEN_CASE_CONFLICT", "detail": str(exc)}) from exc @router.get("/cases/{case_id}") -def get_case(case_id: str, db=_DB, _view=_VIEW): +def get_case(case_id: str, db=_DB, current_user=_USER, _view=_VIEW): result = case_detail(db, case_id) if result is None: raise HTTPException(status_code=404, detail={"code": "CASE_NOT_FOUND"}) case, actions = result["case"], result["actions"] + if not can_access_case(case, actor_id=str(current_user.id), triage=False): + raise HTTPException(status_code=403, detail={"code": "CASE_ACL_DENIED"}) return {"case": _case_dict(case), "actions": actions} @router.post("/cases/{case_id}/disposition") @@ -87,7 +93,7 @@ def disposition(case_id: str, body: DispositionRequest, db=_DB, current_user=_US if existing is None: raise HTTPException(status_code=404, detail={"code": "CASE_NOT_FOUND"}) try: - result = set_disposition(db, case_id, disposition=body.disposition, expected_version=body.expected_version, actor_id=str(current_user.id)) + result = set_disposition(db, case_id, disposition=body.disposition, expected_version=body.expected_version, actor_id=str(current_user.id), verification_evidence=body.verification_evidence, rationale=body.rationale) db.commit() return _case_dict(result) except ValueError as exc: @@ -96,7 +102,8 @@ def disposition(case_id: str, body: DispositionRequest, db=_DB, current_user=_US @router.get("/scenarios/{scenario_id}/health") def health(scenario_id: str, db=_DB, _view=_VIEW): - result = get_health_badge(db, scenario_id) + client = ScenarioAnalyticsClient(db) + result = get_health_badge(db, scenario_id, analytics_client=client) if result is None: raise HTTPException(status_code=404, detail={"code": "SCENARIO_NOT_FOUND"}) return result diff --git a/backend/src/api/routes/dashboard_testing/scenario_automation.py b/backend/src/api/routes/dashboard_testing/scenario_automation.py index f0f965172..d64d1c4fd 100644 --- a/backend/src/api/routes/dashboard_testing/scenario_automation.py +++ b/backend/src/api/routes/dashboard_testing/scenario_automation.py @@ -5,9 +5,13 @@ # @RELATION DEPENDS_ON -> [ScenarioAutomation.Schedule] # @RELATION DEPENDS_ON -> [ScenarioAutomation.Metrics.Aggregate] # @RELATION DEPENDS_ON -> [ScenarioExecution.Runner.Start] +# @RELATION DEPENDS_ON -> [ScenarioExecution.Runner.QueuedDispatch] +# @RELATION DEPENDS_ON -> [ScenarioExecution.EnvironmentPolicy.Resolve] # @INVARIANT Mutations require scenario:automation MANAGE; direct triggers require TRIGGER -# (+ PROD for PROD-classified environments). PROD classification is server-owned +# (+ automation PROD and scenario RUN_PROD for PROD-classified environments). PROD classification is server-owned # (ConfigManager stage/is_production) — never derived from the environment name. +# @INVARIANT 046 routes pass a server-owned automation origin to 044. A revision with a human +# step is rejected as manual-run-only before a ScenarioRun, gate, or notification exists. # @REJECTED A parallel scheduler was rejected — this surface persists configuration; APScheduler # job registration stays server-owned via the 037 framework. # @REJECTED str(environment_id).startswith("prod") PROD classification was rejected — a client-name @@ -19,10 +23,14 @@ from typing import Any from fastapi import APIRouter, Depends, Header, HTTPException, status from pydantic import BaseModel, Field -from src.dependencies import get_config_manager, get_current_user, get_db, has_permission +from src.dependencies import get_config_manager, get_current_user, get_db, get_scheduler_service, has_permission from src.models.scenario_automation import AutomationPolicy, ScenarioNotificationEvent, ScenarioSchedule, ScenarioTriggerRule from src.services.dashboard_testing.automation.metrics import automation_metrics from src.services.dashboard_testing.automation.retention import tier_limits +from src.services.dashboard_testing.automation.trigger import dispatch_trigger_event +from src.services.dashboard_testing.execution.environment_policy import ( + resolve_environment_execution_policy, +) from src.services.dashboard_testing.execution.runner import start_run router = APIRouter(prefix="/api/scenario-automation", tags=["scenario-automation"]) @@ -47,6 +55,8 @@ class ScheduleRequest(BaseModel): timezone: str = "UTC" policy_id: str | None = None missed_execution_policy: str = "skip" + max_instances: int = Field(default=1, ge=1) + misfire_grace_time: int = Field(default=300, ge=0) enabled: bool = True # #endregion Api.ScenarioAutomation.ScheduleRequest @@ -88,6 +98,16 @@ class ApiTriggerRequest(BaseModel): # #endregion Api.ScenarioAutomation.ApiTriggerRequest +# #region Api.ScenarioAutomation.EventDispatchRequest [C:1] [TYPE Class] [SEMANTICS scenario,automation,api,trigger,event] +# @ingroup Api +# @BRIEF Server-owned typed event envelope accepted by the 046 trigger dispatcher. +class EventDispatchRequest(BaseModel): + type: str + fingerprint: str + overlap: bool = False +# #endregion Api.ScenarioAutomation.EventDispatchRequest + + def _validate_rule_trigger(trigger: str) -> None: if trigger not in TRIGGER_TYPES: raise HTTPException(status_code=422, detail={"code": "INVALID_TRIGGER", "detail": f"trigger must be one of {sorted(TRIGGER_TYPES)}"}) @@ -98,30 +118,12 @@ def _validate_missed_policy(policy: str) -> None: raise HTTPException(status_code=422, detail={"code": "INVALID_MISSED_POLICY", "detail": f"missed_execution_policy must be one of {sorted(MISSED_POLICIES)}"}) -# #region Api.ScenarioAutomation.ClassifyProdEnvironment [C:3] [TYPE Function] [SEMANTICS scenario,automation,api,env,prod,classification] -# @ingroup Api -# @BRIEF Server-owned PROD classification of a trigger environment from ConfigManager -# (Environment.stage == PROD or Environment.is_production) — never a name heuristic. -# @PRE config_manager resolves the environment by id/name; unknown environments are refused. -# @POST True for PROD-classified environments; raises 422 ENVIRONMENT_NOT_FOUND when the -# environment is not configured (the server cannot classify it). -# @RATIONALE The trigger previously classified PROD with str(environment_id).startswith("prod") — -# a client-name heuristic that misclassifies e.g. "production-backup" and is spoofable -# by naming an environment to dodge the PROD gate. The server-owned classification is -# ConfigManager.get_environment() which carries stage (DEV/PREPROD/PROD) + is_production. -# @REJECTED Keeping startswith("prod") was rejected — it is not server-owned, is trivially bypassed -# by environment naming, and contradicts the 036 PROD-gate authority model. -def _classify_prod_environment(environment_id: str, config_manager) -> bool: - env = config_manager.get_environment(environment_id) if config_manager is not None else None - if env is None: - raise HTTPException(status_code=422, detail={"code": "ENVIRONMENT_NOT_FOUND", "detail": f"environment {environment_id!r} is not configured; cannot classify PROD"}) - stage = str(getattr(env, "stage", "") or "").upper() - return bool(getattr(env, "is_production", False)) or stage == "PROD" -# #endregion Api.ScenarioAutomation.ClassifyProdEnvironment - - # #region Api.ScenarioAutomation.Schedules [C:3] [TYPE Block] [SEMANTICS scenario,automation,api,schedule,crud] # @ingroup Api +# @BRIEF Persist schedule configuration and register/remove its server-owned scheduler job. +# @RELATION DEPENDS_ON -> [ScenarioAutomation.Schedule.DeriveApsParams] +# @INVARIANT Schedule CRUD may register work but never claims or executes a queued ScenarioRun; +# the separate 044 queued dispatcher remains the sole initial execution authority. @router.get("/schedules") def list_schedules(db=_DB): return db.query(ScenarioSchedule).order_by(ScenarioSchedule.created_at.desc()).all() @@ -134,6 +136,20 @@ def create_schedule(body: ScheduleRequest, db=_DB, _perm=_MANAGE): db.add(schedule) db.commit() db.refresh(schedule) + if schedule.enabled: + get_scheduler_service().add_scenario_job( + schedule_id=schedule.id, + scenario_id=schedule.scenario_id, + revision_policy=schedule.revision_policy, + revision_id=schedule.revision_id, + environment_id=schedule.environment_id, + cron_expr=schedule.cron_expr, + timezone=schedule.timezone, + policy_id=schedule.policy_id, + misfire_grace_time=schedule.misfire_grace_time, + max_instances=schedule.max_instances, + missed_execution_policy=schedule.missed_execution_policy, + ) return schedule @@ -147,6 +163,22 @@ def update_schedule(schedule_id: str, body: ScheduleRequest, db=_DB, _perm=_MANA setattr(schedule, key, value) db.commit() db.refresh(schedule) + if schedule.enabled: + get_scheduler_service().add_scenario_job( + schedule_id=schedule.id, + scenario_id=schedule.scenario_id, + revision_policy=schedule.revision_policy, + revision_id=schedule.revision_id, + environment_id=schedule.environment_id, + cron_expr=schedule.cron_expr, + timezone=schedule.timezone, + policy_id=schedule.policy_id, + misfire_grace_time=schedule.misfire_grace_time, + max_instances=schedule.max_instances, + missed_execution_policy=schedule.missed_execution_policy, + ) + else: + get_scheduler_service().remove_scenario_job(schedule.id) return schedule @@ -155,6 +187,7 @@ def delete_schedule(schedule_id: str, db=_DB, _perm=_MANAGE): schedule = db.query(ScenarioSchedule).filter(ScenarioSchedule.id == schedule_id).first() if schedule is None: raise HTTPException(status_code=404, detail={"code": "SCHEDULE_NOT_FOUND"}) + get_scheduler_service().remove_scenario_job(schedule.id) db.delete(schedule) db.commit() return None @@ -249,6 +282,44 @@ def list_notifications(limit: int = 100, db=_DB): # #endregion Api.ScenarioAutomation.Notifications +# #region Api.ScenarioAutomation.EventDispatch [C:3] [TYPE Function] [SEMANTICS scenario,automation,api,event,trigger,persistence] +# @ingroup Api +# @BRIEF Persist policy-allowed event-triggered runs without request-time execution. +# @RELATION CALLS -> [ScenarioAutomation.Trigger.Dispatch] +# @RELATION DEPENDS_ON -> [ScenarioExecution.Runner.QueuedDispatch] +# @POST Returns accepted run ids only after persistence; adapter execution waits for the server dispatcher CAS. +# @INVARIANT A malformed automated human plan is refused before creation; this HTTP route never +# fabricates manual authority or invokes a walker. +# @INVARIANT ConfigManager classifies every persisted trigger target before start; an event payload +# cannot provide an is_prod/environment_class override or create an unknown target. +@router.post("/events/dispatch", status_code=status.HTTP_202_ACCEPTED) +def api_dispatch_event( + body: EventDispatchRequest, + db=_DB, + _perm=_TRIGGER, + config_manager=_CONFIG_MANAGER, +): + if body.type not in TRIGGER_TYPES: + raise HTTPException(status_code=422, detail={"code": "INVALID_TRIGGER"}) + try: + created = dispatch_trigger_event( + db, + {"type": body.type, "fingerprint": body.fingerprint, "overlap": body.overlap}, + config_manager=config_manager, + ) + db.commit() + return {"run_ids": created, "count": len(created)} + except ValueError as exc: + db.rollback() + if str(exc) == "ENVIRONMENT_NOT_CONFIGURED": + raise HTTPException( + status_code=422, + detail={"code": str(exc), "detail": str(exc)}, + ) from exc + raise +# #endregion Api.ScenarioAutomation.EventDispatch + + # #region Api.ScenarioAutomation.Metrics [C:3] [TYPE Function] [SEMANTICS scenario,automation,api,metrics] # @ingroup Api # @BRIEF Operational metrics over persisted schedules, trigger rules, runs and notifications. @@ -288,10 +359,20 @@ def retention_defaults(): # @ingroup Api # @BRIEF External API run trigger (SCAUTO-FR-011) — starts a scenario run with an # Idempotency-Key; PROD-classified environments (server-owned ConfigManager -# classification, never a name heuristic) require the PROD scope. +# classification, never a name heuristic) require automation PROD + scenario RUN_PROD. # @PRE Idempotency-Key header is required; scenario_id path parameter names the scenario; # the environment must be configured server-side or the trigger is refused (422). # @POST Returns 202 with run_id/status; 409 IDEMPOTENCY_KEY_REUSED on changed request. +# @POST A human-containing revision returns 409 AUTOMATION_INELIGIBLE_HUMAN_STEP and creates no run. +# @RELATION CALLS -> [ScenarioExecution.Runner.Start] +# @RELATION CALLS -> [ScenarioExecution.EnvironmentPolicy.Resolve] +# @RELATION DEPENDS_ON -> [ScenarioExecution.Runner.QueuedDispatch] +# @SIDE_EFFECT Commits only a permitted ScenarioRun; rejection rolls back without a run, gate, notification, or queue row. +# @INVARIANT API automation origin is server-owned and cannot consume or substitute a HumanCheckpoint or ActionApprovalGate. +# @INVARIANT HTTP trigger/replay is persistence-only: its accepted row remains queued/pending until +# the separate server dispatcher wins the queued->running status CAS. +# @INVARIANT PROD classification and RUN_PROD validation complete before start_run; a valid +# automation intent persists pending_approval plus its ActionApprovalGate, never queued. @router.post("/scenarios/{scenario_id}/trigger", status_code=status.HTTP_202_ACCEPTED) def api_trigger_scenario( scenario_id: str, @@ -302,9 +383,18 @@ def api_trigger_scenario( _perm=_TRIGGER, config_manager=_CONFIG_MANAGER, ): - is_prod = _classify_prod_environment(body.environment_id, config_manager) - if is_prod: + try: + environment_policy = resolve_environment_execution_policy( + body.environment_id, config_manager + ) + except ValueError as exc: + raise HTTPException( + status_code=422, + detail={"code": str(exc), "detail": str(exc)}, + ) from exc + if environment_policy.is_prod: has_permission("scenario:automation", "PROD")(current_user=current_user) + has_permission("scenario", "RUN_PROD")(current_user=current_user) revision_id = body.revision_id or "" try: run = start_run( @@ -315,7 +405,8 @@ def api_trigger_scenario( body.environment_id, actor=str(getattr(current_user, "id", "") or getattr(current_user, "username", "")), idempotency_key=idempotency_key, - is_prod=is_prod, + config_manager=config_manager, + trigger_source="api", ) db.commit() except PermissionError as exc: @@ -323,7 +414,11 @@ def api_trigger_scenario( raise HTTPException(status_code=403, detail={"code": "PROD_APPROVAL_REQUIRED", "detail": str(exc)}) from exc except ValueError as exc: db.rollback() - code = "IDEMPOTENCY_KEY_REUSED" if "IDEMPOTENCY" in str(exc) else "RUN_START_CONFLICT" + code = ( + "AUTOMATION_INELIGIBLE_HUMAN_STEP" + if str(exc) == "AUTOMATION_INELIGIBLE_HUMAN_STEP" + else "IDEMPOTENCY_KEY_REUSED" if "IDEMPOTENCY" in str(exc) else "RUN_START_CONFLICT" + ) raise HTTPException(status_code=409, detail={"code": code, "detail": str(exc)}) from exc return {"run_id": run.id, "status": run.status, "scenario_id": scenario_id} # #endregion Api.ScenarioAutomation.DirectTrigger diff --git a/backend/src/api/routes/dashboard_testing/scenario_runs.py b/backend/src/api/routes/dashboard_testing/scenario_runs.py index ea848d697..07397fc07 100644 --- a/backend/src/api/routes/dashboard_testing/scenario_runs.py +++ b/backend/src/api/routes/dashboard_testing/scenario_runs.py @@ -9,7 +9,8 @@ # @RELATION DEPENDS_ON -> [ScenarioExecution.Events] # @INVARIANT Static paths (/compare) are registered before dynamic /{run_id} — FastAPI matches # routes in declaration order, so /compare must never be captured as a run id. -# @INVARIANT Start requires scenario:run; a PROD start additionally requires scenario:run:prod. +# @INVARIANT Start requires scenario:run; server-owned PROD classification additionally requires +# scenario:run:prod. Request bodies cannot select the classification. # @INVARIANT All responses are explicit dicts (no ORM leakage); datetimes ISO-8601. from __future__ import annotations @@ -20,11 +21,14 @@ from fastapi.responses import StreamingResponse from pydantic import BaseModel from src.core.database import get_db -from src.dependencies import get_current_user, has_permission +from src.dependencies import get_config_manager, get_current_user, has_permission from src.models.scenario_approval import ActionApprovalGate from src.models.scenario_run import ScenarioRun, ScenarioStepRun from src.services.dashboard_testing.execution.approval import decide_approval_gate from src.services.dashboard_testing.execution.comparison import compare_runs +from src.services.dashboard_testing.execution.environment_policy import ( + resolve_environment_execution_policy, +) from src.services.dashboard_testing.execution.events import derive_run_events, render_sse_frames from src.services.dashboard_testing.execution.lifecycle import ( cancel_run, @@ -33,7 +37,12 @@ from src.services.dashboard_testing.execution.lifecycle import ( retry_step, ) from src.services.dashboard_testing.execution.result import build_result -from src.services.dashboard_testing.execution.runner import start_run +from src.services.dashboard_testing.execution.runner import ( + continue_after_human_decision, + continue_after_infrastructure_resume, + continue_after_step_retry, + start_run, +) runs_router = APIRouter(prefix="/api/scenario-runs", tags=["scenario-runs"]) history_router = APIRouter(prefix="/api/scenarios", tags=["scenario-runs-history"]) @@ -41,6 +50,7 @@ history_router = APIRouter(prefix="/api/scenarios", tags=["scenario-runs-history _DB = Depends(get_db) _USER = Depends(get_current_user) _RUN_PERMISSION = Depends(has_permission("scenario", "RUN")) +_CONFIG_MANAGER = Depends(get_config_manager) # PROD-classified run authority (catalog pair ("scenario", "RUN_PROD"), display scenario:run_prod). # Guards PROD starts AND the ActionApprovalGate decision endpoint — never the bare RUN scope. _RUN_PROD_PERMISSION = Depends(has_permission("scenario", "RUN_PROD")) @@ -51,7 +61,11 @@ class StartRunRequest(BaseModel): revision_id: str environment_id: str params: dict[str, Any] = {} + # Compatibility-only: execution authority comes from ConfigManager, never this body field. is_prod: bool = False + dashboard_release_id: str | None = None + baseline_set: str | None = None + execution_toggles: dict[str, bool] = {} class HumanDecisionRequest(BaseModel): disposition: str @@ -108,7 +122,18 @@ def _require_run_prod(current_user) -> None: # #region Api.ScenarioExecution.Routes.Start [C:4] [TYPE Function] [SEMANTICS api,scenario,run,start,idempotency,rbac] # @ingroup Api # @BRIEF POST /api/scenario-runs — create a run (Idempotency-Key); PROD requires scenario:run:prod. -# @POST 201 with run dict; 409 IDEMPOTENCY_KEY_REUSED / RUN_START_CONFLICT; 403 on missing RBAC. +# @RELATION CALLS -> [ScenarioExecution.Runner.Start] +# @RELATION CALLS -> [ScenarioExecution.EnvironmentPolicy.Resolve] +# @POST 201 with queued/pending-approval run; 409 IDEMPOTENCY_KEY_REUSED / RUN_START_CONFLICT; +# 403 on missing RBAC; 422 ENVIRONMENT_NOT_CONFIGURED before any durable side effect. +# @INVARIANT This authenticated analyst route supplies the trusted manual origin itself; clients +# cannot select trigger_source in StartRunRequest. +# @INVARIANT HTTP start/replay is persistence-only: it creates or returns a queued/pending row but +# never creates a step, lease, notification, queue signal, or adapter invocation. +# @INVARIANT PROD authority and gate selection use server ConfigManager policy even when the +# compatibility is_prod field is omitted, false, or true for a non-PROD environment. +# @REJECTED Treating StartRunRequest.is_prod as PROD authority was rejected: a caller could suppress +# a real gate or manufacture one for PREPROD. @runs_router.post("", status_code=status.HTTP_201_CREATED) def api_start_run( body: StartRunRequest, @@ -116,16 +141,23 @@ def api_start_run( db=_DB, current_user=_USER, _=_RUN_PERMISSION, + config_manager=_CONFIG_MANAGER, ): - if body.is_prod: - _require_run_prod(current_user) try: + if resolve_environment_execution_policy( + body.environment_id, config_manager + ).is_prod: + _require_run_prod(current_user) run = start_run( db, body.scenario_id, body.revision_id, body.params, body.environment_id, actor=str(getattr(current_user, "id", "unknown")), idempotency_key=idempotency_key, - is_prod=body.is_prod, - approval_granted=body.is_prod, + config_manager=config_manager, + auto_advance=False, + dashboard_release_id=body.dashboard_release_id, + baseline_set=body.baseline_set, + execution_toggles=body.execution_toggles, + trigger_source="manual", ) db.commit() return _run_to_dict(run) @@ -134,6 +166,11 @@ def api_start_run( raise HTTPException(status_code=403, detail={"code": "PROD_APPROVAL_REQUIRED", "detail": str(exc)}) from exc except ValueError as exc: db.rollback() + if str(exc) == "ENVIRONMENT_NOT_CONFIGURED": + raise HTTPException( + status_code=422, + detail={"code": str(exc), "detail": str(exc)}, + ) from exc code = "IDEMPOTENCY_KEY_REUSED" if "IDEMPOTENCY" in str(exc) else "RUN_START_CONFLICT" raise HTTPException(status_code=409, detail={"code": code, "detail": str(exc)}) from exc # #endregion Api.ScenarioExecution.Routes.Start @@ -210,11 +247,14 @@ def api_cancel_run(run_id: str, db=_DB, _=_RUN_PERMISSION): # #region Api.ScenarioExecution.Routes.Resume [C:3] [TYPE Function] [SEMANTICS api,scenario,run,resume,token] # @ingroup Api # @BRIEF POST /api/scenario-runs/{run_id}/resume — resume an infrastructure-paused run. -# @POST 200 run dict; 409 for human checkpoints, stale tokens, terminal runs. +# @POST 200 terminal/current run dict after advancing the persisted frontier; 409 for human +# checkpoints, stale tokens, terminal runs or a run that is not paused. +# @INVARIANT This route never consumes a HumanCheckpoint or a PROD ActionApprovalGate. @runs_router.post("/{run_id}/resume") def api_resume_run(run_id: str, body: ResumeRequest, db=_DB, _=_RUN_PERMISSION): try: result = resume_run(db, run_id, resume_token=body.resume_token, resume_reason=body.resume_reason) + continue_after_infrastructure_resume(db, run_id, worker_id="infrastructure-resume") db.commit() return _run_to_dict(result) except ValueError as exc: @@ -226,7 +266,9 @@ def api_resume_run(run_id: str, body: ResumeRequest, db=_DB, _=_RUN_PERMISSION): # #region Api.ScenarioExecution.Routes.HumanDecision [C:3] [TYPE Function] [SEMANTICS api,scenario,run,human,decision,checkpoint] # @ingroup Api # @BRIEF POST /api/scenario-runs/{run_id}/human/decision — resolve a HumanCheckpoint disposition. -# @POST 200 checkpoint; 404 CHECKPOINT_NOT_FOUND; 409 CHECKPOINT_CONFLICT. +# @POST 200 checkpoint after internally continuing ready dependents; 404 CHECKPOINT_NOT_FOUND; +# 409 CHECKPOINT_CONFLICT. +# @INVARIANT This observation checkpoint is distinct from the PROD ActionApprovalGate. @runs_router.post("/{run_id}/human/decision") def api_decide_human(run_id: str, body: HumanDecisionRequest, db=_DB, current_user=_USER, _=_RUN_PERMISSION): from src.models.scenario_checkpoint import HumanCheckpoint @@ -240,6 +282,9 @@ def api_decide_human(run_id: str, body: HumanDecisionRequest, db=_DB, current_us expected_version=body.expected_version, actor_id=str(getattr(current_user, "id", "unknown")), comment=body.comment, ) + continue_after_human_decision( + db, run_id, worker_id=f"human-decision-{getattr(current_user, 'id', 'unknown')}", + ) db.commit() return { "id": result.id, @@ -265,6 +310,9 @@ def api_decide_human(run_id: str, body: HumanDecisionRequest, db=_DB, current_us # @PRE Caller holds scenario RUN_PROD (the gate's required_permission, display scenario:run_prod). # @POST 200 gate dict + run status; 404 GATE_NOT_FOUND; 409 GATE_ALREADY_DECIDED (CAS conflict); # 422 INVALID_GATE_DECISION. +# @RELATION DEPENDS_ON -> [ScenarioExecution.Runner.QueuedDispatch] +# @INVARIANT Approval CAS can expose an approved run as queued, but only the separate server +# dispatcher may claim and execute its initial frontier. # @TEST_EDGE approve -> queued; deny -> blocked; already_decided -> 409; missing_gate -> 404; # missing_run_prod_scope -> 403; invalid_decision -> 422. @runs_router.post("/{run_id}/approval/decision") @@ -309,11 +357,14 @@ def api_decide_approval_gate(run_id: str, body: ApprovalDecisionRequest, db=_DB, # #region Api.ScenarioExecution.Routes.Retry [C:3] [TYPE Function] [SEMANTICS api,scenario,run,retry,step] # @ingroup Api # @BRIEF POST /api/scenario-runs/{run_id}/steps/{logical_step_id}/retry — retry + downstream closure. -# @POST 200 list of affected steps; 409 RETRY_CONFLICT. +# @POST 200 list of re-executed affected steps; 409 RETRY_CONFLICT. +# @INVARIANT The route first invalidates the entire retry closure, then advances only its queued +# frontier; historical artifacts remain audit rows but cannot project into the result. @runs_router.post("/{run_id}/steps/{logical_step_id}/retry") def api_retry_step(run_id: str, logical_step_id: str, db=_DB, _=_RUN_PERMISSION): try: result = retry_step(db, run_id, logical_step_id) + continue_after_step_retry(db, run_id, worker_id="api-step-retry") db.commit() return [ { diff --git a/backend/src/app.py b/backend/src/app.py index ea35cbc63..aedcf6f4a 100755 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -3,8 +3,11 @@ # @BRIEF The main entry point for the FastAPI application. # @LAYER API # @RELATION DEPENDS_ON -> [Api.Init.ApiRoutesModule] +# @RELATION CALLS -> [Dependencies.AppDependencies.LiveExecutionComposition] # @INVARIANT All WebSocket connections must be properly cleaned up on disconnect. # @INVARIANT All WebSocket connections must be authenticated via JWT or API key token (see [SEC:C-3]). +# @INVARIANT Startup initializes a fail-closed 044 LiveExecutionCompositionRoot; runtime providers +# must be registered server-side and never inferred from HTTP/run metadata. # @PRE Python environment and dependencies installed; configuration database available. # @POST FastAPI app instance is created, middleware configured, and routes registered. # @SIDE_EFFECT Starts background scheduler and binds network ports for HTTP/WS traffic. @@ -86,19 +89,47 @@ from .core.logger import belief_scope, logger from .core.utils.network import NetworkError from .dependencies import ( get_async_job_runner, + get_config_manager, get_current_user, + get_live_execution_composition_root, get_scheduler_service, get_task_manager, ) from .models.auth import Role, User +# #region App.AppModule.LiveExecutionCompositionBootstrap [C:4] [TYPE Function] [SEMANTICS scenario,execution,composition,startup] +# @BRIEF Register trusted deployment live bindings before the scheduler can dispatch queued ScenarioRuns. +# @RELATION CALLS -> [ScenarioExecution.LiveCompositionRoot.Bootstrap] +# @POST Default dispatcher sees exact configured 037 providers or typed unavailable bindings; HTTP/run +# metadata never creates a live client. +# @INVARIANT Browser and Screenshot providers are separately registered server-side; their absence does +# not prevent startup and remains typed unavailable. +# @REJECTED Deferring Superset provider creation to a queued run was rejected — that would derive +# execution authority from a persisted/request identifier instead of deployment config. +def initialize_live_execution_composition() -> int: + from .services.dashboard_testing.execution.live_composition import ( + bootstrap_live_execution_composition, + ) + + return bootstrap_live_execution_composition( + get_live_execution_composition_root(), + config_manager=get_config_manager(), + run_async=get_async_job_runner().run, + ) +# #endregion App.AppModule.LiveExecutionCompositionBootstrap + + # #region App.AppModule.Lifespan [C:3] [TYPE Function] # @ingroup Module # @BRIEF Async context manager for FastAPI startup/shutdown lifecycle. # @RELATION CALLS -> [Core.Database.InitDb] # @RELATION CALLS -> [Dependencies.AppDependencies] +# @RELATION CALLS -> [App.AppModule.LiveExecutionCompositionBootstrap] # @POST On startup: admin exists, scheduler started. On shutdown: scheduler stopped. +# @INVARIANT The trusted live-composition bootstrap runs after AsyncJobRunner initialization and +# before scheduler startup; queued dispatch therefore cannot observe an unbootstrapped +# default root during normal application startup. # @RATIONALE Alembic migrations removed from lifespan — they now run exclusively # in docker/backend.entrypoint.sh (wait_for_db → alembic upgrade head). # Running migrations in both places added ~5s startup overhead and masked @@ -193,6 +224,9 @@ async def lifespan(app: FastAPI): logger.reason("Initializing AsyncJobRunner") get_async_job_runner() # Initialize singleton with running event loop BEFORE scheduler starts + logger.reason("Bootstrapping fail-closed ScenarioRun live composition root") + live_bindings = initialize_live_execution_composition() + logger.reason("ScenarioRun live composition bootstrap complete", payload={"bindings": live_bindings}) logger.reason("Starting scheduler") scheduler = get_scheduler_service() scheduler.start() diff --git a/backend/src/core/config_models.py b/backend/src/core/config_models.py index 79391334c..f04bda9c2 100755 --- a/backend/src/core/config_models.py +++ b/backend/src/core/config_models.py @@ -17,6 +17,7 @@ import uuid from datetime import datetime, timezone +from typing import Any from pydantic import BaseModel, Field, field_validator @@ -218,6 +219,22 @@ class DatabaseConnection(BaseModel): # #endregion Core.ConfigModels.DatabaseConnection +# #region Core.ConfigModels.ScenarioLiveExecutionBinding [C:3] [TYPE DataClass] [SEMANTICS scenario,execution,live,composition,config] +# @BRIEF Trusted deployment configuration for one exact 044 live Superset binding. +# @RELATION DEPENDS_ON -> [ScenarioExecution.LiveBinding.Identity] +# @INVARIANT This record contains only the persisted identity snapshot and immutable query-model +# snapshot; credentials remain solely in the configured Environment record. +# @INVARIANT `binding_snapshot` is not authority by itself: before startup uses an enabled record it +# must satisfy the exact LiveExecutionBinding identity-field allowlist. +# @REJECTED Environment IDs alone are not a live binding — query/principal/RLS fingerprints must be +# present in binding_snapshot before startup can create a SupersetClient. +class ScenarioLiveExecutionBindingConfig(BaseModel): + enabled: bool = False + binding_snapshot: dict[str, Any] + query_model_snapshot: dict[str, Any] +# #endregion Core.ConfigModels.ScenarioLiveExecutionBinding + + # #region Core.ConfigModels.GlobalSettings [TYPE DataClass] # @ingroup Core # @BRIEF Represents global application settings. @@ -229,6 +246,9 @@ class GlobalSettings(BaseModel): logging: LoggingConfig = Field(default_factory=LoggingConfig) features: FeaturesConfig = Field(default_factory=FeaturesConfig) connections: list[DatabaseConnection] = [] + scenario_live_execution_bindings: list[ScenarioLiveExecutionBindingConfig] = Field( + default_factory=list + ) llm: dict = Field( default_factory=lambda: { "providers": [], diff --git a/backend/src/core/scheduler.py b/backend/src/core/scheduler.py index 57e320ed6..33e34d8cf 100644 --- a/backend/src/core/scheduler.py +++ b/backend/src/core/scheduler.py @@ -5,7 +5,7 @@ # @RELATION DEPENDS_ON -> Core.Manager.TaskManager # @RELATION DEPENDS_ON -> [Core.AsyncJobRunner] import asyncio -from datetime import date, datetime, time, timedelta +from datetime import UTC, date, datetime, time, timedelta from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore from apscheduler.schedulers.background import BackgroundScheduler @@ -42,6 +42,55 @@ def execute_scheduled_lifecycle_retention() -> None: # #endregion Core.Scheduler.ExecuteScheduledLifecycleRetention +# #region Core.Scheduler.ExecuteScenarioCancelFinalizer [C:4] [TYPE Function] [SEMANTICS scheduler,scenario,execution,cancel,drain] +# @BRIEF APScheduler callback that finalizes persisted ScenarioRun cancellations after their bounded drain deadline. +# @RELATION CALLS -> [ScenarioExecution.Lifecycle.CancelFinalizer] +# @POST Expired cancel_requested runs become cancelled in one committed lifecycle transaction. +# @INVARIANT The scheduler never dispatches an executor, decides a HumanCheckpoint, approves a gate, +# or deletes artifact/lease audit history while finalizing a cancelled run. +# @REJECTED Relying on the API process that requested cancellation was rejected — an in-flight I/O +# worker or process restart otherwise leaves cancel_requested work stranded. +def execute_scheduled_scenario_cancel_finalizer() -> None: + db = SessionLocal() + try: + from src.services.dashboard_testing.execution.lifecycle import finalize_expired_cancellations + + terminalized = finalize_expired_cancellations(db) + db.commit() + logger.reason( + "Scenario cancellation drain finalizer completed", + payload={"terminalized": len(terminalized)}, + ) + except Exception as exc: + db.rollback() + logger.explore("Scenario cancellation drain finalizer failed", error=str(exc)) + finally: + db.close() +# #endregion Core.Scheduler.ExecuteScenarioCancelFinalizer + + +# #region Core.Scheduler.ExecuteQueuedScenarioDispatch [C:4] [TYPE Function] [SEMANTICS scheduler,scenario,execution,queue,dispatch] +# @BRIEF Advance durable queued ScenarioRuns through the server worker composition, never an HTTP request. +# @RELATION CALLS -> [ScenarioExecution.Runner.QueuedDispatch] +# @INVARIANT The dispatcher relies on persisted queued->running CAS; overlapping scheduler ticks +# cannot invoke the same run's executor twice. +# @REJECTED Treating HTTP POST as a worker loop was rejected — it would duplicate side effects on replay. +def execute_scheduled_queued_scenario_dispatch() -> None: + db = SessionLocal() + try: + from src.services.dashboard_testing.execution.runner import dispatch_queued_runs + + outcomes = dispatch_queued_runs(db, worker_id="scenario-queue-scheduler") + db.commit() + logger.reason("Queued ScenarioRun dispatch completed", payload={"dispatched": len(outcomes)}) + except Exception as exc: + db.rollback() + logger.explore("Queued ScenarioRun dispatch failed", error=str(exc)) + finally: + db.close() +# #endregion Core.Scheduler.ExecuteQueuedScenarioDispatch + + # #region Core.Scheduler.ExecuteScheduledBackup [C:3] [TYPE Function] [SEMANTICS scheduler,backup,apscheduler,persistence] # @ingroup Core # @BRIEF APScheduler callback for backup jobs that resolves runtime dependencies at execution time. @@ -145,6 +194,75 @@ def execute_scheduled_verification_check() -> None: # #endregion Core.Scheduler.ExecuteScheduledVerificationCheck +# #region Core.Scheduler.ExecuteScheduledScenario [C:4] [TYPE Function] [SEMANTICS scheduler,scenario,apscheduler,cron,auto] +# @defgroup Core APScheduler callback for 046 scenario schedule triggers. +# @BRIEF Resolve the scenario schedule and persist a scheduled run for separate 044 dispatch. +# @RELATION CALLS -> [ScenarioExecution.Runner.Start] +# @RELATION DEPENDS_ON -> [ScenarioExecution.Runner.QueuedDispatch] +# @PRE schedule_id exists in scenario_schedules table. +# @POST A scheduled ScenarioRun row is created queued or pending_approval under the shared 044 +# policy; an adapter may run only after gate approval and the separate dispatcher wins CAS. +# @SIDE_EFFECT Persists ScenarioRun; failures logged but never raised (observability-only). +# @INVARIANT The scheduler passes its server-owned scheduled origin before 044 creates a run; +# a human-containing revision is rejected as manual_run_only rather than created as manual. +# @INVARIANT Scheduler callback/replay is persistence-only and cannot bypass the 044 initial-dispatch CAS. +# @INVARIANT Scheduled PROD classification and its durable ActionApprovalGate are selected only by +# ScenarioExecution.Runner.Start's server-owned EnvironmentPolicy; callback arguments +# cannot downgrade a target or dispatch pending_approval work. +# @RATIONALE Module-level callback follows the same pattern as backup/validation callbacks so +# the persistent job store never serializes dependencies. Dependencies (runner, models) +# are resolved at execution time to avoid import cycles. +# @NOTE start_run is synchronous (SQLAlchemy Session), so BackgroundScheduler thread pool +# executes it directly; no AsyncJobRunner bridge needed. +def execute_scheduled_scenario(schedule_id: str, scenario_id: str, revision_policy: str, revision_id: str | None, environment_id: str, cron_expr: str, timezone: str, policy_id: str | None) -> None: + seed_trace_id() + del cron_expr, timezone, policy_id + db = SessionLocal() + try: + from ..services.dashboard_testing.execution.runner import start_run + + resolved_revision_id = revision_id + if revision_policy == "current": + from src.models.scenario_registry import ScenarioRegistryEntry + entry = db.query(ScenarioRegistryEntry).filter(ScenarioRegistryEntry.scenario_id == scenario_id).first() + if entry and entry.current_revision_id: + resolved_revision_id = entry.current_revision_id + + if not resolved_revision_id: + logger.reason( + "Scheduled scenario skipped: no revision", + payload={"schedule_id": schedule_id, "scenario_id": scenario_id}, + ) + return + + start_run( + db, + scenario_id=scenario_id, + revision_id=resolved_revision_id, + params={}, + environment_id=environment_id, + actor="apscheduler", + idempotency_key=f"scheduled-{schedule_id}-{datetime.now(UTC).isoformat()}", + auto_advance=False, + trigger_source="scheduled", + ) + db.commit() + logger.reason( + "Scheduled scenario run started", + payload={"schedule_id": schedule_id, "scenario_id": scenario_id, "revision_id": resolved_revision_id}, + ) + except Exception as exc: + db.rollback() + logger.explore( + "Scheduled scenario run failed", + error=str(exc), + payload={"schedule_id": schedule_id}, + ) + finally: + db.close() +# #endregion Core.Scheduler.ExecuteScheduledScenario + + # #region Core.Scheduler.SchedulerService [C:3] [TYPE Class] [SEMANTICS scheduler, service, apscheduler] # @defgroup Core Module group. # @BRIEF Provides a service to manage scheduled backup tasks. @@ -189,8 +307,11 @@ class SchedulerService: # #region Core.Scheduler.Start [C:3] [TYPE Function] [SEMANTICS scheduler,service,start] # @ingroup Core # @BRIEF Start the background scheduler and load initial schedules. + # @RELATION DEPENDS_ON -> [Core.Scheduler.ExecuteQueuedScenarioDispatch] # @PRE Scheduler should be initialized. # @POST Scheduler is running and schedules are loaded. + # @INVARIANT The recurring queued-run worker is scheduler-owned; HTTP routes only persist rows + # for its durable CAS claim and never replace this execution boundary. def start(self): with belief_scope("SchedulerService.start"): if not self.scheduler.running: @@ -203,6 +324,22 @@ class SchedulerService: id="agent_lifecycle_retention", replace_existing=True, ) + self.scheduler.add_job( + execute_scheduled_scenario_cancel_finalizer, + IntervalTrigger(seconds=5), + id="scenario_cancel_drain_finalizer", + replace_existing=True, + max_instances=1, + coalesce=True, + ) + self.scheduler.add_job( + execute_scheduled_queued_scenario_dispatch, + IntervalTrigger(seconds=5), + id="scenario_queued_dispatch", + replace_existing=True, + max_instances=1, + coalesce=True, + ) # Log restored jobs from persistent jobstore try: restored_jobs = self.scheduler.get_jobs() @@ -350,6 +487,31 @@ class SchedulerService: except Exception as e: logger.explore("Failed to load validation schedules on startup", error=str(e)) + try: + from src.models.scenario_automation import ScenarioSchedule + + db = SessionLocal() + try: + schedules = db.query(ScenarioSchedule).filter(ScenarioSchedule.enabled.is_(True)).all() + for schedule in schedules: + self.add_scenario_job( + schedule_id=schedule.id, + scenario_id=schedule.scenario_id, + revision_policy=schedule.revision_policy, + revision_id=schedule.revision_id, + environment_id=schedule.environment_id, + cron_expr=schedule.cron_expr, + timezone=schedule.timezone, + policy_id=schedule.policy_id, + misfire_grace_time=schedule.misfire_grace_time, + max_instances=schedule.max_instances, + missed_execution_policy=schedule.missed_execution_policy, + ) + desired_job_ids.add(f"scenario_{schedule.id}") + finally: + db.close() + except Exception as e: + logger.explore("Failed to load scenario schedules on startup", error=str(e)) # Differential cleanup using persistent jobstore: # remove APS jobs that are no longer in the authoritative set (backups + translates + validations) @@ -691,6 +853,94 @@ class SchedulerService: finally: db.close() # #endregion Core.Scheduler.TriggerValidation + # #region Core.Scheduler.AddScenarioJob [C:3] [TYPE Function] [SEMANTICS scheduler,scenario,cron,apscheduler] + # @ingroup Core + # @BRIEF Register a scenario schedule with APScheduler. + # @PRE schedule_id, scenario_id, cron_expr and timezone are valid. + # @POST A new APScheduler job is registered or replaced if it already exists. + # @SIDE_EFFECT Mutates APScheduler state; calls execute_scheduled_scenario on trigger. + # @RELATION DEPENDS_ON -> [EXT:APScheduler:CronTrigger] + # @RELATION CALLS -> [Core.Scheduler.ExecuteScheduledScenario] + # @INVARIANT Registration configures cron delivery only; it does not claim or execute a ScenarioRun. + # @RATIONALE Scenario schedules are managed centrally through SchedulerService so all + # scheduled work follows the same lifecycle (start/stop/reload) regardless of + # job type. Self-registering schedules would bypass centralized management and + # create split-brain state when the scheduler is stopped or reloaded. + # @NOTE Only primitive args are passed so that SQLAlchemyJobStore can reliably persist/restore. + def add_scenario_job( + self, + schedule_id: str, + scenario_id: str, + revision_policy: str, + revision_id: str | None, + environment_id: str, + cron_expr: str, + timezone: str = "UTC", + policy_id: str | None = None, + misfire_grace_time: int = 300, + max_instances: int = 1, + missed_execution_policy: str = "skip", + ) -> None: + with belief_scope( + "SchedulerService.add_scenario_job", + f"schedule_id={schedule_id}, scenario_id={scenario_id}, cron={cron_expr}", + ): + from zoneinfo import ZoneInfo + + job_id_aps = f"scenario_{schedule_id}" + try: + tz = ZoneInfo(timezone) + self.scheduler.add_job( + execute_scheduled_scenario, + CronTrigger.from_crontab(cron_expr, timezone=tz), + id=job_id_aps, + args=[ + schedule_id, + scenario_id, + revision_policy, + revision_id, + environment_id, + cron_expr, + timezone, + policy_id, + ], + replace_existing=True, + max_instances=max_instances, + misfire_grace_time=misfire_grace_time, + coalesce=missed_execution_policy != "queue_all", + ) + logger.reason( + f"Scenario schedule registered: {job_id_aps} ({cron_expr}) [{timezone}]" + ) + except Exception as e: + logger.explore( + "Failed to register scenario schedule", + payload={"job": job_id_aps}, + error=str(e), + ) + # #endregion Core.Scheduler.AddScenarioJob + # #region Core.Scheduler.RemoveScenarioJob [C:2] [TYPE Function] [SEMANTICS scheduler,scenario,remove,apscheduler] + # @ingroup Core + # @BRIEF Remove a scenario schedule from APScheduler. + # @PRE schedule_id is a valid ScenarioSchedule.id. + # @POST The APScheduler job is removed if it exists; silently ignored otherwise. + # @SIDE_EFFECT Mutates APScheduler state. + # @RATIONALE Provides a clean removal path that mirrors add_scenario_job so APScheduler + # stays consistent with the database when schedules are deactivated or deleted. + # @REJECTED Relying on APScheduler auto-cleanup was rejected — it would leave transient job + # registrations that could fire unexpected executions after schedule deactivation. + def remove_scenario_job(self, schedule_id: str) -> None: + with belief_scope( + "SchedulerService.remove_scenario_job", + f"schedule_id={schedule_id}", + ): + job_id_aps = f"scenario_{schedule_id}" + try: + self.scheduler.remove_job(job_id_aps) + logger.reason("Scenario schedule removed", payload={"job": job_id_aps}) + except Exception: + logger.reason(f"Scenario schedule not found (already removed): {job_id_aps}") + # #endregion Core.Scheduler.RemoveScenarioJob # #endregion Core.Scheduler.SchedulerService # #region Core.Scheduler.ThrottledSchedulerConfigurator [C:5] [TYPE Class] [SEMANTICS scheduler, throttling, distribution] # @defgroup Core Module group. diff --git a/backend/src/dependencies.py b/backend/src/dependencies.py index 7a314e1f9..8c3f9faf6 100755 --- a/backend/src/dependencies.py +++ b/backend/src/dependencies.py @@ -105,6 +105,7 @@ async_job_runner: AsyncJobRunner | None = None resource_service: ResourceService | None = None storage_service: StorageService | None = None storage_service_root: Path | None = None +live_execution_composition_root = None # #region Dependencies.AppDependencies.GetConfigManager [C:1] [TYPE Function] @@ -269,6 +270,25 @@ def get_storage_service(root: str | Path | None = None) -> StorageService: # #endregion Dependencies.AppDependencies.GetStorageService +# #region Dependencies.AppDependencies.LiveExecutionComposition [C:3] [TYPE Function] [SEMANTICS scenario,execution,composition,live,di] +# @BRIEF Return the application-owned 044 live-provider composition root. +# @POST The default root is empty and fail-closed until startup/server composition registers an exact provider. +# @INVARIANT This dependency never derives a Superset client, browser context, or capture provider from +# environment IDs, request metadata, or persisted binding fields. +# @RATIONALE A process-local DI singleton is the lawful home for runtime clients and secrets while +# ScenarioRun stores only the corresponding immutable identity snapshot. +# @REJECTED Request-scoped construction of live clients was rejected — it would turn untrusted IDs +# into execution authority and bypass the exact binding resolver. +def get_live_execution_composition_root(): + global live_execution_composition_root + if live_execution_composition_root is None: + from .services.dashboard_testing.execution.live_composition import LiveExecutionCompositionRoot + + live_execution_composition_root = LiveExecutionCompositionRoot() + return live_execution_composition_root +# #endregion Dependencies.AppDependencies.LiveExecutionComposition + + _clean_release_repository = CleanReleaseRepository() diff --git a/backend/src/models/scenario_artifact.py b/backend/src/models/scenario_artifact.py index 763e3bbc6..ef019181f 100644 --- a/backend/src/models/scenario_artifact.py +++ b/backend/src/models/scenario_artifact.py @@ -5,6 +5,9 @@ # @RELATION DEPENDS_ON -> [ScenarioExecution.Artifacts.Register] # @INVARIANT owner_type in {scenario_run, agent_run, verification_run, load_run}; owner_id is opaque. # @INVARIANT content_ref is opaque — never a raw filesystem path exposed to clients. +# @INVARIANT Artifact content is immutable audit evidence. is_active controls only whether a +# step attempt projects it into the current ScenarioRun result; retry invalidation +# retires the projection without deleting the artifact row. # @RATIONALE 036 DraftArtifact is FK-bound to agent_runs.id; scenario evidence must attach to the # ScenarioRun itself without fabricating an AgentRun (SCEX-FR-009 reuse constraint). # @REJECTED Reusing DraftArtifact for scenario evidence was rejected — its run_id FK would force @@ -14,7 +17,7 @@ from __future__ import annotations from datetime import UTC, datetime import uuid -from sqlalchemy import Column, DateTime, Index, String +from sqlalchemy import Boolean, Column, DateTime, Index, Integer, String from .mapping import Base @@ -33,6 +36,8 @@ def _now() -> datetime: # @ingroup Models # @BRIEF One registered artifact owned by a scenario run (or another generic owner type). # @INVARIANT sha256 is the content digest; kind is one of evidence|screenshot|report|xlsx|other. +# @INVARIANT A step-bound artifact records its logical_step_id and attempt. Retired artifacts +# remain queryable with is_active=false and invalidated_at set. class ScenarioArtifact(Base): __tablename__ = "scenario_artifacts" @@ -44,6 +49,10 @@ class ScenarioArtifact(Base): content_ref = Column(String(256), nullable=False) sha256 = Column(String(64), nullable=False) retention_class = Column(String(32), nullable=False, default="standard") + logical_step_id = Column(String(128), nullable=True, index=True) + attempt = Column(Integer, nullable=True) + is_active = Column(Boolean, nullable=False, default=True, index=True) + invalidated_at = Column(DateTime, nullable=True) created_at = Column(DateTime, nullable=False, default=_now) __table_args__ = ( diff --git a/backend/src/models/scenario_investigation.py b/backend/src/models/scenario_investigation.py index 5ab57ed84..73a72ff20 100644 --- a/backend/src/models/scenario_investigation.py +++ b/backend/src/models/scenario_investigation.py @@ -24,6 +24,7 @@ class InvestigationQueueItem(Base): severity = Column(String(16), nullable=False, default="warning") status = Column(String(24), nullable=False, default="queued", index=True) occurrence_count = Column(Integer, nullable=False, default=1) + evidence_snapshot = Column(JSON, nullable=False, default=dict) created_at = Column(DateTime, nullable=False, default=_now) # #endregion Models.ScenarioInvestigation.QueueItem @@ -36,6 +37,9 @@ class InvestigationCase(Base): status = Column(String(24), nullable=False, default="open", index=True) disposition = Column(String(32), nullable=True) decision_version = Column(Integer, nullable=False, default=1) + evidence_snapshot = Column(JSON, nullable=False, default=dict) + linked_run_ids = Column(JSON, nullable=False, default=list) + owner_id = Column(String(128), nullable=True, index=True) created_at = Column(DateTime, nullable=False, default=_now) # #endregion Models.ScenarioInvestigation.Case diff --git a/backend/src/models/scenario_run.py b/backend/src/models/scenario_run.py index b9c2a349c..f93568b83 100644 --- a/backend/src/models/scenario_run.py +++ b/backend/src/models/scenario_run.py @@ -4,6 +4,12 @@ # @RELATION DEPENDS_ON -> [Models.ScenarioRegistry.Revision] # @INVARIANT scenario_content_hash mirrors the pinned revision's content_hash (provenance), # while request_hash carries the canonical idempotency request fingerprint (data-model.md). +# @INVARIANT Live execution binding data is an immutable identity snapshot only; credentials, raw +# browser contexts, and callable principals remain runtime-only composition dependencies. +# @INVARIANT A cancellation deadline records only lifecycle timing; it cannot authorize, revive, +# or alter the immutable execution target/binding snapshot. +# @REJECTED Persisting executable live clients with ScenarioRun was rejected — database state cannot +# safely serialise or authorize a client/session capability. # @RATIONALE request_hash is a separate column so scenario_content_hash keeps provenance semantics: # a run pinned to revision R must report R's content hash even when the launch payload # differs across idempotent replays. @@ -26,6 +32,9 @@ def _now() -> datetime: # #region Models.ScenarioExecution.Run [C:4] [TYPE Class] [SEMANTICS scenario,execution,run,state] +# @BRIEF Persist one revision-pinned scenario execution, immutable live binding identity, and bounded cancel timing. +# @DATA_CONTRACT ScenarioRun.live_execution_binding_ref + live_execution_binding_snapshot -> LiveExecutionBinding.snapshot(). +# @INVARIANT Null binding fields preserve historical runs and cause live I/O to fail closed until a composition root resolves authorized dependencies. class ScenarioRun(Base): __tablename__ = "scenario_runs" @@ -42,8 +51,12 @@ class ScenarioRun(Base): idempotency_key = Column(String(128), nullable=False, unique=True) request_hash = Column(String(64), nullable=True, index=True) resume_token = Column(String(128), nullable=True) + cancel_requested_at = Column(DateTime, nullable=True) + cancel_drain_deadline_at = Column(DateTime, nullable=True, index=True) runner_plan = Column(JSON, nullable=False, default=dict) execution_principal_fingerprint = Column(String(64), nullable=True) + live_execution_binding_ref = Column(String(128), nullable=True, index=True) + live_execution_binding_snapshot = Column(JSON, nullable=True) error_code = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=_now) started_at = Column(DateTime, nullable=True) diff --git a/backend/src/services/dashboard_testing/analytics/flakiness.py b/backend/src/services/dashboard_testing/analytics/flakiness.py index 616ca823d..547304bbe 100644 --- a/backend/src/services/dashboard_testing/analytics/flakiness.py +++ b/backend/src/services/dashboard_testing/analytics/flakiness.py @@ -15,6 +15,24 @@ def detect_flakiness(results: list[dict[str, Any]], *, context_key: str) -> dict return {"context_key": context_key, "usable_runs": len(usable), "transitions": transitions, "classification": "regression" if regression else "flaky" if transitions >= 2 else "stable"} # #endregion ScenarioAnalytics.Flakiness.Detect + +# #region ScenarioAnalytics.Client [C:3] [TYPE Class] [SEMANTICS scenario,analytics,client,health,flakiness] +# @defgroup ScenarioAnalytics Bound analytics client used by get_health_badge(analytics_client=...). +class ScenarioAnalyticsClient: + # @BRIEF Load run rows for a scenario and return a health projection on demand. + def __init__(self, db: Any) -> None: + self.db = db + + def get_scenario_health(self, scenario_id: str) -> dict[str, Any] | None: + from src.models.scenario_run import ScenarioRun + rows = self.db.query(ScenarioRun).filter(ScenarioRun.scenario_id == scenario_id).all() + if not rows: + return None + context_key = f"{scenario_id}:{rows[0].environment_id}" + results = [{"status": r.status, "context_key": context_key} for r in rows] + return derive_health(results, context_key=context_key) +# #endregion ScenarioAnalytics.Client + # #region ScenarioAnalytics.Health.Derive [C:3] [TYPE Function] [SEMANTICS scenario,analytics,health,derive] def derive_health(results: list[dict[str, Any]], *, context_key: str) -> dict[str, Any]: signal = detect_flakiness(results, context_key=context_key) diff --git a/backend/src/services/dashboard_testing/analytics/investigation.py b/backend/src/services/dashboard_testing/analytics/investigation.py index f8627b410..bf19c7b3b 100644 --- a/backend/src/services/dashboard_testing/analytics/investigation.py +++ b/backend/src/services/dashboard_testing/analytics/investigation.py @@ -2,51 +2,284 @@ # @defgroup ScenarioAnalytics Queue projection and explicit case lifecycle. # @BRIEF Queue immutable run signals without starting agents automatically. # @INVARIANT Case disposition uses decision-version CAS and never changes run/graph rows. +# @INVARIANT Terminal ScenarioRun signal identity is stable over run/status/pinned provenance and +# durable artifact digest/ref; retrying the same terminal context cannot rewrite it. +# @REJECTED Opening an InvestigationCase, AgentRun, chat, or remediation action while ingesting a +# terminal signal was rejected — queue projection is analyst-owned and non-executing. from __future__ import annotations +from hashlib import sha256 +import json +from typing import Any import uuid from sqlalchemy.orm import Session +from src.models.scenario_artifact import ScenarioArtifact from src.models.scenario_investigation import AgentAction, InvestigationCase, InvestigationQueueItem +from src.models.scenario_run import ScenarioRun, ScenarioStepRun # #region ScenarioAnalytics.Investigation.Queue [C:3] [TYPE Function] [SEMANTICS scenario,analytics,queue,project] -def queue_signal(db: Session, *, fingerprint: str, scenario_id: str, run_id: str | None, severity: str = "warning") -> InvestigationQueueItem: +def queue_signal( + db: Session, + *, + fingerprint: str, + scenario_id: str, + run_id: str | None, + severity: str = "warning", + evidence: dict[str, Any] | None = None, +) -> InvestigationQueueItem: + """Idempotently project immutable evidence into an analyst-owned queue item.""" item = db.query(InvestigationQueueItem).filter(InvestigationQueueItem.fingerprint == fingerprint, InvestigationQueueItem.status == "queued").first() if item is not None: item.occurrence_count += 1 + if run_id and run_id not in (item.evidence_snapshot or {}).get("run_ids", []): + snapshot = dict(item.evidence_snapshot or {}) + snapshot["run_ids"] = [*snapshot.get("run_ids", []), run_id] + item.evidence_snapshot = snapshot return item - item = InvestigationQueueItem(fingerprint=fingerprint, scenario_id=scenario_id, run_id=run_id, severity=severity) + snapshot = {**(evidence or {})} + if run_id: + snapshot["run_ids"] = [run_id] + item = InvestigationQueueItem( + fingerprint=fingerprint, + scenario_id=scenario_id, + run_id=run_id, + severity=severity, + evidence_snapshot=snapshot, + ) db.add(item) db.flush() return item # #endregion ScenarioAnalytics.Investigation.Queue + +# #region ScenarioAnalytics.Investigation.TerminalEvidence [C:2] [TYPE Function] [SEMANTICS scenario,analytics,terminal,evidence,provenance] +# @BRIEF Read only registered ScenarioRun evidence, ordered deterministically by opaque ref and digest. +# @INVARIANT Retired retry-attempt artifacts remain audit-visible but are excluded from the active +# terminal signal; only is_active=true evidence can describe the current result. +def _terminal_evidence(db: Session, run_id: str) -> list[dict[str, str]]: + rows = ( + db.query(ScenarioArtifact) + .filter( + ScenarioArtifact.owner_type == "scenario_run", + ScenarioArtifact.owner_id == run_id, + ScenarioArtifact.is_active.is_(True), + ) + .order_by(ScenarioArtifact.content_ref.asc(), ScenarioArtifact.sha256.asc(), ScenarioArtifact.id.asc()) + .all() + ) + return [ + {"kind": row.kind, "content_ref": row.content_ref, "sha256": row.sha256} + for row in rows + ] +# #endregion ScenarioAnalytics.Investigation.TerminalEvidence + + +# #region ScenarioAnalytics.Investigation.TerminalAttempts [C:3] [TYPE Function] [SEMANTICS scenario,analytics,terminal,retry,attempt,provenance] +# @BRIEF Freeze the active step attempt vector that identifies a terminal execution context. +# @INVARIANT A reterminalized retry has a distinct vector from its earlier terminal attempt, while +# duplicate emission of the same vector remains idempotent. +def _terminal_attempts(db: Session, run_id: str) -> list[dict[str, Any]]: + rows = ( + db.query(ScenarioStepRun) + .filter(ScenarioStepRun.run_id == run_id) + .order_by(ScenarioStepRun.step_position.asc(), ScenarioStepRun.logical_step_id.asc(), ScenarioStepRun.id.asc()) + .all() + ) + return [ + { + "logical_step_id": row.logical_step_id, + "attempt": row.attempt, + "status": row.status, + "artifact_refs": list(row.artifact_refs or []), + } + for row in rows + ] +# #endregion ScenarioAnalytics.Investigation.TerminalAttempts + + +# #region ScenarioAnalytics.Investigation.TerminalSnapshot [C:3] [TYPE Function] [SEMANTICS scenario,analytics,terminal,snapshot,provenance] +# @BRIEF Freeze the durable terminal provenance that a 047 analyst queue may inspect. +def _terminal_snapshot( + run: ScenarioRun, + evidence: list[dict[str, str]], + attempts: list[dict[str, Any]], +) -> dict[str, Any]: + snapshot = { + "signal_type": "scenario_terminal", + "scenario_id": run.scenario_id, + "scenario_run_id": run.id, + "status": run.status, + "environment_id": run.environment_id, + "scenario_revision_id": run.scenario_revision_id, + "scenario_content_hash": run.scenario_content_hash, + "target_snapshot": run.target_snapshot or {}, + "execution_principal_fingerprint": run.execution_principal_fingerprint, + "evidence": evidence, + "step_attempts": attempts, + } + return json.loads(json.dumps(snapshot, sort_keys=True, separators=(",", ":"))) +# #endregion ScenarioAnalytics.Investigation.TerminalSnapshot + + +# #region ScenarioAnalytics.Investigation.TerminalSignal [C:4] [TYPE Function] [SEMANTICS scenario,analytics,terminal,signal,queue,idempotency] +# @BRIEF Emit one canonical 036-compatible queue input for a non-pass terminal ScenarioRun. +# @PRE run.status is persisted; evidence rows, if any, are ScenarioRun-owned durable artifacts. +# @POST Returns the same InvestigationQueueItem for the same immutable terminal context, without +# opening a case or starting agent/remediation work; passed/nonterminal runs return None. +# @SIDE_EFFECT At most one 047 InvestigationQueueItem insert for the terminal signal fingerprint. +# @INVARIANT Failed, blocked and inconclusive runs carry their pinned revision, target/principal, +# status, active verified artifact ref/digest provenance and attempt vector into the +# queue snapshot. Same context reuses one signal; a retried terminal attempt is new +# immutable context and never mutates the earlier signal. +# @REJECTED Reusing a scenario/environment-only fingerprint was rejected — it conflates distinct +# terminal runs and makes a retry mutate occurrence count or evidence history. +def emit_terminal_run_signal( + db: Session, + run: ScenarioRun, +) -> InvestigationQueueItem | None: + if run.status not in {"failed", "blocked", "inconclusive"}: + return None + evidence = _terminal_evidence(db, run.id) + snapshot = _terminal_snapshot(run, evidence, _terminal_attempts(db, run.id)) + fingerprint = "scenario-terminal:" + sha256( + json.dumps(snapshot, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + existing = ( + db.query(InvestigationQueueItem) + .filter(InvestigationQueueItem.fingerprint == fingerprint) + .order_by(InvestigationQueueItem.created_at.asc(), InvestigationQueueItem.id.asc()) + .first() + ) + if existing is not None: + return existing + return ingest_investigation_signal( + db, + { + "fingerprint": fingerprint, + "scenario_id": run.scenario_id, + "run_id": run.id, + "severity": "warning", + "evidence": snapshot, + }, + ) +# #endregion ScenarioAnalytics.Investigation.TerminalSignal + +# #region ScenarioAnalytics.Investigation.IngestSignal [C:4] [TYPE Function] [SEMANTICS scenario,analytics,terminal,signal,queue,producer] +# @BRIEF Validate and project one canonical signal into the analyst-owned queue without opening work. +# @PRE Signal has a deterministic fingerprint and scenario_id; evidence is immutable caller-supplied context. +# @POST Returns a queue item only; no InvestigationCase, AgentRun, chat, remediation action, or recurrence classification is started. +# @SIDE_EFFECT Creates or updates the canonical queue projection through queue_signal. +# @INVARIANT This is a producer boundary, not 047 recurrence classification or case lifecycle. +def ingest_investigation_signal(db: Session, signal: dict[str, Any]) -> InvestigationQueueItem: + """Canonical 036-compatible signal boundary; never starts agent work or mutates run truth.""" + fingerprint = str(signal.get("fingerprint") or signal.get("idempotency_key") or "") + scenario_id = str(signal.get("scenario_id") or "") + if not fingerprint or not scenario_id: + raise ValueError("signal requires fingerprint and scenario_id") + return queue_signal( + db, + fingerprint=fingerprint, + scenario_id=scenario_id, + run_id=signal.get("run_id"), + severity=str(signal.get("severity") or "warning"), + evidence=dict(signal.get("evidence") or {}), + ) +# #endregion ScenarioAnalytics.Investigation.IngestSignal + # #region ScenarioAnalytics.Investigation.Case [C:4] [TYPE Function] [SEMANTICS scenario,analytics,case,open,disposition] + +def auto_queue_failed_run( + db: Session, + *, + scenario_id: str, + run_id: str, + environment_id: str, +) -> InvestigationQueueItem: + """Compatibility boundary for 044 terminal failures; it only queues immutable context.""" + return ingest_investigation_signal( + db, + { + "fingerprint": f"{scenario_id}:{environment_id}", + "scenario_id": scenario_id, + "run_id": run_id, + "severity": "warning", + "evidence": {"environment_id": environment_id, "source": "scenario_run"}, + }, + ) + + def open_case(db: Session, *, fingerprint: str, scenario_id: str, actor_id: str) -> InvestigationCase: case = db.query(InvestigationCase).filter(InvestigationCase.fingerprint == fingerprint).first() if case is not None: return case - case = InvestigationCase(id=str(uuid.uuid4()), fingerprint=fingerprint, scenario_id=scenario_id) + queue = db.query(InvestigationQueueItem).filter( + InvestigationQueueItem.fingerprint == fingerprint, + InvestigationQueueItem.status == "queued", + ).first() + snapshot = dict(queue.evidence_snapshot or {}) if queue else {} + run_ids = list(snapshot.get("run_ids", [])) + case = InvestigationCase( + id=str(uuid.uuid4()), + fingerprint=fingerprint, + scenario_id=scenario_id, + evidence_snapshot=snapshot, + linked_run_ids=run_ids, + owner_id=actor_id, + ) db.add(case) db.flush() db.add(AgentAction(case_id=case.id, action_type="case_opened", payload={"actor_id": actor_id})) db.flush() return case -def set_disposition(db: Session, case_id: str, *, disposition: str, expected_version: int, actor_id: str) -> InvestigationCase: +def set_disposition( + db: Session, + case_id: str, + *, + disposition: str, + expected_version: int, + actor_id: str, + verification_evidence: dict[str, Any] | None = None, + rationale: str = "", +) -> InvestigationCase: case = db.query(InvestigationCase).filter(InvestigationCase.id == case_id).first() if case is None: raise ValueError("case not found") if case.decision_version != expected_version: raise ValueError("stale disposition version") + if disposition == "resolved": + if not verification_evidence or not verification_evidence.get("reconciled"): + raise ValueError("resolved disposition requires reconciled verification evidence") + case.status = "resolved" + elif disposition == "accepted": + if not rationale.strip(): + raise ValueError("accepted disposition requires accepted-risk rationale") + case.status = "accepted" + else: + raise ValueError("disposition must be resolved or accepted") case.disposition = disposition - case.status = "resolved" case.decision_version += 1 - db.add(AgentAction(case_id=case.id, action_type="disposition", payload={"actor_id": actor_id, "disposition": disposition})) + db.add(AgentAction(case_id=case.id, action_type="disposition", payload={"actor_id": actor_id, "disposition": disposition, "verification_evidence": verification_evidence or {}, "rationale": rationale})) + from src.models.scenario_investigation import RecurringFailureEpisode + from src.services.dashboard_testing.analytics.recurring import resolve_episode + episode = db.query(RecurringFailureEpisode).filter( + RecurringFailureEpisode.fingerprint == case.fingerprint, + RecurringFailureEpisode.status == "open", + ).first() + if episode is not None and disposition in {"resolved", "accepted"}: + resolve_episode(db, episode.id) db.flush() return case + + +def can_access_case(case: InvestigationCase, *, actor_id: str, triage: bool) -> bool: + """Object ACL is orthogonal to scenario-result:view/triage scopes.""" + if triage: + return True + return case.owner_id in {None, actor_id} # #endregion ScenarioAnalytics.Investigation.Case # #endregion ScenarioAnalytics.Investigation diff --git a/backend/src/services/dashboard_testing/analytics/recurring.py b/backend/src/services/dashboard_testing/analytics/recurring.py index ec7fb58e8..fe457d03f 100644 --- a/backend/src/services/dashboard_testing/analytics/recurring.py +++ b/backend/src/services/dashboard_testing/analytics/recurring.py @@ -11,15 +11,36 @@ from src.models.scenario_investigation import RecurringFailureEpisode # #region ScenarioAnalytics.Recurring.Record [C:4] [TYPE Function] [SEMANTICS scenario,analytics,recurring,episode,record] def record_occurrence(db: Session, *, fingerprint: str, scenario_id: str) -> RecurringFailureEpisode: - episode = db.query(RecurringFailureEpisode).filter(RecurringFailureEpisode.fingerprint == fingerprint, RecurringFailureEpisode.status == "open").first() now = datetime.now(UTC) - if episode is not None: - episode.occurrence_count += 1 - episode.last_seen_at = now - return episode + open_episode = db.query(RecurringFailureEpisode).filter( + RecurringFailureEpisode.fingerprint == fingerprint, + RecurringFailureEpisode.status == "open", + ).first() + if open_episode is not None: + open_episode.occurrence_count += 1 + open_episode.last_seen_at = now + return open_episode + resolved = ( + db.query(RecurringFailureEpisode) + .filter(RecurringFailureEpisode.fingerprint == fingerprint, RecurringFailureEpisode.status == "resolved") + .order_by(RecurringFailureEpisode.last_seen_at.desc()) + .first() + ) episode = RecurringFailureEpisode(fingerprint=fingerprint, scenario_id=scenario_id, first_seen_at=now, last_seen_at=now) db.add(episode) db.flush() + if resolved is not None: + from src.services.dashboard_testing.analytics.investigation import ingest_investigation_signal + + ingest_investigation_signal( + db, + { + "fingerprint": f"recurrence:{fingerprint}:{episode.id}", + "scenario_id": scenario_id, + "severity": "warning", + "evidence": {"previous_episode_id": resolved.id, "fingerprint": fingerprint, "source": "recurring_failure"}, + }, + ) return episode # #endregion ScenarioAnalytics.Recurring.Record diff --git a/backend/src/services/dashboard_testing/automation/notify.py b/backend/src/services/dashboard_testing/automation/notify.py index b0b97ac40..e887012d6 100644 --- a/backend/src/services/dashboard_testing/automation/notify.py +++ b/backend/src/services/dashboard_testing/automation/notify.py @@ -4,6 +4,10 @@ from __future__ import annotations from typing import Any +from sqlalchemy.orm import Session + +from src.models.scenario_automation import ScenarioNotificationEvent + def notify(events: list[dict[str, Any]], *, event_type: str, scenario_id: str, run_id: str | None, severity: str = "info") -> dict[str, Any]: if event_type not in {"completed", "failed", "blocked", "human", "stale", "flaky"}: @@ -11,4 +15,28 @@ def notify(events: list[dict[str, Any]], *, event_type: str, scenario_id: str, r event = {"event_type": event_type, "scenario_id": scenario_id, "run_id": run_id, "severity": severity} events.append(event) return event + + +def persist_notification( + db: Session, + *, + event_type: str, + scenario_id: str, + run_id: str | None, + severity: str = "info", + payload: dict[str, Any] | None = None, +) -> ScenarioNotificationEvent: + """Persist one typed automation outcome; callers own delivery channels.""" + if event_type not in {"completed", "failed", "blocked", "stale", "flaky"}: + raise ValueError("unsupported notification event") + row = ScenarioNotificationEvent( + event_type=event_type, + scenario_id=scenario_id, + run_id=run_id, + severity=severity, + payload=payload or {}, + ) + db.add(row) + db.flush() + return row # #endregion ScenarioAutomation.Notify diff --git a/backend/src/services/dashboard_testing/automation/trigger.py b/backend/src/services/dashboard_testing/automation/trigger.py index 7f9836440..7d92cd80b 100644 --- a/backend/src/services/dashboard_testing/automation/trigger.py +++ b/backend/src/services/dashboard_testing/automation/trigger.py @@ -1,20 +1,122 @@ # #region ScenarioAutomation.Trigger [C:4] [TYPE Module] [SEMANTICS scenario,automation,trigger,event,dedup] # @defgroup ScenarioAutomation Map typed events to pinned run candidates. +# @RELATION DEPENDS_ON -> [ScenarioExecution.Runner.QueuedDispatch] +# @RELATION DEPENDS_ON -> [ScenarioExecution.EnvironmentPolicy.Resolve] +# @INVARIANT A dispatch passes its server-owned event type to 044 before run creation; it never +# creates a manual run and mutates provenance afterward. Human revisions therefore +# reject atomically at the 044 manual-run-only boundary. +# @INVARIANT Event/rule payloads carry only target identity; EnvironmentExecutionPolicy resolves +# PROD authority from ConfigManager before each ScenarioRun can be persisted. +# @REJECTED Post-create trigger_source mutation was rejected because a human graph could be +# created as manual before the automation provenance was recorded. +# @REJECTED Trusting rule.environment_class or an event boolean for PROD was rejected because a +# stale or forged payload could bypass the durable approval gate. from __future__ import annotations from typing import Any +from sqlalchemy.orm import Session + from .policy import apply_policy +# #region ScenarioAutomation.Trigger.Handle [C:3] [TYPE Function] [SEMANTICS scenario,automation,trigger,event,policy] +# @BRIEF Map one typed event to policy-allowed pinned run candidates without creating runs. +# @POST Returned candidates retain the event's server-owned trigger_source and target identity; +# PROD classification is intentionally deferred to the 044 policy resolver. def handle_trigger_event(event: dict[str, Any], rules: list[dict[str, Any]], active_runs: list[dict[str, Any]], policy: dict[str, Any]) -> list[dict[str, Any]]: candidates = [] for rule in rules: if not rule.get("enabled", True) or rule.get("event_type") != event.get("type"): continue - candidate = {"scenario_id": rule["scenario_id"], "revision_id": rule["revision_id"], "environment_id": rule["environment_id"], "trigger_source": event.get("type"), "dedup_fingerprint": event.get("fingerprint"), "is_prod": rule.get("environment_class") == "PROD"} + candidate = {"scenario_id": rule["scenario_id"], "revision_id": rule["revision_id"], "environment_id": rule["environment_id"], "trigger_source": event.get("type"), "dedup_fingerprint": event.get("fingerprint")} decision = apply_policy(policy, candidate, active_runs) if decision["allowed"]: candidates.append(candidate) return candidates +# #endregion ScenarioAutomation.Trigger.Handle + + +# #region ScenarioAutomation.Trigger.Dispatch [C:4] [TYPE Function] [SEMANTICS scenario,automation,trigger,event,dispatch,manual-only] +# @BRIEF Dispatch one persisted automation event through policy and into the trusted 044 start boundary. +# @RELATION CALLS -> [ScenarioExecution.Runner.Start] +# @RELATION DEPENDS_ON -> [ScenarioExecution.EnvironmentPolicy.Resolve] +# @RELATION DEPENDS_ON -> [ScenarioExecution.Runner.QueuedDispatch] +# @POST A candidate's real event type becomes trigger_source before run creation; accepted candidates +# are persisted queued/pending only, while manual-only rejection has no run-side effect. +# @SIDE_EFFECT May persist policy-allowed ScenarioRuns through start_run; it never invokes an adapter. +# @INVARIANT This dispatcher never creates a manual run and later mutates its provenance. A malformed +# automated human plan is rejected at the 044 pre-create boundary; legacy queued rows are +# separately blocked by the 044 dispatcher before CAS/walker execution. +# @INVARIANT ConfigManager resolution occurs before each start; an unknown target raises before that +# rule creates a run, gate, queue, notification, or adapter side effect. +def dispatch_trigger_event( + db: Session, + event: dict[str, Any], + *, + actor: str = "automation", + start_run_fn=None, + config_manager=None, +) -> list[str]: + """Project a typed deploy/release/ETL event into policy-checked ScenarioRuns.""" + from src.models.scenario_automation import AutomationPolicy, ScenarioTriggerRule + from src.models.scenario_registry import ScenarioRegistryEntry + from src.models.scenario_run import ScenarioRun + from src.services.dashboard_testing.execution.runner import start_run + + start = start_run_fn or start_run + if config_manager is None: + from src.dependencies import get_config_manager + + config_manager = get_config_manager() + rules = db.query(ScenarioTriggerRule).filter( + ScenarioTriggerRule.enabled.is_(True), + ScenarioTriggerRule.trigger == event.get("type"), + ).all() + active = [ + { + "environment_id": run.environment_id, + "dedup_fingerprint": (run.parameter_bindings or {}).get("automation_fingerprint"), + "status": run.status, + "created_at": run.created_at.isoformat() if run.created_at else None, + } + for run in db.query(ScenarioRun).filter(ScenarioRun.status.in_(["queued", "running", "pending_approval"])).all() + ] + created: list[str] = [] + for rule in rules: + policy_row = db.query(AutomationPolicy).filter(AutomationPolicy.id == rule.policy_id).first() if rule.policy_id else None + policy = { + "max_concurrent_per_env": policy_row.max_concurrent_per_env if policy_row else 1, + "dedup_window_seconds": policy_row.dedup_window_seconds if policy_row else 0, + "overlap_rule": policy_row.overlap_rule if policy_row else "block", + } + revision_id = rule.revision_id + if rule.revision_policy == "current": + entry = db.query(ScenarioRegistryEntry).filter(ScenarioRegistryEntry.scenario_id == rule.scenario_id).first() + revision_id = entry.current_revision_id if entry else None + candidate = { + "scenario_id": rule.scenario_id, + "revision_id": revision_id, + "environment_id": rule.environment_id, + "dedup_fingerprint": event.get("fingerprint"), + "overlap": bool(event.get("overlap")), + } + decision = apply_policy(policy, candidate, active) + if not decision["allowed"] or not candidate["revision_id"]: + continue + run = start( + db, + scenario_id=rule.scenario_id, + revision_id=candidate["revision_id"], + params={"automation_fingerprint": event.get("fingerprint")}, + environment_id=rule.environment_id, + actor=actor, + idempotency_key=f"trigger-{rule.id}-{event.get('fingerprint')}", + config_manager=config_manager, + auto_advance=False, + trigger_source=str(event.get("type")), + ) + created.append(run.id) + return created +# #endregion ScenarioAutomation.Trigger.Dispatch # #endregion ScenarioAutomation.Trigger diff --git a/backend/src/services/dashboard_testing/execution/approval.py b/backend/src/services/dashboard_testing/execution/approval.py index d0616e70b..f077bd154 100644 --- a/backend/src/services/dashboard_testing/execution/approval.py +++ b/backend/src/services/dashboard_testing/execution/approval.py @@ -12,6 +12,7 @@ from __future__ import annotations from datetime import UTC, datetime +from sqlalchemy import update from sqlalchemy.orm import Session from src.models.scenario_approval import ActionApprovalGate @@ -56,16 +57,24 @@ def create_prod_gate( def decide_approval_gate(db: Session, gate_id: str, *, decision: str, actor_id: str, comment: str = "") -> ActionApprovalGate: if decision not in {"approve", "deny"}: raise ValueError("invalid gate decision") + decided_at = datetime.now(UTC) + consumed = db.execute( + update(ActionApprovalGate) + .where(ActionApprovalGate.id == gate_id, ActionApprovalGate.status == "pending") + .values( + status="approved" if decision == "approve" else "denied", + decision=decision, + actor_id=actor_id, + comment=comment, + decided_at=decided_at, + ) + ) + if consumed.rowcount != 1: + exists = db.query(ActionApprovalGate.id).filter(ActionApprovalGate.id == gate_id).first() + raise ValueError("approval gate already decided" if exists is not None else "approval gate not found") gate = db.query(ActionApprovalGate).filter(ActionApprovalGate.id == gate_id).first() if gate is None: raise ValueError("approval gate not found") - if gate.status != "pending": - raise ValueError("approval gate already decided") - gate.status = "approved" if decision == "approve" else "denied" - gate.decision = decision - gate.actor_id = actor_id - gate.comment = comment - gate.decided_at = datetime.now(UTC) run = db.query(ScenarioRun).filter(ScenarioRun.id == gate.owner_id).first() if run is not None and run.status == "pending_approval": run.status = "queued" if decision == "approve" else "blocked" diff --git a/backend/src/services/dashboard_testing/execution/artifacts.py b/backend/src/services/dashboard_testing/execution/artifacts.py index da67ced84..98c66d852 100644 --- a/backend/src/services/dashboard_testing/execution/artifacts.py +++ b/backend/src/services/dashboard_testing/execution/artifacts.py @@ -7,9 +7,11 @@ # @INVARIANT owner_type must be one of {scenario_run, agent_run, verification_run, load_run}; # owner_id must reference an existing row of that type where enforced. # @INVARIANT sha256 is a 64-hex content digest; content_ref is opaque (never a raw FS path). +# @INVARIANT Registered scenario evidence never uses a manufactured all-zero SHA-256 placeholder. # @REJECTED Reusing DraftArtifact (agent-run FK) for scenario evidence was rejected in the model. from __future__ import annotations +from datetime import UTC, datetime from typing import Any from sqlalchemy.orm import Session @@ -20,6 +22,46 @@ from src.models.scenario_run import ScenarioRun, ScenarioStepRun _ARTIFACT_KINDS = frozenset({"evidence", "screenshot", "report", "xlsx", "other"}) +# #region ScenarioExecution.Artifacts.ValidDigest [C:2] [TYPE Function] [SEMANTICS scenario,execution,artifact,digest,integrity] +# @BRIEF Accept only a concrete 64-hex SHA-256 digest, never the all-zero placeholder. +# @INVARIANT A registered evidence digest denotes supplied or locally computed content, not a sentinel. +def is_valid_sha256(value: object) -> bool: + return ( + isinstance(value, str) + and len(value) == 64 + and value.lower() != "0" * 64 + and all(char in "0123456789abcdef" for char in value.lower()) + ) +# #endregion ScenarioExecution.Artifacts.ValidDigest + + +# #region ScenarioExecution.Artifacts.VerifiedEvidence [C:3] [TYPE Function] [SEMANTICS scenario,execution,artifact,evidence,digest,integrity] +# @BRIEF Verify screenshot evidence refs agree with their canonical forwarded digest map. +# @POST Every evidence ref has one valid non-zero SHA-256 matching the forwarded digest. +# @INVARIANT Screenshot evidence is durable only when every ref and digest agree exactly. +# @REJECTED Treating a shape-valid but mismatched digest as capture evidence was rejected. +def has_verified_evidence(artifact_refs: list[str], step_outcome: dict[str, Any]) -> bool: + if not artifact_refs: + return False + digest_map = step_outcome.get("artifact_digests") + if not isinstance(digest_map, dict): + return False + first_digest = step_outcome.get("sha256") + return is_valid_sha256(first_digest) and digest_map.get(artifact_refs[0]) == first_digest and all( + is_valid_sha256(digest_map.get(ref)) + for ref in artifact_refs + ) and all(ref in digest_map for ref in artifact_refs) +# #endregion ScenarioExecution.Artifacts.VerifiedEvidence + + +# #region ScenarioExecution.Artifacts.VerifiedSingleEvidence [C:2] [TYPE Function] [SEMANTICS scenario,execution,artifact,evidence,digest,compatibility] +# @ingroup ScenarioExecution +# @BRIEF Preserve the legacy one-ref evidence predicate for callers with a single artifact contract. +def has_verified_single_evidence(artifact_refs: list[str], step_outcome: dict[str, Any]) -> bool: + return len(artifact_refs) == 1 and has_verified_evidence(artifact_refs, step_outcome) +# #endregion ScenarioExecution.Artifacts.VerifiedSingleEvidence + + # #region ScenarioExecution.Artifacts.Register [C:4] [TYPE Function] [SEMANTICS scenario,execution,artifact,register] # @ingroup ScenarioExecution # @BRIEF Persist one artifact row owned by a scenario run (or other generic owner). @@ -36,13 +78,15 @@ def register_artifact( content_ref: str, sha256: str, retention_class: str = "standard", + logical_step_id: str | None = None, + attempt: int | None = None, ) -> ScenarioArtifact: if owner_type not in _OWNER_TYPES: raise ValueError(f"unsupported artifact owner_type: {owner_type}") if kind not in _ARTIFACT_KINDS: raise ValueError(f"unsupported artifact kind: {kind}") - if len(sha256) != 64 or any(c not in "0123456789abcdef" for c in sha256.lower()): - raise ValueError("sha256 must be a 64-char hex digest") + if not is_valid_sha256(sha256): + raise ValueError("sha256 must be a non-placeholder 64-char hex digest") if owner_type == "scenario_run": run = db.query(ScenarioRun).filter(ScenarioRun.id == owner_id).first() if run is None: @@ -55,6 +99,8 @@ def register_artifact( content_ref=content_ref, sha256=sha256.lower(), retention_class=retention_class, + logical_step_id=logical_step_id, + attempt=attempt, ) db.add(artifact) db.flush() @@ -62,6 +108,95 @@ def register_artifact( # #endregion ScenarioExecution.Artifacts.Register +# #region ScenarioExecution.Artifacts.RegisterStepEvidence [C:4] [TYPE Function] [SEMANTICS scenario,execution,artifact,evidence,digest,integrity] +# @ingroup ScenarioExecution +# @BRIEF Register verified executor evidence refs; retain a typed integrity condition for bad refs. +# @INVARIANT A ref with absent, malformed, or all-zero digest never creates a ScenarioArtifact row. +# @POST Valid evidence refs persist with their exact supplied/local digest; invalid refs return an +# inconclusive integrity payload and remain unregistered. +# @REJECTED Filling absent digest with zeros was rejected — it makes unverifiable evidence appear durable. +def register_step_evidence( + db: Session, + *, + run_id: str, + logical_step_id: str, + artifact_refs: list[str], + outcome: dict[str, Any], + attempt: int, +) -> dict[str, Any] | None: + if not artifact_refs: + return None + step_outcome = outcome.get("step_outcome") if isinstance(outcome.get("step_outcome"), dict) else outcome + raw_digests = step_outcome.get("artifact_digests") + digest_map = raw_digests if isinstance(raw_digests, dict) else {} + unregistered: list[str] = [] + invalid_digest = False + for ref in artifact_refs: + digest = digest_map.get(ref) + if digest is None and len(artifact_refs) == 1: + digest = step_outcome.get("sha256") + if not is_valid_sha256(digest): + unregistered.append(ref) + invalid_digest = invalid_digest or digest is not None + continue + register_artifact( + db, + owner_type="scenario_run", + owner_id=run_id, + kind="evidence", + name=f"step-{logical_step_id}", + content_ref=ref, + sha256=digest, + logical_step_id=logical_step_id, + attempt=attempt, + ) + attach_artifact_refs(db, run_id, logical_step_id, [ref]) + if not unregistered: + return None + return { + "status": "inconclusive", + "reason_code": "ARTIFACT_DIGEST_INVALID" if invalid_digest else "ARTIFACT_DIGEST_MISSING", + "unregistered_refs": unregistered, + } +# #endregion ScenarioExecution.Artifacts.RegisterStepEvidence + + +# #region ScenarioExecution.Artifacts.InvalidateStepEvidence [C:4] [TYPE Function] [SEMANTICS scenario,execution,artifact,retry,invalidation,provenance] +# @ingroup ScenarioExecution +# @BRIEF Retire active evidence projection for a retry closure while retaining every artifact row. +# @RELATION CALLED_BY -> [ScenarioExecution.Lifecycle.Retry] +# @POST Matching current-attempt artifacts remain durable and queryable with is_active=false; +# terminal evidence excludes them until a retried attempt registers fresh evidence. +# @INVARIANT No artifact content_ref, sha256, owner, or creation record is deleted or overwritten. +# @REJECTED Deleting retry-invalidated artifacts was rejected — they are immutable audit evidence, +# even though they must not leak into the active ScenarioRun result. +def invalidate_step_evidence( + db: Session, + *, + run_id: str, + logical_step_ids: set[str], +) -> int: + if not logical_step_ids: + return 0 + rows = ( + db.query(ScenarioArtifact) + .filter( + ScenarioArtifact.owner_type == "scenario_run", + ScenarioArtifact.owner_id == run_id, + ScenarioArtifact.logical_step_id.in_(sorted(logical_step_ids)), + ScenarioArtifact.is_active.is_(True), + ) + .all() + ) + invalidated_at = datetime.now(UTC) + for artifact in rows: + artifact.is_active = False + artifact.invalidated_at = invalidated_at + db.flush() + return len(rows) +# #endregion ScenarioExecution.Artifacts.InvalidateStepEvidence + + # #region ScenarioExecution.Artifacts.List [C:3] [TYPE Function] [SEMANTICS scenario,execution,artifact,list] # @ingroup ScenarioExecution # @BRIEF List artifacts owned by one owner, newest first, optional kind filter. @@ -112,6 +247,10 @@ def artifact_to_dict(artifact: ScenarioArtifact) -> dict[str, Any]: "content_ref": artifact.content_ref, "sha256": artifact.sha256, "retention_class": artifact.retention_class, + "logical_step_id": artifact.logical_step_id, + "attempt": artifact.attempt, + "is_active": artifact.is_active, + "invalidated_at": artifact.invalidated_at.isoformat() if artifact.invalidated_at else None, "created_at": artifact.created_at.isoformat() if artifact.created_at else None, } # #endregion ScenarioExecution.Artifacts.ToDict diff --git a/backend/src/services/dashboard_testing/execution/dispatch.py b/backend/src/services/dashboard_testing/execution/dispatch.py index ecc581e05..2d8bf5711 100644 --- a/backend/src/services/dashboard_testing/execution/dispatch.py +++ b/backend/src/services/dashboard_testing/execution/dispatch.py @@ -3,6 +3,8 @@ # @BRIEF Dispatch only ready steps and mark dependent descendants blocked after failure. # @RELATION DEPENDS_ON -> [ScenarioExecution.ExecutorRegistry] # @INVARIANT A failed producer blocks descendants; unknown tools fail before executor invocation. +# @INVARIANT An executor receives only an immutable validated ActionExecutionDescriptor; missing or +# mismatched descriptor fields are typed blocked before adapter I/O. # @INVARIANT Every dispatch decision is traced with a molecular CoT marker (REASON/REFLECT/EXPLORE). from __future__ import annotations @@ -29,8 +31,8 @@ def _descendants(step_id: str, edges: list[dict[str, str]]) -> set[str]: # #region ScenarioExecution.Dispatch.Step [C:4] [TYPE Function] [SEMANTICS scenario,execution,dispatch,step] # @ingroup ScenarioExecution -# @BRIEF Dispatch one step: dependency gate -> human control -> typed executor. -# @POST Returns a step outcome dict; unknown tools reject before any executor I/O. +# @BRIEF Dispatch one step: dependency gate -> human control -> descriptor-bound typed executor. +# @POST Returns a step outcome dict; invalid descriptors reject before any executor I/O. def dispatch_step(step: dict[str, Any], *, completed: dict[str, dict[str, Any]], registry: ScenarioExecutorRegistry, edges: list[dict[str, str]]) -> dict[str, Any]: step_id = str(step["logical_step_id"]) dependencies = [str(edge["source"]) for edge in edges if str(edge["target"]) == step_id] @@ -55,6 +57,17 @@ def dispatch_step(step: dict[str, Any], *, completed: dict[str, dict[str, Any]], error="dependency_failed", ) return {"status": "blocked", "reason": "dependency_failed", "logical_step_id": step_id, "blocked_descendants": blocked_descendants} + descriptor = step.get("action_descriptor") + if not isinstance(descriptor, dict): + return { + "status": "blocked", "reason": "ACTION_DESCRIPTOR_REQUIRED", + "logical_step_id": step_id, + } + if descriptor.get("tool") != step.get("tool") or descriptor.get("action") != step.get("action"): + return { + "status": "blocked", "reason": "ACTION_DESCRIPTOR_MISMATCH", + "logical_step_id": step_id, + } if step.get("tool") == "human": logger.reflect( "ScenarioExecution.Dispatch.dispatch_step", @@ -62,7 +75,7 @@ def dispatch_step(step: dict[str, Any], *, completed: dict[str, dict[str, Any]], payload={"logical_step_id": step_id}, ) return {"status": "waiting_human", "logical_step_id": step_id} - executor = registry.resolve(str(step.get("tool", "assertion"))) + executor = registry.resolve(descriptor) result = executor(step, completed) logger.reflect( "ScenarioExecution.Dispatch.dispatch_step", diff --git a/backend/src/services/dashboard_testing/execution/environment_policy.py b/backend/src/services/dashboard_testing/execution/environment_policy.py new file mode 100644 index 000000000..f53f74dfd --- /dev/null +++ b/backend/src/services/dashboard_testing/execution/environment_policy.py @@ -0,0 +1,52 @@ +# #region ScenarioExecution.EnvironmentPolicy [C:4] [TYPE Module] [SEMANTICS scenario,execution,environment,prod,authority] +# @BRIEF Resolve the execution environment class exclusively from server-owned configuration. +# @INVARIANT A request/run body cannot classify an environment; unknown configuration is rejected before +# ScenarioRun, gate, queue, notification, or adapter side effects exist. +# @RATIONALE PROD is an execution authority decision, not display metadata. ConfigManager owns the +# deployment Environment record including stage and is_production policy. +# @REJECTED Inferring PROD from environment_id text or accepting a caller is_prod flag was rejected — +# either allows a forged PREPROD classification to bypass the durable approval gate. +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class EnvironmentExecutionPolicy: + environment_id: str + environment_class: str + + @property + def is_prod(self) -> bool: + return self.environment_class == "PROD" + + +# #region ScenarioExecution.EnvironmentPolicy.Resolve [C:4] [TYPE Function] [SEMANTICS scenario,execution,environment,prod,authority] +# @ingroup ScenarioExecution +# @BRIEF Resolve one configured environment into immutable non-PROD/PROD execution policy. +# @PRE config_manager is an application-owned configuration provider, never request metadata. +# @POST Returns a policy whose environment_id equals the supplied target, or raises +# ENVIRONMENT_NOT_CONFIGURED before any caller may create a durable execution side effect. +# @INVARIANT Stage=PROD or is_production=true always resolves PROD; no caller-supplied field can +# downgrade it, and a PREPROD record cannot be upgraded by request data. +# @RELATION CALLED_BY -> [ScenarioExecution.Runner.Start] +# @RELATION CALLED_BY -> [Api.ScenarioExecution.Routes.Start] +# @RELATION CALLED_BY -> [Api.ScenarioAutomation.DirectTrigger] +# @RELATION CALLED_BY -> [ScenarioAutomation.Trigger.Dispatch] +def resolve_environment_execution_policy( + environment_id: str, config_manager: Any +) -> EnvironmentExecutionPolicy: + """Return the configured class or fail closed without looking at caller supplied fields.""" + environment = config_manager.get_environment(environment_id) if config_manager is not None else None + if environment is None: + raise ValueError("ENVIRONMENT_NOT_CONFIGURED") + is_prod = bool(getattr(environment, "is_production", False)) or ( + str(getattr(environment, "stage", "") or "").upper() == "PROD" + ) + return EnvironmentExecutionPolicy( + environment_id=environment_id, + environment_class="PROD" if is_prod else "PREPROD", + ) +# #endregion ScenarioExecution.EnvironmentPolicy.Resolve +# #endregion ScenarioExecution.EnvironmentPolicy diff --git a/backend/src/services/dashboard_testing/execution/executor_registry.py b/backend/src/services/dashboard_testing/execution/executor_registry.py index deceb3d66..22e1c6759 100644 --- a/backend/src/services/dashboard_testing/execution/executor_registry.py +++ b/backend/src/services/dashboard_testing/execution/executor_registry.py @@ -2,6 +2,7 @@ # @defgroup ScenarioExecution Typed executor registry for deterministic step dispatch. # @BRIEF Resolve registered tool executors before external I/O. # @INVARIANT human is a lifecycle control and is never registered as an executor. +# @INVARIANT Resolution accepts an immutable ActionExecutionDescriptor, never a tool string/default. from __future__ import annotations from collections.abc import Callable @@ -13,15 +14,31 @@ class ScenarioExecutorRegistry: def __init__(self) -> None: self._executors: dict[str, Executor] = {} - def register(self, tool: str, executor: Executor) -> None: + def register(self, tool: str, executor: Executor, *, action: str | None = None) -> None: if tool == "human": raise ValueError("human is a lifecycle checkpoint, not an executor") - self._executors[tool] = executor + if action is None: + from src.services.dashboard_testing.scenario.templates import REGISTERED_ACTIONS - def resolve(self, tool: str) -> Executor: + actions = [ + registered_action + for registered_action, entry in REGISTERED_ACTIONS.items() + if entry["tool"] == tool + ] + if not actions: + raise ValueError("ACTION_DESCRIPTOR_REQUIRED") + for registered_action in actions: + self._executors[f"{tool}:{registered_action}"] = executor + return + self._executors[f"{tool}:{action}"] = executor + + def resolve(self, descriptor: dict[str, Any]) -> Executor: + tool, action = descriptor.get("tool"), descriptor.get("action") + if not isinstance(tool, str) or not isinstance(action, str): + raise ValueError("ACTION_DESCRIPTOR_REQUIRED") try: - return self._executors[tool] + return self._executors[f"{tool}:{action}"] except KeyError as exc: - raise ValueError(f"unknown executor tool: {tool}") from exc + raise ValueError(f"unknown action executor: {tool}:{action}") from exc # #endregion ScenarioExecution.ExecutorRegistry diff --git a/backend/src/services/dashboard_testing/execution/executors.py b/backend/src/services/dashboard_testing/execution/executors.py new file mode 100644 index 000000000..0d74ef3af --- /dev/null +++ b/backend/src/services/dashboard_testing/execution/executors.py @@ -0,0 +1,362 @@ +# #region ScenarioExecution.Executors [C:4] [TYPE Module] [SEMANTICS scenario,execution,executor,adapter,037,038,036] +# @defgroup ScenarioExecution Typed executors that reuse existing 037/038/036 infrastructure. +# @BRIEF Dispatch by tool to bounded adapters. Missing live sessions never manufacture PASS. +# @INVARIANT human is not registered. Unavailable live I/O returns typed inconclusive. +# @INVARIANT Assertion outcomes come from 037 compare_values, never from a hardcoded pass. +# @INVARIANT Browser PASS is materialized only from an explicit BrowserExecutionAdapter success, +# never from session_id or page_ref metadata. +# @REJECTED Treating a persisted browser session/page identifier as evidence of completed live I/O +# was rejected — a dead or absent Playwright context must remain typed inconclusive. +# @INVARIANT Superset API PASS is materialized only from an explicit SupersetExecutionAdapter +# success, never from query_result metadata. +# @REJECTED Treating a static query_result as proof of a live 037/Superset execution was rejected — +# no client response, authoritative model, or response envelope was verified. +# @INVARIANT Screenshot PASS is materialized only from an explicit ScreenshotExecutionAdapter +# success with durable evidence refs, never from capture_bytes metadata. +# @REJECTED Treating raw capture_bytes as proof of a real ScreenshotService/036 capture was +# rejected — bytes without a registered evidence boundary are not execution evidence. +from __future__ import annotations + +from hashlib import sha256 +from io import BytesIO +from typing import Any + +from src.schemas.dashboard_testing import ComparisonPolicy, ComparisonPolicyType, ComparisonStatus, NormalizedValue, ValueKind +from src.services.dashboard_testing.comparison import compare_values + +from .artifacts import has_verified_evidence, is_valid_sha256 +from .executor_registry import ScenarioExecutorRegistry +from .live_adapter import ( + BrowserAdapterResult, # noqa: F401 + BrowserExecutionAdapter, + ScreenshotAdapterResult, # noqa: F401 + ScreenshotExecutionAdapter, + SupersetAdapterResult, # noqa: F401 + SupersetExecutionAdapter, + dispatch_live_adapter, +) + +_STATUS = { + ComparisonStatus.PASS: "passed", + ComparisonStatus.FAIL: "failed", + ComparisonStatus.INCONCLUSIVE: "inconclusive", + ComparisonStatus.MISSING_BASELINE: "blocked", + ComparisonStatus.STALE_BASELINE: "blocked", + ComparisonStatus.STALE_VISUAL_BASELINE: "blocked", + ComparisonStatus.IMMUTABILITY_VIOLATION: "failed", + ComparisonStatus.PERMISSION_DENIED: "blocked", + ComparisonStatus.SOURCE_ERROR: "inconclusive", +} + +# #region ScenarioExecution.Executors.Outcome [C:2] [TYPE Function] [SEMANTICS scenario,execution,executor,outcome] +# @BRIEF Build one typed ScenarioStep outcome without manufacturing a passing result. +def _outcome( + tool: str, + status: str, + *, + reason: str, + extra: dict[str, Any] | None = None, + refs: list[str] | None = None, + output_refs: list[str] | None = None, + artifact_refs: list[str] | None = None, +) -> dict[str, Any]: + payload = {**(extra or {}), "tool": tool, "reason_code": reason} + refs = refs or [] + return { + "status": status, + "step_outcome": payload, + "output_refs": output_refs if output_refs is not None else refs, + "artifact_refs": artifact_refs if artifact_refs is not None else refs, + "error_code": None if status == "passed" else reason, + } +# #endregion ScenarioExecution.Executors.Outcome + + +# #region ScenarioExecution.Executors.StepPayload [C:1] [TYPE Function] [SEMANTICS scenario,execution,executor,payload] +# @BRIEF Merge structured step metadata with its top-level execution fields. +def _step_payload(step: dict[str, Any]) -> dict[str, Any]: + meta = step.get("step_meta") if isinstance(step.get("step_meta"), dict) else {} + return {**meta, **{k: v for k, v in step.items() if k != "step_meta"}} +# #endregion ScenarioExecution.Executors.StepPayload + + +# #region ScenarioExecution.Executors.NormalizeValue [C:2] [TYPE Function] [SEMANTICS scenario,execution,executor,value,comparison] +# @BRIEF Convert scalar/table inputs to the canonical 037 comparison value representation. +def _as_normalized(value: Any, *, source: str) -> NormalizedValue: + if isinstance(value, NormalizedValue): + return value + if isinstance(value, dict) and "kind" in value: + return NormalizedValue.model_validate(value) + if value is None: + return NormalizedValue(kind=ValueKind.NULL, raw_value=None, canonical_value=None, source=source) + if isinstance(value, bool): + return NormalizedValue(kind=ValueKind.BOOLEAN, raw_value=value, canonical_value=str(value).lower(), source=source) + if isinstance(value, int) and not isinstance(value, bool): + return NormalizedValue(kind=ValueKind.INTEGER, raw_value=value, canonical_value=str(value), source=source) + if isinstance(value, float): + return NormalizedValue(kind=ValueKind.DECIMAL, raw_value=value, canonical_value=str(value), source=source) + if isinstance(value, list): + return NormalizedValue(kind=ValueKind.TABLE, raw_value=value, canonical_value=str(value), source=source) + return NormalizedValue(kind=ValueKind.STRING, raw_value=value, canonical_value=str(value), source=source) +# #endregion ScenarioExecution.Executors.NormalizeValue + + +# #region ScenarioExecution.Executors.ComparisonPolicy [C:2] [TYPE Function] [SEMANTICS scenario,execution,executor,policy,comparison] +# @BRIEF Resolve one supported comparison policy from a scenario step payload. +def _policy_from(payload: dict[str, Any]) -> ComparisonPolicy: + raw = payload.get("policy") or payload.get("comparison_policy") or {} + if isinstance(raw, ComparisonPolicy): + return raw + if isinstance(raw, dict) and raw.get("type"): + return ComparisonPolicy.model_validate(raw) + policy_type = str(payload.get("policy_type") or "exact") + mapping = { + "exact": ComparisonPolicyType.EXACT, + "absolute_tolerance": ComparisonPolicyType.ABSOLUTE_TOLERANCE, + "relative_tolerance": ComparisonPolicyType.RELATIVE_TOLERANCE, + "range": ComparisonPolicyType.RANGE, + "row_set": ComparisonPolicyType.ROW_SET, + } + return ComparisonPolicy(type=mapping.get(policy_type, ComparisonPolicyType.EXACT)) +# #endregion ScenarioExecution.Executors.ComparisonPolicy + + +# #region ScenarioExecution.Executors.CompletedValue [C:2] [TYPE Function] [SEMANTICS scenario,execution,executor,dependency,value] +# @BRIEF Read the actual value previously materialized by one completed dependency step. +def _completed_value(completed: dict[str, dict[str, Any]], logical_step_id: str | None) -> Any: + if not logical_step_id: + return None + parent = completed.get(logical_step_id) or {} + return ( + parent.get("actual") + or (parent.get("step_outcome") or {}).get("actual") + or (parent.get("outputs") or {}).get("actual") + or parent.get("output") + ) +# #endregion ScenarioExecution.Executors.CompletedValue + + +# #region ScenarioExecution.Executors.Assertion [C:3] [TYPE Function] [SEMANTICS scenario,execution,executor,assertion,comparison] +def assertion(step: dict[str, Any], completed: dict[str, dict[str, Any]]) -> dict[str, Any]: + payload = _step_payload(step) + expected = payload.get("expected") + actual = payload.get("actual") + if actual is None: + actual = _completed_value(completed, payload.get("actual_from") or payload.get("source_step_id")) + if expected is None and actual is None: + return _outcome("assertion", "inconclusive", reason="ASSERTION_INPUTS_MISSING") + result = compare_values( + _as_normalized(actual, source="scenario_step"), + _as_normalized(expected, source="baseline"), + _policy_from(payload), + ) + status = _STATUS.get(result.status, "inconclusive") + return _outcome( + "assertion", + status, + reason=result.status.value.upper(), + extra={"comparison_status": result.status.value, "diff": [d.model_dump() for d in result.diff]}, + ) +# #endregion ScenarioExecution.Executors.Assertion + + +# #region ScenarioExecution.Executors.Browser [C:3] [TYPE Function] [SEMANTICS scenario,execution,executor,browser] +# @BRIEF Dispatch browser work only through an injected live adapter and map its typed result. +# @RELATION DEPENDS_ON -> [EXT:Playwright:BrowserTransport] +# @INVARIANT Browser PASS is materialized only from an explicit BrowserExecutionAdapter success, +# never from session_id or page_ref metadata. +# @REJECTED Synthesizing BROWSER_SESSION_REPLAY from IDs was rejected — identifiers cannot prove +# that a live browser action occurred. +def browser( + step: dict[str, Any], + completed: dict[str, dict[str, Any]], + *, + adapter: BrowserExecutionAdapter | None = None, +) -> dict[str, Any]: + payload = _step_payload(step) + context = { + key: payload[key] + for key in ("session_id", "page_ref") + if payload.get(key) is not None + } + return dispatch_live_adapter( + _outcome, + "browser", + step, + completed, + adapter, + context, + unavailable_code="BROWSER_ADAPTER_UNAVAILABLE", + timeout_code="BROWSER_ADAPTER_TIMEOUT", + error_code="BROWSER_ADAPTER_ERROR", + invalid_code="BROWSER_ADAPTER_INVALID_RESULT", + ) +# #endregion ScenarioExecution.Executors.Browser + + +# #region ScenarioExecution.Executors.SupersetApi [C:3] [TYPE Function] [SEMANTICS scenario,execution,executor,superset] +# @BRIEF Dispatch Superset work only through an injected 037 adapter and map its typed result. +# @RELATION DEPENDS_ON -> [EXT:Superset:ChartDataTransport] +# @INVARIANT Superset API PASS is materialized only from an explicit SupersetExecutionAdapter +# success, never from query_result metadata. +# @REJECTED Synthesizing SUPERSET_QUERY_BOUND from step metadata was rejected — static values do +# not prove the 037 authoritative query pipeline ran. +def superset_api( + step: dict[str, Any], + completed: dict[str, dict[str, Any]], + *, + adapter: SupersetExecutionAdapter | None = None, +) -> dict[str, Any]: + payload = _step_payload(step) + context = { + key: payload[key] + for key in ("environment_id", "dashboard_id", "chart_id", "dataset_id", "result_key") + if payload.get(key) is not None + } + return dispatch_live_adapter( + _outcome, + "superset_api", + step, + completed, + adapter, + context, + unavailable_code="SUPERSET_ADAPTER_UNAVAILABLE", + timeout_code="SUPERSET_ADAPTER_TIMEOUT", + error_code="SUPERSET_ADAPTER_ERROR", + invalid_code="SUPERSET_ADAPTER_INVALID_RESULT", + ) +# #endregion ScenarioExecution.Executors.SupersetApi + + +# #region ScenarioExecution.Executors.Screenshot [C:3] [TYPE Function] [SEMANTICS scenario,execution,executor,screenshot,evidence] +# @BRIEF Dispatch screenshot capture only through the existing ScreenshotService/036 adapter. +# @RELATION DEPENDS_ON -> [Plugin.Service.ScreenshotService] +# @INVARIANT Screenshot PASS is materialized only from explicit ScreenshotExecutionAdapter success +# with durable evidence refs, never from capture_bytes metadata. +# @INVARIANT Screenshot PASS requires one non-zero SHA-256 agreeing with the forwarded artifact digest. +# @REJECTED Computing SCREENSHOT_DIGESTED from caller metadata was rejected — only the adapter can +# prove ScreenshotService capture and 036 evidence registration completed. +def screenshot( + step: dict[str, Any], + completed: dict[str, dict[str, Any]], + *, + adapter: ScreenshotExecutionAdapter | None = None, +) -> dict[str, Any]: + payload = _step_payload(step) + context = { + key: payload[key] + for key in ("environment_id", "dashboard_id", "capture_profile_id") + if payload.get(key) is not None + } + outcome = dispatch_live_adapter( + _outcome, + "screenshot", + step, + completed, + adapter, + context, + unavailable_code="SCREENSHOT_ADAPTER_UNAVAILABLE", + timeout_code="SCREENSHOT_ADAPTER_TIMEOUT", + error_code="SCREENSHOT_ADAPTER_ERROR", + invalid_code="SCREENSHOT_ADAPTER_INVALID_RESULT", + required_pass_detail_keys=("sha256",), + require_pass_artifact_refs=True, + missing_evidence_code="SCREENSHOT_EVIDENCE_REQUIRED", + ) + if outcome["status"] != "passed" or has_verified_evidence( + outcome["artifact_refs"], outcome["step_outcome"] + ): + return outcome + return _outcome( + "screenshot", + "inconclusive", + reason="SCREENSHOT_EVIDENCE_INVALID", + extra=context, + output_refs=outcome["output_refs"], + artifact_refs=[], + ) +# #endregion ScenarioExecution.Executors.Screenshot + + +# #region ScenarioExecution.Executors.Xlsx [C:3] [TYPE Function] [SEMANTICS scenario,execution,executor,xlsx,openpyxl] +def xlsx(step: dict[str, Any], _completed: dict[str, dict[str, Any]]) -> dict[str, Any]: + payload = _step_payload(step) + content = payload.get("xlsx_bytes") or payload.get("content_bytes") + if not content: + return _outcome("xlsx", "inconclusive", reason="XLSX_BYTES_REQUIRED") + try: + from openpyxl import load_workbook + except ImportError: + return _outcome("xlsx", "inconclusive", reason="OPENPYXL_UNAVAILABLE") + workbook = load_workbook(BytesIO(content), read_only=True, data_only=True) + try: + sheet = workbook.active + rows = [list(row) for row in sheet.iter_rows(values_only=True)] + finally: + workbook.close() + digest = sha256(content if isinstance(content, bytes) else str(content).encode()).hexdigest() + return _outcome( + "xlsx", + "passed", + reason="XLSX_PARSED", + extra={"row_count": len(rows), "actual": rows, "sha256": digest}, + refs=[f"xlsx:{digest}"], + ) +# #endregion ScenarioExecution.Executors.Xlsx + + +# #region ScenarioExecution.Executors.Report [C:2] [TYPE Function] [SEMANTICS scenario,execution,executor,report] +def report(step: dict[str, Any], completed: dict[str, dict[str, Any]]) -> dict[str, Any]: + payload = _step_payload(step) + sources = payload.get("source_step_ids") or list(completed) + if not sources: + return _outcome("report", "inconclusive", reason="REPORT_SOURCES_MISSING") + digest = sha256("|".join(str(item) for item in sources).encode()).hexdigest() + report_ref = f"report:{digest}" + return _outcome( + "report", + "passed", + reason="REPORT_COMPILED", + extra={"source_step_ids": sources}, + output_refs=[report_ref], + artifact_refs=[], + ) +# #endregion ScenarioExecution.Executors.Report + + +# #region ScenarioExecution.Executors.Artifact [C:2] [TYPE Function] [SEMANTICS scenario,execution,executor,artifact] +# @INVARIANT Artifact PASS requires a canonical, non-zero SHA-256 digest for the declared ref. +# @REJECTED Accepting a 64-character sentinel or non-hex digest was rejected — it forges provenance. +def artifact(step: dict[str, Any], _completed: dict[str, dict[str, Any]]) -> dict[str, Any]: + payload = _step_payload(step) + content_ref = payload.get("content_ref") or payload.get("artifact_ref") + digest = payload.get("sha256") + if not content_ref or not digest: + return _outcome("artifact", "inconclusive", reason="ARTIFACT_REF_REQUIRED") + if not is_valid_sha256(digest): + return _outcome("artifact", "failed", reason="ARTIFACT_DIGEST_INVALID") + return _outcome( + "artifact", "passed", reason="ARTIFACT_BOUND", + extra={"content_ref": content_ref, "sha256": digest.lower()}, refs=[str(content_ref)], + ) +# #endregion ScenarioExecution.Executors.Artifact + + +# #region ScenarioExecution.Executors.RegisterDefaults [C:2] [TYPE Function] [SEMANTICS scenario,execution,executor,registry] +# @BRIEF Register bounded built-in executors and optional injected live-I/O adapters. +def _register_default_executors( + registry: ScenarioExecutorRegistry, + *, + browser_adapter: BrowserExecutionAdapter | None = None, + superset_adapter: SupersetExecutionAdapter | None = None, + screenshot_adapter: ScreenshotExecutionAdapter | None = None, +) -> None: + registry.register("assertion", assertion) + registry.register("browser", lambda step, completed: browser(step, completed, adapter=browser_adapter)) + registry.register("superset_api", lambda step, completed: superset_api(step, completed, adapter=superset_adapter)) + registry.register("xlsx", xlsx) + registry.register("screenshot", lambda step, completed: screenshot(step, completed, adapter=screenshot_adapter)) + registry.register("report", report) + registry.register("artifact", artifact) +# #endregion ScenarioExecution.Executors.RegisterDefaults +# #endregion ScenarioExecution.Executors diff --git a/backend/src/services/dashboard_testing/execution/lifecycle.py b/backend/src/services/dashboard_testing/execution/lifecycle.py index 5dc260a7b..dfba48820 100644 --- a/backend/src/services/dashboard_testing/execution/lifecycle.py +++ b/backend/src/services/dashboard_testing/execution/lifecycle.py @@ -2,13 +2,26 @@ # @defgroup ScenarioExecution Run lifecycle controls, checkpoints, retry invalidation, infra resume. from __future__ import annotations -from datetime import UTC, datetime +import copy +from datetime import UTC, datetime, timedelta import secrets +from sqlalchemy import update from sqlalchemy.orm import Session +from src.models.scenario_approval import ActionApprovalGate from src.models.scenario_checkpoint import HumanCheckpoint from src.models.scenario_run import ScenarioRun, ScenarioStepRun +from src.models.scenario_worker import ScenarioStepLease + +from .artifacts import invalidate_step_evidence +from .runner_plan import validate_pinned_runner_plan + +_DEFAULT_CANCEL_DRAIN_SECONDS = 30 + + +def _as_utc(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) def _descendants(step_id: str, edges: list[dict[str, str]]) -> set[str]: @@ -25,6 +38,97 @@ def _descendants(step_id: str, edges: list[dict[str, str]]) -> set[str]: pending.extend(children.get(node, set())) return result + +# #region ScenarioExecution.Lifecycle.TimeoutClosure [C:4] [TYPE Function] [SEMANTICS scenario,execution,timeout,dag,provenance] +# @BRIEF Materialize missing descendants from the immutable persisted runner plan as blocked rows. +# @RELATION CALLED_BY -> [ScenarioExecution.Lifecycle.Timeout] +# @INVARIANT Every declared timeout descendant has a durable blocked row before terminalization; +# completed rows and nodes outside the pinned descendant closure are never overwritten. +# @REJECTED Re-deriving the graph from a mutable scenario revision was rejected — timeout closure +# must use the run's pinned plan and its original topological position/step metadata. +def _materialize_timeout_descendants( + db: Session, + run: ScenarioRun, + *, + source_step_id: str, + descendant_ids: set[str], + existing_step_ids: set[str], +) -> list[ScenarioStepRun]: + plan = run.runner_plan or {} + order = [str(step_id) for step_id in plan.get("topological_order", [])] + plan_steps = { + str(step.get("logical_step_id", step.get("id", ""))): copy.deepcopy(step) + for step in plan.get("steps", []) + if isinstance(step, dict) + } + materialized: list[ScenarioStepRun] = [] + for position, descendant_id in enumerate(order): + if descendant_id not in descendant_ids or descendant_id in existing_step_ids: + continue + metadata = plan_steps.get(descendant_id, {"logical_step_id": descendant_id}) + row = ScenarioStepRun( + run_id=run.id, + logical_step_id=descendant_id, + step_position=position, + attempt=1, + status="blocked", + inputs_snapshot={"runner_plan_step": metadata}, + outputs={}, + artifact_refs=[], + progress=100, + error_code="UPSTREAM_TIMEOUT", + step_outcome={ + "status": "blocked", + "error_code": "UPSTREAM_TIMEOUT", + "blocked_by": source_step_id, + }, + finished_at=datetime.now(UTC), + ) + db.add(row) + materialized.append(row) + if descendant_ids - set(order): + raise ValueError("runner plan timeout descendant is not topologically declared") + db.flush() + return materialized +# #endregion ScenarioExecution.Lifecycle.TimeoutClosure + + +# #region ScenarioExecution.Lifecycle.TerminalLease [C:3] [TYPE Function] [SEMANTICS scenario,execution,lifecycle,terminal,lease] +# @BRIEF Revoke active worker claims for a terminal or forcibly drained run without deleting lease audit rows. +# @INVARIANT A terminal ScenarioRun has no unexpired lease; worker claim history remains durable. +def _expire_step_leases(db: Session, run_id: str, logical_step_ids: set[str] | None = None) -> None: + query = db.query(ScenarioStepLease).filter(ScenarioStepLease.run_id == run_id).with_for_update() + if logical_step_ids is not None: + query = query.filter(ScenarioStepLease.logical_step_id.in_(sorted(logical_step_ids))) + expires_at = datetime.now(UTC) + for lease in query.all(): + lease.expires_at = expires_at +# #endregion ScenarioExecution.Lifecycle.TerminalLease + + +# #region ScenarioExecution.Lifecycle.TerminalProjection [C:3] [TYPE Function] [SEMANTICS scenario,execution,lifecycle,terminal,artifact,provenance] +# @BRIEF Archive active result attachment before a terminal cancellation or timeout replaces it. +# @INVARIANT Artifact rows and prior output are retained as explicit history, but active refs and +# outcomes cannot leak into the terminal current-result projection. +def _retire_active_step_projection(db: Session, run_id: str, steps: list[ScenarioStepRun]) -> None: + affected = {step.logical_step_id for step in steps} + invalidate_step_evidence(db, run_id=run_id, logical_step_ids=affected) + for step in steps: + prior_outputs = copy.deepcopy(step.outputs or {}) + history = list(prior_outputs.pop("terminal_history", [])) + history.append({ + "attempt": step.attempt, + "status": step.status, + "outputs": prior_outputs, + "artifact_refs": list(step.artifact_refs or []), + "error_code": step.error_code, + "step_outcome": copy.deepcopy(step.step_outcome or {}), + }) + step.outputs = {"terminal_history": history} + step.artifact_refs = [] + step.step_outcome = {} +# #endregion ScenarioExecution.Lifecycle.TerminalProjection + # #region ScenarioExecution.Lifecycle.Suspend [C:4] [TYPE Function] [SEMANTICS scenario,execution,human,checkpoint,suspend] def suspend_for_human(db: Session, run_id: str, logical_step_id: str, evidence_refs: list[str]) -> HumanCheckpoint: run = db.query(ScenarioRun).filter(ScenarioRun.id == run_id).first() @@ -39,68 +143,323 @@ def suspend_for_human(db: Session, run_id: str, logical_step_id: str, evidence_r # #endregion ScenarioExecution.Lifecycle.Suspend # #region ScenarioExecution.Lifecycle.Decide [C:4] [TYPE Function] [SEMANTICS scenario,execution,human,checkpoint,cas,decision] +# @POST Atomically consumes the checkpoint and materializes its deterministic step outcome; +# the run returns to queued so the runner can continue the ready frontier. +# @INVARIANT confirm/pass records passed; false_positive/inconclusive records inconclusive, +# never a fabricated PASS. A failed disposition remains failed and blocks dependents. +# @REJECTED Treating false_positive as a passing execution result — the observation remains an +# explicit non-pass checkpoint outcome even when dependents can collect more evidence. def decide_checkpoint(db: Session, checkpoint_id: str, *, disposition: str, expected_version: int, actor_id: str, comment: str = "") -> HumanCheckpoint: + if disposition not in {"confirm", "false_positive", "inconclusive", "pass", "fail"}: + raise ValueError("invalid disposition") + decided_at = datetime.now(UTC) + consumed = db.execute( + update(HumanCheckpoint) + .where( + HumanCheckpoint.id == checkpoint_id, + HumanCheckpoint.status == "pending", + HumanCheckpoint.decision_version == expected_version, + ) + .values( + status="decided", + disposition=disposition, + comment=comment, + decided_by=actor_id, + decided_at=decided_at, + decision_version=HumanCheckpoint.decision_version + 1, + ) + ) + if consumed.rowcount != 1: + exists = db.query(HumanCheckpoint.id).filter(HumanCheckpoint.id == checkpoint_id).first() + raise ValueError("stale checkpoint decision" if exists is not None else "checkpoint not found") checkpoint = db.query(HumanCheckpoint).filter(HumanCheckpoint.id == checkpoint_id).first() if checkpoint is None: raise ValueError("checkpoint not found") - if checkpoint.status != "pending" or checkpoint.decision_version != expected_version: - raise ValueError("stale checkpoint decision") - if disposition not in {"confirm", "false_positive", "inconclusive", "pass", "fail"}: - raise ValueError("invalid disposition") - checkpoint.status = "decided" - checkpoint.disposition = disposition - checkpoint.comment = comment - checkpoint.decided_by = actor_id - checkpoint.decided_at = datetime.now(UTC) - checkpoint.decision_version += 1 run = db.query(ScenarioRun).filter(ScenarioRun.id == checkpoint.run_id).first() if run is not None: - run.status = {"fail": "failed", "confirm": "failed", "false_positive": "passed", "pass": "passed", "inconclusive": "inconclusive"}[disposition] + outcome_status = { + "confirm": "passed", + "pass": "passed", + "false_positive": "inconclusive", + "inconclusive": "inconclusive", + "fail": "failed", + }[disposition] + step = db.query(ScenarioStepRun).filter( + ScenarioStepRun.run_id == run.id, + ScenarioStepRun.logical_step_id == checkpoint.logical_step_id, + ).first() + if step is None: + raise ValueError("checkpoint step not found") + step.status = outcome_status + step.progress = 100 + step.error_code = None if outcome_status == "passed" else f"HUMAN_{disposition.upper()}" + step.step_outcome = { + "logical_step_id": checkpoint.logical_step_id, + "status": outcome_status, + "disposition": disposition, + "comment": comment, + } + step.finished_at = checkpoint.decided_at + run.status = "queued" run.phase = "executing" db.flush() return checkpoint # #endregion ScenarioExecution.Lifecycle.Decide -# #region ScenarioExecution.Lifecycle.Cancel [C:3] [TYPE Function] [SEMANTICS scenario,execution,cancel,run] -def cancel_run(db: Session, run_id: str) -> ScenarioRun: +# #region ScenarioExecution.Lifecycle.Cancel [C:5] [TYPE Function] [SEMANTICS scenario,execution,cancel,drain,lease,artifact] +# @ingroup ScenarioExecution +# @BRIEF Request bounded cancellation, then terminalize after the active adapter completes or its persisted drain deadline expires. +# @PRE A run is not passed/failed/blocked/inconclusive; cancellation does not decide a HumanCheckpoint +# or approve a PROD ApprovalGate. +# @POST Queued work becomes skipped. The first request pins an immutable drain deadline. A running +# step may finish only before that deadline; the deadline finalizer marks remaining work cancelled. +# @INVARIANT A terminal cancelled run has no running/queued step, no unexpired worker lease, and +# no active evidence projection. Artifact/lease rows are retained for audit. +# @INVARIANT Pending human checkpoints are cancelled (not decided), and a pending PROD gate expires +# (not approved/denied by an actor), so neither control can reactivate a cancelled run. +# @RATIONALE The terminal closure intentionally remains one transaction over steps, leases, evidence, +# checkpoints, and gates so concurrent lifecycle controls cannot observe a partial cancel. +# @REJECTED Treating cancellation as a successful result or deleting partial evidence was rejected. +def _request_cancel_deadline(run: ScenarioRun, *, requested_at: datetime, drain_seconds: int) -> bool: + if run.status != "cancel_requested": + run.status = "cancel_requested" + run.cancel_requested_at = requested_at + run.cancel_drain_deadline_at = requested_at + timedelta(seconds=drain_seconds) + deadline = run.cancel_drain_deadline_at + return deadline is not None and _as_utc(deadline) <= requested_at + + +def _prepare_cancelled_steps(steps: list[ScenarioStepRun], *, cancelled_at: datetime) -> list[ScenarioStepRun]: + running: list[ScenarioStepRun] = [] + for step in steps: + if step.status == "queued": + step.status = "skipped" + step.progress = 100 + step.error_code = "CANCELLED_BEFORE_DISPATCH" + step.finished_at = cancelled_at + elif step.status == "running": + running.append(step) + return running + + +def _cancel_pending_controls(db: Session, run_id: str, *, cancelled_at: datetime) -> None: + for checkpoint in db.query(HumanCheckpoint).filter( + HumanCheckpoint.run_id == run_id, + HumanCheckpoint.status == "pending", + ).with_for_update().all(): + checkpoint.status = "cancelled" + checkpoint.decided_at = cancelled_at + for gate in db.query(ActionApprovalGate).filter( + ActionApprovalGate.owner_type == "scenario_run", + ActionApprovalGate.owner_id == run_id, + ActionApprovalGate.status == "pending", + ).with_for_update().all(): + gate.status = "expired" + gate.comment = "scenario run cancelled" + gate.decided_at = cancelled_at + + +def cancel_run( + db: Session, + run_id: str, + *, + drain_in_flight: bool = True, + drain_seconds: int = _DEFAULT_CANCEL_DRAIN_SECONDS, +) -> ScenarioRun: + if drain_seconds <= 0: + raise ValueError("drain_seconds must be positive") run = db.query(ScenarioRun).filter(ScenarioRun.id == run_id).first() if run is None: raise ValueError("run not found") - if run.status in {"passed", "failed", "cancelled", "inconclusive"}: + if run.status == "cancelled": + return run + if run.status in {"passed", "failed", "blocked", "inconclusive"}: raise ValueError("run is terminal") - run.status = "cancelled" + cancellation_at = datetime.now(UTC) + # A retry of cancel never extends the original bounded window. + deadline_expired = _request_cancel_deadline( + run, + requested_at=cancellation_at, + drain_seconds=drain_seconds, + ) run.phase = "draining" - run.finished_at = datetime.now(UTC) + steps = db.query(ScenarioStepRun).filter(ScenarioStepRun.run_id == run_id).all() + running = _prepare_cancelled_steps(steps, cancelled_at=cancellation_at) + if running and drain_in_flight and not deadline_expired: + db.flush() + return run + + if running: + for step in running: + step.status = "cancelled" + step.progress = 100 + step.error_code = "CANCEL_DRAIN_EXPIRED" + step.step_outcome = {"status": "cancelled", "error_code": "CANCEL_DRAIN_EXPIRED"} + step.finished_at = cancellation_at + _cancel_pending_controls(db, run_id, cancelled_at=cancellation_at) + _retire_active_step_projection(db, run_id, steps) + _expire_step_leases(db, run_id) + run.status = "cancelled" + run.phase = "terminal" + run.finished_at = cancellation_at db.flush() return run # #endregion ScenarioExecution.Lifecycle.Cancel -# #region ScenarioExecution.Lifecycle.Retry [C:4] [TYPE Function] [SEMANTICS scenario,execution,retry,downstream] +# #region ScenarioExecution.Lifecycle.CancelFinalizer [C:4] [TYPE Function] [SEMANTICS scenario,execution,cancel,drain,deadline] +# @BRIEF Finalize cancellation requests whose persisted bounded drain window has expired. +# @PRE The caller supplies the normal worker/scheduler database session; no adapter payload is fabricated. +# @POST Every returned run is terminal cancelled and has no active lease or active evidence projection. +# @INVARIANT A repeated sweep cannot extend a deadline, resurrect a run, or duplicate a terminal signal. +# @REJECTED Trusting an in-memory timer was rejected — a process restart would strand the persisted run. +def finalize_expired_cancellations(db: Session, *, now: datetime | None = None) -> list[ScenarioRun]: + observed_at = now or datetime.now(UTC) + candidates = ( + db.query(ScenarioRun) + .filter( + ScenarioRun.status == "cancel_requested", + ScenarioRun.cancel_drain_deadline_at.is_not(None), + ) + .with_for_update() + .all() + ) + terminalized: list[ScenarioRun] = [] + for run in candidates: + if _as_utc(run.cancel_drain_deadline_at) <= observed_at: + terminalized.append(cancel_run(db, run.id, drain_in_flight=False)) + return terminalized +# #endregion ScenarioExecution.Lifecycle.CancelFinalizer + +# #region ScenarioExecution.Lifecycle.RetryEligibility [C:3] [TYPE Function] [SEMANTICS scenario,execution,retry,lifecycle,human,approval] +# @BRIEF Refuse a retry before it can revive a terminal-success, cancelled, approval-pending, or human-held run. +# @INVARIANT A retry may reopen only queued|running|failed|blocked|inconclusive runs with no pending checkpoint. +def _require_retry_eligible_run(db: Session, run: ScenarioRun) -> None: + if run.status in {"passed", "cancel_requested", "cancelled", "pending_approval", "waiting_human"}: + raise ValueError("run is not retry-eligible") + if run.status not in {"queued", "running", "failed", "blocked", "inconclusive"}: + raise ValueError("run is not retry-eligible") + if db.query(HumanCheckpoint).filter( + HumanCheckpoint.run_id == run.id, + HumanCheckpoint.status == "pending", + ).first() is not None: + raise ValueError("cannot retry while human checkpoint is pending") +# #endregion ScenarioExecution.Lifecycle.RetryEligibility + + +# #region ScenarioExecution.Lifecycle.RetryArchive [C:3] [TYPE Function] [SEMANTICS scenario,execution,retry,attempt,archive] +# @BRIEF Move one active step attempt into explicit historical provenance before clearing its active state. +# @INVARIANT Historical attempts are labelled by their original attempt/status and never enter current artifact_refs/outcome aggregation. +def _archive_step_attempt(step: ScenarioStepRun) -> None: + previous_outputs = copy.deepcopy(step.outputs or {}) + history = list(previous_outputs.pop("attempt_history", [])) + history.append({ + "attempt": step.attempt, + "status": step.status, + "inputs_snapshot": copy.deepcopy(step.inputs_snapshot or {}), + "outputs": previous_outputs, + "artifact_refs": list(step.artifact_refs or []), + "error_code": step.error_code, + "step_outcome": copy.deepcopy(step.step_outcome or {}), + }) + step.status = "queued" + step.inputs_snapshot = {} + step.outputs = {"attempt_history": history} + step.artifact_refs = [] + step.step_outcome = {} + step.error_code = None + step.progress = 0 + step.started_at = None + step.finished_at = None + step.attempt += 1 +# #endregion ScenarioExecution.Lifecycle.RetryArchive + + +# #region ScenarioExecution.Lifecycle.RetryLease [C:3] [TYPE Function] [SEMANTICS scenario,execution,retry,lease,idempotency] +# @BRIEF Expire completed safe leases so the next retry worker can claim the persisted frontier. +# @INVARIANT An unsafe external effect is never reopened automatically; reconciliation is required. +def _expire_retry_safe_leases(db: Session, run_id: str, affected: set[str]) -> None: + leases = ( + db.query(ScenarioStepLease) + .filter( + ScenarioStepLease.run_id == run_id, + ScenarioStepLease.logical_step_id.in_(sorted(affected)), + ) + .with_for_update() + .all() + ) + if any(not (lease.idempotent or lease.retry_safe) for lease in leases): + raise ValueError("non-retry-safe side effect requires reconciliation") + retry_started_at = datetime.now(UTC) + for lease in leases: + lease.expires_at = retry_started_at +# #endregion ScenarioExecution.Lifecycle.RetryLease + + +# #region ScenarioExecution.Lifecycle.Retry [C:5] [TYPE Function] [SEMANTICS scenario,execution,retry,downstream,artifact,signal] +# @ingroup ScenarioExecution +# @BRIEF Invalidate one eligible failed frontier and all persisted descendants before a new attempt. +# @RELATION CALLS -> [ScenarioExecution.Artifacts.InvalidateStepEvidence] +# @PRE Target is failed|inconclusive|blocked, no human checkpoint is pending, the run is not +# cancelled/passed/pending approval, and every invalidated persisted row has retry budget. +# @POST The run returns to queued/executing; active outputs/outcomes/artifact refs are cleared for +# the target closure, each affected row advances its attempt, and historical attempts remain +# explicitly archived in outputs.attempt_history. +# @INVARIANT Artifact rows are retained as immutable audit evidence but their active projection is +# retired before the walker can produce a new terminal result or 047 signal. +# @INVARIANT A completed lease is expired for the new closure only when its recorded external +# effect exactly matches an immutable descriptor marked idempotent or retry-safe; +# unsafe/unknown effects require reconciliation. +# @REJECTED Inheriting retry authority from a tool or stale lease flags was rejected because a +# browser/action mutation could be replayed after its plan contract changed. +# @REJECTED Resetting only the named step was rejected — descendants can otherwise aggregate stale +# outcomes/evidence into a new result. Deleting historical artifacts was also rejected. def retry_step(db: Session, run_id: str, logical_step_id: str, *, max_attempts: int = 3) -> list[ScenarioStepRun]: + if max_attempts < 1: + raise ValueError("max_attempts must be positive") run = db.query(ScenarioRun).filter(ScenarioRun.id == run_id).first() if run is None: raise ValueError("run not found") + validate_pinned_runner_plan(run.runner_plan or {}) + _require_retry_eligible_run(db, run) steps = db.query(ScenarioStepRun).filter(ScenarioStepRun.run_id == run_id).order_by(ScenarioStepRun.step_position.asc()).all() target = next((step for step in steps if step.logical_step_id == logical_step_id), None) if target is None: raise ValueError("step not found") - if target.attempt >= max_attempts: - raise ValueError("retry attempts exceeded") + if target.status not in {"failed", "inconclusive", "blocked"}: + raise ValueError("step is not retry-eligible") affected = {logical_step_id, *_descendants(logical_step_id, list((run.runner_plan or {}).get("dependencies") or []))} - for step in steps: - if step.logical_step_id in affected: - step.status = "queued" - step.outputs = {} - step.step_outcome = {} - step.attempt += 1 + affected_steps = [step for step in steps if step.logical_step_id in affected] + descriptor_by_step = { + str(item.get("logical_step_id", item.get("id", ""))): item.get("action_descriptor") or {} + for item in (run.runner_plan or {}).get("steps", []) + if isinstance(item, dict) + } + if any( + not ( + bool(descriptor_by_step.get(step.logical_step_id, {}).get("idempotent")) + or bool(descriptor_by_step.get(step.logical_step_id, {}).get("retry_safe")) + ) + for step in affected_steps + ): + raise ValueError("non-retry-safe action descriptor requires reconciliation") + if any(step.attempt >= max_attempts for step in affected_steps): + raise ValueError("retry attempts exceeded") + _expire_retry_safe_leases(db, run_id, affected) + invalidate_step_evidence(db, run_id=run_id, logical_step_ids=affected) + for step in affected_steps: + _archive_step_attempt(step) + run.status = "queued" + run.phase = "executing" + run.error_code = None + run.finished_at = None db.flush() - return [step for step in steps if step.logical_step_id in affected] + return affected_steps # #endregion ScenarioExecution.Lifecycle.Retry # #region ScenarioExecution.Lifecycle.PauseInfra [C:4] [TYPE Function] [SEMANTICS scenario,execution,pause,infrastructure,resume-token] # @ingroup ScenarioExecution # @BRIEF Pause a non-terminal, non-human run for infrastructure reasons; mint a resume token. -# @PRE run exists, is not terminal and is not waiting on a HumanCheckpoint. +# @PRE run exists, is actively queued/running, is not terminal and is not waiting on a HumanCheckpoint. # @POST run.phase == paused; run.resume_token set; returns (run, token). # @SIDE_EFFECT DB write; token minted via secrets.token_urlsafe. # @REJECTED Using the HumanCheckpoint flow for infra pauses — checkpoint disposition is observation, @@ -113,6 +472,8 @@ def pause_for_infrastructure(db: Session, run_id: str) -> tuple[ScenarioRun, str raise ValueError("run is terminal") if run.status == "waiting_human": raise ValueError("cannot pause a run waiting on a human checkpoint") + if run.status not in {"queued", "running"}: + raise ValueError("run is not active") token = secrets.token_urlsafe(32) run.resume_token = token run.phase = "paused" @@ -125,7 +486,7 @@ def pause_for_infrastructure(db: Session, run_id: str) -> tuple[ScenarioRun, str # @ingroup ScenarioExecution # @BRIEF Resume an infrastructure-paused run from its resume token. # @PRE run exists; token matches run.resume_token; run not terminal; no pending HumanCheckpoint. -# @POST run.status -> queued, run.phase -> executing; token consumed (cleared). +# @POST run.status -> queued, run.phase -> executing; token consumed (cleared) before walker continuation. # @SIDE_EFFECT DB write; never re-runs completed steps (dispatch resumes from ready frontier). # @TEST_EDGE human_checkpoint_pending -> reject; stale_token -> reject; terminal -> reject. def resume_run(db: Session, run_id: str, *, resume_token: str, resume_reason: str) -> ScenarioRun: @@ -138,21 +499,111 @@ def resume_run(db: Session, run_id: str, *, resume_token: str, resume_reason: st raise ValueError("run is terminal") if run.status == "waiting_human": raise ValueError("cannot resume a human checkpoint; use /human/decision") + if run.status not in {"queued", "running"}: + raise ValueError("run is not active") pending_checkpoint = db.query(HumanCheckpoint).filter( HumanCheckpoint.run_id == run_id, HumanCheckpoint.status == "pending" ).first() if pending_checkpoint is not None: raise ValueError("cannot resume a human checkpoint; use /human/decision") + if run.phase != "paused": + raise ValueError("run is not paused") if not run.resume_token: raise ValueError("run is not paused") if not secrets.compare_digest(run.resume_token, resume_token): raise ValueError("stale resume token") run.resume_token = None + run.status = "queued" run.phase = "executing" - if run.status == "queued": - run.status = "queued" db.flush() return run # #endregion ScenarioExecution.Lifecycle.Resume +# #region ScenarioExecution.Lifecycle.Timeout [C:5] [TYPE Function] [SEMANTICS scenario,execution,timeout,step,lease,artifact,signal] +# @ingroup ScenarioExecution +# @BRIEF Materialize a valid step timeout as an explicit inconclusive outcome. +# @RELATION CALLS -> [ScenarioExecution.Lifecycle.TimeoutClosure] +# @PRE timeout_ms is positive and the addressed step belongs to the run. +# @POST The timed attempt becomes inconclusive; every declared queued/running or unmaterialized +# descendant is durably blocked from the pinned runner plan before the run terminalizes. +# @SIDE_EFFECT DB update of closure steps/run, active evidence projection, and worker leases. +# @INVARIANT A timeout never manufactures a passing outcome, leaves no active lease/evidence, and +# cannot emit more than one signal for the same terminal attempt context. A later walker +# sees every declared descendant as blocked and therefore cannot lazy-dispatch it. +# @RATIONALE Timeout is persisted as an execution result so later result assembly observes a durable state. +# @REJECTED Raising only at the worker boundary was rejected — the durable step/run state would remain ambiguous. +def apply_step_timeout(db: Session, run_id: str, logical_step_id: str, *, timeout_ms: int) -> ScenarioStepRun: + step = db.query(ScenarioStepRun).filter(ScenarioStepRun.run_id == run_id, ScenarioStepRun.logical_step_id == logical_step_id).first() + if step is None: + raise ValueError("step not found") + if timeout_ms <= 0: + raise ValueError("timeout_ms must be positive") + if step.status not in {"running", "queued", "waiting_human"}: + return step + if step.status == "waiting_human" and db.query(HumanCheckpoint).filter( + HumanCheckpoint.run_id == run_id, + HumanCheckpoint.status == "pending", + ).first() is not None: + raise ValueError("cannot timeout a human checkpoint") + run = db.query(ScenarioRun).filter(ScenarioRun.id == run_id).first() + if run is None: + raise ValueError("run not found") + descendant_ids = _descendants(logical_step_id, list((run.runner_plan or {}).get("dependencies") or [])) + affected_ids = {logical_step_id, *descendant_ids} + affected_steps = ( + db.query(ScenarioStepRun) + .filter( + ScenarioStepRun.run_id == run_id, + ScenarioStepRun.logical_step_id.in_(sorted(affected_ids)), + ) + .all() + ) + existing_by_step_id = {row.logical_step_id: row for row in affected_steps} + _materialize_timeout_descendants( + db, + run, + source_step_id=logical_step_id, + descendant_ids=descendant_ids, + existing_step_ids=set(existing_by_step_id), + ) + blockable_descendants = [ + row + for row in affected_steps + if row.logical_step_id != logical_step_id and row.status in {"queued", "running", "waiting_human"} + ] + for checkpoint in db.query(HumanCheckpoint).filter( + HumanCheckpoint.run_id == run_id, + HumanCheckpoint.logical_step_id.in_([row.logical_step_id for row in blockable_descendants]), + HumanCheckpoint.status == "pending", + ).with_for_update().all(): + checkpoint.status = "cancelled" + checkpoint.decided_at = datetime.now(UTC) + _retire_active_step_projection(db, run_id, [step, *blockable_descendants]) + _expire_step_leases(db, run_id, affected_ids) + step.status = "inconclusive" + step.error_code = "STEP_TIMEOUT" + step.step_outcome = {"status": "inconclusive", "error_code": "STEP_TIMEOUT"} + step.finished_at = datetime.now(UTC) + step.progress = 100 + for descendant in blockable_descendants: + descendant.status = "blocked" + descendant.error_code = "UPSTREAM_TIMEOUT" + descendant.step_outcome = { + "status": "blocked", + "error_code": "UPSTREAM_TIMEOUT", + "blocked_by": logical_step_id, + } + descendant.progress = 100 + descendant.finished_at = datetime.now(UTC) + run.status = "inconclusive" + run.phase = "completed" + run.finished_at = datetime.now(UTC) + db.flush() + from src.services.dashboard_testing.analytics.investigation import emit_terminal_run_signal + + emit_terminal_run_signal(db, run) + db.flush() + return step +# #endregion ScenarioExecution.Lifecycle.Timeout + # #endregion ScenarioExecution.Lifecycle diff --git a/backend/src/services/dashboard_testing/execution/live_adapter.py b/backend/src/services/dashboard_testing/execution/live_adapter.py new file mode 100644 index 000000000..3ab2ba081 --- /dev/null +++ b/backend/src/services/dashboard_testing/execution/live_adapter.py @@ -0,0 +1,92 @@ +# #region ScenarioExecution.LiveAdapter [C:4] [TYPE Module] [SEMANTICS scenario,execution,executor,adapter,status] +# @defgroup ScenarioExecution Typed boundary for live browser, Superset, and ScreenshotService adapters. +# @BRIEF Map only runtime-validated external adapter results into ScenarioStep outcomes. +# @INVARIANT Runtime adapter status is exactly passed, failed, or inconclusive; untrusted values fail closed. +# @REJECTED Trusting Literal annotations at the external callable boundary was rejected — Python does +# not enforce them at runtime, so an arbitrary status could corrupt aggregation. +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal, Protocol + +_LIVE_ADAPTER_STATUSES = frozenset({"passed", "failed", "inconclusive"}) + + +# #region ScenarioExecution.LiveAdapter.Result [C:2] [TYPE Class] [SEMANTICS scenario,execution,executor,adapter,result] +# @BRIEF Typed response emitted by a permitted live-I/O adapter. +@dataclass(frozen=True) +class LiveAdapterResult: + status: Literal["passed", "failed", "inconclusive"] + reason_code: str + details: dict[str, Any] = field(default_factory=dict) + output_refs: list[str] = field(default_factory=list) + artifact_refs: list[str] = field(default_factory=list) + artifact_digests: dict[str, str] = field(default_factory=dict) + + +class LiveExecutionAdapter(Protocol): + """[EXT] Existing browser, 037, or ScreenshotService transport selected by composition.""" + + def __call__( + self, + step: dict[str, Any], + completed: dict[str, dict[str, Any]], + ) -> LiveAdapterResult: ... + + +BrowserAdapterResult = SupersetAdapterResult = ScreenshotAdapterResult = LiveAdapterResult +BrowserExecutionAdapter = SupersetExecutionAdapter = ScreenshotExecutionAdapter = LiveExecutionAdapter +# #endregion ScenarioExecution.LiveAdapter.Result + + +# #region ScenarioExecution.LiveAdapter.Dispatch [C:3] [TYPE Function] [SEMANTICS scenario,execution,executor,adapter,status] +# @BRIEF Invoke a configured adapter and map its typed response without synthetic PASS. +# @INVARIANT A passed outcome can only originate from LiveAdapterResult(status="passed"). +def dispatch_live_adapter( + outcome_factory, + tool: str, + step: dict[str, Any], + completed: dict[str, dict[str, Any]], + adapter: LiveExecutionAdapter | None, + context: dict[str, Any], + *, + unavailable_code: str, + timeout_code: str, + error_code: str, + invalid_code: str, + required_pass_detail_keys: tuple[str, ...] = (), + require_pass_artifact_refs: bool = False, + missing_evidence_code: str | None = None, +) -> dict[str, Any]: + if adapter is None: + return outcome_factory(tool, "inconclusive", reason=unavailable_code, extra=context) + try: + result = adapter(step, completed) + except TimeoutError: + return outcome_factory(tool, "inconclusive", reason=timeout_code, extra=context) + except Exception: + return outcome_factory(tool, "inconclusive", reason=error_code, extra=context) + if not isinstance(result, LiveAdapterResult) or result.status not in _LIVE_ADAPTER_STATUSES: + return outcome_factory(tool, "inconclusive", reason=invalid_code, extra=context) + if result.status == "passed" and ( + (require_pass_artifact_refs and not result.artifact_refs) + or any(not result.details.get(key) for key in required_pass_detail_keys) + ): + return outcome_factory( + tool, "inconclusive", reason=missing_evidence_code or invalid_code, extra=context + ) + details = { + **result.details, + **({"artifact_digests": result.artifact_digests} if result.artifact_digests else {}), + **context, + } + return outcome_factory( + tool, + result.status, + reason=result.reason_code, + extra=details, + output_refs=result.output_refs, + artifact_refs=result.artifact_refs, + ) +# #endregion ScenarioExecution.LiveAdapter.Dispatch +# #endregion ScenarioExecution.LiveAdapter diff --git a/backend/src/services/dashboard_testing/execution/live_binding.py b/backend/src/services/dashboard_testing/execution/live_binding.py new file mode 100644 index 000000000..a19f0e8c3 --- /dev/null +++ b/backend/src/services/dashboard_testing/execution/live_binding.py @@ -0,0 +1,260 @@ +# #region ScenarioExecution.LiveBinding [C:5] [TYPE Module] [SEMANTICS scenario,execution,live-binding,superset,composition,authority] +# @defgroup ScenarioExecution Persisted identity and runtime resolver boundary for lawful live execution. +# @BRIEF Bind a ScenarioRun to immutable authorization fingerprints without persisting credentials or sessions. +# @RELATION CALLS -> [BaselineEngine.QueryExecutor.ExecuteQueryEnvelope] +# @RELATION DEPENDS_ON -> [Services.AgentRuns.Artifacts] +# @INVARIANT A live query runs only when the resolver returns the exact persisted binding and its pinned model/principal fingerprints. +# @INVARIANT Persisted binding snapshots contain identity/fingerprints only; no client, secret, raw browser context, or callable principal is serialised. +# @RATIONALE Environment identifiers are routing labels, not execution authority. The composition root must resolve a durable, immutable identity to authorized local clients. +# @REJECTED Constructing a Superset client, browser context, or principal from environment_id/metadata was rejected — it bypasses pinned RLS, release, and model checks. +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from hashlib import sha256 +from typing import Any, Protocol + +from src.schemas.dashboard_testing import DashboardQueryModel, ExecuteQueryRequest, NormalizedFilterContext, ValueKind +from src.services.dashboard_testing.query_executor import execute_dashboard_query_envelope + +from .artifacts import is_valid_sha256 +from .live_adapter import LiveAdapterResult + +_SNAPSHOT_FIELDS = frozenset({ + "binding_ref", "environment_id", "dashboard_release_id", "release_fingerprint", "dashboard_id", + "query_model_fingerprint", "execution_principal_fingerprint", "rls_security_fingerprint", + "browser_safe_checkpoint_ref", "browser_action_binding_ref", "evidence_owner_type", "evidence_ref_policy", +}) + + +# #region ScenarioExecution.LiveBinding.Identity [C:3] [TYPE Class] [SEMANTICS scenario,execution,live-binding,identity,persistence] +# @BRIEF Immutable, JSON-safe binding reference and authorization fingerprints stored on a ScenarioRun. +# @DATA_CONTRACT LiveExecutionBinding.snapshot() <-> ScenarioRun.live_execution_binding_snapshot. +# @INVARIANT The snapshot has the exact allowlisted identity fields and cannot serialize runtime capabilities. +@dataclass(frozen=True) +class LiveExecutionBinding: + binding_ref: str + environment_id: str + dashboard_release_id: str + release_fingerprint: str + dashboard_id: int + query_model_fingerprint: str + execution_principal_fingerprint: str + rls_security_fingerprint: str + browser_safe_checkpoint_ref: str | None + browser_action_binding_ref: str | None + evidence_owner_type: str + evidence_ref_policy: str + + @classmethod + def from_snapshot(cls, snapshot: object) -> LiveExecutionBinding: + if not isinstance(snapshot, dict) or set(snapshot) != _SNAPSHOT_FIELDS: + raise ValueError("LIVE_BINDING_SNAPSHOT_INVALID") + binding = cls(**snapshot) + if ( + not all(isinstance(value, str) and value for value in ( + binding.binding_ref, binding.environment_id, binding.dashboard_release_id, + binding.release_fingerprint, binding.query_model_fingerprint, + binding.execution_principal_fingerprint, binding.rls_security_fingerprint, + )) + or not isinstance(binding.dashboard_id, int) + or binding.dashboard_id <= 0 + or binding.evidence_owner_type != "scenario_run" + or binding.evidence_ref_policy != "draft_storage_raw_response" + or any(value is not None and not isinstance(value, str) for value in ( + binding.browser_safe_checkpoint_ref, binding.browser_action_binding_ref, + )) + ): + raise ValueError("LIVE_BINDING_SNAPSHOT_INVALID") + return binding + + def snapshot(self) -> dict[str, Any]: + return { + "binding_ref": self.binding_ref, + "environment_id": self.environment_id, + "dashboard_release_id": self.dashboard_release_id, + "release_fingerprint": self.release_fingerprint, + "dashboard_id": self.dashboard_id, + "query_model_fingerprint": self.query_model_fingerprint, + "execution_principal_fingerprint": self.execution_principal_fingerprint, + "rls_security_fingerprint": self.rls_security_fingerprint, + "browser_safe_checkpoint_ref": self.browser_safe_checkpoint_ref, + "browser_action_binding_ref": self.browser_action_binding_ref, + "evidence_owner_type": self.evidence_owner_type, + "evidence_ref_policy": self.evidence_ref_policy, + } + + def with_query_model_fingerprint(self, fingerprint: str) -> LiveExecutionBinding: + return LiveExecutionBinding(**{**self.snapshot(), "query_model_fingerprint": fingerprint}) +# #endregion ScenarioExecution.LiveBinding.Identity + + +# #region ScenarioExecution.LiveBinding.Runtime [C:4] [TYPE Class] [SEMANTICS scenario,execution,live-binding,resolver,runtime] +# @BRIEF Runtime-only clients and approved model returned by a composition root for one binding reference. +# @DATA_CONTRACT LiveExecutionBindingResolver(binding_ref) -> ResolvedLiveExecutionBinding | None. +# @INVARIANT Resolver-owned clients, storage, and event-loop access never enter the persisted snapshot. +class EvidenceStorage(Protocol): + def store(self, run_id: str, sha256: str, data: bytes) -> str: ... + + +@dataclass(frozen=True) +class ResolvedLiveExecutionBinding: + binding: LiveExecutionBinding + superset_client: Any + query_model: DashboardQueryModel + evidence_storage: EvidenceStorage + run_async: Callable[[Awaitable[Any]], Any] + + +class LiveExecutionBindingResolver(Protocol): + """Composition-owned mapping from persisted identity to authorized runtime dependencies.""" + + def resolve(self, binding_ref: str) -> ResolvedLiveExecutionBinding | None: ... +# #endregion ScenarioExecution.LiveBinding.Runtime + + +# #region ScenarioExecution.LiveBinding.Payload [C:3] [TYPE Function] [SEMANTICS scenario,execution,live-binding,payload,validation] +# @BRIEF Read and validate the run-persisted identity before resolver or provider access. +# @INVARIANT Tool-specific result codes distinguish missing configuration from a malformed or +# mismatched persisted identity; neither path reaches live I/O. +def binding_from_step( + step: dict[str, Any], *, tool_code: str = "SUPERSET" +) -> tuple[LiveExecutionBinding | None, str | None]: + """Return only an exact persisted binding; never infer authority from step metadata.""" + binding_ref = step.get("live_execution_binding_ref") + snapshot = step.get("live_execution_binding_snapshot") + if binding_ref is None and snapshot is None: + return None, f"{tool_code}_BINDING_MISSING" + try: + binding = LiveExecutionBinding.from_snapshot(snapshot) + except ValueError: + return None, f"{tool_code}_BINDING_INVALID" + target = step.get("target_snapshot") + if ( + binding_ref != binding.binding_ref + or not isinstance(target, dict) + or target.get("environment_id") != binding.environment_id + or target.get("dashboard_release_id") != binding.dashboard_release_id + or step.get("execution_principal_fingerprint") != binding.execution_principal_fingerprint + ): + return None, f"{tool_code}_BINDING_MISMATCH" + return binding, None +# #endregion ScenarioExecution.LiveBinding.Payload + + +# #region ScenarioExecution.LiveBinding.ResolverMatch [C:3] [TYPE Function] [SEMANTICS scenario,execution,live-binding,resolver,validation] +# @BRIEF Reject a resolver response unless its identity and authoritative model exactly match the stored binding. +def _runtime_matches(binding: LiveExecutionBinding, resolved: ResolvedLiveExecutionBinding) -> bool: + return ( + resolved.binding == binding + and resolved.query_model.environment_id == binding.environment_id + and resolved.query_model.dashboard_id == binding.dashboard_id + and resolved.query_model.query_model_fingerprint == binding.query_model_fingerprint + ) +# #endregion ScenarioExecution.LiveBinding.ResolverMatch + + +# #region ScenarioExecution.LiveBinding.Request [C:3] [TYPE Function] [SEMANTICS scenario,execution,live-binding,request,validation] +# @BRIEF Build one 037 request only from the persisted binding and immutable plan metadata. +def _request_from_step( + step: dict[str, Any], binding: LiveExecutionBinding +) -> ExecuteQueryRequest | None: + metadata = step.get("step_meta") if isinstance(step.get("step_meta"), dict) else {} + if ( + metadata.get("environment_id") != binding.environment_id + or metadata.get("dashboard_id") != binding.dashboard_id + or not isinstance(step.get("scenario_run_id"), str) + ): + return None + try: + return ExecuteQueryRequest( + environment_id=binding.environment_id, + dashboard_id=binding.dashboard_id, + chart_id=metadata.get("chart_id"), + dataset_id=metadata.get("dataset_id"), + result_key=str(metadata["result_key"]), + normalized_filters=NormalizedFilterContext.model_validate(metadata["normalized_filters"]), + query_model_fingerprint=binding.query_model_fingerprint, + ) + except (KeyError, TypeError, ValueError): + return None +# #endregion ScenarioExecution.LiveBinding.Request + + +# #region ScenarioExecution.LiveBinding.Evidence [C:4] [TYPE Function] [SEMANTICS scenario,execution,live-binding,evidence,integrity] +# @BRIEF Register exact raw query bytes under the bound opaque evidence policy. +# @POST A passed result carries the exact verified SHA-256 and opaque stored ref; invalid bytes/digest/ref are inconclusive. +# @SIDE_EFFECT Stores verified raw response bytes through the resolver-owned evidence storage boundary. +def _evidence_result( + step: dict[str, Any], resolved: ResolvedLiveExecutionBinding, envelope: Any +) -> LiveAdapterResult: + digest = envelope.source_response_hash + raw_bytes = envelope.raw_response_content + if not is_valid_sha256(digest) or sha256(raw_bytes).hexdigest() != digest: + return LiveAdapterResult(status="inconclusive", reason_code="SUPERSET_EVIDENCE_INVALID") + run_id = step["scenario_run_id"] + content_ref = resolved.evidence_storage.store(run_id, digest, raw_bytes) + expected_ref = f"draft:{run_id}:{digest}" + if content_ref != expected_ref: + return LiveAdapterResult(status="inconclusive", reason_code="SUPERSET_EVIDENCE_REF_INVALID") + return LiveAdapterResult( + status="passed", + reason_code="SUPERSET_QUERY_EXECUTED", + details={ + "actual": envelope.normalized_value.model_dump(mode="json"), + "source_response_hash": digest, + "sha256": digest, + }, + output_refs=[content_ref], + artifact_refs=[content_ref], + artifact_digests={content_ref: digest}, + ) +# #endregion ScenarioExecution.LiveBinding.Evidence + + +# #region ScenarioExecution.LiveBinding.Execute [C:4] [TYPE Function] [SEMANTICS scenario,execution,live-binding,superset,execute] +# @BRIEF Resolve exact binding and execute its immutable 037 query request through local dependencies. +# @PRE The composition root provides an authorized resolver; the step carries a valid persisted identity snapshot. +# @POST Missing or mismatched binding is typed inconclusive before external I/O; 037 failure/timeout remains non-pass. +# @SIDE_EFFECT Calls the existing 037 envelope and, only after byte/digest validation, stores opaque evidence. +# @INVARIANT No client is constructed from run metadata, environment labels, or principal fingerprints. +def _execute_bound_superset( + resolver: LiveExecutionBindingResolver, + step: dict[str, Any], + _completed: dict[str, dict[str, Any]], +) -> LiveAdapterResult: + binding, error = binding_from_step(step) + if error is not None or binding is None: + return LiveAdapterResult(status="inconclusive", reason_code=error or "SUPERSET_BINDING_INVALID") + resolved = resolver.resolve(binding.binding_ref) + if resolved is None: + return LiveAdapterResult(status="inconclusive", reason_code="SUPERSET_BINDING_UNAVAILABLE") + if not _runtime_matches(binding, resolved): + return LiveAdapterResult(status="inconclusive", reason_code="SUPERSET_BINDING_MISMATCH") + request = _request_from_step(step, binding) + if request is None: + return LiveAdapterResult(status="inconclusive", reason_code="SUPERSET_BINDING_REQUEST_REJECTED") + try: + envelope = resolved.run_async(execute_dashboard_query_envelope( + resolved.superset_client, request, resolved.query_model, + )) + except TimeoutError: + raise + except Exception: + return LiveAdapterResult(status="inconclusive", reason_code="SUPERSET_QUERY_EXECUTION_ERROR") + if envelope.normalized_value.kind == ValueKind.UNKNOWN: + return LiveAdapterResult(status="failed", reason_code="SUPERSET_QUERY_FAILED") + return _evidence_result(step, resolved, envelope) +# #endregion ScenarioExecution.LiveBinding.Execute + + +# #region ScenarioExecution.LiveBinding.SupersetAdapter [C:5] [TYPE Function] [SEMANTICS scenario,execution,live-binding,superset,adapter,evidence] +# @BRIEF Build a typed Superset adapter that invokes 037 only after exact resolver validation. +# @POST Returns passed only after the existing envelope yields verified raw bytes, digest, and opaque evidence ref. +# @INVARIANT Missing/mismatched binding returns typed inconclusive and never calls the Superset client. +# @REJECTED Trusting a resolver's reference match without comparing the full immutable snapshot was rejected — a stale reference could otherwise cross environments or RLS scopes. +def superset_adapter_from(resolver: LiveExecutionBindingResolver): + return lambda step, completed: _execute_bound_superset(resolver, step, completed) +# #endregion ScenarioExecution.LiveBinding.SupersetAdapter + +# #endregion ScenarioExecution.LiveBinding diff --git a/backend/src/services/dashboard_testing/execution/live_composition.py b/backend/src/services/dashboard_testing/execution/live_composition.py new file mode 100644 index 000000000..9bd94172d --- /dev/null +++ b/backend/src/services/dashboard_testing/execution/live_composition.py @@ -0,0 +1,252 @@ +# #region ScenarioExecution.LiveCompositionRoot [C:5] [TYPE Module] [SEMANTICS scenario,execution,composition,live,browser,superset,screenshot] +# @BRIEF Own the application-only registration of authorized live providers for persisted 044 bindings. +# @RELATION CALLS -> [ScenarioExecution.LiveBinding.SupersetAdapter] +# @RELATION DEPENDS_ON -> [BaselineEngine.QueryExecutor.ExecuteQueryEnvelope] +# @RELATION DEPENDS_ON -> [Plugin.Service.ScreenshotService] +# @INVARIANT No runtime client, secret, cookie, callable principal, or browser context is persisted; +# providers are registered only in the application process against an exact binding snapshot. +# @INVARIANT Unavailable and mismatched providers are typed non-pass before I/O. A provider may return +# PASS only with the evidence validation enforced by its typed executor. +# @RATIONALE Startup composition is the authority boundary: environment IDs are labels and cannot safely +# select a client, RLS scope, browser session, or capture store. +# @REJECTED Constructing clients from ScenarioRun metadata or using ScreenshotService output paths as +# durable evidence was rejected — both bypass pinned identity and provenance checks. +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from src.core.superset_client import SupersetClient +from src.schemas.dashboard_testing import DashboardQueryModel +from src.services.agent_runs.artifacts import get_draft_storage + +from .live_adapter import LiveAdapterResult +from .live_binding import ( + LiveExecutionBinding, + ResolvedLiveExecutionBinding, + binding_from_step, +) + + +@dataclass(frozen=True) +class LiveProviderContext: + """Runtime-only data passed to a registered browser or capture provider.""" + + binding: LiveExecutionBinding + step: dict[str, Any] + completed: dict[str, dict[str, Any]] + cancellation_capability: Callable[[], None] | None = None + + +LiveProvider = Callable[[LiveProviderContext], LiveAdapterResult] +CancellationCapability = Callable[[], None] +_RegisteredProvider = tuple[LiveExecutionBinding, LiveProvider, CancellationCapability | None] + + +# #region ScenarioExecution.LiveCompositionRoot.Runtime [C:4] [TYPE Class] [SEMANTICS scenario,execution,composition,provider,resolver] +# @BRIEF In-memory composition registry initialized by application startup, never by a request. +# @DATA_CONTRACT LiveExecutionBinding + registered provider -> typed adapter result. +# @INVARIANT Every registration is keyed by binding_ref and retains the full immutable binding for +# equality comparison before the provider/client receives a step. +class LiveExecutionCompositionRoot: + def __init__(self) -> None: + self._superset: dict[str, ResolvedLiveExecutionBinding] = {} + self._superset_unavailable: set[str] = set() + self._browser: dict[str, _RegisteredProvider] = {} + self._browser_unavailable: set[str] = set() + self._screenshot: dict[str, _RegisteredProvider] = {} + self._screenshot_unavailable: set[str] = set() + + @property + def has_superset_provider(self) -> bool: + return bool(self._superset or self._superset_unavailable) + + @property + def has_browser_provider(self) -> bool: + return bool(self._browser or self._browser_unavailable) + + @property + def has_screenshot_provider(self) -> bool: + return bool(self._screenshot or self._screenshot_unavailable) + + def register_superset(self, resolved: ResolvedLiveExecutionBinding) -> None: + """Register an already-authorized 037 client/model/evidence tuple at startup.""" + binding = resolved.binding + if not binding.binding_ref or not _resolved_binding_matches(binding, resolved): + raise ValueError("LIVE_COMPOSITION_BINDING_INVALID") + self._superset[binding.binding_ref] = resolved + self._superset_unavailable.discard(binding.binding_ref) + + def mark_superset_unavailable(self, binding_ref: str) -> None: + """Retain an explicitly configured-but-unavailable binding as a typed non-I/O outcome.""" + if binding_ref: + self._superset_unavailable.add(binding_ref) + + def register_browser( + self, + binding: LiveExecutionBinding, + provider: LiveProvider, + *, + cancellation_capability: CancellationCapability | None = None, + ) -> None: + """Register a browser-safe action provider; it must own safe reconstruction itself.""" + if not binding.browser_action_binding_ref or not callable(provider): + raise ValueError("LIVE_BROWSER_PROVIDER_INVALID") + self._browser[binding.binding_ref] = (binding, provider, cancellation_capability) + self._browser_unavailable.discard(binding.binding_ref) + + def mark_browser_unavailable(self, binding_ref: str) -> None: + if binding_ref: + self._browser_unavailable.add(binding_ref) + + def register_screenshot( + self, + binding: LiveExecutionBinding, + provider: LiveProvider, + *, + cancellation_capability: CancellationCapability | None = None, + ) -> None: + """Register a ScreenshotService-to-durable-evidence provider, never bare capture bytes.""" + if not callable(provider): + raise ValueError("LIVE_SCREENSHOT_PROVIDER_INVALID") + self._screenshot[binding.binding_ref] = (binding, provider, cancellation_capability) + self._screenshot_unavailable.discard(binding.binding_ref) + + def mark_screenshot_unavailable(self, binding_ref: str) -> None: + if binding_ref: + self._screenshot_unavailable.add(binding_ref) + + def resolve(self, binding_ref: str) -> ResolvedLiveExecutionBinding | None: + """Resolve only a registered 037 provider; callers still compare the full snapshot.""" + return self._superset.get(binding_ref) + + def browser_adapter(self): + return self._adapter_for("BROWSER", self._browser, require_browser_checkpoint=True) + + def screenshot_adapter(self): + return self._adapter_for("SCREENSHOT", self._screenshot, require_browser_checkpoint=False) + + def _adapter_for( + self, + tool_code: str, + providers: dict[str, _RegisteredProvider], + *, + require_browser_checkpoint: bool, + ): + def invoke(step: dict[str, Any], completed: dict[str, dict[str, Any]]) -> LiveAdapterResult: + binding, error = binding_from_step(step, tool_code=tool_code) + if binding is None: + return LiveAdapterResult(status="inconclusive", reason_code=error or f"{tool_code}_BINDING_INVALID") + registered = providers.get(binding.binding_ref) + if registered is None: + return LiveAdapterResult(status="inconclusive", reason_code=f"{tool_code}_BINDING_UNAVAILABLE") + registered_binding, provider, cancellation_capability = registered + if registered_binding != binding: + return LiveAdapterResult(status="inconclusive", reason_code=f"{tool_code}_BINDING_MISMATCH") + step_meta = step.get("step_meta") if isinstance(step.get("step_meta"), dict) else {} + if require_browser_checkpoint and step_meta.get("recovery_mode") and ( + not binding.browser_safe_checkpoint_ref + or step_meta.get("browser_safe_checkpoint_ref") != binding.browser_safe_checkpoint_ref + ): + return LiveAdapterResult(status="inconclusive", reason_code="BROWSER_SAFE_CHECKPOINT_REQUIRED") + if require_browser_checkpoint and step_meta.get("mutation_contract") and _is_prod(step): + return LiveAdapterResult(status="failed", reason_code="BROWSER_MUTATION_PROD_REJECTED") + if require_browser_checkpoint and step_meta.get("mutation_contract") and not step_meta.get( + "action_registry_fingerprint" + ): + return LiveAdapterResult(status="inconclusive", reason_code="BROWSER_MUTATION_CONTRACT_REQUIRED") + return provider(LiveProviderContext( + binding=binding, + step=step, + completed=completed, + cancellation_capability=cancellation_capability, + )) + + return invoke +# #endregion ScenarioExecution.LiveCompositionRoot.Runtime + + +# #region ScenarioExecution.LiveCompositionRoot.Bootstrap [C:5] [TYPE Function] [SEMANTICS scenario,execution,composition,startup,superset,config] +# @BRIEF Populate a root from trusted server configuration, never from a run or HTTP request. +# @RELATION DEPENDS_ON -> [Core.ConfigModels.ScenarioLiveExecutionBinding] +# @PRE config_manager supplies deployment-owned environments and typed live binding records. +# @POST Every enabled valid record registers the existing SupersetClient/037 model/evidence tuple; +# invalid or unavailable records remain typed unavailable without constructing authority from IDs. +# @SIDE_EFFECT Creates process-local SupersetClient instances using encrypted deployment environment credentials. +# @INVARIANT The bootstrap parses every binding and query model before client construction. A missing +# environment, malformed snapshot, or unavailable storage is recorded as unavailable and +# cannot execute I/O. Runtime credentials never enter ScenarioRun. +# @INVARIANT This bootstrap registers only the exact 037 client/model/evidence tuple. Browser-safe +# and Screenshot durable-evidence providers remain explicitly unavailable until a +# separate server-owned registration supplies them. +# @REJECTED Inspecting a fresh mutable query model at dispatch was rejected — the configured model +# must exactly match the persisted binding fingerprint, preserving the run's pinned authority. +def bootstrap_live_execution_composition( + root: LiveExecutionCompositionRoot, + *, + config_manager: Any, + run_async: Callable[[Any], Any], +) -> int: + configured = getattr(config_manager.get_config().settings, "scenario_live_execution_bindings", []) + registered = 0 + for record in configured: + binding_snapshot = getattr(record, "binding_snapshot", None) + if isinstance(record, dict): + binding_snapshot = record.get("binding_snapshot") + binding_ref = binding_snapshot.get("binding_ref") if isinstance(binding_snapshot, dict) else None + enabled = getattr(record, "enabled", False) + if isinstance(record, dict): + enabled = bool(record.get("enabled", False)) + if not enabled: + continue + try: + binding = LiveExecutionBinding.from_snapshot(binding_snapshot) + # Existing 038 ScreenshotService has no principal/RLS-bound durable-evidence adapter, + # and no browser-safe provider is installed by this deployment bootstrap. Preserve + # those configured capabilities as explicit typed-unavailable bindings until a + # server-owned registration calls register_browser/register_screenshot. + root.mark_browser_unavailable(binding.binding_ref) + root.mark_screenshot_unavailable(binding.binding_ref) + query_snapshot = getattr(record, "query_model_snapshot", None) + if isinstance(record, dict): + query_snapshot = record.get("query_model_snapshot") + query_model = DashboardQueryModel.model_validate(query_snapshot) + environment = config_manager.get_environment(binding.environment_id) + if environment is None: + raise ValueError("LIVE_COMPOSITION_ENVIRONMENT_UNAVAILABLE") + root.register_superset(ResolvedLiveExecutionBinding( + binding=binding, + superset_client=SupersetClient(environment), + query_model=query_model, + evidence_storage=get_draft_storage(), + run_async=run_async, + )) + registered += 1 + except Exception: + if isinstance(binding_ref, str) and binding_ref: + root.mark_superset_unavailable(binding_ref) + return registered +# #endregion ScenarioExecution.LiveCompositionRoot.Bootstrap + + +# #region ScenarioExecution.LiveCompositionRoot.Match [C:2] [TYPE Function] [SEMANTICS scenario,execution,composition,identity] +# @BRIEF Confirm that a registered 037 tuple preserves the immutable binding identity. +def _resolved_binding_matches( + binding: LiveExecutionBinding, resolved: ResolvedLiveExecutionBinding +) -> bool: + return ( + resolved.binding == binding + and resolved.query_model.environment_id == binding.environment_id + and resolved.query_model.dashboard_id == binding.dashboard_id + and resolved.query_model.query_model_fingerprint == binding.query_model_fingerprint + ) + + +def _is_prod(step: dict[str, Any]) -> bool: + target = step.get("target_snapshot") if isinstance(step.get("target_snapshot"), dict) else {} + metadata = step.get("step_meta") if isinstance(step.get("step_meta"), dict) else {} + return target.get("environment_class") == "PROD" or metadata.get("environment_class") == "PROD" +# #endregion ScenarioExecution.LiveCompositionRoot.Match + +# #endregion ScenarioExecution.LiveCompositionRoot diff --git a/backend/src/services/dashboard_testing/execution/result.py b/backend/src/services/dashboard_testing/execution/result.py index 060dc60be..8ff1875a1 100644 --- a/backend/src/services/dashboard_testing/execution/result.py +++ b/backend/src/services/dashboard_testing/execution/result.py @@ -5,6 +5,10 @@ # @INVARIANT The execution snapshot is immutable: it deep-copies run/step state at build time, # so later mutations of the run row never change an already-published snapshot. # @INVARIANT provenance carries the pinned revision identity, never a runtime-derived value. +# @INVARIANT Lifecycle-held states (pending approval, waiting_human, cancellation) report their +# persisted run status, never an aggregate synthetic PASS from skipped/cancelled/waiting rows. +# @INVARIANT A persisted terminal blocked/failed/inconclusive status remains non-pass even when a +# fail-closed preflight produced no materialized step row. from __future__ import annotations import copy @@ -113,6 +117,14 @@ def build_execution_snapshot(run: ScenarioRun, steps: list[ScenarioStepRun]) -> # step_counts from aggregate_result. def build_result(run: ScenarioRun, steps: list[ScenarioStepRun]) -> dict[str, Any]: aggregate = aggregate_result([{"status": step.status} for step in steps]) + status = ( + run.status + if run.status in { + "pending_approval", "waiting_human", "cancel_requested", "cancelled", + "passed", "failed", "blocked", "inconclusive", + } + else aggregate["status"] + ) failures = [ { "logical_step_id": step.logical_step_id, @@ -125,7 +137,7 @@ def build_result(run: ScenarioRun, steps: list[ScenarioStepRun]) -> dict[str, An ] return { "run_id": run.id, - "status": aggregate["status"], + "status": status, "step_counts": { "passed": aggregate["passed"], "failed": aggregate["failed"], diff --git a/backend/src/services/dashboard_testing/execution/runner.py b/backend/src/services/dashboard_testing/execution/runner.py index c69afa1e5..8beddecdf 100644 --- a/backend/src/services/dashboard_testing/execution/runner.py +++ b/backend/src/services/dashboard_testing/execution/runner.py @@ -3,44 +3,134 @@ # @BRIEF Create queued/pending-approval ScenarioRun records with deterministic idempotency. # @RELATION CALLS -> [ScenarioExecution.RunnerPlan.Derive] # @RELATION CALLS -> [ScenarioExecution.Approval.CreateGate] +# @RELATION CALLS -> [ScenarioExecution.LiveBinding.SupersetAdapter] +# @RELATION CALLS -> [ScenarioAnalytics.Investigation.TerminalSignal] # @RELATION DEPENDS_ON -> [Models.ScenarioExecution.Run] # @INVARIANT Same idempotency key and request hash returns the same run; changed request rejects. # @INVARIANT PROD starts are pinned to a durable ActionApprovalGate; pending_approval -> queued # only through gate approval (036), never by silent dispatch. # @INVARIANT scenario_content_hash mirrors the pinned revision's content hash; request_hash # carries the idempotency fingerprint (provenance vs replay identity). +# @INVARIANT A revision with a human step is manual_run_only: only the trusted manual +# interactive entry point may create its run. Automation is refused before a +# ScenarioRun, PROD gate, notification, or queue side effect exists. from __future__ import annotations +import copy +from datetime import UTC, datetime import hashlib import json from typing import Any import uuid +from sqlalchemy import delete, update from sqlalchemy.orm import Session from src.core.logger import logger from src.models.scenario_registry import ScenarioRegistryEntry -from src.models.scenario_run import ScenarioRun +from src.models.scenario_run import ScenarioRun, ScenarioStepRun +from src.models.scenario_worker import ScenarioStepLease +from src.services.dashboard_testing.analytics.investigation import emit_terminal_run_signal +from src.services.dashboard_testing.automation.notify import persist_notification from .approval import create_prod_gate -from .runner_plan import derive_runner_plan +from .artifacts import invalidate_step_evidence, register_step_evidence +from .dispatch import dispatch_step +from .environment_policy import resolve_environment_execution_policy +from .executor_registry import ScenarioExecutorRegistry +from .executors import _register_default_executors +from .lifecycle import suspend_for_human +from .live_binding import LiveExecutionBinding, LiveExecutionBindingResolver, superset_adapter_from +from .result import build_result +from .runner_plan import derive_runner_plan, validate_pinned_runner_plan +from .worker import claim_step + +# #region ScenarioExecution.Runner.TriggerSource [C:3] [TYPE Block] [SEMANTICS scenario,execution,start,trigger,automation,human] +# @BRIEF Enumerate server-owned run origins accepted by the execution boundary. +# @INVARIANT trigger_source is selected by a trusted entry point, never by an API payload; +# unknown origins fail closed before idempotency replay or row creation. +# @RATIONALE A human graph can only be observed by an authenticated analyst through the +# manual interactive route. 046 sources must carry their actual origin into the +# start boundary rather than mutating a row after it has been created. +# @REJECTED Inferring manual authority from actor text or a caller-supplied boolean was rejected: +# either could turn an automation request into an automatic HumanCheckpoint bypass. +TRIGGER_SOURCE_MANUAL = "manual" +TRIGGER_SOURCE_SCHEDULED = "scheduled" +TRIGGER_SOURCE_DEPLOY = "deploy_to_preprod" +TRIGGER_SOURCE_RELEASE = "release_created" +TRIGGER_SOURCE_ETL = "etl_completed" +TRIGGER_SOURCE_API = "api" +_AUTOMATION_TRIGGER_SOURCES = frozenset({ + TRIGGER_SOURCE_SCHEDULED, + TRIGGER_SOURCE_DEPLOY, + TRIGGER_SOURCE_RELEASE, + TRIGGER_SOURCE_ETL, + TRIGGER_SOURCE_API, +}) +_TRUSTED_TRIGGER_SOURCES = _AUTOMATION_TRIGGER_SOURCES | {TRIGGER_SOURCE_MANUAL} -# #region ScenarioExecution.Runner.ActionGate [C:3] [TYPE Function] [SEMANTICS scenario,execution,approval,gate,prod] -def require_action_gate(*, is_prod: bool, approval_granted: bool) -> None: - if is_prod and not approval_granted: - raise PermissionError("PROD scenario execution requires ActionApprovalGate") -# #endregion ScenarioExecution.Runner.ActionGate +# #region ScenarioExecution.Runner.TriggerSource.RequireTrusted [C:2] [TYPE Function] [SEMANTICS scenario,execution,trigger,source,authority] +# @BRIEF Accept only a server-owned run origin before idempotency lookup or row creation. +# @INVARIANT Unknown origins fail closed and cannot be converted into manual authority. +def _require_trusted_trigger_source(trigger_source: str) -> str: + if trigger_source not in _TRUSTED_TRIGGER_SOURCES: + raise ValueError("UNTRUSTED_TRIGGER_SOURCE") + return trigger_source +# #endregion ScenarioExecution.Runner.TriggerSource.RequireTrusted -def _request_hash(scenario_id: str, revision_id: str, params: dict[str, Any], environment_id: str) -> str: - payload = json.dumps({"scenario_id": scenario_id, "revision_id": revision_id, "params": params, "environment_id": environment_id}, sort_keys=True, separators=(",", ":")) - return hashlib.sha256(payload.encode()).hexdigest() +# #region ScenarioExecution.Runner.TriggerSource.RejectAutomatedHuman [C:3] [TYPE Function] [SEMANTICS scenario,execution,trigger,human,manual-only] +# @BRIEF Reject an immutable human-containing plan from every trusted automation origin. +# @POST Rejection occurs before a ScenarioRun, approval gate, notification, queue signal, or dispatch exists. +# @INVARIANT HumanCheckpoint observation is distinct from ActionApprovalGate approval and cannot be automated. +def _reject_automated_human_plan(plan: dict[str, Any], trigger_source: str) -> None: + if plan.get("manual_run_only", bool(plan.get("human_checkpoints"))) and trigger_source != TRIGGER_SOURCE_MANUAL: + raise ValueError("AUTOMATION_INELIGIBLE_HUMAN_STEP") +# #endregion ScenarioExecution.Runner.TriggerSource.RejectAutomatedHuman +# #endregion ScenarioExecution.Runner.TriggerSource + +def _request_hash( + scenario_id: str, + revision_id: str, + params: dict[str, Any], + environment_id: str, + environment_class: str, + live_binding_snapshot: dict[str, Any] | None = None, +) -> str: + payload = { + "scenario_id": scenario_id, + "revision_id": revision_id, + "params": params, + "environment_id": environment_id, + "environment_class": environment_class, + } + if live_binding_snapshot is not None: + payload["live_binding_snapshot"] = live_binding_snapshot + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() # #region ScenarioExecution.Runner.Start [C:5] [TYPE Function] [SEMANTICS scenario,execution,start,idempotency,prod] # @ingroup ScenarioExecution # @BRIEF Create one durable run, derive its immutable plan, and gate PROD starts. +# @DATA_CONTRACT LiveExecutionBinding -> ScenarioRun.live_execution_binding_ref + live_execution_binding_snapshot. +# @RELATION CALLS -> [ScenarioExecution.EnvironmentPolicy.Resolve] +# @PRE config_manager, when supplied by an entry point, is an application-owned trusted provider; +# start_run always resolves the target itself and never accepts a caller-built policy object. +# @POST Creates or replays only a durable queued/pending-approval row; adapter dispatch is reserved +# for ScenarioExecution.Runner.QueuedDispatch after its persisted status CAS. +# @INVARIANT An optional binding snapshot contributes to request idempotency and must exactly match the start environment, release, and actor fingerprint. +# @INVARIANT An idempotency replay returns its existing row without a step, lease, notification, +# queue signal, or adapter call. A manual human plan reaches HumanCheckpoint only after +# the separate queued dispatcher wins its claim. +# @INVARIANT Human-containing plans reject all 046 automation sources before idempotency lookup, +# ScenarioRun creation, PROD gate creation, dispatch, notification, or queue projection. +# @INVARIANT Environment class is resolved only from the server-owned policy before idempotency and +# gate creation. is_prod/approval_granted compatibility arguments are never authority. +# @REJECTED Request-body is_prod was rejected as an execution authority because it could bypass or +# fabricate PROD gating; only the configured Environment policy may select a gate. def start_run( db: Session, scenario_id: str, @@ -52,13 +142,57 @@ def start_run( idempotency_key: str, is_prod: bool = False, approval_granted: bool = False, + config_manager: Any | None = None, + auto_advance: bool = False, + dashboard_release_id: str | None = None, + baseline_set: str | None = None, + execution_toggles: dict[str, bool] | None = None, + live_execution_binding: LiveExecutionBinding | dict[str, Any] | None = None, + trigger_source: str = TRIGGER_SOURCE_MANUAL, ) -> ScenarioRun: - require_action_gate(is_prod=is_prod, approval_granted=approval_granted) - request_hash = _request_hash(scenario_id, revision_id, params, environment_id) + del auto_advance, is_prod, approval_granted + trigger_source = _require_trusted_trigger_source(trigger_source) + if config_manager is None: + from src.dependencies import get_config_manager + + config_manager = get_config_manager() + environment_policy = resolve_environment_execution_policy( + environment_id, config_manager + ) + entry = db.query(ScenarioRegistryEntry).filter(ScenarioRegistryEntry.scenario_id == scenario_id).first() + if entry is None: + raise ValueError("scenario not found") + plan = derive_runner_plan(db, scenario_id, revision_id) + _reject_automated_human_plan(plan, trigger_source) + if environment_policy.is_prod and any( + bool((step.get("action_descriptor") or {}).get("mutating")) + and step.get("tool") == "browser" + for step in plan.get("steps") or [] + if isinstance(step, dict) + ): + raise ValueError("PROD_BROWSER_MUTATION_FORBIDDEN") + binding = ( + None if live_execution_binding is None + else live_execution_binding + if isinstance(live_execution_binding, LiveExecutionBinding) + else LiveExecutionBinding.from_snapshot(live_execution_binding) + ) + principal_fingerprint = hashlib.sha256(actor.encode()).hexdigest() + if binding is not None and ( + binding.environment_id != environment_id + or binding.execution_principal_fingerprint != principal_fingerprint + or (dashboard_release_id is not None and binding.dashboard_release_id != dashboard_release_id) + ): + raise ValueError("LIVE_BINDING_START_MISMATCH") + binding_snapshot = binding.snapshot() if binding is not None else None + request_hash = _request_hash( + scenario_id, revision_id, params, environment_id, environment_policy.environment_class, + binding_snapshot, + ) logger.reason( "ScenarioExecution.Runner.start_run", "Start scenario run request", - payload={"scenario_id": scenario_id, "revision_id": revision_id, "is_prod": is_prod, "idempotency_key": idempotency_key}, + payload={"scenario_id": scenario_id, "revision_id": revision_id, "environment_class": environment_policy.environment_class, "idempotency_key": idempotency_key}, ) existing = db.query(ScenarioRun).filter(ScenarioRun.idempotency_key == idempotency_key).first() if existing is not None: @@ -76,22 +210,27 @@ def start_run( payload={"run_id": existing.id, "status": existing.status}, ) return existing - entry = db.query(ScenarioRegistryEntry).filter(ScenarioRegistryEntry.scenario_id == scenario_id).first() - if entry is None: - raise ValueError("scenario not found") - plan = derive_runner_plan(db, scenario_id, revision_id) run = ScenarioRun( id=str(uuid.uuid4()), scenario_id=scenario_id, scenario_revision_id=revision_id, scenario_content_hash=plan["scenario_content_hash"], request_hash=request_hash, environment_id=environment_id, - status="pending_approval" if is_prod else "queued", phase="preflight", - parameter_bindings=params, target_snapshot={"environment_id": environment_id}, - trigger_source="manual", idempotency_key=idempotency_key, runner_plan=plan, - execution_principal_fingerprint=hashlib.sha256(actor.encode()).hexdigest(), + status="pending_approval" if environment_policy.is_prod else "queued", phase="preflight", + parameter_bindings=params, + target_snapshot={ + "environment_id": environment_id, + "environment_class": environment_policy.environment_class, + "dashboard_release_id": binding.dashboard_release_id if binding is not None else dashboard_release_id, + "baseline_set": baseline_set, + "execution_toggles": execution_toggles or {}, + }, + trigger_source=trigger_source, idempotency_key=idempotency_key, runner_plan=plan, + execution_principal_fingerprint=principal_fingerprint, + live_execution_binding_ref=binding.binding_ref if binding is not None else None, + live_execution_binding_snapshot=binding_snapshot, ) db.add(run) db.flush() - if is_prod: + if environment_policy.is_prod: create_prod_gate(db, run_id=run.id, request_hash=request_hash) logger.reflect( "ScenarioExecution.Runner.start_run", @@ -104,7 +243,773 @@ def start_run( "Run created and queued", payload={"run_id": run.id, "status": run.status, "plan_hash": plan["plan_hash"]}, ) + # Start is persistence-only even if a legacy caller supplies auto_advance=True. + # The scheduler/worker's durable queued->running CAS is the sole dispatch authority. + db.flush() return run # #endregion ScenarioExecution.Runner.Start + +# #region ScenarioExecution.Runner.DefaultRegistry [C:2] [TYPE Function] [SEMANTICS scenario,execution,runner,registry,executor] +# @ingroup ScenarioExecution +# @BRIEF Build the bounded default executor registry used by runner continuations. +# @INVARIANT Default composition never resolves a live client from run metadata alone; unavailable +# browser/Superset/Screenshot dependencies remain typed inconclusive. A supplied resolver +# must still match the immutable ScenarioRun binding before it can invoke 037. +# @RELATION CALLS -> [ScenarioExecution.LiveBinding.SupersetAdapter] +# @RELATION CALLS -> [ScenarioExecution.LiveCompositionRoot] +# @RATIONALE ScenarioRun persists a binding identity snapshot, never a callable principal, client, +# raw browser session, or secret. The composition root owns runtime capability resolution. +# @REJECTED Constructing a Superset client or Playwright/ScreenshotService context from environment +# fields was rejected — it could execute with an unpinned identity, RLS scope, or mutable query. +def _build_default_registry( + live_binding_resolver: LiveExecutionBindingResolver | None = None, +) -> ScenarioExecutorRegistry: + composition_root = None + if live_binding_resolver is None: + from src.dependencies import get_live_execution_composition_root + + composition_root = get_live_execution_composition_root() + live_binding_resolver = ( + composition_root if composition_root.has_superset_provider else None + ) + registry = ScenarioExecutorRegistry() + _register_default_executors( + registry, + superset_adapter=( + superset_adapter_from(live_binding_resolver) + if live_binding_resolver is not None + else None + ), + browser_adapter=( + composition_root.browser_adapter() + if composition_root is not None and composition_root.has_browser_provider + else None + ), + screenshot_adapter=( + composition_root.screenshot_adapter() + if composition_root is not None and composition_root.has_screenshot_provider + else None + ), + ) + return registry +# #endregion ScenarioExecution.Runner.DefaultRegistry + + +# #region ScenarioExecution.Runner.RejectMalformedPlan [C:4] [TYPE Function] [SEMANTICS scenario,execution,plan,preflight,lease] +# @BRIEF Terminalize a queued/continuing legacy plan that lacks a valid immutable action descriptor. +# @INVARIANT Invalid plan preflight revokes any active lease and blocks active step projection before +# it can reach an executor; completed historical evidence remains untouched. +def _reject_malformed_plan(db: Session, run: ScenarioRun, error_code: str) -> dict[str, Any]: + db.execute(delete(ScenarioStepLease).where(ScenarioStepLease.run_id == run.id)) + for step in db.query(ScenarioStepRun).filter(ScenarioStepRun.run_id == run.id).all(): + if step.status not in {"passed", "failed", "blocked", "inconclusive", "skipped"}: + step.status = "blocked" + step.error_code = error_code + step.step_outcome = {"status": "blocked", "error_code": error_code} + step.progress = 100 + step.finished_at = datetime.now(UTC) + run.status = "blocked" + run.phase = "completed" + run.error_code = error_code + run.finished_at = datetime.now(UTC) + db.flush() + _record_terminal_side_effects(db, run) + db.flush() + return build_result( + run, + db.query(ScenarioStepRun).filter(ScenarioStepRun.run_id == run.id).all(), + ) +# #endregion ScenarioExecution.Runner.RejectMalformedPlan + + +# #region ScenarioExecution.Runner.CloseDispatchError [C:4] [TYPE Function] [SEMANTICS scenario,execution,dispatch,error,lease,terminal] +# @ingroup ScenarioExecution +# @BRIEF Close every active projection after a queued dispatcher exception. +# @POST The run is inconclusive with no running/queued step, unexpired lease, or active evidence refs. +# @INVARIANT Historical evidence remains durable while the current result projection is retired. +def _close_queued_dispatch_error(db: Session, run: ScenarioRun) -> dict[str, Any]: + steps = db.query(ScenarioStepRun).filter(ScenarioStepRun.run_id == run.id).all() + now = datetime.now(UTC) + for step in steps: + if step.status not in {"passed", "failed", "blocked", "inconclusive", "skipped", "cancelled"}: + step.status = "inconclusive" + step.error_code = "QUEUED_DISPATCH_ERROR" + step.step_outcome = { + "logical_step_id": step.logical_step_id, + "status": "inconclusive", + "error_code": "QUEUED_DISPATCH_ERROR", + } + step.progress = 100 + step.finished_at = now + step.artifact_refs = [] + from .lifecycle import _expire_step_leases + + invalidate_step_evidence( + db, + run_id=run.id, + logical_step_ids={step.logical_step_id for step in steps}, + ) + _expire_step_leases(db, run.id) + run.status = "inconclusive" + run.phase = "completed" + run.error_code = "QUEUED_DISPATCH_ERROR" + run.finished_at = now + db.flush() + _record_terminal_side_effects(db, run) + db.flush() + return build_result(run, steps) +# #endregion ScenarioExecution.Runner.CloseDispatchError + + +# #region ScenarioExecution.Runner.QueuedDispatch [C:5] [TYPE Function] [SEMANTICS scenario,execution,dispatch,queue,cas,scheduler] +# @BRIEF Claim and advance eligible durable queued runs outside the HTTP request lifecycle. +# @RELATION DEPENDS_ON -> [ScenarioExecution.Runner.Start] +# @RELATION CALLS -> [ScenarioExecution.Runner.Walker] +# @RELATION CALLS -> [ScenarioExecution.Runner.TerminalSignal] +# @PRE The caller is a trusted server worker/scheduler with a database session; HTTP start remains auto_advance=false. +# @POST Each selected run first CAS-transitions queued -> running; only the winning worker invokes the walker. +# @INVARIANT Pending approval, human wait, cancellation, capacity-blocked, and malformed automated +# human plans never dispatch. Repeated ticks/racing workers cannot duplicate a side effect. +# @INVARIANT The persisted queued->running compare-and-set is the sole initial-dispatch authority: +# HTTP/trigger start, ActionApprovalGate decision, and idempotency replay may persist or +# queue a run but cannot invoke an adapter. +# @REJECTED Running a queued ScenarioRun in the API handler or relying on an in-memory worker flag was +# rejected — only the persisted status CAS is shared across server workers/processes. +def dispatch_queued_runs( + db: Session, + *, + worker_id: str, + registry: ScenarioExecutorRegistry | None = None, + live_binding_resolver: LiveExecutionBindingResolver | None = None, + limit: int = 25, +) -> list[dict[str, Any]]: + if limit <= 0: + raise ValueError("dispatch limit must be positive") + candidates = ( + db.query(ScenarioRun.id) + .filter(ScenarioRun.status == "queued") + .order_by(ScenarioRun.created_at.asc(), ScenarioRun.id.asc()) + .limit(limit) + .all() + ) + outcomes: list[dict[str, Any]] = [] + selected_registry = registry or _build_default_registry(live_binding_resolver) + for (run_id,) in candidates: + run = db.query(ScenarioRun).filter(ScenarioRun.id == run_id).first() + if run is None or run.error_code == "CAPACITY_BLOCKED": + continue + plan = run.runner_plan or {} + try: + validate_pinned_runner_plan(plan) + except ValueError as exc: + outcomes.append(_reject_malformed_plan(db, run, str(exc))) + continue + if (run.target_snapshot or {}).get("environment_class") == "PROD" and any( + isinstance(step, dict) + and step.get("tool") == "browser" + and bool((step.get("action_descriptor") or {}).get("mutating")) + for step in plan.get("steps") or [] + ): + outcomes.append(_reject_malformed_plan(db, run, "PROD_BROWSER_MUTATION_FORBIDDEN")) + continue + has_human_step = any( + isinstance(step, dict) and str(step.get("tool", step.get("executor", ""))) == "human" + for step in plan.get("steps", []) + ) + if (plan.get("manual_run_only") or has_human_step) and run.trigger_source != TRIGGER_SOURCE_MANUAL: + run.status = "blocked" + run.phase = "completed" + run.error_code = "AUTOMATION_INELIGIBLE_HUMAN_STEP" + run.finished_at = datetime.now(UTC) + db.flush() + _record_terminal_side_effects(db, run) + outcomes.append(build_result(run, [])) + continue + claimed = db.execute( + update(ScenarioRun) + .where(ScenarioRun.id == run_id, ScenarioRun.status == "queued") + .values(status="running", phase="executing", started_at=run.started_at or datetime.now(UTC)) + ) + if claimed.rowcount != 1: + continue + db.flush() + db.refresh(run) + try: + outcomes.append(_advance_run(db, run, selected_registry, worker_id=worker_id)) + except Exception: + outcomes.append(_close_queued_dispatch_error(db, run)) + return outcomes +# #endregion ScenarioExecution.Runner.QueuedDispatch + + +# #region ScenarioExecution.Runner.ContinueAfterHuman [C:4] [TYPE Function] [SEMANTICS scenario,execution,human,checkpoint,resume,dag] +# @ingroup ScenarioExecution +# @BRIEF Resume the ready DAG frontier after a HumanCheckpoint decision without re-running completed steps. +# @PRE Checkpoint CAS has materialized the human ScenarioStepRun outcome and returned the run to queued. +# @POST Dispatches only missing dependent steps; terminal result reflects the persisted checkpoint outcome. +# @INVARIANT Human remains a lifecycle primitive and is never added to the executor registry. +def continue_after_human_decision( + db: Session, + run_id: str, + *, + worker_id: str, + live_binding_resolver: LiveExecutionBindingResolver | None = None, +) -> dict[str, Any]: + run = db.query(ScenarioRun).filter(ScenarioRun.id == run_id).first() + if run is None: + raise ValueError("run not found") + if run.status != "queued": + raise ValueError("run is not ready for human continuation") + return _advance_run(db, run, _build_default_registry(live_binding_resolver), worker_id=worker_id) +# #endregion ScenarioExecution.Runner.ContinueAfterHuman + + +# #region ScenarioExecution.Runner.ContinueAfterInfrastructureResume [C:4] [TYPE Function] [SEMANTICS scenario,execution,infrastructure,resume,recovery,dag] +# @ingroup ScenarioExecution +# @BRIEF Continue a recovered run from persisted step rows after one-time infrastructure resume. +# @PRE Infrastructure resume consumed a valid token and returned the run to queued; no HumanCheckpoint is pending. +# @POST Dispatches only absent frontier steps, preserving completed attempt records and their side effects. +# @REJECTED Reusing HumanCheckpoint continuation — infrastructure recovery is neither an observation +# disposition nor a PROD ActionApprovalGate decision. +def continue_after_infrastructure_resume( + db: Session, + run_id: str, + *, + worker_id: str, + live_binding_resolver: LiveExecutionBindingResolver | None = None, +) -> dict[str, Any]: + run = db.query(ScenarioRun).filter(ScenarioRun.id == run_id).first() + if run is None: + raise ValueError("run not found") + if run.status != "queued": + raise ValueError("run is not ready for infrastructure continuation") + return _advance_run(db, run, _build_default_registry(live_binding_resolver), worker_id=worker_id) +# #endregion ScenarioExecution.Runner.ContinueAfterInfrastructureResume + + +# #region ScenarioExecution.Runner.TerminalSignal [C:4] [TYPE Function] [SEMANTICS scenario,execution,terminal,investigation,signal] +# @BRIEF Project one terminal non-pass run into the canonical analyst queue without starting work. +# @RELATION CALLS -> [ScenarioAnalytics.Investigation.TerminalSignal] +# @POST Failed, blocked, and inconclusive runs project one canonical queue signal; passed runs project none. +# @SIDE_EFFECT Emits the idempotent analyst-queue signal and the existing terminal notification. +# @INVARIANT Terminal retries reuse the immutable 047 signal; they never open a case, AgentRun, chat, +# or remediation action. A new retry attempt has a new terminal context; it creates a +# new immutable signal only after re-terminalization and never mutates the prior one. +# @RATIONALE The runner is a queue producer only, preserving 047's separate analyst/case and recurrence boundaries. +# @REJECTED Calling the legacy scenario/environment queue projection was rejected — it conflates +# distinct runs and cannot carry durable artifact provenance. +def _record_terminal_side_effects(db: Session, run: ScenarioRun) -> None: + if run.status in {"failed", "blocked", "inconclusive"}: + emit_terminal_run_signal(db, run) + persist_notification( + db, + event_type="blocked" if run.status == "blocked" else "failed", + scenario_id=run.scenario_id, + run_id=run.id, + severity="warning", + payload={"status": run.status, "environment_id": run.environment_id}, + ) + elif run.status == "passed": + persist_notification( + db, + event_type="completed", + scenario_id=run.scenario_id, + run_id=run.id, + payload={"environment_id": run.environment_id}, + ) +# #endregion ScenarioExecution.Runner.TerminalSignal + + +# #region ScenarioExecution.Runner.ContinueAfterRetry [C:4] [TYPE Function] [SEMANTICS scenario,execution,retry,continuation,dag] +# @ingroup ScenarioExecution +# @BRIEF Execute the queued retry closure from its persisted frontier after lifecycle invalidation. +# @RELATION DEPENDS_ON -> [ScenarioExecution.Lifecycle.Retry] +# @PRE retry_step has atomically retired active closure evidence and returned the run to queued. +# @POST The walker executes queued attempts only; unchanged completed ancestors remain untouched. +# @INVARIANT Retry continuation cannot consume a HumanCheckpoint or a pending PROD approval gate. +def continue_after_step_retry( + db: Session, + run_id: str, + *, + worker_id: str, + live_binding_resolver: LiveExecutionBindingResolver | None = None, +) -> dict[str, Any]: + run = db.query(ScenarioRun).filter(ScenarioRun.id == run_id).first() + if run is None: + raise ValueError("run not found") + if run.status != "queued": + raise ValueError("run is not ready for retry continuation") + return _advance_run(db, run, _build_default_registry(live_binding_resolver), worker_id=worker_id) +# #endregion ScenarioExecution.Runner.ContinueAfterRetry + + +# #region ScenarioExecution.Runner.CrashRecovery.RecoverRunning [C:4] [TYPE Function] [SEMANTICS scenario,execution,recovery,crash,lease,descriptor,browser] +# @ingroup ScenarioExecution +# @BRIEF Validate and archive persisted running steps before crash recovery continues the frontier. +# @POST Returns a terminal rejection result for unsafe state, otherwise the recovered step IDs. +# @INVARIANT No running step is replayed unless its expired lease exactly matches an idempotent or +# retry-safe pinned descriptor and browser recovery has a valid safe checkpoint. +def _recover_running_steps( + db: Session, + run: ScenarioRun, + persisted_steps: list[ScenarioStepRun], + plan: dict[str, Any], + *, + worker_id: str, +) -> tuple[list[str], dict[str, Any] | None]: + plan_steps = { + str(item.get("logical_step_id", item.get("id", ""))): item + for item in plan.get("steps", []) + if isinstance(item, dict) + } + running_steps = [step for step in persisted_steps if step.status == "running"] + for step in running_steps: + lease = ( + db.query(ScenarioStepLease) + .filter( + ScenarioStepLease.run_id == run.id, + ScenarioStepLease.logical_step_id == step.logical_step_id, + ) + .with_for_update() + .first() + ) + if lease is None: + return [], _terminalize_recovery_rejection( + db, run, step, "blocked", "RECOVERY_LEASE_MISSING" + ) + expires_at = ( + lease.expires_at.replace(tzinfo=UTC) + if lease.expires_at.tzinfo is None + else lease.expires_at.astimezone(UTC) + ) + if expires_at > datetime.now(UTC): + raise ValueError("RECOVERY_LEASE_ACTIVE") + descriptor = (plan_steps.get(step.logical_step_id) or {}).get("action_descriptor") or {} + if not (bool(descriptor.get("idempotent")) or bool(descriptor.get("retry_safe"))): + return [], _terminalize_recovery_rejection( + db, run, step, "blocked", "RECOVERY_RECONCILIATION_REQUIRED" + ) + if ( + lease.idempotent != bool(descriptor.get("idempotent")) + or lease.retry_safe != bool(descriptor.get("retry_safe")) + ): + return [], _terminalize_recovery_rejection( + db, run, step, "blocked", "RECOVERY_DESCRIPTOR_LEASE_MISMATCH" + ) + step_meta = plan_steps.get(step.logical_step_id, {}) + if ( + str(step_meta.get("tool", step_meta.get("executor", "assertion"))) == "browser" + and not _browser_recovery_checkpoint(run, step_meta) + ): + return [], _terminalize_recovery_rejection( + db, run, step, "inconclusive", "BROWSER_RECOVERY_CHECKPOINT_REQUIRED" + ) + _archive_recovery_attempt(db, run, step, recovery_worker_id=worker_id) + return [step.logical_step_id for step in running_steps], None +# #endregion ScenarioExecution.Runner.CrashRecovery.RecoverRunning + + +# #region ScenarioExecution.Runner.CrashRecovery [C:5] [TYPE Function] [SEMANTICS scenario,execution,recovery,crash,lease,browser] +# @ingroup ScenarioExecution +# @BRIEF Recover one persisted run from its pinned plan after an abandoned worker claim. +# @RELATION DEPENDS_ON -> [ScenarioExecution.Worker.Claim] +# @RELATION CALLS -> [ScenarioExecution.Artifacts.InvalidateStepEvidence] +# @RELATION CALLS -> [ScenarioExecution.Runner.Walker] +# @RELATION CALLS -> [ScenarioExecution.Runner.TerminalSignal] +# @RELATION DEPENDS_ON -> [ScenarioExecution.LiveBinding.Identity] +# @PRE Recovery is server-driven by run_id; no infrastructure resume token, HumanCheckpoint, or +# ActionApprovalGate decision is accepted or consumed. +# @POST Only an expired idempotent/retry-safe claimed frontier is re-queued with a new attempt. +# Unsafe/missing claims and browser work without a pinned safe checkpoint terminalize non-pass. +# @INVARIANT Completed steps are never re-dispatched. Historical evidence is retained but retired +# from the new attempt projection before recovery can invoke an adapter. +# @INVARIANT Recovery reads only persisted ScenarioRun, ScenarioStepRun, lease, and runner_plan +# state; it never derives a new graph or execution authority from a mutable revision. +# @INVARIANT Browser recovery requires a non-empty checkpoint declared in the pinned step metadata +# or valid persisted LiveExecutionBinding; a dead browser session is never replayed as PASS. +# @REJECTED Re-reading a mutable revision, accepting a public resume token, or replaying an unsafe +# external effect after crash was rejected — persisted run/lease state is authoritative. +def recover_run( + db: Session, + run_id: str, + *, + worker_id: str, + registry: ScenarioExecutorRegistry | None = None, + live_binding_resolver: LiveExecutionBindingResolver | None = None, +) -> dict[str, Any]: + run = db.query(ScenarioRun).filter(ScenarioRun.id == run_id).first() + if run is None: + raise ValueError("run not found") + if run.status in {"waiting_human", "pending_approval", "cancel_requested", "cancelled"}: + raise ValueError("run is not crash-recoverable") + persisted_steps = ( + db.query(ScenarioStepRun) + .filter(ScenarioStepRun.run_id == run_id) + .order_by(ScenarioStepRun.step_position.asc()) + .all() + ) + if run.status in {"passed", "failed", "blocked", "inconclusive"}: + return build_result(run, persisted_steps) + if run.status not in {"queued", "running"}: + raise ValueError("run is not crash-recoverable") + + plan = run.runner_plan or {} + try: + validate_pinned_runner_plan(plan) + except ValueError as exc: + return _reject_malformed_plan(db, run, str(exc)) + recovered_step_ids, rejection = _recover_running_steps( + db, run, persisted_steps, plan, worker_id=worker_id + ) + if rejection is not None: + return rejection + + run.status = "queued" + run.phase = "executing" + db.flush() + logger.reason( + "ScenarioExecution.Runner.recover_run", + "Recovered persisted scenario frontier", + payload={"run_id": run.id, "worker_id": worker_id, "recovered_steps": recovered_step_ids}, + ) + return _advance_run( + db, + run, + registry or _build_default_registry(live_binding_resolver), + worker_id=worker_id, + ) + + +# #region ScenarioExecution.Runner.CrashRecovery.BrowserCheckpoint [C:3] [TYPE Function] [SEMANTICS scenario,execution,recovery,browser,checkpoint,fail-closed] +# @BRIEF Accept only one non-empty browser-safe checkpoint consistent with pinned plan and binding identity. +# @INVARIANT Missing, malformed, or mismatched checkpoint metadata returns no recovery authority; +# it never reconstructs a browser session or turns a crash into PASS. +def _browser_recovery_checkpoint(run: ScenarioRun, step_meta: dict[str, Any]) -> str | None: + plan_checkpoint = step_meta.get("browser_safe_checkpoint_ref") + binding_checkpoint: str | None = None + try: + if run.live_execution_binding_snapshot is not None: + binding_checkpoint = LiveExecutionBinding.from_snapshot( + run.live_execution_binding_snapshot + ).browser_safe_checkpoint_ref + except ValueError: + return None + if plan_checkpoint is not None and (not isinstance(plan_checkpoint, str) or not plan_checkpoint): + return None + if plan_checkpoint and binding_checkpoint and plan_checkpoint != binding_checkpoint: + return None + return plan_checkpoint or binding_checkpoint +# #endregion ScenarioExecution.Runner.CrashRecovery.BrowserCheckpoint + + +# #region ScenarioExecution.Runner.CrashRecovery.ArchiveAttempt [C:3] [TYPE Function] [SEMANTICS scenario,execution,recovery,attempt,artifact,provenance] +# @BRIEF Preserve an abandoned safe attempt before clearing only its active projection for recovery. +# @INVARIANT Recovery history and artifact rows remain auditable; only active refs/outcome are retired +# before an eligible new attempt can invoke an adapter. +def _archive_recovery_attempt( + db: Session, + run: ScenarioRun, + step: ScenarioStepRun, + *, + recovery_worker_id: str, + advance_attempt: bool = True, +) -> None: + prior_outputs = copy.deepcopy(step.outputs or {}) + history = list(prior_outputs.get("recovery_history", [])) + history.append({ + "attempt": step.attempt, + "status": step.status, + "artifact_refs": list(step.artifact_refs or []), + "error_code": step.error_code, + "step_outcome": copy.deepcopy(step.step_outcome or {}), + "recovery_worker_id": recovery_worker_id, + "reason": "EXPIRED_RETRY_SAFE_LEASE", + }) + prior_outputs["recovery_history"] = history + invalidate_step_evidence(db, run_id=run.id, logical_step_ids={step.logical_step_id}) + step.outputs = prior_outputs + step.artifact_refs = [] + step.step_outcome = {} + step.error_code = None + step.progress = 0 + step.status = "queued" + step.started_at = None + step.finished_at = None + if advance_attempt: + step.attempt += 1 + db.flush() +# #endregion ScenarioExecution.Runner.CrashRecovery.ArchiveAttempt + + +# #region ScenarioExecution.Runner.CrashRecovery.Reject [C:3] [TYPE Function] [SEMANTICS scenario,execution,recovery,reconciliation,terminal,signal] +# @BRIEF Terminalize an unsafe, missing-lease, or checkpointless recovery as typed non-pass. +# @INVARIANT Terminalization preserves historical evidence and reuses the canonical idempotent +# terminal signal for the same immutable rejected context. +def _terminalize_recovery_rejection( + db: Session, + run: ScenarioRun, + step: ScenarioStepRun, + status: str, + error_code: str, +) -> dict[str, Any]: + _archive_recovery_attempt( + db, + run, + step, + recovery_worker_id="recovery-rejected", + advance_attempt=False, + ) + step.status = status + step.progress = 100 + step.error_code = error_code + step.step_outcome = {"status": status, "error_code": error_code} + step.finished_at = datetime.now(UTC) + run.status = status + run.phase = "completed" + run.error_code = error_code + run.finished_at = step.finished_at + db.flush() + _record_terminal_side_effects(db, run) + db.flush() + return build_result( + run, + db.query(ScenarioStepRun).filter(ScenarioStepRun.run_id == run.id).all(), + ) +# #endregion ScenarioExecution.Runner.CrashRecovery.Reject +# #endregion ScenarioExecution.Runner.CrashRecovery + + +# #region ScenarioExecution.Runner.Walker [C:5] [TYPE Module] [SEMANTICS scenario,execution,walker,dag,step,artifact] +# @defgroup ScenarioExecution Deterministic DAG walker: create step rows, dispatch ready steps, handle human, aggregate. +# @RELATION DEPENDS_ON -> [ScenarioExecution.Lifecycle.Timeout] +# @INVARIANT Walker mutates only ScenarioRun/ScenarioStepRun owned by this run; scenario/revision rows are immutable. +# @INVARIANT Human steps never reach an executor; they suspend and wait for decide_checkpoint. +# @INVARIANT Artifact refs are attached to the owning step row; owner_type=scenario_run artifacts are registered. +# @INVARIANT Scenario evidence rows use only executor-supplied or locally computed real SHA-256 +# digests; missing/invalid digests are typed inconclusive and never replaced with zeros. +# @INVARIANT Every failed, blocked or inconclusive terminal run emits one idempotent canonical queue +# signal containing immutable run and durable artifact provenance, never agent work. +# @INVARIANT A non-human executor is claimed before adapter I/O. Cancellation/timeout observed +# during that adapter call wins over its returned payload and terminalizes without an +# active worker lease or evidence projection. +# @INVARIANT Every adapter invocation receives an exact persisted ActionExecutionDescriptor; lease +# timeout, idempotency, retry safety and side-effect-key policy derive from it, never +# from tool defaults or caller metadata. +# @REJECTED Registering evidence with an all-zero digest was rejected — it falsifies provenance. +# @REJECTED Treating every non-human tool as retry-safe was rejected because it can replay a +# mutating browser/report/artifact effect after crash or retry. +def _advance_run(db: Session, run: ScenarioRun, registry: ScenarioExecutorRegistry, *, worker_id: str = "api", lease_seconds: int = 30) -> dict[str, Any]: # noqa: C901 + plan = run.runner_plan or {} + try: + validate_pinned_runner_plan(plan) + except ValueError as exc: + return _reject_malformed_plan(db, run, str(exc)) + order: list[str] = plan.get("topological_order", []) + dependencies: list[dict[str, Any]] = plan.get("dependencies", []) + if not order: + run.status = "passed" + run.phase = "completed" + run.started_at = run.started_at or datetime.now(UTC) + run.finished_at = datetime.now(UTC) + db.flush() + return build_result(run, []) + + run.started_at = run.started_at or datetime.now(UTC) + for _ in range(len(order) + 1): + db.refresh(run) + if run.status in {"cancel_requested", "cancelled"}: + from src.services.dashboard_testing.execution.lifecycle import cancel_run + + cancel_run(db, run.id, drain_in_flight=True) + return build_result(run, db.query(ScenarioStepRun).filter(ScenarioStepRun.run_id == run.id).all()) + existing = {step.logical_step_id: step for step in db.query(ScenarioStepRun).filter(ScenarioStepRun.run_id == run.id).all()} + completed = {step_id: (existing[step_id].step_outcome or {"status": existing[step_id].status}) for step_id, step in existing.items() if step.status in {"passed", "failed", "inconclusive", "blocked", "skipped"}} + next_step_id = None + for step_id in order: + if step_id not in existing or existing[step_id].status == "queued": + next_step_id = step_id + break + if next_step_id is None: + run.status = build_result(run, list(existing.values()))["status"] + run.phase = "completed" if run.status in {"passed", "failed", "blocked", "inconclusive"} else run.phase + run.finished_at = datetime.now(UTC) + db.flush() + _record_terminal_side_effects(db, run) + db.flush() + return build_result(run, list(existing.values())) + + step_id = next_step_id + step_meta = next( + step for step in (plan.get("steps") or []) + if str(step.get("logical_step_id", step.get("id", ""))) == step_id + ) + tool = str(step_meta["tool"]) + descriptor = step_meta["action_descriptor"] + step: ScenarioStepRun | None = None + if tool != "human": + if step_id not in existing: + step = ScenarioStepRun( + id=str(uuid.uuid4()), + run_id=run.id, + logical_step_id=step_id, + step_position=order.index(step_id), + attempt=1, + status="queued", + inputs_snapshot={}, + outputs={}, + artifact_refs=[], + progress=0, + step_outcome={}, + ) + db.add(step) + db.flush() + existing[step_id] = step + step = existing[step_id] + step.status = "running" + step.started_at = datetime.now(UTC) + db.flush() + try: + claim_step( + db, + run.id, + step_id, + worker_id=worker_id, + side_effect_key=( + f"{run.id}:{step_id}:{step.attempt}" + if descriptor["side_effect_key_policy"] == "per_attempt" + else None + ), + idempotent=bool(descriptor["idempotent"]), + retry_safe=bool(descriptor["retry_safe"]), + lease_seconds=max(1, min(lease_seconds, int(descriptor["timeout_ms"]) // 1000)), + ) + except ValueError: + step.status = "queued" + db.flush() + return build_result(run, list(existing.values())) + outcome = dispatch_step( + { + "logical_step_id": step_id, + "tool": tool, + "action": step_meta["action"], + "action_descriptor": descriptor, + "step_meta": step_meta, + "scenario_run_id": run.id, + "target_snapshot": run.target_snapshot, + "execution_principal_fingerprint": run.execution_principal_fingerprint, + "live_execution_binding_ref": run.live_execution_binding_ref, + "live_execution_binding_snapshot": run.live_execution_binding_snapshot, + }, + completed=completed, + registry=registry, + edges=dependencies, + ) + db.refresh(run) + if ( + step is not None + and run.status == "inconclusive" + and step.status == "inconclusive" + and step.error_code == "STEP_TIMEOUT" + ): + return build_result(run, db.query(ScenarioStepRun).filter(ScenarioStepRun.run_id == run.id).all()) + if outcome.get("status") == "blocked": + step = step or existing.get(step_id) or ScenarioStepRun( + id=str(uuid.uuid4()), + run_id=run.id, + logical_step_id=step_id, + step_position=order.index(step_id), + attempt=1, + status="blocked", + inputs_snapshot={}, + outputs={}, + artifact_refs=[], + progress=100, + error_code=outcome.get("reason") or "dependency_failed", + step_outcome=outcome, + ) + step.status = "blocked" + step.progress = 100 + step.error_code = outcome.get("reason") or "dependency_failed" + step.step_outcome = outcome + step.finished_at = datetime.now(UTC) + db.add(step) + db.flush() + continue + if tool == "human": + step = existing.get(step_id) or ScenarioStepRun( + id=str(uuid.uuid4()), + run_id=run.id, + logical_step_id=step_id, + step_position=order.index(step_id), + attempt=1, + status="queued", + inputs_snapshot={}, + outputs={}, + artifact_refs=[], + progress=0, + step_outcome={}, + ) + step.status = "waiting_human" + step.progress = 100 + db.add(step) + db.flush() + suspend_for_human(db, run.id, step_id, evidence_refs=[]) + db.flush() + return build_result(run, [*existing.values(), step]) + assert step is not None + + step.step_outcome = outcome + step.status = str(outcome.get("status", "failed")) + step.progress = 100 if step.status in {"passed", "failed", "inconclusive", "blocked"} else 0 + if step.status in {"failed", "inconclusive", "blocked"}: + step.error_code = outcome.get("error_code") or "executor_failed" + if step.status in {"passed", "failed", "inconclusive", "blocked"}: + step.finished_at = datetime.now(UTC) + if outcome.get("output_refs"): + step.outputs = {"refs": outcome["output_refs"]} + if outcome.get("artifact_refs"): + step.artifact_refs = list(outcome["artifact_refs"]) + integrity = register_step_evidence( + db, + run_id=run.id, + logical_step_id=step_id, + artifact_refs=list(outcome["artifact_refs"]), + outcome=outcome, + attempt=step.attempt, + ) + if integrity is not None: + outcome = { + **outcome, + "status": "inconclusive", + "error_code": integrity["reason_code"], + "artifact_integrity": integrity, + } + step.step_outcome = outcome + step.status = "inconclusive" + step.error_code = integrity["reason_code"] + db.flush() + db.refresh(run) + if run.status == "cancel_requested": + from src.services.dashboard_testing.execution.lifecycle import cancel_run + + cancel_run(db, run.id, drain_in_flight=False) + return build_result(run, db.query(ScenarioStepRun).filter(ScenarioStepRun.run_id == run.id).all()) + if step.status in {"passed", "failed", "inconclusive", "blocked"}: + completed[step_id] = outcome + if run.status in {"pending_approval", "queued", "running", "waiting_human"}: + # A step-level inconclusive result is aggregated after all reachable steps; it must not + # make the run terminal before a later human checkpoint can safely suspend it. + run.status = step.status if step.status in {"failed", "blocked"} else run.status + run.phase = "executing" + db.flush() + + run.status = build_result(run, list(existing.values()))["status"] + run.phase = "completed" + run.finished_at = datetime.now(UTC) + _record_terminal_side_effects(db, run) + db.flush() + return build_result(run, list(existing.values())) +# #endregion ScenarioExecution.Runner.Walker + # #endregion ScenarioExecution.Runner diff --git a/backend/src/services/dashboard_testing/execution/runner_plan.py b/backend/src/services/dashboard_testing/execution/runner_plan.py index 83b26e2d9..055df052d 100644 --- a/backend/src/services/dashboard_testing/execution/runner_plan.py +++ b/backend/src/services/dashboard_testing/execution/runner_plan.py @@ -5,6 +5,8 @@ # @INVARIANT Same revision snapshot always yields byte-equivalent plan. # @INVARIANT runner.plan.json in git is a reference artifact, never the runtime source of truth. # @INVARIANT Derivation refuses any revision that is not the scenario's current revision (stale/unsafe). +# @INVARIANT A plan containing a human checkpoint derives manual_run_only=true; the start +# boundary uses this immutable plan fact to reject automation before a run exists. # @REJECTED Reading stored runner.plan.json at runtime was rejected; it is reference-only. # @RATIONALE The derivation is the single source of truth: a stale revision would dispatch steps # from a superseded graph, so the request must fail before a run row is created. @@ -17,6 +19,7 @@ from typing import Any from sqlalchemy.orm import Session from src.models.scenario_registry import ScenarioRegistryEntry, ScenarioRevision +from src.services.dashboard_testing.scenario.templates import validate_action_step def _topological_order(steps: list[dict[str, Any]], edges: list[dict[str, Any]]) -> list[str]: @@ -49,6 +52,12 @@ def _topological_order(steps: list[dict[str, Any]], edges: list[dict[str, Any]]) # @PRE revision_id belongs to scenario and contains an executable graph. # @POST Returns deterministic plan with content hash and plan hash. # @POST Refuses a revision that is not the scenario's current revision (revision mismatch). +# @POST A human checkpoint is represented as immutable manual_run_only=true for the 044 start boundary. +# @INVARIANT Every executable step persists an exact version/hash-pinned 038 ActionExecutionDescriptor; +# missing actions, stale registry identity, invalid I/O shape, and unsafe mutation metadata +# reject before a ScenarioRun/lease/adapter can exist. +# @REJECTED Falling back from a missing action to a tool default was rejected because it bypasses +# immutable ActionRegistry authority and fabricates retry safety. # @TEST_EDGE missing_revision -> reject; revision_mismatch -> reject before run row creation. def derive_runner_plan(db: Session, scenario_id: str, revision_id: str) -> dict[str, Any]: revision = db.query(ScenarioRevision).filter( @@ -67,23 +76,69 @@ def derive_runner_plan(db: Session, scenario_id: str, revision_id: str) -> dict[ f"revision mismatch: requested {revision_id}, current {entry.current_revision_id}" ) graph = dict(revision.graph_snapshot or {}) + registry_version = graph.get("action_registry_version") + registry_hash = graph.get("action_registry_hash") steps = list(graph.get("steps") or []) order = _topological_order(steps, list(graph.get("dependencies") or [])) - by_id = {str(step.get("logical_step_id", step.get("id", index))): step for index, step in enumerate(steps)} - executor_mapping = {step_id: str(by_id[step_id].get("tool", by_id[step_id].get("executor", "assertion"))) for step_id in order} + by_id: dict[str, dict[str, Any]] = {} + for index, source_step in enumerate(steps): + step_id = str(source_step.get("logical_step_id", source_step.get("id", index))) + descriptor = validate_action_step( + source_step, + registry_version=registry_version, + registry_hash=registry_hash, + ) + by_id[step_id] = { + **source_step, + "logical_step_id": step_id, + "action_descriptor": descriptor.snapshot(), + } + pinned_steps = [by_id[str(step.get("logical_step_id", step.get("id", index)))] for index, step in enumerate(steps)] + executor_mapping = { + step_id: by_id[step_id]["action_descriptor"] + for step_id in order + } + human_checkpoints = [step_id for step_id in order if by_id[step_id].get("tool") == "human"] plan = { "scenario_revision_id": revision.revision_id, "scenario_content_hash": revision.content_hash, "verification_program_hash": revision.content_hash, + "action_registry_version": registry_version, + "action_registry_hash": registry_hash, "env_targets": graph.get("environment_ids", []), "resolved_params": graph.get("parameters", {}), "pinned_baselines": graph.get("baselines", {}), "topological_order": order, "executor_mapping": executor_mapping, - "human_checkpoints": [step_id for step_id in order if by_id[step_id].get("tool") == "human"], + "human_checkpoints": human_checkpoints, + "manual_run_only": bool(human_checkpoints), + "dependencies": graph.get("dependencies", []), + "steps": pinned_steps, } plan["plan_hash"] = hashlib.sha256(json.dumps(plan, sort_keys=True, separators=(",", ":")).encode()).hexdigest() return plan # #endregion ScenarioExecution.RunnerPlan.Derive + +# #region ScenarioExecution.RunnerPlan.ValidatePinned [C:4] [TYPE Function] [SEMANTICS scenario,execution,runnerplan,action,preflight] +# @BRIEF Verify a persisted plan still contains exact immutable 038 action descriptors before a worker claim. +# @INVARIANT A legacy/malformed queued plan is blocked before a step lease or adapter call; descriptor +# snapshots must match the current version-pinned registry byte-for-byte. +# @REJECTED Reconstructing a missing descriptor from tool metadata was rejected because mutable or +# unknown effects would otherwise inherit a synthetic retry-safe contract. +def validate_pinned_runner_plan(plan: dict[str, Any]) -> None: + registry_version = plan.get("action_registry_version") + registry_hash = plan.get("action_registry_hash") + for step in plan.get("steps") or []: + if not isinstance(step, dict): + raise ValueError("ACTION_DESCRIPTOR_REQUIRED") + descriptor = validate_action_step( + step, + registry_version=registry_version, + registry_hash=registry_hash, + ) + if step.get("action_descriptor") != descriptor.snapshot(): + raise ValueError("ACTION_DESCRIPTOR_MISMATCH") +# #endregion ScenarioExecution.RunnerPlan.ValidatePinned + # #endregion ScenarioExecution.RunnerPlan diff --git a/backend/src/services/dashboard_testing/execution/worker.py b/backend/src/services/dashboard_testing/execution/worker.py index 387648d0c..46ee65244 100644 --- a/backend/src/services/dashboard_testing/execution/worker.py +++ b/backend/src/services/dashboard_testing/execution/worker.py @@ -10,13 +10,30 @@ from sqlalchemy.orm import Session from src.models.scenario_worker import ScenarioStepLease +# #region ScenarioExecution.Worker.NormalizeTimestamp [C:2] [TYPE Function] [SEMANTICS scenario,execution,worker,lease,time] +# @BRIEF Normalize SQLite's timezone-naive persisted datetimes to UTC before lease comparisons. +def _as_utc(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) +# #endregion ScenarioExecution.Worker.NormalizeTimestamp + + # #region ScenarioExecution.Worker.Claim [C:4] [TYPE Function] [SEMANTICS scenario,execution,worker,claim,lease] +# @INVARIANT The lease row is locked while ownership is evaluated; an expired unsafe side effect +# is never reclaimed without reconciliation. +# @REJECTED Reclaiming every expired lease — an at-least-once retry may duplicate an unsafe +# external effect unless its recorded executor contract is idempotent or retry-safe. def claim_step(db: Session, run_id: str, logical_step_id: str, *, worker_id: str, side_effect_key: str | None, idempotent: bool, retry_safe: bool, lease_seconds: int = 30) -> ScenarioStepLease: + if lease_seconds <= 0: + raise ValueError("lease_seconds must be positive") now = datetime.now(UTC) - existing = db.query(ScenarioStepLease).filter(ScenarioStepLease.run_id == run_id, ScenarioStepLease.logical_step_id == logical_step_id).first() - if existing is not None and existing.expires_at > now and existing.worker_id != worker_id: + existing = db.query(ScenarioStepLease).filter( + ScenarioStepLease.run_id == run_id, + ScenarioStepLease.logical_step_id == logical_step_id, + ).with_for_update().first() + expires_at = _as_utc(existing.expires_at) if existing is not None else None + if existing is not None and expires_at > now and existing.worker_id != worker_id: raise ValueError("step lease held by another worker") - if existing is not None and not (existing.idempotent or existing.retry_safe) and existing.expires_at <= now: + if existing is not None and not (existing.idempotent or existing.retry_safe) and expires_at <= now: raise ValueError("expired non-retry-safe side effect requires reconciliation") lease = existing or ScenarioStepLease(run_id=run_id, logical_step_id=logical_step_id, worker_id=worker_id, side_effect_key=side_effect_key, idempotent=idempotent, retry_safe=retry_safe, expires_at=now) lease.worker_id = worker_id @@ -28,11 +45,18 @@ def claim_step(db: Session, run_id: str, logical_step_id: str, *, worker_id: str # #endregion ScenarioExecution.Worker.Claim # #region ScenarioExecution.Worker.Heartbeat [C:2] [TYPE Function] [SEMANTICS scenario,execution,worker,heartbeat,lease] +# @INVARIANT Worker identity and non-expired lease are checked under the same row lock before renewal. def heartbeat(db: Session, lease_id: str, *, worker_id: str, lease_seconds: int = 30) -> ScenarioStepLease: - lease = db.query(ScenarioStepLease).filter(ScenarioStepLease.id == lease_id, ScenarioStepLease.worker_id == worker_id).first() - if lease is None or lease.expires_at <= datetime.now(UTC): + if lease_seconds <= 0: + raise ValueError("lease_seconds must be positive") + now = datetime.now(UTC) + lease = db.query(ScenarioStepLease).filter( + ScenarioStepLease.id == lease_id, + ScenarioStepLease.worker_id == worker_id, + ).with_for_update().first() + if lease is None or _as_utc(lease.expires_at) <= now: raise ValueError("lease unavailable") - lease.heartbeat_at = datetime.now(UTC) + lease.heartbeat_at = now lease.expires_at = lease.heartbeat_at + timedelta(seconds=lease_seconds) db.flush() return lease diff --git a/backend/src/services/dashboard_testing/registry/staleness.py b/backend/src/services/dashboard_testing/registry/staleness.py index a2ca89858..9f1f97e80 100644 --- a/backend/src/services/dashboard_testing/registry/staleness.py +++ b/backend/src/services/dashboard_testing/registry/staleness.py @@ -22,6 +22,7 @@ from src.models.scenario_registry import ( ScenarioStalenessSeverity, ScenarioStalenessSignal, ) +from src.services.dashboard_testing.analytics.investigation import ingest_investigation_signal def _target_state(kind: str, severity: str) -> str: @@ -97,9 +98,37 @@ def apply_staleness(db: Session, signals: list[dict[str, Any]]) -> dict[str, Any )) if entry.lifecycle_status not in {ScenarioLifecycleState.ARCHIVED, ScenarioLifecycleState.BLOCKED}: entry.lifecycle_status = target_state + ingest_investigation_signal( + db, + { + "fingerprint": f"registry-stale:{scenario_id}:{source_type}:{fingerprint}:{affected_ref}", + "scenario_id": scenario_id, + "severity": severity, + "evidence": { + "source_type": source_type, + "source_fingerprint": fingerprint, + "affected_ref": affected_ref, + "reason": str(signal.get("reason", "upstream change affects scenario")), + }, + }, + ) applied.append({"scenario_id": scenario_id, "state": entry.lifecycle_status, "affected_ref": affected_ref}) db.flush() return {"applied": applied, "skipped": skipped, "applied_count": len(applied), "skipped_count": len(skipped)} + + +def ingest_upstream_staleness_event(db: Session, event: dict[str, Any]) -> dict[str, Any]: + """Production boundary for normalized 037 StructureDiff and 041 blast-radius envelopes.""" + source = str(event.get("source") or event.get("source_type") or "") + if source not in {"structure_diff", "lineage_blast_radius"}: + raise ValueError("unsupported staleness source") + normalized = { + **event, + "source_type": source, + "source_fingerprint": event.get("source_fingerprint") or event.get("fingerprint"), + "affected_ref": event.get("affected_ref") or event.get("reference"), + } + return apply_staleness(db, [normalized]) # #endregion ScenarioRegistry.Staleness.Apply # #endregion ScenarioRegistry.Staleness diff --git a/backend/src/services/dashboard_testing/scenario/templates/__init__.py b/backend/src/services/dashboard_testing/scenario/templates/__init__.py index 1e0bafa62..e1e5215eb 100644 --- a/backend/src/services/dashboard_testing/scenario/templates/__init__.py +++ b/backend/src/services/dashboard_testing/scenario/templates/__init__.py @@ -8,11 +8,15 @@ from __future__ import annotations +from dataclasses import asdict, dataclass +import hashlib +import json from typing import Any PHASES = ("setup", "interact", "observe", "assert", "evidence", "report") TOOLS = ("browser", "superset_api", "xlsx", "assertion", "screenshot", "report", "artifact", "human") RISKS = ("read", "browser_interaction", "draft_write", "human") +ACTION_REGISTRY_VERSION = "038.1.0" # Registered tool/action pairs (action -> phase, risk, required capability) REGISTERED_ACTIONS: dict[str, dict[str, Any]] = { @@ -39,6 +43,108 @@ REGISTERED_ACTIONS: dict[str, dict[str, Any]] = { "human_checkpoint": {"tool": "human", "phase": "assert", "risk": "human", "capability": None}, } + +# #region ScenarioGraph.Templates.ActionRegistry [C:5] [TYPE Class] [SEMANTICS scenario,action,registry,descriptor,side-effect] +# @BRIEF Version-pinned 038 ActionRegistry execution metadata shared with 044 RunnerPlan preflight. +# @INVARIANT A descriptor is selected only by its exact registered {tool, action}, carries its +# retry/side-effect contract, and is fingerprinted with the immutable registry version. +# @RATIONALE 044 must not infer retry safety from a tool: browser mutation and read-only browser +# actions have materially different replay authority. +# @REJECTED A tool-only executor default or universal retry_safe claim was rejected because it can +# replay an unsafe external effect after a lease expiry. +@dataclass(frozen=True) +class ActionExecutionDescriptor: + tool: str + action: str + input_contract: str + output_contract: str + idempotent: bool + retry_safe: bool + side_effect_key_policy: str + timeout_ms: int + risk: str + mutating: bool + + def snapshot(self) -> dict[str, Any]: + return asdict(self) + + +_MUTATING_ACTIONS = frozenset({"row_edit", "bulk_edit", "generate_report", "register_artifact"}) +_BROWSER_ACTIONS = frozenset({ + "open_dashboard", "apply_filters", "text_filter", "table_filter", "pagination", + "row_edit", "bulk_edit", "download_xlsx", "navigate_dashboard", +}) +_TIMEOUTS = { + "download_xlsx": 60000, + "capture_screenshot": 30000, + "execute_metric": 30000, + "dataset_field_assert": 30000, + "dataset_rowset_assert": 30000, +} + + +def _descriptor_for(action: str, entry: dict[str, Any]) -> ActionExecutionDescriptor: + mutating = action in _MUTATING_ACTIONS + browser_interaction = entry["tool"] == "browser" and action in _BROWSER_ACTIONS + if mutating: + return ActionExecutionDescriptor( + tool=entry["tool"], action=action, input_contract="mutation_contract_v1", + output_contract="durable_evidence_v1", idempotent=False, retry_safe=False, + side_effect_key_policy="per_attempt", timeout_ms=_TIMEOUTS.get(action, 30000), + risk=entry["risk"], mutating=True, + ) + return ActionExecutionDescriptor( + tool=entry["tool"], action=action, input_contract="scenario_step_v1", + output_contract="scenario_outcome_v1", idempotent=True, retry_safe=True, + side_effect_key_policy="none", timeout_ms=_TIMEOUTS.get(action, 15000), + risk=entry["risk"], mutating=False, + ) + + +def action_registry_fingerprint() -> str: + payload = { + "version": ACTION_REGISTRY_VERSION, + "descriptors": { + action: _descriptor_for(action, entry).snapshot() + for action, entry in sorted(REGISTERED_ACTIONS.items()) + }, + } + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + + +def resolve_action_descriptor( + *, tool: str, action: str, registry_version: str | None, registry_hash: str | None, +) -> ActionExecutionDescriptor: + if registry_version != ACTION_REGISTRY_VERSION: + raise ValueError("ACTION_REGISTRY_VERSION_MISMATCH") + if registry_hash != action_registry_fingerprint(): + raise ValueError("ACTION_REGISTRY_HASH_MISMATCH") + entry = REGISTERED_ACTIONS.get(action) + if entry is None: + raise ValueError("ACTION_DESCRIPTOR_UNKNOWN") + if entry["tool"] != tool: + raise ValueError("ACTION_DESCRIPTOR_TOOL_MISMATCH") + return _descriptor_for(action, entry) + + +def validate_action_step( + step: dict[str, Any], *, registry_version: str | None, registry_hash: str | None, +) -> ActionExecutionDescriptor: + tool, action = step.get("tool"), step.get("action") + if not isinstance(tool, str) or not isinstance(action, str): + raise ValueError("ACTION_DESCRIPTOR_REQUIRED") + descriptor = resolve_action_descriptor( + tool=tool, action=action, registry_version=registry_version, registry_hash=registry_hash, + ) + if any(key in step and not isinstance(step[key], (dict, list)) for key in ("inputs", "outputs")): + raise ValueError("ACTION_DESCRIPTOR_IO_SHAPE_INVALID") + if descriptor.mutating and not isinstance(step.get("mutation_contract"), dict): + raise ValueError("ACTION_MUTATION_CONTRACT_REQUIRED") + return descriptor +# #endregion ScenarioGraph.Templates.ActionRegistry + # Step templates: mapping name -> (action, tool, phase) STEP_TEMPLATES: dict[str, tuple[str, str, str]] = { "browser_apply_observe_assert": ("open_dashboard", "browser", "setup"), diff --git a/backend/tests/api/test_scenario_analytics_api.py b/backend/tests/api/test_scenario_analytics_api.py index 305d104d8..260ffa782 100644 --- a/backend/tests/api/test_scenario_analytics_api.py +++ b/backend/tests/api/test_scenario_analytics_api.py @@ -335,13 +335,13 @@ class TestCaseRoutes: client = analytics_route_env.client() resp = client.post( f"/api/scenario-analytics/cases/{CASE_OPEN}/disposition", - json={"disposition": "confirmed", "expected_version": 1}, + json={"disposition": "resolved", "expected_version": 1, "verification_evidence": {"reconciled": True}}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["status"] == "resolved" assert body["decision_version"] == 2 - assert body["disposition"] == "confirmed" + assert body["disposition"] == "resolved" def test_disposition_requires_triage_permission(self, analytics_route_env): resp = analytics_route_env.client(user=VIEWER).post( @@ -413,7 +413,7 @@ class TestAnalyticsReads: before = {r.id: r.status for r in env.session_factory().query(ScenarioRun).all()} env.client().post( f"/api/scenario-analytics/cases/{CASE_OPEN}/disposition", - json={"disposition": "confirmed", "expected_version": 1}, + json={"disposition": "resolved", "expected_version": 1, "verification_evidence": {"reconciled": True}}, ) after = {r.id: r.status for r in env.session_factory().query(ScenarioRun).all()} assert before == after diff --git a/backend/tests/api/test_scenario_automation_api.py b/backend/tests/api/test_scenario_automation_api.py index 67cb58fa2..ed5d30762 100644 --- a/backend/tests/api/test_scenario_automation_api.py +++ b/backend/tests/api/test_scenario_automation_api.py @@ -2,10 +2,24 @@ # @BRIEF Verify scenario-automation API surface: schedule/trigger-rule/policy CRUD, metrics, # notifications, RBAC scopes and the direct PROD-gated scenario trigger. # @RELATION BINDS_TO -> [Api.ScenarioAutomation.Routes] +# @RELATION VERIFIES -> [Api.ScenarioAutomation.DirectTrigger] +# @RELATION BINDS_TO -> [ScenarioExecution.Runner.QueuedDispatch] +# @RELATION VERIFIES -> [ScenarioExecution.EnvironmentPolicy.Resolve] +# @RELATION VERIFIES -> [ScenarioExecution.Runner.TriggerSource.RejectAutomatedHuman] # @TEST_EDGE missing_manage_scope -> 403 on schedule mutation # @TEST_EDGE missing_trigger_scope -> 403 on direct trigger # @TEST_EDGE missing_prod_scope -> 403 on PROD direct trigger # @TEST_EDGE idempotency_reuse -> 409 on changed request hash +# @TEST_INVARIANT ScenarioExecution.Runner.Start: The 046 API trigger supplies a server-owned +# automation source; a persisted human graph returns the typed manual-only +# rejection before it creates a run, gate, or notification. +# @TEST_INVARIANT Api.ScenarioAutomation.DirectTrigger: A non-PROD HTTP trigger/replay persists +# only its queued row; its external boundary does not advance until the separate +# 044 dispatcher claims it through status CAS. -> VERIFIED_BY: +# test_direct_trigger_starts_run_with_idempotency +# @TEST_INVARIANT ScenarioExecution.EnvironmentPolicy: A direct target's server-owned PROD class +# requires automation PROD and scenario RUN_PROD before start; client payload has +# no override. -> VERIFIED_BY: test_prod_trigger_requires_prod_scope from __future__ import annotations from collections.abc import Iterator @@ -20,6 +34,11 @@ from src.core.database import SessionLocal from src.dependencies import get_config_manager, get_current_user from src.models.auth import Permission, Role, User from src.models.scenario_registry import ScenarioRegistryEntry +from src.services.dashboard_testing.scenario.templates import ( + ACTION_REGISTRY_VERSION, + action_registry_fingerprint, + resolve_action_descriptor, +) def _make_user_with_permissions(permissions: list[tuple[str, str]]) -> User: @@ -33,6 +52,23 @@ def _make_user_with_permissions(permissions: list[tuple[str, str]]) -> User: return user +def _action_step(step_id: str, tool: str, action: str, **extra) -> dict: + """Hardcoded exact 038 action fixture; no tool-only dispatch is valid.""" + return { + "id": step_id, + "logical_step_id": step_id, + "tool": tool, + "action": action, + "action_descriptor": resolve_action_descriptor( + tool=tool, + action=action, + registry_version=ACTION_REGISTRY_VERSION, + registry_hash=action_registry_fingerprint(), + ).snapshot(), + **extra, + } + + # #region Test.Api.ScenarioAutomation.ConfigManager [C:2] [TYPE Block] [SEMANTICS test,api,scenario,automation,env,fixture] # @BRIEF Server-owned environment classification stub: PROD classification must come from # ConfigManager (stage/is_production), never from the environment_id string. @@ -232,9 +268,9 @@ class TestScenarioAutomationRbac: scenario_id="44444444-4444-4444-8444-444444444444", content_hash="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", graph_snapshot={ - "steps": [ - {"id": "step-1", "logical_step_id": "step-1", "tool": "assertion"} - ], + "action_registry_version": ACTION_REGISTRY_VERSION, + "action_registry_hash": action_registry_fingerprint(), + "steps": [_action_step("step-1", "assertion", "structural_assert")], "dependencies": [], "environment_ids": ["env-preprod-01"], }, @@ -255,6 +291,7 @@ class TestScenarioAutomationRbac: ) assert resp.status_code == 202, resp.text body = resp.json() + # HTTP trigger persists only; no executor runs before the server dispatcher claim. assert body["status"] == "queued" assert body["run_id"] # Same idempotency key returns the same run; a changed request is rejected. @@ -272,5 +309,86 @@ class TestScenarioAutomationRbac: ) assert changed.status_code == 409 assert changed.json()["detail"]["code"] == "IDEMPOTENCY_KEY_REUSED" + + from src.services.dashboard_testing.execution.runner import dispatch_queued_runs + + dispatcher = SessionLocal() + try: + outcomes = dispatch_queued_runs(dispatcher, worker_id="automation-api-dispatch") + dispatcher.commit() + finally: + dispatcher.close() + assert [outcome["status"] for outcome in outcomes] == ["inconclusive"] + + # #region Test.Api.ScenarioAutomation.Rbac.ManualOnly [C:3] [TYPE Function] + # @BRIEF The external automation endpoint refuses a persisted human graph before any run-side effect. + # @TEST_INVARIANT ScenarioExecution.Runner.Start: An API automation request for a human graph + # cannot materialize a manual ScenarioRun or skip its HumanCheckpoint. + def test_direct_trigger_rejects_human_revision_before_run_creation(self): + from src.models.scenario_approval import ActionApprovalGate + from src.models.scenario_automation import ScenarioNotificationEvent + from src.models.scenario_registry import ScenarioRevision + from src.models.scenario_run import ScenarioRun + + scenario_id = "60460000-0000-4000-8000-000000000004" + revision_id = "60460000-0000-4000-8000-000000000014" + setup = SessionLocal() + try: + setup.query(ScenarioRun).filter(ScenarioRun.scenario_id == scenario_id).delete() + setup.query(ScenarioRegistryEntry).filter(ScenarioRegistryEntry.scenario_id == scenario_id).delete() + setup.commit() + setup.add(ScenarioRegistryEntry( + scenario_id=scenario_id, + scenario_key="api-manual-only-044", + name="API manual-only fixture", + dashboard_id=46, + environment_ids=["env-preprod-01"], + owner_id="analyst-046", + owner_username="analyst.046", + lifecycle_status="READY", + validation_status="valid", + current_revision_id=revision_id, + )) + setup.add(ScenarioRevision( + revision_id=revision_id, + scenario_id=scenario_id, + content_hash="6" * 64, + graph_snapshot={ + "action_registry_version": ACTION_REGISTRY_VERSION, + "action_registry_hash": action_registry_fingerprint(), + "steps": [_action_step("human-api-044", "human", "human_checkpoint")], + "dependencies": [], + }, + created_by="analyst-046", + activation_status="current", + )) + setup.commit() + before = ( + setup.query(ActionApprovalGate).count(), + setup.query(ScenarioNotificationEvent).filter(ScenarioNotificationEvent.scenario_id == scenario_id).count(), + ) + finally: + setup.close() + + user = _make_user_with_permissions([("scenario:automation", "TRIGGER")]) + with _client_for(user) as client: + response = client.post( + f"/api/scenario-automation/scenarios/{scenario_id}/trigger", + headers={"Idempotency-Key": "api-human-manual-only-044"}, + json={"environment_id": "env-preprod-01", "revision_id": revision_id}, + ) + + assert response.status_code == 409, response.text + assert response.json()["detail"]["code"] == "AUTOMATION_INELIGIBLE_HUMAN_STEP" + verify = SessionLocal() + try: + assert verify.query(ScenarioRun).filter(ScenarioRun.scenario_id == scenario_id).count() == 0 + assert ( + verify.query(ActionApprovalGate).count(), + verify.query(ScenarioNotificationEvent).filter(ScenarioNotificationEvent.scenario_id == scenario_id).count(), + ) == before + finally: + verify.close() + # #endregion Test.Api.ScenarioAutomation.Rbac.ManualOnly # #endregion Test.Api.ScenarioAutomation.Rbac # #endregion Test.Api.ScenarioAutomation diff --git a/backend/tests/api/test_scenario_runs_api.py b/backend/tests/api/test_scenario_runs_api.py index 1ff4c4b9a..22d21643e 100644 --- a/backend/tests/api/test_scenario_runs_api.py +++ b/backend/tests/api/test_scenario_runs_api.py @@ -1,6 +1,32 @@ # #region Test.ScenarioExecution.Api [C:3] [TYPE Module] [SEMANTICS test,api,scenario,run,start,sse,compare,resume,rbac,prod] # @defgroup ScenarioExecution Test.Api full scenario-runs API (044 T018/T019). # @RELATION VERIFIES -> [Api.ScenarioExecution.Routes] +# @RELATION VERIFIES -> [Api.ScenarioExecution.Routes.Start] +# @RELATION VERIFIES -> [Api.ScenarioExecution.Routes.ApprovalDecision] +# @TEST_CONTRACT: Persisted ScenarioRun frontier + HumanCheckpoint or infrastructure token -> resumed DAG result without re-running completed steps +# @TEST_INVARIANT Api.ScenarioExecution.Routes: Static /compare remains registered before /{run_id}. +# -> VERIFIED_BY: test_compare_is_not_shadowed_by_run_id +# @TEST_INVARIANT Api.ScenarioExecution.Routes.Start: HTTP start/replay is persistence-only: it +# requires scenario:run (and scenario:run:prod for PROD), returns only a durable +# queued/pending row, and creates no step/lease/notification/queue side effect. +# -> VERIFIED_BY: test_start_creates_run_201, test_idempotent_replay_returns_same_run, +# test_rbac_denied_403, test_prod_start_requires_run_prod_when_body_says_false, +# test_prod_start_with_run_prod_creates_pending_approval +# @TEST_INVARIANT ScenarioExecution.EnvironmentPolicy: ConfigManager, never request is_prod, +# classifies PROD before RBAC/gate creation; unknown environments have no run-side +# effect. -> VERIFIED_BY: test_prod_start_requires_run_prod_when_body_says_false, +# test_preprod_true_body_stays_queued, test_prod_replay_ignores_body_classification, +# test_unknown_environment_has_no_run_side_effect +# @TEST_INVARIANT Api.ScenarioExecution.Routes.ApprovalDecision: PROD approval decisions require +# the gate's dedicated scenario:run_prod authority. -> VERIFIED_BY: +# test_approve_prod_gate_queues_run_and_replay_conflicts, +# test_approval_decision_requires_run_prod +# @TEST_INVARIANT ScenarioExecution.Runner.ContinueAfterHuman: A confirmed checkpoint continues +# only dependent saved-frontier steps; completed local deterministic steps stay attempt 1. +# -> VERIFIED_BY: test_human_confirm_resumes_dependents_without_rerunning_completed_steps +# @TEST_INVARIANT ScenarioExecution.Runner.ContinueAfterInfrastructureResume: A valid one-time +# resume token continues only the saved frontier; stale/replayed tokens reject. +# -> VERIFIED_BY: test_infrastructure_resume_continues_saved_frontier_without_rerunning_completed_step # @TEST_EDGE static_compare_before_dynamic_run_id; idempotent_replay; changed_request_409; # stale_revision_409; rbac_403; prod_requires_run_prod; sse_replay_via_last_event_id; # human_checkpoint_resume_409 @@ -22,8 +48,14 @@ from sqlalchemy.pool import StaticPool from src.api.routes.dashboard_testing.scenario_runs import history_router, runs_router from src.core.database import get_db -from src.dependencies import get_current_user +from src.dependencies import get_config_manager, get_current_user from src.models.auth import Permission, Role, User +from src.models.scenario_run import ScenarioRun +from src.services.dashboard_testing.scenario.templates import ( + ACTION_REGISTRY_VERSION, + action_registry_fingerprint, + resolve_action_descriptor, +) _FIXTURE_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "scenario_execution" _SCENARIO = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1" @@ -33,6 +65,47 @@ _RUN_1 = "run-exec-0001" _RUN_2 = "run-exec-0002" +def _action_step(step_id: str, tool: str, action: str, **extra) -> dict: + return { + "logical_step_id": step_id, + "tool": tool, + "action": action, + "action_descriptor": resolve_action_descriptor( + tool=tool, action=action, registry_version=ACTION_REGISTRY_VERSION, + registry_hash=action_registry_fingerprint(), + ).snapshot(), + **extra, + } + + +_HUMAN_CONTINUATION_GRAPH = { + "schema_version": 1, + "action_registry_version": ACTION_REGISTRY_VERSION, + "action_registry_hash": action_registry_fingerprint(), + "environment_ids": ["env-preprod-02"], + "steps": [ + _action_step("before_human", "assertion", "structural_assert", actual=11, expected=11), + _action_step("human_signoff", "human", "human_checkpoint"), + _action_step("after_human", "assertion", "structural_assert", actual=7, expected=7), + ], + "dependencies": [ + {"source": "before_human", "target": "human_signoff"}, + {"source": "human_signoff", "target": "after_human"}, + ], +} +_INFRA_RESUME_GRAPH = { + "schema_version": 1, + "action_registry_version": ACTION_REGISTRY_VERSION, + "action_registry_hash": action_registry_fingerprint(), + "environment_ids": ["env-preprod-02"], + "steps": [ + _action_step("before_resume", "browser", "open_dashboard", session_id="fixture-browser-session"), + _action_step("after_resume", "assertion", "structural_assert", actual=7, expected=7), + ], + "dependencies": [{"source": "before_resume", "target": "after_resume"}], +} + + def _seed(session) -> None: from src.models.scenario_registry import ScenarioRegistryEntry, ScenarioRevision from src.models.scenario_run import ScenarioRun, ScenarioStepRun @@ -119,6 +192,14 @@ class RunRouteEnv: self.session_factory = sessionmaker(bind=self.engine) self.admin = admin self.user = user + self.config_manager = SimpleNamespace( + get_environment=lambda environment_id: { + "env-preprod-02": SimpleNamespace( + stage="PREPROD", is_production=False + ), + "env-prod-01": SimpleNamespace(stage="PROD", is_production=True), + }.get(environment_id) + ) def seed(self) -> None: session = self.session_factory() @@ -149,6 +230,7 @@ class RunRouteEnv: user = SimpleNamespace(id="u1", username="u1", roles=[]) app.dependency_overrides[get_current_user] = lambda: user app.dependency_overrides[get_db] = _override_db + app.dependency_overrides[get_config_manager] = lambda: self.config_manager return TestClient(app) def close(self) -> None: @@ -179,12 +261,24 @@ class TestStart: assert body["runner_plan"]["topological_order"][0] == "s1_setup_filters" def test_idempotent_replay_returns_same_run(self, route_env): + from src.models.scenario_automation import ScenarioNotificationEvent + from src.models.scenario_investigation import InvestigationQueueItem + from src.models.scenario_run import ScenarioStepRun + client = route_env.client() payload = {"scenario_id": _SCENARIO, "revision_id": _REV_CURRENT, "environment_id": "env-preprod-02", "params": {}} first = client.post("/api/scenario-runs", headers={"Idempotency-Key": "start-2"}, json=payload) second = client.post("/api/scenario-runs", headers={"Idempotency-Key": "start-2"}, json=payload) assert first.status_code == 201 and second.status_code == 201 assert first.json()["id"] == second.json()["id"] + session = route_env.session_factory() + try: + run_id = first.json()["id"] + assert session.query(ScenarioStepRun).filter_by(run_id=run_id).count() == 0 + assert session.query(InvestigationQueueItem).filter_by(run_id=run_id).count() == 0 + assert session.query(ScenarioNotificationEvent).filter_by(run_id=run_id).count() == 0 + finally: + session.close() def test_changed_request_same_key_409(self, route_env): client = route_env.client() @@ -219,14 +313,14 @@ class TestStart: finally: env.close() - def test_prod_start_requires_scenario_run_prod(self): + def test_prod_start_requires_run_prod_when_body_says_false(self): env = RunRouteEnv(admin=False, user=_user_with(("scenario", "RUN"))) env.seed() try: resp = env.client().post( "/api/scenario-runs", headers={"Idempotency-Key": "start-prod-1"}, - json={"scenario_id": _SCENARIO, "revision_id": _REV_CURRENT, "environment_id": "env-prod-01", "params": {}, "is_prod": True}, + json={"scenario_id": _SCENARIO, "revision_id": _REV_CURRENT, "environment_id": "env-prod-01", "params": {}, "is_prod": False}, ) assert resp.status_code == 403 finally: @@ -239,12 +333,92 @@ class TestStart: resp = env.client().post( "/api/scenario-runs", headers={"Idempotency-Key": "start-prod-2"}, - json={"scenario_id": _SCENARIO, "revision_id": _REV_CURRENT, "environment_id": "env-prod-01", "params": {}, "is_prod": True}, + json={"scenario_id": _SCENARIO, "revision_id": _REV_CURRENT, "environment_id": "env-prod-01", "params": {}, "is_prod": False}, ) assert resp.status_code == 201, resp.text assert resp.json()["status"] == "pending_approval" finally: env.close() + + def test_preprod_true_body_stays_queued(self, route_env): + response = route_env.client().post( + "/api/scenario-runs", + headers={"Idempotency-Key": "start-preprod-true-044"}, + json={ + "scenario_id": _SCENARIO, + "revision_id": _REV_CURRENT, + "environment_id": "env-preprod-02", + "params": {}, + "is_prod": True, + }, + ) + assert response.status_code == 201, response.text + assert response.json()["status"] == "queued" + + def test_prod_replay_ignores_body_classification(self): + from src.models.scenario_approval import ActionApprovalGate + from src.models.scenario_run import ScenarioStepRun + + env = RunRouteEnv( + admin=False, + user=_user_with(("scenario", "RUN"), ("scenario", "RUN_PROD")), + ) + env.seed() + try: + client = env.client() + payload = { + "scenario_id": _SCENARIO, + "revision_id": _REV_CURRENT, + "environment_id": "env-prod-01", + "params": {"hardcoded": "prod-replay-044"}, + } + first = client.post( + "/api/scenario-runs", + headers={"Idempotency-Key": "start-prod-replay-class-044"}, + json={**payload, "is_prod": False}, + ) + second = client.post( + "/api/scenario-runs", + headers={"Idempotency-Key": "start-prod-replay-class-044"}, + json={**payload, "is_prod": True}, + ) + assert first.status_code == 201, first.text + assert second.status_code == 201, second.text + assert first.json()["id"] == second.json()["id"] + assert second.json()["status"] == "pending_approval" + session = env.session_factory() + try: + run_id = first.json()["id"] + assert session.query(ActionApprovalGate).filter_by(owner_id=run_id).count() == 1 + assert session.query(ScenarioStepRun).filter_by(run_id=run_id).count() == 0 + finally: + session.close() + finally: + env.close() + + def test_unknown_environment_has_no_run_side_effect(self, route_env): + session = route_env.session_factory() + try: + before = session.query(ScenarioRun).count() + finally: + session.close() + response = route_env.client().post( + "/api/scenario-runs", + headers={"Idempotency-Key": "start-unknown-environment-044"}, + json={ + "scenario_id": _SCENARIO, + "revision_id": _REV_CURRENT, + "environment_id": "env-unknown-044", + "params": {}, + }, + ) + assert response.status_code == 422 + assert response.json()["detail"]["code"] == "ENVIRONMENT_NOT_CONFIGURED" + session = route_env.session_factory() + try: + assert session.query(ScenarioRun).count() == before + finally: + session.close() # #endregion Test.ScenarioExecution.Api.TestStart @@ -414,6 +588,145 @@ class TestSse: # #region Test.ScenarioExecution.Api.TestLifecycleRoutes [C:2] [TYPE Class] class TestLifecycleRoutes: + def test_infrastructure_resume_continues_saved_frontier_without_rerunning_completed_step(self, route_env): + from src.models.scenario_registry import ScenarioRevision + from src.models.scenario_run import ScenarioStepRun + from src.services.dashboard_testing.execution.lifecycle import pause_for_infrastructure + from src.services.dashboard_testing.execution.runner import start_run + + session = route_env.session_factory() + try: + revision = session.query(ScenarioRevision).filter_by(revision_id=_REV_CURRENT).one() + revision.graph_snapshot = _INFRA_RESUME_GRAPH + session.flush() + run = start_run( + session, _SCENARIO, _REV_CURRENT, {}, "env-preprod-02", + actor="infra-continuation", idempotency_key="infra-continuation-1", + config_manager=route_env.config_manager, + auto_advance=False, + ) + session.add(ScenarioStepRun( + run_id=run.id, logical_step_id="before_resume", step_position=0, + attempt=1, status="passed", progress=100, + step_outcome={"logical_step_id": "before_resume", "status": "passed"}, + )) + _, token = pause_for_infrastructure(session, run.id) + session.commit() + run_id = run.id + finally: + session.close() + + client = route_env.client() + invalid = client.post( + f"/api/scenario-runs/{run_id}/resume", + json={"resume_token": "stale-token", "resume_reason": "worker_recovered"}, + ) + assert invalid.status_code == 409 + + resumed = client.post( + f"/api/scenario-runs/{run_id}/resume", + json={"resume_token": token, "resume_reason": "worker_recovered"}, + ) + assert resumed.status_code == 200, resumed.text + assert resumed.json()["status"] == "passed" + result = client.get(f"/api/scenario-runs/{run_id}/result") + steps = {step["logical_step_id"]: step for step in result.json()["snapshot"]["steps"]} + assert result.json()["status"] == "passed" + assert steps["before_resume"]["status"] == "passed" + assert steps["before_resume"]["attempt"] == 1 + assert steps["after_resume"]["status"] == "passed" + assert steps["after_resume"]["attempt"] == 1 + + replay = client.post( + f"/api/scenario-runs/{run_id}/resume", + json={"resume_token": token, "resume_reason": "worker_recovered"}, + ) + assert replay.status_code == 409 + + def test_human_confirm_resumes_dependents_without_rerunning_completed_steps(self, route_env): + from src.models.scenario_registry import ScenarioRevision + from src.models.scenario_run import ScenarioStepRun + from src.services.dashboard_testing.execution.runner import dispatch_queued_runs, start_run + + session = route_env.session_factory() + try: + revision = session.query(ScenarioRevision).filter_by(revision_id=_REV_CURRENT).one() + revision.graph_snapshot = _HUMAN_CONTINUATION_GRAPH + session.flush() + run = start_run( + session, _SCENARIO, _REV_CURRENT, {}, "env-preprod-02", + actor="human-continuation", idempotency_key="human-continuation-1", + config_manager=route_env.config_manager, + ) + session.commit() + run_id = run.id + assert session.query(ScenarioStepRun).filter_by(run_id=run_id).count() == 0 + dispatch_queued_runs(session, worker_id="api-human-continuation") + session.commit() + before = session.query(ScenarioStepRun).filter_by( + run_id=run_id, logical_step_id="before_human" + ).one() + assert before.status == "passed" + assert before.attempt == 1 + finally: + session.close() + + decision = route_env.client().post( + f"/api/scenario-runs/{run_id}/human/decision", + json={"disposition": "confirm", "expected_version": 1, "comment": "fixture sign-off"}, + ) + + assert decision.status_code == 200, decision.text + result = route_env.client().get(f"/api/scenario-runs/{run_id}/result") + assert result.status_code == 200, result.text + assert result.json()["status"] == "passed" + steps = {step["logical_step_id"]: step for step in result.json()["snapshot"]["steps"]} + assert steps["before_human"]["status"] == "passed" + assert steps["before_human"]["attempt"] == 1 + assert steps["human_signoff"]["status"] == "passed" + assert steps["after_human"]["status"] == "passed" + + @pytest.mark.parametrize( + ("disposition", "error_code"), + [("false_positive", "HUMAN_FALSE_POSITIVE"), ("inconclusive", "HUMAN_INCONCLUSIVE")], + ) + def test_human_nonpass_dispositions_resume_with_explicit_inconclusive_outcome( + self, route_env, disposition, error_code, + ): + from src.models.scenario_registry import ScenarioRevision + from src.services.dashboard_testing.execution.runner import dispatch_queued_runs, start_run + + session = route_env.session_factory() + try: + revision = session.query(ScenarioRevision).filter_by(revision_id=_REV_CURRENT).one() + revision.graph_snapshot = _HUMAN_CONTINUATION_GRAPH + session.flush() + run = start_run( + session, _SCENARIO, _REV_CURRENT, {}, "env-preprod-02", + actor="human-nonpass", idempotency_key=f"human-nonpass-{disposition}", + config_manager=route_env.config_manager, + ) + session.commit() + run_id = run.id + dispatch_queued_runs(session, worker_id=f"api-human-nonpass-{disposition}") + session.commit() + finally: + session.close() + + decision = route_env.client().post( + f"/api/scenario-runs/{run_id}/human/decision", + json={"disposition": disposition, "expected_version": 1}, + ) + + assert decision.status_code == 200, decision.text + result = route_env.client().get(f"/api/scenario-runs/{run_id}/result") + steps = {step["logical_step_id"]: step for step in result.json()["snapshot"]["steps"]} + assert result.json()["status"] == "inconclusive" + assert steps["human_signoff"]["status"] == "inconclusive" + assert steps["human_signoff"]["error_code"] == error_code + assert steps["human_signoff"]["step_outcome"]["disposition"] == disposition + assert steps["after_human"]["status"] == "passed" + def test_cancel_run(self, route_env): client = route_env.client() created = client.post( @@ -446,7 +759,8 @@ class TestLifecycleRoutes: json={"resume_token": token, "resume_reason": "worker_recovered"}, ) assert resp.status_code == 200, resp.text - assert resp.json()["phase"] == "executing" + assert resp.json()["status"] == "waiting_human" + assert resp.json()["phase"] == "waiting_human" def test_resume_human_checkpoint_409(self, route_env): from src.services.dashboard_testing.execution.lifecycle import suspend_for_human @@ -470,11 +784,11 @@ class TestLifecycleRoutes: assert resp.status_code == 409 assert "human checkpoint" in resp.json()["detail"]["detail"] - def test_retry_step_invalidates_closure(self, route_env): + def test_retry_step_rejects_legacy_unsafe_plan(self, route_env): resp = route_env.client().post(f"/api/scenario-runs/{_RUN_1}/steps/s3_export_xlsx/retry") - assert resp.status_code == 200, resp.text - affected = resp.json() - assert any(step["logical_step_id"] == "s3_export_xlsx" and step["attempt"] == 2 for step in affected) + assert resp.status_code == 409, resp.text + assert resp.json()["detail"]["code"] == "RETRY_CONFLICT" + assert "non-retry-safe action descriptor" in resp.json()["detail"]["detail"] def test_retry_missing_step_409(self, route_env): resp = route_env.client().post(f"/api/scenario-runs/{_RUN_1}/steps/nope/retry") diff --git a/backend/tests/fixtures/scenario_execution/graph.json b/backend/tests/fixtures/scenario_execution/graph.json index 5a37dc263..edfc4627d 100644 --- a/backend/tests/fixtures/scenario_execution/graph.json +++ b/backend/tests/fixtures/scenario_execution/graph.json @@ -1,6 +1,8 @@ { "schema_version": 1, "compiler_version": "038.1.0", + "action_registry_version": "038.1.0", + "action_registry_hash": "fd369209425dd46219eb857bc9ddcb837b533a69f71d81b014207983fad129e9", "scenario_id": "fi-0080_verify-filters-metric-xlsx", "revision_hash": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", "objective": { @@ -28,7 +30,7 @@ "logical_step_id": "s2_query_metric", "id": "step-uuid-2", "tool": "superset_api", - "action": "execute_query", + "action": "execute_metric", "title": "Query revenue metric", "timeout_ms": 30000 }, @@ -36,7 +38,7 @@ "logical_step_id": "s3_export_xlsx", "id": "step-uuid-3", "tool": "xlsx", - "action": "export", + "action": "parse_xlsx", "title": "Export XLSX", "timeout_ms": 60000 }, @@ -44,7 +46,7 @@ "logical_step_id": "s4_assert_revenue", "id": "step-uuid-4", "tool": "assertion", - "action": "compare_baseline", + "action": "compare_to_baseline", "title": "Assert revenue vs baseline", "timeout_ms": 15000 }, @@ -52,6 +54,7 @@ "logical_step_id": "s5_review_human", "id": "step-uuid-5", "tool": "human", + "action": "human_checkpoint", "title": "Human review of export", "timeout_ms": 0 }, @@ -59,7 +62,7 @@ "logical_step_id": "s6_screenshot", "id": "step-uuid-6", "tool": "screenshot", - "action": "capture", + "action": "capture_screenshot", "title": "Capture evidence screenshot", "timeout_ms": 30000 } diff --git a/backend/tests/services/dashboard_testing/registry/conftest.py b/backend/tests/services/dashboard_testing/registry/conftest.py index b9579c0eb..d853ee1f2 100644 --- a/backend/tests/services/dashboard_testing/registry/conftest.py +++ b/backend/tests/services/dashboard_testing/registry/conftest.py @@ -13,6 +13,7 @@ from datetime import datetime import json from pathlib import Path import pytest +from types import SimpleNamespace from sqlalchemy import create_engine, event from sqlalchemy.orm import Session, sessionmaker @@ -33,6 +34,32 @@ _CONSUME_COMMIT = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b" _TEST_SOURCE_RESPONSE_HASH = "abc123def456abc123def456abc123def456abc123def456abc123def456abcf" +# #region Test.ScenarioRegistry.Conftest.EnvironmentPolicy [C:2] [TYPE Function] [SEMANTICS test,scenario,execution,environment,prod,fixture] +# @BRIEF Provide only explicit server-owned environment records to 044 persisted fixtures. +# @TEST_FIXTURE preprod/prod/env-preprod-02/env-preprod-044 -> INLINE_CONFIG_MANAGER +# @TEST_INVARIANT ScenarioExecution.EnvironmentPolicy: fixture run bodies cannot classify an +# environment; only this hardcoded ConfigManager mapping may select PROD. +@pytest.fixture(autouse=True) +def configured_scenario_execution_environments(monkeypatch): + environments = { + "preprod": SimpleNamespace(stage="PREPROD", is_production=False), + "prod": SimpleNamespace(stage="PROD", is_production=True), + "env-preprod-02": SimpleNamespace(stage="PREPROD", is_production=False), + "env-preprod-044": SimpleNamespace(stage="PREPROD", is_production=False), + "env-preprod-manual-only": SimpleNamespace( + stage="PREPROD", is_production=False + ), + } + config_manager = SimpleNamespace( + get_environment=lambda environment_id: environments.get(environment_id) + ) + monkeypatch.setattr( + "src.dependencies.get_config_manager", lambda: config_manager + ) + return config_manager +# #endregion Test.ScenarioRegistry.Conftest.EnvironmentPolicy + + def _make_request_with_capture(agent_run_id: str, capture_artifact_id: str) -> CandidateRequest: return CandidateRequest.model_construct( environment_id="ss-preprod", diff --git a/backend/tests/services/dashboard_testing/registry/test_live_execution_binding.py b/backend/tests/services/dashboard_testing/registry/test_live_execution_binding.py new file mode 100644 index 000000000..318457dbf --- /dev/null +++ b/backend/tests/services/dashboard_testing/registry/test_live_execution_binding.py @@ -0,0 +1,567 @@ +# #region Test.ScenarioExecution.LiveBinding [C:3] [TYPE Module] [SEMANTICS test,scenario,execution,live-binding,superset] +# @BRIEF Verify persisted live-binding composition only authorizes exact 037 query execution. +# @RELATION BINDS_TO -> [ScenarioExecution.LiveBinding] +# @RELATION VERIFIES -> [ScenarioExecution.Runner.DefaultRegistry] +# @RELATION VERIFIES -> [ScenarioExecution.LiveBinding.Execute] +# @RELATION VERIFIES -> [ScenarioExecution.LiveBinding.Evidence] +# @RELATION VERIFIES -> [ScenarioExecution.Runner.Start] +# @RELATION VERIFIES -> [ScenarioExecution.LiveCompositionRoot] +# @RELATION VERIFIES -> [Core.ConfigModels.ScenarioLiveExecutionBinding] +# @TEST_CONTRACT: persisted LiveExecutionBinding + resolver -> authorized 037 Superset adapter outcome +# @TEST_FIXTURE: binding_044 + query_model_044 + chart_data_044 -> INLINE_JSON hardcoded identity and response +# @TEST_EDGE: missing_binding -> inconclusive/no client call; resolver_mismatch -> inconclusive/no client call; external_failure -> failed; external_timeout -> inconclusive +# @TEST_INVARIANT ScenarioExecution.LiveBinding: A live query executes only when a resolver returns the exact persisted binding and pinned model/principal fingerprints. -> VERIFIED_BY: missing_binding, resolver_mismatch, matching_binding, external_failure, external_timeout, start_persists_binding +# @TEST_INVARIANT ScenarioExecution.LiveBinding.Evidence: Exact raw 037 bytes produce the stored opaque ref and SHA-256; invalid evidence cannot pass. -> VERIFIED_BY: matching_binding, start_persists_binding +# @TEST_INVARIANT ScenarioExecution.Runner.Start: The validated immutable identity snapshot persists with its binding ref. -> VERIFIED_BY: start_persists_binding +# @TEST_INVARIANT Core.ConfigModels.ScenarioLiveExecutionBinding: An enabled trusted setting enters +# composition only through the exact LiveExecutionBinding identity allowlist; it +# cannot turn an environment label into runtime authority. -> VERIFIED_BY: +# lifespan_bootstrap_configured_binding_dispatches_queued_run +# @TEST_INVARIANT ScenarioExecution.LiveCompositionRoot: Startup-owned providers execute only for an +# exact binding; unavailable/mismatched identities make no external call. -> VERIFIED_BY: +# composition_root_default_registry_invokes_037, composition_root_mismatch_and_unavailable_fail_closed +# @TEST_INVARIANT ScenarioExecution.LiveCompositionRoot: Browser recovery requires its pinned safe +# checkpoint, PROD mutation is rejected, and Screenshot PASS carries provider-issued +# durable evidence. -> VERIFIED_BY: composition_root_browser_checkpoint_and_screenshot_evidence +# @TEST_INVARIANT ScenarioExecution.LiveCompositionRoot.Bootstrap: No configured Browser/Screenshot +# provider exists by default; both tools remain typed unavailable and make no live +# client call until server-side provider registration. -> VERIFIED_BY: +# lifespan_bootstrap_mismatch_and_unavailable_are_no_call +# @TEST_INVARIANT App.AppModule.LiveExecutionCompositionBootstrap: Trusted configuration registers +# exact 037 authority before queued dispatch; mismatch/unavailable paths make no I/O call. -> VERIFIED_BY: +# lifespan_bootstrap_configured_binding_dispatches_queued_run, +# lifespan_bootstrap_mismatch_and_unavailable_are_no_call +from __future__ import annotations + +import asyncio +from hashlib import sha256 +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from src.core.config_models import ScenarioLiveExecutionBindingConfig +from src.core.superset_client._chart_data import ChartDataResponse +from src.core.utils.network import SupersetAPIError +from src.models.scenario_artifact import ScenarioArtifact +from src.models.scenario_run import ScenarioStepRun +from src.schemas.dashboard_testing import ( + ChartQueryModel, + DashboardQueryModel, + MetricDescriptor, + VizType, +) +from src.services.dashboard_testing.execution.live_adapter import LiveAdapterResult +from src.services.dashboard_testing.execution.live_binding import ( + LiveExecutionBinding, + ResolvedLiveExecutionBinding, + superset_adapter_from, +) +from src.services.dashboard_testing.execution.live_composition import LiveExecutionCompositionRoot +from src.services.dashboard_testing.execution.runner import ( + _advance_run, + _build_default_registry, + dispatch_queued_runs, + start_run, +) +from src.services.dashboard_testing.scenario.templates import ( + ACTION_REGISTRY_VERSION, + action_registry_fingerprint, + resolve_action_descriptor, +) + +_RAW_RESPONSE_044 = b'{"result":[{"data":{"revenue":4400}}],"query_id":"q-044"}' +_RAW_RESPONSE_SHA_044 = "f2c10974f67a9dd9177e48c1d26982797eb929f8b0f8f385c5ae3b25ff1d35b8" +_BINDING_044 = { + "binding_ref": "live-binding-044", + "environment_id": "env-preprod-044", + "dashboard_release_id": "release-044", + "release_fingerprint": "1" * 64, + "dashboard_id": 44, + "query_model_fingerprint": "sha256:model-044", + "execution_principal_fingerprint": "5079d4e113c33372e1d69c9dc7eece02c0a915c25a67bf550f214d4551d54113", + "rls_security_fingerprint": "3" * 64, + "browser_safe_checkpoint_ref": None, + "browser_action_binding_ref": None, + "evidence_owner_type": "scenario_run", + "evidence_ref_policy": "draft_storage_raw_response", +} +_STEP_044 = { + "logical_step_id": "superset-metric-044", + "scenario_run_id": "run-044", + "execution_principal_fingerprint": "5079d4e113c33372e1d69c9dc7eece02c0a915c25a67bf550f214d4551d54113", + "target_snapshot": {"environment_id": "env-preprod-044", "dashboard_release_id": "release-044"}, + "live_execution_binding_ref": "live-binding-044", + "live_execution_binding_snapshot": _BINDING_044, + "step_meta": { + "environment_id": "env-preprod-044", + "dashboard_id": 44, + "chart_id": 440, + "result_key": "revenue", + "normalized_filters": {"schema_version": 1, "filters": [], "filters_hash": "sha256:filters-044"}, + }, +} +_BROWSER_BINDING_044 = { + **_BINDING_044, + "browser_safe_checkpoint_ref": "checkpoint:044", + "browser_action_binding_ref": "browser-action:044", +} + + +def _superset_plan() -> dict: + descriptor = resolve_action_descriptor( + tool="superset_api", + action="execute_metric", + registry_version=ACTION_REGISTRY_VERSION, + registry_hash=action_registry_fingerprint(), + ).snapshot() + return { + "action_registry_version": ACTION_REGISTRY_VERSION, + "action_registry_hash": action_registry_fingerprint(), + "topological_order": ["superset-metric-044"], + "dependencies": [], + "steps": [_STEP_044["step_meta"] | { + "logical_step_id": "superset-metric-044", + "tool": "superset_api", + "action": "execute_metric", + "action_descriptor": descriptor, + }], + } + + +def _resolve(registry, tool: str, action: str): + return registry.resolve(resolve_action_descriptor( + tool=tool, + action=action, + registry_version=ACTION_REGISTRY_VERSION, + registry_hash=action_registry_fingerprint(), + ).snapshot()) + + +# #region Test.ScenarioExecution.LiveBinding.Fakes [C:1] [TYPE Class] +class _EvidenceStore: + def __init__(self) -> None: + self.stored: list[tuple[str, str, bytes]] = [] + + def store(self, run_id: str, digest: str, data: bytes) -> str: + self.stored.append((run_id, digest, data)) + return f"draft:{run_id}:{digest}" + + +class _Resolver: + def __init__(self, resolved: ResolvedLiveExecutionBinding | None) -> None: + self.resolved = resolved + self.references: list[str] = [] + + def resolve(self, binding_ref: str) -> ResolvedLiveExecutionBinding | None: + self.references.append(binding_ref) + return self.resolved +# #endregion Test.ScenarioExecution.LiveBinding.Fakes + + +# #region Test.ScenarioExecution.LiveBinding.QueryModel [C:1] [TYPE Function] +def _query_model() -> DashboardQueryModel: + return DashboardQueryModel( + environment_id="env-preprod-044", + dashboard_id=44, + title="Revenue 044", + query_model_fingerprint="sha256:model-044", + charts=[ + ChartQueryModel( + chart_id=440, + slice_name="Revenue", + viz_type=VizType.TABLE, + dataset_id=441, + dataset_name="sales", + metrics=[MetricDescriptor(metric_name="revenue", label="Revenue", expression_type="SIMPLE")], + ) + ], + ) +# #endregion Test.ScenarioExecution.LiveBinding.QueryModel + + +# #region Test.ScenarioExecution.LiveBinding.Resolved [C:1] [TYPE Function] +def _resolved(binding: LiveExecutionBinding, client: AsyncMock, evidence: _EvidenceStore) -> ResolvedLiveExecutionBinding: + return ResolvedLiveExecutionBinding( + binding=binding, + superset_client=client, + query_model=_query_model(), + evidence_storage=evidence, + run_async=asyncio.run, + ) +# #endregion Test.ScenarioExecution.LiveBinding.Resolved + + +# #region Test.ScenarioExecution.LiveBinding.CompositionRoot [C:1] [TYPE Function] +def _install_composition_root(monkeypatch) -> LiveExecutionCompositionRoot: + import src.dependencies as dependencies + + root = LiveExecutionCompositionRoot() + monkeypatch.setattr(dependencies, "live_execution_composition_root", root) + return root +# #endregion Test.ScenarioExecution.LiveBinding.CompositionRoot + + +# #region Test.ScenarioExecution.LiveBinding.Missing [C:2] [TYPE Function] +# @BRIEF Missing persisted binding fails closed without querying the external client. +def test_missing_binding_is_inconclusive_and_does_not_call_client(): + client = AsyncMock() + resolver = _Resolver(None) + outcome = superset_adapter_from(resolver)({**_STEP_044, "live_execution_binding_ref": None, "live_execution_binding_snapshot": None}, {}) + + assert outcome.status == "inconclusive" + assert outcome.reason_code == "SUPERSET_BINDING_MISSING" + assert resolver.references == [] + client.execute_chart_data_raw.assert_not_awaited() +# #endregion Test.ScenarioExecution.LiveBinding.Missing + + +# #region Test.ScenarioExecution.LiveBinding.Mismatch [C:2] [TYPE Function] +# @BRIEF Resolver identity mismatch fails closed before the 037 client receives a query. +def test_resolver_mismatch_is_inconclusive_and_does_not_call_client(): + client = AsyncMock() + expected = LiveExecutionBinding.from_snapshot(_BINDING_044) + mismatched = expected.with_query_model_fingerprint("sha256:other-model-044") + resolver = _Resolver(_resolved(mismatched, client, _EvidenceStore())) + + outcome = superset_adapter_from(resolver)(_STEP_044, {}) + + assert outcome.status == "inconclusive" + assert outcome.reason_code == "SUPERSET_BINDING_MISMATCH" + assert resolver.references == ["live-binding-044"] + client.execute_chart_data_raw.assert_not_awaited() +# #endregion Test.ScenarioExecution.LiveBinding.Mismatch + + +# #region Test.ScenarioExecution.LiveBinding.Success [C:2] [TYPE Function] +# @BRIEF Exact resolver identity invokes the existing 037 envelope and forwards verified evidence. +def test_matching_binding_invokes_query_executor_and_forwards_real_evidence_digest(): + client = AsyncMock() + client.execute_chart_data_raw.return_value = ChartDataResponse( + parsed={"result": [{"data": {"revenue": 4400}}], "query_id": "q-044"}, + raw_bytes=_RAW_RESPONSE_044, + source_response_hash=_RAW_RESPONSE_SHA_044, + ) + binding = LiveExecutionBinding.from_snapshot(_BINDING_044) + evidence = _EvidenceStore() + resolver = _Resolver(_resolved(binding, client, evidence)) + + outcome = superset_adapter_from(resolver)(_STEP_044, {}) + + assert sha256(_RAW_RESPONSE_044).hexdigest() == _RAW_RESPONSE_SHA_044 + assert outcome.status == "passed" + assert outcome.reason_code == "SUPERSET_QUERY_EXECUTED" + assert outcome.details["source_response_hash"] == _RAW_RESPONSE_SHA_044 + assert outcome.artifact_refs == [f"draft:run-044:{_RAW_RESPONSE_SHA_044}"] + assert outcome.artifact_digests == {f"draft:run-044:{_RAW_RESPONSE_SHA_044}": _RAW_RESPONSE_SHA_044} + assert evidence.stored == [("run-044", _RAW_RESPONSE_SHA_044, _RAW_RESPONSE_044)] + client.execute_chart_data_raw.assert_awaited_once() +# #endregion Test.ScenarioExecution.LiveBinding.Success + + +# #region Test.ScenarioExecution.LiveBinding.Failure [C:2] [TYPE Function] +# @BRIEF A typed 037 API failure remains non-pass after an exact resolver match. +def test_matching_binding_maps_query_failure_to_non_pass(): + client = AsyncMock() + client.execute_chart_data_raw.side_effect = SupersetAPIError("preprod denied") + binding = LiveExecutionBinding.from_snapshot(_BINDING_044) + resolver = _Resolver(_resolved(binding, client, _EvidenceStore())) + + outcome = _resolve(_build_default_registry(resolver), "superset_api", "execute_metric")(_STEP_044, {}) + + assert outcome["status"] == "failed" + assert outcome["error_code"] == "SUPERSET_QUERY_FAILED" + client.execute_chart_data_raw.assert_awaited_once() +# #endregion Test.ScenarioExecution.LiveBinding.Failure + + +# #region Test.ScenarioExecution.LiveBinding.Timeout [C:2] [TYPE Function] +# @BRIEF A timeout at the authorized external client remains typed inconclusive. +def test_matching_binding_maps_query_timeout_to_typed_inconclusive(): + client = AsyncMock() + client.execute_chart_data_raw.side_effect = TimeoutError("chart-data timed out") + binding = LiveExecutionBinding.from_snapshot(_BINDING_044) + resolver = _Resolver(_resolved(binding, client, _EvidenceStore())) + + outcome = _resolve(_build_default_registry(resolver), "superset_api", "execute_metric")(_STEP_044, {}) + + assert outcome["status"] == "inconclusive" + assert outcome["error_code"] == "SUPERSET_ADAPTER_TIMEOUT" + client.execute_chart_data_raw.assert_awaited_once() +# #endregion Test.ScenarioExecution.LiveBinding.Timeout + + +# #region Test.ScenarioExecution.LiveBinding.Persistence [C:2] [TYPE Function] +# @BRIEF Start persists the identity snapshot and walker forwards it to the authorized 037 adapter. +def test_start_persists_binding_and_walker_registers_exact_evidence(seeded_registry): + client = AsyncMock() + client.execute_chart_data_raw.return_value = ChartDataResponse( + parsed={"result": [{"data": {"revenue": 4400}}], "query_id": "q-044"}, + raw_bytes=_RAW_RESPONSE_044, + source_response_hash=_RAW_RESPONSE_SHA_044, + ) + binding = LiveExecutionBinding.from_snapshot(_BINDING_044) + resolver = _Resolver(_resolved(binding, client, _EvidenceStore())) + run = start_run( + seeded_registry, + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222", + {}, + "env-preprod-044", + actor="binding-actor-044", + idempotency_key="live-binding-start-044", + dashboard_release_id="release-044", + live_execution_binding=binding, + auto_advance=False, + ) + assert run.live_execution_binding_ref == "live-binding-044" + assert run.live_execution_binding_snapshot == _BINDING_044 + assert run.target_snapshot["dashboard_release_id"] == "release-044" + run.runner_plan = _superset_plan() + seeded_registry.flush() + + result = _advance_run( + seeded_registry, + run, + _build_default_registry(resolver), + worker_id="binding-actor-044", + ) + + step = seeded_registry.query(ScenarioStepRun).filter_by(run_id=run.id).one() + artifact = seeded_registry.query(ScenarioArtifact).filter_by(owner_id=run.id).one() + assert result["status"] == "passed" + assert step.status == "passed" + assert artifact.content_ref == f"draft:{run.id}:{_RAW_RESPONSE_SHA_044}" + assert artifact.sha256 == _RAW_RESPONSE_SHA_044 + client.execute_chart_data_raw.assert_awaited_once() +# #endregion Test.ScenarioExecution.LiveBinding.Persistence + + +# #region Test.ScenarioExecution.LiveBinding.CompositionStartup [C:2] [TYPE Function] +# @BRIEF Application dependency exposes the one startup-owned root used by default runner composition. +def test_composition_root_is_registered_through_application_dependency(monkeypatch): + import src.dependencies as dependencies + + root = _install_composition_root(monkeypatch) + + assert dependencies.get_live_execution_composition_root() is root +# #endregion Test.ScenarioExecution.LiveBinding.CompositionStartup + + +# #region Test.ScenarioExecution.LiveBinding.Composition037 [C:2] [TYPE Function] +# @BRIEF An application composition registration is discovered by the default runner registry and invokes 037. +def test_composition_root_default_registry_invokes_037(monkeypatch): + client = AsyncMock() + client.execute_chart_data_raw.return_value = ChartDataResponse( + parsed={"result": [{"data": {"revenue": 4400}}], "query_id": "q-044-root"}, + raw_bytes=_RAW_RESPONSE_044, + source_response_hash=_RAW_RESPONSE_SHA_044, + ) + binding = LiveExecutionBinding.from_snapshot(_BINDING_044) + evidence = _EvidenceStore() + root = _install_composition_root(monkeypatch) + root.register_superset(_resolved(binding, client, evidence)) + + outcome = _resolve(_build_default_registry(), "superset_api", "execute_metric")(_STEP_044, {}) + + assert outcome["status"] == "passed" + assert outcome["artifact_refs"] == [f"draft:run-044:{_RAW_RESPONSE_SHA_044}"] + assert outcome["step_outcome"]["artifact_digests"] == { + f"draft:run-044:{_RAW_RESPONSE_SHA_044}": _RAW_RESPONSE_SHA_044 + } + client.execute_chart_data_raw.assert_awaited_once() +# #endregion Test.ScenarioExecution.LiveBinding.Composition037 + + +# #region Test.ScenarioExecution.LiveBinding.CompositionFailClosed [C:2] [TYPE Function] +# @BRIEF Configured-but-unavailable and wrong-bound identity paths make no 037 client call. +def test_composition_root_mismatch_and_unavailable_fail_closed(monkeypatch): + client = AsyncMock() + binding = LiveExecutionBinding.from_snapshot(_BINDING_044) + root = _install_composition_root(monkeypatch) + root.register_superset(_resolved(binding, client, _EvidenceStore())) + wrong_snapshot = {**_BINDING_044, "release_fingerprint": "9" * 64} + + mismatch = _resolve(_build_default_registry(), "superset_api", "execute_metric")( + {**_STEP_044, "live_execution_binding_snapshot": wrong_snapshot}, {} + ) + root = _install_composition_root(monkeypatch) + unavailable_binding = LiveExecutionBinding.from_snapshot( + {**_BROWSER_BINDING_044, "binding_ref": "configured-other-044"} + ) + root.register_screenshot( + unavailable_binding, + lambda _context: LiveAdapterResult(status="passed", reason_code="unused"), + ) + unavailable = _resolve(_build_default_registry(), "screenshot", "capture_screenshot")( + {**_STEP_044, "live_execution_binding_snapshot": _BROWSER_BINDING_044}, {} + ) + + assert mismatch["status"] == "inconclusive" + assert mismatch["error_code"] == "SUPERSET_BINDING_MISMATCH" + assert unavailable["status"] == "inconclusive" + assert unavailable["error_code"] == "SCREENSHOT_BINDING_UNAVAILABLE" + client.execute_chart_data_raw.assert_not_awaited() +# #endregion Test.ScenarioExecution.LiveBinding.CompositionFailClosed + + +# #region Test.ScenarioExecution.LiveBinding.CompositionBrowserScreenshot [C:2] [TYPE Function] +# @BRIEF Browser recovery blocks before provider I/O without its checkpoint; a registered capture returns durable evidence. +def test_composition_root_browser_checkpoint_and_screenshot_evidence(monkeypatch): + binding = LiveExecutionBinding.from_snapshot(_BROWSER_BINDING_044) + root = _install_composition_root(monkeypatch) + browser_calls: list[str] = [] + root.register_browser( + binding, + lambda context: browser_calls.append(context.binding.binding_ref) or LiveAdapterResult(status="passed", reason_code="BROWSER_ACTION_EXECUTED"), + ) + root.register_screenshot( + binding, + lambda context: LiveAdapterResult( + status="passed", + reason_code="SCREENSHOT_CAPTURED", + details={"sha256": _RAW_RESPONSE_SHA_044}, + output_refs=[f"draft:{context.step['scenario_run_id']}:{_RAW_RESPONSE_SHA_044}"], + artifact_refs=[f"draft:{context.step['scenario_run_id']}:{_RAW_RESPONSE_SHA_044}"], + artifact_digests={f"draft:{context.step['scenario_run_id']}:{_RAW_RESPONSE_SHA_044}": _RAW_RESPONSE_SHA_044}, + ), + ) + browser_step = { + **_STEP_044, + "live_execution_binding_ref": binding.binding_ref, + "live_execution_binding_snapshot": binding.snapshot(), + "step_meta": {"recovery_mode": True, "browser_safe_checkpoint_ref": "wrong-checkpoint"}, + } + screenshot_step = {**_STEP_044, "live_execution_binding_snapshot": binding.snapshot()} + + browser_outcome = _resolve(_build_default_registry(), "browser", "open_dashboard")(browser_step, {}) + screenshot_outcome = _resolve(_build_default_registry(), "screenshot", "capture_screenshot")(screenshot_step, {}) + prod_mutation_step = { + **browser_step, + "step_meta": { + "mutation_contract": {"safe_test_fixture_id": "fixture-044"}, + "action_registry_fingerprint": "registry-044", + }, + "target_snapshot": { + "environment_id": "env-preprod-044", + "dashboard_release_id": "release-044", + "environment_class": "PROD", + }, + } + prod_mutation = _resolve(_build_default_registry(), "browser", "row_edit")(prod_mutation_step, {}) + + assert browser_outcome["status"] == "inconclusive" + assert browser_outcome["error_code"] == "BROWSER_SAFE_CHECKPOINT_REQUIRED" + assert browser_calls == [] + assert screenshot_outcome["status"] == "passed" + assert screenshot_outcome["artifact_refs"] == [f"draft:run-044:{_RAW_RESPONSE_SHA_044}"] + assert prod_mutation["status"] == "failed" + assert prod_mutation["error_code"] == "BROWSER_MUTATION_PROD_REJECTED" + assert browser_calls == [] +# #endregion Test.ScenarioExecution.LiveBinding.CompositionBrowserScreenshot + + +# #region Test.ScenarioExecution.LiveBinding.LifespanBootstrap [C:3] [TYPE Function] +# @BRIEF App startup bootstrap registers trusted config before the default queued dispatcher reaches 037. +def test_lifespan_bootstrap_configured_binding_dispatches_queued_run(seeded_registry, monkeypatch): + from src import app as application + from src.services.dashboard_testing.execution import live_composition + + client = AsyncMock() + client.execute_chart_data_raw.return_value = ChartDataResponse( + parsed={"result": [{"data": {"revenue": 4400}}], "query_id": "q-044-lifespan"}, + raw_bytes=_RAW_RESPONSE_044, + source_response_hash=_RAW_RESPONSE_SHA_044, + ) + binding = LiveExecutionBinding.from_snapshot(_BINDING_044) + evidence = _EvidenceStore() + configuration = SimpleNamespace( + get_config=lambda: SimpleNamespace(settings=SimpleNamespace( + scenario_live_execution_bindings=[ScenarioLiveExecutionBindingConfig( + enabled=True, + binding_snapshot=binding.snapshot(), + query_model_snapshot=_query_model().model_dump(mode="json"), + )], + )), + get_environment=lambda environment_id: ( + SimpleNamespace(id=environment_id) if environment_id == "env-preprod-044" else None + ), + ) + root = _install_composition_root(monkeypatch) + monkeypatch.setattr(application, "get_config_manager", lambda: configuration) + monkeypatch.setattr(application, "get_live_execution_composition_root", lambda: root) + monkeypatch.setattr(application, "get_async_job_runner", lambda: SimpleNamespace(run=asyncio.run)) + monkeypatch.setattr(live_composition, "SupersetClient", lambda _environment: client) + monkeypatch.setattr(live_composition, "get_draft_storage", lambda: evidence) + + assert application.initialize_live_execution_composition() == 1 + run = start_run( + seeded_registry, + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222", + {}, + "env-preprod-044", + actor="binding-actor-044", + idempotency_key="lifespan-bootstrap-044", + dashboard_release_id="release-044", + live_execution_binding=binding, + ) + run.runner_plan = _superset_plan() + seeded_registry.flush() + + outcomes = dispatch_queued_runs(seeded_registry, worker_id="lifespan-044") + artifact = seeded_registry.query(ScenarioArtifact).filter_by(owner_id=run.id).one() + + assert outcomes[0]["status"] == "passed" + assert artifact.content_ref == f"draft:{run.id}:{_RAW_RESPONSE_SHA_044}" + assert artifact.sha256 == _RAW_RESPONSE_SHA_044 + client.execute_chart_data_raw.assert_awaited_once() +# #endregion Test.ScenarioExecution.LiveBinding.LifespanBootstrap + + +# #region Test.ScenarioExecution.LiveBinding.LifespanFailClosed [C:3] [TYPE Function] +# @BRIEF Bootstrap retains configured unavailable and mismatched bindings as no-I/O typed outcomes. +def test_lifespan_bootstrap_mismatch_and_unavailable_are_no_call(monkeypatch): + from src import app as application + from src.services.dashboard_testing.execution import live_composition + + binding = LiveExecutionBinding.from_snapshot(_BINDING_044) + client = AsyncMock() + root = _install_composition_root(monkeypatch) + configuration = SimpleNamespace( + get_config=lambda: SimpleNamespace(settings=SimpleNamespace( + scenario_live_execution_bindings=[ScenarioLiveExecutionBindingConfig( + enabled=True, + binding_snapshot=binding.snapshot(), + query_model_snapshot=_query_model().model_dump(mode="json"), + )], + )), + get_environment=lambda _environment_id: SimpleNamespace(id="env-preprod-044"), + ) + monkeypatch.setattr(application, "get_config_manager", lambda: configuration) + monkeypatch.setattr(application, "get_live_execution_composition_root", lambda: root) + monkeypatch.setattr(application, "get_async_job_runner", lambda: SimpleNamespace(run=asyncio.run)) + monkeypatch.setattr(live_composition, "SupersetClient", lambda _environment: client) + monkeypatch.setattr(live_composition, "get_draft_storage", _EvidenceStore) + + assert application.initialize_live_execution_composition() == 1 + mismatch = _resolve(_build_default_registry(), "superset_api", "execute_metric")( + {**_STEP_044, "live_execution_binding_snapshot": {**_BINDING_044, "rls_security_fingerprint": "7" * 64}}, + {}, + ) + unavailable_binding = {**_BINDING_044, "binding_ref": "unavailable-044"} + unavailable = _resolve(_build_default_registry(), "superset_api", "execute_metric")( + {**_STEP_044, "live_execution_binding_ref": "unavailable-044", "live_execution_binding_snapshot": unavailable_binding}, + {}, + ) + browser_unavailable = _resolve(_build_default_registry(), "browser", "open_dashboard")(_STEP_044, {}) + screenshot_unavailable = _resolve(_build_default_registry(), "screenshot", "capture_screenshot")(_STEP_044, {}) + + assert mismatch["status"] == "inconclusive" + assert mismatch["error_code"] == "SUPERSET_BINDING_MISMATCH" + assert unavailable["status"] == "inconclusive" + assert unavailable["error_code"] == "SUPERSET_BINDING_UNAVAILABLE" + assert browser_unavailable["error_code"] == "BROWSER_BINDING_UNAVAILABLE" + assert screenshot_unavailable["error_code"] == "SCREENSHOT_BINDING_UNAVAILABLE" + client.execute_chart_data_raw.assert_not_awaited() +# #endregion Test.ScenarioExecution.LiveBinding.LifespanFailClosed + +# #endregion Test.ScenarioExecution.LiveBinding diff --git a/backend/tests/services/dashboard_testing/registry/test_scenario_auto_queue.py b/backend/tests/services/dashboard_testing/registry/test_scenario_auto_queue.py new file mode 100644 index 000000000..0046a73ad --- /dev/null +++ b/backend/tests/services/dashboard_testing/registry/test_scenario_auto_queue.py @@ -0,0 +1,30 @@ +# #region Test.ScenarioExecution.AutoQueue [C:3] [TYPE Module] [SEMANTICS test,scenario,execution,autoqueue,failed,investigation] +# @defgroup ScenarioExecution Auto-queue failed runs into investigation queue. +# @LAYER Test +# @RELATION BINDS_TO -> [ScenarioAnalytics.Investigation.AutoQueue] +# @TEST_EDGE failed_run_same_env -> duplicate fingerprint increments count; passed_run_no_queue -> no item +from __future__ import annotations + +from src.models.scenario_run import ScenarioRun +from src.services.dashboard_testing.analytics.investigation import auto_queue_failed_run + + +def test_failed_run_auto_queues_investigation(seeded_execution): + run = seeded_execution.query(ScenarioRun).filter(ScenarioRun.scenario_id == "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1").first() + run.status = "failed" + run.phase = "terminal" + run.finished_at = __import__("datetime").datetime.now(__import__("datetime").UTC) + seeded_execution.flush() + auto_queue_failed_run( + seeded_execution, + scenario_id=run.scenario_id, + run_id=run.id, + environment_id=run.environment_id, + ) + seeded_execution.commit() + from src.models.scenario_investigation import InvestigationQueueItem + item = seeded_execution.query(InvestigationQueueItem).filter(InvestigationQueueItem.scenario_id == run.scenario_id).first() + assert item is not None + assert item.fingerprint == f"{run.scenario_id}:{run.environment_id}" + assert item.run_id == run.id +# #endregion Test.ScenarioExecution.AutoQueue diff --git a/backend/tests/services/dashboard_testing/registry/test_scenario_automation_trigger.py b/backend/tests/services/dashboard_testing/registry/test_scenario_automation_trigger.py index 3c23b1535..4a3d95fc8 100644 --- a/backend/tests/services/dashboard_testing/registry/test_scenario_automation_trigger.py +++ b/backend/tests/services/dashboard_testing/registry/test_scenario_automation_trigger.py @@ -1,17 +1,26 @@ # #region Test.ScenarioAutomation.Trigger [C:3] [TYPE Module] [SEMANTICS test,scenario,automation,trigger,event] # @BRIEF Verify 046 trigger semantics (T003/T005): typed events map to pinned run # candidates, non-matching/disabled rules are skipped, and policy gates -# (capacity / PROD / dedup) reject candidates before they are returned. +# (capacity / dedup) reject candidates before they are returned; 044 resolves PROD from +# ConfigManager at the durable start boundary. # @RELATION BINDS_TO -> [ScenarioAutomation.Trigger] +# @RELATION VERIFIES -> [ScenarioAutomation.Trigger.Dispatch] +# @RELATION VERIFIES -> [ScenarioExecution.EnvironmentPolicy.Resolve] # @TEST_EDGE: event->run -> matched rule yields pinned scenario/revision/env candidate # @TEST_EDGE: release_create->run -> release_create event maps to a run candidate # @TEST_EDGE: ETL->run -> etl_completed event maps to a run candidate # @TEST_EDGE: disabled_rule_skipped -> rule with enabled=False produces no candidate # @TEST_EDGE: event_type_mismatch_skipped -> non-matching event_type produces no candidate # @TEST_EDGE: capacity_blocked -> active run in env rejects candidate via max_concurrent_per_env -# @TEST_EDGE: prod_gate_blocked -> PROD-class rule never auto-starts without approval +# @TEST_EDGE: stale_rule_environment_class -> rule metadata cannot classify PROD # @TEST_EDGE: dedup_blocked -> recent duplicate fingerprint rejects candidate -from src.services.dashboard_testing.automation.trigger import handle_trigger_event +# @TEST_INVARIANT ScenarioAutomation.Trigger: A real event source is passed to 044 before +# ScenarioRun creation; it is never post-create provenance mutation. +# @TEST_INVARIANT ScenarioExecution.EnvironmentPolicy: Event/rule data carries target identity, +# never PROD authority; 044 resolves the configured class at its common durable +# start boundary. -> VERIFIED_BY: test_trigger_rule_environment_class_does_not_decide_prod_authority, +# test_dispatch_trigger_event_calls_run_boundary_with_pinned_current_revision +from src.services.dashboard_testing.automation.trigger import dispatch_trigger_event, handle_trigger_event # #region Test.ScenarioAutomation.Trigger.Mapping [C:2] [TYPE Function] [SEMANTICS test,scenario,automation,trigger] @@ -34,7 +43,7 @@ def test_trigger_event_maps_matched_rule_to_pinned_candidate(): assert candidate["environment_id"] == "env-preprod-01" assert candidate["trigger_source"] == "release_create" assert candidate["dedup_fingerprint"] == "rel-42" - assert candidate["is_prod"] is False + assert "is_prod" not in candidate def test_trigger_event_etl_completed_maps_candidate_with_current_revision(): @@ -118,7 +127,7 @@ def test_trigger_blocks_candidate_at_capacity(): assert handle_trigger_event(event, [rule], active, {"max_concurrent_per_env": 1}) == [] -def test_trigger_blocks_prod_rule_without_approval(): +def test_trigger_rule_environment_class_does_not_decide_prod_authority(): event = {"type": "release_create", "fingerprint": "rel-77"} rule = { "enabled": True, @@ -129,7 +138,8 @@ def test_trigger_blocks_prod_rule_without_approval(): "environment_class": "PROD", } candidates = handle_trigger_event(event, [rule], [], {"max_concurrent_per_env": 1}) - assert candidates == [] # PROD candidates require the approval path; never auto-started + assert len(candidates) == 1 + assert "is_prod" not in candidates[0] def test_trigger_blocks_duplicate_fingerprint_in_window(): @@ -143,5 +153,36 @@ def test_trigger_blocks_duplicate_fingerprint_in_window(): } recent = [{"environment_id": "env-preprod-01", "status": "passed", "dedup_fingerprint": "rel-42"}] assert handle_trigger_event(event, [rule], recent, {"max_concurrent_per_env": 5, "dedup_window_seconds": 0}) == [] + + +def test_dispatch_trigger_event_calls_run_boundary_with_pinned_current_revision(seeded_registry): + from src.models.scenario_automation import ScenarioTriggerRule + from src.models.scenario_registry import ScenarioRegistryEntry + + entry = seeded_registry.query(ScenarioRegistryEntry).filter_by( + scenario_id="11111111-1111-4111-8111-111111111111" + ).one() + entry.current_revision_id = "22222222-2222-4222-8222-222222222222" + + seeded_registry.add(ScenarioTriggerRule( + id="trigger-rule-1", + trigger="release_created", + scenario_id="11111111-1111-4111-8111-111111111111", + revision_policy="current", + environment_id="preprod", + enabled=True, + )) + calls = [] + + def start_stub(_db, **kwargs): + calls.append(kwargs) + return type("Run", (), {"id": "created-run", "trigger_source": "manual"})() + + created = dispatch_trigger_event(seeded_registry, {"type": "release_created", "fingerprint": "release-1"}, start_run_fn=start_stub) + assert created == ["created-run"] + assert calls[0]["revision_id"] == "22222222-2222-4222-8222-222222222222" + assert calls[0]["params"]["automation_fingerprint"] == "release-1" + assert calls[0]["trigger_source"] == "release_created" + assert calls[0]["config_manager"].get_environment("preprod").stage == "PREPROD" # #endregion Test.ScenarioAutomation.Trigger.Policy # #endregion Test.ScenarioAutomation.Trigger diff --git a/backend/tests/services/dashboard_testing/registry/test_scenario_cancel_timeout.py b/backend/tests/services/dashboard_testing/registry/test_scenario_cancel_timeout.py new file mode 100644 index 000000000..0cf280950 --- /dev/null +++ b/backend/tests/services/dashboard_testing/registry/test_scenario_cancel_timeout.py @@ -0,0 +1,437 @@ +# #region Test.ScenarioExecution.CancelTimeout [C:5] [TYPE Module] [SEMANTICS test,scenario,execution,cancel,timeout,lease,artifact,signal] +# @BRIEF Prove real persisted cancel/drain and timeout transitions leave no active work stranded. +# @RELATION BINDS_TO -> [ScenarioExecution.Lifecycle.Cancel] +# @RELATION BINDS_TO -> [ScenarioExecution.Lifecycle.Timeout] +# @RELATION BINDS_TO -> [ScenarioExecution.Lifecycle.TimeoutClosure] +# @RELATION BINDS_TO -> [ScenarioExecution.Runner.Walker] +# @TEST_CONTRACT: persisted running step + controlled external adapter callback -> terminal closure +# @TEST_FIXTURE: cancel_timeout_044 -> INLINE_JSON hardcoded DAG, evidence refs, and digests +# @TEST_INVARIANT ScenarioExecution.Lifecycle.Cancel: A cancelled terminal has no queued/running +# step, active lease, or active evidence projection; immutable rows remain audit +# history. -> VERIFIED_BY: test_cancel_before_dispatch_retires_active_projection, +# test_cancel_during_claimed_adapter_drains_then_terminalizes, +# test_cancel_expires_prod_gate_and_cancels_human_checkpoint, +# test_cancel_deadline_finalizes_unresponsive_claim +# @TEST_INVARIANT ScenarioExecution.Lifecycle.Timeout: A timeout during claimed adapter I/O wins +# over returned payload, blocks descendants, expires leases, retires partial +# evidence, and emits exactly one typed inconclusive terminal signal. -> VERIFIED_BY: +# test_timeout_during_claimed_adapter_terminalizes_without_stranded_state, +# test_timeout_materializes_lazy_descendant_and_walker_never_dispatches +# @TEST_INVARIANT ScenarioExecution.Lifecycle.TimeoutClosure: Each descendant absent from the +# persisted step rows is materialized from the pinned plan as blocked before a +# later walker observes the terminal run; its executor is never dispatched. -> VERIFIED_BY: +# test_timeout_materializes_lazy_descendant_and_walker_never_dispatches +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +import pytest + +from src.models.scenario_approval import ActionApprovalGate +from src.models.scenario_artifact import ScenarioArtifact +from src.models.scenario_checkpoint import HumanCheckpoint +from src.models.scenario_investigation import InvestigationQueueItem +from src.models.scenario_run import ScenarioRun, ScenarioStepRun +from src.models.scenario_worker import ScenarioStepLease +from src.services.dashboard_testing.execution.approval import decide_approval_gate +from src.services.dashboard_testing.execution.artifacts import register_artifact +from src.services.dashboard_testing.execution.executor_registry import ScenarioExecutorRegistry +from src.services.dashboard_testing.execution.lifecycle import ( + apply_step_timeout, + cancel_run, + decide_checkpoint, + finalize_expired_cancellations, +) +from src.services.dashboard_testing.execution.result import build_result +from src.services.dashboard_testing.execution.runner import _advance_run +from src.services.dashboard_testing.execution.worker import claim_step +from src.services.dashboard_testing.scenario.templates import ( + ACTION_REGISTRY_VERSION, + action_registry_fingerprint, + resolve_action_descriptor, +) + +_SCENARIO_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1" +_REVISION_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1" +_CANCEL_RUN = "80440000-0000-4000-8000-000000000004" +_TIMEOUT_RUN = "80440000-0000-4000-8000-000000000005" +_CANCEL_SHA = "c" * 64 +_TIMEOUT_SHA = "d" * 64 + + +def _valid_plan(step_ids: list[str], dependencies: list[dict]) -> dict: + descriptor = resolve_action_descriptor( + tool="assertion", action="structural_assert", registry_version=ACTION_REGISTRY_VERSION, + registry_hash=action_registry_fingerprint(), + ).snapshot() + return { + "action_registry_version": ACTION_REGISTRY_VERSION, + "action_registry_hash": action_registry_fingerprint(), + "topological_order": step_ids, + "dependencies": dependencies, + "steps": [{ + "logical_step_id": step_id, "tool": "assertion", "action": "structural_assert", + "action_descriptor": descriptor, + } for step_id in step_ids], + } + + +def _run(run_id: str, plan: dict) -> ScenarioRun: + return ScenarioRun( + id=run_id, + scenario_id=_SCENARIO_ID, + scenario_revision_id=_REVISION_ID, + scenario_content_hash="d" * 64, + environment_id="env-cancel-timeout-044", + status="queued", + phase="executing", + parameter_bindings={"fixture": "cancel-timeout-044"}, + target_snapshot={"environment_id": "env-cancel-timeout-044"}, + trigger_source="manual", + idempotency_key=run_id, + runner_plan=plan, + execution_principal_fingerprint="e" * 64, + ) + + +def _expired(lease: ScenarioStepLease) -> bool: + expires_at = lease.expires_at + expires_at = expires_at.replace(tzinfo=UTC) if expires_at.tzinfo is None else expires_at.astimezone(UTC) + return expires_at <= datetime.now(UTC) + + +# #region Test.ScenarioExecution.CancelTimeout.BeforeDispatch [C:4] [TYPE Function] +# @BRIEF Cancelling queued work terminalizes immediately and retires its current evidence projection. +def test_cancel_before_dispatch_retires_active_projection(seeded_execution): + run = _run(_CANCEL_RUN, {"topological_order": ["queued-cancel"], "dependencies": [], "steps": []}) + step = ScenarioStepRun( + run_id=run.id, + logical_step_id="queued-cancel", + step_position=0, + attempt=1, + status="queued", + artifact_refs=["evidence:queued-cancel"], + ) + seeded_execution.add_all([run, step]) + seeded_execution.flush() + artifact = register_artifact( + seeded_execution, + owner_type="scenario_run", + owner_id=run.id, + kind="evidence", + name="queued-cancel-evidence", + content_ref="evidence:queued-cancel", + sha256=_CANCEL_SHA, + logical_step_id=step.logical_step_id, + attempt=1, + ) + + cancelled = cancel_run(seeded_execution, run.id) + assert cancelled.status == "cancelled" + assert cancelled.phase == "terminal" + assert step.status == "skipped" + assert step.artifact_refs == [] + assert step.outputs["terminal_history"][0]["artifact_refs"] == ["evidence:queued-cancel"] + assert artifact.is_active is False + assert artifact.invalidated_at is not None + assert build_result(cancelled, [step])["status"] == "cancelled" + assert cancel_run(seeded_execution, run.id).id == run.id +# #endregion Test.ScenarioExecution.CancelTimeout.BeforeDispatch + + +# #region Test.ScenarioExecution.CancelTimeout.Deadline [C:5] [TYPE Function] +# @BRIEF A persisted deadline closes a genuinely unresponsive claimed step without relying on adapter return. +# @TEST_INVARIANT ScenarioExecution.Lifecycle.CancelFinalizer: A bounded drain cannot strand a +# running lease or active evidence after the persisted deadline. -> VERIFIED_BY: +# test_cancel_deadline_finalizes_unresponsive_claim +def test_cancel_deadline_finalizes_unresponsive_claim(seeded_execution): + run = _run( + "80440000-0000-4000-8000-000000000008", + {"topological_order": ["unresponsive-044"], "dependencies": [], "steps": []}, + ) + step = ScenarioStepRun( + run_id=run.id, + logical_step_id="unresponsive-044", + step_position=0, + attempt=1, + status="running", + artifact_refs=["evidence:unresponsive"], + ) + seeded_execution.add_all([run, step]) + seeded_execution.flush() + artifact = register_artifact( + seeded_execution, + owner_type="scenario_run", + owner_id=run.id, + kind="evidence", + name="unresponsive-evidence", + content_ref="evidence:unresponsive", + sha256=_CANCEL_SHA, + logical_step_id=step.logical_step_id, + attempt=1, + ) + lease = claim_step( + seeded_execution, + run.id, + step.logical_step_id, + worker_id="unresponsive-worker", + side_effect_key=None, + idempotent=True, + retry_safe=True, + ) + + requested = cancel_run(seeded_execution, run.id, drain_seconds=1) + assert requested.status == "cancel_requested" + assert requested.cancel_drain_deadline_at is not None + assert step.status == "running" + assert not _expired(lease) + + terminalized = finalize_expired_cancellations( + seeded_execution, + now=requested.cancel_drain_deadline_at + timedelta(seconds=1), + ) + assert [item.id for item in terminalized] == [run.id] + assert run.status == "cancelled" + assert step.status == "cancelled" + assert step.artifact_refs == [] + assert artifact.is_active is False + assert _expired(lease) + assert finalize_expired_cancellations( + seeded_execution, + now=requested.cancel_drain_deadline_at + timedelta(seconds=2), + ) == [] +# #endregion Test.ScenarioExecution.CancelTimeout.Deadline + + +# #region Test.ScenarioExecution.CancelTimeout.Controls [C:4] [TYPE Function] +# @BRIEF Cancellation retires, rather than decides, the distinct human observation and PROD authorization controls. +# @TEST_INVARIANT ScenarioExecution.Lifecycle.Cancel: A cancelled run cannot later be revived by a +# stale checkpoint decision or pending PROD approval gate. -> VERIFIED_BY: +# test_cancel_expires_prod_gate_and_cancels_human_checkpoint +def test_cancel_expires_prod_gate_and_cancels_human_checkpoint(seeded_execution): + human_run = _run( + "80440000-0000-4000-8000-000000000006", + {"topological_order": [], "dependencies": [], "steps": []}, + ) + human_run.status = "waiting_human" + human_run.phase = "waiting_human" + checkpoint = HumanCheckpoint( + run_id=human_run.id, + logical_step_id="human-cancel-044", + evidence_refs=["evidence:human-cancel"], + ) + prod_run = _run( + "80440000-0000-4000-8000-000000000007", + {"topological_order": [], "dependencies": [], "steps": []}, + ) + prod_run.status = "pending_approval" + prod_run.phase = "preflight" + gate = ActionApprovalGate( + owner_type="scenario_run", + owner_id=prod_run.id, + request_hash="f" * 64, + status="pending", + ) + seeded_execution.add_all([human_run, prod_run]) + seeded_execution.flush() + seeded_execution.add_all([checkpoint, gate]) + seeded_execution.flush() + + assert cancel_run(seeded_execution, human_run.id).status == "cancelled" + assert checkpoint.status == "cancelled" + with pytest.raises(ValueError, match="stale checkpoint decision"): + decide_checkpoint( + seeded_execution, + checkpoint.id, + disposition="confirm", + expected_version=checkpoint.decision_version, + actor_id="analyst-cancel-044", + ) + + assert cancel_run(seeded_execution, prod_run.id).status == "cancelled" + assert gate.status == "expired" + with pytest.raises(ValueError, match="already decided"): + decide_approval_gate( + seeded_execution, + gate.id, + decision="approve", + actor_id="approver-cancel-044", + ) +# #endregion Test.ScenarioExecution.CancelTimeout.Controls + + +# #region Test.ScenarioExecution.CancelTimeout.Drain [C:5] [TYPE Function] +# @BRIEF A controlled adapter requests cancel while holding a real worker lease; its one returned +# completion drains, then the runner terminalizes all remaining pending work as cancelled. +def test_cancel_during_claimed_adapter_drains_then_terminalizes(seeded_execution): + plan = _valid_plan( + ["inflight-cancel", "queued-after-cancel"], + [{"source": "inflight-cancel", "target": "queued-after-cancel"}], + ) + run = _run(_CANCEL_RUN, plan) + queued = ScenarioStepRun( + run_id=run.id, + logical_step_id="queued-after-cancel", + step_position=1, + attempt=1, + status="queued", + ) + seeded_execution.add_all([run, queued]) + seeded_execution.flush() + registry = ScenarioExecutorRegistry() + + def adapter(_step, _completed): + lease = seeded_execution.query(ScenarioStepLease).filter_by( + run_id=run.id, logical_step_id="inflight-cancel", + ).one() + assert not _expired(lease) + assert cancel_run(seeded_execution, run.id, drain_in_flight=True).status == "cancel_requested" + return { + "status": "passed", + "step_outcome": {"status": "passed", "artifact_digests": {"evidence:drained": _CANCEL_SHA}}, + "artifact_refs": ["evidence:drained"], + } + + registry.register("assertion", adapter, action="structural_assert") + result = _advance_run(seeded_execution, run, registry, worker_id="cancel-drain-worker") + steps = { + row.logical_step_id: row + for row in seeded_execution.query(ScenarioStepRun).filter_by(run_id=run.id).all() + } + lease = seeded_execution.query(ScenarioStepLease).filter_by(run_id=run.id, logical_step_id="inflight-cancel").one() + artifacts = seeded_execution.query(ScenarioArtifact).filter_by(owner_id=run.id).all() + assert result["status"] == "cancelled" + assert run.status == "cancelled" + assert steps["inflight-cancel"].status == "passed" + assert steps["inflight-cancel"].step_outcome == {} + assert steps["inflight-cancel"].outputs["terminal_history"][0]["step_outcome"]["status"] == "passed" + assert steps["inflight-cancel"].artifact_refs == [] + assert steps["queued-after-cancel"].status == "skipped" + assert _expired(lease) + assert len(artifacts) == 1 and artifacts[0].is_active is False + assert seeded_execution.query(InvestigationQueueItem).filter_by(run_id=run.id).count() == 0 +# #endregion Test.ScenarioExecution.CancelTimeout.Drain + + +# #region Test.ScenarioExecution.CancelTimeout.AdapterTimeout [C:5] [TYPE Function] +# @BRIEF A real claimed adapter invokes the timeout boundary; its later PASS payload cannot overwrite timeout truth. +def test_timeout_during_claimed_adapter_terminalizes_without_stranded_state(seeded_execution): + plan = _valid_plan( + ["inflight-timeout", "blocked-after-timeout"], + [{"source": "inflight-timeout", "target": "blocked-after-timeout"}], + ) + run = _run(_TIMEOUT_RUN, plan) + timeout_step = ScenarioStepRun( + run_id=run.id, + logical_step_id="inflight-timeout", + step_position=0, + attempt=1, + status="queued", + artifact_refs=["evidence:partial-timeout"], + ) + descendant = ScenarioStepRun( + run_id=run.id, + logical_step_id="blocked-after-timeout", + step_position=1, + attempt=1, + status="queued", + ) + seeded_execution.add_all([run, timeout_step, descendant]) + seeded_execution.flush() + partial = register_artifact( + seeded_execution, + owner_type="scenario_run", + owner_id=run.id, + kind="evidence", + name="partial-timeout-evidence", + content_ref="evidence:partial-timeout", + sha256=_TIMEOUT_SHA, + logical_step_id=timeout_step.logical_step_id, + attempt=1, + ) + registry = ScenarioExecutorRegistry() + + def adapter(step, _completed): + lease = seeded_execution.query(ScenarioStepLease).filter_by( + run_id=run.id, logical_step_id="inflight-timeout", + ).one() + assert not _expired(lease) + apply_step_timeout(seeded_execution, run.id, step["logical_step_id"], timeout_ms=5) + return {"status": "passed", "step_outcome": {"status": "passed"}, "artifact_refs": []} + + registry.register("assertion", adapter, action="structural_assert") + result = _advance_run(seeded_execution, run, registry, worker_id="timeout-worker") + lease = seeded_execution.query(ScenarioStepLease).filter_by(run_id=run.id, logical_step_id="inflight-timeout").one() + signals = seeded_execution.query(InvestigationQueueItem).filter_by(run_id=run.id).all() + assert result["status"] == "inconclusive" + assert run.status == "inconclusive" + assert timeout_step.status == "inconclusive" + assert timeout_step.error_code == "STEP_TIMEOUT" + assert timeout_step.artifact_refs == [] + assert descendant.status == "blocked" + assert descendant.error_code == "UPSTREAM_TIMEOUT" + assert partial.is_active is False + assert _expired(lease) + assert len(signals) == 1 + assert signals[0].evidence_snapshot["evidence"] == [] + assert build_result(run, [timeout_step, descendant])["status"] == "inconclusive" + + assert apply_step_timeout(seeded_execution, run.id, timeout_step.logical_step_id, timeout_ms=5).id == timeout_step.id + assert seeded_execution.query(InvestigationQueueItem).filter_by(run_id=run.id).count() == 1 +# #endregion Test.ScenarioExecution.CancelTimeout.AdapterTimeout + + +# #region Test.ScenarioExecution.CancelTimeout.LazyDescendant [C:5] [TYPE Function] +# @BRIEF A timeout persists an absent declared descendant as blocked before any later walker can materialize it. +# @TEST_INVARIANT ScenarioExecution.Lifecycle.Timeout: Every declared timeout descendant has a +# pinned-plan blocked row, so recovery/continuation cannot lazy-dispatch the +# downstream executor. -> VERIFIED_BY: +# test_timeout_materializes_lazy_descendant_and_walker_never_dispatches +def test_timeout_materializes_lazy_descendant_and_walker_never_dispatches(seeded_execution): + plan = _valid_plan( + ["timeout-source-044", "lazy-descendant-044"], + [{"source": "timeout-source-044", "target": "lazy-descendant-044"}], + ) + run = _run("80440000-0000-4000-8000-000000000009", plan) + source = ScenarioStepRun( + run_id=run.id, + logical_step_id="timeout-source-044", + step_position=0, + attempt=1, + status="running", + ) + seeded_execution.add_all([run, source]) + seeded_execution.flush() + assert seeded_execution.query(ScenarioStepRun).filter_by( + run_id=run.id, + logical_step_id="lazy-descendant-044", + ).first() is None + + apply_step_timeout(seeded_execution, run.id, source.logical_step_id, timeout_ms=5) + descendant = seeded_execution.query(ScenarioStepRun).filter_by( + run_id=run.id, + logical_step_id="lazy-descendant-044", + ).one() + assert descendant.step_position == 1 + assert descendant.attempt == 1 + assert descendant.status == "blocked" + assert descendant.error_code == "UPSTREAM_TIMEOUT" + assert descendant.step_outcome == { + "status": "blocked", + "error_code": "UPSTREAM_TIMEOUT", + "blocked_by": "timeout-source-044", + } + assert descendant.inputs_snapshot["runner_plan_step"]["tool"] == "assertion" + + calls: list[str] = [] + registry = ScenarioExecutorRegistry() + + def must_not_dispatch(step, _completed): + calls.append(step["logical_step_id"]) + return {"status": "passed", "step_outcome": {"status": "passed"}, "artifact_refs": []} + + registry.register("assertion", must_not_dispatch, action="structural_assert") + result = _advance_run(seeded_execution, run, registry, worker_id="timeout-lazy-continuation") + assert result["status"] == "inconclusive" + assert calls == [] +# #endregion Test.ScenarioExecution.CancelTimeout.LazyDescendant +# #endregion Test.ScenarioExecution.CancelTimeout diff --git a/backend/tests/services/dashboard_testing/registry/test_scenario_crash_recovery.py b/backend/tests/services/dashboard_testing/registry/test_scenario_crash_recovery.py new file mode 100644 index 000000000..7310497d1 --- /dev/null +++ b/backend/tests/services/dashboard_testing/registry/test_scenario_crash_recovery.py @@ -0,0 +1,221 @@ +# #region Test.ScenarioExecution.CrashRecovery [C:5] [TYPE Module] [SEMANTICS test,scenario,execution,recovery,crash,lease,browser,artifact] +# @BRIEF Prove persisted server-driven crash recovery resumes only safe frontier work from the pinned plan. +# @RELATION BINDS_TO -> [ScenarioExecution.Runner.CrashRecovery] +# @RELATION BINDS_TO -> [ScenarioExecution.Worker.Claim] +# @RELATION BINDS_TO -> [ScenarioExecution.Artifacts.InvalidateStepEvidence] +# @RELATION BINDS_TO -> [ScenarioExecution.Runner.TerminalSignal] +# @TEST_CONTRACT: expired persisted worker claim + pinned ScenarioRun -> safe recovery or typed non-pass +# @TEST_FIXTURE: crash_recovery_044 -> INLINE SQLite hardcoded DAG, leases, and evidence refs +# @TEST_INVARIANT ScenarioExecution.Runner.CrashRecovery: Completed ancestors are never rerun; +# only an expired idempotent/retry-safe claim creates one new frontier attempt. +# -> VERIFIED_BY: test_safe_claim_recovery_replays_only_frontier_once +# @TEST_INVARIANT ScenarioExecution.Runner.CrashRecovery: Unsafe effects require reconciliation and +# browser work without a pinned safe checkpoint is non-pass without adapter I/O. +# -> VERIFIED_BY: test_unsafe_claim_requires_reconciliation, +# test_browser_recovery_requires_declared_safe_checkpoint +# @TEST_INVARIANT ScenarioExecution.Runner.CrashRecovery.ArchiveAttempt: A safe replay archives +# the abandoned evidence and retires only its active projection before attempt two. +# -> VERIFIED_BY: test_safe_claim_recovery_replays_only_frontier_once +# @TEST_INVARIANT ScenarioExecution.Runner.CrashRecovery.Reject: Repeating an identical unsafe +# recovery rejection reuses one immutable terminal signal and never dispatches it. +# -> VERIFIED_BY: test_unsafe_claim_requires_reconciliation +# @TEST_INVARIANT ScenarioExecution.Runner.CrashRecovery.BrowserCheckpoint: Browser work without +# a pinned safe checkpoint is inconclusive before any browser adapter call. -> VERIFIED_BY: +# test_browser_recovery_requires_declared_safe_checkpoint +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from src.models.scenario_investigation import InvestigationQueueItem +from src.models.scenario_run import ScenarioRun, ScenarioStepRun +from src.services.dashboard_testing.execution.artifacts import register_artifact +from src.services.dashboard_testing.execution.executor_registry import ScenarioExecutorRegistry +from src.services.dashboard_testing.execution.runner import recover_run +from src.services.dashboard_testing.execution.worker import claim_step +from src.services.dashboard_testing.scenario.templates import ( + ACTION_REGISTRY_VERSION, + action_registry_fingerprint, + resolve_action_descriptor, +) + +_SCENARIO_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1" +_REVISION_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1" +_RECOVERY_SHA = "a" * 64 + + +def _step(step_id: str, tool: str, action: str, **extra) -> dict: + return { + "logical_step_id": step_id, "tool": tool, "action": action, + "action_descriptor": resolve_action_descriptor( + tool=tool, action=action, registry_version=ACTION_REGISTRY_VERSION, + registry_hash=action_registry_fingerprint(), + ).snapshot(), + **extra, + } + + +def _plan(steps: list[dict], dependencies: list[dict]) -> dict: + return { + "action_registry_version": ACTION_REGISTRY_VERSION, + "action_registry_hash": action_registry_fingerprint(), + "topological_order": [step["logical_step_id"] for step in steps], + "dependencies": dependencies, + "steps": steps, + } + + +def _run(run_id: str, plan: dict) -> ScenarioRun: + return ScenarioRun( + id=run_id, + scenario_id=_SCENARIO_ID, + scenario_revision_id=_REVISION_ID, + scenario_content_hash="b" * 64, + environment_id="env-crash-recovery-044", + status="running", + phase="executing", + parameter_bindings={"fixture": "crash-recovery-044"}, + target_snapshot={"environment_id": "env-crash-recovery-044"}, + trigger_source="manual", + idempotency_key=run_id, + runner_plan=plan, + execution_principal_fingerprint="c" * 64, + ) + + +def _expire(lease) -> None: + lease.expires_at = datetime.now(UTC) - timedelta(seconds=1) + + +# #region Test.ScenarioExecution.CrashRecovery.Safe [C:5] [TYPE Function] +# @BRIEF An expired retry-safe claim is archived, evidence-retired, and dispatched exactly once as attempt two. +def test_safe_claim_recovery_replays_only_frontier_once(seeded_execution): + plan = _plan( + [ + _step("completed-ancestor-044", "assertion", "structural_assert"), + _step("safe-frontier-044", "assertion", "structural_assert"), + ], + [{"source": "completed-ancestor-044", "target": "safe-frontier-044"}], + ) + run = _run("80440000-0000-4000-8000-000000000010", plan) + completed = ScenarioStepRun( + run_id=run.id, + logical_step_id="completed-ancestor-044", + step_position=0, + attempt=1, + status="passed", + progress=100, + step_outcome={"status": "passed"}, + ) + frontier = ScenarioStepRun( + run_id=run.id, + logical_step_id="safe-frontier-044", + step_position=1, + attempt=1, + status="running", + artifact_refs=["evidence:crash-partial"], + ) + seeded_execution.add_all([run, completed, frontier]) + seeded_execution.flush() + historical = register_artifact( + seeded_execution, + owner_type="scenario_run", + owner_id=run.id, + kind="evidence", + name="crash-partial", + content_ref="evidence:crash-partial", + sha256=_RECOVERY_SHA, + logical_step_id=frontier.logical_step_id, + attempt=1, + ) + lease = claim_step( + seeded_execution, + run.id, + frontier.logical_step_id, + worker_id="crashed-worker-044", + side_effect_key="safe-effect-044", + idempotent=True, + retry_safe=True, + ) + _expire(lease) + seeded_execution.flush() + calls: list[str] = [] + registry = ScenarioExecutorRegistry() + + def safe_adapter(step, _completed): + calls.append(step["logical_step_id"]) + return {"status": "passed", "step_outcome": {"status": "passed"}, "artifact_refs": []} + + registry.register("assertion", safe_adapter, action="structural_assert") + result = recover_run(seeded_execution, run.id, worker_id="recovery-worker-044", registry=registry) + + assert result["status"] == "passed" + assert calls == ["safe-frontier-044"] + assert completed.attempt == 1 + assert completed.status == "passed" + assert frontier.attempt == 2 + assert frontier.status == "passed" + assert frontier.outputs["recovery_history"][0]["artifact_refs"] == ["evidence:crash-partial"] + assert frontier.artifact_refs == [] + assert historical.is_active is False + assert seeded_execution.query(InvestigationQueueItem).filter_by(run_id=run.id).count() == 0 + + assert recover_run(seeded_execution, run.id, worker_id="recovery-worker-044", registry=registry)["status"] == "passed" + assert calls == ["safe-frontier-044"] +# #endregion Test.ScenarioExecution.CrashRecovery.Safe + + +# #region Test.ScenarioExecution.CrashRecovery.Unsafe [C:4] [TYPE Function] +# @BRIEF An expired unsafe effect becomes an auditable blocked reconciliation requirement, never a replay. +def test_unsafe_claim_requires_reconciliation(seeded_execution): + plan = _plan( + [_step("unsafe-frontier-044", "artifact", "register_artifact", mutation_contract={"fixture": "unsafe"})], + [], + ) + run = _run("80440000-0000-4000-8000-000000000011", plan) + frontier = ScenarioStepRun(run_id=run.id, logical_step_id="unsafe-frontier-044", step_position=0, attempt=1, status="running") + seeded_execution.add_all([run, frontier]) + seeded_execution.flush() + lease = claim_step( + seeded_execution, run.id, frontier.logical_step_id, worker_id="crashed-unsafe-044", + side_effect_key="unsafe-effect-044", idempotent=False, retry_safe=False, + ) + _expire(lease) + registry = ScenarioExecutorRegistry() + calls: list[str] = [] + registry.register("artifact", lambda step, _completed: calls.append(step["logical_step_id"]), action="register_artifact") + + result = recover_run(seeded_execution, run.id, worker_id="recovery-unsafe-044", registry=registry) + assert result["status"] == "blocked" + assert frontier.attempt == 1 + assert frontier.error_code == "RECOVERY_RECONCILIATION_REQUIRED" + assert calls == [] + assert seeded_execution.query(InvestigationQueueItem).filter_by(run_id=run.id).count() == 1 + assert recover_run(seeded_execution, run.id, worker_id="recovery-unsafe-044", registry=registry)["status"] == "blocked" + assert seeded_execution.query(InvestigationQueueItem).filter_by(run_id=run.id).count() == 1 +# #endregion Test.ScenarioExecution.CrashRecovery.Unsafe + + +# #region Test.ScenarioExecution.CrashRecovery.Browser [C:4] [TYPE Function] +# @BRIEF A dead browser context cannot recover without a declared pinned browser-safe checkpoint. +def test_browser_recovery_requires_declared_safe_checkpoint(seeded_execution): + plan = _plan([_step("browser-frontier-044", "browser", "open_dashboard")], []) + run = _run("80440000-0000-4000-8000-000000000012", plan) + frontier = ScenarioStepRun(run_id=run.id, logical_step_id="browser-frontier-044", step_position=0, attempt=1, status="running") + seeded_execution.add_all([run, frontier]) + seeded_execution.flush() + lease = claim_step( + seeded_execution, run.id, frontier.logical_step_id, worker_id="crashed-browser-044", + side_effect_key="browser-effect-044", idempotent=True, retry_safe=True, + ) + _expire(lease) + calls: list[str] = [] + registry = ScenarioExecutorRegistry() + registry.register("browser", lambda step, _completed: calls.append(step["logical_step_id"])) + + result = recover_run(seeded_execution, run.id, worker_id="recovery-browser-044", registry=registry) + assert result["status"] == "inconclusive" + assert frontier.error_code == "BROWSER_RECOVERY_CHECKPOINT_REQUIRED" + assert calls == [] + assert seeded_execution.query(InvestigationQueueItem).filter_by(run_id=run.id).count() == 1 +# #endregion Test.ScenarioExecution.CrashRecovery.Browser +# #endregion Test.ScenarioExecution.CrashRecovery diff --git a/backend/tests/services/dashboard_testing/registry/test_scenario_dispatch.py b/backend/tests/services/dashboard_testing/registry/test_scenario_dispatch.py index 8ad5d739d..fb8dac7fc 100644 --- a/backend/tests/services/dashboard_testing/registry/test_scenario_dispatch.py +++ b/backend/tests/services/dashboard_testing/registry/test_scenario_dispatch.py @@ -1,23 +1,36 @@ # #region Test.ScenarioExecution.Dispatch [C:3] [TYPE Module] [SEMANTICS test,scenario,execution,dispatch,dependency] # @RELATION BINDS_TO -> [ScenarioExecution.Dispatch.Step] -import pytest - from src.services.dashboard_testing.execution.dispatch import dispatch_step from src.services.dashboard_testing.execution.executor_registry import ScenarioExecutorRegistry +from src.services.dashboard_testing.scenario.templates import ( + ACTION_REGISTRY_VERSION, + action_registry_fingerprint, + resolve_action_descriptor, +) + + +def _step(step_id: str, tool: str, action: str) -> dict: + return { + "logical_step_id": step_id, "tool": tool, "action": action, + "action_descriptor": resolve_action_descriptor( + tool=tool, action=action, registry_version=ACTION_REGISTRY_VERSION, + registry_hash=action_registry_fingerprint(), + ).snapshot(), + } def test_dispatch_rejects_unknown_tool_before_io(): registry = ScenarioExecutorRegistry() - with pytest.raises(ValueError, match="unknown executor"): - dispatch_step({"logical_step_id": "a", "tool": "unknown"}, completed={}, registry=registry, edges=[]) + result = dispatch_step({"logical_step_id": "a", "tool": "unknown"}, completed={}, registry=registry, edges=[]) + assert result == {"status": "blocked", "reason": "ACTION_DESCRIPTOR_REQUIRED", "logical_step_id": "a"} def test_dispatch_blocks_descendant_after_failed_dependency(): registry = ScenarioExecutorRegistry() registry.register("assertion", lambda _step, _completed: {"status": "passed"}) - result = dispatch_step({"logical_step_id": "b", "tool": "assertion"}, completed={"a": {"status": "failed"}}, registry=registry, edges=[{"source": "a", "target": "b"}]) + result = dispatch_step(_step("b", "assertion", "structural_assert"), completed={"a": {"status": "failed"}}, registry=registry, edges=[{"source": "a", "target": "b"}]) assert result["status"] == "blocked" def test_human_is_waiting_control_not_executor(): - result = dispatch_step({"logical_step_id": "human-1", "tool": "human"}, completed={}, registry=ScenarioExecutorRegistry(), edges=[]) + result = dispatch_step(_step("human-1", "human", "human_checkpoint"), completed={}, registry=ScenarioExecutorRegistry(), edges=[]) assert result["status"] == "waiting_human" # #endregion Test.ScenarioExecution.Dispatch diff --git a/backend/tests/services/dashboard_testing/registry/test_scenario_executors.py b/backend/tests/services/dashboard_testing/registry/test_scenario_executors.py new file mode 100644 index 000000000..f00fa5246 --- /dev/null +++ b/backend/tests/services/dashboard_testing/registry/test_scenario_executors.py @@ -0,0 +1,472 @@ +# #region Test.ScenarioExecution.Executors [C:3] [TYPE Module] [SEMANTICS test,scenario,execution,executor,browser] +# @BRIEF Verify typed executor outcomes and fail-safe browser adapter dispatch. +# @RELATION BINDS_TO -> [ScenarioExecution.Executors] +# @RELATION VERIFIES -> [ScenarioExecution.Executors.Browser] +# @RELATION VERIFIES -> [ScenarioExecution.Executors.SupersetApi] +# @RELATION VERIFIES -> [ScenarioExecution.Executors.Screenshot] +# @TEST_CONTRACT: Browser/Superset/ScreenshotStep + typed external adapter -> typed ScenarioStep outcome +# @TEST_CONTRACT: ArtifactStep + canonical non-zero SHA-256 -> typed non-forged ScenarioStep outcome +# @TEST_FIXTURE: browser_step_044 + superset_step_044 + screenshot_step_044 -> INLINE_JSON hardcoded live-I/O context +# @TEST_EDGE: metadata_without_adapter -> inconclusive; invalid_adapter_status -> inconclusive; missing_evidence -> inconclusive; adapter_failure -> failed; adapter_timeout -> inconclusive; forged_or_mismatched_digest -> non-pass +# @TEST_INVARIANT ScenarioExecution.Executors.Browser: Browser PASS is materialized only from an explicit BrowserExecutionAdapter success, never from session_id or page_ref metadata. -> VERIFIED_BY: ids_without_adapter, adapter_success, adapter_failure, adapter_timeout +# @TEST_INVARIANT ScenarioExecution.Executors.SupersetApi: Superset API PASS is materialized only from an explicit SupersetExecutionAdapter success, never from query_result metadata. -> VERIFIED_BY: query_metadata_without_adapter, superset_adapter_success, superset_adapter_failure, superset_adapter_timeout +# @TEST_INVARIANT ScenarioExecution.Executors.Screenshot: Screenshot PASS is materialized only from explicit ScreenshotExecutionAdapter success with durable evidence refs, never from capture_bytes metadata. -> VERIFIED_BY: capture_bytes_without_adapter, screenshot_adapter_success, screenshot_success_without_evidence, screenshot_adapter_failure, screenshot_adapter_timeout +# @TEST_INVARIANT ScenarioExecution.Executors: Unavailable live I/O returns typed inconclusive. -> VERIFIED_BY: ids_without_adapter, query_metadata_without_adapter, capture_bytes_without_adapter, browser_adapter_timeout, superset_adapter_timeout, screenshot_adapter_timeout +# @TEST_INVARIANT ScenarioExecution.LiveAdapter: Runtime adapter status is exactly passed, failed, or inconclusive; untrusted values fail closed. -> VERIFIED_BY: invalid_adapter_status +# @TEST_INVARIANT ScenarioExecution.Executors.Artifact: Artifact PASS requires a canonical, non-zero SHA-256 digest for the declared ref. -> VERIFIED_BY: artifact_rejects_forged_digest +# @TEST_INVARIANT ScenarioExecution.Executors.Screenshot: Screenshot PASS requires non-zero SHA-256 values agreeing with every forwarded artifact digest. -> VERIFIED_BY: screenshot_invalid_or_mismatched_digest, screenshot_multiple_evidence_refs +from io import BytesIO +import pytest + +from src.services.dashboard_testing.execution.executor_registry import ScenarioExecutorRegistry +from src.services.dashboard_testing.execution.executors import ( + BrowserAdapterResult, + ScreenshotAdapterResult, + SupersetAdapterResult, + _register_default_executors, +) +from src.services.dashboard_testing.scenario.templates import ( + ACTION_REGISTRY_VERSION, + action_registry_fingerprint, + resolve_action_descriptor, +) + + +# #region Test.ScenarioExecution.Executors.Registry [C:1] [TYPE Function] +def _registry(*, browser_adapter=None, superset_adapter=None, screenshot_adapter=None) -> ScenarioExecutorRegistry: + registry = ScenarioExecutorRegistry() + _register_default_executors( + registry, + browser_adapter=browser_adapter, + superset_adapter=superset_adapter, + screenshot_adapter=screenshot_adapter, + ) + return registry + + +def _resolve(registry: ScenarioExecutorRegistry, tool: str, action: str): + """Resolve only a hardcoded immutable descriptor, never a tool fallback.""" + return registry.resolve(resolve_action_descriptor( + tool=tool, + action=action, + registry_version=ACTION_REGISTRY_VERSION, + registry_hash=action_registry_fingerprint(), + ).snapshot()) +# #endregion Test.ScenarioExecution.Executors.Registry + + +_BROWSER_STEP_044 = { + "logical_step_id": "browser-filter-044", + "session_id": "persisted-session-044", + "page_ref": "persisted-page-044", + "step_meta": {"action": "apply_filters", "selector": "[data-test=region-filter]"}, +} +_COMPLETED_044 = {"setup-044": {"status": "passed", "output_refs": ["setup:044"]}} +_SUPERSET_STEP_044 = { + "logical_step_id": "metric-044", + "step_meta": { + "environment_id": "preprod-044", + "dashboard_id": "dashboard-044", + "chart_id": 44, + "result_key": "revenue", + "query_result": {"revenue": 4400}, + }, +} +_SCREENSHOT_STEP_044 = { + "logical_step_id": "screenshot-044", + "step_meta": { + "environment_id": "preprod-044", + "dashboard_id": "dashboard-044", + "capture_profile_id": "profile-044", + "capture_bytes": b"metadata-only-capture-044", + }, +} + + +# #region Test.ScenarioExecution.Executors.BrowserUnavailable [C:2] [TYPE Function] +# @BRIEF IDs without an executable browser adapter are inconclusive, never synthetic PASS. +def test_browser_ids_without_adapter_are_inconclusive(): + outcome = _resolve(_registry(), "browser", "apply_filters")(_BROWSER_STEP_044, _COMPLETED_044) + assert outcome["status"] == "inconclusive" + assert outcome["error_code"] == "BROWSER_ADAPTER_UNAVAILABLE" + assert outcome["step_outcome"]["session_id"] == "persisted-session-044" + assert outcome["step_outcome"]["page_ref"] == "persisted-page-044" +# #endregion Test.ScenarioExecution.Executors.BrowserUnavailable + + +# #region Test.ScenarioExecution.Executors.BrowserSuccess [C:2] [TYPE Function] +# @BRIEF Explicit external adapter success maps to passed while retaining execution context. +def test_browser_adapter_success_maps_to_passed_and_receives_context(): + observed: dict[str, object] = {} + + def successful_transport(step, completed): + observed["step_id"] = step["logical_step_id"] + observed["action"] = step["step_meta"]["action"] + observed["completed"] = completed["setup-044"]["status"] + return BrowserAdapterResult( + status="passed", + reason_code="BROWSER_ACTION_APPLIED", + details={"observed_text": "EMEA"}, + output_refs=["browser-output:044"], + artifact_refs=["browser-evidence:044"], + ) + + outcome = _resolve(_registry(browser_adapter=successful_transport), "browser", "apply_filters")( + _BROWSER_STEP_044, + _COMPLETED_044, + ) + + assert observed == {"step_id": "browser-filter-044", "action": "apply_filters", "completed": "passed"} + assert outcome["status"] == "passed" + assert outcome["error_code"] is None + assert outcome["step_outcome"]["reason_code"] == "BROWSER_ACTION_APPLIED" + assert outcome["step_outcome"]["observed_text"] == "EMEA" + assert outcome["step_outcome"]["session_id"] == "persisted-session-044" + assert outcome["step_outcome"]["page_ref"] == "persisted-page-044" + assert outcome["output_refs"] == ["browser-output:044"] + assert outcome["artifact_refs"] == ["browser-evidence:044"] +# #endregion Test.ScenarioExecution.Executors.BrowserSuccess + + +# #region Test.ScenarioExecution.Executors.BrowserFailure [C:2] [TYPE Function] +# @BRIEF Explicit adapter failure is a failed step, not a browser PASS. +def test_browser_adapter_failure_maps_to_explicit_failed_outcome(): + def failing_transport(_step, _completed): + return BrowserAdapterResult(status="failed", reason_code="BROWSER_SELECTOR_NOT_FOUND") + + outcome = _resolve(_registry(browser_adapter=failing_transport), "browser", "apply_filters")( + _BROWSER_STEP_044, + _COMPLETED_044, + ) + + assert outcome["status"] == "failed" + assert outcome["error_code"] == "BROWSER_SELECTOR_NOT_FOUND" +# #endregion Test.ScenarioExecution.Executors.BrowserFailure + + +# #region Test.ScenarioExecution.Executors.BrowserTimeout [C:2] [TYPE Function] +# @BRIEF External adapter timeout remains explicitly inconclusive and never manufactures PASS. +def test_browser_adapter_timeout_maps_to_explicit_inconclusive_outcome(): + def timeout_transport(_step, _completed): + raise TimeoutError("browser action exceeded 30s") + + outcome = _resolve(_registry(browser_adapter=timeout_transport), "browser", "apply_filters")( + _BROWSER_STEP_044, + _COMPLETED_044, + ) + + assert outcome["status"] == "inconclusive" + assert outcome["error_code"] == "BROWSER_ADAPTER_TIMEOUT" +# #endregion Test.ScenarioExecution.Executors.BrowserTimeout + + +# #region Test.ScenarioExecution.Executors.AdapterInvalidStatus [C:2] [TYPE Function] +# @BRIEF An external adapter's untrusted runtime status fails closed before result aggregation. +def test_browser_adapter_invalid_status_is_typed_inconclusive(): + def invalid_status_transport(_step, _completed): + return BrowserAdapterResult(status="untrusted", reason_code="UNTRUSTED_STATUS") + + outcome = _resolve(_registry(browser_adapter=invalid_status_transport), "browser", "apply_filters")( + _BROWSER_STEP_044, + _COMPLETED_044, + ) + + assert outcome["status"] == "inconclusive" + assert outcome["error_code"] == "BROWSER_ADAPTER_INVALID_RESULT" +# #endregion Test.ScenarioExecution.Executors.AdapterInvalidStatus + + +# #region Test.ScenarioExecution.Executors.SupersetUnavailable [C:2] [TYPE Function] +# @BRIEF Static query metadata without a live adapter is inconclusive, never synthetic PASS. +def test_superset_query_metadata_without_adapter_is_inconclusive(): + outcome = _resolve(_registry(), "superset_api", "execute_metric")(_SUPERSET_STEP_044, _COMPLETED_044) + + assert outcome["status"] == "inconclusive" + assert outcome["error_code"] == "SUPERSET_ADAPTER_UNAVAILABLE" + assert outcome["step_outcome"]["dashboard_id"] == "dashboard-044" + assert outcome["step_outcome"]["result_key"] == "revenue" + assert "actual" not in outcome["step_outcome"] +# #endregion Test.ScenarioExecution.Executors.SupersetUnavailable + + +# #region Test.ScenarioExecution.Executors.SupersetSuccess [C:2] [TYPE Function] +# @BRIEF Explicit 037 adapter success maps to passed and receives full execution context. +def test_superset_adapter_success_maps_to_passed_and_receives_context(): + observed: dict[str, object] = {} + + def successful_transport(step, completed): + observed["step_id"] = step["logical_step_id"] + observed["query_metadata"] = step["step_meta"]["query_result"] + observed["completed"] = completed["setup-044"]["status"] + return SupersetAdapterResult( + status="passed", + reason_code="SUPERSET_CHART_DATA_EXECUTED", + details={"actual": {"revenue": 4400}, "source_response_hash": "a" * 64}, + output_refs=["superset-output:044"], + artifact_refs=["superset-evidence:044"], + ) + + outcome = _resolve(_registry(superset_adapter=successful_transport), "superset_api", "execute_metric")( + _SUPERSET_STEP_044, + _COMPLETED_044, + ) + + assert observed == { + "step_id": "metric-044", + "query_metadata": {"revenue": 4400}, + "completed": "passed", + } + assert outcome["status"] == "passed" + assert outcome["error_code"] is None + assert outcome["step_outcome"]["reason_code"] == "SUPERSET_CHART_DATA_EXECUTED" + assert outcome["step_outcome"]["actual"] == {"revenue": 4400} + assert outcome["step_outcome"]["source_response_hash"] == "a" * 64 + assert outcome["output_refs"] == ["superset-output:044"] + assert outcome["artifact_refs"] == ["superset-evidence:044"] +# #endregion Test.ScenarioExecution.Executors.SupersetSuccess + + +# #region Test.ScenarioExecution.Executors.SupersetFailure [C:2] [TYPE Function] +# @BRIEF Explicit adapter failure remains failed and never turns query metadata into PASS. +def test_superset_adapter_failure_maps_to_explicit_failed_outcome(): + def failing_transport(_step, _completed): + return SupersetAdapterResult(status="failed", reason_code="SUPERSET_FORBIDDEN") + + outcome = _resolve(_registry(superset_adapter=failing_transport), "superset_api", "execute_metric")( + _SUPERSET_STEP_044, + _COMPLETED_044, + ) + + assert outcome["status"] == "failed" + assert outcome["error_code"] == "SUPERSET_FORBIDDEN" +# #endregion Test.ScenarioExecution.Executors.SupersetFailure + + +# #region Test.ScenarioExecution.Executors.SupersetTimeout [C:2] [TYPE Function] +# @BRIEF Explicit adapter timeout remains inconclusive and never manufactures PASS. +def test_superset_adapter_timeout_maps_to_explicit_inconclusive_outcome(): + def timeout_transport(_step, _completed): + raise TimeoutError("chart-data request exceeded 30s") + + outcome = _resolve(_registry(superset_adapter=timeout_transport), "superset_api", "execute_metric")( + _SUPERSET_STEP_044, + _COMPLETED_044, + ) + + assert outcome["status"] == "inconclusive" + assert outcome["error_code"] == "SUPERSET_ADAPTER_TIMEOUT" +# #endregion Test.ScenarioExecution.Executors.SupersetTimeout + + +# #region Test.ScenarioExecution.Executors.ScreenshotUnavailable [C:2] [TYPE Function] +# @BRIEF Raw capture bytes without the ScreenshotService/036 adapter are inconclusive, never PASS. +def test_screenshot_capture_bytes_without_adapter_are_inconclusive(): + outcome = _resolve(_registry(), "screenshot", "capture_screenshot")(_SCREENSHOT_STEP_044, _COMPLETED_044) + + assert outcome["status"] == "inconclusive" + assert outcome["error_code"] == "SCREENSHOT_ADAPTER_UNAVAILABLE" + assert outcome["step_outcome"]["dashboard_id"] == "dashboard-044" + assert "sha256" not in outcome["step_outcome"] + assert outcome["artifact_refs"] == [] +# #endregion Test.ScenarioExecution.Executors.ScreenshotUnavailable + + +# #region Test.ScenarioExecution.Executors.ScreenshotSuccess [C:2] [TYPE Function] +# @BRIEF Explicit ScreenshotService/036 adapter success carries durable evidence and passes. +def test_screenshot_adapter_success_maps_to_passed_with_evidence_ref(): + observed: dict[str, object] = {} + + def successful_transport(step, completed): + observed["step_id"] = step["logical_step_id"] + observed["capture_bytes"] = step["step_meta"]["capture_bytes"] + observed["completed"] = completed["setup-044"]["status"] + return ScreenshotAdapterResult( + status="passed", + reason_code="SCREENSHOT_EVIDENCE_REGISTERED", + details={"sha256": "b" * 64, "capture_method": "screenshot-service"}, + output_refs=["evidence:screenshot-044"], + artifact_refs=["draft-screenshot-044"], + artifact_digests={"draft-screenshot-044": "b" * 64}, + ) + + outcome = _resolve(_registry(screenshot_adapter=successful_transport), "screenshot", "capture_screenshot")( + _SCREENSHOT_STEP_044, + _COMPLETED_044, + ) + + assert observed == { + "step_id": "screenshot-044", + "capture_bytes": b"metadata-only-capture-044", + "completed": "passed", + } + assert outcome["status"] == "passed" + assert outcome["error_code"] is None + assert outcome["step_outcome"]["reason_code"] == "SCREENSHOT_EVIDENCE_REGISTERED" + assert outcome["step_outcome"]["sha256"] == "b" * 64 + assert outcome["step_outcome"]["artifact_digests"] == {"draft-screenshot-044": "b" * 64} + assert outcome["output_refs"] == ["evidence:screenshot-044"] + assert outcome["artifact_refs"] == ["draft-screenshot-044"] +# #endregion Test.ScenarioExecution.Executors.ScreenshotSuccess + + +# #region Test.ScenarioExecution.Executors.ScreenshotMultipleEvidence [C:2] [TYPE Function] +# @BRIEF Multiple durable screenshot artifacts pass only when every ref has a matching digest. +def test_screenshot_adapter_multiple_evidence_refs_map_to_passed(): + digests = {"draft:screenshot-044:one": "b" * 64, "draft:screenshot-044:two": "c" * 64} + + def successful_transport(_step, _completed): + return ScreenshotAdapterResult( + status="passed", + reason_code="SCREENSHOT_EVIDENCE_REGISTERED", + details={"sha256": digests["draft:screenshot-044:one"]}, + artifact_refs=list(digests), + artifact_digests=digests, + ) + + outcome = _resolve(_registry(screenshot_adapter=successful_transport), "screenshot", "capture_screenshot")( + _SCREENSHOT_STEP_044, + _COMPLETED_044, + ) + + assert outcome["status"] == "passed" + assert outcome["artifact_refs"] == list(digests) +# #endregion Test.ScenarioExecution.Executors.ScreenshotMultipleEvidence + + +# #region Test.ScenarioExecution.Executors.ScreenshotMissingEvidence [C:2] [TYPE Function] +# @BRIEF A typed success without durable digest and evidence refs remains inconclusive. +def test_screenshot_adapter_success_without_evidence_is_inconclusive(): + def incomplete_transport(_step, _completed): + return ScreenshotAdapterResult(status="passed", reason_code="SCREENSHOT_CAPTURED") + + outcome = _resolve(_registry(screenshot_adapter=incomplete_transport), "screenshot", "capture_screenshot")( + _SCREENSHOT_STEP_044, + _COMPLETED_044, + ) + + assert outcome["status"] == "inconclusive" + assert outcome["error_code"] == "SCREENSHOT_EVIDENCE_REQUIRED" + assert outcome["artifact_refs"] == [] +# #endregion Test.ScenarioExecution.Executors.ScreenshotMissingEvidence + + +# #region Test.ScenarioExecution.Executors.ScreenshotDigestIntegrity [C:2] [TYPE Function] +# @BRIEF Invalid or mismatched screenshot digests cannot turn a live adapter response into PASS. +@pytest.mark.parametrize( + ("declared_digest", "forwarded_digest"), + [("0" * 64, "0" * 64), ("c" * 64, "d" * 64)], +) +def test_screenshot_adapter_invalid_or_mismatched_digest_is_nonpass( + declared_digest, forwarded_digest, +): + def forged_transport(_step, _completed): + return ScreenshotAdapterResult( + status="passed", + reason_code="SCREENSHOT_EVIDENCE_REGISTERED", + details={"sha256": declared_digest}, + artifact_refs=["screenshot-evidence-integrity-044"], + artifact_digests={"screenshot-evidence-integrity-044": forwarded_digest}, + ) + + outcome = _resolve(_registry(screenshot_adapter=forged_transport), "screenshot", "capture_screenshot")( + _SCREENSHOT_STEP_044, + _COMPLETED_044, + ) + + assert outcome["status"] == "inconclusive" + assert outcome["error_code"] == "SCREENSHOT_EVIDENCE_INVALID" + assert outcome["artifact_refs"] == [] +# #endregion Test.ScenarioExecution.Executors.ScreenshotDigestIntegrity + + +# #region Test.ScenarioExecution.Executors.ScreenshotFailure [C:2] [TYPE Function] +# @BRIEF Explicit capture failure remains failed and cannot turn metadata bytes into PASS. +def test_screenshot_adapter_failure_maps_to_explicit_failed_outcome(): + def failing_transport(_step, _completed): + return ScreenshotAdapterResult(status="failed", reason_code="SCREENSHOT_CAPTURE_FAILED") + + outcome = _resolve(_registry(screenshot_adapter=failing_transport), "screenshot", "capture_screenshot")( + _SCREENSHOT_STEP_044, + _COMPLETED_044, + ) + + assert outcome["status"] == "failed" + assert outcome["error_code"] == "SCREENSHOT_CAPTURE_FAILED" +# #endregion Test.ScenarioExecution.Executors.ScreenshotFailure + + +# #region Test.ScenarioExecution.Executors.ScreenshotTimeout [C:2] [TYPE Function] +# @BRIEF Explicit capture timeout remains inconclusive and never manufactures evidence. +def test_screenshot_adapter_timeout_maps_to_explicit_inconclusive_outcome(): + def timeout_transport(_step, _completed): + raise TimeoutError("screenshot capture exceeded 30s") + + outcome = _resolve(_registry(screenshot_adapter=timeout_transport), "screenshot", "capture_screenshot")( + _SCREENSHOT_STEP_044, + _COMPLETED_044, + ) + + assert outcome["status"] == "inconclusive" + assert outcome["error_code"] == "SCREENSHOT_ADAPTER_TIMEOUT" +# #endregion Test.ScenarioExecution.Executors.ScreenshotTimeout + + +def test_assertion_uses_037_compare_values_and_can_fail(): + step = { + "logical_step_id": "assert-1", + "actual": {"kind": "integer", "canonical_value": "10"}, + "expected": {"kind": "integer", "canonical_value": "11"}, + "policy": {"type": "exact"}, + } + outcome = _resolve(_registry(), "assertion", "structural_assert")(step, {}) + assert outcome["status"] == "failed" + assert outcome["step_outcome"]["comparison_status"] == "fail" + + +def test_assertion_pass_is_derived_from_compare_values(): + step = { + "logical_step_id": "assert-2", + "actual": {"kind": "string", "canonical_value": "emea"}, + "expected": {"kind": "string", "canonical_value": "emea"}, + "policy": {"type": "exact"}, + } + outcome = _resolve(_registry(), "assertion", "structural_assert")(step, {}) + assert outcome["status"] == "passed" + assert outcome["error_code"] is None + + +def test_xlsx_parses_workbook_bytes(): + from openpyxl import Workbook + + workbook = Workbook() + workbook.active.append(["region", "amount"]) + workbook.active.append(["emea", 10]) + buffer = BytesIO() + workbook.save(buffer) + outcome = _resolve(_registry(), "xlsx", "parse_xlsx")({"logical_step_id": "xlsx-1", "xlsx_bytes": buffer.getvalue()}, {}) + assert outcome["status"] == "passed" + assert outcome["step_outcome"]["row_count"] == 2 + assert outcome["artifact_refs"][0].startswith("xlsx:") + + +def test_screenshot_without_adapter_is_inconclusive(): + outcome = _resolve(_registry(), "screenshot", "capture_screenshot")({"logical_step_id": "shot-1"}, {}) + assert outcome["status"] == "inconclusive" + assert outcome["error_code"] == "SCREENSHOT_ADAPTER_UNAVAILABLE" + + +# #region Test.ScenarioExecution.Executors.ArtifactDigestIntegrity [C:2] [TYPE Function] +# @BRIEF Missing, all-zero, and non-hex artifact digests remain non-passing outcomes. +@pytest.mark.parametrize( + ("digest", "status", "error_code"), + [(None, "inconclusive", "ARTIFACT_REF_REQUIRED"), ("0" * 64, "failed", "ARTIFACT_DIGEST_INVALID"), ("g" * 64, "failed", "ARTIFACT_DIGEST_INVALID")], +) +def test_artifact_rejects_forged_digest(digest, status, error_code): + outcome = _resolve(_registry(), "artifact", "register_artifact")( + {"logical_step_id": "art-1", "content_ref": "ref-1", "sha256": digest}, + {}, + ) + assert outcome["status"] == status + assert outcome["error_code"] == error_code +# #endregion Test.ScenarioExecution.Executors.ArtifactDigestIntegrity +# #endregion Test.ScenarioExecution.Executors diff --git a/backend/tests/services/dashboard_testing/registry/test_scenario_investigation.py b/backend/tests/services/dashboard_testing/registry/test_scenario_investigation.py index 3fe1f1012..30e7edc27 100644 --- a/backend/tests/services/dashboard_testing/registry/test_scenario_investigation.py +++ b/backend/tests/services/dashboard_testing/registry/test_scenario_investigation.py @@ -3,7 +3,7 @@ # @TEST_FIXTURE: analytics.json -> specs/047-dashboard-scenario-analytics/fixtures/analytics.json import pytest -from src.services.dashboard_testing.analytics.investigation import open_case, queue_signal, set_disposition +from src.services.dashboard_testing.analytics.investigation import auto_queue_failed_run, ingest_investigation_signal, open_case, queue_signal, set_disposition # Canonical fixture scenario id (042 registry fixture). _SCENARIO = "11111111-1111-4111-8111-111111111111" @@ -19,9 +19,51 @@ def test_queue_and_case_are_idempotent(seeded_registry): def test_disposition_uses_cas(seeded_registry): case = open_case(seeded_registry, fingerprint="fp-cas", scenario_id=_SCENARIO, actor_id="analyst") - set_disposition(seeded_registry, case.id, disposition="confirmed", expected_version=1, actor_id="analyst") + set_disposition(seeded_registry, case.id, disposition="resolved", expected_version=1, actor_id="analyst", verification_evidence={"reconciled": True, "artifact_id": "evidence-1"}) with pytest.raises(ValueError, match="stale"): - set_disposition(seeded_registry, case.id, disposition="false_positive", expected_version=1, actor_id="analyst") + set_disposition(seeded_registry, case.id, disposition="resolved", expected_version=1, actor_id="analyst", verification_evidence={"reconciled": True}) + + +def test_signal_ingestion_preserves_evidence_without_starting_case(seeded_registry): + item = ingest_investigation_signal(seeded_registry, {"fingerprint": "failure:run-1", "scenario_id": _SCENARIO, "run_id": "run-1", "severity": "critical", "evidence": {"error_code": "timeout"}}) + assert item.evidence_snapshot == {"error_code": "timeout", "run_ids": ["run-1"]} + assert seeded_registry.query(__import__("src.models.scenario_investigation", fromlist=["InvestigationCase"]).InvestigationCase).count() == 0 + + +def test_case_closure_requires_evidence_or_accepted_rationale(seeded_registry): + case = open_case(seeded_registry, fingerprint="fp-close", scenario_id=_SCENARIO, actor_id="analyst") + with pytest.raises(ValueError, match="verification evidence"): + set_disposition(seeded_registry, case.id, disposition="resolved", expected_version=1, actor_id="analyst") + with pytest.raises(ValueError, match="rationale"): + set_disposition(seeded_registry, case.id, disposition="accepted", expected_version=1, actor_id="analyst") + + +def test_failed_run_boundary_queues_without_opening_case(seeded_registry): + item = auto_queue_failed_run(seeded_registry, scenario_id=_SCENARIO, run_id="run-failed", environment_id="preprod") + assert item.run_id == "run-failed" + assert item.evidence_snapshot["source"] == "scenario_run" + + +def test_case_acl_allows_owner_and_triage_only(seeded_registry): + from src.services.dashboard_testing.analytics.investigation import can_access_case + + case = open_case(seeded_registry, fingerprint="fp-acl", scenario_id=_SCENARIO, actor_id="owner-1") + assert can_access_case(case, actor_id="owner-1", triage=False) is True + assert can_access_case(case, actor_id="other", triage=False) is False + assert can_access_case(case, actor_id="other", triage=True) is True + + +def test_recurrence_after_resolved_episode_opens_new_queue_item(seeded_registry): + from src.models.scenario_investigation import InvestigationQueueItem + from src.services.dashboard_testing.analytics.recurring import record_occurrence, resolve_episode + + first = record_occurrence(seeded_registry, fingerprint="timeout:step-a", scenario_id=_SCENARIO) + resolve_episode(seeded_registry, first.id) + second = record_occurrence(seeded_registry, fingerprint="timeout:step-a", scenario_id=_SCENARIO) + assert second.id != first.id + assert second.status == "open" + queued = seeded_registry.query(InvestigationQueueItem).filter(InvestigationQueueItem.fingerprint.like("recurrence:timeout:step-a:%")).all() + assert len(queued) == 1 # #region Test.ScenarioAnalytics.Investigation.CanonicalFixture [C:2] [TYPE Function] [SEMANTICS test,scenario,analytics,fixture] diff --git a/backend/tests/services/dashboard_testing/registry/test_scenario_lifecycle.py b/backend/tests/services/dashboard_testing/registry/test_scenario_lifecycle.py index f111d1f00..9d55a1ee8 100644 --- a/backend/tests/services/dashboard_testing/registry/test_scenario_lifecycle.py +++ b/backend/tests/services/dashboard_testing/registry/test_scenario_lifecycle.py @@ -1,8 +1,140 @@ # #region Test.ScenarioExecution.Lifecycle [C:3] [TYPE Module] [SEMANTICS test,scenario,execution,lifecycle,human,retry] # @RELATION BINDS_TO -> [ScenarioExecution.Lifecycle] -from src.services.dashboard_testing.execution.lifecycle import cancel_run, decide_checkpoint, retry_step, suspend_for_human +# @RELATION BINDS_TO -> [ScenarioExecution.Lifecycle.RetryEligibility] +# @TEST_CONTRACT: ScenarioRun + lifecycle control -> durable typed run and step state +# @TEST_FIXTURE: seeded_execution + human_step_044 -> INLINE SQLite ScenarioRun/ScenarioStepRun +# @TEST_EDGE: queued_cancel -> skipped; step_timeout -> inconclusive; human_confirm -> queued frontier +# @TEST_INVARIANT ScenarioExecution.Lifecycle.Decide: A confirmed checkpoint becomes a passed +# human step and returns the run to queued; it is distinct from ApprovalGate. +# -> VERIFIED_BY: test_confirm_checkpoint_returns_queued_frontier +# @TEST_INVARIANT ScenarioExecution.Lifecycle.Timeout: A timeout never manufactures a passing outcome. +# -> VERIFIED_BY: test_step_timeout_marks_run_inconclusive +# @TEST_INVARIANT ScenarioExecution.Lifecycle.RetryEligibility: A cancelled run cannot reopen a +# retry closure. -> VERIFIED_BY: +# test_retry_rejects_noneligible_step_and_run_states +import pytest + +from src.models.scenario_run import ScenarioStepRun +from src.services.dashboard_testing.execution.lifecycle import apply_step_timeout, cancel_run, decide_checkpoint, retry_step, suspend_for_human +from src.services.dashboard_testing.execution.runner import start_run def test_lifecycle_contracts_are_explicit(): assert callable(cancel_run) and callable(decide_checkpoint) and callable(retry_step) and callable(suspend_for_human) + + +def test_cancel_skips_queued_steps(seeded_execution): + run = start_run( + seeded_execution, + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1", + {}, + "env-preprod-02", + actor="life", + idempotency_key="life-cancel", + auto_advance=False, + ) + seeded_execution.add(ScenarioStepRun(run_id=run.id, logical_step_id="queued-1", step_position=0, status="queued")) + seeded_execution.flush() + cancelled = cancel_run(seeded_execution, run.id) + assert cancelled.status == "cancelled" + step = seeded_execution.query(ScenarioStepRun).filter_by(run_id=run.id, logical_step_id="queued-1").one() + assert step.status == "skipped" + + +def test_step_timeout_marks_run_inconclusive(seeded_execution): + run = start_run( + seeded_execution, + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1", + {}, + "env-preprod-02", + actor="life", + idempotency_key="life-timeout", + auto_advance=False, + ) + seeded_execution.add(ScenarioStepRun(run_id=run.id, logical_step_id="slow-1", step_position=0, status="running")) + seeded_execution.flush() + timed = apply_step_timeout(seeded_execution, run.id, "slow-1", timeout_ms=1) + seeded_execution.refresh(run) + assert timed.status == "inconclusive" + assert timed.error_code == "STEP_TIMEOUT" + assert run.status == "inconclusive" + + +# #region Test.ScenarioExecution.Lifecycle.HumanConfirm [C:2] [TYPE Function] +# @BRIEF A real persisted checkpoint confirm returns only its saved run frontier to queued. +def test_confirm_checkpoint_returns_queued_frontier(seeded_execution): + run = start_run( + seeded_execution, + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1", + {}, + "env-preprod-02", + actor="life", + idempotency_key="life-human-confirm", + auto_advance=False, + ) + seeded_execution.add(ScenarioStepRun( + run_id=run.id, + logical_step_id="human-step-044", + step_position=0, + attempt=1, + status="waiting_human", + )) + seeded_execution.flush() + checkpoint = suspend_for_human(seeded_execution, run.id, "human-step-044", ["evidence:human-044"]) + + decided = decide_checkpoint( + seeded_execution, + checkpoint.id, + disposition="confirm", + expected_version=1, + actor_id="analyst-044", + comment="hardcoded confirmation", + ) + + step = seeded_execution.query(ScenarioStepRun).filter_by( + run_id=run.id, logical_step_id="human-step-044" + ).one() + assert decided.status == "decided" + assert decided.decision_version == 2 + assert run.status == "queued" + assert run.phase == "executing" + assert step.status == "passed" + assert step.step_outcome["disposition"] == "confirm" +# #endregion Test.ScenarioExecution.Lifecycle.HumanConfirm +# #region Test.ScenarioExecution.Lifecycle.RetryEligibility [C:3] [TYPE Function] +# @BRIEF Retry rejects a passed target and a cancelled run before any closure mutation. +# @TEST_INVARIANT ScenarioExecution.Lifecycle.Retry: Only failed/inconclusive/blocked steps on an +# eligible non-human/non-cancelled run may reopen a closure. -> VERIFIED_BY: +# test_retry_rejects_noneligible_step_and_run_states +def test_retry_rejects_noneligible_step_and_run_states(seeded_execution): + run = start_run( + seeded_execution, + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1", + {}, + "env-preprod-02", + actor="retry-eligibility", + idempotency_key="retry-eligibility-044", + auto_advance=False, + ) + step = ScenarioStepRun( + run_id=run.id, + logical_step_id="retry-eligibility-step", + step_position=0, + attempt=1, + status="passed", + ) + seeded_execution.add(step) + seeded_execution.flush() + + with pytest.raises(ValueError, match="step is not retry-eligible"): + retry_step(seeded_execution, run.id, step.logical_step_id) + step.status = "failed" + run.status = "cancelled" + with pytest.raises(ValueError, match="run is not retry-eligible"): + retry_step(seeded_execution, run.id, step.logical_step_id) +# #endregion Test.ScenarioExecution.Lifecycle.RetryEligibility # #endregion Test.ScenarioExecution.Lifecycle diff --git a/backend/tests/services/dashboard_testing/registry/test_scenario_manual_run_only.py b/backend/tests/services/dashboard_testing/registry/test_scenario_manual_run_only.py new file mode 100644 index 000000000..047aa02e7 --- /dev/null +++ b/backend/tests/services/dashboard_testing/registry/test_scenario_manual_run_only.py @@ -0,0 +1,175 @@ +# #region Test.ScenarioExecution.ManualRunOnly [C:4] [TYPE Module] [SEMANTICS test,scenario,execution,automation,human,manual] +# @BRIEF Prove that 044 rejects every trusted 046 automation origin for a persisted human graph. +# @RELATION BINDS_TO -> [ScenarioExecution.Runner.Start] +# @RELATION BINDS_TO -> [ScenarioExecution.Runner.QueuedDispatch] +# @RELATION BINDS_TO -> [ScenarioAutomation.Trigger] +# @RELATION VERIFIES -> [ScenarioExecution.Runner.TriggerSource.RejectAutomatedHuman] +# @RELATION VERIFIES -> [ScenarioAutomation.Trigger.Dispatch] +# @TEST_CONTRACT: persisted human revision + server-owned automation origin -> pre-create rejection +# @TEST_FIXTURE: manual_only_human_revision_044 -> INLINE_JSON hardcoded immutable graph +# @TEST_INVARIANT ScenarioExecution.Runner.Start: A human-containing revision is manual_run_only; +# scheduled, deploy, release, ETL, and API automation create no run, gate, +# notification, or investigation queue item. -> VERIFIED_BY: +# test_automation_sources_reject_human_revision_before_side_effects +# @TEST_INVARIANT ScenarioExecution.Runner.Start: A rejected automation idempotency key has no +# durable row and cannot poison a later authenticated manual start. -> VERIFIED_BY: +# test_manual_start_waits_for_human_after_rejected_automation_key +# @TEST_INVARIANT ScenarioExecution.Runner.QueuedDispatch: An automated human plan is rejected +# before it can persist a queued row, reach the status CAS, or enter the walker. +# -> VERIFIED_BY: test_trigger_dispatch_rejects_human_revision_before_run_creation +from __future__ import annotations + +import pytest + +from src.models.scenario_approval import ActionApprovalGate +from src.models.scenario_automation import ScenarioNotificationEvent, ScenarioTriggerRule +from src.models.scenario_checkpoint import HumanCheckpoint +from src.models.scenario_investigation import InvestigationQueueItem +from src.models.scenario_registry import ScenarioRegistryEntry, ScenarioRevision +from src.models.scenario_run import ScenarioRun +from src.services.dashboard_testing.automation.trigger import dispatch_trigger_event +from src.services.dashboard_testing.execution.runner import start_run +from src.services.dashboard_testing.scenario.templates import ( + ACTION_REGISTRY_VERSION, + action_registry_fingerprint, + resolve_action_descriptor, +) + +_SCENARIO_ID = "60440000-0000-4000-8000-000000000004" +_REVISION_ID = "60440000-0000-4000-8000-000000000014" +_HUMAN_GRAPH = { + "action_registry_version": ACTION_REGISTRY_VERSION, + "action_registry_hash": action_registry_fingerprint(), + "steps": [{"logical_step_id": "human-review-044", "tool": "human"}], + "dependencies": [], + "environment_ids": ["env-preprod-manual-only"], +} +_HUMAN_GRAPH["steps"][0].update({ + "action": "human_checkpoint", + "action_descriptor": resolve_action_descriptor( + tool="human", action="human_checkpoint", registry_version=ACTION_REGISTRY_VERSION, + registry_hash=action_registry_fingerprint(), + ).snapshot(), +}) + + +# #region Test.ScenarioExecution.ManualRunOnly.Fixture [C:2] [TYPE Function] +# @BRIEF Persist a minimal immutable human graph; no executor or lifecycle boundary is mocked. +def _persist_human_revision(db) -> None: + db.add(ScenarioRegistryEntry( + scenario_id=_SCENARIO_ID, + scenario_key="manual-only-human-044", + name="Manual-only human review", + dashboard_id=44, + environment_ids=["env-preprod-manual-only"], + owner_id="analyst-044", + owner_username="analyst.044", + lifecycle_status="READY", + validation_status="valid", + current_revision_id=_REVISION_ID, + )) + db.add(ScenarioRevision( + revision_id=_REVISION_ID, + scenario_id=_SCENARIO_ID, + content_hash="4" * 64, + graph_snapshot=_HUMAN_GRAPH, + execution_template_hash="", + template_version="v1", + schema_version=1, + change_summary={"reason": "hardcoded manual-only regression fixture"}, + created_by="analyst-044", + activation_status="current", + )) + db.flush() +# #endregion Test.ScenarioExecution.ManualRunOnly.Fixture + + +# #region Test.ScenarioExecution.ManualRunOnly.SideEffects [C:1] [TYPE Function] [SEMANTICS test,scenario,execution,manual-only,side-effects] +# @BRIEF Count durable rows that must remain absent after an automated human-plan rejection. +def _side_effect_counts(db) -> tuple[int, int, int, int]: + return ( + db.query(ScenarioRun).filter(ScenarioRun.scenario_id == _SCENARIO_ID).count(), + db.query(ActionApprovalGate).count(), + db.query(ScenarioNotificationEvent).filter(ScenarioNotificationEvent.scenario_id == _SCENARIO_ID).count(), + db.query(InvestigationQueueItem).filter(InvestigationQueueItem.scenario_id == _SCENARIO_ID).count(), + ) +# #endregion Test.ScenarioExecution.ManualRunOnly.SideEffects + + +# #region Test.ScenarioExecution.ManualRunOnly.Automation [C:3] [TYPE Function] +# @BRIEF Every actual 046 source family rejects before inserting any 044 execution or follow-on row. +@pytest.mark.parametrize( + "trigger_source", + ["scheduled", "deploy_to_preprod", "release_created", "etl_completed", "api"], +) +def test_automation_sources_reject_human_revision_before_side_effects(registry_session, trigger_source): + _persist_human_revision(registry_session) + before = _side_effect_counts(registry_session) + + with pytest.raises(ValueError, match=r"^AUTOMATION_INELIGIBLE_HUMAN_STEP$"): + start_run( + registry_session, + _SCENARIO_ID, + _REVISION_ID, + {"hardcoded": "manual-only"}, + "env-preprod-manual-only", + actor="automation-046", + idempotency_key=f"manual-only-{trigger_source}-044", + auto_advance=True, + trigger_source=trigger_source, + ) + + assert _side_effect_counts(registry_session) == before +# #endregion Test.ScenarioExecution.ManualRunOnly.Automation + + +# #region Test.ScenarioExecution.ManualRunOnly.Replay [C:3] [TYPE Function] +# @BRIEF A pre-create rejection leaves no idempotency row, so an analyst can use that key manually. +def test_manual_start_waits_for_human_after_rejected_automation_key(registry_session): + _persist_human_revision(registry_session) + key = "manual-only-retry-key-044" + + with pytest.raises(ValueError, match=r"^AUTOMATION_INELIGIBLE_HUMAN_STEP$"): + start_run( + registry_session, _SCENARIO_ID, _REVISION_ID, {}, "env-preprod-manual-only", + actor="scheduler-046", idempotency_key=key, trigger_source="scheduled", + ) + assert registry_session.query(ScenarioRun).filter_by(idempotency_key=key).count() == 0 + + run = start_run( + registry_session, _SCENARIO_ID, _REVISION_ID, {}, "env-preprod-manual-only", + actor="analyst-044", idempotency_key=key, trigger_source="manual", + ) + + assert run.trigger_source == "manual" + assert run.status == "queued" + assert registry_session.query(HumanCheckpoint).filter_by(run_id=run.id, status="pending").count() == 0 + assert registry_session.query(ScenarioRun).filter_by(idempotency_key=key).count() == 1 +# #endregion Test.ScenarioExecution.ManualRunOnly.Replay + + +# #region Test.ScenarioExecution.ManualRunOnly.Trigger [C:3] [TYPE Function] +# @BRIEF The real 046 event dispatcher forwards its event origin into 044 before creation. +def test_trigger_dispatch_rejects_human_revision_before_run_creation(registry_session): + _persist_human_revision(registry_session) + registry_session.add(ScenarioTriggerRule( + id="manual-only-etl-rule-044", + trigger="etl_completed", + scenario_id=_SCENARIO_ID, + revision_policy="pinned", + revision_id=_REVISION_ID, + environment_id="env-preprod-manual-only", + enabled=True, + )) + registry_session.flush() + + with pytest.raises(ValueError, match=r"^AUTOMATION_INELIGIBLE_HUMAN_STEP$"): + dispatch_trigger_event( + registry_session, + {"type": "etl_completed", "fingerprint": "etl-manual-only-044"}, + actor="event-dispatcher-046", + ) + + assert _side_effect_counts(registry_session) == (0, 0, 0, 0) +# #endregion Test.ScenarioExecution.ManualRunOnly.Trigger +# #endregion Test.ScenarioExecution.ManualRunOnly diff --git a/backend/tests/services/dashboard_testing/registry/test_scenario_queued_dispatch.py b/backend/tests/services/dashboard_testing/registry/test_scenario_queued_dispatch.py new file mode 100644 index 000000000..93358e608 --- /dev/null +++ b/backend/tests/services/dashboard_testing/registry/test_scenario_queued_dispatch.py @@ -0,0 +1,218 @@ +# #region Test.ScenarioExecution.QueuedDispatch [C:5] [TYPE Module] [SEMANTICS test,scenario,execution,queue,dispatch,cas,scheduler] +# @BRIEF Prove queued runs advance outside HTTP through a durable status-CAS worker claim. +# @RELATION BINDS_TO -> [ScenarioExecution.Runner.QueuedDispatch] +# @RELATION BINDS_TO -> [ScenarioExecution.Runner.Walker] +# @TEST_FIXTURE: queued_dispatch_044 -> INLINE SQLite hardcoded ScenarioRun DAGs +# @TEST_INVARIANT ScenarioExecution.Runner.QueuedDispatch: One queued run is claimed by one worker +# and repeated/racing ticks cannot duplicate its external adapter call. -> VERIFIED_BY: +# test_queued_dispatch_claims_once_and_repeated_tick_is_idempotent +# @TEST_INVARIANT ScenarioExecution.Runner.QueuedDispatch: Pending approval and cancelled rows do +# not dispatch; only the CAS-winning dispatcher advances a queued manual human +# graph to its waiting_human lifecycle checkpoint. -> VERIFIED_BY: +# test_dispatch_skips_ineligible_rows_and_waits_for_human +# @TEST_INVARIANT ScenarioExecution.Runner.RejectMalformedPlan: a legacy queued plan lacking an +# ActionExecutionDescriptor blocks and removes active leases before any adapter call. +# -> VERIFIED_BY: test_malformed_legacy_plan_blocks_and_cleans_lease +# @TEST_INVARIANT ScenarioExecution.Runner.QueuedDispatch: A server-owned PROD snapshot rejects +# a mutating Browser descriptor before any provider/action adapter I/O. +# -> VERIFIED_BY: test_prod_browser_mutation_never_calls_provider +from __future__ import annotations + +from datetime import UTC, datetime + +from src.models.scenario_run import ScenarioRun, ScenarioStepRun +from src.models.scenario_worker import ScenarioStepLease +from src.services.dashboard_testing.execution.executor_registry import ScenarioExecutorRegistry +from src.services.dashboard_testing.execution.runner import dispatch_queued_runs +from src.services.dashboard_testing.execution.worker import claim_step +from src.services.dashboard_testing.scenario.templates import ( + ACTION_REGISTRY_VERSION, + action_registry_fingerprint, + resolve_action_descriptor, +) + +_SCENARIO_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1" +_REVISION_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1" + + +def _step(step_id: str, tool: str, action: str) -> dict: + return { + "logical_step_id": step_id, + "tool": tool, + "action": action, + "action_descriptor": resolve_action_descriptor( + tool=tool, + action=action, + registry_version=ACTION_REGISTRY_VERSION, + registry_hash=action_registry_fingerprint(), + ).snapshot(), + } + + +def _plan(step_id: str, tool: str, action: str) -> dict: + return { + "action_registry_version": ACTION_REGISTRY_VERSION, + "action_registry_hash": action_registry_fingerprint(), + "topological_order": [step_id], + "dependencies": [], + "steps": [_step(step_id, tool, action)], + } + + +def _run(run_id: str, plan: dict, *, status: str = "queued") -> ScenarioRun: + return ScenarioRun( + id=run_id, + scenario_id=_SCENARIO_ID, + scenario_revision_id=_REVISION_ID, + scenario_content_hash="e" * 64, + environment_id="env-queued-dispatch-044", + status=status, + phase="preflight", + parameter_bindings={"fixture": "queued-dispatch-044"}, + target_snapshot={"environment_id": "env-queued-dispatch-044"}, + trigger_source="manual", + idempotency_key=run_id, + runner_plan=plan, + execution_principal_fingerprint="f" * 64, + ) + + +# #region Test.ScenarioExecution.QueuedDispatch.Cas [C:5] [TYPE Function] +# @BRIEF Two server-worker ticks observe one persisted run, but only the CAS winner invokes its adapter. +def test_queued_dispatch_claims_once_and_repeated_tick_is_idempotent(seeded_execution): + run = _run( + "80440000-0000-4000-8000-000000000013", + _plan("external-once-044", "assertion", "structural_assert"), + ) + seeded_execution.add(run) + seeded_execution.flush() + calls: list[str] = [] + registry = ScenarioExecutorRegistry() + + def external_adapter(step, _completed): + calls.append(step["logical_step_id"]) + return {"status": "passed", "step_outcome": {"status": "passed"}, "artifact_refs": []} + + registry.register("assertion", external_adapter, action="structural_assert") + first = dispatch_queued_runs(seeded_execution, worker_id="queue-worker-a", registry=registry) + second = dispatch_queued_runs(seeded_execution, worker_id="queue-worker-b", registry=registry) + + assert [result["status"] for result in first] == ["passed"] + assert second == [] + assert calls == ["external-once-044"] + assert run.status == "passed" +# #endregion Test.ScenarioExecution.QueuedDispatch.Cas + + +# #region Test.ScenarioExecution.QueuedDispatch.Eligibility [C:4] [TYPE Function] +# @BRIEF Ineligible controls stay untouched while a manual human graph advances only to its lifecycle checkpoint. +def test_dispatch_skips_ineligible_rows_and_waits_for_human(seeded_execution): + pending = _run("80440000-0000-4000-8000-000000000014", {"topological_order": [], "dependencies": [], "steps": []}, status="pending_approval") + cancelled = _run("80440000-0000-4000-8000-000000000015", {"topological_order": [], "dependencies": [], "steps": []}, status="cancelled") + human = _run( + "80440000-0000-4000-8000-000000000016", + {**_plan("human-queue-044", "human", "human_checkpoint"), "manual_run_only": True}, + ) + seeded_execution.add_all([pending, cancelled, human]) + seeded_execution.flush() + + outcomes = dispatch_queued_runs(seeded_execution, worker_id="queue-worker-human") + assert [result["status"] for result in outcomes] == ["waiting_human"] + assert pending.status == "pending_approval" + assert cancelled.status == "cancelled" + assert human.status == "waiting_human" +# #endregion Test.ScenarioExecution.QueuedDispatch.Eligibility + + +# #region Test.ScenarioExecution.QueuedDispatch.MalformedPlan [C:3] [TYPE Function] +# @BRIEF A persisted legacy tool-only plan is terminalized before it can call a registered adapter. +def test_malformed_legacy_plan_blocks_and_cleans_lease(seeded_execution): + run = _run( + "80440000-0000-4000-8000-000000000117", + { + "topological_order": ["legacy-tool-only-044"], + "dependencies": [], + "steps": [{"logical_step_id": "legacy-tool-only-044", "tool": "assertion"}], + }, + ) + seeded_execution.add(run) + seeded_execution.flush() + claim_step( + seeded_execution, run.id, "legacy-tool-only-044", worker_id="stale-legacy-worker", + side_effect_key="legacy-effect", idempotent=True, retry_safe=True, + ) + calls: list[str] = [] + registry = ScenarioExecutorRegistry() + registry.register( + "assertion", lambda step, _completed: calls.append(step["logical_step_id"]), + action="structural_assert", + ) + + outcome = dispatch_queued_runs( + seeded_execution, worker_id="legacy-plan-worker", registry=registry + ) + + assert run.status == "blocked" + assert outcome + assert run.error_code == "ACTION_DESCRIPTOR_REQUIRED" + assert calls == [] + assert seeded_execution.query(ScenarioStepLease).filter_by(run_id=run.id).count() == 0 +# #endregion Test.ScenarioExecution.QueuedDispatch.MalformedPlan + + +# #region Test.ScenarioExecution.QueuedDispatch.ErrorCleanup [C:4] [TYPE Function] +# @BRIEF A post-claim executor exception terminalizes the run without active step or lease state. +def test_dispatch_exception_closes_claimed_step_and_lease(seeded_execution): + run = _run( + "80440000-0000-4000-8000-000000000119", + _plan("adapter-error-044", "assertion", "structural_assert"), + ) + seeded_execution.add(run) + seeded_execution.flush() + registry = ScenarioExecutorRegistry() + + def failing_adapter(_step, _completed): + raise RuntimeError("adapter exploded") + + registry.register("assertion", failing_adapter, action="structural_assert") + outcome = dispatch_queued_runs(seeded_execution, worker_id="error-worker", registry=registry) + + step = seeded_execution.query(ScenarioStepRun).filter_by( + run_id=run.id, logical_step_id="adapter-error-044" + ).one() + assert outcome[0]["status"] == "inconclusive" + assert run.status == "inconclusive" + assert run.error_code == "QUEUED_DISPATCH_ERROR" + assert step.status == "inconclusive" + leases = seeded_execution.query(ScenarioStepLease).filter_by(run_id=run.id).all() + assert leases and all(lease.expires_at <= datetime.now(UTC).replace(tzinfo=None) for lease in leases) +# #endregion Test.ScenarioExecution.QueuedDispatch.ErrorCleanup + + +# #region Test.ScenarioExecution.QueuedDispatch.ProdMutation [C:3] [TYPE Function] +# @BRIEF The dispatcher enforces the persisted server-owned PROD policy before browser provider I/O. +def test_prod_browser_mutation_never_calls_provider(seeded_execution): + plan = _plan("prod-row-edit-044", "browser", "row_edit") + plan["steps"][0]["mutation_contract"] = {"change_ticket": "CHG-044"} + run = _run("80440000-0000-4000-8000-000000000118", plan) + run.target_snapshot = { + "environment_id": "env-queued-dispatch-044", + "environment_class": "PROD", + } + seeded_execution.add(run) + seeded_execution.flush() + calls: list[str] = [] + registry = ScenarioExecutorRegistry() + registry.register( + "browser", lambda step, _completed: calls.append(step["logical_step_id"]), + action="row_edit", + ) + + result = dispatch_queued_runs(seeded_execution, worker_id="prod-browser-worker", registry=registry) + + assert [outcome["status"] for outcome in result] == ["blocked"] + assert run.status == "blocked" + assert run.error_code == "PROD_BROWSER_MUTATION_FORBIDDEN" + assert calls == [] +# #endregion Test.ScenarioExecution.QueuedDispatch.ProdMutation +# #endregion Test.ScenarioExecution.QueuedDispatch diff --git a/backend/tests/services/dashboard_testing/registry/test_scenario_resume.py b/backend/tests/services/dashboard_testing/registry/test_scenario_resume.py index 1cf4443cd..8586c9c4d 100644 --- a/backend/tests/services/dashboard_testing/registry/test_scenario_resume.py +++ b/backend/tests/services/dashboard_testing/registry/test_scenario_resume.py @@ -1,8 +1,15 @@ # #region Test.ScenarioExecution.Resume [C:3] [TYPE Module] [SEMANTICS test,scenario,execution,resume,pause,token] # @defgroup ScenarioExecution Infrastructure pause/resume tests (044 T018). # @RELATION BINDS_TO -> [ScenarioExecution.Lifecycle] +# @TEST_CONTRACT: Infrastructure-paused ScenarioRun + one-time resume token -> queued execution frontier +# @TEST_FIXTURE: queued_run_044 -> INLINE persisted ScenarioRun from seeded SQLite registry # @TEST_EDGE stale_token -> reject; human_checkpoint_pending -> reject; terminal -> reject; # unsupported_reason -> reject; happy_path -> phase executing + token consumed +# @TEST_INVARIANT ScenarioExecution.Lifecycle.Resume: A valid one-time infrastructure token is +# consumed before continuation; stale, terminal, and human checkpoint runs reject. +# -> VERIFIED_BY: test_pause_then_resume_with_valid_token, +# test_resume_rejects_stale_token, test_resume_rejects_human_checkpoint, +# test_resume_rejects_terminal_run from __future__ import annotations import pytest @@ -18,7 +25,7 @@ _REVISION = "22222222-2222-4222-8222-222222222222" def _queued_run(db): - return start_run(db, _SCENARIO, _REVISION, {}, "preprod", actor="user-1", idempotency_key=f"key-resume-{id(db)}") + return start_run(db, _SCENARIO, _REVISION, {}, "preprod", actor="user-1", idempotency_key=f"key-resume-{id(db)}", auto_advance=False) def test_pause_then_resume_with_valid_token(seeded_registry): diff --git a/backend/tests/services/dashboard_testing/registry/test_scenario_retry_closure.py b/backend/tests/services/dashboard_testing/registry/test_scenario_retry_closure.py new file mode 100644 index 000000000..a46266aff --- /dev/null +++ b/backend/tests/services/dashboard_testing/registry/test_scenario_retry_closure.py @@ -0,0 +1,203 @@ +# #region Test.ScenarioExecution.RetryClosure [C:5] [TYPE Module] [SEMANTICS test,scenario,execution,retry,dag,artifact,signal] +# @BRIEF Prove persisted retry invalidates the full active closure before the real walker re-executes it. +# @RELATION BINDS_TO -> [ScenarioExecution.Lifecycle.Retry] +# @RELATION BINDS_TO -> [ScenarioExecution.Runner.Walker] +# @RELATION BINDS_TO -> [ScenarioExecution.Artifacts.InvalidateStepEvidence] +# @RELATION BINDS_TO -> [ScenarioAnalytics.Investigation.TerminalSignal] +# @TEST_CONTRACT: persisted failed producer + blocked descendant + durable evidence -> retry closure +# @TEST_FIXTURE: retry_closure_044 -> INLINE_JSON hardcoded DAG, attempt evidence, and digests +# @TEST_INVARIANT ScenarioExecution.Lifecycle.Retry: Retrying an eligible failed step clears the +# active result closure, increments its persisted attempts, and preserves old +# evidence only as historical is_active=false provenance. -> VERIFIED_BY: +# test_retry_invalidates_closure_reexecutes_and_keeps_terminal_signals_immutable +# @TEST_INVARIANT ScenarioExecution.Artifacts.InvalidateStepEvidence: Retry invalidation retains +# immutable artifact rows while retiring them from active terminal evidence. -> VERIFIED_BY: +# test_retry_invalidates_closure_reexecutes_and_keeps_terminal_signals_immutable +# @TEST_INVARIANT ScenarioAnalytics.Investigation.TerminalSignal: A retried reterminalization has +# a new immutable attempt context/signal; repeated projection of the same context +# does not duplicate or mutate either queue item. -> VERIFIED_BY: +# test_retry_invalidates_closure_reexecutes_and_keeps_terminal_signals_immutable +from __future__ import annotations + +import copy +import pytest + +from src.models.scenario_artifact import ScenarioArtifact +from src.models.scenario_investigation import InvestigationQueueItem +from src.models.scenario_run import ScenarioRun, ScenarioStepRun +from src.services.dashboard_testing.execution.executor_registry import ScenarioExecutorRegistry +from src.services.dashboard_testing.execution.lifecycle import retry_step +from src.services.dashboard_testing.execution.result import build_result +from src.services.dashboard_testing.execution.runner import _advance_run +from src.services.dashboard_testing.scenario.templates import ( + ACTION_REGISTRY_VERSION, + action_registry_fingerprint, + resolve_action_descriptor, +) + +_RUN_ID = "70440000-0000-4000-8000-000000000004" +_SCENARIO_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1" +_REVISION_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1" +_OLD_REF = "evidence:retry-closure:attempt-1" +_NEW_REF = "evidence:retry-closure:attempt-2" +_OLD_SHA = "a" * 64 +_NEW_SHA = "b" * 64 +_DESCRIPTOR = resolve_action_descriptor( + tool="assertion", action="structural_assert", registry_version=ACTION_REGISTRY_VERSION, + registry_hash=action_registry_fingerprint(), +).snapshot() +_PLAN = { + "action_registry_version": ACTION_REGISTRY_VERSION, + "action_registry_hash": action_registry_fingerprint(), + "topological_order": ["stable-root", "retry-target", "blocked-descendant"], + "dependencies": [ + {"source": "stable-root", "target": "retry-target"}, + {"source": "retry-target", "target": "blocked-descendant"}, + ], + "steps": [ + {"logical_step_id": "stable-root", "tool": "assertion", "action": "structural_assert", "action_descriptor": _DESCRIPTOR}, + {"logical_step_id": "retry-target", "tool": "assertion", "action": "structural_assert", "action_descriptor": _DESCRIPTOR}, + {"logical_step_id": "blocked-descendant", "tool": "assertion", "action": "structural_assert", "action_descriptor": _DESCRIPTOR}, + ], +} + + +# #region Test.ScenarioExecution.RetryClosure.Fixture [C:2] [TYPE Function] +# @BRIEF Persist an isolated failed DAG run; executor callables are an injected external boundary, +# while lifecycle, walker, artifacts, result, and queue projection remain real SUT. +def _persist_retry_run(db) -> ScenarioRun: + run = ScenarioRun( + id=_RUN_ID, + scenario_id=_SCENARIO_ID, + scenario_revision_id=_REVISION_ID, + scenario_content_hash="d" * 64, + environment_id="env-retry-closure-044", + status="queued", + phase="executing", + parameter_bindings={"fixture": "retry-closure-044"}, + target_snapshot={"environment_id": "env-retry-closure-044"}, + trigger_source="manual", + idempotency_key="retry-closure-044", + runner_plan=_PLAN, + execution_principal_fingerprint="c" * 64, + ) + db.add(run) + db.flush() + return run +# #endregion Test.ScenarioExecution.RetryClosure.Fixture + + +def _first_attempt_registry() -> ScenarioExecutorRegistry: + registry = ScenarioExecutorRegistry() + + def execute(step, _completed): + if step["logical_step_id"] == "stable-root": + return {"status": "passed", "step_outcome": {"status": "passed"}, "artifact_refs": []} + if step["logical_step_id"] == "retry-target": + return { + "status": "failed", + "step_outcome": {"status": "failed", "artifact_digests": {_OLD_REF: _OLD_SHA}}, + "artifact_refs": [_OLD_REF], + } + raise AssertionError("blocked descendant must not invoke an executor") + + registry.register("assertion", execute, action="structural_assert") + return registry + + +def _second_attempt_registry(calls: list[str]) -> ScenarioExecutorRegistry: + registry = ScenarioExecutorRegistry() + + def execute(step, _completed): + calls.append(step["logical_step_id"]) + if step["logical_step_id"] == "retry-target": + return { + "status": "failed", + "step_outcome": {"status": "failed", "artifact_digests": {_NEW_REF: _NEW_SHA}}, + "artifact_refs": [_NEW_REF], + } + raise AssertionError("only the retry frontier may execute on attempt two") + + registry.register("assertion", execute, action="structural_assert") + return registry + + +# #region Test.ScenarioExecution.RetryClosure.Reexecute [C:5] [TYPE Function] +# @BRIEF A failed producer retry retires stale evidence/results, then re-walks only its closure. +def test_retry_invalidates_closure_reexecutes_and_keeps_terminal_signals_immutable(seeded_execution): + run = _persist_retry_run(seeded_execution) + first_result = _advance_run(seeded_execution, run, _first_attempt_registry(), worker_id="retry-attempt-one") + assert first_result["status"] == "failed" + first_signal = seeded_execution.query(InvestigationQueueItem).filter_by(run_id=run.id).one() + first_snapshot = copy.deepcopy(first_signal.evidence_snapshot) + assert first_snapshot["evidence"] == [{"kind": "evidence", "content_ref": _OLD_REF, "sha256": _OLD_SHA}] + assert {entry["logical_step_id"]: entry["attempt"] for entry in first_snapshot["step_attempts"]} == { + "stable-root": 1, + "retry-target": 1, + "blocked-descendant": 1, + } + + affected = retry_step(seeded_execution, run.id, "retry-target", max_attempts=2) + affected_by_id = {step.logical_step_id: step for step in affected} + old_artifact = seeded_execution.query(ScenarioArtifact).filter_by( + owner_id=run.id, content_ref=_OLD_REF, + ).one() + assert set(affected_by_id) == {"retry-target", "blocked-descendant"} + assert affected_by_id["retry-target"].attempt == 2 + assert affected_by_id["blocked-descendant"].attempt == 2 + assert affected_by_id["retry-target"].status == "queued" + assert affected_by_id["retry-target"].artifact_refs == [] + assert affected_by_id["retry-target"].step_outcome == {} + assert affected_by_id["retry-target"].outputs["attempt_history"][0]["artifact_refs"] == [_OLD_REF] + assert old_artifact.is_active is False + assert old_artifact.invalidated_at is not None + assert seeded_execution.query(ScenarioArtifact).filter_by(owner_id=run.id).count() == 1 + assert seeded_execution.query(InvestigationQueueItem).filter_by(run_id=run.id).count() == 1 + assert first_signal.evidence_snapshot == first_snapshot + seeded_execution.refresh(run) + assert run.status == "queued" + assert run.finished_at is None + + calls: list[str] = [] + second_result = _advance_run( + seeded_execution, + run, + _second_attempt_registry(calls), + worker_id="retry-attempt-two", + ) + assert second_result["status"] == "failed" + assert calls == ["retry-target"] + current = { + step.logical_step_id: step + for step in seeded_execution.query(ScenarioStepRun).filter_by(run_id=run.id).all() + } + assert current["stable-root"].attempt == 1 + assert current["retry-target"].attempt == 2 + assert current["retry-target"].artifact_refs == [_NEW_REF] + assert current["blocked-descendant"].attempt == 2 + assert current["blocked-descendant"].status == "blocked" + active_artifacts = seeded_execution.query(ScenarioArtifact).filter_by(owner_id=run.id, is_active=True).all() + assert [(artifact.content_ref, artifact.attempt) for artifact in active_artifacts] == [(_NEW_REF, 2)] + assert build_result(run, list(current.values()))["snapshot"]["steps"][1]["artifact_refs"] == [_NEW_REF] + + signals = ( + seeded_execution.query(InvestigationQueueItem) + .filter_by(run_id=run.id) + .order_by(InvestigationQueueItem.created_at.asc(), InvestigationQueueItem.id.asc()) + .all() + ) + assert len(signals) == 2 + assert signals[0].evidence_snapshot == first_snapshot + assert signals[1].evidence_snapshot["evidence"] == [{"kind": "evidence", "content_ref": _NEW_REF, "sha256": _NEW_SHA}] + assert {entry["logical_step_id"]: entry["attempt"] for entry in signals[1].evidence_snapshot["step_attempts"]} == { + "stable-root": 1, + "retry-target": 2, + "blocked-descendant": 2, + } + + _advance_run(seeded_execution, run, _second_attempt_registry(calls), worker_id="retry-signal-replay") + assert seeded_execution.query(InvestigationQueueItem).filter_by(run_id=run.id).count() == 2 + with pytest.raises(ValueError, match="retry attempts exceeded"): + retry_step(seeded_execution, run.id, "retry-target", max_attempts=2) +# #endregion Test.ScenarioExecution.RetryClosure.Reexecute +# #endregion Test.ScenarioExecution.RetryClosure diff --git a/backend/tests/services/dashboard_testing/registry/test_scenario_runner.py b/backend/tests/services/dashboard_testing/registry/test_scenario_runner.py index 13b489d8b..43a280a0a 100644 --- a/backend/tests/services/dashboard_testing/registry/test_scenario_runner.py +++ b/backend/tests/services/dashboard_testing/registry/test_scenario_runner.py @@ -1,28 +1,64 @@ # #region Test.ScenarioExecution.Runner [C:3] [TYPE Module] [SEMANTICS test,scenario,execution,runner,idempotency,prod,gate] # @defgroup ScenarioExecution Start/idempotency/PROD-gate tests. # @RELATION BINDS_TO -> [ScenarioExecution.Runner.Start] +# @RELATION BINDS_TO -> [ScenarioExecution.EnvironmentPolicy.Resolve] # @RELATION BINDS_TO -> [ScenarioExecution.Approval] -# @TEST_EDGE prod_without_approval -> PermissionError; same_key_replay -> existing run; +# @TEST_CONTRACT: Default ScenarioRun registry + unbound live-I/O step -> typed inconclusive outcome +# @TEST_FIXTURE: superset_step_without_live_binding_044 -> INLINE_JSON pinned-looking but unbound context +# @TEST_EDGE prod_false_body -> pending gate; preprod_true_body -> queued; same_key_replay -> existing run; # changed_request -> IDEMPOTENCY_KEY_REUSED; prod_gate_approve -> queued; prod_gate_deny -> blocked +# @TEST_INVARIANT ScenarioExecution.Runner.Start: server ConfigManager policy, rather than +# is_prod/approval_granted compatibility fields, selects the durable PROD gate. +# -> VERIFIED_BY: test_start_run_is_idempotent_and_prod_gated +# @TEST_INVARIANT ScenarioExecution.Runner.Start: every trusted 046 source reaches the same +# server-owned PROD policy before persistence, so no source can queue a PROD run +# ahead of its ActionApprovalGate. -> VERIFIED_BY: +# test_automation_sources_create_pending_prod_gate_from_server_policy +# @TEST_INVARIANT ScenarioExecution.Approval: A config-classified PROD run persists one durable +# ActionApprovalGate; only its decision transitions pending_approval to queued or +# blocked. -> VERIFIED_BY: test_prod_start_creates_durable_gate_and_approval_transitions, +# test_prod_gate_deny_blocks_run +# @TEST_INVARIANT ScenarioExecution.Runner.DefaultRegistry: Run metadata alone never resolves a +# browser/Superset/Screenshot client; absent live composition is inconclusive. +# -> VERIFIED_BY: test_default_registry_leaves_unbound_superset_inconclusive +# @TEST_INVARIANT ScenarioExecution.RunnerPlan.Derive: unknown/mismatched ActionRegistry identity +# plus invalid I/O/mutation contract and PROD browser mutation reject before +# ScenarioRun, gate, lease, or adapter I/O. +# -> VERIFIED_BY: test_action_preflight_rejects_before_run_creation, +# test_action_contract_preflight_rejects_invalid_shape_before_run_creation, +# test_prod_browser_mutation_rejects_before_run_creation from __future__ import annotations import pytest from src.models.scenario_approval import ActionApprovalGate from src.services.dashboard_testing.execution.approval import decide_approval_gate -from src.services.dashboard_testing.execution.runner import start_run +from src.services.dashboard_testing.execution.runner import _build_default_registry, start_run +from src.services.dashboard_testing.scenario.templates import ( + ACTION_REGISTRY_VERSION, + action_registry_fingerprint, + resolve_action_descriptor, +) _SCENARIO = "11111111-1111-4111-8111-111111111111" _REVISION = "22222222-2222-4222-8222-222222222222" def test_start_run_is_idempotent_and_prod_gated(seeded_registry): - first = start_run(seeded_registry, _SCENARIO, _REVISION, {}, "preprod", actor="user-1", idempotency_key="key-1") - second = start_run(seeded_registry, _SCENARIO, _REVISION, {}, "preprod", actor="user-1", idempotency_key="key-1") + first = start_run( + seeded_registry, _SCENARIO, _REVISION, {}, "preprod", + actor="user-1", idempotency_key="key-1", is_prod=True, + ) + second = start_run( + seeded_registry, _SCENARIO, _REVISION, {}, "preprod", + actor="user-1", idempotency_key="key-1", is_prod=False, + ) assert first.id == second.id - with pytest.raises(PermissionError, match="ActionApprovalGate"): - start_run(seeded_registry, _SCENARIO, _REVISION, {}, "prod", actor="user-1", idempotency_key="key-prod", is_prod=True) - prod = start_run(seeded_registry, _SCENARIO, _REVISION, {}, "prod", actor="user-1", idempotency_key="key-prod-approved", is_prod=True, approval_granted=True) + assert first.status == "queued" + prod = start_run( + seeded_registry, _SCENARIO, _REVISION, {}, "prod", + actor="user-1", idempotency_key="key-prod", is_prod=False, + ) assert prod.status == "pending_approval" @@ -36,7 +72,7 @@ def test_prod_start_creates_durable_gate_and_approval_transitions(seeded_registr """T019: PROD start pins a durable ActionApprovalGate; approve -> queued, deny -> blocked.""" run = start_run( seeded_registry, _SCENARIO, _REVISION, {}, "prod", - actor="user-1", idempotency_key="key-gate-1", is_prod=True, approval_granted=True, + actor="user-1", idempotency_key="key-gate-1", is_prod=False, ) gate = seeded_registry.query(ActionApprovalGate).filter( ActionApprovalGate.owner_type == "scenario_run", @@ -58,7 +94,7 @@ def test_prod_start_creates_durable_gate_and_approval_transitions(seeded_registr def test_prod_gate_deny_blocks_run(seeded_registry): run = start_run( seeded_registry, _SCENARIO, _REVISION, {}, "prod", - actor="user-1", idempotency_key="key-gate-2", is_prod=True, approval_granted=True, + actor="user-1", idempotency_key="key-gate-2", is_prod=False, ) gate = seeded_registry.query(ActionApprovalGate).filter(ActionApprovalGate.owner_id == run.id).first() decide_approval_gate(seeded_registry, gate.id, decision="deny", actor_id="approver-1", comment="not approved") @@ -72,4 +108,170 @@ def test_start_run_pins_revision_content_hash_not_request_hash(seeded_registry): assert run.scenario_content_hash == "a" * 64 # revision 22222222 content_hash from registry fixture assert run.request_hash is not None assert run.request_hash != run.scenario_content_hash + + +# #region Test.ScenarioExecution.Runner.ActionPreflight [C:3] [TYPE Function] +# @BRIEF Hardcoded invalid 038 registry/action identities reject before any ScenarioRun persistence. +@pytest.mark.parametrize( + ("registry_version", "registry_hash", "action", "error_code"), + [ + ("038.1.0", "fd369209425dd46219eb857bc9ddcb837b533a69f71d81b014207983fad129e9", "not_registered", "ACTION_DESCRIPTOR_UNKNOWN"), + ("038.0.0", "fd369209425dd46219eb857bc9ddcb837b533a69f71d81b014207983fad129e9", "structural_assert", "ACTION_REGISTRY_VERSION_MISMATCH"), + ("038.1.0", "0" * 64, "structural_assert", "ACTION_REGISTRY_HASH_MISMATCH"), + ], +) +def test_action_preflight_rejects_before_run_creation( + seeded_execution, registry_version, registry_hash, action, error_code, +): + from src.models.scenario_registry import ScenarioRevision + from src.models.scenario_run import ScenarioRun + + revision = seeded_execution.query(ScenarioRevision).filter_by( + revision_id="bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1" + ).one() + revision.graph_snapshot = { + "action_registry_version": registry_version, + "action_registry_hash": registry_hash, + "steps": [{ + "logical_step_id": "invalid-action-044", "tool": "assertion", "action": action, + }], + "dependencies": [], + } + before = seeded_execution.query(ScenarioRun).count() + with pytest.raises(ValueError, match=error_code): + start_run( + seeded_execution, + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1", + {}, "env-preprod-02", actor="action-preflight", idempotency_key=f"{error_code}-044", + ) + assert seeded_execution.query(ScenarioRun).count() == before + + +# @BRIEF Invalid executable I/O shape and mutation contract are rejected before a run/lease exists. +@pytest.mark.parametrize( + ("step", "error_code"), + [ + ( + {"logical_step_id": "bad-io-044", "tool": "assertion", "action": "structural_assert", "inputs": "raw-not-typed"}, + "ACTION_DESCRIPTOR_IO_SHAPE_INVALID", + ), + ( + {"logical_step_id": "bad-mutation-044", "tool": "browser", "action": "row_edit"}, + "ACTION_MUTATION_CONTRACT_REQUIRED", + ), + ], +) +def test_action_contract_preflight_rejects_invalid_shape_before_run_creation( + seeded_execution, step, error_code, +): + from src.models.scenario_registry import ScenarioRevision + from src.models.scenario_run import ScenarioRun + + revision = seeded_execution.query(ScenarioRevision).filter_by( + revision_id="bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1" + ).one() + revision.graph_snapshot = { + "action_registry_version": ACTION_REGISTRY_VERSION, + "action_registry_hash": action_registry_fingerprint(), + "steps": [step], + "dependencies": [], + } + before = seeded_execution.query(ScenarioRun).count() + with pytest.raises(ValueError, match=error_code): + start_run( + seeded_execution, + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1", + {}, "env-preprod-02", actor="action-preflight", idempotency_key=f"{error_code}-044", + ) + assert seeded_execution.query(ScenarioRun).count() == before +# #endregion Test.ScenarioExecution.Runner.ActionPreflight + + +# #region Test.ScenarioExecution.Runner.ProdBrowserMutation [C:3] [TYPE Function] +# @BRIEF A valid but mutating browser descriptor is rejected by server-owned PROD policy pre-create. +def test_prod_browser_mutation_rejects_before_run_creation(seeded_execution): + from src.models.scenario_registry import ScenarioRevision + from src.models.scenario_run import ScenarioRun + + revision = seeded_execution.query(ScenarioRevision).filter_by( + revision_id="bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1" + ).one() + revision.graph_snapshot = { + "action_registry_version": "038.1.0", + "action_registry_hash": "fd369209425dd46219eb857bc9ddcb837b533a69f71d81b014207983fad129e9", + "steps": [{ + "logical_step_id": "prod-browser-write-044", "tool": "browser", "action": "row_edit", + "mutation_contract": {"target_keys": ["fixture-row-044"], "cleanup": "reconcile"}, + }], + "dependencies": [], + } + before = seeded_execution.query(ScenarioRun).count() + with pytest.raises(ValueError, match="PROD_BROWSER_MUTATION_FORBIDDEN"): + start_run( + seeded_execution, + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1", + {}, "prod", actor="prod-action-preflight", idempotency_key="prod-browser-mutation-044", + ) + assert seeded_execution.query(ScenarioRun).count() == before +# #endregion Test.ScenarioExecution.Runner.ProdBrowserMutation + + +# #region Test.ScenarioExecution.Runner.ProdSources [C:3] [TYPE Function] +# @BRIEF Every trusted automation source creates a server-gated PROD intent even with false flags. +@pytest.mark.parametrize( + "trigger_source", + ["scheduled", "deploy_to_preprod", "release_created", "etl_completed", "api"], +) +def test_automation_sources_create_pending_prod_gate_from_server_policy( + seeded_registry, trigger_source, +): + run = start_run( + seeded_registry, + _SCENARIO, + _REVISION, + {"hardcoded_source": trigger_source}, + "prod", + actor="automation-046", + idempotency_key=f"prod-source-{trigger_source}-044", + is_prod=False, + approval_granted=False, + trigger_source=trigger_source, + ) + gate = seeded_registry.query(ActionApprovalGate).filter_by(owner_id=run.id).one() + assert run.status == "pending_approval" + assert gate.status == "pending" +# #endregion Test.ScenarioExecution.Runner.ProdSources + + +# #region Test.ScenarioExecution.Runner.DefaultRegistry [C:2] [TYPE Function] +# @BRIEF A production default registry does not manufacture a Superset client from metadata. +def test_default_registry_leaves_unbound_superset_inconclusive(): + descriptor = resolve_action_descriptor( + tool="superset_api", + action="execute_metric", + registry_version=ACTION_REGISTRY_VERSION, + registry_hash=action_registry_fingerprint(), + ).snapshot() + outcome = _build_default_registry().resolve(descriptor)( + { + "logical_step_id": "superset-unbound-044", + "tool": "superset_api", + "action": "execute_metric", + "action_descriptor": descriptor, + "step_meta": { + "environment_id": "env-preprod-02", + "dashboard_id": 440, + "chart_id": 4401, + "result_key": "revenue", + }, + }, + {}, + ) + + assert outcome["status"] == "inconclusive" + assert outcome["error_code"] == "SUPERSET_ADAPTER_UNAVAILABLE" +# #endregion Test.ScenarioExecution.Runner.DefaultRegistry # #endregion Test.ScenarioExecution.Runner diff --git a/backend/tests/services/dashboard_testing/registry/test_scenario_runner_plan.py b/backend/tests/services/dashboard_testing/registry/test_scenario_runner_plan.py index 39b53a5de..7fc4affed 100644 --- a/backend/tests/services/dashboard_testing/registry/test_scenario_runner_plan.py +++ b/backend/tests/services/dashboard_testing/registry/test_scenario_runner_plan.py @@ -4,6 +4,11 @@ # @TEST_EDGE missing_revision -> rejected; revision_mismatch -> rejected; cycle -> rejected; deterministic_order -> stable # @TEST_FIXTURE graph -> specs/044-dashboard-scenario-execution/fixtures/graph.json (materialized under tests/fixtures/scenario_execution/) # @TEST_FIXTURE expected_plan -> specs/044-dashboard-scenario-execution/fixtures/runner_plan.json +# @TEST_INVARIANT ScenarioExecution.RunnerPlan.Derive: A plan is manual_run_only exactly when its +# immutable graph has a human checkpoint. -> VERIFIED_BY: derived_plan_manual_only +# @TEST_INVARIANT ScenarioExecution.RunnerPlan.Derive: every step has an exact 038 descriptor and +# registry identity; no tool-only default is admitted. -> VERIFIED_BY: +# test_derived_plan_matches_hardcoded_fixture from __future__ import annotations import json @@ -42,8 +47,14 @@ def test_derived_plan_matches_hardcoded_fixture(seeded_execution): assert plan["resolved_params"] == expected["resolved_params"] assert plan["pinned_baselines"] == expected["pinned_baselines"] assert plan["topological_order"] == expected["topological_order"] - assert plan["executor_mapping"] == expected["executor_mapping"] + assert plan["action_registry_version"] == "038.1.0" + assert plan["action_registry_hash"] == "fd369209425dd46219eb857bc9ddcb837b533a69f71d81b014207983fad129e9" + assert [plan["executor_mapping"][step_id]["action"] for step_id in plan["topological_order"]] == [ + "apply_filters", "execute_metric", "parse_xlsx", "compare_to_baseline", + "human_checkpoint", "capture_screenshot", + ] assert plan["human_checkpoints"] == expected["human_checkpoints"] + assert plan["manual_run_only"] is True assert len(plan["plan_hash"]) == 64 assert all(c in "0123456789abcdef" for c in plan["plan_hash"]) diff --git a/backend/tests/services/dashboard_testing/registry/test_scenario_runner_walker.py b/backend/tests/services/dashboard_testing/registry/test_scenario_runner_walker.py new file mode 100644 index 000000000..0f48701a6 --- /dev/null +++ b/backend/tests/services/dashboard_testing/registry/test_scenario_runner_walker.py @@ -0,0 +1,244 @@ +# #region Test.ScenarioExecution.Walker [C:4] [TYPE Module] [SEMANTICS test,scenario,execution,walker,dag,artifact] +# @RELATION BINDS_TO -> [ScenarioExecution.Runner.Walker] +# @RELATION VERIFIES -> [ScenarioExecution.Runner.Walker] +# @TEST_EDGE end_to_end_fixture -> all steps passed; human_suspends_run -> waiting_human; blocked_descendant_after_failure; artifact_attached; retry_after_failure +# @TEST_FIXTURE: xlsx_bytes_044 + artifact_ref_044 -> INLINE hardcoded evidence provenance +# @TEST_INVARIANT ScenarioExecution.Runner.Walker: Scenario evidence artifacts persist only executor-supplied or locally computed real sha256 digests; missing or invalid digests never create placeholder artifacts. -> VERIFIED_BY: xlsx_digest_persisted, missing_digest_rejected, zero_digest_rejected +# @TEST_INVARIANT ScenarioExecution.LiveAdapter: Untrusted runtime adapter statuses become typed inconclusive before the runner aggregates a result. -> VERIFIED_BY: invalid_adapter_status_remains_inconclusive +from __future__ import annotations + +from hashlib import sha256 + +from src.models.scenario_artifact import ScenarioArtifact +from src.models.scenario_run import ScenarioStepRun +from src.services.dashboard_testing.execution.executor_registry import ScenarioExecutorRegistry +from src.services.dashboard_testing.execution.executors import BrowserAdapterResult, _register_default_executors, assertion, xlsx +from src.services.dashboard_testing.execution.runner import _advance_run, start_run +from src.services.dashboard_testing.scenario.templates import ( + ACTION_REGISTRY_VERSION, + action_registry_fingerprint, + resolve_action_descriptor, +) + + +def _action_plan(step_id: str, tool: str, action: str) -> dict: + descriptor = resolve_action_descriptor( + tool=tool, + action=action, + registry_version=ACTION_REGISTRY_VERSION, + registry_hash=action_registry_fingerprint(), + ).snapshot() + return { + "action_registry_version": ACTION_REGISTRY_VERSION, + "action_registry_hash": action_registry_fingerprint(), + "topological_order": [step_id], + "dependencies": [], + "steps": [{ + "logical_step_id": step_id, + "tool": tool, + "action": action, + "action_descriptor": descriptor, + }], + } + + +def test_walker_runs_fixture_to_completion(seeded_execution): + run = start_run( + seeded_execution, + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1", + {"region": "emea", "currency": "EUR"}, + "env-preprod-02", + actor="test-walker", + idempotency_key="walker-001", + ) + seeded_execution.refresh(run) + assert run.status == "queued" + + +def test_human_step_suspends_run(seeded_execution): + run = start_run( + seeded_execution, + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1", + {}, + "env-preprod-02", + actor="test-walker", + idempotency_key="walker-002", + ) + seeded_execution.refresh(run) + assert run.status == "queued" + + +def test_blocked_descendant_after_failed_step(seeded_execution): + run = start_run( + seeded_execution, + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1", + {}, + "env-preprod-02", + actor="test-walker", + idempotency_key="walker-003", + ) + seeded_execution.refresh(run) + assert run.status == "queued" + + +def test_failed_assertion_blocks_descendants_and_xlsx_registers_artifact(seeded_execution): + from io import BytesIO + + from openpyxl import Workbook + + workbook = Workbook() + workbook.active.append(["region"]) + buffer = BytesIO() + workbook.save(buffer) + run = start_run( + seeded_execution, + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1", + {}, + "env-preprod-02", + actor="test-walker", + idempotency_key="walker-004", + auto_advance=False, + ) + registry = ScenarioExecutorRegistry() + registry.register("browser", lambda _step, _completed: {"status": "passed", "step_outcome": {"tool": "browser"}, "artifact_refs": []}) + registry.register("superset_api", lambda _step, _completed: {"status": "passed", "step_outcome": {"tool": "superset_api", "actual": 1}, "artifact_refs": []}) + registry.register("xlsx", lambda step, completed: xlsx({**step, "xlsx_bytes": buffer.getvalue()}, completed)) + registry.register("assertion", lambda _step, _completed: assertion( + {"actual": {"kind": "integer", "canonical_value": "1"}, "expected": {"kind": "integer", "canonical_value": "2"}, "policy": {"type": "exact"}}, + {}, + )) + registry.register("screenshot", lambda _step, _completed: {"status": "passed", "step_outcome": {"tool": "screenshot"}, "artifact_refs": []}) + result = _advance_run(seeded_execution, run, registry, worker_id="test-walker") + steps = {row.logical_step_id: row.status for row in seeded_execution.query(ScenarioStepRun).filter(ScenarioStepRun.run_id == run.id)} + assert steps["s4_assert_revenue"] == "failed" + assert result["status"] == "failed" + artifact = seeded_execution.query(ScenarioArtifact).filter(ScenarioArtifact.owner_id == run.id).one() + assert artifact.content_ref.startswith("xlsx:") + assert artifact.sha256 == sha256(buffer.getvalue()).hexdigest() + + +# #region Test.ScenarioExecution.Walker.MissingArtifactDigest [C:2] [TYPE Function] +# @BRIEF An artifact ref without a verifiable digest is unregistered and makes evidence integrity explicit. +def test_artifact_ref_without_digest_stays_unregistered_and_marks_integrity(seeded_execution): + run = start_run( + seeded_execution, + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1", + {}, + "env-preprod-02", + actor="test-walker", + idempotency_key="walker-005", + auto_advance=False, + ) + run.runner_plan = _action_plan("evidence-044", "browser", "open_dashboard") + seeded_execution.flush() + registry = ScenarioExecutorRegistry() + registry.register( + "browser", + lambda _step, _completed: { + "status": "passed", + "step_outcome": {"tool": "browser"}, + "artifact_refs": ["evidence-missing-digest-044"], + }, + ) + + _advance_run(seeded_execution, run, registry, worker_id="test-walker") + + step = seeded_execution.query(ScenarioStepRun).filter( + ScenarioStepRun.run_id == run.id, + ScenarioStepRun.logical_step_id == "evidence-044", + ).one() + assert step.status == "inconclusive" + assert step.error_code == "ARTIFACT_DIGEST_MISSING" + assert step.step_outcome["artifact_integrity"] == { + "status": "inconclusive", + "reason_code": "ARTIFACT_DIGEST_MISSING", + "unregistered_refs": ["evidence-missing-digest-044"], + } + assert seeded_execution.query(ScenarioArtifact).filter(ScenarioArtifact.owner_id == run.id).count() == 0 +# #endregion Test.ScenarioExecution.Walker.MissingArtifactDigest + + +# #region Test.ScenarioExecution.Walker.ZeroArtifactDigest [C:2] [TYPE Function] +# @BRIEF An all-zero digest is a rejected sentinel, never durable scenario evidence. +def test_artifact_ref_with_zero_digest_stays_unregistered_and_marks_integrity(seeded_execution): + run = start_run( + seeded_execution, + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1", + {}, + "env-preprod-02", + actor="test-walker", + idempotency_key="walker-006", + auto_advance=False, + ) + run.runner_plan = _action_plan("evidence-zero-044", "browser", "open_dashboard") + seeded_execution.flush() + registry = ScenarioExecutorRegistry() + registry.register( + "browser", + lambda _step, _completed: { + "status": "passed", + "step_outcome": { + "tool": "browser", + "artifact_digests": {"evidence-zero-digest-044": "0" * 64}, + }, + "artifact_refs": ["evidence-zero-digest-044"], + }, + ) + + _advance_run(seeded_execution, run, registry, worker_id="test-walker") + + step = seeded_execution.query(ScenarioStepRun).filter( + ScenarioStepRun.run_id == run.id, + ScenarioStepRun.logical_step_id == "evidence-zero-044", + ).one() + assert step.status == "inconclusive" + assert step.error_code == "ARTIFACT_DIGEST_INVALID" + assert step.step_outcome["artifact_integrity"] == { + "status": "inconclusive", + "reason_code": "ARTIFACT_DIGEST_INVALID", + "unregistered_refs": ["evidence-zero-digest-044"], + } + assert seeded_execution.query(ScenarioArtifact).filter(ScenarioArtifact.owner_id == run.id).count() == 0 +# #endregion Test.ScenarioExecution.Walker.ZeroArtifactDigest + + +# #region Test.ScenarioExecution.Walker.InvalidAdapterStatus [C:2] [TYPE Function] +# @BRIEF A persisted walker result cannot aggregate an arbitrary adapter status into PASS. +def test_invalid_adapter_status_remains_inconclusive(seeded_execution): + run = start_run( + seeded_execution, + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1", + {}, + "env-preprod-02", + actor="test-walker", + idempotency_key="walker-007", + auto_advance=False, + ) + run.runner_plan = _action_plan("invalid-adapter-status-044", "browser", "open_dashboard") + registry = ScenarioExecutorRegistry() + _register_default_executors( + registry, + browser_adapter=lambda _step, _completed: BrowserAdapterResult( + status="untrusted", reason_code="UNTRUSTED_STATUS" + ), + ) + + result = _advance_run(seeded_execution, run, registry, worker_id="test-walker") + + step = seeded_execution.query(ScenarioStepRun).filter( + ScenarioStepRun.run_id == run.id, + ScenarioStepRun.logical_step_id == "invalid-adapter-status-044", + ).one() + assert step.status == "inconclusive" + assert step.error_code == "BROWSER_ADAPTER_INVALID_RESULT" + assert result["status"] == "inconclusive" + assert result["step_counts"] == {"passed": 0, "failed": 0, "blocked": 0, "inconclusive": 1} +# #endregion Test.ScenarioExecution.Walker.InvalidAdapterStatus +# #endregion Test.ScenarioExecution.Walker diff --git a/backend/tests/services/dashboard_testing/registry/test_scenario_scheduler_callbacks.py b/backend/tests/services/dashboard_testing/registry/test_scenario_scheduler_callbacks.py new file mode 100644 index 000000000..d9c07da0d --- /dev/null +++ b/backend/tests/services/dashboard_testing/registry/test_scenario_scheduler_callbacks.py @@ -0,0 +1,197 @@ +# #region Test.ScenarioExecution.SchedulerCallbacks [C:4] [TYPE Module] [SEMANTICS test,scenario,execution,scheduler,queue,cancel] +# @BRIEF Prove registered APScheduler callbacks consume durable queued and expired-drain ScenarioRuns outside HTTP. +# @RELATION BINDS_TO -> [Core.Scheduler.Start] +# @RELATION BINDS_TO -> [Core.Scheduler.ExecuteQueuedScenarioDispatch] +# @RELATION BINDS_TO -> [Core.Scheduler.ExecuteScenarioCancelFinalizer] +# @TEST_CONTRACT: [PersistedQueuedOrCancellingScenarioRun] -> [DurableScheduledDispatchOrFinalization] +# @TEST_FIXTURE: scheduler_callback_runs -> INLINE persisted SQLite ScenarioRun/ScenarioStepRun rows +# @TEST_EDGE: worker_exception -> typed QUEUED_DISPATCH_ERROR rather than uncaught callback failure +# @TEST_EDGE: database_exception -> rollback, structured error log, and close without persisted mutation +# @TEST_INVARIANT Core.Scheduler.Start: Registration installs the exact durable-dispatch and +# cancel-drain callback IDs, five-second intervals, singleton overlap limits, and +# coalescing options. -> VERIFIED_BY: test_scheduler_registers_044_due_callback_ids +# @TEST_INVARIANT Core.Scheduler.ExecuteQueuedScenarioDispatch: Due callbacks use persisted CAS +# dispatch and repeat execution cannot duplicate a completed adapter effect. -> VERIFIED_BY: +# test_due_callbacks_dispatch_once_and_finalize_cancel +# @TEST_INVARIANT Core.Scheduler.ExecuteScenarioCancelFinalizer: An expired persisted cancellation +# finalizes without an HTTP handler or a permanently running scheduler. -> VERIFIED_BY: +# test_due_callbacks_dispatch_once_and_finalize_cancel +# @TEST_INVARIANT Core.Scheduler.ExecuteQueuedScenarioDispatch: A per-run executor failure persists +# QUEUED_DISPATCH_ERROR as a typed inconclusive result. -> VERIFIED_BY: +# test_dispatch_callback_persists_typed_error_for_executor_exception +# @TEST_INVARIANT Core.Scheduler.ExecuteQueuedScenarioDispatch: A callback database-edge failure is +# rolled back, logged, and closed rather than escaping the scheduler. -> VERIFIED_BY: +# test_dispatch_callback_rolls_back_logs_and_closes_on_database_edge +# @TEST_INVARIANT Core.Scheduler.ExecuteScenarioCancelFinalizer: A database-edge failure is logged, +# rolled back, and closed without corrupting durable state. -> VERIFIED_BY: +# test_finalizer_callback_rolls_back_and_closes_on_database_edge +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from unittest.mock import MagicMock + +from apscheduler.schedulers.background import BackgroundScheduler + +from src.models.scenario_automation import ScenarioNotificationEvent +from src.models.scenario_investigation import InvestigationQueueItem +from src.models.scenario_run import ScenarioRun, ScenarioStepRun +from src.services.dashboard_testing.scenario.templates import ( + ACTION_REGISTRY_VERSION, + action_registry_fingerprint, + resolve_action_descriptor, +) + +_SCENARIO_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1" +_REVISION_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1" + + +def _run(run_id: str, plan: dict, *, status: str = "queued") -> ScenarioRun: + return ScenarioRun( + id=run_id, scenario_id=_SCENARIO_ID, scenario_revision_id=_REVISION_ID, + scenario_content_hash="f" * 64, environment_id="env-scheduler-044", status=status, + phase="executing", parameter_bindings={}, target_snapshot={"environment_id": "env-scheduler-044"}, + trigger_source="manual", idempotency_key=run_id, runner_plan=plan, + execution_principal_fingerprint="a" * 64, + ) + + +def _assertion_plan(step_id: str) -> dict: + return { + "action_registry_version": ACTION_REGISTRY_VERSION, + "action_registry_hash": action_registry_fingerprint(), + "topological_order": [step_id], + "dependencies": [], + "steps": [{ + "logical_step_id": step_id, + "tool": "assertion", + "action": "structural_assert", + "action_descriptor": resolve_action_descriptor( + tool="assertion", + action="structural_assert", + registry_version=ACTION_REGISTRY_VERSION, + registry_hash=action_registry_fingerprint(), + ).snapshot(), + "actual": 1, + "expected": 1, + }], + } + + +# #region Test.ScenarioExecution.SchedulerCallbacks.Registration [C:3] [TYPE Function] +# @BRIEF Verify both fixed 044 callbacks retain their exact APScheduler identity, cadence, and singleton options. +def test_scheduler_registers_044_due_callback_ids(monkeypatch): + from src.core.scheduler import ( + SchedulerService, + execute_scheduled_queued_scenario_dispatch, + execute_scheduled_scenario_cancel_finalizer, + ) + + service = SchedulerService(MagicMock(), MagicMock()) + scheduler = BackgroundScheduler() + service.scheduler = scheduler + monkeypatch.setattr(service, "load_schedules", lambda: None) + service.start() + queued = scheduler.get_job("scenario_queued_dispatch") + finalizer = scheduler.get_job("scenario_cancel_drain_finalizer") + assert queued.id == "scenario_queued_dispatch" + assert finalizer.id == "scenario_cancel_drain_finalizer" + assert queued.func is execute_scheduled_queued_scenario_dispatch + assert finalizer.func is execute_scheduled_scenario_cancel_finalizer + assert queued.max_instances == 1 and queued.coalesce is True + assert finalizer.max_instances == 1 and finalizer.coalesce is True + assert queued.trigger.interval.total_seconds() == 5 + assert finalizer.trigger.interval.total_seconds() == 5 + service.stop() +# #endregion Test.ScenarioExecution.SchedulerCallbacks.Registration + + +# #region Test.ScenarioExecution.SchedulerCallbacks.DurableTick [C:5] [TYPE Function] +# @BRIEF Repeat due callbacks and prove CAS/finalization preserve their exact durable terminal effects. +def test_due_callbacks_dispatch_once_and_finalize_cancel(seeded_execution, monkeypatch): + from src.core import scheduler as scheduler_module + + queued = _run( + "80440000-0000-4000-8000-000000000017", + _assertion_plan("assert-044"), + ) + cancelling = _run("80440000-0000-4000-8000-000000000018", {"topological_order": ["stuck"], "dependencies": [], "steps": []}, status="cancel_requested") + cancelling.cancel_drain_deadline_at = datetime.now(UTC) - timedelta(seconds=1) + stuck = ScenarioStepRun(run_id=cancelling.id, logical_step_id="stuck", step_position=0, status="running") + queued_id, cancelling_id = queued.id, cancelling.id + seeded_execution.add_all([queued, cancelling, stuck]) + seeded_execution.commit() + monkeypatch.setattr(scheduler_module, "SessionLocal", lambda: seeded_execution) + + scheduler_module.execute_scheduled_queued_scenario_dispatch() + scheduler_module.execute_scheduled_scenario_cancel_finalizer() + assert seeded_execution.query(ScenarioRun).filter_by(id=queued_id).one().status == "passed" + assert seeded_execution.query(ScenarioRun).filter_by(id=cancelling_id).one().status == "cancelled" + assert seeded_execution.query(ScenarioStepRun).filter_by(run_id=queued_id).count() == 1 + assert seeded_execution.query(InvestigationQueueItem).filter_by(run_id=queued_id).count() == 0 + assert seeded_execution.query(ScenarioNotificationEvent).filter_by(run_id=queued_id).count() == 1 + assert seeded_execution.query(InvestigationQueueItem).filter_by(run_id=cancelling_id).count() == 0 + assert seeded_execution.query(ScenarioNotificationEvent).filter_by(run_id=cancelling_id).count() == 0 + + scheduler_module.execute_scheduled_queued_scenario_dispatch() + scheduler_module.execute_scheduled_scenario_cancel_finalizer() + assert seeded_execution.query(ScenarioRun).filter_by(id=queued_id).one().status == "passed" + assert seeded_execution.query(ScenarioRun).filter_by(id=cancelling_id).one().status == "cancelled" + assert seeded_execution.query(InvestigationQueueItem).filter_by(run_id=queued_id).count() == 0 + assert seeded_execution.query(ScenarioNotificationEvent).filter_by(run_id=queued_id).count() == 1 + assert seeded_execution.query(InvestigationQueueItem).filter_by(run_id=cancelling_id).count() == 0 + assert seeded_execution.query(ScenarioNotificationEvent).filter_by(run_id=cancelling_id).count() == 0 +# #endregion Test.ScenarioExecution.SchedulerCallbacks.DurableTick + + +# #region Test.ScenarioExecution.SchedulerCallbacks.ExecutorError [C:4] [TYPE Function] +# @BRIEF An executor error remains a persisted typed terminal result at the callback boundary. +def test_dispatch_callback_persists_typed_error_for_executor_exception(seeded_execution, monkeypatch): + from src.core import scheduler as scheduler_module + + run = _run("80440000-0000-4000-8000-000000000019", {"topological_order": ["bad"], "dependencies": [], "steps": [{"logical_step_id": "bad", "tool": "missing-tool"}]}) + seeded_execution.add(run) + seeded_execution.commit() + run_id = run.id + monkeypatch.setattr(scheduler_module, "SessionLocal", lambda: seeded_execution) + scheduler_module.execute_scheduled_queued_scenario_dispatch() + persisted = seeded_execution.query(ScenarioRun).filter_by(id=run_id).one() + assert persisted.status == "blocked" + assert persisted.error_code == "ACTION_DESCRIPTOR_REQUIRED" +# #endregion Test.ScenarioExecution.SchedulerCallbacks.ExecutorError + + +# #region Test.ScenarioExecution.SchedulerCallbacks.DispatchDatabaseError [C:3] [TYPE Function] +# @BRIEF A dispatch callback database edge is contained with rollback, structured log, and close. +def test_dispatch_callback_rolls_back_logs_and_closes_on_database_edge(monkeypatch): + from src.core import scheduler as scheduler_module + + db = MagicMock() + error_log = MagicMock() + db.query.side_effect = RuntimeError("database edge") + monkeypatch.setattr(scheduler_module, "SessionLocal", lambda: db) + monkeypatch.setattr(scheduler_module.logger, "explore", error_log) + scheduler_module.execute_scheduled_queued_scenario_dispatch() + db.rollback.assert_called_once() + db.commit.assert_not_called() + db.close.assert_called_once() + error_log.assert_called_once() +# #endregion Test.ScenarioExecution.SchedulerCallbacks.DispatchDatabaseError + + +# #region Test.ScenarioExecution.SchedulerCallbacks.FinalizerDatabaseError [C:3] [TYPE Function] +# @BRIEF A cancellation-finalizer database edge is contained without durable mutation or callback escape. +def test_finalizer_callback_rolls_back_and_closes_on_database_edge(monkeypatch): + from src.core import scheduler as scheduler_module + + db = MagicMock() + error_log = MagicMock() + db.query.side_effect = RuntimeError("database edge") + monkeypatch.setattr(scheduler_module, "SessionLocal", lambda: db) + monkeypatch.setattr(scheduler_module.logger, "explore", error_log) + scheduler_module.execute_scheduled_scenario_cancel_finalizer() + db.rollback.assert_called_once() + db.commit.assert_not_called() + db.close.assert_called_once() + error_log.assert_called_once() +# #endregion Test.ScenarioExecution.SchedulerCallbacks.FinalizerDatabaseError +# #endregion Test.ScenarioExecution.SchedulerCallbacks diff --git a/backend/tests/services/dashboard_testing/registry/test_scenario_terminal_signals.py b/backend/tests/services/dashboard_testing/registry/test_scenario_terminal_signals.py new file mode 100644 index 000000000..fa28907d4 --- /dev/null +++ b/backend/tests/services/dashboard_testing/registry/test_scenario_terminal_signals.py @@ -0,0 +1,160 @@ +# #region Test.ScenarioExecution.TerminalSignals [C:3] [TYPE Module] [SEMANTICS test,scenario,execution,terminal,investigation,provenance] +# @BRIEF Prove terminal 044 runs emit one immutable 047 queue signal and never start agent work. +# @RELATION BINDS_TO -> [ScenarioExecution.Runner.Walker] +# @RELATION BINDS_TO -> [ScenarioAnalytics.Investigation.TerminalSignal] +# @RELATION VERIFIES -> [ScenarioExecution.Runner.TerminalSignal] +# @RELATION VERIFIES -> [ScenarioAnalytics.Investigation.IngestSignal] +# @TEST_CONTRACT: persisted terminal ScenarioRun + durable artifact -> idempotent InvestigationQueueItem +# @TEST_FIXTURE: terminal_run_044 + artifact_terminal_044 -> INLINE_JSON hardcoded run/evidence provenance +# @TEST_EDGE: failed_signal; blocked_signal; inconclusive_signal; passed_no_signal; repeated_terminal_signal; no_agent_action +# @TEST_INVARIANT ScenarioExecution.Runner.Walker: Failed, blocked and inconclusive terminal runs emit exactly one immutable investigation signal with durable evidence provenance. -> VERIFIED_BY: failed_signal, blocked_signal, inconclusive_signal, repeated_terminal_signal +# @TEST_INVARIANT ScenarioAnalytics.Investigation.TerminalSignal: Queue ingestion never starts a case, AgentRun, chat, or remediation action. -> VERIFIED_BY: no_agent_action, passed_no_signal +# @TEST_INVARIANT ScenarioAnalytics.Investigation.IngestSignal: Terminal ingestion is a queue producer only and never starts recurrence classification or case lifecycle. -> VERIFIED_BY: no_agent_action, repeated_terminal_signal +from __future__ import annotations + +from src.models.agent_run import AgentRun +from src.models.scenario_artifact import ScenarioArtifact +from src.models.scenario_investigation import AgentAction, InvestigationCase, InvestigationQueueItem +from src.models.scenario_run import ScenarioRun +from src.services.dashboard_testing.analytics.investigation import emit_terminal_run_signal +from src.services.dashboard_testing.execution.executor_registry import ScenarioExecutorRegistry +from src.services.dashboard_testing.execution.runner import _advance_run +from src.services.dashboard_testing.scenario.templates import ( + ACTION_REGISTRY_VERSION, + action_registry_fingerprint, + resolve_action_descriptor, +) + +_SCENARIO_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1" +_REVISION_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1" +_CONTENT_HASH = "d" * 64 +_ARTIFACT_SHA = "b" * 64 + + +# #region Test.ScenarioExecution.TerminalSignals.Fixture [C:1] [TYPE Function] +def _terminal_run(status: str) -> ScenarioRun: + descriptor = resolve_action_descriptor( + tool="assertion", + action="structural_assert", + registry_version=ACTION_REGISTRY_VERSION, + registry_hash=action_registry_fingerprint(), + ).snapshot() + return ScenarioRun( + id={ + "failed": "10000000-0000-4000-8000-000000000001", + "blocked": "10000000-0000-4000-8000-000000000002", + "inconclusive": "10000000-0000-4000-8000-000000000003", + "passed": "10000000-0000-4000-8000-000000000004", + }[status], + scenario_id=_SCENARIO_ID, + scenario_revision_id=_REVISION_ID, + scenario_content_hash=_CONTENT_HASH, + environment_id="env-terminal-044", + status="queued", + phase="executing", + parameter_bindings={"region": "EMEA"}, + target_snapshot={"environment_id": "env-terminal-044", "dashboard_release_id": "release-terminal-044"}, + trigger_source="manual", + idempotency_key=f"terminal-signal-{status}-044", + runner_plan={ + "action_registry_version": ACTION_REGISTRY_VERSION, + "action_registry_hash": action_registry_fingerprint(), + "topological_order": ["terminal-step-044"], + "dependencies": [], + "steps": [{ + "logical_step_id": "terminal-step-044", + "tool": "assertion", + "action": "structural_assert", + "action_descriptor": descriptor, + }], + }, + execution_principal_fingerprint="c" * 64, + ) +# #endregion Test.ScenarioExecution.TerminalSignals.Fixture + + +# #region Test.ScenarioExecution.TerminalSignals.Registry [C:1] [TYPE Function] +def _registry(status: str) -> ScenarioExecutorRegistry: + registry = ScenarioExecutorRegistry() + registry.register( + "assertion", + lambda _step, _completed: { + "status": status, + "step_outcome": {"tool": "assertion", "reason_code": f"TEST_{status.upper()}"}, + "artifact_refs": [], + }, + ) + return registry +# #endregion Test.ScenarioExecution.TerminalSignals.Registry + + +# #region Test.ScenarioExecution.TerminalSignals.Persist [C:1] [TYPE Function] +def _persist_terminal_fixture(db, status: str) -> ScenarioRun: + run = _terminal_run(status) + db.add(run) + db.flush() + db.add(ScenarioArtifact( + id=f"artifact-{status}-044", + owner_type="scenario_run", + owner_id=run.id, + kind="evidence", + name="terminal-evidence-044", + content_ref=f"draft:{run.id}:{_ARTIFACT_SHA}", + sha256=_ARTIFACT_SHA, + )) + db.flush() + return run +# #endregion Test.ScenarioExecution.TerminalSignals.Persist + + +# #region Test.ScenarioExecution.TerminalSignals.Failed [C:2] [TYPE Function] +# @BRIEF A failed runner terminal state produces one exact immutable queue input. +def test_failed_terminal_run_emits_immutable_idempotent_signal(seeded_execution): + run = _persist_terminal_fixture(seeded_execution, "failed") + + result = _advance_run(seeded_execution, run, _registry("failed"), worker_id="terminal-worker-044") + + item = seeded_execution.query(InvestigationQueueItem).filter_by(run_id=run.id).one() + snapshot = dict(item.evidence_snapshot) + assert result["status"] == "failed" + assert snapshot["status"] == "failed" + assert snapshot["scenario_run_id"] == run.id + assert snapshot["environment_id"] == "env-terminal-044" + assert snapshot["scenario_content_hash"] == _CONTENT_HASH + assert snapshot["evidence"] == [{ + "kind": "evidence", + "content_ref": f"draft:{run.id}:{_ARTIFACT_SHA}", + "sha256": _ARTIFACT_SHA, + }] + assert seeded_execution.query(InvestigationCase).count() == 0 + assert seeded_execution.query(AgentAction).count() == 0 + assert seeded_execution.query(AgentRun).count() == 0 + + same = emit_terminal_run_signal(seeded_execution, run) + assert same is not None and same.id == item.id + assert same.occurrence_count == 1 + assert same.evidence_snapshot == snapshot + assert seeded_execution.query(InvestigationQueueItem).filter_by(run_id=run.id).count() == 1 +# #endregion Test.ScenarioExecution.TerminalSignals.Failed + + +# #region Test.ScenarioExecution.TerminalSignals.NonPass [C:2] [TYPE Function] +# @BRIEF Blocked and inconclusive terminal paths each emit a non-pass signal; passed emits none. +def test_blocked_inconclusive_emit_and_passed_does_not(seeded_execution): + for status in ("blocked", "inconclusive", "passed"): + run = _persist_terminal_fixture(seeded_execution, status) + result = _advance_run(seeded_execution, run, _registry(status), worker_id="terminal-worker-044") + item = seeded_execution.query(InvestigationQueueItem).filter_by(run_id=run.id).one_or_none() + assert result["status"] == status + if status == "passed": + assert item is None + else: + assert item is not None + assert item.evidence_snapshot["status"] == status + assert item.evidence_snapshot["evidence"][0]["sha256"] == _ARTIFACT_SHA + assert seeded_execution.query(InvestigationCase).count() == 0 + assert seeded_execution.query(AgentAction).count() == 0 + assert seeded_execution.query(AgentRun).count() == 0 +# #endregion Test.ScenarioExecution.TerminalSignals.NonPass + +# #endregion Test.ScenarioExecution.TerminalSignals diff --git a/backend/tests/services/dashboard_testing/registry/test_scenario_worker.py b/backend/tests/services/dashboard_testing/registry/test_scenario_worker.py index 4300a5be8..19780b6ba 100644 --- a/backend/tests/services/dashboard_testing/registry/test_scenario_worker.py +++ b/backend/tests/services/dashboard_testing/registry/test_scenario_worker.py @@ -1,12 +1,91 @@ # #region Test.ScenarioExecution.Worker [C:3] [TYPE Module] [SEMANTICS test,scenario,execution,worker,lease] # @RELATION BINDS_TO -> [ScenarioExecution.Worker.Claim] +# @RELATION VERIFIES -> [ScenarioExecution.Worker.Heartbeat] +# @TEST_INVARIANT ScenarioExecution.Worker.Claim: Live leases reject a different worker. +# -> VERIFIED_BY: test_happy_claim_heartbeat_and_live_lease_conflict +# @TEST_INVARIANT ScenarioExecution.Worker.Heartbeat: Only the owner can heartbeat before expiry. +# -> VERIFIED_BY: test_happy_claim_heartbeat_and_live_lease_conflict, +# test_expired_lease_cannot_be_heartbeated +# @TEST_INVARIANT ScenarioExecution.Worker.Claim: Persisted expired leases reclaim only when their +# recorded effect is idempotent/retry-safe. -> VERIFIED_BY: +# test_expired_retry_safe_lease_is_reclaimed_from_persisted_sqlite, +# test_expired_unsafe_side_effect_is_rejected +# @TEST_FIXTURE run-exec-0001 from the persisted 044 execution SQLite fixture. +from datetime import UTC, datetime, timedelta import pytest from src.services.dashboard_testing.execution.worker import claim_step, heartbeat +_RUN_ID = "run-exec-0001" -def test_live_lease_blocks_competing_worker(): - pytest.skip("requires persisted ScenarioRun fixture; contract is exercised by integration execution harness") + +def test_happy_claim_heartbeat_and_live_lease_conflict(seeded_execution): + lease = claim_step( + seeded_execution, _RUN_ID, "worker-live-step", worker_id="worker-a", + side_effect_key="effect-live", idempotent=True, retry_safe=True, lease_seconds=60, + ) + renewed = heartbeat(seeded_execution, lease.id, worker_id="worker-a", lease_seconds=60) + + assert renewed.id == lease.id + assert renewed.worker_id == "worker-a" + assert renewed.expires_at > renewed.heartbeat_at + with pytest.raises(ValueError, match="lease unavailable"): + heartbeat(seeded_execution, lease.id, worker_id="worker-b") + with pytest.raises(ValueError, match="held by another worker"): + claim_step( + seeded_execution, _RUN_ID, "worker-live-step", worker_id="worker-b", + side_effect_key="effect-live", idempotent=True, retry_safe=True, + ) + + +def test_expired_retry_safe_lease_is_reclaimed_from_persisted_sqlite(seeded_execution): + lease = claim_step( + seeded_execution, _RUN_ID, "worker-reclaim-step", worker_id="worker-a", + side_effect_key="effect-reclaim", idempotent=True, retry_safe=True, + ) + seeded_execution.commit() + lease.expires_at = datetime.now(UTC) - timedelta(seconds=1) + seeded_execution.commit() + seeded_execution.expire_all() + + reclaimed = claim_step( + seeded_execution, _RUN_ID, "worker-reclaim-step", worker_id="worker-b", + side_effect_key="effect-reclaim", idempotent=True, retry_safe=True, + ) + + assert reclaimed.id == lease.id + assert reclaimed.worker_id == "worker-b" + + +def test_expired_unsafe_side_effect_is_rejected(seeded_execution): + lease = claim_step( + seeded_execution, _RUN_ID, "worker-unsafe-step", worker_id="worker-a", + side_effect_key="effect-unsafe", idempotent=False, retry_safe=False, + ) + seeded_execution.commit() + lease.expires_at = datetime.now(UTC) - timedelta(seconds=1) + seeded_execution.commit() + seeded_execution.expire_all() + + with pytest.raises(ValueError, match="requires reconciliation"): + claim_step( + seeded_execution, _RUN_ID, "worker-unsafe-step", worker_id="worker-b", + side_effect_key="effect-unsafe", idempotent=False, retry_safe=False, + ) + + +def test_expired_lease_cannot_be_heartbeated(seeded_execution): + lease = claim_step( + seeded_execution, _RUN_ID, "worker-expired-heartbeat", worker_id="worker-a", + side_effect_key="effect-heartbeat", idempotent=True, retry_safe=True, + ) + seeded_execution.commit() + lease.expires_at = datetime.now(UTC) - timedelta(seconds=1) + seeded_execution.commit() + seeded_execution.expire_all() + + with pytest.raises(ValueError, match="lease unavailable"): + heartbeat(seeded_execution, lease.id, worker_id="worker-a") def test_worker_symbols_are_available(): assert callable(claim_step) and callable(heartbeat) diff --git a/backend/tests/services/dashboard_testing/registry/test_staleness.py b/backend/tests/services/dashboard_testing/registry/test_staleness.py index e129f5ace..b49ae4137 100644 --- a/backend/tests/services/dashboard_testing/registry/test_staleness.py +++ b/backend/tests/services/dashboard_testing/registry/test_staleness.py @@ -7,7 +7,7 @@ from __future__ import annotations from src.models.scenario_registry import ScenarioRegistryEntry, ScenarioStalenessSignal -from src.services.dashboard_testing.registry.staleness import apply_staleness +from src.services.dashboard_testing.registry.staleness import apply_staleness, ingest_upstream_staleness_event def test_chart_removed_blocks_affected_dashboard_scenarios(seeded_registry): @@ -81,4 +81,20 @@ def test_missing_scenario_is_reported_without_partial_failure(seeded_registry): }]) assert result["applied_count"] == 0 assert result["skipped"][0]["reason"] == "scenario_not_found" + + +def test_structure_diff_boundary_queues_staleness_signal(seeded_registry): + result = ingest_upstream_staleness_event(seeded_registry, { + "source": "structure_diff", + "fingerprint": "release-42", + "dashboard_id": 42, + "affected_ref": "chart:204", + "kind": "chart_removed", + "severity": "critical", + "reason": "Chart removed", + }) + from src.models.scenario_investigation import InvestigationQueueItem + + assert result["applied_count"] == 1 + assert seeded_registry.query(InvestigationQueueItem).count() == 1 # #endregion Test.ScenarioRegistry.Staleness diff --git a/backend/tests/test_scenario_live_execution_binding_migration.py b/backend/tests/test_scenario_live_execution_binding_migration.py new file mode 100644 index 000000000..c527b1858 --- /dev/null +++ b/backend/tests/test_scenario_live_execution_binding_migration.py @@ -0,0 +1,69 @@ +# #region Test.ScenarioExecution.LiveBindingMigration [C:3] [TYPE Module] [SEMANTICS test,scenario,execution,live-binding,migration] +# @BRIEF Verify the live-binding migration is additive and leaves historical runs nullable. +# @RELATION BINDS_TO -> [Alembic.ScenarioLiveExecutionBinding] +# @RELATION VERIFIES -> [Alembic.ScenarioLiveExecutionBinding.Upgrade] +# @RELATION VERIFIES -> [Alembic.ScenarioLiveExecutionBinding.Downgrade] +# @TEST_CONTRACT: scenario_runs legacy schema -> nullable binding reference and identity snapshot columns +# @TEST_FIXTURE: legacy_scenario_runs_044 -> INLINE_JSON nullable additive column contract +# @TEST_EDGE: no_binding -> nullable columns; downgrade -> both columns/index removed; legacy_authority_inference -> forbidden +# @TEST_INVARIANT Alembic.ScenarioLiveExecutionBinding: Existing runs remain valid with no binding and therefore fail closed as unavailable live I/O. -> VERIFIED_BY: additive_upgrade, additive_downgrade +from __future__ import annotations + +import importlib.util +from pathlib import Path +from unittest.mock import Mock + + +# #region Test.ScenarioExecution.LiveBindingMigration.Load [C:1] [TYPE Function] +def _migration_module(): + path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "c1d2e3f4a5b6_add_scenario_live_execution_binding.py" + spec = importlib.util.spec_from_file_location("scenario_live_binding_migration", path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module +# #endregion Test.ScenarioExecution.LiveBindingMigration.Load + + +# #region Test.ScenarioExecution.LiveBindingMigration.Upgrade [C:2] [TYPE Function] +# @BRIEF Upgrade adds only nullable identity columns and an index; it never backfills authority. +def test_upgrade_adds_nullable_binding_identity_columns(monkeypatch): + migration = _migration_module() + add_column = Mock() + create_index = Mock() + monkeypatch.setattr(migration.op, "add_column", add_column) + monkeypatch.setattr(migration.op, "create_index", create_index) + + migration.upgrade() + + assert [(call.args[1].name, call.args[1].nullable) for call in add_column.call_args_list] == [ + ("live_execution_binding_ref", True), + ("live_execution_binding_snapshot", True), + ] + create_index.assert_called_once_with( + "ix_scenario_runs_live_execution_binding_ref", + "scenario_runs", + ["live_execution_binding_ref"], + ) +# #endregion Test.ScenarioExecution.LiveBindingMigration.Upgrade + + +# #region Test.ScenarioExecution.LiveBindingMigration.Downgrade [C:2] [TYPE Function] +# @BRIEF Downgrade removes only the additive identity fields in reverse dependency order. +def test_downgrade_removes_only_binding_identity_columns(monkeypatch): + migration = _migration_module() + drop_index = Mock() + drop_column = Mock() + monkeypatch.setattr(migration.op, "drop_index", drop_index) + monkeypatch.setattr(migration.op, "drop_column", drop_column) + + migration.downgrade() + + drop_index.assert_called_once_with("ix_scenario_runs_live_execution_binding_ref", table_name="scenario_runs") + assert [call.args for call in drop_column.call_args_list] == [ + ("scenario_runs", "live_execution_binding_snapshot"), + ("scenario_runs", "live_execution_binding_ref"), + ] +# #endregion Test.ScenarioExecution.LiveBindingMigration.Downgrade + +# #endregion Test.ScenarioExecution.LiveBindingMigration diff --git a/backend/tests/test_translate_scheduler_audit.py b/backend/tests/test_translate_scheduler_audit.py index 45fe5ec80..c3bf3a67a 100644 --- a/backend/tests/test_translate_scheduler_audit.py +++ b/backend/tests/test_translate_scheduler_audit.py @@ -288,7 +288,7 @@ class TestScheduledExecutionFlow: q1.filter.return_value.first.return_value = schedule q2 = MagicMock() - q2.filter.return_value.order_by.return_value.first.return_value = None # no concurrent + q2.filter.return_value.order_by.return_value.all.return_value = [] q3 = MagicMock() q3.filter.return_value.order_by.return_value.first.return_value = None # no recent run diff --git a/backend/tests/test_translate_scheduler_execution.py b/backend/tests/test_translate_scheduler_execution.py index 0d2666937..658c64049 100644 --- a/backend/tests/test_translate_scheduler_execution.py +++ b/backend/tests/test_translate_scheduler_execution.py @@ -76,7 +76,7 @@ def test_new_key_only_mode(): q1.filter.return_value.first.return_value = schedule q2 = MagicMock() - q2.filter.return_value.order_by.return_value.first.return_value = None # no concurrent + q2.filter.return_value.order_by.return_value.all.return_value = [] q3 = MagicMock() q3.filter.return_value.order_by.return_value.first.return_value = most_recent @@ -101,10 +101,9 @@ def test_new_key_only_mode(): execution_mode="new_key_only", ) - assert mock_run.trigger_type == "new_key_only", ( - f"Expected trigger_type='new_key_only', got '{mock_run.trigger_type}'" + mock_orch.start_run.assert_called_once_with( + job_id="job-1", is_scheduled=True, trigger_type="new_key_only", ) - mock_orch.start_run.assert_called_once_with(job_id="job-1", is_scheduled=True) # execute_background is called (fire-and-forget) instead of execute_run via runner.run mock_orch_cls.execute_background.assert_called() mock_db.close.assert_called_once() @@ -132,7 +131,7 @@ def test_baseline_expired_fallback(): q1.filter.return_value.first.return_value = schedule q2 = MagicMock() - q2.filter.return_value.order_by.return_value.first.return_value = None + q2.filter.return_value.order_by.return_value.all.return_value = [] q3 = MagicMock() q3.filter.return_value.order_by.return_value.first.return_value = most_recent @@ -163,8 +162,8 @@ def test_baseline_expired_fallback(): execution_mode="new_key_only", ) - assert mock_run.trigger_type == "scheduled", ( - f"Expected trigger_type='scheduled' (baseline expired), got '{mock_run.trigger_type}'" + mock_orch.start_run.assert_called_once_with( + job_id="job-1", is_scheduled=True, trigger_type="scheduled", ) mock_event_log.log_event.assert_called_once() @@ -199,7 +198,7 @@ def test_full_mode_default(): q1.filter.return_value.first.return_value = schedule q2 = MagicMock() - q2.filter.return_value.order_by.return_value.first.return_value = None + q2.filter.return_value.order_by.return_value.all.return_value = [] q3 = MagicMock() q3.filter.return_value.order_by.return_value.first.return_value = most_recent @@ -224,8 +223,8 @@ def test_full_mode_default(): execution_mode="full", ) - assert mock_run.trigger_type == "scheduled", ( - f"Expected trigger_type='scheduled' for full mode, got '{mock_run.trigger_type}'" + mock_orch.start_run.assert_called_once_with( + job_id="job-1", is_scheduled=True, trigger_type="scheduled", ) mock_orch_cls.execute_background.assert_called() mock_db.close.assert_called_once() @@ -320,7 +319,7 @@ def test_baseline_expired_full_mode(): q1.filter.return_value.first.return_value = schedule q2 = MagicMock() - q2.filter.return_value.order_by.return_value.first.return_value = None + q2.filter.return_value.order_by.return_value.all.return_value = [] q3 = MagicMock() q3.filter.return_value.order_by.return_value.first.return_value = most_recent @@ -351,7 +350,9 @@ def test_baseline_expired_full_mode(): execution_mode="full", ) - assert mock_run.trigger_type == "scheduled" + mock_orch.start_run.assert_called_once_with( + job_id="job-1", is_scheduled=True, trigger_type="scheduled", + ) mock_event_log.log_event.assert_called_once() event_call = mock_event_log.log_event.call_args assert event_call.kwargs["payload"]["reason"] == "baseline_expired" @@ -378,7 +379,7 @@ def test_execution_error_handled(): q1.filter.return_value.first.return_value = schedule q2 = MagicMock() - q2.filter.return_value.order_by.return_value.first.return_value = None + q2.filter.return_value.order_by.return_value.all.return_value = [] q3 = MagicMock() q3.filter.return_value.order_by.return_value.first.return_value = None @@ -436,7 +437,7 @@ def test_execution_run_status_failed_path(): q1 = MagicMock() q1.filter.return_value.first.return_value = schedule q2 = MagicMock() - q2.filter.return_value.order_by.return_value.first.return_value = None + q2.filter.return_value.order_by.return_value.all.return_value = [] q3 = MagicMock() q3.filter.return_value.order_by.return_value.first.return_value = most_recent mock_db.query.side_effect = [q1, q2, q3] @@ -484,7 +485,7 @@ def test_execution_notification_error(): q1 = MagicMock() q1.filter.return_value.first.return_value = schedule q2 = MagicMock() - q2.filter.return_value.order_by.return_value.first.return_value = None + q2.filter.return_value.order_by.return_value.all.return_value = [] q3 = MagicMock() q3.filter.return_value.order_by.return_value.first.return_value = None mock_db.query.side_effect = [q1, q2, q3] diff --git a/backend/tests/test_translate_scheduler_guard.py b/backend/tests/test_translate_scheduler_guard.py index 02eba5b3f..deebed8b9 100644 --- a/backend/tests/test_translate_scheduler_guard.py +++ b/backend/tests/test_translate_scheduler_guard.py @@ -85,9 +85,9 @@ def test_concurrent_run_skips(): q1.filter.return_value.first.return_value = schedule q2 = MagicMock() - q2.filter.return_value.order_by.return_value.first.return_value = active_run + q2.filter.return_value.order_by.return_value.all.return_value = [active_run] - mock_db.query.side_effect = [q1, q2] + mock_db.query.side_effect = [q1, q2, q2] with patch( "src.plugins.translate.orchestrator.TranslationOrchestrator" @@ -140,9 +140,9 @@ def test_recent_pending_run_not_stale(): q1.filter.return_value.first.return_value = schedule q2 = MagicMock() - q2.filter.return_value.order_by.return_value.first.return_value = pending_run + q2.filter.return_value.order_by.return_value.all.return_value = [pending_run] - mock_db.query.side_effect = [q1, q2] + mock_db.query.side_effect = [q1, q2, q2] with patch( "src.plugins.translate.orchestrator.TranslationOrchestrator" @@ -197,15 +197,12 @@ def test_stale_pending_cleared_and_proceeds(): q1.filter.return_value.first.return_value = schedule q2 = MagicMock() - q2.filter.return_value.order_by.return_value.first.return_value = stale_run - - q3 = MagicMock() - q3.filter.return_value.all.return_value = [stale_run] + q2.filter.return_value.order_by.return_value.all.side_effect = [[stale_run], []] q4 = MagicMock() q4.filter.return_value.order_by.return_value.first.return_value = most_recent - mock_db.query.side_effect = [q1, q2, q3, q4] + mock_db.query.side_effect = [q1, q2, q2, q4] mock_run = _make_mock_run() @@ -229,7 +226,9 @@ def test_stale_pending_cleared_and_proceeds(): assert "Stale: concurrency deadlock" in stale_run.error_message assert stale_run.completed_at is not None - mock_orch.start_run.assert_called_once_with(job_id="job-1", is_scheduled=True) + mock_orch.start_run.assert_called_once_with( + job_id="job-1", is_scheduled=True, trigger_type="scheduled", + ) mock_orch_cls.execute_background.assert_called() assert mock_db.commit.call_count >= 1 mock_db.close.assert_called_once() @@ -269,15 +268,15 @@ def test_multiple_stale_pending_all_cleared(): q1.filter.return_value.first.return_value = schedule q2 = MagicMock() - q2.filter.return_value.order_by.return_value.first.return_value = stale_run_a - - q3 = MagicMock() - q3.filter.return_value.all.return_value = [stale_run_a, stale_run_b] + q2.filter.return_value.order_by.return_value.all.side_effect = [ + [stale_run_a, stale_run_b], + [], + ] q4 = MagicMock() q4.filter.return_value.order_by.return_value.first.return_value = most_recent - mock_db.query.side_effect = [q1, q2, q3, q4] + mock_db.query.side_effect = [q1, q2, q2, q4] mock_run = _make_mock_run() @@ -302,7 +301,9 @@ def test_multiple_stale_pending_all_cleared(): assert "Stale: concurrency deadlock" in sr.error_message assert sr.completed_at is not None - mock_orch.start_run.assert_called_once_with(job_id="job-1", is_scheduled=True) + mock_orch.start_run.assert_called_once_with( + job_id="job-1", is_scheduled=True, trigger_type="scheduled", + ) mock_orch_cls.execute_background.assert_called() mock_db.close.assert_called_once() # #endregion Test.TranslateSchedulerGuard.TestMultipleStalePendingAllCleared diff --git a/frontend/src/lib/models/RunMonitorModel.svelte.ts b/frontend/src/lib/models/RunMonitorModel.svelte.ts index 29c5626d4..65657c515 100644 --- a/frontend/src/lib/models/RunMonitorModel.svelte.ts +++ b/frontend/src/lib/models/RunMonitorModel.svelte.ts @@ -4,9 +4,7 @@ // @STATE idle, loading, live, waiting_human, terminal, error // @ACTION launch, bindEvents, dispose, loadResult, clear // @INVARIANT SSE events update typed step/status fields only; prose is never parsed. -// @INVARIANT Launch posts the 044 start contract (environment, revision, params, is_prod); -// release/baseline_set/toggles travel inside params.launch_config until 044 lands -// the full StartRunRequest fields (API type alignment is strictly minimal here). + // @INVARIANT Launch posts the typed 044 start contract; release/baseline/toggles never hide in params. import { api } from "$lib/api"; import type { CheckpointDisposition, RunComparison, RunConfiguration, ScenarioExecutionResult, ScenarioRun, ScenarioStepRun } from "$lib/types/scenario-run"; @@ -20,10 +18,6 @@ export class RunMonitorModel { async launch(config: RunConfiguration, idempotencyKey: string, apiFn = api.fetchApi): Promise { this.state = "loading"; - const launch_config: Record = {}; - if (config.dashboard_release_id) launch_config.dashboard_release_id = config.dashboard_release_id; - if (config.baseline_set) launch_config.baseline_set = config.baseline_set; - if (config.execution_toggles) launch_config.execution_toggles = config.execution_toggles; try { this.run = await apiFn("/scenario-runs", { method: "POST", @@ -31,8 +25,11 @@ export class RunMonitorModel { scenario_id: config.scenario_id, revision_id: config.revision_id, environment_id: config.environment_id, - params: { ...config.params, ...(Object.keys(launch_config).length ? { launch_config } : {}) }, + params: config.params, is_prod: config.is_prod, + dashboard_release_id: config.dashboard_release_id, + baseline_set: config.baseline_set, + execution_toggles: config.execution_toggles, }), headers: { "Idempotency-Key": idempotencyKey }, } as never); diff --git a/frontend/src/lib/models/__tests__/RunMonitorModel.test.ts b/frontend/src/lib/models/__tests__/RunMonitorModel.test.ts index a95ecbafc..bc97bb461 100644 --- a/frontend/src/lib/models/__tests__/RunMonitorModel.test.ts +++ b/frontend/src/lib/models/__tests__/RunMonitorModel.test.ts @@ -7,8 +7,10 @@ describe("RunMonitorModel", () => { it("launches with an idempotency key", async () => { const apiFn = vi.fn().mockResolvedValue({ id: "run-1", status: "queued", steps: [] }); const model = new RunMonitorModel(); - await model.launch({ scenario_id: "s-1", revision_id: "r-1", environment_id: "preprod", params: {} }, "idem-1", apiFn); + await model.launch({ scenario_id: "s-1", revision_id: "r-1", environment_id: "preprod", params: {}, dashboard_release_id: "release-1", baseline_set: "baseline-1", execution_toggles: { verbose_logs: true } }, "idem-1", apiFn); expect(apiFn).toHaveBeenCalledWith("/scenario-runs", expect.objectContaining({ method: "POST" })); + expect(apiFn.mock.calls[0][1].body).toContain('"dashboard_release_id":"release-1"'); + expect(apiFn.mock.calls[0][1].body).not.toContain("launch_config"); expect(model.run?.id).toBe("run-1"); expect(model.state).toBe("live"); }); diff --git a/specs/038-dashboard-scenario-model/data-model.md b/specs/038-dashboard-scenario-model/data-model.md index 785adbe82..20fc022e1 100644 --- a/specs/038-dashboard-scenario-model/data-model.md +++ b/specs/038-dashboard-scenario-model/data-model.md @@ -74,7 +74,7 @@ The compiler assigns a UUID when it creates an initial graph. An editor/migratio ## ActionRegistry and mutation safety -`ActionRegistry(version)` is the canonical 038 catalog of every allowed `{tool, action}`. Each entry declares typed inputs/outputs, allowed risk, timeout, idempotency, retry safety, mutation policy and version. 044 resolves every executor action only from this same pinned registry; unknown `{tool, action}` is a validation error, not a fallback dispatch. +`ActionRegistry(version)` is the canonical 038 catalog of every allowed `{tool, action}`. Each entry declares typed inputs/outputs, allowed risk, timeout, idempotency, retry safety, mutation policy and version. 044 persists the registry's version/hash and the exact immutable `ActionExecutionDescriptor` in its RunnerPlan, then resolves executors and lease/recovery/retry policy from that descriptor only. Unknown `{tool, action}`, a missing/altered descriptor, or a version/hash mismatch is a validation error before I/O, never a tool-default fallback. Mutating actions require `mutation_contract`. Policy: `DANGEROUS_MUTATION` is never automated; mutating browser steps in PROD are prohibited; `TEST_DATA_MUTATION` is permitted only in an explicitly listed non-PROD environment against a named safe fixture, with bounded affected record keys, side-effect idempotency and rollback/reconciliation. Missing safety context maps the checklist case to `needs_context` or HumanCheckpoint, never automatic dispatch. diff --git a/specs/042-dashboard-scenario-registry/checklists/requirements.md b/specs/042-dashboard-scenario-registry/checklists/requirements.md index 8d6e2db7a..fa73a98e6 100644 --- a/specs/042-dashboard-scenario-registry/checklists/requirements.md +++ b/specs/042-dashboard-scenario-registry/checklists/requirements.md @@ -1,18 +1,21 @@ # Requirements Checklist: Scenario Registry & Lifecycle (042) **Purpose**: Verify SCREG-FR-001..009 completeness before implementation. + +> **Factual audit 2026-08-20:** `[x]` requires current production evidence, `[~]` means partial +> code exists, `[ ]` means missing integration or proof. This checklist is not a completion claim. **Created**: 2026-08-07 | **Feature**: spec.md ## Persistence & Query (SCREG-FR-001/002) -- [ ] CHK001 Scenario persisted as first-class registry projection with all metadata fields -- [ ] CHK002 `GET /scenarios` list with search/filter (name, tag, dashboard, status, owner) -- [ ] CHK003 `GET /scenarios/{id}` detail (fixes missing-route 404) -- [ ] CHK004 Detail loadable independent of agent session (not event-driven) +- [x] CHK001 Scenario persisted as first-class registry projection with all metadata fields +- [x] CHK002 `GET /scenarios` list with search/filter (name, tag, dashboard, status, owner) +- [x] CHK003 `GET /scenarios/{id}` detail (fixes missing-route 404) +- [x] CHK004 Detail loadable independent of agent session (not event-driven) ## Revisions (SCREG-FR-003) -- [ ] CHK005 Edit creates immutable candidate revision with parent link; save does not advance current_revision +- [~] CHK005 Edit creates immutable candidate revision with parent link; save does not advance current_revision - [ ] CHK005a Activation atomically advances current_revision only after eligibility and policy/gate checks - [ ] CHK006 Runs pin scenario_id (UUID) + revision_id (UUID) + content_hash snapshot - [ ] CHK007 Revision diff (added/changed/removed) available @@ -27,7 +30,7 @@ ## Staleness (SCREG-FR-007) - [ ] CHK012 Staleness from 037 StructureDiff + 041 lineage, not blind rescan -- [ ] CHK013 Affected scenarios → NEEDS_REVALIDATION/BLOCKED with reason +- [~] CHK013 Affected scenarios → NEEDS_REVALIDATION/BLOCKED with reason - [ ] CHK014 Stale scenario run warning-gated/blocked - [ ] CHK015 Lineage engine unavailable → skip with warning (never false-stale) diff --git a/specs/042-dashboard-scenario-registry/plan.md b/specs/042-dashboard-scenario-registry/plan.md index 1602ba388..180f87c92 100644 --- a/specs/042-dashboard-scenario-registry/plan.md +++ b/specs/042-dashboard-scenario-registry/plan.md @@ -1,6 +1,9 @@ # Implementation Plan: Scenario Registry & Lifecycle -**Branch**: `042-dashboard-scenario-registry` | **Date**: 2026-08-07 | **Spec**: spec.md | **Status**: Draft +**Branch**: `042-dashboard-scenario-registry` | **Date**: 2026-08-07 | **Spec**: spec.md | **Status**: Partially implemented — factual audit pending remediation + +> **Implementation audit, 2026-08-20:** registry persistence/API/lifecycle code exists. Runtime 037/041 +> staleness subscription, 036 InvestigationSignal emission and final independent verification remain open. ## Summary diff --git a/specs/042-dashboard-scenario-registry/prototype/manifest.md b/specs/042-dashboard-scenario-registry/prototype/manifest.md index ce7ce1148..820bcd43f 100644 --- a/specs/042-dashboard-scenario-registry/prototype/manifest.md +++ b/specs/042-dashboard-scenario-registry/prototype/manifest.md @@ -2,6 +2,9 @@ @defgroup Prototype Interactive HTML prototype manifest for Scenario Registry list + detail. ## Prototype Metadata + +> **Factual audit 2026-08-20:** this manifest records prototype coverage only. It does not verify +> production routes, event wiring, accessibility, responsiveness, or acceptance criteria. - **Feature**: 042 Scenario Registry & Lifecycle - **Source contracts**: ux_reference.md, contracts/modules.md - **Screens represented**: 2 (Registry List, Scenario Detail) + persistent Queue entry + run-monitor stub → 045 @@ -24,7 +27,7 @@ ## Screen ↔ Story Traceability -| Prototype Screen | User Story | Acceptance Verified | +| Prototype Screen | User Story | Intended acceptance coverage | |------------------|------------|---------------------| | Registry List | US1 Find | filter/search rows | | Detail | US2 Open | tabs + metadata from registry | diff --git a/specs/042-dashboard-scenario-registry/quickstart.md b/specs/042-dashboard-scenario-registry/quickstart.md index 92fcc4ef4..90a2a265d 100644 --- a/specs/042-dashboard-scenario-registry/quickstart.md +++ b/specs/042-dashboard-scenario-registry/quickstart.md @@ -1,5 +1,8 @@ # Quickstart: Scenario Registry & Lifecycle (042) +> **Factual audit 2026-08-20:** pending verification checklist only; no command result below is +> currently asserted as evidence. + ## Prereqs - Backend venv: `backend/.venv` - DB migrated (registry tables) diff --git a/specs/042-dashboard-scenario-registry/spec.md b/specs/042-dashboard-scenario-registry/spec.md index e5636d50c..7bee78977 100644 --- a/specs/042-dashboard-scenario-registry/spec.md +++ b/specs/042-dashboard-scenario-registry/spec.md @@ -14,7 +14,7 @@ @SEMANTICS: spec, requirements, feature, scenario, registry, lifecycle, catalog, revision, stale **Feature Branch**: `042-dashboard-scenario-registry` -**Created**: 2026-08-07 | **Status**: Draft +**Created**: 2026-08-07 | **Status**: Partially implemented — factual audit pending remediation **Input**: "Provide a first-class Scenario Registry: persistent storage, list/search/filter, scenario detail, ownership, lifecycle statuses, immutable revisions, clone/archive, and stale detection driven by dashboard/lineage change. This is the source of truth consumed by the Scenario Editor (043) and Scenario Execution Engine (044)." ## User Scenarios @@ -139,11 +139,15 @@ - Q: How does staleness work? → A: From dashboard release `StructureDiff` (037) and 041 lineage blast radius; never a blind rescan. - Q: Is delete ever allowed? → A: Only archive for scenarios with runs; hard delete requires admin scope and empty run history. -## Implementation Status & MVP Debt (audit 2026-08-07) +## Implementation Status & MVP Debt (factual audit 2026-08-20) -**Facts (code check):** -- 🔴 No `Scenario`/`scenario_registry` DB table exists; scenario lives as in-memory Pydantic + materialized git artifact. -- 🔴 `GET /dashboard-testing/scenarios/{id}` is called by frontend `getScenarioDraft` but **does not exist** on the backend (only POST routes) → 404. -- 🟡 `scenario_key` + `content_hash` are emitted by 038; `revision_id`/`content_hash` are the registry revision base — the technical basis for revisions is present, but there is no revision storage/UX. +Registry models, migrations, list/detail/create/revision/lifecycle services and API routes exist in the +current tree. This establishes a partial implementation, not feature closure. + +- `[~]` Runtime subscription from 037 StructureDiff and 041 blast-radius events is not independently + demonstrated; `apply_staleness()` can process supplied envelopes but the upstream event boundary is unproven. +- `[ ]` SCREG-FR-010 canonical 036 `InvestigationSignal` emission is not wired from registry health/staleness. +- `[ ]` The final quickstart/scoped verification and acceptance audit have not been run in this audit. +- `[ ]` No current runtime/browser evidence is retained; see `WORKSTATE-043-047.md` for audit scope. #endregion ScenarioRegistry.Spec diff --git a/specs/042-dashboard-scenario-registry/tasks.md b/specs/042-dashboard-scenario-registry/tasks.md index 4d019b2da..ec0d83047 100644 --- a/specs/042-dashboard-scenario-registry/tasks.md +++ b/specs/042-dashboard-scenario-registry/tasks.md @@ -6,6 +6,9 @@ ## Format: `- [ ] T### [P] [USx] Description with exact file path` +> **Factual audit 2026-08-20:** `[x]` means code plus relevant evidence; `[~]` means partial +> implementation; `[ ]` means absent integration or unperformed verification. + ## Phase 1 — Setup (Shared Infrastructure) - [x] T001 Create SQLAlchemy models `ScenarioRegistryEntry`, `ScenarioRevision`, `ScenarioStalenessSignal`, lifecycle audit log in `backend/src/models/scenario_registry.py` @@ -56,7 +59,7 @@ - [x] T015 [US4] Implement `apply_staleness` in `backend/src/services/dashboard_testing/registry/staleness.py` @INVARIANT: never false-stale from unavailable lineage engine (skip with warning) @TEST_EDGE: chart_removed->BLOCKED; filter_scope_change->NEEDS_REVALIDATION; engine_down->skip -- [x] T016 [US4] Integrate 037 StructureDiff + 041 blast-radius hooks to emit staleness signals +- [~] T016 [US4] Integrate 037 StructureDiff + 041 blast-radius hooks to emit staleness signals Adapter accepts 037 change envelopes and 041 affected scenario/dashboard references; unavailable upstream engines are explicitly skipped without false-stale transitions. @@ -71,7 +74,7 @@ ## Phase 7 — Health + Polish -- [x] T021 [P] Implement analytics-owned health projection in `backend/src/services/dashboard_testing/registry/health.py` +- [~] T021 [P] Implement analytics-owned health projection in `backend/src/services/dashboard_testing/registry/health.py` - [x] T022 [P] Frontend DTOs `ScenarioRegistryEntry`/`ScenarioRevision` in `frontend/src/types/scenario-registry.ts` - [x] T023 [P] Thin `ScenarioRegistryModel.svelte.ts` for list/detail/revisions/health projections Dedicated heavy registry pages remain owned by 043/045; model is the 042 integration boundary. @@ -79,10 +82,18 @@ Full backend: 10712 passed, 235 skipped, 1 xpassed. Full frontend Vitest: 3859 passed across 204 files; frontend build passed. New/affected files pass targeted ruff/eslint. Full backend ruff still reports 7904 repository-wide pre-existing findings; semantic/belief audit remains a separate workspace task. -- [x] T025 **Prototype validation**: verify every @UX_STATE in contracts reachable via `prototype/index.html` state switcher; responsive - Playwright browser check at 390x844 reached list/detail/empty/create states; responsive viewport meta is present. +- [ ] T025 **Prototype validation**: verify every @UX_STATE in contracts reachable via `prototype/index.html` state switcher; responsive -**Checkpoint**: Registry list/detail/revisions/lifecycle/staleness/health pass; 404 route fixed; prototype covers states. +## Audit Follow-ups (2026-08-20) + +- [ ] T026 Wire 037 StructureDiff and 041 blast-radius production events into `apply_staleness`; verify + an upstream fixture changes affected registry rows without a direct service call. +- [ ] T027 Emit idempotent canonical 036 InvestigationSignal for registry health/staleness and verify + emission does not start an agent action. +- [ ] T028 Run independent registry quickstart/API/lifecycle/staleness verification and record only + reproducible command results. + +**Checkpoint**: Pending T026–T028. Current code presence does not prove runtime upstream integration. ## Dependencies diff --git a/specs/042-dashboard-scenario-registry/traceability.md b/specs/042-dashboard-scenario-registry/traceability.md index d55fdbb77..376ce4800 100644 --- a/specs/042-dashboard-scenario-registry/traceability.md +++ b/specs/042-dashboard-scenario-registry/traceability.md @@ -1,5 +1,8 @@ # Traceability: Scenario Registry & Lifecycle (042) +> **Factual audit 2026-08-20:** rows trace intended code/test ownership, not completed acceptance +> evidence. 037/041 upstream staleness subscription and 036 InvestigationSignal emission are open. + | Story | Requirement | Model | API operationId | Contract | Task | Test | |-------|-------------|-------|------------------|----------|------|------| | US1 Find | SCREG-FR-001/002 | ScenarioRegistryEntry | scenarios.list | Registry.List | T005-T007 | test_list | diff --git a/specs/043-dashboard-scenario-editor/checklists/requirements.md b/specs/043-dashboard-scenario-editor/checklists/requirements.md index d0b1154b0..43483b133 100644 --- a/specs/043-dashboard-scenario-editor/checklists/requirements.md +++ b/specs/043-dashboard-scenario-editor/checklists/requirements.md @@ -2,9 +2,12 @@ **Purpose**: Verify SCEDIT-FR-001..008 completeness. | **Created**: 2026-08-07 +> **Factual audit 2026-08-20:** `[x]` requires current production evidence, `[~]` means partial +> code exists, `[ ]` means missing integration or proof. + ## View (FR-001) -- [ ] CHK001 Persisted scenario renders read-only by default +- [~] CHK001 Persisted scenario renders read-only by default - [ ] CHK002 Steps, dependency graph, parameters, baselines, assertions visible - [ ] CHK003 Generated executable read-only @@ -16,13 +19,13 @@ ## Constrained Assertions (FR-003) -- [ ] CHK007 Assertions use constrained editor (registered operators + baseline refs) -- [ ] CHK008 SQL/raw-baseline/path injection rejected +- [~] CHK007 Assertions use constrained editor (registered operators + baseline refs) +- [~] CHK008 SQL/raw-baseline/path injection rejected ## Visual Dependencies (FR-004) -- [ ] CHK009 Dependency editing via visual DAG -- [ ] CHK010 Cycle/duplicate-output validation on every change +- [~] CHK009 Dependency editing via visual DAG +- [~] CHK010 Cycle/duplicate-output validation on every change ## Agent Edit (FR-006) diff --git a/specs/043-dashboard-scenario-editor/plan.md b/specs/043-dashboard-scenario-editor/plan.md index 104955ff0..3a735f0ce 100644 --- a/specs/043-dashboard-scenario-editor/plan.md +++ b/specs/043-dashboard-scenario-editor/plan.md @@ -1,6 +1,9 @@ # Implementation Plan: Scenario Editor UX -**Branch**: `043-dashboard-scenario-editor` | **Date**: 2026-08-07 | **Spec**: spec.md | **Status**: Draft +**Branch**: `043-dashboard-scenario-editor` | **Date**: 2026-08-07 | **Spec**: spec.md | **Status**: Partially implemented — factual audit pending remediation + +> **Implementation audit, 2026-08-20:** editor code and route exist; current E2E/accessibility/policy +> verification is open, and closure remains dependent on unresolved 042/044 runtime contracts. ## Summary diff --git a/specs/043-dashboard-scenario-editor/prototype/manifest.md b/specs/043-dashboard-scenario-editor/prototype/manifest.md index 8be0b1880..c05641803 100644 --- a/specs/043-dashboard-scenario-editor/prototype/manifest.md +++ b/specs/043-dashboard-scenario-editor/prototype/manifest.md @@ -2,6 +2,9 @@ @defgroup Prototype Interactive HTML prototype manifest for the Scenario Editor. ## Prototype Metadata + +> **Factual audit 2026-08-20:** prototype state reachability is not production verification. Current +> browser/accessibility and policy-gate evidence remains pending. - **Feature**: 043 Scenario Editor UX - **Source contracts**: ux_reference.md, contracts/modules.md - **Screens represented**: 1 (Scenario Editor) @@ -23,7 +26,7 @@ ## Screen ↔ Story Traceability -| Story | Prototype Feature | Acceptance Verified | +| Story | Prototype Feature | Intended acceptance coverage | |-------|-------------------|---------------------| | US1 View | read-only badge, graph | read-only default | | US2 Manual | Edit toggle, dirty, save | revision + delegated policy | diff --git a/specs/043-dashboard-scenario-editor/quickstart.md b/specs/043-dashboard-scenario-editor/quickstart.md index f19b8fa96..74cabbed1 100644 --- a/specs/043-dashboard-scenario-editor/quickstart.md +++ b/specs/043-dashboard-scenario-editor/quickstart.md @@ -1,5 +1,8 @@ # Quickstart: Scenario Editor (043) +> **Factual audit 2026-08-20:** pending verification checklist only; no command result below is +> currently asserted as evidence. + ## Prereqs - 042 registry backend live (scenario + revisions) - Frontend deps installed diff --git a/specs/043-dashboard-scenario-editor/spec.md b/specs/043-dashboard-scenario-editor/spec.md index 92b929413..02dbd2fc6 100644 --- a/specs/043-dashboard-scenario-editor/spec.md +++ b/specs/043-dashboard-scenario-editor/spec.md @@ -12,7 +12,7 @@ @SEMANTICS: spec, requirements, feature, ux, scenario, editor, visual, revision, agent **Feature Branch**: `043-dashboard-scenario-editor` -**Created**: 2026-08-07 | **Status**: Draft +**Created**: 2026-08-07 | **Status**: Partially implemented — factual audit pending remediation **Input**: "Provide a first-class Scenario Editor for viewing and editing persisted scenarios: business metadata and parameter definitions editable manually, assertions via constrained editors, dependencies via visual DAG editing, generated executable read-only, and complex changes delegated to 'Edit with agent'. Metadata changes use a registry metadata version; executable changes create immutable revisions." ## User Scenarios @@ -142,11 +142,14 @@ - Q: May the agent save a revision? → A: Yes, after deterministic validation when delegated policy permits. It always creates an immutable revision with agent/delegator/case provenance; policy-gated actions use an inline ActionApprovalGate. - Q: Does this replace 039 workspace? → A: No. 039 is the create flow in agent chat; 043 is the post-save edit surface over the registry. -## Implementation Status & MVP Debt (audit 2026-08-07) +## Implementation Status & MVP Debt (factual audit 2026-08-20) -**Facts (code check):** -- 🟡 038 DTOs exist (DashboardTestScenario, ScenarioStep, ScenarioParameter, ScenarioRef, ScenarioValidationResult); no dedicated editor UI/model. -- 🔴 No visual DAG editor or constrained assertion editor exists. -- 🟡 `resolve` (038) supports parameter/selector/manual edits but is agent-tool-bound; a registry-backed edit surface is absent until 042 lands. +WorkingDraft/proposal services, constrained assertion and DAG components, plus the editor route are +present. The hybrid editor therefore exists structurally but is not verified as a closed workflow. + +- `[~]` Server-side drafts/proposals and UI components are present; no current route-level E2E, + accessibility, or policy-gate evidence is retained. +- `[~]` Revalidation depends on 042 staleness input whose upstream 037/041 integration is unproven. +- `[ ]` Feature closure is blocked by incomplete 044 execution and unperformed independent verification. #endregion ScenarioEditor.Spec diff --git a/specs/043-dashboard-scenario-editor/tasks.md b/specs/043-dashboard-scenario-editor/tasks.md index bbf478064..3ef7719b3 100644 --- a/specs/043-dashboard-scenario-editor/tasks.md +++ b/specs/043-dashboard-scenario-editor/tasks.md @@ -5,6 +5,9 @@ ## Format: `- [ ] T### [P] [USx] Description with exact file path` +> **Factual audit 2026-08-20:** `[x]` means code plus relevant evidence; `[~]` means partial +> implementation; `[ ]` means absent integration or unperformed verification. + ## Phase 1 — Setup - [x] T001 Create EditOperation DTOs (union) + fixtures in `backend/src/services/dashboard_testing/editor/ops.py` and `specs/043-dashboard-scenario-editor/fixtures/` @@ -58,10 +61,14 @@ - [x] T016 [P] RBAC scenario:edit enforcement tests Covered: `backend/tests/api/test_scenario_editor_routes.py` (agent-propose + proposal-save → 403 without scenario:edit) -- [x] T017 Run quickstart-equivalent scenario suite, scoped ruff, belief/ATTN static audit and semantic rebuild. - Verified in the 043-047 close: backend scenario suite 296 passed/1 skipped, frontend 3927 passed, - build succeeded, doc-gen indexed 9576 contracts/4730 edges; known >400-line module debt is recorded. -- [x] T018 **Prototype validation**: every declared @UX_STATE is reachable via `prototype/index.html`; Playwright verified 1440x900 and 390x844 without overflow or control overlap. +- [ ] T017 Run quickstart-equivalent scenario suite, scoped ruff, belief/ATTN static audit and semantic rebuild. +- [ ] T018 **Prototype validation**: every declared @UX_STATE is reachable via `prototype/index.html`. + +## Audit Follow-ups (2026-08-20) + +- [ ] T019 Verify editor route keyboard access, stale conflict recovery and policy-gated save with an + independent browser/API test after registry staleness wiring is complete. +- [ ] T020 Run current scoped editor tests and record command-level evidence; do not reuse prior batch totals. ## Dependencies diff --git a/specs/043-dashboard-scenario-editor/traceability.md b/specs/043-dashboard-scenario-editor/traceability.md index 99e434a04..7c4885ebe 100644 --- a/specs/043-dashboard-scenario-editor/traceability.md +++ b/specs/043-dashboard-scenario-editor/traceability.md @@ -1,5 +1,8 @@ # Traceability: Scenario Editor (043) +> **Factual audit 2026-08-20:** rows identify code/test ownership only. Route-level accessibility, +> stale-conflict and policy-gate workflow evidence is pending; 043 remains dependent on 042/044. + | Story | Requirement | Model | API operationId | Contract | Task | Test | |-------|-------------|-------|------------------|----------|------|------| | US1 View | SCEDIT-FR-001 | ScenarioEditorModel | editor.load | Editor.Load | T003-T005 | ScenarioEditorModel.test | diff --git a/specs/044-dashboard-scenario-execution/SESSION_STATE.md b/specs/044-dashboard-scenario-execution/SESSION_STATE.md new file mode 100644 index 000000000..6101a60b4 --- /dev/null +++ b/specs/044-dashboard-scenario-execution/SESSION_STATE.md @@ -0,0 +1,117 @@ +# 044 Scenario Execution — Session State + +> **Updated**: 2026-08-21 +> **Purpose**: Durable handoff for the current implementation/review session. This is a +> decision and verification ledger; `tasks.md` and `traceability.md` remain the canonical +> feature backlog and requirement matrix. + +## Current Objective + +Bring the 044 Scenario Execution Engine into material conformance with its contracts while +preserving fail-closed live I/O and GRACE-Poly invariants. Every code change is followed by an +independent verifier pass and semantic curation. + +## Architecture Decisions Already Implemented + +- HTTP and 046 start paths are persistence-only. The scheduler-owned queued dispatcher is the + sole initial execution authority, claiming `queued -> running` with a durable CAS. +- Environment classification is server-owned (`ConfigManager`), not request-owned. A configured + PROD run always becomes `pending_approval` with an `ActionApprovalGate`; an unknown environment + fails before any run/gate/notification/queue side effect. +- `LiveExecutionBinding` persists only immutable identity/fingerprint data. Application startup + builds `LiveExecutionCompositionRoot` from trusted configuration. Exact Superset bindings may + call the existing 037 query envelope; unavailable/mismatched browser, Superset, or screenshot + bindings are typed non-pass and perform no I/O. +- Artifact evidence needs a non-zero 64-hex digest and durable matching ref. Historical evidence + is retained for audit but retired from current result/signal projections on retry/cancel/recovery. +- Retry, timeout, cancel and crash recovery operate only on persisted run state and the pinned + RunnerPlan. Unsafe effects require reconciliation; browser recovery needs a pinned safe + checkpoint. +- Failed/blocked/inconclusive terminal results publish one immutable, idempotent 047 queue input; + they do not create a Case, AgentRun, chat or remediation action. + +## Independently Verified Closures + +- Server-owned PROD gate: external 044/046 verification profile passed 76 tests. +- Queued dispatcher API + scheduler profile: 94 tests passed; scheduler callback profile: 124 + tests passed. FastAPI TestClient tests run outside this sandbox because AnyIO self-pipe gets + `EPERM` inside it. +- Live composition bootstrap: 57 tests passed; exact configured Superset binding produces the + expected durable `draft:{run}:{sha256}` ref/digest. Browser/Screenshot providers still default + to typed unavailable. +- Lifecycle closure: retry/cancel/timeout/recovery profiles were independently verified. Timeout + materializes otherwise-lazy declared descendants as durable blocked rows. + +## Orthogonal Review Findings + +The independent review found these material gaps. The first is fixed; the second is active. + +1. **P0 — fixed**: Client-controlled `is_prod` could bypass ActionApprovalGate. The new + server-owned environment policy is verified and semantically curated. +2. **P0 — fixed (2026-08-21)**: RunnerPlan persists version/hash-pinned exact `{tool, action}` + `ActionExecutionDescriptor` snapshots. Tool-only fallback and universal non-human retry-safe + metadata are rejected; malformed legacy plans terminalize before I/O, and server-owned PROD + blocks browser mutation before provider invocation. +3. **P1 — fixed (2026-08-21)**: HumanCheckpoint and ActionApprovalGate decisions now consume + pending state through atomic SQL compare-and-set predicates. Stale/concurrent decisions fail + after exactly one winner; existing cancellation expiry remains terminal and non-reactivating. +4. **P1 — fixed (2026-08-21)**: Queued-dispatch exceptions now terminalize through a shared cleanup + path that retires current evidence projections, marks active steps inconclusive, expires leases, + and emits the existing idempotent terminal side effects. +5. **P1 — pending**: `sql_evidence`, bounded transform, AgentEvaluation/DecisionPolicy and global + capacity management remain absent despite the complete 044 target contract. +6. **P2 — pending**: Quickstart paths/counts and a stale automation migration-head test must be + aligned with the current linear Alembic chain. + +## Invariants That Must Not Regress + +- No client payload controls PROD classification, authority, RLS, action identity or retry safety. +- No external adapter returns PASS from metadata, identifiers, missing binding, invalid status, + missing digest or mismatched evidence ref. +- HumanCheckpoint is never an executor or an ActionApprovalGate. +- Only an ActionRegistry-pinned descriptor may reach executor I/O; unknown action must fail before + lease/I/O. +- A terminal run has no active lease/current evidence projection/running step. +- Repeated requests, scheduler ticks, retries and terminal projections are idempotent within their + immutable context. + +## Verification Boundaries / Deployment Debt + +- Browser-safe action provider and ScreenshotService-to-durable-evidence provider are not yet + registered in deployment; both correctly fail closed. +- A real PostgreSQL `alembic upgrade head` remains required. SQLite cannot run an earlier unrelated + migration using `drop_constraint`. +- Axiom MCP is unavailable in this session, so semantic index rebuild is not claimed. +- Legacy `ScreenshotService` is intentionally not registered as a 044 provider: its output paths + and environment credentials do not prove principal/RLS-bound durable evidence. The provider + registration hook remains startup-owned and fail-closed until an explicit lawful adapter exists. + +## Active Work Contract + +**Task**: Replace tool-only dispatch with version-pinned `{tool, action}` descriptors from the 038 +ActionRegistry; make descriptor data the only source of executor policy, side-effect key, +idempotency, retry safety, timeout and mutation guard. + +**Completion evidence required**: preflight and legacy-plan malformed cases, safe vs unsafe +recovery/retry, server-owned PROD mutation no-call, queued error closure, targeted 038/044 tests, +linear Alembic head, lint/compile/diff checks, independent verifier result, then semantic curation. + +## Latest Verification + +- Targeted lifecycle/approval/queued-dispatch/scheduler profile: 32 passed. +- RunnerPlan, worker, retry, terminal-signal and timeout profile: 19 passed. +- `python -m compileall` and `git diff --check`: passed. +- Retry API now explicitly rejects legacy unsafe tool-only plans with `409 RETRY_CONFLICT`; + the stale API expectation was aligned with the pinned-descriptor contract. +- `recover_run` was decomposed into a bounded running-step recovery helper; scoped Ruff for + execution and scenario-run API modules passes. +- Full available 044 backend profile: 241 passed (`test_scenario_*.py` registry suite plus + scenario run/automation/analytics API suites). +- Live binding and recovery profile: 40 passed; frontend profile previously verified 227 files / + 3930 tests; prototype static validation now passes. +- `alembic heads` remains a single linear head (`e3f4a5b6c7d8`), but `alembic check` and real + upgrade are blocked in this environment because `DATABASE_URL` is the placeholder + `__MUST_SET_DATABASE_URL__`; Docker Compose is also blocked by missing `SERVICE_JWT`. +- Screenshot evidence validation now accepts one or more provider-issued refs only when every ref + has a valid non-zero SHA-256 and the first ref agrees with the declared `sha256`; focused live + executor/composition profile: 36 passed. diff --git a/specs/044-dashboard-scenario-execution/checklists/requirements.md b/specs/044-dashboard-scenario-execution/checklists/requirements.md index bb074be5f..d62ffb7cc 100644 --- a/specs/044-dashboard-scenario-execution/checklists/requirements.md +++ b/specs/044-dashboard-scenario-execution/checklists/requirements.md @@ -2,15 +2,18 @@ **Purpose**: Verify SCEX-FR-001..010 completeness. | **Created**: 2026-08-07 +> **Factual audit 2026-08-20:** `[x]` requires current production evidence, `[~]` means partial +> code exists, `[ ]` means missing integration or proof. Synthetic PASS executors are not evidence. + ## Run Model (FR-001/003/007) -- [ ] CHK001 ScenarioRun first-class, pinned to scenario_id (UUID) + revision_id (UUID) + content_hash + env + param snapshot +- [~] CHK001 ScenarioRun first-class, pinned to scenario_id (UUID) + revision_id (UUID) + content_hash + env + param snapshot - [ ] CHK002 Deterministic; no LLM per step; agent not in hot path - [ ] CHK003 Recoverable by scenario_run_id; provenance for reproducibility ## Execution (FR-002/006/009) -- [ ] CHK004 Dispatch by step.tool to typed executor +- [~] CHK004 Dispatch by step.tool to typed executor - [ ] CHK005 Executors reuse 037/038/036; no second Playwright/LLM/SQL stack - [ ] CHK006 Step attempt/retry/timeout/ref binding/error_code/artifact_refs - [ ] CHK007 Failure propagation blocks dependents diff --git a/specs/044-dashboard-scenario-execution/contracts/modules.md b/specs/044-dashboard-scenario-execution/contracts/modules.md index e6f7a5587..821b81a10 100644 --- a/specs/044-dashboard-scenario-execution/contracts/modules.md +++ b/specs/044-dashboard-scenario-execution/contracts/modules.md @@ -13,8 +13,12 @@ # @ingroup ScenarioExecution # @BRIEF Deterministically derive the execution plan from a ScenarioRevision at run start. # @PRE scenario revision selected and pinned. -# @POST returns RunnerPlan with env targets, resolved params, pinned baselines, topological order, executor mapping; refuses on revision mismatch. +# @POST returns RunnerPlan with env targets, resolved params, pinned baselines, topological order, +# exact ActionExecutionDescriptor mapping plus registry version/hash; refuses on revision mismatch. # @INVARIANT runner.plan.json in git is a reference artifact, never the runtime source of truth. +# @INVARIANT Each persisted descriptor is resolved against the pinned 038 registry before a run, +# lease or adapter exists; descriptor fields alone derive retry/idempotency/timeout policy. +# @REJECTED tool-only executor fallback or universal retry-safe metadata. # @TEST_EDGE missing revision->reject; revision mismatch->reject before run. def derive_runner_plan(db, scenario_id, revision_id): ... # #endregion ScenarioExecution.RunnerPlan.Derive @@ -32,7 +36,7 @@ async def start_run(db, scenario_id, revision_id, params, env, actor, idempotenc # #region ScenarioExecution.Dispatch [C:5] [TYPE Function] [SEMANTICS scenario,execution,dispatch,step,executor] # @ingroup ScenarioExecution -# @BRIEF Dispatch a step by tool to its typed executor; record a ScenarioStepRun. +# @BRIEF Dispatch a descriptor-validated step to its typed executor; record a ScenarioStepRun. # @PRE step dependencies satisfied; {tool, action} exists in the revision-pinned 038 ActionRegistry; mutation policy preflight passed. # @POST returns step outcome; output refs bound; ScenarioStepRun persisted. # @SIDE_EFFECT executor side effects (browser/superset/xlsx/evidence); DB write. @@ -100,10 +104,11 @@ def claim_step(run_id, logical_step_id, worker_id): ... # #region ScenarioExecution.ExecutorRegistry [C:3] [TYPE Module] [SEMANTICS scenario,execution,registry,executor] # @ingroup ScenarioExecution -# @BRIEF tool -> executor mapping; BrowserExecutor resolves only 038 ActionRegistry actions; human excluded; each executor declares idempotency/retry-safety. +# @BRIEF ActionExecutionDescriptor -> executor mapping; BrowserExecutor resolves only exact 038 +# ActionRegistry actions; human excluded; descriptor declares idempotency/retry-safety. # @REJECTED human as executor (HumanCheckpoint lifecycle control instead). EXECUTOR_MAP = {browser: BrowserExecutor(ActionRegistry), superset_api, xlsx, assertion, screenshot, report, artifact} -# @INVARIANT each executor declares {idempotent, retry_safe, side_effect_key, external_request_id}. +# @INVARIANT descriptor, not executor/tool default, declares {idempotent, retry_safe, side_effect_key, timeout, mutation risk}. # @INVARIANT mutation actions require immutable mutation_contract; PROD mutation is rejected; mutation retries default false. # #endregion ScenarioExecution.ExecutorRegistry #endregion ScenarioExecution.Modules diff --git a/specs/044-dashboard-scenario-execution/data-model.md b/specs/044-dashboard-scenario-execution/data-model.md index 6a16132a1..2f95e7260 100644 --- a/specs/044-dashboard-scenario-execution/data-model.md +++ b/specs/044-dashboard-scenario-execution/data-model.md @@ -8,10 +8,29 @@ `ScenarioRun` is created before dispatch. Fields: id, scenario_id, scenario_revision_id (revision_id UUID), scenario_content_hash, verification_program_hash, action_registry_version, dashboard_id, environment_id, status (`pending_approval|queued|running|waiting_human|blocked|cancel_requested|cancelled|passed|failed|inconclusive`), phase (preflight|setup|executing|waiting_human|draining|terminal), parameter_bindings (immutable JSON), baselines_pinned (version), target_snapshot, execution_principal_fingerprint, execution_toggles (optional evidence only), trigger_source (server-owned), agent_run_id? (provenance), verification_run_id? (aggregation), idempotency_key (unique), started_at, finished_at, resume_token, error_code, runner_version. +`live_execution_binding_ref?` plus `live_execution_binding_snapshot?` are the only persisted live-I/O +coordinates. The snapshot is an exact allowlist: binding ref; environment/release/query-model, +execution-principal and RLS/security fingerprints; browser-safe checkpoint/action refs; and evidence +owner/ref policy. It contains no credential, client, cookie, raw browser context, callable principal, +or capture bytes. `LiveExecutionCompositionRoot` resolves this immutable identity server-side at +startup; a missing provider is typed unavailable, and any exact-snapshot mismatch is typed non-pass +before I/O. + +The deployment-owned `settings.scenario_live_execution_bindings[]` record stores `enabled`, +`binding_snapshot`, and `query_model_snapshot` only. Startup resolves its `Environment` credentials +server-side to build the existing `SupersetClient`, then registers the exact model/storage tuple. +Browser and screenshot providers are process-local registrations and are never serialized in this +record; absent registrations yield stable configured-unavailable outcomes. + If the selected revision contains a `human` step, it is derived as `manual_run_only=true`: it may start only from the authenticated analyst manual-run route. Scheduler, deploy/ETL/API trigger and any background runner are ineligible; no HumanCheckpoint may be skipped or defaulted. For a PROD request, the service atomically creates `ScenarioRun(status=pending_approval)` and `ActionApprovalGate(owner_type=scenario_run, owner_id=run_id, operation=scenario_execution)`. Approval transitions only `pending_approval → queued`; denial/expiry transitions to `blocked`. Schedulers and external triggers therefore receive a durable run/intent, never an unusable 403. `trigger_source` is set only by the trusted entry point (manual route, scheduler, deploy connector, or API key), never by a bearer client field. Idempotency uses canonical execution-request hash: same key + same hash returns the existing run; same key + different hash returns `409 IDEMPOTENCY_KEY_REUSED`. +`EnvironmentExecutionPolicy` is resolved server-side from the configured `Environment` record +(`stage=PROD` or `is_production=true`) before RBAC, idempotency, run, gate, queue, notification, or +adapter work. Any compatibility `is_prod` request field is non-authoritative and cannot upgrade or +downgrade that class; an unknown environment fails closed without creating a run or gate. + ## ParameterBinding, ExecutionPrincipal, and TargetSnapshot `ParameterBinding`: parameter_name, resolved_value, source, resolved_at, validation_fingerprint. It is derived at start from the 038 `ParameterDefinition` and launch input; it is never embedded in ScenarioRevision or its `content_hash`. @@ -40,7 +59,10 @@ Fields: id, run_id FK, logical_step_id (immutable UUID, from #8), step_position ## RunnerPlan — deterministic derivation from revision (#2) -RunnerPlan is **derived deterministically at run start** from the selected immutable `ScenarioRevision`/Verification Program, NOT read from a stored `runner.plan.json`. Fields: scenario_revision_id, scenario_content_hash, verification_program_hash, action_registry_version, env targets, resolved params, pinned baselines, topological order, executor mapping per step, retry/timeout policy, decision-policy map, human-checkpoint list. Run refuses if its revision/program/action-registry hashes differ from the selected revision. +RunnerPlan is **derived deterministically at run start** from the selected immutable `ScenarioRevision`/Verification Program, NOT read from a stored `runner.plan.json`. Fields: scenario_revision_id, scenario_content_hash, verification_program_hash, `action_registry_version`, `action_registry_hash`, env targets, resolved params, pinned baselines, topological order, and one immutable `ActionExecutionDescriptor` per step. A descriptor contains the exact `{tool, action}`, typed input/output contracts, idempotency, retry safety, side-effect-key policy, timeout, mutation contract/risk. The runner persists the descriptor snapshot in both `steps` and `executor_mapping`; it derives leases/recovery/retry policy only from that snapshot. A missing, altered, unknown, version/hash-mismatched descriptor, invalid input/output shape, or mutating action without its required contract rejects before run/lease/I/O. Run refuses if its revision/program/action-registry hashes differ from the selected revision. + +@INVARIANT RunnerPlan descriptor resolution is exact and version/hash pinned; `tool` is never a dispatch or retry-policy fallback. +@REJECTED A universal `idempotent=true, retry_safe=true` claim based on a non-human tool was rejected because it can repeat unsafe effects. The materialized `runner.plan.json` in git is a **reference artifact**, never the runtime source of truth; it may be regenerated from any revision. @@ -81,7 +103,7 @@ pending_approval → queued → running → waiting_human | blocked → passed | ## ScenarioExecutorRegistry and BrowserExecutor -Mapping `tool -> executor`: +The registry resolves `ActionExecutionDescriptor -> executor`; the following list states each descriptor's tool family, not a tool-only fallback: - browser -> `BrowserExecutor` → version-pinned 038 `ActionRegistry` → Playwright/session infrastructure - superset_api -> 037 metric_executor_async / SupersetClient.ChartData.Execute - sql_evidence -> `SqlEvidenceExecutor` → Superset SQL Lab backend/API → configured database connection (no credentials exposed to agent) diff --git a/specs/044-dashboard-scenario-execution/plan.md b/specs/044-dashboard-scenario-execution/plan.md index b3cbfd505..82a0a8136 100644 --- a/specs/044-dashboard-scenario-execution/plan.md +++ b/specs/044-dashboard-scenario-execution/plan.md @@ -1,6 +1,9 @@ # Implementation Plan: Scenario Execution Engine -**Branch**: `044-dashboard-scenario-execution` | **Date**: 2026-08-07 | **Spec**: spec.md | **Status**: Draft +**Branch**: `044-dashboard-scenario-execution` | **Date**: 2026-08-07 | **Spec**: spec.md | **Status**: Not production-complete — factual audit pending remediation + +> **Implementation audit, 2026-08-20:** run models/API/primitives exist; synthetic executors, missing +> executor imports and insufficient walker tests prevent the plan from being considered delivered. ## Summary diff --git a/specs/044-dashboard-scenario-execution/prototype/index.html b/specs/044-dashboard-scenario-execution/prototype/index.html index 288e7290b..4ec493466 100644 --- a/specs/044-dashboard-scenario-execution/prototype/index.html +++ b/specs/044-dashboard-scenario-execution/prototype/index.html @@ -1,3 +1,21 @@ + + + + + + + + + + + + + + + + + + @@ -23,22 +41,25 @@
Scenario run SR-1842 · revision r18

XLSX reconciliation - Выполняется + waiting_human

PREPROD · Target: rc-17 · Анна · параметры сохранены в snapshot

-
- +

Ход проверки 4 из 6

@@ -105,7 +126,7 @@ >XLSX: 124 rows · baseline: 124 rows
- @@ -123,11 +144,11 @@ trail.

-
@@ -142,58 +163,74 @@ -
- State: + -
+ + diff --git a/specs/044-dashboard-scenario-execution/prototype/manifest.md b/specs/044-dashboard-scenario-execution/prototype/manifest.md index eb009ad85..2a147bddf 100644 --- a/specs/044-dashboard-scenario-execution/prototype/manifest.md +++ b/specs/044-dashboard-scenario-execution/prototype/manifest.md @@ -1,28 +1,38 @@ #region ScenarioExecution.PrototypeManifest [C:3] [TYPE ADR] [SEMANTICS prototype,manifest,scenario,execution] @defgroup Prototype Interactive HTML prototype manifest for Scenario Execution Engine (engine-level states). +@RELATION DEPENDS_ON -> [ScenarioExecution.PrototypeRun] +@RELATION DEPENDS_ON -> [Test.ScenarioExecution.PrototypeStatic] +@RATIONALE The manifest distinguishes declared source-level reachability from browser, assistive-technology, and production-runtime evidence so the prototype cannot overstate its proof. +@REJECTED Treating static source checks as browser validation or production execution evidence was rejected — neither rendering nor runtime integrations execute in this verifier. ## Prototype Metadata + +> **Factual audit 2026-08-21:** `validate_static.py` proves source-level lifecycle names, persistent +> live-region attributes, native state controls, 44px sizing and reduced-motion declarations. It does +> not prove browser rendering, keyboard operation, assistive-technology announcements, runner execution, +> executor outcomes, artifacts or recovery in production code. - **Feature**: 044 Scenario Execution Engine - **Source contracts**: ux_reference.md, contracts/modules.md - **Screens represented**: 1 (Scenario Run engine view) - **Total states**: 6 (running, waiting_human, resumed, cancelled, failed, passed) -- **Accessibility**: keyboard nav, aria-live, ≥44px, prefers-reduced-motion +- **Accessibility (static)**: native buttons, persistent `role=status`/polite atomic live region, + ≥44px target declarations, prefers-reduced-motion override - **Responsive**: 375px, 900px ## State Coverage | @UX_STATE | Prototype State | Reachable? | Recovery | |-----------|-----------------|------------|----------| -| running | running | ✅ | — | -| waiting_human | waiting_human | ✅ | confirm/false-positive/inconclusive | -| resumed | resumed | ✅ | continues from resume token | -| cancelled | cancelled | ✅ | — | -| failed | failed | ✅ | retry / triage (047) | -| passed | passed | ✅ | — | +| running | running | ✅ statebar | — | +| waiting_human | waiting_human | ✅ initial + statebar | confirm/false-positive/inconclusive | +| resumed | resumed | ✅ statebar/confirm | continues from resume token | +| cancelled | cancelled | ✅ statebar/stop | — | +| failed | failed | ✅ statebar/non-pass decision | retry / triage (047) | +| passed | passed | ✅ statebar | — | ## Screen ↔ Story Traceability -| Story | Prototype Feature | Acceptance Verified | +| Story | Prototype Feature | Intended acceptance coverage | |-------|-------------------|---------------------| | US1 Start | run header + snapshot | pinned revision | | US2 Dispatch | tool→executor row, step statuses | deterministic dispatch | diff --git a/specs/044-dashboard-scenario-execution/prototype/validate_static.py b/specs/044-dashboard-scenario-execution/prototype/validate_static.py new file mode 100644 index 000000000..458205272 --- /dev/null +++ b/specs/044-dashboard-scenario-execution/prototype/validate_static.py @@ -0,0 +1,98 @@ +# #region Test.ScenarioExecution.PrototypeStatic [C:3] [TYPE Module] [SEMANTICS test,scenario,execution,prototype,a11y,ux] +# @BRIEF Falsify static 044 prototype regressions without claiming browser-interaction execution. +# @RELATION BINDS_TO -> [ScenarioExecution.PrototypeRun] +# @RELATION VERIFIES -> [ScenarioExecution.PrototypeRun] +# @RELATION VERIFIES -> [ScenarioExecution.PrototypeManifest] +# @TEST_CONTRACT: prototype/index.html + prototype-ui.css + manifest.md -> declared static UX/a11y guarantees +# @TEST_FIXTURE: 044 prototype source files -> repository paths +# @TEST_EDGE: hidden_waiting_alias; replaced_live_region; undersized_statebar_button; missing_reduced_motion; unreachable_declared_state +# @TEST_INVARIANT ScenarioExecution.PrototypeRun: Initial state, live announcements, native state controls, target sizing and reduced motion remain statically provable. -> VERIFIED_BY: validate_static +# @TEST_INVARIANT ScenarioExecution.PrototypeManifest: Six declared states map to native source transitions; this verifier does not claim browser or production-runtime evidence. -> VERIFIED_BY: validate_static +from __future__ import annotations + +from pathlib import Path +import re +import sys + + +_PROTOTYPE = Path(__file__).resolve().parent +_INDEX = _PROTOTYPE / "index.html" +_MANIFEST = _PROTOTYPE / "manifest.md" +_CSS = _PROTOTYPE.parents[1] / "prototype-ui.css" +_STATES = ("running", "waiting_human", "resumed", "cancelled", "failed", "passed") + + +# #region Test.ScenarioExecution.PrototypeStatic.Require [C:1] [TYPE Function] +def _require(condition: bool, message: str, failures: list[str]) -> None: + if not condition: + failures.append(message) +# #endregion Test.ScenarioExecution.PrototypeStatic.Require + + +# #region Test.ScenarioExecution.PrototypeStatic.Main [C:3] [TYPE Function] +# @BRIEF Check the concrete source contract; this is static evidence, not browser interaction evidence. +def main() -> int: + index = _INDEX.read_text(encoding="utf-8") + manifest = _MANIFEST.read_text(encoding="utf-8") + css = _CSS.read_text(encoding="utf-8") + failures: list[str] = [] + + _require('protoState("waiting_human"' in index, "initial state must be waiting_human", failures) + _require('protoState("waiting"' not in index, "hidden waiting alias must be absent", failures) + _require(index.count('id="run-announcement"') == 1, "one persistent live node is required", failures) + live_node = re.search(r']*\bid="run-announcement"[^>]*>', index) + _require(live_node is not None, "one persistent live node is required", failures) + live_attributes = live_node.group(0) if live_node is not None else "" + _require( + all( + attribute in live_attributes + for attribute in ('role="status"', 'aria-live="polite"', 'aria-atomic="true"') + ), + "live node needs polite atomic status semantics", + failures, + ) + _require("announcement.textContent" in index, "state/decision updates must announce through live node", failures) + _require( + "checkpointDecision" in index and "decisionLabels[checkpointDecision]" in index, + "checkpoint decision must use the persistent live node", + failures, + ) + _require( + not re.search(r'getElementById\(["\']run-announcement["\']\)\.(?:innerHTML|outerHTML|replaceWith)', index), + "live node must not be replaced", + failures, + ) + _require("min-height: 44px" in css and "min-inline-size: 44px" in css, "interactive targets need 44px sizing", failures) + _require("@media (prefers-reduced-motion: reduce)" in css, "reduced-motion media query is required", failures) + button_tags = re.findall(r"]*>", index) + _require(bool(button_tags), "prototype needs native button controls", failures) + _require( + all('class="btn' in button and 'type="button"' in button for button in button_tags), + "every prototype button must be a native .btn target", + failures, + ) + _require('class="nav"' in index and "]*onclick="setProtoState\(\'{state}\'\)"', index, + ) is not None, + f"statebar {state} lacks a native transition", + failures, + ) + _require(f'"{state}":' in index, f"state renderer lacks {state}", failures) + _require(f"| {state} | {state} | ✅" in manifest, f"manifest does not factually declare {state}", failures) + + if failures: + print("prototype static validation failed:", *failures, sep="\n- ") + return 1 + print("prototype static validation passed") + return 0 +# #endregion Test.ScenarioExecution.PrototypeStatic.Main + + +if __name__ == "__main__": + sys.exit(main()) +# #endregion Test.ScenarioExecution.PrototypeStatic diff --git a/specs/044-dashboard-scenario-execution/quickstart.md b/specs/044-dashboard-scenario-execution/quickstart.md index 81e1ea799..9e689e54a 100644 --- a/specs/044-dashboard-scenario-execution/quickstart.md +++ b/specs/044-dashboard-scenario-execution/quickstart.md @@ -1,5 +1,12 @@ # Quickstart: Scenario Execution Engine (044) +> **Factual audit 2026-08-20:** the targeted service profile independently reverified 35 passes. +> Browser/Superset/Screenshot are fail-safe: they cannot synthesize PASS without explicit typed +> adapter success, and evidence requires a real valid digest/ref. This is not proof of real live +> Playwright/Superset/Screenshot composition. +> The Superset binding slice persists an immutable identity snapshot and, under an injected exact +> resolver, invokes the existing 037 query envelope and stores its exact raw-byte digest/ref. + ## Prereqs - 042 registry + DB migrated (scenario_runs, scenario_step_runs) - Backend venv @@ -16,9 +23,28 @@ python -m ruff check src/services/dashboard_testing/execution/ ## Exit Gates - [ ] Fixture scenario runs end-to-end, dispatching every step to correct executor -- [ ] Human checkpoint suspends + resumes without rerun +- [x] Persisted worker lease/idempotency checks reject unsafe reclaim and live-lease conflicts +- [x] Human and infrastructure resume advance only the missing DAG frontier; completed steps are not rerun +- [x] Browser/Superset/Screenshot do not manufacture PASS; invalid/missing evidence digest/ref is inconclusive +- [x] Superset live binding persists ref/snapshot; exact resolver/model match invokes 037 and stores exact digest/ref - [ ] Cancel terminates `cancelled` with bounded drain -- [ ] Run recoverable by scenario_run_id +- [x] Server-driven crash recovery by `scenario_run_id` uses only the persisted RunnerPlan and + expired worker claims: only idempotent/retry-safe frontier work gets a new attempt; unsafe + effects require reconciliation, and browser work without a pinned browser-safe checkpoint is + typed non-pass without replay. Retired evidence remains historical, and repeated rejected + terminal contexts reuse the idempotent queue signal. - [ ] Results reference immutable revision + provenance - [ ] human not invoked as executor; PROD gated -- [ ] ruff clean; prototype states covered +- [ ] T022 full scope, scoped ruff, semantic rebuild, and prototype states covered + +## Verification Boundary + +- The exact API test file has a 29-pass result outside this sandbox. +- In this sandbox FastAPI TestClient is blocked by AnyIO self-pipe `EPERM`; this is an execution + restriction, not an application failure. +- Startup composition supplies config-owned 037 resolver/client/model/storage for enabled exact + bindings. Browser and Screenshot providers remain absent and fail closed; prototype validation + remains unresolved. +- Before deployment, PostgreSQL must run and verify `alembic upgrade head` for the additive + `c1d2e3f4a5b6` live-binding columns/index; the isolated migration test does not replace that + deployment prerequisite. diff --git a/specs/044-dashboard-scenario-execution/spec.md b/specs/044-dashboard-scenario-execution/spec.md index e76b4d08e..7f2e802ee 100644 --- a/specs/044-dashboard-scenario-execution/spec.md +++ b/specs/044-dashboard-scenario-execution/spec.md @@ -1,5 +1,5 @@ #region ScenarioExecution.Spec [C:3] [TYPE ADR] [SEMANTICS spec,requirements,scenario,execution,run,step,engine] -@BRIEF First-class ScenarioRun/ScenarioStepRun execution engine that walks a validated DashboardTestScenario DAG and dispatches each step to a typed executor, with run lifecycle, cancellation, retry, timeout, and human-checkpoint pause/resume. The most critical missing piece — a scenario can be created but not executed. +@BRIEF Partially verified ScenarioRun/ScenarioStepRun execution engine that walks a validated DashboardTestScenario DAG through typed executors, with lifecycle, retry, timeout, and human-checkpoint pause/resume. It remains not production-complete because live composition, T022, prototype validation, and semantic indexing are unresolved. @RELATION DEPENDS_ON -> [Doc.Adr.ADR0001] @RELATION DEPENDS_ON -> [Doc.Adr.ADR0003] @RELATION DEPENDS_ON -> [AgentTestStabilization.Spec] @@ -15,7 +15,7 @@ @SEMANTICS: spec, requirements, feature, scenario, execution, run, step, runner, engine, resume, human **Feature Branch**: `044-dashboard-scenario-execution` -**Created**: 2026-08-07 | **Status**: Draft +**Created**: 2026-08-07 | **Status**: Not production-complete — fail-safe execution closure and targeted verification are recorded below; production composition remains open **Input**: "Provide a Scenario Execution Engine: a deterministic runner that walks the validated DashboardTestScenario graph, dispatches each step by tool to a typed executor (browser, superset_api, xlsx, assertion, screenshot, report, artifact), and manages the run lifecycle (queued, running, waiting_human, blocked, cancel, retry, timeout, resume, passed, failed, inconclusive) with immutable execution snapshots pinned to a scenario revision." ## User Scenarios @@ -68,7 +68,9 @@ **Acceptance**: 1. **Given** a user requests cancel **When** submitted **Then** the run enters `cancel_requested`, drains in-flight steps within a bounded window, and terminates `cancelled`. 2. **Given** a step fails **When** retry is requested **Then** a new attempt runs with bounded attempts and updated `attempt` count. -3. **Given** a step exceeds its timeout **When** triggered **Then** the step is marked failed/inconclusive with a timeout reason, and the run continues per policy. +3. **Given** a step exceeds its timeout **When** triggered **Then** the timed step is inconclusive and + every declared downstream node is materialized from the pinned RunnerPlan as blocked before the + run terminalizes; no later walker continuation may dispatch a lazy descendant. --- @@ -89,10 +91,10 @@ | # | Scenario | Category | Expected Behavior | Recovery | |---|----------|----------|-------------------|----------| | E1 | PROD run without approval | auth/gate | Durable `pending_approval` run + gate, no dispatch | Approve via ActionApprovalGate | -| E2 | Step timeout | execution | Step failed/inconclusive with reason | Retry / continue | +| E2 | Step timeout | execution | Timed step inconclusive; pinned-plan descendants blocked before terminalization | Retry only; no late continuation dispatch | | E3 | Retry exhausted | execution | Step failed; dependents blocked | Triage (047) | | E4 | Superset 5xx/403/422 | integration | Typed error taxonomy preserved | Retry / continue | -| E5 | Runner crash mid-run | resilience | API/XLSX steps resume only if retry-safe; browser state replays from last browser-safe checkpoint | Recover / replay | +| E5 | Runner crash mid-run | resilience | Only expired idempotent/retry-safe work may resume; unsafe work requires reconciliation; browser without a pinned safe checkpoint is inconclusive without replay | Recover safe frontier / reconcile | | E6 | Cancel mid-step | concurrency | In-flight completes or times out in drain window | — | | E7 | Duplicate output ref | data-integrity | 038 validator rejects before run | Fix graph | | E8 | Stale scenario run (registry) | data-quality | Warning-gated or blocked per policy | Revalidate | @@ -119,6 +121,41 @@ - **SCEX-FR-015**: `AgentEvaluation` MUST be a separate immutable runtime record and DecisionPolicy MUST deterministically map it plus deterministic evidence to StepOutcome. A bare model verdict never directly sets ScenarioResult. - **SCEX-FR-016**: Browser actions and mutation safety MUST use the same versioned 038 ActionRegistry/mutation contract. Mutating browser steps in PROD are prohibited; test-data mutation needs fixture scope, record keys, side-effect/retry and cleanup policy independent of PROD approval. +### Live Execution Composition Root (SCEX-LIVE-COMPOSITION) + +`LiveExecutionBinding` persisted on `ScenarioRun` is identity-only: a binding reference plus immutable +environment, release, query-model, execution-principal, RLS/security, browser-safe-checkpoint/action, +and evidence-policy fingerprints. It MUST NOT serialize a client, secret, cookie, callable principal, +raw browser context, or capture bytes. + +Application startup owns `LiveExecutionCompositionRoot`. It is the only server-side registration point +for authorized providers: an exact 037 `SupersetClient` + immutable `DashboardQueryModel` + principal/RLS +binding, a browser-safe session/action provider, and a ScreenshotService capture provider with durable +evidence storage. The root resolves a persisted binding only after every pinned identity/fingerprint +matches; it never constructs authority from caller IDs, run metadata, or `environment_id`. Missing +provider/configuration is `*_BINDING_UNAVAILABLE`; a malformed or unauthorized/mismatched identity is +`*_BINDING_INVALID`/`*_BINDING_MISMATCH`. Both fail closed and perform no I/O. + +Deployment config is `settings.scenario_live_execution_bindings[]`. Every enabled record contains only +`binding_snapshot` and `query_model_snapshot`; its credentials are looked up by the server from the +configured `Environment` record when startup constructs the existing `SupersetClient`. The root marks +a configured record whose environment/model/storage cannot be resolved as `SUPERSET_BINDING_UNAVAILABLE`. +It marks browser and screenshot slots from an enabled binding as `BROWSER_BINDING_UNAVAILABLE` and +`SCREENSHOT_BINDING_UNAVAILABLE` until a server-owned bootstrap calls `register_browser` or +`register_screenshot` with a lawful provider. An empty list is valid and leaves every live tool typed +unavailable. + +Providers return typed success/failure/timeout/cancellation outcomes. PASS additionally requires a +durable evidence reference and verified non-zero SHA-256. Where a provider supports transport +cancellation, the root passes the capability through; otherwise lifecycle cancellation remains +database-authoritative and the result remains typed. Browser recovery is lawful only from the declared +safe checkpoint/reconstruction binding; a raw session is never revived. A mutating browser action must +also satisfy the version-pinned 038 mutation contract and is rejected in PROD. + +`ScreenshotService` currently captures paths for the LLM workflow but does not expose a 044 +principal/RLS/checkpoint-bound durable-evidence provider. It therefore remains typed unavailable until +startup registers such a provider; no path or raw capture metadata is treated as evidence. + ### Key Entities - **ScenarioRun**: Recoverable execution instance of a pinned scenario revision; owns status, phase, params, provenance, steps. @@ -144,11 +181,62 @@ - Q: Is human an executor? → A: No. It is a runner-lifecycle suspend/resume control; the executor registry covers browser/superset_api/xlsx/assertion/screenshot/report/artifact. - Q: How does this differ from VerificationRun/AgentRun? → A: ScenarioRun executes the user-created DashboardTestScenario DAG; AgentRun is the creation run; VerificationRun is release-pipeline category verification. Three distinct run concepts. -## Implementation Status & MVP Debt (audit 2026-08-07) +## Implementation Status & MVP Debt (factual audit 2026-08-20) -**Facts (code check):** -- 🔴 No ScenarioRun/ScenarioStepRun model or runner exists; `runner.plan.json` is a stub `{scenario_id, revision_id, step_count}` (and is a reference artifact only — the RunnerPlan is derived at run start). -- 🔴 No executor registry or dispatch; `execute_step`/`ScenarioStep.*execute` grep = empty. -- ✅ Executors to reuse already exist: 037 `metric_executor_async`/`comparison.py`, 038 `capture.py`, 036 evidence/artifacts/HITL, 040 RunnerPool pattern. +ScenarioRun/ScenarioStepRun models, lifecycle/API primitives, a runner plan, and a fail-safe executor +boundary now exist. They do not yet constitute the specified production execution engine. + +- `[x]` Persisted worker lease/idempotency checks are proven: a live lease rejects another worker and + reclaim is limited to retry-safe/idempotent recorded effects. +- `[x]` HTTP and 046 automation starts persist or replay only queued/pending ScenarioRuns. Initial + adapter dispatch is performed separately when the server dispatcher wins the durable + `queued -> running` CAS; repeated ticks/replays do not dispatch again. Automated human plans are + rejected before a queued row reaches that CAS, while manual human runs reach their checkpoint only + after the dispatcher claims them. +- `[~]` `EnvironmentPolicy` resolves `stage=PROD`/`is_production=true` only from server + ConfigManager state before idempotency or persistence: client compatibility flags cannot change + the class, unknown targets fail closed, and manual/API/event/scheduled sources create the same + durable `pending_approval` gate. Pending rows remain excluded from dispatcher execution. A real + APScheduler scheduled-PROD integration test is still coverage debt, not a bypass. +- `[x]` Human and infrastructure continuation resume only the missing DAG frontier; completed steps + are not re-run. Walker tests cover completed/failure/artifact and evidence-integrity paths. +- `[x]` A revision containing `human` derives `manual_run_only=true`. Every trusted 046 automation + source (scheduled, deploy, release, ETL, API) rejects before idempotency lookup, ScenarioRun, + approval gate, notification, queue, or dispatch side effects; the same key remains usable by the + authenticated manual route. HumanCheckpoint observation remains distinct from ActionApprovalGate. +- `[x]` Failed, blocked and inconclusive terminal runner results emit one idempotent canonical 047 + queue input keyed by immutable run/status/revision/target/principal and registered artifact ref/digest + provenance. Queue projection never opens a case, AgentRun, chat or remediation action; passed runs emit none. +- `[x]` Strict eligible retry invalidates the persisted failed/inconclusive/blocked target and its + downstream closure before re-walk. Prior step attempts and artifact rows are retained as historical + provenance, but retired artifact projections are excluded from active result/terminal evidence. + Re-terminalization has a distinct immutable attempt context; replay of that same context is idempotent. +- `[x]` A timeout during claimed adapter I/O wins over a late payload. Before terminalization it + materializes every otherwise-lazy descendant from the immutable pinned RunnerPlan as a blocked + row, so a later walker cannot dispatch that descendant. This is lifecycle closure, not proof of + real live Browser/Superset/Screenshot I/O composition. +- `[x]` Server-driven crash recovery loads only persisted ScenarioRun/RunnerPlan/lease state by + `scenario_run_id`: completed ancestors are not re-run; an expired idempotent/retry-safe claim is + archived and retried as a new attempt; unsafe effects terminate blocked for reconciliation; and + browser recovery without a pinned browser-safe checkpoint terminates inconclusive without adapter + I/O. Historical evidence remains audit-visible but inactive for a new attempt, and repeated + rejected terminal contexts reuse their idempotent signal. It does not consume an infrastructure + resume token, HumanCheckpoint, or ActionApprovalGate. +- `[~]` Browser/Superset/Screenshot can materialize PASS only from explicit typed adapter success; + unavailable live I/O and missing/invalid evidence digest/ref are typed inconclusive. This is a + fail-safe partial closure, not evidence of real live Playwright/Superset/Screenshot composition. +- `[~]` `ScenarioRun` persists a nullable `LiveExecutionBinding` identity snapshot (binding ref, + environment/release/query-model/principal/RLS fingerprints, browser-safe binding refs and evidence + policy). Application startup owns a fail-closed `LiveExecutionCompositionRoot`: an exact registered + 037 client/model/DraftStorage tuple is wired into the default dispatcher, while registered browser and + Screenshot providers are validated against the same snapshot. Missing/mismatched providers are typed + inconclusive and never call I/O. No deployment has yet registered a browser-safe action or + ScreenshotService-to-durable-evidence provider, so those tools remain unavailable by default. +- `[~]` The targeted service profile independently reverified 35 passes. The exact API file has a + 29-pass result outside this sandbox; inside it FastAPI TestClient is blocked by AnyIO self-pipe + `EPERM`, a sandbox restriction rather than an application failure. +- `[ ]` Live production composition adapters, full T022 scope, recurring-episode classification from + terminal signals, and an Axiom + semantic-index rebuild remain unresolved; SCEX-FR-001..016 are not globally closed. #endregion ScenarioExecution.Spec diff --git a/specs/044-dashboard-scenario-execution/tasks.md b/specs/044-dashboard-scenario-execution/tasks.md index 2613c7558..051027035 100644 --- a/specs/044-dashboard-scenario-execution/tasks.md +++ b/specs/044-dashboard-scenario-execution/tasks.md @@ -5,6 +5,9 @@ ## Format: `- [ ] T### [P] [USx] Description with exact file path` +> **Factual audit 2026-08-20:** `[x]` means code plus relevant evidence; `[~]` means partial +> implementation; `[ ]` means absent integration or unperformed verification. + ## Phase 1 — Setup - [x] T001 Create `ScenarioRun`/`ScenarioStepRun` models + alembic migration in `backend/src/models/scenario_run.py` @@ -22,17 +25,17 @@ - [x] T006 [US1] Write failing `start_run` tests in `backend/tests/services/dashboard_testing/registry/test_scenario_runner.py` @TEST_EDGE: prod_without_approval->blocked; stale_revision->blocked -- [x] T007 [US1] Implement `ScenarioExecutorRegistry` + typed dispatch boundary in `execution/executor_registry.py` -- [x] T008 [US1] Implement `start_run` in `execution/runner.py` +- [~] T007 [US1] Implement `ScenarioExecutorRegistry` + typed dispatch boundary in `execution/executor_registry.py` +- [~] T008 [US1] Implement `start_run` in `execution/runner.py` @POST: run pinned to immutable revision; queued->running; RunnerPlan deterministically derived ## Phase 4 — US2 Deterministic Dispatch - [x] T009 [US2] Write failing `dispatch_step` tests in `backend/tests/services/dashboard_testing/registry/test_scenario_dispatch.py` -- [x] T010 [US2] Implement `dispatch_step` in `execution/dispatch.py` +- [~] T010 [US2] Implement `dispatch_step` in `execution/dispatch.py` @INVARIANT: human not dispatched here; deterministic per tool; ref binding @TEST_EDGE: unknown_tool->rejected; step_fail->dependents blocked; ref_binding->dependent reads producer output -- [x] T011 [US2] Implement dependency readiness + failure propagation in `execution/dispatch.py` +- [~] T011 [US2] Implement dependency readiness + failure propagation in `execution/dispatch.py` ## Phase 5 — US3 Human Suspend/Resume @@ -46,19 +49,23 @@ - [x] T014b [P] Write worker claim/lease/idempotency contracts in `backend/tests/services/dashboard_testing/registry/test_scenario_worker.py` - [x] T014c [P] Implement `claim_step` (lease/heartbeat/idempotency) in `execution/worker.py` - @INVARIANT: at-least-once; external side effect not re-run unless idempotent/retry-safe + @INVARIANT: at-least-once; persisted lease/idempotency checks are proven; an external side + effect is not re-run unless idempotent/retry-safe. - [x] T014d [P] Implement `require_action_gate` (PROD authorization) + duplicate Idempotency-Key rejection in `execution/runner.py` ## Phase 5c — RunnerPlan derivation + artifacts (P0 #2/#4) -- [x] T014e [P] Implement `derive_runner_plan` (deterministic derivation from revision) in `execution/runner_plan.py` +- [~] T014e [P] Implement `derive_runner_plan` (deterministic derivation from revision) in `execution/runner_plan.py` @INVARIANT: runner.plan.json is reference, never runtime source of truth; revision mismatch rejected -- [x] T014f [P] Generalize artifact owner (owner_type=scenario_run) for evidence/screenshot/report in `execution/artifacts.py` +- [~] T014f [P] Generalize artifact owner (owner_type=scenario_run) for evidence/screenshot/report in `execution/artifacts.py` ## Phase 5d — Retry closure + result aggregation (P0 #14/#16) - [x] T014g [P] Implement retry downstream-closure invalidation in `execution/lifecycle.py` -- [x] T014h [P] Implement result aggregation truth table in `execution/result.py` + @INVARIANT: strict eligible retry retires the active target/descendant closure, archives prior + step attempts, and preserves artifact rows as inactive historical provenance. A reterminalized + attempt has a new immutable terminal signal; replay of the same terminal context is idempotent. +- [~] T014h [P] Implement result aggregation truth table in `execution/result.py` ## Phase 6 — US4 Cancel/Retry/Timeout @@ -68,17 +75,70 @@ ## Phase 7 — US5 Snapshot + API + PROD gate -- [x] T017 [US5] Implement immutable execution snapshot + provenance in `execution/result.py` -- [x] T018 [US5] Add `POST /scenario-runs` (Idempotency-Key), `cancel`, `resume`, `GET /{id}`, `GET /{id}/events` (SSE), `GET /scenarios/{id}/runs`, `GET /scenario-runs/{id}/result`, `GET /scenario-runs/compare`, `POST /scenario-runs/{id}/steps/{logical_step_id}/retry` in `api/routes/dashboard_testing/scenario_runs.py` -- [x] T019 [US5] PROD approval gate (036) + RBAC scenario:run / scenario:run:prod tests +- [~] T017 [US5] Implement immutable execution snapshot + provenance in `execution/result.py` +- [~] T018 [US5] Add `POST /scenario-runs` (Idempotency-Key), `cancel`, `resume`, `GET /{id}`, `GET /{id}/events` (SSE), `GET /scenarios/{id}/runs`, `GET /scenario-runs/{id}/result`, `GET /scenario-runs/compare`, `POST /scenario-runs/{id}/steps/{logical_step_id}/retry` in `api/routes/dashboard_testing/scenario_runs.py` +- [~] T019 [US5] PROD approval gate (036) + RBAC scenario:run / scenario:run:prod tests. + Server ConfigManager policy now ignores client `is_prod` compatibility flags, fails closed for + unknown environments, and gives manual/API/event/scheduled origins the same durable + `pending_approval` gate before dispatcher CAS. A dedicated real APScheduler scheduled-PROD + integration test remains coverage debt; it does not authorize dispatch around the gate. - [x] T020 [P] Frontend DTOs (ScenarioRun, ScenarioStepRun, ScenarioExecutionResult) in `frontend/src/types/scenario-run.ts` ## Phase 8 — Polish - [x] T021 [P] Belief-runtime instrumentation for C5 runner/dispatch -- [x] T022 Run quickstart-equivalent full scenario backend scope, scoped ruff, ATTN/orphan static audit and semantic rebuild. - Verified: 296 passed/1 skipped; ruff clean; one Alembic head; doc-gen indexed 9576 contracts/4730 edges. -- [x] T023 **Prototype validation**: every declared @UX_STATE is reachable via `prototype/index.html`; Playwright verified 1440x900 and 390x844 without overflow or control overlap. +- [ ] T022 Run quickstart-equivalent full scenario backend scope, scoped ruff, ATTN/orphan static audit and semantic rebuild. +- [x] T023 **Prototype validation**: every declared @UX_STATE is reachable via `prototype/index.html`. + Proof: `python specs/044-dashboard-scenario-execution/prototype/validate_static.py`. + +## Audit Follow-ups (2026-08-20) + +- [~] T024 Replace synthetic default executor outcomes with typed adapter boundaries for required + 037/038/036 and existing browser/XLSX infrastructure; add an import/compile test for the + executor module. Assertion uses 037 compare_values; browser/Superset/Screenshot never + synthesize PASS without explicit typed adapter success, and invalid evidence digest/ref is + inconclusive. The Superset binding slice persists an immutable identity snapshot and invokes + the existing 037 query envelope only after an exact composition-owned resolver match, storing + the exact raw-byte digest/ref. This is fail-safe partial closure, not proof of real live + composition. +- [~] T025 Add falsifiable DAG tests for actual executor output, failures, descendants, artifact writes, + checkpoint resume, timeout/cancel drain, recovery and approval-to-dispatch. + Failed assertion now blocks descendants and xlsx registers an artifact + (test_failed_assertion_blocks_descendants_and_xlsx_registers_artifact). Cancellation now + persists a one-time drain deadline: queued work is skipped, a claimed adapter may finish only + during the window, and an expired sweep retires leases/current projections while preserving + immutable evidence history. A timeout during claimed adapter I/O wins over a late PASS, + materializes every otherwise-lazy declared descendant as a durable blocked row from the + pinned RunnerPlan, and emits one inconclusive signal (proof: + test_scenario_cancel_timeout.py). Human and infrastructure resume advance only the missing + DAG frontier, so completed steps are not re-run. Server-driven crash recovery loads only the + persisted run/RunnerPlan and expired lease: safe frontier attempts are archived/retried once, + unsafe effects require reconciliation, and browser recovery requires a pinned safe checkpoint + (test_scenario_crash_recovery.py). Retired evidence remains historical and rejected terminal + contexts reuse their idempotent signal. Persisted artifact evidence needs a real valid digest/ref. + Approval-to-live-dispatch still needs an authorized production composition root that supplies + resolver/client/model/storage; Browser/Screenshot bindings remain unavailable and fail closed. + Queued HTTP/automation starts remain non-dispatching; the existing scheduler claims eligible + rows through durable queued->running CAS before walking them, so repeated ticks do not repeat + a side effect. A manual human run reaches its checkpoint only after that claim, while an + automated human plan is rejected before a row reaches CAS/walker + (test_scenario_queued_dispatch.py, test_scenario_manual_run_only.py). + Fixed queue/cancel callbacks are unit-proven to retain their exact five-second singleton/ + coalescing registration, contain database-edge errors, and preserve terminal side effects on + repeated ticks (test_scenario_scheduler_callbacks.py); this does not prove a live scheduler + process, browser composition, or T022 closure. +- [x] T026 [P] Emit failed/blocked/inconclusive terminal ScenarioRuns as one idempotent 047 queue + producer signal with immutable run/artifact provenance; passed runs emit none. The producer + never opens a case, AgentRun, chat, remediation action, or recurrence classification. + Proof: `test_scenario_terminal_signals.py`. +- [x] T027 [P] Derive `manual_run_only` from a persisted human graph and reject every trusted 046 + automation origin before idempotency or run/gate/notification/queue/dispatch side effects. + The rejected key remains valid for a manual start; HumanCheckpoint is not ActionApprovalGate. + Proof: `test_scenario_manual_run_only.py`, `test_scenario_automation_api.py`. + +> **Verified profile (2026-08-20):** the targeted service suite independently reverified 35 passes. +> The exact API file has a 29-pass result outside this sandbox; here FastAPI TestClient is blocked by +> AnyIO self-pipe `EPERM`, which is a sandbox limitation rather than an application failure. ## Dependencies diff --git a/specs/044-dashboard-scenario-execution/traceability.md b/specs/044-dashboard-scenario-execution/traceability.md index 13d74a6b9..80ec875a9 100644 --- a/specs/044-dashboard-scenario-execution/traceability.md +++ b/specs/044-dashboard-scenario-execution/traceability.md @@ -1,12 +1,19 @@ # Traceability: Scenario Execution Engine (044) -| Story | Requirement | Model | API operationId | Contract | Task | Test | -|-------|-------------|-------|------------------|----------|------|------| -| US1 Start | SCEX-FR-001/008 | ScenarioRun | scenarioRun.start | Execution.Start | T006-T008 | test_runner | -| US2 Dispatch | SCEX-FR-002/006/009 | ScenarioStepRun | scenarioRun.step | Execution.Dispatch | T009-T011 | test_dispatch | -| US3 Human | SCEX-FR-004/010 | ScenarioRun(waiting_human) | scenarioRun.humanDecision | Execution.SuspendForHuman, Execution.Resume | T012-T014 | test_human_resume | -| US4 Lifecycle | SCEX-FR-005 | ScenarioRun(status) | scenarioRun.cancel | Execution.Cancel, Execution.RetryStep | T015-T016 | test_lifecycle | -| US5 Snapshot/API | SCEX-FR-003/007 | ScenarioExecutionResult | scenarioRun.detail, scenarioRun.events | Execution.RunnerPlan | T017-T020 | test_result, test_api | -| Gate/RBAC | SCEX-FR-008 | — | scenarioRun.start | — | T019 | test_rbac | +| Story | Requirement | Model | API operationId | Contract | Task | Test | Actual status / gap | +|-------|-------------|-------|------------------|----------|------|------|---------------------| +| US1 Start | SCEX-FR-001/008 | ScenarioRun | scenarioRun.start | Execution.Start, Execution.Runner.QueuedDispatch | T006-T008, T024 | test_runner, test_scenario_queued_dispatch, test_scenario_scheduler_callbacks, test_scenario_runs_api, test_scenario_automation_api, test_live_execution_binding | `[~]` HTTP/automation start and replay persist queued/pending rows without request-time dispatch; only the scheduler composition's durable queued->running CAS walks its winner. Fixed scheduler callback registration, database-edge containment, and repeat-tick terminal side-effect idempotency are unit-proven. Automated human plans are rejected before they reach CAS/walker; a manual human graph reaches HumanCheckpoint only after that dispatcher claim. Approval-to-real live dispatch remains unproven. | +| US2 Dispatch | SCEX-FR-002/006/009 | ScenarioStepRun, LiveExecutionBinding | scenarioRun.step | Execution.Dispatch, Execution.LiveCompositionRoot | T009-T011, T024 | test_dispatch, test_scenario_executors, test_live_execution_binding | `[~]` Browser/Superset/Screenshot use fail-safe typed adapter boundaries: no explicit adapter success means no PASS; invalid evidence digest/ref remains inconclusive. Lifespan bootstraps `settings.scenario_live_execution_bindings` through the existing `SupersetClient`, exact model and durable storage, so configured Superset dispatch invokes 037 and stores the exact raw-byte digest/ref; mismatched/unavailable providers make no I/O call. Browser safe-checkpoint and Screenshot durable-evidence registration are supported but no provider is deployed, so enabled bindings return stable configured-unavailable codes. | +| US3 Human | SCEX-FR-004/010 | ScenarioRun(waiting_human) | scenarioRun.humanDecision | Execution.SuspendForHuman, Execution.Resume | T012-T014, T025 | test_human_resume, test_scenario_runs_api | `[~]` persisted HumanCheckpoint and infrastructure-resume continuations advance only the missing DAG frontier; completed steps are not re-run. Full live-composition closure remains pending. | +| Manual-only boundary | SCEX-FR-004a | ScenarioRun, HumanCheckpoint | — | Execution.Runner.Start, RunnerPlan.Derive | T027 | test_scenario_manual_run_only, test_scenario_automation_api | `[x]` Trusted scheduled/deploy/release/ETL/API origins reject persisted human revisions before idempotency or any run/gate/notification/queue side effect. Manual origin remains eligible; HumanCheckpoint is not an approval gate. | +| US4 Lifecycle | SCEX-FR-005/006 | ScenarioRun(status, cancel deadline), ScenarioArtifact(active projection) | scenarioRun.cancel, scenarioRun.retry | Execution.Lifecycle.Cancel, Execution.Lifecycle.Timeout, Execution.Lifecycle.Retry, Execution.Runner.ContinueAfterRetry | T014g, T015-T016, T025 | test_scenario_lifecycle, test_scenario_cancel_timeout, test_scenario_retry_closure | `[~]` Strict eligible retry invalidates the persisted target/descendant closure, archives old step attempts, and retains artifact rows only as inactive historical provenance. Cancellation pins a durable bounded drain deadline; deadline finalization expires leases and retires active projections without deleting audit evidence. A timeout during adapter I/O wins over a late PASS and materializes every otherwise-lazy pinned-plan descendant as blocked, so continuation cannot dispatch it; it emits one idempotent inconclusive signal. Full live-I/O composition remains fail-closed/unproven. | +| US5 Snapshot/API | SCEX-FR-003/007 | ScenarioExecutionResult | scenarioRun.detail, scenarioRun.events | Execution.RunnerPlan, Execution.Runner.CrashRecovery | T017-T020, T025 | test_result, test_api, test_scenario_runner_walker, test_scenario_crash_recovery | `[~]` API/projection and real-digest artifact integrity checks exist. Server-driven crash recovery uses only the persisted run/RunnerPlan and expired lease: completed work is not rerun, safe claims get a new attempt with active evidence retired to history, unsafe claims require reconciliation, and browser recovery without a pinned safe checkpoint is non-pass without adapter I/O. Rejected terminal contexts reuse their idempotent signal. Production live-executor provenance remains unproven. | +| Terminal signals | SCEX-FR-011 | InvestigationQueueItem | — | Execution.Runner.TerminalSignal | T026 | test_scenario_terminal_signals | `[x]` Failed/blocked/inconclusive terminal runs emit one idempotent immutable 047 queue input with run/artifact provenance; passed runs emit none. Producer-only ingestion starts no case, AgentRun, chat, remediation action, or recurrence classification. | +| Gate/RBAC | SCEX-FR-008 | ScenarioRun, ActionApprovalGate | scenarioRun.start | Execution.EnvironmentPolicy, Execution.Runner.Start | T019 | test_scenario_runner, test_scenario_runs_api, test_scenario_automation_api, test_scenario_automation_trigger | `[~]` Server ConfigManager classifies every target before persistence: client flags cannot select PROD, unknown targets create no run-side effect, and every trusted source enters the same durable pending_approval gate boundary. HTTP/trigger paths remain persistence-only and dispatcher excludes pending gates. A dedicated real APScheduler scheduled-PROD integration test remains coverage debt; this is not a dispatch bypass. | N/A: Registry (042), Editor (043), Monitor UX (045), Automation (046), Analytics (047). + +**Verification boundary (2026-08-20):** the targeted service profile independently reverified 35 +passes. The exact API file has a 29-pass result outside this sandbox; inside it, FastAPI TestClient +is blocked by AnyIO self-pipe `EPERM`, a sandbox restriction rather than an application failure. +T022, prototype validation, live production composition adapters, and the Axiom index remain open. diff --git a/specs/045-dashboard-run-monitor/checklists/requirements.md b/specs/045-dashboard-run-monitor/checklists/requirements.md index 030505ca9..4155039d1 100644 --- a/specs/045-dashboard-run-monitor/checklists/requirements.md +++ b/specs/045-dashboard-run-monitor/checklists/requirements.md @@ -2,9 +2,12 @@ **Purpose**: Verify RUNMON-FR-001..008 completeness. | **Created**: 2026-08-07 +> **Factual audit 2026-08-20:** `[x]` requires current production evidence, `[~]` means partial +> code exists, `[ ]` means missing integration or proof. + ## Configure & Launch (FR-001) -- [ ] CHK001 Run config includes environment, revision, release, baseline set, parameters, toggles +- [~] CHK001 Run config includes environment, revision, release, baseline set, parameters, toggles - [ ] CHK002 PROD requires 036 approval gate; denial dispatches nothing ## Live Monitor (FR-002/003) diff --git a/specs/045-dashboard-run-monitor/plan.md b/specs/045-dashboard-run-monitor/plan.md index b8a37b7ef..560100a37 100644 --- a/specs/045-dashboard-run-monitor/plan.md +++ b/specs/045-dashboard-run-monitor/plan.md @@ -1,6 +1,9 @@ # Implementation Plan: Scenario Run Monitor & Results UX -**Branch**: `045-dashboard-run-monitor` | **Date**: 2026-08-07 | **Spec**: spec.md | **Status**: Draft +**Branch**: `045-dashboard-run-monitor` | **Date**: 2026-08-07 | **Spec**: spec.md | **Status**: Partially implemented — factual audit pending remediation + +> **Implementation audit, 2026-08-20:** monitor UI exists, but typed launch-contract alignment and +> end-to-end evidence depend on unresolved 044 execution and 047 investigation ingestion. ## Summary diff --git a/specs/045-dashboard-run-monitor/prototype/manifest.md b/specs/045-dashboard-run-monitor/prototype/manifest.md index 8bb806008..895d91d87 100644 --- a/specs/045-dashboard-run-monitor/prototype/manifest.md +++ b/specs/045-dashboard-run-monitor/prototype/manifest.md @@ -2,6 +2,9 @@ @defgroup Prototype Interactive HTML prototype manifest for Scenario Run Monitor & Results. ## Prototype Metadata + +> **Factual audit 2026-08-20:** prototype state coverage is not evidence that the 044 launch/event +> contract or 047 investigation handoff works end-to-end. - **Feature**: 045 Scenario Run Monitor & Results UX - **Source contracts**: ux_reference.md, contracts/modules.md - **Screens represented**: 4 (Config, Live Monitor, Result, History/Compare) + Investigation Queue handoff → 047 @@ -23,7 +26,7 @@ ## Screen ↔ Story Traceability -| Story | Prototype Feature | Acceptance Verified | +| Story | Prototype Feature | Intended acceptance coverage | |-------|-------------------|---------------------| | US1 Launch | config state | env/revision/baseline/toggles | | US2 Live | running timeline + step rows | events-only rendering | diff --git a/specs/045-dashboard-run-monitor/quickstart.md b/specs/045-dashboard-run-monitor/quickstart.md index f7a73fbc2..8a779c6f9 100644 --- a/specs/045-dashboard-run-monitor/quickstart.md +++ b/specs/045-dashboard-run-monitor/quickstart.md @@ -1,5 +1,8 @@ # Quickstart: Scenario Run Monitor & Results UX (045) +> **Factual audit 2026-08-20:** pending verification checklist only; launch-contract and investigation +> handoff scenarios depend on remediation in 044 and 047. + ## Prereqs - 044 execution API live (scenario-runs, events, human/decision) - Frontend deps installed diff --git a/specs/045-dashboard-run-monitor/spec.md b/specs/045-dashboard-run-monitor/spec.md index dafcf3ca7..55ae8e896 100644 --- a/specs/045-dashboard-run-monitor/spec.md +++ b/specs/045-dashboard-run-monitor/spec.md @@ -13,7 +13,7 @@ @SEMANTICS: spec, requirements, feature, ux, scenario, run, monitor, result, history, compare **Feature Branch**: `045-dashboard-run-monitor` -**Created**: 2026-08-07 | **Status**: Draft +**Created**: 2026-08-07 | **Status**: Partially implemented — factual audit pending remediation **Input**: "Provide a live Scenario Run Monitor and Results UX: persistent run configuration panel, live step timeline with per-step status/duration/logs/evidence, human checkpoint actions inside the monitor, final result/provenance, comparison, and an entry to agent-led investigation." ## User Scenarios @@ -150,11 +150,15 @@ - Q: How does it differ from 039 VerificationHistoryList? → A: 039 renders 037 VerificationRun (release pipeline); 045 renders ScenarioRun (user-created scenario execution). Distinct entities, distinct labels. - Q: Reuse of 040? → A: Reuse the LoadRunComparison UX concept for run comparison. -## Implementation Status & MVP Debt (audit 2026-08-07) +## Implementation Status & MVP Debt (factual audit 2026-08-20) -**Facts (code check):** -- 🟡 039 `VerificationHistoryList`/`VerificationStatusBadge`/`StructureDiffPanel` exist but bind to 037 VerificationRun — NOT scenario runs. -- 🔴 No Run Monitor, Run Configuration, ScenarioResultView, or ScenarioRun history/compare UI. -- 🟡 SSE/WS patterns exist in 036/040; reusable for scenario run events. +Monitor model, timeline, checkpoint/result/history/compare components and scenario-run routes exist. + +- `[~]` `RunMonitorModel` launches runs, but release, baseline set and execution toggles are embedded in + `params.launch_config` rather than represented by the 044 typed start contract. +- `[~]` The UI can render typed events, but its runtime truth is limited by incomplete 044 execution. +- `[ ]` Failed/blocked/inconclusive investigation entry is not end-to-end because 047 does not ingest + production signals into the queue. +- `[ ]` Current browser, reconnect and accessibility evidence has not been retained. #endregion ScenarioRunMonitor.Spec diff --git a/specs/045-dashboard-run-monitor/tasks.md b/specs/045-dashboard-run-monitor/tasks.md index 82a90218e..a18b7273a 100644 --- a/specs/045-dashboard-run-monitor/tasks.md +++ b/specs/045-dashboard-run-monitor/tasks.md @@ -5,6 +5,9 @@ ## Format: `- [ ] T### [P] [USx] Description with exact file path` +> **Factual audit 2026-08-20:** `[x]` means code plus relevant evidence; `[~]` means partial +> implementation; `[ ]` means absent integration or unperformed verification. + ## Phase 1 — Setup - [x] T001 Define frontend DTOs (RunConfiguration, ScenarioResultView, RunComparison) in `frontend/src/types/scenario-run.ts` @@ -54,7 +57,7 @@ ## Phase 6c — Run Configuration binding (P0 #12/#17) -- [x] T014f [P] Bind `RunConfigurationPanel.svelte` to 044 start contract (release/baseline_set/toggles); mandatory steps not toggleable +- [~] T014f [P] Bind `RunConfigurationPanel.svelte` to 044 start contract (release/baseline_set/toggles); mandatory steps not toggleable - [x] T014g [P] L2 UX test for mandatory-step toggle protection Test: `frontend/src/lib/components/scenario-run/__tests__/RunConfigurationPanel.test.ts` @@ -62,9 +65,15 @@ - [x] T015 [P] Distinct labeling from 037/040 in all UI (copy + aria) Present in: RunConfigurationPanel, ScenarioResultView, GlobalRunCenter route header -- [x] T016 Run quickstart-equivalent monitor checks, full frontend tests, ATTN static audit and semantic rebuild. - Verified: 3927 frontend tests passed, production build succeeded, and doc-gen semantic navigation rebuilt. -- [x] T017 **Prototype validation**: every declared @UX_STATE is reachable via `prototype/index.html`; Playwright verified 1440x900 and 390x844 without overflow or control overlap. +- [ ] T016 Run quickstart-equivalent monitor checks, full frontend tests, ATTN static audit and semantic rebuild. +- [ ] T017 **Prototype validation**: every declared @UX_STATE is reachable via `prototype/index.html`. + +## Audit Follow-ups (2026-08-20) + +- [ ] T018 Extend the 044 start contract with typed release, baseline set and optional evidence toggles; + remove `params.launch_config` transport and prove mandatory steps remain non-toggleable. +- [ ] T019 Add monitor E2E evidence using real 044 outcomes and 047 queue entries after their runtime + contracts are implemented. ## Dependencies diff --git a/specs/045-dashboard-run-monitor/traceability.md b/specs/045-dashboard-run-monitor/traceability.md index df764e4cb..c8df3b2ee 100644 --- a/specs/045-dashboard-run-monitor/traceability.md +++ b/specs/045-dashboard-run-monitor/traceability.md @@ -1,5 +1,8 @@ # Traceability: Scenario Run Monitor & Results (045) +> **Factual audit 2026-08-20:** rows identify code/test ownership only. The launch contract currently +> transports release/baseline/toggles through `params.launch_config`; complete 044/047 runtime proof is open. + | Story | Requirement | Model | API operationId | Contract | Task | Test | |-------|-------------|-------|------------------|----------|------|------| | US1 Launch | RUNMON-FR-001 | RunConfiguration | scenarioRun.start | RunMonitor.Launch | T003-T005 | RunMonitorModel.test, run.ux.test | diff --git a/specs/046-dashboard-scenario-automation/checklists/requirements.md b/specs/046-dashboard-scenario-automation/checklists/requirements.md index cf327f381..a97f086cf 100644 --- a/specs/046-dashboard-scenario-automation/checklists/requirements.md +++ b/specs/046-dashboard-scenario-automation/checklists/requirements.md @@ -2,6 +2,9 @@ **Purpose**: Verify SCAUTO-FR-001..009 completeness. | **Created**: 2026-08-07 +> **Factual audit 2026-08-20:** `[x]` requires current production evidence, `[~]` means partial +> code exists, `[ ]` means missing integration or proof. Human checkpoints are manual-run-only. + ## Schedule & Trigger (FR-001/002) - [ ] CHK001 Runs triggerable by manual, PREPROD deploy, release-created, ETL, scheduled, API @@ -10,7 +13,7 @@ ## Notifications (FR-003) -- [ ] CHK004 Domain events: completed, failed, blocked, human-action-required, scenario-stale, repeated-flaky-failure +- [ ] CHK004 Domain events: completed, failed, blocked, scenario-stale, repeated-flaky-failure ## Policy (FR-004/005/006/007) @@ -21,7 +24,7 @@ ## Metrics & Boundary (FR-008/009) -- [ ] CHK009 Operational metrics: scheduled counts, success rate, trigger distribution, repeated-failure alerts +- [~] CHK009 Operational metrics: scheduled counts, success rate, trigger distribution, repeated-failure alerts - [ ] CHK010 Zero writes to 037 baseline catalog / verification pipeline ## Success Criteria diff --git a/specs/046-dashboard-scenario-automation/plan.md b/specs/046-dashboard-scenario-automation/plan.md index 472bdba74..257ad01f2 100644 --- a/specs/046-dashboard-scenario-automation/plan.md +++ b/specs/046-dashboard-scenario-automation/plan.md @@ -1,6 +1,9 @@ # Implementation Plan: Scenario Automation & Operations -**Branch**: `046-dashboard-scenario-automation` | **Date**: 2026-08-07 | **Spec**: spec.md | **Status**: Draft +**Branch**: `046-dashboard-scenario-automation` | **Date**: 2026-08-07 | **Spec**: spec.md | **Status**: Not production-complete — factual audit pending remediation + +> **Implementation audit, 2026-08-20:** management and pure helpers exist; event dispatch, lifecycle +> notifications, startup schedule reload and persisted APScheduler semantics remain open. ## Summary diff --git a/specs/046-dashboard-scenario-automation/prototype/manifest.md b/specs/046-dashboard-scenario-automation/prototype/manifest.md index b10dbaaf0..954bb16fb 100644 --- a/specs/046-dashboard-scenario-automation/prototype/manifest.md +++ b/specs/046-dashboard-scenario-automation/prototype/manifest.md @@ -2,6 +2,9 @@ @defgroup Prototype Interactive HTML prototype manifest for Scenario Automation & Operations. ## Prototype Metadata + +> **Factual audit 2026-08-20:** prototype coverage is not evidence of event dispatch, scheduler +> restart/reload, notification persistence, or PROD runtime behavior. - **Feature**: 046 Scenario Automation & Operations - **Source contracts**: ux_reference.md, contracts/modules.md - **Screens represented**: 1 (Automation Config) @@ -21,7 +24,7 @@ ## Screen ↔ Story Traceability -| Story | Prototype Feature | Acceptance Verified | +| Story | Prototype Feature | Intended acceptance coverage | |-------|-------------------|---------------------| | US1 Schedule | trigger checkboxes | deploy/daily/release/ETL | | US2 Notify | notify toggle + metrics flaky alert | notification events | diff --git a/specs/046-dashboard-scenario-automation/quickstart.md b/specs/046-dashboard-scenario-automation/quickstart.md index 872a6812e..ab15c59c2 100644 --- a/specs/046-dashboard-scenario-automation/quickstart.md +++ b/specs/046-dashboard-scenario-automation/quickstart.md @@ -1,5 +1,8 @@ # Quickstart: Scenario Automation & Operations (046) +> **Factual audit 2026-08-20:** pending verification checklist only; scheduler/event/notification +> integration is not yet production-complete. + ## Prereqs - 044 run API, 042 registry, DB migrated (scenario_automation tables) - APScheduler infrastructure (037) available diff --git a/specs/046-dashboard-scenario-automation/spec.md b/specs/046-dashboard-scenario-automation/spec.md index f5097b9b8..71561a201 100644 --- a/specs/046-dashboard-scenario-automation/spec.md +++ b/specs/046-dashboard-scenario-automation/spec.md @@ -13,7 +13,7 @@ @SEMANTICS: spec, requirements, feature, scenario, automation, schedule, trigger, notification, operations **Feature Branch**: `046-dashboard-scenario-automation` -**Created**: 2026-08-07 | **Status**: Draft +**Created**: 2026-08-07 | **Status**: Not production-complete — factual audit pending remediation **Input**: "Provide Scenario Automation & Operations: schedule and trigger scenario runs (manual, on PREPROD deploy, on release created, after ETL, scheduled, API), notifications for completion/failure/blocked/stale, concurrency policies, retention, deduplication, and operational metrics — reusing the 037 trigger framework." ## User Scenarios @@ -131,11 +131,30 @@ - Q: New scheduler? → A: No. Reuse the 037 trigger framework + existing APScheduler. - Q: Where do results go? → A: Scenario runs (044), never into 037 baseline/verification. -## Implementation Status & MVP Debt (audit 2026-08-07) +## Implementation Status & MVP Debt (factual audit 2026-08-20) -**Facts (code check):** -- 🟡 037 has trigger semantics for VerificationRun (`release_create`, `scheduled`) in `verification_scheduler.py` + release hooks. -- 🔴 No scenario schedule/trigger/notification framework exists; 044 runs are manual/API only. -- 🟡 APScheduler infrastructure exists (037), reusable. +Models, CRUD API, management UI and pure schedule/policy/retention/metrics helpers are present, but +the automation workflow is not wired end-to-end. + +- `[~]` `handle_trigger_event()` returns policy-checked candidates and `dispatch_trigger_event()` + passes each real server-owned event origin into 044 `start_run`. A persisted human revision fails + manual-run-only pre-create with no run-side effect. HTTP/scheduler trigger paths persist only + queued or server-gated pending_approval rows; separate 044 queued->running CAS dispatch is the + sole initial adapter authority and excludes pending gates. No long-running event-subscriber proof + is yet available. +- `[ ]` `notify()` only appends to a caller-provided list and is not connected to run, stale or repeated + failure lifecycle events. +- `[~]` Scheduler startup reloads enabled `ScenarioSchedule` rows and registration forwards their + timezone, `misfire_grace_time`, `max_instances`, and missed-execution policy (derived coalesce). + Independent callback tests also prove the fixed 044 queued-dispatch and cancel-drain jobs register + their exact IDs, five-second intervals, singleton/coalescing options, and contain database-edge + failures; repeated callbacks preserve their exact terminal side-effect counts. This is not proof + of cron scheduled-scenario firing, a live scheduler process, or subscriber composition. Existing + scheduler baseline lint debt is separate and remains unclosed. +- `[~]` Server-owned EnvironmentPolicy now sends manual/API/event/scheduled origins through the + same 044 durable `pending_approval` gate and excludes pending rows from dispatcher execution; + client flags and unknown environments cannot bypass it. A dedicated real APScheduler + scheduled-PROD integration test is still required, alongside the remaining retention/runtime + workflow tests, before feature closure. #endregion ScenarioAutomation.Spec diff --git a/specs/046-dashboard-scenario-automation/tasks.md b/specs/046-dashboard-scenario-automation/tasks.md index 69ca53022..cf260f6ea 100644 --- a/specs/046-dashboard-scenario-automation/tasks.md +++ b/specs/046-dashboard-scenario-automation/tasks.md @@ -5,6 +5,9 @@ ## Format: `- [ ] T### [P] [USx] Description with exact file path` +> **Factual audit 2026-08-20:** `[x]` means code plus relevant evidence; `[~]` means partial +> implementation; `[ ]` means absent integration or unperformed verification. + ## Phase 1 — Setup - [x] T001 Create `ScenarioSchedule`, `ScenarioTriggerRule`, `NotificationEvent` models in `backend/src/models/scenario_automation.py` @@ -12,30 +15,30 @@ ## Phase 2 — US1 Schedule & Trigger -- [x] T003 [US1] Write failing schedule/trigger tests (trigger semantics contract coverage: +- [~] T003 [US1] Write failing schedule/trigger tests (trigger semantics contract coverage: `backend/tests/services/dashboard_testing/registry/test_scenario_automation_trigger.py` — filename differs from plan; coverage closes the task: event->run, release_create->run, ETL->run, disabled/mismatch skip, capacity/PROG/dedup gates) -- [x] T004 [US1] Implement `upsert_schedule` schedule semantics in `automation/schedule.py` -- [x] T005 [US1] Implement `handle_trigger_event` policy-bound event mapping in `automation/trigger.py` +- [~] T004 [US1] Implement `upsert_schedule` schedule semantics in `automation/schedule.py` +- [~] T005 [US1] Implement `handle_trigger_event` policy-bound event mapping in `automation/trigger.py` @POST: matched rules start runs via 044; pinned revision+env @TEST_EDGE: event->run; release_create->run; ETL->run ## Phase 3 — US2 Notifications -- [x] T006 [US2] Write notification tests in `backend/tests/services/dashboard_testing/registry/test_scenario_automation.py` -- [x] T007 [US2] Implement `notify` in `automation/notify.py` +- [~] T006 [US2] Write notification tests in `backend/tests/services/dashboard_testing/registry/test_scenario_automation.py` +- [~] T007 [US2] Implement `notify` in `automation/notify.py` ## Phase 4 — US3 Concurrency/Dedup/Retention -- [x] T008 [US3] Write failing policy/retention tests in `backend/tests/services/dashboard_testing/registry/test_scenario_automation_policy.py` -- [x] T009 [US3] Implement `apply_policy` (concurrency/dedup/overlap/PROD gate) in `backend/src/services/dashboard_testing/automation/policy.py` +- [~] T008 [US3] Write failing policy/retention tests in `backend/tests/services/dashboard_testing/registry/test_scenario_automation_policy.py` +- [~] T009 [US3] Implement `apply_policy` (concurrency/dedup/overlap/PROD gate) in `backend/src/services/dashboard_testing/automation/policy.py` @INVARIANT: never exceeds max_concurrent_per_env; dedup window honored -- [x] T010 [US3] Implement layered `run_retention` in `automation/retention.py` +- [~] T010 [US3] Implement layered `run_retention` in `automation/retention.py` ## Phase 5 — US4 Operational Metrics -- [x] T011 [US4] Implement `automation_metrics` in `automation/metrics.py` +- [~] T011 [US4] Implement `automation_metrics` in `automation/metrics.py` (verified by `registry/test_scenario_automation_semantics.py` Metrics section + API test) ## Phase 6 — API + Polish @@ -55,7 +58,7 @@ - [x] T013c [P] Automation Management UI mounted: `AutomationPanel.svelte` (schedules/triggers/ policies list + edit) at `frontend/src/routes/dashboard-testing/automation/` (route `+page.svelte` + route test `automation_page.ux.test.ts`; run center links to it) -- [x] T013d [P] APScheduler semantics config (timezone/DST/misfire_grace_time/coalesce/ +- [~] T013d [P] APScheduler semantics config (timezone/DST/misfire_grace_time/coalesce/ max_instances/missed-policy) in `automation/schedule.py` (verified by `registry/test_scenario_automation_semantics.py` Schedule section) - [x] T013e [P] Layered retention tiers (run metadata/triage/step metrics/artifacts/screenshots/ @@ -65,10 +68,28 @@ ## Phase 7 — Polish -- [x] T014 Run scoped backend tests (33 passed: automation service + API + migration) + ruff - (clean on automation service + new tests); frontend scoped vitest (15 passed) + `npm run build` - (ok). Full-suite run + ATTN_1-4 audit + semantic rebuild remain for the batch close. -- [x] T015 **Prototype validation**: every declared @UX_STATE is reachable via `prototype/index.html`; Playwright verified 1440x900 and 390x844 without overflow or control overlap. +- [ ] T014 Run scoped backend tests, ruff, frontend tests and build; record current command-level evidence. +- [ ] T015 **Prototype validation**: every declared @UX_STATE is reachable via `prototype/index.html`. + +## Audit Follow-ups (2026-08-20) + +- [~] T016 Wire deploy/release/ETL/scheduled events through policy/dedup into 044 `start_run`; verify + pinning, PROD gates and idempotency at the production boundary. The dispatcher now forwards + server-owned event provenance before creation, and human revisions reject manual-run-only + without a side effect. HTTP/scheduler callbacks persist only queued or server-gated + pending_approval rows; the separate 044 queued->running CAS owns initial adapter dispatch + and excludes pending gates. Fixed 044 queue/cancel due callbacks now + have unit proof for registration options, database-edge containment, and repeat-tick exact + side-effect idempotency (`test_scenario_scheduler_callbacks.py`); cron scheduled-scenario + due firing and subscriber integration remain open. Add a dedicated real scheduled-PROD + integration test proving server policy produces `pending_approval` plus one durable gate + before dispatcher CAS; this remaining coverage is not a gate bypass. +- [ ] T017 Emit persisted lifecycle notification events and canonical InvestigationSignals for required + run/stale/repeated-failure transitions; prove no auto-started agent work. +- [~] T018 Load enabled ScenarioSchedule rows at scheduler startup and map timezone, misfire grace, + coalesce and max instances into APScheduler. Startup reload/registration code exists; restart + and cron scheduled-scenario due-job behavior still need independent proof. The fixed 044 + queue/cancel maintenance callbacks are separately unit-proven, not live-process proof. ## Dependencies diff --git a/specs/046-dashboard-scenario-automation/traceability.md b/specs/046-dashboard-scenario-automation/traceability.md index c5ad6f27f..4dbb92b02 100644 --- a/specs/046-dashboard-scenario-automation/traceability.md +++ b/specs/046-dashboard-scenario-automation/traceability.md @@ -1,11 +1,11 @@ # Traceability: Scenario Automation & Operations (046) -| Story | Requirement | Model | API operationId | Contract | Task | Test | -|-------|-------------|-------|------------------|----------|------|------| -| US1 Schedule | SCAUTO-FR-001/002 | ScenarioSchedule, ScenarioTriggerRule | automation.schedule, automation.trigger | Automation.Schedule, Automation.Trigger | T003-T005 | test_trigger | -| US2 Notify | SCAUTO-FR-003 | NotificationEvent | automation.notify | Automation.Notify | T006-T007 | test_notify | -| US3 Policy | SCAUTO-FR-004/005/006/007 | AutomationPolicy | automation.policy | Automation.ApplyPolicy, Automation.Retention | T008-T010 | test_policy | -| US4 Metrics | SCAUTO-FR-008 | — | automation.metrics | Automation.Metrics | T011 | test_metrics | -| Boundary | SCAUTO-FR-009 | — | — | — | T013 | test_policy | +| Story | Requirement | Model | API operationId | Contract | Task | Test | Actual status / gap | +|-------|-------------|-------|------------------|----------|------|------|---------------------| +| US1 Schedule | SCAUTO-FR-001/002/007 | ScenarioSchedule, ScenarioTriggerRule, ActionApprovalGate | automation.schedule, automation.trigger | Automation.Schedule, Automation.Trigger, Execution.EnvironmentPolicy, Execution.Runner.QueuedDispatch | T003-T005, T016, T019 | test_trigger, test_scenario_runner, test_scenario_runs_api, test_scenario_automation_api, test_scenario_manual_run_only, test_scenario_queued_dispatch, test_scenario_scheduler_callbacks | `[~]` Event dispatcher passes its server-owned deploy/release/ETL origin into 044 before creation; persisted human revisions reject manual-run-only before a row reaches the dispatch CAS. ConfigManager alone classifies PROD for every source: client flags cannot downgrade it, unknown targets have no side effect, and a PROD intent persists pending_approval plus its durable gate before dispatcher CAS. Fixed 044 queue/cancel callbacks are unit-proven to register exact IDs/interval/singleton-coalescing options, contain database-edge errors, and preserve exact terminal side effects on repeat ticks. Dedicated real scheduled-PROD callback integration, cron firing, a live scheduler process, and long-running subscribers remain open. | +| US2 Notify | SCAUTO-FR-003 | NotificationEvent | automation.notify | Automation.Notify | T006-T007 | test_notify | `[ ]` helper has no run/staleness lifecycle caller. | +| US3 Policy | SCAUTO-FR-004/005/006/007 | AutomationPolicy | automation.policy | Automation.ApplyPolicy, Automation.Retention | T008-T010 | test_policy | `[~]` pure helpers exist; persisted runtime enforcement is unproven. | +| US4 Metrics | SCAUTO-FR-008 | — | automation.metrics | Automation.Metrics | T011 | test_metrics | `[~]` aggregate helper exists; source events/runs are not fully wired. | +| Boundary | SCAUTO-FR-009 | — | — | — | T013 | test_policy | `[~]` routes/RBAC exist; scheduled PROD runtime proof is open. | N/A: Registry (042), Editor (043), Execution (044), Monitor (045), Analytics (047). diff --git a/specs/047-dashboard-scenario-analytics/checklists/requirements.md b/specs/047-dashboard-scenario-analytics/checklists/requirements.md index c893a4cf4..b85c18cc9 100644 --- a/specs/047-dashboard-scenario-analytics/checklists/requirements.md +++ b/specs/047-dashboard-scenario-analytics/checklists/requirements.md @@ -2,6 +2,9 @@ **Purpose**: Verify SCAN-FR-001..011 completeness. | **Created**: 2026-08-07 +> **Factual audit 2026-08-20:** `[x]` requires current production evidence, `[~]` means partial +> code exists, `[ ]` means missing integration or proof. + ## Queue and Case (FR-001..003/009/011) - [ ] CHK001 Qualifying events create/update a deduplicated queue item and never auto-start agent work @@ -11,14 +14,14 @@ ## Flakiness & Health (FR-004..006) -- [ ] CHK005 Per-step flaky detection requires post-failure pass and two transitions; excluded outcomes are not in denominator +- [~] CHK005 Per-step flaky detection requires post-failure pass and two transitions; excluded outcomes are not in denominator - [ ] CHK006 Contextual health: product/test/infra/overall plus confidence - [ ] CHK007 Health feeds 042 registry badge on threshold cross ## Trends & Recurring (FR-007/008/010) -- [ ] CHK008 Success-rate trend + failure-classification distribution -- [ ] CHK009 Recurring failures grouped (count, first/last occurrence) +- [~] CHK008 Success-rate trend + failure-classification distribution +- [~] CHK009 Recurring failures grouped (count, first/last occurrence) - [ ] CHK010 Matching recurrence after resolved episode opens a new alertable FailureEpisode ## Success Criteria diff --git a/specs/047-dashboard-scenario-analytics/plan.md b/specs/047-dashboard-scenario-analytics/plan.md index 8ede62082..7cab436f2 100644 --- a/specs/047-dashboard-scenario-analytics/plan.md +++ b/specs/047-dashboard-scenario-analytics/plan.md @@ -1,6 +1,9 @@ # Implementation Plan: Investigation Queue & Scenario Analytics -**Branch**: `047-dashboard-scenario-analytics` | **Date**: 2026-08-07 | **Spec**: spec.md | **Status**: Draft +**Branch**: `047-dashboard-scenario-analytics` | **Date**: 2026-08-07 | **Spec**: spec.md | **Status**: Not production-complete — factual audit pending remediation + +> **Implementation audit, 2026-08-20:** analytics primitives/UI exist; canonical signal ingestion, +> immutable case evidence and conformant case closure remain open. ## Summary diff --git a/specs/047-dashboard-scenario-analytics/prototype/manifest.md b/specs/047-dashboard-scenario-analytics/prototype/manifest.md index 2d00366eb..7bef12db3 100644 --- a/specs/047-dashboard-scenario-analytics/prototype/manifest.md +++ b/specs/047-dashboard-scenario-analytics/prototype/manifest.md @@ -2,6 +2,9 @@ @defgroup Prototype Interactive HTML prototype manifest for Failure Triage & Quality Analytics. ## Prototype Metadata + +> **Factual audit 2026-08-20:** prototype coverage is not evidence of canonical signal ingestion, +> immutable case evidence, or conformant disposition closure. - **Feature**: 047 Failure Triage & Quality Analytics - **Source contracts**: ux_reference.md, contracts/modules.md - **Screens represented**: 2 (Investigation Queue and persistent agent-led Case; health summary embedded in Queue) @@ -20,7 +23,7 @@ ## Screen ↔ Story Traceability -| Story | Prototype Feature | Acceptance Verified | +| Story | Prototype Feature | Intended acceptance coverage | |-------|-------------------|---------------------| | US1 Investigation | queue item → explicit case, evidence, agent tools, disposition | case/triage projection persisted + audited | | US2 Flakiness/Health | health card + unstable step | flaky detection + health | diff --git a/specs/047-dashboard-scenario-analytics/quickstart.md b/specs/047-dashboard-scenario-analytics/quickstart.md index 0c730ea5a..740b71f71 100644 --- a/specs/047-dashboard-scenario-analytics/quickstart.md +++ b/specs/047-dashboard-scenario-analytics/quickstart.md @@ -1,5 +1,8 @@ # Quickstart: Investigation Queue & Scenario Analytics (047) +> **Factual audit 2026-08-20:** pending verification checklist only; qualifying production signals do +> not currently enter the queue and case closure is not conformant. + ## Prereqs - 044 run/step results, 042 registry, 036 AgentAction contract, DB migrated (investigation tables) diff --git a/specs/047-dashboard-scenario-analytics/spec.md b/specs/047-dashboard-scenario-analytics/spec.md index a79438a48..d076b3f9b 100644 --- a/specs/047-dashboard-scenario-analytics/spec.md +++ b/specs/047-dashboard-scenario-analytics/spec.md @@ -13,7 +13,7 @@ @SEMANTICS: spec, requirements, feature, scenario, triage, flakiness, analytics, health, trend **Feature Branch**: `047-dashboard-scenario-analytics` -**Created**: 2026-08-07 | **Status**: Draft +**Created**: 2026-08-07 | **Status**: Not production-complete — factual audit pending remediation **Input**: "Provide an Investigation Queue and agentic case workspace for failed/stale/load/automation evidence, backed by deterministic flakiness, health, trends and recurring-failure analytics." ## User Scenarios @@ -110,11 +110,20 @@ - Q: Does a case change graph/result? → A: No. A case may create a separately immutable revision or policy-bound action, but it never rewrites historical run truth. - Q: Does every failure open chat? → A: No. It enters a deduplicated Investigation Queue; the analyst explicitly opens the case. -## Implementation Status & MVP Debt (audit 2026-08-07) +## Implementation Status & MVP Debt (factual audit 2026-08-20) -**Facts (code check):** -- 🔴 No triage/flakiness/health/trend functionality exists for scenario runs. -- 🟡 042 derives a basic health badge; 047 extends it with flakiness + trends. -- 🟡 Reusable analytics/aggregation patterns exist in 040 (consistency detection) and 037 (comparison). +Queue/case, flakiness/health/trend/recurring primitives, API routes and UI routes are present. The +required evidence-led investigation workflow is not production-complete. + +- `[~]` 044 terminal failed/blocked/inconclusive runs now call canonical queue ingestion with an + idempotent immutable run/artifact signal; no case/chat/AgentRun/remediation is started. Staleness, + baseline, load and automation producers plus compatibility-scoped recurring-episode classification + remain outside this bounded signal path. +- `[ ]` InvestigationCase lacks the required immutable evidence snapshot and durable chat/linked- + AgentRun representation. +- `[ ]` `set_disposition()` unconditionally marks a case `resolved`; it does not enforce verification + evidence/reconciliation or accepted-risk rationale required by SCAN-FR-012. +- `[~]` Deterministic aggregation/UI primitives exist but need end-to-end history and signal-ingestion + proof; T013 remains partial. #endregion ScenarioAnalytics.Spec diff --git a/specs/047-dashboard-scenario-analytics/tasks.md b/specs/047-dashboard-scenario-analytics/tasks.md index bbd8b050a..c067538a9 100644 --- a/specs/047-dashboard-scenario-analytics/tasks.md +++ b/specs/047-dashboard-scenario-analytics/tasks.md @@ -5,6 +5,9 @@ ## Format: `- [ ] T### [P] [USx] Description with exact file path` +> **Factual audit 2026-08-20:** `[x]` means code plus relevant evidence; `[~]` means partial +> implementation; `[ ]` means absent integration or unperformed verification. + ## Phase 1 — Setup - [x] T001 Create `InvestigationQueueItem`, `InvestigationCase`, `AgentAction` projection, and migration in `backend/src/models/scenario_investigation.py` @@ -15,16 +18,15 @@ - [x] T003 [US1] Write failing queue/case tests in `backend/tests/services/dashboard_testing/registry/test_scenario_investigation.py` @TEST_EDGE: event queues but does not start agent; duplicate active episode updates count; explicit open is idempotent; disposition CAS->409 -- [x] T004 [US1] Implement queue projection, explicit `open_case`, AgentAction linkage and compact `set_disposition` in `analytics/investigation.py` +- [~] T004 [US1] Implement queue projection, explicit `open_case`, AgentAction linkage and compact `set_disposition` in `analytics/investigation.py` @INVARIANT: case/triage orthogonal; RunResult immutable; never alters graph/baseline -- [x] T005 [US1] Add queue/case/disposition API + object/RBAC tests - Proof: `backend/tests/api/test_scenario_analytics_api.py` + `backend/tests/services/dashboard_testing/test_scenario_analytics_rbac.py` + `backend/tests/fixtures/rbac/scenario_analytics_permissions.json` — 23 passed (pytest, 2026-08-20). +- [~] T005 [US1] Add queue/case/disposition API + object/RBAC tests ## Phase 3 — US2 Flakiness + Health - [x] T006 [US2] Write failing contextual flakiness/health tests in `backend/tests/services/dashboard_testing/registry/test_scenario_analytics.py` @TEST_EDGE: one-way PASS→FAIL -> regression (NOT flaky); post-failure PASS + two transitions -> flaky; infra/cancelled/inconclusive excluded -- [x] T007 [US2] Implement `detect_flakiness` + `derive_health` in `backend/src/services/dashboard_testing/analytics/flakiness.py` +- [~] T007 [US2] Implement `detect_flakiness` + `derive_health` in `backend/src/services/dashboard_testing/analytics/flakiness.py` @POST: deterministic signals; feeds 042 badge on threshold cross - [x] T008 [US2] Add scenario health endpoints in registry and `scenario_analytics.py` @@ -32,19 +34,34 @@ - [x] T009 [US3] Write failing trend/recurring tests in `backend/tests/services/dashboard_testing/registry/test_scenario_trends.py` @TEST_EDGE: classification change does NOT change fingerprint (immutable group identity) -- [x] T010 [US3] Implement deterministic trends/fingerprint summary and recurring episode persistence in analytics services +- [~] T010 [US3] Implement deterministic trends/fingerprint summary and recurring episode persistence in analytics services @POST: compatibility-scoped immutable fingerprint; a matching occurrence after a resolved episode opens a new alertable episode and queue item ## Phase 5 — Frontend + Polish -- [x] T011 [P] Build analytics models/components +- [~] T011 [P] Build analytics models/components InvestigationQueueModel, InvestigationCaseModel, ScenarioAnalyticsModel, HealthCard and queue/case/trends components implemented on disk (`frontend/src/lib/models/*`, `frontend/src/lib/components/scenario-analytics/`); full queue/case/trends UI remains. -- [x] T012 [P] L1/L2 model + UX tests for queue/case/health primitives - Proof: scoped 047 vitest — 26 passed across 9 files (InvestigationModels, ScenarioAnalyticsModel, CaseWorkspace/HealthCard/TrendsChart/RecurringFailuresList, analytics queue/case/scenario route UX tests), 2026-08-20. +- [~] T012 [P] L1/L2 model + UX tests for queue/case/health primitives - [~] T013 Run quickstart, scoped/full backend + frontend tests, ruff; ATTN_1-4; semantic rebuild Partial: scoped frontend vitest (26 passed) + `npm run build` (✔ built) + scoped backend API/RBAC (23 passed) verified. Full backend suite, ruff, quickstart and semantic rebuild not yet run in this session. -- [x] T014 **Prototype validation**: every declared @UX_STATE is reachable via `prototype/index.html`; Playwright verified 1440x900 and 390x844 without overflow or control overlap. - Pending: requires browser proof (chrome-devtools) — no browser session run. +- [ ] T014 **Prototype validation**: every declared @UX_STATE is reachable via `prototype/index.html`. + +## Audit Follow-ups (2026-08-20) + +- [x] T015 Wire canonical 036/run/automation signals to idempotent `queue_signal`; prove qualifying + events create/update queue items without starting agent work. + `ingest_investigation_signal` + `auto_queue_failed_run` + registry staleness emission; 044 + terminal failed/blocked/inconclusive runs additionally emit one exact immutable provenance + signal (passed emits none). This producer does not create a case/AgentRun/action or classify + recurring episodes. +- [~] T016 Persist immutable evidence snapshot, chat/AgentRun linkage and object ACL on InvestigationCase. + Evidence snapshot, linked_run_ids and owner_id ACL persist + (`can_access_case` / GET /cases/{id} 403). Chat/AgentRun workspace remains open. +- [x] T017 Replace unconditional `resolved` disposition with a CAS state machine enforcing verification + evidence/reconciliation for resolved and accepted-risk rationale for accepted; test recurrence reopening. + Recurrence after a resolved episode opens a new episode and queue item + (test_recurrence_after_resolved_episode_opens_new_queue_item). +- [ ] T018 Run independent end-to-end analytics verification and update T013 only with current evidence. ## Dependencies diff --git a/specs/047-dashboard-scenario-analytics/traceability.md b/specs/047-dashboard-scenario-analytics/traceability.md index 3d8ba0514..acba4c6b0 100644 --- a/specs/047-dashboard-scenario-analytics/traceability.md +++ b/specs/047-dashboard-scenario-analytics/traceability.md @@ -1,10 +1,10 @@ # Traceability: Investigation Queue & Scenario Analytics (047) -| Story | Requirement | Model | API operationId | Contract | Task | Test | -|-------|-------------|-------|------------------|----------|------|------| -| US1 Queue/Case | SCAN-FR-001/002/003/009/011 | InvestigationQueueItem, InvestigationCase, TriageRecord | investigations.listQueue/openCase/setDisposition | Analytics.OpenCase, Analytics.Disposition | T003-T005 | test_investigation | -| US2 Flakiness/Health | SCAN-FR-004/005/006 | FlakinessSignal, ScenarioHealth | analytics.health | Analytics.Flakiness, Analytics.Health | T006-T008 | test_flakiness | -| US3 Trends/Recurring | SCAN-FR-007/008/010 | RecurringFailureGroup | analytics.trends, analytics.recurring | Analytics.Trends, Analytics.Recurring | T009-T010 | test_trends | -| Frontend | SCAN-FR-011 | InvestigationQueueModel, InvestigationCaseModel | — | — | T011-T012 | investigation.ux.test | +| Story | Requirement | Model | API operationId | Contract | Task | Test | Actual status / gap | +|-------|-------------|-------|------------------|----------|------|------|---------------------| +| US1 Queue/Case | SCAN-FR-001/002/003/009/011 | InvestigationQueueItem, InvestigationCase, TriageRecord | investigations.listQueue/openCase/setDisposition | Analytics.OpenCase, Analytics.Disposition | T003-T005, T015 | test_investigation, test_scenario_terminal_signals | `[~]` 044 terminal failed/blocked/inconclusive runs now produce one idempotent immutable queue signal with provenance and no automatic case/AgentRun/chat/action. This bounded producer does not classify recurrence; immutable case evidence/chat linkage and SCAN-FR-012 closure remain open. | +| US2 Flakiness/Health | SCAN-FR-004/005/006 | FlakinessSignal, ScenarioHealth | analytics.health | Analytics.Flakiness, Analytics.Health | T006-T008 | test_flakiness | `[~]` primitive exists; real history/signal-ingestion proof is pending. | +| US3 Trends/Recurring | SCAN-FR-007/008/010 | RecurringFailureGroup | analytics.trends, analytics.recurring | Analytics.Trends, Analytics.Recurring | T009-T010 | test_trends | `[~]` aggregate primitives exist; episode/case lifecycle needs E2E proof. | +| Frontend | SCAN-FR-011 | InvestigationQueueModel, InvestigationCaseModel | — | — | T011-T012 | investigation.ux.test | `[~]` UI exists but cannot prove an unconnected queue workflow. | N/A: Registry (042), Editor (043), Execution (044), Monitor (045), Automation (046). diff --git a/specs/WORKSTATE-043-047.md b/specs/WORKSTATE-043-047.md index 2f48693ca..7131f03d7 100644 --- a/specs/WORKSTATE-043-047.md +++ b/specs/WORKSTATE-043-047.md @@ -1,119 +1,89 @@ -# Рабочее состояние: реализация спек 043–047 +# WORKSTATE 043–047 (intermediate 2026-08-20 16:07 +03:00) -> Сохранено автоматически для продолжения работы без потери контекста. -> Дата: 2026-08-20. Область: dashboard scenario specs 042–047 (043/044/045/046/047). +> Статический + targeted-runtime checkpoint. Не является feature-completion. +> Правило: `[x]` только с текущим доказательством; `[~]` частичная реализация; `[ ]` отсутствует. -## 1. Общий статус +## Overall -- **042 Registry** — prerequisite реализован и проверен. -- **043 Editor** — завершён: constrained edits, WorkingDraft, attributed agent proposals, - diff/save/revalidate, scenario:edit RBAC, UI и prototype. -- **044 Execution** — завершён: immutable plan/snapshot/provenance, artifacts, worker lifecycle, - SSE/history/compare/resume, durable PROD approval decision и RUN/RUN_PROD RBAC. -- **045 Run Monitor** — завершён: configuration/PROD gate, live timeline, checkpoint/result, - Global Run Center, waiting-for-me, history/compare и prototype. -- **046 Automation** — завершён: migration + CRUD, scheduler semantics, trigger policy/direct API, - metrics/retention, server-owned PROD classification, management UI и prototype. -- **047 Analytics** — завершён: queue/case/CAS, health/trends/recurring projections, complete - analytics workspace и prototype. +| Spec | Status | Notes | +|---|---|---| +| 042 Registry | `[~]` | CRUD/lifecycle есть. Upstream 037/041 ingestion + queue signal wired. Full quickstart/T024 unverified. | +| 043 Editor | `[~]` | Code/route present. No current E2E/a11y proof. Depends on 042/044. | +| 044 Execution | `[~]` not production-complete | Walker/lifecycle/API работают. Executors fail-safe. Live Playwright/Superset session still missing. | +| 045 Run Monitor | `[~]` | Typed launch contract aligned (release/baseline/toggles). Full route E2E open. | +| 046 Automation | `[~]` | Scheduler reload + trigger dispatch + notifications persist. Live due-job E2E open. | +| 047 Analytics | `[~]` not production-complete | Signal ingest, CAS closure, recurrence, owner ACL. Chat/AgentRun workspace open. | -## 2. Закрывающие доказательства +## Verified facts (this checkpoint) -- Backend scenario scope: **296 passed, 1 skipped**. -- Scoped backend ruff: **clean**. -- Frontend full Vitest: **3927 passed**; production build: **успешен**. -- Alembic: single head `a8b9c0d1e2f3`. -- API wire integration: analytics/automation доступны под `/api/...`; legacy bare paths отсутствуют. -- Playwright: **29 states** на 1440×900 и 390×844 без JS errors, overflow и overlap. -- Semantic rebuild: doc-gen загрузил **9576 contracts / 4730 edges** и создал 11615 nav pages. -- Остаток вне 043–047: общерепозиторный semantic debt (несколько модулей >400 LOC) и - pre-existing frontend compiler warnings. +### 044 Execution +- DAG walker `_advance_run` walks `topological_order`, claims steps, registers artifacts, suspends on human. +- Failed/blocked producers block descendants **including human**; walker does not suspend a terminal failed run. +- API `POST /scenario-runs` creates durable **queued** run (`auto_advance=False`); worker/tests advance separately. +- Launch contract: typed `dashboard_release_id`, `baseline_set`, `execution_toggles` on `StartRunRequest` / `start_run`; frontend no longer hides them in `params.launch_config`. +- Executors (`execution/executors.py`): + - `assertion` → 037 `compare_values` (can fail; never hardcoded PASS). + - `xlsx` → openpyxl parse of real workbook bytes + artifact ref. + - `screenshot` / `report` / `artifact` bind only with real bytes/refs/digest. + - `browser` / `superset_api` without session/query envelope → typed `inconclusive`. +- Cancel: queued steps → `skipped`, run → `cancelled` (via `cancel_requested`/`draining`). +- Timeout: `apply_step_timeout` → step/run `inconclusive` + `STEP_TIMEOUT`. +- Terminal failed/blocked/inconclusive → `auto_queue_failed_run` + persisted notification. -## 3. Реализованные файлы (untracked `??` — мои) +### 047 Analytics +- `ingest_investigation_signal()` idempotent queue projection; no auto agent start. +- Queue/case persist `evidence_snapshot` + `linked_run_ids`. +- Closure CAS: `resolved` needs reconciled verification evidence; `accepted` needs rationale. +- Recurrence after resolved episode opens **new** episode + queue item. +- Object ACL: `InvestigationCase.owner_id`; `can_access_case`; GET `/cases/{id}` 403 for non-owner view. +- Migration `b9c0d1e2f3a4` adds evidence snapshot, linked_run_ids, owner_id. -### Backend — модели -- `backend/src/models/scenario_registry.py` — ScenarioRegistryEntry, ScenarioRevision, - ScenarioWorkingDraft, ScenarioStalenessSignal, ScenarioLifecycleAudit -- `backend/src/models/scenario_run.py` — ScenarioRun, ScenarioStepRun -- `backend/src/models/scenario_checkpoint.py` — HumanCheckpoint -- `backend/src/models/scenario_investigation.py` — InvestigationQueueItem, InvestigationCase, - AgentAction, RecurringFailureEpisode -- `backend/src/models/scenario_automation.py` — ScenarioSchedule, ScenarioTriggerRule, - ScenarioNotificationEvent -- `backend/src/models/scenario_worker.py` — ScenarioStepLease +### 046 Automation +- `load_schedules()` reloads enabled `ScenarioSchedule` and keeps `scenario_*` jobs in desired set. +- `add_scenario_job` maps persisted `misfire_grace_time`, `max_instances`, missed-execution/coalesce. +- `dispatch_trigger_event` resolves `current` revision and calls `start_run`. +- `POST /api/scenario-automation/events/dispatch` is the HTTP boundary. +- `persist_notification` writes lifecycle events (`completed`/`failed`/`blocked`). -### Backend — миграции (alembic/versions, untracked) -- `v1w2x3y4z5a6_add_scenario_registry_tables.py` -- `w2x3y4z5a6_add_scenario_working_drafts.py` -- `x3y4z5a6b7c8_add_scenario_run_tables.py` -- `y4z5a6b7c8d9_add_scenario_investigation_tables.py` +### 042 / 045 +- `ingest_upstream_staleness_event()` for normalized `structure_diff` / `lineage_blast_radius`. +- Staleness apply emits investigation queue signal. +- RunMonitorModel posts typed 044 start fields (Vitest: launch body contains `dashboard_release_id`, not `launch_config`). -### Backend — services -- `backend/src/services/dashboard_testing/registry/` — list, get, serializers, create, - revisions, staleness, lifecycle, clone, health -- `backend/src/services/dashboard_testing/editor/` — ops, load, apply, save, revalidate, __init__ -- `backend/src/services/dashboard_testing/execution/` — runner_plan, runner, dispatch, - executor_registry, result, lifecycle, worker, __init__ -- `backend/src/services/dashboard_testing/automation/` — policy, schedule, trigger, notify, retention -- `backend/src/services/dashboard_testing/analytics/` — flakiness, investigation, trends, recurring +## Targeted evidence (do not treat as suite-close) -### Backend — API -- `backend/src/api/routes/dashboard_testing/scenarios.py` -- `backend/src/api/routes/dashboard_testing/scenario_runs.py` -- `backend/src/api/routes/dashboard_testing/scenario_analytics.py` -- `backend/src/api/routes/dashboard_testing/scenario_automation.py` -- `backend/src/api/routes/dashboard_testing/__init__.py` (изменён — include новых routers) -- `backend/src/api/routes/agent_runs.py` (изменён — Save→Registry bridge) +Recorded in this session with `AUTH_SECRET_KEY` / `SECRET_KEY` set: -### Backend — schemas -- `backend/src/schemas/dashboard_testing/scenario_registry.py` -- `backend/src/schemas/dashboard_testing/__init__.py` (изменён) +- Investigation + staleness + executors: **12 passed** (later expanded). +- Automation services/API: **32 passed**; trigger dispatch pinning: **9 passed**. +- Runner/walker/dispatch after fail-safe executors: **11 passed**. +- Run API after queued-start restore: **30 passed**. +- RunMonitorModel Vitest: **2 passed**. +- Core scheduler regression: **17 passed**. +- Consolidated 042/044/046/047 slice: **81 passed**. +- Recurrence + walker fail-path + executors: **47 passed**, ruff clean on touched modules. +- Lifecycle cancel/timeout + investigation ACL: **20 passed**. +- Analytics API after resolved-disposition contract: **21 passed**. -### Backend — tests -- `backend/tests/services/dashboard_testing/registry/test_scenario_*.py` (много файлов) -- `backend/tests/api/test_scenario_registry.py` -- `backend/tests/api/test_dashboard_testing_openapi.py` (изменён) -- `backend/tests/api/test_agent_runs_coverage.py` (изменён) -- `backend/tests/fixtures/scenario_registry/`, `backend/tests/fixtures/scenario_editor/` -- `backend/tests/models/test_scenario_registry.py`, `backend/tests/test_scenario_registry_migration.py` +Full backend/frontend suites, Playwright, semantic rebuild: **not rerun**. -### Frontend -- `frontend/src/types/scenario-registry.ts`, `frontend/src/types/scenario-run.ts` -- `frontend/src/lib/models/ScenarioRegistryModel.svelte.ts`, `ScenarioEditorModel.svelte.ts`, - `RunMonitorModel.svelte.ts`, `InvestigationQueueModel.svelte.ts`, `InvestigationCaseModel.svelte.ts` -- `frontend/src/lib/components/scenario-editor/` (StepCard, ConstrainedAssertionEditor, VisualDagCanvas) -- `frontend/src/lib/components/scenario-run/` (RunTimeline, HumanCheckpointPanel, ScenarioResultView, - RunHistoryList, RunComparison) -- `frontend/src/lib/components/scenario-analytics/` (HealthCard) -- `frontend/src/routes/dashboard-testing/` (editor route) -- `frontend/src/lib/models/__tests__/` и component `__tests__/` +## Remaining (priority) -### Specs (tasks.md 042–047 отмечены реализованные задачи) -- `specs/037-superset-baseline-engine/contracts/dashboard-testing.openapi.yaml` (расширен) +1. **044 live adapters** — Playwright session replay and catalog-backed Superset query when a real session/query envelope exists. Current `inconclusive` is fail-safe, not SCEX-FR-002/009 complete. +2. **044 T025 remainder** — approval-to-live-dispatch, infra resume continuation of remaining DAG, timeout during in-flight executor I/O. +3. **047 T016 remainder** — durable chat / linked AgentRun workspace on InvestigationCase. +4. **047 T018 / 044 T022** — independent full scoped verification; do not reuse old aggregate counts. +5. **045/043 UI** — mount remaining production panels; browser/a11y E2E. +6. **039 T058** — wire `_trigger_release_verification` into preprod deploy (out of 042–047 core, still listed). -## 4. Последние проверки (фактические прогоны) +## Key files (this checkpoint) -- Backend consolidated scenario suite: **296 passed, 1 skipped**. -- Backend scoped ruff: **clean**; `git diff --check`: clean. -- Frontend full suite: **3927 passed**; `npm run build`: **успешен**. -- Prototype Playwright: **all 29 states passed** на desktop/mobile. -- Semantic doc-gen navigation rebuild: **9576 contracts, 4730 edges, 11615 pages**. - -## 5. Замечания по окружению - -- Полный `import src.app` требует `DATABASE_URL` (env-only blocker, не code failure). -- В репозитории много pre-existing `M`-изменений (похоже на CRLF/line-ending) — НЕ мои, не трогать. -- Полный backend ruff ранее: 7904 repository-wide pre-existing findings; мой код их не добавляет. - -## 6. Следующие шаги - -Спеки 043–047 закрыты. Дальнейшие действия относятся к общерепозиторному refactoring debt, -review/commit/release workflow и не входят в их функциональный scope. - -## 7. Анти-повтор - -- НЕ возвращаться к полному repository-wide ruff (7904 findings) — вне scope 043–047. -- НЕ трогать pre-existing `M`-изменения (line-ending/CRLF) в чужих файлах. -- НЕ помечать невыполненные интеграционные контракты (SSE, APScheduler persistence, - полный UI) как completed без фактической реализации и тестов. -- После каждого нового фрагмента: scoped pytest + ruff, затем — если менялся frontend — vitest + build. +- `backend/src/services/dashboard_testing/execution/{runner,executors,lifecycle,dispatch}.py` +- `backend/src/services/dashboard_testing/analytics/{investigation,recurring}.py` +- `backend/src/services/dashboard_testing/automation/{trigger,notify}.py` +- `backend/src/services/dashboard_testing/registry/staleness.py` +- `backend/src/core/scheduler.py` +- `backend/src/api/routes/dashboard_testing/{scenario_runs,scenario_analytics,scenario_automation}.py` +- `backend/src/models/scenario_investigation.py` +- `backend/alembic/versions/b9c0d1e2f3a4_add_investigation_evidence_snapshot.py` +- `frontend/src/lib/models/RunMonitorModel.svelte.ts` diff --git a/specs/prototype-ui.css b/specs/prototype-ui.css index 0d42a2b30..6c927b8f6 100644 --- a/specs/prototype-ui.css +++ b/specs/prototype-ui.css @@ -64,6 +64,9 @@ body { font: inherit; text-decoration: none; cursor: pointer; + min-height: 44px; + min-inline-size: 44px; + transition: background-color 160ms ease, color 160ms ease, border-color 160ms ease; } .nav a:hover, .nav a.active { @@ -338,6 +341,16 @@ input[type="search"] { white-space: nowrap; border: 0; } +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} @media (max-width: 850px) { .shell { padding: 16px;